fix(leads): give a new form lead the same owner as its first call

When a form has no round-robin assignees, the lead now follows the person the automation assigns to the first call.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-29 19:30:13 +03:30
co-authored by Cursor
parent b9bb2ff9fa
commit 99d686fefb
5 changed files with 78 additions and 1 deletions
@@ -7,6 +7,19 @@ export type TemplateVars = {
formValues?: Record<string, unknown> | 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();
@@ -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<string, unknown> | 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(
@@ -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,
@@ -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));
+1 -1
View File
@@ -855,7 +855,7 @@ onMounted(load);
{{ u.fullName }}
</button>
</div>
<p v-if="!assigneeIds.length" class="settings-empty">مسئولی انتخاب نشده؛ لید بدون مسئول ثبت می‌شود.</p>
<p v-if="!assigneeIds.length" class="settings-empty">مسئولی انتخاب نشده. سرنخ جدید مسئولش همان مسئول اولین تماس اتوماسیون می‌شود؛ اگر تماسی ساخته نشود، مالک سازمان مسئول می‌ماند.</p>
</article>
<article class="settings-card optional">