Files
order_site/apps/backend/scripts/audit-finance-derived.ts
T

340 lines
11 KiB
TypeScript

#!/usr/bin/env tsx
/**
* 财务销售/成本/总账一致性只读审计。
*
* 用法:
* npm run audit:finance
* npm run audit:finance -- --strict
* npm run audit:finance -- --date-from=2026-11-01 --date-to=2026-11-30
*
* 唯一口径:
* v_finance_order_item_scope 是销售范围的唯一来源;
* sales_daily_facts 必须与其收入、退款守恒;
* finance_journal_entries(ref_type=sales_fact) 必须与事实守恒。
*/
import process from 'node:process'
import { closeDb, query } from '../src/db/client.js'
const strict = process.argv.includes('--strict')
const options = parseOptions(process.argv.slice(2))
type Totals = {
revenueFen: number
refundFen: number
costFen: number
}
async function main() {
const viewReady = await query<{ ready: boolean }>(
`SELECT to_regclass('public.v_finance_order_item_scope') IS NOT NULL AS ready`,
)
if (!viewReady.rows[0]?.ready) {
throw new Error('缺少 v_finance_order_item_scope;请先执行 npm run db:migrate')
}
const bounds = await query<{ date_from: string | null; date_to: string | null }>(
`WITH dates AS (
SELECT stat_date AS stat_date
FROM v_finance_order_item_scope
UNION ALL
SELECT stat_date
FROM sales_daily_facts
UNION ALL
SELECT (occurred_at AT TIME ZONE 'Asia/Shanghai')::date
FROM finance_journal_entries
WHERE ref_type = 'sales_fact' AND status = 'posted'
)
SELECT MIN(stat_date)::text AS date_from, MAX(stat_date)::text AS date_to
FROM dates`,
)
const dateFrom = normalizeDate(options.dateFrom) || bounds.rows[0]?.date_from || today()
const dateTo = normalizeDate(options.dateTo) || bounds.rows[0]?.date_to || dateFrom
if (dateFrom > dateTo) throw new Error('date-from 不能晚于 date-to')
const scope = await loadScopeTotals(dateFrom, dateTo)
const facts = await loadFactTotals(dateFrom, dateTo)
const ledger = await loadLedgerTotals(dateFrom, dateTo)
const snapshots = await loadSnapshotTotals(dateFrom, dateTo)
const quality = await loadQualityChecks(dateFrom, dateTo)
const critical: string[] = []
compare('销售事实收入', scope.revenueFen, facts.revenueFen, critical)
compare('销售事实退款', scope.refundFen, facts.refundFen, critical)
compare('销售事实成本', snapshots.costFen, facts.costFen, critical)
compare('总账收入', facts.revenueFen, ledger.revenueFen, critical)
compare('总账退款', facts.refundFen, ledger.refundFen, critical)
compare('总账成本', facts.costFen, ledger.costFen, critical)
if (quality.duplicateActiveSnapshots > 0) critical.push('存在重复有效成本快照')
if (quality.negativeFactRows > 0) critical.push('销售事实存在负数金额')
if (quality.unresolvedReconciliations > 0) critical.push('存在未解决平台对账差异')
const result = {
dateFrom,
dateTo,
scope,
facts,
ledger,
snapshots,
quality,
critical,
}
console.log(JSON.stringify(result, null, 2))
if (strict && critical.length > 0) {
process.exitCode = 1
}
}
async function loadScopeTotals(
dateFrom: string,
dateTo: string,
): Promise<Totals & { items: number }> {
const result = await query<{
items: number
revenue_fen: string
refund_fen: string
}>(
`SELECT
COUNT(*)::int AS items,
COALESCE(SUM(revenue_fen), 0)::bigint AS revenue_fen,
COALESCE(SUM(refund_fen), 0)::bigint AS refund_fen
FROM v_finance_order_item_scope
WHERE stat_date BETWEEN $1::date AND $2::date`,
[dateFrom, dateTo],
)
const row = result.rows[0]
return {
items: Number(row?.items || 0),
revenueFen: Number(row?.revenue_fen || 0),
refundFen: Number(row?.refund_fen || 0),
costFen: 0,
}
}
async function loadFactTotals(
dateFrom: string,
dateTo: string,
): Promise<Totals & { rows: number }> {
const result = await query<{
rows: number
revenue_fen: string
refund_fen: string
cost_fen: string
}>(
`SELECT
COUNT(*)::int AS rows,
COALESCE(SUM(gross_revenue_fen), 0)::bigint AS revenue_fen,
COALESCE(SUM(refund_amount_fen), 0)::bigint AS refund_fen,
COALESCE(SUM(cost_amount_fen), 0)::bigint AS cost_fen
FROM sales_daily_facts
WHERE stat_date BETWEEN $1::date AND $2::date`,
[dateFrom, dateTo],
)
const row = result.rows[0]
return {
rows: Number(row?.rows || 0),
revenueFen: Number(row?.revenue_fen || 0),
refundFen: Number(row?.refund_fen || 0),
costFen: Number(row?.cost_fen || 0),
}
}
async function loadLedgerTotals(
dateFrom: string,
dateTo: string,
): Promise<Totals & { entries: number }> {
const result = await query<{
entries: number
revenue_fen: string
refund_fen: string
cost_fen: string
}>(
`WITH entries AS (
SELECT id
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')
)
SELECT
(SELECT COUNT(*)::int FROM entries) AS entries,
COALESCE(SUM(line.debit_fen - line.credit_fen) FILTER (
WHERE line.account_code = '1101'
AND entry.event_type IN ('sales_revenue', 'sales_revenue_adjustment')
), 0)::bigint AS revenue_fen,
COALESCE(SUM(line.debit_fen - line.credit_fen) FILTER (
WHERE line.account_code = '4101'
AND entry.event_type IN ('sales_refund', 'sales_refund_adjustment')
), 0)::bigint AS refund_fen,
COALESCE(SUM(line.debit_fen - line.credit_fen) FILTER (
WHERE line.account_code = '5001'
AND entry.event_type IN ('sales_cost', 'sales_cost_adjustment')
), 0)::bigint AS cost_fen
FROM entries
JOIN finance_journal_entries entry ON entry.id = entries.id
JOIN finance_journal_lines line ON line.entry_id = entry.id`,
[dateFrom, dateTo],
)
const row = result.rows[0]
return {
entries: Number(row?.entries || 0),
revenueFen: Number(row?.revenue_fen || 0),
refundFen: Number(row?.refund_fen || 0),
costFen: Number(row?.cost_fen || 0),
}
}
async function loadSnapshotTotals(
dateFrom: string,
dateTo: string,
): Promise<Totals & { estimated: number; actual: number }> {
const result = await query<{
estimated: number
actual: number
cost_fen: string
}>(
`SELECT
COUNT(*) FILTER (WHERE snapshot.snapshot_stage = 'estimated')::int AS estimated,
COUNT(*) FILTER (WHERE snapshot.snapshot_stage = 'actual')::int AS actual,
COALESCE(SUM(snapshot.total_amount_fen), 0)::bigint AS cost_fen
FROM cost_snapshots snapshot
JOIN v_finance_order_item_scope scope ON scope.order_item_id = snapshot.order_item_id
WHERE snapshot.superseded_at IS NULL
AND snapshot.reversed_at IS NULL
AND scope.stat_date BETWEEN $1::date AND $2::date`,
[dateFrom, dateTo],
)
const row = result.rows[0]
return {
estimated: Number(row?.estimated || 0),
actual: Number(row?.actual || 0),
revenueFen: 0,
refundFen: 0,
costFen: Number(row?.cost_fen || 0),
}
}
async function loadQualityChecks(dateFrom: string, dateTo: string) {
const result = await query<{
pending_normalization: number
duplicate_active_snapshots: number
open_cost_exceptions: number
open_manual_cost_exceptions: number
open_on_account_cost_exceptions: number
negative_fact_rows: number
unresolved_reconciliations: number
closed_periods: number
}>(
`SELECT
(
SELECT COUNT(*)::int
FROM v_finance_order_item_scope
WHERE stat_date BETWEEN $1::date AND $2::date
AND channel <> 'unclassified'
AND canonical_product_id IS NULL
) AS pending_normalization,
(
SELECT COUNT(*)::int
FROM (
SELECT subject_type, subject_id, cost_item_id
FROM cost_snapshots
WHERE superseded_at IS NULL AND reversed_at IS NULL
GROUP BY subject_type, subject_id, cost_item_id
HAVING COUNT(*) > 1
) duplicate
) AS duplicate_active_snapshots,
(
SELECT COUNT(*)::int
FROM cost_exceptions
WHERE reason = 'cost_missing' AND status <> 'resolved'
) AS open_cost_exceptions,
(
SELECT COUNT(*)::int
FROM cost_exceptions
WHERE reason = 'manual_cost_missing' AND status <> 'resolved'
) AS open_manual_cost_exceptions,
(
SELECT COUNT(*)::int
FROM cost_exceptions
WHERE reason = 'on_account_cost_pending' AND status <> 'resolved'
) AS open_on_account_cost_exceptions,
(
SELECT COUNT(*)::int
FROM sales_daily_facts
WHERE stat_date BETWEEN $1::date AND $2::date
AND (
gross_revenue_fen < 0
OR refund_amount_fen < 0
OR cost_amount_fen < 0
)
) AS negative_fact_rows,
(
SELECT COUNT(*)::int
FROM finance_reconciliations
WHERE status = 'differences'
AND period_from <= $2::date
AND period_to >= $1::date
) AS unresolved_reconciliations,
(
SELECT COUNT(*)::int
FROM finance_periods
WHERE status = 'closed'
AND period_key BETWEEN to_char($1::date, 'YYYY-MM') AND to_char($2::date, 'YYYY-MM')
) AS closed_periods`,
[dateFrom, dateTo],
)
const row = result.rows[0]
return {
pendingNormalization: Number(row?.pending_normalization || 0),
duplicateActiveSnapshots: Number(row?.duplicate_active_snapshots || 0),
openCostExceptions: Number(row?.open_cost_exceptions || 0),
openManualCostExceptions: Number(row?.open_manual_cost_exceptions || 0),
openOnAccountCostExceptions: Number(row?.open_on_account_cost_exceptions || 0),
negativeFactRows: Number(row?.negative_fact_rows || 0),
unresolvedReconciliations: Number(row?.unresolved_reconciliations || 0),
closedPeriods: Number(row?.closed_periods || 0),
}
}
function compare(label: string, expected: number, actual: number, critical: string[]) {
if (expected !== actual) {
critical.push(`${label}不一致:应为 ${expected},实际 ${actual}`)
}
}
function parseOptions(args: string[]) {
const result: Record<string, string> = {}
for (const arg of args) {
if (!arg.startsWith('--')) continue
const body = arg.slice(2)
const index = body.indexOf('=')
const key = index === -1 ? body : body.slice(0, index)
result[key.replace(/-([a-z])/g, (_, char: string) => char.toUpperCase())] =
index === -1 ? 'true' : body.slice(index + 1)
}
return result
}
function normalizeDate(value: unknown): string | null {
const text = String(value || '').trim()
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : null
}
function today() {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date())
}
main()
.catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
.finally(() => closeDb())