452 lines
12 KiB
TypeScript
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]'
|
|
}
|