Files
order_site/apps/backend/src/services/fulfillment/routing-config-service.ts
T

452 lines
12 KiB
TypeScript

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<string, unknown>
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<string, FulfillmentRoutingExecutorConfig>
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<Record<string, FulfillmentRoutingExecutorConfig>>(
(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<string, FulfillmentRoutingCandidate>()
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<string>()
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<Record<string, FulfillmentRoutingExecutorConfig>>(
(result, executorKey) => {
result[executorKey] = { enabled: true }
return result
},
{},
),
rules: [],
}
}
function isPlainObject(value: unknown): value is JsonObject {
return Object.prototype.toString.call(value) === '[object Object]'
}