feat(finance): 历史订单退款按退款日入账

- 迁移 116 新增 refund_adjustment 渠道值,并允许该类行净收入为负
- 销售事实按退款日聚合 archived 订单的已确认退款,写冲减行
- 退款后同时回填原销售日与退款日的事实与总账
- 退款同时重算相关订单项的费用快照(比例费按净收入)
- 前端渠道筛选与标签支持「历史退款冲减」
This commit is contained in:
yml2213
2026-11-01 14:46:11 +08:00
parent cf0d600c7d
commit 624c0ef4af
7 changed files with 118 additions and 9 deletions
@@ -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
);
@@ -67,6 +67,7 @@ export type FulfillmentChannel =
| 'pending'
| 'archived'
| 'unclassified'
| 'refund_adjustment'
export const FULFILLMENT_CHANNELS = new Set<string>([
'work_order',
@@ -76,6 +77,7 @@ export const FULFILLMENT_CHANNELS = new Set<string>([
'pending',
'archived',
'unclassified',
'refund_adjustment',
])
/**
@@ -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<void> {
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)}`
}
@@ -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,
}
})
@@ -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<string, string> = {
manual: '人工客服',
archived: '历史归档',
unclassified: '未分类',
refund_adjustment: '历史退款冲减',
}
const COST_STAGE_LABEL: Record<string, { label: string; color: string }> = {
@@ -52,6 +52,7 @@ const FULFILLMENT_MODE_LABELS: Record<string, string> = {
pending: '待分类',
archived: '历史归档',
unclassified: '未分类',
refund_adjustment: '历史退款冲减',
}
const REASON_LABELS: Record<string, string> = {
@@ -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' | ''