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
+155 -5
View File
@@ -1415,6 +1415,8 @@ export interface ActivityDto {
person: PersonSummaryDto | null;
company: CompanySummaryDto | null;
deal: DealSummaryDto | null;
/** لید مرتبط (مثلاً فعالیت ساخته‌شده از فرم) */
lead?: { id: string; title: string } | null;
/** مسئول اصلی (اولین مسئول) — سازگاری با فیلترها و یادآور */
assignee: UserSummaryDto | null;
/** همه مسئولان انجام فعالیت */
@@ -3103,7 +3105,7 @@ export const AUTOMATION_ACTION_LABELS: Record<AutomationAction, string> = {
add_loyalty_points: "افزودن امتیاز باشگاه",
};
export const MAX_AUTOMATION_STEPS = 8;
export const MAX_AUTOMATION_STEPS = 12;
export const AUTOMATION_ACTIONS = [
"create_activity",
@@ -3147,7 +3149,8 @@ export type AutomationConditionField =
| "deal_amount"
| "has_person"
| "has_company"
| "ticket_priority";
| "ticket_priority"
| "form_field";
export type AutomationConditionOp =
| "gte"
@@ -3155,7 +3158,30 @@ export type AutomationConditionOp =
| "eq"
| "neq"
| "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 AutomationNotifyTarget = "staff" | "person";
@@ -3168,6 +3194,7 @@ export const AUTOMATION_CONDITION_FIELD_LABELS: Record<
has_person: "مخاطب دارد",
has_company: "شرکت دارد",
ticket_priority: "اولویت تیکت",
form_field: "پاسخ فیلد فرم",
};
export const AUTOMATION_CONDITION_OP_LABELS: Record<
@@ -3180,6 +3207,8 @@ export const AUTOMATION_CONDITION_OP_LABELS: Record<
neq: "نامساوی",
is_set: "دارد",
is_empty: "ندارد",
contains: "شامل",
not_contains: "شامل نیست",
};
export const AUTOMATION_NOTIFY_CHANNEL_LABELS: Record<
@@ -3222,9 +3251,23 @@ export function conditionFieldsForTrigger(
if (trigger === "ticket_resolved" || trigger === "ticket_created") {
return ["ticket_priority", "has_person", "has_company"];
}
if (trigger === "lead_submitted") {
return ["form_field", "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 {
id: string;
kind?: AutomationStepKind;
@@ -3240,6 +3283,14 @@ export interface AutomationStep {
conditionField?: AutomationConditionField;
conditionOp?: AutomationConditionOp;
conditionValue?: string | number;
/** کلید فیلد فرم برای شرط form_field */
conditionKey?: string | null;
assignMode?: AutomationAssignMode;
assigneeTeamId?: string | null;
assigneeUserIds?: string[];
/** موعد فعالیت نسبت به زمان اجرا (دقیقه)؛ ۰ یا خالی = همان لحظه */
dueOffsetMinutes?: number | null;
activityTypeDefId?: string | null;
}
export interface AutomationRuleConfig {
@@ -3251,8 +3302,15 @@ export interface AutomationRuleConfig {
notifyTarget?: AutomationNotifyTarget;
activityType?: "call" | "meeting" | "task" | "note";
points?: number;
/** فیلتر اختیاری: فقط برای منبع خاص (مثلاً id کمپین SMS) */
assignMode?: AutomationAssignMode;
assigneeTeamId?: string | null;
assigneeUserIds?: string[];
dueOffsetMinutes?: number | null;
activityTypeDefId?: string | null;
/** فیلتر اختیاری: فقط برای منبع خاص (مثلاً id کمپین SMS یا id فرم سرنخ) */
sourceId?: string | null;
/** نبودِ مقدار = stop (رفتار قدیمی) */
chainMode?: AutomationChainMode;
/** زنجیرهٔ اعمال؛ اگر خالی باشد همان action تکی استفاده می‌شود */
steps?: AutomationStep[];
}
@@ -3301,6 +3359,12 @@ export function normalizeAutomationSteps(
conditionField: s.conditionField,
conditionOp: s.conditionOp,
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 [
@@ -3316,11 +3380,19 @@ export function normalizeAutomationSteps(
notifyTarget: cfg.notifyTarget,
activityType: cfg.activityType,
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 =
steps.find((s) => (s.kind ?? "action") !== "condition") ?? steps[0];
return {
@@ -3333,6 +3405,8 @@ export function packAutomationConfig(steps: AutomationStep[]): AutomationRuleCon
notifyTarget: first?.notifyTarget,
activityType: first?.activityType,
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,
activityType: step.activityType,
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 = [