feat: add commission settlement workflow

This commit is contained in:
Codex
2026-06-29 04:18:17 +08:00
parent 0bca1f00a9
commit 0cff0d102d
13 changed files with 1271 additions and 12 deletions

View File

@@ -17,6 +17,7 @@
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、考试倒计时、签到积分、题目反馈、排行榜、分数线、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载。
- 平台后台能力租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录。
- 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置和队列。
- 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态和权限隔离。
- PocketBase schema/数据导入器雏形和导入后校验脚本。
- 本地 Supabase reset、烟测 seed、API 集成测试、完整重构检查命令。
@@ -26,6 +27,7 @@
- 阿里云/腾讯云短信、微信小程序登录、微信支付、支付宝主链路已完成本地适配微信网页登录、QQ 登录、手机号换绑、退款/对账、支付补偿和真实生产账号联调还没接完。
- OSS/COS/Supabase Storage 上传下载签名 provider 已接入上传后校验、PDF 预览、防盗链和视频水印还没完成。
- Excel/CSV 导入、分数线/视频批量导入和异步 worker 还没完成。
- 分佣真实打款、结算导出、发票/凭证、CRM webhook worker 和销售转化看板还没完成。
- Taro 跨端前端还没开始 scaffold。
- 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。
@@ -136,6 +138,8 @@ apps/api/src/features/
platform-admin/ 平台方租户、SaaS 套餐、订阅、账单、用量
profile/ 学生个人中心
referral/ 销售/代理客资追踪、CRM 队列
referral/commission.ts
分佣设置、汇总、来源明细、结算单、审核/打款状态
scoreline/ 分数线
tenant/ 租户解析
tenant-admin/ 租户后台配置、成员权限、班级学生、活动和审计

View File

@@ -0,0 +1,782 @@
import type pg from 'pg';
import { HttpError, type RequestContext } from '../../core/http.js';
import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js';
import { query, queryOne, transaction } from '../../core/db.js';
import { hasTenantPermission, requireTenantAdmin, requireTenantPermission, type TenantAdminAuth } from '../tenant-admin/auth.js';
const SETTLEMENT_STATUSES = ['draft', 'pending_review', 'approved', 'paid', 'rejected', 'cancelled'];
const RATE_SOURCE_ORDER: Record<string, number> = { batch: 1, member: 2, default: 3 };
type JsonBody = Record<string, unknown>;
function nullableString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function intValue(value: unknown, fallback = 0) {
const parsed = Number(value ?? fallback);
return Number.isFinite(parsed) ? Math.trunc(parsed) : fallback;
}
function numberValue(value: unknown, fallback = 0) {
const parsed = Number(value ?? fallback);
return Number.isFinite(parsed) ? parsed : fallback;
}
function optionalChoice(value: unknown, allowed: string[], fallback: string) {
const candidate = nullableString(value) || fallback;
if (!allowed.includes(candidate)) {
throw new HttpError(400, `Invalid value: ${candidate}`, 'INVALID_FIELD_VALUE');
}
return candidate;
}
function rateValue(value: unknown, fallback = 0.2) {
const rate = numberValue(value, fallback);
if (rate < 0 || rate > 1) {
throw new HttpError(400, 'Commission rate must be between 0 and 1', 'INVALID_COMMISSION_RATE');
}
return Number(rate.toFixed(4));
}
function dateValue(value: string, fallback: string) {
const candidate = value || fallback;
if (!/^\d{4}-\d{2}-\d{2}$/.test(candidate)) {
throw new HttpError(400, 'Date must use YYYY-MM-DD format', 'INVALID_DATE');
}
return candidate;
}
function shanghaiDateKey(date: Date) {
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const parts = Object.fromEntries(formatter.formatToParts(date).map(part => [part.type, part.value]));
return `${parts.year}-${parts.month}-${parts.day}`;
}
function periodFromParams(ctx: RequestContext) {
const today = shanghaiDateKey(new Date());
const startDate = dateValue(stringParam(ctx, 'startDate'), today.slice(0, 8) + '01');
const endDate = dateValue(stringParam(ctx, 'endDate'), today);
if (startDate > endDate) {
throw new HttpError(400, 'startDate must be before or equal to endDate', 'INVALID_DATE_RANGE');
}
return { startDate, endDate };
}
function canViewAllReferral(auth: TenantAdminAuth) {
return hasTenantPermission(auth, 'commission:read');
}
function canViewSelfReferral(auth: TenantAdminAuth) {
return hasTenantPermission(auth, 'commission:self') || canViewAllReferral(auth);
}
function canWriteCommission(auth: TenantAdminAuth) {
return hasTenantPermission(auth, 'commission:write');
}
function canReviewCommission(auth: TenantAdminAuth) {
return hasTenantPermission(auth, 'commission:review');
}
function requireCommissionRead(auth: TenantAdminAuth) {
if (!canViewSelfReferral(auth)) {
throw new HttpError(403, 'Commission access is required', 'COMMISSION_ACCESS_REQUIRED');
}
}
function requireCommissionWrite(auth: TenantAdminAuth) {
if (!canWriteCommission(auth)) {
throw new HttpError(403, 'Commission write permission is required', 'TENANT_PERMISSION_REQUIRED');
}
}
function assertSettlementTransition(currentStatus: string, nextStatus: string) {
if (currentStatus === 'paid') {
throw new HttpError(409, 'Paid commission settlements are immutable', 'COMMISSION_SETTLEMENT_LOCKED');
}
if (nextStatus === 'paid' && currentStatus !== 'approved') {
throw new HttpError(409, 'Settlement must be approved before marking paid', 'COMMISSION_SETTLEMENT_NOT_APPROVED');
}
if (currentStatus === 'approved' && !['paid', 'cancelled'].includes(nextStatus)) {
throw new HttpError(409, 'Approved settlement can only be paid or cancelled', 'INVALID_SETTLEMENT_TRANSITION');
}
if (['rejected', 'cancelled'].includes(currentStatus) && currentStatus !== nextStatus) {
throw new HttpError(409, 'Closed commission settlement cannot change status', 'COMMISSION_SETTLEMENT_CLOSED');
}
}
function restrictReferrerScope(auth: TenantAdminAuth, requestedReferrerId: string | null) {
if (canViewAllReferral(auth)) return requestedReferrerId;
if (requestedReferrerId && requestedReferrerId !== auth.userId) {
throw new HttpError(403, 'Only own commission data can be viewed', 'COMMISSION_SCOPE_REQUIRED');
}
return auth.userId;
}
function rowToCommissionItem(row: Record<string, unknown>) {
const grossAmountCents = intValue(row.grossAmountCents);
const commissionAmountCents = intValue(row.commissionAmountCents);
const commissionRate = numberValue(row.commissionRate);
return {
sourceType: String(row.sourceType),
sourceId: String(row.sourceId),
sourceNo: row.sourceNo ? String(row.sourceNo) : null,
sourcePaidAt: row.sourcePaidAt,
referrerUserId: String(row.referrerUserId),
referrerName: row.referrerName ? String(row.referrerName) : null,
referrerPhone: row.referrerPhone ? String(row.referrerPhone) : null,
studentUserId: row.studentUserId ? String(row.studentUserId) : null,
studentName: row.studentName ? String(row.studentName) : null,
studentPhone: row.studentPhone ? String(row.studentPhone) : null,
grossAmountCents,
commissionRate,
commissionAmountCents,
rateSource: String(row.rateSource || 'default'),
attributionType: String(row.attributionType || 'protected_lead'),
settlementId: row.settlementId ? String(row.settlementId) : null,
settlementStatus: row.settlementStatus ? String(row.settlementStatus) : null,
};
}
function summarizeCommissionRows(rows: Record<string, unknown>[]) {
const seenUsers = new Set<string>();
const byReferrer = new Map<string, {
referrerUserId: string;
referrerName: string | null;
referrerPhone: string | null;
sourceCount: number;
paidUserCount: number;
grossAmountCents: number;
commissionAmountCents: number;
effectiveRate: number;
rateSource: string;
}>();
let grossAmountCents = 0;
let commissionAmountCents = 0;
for (const row of rows) {
const referrerUserId = String(row.referrerUserId);
const gross = intValue(row.grossAmountCents);
const commission = intValue(row.commissionAmountCents);
const studentUserId = row.studentUserId ? String(row.studentUserId) : '';
if (studentUserId) seenUsers.add(studentUserId);
grossAmountCents += gross;
commissionAmountCents += commission;
const current = byReferrer.get(referrerUserId) || {
referrerUserId,
referrerName: row.referrerName ? String(row.referrerName) : null,
referrerPhone: row.referrerPhone ? String(row.referrerPhone) : null,
sourceCount: 0,
paidUserCount: 0,
grossAmountCents: 0,
commissionAmountCents: 0,
effectiveRate: numberValue(row.commissionRate),
rateSource: String(row.rateSource || 'default'),
};
current.sourceCount += 1;
current.grossAmountCents += gross;
current.commissionAmountCents += commission;
if (RATE_SOURCE_ORDER[String(row.rateSource)] < RATE_SOURCE_ORDER[current.rateSource]) {
current.rateSource = String(row.rateSource);
current.effectiveRate = numberValue(row.commissionRate);
}
byReferrer.set(referrerUserId, current);
}
const paidUsersByReferrer = new Map<string, Set<string>>();
for (const row of rows) {
const referrerUserId = String(row.referrerUserId);
const studentUserId = row.studentUserId ? String(row.studentUserId) : '';
if (!studentUserId) continue;
const set = paidUsersByReferrer.get(referrerUserId) || new Set<string>();
set.add(studentUserId);
paidUsersByReferrer.set(referrerUserId, set);
}
return {
sourceCount: rows.length,
paidUserCount: seenUsers.size,
grossAmountCents,
commissionAmountCents,
byReferrer: Array.from(byReferrer.values()).map(item => ({
...item,
paidUserCount: paidUsersByReferrer.get(item.referrerUserId)?.size || 0,
effectiveRate: Number(item.effectiveRate.toFixed(4)),
})),
};
}
async function commissionSettings(tenantId: string) {
const item = await queryOne<{
defaultRate: string;
minSettlementCents: number;
settlementCycle: string;
config: Record<string, unknown>;
}>(
`
select default_rate as "defaultRate",
min_settlement_cents as "minSettlementCents",
settlement_cycle as "settlementCycle",
config
from public.tenant_commission_settings
where tenant_id = $1
limit 1
`,
[tenantId],
);
return {
defaultRate: numberValue(item?.defaultRate, 0.2),
minSettlementCents: intValue(item?.minSettlementCents),
settlementCycle: item?.settlementCycle || 'monthly',
config: item?.config || {},
};
}
async function commissionRows(
tenantId: string,
startDate: string,
endDate: string,
referrerUserId: string | null,
limit: number | null,
) {
const params: unknown[] = [tenantId, startDate, endDate, referrerUserId];
const limitSql = limit ? `limit $5` : '';
if (limit) params.push(limit);
return query<Record<string, unknown>>(
`
with settings as (
select coalesce(default_rate, 0.2000)::numeric(6,4) as default_rate
from public.tenant_commission_settings
where tenant_id = $1
union all
select 0.2000::numeric(6,4)
limit 1
),
order_sources as (
select 'order'::text as source_type,
o.id as source_id,
o.order_no as source_no,
coalesce(o.paid_at, o.updated_at, o.created_at) as source_paid_at,
o.user_id as student_user_id,
rl.referrer_user_id,
o.amount_cents::integer as gross_amount_cents,
null::numeric(6,4) as batch_rate,
o.raw_payload as metadata
from public.orders o
join public.referral_leads rl
on rl.tenant_id = o.tenant_id
and rl.student_user_id = o.user_id
and rl.status = 'protected'
where o.tenant_id = $1
and o.status = 'paid'
and o.user_id is not null
and coalesce(o.paid_at, o.updated_at, o.created_at) >= ($2::date::timestamp at time zone 'Asia/Shanghai')
and coalesce(o.paid_at, o.updated_at, o.created_at) < (($3::date + 1)::timestamp at time zone 'Asia/Shanghai')
and coalesce(o.paid_at, o.updated_at, o.created_at) >= rl.bound_at
),
code_sources as (
select 'activation_code'::text as source_type,
ac.id as source_id,
ac.code::text as source_no,
ac.used_at as source_paid_at,
ac.used_by as student_user_id,
rl.referrer_user_id,
greatest(coalesce(ac.unit_price_cents, cb.default_unit_price_cents, 0), 0)::integer as gross_amount_cents,
cb.commission_rate as batch_rate,
jsonb_build_object('saleType', ac.sale_type, 'batchId', ac.batch_id, 'days', ac.days) as metadata
from public.activation_codes ac
left join public.code_batches cb on cb.tenant_id = ac.tenant_id and cb.id = ac.batch_id
join public.referral_leads rl
on rl.tenant_id = ac.tenant_id
and rl.student_user_id = ac.used_by
and rl.status = 'protected'
where ac.tenant_id = $1
and ac.is_used is true
and ac.used_by is not null
and ac.used_at >= ($2::date::timestamp at time zone 'Asia/Shanghai')
and ac.used_at < (($3::date + 1)::timestamp at time zone 'Asia/Shanghai')
and ac.used_at >= rl.bound_at
),
sources as (
select * from order_sources
union all
select * from code_sources
),
referrer_members as (
select distinct on (tm.user_id)
tm.user_id,
tm.commission_rate,
tm.role
from public.tenant_memberships tm
where tm.tenant_id = $1
and tm.status = 'active'
and tm.role in ('tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent')
order by tm.user_id,
case tm.role
when 'sales' then 1
when 'agent' then 2
when 'tenant_operator' then 3
when 'teacher' then 4
else 9
end
)
select s.source_type as "sourceType",
s.source_id as "sourceId",
s.source_no as "sourceNo",
s.source_paid_at as "sourcePaidAt",
s.student_user_id as "studentUserId",
student.name as "studentName",
student.phone as "studentPhone",
s.referrer_user_id as "referrerUserId",
referrer.name as "referrerName",
referrer.phone as "referrerPhone",
s.gross_amount_cents as "grossAmountCents",
coalesce(s.batch_rate, rm.commission_rate, settings.default_rate)::numeric(6,4) as "commissionRate",
round(s.gross_amount_cents * coalesce(s.batch_rate, rm.commission_rate, settings.default_rate))::integer as "commissionAmountCents",
case
when s.batch_rate is not null then 'batch'
when rm.commission_rate is not null then 'member'
else 'default'
end as "rateSource",
'protected_lead'::text as "attributionType",
csi.settlement_id as "settlementId",
cs.status as "settlementStatus",
s.metadata
from sources s
cross join settings
left join referrer_members rm on rm.user_id = s.referrer_user_id
join public.platform_users referrer on referrer.id = s.referrer_user_id
left join public.platform_users student on student.id = s.student_user_id
left join public.commission_settlement_items csi
on csi.tenant_id = $1
and csi.source_type = s.source_type
and csi.source_id = s.source_id
left join public.commission_settlements cs
on cs.tenant_id = csi.tenant_id
and cs.id = csi.settlement_id
where s.gross_amount_cents > 0
and ($4::uuid is null or s.referrer_user_id = $4::uuid)
order by s.source_paid_at desc, s.source_no desc
${limitSql}
`,
params,
);
}
async function recordAudit(
client: pg.PoolClient,
auth: TenantAdminAuth,
action: string,
targetType: string,
targetId: string | null,
details: Record<string, unknown> = {},
) {
await client.query(
`
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
values ($1, $2, $3, $4, $5, $6::jsonb)
`,
[auth.tenantId, auth.userId, action, targetType, targetId, JSON.stringify(details)],
);
}
export async function commissionSettingsRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'commission:read');
const item = await commissionSettings(auth.tenantId);
return { item };
}
export async function updateCommissionSettingsRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'commission:write');
const body = await readJsonBody(ctx);
const defaultRate = rateValue(body.defaultRate, 0.2);
const minSettlementCents = Math.max(0, intValue(body.minSettlementCents, 0));
const settlementCycle = optionalChoice(body.settlementCycle, ['manual', 'weekly', 'monthly'], 'monthly');
const item = await transaction(async client => {
const result = await client.query(
`
insert into public.tenant_commission_settings (
tenant_id, default_rate, min_settlement_cents, settlement_cycle, config, updated_by
)
values ($1, $2, $3, $4, $5::jsonb, $6)
on conflict (tenant_id)
do update set default_rate = excluded.default_rate,
min_settlement_cents = excluded.min_settlement_cents,
settlement_cycle = excluded.settlement_cycle,
config = excluded.config,
updated_by = excluded.updated_by,
updated_at = now()
returning default_rate as "defaultRate", min_settlement_cents as "minSettlementCents",
settlement_cycle as "settlementCycle", config, updated_at as "updatedAt"
`,
[
auth.tenantId,
defaultRate,
minSettlementCents,
settlementCycle,
JSON.stringify(objectValue(body.config)),
auth.userId,
],
);
await recordAudit(client, auth, 'commission.settings.updated', 'tenant_commission_settings', auth.tenantId, {
defaultRate,
minSettlementCents,
settlementCycle,
});
return result.rows[0];
});
return { item };
}
export async function updateMemberCommissionRateRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'commission:write');
const body = await readJsonBody(ctx);
const userId = requiredString(body, 'userId');
const rawRate = body.commissionRate;
const commissionRate = rawRate === null || rawRate === undefined || rawRate === ''
? null
: rateValue(rawRate, 0);
const item = await transaction(async client => {
const result = await client.query(
`
update public.tenant_memberships
set commission_rate = $3,
commission_config = coalesce($4::jsonb, commission_config),
updated_at = now()
where tenant_id = $1
and user_id = $2
and role in ('sales', 'agent', 'tenant_operator', 'teacher', 'tenant_admin', 'tenant_owner')
and status = 'active'
returning id, user_id as "userId", role, commission_rate as "commissionRate",
commission_config as "commissionConfig", updated_at as "updatedAt"
`,
[
auth.tenantId,
userId,
commissionRate,
body.commissionConfig === undefined ? null : JSON.stringify(objectValue(body.commissionConfig)),
],
);
if (!result.rows[0]) throw new HttpError(404, 'Commission member not found', 'COMMISSION_MEMBER_NOT_FOUND');
await recordAudit(client, auth, 'commission.member_rate.updated', 'tenant_memberships', result.rows[0].id, {
userId,
commissionRate,
});
return result.rows[0];
});
return { item };
}
export async function commissionSummaryRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireCommissionRead(auth);
const { startDate, endDate } = periodFromParams(ctx);
const scopedReferrerId = restrictReferrerScope(auth, nullableString(stringParam(ctx, 'referrerUserId')));
const rows = await commissionRows(auth.tenantId, startDate, endDate, scopedReferrerId, null);
const settings = await commissionSettings(auth.tenantId);
return {
item: {
tenantId: auth.tenantId,
startDate,
endDate,
referrerUserId: scopedReferrerId,
defaultRate: settings.defaultRate,
minSettlementCents: settings.minSettlementCents,
...summarizeCommissionRows(rows),
},
};
}
export async function commissionOrdersRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireCommissionRead(auth);
const { startDate, endDate } = periodFromParams(ctx);
const scopedReferrerId = restrictReferrerScope(auth, nullableString(stringParam(ctx, 'referrerUserId')));
const limit = intParam(ctx, 'limit', 100, 500);
const rows = await commissionRows(auth.tenantId, startDate, endDate, scopedReferrerId, limit);
return {
items: rows.map(rowToCommissionItem),
scope: { tenantId: auth.tenantId, startDate, endDate, referrerUserId: scopedReferrerId },
};
}
export async function commissionSettlementsRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireCommissionRead(auth);
const limit = intParam(ctx, 'limit', 100, 500);
const status = stringParam(ctx, 'status');
const requestedReferrerId = nullableString(stringParam(ctx, 'referrerUserId'));
const scopedReferrerId = restrictReferrerScope(auth, requestedReferrerId);
const params: unknown[] = [auth.tenantId];
const filters = ['cs.tenant_id = $1'];
if (status) {
if (!SETTLEMENT_STATUSES.includes(status)) {
throw new HttpError(400, 'Invalid settlement status', 'INVALID_SETTLEMENT_STATUS');
}
params.push(status);
filters.push(`cs.status = $${params.length}`);
}
if (scopedReferrerId) {
params.push(scopedReferrerId);
filters.push(`cs.referrer_user_id = $${params.length}::uuid`);
}
params.push(limit);
const items = await query(
`
select cs.id, cs.settlement_no as "settlementNo", cs.referrer_user_id as "referrerUserId",
u.name as "referrerName", u.phone as "referrerPhone", cs.status,
cs.period_start as "periodStart", cs.period_end as "periodEnd",
cs.source_count as "sourceCount", cs.paid_user_count as "paidUserCount",
cs.gross_amount_cents as "grossAmountCents",
cs.commission_amount_cents as "commissionAmountCents",
cs.default_rate as "defaultRate", cs.effective_rate as "effectiveRate",
cs.reviewed_by as "reviewedBy", cs.reviewed_at as "reviewedAt",
cs.paid_by as "paidBy", cs.paid_at as "paidAt",
cs.payment_method as "paymentMethod", cs.payment_account as "paymentAccount",
cs.remark, cs.metadata, cs.created_at as "createdAt", cs.updated_at as "updatedAt"
from public.commission_settlements cs
join public.platform_users u on u.id = cs.referrer_user_id
where ${filters.join(' and ')}
order by cs.period_start desc, cs.created_at desc
limit $${params.length}
`,
params,
);
return { items };
}
export async function generateCommissionSettlementRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireCommissionWrite(auth);
const body = await readJsonBody(ctx);
const startDate = dateValue(nullableString(body.startDate) || '', '');
const endDate = dateValue(nullableString(body.endDate) || '', '');
const referrerUserId = requiredString(body, 'referrerUserId');
if (startDate > endDate) throw new HttpError(400, 'startDate must be before or equal to endDate', 'INVALID_DATE_RANGE');
const item = await transaction(async client => {
const member = await client.query<{ userId: string }>(
`
select user_id as "userId"
from public.tenant_memberships
where tenant_id = $1
and user_id = $2
and status = 'active'
and role in ('sales', 'agent', 'tenant_operator', 'teacher', 'tenant_admin', 'tenant_owner')
limit 1
`,
[auth.tenantId, referrerUserId],
);
if (!member.rows[0]) throw new HttpError(404, 'Referrer member not found', 'COMMISSION_MEMBER_NOT_FOUND');
const rows = await commissionRows(auth.tenantId, startDate, endDate, referrerUserId, null);
const unsettledRows = rows.filter(row => !row.settlementId);
const summary = summarizeCommissionRows(unsettledRows);
const settings = await commissionSettings(auth.tenantId);
if (summary.sourceCount <= 0) {
throw new HttpError(409, 'No unsettled commission sources found in this period', 'COMMISSION_NO_UNSETTLED_SOURCES');
}
if (summary.commissionAmountCents < settings.minSettlementCents) {
throw new HttpError(409, 'Commission amount is below tenant settlement minimum', 'COMMISSION_BELOW_MINIMUM');
}
const settlementNo = `COMM-${startDate.replace(/-/g, '')}-${endDate.replace(/-/g, '')}-${referrerUserId.slice(0, 8)}`;
const referrerSummary = summary.byReferrer[0] || {
effectiveRate: settings.defaultRate,
rateSource: 'default',
};
const settlement = await client.query(
`
insert into public.commission_settlements (
tenant_id, settlement_no, referrer_user_id, status, period_start, period_end,
source_count, paid_user_count, gross_amount_cents, commission_amount_cents,
default_rate, effective_rate, generated_by, remark, metadata
)
values (
$1, $2, $3, $4, $5::date, $6::date,
$7, $8, $9, $10, $11, $12, $13, $14, $15::jsonb
)
on conflict (tenant_id, referrer_user_id, period_start, period_end)
do update set status = case
when public.commission_settlements.status in ('paid', 'approved')
then public.commission_settlements.status
else excluded.status
end,
source_count = excluded.source_count,
paid_user_count = excluded.paid_user_count,
gross_amount_cents = excluded.gross_amount_cents,
commission_amount_cents = excluded.commission_amount_cents,
default_rate = excluded.default_rate,
effective_rate = excluded.effective_rate,
generated_by = excluded.generated_by,
remark = excluded.remark,
metadata = excluded.metadata,
updated_at = now()
returning id, settlement_no as "settlementNo", referrer_user_id as "referrerUserId",
status, period_start as "periodStart", period_end as "periodEnd",
source_count as "sourceCount", paid_user_count as "paidUserCount",
gross_amount_cents as "grossAmountCents",
commission_amount_cents as "commissionAmountCents",
default_rate as "defaultRate", effective_rate as "effectiveRate",
created_at as "createdAt", updated_at as "updatedAt"
`,
[
auth.tenantId,
settlementNo,
referrerUserId,
optionalChoice(body.status, ['draft', 'pending_review'], 'pending_review'),
startDate,
endDate,
summary.sourceCount,
summary.paidUserCount,
summary.grossAmountCents,
summary.commissionAmountCents,
settings.defaultRate,
referrerSummary.effectiveRate,
auth.userId,
nullableString(body.remark),
JSON.stringify({ rateSource: referrerSummary.rateSource, generatedFrom: 'api' }),
],
);
for (const row of unsettledRows) {
await client.query(
`
insert into public.commission_settlement_items (
tenant_id, settlement_id, referrer_user_id, student_user_id,
source_type, source_id, source_no, source_paid_at,
gross_amount_cents, commission_rate, commission_amount_cents,
rate_source, attribution_type, metadata
)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14::jsonb)
on conflict (tenant_id, source_type, source_id)
do update set settlement_id = excluded.settlement_id,
commission_rate = excluded.commission_rate,
commission_amount_cents = excluded.commission_amount_cents,
rate_source = excluded.rate_source,
metadata = excluded.metadata,
updated_at = now()
`,
[
auth.tenantId,
settlement.rows[0].id,
row.referrerUserId,
row.studentUserId,
row.sourceType,
row.sourceId,
row.sourceNo,
row.sourcePaidAt,
intValue(row.grossAmountCents),
numberValue(row.commissionRate),
intValue(row.commissionAmountCents),
row.rateSource,
row.attributionType,
JSON.stringify(row.metadata || {}),
],
);
}
await recordAudit(client, auth, 'commission.settlement.generated', 'commission_settlements', settlement.rows[0].id, {
referrerUserId,
startDate,
endDate,
sourceCount: summary.sourceCount,
commissionAmountCents: summary.commissionAmountCents,
});
return { ...settlement.rows[0], items: unsettledRows.map(rowToCommissionItem) };
});
return { item };
}
export async function updateCommissionSettlementStatusRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
if (!canReviewCommission(auth)) {
throw new HttpError(403, 'Commission review permission is required', 'TENANT_PERMISSION_REQUIRED');
}
const body = await readJsonBody(ctx);
const settlementId = requiredString(body, 'settlementId');
const status = optionalChoice(body.status, SETTLEMENT_STATUSES, 'approved');
if (status === 'draft') {
throw new HttpError(400, 'Cannot move settlement back to draft through status API', 'INVALID_SETTLEMENT_STATUS');
}
const item = await transaction(async client => {
const current = await client.query<{ status: string }>(
`
select status
from public.commission_settlements
where tenant_id = $1 and id = $2
limit 1
for update
`,
[auth.tenantId, settlementId],
);
if (!current.rows[0]) throw new HttpError(404, 'Commission settlement not found', 'COMMISSION_SETTLEMENT_NOT_FOUND');
assertSettlementTransition(current.rows[0].status, status);
const result = await client.query(
`
update public.commission_settlements
set status = $3,
reviewed_by = case when $3 in ('approved', 'rejected', 'cancelled') then $4 else reviewed_by end,
reviewed_at = case when $3 in ('approved', 'rejected', 'cancelled') then now() else reviewed_at end,
paid_by = case when $3 = 'paid' then $4 else paid_by end,
paid_at = case when $3 = 'paid' then now() else paid_at end,
payment_method = coalesce($5, payment_method),
payment_account = coalesce($6, payment_account),
remark = coalesce($7, remark),
metadata = metadata || $8::jsonb,
updated_at = now()
where tenant_id = $1 and id = $2
returning id, settlement_no as "settlementNo", referrer_user_id as "referrerUserId",
status, period_start as "periodStart", period_end as "periodEnd",
source_count as "sourceCount", paid_user_count as "paidUserCount",
gross_amount_cents as "grossAmountCents",
commission_amount_cents as "commissionAmountCents",
reviewed_by as "reviewedBy", reviewed_at as "reviewedAt",
paid_by as "paidBy", paid_at as "paidAt",
payment_method as "paymentMethod", payment_account as "paymentAccount",
remark, metadata, updated_at as "updatedAt"
`,
[
auth.tenantId,
settlementId,
status,
auth.userId,
nullableString(body.paymentMethod),
nullableString(body.paymentAccount),
nullableString(body.remark),
JSON.stringify(objectValue(body.metadata)),
],
);
if (!result.rows[0]) throw new HttpError(404, 'Commission settlement not found', 'COMMISSION_SETTLEMENT_NOT_FOUND');
await recordAudit(client, auth, 'commission.settlement.status_updated', 'commission_settlements', settlementId, {
status,
});
return result.rows[0];
});
return { item };
}

View File

@@ -1,4 +1,14 @@
import type { RouteDefinition } from '../../core/router.js';
import {
commissionOrdersRoute,
commissionSettingsRoute,
commissionSettlementsRoute,
commissionSummaryRoute,
generateCommissionSettlementRoute,
updateCommissionSettingsRoute,
updateCommissionSettlementStatusRoute,
updateMemberCommissionRateRoute,
} from './commission.js';
import {
crmConfigRoute,
crmQueueRoute,
@@ -31,4 +41,12 @@ export const referralRoutes: RouteDefinition[] = [
['GET', '/api/crm/config', crmConfigRoute],
['PUT', '/api/crm/config', upsertCrmConfigRoute],
['GET', '/api/crm/queue', crmQueueRoute],
['GET', '/api/commission/settings', commissionSettingsRoute],
['PUT', '/api/commission/settings', updateCommissionSettingsRoute],
['PUT', '/api/commission/member-rate', updateMemberCommissionRateRoute],
['GET', '/api/commission/summary', commissionSummaryRoute],
['GET', '/api/commission/orders', commissionOrdersRoute],
['GET', '/api/commission/settlements', commissionSettlementsRoute],
['POST', '/api/commission/settlements/generate', generateCommissionSettlementRoute],
['POST', '/api/commission/settlements/status', updateCommissionSettlementStatusRoute],
];

View File

@@ -13,10 +13,10 @@ const TENANT_ADMIN_ROLES = new Set([
const ROLE_PERMISSION_DEFAULTS: Record<string, string[]> = {
tenant_owner: ['*'],
tenant_admin: ['*'],
tenant_operator: ['dashboard:read', 'content:*', 'marketing:*', 'codes:read', 'coupons:read', 'referral:read', 'crm:read'],
tenant_operator: ['dashboard:read', 'content:*', 'marketing:*', 'codes:read', 'coupons:read', 'referral:read', 'commission:read', 'crm:read'],
teacher: ['content:*', 'classes:read', 'students:read', 'students:notes:*', 'students:followups:*'],
sales: ['codes:*', 'coupons:*', 'referral:*'],
agent: ['codes:read', 'coupons:read', 'referral:self'],
sales: ['codes:*', 'coupons:*', 'referral:*', 'commission:self'],
agent: ['codes:read', 'coupons:read', 'referral:self', 'commission:self'],
student: [],
};
@@ -101,6 +101,10 @@ export function tenantPermissionCatalog() {
{ key: 'referral:read', label: '客资全局查看' },
{ key: 'referral:self', label: '本人客资查看' },
{ key: 'referral:write', label: '客资归属管理' },
{ key: 'commission:self', label: '本人分佣查看' },
{ key: 'commission:read', label: '分佣结算查看' },
{ key: 'commission:write', label: '分佣结算生成' },
{ key: 'commission:review', label: '分佣结算审核/打款' },
{ key: 'crm:read', label: 'CRM 队列查看' },
{ key: 'crm:write', label: 'CRM 入队和重试' },
{ key: 'feedback:read', label: '反馈查看' },

View File

@@ -141,7 +141,8 @@
| 销售统计/客户列表/团队 | 可联调 | `/api/referral/sales-*``team` |
| CRM 配置/队列 | 可联调 | `/api/crm/config``/api/crm/queue` |
| 真实 CRM webhook worker | 待补齐 | 钉钉/飞书/企微发送、签名、重试、死信 |
| 分佣结算 | 待补齐 | 缺佣金规则、结算单、审核、导出 |
| 分佣结算基础闭环 | 可联调 | `/api/commission/settings``member-rate``summary``orders``settlements``settlements/generate``settlements/status`;支持订单/激活码归因、批次/成员/默认比例优先级、北京时间账期、结算单生成、审核、打款状态、已打款锁定、销售/代理本人范围和租户隔离 |
| 分佣打款增强 | 待补齐 | 银行/微信/支付宝真实打款、结算导出、发票/凭证、财务复核和分佣看板 |
## 内容导入与迁移

View File

@@ -12,7 +12,7 @@
- `scoreline`:分数线字段、院校、专业、记录、趋势、年份。
- `video`:题目视频讲解、批量预加载、通用视频搜索。
- `commerce`:订单创建/列表/详情/状态轮询、支付确认、支付 provider/webhook、激活码预检查/兑换、优惠券领取/抵扣、权益查询。
- `referral`:销售/代理邀请码、首绑客资保护、销售统计、团队关系、CRM 队列。
- `referral`:销售/代理邀请码、首绑客资保护、销售统计、团队关系、CRM 队列、分佣设置、佣金来源汇总、结算单和审核/打款状态
- `platform-admin`平台方租户管理、SaaS 套餐、订阅、账单、服务费收款、使用量。
- `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、考试日期、题目反馈处理、激活码批次、优惠券、成员管理、角色模板、班级/学生/教师范围权限、权限矩阵、审计查询。
- `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册 JSON 导入维护。
@@ -149,6 +149,14 @@ POST /api/referral/manual-bind
GET /api/referral/team
PUT /api/referral/team
POST /api/referral/qrcode
GET /api/commission/settings
PUT /api/commission/settings
PUT /api/commission/member-rate
GET /api/commission/summary
GET /api/commission/orders
GET /api/commission/settlements
POST /api/commission/settlements/generate
POST /api/commission/settlements/status
GET /api/crm/config
PUT /api/crm/config
GET /api/crm/queue

View File

@@ -45,7 +45,7 @@
- 真实短信、微信登录、QQ 登录、微信支付、支付宝支付 provider 还未正式接完。
- 对象存储已完成签名 provider但 PDF 预览、防盗链、视频水印、上传后校验还要补。
- 大批量 Excel/CSV、分数线、视频导入和异步 worker 还未完成。
- 数据看板分佣结算、AI 择校、主题模板市场等仍是后续商用增强项。
- 数据看板分佣结算基础 API 已可联调;分佣真实打款/导出/凭证、AI 择校、主题模板市场等仍是后续商用增强项。
## 前后端协作建议

View File

@@ -43,7 +43,7 @@
| 旧功能/组件 | 新后端状态 | 待补齐 |
| --- | --- | --- |
| 用户管理 | 已覆盖 | 租户成员、学生列表、学生资料、批量学生 upsert、禁用/恢复、批量分班、学生备注、跟进任务已实现;批量 CRM 推送、补绑、学习督导自动化待补 |
| 销售/代理管理 | 部分覆盖 | referral/team/stats 有;分佣比例、结算单、审核、导出 |
| 销售/代理管理 | 部分覆盖 | referral/team/stats 有;分佣设置、订单/激活码归因、结算单生成、审核、打款状态和本人/全局权限范围已覆盖;还缺真实打款、导出、发票/凭证和分佣看板 |
| 班级/教师管理 | 已覆盖 | 班级、班级成员、教师/班主任/助教/学生范围权限已有;可视化 UI 和更细数据范围组合待补 |
| 数据看板 | 部分覆盖 | 租户 dashboard 聚合 API 已覆盖收益、注册、答题、活跃、题型、科目、题量、套餐销量、运营动态、24h 活跃和激活码使用;后续补预聚合 worker、销售转化和分佣结算看板 |
| 地区管理 | 部分覆盖 | 地区和内容入口已有;平台公共题库已可按 SaaS 套餐/租户授权并由租户采纳;还缺更完整的全国/单地区套餐 UI 和版本同步策略 |
@@ -100,7 +100,7 @@
3. 题库导出PDF/Word/JSON 导出、水印、导出审计和权限控制。
4. 导入扩展Excel/CSV、分数线、视频批量导入和大批量异步 worker。
5. 公共题库商业化:平台公共/地区题库授权和租户快照采纳已完成基础闭环;还需版本同步、租户自改冲突处理和运营后台 UI。
6. CRM/销售结算:真实 CRM worker、轮询/定向分配、分佣规则、结算单、审核和导出
6. CRM/销售结算:分佣规则、结算单、审核和打款状态基础闭环已完成;仍缺真实 CRM worker、轮询/定向分配、打款导出、凭证和销售结算看板
7. 题目反馈增强:处理通知、消息提醒、问题聚合统计和内容修复闭环。
8. 积分活动增强:积分兑换、活动任务、连续签到奖励规则和风控。

View File

@@ -101,13 +101,19 @@
9. 数据看板
- 已完成首版实时聚合接口覆盖收益、注册趋势、答题次数、收入趋势、题型分布、科目数量、题目总量、套餐销量、运营动态、24h 活跃度和激活码使用情况。
- 继续补日/周/月预聚合 worker、缓存策略、慢 SQL 监控和大租户性能压测。
- 继续补销售/代理转化、分佣结算、客资跟进效果看板。
- 分佣结算基础闭环已完成;继续补销售/代理转化、结算导出、真实打款、凭证和客资跟进效果看板。
10. 学生运营管理
10. 销售/代理分佣
- 已完成租户默认分佣比例、成员分佣比例、激活码批次分佣比例。
- 已完成订单和激活码两类来源的佣金归因,且只统计客资绑定后的成交。
- 已完成结算单生成、重复结算拦截、审核、打款状态、已打款锁定、租户隔离和销售/代理本人范围权限。
- 继续补结算导出、真实打款 provider、发票/凭证、财务复核流、销售团队分佣看板和异常调整单。
11. 学生运营管理
- 已完成学生列表、学生资料维护、班级分组、教师范围可见、学生批量导入、禁用/恢复、批量分班、学生备注和跟进任务。
- 继续补批量 CRM 推送、学习督导自动化、跟进效果统计和前端 UI。
11. AI 择校推荐
12. AI 择校推荐
- 地区考试数据上下文。
- 学生输入 schema。
- AI 返回 JSON schema。

View File

@@ -162,6 +162,7 @@ tenant:<tenantId>:theme
| 学习排行榜 | `GET /api/learning/leaderboard?metric=questions&period=all` |
| 题目视频 | `GET /api/questions/{questionId}/videos``POST /api/questions/videos/batch``POST /api/videos/play` |
| 题目反馈 | `POST /api/profile/feedbacks``GET /api/profile/feedbacks` |
| 分佣结算 | `GET /api/commission/settings``PUT /api/commission/settings``PUT /api/commission/member-rate``GET /api/commission/summary``GET /api/commission/orders``GET /api/commission/settlements``POST /api/commission/settlements/generate``POST /api/commission/settlements/status` |
| 背单词 | `/api/catalog/vocabulary-units``/api/catalog/vocabulary-words` |
| 单词进度/计划 | `/api/learning/vocabulary/progress``/api/learning/vocabulary/stats``/api/learning/vocabulary/review-plan``POST /api/learning/vocabulary/review` |
| 单词收藏 | `/api/learning/vocabulary/favorites` |
@@ -395,6 +396,57 @@ GET /api/tenant-admin/dashboard?timeRange=30d&regionId=<可选地区ID>&limit=10
- `recentActivities.details` 只包含可展示的低敏汇总信息,不包含手机号、支付密钥、对象存储 key 等敏感字段。
- 大租户正式上线后会补预聚合 worker前端不应依赖任何临时 SQL 口径或自己维护缓存口径。
### 销售/代理分佣结算
分佣结算由后端统一计算,前端不要读取订单、激活码或客资后自行算佣金。当前后端已支持订单和激活码两类来源,并且只统计客资首绑保护后的成交,避免后绑抢单。
常用接口:
| 页面/动作 | 接口 | 权限 |
| --- | --- | --- |
| 查看租户分佣设置 | `GET /api/commission/settings` | `commission:read` |
| 修改默认分佣设置 | `PUT /api/commission/settings` | `commission:write` |
| 设置销售/代理个人比例 | `PUT /api/commission/member-rate` | `commission:write` |
| 分佣汇总 | `GET /api/commission/summary?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD&referrerUserId=...` | `commission:read``commission:self` |
| 分佣来源明细 | `GET /api/commission/orders?...` | `commission:read``commission:self` |
| 结算单列表 | `GET /api/commission/settlements?...` | `commission:read``commission:self` |
| 生成结算单 | `POST /api/commission/settlements/generate` | `commission:write` |
| 审核/打款状态 | `POST /api/commission/settlements/status` | `commission:review` |
金额字段统一为分:
```text
grossAmountCents
commissionAmountCents
minSettlementCents
```
比例字段统一为 0 到 1 的数字:
```text
defaultRate = 0.2
commissionRate = 0.35
```
结算状态:
```text
draft -> pending_review -> approved -> paid
pending_review -> rejected/cancelled
approved -> cancelled
```
前端处理规则:
- 销售/代理默认只有 `commission:self`,只能查看自己的分佣;租户运营/管理员拥有 `commission:read` 才能查看全局。
- `startDate/endDate` 使用 `YYYY-MM-DD`,后端按 `Asia/Shanghai` 业务日计算账期。
- 分佣比例优先级由后端处理:激活码批次比例 > 成员个人比例 > 租户默认比例。
- `sourceType=order` 表示学生订单;`sourceType=activation_code` 表示激活码兑换。
- 已进入结算单的来源会返回 `settlementId/settlementStatus`,前端不要重复发起生成。
- 已打款结算单不可再修改状态;遇到 `COMMISSION_SETTLEMENT_LOCKED` 展示“已打款,不可变更”。
- `COMMISSION_NO_UNSETTLED_SOURCES` 表示当前账期无未结算来源,不是系统异常。
- 当前版本仅支持线下打款状态登记;真实银行/微信/支付宝打款、导出、发票/凭证和财务复核后续由 worker/provider 增强。
### 背单词计划与复习上报
背单词页面分三类数据:单元列表、每日计划、单词进度。前端不需要计算下次复习日期,只提交“认识/不认识”,由后端统一更新 `nextReviewDate`、连续正确、掌握状态和每日复习计划。

View File

@@ -155,6 +155,18 @@ function getFreePort() {
});
}
function shanghaiDateKey(date = new Date()) {
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(date).map(part => [part.type, part.value]),
);
return `${parts.year}-${parts.month}-${parts.day}`;
}
async function waitForHealth(timeoutMs = 12_000) {
const started = Date.now();
let lastError = null;
@@ -3757,6 +3769,202 @@ async function testReferralAndCrmGrowth() {
});
assert.ok(teamList.items?.some(item => item.memberUserId === TENANT_AGENT_USER_ID), 'sales should see own agent team');
const commissionSettings = await request('/api/commission/settings', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
defaultRate: 0.2,
minSettlementCents: 0,
settlementCycle: 'monthly',
config: { source: 'integration-test' },
},
});
assert.equal(Number(commissionSettings.item?.defaultRate), 0.2, 'tenant admin should configure default commission rate');
const agentRate = await request('/api/commission/member-rate', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
userId: TENANT_AGENT_USER_ID,
commissionRate: 0.35,
commissionConfig: { note: 'integration agent rate' },
},
});
assert.equal(Number(agentRate.item?.commissionRate), 0.35, 'tenant admin should configure agent commission rate');
const commissionOrder = await request('/api/commerce/orders', {
method: 'POST',
body: {
planId: ids.plan,
payProvider: 'manual',
payMethod: 'manual',
regionId: ids.region,
},
});
assert.equal(commissionOrder.item?.status, 'pending', 'commission smoke order should start pending');
const commissionPaid = await request('/api/commerce/payments/manual-confirm', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
orderNo: commissionOrder.item.orderNo,
amountCents: commissionOrder.item.amountCents,
providerTradeNo: `commission-${commissionOrder.item.orderNo}`,
},
});
assert.equal(commissionPaid.item?.status, 'paid', 'commission smoke order should be paid');
const commissionBatch = await request('/api/tenant-admin/code-batches', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
name: '集成测试分佣批次',
saleType: 'agent',
defaultUnitPriceCents: 2000,
days: 30,
regionId: ids.region,
commissionRate: 0.45,
},
});
assert.equal(Number(commissionBatch.item?.commissionRate), 0.45, 'code batch should persist commission rate');
const commissionCode = await request('/api/tenant-admin/activation-codes', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
code: 'IT-COMMISSION-001',
days: 30,
batchId: commissionBatch.item.id,
unitPriceCents: 2000,
usedRegionId: ids.region,
remark: 'commission integration code',
},
});
assert.equal(commissionCode.item?.code, 'IT-COMMISSION-001', 'tenant admin should create commission activation code');
const commissionRedeemed = await request('/api/commerce/activation-codes/redeem', {
method: 'POST',
body: { code: 'IT-COMMISSION-001', regionId: ids.region },
});
assert.ok(commissionRedeemed.item?.entitlement?.id, 'commission activation code should be redeemable');
const today = shanghaiDateKey();
const agentCommissionSummary = await request('/api/commission/summary', {
userId: TENANT_AGENT_USER_ID,
query: { startDate: today, endDate: today },
});
assert.equal(agentCommissionSummary.item?.referrerUserId, TENANT_AGENT_USER_ID, 'agent commission summary should be scoped to self');
assert.equal(agentCommissionSummary.item?.sourceCount >= 2, true, 'agent commission summary should include order and activation code');
assert.equal(agentCommissionSummary.item?.grossAmountCents >= 2990, true, 'agent commission summary should include gross amount');
assert.equal(agentCommissionSummary.item?.commissionAmountCents >= 1217, true, 'agent commission should apply member and batch rates');
const salesOtherCommissionDenied = await request('/api/commission/summary', {
userId: TENANT_AGENT_USER_ID,
query: { startDate: today, endDate: today, referrerUserId: TENANT_SALES_USER_ID },
expectStatus: 403,
});
assert.equal(salesOtherCommissionDenied.code, 'COMMISSION_SCOPE_REQUIRED', 'agent should not read another referrer commission');
const salesAgentCommissionDenied = await request('/api/commission/summary', {
userId: TENANT_SALES_USER_ID,
query: { startDate: today, endDate: today, referrerUserId: TENANT_AGENT_USER_ID },
expectStatus: 403,
});
assert.equal(salesAgentCommissionDenied.code, 'COMMISSION_SCOPE_REQUIRED', 'sales should not read agent commission without commission:read');
const commissionOrders = await request('/api/commission/orders', {
userId: TENANT_ADMIN_USER_ID,
query: { startDate: today, endDate: today, referrerUserId: TENANT_AGENT_USER_ID, limit: 20 },
});
assert.ok(commissionOrders.items?.some(item => item.sourceType === 'order' && item.rateSource === 'member'), 'commission orders should include paid order with member rate');
assert.ok(commissionOrders.items?.some(item => item.sourceType === 'activation_code' && item.rateSource === 'batch'), 'commission orders should include activation code with batch rate');
assert.ok(!JSON.stringify(commissionOrders).includes('crm-secret-smoke'), 'commission order list must not leak CRM secret');
const generatedSettlement = await request('/api/commission/settlements/generate', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
referrerUserId: TENANT_AGENT_USER_ID,
startDate: today,
endDate: today,
status: 'pending_review',
remark: 'integration settlement',
},
});
assert.equal(generatedSettlement.item?.referrerUserId, TENANT_AGENT_USER_ID, 'tenant admin should generate agent settlement');
assert.equal(generatedSettlement.item?.status, 'pending_review', 'generated settlement should enter review flow');
assert.equal(generatedSettlement.item?.sourceCount >= 2, true, 'generated settlement should include unsettled sources');
const duplicateSettlement = await request('/api/commission/settlements/generate', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
referrerUserId: TENANT_AGENT_USER_ID,
startDate: today,
endDate: today,
},
expectStatus: 409,
});
assert.equal(duplicateSettlement.code, 'COMMISSION_NO_UNSETTLED_SOURCES', 'settlement generation should not duplicate settled sources');
const approvedSettlement = await request('/api/commission/settlements/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
settlementId: generatedSettlement.item.id,
status: 'approved',
remark: 'integration approved',
},
});
assert.equal(approvedSettlement.item?.status, 'approved', 'tenant admin should approve settlement');
assert.equal(approvedSettlement.item?.reviewedBy, TENANT_ADMIN_USER_ID, 'settlement approval should record reviewer');
const paidSettlement = await request('/api/commission/settlements/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
settlementId: generatedSettlement.item.id,
status: 'paid',
paymentMethod: 'offline_bank',
paymentAccount: 'masked-bank-account',
metadata: { source: 'integration-test' },
},
});
assert.equal(paidSettlement.item?.status, 'paid', 'tenant admin should mark settlement paid');
assert.equal(paidSettlement.item?.paidBy, TENANT_ADMIN_USER_ID, 'settlement paid status should record operator');
assert.equal(paidSettlement.item?.paymentMethod, 'offline_bank', 'settlement paid status should persist payment method');
const paidSettlementLocked = await request('/api/commission/settlements/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
settlementId: generatedSettlement.item.id,
status: 'rejected',
},
expectStatus: 409,
});
assert.equal(paidSettlementLocked.code, 'COMMISSION_SETTLEMENT_LOCKED', 'paid settlement should not be mutable');
const settlementList = await request('/api/commission/settlements', {
userId: TENANT_AGENT_USER_ID,
query: { startDate: today, endDate: today },
});
assert.ok(settlementList.items?.some(item => item.id === generatedSettlement.item.id && item.status === 'paid'), 'agent should see own paid settlement');
const studentCommissionDenied = await request('/api/commission/summary', {
query: { startDate: today, endDate: today },
expectStatus: 403,
});
assert.equal(studentCommissionDenied.code, 'TENANT_ADMIN_REQUIRED', 'student should not access commission summary');
const partnerCommissionDenied = await request('/api/commission/summary', {
tenantId: PARTNER_TENANT_ID,
userId: TENANT_AGENT_USER_ID,
query: { startDate: today, endDate: today },
expectStatus: 403,
});
assert.equal(partnerCommissionDenied.code, 'TENANT_ADMIN_REQUIRED', 'commission APIs must be tenant isolated');
const crmQueue = await request('/api/crm/queue', {
userId: TENANT_ADMIN_USER_ID,
query: { status: 'pending' },

View File

@@ -208,7 +208,7 @@ async function main() {
and user_id = $2::uuid
and source_type in ('order', 'activation_code')
and (
legacy_source_id in ('SMOKE20260621', 'SMOKESELF20260621')
legacy_source_id in ('SMOKE20260621', 'SMOKESELF20260621', 'IT-COMMISSION-001')
or source_id in (
select id
from public.orders
@@ -256,6 +256,53 @@ async function main() {
[tenantId, checkoutCouponCodes],
);
await client.query(
`
delete from public.commission_settlement_items
where tenant_id = $1
and (
referrer_user_id in ($2::uuid, $3::uuid, $4::uuid)
or student_user_id in ($5::uuid, $6::uuid)
)
`,
[tenantId, ids.tenantOperatorUser, ids.tenantSalesUser, ids.tenantAgentUser, ids.user, ids.secondStudentUser],
);
await client.query(
`
delete from public.commission_settlements
where tenant_id = $1
and referrer_user_id in ($2::uuid, $3::uuid, $4::uuid)
`,
[tenantId, ids.tenantOperatorUser, ids.tenantSalesUser, ids.tenantAgentUser],
);
await client.query(
`
delete from public.tenant_commission_settings
where tenant_id = $1
`,
[tenantId],
);
await client.query(
`
delete from public.activation_codes
where tenant_id = $1
and code::text = 'IT-COMMISSION-001'
`,
[tenantId],
);
await client.query(
`
delete from public.code_batches
where tenant_id = $1
and name = '集成测试分佣批次'
`,
[tenantId],
);
await client.query(
`
delete from public.crm_webhook_queue

View File

@@ -0,0 +1,129 @@
alter table public.tenant_memberships
add column if not exists commission_rate numeric(6,4),
add column if not exists commission_config jsonb not null default '{}'::jsonb;
do $$
begin
if not exists (select 1 from pg_constraint where conname = 'tenant_memberships_commission_rate_check') then
alter table public.tenant_memberships
add constraint tenant_memberships_commission_rate_check
check (commission_rate is null or (commission_rate >= 0 and commission_rate <= 1));
end if;
end $$;
do $$
begin
if not exists (select 1 from pg_constraint where conname = 'code_batches_commission_rate_check') then
alter table public.code_batches
add constraint code_batches_commission_rate_check
check (commission_rate is null or (commission_rate >= 0 and commission_rate <= 1));
end if;
end $$;
create table if not exists public.tenant_commission_settings (
tenant_id uuid primary key references public.tenants(id) on delete cascade,
default_rate numeric(6,4) not null default 0.2000 check (default_rate >= 0 and default_rate <= 1),
min_settlement_cents integer not null default 0 check (min_settlement_cents >= 0),
settlement_cycle text not null default 'monthly' check (settlement_cycle in ('manual', 'weekly', 'monthly')),
config jsonb not null default '{}'::jsonb,
updated_by uuid references public.platform_users(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists public.commission_settlements (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
settlement_no text not null,
referrer_user_id uuid not null references public.platform_users(id) on delete cascade,
status text not null default 'draft'
check (status in ('draft', 'pending_review', 'approved', 'paid', 'rejected', 'cancelled')),
period_start date not null,
period_end date not null,
source_count integer not null default 0 check (source_count >= 0),
paid_user_count integer not null default 0 check (paid_user_count >= 0),
gross_amount_cents integer not null default 0 check (gross_amount_cents >= 0),
commission_amount_cents integer not null default 0 check (commission_amount_cents >= 0),
default_rate numeric(6,4) not null default 0 check (default_rate >= 0 and default_rate <= 1),
effective_rate numeric(6,4),
generated_by uuid references public.platform_users(id) on delete set null,
reviewed_by uuid references public.platform_users(id) on delete set null,
paid_by uuid references public.platform_users(id) on delete set null,
reviewed_at timestamptz,
paid_at timestamptz,
payment_method text,
payment_account text,
remark text,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, settlement_no),
unique (tenant_id, referrer_user_id, period_start, period_end)
);
create table if not exists public.commission_settlement_items (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
settlement_id uuid references public.commission_settlements(id) on delete set null,
referrer_user_id uuid not null references public.platform_users(id) on delete cascade,
student_user_id uuid references public.platform_users(id) on delete set null,
source_type text not null check (source_type in ('order', 'activation_code')),
source_id uuid not null,
source_no text,
source_paid_at timestamptz,
gross_amount_cents integer not null default 0 check (gross_amount_cents >= 0),
commission_rate numeric(6,4) not null check (commission_rate >= 0 and commission_rate <= 1),
commission_amount_cents integer not null default 0 check (commission_amount_cents >= 0),
rate_source text not null default 'default' check (rate_source in ('batch', 'member', 'default')),
attribution_type text not null default 'protected_lead',
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, source_type, source_id)
);
create index if not exists idx_commission_items_referrer
on public.commission_settlement_items(tenant_id, referrer_user_id, source_paid_at desc);
create index if not exists idx_commission_items_settlement
on public.commission_settlement_items(tenant_id, settlement_id);
create index if not exists idx_commission_settlements_referrer
on public.commission_settlements(tenant_id, referrer_user_id, period_start desc);
alter table public.tenant_commission_settings enable row level security;
alter table public.commission_settlements enable row level security;
alter table public.commission_settlement_items enable row level security;
drop policy if exists tenant_isolation on public.tenant_commission_settings;
create policy tenant_isolation on public.tenant_commission_settings
for all
using (tenant_id = app.current_tenant_id() or app.is_platform_admin())
with check (tenant_id = app.current_tenant_id() or app.is_platform_admin());
drop policy if exists tenant_isolation on public.commission_settlements;
create policy tenant_isolation on public.commission_settlements
for all
using (tenant_id = app.current_tenant_id() or app.is_platform_admin())
with check (tenant_id = app.current_tenant_id() or app.is_platform_admin());
drop policy if exists tenant_isolation on public.commission_settlement_items;
create policy tenant_isolation on public.commission_settlement_items
for all
using (tenant_id = app.current_tenant_id() or app.is_platform_admin())
with check (tenant_id = app.current_tenant_id() or app.is_platform_admin());
drop trigger if exists set_updated_at on public.tenant_commission_settings;
create trigger set_updated_at
before update on public.tenant_commission_settings
for each row execute function app.touch_updated_at();
drop trigger if exists set_updated_at on public.commission_settlements;
create trigger set_updated_at
before update on public.commission_settlements
for each row execute function app.touch_updated_at();
drop trigger if exists set_updated_at on public.commission_settlement_items;
create trigger set_updated_at
before update on public.commission_settlement_items
for each row execute function app.touch_updated_at();