340 lines
11 KiB
TypeScript
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())
|