From 6b0078f680b2c9d895df35ca25c15231f7e89ec9 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 26 Oct 2026 13:48:53 +0800 Subject: [PATCH] =?UTF-8?q?fix(finance):=20=E6=94=B6=E5=8F=A3=E5=BF=AB?= =?UTF-8?q?=E6=89=8B=E9=80=80=E6=AC=BE=E8=B7=AF=E7=94=B1=E5=B9=B6=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E9=94=80=E5=94=AE=E4=BA=8B=E5=AE=9E=E5=8F=98=E6=9B=B4?= =?UTF-8?q?=E5=90=8E=E7=9A=84=E6=80=BB=E8=B4=A6=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 平台退款路由从通用订单路径改为 POST /api/v1/admin/kuaishou-industry/orders/:orderId/refunds/approve-and-record, 服务改名 approveAndRecordKuaishouRefund,明确仅快手 91 - 总账同步改为未关账期间按日整体重建销售凭证,解决收入回填/退款重算后 旧凭证金额不更新的问题;删除与重建放在同一事务,已关账期间保持不动 - 文档明确 revenue:backfill 是存量订单退款的前置条件: revenue_amount_fen=0 会导致退款登记按钮置灰 - 本地生产副本已执行 revenue:backfill:375,447 行全部回填, order 375483 可退金额恢复为 237.00 元 验证:backend check 844(840 pass / 4 skip);frontend check 172 + build; 集成测试通过(代录、总账平衡、关账阻断、对账)。 --- .../src/routes/admin/kuaishou-industry.ts | 39 ++++++++++++++++++- apps/backend/src/routes/admin/orders.ts | 37 ------------------ .../finance/finance-ledger-service.ts | 39 +++++++++++++------ .../manual-fulfillment-integrity.test.ts | 8 ++++ .../services/finance/order-refund-service.ts | 2 +- docs/成本中心API契约.md | 2 +- docs/财务上线初始化与验收-2026-10-26.md | 23 ++++++++--- 7 files changed, 93 insertions(+), 57 deletions(-) diff --git a/apps/backend/src/routes/admin/kuaishou-industry.ts b/apps/backend/src/routes/admin/kuaishou-industry.ts index cbdf238b..be866e77 100644 --- a/apps/backend/src/routes/admin/kuaishou-industry.ts +++ b/apps/backend/src/routes/admin/kuaishou-industry.ts @@ -12,7 +12,8 @@ import { resendAdminKuaishouIndustryVoucherCode, reverseAdminKuaishouIndustryVoucher, } from '../../services/admin/kuaishou-industry-admin-service.js' -import { createJsonHandler, requireAdminRoles } from './session.js' +import { createJsonHandler, requireAdminPermission, requireAdminRoles } from './session.js' +import { approveAndRecordKuaishouRefund } from '../../services/finance/order-refund-service.js' import type { AdminKuaishouIndustryRefundApproveRouteBody, AdminKuaishouIndustryRefundDisagreeRouteBody, @@ -27,6 +28,42 @@ import type { KuaishouIndustryVoucherAdminListQuery } from '../../repositories/k const router = Router() +router.post( + '/kuaishou-industry/orders/:orderId/refunds/approve-and-record', + requireAdminPermission('finance.config'), + createJsonHandler( + (req) => { + const body = req.body as Record + return approveAndRecordKuaishouRefund( + { + orderId: String((req.params as Record).orderId || ''), + orderItemId: body?.orderItemId as string | undefined, + amountFen: body?.amountFen as string | undefined, + platformRefundId: String(body?.platformRefundId || ''), + reason: body?.reason as string | undefined, + note: body?.note as string | undefined, + refundedAt: body?.refundedAt as string | undefined, + sellerId: body?.sellerId as string | undefined, + refundHandingWay: body?.refundHandingWay, + desc: body?.desc as string | undefined, + }, + req.adminSession || null, + ) + }, + { + successMessage: '快手退款已同意并登记', + errorMessage: '快手平台退款失败', + scope: '[admin/kuaishou-industry/orders/refunds/approve-and-record]', + audit: (req, data) => ({ + action: 'kuaishou_order_refund_approved_and_recorded', + targetType: 'order', + targetId: String((req.params as Record).orderId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), +) + router.get( '/kuaishou-industry/vouchers', createJsonHandler( diff --git a/apps/backend/src/routes/admin/orders.ts b/apps/backend/src/routes/admin/orders.ts index 606d4848..d52a4767 100644 --- a/apps/backend/src/routes/admin/orders.ts +++ b/apps/backend/src/routes/admin/orders.ts @@ -8,7 +8,6 @@ import { } from '../../services/admin/write/order-actions.js' import { createManualRefund, - createPlatformRefund, listOrderRefunds, voidOrderRefund, } from '../../services/finance/order-refund-service.js' @@ -104,42 +103,6 @@ router.get( ), ) -router.post( - '/orders/:orderId/refunds/platform', - requireAdminPermission('finance.config'), - createJsonHandler( - (req) => { - const body = req.body as Record - return createPlatformRefund( - { - orderId: String((req.params as AdminOrderRouteParams).orderId || ''), - orderItemId: body?.orderItemId as string | undefined, - amountFen: body?.amountFen as string | undefined, - platformRefundId: String(body?.platformRefundId || ''), - reason: body?.reason as string | undefined, - note: body?.note as string | undefined, - refundedAt: body?.refundedAt as string | undefined, - sellerId: body?.sellerId as string | undefined, - refundHandingWay: body?.refundHandingWay, - desc: body?.desc as string | undefined, - }, - req.adminSession || null, - ) - }, - { - successMessage: '平台退款已提交并登记', - errorMessage: '平台退款失败', - scope: '[admin/orders/:orderId/refunds/platform]', - audit: (req, data) => ({ - action: 'order_platform_refund_created', - targetType: 'order', - targetId: String((req.params as AdminOrderRouteParams).orderId || ''), - data: data && typeof data === 'object' ? (data as Record) : {}, - }), - }, - ), -) - router.post( '/orders/:orderId/refunds', requireAdminPermission('finance.config'), diff --git a/apps/backend/src/services/finance/finance-ledger-service.ts b/apps/backend/src/services/finance/finance-ledger-service.ts index 493de090..991a9285 100644 --- a/apps/backend/src/services/finance/finance-ledger-service.ts +++ b/apps/backend/src/services/finance/finance-ledger-service.ts @@ -189,9 +189,22 @@ export async function syncFinanceLedgerFromFacts(input: { dateFrom: string; date ) let created = 0 + let replaced = 0 let skipped = 0 for (const row of rows.rows) { const occurredAt = `${row.stat_date}T12:00:00+08:00` + const periodKey = formatShanghaiDate(new Date(occurredAt)).slice(0, 7) + const period = await query<{ status: string }>( + `SELECT status FROM finance_periods WHERE period_key = $1 LIMIT 1`, + [periodKey], + ) + if (period.rows[0]?.status === 'closed') { + // 已关账期间不可改账;即使销售事实后来变化,也保留关账时点的凭证。 + skipped += 1 + continue + } + // 未关账期间:销售事实可能因退款/收入回填而变化,按日整体重建, + // 避免旧的幂等凭证一直停留在旧金额;删除与重建放在同一事务内。 const revenue = Number(row.gross_revenue_fen || 0) const refund = Number(row.refund_amount_fen || 0) const cost = Number(row.cost_amount_fen || 0) @@ -235,20 +248,22 @@ export async function syncFinanceLedgerFromFacts(input: { dateFrom: string; date ], }) } - for (const entry of entries) { - try { - const result = await postJournalEntry(entry) - if (result.created) created += 1 - } catch (error) { - if (isPeriodClosedError(error)) { - skipped += 1 - continue - } - throw error + const dayCreated = await withTransaction(async (client) => { + const deleted = await client.query( + `DELETE FROM finance_journal_entries WHERE ref_type = 'sales_fact' AND ref_id = $1`, + [row.stat_date], + ) + replaced += deleted.rowCount || 0 + let count = 0 + for (const entry of entries) { + const result = await postJournalEntry(entry, client) + if (result.created) count += 1 } - } + return count + }) + created += dayCreated } - return { range: { fromDate: dateFrom, toDate: dateTo }, created, skipped } + return { range: { fromDate: dateFrom, toDate: dateTo }, created, replaced, skipped } } export async function listJournalEntries(input: Record = {}) { diff --git a/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts b/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts index ae2cba1f..253a2225 100644 --- a/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts +++ b/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts @@ -166,6 +166,14 @@ test('总账/对账/关账/平台退款能力已接入', async () => { refund.includes('platform_refund_not_supported'), '平台退款必须走平台 API 并落 source=platform', ) + const kuaishouRoute = await readFile( + new URL('../../routes/admin/kuaishou-industry.ts', import.meta.url), + 'utf8', + ) + assert.ok( + kuaishouRoute.includes('/kuaishou-industry/orders/:orderId/refunds/approve-and-record'), + '平台退款路由必须明确收口在快手行业前缀下', + ) assert.ok( manual.includes('fundAccountId') && manual.includes('creditOwnerAdminUserId'), '管理员/财务必须可以指定客服账户代录', diff --git a/apps/backend/src/services/finance/order-refund-service.ts b/apps/backend/src/services/finance/order-refund-service.ts index a9e2da53..7d26c7eb 100644 --- a/apps/backend/src/services/finance/order-refund-service.ts +++ b/apps/backend/src/services/finance/order-refund-service.ts @@ -453,7 +453,7 @@ function formatFen(value: number): string { * 当前只接入快手行业(91)的「同意退款」接口;抖音/拼多多暂无可用退款 API, * 会返回 501,由后台手工登记兜底。 */ -export async function createPlatformRefund( +export async function approveAndRecordKuaishouRefund( input: { orderId: number | string orderItemId?: number | string | null | undefined diff --git a/docs/成本中心API契约.md b/docs/成本中心API契约.md index 0e5cb8d3..4475d47a 100644 --- a/docs/成本中心API契约.md +++ b/docs/成本中心API契约.md @@ -323,7 +323,7 @@ export type CostModel = | POST | `/api/v1/admin/finance/reconciliations/import` | finance.config | 导入平台账单 JSON 并匹配 | | GET | `/api/v1/admin/finance/reconciliations/:id` | finance.view | 对账明细 | | POST | `/api/v1/admin/finance/reconciliations/:id/resolve` | finance.config | 标记差异已解决 | -| POST | `/api/v1/admin/orders/:orderId/refunds/platform` | finance.config | 平台退款(当前仅快手 91) | +| POST | `/api/v1/admin/kuaishou-industry/orders/:orderId/refunds/approve-and-record` | finance.config | 平台退款(当前仅快手 91) | 确认履约接口新增代录字段: diff --git a/docs/财务上线初始化与验收-2026-10-26.md b/docs/财务上线初始化与验收-2026-10-26.md index 16d43335..e0f9950b 100644 --- a/docs/财务上线初始化与验收-2026-10-26.md +++ b/docs/财务上线初始化与验收-2026-10-26.md @@ -145,16 +145,23 @@ GROUP BY status; `manual_cost_missing` 只在「首次配置之后」生成;确认履约写入快照后自动置为 `resolved`。 -### 5. 历史回填(可选,不阻塞上线) +### 5. 历史回填(退款功能上线前必须跑 `revenue:backfill`) -本次上线不要求补历史人工成本;如需历史商品归一/成本快照/销售事实用于报表,另跑: +**存量订单要能退款,必须先回填订单项收入。** 迁移 `104` 只加列不回填;`revenue_basis='unknown'` 的订单项 `revenue_amount_fen=0`,订单详情「退款登记」会因可退金额为 0 而置灰,手工退款也会失败。 ```bash -npm run revenue:backfill -- --dry-run # 应为 unknown=0 -npm run fulfillment:backfill # 只填 NULL +npm run revenue:backfill -- --dry-run # 待回填量,应为 0 +npm run revenue:backfill # 分批回填;中断可重跑 +npm run fulfillment:backfill # 只填 NULL(可选,报表用) npm run finance:sync -- --mode=all --from=2026-07-10 --to=2026-10-22 ``` +验证: + +```sql +SELECT COUNT(*) FROM order_items WHERE revenue_basis = 'unknown'; -- 退款上线前应为 0 +``` + 长区间按 30 天窗口分段执行(默认 `DATABASE_STATEMENT_TIMEOUT_MS=15000`)。 注意:人工历史成本按既定口径**不补录**,历史人工毛利仍不可用。 @@ -212,6 +219,10 @@ SELECT reason, status, COUNT(*) FROM cost_exceptions WHERE created_at >= :launch_at GROUP BY 1,2 ORDER BY 3 DESC; +-- 7.1 存量订单收入回填(退款功能上线前应为 0) +SELECT COUNT(*) AS unknown_revenue_items +FROM order_items WHERE revenue_basis = 'unknown'; + -- 8. 总账借贷平衡(应为差额 0) SELECT COALESCE(SUM(debit_fen), 0) AS debit_fen, @@ -240,6 +251,7 @@ GROUP BY 1; | 确认时报「积分不足」 | 未充值/充值到别的客服 | 积分钱包 → 给本人账户发放 | | 确认成功但列表仍有「待确认」 | 前端缓存 | 刷新;后端以快照为准 | | `manual_cost_missing` 不减少 | 未确认履约,或确认失败回滚 | 看订单详情确认;异常只能由快照自动收口 | +| 订单详情「退款登记」置灰 | 订单项 `revenue_amount_fen=0`,通常是存量订单未跑收入回填 | 执行 `npm run revenue:backfill`;刷新后「可退」应大于 0 | --- @@ -250,6 +262,7 @@ GROUP BY 1; ### 4.1 影子总账 - 销售事实按日同步:收入(借 1101 / 贷 4001)、退款(借 4101 / 贷 1101)、成本(借 5001 / 贷 2001); +- **未关账期间按日整体重建**:退款/收入回填导致销售事实变化时,旧日凭证会被替换;已关账期间保持不动; - 财务可手工录入调整凭证(借贷科目 + 金额 + 摘要),必须借贷平衡; - 试算平衡按日期区间展示;总账不接管 `worker_wallets` / `fund_accounts` 余额; - 定时任务 `cost-finance-sync` 每 5 分钟同步最近窗口;也可手动「同步销售事实到总账」。 @@ -277,7 +290,7 @@ GROUP BY 1; ### 4.4 平台退款 API -- 快手行业(91):`POST /api/v1/admin/orders/:orderId/refunds/platform`,传 `platformRefundId`、`orderItemId`、`amountFen`; +- 快手行业(91):`POST /api/v1/admin/kuaishou-industry/orders/:orderId/refunds/approve-and-record`,传 `platformRefundId`、`orderItemId`、`amountFen`; 先调用快手「同意退款」,成功后再落 `order_refunds.source='platform'` 并冲减销售事实; - 抖音/拼多多:暂无退款 API,返回 501,仍用「手工退款登记」; - 平台退款必须传订单项;参数校验在调用平台之前完成,避免平台已同意、本地记账失败。