Files
order_site/apps/backend/src/services/scheduler/config-service.ts
T
yml2213 65b7f4f9dc refactor(finance)!: 强制渠道模型,移除旧履约方式与同步链路
- 删除 fulfillment_mode 相关的旧同步/回填链路与定时任务
- 删除 cost_finance_sync、成本快照同步、人工商品归一等遗留服务
- 迁移 113 直接删除 order_items.fulfillment_mode 及其索引约束
- 断代脚本自带归档步骤,不再依赖独立回填脚本
- 退款/事实/总账统一走渠道版销售事实
2026-10-31 19:52:03 +08:00

434 lines
13 KiB
TypeScript

import path from 'node:path'
import type { JsonObject } from '../../types/json.js'
import { APP_CONFIG_KEYS } from '../../config/app-config-keys.js'
import { PROJECT_ROOT } from '../../config/runtime.js'
import { readAppConfigEntry, saveAppConfigEntry } from '../config/app-config-store.js'
const SCHEDULED_JOBS_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'scheduled-jobs.json')
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'
/** 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
}
export function getScheduledJobsConfig() {
return readAppConfigEntry({
configKey: APP_CONFIG_KEYS.scheduledJobs,
legacyFilePath: SCHEDULED_JOBS_FILE_PATH,
fallback: createDefaultScheduledJobsConfig,
normalize: normalizeScheduledJobsConfig,
})
}
export function saveScheduledJobsConfig(rawValue: unknown) {
return saveAppConfigEntry({
configKey: APP_CONFIG_KEYS.scheduledJobs,
legacyFilePath: SCHEDULED_JOBS_FILE_PATH,
value: rawValue,
normalize: normalizeScheduledJobsConfig,
})
}
export function getWorkOrderTimeoutJob(config: JsonObject = getScheduledJobsConfig()) {
return (
(Array.isArray(config.jobs) ? config.jobs : []).find(
(item) => String(item.id || '').trim() === WORK_ORDER_TIMEOUT_JOB_ID,
) || createDefaultWorkOrderTimeoutJob()
)
}
export function getDepositUnfreezeJob(config: JsonObject = getScheduledJobsConfig()) {
return (
(Array.isArray(config.jobs) ? config.jobs : []).find(
(item) => String(item.id || '').trim() === DEPOSIT_UNFREEZE_JOB_ID,
) || createDefaultDepositUnfreezeJob()
)
}
export function getPddOrderSyncJob(config: JsonObject = getScheduledJobsConfig()) {
return (
(Array.isArray(config.jobs) ? config.jobs : []).find(
(item) => String(item.id || '').trim() === PDD_ORDER_SYNC_JOB_ID,
) || createDefaultPddOrderSyncJob()
)
}
export function getScheduledJobById(jobId: unknown, config: JsonObject = getScheduledJobsConfig()) {
return (
(Array.isArray(config.jobs) ? config.jobs : []).find(
(item) => String(item.id || '').trim() === String(jobId || '').trim(),
) || null
)
}
export function normalizeScheduledJobsConfig(rawValue: unknown) {
const source = isPlainObject(rawValue) ? rawValue : {}
const rawJobs = Array.isArray(source.jobs) ? source.jobs : []
const jobs = rawJobs
.map((item) => normalizeScheduledJob(item))
.filter((item): item is NonNullable<ReturnType<typeof normalizeScheduledJob>> => Boolean(item))
const hasWorkOrderTimeout = jobs.some((item) => item.id === WORK_ORDER_TIMEOUT_JOB_ID)
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 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)
const hasCostSnapshot = jobs.some((item) => item.id === COST_SNAPSHOT_JOB_ID)
const hasSalesFacts = jobs.some((item) => item.id === SALES_FACTS_JOB_ID)
const hasLedgerPost = jobs.some((item) => item.id === LEDGER_POST_JOB_ID)
if (!hasWorkOrderTimeout) {
jobs.push(createDefaultWorkOrderTimeoutJob())
}
if (!hasDepositUnfreeze) {
jobs.push(createDefaultDepositUnfreezeJob())
}
if (!hasDataRetention) {
jobs.push(createDefaultDataRetentionJob())
}
if (!hasPddOrderSync) {
jobs.push(createDefaultPddOrderSyncJob())
}
// 旧作业 cost_finance_sync 已下线:v2 管道接管,且它写旧字段会覆盖新口径。
// 保留配置项仅用于兼容读取,运行期强制停用。
// v2 管道:老配置里没有这些任务时自动补齐,保证升级后无需手工添加。
if (!hasChannelAssign) jobs.push(createDefaultChannelAssignJob())
if (!hasCarrierDispatch) jobs.push(createDefaultCarrierDispatchJob())
if (!hasProductNormalize) jobs.push(createDefaultProductNormalizeJob())
if (!hasCostSnapshot) jobs.push(createDefaultCostSnapshotJob())
if (!hasSalesFacts) jobs.push(createDefaultSalesFactsJob())
if (!hasLedgerPost) jobs.push(createDefaultLedgerPostJob())
return {
enabled: typeof source.enabled === 'boolean' ? source.enabled : true,
jobs,
}
}
function normalizeScheduledJob(rawValue: unknown) {
if (!isPlainObject(rawValue)) {
return null
}
const type = String(rawValue.type || '').trim()
// 已下线的 cloudtentacles_health 配置直接丢弃
if (type === 'cloudtentacles_health') {
return null
}
if (type === 'work_order_timeout') {
return normalizeWorkOrderTimeoutJob(rawValue)
}
if (type === 'deposit_unfreeze') {
return normalizeDepositUnfreezeJob(rawValue)
}
if (type === 'data_retention') {
return normalizeDataRetentionJob(rawValue)
}
if (type === 'pdd_order_sync') {
return normalizePddOrderSyncJob(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
}
function normalizePddOrderSyncJob(rawValue: JsonObject) {
const config = isPlainObject(rawValue.config) ? rawValue.config : {}
return {
id: PDD_ORDER_SYNC_JOB_ID,
type: 'pdd_order_sync',
enabled: rawValue.enabled !== false,
intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 20, 10, 3600),
config: {
pageSize: normalizeRangeInteger(config.pageSize, 10, 1, 50),
},
}
}
function normalizeDepositUnfreezeJob(rawValue: JsonObject) {
const config = isPlainObject(rawValue.config) ? rawValue.config : {}
return {
id: DEPOSIT_UNFREEZE_JOB_ID,
type: 'deposit_unfreeze',
enabled: rawValue.enabled !== false,
intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 60, 30, 3600),
config: {
scanLimit: normalizeRangeInteger(config.scanLimit, 100, 1, 2000),
},
}
}
function normalizeDataRetentionJob(rawValue: JsonObject) {
return {
id: DATA_RETENTION_JOB_ID,
type: 'data_retention',
enabled: rawValue.enabled !== false,
intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 86400, 3600, 604800),
config: {},
}
}
function normalizeWorkOrderTimeoutJob(rawValue: JsonObject) {
const config = isPlainObject(rawValue.config) ? rawValue.config : {}
return {
id: WORK_ORDER_TIMEOUT_JOB_ID,
type: 'work_order_timeout',
enabled: rawValue.enabled !== false,
intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 60, 30, 3600),
config: {
scanLimit: normalizeRangeInteger(config.scanLimit, 50, 1, 1000),
},
}
}
function createDefaultScheduledJobsConfig() {
return {
enabled: true,
jobs: [
createDefaultWorkOrderTimeoutJob(),
createDefaultDepositUnfreezeJob(),
createDefaultDataRetentionJob(),
createDefaultPddOrderSyncJob(),
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,
type: 'pdd_order_sync',
enabled: true,
intervalSeconds: 20,
config: {
pageSize: 10,
},
}
}
function createDefaultDataRetentionJob() {
return {
id: DATA_RETENTION_JOB_ID,
type: 'data_retention',
enabled: true,
intervalSeconds: 86400,
config: {},
}
}
function createDefaultDepositUnfreezeJob() {
return {
id: DEPOSIT_UNFREEZE_JOB_ID,
type: 'deposit_unfreeze',
enabled: true,
intervalSeconds: 60,
config: {
scanLimit: 100,
},
}
}
function createDefaultWorkOrderTimeoutJob() {
return {
id: WORK_ORDER_TIMEOUT_JOB_ID,
type: 'work_order_timeout',
enabled: true,
intervalSeconds: 60,
config: {
scanLimit: 50,
},
}
}
function normalizeRangeInteger(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))
}
function isPlainObject(value: unknown): value is JsonObject {
return Object.prototype.toString.call(value) === '[object Object]'
}