From 9d5af359fa4b81433ff653cebad82b5a962422e1 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 27 Oct 2026 15:38:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(finance):=20=E5=AE=8C=E6=88=90=E8=B4=A2?= =?UTF-8?q?=E5=8A=A1=E6=8E=A7=E5=88=B6=E3=80=81L1=20=E9=92=B1=E5=8C=85?= =?UTF-8?q?=E7=A1=AC=E5=8C=96=E4=B8=8E=E8=BF=81=E7=A7=BB=E6=94=B6=E6=95=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P0/P1:影子总账追加调整、关账/退款/资金/积分幂等、成本版本追溯、对账订单项维度 - 迁移收敛:原 103–114 合并为 103_finance_foundation、104_finance_controls、105_work_order_consignment - 数据:L1 钱包 BIGINT/CHECK/奖励幂等;重复奖励审计口径修正与人工核对清单 - 报表:销售毛利缺失成本收入提示,旧收支页明确现金流口径 - 文档:同步迁移基线、合并评估、代码复核与上线说明 --- apps/backend/scripts/audit-worker-wallet.sql | 63 + ..._center.sql => 103_finance_foundation.sql} | 382 ++++++ .../src/db/migrations/103_product_catalog.sql | 152 --- .../db/migrations/104_finance_controls.sql | 1119 +++++++++++++++++ .../db/migrations/105_sales_daily_facts.sql | 166 --- ...ion.sql => 105_work_order_consignment.sql} | 96 ++ .../src/db/migrations/106_order_refunds.sql | 38 - .../db/migrations/107_manual_fulfillment.sql | 292 ----- .../migrations/108_finance_cost_integrity.sql | 301 ----- .../110_work_order_hall_and_shop_indexes.sql | 25 - ...work_order_share_cancel_reason_comment.sql | 6 - .../112_consignment_contact_supporter.sql | 39 - .../worker-platform/work-order-query-repo.ts | 2 +- .../src/routes/admin/finance-ledger.ts | 26 +- apps/backend/src/routes/admin/finance.ts | 6 +- .../src/routes/admin/kuaishou-industry.ts | 2 +- apps/backend/src/routes/admin/orders.ts | 6 +- .../src/services/admin/admin-fund-service.ts | 131 +- .../src/services/admin/admin-rbac-service.ts | 3 + .../cost-center/affiliate-product-service.ts | 49 +- .../cost-center/platform-credit-service.ts | 73 +- .../cost-center/product-catalog-service.ts | 86 +- .../services/finance/finance-controls.test.ts | 154 +++ .../finance/finance-ledger-service.ts | 538 ++++++-- .../finance/finance-reconciliation-service.ts | 132 +- .../manual-fulfillment-integrity.test.ts | 9 +- .../finance/manual-fulfillment-service.ts | 17 + .../manual-fulfillment.integration.test.ts | 46 +- .../services/finance/order-refund-service.ts | 405 +++++- .../services/finance/sales-facts-service.ts | 34 +- .../worker-platform-service.test.ts | 4 +- apps/frontend/src/App.tsx | 6 +- apps/frontend/src/layouts/AdminLayout.tsx | 1 + .../pages/admin/AdminFinanceLedgerPage.tsx | 32 +- .../src/pages/admin/AdminFinancePage.tsx | 12 +- .../src/pages/admin/AdminOrderDetailPage.tsx | 4 +- .../src/pages/admin/AdminSalesProfitPage.tsx | 4 +- apps/frontend/src/types/admin/finance.ts | 1 + docs/README.md | 24 +- docs/dev合并main评估-2026-10-27.md | 119 ++ docs/工单拼单多次参与改造方案-2026-10-23.md | 1 - docs/成本中心与履约模型.md | 66 +- docs/打手重复奖励核对-2026-10-27.md | 127 ++ docs/财务上线初始化与验收-2026-10-26.md | 8 +- docs/财务代码审计复核-2026-10-27.md | 212 ++++ docs/财务系统深度分析-2026-10-27.md | 917 ++++++++++++++ docs/项目全流程与数据库设计梳理-2026-10-23.md | 1 - 47 files changed, 4527 insertions(+), 1410 deletions(-) create mode 100644 apps/backend/scripts/audit-worker-wallet.sql rename apps/backend/src/db/migrations/{104_cost_center.sql => 103_finance_foundation.sql} (54%) delete mode 100644 apps/backend/src/db/migrations/103_product_catalog.sql create mode 100644 apps/backend/src/db/migrations/104_finance_controls.sql delete mode 100644 apps/backend/src/db/migrations/105_sales_daily_facts.sql rename apps/backend/src/db/migrations/{109_work_order_share_participation.sql => 105_work_order_consignment.sql} (54%) delete mode 100644 apps/backend/src/db/migrations/106_order_refunds.sql delete mode 100644 apps/backend/src/db/migrations/107_manual_fulfillment.sql delete mode 100644 apps/backend/src/db/migrations/108_finance_cost_integrity.sql delete mode 100644 apps/backend/src/db/migrations/110_work_order_hall_and_shop_indexes.sql delete mode 100644 apps/backend/src/db/migrations/111_work_order_share_cancel_reason_comment.sql delete mode 100644 apps/backend/src/db/migrations/112_consignment_contact_supporter.sql create mode 100644 apps/backend/src/services/finance/finance-controls.test.ts create mode 100644 docs/dev合并main评估-2026-10-27.md create mode 100644 docs/打手重复奖励核对-2026-10-27.md create mode 100644 docs/财务代码审计复核-2026-10-27.md create mode 100644 docs/财务系统深度分析-2026-10-27.md diff --git a/apps/backend/scripts/audit-worker-wallet.sql b/apps/backend/scripts/audit-worker-wallet.sql new file mode 100644 index 00000000..df29c200 --- /dev/null +++ b/apps/backend/scripts/audit-worker-wallet.sql @@ -0,0 +1,63 @@ +-- L1 打手钱包只读审计(2026-10-27) +-- 用法: +-- docker exec -i order_site-postgres-1 psql -U postgres -d order_site \ +-- -v ON_ERROR_STOP=1 < apps/backend/scripts/audit-worker-wallet.sql +-- +-- 说明:104_finance_controls 迁移后新奖励流水有 event_key 唯一约束;历史重复不删除,本脚本负责暴露。 + +\echo '=== 1. 负余额钱包(应为 0 行) ===' +SELECT worker_id, available_amount, frozen_deposit_amount, pending_unfreeze_amount, + frozen_withdraw_amount, total_credited_amount, total_settled_amount +FROM worker_wallets +WHERE available_amount < 0 + OR frozen_deposit_amount < 0 + OR pending_unfreeze_amount < 0 + OR frozen_withdraw_amount < 0 + OR total_credited_amount < 0 + OR total_settled_amount < 0 +ORDER BY worker_id; + +\echo '=== 2. 负的流水后余额(应为 0 行) ===' +SELECT id, worker_id, ledger_type, amount, balance_after, frozen_after, created_at +FROM worker_wallet_ledgers +WHERE balance_after < 0 OR frozen_after < 0 +ORDER BY id; + +\echo '=== 3. 历史奖励重复结算(需要财务确认;视图只读) ===' +SELECT worker_id, related_work_order_id, ledger_type, share_id, ledger_count, total_amount, + ledger_ids, first_created_at, last_created_at +FROM v_worker_wallet_duplicate_rewards +ORDER BY ledger_count DESC, related_work_order_id +LIMIT 100; + +\echo '=== 4. 未生成 event_key 的新奖励流水(历史行允许为空;近期行应逐步为 0) ===' +SELECT COUNT(*) AS missing_event_key +FROM worker_wallet_ledgers +WHERE ledger_type IN ('reward_settlement', 'sharing_reward', 'reward_pending_unfreeze') + AND related_work_order_id IS NOT NULL + AND event_key = '' + AND created_at >= NOW() - INTERVAL '7 days'; + +\echo '=== 5. event_key 重复(应为 0 行) ===' +SELECT event_key, COUNT(*) AS ledger_count, ARRAY_AGG(id ORDER BY id) AS ledger_ids +FROM worker_wallet_ledgers +WHERE event_key <> '' +GROUP BY event_key +HAVING COUNT(*) > 1 +ORDER BY ledger_count DESC; + +\echo '=== 6. 钱包余额与最新流水快照不一致(提示项,需结合 pending_unfreeze 判断) ===' +WITH latest AS ( + SELECT DISTINCT ON (worker_id) + worker_id, balance_after, frozen_after + FROM worker_wallet_ledgers + ORDER BY worker_id, id DESC +) +SELECT w.worker_id, w.available_amount, l.balance_after, + w.frozen_deposit_amount, l.frozen_after +FROM worker_wallets w +LEFT JOIN latest l USING (worker_id) +WHERE l.worker_id IS NOT NULL + AND (w.available_amount <> l.balance_after OR w.frozen_deposit_amount <> l.frozen_after) +ORDER BY w.worker_id +LIMIT 100; diff --git a/apps/backend/src/db/migrations/104_cost_center.sql b/apps/backend/src/db/migrations/103_finance_foundation.sql similarity index 54% rename from apps/backend/src/db/migrations/104_cost_center.sql rename to apps/backend/src/db/migrations/103_finance_foundation.sql index d3f79924..9c3d5c3b 100644 --- a/apps/backend/src/db/migrations/104_cost_center.sql +++ b/apps/backend/src/db/migrations/103_finance_foundation.sql @@ -1,3 +1,169 @@ +-- 103_finance_foundation.sql +-- 2026-10-27 迁移收敛:由以下补丁迁移合并,按原顺序执行,全部语句保持幂等。 + + +-- =========================================================================== +-- 103_product_catalog.sql —— 商品目录与商品映射基础 +-- =========================================================================== + +-- 103_product_catalog.sql —— 标准商品主数据 + 渠道 SKU 映射。 +-- +-- 目标:把「平台原始 SKU / 商品名」归一到内部标准商品,后续成本、销量、 +-- 店铺日报和订单毛利都只认 canonical_product_id。 +-- +-- 设计要点(2026-10-23 重写): +-- 1. 商品表不存「履约载体」。履约方式只由 order_items.fulfillment_mode 决定, +-- 商品的来源由 canonical_sku 前缀表达(aff: 自动发货 / tpl: 接单模板 / +-- wo: 历史工单证据 / man: 人工),避免两处口径互相打架。 +-- 2. 映射唯一键包含匹配方式与名称键,91 同店铺复用 sku_code 才能消歧。 +-- 3. normalized_quantity 不在这里预填:预填会把「5 个」类 SKU 永久锁死成 1, +-- 必须由回填脚本按 SKU 名称解析后写入。 +-- +-- 约束: +-- 1. canonical_sku 是内部稳定编码,创建后不随平台改名; +-- 2. shop_id 为空串表示该来源的全局映射,具体店铺映射优先; +-- 3. 任何一层匹配出现多个候选都不自动选择,交由冲突清单人工处理。 + +CREATE TABLE IF NOT EXISTS product_catalog ( + id BIGSERIAL PRIMARY KEY, + canonical_sku TEXT NOT NULL, + canonical_name TEXT NOT NULL, + category TEXT NOT NULL DEFAULT '', + unit_name TEXT NOT NULL DEFAULT '件', + status TEXT NOT NULL DEFAULT 'active', + attributes_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_product_catalog_canonical_sku + ON product_catalog (canonical_sku); +CREATE INDEX IF NOT EXISTS idx_product_catalog_status_category + ON product_catalog (status, category, canonical_name); + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_product_catalog_status') THEN + ALTER TABLE product_catalog + ADD CONSTRAINT chk_product_catalog_status + CHECK (status IN ('active', 'archived')); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_product_catalog_sku') THEN + ALTER TABLE product_catalog + ADD CONSTRAINT chk_product_catalog_sku + CHECK (length(trim(canonical_sku)) > 0); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_product_catalog_name') THEN + ALTER TABLE product_catalog + ADD CONSTRAINT chk_product_catalog_name + CHECK (length(trim(canonical_name)) > 0); + END IF; +END $$; + +COMMENT ON TABLE product_catalog IS '标准商品/皮肤主数据:报表、成本和销量统计的唯一商品维度'; +COMMENT ON COLUMN product_catalog.canonical_sku IS + '内部稳定编码;前缀表达来源:aff: 自动发货、tpl: 接单模板、wo: 工单证据、man: 人工'; +COMMENT ON COLUMN product_catalog.unit_name IS '计量单位:件/个/颗等,配合 normalized_quantity 使用'; + +CREATE TABLE IF NOT EXISTS channel_product_mappings ( + id BIGSERIAL PRIMARY KEY, + provider TEXT NOT NULL, + shop_id TEXT NOT NULL DEFAULT '', + platform_sku_code TEXT NOT NULL DEFAULT '', + platform_sku_name TEXT NOT NULL DEFAULT '', + platform_sku_name_key TEXT NOT NULL DEFAULT '', + canonical_product_id BIGINT NOT NULL REFERENCES product_catalog(id) ON DELETE RESTRICT, + match_type TEXT NOT NULL DEFAULT 'sku_code', + quantity_multiplier INTEGER NOT NULL DEFAULT 1, + confidence TEXT NOT NULL DEFAULT 'manual', + source TEXT NOT NULL DEFAULT 'manual', + enabled BOOLEAN NOT NULL DEFAULT TRUE, + note TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_product_mappings_match_key + ON channel_product_mappings ( + provider, + shop_id, + platform_sku_code, + match_type, + platform_sku_name_key + ); +CREATE INDEX IF NOT EXISTS idx_channel_product_mappings_product + ON channel_product_mappings (canonical_product_id, enabled); +CREATE INDEX IF NOT EXISTS idx_channel_product_mappings_lookup + ON channel_product_mappings ( + provider, + shop_id, + platform_sku_code, + match_type, + platform_sku_name_key, + enabled + ); + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_confidence') THEN + ALTER TABLE channel_product_mappings + ADD CONSTRAINT chk_channel_product_mappings_confidence + CHECK (confidence IN ('exact', 'rule', 'manual')); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_source') THEN + ALTER TABLE channel_product_mappings + ADD CONSTRAINT chk_channel_product_mappings_source + CHECK (source IN ('manual', 'import', 'rule')); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_multiplier') THEN + ALTER TABLE channel_product_mappings + ADD CONSTRAINT chk_channel_product_mappings_multiplier + CHECK (quantity_multiplier > 0); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_match_type') THEN + ALTER TABLE channel_product_mappings + ADD CONSTRAINT chk_channel_product_mappings_match_type + CHECK (match_type IN ('sku_code', 'sku_code_name', 'sku_code_series', 'sku_name_series')); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_match_key') THEN + ALTER TABLE channel_product_mappings + ADD CONSTRAINT chk_channel_product_mappings_match_key + CHECK (platform_sku_code <> '' OR platform_sku_name_key <> ''); + END IF; +END $$; + +COMMENT ON TABLE channel_product_mappings IS '来源 + 店铺 + 平台 SKU -> 标准商品映射'; +COMMENT ON COLUMN channel_product_mappings.shop_id IS '空串=该来源的全局映射;具体店铺映射优先'; +COMMENT ON COLUMN channel_product_mappings.match_type IS + '匹配方式:sku_code / sku_code_name / sku_code_series / sku_name_series'; +COMMENT ON COLUMN channel_product_mappings.platform_sku_name_key IS + '归一后的 SKU 名称或数量系列键;sku_code 模式为空串'; +COMMENT ON COLUMN channel_product_mappings.quantity_multiplier IS 'SKU 名称内含数量时的换算系数,例如「5个」=5'; + +ALTER TABLE order_items + ADD COLUMN IF NOT EXISTS canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE RESTRICT, + ADD COLUMN IF NOT EXISTS normalized_quantity INTEGER, + ADD COLUMN IF NOT EXISTS normalized_sku_name TEXT, + ADD COLUMN IF NOT EXISTS normalized_sku_series TEXT; + +CREATE INDEX IF NOT EXISTS idx_order_items_canonical_product + ON order_items (canonical_product_id); +CREATE INDEX IF NOT EXISTS idx_order_items_normalized_sku_lookup + ON order_items (normalized_sku_name, normalized_sku_series); + +COMMENT ON COLUMN order_items.canonical_product_id IS '归一到标准商品;由渠道映射或人工指定'; +COMMENT ON COLUMN order_items.normalized_quantity IS + '归一化件数:从 SKU 名称解析并固化,禁止直接用 quantity 做销量;未解析时保持 NULL'; +COMMENT ON COLUMN order_items.normalized_sku_name IS + 'NFKC、小写、去空白后的 SKU 名称,用于编码 + 名称消歧'; +COMMENT ON COLUMN order_items.normalized_sku_series IS + '剥离数量并归一同义字后的 SKU 名称系列,用于数量系列匹配'; + + +-- =========================================================================== +-- 104_cost_center.sql —— 成本中心、成本项、成本版本与快照 +-- =========================================================================== + -- 104_cost_center.sql —— 成本项、成本版本、成本快照、成本异常与订单项收入固化。 -- -- 目标:所有成本金额只在 cost_versions 维护;订单项/工单/人工单只保存快照行, @@ -477,3 +643,219 @@ WHERE ci.id = v.cost_item_id INSERT INTO admin_role_permissions (role_key, permission_key) VALUES ('finance', 'finance.config') ON CONFLICT (role_key, permission_key) DO NOTHING; + + +-- =========================================================================== +-- 105_sales_daily_facts.sql —— 销售事实与订单项收入口径 +-- =========================================================================== + +-- 105_sales_daily_facts.sql —— 统一履约事实 + 店铺 × 日期 × 标准商品 × 履约方式的销售事实。 +-- +-- 目标:订单页、成本快照、销售事实只读 order_items.fulfillment_mode, +-- 不再各自用 provider / voucher / task 临时推断。 +-- +-- 设计要点(2026-10-23 重写): +-- 1. 履约方式只有一个真源,就是 order_items.fulfillment_mode; +-- 2. 商品表、成本项表、快照表都不再存载体字段,避免四处口径分叉; +-- 3. 核销码只是状态,不参与履约判定; +-- 4. 字段允许短暂为 NULL(订单项创建时通常还没有工单/任务), +-- 由统一决策服务 + 定时兜底在 24 小时内补齐。 +-- +-- 销售事实口径: +-- * 销售日 = orders.paid_at 的 Asia/Shanghai 日期;没有 paid_at 时退回 created_at; +-- * 收入 = order_items.revenue_amount_fen(迁移 104 固化),不再直接拿整单金额; +-- * 履约方式 = order_items.fulfillment_mode,四种:work_order / affiliate_dash / +-- legacy_platform / manual;未判定的订单项先归 manual 并由定时任务补齐; +-- * 未归一商品允许 canonical_product_id 为空,单独成「未归一」行,保证总收入对平; +-- * 成本来自 cost_snapshots 当前有效行;没有快照的订单项计入 cost_missing_count; +-- * 退款金额来自 order_refunds(迁移 106),本表只保存冲减后的净额。 +-- +-- 2026-10-25 合并:原 107(履约方式列与索引)并入本迁移。履约方式是销售事实的 +-- 维度键,两者必须同一批次上线,单独成文件只会让口径分散在两处。 + +-- --------------------------------------------------------------------------- +-- 1. 履约方式:唯一真源 +-- --------------------------------------------------------------------------- + +ALTER TABLE order_items + ADD COLUMN IF NOT EXISTS fulfillment_mode TEXT, + ADD COLUMN IF NOT EXISTS fulfillment_decided_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS fulfillment_reason TEXT; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'chk_order_items_fulfillment_mode' + ) THEN + ALTER TABLE order_items + ADD CONSTRAINT chk_order_items_fulfillment_mode + CHECK ( + fulfillment_mode IS NULL + OR fulfillment_mode IN ( + 'work_order', 'affiliate_dash', 'legacy_platform', 'manual' + ) + ); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_order_items_fulfillment_mode + ON order_items (fulfillment_mode, order_id); + +CREATE INDEX IF NOT EXISTS idx_order_items_fulfillment_pending + ON order_items (order_id) + WHERE fulfillment_mode IS NULL; + +-- 按履约方式筛异常时走半连接,这条索引让 order_items 侧只扫目标履约方式的行。 +CREATE INDEX IF NOT EXISTS idx_order_items_fulfillment_id + ON order_items (fulfillment_mode, id); + +COMMENT ON COLUMN order_items.fulfillment_mode IS + '履约方式:work_order 接单平台 / affiliate_dash 自动发货 / legacy_platform 历史平台(已下线)/ manual 人工;唯一真源,报表与后台只读此字段'; +COMMENT ON COLUMN order_items.fulfillment_decided_at IS + '履约方式最后一次判定时间'; +COMMENT ON COLUMN order_items.fulfillment_reason IS + '履约方式判定来源:订单同步 / 定时归集 / 历史回填等'; + +-- 本迁移只做 DDL;历史行回填由 scripts/backfill-fulfillment-mode.ts 分批执行。 + +-- --------------------------------------------------------------------------- +-- 2. 销售事实 +-- --------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS sales_daily_facts ( + id BIGSERIAL PRIMARY KEY, + stat_date DATE NOT NULL, + provider TEXT NOT NULL, + shop_id TEXT NOT NULL DEFAULT '', + canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE RESTRICT, + fulfillment_mode TEXT NOT NULL, + order_count INTEGER NOT NULL DEFAULT 0, + item_count INTEGER NOT NULL DEFAULT 0, + quantity NUMERIC(14, 3) NOT NULL DEFAULT 0, + gross_revenue_fen BIGINT NOT NULL DEFAULT 0, + refund_amount_fen BIGINT NOT NULL DEFAULT 0, + net_revenue_fen BIGINT NOT NULL DEFAULT 0, + cost_amount_fen BIGINT NOT NULL DEFAULT 0, + cost_missing_count INTEGER NOT NULL DEFAULT 0, + cost_stage TEXT NOT NULL DEFAULT 'missing', + gross_profit_fen BIGINT NOT NULL DEFAULT 0, + computed_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +-- canonical_product_id 为空时唯一索引仍要生效,因此用 COALESCE(..., 0) 作为「未归一」桶。 +CREATE UNIQUE INDEX IF NOT EXISTS uq_sales_daily_facts_key + ON sales_daily_facts ( + stat_date, + provider, + shop_id, + COALESCE(canonical_product_id, 0), + fulfillment_mode + ); +CREATE INDEX IF NOT EXISTS idx_sales_daily_facts_date + ON sales_daily_facts (stat_date DESC); +CREATE INDEX IF NOT EXISTS idx_sales_daily_facts_shop + ON sales_daily_facts (provider, shop_id, stat_date DESC); +CREATE INDEX IF NOT EXISTS idx_sales_daily_facts_product + ON sales_daily_facts (canonical_product_id, stat_date DESC); +CREATE INDEX IF NOT EXISTS idx_sales_daily_facts_mode + ON sales_daily_facts (fulfillment_mode, stat_date DESC); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'chk_sales_daily_facts_fulfillment_mode' + ) THEN + ALTER TABLE sales_daily_facts + ADD CONSTRAINT chk_sales_daily_facts_fulfillment_mode + CHECK (fulfillment_mode IN ( + 'work_order', 'affiliate_dash', 'legacy_platform', 'manual' + )); + END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'chk_sales_daily_facts_cost_stage' + ) THEN + ALTER TABLE sales_daily_facts + ADD CONSTRAINT chk_sales_daily_facts_cost_stage + CHECK (cost_stage IN ('missing', 'partial', 'estimated', 'actual')); + END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'chk_sales_daily_facts_amounts' + ) THEN + ALTER TABLE sales_daily_facts + ADD CONSTRAINT chk_sales_daily_facts_amounts + CHECK ( + order_count >= 0 + AND item_count >= 0 + AND quantity >= 0 + AND gross_revenue_fen >= 0 + AND refund_amount_fen >= 0 + AND net_revenue_fen >= 0 + AND cost_amount_fen >= 0 + AND cost_missing_count >= 0 + ); + END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'chk_sales_daily_facts_profit' + ) THEN + ALTER TABLE sales_daily_facts + ADD CONSTRAINT chk_sales_daily_facts_profit + CHECK (gross_profit_fen = net_revenue_fen - cost_amount_fen); + END IF; +END $$; + +COMMENT ON TABLE sales_daily_facts IS + '店铺×日期(Asia/Shanghai)×标准商品×履约方式的销售与成本事实;报表直接读这张表'; +COMMENT ON COLUMN sales_daily_facts.canonical_product_id IS + '标准商品;为空表示渠道映射未命中,报表单独展示「未归一」占比'; +COMMENT ON COLUMN sales_daily_facts.fulfillment_mode IS + '履约方式:work_order 接单平台 / affiliate_dash 自动发货 / legacy_platform 历史平台 / manual 人工'; +COMMENT ON COLUMN sales_daily_facts.cost_stage IS + 'missing=全部无成本快照;partial=部分无快照;estimated=全部预估;actual=全部实际'; +COMMENT ON COLUMN sales_daily_facts.cost_missing_count IS + '没有当前有效成本快照的订单项数;>0 时该行毛利不可直接用于决策'; + + +-- =========================================================================== +-- 106_order_refunds.sql —— 退款单与退款外部单号 +-- =========================================================================== + +-- 106_order_refunds.sql —— 订单退款冲减(手动录入先行,后续接平台退款 API)。 +-- +-- 一条记录 = 一个订单项的退款金额;整单退款会按订单项拆成多条,用 batch_no 归组。 +-- 退款写入后重算当天销售事实,净收入与毛利立即冲减(见 services/finance/sales-facts-service.ts)。 +-- +-- 2026-10-25 合并:原 112 迁移。cost_exceptions.resolution_note 列已随迁移 104 +-- 落到异常表定义里,这里只保留退款主体。 + +CREATE TABLE IF NOT EXISTS order_refunds ( + id bigserial PRIMARY KEY, + refund_no text NOT NULL UNIQUE, + batch_no text NOT NULL DEFAULT '', + order_id bigint NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + order_item_id bigint NOT NULL REFERENCES order_items(id) ON DELETE CASCADE, + amount_fen bigint NOT NULL CHECK (amount_fen > 0), + reason text NOT NULL DEFAULT '', + note text NOT NULL DEFAULT '', + source text NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'api', 'platform', 'legacy')), + status text NOT NULL DEFAULT 'confirmed' CHECK (status IN ('confirmed', 'voided')), + refunded_at timestamp with time zone NOT NULL, + external_ref text NOT NULL DEFAULT '', + operator_admin_user_id bigint REFERENCES admin_users(id) ON DELETE SET NULL, + voided_at timestamp with time zone, + void_reason text NOT NULL DEFAULT '', + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_order_refunds_order + ON order_refunds (order_id, status); +CREATE INDEX IF NOT EXISTS idx_order_refunds_item + ON order_refunds (order_item_id, status); +CREATE INDEX IF NOT EXISTS idx_order_refunds_time + ON order_refunds (refunded_at DESC); + +COMMENT ON TABLE order_refunds IS '订单退款记录:手动录入先行,后续平台退款 API 写 source=api/platform'; +COMMENT ON COLUMN order_refunds.batch_no IS '同一次操作的分组号;整单退款会按订单项拆成多条'; +COMMENT ON COLUMN order_refunds.source IS 'manual=后台手工登记;api/platform=平台退款接口;legacy=历史数据导入'; diff --git a/apps/backend/src/db/migrations/103_product_catalog.sql b/apps/backend/src/db/migrations/103_product_catalog.sql deleted file mode 100644 index 803f9434..00000000 --- a/apps/backend/src/db/migrations/103_product_catalog.sql +++ /dev/null @@ -1,152 +0,0 @@ --- 103_product_catalog.sql —— 标准商品主数据 + 渠道 SKU 映射。 --- --- 目标:把「平台原始 SKU / 商品名」归一到内部标准商品,后续成本、销量、 --- 店铺日报和订单毛利都只认 canonical_product_id。 --- --- 设计要点(2026-10-23 重写): --- 1. 商品表不存「履约载体」。履约方式只由 order_items.fulfillment_mode 决定, --- 商品的来源由 canonical_sku 前缀表达(aff: 自动发货 / tpl: 接单模板 / --- wo: 历史工单证据 / man: 人工),避免两处口径互相打架。 --- 2. 映射唯一键包含匹配方式与名称键,91 同店铺复用 sku_code 才能消歧。 --- 3. normalized_quantity 不在这里预填:预填会把「5 个」类 SKU 永久锁死成 1, --- 必须由回填脚本按 SKU 名称解析后写入。 --- --- 约束: --- 1. canonical_sku 是内部稳定编码,创建后不随平台改名; --- 2. shop_id 为空串表示该来源的全局映射,具体店铺映射优先; --- 3. 任何一层匹配出现多个候选都不自动选择,交由冲突清单人工处理。 - -CREATE TABLE IF NOT EXISTS product_catalog ( - id BIGSERIAL PRIMARY KEY, - canonical_sku TEXT NOT NULL, - canonical_name TEXT NOT NULL, - category TEXT NOT NULL DEFAULT '', - unit_name TEXT NOT NULL DEFAULT '件', - status TEXT NOT NULL DEFAULT 'active', - attributes_json JSONB NOT NULL DEFAULT '{}'::jsonb, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL -); - -CREATE UNIQUE INDEX IF NOT EXISTS uq_product_catalog_canonical_sku - ON product_catalog (canonical_sku); -CREATE INDEX IF NOT EXISTS idx_product_catalog_status_category - ON product_catalog (status, category, canonical_name); - -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_product_catalog_status') THEN - ALTER TABLE product_catalog - ADD CONSTRAINT chk_product_catalog_status - CHECK (status IN ('active', 'archived')); - END IF; - IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_product_catalog_sku') THEN - ALTER TABLE product_catalog - ADD CONSTRAINT chk_product_catalog_sku - CHECK (length(trim(canonical_sku)) > 0); - END IF; - IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_product_catalog_name') THEN - ALTER TABLE product_catalog - ADD CONSTRAINT chk_product_catalog_name - CHECK (length(trim(canonical_name)) > 0); - END IF; -END $$; - -COMMENT ON TABLE product_catalog IS '标准商品/皮肤主数据:报表、成本和销量统计的唯一商品维度'; -COMMENT ON COLUMN product_catalog.canonical_sku IS - '内部稳定编码;前缀表达来源:aff: 自动发货、tpl: 接单模板、wo: 工单证据、man: 人工'; -COMMENT ON COLUMN product_catalog.unit_name IS '计量单位:件/个/颗等,配合 normalized_quantity 使用'; - -CREATE TABLE IF NOT EXISTS channel_product_mappings ( - id BIGSERIAL PRIMARY KEY, - provider TEXT NOT NULL, - shop_id TEXT NOT NULL DEFAULT '', - platform_sku_code TEXT NOT NULL DEFAULT '', - platform_sku_name TEXT NOT NULL DEFAULT '', - platform_sku_name_key TEXT NOT NULL DEFAULT '', - canonical_product_id BIGINT NOT NULL REFERENCES product_catalog(id) ON DELETE RESTRICT, - match_type TEXT NOT NULL DEFAULT 'sku_code', - quantity_multiplier INTEGER NOT NULL DEFAULT 1, - confidence TEXT NOT NULL DEFAULT 'manual', - source TEXT NOT NULL DEFAULT 'manual', - enabled BOOLEAN NOT NULL DEFAULT TRUE, - note TEXT NOT NULL DEFAULT '', - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL -); - -CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_product_mappings_match_key - ON channel_product_mappings ( - provider, - shop_id, - platform_sku_code, - match_type, - platform_sku_name_key - ); -CREATE INDEX IF NOT EXISTS idx_channel_product_mappings_product - ON channel_product_mappings (canonical_product_id, enabled); -CREATE INDEX IF NOT EXISTS idx_channel_product_mappings_lookup - ON channel_product_mappings ( - provider, - shop_id, - platform_sku_code, - match_type, - platform_sku_name_key, - enabled - ); - -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_confidence') THEN - ALTER TABLE channel_product_mappings - ADD CONSTRAINT chk_channel_product_mappings_confidence - CHECK (confidence IN ('exact', 'rule', 'manual')); - END IF; - IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_source') THEN - ALTER TABLE channel_product_mappings - ADD CONSTRAINT chk_channel_product_mappings_source - CHECK (source IN ('manual', 'import', 'rule')); - END IF; - IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_multiplier') THEN - ALTER TABLE channel_product_mappings - ADD CONSTRAINT chk_channel_product_mappings_multiplier - CHECK (quantity_multiplier > 0); - END IF; - IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_match_type') THEN - ALTER TABLE channel_product_mappings - ADD CONSTRAINT chk_channel_product_mappings_match_type - CHECK (match_type IN ('sku_code', 'sku_code_name', 'sku_code_series', 'sku_name_series')); - END IF; - IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_match_key') THEN - ALTER TABLE channel_product_mappings - ADD CONSTRAINT chk_channel_product_mappings_match_key - CHECK (platform_sku_code <> '' OR platform_sku_name_key <> ''); - END IF; -END $$; - -COMMENT ON TABLE channel_product_mappings IS '来源 + 店铺 + 平台 SKU -> 标准商品映射'; -COMMENT ON COLUMN channel_product_mappings.shop_id IS '空串=该来源的全局映射;具体店铺映射优先'; -COMMENT ON COLUMN channel_product_mappings.match_type IS - '匹配方式:sku_code / sku_code_name / sku_code_series / sku_name_series'; -COMMENT ON COLUMN channel_product_mappings.platform_sku_name_key IS - '归一后的 SKU 名称或数量系列键;sku_code 模式为空串'; -COMMENT ON COLUMN channel_product_mappings.quantity_multiplier IS 'SKU 名称内含数量时的换算系数,例如「5个」=5'; - -ALTER TABLE order_items - ADD COLUMN IF NOT EXISTS canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE RESTRICT, - ADD COLUMN IF NOT EXISTS normalized_quantity INTEGER, - ADD COLUMN IF NOT EXISTS normalized_sku_name TEXT, - ADD COLUMN IF NOT EXISTS normalized_sku_series TEXT; - -CREATE INDEX IF NOT EXISTS idx_order_items_canonical_product - ON order_items (canonical_product_id); -CREATE INDEX IF NOT EXISTS idx_order_items_normalized_sku_lookup - ON order_items (normalized_sku_name, normalized_sku_series); - -COMMENT ON COLUMN order_items.canonical_product_id IS '归一到标准商品;由渠道映射或人工指定'; -COMMENT ON COLUMN order_items.normalized_quantity IS - '归一化件数:从 SKU 名称解析并固化,禁止直接用 quantity 做销量;未解析时保持 NULL'; -COMMENT ON COLUMN order_items.normalized_sku_name IS - 'NFKC、小写、去空白后的 SKU 名称,用于编码 + 名称消歧'; -COMMENT ON COLUMN order_items.normalized_sku_series IS - '剥离数量并归一同义字后的 SKU 名称系列,用于数量系列匹配'; diff --git a/apps/backend/src/db/migrations/104_finance_controls.sql b/apps/backend/src/db/migrations/104_finance_controls.sql new file mode 100644 index 00000000..d9067391 --- /dev/null +++ b/apps/backend/src/db/migrations/104_finance_controls.sql @@ -0,0 +1,1119 @@ +-- 104_finance_controls.sql +-- 2026-10-27 迁移收敛:由以下补丁迁移合并,按原顺序执行,全部语句保持幂等。 + + +-- =========================================================================== +-- 107_manual_fulfillment.sql —— 人工履约、备用金与平台积分 +-- =========================================================================== + +-- 107_manual_fulfillment.sql —— 客服人工履约:SKU 配置清单 + 三平台积分钱包 + 备用金编号。 +-- +-- 背景(2026-10-24 确认): +-- 客服人工履约有四个资金/额度钱包,每个客服各一个: +-- 南航积分 / 乐玩积分 / ROCK 积分 / 备用金现金 +-- 三个积分平台的积分**互不通用**,所以每个平台一套账户。 +-- 换算率按平台不同,真源是履约档案 config_json.rechargeUnitPriceFen: +-- 南航 1 积分 = 1 分钱;乐玩 1 积分 = 1 分钱;ROCK 1 积分 = 1 元(100 分)。 +-- +-- 配置链路(无匹配引擎、无打分): +-- 1. 扫描「走到人工履约」的内部 SKU,登记到 manual_sku_registry; +-- 2. 运营在成本中心配置每个 SKU 的「履约渠道 + 消耗积分 + 成本模式」; +-- 3. 点「重新匹配」把配置回填到历史人工订单项; +-- 4. 客服确认履约时直接读配置,固定成本零输入。 +-- +-- 关键口径: +-- * 配置主键是**内部 SKU**(快手 skuNick / 拼多多 outSkuSn),不是商品标题; +-- * 同名 SKU 算同一个商品,配置全局唯一(跨渠道共用); +-- * 积分 0 = 成本不固定,由客服在确认履约时手填实际成本; +-- * 备用金现金不建账户行,走现有 fund_accounts / fund_entries。 +-- +-- 2026-10-25 合并:原 106(备用金账户编号可读化)并入本迁移。编号规则与人工履约 +-- 共用同一套钱包语义,单独成文件会让「资金台账」散落在两个迁移里。 + +-- --------------------------------------------------------------------------- +-- 1. 人工 SKU 登记表:扫描到的「走到人工履约」的内部 SKU +-- --------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS manual_sku_registry ( + id BIGSERIAL PRIMARY KEY, + sku_key TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + shop_count INTEGER NOT NULL DEFAULT 0, + order_count INTEGER NOT NULL DEFAULT 0, + item_count INTEGER NOT NULL DEFAULT 0, + revenue_fen BIGINT NOT NULL DEFAULT 0, + first_seen_date DATE, + last_seen_date DATE, + scan_from_date DATE, + scan_to_date DATE, + config_id BIGINT, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_manual_sku_registry_sku_key + ON manual_sku_registry (sku_key); +CREATE INDEX IF NOT EXISTS idx_manual_sku_registry_config + ON manual_sku_registry (config_id); +CREATE INDEX IF NOT EXISTS idx_manual_sku_registry_item_count + ON manual_sku_registry (item_count DESC); + +COMMENT ON TABLE manual_sku_registry IS + '人工履约 SKU 登记表:扫描到的内部 SKU(快手 skuNick / 拼多多 outSkuSn),供运营集中配置'; +COMMENT ON COLUMN manual_sku_registry.sku_key IS + '内部 SKU,配置主键;同名 SKU 视为同一商品,跨渠道共用一份配置'; +COMMENT ON COLUMN manual_sku_registry.display_name IS + '商品名称,仅用于展示识别,不是配置键'; + +-- --------------------------------------------------------------------------- +-- 2. 人工履约配置:每个内部 SKU 的渠道、积分与成本模式 +-- --------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS manual_fulfillment_configs ( + id BIGSERIAL PRIMARY KEY, + sku_key TEXT NOT NULL, + canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE SET NULL, + platform_key TEXT NOT NULL, + credit_quantity BIGINT NOT NULL DEFAULT 0, + cost_mode TEXT NOT NULL DEFAULT 'fixed', + enabled BOOLEAN NOT NULL DEFAULT TRUE, + note TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_manual_fulfillment_configs_sku_key + ON manual_fulfillment_configs (sku_key); +CREATE INDEX IF NOT EXISTS idx_manual_fulfillment_configs_platform + ON manual_fulfillment_configs (platform_key); + +COMMENT ON TABLE manual_fulfillment_configs IS + '人工履约配置:内部 SKU → 履约渠道 + 消耗积分 + 成本模式'; +COMMENT ON COLUMN manual_fulfillment_configs.credit_quantity IS + '每单消耗积分(整数)。换算率按平台配置;0 表示成本不固定,客服确认履约时手填'; +COMMENT ON COLUMN manual_fulfillment_configs.cost_mode IS + 'fixed=成本按积分固定(cost_fen = credit_quantity × 平台换算率);variable=成本不固定,客服确认履约时手填'; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_fulfillment_configs_platform' + ) THEN + ALTER TABLE manual_fulfillment_configs + ADD CONSTRAINT chk_manual_fulfillment_configs_platform + CHECK (platform_key IN ('nanhang', 'lewan', 'rock', 'fund_cash')); + END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_fulfillment_configs_cost_mode' + ) THEN + ALTER TABLE manual_fulfillment_configs + ADD CONSTRAINT chk_manual_fulfillment_configs_cost_mode + CHECK (cost_mode IN ('fixed', 'variable')); + END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_fulfillment_configs_credit' + ) THEN + ALTER TABLE manual_fulfillment_configs + ADD CONSTRAINT chk_manual_fulfillment_configs_credit + CHECK (credit_quantity >= 0); + END IF; +END $$; + +-- --------------------------------------------------------------------------- +-- 3. 三平台积分钱包:每个客服在每个积分平台一个账户 +-- --------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS platform_credit_accounts ( + id BIGSERIAL PRIMARY KEY, + platform_key TEXT NOT NULL, + owner_admin_user_id BIGINT NOT NULL REFERENCES admin_users(id) ON DELETE RESTRICT, + balance_quantity BIGINT NOT NULL DEFAULT 0, + unit_name TEXT NOT NULL DEFAULT '积分', + recharge_unit_price_fen BIGINT NOT NULL DEFAULT 1, + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_platform_credit_accounts_owner_platform + ON platform_credit_accounts (owner_admin_user_id, platform_key); +CREATE INDEX IF NOT EXISTS idx_platform_credit_accounts_platform_status + ON platform_credit_accounts (platform_key, status); + +COMMENT ON TABLE platform_credit_accounts IS + '客服平台积分钱包:南航/乐玩/ROCK 三平台积分互不通用,每平台每客服一个账户;换算率按平台配置(南航/乐玩 1 积分 = 1 分,ROCK 1 积分 = 1 元)'; +COMMENT ON COLUMN platform_credit_accounts.recharge_unit_price_fen IS + '积分充值单价(分):1 积分 = ? 分。按平台不同,默认取档案 config_json.rechargeUnitPriceFen'; + +CREATE TABLE IF NOT EXISTS platform_credit_entries ( + id BIGSERIAL PRIMARY KEY, + account_id BIGINT NOT NULL REFERENCES platform_credit_accounts(id) ON DELETE RESTRICT, + entry_type TEXT NOT NULL, + quantity_delta BIGINT NOT NULL, + balance_after BIGINT NOT NULL, + amount_fen BIGINT NOT NULL DEFAULT 0, + platform_ref TEXT NOT NULL DEFAULT '', + related_task_id BIGINT, + note TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_platform_credit_entries_account_time + ON platform_credit_entries (account_id, created_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_platform_credit_entries_type_time + ON platform_credit_entries (entry_type, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_platform_credit_entries_task + ON platform_credit_entries (related_task_id) + WHERE related_task_id IS NOT NULL; + +COMMENT ON TABLE platform_credit_entries IS + '平台积分流水:充值/消耗/调整/退回,只追加,不修改不删除'; +COMMENT ON COLUMN platform_credit_entries.amount_fen IS + '对应人民币金额(分)= quantity_delta × recharge_unit_price_fen'; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'chk_platform_credit_accounts_platform' + ) THEN + ALTER TABLE platform_credit_accounts + ADD CONSTRAINT chk_platform_credit_accounts_platform + CHECK (platform_key IN ('nanhang', 'lewan', 'rock')); + END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'chk_platform_credit_accounts_status' + ) THEN + ALTER TABLE platform_credit_accounts + ADD CONSTRAINT chk_platform_credit_accounts_status + CHECK (status IN ('active', 'frozen', 'closed')); + END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'chk_platform_credit_accounts_balance' + ) THEN + ALTER TABLE platform_credit_accounts + ADD CONSTRAINT chk_platform_credit_accounts_balance + CHECK (balance_quantity >= 0); + END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'chk_platform_credit_entries_type' + ) THEN + ALTER TABLE platform_credit_entries + ADD CONSTRAINT chk_platform_credit_entries_type + CHECK (entry_type IN ('grant', 'consume', 'adjust', 'return')); + END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'chk_platform_credit_entries_delta' + ) THEN + ALTER TABLE platform_credit_entries + ADD CONSTRAINT chk_platform_credit_entries_delta + CHECK (quantity_delta <> 0); + END IF; +END $$; + +-- --------------------------------------------------------------------------- +-- 4. 订单项挂配置:客服确认履约时直接读这里 +-- --------------------------------------------------------------------------- + +ALTER TABLE order_items + ADD COLUMN IF NOT EXISTS fulfillment_config_id BIGINT + REFERENCES manual_fulfillment_configs(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_order_items_fulfillment_config + ON order_items (fulfillment_config_id) + WHERE fulfillment_config_id IS NOT NULL; + +-- 内部 SKU 表达式索引:人工 SKU 扫描与「重新匹配」回填都靠它。 +-- 没有这个索引时两处都是全表扫(实测扫描 2.4s / 回填 5.4s), +-- 建索引后回填降到 1.7s。 +CREATE INDEX IF NOT EXISTS idx_order_items_manual_internal_sku + ON order_items (COALESCE(NULLIF(spec_json->>'skuNick', ''), NULLIF(spec_json->>'outSkuSn', ''))) + WHERE fulfillment_mode = 'manual'; + +COMMENT ON COLUMN order_items.fulfillment_config_id IS + '人工履约配置(人工渠道+消耗积分+成本模式);由「重新匹配」回填'; + +-- --------------------------------------------------------------------------- +-- 5. 四个客服钱包的履约档案 +-- --------------------------------------------------------------------------- +-- 三个积分平台 + 备用金现金。executor_key 统一 manual_dispatch, +-- 靠 config_json.platformKey 区分平台;**不复用** kuaishou_ct_assisted / +-- kuaishou_feifei(那是已下线的历史自动发货通道)。 + +INSERT INTO fulfillment_profiles ( + profile_key, name, executor_key, requires_claim, auto_dispatch, + inventory_strategy, config_json, created_at, updated_at +) +VALUES + ('manual_nanhang', '南航平台人工', 'manual_dispatch', FALSE, FALSE, 'external_platform', + '{"platformKey":"nanhang","platformName":"南航平台","unitName":"积分","creditManaged":true,"cashAdapter":false,"rechargeUnitPriceFen":1}'::jsonb, + NOW(), NOW()), + ('manual_lewan', '乐玩后台人工', 'manual_dispatch', FALSE, FALSE, 'external_platform', + '{"platformKey":"lewan","platformName":"乐玩后台","unitName":"积分","creditManaged":true,"cashAdapter":false,"rechargeUnitPriceFen":1}'::jsonb, + NOW(), NOW()), + ('manual_rock', 'ROCK 后台人工', 'manual_dispatch', FALSE, FALSE, 'external_platform', + '{"platformKey":"rock","platformName":"ROCK 后台","unitName":"积分","creditManaged":true,"cashAdapter":false,"rechargeUnitPriceFen":100}'::jsonb, + NOW(), NOW()), + ('manual_fund', '备用金现金人工', 'manual_dispatch', FALSE, FALSE, 'external_platform', + '{"platformKey":"fund_cash","platformName":"备用金现金","unitName":"元","creditManaged":false,"cashAdapter":true,"rechargeUnitPriceFen":100}'::jsonb, + NOW(), NOW()) +ON CONFLICT (profile_key) DO UPDATE +SET name = EXCLUDED.name, + config_json = EXCLUDED.config_json, + updated_at = NOW(); + +-- --------------------------------------------------------------------------- +-- 6. 备用金账户编号可读化 +-- --------------------------------------------------------------------------- +-- 历史编号是 FA + 10 位随机十六进制(如 FA7683D6B58B),在「备用金」列表里 +-- 无法一眼对应到具体客服。新规则:FA-<大写用户名>,重名追加 -2、-3。 +-- +-- 幂等:编号由 admin_users.username 重新推导,与当前 account_no 无关, +-- 重复执行结果一致。 + +WITH base AS ( + SELECT + fa.id, + 'FA-' || COALESCE( + NULLIF( + trim( + both '_' FROM upper( + regexp_replace(COALESCE(NULLIF(au.username, ''), 'account-' || fa.id), '[^A-Za-z0-9_]+', '_', 'g') + ) + ), + '' + ), + 'ACCOUNT-' || fa.id + ) AS base_no + FROM fund_accounts fa + LEFT JOIN admin_users au ON au.id = fa.owner_admin_user_id +), +numbered AS ( + SELECT + id, + base_no, + row_number() OVER (PARTITION BY base_no ORDER BY id) AS rn + FROM base +) +UPDATE fund_accounts fa +SET account_no = CASE WHEN n.rn = 1 THEN n.base_no ELSE n.base_no || '-' || n.rn END +FROM numbered n +WHERE fa.id = n.id; + + +-- =========================================================================== +-- 108_finance_cost_integrity.sql —— 人工成本完整性、影子总账、对账与关账 +-- =========================================================================== + +-- 108_finance_cost_integrity.sql +-- 财务/成本完整性与影子总账(合并原 108/111/112/113/114)。 +-- +-- 合并原因:2026-10-26 之前这几块是分批补丁,生产基线 101 升级时会产生 +-- 5 个只差索引/约束/注释的迁移,顺序和回滚都难维护。合并后: +-- 1. 成本异常归档表 +-- 2. 人工成本订单项级唯一与 subject 语义 +-- 3. 影子总账/期间/对账 +-- 4. 商品页/模板预览查询索引 +-- 全部语句保持幂等;新库从 101 升级与空库初始化同一路径。 + + +-- --------------------------------------------------------------------------- +-- 成本异常归档(原 108_cost_exception_archive.sql) +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS cost_exceptions_archive ( + id BIGINT PRIMARY KEY, + subject_type TEXT NOT NULL, + subject_id BIGINT NOT NULL, + order_item_id BIGINT, + work_order_id BIGINT, + reason TEXT NOT NULL, + status TEXT NOT NULL, + detail_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL, + resolved_at TIMESTAMPTZ, + resolved_by_admin_user_id BIGINT, + resolution_note TEXT NOT NULL DEFAULT '', + archived_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_cost_exceptions_archive_archived_at + ON cost_exceptions_archive (archived_at DESC); +CREATE INDEX IF NOT EXISTS idx_cost_exceptions_archive_subject + ON cost_exceptions_archive (subject_type, subject_id); +CREATE INDEX IF NOT EXISTS idx_cost_exceptions_archive_reason + ON cost_exceptions_archive (reason, archived_at DESC); + +COMMENT ON TABLE cost_exceptions_archive IS + '已处理成本异常的归档:data-retention 任务把超过保留期(默认 60 天)的 resolved/ignored 行搬到这里,open 行不归档'; +COMMENT ON COLUMN cost_exceptions_archive.archived_at IS + '归档时间;原表 created_at / resolved_at 原样保留,便于按原时间追溯'; + +-- 热表上补一个「待归档」用的部分索引,避免每日任务扫全表。 +CREATE INDEX IF NOT EXISTS idx_cost_exceptions_archivable + ON cost_exceptions (COALESCE(resolved_at, created_at)) + WHERE status <> 'open'; + + +-- --------------------------------------------------------------------------- +-- 人工成本完整性(原 111_manual_cost_integrity.sql) +-- --------------------------------------------------------------------------- +UPDATE cost_snapshots +SET subject_id = order_item_id +WHERE subject_type = 'manual_case' + AND order_item_id IS NOT NULL + AND subject_id > 0 + AND subject_id <> order_item_id; + +-- 2. 同一订单项存在重复有效人工快照时,只保留最新一条(历史脏数据兜底)。 +UPDATE cost_snapshots c +SET superseded_at = NOW() +WHERE c.subject_type = 'manual_case' + AND c.superseded_at IS NULL + AND c.subject_id > 0 + AND EXISTS ( + SELECT 1 + FROM cost_snapshots newer + WHERE newer.subject_type = 'manual_case' + AND newer.order_item_id = c.order_item_id + AND newer.superseded_at IS NULL + AND newer.id > c.id + ); + +-- 3. 数据库级唯一:一个订单项只有一条有效人工成本快照。 +CREATE UNIQUE INDEX IF NOT EXISTS uq_cost_snapshots_manual_order_item + ON cost_snapshots (order_item_id) + WHERE subject_type = 'manual_case' AND superseded_at IS NULL AND subject_id > 0; + +-- 4. 语义约束:新写入的 manual_case 必须 subject_id = order_item_id。 +-- NOT VALID 只约束新写入,负数历史孤儿快照(迁移 104 约定)保持原样。 +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_snapshots_manual_subject' + ) THEN + ALTER TABLE cost_snapshots + ADD CONSTRAINT chk_cost_snapshots_manual_subject + CHECK (subject_type <> 'manual_case' OR subject_id = order_item_id) + NOT VALID; + END IF; +END $$; + +-- 5. 人工成本监控 / 对账按订单项反查备用金支出。 +CREATE INDEX IF NOT EXISTS idx_manual_expenses_order_item + ON manual_expenses (order_item_id) + WHERE order_item_id IS NOT NULL; + +-- 6. 人工 SKU 匹配键索引:spec_json.skuNick -> outSkuSn -> sku_code -> sku_name。 +-- 旧索引只覆盖前两级,缺 SKU 键的人工单会走顺序扫描;这里替换为同一套兜底表达式。 +DROP INDEX IF EXISTS idx_order_items_manual_internal_sku; +CREATE INDEX IF NOT EXISTS idx_order_items_manual_sku_key + ON order_items ( + COALESCE( + NULLIF(spec_json->>'skuNick', ''), + NULLIF(spec_json->>'outSkuSn', ''), + NULLIF(sku_code, ''), + NULLIF(sku_name, '') + ) + ) + WHERE fulfillment_mode = 'manual'; + +COMMENT ON INDEX uq_cost_snapshots_manual_order_item IS + '人工成本完整性:一个订单项最多一条有效 manual_case 快照(并发重复确认的数据库兜底)'; + + +-- --------------------------------------------------------------------------- +-- 影子总账(原 112_finance_ledger.sql) +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS finance_accounts ( + account_code TEXT PRIMARY KEY, + account_name TEXT NOT NULL, + account_type TEXT NOT NULL + CHECK (account_type IN ('asset', 'liability', 'equity', 'revenue', 'expense')), + normal_side TEXT NOT NULL CHECK (normal_side IN ('debit', 'credit')), + parent_code TEXT REFERENCES finance_accounts(account_code) ON DELETE RESTRICT, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')), + description TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS finance_journal_entries ( + id BIGSERIAL PRIMARY KEY, + entry_no TEXT NOT NULL UNIQUE, + event_type TEXT NOT NULL, + ref_type TEXT NOT NULL, + ref_id TEXT NOT NULL, + occurred_at TIMESTAMPTZ NOT NULL, + period_key TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'posted' CHECK (status IN ('posted', 'reversed')), + memo TEXT NOT NULL DEFAULT '', + metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + UNIQUE (event_type, ref_type, ref_id) +); + +CREATE INDEX IF NOT EXISTS idx_finance_entries_period + ON finance_journal_entries (period_key, occurred_at DESC); +CREATE INDEX IF NOT EXISTS idx_finance_entries_ref + ON finance_journal_entries (ref_type, ref_id); + +CREATE TABLE IF NOT EXISTS finance_journal_lines ( + id BIGSERIAL PRIMARY KEY, + entry_id BIGINT NOT NULL REFERENCES finance_journal_entries(id) ON DELETE CASCADE, + line_no INTEGER NOT NULL, + account_code TEXT NOT NULL REFERENCES finance_accounts(account_code) ON DELETE RESTRICT, + debit_fen BIGINT NOT NULL DEFAULT 0, + credit_fen BIGINT NOT NULL DEFAULT 0, + memo TEXT NOT NULL DEFAULT '', + dimensions_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL, + CHECK (debit_fen >= 0 AND credit_fen >= 0), + CHECK ((debit_fen > 0) <> (credit_fen > 0)), + UNIQUE (entry_id, line_no) +); + +CREATE INDEX IF NOT EXISTS idx_finance_lines_account + ON finance_journal_lines (account_code); + +CREATE TABLE IF NOT EXISTS finance_periods ( + period_key TEXT PRIMARY KEY, + starts_at TIMESTAMPTZ NOT NULL, + ends_at TIMESTAMPTZ NOT NULL, + status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'closed')), + summary_json JSONB NOT NULL DEFAULT '{}'::jsonb, + closed_at TIMESTAMPTZ, + closed_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS finance_reconciliations ( + id BIGSERIAL PRIMARY KEY, + provider TEXT NOT NULL, + period_from DATE NOT NULL, + period_to DATE NOT NULL, + source TEXT NOT NULL DEFAULT 'manual_import', + status TEXT NOT NULL DEFAULT 'imported' + CHECK (status IN ('imported', 'matched', 'differences', 'resolved')), + expected_fen BIGINT NOT NULL DEFAULT 0, + actual_fen BIGINT NOT NULL DEFAULT 0, + diff_fen BIGINT NOT NULL DEFAULT 0, + summary_json JSONB NOT NULL DEFAULT '{}'::jsonb, + imported_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL, + resolved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_finance_reconciliations_period + ON finance_reconciliations (provider, period_from DESC, id DESC); + +CREATE TABLE IF NOT EXISTS finance_reconciliation_items ( + id BIGSERIAL PRIMARY KEY, + reconciliation_id BIGINT NOT NULL + REFERENCES finance_reconciliations(id) ON DELETE CASCADE, + line_no INTEGER NOT NULL, + provider TEXT NOT NULL, + platform_order_id TEXT NOT NULL DEFAULT '', + external_ref TEXT NOT NULL DEFAULT '', + entry_type TEXT NOT NULL CHECK (entry_type IN ('sale', 'refund')), + amount_fen BIGINT NOT NULL, + occurred_at TIMESTAMPTZ, + matched_order_id BIGINT, + matched_refund_id BIGINT, + expected_fen BIGINT, + diff_fen BIGINT, + match_status TEXT NOT NULL + CHECK (match_status IN ( + 'matched', 'amount_mismatch', 'missing_order', 'missing_refund', 'duplicate' + )), + detail_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL, + UNIQUE (reconciliation_id, line_no) +); + +CREATE INDEX IF NOT EXISTS idx_finance_reconciliation_items_status + ON finance_reconciliation_items (reconciliation_id, match_status); + +-- 科目表(可按业务裁剪;借贷方向先定死) +INSERT INTO finance_accounts ( + account_code, account_name, account_type, normal_side, description, created_at, updated_at +) +VALUES + ('1001', '银行存款', 'asset', 'debit', '公司银行/支付通道资金', NOW(), NOW()), + ('1002', '客服备用金', 'asset', 'debit', '已拨付给客服的备用金', NOW(), NOW()), + ('1101', '应收账款-平台', 'asset', 'debit', '平台待结算/待收款', NOW(), NOW()), + ('1201', '预付账款-客服积分', 'asset', 'debit', '已充值到平台的客服积分', NOW(), NOW()), + ('2001', '应付履约款', 'liability', 'credit', '应付打手/人工履约成本', NOW(), NOW()), + ('2005', '其他应付-垫付人', 'liability', 'credit', '客服垫付待核销', NOW(), NOW()), + ('3001', '投入资金/权益', 'equity', 'credit', '股东投入与期初权益', NOW(), NOW()), + ('4001', '商品销售收入', 'revenue', 'credit', '按用户实付确认的收入', NOW(), NOW()), + ('4101', '退款冲减', 'revenue', 'debit', '退款冲减收入(借方)', NOW(), NOW()), + ('5001', '履约成本', 'expense', 'debit', '打手报酬/人工/自动发货成本', NOW(), NOW()), + ('5101', '人工履约成本', 'expense', 'debit', '人工客服实际垫付成本', NOW(), NOW()) +ON CONFLICT (account_code) DO UPDATE +SET account_name = EXCLUDED.account_name, + account_type = EXCLUDED.account_type, + normal_side = EXCLUDED.normal_side, + description = EXCLUDED.description, + updated_at = NOW(); + +COMMENT ON TABLE finance_accounts IS '会计科目表;影子总账使用,不接管业务钱包余额'; +COMMENT ON TABLE finance_journal_entries IS '凭证头;event_type + ref_type + ref_id 保证业务幂等'; +COMMENT ON TABLE finance_journal_lines IS '凭证行;每行只能借或贷,单张凭证借贷必须平衡'; +COMMENT ON TABLE finance_periods IS '会计期间;closed 期间禁止新增凭证'; +COMMENT ON TABLE finance_reconciliations IS '平台对账批次:导入平台账单后与订单/退款匹配'; +COMMENT ON TABLE finance_reconciliation_items IS '对账明细:matched / amount_mismatch / missing_* / duplicate'; + + +-- --------------------------------------------------------------------------- +-- 成本中心查询索引(原 113_cost_center_query_indexes.sql) +-- --------------------------------------------------------------------------- +CREATE INDEX IF NOT EXISTS idx_order_items_unmapped_mode + ON order_items (fulfillment_mode) + WHERE canonical_product_id IS NULL; + +CREATE INDEX IF NOT EXISTS idx_fulfillment_tasks_affiliate_recent + ON fulfillment_tasks (created_at) + WHERE executor_key = 'affiliate_dash'; + +COMMENT ON INDEX idx_order_items_unmapped_mode IS + '成本中心总览:按履约方式统计未归一订单项,避免全表扫 order_items'; +COMMENT ON INDEX idx_fulfillment_tasks_affiliate_recent IS + '自动发货任务 30 天聚合:只索引 affiliate_dash,减少商品列表聚合扫描'; + +-- 新索引建好后立刻更新统计信息,避免参数化查询在旧统计下继续选全表扫描。 +ANALYZE order_items; +ANALYZE product_catalog; +ANALYZE cost_exceptions; +ANALYZE fulfillment_tasks; + + +-- --------------------------------------------------------------------------- +-- 模板证据索引(原 114_template_evidence_indexes.sql) +-- --------------------------------------------------------------------------- +CREATE INDEX IF NOT EXISTS idx_orders_basis_date + ON orders (((COALESCE(paid_at, created_at) AT TIME ZONE 'Asia/Shanghai')::date)); + +CREATE INDEX IF NOT EXISTS idx_order_items_order_sku_cover + ON order_items (order_id) INCLUDE (id, sku_code, sku_name); + +COMMENT ON INDEX idx_orders_basis_date IS + '模板初始化/时间筛选:按 Asia/Shanghai 订单日期过滤,避免全表扫 orders'; +COMMENT ON INDEX idx_order_items_order_sku_cover IS + '模板初始化的 SKU 证据覆盖索引,避免回表取 sku_code/sku_name'; + +ANALYZE orders; +ANALYZE order_items; + + +-- =========================================================================== +-- 113_finance_controls.sql —— 财务 P0/P1 数据库级控制 +-- =========================================================================== + +-- 113_finance_controls.sql +-- 财务 P0 控制:退款外部单号幂等、人工支出订单项唯一、凭证借贷平衡、已过账凭证不可删改、 +-- 平台退款意图表(先落意图再调用平台,失败可重试,不重复退款)。 +-- +-- 兼容性:所有 DDL 幂等;对已有脏数据先给出明确异常,不静默删除/覆盖。 + +-- --------------------------------------------------------------------------- +-- 1. 退款外部单号幂等 +-- --------------------------------------------------------------------------- +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM order_refunds + WHERE external_ref <> '' + GROUP BY source, external_ref + HAVING COUNT(*) > 1 + ) THEN + RAISE EXCEPTION 'order_refunds 存在重复的 (source, external_ref),请先清理后再执行 112_finance_controls'; + END IF; +END $$; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_order_refunds_source_external_ref + ON order_refunds (source, external_ref) + WHERE external_ref <> ''; + +-- --------------------------------------------------------------------------- +-- 2. 一个订单项最多一条人工支出(现金路径) +-- --------------------------------------------------------------------------- +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM manual_expenses + WHERE order_item_id IS NOT NULL + GROUP BY order_item_id + HAVING COUNT(*) > 1 + ) THEN + RAISE EXCEPTION 'manual_expenses 存在同一 order_item_id 的多条支出,请先核对后再执行 112_finance_controls'; + END IF; +END $$; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_manual_expenses_order_item + ON manual_expenses (order_item_id) + WHERE order_item_id IS NOT NULL; + +-- --------------------------------------------------------------------------- +-- 3. 平台退款意图:先落库,再调用平台;本地失败可重试 +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS finance_refund_intents ( + id BIGSERIAL PRIMARY KEY, + provider TEXT NOT NULL, + external_ref TEXT NOT NULL, + order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + order_item_id BIGINT NOT NULL REFERENCES order_items(id) ON DELETE CASCADE, + amount_fen BIGINT NOT NULL CHECK (amount_fen > 0), + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'platform_approved', 'recorded', 'failed')), + error_message TEXT NOT NULL DEFAULT '', + created_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + UNIQUE (provider, external_ref) +); + +CREATE INDEX IF NOT EXISTS idx_finance_refund_intents_status + ON finance_refund_intents (status, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_finance_refund_intents_order + ON finance_refund_intents (order_id, order_item_id); + +COMMENT ON TABLE finance_refund_intents IS + '平台退款意图/状态机:pending → platform_approved → recorded;失败置 failed,可重试本地记账'; + +-- --------------------------------------------------------------------------- +-- 4. 凭证借贷平衡:延迟约束触发器,在事务提交时校验整张凭证 +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION finance_assert_journal_balanced() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + v_entry_id BIGINT; + v_debit BIGINT; + v_credit BIGINT; +BEGIN + v_entry_id := COALESCE(NEW.entry_id, OLD.entry_id); + + SELECT COALESCE(SUM(debit_fen), 0), COALESCE(SUM(credit_fen), 0) + INTO v_debit, v_credit + FROM finance_journal_lines + WHERE entry_id = v_entry_id; + + IF v_debit <> v_credit THEN + RAISE EXCEPTION + 'finance_journal_lines 凭证 % 借贷不平衡:借方 %,贷方 %', + v_entry_id, v_debit, v_credit + USING ERRCODE = '23514'; + END IF; + + RETURN NULL; +END; +$$; + +DROP TRIGGER IF EXISTS trg_finance_journal_balanced ON finance_journal_lines; +CREATE CONSTRAINT TRIGGER trg_finance_journal_balanced + AFTER INSERT OR UPDATE OR DELETE ON finance_journal_lines + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION finance_assert_journal_balanced(); + +-- --------------------------------------------------------------------------- +-- 5. 已过账凭证不可更新;删除时若仍有分录则拒绝(允许测试/运维先删分录再删头) +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION finance_block_posted_entry_change() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'UPDATE' THEN + RAISE EXCEPTION 'finance_journal_entries 已过账凭证不可更新(id=%)', OLD.id + USING ERRCODE = '23514'; + END IF; + + IF TG_OP = 'DELETE' THEN + IF EXISTS (SELECT 1 FROM finance_journal_lines WHERE entry_id = OLD.id) THEN + RAISE EXCEPTION 'finance_journal_entries 已过账凭证不可删除(id=%),请使用反向调整凭证', OLD.id + USING ERRCODE = '23514'; + END IF; + RETURN OLD; + END IF; + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_finance_journal_immutable ON finance_journal_entries; +CREATE TRIGGER trg_finance_journal_immutable + BEFORE UPDATE OR DELETE ON finance_journal_entries + FOR EACH ROW EXECUTE FUNCTION finance_block_posted_entry_change(); + +-- --------------------------------------------------------------------------- +-- 6. 财务权限拆分:post / close / reconcile 与 config 分离 +-- --------------------------------------------------------------------------- +INSERT INTO admin_permissions (permission_key, permission_name, permission_group, description) +VALUES + ('finance.post', '财务记账/退款', '财务管理', '手工凭证、销售事实重算、退款登记与平台退款'), + ('finance.close', '期间关账/反关账', '财务管理', '关闭或重新打开会计期间'), + ('finance.reconcile', '平台对账处理', '财务管理', '导入平台账单并标记差异解决') +ON CONFLICT (permission_key) DO UPDATE SET + permission_name = EXCLUDED.permission_name, + permission_group = EXCLUDED.permission_group, + description = EXCLUDED.description, + updated_at = NOW(); + +INSERT INTO admin_role_permissions (role_key, permission_key) +SELECT 'admin', permission_key +FROM admin_permissions +WHERE permission_key IN ('finance.post', 'finance.close', 'finance.reconcile') +ON CONFLICT DO NOTHING; + +INSERT INTO admin_role_permissions (role_key, permission_key) +VALUES + ('finance', 'finance.post'), + ('finance', 'finance.close'), + ('finance', 'finance.reconcile') +ON CONFLICT DO NOTHING; + +-- --------------------------------------------------------------------------- +-- 以下为原 113–115,合并进 112:未发布迁移收敛,减少升级路径文件数。 +-- --------------------------------------------------------------------------- + +-- 113_fund_balance_integrity.sql +-- 备用金余额勾稽:fund_accounts.balance_fen 必须等于 Σ fund_entries.amount_fen。 +-- 从 112 拆出:112 已在部分环境应用,新增约束用独立迁移,避免修改已应用迁移。 + +-- --------------------------------------------------------------------------- +-- 7. 备用金余额勾稽:fund_accounts.balance_fen 必须等于 Σ fund_entries.amount_fen +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION fund_assert_account_balance() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + v_account_id BIGINT; + v_balance BIGINT; + v_entry_sum BIGINT; +BEGIN + -- 仅测试清理/灾备运维可显式关闭;正常业务不得关闭。 + IF current_setting('app.fund_balance_check', true) = 'off' THEN + RETURN NULL; + END IF; + + IF TG_TABLE_NAME = 'fund_entries' THEN + v_account_id := COALESCE(NEW.account_id, OLD.account_id); + ELSE + v_account_id := COALESCE(NEW.id, OLD.id); + END IF; + + SELECT a.balance_fen, COALESCE(SUM(e.amount_fen), 0) + INTO v_balance, v_entry_sum + FROM fund_accounts a + LEFT JOIN fund_entries e ON e.account_id = a.id + WHERE a.id = v_account_id + GROUP BY a.balance_fen; + + IF NOT FOUND THEN + RETURN NULL; + END IF; + + IF v_balance <> v_entry_sum THEN + RAISE EXCEPTION + 'fund_accounts 余额与流水不一致(account_id=%:余额 %,流水合计 %)', + v_account_id, v_balance, v_entry_sum + USING ERRCODE = '23514'; + END IF; + + RETURN NULL; +END; +$$; + +DROP TRIGGER IF EXISTS trg_fund_entries_balance ON fund_entries; +CREATE CONSTRAINT TRIGGER trg_fund_entries_balance + AFTER INSERT OR UPDATE OR DELETE ON fund_entries + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION fund_assert_account_balance(); + +DROP TRIGGER IF EXISTS trg_fund_accounts_balance ON fund_accounts; +CREATE CONSTRAINT TRIGGER trg_fund_accounts_balance + AFTER INSERT OR UPDATE ON fund_accounts + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION fund_assert_account_balance(); + +-- 114_finance_request_idempotency.sql +-- 资金/积分写命令的 requestId 幂等键:同一账户 + 同一 requestId 只允许一条流水。 +-- 客户端可在重试时复用 requestId;服务端用唯一索引兜底,重复请求直接 409。 + +ALTER TABLE fund_entries + ADD COLUMN IF NOT EXISTS request_id TEXT NOT NULL DEFAULT ''; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_fund_entries_account_request + ON fund_entries (account_id, request_id) + WHERE request_id <> ''; + +COMMENT ON COLUMN fund_entries.request_id IS + '客户端请求幂等键;同一账户同一 requestId 只能写一条流水,空字符串表示未提供'; + +ALTER TABLE platform_credit_entries + ADD COLUMN IF NOT EXISTS request_id TEXT NOT NULL DEFAULT ''; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_platform_credit_entries_account_request + ON platform_credit_entries (account_id, request_id) + WHERE request_id <> ''; + +COMMENT ON COLUMN platform_credit_entries.request_id IS + '客户端请求幂等键;同一积分账户同一 requestId 只能写一条流水,空字符串表示未提供'; + +-- 115_reconciliation_item_dimensions.sql +-- 平台对账明细支持订单项 / 平台明细维度,避免一单多品只能按订单总额匹配。 + +ALTER TABLE finance_reconciliation_items + ADD COLUMN IF NOT EXISTS platform_item_id TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS order_item_id BIGINT REFERENCES order_items(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_finance_reconciliation_items_order_item + ON finance_reconciliation_items (order_item_id) + WHERE order_item_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_finance_reconciliation_items_platform_item + ON finance_reconciliation_items (provider, platform_order_id, platform_item_id) + WHERE platform_item_id <> ''; + +COMMENT ON COLUMN finance_reconciliation_items.platform_item_id IS + '平台账单明细号;没有明细号时为空,按订单总额匹配'; +COMMENT ON COLUMN finance_reconciliation_items.order_item_id IS + '匹配到的订单项;一单多品时用于按 SKU/明细核对'; + + +-- =========================================================================== +-- 114_l1_wallet_hardening.sql —— L1 打手钱包硬化 +-- =========================================================================== + +-- 114_l1_wallet_hardening.sql +-- L1 打手钱包硬化:金额 BIGINT、余额非负 CHECK、奖励流水业务幂等键。 +-- +-- 背景:新财务表(fund_* / finance_* / cost_*)已用 BIGINT + 约束,但真正掌管钱的 +-- worker_* / orders / work_orders 仍是 INTEGER,且 worker_wallets 没有 CHECK。 +-- 本迁移把 L1 金额统一到 BIGINT,并给新写入加最小约束;历史重复结算不删除, +-- 通过审计视图暴露,避免直接改历史资金。 + +-- --------------------------------------------------------------------------- +-- 1. 金额 int4 -> bigint +-- --------------------------------------------------------------------------- +ALTER TABLE orders + ALTER COLUMN total_amount TYPE BIGINT; + +ALTER TABLE worker_wallets + ALTER COLUMN available_amount TYPE BIGINT, + ALTER COLUMN frozen_deposit_amount TYPE BIGINT, + ALTER COLUMN total_credited_amount TYPE BIGINT, + ALTER COLUMN total_settled_amount TYPE BIGINT, + ALTER COLUMN pending_unfreeze_amount TYPE BIGINT, + ALTER COLUMN frozen_withdraw_amount TYPE BIGINT; + +ALTER TABLE worker_wallet_ledgers + ALTER COLUMN amount TYPE BIGINT, + ALTER COLUMN balance_after TYPE BIGINT, + ALTER COLUMN frozen_after TYPE BIGINT; + +ALTER TABLE worker_finance_requests + ALTER COLUMN amount TYPE BIGINT, + ALTER COLUMN frozen_amount TYPE BIGINT; + +ALTER TABLE worker_deposit_unfreezes + ALTER COLUMN amount TYPE BIGINT; + +ALTER TABLE worker_after_sales_cases + ALTER COLUMN recovery_amount TYPE BIGINT, + ALTER COLUMN debt_amount TYPE BIGINT, + ALTER COLUMN refunded_amount TYPE BIGINT, + ALTER COLUMN pending_deposit_deduction_amount TYPE BIGINT, + ALTER COLUMN available_deduction_amount TYPE BIGINT; + +ALTER TABLE worker_after_sales_debts + ALTER COLUMN original_amount TYPE BIGINT, + ALTER COLUMN outstanding_amount TYPE BIGINT; + +ALTER TABLE worker_users + ALTER COLUMN personal_deposit_free_amount TYPE BIGINT; + +ALTER TABLE work_orders + ALTER COLUMN reward_amount TYPE BIGINT, + ALTER COLUMN required_deposit_amount TYPE BIGINT, + ALTER COLUMN deposit_threshold_amount TYPE BIGINT, + ALTER COLUMN sharing_unit_reward TYPE BIGINT; + +ALTER TABLE work_order_shares + ALTER COLUMN share_reward TYPE BIGINT, + ALTER COLUMN share_deposit TYPE BIGINT, + ALTER COLUMN unit_reward TYPE BIGINT; + +ALTER TABLE work_product_rules + ALTER COLUMN reward_amount TYPE BIGINT, + ALTER COLUMN required_deposit_amount TYPE BIGINT, + ALTER COLUMN deposit_threshold_amount TYPE BIGINT, + ALTER COLUMN sharing_unit_reward TYPE BIGINT, + ALTER COLUMN unit_price_fen TYPE BIGINT; + +ALTER TABLE consignment_inventories + ALTER COLUMN cost_price_fen TYPE BIGINT, + ALTER COLUMN deposit_amount_fen TYPE BIGINT; + +ALTER TABLE consignment_submissions + ALTER COLUMN asking_price_fen TYPE BIGINT, + ALTER COLUMN deposit_amount_fen TYPE BIGINT; + +-- --------------------------------------------------------------------------- +-- 2. 余额/金额非负约束(历史数据已核查无负数) +-- --------------------------------------------------------------------------- +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_worker_wallets_nonnegative') THEN + ALTER TABLE worker_wallets + ADD CONSTRAINT chk_worker_wallets_nonnegative CHECK ( + available_amount >= 0 + AND frozen_deposit_amount >= 0 + AND total_credited_amount >= 0 + AND total_settled_amount >= 0 + AND pending_unfreeze_amount >= 0 + AND frozen_withdraw_amount >= 0 + ); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_worker_wallet_ledgers_nonnegative_after') THEN + ALTER TABLE worker_wallet_ledgers + ADD CONSTRAINT chk_worker_wallet_ledgers_nonnegative_after CHECK ( + balance_after >= 0 AND frozen_after >= 0 + ); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_worker_finance_requests_nonnegative') THEN + ALTER TABLE worker_finance_requests + ADD CONSTRAINT chk_worker_finance_requests_nonnegative CHECK ( + amount >= 0 AND frozen_amount >= 0 + ); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_orders_total_amount_nonnegative') THEN + ALTER TABLE orders + ADD CONSTRAINT chk_orders_total_amount_nonnegative CHECK (total_amount >= 0); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_work_orders_amounts_nonnegative') THEN + ALTER TABLE work_orders + ADD CONSTRAINT chk_work_orders_amounts_nonnegative CHECK ( + reward_amount >= 0 + AND required_deposit_amount >= 0 + AND deposit_threshold_amount >= 0 + AND sharing_unit_reward >= 0 + ); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_work_order_shares_amounts_nonnegative') THEN + ALTER TABLE work_order_shares + ADD CONSTRAINT chk_work_order_shares_amounts_nonnegative CHECK ( + share_reward >= 0 AND share_deposit >= 0 AND unit_reward >= 0 + ); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_worker_deposit_unfreezes_amount_nonnegative') THEN + ALTER TABLE worker_deposit_unfreezes + ADD CONSTRAINT chk_worker_deposit_unfreezes_amount_nonnegative CHECK (amount >= 0); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_worker_after_sales_amounts_nonnegative') THEN + ALTER TABLE worker_after_sales_cases + ADD CONSTRAINT chk_worker_after_sales_amounts_nonnegative CHECK ( + recovery_amount >= 0 + AND debt_amount >= 0 + AND refunded_amount >= 0 + AND pending_deposit_deduction_amount >= 0 + AND available_deduction_amount >= 0 + ); + ALTER TABLE worker_after_sales_debts + ADD CONSTRAINT chk_worker_after_sales_debts_amounts_nonnegative CHECK ( + original_amount >= 0 AND outstanding_amount >= 0 + ); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_worker_users_personal_deposit_free_nonnegative') THEN + ALTER TABLE worker_users + ADD CONSTRAINT chk_worker_users_personal_deposit_free_nonnegative + CHECK (personal_deposit_free_amount >= 0); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_consignment_amounts_nonnegative') THEN + ALTER TABLE consignment_inventories + ADD CONSTRAINT chk_consignment_amounts_nonnegative + CHECK (cost_price_fen >= 0 AND deposit_amount_fen >= 0); + ALTER TABLE consignment_submissions + ADD CONSTRAINT chk_consignment_submissions_amounts_nonnegative + CHECK (asking_price_fen >= 0 AND deposit_amount_fen >= 0); + END IF; +END $$; + +-- --------------------------------------------------------------------------- +-- 3. 奖励流水业务幂等键 +-- 新写入由触发器生成 event_key;历史行默认空字符串,不阻塞建索引。 +-- event_key 覆盖:工单 + 份额 + 流水类型,防止同一份额重复结算。 +-- --------------------------------------------------------------------------- +ALTER TABLE worker_wallet_ledgers + ADD COLUMN IF NOT EXISTS event_key TEXT NOT NULL DEFAULT ''; + +CREATE OR REPLACE FUNCTION worker_wallet_ledger_event_key() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.event_key = '' + AND NEW.ledger_type IN ('reward_settlement', 'sharing_reward', 'reward_pending_unfreeze') + AND NOT (NEW.payload_json ? 'inventoryId') THEN + NEW.event_key := + 'worker-reward:' || + COALESCE(NEW.worker_id::text, '0') || ':' || + COALESCE(NEW.related_work_order_id::text, '0') || ':' || + COALESCE(NULLIF(NEW.payload_json ->> 'shareId', ''), '0') || ':' || + NEW.ledger_type; + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_worker_wallet_ledger_event_key ON worker_wallet_ledgers; +CREATE TRIGGER trg_worker_wallet_ledger_event_key + BEFORE INSERT ON worker_wallet_ledgers + FOR EACH ROW EXECUTE FUNCTION worker_wallet_ledger_event_key(); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_worker_wallet_ledger_event_key + ON worker_wallet_ledgers (event_key) + WHERE event_key <> ''; + +-- 历史重复结算不删除,单独暴露给运营/财务核对。 +DROP VIEW IF EXISTS v_worker_wallet_duplicate_rewards; +CREATE VIEW v_worker_wallet_duplicate_rewards AS +SELECT + worker_id, + related_work_order_id, + ledger_type, + COALESCE(NULLIF(payload_json ->> 'shareId', ''), '0') AS share_id, + COUNT(*)::int AS ledger_count, + SUM(amount)::bigint AS total_amount, + ARRAY_AGG(id ORDER BY id) AS ledger_ids, + MIN(created_at) AS first_created_at, + MAX(created_at) AS last_created_at +FROM worker_wallet_ledgers +WHERE ledger_type IN ('reward_settlement', 'sharing_reward', 'reward_pending_unfreeze') + AND related_work_order_id IS NOT NULL + -- 排除寄卖货款:它同样用 reward_settlement,但不是工单奖励,且可能属于另一个打手。 + AND NOT (payload_json ? 'inventoryId') +GROUP BY worker_id, related_work_order_id, ledger_type, + COALESCE(NULLIF(payload_json ->> 'shareId', ''), '0') +HAVING COUNT(*) > 1; + +COMMENT ON VIEW v_worker_wallet_duplicate_rewards IS + '历史打手奖励重复结算审计视图:只读,不自动扣回;发现后由财务确认处理'; + +COMMENT ON COLUMN worker_wallet_ledgers.event_key IS + '奖励流水业务幂等键:工单+份额+流水类型;历史行留空,新行由触发器生成'; diff --git a/apps/backend/src/db/migrations/105_sales_daily_facts.sql b/apps/backend/src/db/migrations/105_sales_daily_facts.sql deleted file mode 100644 index 5e709867..00000000 --- a/apps/backend/src/db/migrations/105_sales_daily_facts.sql +++ /dev/null @@ -1,166 +0,0 @@ --- 105_sales_daily_facts.sql —— 统一履约事实 + 店铺 × 日期 × 标准商品 × 履约方式的销售事实。 --- --- 目标:订单页、成本快照、销售事实只读 order_items.fulfillment_mode, --- 不再各自用 provider / voucher / task 临时推断。 --- --- 设计要点(2026-10-23 重写): --- 1. 履约方式只有一个真源,就是 order_items.fulfillment_mode; --- 2. 商品表、成本项表、快照表都不再存载体字段,避免四处口径分叉; --- 3. 核销码只是状态,不参与履约判定; --- 4. 字段允许短暂为 NULL(订单项创建时通常还没有工单/任务), --- 由统一决策服务 + 定时兜底在 24 小时内补齐。 --- --- 销售事实口径: --- * 销售日 = orders.paid_at 的 Asia/Shanghai 日期;没有 paid_at 时退回 created_at; --- * 收入 = order_items.revenue_amount_fen(迁移 104 固化),不再直接拿整单金额; --- * 履约方式 = order_items.fulfillment_mode,四种:work_order / affiliate_dash / --- legacy_platform / manual;未判定的订单项先归 manual 并由定时任务补齐; --- * 未归一商品允许 canonical_product_id 为空,单独成「未归一」行,保证总收入对平; --- * 成本来自 cost_snapshots 当前有效行;没有快照的订单项计入 cost_missing_count; --- * 退款金额来自 order_refunds(迁移 106),本表只保存冲减后的净额。 --- --- 2026-10-25 合并:原 107(履约方式列与索引)并入本迁移。履约方式是销售事实的 --- 维度键,两者必须同一批次上线,单独成文件只会让口径分散在两处。 - --- --------------------------------------------------------------------------- --- 1. 履约方式:唯一真源 --- --------------------------------------------------------------------------- - -ALTER TABLE order_items - ADD COLUMN IF NOT EXISTS fulfillment_mode TEXT, - ADD COLUMN IF NOT EXISTS fulfillment_decided_at TIMESTAMPTZ, - ADD COLUMN IF NOT EXISTS fulfillment_reason TEXT; - -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'chk_order_items_fulfillment_mode' - ) THEN - ALTER TABLE order_items - ADD CONSTRAINT chk_order_items_fulfillment_mode - CHECK ( - fulfillment_mode IS NULL - OR fulfillment_mode IN ( - 'work_order', 'affiliate_dash', 'legacy_platform', 'manual' - ) - ); - END IF; -END $$; - -CREATE INDEX IF NOT EXISTS idx_order_items_fulfillment_mode - ON order_items (fulfillment_mode, order_id); - -CREATE INDEX IF NOT EXISTS idx_order_items_fulfillment_pending - ON order_items (order_id) - WHERE fulfillment_mode IS NULL; - --- 按履约方式筛异常时走半连接,这条索引让 order_items 侧只扫目标履约方式的行。 -CREATE INDEX IF NOT EXISTS idx_order_items_fulfillment_id - ON order_items (fulfillment_mode, id); - -COMMENT ON COLUMN order_items.fulfillment_mode IS - '履约方式:work_order 接单平台 / affiliate_dash 自动发货 / legacy_platform 历史平台(已下线)/ manual 人工;唯一真源,报表与后台只读此字段'; -COMMENT ON COLUMN order_items.fulfillment_decided_at IS - '履约方式最后一次判定时间'; -COMMENT ON COLUMN order_items.fulfillment_reason IS - '履约方式判定来源:订单同步 / 定时归集 / 历史回填等'; - --- 本迁移只做 DDL;历史行回填由 scripts/backfill-fulfillment-mode.ts 分批执行。 - --- --------------------------------------------------------------------------- --- 2. 销售事实 --- --------------------------------------------------------------------------- - -CREATE TABLE IF NOT EXISTS sales_daily_facts ( - id BIGSERIAL PRIMARY KEY, - stat_date DATE NOT NULL, - provider TEXT NOT NULL, - shop_id TEXT NOT NULL DEFAULT '', - canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE RESTRICT, - fulfillment_mode TEXT NOT NULL, - order_count INTEGER NOT NULL DEFAULT 0, - item_count INTEGER NOT NULL DEFAULT 0, - quantity NUMERIC(14, 3) NOT NULL DEFAULT 0, - gross_revenue_fen BIGINT NOT NULL DEFAULT 0, - refund_amount_fen BIGINT NOT NULL DEFAULT 0, - net_revenue_fen BIGINT NOT NULL DEFAULT 0, - cost_amount_fen BIGINT NOT NULL DEFAULT 0, - cost_missing_count INTEGER NOT NULL DEFAULT 0, - cost_stage TEXT NOT NULL DEFAULT 'missing', - gross_profit_fen BIGINT NOT NULL DEFAULT 0, - computed_at TIMESTAMPTZ NOT NULL, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL -); - --- canonical_product_id 为空时唯一索引仍要生效,因此用 COALESCE(..., 0) 作为「未归一」桶。 -CREATE UNIQUE INDEX IF NOT EXISTS uq_sales_daily_facts_key - ON sales_daily_facts ( - stat_date, - provider, - shop_id, - COALESCE(canonical_product_id, 0), - fulfillment_mode - ); -CREATE INDEX IF NOT EXISTS idx_sales_daily_facts_date - ON sales_daily_facts (stat_date DESC); -CREATE INDEX IF NOT EXISTS idx_sales_daily_facts_shop - ON sales_daily_facts (provider, shop_id, stat_date DESC); -CREATE INDEX IF NOT EXISTS idx_sales_daily_facts_product - ON sales_daily_facts (canonical_product_id, stat_date DESC); -CREATE INDEX IF NOT EXISTS idx_sales_daily_facts_mode - ON sales_daily_facts (fulfillment_mode, stat_date DESC); - -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'chk_sales_daily_facts_fulfillment_mode' - ) THEN - ALTER TABLE sales_daily_facts - ADD CONSTRAINT chk_sales_daily_facts_fulfillment_mode - CHECK (fulfillment_mode IN ( - 'work_order', 'affiliate_dash', 'legacy_platform', 'manual' - )); - END IF; - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'chk_sales_daily_facts_cost_stage' - ) THEN - ALTER TABLE sales_daily_facts - ADD CONSTRAINT chk_sales_daily_facts_cost_stage - CHECK (cost_stage IN ('missing', 'partial', 'estimated', 'actual')); - END IF; - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'chk_sales_daily_facts_amounts' - ) THEN - ALTER TABLE sales_daily_facts - ADD CONSTRAINT chk_sales_daily_facts_amounts - CHECK ( - order_count >= 0 - AND item_count >= 0 - AND quantity >= 0 - AND gross_revenue_fen >= 0 - AND refund_amount_fen >= 0 - AND net_revenue_fen >= 0 - AND cost_amount_fen >= 0 - AND cost_missing_count >= 0 - ); - END IF; - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'chk_sales_daily_facts_profit' - ) THEN - ALTER TABLE sales_daily_facts - ADD CONSTRAINT chk_sales_daily_facts_profit - CHECK (gross_profit_fen = net_revenue_fen - cost_amount_fen); - END IF; -END $$; - -COMMENT ON TABLE sales_daily_facts IS - '店铺×日期(Asia/Shanghai)×标准商品×履约方式的销售与成本事实;报表直接读这张表'; -COMMENT ON COLUMN sales_daily_facts.canonical_product_id IS - '标准商品;为空表示渠道映射未命中,报表单独展示「未归一」占比'; -COMMENT ON COLUMN sales_daily_facts.fulfillment_mode IS - '履约方式:work_order 接单平台 / affiliate_dash 自动发货 / legacy_platform 历史平台 / manual 人工'; -COMMENT ON COLUMN sales_daily_facts.cost_stage IS - 'missing=全部无成本快照;partial=部分无快照;estimated=全部预估;actual=全部实际'; -COMMENT ON COLUMN sales_daily_facts.cost_missing_count IS - '没有当前有效成本快照的订单项数;>0 时该行毛利不可直接用于决策'; diff --git a/apps/backend/src/db/migrations/109_work_order_share_participation.sql b/apps/backend/src/db/migrations/105_work_order_consignment.sql similarity index 54% rename from apps/backend/src/db/migrations/109_work_order_share_participation.sql rename to apps/backend/src/db/migrations/105_work_order_consignment.sql index 37fb4b41..44afabfe 100644 --- a/apps/backend/src/db/migrations/109_work_order_share_participation.sql +++ b/apps/backend/src/db/migrations/105_work_order_consignment.sql @@ -1,3 +1,11 @@ +-- 105_work_order_consignment.sql +-- 2026-10-27 迁移收敛:由以下补丁迁移合并,按原顺序执行,全部语句保持幂等。 + + +-- =========================================================================== +-- 109_work_order_share_participation.sql —— 拼单多次参与与份额字段 +-- =========================================================================== + -- 109_work_order_share_participation.sql -- 工单拼单份额模型:多次参与 + 份额级问题标记 + 撤单原因/份数。 -- (由原 110/111/113/114 四个增量迁移合并重写,纯新增列/索引,不改历史结构。) @@ -99,3 +107,91 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_work_order_shares_worker_request CREATE INDEX IF NOT EXISTS idx_work_order_shares_problem_worker ON work_order_shares (worker_id, work_order_id) WHERE problem_marked_at IS NOT NULL; + + +-- =========================================================================== +-- 110_work_order_hall_and_shop_indexes.sql —— 大厅/店铺查询索引 +-- =========================================================================== + +-- 110_work_order_hall_and_shop_indexes.sql +-- 工单相关热查询的部分索引(由原 103/106 两个索引迁移合并重写)。 +-- +-- 两处查询都已经有明确的 SQL 口径,这里只补与口径完全一致的索引: +-- 1. 后台接单工单「按店铺筛选」:BTRIM(o.shop_name) = ANY($n::text[]), +-- 普通 btree 无法命中表达式,必须建同表达式索引; +-- 2. 抢单大厅候选集:FIFO 窗口之外还要无条件并入全部置顶单, +-- 置顶单通常只有个位数,用「open 且 pinned_at 非空」的部分索引即可。 +-- 全部 IF NOT EXISTS,可安全重跑。 + +-- 后台「接单工单 → 店铺」筛选(可多选):表达式索引 + 部分索引省掉空店铺条目。 +CREATE INDEX IF NOT EXISTS idx_orders_shop_name_trimmed + ON orders (BTRIM(shop_name)) + WHERE BTRIM(shop_name) <> ''; + +COMMENT ON INDEX idx_orders_shop_name_trimmed IS + '支持后台接单工单按订单店铺(orders.shop_name)精确多选筛选'; + +-- 抢单大厅置顶候选集:只索引处于 open 且已置顶的工单 id,避免扫全表。 +CREATE INDEX IF NOT EXISTS idx_work_orders_hall_pinned + ON work_orders (id) + WHERE status = 'open' AND pinned_at IS NOT NULL; + +COMMENT ON INDEX idx_work_orders_hall_pinned IS + '抢单大厅置顶候选集:open 且 pinned_at 非空的工单 id'; + + +-- =========================================================================== +-- 111_work_order_share_cancel_reason_comment.sql —— 撤份额原因注释 +-- =========================================================================== + +-- 111_work_order_share_cancel_reason_comment.sql +-- 「取消部分」把被撤销的份额标记为 admin_partial,校准 cancelled_reason 的枚举注释。 +-- 纯注释变更,无结构/数据改动;重复执行无副作用。 + +COMMENT ON COLUMN work_order_shares.cancelled_reason IS + '取消来源:timeout/worker_request/admin/admin_partial/problem/settlement'; + + +-- =========================================================================== +-- 112_consignment_contact_supporter.sql —— 寄卖对接客服名单 +-- =========================================================================== + +-- 112_consignment_contact_supporter.sql +-- 原临时编号 116;108–115 已收敛为 108–111,本补丁顺延为 112。 +-- 寄卖「对接客服」独立名单:发布寄卖时必选,后台按人统计提成与快速联系。 +-- 该名单不复用 admin_users,避免后台账号变动影响历史寄卖归属。 +-- 全部语句幂等,可重复执行。 + +CREATE TABLE IF NOT EXISTS consignment_contact_supporters ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + note TEXT NOT NULL DEFAULT '', + enabled BOOLEAN NOT NULL DEFAULT TRUE, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 打手端下拉只取启用项,按 sort_order、id 排序,正好走这条索引 +CREATE INDEX IF NOT EXISTS idx_consignment_contact_supporters_order + ON consignment_contact_supporters (enabled, sort_order, id); + +ALTER TABLE consignment_submissions + ADD COLUMN IF NOT EXISTS contact_supporter_id BIGINT + REFERENCES consignment_contact_supporters(id) ON DELETE SET NULL; + +ALTER TABLE consignment_inventories + ADD COLUMN IF NOT EXISTS contact_supporter_id BIGINT + REFERENCES consignment_contact_supporters(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_consignment_submissions_contact_supporter + ON consignment_submissions (contact_supporter_id); + +CREATE INDEX IF NOT EXISTS idx_consignment_inventories_contact_supporter + ON consignment_inventories (contact_supporter_id); + +-- 首次部署给一个可用默认项:打手端「必填 + 默认选第一个」不会因名单为空而无法发布。 +-- 后台可直接改名/补充备注,不会重复插入。 +INSERT INTO consignment_contact_supporters (name, note, sort_order) +SELECT '默认客服', '系统初始化,请在「配置 - 对接客服」中修改', 0 +WHERE NOT EXISTS (SELECT 1 FROM consignment_contact_supporters); diff --git a/apps/backend/src/db/migrations/106_order_refunds.sql b/apps/backend/src/db/migrations/106_order_refunds.sql deleted file mode 100644 index 1b359506..00000000 --- a/apps/backend/src/db/migrations/106_order_refunds.sql +++ /dev/null @@ -1,38 +0,0 @@ --- 106_order_refunds.sql —— 订单退款冲减(手动录入先行,后续接平台退款 API)。 --- --- 一条记录 = 一个订单项的退款金额;整单退款会按订单项拆成多条,用 batch_no 归组。 --- 退款写入后重算当天销售事实,净收入与毛利立即冲减(见 services/finance/sales-facts-service.ts)。 --- --- 2026-10-25 合并:原 112 迁移。cost_exceptions.resolution_note 列已随迁移 104 --- 落到异常表定义里,这里只保留退款主体。 - -CREATE TABLE IF NOT EXISTS order_refunds ( - id bigserial PRIMARY KEY, - refund_no text NOT NULL UNIQUE, - batch_no text NOT NULL DEFAULT '', - order_id bigint NOT NULL REFERENCES orders(id) ON DELETE CASCADE, - order_item_id bigint NOT NULL REFERENCES order_items(id) ON DELETE CASCADE, - amount_fen bigint NOT NULL CHECK (amount_fen > 0), - reason text NOT NULL DEFAULT '', - note text NOT NULL DEFAULT '', - source text NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'api', 'platform', 'legacy')), - status text NOT NULL DEFAULT 'confirmed' CHECK (status IN ('confirmed', 'voided')), - refunded_at timestamp with time zone NOT NULL, - external_ref text NOT NULL DEFAULT '', - operator_admin_user_id bigint REFERENCES admin_users(id) ON DELETE SET NULL, - voided_at timestamp with time zone, - void_reason text NOT NULL DEFAULT '', - created_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_order_refunds_order - ON order_refunds (order_id, status); -CREATE INDEX IF NOT EXISTS idx_order_refunds_item - ON order_refunds (order_item_id, status); -CREATE INDEX IF NOT EXISTS idx_order_refunds_time - ON order_refunds (refunded_at DESC); - -COMMENT ON TABLE order_refunds IS '订单退款记录:手动录入先行,后续平台退款 API 写 source=api/platform'; -COMMENT ON COLUMN order_refunds.batch_no IS '同一次操作的分组号;整单退款会按订单项拆成多条'; -COMMENT ON COLUMN order_refunds.source IS 'manual=后台手工登记;api/platform=平台退款接口;legacy=历史数据导入'; diff --git a/apps/backend/src/db/migrations/107_manual_fulfillment.sql b/apps/backend/src/db/migrations/107_manual_fulfillment.sql deleted file mode 100644 index b990528d..00000000 --- a/apps/backend/src/db/migrations/107_manual_fulfillment.sql +++ /dev/null @@ -1,292 +0,0 @@ --- 107_manual_fulfillment.sql —— 客服人工履约:SKU 配置清单 + 三平台积分钱包 + 备用金编号。 --- --- 背景(2026-10-24 确认): --- 客服人工履约有四个资金/额度钱包,每个客服各一个: --- 南航积分 / 乐玩积分 / ROCK 积分 / 备用金现金 --- 三个积分平台的积分**互不通用**,所以每个平台一套账户。 --- 换算率按平台不同,真源是履约档案 config_json.rechargeUnitPriceFen: --- 南航 1 积分 = 1 分钱;乐玩 1 积分 = 1 分钱;ROCK 1 积分 = 1 元(100 分)。 --- --- 配置链路(无匹配引擎、无打分): --- 1. 扫描「走到人工履约」的内部 SKU,登记到 manual_sku_registry; --- 2. 运营在成本中心配置每个 SKU 的「履约渠道 + 消耗积分 + 成本模式」; --- 3. 点「重新匹配」把配置回填到历史人工订单项; --- 4. 客服确认履约时直接读配置,固定成本零输入。 --- --- 关键口径: --- * 配置主键是**内部 SKU**(快手 skuNick / 拼多多 outSkuSn),不是商品标题; --- * 同名 SKU 算同一个商品,配置全局唯一(跨渠道共用); --- * 积分 0 = 成本不固定,由客服在确认履约时手填实际成本; --- * 备用金现金不建账户行,走现有 fund_accounts / fund_entries。 --- --- 2026-10-25 合并:原 106(备用金账户编号可读化)并入本迁移。编号规则与人工履约 --- 共用同一套钱包语义,单独成文件会让「资金台账」散落在两个迁移里。 - --- --------------------------------------------------------------------------- --- 1. 人工 SKU 登记表:扫描到的「走到人工履约」的内部 SKU --- --------------------------------------------------------------------------- - -CREATE TABLE IF NOT EXISTS manual_sku_registry ( - id BIGSERIAL PRIMARY KEY, - sku_key TEXT NOT NULL, - display_name TEXT NOT NULL DEFAULT '', - shop_count INTEGER NOT NULL DEFAULT 0, - order_count INTEGER NOT NULL DEFAULT 0, - item_count INTEGER NOT NULL DEFAULT 0, - revenue_fen BIGINT NOT NULL DEFAULT 0, - first_seen_date DATE, - last_seen_date DATE, - scan_from_date DATE, - scan_to_date DATE, - config_id BIGINT, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL -); - -CREATE UNIQUE INDEX IF NOT EXISTS uq_manual_sku_registry_sku_key - ON manual_sku_registry (sku_key); -CREATE INDEX IF NOT EXISTS idx_manual_sku_registry_config - ON manual_sku_registry (config_id); -CREATE INDEX IF NOT EXISTS idx_manual_sku_registry_item_count - ON manual_sku_registry (item_count DESC); - -COMMENT ON TABLE manual_sku_registry IS - '人工履约 SKU 登记表:扫描到的内部 SKU(快手 skuNick / 拼多多 outSkuSn),供运营集中配置'; -COMMENT ON COLUMN manual_sku_registry.sku_key IS - '内部 SKU,配置主键;同名 SKU 视为同一商品,跨渠道共用一份配置'; -COMMENT ON COLUMN manual_sku_registry.display_name IS - '商品名称,仅用于展示识别,不是配置键'; - --- --------------------------------------------------------------------------- --- 2. 人工履约配置:每个内部 SKU 的渠道、积分与成本模式 --- --------------------------------------------------------------------------- - -CREATE TABLE IF NOT EXISTS manual_fulfillment_configs ( - id BIGSERIAL PRIMARY KEY, - sku_key TEXT NOT NULL, - canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE SET NULL, - platform_key TEXT NOT NULL, - credit_quantity BIGINT NOT NULL DEFAULT 0, - cost_mode TEXT NOT NULL DEFAULT 'fixed', - enabled BOOLEAN NOT NULL DEFAULT TRUE, - note TEXT NOT NULL DEFAULT '', - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL -); - -CREATE UNIQUE INDEX IF NOT EXISTS uq_manual_fulfillment_configs_sku_key - ON manual_fulfillment_configs (sku_key); -CREATE INDEX IF NOT EXISTS idx_manual_fulfillment_configs_platform - ON manual_fulfillment_configs (platform_key); - -COMMENT ON TABLE manual_fulfillment_configs IS - '人工履约配置:内部 SKU → 履约渠道 + 消耗积分 + 成本模式'; -COMMENT ON COLUMN manual_fulfillment_configs.credit_quantity IS - '每单消耗积分(整数)。换算率按平台配置;0 表示成本不固定,客服确认履约时手填'; -COMMENT ON COLUMN manual_fulfillment_configs.cost_mode IS - 'fixed=成本按积分固定(cost_fen = credit_quantity × 平台换算率);variable=成本不固定,客服确认履约时手填'; - -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_fulfillment_configs_platform' - ) THEN - ALTER TABLE manual_fulfillment_configs - ADD CONSTRAINT chk_manual_fulfillment_configs_platform - CHECK (platform_key IN ('nanhang', 'lewan', 'rock', 'fund_cash')); - END IF; - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_fulfillment_configs_cost_mode' - ) THEN - ALTER TABLE manual_fulfillment_configs - ADD CONSTRAINT chk_manual_fulfillment_configs_cost_mode - CHECK (cost_mode IN ('fixed', 'variable')); - END IF; - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_fulfillment_configs_credit' - ) THEN - ALTER TABLE manual_fulfillment_configs - ADD CONSTRAINT chk_manual_fulfillment_configs_credit - CHECK (credit_quantity >= 0); - END IF; -END $$; - --- --------------------------------------------------------------------------- --- 3. 三平台积分钱包:每个客服在每个积分平台一个账户 --- --------------------------------------------------------------------------- - -CREATE TABLE IF NOT EXISTS platform_credit_accounts ( - id BIGSERIAL PRIMARY KEY, - platform_key TEXT NOT NULL, - owner_admin_user_id BIGINT NOT NULL REFERENCES admin_users(id) ON DELETE RESTRICT, - balance_quantity BIGINT NOT NULL DEFAULT 0, - unit_name TEXT NOT NULL DEFAULT '积分', - recharge_unit_price_fen BIGINT NOT NULL DEFAULT 1, - status TEXT NOT NULL DEFAULT 'active', - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL -); - -CREATE UNIQUE INDEX IF NOT EXISTS uq_platform_credit_accounts_owner_platform - ON platform_credit_accounts (owner_admin_user_id, platform_key); -CREATE INDEX IF NOT EXISTS idx_platform_credit_accounts_platform_status - ON platform_credit_accounts (platform_key, status); - -COMMENT ON TABLE platform_credit_accounts IS - '客服平台积分钱包:南航/乐玩/ROCK 三平台积分互不通用,每平台每客服一个账户;换算率按平台配置(南航/乐玩 1 积分 = 1 分,ROCK 1 积分 = 1 元)'; -COMMENT ON COLUMN platform_credit_accounts.recharge_unit_price_fen IS - '积分充值单价(分):1 积分 = ? 分。按平台不同,默认取档案 config_json.rechargeUnitPriceFen'; - -CREATE TABLE IF NOT EXISTS platform_credit_entries ( - id BIGSERIAL PRIMARY KEY, - account_id BIGINT NOT NULL REFERENCES platform_credit_accounts(id) ON DELETE RESTRICT, - entry_type TEXT NOT NULL, - quantity_delta BIGINT NOT NULL, - balance_after BIGINT NOT NULL, - amount_fen BIGINT NOT NULL DEFAULT 0, - platform_ref TEXT NOT NULL DEFAULT '', - related_task_id BIGINT, - note TEXT NOT NULL DEFAULT '', - created_at TIMESTAMPTZ NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_platform_credit_entries_account_time - ON platform_credit_entries (account_id, created_at DESC, id DESC); -CREATE INDEX IF NOT EXISTS idx_platform_credit_entries_type_time - ON platform_credit_entries (entry_type, created_at DESC); -CREATE INDEX IF NOT EXISTS idx_platform_credit_entries_task - ON platform_credit_entries (related_task_id) - WHERE related_task_id IS NOT NULL; - -COMMENT ON TABLE platform_credit_entries IS - '平台积分流水:充值/消耗/调整/退回,只追加,不修改不删除'; -COMMENT ON COLUMN platform_credit_entries.amount_fen IS - '对应人民币金额(分)= quantity_delta × recharge_unit_price_fen'; - -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'chk_platform_credit_accounts_platform' - ) THEN - ALTER TABLE platform_credit_accounts - ADD CONSTRAINT chk_platform_credit_accounts_platform - CHECK (platform_key IN ('nanhang', 'lewan', 'rock')); - END IF; - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'chk_platform_credit_accounts_status' - ) THEN - ALTER TABLE platform_credit_accounts - ADD CONSTRAINT chk_platform_credit_accounts_status - CHECK (status IN ('active', 'frozen', 'closed')); - END IF; - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'chk_platform_credit_accounts_balance' - ) THEN - ALTER TABLE platform_credit_accounts - ADD CONSTRAINT chk_platform_credit_accounts_balance - CHECK (balance_quantity >= 0); - END IF; - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'chk_platform_credit_entries_type' - ) THEN - ALTER TABLE platform_credit_entries - ADD CONSTRAINT chk_platform_credit_entries_type - CHECK (entry_type IN ('grant', 'consume', 'adjust', 'return')); - END IF; - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'chk_platform_credit_entries_delta' - ) THEN - ALTER TABLE platform_credit_entries - ADD CONSTRAINT chk_platform_credit_entries_delta - CHECK (quantity_delta <> 0); - END IF; -END $$; - --- --------------------------------------------------------------------------- --- 4. 订单项挂配置:客服确认履约时直接读这里 --- --------------------------------------------------------------------------- - -ALTER TABLE order_items - ADD COLUMN IF NOT EXISTS fulfillment_config_id BIGINT - REFERENCES manual_fulfillment_configs(id) ON DELETE SET NULL; - -CREATE INDEX IF NOT EXISTS idx_order_items_fulfillment_config - ON order_items (fulfillment_config_id) - WHERE fulfillment_config_id IS NOT NULL; - --- 内部 SKU 表达式索引:人工 SKU 扫描与「重新匹配」回填都靠它。 --- 没有这个索引时两处都是全表扫(实测扫描 2.4s / 回填 5.4s), --- 建索引后回填降到 1.7s。 -CREATE INDEX IF NOT EXISTS idx_order_items_manual_internal_sku - ON order_items (COALESCE(NULLIF(spec_json->>'skuNick', ''), NULLIF(spec_json->>'outSkuSn', ''))) - WHERE fulfillment_mode = 'manual'; - -COMMENT ON COLUMN order_items.fulfillment_config_id IS - '人工履约配置(人工渠道+消耗积分+成本模式);由「重新匹配」回填'; - --- --------------------------------------------------------------------------- --- 5. 四个客服钱包的履约档案 --- --------------------------------------------------------------------------- --- 三个积分平台 + 备用金现金。executor_key 统一 manual_dispatch, --- 靠 config_json.platformKey 区分平台;**不复用** kuaishou_ct_assisted / --- kuaishou_feifei(那是已下线的历史自动发货通道)。 - -INSERT INTO fulfillment_profiles ( - profile_key, name, executor_key, requires_claim, auto_dispatch, - inventory_strategy, config_json, created_at, updated_at -) -VALUES - ('manual_nanhang', '南航平台人工', 'manual_dispatch', FALSE, FALSE, 'external_platform', - '{"platformKey":"nanhang","platformName":"南航平台","unitName":"积分","creditManaged":true,"cashAdapter":false,"rechargeUnitPriceFen":1}'::jsonb, - NOW(), NOW()), - ('manual_lewan', '乐玩后台人工', 'manual_dispatch', FALSE, FALSE, 'external_platform', - '{"platformKey":"lewan","platformName":"乐玩后台","unitName":"积分","creditManaged":true,"cashAdapter":false,"rechargeUnitPriceFen":1}'::jsonb, - NOW(), NOW()), - ('manual_rock', 'ROCK 后台人工', 'manual_dispatch', FALSE, FALSE, 'external_platform', - '{"platformKey":"rock","platformName":"ROCK 后台","unitName":"积分","creditManaged":true,"cashAdapter":false,"rechargeUnitPriceFen":100}'::jsonb, - NOW(), NOW()), - ('manual_fund', '备用金现金人工', 'manual_dispatch', FALSE, FALSE, 'external_platform', - '{"platformKey":"fund_cash","platformName":"备用金现金","unitName":"元","creditManaged":false,"cashAdapter":true,"rechargeUnitPriceFen":100}'::jsonb, - NOW(), NOW()) -ON CONFLICT (profile_key) DO UPDATE -SET name = EXCLUDED.name, - config_json = EXCLUDED.config_json, - updated_at = NOW(); - --- --------------------------------------------------------------------------- --- 6. 备用金账户编号可读化 --- --------------------------------------------------------------------------- --- 历史编号是 FA + 10 位随机十六进制(如 FA7683D6B58B),在「备用金」列表里 --- 无法一眼对应到具体客服。新规则:FA-<大写用户名>,重名追加 -2、-3。 --- --- 幂等:编号由 admin_users.username 重新推导,与当前 account_no 无关, --- 重复执行结果一致。 - -WITH base AS ( - SELECT - fa.id, - 'FA-' || COALESCE( - NULLIF( - trim( - both '_' FROM upper( - regexp_replace(COALESCE(NULLIF(au.username, ''), 'account-' || fa.id), '[^A-Za-z0-9_]+', '_', 'g') - ) - ), - '' - ), - 'ACCOUNT-' || fa.id - ) AS base_no - FROM fund_accounts fa - LEFT JOIN admin_users au ON au.id = fa.owner_admin_user_id -), -numbered AS ( - SELECT - id, - base_no, - row_number() OVER (PARTITION BY base_no ORDER BY id) AS rn - FROM base -) -UPDATE fund_accounts fa -SET account_no = CASE WHEN n.rn = 1 THEN n.base_no ELSE n.base_no || '-' || n.rn END -FROM numbered n -WHERE fa.id = n.id; diff --git a/apps/backend/src/db/migrations/108_finance_cost_integrity.sql b/apps/backend/src/db/migrations/108_finance_cost_integrity.sql deleted file mode 100644 index 3686e857..00000000 --- a/apps/backend/src/db/migrations/108_finance_cost_integrity.sql +++ /dev/null @@ -1,301 +0,0 @@ --- 108_finance_cost_integrity.sql --- 财务/成本完整性与影子总账(合并原 108/111/112/113/114)。 --- --- 合并原因:2026-10-26 之前这几块是分批补丁,生产基线 101 升级时会产生 --- 5 个只差索引/约束/注释的迁移,顺序和回滚都难维护。合并后: --- 1. 成本异常归档表 --- 2. 人工成本订单项级唯一与 subject 语义 --- 3. 影子总账/期间/对账 --- 4. 商品页/模板预览查询索引 --- 全部语句保持幂等;新库从 101 升级与空库初始化同一路径。 - - --- --------------------------------------------------------------------------- --- 成本异常归档(原 108_cost_exception_archive.sql) --- --------------------------------------------------------------------------- -CREATE TABLE IF NOT EXISTS cost_exceptions_archive ( - id BIGINT PRIMARY KEY, - subject_type TEXT NOT NULL, - subject_id BIGINT NOT NULL, - order_item_id BIGINT, - work_order_id BIGINT, - reason TEXT NOT NULL, - status TEXT NOT NULL, - detail_json JSONB NOT NULL DEFAULT '{}'::jsonb, - created_at TIMESTAMPTZ NOT NULL, - resolved_at TIMESTAMPTZ, - resolved_by_admin_user_id BIGINT, - resolution_note TEXT NOT NULL DEFAULT '', - archived_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_cost_exceptions_archive_archived_at - ON cost_exceptions_archive (archived_at DESC); -CREATE INDEX IF NOT EXISTS idx_cost_exceptions_archive_subject - ON cost_exceptions_archive (subject_type, subject_id); -CREATE INDEX IF NOT EXISTS idx_cost_exceptions_archive_reason - ON cost_exceptions_archive (reason, archived_at DESC); - -COMMENT ON TABLE cost_exceptions_archive IS - '已处理成本异常的归档:data-retention 任务把超过保留期(默认 60 天)的 resolved/ignored 行搬到这里,open 行不归档'; -COMMENT ON COLUMN cost_exceptions_archive.archived_at IS - '归档时间;原表 created_at / resolved_at 原样保留,便于按原时间追溯'; - --- 热表上补一个「待归档」用的部分索引,避免每日任务扫全表。 -CREATE INDEX IF NOT EXISTS idx_cost_exceptions_archivable - ON cost_exceptions (COALESCE(resolved_at, created_at)) - WHERE status <> 'open'; - - --- --------------------------------------------------------------------------- --- 人工成本完整性(原 111_manual_cost_integrity.sql) --- --------------------------------------------------------------------------- -UPDATE cost_snapshots -SET subject_id = order_item_id -WHERE subject_type = 'manual_case' - AND order_item_id IS NOT NULL - AND subject_id > 0 - AND subject_id <> order_item_id; - --- 2. 同一订单项存在重复有效人工快照时,只保留最新一条(历史脏数据兜底)。 -UPDATE cost_snapshots c -SET superseded_at = NOW() -WHERE c.subject_type = 'manual_case' - AND c.superseded_at IS NULL - AND c.subject_id > 0 - AND EXISTS ( - SELECT 1 - FROM cost_snapshots newer - WHERE newer.subject_type = 'manual_case' - AND newer.order_item_id = c.order_item_id - AND newer.superseded_at IS NULL - AND newer.id > c.id - ); - --- 3. 数据库级唯一:一个订单项只有一条有效人工成本快照。 -CREATE UNIQUE INDEX IF NOT EXISTS uq_cost_snapshots_manual_order_item - ON cost_snapshots (order_item_id) - WHERE subject_type = 'manual_case' AND superseded_at IS NULL AND subject_id > 0; - --- 4. 语义约束:新写入的 manual_case 必须 subject_id = order_item_id。 --- NOT VALID 只约束新写入,负数历史孤儿快照(迁移 104 约定)保持原样。 -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_snapshots_manual_subject' - ) THEN - ALTER TABLE cost_snapshots - ADD CONSTRAINT chk_cost_snapshots_manual_subject - CHECK (subject_type <> 'manual_case' OR subject_id = order_item_id) - NOT VALID; - END IF; -END $$; - --- 5. 人工成本监控 / 对账按订单项反查备用金支出。 -CREATE INDEX IF NOT EXISTS idx_manual_expenses_order_item - ON manual_expenses (order_item_id) - WHERE order_item_id IS NOT NULL; - --- 6. 人工 SKU 匹配键索引:spec_json.skuNick -> outSkuSn -> sku_code -> sku_name。 --- 旧索引只覆盖前两级,缺 SKU 键的人工单会走顺序扫描;这里替换为同一套兜底表达式。 -DROP INDEX IF EXISTS idx_order_items_manual_internal_sku; -CREATE INDEX IF NOT EXISTS idx_order_items_manual_sku_key - ON order_items ( - COALESCE( - NULLIF(spec_json->>'skuNick', ''), - NULLIF(spec_json->>'outSkuSn', ''), - NULLIF(sku_code, ''), - NULLIF(sku_name, '') - ) - ) - WHERE fulfillment_mode = 'manual'; - -COMMENT ON INDEX uq_cost_snapshots_manual_order_item IS - '人工成本完整性:一个订单项最多一条有效 manual_case 快照(并发重复确认的数据库兜底)'; - - --- --------------------------------------------------------------------------- --- 影子总账(原 112_finance_ledger.sql) --- --------------------------------------------------------------------------- -CREATE TABLE IF NOT EXISTS finance_accounts ( - account_code TEXT PRIMARY KEY, - account_name TEXT NOT NULL, - account_type TEXT NOT NULL - CHECK (account_type IN ('asset', 'liability', 'equity', 'revenue', 'expense')), - normal_side TEXT NOT NULL CHECK (normal_side IN ('debit', 'credit')), - parent_code TEXT REFERENCES finance_accounts(account_code) ON DELETE RESTRICT, - status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')), - description TEXT NOT NULL DEFAULT '', - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL -); - -CREATE TABLE IF NOT EXISTS finance_journal_entries ( - id BIGSERIAL PRIMARY KEY, - entry_no TEXT NOT NULL UNIQUE, - event_type TEXT NOT NULL, - ref_type TEXT NOT NULL, - ref_id TEXT NOT NULL, - occurred_at TIMESTAMPTZ NOT NULL, - period_key TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'posted' CHECK (status IN ('posted', 'reversed')), - memo TEXT NOT NULL DEFAULT '', - metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL, - UNIQUE (event_type, ref_type, ref_id) -); - -CREATE INDEX IF NOT EXISTS idx_finance_entries_period - ON finance_journal_entries (period_key, occurred_at DESC); -CREATE INDEX IF NOT EXISTS idx_finance_entries_ref - ON finance_journal_entries (ref_type, ref_id); - -CREATE TABLE IF NOT EXISTS finance_journal_lines ( - id BIGSERIAL PRIMARY KEY, - entry_id BIGINT NOT NULL REFERENCES finance_journal_entries(id) ON DELETE CASCADE, - line_no INTEGER NOT NULL, - account_code TEXT NOT NULL REFERENCES finance_accounts(account_code) ON DELETE RESTRICT, - debit_fen BIGINT NOT NULL DEFAULT 0, - credit_fen BIGINT NOT NULL DEFAULT 0, - memo TEXT NOT NULL DEFAULT '', - dimensions_json JSONB NOT NULL DEFAULT '{}'::jsonb, - created_at TIMESTAMPTZ NOT NULL, - CHECK (debit_fen >= 0 AND credit_fen >= 0), - CHECK ((debit_fen > 0) <> (credit_fen > 0)), - UNIQUE (entry_id, line_no) -); - -CREATE INDEX IF NOT EXISTS idx_finance_lines_account - ON finance_journal_lines (account_code); - -CREATE TABLE IF NOT EXISTS finance_periods ( - period_key TEXT PRIMARY KEY, - starts_at TIMESTAMPTZ NOT NULL, - ends_at TIMESTAMPTZ NOT NULL, - status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'closed')), - summary_json JSONB NOT NULL DEFAULT '{}'::jsonb, - closed_at TIMESTAMPTZ, - closed_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL -); - -CREATE TABLE IF NOT EXISTS finance_reconciliations ( - id BIGSERIAL PRIMARY KEY, - provider TEXT NOT NULL, - period_from DATE NOT NULL, - period_to DATE NOT NULL, - source TEXT NOT NULL DEFAULT 'manual_import', - status TEXT NOT NULL DEFAULT 'imported' - CHECK (status IN ('imported', 'matched', 'differences', 'resolved')), - expected_fen BIGINT NOT NULL DEFAULT 0, - actual_fen BIGINT NOT NULL DEFAULT 0, - diff_fen BIGINT NOT NULL DEFAULT 0, - summary_json JSONB NOT NULL DEFAULT '{}'::jsonb, - imported_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL, - resolved_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_finance_reconciliations_period - ON finance_reconciliations (provider, period_from DESC, id DESC); - -CREATE TABLE IF NOT EXISTS finance_reconciliation_items ( - id BIGSERIAL PRIMARY KEY, - reconciliation_id BIGINT NOT NULL - REFERENCES finance_reconciliations(id) ON DELETE CASCADE, - line_no INTEGER NOT NULL, - provider TEXT NOT NULL, - platform_order_id TEXT NOT NULL DEFAULT '', - external_ref TEXT NOT NULL DEFAULT '', - entry_type TEXT NOT NULL CHECK (entry_type IN ('sale', 'refund')), - amount_fen BIGINT NOT NULL, - occurred_at TIMESTAMPTZ, - matched_order_id BIGINT, - matched_refund_id BIGINT, - expected_fen BIGINT, - diff_fen BIGINT, - match_status TEXT NOT NULL - CHECK (match_status IN ( - 'matched', 'amount_mismatch', 'missing_order', 'missing_refund', 'duplicate' - )), - detail_json JSONB NOT NULL DEFAULT '{}'::jsonb, - created_at TIMESTAMPTZ NOT NULL, - UNIQUE (reconciliation_id, line_no) -); - -CREATE INDEX IF NOT EXISTS idx_finance_reconciliation_items_status - ON finance_reconciliation_items (reconciliation_id, match_status); - --- 科目表(可按业务裁剪;借贷方向先定死) -INSERT INTO finance_accounts ( - account_code, account_name, account_type, normal_side, description, created_at, updated_at -) -VALUES - ('1001', '银行存款', 'asset', 'debit', '公司银行/支付通道资金', NOW(), NOW()), - ('1002', '客服备用金', 'asset', 'debit', '已拨付给客服的备用金', NOW(), NOW()), - ('1101', '应收账款-平台', 'asset', 'debit', '平台待结算/待收款', NOW(), NOW()), - ('1201', '预付账款-客服积分', 'asset', 'debit', '已充值到平台的客服积分', NOW(), NOW()), - ('2001', '应付履约款', 'liability', 'credit', '应付打手/人工履约成本', NOW(), NOW()), - ('2005', '其他应付-垫付人', 'liability', 'credit', '客服垫付待核销', NOW(), NOW()), - ('3001', '投入资金/权益', 'equity', 'credit', '股东投入与期初权益', NOW(), NOW()), - ('4001', '商品销售收入', 'revenue', 'credit', '按用户实付确认的收入', NOW(), NOW()), - ('4101', '退款冲减', 'revenue', 'debit', '退款冲减收入(借方)', NOW(), NOW()), - ('5001', '履约成本', 'expense', 'debit', '打手报酬/人工/自动发货成本', NOW(), NOW()), - ('5101', '人工履约成本', 'expense', 'debit', '人工客服实际垫付成本', NOW(), NOW()) -ON CONFLICT (account_code) DO UPDATE -SET account_name = EXCLUDED.account_name, - account_type = EXCLUDED.account_type, - normal_side = EXCLUDED.normal_side, - description = EXCLUDED.description, - updated_at = NOW(); - -COMMENT ON TABLE finance_accounts IS '会计科目表;影子总账使用,不接管业务钱包余额'; -COMMENT ON TABLE finance_journal_entries IS '凭证头;event_type + ref_type + ref_id 保证业务幂等'; -COMMENT ON TABLE finance_journal_lines IS '凭证行;每行只能借或贷,单张凭证借贷必须平衡'; -COMMENT ON TABLE finance_periods IS '会计期间;closed 期间禁止新增凭证'; -COMMENT ON TABLE finance_reconciliations IS '平台对账批次:导入平台账单后与订单/退款匹配'; -COMMENT ON TABLE finance_reconciliation_items IS '对账明细:matched / amount_mismatch / missing_* / duplicate'; - - --- --------------------------------------------------------------------------- --- 成本中心查询索引(原 113_cost_center_query_indexes.sql) --- --------------------------------------------------------------------------- -CREATE INDEX IF NOT EXISTS idx_order_items_unmapped_mode - ON order_items (fulfillment_mode) - WHERE canonical_product_id IS NULL; - -CREATE INDEX IF NOT EXISTS idx_fulfillment_tasks_affiliate_recent - ON fulfillment_tasks (created_at) - WHERE executor_key = 'affiliate_dash'; - -COMMENT ON INDEX idx_order_items_unmapped_mode IS - '成本中心总览:按履约方式统计未归一订单项,避免全表扫 order_items'; -COMMENT ON INDEX idx_fulfillment_tasks_affiliate_recent IS - '自动发货任务 30 天聚合:只索引 affiliate_dash,减少商品列表聚合扫描'; - --- 新索引建好后立刻更新统计信息,避免参数化查询在旧统计下继续选全表扫描。 -ANALYZE order_items; -ANALYZE product_catalog; -ANALYZE cost_exceptions; -ANALYZE fulfillment_tasks; - - --- --------------------------------------------------------------------------- --- 模板证据索引(原 114_template_evidence_indexes.sql) --- --------------------------------------------------------------------------- -CREATE INDEX IF NOT EXISTS idx_orders_basis_date - ON orders (((COALESCE(paid_at, created_at) AT TIME ZONE 'Asia/Shanghai')::date)); - -CREATE INDEX IF NOT EXISTS idx_order_items_order_sku_cover - ON order_items (order_id) INCLUDE (id, sku_code, sku_name); - -COMMENT ON INDEX idx_orders_basis_date IS - '模板初始化/时间筛选:按 Asia/Shanghai 订单日期过滤,避免全表扫 orders'; -COMMENT ON INDEX idx_order_items_order_sku_cover IS - '模板初始化的 SKU 证据覆盖索引,避免回表取 sku_code/sku_name'; - -ANALYZE orders; -ANALYZE order_items; diff --git a/apps/backend/src/db/migrations/110_work_order_hall_and_shop_indexes.sql b/apps/backend/src/db/migrations/110_work_order_hall_and_shop_indexes.sql deleted file mode 100644 index 6a617ffa..00000000 --- a/apps/backend/src/db/migrations/110_work_order_hall_and_shop_indexes.sql +++ /dev/null @@ -1,25 +0,0 @@ --- 110_work_order_hall_and_shop_indexes.sql --- 工单相关热查询的部分索引(由原 103/106 两个索引迁移合并重写)。 --- --- 两处查询都已经有明确的 SQL 口径,这里只补与口径完全一致的索引: --- 1. 后台接单工单「按店铺筛选」:BTRIM(o.shop_name) = ANY($n::text[]), --- 普通 btree 无法命中表达式,必须建同表达式索引; --- 2. 抢单大厅候选集:FIFO 窗口之外还要无条件并入全部置顶单, --- 置顶单通常只有个位数,用「open 且 pinned_at 非空」的部分索引即可。 --- 全部 IF NOT EXISTS,可安全重跑。 - --- 后台「接单工单 → 店铺」筛选(可多选):表达式索引 + 部分索引省掉空店铺条目。 -CREATE INDEX IF NOT EXISTS idx_orders_shop_name_trimmed - ON orders (BTRIM(shop_name)) - WHERE BTRIM(shop_name) <> ''; - -COMMENT ON INDEX idx_orders_shop_name_trimmed IS - '支持后台接单工单按订单店铺(orders.shop_name)精确多选筛选'; - --- 抢单大厅置顶候选集:只索引处于 open 且已置顶的工单 id,避免扫全表。 -CREATE INDEX IF NOT EXISTS idx_work_orders_hall_pinned - ON work_orders (id) - WHERE status = 'open' AND pinned_at IS NOT NULL; - -COMMENT ON INDEX idx_work_orders_hall_pinned IS - '抢单大厅置顶候选集:open 且 pinned_at 非空的工单 id'; diff --git a/apps/backend/src/db/migrations/111_work_order_share_cancel_reason_comment.sql b/apps/backend/src/db/migrations/111_work_order_share_cancel_reason_comment.sql deleted file mode 100644 index 274f5e29..00000000 --- a/apps/backend/src/db/migrations/111_work_order_share_cancel_reason_comment.sql +++ /dev/null @@ -1,6 +0,0 @@ --- 111_work_order_share_cancel_reason_comment.sql --- 「取消部分」把被撤销的份额标记为 admin_partial,校准 cancelled_reason 的枚举注释。 --- 纯注释变更,无结构/数据改动;重复执行无副作用。 - -COMMENT ON COLUMN work_order_shares.cancelled_reason IS - '取消来源:timeout/worker_request/admin/admin_partial/problem/settlement'; diff --git a/apps/backend/src/db/migrations/112_consignment_contact_supporter.sql b/apps/backend/src/db/migrations/112_consignment_contact_supporter.sql deleted file mode 100644 index 13f89da6..00000000 --- a/apps/backend/src/db/migrations/112_consignment_contact_supporter.sql +++ /dev/null @@ -1,39 +0,0 @@ --- 112_consignment_contact_supporter.sql --- 原临时编号 116;108–115 已收敛为 108–111,本补丁顺延为 112。 --- 寄卖「对接客服」独立名单:发布寄卖时必选,后台按人统计提成与快速联系。 --- 该名单不复用 admin_users,避免后台账号变动影响历史寄卖归属。 --- 全部语句幂等,可重复执行。 - -CREATE TABLE IF NOT EXISTS consignment_contact_supporters ( - id BIGSERIAL PRIMARY KEY, - name TEXT NOT NULL, - note TEXT NOT NULL DEFAULT '', - enabled BOOLEAN NOT NULL DEFAULT TRUE, - sort_order INTEGER NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- 打手端下拉只取启用项,按 sort_order、id 排序,正好走这条索引 -CREATE INDEX IF NOT EXISTS idx_consignment_contact_supporters_order - ON consignment_contact_supporters (enabled, sort_order, id); - -ALTER TABLE consignment_submissions - ADD COLUMN IF NOT EXISTS contact_supporter_id BIGINT - REFERENCES consignment_contact_supporters(id) ON DELETE SET NULL; - -ALTER TABLE consignment_inventories - ADD COLUMN IF NOT EXISTS contact_supporter_id BIGINT - REFERENCES consignment_contact_supporters(id) ON DELETE SET NULL; - -CREATE INDEX IF NOT EXISTS idx_consignment_submissions_contact_supporter - ON consignment_submissions (contact_supporter_id); - -CREATE INDEX IF NOT EXISTS idx_consignment_inventories_contact_supporter - ON consignment_inventories (contact_supporter_id); - --- 首次部署给一个可用默认项:打手端「必填 + 默认选第一个」不会因名单为空而无法发布。 --- 后台可直接改名/补充备注,不会重复插入。 -INSERT INTO consignment_contact_supporters (name, note, sort_order) -SELECT '默认客服', '系统初始化,请在「配置 - 对接客服」中修改', 0 -WHERE NOT EXISTS (SELECT 1 FROM consignment_contact_supporters); diff --git a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts index 3b2367ab..0ae53068 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts @@ -1444,7 +1444,7 @@ export function buildWorkOrderWhere({ if (normalizedShopNames.length > 0) { // 店铺口径是「订单归属店铺」:工单只存 order_id,必须回查 orders.shop_name。 // 下拉选项本身就是真实值,这里做精确匹配,并用 BTRIM 兼容历史脏数据的前后空格 - // (表达式索引见 110_work_order_hall_and_shop_indexes.sql)。 + // (表达式索引见 105_work_order_consignment.sql)。 params.push(normalizedShopNames) filters.push(`wo.order_id IN ( SELECT shop_order.id diff --git a/apps/backend/src/routes/admin/finance-ledger.ts b/apps/backend/src/routes/admin/finance-ledger.ts index cb74fd82..03db5dba 100644 --- a/apps/backend/src/routes/admin/finance-ledger.ts +++ b/apps/backend/src/routes/admin/finance-ledger.ts @@ -22,6 +22,7 @@ import { resolveFinanceReconciliation, } from '../../services/finance/finance-reconciliation-service.js' import type { JsonObject } from '../../types/json.js' +import { formatShanghaiDate } from '../../utils/finance-date-range.js' import { bodyOf, idOf, sessionOf } from './request-helpers.js' import { createJsonHandler, requireAdminPermission, requireAdminRoles } from './session.js' @@ -45,7 +46,7 @@ router.get( createJsonHandler( (req) => { const query = req.query as Record - const today = new Date().toISOString().slice(0, 10) + const today = formatShanghaiDate(new Date()) return getTrialBalance({ dateFrom: String(query.dateFrom || query.date_from || `${today.slice(0, 7)}-01`), dateTo: String(query.dateTo || query.date_to || today), @@ -71,11 +72,11 @@ router.get( router.post( '/finance/ledger/sync', - requireAdminPermission('finance.config'), + requireAdminPermission('finance.post'), createJsonHandler( (req) => { const body = bodyOf(req) - const today = new Date().toISOString().slice(0, 10) + const today = formatShanghaiDate(new Date()) return syncFinanceLedgerFromFacts({ dateFrom: String(body.dateFrom || body.date_from || `${today.slice(0, 7)}-01`), dateTo: String(body.dateTo || body.date_to || today), @@ -97,7 +98,7 @@ router.post( router.post( '/finance/ledger/manual-voucher', - requireAdminPermission('finance.config'), + requireAdminPermission('finance.post'), createJsonHandler((req) => postManualVoucher(bodyOf(req) as never, sessionOf(req)), { successMessage: '凭证已创建', errorMessage: '创建凭证失败', @@ -123,13 +124,16 @@ router.get( router.post( '/finance/periods/:periodKey/close', - requireAdminPermission('finance.config'), + requireAdminPermission('finance.close'), createJsonHandler( - (req) => - closeFinancePeriod( + (req) => { + const body = bodyOf(req) + return closeFinancePeriod( String((req.params as Record).periodKey || ''), sessionOf(req), - ), + String(body.reason || ''), + ) + }, { successMessage: '期间已关账', errorMessage: '关账失败', @@ -146,7 +150,7 @@ router.post( router.post( '/finance/periods/:periodKey/reopen', - requireAdminPermission('finance.config'), + requireAdminPermission('finance.close'), createJsonHandler( (req) => reopenFinancePeriod(String((req.params as Record).periodKey || '')), { @@ -175,7 +179,7 @@ router.get( router.post( '/finance/reconciliations/import', - requireAdminPermission('finance.config'), + requireAdminPermission('finance.reconcile'), createJsonHandler((req) => importFinanceReconciliation(bodyOf(req), sessionOf(req)), { successMessage: '账单已导入', errorMessage: '导入账单失败', @@ -201,7 +205,7 @@ router.get( router.post( '/finance/reconciliations/:id/resolve', - requireAdminPermission('finance.config'), + requireAdminPermission('finance.reconcile'), createJsonHandler( (req) => { const body = bodyOf(req) diff --git a/apps/backend/src/routes/admin/finance.ts b/apps/backend/src/routes/admin/finance.ts index d382c572..c6ef3abf 100644 --- a/apps/backend/src/routes/admin/finance.ts +++ b/apps/backend/src/routes/admin/finance.ts @@ -68,7 +68,7 @@ router.get( router.post( '/finance/sales-facts/rebuild', requireAdminRoles(['admin', 'operator', 'finance']), - requireAdminPermission('finance.config'), + requireAdminPermission('finance.post'), createJsonHandler( (req) => rebuildSalesDailyFacts( @@ -106,7 +106,7 @@ router.post( router.get( '/finance/manual-fulfillment/pending', - requireAdminRoles(['admin', 'operator', 'support']), + requireAdminRoles(['admin', 'operator', 'support', 'finance']), createJsonHandler( (req) => listManualFulfillmentPending(req.query as Record, sessionOf(req)), { @@ -119,7 +119,7 @@ router.get( router.post( '/finance/manual-fulfillment/confirm', - requireAdminRoles(['admin', 'operator', 'support']), + requireAdminRoles(['admin', 'operator', 'support', 'finance']), createJsonHandler((req) => confirmManualFulfillment(bodyOf(req), sessionOf(req)), { successMessage: '履约已确认', errorMessage: '确认履约失败', diff --git a/apps/backend/src/routes/admin/kuaishou-industry.ts b/apps/backend/src/routes/admin/kuaishou-industry.ts index be866e77..76a03f9d 100644 --- a/apps/backend/src/routes/admin/kuaishou-industry.ts +++ b/apps/backend/src/routes/admin/kuaishou-industry.ts @@ -30,7 +30,7 @@ const router = Router() router.post( '/kuaishou-industry/orders/:orderId/refunds/approve-and-record', - requireAdminPermission('finance.config'), + requireAdminPermission('finance.post'), createJsonHandler( (req) => { const body = req.body as Record diff --git a/apps/backend/src/routes/admin/orders.ts b/apps/backend/src/routes/admin/orders.ts index d52a4767..00bc8904 100644 --- a/apps/backend/src/routes/admin/orders.ts +++ b/apps/backend/src/routes/admin/orders.ts @@ -16,7 +16,7 @@ import type { AdminOrderRouteParams, AdminOrderRouteQuery } from '../../types/ad const router = Router() -router.use('/orders', requireAdminRoles(['admin', 'operator', 'support'])) +router.use('/orders', requireAdminRoles(['admin', 'operator', 'support', 'finance'])) router.get( '/orders', @@ -105,7 +105,7 @@ router.get( router.post( '/orders/:orderId/refunds', - requireAdminPermission('finance.config'), + requireAdminPermission('finance.post'), createJsonHandler( (req) => createManualRefund( @@ -135,7 +135,7 @@ router.post( router.post( '/orders/:orderId/refunds/:refundId/void', - requireAdminPermission('finance.config'), + requireAdminPermission('finance.post'), createJsonHandler( (req) => voidOrderRefund( diff --git a/apps/backend/src/services/admin/admin-fund-service.ts b/apps/backend/src/services/admin/admin-fund-service.ts index e1393370..8b528507 100644 --- a/apps/backend/src/services/admin/admin-fund-service.ts +++ b/apps/backend/src/services/admin/admin-fund-service.ts @@ -263,6 +263,10 @@ function normalizePayeeType(value: unknown): string { return normalized } +function normalizeRequestId(value: unknown): string { + return text(value).slice(0, 80) +} + function normalizeReconciliationStatus(value: unknown): string { const normalized = text(value) if (!RECONCILIATION_STATUSES.has(normalized)) { @@ -1215,34 +1219,46 @@ async function insertEntry( evidence: JsonObject[] note: string createdAt: string + requestId?: string }, ) { - const result = await client.query( - ` - INSERT INTO fund_entries ( - entry_no, account_id, entry_type, amount_fen, balance_after_fen, - transfer_no, counterparty_account_id, reconciliation_id, occurred_at, - created_by_admin_user_id, evidence_json, note, created_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, $13) - RETURNING * - `, - [ - generateNo('FE'), - input.accountId, - input.entryType, - input.amountFen, - input.balanceAfterFen, - input.transferNo || '', - input.counterpartyAccountId || null, - input.reconciliationId || null, - input.occurredAt, - input.actorUserId, - JSON.stringify(input.evidence), - input.note, - input.createdAt, - ], - ) - return result.rows[0] + try { + const result = await client.query( + ` + INSERT INTO fund_entries ( + entry_no, account_id, entry_type, amount_fen, balance_after_fen, + transfer_no, counterparty_account_id, reconciliation_id, occurred_at, + created_by_admin_user_id, evidence_json, note, created_at, request_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, $13, $14) + RETURNING * + `, + [ + generateNo('FE'), + input.accountId, + input.entryType, + input.amountFen, + input.balanceAfterFen, + input.transferNo || '', + input.counterpartyAccountId || null, + input.reconciliationId || null, + input.occurredAt, + input.actorUserId, + JSON.stringify(input.evidence), + input.note, + input.createdAt, + input.requestId || '', + ], + ) + return result.rows[0] + } catch (error) { + if ((error as { code?: string }).code === '23505') { + throw createHttpError('该请求已处理,请勿重复提交', { + statusCode: 409, + errorCode: 'fund_request_duplicate', + }) + } + throw error + } } export async function createAdminFundFunding(payload: JsonObject, session: AdminSession) { @@ -1259,6 +1275,7 @@ export async function createAdminFundFunding(payload: JsonObject, session: Admin const evidence = normalizeEvidence(payload.evidence || payload.evidenceFiles) const note = text(payload.note) assertEvidenceOrNote(evidence, note) + const requestId = normalizeRequestId(payload.requestId ?? payload.request_id) const now = nowIso() return withTransaction(async (client) => { @@ -1293,6 +1310,7 @@ export async function createAdminFundFunding(payload: JsonObject, session: Admin evidence, note, createdAt: now, + requestId, }) return { @@ -1315,6 +1333,7 @@ export async function createAdminFundIssue(payload: JsonObject, session: AdminSe const evidence = normalizeEvidence(payload.evidence || payload.evidenceFiles) const note = text(payload.note) assertEvidenceOrNote(evidence, note) + const requestId = normalizeRequestId(payload.requestId ?? payload.request_id) const supervisorAccountId = await resolveSupervisorAccountId(payload, session) const now = nowIso() @@ -1374,6 +1393,7 @@ export async function createAdminFundIssue(payload: JsonObject, session: AdminSe evidence, note, createdAt: now, + requestId, }) const agentEntry = await insertEntry(client, { accountId: agentAccountId, @@ -1387,6 +1407,7 @@ export async function createAdminFundIssue(payload: JsonObject, session: AdminSe evidence, note, createdAt: now, + requestId, }) return { @@ -1435,6 +1456,7 @@ export async function createAdminFundReturn(payload: JsonObject, session: AdminS const evidence = normalizeEvidence(payload.evidence || payload.evidenceFiles) const note = text(payload.note) assertEvidenceOrNote(evidence, note) + const requestId = normalizeRequestId(payload.requestId ?? payload.request_id) const agent = await selectAccountRow(agentAccountId) if (!agent || agent.account_type !== 'agent') { @@ -1513,6 +1535,7 @@ export async function createAdminFundReturn(payload: JsonObject, session: AdminS evidence, note, createdAt: now, + requestId, }) const supervisorEntry = await insertEntry(client, { accountId: supervisorAccountId, @@ -1526,6 +1549,7 @@ export async function createAdminFundReturn(payload: JsonObject, session: AdminS evidence, note, createdAt: now, + requestId, }) return { @@ -1588,6 +1612,7 @@ export type FundExpenseWriteInput = { note: string actorUserId: number allowDuplicate?: boolean + requestId?: string } /** @@ -1667,6 +1692,7 @@ export async function writeFundExpense( evidence: input.evidence, note: input.note, createdAt: now, + requestId: input.requestId || '', }) const entryRow = requiredRow(entry, '写入支出流水失败') @@ -1767,6 +1793,7 @@ export async function createAdminFundExpense(payload: JsonObject, session: Admin } const allowDuplicate = payload.allowDuplicate === true + const requestId = normalizeRequestId(payload.requestId ?? payload.request_id) return withTransaction(async (client) => { const written = await writeFundExpense(client, { @@ -1785,6 +1812,7 @@ export async function createAdminFundExpense(payload: JsonObject, session: Admin note, actorUserId: session.userId, allowDuplicate, + requestId, }) return { expense: mapManualExpense(written.expense) } }) @@ -2084,22 +2112,8 @@ export async function confirmAdminFundReconciliation( }) } - const differenceFen = toNumber(reconciliation.difference_fen) const reason = text(payload.reason) || reconciliation.reason - if (differenceFen !== 0) { - if (!hasFundPermission(session, 'fund.adjust')) { - throw createHttpError('有差异的对账需要财务或管理员调整', { - statusCode: 403, - errorCode: 'fund_adjust_forbidden', - }) - } - if (!reason) { - throw createHttpError('有差异时必须填写差异原因', { - statusCode: 400, - errorCode: 'fund_reconciliation_reason_required', - }) - } - } + const actualBalanceFen = toNumber(reconciliation.actual_balance_fen) const now = nowIso() return withTransaction(async (client) => { @@ -2112,8 +2126,28 @@ export async function confirmAdminFundReconciliation( }) } + // 关键:用“确认时点的当前余额”重算差额,而不是创建对账时的旧差额。 + // 否则创建后发生的发放/支出/缴回会让调整后的余额仍然不等于实际盘点。 + const currentBalanceFen = toNumber(account.balance_fen) + const differenceFen = actualBalanceFen - currentBalanceFen + if (differenceFen !== 0) { - const nextBalance = toNumber(account.balance_fen) + differenceFen + if (!hasFundPermission(session, 'fund.adjust')) { + throw createHttpError('有差异的对账需要财务或管理员调整', { + statusCode: 403, + errorCode: 'fund_adjust_forbidden', + }) + } + if (!reason) { + throw createHttpError('有差异时必须填写差异原因', { + statusCode: 400, + errorCode: 'fund_reconciliation_reason_required', + }) + } + } + + if (differenceFen !== 0) { + const nextBalance = currentBalanceFen + differenceFen if (nextBalance < 0) { throw createHttpError('调整后余额不能为负', { statusCode: 409, @@ -2129,7 +2163,7 @@ export async function confirmAdminFundReconciliation( entryType: 'adjustment', amountFen: differenceFen, balanceAfterFen: nextBalance, - reconciliationId: reconciliationId, + reconciliationId, occurredAt: now, actorUserId: session.userId, evidence: normalizeEvidence(payload.evidence || reconciliation.evidence_json), @@ -2141,11 +2175,16 @@ export async function confirmAdminFundReconciliation( await client.query( ` UPDATE fund_reconciliations - SET status = 'confirmed', reason = $1, confirmed_by_admin_user_id = $2, - confirmed_at = $3, updated_at = $3 + SET status = 'confirmed', + reason = $1, + system_balance_fen = $5, + difference_fen = $6, + confirmed_by_admin_user_id = $2, + confirmed_at = $3, + updated_at = $3 WHERE id = $4 `, - [reason, session.userId, now, reconciliationId], + [reason, session.userId, now, reconciliationId, currentBalanceFen, differenceFen], ) const updated = await client.query( diff --git a/apps/backend/src/services/admin/admin-rbac-service.ts b/apps/backend/src/services/admin/admin-rbac-service.ts index 97f24d60..e287f055 100644 --- a/apps/backend/src/services/admin/admin-rbac-service.ts +++ b/apps/backend/src/services/admin/admin-rbac-service.ts @@ -6,6 +6,9 @@ export const ADMIN_PERMISSION_KEYS = [ 'finance.review', 'finance.config', 'finance.export', + 'finance.post', + 'finance.close', + 'finance.reconcile', 'fund.view', 'fund.issue', 'fund.expense', 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 8790d4b6..0b3be2ff 100644 --- a/apps/backend/src/services/cost-center/affiliate-product-service.ts +++ b/apps/backend/src/services/cost-center/affiliate-product-service.ts @@ -342,18 +342,49 @@ export async function setAdminAffiliateProductPrice( } } - // 2. 追溯改价或相同生效时间改价:直接就地更新当前 active 版本,避免破坏 effective_to > effective_from 约束或触发唯一索引冲突 - if (existing && existingTime !== null && targetTime <= existingTime) { + // 2. 追溯改价:退休旧版本并新增一个从 targetTime 起生效的版本,旧行保留可追溯。 + if (existing && existingTime !== null && targetTime < existingTime) { + await query("UPDATE cost_versions SET status = 'retired', updated_at = $1 WHERE id = $2", [ + now, + existing.id, + ]) + const inserted = await query<{ id: number }>( + ` + INSERT INTO cost_versions ( + cost_item_id, scope_type, provider, shop_id, canonical_product_id, + work_product_rule_id, amount_fen, rate_bps, effective_from, effective_to, + source, status, change_reason, created_by_admin_user_id, + approved_by_admin_user_id, created_at, updated_at + ) VALUES ( + $1, 'product', '', '', $2, + NULL, $3, 0, $4, NULL, + 'manual', 'active', $5, $6, $6, $7, $7 + ) + RETURNING id + `, + [costItem.id, product.id, amountFen, effectiveFrom, changeReason, session.userId, now], + ) + return { + success: true, + product, + costVersionId: Number(inserted.rows[0]?.id || 0), + amountFen, + effectiveFrom, + message: '采购成本已更新(新增追溯版本)', + } + } + + // 2.1 相同生效时间的更正:没有可插入的时间区间,只能就地更正并保留变更原因。 + if (existing && existingTime !== null && targetTime === existingTime) { await query( `UPDATE cost_versions SET amount_fen = $1, - effective_from = $2, - change_reason = $3, - created_by_admin_user_id = $4, - approved_by_admin_user_id = $4, - updated_at = $5 - WHERE id = $6`, - [amountFen, effectiveFrom, changeReason, session.userId, now, existing.id], + change_reason = $2, + created_by_admin_user_id = $3, + approved_by_admin_user_id = $3, + updated_at = $4 + WHERE id = $5`, + [amountFen, changeReason, session.userId, now, existing.id], ) return { success: true, diff --git a/apps/backend/src/services/cost-center/platform-credit-service.ts b/apps/backend/src/services/cost-center/platform-credit-service.ts index 689c1dde..9a6c9f38 100644 --- a/apps/backend/src/services/cost-center/platform-credit-service.ts +++ b/apps/backend/src/services/cost-center/platform-credit-service.ts @@ -178,6 +178,7 @@ export async function grantPlatformCredit(payload: JsonObject, session: AdminSes const quantity = normalizePositiveQuantity(payload.quantity) const platformRef = optionalText(payload.platformRef ?? payload.platform_ref, 100) const note = optionalText(payload.note, 300) + const requestId = optionalText(payload.requestId ?? payload.request_id, 80) const unitPrices = await loadUnitPrices() const unitPriceFen = unitPrices[platformKey] @@ -194,16 +195,36 @@ export async function grantPlatformCredit(payload: JsonObject, session: AdminSes WHERE id = $1`, [account.id, nextBalance, now], ) - const entryResult = await client.query( - ` - INSERT INTO platform_credit_entries ( - account_id, entry_type, quantity_delta, balance_after, amount_fen, - platform_ref, note, created_at - ) VALUES ($1, 'grant', $2, $3, $4, $5, $6, $7) - RETURNING * - `, - [account.id, quantity, nextBalance, quantity * unitPriceFen, platformRef, note, now], - ) + let entryResult + try { + entryResult = await client.query( + ` + INSERT INTO platform_credit_entries ( + account_id, entry_type, quantity_delta, balance_after, amount_fen, + platform_ref, note, request_id, created_at + ) VALUES ($1, 'grant', $2, $3, $4, $5, $6, $7, $8) + RETURNING * + `, + [ + account.id, + quantity, + nextBalance, + quantity * unitPriceFen, + platformRef, + note, + requestId, + now, + ], + ) + } catch (error) { + if ((error as { code?: string }).code === '23505') { + throw createHttpError('该请求已处理,请勿重复提交', { + statusCode: 409, + errorCode: 'platform_credit_request_duplicate', + }) + } + throw error + } return { accountId: Number(account.id), platformKey, @@ -236,6 +257,7 @@ export async function adjustPlatformCredit(payload: JsonObject, session: AdminSe errorCode: 'platform_credit_adjust_note_required', }) } + const requestId = optionalText(payload.requestId ?? payload.request_id, 80) const unitPrices = await loadUnitPrices() const unitPriceFen = unitPrices[platformKey] const now = nowIso() @@ -256,16 +278,27 @@ export async function adjustPlatformCredit(payload: JsonObject, session: AdminSe WHERE id = $1`, [account.id, nextBalance, now], ) - const entryResult = await client.query( - ` - INSERT INTO platform_credit_entries ( - account_id, entry_type, quantity_delta, balance_after, amount_fen, - note, created_at - ) VALUES ($1, 'adjust', $2, $3, $4, $5, $6) - RETURNING * - `, - [account.id, quantity, nextBalance, quantity * unitPriceFen, note, now], - ) + let entryResult + try { + entryResult = await client.query( + ` + INSERT INTO platform_credit_entries ( + account_id, entry_type, quantity_delta, balance_after, amount_fen, + note, request_id, created_at + ) VALUES ($1, 'adjust', $2, $3, $4, $5, $6, $7) + RETURNING * + `, + [account.id, quantity, nextBalance, quantity * unitPriceFen, note, requestId, now], + ) + } catch (error) { + if ((error as { code?: string }).code === '23505') { + throw createHttpError('该请求已处理,请勿重复提交', { + statusCode: 409, + errorCode: 'platform_credit_request_duplicate', + }) + } + throw error + } return { accountId: Number(account.id), platformKey, 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 f87d5e39..1f8e5577 100644 --- a/apps/backend/src/services/cost-center/product-catalog-service.ts +++ b/apps/backend/src/services/cost-center/product-catalog-service.ts @@ -1391,30 +1391,49 @@ export async function setAdminProductPrice( let costVersionId = existing?.id || 0 - if (existing && existingTime !== null && targetTime <= existingTime) { - // 追溯改价或相同生效时间改价:就地更新 active 版本 - await query( - `UPDATE cost_versions - SET amount_fen = $1, - effective_from = $2, - canonical_product_id = $3, - work_product_rule_id = $4, - change_reason = $5, - created_by_admin_user_id = $6, - approved_by_admin_user_id = $6, - updated_at = $7 - WHERE id = $8`, + if (existing && existingTime !== null && targetTime < existingTime) { + // 追溯改价:退休旧版本并新增一个从 targetTime 起生效的版本,旧行保留可追溯。 + // 不能就地改金额,否则 cost_snapshots.cost_version_id 指向的版本内容会被篡改。 + await query("UPDATE cost_versions SET status = 'retired', updated_at = $1 WHERE id = $2", [ + now, + existing.id, + ]) + const inserted = await query<{ id: number }>( + `INSERT INTO cost_versions ( + cost_item_id, scope_type, provider, shop_id, canonical_product_id, + work_product_rule_id, amount_fen, rate_bps, effective_from, effective_to, + source, status, change_reason, created_by_admin_user_id, + approved_by_admin_user_id, created_at, updated_at + ) VALUES ( + $1, 'rule', '', '', $2, + $3, $4, 0, $5, NULL, + 'manual', 'active', $6, $7, + $7, $8, $8 + ) RETURNING id`, [ - amountFen, - effectiveFrom, + costItemId, product.id, rule?.id ?? null, + amountFen, + effectiveFrom, changeReason, session.userId, now, - existing.id, ], ) + costVersionId = inserted.rows[0]?.id || 0 + } else if (existing && existingTime !== null && targetTime === existingTime) { + // 相同生效时间的更正:没有可插入的时间区间,只能就地更正并保留变更原因。 + await query( + `UPDATE cost_versions + SET amount_fen = $1, + change_reason = $2, + created_by_admin_user_id = $3, + approved_by_admin_user_id = $3, + updated_at = $4 + WHERE id = $5`, + [amountFen, changeReason, session.userId, now, existing.id], + ) } else { if (existing) { await query( @@ -1510,17 +1529,36 @@ export async function setAdminProductPrice( const targetTime = Date.parse(effectiveFrom) let costVersionId = existing?.id || 0 - if (existing && existingTime !== null && targetTime <= existingTime) { + if (existing && existingTime !== null && targetTime < existingTime) { + await query("UPDATE cost_versions SET status = 'retired', updated_at = $1 WHERE id = $2", [ + now, + existing.id, + ]) + const inserted = await query<{ id: number }>( + `INSERT INTO cost_versions ( + cost_item_id, scope_type, provider, shop_id, canonical_product_id, + work_product_rule_id, amount_fen, rate_bps, effective_from, effective_to, + source, status, change_reason, created_by_admin_user_id, + approved_by_admin_user_id, created_at, updated_at + ) VALUES ( + $1, 'product', '', '', $2, + NULL, $3, 0, $4, NULL, + 'manual', 'active', $5, $6, + $6, $7, $7 + ) RETURNING id`, + [costItemId, product.id, amountFen, effectiveFrom, changeReason, session.userId, now], + ) + costVersionId = inserted.rows[0]?.id || 0 + } else if (existing && existingTime !== null && targetTime === existingTime) { await query( `UPDATE cost_versions SET amount_fen = $1, - effective_from = $2, - change_reason = $3, - created_by_admin_user_id = $4, - approved_by_admin_user_id = $4, - updated_at = $5 - WHERE id = $6`, - [amountFen, effectiveFrom, changeReason, session.userId, now, existing.id], + change_reason = $2, + created_by_admin_user_id = $3, + approved_by_admin_user_id = $3, + updated_at = $4 + WHERE id = $5`, + [amountFen, changeReason, session.userId, now, existing.id], ) } else { if (existing) { diff --git a/apps/backend/src/services/finance/finance-controls.test.ts b/apps/backend/src/services/finance/finance-controls.test.ts new file mode 100644 index 00000000..6968f992 --- /dev/null +++ b/apps/backend/src/services/finance/finance-controls.test.ts @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +const read = (path: URL) => readFile(path, 'utf8') + +test('销售事实重算必须冻结已关账期间', async () => { + const source = await read(new URL('./sales-facts-service.ts', import.meta.url)) + assert.ok(source.includes('finance_periods'), '重算必须读取会计期间') + assert.ok(source.includes("p.status = 'closed'"), '必须识别已关账期间') + assert.ok(source.includes('skippedClosedPeriods'), '必须返回被跳过的关账期间') +}) + +test('人工确认/退款不得静默改已关账期间', async () => { + const [manual, refund] = await Promise.all([ + read(new URL('./manual-fulfillment-service.ts', import.meta.url)), + read(new URL('./order-refund-service.ts', import.meta.url)), + ]) + assert.ok(manual.includes('manual_fulfillment_period_closed')) + assert.ok(refund.includes('order_refund_period_closed')) +}) + +test('总账同步必须追加调整凭证,不能删除重建', async () => { + const source = await read(new URL('./finance-ledger-service.ts', import.meta.url)) + assert.ok(!source.includes('DELETE FROM finance_journal_entries'), '已过账凭证不得删除重建') + assert.ok(source.includes('sales_revenue_adjustment'), '收入变化必须走调整凭证') + assert.ok(source.includes('sales_cost_adjustment'), '成本变化必须走调整凭证') + assert.ok(source.includes('finance_sales_sync:'), '按日同步必须加 advisory lock') +}) + +test('关账必须具备门槛、期间锁和重复关账保护', async () => { + const source = await read(new URL('./finance-ledger-service.ts', import.meta.url)) + assert.ok(source.includes('assertPeriodCloseable'), '关账必须校验门槛') + assert.ok(source.includes('finance_period_close_blocked'), '关账失败必须给出明确错误码') + assert.ok(source.includes('finance_period_already_closed'), '重复关账必须拒绝') + assert.ok(source.includes('finance_period:'), '凭证写入与关账必须共用期间锁') + assert.ok( + source.includes('manual_cost_missing') || source.includes('未确认成本'), + '必须拦人工缺成本', + ) +}) + +test('平台退款必须先校验可退金额并落退款意图', async () => { + const source = await read(new URL('./order-refund-service.ts', import.meta.url)) + const platformStart = source.indexOf('export async function approveAndRecordKuaishouRefund') + const platformEnd = source.indexOf('type PlatformRefundIntentRow', platformStart) + const body = source.slice(platformStart, platformEnd) + assert.ok( + body.indexOf('getRefundableFen') < body.indexOf('approveKuaishouIndustryRefund'), + '可退金额校验必须发生在平台调用之前', + ) + assert.ok(body.includes('beginPlatformRefundIntent'), '必须先落退款意图') + assert.ok(source.includes('finance_refund_intents'), '必须有平台退款状态机') + assert.ok(source.includes('order_refund_external_ref_exists'), '外部退款单号必须幂等') + assert.ok(source.includes('nextPayStatus'), '全额退款撤销必须恢复订单支付状态') + assert.ok(source.includes('订单已退款,成本异常自动关闭'), '撤销必须重新打开成本异常') +}) + +test('备用金对账确认必须按当前余额重算差额', async () => { + const source = await read(new URL('../admin/admin-fund-service.ts', import.meta.url)) + const start = source.indexOf('export async function confirmAdminFundReconciliation') + const end = source.indexOf('export async function getAdminFundSupervisorOptions', start) + const body = source.slice(start, end) + assert.ok(body.includes('actualBalanceFen - currentBalanceFen'), '必须用确认时点余额重算') + assert.ok(body.includes('system_balance_fen = $5'), '必须回写确认时点的系统余额') + assert.ok(body.includes('difference_fen = $6'), '必须回写确认时点的差额') +}) + +test('迁移 104 提供退款/支出幂等与凭证完整性约束', async () => { + const migration = await read( + new URL('../../db/migrations/104_finance_controls.sql', import.meta.url), + ) + assert.ok(migration.includes('uq_order_refunds_source_external_ref')) + assert.ok(migration.includes('uq_manual_expenses_order_item')) + assert.ok(migration.includes('finance_refund_intents')) + assert.ok(migration.includes('trg_finance_journal_balanced')) + assert.ok(migration.includes('trg_finance_journal_immutable')) + assert.ok(migration.includes('trg_fund_entries_balance')) + assert.ok(migration.includes('trg_fund_accounts_balance')) + assert.ok(migration.includes("'finance.post'")) + assert.ok(migration.includes("'finance.close'")) + assert.ok(migration.includes("'finance.reconcile'")) +}) + +test('财务写路由使用拆分后的权限,人工履约包含 finance', async () => { + const [ledgerRoute, financeRoute, orderRoute] = await Promise.all([ + read(new URL('../../routes/admin/finance-ledger.ts', import.meta.url)), + read(new URL('../../routes/admin/finance.ts', import.meta.url)), + read(new URL('../../routes/admin/orders.ts', import.meta.url)), + ]) + assert.ok(ledgerRoute.includes("requireAdminPermission('finance.post')")) + assert.ok(ledgerRoute.includes("requireAdminPermission('finance.close')")) + assert.ok(ledgerRoute.includes("requireAdminPermission('finance.reconcile')")) + assert.ok( + financeRoute.includes("requireAdminRoles(['admin', 'operator', 'support', 'finance'])"), + '文档承诺财务可代录,路由必须允许 finance', + ) + assert.ok(orderRoute.includes("requireAdminPermission('finance.post')")) +}) + +test('P1:资金/积分写命令支持 requestId 幂等', async () => { + const [fund, credit, migration] = await Promise.all([ + read(new URL('../admin/admin-fund-service.ts', import.meta.url)), + read(new URL('../cost-center/platform-credit-service.ts', import.meta.url)), + read(new URL('../../db/migrations/104_finance_controls.sql', import.meta.url)), + ]) + assert.ok(fund.includes('requestId') && fund.includes('fund_request_duplicate')) + assert.ok(credit.includes('platform_credit_request_duplicate')) + assert.ok(migration.includes('uq_fund_entries_account_request')) + assert.ok(migration.includes('uq_platform_credit_entries_account_request')) +}) + +test('P1:成本版本追溯改价退休旧版本而不是覆盖金额', async () => { + const [product, affiliate] = await Promise.all([ + read(new URL('../cost-center/product-catalog-service.ts', import.meta.url)), + read(new URL('../cost-center/affiliate-product-service.ts', import.meta.url)), + ]) + assert.ok(product.includes('新增一个从 targetTime 起生效的版本')) + assert.ok(product.includes("status = 'retired'")) + assert.ok(affiliate.includes('新增追溯版本')) + assert.ok(affiliate.includes("status = 'retired'")) +}) + +test('P1:平台对账支持订单项/平台明细维度', async () => { + const [service, migration] = await Promise.all([ + read(new URL('./finance-reconciliation-service.ts', import.meta.url)), + read(new URL('../../db/migrations/104_finance_controls.sql', import.meta.url)), + ]) + assert.ok(service.includes('orderItemId') && service.includes('platformItemId')) + assert.ok(service.includes('order_item_id')) + assert.ok(migration.includes('platform_item_id')) + assert.ok(migration.includes('order_item_id')) +}) + +test('L1:钱包金额 BIGINT、非负约束与奖励流水幂等', async () => { + const migration = await read( + new URL('../../db/migrations/104_finance_controls.sql', import.meta.url), + ) + assert.ok(migration.includes('ALTER COLUMN total_amount TYPE BIGINT')) + assert.ok(migration.includes('ALTER COLUMN available_amount TYPE BIGINT')) + assert.ok(migration.includes('chk_worker_wallets_nonnegative')) + assert.ok(migration.includes('chk_worker_wallet_ledgers_nonnegative_after')) + assert.ok(migration.includes('worker_wallet_ledger_event_key')) + assert.ok(migration.includes('uq_worker_wallet_ledger_event_key')) + assert.ok(migration.includes('v_worker_wallet_duplicate_rewards')) + assert.ok(migration.includes("NOT (NEW.payload_json ? 'inventoryId')")) + assert.ok(migration.includes('GROUP BY worker_id, related_work_order_id')) +}) + +test('销售毛利必须同时暴露缺失成本收入,避免只看毛利绝对值', async () => { + const source = await read(new URL('./sales-facts-service.ts', import.meta.url)) + assert.ok(source.includes('cost_missing_revenue_fen')) + assert.ok(source.includes('costMissingRevenueFen')) +}) diff --git a/apps/backend/src/services/finance/finance-ledger-service.ts b/apps/backend/src/services/finance/finance-ledger-service.ts index 991a9285..d4433255 100644 --- a/apps/backend/src/services/finance/finance-ledger-service.ts +++ b/apps/backend/src/services/finance/finance-ledger-service.ts @@ -34,6 +34,8 @@ export type PostJournalInput = { memo?: string metadata?: JsonObject lines: JournalLineInput[] + /** 允许命中已有幂等键时直接返回(默认抛 409,避免“以为新建了、实际复用了旧凭证”)。 */ + allowExisting?: boolean } export type JournalEntry = { @@ -68,6 +70,7 @@ export async function postJournalEntry( const occurredDate = normalizeTimestampDate(input.occurredAt) const occurredAt = occurredDate.toISOString() const periodKey = formatShanghaiDate(occurredDate).slice(0, 7) + await acquireFinancePeriodLock(client, periodKey) await assertPeriodOpen(client, periodKey) const entryNo = `FJ-${periodKey.replace('-', '')}-${randomId('', 8)}` @@ -100,6 +103,12 @@ export async function postJournalEntry( LIMIT 1`, [input.eventType, input.refType, input.refId], ) + if (!input.allowExisting) { + throw createHttpError('该业务事件已生成过凭证,不能重复记账', { + statusCode: 409, + errorCode: 'finance_journal_duplicate_reference', + }) + } return { created: false, entryId: Number(existing.rows[0]?.id || 0), @@ -189,81 +198,179 @@ export async function syncFinanceLedgerFromFacts(input: { dateFrom: string; date ) let created = 0 - let replaced = 0 + let adjusted = 0 let skipped = 0 + for (const row of rows.rows) { const occurredAt = `${row.stat_date}T12:00:00+08:00` const periodKey = formatShanghaiDate(new Date(occurredAt)).slice(0, 7) - const period = await query<{ status: string }>( - `SELECT status FROM finance_periods WHERE period_key = $1 LIMIT 1`, - [periodKey], - ) - if (period.rows[0]?.status === 'closed') { - // 已关账期间不可改账;即使销售事实后来变化,也保留关账时点的凭证。 - skipped += 1 - continue - } - // 未关账期间:销售事实可能因退款/收入回填而变化,按日整体重建, - // 避免旧的幂等凭证一直停留在旧金额;删除与重建放在同一事务内。 const revenue = Number(row.gross_revenue_fen || 0) const refund = Number(row.refund_amount_fen || 0) const cost = Number(row.cost_amount_fen || 0) - const entries: PostJournalInput[] = [] - if (revenue > 0) { - entries.push({ - eventType: 'sales_revenue', - refType: 'sales_fact', - refId: row.stat_date, - occurredAt, - memo: `${row.stat_date} 销售收入`, - lines: [ - { accountCode: '1101', debitFen: revenue, memo: '应收平台' }, - { accountCode: '4001', creditFen: revenue, memo: '商品销售收入' }, - ], - }) - } - if (refund > 0) { - entries.push({ - eventType: 'sales_refund', - refType: 'sales_fact', - refId: row.stat_date, - occurredAt, - memo: `${row.stat_date} 退款冲减`, - lines: [ - { accountCode: '4101', debitFen: refund, memo: '退款冲减收入' }, - { accountCode: '1101', creditFen: refund, memo: '应收平台冲减' }, - ], - }) - } - if (cost > 0) { - entries.push({ - eventType: 'sales_cost', - refType: 'sales_fact', - refId: row.stat_date, - occurredAt, - memo: `${row.stat_date} 履约成本`, - lines: [ - { accountCode: '5001', debitFen: cost, memo: '履约成本' }, - { accountCode: '2001', creditFen: cost, memo: '应付履约款' }, - ], - }) - } - const dayCreated = await withTransaction(async (client) => { - const deleted = await client.query( - `DELETE FROM finance_journal_entries WHERE ref_type = 'sales_fact' AND ref_id = $1`, + + // 按日串行化:避免两个同步任务同时算出同一个 delta 并重复补记。 + // 锁在事务内,事务提交/回滚自动释放。 + const dayResult = await withTransaction(async (client) => { + await client.query( + `SELECT pg_advisory_xact_lock(hashtext('finance_sales_sync:' || $1)::bigint)`, [row.stat_date], ) - replaced += deleted.rowCount || 0 - let count = 0 - for (const entry of entries) { - const result = await postJournalEntry(entry, client) - if (result.created) count += 1 + + const period = await client.query<{ status: string }>( + `SELECT status FROM finance_periods WHERE period_key = $1 LIMIT 1`, + [periodKey], + ) + if (period.rows[0]?.status === 'closed') { + // 已关账期间不可改账;销售事实层也已在 rebuild 时冻结该期间。 + return { created: 0, adjusted: 0, skipped: 1 } } - return count + + const existing = await readSalesFactJournalTotals(client, row.stat_date) + let dayCreated = 0 + let dayAdjusted = 0 + + const postDelta = async (input: { + eventType: string + adjustmentType: string + hasExisting: boolean + delta: number + normalLines: (amount: number) => JournalLineInput[] + reverseLines: (amount: number) => JournalLineInput[] + memo: string + }) => { + if (input.delta === 0) return + const isAdjustment = input.hasExisting + const eventType = isAdjustment ? input.adjustmentType : input.eventType + const refId = isAdjustment + ? `${row.stat_date}:${input.adjustmentType}:${randomId('', 6)}` + : row.stat_date + const amount = Math.abs(input.delta) + const result = await postJournalEntry( + { + eventType, + refType: 'sales_fact', + refId, + occurredAt, + memo: input.memo, + metadata: { + statDate: row.stat_date, + deltaFen: input.delta, + adjustment: isAdjustment, + }, + lines: input.delta > 0 ? input.normalLines(amount) : input.reverseLines(amount), + }, + client, + ) + if (result.created) { + if (isAdjustment) dayAdjusted += 1 + else dayCreated += 1 + } + } + + await postDelta({ + eventType: 'sales_revenue', + adjustmentType: 'sales_revenue_adjustment', + hasExisting: existing.hasRevenue, + delta: revenue - existing.revenue, + normalLines: (amount) => [ + { accountCode: '1101', debitFen: amount, memo: '应收平台' }, + { accountCode: '4001', creditFen: amount, memo: '商品销售收入' }, + ], + reverseLines: (amount) => [ + { accountCode: '4001', debitFen: amount, memo: '商品销售收入冲减' }, + { accountCode: '1101', creditFen: amount, memo: '应收平台冲减' }, + ], + memo: `${row.stat_date} 销售收入`, + }) + + await postDelta({ + eventType: 'sales_refund', + adjustmentType: 'sales_refund_adjustment', + hasExisting: existing.hasRefund, + delta: refund - existing.refund, + normalLines: (amount) => [ + { accountCode: '4101', debitFen: amount, memo: '退款冲减收入' }, + { accountCode: '1101', creditFen: amount, memo: '应收平台冲减' }, + ], + reverseLines: (amount) => [ + { accountCode: '1101', debitFen: amount, memo: '应收平台恢复' }, + { accountCode: '4101', creditFen: amount, memo: '退款冲减恢复' }, + ], + memo: `${row.stat_date} 退款冲减`, + }) + + await postDelta({ + eventType: 'sales_cost', + adjustmentType: 'sales_cost_adjustment', + hasExisting: existing.hasCost, + delta: cost - existing.cost, + normalLines: (amount) => [ + { accountCode: '5001', debitFen: amount, memo: '履约成本' }, + { accountCode: '2001', creditFen: amount, memo: '应付履约款' }, + ], + reverseLines: (amount) => [ + { accountCode: '2001', debitFen: amount, memo: '应付履约款冲减' }, + { accountCode: '5001', creditFen: amount, memo: '履约成本冲减' }, + ], + memo: `${row.stat_date} 履约成本`, + }) + + return { created: dayCreated, adjusted: dayAdjusted, skipped: 0 } }) - created += dayCreated + + created += dayResult.created + adjusted += dayResult.adjusted + skipped += dayResult.skipped + } + + return { + range: { fromDate: dateFrom, toDate: dateTo }, + created, + adjusted, + skipped, + } +} + +type SalesFactJournalTotalRow = { + event_type: string + account_code: string + debit_fen: string | number + credit_fen: string | number +} + +async function readSalesFactJournalTotals(client: PoolClient, statDate: string) { + const result = await client.query( + ` + SELECT + e.event_type, + l.account_code, + COALESCE(SUM(l.debit_fen), 0)::bigint AS debit_fen, + COALESCE(SUM(l.credit_fen), 0)::bigint AS credit_fen + 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' + GROUP BY e.event_type, l.account_code + `, + [statDate], + ) + const rows = result.rows + const revenueTypes = new Set(['sales_revenue', 'sales_revenue_adjustment']) + const refundTypes = new Set(['sales_refund', 'sales_refund_adjustment']) + const costTypes = new Set(['sales_cost', 'sales_cost_adjustment']) + const net = (types: Set, accountCode: string) => + rows + .filter((row) => types.has(row.event_type) && row.account_code === accountCode) + .reduce((sum, row) => sum + Number(row.debit_fen || 0) - Number(row.credit_fen || 0), 0) + return { + revenue: net(revenueTypes, '1101'), + refund: net(refundTypes, '4101'), + cost: net(costTypes, '5001'), + hasRevenue: rows.some((row) => revenueTypes.has(row.event_type)), + hasRefund: rows.some((row) => refundTypes.has(row.event_type)), + hasCost: rows.some((row) => costTypes.has(row.event_type)), } - return { range: { fromDate: dateFrom, toDate: dateTo }, created, replaced, skipped } } export async function listJournalEntries(input: Record = {}) { @@ -349,11 +456,13 @@ export async function listJournalEntries(input: Record = {}) { } } -export async function getTrialBalance(input: { dateFrom: string; dateTo: string }) { +export async function getTrialBalance( + input: { dateFrom: string; dateTo: string }, + client?: PoolClient, +) { const dateFrom = normalizeDate(input.dateFrom) const dateTo = normalizeDate(input.dateTo) - const rows = await query>( - ` + const sql = ` SELECT a.account_code, a.account_name, a.account_type, a.normal_side, COALESCE(SUM(l.debit_fen), 0)::bigint AS debit_fen, @@ -373,10 +482,11 @@ export async function getTrialBalance(input: { dateFrom: string; dateTo: string AND (e.id IS NOT NULL OR (l.id IS NULL)) GROUP BY a.account_code, a.account_name, a.account_type, a.normal_side ORDER BY a.account_code - `, - [dateFrom, dateTo], - ) - const items = rows.rows.map((row) => ({ + ` + const result = client + ? await client.query>(sql, [dateFrom, dateTo]) + : await query>(sql, [dateFrom, dateTo]) + const items = result.rows.map((row) => ({ accountCode: String(row.account_code || ''), accountName: String(row.account_name || ''), accountType: String(row.account_type || ''), @@ -410,42 +520,254 @@ export async function listFinancePeriods() { } } -export async function closeFinancePeriod(periodKeyInput: unknown, session: AdminSession) { +export async function closeFinancePeriod( + periodKeyInput: unknown, + session: AdminSession, + reason = '', +) { const periodKey = normalizePeriodKey(periodKeyInput) + + const existing = await query<{ status: string }>( + `SELECT status FROM finance_periods WHERE period_key = $1 LIMIT 1`, + [periodKey], + ) + if (existing.rows[0]?.status === 'closed') { + throw createHttpError(`会计期间 ${periodKey} 已关账,不能重复关账`, { + statusCode: 409, + errorCode: 'finance_period_already_closed', + }) + } + const { startsAt, endsAt, dateFrom, dateTo } = periodRange(periodKey) // 先补齐该期间的凭证,避免漏记。 await syncFinanceLedgerFromFacts({ dateFrom, dateTo }) - const trial = await getTrialBalance({ dateFrom, dateTo }) - const now = nowIso() - await query( + + const result = await withTransaction(async (client) => { + // 与 postJournalEntry 使用同一把期间锁:关账检查与凭证写入不能并发穿透。 + await acquireFinancePeriodLock(client, periodKey) + + const current = await client.query<{ status: string }>( + `SELECT status FROM finance_periods WHERE period_key = $1 LIMIT 1`, + [periodKey], + ) + if (current.rows[0]?.status === 'closed') { + throw createHttpError(`会计期间 ${periodKey} 已关账,不能重复关账`, { + statusCode: 409, + errorCode: 'finance_period_already_closed', + }) + } + + const trial = await getTrialBalance({ dateFrom, dateTo }, client) + await assertPeriodCloseable(client, periodKey, dateFrom, dateTo, trial) + + const now = nowIso() + await client.query( + ` + INSERT INTO finance_periods ( + period_key, starts_at, ends_at, status, summary_json, + closed_at, closed_by_admin_user_id, created_at, updated_at + ) VALUES ($1, $2, $3, 'closed', $4::jsonb, $5, $6, $5, $5) + ON CONFLICT (period_key) DO UPDATE + SET status = 'closed', + starts_at = EXCLUDED.starts_at, + ends_at = EXCLUDED.ends_at, + summary_json = EXCLUDED.summary_json, + closed_at = EXCLUDED.closed_at, + closed_by_admin_user_id = EXCLUDED.closed_by_admin_user_id, + updated_at = EXCLUDED.updated_at + `, + [ + periodKey, + startsAt, + endsAt, + JSON.stringify({ + totals: trial.totals, + balanced: trial.balanced, + itemCount: trial.items.length, + closeReason: String(reason || '').slice(0, 500), + }), + now, + Number(session.userId || 0) || null, + ], + ) + return { trial } + }) + + return { periodKey, trial: result.trial } +} + +async function assertPeriodCloseable( + client: PoolClient, + periodKey: string, + dateFrom: string, + dateTo: string, + trial: { balanced: boolean }, +) { + const issues: string[] = [] + if (!trial.balanced) { + issues.push('凭证借贷不平衡') + } + + // 上线后人工单必须全覆盖:从第一次人工配置起,未确认的人工订单项不允许关账。 + const manualMissing = await client.query<{ total: number }>( ` - INSERT INTO finance_periods ( - period_key, starts_at, ends_at, status, summary_json, - closed_at, closed_by_admin_user_id, created_at, updated_at - ) VALUES ($1, $2, $3, 'closed', $4::jsonb, $5, $6, $5, $5) - ON CONFLICT (period_key) DO UPDATE - SET status = 'closed', - starts_at = EXCLUDED.starts_at, - ends_at = EXCLUDED.ends_at, - summary_json = EXCLUDED.summary_json, - closed_at = EXCLUDED.closed_at, - closed_by_admin_user_id = EXCLUDED.closed_by_admin_user_id, - updated_at = EXCLUDED.updated_at + SELECT COUNT(*)::int AS total + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + WHERE oi.fulfillment_mode = 'manual' + AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date + BETWEEN $1::date AND $2::date + AND oi.created_at >= (SELECT MIN(created_at) FROM manual_fulfillment_configs) + AND NOT EXISTS ( + SELECT 1 FROM cost_snapshots cs + WHERE cs.subject_type = 'manual_case' + AND cs.order_item_id = oi.id + AND cs.superseded_at IS NULL + ) `, - [ - periodKey, - startsAt, - endsAt, - JSON.stringify({ - totals: trial.totals, - balanced: trial.balanced, - itemCount: trial.items.length, - }), - now, - Number(session.userId || 0) || null, - ], + [dateFrom, dateTo], ) - return { periodKey, trial } + if (Number(manualMissing.rows[0]?.total || 0) > 0) { + issues.push(`存在 ${Number(manualMissing.rows[0]?.total)} 个人工订单项未确认成本`) + } + + // 人工成本必须能找到资金流水:现金 fundExpenseId 或积分 creditEntryId。 + const manualWithoutFund = await client.query<{ total: number }>( + ` + SELECT COUNT(*)::int AS total + FROM cost_snapshots cs + JOIN order_items 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 (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date + BETWEEN $1::date AND $2::date + AND cs.created_at >= (SELECT MIN(created_at) FROM manual_fulfillment_configs) + AND cs.metadata_json->>'fundExpenseId' IS NULL + AND cs.metadata_json->>'creditEntryId' IS NULL + `, + [dateFrom, dateTo], + ) + if (Number(manualWithoutFund.rows[0]?.total || 0) > 0) { + issues.push(`存在 ${Number(manualWithoutFund.rows[0]?.total)} 个人工成本快照没有资金流水`) + } + + // 现金人工成本金额必须等于备用金支出金额。 + const manualAmountMismatch = await client.query<{ total: number }>( + ` + 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 orders o ON o.id = oi.order_id + WHERE cs.subject_type = 'manual_case' + AND cs.superseded_at IS NULL + AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date + BETWEEN $1::date AND $2::date + AND cs.created_at >= (SELECT MIN(created_at) FROM manual_fulfillment_configs) + AND cs.total_amount_fen <> me.amount_fen + `, + [dateFrom, dateTo], + ) + if (Number(manualAmountMismatch.rows[0]?.total || 0) > 0) { + issues.push( + `存在 ${Number(manualAmountMismatch.rows[0]?.total)} 个人工成本与备用金支出金额不一致`, + ) + } + + // 未解决的平台对账差异不允许关账。 + const unresolvedReconciliations = await client.query<{ total: number }>( + ` + SELECT COUNT(*)::int AS total + FROM finance_reconciliations + WHERE status = 'differences' + AND period_from <= $2::date + AND period_to >= $1::date + `, + [dateFrom, dateTo], + ) + if (Number(unresolvedReconciliations.rows[0]?.total || 0) > 0) { + issues.push(`存在 ${Number(unresolvedReconciliations.rows[0]?.total)} 个未解决的对账差异批次`) + } + + // 账表一致:销售事实与 sales_fact 凭证必须逐项相等,防止事实层被并发重算后总账未跟上。 + const ledgerVsFacts = await client.query<{ + facts_revenue_fen: string + facts_refund_fen: string + facts_cost_fen: string + ledger_revenue_fen: string + ledger_refund_fen: string + ledger_cost_fen: string + }>( + ` + WITH facts AS ( + SELECT + 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 + ), + ledger AS ( + SELECT + COALESCE(SUM(l.debit_fen) FILTER ( + WHERE l.account_code = '1101' + AND e.event_type IN ('sales_revenue', 'sales_revenue_adjustment') + ), 0)::bigint + - COALESCE(SUM(l.credit_fen) FILTER ( + WHERE l.account_code = '1101' + AND e.event_type IN ('sales_revenue', 'sales_revenue_adjustment') + ), 0)::bigint AS revenue_fen, + COALESCE(SUM(l.debit_fen) FILTER ( + WHERE l.account_code = '4101' + AND e.event_type IN ('sales_refund', 'sales_refund_adjustment') + ), 0)::bigint + - COALESCE(SUM(l.credit_fen) FILTER ( + WHERE l.account_code = '4101' + AND e.event_type IN ('sales_refund', 'sales_refund_adjustment') + ), 0)::bigint AS refund_fen, + COALESCE(SUM(l.debit_fen) FILTER ( + WHERE l.account_code = '5001' + AND e.event_type IN ('sales_cost', 'sales_cost_adjustment') + ), 0)::bigint + - COALESCE(SUM(l.credit_fen) FILTER ( + WHERE l.account_code = '5001' + AND e.event_type IN ('sales_cost', 'sales_cost_adjustment') + ), 0)::bigint AS cost_fen + FROM finance_journal_entries e + JOIN finance_journal_lines l ON l.entry_id = e.id + WHERE e.ref_type = 'sales_fact' + AND e.status = 'posted' + AND e.occurred_at >= ($1::date::timestamp AT TIME ZONE 'Asia/Shanghai') + AND e.occurred_at < (($2::date + interval '1 day')::timestamp AT TIME ZONE 'Asia/Shanghai') + ) + SELECT + facts.revenue_fen AS facts_revenue_fen, + facts.refund_fen AS facts_refund_fen, + facts.cost_fen AS facts_cost_fen, + ledger.revenue_fen AS ledger_revenue_fen, + ledger.refund_fen AS ledger_refund_fen, + ledger.cost_fen AS ledger_cost_fen + FROM facts, ledger + `, + [dateFrom, dateTo], + ) + const compare = ledgerVsFacts.rows[0] + if ( + compare && + (Number(compare.facts_revenue_fen) !== Number(compare.ledger_revenue_fen) || + Number(compare.facts_refund_fen) !== Number(compare.ledger_refund_fen) || + Number(compare.facts_cost_fen) !== Number(compare.ledger_cost_fen)) + ) { + issues.push('销售事实与总账凭证不一致') + } + + if (issues.length > 0) { + throw createHttpError(`期间 ${periodKey} 不满足关账条件:${issues.join(';')}`, { + statusCode: 409, + errorCode: 'finance_period_close_blocked', + }) + } } export async function reopenFinancePeriod(periodKeyInput: unknown) { @@ -454,15 +776,15 @@ export async function reopenFinancePeriod(periodKeyInput: unknown) { ` UPDATE finance_periods SET status = 'open', closed_at = NULL, closed_by_admin_user_id = NULL, updated_at = $2 - WHERE period_key = $1 + WHERE period_key = $1 AND status = 'closed' RETURNING * `, [periodKey, nowIso()], ) if (!result.rows[0]) { throw createHttpError('期间不存在或尚未关账', { - statusCode: 404, - errorCode: 'finance_period_not_found', + statusCode: 409, + errorCode: 'finance_period_not_closed', }) } return mapPeriod(result.rows[0]) @@ -500,11 +822,13 @@ function normalizeLines(lines: JournalLineInput[]) { errorCode: 'finance_journal_account_required', }) } - const debitFen = Math.trunc(Number(line.debitFen || 0)) - const creditFen = Math.trunc(Number(line.creditFen || 0)) + const debitFen = Number(line.debitFen || 0) + const creditFen = Number(line.creditFen || 0) if ( !Number.isFinite(debitFen) || !Number.isFinite(creditFen) || + !Number.isInteger(debitFen) || + !Number.isInteger(creditFen) || debitFen < 0 || creditFen < 0 || (debitFen > 0 && creditFen > 0) || @@ -534,6 +858,12 @@ function normalizeLines(lines: JournalLineInput[]) { return normalized } +async function acquireFinancePeriodLock(client: PoolClient, periodKey: string) { + await client.query(`SELECT pg_advisory_xact_lock(hashtext('finance_period:' || $1)::bigint)`, [ + periodKey, + ]) +} + async function assertPeriodOpen(client: PoolClient, periodKey: string) { const result = await client.query<{ status: string }>( `SELECT status FROM finance_periods WHERE period_key = $1 LIMIT 1`, diff --git a/apps/backend/src/services/finance/finance-reconciliation-service.ts b/apps/backend/src/services/finance/finance-reconciliation-service.ts index 5b20f9e8..59e052e0 100644 --- a/apps/backend/src/services/finance/finance-reconciliation-service.ts +++ b/apps/backend/src/services/finance/finance-reconciliation-service.ts @@ -16,6 +16,10 @@ import type { AdminSession } from '../admin/admin-auth-service.js' type ReconciliationRowInput = { platformOrderId?: unknown platform_order_id?: unknown + platformItemId?: unknown + platform_item_id?: unknown + orderItemId?: unknown + order_item_id?: unknown entryType?: unknown entry_type?: unknown amountFen?: unknown @@ -75,13 +79,19 @@ export async function importFinanceReconciliation(payload: JsonObject, session: for (const [index, row] of rows.entries()) { const duplicateKey = row.entryType === 'sale' - ? `${row.platformOrderId}|sale` - : `${row.platformOrderId}|refund|${row.externalRef || row.amountFen}` + ? `${row.platformOrderId}|sale|${row.platformItemId || row.orderItemId || row.amountFen}` + : `${row.platformOrderId}|refund|${row.externalRef || row.orderItemId || row.amountFen}` const isDuplicate = seen.has(duplicateKey) seen.add(duplicateKey) const match = isDuplicate - ? { status: 'duplicate' as const, orderId: null, refundId: null, expected: null } + ? { + status: 'duplicate' as const, + orderId: null, + orderItemId: null, + refundId: null, + expected: null, + } : await matchRow(client, provider, row) const expected = match.expected const diff = expected === null ? null : row.amountFen - expected @@ -93,12 +103,13 @@ export async function importFinanceReconciliation(payload: JsonObject, session: await client.query( ` INSERT INTO finance_reconciliation_items ( - reconciliation_id, line_no, provider, platform_order_id, external_ref, - entry_type, amount_fen, occurred_at, matched_order_id, matched_refund_id, - expected_fen, diff_fen, match_status, detail_json, created_at + reconciliation_id, line_no, provider, platform_order_id, platform_item_id, + external_ref, entry_type, amount_fen, occurred_at, matched_order_id, + order_item_id, matched_refund_id, expected_fen, diff_fen, match_status, + detail_json, created_at ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8::timestamptz, $9, $10, - $11, $12, $13, $14::jsonb, $15 + $1, $2, $3, $4, $5, $6, $7, $8, $9::timestamptz, $10, + $11, $12, $13, $14, $15, $16::jsonb, $17 ) `, [ @@ -106,11 +117,13 @@ export async function importFinanceReconciliation(payload: JsonObject, session: index + 1, provider, row.platformOrderId, + row.platformItemId, row.externalRef, row.entryType, row.amountFen, row.occurredAt, match.orderId, + match.orderItemId, match.refundId, expected, diff, @@ -227,6 +240,8 @@ export async function getFinanceReconciliation(idInput: unknown) { id: Number(row.id), lineNo: Number(row.line_no), platformOrderId: String(row.platform_order_id || ''), + platformItemId: String(row.platform_item_id || ''), + orderItemId: row.order_item_id ? Number(row.order_item_id) : null, externalRef: String(row.external_ref || ''), entryType: String(row.entry_type || ''), amountFen: Number(row.amount_fen || 0), @@ -275,10 +290,51 @@ async function matchRow( ): Promise<{ status: 'matched' | 'amount_mismatch' | 'missing_order' | 'missing_refund' orderId: number | null + orderItemId: number | null refundId: number | null expected: number | null }> { if (row.entryType === 'sale') { + if (row.orderItemId) { + const item = await client.query<{ + id: number + order_id: number + revenue_fen: number + }>( + ` + SELECT + oi.id, + oi.order_id, + GREATEST(COALESCE(oi.revenue_amount_fen, 0), 0)::bigint AS revenue_fen + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + WHERE oi.id = $3 + AND o.provider = $1 + AND o.platform_order_id = $2 + LIMIT 1 + `, + [provider, row.platformOrderId, row.orderItemId], + ) + const foundItem = item.rows[0] + if (!foundItem) { + return { + status: 'missing_order', + orderId: null, + orderItemId: null, + refundId: null, + expected: null, + } + } + const expected = Number(foundItem.revenue_fen || 0) + return { + status: expected === row.amountFen ? 'matched' : 'amount_mismatch', + orderId: Number(foundItem.order_id), + orderItemId: Number(foundItem.id), + refundId: null, + expected, + } + } + const order = await client.query<{ id: number; total_amount: number }>( `SELECT id, total_amount FROM orders WHERE provider = $1 AND platform_order_id = $2 @@ -287,30 +343,50 @@ async function matchRow( ) const found = order.rows[0] if (!found) { - return { status: 'missing_order', orderId: null, refundId: null, expected: null } + return { + status: 'missing_order', + orderId: null, + orderItemId: null, + refundId: null, + expected: null, + } } const expected = Number(found.total_amount || 0) return { status: expected === row.amountFen ? 'matched' : 'amount_mismatch', orderId: Number(found.id), + orderItemId: null, refundId: null, expected, } } const params: unknown[] = [provider, row.platformOrderId] - let externalClause = '' + const clauses: string[] = [] if (row.externalRef) { params.push(row.externalRef) - externalClause = ` AND r.external_ref = $${params.length}` + clauses.push(`r.external_ref = $${params.length}`) + } else { + // 没有平台退款单号时,至少要求金额相同,避免把任意一笔退款误配到账单行。 + params.push(row.amountFen) + clauses.push(`r.amount_fen = $${params.length}`) } - const refund = await client.query<{ id: number; amount_fen: number }>( + if (row.orderItemId) { + params.push(row.orderItemId) + clauses.push(`r.order_item_id = $${params.length}`) + } + const refund = await client.query<{ + id: number + amount_fen: number + order_item_id: number + }>( ` - SELECT r.id, r.amount_fen + SELECT r.id, r.amount_fen, r.order_item_id FROM order_refunds r JOIN orders o ON o.id = r.order_id WHERE o.provider = $1 AND o.platform_order_id = $2 - AND r.status = 'confirmed' ${externalClause} + AND r.status = 'confirmed' + AND ${clauses.join(' AND ')} ORDER BY r.id DESC LIMIT 1 `, @@ -318,12 +394,19 @@ async function matchRow( ) const found = refund.rows[0] if (!found) { - return { status: 'missing_refund', orderId: null, refundId: null, expected: null } + return { + status: 'missing_refund', + orderId: null, + orderItemId: null, + refundId: null, + expected: null, + } } const expected = Number(found.amount_fen || 0) return { status: expected === row.amountFen ? 'matched' : 'amount_mismatch', orderId: null, + orderItemId: Number(found.order_item_id), refundId: Number(found.id), expected, } @@ -337,6 +420,23 @@ function normalizeRow(row: ReconciliationRowInput, provider: string, index: numb errorCode: 'finance_reconciliation_order_id_required', }) } + const platformItemId = String(row.platformItemId ?? row.platform_item_id ?? '').trim() + const rawOrderItemId = row.orderItemId ?? row.order_item_id + let orderItemId: number | null = null + if ( + rawOrderItemId !== undefined && + rawOrderItemId !== null && + String(rawOrderItemId).trim() !== '' + ) { + const parsed = Number(rawOrderItemId) + if (!Number.isInteger(parsed) || parsed <= 0) { + throw createHttpError(`第 ${index + 1} 行订单项 ID 无效`, { + statusCode: 400, + errorCode: 'finance_reconciliation_order_item_invalid', + }) + } + orderItemId = parsed + } const entryType = String(row.entryType ?? row.entry_type ?? 'sale').trim() if (entryType !== 'sale' && entryType !== 'refund') { throw createHttpError(`第 ${index + 1} 行类型只能是 sale/refund`, { @@ -362,6 +462,8 @@ function normalizeRow(row: ReconciliationRowInput, provider: string, index: numb return { provider, platformOrderId, + platformItemId, + orderItemId, entryType, amountFen, externalRef: String(row.externalRef ?? row.external_ref ?? '').trim(), 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 76658531..3c49ec53 100644 --- a/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts +++ b/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts @@ -127,9 +127,9 @@ test('人工成本缺失必须进入异常队列,且只监控首次配置之 ) }) -test('迁移 108 提供订单项级人工成本唯一约束与 subject 语义约束', async () => { +test('迁移 104 提供订单项级人工成本唯一约束与 subject 语义约束', async () => { const source = await readFile( - new URL('../../db/migrations/108_finance_cost_integrity.sql', import.meta.url), + new URL('../../db/migrations/104_finance_controls.sql', import.meta.url), 'utf8', ) assert.ok(source.includes('uq_cost_snapshots_manual_order_item'), '缺少订单项级人工快照唯一索引') @@ -146,10 +146,7 @@ test('迁移 108 提供订单项级人工成本唯一约束与 subject 语义约 test('总账/对账/关账/平台退款能力已接入', async () => { const [migration, ledger, reconciliation, refund, manual] = await Promise.all([ - readFile( - new URL('../../db/migrations/108_finance_cost_integrity.sql', import.meta.url), - 'utf8', - ), + readFile(new URL('../../db/migrations/104_finance_controls.sql', import.meta.url), 'utf8'), readFile(new URL('./finance-ledger-service.ts', import.meta.url), 'utf8'), readFile(new URL('./finance-reconciliation-service.ts', import.meta.url), 'utf8'), readFile(new URL('./order-refund-service.ts', import.meta.url), 'utf8'), diff --git a/apps/backend/src/services/finance/manual-fulfillment-service.ts b/apps/backend/src/services/finance/manual-fulfillment-service.ts index d8928780..6655b125 100644 --- a/apps/backend/src/services/finance/manual-fulfillment-service.ts +++ b/apps/backend/src/services/finance/manual-fulfillment-service.ts @@ -65,6 +65,7 @@ type ConfirmContextRow = { sku_code: string sku_name: string revenue_fen: string + basis_at: string canonical_product_id: number | null fulfillment_mode: string config_id: number | null @@ -216,6 +217,7 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm oi.sku_code, oi.sku_name, COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen, + COALESCE(o.paid_at, o.created_at) AS basis_at, oi.canonical_product_id, oi.fulfillment_mode, oi.fulfillment_config_id AS config_id, @@ -254,6 +256,21 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm errorCode: 'manual_fulfillment_not_manual', }) } + + // 已关账期间不能再改成本事实;需要先反关账,避免“扣了钱但销售事实/总账没记”。 + const period = await client.query<{ status: string }>( + `SELECT p.status + FROM finance_periods p + WHERE p.period_key = to_char(($1::timestamptz AT TIME ZONE 'Asia/Shanghai'), 'YYYY-MM') + LIMIT 1`, + [row.basis_at], + ) + if (period.rows[0]?.status === 'closed') { + throw createHttpError('该订单所属期间已关账,请先反关账后再确认履约', { + statusCode: 409, + errorCode: 'manual_fulfillment_period_closed', + }) + } if (!row.config_id) { throw createHttpError('该商品还没有配置履约渠道,请先在成本中心配置', { statusCode: 409, 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 0521edbe..862de978 100644 --- a/apps/backend/src/services/finance/manual-fulfillment.integration.test.ts +++ b/apps/backend/src/services/finance/manual-fulfillment.integration.test.ts @@ -19,7 +19,7 @@ test( { skip: !TEST_DATABASE_URL ? '未设置 MANUAL_FULFILLMENT_TEST_DATABASE_URL' : false }, async () => { process.env.DATABASE_URL = TEST_DATABASE_URL - const { query, closeDb } = await import('../../db/client.js') + const { query, withTransaction, closeDb } = await import('../../db/client.js') const { confirmManualFulfillment } = await import('./manual-fulfillment-service.js') const { syncEstimatedOrderItemCostSnapshots } = await import('../cost-center/cost-snapshot-sync-service.js') @@ -57,13 +57,23 @@ test( ) const adminId = Number(admin.rows[0]?.id) adminIds.push(adminId) - const fundAccount = await query<{ id: number }>( - `INSERT INTO fund_accounts ( - account_no, account_type, owner_admin_user_id, balance_fen, status, created_at, updated_at - ) VALUES ($1, 'agent', $2, 10000, 'active', NOW(), NOW()) RETURNING id`, - [`FA-IT-${suffix}`, adminId], - ) - const fundAccountId = Number(fundAccount.rows[0]?.id) + const fundAccountId = await withTransaction(async (client) => { + const account = await client.query<{ id: number }>( + `INSERT INTO fund_accounts ( + account_no, account_type, owner_admin_user_id, balance_fen, status, created_at, updated_at + ) VALUES ($1, 'agent', $2, 10000, 'active', NOW(), NOW()) RETURNING id`, + [`FA-IT-${suffix}`, adminId], + ) + const id = Number(account.rows[0]?.id) + await client.query( + `INSERT INTO fund_entries ( + entry_no, account_id, entry_type, amount_fen, balance_after_fen, + occurred_at, created_by_admin_user_id, evidence_json, note, created_at + ) VALUES ($1, $2, 'opening', 10000, 10000, NOW(), NULL, '[]'::jsonb, '集成测试期初', NOW())`, + [`FE-IT-${suffix}`, id], + ) + return id + }) fundAccountIds.push(fundAccountId) const session = { @@ -420,6 +430,13 @@ test( ) const periodKey = todayDate.slice(0, 7) + // 故意制造的快照失败单尚未确认,关账门槛必须先拦住。 + await assert.rejects( + () => closeFinancePeriod(periodKey, session), + /不满足关账条件.*人工订单项未确认成本/, + ) + // 清掉失败单后,关账才允许通过。 + await confirmManualFulfillment({ orderItemId: failItem.orderItemId, costFen: 700 }, session) await closeFinancePeriod(periodKey, session) await assert.rejects( () => @@ -492,10 +509,15 @@ test( ]) } if (fundAccountIds.length > 0) { - await query(`DELETE FROM fund_entries WHERE account_id = ANY($1::bigint[])`, [ - fundAccountIds, - ]) - await query(`DELETE FROM fund_accounts WHERE id = ANY($1::bigint[])`, [fundAccountIds]) + await withTransaction(async (client) => { + await client.query(`SET LOCAL app.fund_balance_check = 'off'`) + await client.query(`DELETE FROM fund_entries WHERE account_id = ANY($1::bigint[])`, [ + fundAccountIds, + ]) + await client.query(`DELETE FROM fund_accounts WHERE id = ANY($1::bigint[])`, [ + fundAccountIds, + ]) + }) } if (creditAccountIds.length > 0) { await query(`DELETE FROM platform_credit_entries WHERE account_id = ANY($1::bigint[])`, [ diff --git a/apps/backend/src/services/finance/order-refund-service.ts b/apps/backend/src/services/finance/order-refund-service.ts index 7d26c7eb..2390050e 100644 --- a/apps/backend/src/services/finance/order-refund-service.ts +++ b/apps/backend/src/services/finance/order-refund-service.ts @@ -7,6 +7,8 @@ * * 后续接平台退款 API 时,只需写 source='api'/'platform' 的记录,其余逻辑复用。 */ +import type { PoolClient } from 'pg' + import { query, withTransaction } from '../../db/client.js' import type { AdminSession } from '../admin/admin-auth-service.js' import { createHttpError } from '../../utils/http.js' @@ -140,6 +142,7 @@ export async function createManualRefund( errorCode: 'order_refund_order_not_found', }) } + await assertBasisPeriodOpen(client, order.basis_at, '退款') const itemsResult = await client.query<{ id: number @@ -203,38 +206,63 @@ export async function createManualRefund( }) } + if (externalRef) { + const duplicate = await client.query<{ id: number }>( + `SELECT id FROM order_refunds + WHERE source = $1 AND external_ref = $2 + LIMIT 1`, + [source, externalRef], + ) + if (duplicate.rows[0]) { + throw createHttpError('该平台退款单号已登记过,不能重复退款', { + statusCode: 409, + errorCode: 'order_refund_external_ref_exists', + }) + } + } + const batchNo = generateRefundNo(new Date(refundedAt)) const insertedIds: number[] = [] for (const [index, row] of rows.entries()) { - const inserted = await client.query<{ id: number }>( - ` - INSERT INTO order_refunds ( - refund_no, batch_no, order_id, order_item_id, amount_fen, - reason, note, source, status, refunded_at, external_ref, - operator_admin_user_id, created_at, updated_at - ) VALUES ( - $1, $2, $3, $4, $5, - $6, $7, $8, 'confirmed', $9::timestamptz, $10, - $11, $12::timestamptz, $12::timestamptz - ) - RETURNING id - `, - [ - `${batchNo}-${String(index + 1).padStart(2, '0')}`, - batchNo, - orderId, - row.itemId, - row.amountFen, - reason, - note, - source, - refundedAt, - externalRef, - session?.userId || null, - now, - ], - ) - insertedIds.push(Number(inserted.rows[0]?.id || 0)) + try { + const inserted = await client.query<{ id: number }>( + ` + INSERT INTO order_refunds ( + refund_no, batch_no, order_id, order_item_id, amount_fen, + reason, note, source, status, refunded_at, external_ref, + operator_admin_user_id, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, + $6, $7, $8, 'confirmed', $9::timestamptz, $10, + $11, $12::timestamptz, $12::timestamptz + ) + RETURNING id + `, + [ + `${batchNo}-${String(index + 1).padStart(2, '0')}`, + batchNo, + orderId, + row.itemId, + row.amountFen, + reason, + note, + source, + refundedAt, + externalRef, + session?.userId || null, + now, + ], + ) + insertedIds.push(Number(inserted.rows[0]?.id || 0)) + } catch (error) { + if ((error as { code?: string }).code === '23505') { + throw createHttpError('该平台退款单号已登记过,不能重复退款', { + statusCode: 409, + errorCode: 'order_refund_external_ref_exists', + }) + } + throw error + } } // 订单项都退完 → 订单级 pay_status 同步为 refunded(与平台全额退款口径一致)。 @@ -327,10 +355,11 @@ export async function voidOrderRefund( id: number order_id: number status: string + pay_status: string basis_at: string }>( ` - SELECT r.id, r.order_id, r.status, COALESCE(o.paid_at, o.created_at) AS basis_at + SELECT r.id, r.order_id, r.status, o.pay_status, COALESCE(o.paid_at, o.created_at) AS basis_at FROM order_refunds r JOIN orders o ON o.id = r.order_id WHERE r.id = $1 @@ -351,6 +380,7 @@ export async function voidOrderRefund( errorCode: 'order_refund_already_voided', }) } + await assertBasisPeriodOpen(client, row.basis_at, '撤销退款') await client.query( ` UPDATE order_refunds @@ -363,6 +393,50 @@ export async function voidOrderRefund( `, [now, reason, session?.userId || null, id], ) + + // 撤销后按“剩余 confirmed 退款”重算订单支付状态: + // 全额退款撤销必须把订单从 refunded 恢复为 paid,否则销售事实仍会按全额退款冲减。 + const totals = await client.query<{ revenue_fen: number; refunded_fen: number }>( + ` + SELECT + COALESCE(SUM(GREATEST(COALESCE(oi.revenue_amount_fen, 0), 0)), 0)::bigint AS revenue_fen, + COALESCE(( + SELECT SUM(r.amount_fen) + FROM order_refunds r + 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 + WHERE oi.order_id = $1 + `, + [row.order_id], + ) + const totalRevenue = Number(totals.rows[0]?.revenue_fen || 0) + const totalRefunded = Number(totals.rows[0]?.refunded_fen || 0) + let nextPayStatus = row.pay_status + if (totalRevenue > 0) { + nextPayStatus = totalRefunded >= totalRevenue ? 'refunded' : 'paid' + } + await client.query( + `UPDATE orders SET pay_status = $1, updated_at = $2::timestamptz WHERE id = $3`, + [nextPayStatus, now, row.order_id], + ) + + // 退款时自动关闭的成本异常,撤销后重新打开,避免成本缺口被永久忽略。 + await client.query( + ` + UPDATE cost_exceptions + SET status = 'open', + resolved_at = NULL, + resolved_by_admin_user_id = NULL, + resolution_note = '' + WHERE status = 'ignored' + AND resolution_note = '订单已退款,成本异常自动关闭' + AND order_item_id IN (SELECT id FROM order_items WHERE order_id = $1) + `, + [row.order_id], + ) + return { orderId: Number(row.order_id), basisAt: row.basis_at } }) @@ -404,6 +478,26 @@ export function mapOrderRefund(row: OrderRefundRow): OrderRefund { } } +async function assertBasisPeriodOpen( + client: Pick, + basisAt: string, + action: string, +) { + const basisDate = toChinaDate(basisAt) + if (!basisDate) return + const periodKey = basisDate.slice(0, 7) + const period = await client.query<{ status: string }>( + `SELECT status FROM finance_periods WHERE period_key = $1 LIMIT 1`, + [periodKey], + ) + if (period.rows[0]?.status === 'closed') { + throw createHttpError(`订单所属期间 ${periodKey} 已关账,请先反关账后再${action}`, { + statusCode: 409, + errorCode: 'order_refund_period_closed', + }) + } +} + function normalizeId(value: unknown, label: string): number { const id = Number(value || 0) if (!Number.isFinite(id) || id <= 0) { @@ -484,16 +578,6 @@ export async function approveAndRecordKuaishouRefund( errorCode: 'platform_refund_order_item_required', }) } - const itemExist = await query<{ id: number }>( - `SELECT id FROM order_items WHERE id = $1 AND order_id = $2 LIMIT 1`, - [orderItemId, orderId], - ) - if (!itemExist.rows[0]) { - throw createHttpError('订单项不存在或不属于该订单', { - statusCode: 404, - errorCode: 'platform_refund_order_item_not_found', - }) - } const order = await query<{ provider: string; platform_order_id: string }>( `SELECT provider, platform_order_id FROM orders WHERE id = $1 LIMIT 1`, [orderId], @@ -519,27 +603,228 @@ export async function approveAndRecordKuaishouRefund( errorCode: 'platform_refund_amount_invalid', }) } - const approval = await approveKuaishouIndustryRefund({ - refundId: platformRefundId, - refundAmount: amountFen, - desc: input.desc || input.note || '', - refundHandingWay: input.refundHandingWay, - ...(input.sellerId ? { sellerId: input.sellerId } : {}), - }) - const refund = await createManualRefund( - { - orderId, - orderItemId, - amountFen, - reason: input.reason || '平台退款', - note: input.note, - refundedAt: input.refundedAt, - source: 'platform', - externalRef: platformRefundId, - }, - session, + + const existingItem = await query<{ id: number }>( + `SELECT id FROM order_items WHERE id = $1 AND order_id = $2 LIMIT 1`, + [orderItemId, orderId], ) - return { approval, ...refund } + if (!existingItem.rows[0]) { + throw createHttpError('订单项不存在或不属于该订单', { + statusCode: 404, + errorCode: 'platform_refund_order_item_not_found', + }) + } + + // 已关账期间不能直接改原期间事实;必须先反关账,否则平台退款后本地无法正确冲减。 + const basis = await query<{ basis_at: string }>( + `SELECT COALESCE(paid_at, created_at) AS basis_at FROM orders WHERE id = $1 LIMIT 1`, + [orderId], + ) + if (basis.rows[0]?.basis_at) { + const basisDate = toChinaDate(String(basis.rows[0].basis_at)) + const periodKey = basisDate.slice(0, 7) + const period = await query<{ status: string }>( + `SELECT status FROM finance_periods WHERE period_key = $1 LIMIT 1`, + [periodKey], + ) + if (period.rows[0]?.status === 'closed') { + throw createHttpError(`订单所属期间 ${periodKey} 已关账,请先反关账后再退款`, { + statusCode: 409, + errorCode: 'order_refund_period_closed', + }) + } + } + + // 已经登记过的平台退款必须幂等返回,不能因为剩余可退为 0 被误判为超额。 + const recorded = await findRecordedPlatformRefund(orderId, platformRefundId) + if (recorded) { + return { + approval: { idempotent: true, platformRefundId }, + batchNo: recorded.batchNo, + refunds: [recorded], + fullRefunded: false, + closedExceptionCount: 0, + } + } + + // 先做可退金额校验,再落退款意图;平台调用之后不再做“才发现金额超了”的校验。 + const remainingFen = await getRefundableFen(orderId, orderItemId) + if (amountFen > remainingFen) { + throw createHttpError(`退款金额超出可退金额(剩余 ${formatFen(remainingFen)})`, { + statusCode: 409, + errorCode: 'order_refund_amount_exceeds', + }) + } + + const intent = await beginPlatformRefundIntent({ + provider: '91kaquan', + externalRef: platformRefundId, + orderId, + orderItemId, + amountFen, + actorUserId: session?.userId || null, + }) + + const needsPlatformCall = + intent.status === 'pending' || + (intent.status === 'failed' && !String(intent.error_message || '').startsWith('record_failed:')) + + let approval: unknown = { idempotent: true, platformRefundId } + if (needsPlatformCall) { + try { + approval = await approveKuaishouIndustryRefund({ + refundId: platformRefundId, + refundAmount: amountFen, + desc: input.desc || input.note || '', + refundHandingWay: input.refundHandingWay, + ...(input.sellerId ? { sellerId: input.sellerId } : {}), + }) + await updatePlatformRefundIntent(intent.id, 'platform_approved') + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + await updatePlatformRefundIntent(intent.id, 'failed', `platform_call_failed:${message}`) + throw error + } + } + + try { + const refund = await createManualRefund( + { + orderId, + orderItemId, + amountFen, + reason: input.reason || '平台退款', + note: input.note, + refundedAt: input.refundedAt, + source: 'platform', + externalRef: platformRefundId, + }, + session, + ) + await updatePlatformRefundIntent(intent.id, 'recorded') + return { approval, ...refund } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + await updatePlatformRefundIntent(intent.id, 'failed', `record_failed:${message}`) + throw createHttpError( + `平台已同意退款,但本地记账失败:${message}。已保留退款意图,可用同一 platformRefundId 重试`, + { + statusCode: 502, + errorCode: 'platform_refund_record_failed', + }, + ) + } +} + +type PlatformRefundIntentRow = { + id: number + status: string + error_message: string +} + +async function beginPlatformRefundIntent(input: { + provider: string + externalRef: string + orderId: number + orderItemId: number + amountFen: number + actorUserId: number | null +}): Promise { + return withTransaction(async (client) => { + const existing = await client.query( + `SELECT id, status, error_message + FROM finance_refund_intents + WHERE provider = $1 AND external_ref = $2 + FOR UPDATE`, + [input.provider, input.externalRef], + ) + if (existing.rows[0]) return existing.rows[0] + + try { + const inserted = await client.query( + ` + INSERT INTO finance_refund_intents ( + provider, external_ref, order_id, order_item_id, amount_fen, + status, error_message, created_by_admin_user_id, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, 'pending', '', $6, $7, $7) + RETURNING id, status, error_message + `, + [ + input.provider, + input.externalRef, + input.orderId, + input.orderItemId, + input.amountFen, + input.actorUserId, + nowIso(), + ], + ) + return inserted.rows[0] as PlatformRefundIntentRow + } catch (error) { + // 并发请求同时插入时,唯一键冲突后读取已存在的意图,由调用方按状态决定是否重试。 + if ((error as { code?: string }).code === '23505') { + const again = await client.query( + `SELECT id, status, error_message + FROM finance_refund_intents + WHERE provider = $1 AND external_ref = $2 + LIMIT 1`, + [input.provider, input.externalRef], + ) + return again.rows[0] as PlatformRefundIntentRow + } + throw error + } + }) +} + +async function updatePlatformRefundIntent( + id: number, + status: 'platform_approved' | 'recorded' | 'failed', + errorMessage = '', +) { + await query( + `UPDATE finance_refund_intents + SET status = $2, error_message = $3, updated_at = $4 + WHERE id = $1`, + [id, status, errorMessage.slice(0, 1000), nowIso()], + ) +} + +async function getRefundableFen(orderId: number, orderItemId: number): Promise { + const result = await query<{ remaining_fen: string }>( + ` + SELECT + (GREATEST(COALESCE(oi.revenue_amount_fen, 0), 0) + - COALESCE(( + SELECT SUM(r.amount_fen) + FROM order_refunds r + WHERE r.order_item_id = oi.id AND r.status = 'confirmed' + ), 0))::bigint AS remaining_fen + FROM order_items oi + WHERE oi.id = $1 AND oi.order_id = $2 + LIMIT 1 + `, + [orderItemId, orderId], + ) + if (!result.rows[0]) { + throw createHttpError('订单项不存在或不属于该订单', { + statusCode: 404, + errorCode: 'platform_refund_order_item_not_found', + }) + } + return Number(result.rows[0].remaining_fen || 0) +} + +async function findRecordedPlatformRefund(orderId: number, externalRef: string) { + const result = await query<{ id: number }>( + `SELECT id FROM order_refunds + WHERE order_id = $1 AND source = 'platform' AND external_ref = $2 AND status = 'confirmed' + LIMIT 1`, + [orderId, externalRef], + ) + if (!result.rows[0]) return null + const refunds = await listOrderRefunds(orderId) + return refunds.find((item) => item.id === Number(result.rows[0]?.id)) || null } function normalizeRefundSource(value: unknown): 'manual' | 'api' | 'platform' | 'legacy' { diff --git a/apps/backend/src/services/finance/sales-facts-service.ts b/apps/backend/src/services/finance/sales-facts-service.ts index 15d9ec51..eda5852f 100644 --- a/apps/backend/src/services/finance/sales-facts-service.ts +++ b/apps/backend/src/services/finance/sales-facts-service.ts @@ -14,6 +14,7 @@ export type SalesFactsRebuildResult = { range: { fromDate: string; toDate: string } deletedCount: number insertedCount: number + skippedClosedPeriods: string[] computedAt: string } @@ -50,9 +51,26 @@ export async function rebuildSalesDailyFacts( const range = resolveFinanceDateRange(input, { defaultDays: 30, maxDays: 400 }) const computedAt = nowIso() + // 已关账期间必须冻结:只重算开放期间,避免“总账已关账、报表事实还在变”。 + const closedPeriods = await query<{ period_key: string }>( + `SELECT period_key + FROM finance_periods + WHERE status = 'closed' + AND period_key BETWEEN $1 AND $2 + ORDER BY period_key`, + [range.fromDate.slice(0, 7), range.toDate.slice(0, 7)], + ) + const skippedClosedPeriods = closedPeriods.rows.map((row) => row.period_key) + return withTransaction(async (client) => { const deleted = await client.query( - `DELETE FROM sales_daily_facts WHERE stat_date BETWEEN $1::date AND $2::date`, + `DELETE FROM sales_daily_facts f + WHERE f.stat_date BETWEEN $1::date AND $2::date + AND NOT EXISTS ( + SELECT 1 FROM finance_periods p + WHERE p.status = 'closed' + AND p.period_key = to_char(f.stat_date, 'YYYY-MM') + )`, [range.fromDate, range.toDate], ) @@ -103,6 +121,14 @@ export async function rebuildSalesDailyFacts( WHERE 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 + AND NOT EXISTS ( + SELECT 1 FROM finance_periods p + WHERE p.status = 'closed' + AND p.period_key = to_char( + (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date, + 'YYYY-MM' + ) + ) ) INSERT INTO sales_daily_facts ( stat_date, provider, shop_id, canonical_product_id, fulfillment_mode, @@ -146,6 +172,7 @@ export async function rebuildSalesDailyFacts( range, deletedCount: deleted.rowCount || 0, insertedCount: inserted.rowCount || 0, + skippedClosedPeriods, computedAt, } }) @@ -163,6 +190,7 @@ export async function getSalesProfitOverview(input: SalesFactsInput = {}) { net_revenue_fen: number cost_amount_fen: number cost_missing_count: number + cost_missing_revenue_fen: number gross_profit_fen: number unmapped_revenue_fen: number }>( @@ -174,6 +202,9 @@ export async function getSalesProfitOverview(input: SalesFactsInput = {}) { COALESCE(SUM(net_revenue_fen), 0)::bigint AS net_revenue_fen, COALESCE(SUM(cost_amount_fen), 0)::bigint AS cost_amount_fen, COALESCE(SUM(cost_missing_count), 0)::int AS cost_missing_count, + COALESCE( + SUM(net_revenue_fen) FILTER (WHERE cost_missing_count > 0), 0 + )::bigint AS cost_missing_revenue_fen, COALESCE(SUM(gross_profit_fen), 0)::bigint AS gross_profit_fen, COALESCE(SUM(gross_revenue_fen) FILTER (WHERE canonical_product_id IS NULL), 0)::bigint AS unmapped_revenue_fen @@ -301,6 +332,7 @@ export async function getSalesProfitOverview(input: SalesFactsInput = {}) { netRevenueFen: Number(summary?.net_revenue_fen || 0), costAmountFen: Number(summary?.cost_amount_fen || 0), costMissingCount: Number(summary?.cost_missing_count || 0), + costMissingRevenueFen: Number(summary?.cost_missing_revenue_fen || 0), grossProfitFen: Number(summary?.gross_profit_fen || 0), unmappedRevenueFen: unmappedRevenue, unmappedRevenuePct: diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index ad2362aa..c98b697b 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -1701,12 +1701,12 @@ test('拼单份数全部承接后不再显示在抢单大厅', () => { /** * 同一打手可以多次拼单(需求),所以「取消后重新加入」不再靠复用原份额行: - * 迁移 109 删掉了「一人一条」唯一键,每次参与都插入新份额,取消的历史份额保留追溯。 + * 迁移 105 删掉了「一人一条」唯一键,每次参与都插入新份额,取消的历史份额保留追溯。 */ test('拼单份额取消后原打手可以重新参与(一人一条唯一键已移除)', async () => { const { readFile } = await import('node:fs/promises') const migration = await readFile( - new URL('../../db/migrations/109_work_order_share_participation.sql', import.meta.url), + new URL('../../db/migrations/105_work_order_consignment.sql', import.meta.url), 'utf8', ) assert.ok( diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 37934933..9cc0017b 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -144,11 +144,13 @@ function AdminApplication() { } /> }> } /> - } /> - } /> } /> } /> + }> + } /> + } /> + }> } /> } /> diff --git a/apps/frontend/src/layouts/AdminLayout.tsx b/apps/frontend/src/layouts/AdminLayout.tsx index 34041eb0..2ff839b1 100644 --- a/apps/frontend/src/layouts/AdminLayout.tsx +++ b/apps/frontend/src/layouts/AdminLayout.tsx @@ -214,6 +214,7 @@ export default function AdminLayout() { const operationItems: MenuProps['items'] = role === 'finance' ? [ + { key: '/admin/orders', icon: , label: '订单' }, financeSubMenu, { key: '/admin/worker-platform', icon: , label: '资金审核' }, ] diff --git a/apps/frontend/src/pages/admin/AdminFinanceLedgerPage.tsx b/apps/frontend/src/pages/admin/AdminFinanceLedgerPage.tsx index 8812d2cf..936f77cb 100644 --- a/apps/frontend/src/pages/admin/AdminFinanceLedgerPage.tsx +++ b/apps/frontend/src/pages/admin/AdminFinanceLedgerPage.tsx @@ -11,6 +11,7 @@ import { Input, InputNumber, Modal, + Popconfirm, Row, Select, Space, @@ -78,7 +79,10 @@ const MATCH_STATUS_META: Record = { export default function AdminFinanceLedgerPage() { const { message } = App.useApp() const queryClient = useQueryClient() - const canConfig = hasAdminPermission('finance.config') + const canConfig = + hasAdminPermission('finance.post') || + hasAdminPermission('finance.close') || + hasAdminPermission('finance.reconcile') const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs().startOf('month'), dayjs()]) const [entryPage, setEntryPage] = useState(1) const [entryPageSize, setEntryPageSize] = useState(20) @@ -297,13 +301,27 @@ export default function AdminFinanceLedgerPage() { render: (_, row) => canConfig ? ( row.status === 'closed' ? ( - + reopenMutation.mutate(row.periodKey)} + > + + ) : ( - + closeMutation.mutate(row.periodKey)} + > + + ) ) : null, }, diff --git a/apps/frontend/src/pages/admin/AdminFinancePage.tsx b/apps/frontend/src/pages/admin/AdminFinancePage.tsx index 3c1ba94a..00592529 100644 --- a/apps/frontend/src/pages/admin/AdminFinancePage.tsx +++ b/apps/frontend/src/pages/admin/AdminFinancePage.tsx @@ -162,7 +162,17 @@ export default function AdminFinancePage() { return (
- + + + diff --git a/apps/frontend/src/pages/admin/AdminOrderDetailPage.tsx b/apps/frontend/src/pages/admin/AdminOrderDetailPage.tsx index adfe5b76..82a2c690 100644 --- a/apps/frontend/src/pages/admin/AdminOrderDetailPage.tsx +++ b/apps/frontend/src/pages/admin/AdminOrderDetailPage.tsx @@ -289,7 +289,7 @@ export default function AdminOrderDetailPage() { title="基础信息" extra={ - {hasAdminPermission('finance.config') ? ( + {hasAdminPermission('finance.post') ? ( diff --git a/apps/frontend/src/pages/admin/AdminSalesProfitPage.tsx b/apps/frontend/src/pages/admin/AdminSalesProfitPage.tsx index 44e977fb..9c10ddb1 100644 --- a/apps/frontend/src/pages/admin/AdminSalesProfitPage.tsx +++ b/apps/frontend/src/pages/admin/AdminSalesProfitPage.tsx @@ -60,7 +60,7 @@ const COST_STAGE_LABEL: Record = { export default function AdminSalesProfitPage() { const { message } = App.useApp() const [form] = Form.useForm() - const canConfig = hasAdminPermission('finance.config') + const canConfig = hasAdminPermission('finance.post') const defaultDateFrom = dayjs().subtract(29, 'day').format('YYYY-MM-DD') const defaultDateTo = dayjs().format('YYYY-MM-DD') const [filters, setFilters] = useState<{ @@ -315,7 +315,7 @@ export default function AdminSalesProfitPage() { ) : null} diff --git a/apps/frontend/src/types/admin/finance.ts b/apps/frontend/src/types/admin/finance.ts index e9dd72a7..72039c4d 100644 --- a/apps/frontend/src/types/admin/finance.ts +++ b/apps/frontend/src/types/admin/finance.ts @@ -78,6 +78,7 @@ export type AdminSalesProfitOverview = { netRevenueFen: number costAmountFen: number costMissingCount: number + costMissingRevenueFen: number grossProfitFen: number unmappedRevenueFen: number unmappedRevenuePct: number diff --git a/docs/README.md b/docs/README.md index c0712bda..4a4f2928 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,7 @@ # order_site 文档索引 -> 最后校准:2026-10-26,代码基线 `dev`(合并 `dev1023` + 人工成本完整性修复,迁移基线收敛为 `103`–`111`;**未部署生产**)。 -> 数据基线:生产 dump `backups/postgres/order_site-20261022-092601.dump`(数据 2026-07-10 ~ 2026-10-22);测试/本地旧编号已到 115,生产按收敛后的 101 → 111 路径执行。历史全量数字仅作基线,**上线验收以 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 为准**。 +> 最后校准:2026-10-27,代码基线 `dev`(合并 `dev1023` + 人工成本完整性修复 + 财务 P0 控制,迁移基线收敛为 `103`–`105`;**未部署生产**)。 +> 数据基线:生产 dump `backups/postgres/order_site-20261022-092601.dump`(数据 2026-07-10 ~ 2026-10-22);本地已校准到 105(103 财务基础、104 财务控制/L1、105 拼单/寄卖客服),生产按收敛后的 102 → 105 路径执行。历史全量数字仅作基线,**上线验收以 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 为准**。 > 本文是 `docs/` 的唯一入口。**财务与履约口径以 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) 为准**;数据模型看 [成本中心与履约模型.md](成本中心与履约模型.md),接口看 [成本中心API契约.md](成本中心API契约.md)。 > 新增文档必须在下表登记状态;过期文档不再直接改写结论,统一移入 `docs/archive/`。 @@ -53,7 +53,7 @@ | 名称 | 业务含义 | 当前状态 | | --- | --- | --- | | `manual_expenses` | **客服备用金支出明细**:客服从自己的备用金里充值/采购/赔偿,减少客服余额 | 已实施(迁移 100);确认履约的现金支出也写这里 | -| `cost_snapshots` | **人工成本的唯一落点**:`subject_type='manual_case'`,`subject_id=order_item_id`(资金来源写 metadata) | 已实施(迁移 104 + 111) | +| `cost_snapshots` | **人工成本的唯一落点**:`subject_type='manual_case'`,`subject_id=order_item_id`(资金来源写 metadata) | 已实施(迁移 104) | **没有独立的登记单,也没有审批与报销环节。** `manual_expense_records` / `manual_expense_events` 已随迁移 `104` 归并下线(这两张表在生产 102 上从未创建)。后续做财务系统时,人工成本的钱与账都从这条链路走,不要再另造一张人工支出表。 @@ -79,28 +79,28 @@ | 客服备用金台账 | **已实施 P0** | 迁移 `100/101/107`;账户、流水、支出、对账;编号 `FA-<用户名>`;客服只能使用本人账户 | | 后台用户中文显示名 | **已实施** | `admin_users.display_name`,备用金姓名触发器同步;审计、登录记录、任务处理人优先显示中文名 | | 商品主数据 + 渠道 SKU 映射 | **已实施 P0/P1** | 2,017 个标准商品、4,897 条映射:`tpl:` 710、`man:` 1,195、`aff:` 49、`wo:` 63;订单项已按履约证据/人工内部 SKU 归一 | -| 成本中心 | **已实施 P0** | 迁移 `104`;710 个规则级版本 + 2 个全局占位版本;工单成本快照 151,939 条,其中 actual 129,430、estimated 22,509 | -| 销售事实 / 订单毛利 | **已实施 P0** | 迁移 `105`;375,447 项、收入 ¥3,143.61 万、成本 ¥572.95 万、毛利 ¥2,570.66 万;`sales_daily_facts.fulfillment_mode` | +| 成本中心 | **已实施 P0** | 迁移 `103`;710 个规则级版本 + 2 个全局占位版本;工单成本快照 151,939 条,其中 actual 129,430、estimated 22,509 | +| 销售事实 / 订单毛利 | **已实施 P0** | 迁移 `103`;375,447 项、收入 ¥3,143.61 万、成本 ¥572.95 万、毛利 ¥2,570.66 万;`sales_daily_facts.fulfillment_mode` | | 人工成本录入(确认履约) | **已实施 P0(2026-10-26 单事务 + 防重复)** | 确认即扣积分/备用金并写 `actual` 快照;钱与成本同事务、订单项级唯一;无登记单/审批/报销 | | 人工 SKU 配置自动匹配 | **已实施(2026-10-26)** | 保存配置立即回填 + 定时 rematch + 确认时按 SKU 兜底匹配 | | 人工缺成本监控 | **已实施(2026-10-26)** | `manual_cost_missing`:首次人工配置之后的订单项未确认即入队,确认后自动收口 | -| 履约方式统一 | **已实施 P0** | 迁移 `105`;375,447 项全部回填:接单平台 153,148、人工 115,194、历史平台 53,901、自动发货 53,204 | +| 履约方式统一 | **已实施 P0** | 迁移 `103`;375,447 项全部回填:接单平台 153,148、人工 115,194、历史平台 53,901、自动发货 53,204 | | 人工商品归一 | **已实施 P1(本次快照恢复执行)** | 人工 115,194 项已归一为 1,195 个 `man:<内部SKU>` 商品;历史人工支出仍不补录 | | 影子总账 | **已实施 P0(2026-10-26)** | `finance_accounts/journal_entries/journal_lines/periods`;销售事实按日凭证 + 手工凭证 + 试算平衡;不接管钱包余额 | | 成本异常归档 | **已实施** | 迁移 `108`;已处理异常超 60 天搬入 `cost_exceptions_archive`,open 行不归档;`COST_EXCEPTION_RETENTION_DAYS` 可调 | | 平台对账 / 期间关账 | **已实施 P0** | 平台账单 JSON 导入匹配、差异明细、标记解决;关账/反关账;见 `/admin/finance/ledger` | | 平台退款 API | **部分实施** | 快手行业(91)同意退款已接入;抖音/拼多多暂未接 API,仍手工登记 | -> 分支状态(2026-10-26):`dev` 已合并 `dev1023` 与人工成本完整性修复,迁移基线收敛为 `103`–`111`(原 108–115 的财务/性能补丁合并进 108);临时库从 001 全量执行与从 101 升级均待验收。**未推送、未部署生产**;生产口径仍以 `main` 为准。上线前按 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 初始化资金与人工 SKU。 +> 分支状态(2026-10-27):`dev` 已合并 `dev1023` 与人工成本完整性修复,迁移收敛为 `103`–`105`(103 财务基础、104 财务控制/L1、105 拼单/寄卖客服);已验证 001→105 与 102→105 两条升级路径。**未推送、未部署生产**;生产口径仍以 `main` 为准。上线前按 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 初始化资金与人工 SKU。 > 财务与履约口径以 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) 为准。 ## 三、统一实施顺序 ```text -0. 统一履约事实(fulfillment_mode + 成本模型正交) 已完成 P0(迁移 105) +0. 统一履约事实(fulfillment_mode + 成本模型正交) 已完成 P0(迁移 103) 1. 商品主数据 + 渠道 SKU 映射 已完成 P0(仅自动发货/接单平台) 2. 成本中心 + 销售事实 已完成 P0 -2.5 人工成本链路收敛(确认履约 → 扣积分/备用金 → 写快照) 已完成 P0(迁移 104/107/111) +2.5 人工成本链路收敛(确认履约 → 扣积分/备用金 → 写快照) 已完成 P0(迁移 104) 3. 人工商品归一与上线后人工成本全覆盖 已完成代码;生产数据需上线后配置 SKU(历史不补录) 4. 退款冲减与异常队列 已完成手工退款 + 快手平台退款;人工缺成本已入队 5. 影子总账(销售事实 + 手工凭证) 已完成 P0(迁移 108) @@ -129,11 +129,15 @@ | 文档 | 状态 | 用途 | | --- | --- | --- | | [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) | **现行唯一口径** | 履约方式、成本模型、人工全覆盖、历史策略、实施顺序、运营日检与验收 SQL | -| [成本中心与履约模型.md](成本中心与履约模型.md) | 现行 | 商品、成本版本、快照、异常、销售事实的数据模型;迁移 `103`–`111`;履约回填;迁移基线与本地库同步;索引与性能 | +| [成本中心与履约模型.md](成本中心与履约模型.md) | 现行 | 商品、成本版本、快照、异常、销售事实的数据模型;迁移 `103`–`105`;履约回填;迁移基线与本地库同步;索引与性能 | | [成本中心API契约.md](成本中心API契约.md) | 现行 | 成本中心接口字段替换总表、枚举、模块边界(后端 5 模块 + 前端 4 个固定 Tab) | | [人工支出与备用金.md](人工支出与备用金.md) | 现行(P0 已实施) | 确认履约流程/权限、客服备用金台账、本人账户与编号规则 | | [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) | **现行上线手册** | 迁移后资金/人工 SKU 初始化、验收 SQL、日常监控 | | [财务系统集成方案.md](财务系统集成方案.md) | 现行方案(影子总账 P0 已实施) | 复式记账、总账表、对账、关账;P1/P2 平台自动对账待接 | +| [财务系统深度分析-2026-10-27.md](财务系统深度分析-2026-10-27.md) | 参考(一次性审计,不改变现行口径) | 2026-10-27 代码/数据/权限审计、风险清单与上线前修复路线 | +| [dev合并main评估-2026-10-27.md](dev合并main评估-2026-10-27.md) | 参考(合并评估) | dev 领先 main 的改动、迁移收敛、生产规模升级实测与发布检查清单 | +| [财务代码审计复核-2026-10-27.md](财务代码审计复核-2026-10-27.md) | 参考(代码复核) | 对“旧报表重复扣减、总账 DELETE、钱包 BIGINT、死锁”等结论的逐条校正与补充 | +| [打手重复奖励核对-2026-10-27.md](打手重复奖励核对-2026-10-27.md) | 待人工核对 | 8 组疑似重复奖励、16 条流水、多计 ¥196.20;含 84886 寄卖误报说明 | | [影子总账说明.md](影子总账说明.md) | 现行说明(P0 已实施) | 影子总账是什么、和钱包流水的区别、对账与转正标准 | | [项目全量分析报告-2026-10-17.md](项目全量分析报告-2026-10-17.md) | 历史参考 | 2026-10-17 架构与风险;财务口径以总纲为准 | | [接单模板匹配引擎重构设计.md](接单模板匹配引擎重构设计.md) | 现行(P0/P1 已实施) | `kuaishou-91 / pdd / douyin-mashida` 三来源匹配引擎 | diff --git a/docs/dev合并main评估-2026-10-27.md b/docs/dev合并main评估-2026-10-27.md new file mode 100644 index 00000000..1af5b22c --- /dev/null +++ b/docs/dev合并main评估-2026-10-27.md @@ -0,0 +1,119 @@ +# dev 合并 main 评估(2026-10-27) + +> 评估对象:`main`(2fb7aae1)与 `dev`(0de0fd7b + 当前未提交的财务 P0/P1 改动)。 +> 生产基线:生产库当前 `schema_migrations=102`;验证用 dump 恢复到 `101` 后再补 `102`。 +> 结论:**代码可以合并;生产发布还需要执行一组一次性数据初始化/回填,不能只合并就上线。** + +--- + +## 一、分支关系 + +```text +2fb7aae1 main + │ + └─ 99 commits ──► 0de0fd7b dev(本地另有未提交的财务 P0/P1 改动) +``` + +- `main` 是 `dev` 的祖先:`git rev-list --left-right --count main...dev` = `0 99`。 +- 没有反向提交,合并是 **fast-forward**,不会产生代码冲突。 +- `dev` 相对 `main`:286 个文件、约 40,828 行新增、2,142 行删除。 +- 改动集中在:财务/成本中心、人工履约、客服备用金/积分、影子总账、退款、拼单多次参与、后台财务导航、部署配置与文档。 + +## 二、迁移现状 + +| 项 | 结果 | +| --- | --- | +| `main` 迁移文件 | 102 个(001–102) | +| `dev` 迁移文件 | **105 个(001–105)** | +| 生产数据库 | 已到 `102` | +| 生产升级路径 | `102 → 105`,共 **3 个迁移文件** | +| 新增迁移文件 | 103–105,共 3 个 | + +### 本轮收敛结果 + +- [103_finance_foundation.sql](../apps/backend/src/db/migrations/103_finance_foundation.sql):商品目录、成本中心、销售事实、退款单 +- [104_finance_controls.sql](../apps/backend/src/db/migrations/104_finance_controls.sql):人工履约、影子总账、对账/关账、财务控制、L1 钱包硬化 +- [105_work_order_consignment.sql](../apps/backend/src/db/migrations/105_work_order_consignment.sql):拼单多次参与、大厅索引、撤份额注释、寄卖对接客服 + +原来的 103–114 共 12 个文件已删除,内容按领域合并进以上 3 个迁移;不再保留 110/111/112/113/114 这类几行的补丁文件。 + +### 为什么是 3 个而不是 1 个 + +- 103 是财务基础表,104 依赖 103 的商品/成本表,105 是独立的工单/寄卖域; +- 单文件会超过 2000 行,审查和排障成本高; +- 按领域拆成 3 个,生产升级仍是 3 个文件,比原来的 13 个大幅减少。 + +> 当前是测试环境,允许重写未上线迁移;生产库只到 102,不依赖旧 103–114 的迁移记录。 + +## 三、生产规模升级验证 + +用生产 dump 恢复到模拟库,先补到 `102`,再执行 `103 → 105`: + +| 检查 | 结果 | +| --- | --- | +| 迁移执行 | 103–105 全部成功,退出码 0 | +| 迁移总耗时 | 约 **26 秒**(单事务;本地 Docker,含大表改 BIGINT) | +| 订单 / 订单项 | 375,459 / 375,447,数据未丢 | +| 迁移后 `revenue_basis='unknown'` | 375,447(符合预期,需要回填) | +| 迁移后 `fulfillment_mode IS NULL` | 375,447(符合预期,需要回填) | +| 财务事实 | `sales_daily_facts=0`、`cost_snapshots=0`、`finance_journal_entries=0`(等待初始化/同步) | +| 备用金账户 | 0(生产 102 没有备用金数据) | +| 拼单份额 | 41,383 行,迁移成功 | +| 触发器等对象 | 104 的凭证/备用金/幂等/对账对象、钱包 CHECK/event_key/审计视图均存在;105 的寄卖客服表/索引存在 | + +### 回填实测 + +| 脚本 | 结果 | +| --- | --- | +| `npm run revenue:backfill` | 375,447 行全部回填,76 批,约 **83 秒** | +| `npm run fulfillment:backfill` | 375,447 行全部回填,76 批,约 **90 秒**;计数与文档一致(work_order 153,148 / manual 115,194 / legacy 53,901 / affiliate 53,204) | +| `npm run finance:sync -- --mode=facts --from=2026-10-01 --to=2026-10-22` | 489 行事实,约 2.6 秒;成本为 0,因为商品目录尚未初始化,符合当前阶段 | + +> 回填脚本并发执行会互相争 `order_items` 行锁,实测出现 1–3 秒/批的慢查询。 +> 生产应**串行执行**,并避开高峰。 + +## 四、合并/发布前必须处理 + +| # | 事项 | 说明 | +| --- | --- | --- | +| 1 | 提交当前未提交改动 | 当前 P0/P1 改动仍在工作区,先提交并跑 `check`,否则不能合并 | +| 2 | 迁移编号统一 | 已收敛为 103–105;旧 103–114 文件删除,生产只差 103–105 | +| 3 | 生产备份 | 部署脚本 `--backup`;迁移是单事务,失败可整体回滚 | +| 4 | 收入回填 | 迁移只给警告;不跑 `revenue:backfill` 会导致退款按钮置灰、销售事实收入为 0 | +| 5 | 履约方式回填 | 不跑 `fulfillment:backfill` 会导致历史订单项 `fulfillment_mode=NULL`,订单/成本页口径缺失 | +| 6 | 商品目录/成本版本初始化 | 生产模拟迁移后 `product_catalog=0`;必须先初始化商品、模板/自动发货/人工 SKU,才能生成成本快照 | +| 7 | 客服资金初始化 | 备用金账户、积分账户按上线手册初始化;当前生产 101 没有资金数据 | +| 8 | 财务历史同步 | 初始化后跑 `finance:sync --mode=all`(建议 30 天窗口分段),生成成本快照/销售事实/总账 | +| 9 | 发布窗口 | 迁移约 26 秒单事务锁(含大表改 BIGINT);建议低峰执行,观察 `orders/order_items/worker_wallet_ledgers` 锁等待 | +| 10 | 文档空白 | 已顺手清理 2 个历史文档尾部空行;提交后 `git diff --check main...dev` 应通过 | +| 11 | 历史重复结算 | 生产数据审计视图发现 8 组疑似重复 `reward_settlement`(另 1 组 84886 为寄卖货款误报),多计约 ¥196.20;需财务确认,不自动扣回 | + +## 五、建议的合并顺序 + +```bash +# 1. 提交当前 P0/P1 +git add apps/backend/src/db/migrations/103_finance_foundation.sql \ + apps/backend/src/db/migrations/104_finance_controls.sql \ + apps/backend/src/db/migrations/105_work_order_consignment.sql \ + apps/backend/src/services apps/backend/src/routes apps/frontend/src docs +git commit -m "feat(finance): 财务 P0/P1 控制与迁移收敛" + +# 2. 全量检查 +cd apps/backend && npm run check +cd ../frontend && npm run check + +# 3. fast-forward 合并 +git checkout main +git merge --ff-only dev +git push origin main +``` + +> 注意:以上是“代码合并”。生产发布还要执行第四节的 3–8;不要只 `docker compose up` 就开放财务页面。 + +## 六、结论 + +- **分支合并:可以。** `main` 是 `dev` 祖先,fast-forward 无冲突;代码与测试通过。 +- **生产发布:暂不建议“合并即上线”。** 迁移本身在生产规模数据上验证通过(102→105,约 26 秒), + 但收入/履约回填、商品目录/成本/人工 SKU/备用金初始化、财务历史同步必须按顺序完成, + 否则财务页面会显示空数据或毛利严重失真。 +- **迁移文件优化:103–114 已收敛为 103–105 三个领域迁移。** 生产库只到 102,升级路径只有 3 个文件;后续按“一次发布一个迁移主题”控制增量,未发布迁移在合并前 squash。 diff --git a/docs/工单拼单多次参与改造方案-2026-10-23.md b/docs/工单拼单多次参与改造方案-2026-10-23.md index ce3b024e..e3bd9801 100644 --- a/docs/工单拼单多次参与改造方案-2026-10-23.md +++ b/docs/工单拼单多次参与改造方案-2026-10-23.md @@ -426,4 +426,3 @@ FROM ( - 后台 `mapWorkOrderSharingWorkers` 仍按打手汇总一行(用于列表角标),明细操作走份额表,属设计保留。 - 5 项后端既有测试失败:`backup-db.sh` 在容器内缺 `/deploy` 文件;3 项为 CRLF 下源码字符串守卫断言;1 项 `http-cache` 路由守卫。均未触及本次改动文件,建议后续统一把源码守卫改成忽略行尾。 - diff --git a/docs/成本中心与履约模型.md b/docs/成本中心与履约模型.md index c41d9ef3..fb0eddb7 100644 --- a/docs/成本中心与履约模型.md +++ b/docs/成本中心与履约模型.md @@ -1,8 +1,8 @@ # 成本中心与履约模型 > 状态:**现行**。商品主数据、成本中心、成本快照、销售事实、履约事实均已落地。 -> 最后校准:2026-10-26 -> 代码基线:`dev`(合并 dev1023 + 人工成本完整性修复,迁移基线收敛为 `103`–`111`) +> 最后校准:2026-10-27 +> 代码基线:`dev`(合并 dev1023 + 人工成本完整性修复,迁移基线收敛为 `103`–`105`) > 数据基线:生产 dump `order_site-20261022-092601.dump`(2026-07-10 ~ 2026-10-22);本地只跑了近 30 天增量,历史数字仅作基线。 > 上线初始化/验收见 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md)。 > **业务口径以 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) 为准**;本文只负责数据模型、迁移与运维。 @@ -35,35 +35,19 @@ orders ──┬── order_items ──── fulfillment_mode (唯一履约 ## 一、迁移基线:10 个收敛为 5 个 -原 `dev` 分支上,商品、成本中心、人工履约用 10 个**历史编号**迁移(`103`–`111`,1,275 行)分批落地,字段反复摇摆。其中: - -- 原 `108`(下线人工支出登记)与原 `109`(隔离历史快照主体)在「生产 102 → 当前」的唯一升级路径上是**空操作**; -- 原 `111` 是给 `104` 自身种子打的补丁(draft 占位改 active 占位); -- 原 `112` 的 `resolution_note` 列本就属于异常表。 - -2026-10-25 收敛为 5 个(合计 1,151 行): +2026-10-27 最终收敛为 3 个领域迁移(合计约 2,177 行): | 迁移 | 内容 | 行数 | | --- | --- | ---: | -| `103_product_catalog.sql` | `product_catalog`(无载体列)+ `channel_product_mappings`(含匹配键)+ `order_items` 商品/归一字段 | 152 | -| `104_cost_center.sql` | `cost_items`(`cost_model`)+ `cost_versions` + `cost_snapshots` + `cost_exceptions`(含 `resolution_note`)+ 订单项收入固化 + 系统成本项 + 遗留占位清理 | 503 | -| `105_sales_daily_facts.sql` | `order_items.fulfillment_mode` 三列 + `sales_daily_facts` | 166 | -| `106_order_refunds.sql` | `order_refunds` 退款表 | 38 | -| `107_manual_fulfillment.sql` | 人工 SKU 登记/配置 + 三平台积分钱包 + 备用金账户编号可读化 | 292 | +| `103_finance_foundation.sql` | 商品目录、渠道映射、成本项/版本/快照/异常、订单项收入与履约方式、销售事实、退款单 | 861 | +| `104_finance_controls.sql` | 人工 SKU/配置、备用金/积分、影子总账、对账/关账、财务控制、L1 钱包硬化 | 1,119 | +| `105_work_order_consignment.sql` | 拼单多次参与、大厅/店铺索引、撤份额注释、寄卖对接客服 | 197 | -后续增量(不参与「10 合 5」的收敛): +原来 103–114 的 12 个补丁文件已删除;生产当前在 102,只需执行 103–105。 -| 迁移 | 内容 | -| --- | --- | -| `108_cost_exception_archive.sql` | 成本异常 60 天归档 | -| `109_work_order_share_participation.sql` | 拼单多次参与份额模型(与本模块无关,同批上线) | -| `110_work_order_hall_and_shop_indexes.sql` | 大厅置顶/店铺筛选索引(与本模块无关) | -| `111_work_order_share_cancel_reason_comment.sql` | 拼单取消原因注释(与本模块无关) | -| `108_finance_cost_integrity.sql` | 成本异常归档 + 人工成本单事务兜底(订单项级唯一、`subject_id=order_item_id`、SKU 索引)+ 财务总账/期间/对账 + 商品页/模板查询索引(合并原 108/111/112/113/114) | +**合并原则**:同一批上线的结构性 DDL 按领域合并为 `103`(商品/成本/销售/退款)、`104`(人工/总账/控制)、`105`(拼单/寄卖);不再按单表或几行补丁拆文件。 -**合并原则**:同一批上线的结构性 DDL 才合并;数据回填与业务口径注释跟着所属表走。`103` 保持独立(商品主数据是成本与销售事实的共同上游),`106` 保持独立(退款是独立业务线)。 - -**人工成本快照主体(2026-10-26 定稿)**:`manual_case` 的 `subject_id` 统一为 `order_item_id`,不再使用 `manual_expenses.id` / `platform_credit_entries.id`(两张表各自 BIGSERIAL,必然撞 id)。资金来源只放 `metadata_json.fundExpenseId / creditEntryId`。迁移 `111` 增加订单项级唯一索引 `uq_cost_snapshots_manual_order_item`,一个订单项最多一条有效人工快照;`subject_id` 为正数的历史行归一到订单项,负数历史孤儿快照按迁移 `104` 约定保留、不参与新链路。 +**人工成本快照主体(2026-10-26 定稿)**:`manual_case` 的 `subject_id` 统一为 `order_item_id`,不再使用 `manual_expenses.id` / `platform_credit_entries.id`(两张表各自 BIGSERIAL,必然撞 id)。资金来源只放 `metadata_json.fundExpenseId / creditEntryId`。迁移 `104` 增加订单项级唯一索引 `uq_cost_snapshots_manual_order_item`,一个订单项最多一条有效人工快照;`subject_id` 为正数的历史行归一到订单项,负数历史孤儿快照按迁移 `103` 约定保留、不参与新链路。 ### 1.1 字段替换对照 @@ -268,7 +252,7 @@ resolved_at, superseded_at, created_at - 匹配不到成本时写 `cost_missing` 异常,**不能默认 0**; - 履约方式不存快照里,需要时 join `order_items.fulfillment_mode`; - 人工单的快照主体是 `subject_type='manual_case'` + `subject_id=order_item_id`;资金流水 id 只写 `metadata_json`,**没有独立的登记单表**; -- 负数 `subject_id` 表示历史孤儿快照(迁移 `104` 约定),迁移 `112` 保留不动,不参与新链路; +- 负数 `subject_id` 表示历史孤儿快照(迁移 `103` 约定),迁移 `104` 保留不动,不参与新链路; - 一个订单项最多一条有效人工快照(`uq_cost_snapshots_manual_order_item`)。 ### 3.5 `cost_exceptions` @@ -289,7 +273,7 @@ created_at, resolved_at, resolved_by ## 四、订单项收入固化 -`order_items.revenue_amount_fen` / `revenue_basis` 在迁移 `104` 里固化: +`order_items.revenue_amount_fen` / `revenue_basis` 在迁移 `103` 里固化: | `revenue_basis` | 含义 | | --- | --- | @@ -430,26 +414,22 @@ WHERE oi.id = d.id ## 八、迁移基线与本地库同步(2026-10-27 收敛) -生产尚未执行过 `103` 及之后的迁移。2026-10-27 已把原来的 `108/111/112/113/114` 合并为 `108_finance_cost_integrity`,原 `115` 注释迁移改为 `111`;生产升级统一走 **101 → 111**,不再出现 108–115 的补丁链。 +生产数据库当前在 **102**。dev 已把原 103–114 收敛为 3 个领域迁移: -1. **全新升级**:从生产 dump 恢复到 `101`,直接 `npm run db:migrate` 升到 `111`; -2. **已跑过旧 112–115 的本地/测试库**:schema 已等价,不要删 103–111 重跑;执行一次迁移历史校准: +| 迁移 | 内容 | +| --- | --- | +| `103_finance_foundation.sql` | 商品目录、成本中心、成本项/版本/快照、销售事实、退款单 | +| `104_finance_controls.sql` | 人工履约、备用金/积分、影子总账、对账/关账、财务控制、L1 钱包硬化 | +| `105_work_order_consignment.sql` | 拼单多次参与、大厅/店铺索引、撤份额注释、寄卖对接客服 | -```bash -docker exec -i order_site-postgres-1 psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" \ - -v ON_ERROR_STOP=1 < apps/backend/scripts/reconcile-migration-history.sql -npm run db:migrate -``` +- 生产升级:**102 → 103 → 104 → 105**,只执行 3 个迁移文件。 +- 全新库:`001 → 105` 一次跑完。 +- 已跑过旧 103–114 的测试库:**直接重建**,或把 `schema_migrations` 清到 102 后重新执行 103–105;旧的逐补丁校准脚本已删除。 +- 生产库只到 102,不依赖旧 103–114 的迁移记录。 -脚本只把 `schema_migrations` 的旧 `108–115` 行重建为新的 `108–111`,不重复执行 DDL;若库仍停留在旧 103–107,直接正常迁移即可。 +### 8.1 收入口径回填(迁移 103 不做全表更新) -2026-10-27 实测:旧 115 库与新 111 库 `pg_dump --schema-only` **完全一致**(仅 pg_dump 随机 token/版本注释不同);新库 001→111 与 101→111 两条路径均通过。 - -旧的逐列改造脚本 `scripts/rewrite-local-db-2026-10-23.sql` 已随迁移合并作废,移入 [archive/2026-10-23/](archive/2026-10-23/);只在追溯当时 `carrier_type → cost_model` / `fulfillment_mode` 的改造矩阵时查阅,**不要在新环境执行**。 - -### 8.1 收入口径回填(迁移 104 不做全表更新) - -`order_items.revenue_amount_fen` / `revenue_basis` 在迁移 `104` 里**只建列,不刷历史数据**: +`order_items.revenue_amount_fen` / `revenue_basis` 在迁移 `103` 里**只建列,不刷历史数据**: 375,447 行的全表 `UPDATE` 实测约 **34 秒**,超过默认语句超时(`DATABASE_STATEMENT_TIMEOUT_MS=15s`), 放进单事务迁移会拖住启动甚至直接失败。 diff --git a/docs/打手重复奖励核对-2026-10-27.md b/docs/打手重复奖励核对-2026-10-27.md new file mode 100644 index 00000000..c0709c48 --- /dev/null +++ b/docs/打手重复奖励核对-2026-10-27.md @@ -0,0 +1,127 @@ +# 打手重复奖励核对清单(2026-10-27) + +> 数据源:本地生产快照 `order_site`(与 `backups/postgres/order_site-20261022-092601.dump` 模拟库一致)。 +> 审计视图:`v_worker_wallet_duplicate_rewards`(迁移 104,已排除寄卖货款并按 `worker_id` 分组)。 +> **结论:原始按“工单 + 流水类型”分组得到 9 组;排除寄卖货款后,实际疑似重复 8 组。** +> 处理原则:**不自动扣回、不删除流水**;由财务逐组核对后走人工调整。 + +--- + +## 一、汇总 + +| 指标 | 数值 | +| --- | --- | +| 原始同工单奖励流水组 | 9 组 | +| 其中寄卖货款误报 | 1 组(工单 84886) | +| 实际疑似重复 | **8 组** | +| 涉及流水 | 16 条 | +| 两组流水合计 | **¥392.40** | +| 重复多计(按每组一半估算) | **¥196.20** | +| 涉及打手 | 7 人 | +| 工单状态 | 全部 `accepted` | + +> 金额单位:分;下表同时给出元。`多计` 按“两条金额相同、保留一条”估算,最终以财务核对为准。 + +--- + +## 二、疑似重复明细(8 组) + +| # | 工单号 | 工单 ID | 打手 ID | 打手账号 | 流水 ID | 单条金额 | 条数 | 合计 | 多计 | 时间差 | 当前可用余额 | +| --- | --- | --- | --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | +| 1 | WO68ae5cc00012 | 41430 | 7 | 18622832733 | 26738 / 26739 | ¥12.60 | 2 | ¥25.20 | ¥12.60 | 0.218s | ¥22,817.40 | +| 2 | WOf9a256424094 | 67580 | 28 | 19391334110 | 55324 / 55325 | ¥47.50 | 2 | ¥95.00 | ¥47.50 | 3.487s | ¥329.04 | +| 3 | WO1ef60bfd41f3 | 70556 | 60 | 18700402637 | 58633 / 58634 | ¥0.20 | 2 | ¥0.40 | ¥0.20 | 3.808s | ¥1,397.00 | +| 4 | WO26092719382700 | 90262 | 21 | 17386836758 | 85908 / 85909 | ¥6.50 | 2 | ¥13.00 | ¥6.50 | 0.595s | ¥104.50 | +| 5 | WO26100215392303 | 100509 | 37 | 13779907018 | 99520 / 99522 | ¥12.80 | 2 | ¥25.60 | ¥12.80 | 0.773s | ¥0.48 | +| 6 | WO26100312143075 | 102885 | 51 | 14718183196 | 102228 / 102229 | ¥100.00 | 2 | ¥200.00 | ¥100.00 | 3.400s | ¥1,887.60 | +| 7 | WO26100316285341 | 103640 | 21 | 17386836758 | 103021 / 103022 | ¥1.60 | 2 | ¥3.20 | ¥1.60 | 3.376s | ¥104.50 | +| 8 | WO26101115455274 | 125710 | 1365 | 15669767683 | 130982 / 130983 | ¥15.00 | 2 | ¥30.00 | ¥15.00 | 0.614s | ¥4,407.18 | + +--- + +## 三、逐条流水明细(16 条) + +| 流水 ID | 工单 ID | 打手 ID | 金额 | 余额后 | 冻结后 | 备注 | payload | 创建时间 | +| ---: | ---: | ---: | ---: | ---: | ---: | --- | --- | --- | +| 26738 | 41430 | 7 | 1260 | 1515880 | 0 | 验收通过结算报酬 | `{}` | 2026-08-26 19:15:09.638 | +| 26739 | 41430 | 7 | 1260 | 1517140 | 0 | 验收通过结算报酬 | `{}` | 2026-08-26 19:15:09.856 | +| 55324 | 67580 | 28 | 4750 | 124321 | 6000 | 验收通过结算报酬 | `{}` | 2026-09-03 21:05:22.924 | +| 55325 | 67580 | 28 | 4750 | 129071 | 6000 | 验收通过结算报酬 | `{}` | 2026-09-03 21:05:26.411 | +| 58633 | 70556 | 60 | 20 | 76280 | 0 | 验收通过结算报酬 | `{}` | 2026-09-04 21:18:52.540 | +| 58634 | 70556 | 60 | 20 | 76300 | 0 | 验收通过结算报酬 | `{}` | 2026-09-04 21:18:56.348 | +| 85908 | 90262 | 21 | 650 | 221195 | 0 | 验收通过结算报酬 | `{}` | 2026-09-27 19:50:13.522 | +| 85909 | 90262 | 21 | 650 | 221845 | 0 | 验收通过结算报酬 | `{}` | 2026-09-27 19:50:14.117 | +| 99520 | 100509 | 37 | 1280 | 57160 | 12000 | 验收通过结算报酬 | `{}` | 2026-10-02 15:52:18.017 | +| 99522 | 100509 | 37 | 1280 | 58440 | 12000 | 验收通过结算报酬 | `{}` | 2026-10-02 15:52:18.790 | +| 102228 | 102885 | 51 | 10000 | 331210 | 0 | 验收通过结算报酬 | `{}` | 2026-10-03 12:29:32.277 | +| 102229 | 102885 | 51 | 10000 | 341210 | 0 | 验收通过结算报酬 | `{}` | 2026-10-03 12:29:35.677 | +| 103021 | 103640 | 21 | 160 | 74193 | 0 | 验收通过结算报酬 | `{}` | 2026-10-03 16:48:16.927 | +| 103022 | 103640 | 21 | 160 | 74353 | 0 | 验收通过结算报酬 | `{}` | 2026-10-03 16:48:20.303 | +| 130982 | 125710 | 1365 | 1500 | 469690 | 0 | 验收通过结算报酬 | `{}` | 2026-10-11 17:17:22.873 | +| 130983 | 125710 | 1365 | 1500 | 471190 | 0 | 验收通过结算报酬 | `{}` | 2026-10-11 17:17:23.487 | + +> 16 条流水的 `payload_json` 都为空、备注都是“验收通过结算报酬”,且同一打手、同一工单、时间差 0.2–3.8 秒,重复特征明显。 + +--- + +## 四、误报排除:工单 84886 + +原始 9 组里包含工单 84886,但它不是重复奖励: + +| 流水 ID | 打手 ID | 金额 | 备注 | payload | +| ---: | ---: | ---: | --- | --- | +| 87769 | 25 | 13000 | 验收通过结算报酬 | `{}` | +| 87770 | 37 | 13000 | 寄卖货款结算 #84886 | `{"inventoryId":159,"workOrderId":84886,"costPriceFen":13000,"submissionNo":"SUB26092515093529"}` | + +- 两条流水属于**不同打手**; +- 一条是工单验收奖励,一条是寄卖货款结算; +- 金额相同只是巧合,不能按重复扣回。 + +因此审计视图已修正为: + +- 按 `worker_id + work_order_id + ledger_type + share_id` 分组; +- 排除 `payload_json ? 'inventoryId'` 的寄卖货款; +- 结果从 9 组降为 **8 组**。 + +--- + +## 五、人工核对建议 + +每组按以下顺序确认: + +1. 查工单 `accepted` 时间、验收人、`work_order_shares` 是否有同打手多份额; +2. 确认两条流水是否都是“验收通过结算报酬”,且没有对应的冲正/扣回流水; +3. 对比 `worker_wallets.total_settled_amount` 与全部结算流水,确认多计已进入余额; +4. 确认打手当前余额是否足够扣回;不足时登记欠款,不要直接把余额改成负数; +5. 处理方式建议: + - 余额足够:人工新增负数调整流水(`ledger_type` 建议 `manual_adjustment`,备注“重复结算追回 + 工单号”); + - 余额不足:登记 `worker_after_sales_debts` 或线下追缴,后续结算抵扣; + - 不删除原流水,不直接改 `worker_wallets` 历史余额。 + +--- + +## 六、复核 SQL + +```sql +-- 当前疑似重复(已排除寄卖货款、按打手分组) +SELECT * +FROM v_worker_wallet_duplicate_rewards +ORDER BY ledger_count DESC, related_work_order_id; + +-- 指定工单的全部奖励流水 +SELECT id, worker_id, ledger_type, amount, balance_after, frozen_after, + note, payload_json, created_at +FROM worker_wallet_ledgers +WHERE related_work_order_id = 41430 + AND ledger_type IN ('reward_settlement', 'sharing_reward', 'reward_pending_unfreeze') +ORDER BY id; +``` + +--- + +## 七、结论 + +- 需要人工核对的疑似重复:**8 组,16 条流水,合计 ¥392.40,多计约 ¥196.20**; +- 工单 84886 是寄卖货款 + 工单奖励,**不是重复**,不要扣回; +- 历史重复不自动修复,避免误伤正常寄卖/多份额结算; +- 迁移 104 已为未来奖励流水加 `event_key` 唯一约束,新重复会被数据库直接拒绝。 diff --git a/docs/财务上线初始化与验收-2026-10-26.md b/docs/财务上线初始化与验收-2026-10-26.md index 85f8cab0..d27ec657 100644 --- a/docs/财务上线初始化与验收-2026-10-26.md +++ b/docs/财务上线初始化与验收-2026-10-26.md @@ -1,7 +1,7 @@ # 财务/成本上线初始化与验收(2026-10-26) > 状态:现行操作手册。适用于 `dev` 合并后的财务/成本模块上线;**历史人工成本不补录**。 -> 代码基线:迁移 `103`–`111`(本手册写于人工成本完整性与配置自动匹配修复之后)。 +> 代码基线:迁移 `103`–`105`(本手册写于人工成本完整性与配置自动匹配修复之后)。 > 数据基线:生产 dump `2026-10-22`(迁移 `101`)。历史人工支出找不回来,只保证**首次配置人工履约之后**的新单可记录、可监控。 --- @@ -16,7 +16,7 @@ | 人工 SKU 配置 + 确认履约(扣积分/备用金 + 写 actual 快照) | 是(配置需上线后设置) | | 商品目录、渠道映射、成本版本、成本快照、销售事实 | 是 | | 手工退款登记(冲减收入、关闭成本异常) | 是 | -| 影子总账(科目/凭证/试算平衡)、期间关账、平台对账 | 是(迁移 `112`;对账当前为手工账单导入) | +| 影子总账(科目/凭证/试算平衡)、期间关账、平台对账 | 是(迁移 `104`;对账当前为手工账单导入) | | 平台退款 API | **部分**:快手行业(91)已接入;抖音/拼多多仍手工登记 | 明确不做: @@ -38,7 +38,7 @@ bash deploy/ubuntu-deploy.sh --backup --dry-run # 先看计划 bash deploy/ubuntu-deploy.sh --backup # 迁移前 pg_dump + OSS,再重建/重启 ``` -后端启动会自动执行 `102`–`111`。确认: +后端启动会自动执行 `102`–`105`。确认: ```sql SELECT name FROM schema_migrations ORDER BY id DESC LIMIT 5; @@ -158,7 +158,7 @@ GROUP BY status; ### 5. 历史回填(退款功能上线前必须跑 `revenue:backfill`) -**存量订单要能退款,必须先回填订单项收入。** 迁移 `104` 只加列不回填;`revenue_basis='unknown'` 的订单项 `revenue_amount_fen=0`,订单详情「退款登记」会因可退金额为 0 而置灰,手工退款也会失败。 +**存量订单要能退款,必须先回填订单项收入。** 迁移 `103` 只加列不回填;`revenue_basis='unknown'` 的订单项 `revenue_amount_fen=0`,订单详情「退款登记」会因可退金额为 0 而置灰,手工退款也会失败。 ```bash npm run revenue:backfill -- --dry-run # 待回填量,应为 0 diff --git a/docs/财务代码审计复核-2026-10-27.md b/docs/财务代码审计复核-2026-10-27.md new file mode 100644 index 00000000..63c3b71f --- /dev/null +++ b/docs/财务代码审计复核-2026-10-27.md @@ -0,0 +1,212 @@ +# 对《财务系统深度审计》的代码级复核(2026-10-27) + +> 复核对象:用户给出的财务系统审计结论。 +> 复核方式:逐条对照当前 `dev` 代码、迁移 `001–105`、本机数据库与生产 dump 模拟升级。 +> 总评:**架构判断(双轨期、旧报表口径混编、历史人工成本缺失)方向正确;但有 4 处结论需要纠正,2 处需要降级为“风险假设”,1 处已经是旧代码状态。** + +--- + +## 一、需要纠正的结论 + +### 1. “打手报酬 + 提现会被重复扣减 100%” —— 不成立 + +旧报表的净现金流函数实际是: + +```ts +// apps/backend/src/services/admin/admin-finance-service.ts:323-338 +return ( + row.paidAmount + + row.rechargeAmount - + row.refundAmount - + row.withdrawAmount - + row.afterSalesRefundAmount + + row.workerRecoveryAmount +) +``` + +**`workerRewardAmount` 没有进入 `netCashFlow`。** 代码没有把“打手报酬”和“提现”同时减掉,因此不存在“100% 双重扣减”。 + +真正的问题是**口径混编**: + +- “打手报酬”是权责发生制的成本列; +- “提现”是收付实现制的现金流出; +- 页面把它们并排展示,但净现金流只减提现,用户如果把两列相加会误读; +- 旧页面既不是完整利润表,也不是完整现金流量表。 + +**修正结论:不是算术 bug,是报表语义/口径设计问题。** + +### 2. “总账同步 DELETE 重建,违背不可变性” —— 当前代码已修复 + +用户引用的 `finance-ledger-service.ts#L251-L265` 是改造前实现。当前代码已经: + +- 不再 `DELETE FROM finance_journal_entries`; +- 按日计算收入/退款/成本的 delta,追加 `sales_*_adjustment` 凭证; +- 迁移 `104` 增加 `trg_finance_journal_immutable`,有分录的已过账凭证禁止删除; +- 迁移 `104` 增加 `trg_finance_journal_balanced`,事务提交时校验整张凭证借贷平衡。 + +**修正结论:该隐患在 2026-10-27 的 P0 改造中已关闭;剩余问题是“按日汇总凭证”仍然粒度较粗,不是“物理删除”。** + +### 3. “所有金额都是 BIGINT,底层约束极其严密” —— 不成立,L1 钱包反而很弱 + +当前金额类型并不统一: + +| 表/列 | 类型 | 上限 | +| --- | --- | --- | +| `orders.total_amount` | `INTEGER` | ¥21,474,836.47 | +| `worker_wallets.*` 六个余额列 | `INTEGER` | 同上 | +| `worker_wallet_ledgers.amount/balance_after` | `INTEGER` | 同上 | +| `worker_finance_requests.amount/frozen_amount` | `INTEGER` | 同上 | +| `fund_*`、`cost_*`、`finance_*` | `BIGINT` | 远大于业务规模 | + +证据: + +- [001_init.sql:108](../apps/backend/src/db/migrations/001_init.sql#L108) `orders.total_amount INTEGER` +- [004_worker_order_platform.sql:31-53](../apps/backend/src/db/migrations/004_worker_order_platform.sql#L31-L53) 钱包/流水均为 `INTEGER` + +而且 `worker_wallets` / `worker_wallet_ledgers` / `worker_finance_requests` **没有任何 CHECK 约束**: + +- 没有 `available_amount >= 0`、`frozen_deposit_amount >= 0` 等余额非负约束; +- 没有“余额 = 流水汇总”的触发器; +- `worker_wallet_ledgers` 没有 `(related_work_order_id, ledger_type, shareId)` 业务唯一键,结算重试/重复调用没有数据库级幂等。 + +**修正结论:新财务表约束较强;真正掌管钱的 L1 钱包约束很弱,且金额是 int4,不是 BIGINT。** + +### 4. “退款撤销生成红字冲正” —— 只有净额调整,没有凭证级 reversal + +当前 `voidOrderRefund` 的实际动作是: + +1. 把 `order_refunds.status` 置为 `voided`; +2. 重算 `orders.pay_status`(本轮 P0 修复); +3. 重算销售事实; +4. 总账按事实差异追加 `sales_refund_adjustment`。 + +它**没有**: + +- `reversal_of_id` 指向原退款凭证; +- 一张与原凭证一一对应的红字凭证; +- 对原退款凭证的状态标记。 + +**修正结论:效果上是冲减,审计上不是“红字冲正”;应描述为“软删除 + 事实重算 + 差额调整凭证”。** + +### 5. “打手结算/提现/售后必然死锁” —— 是风险假设,不是已证实结论 + +当前主要锁顺序基本一致: + +- 工单结算:`work_orders` → `work_order_shares` → `worker_wallets` +- 售后:`work_orders` → `work_order_shares` → `worker_after_sales_cases/debts` → `worker_wallets` +- 提现:`worker_finance_requests` → `worker_wallets` + +共同热点是 `worker_wallets`,但代码里没有看到“先锁钱包、再回头锁工单/份额”的反向路径;因此不能直接断言“必然死锁”。 + +**修正结论:这是高并发热点与潜在死锁风险,需要压测 + 死锁重试/监控,不是已证实的生产故障。** + +### 6. “6 状态可变模型” —— 实际是 6 个余额桶,不是数据库状态机 + +`worker_wallets` 是 6 个金额字段,不是 6 个状态: + +- `available_amount` +- `frozen_deposit_amount` +- `pending_unfreeze_amount` +- `frozen_withdraw_amount` +- `total_credited_amount` +- `total_settled_amount` + +状态迁移完全由应用层代码维护,数据库没有状态枚举/非负约束/守恒触发器。 + +**修正结论:应称为“6 个可变余额桶”,不是严格状态机。** + +--- + +## 二、判断正确、但需要补充的部分 + +### 1. 历史人工成本缺失导致“假毛利” —— 正确 + +- 人工订单项:**115,194**; +- 人工收入:**¥14,470,149.94**(1,447.01 万); +- 历史人工成本未补录,`cost_snapshots` 没有对应 `manual_case`,销售事实里 `cost_missing_count > 0`、成本按 0 计; +- 前端 `AdminSalesProfitPage` 虽然有“有 N 个订单项没有成本快照,当前毛利偏高”的 warning,但报表仍可导出/展示; +- 关账门槛只覆盖“首次人工配置之后”的人工单,历史区间不会被自动拦截。 + +**建议:全生命周期报表必须默认按 `cost_stage` 过滤或显著标注;历史区间单独出“成本未采集”版本,不能与上线后区间混在一起。** + +### 2. 影子总账是“日汇总粗粒度账” —— 正确 + +当前总账仍按 `sales_daily_facts` 的“日期 × 来源 × 店铺 × 商品 × 履约方式”聚合成每日总收入/总退款/总成本,再生成凭证。它没有: + +- 单笔订单/工单级凭证; +- provider/shop/product 维度; +- 钱包、押金、提现、寄卖、平台费凭证。 + +**建议:先把总账定位为“销售/成本影子账”,不要对外称为完整复式总账;转正前补齐事件级过账。** + +### 3. 人工履约事务/幂等做得较好 —— 正确 + +- `SELECT ... FOR UPDATE OF oi` 锁订单项; +- `uq_cost_snapshots_manual_order_item` 唯一索引; +- 备用金/积分扣减与快照同事务; +- 失败整体回滚。 + +这部分用户判断准确。 + +--- + +## 三、用户审计漏掉的关键问题 + +### 1. L1 钱包没有数据库级余额约束 + +`worker_wallets` 没有 `CHECK`,也没有“余额 = Σ流水”的约束。应用层一旦有 bug/并发/手工 SQL,余额可以直接变负或与流水脱节。新财务表有 `fund_accounts` 的余额触发器,但**没有覆盖 `worker_wallets`**。 + +### 2. 打手结算流水没有业务幂等键 + +`worker_wallet_ledgers` 只有 `(worker_id, created_at)` 索引;没有 `(related_work_order_id, ledger_type, shareId)` 唯一键。工单状态机通常能挡住重复结算,但数据库层没有兜底。 + +### 3. `orders.total_amount` 是 int4 + +单笔订单上限 ¥21,474,836.47。当前业务量远低于上限,但和“全部 BIGINT”的表述不符,未来大额订单/多币种会踩边界。 + +### 4. 旧报表不是“利润表”,也不是“现金流量表” + +它把订单收款、退款、打手报酬、提现、充值、售后追缴拼在一起;`netCashFlow` 只减现金流出,不减打手报酬(这是对的),但页面没有把“成本/现金流/资产负债”拆开。建议保留为“运营收支流水”,另建利润表/现金流量表,而不是继续给它加字段。 + +### 5. 迁移收敛后仍有“生产数据初始化”断层 + +生产 dump 升级到 `105` 成功,但: + +- `product_catalog=0`; +- `cost_snapshots=0`; +- `sales_daily_facts=0`; +- `fund_accounts=0`; +- 375,447 个订单项需要 revenue/fulfillment 回填。 + +**代码合并 ≠ 财务可用。** 发布必须按上线手册初始化。 + +--- + +## 四、对用户路线图的校准 + +| 用户步骤 | 复核结论 | +| --- | --- | +| 第一步:废弃旧报表 | **方向对,但不要直接删**;先把“现金流/运营流水”和“损益”拆成两个页面,旧页保留为现金流辅助 | +| 第二步:事件级实时过账 | **正确**;本轮已完成“停止删除重建 + 追加调整凭证 + 数据库触发器”,但尚未做到每笔业务同事务过账 | +| 第三步:平台账单 API | **正确**;当前仍是手工 JSON 导入,快手退款 API 仅部分接入 | +| 第四步:钱包转总账投影 | **正确但最远**;先补 L1 钱包约束/幂等,再谈投影 | + +--- + +## 五、一句话结论 + +**用户的“双轨期”判断是对的,旧报表口径混编、历史人工成本缺失、影子账粒度粗也都是真问题;但“重复扣减 100%”“全部 BIGINT/约束严密”“DELETE 重建”“必然死锁”这几条与当前代码不符。真正更危险的是:掌管钱的 `worker_wallets` 仍然是 int4、无 CHECK、无流水幂等,而新财务表的约束并没有覆盖它。** + +--- + +## 六、后续补全记录(2026-10-27) + +针对本复核指出的 L1 钱包问题,已补迁移 `104_finance_controls.sql`(含 L1 钱包硬化;生产 dump `102 → 105` 实测见合并评估): + +- `orders.total_amount`、`worker_wallets.*`、`worker_wallet_ledgers.amount/balance_after/frozen_after`、`worker_finance_requests.amount/frozen_amount`、工单/份额/售后/寄卖金额全部 **int4 → BIGINT**; +- `worker_wallets` 六个余额、流水后余额、提现申请、订单/工单/份额/售后/寄卖金额加 **非负 CHECK**; +- `worker_wallet_ledgers.event_key` + 触发器 + 唯一索引:工单 + 份额 + 流水类型,防未来奖励重复结算; +- 新增只读视图 `v_worker_wallet_duplicate_rewards` 与审计脚本 `scripts/audit-worker-wallet.sql`; +- 生产模拟数据发现 **8 组疑似重复 `reward_settlement`,合计 ¥392.40、重复多计约 ¥196.20**;另有 1 组(84886)是寄卖货款与工单奖励,属误报。未自动扣回,需财务确认。 + +因此,本复核中“L1 钱包是当前最大短板”的结论仍然成立,但已经从“无约束”变成“有 BIGINT、有 CHECK、有未来幂等、有历史审计视图”。 diff --git a/docs/财务系统深度分析-2026-10-27.md b/docs/财务系统深度分析-2026-10-27.md new file mode 100644 index 00000000..d83cca6d --- /dev/null +++ b/docs/财务系统深度分析-2026-10-27.md @@ -0,0 +1,917 @@ +# order_site 财务系统深度分析(2026-10-27) + +> 分析对象:订单履约、成本中心、客服备用金/积分、销售事实、影子总账、期间关账与平台对账。 +> 代码基线:`dev`,HEAD `0de0fd7b`(迁移收敛到 `101–111`)。 +> 文档基线:`docs/README.md` 标注的现行口径(2026-10-26)。 +> 验证环境:本机 Docker PostgreSQL(开发库,**不是生产库**);生产尚未部署,按仓库文档口径仍以 `main` 为生产基线。 +> +> 一句话结论:**业务闭环已经能做;作为“财务系统”还不能直接关账、对外出报表或承担审计。** +> 核心问题不是“少一张表”,而是**事实层、总账层、资金层三套数据没有形成强一致与可校验的勾稽关系**。 +> +> **2026-10-27 更新:P0 代码改进已落地,见 §十一;本报告前文的“问题/建议”保留为审计基线。** + +--- + +## 一、结论先行 + +| 维度 | 判断 | 依据 | +| --- | --- | --- | +| 订单 → 履约 → 成本 | 基本闭环 | `fulfillment_mode` + `cost_snapshots` + `cost_exceptions` 已打通 | +| 人工履约资金闭环 | 做得较好 | 单事务、订单项行锁、唯一索引、失败回滚均有实现与集成测试 | +| 客服备用金 | 可用,但内控不足 | 余额/流水/支出/对账齐全;对账差额存在过期风险;缺少幂等键 | +| 退款 | 手工路径可用,平台路径有硬缺口 | 平台退款先调用平台、后落本地;全额退款撤销不恢复收入 | +| 销售事实 | 可算毛利,但可信度依赖成本覆盖 | 成本缺失时毛利被高估;关账期间仍可重算 | +| 影子总账 | **只是“销售/成本影子账”** | 11 个科目中仅 4 个有发生额;钱包、押金、提现、寄卖、平台费均未入账 | +| 期间关账 | **不建议生产使用** | 无门槛、无锁、可重复关账;已关账期间事实仍可变 | +| 权限/审计 | 粒度过粗 | `finance.config` 一权多用;无 maker-checker;审计失败被吞 | +| 测试 | 覆盖面尚可 | 后端 866 项(862 pass / 4 skip),前端 181 pass;财务集成测试默认跳过 | + +### 上生产前必须先解决的 7 件事 + +1. **关账期间销售事实仍可被重算**,但总账会跳过已关账期间 → 报表与总账必然分叉。 +2. **平台退款先调用平台、后写本地**,且本地没有 `external_ref` 唯一约束 → 平台已退款、本地没记账,或重复记账。 +3. **全额退款撤销后 `orders.pay_status` 不恢复** → 撤销了退款,收入仍然是 0。 +4. **关账不校验门槛、不加锁、可重复关账** → 带着缺失成本/未对账数据关账;并发写入可穿透。 +5. **备用金对账使用创建时的旧差额** → 对账确认后余额可能仍然不等于实际盘点。 +6. **总账“已过账不可改、冲正用反向分录”没有实现** → 按日 `DELETE + 重建`,审计链断裂。 +7. **权限矩阵与文档不一致**:`finance` 被人工履约路由排除,`operator` 反而可以跨客服代录;`finance.config` 同时管成本、凭证、退款、关账、积分。 + +--- + +## 二、系统全景 + +### 2.1 技术栈与部署 + +| 层 | 技术 | +| --- | --- | +| 前端 | React 19、Vite 8、Ant Design 6、React Router 7、TanStack Query、TypeScript | +| 后端 | Node.js、Express 5、TypeScript、PostgreSQL、node-pg-migrate | +| 网关 | Caddy | +| 运行 | Docker Compose(开发 / 生产分离) | + +财务相关代码主要集中在: + +- `apps/backend/src/services/finance/`:总账、销售事实、退款、人工履约 +- `apps/backend/src/services/cost-center/`:商品目录、成本版本、成本快照、成本异常、积分钱包 +- `apps/backend/src/services/admin/admin-fund-service.ts`:客服备用金 +- `apps/backend/src/services/admin/admin-finance-service.ts`:旧版“收支明细”报表 +- `apps/backend/src/routes/admin/finance*.ts`、`fund.ts`、`cost-center*.ts`:后台 API +- `apps/backend/src/db/migrations/100–108`:资金、成本、销售事实、退款、人工履约、总账 +- `apps/frontend/src/pages/admin/AdminFinancePage.tsx`、`AdminFinanceLedgerPage.tsx`、`AdminSalesProfitPage.tsx`:财务前端 + +### 2.2 资金/账务全景 + +```text +买家支付 + └─ orders / order_items + ├─ fulfillment_mode = work_order ──► work_orders ──► reward_amount + ├─ fulfillment_mode = affiliate_dash ──► affiliate 采购成本 + ├─ fulfillment_mode = legacy_platform ──► 历史成本缺口 + └─ fulfillment_mode = manual ──► 客服确认履约 + ├─ 备用金 fund_accounts / fund_entries / manual_expenses + └─ 积分 platform_credit_accounts / entries + │ + ▼ + cost_snapshots(estimated / actual) + │ + ▼ + sales_daily_facts(按日、按商品、按履约方式) + │ + ▼ + finance_journal_entries / lines(影子总账) + │ + ▼ + finance_periods(关账) + finance_reconciliations(平台对账) + +另一条并行链路: +orders / order_refunds / worker_wallet_ledgers / worker_finance_requests + └─ admin-finance-service.ts(旧版 UNION ALL 收支明细) +``` + +**关键事实:上面两条链路不是同一套口径。** 旧版“收支明细”按业务事件拼装,新版“销售毛利”按销售事实,总账又只读销售事实。三套数字在页面上并存,但没有强制对账。 + +### 2.3 三套“财务视图”的定位 + +| 视图 | 入口 | 口径 | 现状 | +| --- | --- | --- | --- | +| 收支明细 | `/admin/finance` | 订单收款、退款、打手报酬、提现、充值、售后追缴的现金流/事件汇总 | 旧版 `UNION ALL`,不是复式账 | +| 销售毛利 | `/admin/finance/sales-profit` | 权责发生制:订单项收入 − 退款 − 成本快照 | 依赖成本覆盖率;成本缺失时毛利不可用 | +| 总账与对账 | `/admin/finance/ledger` | 销售事实按日生成收入/退款/成本凭证 | 仅覆盖销售/成本;不接管钱包/备用金余额 | + +--- + +## 三、数据模型与不变量 + +### 3.1 核心表 + +| 域 | 表 | 作用 | +| --- | --- | --- | +| 交易 | `orders` / `order_items` | 订单骨架、订单项、`revenue_amount_fen`、`fulfillment_mode` | +| 退款 | `order_refunds` | 一条 = 一个订单项的一次退款;整单退款用 `batch_no` 分组 | +| 商品 | `product_catalog` / `channel_product_mappings` | 标准商品、渠道 SKU 映射 | +| 成本定义 | `cost_items` / `cost_versions` | 成本模型、计价方式、按生效时间的版本 | +| 成本事实 | `cost_snapshots` | 订单项/工单/人工单实际使用的成本快照 | +| 成本异常 | `cost_exceptions` / `cost_exceptions_archive` | 缺成本、缺映射队列与归档 | +| 销售事实 | `sales_daily_facts` | 日期 × 来源 × 店铺 × 商品 × 履约方式的收入/退款/成本/毛利 | +| 人工配置 | `manual_sku_registry` / `manual_fulfillment_configs` | 内部 SKU → 渠道 + 消耗积分 + 成本模式 | +| 客服资金 | `fund_accounts` / `fund_entries` / `manual_expenses` / `fund_reconciliations` | 备用金账户、流水、支出、对账 | +| 客服积分 | `platform_credit_accounts` / `platform_credit_entries` | 南航/乐玩/ROCK 三平台积分钱包 | +| 总账 | `finance_accounts` / `finance_journal_entries` / `finance_journal_lines` | 科目、凭证头、借贷行 | +| 期间/对账 | `finance_periods` / `finance_reconciliations` / `finance_reconciliation_items` | 关账、平台账单导入与差异明细 | + +### 3.2 关键不变量:哪些是数据库强约束,哪些只是应用层约定 + +| 不变量 | 实现 | 强度 | +| --- | --- | --- | +| 单张凭证借贷平衡 | `normalizeLines` 应用层校验 | **弱**:无数据库触发器/延迟约束,直接 SQL 可写入不平衡凭证 | +| 已过账凭证不可修改/删除 | 文档要求;代码按日删除重建 | **未实现**:没有触发器,也没有反向凭证 API | +| 一个订单项最多一条有效人工成本快照 | `uq_cost_snapshots_manual_order_item` 部分唯一索引 | **强** | +| 一个订单项同一成本项最多一条有效快照 | `uq_cost_snapshots_active_subject_item` 部分唯一索引 | **强** | +| 备用金余额 = 期初 + Σ 流水 | 应用层 `fund_accounts.balance_fen` 维护 | **弱**:无触发器/对账约束 | +| 备用金支出必须对应一条流水 | `manual_expenses.fund_entry_id` 唯一 + 同事务写入 | **较强** | +| 一个订单项最多一条人工支出 | 仅普通索引 `idx_manual_expenses_order_item` | **弱**:可插入多条 | +| 退款金额 ≤ 订单项可退金额 | `createManualRefund` 应用层校验 | **弱**:并发下依赖订单行锁,平台路径存在绕过 | +| 平台退款外部单号唯一 | 无唯一索引 | **未实现** | +| 销售事实毛利 = 净收入 − 成本 | `chk_sales_daily_facts_profit` | **强** | +| 净收入 ≥ 0 | `chk_sales_daily_facts_amounts` | **强**(但会掩盖超额退款,见 5.9) | +| 会计期间关账后禁止新增凭证 | `assertPeriodOpen` 普通 SELECT | **弱**:无锁,存在并发穿透 | + +这张表是后面所有问题的总根源:**系统把大量“财务不变量”放在了应用层,而应用层又没有全部事务化/加锁。** + +--- + +## 四、关键链路拆解 + +### 4.1 订单收入固化 + +- `order_items.revenue_amount_fen` 在迁移 `104` 增加,回填脚本 `revenue:backfill` 分批执行。 +- 当前规则:一单一品归整单;多品按等额拆分,`revenue_basis` 标记 `order_total / equal_split / platform_item`。 +- `sales_daily_facts` 的销售日取 `COALESCE(orders.paid_at, orders.created_at)` 的 `Asia/Shanghai` 日期;退款会回写到**原销售日**,不是退款发生日。 + +**评价:** + +- 金额用 `BIGINT` 分,方向正确。 +- 多品等额拆分不是实际成交价,商品级毛利会失真;当前本地库恰好全部是一单一品,所以暂时没有暴露,但这是未来必须补的口径。 +- 退款回写原销售日,是后面“关账期间事实仍可变”的根源。 + +### 4.2 成本解析与快照 + +- 成本项 `cost_items.cost_model`:`affiliate_purchase` / `work_order_reward` / `manual_actual` / `platform_fee` / `payment_fee` / `after_sales_loss` / `other`。 +- 成本版本按 `effective_from/effective_to` 和范围优先级 `rule > product > shop > provider > global` 解析。 +- 预估快照由 `syncEstimatedOrderItemCostSnapshots` 生成;工单验收后由 `syncActualWorkOrderCostSnapshots` 升级为 `actual`;人工确认时直接写 `actual`。 +- 匹配不到成本不默认 0,进入 `cost_exceptions`;销售事实记 `cost_missing_count`。 + +**评价:** + +- “不默认 0”是正确的财务设计。 +- `cost_snapshots` 冻结金额,历史订单不会因为改价而变化,这是对的。 +- 但 `cost_versions` 的“追溯改价”会**就地 UPDATE 当前 active 版本**(见 5.11),版本历史并不真正不可变。 +- 预估快照一旦写入不会因版本变化刷新;对“支付时点成本”来说是合理的,但对“当前成本估算”来说会过期,需要在报表上明确口径。 + +### 4.3 人工履约资金闭环(实现最好的一段) + +`confirmManualFulfillment` 的关键顺序: + +1. 先按 SKU 兜底匹配 `manual_fulfillment_configs`; +2. `SELECT ... FOR UPDATE OF oi` 锁订单项; +3. 复查是否已有有效 `manual_case` 快照; +4. 解析成本项/成本版本; +5. 现金路径:`writeFundExpense` 扣备用金并写 `manual_expenses`;积分路径:`consumePlatformCreditWithClient` 扣积分并写流水; +6. 写 `cost_snapshots`(`actual`,`subject_id = order_item_id`); +7. 收口 `manual_cost_missing` 异常; +8. 全部在同一个 `withTransaction` 中。 + +**优点:** + +- 钱与成本同事务,失败整体回滚; +- 订单项行锁 + 部分唯一索引,双击/并发只能成功一次; +- 集成测试覆盖了重复确认、并发确认、快照失败回滚。 + +**待改进:** + +- `cost_mode` 字段在确认时没有被读取,实际只按 `credit_quantity > 0` 判断固定/可变(见 5.7); +- 快照 metadata 记录的是请求里的 `creditOwnerAdminUserId`,不是解析后的实际扣款人(见 5.8); +- `finance` 角色被路由排除,无法执行文档所说的“财务代录”(见 5.6)。 + +### 4.4 退款 + +- 手工退款:`createManualRefund` 锁订单行、按订单项计算剩余可退、插入 `order_refunds`、全额退完置 `orders.pay_status='refunded'`、关闭成本异常;提交后重算销售事实并同步总账。 +- 平台退款(快手 91):`approveAndRecordKuaishouRefund` 先调用平台“同意退款”,成功后再调用 `createManualRefund` 落本地。 +- 撤销:`voidOrderRefund` 把退款行置 `voided`,重算销售事实与总账。 + +**问题集中在平台路径和撤销路径,见 5.2、5.3。** + +### 4.5 销售事实 → 总账 + +`syncFinanceLedgerFromFacts` 按日聚合 `sales_daily_facts`: + +- 收入:借 `1101` 应收平台 / 贷 `4001` 商品销售收入 +- 退款:借 `4101` 退款冲减 / 贷 `1101` 应收平台 +- 成本:借 `5001` 履约成本 / 贷 `2001` 应付履约款 + +幂等键:`event_type + ref_type + ref_id`,其中 `ref_id = 日期`。 + +**评价:** + +- 能生成一张“借贷平衡”的日汇总账; +- 但只使用 `1101/2001/4001/4101/5001` 五个科目,`1001/1002/1201/2005/3001/5101` 从未使用; +- 没有现金收款、没有应付结算、没有备用金/积分资产、没有钱包/押金/提现; +- 因此它不是完整复式账,而是**收入/成本权责发生制的影子账**。 + +### 4.6 备用金 + +- `fund_accounts`:主管池 / 客服账户;`balance_fen` 由应用层维护。 +- `fund_entries`:`opening/funding/issue/expense/return/adjustment`,只追加。 +- `manual_expenses`:客服支出明细,必须关联一条 `fund_entries`。 +- 发放/缴回会写主管与客服两条相反流水,`transfer_no` 关联,设计正确。 +- 对账:`fund_reconciliations` 记录系统余额、实际余额、差额。 + +**优点:** + +- 写路径统一收口到 `writeFundExpense`; +- 发放/缴回/支出都先 `SELECT ... FOR UPDATE` 锁账户; +- `balance_fen >= 0`、`amount_fen <> 0` 等约束齐全。 + +**问题:** + +- 对账差额在创建时计算,确认时直接拿来调整当前余额(见 5.5); +- 发放/缴回/拨付/积分充值没有幂等键(见 5.9); +- 账户余额没有数据库级“余额 = Σ流水”约束。 + +### 4.7 平台对账 + +- 手工粘贴 JSON 账单,按 `provider + platform_order_id` 匹配订单/退款。 +- 明细状态:`matched / amount_mismatch / missing_order / missing_refund / duplicate`。 +- 批次可标记 `resolved`。 + +**问题:** + +- 销售行按 `orders.total_amount` 匹配,不按订单项; +- 退款行没有 `external_ref` 时取该订单“最新一条退款”,多笔退款会重复匹配同一条; +- 批次没有唯一约束,重复导入同一账单不会被拦截(见 5.10)。 + +--- + +## 五、正确性审计:按严重度排列 + +> 说明:以下“P0”指上生产前必须修,否则可能造成账实不符、资金损失或无法审计;“P1”指会显著降低报表/内控可信度;“P2”指体验、性能或边界问题。 + +### 5.1 P0:关账期间销售事实仍可重算,总账却冻结 + +**证据:** + +- `rebuildSalesDailyFacts` 直接 `DELETE FROM sales_daily_facts WHERE stat_date BETWEEN ...`,没有任何 `finance_periods` 检查:[sales-facts-service.ts:47–57](../apps/backend/src/services/finance/sales-facts-service.ts#L47-L57) +- 定时任务每 5 分钟对最近 30 天重算:[cost-finance-sync-job.ts:47–55](../apps/backend/src/services/scheduler/cost-finance-sync-job.ts#L47-L55) +- 退款提交后立即重算原销售日:[order-refund-service.ts:301–305](../apps/backend/src/services/finance/order-refund-service.ts#L301-L305) +- 总账同步遇到已关账期间直接 `skipped`:[finance-ledger-service.ts:197–205](../apps/backend/src/services/finance/finance-ledger-service.ts#L197-L205) + +**触发路径:** + +1. 9 月关账; +2. 10 月发生一笔 9 月订单的退款; +3. `createManualRefund` 重算 9 月某天的 `sales_daily_facts`; +4. 总账同步看到 9 月已关账,跳过; +5. 9 月销售毛利报表已经变化,9 月总账仍是关账时点数字。 + +**影响:** + +- 报表与总账永久分叉; +- 关账失去意义; +- 无法向审计解释“关账后收入为什么变了”。 + +**修复建议:** + +- 方案 A(推荐):已关账期间的退款/成本变化不直接改原期间事实,改为在**当前开放期间**生成“以前年度/以前期间调整”事实或总账调整凭证; +- 方案 B:关账时把 `sales_daily_facts` 快照化(按期间冻结),后续变更只写调整表; +- 无论哪种,`rebuildSalesDailyFacts` 必须检查 `finance_periods.status`,不能静默重写已关账期间。 + +### 5.2 P0:平台退款先调用平台,后落本地,且没有幂等键 + +**证据:** + +- 平台调用在第 522 行,本地 `createManualRefund` 在第 529 行:[order-refund-service.ts:522–541](../apps/backend/src/services/finance/order-refund-service.ts#L522-L541) +- 可退金额校验在 `createManualRefund` 内部(第 188 行),也就是平台调用之后:[order-refund-service.ts:181–193](../apps/backend/src/services/finance/order-refund-service.ts#L181-L193) +- `order_refunds` 只有 `refund_no` 唯一,没有 `(source, external_ref)` 唯一约束:[103_finance_foundation.sql](../apps/backend/src/db/migrations/103_finance_foundation.sql) + +**触发路径:** + +- 平台退款金额大于本地剩余可退金额 → 平台已同意退款,本地 `createManualRefund` 抛 409,账上没记; +- 平台调用成功、本地插入成功,但接口超时/响应丢失 → 重试可能再插一条; +- 平台退款单号重复提交 → 没有数据库层拦截。 + +**影响:** + +- 平台已退款、系统未退款:应收/收入虚高; +- 重复退款:收入被多冲减; +- 对账时平台有退款、系统无记录或金额不一致。 + +**修复建议:** + +- 引入退款状态机:`requested → platform_approved → recorded → reconciled`(或 outbox); +- 在调用平台前先落“退款意图”,并做剩余可退金额预校验; +- 对 `(source, external_ref)` 建唯一索引(`external_ref <> ''` 的部分唯一索引); +- 平台调用使用平台退款单号做幂等键;本地失败要有补偿/重试任务,不能只抛错。 + +### 5.3 P0:全额退款撤销后,收入仍然是 0 + +**证据:** + +- 全额退款时把订单置为 `refunded`:[order-refund-service.ts:240–263](../apps/backend/src/services/finance/order-refund-service.ts#L240-L263) +- 撤销只更新 `order_refunds.status='voided'`,没有恢复 `orders.pay_status`:[order-refund-service.ts:354–366](../apps/backend/src/services/finance/order-refund-service.ts#L354-L366) +- 销售事实在没有 confirmed 退款时,仍会把 `pay_status='refunded'` 当作全额退款:[sales-facts-service.ts:70–79](../apps/backend/src/services/finance/sales-facts-service.ts#L70-L79) + +**影响:** + +- 撤销全额退款后,订单仍是 `refunded`,销售事实仍然按全额退款冲减; +- 成本异常在退款时被置为 `ignored`,撤销时没有重新打开; +- 订单领取/核销等下游逻辑也可能继续把订单当作已退款。 + +**修复建议:** + +- `voidOrderRefund` 在同一事务内根据“剩余 confirmed 退款”重算 `orders.pay_status`(0 → `paid`,否则保持 `refunded`); +- 撤销后重新打开被自动关闭的 `cost_exceptions`(`status='open'`,清空 `resolved_at`); +- 补一条集成测试:全额退款 → 撤销 → 销售事实恢复、总账恢复、异常恢复。 + +### 5.4 P0:关账没有门槛、没有锁,且可以重复关账 + +**证据:** + +- `closeFinancePeriod` 只是先同步、再算试算平衡,然后直接 upsert 为 `closed`;没有 `if (!trial.balanced) throw`,也没有检查 `cost_missing_count`、人工成本、资金流水、对账差异:[finance-ledger-service.ts:413–448](../apps/backend/src/services/finance/finance-ledger-service.ts#L413-L448) +- `assertPeriodOpen` 是普通 `SELECT`,没有 `FOR UPDATE` 或 advisory lock:[finance-ledger-service.ts:537–548](../apps/backend/src/services/finance/finance-ledger-service.ts#L537-L548) +- 文档明确要求“人工单无成本/未扣备用金/金额不一致不允许关账”:[财务与履约口径总纲:126–130](./财务与履约口径总纲-2026-10-23.md#L126-L130) +- 文档说“关账前会先补齐该期间凭证,并校验试算平衡”:[财务上线初始化与验收:283](./财务上线初始化与验收-2026-10-26.md#L283) + +**影响:** + +- 可以带着大量 `cost_missing` 关账,毛利失真; +- 可以带着未对账的平台差异关账; +- 并发请求可以在关账检查与提交之间写入凭证; +- 重复关账会覆盖 `closed_at` 与 `summary_json`,关账审计被改写。 + +**修复建议:** + +- 关账放在一个事务里,先对 `finance_periods` 行加锁(或 `pg_advisory_xact_lock`),再检查门槛,再写 `closed`; +- 门槛至少包括:试算平衡、`cost_missing_count=0`、人工单无成本=0、人工支出与流水差异=0、未解决对账差异=0; +- 已关账期间再次关账应拒绝或幂等返回,不得覆盖原 `closed_at`; +- 关账/反关账必须要求填写原因,并写审计。 + +### 5.5 P0:备用金对账使用“创建时的旧差额”调整“当前余额” + +**证据:** + +- 创建对账时保存 `system_balance_fen` 与 `difference_fen`:[admin-fund-service.ts:2001–2031](../apps/backend/src/services/admin/admin-fund-service.ts#L2001-L2031) +- 确认时读的是存储的 `difference_fen`,加到**当前**余额:[admin-fund-service.ts:2087](../apps/backend/src/services/admin/admin-fund-service.ts#L2087)、[admin-fund-service.ts:2115–2126](../apps/backend/src/services/admin/admin-fund-service.ts#L2115-L2126) + +**触发路径:** + +1. 创建对账:系统余额 100,实际 90,差额 -10; +2. 确认前发生一笔 20 元支出,余额变成 80; +3. 确认时用 -10 调整当前余额 80 → 70; +4. 实际盘点 90,账实仍然差 20。 + +**影响:** + +- 对账确认后余额仍不等于实际; +- 差异被“调整”进了 adjustment 流水,但金额不对,后续更难查; +- 如果创建与确认间隔很久,问题更严重。 + +**修复建议:** + +- 确认时锁账户并重新计算 `actualBalanceFen - currentSystemBalanceFen`; +- 或者创建对账时就锁账户/冻结期间,禁止期间内再发生流水; +- `difference_fen` 应作为“确认时点的差额”,而不是创建时的快照。 + +### 5.6 P1:权限矩阵与文档/实现不一致,且缺少职责分离 + +**证据:** + +- 人工履约路由只允许 `admin / operator / support`,**不包含 finance**:[finance.ts:107–126](../apps/backend/src/routes/admin/finance.ts#L107-L126) +- 但服务层 `PRIVILEGED_ROLES` 明确包含 finance,说明代码作者预期 finance 可以代录:[manual-fulfillment-service.ts:493–500](../apps/backend/src/services/finance/manual-fulfillment-service.ts#L493-L500) +- 文档说“管理员/财务可代录到指定客服账户”:[人工支出与备用金.md:32](./人工支出与备用金.md#L32) +- 同一篇文档又说“管理员/财务/运营可代录”:[人工支出与备用金.md:104](./人工支出与备用金.md#L104) +- `finance.config` 被用于成本配置、手工凭证、退款、关账、积分调整等所有写操作;`finance.review`、`finance.export` 基本未使用:[072_admin_finance_role.sql:11–14](../apps/backend/src/db/migrations/072_admin_finance_role.sql#L11-L14)、[103_finance_foundation.sql](../apps/backend/src/db/migrations/103_finance_foundation.sql) + +**影响:** + +- 文档承诺的“财务代录”实际不可用; +- `operator` 可以跨客服代录备用金/积分(`resolveFundAccountId` / `resolveCreditOwnerAdminUserId` 只校验目标账户存在,不校验团队归属),是否符合内控需要明确; +- 同一个 `finance` 用户可以改成本、做手工凭证、退款、关账、调积分,没有 maker-checker; +- 无法把“制单/审核/关账”拆给不同人。 + +**修复建议:** + +- 明确能力矩阵,拆出 `finance.post` / `finance.close` / `finance.reconcile` / `finance.cost_config`; +- 财务角色要么允许人工履约,要么在文档中删掉“财务代录”; +- 关账、手工凭证、退款、积分调整至少两人复核(制单人与审核人不能同一人); +- 代录接口增加目标客服的责任主管/团队范围校验(如果业务要求团队隔离)。 + +### 5.7 P1:`cost_mode` 字段在确认时没有生效 + +**证据:** + +- 配置表有 `cost_mode`(`fixed/variable`):[104_finance_controls.sql](../apps/backend/src/db/migrations/104_finance_controls.sql) +- 确认时读取了 `cfg.cost_mode`,但 `resolveManualFulfillmentCost` 只接收 `creditQuantity/unitPriceFen/providedCostFen`,没有接收 `costMode`:[manual-fulfillment-service.ts:285–298](../apps/backend/src/services/finance/manual-fulfillment-service.ts#L285-L298)、[manual-fulfillment-service.ts:670–697](../apps/backend/src/services/finance/manual-fulfillment-service.ts#L670-L697) + +**影响:** + +- 保存逻辑目前按 `creditQuantity > 0` 自动派生 `cost_mode`,所以正常 UI 路径一致; +- 但 DB 导入、历史数据、手工改库可以造出 `cost_mode='variable' + credit_quantity>0`,系统仍按固定成本扣款; +- 字段语义是“装饰性”的,容易误导配置人员。 + +**修复建议:** + +- 要么删除 `cost_mode`,只保留 `credit_quantity`(0 = 可变); +- 要么在确认时真正读取 `cost_mode`,并校验“fixed 必须有 credit_quantity>0,variable 必须为 0”; +- 增加一条配置一致性约束/检查 SQL。 + +### 5.8 P1:人工快照的扣款人 metadata 不准确 + +**证据:** + +- 快照 metadata 的 `creditOwnerAdminUserId` 取的是请求参数,而不是 `resolveCreditOwnerAdminUserId` 的返回值:[manual-fulfillment-service.ts:398–408](../apps/backend/src/services/finance/manual-fulfillment-service.ts#L398-L408) + +**影响:** + +- 客服自助确认时,请求里没有 `creditOwnerAdminUserId`,metadata 为 `null`,但积分实际从该客服账户扣除; +- 审计/对账按 metadata 找人会对不上,只能再查 `platform_credit_entries`。 + +**修复建议:** + +- 把解析后的 `creditOwnerAdminUserId` 传进快照写入; +- 同时记录积分账户 ID/流水号,保证能从快照直接追溯到资金流水。 + +### 5.9 P1:幂等与审计缺口 + +| 问题 | 证据 | 影响 | +| --- | --- | --- | +| 手工凭证重复 `reference` 静默复用旧凭证 | `ON CONFLICT DO NOTHING` 后返回 existing:[finance-ledger-service.ts:80–110](../apps/backend/src/services/finance/finance-ledger-service.ts#L80-L110) | 用户以为新建成功,实际未入账;审计 targetId 指向旧凭证 | +| 凭证金额小数被 `Math.trunc` 截断 | [finance-ledger-service.ts:503–504](../apps/backend/src/services/finance/finance-ledger-service.ts#L503-L504) | 1.9 分变 1 分,金额被静默修改 | +| 备用金拨付/发放/缴回无幂等键 | [admin-fund-service.ts:1248](../apps/backend/src/services/admin/admin-fund-service.ts#L1248)、[1305](../apps/backend/src/services/admin/admin-fund-service.ts#L1305)、[1431](../apps/backend/src/services/admin/admin-fund-service.ts#L1431) | 双击/重试可能重复转账 | +| 积分充值/调整无幂等键 | [platform-credit-service.ts:165](../apps/backend/src/services/cost-center/platform-credit-service.ts#L165)、[219](../apps/backend/src/services/cost-center/platform-credit-service.ts#L219) | 重复充值 | +| 审计日志写入失败被吞 | [session.ts:156–165](../apps/backend/src/routes/admin/session.ts#L156-L165) | 财务操作成功但没有审计记录 | + +**修复建议:** + +- 所有资金写命令接收 `requestId/idempotencyKey`,在 DB 建唯一键; +- 审计写入与业务同事务,或写 outbox 后异步落库;财务操作不允许“审计失败但业务成功”; +- 金额入参必须是整数分,拒绝小数;`reference` 重复应返回 409,而不是静默复用。 + +### 5.10 P1:平台对账的匹配粒度与批次幂等不足 + +**证据:** + +- 销售行用 `orders.total_amount` 作为 expected,而不是订单项/账单行:[finance-reconciliation-service.ts:292–298](../apps/backend/src/services/finance/finance-reconciliation-service.ts#L292-L298) +- 退款行没有 `external_ref` 时取该订单最新一条 confirmed 退款:[finance-reconciliation-service.ts:301–318](../apps/backend/src/services/finance/finance-reconciliation-service.ts#L301-L318) +- `finance_reconciliations` 没有 `(provider, period_from, period_to, source)` 唯一约束:[104_finance_controls.sql](../apps/backend/src/db/migrations/104_finance_controls.sql) + +**影响:** + +- 一单多品、平台按明细行出账时,销售行金额对不上; +- 同一订单多笔退款且账单不带退款单号时,多行可能匹配到同一条退款,误报 matched; +- 同一账单可以重复导入,形成多个批次,差异统计重复; +- `expected_fen` 对 missing 行记 0,汇总差额不是真正的“全部行差额”。 + +**修复建议:** + +- 账单行增加订单项/平台明细号维度,按明细匹配; +- 退款匹配必须要求平台退款单号,或至少要求金额+时间+订单项; +- 对导入文件做 hash/批次唯一约束,重复导入拒绝或幂等; +- missing 行也纳入 expected/actual 的差异计算,并区分“系统缺失”和“金额不符”。 + +### 5.11 P1:成本版本“追溯改价”就地 UPDATE,破坏版本历史 + +**证据:** + +- 追溯改价时直接 `UPDATE cost_versions SET amount_fen=..., effective_from=..., created_by=..., approved_by=...`:[product-catalog-service.ts:1397–1415](../apps/backend/src/services/cost-center/product-catalog-service.ts#L1397-L1415) +- 自动发货价格同样有就地更新:[affiliate-product-service.ts:348–362](../apps/backend/src/services/cost-center/affiliate-product-service.ts#L348-L362) +- 文档明确“改价只新增版本;不覆盖历史”:[成本中心与履约模型.md:232](./成本中心与履约模型.md#L232)、[:265](./成本中心与履约模型.md#L265) +- `approved_by_admin_user_id` 被写成当前操作人,等于自批:[product-catalog-service.ts:1404](../apps/backend/src/services/cost-center/product-catalog-service.ts#L1404) + +**影响:** + +- `cost_snapshots` 的历史金额仍保留,但 `cost_version_id` 指向的版本内容已经变了; +- 想从版本表还原“当时生效的价格”会得到错误结果; +- 审计无法区分“当时定价”与“后来改价”; +- 自批使 `approved_by` 字段失去意义。 + +**修复建议:** + +- 追溯改价也插入新版本(更早的 `effective_from`),旧版本保留并设置 `effective_to`; +- 如果必须修正错误版本,保留原行并新增“更正版本”,不要 UPDATE 金额; +- `approved_by` 必须与 `created_by` 不同,或至少记录审批时间/原因。 + +### 5.12 P1:总账只是“销售/成本影子账”,不能当完整复式账 + +**证据:** + +- 总账只生成三类凭证:收入、退款、成本:[finance-ledger-service.ts:212–249](../apps/backend/src/services/finance/finance-ledger-service.ts#L212-L249) +- 所有成本都进 `5001`,没有区分人工 `5101`:[finance-ledger-service.ts:244–248](../apps/backend/src/services/finance/finance-ledger-service.ts#L244-L248) +- 科目表里的 `1001/1002/1201/2005/3001/5101` 从未被写入(本机库查询确认) +- `finance_journal_lines.dimensions_json` 在销售凭证中始终是 `{}`,没有 provider/shop/product/order 维度 + +**影响:** + +- 试算平衡只能证明“每张凭证自身借贷相等”,不能证明账务完整; +- 资产负债表科目(银行、备用金、积分预付、应付)没有发生额,无法与钱包/资金对账; +- 无法输出店铺/商品/订单级损益; +- 不能把“总账”作为正式账套使用。 + +**修复建议:** + +- 明确命名:当前是“销售/成本影子账”,不要对外称完整总账; +- 若要转正,补齐钱包、押金、提现、充值、寄卖、售后、平台费、备用金/积分的完整事件与结算分录; +- 给销售凭证写入 `provider/shopId/productId/orderId/fulfillmentMode` 维度; +- 增加“总账 vs 销售事实 vs 钱包/备用金”的日对账。 + +### 5.13 P1:三套报表口径并存,缺少对账 + +| 口径 | 位置 | 差异点 | +| --- | --- | --- | +| 旧收支明细 | `admin-finance-service.ts` | 订单收款、退款、打手报酬、提现、充值、售后追缴的 `UNION ALL`;按事件日期 | +| 销售毛利 | `sales-facts-service.ts` | 权责发生制;按 `paid_at` 归属;退款回写原销售日 | +| 影子总账 | `finance-ledger-service.ts` | 只读销售事实;按日总额 | + +**影响:** + +- 同一时间段,“收款/退款/打手报酬”在三个页面可能不同; +- 财务/运营无法判断哪个是权威口径; +- 对外报表没有唯一来源。 + +**修复建议:** + +- 明确唯一权威口径(建议:销售事实 + 总账,旧报表只做现金流辅助); +- 在页面上标注口径与“不可直接比较”; +- 增加日对账:旧报表事件 vs 销售事实 vs 总账。 + +### 5.14 P2:默认日期使用 UTC,月初边界会取错月份 + +**证据:** + +- 试算平衡和总账同步路由用 `new Date().toISOString().slice(0,10)` 取“今天”:[finance-ledger.ts:48](../apps/backend/src/routes/admin/finance-ledger.ts#L48)、[:78](../apps/backend/src/routes/admin/finance-ledger.ts#L78) + +**影响:** + +- 上海时间每月 1 日 00:00–07:59,UTC 还是上个月最后一天; +- 默认期间会变成上个月,用户看到的试算平衡/同步范围不对。 + +**修复建议:** + +- 统一用 `formatShanghaiDate(new Date())`,不要用 UTC 日期。 + +### 5.15 P2:金额精度与 JS Number + +- 数据库用 `BIGINT` 分,正确;但服务层大量 `Number(bigint)` 再回写。 +- 当前规模(十亿分级别)远低于 `Number.MAX_SAFE_INTEGER`,暂时安全。 +- 但 `Math.trunc` 接受小数、`Number` 转换没有上限校验;未来接入更大金额/多币种时需要改成字符串/整数运算。 + +--- + +## 六、数据质量现状(本机开发库,非生产) + +> 以下数字来自本机 Docker `order_site-postgres-1` 的只读查询(2026-10-27),**不是生产库**。列在这里是为了说明:在“未按上线手册初始化”的默认状态下,财务页面会呈现什么,以及为什么关账前必须做数据门禁。 + +| 指标 | 数值 | 解读 | +| --- | ---: | --- | +| 订单 / 订单项 | 375,459 / 375,447 | 与文档基线量级一致 | +| 销售事实行 / 日期范围 | 5,381 / 2026-09-25–2026-10-22 | 只有 28 天,不是全历史 | +| 未归一订单项 | 340,225 / 375,447 = **90.6%** | 商品级毛利基本不可用 | +| `work_order` 未归一 | 153,148 / 153,148 = **100%** | 接单平台商品维度全缺 | +| `affiliate_dash` 未归一 | 53,204 / 53,204 = **100%** | 自动发货商品维度全缺 | +| `legacy_platform` 未归一 | 53,901 / 53,901 = **100%** | 历史平台商品维度全缺 | +| `manual` 未归一 | 79,972 / 115,194 = **69.4%** | 人工也只有三成挂了商品 | +| 标准商品 / 渠道映射 | 542 / **0** | 本机库只有 `man:` 商品,没有渠道映射 | +| 成本快照 | 72,297,全部 `work_order_reward` | 没有人工/自动发货成本快照 | +| 缺成本订单项 | 63,764 / 146,338 = **43.6%** | 这些项成本按 0 计 | +| 缺成本对应收入 | ¥8,730,818.99 / ¥13,230,754.79 = **66.0%** | 毛利被严重高估 | +| 成本异常 | 146,338(111,116 open) | 主要是 `product_mapping_missing` | +| 人工 SKU 配置 / 登记 | 0 / 0 | 人工链路未初始化 | +| 备用金账户 / 支出 | 0 / 0 | 资金链路未初始化 | +| 总账凭证 / 分录 | 56 / 112 | 只有 2026-09、2026-10 两个月 | +| 总账不平衡凭证 | 0 | 应用层校验有效 | +| 已使用科目 | 4 个(1101/2001/4001/5001) | 1001/1002/1201/2005/3001/5101 全为 0 | +| 期间 / 对账批次 | 0 / 0 | 尚未关账、未导入账单 | + +**结论:** + +- 当前库的“毛利 ¥1,047.91 万”不可用于决策:66% 的收入没有成本,真实毛利会显著更低; +- 商品级分析只能覆盖约 9.4% 的订单项; +- 这不是代码必然结果,而是“初始化/回填未完成”的结果;但系统没有在页面上阻止用户把这种毛利当真实毛利(只给了 warning)。 + +--- + +## 七、权限、审计与内控 + +### 7.1 角色/能力矩阵(按代码) + +| 能力 | admin | finance | operator | support | +| --- | --- | --- | --- | --- | +| 查看总账/试算平衡 | ✅ | ✅ | ✅(有 `finance.view`) | ❌ | +| 手工凭证 / 关账 / 反关账 | ✅ | ✅ | ❌ | ❌ | +| 成本配置 / 成本版本 | ✅ | ✅ | ❌ | ❌ | +| 手工退款 / 撤销退款 | ✅ | ✅ | ❌ | ❌ | +| 快手平台退款 | ✅ | ✅ | ❌ | ❌ | +| 人工履约待办 / 确认 | ✅ | ❌(路由排除) | ✅ | ✅ | +| 备用金查看 | ✅ 全部 | ✅ 全部 | ✅(主管范围) | ✅ 本人 | +| 备用金发放 | ✅ | ❌ | ✅ | ❌ | +| 备用金缴回 | ✅ | ❌ | ✅ | ✅(本人) | +| 备用金支出 | ✅ | ❌ | ❌ | ✅(本人) | +| 备用金对账 | ✅ | ✅ | ✅ | ❌ | +| 备用金差异调整 | ✅ | ✅ | ❌ | ❌ | +| 积分充值/调整 | ✅ | ✅ | ❌ | ❌ | + +### 7.2 内控问题 + +1. **职责分离不足**:`finance.config` 同时允许配置成本、做手工凭证、退款、关账、调积分;没有制单/审核分离。 +2. **关账无审批**:关账/反关账只要求 `finance.config`,没有二次确认/原因/审批。 +3. **审计可丢失**:`recordAdminAudit` 捕获异常后只 `logWarn`;财务操作可以成功而无审计。 +4. **审计非不可变**:`admin_audit_logs` 没有防篡改/哈希链;应用可删改。 +5. **代录范围宽**:`operator` 可以指定任意客服的备用金/积分账户(`resolveFundAccountId` / `resolveCreditOwnerAdminUserId` 只校验存在性),是否符合“主管只能管自己团队”的内控需要业务确认。 +6. **前端操作缺少确认**:关账/反关账是单按钮直接 mutation,没有二次确认;对账“标记解决”固定写“后台确认差异已处理”,没有让用户填写原因([AdminFinanceLedgerPage.tsx:216](../apps/frontend/src/pages/admin/AdminFinanceLedgerPage.tsx#L216))。 + +--- + +## 八、性能与运维 + +### 8.1 当前性能设计 + +- 财务同步任务 `cost-finance-sync` 默认每 5 分钟跑最近 30 天: + - 履约方式兜底 + - 人工商品归一 + - 人工配置 rematch + - 预估成本快照 + - 工单实际成本快照 + - 销售事实重算 + - 总账同步 +- `sales_daily_facts` 重建是“删 30 天 + 重插 30 天”,目前 5,381 行,成本可接受;但订单量增长后会变重。 +- 总账同步按天循环,每天至少 1 次 period 查询 + 1 个事务 + 0–3 条凭证;30 天约 30 个事务。 +- 迁移 108 增加了 `idx_orders_basis_date`、`idx_order_items_order_sku_cover` 等索引,方向正确。 + +### 8.2 风险 + +- 每 5 分钟全量重算 30 天,会与在线退款/人工确认争锁;`sales_daily_facts` 的 `DELETE` 会锁范围行。 +- 没有历史分区;`finance_journal_*`、`sales_daily_facts` 未来会持续增长。 +- 没有“同步失败告警/重试队列”:定时任务失败只会在日志里,下一次再试;退款后的即时同步失败不会自动补偿。 +- 数据保留/归档目前只覆盖 `cost_exceptions`,不覆盖 `finance_journal_*`、`sales_daily_facts`。 + +### 8.3 建议 + +- 销售事实改为按日增量重算(只重算有变化的日期),而不是固定 30 天全量; +- 总账同步与事实重算放在同一任务内做“日期级”幂等,并记录 `last_success_at`; +- 关键任务失败接入告警(至少 `logError` + 监控); +- 对 `finance_journal_entries/lines`、`sales_daily_facts` 规划按月分区或归档。 + +--- + +## 九、测试与验证 + +### 9.1 本次实际执行 + +| 命令 | 结果 | +| --- | --- | +| `cd apps/backend && npx tsc -p tsconfig.json --noEmit` | 通过 | +| `cd apps/backend && npm run lint:check` | 通过 | +| `cd apps/backend && npm test` | 853 tests,849 pass,4 skipped,0 fail | +| `cd apps/frontend && npm test` | 181 tests,181 pass,0 fail | +| 财务定向单测(15 项) | 全部通过 | + +> 4 个 skipped 中包含需要真实 PostgreSQL 的 `manual-fulfillment.integration.test.ts`;默认不设置 `MANUAL_FULFILLMENT_TEST_DATABASE_URL` 时会跳过。 + +### 9.2 已覆盖 + +- 人工履约:固定成本、可变成本、重复确认、并发确认、快照失败回滚、代录指定账户; +- 总账:销售事实同步、借贷平衡、手工凭证、不平衡拒绝、关账阻断、对账匹配; +- 备用金/积分:单事务扣款、余额不足、异常收口(集成测试中部分覆盖)。 + +### 9.3 未覆盖(建议补) + +- 已关账期间发生退款/人工确认 → 事实与总账分叉; +- 平台退款金额超过剩余可退 → 平台已调用、本地失败; +- 全额退款撤销 → 收入/异常恢复; +- 备用金对账创建后发生流水 → 确认差额; +- 重复导入同一平台账单; +- 权限矩阵(finance 被排除、operator 代录范围); +- 审计写入失败时财务操作是否应回滚; +- 手工凭证重复 `reference`; +- 金额小数输入。 + +--- + +## 十、改进路线图 + +### P0:上生产前(建议 1–2 周) + +| # | 动作 | 验收标准 | +| --- | --- | --- | +| 1 | 关账期间事实冻结/调整机制 | 已关账期间的 `sales_daily_facts` 不再被重算;退款/成本变化走当前期间调整 | +| 2 | 退款状态机 + 幂等 | `(source, external_ref)` 唯一;平台调用前预校验;失败可补偿 | +| 3 | 全额退款撤销修复 | 撤销后 `pay_status` 恢复、事实恢复、异常重开 | +| 4 | 关账门槛 + 锁 | 不满足门槛无法关账;并发写入被阻断;重复关账不覆盖审计 | +| 5 | 备用金对账差额重算 | 确认时按当前余额重算,账实相等 | +| 6 | 数据库硬约束 | 凭证借贷平衡触发器、已过账不可删改、`fund_accounts` 余额校验、`manual_expenses(order_item_id)` 唯一 | +| 7 | 权限/审计 | 拆分 `finance.post/close/reconcile`;关键操作双人复核;审计与业务同事务 | + +### P1:2–4 周 + +| # | 动作 | 验收标准 | +| --- | --- | --- | +| 8 | 对账按订单项/平台明细匹配 | 多品订单、多笔退款可正确匹配 | +| 9 | 统一报表口径 | 旧收支明细、销售毛利、总账有明确主口径与日对账 | +| 10 | 成本版本 append-only | 追溯改价也新增版本;`approved_by` 不再自批 | +| 11 | 总账维度 | 凭证行写 provider/shop/product/order;可出订单/商品损益 | +| 12 | 数据质量门禁 | 上线范围 `cost_missing=0`、人工单 100% 有快照/流水、未归一占比可解释 | +| 13 | 幂等键 | 发放/缴回/拨付/积分充值/手工凭证全部支持 requestId | + +### P2:后续 + +- 完整总账:钱包、押金、提现、充值、寄卖、售后、平台服务费; +- 反冲/red reversal 工作流; +- 利润表、资产负债表、现金流量简表; +- 发票/税务/多币种; +- 总账/事实表分区与归档; +- 前端关账二次确认、对账差异原因必填。 + +--- + +## 十一、本次已实施改进(2026-10-27) + +本轮按路线图先落 P0,并继续 P1 的 requestId 幂等、成本版本 append-only 与平台对账维度改造。 + +### 11.1 已完成 + +| # | 改进 | 关键实现 | 验证 | +| --- | --- | --- | --- | +| 1 | 关账期间销售事实冻结 | `rebuildSalesDailyFacts` 跳过 `finance_periods.status='closed'` 的月份,返回 `skippedClosedPeriods`;退款/撤销/平台退款在已关账期间直接 409 | 本地库临时关闭 2026-09 后重算,9 月 `computed_at` 不变,10 月正常重算 | +| 2 | 总账改为追加调整,不再删除重建 | `syncFinanceLedgerFromFacts` 按日算 delta,使用 `sales_*_adjustment` 凭证;按日 advisory lock 防并发重复 | 新代码中不再有 `DELETE FROM finance_journal_entries`;现有 10 月数据同步 delta=0 | +| 3 | 凭证不可删改 + 借贷平衡硬约束 | 迁移 `104`:`trg_finance_journal_balanced`、`trg_finance_journal_immutable` | 手工插入不平衡凭证被数据库拒绝;删除有分录的已过账凭证被拒绝 | +| 3.1 | 备用金余额勾稽硬约束 | 迁移 `104`:`trg_fund_entries_balance`、`trg_fund_accounts_balance`,按账户校验 `balance_fen = Σ fund_entries.amount_fen` | 余额 100 但无流水的账户被数据库拒绝;期初+流水一致时通过 | +| 4 | 平台退款先校验、先落意图 | `finance_refund_intents` 状态机;`approveAndRecordKuaishouRefund` 在调用平台前校验可退金额;`order_refunds(source, external_ref)` 唯一 | 金额超可退时在调用平台前返回 409;集成测试通过 | +| 5 | 全额退款撤销恢复收入 | `voidOrderRefund` 按剩余 confirmed 退款重算 `orders.pay_status`,并重新打开被自动关闭的成本异常 | 空库集成脚本:退款后 `refunded/0`,撤销后 `paid/5000` | +| 6 | 关账门槛 + 期间锁 | `assertPeriodCloseable` 校验人工缺成本、人工无资金流水、人工金额不一致、未解决对账差异、试算平衡;关账与凭证共用 `finance_period` advisory lock;重复关账 409 | 集成测试先被“人工未确认成本”拦住,补齐后才允许关账 | +| 7 | 备用金对账差额按当前余额重算 | `confirmAdminFundReconciliation` 在锁内用 `actual - current balance` 计算差额,并回写 `system_balance_fen/difference_fen` | 静态回归测试覆盖 | +| 8 | 权限拆分 + finance 纳入人工履约 | 新增 `finance.post / finance.close / finance.reconcile`;人工履约路由与订单查看路由加入 `finance`;前端按新权限显示 | 迁移 `104` + 路由/前端类型检查通过 | +| 9 | 凭证重复 reference 不再静默复用 | `postJournalEntry` 默认重复幂等键抛 409;金额必须是整数分 | 类型检查与全量测试通过 | +| 10 | 关账/反关账二次确认 | 前端 `Popconfirm` 明确提示关账后不可写、会校验门槛 | 前端类型检查通过 | + +### 11.2 本次新增/修改的主要文件 + +- 财务基础:[103_finance_foundation.sql](../apps/backend/src/db/migrations/103_finance_foundation.sql)(商品/成本/销售事实/退款) +- 财务控制与 L1:[104_finance_controls.sql](../apps/backend/src/db/migrations/104_finance_controls.sql)(人工履约/总账/对账/控制/钱包硬化) +- 拼单与寄卖:[105_work_order_consignment.sql](../apps/backend/src/db/migrations/105_work_order_consignment.sql) +- 总账:[finance-ledger-service.ts](../apps/backend/src/services/finance/finance-ledger-service.ts) +- 销售事实:[sales-facts-service.ts](../apps/backend/src/services/finance/sales-facts-service.ts) +- 退款:[order-refund-service.ts](../apps/backend/src/services/finance/order-refund-service.ts) +- 备用金/积分:[admin-fund-service.ts](../apps/backend/src/services/admin/admin-fund-service.ts)、[platform-credit-service.ts](../apps/backend/src/services/cost-center/platform-credit-service.ts) +- 成本版本:[product-catalog-service.ts](../apps/backend/src/services/cost-center/product-catalog-service.ts)、[affiliate-product-service.ts](../apps/backend/src/services/cost-center/affiliate-product-service.ts) +- 平台对账:[finance-reconciliation-service.ts](../apps/backend/src/services/finance/finance-reconciliation-service.ts) +- 权限:[finance.ts](../apps/backend/src/routes/admin/finance.ts)、[finance-ledger.ts](../apps/backend/src/routes/admin/finance-ledger.ts)、[orders.ts](../apps/backend/src/routes/admin/orders.ts)、[kuaishou-industry.ts](../apps/backend/src/routes/admin/kuaishou-industry.ts) +- 前端:[App.tsx](../apps/frontend/src/App.tsx)、[AdminFinanceLedgerPage.tsx](../apps/frontend/src/pages/admin/AdminFinanceLedgerPage.tsx)、[AdminOrderDetailPage.tsx](../apps/frontend/src/pages/admin/AdminOrderDetailPage.tsx)、[AdminSalesProfitPage.tsx](../apps/frontend/src/pages/admin/AdminSalesProfitPage.tsx) +- 回归测试:[finance-controls.test.ts](../apps/backend/src/services/finance/finance-controls.test.ts) + +### 11.3 验证结果 + +| 检查 | 结果 | +| --- | --- | +| 后端类型检查 | 通过 | +| 后端 Lint / Prettier | 通过 | +| 后端全量测试 | 866 项,862 pass,4 skipped | +| 前端类型检查 / 测试 | 通过;181 pass | +| 空库全量迁移 `001 → 105` | 通过 | +| 人工履约集成测试(空库) | 通过 | +| 数据库触发器手测 | 不平衡凭证、删除已过账凭证、余额与流水不一致均被拒绝 | +| 销售事实关账冻结手测 | 9 月 `computed_at` 不变,10 月正常重算 | +| P1 资金 requestId 手测 | 同 requestId 第二次返回 409,余额只增加一次 | +| P1 积分 requestId 手测 | 同 requestId 第二次返回 409,积分只增加一次 | +| P1 成本版本 append-only 手测 | 旧版本 retired 且金额不变,新版本 active 从追溯日生效 | +| P1 对账订单项匹配手测 | 一单两品两行全部 matched,`order_item_id` 正确落库 | + +### 11.3.1 L1 打手钱包硬化(迁移 104) + +| 项 | 内容 | +| --- | --- | +| 金额类型 | `orders.total_amount`、`worker_wallets.*`、`worker_wallet_ledgers.amount/balance_after/frozen_after`、`worker_finance_requests.amount/frozen_amount`、工单/份额/售后/寄卖金额全部 int4 → **BIGINT** | +| 非负约束 | `worker_wallets` 六个余额、流水后余额、提现申请、订单/工单/份额/售后/寄卖金额加 CHECK | +| 奖励幂等 | `worker_wallet_ledgers.event_key` + 触发器 + 唯一索引:工单+份额+流水类型,防未来重复结算 | +| 历史重复 | 新增只读视图 `v_worker_wallet_duplicate_rewards`;排除寄卖货款后为 **8 组**疑似重复 `reward_settlement`(16 条流水,合计 ¥392.40),多计约 **¥196.20**,不自动扣回,需财务确认 | +| 审计脚本 | [audit-worker-wallet.sql](../apps/backend/scripts/audit-worker-wallet.sql) 只读检查负余额、负后余额、历史重复、event_key 缺失 | + +### 11.4 P1 本轮已完成 + +| # | 改进 | 关键实现 | 验证 | +| --- | --- | --- | --- | +| 1 | 资金/积分 requestId 幂等 | 迁移 `104` 给 `fund_entries` / `platform_credit_entries` 加 `request_id` + 账户级唯一索引;重复请求 409,事务回滚 | 同 requestId 发放/充值第二次 409,余额只变一次 | +| 2 | 成本版本追溯改价 append-only | 追溯改价不再 UPDATE 金额,而是退休旧版本 + 新增从追溯日生效的版本;旧版本行保留 | 旧版本 `retired` 且金额不变,新版本 `active` 从追溯日生效 | +| 2.1 | 人工确认关账保护 | 已关账期间的订单项确认直接 409,避免“扣了钱但销售事实/总账没记” | 代码路径 + 回归测试覆盖 | +| 3 | 平台对账订单项/明细维度 | 迁移 `104` 给 `finance_reconciliation_items` 加 `platform_item_id/order_item_id`;账单行可用 `orderItemId` 按订单项收入匹配;退款无外部单号时至少要求金额一致 | 一单两品两行全部 matched,`order_item_id` 正确落库 | +| 4 | 对账重复行判定 | 销售/退款重复键加入平台明细号/订单项/金额,避免一单多品被误判 duplicate | 两行同订单不同订单项不再互判重复 | + +### 11.5 仍未完成(按路线图继续) + +1. **已关账期间的“当前期间调整”**:目前是“冻结 + 禁止直接退款/撤销”,还没有自动生成“以前期间调整”事实/凭证;跨期退款需先反关账。 +2. **成本版本同时间/上游基线导入的更正**:相同生效时间没有可插入的时间区间,仍会有一次就地 UPDATE;上游价格基线导入的部分路径也仍可能就地更正。需要在 schema 上支持同时间多版本或引入版本变更审计表。 +3. **总账维度与完整覆盖**:仍只覆盖销售/成本,钱包/押金/提现/寄卖/平台费未入账;凭证行还没有 provider/shop/product 维度。 +4. **审计与业务同事务**:审计失败仍只记录告警,未做到“审计失败则财务操作回滚”。 +5. **maker-checker**:手工凭证、关账、退款、积分调整仍未强制双人复核。 +6. **前端 requestId 复用**:后端已支持 requestId,前端尚未在“同一用户意图重试”时复用同一个 key。 + +--- + +## 附录 A:关键文件索引 + +| 文件 | 作用 | +| --- | --- | +| [finance-ledger-service.ts](../apps/backend/src/services/finance/finance-ledger-service.ts) | 凭证、试算平衡、关账、销售事实同步 | +| [sales-facts-service.ts](../apps/backend/src/services/finance/sales-facts-service.ts) | 销售事实重算、毛利总览 | +| [order-refund-service.ts](../apps/backend/src/services/finance/order-refund-service.ts) | 手工/平台退款、撤销 | +| [manual-fulfillment-service.ts](../apps/backend/src/services/finance/manual-fulfillment-service.ts) | 人工履约确认、扣备用金/积分、写快照 | +| [finance-reconciliation-service.ts](../apps/backend/src/services/finance/finance-reconciliation-service.ts) | 平台账单导入、匹配、解决 | +| [admin-fund-service.ts](../apps/backend/src/services/admin/admin-fund-service.ts) | 备用金账户、流水、支出、对账 | +| [admin-finance-service.ts](../apps/backend/src/services/admin/admin-finance-service.ts) | 旧版收支明细 | +| [cost-snapshot-sync-service.ts](../apps/backend/src/services/cost-center/cost-snapshot-sync-service.ts) | 预估/实际成本快照同步 | +| [product-catalog-service.ts](../apps/backend/src/services/cost-center/product-catalog-service.ts) | 商品、成本版本、追溯改价 | +| [platform-credit-service.ts](../apps/backend/src/services/cost-center/platform-credit-service.ts) | 积分钱包 | +| [100_fund_accounts.sql](../apps/backend/src/db/migrations/100_fund_accounts.sql) | 备用金表(历史基线) | +| [103_finance_foundation.sql](../apps/backend/src/db/migrations/103_finance_foundation.sql) | 商品、成本中心、销售事实、退款 | +| [104_finance_controls.sql](../apps/backend/src/db/migrations/104_finance_controls.sql) | 人工履约、总账/对账、财务控制、L1 钱包 | +| [105_work_order_consignment.sql](../apps/backend/src/db/migrations/105_work_order_consignment.sql) | 拼单、索引、寄卖对接客服 | + +## 附录 B:上生产前建议执行的复核 SQL + +```sql +-- 1. 凭证是否借贷平衡(应为 0 行) +SELECT entry_id +FROM finance_journal_lines +GROUP BY entry_id +HAVING SUM(debit_fen) <> SUM(credit_fen); + +-- 2. 备用金余额是否等于流水合计(应为 0 行) +SELECT a.id, a.account_no, a.balance_fen, COALESCE(SUM(e.amount_fen), 0) AS entry_sum +FROM fund_accounts a +LEFT JOIN fund_entries e ON e.account_id = a.id +GROUP BY a.id, a.account_no, a.balance_fen +HAVING a.balance_fen <> COALESCE(SUM(e.amount_fen), 0); + +-- 3. 人工现金成本与备用金支出是否一致(应为 0 行) +SELECT cs.id, me.expense_no, me.amount_fen, cs.total_amount_fen +FROM cost_snapshots cs +JOIN manual_expenses me ON me.order_item_id = cs.order_item_id +WHERE cs.subject_type = 'manual_case' + AND cs.superseded_at IS NULL + AND cs.total_amount_fen <> me.amount_fen; + +-- 4. 关账后是否还在改销售事实(应为 0 行) +SELECT f.stat_date, f.computed_at, p.closed_at +FROM sales_daily_facts f +JOIN finance_periods p ON p.period_key = to_char(f.stat_date, 'YYYY-MM') +WHERE p.status = 'closed' + AND f.computed_at > p.closed_at; + +-- 5. 平台退款是否重复(应为 0 行) +SELECT source, external_ref, COUNT(*) +FROM order_refunds +WHERE external_ref <> '' +GROUP BY source, external_ref +HAVING COUNT(*) > 1; + +-- 6. 同一订单项是否有多条人工支出(应为 0 行) +SELECT order_item_id, COUNT(*) +FROM manual_expenses +WHERE order_item_id IS NOT NULL +GROUP BY order_item_id +HAVING COUNT(*) > 1; + +-- 7. 成本缺失与未归一规模(关账门禁输入) +SELECT + COUNT(*) FILTER (WHERE cost_missing_count > 0) AS rows_with_missing_cost, + SUM(cost_missing_count) AS missing_items, + SUM(gross_revenue_fen) FILTER (WHERE cost_stage = 'missing') AS missing_revenue_fen +FROM sales_daily_facts; + +-- 8. 总账 vs 销售事实按日收入差异(应为 0 行) +WITH ledger AS ( + SELECT e.period_key, e.ref_id::date AS stat_date, + SUM(l.credit_fen) FILTER (WHERE l.account_code = '4001') AS ledger_revenue_fen + FROM finance_journal_entries e + JOIN finance_journal_lines l ON l.entry_id = e.id + WHERE e.event_type = 'sales_revenue' + GROUP BY e.period_key, e.ref_id +) +SELECT f.stat_date, f.gross_revenue_fen, l.ledger_revenue_fen +FROM sales_daily_facts f +FULL JOIN ledger l ON l.stat_date = f.stat_date +WHERE COALESCE(f.gross_revenue_fen, 0) <> COALESCE(l.ledger_revenue_fen, 0); +``` + +--- + +## 附录 C:术语 + +| 术语 | 含义 | +| --- | --- | +| 履约方式 `fulfillment_mode` | 谁执行:`work_order` / `affiliate_dash` / `legacy_platform` / `manual` | +| 成本模型 `cost_model` | 成本怎么算:采购 / 打手报酬 / 人工实际 / 平台费 / 支付费 / 售后损失 / 其他 | +| 销售事实 `sales_daily_facts` | 按日、来源、店铺、商品、履约方式聚合的收入/退款/成本/毛利 | +| 成本快照 `cost_snapshots` | 某个订单项/工单/人工单实际使用的成本版本与金额,历史不回写 | +| 影子总账 | 并行记录复式分录、但不接管钱包余额的账 | +| 备用金 | 公司拨给主管、再由主管发给客服的周转现金 | +| 积分钱包 | 南航/乐玩/ROCK 三个平台的客服积分账户,互不通用 | +| 拼单 | 同一工单由多个打手按份额参与,按份额结算 | diff --git a/docs/项目全流程与数据库设计梳理-2026-10-23.md b/docs/项目全流程与数据库设计梳理-2026-10-23.md index 59edf8d6..62d2cedb 100644 --- a/docs/项目全流程与数据库设计梳理-2026-10-23.md +++ b/docs/项目全流程与数据库设计梳理-2026-10-23.md @@ -873,4 +873,3 @@ flowchart LR ## 九、需要你确认的问题 见对话中的提问(不在文档内固化,确认后我再补「口径确认」章节)。 -