From f8cbb645fe6b789b8aa155142ed6c357021d1443 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 8 Nov 2026 19:25:05 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=EF=BC=9A=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E8=B4=A2=E5=8A=A1=E6=B4=BE=E7=94=9F=E6=95=B0=E6=8D=AE=E7=9C=9F?= =?UTF-8?q?=E6=BA=90=E4=B8=8E=E9=87=8D=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/package.json | 2 + apps/backend/scripts/audit-finance-derived.ts | 339 +++++++++++++++ .../audit-fulfillment-finance-alignment.ts | 210 --------- apps/backend/scripts/finance-cutover-v2.ts | 259 ----------- apps/backend/scripts/rebuild-after-cutover.ts | 8 +- .../scripts/rebuild-finance-derived.ts | 408 ++++++++++++++++++ .../migrations/167_finance_derived_scope.sql | 46 ++ .../services/finance/finance-controls.test.ts | 15 +- .../finance/finance-derived-rebuild.test.ts | 79 ++++ .../finance/finance-ledger-service.ts | 37 +- .../finance/sales-facts-channel-service.ts | 52 +-- deploy/post-deploy.sh | 11 +- docs/统一订单履约与财务模型-权威版.md | 52 ++- scripts/verify-finance-launch.sql | 34 +- 14 files changed, 1011 insertions(+), 541 deletions(-) create mode 100644 apps/backend/scripts/audit-finance-derived.ts delete mode 100644 apps/backend/scripts/audit-fulfillment-finance-alignment.ts delete mode 100644 apps/backend/scripts/finance-cutover-v2.ts create mode 100644 apps/backend/scripts/rebuild-finance-derived.ts create mode 100644 apps/backend/src/db/migrations/167_finance_derived_scope.sql create mode 100644 apps/backend/src/services/finance/finance-derived-rebuild.test.ts diff --git a/apps/backend/package.json b/apps/backend/package.json index faf6a594..bcedc9f2 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -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", diff --git a/apps/backend/scripts/audit-finance-derived.ts b/apps/backend/scripts/audit-finance-derived.ts new file mode 100644 index 00000000..5b5cbda5 --- /dev/null +++ b/apps/backend/scripts/audit-finance-derived.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 { + 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()) diff --git a/apps/backend/scripts/audit-fulfillment-finance-alignment.ts b/apps/backend/scripts/audit-fulfillment-finance-alignment.ts deleted file mode 100644 index 2a2a3db2..00000000 --- a/apps/backend/scripts/audit-fulfillment-finance-alignment.ts +++ /dev/null @@ -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()) diff --git a/apps/backend/scripts/finance-cutover-v2.ts b/apps/backend/scripts/finance-cutover-v2.ts deleted file mode 100644 index 5fdaf7dc..00000000 --- a/apps/backend/scripts/finance-cutover-v2.ts +++ /dev/null @@ -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 = {} - 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 = {} - 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 -} diff --git a/apps/backend/scripts/rebuild-after-cutover.ts b/apps/backend/scripts/rebuild-after-cutover.ts index f6f35bbf..bf55fff6 100644 --- a/apps/backend/scripts/rebuild-after-cutover.ts +++ b/apps/backend/scripts/rebuild-after-cutover.ts @@ -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 `, ) diff --git a/apps/backend/scripts/rebuild-finance-derived.ts b/apps/backend/scripts/rebuild-finance-derived.ts new file mode 100644 index 00000000..40315c8f --- /dev/null +++ b/apps/backend/scripts/rebuild-finance-derived.ts @@ -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() } + 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 { + 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 { + 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 { + 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 { + 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 { + 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>, + after: Awaited>, +) { + 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(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 = {} + 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()) diff --git a/apps/backend/src/db/migrations/167_finance_derived_scope.sql b/apps/backend/src/db/migrations/167_finance_derived_scope.sql new file mode 100644 index 00000000..e75b171c --- /dev/null +++ b/apps/backend/src/db/migrations/167_finance_derived_scope.sql @@ -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'; diff --git a/apps/backend/src/services/finance/finance-controls.test.ts b/apps/backend/src/services/finance/finance-controls.test.ts index 5a0a2156..31f75820 100644 --- a/apps/backend/src/services/finance/finance-controls.test.ts +++ b/apps/backend/src/services/finance/finance-controls.test.ts @@ -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)'), diff --git a/apps/backend/src/services/finance/finance-derived-rebuild.test.ts b/apps/backend/src/services/finance/finance-derived-rebuild.test.ts new file mode 100644 index 00000000..72978757 --- /dev/null +++ b/apps/backend/src/services/finance/finance-derived-rebuild.test.ts @@ -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')) +}) diff --git a/apps/backend/src/services/finance/finance-ledger-service.ts b/apps/backend/src/services/finance/finance-ledger-service.ts index cde79b49..6c9575bd 100644 --- a/apps/backend/src/services/finance/finance-ledger-service.ts +++ b/apps/backend/src/services/finance/finance-ledger-service.ts @@ -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], ) diff --git a/apps/backend/src/services/finance/sales-facts-channel-service.ts b/apps/backend/src/services/finance/sales-facts-channel-service.ts index 2e445078..ec493d6b 100644 --- a/apps/backend/src/services/finance/sales-facts-channel-service.ts +++ b/apps/backend/src/services/finance/sales-facts-channel-service.ts @@ -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, diff --git a/deploy/post-deploy.sh b/deploy/post-deploy.sh index a438a14d..b9441f25 100755 --- a/deploy/post-deploy.sh +++ b/deploy/post-deploy.sh @@ -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 "验收全部通过" } diff --git a/docs/统一订单履约与财务模型-权威版.md b/docs/统一订单履约与财务模型-权威版.md index 0318780e..9ec22b7e 100644 --- a/docs/统一订单履约与财务模型-权威版.md +++ b/docs/统一订单履约与财务模型-权威版.md @@ -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:停止旧写路径并删除兼容层 -状态:履约域已在本地停止旧写路径;商品、成本、人工配置的全局兼容清理仍需后续完成。 +状态:履约与财务派生写入路径已在本地收敛;商品、成本、人工配置的全局兼容清理与生产观察期仍需后续完成。 只有前五个阶段持续稳定后才允许: diff --git a/scripts/verify-finance-launch.sql b/scripts/verify-finance-launch.sql index 310593ee..1df0af8c 100644 --- a/scripts/verify-finance-launch.sql +++ b/scripts/verify-finance-launch.sql @@ -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;