diff --git a/apps/backend/scripts/backfill-fulfillment-mode.ts b/apps/backend/scripts/backfill-fulfillment-mode.ts index 47f92880..7281efa6 100644 --- a/apps/backend/scripts/backfill-fulfillment-mode.ts +++ b/apps/backend/scripts/backfill-fulfillment-mode.ts @@ -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() diff --git a/apps/backend/scripts/check-sql-guard.ts b/apps/backend/scripts/check-sql-guard.ts index 19b478ef..d8a64158 100644 --- a/apps/backend/scripts/check-sql-guard.ts +++ b/apps/backend/scripts/check-sql-guard.ts @@ -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) { diff --git a/apps/backend/src/db/migrations/104_cost_center.sql b/apps/backend/src/db/migrations/104_cost_center.sql index a686ffed..d875fb0c 100644 --- a/apps/backend/src/db/migrations/104_cost_center.sql +++ b/apps/backend/src/db/migrations/104_cost_center.sql @@ -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. 财务角色补齐成本中心维护权限 diff --git a/apps/backend/src/db/migrations/105_sales_daily_facts.sql b/apps/backend/src/db/migrations/105_sales_daily_facts.sql index 32d8e2e4..5e709867 100644 --- a/apps/backend/src/db/migrations/105_sales_daily_facts.sql +++ b/apps/backend/src/db/migrations/105_sales_daily_facts.sql @@ -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, diff --git a/apps/backend/src/db/migrations/106_fund_account_no_readable.sql b/apps/backend/src/db/migrations/106_fund_account_no_readable.sql deleted file mode 100644 index e7b43c23..00000000 --- a/apps/backend/src/db/migrations/106_fund_account_no_readable.sql +++ /dev/null @@ -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; diff --git a/apps/backend/src/db/migrations/112_order_refunds.sql b/apps/backend/src/db/migrations/106_order_refunds.sql similarity index 74% rename from apps/backend/src/db/migrations/112_order_refunds.sql rename to apps/backend/src/db/migrations/106_order_refunds.sql index 23c1ac9f..1b359506 100644 --- a/apps/backend/src/db/migrations/112_order_refunds.sql +++ b/apps/backend/src/db/migrations/106_order_refunds.sql @@ -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=历史数据导入'; diff --git a/apps/backend/src/db/migrations/110_manual_fulfillment_platform_credits.sql b/apps/backend/src/db/migrations/107_manual_fulfillment.sql similarity index 86% rename from apps/backend/src/db/migrations/110_manual_fulfillment_platform_credits.sql rename to apps/backend/src/db/migrations/107_manual_fulfillment.sql index 82e1243c..b990528d 100644 --- a/apps/backend/src/db/migrations/110_manual_fulfillment_platform_credits.sql +++ b/apps/backend/src/db/migrations/107_manual_fulfillment.sql @@ -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; diff --git a/apps/backend/src/db/migrations/107_order_item_fulfillment_mode.sql b/apps/backend/src/db/migrations/107_order_item_fulfillment_mode.sql deleted file mode 100644 index 2db73d2d..00000000 --- a/apps/backend/src/db/migrations/107_order_item_fulfillment_mode.sql +++ /dev/null @@ -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 - '履约方式判定来源:订单同步 / 定时归集 / 历史回填等'; diff --git a/apps/backend/src/db/migrations/108_drop_manual_expense_records.sql b/apps/backend/src/db/migrations/108_drop_manual_expense_records.sql deleted file mode 100644 index 8feaa16e..00000000 --- a/apps/backend/src/db/migrations/108_drop_manual_expense_records.sql +++ /dev/null @@ -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'; diff --git a/apps/backend/src/db/migrations/109_isolate_legacy_manual_snapshots.sql b/apps/backend/src/db/migrations/109_isolate_legacy_manual_snapshots.sql deleted file mode 100644 index 6330026a..00000000 --- a/apps/backend/src/db/migrations/109_isolate_legacy_manual_snapshots.sql +++ /dev/null @@ -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;负数表示历史孤儿快照(已与新链路隔离)'; diff --git a/apps/backend/src/db/migrations/111_manual_service_zero_placeholder.sql b/apps/backend/src/db/migrations/111_manual_service_zero_placeholder.sql deleted file mode 100644 index 758053af..00000000 --- a/apps/backend/src/db/migrations/111_manual_service_zero_placeholder.sql +++ /dev/null @@ -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' - ); diff --git a/scripts/rewrite-local-db-2026-10-23.sql b/docs/archive/2026-10-23/2026-10-23-本地库原地改造.sql similarity index 100% rename from scripts/rewrite-local-db-2026-10-23.sql rename to docs/archive/2026-10-23/2026-10-23-本地库原地改造.sql diff --git a/docs/archive/README.md b/docs/archive/README.md index 1e07b7e2..d1f2e78c 100644 --- a/docs/archive/README.md +++ b/docs/archive/README.md @@ -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`(状态索引)。