refactor(finance)!: 强制渠道模型,移除旧履约方式与同步链路

- 删除 fulfillment_mode 相关的旧同步/回填链路与定时任务
- 删除 cost_finance_sync、成本快照同步、人工商品归一等遗留服务
- 迁移 113 直接删除 order_items.fulfillment_mode 及其索引约束
- 断代脚本自带归档步骤,不再依赖独立回填脚本
- 退款/事实/总账统一走渠道版销售事实
This commit is contained in:
yml2213
2026-10-31 19:52:03 +08:00
parent 808949f3b8
commit 65b7f4f9dc
25 changed files with 156 additions and 2383 deletions
+53 -17
View File
@@ -16,9 +16,10 @@ import { closeDb, query } from '../src/db/client.js'
* sku_channel_rules(配置)
*
* 前置:
* 1. 迁移 112 已执行;
* 2. `npm run fulfillment:backfill-channel` 已完成(不允许有 assigned 但无渠道的行);
* 3. 已做数据库备份。
* 1. 迁移 112/113 已执行;
* 2. 已做数据库备份。
*
* 断代本身包含两步:先把断代前的订单项批量置为 archived,再清理派生数据。
*
* 默认 dry-run,只打印将删除的行数;加 `--execute --confirm=2026-10-31` 才真正执行。
* 不可重复执行:成功后会写入 finance_cutovers,第二次会被拦下。
@@ -77,6 +78,8 @@ async function main() {
await assertNotExecuted()
const archived = await archiveHistoricalItems()
const deleted: Record<string, number> = {}
for (const item of [...DERIVED_TABLES].sort((a, b) => a.order - b.order)) {
const result = await query(
@@ -110,7 +113,13 @@ async function main() {
console.log(
JSON.stringify(
{ mode: 'execute', cutoverDate: CUTOVER_DATE, deleted, durationMs: Date.now() - startedAt },
{
mode: 'execute',
cutoverDate: CUTOVER_DATE,
archivedItems: archived.updated,
deleted,
durationMs: Date.now() - startedAt,
},
null,
2,
),
@@ -181,21 +190,48 @@ async function assertPreconditions() {
`有 ${bad} 行 status=assigned 但 channel 为空;请先执行 fulfillment:backfill-channel`,
)
}
}
const pendingBackfill = await query<{ count: number }>(
`
SELECT COUNT(*)::int AS count
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date < $1::date
AND oi.fulfillment_status <> 'archived'
`,
[CUTOVER_DATE],
)
const notArchived = pendingBackfill.rows[0]?.count || 0
if (notArchived > 0) {
throw new Error(`有 ${notArchived} 行断代前订单项未归档;请先执行 fulfillment:backfill-channel`)
/**
* 断代前的订单项统一置为 archived(分批,避免 42 万行整表更新撞超时)。
*/
async function archiveHistoricalItems(): Promise<{ updated: number }> {
let cursor = 0
let updated = 0
for (let batch = 0; batch < 500; batch += 1) {
const rows = await query<{ id: string }>(
`
SELECT oi.id::text
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date < $1::date
AND oi.fulfillment_status <> 'archived'
AND oi.id > $2
ORDER BY oi.id
LIMIT 20000
`,
[CUTOVER_DATE, cursor],
)
if (rows.rows.length === 0) break
cursor = Number(rows.rows[rows.rows.length - 1]!.id)
const result = await query(
`
UPDATE order_items
SET fulfillment_status = 'archived',
fulfillment_channel = NULL,
fulfillment_source = 'cutover_archive',
fulfillment_decided_at = NOW(),
updated_at = NOW()
WHERE id = ANY($1::bigint[])
AND fulfillment_status <> 'archived'
`,
[rows.rows.map((row) => Number(row.id))],
)
updated += result.rowCount || 0
process.stderr.write(`归档 ${result.rowCount || 0} 行(游标 ${cursor})\n`)
}
return { updated }
}
async function assertNotExecuted() {