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
@@ -41,7 +41,30 @@ test('normalizeScheduledJobsConfig ensures default jobs exist', () => {
assert.ok(ids.includes('deposit-unfreeze'))
assert.ok(ids.includes('data-retention'))
assert.ok(ids.includes('pdd_order_sync'))
assert.ok(ids.includes('cost-finance-sync'))
// 旧财务闭环作业已下线,不再作为默认任务注入。
assert.equal(ids.includes('cost-finance-sync'), false)
assert.ok(ids.includes('channel-assign'))
assert.ok(ids.includes('ledger-post'))
})
test('旧的 cost_finance_sync 任务已彻底移除,不再出现在配置里', () => {
const config = normalizeScheduledJobsConfig({
enabled: true,
jobs: [
{
id: 'cost-finance-sync',
type: 'cost_finance_sync',
enabled: true,
intervalSeconds: 900,
config: { days: 7 },
},
],
})
assert.equal(
config.jobs.some((item) => item.type === 'cost_finance_sync'),
false,
)
})
test('normalizeScheduledJobsConfig 收录 v2 六个管道作业并保持各自配置', () => {
@@ -10,7 +10,6 @@ const WORK_ORDER_TIMEOUT_JOB_ID = 'work-order-timeout'
const DEPOSIT_UNFREEZE_JOB_ID = 'deposit-unfreeze'
const DATA_RETENTION_JOB_ID = 'data-retention'
const PDD_ORDER_SYNC_JOB_ID = 'pdd_order_sync'
const COST_FINANCE_SYNC_JOB_ID = 'cost-finance-sync'
/** v2 管道作业:各自只负责一层,可单独启停。 */
const CHANNEL_ASSIGN_JOB_ID = 'channel-assign'
@@ -84,7 +83,6 @@ export function normalizeScheduledJobsConfig(rawValue: unknown) {
const hasDepositUnfreeze = jobs.some((item) => item.id === DEPOSIT_UNFREEZE_JOB_ID)
const hasDataRetention = jobs.some((item) => item.id === DATA_RETENTION_JOB_ID)
const hasPddOrderSync = jobs.some((item) => item.id === PDD_ORDER_SYNC_JOB_ID)
const hasCostFinanceSync = jobs.some((item) => item.id === COST_FINANCE_SYNC_JOB_ID)
const hasChannelAssign = jobs.some((item) => item.id === CHANNEL_ASSIGN_JOB_ID)
const hasCarrierDispatch = jobs.some((item) => item.id === CARRIER_DISPATCH_JOB_ID)
const hasProductNormalize = jobs.some((item) => item.id === PRODUCT_NORMALIZE_JOB_ID)
@@ -104,9 +102,8 @@ export function normalizeScheduledJobsConfig(rawValue: unknown) {
if (!hasPddOrderSync) {
jobs.push(createDefaultPddOrderSyncJob())
}
if (!hasCostFinanceSync) {
jobs.push(createDefaultCostFinanceSyncJob())
}
// 旧作业 cost_finance_sync 已下线:v2 管道接管,且它写旧字段会覆盖新口径。
// 保留配置项仅用于兼容读取,运行期强制停用。
// v2 管道:老配置里没有这些任务时自动补齐,保证升级后无需手工添加。
if (!hasChannelAssign) jobs.push(createDefaultChannelAssignJob())
if (!hasCarrierDispatch) jobs.push(createDefaultCarrierDispatchJob())
@@ -143,9 +140,6 @@ function normalizeScheduledJob(rawValue: unknown) {
if (type === 'pdd_order_sync') {
return normalizePddOrderSyncJob(rawValue)
}
if (type === 'cost_finance_sync') {
return normalizeCostFinanceSyncJob(rawValue)
}
if (type === 'channel_assign') {
return normalizeChannelAssignJob(rawValue)
}
@@ -182,20 +176,6 @@ function normalizePddOrderSyncJob(rawValue: JsonObject) {
}
}
function normalizeCostFinanceSyncJob(rawValue: JsonObject) {
const config = isPlainObject(rawValue.config) ? rawValue.config : {}
return {
id: COST_FINANCE_SYNC_JOB_ID,
type: 'cost_finance_sync',
enabled: rawValue.enabled !== false,
intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 300, 60, 3600),
config: {
days: normalizeRangeInteger(config.days, 7, 1, 90),
},
}
}
function normalizeDepositUnfreezeJob(rawValue: JsonObject) {
const config = isPlainObject(rawValue.config) ? rawValue.config : {}
@@ -242,7 +222,6 @@ function createDefaultScheduledJobsConfig() {
createDefaultDepositUnfreezeJob(),
createDefaultDataRetentionJob(),
createDefaultPddOrderSyncJob(),
createDefaultCostFinanceSyncJob(),
createDefaultChannelAssignJob(),
createDefaultCarrierDispatchJob(),
createDefaultProductNormalizeJob(),
@@ -406,21 +385,6 @@ function createDefaultPddOrderSyncJob() {
}
}
function createDefaultCostFinanceSyncJob() {
return {
id: COST_FINANCE_SYNC_JOB_ID,
type: 'cost_finance_sync',
enabled: true,
// 一次同步要跑 6 条全窗口聚合(41 万单实测 30–60 秒)。5 分钟一次会让
// 数据库长期处在 10–20% 的同步负载下,后台页面首屏被拖慢;财务事实
// 15 分钟刷新已足够,需要更快可在后台定时任务里改回 300。
intervalSeconds: 900,
config: {
days: 7,
},
}
}
function createDefaultDataRetentionJob() {
return {
id: DATA_RETENTION_JOB_ID,
@@ -1,108 +0,0 @@
import type { JsonObject } from '../../types/json.js'
import {
syncActualWorkOrderCostSnapshots,
syncEstimatedOrderItemCostSnapshots,
} from '../cost-center/cost-snapshot-sync-service.js'
import { normalizeManualProducts } from '../cost-center/manual-product-normalization-service.js'
import { rematchManualFulfillments } from '../cost-center/manual-fulfillment-config-service.js'
import { rebuildSalesDailyFacts } from '../finance/sales-facts-service.js'
import { syncFinanceLedgerFromFacts } from '../finance/finance-ledger-service.js'
import { reconcilePendingFulfillmentModes } from '../fulfillment/fulfillment-mode-service.js'
/**
* 财务闭环同步:履约方式兜底 -> 人工商品归一 -> 预估成本快照 -> 工单实际快照 -> 销售事实重算。
* 只处理最近 N 天,避免定时任务扫全量历史。
*/
export async function runCostFinanceSyncJob(job: JsonObject = {}) {
const config = job.config && typeof job.config === 'object' ? (job.config as JsonObject) : {}
const days = clampInteger(config.days, 30, 1, 90)
const fulfillment = await reconcilePendingFulfillmentModes({
batchSize: 2000,
maxBatches: 10,
reason: 'cost_finance_sync',
})
// 人工单按内部 SKU 归一;异常收口交给预估同步统一处理,这里只补商品维度。
const manualProducts = await normalizeManualProducts({
dateFrom: shiftDays(-(days - 1)),
dateTo: today(),
resolveExceptions: false,
})
// 人工 SKU 配置可能在订单之后才补:每次同步都按配置回填未匹配订单项,
// 保证「配置好人工之后的新单」自动挂上渠道,不依赖管理员手动点「重新匹配」。
const manualMatches = await rematchManualFulfillments({
onlyUnmatched: true,
dateFrom: shiftDays(-(days - 1)),
dateTo: today(),
})
const estimated = await syncEstimatedOrderItemCostSnapshots({
dateFrom: shiftDays(-(days - 1)),
dateTo: today(),
})
const actual = await syncActualWorkOrderCostSnapshots({
dateFrom: shiftDays(-(days - 1)),
dateTo: today(),
})
const facts = await rebuildSalesDailyFacts({
dateFrom: shiftDays(-(days - 1)),
dateTo: today(),
})
// 影子总账按日同步销售事实(幂等;已关账期间会跳过)。
const ledger = await syncFinanceLedgerFromFacts({
dateFrom: shiftDays(-(days - 1)),
dateTo: today(),
})
return {
status: 'ok',
message:
`履约方式 ${fulfillment.updatedCount} 项 / ` +
`人工商品 ${manualProducts.updatedItemCount} 项 / ` +
`人工渠道匹配 ${manualMatches.updatedCount} 项 / ` +
`成本快照 ${estimated.createdCount} 商品预估 / ` +
`${actual.directRewardCount + actual.upgradedCount} 工单实际 / ` +
`${actual.estimatedRewardCount} 工单在途,销售事实 ${facts.insertedCount} 行,` +
`总账凭证 ${ledger.created} 条`,
days,
fulfillmentModeUpdated: fulfillment.updatedCount,
fulfillmentModeRemaining: fulfillment.remainingCount,
manualItemsNormalized: manualProducts.updatedItemCount,
manualProductsCreated: manualProducts.createdProductCount,
manualItemsMatched: manualMatches.updatedCount,
estimatedCreated: estimated.createdCount,
actualCreated: actual.directRewardCount + actual.upgradedCount,
estimatedRewardCreated: actual.estimatedRewardCount,
exceptionsCreated: estimated.exceptionCreatedCount,
factsInserted: facts.insertedCount,
ledgerEntriesCreated: ledger.created,
ledgerSkippedClosedPeriods: ledger.skipped,
dateFrom: facts.range.fromDate,
dateTo: facts.range.toDate,
}
}
/** 模块级只构造一次:`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 today() {
return SHANGHAI_DATE_FORMATTER.format(new Date())
}
function shiftDays(days: number) {
const [year, month, day] = today().split('-').map(Number)
const date = new Date(Date.UTC(year || 1970, (month || 1) - 1, day || 1))
date.setUTCDate(date.getUTCDate() + days)
return date.toISOString().slice(0, 10)
}
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))
}
@@ -6,7 +6,6 @@ import { runDepositUnfreezeJob } from './deposit-unfreeze-job.js'
import { runWorkOrderTimeoutJob } from './work-order-timeout-job.js'
import { runDataRetentionJob } from './data-retention-job.js'
import { runPddOrderSyncJob } from './pdd-order-sync-job.js'
import { runCostFinanceSyncJob } from './cost-finance-sync-job.js'
import {
runCarrierDispatchJob,
runChannelAssignJob,
@@ -213,9 +212,6 @@ function dispatchJob(job: JsonObject) {
if (job.type === 'pdd_order_sync') {
return runPddOrderSyncJob(job)
}
if (job.type === 'cost_finance_sync') {
return runCostFinanceSyncJob(job)
}
if (job.type === 'channel_assign') {
return runChannelAssignJob(job)
}