feat(finance): 商品归一、成本快照与渠道销售事实三条管道
- 商品归一统一为一个匹配器,禁止自动建商品,未命中进未归一队列 - 成本快照收敛为唯一入口:商品/规则/全局版本解析,工单验收自动升级 actual - 异常改为 SKU 级聚合,补齐后自动关闭 - 销售事实按渠道汇总,pending 收入进 unclassified 桶,归档不进报表
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user