#!/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 { 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 { 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 { 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 { 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 = {} 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())