chore(db): 将 108–115 补丁迁移收敛为 108–111

- 原 108/111/112/113/114 合并为 108_finance_cost_integrity:
  成本异常归档、人工成本完整性、影子总账、商品页/模板查询索引
- 原 115 注释迁移改为 111_work_order_share_cancel_reason_comment
- 新增 reconcile-migration-history.sql,用于已跑旧 112–115 的库
  重建 schema_migrations 顺序,不重复执行 DDL
- 已验证:001→111、101→111 两条升级路径;
  旧 115 与新 111 的 pg_dump schema 完全一致
- 测试/文档迁移编号同步到 103–111

验证:backend check 853(849 pass / 4 skip);frontend check 172 + build。
This commit is contained in:
yml2213
2026-10-27 12:37:21 +08:00
parent e686a77a9e
commit 0de0fd7b58
19 changed files with 394 additions and 360 deletions
@@ -0,0 +1,54 @@
-- 旧 108–115 → 新 108–111 的迁移元数据校准(仅用于已经跑过旧 112–115 的本地/测试库)。
--
-- 背景:2026-10-27 把原 108/111/112/113/114 合并成 108_finance_cost_integrity,
-- 原 115 注释迁移改名为 111。新库从 001/101 直接跑新文件即可;
-- 已跑过旧 112–115 的库 schema 已等价,只需把 schema_migrations 的旧名字替换掉,
-- 否则 node-pg-migrate 会因 108 顺序冲突拒绝启动。
--
-- 用法(先备份):
-- 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
--
-- 幂等:只在检测到旧 112_finance_ledger 时执行。
BEGIN;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM schema_migrations WHERE name = '112_finance_ledger') THEN
RAISE NOTICE '未检测到旧 112_finance_ledger,跳过迁移历史校准';
RETURN;
END IF;
-- 108–111 整段重建,保证 node-pg-migrate 的 runNames 顺序与文件顺序一致。
-- 109/110 的 schema 已经存在,重新登记不会重复执行。
DELETE FROM schema_migrations
WHERE name IN (
'108_cost_exception_archive',
'108_finance_cost_integrity',
'109_work_order_share_participation',
'110_work_order_hall_and_shop_indexes',
'111_manual_cost_integrity',
'111_work_order_share_cancel_reason_comment',
'112_finance_ledger',
'113_cost_center_query_indexes',
'114_template_evidence_indexes',
'115_work_order_share_cancel_reason_comment'
);
INSERT INTO schema_migrations (name, filename, run_on)
VALUES
('108_finance_cost_integrity', '108_finance_cost_integrity.sql', NOW()),
('109_work_order_share_participation', '109_work_order_share_participation.sql', NOW()),
('110_work_order_hall_and_shop_indexes', '110_work_order_hall_and_shop_indexes.sql', NOW()),
('111_work_order_share_cancel_reason_comment', '111_work_order_share_cancel_reason_comment.sql', NOW())
ON CONFLICT (name) DO NOTHING;
-- 旧 115 的注释效果已经在 schema 里;这里保证新基线也带上同一注释。
COMMENT ON COLUMN work_order_shares.cancelled_reason IS
'取消来源:timeout/worker_request/admin/admin_partial/problem/settlement';
RAISE NOTICE '迁移历史已校准为 108–111';
END $$;
COMMIT;
@@ -1,49 +0,0 @@
-- 108_cost_exception_archive.sql —— 成本异常归档表。
--
-- 背景(2026-10-25):
-- cost_exceptions 只增不减。本地快照上 260,253 行(resolved 151,939 / open 108,314),
-- 其中 open 的 product_mapping_missing 与 cost_missing 仍是可操作队列,
-- 但已处理(resolved / ignored)的历史行只是审计痕迹,继续留在热表里拖慢:
-- - 按状态 + 时间分页要扫过大量已完成行;
-- - 唯一索引 uq_cost_exceptions_subject_reason 随行数膨胀。
--
-- 策略:
-- 1. 只归档**已处理**(status <> 'open')且超过保留期(默认 60 天)的行;
-- 2. open 行永远留在热表,避免把未解决的问题藏起来;
-- 3. 归档按批(每次 1000 行)在 data-retention 定时任务里执行,不锁全表;
-- 4. 归档表不建外键:历史异常可能指向已删除的订单项/工单,保留原值即可。
--
-- 保留期由 COST_EXCEPTION_RETENTION_DAYS 控制(默认 60)。
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';
@@ -0,0 +1,301 @@
-- 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;
@@ -1,85 +0,0 @@
-- 111_manual_cost_integrity.sql
-- 人工成本完整性:一个订单项只允许一条有效的人工成本快照。
--
-- 背景:
-- 1. 旧实现把 cost_snapshots.subject_id 写成 manual_expenses.id 或
-- platform_credit_entries.id(两张表各自 BIGSERIAL),与唯一索引
-- (subject_type, subject_id, cost_item_id) 叠加后,两条链路会互相撞 id:
-- 第二次合法确认被当成「已有人工成本快照」拒绝,而钱已经扣了。
-- 2. 人工确认原先是「先扣款、后写快照」两个事务,快照失败会留下已扣款未记成本。
-- 现在改为同一事务,并由本迁移提供数据库级兜底:
-- 一个 order_item 只允许一条有效 (superseded_at IS NULL) 的 manual_case 快照。
--
-- 语义调整:
-- * subject_type = 'manual_case' 的 subject_id 统一为 order_item_id;
-- 资金来源(manual_expenses.id / platform_credit_entries.id)只写 metadata_json,
-- 不再作为 subject_id。
-- * 正数 subject_id 的历史行(旧实现写入的支出单/积分流水 id)修正为 order_item_id;
-- 负数 subject_id 的历史孤儿快照按迁移 104 的约定保留,不参与新链路。
-- * 重复正数行保留最新一条,其余标记 superseded,避免唯一索引创建失败。
--
-- 全部语句幂等,可安全重跑。
-- 1. 历史人工快照的 subject_id 归一为 order_item_id。
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 快照(并发重复确认的数据库兜底)';
@@ -1,152 +0,0 @@
-- 112_finance_ledger.sql
-- 财务总账(影子账):科目、凭证、期间关账、平台对账。
--
-- 设计边界:
-- * 影子账不接管现有钱包余额;现有 worker_wallets / fund_accounts 仍是运行时权威。
-- * 凭证按业务/日期幂等(event_type + ref_type + ref_id 唯一),可重复同步。
-- * 销售事实同步按日生成收入/退款/成本凭证;人工确认履约时补记「付款冲应付」凭证。
-- * 期间关账只阻断该期间的新凭证,不修改历史凭证。
--
-- 全部建表/种子幂等。
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';
@@ -1,25 +0,0 @@
-- 113_cost_center_query_indexes.sql
-- 成本中心商品页性能:总览的「未映射订单项」与商品列表的 affiliate 任务聚合。
--
-- 背景:测试库 40 万 order_items 时,总览对 canonical_product_id IS NULL 做全表扫描约 3.8s;
-- 商品列表在人工 Tab 仍会聚合 30 天 affiliate 任务,测试机 IO 差时超过 10s。
-- 两个索引都是部分索引,体积远小于全表索引。
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;
@@ -1,17 +0,0 @@
-- 114_template_evidence_indexes.sql
-- 接单模板初始化预览:按订单日期筛 30 天,再按订单项取 SKU 证据。
-- 测试库 40 万订单项时缺索引会到 40 秒;两个索引把预览降到秒级。
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;
@@ -127,9 +127,9 @@ test('人工成本缺失必须进入异常队列,且只监控首次配置之
)
})
test('迁移 111 提供订单项级人工成本唯一约束与 subject 语义约束', async () => {
test('迁移 108 提供订单项级人工成本唯一约束与 subject 语义约束', async () => {
const source = await readFile(
new URL('../../db/migrations/111_manual_cost_integrity.sql', import.meta.url),
new URL('../../db/migrations/108_finance_cost_integrity.sql', import.meta.url),
'utf8',
)
assert.ok(source.includes('uq_cost_snapshots_manual_order_item'), '缺少订单项级人工快照唯一索引')
@@ -146,7 +146,10 @@ test('迁移 111 提供订单项级人工成本唯一约束与 subject 语义约
test('总账/对账/关账/平台退款能力已接入', async () => {
const [migration, ledger, reconciliation, refund, manual] = await Promise.all([
readFile(new URL('../../db/migrations/112_finance_ledger.sql', import.meta.url), 'utf8'),
readFile(
new URL('../../db/migrations/108_finance_cost_integrity.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'),
+5 -5
View File
@@ -1,7 +1,7 @@
# order_site 文档索引
> 最后校准:2026-10-26,代码基线 `dev`(合并 `dev1023` + 人工成本完整性修复,迁移到 `112`;**未推送、未部署生产**)。
> 数据基线:生产 dump `backups/postgres/order_site-20261022-092601.dump`(数据 2026-07-10 ~ 2026-10-22);本地生产副本已升级到 112,只跑了近 30 天增量。历史全量数字仅作基线,**上线验收以 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 为准**。
> 最后校准: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) 为准**。
> 本文是 `docs/` 的唯一入口。**财务与履约口径以 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) 为准**;数据模型看 [成本中心与履约模型.md](成本中心与履约模型.md),接口看 [成本中心API契约.md](成本中心API契约.md)。
> 新增文档必须在下表登记状态;过期文档不再直接改写结论,统一移入 `docs/archive/`。
@@ -91,7 +91,7 @@
| 平台对账 / 期间关账 | **已实施 P0** | 平台账单 JSON 导入匹配、差异明细、标记解决;关账/反关账;见 `/admin/finance/ledger` |
| 平台退款 API | **部分实施** | 快手行业(91)同意退款已接入;抖音/拼多多暂未接 API,仍手工登记 |
> 分支状态(2026-10-26):`dev` 已合并 `dev1023` 与人工成本完整性修复,迁移到 `112`;本地生产副本已验证 101 → 112(109/110 索引在生产规模数据上实测 < 0.3s),临时库全量执行与重跑幂等通过。**未推送、未部署生产**;生产口径仍以 `main` 为准。上线前按 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 初始化资金与人工 SKU。
> 分支状态(2026-10-26):`dev` 已合并 `dev1023` 与人工成本完整性修复,迁移基线收敛为 `103`–`111`(原 108–115 的财务/性能补丁合并进 108);临时库从 001 全量执行与从 101 升级均待验收。**未推送、未部署生产**;生产口径仍以 `main` 为准。上线前按 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 初始化资金与人工 SKU。
> 财务与履约口径以 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) 为准。
## 三、统一实施顺序
@@ -103,7 +103,7 @@
2.5 人工成本链路收敛(确认履约 → 扣积分/备用金 → 写快照) 已完成 P0(迁移 104/107/111)
3. 人工商品归一与上线后人工成本全覆盖 已完成代码;生产数据需上线后配置 SKU(历史不补录)
4. 退款冲减与异常队列 已完成手工退款 + 快手平台退款;人工缺成本已入队
5. 影子总账(销售事实 + 手工凭证) 已完成 P0(迁移 112)
5. 影子总账(销售事实 + 手工凭证) 已完成 P0(迁移 108)
6. 平台对账、期间关账 已完成 P0(手工账单导入);平台自动拉单未实施
```
@@ -129,7 +129,7 @@
| 文档 | 状态 | 用途 |
| --- | --- | --- |
| [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) | **现行唯一口径** | 履约方式、成本模型、人工全覆盖、历史策略、实施顺序、运营日检与验收 SQL |
| [成本中心与履约模型.md](成本中心与履约模型.md) | 现行 | 商品、成本版本、快照、异常、销售事实的数据模型;迁移 `103`–`112`;履约回填;迁移基线与本地库同步;索引与性能 |
| [成本中心与履约模型.md](成本中心与履约模型.md) | 现行 | 商品、成本版本、快照、异常、销售事实的数据模型;迁移 `103`–`111`;履约回填;迁移基线与本地库同步;索引与性能 |
| [成本中心API契约.md](成本中心API契约.md) | 现行 | 成本中心接口字段替换总表、枚举、模块边界(后端 5 模块 + 前端 4 个固定 Tab) |
| [人工支出与备用金.md](人工支出与备用金.md) | 现行(P0 已实施) | 确认履约流程/权限、客服备用金台账、本人账户与编号规则 |
| [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) | **现行上线手册** | 迁移后资金/人工 SKU 初始化、验收 SQL、日常监控 |
@@ -1,6 +1,6 @@
# 多发货平台与电子凭证整体链路
> 状态:现行。最后校准:2026-10-23(代码基线 `dev`(合并 dev1023 后)+ 迁移 `103`–`112`;履约方式已统一落库)。
> 状态:现行。最后校准:2026-10-23(代码基线 `dev`(合并 dev1023 后)+ 迁移 `103`–`111`;履约方式已统一落库)。
> 财务与履约总口径见 [财务与履约口径总纲-2026-10-23.md](../财务与履约口径总纲-2026-10-23.md)。
> 来源口径:本系统只有三个订单来源——`kuaishou-91`、`pdd`、`douyin-mashida`。**91 回调与快手 send-code 是同一个 `kuaishou-91` 来源的两条数据通道**(91=骨架权威源,快手 send-code=补全/兜底源),不是一个来源加另一个来源。
+1 -1
View File
@@ -2,7 +2,7 @@
> 状态:**现行说明(P0 已实施,2026-10-26)**
> 最后校准:2026-10-26
> 代码基线:`dev`(合并 dev1023 后)+ 迁移 `103`–`112`(已合并到 `dev`,**未推送、未部署生产**)
> 代码基线:`dev`(合并 dev1023 后)+ 迁移 `103`–`111`(已合并到 `dev`,**未推送、未部署生产**)
> 配套方案:[财务系统集成方案.md](财务系统集成方案.md)
> **总口径以 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) 为准。**
> 前置:统一履约事实、商品主数据、渠道映射、成本中心与销售事实;实施顺序以总纲第六节为准。
+1 -1
View File
@@ -2,7 +2,7 @@
> 状态:**现行**。后端服务与前端页面都必须严格按此实现,不得各自发挥。
> 最后校准:2026-10-25
> 代码基线:`dev`(合并 dev1023 后)+ 迁移 `103`–`112`(已合并到 `dev`,**未推送、未部署生产**)
> 代码基线:`dev`(合并 dev1023 后)+ 迁移 `103`–`111`(已合并到 `dev`,**未推送、未部署生产**)
> 详细业务口径见 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md);数据模型见 [成本中心与履约模型.md](成本中心与履约模型.md)。
## 一、字段替换总表
+15 -11
View File
@@ -2,7 +2,7 @@
> 状态:**现行**。商品主数据、成本中心、成本快照、销售事实、履约事实均已落地。
> 最后校准:2026-10-26
> 代码基线:`dev`(合并 dev1023 + 人工成本完整性修复,迁移到 `112`)
> 代码基线:`dev`(合并 dev1023 + 人工成本完整性修复,迁移基线收敛为 `103`–`111`)
> 数据基线:生产 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,7 +35,7 @@ orders ──┬── order_items ──── fulfillment_mode (唯一履约
## 一、迁移基线:10 个收敛为 5 个
原 `dev` 分支上,商品、成本中心、人工履约用 10 个**历史编号**迁移(`103`–`112`,1,275 行)分批落地,字段反复摇摆。其中:
原 `dev` 分支上,商品、成本中心、人工履约用 10 个**历史编号**迁移(`103`–`111`,1,275 行)分批落地,字段反复摇摆。其中:
- 原 `108`(下线人工支出登记)与原 `109`(隔离历史快照主体)在「生产 102 → 当前」的唯一升级路径上是**空操作**;
- 原 `111` 是给 `104` 自身种子打的补丁(draft 占位改 active 占位);
@@ -58,8 +58,8 @@ orders ──┬── order_items ──── fulfillment_mode (唯一履约
| `108_cost_exception_archive.sql` | 成本异常 60 天归档 |
| `109_work_order_share_participation.sql` | 拼单多次参与份额模型(与本模块无关,同批上线) |
| `110_work_order_hall_and_shop_indexes.sql` | 大厅置顶/店铺筛选索引(与本模块无关) |
| `111_manual_cost_integrity.sql` | 人工成本单事务兜底:订单项级唯一、`subject_id=order_item_id`、人工 SKU 匹配索引、`manual_cost_missing` 监控所需索引 |
| `112_finance_ledger.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` 保持独立(商品主数据是成本与销售事实的共同上游),`106` 保持独立(退款是独立业务线)。
@@ -428,18 +428,22 @@ WHERE oi.id = d.id
---
## 八、迁移基线与本地库同步(2026-10-25 更新)
## 八、迁移基线与本地库同步(2026-10-27 收敛)
生产尚未执行过 `103` 及之后的迁移。本地库与代码对齐有两条路径:
生产尚未执行过 `103` 及之后的迁移。2026-10-27 已把原来的 `108/111/112/113/114` 合并为 `108_finance_cost_integrity`,原 `115` 注释迁移改为 `111`;生产升级统一走 **101 → 111**,不再出现 108–115 的补丁链。
1. **全新升级**:从生产 dump 恢复到 `101` / `102`,直接 `npm run db:migrate` 升到 `111`;
2. **已有本地库**:清掉 `schema_migrations` 里 `103`–`112` 的记录后重跑。所有迁移都是 `IF NOT EXISTS`,数据步骤幂等:
1. **全新升级**:从生产 dump 恢复到 `101`,直接 `npm run db:migrate` 升到 `111`;
2. **已跑过旧 112–115 的本地/测试库**:schema 已等价,不要删 103–111 重跑;执行一次迁移历史校准:
```sql
DELETE FROM schema_migrations WHERE name ~ '^(10[3-9]|11[0-1])_';
```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
```
2026-10-25 实测:按第 2 条路径升级后的库,与全新构建的库 **schema 完全一致**(`pg_dump --schema-only` 仅随机 token 不同),种子数据(3 个系统成本项、2 个全局占位版本、4 个人工履约档案)也一致。
脚本只把 `schema_migrations` 的旧 `108–115` 行重建为新的 `108–111`,不重复执行 DDL;若库仍停留在旧 103–107,直接正常迁移即可。
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` 的改造矩阵时查阅,**不要在新环境执行**。
+1 -1
View File
@@ -1,6 +1,6 @@
# 接单模板匹配引擎重构设计
> 目标读者:后端 / 后台运营。状态:**P0 + P1 已实施;P2 / P3 待办**。最后校准:2026-10-23(代码基线 `dev`(合并 dev1023 后)+ 迁移 `103`–`112`;匹配业务逻辑未变)。
> 目标读者:后端 / 后台运营。状态:**P0 + P1 已实施;P2 / P3 待办**。最后校准:2026-10-23(代码基线 `dev`(合并 dev1023 后)+ 迁移 `103`–`111`;匹配业务逻辑未变)。
>
> 一句话:把「订单信息 → 接单模板(价格/拼单/要求)」的匹配,从散落的多个消费点、平台判断和"店铺必填"的绑定表里,收敛为**一个平台无关的引擎**:匹配条件完全由**订单信息**表达,**店铺只是可选条件之一**,新增来源只加一个适配器 + 一组配置。
>
@@ -1,7 +1,7 @@
# 财务/成本上线初始化与验收(2026-10-26)
> 状态:现行操作手册。适用于 `dev` 合并后的财务/成本模块上线;**历史人工成本不补录**。
> 代码基线:迁移 `103`–`112`(本手册写于人工成本完整性与配置自动匹配修复之后)。
> 代码基线:迁移 `103`–`111`(本手册写于人工成本完整性与配置自动匹配修复之后)。
> 数据基线:生产 dump `2026-10-22`(迁移 `101`)。历史人工支出找不回来,只保证**首次配置人工履约之后**的新单可记录、可监控。
---
@@ -42,7 +42,7 @@ bash deploy/ubuntu-deploy.sh --backup # 迁移前 pg_dump + OSS,
```sql
SELECT name FROM schema_migrations ORDER BY id DESC LIMIT 5;
-- 期望包含:111_manual_cost_integrity / 110_work_order_hall_and_shop_indexes / 109_work_order_share_participation
-- 期望包含:111_work_order_share_cancel_reason_comment / 110_work_order_hall_and_shop_indexes / 109_work_order_share_participation / 108_finance_cost_integrity
```
> 迁移 `109`/`110`/`111` 都是增量、幂等;`111` 会给 `orders(375k)` 建表达式索引,实测写阻塞约 200–300ms。
@@ -266,7 +266,7 @@ GROUP BY 1;
---
## 四、总账、期间关账与平台对账(迁移 112)
## 四、总账、期间关账与平台对账(迁移 108)
入口:后台「财务 → 总账与对账」(`/admin/finance/ledger`)。
@@ -2,7 +2,7 @@
> 状态:**现行唯一口径**。本文是财务、成本、履约分类的总入口;其他文档与本文件冲突时,以本文为准。
> 最后校准:2026-10-26
> 代码基线:`dev`(合并 dev1023 + 人工成本完整性修复,迁移到 `112`)
> 代码基线:`dev`(合并 dev1023 + 人工成本完整性修复,迁移到 `111`)
> 数据基线:§二/§七 的数字来自 2026-10-22 生产 dump 的**历史快照**,只用于理解历史缺口,**不再作为上线验收依据**;
> 上线初始化与验收以 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 为准。
> 适用范围:订单接入、履约判定、商品归一、成本快照、销售事实、人工录单、备用金、后续总账与对账。
@@ -181,7 +181,7 @@ P0 的具体迁移、回填、代码清单与验收 SQL 见 [成本中心与履
| 影子总账、对账、关账 | **已实施 P0** | 迁移 `112`;销售事实按日凭证 + 手工凭证 + 试算平衡 + 账单导入对账 + 关账/反关账 |
> 历史平台仍有 53,901 个订单项(¥389.12 万)未归一,不属于 P1 范围;人工历史成本按既定口径不补录,因此人工毛利当前是“收入已归商品、成本仅新单有”的状态。
> 分支事实:以上实现已合并到 `dev`;迁移 `103`–`112` **未推送远端、未部署生产**。生产口径仍以 `main` 为准;上线前按 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 初始化资金与人工 SKU。
> 分支事实:以上实现已合并到 `dev`;迁移 `103`–`111` **未推送远端、未部署生产**。生产口径仍以 `main` 为准;上线前按 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 初始化资金与人工 SKU。
---
+3 -3
View File
@@ -2,7 +2,7 @@
> 状态:**现行方案(影子总账 P0 已实施,2026-10-26)**。`finance_accounts / journal_entries / journal_lines / periods / reconciliations` 已建;总账不接管钱包余额;对账当前为手工账单导入,平台自动拉单待接。
> 最后校准:2026-10-26
> 代码基线:`dev`(迁移到 `112`,**未推送、未部署生产**)
> 代码基线:`dev`(迁移到 `111`,**未推送、未部署生产**)
> 说明:本文是总账/对账/关账的目标架构与分期;当前已实施 P0(销售事实凭证、手工凭证、试算平衡、期间关账、手工账单对账)与快手平台退款。初始化步骤见 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md)。
> **履约方式、成本模型、人工全覆盖口径以 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) 为准**;本文不重复分类设计。
> 目标:在**不破坏现有抢单、押金、结算、寄卖、提现链路**的前提下,增加可对账、可关账、可追溯的复式记账底座。
@@ -21,8 +21,8 @@
| `sales_daily_facts` / 订单级毛利 | **已实施 P0(`dev` 分支)**,迁移 `105`;94 天收入 ¥2,650.87 万、成本 ¥950.27 万 |
| 人工成本录入(确认履约 → 扣积分/备用金 → 写快照) | **已实施 P0(`dev` 分支)**,迁移 `104/107`;2026-10-24 人工履约页下线、入口收敛到订单页;**无登记单、无审批、无报销** |
| 履约方式统一 `fulfillment_mode` | **已实施 P0(`dev` 分支)**;迁移 `105`,316,585 项已回填;订单页、成本快照、销售事实统一读该字段 |
| `finance_accounts` / `finance_journal_entries` / `finance_journal_lines` | **已实施 P0(迁移 112)** |
| `finance_periods` / `finance_reconciliations` / `finance_reconciliation_items` | **已实施 P0(迁移 112)** |
| `finance_accounts` / `finance_journal_entries` / `finance_journal_lines` | **已实施 P0(迁移 108)** |
| `finance_periods` / `finance_reconciliations` / `finance_reconciliation_items` | **已实施 P0(迁移 108)** |
| `finance_business_events` / `finance_settlements` | 未实施(当前用条目唯一键幂等) |
| 现有 `admin-finance-service.ts` | 仍是业务事件 `UNION ALL`,不是总账 |
+1 -1
View File
@@ -219,7 +219,7 @@ docker compose ps
docker compose logs -f backend
```
## 财务/成本上线后的初始化(迁移 102–112)
## 财务/成本上线后的初始化(迁移 102–111)
代码与迁移只负责建表;**备用金账户、积分余额、人工 SKU 配置不会自动创建**。
首次上线后按 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 执行: