重构:统一财务派生数据真源与重建
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user