重构:统一财务派生数据真源与重建
This commit is contained in:
@@ -11,6 +11,8 @@
|
||||
"db:migrate:status": "tsx scripts/migration-status.ts",
|
||||
"finance:sync": "tsx scripts/sync-cost-finance.ts",
|
||||
"finance:rebuild-cutover": "tsx scripts/rebuild-after-cutover.ts",
|
||||
"finance:rebuild-derived": "tsx scripts/rebuild-finance-derived.ts",
|
||||
"audit:finance": "tsx scripts/audit-finance-derived.ts",
|
||||
"revenue:backfill": "tsx scripts/backfill-revenue-basis.ts",
|
||||
"cost-exceptions:archive": "tsx scripts/archive-cost-exceptions.ts",
|
||||
"storage:migrate": "tsx scripts/migrate-storage.ts",
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
#!/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())
|
||||
@@ -1,210 +0,0 @@
|
||||
/**
|
||||
* 履约载体 ↔ 财务渠道 对齐审计(只读)。
|
||||
*
|
||||
* 用法:npx tsx scripts/audit-fulfillment-finance-alignment.ts
|
||||
*/
|
||||
import { closeDb, query } from '../src/db/client.js'
|
||||
|
||||
async function main() {
|
||||
const pending = await query<{
|
||||
total: number
|
||||
with_work_order: number
|
||||
with_affiliate_task: number
|
||||
with_manual_task: number
|
||||
no_carrier: number
|
||||
}>(
|
||||
`
|
||||
SELECT
|
||||
count(*)::int AS total,
|
||||
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled'))::int AS with_work_order,
|
||||
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')))::int AS with_affiliate_task,
|
||||
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch' AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')))::int AS with_manual_task,
|
||||
count(*) FILTER (
|
||||
WHERE NOT EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled')
|
||||
AND NOT EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id)
|
||||
)::int AS no_carrier
|
||||
FROM order_items oi
|
||||
WHERE oi.fulfillment_status = 'pending'
|
||||
`,
|
||||
)
|
||||
|
||||
const channelCarrier = await query<{
|
||||
channel: string
|
||||
total: number
|
||||
work_orders: number
|
||||
affiliate_tasks: number
|
||||
manual_tasks: number
|
||||
config_missing: number
|
||||
no_carrier: number
|
||||
}>(
|
||||
`
|
||||
SELECT
|
||||
COALESCE(NULLIF(oi.fulfillment_channel, ''), '(pending)') AS channel,
|
||||
count(*)::int AS total,
|
||||
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled'))::int AS work_orders,
|
||||
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')))::int AS affiliate_tasks,
|
||||
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch' AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')))::int AS manual_tasks,
|
||||
count(*) FILTER (
|
||||
WHERE oi.fulfillment_channel = 'manual' AND oi.fulfillment_config_id IS NULL
|
||||
)::int AS config_missing,
|
||||
count(*) FILTER (
|
||||
WHERE (
|
||||
oi.fulfillment_channel = 'affiliate_dash'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'
|
||||
AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')
|
||||
)
|
||||
) OR (
|
||||
oi.fulfillment_channel = 'work_order'
|
||||
AND NOT EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled')
|
||||
)
|
||||
)::int AS no_carrier
|
||||
FROM order_items oi
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
GROUP BY 1
|
||||
ORDER BY 2 DESC
|
||||
`,
|
||||
)
|
||||
|
||||
const internalSku = await query<{
|
||||
total: number
|
||||
null_or_blank: number
|
||||
numeric_only: number
|
||||
with_cjk: number
|
||||
}>(
|
||||
`
|
||||
SELECT
|
||||
count(*)::int AS total,
|
||||
count(*) FILTER (WHERE btrim(COALESCE(resolve_internal_sku(
|
||||
manual_sku_key, sku_name, sku_code
|
||||
), '')) = '')::int AS null_or_blank,
|
||||
count(*) FILTER (WHERE btrim(COALESCE(resolve_internal_sku(
|
||||
manual_sku_key, sku_name, sku_code
|
||||
), '')) ~ '^[0-9]+$')::int AS numeric_only,
|
||||
count(*) FILTER (WHERE btrim(COALESCE(resolve_internal_sku(
|
||||
manual_sku_key, sku_name, sku_code
|
||||
), '')) ~ '[㐀-䶿一-鿿豈-𠀀-𰀀-𲎯]')::int AS with_cjk
|
||||
FROM order_items
|
||||
WHERE fulfillment_status = 'assigned'
|
||||
`,
|
||||
)
|
||||
|
||||
const duplicateProducts = await query<{ duplicate_names: number; duplicate_rows: number }>(
|
||||
`
|
||||
SELECT count(*)::int AS duplicate_names, COALESCE(sum(total), 0)::int AS duplicate_rows
|
||||
FROM (
|
||||
SELECT canonical_name, count(*) AS total
|
||||
FROM product_catalog
|
||||
WHERE status = 'active'
|
||||
GROUP BY canonical_name
|
||||
HAVING count(*) > 1
|
||||
) grouped
|
||||
`,
|
||||
)
|
||||
|
||||
const routes = await query<{
|
||||
channel: string
|
||||
route_role: string
|
||||
source: string
|
||||
total: number
|
||||
}>(
|
||||
`SELECT channel, route_role, source, count(*)::int AS total
|
||||
FROM sku_channel_routes
|
||||
WHERE enabled = TRUE
|
||||
GROUP BY channel, route_role, source
|
||||
ORDER BY channel, route_role, source`,
|
||||
)
|
||||
|
||||
const internalRouteConflicts = await query<{
|
||||
internal_sku_id: number
|
||||
total: number
|
||||
}>(
|
||||
`SELECT internal_sku_id, count(*)::int AS total
|
||||
FROM sku_channel_routes
|
||||
WHERE enabled = TRUE AND route_role = 'primary'
|
||||
GROUP BY internal_sku_id
|
||||
HAVING count(*) > 1
|
||||
ORDER BY count(*) DESC, internal_sku_id`,
|
||||
)
|
||||
|
||||
const assignedCarrierConflicts = await query<{
|
||||
order_item_id: number
|
||||
channel: string
|
||||
carrier_count: number
|
||||
}>(
|
||||
`SELECT oi.id AS order_item_id, oi.fulfillment_channel AS channel,
|
||||
(EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled'))::int
|
||||
+ (EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'
|
||||
AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')
|
||||
))::int
|
||||
+ (EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch'
|
||||
AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')
|
||||
))::int AS carrier_count
|
||||
FROM order_items oi
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
AND (EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled'))::int
|
||||
+ (EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'
|
||||
AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')
|
||||
))::int
|
||||
+ (EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch'
|
||||
AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')
|
||||
))::int > 1
|
||||
ORDER BY oi.id
|
||||
LIMIT 200`,
|
||||
)
|
||||
|
||||
const byProvider = await query<{
|
||||
provider: string
|
||||
total: number
|
||||
pending: number
|
||||
with_carrier: number
|
||||
}>(
|
||||
`
|
||||
SELECT o.provider,
|
||||
count(*)::int AS total,
|
||||
count(*) FILTER (WHERE oi.fulfillment_status = 'pending')::int AS pending,
|
||||
count(*) FILTER (
|
||||
WHERE EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled')
|
||||
OR EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id)
|
||||
)::int AS with_carrier
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE COALESCE(o.paid_at, o.created_at) >= '2026-10-31 00:00:00+08'
|
||||
GROUP BY 1
|
||||
ORDER BY 2 DESC
|
||||
`,
|
||||
)
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
pending: pending.rows[0],
|
||||
channelCarrier: channelCarrier.rows,
|
||||
internalSku: internalSku.rows[0],
|
||||
duplicateProducts: duplicateProducts.rows[0],
|
||||
routes: routes.rows,
|
||||
internalRouteConflicts: internalRouteConflicts.rows,
|
||||
assignedCarrierConflicts: assignedCarrierConflicts.rows,
|
||||
byProvider: byProvider.rows,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(() => closeDb())
|
||||
@@ -1,259 +0,0 @@
|
||||
import process from 'node:process'
|
||||
|
||||
import { closeDb, query } from '../src/db/client.js'
|
||||
|
||||
/**
|
||||
* 财务断代 v2:清理断代前的派生数据,为 2026-10-31 起点重建让路。
|
||||
*
|
||||
* 只删「派生数据」,不删业务原始票据:
|
||||
* 删除:cost_snapshots / cost_exceptions(+items) / sales_daily_facts /
|
||||
* finance_journal_lines(+entries) / finance_reconciliations(+items) /
|
||||
* finance_refund_intents / order_refunds / manual_expenses /
|
||||
* fund_entries / platform_credit_entries / manual_sku_registry
|
||||
* 保留:orders / order_items / work_orders / fulfillment_tasks(审计只读)
|
||||
* product_catalog / channel_product_mappings / cost_items /
|
||||
* cost_versions / work_product_rules / manual_fulfillment_configs /
|
||||
* sku_channel_routes(配置)
|
||||
*
|
||||
* 前置:
|
||||
* 1. 迁移 112/113 已执行;
|
||||
* 2. 已做数据库备份。
|
||||
*
|
||||
* 断代本身包含两步:先把断代前的订单项批量置为 archived,再清理派生数据。
|
||||
*
|
||||
* 默认 dry-run,只打印将删除的行数;加 `--execute --confirm=2026-10-31` 才真正执行。
|
||||
* 不可重复执行:成功后会写入 finance_cutovers,第二次会被拦下。
|
||||
*/
|
||||
|
||||
const CUTOVER_DATE = '2026-10-31'
|
||||
|
||||
const options = parseOptions(process.argv.slice(2))
|
||||
const execute = options.execute === 'true'
|
||||
const confirm = String(options.confirm || '').trim()
|
||||
|
||||
/** 派生表:按外键顺序删除。 */
|
||||
const DERIVED_TABLES: Array<{ table: string; order: number; where?: string }> = [
|
||||
{ table: 'cost_exception_items', order: 10 },
|
||||
{ table: 'cost_exceptions', order: 20 },
|
||||
{ table: 'cost_snapshots', order: 30 },
|
||||
{ table: 'sales_daily_facts', order: 40 },
|
||||
{ table: 'finance_journal_lines', order: 50 },
|
||||
{ table: 'finance_journal_entries', order: 60 },
|
||||
{ table: 'finance_reconciliation_items', order: 70 },
|
||||
{ table: 'finance_reconciliations', order: 80 },
|
||||
{ table: 'finance_refund_intents', order: 90 },
|
||||
{ table: 'order_refunds', order: 100 },
|
||||
{ table: 'manual_expenses', order: 110 },
|
||||
{ table: 'fund_entries', order: 120 },
|
||||
{ table: 'platform_credit_entries', order: 130 },
|
||||
{ table: 'manual_sku_registry', order: 140 },
|
||||
]
|
||||
|
||||
async function main() {
|
||||
await assertPreconditions()
|
||||
|
||||
const startedAt = Date.now()
|
||||
const plan = await buildPlan()
|
||||
|
||||
if (!execute) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
mode: 'dry-run',
|
||||
cutoverDate: CUTOVER_DATE,
|
||||
deletePlan: plan,
|
||||
hint: '确认无误后追加 --execute --confirm=2026-10-31',
|
||||
durationMs: Date.now() - startedAt,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (confirm !== CUTOVER_DATE) {
|
||||
throw new Error(`--confirm 必须是 ${CUTOVER_DATE},当前为 "${confirm}"`)
|
||||
}
|
||||
|
||||
await assertNotExecuted()
|
||||
|
||||
const archived = await archiveHistoricalItems()
|
||||
|
||||
const deleted: Record<string, number> = {}
|
||||
for (const item of [...DERIVED_TABLES].sort((a, b) => a.order - b.order)) {
|
||||
const result = await query(
|
||||
`DELETE FROM ${item.table}${item.where ? ` WHERE ${item.where}` : ''}`,
|
||||
)
|
||||
deleted[item.table] = result.rowCount || 0
|
||||
process.stderr.write(`删除 ${item.table}: ${result.rowCount || 0}\n`)
|
||||
}
|
||||
|
||||
// 旧归档表不再保留:生产库不留可查询的历史异常副本。
|
||||
await query(`DROP TABLE IF EXISTS cost_exceptions_archive`)
|
||||
|
||||
// 水位归零:管道从断代日起重新扫描。
|
||||
await query(
|
||||
`
|
||||
UPDATE finance_watermarks
|
||||
SET last_item_id = 0, last_stat_date = $1::date, last_success_at = NULL,
|
||||
last_error = '', updated_at = NOW()
|
||||
`,
|
||||
[CUTOVER_DATE],
|
||||
)
|
||||
|
||||
await query(
|
||||
`
|
||||
INSERT INTO finance_cutovers (cutover_date, executed_at, detail_json)
|
||||
VALUES ($1::date, NOW(), $2::jsonb)
|
||||
ON CONFLICT (cutover_date) DO NOTHING
|
||||
`,
|
||||
[CUTOVER_DATE, JSON.stringify({ deleted, tables: DERIVED_TABLES.map((t) => t.table) })],
|
||||
)
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
mode: 'execute',
|
||||
cutoverDate: CUTOVER_DATE,
|
||||
archivedItems: archived.updated,
|
||||
deleted,
|
||||
durationMs: Date.now() - startedAt,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(async () => {
|
||||
await closeDb()
|
||||
})
|
||||
|
||||
async function buildPlan() {
|
||||
const plan: Array<{ table: string; rows: number }> = []
|
||||
for (const item of [...DERIVED_TABLES].sort((a, b) => a.order - b.order)) {
|
||||
const result = await query<{ count: number }>(
|
||||
`SELECT COUNT(*)::int AS count FROM ${item.table}${item.where ? ` WHERE ${item.where}` : ''}`,
|
||||
)
|
||||
plan.push({ table: item.table, rows: result.rows[0]?.count || 0 })
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
async function assertPreconditions() {
|
||||
const columns = await query<{ column_name: string }>(
|
||||
`
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'order_items'
|
||||
AND column_name IN ('fulfillment_channel', 'fulfillment_status', 'fulfillment_source')
|
||||
`,
|
||||
)
|
||||
if (columns.rows.length < 3) {
|
||||
throw new Error('缺少 112 的三列;请先执行迁移 112')
|
||||
}
|
||||
|
||||
const watermark = await query<{ exists: boolean }>(
|
||||
`SELECT to_regclass('public.finance_watermarks') IS NOT NULL AS exists`,
|
||||
)
|
||||
if (!watermark.rows[0]?.exists) {
|
||||
throw new Error('finance_watermarks 不存在;请先执行迁移 112')
|
||||
}
|
||||
|
||||
await query(
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS finance_cutovers (
|
||||
cutover_date DATE PRIMARY KEY,
|
||||
executed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
detail_json JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
)
|
||||
`,
|
||||
)
|
||||
|
||||
const inconsistent = await query<{ count: number }>(
|
||||
`
|
||||
SELECT COUNT(*)::int AS count
|
||||
FROM order_items
|
||||
WHERE fulfillment_status = 'assigned' AND fulfillment_channel IS NULL
|
||||
`,
|
||||
)
|
||||
const bad = inconsistent.rows[0]?.count || 0
|
||||
if (bad > 0) {
|
||||
throw new Error(
|
||||
`有 ${bad} 行 status=assigned 但 channel 为空;请先执行 fulfillment:backfill-channel`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断代前的订单项统一置为 archived(分批,避免 42 万行整表更新撞超时)。
|
||||
*/
|
||||
async function archiveHistoricalItems(): Promise<{ updated: number }> {
|
||||
let cursor = 0
|
||||
let updated = 0
|
||||
for (let batch = 0; batch < 500; batch += 1) {
|
||||
const rows = await query<{ id: string }>(
|
||||
`
|
||||
SELECT oi.id::text
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date < $1::date
|
||||
AND oi.fulfillment_status <> 'archived'
|
||||
AND oi.id > $2
|
||||
ORDER BY oi.id
|
||||
LIMIT 20000
|
||||
`,
|
||||
[CUTOVER_DATE, cursor],
|
||||
)
|
||||
if (rows.rows.length === 0) break
|
||||
cursor = Number(rows.rows[rows.rows.length - 1]!.id)
|
||||
|
||||
const result = await query(
|
||||
`
|
||||
UPDATE order_items
|
||||
SET fulfillment_status = 'archived',
|
||||
fulfillment_channel = NULL,
|
||||
fulfillment_source = 'cutover_archive',
|
||||
fulfillment_decided_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = ANY($1::bigint[])
|
||||
AND fulfillment_status <> 'archived'
|
||||
`,
|
||||
[rows.rows.map((row) => Number(row.id))],
|
||||
)
|
||||
updated += result.rowCount || 0
|
||||
process.stderr.write(`归档 ${result.rowCount || 0} 行(游标 ${cursor})\n`)
|
||||
}
|
||||
return { updated }
|
||||
}
|
||||
|
||||
async function assertNotExecuted() {
|
||||
const done = await query<{ count: number }>(
|
||||
`SELECT COUNT(*)::int AS count FROM finance_cutovers WHERE cutover_date = $1::date`,
|
||||
[CUTOVER_DATE],
|
||||
)
|
||||
if ((done.rows[0]?.count || 0) > 0) {
|
||||
throw new Error(`${CUTOVER_DATE} 的断代已执行过,禁止重复执行`)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -44,12 +44,12 @@ async function main() {
|
||||
}>(
|
||||
`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE fulfillment_status = 'assigned')::int AS assigned,
|
||||
COUNT(*) FILTER (WHERE fulfillment_status = 'pending')::int AS pending,
|
||||
COUNT(*) FILTER (WHERE fulfillment_status = 'archived')::int AS archived,
|
||||
COUNT(*) FILTER (WHERE status = 'assigned')::int AS assigned,
|
||||
COUNT(*) FILTER (WHERE status = 'pending')::int AS pending,
|
||||
COUNT(*) FILTER (WHERE status = 'archived')::int AS archived,
|
||||
(SELECT COUNT(*)::int FROM sales_daily_facts) AS facts,
|
||||
(SELECT COUNT(*)::int FROM cost_snapshots WHERE superseded_at IS NULL) AS snapshots
|
||||
FROM order_items
|
||||
FROM fulfillments
|
||||
`,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
#!/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())
|
||||
@@ -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,
|
||||
|
||||
+10
-1
@@ -343,7 +343,7 @@ preflight() {
|
||||
fi
|
||||
|
||||
PENDING_REVENUE="$(psql_scalar "SELECT count(*) FROM order_items WHERE revenue_basis = 'unknown'" | tr -d '[:space:]')"
|
||||
PENDING_FULFILLMENT="$(psql_scalar "SELECT count(*) FROM order_items WHERE fulfillment_status = 'pending'" | tr -d '[:space:]')"
|
||||
PENDING_FULFILLMENT="$(psql_scalar "SELECT count(*) FROM fulfillments WHERE status = 'pending'" | tr -d '[:space:]')"
|
||||
|
||||
log " postgres 运行中 / 迁移 103–106 已应用"
|
||||
log " 待回填收入项:${PENDING_REVENUE} 行;待回填履约方式:${PENDING_FULFILLMENT} 行"
|
||||
@@ -418,6 +418,15 @@ step_verify() {
|
||||
warn "验收未通过(psql 退出码 ${code}):按上面 FAIL 项的「处理建议」逐条处理后,重跑 --only=verify"
|
||||
return 1
|
||||
fi
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
log " [dry-run] npm run audit:finance -- --strict"
|
||||
else
|
||||
ensure_tools_image
|
||||
if ! run_tool audit:finance -- --strict; then
|
||||
warn "财务审计未通过:修复销售范围/事实/总账差异后重跑 --only=verify"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
log "验收全部通过"
|
||||
}
|
||||
|
||||
|
||||
+42
-10
@@ -3,7 +3,7 @@ type: 权威架构与实施计划
|
||||
description: 订单、内部 SKU、履约路由、履约执行、商品、成本与财务的唯一权威口径及重构计划。
|
||||
tags: [订单, 内部SKU, 履约, 商品, 成本, 财务, 迁移]
|
||||
status: authoritative
|
||||
version: 2.0
|
||||
version: 2.1
|
||||
updated_at: 2026-11-08
|
||||
---
|
||||
|
||||
@@ -12,7 +12,7 @@ updated_at: 2026-11-08
|
||||
> 状态:**唯一权威文档**。
|
||||
> 最后校准:2026-11-08。
|
||||
> 生产基线:迁移 `146`。
|
||||
> 开发基线:迁移 `166`(仅本地,尚未部署生产)。
|
||||
> 开发基线:迁移 `167`(仅本地,尚未部署生产)。
|
||||
>
|
||||
> 从本文件生效起,订单、内部 SKU、履约渠道、履约载体、标准商品、成本和财务相关实现,
|
||||
> 只能以本文件为业务口径。其他旧分析只作为历史资料,不再具备设计或验收效力。
|
||||
@@ -332,7 +332,7 @@ finance_journal_* -- 总账凭证,可重建
|
||||
|
||||
2026-11-08 生产只读核对结果:
|
||||
|
||||
- 生产迁移 `146`,本地开发基线迁移 `166`。
|
||||
- 生产迁移 `146`,本地开发基线迁移 `167`。
|
||||
- 启用标准商品 `1127`:`aff:` 30、`tpl:` 903、`tpv:` 46、`man:` 148。
|
||||
- `man:` 商品中只有 105 个当前主渠道为人工;41 个已走其他主渠道,2 个无主渠道。
|
||||
- 人工页头部显示 `107`,商品列表显示 `148`,相差的 41 个就是跨渠道旧商品。
|
||||
@@ -395,7 +395,8 @@ A 级数据只能追加新事件或做字段兼容迁移,不能为了“模型
|
||||
- `cost_snapshots`
|
||||
- `cost_exceptions`
|
||||
- `sales_daily_facts`
|
||||
- `finance_journal_entries` / `finance_journal_lines`
|
||||
- `finance_journal_entries` / `finance_journal_lines` 仅允许在空库或明确废弃的测试库重建;
|
||||
已有环境的已过账凭证不得删除,只能追加反向/补差凭证
|
||||
- `v_fulfillment_item_state` 及其依赖投影
|
||||
- 商品统计投影、渠道统计投影、页面汇总缓存
|
||||
- 管道水位与失败重试状态
|
||||
@@ -531,7 +532,7 @@ has_active_cost
|
||||
| 2 建立目标身份与投影 | 本地完成 | 只新增结构 |
|
||||
| 3 迁移路由与商品配置 | 本地完成,生产未执行 | 是,需备份 |
|
||||
| 4 重建履约模型 | 本地完成,生产未执行 | 是,需备份 |
|
||||
| 5 重建财务派生数据 | 未开始 | 是,需备份与对账 |
|
||||
| 5 重建财务派生数据 | 本地完成,生产未执行 | 是,需备份与对账 |
|
||||
| 6 停止旧写路径并删除兼容层 | 履约侧已完成,全局未开始 | 是,需观察期结束 |
|
||||
|
||||
### 阶段 0:口径冻结与文档收口
|
||||
@@ -609,6 +610,8 @@ assigned 且 carrier_status=missing 必须进入异常队列
|
||||
- 迁移 `165`:履约事件只阻止 UPDATE,数据库级联删除不再被 append-only 触发器阻断。
|
||||
- 迁移 `166`:无法解析内部 SKU 时,正式履约行的 `internal_sku` 统一落空字符串,
|
||||
并进入 `missing_internal_sku` 异常,而不是触发非空约束。
|
||||
- 迁移 `167`:建立 `v_finance_order_item_scope`,统一销售事实、重建与审计的收入、
|
||||
退款、商品、渠道和业务日口径;增加销售凭证按日查询索引。
|
||||
- 商品列表、总览、渠道映射和商品下拉统一读取商品当前渠道。
|
||||
- 运行期代码停止写入 `product_channel_capabilities`,该表仅保留到兼容期结束。
|
||||
- 只读审计:`npm run audit:product-routing -- --strict`。
|
||||
@@ -616,7 +619,7 @@ assigned 且 carrier_status=missing 必须进入异常队列
|
||||
- 自动/卡券/接单主渠道商品的人工配置均保留为显式 `fallback`,没有被误升为人工主渠道。
|
||||
- 1 个无主路由、无人工配置、无启用映射的 `man:` 商品已归档,未删除历史引用。
|
||||
- 旧 `internal_sku_channels` 已冻结:运行时不再读、不再写,也不再有视图或触发器依赖。
|
||||
- 空库从迁移 `001` 顺序执行到 `166` 已通过;新库不再保留任何旧履约影子同步触发器。
|
||||
- 空库从迁移 `001` 顺序执行到 `167` 已通过;新库不再保留任何旧履约影子同步触发器。
|
||||
- 无主路由的活动商品会进入“未路由”,不自动猜测渠道。
|
||||
|
||||
当前本地仍需业务处理的数据:
|
||||
@@ -676,7 +679,7 @@ assigned 且 carrier_status=missing 必须进入异常队列
|
||||
5. 全部业务读 SQL 切到 `v_order_item_current`,禁止直接读取订单项旧状态列。
|
||||
6. 状态事件、任务尝试、工单尝试和配置变化触发器统一围绕正式履约表工作。
|
||||
7. 真实 PostgreSQL 集成测试覆盖状态写入、幂等、事件留痕、当前尝试和配置关联。
|
||||
8. 本地存量库与空库 `001 → 166` 均完成硬切验证。
|
||||
8. 本地存量库与空库 `001 → 167` 均完成硬切验证。
|
||||
|
||||
验收:
|
||||
|
||||
@@ -690,6 +693,8 @@ assigned 且 carrier_status=missing 必须进入异常队列
|
||||
|
||||
### 阶段 5:重建财务派生数据
|
||||
|
||||
状态:本地完成,生产未执行。
|
||||
|
||||
本地提前发现并修复:销售凭证增量同步读取当日余额时,必须统计同一天全部
|
||||
`sales_revenue_adjustment / sales_refund_adjustment / sales_cost_adjustment`,
|
||||
不能只按原始凭证的 `ref_id = 日期` 读取,否则重复同步会累计重复补记。
|
||||
@@ -697,7 +702,9 @@ assigned 且 carrier_status=missing 必须进入异常队列
|
||||
目标:
|
||||
|
||||
- 保留 A 级订单、退款事实和 B 级配置。
|
||||
- 删除 C 级成本和财务派生数据并从新模型重建。
|
||||
- 删除可重建的 estimated 成本快照和销售事实;
|
||||
- 已过账总账保持 append-only,通过反向/补差凭证收敛到新事实;
|
||||
- 保留真实退款、人工支出、资金流水、actual 快照和对账原始批次。
|
||||
|
||||
重建顺序:
|
||||
|
||||
@@ -705,10 +712,35 @@ assigned 且 carrier_status=missing 必须进入异常队列
|
||||
product normalization
|
||||
→ cost snapshot
|
||||
→ sales facts
|
||||
→ journal
|
||||
→ journal adjustment
|
||||
→ reconciliation
|
||||
```
|
||||
|
||||
本地产出:
|
||||
|
||||
- `v_finance_order_item_scope` 成为销售范围唯一读模型。
|
||||
- `finance:rebuild-derived` 默认 dry-run,执行需要
|
||||
`--execute --confirm=REBUILD_DERIVED`。
|
||||
- 重建前后校验订单数、订单项数、订单收入、退款、人工支出和资金流水,不守恒立即失败。
|
||||
- 删除旧的 `finance-cutover-v2.ts`;该脚本会删除退款、人工支出和资金流水,违反 A 级保留规则。
|
||||
- 本地重建区间 `2026-10-30 → 2026-11-08`:
|
||||
- 财务范围 8 个订单项,收入 `8000` 分,退款 `0`;
|
||||
- 销售事实收入 `8000` 分,退款 `0`,成本 `0`;
|
||||
- 总账收入 `8000` 分,退款 `0`,成本 `0`;
|
||||
- 第二次重建总账调整为 `0`,证明幂等;
|
||||
- 严格审计关键异常 `0`;
|
||||
- 遗留 2 个待归一商品项和 2 个 `manual_cost_missing`,均有结构化异常,不自动猜测。
|
||||
- 重建前派生数据备份在本地 schema
|
||||
`finance_rebuild_backup_20261108_1915`,不属于生产迁移结构。
|
||||
|
||||
验收命令:
|
||||
|
||||
```bash
|
||||
npm run finance:rebuild-derived
|
||||
npm run finance:rebuild-derived -- --execute --confirm=REBUILD_DERIVED
|
||||
npm run audit:finance -- --strict
|
||||
```
|
||||
|
||||
验收:
|
||||
|
||||
- 收入总额守恒。
|
||||
@@ -719,7 +751,7 @@ product normalization
|
||||
|
||||
### 阶段 6:停止旧写路径并删除兼容层
|
||||
|
||||
状态:履约域已在本地停止旧写路径;商品、成本、人工配置的全局兼容清理仍需后续完成。
|
||||
状态:履约与财务派生写入路径已在本地收敛;商品、成本、人工配置的全局兼容清理与生产观察期仍需后续完成。
|
||||
|
||||
只有前五个阶段持续稳定后才允许:
|
||||
|
||||
|
||||
@@ -70,14 +70,14 @@ FROM (
|
||||
|
||||
-- 03 履约渠道/状态回填 -----------------------------------------------------
|
||||
INSERT INTO _launch_checks
|
||||
SELECT 3, '03', '履约渠道与状态合法(fulfillment_channel/status)',
|
||||
SELECT 3, '03', '履约状态合法(fulfillments.status)',
|
||||
v::text, '0',
|
||||
CASE WHEN v = 0 THEN 'PASS' ELSE 'FAIL' END,
|
||||
'存在非法状态:检查迁移 112 与渠道判定管道(channel_assign)'
|
||||
'存在非法状态:检查迁移 163 与履约状态写入口'
|
||||
FROM (
|
||||
SELECT count(*) AS v
|
||||
FROM order_items
|
||||
WHERE fulfillment_status NOT IN ('pending', 'assigned', 'archived')
|
||||
FROM fulfillments
|
||||
WHERE status NOT IN ('pending', 'assigned', 'archived')
|
||||
) s;
|
||||
|
||||
-- 04 履约渠道取值合法 ------------------------------------------------------
|
||||
@@ -85,12 +85,17 @@ INSERT INTO _launch_checks
|
||||
SELECT 4, '04', '履约渠道取值合法',
|
||||
v::text, '0',
|
||||
CASE WHEN v = 0 THEN 'PASS' ELSE 'FAIL' END,
|
||||
'存在未知 fulfillment_channel,检查渠道规则与管道写入'
|
||||
'存在未知 fulfillments.channel,检查渠道规则与履约状态写入口'
|
||||
FROM (
|
||||
SELECT count(*) AS v
|
||||
FROM order_items
|
||||
WHERE fulfillment_status = 'assigned'
|
||||
AND fulfillment_channel NOT IN ('work_order', 'affiliate_dash', 'third_party_voucher', 'manual')
|
||||
FROM fulfillments
|
||||
WHERE status = 'assigned'
|
||||
AND (
|
||||
channel IS NULL
|
||||
OR channel NOT IN (
|
||||
'work_order', 'affiliate_dash', 'third_party_voucher', 'manual', 'on_account'
|
||||
)
|
||||
)
|
||||
) s;
|
||||
|
||||
-- 05 商品目录 --------------------------------------------------------------
|
||||
@@ -113,7 +118,7 @@ FROM (
|
||||
(SELECT count(*) FROM cost_versions
|
||||
WHERE status = 'active' AND (amount_fen > 0 OR rate_bps > 0)) AS priced_versions,
|
||||
(SELECT count(*) FROM cost_snapshots
|
||||
WHERE superseded_at IS NULL AND total_amount_fen > 0) AS positive_snapshots
|
||||
WHERE superseded_at IS NULL AND reversed_at IS NULL AND total_amount_fen > 0) AS positive_snapshots
|
||||
) s;
|
||||
|
||||
-- 07 客服备用金账户 --------------------------------------------------------
|
||||
@@ -140,7 +145,7 @@ SELECT 8, '08', '上线后人工项已匹配履约配置',
|
||||
'商品与成本 → 人工 SKU 配置 →「扫描」并逐项配置;保存后会自动回填近 30 天'
|
||||
FROM (
|
||||
SELECT count(*) AS v
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
AND oi.fulfillment_channel = 'manual'
|
||||
AND oi.created_at >= :'launch_at'::timestamptz
|
||||
@@ -155,7 +160,7 @@ SELECT 9, '09', '上线后人工项均已确认履约(关账门槛)',
|
||||
'待办量:有未确认人工单就会 FAIL,不宜当日常健康检查;订单列表/详情 →「确认履约」后写 manual_case 快照并扣备用金/积分'
|
||||
FROM (
|
||||
SELECT count(*) AS v
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
AND oi.fulfillment_channel = 'manual'
|
||||
AND oi.created_at >= :'launch_at'::timestamptz
|
||||
@@ -164,6 +169,7 @@ FROM (
|
||||
WHERE cs.subject_type = 'manual_case'
|
||||
AND cs.order_item_id = oi.id
|
||||
AND cs.superseded_at IS NULL
|
||||
AND cs.reversed_at IS NULL
|
||||
)
|
||||
) s;
|
||||
|
||||
@@ -178,7 +184,10 @@ FROM (
|
||||
FROM (
|
||||
SELECT order_item_id
|
||||
FROM cost_snapshots
|
||||
WHERE subject_type = 'manual_case' AND superseded_at IS NULL
|
||||
WHERE subject_type = 'manual_case'
|
||||
AND superseded_at IS NULL
|
||||
AND reversed_at IS NULL
|
||||
AND subject_id > 0
|
||||
GROUP BY 1
|
||||
HAVING count(*) > 1
|
||||
) dup
|
||||
@@ -199,6 +208,7 @@ FROM (
|
||||
ON me.id = NULLIF(cs.metadata_json->>'fundExpenseId', '')::bigint
|
||||
WHERE cs.subject_type = 'manual_case'
|
||||
AND cs.superseded_at IS NULL
|
||||
AND cs.reversed_at IS NULL
|
||||
AND cs.created_at >= :'launch_at'::timestamptz
|
||||
AND cs.total_amount_fen <> me.amount_fen
|
||||
) s;
|
||||
|
||||
Reference in New Issue
Block a user