diff --git a/apps/api/package.json b/apps/api/package.json index c49d4d1..891a384 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,7 +12,7 @@ "prisma:seed": "tsx prisma/seed.ts", "lint": "echo \"api lint placeholder\"", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p test/tsconfig.json", - "test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts" + "test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts" }, "dependencies": { "@mento/shared": "*", diff --git a/apps/api/prisma/migrations/20261011120000_automation_branches/migration.sql b/apps/api/prisma/migrations/20261011120000_automation_branches/migration.sql new file mode 100644 index 0000000..303465f --- /dev/null +++ b/apps/api/prisma/migrations/20261011120000_automation_branches/migration.sql @@ -0,0 +1,25 @@ +-- AlterTable +ALTER TABLE "Activity" ADD COLUMN "leadId" TEXT; + +-- CreateTable +CREATE TABLE "AutomationCursor" ( + "id" TEXT NOT NULL, + "ruleId" TEXT NOT NULL, + "stepId" TEXT NOT NULL, + "counter" INTEGER NOT NULL DEFAULT 0, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AutomationCursor_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Activity_leadId_idx" ON "Activity"("leadId"); + +-- CreateIndex +CREATE UNIQUE INDEX "AutomationCursor_ruleId_stepId_key" ON "AutomationCursor"("ruleId", "stepId"); + +-- AddForeignKey +ALTER TABLE "Activity" ADD CONSTRAINT "Activity_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AutomationCursor" ADD CONSTRAINT "AutomationCursor_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "AutomationRule"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 027727c..9227192 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -725,11 +725,15 @@ model Activity { fields Json? /// کاربران شرکت‌کننده؛ فعالیت در لیست آن‌ها هم دیده می‌شود participantIds String[] @default([]) + /// لید مرتبط (مثلاً فعالیت ساخته‌شده از ارسال فرم) + leadId String? + lead Lead? @relation(fields: [leadId], references: [id], onDelete: SetNull) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([stageId]) @@index([typeDefId]) + @@index([leadId]) @@index([organizationId, status, dueAt]) @@index([organizationId, assigneeId]) @@index([organizationId, createdById]) @@ -1443,12 +1447,25 @@ model AutomationRule { action AutomationAction /// عنوان فعالیت/اعلان، assigneeId، notifyUserId، points، activityType config Json @default("{}") + cursors AutomationCursor[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([organizationId, isActive, trigger]) } +/// شمارندهٔ تقسیم نوبتی برای هر گام عمل در یک قانون اتوماسیون +model AutomationCursor { + id String @id @default(cuid()) + ruleId String + rule AutomationRule @relation(fields: [ruleId], references: [id], onDelete: Cascade) + stepId String + counter Int @default(0) + updatedAt DateTime @updatedAt + + @@unique([ruleId, stepId]) +} + enum MessageAutomationTrigger { person_created lead_submitted @@ -2400,6 +2417,7 @@ model Lead { trackingLinks TrackingLink[] trackingEvents TrackingEvent[] routineTasks RoutineTask[] + activities Activity[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/apps/api/src/activities/activities.controller.ts b/apps/api/src/activities/activities.controller.ts index 268c70b..23d8302 100644 --- a/apps/api/src/activities/activities.controller.ts +++ b/apps/api/src/activities/activities.controller.ts @@ -173,6 +173,7 @@ export class ActivitiesController { personId: query.personId, companyId: query.companyId, dealId: query.dealId, + leadId: query.leadId, }, { from: query.from, diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index ae3211a..ca4e118 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -69,6 +69,7 @@ type ActivityRow = { person: { id: string; firstName: string; lastName: string } | null; company: { id: string; name: string } | null; deal: { id: string; title: string } | null; + lead?: { id: string; title: string } | null; assignee: { id: string; fullName: string } | null; assignees: Array<{ id: string; fullName: string }>; createdBy: { id: string; fullName: string } | null; @@ -84,6 +85,7 @@ const include = { person: { select: { id: true, firstName: true, lastName: true } }, company: { select: { id: true, name: true } }, deal: { select: { id: true, title: true } }, + lead: { select: { id: true, title: true } }, assignee: { select: { id: true, fullName: true } }, assignees: { select: { id: true, fullName: true }, orderBy: { fullName: 'asc' as const } }, createdBy: { select: { id: true, fullName: true } }, @@ -190,6 +192,7 @@ export class ActivitiesService { personId?: string; companyId?: string; dealId?: string; + leadId?: string; }, range?: { from?: string; @@ -602,6 +605,7 @@ export class ActivitiesService { personId?: string; companyId?: string; dealId?: string; + leadId?: string; }, range?: { from?: string; @@ -643,6 +647,7 @@ export class ActivitiesService { ...(entity?.personId ? [{ personId: entity.personId }] : []), ...(entity?.companyId ? [{ companyId: entity.companyId }] : []), ...(entity?.dealId ? [{ dealId: entity.dealId }] : []), + ...(entity?.leadId ? [{ leadId: entity.leadId }] : []), ]; const search = buildSearchWhere(q, (t) => [ { title: ci(t) }, @@ -872,6 +877,7 @@ export class ActivitiesService { : null, company: row.company, deal: row.deal, + lead: row.lead ?? null, assignee: row.assignee, assignees: (() => { const list = [...(row.assignees ?? [])]; diff --git a/apps/api/src/activities/dto.ts b/apps/api/src/activities/dto.ts index 6ec35bc..3228b21 100644 --- a/apps/api/src/activities/dto.ts +++ b/apps/api/src/activities/dto.ts @@ -91,6 +91,10 @@ export class ListActivitiesQueryDto { @IsString() dealId?: string; + @IsOptional() + @IsString() + leadId?: string; + /** Inclusive start (ISO). When set with/without filter, restricts dueAt. */ @IsOptional() @IsDateString() diff --git a/apps/api/src/automations/automation-engine.util.ts b/apps/api/src/automations/automation-engine.util.ts new file mode 100644 index 0000000..2f75364 --- /dev/null +++ b/apps/api/src/automations/automation-engine.util.ts @@ -0,0 +1,51 @@ +export type FormFieldLabel = { key: string; label: string }; + +export type TemplateVars = { + title: string; + mobile?: string | null; + formName?: string | null; + formValues?: Record | null; +}; + +/** counter یک‌واحد افزایش‌یافته از AutomationCursor است (از ۱ شروع می‌شود) */ +export function pickRoundRobin(memberIds: string[], counter: number): string | null { + const ids = [...new Set(memberIds.filter(Boolean))].sort(); + if (!ids.length) return null; + const n = Math.max(1, Math.trunc(counter)); + return ids[(n - 1) % ids.length] ?? null; +} + +/** جایگزینی {{title}}، {{mobile}}، {{form}} و {{field.}} */ +export function renderAutomationTemplate(text: string, vars: TemplateVars): string { + return text.replace(/\{\{\s*([\w.\u0600-\u06FF-]+)\s*\}\}/g, (_m, name: string) => { + if (name === 'title') return vars.title ?? ''; + if (name === 'mobile') return vars.mobile ?? ''; + if (name === 'form') return vars.formName ?? ''; + if (name.startsWith('field.')) { + const v = vars.formValues?.[name.slice(6)]; + return v == null ? '' : String(v); + } + return ''; + }); +} + +export function formatFormAnswers( + fields: FormFieldLabel[] | null | undefined, + values: Record | null | undefined, +): string { + if (!values) return ''; + const lines: string[] = []; + const seen = new Set(); + for (const f of fields ?? []) { + seen.add(f.key); + const raw = values[f.key]; + const v = raw == null ? '' : String(raw).trim(); + if (v) lines.push(`${f.label}: ${v}`); + } + for (const [key, raw] of Object.entries(values)) { + if (seen.has(key)) continue; + const v = raw == null ? '' : String(raw).trim(); + if (v) lines.push(`${key}: ${v}`); + } + return lines.join('\n'); +} diff --git a/apps/api/src/automations/automations.service.ts b/apps/api/src/automations/automations.service.ts index 912e5d1..1fc3209 100644 --- a/apps/api/src/automations/automations.service.ts +++ b/apps/api/src/automations/automations.service.ts @@ -20,30 +20,42 @@ import { MAX_AUTOMATION_STEPS, automationStepToConfig, firstAutomationAction, - isAutomationCondition, + matchFormFieldCondition, normalizeAutomationSteps, packAutomationConfig, + planAutomationActions, } from '@mento/shared'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { NotificationsService } from '../notifications/notifications.service'; import { LoyaltyService } from '../loyalty/loyalty.service'; import { SmsService } from '../common/sms.service'; +import { + formatFormAnswers, + pickRoundRobin, + renderAutomationTemplate, + type FormFieldLabel, +} from './automation-engine.util'; export type AutomationContext = { organizationId: string; - entityType: 'deal' | 'ticket' | 'person' | 'invoice' | 'activity'; + entityType: 'deal' | 'ticket' | 'person' | 'invoice' | 'activity' | 'lead'; entityId: string; title: string; personId?: string | null; companyId?: string | null; dealId?: string | null; + leadId?: string | null; ownerId?: string | null; assigneeId?: string | null; - /** مثلاً id کمپین SMS برای فیلتر قانون */ + /** مثلاً id کمپین SMS یا id فرم سرنخ برای فیلتر قانون */ sourceId?: string | null; sourceType?: string | null; clickCount?: number | null; + mobile?: string | null; + formName?: string | null; + formValues?: Record | null; + formFields?: FormFieldLabel[] | null; }; type AutomationFacts = { @@ -51,8 +63,11 @@ type AutomationFacts = { hasPerson: boolean; hasCompany: boolean; ticketPriority: string | null; + formValues: Record; }; +type StepRef = { ruleId: string; stepId: string }; + @Injectable() export class AutomationsService { private readonly logger = new Logger(AutomationsService.name); @@ -79,6 +94,7 @@ export class AutomationsService { const steps = resolveSteps(dto.action, dto.config); const action = firstAutomationAction(steps); if (!action) throw new BadRequestException('حداقل یک عمل لازم است'); + await this.assertStepRefs(organizationId, steps); const row = await this.prisma.automationRule.create({ data: { organizationId, @@ -86,7 +102,7 @@ export class AutomationsService { trigger: dto.trigger, action, isActive: dto.isActive ?? true, - config: packAutomationConfig(steps) as Prisma.InputJsonValue, + config: packAutomationConfig(steps, chainExtra(dto.config)) as Prisma.InputJsonValue, }, }); return this.toDto(row); @@ -114,6 +130,7 @@ export class AutomationsService { if (steps && !nextAction) { throw new BadRequestException('حداقل یک عمل لازم است'); } + if (steps) await this.assertStepRefs(organizationId, steps); const row = await this.prisma.automationRule.update({ where: { id }, @@ -124,7 +141,10 @@ export class AutomationsService { ...(steps && nextAction ? { action: nextAction, - config: packAutomationConfig(steps) as Prisma.InputJsonValue, + config: packAutomationConfig( + steps, + chainExtra(dto.config ?? asConfig(existing.config)), + ) as Prisma.InputJsonValue, } : {}), }, @@ -151,7 +171,11 @@ export class AutomationsService { trigger, }, }); - const facts = await this.loadFacts(ctx); + if (!rules.length) return; + const facts: AutomationFacts = { + ...(await this.loadFacts(ctx)), + formValues: ctx.formValues ?? {}, + }; for (const rule of rules) { try { const config = asConfig(rule.config); @@ -167,13 +191,20 @@ export class AutomationsService { rule.action as AutomationAction, config, ); - for (const step of steps) { - if (isAutomationCondition(step)) { - if (!matchCondition(step, facts)) break; - continue; + const actions = planAutomationActions(steps, config.chainMode, (step) => + matchCondition(step, facts), + ); + for (const step of actions) { + try { + await this.execute(step.action!, automationStepToConfig(step), ctx, { + ruleId: rule.id, + stepId: step.id, + }); + } catch (e) { + this.logger.warn( + `Automation ${rule.id} step ${step.id} failed: ${(e as Error)?.message ?? e}`, + ); } - if (!step.action) continue; - await this.execute(step.action, automationStepToConfig(step), ctx); } } catch { // قوانین نباید شکست عملیات اصلی را ایجاد کنند @@ -181,7 +212,9 @@ export class AutomationsService { } } - private async loadFacts(ctx: AutomationContext): Promise { + private async loadFacts( + ctx: AutomationContext, + ): Promise> { if (ctx.entityType === 'deal') { const deal = await this.prisma.deal.findFirst({ where: { id: ctx.entityId, organizationId: ctx.organizationId }, @@ -258,42 +291,26 @@ export class AutomationsService { private async execute( action: AutomationAction, - config: AutomationRuleConfig, + rawConfig: AutomationRuleConfig, ctx: AutomationContext, + ref: StepRef, ) { + const vars = { + title: ctx.title, + mobile: ctx.mobile, + formName: ctx.formName, + formValues: ctx.formValues, + }; + const config: AutomationRuleConfig = { + ...rawConfig, + title: rawConfig.title ? renderAutomationTemplate(rawConfig.title, vars) : rawConfig.title, + body: rawConfig.body ? renderAutomationTemplate(rawConfig.body, vars) : rawConfig.body, + }; const title = (config.title?.trim() || defaultTitle(action, ctx.title)).slice(0, 200); if (action === 'create_activity') { - const assigneeId = - config.assigneeId?.trim() || - ctx.assigneeId || - ctx.ownerId || - null; - await this.prisma.activity.create({ - data: { - organizationId: ctx.organizationId, - type: (config.activityType as never) || 'task', - title, - description: config.body?.trim() || null, - personId: ctx.personId ?? null, - companyId: ctx.companyId ?? null, - dealId: ctx.dealId ?? (ctx.entityType === 'deal' ? ctx.entityId : null), - assigneeId, - }, - }); - if (assigneeId) { - await this.notifications.create({ - organizationId: ctx.organizationId, - userId: assigneeId, - type: 'automation', - title: 'فعالیت خودکار', - body: title, - link: entityLink(ctx), - entityType: ctx.entityType, - entityId: ctx.entityId, - }); - } + await this.executeCreateActivity(title, config, ctx, ref); return; } @@ -313,6 +330,163 @@ export class AutomationsService { } } + private async executeCreateActivity( + title: string, + config: AutomationRuleConfig, + ctx: AutomationContext, + ref: StepRef, + ) { + const assigneeId = await this.resolveAssignee(config, ctx, ref); + + let type = (config.activityType as 'call' | 'meeting' | 'task' | 'note') || 'task'; + let typeDefId: string | null = null; + if (config.activityTypeDefId) { + const def = await this.prisma.activityTypeDef.findFirst({ + where: { id: config.activityTypeDefId, organizationId: ctx.organizationId }, + select: { id: true, baseType: true }, + }); + if (def) { + typeDefId = def.id; + type = def.baseType as typeof type; + } + } + + const offset = config.dueOffsetMinutes; + const dueAt = + typeof offset === 'number' && Number.isFinite(offset) + ? new Date(Date.now() + Math.max(0, offset) * 60_000) + : null; + + const description = [ + config.body?.trim(), + leadSummary(ctx), + ] + .filter(Boolean) + .join('\n\n') + .slice(0, 5000); + + const activity = await this.prisma.activity.create({ + data: { + organizationId: ctx.organizationId, + type, + typeDefId, + title, + description: description || null, + dueAt, + personId: ctx.personId ?? null, + companyId: ctx.companyId ?? null, + dealId: ctx.dealId ?? (ctx.entityType === 'deal' ? ctx.entityId : null), + leadId: ctx.leadId ?? (ctx.entityType === 'lead' ? ctx.entityId : null), + assigneeId, + assignees: assigneeId ? { connect: [{ id: assigneeId }] } : undefined, + tags: ['automation'], + }, + select: { id: true }, + }); + if (assigneeId) { + await this.notifications.create({ + organizationId: ctx.organizationId, + userId: assigneeId, + type: 'automation', + title: 'فعالیت خودکار', + body: title, + link: `/activities/${activity.id}`, + entityType: 'activity', + entityId: activity.id, + }); + } + } + + private async resolveAssignee( + config: AutomationRuleConfig, + ctx: AutomationContext, + ref: StepRef, + ): Promise { + const fallback = ctx.assigneeId || ctx.ownerId || null; + const mode = config.assignMode ?? (config.assigneeId ? 'user' : 'owner'); + + if (mode === 'user') { + const id = config.assigneeId?.trim(); + if (id && (await this.isMember(ctx.organizationId, id))) return id; + return fallback; + } + + if (mode === 'team_round_robin' || mode === 'users_round_robin') { + let candidates: string[] = []; + if (mode === 'team_round_robin' && config.assigneeTeamId) { + const members = await this.prisma.teamMember.findMany({ + where: { + teamId: config.assigneeTeamId, + team: { organizationId: ctx.organizationId }, + }, + select: { userId: true }, + }); + candidates = members.map((m) => m.userId); + } else if (mode === 'users_round_robin') { + candidates = config.assigneeUserIds ?? []; + } + const valid = await this.filterMembers(ctx.organizationId, candidates); + if (!valid.length) return fallback; + const cursor = await this.prisma.automationCursor.upsert({ + where: { ruleId_stepId: { ruleId: ref.ruleId, stepId: ref.stepId } }, + create: { ruleId: ref.ruleId, stepId: ref.stepId, counter: 1 }, + update: { counter: { increment: 1 } }, + select: { counter: true }, + }); + return pickRoundRobin(valid, cursor.counter) ?? fallback; + } + + return fallback; + } + + private async isMember(organizationId: string, userId: string) { + return (await this.filterMembers(organizationId, [userId])).length > 0; + } + + private async filterMembers(organizationId: string, userIds: string[]) { + const ids = [...new Set(userIds.filter(Boolean))]; + if (!ids.length) return []; + const rows = await this.prisma.user.findMany({ + where: { + id: { in: ids }, + memberships: { some: { organizationId } }, + }, + select: { id: true }, + }); + return rows.map((r) => r.id); + } + + private async assertStepRefs(organizationId: string, steps: AutomationStep[]) { + for (const step of steps) { + if (step.kind === 'condition' || step.action !== 'create_activity') continue; + if (step.assignMode === 'team_round_robin') { + if (!step.assigneeTeamId) { + throw new BadRequestException('برای تقسیم نوبتی، تیم را انتخاب کنید'); + } + const team = await this.prisma.team.findFirst({ + where: { id: step.assigneeTeamId, organizationId }, + select: { id: true }, + }); + if (!team) throw new BadRequestException('تیم انتخاب‌شده معتبر نیست'); + } + if (step.assignMode === 'users_round_robin') { + const ids = step.assigneeUserIds ?? []; + if (!ids.length) { + throw new BadRequestException('برای تقسیم نوبتی، حداقل یک کاربر انتخاب کنید'); + } + const valid = await this.filterMembers(organizationId, ids); + if (valid.length !== new Set(ids).size) { + throw new BadRequestException('یکی از کاربران انتخاب‌شده معتبر نیست'); + } + } + if (step.assignMode === 'user' && step.assigneeId) { + if (!(await this.isMember(organizationId, step.assigneeId))) { + throw new BadRequestException('مسئول انتخاب‌شده معتبر نیست'); + } + } + } + } + private async executeNotify( title: string, config: AutomationRuleConfig, @@ -464,9 +638,37 @@ function matchCondition(step: AutomationStep, facts: AutomationFacts): boolean { return current === value; } + if (field === 'form_field') { + const key = step.conditionKey?.trim(); + if (!key) return false; + return matchFormFieldCondition(op, facts.formValues[key], step.conditionValue); + } + return false; } +function chainExtra( + config?: AutomationRuleConfig | null, +): Pick { + return { + sourceId: config?.sourceId ?? null, + chainMode: config?.chainMode === 'branches' ? 'branches' : config?.chainMode === 'stop' ? 'stop' : undefined, + }; +} + +function leadSummary(ctx: AutomationContext): string { + if (ctx.sourceType !== 'lead_form') return ''; + const head = [ + ctx.formName ? `فرم: ${ctx.formName}` : '', + ctx.title ? `نام: ${ctx.title}` : '', + ctx.mobile ? `موبایل: ${ctx.mobile}` : '', + ].filter(Boolean); + const answers = formatFormAnswers(ctx.formFields, ctx.formValues); + return [head.join('\n'), answers ? `پاسخ‌های فرم:\n${answers}` : ''] + .filter(Boolean) + .join('\n\n'); +} + function defaultTitle(action: AutomationAction, entityTitle: string) { if (action === 'create_activity') return `پیگیری خودکار: ${entityTitle}`; if (action === 'create_notification') return `اتوماسیون: ${entityTitle}`; @@ -479,5 +681,6 @@ function entityLink(ctx: AutomationContext) { if (ctx.entityType === 'invoice') return `/invoices/${ctx.entityId}`; if (ctx.entityType === 'person') return `/contacts/people/${ctx.entityId}`; if (ctx.entityType === 'activity') return `/activities/${ctx.entityId}`; + if (ctx.entityType === 'lead') return `/leads/${ctx.entityId}`; return '/'; } diff --git a/apps/api/src/lead-forms/lead-forms.service.ts b/apps/api/src/lead-forms/lead-forms.service.ts index 16dc598..eea04d9 100644 --- a/apps/api/src/lead-forms/lead-forms.service.ts +++ b/apps/api/src/lead-forms/lead-forms.service.ts @@ -385,7 +385,19 @@ export class LeadFormsService { personId, ownerId: ownerId ?? actorId, }; - await this.automations.runTrigger('lead_submitted', leadCtx); + await this.automations.runTrigger('lead_submitted', { + ...leadCtx, + entityType: personId ? 'person' : leadId ? 'lead' : 'person', + sourceId: form.id, + sourceType: 'lead_form', + leadId, + mobile: mapped.mobile ?? mapped.phone ?? null, + formName: form.name, + formValues: values, + formFields: form.fields + .filter((f) => f.fieldType !== 'heading') + .map((f) => ({ key: f.key, label: f.label })), + }); await this.messageAutomations.onEvent('lead_submitted', leadCtx); return { diff --git a/apps/api/test/automation-branches.test.ts b/apps/api/test/automation-branches.test.ts new file mode 100644 index 0000000..fc90d35 --- /dev/null +++ b/apps/api/test/automation-branches.test.ts @@ -0,0 +1,115 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + matchFormFieldCondition, + planAutomationActions, + splitFormAnswer, + type AutomationStep, +} from '@mento/shared'; +import { + formatFormAnswers, + pickRoundRobin, + renderAutomationTemplate, +} from '../src/automations/automation-engine.util'; + +const KEY = 'coop_type'; +const answers = (raw: string): Record => ({ [KEY]: raw }); + +function cond(id: string, value: string): AutomationStep { + return { + id, + kind: 'condition', + conditionField: 'form_field', + conditionKey: KEY, + conditionOp: 'contains', + conditionValue: value, + }; +} + +function act(id: string): AutomationStep { + return { id, kind: 'action', action: 'create_activity' }; +} + +const BSJ: AutomationStep[] = [ + cond('c1', 'بنکداری'), + act('a1'), + cond('c2', 'آنلاین شاپ'), + act('a2'), + cond('c3', 'ویترین نقره'), + act('a3'), +]; + +function run(steps: AutomationStep[], raw: string, mode: 'branches' | 'stop' = 'branches') { + const values = answers(raw); + return planAutomationActions(steps, mode, (s) => + matchFormFieldCondition(s.conditionOp, values[s.conditionKey ?? ''], s.conditionValue), + ).map((s) => s.id); +} + +describe('اتوماسیون شاخه‌ای فرم', () => { + it('چندانتخابی را می‌شکند', () => { + assert.deepEqual(splitFormAnswer('بنکداری, آنلاین شاپ'), ['بنکداری', 'آنلاین شاپ']); + assert.deepEqual(splitFormAnswer('الف، ب'), ['الف', 'ب']); + assert.deepEqual(splitFormAnswer(''), []); + assert.deepEqual(splitFormAnswer(undefined), []); + }); + + it('عملگرهای فیلد فرم', () => { + assert.equal(matchFormFieldCondition('contains', 'بنکداری, ویترین نقره', 'ویترین نقره'), true); + assert.equal(matchFormFieldCondition('contains', 'بنکداری', 'آنلاین شاپ'), false); + assert.equal(matchFormFieldCondition('not_contains', 'بنکداری', 'آنلاین شاپ'), true); + assert.equal(matchFormFieldCondition('eq', 'بنکداری', 'بنکداری'), true); + assert.equal(matchFormFieldCondition('eq', 'بنکداری, آنلاین شاپ', 'بنکداری'), false); + assert.equal(matchFormFieldCondition('is_set', 'x', ''), true); + assert.equal(matchFormFieldCondition('is_empty', '', ''), true); + }); + + it('هر گزینه فقط شاخهٔ خودش را اجرا می‌کند', () => { + assert.deepEqual(run(BSJ, 'بنکداری'), ['a1']); + assert.deepEqual(run(BSJ, 'آنلاین شاپ'), ['a2']); + assert.deepEqual(run(BSJ, 'ویترین نقره'), ['a3']); + }); + + it('چند تیک = چند فعالیت', () => { + assert.deepEqual(run(BSJ, 'بنکداری, ویترین نقره'), ['a1', 'a3']); + assert.deepEqual(run(BSJ, 'بنکداری, آنلاین شاپ, ویترین نقره'), ['a1', 'a2', 'a3']); + assert.deepEqual(run(BSJ, ''), []); + }); + + it('شرط‌های پشت‌سرهم با «و» ترکیب می‌شوند', () => { + const steps = [cond('c1', 'بنکداری'), cond('c2', 'آنلاین شاپ'), act('a1'), act('a2')]; + assert.deepEqual(run(steps, 'بنکداری'), []); + assert.deepEqual(run(steps, 'بنکداری, آنلاین شاپ'), ['a1', 'a2']); + }); + + it('حالت stop رفتار قدیمی را حفظ می‌کند', () => { + assert.deepEqual(run(BSJ, 'آنلاین شاپ', 'stop'), []); + assert.deepEqual(run(BSJ, 'بنکداری, آنلاین شاپ, ویترین نقره', 'stop'), ['a1', 'a2', 'a3']); + assert.deepEqual( + planAutomationActions([act('a0')], undefined, () => false).map((s) => s.id), + ['a0'], + ); + }); + + it('تقسیم نوبتی مساوی و پایدار', () => { + const team = ['u3', 'u1', 'u2']; + const picks = [1, 2, 3, 4, 5, 6].map((n) => pickRoundRobin(team, n)); + assert.deepEqual(picks, ['u1', 'u2', 'u3', 'u1', 'u2', 'u3']); + assert.equal(pickRoundRobin([], 1), null); + assert.equal(pickRoundRobin(['u1', 'u1'], 2), 'u1'); + }); + + it('جایگذاری متغیرها و خلاصهٔ پاسخ‌ها', () => { + const text = renderAutomationTemplate('تماس با {{title}} ({{mobile}}) — {{field.coop_type}} از {{form}}', { + title: 'علی', + mobile: '0912', + formName: 'BSJ', + formValues: answers('بنکداری'), + }); + assert.equal(text, 'تماس با علی (0912) — بنکداری از BSJ'); + assert.equal( + formatFormAnswers([{ key: KEY, label: 'نوع همکاری' }], { [KEY]: 'بنکداری', extra: 'x' }), + 'نوع همکاری: بنکداری\nextra: x', + ); + }); +}); diff --git a/apps/web/src/components/automations/AutomationCanvas.vue b/apps/web/src/components/automations/AutomationCanvas.vue index fa2e6e2..e5a4087 100644 --- a/apps/web/src/components/automations/AutomationCanvas.vue +++ b/apps/web/src/components/automations/AutomationCanvas.vue @@ -11,8 +11,12 @@ import { MAX_AUTOMATION_STEPS, isAutomationCondition, type AutomationAction, + type AutomationChainMode, type AutomationStep, type AutomationTrigger, + type LeadFormDto, + type OrgUserDto, + type TeamDto, } from '@mento/shared'; import { toFaDigits } from '@/lib/jalali'; @@ -20,11 +24,44 @@ export type AutomationAddPayload = | { kind: 'action'; action: AutomationAction } | { kind: 'condition' }; -const props = defineProps<{ - trigger: AutomationTrigger; - steps: AutomationStep[]; - selected: 'trigger' | string; -}>(); +const props = withDefaults( + defineProps<{ + trigger: AutomationTrigger; + steps: AutomationStep[]; + selected: 'trigger' | string; + chainMode?: AutomationChainMode; + form?: LeadFormDto | null; + users?: OrgUserDto[]; + teams?: TeamDto[]; + }>(), + { chainMode: 'stop', form: null, users: () => [], teams: () => [] }, +); + +/** شرطی که یک شاخهٔ جدید را شروع می‌کند (گام قبلی شرط نیست) */ +function isBranchStart(index: number) { + if (props.chainMode !== 'branches') return false; + const step = props.steps[index]; + if (!step || !isAutomationCondition(step)) return false; + const prev = props.steps[index - 1]; + return !prev || !isAutomationCondition(prev); +} + +function userName(id?: string | null) { + return props.users.find((u) => u.id === id)?.fullName ?? 'کاربر'; +} + +function assignHint(step: AutomationStep) { + const mode = step.assignMode ?? (step.assigneeId ? 'user' : 'owner'); + if (mode === 'user') return userName(step.assigneeId); + if (mode === 'team_round_robin') { + const t = props.teams.find((x) => x.id === step.assigneeTeamId); + return `نوبتی: ${t?.name ?? 'تیم'}`; + } + if (mode === 'users_round_robin') { + return `نوبتی بین ${toFaDigits((step.assigneeUserIds ?? []).length)} نفر`; + } + return 'مسئول پرونده'; +} const emit = defineEmits<{ select: [id: 'trigger' | string]; @@ -55,6 +92,10 @@ function canRemove(step: AutomationStep) { function stepTitle(step: AutomationStep) { if (isAutomationCondition(step)) { const field = step.conditionField ?? 'has_person'; + if (field === 'form_field') { + const f = props.form?.fields.find((x) => x.key === step.conditionKey); + return f?.label ?? AUTOMATION_CONDITION_FIELD_LABELS[field]; + } return AUTOMATION_CONDITION_FIELD_LABELS[field]; } return AUTOMATION_ACTION_LABELS[step.action ?? 'create_activity']; @@ -64,6 +105,10 @@ function stepHint(step: AutomationStep) { if (isAutomationCondition(step)) { const op = step.conditionOp ?? 'is_set'; const opLabel = AUTOMATION_CONDITION_OP_LABELS[op]; + if (step.conditionField === 'form_field') { + if (op === 'is_set' || op === 'is_empty') return opLabel; + return `${opLabel} «${step.conditionValue ?? ''}»`; + } if (step.conditionField === 'deal_amount' && step.conditionValue != null) { return `${opLabel} ${toFaDigits(Number(step.conditionValue))}`; } @@ -90,7 +135,7 @@ function stepHint(step: AutomationStep) { task: 'وظیفه', note: 'یادداشت', }; - return types[step.activityType ?? 'task'] ?? 'وظیفه'; + return `${types[step.activityType ?? 'task'] ?? 'وظیفه'} — ${assignHint(step)}`; } function openAdd(index: number) { @@ -148,10 +193,11 @@ function nodeClass(step: AutomationStep) {
+ شاخه