feat(automations): form-driven branching rules, round-robin assignment, lead-linked activities
This commit is contained in:
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user