diff --git a/apps/api/src/automations/automation-engine.util.ts b/apps/api/src/automations/automation-engine.util.ts index 2f75364..6e803ec 100644 --- a/apps/api/src/automations/automation-engine.util.ts +++ b/apps/api/src/automations/automation-engine.util.ts @@ -7,6 +7,19 @@ export type TemplateVars = { formValues?: Record | null; }; +/** + * مسئول سرنخ فقط از اولین فعالیتی که مسئول دارد پر می‌شود. + * اگر قبلاً گرفته شده یا این فعالیت مسئول ندارد، null برمی‌گردد. + */ +export function claimLeadAssignee( + alreadyClaimed: boolean, + activityAssigneeId: string | null | undefined, +): string | null { + if (alreadyClaimed) return null; + const id = activityAssigneeId?.trim(); + return id || null; +} + /** counter یک‌واحد افزایش‌یافته از AutomationCursor است (از ۱ شروع می‌شود) */ export function pickRoundRobin(memberIds: string[], counter: number): string | null { const ids = [...new Set(memberIds.filter(Boolean))].sort(); diff --git a/apps/api/src/automations/automations.service.ts b/apps/api/src/automations/automations.service.ts index 844cd31..d97779d 100644 --- a/apps/api/src/automations/automations.service.ts +++ b/apps/api/src/automations/automations.service.ts @@ -34,6 +34,7 @@ import { NotificationsService } from '../notifications/notifications.service'; import { LoyaltyService } from '../loyalty/loyalty.service'; import { SmsGatewayService } from '../sms-billing/sms-gateway.service'; import { + claimLeadAssignee, formatFormAnswers, pickRoundRobin, renderAutomationTemplate, @@ -59,6 +60,12 @@ export type AutomationContext = { formName?: string | null; formValues?: Record | null; formFields?: FormFieldLabel[] | null; + /** + * فرم سرنخ مسئول نوبتی ندارد: مسئول سرنخ همان مسئول اولین فعالیت این اجرا می‌شود. + * داخل همان اجرا یک‌بار ست می‌شود تا فعالیت بعدی مسئول را عوض نکند. + */ + syncLeadAssignee?: boolean; + leadAssigneeSynced?: boolean; }; type AutomationFacts = { @@ -406,6 +413,54 @@ export class AutomationsService { entityId: activity.id, }); } + await this.syncLeadAssigneeFromActivity(ctx, type === 'call' ? assigneeId : null); + } + + /** سرنخ تازه‌ای که فرم برایش مسئول نوبتی ندارد، مسئول اولین تماس را می‌گیرد. */ + private async syncLeadAssigneeFromActivity( + ctx: AutomationContext, + activityAssigneeId: string | null, + ) { + const assigneeId = claimLeadAssignee(ctx.leadAssigneeSynced === true, activityAssigneeId); + if (!ctx.syncLeadAssignee || !assigneeId || !ctx.leadId) return; + + const existing = await this.prisma.lead.findFirst({ + where: { + id: ctx.leadId, + organizationId: ctx.organizationId, + archivedAt: null, + }, + select: { assigneeId: true }, + }); + if (!existing) return; + + ctx.leadAssigneeSynced = true; + ctx.assigneeId = assigneeId; + if (existing.assigneeId === assigneeId) return; + + await this.prisma.lead.updateMany({ + where: { + id: ctx.leadId, + organizationId: ctx.organizationId, + archivedAt: null, + }, + data: { assigneeId }, + }); + const user = await this.prisma.user.findFirst({ + where: { id: assigneeId }, + select: { fullName: true }, + }); + const name = user?.fullName?.trim() || 'کارشناس'; + await this.prisma.leadEvent.create({ + data: { + organizationId: ctx.organizationId, + leadId: ctx.leadId, + type: 'assigned', + title: `به «${name}» اختصاص داده شد`, + body: 'مسئول سرنخ همان مسئول اولین تماس اتوماسیون شد.', + meta: { assigneeId, source: 'automation' }, + }, + }); } private async resolveAssignee( diff --git a/apps/api/src/lead-forms/lead-forms.service.ts b/apps/api/src/lead-forms/lead-forms.service.ts index eea04d9..97d41bc 100644 --- a/apps/api/src/lead-forms/lead-forms.service.ts +++ b/apps/api/src/lead-forms/lead-forms.service.ts @@ -391,6 +391,7 @@ export class LeadFormsService { sourceId: form.id, sourceType: 'lead_form', leadId, + syncLeadAssignee: !ownerId && leadCreated && !!leadId, mobile: mapped.mobile ?? mapped.phone ?? null, formName: form.name, formValues: values, diff --git a/apps/api/test/automation-branches.test.ts b/apps/api/test/automation-branches.test.ts index a5be24b..5613482 100644 --- a/apps/api/test/automation-branches.test.ts +++ b/apps/api/test/automation-branches.test.ts @@ -10,6 +10,7 @@ import { type AutomationStep, } from '@mento/shared'; import { + claimLeadAssignee, formatFormAnswers, pickRoundRobin, renderAutomationTemplate, @@ -136,6 +137,13 @@ describe('اتوماسیون شاخه‌ای فرم', () => { ); }); + it('مسئول سرنخ فقط از اولین فعالیت دارای مسئول پر می‌شود', () => { + assert.equal(claimLeadAssignee(false, null), null); + assert.equal(claimLeadAssignee(false, ' '), null); + assert.equal(claimLeadAssignee(false, 'romina'), 'romina'); + assert.equal(claimLeadAssignee(true, 'fatemeh'), null); + }); + it('تقسیم نوبتی مساوی و پایدار', () => { const team = ['u3', 'u1', 'u2']; const picks = [1, 2, 3, 4, 5, 6].map((n) => pickRoundRobin(team, n)); diff --git a/apps/web/src/views/LeadFormBuilderView.vue b/apps/web/src/views/LeadFormBuilderView.vue index 64c9cb0..ced3ad3 100644 --- a/apps/web/src/views/LeadFormBuilderView.vue +++ b/apps/web/src/views/LeadFormBuilderView.vue @@ -855,7 +855,7 @@ onMounted(load); {{ u.fullName }} -

مسئولی انتخاب نشده؛ لید بدون مسئول ثبت می‌شود.

+

مسئولی انتخاب نشده. سرنخ جدید مسئولش همان مسئول اولین تماس اتوماسیون می‌شود؛ اگر تماسی ساخته نشود، مالک سازمان مسئول می‌ماند.