diff --git a/README.md b/README.md index 824fdadd..a4ece9ca 100644 --- a/README.md +++ b/README.md @@ -22,14 +22,14 @@ - `apps/worker` 后台任务进程:CRM webhook 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志;commerce worker 可补偿查询微信/支付宝支付和退款状态;provider-bills worker 可下载微信/支付宝官方账单并导入资金对账;assets worker 可复检托管资源元数据、执行内置安全扫描并自动下架异常资源;imports worker 可执行大批量导入;public-banks worker 可自动同步公共题库采纳副本;exports worker 可渲染 PDF/Word 导出文件和每日一练 ZIP 图片素材包。 - 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态、CSV/JSON 导出、打款凭证登记/复核和权限隔离。 - 订单售后基础闭环:退款请求、审核、处理状态流、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。 -- 资金对账和差错工单闭环:租户财务/运营可通过 `/api/commerce/reconciliation/*` 导入或预览支付/退款账单行,也可创建微信/支付宝官方账单下载任务;后端按租户隔离比对本地订单、支付、退款记录,识别已匹配、金额不一致、状态不一致、供应商有本地无、本地有供应商无、重复行和无效行,并写入对账批次、明细和审计日志;异常明细可创建差错工单,支持分配、开始处理、升级、解决、忽略、重开和事件留痕。工单只做财务审核闭环,不直接修改订单、支付、退款或权益。 +- 资金对账、异常订单和财务凭证闭环:租户财务/运营可通过 `/api/commerce/reconciliation/*` 导入或预览支付/退款账单行,也可创建微信/支付宝官方账单下载任务;后端按租户隔离比对本地订单、支付、退款记录,识别已匹配、金额不一致、状态不一致、供应商有本地无、本地有供应商无、重复行和无效行,并写入对账批次、明细和审计日志;异常明细可创建差错工单,支持分配、开始处理、升级、解决、忽略、重开和事件留痕;`/api/commerce/operations/anomalies` 聚合异常订单风险,`/api/commerce/adjustment-vouchers*` 支持人工调整凭证、复核、事件轨迹和报表。工单和凭证只做财务审核闭环,不直接修改订单、支付、退款或权益。 - PocketBase schema/数据导入器雏形和导入后校验脚本。 - 本地 Supabase reset、烟测 seed、API 集成测试、完整重构检查命令。 还没有达到生产交付的部分: - Supabase Auth/JWT、租户角色模板、班级/教师/学生范围权限已可联调;生产前还要做真实云端 Auth/JWKS 回归和 RLS 深测。 -- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、微信支付、支付宝主链路、微信/支付宝发起退款/查询确认/退款通知、支付/退款补偿 worker 已完成本地适配;资金对账已支持手工/API 账单导入、微信/支付宝官方账单下载任务、provider-bills worker 自动导入比对和差错工单处理;异常订单运营台、人工调整凭证复核报表和真实生产账号联调还没接完。 +- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、微信支付、支付宝主链路、微信/支付宝发起退款/查询确认/退款通知、支付/退款补偿 worker 已完成本地适配;资金对账已支持手工/API 账单导入、微信/支付宝官方账单下载任务、provider-bills worker 自动导入比对、差错工单处理、异常订单运营台和人工调整凭证复核报表;真实生产账号、真实回调域名和真实生产账单抽样验收还没接完。 - 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 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。 @@ -316,7 +316,7 @@ API 身份上下文: - 题库入口和分类使用 `content_entries/content_nodes`;题目列表和练习规则使用 `question_collections/practice_blueprints`,前端不要再把旧树字段当成唯一业务结构。 - 批量导入必须先写 `content_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录。题目、单词、知识手册、分数线和视频 JSON/CSV/Excel 导入已走这套后台校验管线;大批量任务可提交 `executionMode=async`,由 imports worker 消费,前端只轮询 job 状态和展示 issues。 - 题库导出必须由后端按权限生成,不允许前端直接读取数据库拼导出文件;不开启答案/解析时,顶层题目和复合题子题都必须脱敏;PDF/Word/每日一练 ZIP 只通过 exports worker 写入 `content_assets` 后再签名下载/预览。 -- 支付 webhook 必须先设计幂等键和验签流程,再进入生产使用;生产环境还应定时运行 commerce worker 兜底供应商漏通知和处理中退款,并定时运行 provider-bills worker 下载官方账单核对本地订单。官方账单下载任务只保存下载域名、hash 和对账批次 ID,不向前端暴露下载 URL 或商户密钥。对账差错工单只允许记录财务处理结论和凭证,不允许前端或工单接口直接篡改订单、支付、退款或权益状态。 +- 支付 webhook 必须先设计幂等键和验签流程,再进入生产使用;生产环境还应定时运行 commerce worker 兜底供应商漏通知和处理中退款,并定时运行 provider-bills worker 下载官方账单核对本地订单。官方账单下载任务只保存下载域名、hash 和对账批次 ID,不向前端暴露下载 URL 或商户密钥。对账差错工单和人工调整凭证只允许记录财务处理结论、附件引用和审计事件,不允许前端、工单接口或凭证审批接口直接篡改订单、支付、退款或权益状态。 ## 最近一次验证 @@ -335,7 +335,7 @@ npm run audit:runtime git diff --check ``` -结果:通过。`npm run test:api` 覆盖资源访问事件、锁定 CDN 资源拒绝、provider-managed CDN 显式放行、学生短 TTL 下载/预览、访问记录查询、安全扫描门禁、官方账单下载任务权限和脱敏响应。`npm run test:worker:commerce` 覆盖支付/退款补偿、微信/支付宝官方账单下载、账单 hash 校验、导入 `provider_download` 对账批次和密钥不泄露。`npm run test:worker:assets` 覆盖托管资源复检、内置安全扫描、外部 HTTP scanner 通过/失败/不可用 fail-closed、扫描失败/跳过事件和异常资源自动下架。`npm run test:worker:exports` 覆盖导出 worker 生成可信资源并标记 `securityScanStatus=passed`。`npm run audit:runtime` 无 high/critical 漏洞;当前运行时依赖树仍有 `exceljs -> uuid` 的 moderate 级提示,修复需要破坏性降级 `exceljs`,后续应在导入 Excel 回归充分后单独处理。 +结果:通过。`npm run test:api` 覆盖资源访问事件、锁定 CDN 资源拒绝、provider-managed CDN 显式放行、学生短 TTL 下载/预览、访问记录查询、安全扫描门禁、官方账单下载任务权限和脱敏响应、异常订单运营台、人工调整凭证提交/复核/事件/报表、租户隔离,以及凭证审批不修改订单/支付/权益。`npm run test:worker:commerce` 覆盖支付/退款补偿、微信/支付宝官方账单下载、账单 hash 校验、导入 `provider_download` 对账批次和密钥不泄露。`npm run test:worker:assets` 覆盖托管资源复检、内置安全扫描、外部 HTTP scanner 通过/失败/不可用 fail-closed、扫描失败/跳过事件和异常资源自动下架。`npm run test:worker:exports` 覆盖导出 worker 生成可信资源并标记 `securityScanStatus=passed`。`npm run audit:runtime` 无 high/critical 漏洞;当前运行时依赖树仍有 `exceljs -> uuid` 的 moderate 级提示,修复需要破坏性降级 `exceljs`,后续应在导入 Excel 回归充分后单独处理。 注意:`apps/taro` 是静态构建工程,线上发布 `apps/taro/dist/**`,不发布 `node_modules`。Taro 4.2.0 当前构建工具链仍会触发 `npm run audit:taro:toolchain` 的上游 high/critical 提示,不能用 `npm audit fix --force` 降级到 Taro 3 破坏构建;上线验收时以 `audit:runtime`、构建产物、前端密钥检查和静态服务器配置为准,并持续跟进 Taro 官方修复。 @@ -347,4 +347,4 @@ git diff --check 2. 继续补 Taro 前端:学生端视频/反馈/模考报告/订单收银台,租户后台写入表单/导入操作台/公共题库同步/角色模板 UI,平台后台租户详情/审计/自动计费增强,小程序兼容验证。 3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、转码/CDN 级水印和生命周期策略。 4. 题库导出模板精排、导出操作台、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。 -5. 真实 OAuth/短信/支付生产账号联调、异常订单运营台、人工调整凭证复核、公共题库版本通知/冲突处理操作台、积分活动深化,以及排行榜防刷/预聚合。 +5. 真实 OAuth/短信/支付生产账号联调、真实生产账单抽样验收、财务操作台前端体验、公共题库版本通知/冲突处理操作台、积分活动深化,以及排行榜防刷/预聚合。 diff --git a/apps/api/src/features/commerce/adjustments.ts b/apps/api/src/features/commerce/adjustments.ts new file mode 100644 index 00000000..914bc2f3 --- /dev/null +++ b/apps/api/src/features/commerce/adjustments.ts @@ -0,0 +1,1029 @@ +import crypto from 'node:crypto'; +import type pg from 'pg'; +import { HttpError, type RequestContext } from '../../core/http.js'; +import { intParam, readJsonBody, stringParam } from '../../core/request.js'; +import { query, transaction } from '../../core/db.js'; +import { requireTenantAdmin, requireTenantPermission, type TenantAdminAuth } from '../tenant-admin/auth.js'; + +type VoucherSourceType = 'reconciliation_issue' | 'reconciliation_item' | 'order' | 'payment' | 'refund' | 'manual'; +type AdjustmentType = + | 'manual_payment_confirm' + | 'refund_correction' + | 'provider_confirmed' + | 'local_corrected' + | 'write_off' + | 'duplicate' + | 'other'; +type AdjustmentDirection = 'increase' | 'decrease' | 'none'; +type VoucherStatus = 'draft' | 'submitted' | 'approved' | 'rejected' | 'voided'; + +const SOURCE_TYPES = new Set([ + 'reconciliation_issue', + 'reconciliation_item', + 'order', + 'payment', + 'refund', + 'manual', +]); +const ADJUSTMENT_TYPES = new Set([ + 'manual_payment_confirm', + 'refund_correction', + 'provider_confirmed', + 'local_corrected', + 'write_off', + 'duplicate', + 'other', +]); +const DIRECTIONS = new Set(['increase', 'decrease', 'none']); +const STATUSES = new Set(['draft', 'submitted', 'approved', 'rejected', 'voided']); +const CREATE_STATUSES = new Set(['draft', 'submitted']); +const CLOSED_STATUSES = new Set(['approved', 'rejected', 'voided']); +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +interface VoucherSource { + sourceType: VoucherSourceType; + sourceId: string | null; + reconciliationIssueId: string | null; + reconciliationItemId: string | null; + orderId: string | null; + paymentId: string | null; + refundRequestId: string | null; + orderNo: string | null; + refundNo: string | null; + provider: string | null; + providerTradeNo: string | null; + providerRefundNo: string | null; + titleHint: string; + amountCents: number; +} + +function nullableString(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function uuidValue(value: unknown, fieldName: string) { + const text = nullableString(value); + if (!text) return null; + if (!UUID_PATTERN.test(text)) { + throw new HttpError(400, `${fieldName} must be a valid UUID`, 'INVALID_UUID'); + } + return text; +} + +function objectValue(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} + +function intValue(value: unknown, fallback = 0) { + const parsed = Number(value ?? fallback); + return Number.isFinite(parsed) ? Math.trunc(parsed) : fallback; +} + +function optionalChoice(value: unknown, allowed: Set, fallback: T, code = 'INVALID_FIELD_VALUE') { + const candidate = (nullableString(value) || fallback) as T; + if (!allowed.has(candidate)) { + throw new HttpError(400, `Invalid value: ${candidate}`, code); + } + return candidate; +} + +function dateParam(ctx: RequestContext, key: string, fallback: string) { + const candidate = stringParam(ctx, key) || fallback; + if (!/^\d{4}-\d{2}-\d{2}$/.test(candidate)) { + throw new HttpError(400, `${key} must use YYYY-MM-DD format`, 'INVALID_DATE'); + } + return candidate; +} + +function shanghaiDateKey(date = new 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 normalizeVoucherNo(value: unknown) { + const supplied = nullableString(value); + if (supplied) { + const normalized = supplied.replace(/\s+/g, '').toUpperCase(); + if (normalized.length > 80) throw new HttpError(400, 'voucherNo is too long', 'ADJUSTMENT_VOUCHER_NO_INVALID'); + return normalized; + } + return `ADJ-${shanghaiDateKey().replace(/-/g, '')}-${crypto.randomInt(100000, 999999)}`; +} + +function titleValue(value: unknown, fallback: string) { + const title = nullableString(value) || fallback; + return title.length > 120 ? title.slice(0, 120) : title; +} + +function externalUrlValue(value: unknown) { + const url = nullableString(value); + if (!url) return null; + if (url.length > 1000) throw new HttpError(400, 'externalUrl is too long', 'ADJUSTMENT_EXTERNAL_URL_INVALID'); + 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, 'externalUrl must be a valid http(s) URL', 'ADJUSTMENT_EXTERNAL_URL_INVALID'); + } +} + +async function authorizeRead(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'tenant:reconciliation:read'); + return auth; +} + +async function authorizeWrite(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'tenant:reconciliation:write'); + return auth; +} + +async function authorizeReview(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'tenant:reconciliation:review'); + return auth; +} + +async function assertTenantAsset(client: pg.PoolClient, tenantId: string, assetId: string | null) { + const safeAssetId = uuidValue(assetId, 'assetId'); + if (!safeAssetId) 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, safeAssetId], + ); + if (!result.rows[0]) { + throw new HttpError(400, 'Adjustment proof asset does not belong to this tenant', 'ADJUSTMENT_ASSET_INVALID'); + } + return safeAssetId; +} + +function requestedSourceType(body: Record) { + if (nullableString(body.sourceType)) return optionalChoice(body.sourceType, SOURCE_TYPES, 'manual', 'ADJUSTMENT_SOURCE_TYPE_INVALID'); + if (nullableString(body.reconciliationIssueId)) return 'reconciliation_issue'; + if (nullableString(body.reconciliationItemId)) return 'reconciliation_item'; + if (nullableString(body.refundRequestId) || nullableString(body.refundNo)) return 'refund'; + if (nullableString(body.paymentId)) return 'payment'; + if (nullableString(body.orderId) || nullableString(body.orderNo)) return 'order'; + return 'manual'; +} + +async function resolveVoucherSource(client: pg.PoolClient, auth: TenantAdminAuth, body: Record): Promise { + const sourceType = requestedSourceType(body); + const sourceId = uuidValue(body.sourceId, 'sourceId'); + + if (sourceType === 'reconciliation_issue') { + const id = uuidValue(body.reconciliationIssueId, 'reconciliationIssueId') || sourceId; + if (!id) throw new HttpError(400, 'reconciliationIssueId is required', 'ADJUSTMENT_SOURCE_ID_REQUIRED'); + const result = await client.query>( + ` + select id, item_id as "itemId", order_id as "orderId", payment_id as "paymentId", + refund_request_id as "refundRequestId", order_no as "orderNo", refund_no as "refundNo", + provider, provider_trade_no as "providerTradeNo", provider_refund_no as "providerRefundNo", + amount_cents as "amountCents", refund_amount_cents as "refundAmountCents", + issue_no as "issueNo", match_status as "matchStatus" + from public.commerce_reconciliation_issues + where tenant_id = $1 and id = $2 + limit 1 + `, + [auth.tenantId, id], + ); + const row = result.rows[0]; + if (!row) throw new HttpError(404, 'Reconciliation issue not found', 'RECONCILIATION_ISSUE_NOT_FOUND'); + return { + sourceType, + sourceId: String(row.id), + reconciliationIssueId: String(row.id), + reconciliationItemId: nullableString(row.itemId), + orderId: nullableString(row.orderId), + paymentId: nullableString(row.paymentId), + refundRequestId: nullableString(row.refundRequestId), + orderNo: nullableString(row.orderNo), + refundNo: nullableString(row.refundNo), + provider: nullableString(row.provider), + providerTradeNo: nullableString(row.providerTradeNo), + providerRefundNo: nullableString(row.providerRefundNo), + titleHint: `差错工单 ${row.issueNo || row.matchStatus || row.id}`, + amountCents: intValue(row.refundAmountCents) || intValue(row.amountCents), + }; + } + + if (sourceType === 'reconciliation_item') { + const id = uuidValue(body.reconciliationItemId, 'reconciliationItemId') || sourceId; + if (!id) throw new HttpError(400, 'reconciliationItemId is required', 'ADJUSTMENT_SOURCE_ID_REQUIRED'); + const result = await client.query>( + ` + select id, order_id as "orderId", payment_id as "paymentId", + refund_request_id as "refundRequestId", order_no as "orderNo", refund_no as "refundNo", + provider, provider_trade_no as "providerTradeNo", provider_refund_no as "providerRefundNo", + amount_cents as "amountCents", refund_amount_cents as "refundAmountCents", + match_status as "matchStatus", issue_code as "issueCode" + from public.commerce_reconciliation_items + where tenant_id = $1 and id = $2 + limit 1 + `, + [auth.tenantId, id], + ); + const row = result.rows[0]; + if (!row) throw new HttpError(404, 'Reconciliation item not found', 'RECONCILIATION_ITEM_NOT_FOUND'); + return { + sourceType, + sourceId: String(row.id), + reconciliationIssueId: null, + reconciliationItemId: String(row.id), + orderId: nullableString(row.orderId), + paymentId: nullableString(row.paymentId), + refundRequestId: nullableString(row.refundRequestId), + orderNo: nullableString(row.orderNo), + refundNo: nullableString(row.refundNo), + provider: nullableString(row.provider), + providerTradeNo: nullableString(row.providerTradeNo), + providerRefundNo: nullableString(row.providerRefundNo), + titleHint: `对账明细 ${row.issueCode || row.matchStatus || row.id}`, + amountCents: intValue(row.refundAmountCents) || intValue(row.amountCents), + }; + } + + if (sourceType === 'order') { + const orderNo = nullableString(body.orderNo); + const id = uuidValue(body.orderId, 'orderId') || sourceId; + if (!id && !orderNo) throw new HttpError(400, 'orderId or orderNo is required', 'ADJUSTMENT_SOURCE_ID_REQUIRED'); + const result = await client.query>( + ` + select o.id, o.order_no as "orderNo", o.pay_provider as "provider", + o.trade_no as "providerTradeNo", o.amount_cents as "amountCents", + p.id as "paymentId", p.provider_trade_no as "paymentProviderTradeNo" + from public.orders o + left join public.payments p on p.tenant_id = o.tenant_id and p.order_id = o.id + where o.tenant_id = $1 and (($2::uuid is not null and o.id = $2::uuid) or ($3::text is not null and o.order_no = $3)) + order by p.created_at desc nulls last + limit 1 + `, + [auth.tenantId, id, orderNo], + ); + const row = result.rows[0]; + if (!row) throw new HttpError(404, 'Order not found', 'ORDER_NOT_FOUND'); + return { + sourceType, + sourceId: String(row.id), + reconciliationIssueId: null, + reconciliationItemId: null, + orderId: String(row.id), + paymentId: nullableString(row.paymentId), + refundRequestId: null, + orderNo: nullableString(row.orderNo), + refundNo: null, + provider: nullableString(row.provider), + providerTradeNo: nullableString(row.paymentProviderTradeNo) || nullableString(row.providerTradeNo), + providerRefundNo: null, + titleHint: `订单 ${row.orderNo || row.id}`, + amountCents: intValue(row.amountCents), + }; + } + + if (sourceType === 'payment') { + const id = uuidValue(body.paymentId, 'paymentId') || sourceId; + if (!id) throw new HttpError(400, 'paymentId is required', 'ADJUSTMENT_SOURCE_ID_REQUIRED'); + const result = await client.query>( + ` + select p.id, p.order_id as "orderId", o.order_no as "orderNo", p.provider, + p.provider_trade_no as "providerTradeNo", p.amount_cents as "amountCents" + from public.payments p + join public.orders o on o.tenant_id = p.tenant_id and o.id = p.order_id + where p.tenant_id = $1 and p.id = $2 + limit 1 + `, + [auth.tenantId, id], + ); + const row = result.rows[0]; + if (!row) throw new HttpError(404, 'Payment not found', 'PAYMENT_NOT_FOUND'); + return { + sourceType, + sourceId: String(row.id), + reconciliationIssueId: null, + reconciliationItemId: null, + orderId: nullableString(row.orderId), + paymentId: String(row.id), + refundRequestId: null, + orderNo: nullableString(row.orderNo), + refundNo: null, + provider: nullableString(row.provider), + providerTradeNo: nullableString(row.providerTradeNo), + providerRefundNo: null, + titleHint: `支付 ${row.orderNo || row.providerTradeNo || row.id}`, + amountCents: intValue(row.amountCents), + }; + } + + if (sourceType === 'refund') { + const refundNo = nullableString(body.refundNo); + const id = uuidValue(body.refundRequestId, 'refundRequestId') || sourceId; + if (!id && !refundNo) throw new HttpError(400, 'refundRequestId or refundNo is required', 'ADJUSTMENT_SOURCE_ID_REQUIRED'); + const result = await client.query>( + ` + select rr.id, rr.order_id as "orderId", rr.payment_id as "paymentId", + o.order_no as "orderNo", rr.refund_no as "refundNo", rr.provider, + rr.provider_refund_no as "providerRefundNo", p.provider_trade_no as "providerTradeNo", + rr.amount_cents as "amountCents" + from public.commerce_refund_requests rr + join public.orders o on o.tenant_id = rr.tenant_id and o.id = rr.order_id + left join public.payments p on p.tenant_id = rr.tenant_id and p.id = rr.payment_id + where rr.tenant_id = $1 and (($2::uuid is not null and rr.id = $2::uuid) or ($3::text is not null and rr.refund_no = $3)) + limit 1 + `, + [auth.tenantId, id, refundNo], + ); + const row = result.rows[0]; + if (!row) throw new HttpError(404, 'Refund request not found', 'REFUND_NOT_FOUND'); + return { + sourceType, + sourceId: String(row.id), + reconciliationIssueId: null, + reconciliationItemId: null, + orderId: nullableString(row.orderId), + paymentId: nullableString(row.paymentId), + refundRequestId: String(row.id), + orderNo: nullableString(row.orderNo), + refundNo: nullableString(row.refundNo), + provider: nullableString(row.provider), + providerTradeNo: nullableString(row.providerTradeNo), + providerRefundNo: nullableString(row.providerRefundNo), + titleHint: `退款 ${row.refundNo || row.orderNo || row.id}`, + amountCents: intValue(row.amountCents), + }; + } + + return { + sourceType: 'manual', + sourceId: null, + reconciliationIssueId: null, + reconciliationItemId: null, + orderId: null, + paymentId: null, + refundRequestId: null, + orderNo: nullableString(body.orderNo), + refundNo: nullableString(body.refundNo), + provider: nullableString(body.provider), + providerTradeNo: nullableString(body.providerTradeNo), + providerRefundNo: nullableString(body.providerRefundNo), + titleHint: '人工调整凭证', + amountCents: intValue(body.amountCents), + }; +} + +function voucherPayload(row: Record) { + return { + id: row.id, + voucherNo: row.voucherNo, + sourceType: row.sourceType, + sourceId: row.sourceId, + reconciliationIssueId: row.reconciliationIssueId, + reconciliationItemId: row.reconciliationItemId, + orderId: row.orderId, + paymentId: row.paymentId, + refundRequestId: row.refundRequestId, + orderNo: row.orderNo, + refundNo: row.refundNo, + provider: row.provider, + providerTradeNo: row.providerTradeNo, + providerRefundNo: row.providerRefundNo, + adjustmentType: row.adjustmentType, + direction: row.direction, + amountCents: row.amountCents, + status: row.status, + title: row.title, + description: row.description, + assetId: row.assetId, + externalUrl: row.externalUrl, + submittedBy: row.submittedBy, + submittedByName: row.submittedByName, + submittedAt: row.submittedAt, + reviewedBy: row.reviewedBy, + reviewedByName: row.reviewedByName, + reviewedAt: row.reviewedAt, + reviewNote: row.reviewNote, + voidedBy: row.voidedBy, + voidedAt: row.voidedAt, + metadata: row.metadata || {}, + createdBy: row.createdBy, + createdByName: row.createdByName, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +function voucherSelectSql() { + return ` + select v.id, v.voucher_no as "voucherNo", v.source_type as "sourceType", v.source_id as "sourceId", + v.reconciliation_issue_id as "reconciliationIssueId", + v.reconciliation_item_id as "reconciliationItemId", + v.order_id as "orderId", v.payment_id as "paymentId", + v.refund_request_id as "refundRequestId", v.order_no as "orderNo", + v.refund_no as "refundNo", v.provider, v.provider_trade_no as "providerTradeNo", + v.provider_refund_no as "providerRefundNo", v.adjustment_type as "adjustmentType", + v.direction, v.amount_cents as "amountCents", v.status, + v.title, v.description, v.asset_id as "assetId", v.external_url as "externalUrl", + v.submitted_by as "submittedBy", submitter.name as "submittedByName", + v.submitted_at as "submittedAt", v.reviewed_by as "reviewedBy", + reviewer.name as "reviewedByName", v.reviewed_at as "reviewedAt", + v.review_note as "reviewNote", v.voided_by as "voidedBy", + v.voided_at as "voidedAt", v.metadata, v.created_by as "createdBy", + creator.name as "createdByName", v.created_at as "createdAt", + v.updated_at as "updatedAt" + from public.commerce_adjustment_vouchers v + left join public.platform_users submitter on submitter.id = v.submitted_by + left join public.platform_users reviewer on reviewer.id = v.reviewed_by + left join public.platform_users creator on creator.id = v.created_by + `; +} + +async function fetchVoucherForTenant(client: pg.PoolClient, tenantId: string, voucherId: string, lock = false) { + const safeVoucherId = uuidValue(voucherId, 'voucherId'); + if (!safeVoucherId) return null; + const result = await client.query>( + ` + ${voucherSelectSql()} + where v.tenant_id = $1 and v.id = $2 + limit 1 + ${lock ? 'for update of v' : ''} + `, + [tenantId, safeVoucherId], + ); + return result.rows[0] || null; +} + +async function recordVoucherEvent( + client: pg.PoolClient, + auth: TenantAdminAuth, + input: { + voucherId: string; + fromStatus?: string | null; + toStatus?: string | null; + eventType: string; + note?: string | null; + details?: Record; + }, +) { + await client.query( + ` + insert into public.commerce_adjustment_voucher_events ( + tenant_id, voucher_id, from_status, to_status, event_type, actor_user_id, note, details + ) + values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb) + `, + [ + auth.tenantId, + input.voucherId, + input.fromStatus || null, + input.toStatus || null, + input.eventType, + auth.userId, + input.note || null, + JSON.stringify(input.details || {}), + ], + ); +} + +async function recordAudit( + client: pg.PoolClient, + auth: TenantAdminAuth, + action: string, + targetId: string | null, + details: Record, +) { + await client.query( + ` + insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details) + values ($1, $2, $3, 'commerce_adjustment_voucher', $4, $5::jsonb) + `, + [auth.tenantId, auth.userId, action, targetId, JSON.stringify(details)], + ); +} + +function assertVoucherTransition(currentStatus: VoucherStatus, nextStatus: VoucherStatus) { + if (nextStatus === 'draft') { + throw new HttpError(400, 'Cannot move voucher back to draft through status API', 'ADJUSTMENT_STATUS_INVALID'); + } + if (currentStatus === nextStatus) return; + if (CLOSED_STATUSES.has(currentStatus)) { + throw new HttpError(409, 'Closed adjustment voucher cannot change status', 'ADJUSTMENT_VOUCHER_CLOSED'); + } + if (currentStatus === 'draft' && !['submitted', 'voided'].includes(nextStatus)) { + throw new HttpError(409, 'Draft voucher must be submitted before review', 'ADJUSTMENT_STATUS_CONFLICT'); + } + if (currentStatus === 'submitted' && !['approved', 'rejected', 'voided'].includes(nextStatus)) { + throw new HttpError(409, 'Submitted voucher can only be approved, rejected, or voided', 'ADJUSTMENT_STATUS_CONFLICT'); + } +} + +export async function adjustmentVouchersRoute(ctx: RequestContext) { + const auth = await authorizeRead(ctx); + const limit = intParam(ctx, 'limit', 50, 200); + const status = stringParam(ctx, 'status'); + const sourceType = stringParam(ctx, 'sourceType'); + const orderNo = stringParam(ctx, 'orderNo'); + const voucherNo = stringParam(ctx, 'voucherNo'); + const issueId = uuidValue(stringParam(ctx, 'reconciliationIssueId') || stringParam(ctx, 'issueId'), 'reconciliationIssueId'); + + const params: unknown[] = [auth.tenantId, limit]; + const where = ['v.tenant_id = $1']; + if (status) { + if (!STATUSES.has(status as VoucherStatus)) throw new HttpError(400, 'status is invalid', 'ADJUSTMENT_STATUS_INVALID'); + params.push(status); + where.push(`v.status = $${params.length}`); + } + if (sourceType) { + if (!SOURCE_TYPES.has(sourceType as VoucherSourceType)) throw new HttpError(400, 'sourceType is invalid', 'ADJUSTMENT_SOURCE_TYPE_INVALID'); + params.push(sourceType); + where.push(`v.source_type = $${params.length}`); + } + if (orderNo) { + params.push(orderNo); + where.push(`v.order_no = $${params.length}`); + } + if (voucherNo) { + params.push(voucherNo); + where.push(`v.voucher_no = $${params.length}`); + } + if (issueId) { + params.push(issueId); + where.push(`v.reconciliation_issue_id = $${params.length}::uuid`); + } + + const items = await query>( + ` + ${voucherSelectSql()} + where ${where.join(' and ')} + order by v.created_at desc + limit $2 + `, + params, + ); + return { items: items.map(voucherPayload) }; +} + +export async function createAdjustmentVoucherRoute(ctx: RequestContext) { + const auth = await authorizeWrite(ctx); + const body = await readJsonBody(ctx); + const status = optionalChoice(body.status, CREATE_STATUSES, 'submitted', 'ADJUSTMENT_STATUS_INVALID'); + const adjustmentType = optionalChoice(body.adjustmentType, ADJUSTMENT_TYPES, 'other', 'ADJUSTMENT_TYPE_INVALID'); + const direction = optionalChoice(body.direction, DIRECTIONS, 'none', 'ADJUSTMENT_DIRECTION_INVALID'); + const amountCents = Math.max(0, intValue(body.amountCents)); + const voucherNo = normalizeVoucherNo(body.voucherNo); + const externalUrl = externalUrlValue(body.externalUrl); + const note = nullableString(body.note); + + const item = await transaction(async client => { + const source = await resolveVoucherSource(client, auth, body); + const assetId = await assertTenantAsset(client, auth.tenantId, nullableString(body.assetId)); + const effectiveAmountCents = amountCents || source.amountCents; + const result = await client.query>( + ` + insert into public.commerce_adjustment_vouchers ( + tenant_id, voucher_no, source_type, source_id, + reconciliation_issue_id, reconciliation_item_id, order_id, payment_id, refund_request_id, + order_no, refund_no, provider, provider_trade_no, provider_refund_no, + adjustment_type, direction, amount_cents, status, title, description, + asset_id, external_url, submitted_by, submitted_at, metadata, created_by + ) + values ( + $1, $2, $3, $4::uuid, + $5::uuid, $6::uuid, $7::uuid, $8::uuid, $9::uuid, + $10, $11, $12, $13, $14, + $15, $16, $17, $18, $19, $20, + $21::uuid, $22, case when $18 = 'submitted' then $23::uuid else null end, + case when $18 = 'submitted' then now() else null end, + $24::jsonb, $23::uuid + ) + on conflict (tenant_id, voucher_no) do nothing + returning id + `, + [ + auth.tenantId, + voucherNo, + source.sourceType, + source.sourceId, + source.reconciliationIssueId, + source.reconciliationItemId, + source.orderId, + source.paymentId, + source.refundRequestId, + source.orderNo, + source.refundNo, + source.provider, + source.providerTradeNo, + source.providerRefundNo, + adjustmentType, + direction, + effectiveAmountCents, + status, + titleValue(body.title, source.titleHint), + nullableString(body.description), + assetId, + externalUrl, + auth.userId, + JSON.stringify(objectValue(body.metadata)), + ], + ); + if (!result.rows[0]) { + throw new HttpError(409, 'Adjustment voucher number already exists', 'ADJUSTMENT_VOUCHER_DUPLICATE'); + } + const voucherId = String(result.rows[0].id); + await recordVoucherEvent(client, auth, { + voucherId, + toStatus: status, + eventType: status === 'submitted' ? 'submitted' : 'created', + note, + details: { + sourceType: source.sourceType, + sourceId: source.sourceId, + adjustmentType, + direction, + amountCents: effectiveAmountCents, + hasAsset: Boolean(assetId), + hasExternalUrl: Boolean(externalUrl), + }, + }); + await recordAudit(client, auth, 'commerce.adjustment_voucher.created', voucherId, { + voucherNo, + sourceType: source.sourceType, + sourceId: source.sourceId, + adjustmentType, + direction, + amountCents: effectiveAmountCents, + status, + }); + const row = await fetchVoucherForTenant(client, auth.tenantId, voucherId); + return row || result.rows[0]; + }); + + return { item: voucherPayload(item) }; +} + +export async function updateAdjustmentVoucherStatusRoute(ctx: RequestContext) { + const body = await readJsonBody(ctx); + const nextStatus = optionalChoice(body.status, STATUSES, 'approved', 'ADJUSTMENT_STATUS_INVALID'); + const auth = nextStatus === 'submitted' ? await authorizeWrite(ctx) : await authorizeReview(ctx); + const voucherId = uuidValue(body.voucherId, 'voucherId'); + if (!voucherId) throw new HttpError(400, 'voucherId is required', 'ADJUSTMENT_VOUCHER_ID_REQUIRED'); + const reviewNote = nullableString(body.reviewNote) || nullableString(body.note); + + const result = await transaction(async client => { + const current = await fetchVoucherForTenant(client, auth.tenantId, voucherId, true); + if (!current) throw new HttpError(404, 'Adjustment voucher not found', 'ADJUSTMENT_VOUCHER_NOT_FOUND'); + const currentStatus = String(current.status) as VoucherStatus; + assertVoucherTransition(currentStatus, nextStatus); + + if (currentStatus === nextStatus) { + return { item: current, idempotent: true }; + } + + const update = await client.query>( + ` + update public.commerce_adjustment_vouchers + set status = $3, + submitted_by = case when $3 = 'submitted' and submitted_by is null then $4::uuid else submitted_by end, + submitted_at = case when $3 = 'submitted' and submitted_at is null then now() else submitted_at end, + reviewed_by = case when $3 in ('approved', 'rejected') then $4::uuid else reviewed_by end, + reviewed_at = case when $3 in ('approved', 'rejected') then now() else reviewed_at end, + review_note = case when $3 in ('approved', 'rejected') then coalesce($5, review_note) else review_note end, + voided_by = case when $3 = 'voided' then $4::uuid else voided_by end, + voided_at = case when $3 = 'voided' then now() else voided_at end, + metadata = metadata || $6::jsonb, + updated_at = now() + where tenant_id = $1 and id = $2 + returning id + `, + [ + auth.tenantId, + voucherId, + nextStatus, + auth.userId, + reviewNote, + JSON.stringify(objectValue(body.metadata)), + ], + ); + if (!update.rows[0]) throw new HttpError(404, 'Adjustment voucher not found', 'ADJUSTMENT_VOUCHER_NOT_FOUND'); + + await recordVoucherEvent(client, auth, { + voucherId, + fromStatus: currentStatus, + toStatus: nextStatus, + eventType: nextStatus, + note: reviewNote, + details: { + previousStatus: currentStatus, + nextStatus, + businessStateMutated: false, + }, + }); + await recordAudit(client, auth, 'commerce.adjustment_voucher.status_updated', voucherId, { + fromStatus: currentStatus, + toStatus: nextStatus, + businessStateMutated: false, + }); + + const row = await fetchVoucherForTenant(client, auth.tenantId, voucherId); + return { item: row || current, idempotent: false }; + }); + + return { item: voucherPayload(result.item), idempotent: result.idempotent }; +} + +export async function adjustmentVoucherEventsRoute(ctx: RequestContext) { + const auth = await authorizeRead(ctx); + const voucherId = uuidValue(stringParam(ctx, 'voucherId'), 'voucherId'); + if (!voucherId) throw new HttpError(400, 'voucherId is required', 'ADJUSTMENT_VOUCHER_ID_REQUIRED'); + const limit = intParam(ctx, 'limit', 100, 300); + + const voucher = await query>( + 'select id from public.commerce_adjustment_vouchers where tenant_id = $1 and id = $2 limit 1', + [auth.tenantId, voucherId], + ); + if (!voucher[0]) throw new HttpError(404, 'Adjustment voucher not found', 'ADJUSTMENT_VOUCHER_NOT_FOUND'); + + const items = await query>( + ` + select e.id, e.voucher_id as "voucherId", e.from_status as "fromStatus", + e.to_status as "toStatus", e.event_type as "eventType", + e.actor_user_id as "actorUserId", actor.name as "actorName", + e.note, e.details, e.created_at as "createdAt" + from public.commerce_adjustment_voucher_events e + left join public.platform_users actor on actor.id = e.actor_user_id + where e.tenant_id = $1 and e.voucher_id = $2 + order by e.created_at asc + limit $3 + `, + [auth.tenantId, voucherId, limit], + ); + return { items }; +} + +export async function adjustmentVoucherReportRoute(ctx: RequestContext) { + const auth = await authorizeRead(ctx); + const today = shanghaiDateKey(); + const startDate = dateParam(ctx, 'startDate', today.slice(0, 8) + '01'); + const endDate = dateParam(ctx, 'endDate', today); + if (startDate > endDate) throw new HttpError(400, 'startDate must be before or equal to endDate', 'INVALID_DATE_RANGE'); + + const [statusRows, typeRows, sourceRows, dailyRows] = await Promise.all([ + query>( + ` + select status, count(*)::int as count, coalesce(sum(amount_cents), 0)::int as "amountCents" + from public.commerce_adjustment_vouchers + where tenant_id = $1 and created_at >= $2::date and created_at < ($3::date + interval '1 day') + group by status + order by status + `, + [auth.tenantId, startDate, endDate], + ), + query>( + ` + select adjustment_type as "adjustmentType", direction, + count(*)::int as count, coalesce(sum(amount_cents), 0)::int as "amountCents" + from public.commerce_adjustment_vouchers + where tenant_id = $1 and created_at >= $2::date and created_at < ($3::date + interval '1 day') + group by adjustment_type, direction + order by adjustment_type, direction + `, + [auth.tenantId, startDate, endDate], + ), + query>( + ` + select source_type as "sourceType", count(*)::int as count, + coalesce(sum(amount_cents), 0)::int as "amountCents" + from public.commerce_adjustment_vouchers + where tenant_id = $1 and created_at >= $2::date and created_at < ($3::date + interval '1 day') + group by source_type + order by source_type + `, + [auth.tenantId, startDate, endDate], + ), + query>( + ` + select created_at::date::text as date, status, count(*)::int as count, + coalesce(sum(amount_cents), 0)::int as "amountCents" + from public.commerce_adjustment_vouchers + where tenant_id = $1 and created_at >= $2::date and created_at < ($3::date + interval '1 day') + group by created_at::date, status + order by date asc, status + `, + [auth.tenantId, startDate, endDate], + ), + ]); + + const totalCount = statusRows.reduce((sum, row) => sum + intValue(row.count), 0); + const totalAmountCents = statusRows.reduce((sum, row) => sum + intValue(row.amountCents), 0); + return { + item: { + startDate, + endDate, + totalCount, + totalAmountCents, + pendingReviewCount: statusRows + .filter(row => ['draft', 'submitted'].includes(String(row.status))) + .reduce((sum, row) => sum + intValue(row.count), 0), + byStatus: statusRows, + byType: typeRows, + bySource: sourceRows, + daily: dailyRows, + }, + }; +} + +function operationSeverity(severity: unknown, fallback: 'info' | 'warning' | 'error' | 'critical' = 'warning') { + const text = String(severity || fallback); + return ['info', 'warning', 'error', 'critical'].includes(text) ? text : fallback; +} + +export async function commerceOperationsAnomaliesRoute(ctx: RequestContext) { + const auth = await authorizeRead(ctx); + const limit = intParam(ctx, 'limit', 50, 200); + const provider = stringParam(ctx, 'provider'); + const params: unknown[] = [auth.tenantId, Math.max(limit, 20)]; + const providerWhere = provider ? 'and provider = $3' : ''; + if (provider) params.push(provider); + + const [issues, billJobs, paymentEvents, pendingPayments, pendingRefunds] = await Promise.all([ + query>( + ` + select id, issue_no as "issueNo", status, severity, provider, + order_no as "orderNo", refund_no as "refundNo", amount_cents as "amountCents", + refund_amount_cents as "refundAmountCents", summary, created_at as "createdAt", + updated_at as "updatedAt" + from public.commerce_reconciliation_issues + where tenant_id = $1 + and status in ('open', 'investigating', 'escalated') + ${providerWhere} + order by case severity when 'critical' then 1 when 'error' then 2 when 'warning' then 3 else 4 end, + created_at desc + limit $2 + `, + params, + ), + query>( + ` + select id, provider, bill_date as "billDate", bill_type as "billType", status, + error_code as "errorCode", error_message as "errorMessage", + created_at as "createdAt", updated_at as "updatedAt" + from public.commerce_bill_download_jobs + where tenant_id = $1 + and (status = 'failed' or (status = 'running' and claimed_at < now() - interval '30 minutes')) + ${providerWhere} + order by updated_at desc + limit $2 + `, + params, + ), + query>( + ` + select id, payment_id as "paymentId", provider, event_type as "eventType", + event_id as "eventId", error, created_at as "createdAt", processed_at as "processedAt" + from public.payment_events + where tenant_id = $1 and error is not null ${providerWhere} + order by created_at desc + limit $2 + `, + params, + ), + query>( + ` + select p.id, p.order_id as "orderId", o.order_no as "orderNo", + p.provider, p.status, p.amount_cents as "amountCents", + p.provider_trade_no as "providerTradeNo", + p.created_at as "createdAt", p.updated_at as "updatedAt" + from public.payments p + join public.orders o on o.tenant_id = p.tenant_id and o.id = p.order_id + where p.tenant_id = $1 + and p.status = 'pending' + and p.created_at < now() - interval '30 minutes' + ${provider ? 'and p.provider = $3' : ''} + order by p.created_at asc + limit $2 + `, + params, + ), + query>( + ` + select rr.id, rr.refund_no as "refundNo", o.order_no as "orderNo", + coalesce(rr.provider, o.pay_provider) as provider, rr.status, + rr.amount_cents as "amountCents", rr.failure_reason as "failureReason", + rr.created_at as "createdAt", rr.updated_at as "updatedAt" + from public.commerce_refund_requests rr + join public.orders o on o.tenant_id = rr.tenant_id and o.id = rr.order_id + where rr.tenant_id = $1 + and rr.status in ('requested', 'approved', 'processing') + and rr.created_at < now() - interval '1 day' + ${provider ? 'and coalesce(rr.provider, o.pay_provider) = $3' : ''} + order by rr.created_at asc + limit $2 + `, + params, + ), + ]); + + const items = [ + ...issues.map(item => ({ + type: 'reconciliation_issue', + id: item.id, + severity: operationSeverity(item.severity), + status: item.status, + title: item.summary || item.issueNo || '待处理对账差错', + provider: item.provider, + orderNo: item.orderNo, + refundNo: item.refundNo, + amountCents: intValue(item.refundAmountCents) || intValue(item.amountCents), + createdAt: item.createdAt, + updatedAt: item.updatedAt, + source: item, + })), + ...billJobs.map(item => ({ + type: 'provider_bill_job', + id: item.id, + severity: item.status === 'failed' ? 'error' : 'warning', + status: item.status, + title: `${item.provider} ${item.billDate} 官方账单任务异常`, + provider: item.provider, + orderNo: null, + refundNo: null, + amountCents: 0, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + source: item, + })), + ...paymentEvents.map(item => ({ + type: 'payment_event_error', + id: item.id, + severity: 'error', + status: 'error', + title: `支付事件处理失败 ${item.eventType || item.eventId || item.id}`, + provider: item.provider, + orderNo: null, + refundNo: null, + amountCents: 0, + createdAt: item.createdAt, + updatedAt: item.processedAt || item.createdAt, + source: item, + })), + ...pendingPayments.map(item => ({ + type: 'stuck_pending_payment', + id: item.id, + severity: 'warning', + status: item.status, + title: `支付长时间待确认 ${item.orderNo || item.id}`, + provider: item.provider, + orderNo: item.orderNo, + refundNo: null, + amountCents: intValue(item.amountCents), + createdAt: item.createdAt, + updatedAt: item.updatedAt, + source: item, + })), + ...pendingRefunds.map(item => ({ + type: 'stuck_refund', + id: item.id, + severity: item.status === 'processing' ? 'warning' : 'info', + status: item.status, + title: `退款长时间待处理 ${item.refundNo || item.orderNo || item.id}`, + provider: item.provider, + orderNo: item.orderNo, + refundNo: item.refundNo, + amountCents: intValue(item.amountCents), + createdAt: item.createdAt, + updatedAt: item.updatedAt, + source: item, + })), + ] + .sort((a, b) => String(b.updatedAt || b.createdAt).localeCompare(String(a.updatedAt || a.createdAt))) + .slice(0, limit); + + const byType = items.reduce>((acc, item) => { + acc[item.type] = (acc[item.type] || 0) + 1; + return acc; + }, {}); + const bySeverity = items.reduce>((acc, item) => { + acc[item.severity] = (acc[item.severity] || 0) + 1; + return acc; + }, {}); + + return { + summary: { + total: items.length, + byType, + bySeverity, + }, + items, + }; +} diff --git a/apps/api/src/features/commerce/index.ts b/apps/api/src/features/commerce/index.ts index 5bcdde11..a9cc11ef 100644 --- a/apps/api/src/features/commerce/index.ts +++ b/apps/api/src/features/commerce/index.ts @@ -17,6 +17,14 @@ import { refundNotifyRoute, updateRefundStatusRoute, } from './routes.js'; +import { + adjustmentVoucherEventsRoute, + adjustmentVoucherReportRoute, + adjustmentVouchersRoute, + commerceOperationsAnomaliesRoute, + createAdjustmentVoucherRoute, + updateAdjustmentVoucherStatusRoute, +} from './adjustments.js'; import { createReconciliationIssueRoute, importReconciliationRoute, @@ -54,6 +62,12 @@ export const commerceRoutes: RouteDefinition[] = [ ['GET', '/api/commerce/reconciliation/batches', reconciliationBatchesRoute], ['GET', '/api/commerce/reconciliation/items', reconciliationItemsRoute], ['GET', '/api/commerce/reconciliation/anomalies', reconciliationAnomaliesRoute], + ['GET', '/api/commerce/operations/anomalies', commerceOperationsAnomaliesRoute], + ['GET', '/api/commerce/adjustment-vouchers', adjustmentVouchersRoute], + ['POST', '/api/commerce/adjustment-vouchers', createAdjustmentVoucherRoute], + ['POST', '/api/commerce/adjustment-vouchers/status', updateAdjustmentVoucherStatusRoute], + ['GET', '/api/commerce/adjustment-vouchers/events', adjustmentVoucherEventsRoute], + ['GET', '/api/commerce/adjustment-vouchers/report', adjustmentVoucherReportRoute], ['POST', '/api/commerce/reconciliation/provider-bills/request', requestProviderBillDownloadRoute], ['GET', '/api/commerce/reconciliation/provider-bills/jobs', providerBillDownloadJobsRoute], ['POST', '/api/commerce/reconciliation/issues/create', createReconciliationIssueRoute], diff --git a/apps/api/src/features/tenant-admin/auth.ts b/apps/api/src/features/tenant-admin/auth.ts index 9a807046..94adde25 100644 --- a/apps/api/src/features/tenant-admin/auth.ts +++ b/apps/api/src/features/tenant-admin/auth.ts @@ -92,6 +92,7 @@ export function tenantPermissionCatalog() { { key: 'tenant:payment:write', label: '商户配置管理' }, { key: 'tenant:reconciliation:read', label: '资金对账查看' }, { key: 'tenant:reconciliation:write', label: '资金对账导入' }, + { key: 'tenant:reconciliation:review', label: '资金差错复核' }, { key: 'tenant:reconciliation:download', label: '官方账单下载' }, { key: 'tenant:refund:read', label: '退款查看' }, { key: 'tenant:refund:write', label: '退款申请/处理' }, diff --git a/docs/refactor/auth-payment-provider-plan.md b/docs/refactor/auth-payment-provider-plan.md index 7ef2e10b..c7583af5 100644 --- a/docs/refactor/auth-payment-provider-plan.md +++ b/docs/refactor/auth-payment-provider-plan.md @@ -287,16 +287,23 @@ body: { "code": "", "redirectUri": "https://h5.example.com/auth/q - `GET /api/commerce/reconciliation/issues`:按状态、严重级别、负责人、批次、订单号筛选差错工单。 - `POST /api/commerce/reconciliation/issues/status`:执行 `start/assign/resolve/ignore/escalate/reopen` 状态流转。 - `GET /api/commerce/reconciliation/issues/events`:查看工单创建、分配、处理、解决、忽略、重开等事件轨迹。 +- `GET /api/commerce/operations/anomalies`:聚合未关闭对账工单、失败官方账单任务、支付事件错误、长时间 pending 支付/退款,供租户财务/售后运营台使用。 +- `GET /api/commerce/adjustment-vouchers`:查询人工调整凭证。 +- `POST /api/commerce/adjustment-vouchers`:创建人工调整凭证,可关联差错工单、对账明细、订单、支付或退款。 +- `POST /api/commerce/adjustment-vouchers/status`:执行 `submitted/approved/rejected/voided` 复核流转。 +- `GET /api/commerce/adjustment-vouchers/events`:查看凭证事件轨迹。 +- `GET /api/commerce/adjustment-vouchers/report`:按日期输出凭证状态、类型、来源和日趋势统计。 权限点: - `tenant:reconciliation:read`:查看/预览对账。 - `tenant:reconciliation:write`:导入对账批次、创建/处理差错工单。 +- `tenant:reconciliation:review`:审批、驳回或作废人工调整凭证。 - `tenant:reconciliation:download`:创建微信/支付宝官方账单下载任务。 官方账单下载由 `apps/worker --job provider-bills` 执行。API 只创建 `commerce_bill_download_jobs`,不会在前端返回供应商 `download_url`、商户私钥、微信 API v3 key 或支付宝应用私钥。worker 使用租户 `tenant_payment_accounts.config_public.secretRef` 找到 `app_private.tenant_secrets(secret_scope='payment')`,在后端签名申请下载 URL,校验微信返回的 `hash_type/hash_value`,解析 JSON/CSV/ZIP 账单后复用 `importReconciliationBatch` 写入 `commerce_reconciliation_batches/items`,`source='provider_download'`。 -对账和差错工单只生成差异台账、处理记录和审计,不自动修改订单、支付、退款和权益。`resolve/ignore` 只是财务审核结论,例如 `manual_adjustment`、`provider_confirmed`、`false_positive`,最终落账仍必须走退款状态机、支付补偿、手工支付确认或后续专门的人工调整命令。人工调整凭证附件、财务复核报表、异常订单运营台和真实生产账单格式抽样验收仍需要继续补。 +对账、差错工单和人工调整凭证只生成差异台账、处理记录、凭证附件引用和审计,不自动修改订单、支付、退款和权益。`resolve/ignore` 只是财务审核结论,例如 `manual_adjustment`、`provider_confirmed`、`false_positive`;调整凭证审批也只表示财务复核通过。最终落账仍必须走退款状态机、支付补偿、手工支付确认或后续专门的落账命令。真实生产账单格式抽样验收和前端财务操作台仍需要继续补。 B 端合作商年费、服务费、服务器资源费不走学生端 `orders`,而是走平台账务: diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 3cfa8f68..900f7eb0 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -122,7 +122,7 @@ | 资金流水对账 | 可联调 | `commerce_reconciliation_batches/items` + `/api/commerce/reconciliation/preview/import/batches/items/anomalies`;租户后台需 `tenant:reconciliation:read/write`,支持支付/退款账单行手工或 API 导入、来源 hash、批次统计、逐行匹配、金额/状态差异、本地缺失、供应商缺失、重复行、无效行和审计;对账只生成差异,不自动改订单/权益 | | 对账差错工单 | 可联调 | `commerce_reconciliation_issues/events` + `/api/commerce/reconciliation/issues*`;异常明细可创建工单,支持分配、开始处理、升级、解决、忽略、重开、事件轨迹和审计;处理结论只作为财务审核记录,不直接修改订单、支付、退款或权益 | | 官方账单下载 | 可联调 | `commerce_bill_download_jobs` + `/api/commerce/reconciliation/provider-bills/request/jobs` + `apps/worker --job provider-bills`;租户后台需 `tenant:reconciliation:download` 创建任务,worker 后端使用租户商户密钥申请微信/支付宝官方账单下载 URL、校验 hash、解析 JSON/CSV/ZIP 账单并复用同一套 `provider_download` 对账导入;响应只暴露任务状态、下载域名、hash 和对账批次 ID,不暴露下载 URL 或密钥 | -| 异常订单运营台和财务报表 | 待补齐 | 后续补人工调整凭证附件/复核、异常订单运营台、财务复核报表和真实生产账单格式抽样验收 | +| 异常订单运营台和财务报表 | 可联调 | `commerce_adjustment_vouchers/events` + `/api/commerce/operations/anomalies`、`/api/commerce/adjustment-vouchers*`;支持聚合未关闭对账工单、失败官方账单任务、支付事件错误、长时间 pending 支付/退款,支持人工调整凭证提交、审批、驳回、作废、事件轨迹和复核报表;需 `tenant:reconciliation:read/write/review`,凭证只做审计证据,不直接修改订单、支付、退款或权益;真实生产账单格式抽样和前端财务操作台待继续验收 | ## 租户后台与平台后台 diff --git a/docs/refactor/implementation-status.md b/docs/refactor/implementation-status.md index 66f4c9e5..1253aea3 100644 --- a/docs/refactor/implementation-status.md +++ b/docs/refactor/implementation-status.md @@ -27,7 +27,7 @@ | 刷题题库 | 已建题库、题目、题目版本、内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、导入任务台账、导出任务台账、公共题库授权/采纳表、租户内容通知表 | 已支持核心映射,JSON/CSV/Excel 导入可落到新入口/节点/集合,阅读理解/案例分析子题沿用 `subQuestions/sub_questions` | 题目列表、内容入口、分类树、集合题目、顺序/随机/全真模拟 session、答题提交、复合题 `subAnswers` 判分和报告明细、租户后台题目录入/更新、JSON/CSV/Excel 预览/导入、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、每日一练九宫格 metadata、PDF/Word 运营版式、ZIP 图片素材包、异步导入 worker、平台公共题库授权、租户采纳快照、手动同步、自动同步 worker、同步通知、冲突查询和单条/批量冲突处理已实现 | 核心 API 集成测试含导航、组卷、复合题后台录入/练习/判分/报告、导入、导出权限/脱敏、每日一练导出 metadata、异步 PDF/Word/每日一练 ZIP job 创建、exports worker、公共题库授权、采纳后组卷、同步新增题、通知隔离/已读/自动 resolved、租户自改冲突保护、单条/批量冲突处理和 worker 自动同步断言 | 新题库导航和组卷基础闭环可跑,阅读理解/案例分析多小题第一版可联调,公共题库采纳/手动/自动同步、同步通知、冲突查询/处理、导入后复检、模板下载、字段映射 API、JSON/PDF/Word/每日一练 ZIP 基础导出可联调;公共题库生产调度/失败告警、更精细导出模板和更完整运营消息仍需补齐 | | 错题本 | 已建 `wrong_questions` | 已支持旧错题归一化 | 错题列表、答题自动入错题、移出错题已实现 | 仅烟测 | 基础功能已实现,复习计划和统计未完成 | | 收藏夹 | 已建 `favorite_questions` | 已支持旧收藏归一化 | 收藏/取消收藏、收藏列表已实现 | 仅烟测 | 基础功能已实现 | -| 用户订阅/题库会员/SVIP | 已建 `orders`、`payments`、`entitlements`、`svip_plans`、激活码 | 已映射旧 SVIP/会员权益 | 下单、订单详情/状态轮询、手工支付确认权限保护、微信/支付宝支付、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、激活码预检查/兑换、优惠券抵扣、零元订单自动开通、权益查询、支付/退款补偿、微信/支付宝官方账单下载、资金对账和差错工单已实现 | API 集成测试、commerce worker 集成测试 | 商城主链路可联调,异常订单运营台、真实生产账单格式验收和生产账号联调待补 | +| 用户订阅/题库会员/SVIP | 已建 `orders`、`payments`、`entitlements`、`svip_plans`、激活码 | 已映射旧 SVIP/会员权益 | 下单、订单详情/状态轮询、手工支付确认权限保护、微信/支付宝支付、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、激活码预检查/兑换、优惠券抵扣、零元订单自动开通、权益查询、支付/退款补偿、微信/支付宝官方账单下载、资金对账、差错工单、异常订单运营台和人工调整凭证已实现 | API 集成测试、commerce worker 集成测试 | 商城主链路可联调,真实生产账单格式验收、生产账号联调和前端售后/财务体验待补 | | 背单词 | 已建单词单元、单词、进度、收藏表,并可绑定 `content_entries/content_nodes` | 已支持内容和部分用户状态映射 | 单元/单词只读、进度、收藏、统计、每日复习计划、租户后台单词维护 API、旧模板/新模板 JSON 预览导入、排行榜已实现 | 核心 API 集成测试含导入和排行榜断言 | 学生端学习状态、后台维护、批量 JSON 导入和基础排行榜已实现,更细复习参数和后台统计待完善 | | 知识手册 | 已建手册科目、章节、条目,并可绑定 `content_entries/content_nodes` | 已支持内容导入 | 只读 API、租户后台手册科目/章节/条目维护 API、嵌套 JSON 预览导入已实现 | 核心 API 集成测试含导入断言 | 学生端阅读、后台维护和批量 JSON 导入基础可用,富文本资源/版本管理待补 | | 分数线 | 已建院校、专业、字段、记录表 | 已支持导入映射 | 字段、院校、专业、记录、趋势、年份、租户后台维护 API、JSON 预览导入已实现 | 核心 API 集成测试含导入断言 | 查询、后台维护和批量 JSON 导入基础闭环已实现,复杂动态筛选和 AI 择校上下文待补 | diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index 87ebadd6..cb0a2ea4 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -30,7 +30,7 @@ | 背单词 | `VocabularyPage.tsx`、`VocabularyQuiz.tsx` | 部分覆盖 | 单词列表、进度、收藏、统计、每日计划和后端复习调度已覆盖;后续补收藏练习体验、发音/音频策略、排行榜和更精细的间隔算法参数 | | 知识手册 | `Handbook*.tsx` | 已覆盖 | 前端需做好 Markdown/公式/图片渲染和搜索体验 | | 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势、后台维护和 JSON 批量导入已有;后续补复杂筛选优化和 AI 择校数据上下文 | -| 商城/SVIP | `Store.tsx`、`SvipModal.tsx` | 部分覆盖 | 套餐、订单、订单详情/状态轮询、权益、激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路、内部退款状态机、微信/支付宝发起退款、退款查询确认、退款通知 webhook、支付/退款补偿 worker、全额退款权益撤销、资金对账手工/API 导入比对、微信/支付宝官方账单下载 worker、异常查询、差错工单和事件轨迹已有;缺异常订单运营台、人工调整凭证复核报表和前端收银台/售后体验 | +| 商城/SVIP | `Store.tsx`、`SvipModal.tsx` | 部分覆盖 | 套餐、订单、订单详情/状态轮询、权益、激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路、内部退款状态机、微信/支付宝发起退款、退款查询确认、退款通知 webhook、支付/退款补偿 worker、全额退款权益撤销、资金对账手工/API 导入比对、微信/支付宝官方账单下载 worker、异常查询、差错工单和事件轨迹、异常订单运营台、人工调整凭证复核报表已有;缺前端收银台/售后体验、真实生产账单抽样验收和财务操作台 UI | | 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、手机号绑定/换绑、权益、订单统计、练习历史、学习统计、签到积分、考试倒计时、趋势和勋章展示 API 已有;缺学习报告可视化 | | 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账、上传确认、签名下载、PDF/图片预览、动态水印上下文、worker 复检、内置安全扫描和外部 HTTP scanner 接入层已有;缺前端水印渲染、深度防盗链、真实 AV/内容安全服务联调和生命周期策略 | | AI 择校推荐 | 业务规划新增 | 部分覆盖 | 已有 SVIP 门禁、学生输入 schema、地区/分数线上下文、稳定 JSON 输出、报告台账、审计和 Taro 学生端基础页;真实 AI provider、prompt 版本管理后台、报告 PDF 渲染和更细推荐算法待补 | diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 90713eb2..1fa9937c 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -29,6 +29,7 @@ - 支付/退款补偿 worker 已完成:`apps/worker --job commerce` 可查询微信/支付宝支付和处理中退款,补偿漏通知订单,支付成功幂等开通权益,退款成功幂等更新退款/订单/支付并在全额退款时撤销订单权益。 - 资金对账和差错工单闭环已完成:`commerce_reconciliation_batches/items` 和 `/api/commerce/reconciliation/*` 支持手工/API 导入供应商账单行、预览差异、生成批次统计、查询异常、租户隔离、权限点 `tenant:reconciliation:read/write` 和审计日志;`commerce_reconciliation_issues/events` 支持异常明细创建工单、分配、开始处理、升级、解决、忽略、重开和事件留痕,且不直接修改订单/支付/退款/权益。 - 微信/支付宝官方账单下载地基已完成:`commerce_bill_download_jobs`、`POST /api/commerce/reconciliation/provider-bills/request`、`GET /api/commerce/reconciliation/provider-bills/jobs` 和 `apps/worker --job provider-bills` 已接入,worker 负责后端签名申请下载 URL、hash 校验、JSON/CSV/ZIP 账单解析、复用 `provider_download` 对账导入、任务状态回写和密钥脱敏。 +- 异常订单运营台和人工调整凭证已完成后端第一版:`/api/commerce/operations/anomalies` 聚合未关闭对账工单、失败官方账单任务、支付事件错误、长时间 pending 支付/退款;`/api/commerce/adjustment-vouchers*` 支持凭证提交、审批、驳回、作废、事件轨迹和复核报表,使用 `tenant:reconciliation:review` 做独立复核权限,且审批凭证不会直接修改订单、支付、退款或权益。 - 内容资源复检与安全扫描 worker 已完成:`apps/worker --job assets` 可复检 `content_assets` 中的托管对象元数据,并执行内置 `metadata_rules` 和可选外部 HTTP scanner;正常资源写回复检/扫描证据,异常资源自动置为 `failed/skipped + draft` 或 `security_scan_status=failed`,外部 scanner 不可用默认 fail-closed,并写入审计、扫描事件和安全标记。 - 题库导出 worker 已完成:`apps/worker --job exports` 可抢占 `pdf/docx/daily_practice_zip` 导出任务,渲染 PDF/Word、水印或每日一练图片素材包,写入对象存储或本地开发存储,创建 `content_assets` 并回填 `assetId/hash/size`;`daily_practice` 已支持每日一练九宫格 metadata、PDF/Word 基础版式、9 张 PNG/SVG 卡片和拼图 ZIP。 - 租户后台媒体运营报表已完成:`/api/tenant-content/media-analytics/summary`、`asset-events`、`video-events` 可按 7/30/90 天、资源、视频、用户和水印 traceId 查询资料访问、视频播放、拒绝访问、Top 资源/视频和日趋势;仅开放给 owner/admin/operator 或 `content:analytics:read` 权限角色,前端不会拿到签名 URL 或播放 token。 @@ -76,7 +77,7 @@ - 已完成微信支付 JSAPI、支付宝 WAP/H5 的创建支付参数和 webhook 幂等开通权益。 - 已完成内部退款状态机、退款申请/审核/处理接口、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、部分/全额退款状态、全额退款权益撤销和审计事件。 - 已完成支付/退款补偿 worker,可兜底供应商漏通知、处理中退款和重复执行幂等。 - - 已完成资金对账手工/API 导入比对、微信/支付宝官方账单下载任务、批次/明细/异常查询、差错工单状态流和审计;继续补真实生产账单格式抽样验收、人工调整凭证附件、财务复核报表和异常订单运营台。 + - 已完成资金对账手工/API 导入比对、微信/支付宝官方账单下载任务、批次/明细/异常查询、差错工单状态流、异常订单运营台、人工调整凭证和财务复核报表;继续补真实生产账单格式抽样验收和前端财务操作台体验。 - 租户自有商户收款和平台代收/服务商模式。 2. 国内登录和短信 @@ -115,7 +116,7 @@ 8. 订单和营销体验 - 已完成订单详情、订单状态轮询、激活码预检查、优惠券前台领取、下单抵扣计算和内部退款状态机。 - - 已完成支付/退款补偿 worker、官方账单下载 worker、资金对账导入比对和差错工单;继续补异常订单运营台、优惠券核销报表和复杂活动规则。 + - 已完成支付/退款补偿 worker、官方账单下载 worker、资金对账导入比对、差错工单、异常订单运营台和人工调整凭证复核;继续补优惠券核销报表、复杂活动规则和前端售后操作台。 9. 积分和反馈增强 - 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 5b709e86..9ecab4b9 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -1853,7 +1853,7 @@ approved/processing -> failed - 退款通知地址由支付账户或 `submit_provider_refund.providerNotifyUrl` 配置,后端公开接收路径为 `POST /api/commerce/refunds/notify/wechat_pay?tenantId=`、`POST /api/commerce/refunds/notify/alipay?tenantId=`。这是支付平台回调地址,Taro 前端不要主动调用。 - 退款通知只会推进已经审核/处理中的退款申请;未审核的 `requested` 退款不能被外部通知直接落账。 - 已经 `succeeded` 的退款不能再次查询或再次标记成功,避免订单退款金额重复累加。前端应按接口返回状态展示,不要假设点击后立即到账。 -- 自动补偿 worker 已接入:支付漏通知和处理中退款会由后端定时查询供应商并幂等落账。资金对账已支持租户后台手工/API 导入供应商账单、微信/支付宝官方账单下载任务、查询差异和差错工单处理;异常订单运营台和人工调整凭证复核后续继续补。生产联调时仍需保留人工确认/失败登记入口。 +- 自动补偿 worker 已接入:支付漏通知和处理中退款会由后端定时查询供应商并幂等落账。资金对账已支持租户后台手工/API 导入供应商账单、微信/支付宝官方账单下载任务、查询差异、差错工单处理、异常订单运营台、人工调整凭证和复核报表。生产联调时仍需保留人工确认/失败登记入口。 ### 租户后台资金对账 @@ -2057,6 +2057,112 @@ ignored 无效行或不符合本次 billType - 对账差异和差错工单只是运营判断依据,`resolve/ignore` 不会落账。最终订单修正必须走退款、补偿、人工确认或后续专门的人工调整接口。 - 当前后端支持 JSON 行手工导入;官方账单下载 worker 支持微信/支付宝账单 JSON/CSV/ZIP 解析,并复用同一套对账导入逻辑。真实生产接入时仍要用真实账单文件抽样验收字段映射。 +### 异常订单运营台和调整凭证 + +租户后台财务/售后页可以用异常运营台作为入口。该页只展示待处理风险和凭证复核状态,不允许前端直接修改订单、支付、退款或权益。 + +异常运营台: + +```text +GET /api/commerce/operations/anomalies?provider=wechat_pay&limit=50 +权限:tenant:reconciliation:read +``` + +返回中 `items[].type` 可能是: + +```text +reconciliation_issue 未关闭对账差错工单 +provider_bill_job 官方账单下载失败或运行超时 +payment_event_error 支付/退款通知处理异常 +stuck_pending_payment 长时间 pending 支付 +stuck_refund 长时间待处理/处理中退款 +``` + +创建人工调整凭证: + +```text +POST /api/commerce/adjustment-vouchers +权限:tenant:reconciliation:write +body: { + "voucherNo": "ADJ-20260629-001", + "reconciliationIssueId": "", + "adjustmentType": "write_off", + "direction": "decrease", + "amountCents": 990, + "title": "供应商缺失账单人工核销凭证", + "description": "仅作为财务复核证据", + "assetId": "", + "externalUrl": "https://finance.example.com/proofs/ADJ-20260629-001", + "metadata": { + "operatorRemark": "后台上传凭证" + } +} +``` + +可关联的来源字段: + +```text +reconciliationIssueId 对账差错工单 +reconciliationItemId 对账明细 +orderId / orderNo 订单 +paymentId 支付记录 +refundRequestId/refundNo 退款申请 +sourceType=manual 纯人工凭证 +``` + +凭证字段: + +```text +adjustmentType: + manual_payment_confirm | refund_correction | provider_confirmed | + local_corrected | write_off | duplicate | other + +direction: + increase | decrease | none + +status: + draft | submitted | approved | rejected | voided +``` + +查询凭证: + +```text +GET /api/commerce/adjustment-vouchers?status=submitted&orderNo= +权限:tenant:reconciliation:read +``` + +复核凭证: + +```text +POST /api/commerce/adjustment-vouchers/status +权限:tenant:reconciliation:review +body: { + "voucherId": "", + "status": "approved | rejected | voided", + "reviewNote": "财务复核意见", + "metadata": { + "reviewChannel": "tenant-admin" + } +} +``` + +查看凭证轨迹和报表: + +```text +GET /api/commerce/adjustment-vouchers/events?voucherId= +GET /api/commerce/adjustment-vouchers/report?startDate=2026-06-01&endDate=2026-06-29 +权限:tenant:reconciliation:read +``` + +前端处理规则: + +- 学生端不要接这些接口。 +- `tenant:reconciliation:write` 可创建凭证,`tenant:reconciliation:review` 才能审批、驳回或作废凭证。 +- 后端会校验凭证来源和附件 `content_assets` 必须属于当前租户。 +- 已 `approved/rejected/voided` 的凭证不能翻转到其它关闭状态。 +- 凭证审批不会自动改订单、支付、退款和权益;真正落账仍要走退款状态机、支付补偿、手工支付确认或后续专门落账命令。 +- 前端可在差错工单 `resolve` 时把 `metadata.voucherNo` 一并传入,用于人读检索,但不要把它当成落账动作。 + ### 激活码预检查与兑换 兑换前建议先调用: diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 5ff63eff..3541b3a4 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -2541,6 +2541,180 @@ async function testCommerce() { }); assert.equal(crossTenantProviderBillJobsDenied.code, 'TENANT_ADMIN_REQUIRED', 'provider bill jobs must be tenant isolated'); + const operationReconItems = await request('/api/commerce/reconciliation/items', { + userId: TENANT_ADMIN_USER_ID, + query: { batchId: reconciliationImport.item.id, matchStatus: 'amount_mismatch' }, + }); + const operationReconItem = operationReconItems.items?.find(item => item.matchStatus === 'amount_mismatch'); + assert.ok(operationReconItem?.id, 'operation anomaly test should have an open actionable reconciliation item'); + + const operationIssue = await request('/api/commerce/reconciliation/issues/create', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + itemId: operationReconItem.id, + summary: '运营台展示未关闭对账差错', + note: '保持 open 供运营台测试', + metadata: { source: 'operation-anomaly-test' }, + }, + }); + assert.equal(operationIssue.item?.status, 'open', 'operation anomaly issue should remain open'); + + const studentOperationsDenied = await request('/api/commerce/operations/anomalies', { + expectStatus: 403, + }); + assert.equal(studentOperationsDenied.code, 'TENANT_ADMIN_REQUIRED', 'students must not access finance operation anomalies'); + + const operationAnomalies = await request('/api/commerce/operations/anomalies', { + userId: TENANT_ADMIN_USER_ID, + query: { provider: 'manual' }, + }); + assert.ok( + operationAnomalies.items?.some(item => item.type === 'reconciliation_issue' && item.id === operationIssue.item.id), + 'operation anomaly console should surface open reconciliation issues', + ); + + const voucherSourceOrderBefore = await request('/api/commerce/orders/status', { + query: { orderNo: reconMissingProviderOrder.item.orderNo }, + }); + const voucherSourcePaymentBefore = voucherSourceOrderBefore.item?.payment; + const voucherEntitlementsBefore = await request('/api/commerce/entitlements', {}); + + const studentAdjustmentDenied = await request('/api/commerce/adjustment-vouchers', { + method: 'POST', + body: { + reconciliationIssueId: reconciliationIssue.item.id, + title: 'student should not create adjustment voucher', + }, + expectStatus: 403, + }); + assert.equal(studentAdjustmentDenied.code, 'TENANT_ADMIN_REQUIRED', 'students must not create adjustment vouchers'); + + const adjustmentVoucherNo = `ADJ-INTEGRATION-${Date.now()}`; + const adjustmentVoucher = await request('/api/commerce/adjustment-vouchers', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + voucherNo: adjustmentVoucherNo, + reconciliationIssueId: reconciliationIssue.item.id, + adjustmentType: 'write_off', + direction: 'decrease', + amountCents: reconMissingProviderOrder.item.amountCents, + title: '供应商缺失账单人工核销凭证', + description: '仅作为财务复核证据,不直接修改订单状态', + externalUrl: 'https://finance.example.test/proofs/adj-integration-001', + metadata: { source: 'api-integration-test' }, + }, + }); + assert.ok(adjustmentVoucher.item?.id, 'tenant admin should create adjustment voucher'); + assert.equal(adjustmentVoucher.item?.status, 'submitted', 'adjustment voucher should default to submitted'); + assert.equal(adjustmentVoucher.item?.reconciliationIssueId, reconciliationIssue.item.id, 'voucher should link reconciliation issue'); + assert.equal(adjustmentVoucher.item?.orderNo, reconMissingProviderOrder.item.orderNo, 'voucher should copy order identity from issue'); + + const duplicateAdjustmentVoucher = await request('/api/commerce/adjustment-vouchers', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + voucherNo: adjustmentVoucherNo, + reconciliationIssueId: reconciliationIssue.item.id, + title: 'duplicate voucher number should be rejected', + }, + expectStatus: 409, + }); + assert.equal(duplicateAdjustmentVoucher.code, 'ADJUSTMENT_VOUCHER_DUPLICATE', 'duplicate voucher number should return business conflict'); + + const invalidAdjustmentSource = await request('/api/commerce/adjustment-vouchers', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + reconciliationIssueId: 'not-a-uuid', + title: 'invalid source id should be rejected', + }, + expectStatus: 400, + }); + assert.equal(invalidAdjustmentSource.code, 'INVALID_UUID', 'adjustment voucher should reject malformed source UUIDs'); + + const listedAdjustmentVouchers = await request('/api/commerce/adjustment-vouchers', { + userId: TENANT_ADMIN_USER_ID, + query: { status: 'submitted', orderNo: reconMissingProviderOrder.item.orderNo }, + }); + assert.ok( + listedAdjustmentVouchers.items?.some(item => item.id === adjustmentVoucher.item.id), + 'adjustment voucher list should filter submitted vouchers by order number', + ); + + const adjustmentReport = await request('/api/commerce/adjustment-vouchers/report', { + userId: TENANT_ADMIN_USER_ID, + query: { startDate: billDate, endDate: billDate }, + }); + assert.ok(adjustmentReport.item?.totalCount >= 1, 'adjustment voucher report should include created voucher'); + assert.ok( + adjustmentReport.item?.byStatus?.some(item => item.status === 'submitted' && item.count >= 1), + 'adjustment voucher report should aggregate by status', + ); + + const approvedAdjustmentVoucher = await request('/api/commerce/adjustment-vouchers/status', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + voucherId: adjustmentVoucher.item.id, + status: 'approved', + reviewNote: '财务复核通过,仅保留审计凭证', + metadata: { reviewedFrom: 'api-integration-test' }, + }, + }); + assert.equal(approvedAdjustmentVoucher.item?.status, 'approved', 'reviewer should approve submitted adjustment voucher'); + assert.equal(approvedAdjustmentVoucher.item?.reviewedBy, TENANT_ADMIN_USER_ID, 'voucher approval should record reviewer'); + + const adjustmentVoucherEvents = await request('/api/commerce/adjustment-vouchers/events', { + userId: TENANT_ADMIN_USER_ID, + query: { voucherId: adjustmentVoucher.item.id }, + }); + assert.ok( + adjustmentVoucherEvents.items?.some(item => item.eventType === 'submitted' && item.toStatus === 'submitted'), + 'voucher events should include submit event', + ); + assert.ok( + adjustmentVoucherEvents.items?.some(item => item.eventType === 'approved' && item.toStatus === 'approved'), + 'voucher events should include approval event', + ); + + const closedVoucherChangeDenied = await request('/api/commerce/adjustment-vouchers/status', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + voucherId: adjustmentVoucher.item.id, + status: 'rejected', + reviewNote: 'closed voucher should not flip to rejected', + }, + expectStatus: 409, + }); + assert.equal(closedVoucherChangeDenied.code, 'ADJUSTMENT_VOUCHER_CLOSED', 'closed adjustment voucher must be immutable'); + + const crossTenantVoucherDenied = await request('/api/commerce/adjustment-vouchers', { + tenantId: PARTNER_TENANT_ID, + userId: TENANT_ADMIN_USER_ID, + query: { voucherNo: adjustmentVoucherNo }, + expectStatus: 403, + }); + assert.equal(crossTenantVoucherDenied.code, 'TENANT_ADMIN_REQUIRED', 'adjustment vouchers must be tenant isolated'); + + const voucherSourceOrderAfter = await request('/api/commerce/orders/status', { + query: { orderNo: reconMissingProviderOrder.item.orderNo }, + }); + const voucherEntitlementsAfter = await request('/api/commerce/entitlements', {}); + assert.equal(voucherSourceOrderAfter.item?.status, voucherSourceOrderBefore.item?.status, 'adjustment voucher approval must not mutate order status'); + assert.equal( + voucherSourceOrderAfter.item?.payment?.status, + voucherSourcePaymentBefore?.status, + 'adjustment voucher approval must not mutate payment status', + ); + assert.equal( + voucherEntitlementsAfter.items?.length, + voucherEntitlementsBefore.items?.length, + 'adjustment voucher approval must not grant or revoke entitlements', + ); + const fakeWechatPay = await startFakeWechatPayServer(); const wechatAccount = await request('/api/tenant-admin/payment-accounts', { userId: TENANT_ADMIN_USER_ID, @@ -6210,6 +6384,7 @@ async function testTenantMemberPermissionsAndAudit() { assert.ok(permissionMatrix.permissions?.some(item => item.key === 'badges:grant'), 'permission matrix should expose badge grant permission'); assert.ok(permissionMatrix.permissions?.some(item => item.key === 'tenant:reconciliation:read'), 'permission matrix should expose reconciliation read permission'); assert.ok(permissionMatrix.permissions?.some(item => item.key === 'tenant:reconciliation:write'), 'permission matrix should expose reconciliation write permission'); + assert.ok(permissionMatrix.permissions?.some(item => item.key === 'tenant:reconciliation:review'), 'permission matrix should expose reconciliation review permission'); assert.ok(permissionMatrix.menuGroups?.some(item => item.key === 'sales'), 'permission matrix should expose menu groups'); assert.ok(permissionMatrix.roleDefaults?.tenant_operator?.includes('dashboard:read'), 'tenant operator defaults should include dashboard read'); assert.ok(permissionMatrix.roleDefaults?.tenant_operator?.includes('marketing:*'), 'permission matrix should include role defaults'); diff --git a/supabase/migrations/202606290030_commerce_adjustment_vouchers.sql b/supabase/migrations/202606290030_commerce_adjustment_vouchers.sql new file mode 100644 index 00000000..289794ae --- /dev/null +++ b/supabase/migrations/202606290030_commerce_adjustment_vouchers.sql @@ -0,0 +1,99 @@ +create table if not exists public.commerce_adjustment_vouchers ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references public.tenants(id) on delete cascade, + voucher_no text not null, + source_type text not null default 'manual' + check (source_type in ('reconciliation_issue', 'reconciliation_item', 'order', 'payment', 'refund', 'manual')), + source_id uuid, + reconciliation_issue_id uuid references public.commerce_reconciliation_issues(id) on delete set null, + reconciliation_item_id uuid references public.commerce_reconciliation_items(id) on delete set null, + order_id uuid references public.orders(id) on delete set null, + payment_id uuid references public.payments(id) on delete set null, + refund_request_id uuid references public.commerce_refund_requests(id) on delete set null, + order_no text, + refund_no text, + provider text, + provider_trade_no text, + provider_refund_no text, + adjustment_type text not null default 'other' + check (adjustment_type in ( + 'manual_payment_confirm', + 'refund_correction', + 'provider_confirmed', + 'local_corrected', + 'write_off', + 'duplicate', + 'other' + )), + direction text not null default 'none' + check (direction in ('increase', 'decrease', 'none')), + amount_cents integer not null default 0 check (amount_cents >= 0), + status text not null default 'draft' + check (status in ('draft', 'submitted', 'approved', 'rejected', 'voided')), + title text not null, + description text, + asset_id uuid references public.content_assets(id) on delete set null, + external_url text, + submitted_by uuid references public.platform_users(id) on delete set null, + submitted_at timestamptz, + reviewed_by uuid references public.platform_users(id) on delete set null, + reviewed_at timestamptz, + review_note text, + voided_by uuid references public.platform_users(id) on delete set null, + voided_at timestamptz, + metadata jsonb not null default '{}'::jsonb, + created_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(), + unique (tenant_id, voucher_no) +); + +create table if not exists public.commerce_adjustment_voucher_events ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references public.tenants(id) on delete cascade, + voucher_id uuid not null references public.commerce_adjustment_vouchers(id) on delete cascade, + from_status text, + to_status text, + event_type text not null, + actor_user_id uuid references public.platform_users(id) on delete set null, + note text, + details jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + check (from_status is null or from_status in ('draft', 'submitted', 'approved', 'rejected', 'voided')), + check (to_status is null or to_status in ('draft', 'submitted', 'approved', 'rejected', 'voided')) +); + +create index if not exists idx_commerce_adjustment_vouchers_tenant_status + on public.commerce_adjustment_vouchers(tenant_id, status, created_at desc); + +create index if not exists idx_commerce_adjustment_vouchers_source + on public.commerce_adjustment_vouchers(tenant_id, source_type, source_id, created_at desc); + +create index if not exists idx_commerce_adjustment_vouchers_issue + on public.commerce_adjustment_vouchers(tenant_id, reconciliation_issue_id, status, created_at desc); + +create index if not exists idx_commerce_adjustment_vouchers_order + on public.commerce_adjustment_vouchers(tenant_id, order_no, status, created_at desc); + +create index if not exists idx_commerce_adjustment_voucher_events_voucher + on public.commerce_adjustment_voucher_events(tenant_id, voucher_id, created_at desc); + +alter table public.commerce_adjustment_vouchers enable row level security; +alter table public.commerce_adjustment_voucher_events enable row level security; + +drop policy if exists tenant_isolation on public.commerce_adjustment_vouchers; +create policy tenant_isolation on public.commerce_adjustment_vouchers + 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.commerce_adjustment_voucher_events; +create policy tenant_isolation on public.commerce_adjustment_voucher_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.commerce_adjustment_vouchers; +create trigger set_updated_at + before update on public.commerce_adjustment_vouchers + for each row execute function app.touch_updated_at();