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:
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user