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

409 lines
14 KiB
TypeScript

#!/usr/bin/env tsx
/**
* 从统一履约/商品真源重建财务派生数据。
*
* 只删除可重建数据:
* - estimated 成本快照;
* - 指定期间的销售事实;
* - 商品/成本类异常明细。
*
* 永久保留:
* - 订单、订单项、退款、真实资金流水、人工支出;
* - actual 成本快照;
* - 全部已过账凭证;销售凭证只追加反向/补差调整,不删除;
* - 手工凭证与已导入的平台对账原始批次。
*
* 默认 dry-run;执行需要显式确认。
*
* npm run finance:rebuild-derived
* npm run finance:rebuild-derived -- --execute --confirm=REBUILD_DERIVED
*/
import process from 'node:process'
import { closeDb, query } from '../src/db/client.js'
import { syncCostSnapshotsForItems } from '../src/services/cost-center/cost-snapshot-service.js'
import {
resolveExceptionsForItemsByReason,
upsertAggregatedExceptionsBatch,
type AggregatedExceptionEntry,
type CostExceptionReason,
} from '../src/services/cost-center/cost-exception-service.js'
import { normalizeProductsForItems } from '../src/services/cost-center/product-normalize-service.js'
import { syncFinanceLedgerFromFacts } from '../src/services/finance/finance-ledger-service.js'
import { rebuildChannelSalesDailyFacts } from '../src/services/finance/sales-facts-channel-service.js'
const CONFIRMATION = 'REBUILD_DERIVED'
const REBUILD_EXCEPTION_REASONS = [
'product_mapping_missing',
'cost_missing',
'manual_cost_missing',
'on_account_cost_pending',
] as const satisfies readonly CostExceptionReason[]
const options = parseOptions(process.argv.slice(2))
const execute = options.execute === 'true'
const confirmation = String(options.confirm || '').trim()
async function main() {
await assertSchema()
const bounds = await resolveDateRange()
const closedPeriods = await loadClosedPeriods(bounds.dateFrom, bounds.dateTo)
if (closedPeriods.length > 0) {
throw new Error(
`区间包含已关账期间 ${closedPeriods.join(', ')};请先反关账,禁止直接重建已关账数据`,
)
}
const before = await loadProtectedTotals()
const scopeItemIds = await loadScopeItemIds(bounds.dateFrom, bounds.dateTo)
const plan = await loadPlan(bounds.dateFrom, bounds.dateTo)
const startedAt = Date.now()
if (!execute) {
console.log(
JSON.stringify(
{
mode: 'dry-run',
...bounds,
scopeItems: scopeItemIds.length,
closedPeriods,
plan,
protectedTotals: before,
hint: `确认后执行:npm run finance:rebuild-derived -- --execute --confirm=${CONFIRMATION}`,
},
null,
2,
),
)
return
}
if (confirmation !== CONFIRMATION) {
throw new Error(`--confirm 必须是 ${CONFIRMATION}`)
}
const clearedExceptions = await clearFinanceExceptions(scopeItemIds)
const clearedSnapshots = await clearEstimatedSnapshots(bounds.dateFrom, bounds.dateTo)
const clearedFacts = await clearSalesFacts(bounds.dateFrom, bounds.dateTo)
const normalization = { matched: 0, unmatched: 0, batches: 0 }
const cost = { created: 0, superseded: 0, missing: 0, affectedDates: new Set<string>() }
for (const ids of chunk(scopeItemIds, 2000)) {
const normalized = await normalizeProductsForItems(ids)
normalization.matched += normalized.matched
normalization.unmatched += normalized.unmatched
normalization.batches += 1
const snapshots = await syncCostSnapshotsForItems(ids)
cost.created += snapshots.created
cost.superseded += snapshots.superseded
cost.missing += snapshots.missing
for (const date of snapshots.affectedDates) cost.affectedDates.add(date)
}
const facts = await rebuildChannelSalesDailyFacts(bounds)
const ledger = await syncFinanceLedgerFromFacts(bounds)
const regeneratedExceptions = await regenerateCostExceptions(scopeItemIds)
const after = await loadProtectedTotals()
assertProtectedTotals(before, after)
console.log(
JSON.stringify(
{
mode: 'execute',
...bounds,
scopeItems: scopeItemIds.length,
cleared: {
estimatedSnapshots: clearedSnapshots,
salesFacts: clearedFacts,
financeExceptionItems: clearedExceptions,
},
normalization,
cost: {
...cost,
affectedDates: [...cost.affectedDates].sort(),
},
facts,
ledger,
regeneratedExceptions,
protectedTotals: after,
durationMs: Date.now() - startedAt,
auditCommand: 'npm run audit:finance -- --strict',
},
null,
2,
),
)
}
async function resolveDateRange() {
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
const dateTo = normalizeDate(options.dateTo) || bounds.rows[0]?.date_to || dateFrom
if (!dateFrom || !dateTo) throw new Error('财务销售范围没有可用业务日')
if (dateFrom > dateTo) throw new Error('date-from 不能晚于 date-to')
return { dateFrom, dateTo }
}
async function loadClosedPeriods(dateFrom: string, dateTo: string): Promise<string[]> {
const result = await query<{ period_key: string }>(
`SELECT period_key
FROM finance_periods
WHERE status = 'closed'
AND period_key BETWEEN to_char($1::date, 'YYYY-MM') AND to_char($2::date, 'YYYY-MM')
ORDER BY period_key`,
[dateFrom, dateTo],
)
return result.rows.map((row) => row.period_key)
}
async function loadScopeItemIds(dateFrom: string, dateTo: string): Promise<number[]> {
const result = await query<{ order_item_id: number }>(
`SELECT order_item_id
FROM v_finance_order_item_scope
WHERE stat_date BETWEEN $1::date AND $2::date
ORDER BY order_item_id`,
[dateFrom, dateTo],
)
return result.rows.map((row) => Number(row.order_item_id))
}
async function loadPlan(dateFrom: string, dateTo: string) {
const result = await query<{
estimated_snapshots: number
sales_facts: number
sales_journal_entries_to_adjust: number
finance_exception_items: number
}>(
`SELECT
(
SELECT COUNT(*)::int
FROM cost_snapshots snapshot
JOIN v_finance_order_item_scope scope ON scope.order_item_id = snapshot.order_item_id
WHERE scope.stat_date BETWEEN $1::date AND $2::date
AND snapshot.snapshot_stage = 'estimated'
) AS estimated_snapshots,
(
SELECT COUNT(*)::int
FROM sales_daily_facts
WHERE stat_date BETWEEN $1::date AND $2::date
) AS sales_facts,
(
SELECT COUNT(*)::int
FROM finance_journal_entries
WHERE ref_type = 'sales_fact'
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')
) AS sales_journal_entries_to_adjust,
(
SELECT COUNT(*)::int
FROM cost_exception_items item
JOIN cost_exceptions exception ON exception.id = item.exception_id
JOIN v_finance_order_item_scope scope ON scope.order_item_id = item.order_item_id
WHERE exception.reason = ANY($3::text[])
AND scope.stat_date BETWEEN $1::date AND $2::date
) AS finance_exception_items`,
[dateFrom, dateTo, REBUILD_EXCEPTION_REASONS],
)
const row = result.rows[0]
return {
estimatedSnapshots: Number(row?.estimated_snapshots || 0),
salesFacts: Number(row?.sales_facts || 0),
salesJournalEntriesToAdjust: Number(row?.sales_journal_entries_to_adjust || 0),
financeExceptionItems: Number(row?.finance_exception_items || 0),
}
}
async function clearFinanceExceptions(itemIds: number[]): Promise<number> {
let resolved = 0
for (const ids of chunk(itemIds, 2000)) {
for (const reason of REBUILD_EXCEPTION_REASONS) {
resolved += await resolveExceptionsForItemsByReason(ids, reason)
}
}
return resolved
}
async function clearEstimatedSnapshots(dateFrom: string, dateTo: string): Promise<number> {
const result = await query(
`DELETE FROM cost_snapshots snapshot
USING v_finance_order_item_scope scope
WHERE scope.order_item_id = snapshot.order_item_id
AND scope.stat_date BETWEEN $1::date AND $2::date
AND snapshot.snapshot_stage = 'estimated'`,
[dateFrom, dateTo],
)
return result.rowCount || 0
}
async function clearSalesFacts(dateFrom: string, dateTo: string): Promise<number> {
const result = await query(
`DELETE FROM sales_daily_facts WHERE stat_date BETWEEN $1::date AND $2::date`,
[dateFrom, dateTo],
)
return result.rowCount || 0
}
async function regenerateCostExceptions(itemIds: number[]) {
const manualRows = itemIds.length
? await query<{ order_item_id: number; sku_key: string }>(
`SELECT DISTINCT ON (scope.order_item_id)
scope.order_item_id,
resolve_internal_sku(item.manual_sku_key, item.sku_name, item.sku_code) AS sku_key
FROM v_finance_order_item_scope scope
JOIN order_items item ON item.id = scope.order_item_id
JOIN fulfillments fulfillment ON fulfillment.order_item_id = scope.order_item_id
WHERE scope.order_item_id = ANY($1::bigint[])
AND fulfillment.channel = 'manual'
AND fulfillment.fulfillment_config_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM cost_snapshots snapshot
WHERE snapshot.subject_type = 'manual_case'
AND snapshot.order_item_id = scope.order_item_id
AND snapshot.superseded_at IS NULL
AND snapshot.reversed_at IS NULL
)
ORDER BY scope.order_item_id`,
[itemIds],
)
: { rows: [] }
const onAccountRows = itemIds.length
? await query<{ order_item_id: number; sku_key: string; bill_id: number }>(
`SELECT
bill.order_item_id,
bill.sku_key,
bill.id AS bill_id
FROM on_account_bills bill
WHERE bill.status = 'pending'
AND bill.order_item_id = ANY($1::bigint[])
ORDER BY bill.order_item_id`,
[itemIds],
)
: { rows: [] }
const entries: AggregatedExceptionEntry[] = [
...manualRows.rows.map((row) => ({
reason: 'manual_cost_missing',
scopeKey: `manual_cost_missing|-|-|${row.sku_key}|manual`,
orderItemId: Number(row.order_item_id),
detail: { skuKey: row.sku_key },
})),
...onAccountRows.rows.map((row) => ({
reason: 'on_account_cost_pending',
scopeKey: `on_account_cost_pending|-|-|${row.sku_key}|on_account`,
orderItemId: Number(row.order_item_id),
detail: { billId: Number(row.bill_id), internalSku: row.sku_key },
})),
]
await upsertAggregatedExceptionsBatch(entries)
return {
manualCostMissing: manualRows.rows.length,
onAccountCostPending: onAccountRows.rows.length,
linked: entries.length,
}
}
async function loadProtectedTotals() {
const result = await query<{
orders: number
order_items: number
order_revenue_fen: string
refunds: number
refund_fen: string
manual_expenses: number
manual_expense_fen: string
fund_entries: number
}>(
`SELECT
(SELECT COUNT(*)::int FROM orders) AS orders,
(SELECT COUNT(*)::int FROM order_items) AS order_items,
(SELECT COALESCE(SUM(revenue_amount_fen), 0)::bigint FROM order_items) AS order_revenue_fen,
(SELECT COUNT(*)::int FROM order_refunds WHERE status = 'confirmed') AS refunds,
(SELECT COALESCE(SUM(amount_fen), 0)::bigint FROM order_refunds WHERE status = 'confirmed')
AS refund_fen,
(SELECT COUNT(*)::int FROM manual_expenses) AS manual_expenses,
(SELECT COALESCE(SUM(amount_fen), 0)::bigint FROM manual_expenses) AS manual_expense_fen,
(SELECT COUNT(*)::int FROM fund_entries) AS fund_entries`,
)
const row = result.rows[0]
return {
orders: Number(row?.orders || 0),
orderItems: Number(row?.order_items || 0),
orderRevenueFen: Number(row?.order_revenue_fen || 0),
refunds: Number(row?.refunds || 0),
refundFen: Number(row?.refund_fen || 0),
manualExpenses: Number(row?.manual_expenses || 0),
manualExpenseFen: Number(row?.manual_expense_fen || 0),
fundEntries: Number(row?.fund_entries || 0),
}
}
function assertProtectedTotals(
before: Awaited<ReturnType<typeof loadProtectedTotals>>,
after: Awaited<ReturnType<typeof loadProtectedTotals>>,
) {
if (JSON.stringify(before) !== JSON.stringify(after)) {
throw new Error(`原始订单/退款/资金数据在重建前后不守恒:${JSON.stringify({ before, after })}`)
}
}
async function assertSchema() {
const result = await query<{ ready: boolean }>(
`SELECT
to_regclass('public.v_finance_order_item_scope') IS NOT NULL
AND to_regclass('public.fulfillments') IS NOT NULL
AND to_regclass('public.v_order_item_current') IS NOT NULL AS ready`,
)
if (!result.rows[0]?.ready) {
throw new Error('财务真源视图缺失;请先执行 npm run db:migrate')
}
}
function chunk<T>(items: T[], size: number): T[][] {
const result: T[][] = []
for (let index = 0; index < items.length; index += size) {
result.push(items.slice(index, index + size))
}
return result
}
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 rawKey = index === -1 ? body : body.slice(0, index)
const value = index === -1 ? 'true' : body.slice(index + 1)
result[rawKey] = value
result[rawKey.replace(/-([a-z])/g, (_, char: string) => char.toUpperCase())] = value
}
return result
}
function normalizeDate(value: unknown): string | null {
const text = String(value || '').trim()
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : null
}
main()
.catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
.finally(() => closeDb())