refactor(finance)!: 强制渠道模型,移除旧履约方式与同步链路
- 删除 fulfillment_mode 相关的旧同步/回填链路与定时任务 - 删除 cost_finance_sync、成本快照同步、人工商品归一等遗留服务 - 迁移 113 直接删除 order_items.fulfillment_mode 及其索引约束 - 断代脚本自带归档步骤,不再依赖独立回填脚本 - 退款/事实/总账统一走渠道版销售事实
This commit is contained in:
@@ -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))
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user