重构:统一财务派生数据真源与重建

This commit is contained in:
yml2213
2026-11-08 19:44:27 +08:00
parent bae9a5ae5e
commit f8cbb645fe
14 changed files with 1011 additions and 541 deletions
@@ -0,0 +1,46 @@
-- 167_finance_derived_scope.sql
--
-- 财务派生范围唯一读模型:
-- 1. 只纳入 pending / assigned 履约项;
-- 2. 只纳入 paid / refunded 商业订单;
-- 3. 收入、退款、商品、渠道和业务日一次算清;
-- 4. 销售事实、重建和审计共用该视图,禁止各自拼口径。
CREATE OR REPLACE VIEW v_finance_order_item_scope AS
WITH refunded AS (
SELECT
refund.order_item_id,
SUM(refund.amount_fen)::bigint AS refund_fen
FROM order_refunds refund
WHERE refund.status = 'confirmed'
GROUP BY refund.order_item_id
)
SELECT
item.id AS order_item_id,
item.order_id,
(COALESCE(order_record.paid_at, order_record.created_at)
AT TIME ZONE 'Asia/Shanghai')::date AS stat_date,
COALESCE(order_record.paid_at, order_record.created_at) AS basis_at,
order_record.provider,
order_record.shop_id,
fulfillment.canonical_product_id,
CASE
WHEN fulfillment.status = 'pending' OR fulfillment.channel IS NULL THEN 'unclassified'
ELSE fulfillment.channel
END AS channel,
GREATEST(COALESCE(item.normalized_quantity, item.quantity, 1), 1)::numeric AS quantity,
GREATEST(COALESCE(item.revenue_amount_fen, 0), 0)::bigint AS revenue_fen,
GREATEST(COALESCE(refunded.refund_fen, 0), 0)::bigint AS refund_fen
FROM fulfillments fulfillment
JOIN order_items item ON item.id = fulfillment.order_item_id
JOIN orders order_record ON order_record.id = fulfillment.order_id
LEFT JOIN refunded ON refunded.order_item_id = item.id
WHERE fulfillment.status IN ('pending', 'assigned')
AND order_record.pay_status IN ('paid', 'refunded');
COMMENT ON VIEW v_finance_order_item_scope IS
'财务销售范围唯一读模型:收入、退款、商品、渠道和业务日统一从履约真源与商业订单计算';
CREATE INDEX IF NOT EXISTS idx_finance_entries_sales_fact_day
ON finance_journal_entries (occurred_at)
WHERE ref_type = 'sales_fact';
@@ -155,10 +155,17 @@ test('销售毛利必须同时暴露缺失成本收入,避免只看毛利绝
})
test('销售事实必须只统计已支付订单,并扣减已确认退款', async () => {
const source = await read(new URL('./sales-facts-channel-service.ts', import.meta.url))
assert.ok(source.includes("o.pay_status IN ('paid', 'refunded')"), '必须过滤未支付订单')
assert.ok(source.includes('order_refunds'), '必须读取退款表')
assert.ok(source.includes("r.status = 'confirmed'"), '只计入已确认退款')
const [source, scopeMigration] = await Promise.all([
read(new URL('./sales-facts-channel-service.ts', import.meta.url)),
read(new URL('../../db/migrations/167_finance_derived_scope.sql', import.meta.url)),
])
assert.ok(
scopeMigration.includes("order_record.pay_status IN ('paid', 'refunded')"),
'必须过滤未支付订单',
)
assert.ok(scopeMigration.includes('order_refunds'), '必须读取退款表')
assert.ok(scopeMigration.includes("refund.status = 'confirmed'"), '只计入已确认退款')
assert.ok(source.includes('FROM v_finance_order_item_scope scope'))
assert.ok(source.includes('SUM(revenue_fen) - SUM(refund_fen)'), '净收入必须扣减退款')
assert.ok(
source.includes('SUM(revenue_fen) - SUM(refund_fen) - SUM(cost_fen)'),
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
function read(relativePath: string) {
return readFileSync(new URL(relativePath, import.meta.url), 'utf8')
}
const migration = read('../../db/migrations/167_finance_derived_scope.sql')
const salesFacts = read('./sales-facts-channel-service.ts')
const ledger = read('./finance-ledger-service.ts')
const rebuild = read('../../../scripts/rebuild-finance-derived.ts')
const audit = read('../../../scripts/audit-finance-derived.ts')
const launchVerification = read('../../../../../scripts/verify-finance-launch.sql')
test('财务销售范围只从正式履约和商业订单生成', () => {
assert.ok(migration.includes('CREATE OR REPLACE VIEW v_finance_order_item_scope'))
assert.ok(migration.includes("fulfillment.status IN ('pending', 'assigned')"))
assert.ok(migration.includes("order_record.pay_status IN ('paid', 'refunded')"))
assert.ok(migration.includes('idx_finance_entries_sales_fact_day'))
assert.ok(salesFacts.includes('FROM v_finance_order_item_scope scope'))
assert.ok(!salesFacts.includes('FROM v_order_item_current oi\n JOIN orders o'))
})
test('销售凭证同步覆盖事实日期和已有凭证日期,删除事实后能自动冲平', () => {
assert.ok(ledger.includes('WITH target_days AS'))
assert.ok(ledger.includes('FROM sales_daily_facts'))
assert.ok(ledger.includes('SELECT (occurred_at AT TIME ZONE'))
assert.ok(ledger.includes('UNION'))
assert.ok(ledger.includes('LEFT JOIN fact_days'))
})
test('财务重建默认 dry-run,只清理可重建数据并保护原始事实', () => {
assert.ok(rebuild.includes("const CONFIRMATION = 'REBUILD_DERIVED'"))
assert.ok(rebuild.includes('if (!execute)'))
assert.ok(rebuild.includes('clearEstimatedSnapshots'))
assert.ok(rebuild.includes('clearSalesFacts'))
assert.ok(rebuild.includes('assertProtectedTotals'))
assert.doesNotMatch(
rebuild,
/DELETE\s+FROM\s+(?:orders|order_items|order_refunds|manual_expenses|fund_entries)\b/i,
)
assert.doesNotMatch(rebuild, /DELETE\s+FROM\s+finance_journal_entries\b/i)
})
test('财务边界同时覆盖销售范围、现有事实和已过账凭证日期', () => {
assert.match(
rebuild,
/FROM v_finance_order_item_scope\s+UNION ALL\s+SELECT stat_date\s+FROM sales_daily_facts/,
)
assert.match(
audit,
/FROM v_finance_order_item_scope\s+UNION ALL\s+SELECT stat_date\s+FROM sales_daily_facts/,
)
})
test('财务上线门禁只把未冲销的人工快照视为有效', () => {
assert.match(
launchVerification,
/WHERE cs\.subject_type = 'manual_case'[\s\S]+AND cs\.superseded_at IS NULL[\s\S]+AND cs\.reversed_at IS NULL/,
)
assert.match(
launchVerification,
/WHERE subject_type = 'manual_case'[\s\S]+AND reversed_at IS NULL[\s\S]+AND subject_id > 0/,
)
assert.match(
launchVerification,
/WHERE status = 'assigned'[\s\S]+AND \([\s\S]+channel IS NULL[\s\S]+OR channel NOT IN/,
)
})
test('财务审计核对销售范围、事实、总账、快照和对账', () => {
assert.ok(audit.includes('v_finance_order_item_scope'))
assert.ok(audit.includes('sales_daily_facts'))
assert.ok(audit.includes("ref_type = 'sales_fact'"))
assert.ok(audit.includes('duplicateActiveSnapshots'))
assert.ok(audit.includes('unresolvedReconciliations'))
assert.ok(audit.includes('process.exitCode = 1'))
})
@@ -184,15 +184,36 @@ export async function syncFinanceLedgerFromFacts(input: { dateFrom: string; date
cost_amount_fen: string
}>(
`
WITH target_days AS (
SELECT stat_date
FROM sales_daily_facts
WHERE stat_date BETWEEN $1::date AND $2::date
UNION
SELECT (occurred_at AT TIME ZONE 'Asia/Shanghai')::date AS stat_date
FROM finance_journal_entries
WHERE ref_type = 'sales_fact'
AND status = 'posted'
AND occurred_at >= ($1::date::timestamp AT TIME ZONE 'Asia/Shanghai')
AND occurred_at < (($2::date + interval '1 day')::timestamp AT TIME ZONE 'Asia/Shanghai')
),
fact_days AS (
SELECT
stat_date,
COALESCE(SUM(gross_revenue_fen), 0)::bigint AS gross_revenue_fen,
COALESCE(SUM(refund_amount_fen), 0)::bigint AS refund_amount_fen,
COALESCE(SUM(cost_amount_fen), 0)::bigint AS cost_amount_fen
FROM sales_daily_facts
WHERE stat_date BETWEEN $1::date AND $2::date
GROUP BY stat_date
)
SELECT
stat_date::text AS stat_date,
COALESCE(SUM(gross_revenue_fen), 0)::bigint AS gross_revenue_fen,
COALESCE(SUM(refund_amount_fen), 0)::bigint AS refund_amount_fen,
COALESCE(SUM(cost_amount_fen), 0)::bigint AS cost_amount_fen
FROM sales_daily_facts
WHERE stat_date BETWEEN $1::date AND $2::date
GROUP BY stat_date
ORDER BY stat_date
target_days.stat_date::text AS stat_date,
COALESCE(fact_days.gross_revenue_fen, 0)::bigint AS gross_revenue_fen,
COALESCE(fact_days.refund_amount_fen, 0)::bigint AS refund_amount_fen,
COALESCE(fact_days.cost_amount_fen, 0)::bigint AS cost_amount_fen
FROM target_days
LEFT JOIN fact_days ON fact_days.stat_date = target_days.stat_date
ORDER BY target_days.stat_date
`,
[dateFrom, dateTo],
)
@@ -61,42 +61,28 @@ export async function rebuildChannelSalesDailyFacts(input: {
`
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,
scope.stat_date,
scope.provider,
scope.shop_id,
scope.canonical_product_id,
scope.channel,
scope.order_item_id,
scope.order_id,
scope.quantity,
scope.revenue_fen,
scope.refund_fen,
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,
CASE
WHEN oi.fulfillment_channel = 'affiliate_dash' THEN 'sys_affiliate_purchase'
WHEN oi.fulfillment_channel = 'third_party_voucher' THEN 'sys_third_party_voucher_cost'
WHEN oi.fulfillment_channel = 'work_order' THEN 'sys_work_order_reward'
WHEN scope.channel = 'affiliate_dash' THEN 'sys_affiliate_purchase'
WHEN scope.channel = 'third_party_voucher' THEN 'sys_third_party_voucher_cost'
WHEN scope.channel = 'work_order' THEN 'sys_work_order_reward'
ELSE NULL
END AS channel_cost_code
FROM v_order_item_current oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.fulfillment_status IN ('assigned', 'pending')
AND 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
),
refunded AS (
SELECT r.order_item_id, SUM(r.amount_fen)::bigint AS refund_fen
FROM order_refunds r
WHERE r.status = 'confirmed'
GROUP BY r.order_item_id
FROM v_finance_order_item_scope scope
WHERE scope.stat_date BETWEEN $1::date AND $2::date
),
costed AS (
SELECT
s.*,
COALESCE(rf.refund_fen, 0)::bigint AS refund_fen,
COALESCE(c.cost_fen, 0)::bigint AS cost_fen,
-- 成本缺失按「该渠道应有的成本项」判定,避免平台费快照把缺口掩盖掉。
CASE
@@ -105,23 +91,23 @@ export async function rebuildChannelSalesDailyFacts(input: {
FROM cost_snapshots cs
JOIN cost_items ci ON ci.id = cs.cost_item_id
WHERE cs.order_item_id = s.order_item_id
AND cs.superseded_at IS NULL AND reversed_at IS NULL AND cs.reversed_at IS NULL
AND cs.superseded_at IS NULL AND cs.reversed_at IS NULL
AND ci.code = s.channel_cost_code
)
WHEN s.channel = 'manual' THEN NOT EXISTS (
SELECT 1 FROM cost_snapshots cs
WHERE cs.order_item_id = s.order_item_id
AND cs.superseded_at IS NULL AND reversed_at IS NULL AND cs.reversed_at IS NULL
AND cs.superseded_at IS NULL AND cs.reversed_at IS NULL
AND cs.subject_type = 'manual_case'
)
ELSE NOT EXISTS (
SELECT 1 FROM cost_snapshots cs
WHERE cs.order_item_id = s.order_item_id AND cs.superseded_at IS NULL AND reversed_at IS NULL AND cs.reversed_at IS NULL
WHERE cs.order_item_id = s.order_item_id
AND cs.superseded_at IS NULL AND cs.reversed_at IS NULL
)
END AS cost_missing,
COALESCE(c.has_actual, FALSE) AS has_actual
FROM scope s
LEFT JOIN refunded rf ON rf.order_item_id = s.order_item_id
LEFT JOIN (
SELECT
cs.order_item_id,