feat(scheduler): 六个财务管道作业,模板改价即同步成本版本

- channel_assign / carrier_dispatch / product_normalize / cost_snapshot / sales_facts / ledger_post
- 每个作业只写自己那一层,失败互不影响,可单独启停
- 接单模板保存后自动同步规则级成本版本,失败不阻塞模板保存
This commit is contained in:
yml2213
2026-10-31 18:12:19 +08:00
parent 3b0cfbb8fa
commit 75a3801236
5 changed files with 461 additions and 0 deletions
@@ -43,3 +43,72 @@ test('normalizeScheduledJobsConfig ensures default jobs exist', () => {
assert.ok(ids.includes('pdd_order_sync'))
assert.ok(ids.includes('cost-finance-sync'))
})
test('normalizeScheduledJobsConfig 收录 v2 六个管道作业并保持各自配置', () => {
const config = normalizeScheduledJobsConfig({
enabled: true,
jobs: [
{
id: 'channel-assign',
type: 'channel_assign',
enabled: true,
intervalSeconds: 60,
config: { batchSize: 800, maxBatches: 5 },
},
{
id: 'carrier-dispatch',
type: 'carrier_dispatch',
enabled: false,
intervalSeconds: 60,
config: { limit: 50 },
},
{
id: 'product-normalize',
type: 'product_normalize',
enabled: true,
intervalSeconds: 300,
config: {},
},
{
id: 'cost-snapshot',
type: 'cost_snapshot',
enabled: true,
intervalSeconds: 300,
config: {},
},
{
id: 'sales-facts',
type: 'sales_facts',
enabled: true,
intervalSeconds: 600,
config: { days: 7 },
},
{
id: 'ledger-post',
type: 'ledger_post',
enabled: true,
intervalSeconds: 600,
config: { days: 7 },
},
],
})
const byType = new Map(config.jobs.map((item) => [item.type, item]))
assert.equal(byType.get('channel_assign')?.config.batchSize, 800)
assert.equal(byType.get('carrier_dispatch')?.enabled, false)
assert.equal(byType.get('sales_facts')?.config.days, 7)
assert.equal(byType.get('ledger_post')?.config.days, 7)
assert.ok(byType.has('product_normalize'))
assert.ok(byType.has('cost_snapshot'))
})
test('normalizeScheduledJobsConfig 丢弃未知任务类型', () => {
const config = normalizeScheduledJobsConfig({
enabled: true,
jobs: [{ id: 'nope', type: 'unknown_type', enabled: true, intervalSeconds: 60, config: {} }],
})
assert.equal(
config.jobs.some((item) => item.type === 'unknown_type'),
false,
)
})
@@ -12,6 +12,14 @@ 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'
const CARRIER_DISPATCH_JOB_ID = 'carrier-dispatch'
const PRODUCT_NORMALIZE_JOB_ID = 'product-normalize'
const COST_SNAPSHOT_JOB_ID = 'cost-snapshot'
const SALES_FACTS_JOB_ID = 'sales-facts'
const LEDGER_POST_JOB_ID = 'ledger-post'
export function getScheduledJobsFilePath() {
return SCHEDULED_JOBS_FILE_PATH
}
@@ -125,6 +133,24 @@ function normalizeScheduledJob(rawValue: unknown) {
if (type === 'cost_finance_sync') {
return normalizeCostFinanceSyncJob(rawValue)
}
if (type === 'channel_assign') {
return normalizeChannelAssignJob(rawValue)
}
if (type === 'carrier_dispatch') {
return normalizeCarrierDispatchJob(rawValue)
}
if (type === 'product_normalize') {
return normalizeProductNormalizeJob(rawValue)
}
if (type === 'cost_snapshot') {
return normalizeCostSnapshotJob(rawValue)
}
if (type === 'sales_facts') {
return normalizeSalesFactsJob(rawValue)
}
if (type === 'ledger_post') {
return normalizeLedgerPostJob(rawValue)
}
return null
}
@@ -204,10 +230,157 @@ function createDefaultScheduledJobsConfig() {
createDefaultDataRetentionJob(),
createDefaultPddOrderSyncJob(),
createDefaultCostFinanceSyncJob(),
createDefaultChannelAssignJob(),
createDefaultCarrierDispatchJob(),
createDefaultProductNormalizeJob(),
createDefaultCostSnapshotJob(),
createDefaultSalesFactsJob(),
createDefaultLedgerPostJob(),
],
}
}
function normalizeChannelAssignJob(rawValue: JsonObject) {
const config = isPlainObject(rawValue.config) ? rawValue.config : {}
return {
id: CHANNEL_ASSIGN_JOB_ID,
type: 'channel_assign',
enabled: rawValue.enabled !== false,
intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 60, 30, 3600),
config: {
batchSize: normalizeRangeInteger(config.batchSize, 500, 50, 5000),
maxBatches: normalizeRangeInteger(config.maxBatches, 20, 1, 200),
},
}
}
function normalizeCarrierDispatchJob(rawValue: JsonObject) {
const config = isPlainObject(rawValue.config) ? rawValue.config : {}
return {
id: CARRIER_DISPATCH_JOB_ID,
type: 'carrier_dispatch',
enabled: rawValue.enabled !== false,
intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 60, 30, 3600),
config: {
limit: normalizeRangeInteger(config.limit, 200, 10, 2000),
},
}
}
function normalizeProductNormalizeJob(rawValue: JsonObject) {
const config = isPlainObject(rawValue.config) ? rawValue.config : {}
return {
id: PRODUCT_NORMALIZE_JOB_ID,
type: 'product_normalize',
enabled: rawValue.enabled !== false,
intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 300, 60, 3600),
config: {
batchSize: normalizeRangeInteger(config.batchSize, 500, 50, 5000),
maxBatches: normalizeRangeInteger(config.maxBatches, 20, 1, 200),
},
}
}
function normalizeCostSnapshotJob(rawValue: JsonObject) {
const config = isPlainObject(rawValue.config) ? rawValue.config : {}
return {
id: COST_SNAPSHOT_JOB_ID,
type: 'cost_snapshot',
enabled: rawValue.enabled !== false,
intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 300, 60, 3600),
config: {
batchSize: normalizeRangeInteger(config.batchSize, 500, 50, 5000),
maxBatches: normalizeRangeInteger(config.maxBatches, 20, 1, 200),
},
}
}
function normalizeSalesFactsJob(rawValue: JsonObject) {
const config = isPlainObject(rawValue.config) ? rawValue.config : {}
return {
id: SALES_FACTS_JOB_ID,
type: 'sales_facts',
enabled: rawValue.enabled !== false,
intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 600, 60, 3600),
config: {
days: normalizeRangeInteger(config.days, 3, 1, 60),
},
}
}
function normalizeLedgerPostJob(rawValue: JsonObject) {
const config = isPlainObject(rawValue.config) ? rawValue.config : {}
return {
id: LEDGER_POST_JOB_ID,
type: 'ledger_post',
enabled: rawValue.enabled !== false,
intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 600, 60, 3600),
config: {
days: normalizeRangeInteger(config.days, 3, 1, 60),
},
}
}
function createDefaultChannelAssignJob() {
return {
id: CHANNEL_ASSIGN_JOB_ID,
type: 'channel_assign',
enabled: true,
intervalSeconds: 60,
config: { batchSize: 500, maxBatches: 20 },
}
}
function createDefaultCarrierDispatchJob() {
return {
id: CARRIER_DISPATCH_JOB_ID,
type: 'carrier_dispatch',
enabled: true,
intervalSeconds: 60,
config: { limit: 200 },
}
}
function createDefaultProductNormalizeJob() {
return {
id: PRODUCT_NORMALIZE_JOB_ID,
type: 'product_normalize',
enabled: true,
intervalSeconds: 300,
config: { batchSize: 500, maxBatches: 20 },
}
}
function createDefaultCostSnapshotJob() {
return {
id: COST_SNAPSHOT_JOB_ID,
type: 'cost_snapshot',
enabled: true,
intervalSeconds: 300,
config: { batchSize: 500, maxBatches: 20 },
}
}
function createDefaultSalesFactsJob() {
return {
id: SALES_FACTS_JOB_ID,
type: 'sales_facts',
enabled: true,
intervalSeconds: 600,
config: { days: 3 },
}
}
function createDefaultLedgerPostJob() {
return {
id: LEDGER_POST_JOB_ID,
type: 'ledger_post',
enabled: true,
intervalSeconds: 600,
config: { days: 3 },
}
}
function createDefaultPddOrderSyncJob() {
return {
id: PDD_ORDER_SYNC_JOB_ID,
@@ -0,0 +1,181 @@
import type { JsonObject } from '../../types/json.js'
import { query } from '../../db/client.js'
import { assignPendingOrderItemChannels } from '../fulfillment/channel-assign-service.js'
import { normalizeOrderItemProducts } from '../cost-center/product-normalize-service.js'
import { syncChannelCostSnapshots } from '../cost-center/cost-snapshot-service.js'
import { upsertAggregatedException } from '../cost-center/cost-exception-service.js'
import { rebuildChannelSalesDailyFacts } from '../finance/sales-facts-channel-service.js'
import { syncFinanceLedgerFromFacts } from '../finance/finance-ledger-service.js'
import { formatShanghaiDate, shiftDate } from '../../utils/finance-date-range.js'
/**
* 财务与履约管道作业。
*
* 每个作业只做一件事、可单独重跑,彼此不共享事务:
* channel_assign → carrier_dispatch → product_normalize
* → cost_snapshot → sales_facts → ledger_post
*
* 与旧 `cost_finance_sync` 的区别:旧作业把六件事串在一个函数里,
* 任一步失败就半途,而且会顺手写履约字段;这里每个作业只碰自己那一列。
*/
function jobConfig(job: JsonObject): JsonObject {
return job.config && typeof job.config === 'object' ? (job.config as JsonObject) : {}
}
function clampInt(value: unknown, fallback: number, min: number, max: number): number {
const parsed = Number(value)
if (!Number.isInteger(parsed)) return fallback
return Math.min(max, Math.max(min, parsed))
}
export async function runChannelAssignJob(job: JsonObject = {}) {
const config = jobConfig(job)
const result = await assignPendingOrderItemChannels({
batchSize: clampInt(config.batchSize, 500, 1, 5000),
maxBatches: clampInt(config.maxBatches, 20, 1, 200),
afterId: clampInt(config.afterId, 0, 0, Number.MAX_SAFE_INTEGER),
})
return {
status: 'ok',
message:
`渠道判定:扫描 ${result.scanned},已定 ${result.assigned},` +
`冲突 ${result.conflicted},仍待定 ${result.stillPending}`,
...result,
}
}
export async function runCarrierDispatchJob(job: JsonObject = {}) {
const config = jobConfig(job)
const limit = clampInt(config.limit, 200, 1, 2000)
const manual = await query<{ id: number; sku_key: string; config_id: number }>(
`
SELECT oi.id, oi.manual_sku_key AS sku_key, cfg.id AS config_id
FROM order_items oi
JOIN manual_fulfillment_configs cfg ON cfg.sku_key = oi.manual_sku_key AND cfg.enabled
WHERE oi.fulfillment_status = 'assigned'
AND oi.fulfillment_channel = 'manual'
AND oi.fulfillment_config_id IS NULL
ORDER BY oi.id
LIMIT $1
`,
[limit],
)
let attached = 0
for (const row of manual.rows) {
const updated = await query(
`
UPDATE order_items
SET fulfillment_config_id = $2, updated_at = NOW()
WHERE id = $1 AND fulfillment_config_id IS NULL
`,
[row.id, row.config_id],
)
attached += updated.rowCount || 0
}
// 人工渠道但没有配置:进「人工 SKU 未配置」队列。
const missingManual = await query<{ id: number; sku_key: string }>(
`
SELECT oi.id, oi.manual_sku_key AS sku_key
FROM order_items oi
WHERE oi.fulfillment_status = 'assigned'
AND oi.fulfillment_channel = 'manual'
AND oi.fulfillment_config_id IS NULL
AND NOT EXISTS (
SELECT 1 FROM manual_fulfillment_configs cfg
WHERE cfg.sku_key = oi.manual_sku_key AND cfg.enabled
)
ORDER BY oi.id
LIMIT $1
`,
[limit],
)
for (const row of missingManual.rows) {
await upsertAggregatedException({
reason: 'manual_config_missing',
scopeKey: `manual_config_missing|-|-|${row.sku_key}|manual`,
orderItemId: row.id,
detail: { skuKey: row.sku_key },
})
}
return {
status: 'ok',
message: `人工载体挂载 ${attached},缺配置 ${missingManual.rows.length}`,
attached,
missingManual: missingManual.rows.length,
}
}
export async function runProductNormalizeJob(job: JsonObject = {}) {
const config = jobConfig(job)
const result = await normalizeOrderItemProducts({
batchSize: clampInt(config.batchSize, 500, 1, 5000),
maxBatches: clampInt(config.maxBatches, 20, 1, 200),
})
return {
status: 'ok',
message: `商品归一:扫描 ${result.scanned},命中 ${result.matched},未命中 ${result.unmatched}`,
...result,
}
}
export async function runCostSnapshotJob(job: JsonObject = {}) {
const config = jobConfig(job)
const result = await syncChannelCostSnapshots({
batchSize: clampInt(config.batchSize, 500, 1, 5000),
maxBatches: clampInt(config.maxBatches, 20, 1, 200),
})
return {
status: 'ok',
message: `成本快照:扫描 ${result.scanned},生成 ${result.created},缺失 ${result.missing}`,
...result,
}
}
export async function runSalesFactsJob(job: JsonObject = {}) {
const config = jobConfig(job)
const days = clampInt(config.days, 3, 1, 60)
const dateTo = formatShanghaiDate(new Date())
const dateFrom = shiftDate(dateTo, -(days - 1))
const result = await rebuildChannelSalesDailyFacts({ dateFrom, dateTo })
return {
status: 'ok',
message: `销售事实:${result.dateFrom}~${result.dateTo} 写入 ${result.inserted} 行`,
...result,
}
}
export async function runLedgerPostJob(job: JsonObject = {}) {
const config = jobConfig(job)
const days = clampInt(config.days, 3, 1, 60)
const dateTo = formatShanghaiDate(new Date())
const dateFrom = shiftDate(dateTo, -(days - 1))
const result = await syncFinanceLedgerFromFacts({ dateFrom, dateTo })
return {
status: 'ok',
message: `总账:${dateFrom}~${dateTo} 新增 ${result.created} 条,跳过已关账 ${result.skipped}`,
...result,
}
}
/** 顺序执行全部管道,供管理端「一键重算」使用。 */
export async function runFinancePipelineOnce(input: { days?: number } = {}) {
const channel = await runChannelAssignJob({ config: { batchSize: 1000, maxBatches: 50 } })
const carrier = await runCarrierDispatchJob({ config: { limit: 500 } })
const product = await runProductNormalizeJob({ config: { batchSize: 1000, maxBatches: 50 } })
const cost = await runCostSnapshotJob({ config: { batchSize: 1000, maxBatches: 50 } })
const facts = await runSalesFactsJob({ config: { days: input.days ?? 3 } })
const ledger = await runLedgerPostJob({ config: { days: input.days ?? 3 } })
return { channel, carrier, product, cost, facts, ledger }
}
@@ -7,6 +7,14 @@ 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,
runCostSnapshotJob,
runLedgerPostJob,
runProductNormalizeJob,
runSalesFactsJob,
} from './finance-pipeline-jobs.js'
const timers = new Map<string, NodeJS.Timeout>()
const jobStates = new Map<string, JsonObject>()
@@ -208,6 +216,24 @@ function dispatchJob(job: JsonObject) {
if (job.type === 'cost_finance_sync') {
return runCostFinanceSyncJob(job)
}
if (job.type === 'channel_assign') {
return runChannelAssignJob(job)
}
if (job.type === 'carrier_dispatch') {
return runCarrierDispatchJob(job)
}
if (job.type === 'product_normalize') {
return runProductNormalizeJob(job)
}
if (job.type === 'cost_snapshot') {
return runCostSnapshotJob(job)
}
if (job.type === 'sales_facts') {
return runSalesFactsJob(job)
}
if (job.type === 'ledger_post') {
return runLedgerPostJob(job)
}
throw createHttpError(`不支持的定时任务类型:${job.type}`, {
statusCode: 400,
@@ -27,6 +27,7 @@ import {
upsertWorkProductRule,
} from '../../repositories/worker-platform/work-product-rule-repo.js'
import type { WorkOrderRow, WorkProductRuleRow } from '../../repositories/worker-platform/types.js'
import { syncAdminWorkOrderTemplateCostVersions } from '../cost-center/work-order-product-service.js'
import {
normalizeWorkOrderAcceptanceMode,
normalizeWorkOrderGiftCooldownHours,
@@ -531,6 +532,17 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
sortOrder: normalizeInteger(payload.sortOrder, 100),
now: nowIso(),
})
// 模板价格变更即同步成本版本(事件驱动);失败不阻塞模板保存,
// 夜间巡检与后台「同步发单金额」按钮仍可修复。
if (rule) {
try {
await syncAdminWorkOrderTemplateCostVersions(rule.id)
} catch {
// 成本版本同步失败不允许影响模板本身;由巡检补齐。
}
}
return { rule: mapWorkProductRule(rule) }
}