From 624c0ef4afc89433d9994c568597e6689e31711d Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 1 Nov 2026 14:46:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(finance):=20=E5=8E=86=E5=8F=B2=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E9=80=80=E6=AC=BE=E6=8C=89=E9=80=80=E6=AC=BE=E6=97=A5?= =?UTF-8?q?=E5=85=A5=E8=B4=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 迁移 116 新增 refund_adjustment 渠道值,并允许该类行净收入为负 - 销售事实按退款日聚合 archived 订单的已确认退款,写冲减行 - 退款后同时回填原销售日与退款日的事实与总账 - 退款同时重算相关订单项的费用快照(比例费按净收入) - 前端渠道筛选与标签支持「历史退款冲减」 --- .../116_refund_adjustment_facts.sql | 39 +++++++++++++++++ .../cost-center/cost-center-constants.ts | 2 + .../services/finance/order-refund-service.ts | 42 +++++++++++++++---- .../finance/sales-facts-channel-service.ts | 40 +++++++++++++++++- .../src/pages/admin/AdminSalesProfitPage.tsx | 2 + .../admin/cost-center/CostExceptionPanel.tsx | 1 + apps/frontend/src/types/admin/cost-center.ts | 1 + 7 files changed, 118 insertions(+), 9 deletions(-) create mode 100644 apps/backend/src/db/migrations/116_refund_adjustment_facts.sql diff --git a/apps/backend/src/db/migrations/116_refund_adjustment_facts.sql b/apps/backend/src/db/migrations/116_refund_adjustment_facts.sql new file mode 100644 index 00000000..9e916f68 --- /dev/null +++ b/apps/backend/src/db/migrations/116_refund_adjustment_facts.sql @@ -0,0 +1,39 @@ +-- 116_refund_adjustment_facts.sql +-- +-- 断代后历史订单仍会退款:这些订单项已 archived、不参与正常销售事实, +-- 需要在「退款发生日」记一笔冲减,否则退款在财务体系里完全丢失。 +-- 这里新增事实渠道值 refund_adjustment 承载这类冲减行。 + +ALTER TABLE sales_daily_facts + DROP CONSTRAINT IF EXISTS chk_sales_daily_facts_fulfillment_mode; +ALTER TABLE sales_daily_facts + DROP CONSTRAINT IF EXISTS chk_sales_daily_facts_channel; + +ALTER TABLE sales_daily_facts + ADD CONSTRAINT chk_sales_daily_facts_channel + CHECK ( + channel IN ( + 'work_order', 'affiliate_dash', 'third_party_voucher', + 'manual', 'unclassified', 'archived', 'refund_adjustment' + ) + ); + +COMMENT ON COLUMN sales_daily_facts.channel IS + '渠道维度:work_order / affiliate_dash / third_party_voucher / manual / unclassified / archived / refund_adjustment'; + +-- 退款冲减行允许净收入为负(其余渠道仍要求非负)。 +ALTER TABLE sales_daily_facts + DROP CONSTRAINT IF EXISTS chk_sales_daily_facts_amounts; + +ALTER TABLE sales_daily_facts + ADD CONSTRAINT chk_sales_daily_facts_amounts + CHECK ( + order_count >= 0 + AND item_count >= 0 + AND quantity >= 0 + AND gross_revenue_fen >= 0 + AND refund_amount_fen >= 0 + AND (net_revenue_fen >= 0 OR channel = 'refund_adjustment') + AND cost_amount_fen >= 0 + AND cost_missing_count >= 0 + ); diff --git a/apps/backend/src/services/cost-center/cost-center-constants.ts b/apps/backend/src/services/cost-center/cost-center-constants.ts index daca936f..48082dbf 100644 --- a/apps/backend/src/services/cost-center/cost-center-constants.ts +++ b/apps/backend/src/services/cost-center/cost-center-constants.ts @@ -67,6 +67,7 @@ export type FulfillmentChannel = | 'pending' | 'archived' | 'unclassified' + | 'refund_adjustment' export const FULFILLMENT_CHANNELS = new Set([ 'work_order', @@ -76,6 +77,7 @@ export const FULFILLMENT_CHANNELS = new Set([ 'pending', 'archived', 'unclassified', + 'refund_adjustment', ]) /** diff --git a/apps/backend/src/services/finance/order-refund-service.ts b/apps/backend/src/services/finance/order-refund-service.ts index 8e68ddaf..27dca865 100644 --- a/apps/backend/src/services/finance/order-refund-service.ts +++ b/apps/backend/src/services/finance/order-refund-service.ts @@ -15,6 +15,7 @@ import { createHttpError } from '../../utils/http.js' import { generateRefundNo } from '../../utils/random.js' import { nowIso } from '../../utils/time.js' import { rebuildChannelSalesDailyFacts } from './sales-facts-channel-service.js' +import { syncChannelCostSnapshots } from '../cost-center/cost-snapshot-service.js' import { approveKuaishouIndustryRefund } from '../platforms/kuaishou-industry/refund-service.js' import { syncFinanceLedgerFromFacts } from './finance-ledger-service.js' @@ -326,12 +327,26 @@ export async function createManualRefund( } }) - const basisDate = toChinaDate(written.basisAt) - if (basisDate) { - await rebuildChannelSalesDailyFacts({ dateFrom: basisDate, dateTo: basisDate }) - await syncFinanceLedgerFromFacts({ dateFrom: basisDate, dateTo: basisDate }) + // 比例类费用按净收入计:退款后重算相关订单项的费用快照,再回填事实。 + try { + const refundedItems = await query<{ order_item_id: number }>( + 'SELECT DISTINCT order_item_id FROM order_refunds WHERE id = ANY($1::bigint[])', + [written.insertedIds], + ) + for (const row of refundedItems.rows) { + await syncChannelCostSnapshots({ + afterId: Math.max(0, Number(row.order_item_id) - 1), + batchSize: 1, + maxBatches: 1, + }) + } + } catch { + // 快照重算失败不影响退款本身,成本快照管道会兜底。 } + const basisDate = toChinaDate(written.basisAt) + await refreshRefundDates(basisDate) + const refunds = await listOrderRefunds(orderId) return { batchNo: written.batchNo, @@ -441,10 +456,7 @@ export async function voidOrderRefund( }) const rebuiltDate = toChinaDate(context.basisAt) - if (rebuiltDate) { - await rebuildChannelSalesDailyFacts({ dateFrom: rebuiltDate, dateTo: rebuiltDate }) - await syncFinanceLedgerFromFacts({ dateFrom: rebuiltDate, dateTo: rebuiltDate }) - } + await refreshRefundDates(rebuiltDate) const refunds = await listOrderRefunds(context.orderId) const refund = refunds.find((item) => item.id === id) if (!refund) { @@ -540,6 +552,20 @@ function toChinaDate(value: string): string { return CHINA_DATE_FORMATTER.format(date) } +/** + * 退款后回填事实与总账: + * - 原销售日:更新该订单项的收入/费用; + * - 退款日:生成历史订单(archived)的退款冲减行。 + */ +async function refreshRefundDates(basisDate: string): Promise { + const refundDate = CHINA_DATE_FORMATTER.format(new Date()) + const dates = [basisDate, refundDate].filter(Boolean) + for (const date of [...new Set(dates)]) { + await rebuildChannelSalesDailyFacts({ dateFrom: date, dateTo: date }) + await syncFinanceLedgerFromFacts({ dateFrom: date, dateTo: date }) + } +} + function formatFen(value: number): string { return `¥${(Number(value || 0) / 100).toFixed(2)}` } diff --git a/apps/backend/src/services/finance/sales-facts-channel-service.ts b/apps/backend/src/services/finance/sales-facts-channel-service.ts index 364909ad..3b66d640 100644 --- a/apps/backend/src/services/finance/sales-facts-channel-service.ts +++ b/apps/backend/src/services/finance/sales-facts-channel-service.ts @@ -143,9 +143,47 @@ export async function rebuildChannelSalesDailyFacts(input: { [dateFrom, dateTo, computedAt], ) + // 断代后历史订单的退款:按退款发生日记冲减行,否则这类退款会彻底丢失。 + const adjustments = await client.query( + ` + WITH adj AS ( + SELECT + (r.refunded_at AT TIME ZONE 'Asia/Shanghai')::date AS stat_date, + o.provider, + o.shop_id, + COUNT(DISTINCT o.id)::int AS order_count, + COUNT(*)::int AS item_count, + SUM(r.amount_fen)::bigint AS refund_fen + FROM order_refunds r + JOIN order_items oi ON oi.id = r.order_item_id + JOIN orders o ON o.id = oi.order_id + WHERE r.status = 'confirmed' + AND oi.fulfillment_status = 'archived' + AND (r.refunded_at AT TIME ZONE 'Asia/Shanghai')::date + BETWEEN $1::date AND $2::date + GROUP BY 1, 2, 3 + ) + INSERT INTO sales_daily_facts ( + stat_date, provider, shop_id, canonical_product_id, channel, + order_count, item_count, quantity, + gross_revenue_fen, refund_amount_fen, net_revenue_fen, + cost_amount_fen, cost_missing_count, cost_stage, + gross_profit_fen, computed_at, created_at, updated_at + ) + SELECT + stat_date, provider, shop_id, NULL, 'refund_adjustment', + order_count, item_count, 0, + 0, refund_fen, -refund_fen, + 0, 0, 'actual', + -refund_fen, $3::timestamptz, $3::timestamptz, $3::timestamptz + FROM adj + `, + [dateFrom, dateTo, computedAt], + ) + return { deleted: deleted.rowCount || 0, - inserted: inserted.rowCount || 0, + inserted: (inserted.rowCount || 0) + (adjustments.rowCount || 0), skipped, } }) diff --git a/apps/frontend/src/pages/admin/AdminSalesProfitPage.tsx b/apps/frontend/src/pages/admin/AdminSalesProfitPage.tsx index a51a702c..3c0e9ed4 100644 --- a/apps/frontend/src/pages/admin/AdminSalesProfitPage.tsx +++ b/apps/frontend/src/pages/admin/AdminSalesProfitPage.tsx @@ -41,6 +41,7 @@ const FULFILLMENT_MODE_OPTIONS = [ { value: 'third_party_voucher', label: '第三方卡券' }, { value: 'manual', label: '人工客服' }, { value: 'unclassified', label: '未分类' }, + { value: 'refund_adjustment', label: '历史退款冲减' }, { value: 'archived', label: '历史归档' }, ] @@ -52,6 +53,7 @@ const FULFILLMENT_MODE_LABEL: Record = { manual: '人工客服', archived: '历史归档', unclassified: '未分类', + refund_adjustment: '历史退款冲减', } const COST_STAGE_LABEL: Record = { diff --git a/apps/frontend/src/pages/admin/cost-center/CostExceptionPanel.tsx b/apps/frontend/src/pages/admin/cost-center/CostExceptionPanel.tsx index 18c9fffb..9b5b41c7 100644 --- a/apps/frontend/src/pages/admin/cost-center/CostExceptionPanel.tsx +++ b/apps/frontend/src/pages/admin/cost-center/CostExceptionPanel.tsx @@ -52,6 +52,7 @@ const FULFILLMENT_MODE_LABELS: Record = { pending: '待分类', archived: '历史归档', unclassified: '未分类', + refund_adjustment: '历史退款冲减', } const REASON_LABELS: Record = { diff --git a/apps/frontend/src/types/admin/cost-center.ts b/apps/frontend/src/types/admin/cost-center.ts index 6363f8af..3ce63cd0 100644 --- a/apps/frontend/src/types/admin/cost-center.ts +++ b/apps/frontend/src/types/admin/cost-center.ts @@ -8,6 +8,7 @@ export type FulfillmentChannel = | 'third_party_voucher' | 'legacy_platform' | 'manual' + | 'refund_adjustment' /** 商品来源:由 canonical_sku 前缀推导,空串表示未分类。 */ export type ProductSource = 'affiliate_dash' | 'work_order' | 'third_party_voucher' | 'manual' | ''