forked from wangziqi/gongxue-base
feat: add CRM lead assignment strategies
This commit is contained in:
@@ -11,6 +11,8 @@ type JsonBody = Record<string, unknown>;
|
||||
const REFERRAL_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
const EVENT_TYPES = ['enter', 'register', 'purchase', 'share', 'scan', 'manual_bind'];
|
||||
const TRACK_SOURCES = ['share', 'qrcode', 'timeline', 'miniapp', 'h5', 'manual', 'unknown'];
|
||||
const CRM_ASSIGNMENT_MODES = ['none', 'direct', 'round_robin', 'referrer'];
|
||||
const CRM_ASSIGNABLE_ROLES = ['tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent'];
|
||||
|
||||
function nullableString(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
@@ -28,6 +30,19 @@ function optionalChoice(value: unknown, allowed: string[], fallback: string) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function stringArray(value: unknown) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const seen = new Set<string>();
|
||||
const items: string[] = [];
|
||||
for (const item of value) {
|
||||
const normalized = typeof item === 'string' ? item.trim() : '';
|
||||
if (!normalized || seen.has(normalized)) continue;
|
||||
seen.add(normalized);
|
||||
items.push(normalized);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function normalizeCode(code: string) {
|
||||
return code.replace(/\s+/g, '').toUpperCase();
|
||||
}
|
||||
@@ -65,6 +80,169 @@ async function userHasTenantMembership(client: pg.PoolClient, tenantId: string,
|
||||
return Boolean(result.rows[0]);
|
||||
}
|
||||
|
||||
async function tenantMemberForCrmAssignment(client: pg.PoolClient, tenantId: string, userId: string) {
|
||||
const result = await client.query<{ userId: string; role: string; name: string | null; username: string | null }>(
|
||||
`
|
||||
select tm.user_id as "userId", tm.role, u.name, u.username
|
||||
from public.tenant_memberships tm
|
||||
join public.platform_users u on u.id = tm.user_id
|
||||
where tm.tenant_id = $1
|
||||
and tm.user_id = $2
|
||||
and tm.status = 'active'
|
||||
and tm.role = any($3::text[])
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, userId, CRM_ASSIGNABLE_ROLES],
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
async function validateCrmAssignmentPool(client: pg.PoolClient, tenantId: string, pool: string[], mode: string) {
|
||||
if (!pool.length) {
|
||||
if (mode === 'direct' || mode === 'round_robin') {
|
||||
throw new HttpError(400, 'assignmentPool is required for this CRM assignment mode', 'CRM_ASSIGNMENT_POOL_REQUIRED');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
if (pool.length > 100) {
|
||||
throw new HttpError(400, 'assignmentPool cannot contain more than 100 members', 'CRM_ASSIGNMENT_POOL_TOO_LARGE');
|
||||
}
|
||||
|
||||
const rows = await client.query<{ userId: string }>(
|
||||
`
|
||||
select tm.user_id as "userId"
|
||||
from public.tenant_memberships tm
|
||||
where tm.tenant_id = $1
|
||||
and tm.user_id = any($2::uuid[])
|
||||
and tm.status = 'active'
|
||||
and tm.role = any($3::text[])
|
||||
`,
|
||||
[tenantId, pool, CRM_ASSIGNABLE_ROLES],
|
||||
);
|
||||
const valid = new Set(rows.rows.map(row => row.userId));
|
||||
const invalid = pool.filter(userId => !valid.has(userId));
|
||||
if (invalid.length) {
|
||||
throw new HttpError(400, 'CRM assignment pool contains users outside the tenant or unsupported roles', 'CRM_ASSIGNMENT_POOL_INVALID');
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
async function crmAssignmentPoolMembers(client: pg.PoolClient, tenantId: string, pool: unknown) {
|
||||
const ids = stringArray(pool);
|
||||
if (!ids.length) return [];
|
||||
const rows = await client.query<{ userId: string; role: string; name: string | null; username: string | null }>(
|
||||
`
|
||||
with pool(user_id, ord) as (
|
||||
select value::uuid, ord
|
||||
from jsonb_array_elements_text($2::jsonb) with ordinality as item(value, ord)
|
||||
)
|
||||
select p.user_id as "userId", tm.role, u.name, u.username
|
||||
from pool p
|
||||
join public.tenant_memberships tm on tm.tenant_id = $1
|
||||
and tm.user_id = p.user_id
|
||||
and tm.status = 'active'
|
||||
and tm.role = any($3::text[])
|
||||
join public.platform_users u on u.id = tm.user_id
|
||||
order by p.ord asc
|
||||
`,
|
||||
[tenantId, JSON.stringify(ids), CRM_ASSIGNABLE_ROLES],
|
||||
);
|
||||
return rows.rows;
|
||||
}
|
||||
|
||||
async function assignReferralLeadFromCrmConfig(
|
||||
client: pg.PoolClient,
|
||||
input: {
|
||||
tenantId: string;
|
||||
leadId: string;
|
||||
referrerUserId: string | null;
|
||||
actorUserId?: string | null;
|
||||
trigger: string;
|
||||
},
|
||||
) {
|
||||
const cfg = await client.query<{
|
||||
assignmentMode: string;
|
||||
assignmentPool: unknown;
|
||||
assignmentCursor: number;
|
||||
}>(
|
||||
`
|
||||
select assignment_mode as "assignmentMode",
|
||||
assignment_pool as "assignmentPool",
|
||||
assignment_cursor as "assignmentCursor"
|
||||
from public.crm_config
|
||||
where tenant_id = $1
|
||||
limit 1
|
||||
for update
|
||||
`,
|
||||
[input.tenantId],
|
||||
);
|
||||
const config = cfg.rows[0];
|
||||
const mode = config?.assignmentMode || 'none';
|
||||
if (mode === 'none') return null;
|
||||
|
||||
let assignee: { userId: string; role: string; name: string | null; username: string | null } | null = null;
|
||||
if (mode === 'referrer' && input.referrerUserId) {
|
||||
assignee = await tenantMemberForCrmAssignment(client, input.tenantId, input.referrerUserId);
|
||||
}
|
||||
if (mode === 'direct' || mode === 'round_robin') {
|
||||
const members = await crmAssignmentPoolMembers(client, input.tenantId, config.assignmentPool);
|
||||
if (members.length) {
|
||||
const cursor = Math.max(0, Number(config.assignmentCursor || 0));
|
||||
assignee = mode === 'round_robin' ? members[cursor % members.length] : members[0];
|
||||
if (mode === 'round_robin') {
|
||||
await client.query(
|
||||
'update public.crm_config set assignment_cursor = assignment_cursor + 1, updated_at = now() where tenant_id = $1',
|
||||
[input.tenantId],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!assignee) return null;
|
||||
|
||||
const result = await client.query(
|
||||
`
|
||||
update public.referral_leads
|
||||
set assigned_to_user_id = $3::uuid,
|
||||
assignment_mode = $4::text,
|
||||
assigned_at = now(),
|
||||
assigned_by = $5::uuid,
|
||||
metadata = metadata || jsonb_build_object(
|
||||
'crmAssignment',
|
||||
jsonb_build_object(
|
||||
'mode', $4::text,
|
||||
'trigger', $6::text,
|
||||
'assignedToUserId', $3::uuid,
|
||||
'assignedToRole', $7::text,
|
||||
'assignedAt', now()
|
||||
)
|
||||
),
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
returning id, student_user_id as "studentUserId", referrer_user_id as "referrerUserId",
|
||||
ref_code::text as "refCode", source, bind_type as "bindType", status,
|
||||
assigned_to_user_id as "assignedToUserId", assignment_mode as "assignmentMode",
|
||||
assigned_at as "assignedAt", assigned_by as "assignedBy",
|
||||
bound_at as "boundAt", created_at as "createdAt", updated_at as "updatedAt"
|
||||
`,
|
||||
[input.tenantId, input.leadId, assignee.userId, mode, input.actorUserId || null, input.trigger, assignee.role],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
|
||||
values ($1, $2, 'crm.lead.assigned', 'referral_leads', $3, $4::jsonb)
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
input.actorUserId || null,
|
||||
input.leadId,
|
||||
JSON.stringify({ mode, trigger: input.trigger, assignedToUserId: assignee.userId, assignedToRole: assignee.role }),
|
||||
],
|
||||
);
|
||||
|
||||
return { item: result.rows[0], assignee: { ...assignee, mode } };
|
||||
}
|
||||
|
||||
async function resolveReferralCode(tenantId: string, code: string) {
|
||||
return queryOne<{
|
||||
code: string;
|
||||
@@ -181,12 +359,24 @@ async function enqueueCrmLead(
|
||||
const lead = await client.query<{
|
||||
ref_code: string | null;
|
||||
referrer_user_id: string | null;
|
||||
assigned_to_user_id: string | null;
|
||||
assignment_mode: string | null;
|
||||
bound_at: string;
|
||||
assignee_name: string | null;
|
||||
assignee_username: string | null;
|
||||
assignee_role: string | null;
|
||||
}>(
|
||||
`
|
||||
select ref_code::text, referrer_user_id, bound_at
|
||||
from public.referral_leads
|
||||
where tenant_id = $1 and id = $2
|
||||
select rl.ref_code::text, rl.referrer_user_id, rl.assigned_to_user_id,
|
||||
rl.assignment_mode, rl.bound_at,
|
||||
assignee.name as assignee_name, assignee.username as assignee_username,
|
||||
assignee_tm.role as assignee_role
|
||||
from public.referral_leads rl
|
||||
left join public.platform_users assignee on assignee.id = rl.assigned_to_user_id
|
||||
left join public.tenant_memberships assignee_tm on assignee_tm.tenant_id = rl.tenant_id
|
||||
and assignee_tm.user_id = rl.assigned_to_user_id
|
||||
and assignee_tm.status = 'active'
|
||||
where rl.tenant_id = $1 and rl.id = $2
|
||||
limit 1
|
||||
`,
|
||||
[input.tenantId, input.leadId],
|
||||
@@ -209,6 +399,12 @@ async function enqueueCrmLead(
|
||||
referrerUserId: lead.rows[0]?.referrer_user_id || null,
|
||||
boundAt: lead.rows[0]?.bound_at || null,
|
||||
},
|
||||
assignee: lead.rows[0]?.assigned_to_user_id ? {
|
||||
id: lead.rows[0].assigned_to_user_id,
|
||||
name: lead.rows[0].assignee_name || lead.rows[0].assignee_username || null,
|
||||
role: lead.rows[0].assignee_role || null,
|
||||
assignmentMode: lead.rows[0].assignment_mode || null,
|
||||
} : null,
|
||||
metadata: input.metadata || {},
|
||||
};
|
||||
|
||||
@@ -270,12 +466,16 @@ async function bindReferralLead(
|
||||
id: string;
|
||||
referrerUserId: string | null;
|
||||
refCode: string | null;
|
||||
assignedToUserId: string | null;
|
||||
assignmentMode: string | null;
|
||||
assignedAt: string | null;
|
||||
status: string;
|
||||
boundAt: string;
|
||||
}>(
|
||||
`
|
||||
select id, referrer_user_id as "referrerUserId", ref_code::text as "refCode",
|
||||
status, bound_at as "boundAt"
|
||||
assigned_to_user_id as "assignedToUserId", assignment_mode as "assignmentMode",
|
||||
assigned_at as "assignedAt", status, bound_at as "boundAt"
|
||||
from public.referral_leads
|
||||
where tenant_id = $1 and student_user_id = $2
|
||||
limit 1
|
||||
@@ -305,6 +505,8 @@ async function bindReferralLead(
|
||||
updated_at = now()
|
||||
returning id, student_user_id as "studentUserId", referrer_user_id as "referrerUserId",
|
||||
ref_code::text as "refCode", source, bind_type as "bindType", status,
|
||||
assigned_to_user_id as "assignedToUserId", assignment_mode as "assignmentMode",
|
||||
assigned_at as "assignedAt", assigned_by as "assignedBy",
|
||||
bound_at as "boundAt", created_at as "createdAt", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
@@ -326,7 +528,15 @@ async function bindReferralLead(
|
||||
);
|
||||
}
|
||||
|
||||
return { item: result.rows[0], bound: true, protected: false };
|
||||
const assignment = await assignReferralLeadFromCrmConfig(client, {
|
||||
tenantId: input.tenantId,
|
||||
leadId: result.rows[0].id,
|
||||
referrerUserId: result.rows[0].referrerUserId,
|
||||
actorUserId: typeof input.metadata?.operatorUserId === 'string' ? input.metadata.operatorUserId : null,
|
||||
trigger: input.bindType || 'first_touch',
|
||||
});
|
||||
|
||||
return { item: assignment?.item || result.rows[0], assignment: assignment?.assignee || null, bound: true, protected: false };
|
||||
}
|
||||
|
||||
function crmPermission(auth: TenantAdminAuth) {
|
||||
@@ -561,6 +771,7 @@ export async function referralSalesStatsRoute(ctx: RequestContext) {
|
||||
lead_stats as (
|
||||
select referrer_user_id,
|
||||
count(*)::int as lead_count,
|
||||
count(*) filter (where assigned_to_user_id is not null)::int as assigned_lead_count,
|
||||
count(*) filter (where exists (
|
||||
select 1 from public.orders o
|
||||
where o.tenant_id = rl.tenant_id
|
||||
@@ -587,6 +798,7 @@ export async function referralSalesStatsRoute(ctx: RequestContext) {
|
||||
select r.user_id as "referrerUserId", r.role, r.username, r.name, r.phone,
|
||||
r.code as "inviteCode",
|
||||
coalesce(ls.lead_count, 0) as "leadCount",
|
||||
coalesce(ls.assigned_lead_count, 0) as "assignedLeadCount",
|
||||
coalesce(ls.paid_lead_count, 0) as "paidLeadCount",
|
||||
coalesce(os.paid_amount_cents, 0)::text as "paidAmountCents",
|
||||
coalesce(ts.track_count, 0) as "trackCount"
|
||||
@@ -632,6 +844,10 @@ export async function referralSalesClientsRoute(ctx: RequestContext) {
|
||||
source: string | null;
|
||||
bindType: string;
|
||||
status: string;
|
||||
assignedToUserId: string | null;
|
||||
assignedToName: string | null;
|
||||
assignmentMode: string | null;
|
||||
assignedAt: string | null;
|
||||
boundAt: string;
|
||||
paidAmountCents: string;
|
||||
lastPaidAt: string | null;
|
||||
@@ -640,16 +856,21 @@ export async function referralSalesClientsRoute(ctx: RequestContext) {
|
||||
select rl.id, rl.student_user_id as "studentUserId",
|
||||
u.username, u.name, u.phone, u.email::text as email,
|
||||
rl.referrer_user_id as "referrerUserId", rl.ref_code::text as "refCode",
|
||||
rl.source, rl.bind_type as "bindType", rl.status, rl.bound_at as "boundAt",
|
||||
rl.source, rl.bind_type as "bindType", rl.status,
|
||||
rl.assigned_to_user_id as "assignedToUserId",
|
||||
coalesce(assignee.name, assignee.username) as "assignedToName",
|
||||
rl.assignment_mode as "assignmentMode", rl.assigned_at as "assignedAt",
|
||||
rl.bound_at as "boundAt",
|
||||
coalesce(sum(o.amount_cents) filter (where o.status = 'paid'), 0)::text as "paidAmountCents",
|
||||
max(o.paid_at) filter (where o.status = 'paid') as "lastPaidAt"
|
||||
from public.referral_leads rl
|
||||
join public.platform_users u on u.id = rl.student_user_id
|
||||
left join public.platform_users assignee on assignee.id = rl.assigned_to_user_id
|
||||
left join public.orders o on o.tenant_id = rl.tenant_id and o.user_id = rl.student_user_id
|
||||
where rl.tenant_id = $1
|
||||
and rl.referrer_user_id = $2
|
||||
and rl.status = 'protected'
|
||||
group by rl.id, u.id
|
||||
group by rl.id, u.id, assignee.id
|
||||
order by rl.bound_at desc
|
||||
limit $3
|
||||
`,
|
||||
@@ -823,6 +1044,10 @@ export async function crmConfigRoute(ctx: RequestContext) {
|
||||
`
|
||||
select id, enabled, url, secret_ref as "secretRef", form_name as "formName",
|
||||
exam_type as "examType", timeout_sec as "timeoutSec", delay_sec as "delaySec",
|
||||
assignment_mode as "assignmentMode",
|
||||
assignment_pool as "assignmentPool",
|
||||
assignment_cursor as "assignmentCursor",
|
||||
assignment_config as "assignmentConfig",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.crm_config
|
||||
where tenant_id = $1
|
||||
@@ -838,8 +1063,12 @@ export async function upsertCrmConfigRoute(ctx: RequestContext) {
|
||||
requireTenantPermission(auth, 'crm:write');
|
||||
const body = await readJsonBody(ctx);
|
||||
const secretRef = optionalString(body, 'secretRef') || (body.secret ? 'app_private.tenant_secrets:crm:webhook' : null);
|
||||
const assignmentMode = optionalChoice(body.assignmentMode, CRM_ASSIGNMENT_MODES, 'none');
|
||||
const assignmentPool = stringArray(body.assignmentPool);
|
||||
const assignmentConfig = objectValue(body.assignmentConfig);
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const validAssignmentPool = await validateCrmAssignmentPool(client, auth.tenantId, assignmentPool, assignmentMode);
|
||||
if (body.secret && typeof body.secret === 'string') {
|
||||
await client.query(
|
||||
`
|
||||
@@ -858,9 +1087,10 @@ export async function upsertCrmConfigRoute(ctx: RequestContext) {
|
||||
const result = await client.query(
|
||||
`
|
||||
insert into public.crm_config (
|
||||
tenant_id, enabled, url, secret_ref, form_name, exam_type, timeout_sec, delay_sec
|
||||
tenant_id, enabled, url, secret_ref, form_name, exam_type, timeout_sec, delay_sec,
|
||||
assignment_mode, assignment_pool, assignment_config
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb)
|
||||
on conflict (tenant_id)
|
||||
do update set enabled = excluded.enabled,
|
||||
url = excluded.url,
|
||||
@@ -869,9 +1099,14 @@ export async function upsertCrmConfigRoute(ctx: RequestContext) {
|
||||
exam_type = excluded.exam_type,
|
||||
timeout_sec = excluded.timeout_sec,
|
||||
delay_sec = excluded.delay_sec,
|
||||
assignment_mode = excluded.assignment_mode,
|
||||
assignment_pool = excluded.assignment_pool,
|
||||
assignment_config = excluded.assignment_config,
|
||||
updated_at = now()
|
||||
returning id, enabled, url, secret_ref as "secretRef", form_name as "formName",
|
||||
exam_type as "examType", timeout_sec as "timeoutSec", delay_sec as "delaySec",
|
||||
assignment_mode as "assignmentMode", assignment_pool as "assignmentPool",
|
||||
assignment_cursor as "assignmentCursor", assignment_config as "assignmentConfig",
|
||||
updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
@@ -883,6 +1118,21 @@ export async function upsertCrmConfigRoute(ctx: RequestContext) {
|
||||
optionalString(body, 'examType') || '成人本科',
|
||||
Number(body.timeoutSec || 10),
|
||||
Number(body.delaySec || 60),
|
||||
assignmentMode,
|
||||
JSON.stringify(validAssignmentPool),
|
||||
JSON.stringify(assignmentConfig),
|
||||
],
|
||||
);
|
||||
await client.query(
|
||||
`
|
||||
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
|
||||
values ($1, $2, 'crm.config.updated', 'crm_config', $3, $4::jsonb)
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
auth.userId,
|
||||
result.rows[0].id,
|
||||
JSON.stringify({ enabled: body.enabled === true, assignmentMode, assignmentPoolSize: validAssignmentPool.length }),
|
||||
],
|
||||
);
|
||||
return result.rows[0];
|
||||
|
||||
Reference in New Issue
Block a user