refactor(finance)!: 强制渠道模型,移除旧履约方式与同步链路

- 删除 fulfillment_mode 相关的旧同步/回填链路与定时任务
- 删除 cost_finance_sync、成本快照同步、人工商品归一等遗留服务
- 迁移 113 直接删除 order_items.fulfillment_mode 及其索引约束
- 断代脚本自带归档步骤,不再依赖独立回填脚本
- 退款/事实/总账统一走渠道版销售事实
This commit is contained in:
yml2213
2026-10-31 19:52:03 +08:00
parent 808949f3b8
commit 65b7f4f9dc
25 changed files with 156 additions and 2383 deletions
@@ -5,7 +5,7 @@ import test from 'node:test'
const read = (path: URL) => readFile(path, 'utf8')
test('销售事实重算必须冻结已关账期间', async () => {
const source = await read(new URL('./sales-facts-service.ts', import.meta.url))
const source = await read(new URL('./sales-facts-channel-service.ts', import.meta.url))
assert.ok(source.includes('finance_periods'), '重算必须读取会计期间')
assert.ok(source.includes("p.status = 'closed'"), '必须识别已关账期间')
assert.ok(source.includes('skippedClosedPeriods'), '必须返回被跳过的关账期间')
@@ -85,7 +85,7 @@ test('人工确认前必须自动匹配履约配置,配置保存/定时任务
new URL('../cost-center/manual-fulfillment-config-service.ts', import.meta.url),
'utf8',
),
readFile(new URL('../scheduler/cost-finance-sync-job.ts', import.meta.url), 'utf8'),
readFile(new URL('../scheduler/finance-pipeline-jobs.ts', import.meta.url), 'utf8'),
readFile(new URL('../../db/migrations/106_manual_sku_key.sql', import.meta.url), 'utf8'),
])
assert.ok(
@@ -99,10 +99,9 @@ test('人工确认前必须自动匹配履约配置,配置保存/定时任务
'保存人工 SKU 配置后必须立即回填最近 30 天订单项',
)
assert.ok(
jobSource.includes('rematchManualFulfillments({') &&
jobSource.includes('onlyUnmatched: true') &&
jobSource.includes('dateFrom:'),
'定时同步必须自动回填未匹配的人工订单项,并限制在最近窗口',
jobSource.includes("fulfillment_channel = 'manual'") &&
jobSource.includes('fulfillment_config_id'),
'人工载体管道必须挂载人工配置',
)
assert.ok(
configSource.includes('manual_sku_key'),
@@ -117,21 +116,20 @@ test('人工确认前必须自动匹配履约配置,配置保存/定时任务
)
})
test('人工成本缺失必须进入异常队列,且只监控首次配置之后的订单项', async () => {
const source = await readFile(
new URL('../cost-center/cost-snapshot-sync-service.ts', import.meta.url),
'utf8',
)
assert.ok(source.includes("'manual_cost_missing'"), '缺少人工成本缺失异常')
test('人工成本缺失由管道按配置与快照生成并收口', async () => {
const [pipelineSource, manualSource] = await Promise.all([
readFile(new URL('../scheduler/finance-pipeline-jobs.ts', import.meta.url), 'utf8'),
readFile(new URL('./manual-fulfillment-service.ts', import.meta.url), 'utf8'),
])
assert.ok(pipelineSource.includes("'manual_cost_missing'"), '管道必须产出人工成本缺失异常')
assert.ok(
source.includes('FROM manual_fulfillment_configs') &&
source.includes('s.basis_at >= c.enforce_from'),
'人工异常必须以首次配置时间为起点,避免历史人工单刷屏',
pipelineSource.includes("cs.subject_type = 'manual_case'") &&
pipelineSource.includes('cs.order_item_id = oi.id'),
'人工成本缺失必须以人工快照是否存在为判定',
)
assert.ok(
source.includes("cs.subject_type = 'manual_case'") &&
source.includes('cs.order_item_id = s.order_item_id'),
'人工异常回填/收口必须按订单项关联人工快照',
manualSource.includes("reason = 'manual_cost_missing'"),
'确认履约后必须收口人工成本缺失异常',
)
})
@@ -21,8 +21,6 @@ test(
process.env.DATABASE_URL = TEST_DATABASE_URL
const { query, withTransaction, closeDb } = await import('../../db/client.js')
const { confirmManualFulfillment } = await import('./manual-fulfillment-service.js')
const { syncEstimatedOrderItemCostSnapshots } =
await import('../cost-center/cost-snapshot-sync-service.js')
const { rematchManualFulfillments } =
await import('../cost-center/manual-fulfillment-config-service.js')
const {
@@ -364,14 +362,6 @@ test(
orderIds.push(pendingItem.orderId)
orderItemIds.push(pendingItem.orderItemId)
await syncEstimatedOrderItemCostSnapshots({ dateFrom: today, dateTo: today })
const openManualException = await query<{ count: number }>(
`SELECT COUNT(*)::int AS count FROM cost_exceptions
WHERE reason = 'manual_cost_missing' AND status = 'open' AND subject_id = $1`,
[pendingItem.orderItemId],
)
assert.equal(Number(openManualException.rows[0]?.count), 1, '人工缺成本必须进异常队列')
await confirmManualFulfillment(
{ orderItemId: pendingItem.orderItemId, costFen: 100 },
session,
@@ -382,13 +372,6 @@ test(
[pendingItem.orderItemId],
)
assert.equal(Number(openAfterConfirm.rows[0]?.count), 0, '确认后应立即收口人工异常')
await syncEstimatedOrderItemCostSnapshots({ dateFrom: today, dateTo: today })
const resolvedManualException = await query<{ count: number }>(
`SELECT COUNT(*)::int AS count FROM cost_exceptions
WHERE reason = 'manual_cost_missing' AND status = 'resolved' AND subject_id = $1`,
[pendingItem.orderItemId],
)
assert.equal(Number(resolvedManualException.rows[0]?.count), 1, '确认后异常必须自动收口')
// 7. 总账:销售事实同步、试算平衡、关账阻断、平台对账匹配
const todayDate = shanghaiDate()
@@ -14,7 +14,7 @@ import type { AdminSession } from '../admin/admin-auth-service.js'
import { createHttpError } from '../../utils/http.js'
import { generateRefundNo } from '../../utils/random.js'
import { nowIso } from '../../utils/time.js'
import { rebuildSalesDailyFacts } from './sales-facts-service.js'
import { rebuildChannelSalesDailyFacts } from './sales-facts-channel-service.js'
import { approveKuaishouIndustryRefund } from '../platforms/kuaishou-industry/refund-service.js'
import { syncFinanceLedgerFromFacts } from './finance-ledger-service.js'
@@ -328,7 +328,7 @@ export async function createManualRefund(
const basisDate = toChinaDate(written.basisAt)
if (basisDate) {
await rebuildSalesDailyFacts({ dateFrom: basisDate, dateTo: basisDate })
await rebuildChannelSalesDailyFacts({ dateFrom: basisDate, dateTo: basisDate })
await syncFinanceLedgerFromFacts({ dateFrom: basisDate, dateTo: basisDate })
}
@@ -442,7 +442,7 @@ export async function voidOrderRefund(
const rebuiltDate = toChinaDate(context.basisAt)
if (rebuiltDate) {
await rebuildSalesDailyFacts({ dateFrom: rebuiltDate, dateTo: rebuiltDate })
await rebuildChannelSalesDailyFacts({ dateFrom: rebuiltDate, dateTo: rebuiltDate })
await syncFinanceLedgerFromFacts({ dateFrom: rebuiltDate, dateTo: rebuiltDate })
}
const refunds = await listOrderRefunds(context.orderId)
@@ -1,7 +1,6 @@
import { query, withTransaction } from '../../db/client.js'
import { query } from '../../db/client.js'
import { createHttpError } from '../../utils/http.js'
import { resolveFinanceDateRange } from '../../utils/finance-date-range.js'
import { nowIso } from '../../utils/time.js'
export type SalesFactsInput = {
dateFrom?: unknown
@@ -10,14 +9,6 @@ export type SalesFactsInput = {
to?: unknown
}
export type SalesFactsRebuildResult = {
range: { fromDate: string; toDate: string }
deletedCount: number
insertedCount: number
skippedClosedPeriods: string[]
computedAt: string
}
type SalesFactRow = {
[column: string]: unknown
id: number
@@ -45,167 +36,6 @@ type SalesFactRow = {
* 重算指定日期范围的销售事实。日期统一按 Asia/Shanghai 归属。
* 未归一商品允许 canonical_product_id 为空,汇总时形成“未归一”桶。
*/
export async function rebuildSalesDailyFacts(
input: SalesFactsInput = {},
): Promise<SalesFactsRebuildResult> {
const range = resolveFinanceDateRange(input, { defaultDays: 30, maxDays: 400 })
const computedAt = nowIso()
// 已关账期间必须冻结:只重算开放期间,避免“总账已关账、报表事实还在变”。
const closedPeriods = await query<{ period_key: string }>(
`SELECT period_key
FROM finance_periods
WHERE status = 'closed'
AND period_key BETWEEN $1 AND $2
ORDER BY period_key`,
[range.fromDate.slice(0, 7), range.toDate.slice(0, 7)],
)
const skippedClosedPeriods = closedPeriods.rows.map((row) => row.period_key)
return withTransaction(async (client) => {
const deleted = await client.query(
`DELETE FROM sales_daily_facts f
WHERE f.stat_date BETWEEN $1::date AND $2::date
AND NOT EXISTS (
SELECT 1 FROM finance_periods p
WHERE p.status = 'closed'
AND p.period_key = to_char(f.stat_date, 'YYYY-MM')
)`,
[range.fromDate, range.toDate],
)
const inserted = await client.query(
`
WITH scoped_orders AS MATERIALIZED (
-- 先物化 7 天窗口内的订单集合,避免 planner 对每个订单项做一次
-- orders 随机回表;后续全部走 hash/merge join。
SELECT
o.id,
o.provider,
o.shop_id,
o.pay_status,
COALESCE(o.paid_at, o.created_at) AS basis_at
FROM orders o
WHERE o.pay_status IN ('paid', 'refunded')
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
BETWEEN $1::date AND $2::date
AND NOT EXISTS (
SELECT 1 FROM finance_periods p
WHERE p.status = 'closed'
AND p.period_key = to_char(
(COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date,
'YYYY-MM'
)
)
),
scoped_items AS MATERIALIZED (
SELECT
oi.id,
oi.order_id,
oi.canonical_product_id,
oi.fulfillment_mode,
oi.revenue_amount_fen,
oi.normalized_quantity,
oi.quantity
FROM order_items oi
WHERE oi.order_id IN (SELECT id FROM scoped_orders)
),
cost_by_item AS (
-- 一次全量聚合后再 hash join,替代逐项 LATERAL 查 cost_snapshots。
SELECT
order_item_id,
SUM(total_amount_fen)::bigint AS cost_fen,
COUNT(*)::int AS snapshot_count,
COUNT(*) FILTER (WHERE snapshot_stage = 'actual')::int AS actual_count
FROM cost_snapshots
WHERE superseded_at IS NULL
GROUP BY order_item_id
),
refunds_by_item AS (
SELECT
order_item_id,
SUM(amount_fen)::bigint AS refund_fen,
COUNT(*)::int AS refund_count
FROM order_refunds
WHERE status = 'confirmed'
GROUP BY order_item_id
),
item_base AS (
SELECT
(o.basis_at AT TIME ZONE 'Asia/Shanghai')::date AS stat_date,
o.provider,
o.shop_id,
oi.canonical_product_id,
COALESCE(oi.fulfillment_mode, 'manual') AS fulfillment_mode,
oi.order_id,
COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen,
CASE
WHEN COALESCE(refunds.refund_count, 0) > 0
THEN LEAST(
COALESCE(refunds.refund_fen, 0),
GREATEST(COALESCE(oi.revenue_amount_fen, 0), 0)
)::bigint
WHEN o.pay_status = 'refunded'
THEN GREATEST(COALESCE(oi.revenue_amount_fen, 0), 0)::bigint
ELSE 0::bigint
END AS refund_fen,
GREATEST(COALESCE(oi.normalized_quantity, oi.quantity, 1), 1)::numeric AS quantity,
COALESCE(cost.cost_fen, 0)::bigint AS cost_fen,
COALESCE(cost.snapshot_count, 0)::int AS snapshot_count,
COALESCE(cost.actual_count, 0)::int AS actual_count
FROM scoped_items oi
JOIN scoped_orders o ON o.id = oi.order_id
LEFT JOIN cost_by_item cost ON cost.order_item_id = oi.id
LEFT JOIN refunds_by_item refunds ON refunds.order_item_id = oi.id
)
INSERT INTO sales_daily_facts (
stat_date, provider, shop_id, canonical_product_id, fulfillment_mode,
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,
canonical_product_id,
fulfillment_mode,
COUNT(DISTINCT order_id)::int,
COUNT(*)::int,
SUM(quantity),
SUM(revenue_fen),
SUM(refund_fen),
SUM(revenue_fen - refund_fen),
SUM(cost_fen),
COUNT(*) FILTER (WHERE snapshot_count = 0)::int,
CASE
WHEN COUNT(*) FILTER (WHERE snapshot_count = 0) = COUNT(*) THEN 'missing'
WHEN COUNT(*) FILTER (WHERE snapshot_count = 0) > 0 THEN 'partial'
WHEN COUNT(*) FILTER (WHERE snapshot_count > 0 AND actual_count < snapshot_count) > 0
THEN 'estimated'
ELSE 'actual'
END,
SUM(revenue_fen - refund_fen) - SUM(cost_fen),
$3::timestamptz,
$3::timestamptz,
$3::timestamptz
FROM item_base
GROUP BY stat_date, provider, shop_id, canonical_product_id, fulfillment_mode
`,
[range.fromDate, range.toDate, computedAt],
)
return {
range,
deletedCount: deleted.rowCount || 0,
insertedCount: inserted.rowCount || 0,
skippedClosedPeriods,
computedAt,
}
})
}
export async function getSalesProfitOverview(input: SalesFactsInput = {}) {
const range = resolveFinanceDateRange(input, { defaultDays: 30, maxDays: 400 })