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", "prisma:seed": "tsx prisma/seed.ts",
"lint": "echo \"api lint placeholder\"", "lint": "echo \"api lint placeholder\"",
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p test/tsconfig.json", "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": { "dependencies": {
"@mento/shared": "*", "@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? fields Json?
/// کاربران شرکت‌کننده؛ فعالیت در لیست آن‌ها هم دیده می‌شود /// کاربران شرکت‌کننده؛ فعالیت در لیست آن‌ها هم دیده می‌شود
participantIds String[] @default([]) participantIds String[] @default([])
/// لید مرتبط (مثلاً فعالیت ساخته‌شده از ارسال فرم)
leadId String?
lead Lead? @relation(fields: [leadId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([stageId]) @@index([stageId])
@@index([typeDefId]) @@index([typeDefId])
@@index([leadId])
@@index([organizationId, status, dueAt]) @@index([organizationId, status, dueAt])
@@index([organizationId, assigneeId]) @@index([organizationId, assigneeId])
@@index([organizationId, createdById]) @@index([organizationId, createdById])
@@ -1443,12 +1447,25 @@ model AutomationRule {
action AutomationAction action AutomationAction
/// عنوان فعالیت/اعلان، assigneeId، notifyUserId، points، activityType /// عنوان فعالیت/اعلان، assigneeId، notifyUserId، points، activityType
config Json @default("{}") config Json @default("{}")
cursors AutomationCursor[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([organizationId, isActive, trigger]) @@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 { enum MessageAutomationTrigger {
person_created person_created
lead_submitted lead_submitted
@@ -2400,6 +2417,7 @@ model Lead {
trackingLinks TrackingLink[] trackingLinks TrackingLink[]
trackingEvents TrackingEvent[] trackingEvents TrackingEvent[]
routineTasks RoutineTask[] routineTasks RoutineTask[]
activities Activity[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -173,6 +173,7 @@ export class ActivitiesController {
personId: query.personId, personId: query.personId,
companyId: query.companyId, companyId: query.companyId,
dealId: query.dealId, dealId: query.dealId,
leadId: query.leadId,
}, },
{ {
from: query.from, from: query.from,
@@ -69,6 +69,7 @@ type ActivityRow = {
person: { id: string; firstName: string; lastName: string } | null; person: { id: string; firstName: string; lastName: string } | null;
company: { id: string; name: string } | null; company: { id: string; name: string } | null;
deal: { id: string; title: string } | null; deal: { id: string; title: string } | null;
lead?: { id: string; title: string } | null;
assignee: { id: string; fullName: string } | null; assignee: { id: string; fullName: string } | null;
assignees: Array<{ id: string; fullName: string }>; assignees: Array<{ id: string; fullName: string }>;
createdBy: { id: string; fullName: string } | null; createdBy: { id: string; fullName: string } | null;
@@ -84,6 +85,7 @@ const include = {
person: { select: { id: true, firstName: true, lastName: true } }, person: { select: { id: true, firstName: true, lastName: true } },
company: { select: { id: true, name: true } }, company: { select: { id: true, name: true } },
deal: { select: { id: true, title: true } }, deal: { select: { id: true, title: true } },
lead: { select: { id: true, title: true } },
assignee: { select: { id: true, fullName: true } }, assignee: { select: { id: true, fullName: true } },
assignees: { select: { id: true, fullName: true }, orderBy: { fullName: 'asc' as const } }, assignees: { select: { id: true, fullName: true }, orderBy: { fullName: 'asc' as const } },
createdBy: { select: { id: true, fullName: true } }, createdBy: { select: { id: true, fullName: true } },
@@ -190,6 +192,7 @@ export class ActivitiesService {
personId?: string; personId?: string;
companyId?: string; companyId?: string;
dealId?: string; dealId?: string;
leadId?: string;
}, },
range?: { range?: {
from?: string; from?: string;
@@ -602,6 +605,7 @@ export class ActivitiesService {
personId?: string; personId?: string;
companyId?: string; companyId?: string;
dealId?: string; dealId?: string;
leadId?: string;
}, },
range?: { range?: {
from?: string; from?: string;
@@ -643,6 +647,7 @@ export class ActivitiesService {
...(entity?.personId ? [{ personId: entity.personId }] : []), ...(entity?.personId ? [{ personId: entity.personId }] : []),
...(entity?.companyId ? [{ companyId: entity.companyId }] : []), ...(entity?.companyId ? [{ companyId: entity.companyId }] : []),
...(entity?.dealId ? [{ dealId: entity.dealId }] : []), ...(entity?.dealId ? [{ dealId: entity.dealId }] : []),
...(entity?.leadId ? [{ leadId: entity.leadId }] : []),
]; ];
const search = buildSearchWhere<Prisma.ActivityWhereInput>(q, (t) => [ const search = buildSearchWhere<Prisma.ActivityWhereInput>(q, (t) => [
{ title: ci(t) }, { title: ci(t) },
@@ -872,6 +877,7 @@ export class ActivitiesService {
: null, : null,
company: row.company, company: row.company,
deal: row.deal, deal: row.deal,
lead: row.lead ?? null,
assignee: row.assignee, assignee: row.assignee,
assignees: (() => { assignees: (() => {
const list = [...(row.assignees ?? [])]; const list = [...(row.assignees ?? [])];
+4
View File
@@ -91,6 +91,10 @@ export class ListActivitiesQueryDto {
@IsString() @IsString()
dealId?: string; dealId?: string;
@IsOptional()
@IsString()
leadId?: string;
/** Inclusive start (ISO). When set with/without filter, restricts dueAt. */ /** Inclusive start (ISO). When set with/without filter, restricts dueAt. */
@IsOptional() @IsOptional()
@IsDateString() @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, MAX_AUTOMATION_STEPS,
automationStepToConfig, automationStepToConfig,
firstAutomationAction, firstAutomationAction,
isAutomationCondition, matchFormFieldCondition,
normalizeAutomationSteps, normalizeAutomationSteps,
packAutomationConfig, packAutomationConfig,
planAutomationActions,
} from '@mento/shared'; } from '@mento/shared';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { LoyaltyService } from '../loyalty/loyalty.service'; import { LoyaltyService } from '../loyalty/loyalty.service';
import { SmsService } from '../common/sms.service'; import { SmsService } from '../common/sms.service';
import {
formatFormAnswers,
pickRoundRobin,
renderAutomationTemplate,
type FormFieldLabel,
} from './automation-engine.util';
export type AutomationContext = { export type AutomationContext = {
organizationId: string; organizationId: string;
entityType: 'deal' | 'ticket' | 'person' | 'invoice' | 'activity'; entityType: 'deal' | 'ticket' | 'person' | 'invoice' | 'activity' | 'lead';
entityId: string; entityId: string;
title: string; title: string;
personId?: string | null; personId?: string | null;
companyId?: string | null; companyId?: string | null;
dealId?: string | null; dealId?: string | null;
leadId?: string | null;
ownerId?: string | null; ownerId?: string | null;
assigneeId?: string | null; assigneeId?: string | null;
/** مثلاً id کمپین SMS برای فیلتر قانون */ /** مثلاً id کمپین SMS یا id فرم سرنخ برای فیلتر قانون */
sourceId?: string | null; sourceId?: string | null;
sourceType?: string | null; sourceType?: string | null;
clickCount?: number | null; clickCount?: number | null;
mobile?: string | null;
formName?: string | null;
formValues?: Record<string, unknown> | null;
formFields?: FormFieldLabel[] | null;
}; };
type AutomationFacts = { type AutomationFacts = {
@@ -51,8 +63,11 @@ type AutomationFacts = {
hasPerson: boolean; hasPerson: boolean;
hasCompany: boolean; hasCompany: boolean;
ticketPriority: string | null; ticketPriority: string | null;
formValues: Record<string, unknown>;
}; };
type StepRef = { ruleId: string; stepId: string };
@Injectable() @Injectable()
export class AutomationsService { export class AutomationsService {
private readonly logger = new Logger(AutomationsService.name); private readonly logger = new Logger(AutomationsService.name);
@@ -79,6 +94,7 @@ export class AutomationsService {
const steps = resolveSteps(dto.action, dto.config); const steps = resolveSteps(dto.action, dto.config);
const action = firstAutomationAction(steps); const action = firstAutomationAction(steps);
if (!action) throw new BadRequestException('حداقل یک عمل لازم است'); if (!action) throw new BadRequestException('حداقل یک عمل لازم است');
await this.assertStepRefs(organizationId, steps);
const row = await this.prisma.automationRule.create({ const row = await this.prisma.automationRule.create({
data: { data: {
organizationId, organizationId,
@@ -86,7 +102,7 @@ export class AutomationsService {
trigger: dto.trigger, trigger: dto.trigger,
action, action,
isActive: dto.isActive ?? true, isActive: dto.isActive ?? true,
config: packAutomationConfig(steps) as Prisma.InputJsonValue, config: packAutomationConfig(steps, chainExtra(dto.config)) as Prisma.InputJsonValue,
}, },
}); });
return this.toDto(row); return this.toDto(row);
@@ -114,6 +130,7 @@ export class AutomationsService {
if (steps && !nextAction) { if (steps && !nextAction) {
throw new BadRequestException('حداقل یک عمل لازم است'); throw new BadRequestException('حداقل یک عمل لازم است');
} }
if (steps) await this.assertStepRefs(organizationId, steps);
const row = await this.prisma.automationRule.update({ const row = await this.prisma.automationRule.update({
where: { id }, where: { id },
@@ -124,7 +141,10 @@ export class AutomationsService {
...(steps && nextAction ...(steps && nextAction
? { ? {
action: 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, 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) { for (const rule of rules) {
try { try {
const config = asConfig(rule.config); const config = asConfig(rule.config);
@@ -167,13 +191,20 @@ export class AutomationsService {
rule.action as AutomationAction, rule.action as AutomationAction,
config, config,
); );
for (const step of steps) { const actions = planAutomationActions(steps, config.chainMode, (step) =>
if (isAutomationCondition(step)) { matchCondition(step, facts),
if (!matchCondition(step, facts)) break; );
continue; 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 { } 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') { if (ctx.entityType === 'deal') {
const deal = await this.prisma.deal.findFirst({ const deal = await this.prisma.deal.findFirst({
where: { id: ctx.entityId, organizationId: ctx.organizationId }, where: { id: ctx.entityId, organizationId: ctx.organizationId },
@@ -258,42 +291,26 @@ export class AutomationsService {
private async execute( private async execute(
action: AutomationAction, action: AutomationAction,
config: AutomationRuleConfig, rawConfig: AutomationRuleConfig,
ctx: AutomationContext, 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 = const title =
(config.title?.trim() || defaultTitle(action, ctx.title)).slice(0, 200); (config.title?.trim() || defaultTitle(action, ctx.title)).slice(0, 200);
if (action === 'create_activity') { if (action === 'create_activity') {
const assigneeId = await this.executeCreateActivity(title, config, ctx, ref);
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,
});
}
return; 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( private async executeNotify(
title: string, title: string,
config: AutomationRuleConfig, config: AutomationRuleConfig,
@@ -464,9 +638,37 @@ function matchCondition(step: AutomationStep, facts: AutomationFacts): boolean {
return current === value; 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; 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) { function defaultTitle(action: AutomationAction, entityTitle: string) {
if (action === 'create_activity') return `پیگیری خودکار: ${entityTitle}`; if (action === 'create_activity') return `پیگیری خودکار: ${entityTitle}`;
if (action === 'create_notification') 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 === 'invoice') return `/invoices/${ctx.entityId}`;
if (ctx.entityType === 'person') return `/contacts/people/${ctx.entityId}`; if (ctx.entityType === 'person') return `/contacts/people/${ctx.entityId}`;
if (ctx.entityType === 'activity') return `/activities/${ctx.entityId}`; if (ctx.entityType === 'activity') return `/activities/${ctx.entityId}`;
if (ctx.entityType === 'lead') return `/leads/${ctx.entityId}`;
return '/'; return '/';
} }
+13 -1
View File
@@ -385,7 +385,19 @@ export class LeadFormsService {
personId, personId,
ownerId: ownerId ?? actorId, 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); await this.messageAutomations.onEvent('lead_submitted', leadCtx);
return { 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',
);
});
});
@@ -11,8 +11,12 @@ import {
MAX_AUTOMATION_STEPS, MAX_AUTOMATION_STEPS,
isAutomationCondition, isAutomationCondition,
type AutomationAction, type AutomationAction,
type AutomationChainMode,
type AutomationStep, type AutomationStep,
type AutomationTrigger, type AutomationTrigger,
type LeadFormDto,
type OrgUserDto,
type TeamDto,
} from '@mento/shared'; } from '@mento/shared';
import { toFaDigits } from '@/lib/jalali'; import { toFaDigits } from '@/lib/jalali';
@@ -20,11 +24,44 @@ export type AutomationAddPayload =
| { kind: 'action'; action: AutomationAction } | { kind: 'action'; action: AutomationAction }
| { kind: 'condition' }; | { kind: 'condition' };
const props = defineProps<{ const props = withDefaults(
trigger: AutomationTrigger; defineProps<{
steps: AutomationStep[]; trigger: AutomationTrigger;
selected: 'trigger' | string; 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<{ const emit = defineEmits<{
select: [id: 'trigger' | string]; select: [id: 'trigger' | string];
@@ -55,6 +92,10 @@ function canRemove(step: AutomationStep) {
function stepTitle(step: AutomationStep) { function stepTitle(step: AutomationStep) {
if (isAutomationCondition(step)) { if (isAutomationCondition(step)) {
const field = step.conditionField ?? 'has_person'; 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_CONDITION_FIELD_LABELS[field];
} }
return AUTOMATION_ACTION_LABELS[step.action ?? 'create_activity']; return AUTOMATION_ACTION_LABELS[step.action ?? 'create_activity'];
@@ -64,6 +105,10 @@ function stepHint(step: AutomationStep) {
if (isAutomationCondition(step)) { if (isAutomationCondition(step)) {
const op = step.conditionOp ?? 'is_set'; const op = step.conditionOp ?? 'is_set';
const opLabel = AUTOMATION_CONDITION_OP_LABELS[op]; 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) { if (step.conditionField === 'deal_amount' && step.conditionValue != null) {
return `${opLabel} ${toFaDigits(Number(step.conditionValue))}`; return `${opLabel} ${toFaDigits(Number(step.conditionValue))}`;
} }
@@ -90,7 +135,7 @@ function stepHint(step: AutomationStep) {
task: 'وظیفه', task: 'وظیفه',
note: 'یادداشت', note: 'یادداشت',
}; };
return types[step.activityType ?? 'task'] ?? 'وظیفه'; return `${types[step.activityType ?? 'task'] ?? 'وظیفه'} — ${assignHint(step)}`;
} }
function openAdd(index: number) { function openAdd(index: number) {
@@ -148,10 +193,11 @@ function nodeClass(step: AutomationStep) {
</div> </div>
<div class="node-wrap"> <div class="node-wrap">
<span v-if="isBranchStart(i)" class="branch-tag">شاخه</span>
<button <button
class="node action" class="node action"
type="button" type="button"
:class="[nodeClass(step), { on: selected === step.id }]" :class="[nodeClass(step), { on: selected === step.id, 'branch-start': isBranchStart(i) }]"
@click="emit('select', step.id)" @click="emit('select', step.id)"
> >
<span class="icon"> <span class="icon">
@@ -301,6 +347,25 @@ function nodeClass(step: AutomationStep) {
filter: drop-shadow(0 8px 14px rgba(180, 83, 9, 0.14)); filter: drop-shadow(0 8px 14px rgba(180, 83, 9, 0.14));
} }
.branch-tag {
position: absolute;
top: -1.35rem;
inset-inline-start: 50%;
transform: translateX(50%);
font-size: 0.68rem;
font-weight: 700;
color: #b45309;
background: #fff7ed;
border: 1px solid #fed7aa;
border-radius: 999px;
padding: 0.05rem 0.5rem;
white-space: nowrap;
}
.node.condition.branch-start {
background: #fef3c7;
}
.node.on { .node.on {
border-color: #14b8a6; border-color: #14b8a6;
box-shadow: box-shadow:
@@ -2,17 +2,24 @@
import { computed, watch } from 'vue'; import { computed, watch } from 'vue';
import type { import type {
ActivityType, ActivityType,
ActivityTypeDefDto,
AutomationAction, AutomationAction,
AutomationAssignMode,
AutomationChainMode,
AutomationConditionField, AutomationConditionField,
AutomationConditionOp, AutomationConditionOp,
AutomationStep, AutomationStep,
AutomationTrigger, AutomationTrigger,
LeadFormDto,
OrgUserDto, OrgUserDto,
TeamDto,
TicketPriority, TicketPriority,
} from '@mento/shared'; } from '@mento/shared';
import { import {
AUTOMATION_ACTION_LABELS, AUTOMATION_ACTION_LABELS,
AUTOMATION_ACTIONS, AUTOMATION_ACTIONS,
AUTOMATION_ASSIGN_MODE_LABELS,
AUTOMATION_CHAIN_MODE_LABELS,
AUTOMATION_CONDITION_FIELD_LABELS, AUTOMATION_CONDITION_FIELD_LABELS,
AUTOMATION_CONDITION_OP_LABELS, AUTOMATION_CONDITION_OP_LABELS,
AUTOMATION_NOTIFY_CHANNEL_LABELS, AUTOMATION_NOTIFY_CHANNEL_LABELS,
@@ -20,16 +27,22 @@ import {
AUTOMATION_TRIGGER_GROUPS, AUTOMATION_TRIGGER_GROUPS,
AUTOMATION_TRIGGER_LABELS, AUTOMATION_TRIGGER_LABELS,
conditionFieldsForTrigger, conditionFieldsForTrigger,
conditionOpsForField,
isAutomationCondition, isAutomationCondition,
} from '@mento/shared'; } from '@mento/shared';
const props = defineProps<{ const props = defineProps<{
selected: 'trigger' | string; selected: 'trigger' | string;
users: OrgUserDto[]; users: OrgUserDto[];
forms: LeadFormDto[];
teams: TeamDto[];
activityTypeDefs: ActivityTypeDefDto[];
}>(); }>();
const trigger = defineModel<AutomationTrigger>('trigger', { required: true }); const trigger = defineModel<AutomationTrigger>('trigger', { required: true });
const steps = defineModel<AutomationStep[]>('steps', { required: true }); const steps = defineModel<AutomationStep[]>('steps', { required: true });
const sourceId = defineModel<string | null>('sourceId', { default: null });
const chainMode = defineModel<AutomationChainMode>('chainMode', { default: 'branches' });
const selectedStep = computed(() => { const selectedStep = computed(() => {
if (props.selected === 'trigger') return null; if (props.selected === 'trigger') return null;
@@ -42,13 +55,67 @@ const isCondition = computed(
const conditionFields = computed(() => conditionFieldsForTrigger(trigger.value)); const conditionFields = computed(() => conditionFieldsForTrigger(trigger.value));
const conditionOps = computed((): AutomationConditionOp[] => { const conditionOps = computed((): AutomationConditionOp[] =>
const field = selectedStep.value?.conditionField; conditionOpsForField(selectedStep.value?.conditionField),
if (field === 'deal_amount') return ['gte', 'lte', 'eq', 'neq']; );
if (field === 'ticket_priority') return ['eq', 'neq'];
return ['is_set', 'is_empty']; const selectedForm = computed(() =>
sourceId.value ? props.forms.find((f) => f.id === sourceId.value) ?? null : null,
);
const formFields = computed(() =>
(selectedForm.value?.fields ?? []).filter((f) => f.fieldType !== 'heading'),
);
const conditionFormField = computed(() => {
const key = selectedStep.value?.conditionKey;
return key ? formFields.value.find((f) => f.key === key) ?? null : null;
}); });
const needsConditionValue = computed(() => {
const op = selectedStep.value?.conditionOp;
return op !== 'is_set' && op !== 'is_empty';
});
const assignModes = Object.keys(AUTOMATION_ASSIGN_MODE_LABELS) as AutomationAssignMode[];
const dueUnits = [
{ value: 'now', label: 'همان لحظه (امروز)' },
{ value: 'hours', label: 'چند ساعت بعد' },
{ value: 'days', label: 'چند روز بعد' },
] as const;
type DueUnit = (typeof dueUnits)[number]['value'];
function dueUnitOf(step: AutomationStep): DueUnit {
const m = step.dueOffsetMinutes ?? 0;
if (!m) return 'now';
return m % 1440 === 0 ? 'days' : 'hours';
}
function dueAmountOf(step: AutomationStep): number {
const m = step.dueOffsetMinutes ?? 0;
if (!m) return 0;
return m % 1440 === 0 ? m / 1440 : Math.round(m / 60);
}
function setDue(step: AutomationStep, unit: DueUnit, amount: number) {
const n = Math.max(1, Math.trunc(Number(amount) || 1));
step.dueOffsetMinutes = unit === 'now' ? 0 : unit === 'days' ? n * 1440 : n * 60;
}
function toggleUser(step: AutomationStep, id: string, on: boolean) {
const cur = new Set(step.assigneeUserIds ?? []);
if (on) cur.add(id);
else cur.delete(id);
step.assigneeUserIds = [...cur];
}
function setConditionKey(step: AutomationStep, key: string) {
step.conditionKey = key || null;
const field = formFields.value.find((f) => f.key === key);
step.conditionValue = field?.options?.[0] ?? '';
}
const activityTypes: Array<{ value: ActivityType; label: string }> = [ const activityTypes: Array<{ value: ActivityType; label: string }> = [
{ value: 'task', label: 'وظیفه' }, { value: 'task', label: 'وظیفه' },
{ value: 'call', label: 'تماس' }, { value: 'call', label: 'تماس' },
@@ -68,17 +135,22 @@ function setAction(step: AutomationStep, action: AutomationAction) {
step.action = action; step.action = action;
if (action === 'add_loyalty_points' && !step.points) step.points = 100; if (action === 'add_loyalty_points' && !step.points) step.points = 100;
if (action === 'create_activity' && !step.activityType) step.activityType = 'task'; if (action === 'create_activity' && !step.activityType) step.activityType = 'task';
if (action === 'create_activity' && !step.assignMode) step.assignMode = 'owner';
if (action === 'create_notification' && !step.notifyChannel) step.notifyChannel = 'app'; if (action === 'create_notification' && !step.notifyChannel) step.notifyChannel = 'app';
} }
function setConditionField(step: AutomationStep, field: AutomationConditionField) { function setConditionField(step: AutomationStep, field: AutomationConditionField) {
step.conditionField = field; step.conditionField = field;
step.conditionKey = null;
if (field === 'deal_amount') { if (field === 'deal_amount') {
step.conditionOp = 'gte'; step.conditionOp = 'gte';
step.conditionValue = typeof step.conditionValue === 'number' ? step.conditionValue : 0; step.conditionValue = typeof step.conditionValue === 'number' ? step.conditionValue : 0;
} else if (field === 'ticket_priority') { } else if (field === 'ticket_priority') {
step.conditionOp = 'eq'; step.conditionOp = 'eq';
step.conditionValue = 'normal'; step.conditionValue = 'normal';
} else if (field === 'form_field') {
step.conditionOp = 'contains';
step.conditionValue = '';
} else { } else {
step.conditionOp = 'is_set'; step.conditionOp = 'is_set';
step.conditionValue = undefined; step.conditionValue = undefined;
@@ -115,12 +187,38 @@ watch(
</optgroup> </optgroup>
</select> </select>
</label> </label>
<label v-if="trigger === 'lead_submitted'">
فرم
<select v-model="sourceId">
<option :value="null">همه فرم‌ها</option>
<option v-for="f in forms" :key="f.id" :value="f.id">{{ f.name }}</option>
</select>
</label>
<p v-if="trigger === 'lead_submitted' && !sourceId" class="hint">
برای شرط روی پاسخ‌های فرم، یک فرم مشخص را انتخاب کنید.
</p>
<label>
رفتار شرط‌ها
<select v-model="chainMode">
<option value="branches">{{ AUTOMATION_CHAIN_MODE_LABELS.branches }}</option>
<option value="stop">{{ AUTOMATION_CHAIN_MODE_LABELS.stop }}</option>
</select>
</label>
<p v-if="chainMode === 'branches'" class="hint">
هر شرط یک شاخه را شروع می‌کند. شرط‌های پشت‌سرهم با «و» ترکیب می‌شوند و فقط
عمل‌های بعد از خودشان (تا شرط بعدی) را کنترل می‌کنند؛ مثلاً اگر دو گزینه تیک بخورد، هر دو
شاخه اجرا می‌شود.
</p>
</template> </template>
<template v-else-if="selectedStep && isCondition"> <template v-else-if="selectedStep && isCondition">
<h2>گره شرط</h2> <h2>گره شرط</h2>
<p class="hint"> <p class="hint">
اگر شرط برقرار نباشد، عمل‌های بعد از این گره اجرا نمی‌شوند. {{
chainMode === 'branches'
? 'اگر شرط برقرار نباشد، فقط عمل‌های این شاخه (تا شرط بعدی) اجرا نمی‌شوند.'
: 'اگر شرط برقرار نباشد، عمل‌های بعد از این گره اجرا نمی‌شوند.'
}}
</p> </p>
<label> <label>
فیلد فیلد
@@ -138,6 +236,21 @@ watch(
</option> </option>
</select> </select>
</label> </label>
<template v-if="selectedStep.conditionField === 'form_field'">
<p v-if="!selectedForm" class="hint warn">
ابتدا در گره محرک یک فرم مشخص انتخاب کنید.
</p>
<label v-else>
فیلد فرم
<select
:value="selectedStep.conditionKey ?? ''"
@change="setConditionKey(selectedStep, ($event.target as HTMLSelectElement).value)"
>
<option value="" disabled>انتخاب فیلد…</option>
<option v-for="f in formFields" :key="f.key" :value="f.key">{{ f.label }}</option>
</select>
</label>
</template>
<label> <label>
مقایسه مقایسه
<select v-model="selectedStep.conditionOp"> <select v-model="selectedStep.conditionOp">
@@ -158,6 +271,18 @@ watch(
</option> </option>
</select> </select>
</label> </label>
<template v-if="selectedStep.conditionField === 'form_field' && needsConditionValue">
<label v-if="conditionFormField?.options?.length">
گزینه
<select v-model="selectedStep.conditionValue">
<option v-for="o in conditionFormField.options" :key="o" :value="o">{{ o }}</option>
</select>
</label>
<label v-else>
مقدار
<input v-model.trim="selectedStep.conditionValue" type="text" maxlength="200" />
</label>
</template>
</template> </template>
<template v-else-if="selectedStep"> <template v-else-if="selectedStep">
@@ -207,15 +332,106 @@ watch(
</option> </option>
</select> </select>
</label> </label>
<label v-if="selectedStep.action === 'create_activity'"> <template v-if="selectedStep.action === 'create_activity'">
مسئول فعالیت <label v-if="activityTypeDefs.length">
<select v-model="selectedStep.assigneeId"> نوع سفارشی (اختیاری)
<option :value="null">مسئول معامله / تیکت</option> <select v-model="selectedStep.activityTypeDefId">
<option v-for="u in users" :key="u.id" :value="u.id"> <option :value="null">— بدون نوع سفارشی —</option>
<option v-for="t in activityTypeDefs" :key="t.id" :value="t.id">{{ t.name }}</option>
</select>
</label>
<label>
تخصیص به
<select v-model="selectedStep.assignMode">
<option v-for="m in assignModes" :key="m" :value="m">
{{ AUTOMATION_ASSIGN_MODE_LABELS[m] }}
</option>
</select>
</label>
<label v-if="selectedStep.assignMode === 'user'">
کاربر
<select v-model="selectedStep.assigneeId">
<option :value="null" disabled>انتخاب کاربر…</option>
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
</select>
</label>
<template v-else-if="selectedStep.assignMode === 'team_round_robin'">
<label>
تیم
<select v-model="selectedStep.assigneeTeamId">
<option :value="null" disabled>انتخاب تیم…</option>
<option v-for="t in teams" :key="t.id" :value="t.id">
{{ t.name }} ({{ t.memberCount }} نفر)
</option>
</select>
</label>
<p v-if="!teams.length" class="hint warn">
هنوز تیمی تعریف نشده؛ از «تنظیمات ← تیم‌ها و سطح دسترسی» بسازید.
</p>
</template>
<div v-else-if="selectedStep.assignMode === 'users_round_robin'" class="user-picks">
<p class="field-label">کاربران</p>
<label v-for="u in users" :key="u.id" class="ch">
<input
type="checkbox"
:checked="(selectedStep.assigneeUserIds ?? []).includes(u.id)"
@change="toggleUser(selectedStep, u.id, ($event.target as HTMLInputElement).checked)"
/>
{{ u.fullName }} {{ u.fullName }}
</option> </label>
</select> </div>
</label> <p
v-if="selectedStep.assignMode === 'team_round_robin' || selectedStep.assignMode === 'users_round_robin'"
class="hint"
>
هر بار نفر بعدی به نوبت انتخاب می‌شود تا کارها مساوی تقسیم شوند.
</p>
<div class="due">
<label>
موعد
<select
:value="dueUnitOf(selectedStep)"
@change="
setDue(
selectedStep,
($event.target as HTMLSelectElement).value as DueUnit,
dueAmountOf(selectedStep) || 1,
)
"
>
<option v-for="u in dueUnits" :key="u.value" :value="u.value">{{ u.label }}</option>
</select>
</label>
<label v-if="dueUnitOf(selectedStep) !== 'now'">
تعداد
<input
type="number"
min="1"
:value="dueAmountOf(selectedStep)"
@input="
setDue(
selectedStep,
dueUnitOf(selectedStep),
Number(($event.target as HTMLInputElement).value),
)
"
/>
</label>
</div>
</template>
<p v-if="selectedStep.action === 'create_activity' || selectedStep.action === 'create_notification'" class="hint">
در عنوان و متن می‌توانید از
<code v-pre>{{title}}</code>، <code v-pre>{{mobile}}</code>، <code v-pre>{{form}}</code> و
<code v-pre>{{field.کلید}}</code> استفاده کنید.
<template v-if="formFields.length">
کلیدها:
<span v-for="(f, i) in formFields" :key="f.key">
<code>{{ f.key }}</code> ({{ f.label }})<template v-if="i < formFields.length - 1">، </template>
</span>
</template>
</p>
<template v-if="selectedStep.action === 'create_notification'"> <template v-if="selectedStep.action === 'create_notification'">
<p class="field-label">نحوه ارسال</p> <p class="field-label">نحوه ارسال</p>
@@ -341,4 +557,35 @@ textarea {
height: 14px; height: 14px;
margin: 0; margin: 0;
} }
.hint.warn {
color: #b45309;
}
.hint code {
font-size: 0.76rem;
background: #f1f5f9;
border-radius: 4px;
padding: 0 0.25rem;
direction: ltr;
unicode-bidi: embed;
}
.user-picks {
display: grid;
gap: 0.4rem;
max-height: 12rem;
overflow: auto;
}
.due {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.5rem;
align-items: end;
}
.due input {
width: 5.5rem;
}
</style> </style>
+24
View File
@@ -391,6 +391,20 @@ onMounted(() => {
{{ activity.createdBy?.fullName || '—' }} {{ activity.createdBy?.fullName || '—' }}
</dd> </dd>
</div> </div>
<div v-if="activity.lead">
<dt>
<UiIcon name="target" :size="15" />
لید
</dt>
<dd>
<RouterLink
class="lead-link"
:to="{ name: 'lead-detail', params: { id: activity.lead.id } }"
>
{{ activity.lead.title }}
</RouterLink>
</dd>
</div>
<div> <div>
<dt> <dt>
<UiIcon name="calendar" :size="15" /> <UiIcon name="calendar" :size="15" />
@@ -684,6 +698,16 @@ dd {
text-align: start; text-align: start;
} }
.lead-link {
color: var(--accent, #0f766e);
font-weight: 600;
text-decoration: none;
}
.lead-link:hover {
text-decoration: underline;
}
.person { .person {
display: flex; display: flex;
align-items: center; align-items: center;
+70 -19
View File
@@ -14,12 +14,17 @@ import type { AutomationAddPayload } from '@/components/automations/AutomationCa
import AutomationInspector from '@/components/automations/AutomationInspector.vue'; import AutomationInspector from '@/components/automations/AutomationInspector.vue';
import { toFaDigits } from '@/lib/jalali'; import { toFaDigits } from '@/lib/jalali';
import type { import type {
ActivityTypeDefDto,
AutomationAction, AutomationAction,
AutomationChainMode,
AutomationConditionField,
AutomationRuleDto, AutomationRuleDto,
AutomationStep, AutomationStep,
AutomationTrigger, AutomationTrigger,
CreateAutomationRuleRequest, CreateAutomationRuleRequest,
LeadFormDto,
OrgUserDto, OrgUserDto,
TeamDto,
} from '@mento/shared'; } from '@mento/shared';
import { import {
AUTOMATION_ACTION_LABELS, AUTOMATION_ACTION_LABELS,
@@ -38,16 +43,26 @@ const error = ref('');
const ok = ref(''); const ok = ref('');
const items = ref<AutomationRuleDto[]>([]); const items = ref<AutomationRuleDto[]>([]);
const users = ref<OrgUserDto[]>([]); const users = ref<OrgUserDto[]>([]);
const forms = ref<LeadFormDto[]>([]);
const teams = ref<TeamDto[]>([]);
const activityTypeDefs = ref<ActivityTypeDefDto[]>([]);
const editingId = ref<string | null>(null); const editingId = ref<string | null>(null);
const name = ref('قانون بدون نام'); const name = ref('قانون بدون نام');
const trigger = ref<AutomationTrigger>('deal_won'); const trigger = ref<AutomationTrigger>('deal_won');
const isActive = ref(true); const isActive = ref(true);
const sourceId = ref<string | null>(null);
const chainMode = ref<AutomationChainMode>('branches');
const steps = ref<AutomationStep[]>([makeStep('create_activity')]); const steps = ref<AutomationStep[]>([makeStep('create_activity')]);
const selected = ref<'trigger' | string>('trigger'); const selected = ref<'trigger' | string>('trigger');
const savedSnap = ref(''); const savedSnap = ref('');
const dirty = computed(() => snap() !== savedSnap.value); const dirty = computed(() => snap() !== savedSnap.value);
const selectedForm = computed(() =>
trigger.value === 'lead_submitted' && sourceId.value
? forms.value.find((f) => f.id === sourceId.value) ?? null
: null,
);
const canSave = computed( const canSave = computed(
() => name.value.trim().length >= 2 && Boolean(firstAutomationAction(steps.value)), () => name.value.trim().length >= 2 && Boolean(firstAutomationAction(steps.value)),
); );
@@ -63,19 +78,29 @@ function makeStep(action: AutomationAction): AutomationStep {
notifyTarget: action === 'create_notification' ? 'staff' : undefined, notifyTarget: action === 'create_notification' ? 'staff' : undefined,
assigneeId: null, assigneeId: null,
notifyUserId: null, notifyUserId: null,
assignMode: action === 'create_activity' ? 'owner' : undefined,
dueOffsetMinutes: action === 'create_activity' ? 0 : undefined,
}; };
} }
function conditionDefaults(field: AutomationConditionField): Partial<AutomationStep> {
if (field === 'deal_amount') return { conditionOp: 'gte', conditionValue: 0, conditionKey: null };
if (field === 'ticket_priority') {
return { conditionOp: 'eq', conditionValue: 'normal', conditionKey: null };
}
if (field === 'form_field') {
return { conditionOp: 'contains', conditionValue: '', conditionKey: null };
}
return { conditionOp: 'is_set', conditionValue: undefined, conditionKey: null };
}
function makeCondition(): AutomationStep { function makeCondition(): AutomationStep {
const field = conditionFieldsForTrigger(trigger.value)[0] ?? 'has_person'; const field = conditionFieldsForTrigger(trigger.value)[0] ?? 'has_person';
return { return {
id: `s-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, id: `s-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'condition', kind: 'condition',
conditionField: field, conditionField: field,
conditionOp: ...conditionDefaults(field),
field === 'deal_amount' ? 'gte' : field === 'ticket_priority' ? 'eq' : 'is_set',
conditionValue:
field === 'deal_amount' ? 0 : field === 'ticket_priority' ? 'normal' : undefined,
}; };
} }
@@ -84,6 +109,8 @@ function snap() {
name: name.value.trim(), name: name.value.trim(),
trigger: trigger.value, trigger: trigger.value,
isActive: isActive.value, isActive: isActive.value,
sourceId: sourceId.value,
chainMode: chainMode.value,
steps: steps.value, steps: steps.value,
}); });
} }
@@ -106,6 +133,8 @@ function hydrate(row?: AutomationRuleDto | null) {
name.value = 'قانون بدون نام'; name.value = 'قانون بدون نام';
trigger.value = 'deal_won'; trigger.value = 'deal_won';
isActive.value = true; isActive.value = true;
sourceId.value = null;
chainMode.value = 'branches';
steps.value = [makeStep('create_activity')]; steps.value = [makeStep('create_activity')];
selected.value = 'trigger'; selected.value = 'trigger';
markClean(); markClean();
@@ -115,6 +144,8 @@ function hydrate(row?: AutomationRuleDto | null) {
name.value = row.name; name.value = row.name;
trigger.value = row.trigger; trigger.value = row.trigger;
isActive.value = row.isActive; isActive.value = row.isActive;
sourceId.value = row.config.sourceId ?? null;
chainMode.value = row.config.chainMode ?? 'stop';
steps.value = normalizeAutomationSteps(row.action, row.config).map((s, i) => ({ steps.value = normalizeAutomationSteps(row.action, row.config).map((s, i) => ({
...s, ...s,
id: s.id || `step-${i}`, id: s.id || `step-${i}`,
@@ -130,6 +161,14 @@ function hydrate(row?: AutomationRuleDto | null) {
activityType: activityType:
s.activityType ?? (s.action === 'create_activity' ? 'task' : undefined), s.activityType ?? (s.action === 'create_activity' ? 'task' : undefined),
points: s.points ?? (s.action === 'add_loyalty_points' ? 100 : undefined), points: s.points ?? (s.action === 'add_loyalty_points' ? 100 : undefined),
assignMode:
s.assignMode ??
(s.action === 'create_activity' ? (s.assigneeId ? 'user' : 'owner') : undefined),
assigneeTeamId: s.assigneeTeamId ?? null,
assigneeUserIds: s.assigneeUserIds ?? [],
dueOffsetMinutes: s.dueOffsetMinutes ?? null,
activityTypeDefId: s.activityTypeDefId ?? null,
conditionKey: s.conditionKey ?? null,
})); }));
if (!steps.value.length) steps.value = [makeStep('create_activity')]; if (!steps.value.length) steps.value = [makeStep('create_activity')];
selected.value = 'trigger'; selected.value = 'trigger';
@@ -190,12 +229,20 @@ async function load() {
loading.value = true; loading.value = true;
error.value = ''; error.value = '';
try { try {
const [rulesRes, usersRes] = await Promise.all([ const [rulesRes, usersRes, formsRes, teamsRes, typesRes] = await Promise.all([
api.get<AutomationRuleDto[]>('/automations'), api.get<AutomationRuleDto[]>('/automations'),
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })), api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
api.get<LeadFormDto[]>('/lead-forms').catch(() => ({ data: [] as LeadFormDto[] })),
api.get<TeamDto[]>('/teams').catch(() => ({ data: [] as TeamDto[] })),
api
.get<ActivityTypeDefDto[]>('/activities/types')
.catch(() => ({ data: [] as ActivityTypeDefDto[] })),
]); ]);
items.value = rulesRes.data; items.value = rulesRes.data;
users.value = usersRes.data; users.value = usersRes.data;
forms.value = formsRes.data;
teams.value = teamsRes.data;
activityTypeDefs.value = typesRes.data.filter((t) => t.isActive && !t.isSystem);
if (editingId.value) { if (editingId.value) {
const cur = items.value.find((x) => x.id === editingId.value); const cur = items.value.find((x) => x.id === editingId.value);
if (cur && !dirty.value) hydrate(cur); if (cur && !dirty.value) hydrate(cur);
@@ -223,7 +270,12 @@ async function save() {
trigger: trigger.value, trigger: trigger.value,
action, action,
isActive: isActive.value, isActive: isActive.value,
config: packAutomationConfig(steps.value), config: packAutomationConfig(steps.value, {
sourceId: trigger.value === 'lead_submitted' || trigger.value === 'sms_link_clicked'
? sourceId.value
: null,
chainMode: chainMode.value,
}),
}; };
if (editingId.value) { if (editingId.value) {
await api.patch(`/automations/${editingId.value}`, payload); await api.patch(`/automations/${editingId.value}`, payload);
@@ -269,25 +321,15 @@ onMounted(async () => {
hydrate(items.value[0] ?? null); hydrate(items.value[0] ?? null);
}); });
watch(trigger, (next) => { watch(trigger, (next, prev) => {
if (prev === 'lead_submitted' && next !== 'lead_submitted') sourceId.value = null;
const allowed = conditionFieldsForTrigger(next); const allowed = conditionFieldsForTrigger(next);
const fallback = allowed[0] ?? 'has_person'; const fallback = allowed[0] ?? 'has_person';
for (const step of steps.value) { for (const step of steps.value) {
if (!isAutomationCondition(step)) continue; if (!isAutomationCondition(step)) continue;
if (step.conditionField && allowed.includes(step.conditionField)) continue; if (step.conditionField && allowed.includes(step.conditionField)) continue;
step.conditionField = fallback; step.conditionField = fallback;
step.conditionOp = Object.assign(step, conditionDefaults(fallback));
fallback === 'deal_amount'
? 'gte'
: fallback === 'ticket_priority'
? 'eq'
: 'is_set';
step.conditionValue =
fallback === 'deal_amount'
? 0
: fallback === 'ticket_priority'
? 'normal'
: undefined;
} }
}); });
</script> </script>
@@ -372,6 +414,10 @@ watch(trigger, (next) => {
:trigger="trigger" :trigger="trigger"
:steps="steps" :steps="steps"
:selected="selected" :selected="selected"
:chain-mode="chainMode"
:form="selectedForm"
:users="users"
:teams="teams"
@select="selected = $event" @select="selected = $event"
@add="addStep" @add="addStep"
@remove="removeStep" @remove="removeStep"
@@ -379,8 +425,13 @@ watch(trigger, (next) => {
<AutomationInspector <AutomationInspector
v-model:trigger="trigger" v-model:trigger="trigger"
v-model:steps="steps" v-model:steps="steps"
v-model:source-id="sourceId"
v-model:chain-mode="chainMode"
:selected="selected" :selected="selected"
:users="users" :users="users"
:forms="forms"
:teams="teams"
:activity-type-defs="activityTypeDefs"
/> />
</div> </div>
</div> </div>
+60 -1
View File
@@ -17,12 +17,15 @@ import {
} from '@/components/ui'; } from '@/components/ui';
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue'; import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
import type { import type {
ActivityDto,
ConvertLeadResult, ConvertLeadResult,
LeadDto, LeadDto,
LeadEventDto, LeadEventDto,
LeadStageDto, LeadStageDto,
OrgUserDto, OrgUserDto,
PaginatedResponse,
} from '@mento/shared'; } from '@mento/shared';
import { ACTIVITY_TYPE_LABELS } from '@mento/shared';
type LeadDetail = LeadDto & { events: LeadEventDto[] }; type LeadDetail = LeadDto & { events: LeadEventDto[] };
@@ -39,6 +42,7 @@ const okMsg = ref('');
const lead = ref<LeadDetail | null>(null); const lead = ref<LeadDetail | null>(null);
const stages = ref<LeadStageDto[]>([]); const stages = ref<LeadStageDto[]>([]);
const users = ref<OrgUserDto[]>([]); const users = ref<OrgUserDto[]>([]);
const activities = ref<ActivityDto[]>([]);
const stageId = ref(''); const stageId = ref('');
const assigneeId = ref(''); const assigneeId = ref('');
@@ -85,12 +89,18 @@ async function load() {
loading.value = true; loading.value = true;
error.value = ''; error.value = '';
try { try {
const [leadRes, stagesRes, usersRes] = await Promise.all([ const [leadRes, stagesRes, usersRes, actRes] = await Promise.all([
api.get<LeadDetail>(`/leads/${id.value}`), api.get<LeadDetail>(`/leads/${id.value}`),
api.get<LeadStageDto[]>('/leads/stages'), api.get<LeadStageDto[]>('/leads/stages'),
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })), api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
api
.get<PaginatedResponse<ActivityDto>>('/activities', {
params: { filter: 'all', leadId: id.value, limit: 30 },
})
.catch(() => null),
]); ]);
lead.value = leadRes.data; lead.value = leadRes.data;
activities.value = actRes?.data.items ?? [];
stages.value = stagesRes.data; stages.value = stagesRes.data;
users.value = usersRes.data.filter((u) => u.isActive !== false); users.value = usersRes.data.filter((u) => u.isActive !== false);
stageId.value = leadRes.data.stageId; stageId.value = leadRes.data.stageId;
@@ -394,6 +404,22 @@ onMounted(load);
<RelatedTasksPanel :entity="{ type: 'lead', id: lead.id, label: lead.title }" /> <RelatedTasksPanel :entity="{ type: 'lead', id: lead.id, label: lead.title }" />
<UiCard v-if="activities.length" title="فعالیت‌های مرتبط">
<ul class="lead-acts">
<li v-for="a in activities" :key="a.id">
<RouterLink :to="{ name: 'activity-detail', params: { id: a.id } }">
<strong>{{ a.title }}</strong>
</RouterLink>
<span class="muted">
{{ a.typeDef?.name || ACTIVITY_TYPE_LABELS[a.type] }}
· {{ a.assignee?.fullName || 'بدون مسئول' }}
· {{ formatDate(a.dueAt) }}
· {{ a.status === 'done' ? 'انجام شد' : a.status === 'canceled' ? 'لغو شده' : 'باز' }}
</span>
</li>
</ul>
</UiCard>
<UiCard title="جدول زمانی"> <UiCard title="جدول زمانی">
<ul v-if="lead.events.length" class="timeline"> <ul v-if="lead.events.length" class="timeline">
<li v-for="ev in lead.events" :key="ev.id"> <li v-for="ev in lead.events" :key="ev.id">
@@ -538,6 +564,39 @@ onMounted(load);
margin-bottom: 0; margin-bottom: 0;
} }
.lead-acts {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.6rem;
}
.lead-acts li {
display: grid;
gap: 0.15rem;
padding-bottom: 0.55rem;
border-bottom: 1px solid #f1f5f9;
}
.lead-acts li:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.lead-acts a {
color: inherit;
text-decoration: none;
}
.lead-acts a:hover strong {
color: var(--accent, #0f766e);
}
.lead-acts .muted {
font-size: 0.78rem;
}
.timeline { .timeline {
list-style: none; list-style: none;
margin: 0; margin: 0;
Binary file not shown.
Binary file not shown.
Binary file not shown.
+30
View File
@@ -646,6 +646,36 @@
مثال: معامله برنده شد → فعالیت پیگیری ساخته شود، اعلان برود یا امتیاز باشگاه داده شود. مثال: معامله برنده شد → فعالیت پیگیری ساخته شود، اعلان برود یا امتیاز باشگاه داده شود.
یک محرک مهم: **کلیک روی لینک پیامک**. یک محرک مهم: **کلیک روی لینک پیامک**.
#### اتوماسیون بر اساس پاسخ فرم (شاخه‌ای)
محرک **«ثبت لید از فرم»** را انتخاب کنید. در همان پنل محرک می‌توانید:
- **فرم** را مشخص کنید تا قانون فقط برای همان فرم اجرا شود (یا «همه فرم‌ها»).
- **رفتار شرط‌ها** را تعیین کنید:
- **شاخه‌ای (پیشنهادی):** هر شرط فقط اقدام‌های بعد از خودش را تا شرط بعدی کنترل می‌کند. چند شرطِ پشت‌سرهم با «و» ترکیب می‌شوند.
- **توقف:** همان رفتار قبلی؛ اولین شرطی که برقرار نباشد کل زنجیره را متوقف می‌کند.
شرط **«پاسخ فیلد فرم»** یک فیلد از فرم را می‌گیرد و با عملگرهای «شامل / شامل نیست / برابر / نابرابر / پر شده / خالی» بررسی می‌کند. برای فیلدهای چندانتخابی (چک‌باکس) از **«شامل»** استفاده کنید.
اقدام **«ساخت فعالیت»** این تنظیمات را دارد:
- **نوع فعالیت:** نوع پایه یا نوع سفارشی.
- **واگذاری:**
- مسئول پرونده
- یک کاربر مشخص
- **نوبتی بین اعضای یک تیم**
- **نوبتی بین چند کاربر انتخابی**
در حالت نوبتی، کارها به‌ترتیب و مساوی بین افراد پخش می‌شوند.
- **سررسید:** همان لحظه، یا چند ساعت یا چند روز بعد.
- **متغیرها در عنوان و توضیح:** `{{title}}`، `{{mobile}}`، `{{form}}` و `{{field.کلید_فیلد}}`.
پاسخ‌های فرم خودکار در توضیح فعالیت می‌آید. فعالیت به لید وصل می‌شود: در جزئیات فعالیت لینک «لید» دیده می‌شود و در صفحهٔ لید بخش «فعالیت‌های مرتبط» نمایش داده می‌شود.
**مثال (فرم «درخواست شروع همکاری با BSJ»، فیلد «نوع همکاری»):**
1. شرط: نوع همکاری **شامل** «بنکداری» ← اقدام: فعالیت تماس برای کاربر الف، سررسید همان لحظه.
2. شرط: نوع همکاری **شامل** «آنلاین شاپ» ← اقدام: فعالیت تماس برای کاربر ب.
3. شرط: نوع همکاری **شامل** «ویترین نقره» ← اقدام: فعالیت تماس، نوبتی بین اعضای تیم «فروش تلفنی».
اگر چند گزینه با هم تیک بخورد، فعالیت هر گزینه جداگانه ساخته می‌شود.
### اتوماسیون پیامک‌ها ### اتوماسیون پیامک‌ها
![اتوماسیون پیامک‌ها](user-guide/images/17-message-automations.png) ![اتوماسیون پیامک‌ها](user-guide/images/17-message-automations.png)
Binary file not shown.
Binary file not shown.
+7
View File
@@ -363,6 +363,13 @@
### اتوماسیون گردش کار (اگر/آنگاه) ### اتوماسیون گردش کار (اگر/آنگاه)
- محرک‌ها: برنده شدن معامله، حل تیکت، **کلیک روی لینک پیامک** و … - محرک‌ها: برنده شدن معامله، حل تیکت، **کلیک روی لینک پیامک** و …
- اقدام‌ها: ساخت فعالیت، اعلان، امتیاز باشگاه، … - اقدام‌ها: ساخت فعالیت، اعلان، امتیاز باشگاه، …
- محرک **ثبت لید از فرم** با فیلتر روی یک فرم مشخص
- شرط **پاسخ فیلد فرم** (شامل / شامل نیست / برابر / پر یا خالی). از چک‌باکس‌های چندانتخابی پشتیبانی می‌کند.
- **حالت شاخه‌ای:** هر شرط فقط اقدام‌های بعد از خودش را کنترل می‌کند، پس یک قانون می‌تواند چند مسیر داشته باشد.
- واگذاری فعالیت: مسئول پرونده، کاربر ثابت، **نوبتی مساوی بین اعضای تیم** یا بین کاربران انتخابی
- سررسید نسبی (همان لحظه / چند ساعت یا چند روز بعد) و نوع فعالیت سفارشی
- متغیرهای `{{title}}`، `{{mobile}}`، `{{form}}` و `{{field.key}}` در عنوان و توضیح، به‌همراه درج خودکار پاسخ‌های فرم
- اتصال فعالیت به لید (لینک در فعالیت و فهرست «فعالیت‌های مرتبط» در لید)
### اتوماسیون پیامک ### اتوماسیون پیامک
- ارسال پیامک/اعلان با **تأخیر** و **تکرار** - ارسال پیامک/اعلان با **تأخیر** و **تکرار**
Binary file not shown.
Binary file not shown.
+1
View File
@@ -147,6 +147,7 @@
| [x] | ته‌حساب پیشرفته | نمای سند شبیه فاکتور با برچسب فلز، دادهٔ خام، ورود گروهی مشتریان | | [x] | ته‌حساب پیشرفته | نمای سند شبیه فاکتور با برچسب فلز، دادهٔ خام، ورود گروهی مشتریان |
| [x] | گزارش‌های عمیق | گزارش‌ساز، ذخیرهٔ نما، لینک اشتراک، تبدیل مرحله‌ای، مقایسهٔ دوره، drill-down، دلایل شکست، منبع ورود | | [x] | گزارش‌های عمیق | گزارش‌ساز، ذخیرهٔ نما، لینک اشتراک، تبدیل مرحله‌ای، مقایسهٔ دوره، drill-down، دلایل شکست، منبع ورود |
| [x] | لینک ردیابی پیامک | لینک اختصاصی هر گیرنده، گزارش کلیک، محرک اتوماسیون | | [x] | لینک ردیابی پیامک | لینک اختصاصی هر گیرنده، گزارش کلیک، محرک اتوماسیون |
| [x] | اتوماسیون شاخه‌ای فرم | فیلتر فرم، شرط پاسخ فیلد (چندانتخابی)، حالت شاخه‌ای، واگذاری نوبتی تیم/کاربران، سررسید نسبی، اتصال فعالیت به لید |
--- ---
Binary file not shown.
+155 -5
View File
@@ -1415,6 +1415,8 @@ export interface ActivityDto {
person: PersonSummaryDto | null; person: PersonSummaryDto | null;
company: CompanySummaryDto | null; company: CompanySummaryDto | null;
deal: DealSummaryDto | null; deal: DealSummaryDto | null;
/** لید مرتبط (مثلاً فعالیت ساخته‌شده از فرم) */
lead?: { id: string; title: string } | null;
/** مسئول اصلی (اولین مسئول) — سازگاری با فیلترها و یادآور */ /** مسئول اصلی (اولین مسئول) — سازگاری با فیلترها و یادآور */
assignee: UserSummaryDto | null; assignee: UserSummaryDto | null;
/** همه مسئولان انجام فعالیت */ /** همه مسئولان انجام فعالیت */
@@ -3103,7 +3105,7 @@ export const AUTOMATION_ACTION_LABELS: Record<AutomationAction, string> = {
add_loyalty_points: "افزودن امتیاز باشگاه", add_loyalty_points: "افزودن امتیاز باشگاه",
}; };
export const MAX_AUTOMATION_STEPS = 8; export const MAX_AUTOMATION_STEPS = 12;
export const AUTOMATION_ACTIONS = [ export const AUTOMATION_ACTIONS = [
"create_activity", "create_activity",
@@ -3147,7 +3149,8 @@ export type AutomationConditionField =
| "deal_amount" | "deal_amount"
| "has_person" | "has_person"
| "has_company" | "has_company"
| "ticket_priority"; | "ticket_priority"
| "form_field";
export type AutomationConditionOp = export type AutomationConditionOp =
| "gte" | "gte"
@@ -3155,7 +3158,30 @@ export type AutomationConditionOp =
| "eq" | "eq"
| "neq" | "neq"
| "is_set" | "is_set"
| "is_empty"; | "is_empty"
| "contains"
| "not_contains";
/** stop: شرط ناموفق کل زنجیره را متوقف می‌کند — branches: هر شرط فقط عمل‌های بعدی خودش را تا شرط بعدی کنترل می‌کند */
export type AutomationChainMode = "stop" | "branches";
export type AutomationAssignMode =
| "owner"
| "user"
| "team_round_robin"
| "users_round_robin";
export const AUTOMATION_ASSIGN_MODE_LABELS: Record<AutomationAssignMode, string> = {
owner: "مسئول پیش‌فرض (مسئول پرونده)",
user: "کاربر مشخص",
team_round_robin: "نوبتی بین اعضای یک تیم",
users_round_robin: "نوبتی بین کاربران انتخابی",
};
export const AUTOMATION_CHAIN_MODE_LABELS: Record<AutomationChainMode, string> = {
branches: "شاخه‌ای — هر شرط فقط عمل‌های بعد از خودش را کنترل می‌کند",
stop: "توقف — شرط ناموفق کل زنجیره را متوقف می‌کند",
};
export type AutomationNotifyChannel = "app" | "sms" | "both"; export type AutomationNotifyChannel = "app" | "sms" | "both";
export type AutomationNotifyTarget = "staff" | "person"; export type AutomationNotifyTarget = "staff" | "person";
@@ -3168,6 +3194,7 @@ export const AUTOMATION_CONDITION_FIELD_LABELS: Record<
has_person: "مخاطب دارد", has_person: "مخاطب دارد",
has_company: "شرکت دارد", has_company: "شرکت دارد",
ticket_priority: "اولویت تیکت", ticket_priority: "اولویت تیکت",
form_field: "پاسخ فیلد فرم",
}; };
export const AUTOMATION_CONDITION_OP_LABELS: Record< export const AUTOMATION_CONDITION_OP_LABELS: Record<
@@ -3180,6 +3207,8 @@ export const AUTOMATION_CONDITION_OP_LABELS: Record<
neq: "نامساوی", neq: "نامساوی",
is_set: "دارد", is_set: "دارد",
is_empty: "ندارد", is_empty: "ندارد",
contains: "شامل",
not_contains: "شامل نیست",
}; };
export const AUTOMATION_NOTIFY_CHANNEL_LABELS: Record< export const AUTOMATION_NOTIFY_CHANNEL_LABELS: Record<
@@ -3222,9 +3251,23 @@ export function conditionFieldsForTrigger(
if (trigger === "ticket_resolved" || trigger === "ticket_created") { if (trigger === "ticket_resolved" || trigger === "ticket_created") {
return ["ticket_priority", "has_person", "has_company"]; return ["ticket_priority", "has_person", "has_company"];
} }
if (trigger === "lead_submitted") {
return ["form_field", "has_person", "has_company"];
}
return ["has_person", "has_company"]; return ["has_person", "has_company"];
} }
export function conditionOpsForField(
field: AutomationConditionField | undefined,
): AutomationConditionOp[] {
if (field === "deal_amount") return ["gte", "lte", "eq", "neq"];
if (field === "ticket_priority") return ["eq", "neq"];
if (field === "form_field") {
return ["contains", "not_contains", "eq", "neq", "is_set", "is_empty"];
}
return ["is_set", "is_empty"];
}
export interface AutomationStep { export interface AutomationStep {
id: string; id: string;
kind?: AutomationStepKind; kind?: AutomationStepKind;
@@ -3240,6 +3283,14 @@ export interface AutomationStep {
conditionField?: AutomationConditionField; conditionField?: AutomationConditionField;
conditionOp?: AutomationConditionOp; conditionOp?: AutomationConditionOp;
conditionValue?: string | number; conditionValue?: string | number;
/** کلید فیلد فرم برای شرط form_field */
conditionKey?: string | null;
assignMode?: AutomationAssignMode;
assigneeTeamId?: string | null;
assigneeUserIds?: string[];
/** موعد فعالیت نسبت به زمان اجرا (دقیقه)؛ ۰ یا خالی = همان لحظه */
dueOffsetMinutes?: number | null;
activityTypeDefId?: string | null;
} }
export interface AutomationRuleConfig { export interface AutomationRuleConfig {
@@ -3251,8 +3302,15 @@ export interface AutomationRuleConfig {
notifyTarget?: AutomationNotifyTarget; notifyTarget?: AutomationNotifyTarget;
activityType?: "call" | "meeting" | "task" | "note"; activityType?: "call" | "meeting" | "task" | "note";
points?: number; points?: number;
/** فیلتر اختیاری: فقط برای منبع خاص (مثلاً id کمپین SMS) */ assignMode?: AutomationAssignMode;
assigneeTeamId?: string | null;
assigneeUserIds?: string[];
dueOffsetMinutes?: number | null;
activityTypeDefId?: string | null;
/** فیلتر اختیاری: فقط برای منبع خاص (مثلاً id کمپین SMS یا id فرم سرنخ) */
sourceId?: string | null; sourceId?: string | null;
/** نبودِ مقدار = stop (رفتار قدیمی) */
chainMode?: AutomationChainMode;
/** زنجیرهٔ اعمال؛ اگر خالی باشد همان action تکی استفاده می‌شود */ /** زنجیرهٔ اعمال؛ اگر خالی باشد همان action تکی استفاده می‌شود */
steps?: AutomationStep[]; steps?: AutomationStep[];
} }
@@ -3301,6 +3359,12 @@ export function normalizeAutomationSteps(
conditionField: s.conditionField, conditionField: s.conditionField,
conditionOp: s.conditionOp, conditionOp: s.conditionOp,
conditionValue: s.conditionValue, conditionValue: s.conditionValue,
conditionKey: s.conditionKey,
assignMode: s.assignMode,
assigneeTeamId: s.assigneeTeamId,
assigneeUserIds: Array.isArray(s.assigneeUserIds) ? s.assigneeUserIds : undefined,
dueOffsetMinutes: s.dueOffsetMinutes,
activityTypeDefId: s.activityTypeDefId,
})); }));
} }
return [ return [
@@ -3316,11 +3380,19 @@ export function normalizeAutomationSteps(
notifyTarget: cfg.notifyTarget, notifyTarget: cfg.notifyTarget,
activityType: cfg.activityType, activityType: cfg.activityType,
points: cfg.points, points: cfg.points,
assignMode: cfg.assignMode,
assigneeTeamId: cfg.assigneeTeamId,
assigneeUserIds: cfg.assigneeUserIds,
dueOffsetMinutes: cfg.dueOffsetMinutes,
activityTypeDefId: cfg.activityTypeDefId,
}, },
]; ];
} }
export function packAutomationConfig(steps: AutomationStep[]): AutomationRuleConfig { export function packAutomationConfig(
steps: AutomationStep[],
extra: Pick<AutomationRuleConfig, "sourceId" | "chainMode"> = {},
): AutomationRuleConfig {
const first = const first =
steps.find((s) => (s.kind ?? "action") !== "condition") ?? steps[0]; steps.find((s) => (s.kind ?? "action") !== "condition") ?? steps[0];
return { return {
@@ -3333,6 +3405,8 @@ export function packAutomationConfig(steps: AutomationStep[]): AutomationRuleCon
notifyTarget: first?.notifyTarget, notifyTarget: first?.notifyTarget,
activityType: first?.activityType, activityType: first?.activityType,
points: first?.points, points: first?.points,
...(extra.sourceId !== undefined ? { sourceId: extra.sourceId || null } : {}),
...(extra.chainMode ? { chainMode: extra.chainMode } : {}),
}; };
} }
@@ -3346,9 +3420,85 @@ export function automationStepToConfig(step: AutomationStep): AutomationRuleConf
notifyTarget: step.notifyTarget, notifyTarget: step.notifyTarget,
activityType: step.activityType, activityType: step.activityType,
points: step.points, points: step.points,
assignMode: step.assignMode,
assigneeTeamId: step.assigneeTeamId,
assigneeUserIds: step.assigneeUserIds,
dueOffsetMinutes: step.dueOffsetMinutes,
activityTypeDefId: step.activityTypeDefId,
}; };
} }
/**
* گام‌های عملی که باید اجرا شوند.
* stop: اولین شرط ناموفق بقیهٔ زنجیره را قطع می‌کند.
* branches: هر دنبالهٔ پیاپی از شرط‌ها (AND) فقط عمل‌های بعد از خودش را تا شرط بعدی کنترل می‌کند.
*/
export function planAutomationActions(
steps: AutomationStep[],
chainMode: AutomationChainMode | undefined,
evalCondition: (step: AutomationStep) => boolean,
): AutomationStep[] {
const out: AutomationStep[] = [];
if (chainMode !== "branches") {
for (const step of steps) {
if (isAutomationCondition(step)) {
if (!evalCondition(step)) break;
continue;
}
if (step.action) out.push(step);
}
return out;
}
let gateOpen = true;
let prevWasCondition = false;
for (const step of steps) {
if (isAutomationCondition(step)) {
const ok = evalCondition(step);
gateOpen = prevWasCondition ? gateOpen && ok : ok;
prevWasCondition = true;
continue;
}
prevWasCondition = false;
if (gateOpen && step.action) out.push(step);
}
return out;
}
/** مقدار پاسخ فرم را به گزینه‌ها می‌شکند (چندانتخابی با کاما ذخیره می‌شود) */
export function splitFormAnswer(raw: unknown): string[] {
if (raw == null) return [];
return String(raw)
.split(/[,،]/)
.map((s) => s.trim())
.filter(Boolean);
}
export function matchFormFieldCondition(
op: AutomationConditionOp | undefined,
raw: unknown,
expected: unknown,
): boolean {
const parts = splitFormAnswer(raw);
const value = String(expected ?? "").trim();
const whole = raw == null ? "" : String(raw).trim();
switch (op ?? "contains") {
case "is_set":
return parts.length > 0;
case "is_empty":
return parts.length === 0;
case "contains":
return Boolean(value) && parts.includes(value);
case "not_contains":
return !value || !parts.includes(value);
case "eq":
return whole === value || (parts.length === 1 && parts[0] === value);
case "neq":
return !(whole === value || (parts.length === 1 && parts[0] === value));
default:
return false;
}
}
// —— ته‌حساب / حسابداری —— // —— ته‌حساب / حسابداری ——
export const TAHSAB_SANAD_TYPES = [ export const TAHSAB_SANAD_TYPES = [