import path from 'node:path' import { PROJECT_ROOT } from '../../config/runtime.js' import { readJsonFile, writeJsonFile } from '../../utils/json-file-store.js' import { normalizeCloudtentaclesMatchName } from '../order/cloudtentacles-match-utils.js' import { FULFILLMENT_EXECUTOR_KEYS } from './executors/types.js' type JsonObject = Record export type FulfillmentRoutingRuleMatchType = 'exact' | 'contains' export type FulfillmentRoutingExecutorConfig = { enabled: boolean } export type FulfillmentRoutingRule = { id: string enabled: boolean productName: string normalizedProductName: string matchType: FulfillmentRoutingRuleMatchType executorKey: string priority: number notes: string } export type FulfillmentRoutingConfig = { enabled: boolean defaultExecutorPriority: string[] unmatchedExecutorKey: string executors: Record rules: FulfillmentRoutingRule[] } export type FulfillmentRoutingCandidate = { executorKey: string available: boolean reason?: string } export type FulfillmentRouteResult = { selectedExecutorKey: string selectedRuleId: string reason: string matchedRule: null | { id: string productName: string executorKey: string priority: number } candidates: FulfillmentRoutingCandidate[] skipped: Array<{ executorKey: string reason: string }> } const FULFILLMENT_ROUTING_CONFIG_FILE_PATH = path.join( PROJECT_ROOT, 'data', 'fulfillment-routing-config.json', ) const ROUTABLE_EXECUTOR_KEYS: string[] = [ FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD, FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI, FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH, ] const DEFAULT_EXECUTOR_PRIORITY = [ FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD, FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI, ] export function getFulfillmentRoutingConfigFilePath() { return FULFILLMENT_ROUTING_CONFIG_FILE_PATH } export function getFulfillmentRoutingConfig(): FulfillmentRoutingConfig { return readJsonFile( FULFILLMENT_ROUTING_CONFIG_FILE_PATH, createDefaultFulfillmentRoutingConfig, normalizeFulfillmentRoutingConfig, ) } export function saveFulfillmentRoutingConfig(rawValue: unknown): FulfillmentRoutingConfig { return writeJsonFile( FULFILLMENT_ROUTING_CONFIG_FILE_PATH, rawValue, normalizeFulfillmentRoutingConfig, ) } export function normalizeFulfillmentRoutingConfig(rawValue: unknown): FulfillmentRoutingConfig { const source = isPlainObject(rawValue) ? rawValue : {} const sourceExecutors = isPlainObject(source.executors) ? source.executors : {} const rules = Array.isArray(source.rules) ? source.rules : [] const defaultExecutorPriority = normalizeExecutorPriority( source.defaultExecutorPriority || source.executorPriority, ) const unmatchedExecutorKey = normalizeExecutorKey(source.unmatchedExecutorKey) return { enabled: source.enabled !== false, defaultExecutorPriority: defaultExecutorPriority.length > 0 ? defaultExecutorPriority : [...DEFAULT_EXECUTOR_PRIORITY], unmatchedExecutorKey: unmatchedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH ? unmatchedExecutorKey : '', executors: ROUTABLE_EXECUTOR_KEYS.reduce>( (result, executorKey) => { const executorConfig = isPlainObject(sourceExecutors[executorKey]) ? sourceExecutors[executorKey] as JsonObject : {} result[executorKey] = { enabled: executorConfig.enabled !== false, } return result }, {}, ), rules: rules .map((rule) => normalizeFulfillmentRoutingRule(rule)) .filter((rule): rule is FulfillmentRoutingRule => Boolean(rule)) .sort((left, right) => right.priority - left.priority), } } export function resolveFulfillmentRoute({ productName, candidates, config = getFulfillmentRoutingConfig(), }: { productName: unknown candidates: FulfillmentRoutingCandidate[] config?: FulfillmentRoutingConfig }): FulfillmentRouteResult { const normalizedConfig = normalizeFulfillmentRoutingConfig(config) const normalizedCandidates = normalizeRoutingCandidates(candidates) const skipped: FulfillmentRouteResult['skipped'] = [] if (normalizedConfig.enabled === false) { return resolveByExecutorPriority({ priority: DEFAULT_EXECUTOR_PRIORITY, candidates: normalizedCandidates, config: createDefaultFulfillmentRoutingConfig(), skipped, reasonPrefix: '履约路由未启用,使用历史顺序', }) } const matchedRule = findMatchedRoutingRule(productName, normalizedConfig.rules) if (matchedRule) { const selected = resolveExecutorSelection({ executorKey: matchedRule.executorKey, candidates: normalizedCandidates, config: normalizedConfig, skipped, reason: '命中商品路由规则', matchedRule, }) if (selected.selectedExecutorKey) { return selected } return { ...selected, reason: selected.reason || '商品路由规则目标通道不可用', } } const selected = resolveByExecutorPriority({ priority: normalizedConfig.defaultExecutorPriority, candidates: normalizedCandidates, config: normalizedConfig, skipped, reasonPrefix: '按全局优先级命中', }) if (selected.selectedExecutorKey) { return selected } if (normalizedConfig.unmatchedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH) { const fallback = resolveExecutorSelection({ executorKey: FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH, candidates: normalizedCandidates, config: normalizedConfig, skipped, reason: '未命中自动履约通道,进入人工履约', matchedRule: null, }) if (fallback.selectedExecutorKey) { return fallback } } return { selectedExecutorKey: '', selectedRuleId: '', reason: '未命中可用履约通道', matchedRule: null, candidates: normalizedCandidates, skipped, } } function resolveByExecutorPriority({ priority, candidates, config, skipped, reasonPrefix, }: { priority: string[] candidates: FulfillmentRoutingCandidate[] config: FulfillmentRoutingConfig skipped: FulfillmentRouteResult['skipped'] reasonPrefix: string }) { for (const executorKey of priority) { const selected = resolveExecutorSelection({ executorKey, candidates, config, skipped, reason: reasonPrefix, matchedRule: null, }) if (selected.selectedExecutorKey) { return selected } } return { selectedExecutorKey: '', selectedRuleId: '', reason: '', matchedRule: null, candidates, skipped, } } function resolveExecutorSelection({ executorKey, candidates, config, skipped, reason, matchedRule, }: { executorKey: string candidates: FulfillmentRoutingCandidate[] config: FulfillmentRoutingConfig skipped: FulfillmentRouteResult['skipped'] reason: string matchedRule: FulfillmentRoutingRule | null }): FulfillmentRouteResult { const normalizedExecutorKey = normalizeExecutorKey(executorKey) if (!normalizedExecutorKey) { return createEmptyRouteResult(candidates, skipped) } if (config.executors[normalizedExecutorKey]?.enabled === false) { skipped.push({ executorKey: normalizedExecutorKey, reason: '履约通道已停用', }) return createEmptyRouteResult(candidates, skipped) } if (normalizedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH) { return createSelectedRouteResult({ executorKey: normalizedExecutorKey, reason, matchedRule, candidates, skipped, }) } const candidate = candidates.find((item) => item.executorKey === normalizedExecutorKey) if (!candidate?.available) { skipped.push({ executorKey: normalizedExecutorKey, reason: candidate?.reason || '未命中该通道商品配置', }) return createEmptyRouteResult(candidates, skipped) } return createSelectedRouteResult({ executorKey: normalizedExecutorKey, reason, matchedRule, candidates, skipped, }) } function createSelectedRouteResult({ executorKey, reason, matchedRule, candidates, skipped, }: { executorKey: string reason: string matchedRule: FulfillmentRoutingRule | null candidates: FulfillmentRoutingCandidate[] skipped: FulfillmentRouteResult['skipped'] }): FulfillmentRouteResult { return { selectedExecutorKey: executorKey, selectedRuleId: matchedRule?.id || '', reason, matchedRule: matchedRule ? { id: matchedRule.id, productName: matchedRule.productName, executorKey: matchedRule.executorKey, priority: matchedRule.priority, } : null, candidates, skipped, } } function createEmptyRouteResult( candidates: FulfillmentRoutingCandidate[], skipped: FulfillmentRouteResult['skipped'], ): FulfillmentRouteResult { return { selectedExecutorKey: '', selectedRuleId: '', reason: '', matchedRule: null, candidates, skipped, } } function findMatchedRoutingRule(productName: unknown, rules: FulfillmentRoutingRule[]) { const normalizedProductName = normalizeCloudtentaclesMatchName(productName) if (!normalizedProductName) { return null } return rules.find((rule) => { if (rule.enabled === false || !rule.normalizedProductName) { return false } if (rule.matchType === 'contains') { return normalizedProductName.includes(rule.normalizedProductName) } return normalizedProductName === rule.normalizedProductName }) || null } function normalizeFulfillmentRoutingRule(rawValue: unknown): FulfillmentRoutingRule | null { const source = isPlainObject(rawValue) ? rawValue : {} const productName = String(source.productName || source.name || '').trim() const normalizedProductName = normalizeCloudtentaclesMatchName(source.normalizedProductName) || normalizeCloudtentaclesMatchName(productName) const executorKey = normalizeExecutorKey(source.executorKey) if (!productName || !normalizedProductName || !executorKey) { return null } return { id: String(source.id || `${normalizedProductName}:${executorKey}`).trim() || `${normalizedProductName}:${executorKey}`, enabled: source.enabled !== false, productName, normalizedProductName, matchType: source.matchType === 'contains' ? 'contains' : 'exact', executorKey, priority: normalizeInteger(source.priority, 100), notes: String(source.notes || '').trim(), } } function normalizeRoutingCandidates(candidates: FulfillmentRoutingCandidate[]) { const result = new Map() for (const candidate of Array.isArray(candidates) ? candidates : []) { const executorKey = normalizeExecutorKey(candidate.executorKey) if (!executorKey || executorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH) { continue } result.set(executorKey, { executorKey, available: candidate.available === true, reason: String(candidate.reason || '').trim(), }) } return Array.from(result.values()) } function normalizeExecutorPriority(value: unknown) { const rawItems = Array.isArray(value) ? value : [] const seen = new Set() const items: string[] = [] for (const item of rawItems) { const executorKey = normalizeExecutorKey(item) if (!executorKey || seen.has(executorKey) || executorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH) { continue } seen.add(executorKey) items.push(executorKey) } return items } function normalizeExecutorKey(value: unknown) { const executorKey = String(value || '').trim() return ROUTABLE_EXECUTOR_KEYS.includes(executorKey) ? executorKey : '' } function normalizeInteger(value: unknown, fallback: number) { const parsed = Number(value) return Number.isInteger(parsed) ? parsed : fallback } function createDefaultFulfillmentRoutingConfig(): FulfillmentRoutingConfig { return { enabled: true, defaultExecutorPriority: [...DEFAULT_EXECUTOR_PRIORITY], unmatchedExecutorKey: '', executors: ROUTABLE_EXECUTOR_KEYS.reduce>( (result, executorKey) => { result[executorKey] = { enabled: true } return result }, {}, ), rules: [], } } function isPlainObject(value: unknown): value is JsonObject { return Object.prototype.toString.call(value) === '[object Object]' }