From 8d4428214a1bbc44a3a8981477fafd0683a983f1 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 29 Jun 2026 21:03:57 +0800 Subject: [PATCH] feat: add commission settlement exports and proofs --- README.md | 6 +- apps/api/src/features/referral/commission.ts | 361 ++++++++++++++++++ apps/api/src/features/referral/index.ts | 8 + .../pages/tenant-admin/marketing/index.tsx | 135 ++++++- apps/taro/src/services/tenantAdmin.ts | 78 ++++ docs/refactor/backend-capability-status.md | 4 +- docs/refactor/legacy-feature-gap-matrix.md | 4 +- docs/refactor/next-development-todo.md | 10 +- docs/refactor/taro-frontend-integration.md | 9 +- scripts/api-integration-test.js | 52 +++ ...606290027_commission_settlement_proofs.sql | 63 +++ 11 files changed, 706 insertions(+), 24 deletions(-) create mode 100644 supabase/migrations/202606290027_commission_settlement_proofs.sql diff --git a/README.md b/README.md index fe855064..1bf33faa 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ - 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 JSON、`paper_json`、打印 payload、PDF、Word 和每日一练图片 ZIP 素材包,后端强制租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计;PDF/Word/ZIP 由 exports worker 生成水印文件或运营素材并发布到 `content_assets`;`daily_practice` 支持每日一练九宫格 metadata、PDF/Word 版式、9 张 PNG/SVG 卡片和拼图包。 - 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置、跟进分配策略和队列。 - `apps/worker` 后台任务进程:CRM webhook 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志;commerce worker 可补偿查询微信/支付宝支付和退款状态;assets worker 可复检托管资源元数据、执行内置安全扫描并自动下架异常资源;imports worker 可执行大批量导入;public-banks worker 可自动同步公共题库采纳副本;exports worker 可渲染 PDF/Word 导出文件和每日一练 ZIP 图片素材包。 -- 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态和权限隔离。 +- 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态、CSV/JSON 导出、打款凭证登记/复核和权限隔离。 - 订单售后基础闭环:退款请求、审核、处理状态流、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。 - 资金对账和差错工单闭环:租户财务/运营可通过 `/api/commerce/reconciliation/*` 导入或预览支付/退款账单行,后端按租户隔离比对本地订单、支付、退款记录,识别已匹配、金额不一致、状态不一致、供应商有本地无、本地有供应商无、重复行和无效行,并写入对账批次、明细和审计日志;异常明细可创建差错工单,支持分配、开始处理、升级、解决、忽略、重开和事件留痕。工单只做财务审核闭环,不直接修改订单、支付、退款或权益。 - PocketBase schema/数据导入器雏形和导入后校验脚本。 @@ -33,7 +33,7 @@ - OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF/图片预览、资源访问事件、动态水印上下文、锁定资源 CDN 边界、资源复检 worker、内置 `metadata_rules` 安全扫描和外部 HTTP 杀毒/内容安全 scanner 接入层已完成。生产还要配置真实扫描服务 endpoint/token,并继续补转码/CDN 级水印、CDN 刷新和对象生命周期策略。 - Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。 - 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。 -- 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款、结算导出、发票/凭证、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台还没完成。 +- 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款 provider、发票、批量凭证上传、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台还没完成。 - `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、资料和个人中心;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心和租户设置,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、状态变更、订阅、账单、收款、用量和题库授权第一版写操作。 - 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。 @@ -285,7 +285,7 @@ apps/api/src/features/ profile/ 学生个人中心、勋章 referral/ 销售/代理客资追踪、CRM 队列 referral/commission.ts - 分佣设置、汇总、来源明细、结算单、审核/打款状态 + 分佣设置、汇总、来源明细、结算单、审核/打款、导出和凭证复核 scoreline/ 分数线 tenant/ 租户解析 tenant-admin/ 租户后台配置、主题、成员权限、班级学生、活动、勋章和审计 diff --git a/apps/api/src/features/referral/commission.ts b/apps/api/src/features/referral/commission.ts index c5579ee8..cba5f441 100644 --- a/apps/api/src/features/referral/commission.ts +++ b/apps/api/src/features/referral/commission.ts @@ -1,3 +1,4 @@ +import crypto from 'node:crypto'; import type pg from 'pg'; import { HttpError, type RequestContext } from '../../core/http.js'; import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js'; @@ -5,6 +6,8 @@ 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 PROOF_TYPES = ['payment', 'invoice', 'receipt', 'adjustment', 'other']; +const PROOF_STATUSES = ['submitted', 'approved', 'rejected', 'voided']; const RATE_SOURCE_ORDER: Record = { batch: 1, member: 2, default: 3 }; type JsonBody = Record; @@ -148,6 +151,76 @@ function rowToCommissionItem(row: Record) { }; } +function csvEscape(value: unknown) { + if (value === null || value === undefined) return ''; + const text = String(value); + if (!/[",\r\n]/.test(text)) return text; + return `"${text.replace(/"/g, '""')}"`; +} + +function csvLine(values: unknown[]) { + return values.map(csvEscape).join(','); +} + +function contentBase64AndHash(content: string) { + const buffer = Buffer.from(content, 'utf8'); + return { + contentBase64: buffer.toString('base64'), + sha256: crypto.createHash('sha256').update(buffer).digest('hex'), + sizeBytes: buffer.length, + }; +} + +function proofTitle(value: unknown, fallback: string) { + const title = nullableString(value); + if (!title) return fallback; + return title.length > 120 ? title.slice(0, 120) : title; +} + +function externalProofUrl(value: unknown) { + const url = nullableString(value); + if (!url) return null; + if (url.length > 1000) { + throw new HttpError(400, 'Proof URL is too long', 'INVALID_PROOF_URL'); + } + try { + const parsed = new URL(url); + if (!['https:', 'http:'].includes(parsed.protocol)) { + throw new Error('unsupported protocol'); + } + return parsed.toString(); + } catch { + throw new HttpError(400, 'Proof URL must be a valid http(s) URL', 'INVALID_PROOF_URL'); + } +} + +function optionalTimestamp(value: unknown, fieldName: string) { + const text = nullableString(value); + if (!text) return null; + const parsed = new Date(text); + if (Number.isNaN(parsed.getTime())) { + throw new HttpError(400, `${fieldName} must be a valid timestamp`, 'INVALID_TIMESTAMP'); + } + return parsed.toISOString(); +} + +async function assertTenantAsset(client: pg.PoolClient, tenantId: string, assetId: string | null) { + if (!assetId) return null; + const result = await client.query<{ id: string }>( + ` + select id + from public.content_assets + where tenant_id = $1 and id = $2 + limit 1 + `, + [tenantId, assetId], + ); + if (!result.rows[0]) { + throw new HttpError(400, 'Proof asset does not belong to this tenant', 'COMMISSION_PROOF_ASSET_INVALID'); + } + return assetId; +} + function summarizeCommissionRows(rows: Record[]) { const seenUsers = new Set(); const byReferrer = new Map>( + ` + 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 cs.tenant_id = $1 and cs.id = $2 + limit 1 + `, + [auth.tenantId, settlementId], + ); + if (!item) throw new HttpError(404, 'Commission settlement not found', 'COMMISSION_SETTLEMENT_NOT_FOUND'); + restrictReferrerScope(auth, String(item.referrerUserId)); + return item; +} + +async function settlementItems(tenantId: string, settlementId: string) { + return query>( + ` + select csi.id, csi.source_type as "sourceType", csi.source_id as "sourceId", + csi.source_no as "sourceNo", csi.source_paid_at as "sourcePaidAt", + csi.student_user_id as "studentUserId", student.name as "studentName", + student.phone as "studentPhone", csi.referrer_user_id as "referrerUserId", + referrer.name as "referrerName", referrer.phone as "referrerPhone", + csi.gross_amount_cents as "grossAmountCents", + csi.commission_rate as "commissionRate", + csi.commission_amount_cents as "commissionAmountCents", + csi.rate_source as "rateSource", csi.attribution_type as "attributionType", + csi.metadata, csi.created_at as "createdAt" + from public.commission_settlement_items csi + join public.platform_users referrer on referrer.id = csi.referrer_user_id + left join public.platform_users student on student.id = csi.student_user_id + where csi.tenant_id = $1 and csi.settlement_id = $2 + order by csi.source_paid_at asc nulls last, csi.source_no asc nulls last + `, + [tenantId, settlementId], + ); +} + +export async function commissionSettlementExportRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireCommissionRead(auth); + const settlementId = stringParam(ctx, 'settlementId'); + if (!settlementId) throw new HttpError(400, 'settlementId is required', 'REQUIRED_FIELD'); + const format = optionalChoice(ctx.url.searchParams.get('format'), ['csv', 'json'], 'csv'); + const settlement = await settlementForScope(auth, settlementId); + const rows = await settlementItems(auth.tenantId, settlementId); + const filename = `${settlement.settlementNo || settlementId}.${format}`; + + const payload = { + settlement, + items: rows.map(rowToCommissionItem), + exportedAt: new Date().toISOString(), + }; + const content = format === 'json' + ? JSON.stringify(payload, null, 2) + : [ + csvLine([ + 'settlementNo', + 'referrerName', + 'referrerPhone', + 'periodStart', + 'periodEnd', + 'sourceType', + 'sourceNo', + 'sourcePaidAt', + 'studentName', + 'studentPhone', + 'grossAmountCents', + 'commissionRate', + 'commissionAmountCents', + 'rateSource', + ]), + ...rows.map(row => csvLine([ + settlement.settlementNo, + settlement.referrerName, + settlement.referrerPhone, + settlement.periodStart, + settlement.periodEnd, + row.sourceType, + row.sourceNo, + row.sourcePaidAt, + row.studentName, + row.studentPhone, + row.grossAmountCents, + row.commissionRate, + row.commissionAmountCents, + row.rateSource, + ])), + ].join('\n'); + const encoded = contentBase64AndHash(content); + + await transaction(async client => { + await client.query( + ` + insert into public.commission_settlement_export_events ( + tenant_id, settlement_id, export_format, filename, row_count, content_sha256, exported_by, metadata + ) + values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb) + `, + [ + auth.tenantId, + settlementId, + format, + filename, + rows.length, + encoded.sha256, + auth.userId, + JSON.stringify({ sizeBytes: encoded.sizeBytes }), + ], + ); + await recordAudit(client, auth, 'commission.settlement.exported', 'commission_settlements', settlementId, { + format, + filename, + rowCount: rows.length, + sha256: encoded.sha256, + }); + }); + + return { + item: { + settlementId, + filename, + format, + mimeType: format === 'json' ? 'application/json' : 'text/csv', + rowCount: rows.length, + ...encoded, + }, + }; +} + +export async function commissionSettlementProofsRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireCommissionRead(auth); + const settlementId = stringParam(ctx, 'settlementId'); + if (!settlementId) throw new HttpError(400, 'settlementId is required', 'REQUIRED_FIELD'); + await settlementForScope(auth, settlementId); + const items = await query( + ` + select p.id, p.settlement_id as "settlementId", p.proof_type as "proofType", p.status, + p.title, p.description, p.asset_id as "assetId", p.external_url as "externalUrl", + p.amount_cents as "amountCents", p.payment_method as "paymentMethod", + p.payment_account as "paymentAccount", p.paid_at as "paidAt", + p.submitted_by as "submittedBy", submitter.name as "submittedByName", + p.reviewed_by as "reviewedBy", reviewer.name as "reviewedByName", + p.reviewed_at as "reviewedAt", p.review_note as "reviewNote", + p.metadata, p.created_at as "createdAt", p.updated_at as "updatedAt" + from public.commission_settlement_proofs p + left join public.platform_users submitter on submitter.id = p.submitted_by + left join public.platform_users reviewer on reviewer.id = p.reviewed_by + where p.tenant_id = $1 and p.settlement_id = $2 + order by p.created_at desc + `, + [auth.tenantId, settlementId], + ); + return { items }; +} + +export async function createCommissionSettlementProofRoute(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 settlement = await settlementForScope(auth, settlementId); + const proofType = optionalChoice(body.proofType, PROOF_TYPES, 'payment'); + const amountCents = body.amountCents === undefined || body.amountCents === null + ? intValue(settlement.commissionAmountCents) + : Math.max(0, intValue(body.amountCents)); + const externalUrl = externalProofUrl(body.externalUrl); + const paidAt = optionalTimestamp(body.paidAt, 'paidAt'); + + const item = await transaction(async client => { + const assetId = await assertTenantAsset(client, auth.tenantId, nullableString(body.assetId)); + const result = await client.query( + ` + insert into public.commission_settlement_proofs ( + tenant_id, settlement_id, proof_type, status, title, description, + asset_id, external_url, amount_cents, payment_method, payment_account, + paid_at, submitted_by, metadata + ) + values ($1, $2, $3, 'submitted', $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb) + returning id, settlement_id as "settlementId", proof_type as "proofType", status, + title, description, asset_id as "assetId", external_url as "externalUrl", + amount_cents as "amountCents", payment_method as "paymentMethod", + payment_account as "paymentAccount", paid_at as "paidAt", + submitted_by as "submittedBy", metadata, created_at as "createdAt", updated_at as "updatedAt" + `, + [ + auth.tenantId, + settlementId, + proofType, + proofTitle(body.title, `${settlement.settlementNo || settlementId} 打款凭证`), + nullableString(body.description), + assetId, + externalUrl, + amountCents, + nullableString(body.paymentMethod), + nullableString(body.paymentAccount), + paidAt, + auth.userId, + JSON.stringify(objectValue(body.metadata)), + ], + ); + await recordAudit(client, auth, 'commission.settlement.proof_submitted', 'commission_settlement_proofs', result.rows[0].id, { + settlementId, + proofType, + amountCents, + hasAsset: Boolean(assetId), + hasExternalUrl: Boolean(externalUrl), + }); + return result.rows[0]; + }); + + return { item }; +} + +export async function updateCommissionSettlementProofStatusRoute(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 proofId = requiredString(body, 'proofId'); + const status = optionalChoice(body.status, PROOF_STATUSES, 'approved'); + if (status === 'submitted') { + throw new HttpError(400, 'Cannot move proof back to submitted through status API', 'INVALID_PROOF_STATUS'); + } + + const item = await transaction(async client => { + const current = await client.query<{ id: string; settlementId: string; status: string }>( + ` + select id, settlement_id as "settlementId", status + from public.commission_settlement_proofs + where tenant_id = $1 and id = $2 + limit 1 + for update + `, + [auth.tenantId, proofId], + ); + const proof = current.rows[0]; + if (!proof) throw new HttpError(404, 'Commission settlement proof not found', 'COMMISSION_PROOF_NOT_FOUND'); + await settlementForScope(auth, proof.settlementId); + if (['approved', 'rejected', 'voided'].includes(proof.status) && proof.status !== status) { + throw new HttpError(409, 'Closed proof cannot change status', 'COMMISSION_PROOF_CLOSED'); + } + + const result = await client.query( + ` + update public.commission_settlement_proofs + set status = $3, + reviewed_by = $4, + reviewed_at = now(), + review_note = coalesce($5, review_note), + metadata = metadata || $6::jsonb, + updated_at = now() + where tenant_id = $1 and id = $2 + returning id, settlement_id as "settlementId", proof_type as "proofType", status, + title, description, asset_id as "assetId", external_url as "externalUrl", + amount_cents as "amountCents", payment_method as "paymentMethod", + payment_account as "paymentAccount", paid_at as "paidAt", + submitted_by as "submittedBy", reviewed_by as "reviewedBy", + reviewed_at as "reviewedAt", review_note as "reviewNote", + metadata, created_at as "createdAt", updated_at as "updatedAt" + `, + [auth.tenantId, proofId, status, auth.userId, nullableString(body.reviewNote), JSON.stringify(objectValue(body.metadata))], + ); + await recordAudit(client, auth, 'commission.settlement.proof_status_updated', 'commission_settlement_proofs', proofId, { + settlementId: proof.settlementId, + status, + }); + return result.rows[0]; + }); + + return { item }; +} + export async function generateCommissionSettlementRoute(ctx: RequestContext) { const auth = await requireTenantAdmin(ctx); requireCommissionWrite(auth); diff --git a/apps/api/src/features/referral/index.ts b/apps/api/src/features/referral/index.ts index a2b930df..6060bfca 100644 --- a/apps/api/src/features/referral/index.ts +++ b/apps/api/src/features/referral/index.ts @@ -2,10 +2,14 @@ import type { RouteDefinition } from '../../core/router.js'; import { commissionOrdersRoute, commissionSettingsRoute, + commissionSettlementExportRoute, + commissionSettlementProofsRoute, commissionSettlementsRoute, commissionSummaryRoute, + createCommissionSettlementProofRoute, generateCommissionSettlementRoute, updateCommissionSettingsRoute, + updateCommissionSettlementProofStatusRoute, updateCommissionSettlementStatusRoute, updateMemberCommissionRateRoute, } from './commission.js'; @@ -47,6 +51,10 @@ export const referralRoutes: RouteDefinition[] = [ ['GET', '/api/commission/summary', commissionSummaryRoute], ['GET', '/api/commission/orders', commissionOrdersRoute], ['GET', '/api/commission/settlements', commissionSettlementsRoute], + ['GET', '/api/commission/settlements/export', commissionSettlementExportRoute], + ['GET', '/api/commission/settlements/proofs', commissionSettlementProofsRoute], ['POST', '/api/commission/settlements/generate', generateCommissionSettlementRoute], ['POST', '/api/commission/settlements/status', updateCommissionSettlementStatusRoute], + ['POST', '/api/commission/settlements/proofs', createCommissionSettlementProofRoute], + ['POST', '/api/commission/settlements/proofs/status', updateCommissionSettlementProofStatusRoute], ]; diff --git a/apps/taro/src/pages/tenant-admin/marketing/index.tsx b/apps/taro/src/pages/tenant-admin/marketing/index.tsx index 49c841ea..33869af3 100644 --- a/apps/taro/src/pages/tenant-admin/marketing/index.tsx +++ b/apps/taro/src/pages/tenant-admin/marketing/index.tsx @@ -2,10 +2,13 @@ import { useEffect, useMemo, useState } from 'react'; import Taro from '@tarojs/taro'; import { Button, Input, Text, View } from '@tarojs/components'; import { + createCommissionSettlementProof, + exportCommissionSettlement, generateCommissionSettlement, loadActivationCodes, loadCodeBatches, loadCommissionOrders, + loadCommissionSettlementProofs, loadCommissionSettings, loadCommissionSettlements, loadCommissionSummary, @@ -14,11 +17,13 @@ import { loadCrmQueue, loadTenantMembers, updateCommissionSettings, + updateCommissionSettlementProofStatus, updateCommissionSettlementStatus, updateMemberCommissionRate, upsertCrmConfig, type CodeBatchItem, type CommissionOrderItem, + type CommissionSettlementProofItem, type CommissionSettlementItem, type CommissionSettingsItem, type CommissionSummaryItem, @@ -51,6 +56,17 @@ function rateToPercent(value: unknown) { return String((Number(value || 0) * 100).toFixed(2)).replace(/\.00$/, ''); } +function downloadBase64File(filename: string, contentBase64: string, mimeType: string) { + if (typeof document === 'undefined') return false; + const link = document.createElement('a'); + link.href = `data:${mimeType};base64,${contentBase64}`; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + return true; +} + export default function TenantMarketingPage() { const [coupons, setCoupons] = useState([]); const [batches, setBatches] = useState([]); @@ -61,6 +77,7 @@ export default function TenantMarketingPage() { const [commission, setCommission] = useState(null); const [commissionOrders, setCommissionOrders] = useState([]); const [settlements, setSettlements] = useState([]); + const [settlementProofs, setSettlementProofs] = useState>({}); const [members, setMembers] = useState([]); const [crmForm, setCrmForm] = useState({ enabled: false, @@ -77,6 +94,7 @@ export default function TenantMarketingPage() { const [period, setPeriod] = useState({ startDate: monthStart(), endDate: today(), referrerUserId: '' }); const [memberRate, setMemberRate] = useState({ userId: '', ratePercent: '' }); const [settlementPay, setSettlementPay] = useState({ paymentMethod: 'offline_bank', paymentAccount: '' }); + const [proofForm, setProofForm] = useState({ externalUrl: '', title: '', reviewNote: '' }); const [crmStatus, setCrmStatus] = useState(''); const [busy, setBusy] = useState(''); const [error, setError] = useState(''); @@ -292,6 +310,82 @@ export default function TenantMarketingPage() { } } + async function exportSettlement(item: CommissionSettlementItem) { + setBusy(`settlement:${item.id}:export`); + setError(''); + try { + const payload = await exportCommissionSettlement(item.id, 'csv'); + const exportItem = payload.item; + if (exportItem?.contentBase64 && exportItem.filename) { + const downloaded = downloadBase64File(exportItem.filename, exportItem.contentBase64, exportItem.mimeType || 'text/csv'); + Taro.showToast({ title: downloaded ? '导出已下载' : '导出已生成', icon: 'success' }); + } + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '结算导出失败'); + } finally { + setBusy(''); + } + } + + async function submitProof(item: CommissionSettlementItem) { + if (!proofForm.externalUrl.trim()) { + Taro.showToast({ title: '请填写凭证链接', icon: 'none' }); + return; + } + setBusy(`settlement:${item.id}:proof`); + setError(''); + try { + await createCommissionSettlementProof({ + settlementId: item.id, + proofType: 'payment', + title: proofForm.title.trim() || `${item.settlementNo || item.id} 打款凭证`, + externalUrl: proofForm.externalUrl.trim(), + amountCents: item.commissionAmountCents || null, + paymentMethod: settlementPay.paymentMethod || null, + paymentAccount: settlementPay.paymentAccount || null, + metadata: { source: 'taro-tenant-admin' }, + }); + Taro.showToast({ title: '凭证已登记', icon: 'success' }); + await refreshProofs(item.id); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '凭证登记失败'); + } finally { + setBusy(''); + } + } + + async function refreshProofs(settlementId: string) { + setBusy(`settlement:${settlementId}:proofs`); + setError(''); + try { + const payload = await loadCommissionSettlementProofs(settlementId); + setSettlementProofs(prev => ({ ...prev, [settlementId]: payload.items || [] })); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '凭证加载失败'); + } finally { + setBusy(''); + } + } + + async function reviewProof(proof: CommissionSettlementProofItem, status: string) { + setBusy(`proof:${proof.id}:${status}`); + setError(''); + try { + await updateCommissionSettlementProofStatus({ + proofId: proof.id, + status, + reviewNote: proofForm.reviewNote.trim() || null, + metadata: { source: 'taro-tenant-admin' }, + }); + Taro.showToast({ title: '凭证状态已更新', icon: 'success' }); + if (proof.settlementId) await refreshProofs(proof.settlementId); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '凭证复核失败'); + } finally { + setBusy(''); + } + } + return ( @@ -455,20 +549,39 @@ export default function TenantMarketingPage() { setSettlementPay(prev => ({ ...prev, paymentMethod: String(event.detail.value || '') }))} /> setSettlementPay(prev => ({ ...prev, paymentAccount: String(event.detail.value || '') }))} /> + setProofForm(prev => ({ ...prev, externalUrl: String(event.detail.value || '') }))} /> + setProofForm(prev => ({ ...prev, title: String(event.detail.value || '') }))} /> + setProofForm(prev => ({ ...prev, reviewNote: String(event.detail.value || '') }))} /> - {settlements.map(item => ( - - {item.settlementNo || item.id} - {item.referrerName || item.referrerPhone || item.referrerUserId} · {item.status || 'draft'} · {money(item.commissionAmountCents)} - {item.periodStart || '-'} 至 {item.periodEnd || '-'} · 来源 {item.sourceCount || 0} - - {item.status === 'pending_review' ? : null} - {item.status === 'pending_review' ? : null} - {item.status === 'approved' ? : null} + {settlements.map(item => { + const proofs = settlementProofs[item.id] || []; + return ( + + {item.settlementNo || item.id} + {item.referrerName || item.referrerPhone || item.referrerUserId} · {item.status || 'draft'} · {money(item.commissionAmountCents)} + {item.periodStart || '-'} 至 {item.periodEnd || '-'} · 来源 {item.sourceCount || 0} · 凭证 {proofs.length} + + + + {item.status === 'pending_review' ? : null} + {item.status === 'pending_review' ? : null} + {item.status === 'approved' ? : null} + {item.status === 'paid' ? : null} + + {proofs.map(proof => ( + + {proof.title || proof.proofType || '凭证'} · {proof.status || 'submitted'} + {proof.externalUrl || proof.assetId || '-'} · {money(proof.amountCents)} + + {proof.status === 'submitted' ? : null} + {proof.status === 'submitted' ? : null} + + + ))} - - ))} + ); + })} {!settlements.length ? 暂无结算单。 : null} diff --git a/apps/taro/src/services/tenantAdmin.ts b/apps/taro/src/services/tenantAdmin.ts index 376eab0b..1c2354f2 100644 --- a/apps/taro/src/services/tenantAdmin.ts +++ b/apps/taro/src/services/tenantAdmin.ts @@ -511,6 +511,41 @@ export interface CommissionSettlementItem { updatedAt?: string; } +export interface CommissionSettlementExportItem { + settlementId?: string; + filename?: string; + format?: 'csv' | 'json'; + mimeType?: string; + rowCount?: number; + contentBase64?: string; + sha256?: string; + sizeBytes?: number; +} + +export interface CommissionSettlementProofItem { + id: string; + settlementId?: string; + proofType?: string; + status?: string; + title?: string | null; + description?: string | null; + assetId?: string | null; + externalUrl?: string | null; + amountCents?: number | null; + paymentMethod?: string | null; + paymentAccount?: string | null; + paidAt?: string | null; + submittedBy?: string | null; + submittedByName?: string | null; + reviewedBy?: string | null; + reviewedByName?: string | null; + reviewedAt?: string | null; + reviewNote?: string | null; + metadata?: Record; + createdAt?: string; + updatedAt?: string; +} + export interface TenantOverview { id?: string; name?: string; @@ -1078,6 +1113,18 @@ export async function loadCommissionSettlements(query: { status?: string; referr }); } +export async function exportCommissionSettlement(settlementId: string, format: 'csv' | 'json' = 'csv') { + return apiRequest<{ item?: CommissionSettlementExportItem }>('/api/commission/settlements/export', { + query: { settlementId, format }, + }); +} + +export async function loadCommissionSettlementProofs(settlementId: string) { + return apiRequest<{ items?: CommissionSettlementProofItem[] }>('/api/commission/settlements/proofs', { + query: { settlementId }, + }); +} + export async function generateCommissionSettlement(input: { referrerUserId: string; startDate: string; @@ -1104,3 +1151,34 @@ export async function updateCommissionSettlementStatus(input: { body: input, }); } + +export async function createCommissionSettlementProof(input: { + settlementId: string; + proofType?: string; + title?: string | null; + description?: string | null; + assetId?: string | null; + externalUrl?: string | null; + amountCents?: number | null; + paymentMethod?: string | null; + paymentAccount?: string | null; + paidAt?: string | null; + metadata?: Record; +}) { + return apiRequest<{ item?: CommissionSettlementProofItem }>('/api/commission/settlements/proofs', { + method: 'POST', + body: input, + }); +} + +export async function updateCommissionSettlementProofStatus(input: { + proofId: string; + status: string; + reviewNote?: string | null; + metadata?: Record; +}) { + return apiRequest<{ item?: CommissionSettlementProofItem }>('/api/commission/settlements/proofs/status', { + method: 'POST', + body: input, + }); +} diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 1d63e942..213742cc 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -156,8 +156,8 @@ | CRM 配置/队列 | 可联调 | `/api/crm/config`、`/api/crm/queue`;CRM 配置支持 `none/direct/round_robin/referrer` 客资跟进分配策略、租户内候选成员校验、轮询游标、分配审计,客资首绑成功后会写入 `assignedToUserId` 并进入队列 payload | | CRM webhook worker | 可联调 | `apps/worker` 已支持 generic webhook、钉钉、飞书、企微群机器人消息体/签名、到期任务消费、失败退避重试、最终失败、discarded 和 `crm_webhook_log` | | CRM 增强 | 待补齐 | 富卡片模板、失败告警、死信运营后台、批量 CRM 推送和更细销售转化看板 | -| 分佣结算基础闭环 | 可联调 | `/api/commission/settings`、`member-rate`、`summary`、`orders`、`settlements`、`settlements/generate`、`settlements/status`;支持订单/激活码归因、批次/成员/默认比例优先级、北京时间账期、结算单生成、审核、打款状态、已打款锁定、销售/代理本人范围和租户隔离 | -| 分佣打款增强 | 待补齐 | 银行/微信/支付宝真实打款、结算导出、发票/凭证、财务复核和分佣看板 | +| 分佣结算基础闭环 | 可联调 | `/api/commission/settings`、`member-rate`、`summary`、`orders`、`settlements`、`settlements/generate`、`settlements/status`、`settlements/export`、`settlements/proofs`;支持订单/激活码归因、批次/成员/默认比例优先级、北京时间账期、结算单生成、审核、打款状态、已打款锁定、CSV/JSON 导出、打款凭证登记/复核、销售/代理本人范围和租户隔离 | +| 分佣打款增强 | 待补齐 | 银行/微信/支付宝真实打款 provider、发票管理、批量凭证上传、异常调整单和分佣看板 | ## 内容导入与迁移 diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index ff8e9acf..f482967d 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -43,7 +43,7 @@ | 旧功能/组件 | 新后端状态 | 待补齐 | | --- | --- | --- | | 用户管理 | 已覆盖 | 租户成员、学生列表、学生资料、批量学生 upsert、禁用/恢复、批量分班、学生备注、跟进任务已实现;批量 CRM 推送、补绑、学习督导自动化待补 | -| 销售/代理管理 | 部分覆盖 | referral/team/stats 已有;分佣设置、订单/激活码归因、结算单生成、审核、打款状态和本人/全局权限范围已覆盖;还缺真实打款、导出、发票/凭证和分佣看板 | +| 销售/代理管理 | 部分覆盖 | referral/team/stats 已有;分佣设置、订单/激活码归因、结算单生成、审核、打款状态、CSV/JSON 导出、打款凭证登记/复核和本人/全局权限范围已覆盖;还缺真实打款 provider、发票、批量凭证上传和分佣看板 | | 班级/教师管理 | 已覆盖 | 班级、班级成员、教师/班主任/助教/学生范围权限已有;可视化 UI 和更细数据范围组合待补 | | 数据看板 | 部分覆盖 | 租户 dashboard 聚合 API 已覆盖收益、注册、答题、活跃、题型、科目、题量、套餐销量、运营动态、24h 活跃和激活码使用;后续补预聚合 worker、销售转化和分佣结算看板 | | 地区管理 | 部分覆盖 | 地区和内容入口已有;平台公共题库已可按 SaaS 套餐/租户授权并由租户采纳;还缺更完整的全国/单地区套餐 UI 和版本同步策略 | @@ -101,7 +101,7 @@ 4. 题库导出:服务端 JSON/试卷 payload、PDF/Word 二进制生成、水印、资料发布路径、权限审计、答案脱敏、每日一练基础导出和图片 ZIP 素材包已补;仍缺更精细试卷模板和后台导出操作台体验。 5. 导入扩展:题目/单词/知识手册/分数线/视频已支持 JSON、CSV 和 Excel 预览导入,并可用 `executionMode=async` 进入 imports worker;导入后复检、导入任务详情、模板下载按钮、字段映射 API、Taro 字段别名编辑、异步轮询和 PocketBase JSON dry-run 报告已补,仍缺真实数据执行验收。 6. 公共题库商业化:平台公共/地区题库授权、租户快照采纳、手动同步、自动同步 worker、同步通知、冲突查询、租户自改冲突保护和单条/批量冲突处理已完成基础闭环;还需生产定时调度、失败告警和更完整运营后台消息。 -7. CRM/销售结算:CRM worker、跟进分配策略、分佣规则、结算单、审核和打款状态基础闭环已完成;仍缺富卡片模板、失败告警、死信运营台、打款导出、凭证和销售结算看板。 +7. CRM/销售结算:CRM worker、跟进分配策略、分佣规则、结算单、审核、打款状态、导出和凭证复核基础闭环已完成;仍缺富卡片模板、失败告警、死信运营台、真实打款 provider、发票和销售结算看板。 8. 题目反馈增强:处理通知、消息提醒、问题聚合统计和内容修复闭环。 9. 积分活动增强:积分兑换、活动任务、连续签到奖励规则和风控。 10. 勋章增强:后台维护和手动发放已有;仍缺按学习行为、签到、积分、活动任务自动发放,以及发放通知。 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 12f3c4c3..3fce483e 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -12,7 +12,7 @@ - 租户后台 API:品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、考试日期、题目反馈处理、激活码、优惠券、勋章管理/发放、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。 - 租户主题系统:平台默认经典蓝、专注绿、高对比三套模板,租户可保存草稿、发布主题,公开租户解析只返回已发布主题,Taro 租户设置页已接第一版主题操作台。 - 平台后台 API:租户、SaaS 套餐、订阅、账单、服务费收款、用量。 -- 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、CRM worker、分佣规则、成员比例、订单/激活码归因、结算生成、审核和打款状态;Taro 租户营销中心已接第一版操作台。 +- 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、CRM worker、分佣规则、成员比例、订单/激活码归因、结算生成、审核、打款状态、结算导出和凭证复核;Taro 租户营销中心已接第一版操作台。 - 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。 - 练习组卷:`question_collections/practice_blueprints` 支持顺序、随机、全真模拟快照。 - 模考报告与学习统计:`practice_session_reports/practice_session_report_sections` 支持交卷、评分、题型/小节统计、错题解析汇总和历史查询;`/api/learning/stats`、`trend`、`practice-sessions/history`、`wrong-questions/review-plan` 可支撑个人中心和学习报告基础页。 @@ -124,13 +124,13 @@ 10. 数据看板 - 已完成首版实时聚合接口,覆盖收益、注册趋势、答题次数、收入趋势、题型分布、科目数量、题目总量、套餐销量、运营动态、24h 活跃度和激活码使用情况。 - 继续补日/周/月预聚合 worker、缓存策略、慢 SQL 监控和大租户性能压测。 - - 分佣结算基础闭环已完成;继续补销售/代理转化、结算导出、真实打款、凭证和客资跟进效果看板。 + - 分佣结算基础闭环已完成;继续补销售/代理转化、真实打款 provider、发票、异常调整单和客资跟进效果看板。 11. 销售/代理分佣 - 已完成租户默认分佣比例、成员分佣比例、激活码批次分佣比例。 - 已完成订单和激活码两类来源的佣金归因,且只统计客资绑定后的成交。 - - 已完成结算单生成、重复结算拦截、审核、打款状态、已打款锁定、租户隔离和销售/代理本人范围权限。 - - 继续补结算导出、真实打款 provider、发票/凭证、财务复核流、销售团队分佣看板和异常调整单。 + - 已完成结算单生成、重复结算拦截、审核、打款状态、已打款锁定、CSV/JSON 导出、打款凭证登记、凭证复核、租户隔离和销售/代理本人范围权限。 + - 继续补真实打款 provider、发票管理、批量凭证上传、销售团队分佣看板和异常调整单。 12. 学生运营管理 - 已完成学生列表、学生资料维护、班级分组、教师范围可见、学生批量导入、禁用/恢复、批量分班、学生备注和跟进任务。 @@ -225,7 +225,7 @@ ## 推荐下一步顺序 -1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条/批量冲突采纳平台或保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询、导入后复检详情、权限驱动工作台、学生创建/更新/批量导入/批量分班/备注/跟进、角色模板配置、成员绑定模板、CRM 配置/队列/跟进分配策略、分佣规则/成员比例/结算生成审核打款已接第一版;继续补成员批量运营、更细数据范围 UI、真实打款/导出/凭证。 +1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条/批量冲突采纳平台或保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询、导入后复检详情、权限驱动工作台、学生创建/更新/批量导入/批量分班/备注/跟进、角色模板配置、成员绑定模板、CRM 配置/队列/跟进分配策略、分佣规则/成员比例/结算生成审核打款/导出/凭证复核已接第一版;继续补成员批量运营、更细数据范围 UI、真实打款 provider 和发票。 2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、错题/收藏专题、收银台、订单详情和售后入口已接第一版;继续补长题干排版、公式图片混排、小程序支付容器、分享场景和状态管理。 3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。 4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index de0802fe..1afea5e7 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -785,8 +785,12 @@ GET /api/tenant-admin/dashboard?timeRange=30d®ionId=<可选地区ID>&limit=10 | 分佣汇总 | `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` | +| 导出结算明细 | `GET /api/commission/settlements/export?settlementId=...&format=csv` | `commission:read` 或 `commission:self` | | 生成结算单 | `POST /api/commission/settlements/generate` | `commission:write` | | 审核/打款状态 | `POST /api/commission/settlements/status` | `commission:review` | +| 查看凭证 | `GET /api/commission/settlements/proofs?settlementId=...` | `commission:read` 或 `commission:self` | +| 登记凭证 | `POST /api/commission/settlements/proofs` | `commission:review` | +| 凭证复核 | `POST /api/commission/settlements/proofs/status` | `commission:review` | 金额字段统一为分: @@ -820,7 +824,10 @@ approved -> cancelled - 已进入结算单的来源会返回 `settlementId/settlementStatus`,前端不要重复发起生成。 - 已打款结算单不可再修改状态;遇到 `COMMISSION_SETTLEMENT_LOCKED` 展示“已打款,不可变更”。 - `COMMISSION_NO_UNSETTLED_SOURCES` 表示当前账期无未结算来源,不是系统异常。 -- 当前版本仅支持线下打款状态登记;真实银行/微信/支付宝打款、导出、发票/凭证和财务复核后续由 worker/provider 增强。 +- 导出接口返回 `contentBase64/sha256/filename/mimeType`,H5 可转成下载,微信小程序端建议后续使用文件系统保存;前端不要直接查询 `commission_settlement_items` 拼文件。 +- 凭证支持 `assetId` 或 `externalUrl`。如果使用 `assetId`,必须先通过资料/对象存储台账上传凭证,后端会校验资源属于当前租户;`externalUrl` 只接受 http/https。 +- `referrerUserId` 是受首绑保护的推广/分佣归属;CRM 的 `assignedToUserId` 只是跟进负责人,不能作为分佣结算依据。 +- 当前版本支持线下打款状态登记、结算导出、凭证登记和凭证复核;真实银行/微信/支付宝打款 provider、发票和批量凭证上传后续增强。 ### 背单词计划与复习上报 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 25b3de01..ffa457c2 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -7066,6 +7066,58 @@ async function testReferralAndCrmGrowth() { 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 exportedSettlement = await request('/api/commission/settlements/export', { + userId: TENANT_ADMIN_USER_ID, + query: { settlementId: generatedSettlement.item.id, format: 'csv' }, + }); + assert.equal(exportedSettlement.item?.format, 'csv', 'tenant admin should export settlement csv'); + assert.equal(exportedSettlement.item?.rowCount >= 2, true, 'settlement export should include item rows'); + assert.ok(exportedSettlement.item?.sha256, 'settlement export should include content hash'); + assert.ok(Buffer.from(exportedSettlement.item?.contentBase64 || '', 'base64').toString('utf8').includes('commissionAmountCents'), 'settlement export should include csv headers'); + + const settlementProof = await request('/api/commission/settlements/proofs', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + settlementId: generatedSettlement.item.id, + proofType: 'payment', + title: '集成测试打款凭证', + externalUrl: 'https://finance.example.test/proofs/commission-paid.png', + amountCents: paidSettlement.item.commissionAmountCents, + paymentMethod: 'offline_bank', + paymentAccount: 'masked-bank-account', + metadata: { source: 'integration-test' }, + }, + }); + assert.equal(settlementProof.item?.status, 'submitted', 'tenant admin should submit payment proof'); + assert.equal(settlementProof.item?.submittedBy, TENANT_ADMIN_USER_ID, 'proof should record submitter'); + + const approvedProof = await request('/api/commission/settlements/proofs/status', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + proofId: settlementProof.item.id, + status: 'approved', + reviewNote: 'integration proof reviewed', + }, + }); + assert.equal(approvedProof.item?.status, 'approved', 'tenant admin should approve payment proof'); + assert.equal(approvedProof.item?.reviewedBy, TENANT_ADMIN_USER_ID, 'proof approval should record reviewer'); + + const proofList = await request('/api/commission/settlements/proofs', { + userId: TENANT_AGENT_USER_ID, + query: { settlementId: generatedSettlement.item.id }, + }); + assert.ok(proofList.items?.some(item => item.id === settlementProof.item.id), 'agent should read own settlement proof'); + assert.ok(!JSON.stringify(proofList).includes('crm-secret-smoke'), 'settlement proofs must not leak CRM secret'); + + const salesExportDenied = await request('/api/commission/settlements/export', { + userId: TENANT_SALES_USER_ID, + query: { settlementId: generatedSettlement.item.id, format: 'json' }, + expectStatus: 403, + }); + assert.equal(salesExportDenied.code, 'COMMISSION_SCOPE_REQUIRED', 'sales must not export another referrer settlement'); + const paidSettlementLocked = await request('/api/commission/settlements/status', { userId: TENANT_ADMIN_USER_ID, method: 'POST', diff --git a/supabase/migrations/202606290027_commission_settlement_proofs.sql b/supabase/migrations/202606290027_commission_settlement_proofs.sql new file mode 100644 index 00000000..30c8dc07 --- /dev/null +++ b/supabase/migrations/202606290027_commission_settlement_proofs.sql @@ -0,0 +1,63 @@ +create table if not exists public.commission_settlement_proofs ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references public.tenants(id) on delete cascade, + settlement_id uuid not null references public.commission_settlements(id) on delete cascade, + proof_type text not null default 'payment' + check (proof_type in ('payment', 'invoice', 'receipt', 'adjustment', 'other')), + status text not null default 'submitted' + check (status in ('submitted', 'approved', 'rejected', 'voided')), + title text, + description text, + asset_id uuid references public.content_assets(id) on delete set null, + external_url text, + amount_cents integer check (amount_cents is null or amount_cents >= 0), + payment_method text, + payment_account text, + paid_at timestamptz, + submitted_by uuid references public.platform_users(id) on delete set null, + reviewed_by uuid references public.platform_users(id) on delete set null, + reviewed_at timestamptz, + review_note text, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists public.commission_settlement_export_events ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references public.tenants(id) on delete cascade, + settlement_id uuid not null references public.commission_settlements(id) on delete cascade, + export_format text not null check (export_format in ('csv', 'json')), + filename text not null, + row_count integer not null default 0 check (row_count >= 0), + content_sha256 text, + exported_by uuid references public.platform_users(id) on delete set null, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now() +); + +create index if not exists idx_commission_proofs_settlement + on public.commission_settlement_proofs(tenant_id, settlement_id, status, created_at desc); + +create index if not exists idx_commission_exports_settlement + on public.commission_settlement_export_events(tenant_id, settlement_id, created_at desc); + +alter table public.commission_settlement_proofs enable row level security; +alter table public.commission_settlement_export_events enable row level security; + +drop policy if exists tenant_isolation on public.commission_settlement_proofs; +create policy tenant_isolation on public.commission_settlement_proofs + 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_export_events; +create policy tenant_isolation on public.commission_settlement_export_events + 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.commission_settlement_proofs; +create trigger set_updated_at + before update on public.commission_settlement_proofs + for each row execute function app.touch_updated_at();