refactor(db): 财务迁移收敛为 103–107

把 103–112 十个迁移收敛为五个,删除在「生产 102 → 当前」路径上的空操作与补丁:

- 108(下线人工支出登记)与 109(隔离历史快照主体)在生产库和重建库上都是空操作,
  口径注释并入 104;
- 111 是给 104 自身种子打的补丁,合并后 104 直接写入 active 0 元占位;
- 112 的 cost_exceptions.resolution_note 并入 104,退款表独立为 106;
- 106(备用金编号可读化)与 110(积分钱包)合并为 107(人工履约资金层);
- 107(履约方式列)并入 105,履约方式本就是销售事实的维度键。

配套调整:
- check-sql-guard 改为以 main 为基线校验迁移字节,删除 103–114 的永久豁免;
  未合并分支上的新迁移仍可重写,已发布迁移不可修改。
- 本地库原地改造脚本移入 docs/archive,禁止对新环境执行。

验证:清掉 schema_migrations 里 103–112 的记录后重跑,升级结果与全新构建的
schema 完全一致(pg_dump --schema-only 仅随机 token 不同),种子数据一致;
升级前后业务数据量不变(成本版本 713 → 712,差异来自删除占位草稿)。
This commit is contained in:
yml2213
2026-10-25 11:38:12 +08:00
parent 465a9f65dc
commit 472769a977
13 changed files with 206 additions and 333 deletions
@@ -24,7 +24,7 @@ async function main() {
)
if (!column.rows[0]?.exists) {
throw new Error('order_items.fulfillment_mode 不存在,请先执行 npm run db:migrate(迁移 107)')
throw new Error('order_items.fulfillment_mode 不存在,请先执行 npm run db:migrate(迁移 105)')
}
const startedAt = Date.now()
+44 -53
View File
@@ -88,18 +88,27 @@ const checks: Array<{
const failures: string[] = []
// 生产尚未执行 102;按产品确认,允许在发布前移除其中的机器码字段。
// 除此之外,所有历史迁移仍必须保持字节不变。
const allowedHistoricalMigrationChanges = new Set([
'M apps/backend/src/db/migrations/102_admin_phone_device_auth.sql',
])
// 迁移基线的解析顺序:环境变量 > main > origin/main。
// 用「已发布基线」而不是 HEAD 做比较,是因为以 HEAD 为基线时,本次提交新增的
// 迁移也会被算成历史迁移,合并提交后必然自相矛盾。
const MIGRATION_BASELINE_REF = resolveMigrationBaselineRef()
// 2026-10-23 财务重写:103–114 从未部署到生产,整体重写为 103–107 五个迁移。
// 这一组迁移在本分支内是可重写的,护栏不再逐个校验其字节。
const REWRITABLE_MIGRATION_PREFIX = /^1(0[3-9]|1[0-4])_/
function isRewritableFinanceMigration(file: string) {
return REWRITABLE_MIGRATION_PREFIX.test(path.basename(file))
function resolveMigrationBaselineRef(): string | null {
const candidates = [process.env.MIGRATION_BASELINE_REF, 'main', 'origin/main'].filter(
(value): value is string => Boolean(value && value.trim()),
)
for (const ref of candidates) {
try {
execFileSync('git', ['rev-parse', '--verify', '--quiet', ref], {
cwd: BACKEND_ROOT,
stdio: 'ignore',
})
return ref
} catch {
// 该基线不存在,尝试下一个
}
}
return null
}
// 窗口总数会在 LIMIT/OFFSET 之前消费全部候选行;全后端 SQL 都禁止引入该写法。
@@ -110,49 +119,31 @@ for (const file of listSourceFiles(path.join(BACKEND_ROOT, 'src'))) {
}
}
// 已应用的历史迁移必须保持字节不变,所有结构或数据修复都必须新增迁移文件。
try {
const diffOutputs = [
execFileSync('git', ['diff', '--name-status', 'HEAD', '--', 'src/db/migrations'], {
cwd: BACKEND_ROOT,
encoding: 'utf8',
}),
execFileSync('git', ['diff', '--name-status', 'HEAD^', 'HEAD', '--', 'src/db/migrations'], {
cwd: BACKEND_ROOT,
encoding: 'utf8',
}),
]
const changedHistoricalMigrations = diffOutputs
.flatMap((output) => output.split('\n'))
.map((line) => line.trim().split(/\s+/))
.filter(([status, file]) => status && file?.endsWith('.sql') && status !== 'A')
.filter(([status, file, renamedTo]) => !isMigrationRenumber(status, file, renamedTo))
.map(([status, file]) => `${status} ${file}`)
.filter((change) => !allowedHistoricalMigrationChanges.has(change))
.filter((change) => {
const file = change.split(' ').slice(1).join(' ')
return !isRewritableFinanceMigration(file)
})
if (changedHistoricalMigrations.length > 0) {
failures.push(`历史迁移文件不可修改: ${changedHistoricalMigrations.join(', ')}`)
// 已发布的历史迁移必须保持字节不变,所有结构或数据修复都必须新增迁移文件。
if (!MIGRATION_BASELINE_REF) {
console.warn('[check-sql] 未找到迁移基线分支(main / origin/main),跳过迁移字节检查')
} else {
try {
const diffOutput = execFileSync(
'git',
['diff', '--name-status', MIGRATION_BASELINE_REF, '--', 'src/db/migrations'],
{ cwd: BACKEND_ROOT, encoding: 'utf8' },
)
const changedHistoricalMigrations = diffOutput
.split('\n')
.map((line) => line.trim().split(/\s+/))
.filter(([status, file]) => status && file?.endsWith('.sql') && status !== 'A')
.map(([status, file]) => `${status} ${file}`)
if (changedHistoricalMigrations.length > 0) {
failures.push(
`已发布迁移文件不可修改(基线 ${MIGRATION_BASELINE_REF}): ${changedHistoricalMigrations.join(', ')}`,
)
}
} catch (error) {
failures.push(
`历史迁移完整性检查失败: ${error instanceof Error ? error.message : String(error)}`,
)
}
} catch (error) {
failures.push(`历史迁移完整性检查失败: ${error instanceof Error ? error.message : String(error)}`)
}
// 分支合流后把尚未上线的迁移顺延到主线版本号之后时,只允许改序号前缀,
// 文件名主体必须完全一致(例:102_product_catalog.sql -> 103_product_catalog.sql)。
function isMigrationRenumber(
status: string | undefined,
renamedFrom: string | undefined,
renamedTo: string | undefined,
) {
if (!status?.startsWith('R') || !renamedFrom || !renamedTo) return false
return stripMigrationPrefix(renamedFrom) === stripMigrationPrefix(renamedTo)
}
function stripMigrationPrefix(filePath: string) {
return path.basename(filePath).replace(/^\d{3}_/, '')
}
for (const check of checks) {
@@ -16,8 +16,13 @@
-- 口径(2026-10-22 确认):
-- * 自动发货 = 上游采购成本;
-- * 接单平台 = 打手报酬,按总额法计履约成本;
-- * 人工客服 = 人工支出登记的实际金额;
-- * 人工客服 = 订单页人工录单的实际金额;
-- * 收入按 paid_at 归属销售日;一单一品归整单,多品按等额拆分并标 basis。
--
-- 2026-10-25 合并:原 108(人工支出登记下线)、109(历史快照主体隔离)、
-- 111(人工成本占位版本)、112 的 resolution_note 列并入本迁移。这三条原迁移
-- 在「生产 102 → 当前」的唯一升级路径上是空操作,保留下来的只是口径注释与
-- 一次性的历史数据修补,故作废。
-- ---------------------------------------------------------------------------
-- 1. 成本项
@@ -215,7 +220,9 @@ BEGIN
END $$;
COMMENT ON TABLE cost_snapshots IS
'成本快照:订单项/工单/人工单实际使用的成本版本与金额;履约方式看 order_items.fulfillment_mode';
'成本快照:订单项/工单/人工单实际使用的成本版本与金额;履约方式看 order_items.fulfillment_mode;人工单 subject_id 指向 manual_expenses.id';
COMMENT ON COLUMN cost_snapshots.subject_id IS
'主体 id:order_item→order_items.id;work_order→work_orders.id;manual_case→manual_expenses.id;负数表示历史孤儿快照(已与新链路隔离)';
COMMENT ON COLUMN cost_snapshots.source IS 'resolved=成本中心命中;legacy_rule=旧规则回退;manual_input=人工;missing=未匹配';
COMMENT ON COLUMN cost_snapshots.cost_basis_at IS
'解析成本版本时使用的业务时间:订单支付时间/工单验收时间/人工完成时间';
@@ -237,9 +244,14 @@ CREATE TABLE IF NOT EXISTS cost_exceptions (
detail_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL,
resolved_at TIMESTAMPTZ,
resolved_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL
resolved_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
resolution_note TEXT NOT NULL DEFAULT ''
);
-- 兼容已存在异常表的环境:CREATE TABLE IF NOT EXISTS 不会补列,这里显式兜底。
ALTER TABLE cost_exceptions
ADD COLUMN IF NOT EXISTS resolution_note TEXT NOT NULL DEFAULT '';
CREATE UNIQUE INDEX IF NOT EXISTS uq_cost_exceptions_subject_reason
ON cost_exceptions (subject_type, subject_id, reason);
CREATE INDEX IF NOT EXISTS idx_cost_exceptions_status_created
@@ -263,6 +275,8 @@ END $$;
COMMENT ON TABLE cost_exceptions IS
'成本缺失/冲突异常队列;open 数量必须在后台暴露;履约方式看 order_items.fulfillment_mode';
COMMENT ON COLUMN cost_exceptions.resolution_note IS
'手动处理或退款自动关闭时记录的处理原因';
-- ---------------------------------------------------------------------------
-- 5. 接单模板过渡列
@@ -304,6 +318,7 @@ BEGIN
END $$;
-- 历史订单回填:当前生产快照全部是一单一品,仍按通用规则处理多订单项。
-- 幂等:只处理 revenue_basis 仍为 unknown 的行,重复执行不会二次拆分。
WITH item_counts AS (
SELECT order_id, COUNT(*)::int AS item_count
FROM order_items
@@ -383,7 +398,8 @@ SET
note = EXCLUDED.note,
updated_at = NOW();
-- 人工客服履约成本:只能由公司人工支出登记给出实际金额。
-- 人工客服履约成本:金额的唯一真源是 manual_fulfillment_configs.credit_quantity
-- × 平台换算率;cost_versions 里的这一行只是快照的挂载点,金额不参与计算。
INSERT INTO cost_items (
code, name, cost_model, pricing_mode, unit_name, currency,
status, note, created_at, updated_at
@@ -396,7 +412,7 @@ VALUES (
'单',
'CNY',
'active',
'系统内置:由公司人工支出登记写入 actual 快照;逐单实际金额,不按工时或固定单价',
'系统内置:订单页人工录单直接从客服备用金扣款并写入 actual 快照;逐单实际金额,不按工时或固定单价',
NOW(),
NOW()
)
@@ -448,7 +464,8 @@ FROM cost_items ci
WHERE ci.code = 'sys_work_order_reward'
ON CONFLICT DO NOTHING;
-- 人工客服:draft 版本,避免在财务未填写金额时静默按 0 计成本。
-- 人工客服:active 0 元占位,避免在财务未填写金额时静默按 0 计成本。
-- 幂等:已有 active 版本时不再新增,重复执行不会产生第二个生效版本。
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, source,
@@ -456,12 +473,26 @@ INSERT INTO cost_versions (
)
SELECT
ci.id, 'global', '', '', NULL, NULL, 0, 0,
'1970-01-01T00:00:00+08:00', 'manual', 'draft',
'草稿占位:待财务填写人工客服实际按单成本后启用',
'1970-01-01T00:00:00+08:00', 'manual', 'active',
'系统占位:人工成本按人工 SKU 配置的消耗积分结算,版本金额不参与计算',
NOW(), NOW()
FROM cost_items ci
WHERE ci.code = 'sys_manual_service'
ON CONFLICT DO NOTHING;
AND NOT EXISTS (
SELECT 1 FROM cost_versions v
WHERE v.cost_item_id = ci.id AND v.status = 'active'
);
-- 早期版本曾把人工成本写成 draft 草稿占位;现统一为上面的 active 0 元占位。
-- 只删除没有任何快照引用的草稿行,避免误删仍在引用的历史版本。
DELETE FROM cost_versions v
USING cost_items ci
WHERE ci.id = v.cost_item_id
AND ci.code = 'sys_manual_service'
AND v.status = 'draft'
AND NOT EXISTS (
SELECT 1 FROM cost_snapshots s WHERE s.cost_version_id = v.id
);
-- ---------------------------------------------------------------------------
-- 9. 财务角色补齐成本中心维护权限
@@ -1,13 +1,75 @@
-- 105_sales_daily_facts.sql —— 店铺 × 日期 × 标准商品 × 履约方式的销售事实。
-- 105_sales_daily_facts.sql —— 统一履约事实 + 店铺 × 日期 × 标准商品 × 履约方式的销售事实。
--
-- 口径(2026-10-23 重写):
-- 目标:订单页、成本快照、销售事实只读 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;
-- * 退款暂未接入,refund_amount_fen 先按 pay_status=refunded 预留。
-- * 退款金额来自 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,
@@ -1,51 +0,0 @@
-- 106_manual_expense_records.sql —— 备用金账户编号可读化。
--
-- 2026-10-23 口径调整:公司人工支出登记(manual_expense_records)与审批报销
-- 状态机(manual_expense_events)整链下线。人工成本改为「订单页人工录单 →
-- 直接从客服备用金扣款 → 直接写成本快照」,不再有登记单、审批与报销。
--
-- 因此本迁移不再创建这两张表,只保留备用金账户编号可读化:
-- manual_expenses(迁移 100)= 客服备用金支出明细,唯一的资金台账
-- cost_snapshots(迁移 104)= 人工成本的唯一落点,subject_type='manual_case'
--
-- 口径(2026-10-23 确认):
-- * 人工成本逐单、按实际金额录入,不按工时、不按固定单量/固定单价;
-- * 人工支出从客服备用金账户扣款,客服角色只能用本人账户;
-- * 历史人工支出找不回来、也不补录,只保证上线后的新单不丢;
-- * 没有审批与报销环节,录入即计入成本快照。
-- ---------------------------------------------------------------------------
-- 备用金账户编号改为可读的 FA-<用户名>
--
-- 历史编号是 FA + 10 位随机十六进制(如 FA7683D6B58B),在「备用金」列表里
-- 无法一眼对应到具体客服。新规则:FA-<大写用户名>,重名追加 -2、-3。
-- ---------------------------------------------------------------------------
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;
@@ -1,5 +1,10 @@
-- P2 退款冲减(手动录入先行,后续接平台退款 API)。
-- 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,
@@ -30,7 +35,4 @@ CREATE INDEX IF NOT EXISTS idx_order_refunds_time
COMMENT ON TABLE order_refunds IS '订单退款记录:手动录入先行,后续平台退款 API 写 source=api/platform';
COMMENT ON COLUMN order_refunds.batch_no IS '同一次操作的分组号;整单退款会按订单项拆成多条';
-- 异常闭环备注:手动处理或退款自动关闭时记录原因。
ALTER TABLE cost_exceptions
ADD COLUMN IF NOT EXISTS resolution_note text NOT NULL DEFAULT '';
COMMENT ON COLUMN order_refunds.source IS 'manual=后台手工登记;api/platform=平台退款接口;legacy=历史数据导入';
@@ -1,10 +1,11 @@
-- 110_manual_fulfillment_platform_credits.sql —— 客服人工履约:SKU 配置清单 + 三平台积分钱包。
-- 107_manual_fulfillment.sql —— 客服人工履约:SKU 配置清单 + 三平台积分钱包 + 备用金编号。
--
-- 背景(2026-10-24 确认):
-- 客服人工履约有四个资金/额度钱包,每个客服各一个:
-- 南航积分 / 乐玩积分 / ROCK 积分 / 备用金现金
-- 三个积分平台的积分**互不通用**,所以每个平台一套账户。
-- 换算率固定:1 积分 = 1 分钱(= 1 fen),三平台统一。
-- 换算率按平台不同,真源是履约档案 config_json.rechargeUnitPriceFen:
-- 南航 1 积分 = 1 分钱;乐玩 1 积分 = 1 分钱;ROCK 1 积分 = 1 元(100 分)。
--
-- 配置链路(无匹配引擎、无打分):
-- 1. 扫描「走到人工履约」的内部 SKU,登记到 manual_sku_registry;
@@ -17,6 +18,9 @@
-- * 同名 SKU 算同一个商品,配置全局唯一(跨渠道共用);
-- * 积分 0 = 成本不固定,由客服在确认履约时手填实际成本;
-- * 备用金现金不建账户行,走现有 fund_accounts / fund_entries。
--
-- 2026-10-25 合并:原 106(备用金账户编号可读化)并入本迁移。编号规则与人工履约
-- 共用同一套钱包语义,单独成文件会让「资金台账」散落在两个迁移里。
-- ---------------------------------------------------------------------------
-- 1. 人工 SKU 登记表:扫描到的「走到人工履约」的内部 SKU
@@ -78,9 +82,9 @@ CREATE INDEX IF NOT EXISTS idx_manual_fulfillment_configs_platform
COMMENT ON TABLE manual_fulfillment_configs IS
'人工履约配置:内部 SKU → 履约渠道 + 消耗积分 + 成本模式';
COMMENT ON COLUMN manual_fulfillment_configs.credit_quantity IS
'每单消耗积分(整数)。换算率固定 1 积分 = 1 分钱;0 表示成本不固定,客服确认履约时手填';
'每单消耗积分(整数)。换算率按平台配置;0 表示成本不固定,客服确认履约时手填';
COMMENT ON COLUMN manual_fulfillment_configs.cost_mode IS
'fixed=成本按积分固定(cost_fen = credit_quantity);variable=成本不固定,客服确认履约时手填';
'fixed=成本按积分固定(cost_fen = credit_quantity × 平台换算率);variable=成本不固定,客服确认履约时手填';
DO $$
BEGIN
@@ -129,7 +133,7 @@ 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 三平台积分互不通用,每平台每客服一个账户';
'客服平台积分钱包:南航/乐玩/ROCK 三平台积分互不通用,每平台每客服一个账户;换算率按平台配置(南航/乐玩 1 积分 = 1 分,ROCK 1 积分 = 1 元)';
COMMENT ON COLUMN platform_credit_accounts.recharge_unit_price_fen IS
'积分充值单价(分):1 积分 = ? 分。按平台不同,默认取档案 config_json.rechargeUnitPriceFen';
@@ -250,11 +254,39 @@ SET name = EXCLUDED.name,
updated_at = NOW();
-- ---------------------------------------------------------------------------
-- 6. 积分充值价口径:**按平台不同**
-- 南航 1 积分 = 1 分;乐玩 1 积分 = 1 分;ROCK 1 积分 = 1 元(100 分)。
-- 真源是各档案的 config_json.rechargeUnitPriceFen,
-- 成本折算与积分流水都从这里读,改价只改档案一处。
-- 6. 备用金账户编号可读化
-- ---------------------------------------------------------------------------
-- 历史编号是 FA + 10 位随机十六进制(如 FA7683D6B58B),在「备用金」列表里
-- 无法一眼对应到具体客服。新规则:FA-<大写用户名>,重名追加 -2、-3。
--
-- 幂等:编号由 admin_users.username 重新推导,与当前 account_no 无关,
-- 重复执行结果一致。
COMMENT ON TABLE platform_credit_accounts IS
'客服平台积分钱包:南航/乐玩/ROCK 三平台积分互不通用,每平台每客服一个账户;换算率按平台配置(南航/乐玩 1 积分 = 1 分,ROCK 1 积分 = 1 元)';
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;
@@ -1,52 +0,0 @@
-- 107_order_item_fulfillment_mode.sql —— 统一履约事实。
--
-- 目标:订单页、成本快照、销售事实只读 order_items.fulfillment_mode,
-- 不再各自用 provider / voucher / task 临时推断。
--
-- 设计要点(2026-10-23 重写):
-- 1. 履约方式只有一个真源,就是 order_items.fulfillment_mode;
-- 2. 商品表、成本项表、快照表都不再存载体字段,避免四处口径分叉;
-- 3. 核销码只是状态,不参与履约判定;
-- 4. 字段允许短暂为 NULL(订单项创建时通常还没有工单/任务),
-- 由统一决策服务 + 定时兜底在 24 小时内补齐。
--
-- 本迁移只做 DDL;31 万行回填由 scripts/backfill-fulfillment-mode.ts 分批执行。
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
'履约方式判定来源:订单同步 / 定时归集 / 历史回填等';
@@ -1,71 +0,0 @@
-- 108_drop_manual_expense_records.sql —— 下线公司人工支出登记与审批报销状态机。
--
-- 背景(2026-10-23 确认):
-- 人工成本不再走「登记单 → 审批 → 报销」三段式。订单页人工录单直接扣客服备用金、
-- 直接写成本快照,备用金支出明细(manual_expenses,迁移 100)是唯一资金台账。
-- 因此 manual_expense_records(登记单)与 manual_expense_events(状态机审计)
-- 整链下线。
--
-- 处理顺序:
-- 1. 先把挂在人工登记单上的成本快照改挂到备用金支出单上(若存在);
-- 2. 再删表与索引;
-- 3. 成本项 sys_manual_service 的备注改为「人工录单直写」口径。
--
-- 成本快照主体约定(改后唯一):
-- cost_snapshots.subject_type = 'manual_case'
-- cost_snapshots.subject_id = manual_expenses.id(客服备用金支出单 id)
-- ---------------------------------------------------------------------------
-- 1. 修正历史成本快照的主体指向
-- ---------------------------------------------------------------------------
-- 旧登记单的 subject_id 指向 manual_expense_records.id。旧数据里人工登记单没有
-- 关联备用金支出单(fund_expense_id 为空)的,保留原主体 id 不动,避免误删成本;
-- 有 fund_expense_id 的改挂到备用金支出单。
DO $$
BEGIN
IF to_regclass('public.manual_expense_records') IS NOT NULL THEN
UPDATE cost_snapshots cs
SET subject_id = mer.fund_expense_id
FROM manual_expense_records mer
WHERE cs.subject_type = 'manual_case'
AND mer.fund_expense_id IS NOT NULL
AND cs.subject_id = mer.id
AND cs.subject_id <> mer.fund_expense_id;
END IF;
END $$;
-- 若同一备用金支出单已存在有效快照,保留先写的,把重复的作废(superseded_at)。
UPDATE cost_snapshots dup
SET superseded_at = NOW()
WHERE dup.subject_type = 'manual_case'
AND dup.superseded_at IS NULL
AND EXISTS (
SELECT 1
FROM cost_snapshots keep
WHERE keep.subject_type = 'manual_case'
AND keep.subject_id = dup.subject_id
AND keep.cost_item_id = dup.cost_item_id
AND keep.superseded_at IS NULL
AND keep.id < dup.id
);
-- ---------------------------------------------------------------------------
-- 2. 删除人工支出登记与状态机
-- ---------------------------------------------------------------------------
DROP TABLE IF EXISTS manual_expense_events;
DROP TABLE IF EXISTS manual_expense_records;
-- ---------------------------------------------------------------------------
-- 3. 人工成本项备注按新口径更新
-- ---------------------------------------------------------------------------
UPDATE cost_items
SET note = '系统内置:订单页人工录单直接从客服备用金扣款并写入 actual 快照;逐单实际金额,不按工时或固定单价',
updated_at = NOW()
WHERE code = 'sys_manual_service';
COMMENT ON TABLE cost_snapshots IS
'成本快照:订单项/工单/人工单实际使用的成本版本与金额;履约方式看 order_items.fulfillment_mode;人工单 subject_id 指向 manual_expenses.id';
@@ -1,44 +0,0 @@
-- 109_isolate_legacy_manual_snapshots.sql —— 隔离历史人工成本快照的主体 id 空间。
--
-- 背景(2026-10-23):
-- 人工成本的快照主体从「人工支出登记单 id」改成「客服备用金支出单 id」
-- (manual_expenses.id),两套 id 空间各自从 1 开始,必然撞车。
-- 迁移 108 把有 fund_expense_id 的旧快照改挂到备用金支出单,但**没有**关联
-- 备用金支出单的孤儿快照(fund_expense_id 为空)保留了原 subject_id,
-- 于是会与新建的 manual_expenses.id 撞唯一索引
-- uq_cost_snapshots_active_subject_item。
--
-- 实测后果:人工录单第二笔时 INSERT ... ON CONFLICT DO NOTHING 静默跳过,
-- 钱从备用金扣了、成本快照却没写成 —— 账实不符。
--
-- 处理:把「来源是旧人工支出登记单」的历史快照主体 id 改成负数,
-- 与 manual_expenses.id(正数)彻底隔离。这些是历史人工成本,按口径不再补录,
-- 保留金额供毛利参考,只是不再与新链路争主体 id。
--
-- 判定依据:metadata_json.source = 'manual_expense_record'(旧登记链写入时打的标),
-- 且该主体 id 没有对应的有效 manual_expenses 关联标记。
--
-- 幂等:可重复执行。
UPDATE cost_snapshots cs
SET
subject_id = -cs.subject_id,
metadata_json = cs.metadata_json
|| jsonb_build_object(
'source', 'legacy_manual_expense_record',
'legacySubjectId', cs.subject_id,
'isolatedAt', NOW()
)
WHERE cs.subject_type = 'manual_case'
AND cs.subject_id > 0
AND cs.metadata_json->>'source' = 'manual_expense_record'
AND NOT EXISTS (
SELECT 1 FROM cost_snapshots other
WHERE other.subject_type = 'manual_case'
AND other.subject_id = -cs.subject_id
AND other.cost_item_id = cs.cost_item_id
AND other.superseded_at IS NULL
);
COMMENT ON COLUMN cost_snapshots.subject_id IS
'主体 id:order_item→order_items.id;work_order→work_orders.id;manual_case→manual_expenses.id;负数表示历史孤儿快照(已与新链路隔离)';
@@ -1,33 +0,0 @@
-- 人工履约成本金额的唯一真源是 manual_fulfillment_configs.credit_quantity × 平台换算率。
-- cost_versions 里的 sys_manual_service 行只是 cost_snapshots.cost_version_id 的挂载点,
-- 金额不参与计算;历史上手工填的 ¥88 全局版本会让「成本预览」显示错误金额,这里清零。
UPDATE cost_versions v
SET amount_fen = 0,
rate_bps = 0,
change_reason = '系统占位:人工成本按人工 SKU 配置的消耗积分结算,版本金额不参与计算',
updated_at = NOW()
FROM cost_items ci
WHERE ci.id = v.cost_item_id
AND ci.code = 'sys_manual_service'
AND v.scope_type = 'global'
AND v.status = 'active';
-- 若人工成本项还没有生效版本,补一条 0 元全局占位,保证快照能挂到版本上。
-- 104 已写入一条 1970 年的 draft 占位;这里必须用新的 effective_from,
-- 否则会与 draft 行撞 uq_cost_versions_scope(唯一键不区分状态)。
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, source,
status, change_reason, created_at, updated_at
)
SELECT
ci.id, 'global', '', '', NULL, NULL, 0, 0,
NOW(), 'manual', 'active',
'系统占位:人工成本按人工 SKU 配置的消耗积分结算', NOW(), NOW()
FROM cost_items ci
WHERE ci.code = 'sys_manual_service'
AND NOT EXISTS (
SELECT 1 FROM cost_versions v
WHERE v.cost_item_id = ci.id AND v.status = 'active'
);
+6
View File
@@ -20,6 +20,12 @@
| --- | --- |
| `2026-10-17/载体判定与成本中心落地-复核与草案.md` | 2026-10-17 第二轮复核与数据量化;现行结论已吸收进 `成本中心与三块履约模型.md` 与 `财务系统集成方案.md`,仅保留作数据与 DDL 验证追溯 |
## 2026-10-23
| 文档 | 归档原因 |
| --- | --- |
| `2026-10-23/2026-10-23-本地库原地改造.sql` | 逐列把 `carrier_type` / `cost_type` 改成 `cost_model` / `fulfillment_mode` 的一次性脚本;迁移收敛为 `103`–`107` 后不再成立,**禁止在新环境执行**,仅作改造矩阵追溯 |
## 使用建议
- 需要当前架构与风险:看 `docs/项目全量分析报告-2026-10-17.md`(参考)和 `docs/README.md`(状态索引)。