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
@@ -1,241 +0,0 @@
import process from 'node:process'
import { closeDb, query } from '../src/db/client.js'
/**
* 112 之后的渠道回填:把旧 `fulfillment_mode` 转成
* `fulfillment_channel` + `fulfillment_status` + `fulfillment_source`。
*
* 规则(2026-10-31 断代):
* 1. 业务日期 < 2026-10-31 → status=archived,source=cutover_archive;
* channel 按旧 mode 映射(legacy_platform 没有新渠道,置空)。
* 2. 业务日期 >= 2026-10-31:
* · work_order / affiliate_dash → status=assigned,source=carrier_evidence;
* · manual → status=pending(旧 manual 是兜底值,必须由运营重新确认);
* · 其余 / NULL → status=pending。
*
* 幂等:每批按 id 重算,只有值变化才写;运营锁定行(fulfillment_locked)跳过。
* 分批:keyset 扫描,单批默认 5000,避免 438k 行整表 UPDATE 撞语句超时。
*
* 用法:
* npm run fulfillment:backfill-channel -- --dry-run
* npm run fulfillment:backfill-channel -- --batch-size=5000
* npm run fulfillment:backfill-channel -- --max-batches=100 --after-id=123456
*/
/** 财务断代日:这一天之前的数据只保留审计价值。 */
const CUTOVER_DATE = '2026-10-31'
const options = parseOptions(process.argv.slice(2))
const batchSize = normalizeInteger(options.batchSize, 5000, 1, 50_000)
const maxBatches = normalizeInteger(options.maxBatches, 1000, 1, 100_000)
const afterId = normalizeInteger(options.afterId, 0, 0, Number.MAX_SAFE_INTEGER)
const dryRun = options.dryRun === 'true' || options['dry-run'] === 'true'
const CHANNEL_SQL = `
CASE oi.fulfillment_mode
WHEN 'work_order' THEN 'work_order'
WHEN 'affiliate_dash' THEN 'affiliate_dash'
WHEN 'manual' THEN 'manual'
ELSE NULL
END
`
const DECISION_SQL = `
CASE
WHEN (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
< DATE '${CUTOVER_DATE}' THEN 'archived'
WHEN oi.fulfillment_mode = 'manual' THEN 'pending'
WHEN oi.fulfillment_mode IN ('work_order', 'affiliate_dash') THEN 'assigned'
ELSE 'pending'
END
`
const SOURCE_SQL = `
CASE
WHEN (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
< DATE '${CUTOVER_DATE}' THEN 'cutover_archive'
WHEN oi.fulfillment_mode IN ('work_order', 'affiliate_dash') THEN 'carrier_evidence'
ELSE ''
END
`
type BatchRow = {
id: number
channel: string | null
status: string
source: string
changed: boolean
}
async function main() {
await assertSchema()
const startedAt = Date.now()
let cursor = afterId
let scanned = 0
let updated = 0
const summary = new Map<string, number>()
for (let batch = 0; batch < maxBatches; batch += 1) {
const rows = await query<BatchRow>(
`
SELECT
oi.id,
${CHANNEL_SQL} AS channel,
${DECISION_SQL} AS status,
${SOURCE_SQL} AS source,
(
oi.fulfillment_channel IS DISTINCT FROM (${CHANNEL_SQL})
OR oi.fulfillment_status IS DISTINCT FROM (${DECISION_SQL})
OR oi.fulfillment_source IS DISTINCT FROM (${SOURCE_SQL})
) AS changed
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.id > $1
ORDER BY oi.id
LIMIT $2
`,
[cursor, batchSize],
)
if (rows.rows.length === 0) break
scanned += rows.rows.length
cursor = rows.rows[rows.rows.length - 1]!.id
for (const row of rows.rows) {
const key = `${row.status}/${row.channel ?? '-'}`
summary.set(key, (summary.get(key) || 0) + 1)
}
const changedRows = rows.rows.filter((row) => row.changed)
if (changedRows.length === 0) {
process.stderr.write(`批 ${batch + 1}: 扫描 ${rows.rows.length},无变化\n`)
continue
}
if (dryRun) {
updated += changedRows.length
process.stderr.write(
`批 ${batch + 1}: 扫描 ${rows.rows.length},将更新 ${changedRows.length}\n`,
)
continue
}
const ids = changedRows.map((row) => row.id)
const channels = changedRows.map((row) => row.channel)
const statuses = changedRows.map((row) => row.status)
const sources = changedRows.map((row) => row.source)
const result = await query(
`
UPDATE order_items oi
SET
fulfillment_channel = input.channel,
fulfillment_status = input.status,
fulfillment_source = input.source,
fulfillment_decided_at = NOW(),
updated_at = NOW()
FROM unnest($1::bigint[], $2::text[], $3::text[], $4::text[])
AS input(id, channel, status, source)
WHERE oi.id = input.id
AND oi.fulfillment_locked = FALSE
`,
[ids, channels, statuses, sources],
)
updated += result.rowCount || 0
process.stderr.write(
`批 ${batch + 1}: 扫描 ${rows.rows.length},更新 ${result.rowCount || 0}\n`,
)
}
const remaining = await query<{ count: number }>(
`SELECT COUNT(*)::int AS count FROM order_items WHERE id > $1`,
[cursor],
)
console.log(
JSON.stringify(
{
cutoverDate: CUTOVER_DATE,
dryRun,
batchSize,
afterId,
scanned,
updated,
lastId: cursor,
remaining: remaining.rows[0]?.count || 0,
summary: Object.fromEntries([...summary.entries()].sort()),
durationMs: Date.now() - startedAt,
},
null,
2,
),
)
if ((remaining.rows[0]?.count || 0) > 0) {
console.error(
`仍有 ${remaining.rows[0]?.count} 个订单项未扫描;请增大 --max-batches 或用 --after-id=${cursor} 续跑。`,
)
process.exitCode = 2
}
}
main()
.catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
.finally(async () => {
await closeDb()
})
async function assertSchema() {
const columns = await query<{ column_name: string }>(
`
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'order_items'
AND column_name IN (
'fulfillment_mode', 'fulfillment_channel',
'fulfillment_status', 'fulfillment_source', 'fulfillment_locked'
)
`,
)
const found = new Set(columns.rows.map((row) => row.column_name))
const missing = [
'fulfillment_mode',
'fulfillment_channel',
'fulfillment_status',
'fulfillment_source',
'fulfillment_locked',
].filter((name) => !found.has(name))
if (missing.length > 0) {
throw new Error(`order_items 缺少列:${missing.join(', ')};请先执行迁移 112`)
}
}
function parseOptions(args: string[]) {
const result: Record<string, string> = {}
for (const arg of args) {
if (!arg.startsWith('--')) continue
const body = arg.slice(2)
const index = body.indexOf('=')
const rawKey = index === -1 ? body : body.slice(0, index)
const value = index === -1 ? 'true' : body.slice(index + 1)
result[rawKey] = value
// 同时支持 --batch-size 与 --batchSize 两种写法。
result[rawKey.replace(/-([a-z])/g, (_, char: string) => char.toUpperCase())] = value
}
return result
}
function normalizeInteger(value: unknown, fallback: number, min: number, max: number) {
const parsed = Number(value)
if (!Number.isInteger(parsed)) return fallback
return Math.min(max, Math.max(min, parsed))
}
@@ -1,88 +0,0 @@
import process from 'node:process'
import { closeDb, query } from '../src/db/client.js'
import {
getFulfillmentModeCounts,
reconcilePendingFulfillmentModes,
} from '../src/services/fulfillment/fulfillment-mode-service.js'
const options = parseOptions(process.argv.slice(2))
const batchSize = normalizeInteger(options.batchSize, 5000, 1, 50_000)
const maxBatches = normalizeInteger(options.maxBatches, 1000, 1, 10_000)
const afterId = normalizeInteger(options.afterId, 0, 0, Number.MAX_SAFE_INTEGER)
async function main() {
const column = await query<{ exists: boolean }>(
`
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'order_items'
AND column_name = 'fulfillment_mode'
) AS exists
`,
)
if (!column.rows[0]?.exists) {
throw new Error('order_items.fulfillment_mode 不存在,请先执行 npm run db:migrate(迁移 105)')
}
const startedAt = Date.now()
const result = await reconcilePendingFulfillmentModes({
batchSize,
maxBatches,
reason: 'backfill_2026_10_23',
afterId,
})
const counts = await getFulfillmentModeCounts()
console.log(
JSON.stringify(
{
...result,
batchSize,
maxBatches,
counts,
durationMs: Date.now() - startedAt,
},
null,
2,
),
)
if (result.remainingCount > 0) {
console.error(`仍有 ${result.remainingCount} 个订单项未回填;请增大 --max-batches 或检查数据。`)
process.exitCode = 2
}
}
main()
.catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
.finally(async () => {
await closeDb()
})
function parseOptions(args: string[]) {
const result: Record<string, string> = {}
for (const arg of args) {
if (!arg.startsWith('--')) continue
const body = arg.slice(2)
const index = body.indexOf('=')
if (index === -1) {
result[body] = 'true'
} else {
result[body.slice(0, index)] = body.slice(index + 1)
}
}
return result
}
function normalizeInteger(value: unknown, fallback: number, min: number, max: number) {
const parsed = Number(value)
if (!Number.isInteger(parsed)) return fallback
return Math.min(max, Math.max(min, parsed))
}
+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() {
-91
View File
@@ -1,91 +0,0 @@
import process from 'node:process'
import { closeDb } from '../src/db/client.js'
import {
syncActualWorkOrderCostSnapshots,
syncEstimatedOrderItemCostSnapshots,
} from '../src/services/cost-center/cost-snapshot-sync-service.js'
import { rebuildSalesDailyFacts } from '../src/services/finance/sales-facts-service.js'
import { resolveFinanceDateRange, shiftDate } from '../src/utils/finance-date-range.js'
import { nowIso } from '../src/utils/time.js'
type Mode = 'estimated' | 'actual' | 'facts' | 'all'
const options = parseOptions(process.argv.slice(2))
const mode = normalizeMode(options.mode)
const days = normalizeDays(options.days)
const fallbackTo = options.to || formatShanghaiDate(new Date())
const fallbackFrom = options.from || shiftDate(fallbackTo, -(days - 1))
async function main() {
const input = { dateFrom: fallbackFrom, dateTo: fallbackTo }
const range = resolveFinanceDateRange(input, { defaultDays: days, maxDays: 400 })
const startedAt = Date.now()
const result: Record<string, unknown> = { mode, range }
if (mode === 'estimated' || mode === 'all') {
result.estimated = await syncEstimatedOrderItemCostSnapshots(input)
}
if (mode === 'actual' || mode === 'all') {
result.actual = await syncActualWorkOrderCostSnapshots(input)
}
if (mode === 'facts' || mode === 'all') {
result.facts = await rebuildSalesDailyFacts(input)
}
result.durationMs = Date.now() - startedAt
result.finishedAt = nowIso()
console.log(JSON.stringify(result, null, 2))
}
main()
.catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
.finally(async () => {
await closeDb()
})
function parseOptions(args: string[]) {
const result: Record<string, string> = {}
for (const arg of args) {
if (!arg.startsWith('--')) continue
const body = arg.slice(2)
const index = body.indexOf('=')
if (index === -1) {
result[body] = 'true'
} else {
result[body.slice(0, index)] = body.slice(index + 1)
}
}
return result
}
function normalizeMode(value: unknown): Mode {
const mode = String(value || 'all').trim()
if (mode === 'estimated' || mode === 'actual' || mode === 'facts' || mode === 'all') {
return mode
}
throw new Error('--mode 只能是 estimated / actual / facts / all')
}
function normalizeDays(value: unknown) {
const parsed = Number(value || 7)
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 400) {
throw new Error('--days 必须是 1-400 的整数')
}
return parsed
}
/** 模块级只构造一次:`new Intl.DateTimeFormat()` 每次约 0.5ms,逐条调用会线性放大。 */
const SHANGHAI_DATE_FORMATTER = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
function formatShanghaiDate(value: Date) {
return SHANGHAI_DATE_FORMATTER.format(value)
}