feat(finance): 商品归一、成本快照与渠道销售事实三条管道

- 商品归一统一为一个匹配器,禁止自动建商品,未命中进未归一队列
- 成本快照收敛为唯一入口:商品/规则/全局版本解析,工单验收自动升级 actual
- 异常改为 SKU 级聚合,补齐后自动关闭
- 销售事实按渠道汇总,pending 收入进 unclassified 桶,归档不进报表
This commit is contained in:
yml2213
2026-10-31 18:12:13 +08:00
parent 8cdc2423d5
commit 3b0cfbb8fa
6 changed files with 1193 additions and 0 deletions
@@ -0,0 +1,161 @@
import { withTransaction } from '../../db/client.js'
import { nowIso } from '../../utils/time.js'
/**
* 管道 C2:销售事实(渠道版)。
*
* 与旧实现的关键差别:
* 1. 维度用 `fulfillment_channel`,未分类订单进 `unclassified` 桶,
* 收入不会因为「还没分类」而消失;
* 2. 归档订单(archived)不进事实;
* 3. 成本来自 `cost_snapshots`,缺失就记 0 并计数,不把 0 当真实成本。
*
* 事实表暂时沿用 `fulfillment_mode` 列承载渠道值(113 再改列名)。
*/
export type ChannelFactsResult = {
dateFrom: string
dateTo: string
deleted: number
inserted: number
skippedClosedPeriods: string[]
}
export async function rebuildChannelSalesDailyFacts(input: {
dateFrom: string
dateTo: string
}): Promise<ChannelFactsResult> {
const dateFrom = normalizeDate(input.dateFrom)
const dateTo = normalizeDate(input.dateTo)
if (dateFrom > dateTo) {
throw new Error(`开始日期不能晚于结束日期:${dateFrom} > ${dateTo}`)
}
const computedAt = nowIso()
const closed = await withTransaction(async (client) => {
const periods = await client.query<{ period_key: string }>(
`
SELECT period_key FROM finance_periods
WHERE status = 'closed'
AND period_key BETWEEN $1 AND $2
ORDER BY period_key
`,
[dateFrom.slice(0, 7), dateTo.slice(0, 7)],
)
const skipped = periods.rows.map((row) => row.period_key)
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 to_char(f.stat_date, 'YYYY-MM') = p.period_key
)
`,
[dateFrom, dateTo],
)
const inserted = await client.query(
`
WITH scope AS (
SELECT
(COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date AS stat_date,
o.provider,
o.shop_id,
oi.canonical_product_id,
CASE
WHEN oi.fulfillment_status = 'pending' THEN 'unclassified'
WHEN oi.fulfillment_channel IS NULL THEN 'unclassified'
ELSE oi.fulfillment_channel
END AS channel,
oi.id AS order_item_id,
o.id AS order_id,
COALESCE(oi.normalized_quantity, oi.quantity, 1)::numeric AS quantity,
COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.fulfillment_status IN ('assigned', 'pending')
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
BETWEEN $1::date AND $2::date
),
costed AS (
SELECT
s.*,
COALESCE(c.cost_fen, 0)::bigint AS cost_fen,
(c.order_item_id IS NULL) AS cost_missing,
COALESCE(c.has_actual, FALSE) AS has_actual
FROM scope s
LEFT JOIN (
SELECT
cs.order_item_id,
SUM(cs.total_amount_fen)::bigint AS cost_fen,
BOOL_AND(cs.snapshot_stage = 'actual') AS has_actual
FROM cost_snapshots cs
WHERE cs.superseded_at IS NULL
GROUP BY cs.order_item_id
) c ON c.order_item_id = s.order_item_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,
channel,
COUNT(DISTINCT order_id)::int,
COUNT(*)::int,
SUM(quantity),
SUM(revenue_fen),
0,
SUM(revenue_fen),
SUM(cost_fen),
COUNT(*) FILTER (WHERE cost_missing)::int,
CASE
WHEN COUNT(*) FILTER (WHERE cost_missing) = COUNT(*) THEN 'missing'
WHEN COUNT(*) FILTER (WHERE cost_missing) > 0 THEN 'partial'
WHEN BOOL_AND(has_actual) THEN 'actual'
ELSE 'estimated'
END,
SUM(revenue_fen) - SUM(cost_fen),
$3::timestamptz,
$3::timestamptz,
$3::timestamptz
FROM costed
GROUP BY stat_date, provider, shop_id, canonical_product_id, channel
`,
[dateFrom, dateTo, computedAt],
)
return {
deleted: deleted.rowCount || 0,
inserted: inserted.rowCount || 0,
skipped,
}
})
return {
dateFrom,
dateTo,
deleted: closed.deleted,
inserted: closed.inserted,
skippedClosedPeriods: closed.skipped,
}
}
function normalizeDate(value: unknown): string {
const text = String(value || '')
.trim()
.slice(0, 10)
if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) {
throw new Error(`日期格式无效:${String(value)}`)
}
return text
}