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,75 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { classifyFulfillmentMode, resolveReconcileCursor } from './fulfillment-mode-service.js'
test('履约判定优先级:工单 > 自动发货 > 历史平台 > 人工', () => {
assert.equal(
classifyFulfillmentMode({
hasWorkOrder: true,
hasAffiliateDashTask: true,
hasLegacyPlatformTask: true,
}),
'work_order',
)
assert.equal(
classifyFulfillmentMode({
hasWorkOrder: false,
hasAffiliateDashTask: true,
hasLegacyPlatformTask: true,
}),
'affiliate_dash',
)
assert.equal(
classifyFulfillmentMode({
hasWorkOrder: false,
hasAffiliateDashTask: false,
hasLegacyPlatformTask: true,
}),
'legacy_platform',
)
assert.equal(
classifyFulfillmentMode({
hasWorkOrder: false,
hasAffiliateDashTask: false,
hasLegacyPlatformTask: false,
}),
'manual',
)
})
test('keyset 游标:整批取满继续前进', () => {
assert.deepEqual(
resolveReconcileCursor({
cursor: 0,
selectedCount: 2000,
lastScannedId: 2000,
batchSize: 2000,
}),
{ nextCursor: 2000, done: false },
)
})
test('keyset 游标:不足一批表示已经扫到底', () => {
assert.deepEqual(
resolveReconcileCursor({
cursor: 1000,
selectedCount: 999,
lastScannedId: 1999,
batchSize: 2000,
}),
{ nextCursor: 1999, done: true },
)
})
test('keyset 游标:游标没有前进时强制结束,避免死循环', () => {
assert.deepEqual(
resolveReconcileCursor({
cursor: 500,
selectedCount: 2000,
lastScannedId: 500,
batchSize: 2000,
}),
{ nextCursor: 500, done: true },
)
})
@@ -1,238 +0,0 @@
import { query } from '../../db/client.js'
import { nowIso } from '../../utils/time.js'
export type OrderFulfillmentMode = 'work_order' | 'affiliate_dash' | 'legacy_platform' | 'manual'
export type FulfillmentModeDecisionInput = {
hasWorkOrder: boolean
hasAffiliateDashTask: boolean
hasLegacyPlatformTask: boolean
}
export type ReconcileFulfillmentModesOptions = {
batchSize?: number
maxBatches?: number
reason?: string
/** 从该 order_item id 之后继续扫(keyset 分页;默认 0 表示从头开始)。 */
afterId?: number
}
export type ReconcileFulfillmentModesResult = {
updatedCount: number
remainingCount: number
batches: number
reason: string
finishedAt: string
/** 本次扫到的最大 order_item id;可作为下一次的 afterId 续跑。 */
lastScannedId: number
}
/**
* 统一履约判定(纯函数,供单测与文档对照)。
*
* 优先级固定:工单 → 自动发货任务 → 历史平台任务 → 人工。
* 核销码、provider、商品来源都不参与判定。
*/
export function classifyFulfillmentMode({
hasWorkOrder,
hasAffiliateDashTask,
hasLegacyPlatformTask,
}: FulfillmentModeDecisionInput): OrderFulfillmentMode {
if (hasWorkOrder) return 'work_order'
if (hasAffiliateDashTask) return 'affiliate_dash'
if (hasLegacyPlatformTask) return 'legacy_platform'
return 'manual'
}
const FULFILLMENT_MODE_CASE_SQL = `
CASE
WHEN EXISTS (
SELECT 1 FROM work_orders wo
WHERE wo.order_item_id = oi.id
) THEN 'work_order'
WHEN EXISTS (
SELECT 1 FROM fulfillment_tasks ft
WHERE ft.order_item_id = oi.id
AND ft.executor_key = 'affiliate_dash'
) THEN 'affiliate_dash'
WHEN EXISTS (
SELECT 1 FROM fulfillment_tasks ft
WHERE ft.order_item_id = oi.id
AND ft.executor_key IN ('kuaishou_ct_assisted', 'kuaishou_feifei')
) THEN 'legacy_platform'
ELSE 'manual'
END
`
/**
* 重新判定指定订单项的履约方式。
*
* 只在模式发生变化时更新 fulfillment_decided_at / fulfillment_reason;
* 同一订单项重复调用是幂等的。
*/
export async function decideOrderItemsFulfillmentModes(
orderItemIds: Array<number | string>,
{ reason = 'order_sync' }: { reason?: string } = {},
): Promise<{ updatedCount: number }> {
const ids = Array.from(
new Set(
orderItemIds
.map((value) => Number(value))
.filter((value) => Number.isInteger(value) && value > 0),
),
)
if (ids.length === 0) return { updatedCount: 0 }
const result = await query<{ fulfillment_mode: OrderFulfillmentMode }>(
`
WITH decided AS (
SELECT oi.id, ${FULFILLMENT_MODE_CASE_SQL} AS mode
FROM order_items oi
WHERE oi.id = ANY($1::bigint[])
)
UPDATE order_items oi
SET
fulfillment_mode = d.mode,
fulfillment_decided_at = NOW(),
fulfillment_reason = $2
FROM decided d
WHERE oi.id = d.id
AND (
oi.fulfillment_mode IS NULL
OR oi.fulfillment_mode IS DISTINCT FROM d.mode
)
RETURNING oi.fulfillment_mode
`,
[ids, String(reason || 'order_sync')],
)
return { updatedCount: result.rowCount || 0 }
}
/**
* 批处理游标推进。
*
* keyset 分页必须保证单调前进,否则同一批数据会被反复取到、陷入死循环。
* selectedCount < batchSize 表示已经扫到底;lastScannedId 没有前进同样视为结束。
*/
export function resolveReconcileCursor(input: {
cursor: number
selectedCount: number
lastScannedId: number
batchSize: number
}): { nextCursor: number; done: boolean } {
const nextCursor = Math.max(input.cursor, input.lastScannedId)
const done = input.selectedCount < input.batchSize || nextCursor <= input.cursor
return { nextCursor, done }
}
/**
* 兜底归集:分批处理 fulfillment_mode 为 NULL 的订单项。
*
* 定时任务只需处理新单;历史回填用同一个函数,把 maxBatches 调大即可。
* 走 keyset(id > 上一批最大 id),每批只扫索引里剩下的未判定行,
* 不会像 OFFSET 那样反复从头扫已回填的数据。
*/
export async function reconcilePendingFulfillmentModes({
batchSize = 2000,
maxBatches = 10,
reason = 'reconcile',
afterId = 0,
}: ReconcileFulfillmentModesOptions = {}): Promise<ReconcileFulfillmentModesResult> {
const normalizedBatchSize = clampInteger(batchSize, 2000, 1, 50_000)
const normalizedMaxBatches = clampInteger(maxBatches, 10, 1, 10_000)
const normalizedReason = String(reason || 'reconcile').trim() || 'reconcile'
let cursor = Math.max(0, Math.trunc(Number(afterId) || 0))
let updatedCount = 0
let batches = 0
for (let index = 0; index < normalizedMaxBatches; index += 1) {
// keyset:每批从上一批最大 id 之后继续,避免每次从头扫已回填过的行。
// 返回 pending 的数量与最大 id,用于判断是否扫到底、以及下一批的起点。
const result = await query<{
updated_count: number
selected_count: number
last_id: number | string | null
}>(
`
WITH pending AS (
SELECT oi.id
FROM order_items oi
WHERE oi.fulfillment_mode IS NULL
AND oi.id > $3
ORDER BY oi.id
LIMIT $1
),
decided AS (
SELECT oi.id, ${FULFILLMENT_MODE_CASE_SQL} AS mode
FROM order_items oi
JOIN pending p ON p.id = oi.id
),
updated AS (
UPDATE order_items oi
SET
fulfillment_mode = d.mode,
fulfillment_decided_at = NOW(),
fulfillment_reason = $2
FROM decided d
WHERE oi.id = d.id
AND oi.fulfillment_mode IS NULL
RETURNING oi.id
)
SELECT
(SELECT COUNT(*)::int FROM updated) AS updated_count,
(SELECT COUNT(*)::int FROM pending) AS selected_count,
(SELECT COALESCE(MAX(id), 0)::bigint FROM pending) AS last_id
`,
[normalizedBatchSize, normalizedReason, cursor],
)
const row = result.rows[0]
const step = resolveReconcileCursor({
cursor,
selectedCount: Number(row?.selected_count || 0),
lastScannedId: Number(row?.last_id || 0),
batchSize: normalizedBatchSize,
})
updatedCount += Number(row?.updated_count || 0)
batches += 1
cursor = step.nextCursor
if (step.done) break
}
const remaining = await query<{ count: number }>(
`SELECT COUNT(*)::int AS count FROM order_items WHERE fulfillment_mode IS NULL`,
)
return {
updatedCount,
remainingCount: Number(remaining.rows[0]?.count || 0),
batches,
reason: normalizedReason,
finishedAt: nowIso(),
lastScannedId: cursor,
}
}
export async function getFulfillmentModeCounts(): Promise<
Array<{ fulfillmentMode: OrderFulfillmentMode | null; count: number }>
> {
const result = await query<{ fulfillment_mode: OrderFulfillmentMode | null; count: number }>(
`
SELECT fulfillment_mode, COUNT(*)::int AS count
FROM order_items
GROUP BY fulfillment_mode
ORDER BY COUNT(*) DESC
`,
)
return result.rows.map((row) => ({
fulfillmentMode: row.fulfillment_mode || null,
count: Number(row.count || 0),
}))
}
function clampInteger(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))
}