feat(automations): form-driven branching rules, round-robin assignment, lead-linked activities

This commit is contained in:
2026-10-11 14:12:36 +03:30
parent b30f68563d
commit b3b92abae9
27 changed files with 1160 additions and 91 deletions
+1 -1
View File
@@ -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": "*",
@@ -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;
+18
View File
@@ -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
@@ -173,6 +173,7 @@ export class ActivitiesController {
personId: query.personId,
companyId: query.companyId,
dealId: query.dealId,
leadId: query.leadId,
},
{
from: query.from,
@@ -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<Prisma.ActivityWhereInput>(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 ?? [])];
+4
View File
@@ -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()
@@ -0,0 +1,51 @@
export type FormFieldLabel = { key: string; label: string };
export type TemplateVars = {
title: string;
mobile?: string | null;
formName?: string | null;
formValues?: Record<string, unknown> | 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.<key>}} */
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<string, unknown> | null | undefined,
): string {
if (!values) return '';
const lines: string[] = [];
const seen = new Set<string>();
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');
}
+246 -43
View File
@@ -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<string, unknown> | null;
formFields?: FormFieldLabel[] | null;
};
type AutomationFacts = {
@@ -51,8 +63,11 @@ type AutomationFacts = {
hasPerson: boolean;
hasCompany: boolean;
ticketPriority: string | null;
formValues: Record<string, unknown>;
};
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<AutomationFacts> {
private async loadFacts(
ctx: AutomationContext,
): Promise<Omit<AutomationFacts, 'formValues'>> {
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<string | null> {
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<AutomationRuleConfig, 'sourceId' | 'chainMode'> {
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 '/';
}
+13 -1
View File
@@ -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 {
+115
View File
@@ -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<string, string> => ({ [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',
);
});
});