diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..38aa0f8e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,98 @@ +# order_site 项目规则 + +## 项目目标 + +维护一个多渠道订单履约平台,覆盖平台入单、内部 SKU、履约路由、接单工单、自动发货、人工客服、 +第三方卡券、寄卖、成本、销售事实与财务总账。 + +## 每次任务的读取顺序 + +1. 本文件。 +2. [docs/README.md](docs/README.md)。 +3. 涉及订单、内部 SKU、履约、商品、成本或财务时,必须完整阅读 + [docs/统一订单履约与财务模型-权威版.md](docs/统一订单履约与财务模型-权威版.md)。 +4. 涉及部署、数据库或备份时,再读取对应的运维文档。 +5. 涉及生产升级、数据库迁移、领域模型切换或财务重建时,必须完整阅读 + [docs/生产升级操作手册.md](docs/生产升级操作手册.md)。 +6. `docs/archive/` 只用于追溯,不作为实现依据。 + +## 唯一权威 + +订单、内部 SKU、渠道路由、履约载体、标准商品、成本和财务口径只有一个权威来源: + +[docs/统一订单履约与财务模型-权威版.md](docs/统一订单履约与财务模型-权威版.md) + +禁止: + +- 在代码注释、接口、页面或新文档中定义另一套业务口径。 +- 将商品名称、`canonical_sku` 前缀、商品分类或支付状态用于反推履约渠道。 +- 让多个服务同时写履约核心状态。 +- 用自由 JSON 或字符串状态替代正式领域字段。 + +代码实现与权威文档冲突时,先停止修改并核对生产数据,不得自行选择其中一方继续开发。 + +## 数据安全边界 + +永久保留,不得删除或重写历史: + +- `orders` +- `order_items` 中的平台原始字段 +- 平台原始订单与原始报文 +- 平台售后、退款和支付事实 +- `work_orders`、`work_order_shares`、`work_order_events` +- 真实 `fulfillment_tasks` 执行记录 +- `worker_wallet_ledgers`、`fund_accounts` 等真实资金流水 +- `admin_audit_logs` + +允许在备份和验收方案完备后删除并重建: + +- `cost_snapshots` +- `cost_exceptions` +- `sales_daily_facts` +- `finance_journal_*` +- `fulfillments` / `fulfillment_attempts` / `fulfillment_issues` +- 页面投影、统计缓存、水印和任务重试状态 + +任何数据清理必须先区分“原始事实”“业务输入配置”“可重建派生数据”。 + +## 开发规则 + +1. 先读权威文档,再改代码。 +2. 结构性变更必须新增迁移,不修改已发布迁移。 +3. 核心状态只能通过对应领域服务修改。 +4. 新功能必须有真实 PostgreSQL 集成测试或明确说明无法覆盖的原因。 +5. 页面与统计如果显示同一个业务量,必须读取同一投影或同一查询口径。 +6. 不把临时修数据脚本当成长期业务流程。 +7. 历史清理必须可 dry-run、可审计、可回滚,并且全量扫描存量。 +8. 结构性切换采用“备份 → 回填 → 一致性审计 → 原子切换 → 观察”。 + 不再新增双读、双写或旧表运行期兼容;无法确定的数据进入待分类,不做猜测。 + +## 文档规则 + +1. 业务与架构口径只更新唯一权威文档。 +2. `docs/README.md` 只做导航,不定义业务规则。 +3. 被替代的内部设计文档移动到 `docs/archive/<日期>/`。 +4. 外部平台协议可以保留,但不得覆盖项目自身领域模型。 +5. 文档中的生产迁移号、代码基线、数据结论必须注明日期和来源。 +6. 不保留第二份“现行方案”“最终口径”或“统一总纲”。 + +## 验证要求 + +涉及代码时至少执行: + +```bash +apps/backend: npm run check +apps/frontend: npm run check +``` + +涉及生产数据判断时: + +- 默认只读。 +- 显式设置只读事务。 +- 不执行 `UPDATE`、`DELETE`、`TRUNCATE`、DDL 或调度任务。 + +## Git + +- 所有 Git commit 提交信息使用中文。 +- 不回退或覆盖用户的未提交修改。 +- 提交时只暂存本次任务相关文件。 diff --git a/README.md b/README.md index d9985cb8..c5bd35a6 100644 --- a/README.md +++ b/README.md @@ -26,43 +26,18 @@ deploy/ caddy/ Caddy 反向代理 docker/ 前后端 Dockerfile mac-dev.sh / ubuntu-deploy.sh -docs/ 文档索引、架构分析、财务方案、平台接口、部署说明 +docs/ 唯一权威模型、平台接口、运维文档、历史归档 docker-compose.dev.yml 本地 Docker 开发环境 docker-compose.yml 生产 / 服务器 Compose ``` -## 来源模型(现行) +## 业务模型 -系统只有三个订单来源: +订单、内部 SKU、履约路由、履约执行、商品、成本与财务的唯一权威口径: -| sourceKey | 数据通道 | 说明 | -| --- | --- | --- | -| `kuaishou-91` | 91 回调 + 快手 send-code | **同一个来源的两条通道**:91 是订单骨架权威源,快手 send-code 是数据补全源与兜底源;共享店铺键空间与同一套匹配上下文 | -| `pdd` | 拼多多订单同步 | 两段式 `pdd_raw_orders → orders` | -| `douyin-mashida` | 抖音码速达推送 | 抖店订单、DYC 券、H5/兑换页 | +[docs/统一订单履约与财务模型-权威版.md](docs/统一订单履约与财务模型-权威版.md) -不要把 91 与快手 send-code 拆成两个来源或两个适配器。代码中的唯一来源键是 `kuaishou-91`(`apps/backend/src/services/product-match/types.ts`)。 - -## 业务边界(简图) - -```text -kuaishou-91(91 回调 + 快手 send-code) - → 本系统建单 / 匹配商品 / 创建履约任务 - → 返回统一 claimUrl -用户打开领取页 - → 可选:快手行业电子凭证自动核销 - → affiliate-dash / 人工 完成发货 -后台 - → 订单任务、平台配置、审计、人工复核 -接单平台(打手众包) - → 工单发布到抢单大厅,打手抢单代练 - → 提交验收 / 问题单处置 / 任务时限超时自动判定 -抖音码速达 - → 抖店订单推送 → 建单 → DYC 券 → 领取/兑换页 → 核销 -``` - -更完整的链路说明见:`docs/履约配置/多发货平台与电子凭证整体链路.md`。 -文档总索引见:`docs/README.md`。 +文档总索引见:[docs/README.md](docs/README.md)。 ## 本地开发 diff --git a/apps/backend/package.json b/apps/backend/package.json index c3232050..6220a827 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -9,10 +9,10 @@ "db:migrate": "tsx src/db/migrate.ts", "db:migrate:create": "tsx scripts/create-migration.ts", "db:migrate:status": "tsx scripts/migration-status.ts", - "finance:sync": "tsx scripts/sync-cost-finance.ts", - "fulfillment:normalize-sku-configs": "tsx scripts/normalize-internal-sku-configs.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", + "voucher:backfill-order-received": "tsx scripts/backfill-voucher-consume-order-received.ts", "cost-exceptions:archive": "tsx scripts/archive-cost-exceptions.ts", "storage:migrate": "tsx scripts/migrate-storage.ts", "storage:migrate:built": "node dist/storage-migrate.js", @@ -33,12 +33,11 @@ "test:industry:gen": "tsx scripts/gen-test-cases.ts", "test:industry:curl": "tsx scripts/curl-send-callback.ts", "audit:fulfillment": "tsx scripts/audit-fulfillment-state.ts", + "audit:product-routing": "tsx scripts/audit-product-routing-alignment.ts", "typecheck": "tsc -p tsconfig.json --noEmit", "check": "npm run format:check && npm run check:sql && npm run lint:check && npm run typecheck && npm test && npm run build", "start": "node dist/index.js", "start:src": "tsx src/index.ts", - "products:merge-duplicates": "tsx scripts/merge-duplicate-products.ts", - "products:reclassify": "tsx scripts/reclassify-post-cutover.ts", "demo:seed-finance": "tsx scripts/seed-local-finance-demo.ts" }, "dependencies": { 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 1cdd3811..00000000 --- a/apps/backend/scripts/audit-fulfillment-finance-alignment.ts +++ /dev/null @@ -1,243 +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 rules = await query<{ - match_key_type: string - channel: string - scope: string - total: number - }>( - ` - SELECT match_key_type, channel, - CASE WHEN provider = '' AND shop_id = '' THEN 'global' ELSE 'scoped' END AS scope, - count(*)::int AS total - FROM sku_channel_rules - GROUP BY 1, 2, 3 - ORDER BY 1, 2, 3 - `, - ) - - 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 capabilities = await query<{ channel: string; total: number }>( - `SELECT channel, count(*)::int AS total FROM product_channel_capabilities WHERE enabled GROUP BY 1 ORDER BY 2 DESC`, - ) - - const internalChannelConflicts = await query<{ internal_sku: string; total: number }>( - `SELECT internal_sku, count(*)::int AS total - FROM internal_sku_channels - WHERE enabled = TRUE AND is_fallback = FALSE - GROUP BY internal_sku - HAVING count(*) > 1 - ORDER BY count(*) DESC, internal_sku`, - ) - - const internalChannels = await query<{ - channel: string - source: string - total: number - }>( - `SELECT channel, source, count(*)::int AS total - FROM internal_sku_channels - WHERE enabled = TRUE - GROUP BY channel, source - ORDER BY channel, source`, - ) - - const canonicalChannelDuplicates = await query<{ - internal_sku: string - channel: string - total: number - }>( - `SELECT canonicalize_internal_sku(internal_sku) AS internal_sku, - channel, count(*)::int AS total - FROM internal_sku_channels - WHERE enabled = TRUE - GROUP BY canonicalize_internal_sku(internal_sku), channel - HAVING count(*) > 1 - ORDER BY count(*) DESC, internal_sku, channel`, - ) - - 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], - rules: rules.rows, - duplicateProducts: duplicateProducts.rows[0], - capabilities: capabilities.rows, - internalChannels: internalChannels.rows, - internalChannelConflicts: internalChannelConflicts.rows, - canonicalChannelDuplicates: canonicalChannelDuplicates.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/audit-fulfillment-state.ts b/apps/backend/scripts/audit-fulfillment-state.ts index f624404c..daa76672 100644 --- a/apps/backend/scripts/audit-fulfillment-state.ts +++ b/apps/backend/scripts/audit-fulfillment-state.ts @@ -93,7 +93,7 @@ async function loadRecentDuplicateAttempts(): Promise { return result.rows } -async function loadShadowModelDiffs(): Promise { +async function loadTruthIntegrityChecks(): Promise { const result = await query<{ missing_fulfillments: number fulfillment_state_diffs: number @@ -116,7 +116,7 @@ async function loadShadowModelDiffs(): Promise { fulfillment.issue_category ) IS DISTINCT FROM ROW( CASE WHEN state.route_status = 'configured' THEN 'configured' ELSE 'unrouted' END, - state.intended_channel, + state.stored_channel, state.carrier_status, state.current_attempt_type, NULLIF(state.current_attempt_id, 0), @@ -162,8 +162,6 @@ async function loadShadowModelDiffs(): Promise { ( SELECT COUNT(*)::int FROM fulfillment_state_events event - JOIN v_fulfillment_item_state active_state - ON active_state.order_item_id = event.order_item_id WHERE event.fulfillment_id IS NULL ) AS unlinked_events FROM v_fulfillment_item_state state @@ -173,20 +171,20 @@ async function loadShadowModelDiffs(): Promise { ) const row = result.rows[0] return [ - { label: '影子履约缺失', count: Number(row?.missing_fulfillments || 0) }, - { label: '影子履约状态差异', count: Number(row?.fulfillment_state_diffs || 0) }, - { label: '影子执行尝试缺失', count: Number(row?.missing_attempts || 0) }, - { label: '影子开放异常缺失', count: Number(row?.missing_open_issues || 0) }, - { label: '履约事件未关联影子记录', count: Number(row?.unlinked_events || 0) }, + { label: '正式履约缺失', count: Number(row?.missing_fulfillments || 0) }, + { label: '正式履约状态差异', count: Number(row?.fulfillment_state_diffs || 0) }, + { label: '执行尝试缺失', count: Number(row?.missing_attempts || 0) }, + { label: '开放异常缺失', count: Number(row?.missing_open_issues || 0) }, + { label: '履约事件未关联正式记录', count: Number(row?.unlinked_events || 0) }, ] } async function main(): Promise { - const [counts, issues, duplicates, shadowDiffs] = await Promise.all([ + const [counts, issues, duplicates, integrityChecks] = await Promise.all([ loadCounts(), loadIssueBreakdown(), loadRecentDuplicateAttempts(), - loadShadowModelDiffs(), + loadTruthIntegrityChecks(), ]) console.info('[fulfillment-audit] 总览') @@ -208,20 +206,23 @@ async function main(): Promise { console.info(` ${row.label}: ${row.count}`) } - console.info('[fulfillment-audit] 影子模型差异') - for (const row of shadowDiffs) { + console.info('[fulfillment-audit] 正式履约完整性') + for (const row of integrityChecks) { console.info(` ${row.label}: ${row.count}`) } const critical = new Map(counts.map((row) => [row.label, Number(row.count || 0)])) const duplicateCount = Number(duplicates[0]?.count || 0) - const shadowDiffCount = shadowDiffs.reduce((sum, row) => sum + Number(row.count || 0), 0) + const integrityDifferenceCount = integrityChecks.reduce( + (sum, row) => sum + Number(row.count || 0), + 0, + ) const hasCritical = (critical.get('状态不一致') || 0) > 0 || (critical.get('售后关闭仍进入履约队列') || 0) > 0 || (critical.get('已分配但无有效载体') || 0) > 0 || duplicateCount > 0 || - shadowDiffCount > 0 + integrityDifferenceCount > 0 if (strict && hasCritical) { process.exitCode = 1 diff --git a/apps/backend/scripts/audit-product-routing-alignment.ts b/apps/backend/scripts/audit-product-routing-alignment.ts new file mode 100644 index 00000000..4b161e79 --- /dev/null +++ b/apps/backend/scripts/audit-product-routing-alignment.ts @@ -0,0 +1,207 @@ +#!/usr/bin/env tsx +/** + * 商品来源、内部 SKU 与履约路由只读审计。 + * + * 用法: + * npm run audit:product-routing + * npm run audit:product-routing -- --strict + */ +import { closeDb, query } from '../src/db/client.js' + +const strict = process.argv.includes('--strict') + +async function main() { + const [ + sourceChannel, + routeCounts, + manualConfigRoutes, + orphanManualProducts, + multiplePrimaryRoutes, + missingInternalSku, + unroutedProducts, + invalidSourceKinds, + ] = await Promise.all([ + query<{ source_kind: string; primary_channel: string | null; total: number }>( + ` + SELECT source_kind, active_primary_channel AS primary_channel, COUNT(*)::int AS total + FROM v_product_current + WHERE status = 'active' + GROUP BY 1, 2 + ORDER BY 1, 2 NULLS FIRST + `, + ), + query<{ channel: string; route_role: string; total: number }>( + ` + SELECT channel, route_role, COUNT(*)::int AS total + FROM sku_channel_routes + WHERE enabled = TRUE + GROUP BY 1, 2 + ORDER BY 1, 2 + `, + ), + query<{ + route_state: string + total: number + }>( + ` + SELECT + CASE + WHEN primary_route.channel = 'manual' THEN 'primary_manual' + WHEN primary_route.channel IS NOT NULL + AND manual_route.route_role = 'fallback' THEN 'manual_fallback' + WHEN manual_route.route_role = 'fallback' THEN 'fallback_without_primary' + ELSE 'missing_manual_route' + END AS route_state, + COUNT(*)::int AS total + FROM manual_fulfillment_configs config + LEFT JOIN LATERAL ( + SELECT route.channel + FROM sku_channel_routes route + WHERE route.internal_sku_id = config.internal_sku_id + AND route.enabled = TRUE + AND route.route_role = 'primary' + ORDER BY route.priority, route.channel + LIMIT 1 + ) primary_route ON TRUE + LEFT JOIN sku_channel_routes manual_route + ON manual_route.internal_sku_id = config.internal_sku_id + AND manual_route.channel = 'manual' + AND manual_route.enabled = TRUE + WHERE config.enabled = TRUE + GROUP BY 1 + ORDER BY 1 + `, + ), + query<{ + product_id: number + canonical_sku: string + canonical_name: string + }>( + ` + SELECT product.id AS product_id, + product.canonical_sku, + product.canonical_name + FROM product_catalog product + WHERE product.status = 'active' + AND product.source_kind = 'manual' + AND NOT EXISTS ( + SELECT 1 + FROM sku_channel_routes route + WHERE route.internal_sku_id = product.internal_sku_id + AND route.enabled = TRUE + AND route.route_role = 'primary' + ) + AND NOT EXISTS ( + SELECT 1 + FROM manual_fulfillment_configs config + WHERE config.enabled = TRUE + AND ( + config.canonical_product_id = product.id + OR config.internal_sku_id = product.internal_sku_id + OR canonicalize_internal_sku(config.sku_key) + = canonicalize_internal_sku(substring(product.canonical_sku from 5)) + ) + ) + ORDER BY product.id + `, + ), + 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 1 + HAVING COUNT(*) > 1 + ORDER BY 2 DESC, 1 + `, + ), + query<{ source_kind: string; total: number }>( + ` + SELECT source_kind, COUNT(*)::int AS total + FROM product_catalog + WHERE status = 'active' + AND source_kind IN ('manual', 'third_party_voucher') + AND internal_sku_id IS NULL + GROUP BY 1 + ORDER BY 1 + `, + ), + query<{ + product_id: number + canonical_sku: string + canonical_name: string + source_kind: string + }>( + ` + SELECT id AS product_id, canonical_sku, canonical_name, source_kind + FROM v_product_current + WHERE status = 'active' AND active_primary_channel IS NULL + ORDER BY source_kind, id + `, + ), + query<{ source_kind: string; total: number }>( + ` + SELECT source_kind, COUNT(*)::int AS total + FROM product_catalog + WHERE status = 'active' + AND source_kind NOT IN ( + 'affiliate_dash', + 'work_order', + 'third_party_voucher', + 'manual', + 'unclassified' + ) + GROUP BY 1 + ORDER BY 1 + `, + ), + ]) + + const critical = { + orphanManualProducts: orphanManualProducts.rows.length, + multiplePrimaryRoutes: multiplePrimaryRoutes.rows.length, + missingInternalSku: missingInternalSku.rows.reduce((sum, row) => sum + Number(row.total), 0), + invalidSourceKinds: invalidSourceKinds.rows.reduce((sum, row) => sum + Number(row.total), 0), + invalidManualConfigRoutes: manualConfigRoutes.rows + .filter( + (row) => row.route_state !== 'primary_manual' && row.route_state !== 'manual_fallback', + ) + .reduce((sum, row) => sum + Number(row.total), 0), + } + + console.info('[product-routing-audit] 商品来源 × 当前渠道') + console.table(sourceChannel.rows) + + console.info('[product-routing-audit] 路由分布') + console.table(routeCounts.rows) + + console.info('[product-routing-audit] 人工配置路由状态') + console.table(manualConfigRoutes.rows) + + console.info('[product-routing-audit] 无主路由且无人工配置的 man 商品') + console.table(orphanManualProducts.rows) + + console.info('[product-routing-audit] 多主路由内部 SKU') + console.table(multiplePrimaryRoutes.rows) + + console.info('[product-routing-audit] 缺少内部 SKU 的商品') + console.table(missingInternalSku.rows) + + console.info('[product-routing-audit] 非法商品来源') + console.table(invalidSourceKinds.rows) + + console.info('[product-routing-audit] 未路由活动商品(待人工分类)') + console.table(unroutedProducts.rows) + + console.info('[product-routing-audit] 关键异常', critical) + if (strict && Object.values(critical).some((count) => count > 0)) { + process.exitCode = 1 + } +} + +main() + .catch((error) => { + console.error(error) + process.exitCode = 1 + }) + .finally(() => closeDb()) diff --git a/apps/backend/scripts/backfill-voucher-consume-order-received.ts b/apps/backend/scripts/backfill-voucher-consume-order-received.ts new file mode 100644 index 00000000..eb223cb5 --- /dev/null +++ b/apps/backend/scripts/backfill-voucher-consume-order-received.ts @@ -0,0 +1,261 @@ +#!/usr/bin/env tsx +/** + * 为部署前已支付、已有可核销券但未进入订单级核销队列的订单补投任务。 + * + * 只处理明确可核销的券: + * - 快手电子凭证:UNUSED 且发码回调成功; + * - 抖音码速达:issued/synced 且同步成功。 + * + * 默认 dry-run。执行必须显式确认。 + */ +import process from 'node:process' + +import { closeDb, query } from '../src/db/client.js' +import { loadAppConfigCacheFromDatabase } from '../src/services/config/app-config-store.js' +import { getVoucherConsumePolicy } from '../src/services/fulfillment/voucher-consume-policy-service.js' +import { scheduleVoucherConsumeForOrderReceived } from '../src/services/fulfillment/voucher-consume-queue-service.js' + +const CONFIRMATION = 'ENQUEUE_ORDER_RECEIVED' +const options = parseOptions(process.argv.slice(2)) +const execute = options.execute === 'true' +const confirmation = String(options.confirm || '').trim() +const days = normalizeInteger(options.days, 7, 1, 90) +const batchSize = normalizeInteger(options.batchSize, 500, 1, 5000) +const maxBatches = normalizeInteger(options.maxBatches, 1000, 1, 10000) +const dateTo = normalizeDate(options.to) || today() +const dateFrom = normalizeDate(options.from) || shiftDate(dateTo, -(days - 1)) + +const ELIGIBLE_ORDER_SQL = ` + o.created_at >= $1::date + AND o.created_at < ($2::date + INTERVAL '1 day') + AND o.pay_status = 'paid' + AND o.order_status NOT IN ('cancelled', 'closed', 'refunded') + AND ( + ( + $3::boolean + AND (o.provider IN ('91kaquan', 'open_91', '91') OR o.platform = 'kuaishou') + AND EXISTS ( + SELECT 1 + FROM kuaishou_industry_vouchers voucher + WHERE voucher.oid = o.platform_order_id + AND voucher.status = 'UNUSED' + AND voucher.send_callback_status = 'success' + ) + ) + OR + ( + $4::boolean + AND o.provider = 'douyin_mashida' + AND EXISTS ( + SELECT 1 + FROM redeem_certificates certificate + WHERE certificate.provider = 'douyin_mashida' + AND ( + certificate.local_order_id = o.id + OR certificate.order_id = o.platform_order_id + ) + AND certificate.status IN ('issued', 'synced') + AND certificate.sync_status = 'success' + ) + ) + ) + AND NOT EXISTS ( + SELECT 1 + FROM voucher_consume_jobs job + WHERE job.order_id = o.id + AND job.status IN ('pending', 'processing', 'succeeded') + ) +` + +async function main() { + if (dateFrom > dateTo) { + throw new Error(`--from 不能晚于 --to:${dateFrom} > ${dateTo}`) + } + + await loadAppConfigCacheFromDatabase() + const policy = getVoucherConsumePolicy() + const enabledProviders = (['kuaishou_industry', 'douyin_mashida'] as const).filter( + (provider) => policy.platforms[provider].channels.order_received.enabled, + ) + if (enabledProviders.length === 0) { + console.log( + JSON.stringify( + { + mode: execute ? 'execute' : 'dry-run', + dateFrom, + dateTo, + policyDisabled: true, + enabledProviders, + pendingOrders: 0, + }, + null, + 2, + ), + ) + return + } + + const includeKuaishou = enabledProviders.includes('kuaishou_industry') + const includeDouyin = enabledProviders.includes('douyin_mashida') + const pending = await countEligibleOrders(includeKuaishou, includeDouyin) + const startedAt = Date.now() + + if (!execute) { + const samples = await previewEligibleOrders(includeKuaishou, includeDouyin) + console.log( + JSON.stringify( + { + mode: 'dry-run', + dateFrom, + dateTo, + pendingOrders: pending, + batchSize, + estimatedBatches: Math.ceil(pending / batchSize), + samples, + durationMs: Date.now() - startedAt, + hint: `确认后执行:npm run voucher:backfill-order-received -- --from=${dateFrom} --to=${dateTo} --execute --confirm=${CONFIRMATION}`, + }, + null, + 2, + ), + ) + return + } + + if (confirmation !== CONFIRMATION) { + throw new Error(`--confirm 必须是 ${CONFIRMATION}`) + } + + let cursor = 0 + let scanned = 0 + let queued = 0 + let batches = 0 + + while (batches < maxBatches) { + const orderIds = await loadEligibleOrderIds(cursor, batchSize, includeKuaishou, includeDouyin) + if (orderIds.length === 0) break + + const scheduled = await scheduleVoucherConsumeForOrderReceived(orderIds) + scanned += orderIds.length + queued += scheduled.queuedCount + cursor = orderIds[orderIds.length - 1]! + batches += 1 + + if (orderIds.length < batchSize) break + } + + const remaining = await countEligibleOrders(includeKuaishou, includeDouyin) + console.log( + JSON.stringify( + { + mode: 'execute', + dateFrom, + dateTo, + pendingBefore: pending, + batches, + scannedOrders: scanned, + queuedJobs: queued, + remainingOrders: remaining, + durationMs: Date.now() - startedAt, + }, + null, + 2, + ), + ) + + if (remaining > 0) { + console.error(`仍有 ${remaining} 个订单未入队;请增大 --max-batches 后重跑(幂等)。`) + process.exitCode = 2 + } +} + +async function countEligibleOrders(includeKuaishou: boolean, includeDouyin: boolean) { + const result = await query<{ count: number }>( + `SELECT COUNT(*)::int AS count + FROM orders o + WHERE ${ELIGIBLE_ORDER_SQL}`, + [dateFrom, dateTo, includeKuaishou, includeDouyin], + ) + return Number(result.rows[0]?.count || 0) +} + +async function loadEligibleOrderIds( + afterId: number, + limit: number, + includeKuaishou: boolean, + includeDouyin: boolean, +) { + const result = await query<{ id: number }>( + `SELECT o.id + FROM orders o + WHERE o.id > $5 + AND ${ELIGIBLE_ORDER_SQL} + ORDER BY o.id + LIMIT $6`, + [dateFrom, dateTo, includeKuaishou, includeDouyin, afterId, limit], + ) + return result.rows.map((row) => Number(row.id)) +} + +async function previewEligibleOrders(includeKuaishou: boolean, includeDouyin: boolean) { + const result = await query<{ + id: number + provider: string + platform_order_id: string + created_at: string + }>( + `SELECT o.id, o.provider, o.platform_order_id, o.created_at::text + FROM orders o + WHERE ${ELIGIBLE_ORDER_SQL} + ORDER BY o.id + LIMIT 10`, + [dateFrom, dateTo, includeKuaishou, includeDouyin], + ) + return result.rows +} + +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) + const value = index === -1 ? 'true' : body.slice(index + 1) + result[key] = value + result[toCamelCase(key)] = value + } + return result +} + +function toCamelCase(value: string) { + return value.replace(/-([a-z])/g, (_, char: string) => char.toUpperCase()) +} + +function normalizeInteger(value: unknown, fallback: number, min: number, max: number) { + const parsed = Number(value) + if (!Number.isInteger(parsed)) return fallback + return Math.min(max, Math.max(min, parsed)) +} + +function normalizeDate(value: unknown): string { + const text = String(value || '').trim() + return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : '' +} + +function today() { + return new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' }) +} + +function shiftDate(value: string, days: number) { + const date = new Date(`${value}T12:00:00+08:00`) + date.setUTCDate(date.getUTCDate() + days) + return date.toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' }) +} + +main() + .catch((error) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + }) + .finally(() => closeDb()) diff --git a/apps/backend/scripts/cleanup-legacy-sku-code-rules.ts b/apps/backend/scripts/cleanup-legacy-sku-code-rules.ts deleted file mode 100644 index 19e84961..00000000 --- a/apps/backend/scripts/cleanup-legacy-sku-code-rules.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * 把旧的 sku_code 渠道规则转换为 internal_sku 全局规则,并停用旧规则。 - * - * 用法: - * npx tsx scripts/cleanup-legacy-sku-code-rules.ts - * npx tsx scripts/cleanup-legacy-sku-code-rules.ts --apply - */ -import { closeDb, query } from '../src/db/client.js' - -type LegacyRule = { - id: number - provider: string - shop_id: string - match_key: string - channel: string - priority: number - source: string -} - -async function resolveInternalSku(rule: LegacyRule): Promise { - if (rule.channel === 'affiliate_dash') { - const product = await query<{ canonical_name: string }>( - `SELECT canonical_name FROM product_catalog - WHERE canonical_sku = $1 AND status = 'active' AND btrim(canonical_name) <> '' - LIMIT 1`, - [`aff:${rule.match_key}`], - ) - if (product.rows[0]?.canonical_name) return String(product.rows[0].canonical_name).trim() - } - - const params: unknown[] = [rule.match_key] - let scope = '' - if (rule.provider) { - params.push(rule.provider) - scope += ` AND o.provider = $${params.length}` - } - if (rule.shop_id) { - params.push(rule.shop_id) - scope += ` AND o.shop_id = $${params.length}` - } - const items = await query<{ internal_sku: string }>( - ` - SELECT DISTINCT btrim(oi.manual_sku_key) AS internal_sku - FROM order_items oi - JOIN orders o ON o.id = oi.order_id - WHERE oi.sku_code = $1 - AND btrim(COALESCE(oi.manual_sku_key, '')) ~ '[㐀-䶿一-鿿豈-﫿𠀀-𯨟𰀀-𲎯]' - ${scope} - LIMIT 2 - `, - params, - ) - const values = items.rows.map((row) => String(row.internal_sku || '').trim()).filter(Boolean) - return values.length === 1 ? values[0]! : '' -} - -async function main() { - const apply = process.argv.includes('--apply') - const rules = await query( - ` - SELECT id, provider, shop_id, match_key, channel, priority, source - FROM sku_channel_rules - WHERE match_key_type = 'sku_code' AND enabled = TRUE - ORDER BY id - `, - ) - - const converted: Array<{ id: number; matchKey: string; internalSku: string; channel: string }> = - [] - const unresolved: LegacyRule[] = [] - for (const rule of rules.rows) { - const internalSku = await resolveInternalSku(rule) - if (!internalSku) { - unresolved.push(rule) - continue - } - converted.push({ id: rule.id, matchKey: rule.match_key, internalSku, channel: rule.channel }) - if (!apply) continue - - await query( - `UPDATE internal_sku_channels - SET is_fallback = TRUE, - priority = GREATEST(priority, 90), - updated_at = NOW() - WHERE btrim(internal_sku) = $1 - AND channel <> $2 - AND enabled = TRUE - AND is_fallback = FALSE`, - [internalSku, rule.channel], - ) - await query( - ` - INSERT INTO internal_sku_channels ( - internal_sku, channel, priority, is_fallback, enabled, source - ) VALUES ($1, $2, $3, FALSE, TRUE, 'legacy-rule') - ON CONFLICT (internal_sku, channel) DO UPDATE SET - priority = LEAST(internal_sku_channels.priority, EXCLUDED.priority), - is_fallback = FALSE, - enabled = TRUE, - updated_at = NOW() - `, - [internalSku, rule.channel, rule.priority], - ) - await query( - `UPDATE sku_channel_rules - SET enabled = FALSE, - note = CONCAT(COALESCE(note, ''), ' [已迁移 internal_sku:', $2::text, ']'), - updated_at = NOW() - WHERE id = $1`, - [rule.id, internalSku], - ) - } - - console.log( - JSON.stringify( - { - apply, - legacyRules: rules.rows.length, - convertible: converted.length, - converted, - unresolved, - }, - 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 19a1da5c..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_rules(配置) - * - * 前置: - * 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/merge-duplicate-products.ts b/apps/backend/scripts/merge-duplicate-products.ts deleted file mode 100644 index 92abfbe1..00000000 --- a/apps/backend/scripts/merge-duplicate-products.ts +++ /dev/null @@ -1,251 +0,0 @@ -/** - * 合并同一内部 SKU 的重复商品(aff: / man:)。 - * - * 默认 dry-run;--apply 才写库。 - * 保留 aff: 商品作为主商品,把 man: 商品的引用全部迁过去, - * 再把人工/自动两种渠道能力挂到主商品上,并生成中文内部 SKU 的自动发货规则。 - * - * 用法: - * npm run products:merge-duplicates -- --dry-run - * npm run products:merge-duplicates -- --apply - */ -import process from 'node:process' - -import { closeDb, query, withTransaction } from '../src/db/client.js' -import { refreshFactsForDates } from '../src/services/finance/refresh-facts.js' - -type GroupRow = { - canonical_name: string - keeper_id: number - keeper_sku: string - duplicate_ids: number[] - duplicate_skus: string[] -} - -async function loadGroups(): Promise { - const result = await query<{ - canonical_name: string - keeper_id: number - keeper_sku: string - duplicate_ids: number[] - duplicate_skus: string[] - }>( - ` - SELECT - canonical_name, - (array_agg(id ORDER BY (canonical_sku LIKE 'aff:%') DESC, id))[1] AS keeper_id, - (array_agg(canonical_sku ORDER BY (canonical_sku LIKE 'aff:%') DESC, id))[1] AS keeper_sku, - array_remove(array_agg(id ORDER BY id), (array_agg(id ORDER BY (canonical_sku LIKE 'aff:%') DESC, id))[1]) AS duplicate_ids, - array_remove(array_agg(canonical_sku ORDER BY id), (array_agg(canonical_sku ORDER BY (canonical_sku LIKE 'aff:%') DESC, id))[1]) AS duplicate_skus - FROM product_catalog - WHERE status = 'active' - GROUP BY canonical_name - HAVING count(*) > 1 - AND bool_or(canonical_sku LIKE 'aff:%') - AND bool_or(canonical_sku LIKE 'man:%') - ORDER BY canonical_name - `, - ) - return result.rows -} - -async function mergeGroup( - group: GroupRow, -): Promise<{ mappings: number; versions: number; facts: number }> { - let mappings = 0 - let versions = 0 - let facts = 0 - - for (const duplicateId of group.duplicate_ids) { - await withTransaction(async (client) => { - await client.query( - 'UPDATE order_items SET canonical_product_id = $2, updated_at = NOW() WHERE canonical_product_id = $1', - [duplicateId, group.keeper_id], - ) - await client.query( - 'UPDATE cost_snapshots SET canonical_product_id = $2 WHERE canonical_product_id = $1', - [duplicateId, group.keeper_id], - ) - await client.query( - 'UPDATE manual_fulfillment_configs SET canonical_product_id = $2, updated_at = NOW() WHERE canonical_product_id = $1', - [duplicateId, group.keeper_id], - ) - - // 映射:先删掉会与主商品冲突的重复映射,再迁移其余。 - const conflict = await client.query( - ` - DELETE FROM channel_product_mappings d - USING channel_product_mappings k - WHERE d.canonical_product_id = $1 AND k.canonical_product_id = $2 - AND d.provider = k.provider AND d.shop_id = k.shop_id - AND d.platform_sku_code = k.platform_sku_code AND d.match_type = k.match_type - AND d.platform_sku_name_key = k.platform_sku_name_key - `, - [duplicateId, group.keeper_id], - ) - mappings += conflict.rowCount || 0 - const movedMappings = await client.query( - 'UPDATE channel_product_mappings SET canonical_product_id = $2 WHERE canonical_product_id = $1', - [duplicateId, group.keeper_id], - ) - mappings += movedMappings.rowCount || 0 - - // 成本版本:有唯一键冲突就中止该组,避免静默丢价格。 - const versionConflict = await client.query( - ` - SELECT 1 FROM cost_versions d - JOIN cost_versions k - ON k.cost_item_id = d.cost_item_id - AND k.scope_type = d.scope_type - AND k.provider = d.provider - AND k.shop_id = d.shop_id - AND COALESCE(k.work_product_rule_id, 0) = COALESCE(d.work_product_rule_id, 0) - AND k.effective_from = d.effective_from - WHERE d.canonical_product_id = $1 AND k.canonical_product_id = $2 - LIMIT 1 - `, - [duplicateId, group.keeper_id], - ) - if (versionConflict.rowCount) { - throw new Error(`商品 ${duplicateId} 的成本版本与主商品冲突,已中止`) - } - const movedVersions = await client.query( - 'UPDATE cost_versions SET canonical_product_id = $2 WHERE canonical_product_id = $1', - [duplicateId, group.keeper_id], - ) - versions += movedVersions.rowCount || 0 - - // 事实:先删冲突行,其余迁移;迁移后统一重算受影响日期。 - const conflictFacts = await client.query( - ` - DELETE FROM sales_daily_facts d - USING sales_daily_facts k - WHERE d.canonical_product_id = $1 AND k.canonical_product_id = $2 - AND d.stat_date = k.stat_date AND d.provider = k.provider - AND d.shop_id = k.shop_id AND d.channel = k.channel - `, - [duplicateId, group.keeper_id], - ) - facts += conflictFacts.rowCount || 0 - const movedFacts = await client.query( - 'UPDATE sales_daily_facts SET canonical_product_id = $2 WHERE canonical_product_id = $1', - [duplicateId, group.keeper_id], - ) - facts += movedFacts.rowCount || 0 - }) - } - - await query( - ` - INSERT INTO product_channel_capabilities (product_id, channel, priority, is_fallback, enabled, source) - VALUES ($1, 'affiliate_dash', 10, FALSE, TRUE, 'merged'), ($1, 'manual', 20, TRUE, TRUE, 'merged') - ON CONFLICT (product_id, channel) DO UPDATE - SET priority = LEAST(product_channel_capabilities.priority, EXCLUDED.priority), - is_fallback = product_channel_capabilities.is_fallback OR EXCLUDED.is_fallback, - updated_at = NOW() - `, - [group.keeper_id], - ) - - await query( - `UPDATE internal_sku_channels - SET is_fallback = TRUE, - priority = GREATEST(priority, 90), - updated_at = NOW() - WHERE btrim(internal_sku) = $1 - AND channel <> 'affiliate_dash' - AND enabled = TRUE - AND is_fallback = FALSE`, - [group.canonical_name], - ) - - await query( - ` - INSERT INTO internal_sku_channels ( - internal_sku, channel, priority, is_fallback, enabled, canonical_product_id, source - ) - VALUES ($1, 'affiliate_dash', 10, FALSE, TRUE, $2, 'merged'), - ($1, 'manual', 20, TRUE, TRUE, $2, 'merged') - ON CONFLICT (internal_sku, channel) DO UPDATE SET - priority = LEAST(internal_sku_channels.priority, EXCLUDED.priority), - is_fallback = internal_sku_channels.is_fallback OR EXCLUDED.is_fallback, - enabled = TRUE, - canonical_product_id = COALESCE(EXCLUDED.canonical_product_id, internal_sku_channels.canonical_product_id), - updated_at = NOW() - `, - [group.canonical_name, group.keeper_id], - ) - - // 只删除已经没有引用的重复商品,仍被引用就归档。 - for (const duplicateId of group.duplicate_ids) { - const refs = await query<{ total: number }>( - ` - SELECT ( - (SELECT count(*) FROM order_items WHERE canonical_product_id = $1) + - (SELECT count(*) FROM cost_snapshots WHERE canonical_product_id = $1) + - (SELECT count(*) FROM sales_daily_facts WHERE canonical_product_id = $1) + - (SELECT count(*) FROM cost_versions WHERE canonical_product_id = $1) + - (SELECT count(*) FROM channel_product_mappings WHERE canonical_product_id = $1) + - (SELECT count(*) FROM manual_fulfillment_configs WHERE canonical_product_id = $1) - )::int AS total - `, - [duplicateId], - ) - if (Number(refs.rows[0]?.total || 0) === 0) { - await query('DELETE FROM product_catalog WHERE id = $1', [duplicateId]) - } else { - await query( - "UPDATE product_catalog SET status = 'archived', updated_at = NOW() WHERE id = $1", - [duplicateId], - ) - } - } - - return { mappings, versions, facts } -} - -async function main() { - const apply = process.argv.includes('--apply') - const groups = await loadGroups() - console.log( - JSON.stringify( - { - mode: apply ? 'apply' : 'dry-run', - groups: groups.map((g) => ({ - name: g.canonical_name, - keeper: g.keeper_sku, - duplicates: g.duplicate_skus, - })), - }, - null, - 2, - ), - ) - if (!apply || groups.length === 0) return - - const dates = await query<{ stat_date: string }>( - `SELECT DISTINCT stat_date::text FROM sales_daily_facts WHERE stat_date >= '2026-10-31'`, - ) - const affectedDates = dates.rows.map((row) => row.stat_date) - let totals = { mappings: 0, versions: 0, facts: 0 } - for (const group of groups) { - const result = await mergeGroup(group) - totals = { - mappings: totals.mappings + result.mappings, - versions: totals.versions + result.versions, - facts: totals.facts + result.facts, - } - console.log( - `merged ${group.canonical_name}: keeper=${group.keeper_sku} dup=${group.duplicate_skus.join(',')}`, - ) - } - await refreshFactsForDates(affectedDates) - console.log(JSON.stringify({ totals, affectedDates }, null, 2)) -} - -main() - .catch((error) => { - console.error(error) - process.exitCode = 1 - }) - .finally(() => closeDb()) diff --git a/apps/backend/scripts/normalize-internal-sku-configs.ts b/apps/backend/scripts/normalize-internal-sku-configs.ts deleted file mode 100644 index 89523e00..00000000 --- a/apps/backend/scripts/normalize-internal-sku-configs.ts +++ /dev/null @@ -1,201 +0,0 @@ -/** - * 合并规范化后重复的内部 SKU 渠道配置。 - * - * 默认只读;确认后加 --apply。 - * 用法:npx tsx scripts/normalize-internal-sku-configs.ts [--apply] - */ -import { closeDb, query, withTransaction } from '../src/db/client.js' -import { canonicalizeInternalSku } from '../src/services/fulfillment/internal-sku-resolver.js' - -type ChannelRow = { - id: number - internal_sku: string - channel: string - priority: number - is_fallback: boolean - enabled: boolean - canonical_product_id: number | null - config_json: Record - source: string - note: string - created_by_admin_user_id: number | null -} - -type DuplicateGroup = { - internalSku: string - channel: string - rows: ChannelRow[] -} - -type MappingRow = { - id: number - provider: string - shop_id: string - match_type: string - match_value: string - internal_sku: string - priority: number - enabled: boolean - source: string - note: string -} - -async function main() { - const apply = process.argv.includes('--apply') - const result = await query( - `SELECT id, internal_sku, channel, priority, is_fallback, enabled, - canonical_product_id, config_json, source, note, created_by_admin_user_id - FROM internal_sku_channels - ORDER BY canonicalize_internal_sku(internal_sku), channel, is_fallback, priority, id`, - ) - - const groups = new Map() - for (const row of result.rows) { - const internalSku = canonicalizeInternalSku(row.internal_sku) - const key = `${internalSku}|${row.channel}` - const group = groups.get(key) || { internalSku, channel: row.channel, rows: [] } - group.rows.push({ ...row, id: Number(row.id), priority: Number(row.priority) }) - groups.set(key, group) - } - - const duplicates = [...groups.values()].filter((group) => group.rows.length > 1) - const mappingRows = await query( - `SELECT id, provider, shop_id, match_type, match_value, internal_sku, - priority, enabled, source, note - FROM internal_sku_mappings - ORDER BY provider, shop_id, match_type, canonicalize_internal_sku(match_value), enabled DESC, priority, id`, - ) - const duplicateMappings = new Map() - for (const row of mappingRows.rows) { - const key = [ - row.provider, - row.shop_id, - row.match_type, - canonicalizeInternalSku(row.match_value), - ].join('|') - const list = duplicateMappings.get(key) || [] - list.push({ ...row, id: Number(row.id), priority: Number(row.priority) }) - duplicateMappings.set(key, list) - } - const mappingDuplicates = [...duplicateMappings.values()].filter((rows) => rows.length > 1) - if (apply) { - for (const group of duplicates) { - await withTransaction(async (client) => { - const rows = ( - await client.query( - `SELECT id, internal_sku, channel, priority, is_fallback, enabled, - canonical_product_id, config_json, source, note, created_by_admin_user_id - FROM internal_sku_channels - WHERE id = ANY($1::bigint[]) - ORDER BY is_fallback, priority, id - FOR UPDATE`, - [group.rows.map((row) => row.id)], - ) - ).rows.map((row) => ({ ...row, id: Number(row.id), priority: Number(row.priority) })) - if (rows.length <= 1) return - - const keeper = rows[0]! - const primaryRows = rows.filter((row) => !row.is_fallback) - const configJson = rows.reduce>( - (merged, row) => ({ ...merged, ...(row.config_json || {}) }), - {}, - ) - const source = rows.find((row) => row.source === 'operator')?.source || keeper.source - const note = [ - ...new Set(rows.map((row) => String(row.note || '').trim()).filter(Boolean)), - ].join(' / ') - await client.query(`DELETE FROM internal_sku_channels WHERE id = ANY($1::bigint[])`, [ - rows.slice(1).map((row) => row.id), - ]) - await client.query( - `UPDATE internal_sku_channels - SET internal_sku = $2, - priority = $3, - is_fallback = $4, - enabled = $5, - canonical_product_id = $6, - config_json = $7::jsonb, - source = $8, - note = $9, - created_by_admin_user_id = $10, - updated_at = NOW() - WHERE id = $1`, - [ - keeper.id, - group.internalSku, - Math.min(...(primaryRows.length > 0 ? primaryRows : rows).map((row) => row.priority)), - primaryRows.length === 0, - rows.some((row) => row.enabled), - rows.find((row) => row.canonical_product_id != null)?.canonical_product_id || null, - JSON.stringify(configJson), - source, - note, - rows.find((row) => row.created_by_admin_user_id != null)?.created_by_admin_user_id || - null, - ], - ) - }) - } - for (const rows of mappingDuplicates) { - await withTransaction(async (client) => { - const keeper = rows[0]! - await client.query(`DELETE FROM internal_sku_mappings WHERE id = ANY($1::bigint[])`, [ - rows.slice(1).map((row) => row.id), - ]) - await client.query( - `UPDATE internal_sku_mappings - SET match_value = $2, internal_sku = $3, priority = $4, - enabled = $5, source = $6, note = $7, updated_at = NOW() - WHERE id = $1`, - [ - keeper.id, - canonicalizeInternalSku(keeper.match_value), - canonicalizeInternalSku(keeper.internal_sku), - Math.min(...rows.map((row) => row.priority)), - rows.some((row) => row.enabled), - keeper.source, - [...new Set(rows.map((row) => String(row.note || '').trim()).filter(Boolean))].join( - ' / ', - ), - ], - ) - }) - } - } - - const duplicateManualConfigs = await query<{ label: string; total: number }>( - `SELECT canonicalize_internal_sku(sku_key) AS label, count(*)::int AS total - FROM manual_fulfillment_configs - GROUP BY canonicalize_internal_sku(sku_key) - HAVING count(*) > 1 - ORDER BY count(*) DESC, label - LIMIT 20`, - ) - - console.log( - JSON.stringify( - { - apply, - duplicateGroups: duplicates.length, - duplicateRows: duplicates.reduce((total, group) => total + group.rows.length, 0), - duplicateMappingGroups: mappingDuplicates.length, - duplicateMappingRows: mappingDuplicates.reduce((total, rows) => total + rows.length, 0), - sample: duplicates.slice(0, 20).map((group) => ({ - internalSku: group.internalSku, - channel: group.channel, - ids: group.rows.map((row) => row.id), - })), - duplicateManualConfigs: duplicateManualConfigs.rows, - }, - null, - 2, - ), - ) -} - -main() - .catch((error) => { - console.error(error) - process.exitCode = 1 - }) - .finally(() => closeDb()) diff --git a/apps/backend/scripts/preflight-fulfillment-routing.ts b/apps/backend/scripts/preflight-fulfillment-routing.ts deleted file mode 100644 index 3a7eaa91..00000000 --- a/apps/backend/scripts/preflight-fulfillment-routing.ts +++ /dev/null @@ -1,345 +0,0 @@ -/** - * 履约/财务统一分流部署后核对(只读)。 - * - * 用法: - * npx tsx scripts/preflight-fulfillment-routing.ts - * npx tsx scripts/preflight-fulfillment-routing.ts --strict - */ -import { closeDb, query } from '../src/db/client.js' - -type CountRow = { label: string; total: number } - -async function main() { - const strict = process.argv.includes('--strict') - const includeDemo = process.argv.includes('--include-demo') - const checks: Record = {} - - checks.migrations = ( - await query<{ name: string }>(`SELECT name FROM schema_migrations ORDER BY id DESC LIMIT 5`) - ).rows.map((row) => row.name) - - checks.pendingReasons = ( - await query( - `SELECT COALESCE(fulfillment_pending_reason, '(null)') AS label, count(*)::int AS total - FROM order_items WHERE fulfillment_status = 'pending' - GROUP BY 1 ORDER BY 2 DESC`, - ) - ).rows - - checks.renameCardChannels = ( - await query( - `SELECT COALESCE(fulfillment_channel, '(pending)') AS label, count(*)::int AS total - FROM order_items - WHERE sku_name = '11月改名卡' - GROUP BY 1 ORDER BY 2 DESC`, - ) - ).rows - - checks.rules = ( - await query( - `SELECT match_key_type || '/' || channel || '/' || - CASE WHEN enabled THEN 'on' ELSE 'off' END AS label, count(*)::int AS total - FROM sku_channel_rules GROUP BY 1 ORDER BY 2 DESC`, - ) - ).rows - - checks.internalChannels = ( - await query( - `SELECT channel AS label, count(*)::int AS total - FROM internal_sku_channels WHERE enabled GROUP BY 1 ORDER BY 2 DESC`, - ) - ).rows - - checks.uppercaseInternalChannels = ( - await query( - `SELECT 'uppercase_internal_sku' AS label, count(*)::int AS total - FROM internal_sku_channels - WHERE internal_sku ~ '[A-Z]'`, - ) - ).rows[0] - - checks.pendingWithoutChannel = ( - await query( - `SELECT 'pending_no_internal_channel' AS label, count(*)::int AS total - FROM order_items oi - WHERE oi.fulfillment_status = 'pending' - AND btrim(COALESCE(resolve_internal_sku( - oi.manual_sku_key, oi.sku_name, oi.sku_code - ), '')) <> '' - AND NOT EXISTS ( - SELECT 1 FROM internal_sku_channels c - WHERE c.enabled AND btrim(c.internal_sku) = resolve_internal_sku( - oi.manual_sku_key, oi.sku_name, oi.sku_code - ) - )`, - ) - ).rows[0] - - checks.assignedWithoutCarrier = ( - await query( - `SELECT 'assigned_without_carrier' AS label, count(*)::int AS total - FROM order_items oi - JOIN orders o ON o.id = oi.order_id - WHERE oi.fulfillment_status = 'assigned' - AND oi.fulfillment_channel IN ('affiliate_dash', 'work_order') - AND ($1::boolean OR o.shop_id NOT LIKE 'sim-%') - AND 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 AND ft.executor_key = 'affiliate_dash' - AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled') - )`, - [includeDemo], - ) - ).rows[0] - - checks.assignedWithMultipleCarriers = ( - await query( - `SELECT 'assigned_with_multiple_carriers' AS label, count(*)::int AS total - FROM order_items oi - JOIN orders o ON o.id = oi.order_id - WHERE oi.fulfillment_status = 'assigned' - AND ($1::boolean OR o.shop_id NOT LIKE 'sim-%') - 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`, - [includeDemo], - ) - ).rows[0] - - checks.internalChannelConflicts = ( - await query( - `SELECT internal_sku AS label, count(*)::int AS total - FROM internal_sku_channels - WHERE enabled = TRUE AND is_fallback = FALSE - GROUP BY internal_sku - HAVING count(*) > 1 - ORDER BY count(*) DESC, internal_sku`, - ) - ).rows - - checks.canonicalChannelDuplicates = ( - await query( - `SELECT canonicalize_internal_sku(internal_sku) AS label, count(*)::int AS total - FROM internal_sku_channels - WHERE enabled = TRUE - GROUP BY canonicalize_internal_sku(internal_sku), channel - HAVING count(*) > 1 - ORDER BY count(*) DESC, label`, - ) - ).rows - - checks.incompleteCarrierConfigs = ( - await query( - `SELECT channel AS label, count(*)::int AS total - FROM internal_sku_channels - WHERE enabled = TRUE - AND ( - ( - channel = 'work_order' - AND ( - COALESCE(config_json->>'workRuleKey', '') = '' - OR NOT EXISTS ( - SELECT 1 FROM work_product_rules rule - WHERE rule.rule_key = config_json->>'workRuleKey' - AND rule.enabled = TRUE - ) - ) - ) - OR ( - channel = 'manual' - AND NOT EXISTS ( - SELECT 1 FROM manual_fulfillment_configs cfg - WHERE cfg.enabled = TRUE - AND canonicalize_internal_sku(cfg.sku_key) - = canonicalize_internal_sku(internal_sku_channels.internal_sku) - ) - ) - ) - GROUP BY channel ORDER BY count(*) DESC`, - ) - ).rows - - checks.ambiguousCarrierConfigs = ( - await query( - `SELECT canonicalize_internal_sku(canonical_name) || '/' || channel AS label, - count(DISTINCT canonical_sku)::int AS total - FROM ( - SELECT canonical_name, canonical_sku, - CASE - WHEN canonical_sku LIKE 'aff:%' THEN 'affiliate_dash' - WHEN canonical_sku LIKE 'tpl:%' OR canonical_sku LIKE 'wo:%' THEN 'work_order' - WHEN canonical_sku LIKE 'tpv:%' THEN 'third_party_voucher' - END AS channel - FROM product_catalog - WHERE status = 'active' - AND (canonical_sku LIKE 'aff:%' OR canonical_sku LIKE 'tpl:%' - OR canonical_sku LIKE 'wo:%' OR canonical_sku LIKE 'tpv:%') - ) carrier - WHERE channel IS NOT NULL - GROUP BY canonicalize_internal_sku(canonical_name), channel - HAVING count(DISTINCT canonical_sku) > 1 - ORDER BY count(DISTINCT canonical_sku) DESC, label`, - ) - ).rows - - checks.ambiguousManualConfigs = ( - await query( - `SELECT canonicalize_internal_sku(sku_key) AS label, count(*)::int AS total - FROM manual_fulfillment_configs - WHERE enabled = TRUE - GROUP BY canonicalize_internal_sku(sku_key) - HAVING count(*) > 1 - ORDER BY count(*) DESC, label`, - ) - ).rows - - checks.ambiguousInternalSkuMappings = ( - await query( - `SELECT provider || '/' || shop_id || '/' || match_type || '/' || - canonicalize_internal_sku(match_value) AS label, - count(*)::int AS total - FROM internal_sku_mappings - WHERE enabled = TRUE - GROUP BY provider, shop_id, match_type, canonicalize_internal_sku(match_value) - HAVING count(*) > 1 - ORDER BY count(*) DESC, label - LIMIT 200`, - ) - ).rows - - checks.pendingWithoutChineseInternalSku = ( - await query( - `SELECT 'pending_without_chinese_internal_sku' AS label, count(*)::int AS total - FROM order_items oi - JOIN orders o ON o.id = oi.order_id - WHERE oi.fulfillment_status = 'pending' - AND ($1::boolean OR o.shop_id NOT LIKE 'sim-%') - AND COALESCE(resolve_internal_sku( - oi.manual_sku_key, oi.sku_name, oi.sku_code - ), '') = ''`, - [includeDemo], - ) - ).rows[0] - - checks.enabledLegacyRules = ( - await query( - `SELECT match_key_type || '/' || channel AS label, count(*)::int AS total - FROM sku_channel_rules - WHERE enabled = TRUE AND match_key_type = 'sku_code' - GROUP BY 1 ORDER BY 2 DESC`, - ) - ).rows - - checks.demoPending = ( - await query( - `SELECT 'demo_pending' AS label, count(*)::int AS total - FROM order_items oi - JOIN orders o ON o.id = oi.order_id - WHERE oi.fulfillment_status = 'pending' AND o.shop_id LIKE 'sim-%'`, - ) - ).rows[0] - - checks.pendingWithoutReason = ( - await query( - `SELECT 'pending_without_reason' AS label, count(*)::int AS total - FROM order_items oi - JOIN orders o ON o.id = oi.order_id - WHERE oi.fulfillment_status = 'pending' - AND ($1::boolean OR o.shop_id NOT LIKE 'sim-%') - AND oi.fulfillment_pending_reason IS NULL`, - [includeDemo], - ) - ).rows[0] - - checks.channelCarrier = ( - await query<{ - channel: string - total: number - work_orders: number - affiliate_tasks: number - manual_tasks: number - }>( - `SELECT COALESCE(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 - FROM order_items oi - WHERE oi.fulfillment_status = 'assigned' - GROUP BY 1 ORDER BY 2 DESC`, - ) - ).rows - - const failed: string[] = [] - const rename = checks.renameCardChannels as CountRow[] - if (rename.some((row) => row.label === 'pending')) failed.push('11月改名卡仍有 pending') - if (!includeDemo && (checks.demoPending as CountRow).total > 0) { - checks.excludedDemoPending = (checks.demoPending as CountRow).total - } - if ((checks.pendingWithoutChannel as CountRow).total > 0 && !includeDemo) { - // 本地演示店铺故意保留 pending;严格模式默认只校验非演示数据。 - const productionPending = await query( - `SELECT 'pending_no_internal_channel' AS label, count(*)::int AS total - FROM order_items oi - JOIN orders o ON o.id = oi.order_id - WHERE oi.fulfillment_status = 'pending' - AND o.shop_id NOT LIKE 'sim-%' - AND btrim(COALESCE(resolve_internal_sku( - oi.manual_sku_key, oi.sku_name, oi.sku_code - ), '')) <> '' - AND NOT EXISTS ( - SELECT 1 FROM internal_sku_channels c - WHERE c.enabled AND btrim(c.internal_sku) = resolve_internal_sku( - oi.manual_sku_key, oi.sku_name, oi.sku_code - ) - )`, - ) - if (productionPending.rows[0]!.total > 0) - failed.push('存在有内部 SKU 但无渠道能力的非演示 pending') - } else if ((checks.pendingWithoutChannel as CountRow).total > 0) { - failed.push('存在有内部 SKU 但无渠道能力的 pending') - } - if ((checks.assignedWithoutCarrier as CountRow).total > 0) - failed.push('存在已分配自动发货/接单渠道但没有真实载体的订单项') - if ((checks.assignedWithMultipleCarriers as CountRow).total > 0) - failed.push('存在同时挂载多个真实履约载体的已分配订单项') - if ((checks.internalChannelConflicts as CountRow[]).length > 0) - failed.push('存在多个非兜底主渠道') - if ((checks.uppercaseInternalChannels as CountRow).total > 0) - failed.push('内部 SKU 渠道配置仍含未规范化的大写英文') - if ((checks.canonicalChannelDuplicates as CountRow[]).length > 0) - failed.push('存在规范化后重复的内部 SKU 渠道配置') - if ((checks.incompleteCarrierConfigs as CountRow[]).length > 0) - failed.push('存在缺少 workRuleKey 或人工履约配置的启用渠道') - if ((checks.ambiguousCarrierConfigs as CountRow[]).length > 0) - failed.push('存在同一中文内部 SKU 的多个载体配置') - if ((checks.ambiguousManualConfigs as CountRow[]).length > 0) - failed.push('存在规范化后重复的人工履约配置') - if ((checks.ambiguousInternalSkuMappings as CountRow[]).length > 0) - failed.push('存在规范化后重复的内部 SKU 映射') - if ((checks.pendingWithoutChineseInternalSku as CountRow).total > 0) - failed.push('存在无法归一到中文内部 SKU 的 pending 订单项') - if ((checks.enabledLegacyRules as CountRow[]).length > 0) - failed.push('仍有启用的 sku_code 兼容规则,未完全收敛到内部 SKU') - if ((checks.pendingWithoutReason as CountRow).total > 0) - failed.push('存在未写入 pending 原因的订单项,请先执行履约/财务管道') - - console.log(JSON.stringify({ strict, failed, ...checks }, null, 2)) - if (strict && failed.length > 0) process.exitCode = 2 -} - -main() - .catch((error) => { - console.error(error) - process.exitCode = 1 - }) - .finally(() => closeDb()) diff --git a/apps/backend/scripts/rebuild-after-cutover.ts b/apps/backend/scripts/rebuild-after-cutover.ts deleted file mode 100644 index ffe534e4..00000000 --- a/apps/backend/scripts/rebuild-after-cutover.ts +++ /dev/null @@ -1,111 +0,0 @@ -import process from 'node:process' - -import { closeDb, query } from '../src/db/client.js' -import { assignPendingOrderItemChannels } from '../src/services/fulfillment/channel-assign-service.js' -import { normalizeOrderItemProducts } from '../src/services/cost-center/product-normalize-service.js' -import { syncChannelCostSnapshots } from '../src/services/cost-center/cost-snapshot-service.js' -import { rebuildChannelSalesDailyFacts } from '../src/services/finance/sales-facts-channel-service.js' -import { syncFinanceLedgerFromFacts } from '../src/services/finance/finance-ledger-service.js' -import { formatShanghaiDate } from '../src/utils/finance-date-range.js' - -/** - * 断代后重建:从 cutover 日起重跑三条管道,生成成本快照、销售事实与期初分录。 - * - * 幂等:渠道判定/商品归一/成本快照都按「缺什么补什么」推进,事实按日先删后插, - * 总账按「event_type + 日期」幂等。可以反复执行。 - * - * 用法: - * npm run finance:rebuild-cutover -- --date-from=2026-10-31 --date-to=2026-10-31 - * npm run finance:rebuild-cutover -- --max-batches=200 - */ - -const options = parseOptions(process.argv.slice(2)) -const dateFrom = String(options.dateFrom || '2026-10-31').slice(0, 10) -const dateTo = String(options.dateTo || formatShanghaiDate(new Date())).slice(0, 10) -const maxBatches = clampInt(options.maxBatches, 200, 1, 2000) -const batchSize = clampInt(options.batchSize, 2000, 1, 5000) - -async function main() { - await assertSchema() - const startedAt = Date.now() - - const channel = await assignPendingOrderItemChannels({ batchSize, maxBatches }) - const product = await normalizeOrderItemProducts({ batchSize, maxBatches }) - const cost = await syncChannelCostSnapshots({ batchSize, maxBatches }) - const facts = await rebuildChannelSalesDailyFacts({ dateFrom, dateTo }) - const ledger = await syncFinanceLedgerFromFacts({ dateFrom, dateTo }) - - const summary = await query<{ - assigned: number - pending: number - archived: number - facts: number - snapshots: number - }>( - ` - 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, - (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 - `, - ) - - console.log( - JSON.stringify( - { - dateFrom, - dateTo, - channel, - product, - cost, - facts, - ledger, - totals: summary.rows[0], - 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 assertSchema() { - const ready = await query<{ ready: boolean }>( - `SELECT to_regclass('public.sku_channel_rules') IS NOT NULL AS ready`, - ) - if (!ready.rows[0]?.ready) { - throw new Error('缺少 112 迁移产物;请先执行 npm run db:migrate') - } -} - -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 clampInt(value: unknown, fallback: number, min: number, max: number) { - const parsed = Number(value) - if (!Number.isInteger(parsed)) return fallback - return Math.min(max, Math.max(min, parsed)) -} 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/scripts/reclassify-post-cutover.ts b/apps/backend/scripts/reclassify-post-cutover.ts deleted file mode 100644 index dcb42ac7..00000000 --- a/apps/backend/scripts/reclassify-post-cutover.ts +++ /dev/null @@ -1,179 +0,0 @@ -/** - * 断代后订单按新的商品渠道能力重判。 - * - * 仅处理:assigned、未锁定、无成功履约证据、有商品能力的订单项。 - * 默认 dry-run;--apply 才写库。 - */ -import process from 'node:process' - -import { closeDb, query, withTransaction } from '../src/db/client.js' -import { syncCostSnapshotsForItems } from '../src/services/cost-center/cost-snapshot-service.js' -import { refreshFactsForDates } from '../src/services/finance/refresh-facts.js' - -const CHANNEL_COST_CODE: Record = { - affiliate_dash: 'sys_affiliate_purchase', - third_party_voucher: 'sys_third_party_voucher_cost', - work_order: 'sys_work_order_reward', -} - -type Candidate = { - id: number - current_channel: string - desired_channel: string - basis_date: string -} - -async function loadCandidates(limit: number): Promise { - const result = await query( - ` - SELECT oi.id, oi.fulfillment_channel AS current_channel, - cap.channel AS desired_channel, - (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date::text AS basis_date - FROM order_items oi - JOIN orders o ON o.id = oi.order_id - JOIN LATERAL ( - SELECT c.channel - FROM internal_sku_channels c - WHERE c.enabled - AND c.channel IN ('affiliate_dash', 'manual', 'third_party_voucher') - AND btrim(c.internal_sku) = resolve_internal_sku( - oi.manual_sku_key, oi.sku_name, oi.sku_code - ) - ORDER BY - c.priority ASC, - CASE c.channel - WHEN 'affiliate_dash' THEN 1 - WHEN 'work_order' THEN 2 - WHEN 'third_party_voucher' THEN 3 - WHEN 'manual' THEN 4 - ELSE 9 - END, - c.channel ASC - LIMIT 1 - ) cap ON TRUE - WHERE oi.fulfillment_status = 'assigned' - AND oi.fulfillment_locked = FALSE - AND COALESCE(o.paid_at, o.created_at) >= '2026-10-31 00:00:00+08' - AND oi.fulfillment_source NOT IN ('operator_assigned', 'manual_confirm') - AND ( - cap.channel <> 'affiliate_dash' - OR 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') - ) - ) - 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 IN ('redeemed', 'paid') - ) - AND 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 cost_snapshots cs - JOIN cost_items ci ON ci.id = cs.cost_item_id - WHERE cs.order_item_id = oi.id - AND ci.code = 'sys_manual_service' - AND cs.superseded_at IS NULL - AND cs.reversed_at IS NULL - ) - ORDER BY oi.id - LIMIT $1 - `, - [limit], - ) - return result.rows -} - -async function applyOne(row: Candidate): Promise { - const previous = row.current_channel - if (!previous || previous === row.desired_channel) return false - - const changed = await withTransaction(async (client) => { - const result = await client.query( - ` - UPDATE order_items - SET original_channel = COALESCE(original_channel, $2), - fulfillment_channel = $3, - fulfillment_source = 'auto_rule', - channel_switch_reason = 'reclassify', - channel_switched_at = NOW(), - updated_at = NOW() - WHERE id = $1 - AND fulfillment_status = 'assigned' - AND fulfillment_locked = FALSE - AND fulfillment_channel = $2 - RETURNING id - `, - [row.id, previous, row.desired_channel], - ) - if ((result.rowCount || 0) === 0) return false - - const code = CHANNEL_COST_CODE[previous] - if (code) { - await client.query( - ` - UPDATE cost_snapshots - SET reversed_at = NOW(), reversal_reason = 'reclassify' - WHERE order_item_id = $1 - AND cost_item_id = (SELECT id FROM cost_items WHERE code = $2) - AND superseded_at IS NULL - AND reversed_at IS NULL - `, - [row.id, code], - ) - } - return true - }) - if (!changed) return false - - await syncCostSnapshotsForItems([row.id]).catch(() => undefined) - return true -} - -async function main() { - const apply = process.argv.includes('--apply') - const limit = 5000 - const rows = await loadCandidates(limit) - const changes = rows.filter((row) => row.current_channel !== row.desired_channel) - console.log( - JSON.stringify( - { - mode: apply ? 'apply' : 'dry-run', - scanned: rows.length, - toChange: changes.length, - sample: changes.slice(0, 20).map((row) => ({ - id: row.id, - from: row.current_channel, - to: row.desired_channel, - date: row.basis_date, - })), - }, - null, - 2, - ), - ) - if (!apply || changes.length === 0) return - - let switched = 0 - const dates = new Set() - for (const row of changes) { - if (await applyOne(row)) { - switched += 1 - dates.add(row.basis_date) - } - } - await refreshFactsForDates([...dates]) - console.log(JSON.stringify({ switched, dates: [...dates].sort() }, null, 2)) -} - -main() - .catch((error) => { - console.error(error) - process.exitCode = 1 - }) - .finally(() => closeDb()) diff --git a/apps/backend/scripts/seed-local-finance-demo.ts b/apps/backend/scripts/seed-local-finance-demo.ts index 070d3d1f..429e18e9 100644 --- a/apps/backend/scripts/seed-local-finance-demo.ts +++ b/apps/backend/scripts/seed-local-finance-demo.ts @@ -18,26 +18,19 @@ async function cleanup() { ) await query(`DELETE FROM sales_daily_facts WHERE shop_id = ANY($1)`, [SHOPS]) await query( - `DELETE FROM internal_sku_channels - WHERE internal_sku IN ('模拟自动发货商品', '模拟三方卡券', '模拟人工商品')`, - ) - await query( - `DELETE FROM order_items WHERE order_id IN (SELECT id FROM orders WHERE shop_id = ANY($1))`, - [SHOPS], + 'ALTER TABLE fulfillment_state_events DISABLE TRIGGER trg_fulfillment_state_events_append_only', ) + try { + await query( + `DELETE FROM order_items WHERE order_id IN (SELECT id FROM orders WHERE shop_id = ANY($1))`, + [SHOPS], + ) + } finally { + await query( + 'ALTER TABLE fulfillment_state_events ENABLE TRIGGER trg_fulfillment_state_events_append_only', + ) + } await query(`DELETE FROM orders WHERE shop_id = ANY($1)`, [SHOPS]) - await query( - `DELETE FROM sku_channel_rules - WHERE match_key IN ('SIM_AFF_1', 'SIMV001', 'SIMM001', - '模拟自动发货商品', '模拟三方卡券', '模拟人工商品')`, - ) - await query( - `DELETE FROM product_channel_capabilities - WHERE product_id IN ( - SELECT id FROM product_catalog - WHERE canonical_sku IN ('aff:SIM_AFF_1', 'man:模拟人工商品') - )`, - ) await query( `DELETE FROM channel_product_mappings WHERE canonical_product_id IN ( @@ -51,7 +44,9 @@ async function cleanup() { `DELETE FROM cost_versions WHERE canonical_product_id IN ( SELECT id FROM product_catalog - WHERE canonical_sku IN ('aff:SIM_AFF_1', 'tpv:模拟三方卡券', 'tpv:模拟三方卡券B') + WHERE canonical_sku IN ( + 'aff:SIM_AFF_1', 'man:模拟人工商品', 'tpv:模拟三方卡券', 'tpv:模拟三方卡券B' + ) )`, ) await query( @@ -64,6 +59,14 @@ async function cleanup() { 'aff:SIM_AFF_1', 'man:模拟人工商品', 'tpv:模拟三方卡券', 'tpv:模拟三方卡券B' )`, ) + await query( + `DELETE FROM internal_skus + WHERE internal_sku_key IN ( + canonicalize_internal_sku('模拟自动发货商品'), + canonicalize_internal_sku('模拟三方卡券'), + canonicalize_internal_sku('模拟人工商品') + )`, + ) } async function createOrder(shop: string, orderNo: string, amountFen = 1000) { @@ -119,11 +122,6 @@ async function main() { VALUES ($1,'product',$2,500,0,'2026-10-01T00:00:00+08:00','active','本地演示采购价',NOW(),NOW())`, [affCostItem.rows[0]!.id, affProductId], ) - await query( - `INSERT INTO product_channel_capabilities (product_id, channel, priority, is_fallback, enabled, source) - VALUES ($1,'affiliate_dash',10,FALSE,TRUE,'demo'), ($1,'manual',20,TRUE,TRUE,'demo')`, - [affProductId], - ) const manualProduct = await query<{ id: number }>( `INSERT INTO product_catalog ( canonical_sku, canonical_name, category, unit_name, status, @@ -134,12 +132,6 @@ async function main() { ) RETURNING id`, ) const manualProductId = Number(manualProduct.rows[0]!.id) - await query( - `INSERT INTO product_channel_capabilities ( - product_id, channel, priority, is_fallback, enabled, source - ) VALUES ($1, 'manual', 10, FALSE, TRUE, 'demo')`, - [manualProductId], - ) const manualCostItem = await query<{ id: number }>( `SELECT id FROM cost_items WHERE code = 'sys_manual_service'`, ) @@ -154,17 +146,6 @@ async function main() { 'active', '本地演示人工成本', NOW(), NOW())`, [manualCostItem.rows[0]!.id, affProductId, manualProductId], ) - await query( - `INSERT INTO internal_sku_channels ( - internal_sku, channel, priority, is_fallback, enabled, - canonical_product_id, config_json, source, created_at, updated_at - ) VALUES - ('模拟自动发货商品', 'affiliate_dash', 10, FALSE, TRUE, $1, - '{"affiliateSku":"SIM_AFF_1"}'::jsonb, 'demo', NOW(), NOW()), - ('模拟自动发货商品', 'manual', 90, TRUE, TRUE, $1, - '{}'::jsonb, 'demo', NOW(), NOW())`, - [affProductId], - ) // 人工演示配置 await query( `INSERT INTO manual_fulfillment_configs ( @@ -176,9 +157,31 @@ async function main() { [affProductId, manualProductId], ) await query( - `INSERT INTO internal_sku_channels ( - internal_sku, channel, priority, is_fallback, enabled, source, created_at, updated_at - ) VALUES ('模拟人工商品', 'manual', 10, FALSE, TRUE, 'demo', NOW(), NOW())`, + ` + INSERT INTO sku_channel_routes ( + internal_sku_id, channel, route_role, priority, enabled, + canonical_product_id, carrier_config_json, source, created_at, updated_at + ) + SELECT sku_id, channel, route_role, priority, TRUE, product_id, config_json, 'demo', NOW(), NOW() + FROM ( + VALUES + (ensure_canonical_internal_sku('模拟自动发货商品'), 'affiliate_dash', 'primary', 10, + $1::bigint, '{"affiliateSku":"SIM_AFF_1"}'::jsonb), + (ensure_canonical_internal_sku('模拟自动发货商品'), 'manual', 'fallback', 90, + $1::bigint, '{}'::jsonb), + (ensure_canonical_internal_sku('模拟人工商品'), 'manual', 'primary', 10, + $2::bigint, '{}'::jsonb) + ) demo(sku_id, channel, route_role, priority, product_id, config_json) + ON CONFLICT (internal_sku_id, channel) DO UPDATE SET + route_role = EXCLUDED.route_role, + priority = EXCLUDED.priority, + enabled = TRUE, + canonical_product_id = EXCLUDED.canonical_product_id, + carrier_config_json = EXCLUDED.carrier_config_json, + source = EXCLUDED.source, + updated_at = NOW() + `, + [affProductId, manualProductId], ) // 1. 三方卡券:待分类,指定后自动建商品 diff --git a/apps/backend/src/db/migrations/147_internal_sku_product_routing.sql b/apps/backend/src/db/migrations/147_internal_sku_product_routing.sql new file mode 100644 index 00000000..15c163bd --- /dev/null +++ b/apps/backend/src/db/migrations/147_internal_sku_product_routing.sql @@ -0,0 +1,1335 @@ +-- 147_internal_sku_product_routing.sql +-- +-- 内部 SKU、标准商品与履约路由硬切: +-- 1. 建立 internal_skus / sku_channel_routes; +-- 2. product_catalog 固化为 source_kind + internal_sku_id; +-- 3. 完成旧路由停写、投影切换与历史兼容面下线; +-- 4. 收紧 source_kind 允许值。 +-- +-- 合并自未上生产的 154–162、168。 + +-- --------------------------------------------------------------------------- +-- 内部 SKU 身份 +-- --------------------------------------------------------------------------- +-- 统一内部 SKU 身份与履约路由: +-- 1. internal_skus 成为内部 SKU 的稳定身份; +-- 2. sku_channel_routes 成为主渠道/兜底渠道的唯一结构化表达; +-- 3. 兼容期由旧 internal_sku_channels 通过触发器同步; +-- 4. product_catalog 增加 source_kind 与 internal_sku_id,不再依赖前缀判断业务来源。 + +CREATE TABLE internal_skus ( + id BIGSERIAL PRIMARY KEY, + internal_sku_key TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + merged_into_id BIGINT REFERENCES internal_skus(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT internal_skus_key_not_blank CHECK (BTRIM(internal_sku_key) <> ''), + CONSTRAINT internal_skus_status_check CHECK (status IN ('active', 'disabled', 'merged')) +); + +CREATE UNIQUE INDEX uq_internal_skus_key + ON internal_skus(internal_sku_key); + +COMMENT ON TABLE internal_skus IS + '中文内部 SKU 稳定身份;所有路由、配置和商品关联最终通过 internal_sku_id 引用'; +COMMENT ON COLUMN internal_skus.internal_sku_key IS + 'NFKC、去空白、转小写后的稳定键'; + +WITH source_rows AS ( + SELECT internal_sku AS raw_value, updated_at FROM internal_sku_channels + UNION ALL + SELECT internal_sku AS raw_value, updated_at FROM internal_sku_mappings + UNION ALL + SELECT sku_key AS raw_value, updated_at FROM manual_fulfillment_configs + UNION ALL + SELECT sku_key AS raw_value, updated_at FROM manual_sku_registry + UNION ALL + SELECT canonical_name AS raw_value, updated_at FROM product_catalog +), +normalized AS ( + SELECT + canonicalize_internal_sku(raw_value) AS internal_sku_key, + BTRIM(raw_value) AS display_name, + updated_at + FROM source_rows + WHERE canonicalize_internal_sku(raw_value) <> '' +), +picked AS ( + SELECT DISTINCT ON (internal_sku_key) + internal_sku_key, + display_name, + updated_at + FROM normalized + ORDER BY internal_sku_key, updated_at DESC NULLS LAST, display_name +) +INSERT INTO internal_skus ( + internal_sku_key, + display_name, + status, + created_at, + updated_at +) +SELECT + internal_sku_key, + display_name, + 'active', + COALESCE(updated_at, NOW()), + COALESCE(updated_at, NOW()) +FROM picked +ON CONFLICT (internal_sku_key) DO UPDATE SET + display_name = CASE + WHEN internal_skus.display_name = '' THEN EXCLUDED.display_name + ELSE internal_skus.display_name + END, + updated_at = GREATEST(internal_skus.updated_at, EXCLUDED.updated_at); + +ALTER TABLE internal_sku_mappings + ADD COLUMN internal_sku_id BIGINT REFERENCES internal_skus(id) ON DELETE SET NULL; +ALTER TABLE manual_fulfillment_configs + ADD COLUMN internal_sku_id BIGINT REFERENCES internal_skus(id) ON DELETE SET NULL; +ALTER TABLE product_catalog + ADD COLUMN internal_sku_id BIGINT REFERENCES internal_skus(id) ON DELETE SET NULL, + ADD COLUMN source_kind TEXT; + +UPDATE internal_sku_mappings mapping +SET internal_sku_id = sku.id +FROM internal_skus sku +WHERE sku.internal_sku_key = canonicalize_internal_sku(mapping.internal_sku); + +UPDATE manual_fulfillment_configs config +SET internal_sku_id = sku.id +FROM internal_skus sku +WHERE sku.internal_sku_key = canonicalize_internal_sku(config.sku_key); + +UPDATE product_catalog product +SET source_kind = CASE + WHEN product.canonical_sku LIKE 'aff:%' THEN 'affiliate_dash' + WHEN product.canonical_sku LIKE 'tpl:%' OR product.canonical_sku LIKE 'wo:%' THEN 'work_order' + WHEN product.canonical_sku LIKE 'tpv:%' THEN 'third_party_voucher' + WHEN product.canonical_sku LIKE 'man:%' THEN 'manual' + ELSE 'unclassified' +END; + +UPDATE product_catalog product +SET internal_sku_id = sku.id +FROM internal_skus sku +WHERE sku.internal_sku_key = canonicalize_internal_sku( + CASE + WHEN product.canonical_sku LIKE 'man:%' THEN substring(product.canonical_sku from 5) + WHEN product.canonical_sku LIKE 'tpv:%' THEN substring(product.canonical_sku from 5) + ELSE NULL + END +); + +ALTER TABLE product_catalog + ALTER COLUMN source_kind SET DEFAULT 'unclassified', + ALTER COLUMN source_kind SET NOT NULL; + +CREATE INDEX idx_product_catalog_internal_sku + ON product_catalog(internal_sku_id) + WHERE internal_sku_id IS NOT NULL; +CREATE INDEX idx_product_catalog_source_kind + ON product_catalog(source_kind, status); +CREATE INDEX idx_internal_sku_mappings_internal_sku + ON internal_sku_mappings(internal_sku_id) + WHERE internal_sku_id IS NOT NULL; +CREATE INDEX idx_manual_configs_internal_sku + ON manual_fulfillment_configs(internal_sku_id) + WHERE internal_sku_id IS NOT NULL; + +CREATE TABLE sku_channel_routes ( + id BIGSERIAL PRIMARY KEY, + internal_sku_id BIGINT NOT NULL REFERENCES internal_skus(id) ON DELETE CASCADE, + channel TEXT NOT NULL, + route_role TEXT NOT NULL DEFAULT 'fallback', + priority INTEGER NOT NULL DEFAULT 100, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE SET NULL, + carrier_config_json JSONB NOT NULL DEFAULT '{}'::jsonb, + source TEXT NOT NULL DEFAULT 'legacy_sync', + note TEXT NOT NULL DEFAULT '', + created_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT sku_channel_routes_channel_check + CHECK (channel IN ('work_order', 'affiliate_dash', 'third_party_voucher', 'manual', 'on_account')), + CONSTRAINT sku_channel_routes_role_check + CHECK (route_role IN ('primary', 'fallback')), + CONSTRAINT sku_channel_routes_priority_check + CHECK (priority >= 0) +); + +CREATE UNIQUE INDEX uq_sku_channel_routes_channel + ON sku_channel_routes(internal_sku_id, channel); +CREATE UNIQUE INDEX uq_sku_channel_routes_primary + ON sku_channel_routes(internal_sku_id) + WHERE enabled = TRUE AND route_role = 'primary'; +CREATE INDEX idx_sku_channel_routes_lookup + ON sku_channel_routes(channel, enabled, priority) + WHERE enabled = TRUE; + +COMMENT ON TABLE sku_channel_routes IS + '内部 SKU 履约路由:一个主渠道加若干显式兜底渠道'; +COMMENT ON COLUMN sku_channel_routes.carrier_config_json IS + '渠道载体参数,例如 workRuleKey / affiliateSku / manualConfigId / voucherSku'; + +WITH legacy_rows AS ( + SELECT + sku.id AS internal_sku_id, + channel.channel, + channel.priority, + channel.is_fallback, + channel.enabled, + channel.canonical_product_id, + channel.config_json, + channel.source, + channel.note, + channel.created_by_admin_user_id, + channel.created_at, + channel.updated_at, + ROW_NUMBER() OVER ( + PARTITION BY sku.id, channel.channel + ORDER BY channel.is_fallback, channel.priority, channel.id + ) AS channel_rank + FROM internal_sku_channels channel + JOIN internal_skus sku + ON sku.internal_sku_key = canonicalize_internal_sku(channel.internal_sku) + WHERE channel.enabled = TRUE +), +deduplicated AS ( + SELECT * FROM legacy_rows WHERE channel_rank = 1 +), +ranked AS ( + SELECT + *, + ROW_NUMBER() OVER ( + PARTITION BY internal_sku_id + ORDER BY is_fallback, priority, channel + ) AS overall_rank + FROM deduplicated +) +INSERT INTO sku_channel_routes ( + internal_sku_id, + channel, + route_role, + priority, + enabled, + canonical_product_id, + carrier_config_json, + source, + note, + created_by_admin_user_id, + created_at, + updated_at +) +SELECT + internal_sku_id, + channel, + CASE WHEN overall_rank = 1 THEN 'primary' ELSE 'fallback' END, + CASE WHEN overall_rank = 1 THEN priority ELSE GREATEST(priority, 90) END, + TRUE, + canonical_product_id, + COALESCE(config_json, '{}'::jsonb), + COALESCE(NULLIF(source, ''), 'legacy_sync'), + COALESCE(note, ''), + created_by_admin_user_id, + COALESCE(created_at, NOW()), + COALESCE(updated_at, NOW()) +FROM ranked +ON CONFLICT (internal_sku_id, channel) DO NOTHING; + +CREATE OR REPLACE FUNCTION ensure_canonical_internal_sku(p_raw TEXT) +RETURNS BIGINT +LANGUAGE plpgsql +AS $$ +DECLARE + v_key TEXT := canonicalize_internal_sku(p_raw); + v_display_name TEXT := BTRIM(COALESCE(p_raw, '')); + v_id BIGINT; +BEGIN + IF v_key = '' THEN + RETURN NULL; + END IF; + + INSERT INTO internal_skus (internal_sku_key, display_name, status, created_at, updated_at) + VALUES (v_key, v_display_name, 'active', NOW(), NOW()) + ON CONFLICT (internal_sku_key) DO UPDATE SET + display_name = CASE + WHEN internal_skus.display_name = '' THEN EXCLUDED.display_name + ELSE internal_skus.display_name + END, + updated_at = GREATEST(internal_skus.updated_at, EXCLUDED.updated_at) + RETURNING id INTO v_id; + + RETURN v_id; +END; +$$; + +CREATE OR REPLACE FUNCTION sync_sku_channel_routes_for_key(p_raw TEXT) +RETURNS VOID +LANGUAGE plpgsql +AS $$ +DECLARE + v_sku_id BIGINT; +BEGIN + SELECT id INTO v_sku_id + FROM internal_skus + WHERE internal_sku_key = canonicalize_internal_sku(p_raw) + LIMIT 1; + + IF v_sku_id IS NULL THEN + RETURN; + END IF; + + DELETE FROM sku_channel_routes WHERE internal_sku_id = v_sku_id; + + WITH legacy_rows AS ( + SELECT + channel.channel, + channel.priority, + channel.is_fallback, + channel.canonical_product_id, + channel.config_json, + channel.source, + channel.note, + channel.created_by_admin_user_id, + channel.created_at, + channel.updated_at, + ROW_NUMBER() OVER ( + PARTITION BY channel.channel + ORDER BY channel.is_fallback, channel.priority, channel.id + ) AS channel_rank + FROM internal_sku_channels channel + WHERE channel.enabled = TRUE + AND canonicalize_internal_sku(channel.internal_sku) + = canonicalize_internal_sku(p_raw) + ), + ranked AS ( + SELECT + *, + ROW_NUMBER() OVER ( + ORDER BY is_fallback, priority, channel + ) AS overall_rank + FROM legacy_rows + WHERE channel_rank = 1 + ) + INSERT INTO sku_channel_routes ( + internal_sku_id, + channel, + route_role, + priority, + enabled, + canonical_product_id, + carrier_config_json, + source, + note, + created_by_admin_user_id, + created_at, + updated_at + ) + SELECT + v_sku_id, + channel, + CASE WHEN overall_rank = 1 THEN 'primary' ELSE 'fallback' END, + CASE WHEN overall_rank = 1 THEN priority ELSE GREATEST(priority, 90) END, + TRUE, + canonical_product_id, + COALESCE(config_json, '{}'::jsonb), + COALESCE(NULLIF(source, ''), 'legacy_sync'), + COALESCE(note, ''), + created_by_admin_user_id, + COALESCE(created_at, NOW()), + COALESCE(updated_at, NOW()) + FROM ranked; +END; +$$; + +CREATE OR REPLACE FUNCTION sync_sku_channel_routes_trigger() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP <> 'INSERT' THEN + PERFORM sync_sku_channel_routes_for_key(OLD.internal_sku); + END IF; + IF TG_OP <> 'DELETE' THEN + PERFORM ensure_canonical_internal_sku(NEW.internal_sku); + PERFORM sync_sku_channel_routes_for_key(NEW.internal_sku); + END IF; + RETURN COALESCE(NEW, OLD); +END; +$$; + +CREATE TRIGGER trg_sync_sku_channel_routes +AFTER INSERT OR UPDATE OR DELETE ON internal_sku_channels +FOR EACH ROW +EXECUTE FUNCTION sync_sku_channel_routes_trigger(); + +CREATE OR REPLACE FUNCTION set_internal_sku_id_from_text() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.internal_sku_id := ensure_canonical_internal_sku(NEW.internal_sku); + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_internal_sku_mapping_identity +BEFORE INSERT OR UPDATE OF internal_sku ON internal_sku_mappings +FOR EACH ROW +EXECUTE FUNCTION set_internal_sku_id_from_text(); + +CREATE OR REPLACE FUNCTION set_manual_config_internal_sku_id() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.internal_sku_id := ensure_canonical_internal_sku(NEW.sku_key); + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_manual_config_internal_sku_identity +BEFORE INSERT OR UPDATE OF sku_key ON manual_fulfillment_configs +FOR EACH ROW +EXECUTE FUNCTION set_manual_config_internal_sku_id(); + +CREATE OR REPLACE FUNCTION normalize_product_identity() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.source_kind := COALESCE( + NULLIF(NEW.source_kind, ''), + CASE + WHEN NEW.canonical_sku LIKE 'aff:%' THEN 'affiliate_dash' + WHEN NEW.canonical_sku LIKE 'tpl:%' OR NEW.canonical_sku LIKE 'wo:%' THEN 'work_order' + WHEN NEW.canonical_sku LIKE 'tpv:%' THEN 'third_party_voucher' + WHEN NEW.canonical_sku LIKE 'man:%' THEN 'manual' + ELSE 'unclassified' + END + ); + + IF NEW.internal_sku_id IS NULL THEN + SELECT sku.id INTO NEW.internal_sku_id + FROM internal_skus sku + WHERE sku.internal_sku_key = canonicalize_internal_sku( + CASE + WHEN NEW.canonical_sku LIKE 'man:%' THEN substring(NEW.canonical_sku from 5) + WHEN NEW.canonical_sku LIKE 'tpv:%' THEN substring(NEW.canonical_sku from 5) + ELSE NULL + END + ) + LIMIT 1; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_normalize_product_identity +BEFORE INSERT OR UPDATE OF canonical_sku, canonical_name, source_kind, internal_sku_id +ON product_catalog +FOR EACH ROW +EXECUTE FUNCTION normalize_product_identity(); + +-- --------------------------------------------------------------------------- +-- 商品当前渠道投影 +-- --------------------------------------------------------------------------- +-- 统一商品当前渠道投影,并清理已被其他渠道接管且没有人工兜底配置的旧 man 商品。 + +UPDATE sku_channel_routes route +SET canonical_product_id = product.id, + updated_at = NOW() +FROM product_catalog product +WHERE route.channel = 'work_order' + AND route.canonical_product_id IS NULL + AND product.status = 'active' + AND product.canonical_sku = 'tpl:' || COALESCE(route.carrier_config_json->>'workRuleKey', ''); + +CREATE OR REPLACE VIEW v_product_current AS +SELECT + product.*, + sku.internal_sku_key, + COALESCE( + primary_route.channel, + CASE product.source_kind + WHEN 'affiliate_dash' THEN 'affiliate_dash' + WHEN 'work_order' THEN 'work_order' + WHEN 'third_party_voucher' THEN 'third_party_voucher' + WHEN 'manual' THEN 'manual' + ELSE NULL + END + ) AS active_primary_channel, + COALESCE(fallback_routes.channels, ARRAY[]::text[]) AS fallback_channels, + manual_config.id AS manual_config_id, + (manual_config.id IS NOT NULL) AS has_active_manual_config +FROM product_catalog product +LEFT JOIN internal_skus sku ON sku.id = product.internal_sku_id +LEFT JOIN LATERAL ( + SELECT route.channel + FROM sku_channel_routes route + WHERE route.internal_sku_id = product.internal_sku_id + AND route.enabled = TRUE + AND route.route_role = 'primary' + ORDER BY route.priority, route.channel + LIMIT 1 +) primary_route ON TRUE +LEFT JOIN LATERAL ( + SELECT ARRAY_AGG(route.channel ORDER BY route.priority, route.channel) AS channels + FROM sku_channel_routes route + WHERE route.internal_sku_id = product.internal_sku_id + AND route.enabled = TRUE + AND route.route_role = 'fallback' +) fallback_routes ON TRUE +LEFT JOIN LATERAL ( + SELECT config.id + FROM manual_fulfillment_configs config + WHERE config.enabled = TRUE + AND ( + config.canonical_product_id = product.id + OR ( + sku.internal_sku_key IS NOT NULL + AND canonicalize_internal_sku(config.sku_key) = sku.internal_sku_key + ) + ) + ORDER BY config.id + LIMIT 1 +) manual_config ON TRUE; + +COMMENT ON VIEW v_product_current IS + '标准商品统一投影:来源、当前主渠道、兜底渠道和人工配置状态同源输出'; + +WITH candidates AS ( + SELECT product.id + FROM v_product_current product + WHERE product.status = 'active' + AND product.canonical_sku LIKE 'man:%' + AND product.active_primary_channel IS NOT NULL + AND product.active_primary_channel <> 'manual' + AND product.has_active_manual_config = FALSE + AND EXISTS ( + SELECT 1 + FROM sku_channel_routes route + WHERE route.internal_sku_id = product.internal_sku_id + AND route.enabled = TRUE + AND route.route_role = 'primary' + AND route.channel <> 'manual' + ) +) +UPDATE product_catalog product +SET status = 'archived', + attributes_json = COALESCE(product.attributes_json, '{}'::jsonb) || jsonb_build_object( + 'legacyManualArchived', TRUE, + 'legacyManualArchiveReason', 'superseded_by_current_primary_channel', + 'legacyManualArchivedAt', NOW() + ), + updated_at = NOW() +FROM candidates +WHERE product.id = candidates.id; + +COMMENT ON COLUMN product_catalog.source_kind IS + '标准商品来源类型;来源不等于当前履约渠道,当前渠道统一读取 v_product_current'; + +-- --------------------------------------------------------------------------- +-- 商品来源触发器修正 +-- --------------------------------------------------------------------------- +-- source_kind 的列默认值会让 BEFORE INSERT 触发器看到“非空但未分类”, +-- 从而跳过前缀回填。这里保留默认值兼容直接写入,同时让已知前缀始终覆盖 +-- unclassified,并修复兼容期内已经写入的误判行。 + +CREATE OR REPLACE FUNCTION normalize_product_identity() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.source_kind := CASE + WHEN NEW.canonical_sku LIKE 'aff:%' THEN 'affiliate_dash' + WHEN NEW.canonical_sku LIKE 'tpl:%' OR NEW.canonical_sku LIKE 'wo:%' THEN 'work_order' + WHEN NEW.canonical_sku LIKE 'tpv:%' THEN 'third_party_voucher' + WHEN NEW.canonical_sku LIKE 'man:%' THEN 'manual' + ELSE COALESCE(NULLIF(NEW.source_kind, ''), 'unclassified') + END; + + IF NEW.internal_sku_id IS NULL THEN + SELECT sku.id INTO NEW.internal_sku_id + FROM internal_skus sku + WHERE sku.internal_sku_key = canonicalize_internal_sku( + CASE + WHEN NEW.canonical_sku LIKE 'man:%' THEN substring(NEW.canonical_sku from 5) + WHEN NEW.canonical_sku LIKE 'tpv:%' THEN substring(NEW.canonical_sku from 5) + ELSE NULL + END + ) + LIMIT 1; + END IF; + + RETURN NEW; +END; +$$; + +UPDATE product_catalog +SET source_kind = CASE + WHEN canonical_sku LIKE 'aff:%' THEN 'affiliate_dash' + WHEN canonical_sku LIKE 'tpl:%' OR canonical_sku LIKE 'wo:%' THEN 'work_order' + WHEN canonical_sku LIKE 'tpv:%' THEN 'third_party_voucher' + WHEN canonical_sku LIKE 'man:%' THEN 'manual' + ELSE source_kind +END, +updated_at = NOW() +WHERE source_kind = 'unclassified' + AND ( + canonical_sku LIKE 'aff:%' + OR canonical_sku LIKE 'tpl:%' + OR canonical_sku LIKE 'wo:%' + OR canonical_sku LIKE 'tpv:%' + OR canonical_sku LIKE 'man:%' + ); + +-- --------------------------------------------------------------------------- +-- 仅兜底路由保持兜底 +-- --------------------------------------------------------------------------- +-- 兼容同步不能把“只有兜底渠道”的旧配置自动升级成主渠道。 +-- 只有旧表中明确 is_fallback = FALSE 的渠道才允许竞争 primary; +-- 如果某个内部 SKU 没有主渠道,则所有启用路由都保持 fallback。 + +CREATE OR REPLACE FUNCTION sync_sku_channel_routes_for_key(p_raw TEXT) +RETURNS VOID +LANGUAGE plpgsql +AS $$ +DECLARE + v_sku_id BIGINT; +BEGIN + SELECT id INTO v_sku_id + FROM internal_skus + WHERE internal_sku_key = canonicalize_internal_sku(p_raw) + LIMIT 1; + + IF v_sku_id IS NULL THEN + RETURN; + END IF; + + DELETE FROM sku_channel_routes WHERE internal_sku_id = v_sku_id; + + WITH legacy_rows AS ( + SELECT + channel.channel, + channel.priority, + channel.is_fallback, + channel.canonical_product_id, + channel.config_json, + channel.source, + channel.note, + channel.created_by_admin_user_id, + channel.created_at, + channel.updated_at, + ROW_NUMBER() OVER ( + PARTITION BY channel.channel + ORDER BY channel.priority, channel.id + ) AS channel_rank + FROM internal_sku_channels channel + WHERE channel.enabled = TRUE + AND canonicalize_internal_sku(channel.internal_sku) + = canonicalize_internal_sku(p_raw) + ), + ranked AS ( + SELECT + *, + ROW_NUMBER() OVER ( + PARTITION BY is_fallback + ORDER BY priority, channel + ) AS role_rank + FROM legacy_rows + WHERE channel_rank = 1 + ) + INSERT INTO sku_channel_routes ( + internal_sku_id, + channel, + route_role, + priority, + enabled, + canonical_product_id, + carrier_config_json, + source, + note, + created_by_admin_user_id, + created_at, + updated_at + ) + SELECT + v_sku_id, + channel, + CASE + WHEN is_fallback = FALSE AND role_rank = 1 THEN 'primary' + ELSE 'fallback' + END, + CASE + WHEN is_fallback = FALSE AND role_rank = 1 THEN priority + ELSE GREATEST(priority, 90) + END, + TRUE, + canonical_product_id, + COALESCE(config_json, '{}'::jsonb), + COALESCE(NULLIF(source, ''), 'legacy_sync'), + COALESCE(note, ''), + created_by_admin_user_id, + COALESCE(created_at, NOW()), + COALESCE(updated_at, NOW()) + FROM ranked; +END; +$$; + +DO $$ +DECLARE + v_internal_sku TEXT; +BEGIN + FOR v_internal_sku IN + SELECT DISTINCT internal_sku + FROM internal_sku_channels + WHERE enabled = TRUE + LOOP + PERFORM sync_sku_channel_routes_for_key(v_internal_sku); + END LOOP; +END; +$$; + +-- --------------------------------------------------------------------------- +-- 人工来源不推导人工主渠道 +-- --------------------------------------------------------------------------- +-- man: 只表示商品来源,不能证明当前由人工履约。 +-- 兼容期内,自动发货 / 接单平台 / 第三方卡券这类直连来源允许暂时由 source_kind +-- 推导当前渠道;人工客服必须有真实启用的 primary route,否则就是“未路由”。 + +CREATE OR REPLACE VIEW v_product_current AS +SELECT + product.*, + sku.internal_sku_key, + COALESCE( + primary_route.channel, + CASE product.source_kind + WHEN 'affiliate_dash' THEN 'affiliate_dash' + WHEN 'work_order' THEN 'work_order' + WHEN 'third_party_voucher' THEN 'third_party_voucher' + ELSE NULL + END + ) AS active_primary_channel, + COALESCE(fallback_routes.channels, ARRAY[]::text[]) AS fallback_channels, + manual_config.id AS manual_config_id, + (manual_config.id IS NOT NULL) AS has_active_manual_config +FROM product_catalog product +LEFT JOIN internal_skus sku ON sku.id = product.internal_sku_id +LEFT JOIN LATERAL ( + SELECT route.channel + FROM sku_channel_routes route + WHERE route.internal_sku_id = product.internal_sku_id + AND route.enabled = TRUE + AND route.route_role = 'primary' + ORDER BY route.priority, route.channel + LIMIT 1 +) primary_route ON TRUE +LEFT JOIN LATERAL ( + SELECT ARRAY_AGG(route.channel ORDER BY route.priority, route.channel) AS channels + FROM sku_channel_routes route + WHERE route.internal_sku_id = product.internal_sku_id + AND route.enabled = TRUE + AND route.route_role = 'fallback' +) fallback_routes ON TRUE +LEFT JOIN LATERAL ( + SELECT config.id + FROM manual_fulfillment_configs config + WHERE config.enabled = TRUE + AND ( + config.canonical_product_id = product.id + OR ( + sku.internal_sku_key IS NOT NULL + AND canonicalize_internal_sku(config.sku_key) = sku.internal_sku_key + ) + ) + ORDER BY config.id + LIMIT 1 +) manual_config ON TRUE; + +COMMENT ON VIEW v_product_current IS + '标准商品统一投影:来源不等于当前渠道;manual 必须有真实主路由,直连来源兼容期允许回退 source_kind'; + +-- --------------------------------------------------------------------------- +-- 归档孤儿人工商品 +-- --------------------------------------------------------------------------- +-- man: 只表示来源,不能证明当前可履约。 +-- 没有主路由、没有启用人工配置、也没有启用商品映射的 man 商品属于孤儿商品, +-- 只归档,不删除;历史订单、成本版本和成本快照继续保留。 + +WITH candidates AS ( + SELECT product.id + FROM product_catalog product + WHERE product.status = 'active' + AND product.source_kind = 'manual' + AND NOT EXISTS ( + SELECT 1 + FROM sku_channel_routes route + WHERE route.internal_sku_id = product.internal_sku_id + AND route.enabled = TRUE + AND route.route_role = 'primary' + ) + AND NOT EXISTS ( + SELECT 1 + FROM manual_fulfillment_configs config + WHERE config.enabled = TRUE + AND ( + config.canonical_product_id = product.id + OR config.internal_sku_id = product.internal_sku_id + OR canonicalize_internal_sku(config.sku_key) + = canonicalize_internal_sku(substring(product.canonical_sku from 5)) + ) + ) + AND NOT EXISTS ( + SELECT 1 + FROM channel_product_mappings mapping + WHERE mapping.canonical_product_id = product.id + AND mapping.enabled = TRUE + ) +) +UPDATE product_catalog product +SET status = 'archived', + attributes_json = COALESCE(product.attributes_json, '{}'::jsonb) || jsonb_build_object( + 'legacyManualArchived', TRUE, + 'legacyManualArchiveReason', 'no_primary_route_no_config_no_mapping', + 'legacyManualArchivedAt', NOW() + ), + updated_at = NOW() +FROM candidates +WHERE product.id = candidates.id; + +COMMENT ON COLUMN product_catalog.source_kind IS + '标准商品来源类型;来源不等于当前履约渠道,孤儿 man 商品只归档不删除'; + +-- --------------------------------------------------------------------------- +-- 履约路由硬切 +-- --------------------------------------------------------------------------- +-- 硬切换履约路由真源: +-- 1. 所有标准商品补齐 internal_sku_id; +-- 2. 运行时唯一路由真源改为 sku_channel_routes; +-- 3. 商品当前渠道不再回退 source_kind; +-- 4. 履约读模型不再读取 internal_sku_channels; +-- 5. 停止旧表触发同步,不再双写。 + +UPDATE product_catalog product +SET internal_sku_id = ensure_canonical_internal_sku(product.canonical_name), + updated_at = NOW() +WHERE product.internal_sku_id IS NULL + AND canonicalize_internal_sku(product.canonical_name) <> ''; + +CREATE OR REPLACE FUNCTION normalize_product_identity() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.source_kind := CASE + WHEN NEW.canonical_sku LIKE 'aff:%' THEN 'affiliate_dash' + WHEN NEW.canonical_sku LIKE 'tpl:%' OR NEW.canonical_sku LIKE 'wo:%' THEN 'work_order' + WHEN NEW.canonical_sku LIKE 'tpv:%' THEN 'third_party_voucher' + WHEN NEW.canonical_sku LIKE 'man:%' THEN 'manual' + ELSE COALESCE(NULLIF(NEW.source_kind, ''), 'unclassified') + END; + + IF NEW.internal_sku_id IS NULL THEN + NEW.internal_sku_id := ensure_canonical_internal_sku(NEW.canonical_name); + END IF; + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_sync_sku_channel_routes ON internal_sku_channels; +DROP FUNCTION IF EXISTS sync_sku_channel_routes_trigger(); +DROP FUNCTION IF EXISTS sync_sku_channel_routes_for_key(TEXT); + +COMMENT ON TABLE internal_sku_channels IS + '冻结的旧路由输入表;本迁移后运行时不读不写,仅保留历史数据供追溯'; + +CREATE OR REPLACE VIEW v_product_current AS +SELECT + product.*, + sku.internal_sku_key, + primary_route.channel AS active_primary_channel, + COALESCE(fallback_routes.channels, ARRAY[]::text[]) AS fallback_channels, + manual_config.id AS manual_config_id, + (manual_config.id IS NOT NULL) AS has_active_manual_config +FROM product_catalog product +LEFT JOIN internal_skus sku ON sku.id = product.internal_sku_id +LEFT JOIN LATERAL ( + SELECT route.channel + FROM sku_channel_routes route + WHERE route.internal_sku_id = product.internal_sku_id + AND route.enabled = TRUE + AND route.route_role = 'primary' + ORDER BY route.priority, route.channel + LIMIT 1 +) primary_route ON TRUE +LEFT JOIN LATERAL ( + SELECT ARRAY_AGG(route.channel ORDER BY route.priority, route.channel) AS channels + FROM sku_channel_routes route + WHERE route.internal_sku_id = product.internal_sku_id + AND route.enabled = TRUE + AND route.route_role = 'fallback' +) fallback_routes ON TRUE +LEFT JOIN LATERAL ( + SELECT config.id + FROM manual_fulfillment_configs config + WHERE config.enabled = TRUE + AND ( + config.canonical_product_id = product.id + OR ( + sku.internal_sku_key IS NOT NULL + AND canonicalize_internal_sku(config.sku_key) = sku.internal_sku_key + ) + ) + ORDER BY config.id + LIMIT 1 +) manual_config ON TRUE; + +COMMENT ON VIEW v_product_current IS + '标准商品统一投影:来源与当前渠道分离,当前渠道只来自 sku_channel_routes'; + +CREATE OR REPLACE VIEW v_fulfillment_item_state AS +WITH active_items AS ( + SELECT + oi.*, + resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku + FROM order_items oi + WHERE oi.fulfillment_status IN ('pending', 'assigned') +), +base AS ( + SELECT + oi.id AS order_item_id, + oi.order_id, + o.id AS local_order_id, + o.provider, + o.platform, + o.shop_id, + o.shop_name, + o.platform_order_id, + o.pay_status, + o.order_status, + oi.fulfillment_status AS item_fulfillment_status, + oi.fulfillment_channel AS stored_channel, + oi.fulfillment_source, + oi.fulfillment_locked, + oi.fulfillment_pending_reason, + oi.fulfillment_pending_detail, + oi.sku_code, + oi.sku_name, + oi.quantity, + oi.revenue_amount_fen, + oi.created_at, + oi.updated_at, + oi.internal_sku, + channel_plan.channel AS configured_channel, + channel_plan.config_json AS configured_channel_config, + COALESCE(channel_stats.channel_count, 0) AS channel_count, + COALESCE(latest_task.task_id, 0) AS latest_task_id, + latest_task.executor_key AS latest_task_executor_key, + latest_task.task_status AS latest_task_status, + latest_task.result_code AS latest_task_result_code, + latest_task.result_message AS latest_task_result_message, + latest_task.updated_at AS latest_task_updated_at, + COALESCE(latest_work_order.work_order_id, 0) AS latest_work_order_id, + latest_work_order.status AS latest_work_order_status, + latest_work_order.result_code AS latest_work_order_result_code, + latest_work_order.updated_at AS latest_work_order_updated_at, + EXISTS ( + SELECT 1 + FROM fulfillment_tasks active_task + WHERE active_task.order_item_id = oi.id + AND active_task.executor_key IN ('affiliate_dash', 'manual_dispatch') + AND active_task.task_status NOT IN ( + 'closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled' + ) + ) AS has_active_task, + EXISTS ( + SELECT 1 + FROM work_orders active_work_order + WHERE active_work_order.order_item_id = oi.id + AND active_work_order.status <> 'cancelled' + ) AS has_active_work_order, + EXISTS ( + SELECT 1 + FROM manual_fulfillment_configs cfg + WHERE cfg.enabled = TRUE + AND canonicalize_internal_sku(cfg.sku_key) = oi.internal_sku + ) AS has_manual_config + FROM active_items oi + JOIN orders o ON o.id = oi.order_id + LEFT JOIN LATERAL ( + SELECT route.channel, route.carrier_config_json AS config_json + FROM sku_channel_routes route + JOIN internal_skus sku ON sku.id = route.internal_sku_id + WHERE route.enabled = TRUE + AND sku.internal_sku_key = oi.internal_sku + ORDER BY + (route.route_role = 'fallback'), + route.priority, + route.channel, + route.id + LIMIT 1 + ) channel_plan ON TRUE + LEFT JOIN LATERAL ( + SELECT COUNT(*)::int AS channel_count + FROM sku_channel_routes route + JOIN internal_skus sku ON sku.id = route.internal_sku_id + WHERE route.enabled = TRUE + AND sku.internal_sku_key = oi.internal_sku + ) channel_stats ON TRUE + LEFT JOIN LATERAL ( + SELECT + task.id AS task_id, + task.executor_key, + task.task_status, + task.result_code, + task.result_message, + task.updated_at + FROM fulfillment_tasks task + WHERE task.order_item_id = oi.id + ORDER BY task.id DESC + LIMIT 1 + ) latest_task ON TRUE + LEFT JOIN LATERAL ( + SELECT + work_order.id AS work_order_id, + work_order.status, + ''::text AS result_code, + work_order.updated_at + FROM work_orders work_order + WHERE work_order.order_item_id = oi.id + ORDER BY work_order.id DESC + LIMIT 1 + ) latest_work_order ON TRUE +), +classified AS ( + SELECT + base.*, + COALESCE(base.stored_channel, base.configured_channel) AS intended_channel, + CASE + WHEN base.pay_status = 'refunded' + OR base.order_status IN ('refunded', 'cancelled') + OR base.latest_task_result_code = 'USER_APPLY_REFUND' + THEN TRUE + ELSE FALSE + END AS is_user_refund, + CASE + WHEN base.pay_status = 'cancelled' + OR base.order_status = 'cancelled' + OR base.latest_task_result_code = 'affiliate_dash_cancelled' + THEN TRUE + ELSE FALSE + END AS is_platform_cancelled + FROM base +), +current_attempt AS ( + SELECT + classified.*, + CASE + WHEN classified.latest_task_id > 0 + AND ( + classified.latest_work_order_id = 0 + OR classified.latest_task_updated_at >= classified.latest_work_order_updated_at + ) + THEN 'task' + WHEN classified.latest_work_order_id > 0 THEN 'work_order' + ELSE '' + END AS current_attempt_type, + CASE + WHEN classified.latest_task_id > 0 + AND ( + classified.latest_work_order_id = 0 + OR classified.latest_task_updated_at >= classified.latest_work_order_updated_at + ) + THEN classified.latest_task_id + WHEN classified.latest_work_order_id > 0 THEN classified.latest_work_order_id + ELSE 0 + END AS current_attempt_id, + CASE + WHEN classified.latest_task_id > 0 + AND ( + classified.latest_work_order_id = 0 + OR classified.latest_task_updated_at >= classified.latest_work_order_updated_at + ) + THEN classified.latest_task_status + WHEN classified.latest_work_order_id > 0 THEN classified.latest_work_order_status + ELSE '' + END AS current_attempt_status, + CASE + WHEN classified.latest_task_id > 0 + AND ( + classified.latest_work_order_id = 0 + OR classified.latest_task_updated_at >= classified.latest_work_order_updated_at + ) + THEN classified.latest_task_result_code + WHEN classified.latest_work_order_id > 0 THEN classified.latest_work_order_result_code + ELSE '' + END AS current_attempt_result_code + FROM classified +), +state_classified AS ( + SELECT + current_attempt.*, + CASE + WHEN current_attempt.intended_channel IN ('third_party_voucher', 'on_account') + THEN 'not_required' + WHEN current_attempt.intended_channel = 'manual' + AND current_attempt.has_manual_config + THEN 'ready' + WHEN current_attempt.has_active_task OR current_attempt.has_active_work_order + THEN 'ready' + WHEN current_attempt.latest_task_status IN ( + 'closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled' + ) + THEN 'terminal' + WHEN current_attempt.latest_work_order_status = 'cancelled' + THEN 'terminal' + WHEN current_attempt.intended_channel IS NOT NULL + THEN 'missing' + ELSE 'missing' + END AS carrier_status, + CASE + WHEN COALESCE(current_attempt.internal_sku, '') = '' + THEN 'missing_internal_sku' + WHEN current_attempt.channel_count = 0 + THEN 'missing_channel' + ELSE 'configured' + END AS route_status + FROM current_attempt +), +issue_classified AS ( + SELECT + state_classified.*, + CASE + WHEN state_classified.is_user_refund THEN 'user_refund' + WHEN state_classified.is_platform_cancelled THEN 'platform_cancelled' + WHEN state_classified.route_status = 'missing_internal_sku' THEN 'missing_internal_sku' + WHEN state_classified.route_status = 'missing_channel' THEN 'missing_channel' + WHEN state_classified.intended_channel = 'manual' + AND NOT state_classified.has_manual_config + THEN 'manual_config_missing' + WHEN state_classified.intended_channel = 'work_order' + AND BTRIM(COALESCE( + state_classified.configured_channel_config->>'workRuleKey', + '' + )) = '' + THEN 'work_rule_missing' + WHEN state_classified.intended_channel = 'affiliate_dash' + AND BTRIM(COALESCE( + state_classified.configured_channel_config->>'affiliateSku', + '' + )) = '' + THEN 'affiliate_mapping_missing' + WHEN state_classified.carrier_status IN ('ready', 'not_required') THEN '' + WHEN state_classified.latest_task_status = 'manual_review' + THEN 'carrier_manual_review' + WHEN state_classified.latest_task_status IN ('failed', 'retry_pending') + THEN 'carrier_retry_pending' + WHEN state_classified.latest_task_status = 'expired' THEN 'carrier_expired' + WHEN state_classified.latest_task_status = 'closed' + OR state_classified.latest_work_order_status = 'cancelled' + THEN 'carrier_closed' + WHEN state_classified.item_fulfillment_status = 'assigned' + THEN 'state_inconsistent' + WHEN state_classified.intended_channel IN ( + 'affiliate_dash', 'work_order', 'manual' + ) + THEN 'carrier_missing' + ELSE 'state_inconsistent' + END AS issue_code + FROM state_classified +), +categorized AS ( + SELECT + issue_classified.*, + CASE + WHEN issue_code = '' THEN '' + WHEN issue_code IN ('user_refund', 'platform_cancelled') THEN 'after_sales' + WHEN issue_code = 'carrier_manual_review' THEN 'manual_review' + WHEN issue_code IN ('missing_internal_sku', 'missing_channel') THEN 'classification' + WHEN issue_code IN ( + 'manual_config_missing', 'work_rule_missing', 'affiliate_mapping_missing' + ) THEN 'configuration' + WHEN issue_code = 'state_inconsistent' THEN 'system' + ELSE 'carrier' + END AS issue_category + FROM issue_classified +) +SELECT + order_item_id, + order_id, + local_order_id, + provider, + platform, + shop_id, + shop_name, + platform_order_id, + pay_status, + order_status, + item_fulfillment_status AS fulfillment_status, + internal_sku, + intended_channel, + stored_channel, + configured_channel, + route_status, + carrier_status, + current_attempt_type, + current_attempt_id, + current_attempt_status, + current_attempt_result_code, + latest_task_id, + latest_task_executor_key, + latest_task_status, + latest_task_result_code, + latest_work_order_id, + latest_work_order_status, + issue_code, + issue_category, + CASE + WHEN issue_code = '' THEN FALSE + WHEN issue_code IN ( + 'user_refund', 'platform_cancelled', 'carrier_manual_review', + 'manual_config_missing', 'work_rule_missing', 'affiliate_mapping_missing', + 'missing_internal_sku', 'missing_channel' + ) THEN FALSE + ELSE TRUE + END AS retryable, + CASE + WHEN issue_code IN ('carrier_missing', 'state_inconsistent') THEN TRUE + ELSE FALSE + END AS auto_retryable, + CASE + WHEN issue_code IN ('user_refund', 'platform_cancelled', 'carrier_manual_review') + THEN 'support' + WHEN issue_code IN ( + 'missing_internal_sku', 'missing_channel', 'manual_config_missing', + 'work_rule_missing', 'affiliate_mapping_missing' + ) THEN 'operator' + WHEN issue_code = '' THEN '' + ELSE 'system' + END AS owner_role, + fulfillment_locked, + CASE + WHEN NOT fulfillment_locked THEN '' + ELSE COALESCE( + NULLIF(fulfillment_pending_detail->>'carrierIssue', ''), + NULLIF(fulfillment_pending_detail->>'reason', ''), + 'operator_assigned' + ) + END AS lock_reason, + fulfillment_pending_reason, + fulfillment_pending_detail, + fulfillment_source, + sku_code, + sku_name, + quantity, + revenue_amount_fen, + created_at, + updated_at, + GREATEST( + 0, + FLOOR(EXTRACT(EPOCH FROM (NOW() - created_at)) / 60) + )::bigint AS age_minutes +FROM categorized; + +COMMENT ON VIEW v_fulfillment_item_state IS + '履约统一读模型:每个订单项一行,仅读取 sku_channel_routes 的当前路由'; + +-- --------------------------------------------------------------------------- +-- 影子同步迁移到新路由 +-- --------------------------------------------------------------------------- +-- 路由唯一真源已经切换到 sku_channel_routes。 +-- 履约影子模型必须跟随新表变化,旧表不再拥有任何同步触发器。 + +DROP TRIGGER IF EXISTS trg_internal_sku_channel_shadow ON internal_sku_channels; +DROP FUNCTION IF EXISTS sync_fulfillment_shadow_for_internal_channel(); + +CREATE OR REPLACE FUNCTION sync_fulfillment_shadow_for_sku_route() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + old_key TEXT; + new_key TEXT; +BEGIN + IF TG_OP <> 'INSERT' THEN + SELECT internal_sku_key INTO old_key + FROM internal_skus + WHERE id = OLD.internal_sku_id; + END IF; + IF TG_OP <> 'DELETE' THEN + SELECT internal_sku_key INTO new_key + FROM internal_skus + WHERE id = NEW.internal_sku_id; + END IF; + + PERFORM sync_fulfillment_shadow(item.id) + FROM order_items item + WHERE item.fulfillment_status IN ('pending', 'assigned') + AND canonicalize_internal_sku( + resolve_internal_sku(item.manual_sku_key, item.sku_name, item.sku_code) + ) IN (old_key, new_key); + + RETURN COALESCE(NEW, OLD); +END; +$$; + +DROP TRIGGER IF EXISTS trg_sku_route_fulfillment_shadow ON sku_channel_routes; +CREATE TRIGGER trg_sku_route_fulfillment_shadow +AFTER INSERT OR UPDATE OR DELETE ON sku_channel_routes +FOR EACH ROW +EXECUTE FUNCTION sync_fulfillment_shadow_for_sku_route(); + +COMMENT ON FUNCTION sync_fulfillment_shadow_for_sku_route() IS + 'sku_channel_routes 变化后刷新同内部 SKU 的履约影子'; + +-- --------------------------------------------------------------------------- +-- 下线旧路由读取面 +-- --------------------------------------------------------------------------- +-- 前序路由硬切段已把业务读模型和影子同步切到 sku_channel_routes。 +-- 这里删除最后一个旧表只读视图,以及旧表最后一个规范化触发器。 + +DROP VIEW IF EXISTS v_fulfillment_item_state_raw; + +DROP TRIGGER IF EXISTS trg_normalize_internal_sku_channel_key ON internal_sku_channels; +DROP FUNCTION IF EXISTS normalize_internal_sku_channel_key(); + +COMMENT ON TABLE internal_sku_channels IS + '冻结的历史路由输入;无触发器、无运行时读取、无运行时写入'; + +-- --------------------------------------------------------------------------- +-- 收紧商品来源约束 +-- --------------------------------------------------------------------------- +-- 商品来源统一由 product_catalog.source_kind 表达。 +-- 运行期不再解析 canonical_sku 前缀;数据库同步收紧允许值。 + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'chk_product_catalog_source_kind' + ) THEN + ALTER TABLE product_catalog + ADD CONSTRAINT chk_product_catalog_source_kind + CHECK ( + source_kind IN ( + 'affiliate_dash', + 'work_order', + 'third_party_voucher', + 'manual', + 'unclassified' + ) + ) + NOT VALID; + END IF; +END $$; + +ALTER TABLE product_catalog + VALIDATE CONSTRAINT chk_product_catalog_source_kind; diff --git a/apps/backend/src/db/migrations/148_fulfillment_execution_truth.sql b/apps/backend/src/db/migrations/148_fulfillment_execution_truth.sql new file mode 100644 index 00000000..f5cc1230 --- /dev/null +++ b/apps/backend/src/db/migrations/148_fulfillment_execution_truth.sql @@ -0,0 +1,1456 @@ +-- 148_fulfillment_execution_truth.sql +-- +-- 正式履约执行真源硬切: +-- 1. fulfillments 成为状态、渠道、载体、配置和当前尝试的唯一真源; +-- 2. 任务与工单只追加 fulfillment_attempts; +-- 3. 订单项旧履约列冻结为 legacy_*,统一读取 v_order_item_current; +-- 4. 履约事件、外键级联和无法解析内部 SKU 的口径一次收口。 +-- +-- 合并自未上生产的 163–166。 + +-- --------------------------------------------------------------------------- +-- 履约状态硬切 +-- --------------------------------------------------------------------------- +-- 履约状态硬切: +-- 1. fulfillments 成为履约状态、渠道、配置关联和当前尝试的唯一真源; +-- 2. order_items 只保留原始订单项数据,旧履约列改名冻结,不再读写; +-- 3. 任务/工单只追加 fulfillment_attempts,不再回写订单项; +-- 4. v_fulfillment_item_state 和 v_order_item_current 均直接投影正式履约表。 + +ALTER TABLE fulfillments + ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'pending', + ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS locked BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS pending_reason TEXT, + ADD COLUMN IF NOT EXISTS pending_detail JSONB, + ADD COLUMN IF NOT EXISTS reason TEXT, + ADD COLUMN IF NOT EXISTS decided_by BIGINT REFERENCES admin_users(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS decided_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS original_channel TEXT, + ADD COLUMN IF NOT EXISTS channel_switch_reason TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS channel_switched_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS fulfillment_config_id BIGINT + REFERENCES manual_fulfillment_configs(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS canonical_product_id BIGINT + REFERENCES product_catalog(id) ON DELETE RESTRICT; + +ALTER TABLE fulfillments + DROP CONSTRAINT IF EXISTS chk_fulfillments_status, + DROP CONSTRAINT IF EXISTS chk_fulfillments_status_channel, + DROP CONSTRAINT IF EXISTS chk_fulfillments_original_channel; + +CREATE INDEX IF NOT EXISTS idx_fulfillments_status_channel + ON fulfillments (status, channel, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_fulfillments_config + ON fulfillments (fulfillment_config_id) + WHERE fulfillment_config_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_fulfillments_product + ON fulfillments (canonical_product_id) + WHERE canonical_product_id IS NOT NULL; + +-- 老状态列只在本次迁移中读取一次,之后作为 legacy_* 冻结留档。 +ALTER TABLE order_items + DROP CONSTRAINT IF EXISTS chk_order_items_fulfillment_channel, + DROP CONSTRAINT IF EXISTS chk_order_items_fulfillment_status, + DROP CONSTRAINT IF EXISTS chk_order_items_fulfillment_channel_status, + DROP CONSTRAINT IF EXISTS chk_order_items_fulfillment_source; + +DROP INDEX IF EXISTS idx_order_items_active_fulfillment_state; +DROP INDEX IF EXISTS idx_order_items_canonical_product; +DROP INDEX IF EXISTS idx_order_items_carrier_check_id; +DROP INDEX IF EXISTS idx_order_items_channel_product; +DROP INDEX IF EXISTS idx_order_items_cost_ready_id; +DROP INDEX IF EXISTS idx_order_items_fulfillment_channel_status; +DROP INDEX IF EXISTS idx_order_items_fulfillment_pending_id_v2; +DROP INDEX IF EXISTS idx_order_items_manual_configured_id; +DROP INDEX IF EXISTS idx_order_items_manual_pending_config_id; +DROP INDEX IF EXISTS idx_order_items_manual_sku_key_id; +DROP INDEX IF EXISTS idx_order_items_pending_internal_sku; +DROP INDEX IF EXISTS idx_order_items_pending_reason; +DROP INDEX IF EXISTS idx_order_items_unmapped_id; + +DROP VIEW IF EXISTS v_fulfillment_item_state; + +INSERT INTO fulfillments ( + order_item_id, + order_id, + internal_sku, + route_status, + channel, + carrier_status, + current_attempt_type, + current_attempt_source_id, + current_attempt_status, + lock_reason, + issue_code, + issue_category, + retryable, + owner_role, + created_at, + updated_at, + status, + source, + locked, + pending_reason, + pending_detail, + reason, + decided_by, + decided_at, + original_channel, + channel_switch_reason, + channel_switched_at, + fulfillment_config_id, + canonical_product_id +) +SELECT + oi.id, + oi.order_id, + COALESCE(resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code), ''), + 'unrouted', + oi.fulfillment_channel, + 'missing', + '', + NULL, + '', + '', + '', + '', + FALSE, + '', + oi.created_at, + NOW(), + oi.fulfillment_status, + oi.fulfillment_source, + oi.fulfillment_locked, + oi.fulfillment_pending_reason, + oi.fulfillment_pending_detail, + oi.fulfillment_reason, + oi.fulfillment_decided_by, + oi.fulfillment_decided_at, + oi.original_channel, + oi.channel_switch_reason, + oi.channel_switched_at, + oi.fulfillment_config_id, + oi.canonical_product_id +FROM order_items oi +ON CONFLICT (order_item_id) DO NOTHING; + +UPDATE fulfillments fulfillment +SET + order_id = item.order_id, + internal_sku = COALESCE( + resolve_internal_sku(item.manual_sku_key, item.sku_name, item.sku_code), + '' + ), + channel = item.fulfillment_channel, + status = item.fulfillment_status, + source = item.fulfillment_source, + locked = item.fulfillment_locked, + pending_reason = item.fulfillment_pending_reason, + pending_detail = item.fulfillment_pending_detail, + reason = item.fulfillment_reason, + decided_by = item.fulfillment_decided_by, + decided_at = item.fulfillment_decided_at, + original_channel = item.original_channel, + channel_switch_reason = item.channel_switch_reason, + channel_switched_at = item.channel_switched_at, + fulfillment_config_id = item.fulfillment_config_id, + canonical_product_id = item.canonical_product_id, + updated_at = NOW() +FROM order_items item +WHERE item.id = fulfillment.order_item_id; + +UPDATE fulfillments +SET original_channel = NULL +WHERE original_channel IS NOT NULL + AND original_channel NOT IN ( + 'work_order', 'affiliate_dash', 'third_party_voucher', 'manual', 'on_account' + ); + +ALTER TABLE fulfillments + ADD CONSTRAINT chk_fulfillments_status + CHECK (status IN ('pending', 'assigned', 'archived')), + ADD CONSTRAINT chk_fulfillments_status_channel + CHECK ( + (status = 'pending' AND channel IS NULL) + OR (status = 'assigned' AND channel IS NOT NULL) + OR status = 'archived' + ), + ADD CONSTRAINT chk_fulfillments_original_channel + CHECK ( + original_channel IS NULL + OR original_channel IN ( + 'work_order', 'affiliate_dash', 'third_party_voucher', 'manual', 'on_account' + ) + ); + +-- 补齐历史尝试;已有尝试保留,仅追加缺失的真实任务/工单。 +WITH source_attempts AS ( + SELECT + task.order_item_id, + task.unit_index, + task.created_at AS occurred_at, + 'task'::text AS attempt_type, + task.id AS source_id, + task.attempt_no AS source_attempt_no, + task.executor_key, + task.task_status AS status, + task.result_code, + task.result_message, + task.created_at AS started_at, + CASE + WHEN task.task_status IN ( + 'closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled' + ) THEN task.updated_at + ELSE NULL + END AS finished_at, + task.is_current, + task.context_json, + task.created_at, + task.updated_at + FROM fulfillment_tasks task + + UNION ALL + + SELECT + work_order.order_item_id, + 1, + work_order.created_at, + 'work_order'::text, + work_order.id, + 1, + 'work_order'::text, + work_order.status, + ''::text, + work_order.problem_note, + work_order.created_at, + CASE + WHEN work_order.status IN ('accepted', 'cancelled', 'closed') THEN work_order.updated_at + ELSE NULL + END, + work_order.status <> 'cancelled', + jsonb_build_object( + 'workOrderNo', work_order.work_order_no, + 'productName', work_order.product_name + ), + work_order.created_at, + work_order.updated_at + FROM work_orders work_order + WHERE work_order.order_item_id IS NOT NULL +), +ranked AS ( + SELECT + source_attempts.*, + ROW_NUMBER() OVER ( + PARTITION BY order_item_id, unit_index + ORDER BY occurred_at, attempt_type, source_id + )::int AS attempt_no + FROM source_attempts +) +INSERT INTO fulfillment_attempts ( + fulfillment_id, + unit_index, + attempt_no, + attempt_type, + source_id, + source_attempt_no, + executor_key, + status, + result_code, + result_message, + started_at, + finished_at, + is_current, + context_json, + created_at, + updated_at +) +SELECT + fulfillment.id, + ranked.unit_index, + ranked.attempt_no, + ranked.attempt_type, + ranked.source_id, + ranked.source_attempt_no, + ranked.executor_key, + ranked.status, + ranked.result_code, + ranked.result_message, + ranked.started_at, + ranked.finished_at, + FALSE, + ranked.context_json, + ranked.created_at, + ranked.updated_at +FROM ranked +JOIN fulfillments fulfillment ON fulfillment.order_item_id = ranked.order_item_id +ON CONFLICT (attempt_type, source_id) DO NOTHING; + +CREATE OR REPLACE FUNCTION refresh_fulfillment_derived(p_order_item_id BIGINT) +RETURNS VOID +LANGUAGE plpgsql +AS $$ +DECLARE + state_record RECORD; + route_status_value TEXT; + carrier_status_value TEXT; + issue_code_value TEXT := ''; + issue_category_value TEXT := ''; + retryable_value BOOLEAN := FALSE; + owner_role_value TEXT := ''; + severity_value TEXT := 'warning'; +BEGIN + SELECT + fulfillment.id AS fulfillment_id, + fulfillment.status, + fulfillment.channel, + fulfillment.pending_detail, + fulfillment.fulfillment_config_id, + item.id AS order_item_id, + item.order_id, + resolve_internal_sku(item.manual_sku_key, item.sku_name, item.sku_code) AS internal_sku, + order_record.pay_status, + order_record.order_status, + route.channel AS configured_channel, + route.carrier_config_json AS configured_channel_config, + attempt.attempt_type, + attempt.source_id, + attempt.status AS attempt_status, + attempt.result_code AS attempt_result_code, + EXISTS ( + SELECT 1 + FROM sku_channel_routes primary_route + JOIN internal_skus sku ON sku.id = primary_route.internal_sku_id + WHERE primary_route.enabled = TRUE + AND primary_route.route_role = 'primary' + AND sku.internal_sku_key = + resolve_internal_sku(item.manual_sku_key, item.sku_name, item.sku_code) + ) AS has_primary_route, + EXISTS ( + SELECT 1 + FROM manual_fulfillment_configs config + WHERE config.enabled = TRUE + AND ( + config.id = fulfillment.fulfillment_config_id + OR ( + route.channel = 'manual' + AND canonicalize_internal_sku(config.sku_key) = + resolve_internal_sku(item.manual_sku_key, item.sku_name, item.sku_code) + ) + ) + ) AS has_manual_config + INTO state_record + FROM fulfillments fulfillment + JOIN order_items item ON item.id = fulfillment.order_item_id + JOIN orders order_record ON order_record.id = item.order_id + LEFT JOIN LATERAL ( + SELECT + channel_route.channel, + channel_route.carrier_config_json + FROM sku_channel_routes channel_route + JOIN internal_skus sku ON sku.id = channel_route.internal_sku_id + WHERE channel_route.enabled = TRUE + AND sku.internal_sku_key = + resolve_internal_sku(item.manual_sku_key, item.sku_name, item.sku_code) + ORDER BY + (channel_route.route_role = 'fallback'), + channel_route.priority, + channel_route.channel, + channel_route.id + LIMIT 1 + ) route ON TRUE + LEFT JOIN LATERAL ( + SELECT + current_attempt.attempt_type, + current_attempt.source_id, + current_attempt.status, + current_attempt.result_code + FROM fulfillment_attempts current_attempt + WHERE current_attempt.fulfillment_id = fulfillment.id + ORDER BY + current_attempt.is_current DESC, + current_attempt.updated_at DESC, + current_attempt.id DESC + LIMIT 1 + ) attempt ON TRUE + WHERE fulfillment.order_item_id = p_order_item_id; + + IF NOT FOUND THEN + RETURN; + END IF; + + route_status_value := CASE + WHEN COALESCE(state_record.internal_sku, '') = '' THEN 'unrouted' + WHEN state_record.channel IS NOT NULL OR state_record.has_primary_route THEN 'configured' + ELSE 'unrouted' + END; + + carrier_status_value := CASE + WHEN state_record.channel IN ('third_party_voucher', 'on_account') THEN 'not_required' + WHEN state_record.channel = 'manual' AND state_record.has_manual_config THEN 'ready' + WHEN state_record.attempt_status NOT IN ( + '', 'closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled' + ) AND state_record.attempt_status IS NOT NULL THEN 'ready' + WHEN state_record.attempt_status IS NOT NULL + AND state_record.attempt_status <> '' THEN 'terminal' + WHEN state_record.channel IS NOT NULL THEN 'missing' + ELSE 'missing' + END; + + IF state_record.status <> 'archived' THEN + IF state_record.pay_status = 'refunded' + OR state_record.order_status = 'refunded' + OR state_record.attempt_result_code = 'USER_APPLY_REFUND' THEN + issue_code_value := 'user_refund'; + ELSIF state_record.pay_status = 'cancelled' + OR state_record.order_status = 'cancelled' + OR state_record.attempt_result_code = 'affiliate_dash_cancelled' THEN + issue_code_value := 'platform_cancelled'; + ELSIF COALESCE(state_record.internal_sku, '') = '' THEN + issue_code_value := 'missing_internal_sku'; + ELSIF route_status_value = 'unrouted' THEN + issue_code_value := 'missing_channel'; + ELSIF state_record.channel = 'manual' AND NOT state_record.has_manual_config THEN + issue_code_value := 'manual_config_missing'; + ELSIF state_record.configured_channel = 'work_order' + AND BTRIM(COALESCE(state_record.configured_channel_config->>'workRuleKey', '')) = '' THEN + issue_code_value := 'work_rule_missing'; + ELSIF state_record.configured_channel = 'affiliate_dash' + AND BTRIM(COALESCE(state_record.configured_channel_config->>'affiliateSku', '')) = '' THEN + issue_code_value := 'affiliate_mapping_missing'; + ELSIF carrier_status_value IN ('ready', 'not_required') THEN + issue_code_value := ''; + ELSIF state_record.attempt_status = 'manual_review' THEN + issue_code_value := 'carrier_manual_review'; + ELSIF state_record.attempt_status IN ('failed', 'retry_pending') THEN + issue_code_value := 'carrier_retry_pending'; + ELSIF state_record.attempt_status = 'expired' THEN + issue_code_value := 'carrier_expired'; + ELSIF state_record.attempt_status IN ('closed', 'cancelled') THEN + issue_code_value := 'carrier_closed'; + ELSIF state_record.status = 'assigned' AND state_record.channel IS NOT NULL THEN + issue_code_value := 'carrier_missing'; + ELSE + issue_code_value := 'state_inconsistent'; + END IF; + END IF; + + issue_category_value := CASE + WHEN issue_code_value = '' THEN '' + WHEN issue_code_value IN ('user_refund', 'platform_cancelled') THEN 'after_sales' + WHEN issue_code_value = 'carrier_manual_review' THEN 'manual_review' + WHEN issue_code_value IN ('missing_internal_sku', 'missing_channel') THEN 'classification' + WHEN issue_code_value IN ( + 'manual_config_missing', 'work_rule_missing', 'affiliate_mapping_missing' + ) THEN 'configuration' + WHEN issue_code_value = 'state_inconsistent' THEN 'system' + ELSE 'carrier' + END; + + retryable_value := issue_code_value NOT IN ( + '', 'user_refund', 'platform_cancelled', 'carrier_manual_review', + 'manual_config_missing', 'work_rule_missing', 'affiliate_mapping_missing', + 'missing_internal_sku', 'missing_channel' + ); + owner_role_value := CASE + WHEN issue_code_value IN ('user_refund', 'platform_cancelled', 'carrier_manual_review') + THEN 'support' + WHEN issue_code_value IN ( + 'missing_internal_sku', 'missing_channel', 'manual_config_missing', + 'work_rule_missing', 'affiliate_mapping_missing' + ) THEN 'operator' + WHEN issue_code_value = '' THEN '' + ELSE 'system' + END; + severity_value := CASE + WHEN issue_category_value IN ('after_sales', 'system') THEN 'error' + WHEN issue_category_value = 'carrier' THEN 'warning' + ELSE 'info' + END; + + UPDATE fulfillments + SET + internal_sku = COALESCE(state_record.internal_sku, ''), + route_status = route_status_value, + carrier_status = carrier_status_value, + current_attempt_type = COALESCE(state_record.attempt_type, ''), + current_attempt_source_id = state_record.source_id, + current_attempt_status = COALESCE(state_record.attempt_status, ''), + lock_reason = CASE + WHEN NOT locked THEN '' + ELSE COALESCE( + NULLIF(pending_detail->>'carrierIssue', ''), + NULLIF(pending_detail->>'reason', ''), + 'operator_assigned' + ) + END, + issue_code = issue_code_value, + issue_category = issue_category_value, + retryable = retryable_value, + owner_role = owner_role_value, + updated_at = NOW() + WHERE id = state_record.fulfillment_id; + + IF issue_code_value <> '' THEN + INSERT INTO fulfillment_issues ( + fulfillment_id, + issue_code, + issue_category, + severity, + source, + retryable, + auto_retryable, + owner_role, + status, + detail_json, + first_seen_at, + last_seen_at, + resolved_at + ) VALUES ( + state_record.fulfillment_id, + issue_code_value, + issue_category_value, + severity_value, + 'fulfillment_projection', + retryable_value, + issue_code_value IN ('carrier_missing', 'state_inconsistent'), + owner_role_value, + 'open', + COALESCE(state_record.pending_detail, '{}'::jsonb), + NOW(), + NOW(), + NULL + ) + ON CONFLICT (fulfillment_id, issue_code) DO UPDATE SET + issue_category = EXCLUDED.issue_category, + severity = EXCLUDED.severity, + source = EXCLUDED.source, + retryable = EXCLUDED.retryable, + auto_retryable = EXCLUDED.auto_retryable, + owner_role = EXCLUDED.owner_role, + status = 'open', + detail_json = EXCLUDED.detail_json, + last_seen_at = NOW(), + resolved_at = NULL; + + UPDATE fulfillment_issues + SET status = 'resolved', + resolved_at = NOW() + WHERE fulfillment_id = state_record.fulfillment_id + AND issue_code <> issue_code_value + AND status <> 'resolved'; + ELSE + UPDATE fulfillment_issues + SET status = 'resolved', + resolved_at = NOW() + WHERE fulfillment_id = state_record.fulfillment_id + AND status <> 'resolved'; + END IF; +END; +$$; + +-- 旧影子同步及其触发器彻底移除。 +DROP TRIGGER IF EXISTS trg_order_items_fulfillment_shadow ON order_items; +DROP TRIGGER IF EXISTS trg_fulfillment_tasks_shadow ON fulfillment_tasks; +DROP TRIGGER IF EXISTS trg_work_orders_fulfillment_shadow ON work_orders; +DROP TRIGGER IF EXISTS trg_manual_fulfillment_config_shadow ON manual_fulfillment_configs; +DROP TRIGGER IF EXISTS trg_sku_route_fulfillment_shadow ON sku_channel_routes; +DROP TRIGGER IF EXISTS trg_fulfillment_state_events_shadow ON fulfillment_state_events; +DROP FUNCTION IF EXISTS sync_fulfillment_shadow(BIGINT); +DROP FUNCTION IF EXISTS sync_fulfillment_shadow_from_order_item(); +DROP FUNCTION IF EXISTS sync_fulfillment_task_attempt(); +DROP FUNCTION IF EXISTS sync_fulfillment_work_order_attempt(); +DROP FUNCTION IF EXISTS sync_fulfillment_shadow_for_manual_config(); +DROP FUNCTION IF EXISTS sync_fulfillment_shadow_for_sku_route(); +DROP FUNCTION IF EXISTS assign_fulfillment_event_shadow(); + +CREATE OR REPLACE FUNCTION ensure_fulfillment_for_order_item() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + INSERT INTO fulfillments ( + order_item_id, + order_id, + internal_sku, + route_status, + channel, + carrier_status, + current_attempt_type, + current_attempt_source_id, + current_attempt_status, + lock_reason, + issue_code, + issue_category, + retryable, + owner_role, + created_at, + updated_at, + status, + source, + locked, + channel_switch_reason + ) VALUES ( + NEW.id, + NEW.order_id, + COALESCE(resolve_internal_sku(NEW.manual_sku_key, NEW.sku_name, NEW.sku_code), ''), + 'unrouted', + NULL, + 'missing', + '', + NULL, + '', + '', + '', + '', + FALSE, + '', + NEW.created_at, + NOW(), + 'pending', + '', + FALSE, + '' + ) + ON CONFLICT (order_item_id) DO NOTHING; + + PERFORM refresh_fulfillment_derived(NEW.id); + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_order_items_create_fulfillment ON order_items; +CREATE TRIGGER trg_order_items_create_fulfillment +AFTER INSERT ON order_items +FOR EACH ROW +EXECUTE FUNCTION ensure_fulfillment_for_order_item(); + +CREATE OR REPLACE FUNCTION refresh_fulfillment_for_order_item_change() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + PERFORM refresh_fulfillment_derived(NEW.id); + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_order_items_refresh_fulfillment ON order_items; +CREATE TRIGGER trg_order_items_refresh_fulfillment +AFTER UPDATE OF manual_sku_key, sku_code, sku_name +ON order_items +FOR EACH ROW +EXECUTE FUNCTION refresh_fulfillment_for_order_item_change(); + +CREATE OR REPLACE FUNCTION refresh_fulfillments_for_order_change() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + PERFORM refresh_fulfillment_derived(item.id) + FROM order_items item + WHERE item.order_id = NEW.id; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_orders_refresh_fulfillments ON orders; +CREATE TRIGGER trg_orders_refresh_fulfillments +AFTER UPDATE OF pay_status, order_status +ON orders +FOR EACH ROW +EXECUTE FUNCTION refresh_fulfillments_for_order_change(); + +CREATE OR REPLACE FUNCTION refresh_fulfillments_for_configuration_change() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + old_key TEXT; + new_key TEXT; +BEGIN + IF TG_OP <> 'INSERT' THEN + old_key := canonicalize_internal_sku(OLD.sku_key); + END IF; + IF TG_OP <> 'DELETE' THEN + new_key := canonicalize_internal_sku(NEW.sku_key); + END IF; + + PERFORM refresh_fulfillment_derived(fulfillment.order_item_id) + FROM fulfillments fulfillment + WHERE canonicalize_internal_sku(fulfillment.internal_sku) IN (old_key, new_key); + + RETURN COALESCE(NEW, OLD); +END; +$$; + +DROP TRIGGER IF EXISTS trg_manual_config_refresh_fulfillments + ON manual_fulfillment_configs; +CREATE TRIGGER trg_manual_config_refresh_fulfillments +AFTER INSERT OR UPDATE OR DELETE ON manual_fulfillment_configs +FOR EACH ROW +EXECUTE FUNCTION refresh_fulfillments_for_configuration_change(); + +CREATE OR REPLACE FUNCTION refresh_fulfillments_for_sku_route_change() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + old_key TEXT; + new_key TEXT; +BEGIN + IF TG_OP <> 'INSERT' THEN + SELECT internal_sku_key INTO old_key + FROM internal_skus + WHERE id = OLD.internal_sku_id; + END IF; + IF TG_OP <> 'DELETE' THEN + SELECT internal_sku_key INTO new_key + FROM internal_skus + WHERE id = NEW.internal_sku_id; + END IF; + + PERFORM refresh_fulfillment_derived(fulfillment.order_item_id) + FROM fulfillments fulfillment + WHERE canonicalize_internal_sku(fulfillment.internal_sku) IN (old_key, new_key); + + RETURN COALESCE(NEW, OLD); +END; +$$; + +DROP TRIGGER IF EXISTS trg_sku_route_refresh_fulfillments ON sku_channel_routes; +CREATE TRIGGER trg_sku_route_refresh_fulfillments +AFTER INSERT OR UPDATE OR DELETE ON sku_channel_routes +FOR EACH ROW +EXECUTE FUNCTION refresh_fulfillments_for_sku_route_change(); + +CREATE OR REPLACE FUNCTION sync_fulfillment_task_attempt() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + fulfillment_id_value BIGINT; + next_attempt_no INTEGER; +BEGIN + IF TG_OP = 'DELETE' THEN + DELETE FROM fulfillment_attempts + WHERE attempt_type = 'task' AND source_id = OLD.id; + PERFORM refresh_fulfillment_derived(OLD.order_item_id); + RETURN OLD; + END IF; + + SELECT id INTO fulfillment_id_value + FROM fulfillments + WHERE order_item_id = NEW.order_item_id; + IF fulfillment_id_value IS NULL THEN + RETURN NEW; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM fulfillment_attempts + WHERE attempt_type = 'task' AND source_id = NEW.id + ) THEN + SELECT COALESCE(MAX(attempt_no), 0) + 1 + INTO next_attempt_no + FROM fulfillment_attempts + WHERE fulfillment_id = fulfillment_id_value + AND unit_index = NEW.unit_index; + END IF; + + IF NEW.is_current THEN + UPDATE fulfillment_attempts + SET is_current = FALSE, + updated_at = NOW() + WHERE fulfillment_id = fulfillment_id_value + AND unit_index = NEW.unit_index + AND is_current = TRUE + AND NOT (attempt_type = 'task' AND source_id = NEW.id); + END IF; + + INSERT INTO fulfillment_attempts ( + fulfillment_id, + unit_index, + attempt_no, + attempt_type, + source_id, + source_attempt_no, + executor_key, + status, + result_code, + result_message, + started_at, + finished_at, + is_current, + context_json, + created_at, + updated_at + ) VALUES ( + fulfillment_id_value, + NEW.unit_index, + COALESCE(next_attempt_no, 1), + 'task', + NEW.id, + NEW.attempt_no, + NEW.executor_key, + NEW.task_status, + NEW.result_code, + NEW.result_message, + NEW.created_at, + CASE + WHEN NEW.task_status IN ( + 'closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled' + ) THEN NEW.updated_at + ELSE NULL + END, + NEW.is_current, + NEW.context_json, + NEW.created_at, + NEW.updated_at + ) + ON CONFLICT (attempt_type, source_id) DO UPDATE SET + executor_key = EXCLUDED.executor_key, + status = EXCLUDED.status, + result_code = EXCLUDED.result_code, + result_message = EXCLUDED.result_message, + finished_at = EXCLUDED.finished_at, + is_current = EXCLUDED.is_current, + context_json = EXCLUDED.context_json, + updated_at = EXCLUDED.updated_at; + + PERFORM refresh_fulfillment_derived(NEW.order_item_id); + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_fulfillment_tasks_attempt ON fulfillment_tasks; +CREATE TRIGGER trg_fulfillment_tasks_attempt +AFTER INSERT OR UPDATE OR DELETE ON fulfillment_tasks +FOR EACH ROW +EXECUTE FUNCTION sync_fulfillment_task_attempt(); + +CREATE OR REPLACE FUNCTION sync_fulfillment_work_order_attempt() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + fulfillment_id_value BIGINT; + next_attempt_no INTEGER; + current_value BOOLEAN; +BEGIN + IF TG_OP = 'DELETE' THEN + DELETE FROM fulfillment_attempts + WHERE attempt_type = 'work_order' AND source_id = OLD.id; + IF OLD.order_item_id IS NOT NULL THEN + PERFORM refresh_fulfillment_derived(OLD.order_item_id); + END IF; + RETURN OLD; + END IF; + + IF NEW.order_item_id IS NULL THEN + RETURN NEW; + END IF; + + SELECT id INTO fulfillment_id_value + FROM fulfillments + WHERE order_item_id = NEW.order_item_id; + IF fulfillment_id_value IS NULL THEN + RETURN NEW; + END IF; + + current_value := NEW.status <> 'cancelled'; + + IF NOT EXISTS ( + SELECT 1 FROM fulfillment_attempts + WHERE attempt_type = 'work_order' AND source_id = NEW.id + ) THEN + SELECT COALESCE(MAX(attempt_no), 0) + 1 + INTO next_attempt_no + FROM fulfillment_attempts + WHERE fulfillment_id = fulfillment_id_value + AND unit_index = 1; + END IF; + + IF current_value THEN + UPDATE fulfillment_attempts + SET is_current = FALSE, + updated_at = NOW() + WHERE fulfillment_id = fulfillment_id_value + AND unit_index = 1 + AND is_current = TRUE + AND NOT (attempt_type = 'work_order' AND source_id = NEW.id); + END IF; + + INSERT INTO fulfillment_attempts ( + fulfillment_id, + unit_index, + attempt_no, + attempt_type, + source_id, + source_attempt_no, + executor_key, + status, + result_code, + result_message, + started_at, + finished_at, + is_current, + context_json, + created_at, + updated_at + ) VALUES ( + fulfillment_id_value, + 1, + COALESCE(next_attempt_no, 1), + 'work_order', + NEW.id, + 1, + 'work_order', + NEW.status, + '', + NEW.problem_note, + NEW.created_at, + CASE + WHEN NEW.status IN ('accepted', 'cancelled', 'closed') THEN NEW.updated_at + ELSE NULL + END, + current_value, + jsonb_build_object( + 'workOrderNo', NEW.work_order_no, + 'productName', NEW.product_name + ), + NEW.created_at, + NEW.updated_at + ) + ON CONFLICT (attempt_type, source_id) DO UPDATE SET + status = EXCLUDED.status, + result_message = EXCLUDED.result_message, + finished_at = EXCLUDED.finished_at, + is_current = EXCLUDED.is_current, + context_json = EXCLUDED.context_json, + updated_at = EXCLUDED.updated_at; + + PERFORM refresh_fulfillment_derived(NEW.order_item_id); + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_work_orders_attempt ON work_orders; +CREATE TRIGGER trg_work_orders_attempt +AFTER INSERT OR UPDATE OR DELETE ON work_orders +FOR EACH ROW +EXECUTE FUNCTION sync_fulfillment_work_order_attempt(); + +DROP TRIGGER IF EXISTS trg_order_items_fulfillment_state_event ON order_items; + +CREATE OR REPLACE FUNCTION record_fulfillment_state_event() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + event_type_value TEXT; + actor_type_value TEXT; + actor_id_value TEXT; + reason_value TEXT; + changed_fields JSONB; +BEGIN + IF ROW( + OLD.status, + OLD.channel, + OLD.source, + OLD.locked, + OLD.pending_reason, + OLD.pending_detail, + OLD.reason, + OLD.decided_by, + OLD.decided_at, + OLD.original_channel, + OLD.channel_switch_reason, + OLD.channel_switched_at, + OLD.fulfillment_config_id, + OLD.canonical_product_id + ) IS NOT DISTINCT FROM ROW( + NEW.status, + NEW.channel, + NEW.source, + NEW.locked, + NEW.pending_reason, + NEW.pending_detail, + NEW.reason, + NEW.decided_by, + NEW.decided_at, + NEW.original_channel, + NEW.channel_switch_reason, + NEW.channel_switched_at, + NEW.fulfillment_config_id, + NEW.canonical_product_id + ) THEN + RETURN NEW; + END IF; + + event_type_value := COALESCE( + NULLIF(BTRIM(current_setting('app.fulfillment_event_type', TRUE)), ''), + 'fulfillment_state_changed' + ); + actor_type_value := COALESCE( + NULLIF(BTRIM(current_setting('app.fulfillment_actor_type', TRUE)), ''), + 'system' + ); + actor_id_value := COALESCE(current_setting('app.fulfillment_actor_id', TRUE), ''); + reason_value := COALESCE(current_setting('app.fulfillment_reason', TRUE), ''); + + changed_fields := jsonb_strip_nulls(jsonb_build_object( + 'fulfillmentStatus', CASE + WHEN OLD.status IS DISTINCT FROM NEW.status + THEN jsonb_build_object('from', OLD.status, 'to', NEW.status) + END, + 'fulfillmentChannel', CASE + WHEN OLD.channel IS DISTINCT FROM NEW.channel + THEN jsonb_build_object('from', OLD.channel, 'to', NEW.channel) + END, + 'fulfillmentSource', CASE + WHEN OLD.source IS DISTINCT FROM NEW.source + THEN jsonb_build_object('from', OLD.source, 'to', NEW.source) + END, + 'fulfillmentLocked', CASE + WHEN OLD.locked IS DISTINCT FROM NEW.locked + THEN jsonb_build_object('from', OLD.locked, 'to', NEW.locked) + END, + 'fulfillmentPendingReason', CASE + WHEN OLD.pending_reason IS DISTINCT FROM NEW.pending_reason + THEN jsonb_build_object('from', OLD.pending_reason, 'to', NEW.pending_reason) + END, + 'fulfillmentPendingDetail', CASE + WHEN OLD.pending_detail IS DISTINCT FROM NEW.pending_detail + THEN jsonb_build_object('from', OLD.pending_detail, 'to', NEW.pending_detail) + END, + 'fulfillmentConfigId', CASE + WHEN OLD.fulfillment_config_id IS DISTINCT FROM NEW.fulfillment_config_id + THEN jsonb_build_object('from', OLD.fulfillment_config_id, 'to', NEW.fulfillment_config_id) + END, + 'canonicalProductId', CASE + WHEN OLD.canonical_product_id IS DISTINCT FROM NEW.canonical_product_id + THEN jsonb_build_object( + 'from', OLD.canonical_product_id, + 'to', NEW.canonical_product_id + ) + END + )); + + INSERT INTO fulfillment_state_events ( + fulfillment_id, + order_item_id, + order_id, + event_type, + actor_type, + actor_id, + reason, + previous_status, + next_status, + previous_channel, + next_channel, + previous_source, + next_source, + previous_locked, + next_locked, + previous_pending_reason, + next_pending_reason, + previous_pending_detail, + next_pending_detail, + payload_json + ) VALUES ( + NEW.id, + NEW.order_item_id, + NEW.order_id, + LEFT(event_type_value, 100), + LEFT(actor_type_value, 32), + LEFT(actor_id_value, 200), + LEFT(reason_value, 500), + OLD.status, + NEW.status, + OLD.channel, + NEW.channel, + OLD.source, + NEW.source, + OLD.locked, + NEW.locked, + OLD.pending_reason, + NEW.pending_reason, + OLD.pending_detail, + NEW.pending_detail, + changed_fields + ); + + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_fulfillments_state_event +AFTER UPDATE OF + status, + channel, + source, + locked, + pending_reason, + pending_detail, + reason, + decided_by, + decided_at, + original_channel, + channel_switch_reason, + channel_switched_at, + fulfillment_config_id, + canonical_product_id +ON fulfillments +FOR EACH ROW +EXECUTE FUNCTION record_fulfillment_state_event(); + +-- 兼容已有事件的关联字段,新事件由正式表触发器直接写入。 +CREATE OR REPLACE FUNCTION assign_fulfillment_event_lookup() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.fulfillment_id IS NULL THEN + SELECT id INTO NEW.fulfillment_id + FROM fulfillments + WHERE order_item_id = NEW.order_item_id; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_fulfillment_state_events_fulfillment +BEFORE INSERT ON fulfillment_state_events +FOR EACH ROW +EXECUTE FUNCTION assign_fulfillment_event_lookup(); + +-- 活跃履约逐行重算;历史归档只做集合收敛,避免大表迁移被 N+1 重算拖住。 +SELECT refresh_fulfillment_derived(fulfillment.order_item_id) +FROM fulfillments fulfillment +WHERE fulfillment.status IN ('pending', 'assigned'); + +WITH archived_routes AS ( + SELECT + fulfillment.id, + EXISTS ( + SELECT 1 + FROM sku_channel_routes route + JOIN internal_skus sku ON sku.id = route.internal_sku_id + WHERE route.enabled = TRUE + AND route.route_role = 'primary' + AND sku.internal_sku_key = fulfillment.internal_sku + ) AS has_primary_route + FROM fulfillments fulfillment + WHERE fulfillment.status = 'archived' +) +UPDATE fulfillments fulfillment +SET + route_status = CASE + WHEN COALESCE(fulfillment.internal_sku, '') = '' THEN 'unrouted' + WHEN fulfillment.channel IS NOT NULL OR archived_routes.has_primary_route THEN 'configured' + ELSE 'unrouted' + END, + carrier_status = CASE + WHEN fulfillment.channel IN ('third_party_voucher', 'on_account') THEN 'not_required' + WHEN fulfillment.channel IS NOT NULL THEN 'terminal' + ELSE 'missing' + END, + current_attempt_type = '', + current_attempt_source_id = NULL, + current_attempt_status = '', + lock_reason = '', + issue_code = '', + issue_category = '', + retryable = FALSE, + owner_role = '', + updated_at = NOW() +FROM archived_routes +WHERE archived_routes.id = fulfillment.id; + +UPDATE fulfillment_issues issue +SET status = 'resolved', + resolved_at = NOW() +FROM fulfillments fulfillment +WHERE fulfillment.id = issue.fulfillment_id + AND fulfillment.status = 'archived' + AND issue.status <> 'resolved'; + +ALTER TABLE order_items + RENAME COLUMN canonical_product_id TO legacy_canonical_product_id; +ALTER TABLE order_items + RENAME COLUMN fulfillment_decided_at TO legacy_fulfillment_decided_at; +ALTER TABLE order_items + RENAME COLUMN fulfillment_reason TO legacy_fulfillment_reason; +ALTER TABLE order_items + RENAME COLUMN fulfillment_config_id TO legacy_fulfillment_config_id; +ALTER TABLE order_items + RENAME COLUMN fulfillment_channel TO legacy_fulfillment_channel; +ALTER TABLE order_items + RENAME COLUMN fulfillment_status TO legacy_fulfillment_status; +ALTER TABLE order_items + RENAME COLUMN fulfillment_source TO legacy_fulfillment_source; +ALTER TABLE order_items + RENAME COLUMN fulfillment_locked TO legacy_fulfillment_locked; +ALTER TABLE order_items + RENAME COLUMN fulfillment_decided_by TO legacy_fulfillment_decided_by; +ALTER TABLE order_items + RENAME COLUMN original_channel TO legacy_original_channel; +ALTER TABLE order_items + RENAME COLUMN channel_switch_reason TO legacy_channel_switch_reason; +ALTER TABLE order_items + RENAME COLUMN channel_switched_at TO legacy_channel_switched_at; +ALTER TABLE order_items + RENAME COLUMN fulfillment_pending_reason TO legacy_fulfillment_pending_reason; +ALTER TABLE order_items + RENAME COLUMN fulfillment_pending_detail TO legacy_fulfillment_pending_detail; + +ALTER TABLE order_items + DROP CONSTRAINT IF EXISTS order_items_canonical_product_id_fkey, + DROP CONSTRAINT IF EXISTS order_items_fulfillment_config_id_fkey, + DROP CONSTRAINT IF EXISTS order_items_fulfillment_decided_by_fkey; + +COMMENT ON COLUMN order_items.legacy_canonical_product_id IS + '冻结的历史商品归一值;本迁移后唯一真源是 fulfillments.canonical_product_id'; +COMMENT ON COLUMN order_items.legacy_fulfillment_status IS + '冻结的历史履约状态;本迁移后唯一真源是 fulfillments.status'; +COMMENT ON TABLE fulfillments IS + '履约唯一真源:每订单项一行,保存状态、渠道、配置关联、载体、当前尝试和异常摘要'; + +CREATE VIEW v_order_item_current AS +SELECT + item.id, + item.order_id, + item.sku_code, + item.sku_name, + item.quantity, + item.spec_json, + item.item_snapshot_json, + item.created_at, + item.updated_at, + fulfillment.canonical_product_id, + item.normalized_quantity, + item.normalized_sku_name, + item.normalized_sku_series, + item.revenue_amount_fen, + item.revenue_basis, + fulfillment.decided_at AS fulfillment_decided_at, + fulfillment.reason AS fulfillment_reason, + fulfillment.fulfillment_config_id, + item.manual_sku_key, + fulfillment.channel AS fulfillment_channel, + fulfillment.status AS fulfillment_status, + fulfillment.source AS fulfillment_source, + fulfillment.locked AS fulfillment_locked, + fulfillment.decided_by AS fulfillment_decided_by, + fulfillment.original_channel, + fulfillment.channel_switch_reason, + fulfillment.channel_switched_at, + fulfillment.pending_reason AS fulfillment_pending_reason, + fulfillment.pending_detail AS fulfillment_pending_detail +FROM order_items item +JOIN fulfillments fulfillment ON fulfillment.order_item_id = item.id; + +COMMENT ON VIEW v_order_item_current IS + '订单项权威读投影:原始订单字段来自 order_items,履约字段来自 fulfillments'; + +CREATE VIEW v_fulfillment_item_state AS +SELECT + fulfillment.order_item_id, + fulfillment.order_id, + order_record.id AS local_order_id, + order_record.provider, + order_record.platform, + order_record.shop_id, + order_record.shop_name, + order_record.platform_order_id, + order_record.pay_status, + order_record.order_status, + fulfillment.status AS fulfillment_status, + fulfillment.internal_sku, + COALESCE(fulfillment.channel, route.channel) AS intended_channel, + fulfillment.channel AS stored_channel, + route.channel AS configured_channel, + fulfillment.route_status, + fulfillment.carrier_status, + fulfillment.current_attempt_type, + COALESCE(fulfillment.current_attempt_source_id, 0) AS current_attempt_id, + fulfillment.current_attempt_status, + COALESCE(current_attempt.result_code, '') AS current_attempt_result_code, + COALESCE(latest_task.source_id, 0) AS latest_task_id, + latest_task.executor_key AS latest_task_executor_key, + latest_task.status AS latest_task_status, + latest_task.result_code AS latest_task_result_code, + COALESCE(latest_work_order.source_id, 0) AS latest_work_order_id, + latest_work_order.status AS latest_work_order_status, + fulfillment.issue_code, + fulfillment.issue_category, + fulfillment.retryable, + ( + fulfillment.issue_code IN ('carrier_missing', 'state_inconsistent') + ) AS auto_retryable, + fulfillment.owner_role, + fulfillment.locked AS fulfillment_locked, + fulfillment.lock_reason, + fulfillment.pending_reason AS fulfillment_pending_reason, + fulfillment.pending_detail AS fulfillment_pending_detail, + fulfillment.source AS fulfillment_source, + item.sku_code, + item.sku_name, + item.quantity, + item.revenue_amount_fen, + item.created_at, + fulfillment.updated_at, + GREATEST( + 0, + FLOOR(EXTRACT(EPOCH FROM (NOW() - item.created_at)) / 60) + )::bigint AS age_minutes +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 LATERAL ( + SELECT + channel_route.channel, + channel_route.carrier_config_json + FROM sku_channel_routes channel_route + JOIN internal_skus sku ON sku.id = channel_route.internal_sku_id + WHERE channel_route.enabled = TRUE + AND sku.internal_sku_key = fulfillment.internal_sku + ORDER BY + (channel_route.route_role = 'fallback'), + channel_route.priority, + channel_route.channel, + channel_route.id + LIMIT 1 +) route ON TRUE +LEFT JOIN LATERAL ( + SELECT + attempt.source_id, + attempt.executor_key, + attempt.status, + attempt.result_code + FROM fulfillment_attempts attempt + WHERE attempt.fulfillment_id = fulfillment.id + AND attempt.attempt_type = 'task' + ORDER BY attempt.updated_at DESC, attempt.id DESC + LIMIT 1 +) latest_task ON TRUE +LEFT JOIN LATERAL ( + SELECT + attempt.source_id, + attempt.status + FROM fulfillment_attempts attempt + WHERE attempt.fulfillment_id = fulfillment.id + AND attempt.attempt_type = 'work_order' + ORDER BY attempt.updated_at DESC, attempt.id DESC + LIMIT 1 +) latest_work_order ON TRUE +LEFT JOIN LATERAL ( + SELECT + attempt.attempt_type, + attempt.source_id, + attempt.status, + attempt.result_code + FROM fulfillment_attempts attempt + WHERE attempt.fulfillment_id = fulfillment.id + AND attempt.attempt_type = fulfillment.current_attempt_type + AND attempt.source_id = fulfillment.current_attempt_source_id + LIMIT 1 +) current_attempt ON TRUE +WHERE fulfillment.status IN ('pending', 'assigned'); + +COMMENT ON VIEW v_fulfillment_item_state IS + '履约统一读模型:直接投影 fulfillments、fulfillment_attempts、fulfillment_issues 和 sku_channel_routes'; + +-- --------------------------------------------------------------------------- +-- 履约事件外键级联 +-- --------------------------------------------------------------------------- +-- 履约事件已经由 fulfillment_id 直接归属正式履约记录。 +-- 订单项删除时事件应随履约记录级联清理,不能先 SET NULL 再触发 append-only 更新保护。 + +ALTER TABLE fulfillment_state_events + DROP CONSTRAINT IF EXISTS fk_fulfillment_state_events_fulfillment; + +ALTER TABLE fulfillment_state_events + ADD CONSTRAINT fk_fulfillment_state_events_fulfillment + FOREIGN KEY (fulfillment_id) + REFERENCES fulfillments(id) + ON DELETE CASCADE; + +-- --------------------------------------------------------------------------- +-- 履约事件只禁止更新 +-- --------------------------------------------------------------------------- +-- 事件语义是“应用不可改”,父记录删除时允许数据库级联清理。 +-- 旧环境里曾存在 BEFORE DELETE OR UPDATE 的漂移定义,这里统一收回为只阻止 UPDATE。 + +DROP TRIGGER IF EXISTS trg_fulfillment_state_events_append_only + ON fulfillment_state_events; + +CREATE TRIGGER trg_fulfillment_state_events_append_only +BEFORE UPDATE ON fulfillment_state_events +FOR EACH ROW +EXECUTE FUNCTION reject_fulfillment_state_event_mutation(); + +COMMENT ON TRIGGER trg_fulfillment_state_events_append_only + ON fulfillment_state_events IS + '禁止应用修改履约事件;数据库外键级联删除不受影响'; + +-- --------------------------------------------------------------------------- +-- 无法解析内部 SKU 的兜底 +-- --------------------------------------------------------------------------- +-- resolve_internal_sku 可能返回 NULL。正式履约行的 internal_sku 是不带 NULL 的稳定键, +-- 无法解析时统一落空字符串,并立即进入 missing_internal_sku 异常口径。 + +CREATE OR REPLACE FUNCTION ensure_fulfillment_for_order_item() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + INSERT INTO fulfillments ( + order_item_id, + order_id, + internal_sku, + route_status, + channel, + carrier_status, + current_attempt_type, + current_attempt_source_id, + current_attempt_status, + lock_reason, + issue_code, + issue_category, + retryable, + owner_role, + created_at, + updated_at, + status, + source, + locked, + channel_switch_reason + ) VALUES ( + NEW.id, + NEW.order_id, + COALESCE(resolve_internal_sku(NEW.manual_sku_key, NEW.sku_name, NEW.sku_code), ''), + 'unrouted', + NULL, + 'missing', + '', + NULL, + '', + '', + '', + '', + FALSE, + '', + NEW.created_at, + NOW(), + 'pending', + '', + FALSE, + '' + ) + ON CONFLICT (order_item_id) DO NOTHING; + + PERFORM refresh_fulfillment_derived(NEW.id); + RETURN NEW; +END; +$$; diff --git a/apps/backend/src/db/migrations/149_finance_derived_scope.sql b/apps/backend/src/db/migrations/149_finance_derived_scope.sql new file mode 100644 index 00000000..412d01ef --- /dev/null +++ b/apps/backend/src/db/migrations/149_finance_derived_scope.sql @@ -0,0 +1,51 @@ +-- 149_finance_derived_scope.sql +-- +-- 财务销售范围唯一读模型: +-- v_finance_order_item_scope 统一收入、退款、商品、渠道和业务日, +-- 销售事实、重建与审计共用这一口径。 +-- +-- 合并自未上生产的 167。 +-- 财务派生范围唯一读模型: +-- 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/db/migrations/150_voucher_consume_order_received_scope.sql b/apps/backend/src/db/migrations/150_voucher_consume_order_received_scope.sql new file mode 100644 index 00000000..158ba0b5 --- /dev/null +++ b/apps/backend/src/db/migrations/150_voucher_consume_order_received_scope.sql @@ -0,0 +1,24 @@ +-- 150_voucher_consume_order_received_scope.sql +-- +-- “收到订单后”核销改为订单级触发: +-- 1. 不再依赖履约渠道、订单项状态或待处理原因; +-- 2. 订单级任务以 order:<订单ID> 去重; +-- 3. channel=order_received 仅用于任务来源审计和后台筛选。 + +ALTER TABLE voucher_consume_jobs + DROP CONSTRAINT IF EXISTS voucher_consume_jobs_channel_check; + +ALTER TABLE voucher_consume_jobs + ADD CONSTRAINT voucher_consume_jobs_channel_check + CHECK (channel IN ( + 'order_received', + 'affiliate_dash', + 'work_order', + 'manual', + 'third_party_voucher', + 'on_account', + 'unmatched' + )); + +COMMENT ON COLUMN voucher_consume_jobs.channel IS + '触发核销时的策略来源;order_received 表示与履约渠道无关的订单级收单触发'; diff --git a/apps/backend/src/db/migrations/151_sales_daily_facts_on_account_channel.sql b/apps/backend/src/db/migrations/151_sales_daily_facts_on_account_channel.sql new file mode 100644 index 00000000..9a89b47f --- /dev/null +++ b/apps/backend/src/db/migrations/151_sales_daily_facts_on_account_channel.sql @@ -0,0 +1,25 @@ +-- 151_sales_daily_facts_on_account_channel.sql +-- +-- 财务销售事实渠道枚举补齐统一结算渠道。 +-- v_finance_order_item_scope 会原样输出 fulfillments.channel,而履约真源自 +-- 132_on_account_and_manual_channels.sql 起已合法包含 on_account。 + +ALTER TABLE sales_daily_facts + DROP CONSTRAINT IF EXISTS chk_sales_daily_facts_fulfillment_mode; + +ALTER TABLE sales_daily_facts + ADD CONSTRAINT chk_sales_daily_facts_fulfillment_mode + CHECK ( + channel IN ( + 'work_order', + 'affiliate_dash', + 'third_party_voucher', + 'manual', + 'on_account', + 'unclassified', + 'archived' + ) + ); + +COMMENT ON COLUMN sales_daily_facts.channel IS + '销售事实维度(渠道):work_order / affiliate_dash / third_party_voucher / manual / on_account / unclassified 未分类 / archived 归档'; diff --git a/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts b/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts index f7ad42c9..e70f9e1a 100644 --- a/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts +++ b/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts @@ -355,10 +355,10 @@ export async function listKuaishouIndustryVouchersForAdmin( COALESCE(ft.task_no, '') AS task_no FROM kuaishou_industry_vouchers v LEFT JOIN fulfillment_tasks ft ON ft.id = v.task_id - LEFT JOIN order_items oi_task ON oi_task.id = ft.order_item_id + LEFT JOIN v_order_item_current oi_task ON oi_task.id = ft.order_item_id LEFT JOIN LATERAL ( SELECT oi.sku_code, oi.sku_name - FROM order_items oi + FROM v_order_item_current oi WHERE v.order_id IS NOT NULL AND oi.order_id = v.order_id ORDER BY oi.id ASC diff --git a/apps/backend/src/repositories/order-item-repo.test.ts b/apps/backend/src/repositories/order-item-repo.test.ts index 560d5414..50a7d0ca 100644 --- a/apps/backend/src/repositories/order-item-repo.test.ts +++ b/apps/backend/src/repositories/order-item-repo.test.ts @@ -186,7 +186,7 @@ test('已分配订单重推写库时不会用平台新 SKU 覆盖冻结值', asy let selectCount = 0 const executor = async (sql: string, params: unknown[] = []) => { - if (sql.includes('SELECT *') && sql.includes('FROM order_items')) { + if (sql.includes('SELECT *') && sql.includes('FROM v_order_item_current')) { selectCount += 1 return { rows: selectCount === 1 ? [existing] : [] } } diff --git a/apps/backend/src/repositories/order-item-repo.ts b/apps/backend/src/repositories/order-item-repo.ts index 326e6a9c..0fd3f8c2 100644 --- a/apps/backend/src/repositories/order-item-repo.ts +++ b/apps/backend/src/repositories/order-item-repo.ts @@ -29,7 +29,7 @@ export async function listOrderItemsByOrderIds(orderIds: number[]): Promise( ` SELECT * - FROM order_items + FROM v_order_item_current WHERE order_id = ANY($1::bigint[]) ORDER BY order_id ASC, id ASC `, @@ -316,7 +316,7 @@ async function listDeletableOrderItemIdsWithExecutor( const result = await executor( ` SELECT oi.id - FROM order_items oi + FROM v_order_item_current oi LEFT JOIN fulfillment_tasks ft ON ft.order_item_id = oi.id WHERE oi.id = ANY($1::bigint[]) GROUP BY oi.id @@ -336,7 +336,7 @@ async function listOrderItemsByOrderIdWithExecutor( const result = await executor( ` SELECT * - FROM order_items + FROM v_order_item_current WHERE order_id = $1 ORDER BY id ASC `, @@ -347,9 +347,10 @@ async function listOrderItemsByOrderIdWithExecutor( } export async function getOrderItemById(orderItemId: number | string): Promise { - const result = await query('SELECT * FROM order_items WHERE id = $1 LIMIT 1', [ - Number(orderItemId), - ]) + const result = await query( + 'SELECT * FROM v_order_item_current WHERE id = $1 LIMIT 1', + [Number(orderItemId)], + ) return result.rows[0] || null } @@ -358,9 +359,17 @@ export async function updateOrderItemSourceSnapshot( input: { skuName: string; itemSnapshotJson: string; updatedAt: string; specJson?: string }, ): Promise { return withTransaction(async (client) => { + const id = Number(orderItemId) + const orderItemLock = await client.query<{ id: number }>( + `SELECT id FROM order_items WHERE id = $1 FOR UPDATE`, + [id], + ) + if (!orderItemLock.rows[0]) return null + await client.query(`SELECT id FROM fulfillments WHERE order_item_id = $1 FOR UPDATE`, [id]) + const selected = await client.query( - `SELECT * FROM order_items WHERE id = $1 FOR UPDATE`, - [Number(orderItemId)], + `SELECT * FROM v_order_item_current WHERE id = $1`, + [id], ) const existing = selected.rows[0] if (!existing) return null diff --git a/apps/backend/src/repositories/order-repo.test.ts b/apps/backend/src/repositories/order-repo.test.ts index 686b3de1..dd183799 100644 --- a/apps/backend/src/repositories/order-repo.test.ts +++ b/apps/backend/src/repositories/order-repo.test.ts @@ -107,8 +107,8 @@ test('订单列表按当前履约模式筛选,不再依赖任务执行器', as channel: 'manual', }) - assert.match(calls[0]?.sql || '', /oi\.fulfillment_channel = \$1/) - assert.match(calls[0]?.sql || '', /oi\.fulfillment_channel IS DISTINCT FROM \$1/) + assert.match(calls[0]?.sql || '', /fulfillment\.channel = \$1/) + assert.match(calls[0]?.sql || '', /fulfillment\.channel IS DISTINCT FROM \$1/) assert.doesNotMatch(calls[0]?.sql || '', /COUNT\(DISTINCT/) assert.doesNotMatch(calls[0]?.sql || '', /ft\.executor_key/) assert.deepEqual(calls[0]?.params, ['manual']) @@ -122,7 +122,7 @@ test('订单列表待判定筛选不扫描任务执行器', async () => { }) assert.match(calls[0]?.sql || '', /EXISTS/) - assert.match(calls[0]?.sql || '', /oi\.fulfillment_status = 'pending'/) + assert.match(calls[0]?.sql || '', /fulfillment\.status = 'pending'/) assert.doesNotMatch(calls[0]?.sql || '', /COUNT\(DISTINCT/) assert.deepEqual(calls[0]?.params, []) }) @@ -134,7 +134,7 @@ test('订单列表历史平台筛选映射为 archived', async () => { channel: 'legacy_platform', }) - assert.match(calls[0]?.sql || '', /oi\.fulfillment_status = 'archived'/) + assert.match(calls[0]?.sql || '', /fulfillment\.status = 'archived'/) assert.deepEqual(calls[0]?.params, []) }) @@ -145,7 +145,7 @@ test('订单列表混合履约筛选按订单项聚合判定', async () => { channel: 'mixed', }) - assert.match(calls[0]?.sql || '', /HAVING COUNT\(DISTINCT oi\.fulfillment_channel\) > 1/) + assert.match(calls[0]?.sql || '', /HAVING COUNT\(DISTINCT fulfillment\.channel\) > 1/) assert.deepEqual(calls[0]?.params, []) }) diff --git a/apps/backend/src/repositories/order-repo.ts b/apps/backend/src/repositories/order-repo.ts index 3431ddad..2874a936 100644 --- a/apps/backend/src/repositories/order-repo.ts +++ b/apps/backend/src/repositories/order-repo.ts @@ -279,7 +279,7 @@ export async function listOrdersWithExecutor( ) ILIKE ${keywordParam} OR EXISTS ( SELECT 1 - FROM order_items oi + FROM v_order_item_current oi WHERE oi.order_id = o.id AND ( ( @@ -339,36 +339,36 @@ export async function listOrdersWithExecutor( const normalizedMode = channel === 'legacy_platform' ? 'archived' : channel if (normalizedMode === 'pending') { filters.push(`EXISTS ( - SELECT 1 FROM order_items oi - WHERE oi.order_id = o.id AND oi.fulfillment_status = 'pending' + SELECT 1 FROM fulfillments fulfillment + WHERE fulfillment.order_id = o.id AND fulfillment.status = 'pending' )`) } else if (normalizedMode === 'archived') { filters.push(`EXISTS ( - SELECT 1 FROM order_items oi - WHERE oi.order_id = o.id AND oi.fulfillment_status = 'archived' + SELECT 1 FROM fulfillments fulfillment + WHERE fulfillment.order_id = o.id AND fulfillment.status = 'archived' )`) } else if (normalizedMode === 'mixed') { filters.push(`o.id IN ( - SELECT oi.order_id FROM order_items oi - WHERE oi.fulfillment_status = 'assigned' - GROUP BY oi.order_id - HAVING COUNT(DISTINCT oi.fulfillment_channel) > 1 + SELECT fulfillment.order_id FROM fulfillments fulfillment + WHERE fulfillment.status = 'assigned' + GROUP BY fulfillment.order_id + HAVING COUNT(DISTINCT fulfillment.channel) > 1 )`) } else { params.push(normalizedMode) const modeParam = `$${params.length}` filters.push(`( EXISTS ( - SELECT 1 FROM order_items oi - WHERE oi.order_id = o.id - AND oi.fulfillment_status = 'assigned' - AND oi.fulfillment_channel = ${modeParam} + SELECT 1 FROM fulfillments fulfillment + WHERE fulfillment.order_id = o.id + AND fulfillment.status = 'assigned' + AND fulfillment.channel = ${modeParam} ) AND NOT EXISTS ( - SELECT 1 FROM order_items oi - WHERE oi.order_id = o.id - AND oi.fulfillment_status = 'assigned' - AND oi.fulfillment_channel IS DISTINCT FROM ${modeParam} + SELECT 1 FROM fulfillments fulfillment + WHERE fulfillment.order_id = o.id + AND fulfillment.status = 'assigned' + AND fulfillment.channel IS DISTINCT FROM ${modeParam} ) )`) } @@ -377,7 +377,7 @@ export async function listOrdersWithExecutor( if (skuCode) { params.push(skuCode) filters.push( - `EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.sku_code = $${params.length})`, + `EXISTS (SELECT 1 FROM v_order_item_current oi WHERE oi.order_id = o.id AND oi.sku_code = $${params.length})`, ) } diff --git a/apps/backend/src/repositories/redeem-certificate-repo.ts b/apps/backend/src/repositories/redeem-certificate-repo.ts index 855fb8f3..ae46db23 100644 --- a/apps/backend/src/repositories/redeem-certificate-repo.ts +++ b/apps/backend/src/repositories/redeem-certificate-repo.ts @@ -167,10 +167,10 @@ export async function listRedeemCertificatesForAdmin( ORDER BY ft.id ASC LIMIT 1 ) ft ON true - LEFT JOIN order_items oi_task ON oi_task.id = ft.order_item_id + LEFT JOIN v_order_item_current oi_task ON oi_task.id = ft.order_item_id LEFT JOIN LATERAL ( SELECT oi.sku_code, oi.sku_name - FROM order_items oi + FROM v_order_item_current oi WHERE c.local_order_id IS NOT NULL AND oi.order_id = c.local_order_id ORDER BY oi.id ASC diff --git a/apps/backend/src/repositories/task-repo.ts b/apps/backend/src/repositories/task-repo.ts index ba634a42..f62a1559 100644 --- a/apps/backend/src/repositories/task-repo.ts +++ b/apps/backend/src/repositories/task-repo.ts @@ -96,7 +96,9 @@ export async function createTask(input: TaskCreateInput): Promise { // 同一订单项单元的创建必须串行,否则并发调度会同时算出同一尝试号, // 再一起绕过“仅一个当前尝试”的唯一索引。 - await client.query(`SELECT id FROM order_items WHERE id = $1 FOR UPDATE`, [input.orderItemId]) + await client.query(`SELECT id FROM fulfillments WHERE order_item_id = $1 FOR UPDATE`, [ + input.orderItemId, + ]) const active = await client.query<{ id: number }>( `SELECT id FROM fulfillment_tasks @@ -483,7 +485,7 @@ export async function listTasks({ const itemsParams = [...params, pageSize, offset] const itemsResult = await query( `${buildTaskSelect('oi.sku_code, oi.sku_name, oi.quantity')} - LEFT JOIN order_items oi ON oi.id = ft.order_item_id + LEFT JOIN v_order_item_current oi ON oi.id = ft.order_item_id ${whereClause} ORDER BY ft.id DESC LIMIT $${itemsParams.length - 1} OFFSET $${itemsParams.length}`, @@ -504,7 +506,7 @@ export async function listTasks({ const needsJoinKcts = whereClause.includes('kcts.') const joinClauses: string[] = [] if (needsJoinOi) { - joinClauses.push('LEFT JOIN order_items oi ON oi.id = ft.order_item_id') + joinClauses.push('LEFT JOIN v_order_item_current oi ON oi.id = ft.order_item_id') } if (needsJoinCtx) { joinClauses.push('LEFT JOIN task_runtime_contexts ctx ON ctx.task_id = ft.id') diff --git a/apps/backend/src/services/admin/admin-order-read-helpers.ts b/apps/backend/src/services/admin/admin-order-read-helpers.ts index 049c1b9d..1d44ec06 100644 --- a/apps/backend/src/services/admin/admin-order-read-helpers.ts +++ b/apps/backend/src/services/admin/admin-order-read-helpers.ts @@ -202,7 +202,7 @@ export function resolveVoucherSummary(vouchers: KuaishouIndustryVoucherRow[] = [ } /** - * 订单级履约摘要:只读 order_items.fulfillment_channel / fulfillment_status。 + * 订单级履约摘要:只读 fulfillments.channel / status 的订单项投影。 * 多商品订单出现不同模式时显示 mixed;尚未判定的订单项单独计数。 */ export function resolveOrderFulfillmentSummary({ diff --git a/apps/backend/src/services/cost-center/affiliate-product-service.ts b/apps/backend/src/services/cost-center/affiliate-product-service.ts index 47ffe772..ee8f74d2 100644 --- a/apps/backend/src/services/cost-center/affiliate-product-service.ts +++ b/apps/backend/src/services/cost-center/affiliate-product-service.ts @@ -23,7 +23,7 @@ export async function cleanupDirtyAffiliateDashProducts() { oi.id AS order_item_id, clean.id AS clean_product_id, dirty.id AS expected_product_id - FROM order_items oi + FROM v_order_item_current oi JOIN product_catalog dirty ON dirty.id = oi.canonical_product_id JOIN fulfillment_tasks ft ON ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' JOIN product_catalog clean ON clean.canonical_sku = 'aff:' || COALESCE( @@ -58,7 +58,7 @@ export async function cleanupDirtyAffiliateDashProducts() { await query(` DELETE FROM product_catalog WHERE canonical_sku ~ '^aff:[^a-z0-9_]' - AND NOT EXISTS (SELECT 1 FROM order_items WHERE canonical_product_id = product_catalog.id) + AND NOT EXISTS (SELECT 1 FROM fulfillments WHERE canonical_product_id = product_catalog.id) AND NOT EXISTS (SELECT 1 FROM cost_versions WHERE canonical_product_id = product_catalog.id) AND NOT EXISTS (SELECT 1 FROM cost_snapshots WHERE canonical_product_id = product_catalog.id) AND NOT EXISTS (SELECT 1 FROM sales_daily_facts WHERE canonical_product_id = product_catalog.id) @@ -110,7 +110,7 @@ export async function syncAdminAffiliateDashProducts() { GROUP BY sku_code, sku_name ) INSERT INTO product_catalog ( - canonical_sku, canonical_name, category, unit_name, status, + canonical_sku, canonical_name, category, unit_name, source_kind, status, attributes_json, created_at, updated_at ) SELECT @@ -118,6 +118,7 @@ export async function syncAdminAffiliateDashProducts() { ranked.sku_name, '', CASE WHEN ranked.sku_name LIKE '%个' THEN '个' ELSE '件' END, + 'affiliate_dash', 'active', jsonb_build_object( 'source', 'affiliate_dash_task', @@ -133,6 +134,7 @@ export async function syncAdminAffiliateDashProducts() { ON CONFLICT (canonical_sku) DO UPDATE SET canonical_name = CASE WHEN product_catalog.canonical_name = '' THEN EXCLUDED.canonical_name ELSE product_catalog.canonical_name END, unit_name = EXCLUDED.unit_name, + source_kind = EXCLUDED.source_kind, attributes_json = product_catalog.attributes_json || EXCLUDED.attributes_json, updated_at = $1 RETURNING (xmax = 0) AS inserted @@ -207,7 +209,7 @@ export async function backfillAdminAffiliateDashSkus() { FROM affiliate_item ai JOIN product_catalog p ON p.canonical_sku = 'aff:' || ai.sku_code - JOIN order_items oi ON oi.id = ai.id + JOIN v_order_item_current oi ON oi.id = ai.id ) SELECT id, canonical_product_id, expected_product_id FROM target @@ -240,7 +242,7 @@ export async function backfillAdminAffiliateDashSkus() { 'YYYY-MM-DD' ) FROM affiliate_item - JOIN order_items oi ON oi.id = affiliate_item.id + JOIN v_order_item_current oi ON oi.id = affiliate_item.id JOIN orders o ON o.id = oi.order_id ) AS from_date, ( @@ -249,7 +251,7 @@ export async function backfillAdminAffiliateDashSkus() { 'YYYY-MM-DD' ) FROM affiliate_item - JOIN order_items oi ON oi.id = affiliate_item.id + JOIN v_order_item_current oi ON oi.id = affiliate_item.id JOIN orders o ON o.id = oi.order_id ) AS to_date `, @@ -275,7 +277,7 @@ export async function backfillAdminAffiliateDashSkus() { const remainingResult = await query<{ total: number }>( ` SELECT COUNT(*)::int AS total - FROM order_items oi + FROM v_order_item_current oi WHERE oi.canonical_product_id IS NULL AND EXISTS ( SELECT 1 FROM fulfillment_tasks ft @@ -319,12 +321,15 @@ export async function setAdminAffiliateProductPrice( id: number canonical_sku: string canonical_name: string - }>('SELECT id, canonical_sku, canonical_name FROM product_catalog WHERE id = $1', [productId]) + source_kind: string + }>('SELECT id, canonical_sku, canonical_name, source_kind FROM product_catalog WHERE id = $1', [ + productId, + ]) const product = productResult.rows[0] if (!product) { throw createHttpError('商品不存在', { statusCode: 404 }) } - if (!product.canonical_sku.startsWith('aff:')) { + if (product.source_kind !== 'affiliate_dash') { throw createHttpError('该商品不是自动发货商品,无法设置自动发货采购成本', { statusCode: 400 }) } @@ -501,16 +506,17 @@ export async function importAdminAffiliateDashPricesBaseline(session: AdminSessi const productInsert = await query<{ id: number; inserted: boolean }>( ` INSERT INTO product_catalog ( - canonical_sku, canonical_name, category, unit_name, status, + canonical_sku, canonical_name, category, unit_name, source_kind, status, attributes_json, created_at, updated_at ) VALUES ( - $1, $2, $3, $4, 'active', + $1, $2, $3, $4, 'affiliate_dash', 'active', jsonb_build_object('source', 'affiliate_dash_price_import', 'skuCode', $5::text), $6, $6 ) ON CONFLICT (canonical_sku) DO UPDATE SET canonical_name = CASE WHEN product_catalog.canonical_name = '' THEN EXCLUDED.canonical_name ELSE product_catalog.canonical_name END, category = CASE WHEN product_catalog.category = '' THEN EXCLUDED.category ELSE product_catalog.category END, + source_kind = EXCLUDED.source_kind, updated_at = $6 RETURNING id, (xmax = 0) AS inserted `, diff --git a/apps/backend/src/services/cost-center/cost-center-constants.ts b/apps/backend/src/services/cost-center/cost-center-constants.ts index e0114409..6e18acdb 100644 --- a/apps/backend/src/services/cost-center/cost-center-constants.ts +++ b/apps/backend/src/services/cost-center/cost-center-constants.ts @@ -58,7 +58,7 @@ export const COST_MODEL_CODE_BASE: Record = { other: 'other_cost', } -/** 履约渠道:唯一真源是 order_items.fulfillment_channel + fulfillment_status。 */ +/** 履约渠道:唯一真源是 fulfillments.channel + status。 */ export type FulfillmentChannel = | 'work_order' | 'affiliate_dash' @@ -103,8 +103,8 @@ export const PROVIDERS = ['91kaquan', 'pdd', 'douyin_mashida'] export const WORK_ORDER_TEMPLATE_COST_BASELINE = '2026-07-10T00:00:00+08:00' /** - * 商品来源前缀。商品表不再存履约载体,来源只由 canonical_sku 前缀表达。 - * 前缀之外的 SKU 归入「未分类」,不允许反推履约方式。 + * 商品创建时使用的编码前缀。运行期来源统一读取 product_catalog.source_kind, + * 不能再用 canonical_sku 前缀反推商品来源或履约方式。 */ export const PRODUCT_SOURCE_PREFIXES: Array<{ prefix: string diff --git a/apps/backend/src/services/cost-center/cost-center-mappers.ts b/apps/backend/src/services/cost-center/cost-center-mappers.ts index 8b6b8b43..b64c39d6 100644 --- a/apps/backend/src/services/cost-center/cost-center-mappers.ts +++ b/apps/backend/src/services/cost-center/cost-center-mappers.ts @@ -3,7 +3,7 @@ * * 把数据库行映射成接口 DTO,并提供「成本模型是否适用于履约方式」这类口径函数。 */ -import { GENERIC_COST_MODELS, PRODUCT_SOURCE_PREFIXES } from './cost-center-constants.js' +import { GENERIC_COST_MODELS } from './cost-center-constants.js' import type { FulfillmentChannel } from './cost-center-constants.js' import { parseJsonObject } from './cost-center-normalizers.js' import type { @@ -21,25 +21,34 @@ import type { // 履约方式 / 商品来源 / 成本模型 // --------------------------------------------------------------------------- -/** - * 由 canonical_sku 前缀推导商品来源。 - * 返回空串表示未分类;未分类商品不允许反推履约方式。 - */ -export function productSourceOf(canonicalSku: string): string { - const sku = String(canonicalSku || '').trim() - for (const item of PRODUCT_SOURCE_PREFIXES) { - if (sku.startsWith(item.prefix)) return item.source - } - return '' +/** 标准商品目录允许使用的当前渠道筛选,包含“未路由”这个投影状态。 */ +export const PRODUCT_CURRENT_CHANNEL_FILTERS = [ + 'affiliate_dash', + 'work_order', + 'third_party_voucher', + 'manual', + 'on_account', + 'unrouted', +] as const + +export type ProductCurrentChannelFilter = (typeof PRODUCT_CURRENT_CHANNEL_FILTERS)[number] + +/** 商品来源筛选的唯一 SQL 口径:读取 source_kind,不解析 canonical_sku。 */ +export function productSourceFilterSql(paramIndex: number, column = 'p.source_kind'): string { + return `${column} = $${paramIndex}` } -/** 商品来源筛选的 SQL 片段:按 canonical_sku 前缀过滤。 */ -export function productSourceFilterSql(paramIndex: number, column = 'p.canonical_sku'): string { +/** 当前渠道筛选:标准商品页签的唯一筛选口径。 */ +export function productChannelFilterSql( + paramIndex: number, + column = 'p.active_primary_channel', +): string { return `( - ($${paramIndex} = 'affiliate_dash' AND ${column} LIKE 'aff:%') - OR ($${paramIndex} = 'work_order' AND (${column} LIKE 'tpl:%' OR ${column} LIKE 'wo:%')) - OR ($${paramIndex} = 'third_party_voucher' AND ${column} LIKE 'tpv:%') - OR ($${paramIndex} = 'manual' AND ${column} LIKE 'man:%') + ($${paramIndex} = 'unrouted' AND ${column} IS NULL) + OR ( + $${paramIndex} <> 'unrouted' + AND ${column} = $${paramIndex} + ) )` } @@ -95,7 +104,8 @@ export function mapProduct(row: ProductRow) { canonicalSku: row.canonical_sku, canonicalName: row.canonical_name, category: row.category || '', - source: productSourceOf(row.canonical_sku), + source: String(row.source_kind || 'unclassified'), + sourceKind: String(row.source_kind || 'unclassified'), rootCategoryId: row.root_category_id ? Number(row.root_category_id) : null, rootCategoryName: row.root_category_name || row.category || '', subCategoryId: row.sub_category_id ? Number(row.sub_category_id) : null, @@ -122,6 +132,7 @@ export function mapProduct(row: ProductRow) { ? null : row.template_sharing_enabled === true, activePrimaryChannel: row.active_primary_channel || null, + fallbackChannels: Array.isArray(row.fallback_channels) ? row.fallback_channels : [], affiliateTaskCount: Number(row.affiliate_task_count || 0), affiliateLastFulfilledAt: row.affiliate_last_fulfilled_at || null, createdAt: row.created_at, diff --git a/apps/backend/src/services/cost-center/cost-center-overview-service.ts b/apps/backend/src/services/cost-center/cost-center-overview-service.ts index 099a9e5d..5e8bf7a3 100644 --- a/apps/backend/src/services/cost-center/cost-center-overview-service.ts +++ b/apps/backend/src/services/cost-center/cost-center-overview-service.ts @@ -2,20 +2,26 @@ * 成本中心总览与选项。 * * 口径(2026-10-23 重写): - * 1. 商品来源统计按 canonical_sku 前缀分组,不再读 product_catalog.carrier_type; + * 1. 商品统计按 v_product_current.active_primary_channel 分组; * 2. 成本项统计按 cost_model 分组,不再读 cost_items.carrier_type。 */ import { query } from '../../db/client.js' -import { productSourceFilterSql } from './cost-center-mappers.js' -import { PROVIDERS, productSourceOf, toNumber } from './cost-center-shared.js' +import { productChannelFilterSql } from './cost-center-mappers.js' +import { PROVIDERS, toNumber } from './cost-center-shared.js' -export type CostCenterScope = 'affiliate_dash' | 'work_order' | 'manual' +export type CostCenterScope = + | 'affiliate_dash' + | 'work_order' + | 'third_party_voucher' + | 'manual' + | 'on_account' + | 'unrouted' /** * 成本中心总览。 * - * scope 为空表示「全部来源」;传入具体来源时,商品/映射/成本项/版本/异常/未映射 - * 六项统计都按该来源收敛,页面顶部数字才会和下面的列表对得上。 + * scope 为空表示「全部渠道」;传入具体渠道时,商品/映射/成本项/版本/异常/未映射 + * 六项统计都按该渠道收敛,页面顶部数字才会和下面的列表对得上。 * * 来源与成本模型的对应关系: * affiliate_dash → affiliate_purchase @@ -36,9 +42,9 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {} ` WITH scoped_products AS ( SELECT p.id - FROM product_catalog p + FROM v_product_current p WHERE p.status = 'active' - AND ($1::text IS NULL OR ${productSourceFilterSql(1)}) + AND ($1::text IS NULL OR ${productChannelFilterSql(1)}) ), scoped_cost_models AS ( SELECT CASE $1 @@ -46,6 +52,7 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {} WHEN 'work_order' THEN 'work_order_reward' WHEN 'third_party_voucher' THEN 'third_party_voucher_cost' WHEN 'manual' THEN 'manual_actual' + WHEN 'on_account' THEN 'manual_actual' END AS cost_model ) SELECT @@ -73,7 +80,7 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {} ( SELECT COUNT(*)::int FROM cost_exceptions ce - JOIN order_items oi ON oi.id = ce.order_item_id + JOIN v_order_item_current oi ON oi.id = ce.order_item_id WHERE ce.status = 'open' AND ( $1::text IS NULL @@ -82,7 +89,7 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {} ) AS open_exception_count, ( SELECT COUNT(*)::int - FROM order_items oi + FROM v_order_item_current oi WHERE oi.canonical_product_id IS NULL -- 只统计已定渠道的单:待分类单还没有渠道,不能算「未映射」。 AND oi.fulfillment_status = 'assigned' @@ -93,22 +100,12 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {} ) const row = result.rows[0] const [productSourceResult, costModelResult] = await Promise.all([ - query<{ canonical_sku: string; active_primary_channel: string | null; total: number }>( - `SELECT p.canonical_sku, - ( - SELECT c.channel - FROM internal_sku_channels c - WHERE c.enabled = TRUE - AND c.is_fallback = FALSE - AND canonicalize_internal_sku(c.internal_sku) - = canonicalize_internal_sku(p.canonical_name) - ORDER BY c.priority, c.channel - LIMIT 1 - ) AS active_primary_channel, + query<{ channel: string | null; total: number }>( + `SELECT p.active_primary_channel AS channel, COUNT(*)::int AS total - FROM product_catalog p + FROM v_product_current p WHERE p.status = 'active' - GROUP BY p.canonical_sku, p.canonical_name`, + GROUP BY p.active_primary_channel`, ), query<{ cost_model: string; total: number }>( `SELECT cost_model, COUNT(*)::int AS total FROM cost_items WHERE status = 'active' GROUP BY cost_model`, @@ -134,6 +131,8 @@ const COST_CENTER_SCOPES = new Set([ 'work_order', 'third_party_voucher', 'manual', + 'on_account', + 'unrouted', ]) function normalizeScope(raw: unknown): CostCenterScope | null { @@ -141,27 +140,18 @@ function normalizeScope(raw: unknown): CostCenterScope | null { return COST_CENTER_SCOPES.has(value) ? (value as CostCenterScope) : null } -/** 商品来源计数:按 canonical_sku 前缀归类,未分类单独成桶。 */ -function productSourceCountsOf( - rows: Array<{ canonical_sku: string; active_primary_channel: string | null; total: number }>, -) { +/** 商品当前渠道计数:总览和列表共用 v_product_current。 */ +function productSourceCountsOf(rows: Array<{ channel: string | null; total: number }>) { const counts: Record = { affiliate_dash: 0, work_order: 0, + third_party_voucher: 0, manual: 0, - uncategorized: 0, + on_account: 0, + unrouted: 0, } for (const row of rows) { - const source = productSourceOf(row.canonical_sku) - // man: 只是旧来源;已有其他主渠道的商品不再计入当前人工客服。 - if ( - source === 'manual' && - row.active_primary_channel && - row.active_primary_channel !== 'manual' - ) { - continue - } - const key = source || 'uncategorized' + const key = row.channel || 'unrouted' counts[key] = (counts[key] || 0) + toNumber(row.total) } return counts @@ -183,10 +173,12 @@ export async function getAdminCostCenterOptions() { canonical_sku: string canonical_name: string unit_name: string + source_kind: string + active_primary_channel: string | null }>( ` - SELECT id, canonical_sku, canonical_name, unit_name - FROM product_catalog + SELECT id, canonical_sku, canonical_name, unit_name, source_kind, active_primary_channel + FROM v_product_current WHERE status = 'active' ORDER BY canonical_name, id LIMIT 5000 @@ -225,7 +217,8 @@ export async function getAdminCostCenterOptions() { canonicalSku: row.canonical_sku, canonicalName: row.canonical_name, unitName: row.unit_name, - source: productSourceOf(row.canonical_sku), + source: row.source_kind, + activePrimaryChannel: row.active_primary_channel, })), costItems: costItemsResult.rows.map((row) => ({ id: Number(row.id), diff --git a/apps/backend/src/services/cost-center/cost-center-shared.ts b/apps/backend/src/services/cost-center/cost-center-shared.ts index 5725f1ad..03401da1 100644 --- a/apps/backend/src/services/cost-center/cost-center-shared.ts +++ b/apps/backend/src/services/cost-center/cost-center-shared.ts @@ -2,9 +2,9 @@ * 成本中心共享层入口。 * * 口径(2026-10-23 重写): - * 1. 履约渠道只有 order_items.fulfillment_channel/status 一个真源; + * 1. 履约渠道只有 fulfillments.channel/status 一个真源; * 2. 成本项用 cost_model 表达「成本怎么算」,与履约方式正交; - * 3. 商品来源由 canonical_sku 前缀推导,商品表不再存履约载体; + * 3. 商品来源读取 product_catalog.source_kind,商品表不再存履约载体; * 4. 快照与异常表都不再存履约载体,join order_items 即可拿到。 * * 2026-10-25 拆分:804 行的共享层按职责拆成四个模块,本文件只做统一出口, diff --git a/apps/backend/src/services/cost-center/cost-center-types.ts b/apps/backend/src/services/cost-center/cost-center-types.ts index db0ab4b6..1ecc21b4 100644 --- a/apps/backend/src/services/cost-center/cost-center-types.ts +++ b/apps/backend/src/services/cost-center/cost-center-types.ts @@ -29,7 +29,10 @@ export type ProductRow = { template_pricing_mode?: string | null template_enabled?: boolean | null template_sharing_enabled?: boolean | null + source_kind?: string | null active_primary_channel?: string | null + fallback_channels?: string[] | null + manual_config_id?: number | null affiliate_task_count?: number affiliate_last_fulfilled_at?: string | null } diff --git a/apps/backend/src/services/cost-center/cost-center.test.ts b/apps/backend/src/services/cost-center/cost-center.test.ts index c7039c29..012b10e8 100644 --- a/apps/backend/src/services/cost-center/cost-center.test.ts +++ b/apps/backend/src/services/cost-center/cost-center.test.ts @@ -1,7 +1,8 @@ import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' import test from 'node:test' -import { costModelAppliesTo } from './cost-center-mappers.js' +import { costModelAppliesTo, mapProduct } from './cost-center-mappers.js' import { normalizeMappingMatchType, normalizeProviderKey, @@ -9,7 +10,7 @@ import { } from './cost-center-shared.js' import { calculateCostTotal, validateCostVersionScope } from './cost-resolution-service.js' import { createAdminProduct, resolveMappingProductId } from './product-catalog-service.js' -import { productSourceFilterSql } from './cost-center-mappers.js' +import { productChannelFilterSql, productSourceFilterSql } from './cost-center-mappers.js' import { buildTemplateMappingPlan, buildTemplateProductSku, @@ -287,8 +288,13 @@ test('接单模板改价必须在同一事务中同步成本版本与模板投 test('成本模型与商品来源必须覆盖第三方卡券渠道', () => { assert.equal(costModelAppliesTo('third_party_voucher_cost', 'third_party_voucher'), true) assert.equal(costModelAppliesTo('third_party_voucher_cost', 'manual'), false) - assert.ok(productSourceFilterSql(1).includes("'third_party_voucher'")) - assert.ok(productSourceFilterSql(1).includes("'tpv:%'")) + const sourceSql = productSourceFilterSql(1) + assert.equal(sourceSql, 'p.source_kind = $1') + assert.ok(!sourceSql.includes('canonical_sku')) + const channelSql = productChannelFilterSql(1) + assert.ok(channelSql.includes('active_primary_channel')) + assert.ok(channelSql.includes("$1 = 'unrouted'")) + assert.ok(!channelSql.includes('canonical_sku')) }) test('第三方卡券删除:无历史引用硬删除,有历史引用自动归档并停用规则', async () => { @@ -310,11 +316,15 @@ test('第三方卡券删除:无历史引用硬删除,有历史引用自动 assert.ok(routeSource.includes("'/cost-center/voucher-products/:productId'")) }) -test('人工客服筛选保留所有可维护的 man 商品和显式兜底配置', () => { - const sql = productSourceFilterSql(1) - assert.ok(sql.includes(`$1 = 'manual'`)) - assert.ok(sql.includes("LIKE 'man:%'")) - assert.ok(!sql.includes('live_channel')) +test('人工客服页签只按当前主渠道筛选,不能再按 man 前缀放回人工页', async () => { + const source = await readFile(new URL('./product-catalog-service.ts', import.meta.url), 'utf8') + const sql = productChannelFilterSql(1) + assert.ok(sql.includes('active_primary_channel')) + assert.ok(sql.includes("$1 <> 'unrouted'")) + assert.ok(sql.includes('active_primary_channel = $1')) + assert.ok(!sql.includes('canonical_sku')) + assert.ok(source.includes('FROM v_product_current p')) + assert.ok(source.includes('productChannelFilterSql')) }) test('成本中心总览异常统计必须走异常表关联,不能全表扫描订单项子查询', async () => { @@ -323,16 +333,54 @@ test('成本中心总览异常统计必须走异常表关联,不能全表扫 new URL('./cost-center-overview-service.ts', import.meta.url), 'utf8', ) - assert.ok(source.includes('JOIN order_items oi ON oi.id = ce.order_item_id')) + assert.ok(source.includes('JOIN v_order_item_current oi ON oi.id = ce.order_item_id')) assert.ok(!source.includes('ce.order_item_id IN (')) - assert.ok(source.includes('productSourceFilterSql(1)')) + assert.ok(source.includes('productChannelFilterSql(1)')) }) -test('tpv: 商品来源归为第三方卡券', async () => { - const { productSourceOf } = await import('./cost-center-mappers.js') - const assert = (await import('node:assert/strict')).default - assert.equal(productSourceOf('tpv:模拟卡券'), 'third_party_voucher') - assert.equal(productSourceOf('man:模拟人工'), 'manual') +test('运行期停止写入已废弃的 product_channel_capabilities', async () => { + const files = await Promise.all( + [ + './voucher-product-service.ts', + './product-normalize-service.ts', + './product-catalog-service.ts', + '../fulfillment/channel-assign-service.ts', + ].map((path) => readFile(new URL(path, import.meta.url), 'utf8')), + ) + for (const source of files) { + assert.ok(!source.includes('product_channel_capabilities')) + } +}) + +test('商品来源只读 source_kind,不再按 canonical_sku 前缀回退', () => { + const product = mapProduct({ + id: 1, + canonical_sku: 'man:编码与来源故意不一致', + canonical_name: '测试商品', + category: '', + unit_name: '件', + status: 'active', + root_category_id: null, + root_category_name: '', + sub_category_id: null, + sub_category_name: '', + attributes_json: {}, + created_at: '', + updated_at: '', + source_kind: 'third_party_voucher', + }) + assert.equal(product.source, 'third_party_voucher') + assert.equal(product.sourceKind, 'third_party_voucher') +}) + +test('阶段 6 不再保留会写旧履约列的运维脚本', async () => { + const packageJson = JSON.parse( + await readFile(new URL('../../../package.json', import.meta.url), 'utf8'), + ) as { scripts?: Record } + assert.equal(packageJson.scripts?.['finance:sync'], undefined) + assert.equal(packageJson.scripts?.['finance:rebuild-cutover'], undefined) + assert.equal(packageJson.scripts?.['products:merge-duplicates'], undefined) + assert.equal(packageJson.scripts?.['products:reclassify'], undefined) }) test('人工标准商品禁止绕过人工 SKU 配置手工创建', async () => { diff --git a/apps/backend/src/services/cost-center/cost-definition-service.ts b/apps/backend/src/services/cost-center/cost-definition-service.ts index 75f9bf59..780b59d2 100644 --- a/apps/backend/src/services/cost-center/cost-definition-service.ts +++ b/apps/backend/src/services/cost-center/cost-definition-service.ts @@ -343,7 +343,7 @@ export async function listAdminCostVersions(payload: JsonObject = {}) { ROUND(AVG(wo.reward_amount))::bigint AS reward_avg_fen, SUM(wo.reward_amount)::bigint AS reward_fen FROM work_orders wo - JOIN order_items oi ON oi.id = wo.order_item_id + JOIN v_order_item_current oi ON oi.id = wo.order_item_id JOIN orders o ON o.id = oi.order_id WHERE o.pay_status IN ('paid', 'refunded') GROUP BY wo.product_rule_id @@ -583,7 +583,7 @@ export async function listAdminCostExceptions(payload: JsonObject = {}) { params.push(channel) clauses.push( `ce.order_item_id IN ( - SELECT oi.id FROM order_items oi WHERE COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) = $${params.length} + SELECT oi.id FROM v_order_item_current oi WHERE COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) = $${params.length} )`, ) } @@ -600,7 +600,7 @@ export async function listAdminCostExceptions(payload: JsonObject = {}) { ce.*, COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) AS channel FROM cost_exceptions ce - LEFT JOIN order_items oi ON oi.id = ce.order_item_id + LEFT JOIN v_order_item_current oi ON oi.id = ce.order_item_id ${where} ORDER BY ce.status ASC, ce.created_at DESC, ce.id DESC LIMIT $${listParams.length - 1} OFFSET $${listParams.length} @@ -642,7 +642,7 @@ export async function summarizeAdminCostExceptions( params.push(channel) clauses.push( `ce.order_item_id IN ( - SELECT oi.id FROM order_items oi WHERE COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) = $${params.length} + SELECT oi.id FROM v_order_item_current oi WHERE COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) = $${params.length} )`, ) } diff --git a/apps/backend/src/services/cost-center/cost-resolution-service.ts b/apps/backend/src/services/cost-center/cost-resolution-service.ts index a560000b..6e704d34 100644 --- a/apps/backend/src/services/cost-center/cost-resolution-service.ts +++ b/apps/backend/src/services/cost-center/cost-resolution-service.ts @@ -129,7 +129,7 @@ async function resolveManualCostHint(orderItemId: number | null) { cfg.credit_quantity, cfg.cost_mode, COALESCE((fp.config_json->>'rechargeUnitPriceFen')::bigint, 1) AS unit_price_fen - FROM order_items oi + FROM v_order_item_current oi JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id LEFT JOIN fulfillment_profiles fp ON fp.profile_key = 'manual_' || cfg.platform_key WHERE oi.id = $1 @@ -317,7 +317,7 @@ async function resolveCostContext(payload: JsonObject): Promise 'cancelled' LEFT JOIN work_product_rules wpr ON wpr.id = wo.product_rule_id diff --git a/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts b/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts index dcfab3f0..dc4bdf72 100644 --- a/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts +++ b/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts @@ -156,7 +156,7 @@ export async function scanManualSkus(options: ScanOptions) { COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen, (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date AS stat_date, resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS sku_key - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual' AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date @@ -247,7 +247,7 @@ export async function listManualSkus(input: Record = {}) { const platformKey = String(input.platformKey || input.platform_key || '').trim() const provider = String(input.provider || '').trim() - const conditions: string[] = [`p.status = 'active'`, `p.canonical_sku LIKE 'man:%'`] + const conditions: string[] = [`p.status = 'active'`] const params: unknown[] = ['manual'] conditions.push(productSourceFilterSql(params.length)) @@ -273,12 +273,18 @@ export async function listManualSkus(input: Record = {}) { if (provider) { params.push(provider) conditions.push(`EXISTS ( - SELECT 1 FROM order_items oi + SELECT 1 FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual' AND o.provider = $${params.length} - AND resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) - = canonicalize_internal_sku(substring(p.canonical_sku from 5)) + AND p.internal_sku_id = ( + SELECT sku.id + FROM internal_skus sku + WHERE sku.internal_sku_key = resolve_internal_sku( + oi.manual_sku_key, oi.sku_name, oi.sku_code + ) + LIMIT 1 + ) )`) } @@ -287,11 +293,11 @@ export async function listManualSkus(input: Record = {}) { ` SELECT COUNT(*)::int AS total FROM product_catalog p + JOIN internal_skus product_sku ON product_sku.id = p.internal_sku_id LEFT JOIN LATERAL ( SELECT * FROM manual_fulfillment_configs cfg - WHERE canonicalize_internal_sku(cfg.sku_key) - = canonicalize_internal_sku(substring(p.canonical_sku from 5)) + WHERE canonicalize_internal_sku(cfg.sku_key) = product_sku.internal_sku_key ORDER BY cfg.id LIMIT 1 ) cfg ON TRUE @@ -314,7 +320,7 @@ export async function listManualSkus(input: Record = {}) { AS first_seen_date, MAX((COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date) AS last_seen_date - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual' @@ -322,7 +328,7 @@ export async function listManualSkus(input: Record = {}) { ) SELECT p.id, - substring(p.canonical_sku from 5) AS sku_key, + product_sku.internal_sku_key AS sku_key, p.canonical_name AS display_name, COALESCE(manual_stats.shop_count, 0)::int AS shop_count, COALESCE(manual_stats.order_count, 0)::int AS order_count, @@ -340,16 +346,16 @@ export async function listManualSkus(input: Record = {}) { cfg.enabled AS config_enabled, cfg.channels_json AS config_channels_json FROM product_catalog p + JOIN internal_skus product_sku ON product_sku.id = p.internal_sku_id LEFT JOIN LATERAL ( SELECT * FROM manual_fulfillment_configs cfg - WHERE canonicalize_internal_sku(cfg.sku_key) - = canonicalize_internal_sku(substring(p.canonical_sku from 5)) + WHERE canonicalize_internal_sku(cfg.sku_key) = product_sku.internal_sku_key ORDER BY cfg.id LIMIT 1 ) cfg ON TRUE LEFT JOIN manual_stats - ON manual_stats.sku_key = canonicalize_internal_sku(substring(p.canonical_sku from 5)) + ON manual_stats.sku_key = product_sku.internal_sku_key WHERE ${where} ORDER BY COALESCE(manual_stats.item_count, 0) DESC, p.canonical_name ASC, p.id ASC LIMIT $${listParams.length - 1} OFFSET $${listParams.length} @@ -381,25 +387,29 @@ export async function getManualSkuOverview() { COUNT(*)::int AS item_count, MAX((COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date) AS last_seen_date - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual' GROUP BY 1 ), manual_scope AS ( - SELECT p.id, p.canonical_sku, p.canonical_name, cfg.id AS config_id + SELECT + p.id, + p.canonical_sku, + p.canonical_name, + product_sku.internal_sku_key, + cfg.id AS config_id FROM product_catalog p + JOIN internal_skus product_sku ON product_sku.id = p.internal_sku_id LEFT JOIN LATERAL ( SELECT cfg.id FROM manual_fulfillment_configs cfg - WHERE canonicalize_internal_sku(cfg.sku_key) - = canonicalize_internal_sku(substring(p.canonical_sku from 5)) + WHERE canonicalize_internal_sku(cfg.sku_key) = product_sku.internal_sku_key ORDER BY cfg.id LIMIT 1 ) cfg ON TRUE WHERE p.status = 'active' - AND p.canonical_sku LIKE 'man:%' AND ${productSourceFilterSql(1)} ) SELECT @@ -414,9 +424,7 @@ export async function getManualSkuOverview() { MAX(manual_stats.last_seen_date)::text AS last_scan_at FROM manual_scope LEFT JOIN manual_stats - ON manual_stats.sku_key = canonicalize_internal_sku( - substring(manual_scope.canonical_sku from 5) - ) + ON manual_stats.sku_key = manual_scope.internal_sku_key `, ['manual'], ) @@ -469,8 +477,12 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) { let resolvedProductId = canonicalProductId if (resolvedProductId) { - const product = await client.query<{ canonical_sku: string; status: string }>( - `SELECT canonical_sku, status + const product = await client.query<{ + canonical_sku: string + source_kind: string + status: string + }>( + `SELECT canonical_sku, source_kind, status FROM product_catalog WHERE id = $1 LIMIT 1`, @@ -479,9 +491,9 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) { if ( !product.rows[0] || product.rows[0].status !== 'active' || - !String(product.rows[0].canonical_sku || '').startsWith('man:') + product.rows[0].source_kind !== 'manual' ) { - throw createHttpError('人工履约配置只能关联有效的 man: 标准商品', { + throw createHttpError('人工履约配置只能关联来源为人工的有效标准商品', { statusCode: 400, errorCode: 'manual_config_product_source_invalid', }) @@ -510,10 +522,10 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) { .slice(0, 120) || skuKey.slice(0, 120) const product = await client.query<{ id: number }>( `INSERT INTO product_catalog ( - canonical_sku, canonical_name, category, unit_name, status, + canonical_sku, canonical_name, category, unit_name, source_kind, status, attributes_json, created_at, updated_at ) VALUES ( - $1, $2, '人工', '件', 'active', + $1, $2, '人工', '件', 'manual', 'active', '{"source":"manual_config","auto_created":true}'::jsonb, $3, $3 ) ON CONFLICT (canonical_sku) DO UPDATE SET @@ -602,7 +614,7 @@ async function retryPendingManualItems(skuKey: string): Promise { if (!key) return 0 const candidates = await query<{ id: number }>( `SELECT oi.id - FROM order_items oi + FROM v_order_item_current oi WHERE oi.fulfillment_status = 'pending' AND resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) = $1 ORDER BY oi.id @@ -688,7 +700,7 @@ export async function deleteManualSkuClassification( oi.id, (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date::text AS basis_date - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual' @@ -771,9 +783,12 @@ export async function deleteManualSkuClassification( [key], ) const rules = await client.query( - `DELETE FROM internal_sku_channels - WHERE canonicalize_internal_sku(internal_sku) = $1 AND channel = 'manual' - RETURNING id`, + `DELETE FROM sku_channel_routes route + USING internal_skus sku + WHERE sku.id = route.internal_sku_id + AND sku.internal_sku_key = $1 + AND route.channel = 'manual' + RETURNING route.id`, [key], ) const products = await client.query( @@ -786,8 +801,8 @@ export async function deleteManualSkuClassification( ), updated_at = $2::timestamptz WHERE status = 'active' - AND canonical_sku LIKE 'man:%' - AND canonicalize_internal_sku(substring(canonical_sku from 5)) = $1 + AND source_kind = 'manual' + AND internal_sku_id = (SELECT id FROM internal_skus WHERE internal_sku_key = $1 LIMIT 1) RETURNING id `, [key, nowIso()], @@ -864,7 +879,7 @@ export async function rematchManualFulfillments(input: Record = oi.id AS order_item_id, cfg.id AS config_id, cfg.canonical_product_id - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id JOIN manual_fulfillment_configs cfg ON canonicalize_internal_sku(cfg.sku_key) = resolve_internal_sku( @@ -1332,9 +1347,7 @@ function requiredRow(row: T | undefined, message: string): T { return row } -/** - * 人工配置 → internal_sku_channels(内部 SKU 精确匹配)。 - */ +/** 人工配置 → sku_channel_routes(内部 SKU 精确匹配)。 */ async function syncManualChannelRule( config: { id: number @@ -1350,10 +1363,13 @@ async function syncManualChannelRule( const otherChannelSql = ` SELECT EXISTS ( - SELECT 1 FROM internal_sku_channels - WHERE canonicalize_internal_sku(internal_sku) = $1 - AND enabled = TRUE - AND channel <> 'manual' + SELECT 1 + FROM sku_channel_routes route + JOIN internal_skus sku ON sku.id = route.internal_sku_id + WHERE sku.internal_sku_key = $1 + AND route.enabled = TRUE + AND route.route_role = 'primary' + AND route.channel <> 'manual' ) AS exists ` const otherChannel = client diff --git a/apps/backend/src/services/cost-center/product-catalog-service.ts b/apps/backend/src/services/cost-center/product-catalog-service.ts index e89a2453..7336850a 100644 --- a/apps/backend/src/services/cost-center/product-catalog-service.ts +++ b/apps/backend/src/services/cost-center/product-catalog-service.ts @@ -27,7 +27,6 @@ import { orderItemBasisDates, refreshFactsForDates } from '../finance/refresh-fa import { syncAdminWorkProductRulePrice } from '../worker-platform/admin-worker-catalog-service.js' import { AFFILIATE_TASK_AGG_JOIN, - FULFILLMENT_CHANNELS, MAPPING_CONFIDENCES, MAPPING_SOURCES, PRODUCT_CATEGORY_JOINS, @@ -52,7 +51,8 @@ import { normalizeRequiredText, normalizeStatus, parseJsonObject, - productSourceFilterSql, + productChannelFilterSql, + PRODUCT_CURRENT_CHANNEL_FILTERS, requiredRow, resolveMappingNameKey, text, @@ -64,21 +64,13 @@ import { type WorkCategoryRow, } from './cost-center-shared.js' -/** 商品来源的筛选取值:与 canonical_sku 前缀一一对应。 */ -const PRODUCT_SOURCE_FILTERS = new Set([ - 'affiliate_dash', - 'work_order', - 'third_party_voucher', - 'manual', -]) +/** 标准商品页签的当前渠道筛选取值。 */ +const PRODUCT_CHANNEL_FILTERS = new Set(PRODUCT_CURRENT_CHANNEL_FILTERS) -/** - * 校验新建商品的 SKU 前缀。 - * 前缀是商品来源的唯一表达,不允许出现无法归类的商品。 - */ -function assertKnownSourcePrefix(canonicalSku: string) { +/** 创建商品时把编码前缀固化为 source_kind,运行期不再反推来源。 */ +function resolveProductSourceKind(canonicalSku: string) { for (const item of PRODUCT_SOURCE_PREFIXES) { - if (canonicalSku.startsWith(item.prefix)) return + if (canonicalSku.startsWith(item.prefix)) return item.source } throw createHttpError( `标准 SKU 必须以下列前缀开头:${PRODUCT_SOURCE_PREFIXES.map((item) => item.prefix).join(' / ')}`, @@ -108,17 +100,19 @@ export async function listAdminProducts(payload: JsonObject = {}) { assertOneOf(status, PRODUCT_STATUSES, '商品状态无效') baseParams.push(status) baseClauses.push(`p.status = $${baseParams.length}`) - // carrierType 参数保留,语义改为「按 canonical_sku 前缀筛选商品来源」。 - const source = text(payload.carrierType ?? payload.carrier_type ?? payload.source) - if (source) { - assertOneOf(source, PRODUCT_SOURCE_FILTERS, '商品来源无效') - baseParams.push(source) - baseClauses.push(productSourceFilterSql(baseParams.length)) + // carrierType 参数仅为兼容旧前端,语义统一为「按当前主渠道筛选」。 + const channel = text( + payload.channel ?? payload.currentChannel ?? payload.carrierType ?? payload.carrier_type, + ) + if (channel) { + assertOneOf(channel, PRODUCT_CHANNEL_FILTERS, '商品渠道无效') + baseParams.push(channel) + baseClauses.push(productChannelFilterSql(baseParams.length)) } // 人工/接单平台商品不需要 affiliate 任务统计:跳过 30 天 fulfillment_tasks 聚合。 // 只有「全部」和「自动发货」Tab 才需要这条聚合,避免小库 IO 被无关数据拖慢。 - const needsAffiliateTaskStats = !source || source === 'affiliate_dash' + const needsAffiliateTaskStats = !channel || channel === 'affiliate_dash' const affiliateTaskJoinSql = needsAffiliateTaskStats ? AFFILIATE_TASK_AGG_JOIN : '' const affiliateColumnsSql = needsAffiliateTaskStats ? 'COALESCE(aff.task_count, 0) AS affiliate_task_count, aff.last_fulfilled_at AS affiliate_last_fulfilled_at' @@ -146,7 +140,7 @@ export async function listAdminProducts(payload: JsonObject = {}) { const [countResult, result, categoryStatResult, categoryResult] = await Promise.all([ query<{ total: number }>( - `SELECT COUNT(*)::int AS total FROM product_catalog p ${PRODUCT_CATEGORY_JOINS} ${where}`, + `SELECT COUNT(*)::int AS total FROM v_product_current p ${PRODUCT_CATEGORY_JOINS} ${where}`, listParams, ), query( @@ -156,16 +150,6 @@ export async function listAdminProducts(payload: JsonObject = {}) { tpl_rule.pricing_mode AS template_pricing_mode, tpl_rule.enabled AS template_enabled, tpl_rule.sharing_enabled AS template_sharing_enabled, - ( - SELECT c.channel - FROM internal_sku_channels c - WHERE c.enabled = TRUE - AND c.is_fallback = FALSE - AND canonicalize_internal_sku(c.internal_sku) - = canonicalize_internal_sku(p.canonical_name) - ORDER BY c.priority, c.channel - LIMIT 1 - ) AS active_primary_channel, CASE WHEN c.parent_id IS NOT NULL THEN c.id END AS sub_category_id, CASE WHEN c.parent_id IS NOT NULL THEN c.name ELSE '' END AS sub_category_name, root.id AS root_category_id, @@ -228,7 +212,7 @@ export async function listAdminProducts(payload: JsonObject = {}) { ) ) AS active_cost_effective_from, ${affiliateColumnsSql} - FROM product_catalog p + FROM v_product_current p ${PRODUCT_CATEGORY_JOINS} LEFT JOIN work_product_rules tpl_rule ON p.canonical_sku LIKE 'tpl:%' @@ -238,9 +222,9 @@ export async function listAdminProducts(payload: JsonObject = {}) { ORDER BY p.status ASC, CASE - WHEN p.canonical_sku LIKE 'aff:%' THEN 1 - WHEN p.canonical_sku LIKE 'tpl:%' OR p.canonical_sku LIKE 'wo:%' THEN 2 - WHEN p.canonical_sku LIKE 'man:%' THEN 3 + WHEN p.source_kind = 'affiliate_dash' THEN 1 + WHEN p.source_kind = 'work_order' THEN 2 + WHEN p.source_kind = 'manual' THEN 3 ELSE 4 END, p.canonical_name ASC, @@ -252,7 +236,7 @@ export async function listAdminProducts(payload: JsonObject = {}) { query( ` SELECT c.id AS category_id, c.name, c.parent_id, COUNT(*)::int AS total - FROM product_catalog p + FROM v_product_current p ${PRODUCT_CATEGORY_JOINS} ${baseWhere} GROUP BY c.id, c.name, c.parent_id @@ -336,7 +320,7 @@ export async function createAdminProduct(payload: JsonObject, _session: AdminSes errorCode: 'cost_product_sku_invalid', }) } - assertKnownSourcePrefix(canonicalSku) + const sourceKind = resolveProductSourceKind(canonicalSku) const canonicalName = normalizeRequiredText( payload.canonicalName ?? payload.canonical_name, '商品名称', @@ -351,13 +335,22 @@ export async function createAdminProduct(payload: JsonObject, _session: AdminSes const result = await query( ` INSERT INTO product_catalog ( - canonical_sku, canonical_name, category, unit_name, status, + canonical_sku, canonical_name, category, unit_name, source_kind, status, attributes_json, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $7) + ) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $8) ON CONFLICT (canonical_sku) DO NOTHING RETURNING * `, - [canonicalSku, canonicalName, category, unitName, status, JSON.stringify(attributes), now], + [ + canonicalSku, + canonicalName, + category, + unitName, + sourceKind, + status, + JSON.stringify(attributes), + now, + ], ) const inserted = result.rows[0] if (!inserted) { @@ -423,7 +416,7 @@ export async function deleteAdminProduct(productId: number | string) { (SELECT COUNT(*)::int FROM channel_product_mappings WHERE canonical_product_id = $1) AS mapping_count, (SELECT COUNT(*)::int FROM cost_versions WHERE canonical_product_id = $1) AS version_count, (SELECT COUNT(*)::int FROM cost_snapshots WHERE canonical_product_id = $1) AS snapshot_count, - (SELECT COUNT(*)::int FROM order_items WHERE canonical_product_id = $1) AS order_item_count + (SELECT COUNT(*)::int FROM fulfillments WHERE canonical_product_id = $1) AS order_item_count `, [id], ) @@ -462,7 +455,7 @@ export async function deleteAdminVoucherProduct(productId: number | string) { [id], ) const current = requiredRow(currentResult.rows[0], '商品不存在') - if (!current.canonical_sku.startsWith('tpv:')) { + if (current.source_kind !== 'third_party_voucher') { throw createHttpError('该商品不是第三方卡券商品', { statusCode: 400, errorCode: 'voucher_product_type_invalid', @@ -487,7 +480,7 @@ export async function deleteAdminVoucherProduct(productId: number | string) { ) ) AS snapshot_count, - (SELECT COUNT(*)::int FROM order_items WHERE canonical_product_id = $1) + (SELECT COUNT(*)::int FROM fulfillments WHERE canonical_product_id = $1) AS order_item_count `, [id], @@ -500,28 +493,20 @@ export async function deleteAdminVoucherProduct(productId: number | string) { await client.query( ` - UPDATE internal_sku_channels + UPDATE sku_channel_routes route SET enabled = FALSE, - is_fallback = FALSE, + route_role = 'fallback', updated_at = $3 - WHERE channel = 'third_party_voucher' + FROM internal_skus sku + WHERE sku.id = route.internal_sku_id + AND route.channel = 'third_party_voucher' AND ( - canonical_product_id = $1 - OR canonicalize_internal_sku(internal_sku) = canonicalize_internal_sku($2) + route.canonical_product_id = $1 + OR sku.internal_sku_key = canonicalize_internal_sku($2) ) `, [id, internalSku, nowIso()], ) - await client.query( - ` - UPDATE product_channel_capabilities - SET enabled = FALSE, - updated_at = $2 - WHERE product_id = $1 AND channel = 'third_party_voucher' - `, - [id, nowIso()], - ) - if (mappingCount > 0 || snapshotCount > 0 || orderItemCount > 0) { await client.query( ` @@ -566,14 +551,15 @@ export async function deleteAdminVoucherProduct(productId: number | string) { `DELETE FROM cost_versions WHERE canonical_product_id = $1`, [id], ) - await client.query(`DELETE FROM product_channel_capabilities WHERE product_id = $1`, [id]) await client.query( ` - DELETE FROM internal_sku_channels - WHERE channel = 'third_party_voucher' + DELETE FROM sku_channel_routes route + USING internal_skus sku + WHERE sku.id = route.internal_sku_id + AND route.channel = 'third_party_voucher' AND ( - canonical_product_id = $1 - OR canonicalize_internal_sku(internal_sku) = canonicalize_internal_sku($2) + route.canonical_product_id = $1 + OR sku.internal_sku_key = canonicalize_internal_sku($2) ) `, [id, internalSku], @@ -627,12 +613,14 @@ export async function listAdminChannelMappings(payload: JsonObject = {}) { params.push(canonicalProductId) clauses.push(`m.canonical_product_id = $${params.length}`) } - // carrierType 参数保留,语义改为「按 canonical_sku 前缀筛选商品来源」。 - const source = text(payload.carrierType ?? payload.carrier_type ?? payload.source) - if (source) { - assertOneOf(source, PRODUCT_SOURCE_FILTERS, '商品来源无效') - params.push(source) - clauses.push(productSourceFilterSql(params.length)) + // carrierType 仅为旧客户端兼容,筛选口径统一是商品当前主渠道。 + const channel = text( + payload.channel ?? payload.currentChannel ?? payload.carrierType ?? payload.carrier_type, + ) + if (channel) { + assertOneOf(channel, PRODUCT_CHANNEL_FILTERS, '商品渠道无效') + params.push(channel) + clauses.push(productChannelFilterSql(params.length)) } if (payload.enabled !== undefined && payload.enabled !== '') { params.push(normalizeBoolean(payload.enabled)) @@ -652,7 +640,7 @@ export async function listAdminChannelMappings(payload: JsonObject = {}) { p.unit_name, p.status FROM channel_product_mappings m - JOIN product_catalog p ON p.id = m.canonical_product_id + JOIN v_product_current p ON p.id = m.canonical_product_id ${where} GROUP BY p.id, @@ -672,7 +660,7 @@ export async function listAdminChannelMappings(payload: JsonObject = {}) { COUNT(DISTINCT p.id)::int AS total_count, COUNT(*)::int AS mapping_total FROM channel_product_mappings m - JOIN product_catalog p ON p.id = m.canonical_product_id + JOIN v_product_current p ON p.id = m.canonical_product_id ${where} `, params, @@ -698,7 +686,7 @@ export async function listAdminChannelMappings(payload: JsonObject = {}) { p.canonical_sku, p.canonical_name FROM channel_product_mappings m - JOIN product_catalog p ON p.id = m.canonical_product_id + JOIN v_product_current p ON p.id = m.canonical_product_id ${where} ${where ? 'AND' : 'WHERE'} m.canonical_product_id = ANY($${mappingParams.length}::bigint[]) ORDER BY @@ -748,16 +736,16 @@ export async function listAdminChannelMappings(payload: JsonObject = {}) { * 「全部」时并行跑四种模式再求和,避免一次全表 hash join。 */ export async function getAdminChannelMappingDiagnostics(payload: JsonObject = {}) { - const source = text(payload.carrierType ?? payload.carrier_type ?? payload.source) - if (source) assertOneOf(source, PRODUCT_SOURCE_FILTERS, '商品来源无效') - const channel = text(payload.channel ?? payload.channel) - if (channel) assertOneOf(channel, FULFILLMENT_CHANNELS, '履约方式无效') + const channel = text( + payload.channel ?? payload.currentChannel ?? payload.carrierType ?? payload.carrier_type, + ) + if (channel) assertOneOf(channel, PRODUCT_CHANNEL_FILTERS, '商品渠道无效') const productParams: unknown[] = [] - let sourceSql = 'TRUE' - if (source) { - productParams.push(source) - sourceSql = productSourceFilterSql(productParams.length) + let productChannelSql = 'TRUE' + if (channel) { + productParams.push(channel) + productChannelSql = productChannelFilterSql(productParams.length) } const counts = await query<{ product_total: number @@ -767,20 +755,20 @@ export async function getAdminChannelMappingDiagnostics(payload: JsonObject = {} ` SELECT ( - SELECT COUNT(*)::int FROM product_catalog p - WHERE p.status = 'active' AND ${sourceSql} + SELECT COUNT(*)::int FROM v_product_current p + WHERE p.status = 'active' AND ${productChannelSql} ) AS product_total, ( SELECT COUNT(*)::int FROM channel_product_mappings m - JOIN product_catalog p ON p.id = m.canonical_product_id - WHERE m.enabled = TRUE AND ${sourceSql} + JOIN v_product_current p ON p.id = m.canonical_product_id + WHERE m.enabled = TRUE AND ${productChannelSql} ) AS mapping_total, ( SELECT COUNT(DISTINCT m.canonical_product_id)::int FROM channel_product_mappings m - JOIN product_catalog p ON p.id = m.canonical_product_id - WHERE m.enabled = TRUE AND ${sourceSql} + JOIN v_product_current p ON p.id = m.canonical_product_id + WHERE m.enabled = TRUE AND ${productChannelSql} ) AS mapping_product_total `, productParams, @@ -797,7 +785,7 @@ export async function getAdminChannelMappingDiagnostics(payload: JsonObject = {} COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) AS channel, COUNT(*)::int AS item_count FROM cost_exceptions ce - JOIN order_items oi ON oi.id = ce.order_item_id + JOIN v_order_item_current oi ON oi.id = ce.order_item_id WHERE ce.reason = 'product_mapping_missing' AND ce.status = 'open' AND COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) = $1 @@ -816,9 +804,12 @@ export async function getAdminChannelMappingDiagnostics(payload: JsonObject = {} } } - const modes = channel - ? [channel] - : ['work_order', 'affiliate_dash', 'third_party_voucher', 'manual', 'on_account'] + const modes = + channel === 'unrouted' + ? [] + : channel + ? [channel] + : ['work_order', 'affiliate_dash', 'third_party_voucher', 'manual', 'on_account'] // 顺序跑:并行会同时扫 cost_exceptions,实测 4 路并发比串行更慢(I/O 争抢)。 const aggregates: Array<{ groupTotal: number; itemTotal: number }> = [] for (const mode of modes) { @@ -849,8 +840,14 @@ export async function listAdminUnmappedMappingGroups(payload: JsonObject = {}) { const params: unknown[] = [] const clauses = [`ce.reason = 'product_mapping_missing'`, `ce.status = 'open'`] - const channel = text(payload.channel ?? payload.channel) - if (channel) assertOneOf(channel, FULFILLMENT_CHANNELS, '履约方式无效') + const channel = text(payload.channel ?? payload.currentChannel) + if (channel) assertOneOf(channel, PRODUCT_CHANNEL_FILTERS, '商品渠道无效') + if (channel === 'unrouted') { + return { + items: [], + pagination: { page, pageSize, total: 0, itemTotal: 0 }, + } + } const provider = text(payload.provider) if (provider) { params.push(normalizeProviderKey(provider)) @@ -883,7 +880,7 @@ export async function listAdminUnmappedMappingGroups(payload: JsonObject = {}) { COALESCE(ce.detail_json->>'skuCode', '') AS sku_code, COUNT(*)::int AS item_count FROM cost_exceptions ce - JOIN order_items oi ON oi.id = ce.order_item_id + JOIN v_order_item_current oi ON oi.id = ce.order_item_id WHERE ${clauses.join(' AND ')} AND COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) = $${totalParams.length} GROUP BY 1, 2, 3 @@ -936,7 +933,7 @@ export async function listAdminUnmappedMappingGroups(payload: JsonObject = {}) { MAX(NULLIF(ce.detail_json->>'basisAt', '')) AS last_seen_at, COUNT(*)::int AS item_count FROM cost_exceptions ce - JOIN order_items oi ON oi.id = ce.order_item_id + JOIN v_order_item_current oi ON oi.id = ce.order_item_id WHERE ${clauses.join(' AND ')} ${modeClause} GROUP BY 1, 2, 3, 4 @@ -1201,7 +1198,7 @@ export async function backfillAdminProductMappings(payload: JsonObject = {}) { oi.sku_code, oi.normalized_sku_name, oi.normalized_sku_series - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.canonical_product_id IS NULL AND oi.id > $1 @@ -1250,7 +1247,7 @@ export async function backfillAdminProductMappings(payload: JsonObject = {}) { AND ce.subject_type = 'order_item' AND ce.reason = 'product_mapping_missing' AND EXISTS ( - SELECT 1 FROM order_items oi + SELECT 1 FROM v_order_item_current oi WHERE oi.id = ce.subject_id AND oi.canonical_product_id IS NOT NULL ) `, @@ -1261,7 +1258,7 @@ export async function backfillAdminProductMappings(payload: JsonObject = {}) { const remainingResult = await query<{ total: number }>( ` SELECT COUNT(*)::int AS total - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.canonical_product_id IS NULL AND ($1 = '' OR o.provider = $1) @@ -1363,7 +1360,7 @@ export async function backfillAdminNormalizedQuantities(payload: JsonObject = {} ` SELECT oi.id, oi.sku_name, oi.sku_code, oi.normalized_sku_name, oi.normalized_sku_series, oi.normalized_quantity - FROM order_items oi + FROM v_order_item_current oi ${range ? 'JOIN orders o ON o.id = oi.order_id' : ''} WHERE (oi.normalized_quantity IS NULL OR oi.normalized_sku_name IS NULL @@ -1413,7 +1410,7 @@ export async function backfillAdminNormalizedQuantities(payload: JsonObject = {} const remainingResult = await query<{ total: number }>( `SELECT COUNT(*)::int AS total - FROM order_items oi + FROM v_order_item_current oi ${range ? 'JOIN orders o ON o.id = oi.order_id' : ''} WHERE (oi.normalized_quantity IS NULL OR oi.normalized_sku_name IS NULL @@ -1507,9 +1504,10 @@ export async function setAdminProductPrice( id: number canonical_sku: string canonical_name: string + source_kind: string attributes_json: unknown }>( - 'SELECT id, canonical_sku, canonical_name, attributes_json FROM product_catalog WHERE id = $1', + 'SELECT id, canonical_sku, canonical_name, source_kind, attributes_json FROM product_catalog WHERE id = $1', [productId], ) const product = productResult.rows[0] @@ -1518,7 +1516,7 @@ export async function setAdminProductPrice( } // 1. 如果是自动发货商品,委托给 setAdminAffiliateProductPrice - if (product.canonical_sku.startsWith('aff:')) { + if (product.source_kind === 'affiliate_dash') { return setAdminAffiliateProductPrice(productId, payload, session) } @@ -1532,7 +1530,7 @@ export async function setAdminProductPrice( const changeReason = String(payload.changeReason || '在商品与成本中心统一调整价格').trim() // 2. 如果是接单模板商品 (tpl:) - if (product.canonical_sku.startsWith('tpl:')) { + if (product.source_kind === 'work_order' && product.canonical_sku.startsWith('tpl:')) { const ruleKey = product.canonical_sku.slice(4) const ruleResult = await query<{ id: number @@ -1738,12 +1736,13 @@ export async function setAdminProductPrice( } } - // 3. 其他标准商品 (wo: / tpv: / man:) - const costItemCode = product.canonical_sku.startsWith('wo:') - ? 'sys_work_order_reward' - : product.canonical_sku.startsWith('tpv:') - ? 'sys_third_party_voucher_cost' - : 'sys_manual_service' + // 3. 其他标准商品:成本项由 source_kind 决定,canonical_sku 只保留载体键。 + const costItemCode = + product.source_kind === 'work_order' + ? 'sys_work_order_reward' + : product.source_kind === 'third_party_voucher' + ? 'sys_third_party_voucher_cost' + : 'sys_manual_service' const costItemResult = await query<{ id: number }>( 'SELECT id FROM cost_items WHERE code = $1 LIMIT 1', [costItemCode], @@ -1767,7 +1766,7 @@ export async function setAdminProductPrice( const earliest = await query<{ basis_date: string | null }>( ` SELECT MIN((COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date)::text AS basis_date - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.canonical_product_id = $1 AND oi.fulfillment_status = 'assigned' @@ -1849,7 +1848,7 @@ export async function setAdminProductPrice( const missingItems = await query<{ id: number }>( ` SELECT oi.id - FROM order_items oi + FROM v_order_item_current oi WHERE oi.canonical_product_id = $1 AND oi.fulfillment_status = 'assigned' AND NOT EXISTS ( @@ -1875,7 +1874,7 @@ export async function setAdminProductPrice( const coveredItems = await query<{ id: number }>( ` SELECT oi.id - FROM order_items oi + FROM v_order_item_current oi WHERE oi.canonical_product_id = $1 AND oi.fulfillment_status = 'assigned' AND EXISTS ( @@ -1920,13 +1919,16 @@ export async function syncAdminProductPrice(productIdInput: unknown, session: Ad id: number canonical_sku: string canonical_name: string - }>('SELECT id, canonical_sku, canonical_name FROM product_catalog WHERE id = $1', [productId]) + source_kind: string + }>('SELECT id, canonical_sku, canonical_name, source_kind FROM product_catalog WHERE id = $1', [ + productId, + ]) const product = productResult.rows[0] if (!product) { throw createHttpError('商品不存在', { statusCode: 404 }) } - if (product.canonical_sku.startsWith('tpl:')) { + if (product.source_kind === 'work_order' && product.canonical_sku.startsWith('tpl:')) { const ruleKey = product.canonical_sku.slice(4) const ruleResult = await query<{ id: number }>( 'SELECT id FROM work_product_rules WHERE rule_key = $1 LIMIT 1', @@ -1946,7 +1948,7 @@ export async function syncAdminProductPrice(productIdInput: unknown, session: Ad } } - if (product.canonical_sku.startsWith('aff:')) { + if (product.source_kind === 'affiliate_dash') { // 成本快照由成本快照管道按缺补算,这里不再同步触发。 return { success: true, diff --git a/apps/backend/src/services/cost-center/product-normalize-service.test.ts b/apps/backend/src/services/cost-center/product-normalize-service.test.ts index 4ac1cd97..4bd99f2c 100644 --- a/apps/backend/src/services/cost-center/product-normalize-service.test.ts +++ b/apps/backend/src/services/cost-center/product-normalize-service.test.ts @@ -68,7 +68,7 @@ function item(patch: Record = {}) { canonical_product_id: null as number | null, work_rule_key: null as string | null, manual_config_product_id: null as number | null, - manual_config_product_sku: null as string | null, + manual_config_product_source_kind: null as string | null, ...patch, } } @@ -126,7 +126,7 @@ test('商品归一:人工渠道直接使用配置里指定的商品', () => { fulfillment_channel: 'manual', sku_code: 'unknown', manual_config_product_id: 44, - manual_config_product_sku: 'man:配置商品', + manual_config_product_source_kind: 'manual', }), index, ), @@ -177,13 +177,13 @@ test('商品归一:其他已确定渠道也不能借用同名其他来源商 } }) -test('商品归一:人工配置也只能挂载 man: 商品', () => { +test('商品归一:人工配置只能挂载来源为人工的标准商品', () => { assert.equal( resolveProductId( item({ fulfillment_channel: 'manual', manual_config_product_id: 99, - manual_config_product_sku: 'aff:错误商品', + manual_config_product_source_kind: 'affiliate_dash', }), index, ), @@ -194,7 +194,7 @@ test('商品归一:人工配置也只能挂载 man: 商品', () => { item({ fulfillment_channel: 'on_account', manual_config_product_id: 99, - manual_config_product_sku: 'man:正确商品', + manual_config_product_source_kind: 'manual', }), index, ), @@ -240,10 +240,11 @@ test('三方卡券自动建商品时优先使用内部中文 SKU', () => { test('商品归一扫描会纠正所有渠道的错误来源商品', async () => { const source = await readFile(new URL('./product-normalize-service.ts', import.meta.url), 'utf8') - assert.ok(source.includes("current_product.canonical_sku NOT LIKE 'aff:%'")) - assert.ok(source.includes("current_product.canonical_sku NOT LIKE 'man:%'")) - assert.ok(source.includes("current_product.canonical_sku NOT LIKE 'tpl:%'")) - assert.ok(source.includes("current_product.canonical_sku NOT LIKE 'tpv:%'")) + assert.ok(source.includes("current_product.source_kind IS DISTINCT FROM 'affiliate_dash'")) + assert.ok(source.includes("current_product.source_kind IS DISTINCT FROM 'manual'")) + assert.ok(source.includes("current_product.source_kind IS DISTINCT FROM 'work_order'")) + assert.ok(source.includes("current_product.source_kind IS DISTINCT FROM 'third_party_voucher'")) + assert.ok(!source.includes("current_product.canonical_sku NOT LIKE 'man:%'")) assert.ok(source.includes('canonicalProductId: null')) assert.ok(source.includes('onlyWhenCanonicalProductDifferent: true')) }) diff --git a/apps/backend/src/services/cost-center/product-normalize-service.ts b/apps/backend/src/services/cost-center/product-normalize-service.ts index 050ab17b..e99ac9fd 100644 --- a/apps/backend/src/services/cost-center/product-normalize-service.ts +++ b/apps/backend/src/services/cost-center/product-normalize-service.ts @@ -11,7 +11,7 @@ import { /** * 管道 B:商品归一。 * - * 只写 `order_items.canonical_product_id`,不改渠道、不碰成本; + * 只写 `fulfillments.canonical_product_id`,不改渠道、不碰成本; * 例外:三方卡券缺商品时按内部中文 SKU 自动建 `tpv:` 商品并补渠道规则。 * * 解析顺序: @@ -57,7 +57,15 @@ type ItemRow = { canonical_product_id?: number | null work_rule_key: string | null manual_config_product_id: number | null - manual_config_product_sku?: string | null + manual_config_product_source_kind?: string | null +} + +const EXPECTED_PRODUCT_SOURCE: Record = { + affiliate_dash: 'affiliate_dash', + work_order: 'work_order', + third_party_voucher: 'third_party_voucher', + manual: 'manual', + on_account: 'manual', } function key(...parts: string[]): string { @@ -179,10 +187,13 @@ async function ensureVoucherProductForItem(item: ItemRow): Promise( ` INSERT INTO product_catalog ( - canonical_sku, canonical_name, category, unit_name, status, + canonical_sku, canonical_name, category, unit_name, source_kind, status, attributes_json, created_at, updated_at ) - VALUES ($1, $2, '卡券', '张', 'active', '{"source":"third_party_voucher","auto_created":true}'::jsonb, NOW(), NOW()) + VALUES ( + $1, $2, '卡券', '张', 'third_party_voucher', 'active', + '{"source":"third_party_voucher","auto_created":true}'::jsonb, NOW(), NOW() + ) ON CONFLICT (canonical_sku) DO NOTHING RETURNING id `, @@ -210,15 +221,6 @@ async function ensureVoucherProductForItem(item: ItemRow): Promise 'cancelled' LEFT JOIN work_product_rules wpr ON wpr.id = wo.product_rule_id @@ -358,36 +360,22 @@ export async function normalizeOrderItemProducts( WHERE oi.fulfillment_status = 'assigned' AND ( oi.canonical_product_id IS NULL + OR current_product.id IS NULL OR ( oi.fulfillment_channel = 'third_party_voucher' - AND ( - current_product.id IS NULL - OR current_product.canonical_sku NOT LIKE 'tpv:%' - ) + AND current_product.source_kind IS DISTINCT FROM 'third_party_voucher' ) OR ( oi.fulfillment_channel = 'affiliate_dash' - AND ( - current_product.id IS NULL - OR current_product.canonical_sku NOT LIKE 'aff:%' - ) + AND current_product.source_kind IS DISTINCT FROM 'affiliate_dash' ) OR ( oi.fulfillment_channel IN ('manual', 'on_account') - AND ( - current_product.id IS NULL - OR current_product.canonical_sku NOT LIKE 'man:%' - ) + AND current_product.source_kind IS DISTINCT FROM 'manual' ) OR ( oi.fulfillment_channel = 'work_order' - AND ( - current_product.id IS NULL - OR ( - current_product.canonical_sku NOT LIKE 'tpl:%' - AND current_product.canonical_sku NOT LIKE 'wo:%' - ) - ) + AND current_product.source_kind IS DISTINCT FROM 'work_order' ) ) AND oi.id > $1 @@ -459,7 +447,7 @@ async function loadMappingIndex(): Promise { ` SELECT id, canonical_sku FROM product_catalog - WHERE status = 'active' AND canonical_sku LIKE 'aff:%' + WHERE status = 'active' AND source_kind = 'affiliate_dash' `, ) @@ -467,7 +455,9 @@ async function loadMappingIndex(): Promise { ` SELECT id, canonical_sku FROM product_catalog - WHERE status = 'active' AND canonical_sku LIKE 'tpl:%' + WHERE status = 'active' + AND source_kind = 'work_order' + AND canonical_sku LIKE 'tpl:%' `, ) @@ -475,7 +465,7 @@ async function loadMappingIndex(): Promise { ` SELECT id, canonical_sku FROM product_catalog - WHERE status = 'active' AND canonical_sku LIKE 'tpv:%' + WHERE status = 'active' AND source_kind = 'third_party_voucher' `, ) @@ -537,8 +527,8 @@ export async function normalizeProductsForItems(itemIds: number[]): Promise<{ oi.canonical_product_id, wpr.rule_key AS work_rule_key, cfg.canonical_product_id AS manual_config_product_id, - cfg_product.canonical_sku AS manual_config_product_sku - FROM order_items oi + cfg_product.source_kind AS manual_config_product_source_kind + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id LEFT JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled' LEFT JOIN work_product_rules wpr ON wpr.id = wo.product_rule_id @@ -548,36 +538,22 @@ export async function normalizeProductsForItems(itemIds: number[]): Promise<{ WHERE oi.fulfillment_status = 'assigned' AND ( oi.canonical_product_id IS NULL + OR current_product.id IS NULL OR ( oi.fulfillment_channel = 'third_party_voucher' - AND ( - current_product.id IS NULL - OR current_product.canonical_sku NOT LIKE 'tpv:%' - ) + AND current_product.source_kind IS DISTINCT FROM 'third_party_voucher' ) OR ( oi.fulfillment_channel = 'affiliate_dash' - AND ( - current_product.id IS NULL - OR current_product.canonical_sku NOT LIKE 'aff:%' - ) + AND current_product.source_kind IS DISTINCT FROM 'affiliate_dash' ) OR ( oi.fulfillment_channel IN ('manual', 'on_account') - AND ( - current_product.id IS NULL - OR current_product.canonical_sku NOT LIKE 'man:%' - ) + AND current_product.source_kind IS DISTINCT FROM 'manual' ) OR ( oi.fulfillment_channel = 'work_order' - AND ( - current_product.id IS NULL - OR ( - current_product.canonical_sku NOT LIKE 'tpl:%' - AND current_product.canonical_sku NOT LIKE 'wo:%' - ) - ) + AND current_product.source_kind IS DISTINCT FROM 'work_order' ) ) AND oi.id = ANY($1::bigint[]) diff --git a/apps/backend/src/services/cost-center/template-bootstrap-service.ts b/apps/backend/src/services/cost-center/template-bootstrap-service.ts index 5b53afdf..03137925 100644 --- a/apps/backend/src/services/cost-center/template-bootstrap-service.ts +++ b/apps/backend/src/services/cost-center/template-bootstrap-service.ts @@ -168,9 +168,9 @@ export async function applyAdminTemplateProductBootstrap( const inserted = await client.query<{ id: number }>( ` INSERT INTO product_catalog ( - canonical_sku, canonical_name, category, unit_name, status, + canonical_sku, canonical_name, category, unit_name, source_kind, status, attributes_json, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $7) + ) VALUES ($1, $2, $3, $4, 'work_order', $5, $6::jsonb, $7, $7) ON CONFLICT (canonical_sku) DO NOTHING RETURNING id `, @@ -626,7 +626,7 @@ async function resetImportedTemplateProductBootstrap(range: { fromDate: string; ` WITH candidates AS ( SELECT oi.id, oi.canonical_product_id AS expected_product_id - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id JOIN channel_product_mappings m ON m.provider = o.provider @@ -680,7 +680,7 @@ async function resetImportedTemplateProductBootstrap(range: { fromDate: string; AND m.note LIKE '接单模板 %自动初始化%' AND EXISTS ( SELECT 1 - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.canonical_product_id = m.canonical_product_id AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date @@ -732,7 +732,7 @@ async function loadTemplateProductBootstrapData(range: { fromDate: string; toDat wo.product_rule_id, r.rule_key, COUNT(DISTINCT oi.id)::int AS item_count - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled' JOIN work_product_rules r ON r.id = wo.product_rule_id AND r.enabled = TRUE diff --git a/apps/backend/src/services/cost-center/voucher-product-service.test.ts b/apps/backend/src/services/cost-center/voucher-product-service.test.ts index 03e01f07..1799006c 100644 --- a/apps/backend/src/services/cost-center/voucher-product-service.test.ts +++ b/apps/backend/src/services/cost-center/voucher-product-service.test.ts @@ -8,7 +8,8 @@ test('卡券 SKU 一个入口完成商品、规则、成本版本和订单回填 assert.ok(service.includes('withTransaction(')) assert.ok(service.includes("channel: 'third_party_voucher'")) assert.ok(service.includes('sys_third_party_voucher_cost')) - assert.ok(service.includes('UPDATE order_items oi')) + assert.ok(service.includes('attachFulfillmentConfigurationsWithExecutor')) + assert.ok(!service.includes('UPDATE order_items oi')) assert.ok(service.includes('syncCostSnapshotsForItems')) assert.ok(service.includes('refreshFactsForDates')) assert.ok(service.includes("resolveExceptionsForItemsByReason(result.itemIds, 'cost_missing')")) diff --git a/apps/backend/src/services/cost-center/voucher-product-service.ts b/apps/backend/src/services/cost-center/voucher-product-service.ts index 66fd82da..01e06074 100644 --- a/apps/backend/src/services/cost-center/voucher-product-service.ts +++ b/apps/backend/src/services/cost-center/voucher-product-service.ts @@ -6,6 +6,7 @@ import type { AdminSession } from '../admin/admin-auth-service.js' import { orderItemBasisDates, refreshFactsForDates } from '../finance/refresh-facts.js' import { upsertInternalSkuChannelWithClient } from '../fulfillment/internal-sku-channel-service.js' import { canonicalizeInternalSku, containsCjk } from '../fulfillment/internal-sku-resolver.js' +import { attachFulfillmentConfigurationsWithExecutor } from '../fulfillment/fulfillment-state-service.js' import { resolvePriceEffectiveFrom } from './cost-center-normalizers.js' import { resolveExceptionsForItemsByReason, @@ -59,14 +60,15 @@ export async function upsertAdminVoucherProduct(payload: JsonObject, session: Ad const productResult = await client.query( ` INSERT INTO product_catalog ( - canonical_sku, canonical_name, category, unit_name, status, + canonical_sku, canonical_name, category, unit_name, source_kind, status, attributes_json, created_at, updated_at ) VALUES ( - $1, $2, '卡券', '张', 'active', + $1, $2, '卡券', '张', 'third_party_voucher', 'active', $3::jsonb, $4, $4 ) ON CONFLICT (canonical_sku) DO UPDATE SET status = 'active', + source_kind = EXCLUDED.source_kind, canonical_name = EXCLUDED.canonical_name, attributes_json = product_catalog.attributes_json || EXCLUDED.attributes_json, updated_at = EXCLUDED.updated_at @@ -89,21 +91,6 @@ export async function upsertAdminVoucherProduct(payload: JsonObject, session: Ad throw createHttpError('卡券标准商品保存失败', { statusCode: 500 }) } - await client.query( - ` - INSERT INTO product_channel_capabilities ( - product_id, channel, priority, is_fallback, enabled, source, created_at, updated_at - ) VALUES ($1, 'third_party_voucher', 10, FALSE, TRUE, 'voucher', $2, $2) - ON CONFLICT (product_id, channel) DO UPDATE SET - priority = 10, - is_fallback = FALSE, - enabled = TRUE, - source = 'voucher', - updated_at = EXCLUDED.updated_at - `, - [productId, now], - ) - await upsertInternalSkuChannelWithClient(client, { internalSku: skuKey, channel: 'third_party_voucher', @@ -118,24 +105,31 @@ export async function upsertAdminVoucherProduct(payload: JsonObject, session: Ad replacePriority: true, }) - const repairedItems = await client.query( + const repairCandidates = await client.query( ` - UPDATE order_items oi - SET canonical_product_id = $2, - updated_at = $3 - FROM orders o - WHERE o.id = oi.order_id - AND oi.fulfillment_channel = 'third_party_voucher' - AND resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) = $1 - AND oi.canonical_product_id IS DISTINCT FROM $2 - RETURNING + SELECT oi.id AS order_item_id, (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date::text AS basis_date + FROM v_order_item_current oi + JOIN orders o ON o.id = oi.order_id + WHERE TRUE + AND oi.fulfillment_channel = 'third_party_voucher' + AND resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) = $1 + AND oi.canonical_product_id IS DISTINCT FROM $2 `, - [skuKey, productId, now], + [skuKey, productId], ) - const itemIds = repairedItems.rows.map((row) => Number(row.order_item_id)) + const itemIds = repairCandidates.rows.map((row) => Number(row.order_item_id)) + if (itemIds.length > 0) { + await attachFulfillmentConfigurationsWithExecutor(client, { + mappings: itemIds.map((orderItemId) => ({ + orderItemId, + canonicalProductId: productId, + })), + onlyWhenCanonicalProductDifferent: true, + }) + } if (itemIds.length > 0) { await client.query( `UPDATE cost_snapshots @@ -160,13 +154,13 @@ export async function upsertAdminVoucherProduct(payload: JsonObject, session: Ad } let effectiveFrom = initialEffectiveFrom - if (!requestedEffectiveFrom && repairedItems.rows.length > 0) { + if (!requestedEffectiveFrom && repairCandidates.rows.length > 0) { const missingDates = await client.query<{ basis_date: string }>( ` SELECT MIN( (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date )::text AS basis_date - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.id = ANY($1::bigint[]) AND NOT EXISTS ( @@ -287,7 +281,7 @@ export async function upsertAdminVoucherProduct(payload: JsonObject, session: Ad return { product: mapProduct(product), itemIds, - dates: [...new Set(repairedItems.rows.map((row) => row.basis_date).filter(Boolean))], + dates: [...new Set(repairCandidates.rows.map((row) => row.basis_date).filter(Boolean))], costVersionId, effectiveFrom, amountFen, diff --git a/apps/backend/src/services/cost-center/work-order-product-service.ts b/apps/backend/src/services/cost-center/work-order-product-service.ts index 5ab02b77..4fe319f7 100644 --- a/apps/backend/src/services/cost-center/work-order-product-service.ts +++ b/apps/backend/src/services/cost-center/work-order-product-service.ts @@ -49,7 +49,7 @@ export async function backfillAdminWorkOrderProductSkus(payload: Record 'cancelled' @@ -77,7 +77,7 @@ export async function backfillAdminWorkOrderProductSkus(payload: Record 'cancelled' @@ -120,7 +120,7 @@ export async function backfillAdminWorkOrderProductSkus(payload: Record '' @@ -141,7 +141,7 @@ export async function backfillAdminWorkOrderProductSkus(payload: Record '' @@ -219,7 +220,7 @@ export async function backfillAdminWorkOrderProductSkus(payload: Record '' @@ -267,7 +268,7 @@ export async function backfillAdminWorkOrderProductSkus(payload: Record( ` SELECT COUNT(*)::int AS total - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.canonical_product_id IS NULL AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date diff --git a/apps/backend/src/services/cost-center/work-order-template-cost.integration.test.ts b/apps/backend/src/services/cost-center/work-order-template-cost.integration.test.ts index 225c1531..fdeb3647 100644 --- a/apps/backend/src/services/cost-center/work-order-template-cost.integration.test.ts +++ b/apps/backend/src/services/cost-center/work-order-template-cost.integration.test.ts @@ -248,7 +248,7 @@ test( ]) await query('DELETE FROM product_channel_capabilities WHERE product_id = $1', [productId]) await query( - 'UPDATE internal_sku_channels SET canonical_product_id = NULL WHERE canonical_product_id = $1', + 'UPDATE sku_channel_routes SET canonical_product_id = NULL WHERE canonical_product_id = $1', [productId], ) await query('DELETE FROM product_catalog WHERE id = $1', [productId]) diff --git a/apps/backend/src/services/cost-center/work-order-template-product-service.ts b/apps/backend/src/services/cost-center/work-order-template-product-service.ts index acac9036..4358944f 100644 --- a/apps/backend/src/services/cost-center/work-order-template-product-service.ts +++ b/apps/backend/src/services/cost-center/work-order-template-product-service.ts @@ -109,13 +109,14 @@ export async function syncWorkOrderTemplateProduct( client, ` INSERT INTO product_catalog ( - canonical_sku, canonical_name, category, unit_name, status, + canonical_sku, canonical_name, category, unit_name, source_kind, status, attributes_json, created_at, updated_at - ) VALUES ($1, $2, $3, $4, 'active', $5::jsonb, $6, $6) + ) VALUES ($1, $2, $3, $4, 'work_order', 'active', $5::jsonb, $6, $6) ON CONFLICT (canonical_sku) DO UPDATE SET canonical_name = EXCLUDED.canonical_name, category = EXCLUDED.category, unit_name = EXCLUDED.unit_name, + source_kind = EXCLUDED.source_kind, attributes_json = COALESCE(product_catalog.attributes_json, '{}'::jsonb) || EXCLUDED.attributes_json, updated_at = EXCLUDED.updated_at RETURNING id, status diff --git a/apps/backend/src/services/finance/customer-refund-service.ts b/apps/backend/src/services/finance/customer-refund-service.ts index 5d649c92..f090279b 100644 --- a/apps/backend/src/services/finance/customer-refund-service.ts +++ b/apps/backend/src/services/finance/customer-refund-service.ts @@ -411,10 +411,10 @@ async function loadRefundContext(workOrderIdValue: number | string): Promise { - 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/149_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..274b8a49 --- /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/149_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 2e18ccb0..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], ) @@ -349,8 +370,9 @@ async function readSalesFactJournalTotals(client: PoolClient, statDate: string) FROM finance_journal_entries e JOIN finance_journal_lines l ON l.entry_id = e.id WHERE e.ref_type = 'sales_fact' - AND e.ref_id = $1 AND e.status = 'posted' + AND e.occurred_at >= ($1::date::timestamp AT TIME ZONE 'Asia/Shanghai') + AND e.occurred_at < (($1::date + interval '1 day')::timestamp AT TIME ZONE 'Asia/Shanghai') GROUP BY e.event_type, l.account_code `, [statDate], @@ -612,7 +634,7 @@ async function assertPeriodCloseable( const manualMissing = await client.query<{ total: number }>( ` SELECT COUNT(*)::int AS total - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual' AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date @@ -636,7 +658,7 @@ async function assertPeriodCloseable( ` SELECT COUNT(*)::int AS total FROM cost_snapshots cs - JOIN order_items oi ON oi.id = cs.order_item_id + JOIN v_order_item_current oi ON oi.id = cs.order_item_id JOIN orders o ON o.id = oi.order_id WHERE cs.subject_type = 'manual_case' AND cs.superseded_at IS NULL AND reversed_at IS NULL @@ -658,7 +680,7 @@ async function assertPeriodCloseable( SELECT COUNT(*)::int AS total FROM cost_snapshots cs JOIN manual_expenses me ON me.order_item_id = cs.order_item_id - JOIN order_items oi ON oi.id = cs.order_item_id + JOIN v_order_item_current oi ON oi.id = cs.order_item_id JOIN orders o ON o.id = oi.order_id WHERE cs.subject_type = 'manual_case' AND cs.superseded_at IS NULL AND reversed_at IS NULL diff --git a/apps/backend/src/services/finance/finance-reconciliation-service.ts b/apps/backend/src/services/finance/finance-reconciliation-service.ts index 59e052e0..18d7d4d0 100644 --- a/apps/backend/src/services/finance/finance-reconciliation-service.ts +++ b/apps/backend/src/services/finance/finance-reconciliation-service.ts @@ -306,7 +306,7 @@ async function matchRow( oi.id, oi.order_id, GREATEST(COALESCE(oi.revenue_amount_fen, 0), 0)::bigint AS revenue_fen - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.id = $3 AND o.provider = $1 diff --git a/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts b/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts index ea0c20cc..3bfffe4b 100644 --- a/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts +++ b/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts @@ -156,7 +156,12 @@ test('人工确认前必须自动匹配履约配置,配置保存/定时任务 const listBody = configSource.slice(listStart, listEnd) assert.ok( listBody.includes('FROM product_catalog p') && listBody.includes('productSourceFilterSql'), - '人工成本配置必须以 man: 标准商品为唯一清单来源', + '人工成本配置必须以 source_kind=manual 的标准商品为唯一清单来源', + ) + assert.ok( + listBody.includes('JOIN internal_skus product_sku') && + !listBody.includes('substring(p.canonical_sku'), + '人工成本配置必须通过 internal_sku_id 读取内部 SKU,不能解析 man: 前缀', ) assert.ok( reconcileMigration.includes('tmp_manual_product_merge') && diff --git a/apps/backend/src/services/finance/manual-fulfillment-service.ts b/apps/backend/src/services/finance/manual-fulfillment-service.ts index 091d6841..da462153 100644 --- a/apps/backend/src/services/finance/manual-fulfillment-service.ts +++ b/apps/backend/src/services/finance/manual-fulfillment-service.ts @@ -242,7 +242,7 @@ export async function listManualFulfillmentPending( const countResult = await query<{ total: number }>( `${baseCte} SELECT COUNT(*)::int AS total - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id LEFT JOIN fulfilled ON fulfilled.order_item_id = oi.id @@ -276,7 +276,7 @@ export async function listManualFulfillmentPending( cfg.channels_json AS config_channels_json, COALESCE((fp.config_json->>'rechargeUnitPriceFen')::bigint, 1) AS credit_unit_price_fen, fulfilled.fulfilled_at - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id LEFT JOIN fulfillment_profiles fp ON fp.profile_key = 'manual_' || cfg.platform_key @@ -339,7 +339,7 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm wo.id AS work_order_id, wo.work_order_no, wo.product_rule_id AS work_product_rule_id - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id LEFT JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id LEFT JOIN fulfillment_profiles fp ON fp.profile_key = 'manual_' || cfg.platform_key @@ -736,7 +736,7 @@ export async function refreshFactsForOrderItem(orderItemId: number): Promise( ` SELECT cfg.id, cfg.canonical_product_id - FROM order_items oi + FROM v_order_item_current oi JOIN manual_fulfillment_configs cfg ON cfg.enabled = TRUE AND ( canonicalize_internal_sku(cfg.sku_key) = resolve_internal_sku( @@ -1097,7 +1097,7 @@ export async function getManualFulfillmentCountsByOrderIds( WHERE cfg.id IS NOT NULL AND fulfilled.fulfilled_at IS NULL )::int AS pending_count, COUNT(*) FILTER (WHERE cfg.id IS NULL)::int AS unconfigured_count - FROM order_items oi + FROM v_order_item_current oi LEFT JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id LEFT JOIN fulfilled ON fulfilled.order_item_id = oi.id WHERE oi.order_id = ANY($1::bigint[]) diff --git a/apps/backend/src/services/finance/manual-fulfillment.integration.test.ts b/apps/backend/src/services/finance/manual-fulfillment.integration.test.ts index 7b244d14..3de37407 100644 --- a/apps/backend/src/services/finance/manual-fulfillment.integration.test.ts +++ b/apps/backend/src/services/finance/manual-fulfillment.integration.test.ts @@ -30,13 +30,13 @@ test( reopenFinancePeriod, syncFinanceLedgerFromFacts, } = await import('./finance-ledger-service.js') - const { rebuildSalesDailyFacts } = await import('./sales-facts-service.js') + const { rebuildChannelSalesDailyFacts } = await import('./sales-facts-channel-service.js') const { importFinanceReconciliation } = await import('./finance-reconciliation-service.js') const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}` - const skuKey = `IT-MANUAL-${suffix}` - const creditSkuKey = `IT-CREDIT-${suffix}` - const unconfirmedSkuKey = `IT-PENDING-${suffix}` + const skuKey = `人工履约-${suffix}` + const creditSkuKey = `人工额度-${suffix}` + const unconfirmedSkuKey = `人工待确认-${suffix}` const orderIds: number[] = [] const orderItemIds: number[] = [] const fundAccountIds: number[] = [] @@ -103,7 +103,7 @@ test( }) assert.ok(rematched.updatedCount >= 1) const matchedConfig = await query<{ fulfillment_config_id: number | null }>( - `SELECT fulfillment_config_id FROM order_items WHERE id = $1`, + `SELECT fulfillment_config_id FROM v_order_item_current WHERE id = $1`, [cashItem.orderItemId], ) assert.ok(Number(matchedConfig.rows[0]?.fulfillment_config_id) > 0, 'rematch 必须挂上配置') @@ -375,12 +375,28 @@ test( // 7. 总账:销售事实同步、试算平衡、关账阻断、平台对账匹配 const todayDate = shanghaiDate() - await rebuildSalesDailyFacts({ dateFrom: todayDate, dateTo: todayDate }) + await rebuildChannelSalesDailyFacts({ dateFrom: todayDate, dateTo: todayDate }) + const salesFacts = await query<{ count: number }>( + `SELECT COUNT(*)::int AS count + FROM sales_daily_facts + WHERE stat_date = $1::date`, + [todayDate], + ) + assert.ok(Number(salesFacts.rows[0]?.count || 0) > 0, '必须先从统一履约投影生成销售事实') const ledgerSync = await syncFinanceLedgerFromFacts({ dateFrom: todayDate, dateTo: todayDate, }) - assert.ok(ledgerSync.created > 0, '销售事实必须生成总账凭证') + const journalEntries = await query<{ count: number }>( + `SELECT COUNT(*)::int AS count + FROM finance_journal_entries + WHERE period_key = $1`, + [todayDate.slice(0, 7)], + ) + assert.ok( + ledgerSync.created > 0 || Number(journalEntries.rows[0]?.count || 0) > 0, + '销售事实必须生成总账凭证', + ) const trial = await getTrialBalance({ dateFrom: todayDate, dateTo: todayDate }) assert.equal(trial.balanced, true, '总账必须借贷平衡') @@ -420,6 +436,8 @@ test( ) // 清掉失败单后,关账才允许通过。 await confirmManualFulfillment({ orderItemId: failItem.orderItemId, costFen: 700 }, session) + await rebuildChannelSalesDailyFacts({ dateFrom: todayDate, dateTo: todayDate }) + await syncFinanceLedgerFromFacts({ dateFrom: todayDate, dateTo: todayDate }) await closeFinancePeriod(periodKey, session) await assert.rejects( () => @@ -544,13 +562,23 @@ async function createManualItem( const orderId = Number(order.rows[0]?.id) const item = await query( `INSERT INTO order_items ( - order_id, sku_code, sku_name, channel, spec_json, + order_id, sku_code, sku_name, spec_json, revenue_amount_fen, revenue_basis, created_at, updated_at - ) VALUES ($1, $2, $3, 'manual', $4::jsonb, 5000, 'order_total', NOW(), NOW()) + ) VALUES ($1, $2, $3, $4::jsonb, 5000, 'order_total', NOW(), NOW()) RETURNING id`, - [orderId, skuNick, `集成测试SKU-${skuNick}`, JSON.stringify({ skuNick })], + [orderId, skuNick, skuNick, JSON.stringify({ skuNick })], ) - return { orderId, orderItemId: Number(item.rows[0]?.id) } + const orderItemId = Number(item.rows[0]?.id) + await query( + `UPDATE fulfillments + SET status = 'assigned', + channel = 'manual', + source = 'carrier_evidence', + updated_at = NOW() + WHERE order_item_id = $1`, + [orderItemId], + ) + return { orderId, orderItemId } } function shanghaiDate(date = new Date()) { diff --git a/apps/backend/src/services/finance/order-refund-service.ts b/apps/backend/src/services/finance/order-refund-service.ts index d147de04..07989f55 100644 --- a/apps/backend/src/services/finance/order-refund-service.ts +++ b/apps/backend/src/services/finance/order-refund-service.ts @@ -81,7 +81,7 @@ export async function listOrderRefunds(orderId: number | string): Promise( - `SELECT COUNT(*)::int AS total FROM order_items + `SELECT COUNT(*)::int AS total FROM v_order_item_current WHERE order_id = $1 AND fulfillment_status <> 'archived'`, [orderId], ) @@ -170,7 +170,7 @@ export async function createManualRefund( FROM order_refunds r WHERE r.order_item_id = oi.id AND r.status = 'confirmed' ), 0)::bigint AS refunded_fen - FROM order_items oi + FROM v_order_item_current oi WHERE oi.order_id = $1 ORDER BY oi.id `, @@ -286,7 +286,7 @@ export async function createManualRefund( JOIN order_items ri ON ri.id = r.order_item_id WHERE ri.order_id = $1 AND r.status = 'confirmed' ), 0)::bigint AS refunded_fen - FROM order_items oi + FROM v_order_item_current oi WHERE oi.order_id = $1 `, [orderId], @@ -407,7 +407,7 @@ export async function voidOrderRefund( }) } const activeItems = await client.query<{ total: number }>( - `SELECT COUNT(*)::int AS total FROM order_items + `SELECT COUNT(*)::int AS total FROM v_order_item_current WHERE order_id = $1 AND fulfillment_status <> 'archived'`, [row.order_id], ) @@ -440,7 +440,7 @@ export async function voidOrderRefund( JOIN order_items ri ON ri.id = r.order_item_id WHERE ri.order_id = $1 AND r.status = 'confirmed' ), 0)::bigint AS refunded_fen - FROM order_items oi + FROM v_order_item_current oi WHERE oi.order_id = $1 `, [row.order_id], @@ -605,7 +605,7 @@ async function refreshActiveRefundFacts(itemIds: number[], basisDate: string): P const ids = [...new Set(itemIds.filter((id) => Number.isInteger(id) && id > 0))] if (ids.length === 0 || !basisDate) return const active = await query<{ total: number }>( - `SELECT COUNT(*)::int AS total FROM order_items + `SELECT COUNT(*)::int AS total FROM v_order_item_current WHERE id = ANY($1::bigint[]) AND fulfillment_status <> 'archived'`, [ids], ) @@ -684,7 +684,10 @@ export async function approveAndRecordKuaishouRefund( } const existingItem = await query<{ id: number; fulfillment_status: string }>( - `SELECT id, fulfillment_status FROM order_items WHERE id = $1 AND order_id = $2 LIMIT 1`, + `SELECT id, fulfillment_status + FROM v_order_item_current + WHERE id = $1 AND order_id = $2 + LIMIT 1`, [orderItemId, orderId], ) if (!existingItem.rows[0]) { @@ -897,7 +900,7 @@ async function getRefundableFen(orderId: number, orderItemId: number): Promise SELECT DISTINCT (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date::text AS basis_date - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.id = ANY($1::bigint[]) `, 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 c006ef42..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 order_items 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/apps/backend/src/services/fulfillment/affiliate-fallback-service.ts b/apps/backend/src/services/fulfillment/affiliate-fallback-service.ts index a9928ac9..b5da7cb8 100644 --- a/apps/backend/src/services/fulfillment/affiliate-fallback-service.ts +++ b/apps/backend/src/services/fulfillment/affiliate-fallback-service.ts @@ -55,7 +55,7 @@ export async function runAffiliateFallback( cfg.id AS config_id, cfg.canonical_product_id AS config_product_id, (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date::text AS basis_date - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id LEFT JOIN manual_fulfillment_configs cfg ON canonicalize_internal_sku(cfg.sku_key) = resolve_internal_sku( @@ -67,11 +67,13 @@ export async function runAffiliateFallback( AND oi.fulfillment_locked = FALSE AND cfg.id IS NOT NULL AND EXISTS ( - SELECT 1 FROM internal_sku_channels channel - WHERE channel.enabled = TRUE - AND channel.channel = 'manual' - AND channel.is_fallback = TRUE - AND canonicalize_internal_sku(channel.internal_sku) = resolve_internal_sku( + SELECT 1 + FROM sku_channel_routes route + JOIN internal_skus sku ON sku.id = route.internal_sku_id + WHERE route.enabled = TRUE + AND route.channel = 'manual' + AND route.route_role = 'fallback' + AND sku.internal_sku_key = resolve_internal_sku( oi.manual_sku_key, oi.sku_name, oi.sku_code ) ) diff --git a/apps/backend/src/services/fulfillment/carrier-creation-service.ts b/apps/backend/src/services/fulfillment/carrier-creation-service.ts index 5a1e2bf0..bc1a790e 100644 --- a/apps/backend/src/services/fulfillment/carrier-creation-service.ts +++ b/apps/backend/src/services/fulfillment/carrier-creation-service.ts @@ -257,9 +257,12 @@ export async function ensureCarrierForOrderItem(input: { }), ) const channelConfig = await query<{ config_json: Record }>( - `SELECT config_json - FROM internal_sku_channels - WHERE canonicalize_internal_sku(internal_sku) = $1 AND channel = 'work_order' AND enabled = TRUE + `SELECT route.carrier_config_json AS config_json + FROM sku_channel_routes route + JOIN internal_skus sku ON sku.id = route.internal_sku_id + WHERE sku.internal_sku_key = $1 + AND route.channel = 'work_order' + AND route.enabled = TRUE LIMIT 1`, [internalSku], ) diff --git a/apps/backend/src/services/fulfillment/carrier-evidence-service.ts b/apps/backend/src/services/fulfillment/carrier-evidence-service.ts index d8633c8b..efaf911c 100644 --- a/apps/backend/src/services/fulfillment/carrier-evidence-service.ts +++ b/apps/backend/src/services/fulfillment/carrier-evidence-service.ts @@ -44,7 +44,7 @@ export async function loadActiveCarrierEvidence( AND ft.executor_key = 'manual_dispatch' AND ft.task_status <> ALL($2::text[]) ) AS has_manual_task - FROM order_items oi + FROM v_order_item_current oi WHERE oi.id = ANY($1::bigint[]) `, [ids, INACTIVE_CARRIER_TASK_STATUSES], diff --git a/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts b/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts index 55d53cb8..1e33b2b2 100644 --- a/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts +++ b/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts @@ -105,30 +105,31 @@ async function loadCandidateItems(input: { oi.order_id, resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku, capability.channels - FROM order_items oi + FROM v_order_item_current oi JOIN LATERAL ( SELECT COALESCE( jsonb_agg( jsonb_build_object( - 'id', channel.id, - 'internal_sku', channel.internal_sku, - 'channel', channel.channel, - 'priority', channel.priority, - 'is_fallback', channel.is_fallback, - 'enabled', channel.enabled, - 'canonical_product_id', channel.canonical_product_id, - 'config_json', channel.config_json, - 'source', channel.source - ,'note', channel.note - ,'created_by_admin_user_id', channel.created_by_admin_user_id + 'id', route.id, + 'internal_sku', sku.internal_sku_key, + 'channel', route.channel, + 'priority', route.priority, + 'is_fallback', route.route_role = 'fallback', + 'enabled', route.enabled, + 'canonical_product_id', route.canonical_product_id, + 'config_json', route.carrier_config_json, + 'source', route.source, + 'note', route.note, + 'created_by_admin_user_id', route.created_by_admin_user_id ) - ORDER BY channel.priority, channel.channel + ORDER BY route.priority, route.channel ), '[]'::jsonb ) AS channels - FROM internal_sku_channels channel - WHERE channel.enabled = TRUE - AND canonicalize_internal_sku(channel.internal_sku) = resolve_internal_sku( + FROM sku_channel_routes route + JOIN internal_skus sku ON sku.id = route.internal_sku_id + WHERE route.enabled = TRUE + AND sku.internal_sku_key = resolve_internal_sku( oi.manual_sku_key, oi.sku_name, oi.sku_code ) ) capability ON jsonb_array_length(capability.channels) > 0 @@ -246,7 +247,7 @@ async function selectSafeFallback(input: { } /** - * 按 internal_sku_channels 的明确优先级补载体。 + * 按 sku_channel_routes 的明确优先级补载体。 * * 主渠道是 affiliate/work_order 但载体暂时创建失败时,只有显式 is_fallback 的 * manual/voucher 才能接管。所有结果都落 pending 明细,不再静默卡住。 @@ -295,9 +296,10 @@ export async function reconcilePendingCarriers( for (const [orderId, candidates] of byOrder) { const order = await getOrderById(orderId) if (!order) continue - const itemRows = await query(`SELECT * FROM order_items WHERE order_id = $1`, [ - orderId, - ]) + const itemRows = await query( + `SELECT * FROM v_order_item_current WHERE order_id = $1`, + [orderId], + ) const itemById = new Map(itemRows.rows.map((item) => [Number(item.id), item])) for (const candidate of candidates) { @@ -423,7 +425,7 @@ export async function reconcilePendingCarriers( const remainingResult = await query<{ count: number }>( ` SELECT count(*)::int AS count - FROM order_items oi + FROM v_order_item_current oi WHERE oi.fulfillment_status IN ('pending', 'assigned') AND oi.fulfillment_channel IN ('affiliate_dash', 'work_order') AND NOT EXISTS ( diff --git a/apps/backend/src/services/fulfillment/channel-assign-service.ts b/apps/backend/src/services/fulfillment/channel-assign-service.ts index 7491c542..0555a711 100644 --- a/apps/backend/src/services/fulfillment/channel-assign-service.ts +++ b/apps/backend/src/services/fulfillment/channel-assign-service.ts @@ -50,13 +50,13 @@ import { /** * 管道 A:渠道判定。 * - * 只写 `fulfillment_channel / fulfillment_status / fulfillment_source / - * fulfillment_decided_at`,绝不写商品、成本、事实,也绝不改旧 channel。 + * 只写 `fulfillments.channel / status / source / decided_at`, + * 绝不写商品、成本、事实,也绝不改冻结的订单项旧列。 * * 判定顺序: * 1. 运营锁定 → 跳过 * 2. 已有且唯一的真实载体 → 直接按载体落渠道;多载体 → 冲突 - * 3. internal_sku_channels:一个主渠道 + 显式兜底;多主渠道 → 冲突 + * 3. sku_channel_routes:一个主渠道 + 显式兜底;多主渠道 → 冲突 * 4. 没有统一配置或载体不满足 → 保留 pending 并写明确原因 */ @@ -335,7 +335,7 @@ export async function assignPendingOrderItemChannels( WHERE task.order_item_id = oi.id AND task.executor_key = 'manual_dispatch' AND ${ACTIVE_CARRIER_TASK_SQL} ) AS has_manual_task - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.fulfillment_status = 'pending' ${options.includeLocked ? '' : 'AND oi.fulfillment_locked = FALSE'} @@ -493,7 +493,9 @@ export async function assignOrderItemsAfterCarrierReady(input: { } const items = ( - await query(`SELECT * FROM order_items WHERE id = ANY($1::bigint[])`, [ids]) + await query(`SELECT * FROM v_order_item_current WHERE id = ANY($1::bigint[])`, [ + ids, + ]) ).rows const orderIds = [...new Set(items.map((item) => Number(item.order_id)))] const orders = new Map( @@ -712,9 +714,13 @@ async function syncAssignedChannelSnapshots( || jsonb_build_object('channel', $3) ), updated_at = NOW() - WHERE id = ANY($1::bigint[]) - AND fulfillment_status = 'assigned' - AND fulfillment_channel = $3`, + WHERE id IN ( + SELECT id + FROM v_order_item_current + WHERE id = ANY($1::bigint[]) + AND fulfillment_status = 'assigned' + AND fulfillment_channel = $3 + )`, [ids, JSON.stringify(decision), channel], ) } @@ -759,7 +765,7 @@ export async function switchChannelForOrderItems(input: { const skuRows = await query<{ internal_sku: string }>( `SELECT DISTINCT resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku - FROM order_items oi + FROM v_order_item_current oi WHERE oi.id = ANY($1::bigint[])`, [ids], ) @@ -783,7 +789,9 @@ export async function switchChannelForOrderItems(input: { } const items = ( - await query(`SELECT * FROM order_items WHERE id = ANY($1::bigint[])`, [ids]) + await query(`SELECT * FROM v_order_item_current WHERE id = ANY($1::bigint[])`, [ + ids, + ]) ).rows const orderIds = [...new Set(items.map((item) => Number(item.order_id)))] const orders = new Map( @@ -839,7 +847,10 @@ export async function switchChannelForOrderItems(input: { const now = nowIso() await withTransaction(async (client) => { const locked = await client.query( - `SELECT * FROM order_items WHERE id = $1 FOR UPDATE`, + `SELECT status AS fulfillment_status + FROM fulfillments + WHERE order_item_id = $1 + FOR UPDATE`, [item.id], ) if (!locked.rows[0] || locked.rows[0].fulfillment_status === 'archived') { @@ -983,7 +994,7 @@ export async function assignChannelToOrderItems(input: { } const rows = await query<{ internal_sku: string }>( `SELECT resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku - FROM order_items oi + FROM v_order_item_current oi WHERE oi.id = ANY($1::bigint[])`, [ids], ) @@ -1279,11 +1290,11 @@ export async function listPendingChannelGroups(input: { FROM grouped LEFT JOIN issue_summary ON issue_summary.group_key = grouped.group_key LEFT JOIN LATERAL ( - SELECT array_agg(DISTINCT channel.channel ORDER BY channel.channel) AS channels - FROM internal_sku_channels channel - WHERE channel.enabled = TRUE - AND canonicalize_internal_sku(channel.internal_sku) = - grouped.internal_sku + SELECT array_agg(DISTINCT route.channel ORDER BY route.channel) AS channels + FROM sku_channel_routes route + JOIN internal_skus sku ON sku.id = route.internal_sku_id + WHERE route.enabled = TRUE + AND sku.internal_sku_key = grouped.internal_sku ) cfg ON TRUE ORDER BY grouped.item_count DESC, grouped.internal_sku LIMIT $${params.length - 1} OFFSET $${params.length} @@ -1453,7 +1464,7 @@ export async function retryPendingCarrierGroup(input: { oi.id AS order_item_id, cfg.id AS config_id, cfg.canonical_product_id - FROM order_items oi + FROM v_order_item_current oi JOIN manual_fulfillment_configs cfg ON canonicalize_internal_sku(cfg.sku_key) = resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) @@ -1489,7 +1500,7 @@ export async function retryPendingCarrierGroup(input: { `SELECT count(*) FILTER (WHERE fulfillment_status = 'assigned')::int AS assigned, count(*) FILTER (WHERE fulfillment_status = 'pending')::int AS remaining - FROM order_items + FROM v_order_item_current WHERE id = ANY($1::bigint[])`, [ids], ) @@ -1611,7 +1622,7 @@ export async function deletePendingChannelGroup(input: { const result = await withTransaction(async (client) => { const itemParams: unknown[] = [] - const itemWhere = ["oi.fulfillment_status = 'pending'"] + const itemWhere = ["fulfillment.status = 'pending'"] if (scope.type === 'internal_sku') { itemParams.push(scope.internalSku) itemWhere.push(`${INTERNAL_SKU_SQL} = $${itemParams.length}`) @@ -1637,10 +1648,11 @@ export async function deletePendingChannelGroup(input: { (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date::text AS basis_date FROM order_items oi + JOIN fulfillments fulfillment ON fulfillment.order_item_id = oi.id JOIN orders o ON o.id = oi.order_id WHERE ${itemWhere.join(' AND ')} ORDER BY oi.id - FOR UPDATE OF oi + FOR UPDATE OF fulfillment `, itemParams, ) @@ -1783,7 +1795,12 @@ export async function assignPendingChannelGroup(input: { AND o.provider = $1 AND o.shop_id = $2 AND oi.sku_code = $3 - AND oi.fulfillment_status = 'pending' + AND EXISTS ( + SELECT 1 + FROM fulfillments fulfillment + WHERE fulfillment.order_item_id = oi.id + AND fulfillment.status = 'pending' + ) ${issueCategoryFilter} RETURNING oi.id `, @@ -1943,7 +1960,7 @@ export async function assignChannelBySku(input: { ` SELECT oi.id, ${INTERNAL_SKU_SQL} AS internal_sku FROM orders o - JOIN order_items oi ON oi.order_id = o.id + JOIN v_order_item_current oi ON oi.order_id = o.id WHERE o.id = oi.order_id AND o.provider = $1 AND o.shop_id = $2 @@ -2017,11 +2034,18 @@ function buildChannelRuleFilter(input: ChannelRuleFilterInput) { const CHANNEL_RULE_FROM_SQL = ` FROM ( - SELECT id, + SELECT route.id, 'internal_sku'::text AS match_key_type, - canonicalize_internal_sku(internal_sku) AS match_key, - channel, priority, enabled, is_fallback, source, config_json, note - FROM internal_sku_channels + sku.internal_sku_key AS match_key, + route.channel, + route.priority, + route.enabled, + route.route_role = 'fallback' AS is_fallback, + route.source, + route.carrier_config_json AS config_json, + route.note + FROM sku_channel_routes route + JOIN internal_skus sku ON sku.id = route.internal_sku_id ) internal_rules ` @@ -2088,11 +2112,21 @@ async function ensureDraftProductForChannel( const key = canonicalizeInternalSku(internalSku) if (!key) return null const definition = { - affiliate_dash: { prefix: 'aff:', category: '自动发货', unit: '件' }, - work_order: { prefix: 'tpl:', category: '接单', unit: '件' }, - manual: { prefix: 'man:', category: '人工', unit: '件' }, - on_account: { prefix: 'man:', category: '统一结算', unit: '件' }, - third_party_voucher: { prefix: 'tpv:', category: '卡券', unit: '张' }, + affiliate_dash: { + prefix: 'aff:', + category: '自动发货', + unit: '件', + sourceKind: 'affiliate_dash', + }, + work_order: { prefix: 'tpl:', category: '接单', unit: '件', sourceKind: 'work_order' }, + manual: { prefix: 'man:', category: '人工', unit: '件', sourceKind: 'manual' }, + on_account: { prefix: 'man:', category: '统一结算', unit: '件', sourceKind: 'manual' }, + third_party_voucher: { + prefix: 'tpv:', + category: '卡券', + unit: '张', + sourceKind: 'third_party_voucher', + }, }[channel] const canonicalSku = `${definition.prefix}${key}` if (canonicalSku.length > 64) return null @@ -2105,16 +2139,18 @@ async function ensureDraftProductForChannel( canonical_name, category, unit_name, + source_kind, status, attributes_json, created_at, updated_at ) VALUES ( - $1, $2, $3, $4, 'active', - $5::jsonb, $6, $6 + $1, $2, $3, $4, $5, 'active', + $6::jsonb, $7, $7 ) ON CONFLICT (canonical_sku) DO UPDATE SET status = 'active', + source_kind = EXCLUDED.source_kind, canonical_name = CASE WHEN BTRIM(product_catalog.canonical_name) = '' THEN EXCLUDED.canonical_name ELSE product_catalog.canonical_name @@ -2133,6 +2169,7 @@ async function ensureDraftProductForChannel( key.slice(0, 120), definition.category, definition.unit, + definition.sourceKind, JSON.stringify({ source: channel, auto_created: true, @@ -2146,15 +2183,6 @@ async function ensureDraftProductForChannel( const productId = Number(product?.id || 0) if (!productId || product?.status !== 'active') return null - await query( - ` - INSERT INTO product_channel_capabilities ( - product_id, channel, priority, is_fallback, enabled, source - ) VALUES ($1, $2, 10, FALSE, TRUE, 'auto') - ON CONFLICT (product_id, channel) DO NOTHING - `, - [productId, channel], - ) return productId } @@ -2340,8 +2368,13 @@ async function assertRulesDeletable(idsInput: number[]): Promise { const ids = [...new Set(idsInput.map(Number).filter((id) => Number.isInteger(id) && id > 0))] if (ids.length === 0) return const rows = await query<{ id: number; source: string; match_key: string; channel: string }>( - `SELECT id, source, canonicalize_internal_sku(internal_sku) AS match_key, channel - FROM internal_sku_channels WHERE id = ANY($1::bigint[])`, + `SELECT route.id, + route.source, + sku.internal_sku_key AS match_key, + route.channel + FROM sku_channel_routes route + JOIN internal_skus sku ON sku.id = route.internal_sku_id + WHERE route.id = ANY($1::bigint[])`, [ids], ) const protectedRow = rows.rows.find( @@ -2364,11 +2397,21 @@ async function loadInternalSkuChannelsForKeys( const rows = await query( ` - SELECT id, internal_sku, channel, priority, is_fallback, enabled, - canonical_product_id, config_json, source, note, created_by_admin_user_id - FROM internal_sku_channels - WHERE enabled = TRUE AND canonicalize_internal_sku(internal_sku) = ANY($1::text[]) - ORDER BY canonicalize_internal_sku(internal_sku), priority, channel, id + SELECT route.id, + sku.internal_sku_key AS internal_sku, + route.channel, + route.priority, + route.route_role = 'fallback' AS is_fallback, + route.enabled, + route.canonical_product_id, + route.carrier_config_json AS config_json, + route.source, + route.note, + route.created_by_admin_user_id + FROM sku_channel_routes route + JOIN internal_skus sku ON sku.id = route.internal_sku_id + WHERE route.enabled = TRUE AND sku.internal_sku_key = ANY($1::text[]) + ORDER BY sku.internal_sku_key, route.priority, route.channel, route.id `, [normalized], ) diff --git a/apps/backend/src/services/fulfillment/fulfillment-retry-guard.test.ts b/apps/backend/src/services/fulfillment/fulfillment-retry-guard.test.ts index 1c9c1568..123d16b6 100644 --- a/apps/backend/src/services/fulfillment/fulfillment-retry-guard.test.ts +++ b/apps/backend/src/services/fulfillment/fulfillment-retry-guard.test.ts @@ -57,7 +57,7 @@ test('创建新履约尝试时自动退休旧当前尝试', () => { assert.ok(taskRepo.includes('MAX(attempt_no)')) assert.ok(taskRepo.includes('SET is_current = FALSE')) assert.ok(taskRepo.includes('$26, TRUE')) - assert.ok(taskRepo.includes('SELECT id FROM order_items WHERE id = $1 FOR UPDATE')) + assert.ok(taskRepo.includes('SELECT id FROM fulfillments WHERE order_item_id = $1 FOR UPDATE')) }) test('当前尝试必须是同单元最新任务,统一视图不能读到已退休尝试', () => { diff --git a/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts index 42f659c2..f5a281db 100644 --- a/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts +++ b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts @@ -128,6 +128,59 @@ test( return orderItemId } + async function setRoute(input: { + sku: string + channel: string + priority?: number + role?: 'primary' | 'fallback' + configJson?: Record + source?: string + }) { + const sku = await query<{ id: string }>('SELECT ensure_canonical_internal_sku($1) AS id', [ + input.sku, + ]) + const internalSkuId = Number(sku.rows[0]!.id) + const role = input.role || 'primary' + if (role === 'primary') { + await query( + `UPDATE sku_channel_routes + SET route_role = 'fallback', + priority = GREATEST(priority, 90), + updated_at = NOW() + WHERE internal_sku_id = $1 + AND channel <> $2 + AND enabled = TRUE + AND route_role = 'primary'`, + [internalSkuId, input.channel], + ) + } + const route = await query<{ id: number }>( + ` + INSERT INTO sku_channel_routes ( + internal_sku_id, channel, route_role, priority, enabled, + carrier_config_json, source + ) VALUES ($1, $2, $3, $4, TRUE, $5::jsonb, $6) + ON CONFLICT (internal_sku_id, channel) DO UPDATE SET + route_role = EXCLUDED.route_role, + priority = EXCLUDED.priority, + enabled = TRUE, + carrier_config_json = EXCLUDED.carrier_config_json, + source = EXCLUDED.source, + updated_at = NOW() + RETURNING id + `, + [ + internalSkuId, + input.channel, + role, + input.priority ?? (role === 'primary' ? 10 : 90), + JSON.stringify(input.configJson || {}), + input.source || 'integration_test', + ], + ) + return Number(route.rows[0]!.id) + } + try { const canonicalResolution = await query<{ internal_sku: string | null @@ -153,15 +206,10 @@ test( `UPDATE orders SET created_at = '2019-01-01T00:00:00+08:00', updated_at = '2019-01-01T00:00:00+08:00' - WHERE id = (SELECT order_id FROM order_items WHERE id = $1)`, + WHERE id = (SELECT order_id FROM v_order_item_current WHERE id = $1)`, [cutoffOldItemId], ) - await query( - `INSERT INTO internal_sku_channels ( - internal_sku, channel, priority, is_fallback, enabled, config_json, source - ) VALUES ($1, 'third_party_voucher', 10, FALSE, TRUE, '{}'::jsonb, 'integration_test')`, - [cutoffTestSku], - ) + await setRoute({ sku: cutoffTestSku, channel: 'third_party_voucher' }) const cutoffAssignment = await assignPendingOrderItemChannels({ orderItemIds: [cutoffOldItemId, cutoffNewItemId], batchSize: 10, @@ -172,7 +220,7 @@ test( assert.equal(cutoffAssignment.assigned, 1) const cutoffStates = await query<{ id: number; fulfillment_status: string }>( `SELECT id, fulfillment_status - FROM order_items + FROM v_order_item_current WHERE id = ANY($1::bigint[]) ORDER BY id DESC`, [[cutoffOldItemId, cutoffNewItemId]], @@ -180,17 +228,18 @@ test( assert.equal(cutoffStates.rows[0]?.fulfillment_status, 'pending') assert.equal(cutoffStates.rows[1]?.fulfillment_status, 'assigned') - await query( - `INSERT INTO internal_sku_channels (internal_sku, channel, priority, is_fallback, enabled, source) - VALUES ($1, 'manual', 90, TRUE, TRUE, 'integration_test')`, - [` 集成测试 规范-${suffix} `], - ) + await setRoute({ + sku: ` 集成测试 规范-${suffix} `, + channel: 'manual', + role: 'fallback', + }) const normalizedInsert = await query<{ internal_sku: string }>( - `SELECT internal_sku - FROM internal_sku_channels - WHERE source = 'integration_test' - AND canonicalize_internal_sku(internal_sku) = canonicalize_internal_sku($1) - ORDER BY id DESC + `SELECT sku.internal_sku_key AS internal_sku + FROM sku_channel_routes route + JOIN internal_skus sku ON sku.id = route.internal_sku_id + WHERE route.source = 'integration_test' + AND sku.internal_sku_key = canonicalize_internal_sku($1) + ORDER BY route.id DESC LIMIT 1`, [triggerSku], ) @@ -258,7 +307,7 @@ test( fulfillment_config_id: number | null }>( `SELECT fulfillment_status, fulfillment_channel, fulfillment_config_id - FROM order_items WHERE id = $1`, + FROM v_order_item_current WHERE id = $1`, [manualItemId], ) assert.equal(manualItem.rows[0]?.fulfillment_status, 'assigned') @@ -292,7 +341,7 @@ test( }>( `SELECT oi.fulfillment_status, oi.fulfillment_channel, oi.original_channel, oi.canonical_product_id, product.canonical_sku, oi.item_snapshot_json - FROM order_items oi + FROM v_order_item_current oi LEFT JOIN product_catalog product ON product.id = oi.canonical_product_id WHERE oi.id = $1`, [manualItemId], @@ -321,7 +370,7 @@ test( fulfillment_pending_reason: string | null }>( `SELECT fulfillment_status, fulfillment_channel, fulfillment_pending_reason - FROM order_items WHERE id = $1`, + FROM v_order_item_current WHERE id = $1`, [missingAffiliateItemId], ) assert.equal(pendingItem.rows[0]?.fulfillment_status, 'pending') @@ -353,7 +402,7 @@ test( canonical_product_id: number | null }>( `SELECT fulfillment_status, fulfillment_channel, fulfillment_config_id, canonical_product_id - FROM order_items WHERE id = $1`, + FROM v_order_item_current WHERE id = $1`, [autoManualItemId], ) assert.equal(autoManualItem.rows[0]?.fulfillment_status, 'assigned') @@ -388,12 +437,11 @@ test( )`, [retryWorkOrderRuleId, retryWorkOrderSku], ) - await query( - `INSERT INTO internal_sku_channels ( - internal_sku, channel, priority, is_fallback, enabled, config_json, source - ) VALUES ($1, 'work_order', 10, FALSE, TRUE, $2::jsonb, 'integration_test')`, - [retryWorkOrderSku, JSON.stringify({ workRuleKey: retryWorkOrderRuleKey })], - ) + await setRoute({ + sku: retryWorkOrderSku, + channel: 'work_order', + configJson: { workRuleKey: retryWorkOrderRuleKey }, + }) const retriedWorkOrders = await retryPendingWorkOrderRouting({ internalSku: retryWorkOrderSku, }) @@ -407,7 +455,7 @@ test( work_rule_key: string | null }>( `SELECT oi.fulfillment_status, oi.fulfillment_channel, wpr.rule_key AS work_rule_key - FROM order_items oi + FROM v_order_item_current oi LEFT JOIN work_orders wo ON wo.order_item_id = oi.id LEFT JOIN work_product_rules wpr ON wpr.id = wo.product_rule_id WHERE oi.id = $1`, @@ -422,11 +470,11 @@ test( [retryWorkOrderItemId], ) await query( - `UPDATE order_items - SET fulfillment_status = 'pending', fulfillment_channel = NULL, - fulfillment_source = 'auto_mapping', fulfillment_pending_reason = 'no_carrier', - fulfillment_pending_detail = $2::jsonb, updated_at = NOW() - WHERE id = $1`, + `UPDATE fulfillments + SET status = 'pending', channel = NULL, + source = 'auto_mapping', pending_reason = 'no_carrier', + pending_detail = $2::jsonb, updated_at = NOW() + WHERE order_item_id = $1`, [ retryWorkOrderItemId, JSON.stringify({ carrierIssue: 'carrier_task_closed', channel: 'work_order' }), @@ -521,11 +569,11 @@ test( shopName: '测试店铺 A', }) await query( - `UPDATE order_items - SET fulfillment_status = 'assigned', fulfillment_channel = 'manual', - fulfillment_source = 'carrier_evidence', fulfillment_config_id = NULL, + `UPDATE fulfillments + SET status = 'assigned', channel = 'manual', + source = 'carrier_evidence', fulfillment_config_id = NULL, updated_at = NOW() - WHERE id = $1`, + WHERE order_item_id = $1`, [manualRemoveItemId], ) await query( @@ -535,12 +583,12 @@ test( ) VALUES ($1, $1, 1, 1, 1, 1000, CURRENT_DATE, CURRENT_DATE, CURRENT_DATE, CURRENT_DATE, NOW(), NOW())`, [manualRemoveSku], ) - await query( - `INSERT INTO internal_sku_channels ( - internal_sku, channel, priority, is_fallback, enabled, source, config_json - ) VALUES ($1, 'manual', 90, TRUE, TRUE, 'manual_config', '{}'::jsonb)`, - [manualRemoveSku], - ) + await setRoute({ + sku: manualRemoveSku, + channel: 'manual', + role: 'fallback', + source: 'manual_config', + }) const deletedManual = await deleteManualSkuClassification(manualRemoveSku) assert.equal(deletedManual.affectedItems, 1) assert.equal(deletedManual.deletedRegistry, 1) @@ -548,40 +596,32 @@ test( const removedManualItem = await query<{ fulfillment_status: string fulfillment_channel: string | null - }>(`SELECT fulfillment_status, fulfillment_channel FROM order_items WHERE id = $1`, [ + }>(`SELECT fulfillment_status, fulfillment_channel FROM v_order_item_current WHERE id = $1`, [ manualRemoveItemId, ]) assert.equal(removedManualItem.rows[0]?.fulfillment_status, 'pending') assert.equal(removedManualItem.rows[0]?.fulfillment_channel, null) - const protectedRule = await query<{ id: number }>( - `INSERT INTO internal_sku_channels ( - internal_sku, channel, priority, is_fallback, enabled, source, config_json - ) VALUES ($1, 'manual', 70, FALSE, TRUE, 'manual_config', '{}'::jsonb) - RETURNING id`, - [protectedRuleSku], - ) - const protectedRuleId = Number(protectedRule.rows[0]!.id) + const protectedRuleId = await setRoute({ + sku: protectedRuleSku, + channel: 'manual', + priority: 70, + source: 'manual_config', + }) await assert.rejects(() => deleteChannelRules([protectedRuleId]), /不能在渠道页直接删除/) const forcedDeleted = await forceDeleteChannelRules([protectedRuleId]) assert.equal(forcedDeleted.deletedCount, 1) assert.deepEqual(forcedDeleted.deletedIds, [protectedRuleId]) const fallbackItemId = await createPendingOrderItem(fallbackSku) - await query( - `INSERT INTO internal_sku_channels ( - internal_sku, channel, priority, is_fallback, enabled, config_json, source - ) VALUES - ($1, 'affiliate_dash', 10, FALSE, TRUE, '{}'::jsonb, 'integration_test'), - ($1, 'manual', 90, TRUE, TRUE, '{}'::jsonb, 'integration_test')`, - [fallbackSku], - ) + await setRoute({ sku: fallbackSku, channel: 'affiliate_dash' }) + await setRoute({ sku: fallbackSku, channel: 'manual', role: 'fallback' }) const fallbackItem = await query<{ order_id: number }>( - `UPDATE order_items - SET fulfillment_status = 'assigned', fulfillment_channel = 'affiliate_dash', - fulfillment_source = 'operator_assigned', fulfillment_locked = FALSE, + `UPDATE fulfillments + SET status = 'assigned', channel = 'affiliate_dash', + source = 'operator_assigned', locked = FALSE, updated_at = NOW() - WHERE id = $1 + WHERE order_item_id = $1 RETURNING order_id`, [fallbackItemId], ) @@ -632,7 +672,7 @@ test( assert.equal( ( await query<{ fulfillment_channel: string }>( - `SELECT fulfillment_channel FROM order_items WHERE id = $1`, + `SELECT fulfillment_channel FROM v_order_item_current WHERE id = $1`, [fallbackItemId], ) ).rows[0]?.fulfillment_channel, @@ -654,7 +694,7 @@ test( item_snapshot_json: Record }>( `SELECT fulfillment_channel, fulfillment_config_id, item_snapshot_json - FROM order_items WHERE id = $1`, + FROM v_order_item_current WHERE id = $1`, [fallbackItemId], ) assert.equal(fallbackAssigned.rows[0]?.fulfillment_channel, 'manual') @@ -675,7 +715,7 @@ test( platform_order_id: string }>( `SELECT oi.order_id, o.provider, o.platform, o.shop_id, o.shop_name, o.platform_order_id - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.id = $1`, [retryTaskItemId], @@ -743,8 +783,10 @@ test( assert.equal(await ensureVoucherRuleForSku(missingVoucherSku), false) const missingVoucherRules = await query<{ count: number }>( - `SELECT count(*)::int AS count FROM internal_sku_channels - WHERE internal_sku = $1 AND channel = 'third_party_voucher'`, + `SELECT count(*)::int AS count + FROM sku_channel_routes route + JOIN internal_skus sku ON sku.id = route.internal_sku_id + WHERE sku.internal_sku_key = $1 AND route.channel = 'third_party_voucher'`, [missingVoucherSku], ) assert.equal(missingVoucherRules.rows[0]?.count, 0) @@ -767,9 +809,16 @@ test( is_fallback: boolean config_json: Record }>( - `SELECT channel, source, canonical_product_id, priority, is_fallback, config_json - FROM internal_sku_channels - WHERE internal_sku = $1 ORDER BY channel`, + `SELECT route.channel, + route.source, + route.canonical_product_id, + route.priority, + route.route_role = 'fallback' AS is_fallback, + route.carrier_config_json AS config_json + FROM sku_channel_routes route + JOIN internal_skus sku ON sku.id = route.internal_sku_id + WHERE sku.internal_sku_key = $1 + ORDER BY route.channel`, [voucherSku], ) assert.equal(voucherChannel.rows.length, 1) @@ -855,10 +904,12 @@ test( is_fallback: boolean }>( `SELECT product.status, product.attributes_json, - channel.canonical_product_id, channel.priority, channel.is_fallback - FROM internal_sku_channels channel - JOIN product_catalog product ON product.id = channel.canonical_product_id - WHERE channel.id = $1`, + route.canonical_product_id, + route.priority, + route.route_role = 'fallback' AS is_fallback + FROM sku_channel_routes route + JOIN product_catalog product ON product.id = route.canonical_product_id + WHERE route.id = $1`, [reactivatedRule.id], ) assert.equal(reactivated.rows[0]?.status, 'active') @@ -869,24 +920,28 @@ test( assert.equal(typeof reactivated.rows[0]?.attributes_json.reactivatedAt, 'string') const retryPendingItemId = await createPendingOrderItem(retryPendingAffiliateSku) + await setRoute({ + sku: retryPendingAffiliateSku, + channel: 'affiliate_dash', + configJson: { affiliateSku: retryPendingAffiliateSku }, + }) await query( - `INSERT INTO internal_sku_channels ( - internal_sku, channel, priority, is_fallback, enabled, config_json, source - ) VALUES ( - $1, 'affiliate_dash', 10, FALSE, TRUE, - jsonb_build_object('affiliateSku', $1::text), 'integration_test' - )`, - [retryPendingAffiliateSku], - ) - await query( - `UPDATE order_items - SET fulfillment_status = 'assigned', - fulfillment_channel = 'affiliate_dash', - fulfillment_source = 'carrier_evidence', + `UPDATE fulfillments + SET status = 'assigned', + channel = 'affiliate_dash', + source = 'carrier_evidence', updated_at = NOW() - WHERE id = $1`, + WHERE order_item_id = $1`, [retryPendingItemId], ) + const existingRetryTasks = await query<{ id: number; task_status: string }>( + `SELECT id, task_status + FROM fulfillment_tasks + WHERE order_item_id = $1 + ORDER BY id`, + [retryPendingItemId], + ) + assert.deepEqual(existingRetryTasks.rows, [], `测试订单项已有任务:${retryPendingItemId}`) await query( `INSERT INTO fulfillment_tasks ( order_id, order_item_id, unit_index, task_no, provider, platform, @@ -896,16 +951,23 @@ test( SELECT oi.order_id, oi.id, 1, $2, o.provider, o.platform, o.shop_id, o.shop_name, o.platform_order_id, profile.id, 'affiliate_dash', 'retry_pending', NOW(), NOW() - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id - JOIN LATERAL ( - SELECT id FROM fulfillment_profiles - WHERE profile_key = 'affiliate_dash' AND executor_key = 'affiliate_dash' - ORDER BY id LIMIT 1 - ) profile ON TRUE + JOIN fulfillment_profiles profile + ON profile.id = $3 + AND profile.executor_key = 'affiliate_dash' WHERE oi.id = $1`, - [retryPendingItemId, `IT-RETRY-PENDING-${suffix}`], + [retryPendingItemId, `IT-RETRY-PENDING-${suffix}`, fallbackProfileId], ) + const retryPendingTask = await query<{ task_status: string }>( + `SELECT task_status + FROM fulfillment_tasks + WHERE order_item_id = $1 AND executor_key = 'affiliate_dash' + ORDER BY id DESC + LIMIT 1`, + [retryPendingItemId], + ) + assert.equal(retryPendingTask.rows[0]?.task_status, 'retry_pending') const blockedRetry = await reconcilePendingCarriers({ orderItemIds: [retryPendingItemId], includeAssignedMissingCarrier: true, @@ -925,7 +987,7 @@ test( oi.fulfillment_pending_reason, oi.fulfillment_pending_detail, (SELECT count(*)::int FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id) AS task_count - FROM order_items oi + FROM v_order_item_current oi WHERE oi.id = $1`, [retryPendingItemId], ) @@ -938,7 +1000,7 @@ test( ) assert.equal(retryPendingState.rows[0]?.task_count, 1) await query( - `UPDATE order_items SET fulfillment_locked = TRUE, updated_at = NOW() WHERE id = $1`, + `UPDATE fulfillments SET locked = TRUE, updated_at = NOW() WHERE order_item_id = $1`, [retryPendingItemId], ) const unlockedPendingGroup = await unlockPendingChannelGroup({ @@ -946,7 +1008,7 @@ test( }) assert.equal(unlockedPendingGroup.updated, 1) const unlockedPendingItem = await query<{ fulfillment_locked: boolean }>( - `SELECT fulfillment_locked FROM order_items WHERE id = $1`, + `SELECT fulfillment_locked FROM v_order_item_current WHERE id = $1`, [retryPendingItemId], ) assert.equal(unlockedPendingItem.rows[0]?.fulfillment_locked, false) @@ -960,14 +1022,14 @@ test( [retryPendingItemId], ) await query( - `UPDATE order_items - SET fulfillment_status = 'pending', - fulfillment_channel = NULL, - fulfillment_source = 'auto_mapping', - fulfillment_pending_reason = NULL, - fulfillment_pending_detail = NULL, + `UPDATE fulfillments + SET status = 'pending', + channel = NULL, + source = 'auto_mapping', + pending_reason = NULL, + pending_detail = NULL, updated_at = NOW() - WHERE id = $1`, + WHERE order_item_id = $1`, [retryPendingItemId], ) await assert.rejects( @@ -988,7 +1050,7 @@ test( oi.fulfillment_status, oi.fulfillment_pending_detail, (SELECT count(*)::int FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id) AS task_count - FROM order_items oi + FROM v_order_item_current oi WHERE oi.id = $1`, [retryPendingItemId], ) @@ -1067,7 +1129,16 @@ test( await query(`DELETE FROM work_orders WHERE order_item_id = ANY($1::bigint[])`, [ orderItemIds, ]) - await query(`DELETE FROM order_items WHERE id = ANY($1::bigint[])`, [orderItemIds]) + await query( + 'ALTER TABLE fulfillment_state_events DISABLE TRIGGER trg_fulfillment_state_events_append_only', + ) + try { + await query(`DELETE FROM order_items WHERE id = ANY($1::bigint[])`, [orderItemIds]) + } finally { + await query( + 'ALTER TABLE fulfillment_state_events ENABLE TRIGGER trg_fulfillment_state_events_append_only', + ) + } } if (orderIds.length > 0) { await query(`DELETE FROM orders WHERE id = ANY($1::bigint[])`, [orderIds]) @@ -1083,9 +1154,11 @@ test( `DELETE FROM internal_sku_mappings WHERE provider = '91kaquan' AND shop_id = $1`, [shopId], ) - await query(`DELETE FROM internal_sku_channels WHERE internal_sku = ANY($1::text[])`, [ - internalSkus, - ]) + await query( + `DELETE FROM internal_skus + WHERE internal_sku_key = ANY($1::text[])`, + [internalSkus.map((sku) => sku.trim().normalize('NFKC').toLowerCase())], + ) await query(`DELETE FROM manual_sku_registry WHERE sku_key = ANY($1::text[])`, [internalSkus]) await query( `DELETE FROM sku_channel_rules diff --git a/apps/backend/src/services/fulfillment/fulfillment-shadow-model.test.ts b/apps/backend/src/services/fulfillment/fulfillment-shadow-model.test.ts deleted file mode 100644 index 6c1df388..00000000 --- a/apps/backend/src/services/fulfillment/fulfillment-shadow-model.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' -import test from 'node:test' - -const migration = readFileSync( - new URL('../../db/migrations/141_fulfillment_shadow_model.sql', import.meta.url), - 'utf8', -) -const auditScript = readFileSync( - new URL('../../../scripts/audit-fulfillment-state.ts', import.meta.url), - 'utf8', -) -const configSyncMigration = readFileSync( - new URL('../../db/migrations/144_fulfillment_shadow_configuration_sync.sql', import.meta.url), - 'utf8', -) - -test('正式履约影子模型包含履约、尝试和异常三张表', () => { - assert.ok(migration.includes('CREATE TABLE IF NOT EXISTS fulfillments')) - assert.ok(migration.includes('CREATE TABLE IF NOT EXISTS fulfillment_attempts')) - assert.ok(migration.includes('CREATE TABLE IF NOT EXISTS fulfillment_issues')) - assert.ok(migration.includes('uq_fulfillment_attempts_current_unit')) - assert.ok(migration.includes('UNIQUE (fulfillment_id, issue_code)')) -}) - -test('影子模型从现有真源回填并持续双写', () => { - assert.ok(migration.includes('SELECT sync_fulfillment_shadow(state.order_item_id)')) - assert.ok(migration.includes('trg_order_items_fulfillment_shadow')) - assert.ok(migration.includes('trg_fulfillment_tasks_shadow')) - assert.ok(migration.includes('trg_work_orders_fulfillment_shadow')) - assert.ok(migration.includes('assign_fulfillment_event_shadow')) -}) - -test('人工配置和统一渠道规则变化会刷新履约影子', () => { - assert.ok(configSyncMigration.includes('trg_manual_fulfillment_config_shadow')) - assert.ok(configSyncMigration.includes('trg_internal_sku_channel_shadow')) - assert.ok(configSyncMigration.includes('sync_fulfillment_shadow(item.id)')) -}) - -test('人工履约配置不能关联其他来源商品', () => { - assert.ok(configSyncMigration.includes('trg_manual_config_product_source')) - assert.ok(configSyncMigration.includes("product.canonical_sku LIKE 'man:%'")) - assert.ok(configSyncMigration.includes("ERRCODE = '23514'")) - assert.ok(configSyncMigration.includes('BEFORE INSERT OR UPDATE ON manual_fulfillment_configs')) - assert.ok(configSyncMigration.includes('config.enabled = TRUE')) - assert.ok(configSyncMigration.includes("SET status = 'active'")) -}) - -test('明确渠道即视为已路由,不用渠道配置缺失覆盖已选渠道', () => { - assert.ok(migration.includes('v_fulfillment_item_state_raw')) - assert.ok(migration.includes("WHEN raw.intended_channel IS NOT NULL THEN 'configured'")) - assert.ok( - migration.includes( - "WHEN raw.route_status = 'missing_channel'\n AND raw.intended_channel IS NOT NULL\n THEN ''", - ), - ) -}) - -test('审计脚本核对影子表、执行尝试、开放异常和事件关联', () => { - assert.ok(auditScript.includes('影子履约缺失')) - assert.ok(auditScript.includes('影子履约状态差异')) - assert.ok(auditScript.includes('影子执行尝试缺失')) - assert.ok(auditScript.includes('影子开放异常缺失')) - assert.ok(auditScript.includes('履约事件未关联影子记录')) -}) diff --git a/apps/backend/src/services/fulfillment/fulfillment-state-service.integration.test.ts b/apps/backend/src/services/fulfillment/fulfillment-state-service.integration.test.ts index fda1f1f3..024832bc 100644 --- a/apps/backend/src/services/fulfillment/fulfillment-state-service.integration.test.ts +++ b/apps/backend/src/services/fulfillment/fulfillment-state-service.integration.test.ts @@ -116,10 +116,10 @@ test( assert.equal((await listFulfillmentStateEvents(orderItemId)).length, 1) await query( - `UPDATE order_items - SET fulfillment_pending_detail = jsonb_build_object('legacyWrite', TRUE), + `UPDATE fulfillments + SET pending_detail = jsonb_build_object('legacyWrite', TRUE), updated_at = NOW() - WHERE id = $1`, + WHERE order_item_id = $1`, [orderItemId], ) events = await listFulfillmentStateEvents(orderItemId) @@ -128,10 +128,10 @@ test( assert.equal(events[0]?.actor_type, 'system') await query( - `UPDATE order_items - SET fulfillment_pending_detail = jsonb_build_object('legacyWrite', TRUE), + `UPDATE fulfillments + SET pending_detail = jsonb_build_object('legacyWrite', TRUE), updated_at = NOW() - WHERE id = $1`, + WHERE order_item_id = $1`, [orderItemId], ) events = await listFulfillmentStateEvents(orderItemId) @@ -193,7 +193,7 @@ test( attributes_json, created_at, updated_at ) VALUES ($1, $2, '测试', '件', 'active', '{}'::jsonb, NOW(), NOW()) RETURNING id`, - [`test:ff-state-${suffix}`, `状态事件商品-${suffix}`], + [`man:ff-state-${suffix}`, `状态事件商品-${suffix}`], ) productId = Number(product.rows[0]!.id) const config = await query<{ id: number }>( diff --git a/apps/backend/src/services/fulfillment/fulfillment-state-service.test.ts b/apps/backend/src/services/fulfillment/fulfillment-state-service.test.ts index fa808a78..1587d7ab 100644 --- a/apps/backend/src/services/fulfillment/fulfillment-state-service.test.ts +++ b/apps/backend/src/services/fulfillment/fulfillment-state-service.test.ts @@ -7,6 +7,7 @@ function read(relativePath: string) { } const migration = read('../../db/migrations/140_fulfillment_state_events.sql') +const cutoverMigration = read('../../db/migrations/148_fulfillment_execution_truth.sql') const stateService = read('./fulfillment-state-service.ts') const channelAssign = read('./channel-assign-service.ts') const carrierReconciliation = read('./carrier-reconciliation-service.ts') @@ -32,8 +33,10 @@ test('履约状态事件表记录前后状态并禁止应用修改', () => { assert.ok(migration.includes('fulfillment_state_events is append-only')) }) -test('状态写入口串行锁订单项,并在写入前校验期望状态', () => { - assert.ok(stateService.includes('SELECT * FROM order_items WHERE id = $1 FOR UPDATE')) +test('状态写入口串行锁正式履约记录,并在写入前校验期望状态', () => { + assert.ok(stateService.includes('FROM fulfillments')) + assert.ok(stateService.includes('FOR UPDATE')) + assert.ok(!stateService.includes('SELECT * FROM order_items WHERE id = $1 FOR UPDATE')) assert.ok(stateService.includes('expected.status')) assert.ok(stateService.includes('expected.channel')) assert.ok(stateService.includes('sourceNotIn')) @@ -69,8 +72,18 @@ test('履约配置和标准商品关联统一通过状态服务的映射接口 assert.ok(stateService.includes('onlyWhenCanonicalProductNull')) }) -test('任务创建先锁订单项并复用已有活跃尝试', () => { - assert.ok(taskRepo.includes('SELECT id FROM order_items WHERE id = $1 FOR UPDATE')) +test('任务创建先锁正式履约记录并复用已有活跃尝试', () => { + assert.ok(taskRepo.includes('SELECT id FROM fulfillments WHERE order_item_id = $1 FOR UPDATE')) assert.ok(taskRepo.includes('task_status NOT IN (')) assert.ok(taskRepo.includes('ORDER BY is_current DESC, id DESC')) }) + +test('订单项状态列冻结,读模型直接投影正式履约表', () => { + assert.ok( + cutoverMigration.includes('RENAME COLUMN fulfillment_status TO legacy_fulfillment_status'), + ) + assert.ok(cutoverMigration.includes('CREATE VIEW v_order_item_current')) + assert.ok(cutoverMigration.includes('FROM fulfillments fulfillment')) + assert.ok(cutoverMigration.includes('DROP FUNCTION IF EXISTS sync_fulfillment_shadow(BIGINT)')) + assert.ok(cutoverMigration.includes('CREATE TRIGGER trg_fulfillments_state_event')) +}) diff --git a/apps/backend/src/services/fulfillment/fulfillment-state-service.ts b/apps/backend/src/services/fulfillment/fulfillment-state-service.ts index 2e04a758..0880c4ce 100644 --- a/apps/backend/src/services/fulfillment/fulfillment-state-service.ts +++ b/apps/backend/src/services/fulfillment/fulfillment-state-service.ts @@ -97,7 +97,6 @@ type StateColumn = { column: string value: unknown cast?: 'jsonb' - mergeSnapshot?: boolean coalesceCurrent?: boolean preserveCarrierIssue?: boolean } @@ -119,46 +118,46 @@ function buildPatchColumns(patch: FulfillmentStatePatch): StateColumn[] { if (!['pending', 'assigned', 'archived'].includes(patch.status)) { throw new TypeError(`invalid fulfillment status: ${patch.status}`) } - columns.push({ column: 'fulfillment_status', value: patch.status }) + columns.push({ column: 'status', value: patch.status }) } if (patch.channel !== undefined) { if (patch.channel !== null && !isFulfillmentChannel(patch.channel)) { throw new TypeError(`invalid fulfillment channel: ${patch.channel}`) } - columns.push({ column: 'fulfillment_channel', value: patch.channel }) + columns.push({ column: 'channel', value: patch.channel }) } if (patch.source !== undefined) { - columns.push({ column: 'fulfillment_source', value: patch.source }) + columns.push({ column: 'source', value: patch.source }) } if (patch.locked !== undefined) { - columns.push({ column: 'fulfillment_locked', value: Boolean(patch.locked) }) + columns.push({ column: 'locked', value: Boolean(patch.locked) }) } if (patch.pendingReason !== undefined) { - columns.push({ column: 'fulfillment_pending_reason', value: patch.pendingReason || null }) + columns.push({ column: 'pending_reason', value: patch.pendingReason || null }) } if (patch.pendingDetail !== undefined) { columns.push({ - column: 'fulfillment_pending_detail', + column: 'pending_detail', value: patch.pendingDetail, cast: 'jsonb', }) } if (patch.pendingDetailUnlessCarrierIssue !== undefined) { columns.push({ - column: 'fulfillment_pending_detail', + column: 'pending_detail', value: patch.pendingDetailUnlessCarrierIssue, cast: 'jsonb', preserveCarrierIssue: true, }) } if (patch.reason !== undefined) { - columns.push({ column: 'fulfillment_reason', value: patch.reason || null }) + columns.push({ column: 'reason', value: patch.reason || null }) } if (patch.decidedBy !== undefined) { - columns.push({ column: 'fulfillment_decided_by', value: patch.decidedBy }) + columns.push({ column: 'decided_by', value: patch.decidedBy }) } if (patch.decidedAt !== undefined) { - columns.push({ column: 'fulfillment_decided_at', value: patch.decidedAt }) + columns.push({ column: 'decided_at', value: patch.decidedAt }) } if (patch.originalChannelIfNull !== undefined) { if (!isFulfillmentChannel(patch.originalChannelIfNull)) { @@ -197,26 +196,49 @@ function buildPatchColumns(patch: FulfillmentStatePatch): StateColumn[] { }) } if (patch.itemSnapshotJson !== undefined) { - columns.push({ - column: 'item_snapshot_json', - value: patch.itemSnapshotJson, - cast: 'jsonb', - }) + // 原订单项快照属于订单事实,不进入履约状态表。 } if (patch.itemSnapshotPatch !== undefined) { - columns.push({ - column: 'item_snapshot_json', - value: patch.itemSnapshotPatch, - cast: 'jsonb', - mergeSnapshot: true, - }) + // 同上,在状态更新后单独合并。 } - if (columns.length === 0) { + if ( + columns.length === 0 && + patch.itemSnapshotJson === undefined && + patch.itemSnapshotPatch === undefined + ) { throw new TypeError('fulfillment state patch must change at least one column') } return columns } +async function applyOrderItemSnapshotPatch( + executor: FulfillmentStateExecutor, + orderItemId: number, + patch: FulfillmentStatePatch, +) { + if (patch.itemSnapshotJson === undefined && patch.itemSnapshotPatch === undefined) { + return + } + if (patch.itemSnapshotJson !== undefined) { + await executor.query( + `UPDATE order_items + SET item_snapshot_json = $2::jsonb, + updated_at = $3::timestamptz + WHERE id = $1`, + [orderItemId, patch.itemSnapshotJson, new Date().toISOString()], + ) + return + } + await executor.query( + `UPDATE order_items + SET item_snapshot_json = + COALESCE(item_snapshot_json, '{}'::jsonb) || $2::jsonb, + updated_at = $3::timestamptz + WHERE id = $1`, + [orderItemId, patch.itemSnapshotPatch, new Date().toISOString()], + ) +} + async function setEventContext( executor: FulfillmentStateExecutor, transition: Pick & { @@ -261,71 +283,84 @@ export async function transitionFulfillmentStateWithExecutor( const columns = buildPatchColumns(input.patch) await setEventContext(executor, input) try { - const lockResult = await executor.query( - `SELECT * FROM order_items WHERE id = $1 FOR UPDATE`, + const lockResult = await executor.query<{ + id: number + order_item_id: number + status: string + channel: string | null + source: string + locked: boolean + }>( + `SELECT id, order_item_id, status, channel, source, locked + FROM fulfillments + WHERE order_item_id = $1 + FOR UPDATE`, [orderItemId], ) const current = lockResult.rows[0] if (!current) return null - if ( - input.expected?.status !== undefined && - current.fulfillment_status !== input.expected.status - ) { + if (input.expected?.status !== undefined && current.status !== input.expected.status) { return null } if ( input.expected?.channel !== undefined && - (current.fulfillment_channel ?? null) !== input.expected.channel + (current.channel ?? null) !== input.expected.channel ) { return null } if ( input.expected?.source !== undefined && - (current.fulfillment_source || '') !== (input.expected.source || '') + (current.source || '') !== (input.expected.source || '') ) { return null } if ( input.expected?.sourceNotIn && - input.expected.sourceNotIn.includes(String(current.fulfillment_source || '')) + input.expected.sourceNotIn.includes(String(current.source || '')) ) { return null } if ( input.expected?.locked !== undefined && - Boolean(current.fulfillment_locked) !== Boolean(input.expected.locked) + Boolean(current.locked) !== Boolean(input.expected.locked) ) { return null } - const params: unknown[] = [] - const assignments = columns.map((entry) => { - params.push(entry.value) - let expression = `$${params.length}` - if (entry.cast) expression += `::${entry.cast}` - if (entry.mergeSnapshot) { - expression = `COALESCE(item_snapshot_json, '{}'::jsonb) || ${expression}` - } else if (entry.preserveCarrierIssue) { - expression = - `CASE WHEN COALESCE(fulfillment_pending_detail->>'carrierIssue', '') ` + - `NOT IN ('', 'carrier_not_attempted') ` + - `THEN fulfillment_pending_detail ELSE ${expression} END` - } else if (entry.coalesceCurrent) { - expression = `COALESCE(${entry.column}, ${expression})` - } - return `${entry.column} = ${expression}` - }) - params.push(new Date().toISOString()) - assignments.push(`updated_at = $${params.length}::timestamptz`) - params.push(orderItemId) + if (columns.length > 0) { + const params: unknown[] = [] + const assignments = columns.map((entry) => { + params.push(entry.value) + let expression = `$${params.length}` + if (entry.cast) expression += `::${entry.cast}` + if (entry.preserveCarrierIssue) { + expression = + `CASE WHEN COALESCE(pending_detail->>'carrierIssue', '') ` + + `NOT IN ('', 'carrier_not_attempted') ` + + `THEN pending_detail ELSE ${expression} END` + } else if (entry.coalesceCurrent) { + expression = `COALESCE(${entry.column}, ${expression})` + } + return `${entry.column} = ${expression}` + }) + params.push(new Date().toISOString()) + assignments.push(`updated_at = $${params.length}::timestamptz`) + params.push(current.id) + await executor.query( + `UPDATE fulfillments + SET ${assignments.join(', ')} + WHERE id = $${params.length}`, + params, + ) + } + + await applyOrderItemSnapshotPatch(executor, orderItemId, input.patch) + await executor.query(`SELECT refresh_fulfillment_derived($1)`, [orderItemId]) const updated = await executor.query( - `UPDATE order_items - SET ${assignments.join(', ')} - WHERE id = $${params.length} - RETURNING *`, - params, + `SELECT * FROM v_order_item_current WHERE id = $1`, + [orderItemId], ) return updated.rows[0] || null } finally { @@ -409,21 +444,21 @@ export async function assignFulfillmentChannelsWithExecutor( ...(input.reason ? { reason: input.reason } : {}), }) try { - const result = await executor.query( - `UPDATE order_items item - SET fulfillment_channel = input.channel, - fulfillment_status = 'assigned', - fulfillment_source = input.source, - fulfillment_decided_at = NOW(), - fulfillment_pending_reason = NULL, - fulfillment_pending_detail = NULL, + const result = await executor.query<{ order_item_id: number }>( + `UPDATE fulfillments fulfillment + SET channel = input.channel, + status = 'assigned', + source = input.source, + decided_at = NOW(), + pending_reason = NULL, + pending_detail = NULL, updated_at = NOW() FROM unnest($1::bigint[], $2::text[], $3::text[]) AS input(order_item_id, channel, source) - WHERE item.id = input.order_item_id - AND item.fulfillment_status = 'pending' - AND (item.fulfillment_locked = FALSE OR $4::boolean) - RETURNING item.*`, + WHERE fulfillment.order_item_id = input.order_item_id + AND fulfillment.status = 'pending' + AND (fulfillment.locked = FALSE OR $4::boolean) + RETURNING fulfillment.order_item_id`, [ assignments.map((row) => row.orderItemId), assignments.map((row) => row.channel), @@ -431,7 +466,21 @@ export async function assignFulfillmentChannelsWithExecutor( input.includeLocked === true, ], ) - return result.rows + const updatedIds = result.rows.map((row) => Number(row.order_item_id)) + if (updatedIds.length === 0) return [] + await executor.query( + `SELECT refresh_fulfillment_derived(order_item_id) + FROM fulfillments + WHERE order_item_id = ANY($1::bigint[])`, + [updatedIds], + ) + const updated = await executor.query( + `SELECT * FROM v_order_item_current + WHERE id = ANY($1::bigint[]) + ORDER BY id`, + [updatedIds], + ) + return updated.rows } finally { await clearEventContext(executor) } @@ -487,14 +536,14 @@ export async function attachFulfillmentConfigurationsWithExecutor( mappings.map((row) => row.requireExpectedProduct), mappings.map((row) => row.expectedCanonicalProductId), ] - const where = ['item.id = input.order_item_id'] + const where = ['item.order_item_id = input.order_item_id'] if (input.expected?.status !== undefined) { params.push(input.expected.status) - where.push(`item.fulfillment_status IS NOT DISTINCT FROM $${params.length}`) + where.push(`item.status IS NOT DISTINCT FROM $${params.length}`) } if (input.expected?.channel !== undefined) { params.push(input.expected.channel) - where.push(`item.fulfillment_channel IS NOT DISTINCT FROM $${params.length}`) + where.push(`item.channel IS NOT DISTINCT FROM $${params.length}`) } if (input.onlyWhenConfigNull) { where.push('item.fulfillment_config_id IS NULL') @@ -514,8 +563,8 @@ export async function attachFulfillmentConfigurationsWithExecutor( params.push(new Date().toISOString()) const updatedAtParam = params.length - const result = await executor.query( - `UPDATE order_items item + const updatedIds = await executor.query<{ order_item_id: number }>( + `UPDATE fulfillments item SET fulfillment_config_id = CASE WHEN input.set_config THEN input.config_id ELSE item.fulfillment_config_id @@ -545,9 +594,23 @@ export async function attachFulfillmentConfigurationsWithExecutor( expected_canonical_product_id ) WHERE ${where.join(' AND ')} - RETURNING item.*`, + RETURNING item.order_item_id`, params, ) + const ids = updatedIds.rows.map((row) => Number(row.order_item_id)) + if (ids.length === 0) return [] + await executor.query( + `SELECT refresh_fulfillment_derived(order_item_id) + FROM fulfillments + WHERE order_item_id = ANY($1::bigint[])`, + [ids], + ) + const result = await executor.query( + `SELECT * FROM v_order_item_current + WHERE id = ANY($1::bigint[]) + ORDER BY id`, + [ids], + ) return result.rows } diff --git a/apps/backend/src/services/fulfillment/fulfillment-truth-model.test.ts b/apps/backend/src/services/fulfillment/fulfillment-truth-model.test.ts new file mode 100644 index 00000000..291ab44c --- /dev/null +++ b/apps/backend/src/services/fulfillment/fulfillment-truth-model.test.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const migration = readFileSync( + new URL('../../db/migrations/141_fulfillment_shadow_model.sql', import.meta.url), + 'utf8', +) +const auditScript = readFileSync( + new URL('../../../scripts/audit-fulfillment-state.ts', import.meta.url), + 'utf8', +) +const configSyncMigration = readFileSync( + new URL('../../db/migrations/144_fulfillment_shadow_configuration_sync.sql', import.meta.url), + 'utf8', +) +const routeCutoverMigration = readFileSync( + new URL('../../db/migrations/147_internal_sku_product_routing.sql', import.meta.url), + 'utf8', +) +const truthCutoverMigration = readFileSync( + new URL('../../db/migrations/148_fulfillment_execution_truth.sql', import.meta.url), + 'utf8', +) +const insertSkuMigration = readFileSync( + new URL('../../db/migrations/148_fulfillment_execution_truth.sql', import.meta.url), + 'utf8', +) + +test('正式履约模型包含履约、尝试和异常三张表', () => { + assert.ok(migration.includes('CREATE TABLE IF NOT EXISTS fulfillments')) + assert.ok(migration.includes('CREATE TABLE IF NOT EXISTS fulfillment_attempts')) + assert.ok(migration.includes('CREATE TABLE IF NOT EXISTS fulfillment_issues')) + assert.ok(migration.includes('uq_fulfillment_attempts_current_unit')) + assert.ok(migration.includes('UNIQUE (fulfillment_id, issue_code)')) +}) + +test('迁移 148 删除影子双写并直接维护正式履约真源', () => { + assert.ok( + truthCutoverMigration.includes('DROP TRIGGER IF EXISTS trg_order_items_fulfillment_shadow'), + ) + assert.ok( + truthCutoverMigration.includes('DROP FUNCTION IF EXISTS sync_fulfillment_shadow(BIGINT)'), + ) + assert.ok(truthCutoverMigration.includes('CREATE TRIGGER trg_fulfillments_state_event')) + assert.ok(truthCutoverMigration.includes('CREATE TRIGGER trg_fulfillment_tasks_attempt')) + assert.ok(truthCutoverMigration.includes('CREATE TRIGGER trg_work_orders_attempt')) + assert.ok(truthCutoverMigration.includes('CREATE VIEW v_order_item_current')) +}) + +test('人工配置和路由变化直接刷新正式履约投影', () => { + assert.ok(truthCutoverMigration.includes('trg_manual_config_refresh_fulfillments')) + assert.ok(truthCutoverMigration.includes('trg_sku_route_refresh_fulfillments')) + assert.ok(truthCutoverMigration.includes('refresh_fulfillment_derived')) + assert.ok( + routeCutoverMigration.includes('DROP TRIGGER IF EXISTS trg_internal_sku_channel_shadow'), + ) +}) + +test('人工履约配置不能关联其他来源商品', () => { + assert.ok(configSyncMigration.includes('trg_manual_config_product_source')) + assert.ok(configSyncMigration.includes("product.canonical_sku LIKE 'man:%'")) + assert.ok(configSyncMigration.includes("ERRCODE = '23514'")) + assert.ok(configSyncMigration.includes('BEFORE INSERT OR UPDATE ON manual_fulfillment_configs')) + assert.ok(configSyncMigration.includes('config.enabled = TRUE')) + assert.ok(configSyncMigration.includes("SET status = 'active'")) +}) + +test('履约读模型直接投影正式表,订单项旧列冻结', () => { + assert.ok( + truthCutoverMigration.includes('RENAME COLUMN fulfillment_status TO legacy_fulfillment_status'), + ) + assert.ok( + truthCutoverMigration.includes( + 'RENAME COLUMN canonical_product_id TO legacy_canonical_product_id', + ), + ) + assert.ok(truthCutoverMigration.includes('FROM fulfillments fulfillment')) + assert.ok(truthCutoverMigration.includes('FROM fulfillment_attempts attempt')) + assert.ok(insertSkuMigration.includes('COALESCE(resolve_internal_sku')) +}) + +test('审计脚本核对正式履约、执行尝试、开放异常和事件关联', () => { + assert.ok(auditScript.includes('正式履约缺失')) + assert.ok(auditScript.includes('正式履约状态差异')) + assert.ok(auditScript.includes('执行尝试缺失')) + assert.ok(auditScript.includes('开放异常缺失')) + assert.ok(auditScript.includes('履约事件未关联正式记录')) +}) diff --git a/apps/backend/src/services/fulfillment/intake-channel-decision-service.ts b/apps/backend/src/services/fulfillment/intake-channel-decision-service.ts index ee92a616..01532f49 100644 --- a/apps/backend/src/services/fulfillment/intake-channel-decision-service.ts +++ b/apps/backend/src/services/fulfillment/intake-channel-decision-service.ts @@ -77,7 +77,7 @@ function withChannelSnapshot(input: { /** * 纯函数:把内部 SKU 渠道计划应用到订单项。 - * 渠道选择只允许来自 internal_sku_channels,旧路由不能抢占。 + * 渠道选择只允许来自 sku_channel_routes,旧表和其他路由不能抢占。 */ export function applyIntakeChannelPlan( item: ResolvedFulfillmentItem, @@ -190,7 +190,7 @@ export function applyIntakeChannelPlan( }) } - // 接单平台由发单入口读取同一份 internal_sku_channels 创建工单; + // 接单平台由发单入口读取同一份 sku_channel_routes 创建工单; // 卡券无执行任务。两者都不能留下 affiliate/manual 路由。 return withChannelSnapshot({ item, @@ -207,7 +207,7 @@ export function applyIntakeChannelPlan( }) } -/** 下单入口读取唯一渠道源:internal_sku_channels。 */ +/** 下单入口读取唯一渠道源:sku_channel_routes。 */ export async function applyIntakeChannelDecision( item: ResolvedFulfillmentItem, _input: IntakeChannelDecisionInput, diff --git a/apps/backend/src/services/fulfillment/internal-sku-channel-service.test.ts b/apps/backend/src/services/fulfillment/internal-sku-channel-service.test.ts index 0c949b5f..ac728c0a 100644 --- a/apps/backend/src/services/fulfillment/internal-sku-channel-service.test.ts +++ b/apps/backend/src/services/fulfillment/internal-sku-channel-service.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' import test from 'node:test' import { @@ -53,15 +54,13 @@ test('渠道计划:非兜底渠道即使优先级数值更大也压过兜底 assert.equal(plan.fallbacks[0]?.channel, 'manual') }) -test('渠道计划:仅有一个兜底渠道时仍可作为实际主渠道', () => { +test('渠道计划:只有兜底渠道时保持未路由,必须人工补主渠道', () => { const plan = planInternalSkuChannels([ channel({ channel: 'manual', priority: 90, is_fallback: true }), ]) - assert.equal(plan.primary?.channel, 'manual') - assert.deepEqual( - plan.attempts.map((item) => item.channel), - ['manual'], - ) + assert.equal(plan.primary, null) + assert.deepEqual(plan.fallbacks, []) + assert.deepEqual(plan.attempts, []) }) test('渠道计划:历史规范化重复的同一渠道会去重,不误判为冲突', () => { @@ -73,3 +72,14 @@ test('渠道计划:历史规范化重复的同一渠道会去重,不误判 assert.equal(plan.primary?.id, 2) assert.deepEqual(plan.conflicts, []) }) + +test('运行时渠道服务只读 sku_channel_routes,不再读写旧表', async () => { + const source = await readFile( + new URL('./internal-sku-channel-service.ts', import.meta.url), + 'utf8', + ) + assert.ok(source.includes('FROM sku_channel_routes')) + assert.ok(source.includes('INSERT INTO sku_channel_routes')) + assert.ok(source.includes('DELETE FROM sku_channel_routes')) + assert.ok(!source.includes('internal_sku_channels')) +}) diff --git a/apps/backend/src/services/fulfillment/internal-sku-channel-service.ts b/apps/backend/src/services/fulfillment/internal-sku-channel-service.ts index 227cccc4..6d01d188 100644 --- a/apps/backend/src/services/fulfillment/internal-sku-channel-service.ts +++ b/apps/backend/src/services/fulfillment/internal-sku-channel-service.ts @@ -32,13 +32,23 @@ export async function listInternalSkuChannels( if (!key) return [] const result = await query( ` - SELECT id, internal_sku, channel, priority, is_fallback, enabled, canonical_product_id, - config_json, source, note, created_by_admin_user_id - FROM internal_sku_channels - WHERE canonicalize_internal_sku(internal_sku) = $1 AND enabled = TRUE + SELECT route.id, + sku.internal_sku_key AS internal_sku, + route.channel, + route.priority, + (route.route_role = 'fallback') AS is_fallback, + route.enabled, + route.canonical_product_id, + route.carrier_config_json AS config_json, + route.source, + route.note, + route.created_by_admin_user_id + FROM sku_channel_routes route + JOIN internal_skus sku ON sku.id = route.internal_sku_id + WHERE sku.internal_sku_key = $1 AND route.enabled = TRUE ORDER BY - priority ASC, - channel ASC + route.priority ASC, + route.channel ASC `, [key], ) @@ -55,11 +65,21 @@ export async function loadInternalSkuChannelPlans( const rows = await query( ` - SELECT id, internal_sku, channel, priority, is_fallback, enabled, - canonical_product_id, config_json, source, note, created_by_admin_user_id - FROM internal_sku_channels - WHERE enabled = TRUE AND canonicalize_internal_sku(internal_sku) = ANY($1::text[]) - ORDER BY canonicalize_internal_sku(internal_sku), priority, channel, id + SELECT route.id, + sku.internal_sku_key AS internal_sku, + route.channel, + route.priority, + (route.route_role = 'fallback') AS is_fallback, + route.enabled, + route.canonical_product_id, + route.carrier_config_json AS config_json, + route.source, + route.note, + route.created_by_admin_user_id + FROM sku_channel_routes route + JOIN internal_skus sku ON sku.id = route.internal_sku_id + WHERE route.enabled = TRUE AND sku.internal_sku_key = ANY($1::text[]) + ORDER BY sku.internal_sku_key, route.priority, route.channel, route.id `, [keys], ) @@ -105,7 +125,7 @@ export function planInternalSkuChannels(input: InternalSkuChannelRow[]): Interna ) const explicitPrimary = channels.filter((row) => !row.is_fallback) - const primaryPool = explicitPrimary.length > 0 ? explicitPrimary : channels + const primaryPool = explicitPrimary if (primaryPool.length === 0) { return { primary: null, fallbacks: [], attempts: [], conflicts: [] } } @@ -149,46 +169,52 @@ async function upsertInternalSkuChannelWithExecutor( ): Promise { const internalSku = canonicalizeInternalSku(input.internalSku) if (!internalSku) return 0 + const skuResult = await client.query<{ id: string }>( + 'SELECT ensure_canonical_internal_sku($1) AS id', + [internalSku], + ) + const internalSkuId = Number(skuResult.rows[0]?.id || 0) + if (!internalSkuId) return 0 if (input.enabled !== false && input.isFallback !== true) { await client.query( - `UPDATE internal_sku_channels - SET is_fallback = TRUE, + `UPDATE sku_channel_routes + SET route_role = 'fallback', priority = GREATEST(priority, 90), updated_at = NOW() - WHERE canonicalize_internal_sku(internal_sku) = $1 + WHERE internal_sku_id = $1 AND channel <> $2 AND enabled = TRUE - AND is_fallback = FALSE`, - [internalSku, input.channel], + AND route_role = 'primary'`, + [internalSkuId, input.channel], ) } const result = await client.query<{ id: number }>( ` - INSERT INTO internal_sku_channels ( - internal_sku, channel, priority, is_fallback, enabled, - canonical_product_id, config_json, source, note, created_by_admin_user_id, + INSERT INTO sku_channel_routes ( + internal_sku_id, channel, route_role, priority, enabled, + canonical_product_id, carrier_config_json, source, note, created_by_admin_user_id, created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, $10, NOW(), NOW()) - ON CONFLICT (internal_sku, channel) DO UPDATE SET + ON CONFLICT (internal_sku_id, channel) DO UPDATE SET priority = CASE WHEN $11::boolean THEN EXCLUDED.priority - ELSE LEAST(internal_sku_channels.priority, EXCLUDED.priority) + ELSE LEAST(sku_channel_routes.priority, EXCLUDED.priority) END, - is_fallback = EXCLUDED.is_fallback, + route_role = EXCLUDED.route_role, enabled = EXCLUDED.enabled, - canonical_product_id = COALESCE(EXCLUDED.canonical_product_id, internal_sku_channels.canonical_product_id), - config_json = internal_sku_channels.config_json || EXCLUDED.config_json, + canonical_product_id = COALESCE(EXCLUDED.canonical_product_id, sku_channel_routes.canonical_product_id), + carrier_config_json = sku_channel_routes.carrier_config_json || EXCLUDED.carrier_config_json, source = EXCLUDED.source, - note = CASE WHEN EXCLUDED.note <> '' THEN EXCLUDED.note ELSE internal_sku_channels.note END, - created_by_admin_user_id = COALESCE(EXCLUDED.created_by_admin_user_id, internal_sku_channels.created_by_admin_user_id), + note = CASE WHEN EXCLUDED.note <> '' THEN EXCLUDED.note ELSE sku_channel_routes.note END, + created_by_admin_user_id = COALESCE(EXCLUDED.created_by_admin_user_id, sku_channel_routes.created_by_admin_user_id), updated_at = NOW() RETURNING id `, [ - internalSku, + internalSkuId, input.channel, + input.isFallback === true ? 'fallback' : 'primary', Number.isFinite(Number(input.priority)) ? Number(input.priority) : 100, - input.isFallback === true, input.enabled !== false, input.canonicalProductId ?? null, JSON.stringify(input.configJson || {}), @@ -250,9 +276,12 @@ export async function disableInternalSkuChannel(input: { }): Promise { const result = await query( ` - UPDATE internal_sku_channels + UPDATE sku_channel_routes route SET enabled = FALSE, updated_at = NOW() - WHERE canonicalize_internal_sku(internal_sku) = $1 AND channel = $2 + FROM internal_skus sku + WHERE sku.id = route.internal_sku_id + AND sku.internal_sku_key = $1 + AND route.channel = $2 `, [canonicalizeInternalSku(input.internalSku), input.channel], ) @@ -262,7 +291,7 @@ export async function disableInternalSkuChannel(input: { export async function deleteInternalSkuChannelById(channelId: number): Promise { const id = Math.trunc(Number(channelId) || 0) if (id <= 0) return false - const result = await query(`DELETE FROM internal_sku_channels WHERE id = $1`, [id]) + const result = await query(`DELETE FROM sku_channel_routes WHERE id = $1`, [id]) return (result.rowCount || 0) > 0 } @@ -273,7 +302,7 @@ export async function deleteInternalSkuChannelsByIds(idsInput: number[]): Promis const ids = [...new Set(idsInput.map(Number).filter((id) => Number.isInteger(id) && id > 0))] if (ids.length === 0) return { deletedIds: [], missingIds: [] } const result = await query<{ id: number }>( - `DELETE FROM internal_sku_channels WHERE id = ANY($1::bigint[]) RETURNING id`, + `DELETE FROM sku_channel_routes WHERE id = ANY($1::bigint[]) RETURNING id`, [ids], ) const deletedIds = result.rows.map((row) => Number(row.id)) diff --git a/apps/backend/src/services/fulfillment/internal-sku-routing.integration.test.ts b/apps/backend/src/services/fulfillment/internal-sku-routing.integration.test.ts new file mode 100644 index 00000000..97fb8ed0 --- /dev/null +++ b/apps/backend/src/services/fulfillment/internal-sku-routing.integration.test.ts @@ -0,0 +1,185 @@ +/** + * 内部 SKU 与渠道路由硬切换集成测试(需要真实 PostgreSQL)。 + * + * INTERNAL_SKU_ROUTING_TEST_DATABASE_URL=postgres://... \ + * npx tsx --test src/services/fulfillment/internal-sku-routing.integration.test.ts + */ +import assert from 'node:assert/strict' +import test from 'node:test' + +const TEST_DATABASE_URL = String(process.env.INTERNAL_SKU_ROUTING_TEST_DATABASE_URL || '').trim() + +test( + '运行时只写 sku_channel_routes,旧 internal_sku_channels 已停止同步', + { skip: !TEST_DATABASE_URL ? '未设置 INTERNAL_SKU_ROUTING_TEST_DATABASE_URL' : false }, + async () => { + process.env.DATABASE_URL = TEST_DATABASE_URL + const { closeDb, query } = await import('../../db/client.js') + const { upsertInternalSkuChannel } = await import('./internal-sku-channel-service.js') + const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}` + const internalSku = `路由集成测试-${suffix}` + const canonicalSku = `man:${internalSku}` + let internalSkuId = 0 + let productId = 0 + + try { + const triggers = await query<{ event_object_table: string; trigger_name: string }>( + ` + SELECT DISTINCT event_object_table, trigger_name + FROM information_schema.triggers + WHERE trigger_name IN ( + 'trg_sync_sku_channel_routes', + 'trg_internal_sku_channel_shadow', + 'trg_sku_route_refresh_fulfillments' + ) + ORDER BY event_object_table, trigger_name + `, + ) + assert.deepEqual(triggers.rows, [ + { + event_object_table: 'sku_channel_routes', + trigger_name: 'trg_sku_route_refresh_fulfillments', + }, + ]) + + const createdSku = await query<{ id: string }>( + 'SELECT ensure_canonical_internal_sku($1) AS id', + [internalSku], + ) + internalSkuId = Number(createdSku.rows[0]?.id || 0) + assert.ok(internalSkuId > 0) + + const createdProduct = await query<{ id: string; source_kind: string }>( + ` + INSERT INTO product_catalog ( + canonical_sku, canonical_name, category, unit_name, status, attributes_json, + created_at, updated_at + ) VALUES ($1, $2, '集成测试', '件', 'active', '{}'::jsonb, NOW(), NOW()) + RETURNING id, source_kind + `, + [canonicalSku, internalSku], + ) + productId = Number(createdProduct.rows[0]?.id || 0) + assert.ok(productId > 0) + assert.equal(createdProduct.rows[0]?.source_kind, 'manual') + + const beforeRoute = await query<{ active_primary_channel: string | null }>( + 'SELECT active_primary_channel FROM v_product_current WHERE id = $1', + [productId], + ) + assert.equal( + beforeRoute.rows[0]?.active_primary_channel, + null, + 'man: 来源没有主路由时必须显示未路由', + ) + + await upsertInternalSkuChannel({ + internalSku, + channel: 'manual', + priority: 10, + isFallback: false, + canonicalProductId: productId, + configJson: { manualConfigId: 1 }, + source: 'integration_test', + replacePriority: true, + }) + + const primary = await query<{ route_role: string; channel: string }>( + ` + SELECT route_role, channel + FROM sku_channel_routes + WHERE internal_sku_id = $1 AND enabled = TRUE + `, + [internalSkuId], + ) + assert.deepEqual(primary.rows, [{ route_role: 'primary', channel: 'manual' }]) + + const projection = await query<{ + source_kind: string + active_primary_channel: string + internal_sku_id: string + }>( + ` + SELECT source_kind, active_primary_channel, internal_sku_id + FROM v_product_current + WHERE id = $1 + `, + [productId], + ) + assert.equal(projection.rows[0]?.source_kind, 'manual') + assert.equal(projection.rows[0]?.active_primary_channel, 'manual') + assert.equal(Number(projection.rows[0]?.internal_sku_id), internalSkuId) + + const { getAdminCostCenterOverview } = + await import('../cost-center/cost-center-overview-service.js') + const { listAdminProducts } = await import('../cost-center/product-catalog-service.js') + const overview = await getAdminCostCenterOverview({ scope: 'manual' }) + const products = await listAdminProducts({ channel: 'manual', page: 1, pageSize: 100 }) + assert.equal(products.pagination.total, overview.productCount) + assert.ok(products.items.every((product) => product.activePrimaryChannel === 'manual')) + + await upsertInternalSkuChannel({ + internalSku, + channel: 'work_order', + priority: 10, + isFallback: false, + configJson: { workRuleKey: `rule-${suffix}` }, + source: 'integration_test', + replacePriority: true, + }) + const switched = await query<{ channel: string; route_role: string }>( + ` + SELECT channel, route_role + FROM sku_channel_routes + WHERE internal_sku_id = $1 + ORDER BY channel + `, + [internalSkuId], + ) + assert.deepEqual(switched.rows, [ + { channel: 'manual', route_role: 'fallback' }, + { channel: 'work_order', route_role: 'primary' }, + ]) + + await query( + ` + INSERT INTO internal_sku_channels ( + internal_sku, channel, priority, is_fallback, enabled, source + ) VALUES ($1, 'affiliate_dash', 1, FALSE, TRUE, 'integration_test') + `, + [internalSku], + ) + const legacyOnly = await query<{ count: number }>( + ` + SELECT COUNT(*)::int AS count + FROM sku_channel_routes + WHERE internal_sku_id = $1 AND channel = 'affiliate_dash' + `, + [internalSkuId], + ) + assert.equal(legacyOnly.rows[0]?.count, 0, '旧表写入不能再生效或同步到新路由') + + await assert.rejects( + () => + query( + ` + INSERT INTO sku_channel_routes ( + internal_sku_id, channel, route_role, priority, enabled + ) VALUES ($1, 'affiliate_dash', 'primary', 1, TRUE) + `, + [internalSkuId], + ), + /uq_sku_channel_routes_primary|duplicate key/, + ) + } finally { + if (productId > 0) { + await query('DELETE FROM product_catalog WHERE id = $1', [productId]) + } + if (internalSkuId > 0) { + await query('DELETE FROM internal_sku_channels WHERE internal_sku = $1', [internalSku]) + await query('DELETE FROM internal_skus WHERE id = $1', [internalSkuId]) + } + await closeDb() + } + }, +) diff --git a/apps/backend/src/services/fulfillment/pending-channel-repair-service.ts b/apps/backend/src/services/fulfillment/pending-channel-repair-service.ts index 599c0ce0..c2973781 100644 --- a/apps/backend/src/services/fulfillment/pending-channel-repair-service.ts +++ b/apps/backend/src/services/fulfillment/pending-channel-repair-service.ts @@ -35,14 +35,15 @@ export async function retryPendingWorkOrderRouting( if (internalSku) params.push(internalSku) const pending = await query( `SELECT oi.* - FROM order_items oi + FROM v_order_item_current oi WHERE oi.fulfillment_status = 'pending' AND EXISTS ( SELECT 1 - FROM internal_sku_channels channel - WHERE channel.enabled = TRUE - AND channel.channel = 'work_order' - AND canonicalize_internal_sku(channel.internal_sku) = + FROM sku_channel_routes route + JOIN internal_skus sku ON sku.id = route.internal_sku_id + WHERE route.enabled = TRUE + AND route.channel = 'work_order' + AND sku.internal_sku_key = resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) ) ${skuClause} @@ -97,7 +98,7 @@ export async function retryPendingWorkOrderRouting( }) const remaining = await query<{ count: number }>( `SELECT COUNT(*)::int AS count - FROM order_items + FROM v_order_item_current WHERE id = ANY($1::bigint[]) AND fulfillment_status = 'pending'`, [ids], ) diff --git a/apps/backend/src/services/fulfillment/product-resolution-service.ts b/apps/backend/src/services/fulfillment/product-resolution-service.ts index 8dc36213..59a0c637 100644 --- a/apps/backend/src/services/fulfillment/product-resolution-service.ts +++ b/apps/backend/src/services/fulfillment/product-resolution-service.ts @@ -56,7 +56,7 @@ export type ResolvedFulfillmentItem = FulfillmentItem & { * 订单入口只负责把各种平台字段统一归一到中文内部 SKU。 * * 渠道选择、载体配置和路由优先级都不在这里判断;它们统一读取 - * `internal_sku_channels`,避免平台 SKU 或旧路由抢先创建载体。 + * `sku_channel_routes`,避免平台 SKU 或旧路由抢先创建载体。 */ export async function resolveOrderItemForFulfillment( { provider = '', platform = '', shopId = '', item = {} }: ResolveOrderItemForFulfillmentInput, diff --git a/apps/backend/src/services/fulfillment/voucher-consume-job-list.test.ts b/apps/backend/src/services/fulfillment/voucher-consume-job-list.test.ts index f5338c18..67ad9c78 100644 --- a/apps/backend/src/services/fulfillment/voucher-consume-job-list.test.ts +++ b/apps/backend/src/services/fulfillment/voucher-consume-job-list.test.ts @@ -11,10 +11,18 @@ const fallbackMigration = readFileSync( new URL('../../db/migrations/146_voucher_consume_fallback_channel.sql', import.meta.url), 'utf8', ) +const orderReceivedMigration = readFileSync( + new URL('../../db/migrations/150_voucher_consume_order_received_scope.sql', import.meta.url), + 'utf8', +) const channelAssignSource = readFileSync( new URL('./channel-assign-service.ts', import.meta.url), 'utf8', ) +const orderServiceSource = readFileSync( + new URL('../order/order-service.ts', import.meta.url), + 'utf8', +) test('核销任务列表支持关键词、平台、渠道和状态组合筛选', () => { assert.ok(source.includes('keyword?: string')) @@ -63,3 +71,18 @@ test('未命中履约渠道时按平台策略安排兜底核销,不修改订 '只允许未命中渠道进入兜底,不能覆盖无载体等其它问题', ) }) + +test('收到订单后按订单级策略入队,不依赖订单项渠道或状态', () => { + assert.ok(source.includes('scheduleVoucherConsumeForOrderReceived')) + assert.ok(source.includes("channel: 'order_received'")) + assert.ok(source.includes('target_key: `order:${row.id}`')) + assert.ok(source.includes("AND o.pay_status = 'paid'")) + assert.ok( + orderServiceSource.includes('scheduleVoucherConsumeForOrderReceived([Number(order.id)])'), + '订单入库成功后必须投递订单级收单核销任务', + ) + assert.ok( + orderReceivedMigration.includes("'order_received'"), + '数据库必须允许记录订单级核销任务来源', + ) +}) diff --git a/apps/backend/src/services/fulfillment/voucher-consume-order-received.integration.test.ts b/apps/backend/src/services/fulfillment/voucher-consume-order-received.integration.test.ts new file mode 100644 index 00000000..7c31ba9a --- /dev/null +++ b/apps/backend/src/services/fulfillment/voucher-consume-order-received.integration.test.ts @@ -0,0 +1,111 @@ +/** + * 订单级收单核销集成测试(需要真实 PostgreSQL)。 + * + * 运行: + * VOUCHER_CONSUME_ORDER_RECEIVED_TEST_DATABASE_URL="$DATABASE_URL" \ + * npx tsx --test src/services/fulfillment/voucher-consume-order-received.integration.test.ts + */ +import assert from 'node:assert/strict' +import test from 'node:test' + +import type { VoucherConsumePolicy } from './voucher-consume-policy-service.js' + +const TEST_DATABASE_URL = String( + process.env.VOUCHER_CONSUME_ORDER_RECEIVED_TEST_DATABASE_URL || '', +).trim() + +test( + '订单级收单核销不依赖履约渠道或订单项状态', + { skip: !TEST_DATABASE_URL ? '未设置 VOUCHER_CONSUME_ORDER_RECEIVED_TEST_DATABASE_URL' : false }, + async () => { + process.env.DATABASE_URL = TEST_DATABASE_URL + const { closeDb, query } = await import('../../db/client.js') + const { loadAppConfigCacheFromDatabase } = await import('../config/app-config-store.js') + const { getVoucherConsumePolicy, saveVoucherConsumePolicy } = + await import('./voucher-consume-policy-service.js') + const { scheduleVoucherConsumeForOrderReceived, listVoucherConsumeJobs } = + await import('./voucher-consume-queue-service.js') + + const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}` + const platformOrderId = `IT-ORDER-RECEIVED-${suffix}` + let orderId = 0 + let orderItemId = 0 + let originalPolicy: VoucherConsumePolicy | null = null + + try { + await loadAppConfigCacheFromDatabase() + originalPolicy = getVoucherConsumePolicy() + const enabledPolicy = structuredClone(originalPolicy) + enabledPolicy.enabled = true + enabledPolicy.platforms.kuaishou_industry.enabled = true + enabledPolicy.platforms.kuaishou_industry.channels.order_received = { + enabled: true, + mode: 'after_order_received', + delaySeconds: 7, + } + await saveVoucherConsumePolicy(enabledPolicy) + + const order = await query<{ id: number }>( + `INSERT INTO orders ( + provider, platform, shop_id, shop_name, platform_order_id, order_status, pay_status, + total_amount, paid_at, created_at, updated_at + ) VALUES ( + '91kaquan', 'kuaishou', 'it-order-received', '', $1, 'paid', 'paid', + 1000, NOW(), NOW(), NOW() + ) RETURNING id`, + [platformOrderId], + ) + orderId = Number(order.rows[0]!.id) + + const item = await query<{ id: number }>( + `INSERT INTO order_items ( + order_id, sku_code, sku_name, quantity, spec_json, + revenue_amount_fen, revenue_basis, created_at, updated_at + ) VALUES ( + $1, 'it-order-received-sku', '订单级核销测试商品', 1, '{}'::jsonb, + 1000, 'order_total', NOW(), NOW() + ) RETURNING id`, + [orderId], + ) + orderItemId = Number(item.rows[0]!.id) + + const before = Date.now() + const scheduled = await scheduleVoucherConsumeForOrderReceived([orderId]) + assert.equal(scheduled.targetCount, 1) + assert.equal(scheduled.queuedCount, 1) + assert.equal(scheduled.jobIds.length, 1) + + const jobs = await listVoucherConsumeJobs({ + keyword: platformOrderId, + page: 1, + pageSize: 20, + }) + assert.equal(jobs.total, 1) + const job = jobs.items[0] + assert.equal(job?.channel, 'order_received') + assert.equal(job?.trigger_event, 'order_received') + assert.equal(job?.target_key, `order:${orderId}`) + assert.equal(Number(job?.order_id), orderId) + assert.equal(job?.order_item_id, null) + assert.ok(Date.parse(String(job?.due_at)) >= before + 6_000) + + const repeated = await scheduleVoucherConsumeForOrderReceived([orderId]) + assert.equal(repeated.targetCount, 1) + assert.equal(repeated.queuedCount, 0) + } finally { + if (orderId > 0) { + await query(`DELETE FROM voucher_consume_jobs WHERE order_id = $1`, [orderId]) + } + if (orderItemId > 0) { + await query(`DELETE FROM order_items WHERE id = $1`, [orderItemId]) + } + if (orderId > 0) { + await query(`DELETE FROM orders WHERE id = $1`, [orderId]) + } + if (originalPolicy) { + await saveVoucherConsumePolicy(originalPolicy) + } + await closeDb() + } + }, +) diff --git a/apps/backend/src/services/fulfillment/voucher-consume-policy-service.test.ts b/apps/backend/src/services/fulfillment/voucher-consume-policy-service.test.ts index 893b64fb..7884b7e2 100644 --- a/apps/backend/src/services/fulfillment/voucher-consume-policy-service.test.ts +++ b/apps/backend/src/services/fulfillment/voucher-consume-policy-service.test.ts @@ -10,6 +10,11 @@ import { test('默认核销策略:自动发货 5 秒,工单发布或指派后 10 秒,人工任务 10 秒', () => { const policy = createDefaultVoucherConsumePolicy() + assert.deepEqual(policy.platforms.kuaishou_industry.channels.order_received, { + enabled: false, + mode: 'after_order_received', + delaySeconds: 5, + }) assert.deepEqual(policy.platforms.kuaishou_industry.channels.affiliate_dash, { enabled: true, mode: 'after_link_generated', @@ -189,3 +194,58 @@ test('旧配置自动补齐未命中渠道兜底,并支持手动开启', () => { enabled: true, delaySeconds: 12, mode: 'after_order_received' }, ) }) + +test('旧配置开启未命中渠道兜底时,自动继承为订单级收单核销', () => { + const policy = normalizeVoucherConsumePolicy({ + version: 2, + enabled: true, + platforms: { + kuaishou_industry: { + enabled: true, + channels: { + unmatched: { + enabled: true, + mode: 'after_order_received', + delaySeconds: 12, + }, + }, + }, + }, + }) + + assert.deepEqual(policy.platforms.kuaishou_industry.channels.order_received, { + enabled: true, + mode: 'after_order_received', + delaySeconds: 12, + }) +}) + +test('显式订单级策略优先于旧未命中渠道兜底配置', () => { + const policy = normalizeVoucherConsumePolicy({ + version: 2, + enabled: true, + platforms: { + kuaishou_industry: { + enabled: true, + channels: { + order_received: { + enabled: false, + mode: 'manual', + delaySeconds: 0, + }, + unmatched: { + enabled: true, + mode: 'after_order_received', + delaySeconds: 12, + }, + }, + }, + }, + }) + + assert.deepEqual(policy.platforms.kuaishou_industry.channels.order_received, { + enabled: false, + mode: 'manual', + delaySeconds: 0, + }) +}) diff --git a/apps/backend/src/services/fulfillment/voucher-consume-policy-service.ts b/apps/backend/src/services/fulfillment/voucher-consume-policy-service.ts index 569d9f6e..a6e1e50d 100644 --- a/apps/backend/src/services/fulfillment/voucher-consume-policy-service.ts +++ b/apps/backend/src/services/fulfillment/voucher-consume-policy-service.ts @@ -6,6 +6,7 @@ export const VOUCHER_CONSUME_PROVIDERS = ['kuaishou_industry', 'douyin_mashida'] export type VoucherConsumeProvider = (typeof VOUCHER_CONSUME_PROVIDERS)[number] export const VOUCHER_CONSUME_CHANNELS = [ + 'order_received', 'affiliate_dash', 'work_order', 'manual', @@ -132,6 +133,11 @@ function createDefaultPlatformPolicy(): VoucherPlatformConsumePolicy { return { enabled: true, channels: { + order_received: { + enabled: false, + mode: 'after_order_received', + delaySeconds: 5, + }, affiliate_dash: { enabled: true, mode: 'after_link_generated', @@ -174,10 +180,16 @@ function normalizePlatformPolicy( const source = isPlainObject(rawValue) ? rawValue : {} const rawChannels = isPlainObject(source.channels) ? source.channels : {} const legacyThirdPartyDisabled = isLegacyThirdPartyDisabled(rawValue, version) + const rawOrderReceived = + rawChannels.order_received ?? inferOrderReceivedPolicyFromUnmatched(rawChannels.unmatched) return { enabled: normalizeBoolean(source.enabled, fallback.enabled), channels: { + order_received: normalizeChannelPolicy(rawOrderReceived, fallback.channels.order_received, [ + 'manual', + 'after_order_received', + ]), affiliate_dash: normalizeChannelPolicy( rawChannels.affiliate_dash, fallback.channels.affiliate_dash, @@ -213,6 +225,17 @@ function normalizePlatformPolicy( } } +function inferOrderReceivedPolicyFromUnmatched(rawValue: unknown) { + if (!isPlainObject(rawValue)) return {} + if (rawValue.enabled !== true) return {} + if (String(rawValue.mode || '').trim() !== 'after_order_received') return {} + return { + enabled: true, + mode: 'after_order_received', + delaySeconds: rawValue.delaySeconds, + } +} + function isLegacyThirdPartyDisabled(rawPlatform: unknown, version: number): boolean { if (version >= 2 || !isPlainObject(rawPlatform)) return false const rawChannels = isPlainObject(rawPlatform.channels) ? rawPlatform.channels : {} diff --git a/apps/backend/src/services/fulfillment/voucher-consume-queue-service.ts b/apps/backend/src/services/fulfillment/voucher-consume-queue-service.ts index 59a2681b..fbf2a517 100644 --- a/apps/backend/src/services/fulfillment/voucher-consume-queue-service.ts +++ b/apps/backend/src/services/fulfillment/voucher-consume-queue-service.ts @@ -110,10 +110,19 @@ type VoucherConsumeTargetInsert = { target_key: string platform_order_id: string order_id: number - order_item_id: number + order_item_id: number | null due_at: string } +type VoucherOrderTargetRow = { + id: number + provider: string + platform: string + platform_order_id: string + pay_status: string + order_status: string +} + const RETRY_DELAYS_SECONDS = [10, 30, 60, 300, 600, 900, 1800] const PROCESSING_LOCK_SECONDS = 120 const VOUCHER_CONSUME_JOB_STATUSES = [ @@ -212,7 +221,7 @@ export async function scheduleVoucherConsumeForOrderItems( o.provider, o.platform, o.platform_order_id - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.id = ANY($1::bigint[]) AND oi.fulfillment_status = 'assigned' @@ -224,9 +233,32 @@ export async function scheduleVoucherConsumeForOrderItems( for (const row of rows.rows) { const provider = resolveVoucherProviderFromFields(row.provider, row.platform) - const channel = normalizeVoucherChannel(row.fulfillment_channel) - if (!provider || !channel) continue + if (!provider) continue + if (event === 'order_received') { + const orderReceivedPolicy = resolveVoucherConsumePolicy({ + provider, + channel: 'order_received', + event, + policy, + }) + if (orderReceivedPolicy.enabled) { + targets.push({ + provider, + channel: 'order_received', + trigger_event: event, + target_key: `order:${row.order_id}`, + platform_order_id: String(row.platform_order_id || '').trim(), + order_id: Number(row.order_id), + order_item_id: null, + due_at: new Date(Date.now() + orderReceivedPolicy.delaySeconds * 1000).toISOString(), + }) + continue + } + } + + const channel = normalizeVoucherChannel(row.fulfillment_channel) + if (!channel) continue const resolved = resolveVoucherConsumePolicy({ provider, channel, @@ -250,6 +282,66 @@ export async function scheduleVoucherConsumeForOrderItems( return queueVoucherConsumeTargets(targets, event) } +/** + * 订单级“收到订单后”核销。 + * + * 该入口只判断平台策略,不读取履约渠道、订单项状态、载体或待处理原因。 + * 券可能尚未到达;任务执行时会重新解析券,并按既有重试策略等待发码完成。 + */ +export async function scheduleVoucherConsumeForOrderReceived( + orderIds: number[], +): Promise<{ targetCount: number; queuedCount: number; jobIds: number[] }> { + const ids = [...new Set(orderIds.map(Number).filter((id) => Number.isInteger(id) && id > 0))] + if (ids.length === 0) { + return { targetCount: 0, queuedCount: 0, jobIds: [] } + } + + const rows = await query( + ` + SELECT + o.id, + o.provider, + o.platform, + o.platform_order_id, + o.pay_status, + o.order_status + FROM orders o + WHERE o.id = ANY($1::bigint[]) + AND o.pay_status = 'paid' + AND o.order_status NOT IN ('cancelled', 'closed', 'refunded') + `, + [ids], + ) + const policy = getVoucherConsumePolicy() + const targets: VoucherConsumeTargetInsert[] = [] + + for (const row of rows.rows) { + const provider = resolveVoucherProviderFromFields(row.provider, row.platform) + if (!provider) continue + + const resolved = resolveVoucherConsumePolicy({ + provider, + channel: 'order_received', + event: 'order_received', + policy, + }) + if (!resolved.enabled) continue + + targets.push({ + provider, + channel: 'order_received', + trigger_event: 'order_received', + target_key: `order:${row.id}`, + platform_order_id: String(row.platform_order_id || '').trim(), + order_id: Number(row.id), + order_item_id: null, + due_at: new Date(Date.now() + resolved.delaySeconds * 1000).toISOString(), + }) + } + + return queueVoucherConsumeTargets(targets, 'order_received') +} + /** * 未命中任何履约渠道时,按平台兜底策略安排核销。 * @@ -275,7 +367,7 @@ export async function scheduleVoucherConsumeFallbackForOrderItems( o.provider, o.platform, o.platform_order_id - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.id = ANY($1::bigint[]) AND oi.fulfillment_status = 'pending' @@ -285,11 +377,32 @@ export async function scheduleVoucherConsumeFallbackForOrderItems( ) const policy = getVoucherConsumePolicy() const targets: VoucherConsumeTargetInsert[] = [] + const orderReceivedTargets: VoucherConsumeTargetInsert[] = [] for (const row of rows.rows) { const provider = resolveVoucherProviderFromFields(row.provider, row.platform) if (!provider) continue + const orderReceivedPolicy = resolveVoucherConsumePolicy({ + provider, + channel: 'order_received', + event, + policy, + }) + if (orderReceivedPolicy.enabled) { + orderReceivedTargets.push({ + provider, + channel: 'order_received', + trigger_event: event, + target_key: `order:${row.order_id}`, + platform_order_id: String(row.platform_order_id || '').trim(), + order_id: Number(row.order_id), + order_item_id: null, + due_at: new Date(Date.now() + orderReceivedPolicy.delaySeconds * 1000).toISOString(), + }) + continue + } + const resolved = resolveVoucherConsumePolicy({ provider, channel: 'unmatched', @@ -310,7 +423,15 @@ export async function scheduleVoucherConsumeFallbackForOrderItems( }) } - return queueVoucherConsumeTargets(targets, event) + const [orderReceivedResult, fallbackResult] = await Promise.all([ + queueVoucherConsumeTargets(orderReceivedTargets, event), + queueVoucherConsumeTargets(targets, event), + ]) + return { + targetCount: orderReceivedResult.targetCount + fallbackResult.targetCount, + queuedCount: orderReceivedResult.queuedCount + fallbackResult.queuedCount, + jobIds: [...orderReceivedResult.jobIds, ...fallbackResult.jobIds], + } } async function queueVoucherConsumeTargets( @@ -360,6 +481,15 @@ async function queueVoucherConsumeTargets( order_item_id bigint, due_at timestamptz ) + WHERE target.channel <> 'order_received' + OR NOT EXISTS ( + SELECT 1 + FROM voucher_consume_jobs active + WHERE active.provider = target.provider + AND active.order_id = target.order_id + AND active.target_key <> target.target_key + AND active.status IN ('pending', 'processing', 'succeeded') + ) ON CONFLICT (provider, target_key) WHERE status IN ('pending', 'processing', 'succeeded') DO NOTHING @@ -1016,6 +1146,7 @@ function resolveVoucherProviderFromFields( function normalizeVoucherChannel(value: unknown): VoucherConsumeChannel | null { const channel = String(value || '').trim() if ( + channel === 'order_received' || channel === 'affiliate_dash' || channel === 'work_order' || channel === 'manual' || diff --git a/apps/backend/src/services/order/order-service.ts b/apps/backend/src/services/order/order-service.ts index 04b3ee4d..c717e4de 100644 --- a/apps/backend/src/services/order/order-service.ts +++ b/apps/backend/src/services/order/order-service.ts @@ -21,6 +21,7 @@ import { resolveOrderFulfillmentReadiness, syncOrderFulfillmentAttachments, } from '../fulfillment/order-fulfillment-readiness-service.js' +import { scheduleVoucherConsumeForOrderReceived } from '../fulfillment/voucher-consume-queue-service.js' import { syncWorkerOrdersForSourceOrder } from '../worker-platform/index.js' import { publishAdminOrderRealtimeChange, @@ -212,6 +213,17 @@ export async function upsertOrderFromSource( const { order, orderItems } = persisted + if (String(order.pay_status || '').trim() === 'paid') { + await scheduleVoucherConsumeForOrderReceived([Number(order.id)]).catch((error) => { + logWarn('[order-service]', '收到订单后安排订单级自动核销失败', { + orderId: order.id, + provider: order.provider, + platformOrderId: order.platform_order_id, + error: error instanceof Error ? error.message : String(error), + }) + }) + } + const configuredOrderItems = orderItems.filter(isOrderItemConfiguredForFulfillment) const readiness = configuredOrderItems.length > 0 diff --git a/apps/backend/src/services/platforms/ninetyone/order-service.ts b/apps/backend/src/services/platforms/ninetyone/order-service.ts index 92793973..78153313 100644 --- a/apps/backend/src/services/platforms/ninetyone/order-service.ts +++ b/apps/backend/src/services/platforms/ninetyone/order-service.ts @@ -317,7 +317,7 @@ export async function listOpen91Orders({ 'quantity', oi.quantity, 'snapshot', oi.item_snapshot_json ) ORDER BY oi.id ASC) AS items - FROM order_items oi + FROM v_order_item_current oi WHERE oi.order_id = o.id ) item_summary ON TRUE LEFT JOIN LATERAL ( diff --git a/apps/backend/src/services/product-match/compile.ts b/apps/backend/src/services/product-match/compile.ts index 7f797b5c..f0a6d271 100644 --- a/apps/backend/src/services/product-match/compile.ts +++ b/apps/backend/src/services/product-match/compile.ts @@ -93,7 +93,7 @@ function buildRuleEntries( }) } // 只配了 sellerId 的"空条件结构化"(旧实现会整店命中)不编译:属于历史脏配置, - // 新模型一律要求显式判定维度(见 docs/接单模板匹配引擎重构设计.md §2.3)。 + // 新模型一律要求显式判定维度(见 docs/archive/2026-11-08/接单模板匹配引擎重构设计.md §2.3)。 if (structuredAll.length > 0) { entries.push({ key: `legacy:rule:${rule.id}:structured`, diff --git a/apps/backend/src/services/product-match/index.ts b/apps/backend/src/services/product-match/index.ts index e96e841d..a3f3f46c 100644 --- a/apps/backend/src/services/product-match/index.ts +++ b/apps/backend/src/services/product-match/index.ts @@ -5,7 +5,7 @@ * - 引擎:条件打分 + 决策(并列最高=ambiguous,不猜)+ 诊断(差在哪一维); * - 价格/数量/拼单:`pricing.ts` 唯一实现。 * - * 设计见 docs/接单模板匹配引擎重构设计.md。 + * 设计见 docs/archive/2026-11-08/接单模板匹配引擎重构设计.md。 */ export type { ProductMatchCandidate, diff --git a/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts b/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts index 2a0dfd62..f171afc3 100644 --- a/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts +++ b/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts @@ -131,7 +131,7 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { SELECT oi.id, resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS sku_key, cfg.id AS config_id - FROM order_items oi + FROM v_order_item_current oi JOIN manual_fulfillment_configs cfg ON canonicalize_internal_sku(cfg.sku_key) = resolve_internal_sku( oi.manual_sku_key, oi.sku_name, oi.sku_code @@ -169,7 +169,7 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { ` SELECT oi.id, resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS sku_key - FROM order_items oi + FROM v_order_item_current oi WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual' AND oi.fulfillment_config_id IS NULL @@ -210,7 +210,7 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { ` SELECT oi.id, resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS sku_key - FROM order_items oi + FROM v_order_item_current oi WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual' AND oi.fulfillment_config_id IS NOT NULL @@ -257,7 +257,7 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { ` SELECT oi.id, oi.fulfillment_channel AS channel, oi.sku_code, resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku - FROM order_items oi + FROM v_order_item_current oi LEFT JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled' WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel IN ('affiliate_dash', 'work_order') @@ -316,7 +316,7 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { ` SELECT oi.id, oi.fulfillment_channel AS channel, oi.sku_code, resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku - FROM order_items oi + FROM v_order_item_current 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') @@ -407,7 +407,7 @@ async function resolveCarrierDispatchExceptions(cutoffCreatedAt: string): Promis SELECT DISTINCT ON (oi.id) oi.id AS order_item_id, cfg.id AS config_id - FROM order_items oi + FROM v_order_item_current oi JOIN manual_fulfillment_configs cfg ON canonicalize_internal_sku(cfg.sku_key) = resolve_internal_sku( oi.manual_sku_key, oi.sku_name, oi.sku_code @@ -456,7 +456,7 @@ async function resolveCarrierDispatchExceptions(cutoffCreatedAt: string): Promis SELECT DISTINCT i.order_item_id FROM cost_exception_items i JOIN cost_exceptions ce ON ce.id = i.exception_id - JOIN order_items oi ON oi.id = i.order_item_id + JOIN v_order_item_current oi ON oi.id = i.order_item_id WHERE ce.reason = 'carrier_missing' AND ce.status <> 'resolved' AND ( @@ -479,7 +479,7 @@ async function resolveCarrierDispatchExceptions(cutoffCreatedAt: string): Promis SELECT DISTINCT i.order_item_id FROM cost_exception_items i JOIN cost_exceptions ce ON ce.id = i.exception_id - JOIN order_items oi ON oi.id = i.order_item_id + JOIN v_order_item_current oi ON oi.id = i.order_item_id WHERE ce.reason = 'manual_config_missing' AND ce.status <> 'resolved' AND oi.fulfillment_config_id IS NOT NULL @@ -493,7 +493,7 @@ async function resolveCarrierDispatchExceptions(cutoffCreatedAt: string): Promis SELECT DISTINCT i.order_item_id FROM cost_exception_items i JOIN cost_exceptions ce ON ce.id = i.exception_id - JOIN order_items oi ON oi.id = i.order_item_id + JOIN v_order_item_current oi ON oi.id = i.order_item_id WHERE ce.reason = 'manual_cost_missing' AND ce.status <> 'resolved' AND EXISTS ( diff --git a/apps/backend/src/services/worker-platform/admin-kuaishou-match-service.ts b/apps/backend/src/services/worker-platform/admin-kuaishou-match-service.ts index 2ea08d82..3facc21f 100644 --- a/apps/backend/src/services/worker-platform/admin-kuaishou-match-service.ts +++ b/apps/backend/src/services/worker-platform/admin-kuaishou-match-service.ts @@ -210,7 +210,7 @@ export async function listAdminKuaishouMatchSources(payload: JsonObject = {}) { oi.item_snapshot_json, oi.created_at, oi.updated_at - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id ORDER BY oi.id DESC LIMIT $1 diff --git a/apps/frontend/src/pages/admin/cost-center/AffiliateSkuPanel.tsx b/apps/frontend/src/pages/admin/cost-center/AffiliateSkuPanel.tsx index 127e7b8f..1be48917 100644 --- a/apps/frontend/src/pages/admin/cost-center/AffiliateSkuPanel.tsx +++ b/apps/frontend/src/pages/admin/cost-center/AffiliateSkuPanel.tsx @@ -66,7 +66,7 @@ export function AffiliateSkuPanel() { queryKey: ['admin-cost-center-products', 'affiliate_dash', page, pageSize], queryFn: () => fetchAdminProductCatalog({ - carrierType: 'affiliate_dash', + channel: 'affiliate_dash', page, pageSize, }), diff --git a/apps/frontend/src/pages/admin/cost-center/ChannelMappingCatalog.tsx b/apps/frontend/src/pages/admin/cost-center/ChannelMappingCatalog.tsx index a139892c..f743d58a 100644 --- a/apps/frontend/src/pages/admin/cost-center/ChannelMappingCatalog.tsx +++ b/apps/frontend/src/pages/admin/cost-center/ChannelMappingCatalog.tsx @@ -34,7 +34,7 @@ type Props = { providers: string[] canConfig: boolean diagnostics?: MappingDiagnostics - sourceLabel: string + channelLabel: string expandedRowKeys: string[] detailPageSize: number onKeywordChange: (value: string) => void @@ -60,7 +60,7 @@ export default function ChannelMappingCatalog({ providers, canConfig, diagnostics, - sourceLabel, + channelLabel, expandedRowKeys, detailPageSize, onKeywordChange, @@ -241,7 +241,7 @@ export default function ChannelMappingCatalog({ )} diff --git a/apps/frontend/src/pages/admin/cost-center/ChannelMappingPanel.tsx b/apps/frontend/src/pages/admin/cost-center/ChannelMappingPanel.tsx index 82b49f2e..74e9a93d 100644 --- a/apps/frontend/src/pages/admin/cost-center/ChannelMappingPanel.tsx +++ b/apps/frontend/src/pages/admin/cost-center/ChannelMappingPanel.tsx @@ -3,7 +3,7 @@ * * 采用「诊断优先」结构: * 1. 顶部统计卡:标准商品 / 映射条数 / 未归一订单项 / 未归一 SKU 组; - * 2. 未归一队列:按来源 + 店铺 + 平台 SKU 聚合成可操作的行,行内直接建映射; + * 2. 未归一队列:按渠道 + 店铺 + 平台 SKU 聚合成可操作的行,行内直接建映射; * 3. 映射目录:只保留 4 列(标准商品 / 分类 / 渠道覆盖 / 映射),匹配方式进展开行。 * * 2026-10-25 拆分:本文件只保留查询、状态与动作,视图拆到 @@ -44,7 +44,7 @@ import { export function ChannelMappingPanel() { const { message, modal } = App.useApp() const queryClient = useQueryClient() - const { canConfig, productSourceFilter, options, refreshAll } = useCostCenter() + const { canConfig, channelFilter, options, refreshAll } = useCostCenter() const [mappingForm] = Form.useForm() const [view, setView] = useState<'catalog' | 'queue'>('catalog') @@ -67,11 +67,10 @@ export function ChannelMappingPanel() { const providers = options?.providers || [] const diagnosticsQuery = useQuery({ - queryKey: ['admin-cost-center-mapping-diagnostics', productSourceFilter], + queryKey: ['admin-cost-center-mapping-diagnostics', channelFilter], queryFn: () => fetchAdminChannelMappingDiagnostics({ - carrierType: productSourceFilter, - channel: productSourceFilter, + channel: channelFilter, }), }) const diagnostics = diagnosticsQuery.data?.data @@ -79,7 +78,7 @@ export function ChannelMappingPanel() { const mappingsQuery = useQuery({ queryKey: [ 'admin-cost-center-mappings', - productSourceFilter, + channelFilter, mappingKeyword, mappingProvider, mappingPage, @@ -89,7 +88,7 @@ export function ChannelMappingPanel() { fetchAdminChannelProductMappings({ keyword: mappingKeyword || undefined, provider: mappingProvider || undefined, - carrierType: productSourceFilter, + channel: channelFilter, page: mappingPage, pageSize: mappingPageSize, }), @@ -99,7 +98,7 @@ export function ChannelMappingPanel() { const queueQuery = useQuery({ queryKey: [ 'admin-cost-center-unmapped-mappings', - productSourceFilter, + channelFilter, queueKeyword, queueProvider, queuePage, @@ -107,7 +106,7 @@ export function ChannelMappingPanel() { ], queryFn: () => fetchAdminUnmappedMappingGroups({ - channel: productSourceFilter, + channel: channelFilter, provider: queueProvider || undefined, keyword: queueKeyword || undefined, page: queuePage, @@ -126,7 +125,13 @@ export function ChannelMappingPanel() { const productOptions = options?.products - .filter((item) => !productSourceFilter || item.source === productSourceFilter) + .filter( + (item) => + !channelFilter || + (channelFilter === 'unrouted' + ? item.activePrimaryChannel === null + : item.activePrimaryChannel === channelFilter), + ) .map((item) => ({ value: item.id, label: `${item.canonicalName}(${item.canonicalSku})`, @@ -274,9 +279,9 @@ export function ChannelMappingPanel() { }) } - const sourceLabel = productSourceFilter - ? (FULFILLMENT_MODE_LABELS[productSourceFilter] ?? productSourceFilter) - : '全部来源' + const channelLabel = channelFilter + ? (FULFILLMENT_MODE_LABELS[channelFilter] ?? channelFilter) + : '全部渠道' return ( <> @@ -284,7 +289,7 @@ export function ChannelMappingPanel() { title={ 渠道映射 - {sourceLabel} + {channelLabel} } extra={ @@ -344,7 +349,7 @@ export function ChannelMappingPanel() { providers={providers} canConfig={canConfig} diagnostics={diagnostics} - sourceLabel={sourceLabel} + channelLabel={channelLabel} expandedRowKeys={expandedMappingProductIds} detailPageSize={mappingDetailPageSize} onKeywordChange={(value) => { @@ -387,7 +392,7 @@ export function ChannelMappingPanel() { provider={queueProvider} providers={providers} canConfig={canConfig} - sourceLabel={sourceLabel} + channelLabel={channelLabel} onKeywordChange={(value) => { setQueueKeyword(value) setQueuePage(1) diff --git a/apps/frontend/src/pages/admin/cost-center/ChannelMappingQueue.tsx b/apps/frontend/src/pages/admin/cost-center/ChannelMappingQueue.tsx index f49b7314..58b321a1 100644 --- a/apps/frontend/src/pages/admin/cost-center/ChannelMappingQueue.tsx +++ b/apps/frontend/src/pages/admin/cost-center/ChannelMappingQueue.tsx @@ -34,7 +34,7 @@ type Props = { provider: string providers: string[] canConfig: boolean - sourceLabel: string + channelLabel: string onKeywordChange: (value: string) => void onProviderChange: (value: string) => void onPageChange: (page: number, pageSize: number) => void @@ -54,7 +54,7 @@ export default function ChannelMappingQueue({ provider, providers, canConfig, - sourceLabel, + channelLabel, onKeywordChange, onProviderChange, onPageChange, @@ -151,7 +151,7 @@ export default function ChannelMappingQueue({ ) : ( diff --git a/apps/frontend/src/pages/admin/cost-center/CostCenterContext.ts b/apps/frontend/src/pages/admin/cost-center/CostCenterContext.ts index f33ab919..e9586acb 100644 --- a/apps/frontend/src/pages/admin/cost-center/CostCenterContext.ts +++ b/apps/frontend/src/pages/admin/cost-center/CostCenterContext.ts @@ -1,7 +1,7 @@ /** * 成本中心页面上下文。 * - * 只存放「所有面板都要知道」的三件事:权限、当前来源筛选、刷新动作。 + * 只存放「所有面板都要知道」的三件事:权限、当前渠道筛选、刷新动作。 * 具体数据由各面板自己用 react-query 取——曾经这里塞过 products / versions / * exceptions 等空数组当作「统一数据源」,实际没人读,已删除。 */ @@ -15,16 +15,14 @@ export type CostCenterTab = 'products' | 'mappings' | 'cost' | 'exceptions' export type CostCenterContextValue = { canConfig: boolean - /** 当前来源筛选:all / affiliate_dash / work_order / manual。 */ + /** 当前渠道筛选。 */ scope: CostScope setScope: (scope: CostScope) => void activeTab: CostCenterTab setActiveTab: (tab: CostCenterTab) => void scopeLabel: string - /** 商品/映射按 canonical_sku 前缀筛选。 */ - productSourceFilter?: ProductSource - /** 成本项/版本/异常按履约方式筛选。 */ - channelFilter?: 'affiliate_dash' | 'work_order' | 'third_party_voucher' | 'manual' + /** 商品、映射、成本项、版本和异常共用的当前渠道筛选。 */ + channelFilter?: Exclude options?: { providers: string[] @@ -34,6 +32,7 @@ export type CostCenterContextValue = { canonicalName: string unitName: string source: ProductSource + activePrimaryChannel: string | null }> costItems: Array<{ id: number diff --git a/apps/frontend/src/pages/admin/cost-center/CostCenterShell.tsx b/apps/frontend/src/pages/admin/cost-center/CostCenterShell.tsx index 05e23324..e73bb269 100644 --- a/apps/frontend/src/pages/admin/cost-center/CostCenterShell.tsx +++ b/apps/frontend/src/pages/admin/cost-center/CostCenterShell.tsx @@ -1,5 +1,5 @@ /** - * 成本中心外壳:来源筛选、总览统计与固定 Tab。 + * 成本中心外壳:当前渠道筛选、总览统计与固定 Tab。 * * 结构(2026-10-25 调整): * 1. 来源不再是「假 Tab」,改为工具栏 Segmented —— 只筛数据,不改变导航; @@ -39,7 +39,7 @@ import { CostCenterContext, type CostCenterTab } from './CostCenterContext' import type { CostScope } from './shared' const SCOPES: Array<{ key: CostScope; label: string; hint: string }> = [ - { key: 'all', label: '全部', hint: '不区分来源,查看全部商品、映射与成本。' }, + { key: 'all', label: '全部', hint: '不区分当前渠道,查看全部商品、映射与成本。' }, { key: 'affiliate_dash', label: '自动发货', @@ -60,6 +60,16 @@ const SCOPES: Array<{ key: CostScope; label: string; hint: string }> = [ label: '人工客服', hint: '客服手工处理的商品;成本按内部 SKU 的消耗积分结算。', }, + { + key: 'on_account', + label: '统一结算', + hint: '统一结算商品;不进入人工客服或自动履约。', + }, + { + key: 'unrouted', + label: '未路由', + hint: '尚未配置有效主渠道的商品。', + }, ] const TABS: readonly CostCenterTab[] = ['products', 'mappings', 'cost', 'exceptions'] @@ -137,11 +147,7 @@ export default function CostCenterShell() { const scopeLabel = SCOPES.find((item) => item.key === scope)?.label ?? '全部' const scopeHint = SCOPES.find((item) => item.key === scope)?.hint ?? '' - const productSourceFilter = scope === 'all' ? undefined : scope - const channelFilter = - scope === 'all' - ? undefined - : (scope as 'affiliate_dash' | 'work_order' | 'third_party_voucher' | 'manual') + const channelFilter = scope === 'all' ? undefined : scope return ( 统计口径 {scopeLabel} {scope !== 'all' ? ( - (跟随上方来源筛选变化) + (跟随上方渠道筛选变化) ) : null} + } + /> + ) + } return ( - 成本配置按来源分开维护,选择一个来源查看;也可以在页面顶部直接切换来源。 + 成本配置按履约渠道分开维护;也可以在页面顶部直接切换渠道。 {CONFIG_ENTRIES.map((entry) => ( diff --git a/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx b/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx index ee9af673..e2260a81 100644 --- a/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx +++ b/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx @@ -94,7 +94,7 @@ export function ProductCatalogPanel() { const { message, modal } = App.useApp() const queryClient = useQueryClient() const navigate = useNavigate() - const { canConfig, productSourceFilter, refreshAll, setScope, setActiveTab } = useCostCenter() + const { canConfig, channelFilter, refreshAll, setScope, setActiveTab } = useCostCenter() const [productForm] = Form.useForm() // 历史修复只处理最近 30 天,避免很早以前的乱 SKU 再次进入商品与映射。 const [backfillRange, setBackfillRange] = useState<[Dayjs, Dayjs]>([ @@ -120,7 +120,7 @@ export function ProductCatalogPanel() { const [priceForm] = Form.useForm() // 游戏分类与二级目录结构仅为接单平台(work_order)独有,自动发货与人工客服不使用 - const isWorkOrder = productSourceFilter === 'work_order' + const isWorkOrder = channelFilter === 'work_order' const showCategoryBar = isWorkOrder useEffect(() => { @@ -133,7 +133,7 @@ export function ProductCatalogPanel() { const productsQuery = useQuery({ queryKey: [ 'admin-cost-center-products', - productSourceFilter, + channelFilter, productKeyword, isWorkOrder ? productRootCategoryId : null, isWorkOrder ? productSubCategoryId : null, @@ -143,7 +143,7 @@ export function ProductCatalogPanel() { queryFn: () => fetchAdminProductCatalog({ keyword: productKeyword || undefined, - carrierType: productSourceFilter, + channel: channelFilter, rootCategoryId: isWorkOrder ? (productRootCategoryId ?? undefined) : undefined, categoryId: isWorkOrder ? (productSubCategoryId ?? undefined) : undefined, page: productPage, @@ -451,7 +451,7 @@ export function ProductCatalogPanel() { function deleteProduct(item: ProductCatalogItem) { const affiliateHint = - item.source === 'affiliate_dash' + item.sourceKind === 'affiliate_dash' ? '这是自动发货 SKU,删除后再次同步会自动生成;只想隐藏它请改为归档。' : '' modal.confirm({ @@ -516,12 +516,6 @@ export function ProductCatalogPanel() { {row.templatePricingMode === 'unit' ? '按件计价' : '固定金额'} ) : null} - {row.activePrimaryChannel && row.activePrimaryChannel !== row.source ? ( - - 当前主渠道: - {FULFILLMENT_MODE_LABELS[row.activePrimaryChannel] || row.activePrimaryChannel} - - ) : null} ), @@ -540,14 +534,40 @@ export function ProductCatalogPanel() { ), }, { - title: '已绑定渠道', + title: '当前履约', + width: 160, + render: (_, row) => ( + + {row.activePrimaryChannel ? ( + + {FULFILLMENT_MODE_LABELS[row.activePrimaryChannel] || row.activePrimaryChannel} + + ) : ( + 未路由 + )} + {row.fallbackChannels?.length ? ( + + 兜底: + {row.fallbackChannels + .map((channel) => FULFILLMENT_MODE_LABELS[channel] || channel) + .join(' / ')} + + ) : null} + + ), + }, + { + title: '平台映射', dataIndex: 'mappingCount', width: 130, render: (value: number, row) => { - if (row.source === 'affiliate_dash') { + if (row.sourceKind === 'affiliate_dash') { return 直连无需映射 } - if (row.source === 'manual') { + if (row.sourceKind === 'manual') { return 内部SKU无需映射 } return value > 0 ? ( @@ -562,7 +582,7 @@ export function ProductCatalogPanel() { dataIndex: 'costVersionCount', width: 140, render: (value: number, row) => { - if (row.source === 'manual') { + if (row.sourceKind === 'manual') { return ( 以人工 SKU 配置为准 @@ -596,7 +616,7 @@ export function ProductCatalogPanel() { > {value > 0 ? `${value} 个版本 · 改价` - : row.source === 'work_order' + : row.sourceKind === 'work_order' ? '配置结算成本' : '未配置 · 立即配置'} @@ -634,7 +654,7 @@ export function ProductCatalogPanel() { render: (_, row) => canConfig ? ( - {row.source === 'manual' ? ( + {row.sourceKind === 'manual' ? ( - ) : productSourceFilter === 'third_party_voucher' ? ( + ) : channelFilter === 'third_party_voucher' ? (