优化履约路由并重命名乐玩通道
This commit is contained in:
@@ -10,7 +10,7 @@ import {
|
||||
shouldEnsureKuaishouCloudClaimLink,
|
||||
} from './task-status.js'
|
||||
|
||||
test('任务状态机声明快手 Cloud 主流程跳转', () => {
|
||||
test('任务状态机声明 kuaishou-lewan 主流程跳转', () => {
|
||||
assert.equal(canTaskTransition(TASK_STATUS.PENDING_BINDING_PREPARE, TASK_STATUS.WAITING_BINDING), true)
|
||||
assert.equal(canTaskTransition(TASK_STATUS.WAITING_BINDING, TASK_STATUS.ROLE_CONFIRMED), true)
|
||||
assert.equal(canTaskTransition(TASK_STATUS.ROLE_CONFIRMED, TASK_STATUS.REDEEMING), true)
|
||||
|
||||
@@ -7,6 +7,7 @@ import kuaishouFeifeiRouter from "./platform-config/kuaishou-feifei.js";
|
||||
import kuaishouEticketRouter from "./platform-config/kuaishou-eticket.js";
|
||||
import ninetyoneRouter from "./platform-config/ninetyone.js";
|
||||
import notificationsRouter from "./platform-config/notifications.js";
|
||||
import fulfillmentRoutingRouter from "./platform-config/fulfillment-routing.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -16,6 +17,7 @@ router.use("/platform-config", kuaishouIndustryRouter);
|
||||
router.use("/platform-config", kuaishouEticketRouter);
|
||||
router.use("/platform-config", kuaishouFeifeiRouter);
|
||||
router.use("/platform-config", ninetyoneRouter);
|
||||
router.use("/platform-config", fulfillmentRoutingRouter);
|
||||
router.use("/platform-config", cloudtentaclesRouter);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import {
|
||||
getAdminFulfillmentRoutingConfig,
|
||||
previewAdminFulfillmentRouting,
|
||||
updateAdminFulfillmentRoutingConfig,
|
||||
} from '../../../services/admin/platform-config/fulfillment-routing-service.js'
|
||||
import { createJsonHandler } from '../session.js'
|
||||
import type { JsonRecord } from '../../../types/json.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get(
|
||||
'/fulfillment-routing',
|
||||
createJsonHandler(() => getAdminFulfillmentRoutingConfig(), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取履约路由配置失败',
|
||||
scope: '[admin/platform-config/fulfillment-routing]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/fulfillment-routing',
|
||||
createJsonHandler(
|
||||
(req) => updateAdminFulfillmentRoutingConfig(req.body as JsonRecord),
|
||||
{
|
||||
successMessage: '履约路由配置已保存',
|
||||
errorMessage: '保存履约路由配置失败',
|
||||
scope: '[admin/platform-config/fulfillment-routing]',
|
||||
audit: (_req, data) => {
|
||||
const result = data as JsonRecord
|
||||
return {
|
||||
action: 'platform_fulfillment_routing_updated',
|
||||
targetType: 'platform_config',
|
||||
targetId: 'fulfillment_routing',
|
||||
data: {
|
||||
filePath: String(result.filePath || '').trim(),
|
||||
enabled: result.enabled !== false,
|
||||
ruleCount: Array.isArray(result.rules) ? result.rules.length : 0,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/fulfillment-routing/preview',
|
||||
createJsonHandler(
|
||||
(req) => previewAdminFulfillmentRouting(req.body as JsonRecord),
|
||||
{
|
||||
successMessage: 'ok',
|
||||
errorMessage: '预览履约路由失败',
|
||||
scope: '[admin/platform-config/fulfillment-routing/preview]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
getFulfillmentRoutingConfig,
|
||||
getFulfillmentRoutingConfigFilePath,
|
||||
saveFulfillmentRoutingConfig,
|
||||
} from '../../fulfillment/routing-config-service.js'
|
||||
import { resolveOrderItemForFulfillment } from '../../fulfillment/product-resolution-service.js'
|
||||
import { normalizeCloudtentaclesMatchName } from '../../order/cloudtentacles-match-utils.js'
|
||||
import type { JsonRecord } from '../../../types/json.js'
|
||||
|
||||
export function getAdminFulfillmentRoutingConfig() {
|
||||
return {
|
||||
filePath: getFulfillmentRoutingConfigFilePath(),
|
||||
...getFulfillmentRoutingConfig(),
|
||||
}
|
||||
}
|
||||
|
||||
export function updateAdminFulfillmentRoutingConfig(payload: JsonRecord = {}) {
|
||||
return {
|
||||
filePath: getFulfillmentRoutingConfigFilePath(),
|
||||
...saveFulfillmentRoutingConfig(payload),
|
||||
}
|
||||
}
|
||||
|
||||
export async function previewAdminFulfillmentRouting(payload: JsonRecord = {}) {
|
||||
const productName = String(payload.productName || payload.skuName || '').trim()
|
||||
const item = await resolveOrderItemForFulfillment({
|
||||
provider: '91kaquan',
|
||||
platform: 'kuaishou',
|
||||
item: {
|
||||
itemId: productName,
|
||||
externalItemId: productName,
|
||||
skuCode: productName,
|
||||
skuName: productName,
|
||||
externalSkuCode: productName,
|
||||
externalSkuName: productName,
|
||||
quantity: 1,
|
||||
},
|
||||
})
|
||||
const snapshot = item.snapshot || {}
|
||||
|
||||
return {
|
||||
productName,
|
||||
normalizedProductName: normalizeCloudtentaclesMatchName(productName),
|
||||
resolvedSkuCode: item.skuCode,
|
||||
resolvedSkuName: item.skuName,
|
||||
isConfigured: item.isConfigured,
|
||||
fulfillmentRoute: snapshot.fulfillmentRoute || null,
|
||||
cloudtentacles: snapshot.cloudtentacles || null,
|
||||
kuaishouFeifei: snapshot.kuaishouFeifei || null,
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,7 @@ export async function prepareAdminTaskKuaishouCloudFulfillment(
|
||||
}
|
||||
|
||||
if (!isKuaishouCloudTask(task)) {
|
||||
throw createHttpError('当前任务不是快手 cloud 履约任务', {
|
||||
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_not_kuaishou_cloud',
|
||||
})
|
||||
@@ -261,7 +261,7 @@ export async function dispatchAdminTaskKuaishouCloudFulfillment(
|
||||
}
|
||||
|
||||
if (!isKuaishouCloudTask(task)) {
|
||||
throw createHttpError('当前任务不是快手 cloud 履约任务', {
|
||||
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_not_kuaishou_cloud',
|
||||
})
|
||||
@@ -397,7 +397,7 @@ export async function refreshAdminTaskKuaishouCloudRoleInfo(
|
||||
}
|
||||
|
||||
if (!isKuaishouCloudTask(task)) {
|
||||
throw createHttpError('当前任务不是快手 cloud 履约任务', {
|
||||
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_not_kuaishou_cloud',
|
||||
})
|
||||
@@ -522,7 +522,7 @@ export async function returnNumberAdminTaskKuaishouCloudFulfillment(
|
||||
}
|
||||
|
||||
if (!isKuaishouCloudTask(task)) {
|
||||
throw createHttpError('当前任务不是快手 cloud 履约任务', {
|
||||
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_not_kuaishou_cloud',
|
||||
})
|
||||
|
||||
@@ -29,7 +29,7 @@ const CORE_PROFILES: CoreProfile[] = [
|
||||
},
|
||||
{
|
||||
profileKey: 'kuaishou_ct_assisted',
|
||||
name: '快手 cloud 履约',
|
||||
name: 'kuaishou-lewan 履约',
|
||||
executorKey: 'kuaishou_ct_assisted',
|
||||
requiresClaim: false,
|
||||
autoDispatch: false,
|
||||
|
||||
@@ -56,7 +56,7 @@ export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: Js
|
||||
}
|
||||
|
||||
if (executorKey !== 'kuaishou_ct_assisted') {
|
||||
throw createHttpError('当前领取链接不是快手 Cloud 客户领取流程', {
|
||||
throw createHttpError('当前领取链接不是 kuaishou-lewan 客户领取流程', {
|
||||
statusCode: 409,
|
||||
errorCode: 'claim_not_kuaishou_cloud',
|
||||
})
|
||||
@@ -440,7 +440,7 @@ export async function rebindKuaishouCloudClaimRole(token: unknown) {
|
||||
const context = await getClaimContext(token)
|
||||
|
||||
if (String(context.task.executor_key || '').trim() !== 'kuaishou_ct_assisted') {
|
||||
throw createHttpError('当前领取链接不是快手 Cloud 客户领取流程', {
|
||||
throw createHttpError('当前领取链接不是 kuaishou-lewan 客户领取流程', {
|
||||
statusCode: 409,
|
||||
errorCode: 'claim_not_kuaishou_cloud',
|
||||
})
|
||||
@@ -633,7 +633,7 @@ export async function confirmKuaishouCloudClaimRole(token: unknown) {
|
||||
const now = nowIso()
|
||||
|
||||
if (String(context.task.executor_key || '').trim() !== 'kuaishou_ct_assisted') {
|
||||
throw createHttpError('当前领取链接不是快手 Cloud 客户领取流程', {
|
||||
throw createHttpError('当前领取链接不是 kuaishou-lewan 客户领取流程', {
|
||||
statusCode: 409,
|
||||
errorCode: 'claim_not_kuaishou_cloud',
|
||||
})
|
||||
@@ -711,7 +711,7 @@ export async function redeemKuaishouCloudClaim(token: unknown) {
|
||||
const now = nowIso()
|
||||
|
||||
if (String(context.task.executor_key || '').trim() !== 'kuaishou_ct_assisted') {
|
||||
throw createHttpError('当前领取链接不是快手 Cloud 客户领取流程', {
|
||||
throw createHttpError('当前领取链接不是 kuaishou-lewan 客户领取流程', {
|
||||
statusCode: 409,
|
||||
errorCode: 'claim_not_kuaishou_cloud',
|
||||
})
|
||||
|
||||
@@ -196,7 +196,7 @@ export async function ensureTaskClaimLink(task: TaskRow) {
|
||||
|
||||
export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options: JsonObject = {}) {
|
||||
if (!isKuaishouCloudTask(task)) {
|
||||
throw createHttpError('当前任务不是快手 Cloud 履约任务', {
|
||||
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
|
||||
statusCode: 409,
|
||||
errorCode: 'kuaishou_cloud_task_invalid',
|
||||
})
|
||||
@@ -411,7 +411,7 @@ export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options
|
||||
|
||||
export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: JsonObject = {}) {
|
||||
if (!isKuaishouCloudTask(task)) {
|
||||
throw createHttpError('当前任务不是快手 Cloud 履约任务', {
|
||||
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
|
||||
statusCode: 409,
|
||||
errorCode: 'kuaishou_cloud_task_invalid',
|
||||
})
|
||||
@@ -571,7 +571,7 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js
|
||||
|
||||
export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonObject = {}) {
|
||||
if (!isKuaishouCloudTask(task)) {
|
||||
throw createHttpError('当前任务不是快手 Cloud 履约任务', {
|
||||
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
|
||||
statusCode: 409,
|
||||
errorCode: 'kuaishou_cloud_task_invalid',
|
||||
})
|
||||
@@ -852,7 +852,7 @@ export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonOb
|
||||
|
||||
export async function probeKuaishouCloudTaskBindUrl(task: TaskRow, options: JsonObject = {}) {
|
||||
if (!isKuaishouCloudTask(task)) {
|
||||
throw createHttpError('当前任务不是快手 Cloud 履约任务', {
|
||||
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
|
||||
statusCode: 409,
|
||||
errorCode: 'kuaishou_cloud_task_invalid',
|
||||
})
|
||||
@@ -1008,7 +1008,7 @@ async function markKuaishouCloudBindUrlRefreshFailed(
|
||||
|
||||
export async function refreshKuaishouCloudTaskRoleInfo(task: TaskRow, options: JsonObject = {}) {
|
||||
if (!isKuaishouCloudTask(task)) {
|
||||
throw createHttpError('当前任务不是快手 Cloud 履约任务', {
|
||||
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
|
||||
statusCode: 409,
|
||||
errorCode: 'kuaishou_cloud_task_invalid',
|
||||
})
|
||||
|
||||
@@ -70,7 +70,7 @@ export async function dispatchKuaishouCloudFulfillmentTask(
|
||||
options: JsonObject = {}
|
||||
) {
|
||||
if (!isKuaishouCloudTask(task)) {
|
||||
throw createHttpError("当前任务不是快手 Cloud 履约任务", {
|
||||
throw createHttpError("当前任务不是 kuaishou-lewan 履约任务", {
|
||||
statusCode: 409,
|
||||
errorCode: "kuaishou_cloud_task_invalid",
|
||||
});
|
||||
@@ -211,7 +211,7 @@ export async function dispatchKuaishouCloudFulfillmentTask(
|
||||
updated_at: now,
|
||||
});
|
||||
if (!updatedTask) {
|
||||
throw createHttpError("快手 Cloud 发货状态更新失败", {
|
||||
throw createHttpError("kuaishou-lewan 发货状态更新失败", {
|
||||
statusCode: 500,
|
||||
errorCode: "kuaishou_cloud_dispatch_update_failed",
|
||||
});
|
||||
@@ -749,7 +749,7 @@ export async function returnKuaishouCloudFulfillmentTask(
|
||||
options: JsonObject = {}
|
||||
) {
|
||||
if (!isKuaishouCloudTask(task)) {
|
||||
throw createHttpError("当前任务不是快手 Cloud 履约任务", {
|
||||
throw createHttpError("当前任务不是 kuaishou-lewan 履约任务", {
|
||||
statusCode: 409,
|
||||
errorCode: "kuaishou_cloud_task_invalid",
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import test from 'node:test'
|
||||
import { planFulfillmentTaskForOrderItem } from './planner.js'
|
||||
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
|
||||
|
||||
test('planFulfillmentTaskForOrderItem 为 cloudtentacles 商品生成 cloud 履约计划', async () => {
|
||||
test('planFulfillmentTaskForOrderItem 为 cloudtentacles 商品生成 kuaishou-lewan 履约计划', async () => {
|
||||
const plan = await planFulfillmentTaskForOrderItem({
|
||||
order: createOrder(),
|
||||
item: createOrderItem({
|
||||
@@ -23,7 +23,7 @@ test('planFulfillmentTaskForOrderItem 为 cloudtentacles 商品生成 cloud 履
|
||||
getProfileByKey: async (profileKey) => ({
|
||||
id: 9,
|
||||
profile_key: profileKey,
|
||||
name: '快手 cloud 履约',
|
||||
name: 'kuaishou-lewan 履约',
|
||||
executor_key: profileKey,
|
||||
requires_claim: true,
|
||||
auto_dispatch: true,
|
||||
@@ -76,6 +76,74 @@ test('planFulfillmentTaskForOrderItem 为 feifei 商品生成 feifei 履约计
|
||||
assert.equal((plan.context.kuaishouFeifei as any).productName, '测试皮肤')
|
||||
})
|
||||
|
||||
test('planFulfillmentTaskForOrderItem 尊重履约路由选择 feifei', async () => {
|
||||
const plan = await planFulfillmentTaskForOrderItem({
|
||||
order: createOrder(),
|
||||
item: createOrderItem({
|
||||
sku_code: 'FF-ROUTE',
|
||||
sku_name: '同时命中商品',
|
||||
item_snapshot_json: {
|
||||
fulfillmentRoute: {
|
||||
selectedExecutorKey: 'kuaishou_feifei',
|
||||
},
|
||||
cloudtentacles: {
|
||||
cloudSourceKeys: ['account-a'],
|
||||
cloudSkuId: 74,
|
||||
cloudSkuName: 'Cloud 商品',
|
||||
},
|
||||
kuaishouFeifei: {
|
||||
productCode: 'FF-ROUTE',
|
||||
skuName: 'feifei 商品',
|
||||
matchMode: 'kuaishou_feifei_name',
|
||||
},
|
||||
},
|
||||
}),
|
||||
getProfileByKey: async (profileKey) => ({
|
||||
id: 10,
|
||||
profile_key: profileKey,
|
||||
name: '测试履约',
|
||||
executor_key: profileKey,
|
||||
requires_claim: false,
|
||||
auto_dispatch: true,
|
||||
}),
|
||||
})
|
||||
|
||||
assert.ok(plan)
|
||||
assert.equal(plan.executorKey, 'kuaishou_feifei')
|
||||
assert.equal(plan.context.kuaishouCloudFulfillment, null)
|
||||
assert.equal((plan.context.kuaishouFeifei as any).productCode, 'FF-ROUTE')
|
||||
})
|
||||
|
||||
test('planFulfillmentTaskForOrderItem 支持履约路由转人工', async () => {
|
||||
const plan = await planFulfillmentTaskForOrderItem({
|
||||
order: createOrder(),
|
||||
item: createOrderItem({
|
||||
sku_code: 'MANUAL-1',
|
||||
sku_name: '人工商品',
|
||||
item_snapshot_json: {
|
||||
fulfillmentRoute: {
|
||||
selectedExecutorKey: 'manual_dispatch',
|
||||
},
|
||||
},
|
||||
}),
|
||||
getProfileByKey: async (profileKey) => ({
|
||||
id: 1,
|
||||
profile_key: profileKey,
|
||||
name: '人工发货',
|
||||
executor_key: 'manual_dispatch',
|
||||
requires_claim: true,
|
||||
auto_dispatch: true,
|
||||
}),
|
||||
})
|
||||
|
||||
assert.ok(plan)
|
||||
assert.equal(plan.executorKey, 'manual_dispatch')
|
||||
assert.equal(plan.requiresClaim, false)
|
||||
assert.equal(plan.autoDispatch, false)
|
||||
assert.equal(plan.context.kuaishouCloudFulfillment, null)
|
||||
assert.equal(plan.context.kuaishouFeifei, null)
|
||||
})
|
||||
|
||||
test('planFulfillmentTaskForOrderItem 未命中平台配置时返回 null', async () => {
|
||||
const plan = await planFulfillmentTaskForOrderItem({
|
||||
order: createOrder(),
|
||||
|
||||
@@ -231,6 +231,21 @@ async function resolveDynamicFulfillmentProfile(
|
||||
item: OrderItemRow,
|
||||
getProfileByKey: FulfillmentProfileResolver,
|
||||
): Promise<FulfillmentBindingLike | null> {
|
||||
const selectedExecutorKey = resolveSelectedFulfillmentExecutorKey(item)
|
||||
if (selectedExecutorKey) {
|
||||
if (selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD) {
|
||||
return resolveDynamicCloudtentaclesProfile(item, getProfileByKey)
|
||||
}
|
||||
|
||||
if (selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI) {
|
||||
return resolveDynamicKuaishouFeifeiProfile(item, getProfileByKey)
|
||||
}
|
||||
|
||||
if (selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH) {
|
||||
return resolveDynamicManualDispatchProfile(item, getProfileByKey)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
await resolveDynamicCloudtentaclesProfile(item, getProfileByKey) ||
|
||||
await resolveDynamicKuaishouFeifeiProfile(item, getProfileByKey)
|
||||
@@ -272,7 +287,7 @@ async function resolveDynamicCloudtentaclesProfile(
|
||||
...profile,
|
||||
profile_id: Number(profile.id || profile.profile_id || 0),
|
||||
profile_key: FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD,
|
||||
profile_name: String(profile.profile_name || profile.name || '快手 cloud 履约').trim(),
|
||||
profile_name: String(profile.profile_name || profile.name || 'kuaishou-lewan 履约').trim(),
|
||||
executor_key: FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD,
|
||||
requires_claim: false,
|
||||
auto_dispatch: false,
|
||||
@@ -343,6 +358,42 @@ async function resolveDynamicKuaishouFeifeiProfile(
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveDynamicManualDispatchProfile(
|
||||
item: OrderItemRow,
|
||||
getProfileByKey: FulfillmentProfileResolver,
|
||||
): Promise<FulfillmentBindingLike | null> {
|
||||
const profile = await getProfileByKey('manual_review')
|
||||
if (!profile) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...profile,
|
||||
profile_id: Number(profile.id || profile.profile_id || 0),
|
||||
profile_key: 'manual_review',
|
||||
profile_name: String(profile.profile_name || profile.name || '人工发货').trim(),
|
||||
executor_key: FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH,
|
||||
requires_claim: false,
|
||||
auto_dispatch: false,
|
||||
config_json: {
|
||||
flowType: 'manual_dispatch',
|
||||
configId: `manual_dispatch:${item.sku_code || item.id}`,
|
||||
manualDispatch: {
|
||||
source: 'fulfillment_routing',
|
||||
skuCode: item.sku_code,
|
||||
skuName: item.sku_name,
|
||||
},
|
||||
notes: '履约路由规则指定人工履约',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSelectedFulfillmentExecutorKey(item: OrderItemRow) {
|
||||
const snapshot = parseJsonObject(item.item_snapshot_json)
|
||||
const route = parseJsonObject(snapshot.fulfillmentRoute)
|
||||
return String(route.selectedExecutorKey || '').trim()
|
||||
}
|
||||
|
||||
function parseJsonObject(value: unknown): JsonObject {
|
||||
if (!value) {
|
||||
return {}
|
||||
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
resolveKuaishouFeifeiProductByName,
|
||||
type KuaishouFeifeiProductMatch,
|
||||
} from '../platforms/kuaishou-feifei/product-rule-service.js'
|
||||
import {
|
||||
resolveFulfillmentRoute,
|
||||
type FulfillmentRouteResult,
|
||||
} from './routing-config-service.js'
|
||||
import { FULFILLMENT_EXECUTOR_KEYS } from './executors/types.js'
|
||||
|
||||
export type FulfillmentItem = {
|
||||
itemId?: string
|
||||
@@ -39,6 +44,7 @@ type FulfillmentItemCandidate = {
|
||||
resolvedSkuCode: string
|
||||
cloudtentaclesNameMatch: CloudtentaclesNameMatchResult | null
|
||||
kuaishouFeifeiMatch: KuaishouFeifeiProductMatch | null
|
||||
fulfillmentRoute: FulfillmentRouteResult | null
|
||||
isConfigured: boolean
|
||||
}
|
||||
|
||||
@@ -70,17 +76,27 @@ export async function resolveOrderItemForFulfillment({
|
||||
externalSkuNameNormalized,
|
||||
cloudtentaclesNameMatch,
|
||||
kuaishouFeifeiMatch,
|
||||
fulfillmentRoute,
|
||||
} = candidate
|
||||
|
||||
const selectedExecutorKey = fulfillmentRoute?.selectedExecutorKey || ''
|
||||
const resolvedSkuCode = pickFirstNonEmpty([
|
||||
cloudtentaclesNameMatch?.cloudSkuName,
|
||||
kuaishouFeifeiMatch?.productCode,
|
||||
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD
|
||||
? cloudtentaclesNameMatch?.cloudSkuName
|
||||
: '',
|
||||
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI
|
||||
? kuaishouFeifeiMatch?.productCode
|
||||
: '',
|
||||
externalSkuCode,
|
||||
externalItemId,
|
||||
])
|
||||
const resolvedSkuName = pickFirstNonEmpty([
|
||||
cloudtentaclesNameMatch?.cloudSkuName,
|
||||
kuaishouFeifeiMatch?.skuName,
|
||||
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD
|
||||
? cloudtentaclesNameMatch?.cloudSkuName
|
||||
: '',
|
||||
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI
|
||||
? kuaishouFeifeiMatch?.skuName
|
||||
: '',
|
||||
item.skuName,
|
||||
externalSkuName,
|
||||
resolvedSkuCode,
|
||||
@@ -92,7 +108,13 @@ export async function resolveOrderItemForFulfillment({
|
||||
externalSkuName,
|
||||
externalSkuNameNormalized,
|
||||
resolvedSkuCode,
|
||||
matchMode: cloudtentaclesNameMatch?.matchMode || '',
|
||||
matchMode:
|
||||
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD
|
||||
? cloudtentaclesNameMatch?.matchMode || ''
|
||||
: selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI
|
||||
? kuaishouFeifeiMatch?.matchMode || ''
|
||||
: '',
|
||||
fulfillmentRoute,
|
||||
cloudtentacles: cloudtentaclesNameMatch
|
||||
? {
|
||||
matchMode: cloudtentaclesNameMatch.matchMode,
|
||||
@@ -187,12 +209,30 @@ async function resolveConfiguredItemCandidate({
|
||||
|
||||
if (isOpen91KuaishouOrder(provider, platform)) {
|
||||
const cloudtentaclesNameMatch = await resolveCloudtentaclesSkuByProductName(externalSkuName)
|
||||
const kuaishouFeifeiMatch = cloudtentaclesNameMatch
|
||||
? null
|
||||
: resolveKuaishouFeifeiProductByName(externalSkuName)
|
||||
const kuaishouFeifeiMatch = resolveKuaishouFeifeiProductByName(externalSkuName)
|
||||
const fulfillmentRoute = resolveFulfillmentRoute({
|
||||
productName: externalSkuName,
|
||||
candidates: [
|
||||
{
|
||||
executorKey: FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD,
|
||||
available: Boolean(cloudtentaclesNameMatch),
|
||||
reason: cloudtentaclesNameMatch ? '' : '未命中 cloudtentacles 商品或账号不可用',
|
||||
},
|
||||
{
|
||||
executorKey: FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI,
|
||||
available: Boolean(kuaishouFeifeiMatch),
|
||||
reason: kuaishouFeifeiMatch ? '' : '未命中 kuaishou-feifei 商品规则',
|
||||
},
|
||||
],
|
||||
})
|
||||
const selectedExecutorKey = fulfillmentRoute.selectedExecutorKey
|
||||
const resolvedSkuCode = pickFirstNonEmpty([
|
||||
cloudtentaclesNameMatch?.cloudSkuName,
|
||||
kuaishouFeifeiMatch?.productCode,
|
||||
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD
|
||||
? cloudtentaclesNameMatch?.cloudSkuName
|
||||
: '',
|
||||
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI
|
||||
? kuaishouFeifeiMatch?.productCode
|
||||
: '',
|
||||
externalSkuCode,
|
||||
externalItemId,
|
||||
])
|
||||
@@ -205,7 +245,8 @@ async function resolveConfiguredItemCandidate({
|
||||
resolvedSkuCode,
|
||||
cloudtentaclesNameMatch,
|
||||
kuaishouFeifeiMatch,
|
||||
isConfigured: Boolean(cloudtentaclesNameMatch || kuaishouFeifeiMatch),
|
||||
fulfillmentRoute,
|
||||
isConfigured: Boolean(selectedExecutorKey),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +263,7 @@ async function resolveConfiguredItemCandidate({
|
||||
resolvedSkuCode,
|
||||
cloudtentaclesNameMatch: null,
|
||||
kuaishouFeifeiMatch: null,
|
||||
fulfillmentRoute: null,
|
||||
isConfigured: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
normalizeFulfillmentRoutingConfig,
|
||||
resolveFulfillmentRoute,
|
||||
} from './routing-config-service.js'
|
||||
|
||||
test('resolveFulfillmentRoute 默认保持 kuaishou-lewan 优先', () => {
|
||||
const result = resolveFulfillmentRoute({
|
||||
productName: '测试商品',
|
||||
candidates: [
|
||||
{ executorKey: 'kuaishou_ct_assisted', available: true },
|
||||
{ executorKey: 'kuaishou_feifei', available: true },
|
||||
],
|
||||
config: normalizeFulfillmentRoutingConfig({}),
|
||||
})
|
||||
|
||||
assert.equal(result.selectedExecutorKey, 'kuaishou_ct_assisted')
|
||||
assert.equal(result.reason, '按全局优先级命中')
|
||||
})
|
||||
|
||||
test('resolveFulfillmentRoute 全局关闭 kuaishou-lewan 后选择 feifei', () => {
|
||||
const result = resolveFulfillmentRoute({
|
||||
productName: '测试商品',
|
||||
candidates: [
|
||||
{ executorKey: 'kuaishou_ct_assisted', available: true },
|
||||
{ executorKey: 'kuaishou_feifei', available: true },
|
||||
],
|
||||
config: normalizeFulfillmentRoutingConfig({
|
||||
executors: {
|
||||
kuaishou_ct_assisted: { enabled: false },
|
||||
kuaishou_feifei: { enabled: true },
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
assert.equal(result.selectedExecutorKey, 'kuaishou_feifei')
|
||||
assert.deepEqual(result.skipped[0], {
|
||||
executorKey: 'kuaishou_ct_assisted',
|
||||
reason: '履约通道已停用',
|
||||
})
|
||||
})
|
||||
|
||||
test('resolveFulfillmentRoute 商品规则可以强制走 feifei', () => {
|
||||
const result = resolveFulfillmentRoute({
|
||||
productName: '荣耀套装',
|
||||
candidates: [
|
||||
{ executorKey: 'kuaishou_ct_assisted', available: true },
|
||||
{ executorKey: 'kuaishou_feifei', available: true },
|
||||
],
|
||||
config: normalizeFulfillmentRoutingConfig({
|
||||
rules: [
|
||||
{
|
||||
enabled: true,
|
||||
productName: '荣耀套装',
|
||||
executorKey: 'kuaishou_feifei',
|
||||
priority: 200,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
assert.equal(result.selectedExecutorKey, 'kuaishou_feifei')
|
||||
assert.equal(result.selectedRuleId, '荣耀套装:kuaishou_feifei')
|
||||
assert.equal(result.reason, '命中商品路由规则')
|
||||
})
|
||||
|
||||
test('resolveFulfillmentRoute 商品规则可以强制转人工', () => {
|
||||
const result = resolveFulfillmentRoute({
|
||||
productName: '临时停用商品',
|
||||
candidates: [
|
||||
{ executorKey: 'kuaishou_ct_assisted', available: true },
|
||||
{ executorKey: 'kuaishou_feifei', available: false },
|
||||
],
|
||||
config: normalizeFulfillmentRoutingConfig({
|
||||
rules: [
|
||||
{
|
||||
enabled: true,
|
||||
productName: '临时停用',
|
||||
matchType: 'contains',
|
||||
executorKey: 'manual_dispatch',
|
||||
priority: 300,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
assert.equal(result.selectedExecutorKey, 'manual_dispatch')
|
||||
assert.equal(result.reason, '命中商品路由规则')
|
||||
})
|
||||
@@ -0,0 +1,451 @@
|
||||
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]'
|
||||
}
|
||||
@@ -107,7 +107,7 @@ export function notifyKuaishouCloudAssetNotEnough({
|
||||
const binding = toRecord(flowRecord.binding)
|
||||
|
||||
return notifyInternalSafely({
|
||||
title: '快手 Cloud 余额不足',
|
||||
title: 'kuaishou-lewan 余额不足',
|
||||
body: [
|
||||
formatTaskLine(taskRecord),
|
||||
`当前余额:${Number(assetBefore || 0)}`,
|
||||
@@ -162,8 +162,8 @@ export function notifyCloudtentaclesAssetLow({
|
||||
|
||||
return notifyInternalSafely({
|
||||
title: normalizedAccountLabel
|
||||
? `快手 Cloud 余额低于阈值:${normalizedAccountLabel}`
|
||||
: '快手 Cloud 余额低于阈值',
|
||||
? `kuaishou-lewan 余额低于阈值:${normalizedAccountLabel}`
|
||||
: 'kuaishou-lewan 余额低于阈值',
|
||||
body: [
|
||||
normalizedSourceKey ? `账号:${normalizedAccountLabel || normalizedSourceKey}` : '',
|
||||
`当前余额:${Number(asset || 0)}`,
|
||||
@@ -205,7 +205,7 @@ export function notifyKuaishouCloudBindUrlRefreshFailed({
|
||||
const taskRecord = toRecord(task)
|
||||
|
||||
return notifyInternalSafely({
|
||||
title: '快手 Cloud 链接刷新失败',
|
||||
title: 'kuaishou-lewan 链接刷新失败',
|
||||
body: [
|
||||
formatTaskLine(taskRecord),
|
||||
`原因:${String(errorMessage || taskRecord.last_error || '').trim() || '-'}`,
|
||||
|
||||
@@ -45,7 +45,7 @@ test('syncDeliveryTasksForOrder 多账号候选时不提前固定 cloudtentacles
|
||||
getFulfillmentProfileByKey: async () => ({
|
||||
id: 9,
|
||||
profile_key: 'kuaishou_ct_assisted',
|
||||
profile_name: '快手 cloud 履约',
|
||||
profile_name: 'kuaishou-lewan 履约',
|
||||
executor_key: 'kuaishou_ct_assisted',
|
||||
}),
|
||||
createTask: async (input: any) => {
|
||||
@@ -105,7 +105,7 @@ test('syncDeliveryTasksForOrder 单账号候选时保留固定 cloudtentacles
|
||||
getFulfillmentProfileByKey: async () => ({
|
||||
id: 9,
|
||||
profile_key: 'kuaishou_ct_assisted',
|
||||
profile_name: '快手 cloud 履约',
|
||||
profile_name: 'kuaishou-lewan 履约',
|
||||
executor_key: 'kuaishou_ct_assisted',
|
||||
}),
|
||||
createTask: async (input: any) => {
|
||||
|
||||
Reference in New Issue
Block a user