forked from wangziqi/gongxue-base
feat: add commission settlement workflow
This commit is contained in:
@@ -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/ 租户后台配置、成员权限、班级学生、活动和审计
|
||||
|
||||
782
apps/api/src/features/referral/commission.ts
Normal file
782
apps/api/src/features/referral/commission.ts
Normal 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 };
|
||||
}
|
||||
@@ -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],
|
||||
];
|
||||
|
||||
@@ -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: '反馈查看' },
|
||||
|
||||
@@ -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`;支持订单/激活码归因、批次/成员/默认比例优先级、北京时间账期、结算单生成、审核、打款状态、已打款锁定、销售/代理本人范围和租户隔离 |
|
||||
| 分佣打款增强 | 待补齐 | 银行/微信/支付宝真实打款、结算导出、发票/凭证、财务复核和分佣看板 |
|
||||
|
||||
## 内容导入与迁移
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
- 真实短信、微信登录、QQ 登录、微信支付、支付宝支付 provider 还未正式接完。
|
||||
- 对象存储已完成签名 provider,但 PDF 预览、防盗链、视频水印、上传后校验还要补。
|
||||
- 大批量 Excel/CSV、分数线、视频导入和异步 worker 还未完成。
|
||||
- 数据看板、分佣结算、AI 择校、主题模板市场等仍是后续商用增强项。
|
||||
- 数据看板和分佣结算基础 API 已可联调;分佣真实打款/导出/凭证、AI 择校、主题模板市场等仍是后续商用增强项。
|
||||
|
||||
## 前后端协作建议
|
||||
|
||||
|
||||
@@ -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. 积分活动增强:积分兑换、活动任务、连续签到奖励规则和风控。
|
||||
|
||||
|
||||
@@ -101,13 +101,19 @@
|
||||
9. 数据看板
|
||||
- 已完成首版实时聚合接口,覆盖收益、注册趋势、答题次数、收入趋势、题型分布、科目数量、题目总量、套餐销量、运营动态、24h 活跃度和激活码使用情况。
|
||||
- 继续补日/周/月预聚合 worker、缓存策略、慢 SQL 监控和大租户性能压测。
|
||||
- 继续补销售/代理转化、分佣结算、客资跟进效果看板。
|
||||
- 分佣结算基础闭环已完成;继续补销售/代理转化、结算导出、真实打款、凭证和客资跟进效果看板。
|
||||
|
||||
10. 学生运营管理
|
||||
10. 销售/代理分佣
|
||||
- 已完成租户默认分佣比例、成员分佣比例、激活码批次分佣比例。
|
||||
- 已完成订单和激活码两类来源的佣金归因,且只统计客资绑定后的成交。
|
||||
- 已完成结算单生成、重复结算拦截、审核、打款状态、已打款锁定、租户隔离和销售/代理本人范围权限。
|
||||
- 继续补结算导出、真实打款 provider、发票/凭证、财务复核流、销售团队分佣看板和异常调整单。
|
||||
|
||||
11. 学生运营管理
|
||||
- 已完成学生列表、学生资料维护、班级分组、教师范围可见、学生批量导入、禁用/恢复、批量分班、学生备注和跟进任务。
|
||||
- 继续补批量 CRM 推送、学习督导自动化、跟进效果统计和前端 UI。
|
||||
|
||||
11. AI 择校推荐
|
||||
12. AI 择校推荐
|
||||
- 地区考试数据上下文。
|
||||
- 学生输入 schema。
|
||||
- AI 返回 JSON schema。
|
||||
|
||||
@@ -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®ionId=<可选地区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`、连续正确、掌握状态和每日复习计划。
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
|
||||
129
supabase/migrations/202606290009_commission_settlements.sql
Normal file
129
supabase/migrations/202606290009_commission_settlements.sql
Normal 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();
|
||||
Reference in New Issue
Block a user