feat: add CRM lead assignment strategies

This commit is contained in:
Codex
2026-06-29 20:51:24 +08:00
parent f6710ace8b
commit ec23cd3b9b
10 changed files with 375 additions and 20 deletions

View File

@@ -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];

View File

@@ -62,7 +62,17 @@ export default function TenantMarketingPage() {
const [commissionOrders, setCommissionOrders] = useState<CommissionOrderItem[]>([]);
const [settlements, setSettlements] = useState<CommissionSettlementItem[]>([]);
const [members, setMembers] = useState<TenantMemberItem[]>([]);
const [crmForm, setCrmForm] = useState({ enabled: false, url: '', secretRef: '', formName: '刷题题库', examType: '专升本', timeoutSec: '10', delaySec: '60' });
const [crmForm, setCrmForm] = useState({
enabled: false,
url: '',
secretRef: '',
formName: '刷题题库',
examType: '专升本',
timeoutSec: '10',
delaySec: '60',
assignmentMode: 'none' as 'none' | 'direct' | 'round_robin' | 'referrer',
assignmentPoolText: '',
});
const [commissionForm, setCommissionForm] = useState({ defaultRatePercent: '20', minSettlementYuan: '0', settlementCycle: 'monthly' });
const [period, setPeriod] = useState({ startDate: monthStart(), endDate: today(), referrerUserId: '' });
const [memberRate, setMemberRate] = useState({ userId: '', ratePercent: '' });
@@ -112,6 +122,8 @@ export default function TenantMarketingPage() {
examType: nextCrm.examType || '专升本',
timeoutSec: String(nextCrm.timeoutSec || 10),
delaySec: String(nextCrm.delaySec || 60),
assignmentMode: nextCrm.assignmentMode || 'none',
assignmentPoolText: (nextCrm.assignmentPool || []).join(','),
});
}
if (nextSettings) {
@@ -142,6 +154,8 @@ export default function TenantMarketingPage() {
examType: crmForm.examType.trim() || '专升本',
timeoutSec: Math.max(1, Math.trunc(Number(crmForm.timeoutSec || 10))),
delaySec: Math.max(0, Math.trunc(Number(crmForm.delaySec || 60))),
assignmentMode: crmForm.assignmentMode,
assignmentPool: crmForm.assignmentPoolText.split(',').map(item => item.trim()).filter(Boolean),
});
setCrmConfig(result.item || null);
Taro.showToast({ title: 'CRM 已保存', icon: 'success' });
@@ -304,6 +318,18 @@ export default function TenantMarketingPage() {
<Input className='admin-input' placeholder='考试类型' value={crmForm.examType} onInput={event => setCrmForm(prev => ({ ...prev, examType: String(event.detail.value || '') }))} />
<Input className='admin-input' type='number' placeholder='超时秒数' value={crmForm.timeoutSec} onInput={event => setCrmForm(prev => ({ ...prev, timeoutSec: String(event.detail.value || '') }))} />
<Input className='admin-input' type='number' placeholder='延迟推送秒数' value={crmForm.delaySec} onInput={event => setCrmForm(prev => ({ ...prev, delaySec: String(event.detail.value || '') }))} />
<View className='admin-actions compact'>
{(['none', 'referrer', 'direct', 'round_robin'] as const).map(mode => (
<Button
key={mode}
className={`admin-button ${crmForm.assignmentMode === mode ? 'active' : ''}`}
onClick={() => setCrmForm(prev => ({ ...prev, assignmentMode: mode }))}
>
{mode === 'none' ? '不分配' : mode === 'referrer' ? '归属人跟进' : mode === 'direct' ? '固定负责人' : '轮询分配'}
</Button>
))}
</View>
<Input className='admin-input' placeholder='负责人用户ID多个用英文逗号分隔' value={crmForm.assignmentPoolText} onInput={event => setCrmForm(prev => ({ ...prev, assignmentPoolText: String(event.detail.value || '') }))} />
</View>
<View className='admin-actions compact'>
<Button className='admin-button primary' loading={busy === 'crm'} onClick={saveCrmConfig}> CRM</Button>
@@ -312,6 +338,15 @@ export default function TenantMarketingPage() {
<View className='admin-row'>
<Text className='admin-row-main'>{crmConfig?.enabled ? 'CRM 已启用' : 'CRM 未启用'}</Text>
<Text className='admin-row-meta'>{crmConfig?.url || '未配置 URL'} · {crmConfig?.secretRef || '未配置 secretRef'}</Text>
<Text className='admin-row-meta'> {crmConfig?.assignmentMode || 'none'} · {crmConfig?.assignmentPool?.length || 0} · {crmConfig?.assignmentCursor || 0}</Text>
</View>
<View className='admin-list compact'>
{salesMembers.slice(0, 8).map(item => (
<View className='admin-row' key={item.userId}>
<Text className='admin-row-main'>{item.name || item.username || item.userId}</Text>
<Text className='admin-row-meta'>{item.role} · {item.userId}</Text>
</View>
))}
</View>
</View>

View File

@@ -421,6 +421,10 @@ export interface CrmConfigItem {
examType?: string | null;
timeoutSec?: number;
delaySec?: number;
assignmentMode?: 'none' | 'direct' | 'round_robin' | 'referrer';
assignmentPool?: string[];
assignmentCursor?: number;
assignmentConfig?: Record<string, unknown>;
updatedAt?: string;
}
@@ -1017,6 +1021,9 @@ export async function upsertCrmConfig(input: {
examType?: string | null;
timeoutSec?: number;
delaySec?: number;
assignmentMode?: 'none' | 'direct' | 'round_robin' | 'referrer';
assignmentPool?: string[];
assignmentConfig?: Record<string, unknown>;
}) {
return apiRequest<{ item?: CrmConfigItem }>('/api/crm/config', {
method: 'PUT',