forked from wangziqi/gongxue-base
feat: add CRM lead assignment strategies
This commit is contained in:
@@ -18,7 +18,7 @@
|
|||||||
- 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录、公共题库授权。
|
- 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录、公共题库授权。
|
||||||
- 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 worker 自动同步平台新增/更新题目;同步会保护租户自改题目,返回冲突而不覆盖,后台可查询冲突明细。
|
- 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 worker 自动同步平台新增/更新题目;同步会保护租户自改题目,返回冲突而不覆盖,后台可查询冲突明细。
|
||||||
- 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 JSON、`paper_json`、打印 payload、PDF、Word 和每日一练图片 ZIP 素材包,后端强制租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计;PDF/Word/ZIP 由 exports worker 生成水印文件或运营素材并发布到 `content_assets`;`daily_practice` 支持每日一练九宫格 metadata、PDF/Word 版式、9 张 PNG/SVG 卡片和拼图包。
|
- 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 JSON、`paper_json`、打印 payload、PDF、Word 和每日一练图片 ZIP 素材包,后端强制租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计;PDF/Word/ZIP 由 exports worker 生成水印文件或运营素材并发布到 `content_assets`;`daily_practice` 支持每日一练九宫格 metadata、PDF/Word 版式、9 张 PNG/SVG 卡片和拼图包。
|
||||||
- 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置和队列。
|
- 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置、跟进分配策略和队列。
|
||||||
- `apps/worker` 后台任务进程:CRM webhook 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志;commerce worker 可补偿查询微信/支付宝支付和退款状态;assets worker 可复检托管资源元数据、执行内置安全扫描并自动下架异常资源;imports worker 可执行大批量导入;public-banks worker 可自动同步公共题库采纳副本;exports worker 可渲染 PDF/Word 导出文件和每日一练 ZIP 图片素材包。
|
- `apps/worker` 后台任务进程:CRM webhook 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志;commerce worker 可补偿查询微信/支付宝支付和退款状态;assets worker 可复检托管资源元数据、执行内置安全扫描并自动下架异常资源;imports worker 可执行大批量导入;public-banks worker 可自动同步公共题库采纳副本;exports worker 可渲染 PDF/Word 导出文件和每日一练 ZIP 图片素材包。
|
||||||
- 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态和权限隔离。
|
- 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态和权限隔离。
|
||||||
- 订单售后基础闭环:退款请求、审核、处理状态流、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。
|
- 订单售后基础闭环:退款请求、审核、处理状态流、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
- OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF/图片预览、资源访问事件、动态水印上下文、锁定资源 CDN 边界、资源复检 worker、内置 `metadata_rules` 安全扫描和外部 HTTP 杀毒/内容安全 scanner 接入层已完成。生产还要配置真实扫描服务 endpoint/token,并继续补转码/CDN 级水印、CDN 刷新和对象生命周期策略。
|
- OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF/图片预览、资源访问事件、动态水印上下文、锁定资源 CDN 边界、资源复检 worker、内置 `metadata_rules` 安全扫描和外部 HTTP 杀毒/内容安全 scanner 接入层已完成。生产还要配置真实扫描服务 endpoint/token,并继续补转码/CDN 级水印、CDN 刷新和对象生命周期策略。
|
||||||
- Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。
|
- Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。
|
||||||
- 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。
|
- 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。
|
||||||
- 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警、销售转化看板、公共题库版本通知和冲突处理操作台还没完成。
|
- 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款、结算导出、发票/凭证、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台还没完成。
|
||||||
- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、资料和个人中心;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心和租户设置,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、状态变更、订阅、账单、收款、用量和题库授权第一版写操作。
|
- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、资料和个人中心;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心和租户设置,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、状态变更、订阅、账单、收款、用量和题库授权第一版写操作。
|
||||||
- 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。
|
- 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ type JsonBody = Record<string, unknown>;
|
|||||||
const REFERRAL_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
const REFERRAL_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||||
const EVENT_TYPES = ['enter', 'register', 'purchase', 'share', 'scan', 'manual_bind'];
|
const EVENT_TYPES = ['enter', 'register', 'purchase', 'share', 'scan', 'manual_bind'];
|
||||||
const TRACK_SOURCES = ['share', 'qrcode', 'timeline', 'miniapp', 'h5', 'manual', 'unknown'];
|
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) {
|
function nullableString(value: unknown) {
|
||||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||||
@@ -28,6 +30,19 @@ function optionalChoice(value: unknown, allowed: string[], fallback: string) {
|
|||||||
return candidate;
|
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) {
|
function normalizeCode(code: string) {
|
||||||
return code.replace(/\s+/g, '').toUpperCase();
|
return code.replace(/\s+/g, '').toUpperCase();
|
||||||
}
|
}
|
||||||
@@ -65,6 +80,169 @@ async function userHasTenantMembership(client: pg.PoolClient, tenantId: string,
|
|||||||
return Boolean(result.rows[0]);
|
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) {
|
async function resolveReferralCode(tenantId: string, code: string) {
|
||||||
return queryOne<{
|
return queryOne<{
|
||||||
code: string;
|
code: string;
|
||||||
@@ -181,12 +359,24 @@ async function enqueueCrmLead(
|
|||||||
const lead = await client.query<{
|
const lead = await client.query<{
|
||||||
ref_code: string | null;
|
ref_code: string | null;
|
||||||
referrer_user_id: string | null;
|
referrer_user_id: string | null;
|
||||||
|
assigned_to_user_id: string | null;
|
||||||
|
assignment_mode: string | null;
|
||||||
bound_at: string;
|
bound_at: string;
|
||||||
|
assignee_name: string | null;
|
||||||
|
assignee_username: string | null;
|
||||||
|
assignee_role: string | null;
|
||||||
}>(
|
}>(
|
||||||
`
|
`
|
||||||
select ref_code::text, referrer_user_id, bound_at
|
select rl.ref_code::text, rl.referrer_user_id, rl.assigned_to_user_id,
|
||||||
from public.referral_leads
|
rl.assignment_mode, rl.bound_at,
|
||||||
where tenant_id = $1 and id = $2
|
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
|
limit 1
|
||||||
`,
|
`,
|
||||||
[input.tenantId, input.leadId],
|
[input.tenantId, input.leadId],
|
||||||
@@ -209,6 +399,12 @@ async function enqueueCrmLead(
|
|||||||
referrerUserId: lead.rows[0]?.referrer_user_id || null,
|
referrerUserId: lead.rows[0]?.referrer_user_id || null,
|
||||||
boundAt: lead.rows[0]?.bound_at || 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 || {},
|
metadata: input.metadata || {},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -270,12 +466,16 @@ async function bindReferralLead(
|
|||||||
id: string;
|
id: string;
|
||||||
referrerUserId: string | null;
|
referrerUserId: string | null;
|
||||||
refCode: string | null;
|
refCode: string | null;
|
||||||
|
assignedToUserId: string | null;
|
||||||
|
assignmentMode: string | null;
|
||||||
|
assignedAt: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
boundAt: string;
|
boundAt: string;
|
||||||
}>(
|
}>(
|
||||||
`
|
`
|
||||||
select id, referrer_user_id as "referrerUserId", ref_code::text as "refCode",
|
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
|
from public.referral_leads
|
||||||
where tenant_id = $1 and student_user_id = $2
|
where tenant_id = $1 and student_user_id = $2
|
||||||
limit 1
|
limit 1
|
||||||
@@ -305,6 +505,8 @@ async function bindReferralLead(
|
|||||||
updated_at = now()
|
updated_at = now()
|
||||||
returning id, student_user_id as "studentUserId", referrer_user_id as "referrerUserId",
|
returning id, student_user_id as "studentUserId", referrer_user_id as "referrerUserId",
|
||||||
ref_code::text as "refCode", source, bind_type as "bindType", status,
|
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"
|
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) {
|
function crmPermission(auth: TenantAdminAuth) {
|
||||||
@@ -561,6 +771,7 @@ export async function referralSalesStatsRoute(ctx: RequestContext) {
|
|||||||
lead_stats as (
|
lead_stats as (
|
||||||
select referrer_user_id,
|
select referrer_user_id,
|
||||||
count(*)::int as lead_count,
|
count(*)::int as lead_count,
|
||||||
|
count(*) filter (where assigned_to_user_id is not null)::int as assigned_lead_count,
|
||||||
count(*) filter (where exists (
|
count(*) filter (where exists (
|
||||||
select 1 from public.orders o
|
select 1 from public.orders o
|
||||||
where o.tenant_id = rl.tenant_id
|
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,
|
select r.user_id as "referrerUserId", r.role, r.username, r.name, r.phone,
|
||||||
r.code as "inviteCode",
|
r.code as "inviteCode",
|
||||||
coalesce(ls.lead_count, 0) as "leadCount",
|
coalesce(ls.lead_count, 0) as "leadCount",
|
||||||
|
coalesce(ls.assigned_lead_count, 0) as "assignedLeadCount",
|
||||||
coalesce(ls.paid_lead_count, 0) as "paidLeadCount",
|
coalesce(ls.paid_lead_count, 0) as "paidLeadCount",
|
||||||
coalesce(os.paid_amount_cents, 0)::text as "paidAmountCents",
|
coalesce(os.paid_amount_cents, 0)::text as "paidAmountCents",
|
||||||
coalesce(ts.track_count, 0) as "trackCount"
|
coalesce(ts.track_count, 0) as "trackCount"
|
||||||
@@ -632,6 +844,10 @@ export async function referralSalesClientsRoute(ctx: RequestContext) {
|
|||||||
source: string | null;
|
source: string | null;
|
||||||
bindType: string;
|
bindType: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
assignedToUserId: string | null;
|
||||||
|
assignedToName: string | null;
|
||||||
|
assignmentMode: string | null;
|
||||||
|
assignedAt: string | null;
|
||||||
boundAt: string;
|
boundAt: string;
|
||||||
paidAmountCents: string;
|
paidAmountCents: string;
|
||||||
lastPaidAt: string | null;
|
lastPaidAt: string | null;
|
||||||
@@ -640,16 +856,21 @@ export async function referralSalesClientsRoute(ctx: RequestContext) {
|
|||||||
select rl.id, rl.student_user_id as "studentUserId",
|
select rl.id, rl.student_user_id as "studentUserId",
|
||||||
u.username, u.name, u.phone, u.email::text as email,
|
u.username, u.name, u.phone, u.email::text as email,
|
||||||
rl.referrer_user_id as "referrerUserId", rl.ref_code::text as "refCode",
|
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",
|
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"
|
max(o.paid_at) filter (where o.status = 'paid') as "lastPaidAt"
|
||||||
from public.referral_leads rl
|
from public.referral_leads rl
|
||||||
join public.platform_users u on u.id = rl.student_user_id
|
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
|
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
|
where rl.tenant_id = $1
|
||||||
and rl.referrer_user_id = $2
|
and rl.referrer_user_id = $2
|
||||||
and rl.status = 'protected'
|
and rl.status = 'protected'
|
||||||
group by rl.id, u.id
|
group by rl.id, u.id, assignee.id
|
||||||
order by rl.bound_at desc
|
order by rl.bound_at desc
|
||||||
limit $3
|
limit $3
|
||||||
`,
|
`,
|
||||||
@@ -823,6 +1044,10 @@ export async function crmConfigRoute(ctx: RequestContext) {
|
|||||||
`
|
`
|
||||||
select id, enabled, url, secret_ref as "secretRef", form_name as "formName",
|
select id, enabled, url, secret_ref as "secretRef", form_name as "formName",
|
||||||
exam_type as "examType", timeout_sec as "timeoutSec", delay_sec as "delaySec",
|
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"
|
created_at as "createdAt", updated_at as "updatedAt"
|
||||||
from public.crm_config
|
from public.crm_config
|
||||||
where tenant_id = $1
|
where tenant_id = $1
|
||||||
@@ -838,8 +1063,12 @@ export async function upsertCrmConfigRoute(ctx: RequestContext) {
|
|||||||
requireTenantPermission(auth, 'crm:write');
|
requireTenantPermission(auth, 'crm:write');
|
||||||
const body = await readJsonBody(ctx);
|
const body = await readJsonBody(ctx);
|
||||||
const secretRef = optionalString(body, 'secretRef') || (body.secret ? 'app_private.tenant_secrets:crm:webhook' : null);
|
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 item = await transaction(async client => {
|
||||||
|
const validAssignmentPool = await validateCrmAssignmentPool(client, auth.tenantId, assignmentPool, assignmentMode);
|
||||||
if (body.secret && typeof body.secret === 'string') {
|
if (body.secret && typeof body.secret === 'string') {
|
||||||
await client.query(
|
await client.query(
|
||||||
`
|
`
|
||||||
@@ -858,9 +1087,10 @@ export async function upsertCrmConfigRoute(ctx: RequestContext) {
|
|||||||
const result = await client.query(
|
const result = await client.query(
|
||||||
`
|
`
|
||||||
insert into public.crm_config (
|
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)
|
on conflict (tenant_id)
|
||||||
do update set enabled = excluded.enabled,
|
do update set enabled = excluded.enabled,
|
||||||
url = excluded.url,
|
url = excluded.url,
|
||||||
@@ -869,9 +1099,14 @@ export async function upsertCrmConfigRoute(ctx: RequestContext) {
|
|||||||
exam_type = excluded.exam_type,
|
exam_type = excluded.exam_type,
|
||||||
timeout_sec = excluded.timeout_sec,
|
timeout_sec = excluded.timeout_sec,
|
||||||
delay_sec = excluded.delay_sec,
|
delay_sec = excluded.delay_sec,
|
||||||
|
assignment_mode = excluded.assignment_mode,
|
||||||
|
assignment_pool = excluded.assignment_pool,
|
||||||
|
assignment_config = excluded.assignment_config,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
returning id, enabled, url, secret_ref as "secretRef", form_name as "formName",
|
returning id, enabled, url, secret_ref as "secretRef", form_name as "formName",
|
||||||
exam_type as "examType", timeout_sec as "timeoutSec", delay_sec as "delaySec",
|
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"
|
updated_at as "updatedAt"
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
@@ -883,6 +1118,21 @@ export async function upsertCrmConfigRoute(ctx: RequestContext) {
|
|||||||
optionalString(body, 'examType') || '成人本科',
|
optionalString(body, 'examType') || '成人本科',
|
||||||
Number(body.timeoutSec || 10),
|
Number(body.timeoutSec || 10),
|
||||||
Number(body.delaySec || 60),
|
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];
|
return result.rows[0];
|
||||||
|
|||||||
@@ -62,7 +62,17 @@ export default function TenantMarketingPage() {
|
|||||||
const [commissionOrders, setCommissionOrders] = useState<CommissionOrderItem[]>([]);
|
const [commissionOrders, setCommissionOrders] = useState<CommissionOrderItem[]>([]);
|
||||||
const [settlements, setSettlements] = useState<CommissionSettlementItem[]>([]);
|
const [settlements, setSettlements] = useState<CommissionSettlementItem[]>([]);
|
||||||
const [members, setMembers] = useState<TenantMemberItem[]>([]);
|
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 [commissionForm, setCommissionForm] = useState({ defaultRatePercent: '20', minSettlementYuan: '0', settlementCycle: 'monthly' });
|
||||||
const [period, setPeriod] = useState({ startDate: monthStart(), endDate: today(), referrerUserId: '' });
|
const [period, setPeriod] = useState({ startDate: monthStart(), endDate: today(), referrerUserId: '' });
|
||||||
const [memberRate, setMemberRate] = useState({ userId: '', ratePercent: '' });
|
const [memberRate, setMemberRate] = useState({ userId: '', ratePercent: '' });
|
||||||
@@ -112,6 +122,8 @@ export default function TenantMarketingPage() {
|
|||||||
examType: nextCrm.examType || '专升本',
|
examType: nextCrm.examType || '专升本',
|
||||||
timeoutSec: String(nextCrm.timeoutSec || 10),
|
timeoutSec: String(nextCrm.timeoutSec || 10),
|
||||||
delaySec: String(nextCrm.delaySec || 60),
|
delaySec: String(nextCrm.delaySec || 60),
|
||||||
|
assignmentMode: nextCrm.assignmentMode || 'none',
|
||||||
|
assignmentPoolText: (nextCrm.assignmentPool || []).join(','),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (nextSettings) {
|
if (nextSettings) {
|
||||||
@@ -142,6 +154,8 @@ export default function TenantMarketingPage() {
|
|||||||
examType: crmForm.examType.trim() || '专升本',
|
examType: crmForm.examType.trim() || '专升本',
|
||||||
timeoutSec: Math.max(1, Math.trunc(Number(crmForm.timeoutSec || 10))),
|
timeoutSec: Math.max(1, Math.trunc(Number(crmForm.timeoutSec || 10))),
|
||||||
delaySec: Math.max(0, Math.trunc(Number(crmForm.delaySec || 60))),
|
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);
|
setCrmConfig(result.item || null);
|
||||||
Taro.showToast({ title: 'CRM 已保存', icon: 'success' });
|
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' 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.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 || '') }))} />
|
<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>
|
||||||
<View className='admin-actions compact'>
|
<View className='admin-actions compact'>
|
||||||
<Button className='admin-button primary' loading={busy === 'crm'} onClick={saveCrmConfig}>保存 CRM</Button>
|
<Button className='admin-button primary' loading={busy === 'crm'} onClick={saveCrmConfig}>保存 CRM</Button>
|
||||||
@@ -312,6 +338,15 @@ export default function TenantMarketingPage() {
|
|||||||
<View className='admin-row'>
|
<View className='admin-row'>
|
||||||
<Text className='admin-row-main'>{crmConfig?.enabled ? 'CRM 已启用' : 'CRM 未启用'}</Text>
|
<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?.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>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@@ -421,6 +421,10 @@ export interface CrmConfigItem {
|
|||||||
examType?: string | null;
|
examType?: string | null;
|
||||||
timeoutSec?: number;
|
timeoutSec?: number;
|
||||||
delaySec?: number;
|
delaySec?: number;
|
||||||
|
assignmentMode?: 'none' | 'direct' | 'round_robin' | 'referrer';
|
||||||
|
assignmentPool?: string[];
|
||||||
|
assignmentCursor?: number;
|
||||||
|
assignmentConfig?: Record<string, unknown>;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1017,6 +1021,9 @@ export async function upsertCrmConfig(input: {
|
|||||||
examType?: string | null;
|
examType?: string | null;
|
||||||
timeoutSec?: number;
|
timeoutSec?: number;
|
||||||
delaySec?: number;
|
delaySec?: number;
|
||||||
|
assignmentMode?: 'none' | 'direct' | 'round_robin' | 'referrer';
|
||||||
|
assignmentPool?: string[];
|
||||||
|
assignmentConfig?: Record<string, unknown>;
|
||||||
}) {
|
}) {
|
||||||
return apiRequest<{ item?: CrmConfigItem }>('/api/crm/config', {
|
return apiRequest<{ item?: CrmConfigItem }>('/api/crm/config', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|||||||
@@ -153,9 +153,9 @@
|
|||||||
| 首绑客资保护 | 可联调 | `/api/referral/bind` |
|
| 首绑客资保护 | 可联调 | `/api/referral/bind` |
|
||||||
| 手工补绑 | 可联调 | 需要 `referral:write` |
|
| 手工补绑 | 可联调 | 需要 `referral:write` |
|
||||||
| 销售统计/客户列表/团队 | 可联调 | `/api/referral/sales-*`、`team` |
|
| 销售统计/客户列表/团队 | 可联调 | `/api/referral/sales-*`、`team` |
|
||||||
| CRM 配置/队列 | 可联调 | `/api/crm/config`、`/api/crm/queue` |
|
| CRM 配置/队列 | 可联调 | `/api/crm/config`、`/api/crm/queue`;CRM 配置支持 `none/direct/round_robin/referrer` 客资跟进分配策略、租户内候选成员校验、轮询游标、分配审计,客资首绑成功后会写入 `assignedToUserId` 并进入队列 payload |
|
||||||
| CRM webhook worker | 可联调 | `apps/worker` 已支持 generic webhook、钉钉、飞书、企微群机器人消息体/签名、到期任务消费、失败退避重试、最终失败、discarded 和 `crm_webhook_log` |
|
| CRM webhook worker | 可联调 | `apps/worker` 已支持 generic webhook、钉钉、飞书、企微群机器人消息体/签名、到期任务消费、失败退避重试、最终失败、discarded 和 `crm_webhook_log` |
|
||||||
| CRM 增强 | 待补齐 | 轮询/定向分配策略、富卡片模板、失败告警、死信运营后台和批量 CRM 推送 |
|
| CRM 增强 | 待补齐 | 富卡片模板、失败告警、死信运营后台、批量 CRM 推送和更细销售转化看板 |
|
||||||
| 分佣结算基础闭环 | 可联调 | `/api/commission/settings`、`member-rate`、`summary`、`orders`、`settlements`、`settlements/generate`、`settlements/status`;支持订单/激活码归因、批次/成员/默认比例优先级、北京时间账期、结算单生成、审核、打款状态、已打款锁定、销售/代理本人范围和租户隔离 |
|
| 分佣结算基础闭环 | 可联调 | `/api/commission/settings`、`member-rate`、`summary`、`orders`、`settlements`、`settlements/generate`、`settlements/status`;支持订单/激活码归因、批次/成员/默认比例优先级、北京时间账期、结算单生成、审核、打款状态、已打款锁定、销售/代理本人范围和租户隔离 |
|
||||||
| 分佣打款增强 | 待补齐 | 银行/微信/支付宝真实打款、结算导出、发票/凭证、财务复核和分佣看板 |
|
| 分佣打款增强 | 待补齐 | 银行/微信/支付宝真实打款、结算导出、发票/凭证、财务复核和分佣看板 |
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
| 知识手册维护 | 已覆盖 | subject/chapter/entry CRUD 和导入已有 |
|
| 知识手册维护 | 已覆盖 | subject/chapter/entry CRUD 和导入已有 |
|
||||||
| 分数线维护 | 已覆盖 | 字段/院校/专业/记录 CRUD 和 JSON 批量导入已有 |
|
| 分数线维护 | 已覆盖 | 字段/院校/专业/记录 CRUD 和 JSON 批量导入已有 |
|
||||||
| 视频维护/绑定 | 已覆盖 | video CRUD、question-video 绑定和视频 JSON 批量导入已有 |
|
| 视频维护/绑定 | 已覆盖 | video CRUD、question-video 绑定和视频 JSON 批量导入已有 |
|
||||||
| CRM 配置和队列 | 部分覆盖 | 配置/队列已有;钉钉/飞书/企微真实发送 worker、签名、重试、死信待补 |
|
| CRM 配置和队列 | 部分覆盖 | 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、钉钉/飞书/企微 worker、签名和重试已有;富卡片模板、失败告警、死信运营台和批量推送待补 |
|
||||||
| 对象存储配置 | 部分覆盖 | 系统 env provider、上传签名、上传确认、预览下载签名、资源访问审计、水印 traceId、内置安全扫描和外部 HTTP scanner 接入层已有;租户级存储策略、CDN/转码级水印/真实 AV 服务联调待补 |
|
| 对象存储配置 | 部分覆盖 | 系统 env provider、上传签名、上传确认、预览下载签名、资源访问审计、水印 traceId、内置安全扫描和外部 HTTP scanner 接入层已有;租户级存储策略、CDN/转码级水印/真实 AV 服务联调待补 |
|
||||||
|
|
||||||
## 平台 SaaS 后台功能
|
## 平台 SaaS 后台功能
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
4. 题库导出:服务端 JSON/试卷 payload、PDF/Word 二进制生成、水印、资料发布路径、权限审计、答案脱敏、每日一练基础导出和图片 ZIP 素材包已补;仍缺更精细试卷模板和后台导出操作台体验。
|
4. 题库导出:服务端 JSON/试卷 payload、PDF/Word 二进制生成、水印、资料发布路径、权限审计、答案脱敏、每日一练基础导出和图片 ZIP 素材包已补;仍缺更精细试卷模板和后台导出操作台体验。
|
||||||
5. 导入扩展:题目/单词/知识手册/分数线/视频已支持 JSON、CSV 和 Excel 预览导入,并可用 `executionMode=async` 进入 imports worker;导入后复检、导入任务详情、模板下载按钮、字段映射 API、Taro 字段别名编辑、异步轮询和 PocketBase JSON dry-run 报告已补,仍缺真实数据执行验收。
|
5. 导入扩展:题目/单词/知识手册/分数线/视频已支持 JSON、CSV 和 Excel 预览导入,并可用 `executionMode=async` 进入 imports worker;导入后复检、导入任务详情、模板下载按钮、字段映射 API、Taro 字段别名编辑、异步轮询和 PocketBase JSON dry-run 报告已补,仍缺真实数据执行验收。
|
||||||
6. 公共题库商业化:平台公共/地区题库授权、租户快照采纳、手动同步、自动同步 worker、同步通知、冲突查询、租户自改冲突保护和单条/批量冲突处理已完成基础闭环;还需生产定时调度、失败告警和更完整运营后台消息。
|
6. 公共题库商业化:平台公共/地区题库授权、租户快照采纳、手动同步、自动同步 worker、同步通知、冲突查询、租户自改冲突保护和单条/批量冲突处理已完成基础闭环;还需生产定时调度、失败告警和更完整运营后台消息。
|
||||||
7. CRM/销售结算:CRM worker、分佣规则、结算单、审核和打款状态基础闭环已完成;仍缺轮询/定向分配、打款导出、凭证和销售结算看板。
|
7. CRM/销售结算:CRM worker、跟进分配策略、分佣规则、结算单、审核和打款状态基础闭环已完成;仍缺富卡片模板、失败告警、死信运营台、打款导出、凭证和销售结算看板。
|
||||||
8. 题目反馈增强:处理通知、消息提醒、问题聚合统计和内容修复闭环。
|
8. 题目反馈增强:处理通知、消息提醒、问题聚合统计和内容修复闭环。
|
||||||
9. 积分活动增强:积分兑换、活动任务、连续签到奖励规则和风控。
|
9. 积分活动增强:积分兑换、活动任务、连续签到奖励规则和风控。
|
||||||
10. 勋章增强:后台维护和手动发放已有;仍缺按学习行为、签到、积分、活动任务自动发放,以及发放通知。
|
10. 勋章增强:后台维护和手动发放已有;仍缺按学习行为、签到、积分、活动任务自动发放,以及发放通知。
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
- 租户后台 API:品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、考试日期、题目反馈处理、激活码、优惠券、勋章管理/发放、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。
|
- 租户后台 API:品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、考试日期、题目反馈处理、激活码、优惠券、勋章管理/发放、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。
|
||||||
- 租户主题系统:平台默认经典蓝、专注绿、高对比三套模板,租户可保存草稿、发布主题,公开租户解析只返回已发布主题,Taro 租户设置页已接第一版主题操作台。
|
- 租户主题系统:平台默认经典蓝、专注绿、高对比三套模板,租户可保存草稿、发布主题,公开租户解析只返回已发布主题,Taro 租户设置页已接第一版主题操作台。
|
||||||
- 平台后台 API:租户、SaaS 套餐、订阅、账单、服务费收款、用量。
|
- 平台后台 API:租户、SaaS 套餐、订阅、账单、服务费收款、用量。
|
||||||
- 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 配置/队列、CRM worker、分佣规则、成员比例、订单/激活码归因、结算生成、审核和打款状态;Taro 租户营销中心已接第一版操作台。
|
- 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、CRM worker、分佣规则、成员比例、订单/激活码归因、结算生成、审核和打款状态;Taro 租户营销中心已接第一版操作台。
|
||||||
- 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。
|
- 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。
|
||||||
- 练习组卷:`question_collections/practice_blueprints` 支持顺序、随机、全真模拟快照。
|
- 练习组卷:`question_collections/practice_blueprints` 支持顺序、随机、全真模拟快照。
|
||||||
- 模考报告与学习统计:`practice_session_reports/practice_session_report_sections` 支持交卷、评分、题型/小节统计、错题解析汇总和历史查询;`/api/learning/stats`、`trend`、`practice-sessions/history`、`wrong-questions/review-plan` 可支撑个人中心和学习报告基础页。
|
- 模考报告与学习统计:`practice_session_reports/practice_session_report_sections` 支持交卷、评分、题型/小节统计、错题解析汇总和历史查询;`/api/learning/stats`、`trend`、`practice-sessions/history`、`wrong-questions/review-plan` 可支撑个人中心和学习报告基础页。
|
||||||
@@ -160,7 +160,8 @@
|
|||||||
|
|
||||||
3. CRM worker
|
3. CRM worker
|
||||||
- 已完成 `apps/worker` CRM 队列消费、generic webhook、钉钉、飞书、企业微信机器人 adapter、签名、失败重试和日志。
|
- 已完成 `apps/worker` CRM 队列消费、generic webhook、钉钉、飞书、企业微信机器人 adapter、签名、失败重试和日志。
|
||||||
- 继续补轮询/定向分配、富卡片模板、失败告警、死信运营后台和批量 CRM 推送。
|
- 已完成 CRM 配置中的跟进分配策略:`none/direct/round_robin/referrer`,后端校验候选人必须是当前租户内 active 的销售/代理/运营/教师/管理员,首绑客资后写入 `assignedToUserId` 并把 assignee 放进 CRM 队列 payload。
|
||||||
|
- 继续补富卡片模板、失败告警、死信运营后台和批量 CRM 推送。
|
||||||
|
|
||||||
4. 运维
|
4. 运维
|
||||||
- 后台操作审计报表。
|
- 后台操作审计报表。
|
||||||
@@ -224,7 +225,7 @@
|
|||||||
|
|
||||||
## 推荐下一步顺序
|
## 推荐下一步顺序
|
||||||
|
|
||||||
1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条/批量冲突采纳平台或保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询、导入后复检详情、权限驱动工作台、学生创建/更新/批量导入/批量分班/备注/跟进、角色模板配置、成员绑定模板、CRM 配置/队列、分佣规则/成员比例/结算生成审核打款已接第一版;继续补成员批量运营、更细数据范围 UI、真实打款/导出/凭证。
|
1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条/批量冲突采纳平台或保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询、导入后复检详情、权限驱动工作台、学生创建/更新/批量导入/批量分班/备注/跟进、角色模板配置、成员绑定模板、CRM 配置/队列/跟进分配策略、分佣规则/成员比例/结算生成审核打款已接第一版;继续补成员批量运营、更细数据范围 UI、真实打款/导出/凭证。
|
||||||
2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、错题/收藏专题、收银台、订单详情和售后入口已接第一版;继续补长题干排版、公式图片混排、小程序支付容器、分享场景和状态管理。
|
2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、错题/收藏专题、收银台、订单详情和售后入口已接第一版;继续补长题干排版、公式图片混排、小程序支付容器、分享场景和状态管理。
|
||||||
3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。
|
3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。
|
||||||
4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
||||||
|
|||||||
@@ -2122,7 +2122,18 @@ npm run build:taro:h5:platform
|
|||||||
- 勋章:`GET/PUT /api/tenant-admin/badges`、`GET/POST /api/tenant-admin/badge-grants`
|
- 勋章:`GET/PUT /api/tenant-admin/badges`、`GET/POST /api/tenant-admin/badge-grants`
|
||||||
- 考试日期:`GET/PUT /api/tenant-admin/exam-dates`
|
- 考试日期:`GET/PUT /api/tenant-admin/exam-dates`
|
||||||
- 题目反馈:`GET /api/tenant-admin/feedbacks`、`POST /api/tenant-admin/feedbacks/status`、`GET /api/tenant-admin/feedbacks/events`
|
- 题目反馈:`GET /api/tenant-admin/feedbacks`、`POST /api/tenant-admin/feedbacks/status`、`GET /api/tenant-admin/feedbacks/events`
|
||||||
- 销售/代理/CRM 队列、CRM 配置、分佣规则、成员分佣比例、分佣订单、结算单审核和线下打款登记;当前 Taro 租户营销中心已接第一版,真实打款 provider、结算导出、凭证和财务复核后续增强。
|
- 销售/代理/CRM 队列、CRM 配置、CRM 跟进分配策略、分佣规则、成员分佣比例、分佣订单、结算单审核和线下打款登记;当前 Taro 租户营销中心已接第一版,真实打款 provider、结算导出、凭证和财务复核后续增强。
|
||||||
|
|
||||||
|
CRM 分配策略由后端执行,前端只提交配置:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"assignmentMode": "round_robin",
|
||||||
|
"assignmentPool": ["<salesUserId>", "<agentUserId>"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`assignmentMode` 支持 `none`、`direct`、`round_robin`、`referrer`。后端会校验 `assignmentPool` 中的用户必须是当前租户内 active 的销售、代理、运营、教师或管理员;跨租户成员会返回 `CRM_ASSIGNMENT_POOL_INVALID`。首绑客资成功后,`lead.item.assignedToUserId` 是 CRM 跟进负责人,`lead.item.referrerUserId` 仍是受首绑保护的推广/分佣归属,两者不要在前端混用。CRM 队列 `payload.assignee` 可用于展示推送目标,但前端不能自行改写客资归属或分配游标。
|
||||||
|
|
||||||
租户后台不应在前端自行决定权限;隐藏菜单只是体验优化,接口仍会校验权限。角色模板用于让租户配置“运营、教师、销售、代理”等自定义后台体验,成员绑定模板后,前端按模板的菜单/模块/字段权限渲染,后端按 permission keys 执行真正的访问控制。班级/学生范围权限由后端根据角色、模板 `dataScope.classIds` 和 `tenant_class_members` 计算,教师默认只能看到自己负责班级。
|
租户后台不应在前端自行决定权限;隐藏菜单只是体验优化,接口仍会校验权限。角色模板用于让租户配置“运营、教师、销售、代理”等自定义后台体验,成员绑定模板后,前端按模板的菜单/模块/字段权限渲染,后端按 permission keys 执行真正的访问控制。班级/学生范围权限由后端根据角色、模板 `dataScope.classIds` 和 `tenant_class_members` 计算,教师默认只能看到自己负责班级。
|
||||||
|
|
||||||
|
|||||||
@@ -6789,11 +6789,28 @@ async function testReferralAndCrmGrowth() {
|
|||||||
formName: '集成测试客资',
|
formName: '集成测试客资',
|
||||||
examType: '专升本',
|
examType: '专升本',
|
||||||
delaySec: 1,
|
delaySec: 1,
|
||||||
|
assignmentMode: 'round_robin',
|
||||||
|
assignmentPool: [TENANT_SALES_USER_ID, TENANT_AGENT_USER_ID],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.equal(crmConfig.item?.enabled, true, 'tenant admin should enable CRM config');
|
assert.equal(crmConfig.item?.enabled, true, 'tenant admin should enable CRM config');
|
||||||
|
assert.equal(crmConfig.item?.assignmentMode, 'round_robin', 'CRM config should persist lead assignment mode');
|
||||||
|
assert.deepEqual(crmConfig.item?.assignmentPool, [TENANT_SALES_USER_ID, TENANT_AGENT_USER_ID], 'CRM config should persist assignment pool');
|
||||||
assert.ok(!JSON.stringify(crmConfig).includes('crm-secret-smoke'), 'CRM config response should not leak secret');
|
assert.ok(!JSON.stringify(crmConfig).includes('crm-secret-smoke'), 'CRM config response should not leak secret');
|
||||||
|
|
||||||
|
const invalidCrmAssignment = await request('/api/crm/config', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'PUT',
|
||||||
|
body: {
|
||||||
|
enabled: true,
|
||||||
|
url: 'https://crm.example.test/webhook',
|
||||||
|
assignmentMode: 'direct',
|
||||||
|
assignmentPool: [PARTNER_TENANT_ADMIN_USER_ID],
|
||||||
|
},
|
||||||
|
expectStatus: 400,
|
||||||
|
});
|
||||||
|
assert.equal(invalidCrmAssignment.code, 'CRM_ASSIGNMENT_POOL_INVALID', 'CRM assignment pool must reject cross-tenant users');
|
||||||
|
|
||||||
const tracked = await request('/api/referral/track-event', {
|
const tracked = await request('/api/referral/track-event', {
|
||||||
userId: false,
|
userId: false,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -6808,6 +6825,8 @@ async function testReferralAndCrmGrowth() {
|
|||||||
assert.equal(tracked.item?.refUserId, TENANT_SALES_USER_ID, 'tracking event should resolve referrer');
|
assert.equal(tracked.item?.refUserId, TENANT_SALES_USER_ID, 'tracking event should resolve referrer');
|
||||||
assert.equal(tracked.lead?.bound, true, 'first referral event should bind lead');
|
assert.equal(tracked.lead?.bound, true, 'first referral event should bind lead');
|
||||||
assert.equal(tracked.lead?.item?.referrerUserId, TENANT_SALES_USER_ID, 'lead should be bound to sales user');
|
assert.equal(tracked.lead?.item?.referrerUserId, TENANT_SALES_USER_ID, 'lead should be bound to sales user');
|
||||||
|
assert.equal(tracked.lead?.item?.assignedToUserId, TENANT_SALES_USER_ID, 'round-robin should assign first lead to first sales member');
|
||||||
|
assert.equal(tracked.lead?.assignment?.userId, TENANT_SALES_USER_ID, 'track response should expose assignment assignee');
|
||||||
assert.ok(tracked.crmQueue?.id, 'first lead binding should enqueue CRM task');
|
assert.ok(tracked.crmQueue?.id, 'first lead binding should enqueue CRM task');
|
||||||
|
|
||||||
const secondBind = await request('/api/referral/bind', {
|
const secondBind = await request('/api/referral/bind', {
|
||||||
@@ -6853,6 +6872,7 @@ async function testReferralAndCrmGrowth() {
|
|||||||
});
|
});
|
||||||
assert.equal(manual.lead?.bound, true, 'tenant admin should be able to force manual bind');
|
assert.equal(manual.lead?.bound, true, 'tenant admin should be able to force manual bind');
|
||||||
assert.equal(manual.lead?.item?.referrerUserId, TENANT_AGENT_USER_ID, 'manual bind should move lead to agent');
|
assert.equal(manual.lead?.item?.referrerUserId, TENANT_AGENT_USER_ID, 'manual bind should move lead to agent');
|
||||||
|
assert.equal(manual.lead?.item?.assignedToUserId, TENANT_AGENT_USER_ID, 'round-robin should assign second lead to next pool member');
|
||||||
|
|
||||||
const qrcode = await request('/api/referral/qrcode', {
|
const qrcode = await request('/api/referral/qrcode', {
|
||||||
userId: TENANT_AGENT_USER_ID,
|
userId: TENANT_AGENT_USER_ID,
|
||||||
@@ -7082,6 +7102,7 @@ async function testReferralAndCrmGrowth() {
|
|||||||
query: { status: 'pending' },
|
query: { status: 'pending' },
|
||||||
});
|
});
|
||||||
assert.ok(crmQueue.items?.some(item => item.leadId === manual.lead.item.id || item.leadId === tracked.lead.item.id), 'CRM queue should include referral lead task');
|
assert.ok(crmQueue.items?.some(item => item.leadId === manual.lead.item.id || item.leadId === tracked.lead.item.id), 'CRM queue should include referral lead task');
|
||||||
|
assert.ok(crmQueue.items?.some(item => item.payload?.assignee?.id === TENANT_AGENT_USER_ID), 'CRM queue payload should include assigned follow-up user');
|
||||||
assert.ok(!JSON.stringify(crmQueue).includes('crm-secret-smoke'), 'CRM queue should not leak secret');
|
assert.ok(!JSON.stringify(crmQueue).includes('crm-secret-smoke'), 'CRM queue should not leak secret');
|
||||||
|
|
||||||
const partnerReferralDenied = await request('/api/referral/sales-stats', {
|
const partnerReferralDenied = await request('/api/referral/sales-stats', {
|
||||||
|
|||||||
30
supabase/migrations/202606290026_crm_lead_assignment.sql
Normal file
30
supabase/migrations/202606290026_crm_lead_assignment.sql
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
alter table public.crm_config
|
||||||
|
add column if not exists assignment_mode text not null default 'none',
|
||||||
|
add column if not exists assignment_pool jsonb not null default '[]'::jsonb,
|
||||||
|
add column if not exists assignment_cursor integer not null default 0,
|
||||||
|
add column if not exists assignment_config jsonb not null default '{}'::jsonb;
|
||||||
|
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
if not exists (
|
||||||
|
select 1
|
||||||
|
from pg_constraint
|
||||||
|
where conname = 'crm_config_assignment_mode_check'
|
||||||
|
) then
|
||||||
|
alter table public.crm_config
|
||||||
|
add constraint crm_config_assignment_mode_check
|
||||||
|
check (assignment_mode in ('none', 'direct', 'round_robin', 'referrer'));
|
||||||
|
end if;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
alter table public.referral_leads
|
||||||
|
add column if not exists assigned_to_user_id uuid references public.platform_users(id) on delete set null,
|
||||||
|
add column if not exists assignment_mode text,
|
||||||
|
add column if not exists assigned_at timestamptz,
|
||||||
|
add column if not exists assigned_by uuid references public.platform_users(id) on delete set null;
|
||||||
|
|
||||||
|
create index if not exists idx_referral_leads_assignee
|
||||||
|
on public.referral_leads(tenant_id, assigned_to_user_id, status, bound_at desc);
|
||||||
|
|
||||||
|
create index if not exists idx_crm_config_assignment_mode
|
||||||
|
on public.crm_config(tenant_id, assignment_mode);
|
||||||
Reference in New Issue
Block a user