优化履约路由并重命名乐玩通道
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
快手轻量后端服务。
|
快手轻量后端服务。
|
||||||
|
|
||||||
后端负责订单入库、履约任务编排、快手 Cloud / 91 卡券 / 快手小店核销配置、后台管理接口和数据库迁移。默认入口就是快手轻量后端。
|
后端负责订单入库、履约任务编排、kuaishou-lewan / 91 卡券 / 快手小店核销配置、后台管理接口和数据库迁移。默认入口就是快手轻量后端。
|
||||||
|
|
||||||
## 快速启动
|
## 快速启动
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ npm test
|
|||||||
- `src/app.ts`:Express 应用组装
|
- `src/app.ts`:Express 应用组装
|
||||||
- `src/routes`:API 路由
|
- `src/routes`:API 路由
|
||||||
- `src/services/order`:订单、商品匹配和履约任务编排
|
- `src/services/order`:订单、商品匹配和履约任务编排
|
||||||
- `src/services/fulfillment`:快手 Cloud 履约编排
|
- `src/services/fulfillment`:kuaishou-lewan 履约编排
|
||||||
- `src/services/admin`:后台读写、权限、配置管理
|
- `src/services/admin`:后台读写、权限、配置管理
|
||||||
- `src/repositories`:数据库访问
|
- `src/repositories`:数据库访问
|
||||||
- `src/db/migrations`:新库初始化脚本
|
- `src/db/migrations`:新库初始化脚本
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ try {
|
|||||||
updated_at: now,
|
updated_at: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('已生成快手 Cloud 领取 mock 数据:')
|
console.log('已生成 kuaishou-lewan 领取 mock 数据:')
|
||||||
console.log(` 91订单号:${orderNo}`)
|
console.log(` 91订单号:${orderNo}`)
|
||||||
console.log(` 商品:${productName}`)
|
console.log(` 商品:${productName}`)
|
||||||
console.log(` 当前步骤:${step}`)
|
console.log(` 当前步骤:${step}`)
|
||||||
@@ -197,7 +197,7 @@ async function ensureKuaishouCloudProfile(timestamp: string) {
|
|||||||
|
|
||||||
const profile = await upsertFulfillmentProfile({
|
const profile = await upsertFulfillmentProfile({
|
||||||
profileKey: 'kuaishou_ct_assisted',
|
profileKey: 'kuaishou_ct_assisted',
|
||||||
name: '快手 cloud 履约',
|
name: 'kuaishou-lewan 履约',
|
||||||
executorKey: 'kuaishou_ct_assisted',
|
executorKey: 'kuaishou_ct_assisted',
|
||||||
requiresClaim: false,
|
requiresClaim: false,
|
||||||
autoDispatch: false,
|
autoDispatch: false,
|
||||||
@@ -243,7 +243,7 @@ function buildMockTaskContext({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
profileKey: 'kuaishou_ct_assisted',
|
profileKey: 'kuaishou_ct_assisted',
|
||||||
profileName: '快手 cloud 履约',
|
profileName: 'kuaishou-lewan 履约',
|
||||||
skuCode: productName,
|
skuCode: productName,
|
||||||
skuName: productName,
|
skuName: productName,
|
||||||
kuaishouCloudFulfillment: {
|
kuaishouCloudFulfillment: {
|
||||||
@@ -426,7 +426,7 @@ function buildFrontendClaimUrl(token: string, rawBaseUrl: unknown) {
|
|||||||
|
|
||||||
function printHelp() {
|
function printHelp() {
|
||||||
console.log(`
|
console.log(`
|
||||||
生成快手 Cloud 领取 mock 数据:
|
生成 kuaishou-lewan 领取 mock 数据:
|
||||||
|
|
||||||
npm run mock:claim
|
npm run mock:claim
|
||||||
npm run mock:claim -- --step=binding
|
npm run mock:claim -- --step=binding
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
shouldEnsureKuaishouCloudClaimLink,
|
shouldEnsureKuaishouCloudClaimLink,
|
||||||
} from './task-status.js'
|
} 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.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.WAITING_BINDING, TASK_STATUS.ROLE_CONFIRMED), true)
|
||||||
assert.equal(canTaskTransition(TASK_STATUS.ROLE_CONFIRMED, TASK_STATUS.REDEEMING), 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 kuaishouEticketRouter from "./platform-config/kuaishou-eticket.js";
|
||||||
import ninetyoneRouter from "./platform-config/ninetyone.js";
|
import ninetyoneRouter from "./platform-config/ninetyone.js";
|
||||||
import notificationsRouter from "./platform-config/notifications.js";
|
import notificationsRouter from "./platform-config/notifications.js";
|
||||||
|
import fulfillmentRoutingRouter from "./platform-config/fulfillment-routing.js";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ router.use("/platform-config", kuaishouIndustryRouter);
|
|||||||
router.use("/platform-config", kuaishouEticketRouter);
|
router.use("/platform-config", kuaishouEticketRouter);
|
||||||
router.use("/platform-config", kuaishouFeifeiRouter);
|
router.use("/platform-config", kuaishouFeifeiRouter);
|
||||||
router.use("/platform-config", ninetyoneRouter);
|
router.use("/platform-config", ninetyoneRouter);
|
||||||
|
router.use("/platform-config", fulfillmentRoutingRouter);
|
||||||
router.use("/platform-config", cloudtentaclesRouter);
|
router.use("/platform-config", cloudtentaclesRouter);
|
||||||
|
|
||||||
export default router;
|
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)) {
|
if (!isKuaishouCloudTask(task)) {
|
||||||
throw createHttpError('当前任务不是快手 cloud 履约任务', {
|
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
errorCode: 'admin_task_not_kuaishou_cloud',
|
errorCode: 'admin_task_not_kuaishou_cloud',
|
||||||
})
|
})
|
||||||
@@ -261,7 +261,7 @@ export async function dispatchAdminTaskKuaishouCloudFulfillment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isKuaishouCloudTask(task)) {
|
if (!isKuaishouCloudTask(task)) {
|
||||||
throw createHttpError('当前任务不是快手 cloud 履约任务', {
|
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
errorCode: 'admin_task_not_kuaishou_cloud',
|
errorCode: 'admin_task_not_kuaishou_cloud',
|
||||||
})
|
})
|
||||||
@@ -397,7 +397,7 @@ export async function refreshAdminTaskKuaishouCloudRoleInfo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isKuaishouCloudTask(task)) {
|
if (!isKuaishouCloudTask(task)) {
|
||||||
throw createHttpError('当前任务不是快手 cloud 履约任务', {
|
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
errorCode: 'admin_task_not_kuaishou_cloud',
|
errorCode: 'admin_task_not_kuaishou_cloud',
|
||||||
})
|
})
|
||||||
@@ -522,7 +522,7 @@ export async function returnNumberAdminTaskKuaishouCloudFulfillment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isKuaishouCloudTask(task)) {
|
if (!isKuaishouCloudTask(task)) {
|
||||||
throw createHttpError('当前任务不是快手 cloud 履约任务', {
|
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
errorCode: 'admin_task_not_kuaishou_cloud',
|
errorCode: 'admin_task_not_kuaishou_cloud',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const CORE_PROFILES: CoreProfile[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
profileKey: 'kuaishou_ct_assisted',
|
profileKey: 'kuaishou_ct_assisted',
|
||||||
name: '快手 cloud 履约',
|
name: 'kuaishou-lewan 履约',
|
||||||
executorKey: 'kuaishou_ct_assisted',
|
executorKey: 'kuaishou_ct_assisted',
|
||||||
requiresClaim: false,
|
requiresClaim: false,
|
||||||
autoDispatch: false,
|
autoDispatch: false,
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: Js
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (executorKey !== 'kuaishou_ct_assisted') {
|
if (executorKey !== 'kuaishou_ct_assisted') {
|
||||||
throw createHttpError('当前领取链接不是快手 Cloud 客户领取流程', {
|
throw createHttpError('当前领取链接不是 kuaishou-lewan 客户领取流程', {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
errorCode: 'claim_not_kuaishou_cloud',
|
errorCode: 'claim_not_kuaishou_cloud',
|
||||||
})
|
})
|
||||||
@@ -440,7 +440,7 @@ export async function rebindKuaishouCloudClaimRole(token: unknown) {
|
|||||||
const context = await getClaimContext(token)
|
const context = await getClaimContext(token)
|
||||||
|
|
||||||
if (String(context.task.executor_key || '').trim() !== 'kuaishou_ct_assisted') {
|
if (String(context.task.executor_key || '').trim() !== 'kuaishou_ct_assisted') {
|
||||||
throw createHttpError('当前领取链接不是快手 Cloud 客户领取流程', {
|
throw createHttpError('当前领取链接不是 kuaishou-lewan 客户领取流程', {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
errorCode: 'claim_not_kuaishou_cloud',
|
errorCode: 'claim_not_kuaishou_cloud',
|
||||||
})
|
})
|
||||||
@@ -633,7 +633,7 @@ export async function confirmKuaishouCloudClaimRole(token: unknown) {
|
|||||||
const now = nowIso()
|
const now = nowIso()
|
||||||
|
|
||||||
if (String(context.task.executor_key || '').trim() !== 'kuaishou_ct_assisted') {
|
if (String(context.task.executor_key || '').trim() !== 'kuaishou_ct_assisted') {
|
||||||
throw createHttpError('当前领取链接不是快手 Cloud 客户领取流程', {
|
throw createHttpError('当前领取链接不是 kuaishou-lewan 客户领取流程', {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
errorCode: 'claim_not_kuaishou_cloud',
|
errorCode: 'claim_not_kuaishou_cloud',
|
||||||
})
|
})
|
||||||
@@ -711,7 +711,7 @@ export async function redeemKuaishouCloudClaim(token: unknown) {
|
|||||||
const now = nowIso()
|
const now = nowIso()
|
||||||
|
|
||||||
if (String(context.task.executor_key || '').trim() !== 'kuaishou_ct_assisted') {
|
if (String(context.task.executor_key || '').trim() !== 'kuaishou_ct_assisted') {
|
||||||
throw createHttpError('当前领取链接不是快手 Cloud 客户领取流程', {
|
throw createHttpError('当前领取链接不是 kuaishou-lewan 客户领取流程', {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
errorCode: 'claim_not_kuaishou_cloud',
|
errorCode: 'claim_not_kuaishou_cloud',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ export async function ensureTaskClaimLink(task: TaskRow) {
|
|||||||
|
|
||||||
export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options: JsonObject = {}) {
|
export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options: JsonObject = {}) {
|
||||||
if (!isKuaishouCloudTask(task)) {
|
if (!isKuaishouCloudTask(task)) {
|
||||||
throw createHttpError('当前任务不是快手 Cloud 履约任务', {
|
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
errorCode: 'kuaishou_cloud_task_invalid',
|
errorCode: 'kuaishou_cloud_task_invalid',
|
||||||
})
|
})
|
||||||
@@ -411,7 +411,7 @@ export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options
|
|||||||
|
|
||||||
export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: JsonObject = {}) {
|
export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: JsonObject = {}) {
|
||||||
if (!isKuaishouCloudTask(task)) {
|
if (!isKuaishouCloudTask(task)) {
|
||||||
throw createHttpError('当前任务不是快手 Cloud 履约任务', {
|
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
errorCode: 'kuaishou_cloud_task_invalid',
|
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 = {}) {
|
export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonObject = {}) {
|
||||||
if (!isKuaishouCloudTask(task)) {
|
if (!isKuaishouCloudTask(task)) {
|
||||||
throw createHttpError('当前任务不是快手 Cloud 履约任务', {
|
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
errorCode: 'kuaishou_cloud_task_invalid',
|
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 = {}) {
|
export async function probeKuaishouCloudTaskBindUrl(task: TaskRow, options: JsonObject = {}) {
|
||||||
if (!isKuaishouCloudTask(task)) {
|
if (!isKuaishouCloudTask(task)) {
|
||||||
throw createHttpError('当前任务不是快手 Cloud 履约任务', {
|
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
errorCode: 'kuaishou_cloud_task_invalid',
|
errorCode: 'kuaishou_cloud_task_invalid',
|
||||||
})
|
})
|
||||||
@@ -1008,7 +1008,7 @@ async function markKuaishouCloudBindUrlRefreshFailed(
|
|||||||
|
|
||||||
export async function refreshKuaishouCloudTaskRoleInfo(task: TaskRow, options: JsonObject = {}) {
|
export async function refreshKuaishouCloudTaskRoleInfo(task: TaskRow, options: JsonObject = {}) {
|
||||||
if (!isKuaishouCloudTask(task)) {
|
if (!isKuaishouCloudTask(task)) {
|
||||||
throw createHttpError('当前任务不是快手 Cloud 履约任务', {
|
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
errorCode: 'kuaishou_cloud_task_invalid',
|
errorCode: 'kuaishou_cloud_task_invalid',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export async function dispatchKuaishouCloudFulfillmentTask(
|
|||||||
options: JsonObject = {}
|
options: JsonObject = {}
|
||||||
) {
|
) {
|
||||||
if (!isKuaishouCloudTask(task)) {
|
if (!isKuaishouCloudTask(task)) {
|
||||||
throw createHttpError("当前任务不是快手 Cloud 履约任务", {
|
throw createHttpError("当前任务不是 kuaishou-lewan 履约任务", {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
errorCode: "kuaishou_cloud_task_invalid",
|
errorCode: "kuaishou_cloud_task_invalid",
|
||||||
});
|
});
|
||||||
@@ -211,7 +211,7 @@ export async function dispatchKuaishouCloudFulfillmentTask(
|
|||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
if (!updatedTask) {
|
if (!updatedTask) {
|
||||||
throw createHttpError("快手 Cloud 发货状态更新失败", {
|
throw createHttpError("kuaishou-lewan 发货状态更新失败", {
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
errorCode: "kuaishou_cloud_dispatch_update_failed",
|
errorCode: "kuaishou_cloud_dispatch_update_failed",
|
||||||
});
|
});
|
||||||
@@ -749,7 +749,7 @@ export async function returnKuaishouCloudFulfillmentTask(
|
|||||||
options: JsonObject = {}
|
options: JsonObject = {}
|
||||||
) {
|
) {
|
||||||
if (!isKuaishouCloudTask(task)) {
|
if (!isKuaishouCloudTask(task)) {
|
||||||
throw createHttpError("当前任务不是快手 Cloud 履约任务", {
|
throw createHttpError("当前任务不是 kuaishou-lewan 履约任务", {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
errorCode: "kuaishou_cloud_task_invalid",
|
errorCode: "kuaishou_cloud_task_invalid",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import test from 'node:test'
|
|||||||
import { planFulfillmentTaskForOrderItem } from './planner.js'
|
import { planFulfillmentTaskForOrderItem } from './planner.js'
|
||||||
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.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({
|
const plan = await planFulfillmentTaskForOrderItem({
|
||||||
order: createOrder(),
|
order: createOrder(),
|
||||||
item: createOrderItem({
|
item: createOrderItem({
|
||||||
@@ -23,7 +23,7 @@ test('planFulfillmentTaskForOrderItem 为 cloudtentacles 商品生成 cloud 履
|
|||||||
getProfileByKey: async (profileKey) => ({
|
getProfileByKey: async (profileKey) => ({
|
||||||
id: 9,
|
id: 9,
|
||||||
profile_key: profileKey,
|
profile_key: profileKey,
|
||||||
name: '快手 cloud 履约',
|
name: 'kuaishou-lewan 履约',
|
||||||
executor_key: profileKey,
|
executor_key: profileKey,
|
||||||
requires_claim: true,
|
requires_claim: true,
|
||||||
auto_dispatch: true,
|
auto_dispatch: true,
|
||||||
@@ -76,6 +76,74 @@ test('planFulfillmentTaskForOrderItem 为 feifei 商品生成 feifei 履约计
|
|||||||
assert.equal((plan.context.kuaishouFeifei as any).productName, '测试皮肤')
|
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 () => {
|
test('planFulfillmentTaskForOrderItem 未命中平台配置时返回 null', async () => {
|
||||||
const plan = await planFulfillmentTaskForOrderItem({
|
const plan = await planFulfillmentTaskForOrderItem({
|
||||||
order: createOrder(),
|
order: createOrder(),
|
||||||
|
|||||||
@@ -231,6 +231,21 @@ async function resolveDynamicFulfillmentProfile(
|
|||||||
item: OrderItemRow,
|
item: OrderItemRow,
|
||||||
getProfileByKey: FulfillmentProfileResolver,
|
getProfileByKey: FulfillmentProfileResolver,
|
||||||
): Promise<FulfillmentBindingLike | null> {
|
): 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 (
|
return (
|
||||||
await resolveDynamicCloudtentaclesProfile(item, getProfileByKey) ||
|
await resolveDynamicCloudtentaclesProfile(item, getProfileByKey) ||
|
||||||
await resolveDynamicKuaishouFeifeiProfile(item, getProfileByKey)
|
await resolveDynamicKuaishouFeifeiProfile(item, getProfileByKey)
|
||||||
@@ -272,7 +287,7 @@ async function resolveDynamicCloudtentaclesProfile(
|
|||||||
...profile,
|
...profile,
|
||||||
profile_id: Number(profile.id || profile.profile_id || 0),
|
profile_id: Number(profile.id || profile.profile_id || 0),
|
||||||
profile_key: FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD,
|
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,
|
executor_key: FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD,
|
||||||
requires_claim: false,
|
requires_claim: false,
|
||||||
auto_dispatch: 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 {
|
function parseJsonObject(value: unknown): JsonObject {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import {
|
|||||||
resolveKuaishouFeifeiProductByName,
|
resolveKuaishouFeifeiProductByName,
|
||||||
type KuaishouFeifeiProductMatch,
|
type KuaishouFeifeiProductMatch,
|
||||||
} from '../platforms/kuaishou-feifei/product-rule-service.js'
|
} 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 = {
|
export type FulfillmentItem = {
|
||||||
itemId?: string
|
itemId?: string
|
||||||
@@ -39,6 +44,7 @@ type FulfillmentItemCandidate = {
|
|||||||
resolvedSkuCode: string
|
resolvedSkuCode: string
|
||||||
cloudtentaclesNameMatch: CloudtentaclesNameMatchResult | null
|
cloudtentaclesNameMatch: CloudtentaclesNameMatchResult | null
|
||||||
kuaishouFeifeiMatch: KuaishouFeifeiProductMatch | null
|
kuaishouFeifeiMatch: KuaishouFeifeiProductMatch | null
|
||||||
|
fulfillmentRoute: FulfillmentRouteResult | null
|
||||||
isConfigured: boolean
|
isConfigured: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,17 +76,27 @@ export async function resolveOrderItemForFulfillment({
|
|||||||
externalSkuNameNormalized,
|
externalSkuNameNormalized,
|
||||||
cloudtentaclesNameMatch,
|
cloudtentaclesNameMatch,
|
||||||
kuaishouFeifeiMatch,
|
kuaishouFeifeiMatch,
|
||||||
|
fulfillmentRoute,
|
||||||
} = candidate
|
} = candidate
|
||||||
|
|
||||||
|
const selectedExecutorKey = fulfillmentRoute?.selectedExecutorKey || ''
|
||||||
const resolvedSkuCode = pickFirstNonEmpty([
|
const resolvedSkuCode = pickFirstNonEmpty([
|
||||||
cloudtentaclesNameMatch?.cloudSkuName,
|
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD
|
||||||
kuaishouFeifeiMatch?.productCode,
|
? cloudtentaclesNameMatch?.cloudSkuName
|
||||||
|
: '',
|
||||||
|
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI
|
||||||
|
? kuaishouFeifeiMatch?.productCode
|
||||||
|
: '',
|
||||||
externalSkuCode,
|
externalSkuCode,
|
||||||
externalItemId,
|
externalItemId,
|
||||||
])
|
])
|
||||||
const resolvedSkuName = pickFirstNonEmpty([
|
const resolvedSkuName = pickFirstNonEmpty([
|
||||||
cloudtentaclesNameMatch?.cloudSkuName,
|
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD
|
||||||
kuaishouFeifeiMatch?.skuName,
|
? cloudtentaclesNameMatch?.cloudSkuName
|
||||||
|
: '',
|
||||||
|
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI
|
||||||
|
? kuaishouFeifeiMatch?.skuName
|
||||||
|
: '',
|
||||||
item.skuName,
|
item.skuName,
|
||||||
externalSkuName,
|
externalSkuName,
|
||||||
resolvedSkuCode,
|
resolvedSkuCode,
|
||||||
@@ -92,7 +108,13 @@ export async function resolveOrderItemForFulfillment({
|
|||||||
externalSkuName,
|
externalSkuName,
|
||||||
externalSkuNameNormalized,
|
externalSkuNameNormalized,
|
||||||
resolvedSkuCode,
|
resolvedSkuCode,
|
||||||
matchMode: cloudtentaclesNameMatch?.matchMode || '',
|
matchMode:
|
||||||
|
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD
|
||||||
|
? cloudtentaclesNameMatch?.matchMode || ''
|
||||||
|
: selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI
|
||||||
|
? kuaishouFeifeiMatch?.matchMode || ''
|
||||||
|
: '',
|
||||||
|
fulfillmentRoute,
|
||||||
cloudtentacles: cloudtentaclesNameMatch
|
cloudtentacles: cloudtentaclesNameMatch
|
||||||
? {
|
? {
|
||||||
matchMode: cloudtentaclesNameMatch.matchMode,
|
matchMode: cloudtentaclesNameMatch.matchMode,
|
||||||
@@ -187,12 +209,30 @@ async function resolveConfiguredItemCandidate({
|
|||||||
|
|
||||||
if (isOpen91KuaishouOrder(provider, platform)) {
|
if (isOpen91KuaishouOrder(provider, platform)) {
|
||||||
const cloudtentaclesNameMatch = await resolveCloudtentaclesSkuByProductName(externalSkuName)
|
const cloudtentaclesNameMatch = await resolveCloudtentaclesSkuByProductName(externalSkuName)
|
||||||
const kuaishouFeifeiMatch = cloudtentaclesNameMatch
|
const kuaishouFeifeiMatch = resolveKuaishouFeifeiProductByName(externalSkuName)
|
||||||
? null
|
const fulfillmentRoute = resolveFulfillmentRoute({
|
||||||
: resolveKuaishouFeifeiProductByName(externalSkuName)
|
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([
|
const resolvedSkuCode = pickFirstNonEmpty([
|
||||||
cloudtentaclesNameMatch?.cloudSkuName,
|
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD
|
||||||
kuaishouFeifeiMatch?.productCode,
|
? cloudtentaclesNameMatch?.cloudSkuName
|
||||||
|
: '',
|
||||||
|
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI
|
||||||
|
? kuaishouFeifeiMatch?.productCode
|
||||||
|
: '',
|
||||||
externalSkuCode,
|
externalSkuCode,
|
||||||
externalItemId,
|
externalItemId,
|
||||||
])
|
])
|
||||||
@@ -205,7 +245,8 @@ async function resolveConfiguredItemCandidate({
|
|||||||
resolvedSkuCode,
|
resolvedSkuCode,
|
||||||
cloudtentaclesNameMatch,
|
cloudtentaclesNameMatch,
|
||||||
kuaishouFeifeiMatch,
|
kuaishouFeifeiMatch,
|
||||||
isConfigured: Boolean(cloudtentaclesNameMatch || kuaishouFeifeiMatch),
|
fulfillmentRoute,
|
||||||
|
isConfigured: Boolean(selectedExecutorKey),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +263,7 @@ async function resolveConfiguredItemCandidate({
|
|||||||
resolvedSkuCode,
|
resolvedSkuCode,
|
||||||
cloudtentaclesNameMatch: null,
|
cloudtentaclesNameMatch: null,
|
||||||
kuaishouFeifeiMatch: null,
|
kuaishouFeifeiMatch: null,
|
||||||
|
fulfillmentRoute: null,
|
||||||
isConfigured: false,
|
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)
|
const binding = toRecord(flowRecord.binding)
|
||||||
|
|
||||||
return notifyInternalSafely({
|
return notifyInternalSafely({
|
||||||
title: '快手 Cloud 余额不足',
|
title: 'kuaishou-lewan 余额不足',
|
||||||
body: [
|
body: [
|
||||||
formatTaskLine(taskRecord),
|
formatTaskLine(taskRecord),
|
||||||
`当前余额:${Number(assetBefore || 0)}`,
|
`当前余额:${Number(assetBefore || 0)}`,
|
||||||
@@ -162,8 +162,8 @@ export function notifyCloudtentaclesAssetLow({
|
|||||||
|
|
||||||
return notifyInternalSafely({
|
return notifyInternalSafely({
|
||||||
title: normalizedAccountLabel
|
title: normalizedAccountLabel
|
||||||
? `快手 Cloud 余额低于阈值:${normalizedAccountLabel}`
|
? `kuaishou-lewan 余额低于阈值:${normalizedAccountLabel}`
|
||||||
: '快手 Cloud 余额低于阈值',
|
: 'kuaishou-lewan 余额低于阈值',
|
||||||
body: [
|
body: [
|
||||||
normalizedSourceKey ? `账号:${normalizedAccountLabel || normalizedSourceKey}` : '',
|
normalizedSourceKey ? `账号:${normalizedAccountLabel || normalizedSourceKey}` : '',
|
||||||
`当前余额:${Number(asset || 0)}`,
|
`当前余额:${Number(asset || 0)}`,
|
||||||
@@ -205,7 +205,7 @@ export function notifyKuaishouCloudBindUrlRefreshFailed({
|
|||||||
const taskRecord = toRecord(task)
|
const taskRecord = toRecord(task)
|
||||||
|
|
||||||
return notifyInternalSafely({
|
return notifyInternalSafely({
|
||||||
title: '快手 Cloud 链接刷新失败',
|
title: 'kuaishou-lewan 链接刷新失败',
|
||||||
body: [
|
body: [
|
||||||
formatTaskLine(taskRecord),
|
formatTaskLine(taskRecord),
|
||||||
`原因:${String(errorMessage || taskRecord.last_error || '').trim() || '-'}`,
|
`原因:${String(errorMessage || taskRecord.last_error || '').trim() || '-'}`,
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ test('syncDeliveryTasksForOrder 多账号候选时不提前固定 cloudtentacles
|
|||||||
getFulfillmentProfileByKey: async () => ({
|
getFulfillmentProfileByKey: async () => ({
|
||||||
id: 9,
|
id: 9,
|
||||||
profile_key: 'kuaishou_ct_assisted',
|
profile_key: 'kuaishou_ct_assisted',
|
||||||
profile_name: '快手 cloud 履约',
|
profile_name: 'kuaishou-lewan 履约',
|
||||||
executor_key: 'kuaishou_ct_assisted',
|
executor_key: 'kuaishou_ct_assisted',
|
||||||
}),
|
}),
|
||||||
createTask: async (input: any) => {
|
createTask: async (input: any) => {
|
||||||
@@ -105,7 +105,7 @@ test('syncDeliveryTasksForOrder 单账号候选时保留固定 cloudtentacles
|
|||||||
getFulfillmentProfileByKey: async () => ({
|
getFulfillmentProfileByKey: async () => ({
|
||||||
id: 9,
|
id: 9,
|
||||||
profile_key: 'kuaishou_ct_assisted',
|
profile_key: 'kuaishou_ct_assisted',
|
||||||
profile_name: '快手 cloud 履约',
|
profile_name: 'kuaishou-lewan 履约',
|
||||||
executor_key: 'kuaishou_ct_assisted',
|
executor_key: 'kuaishou_ct_assisted',
|
||||||
}),
|
}),
|
||||||
createTask: async (input: any) => {
|
createTask: async (input: any) => {
|
||||||
|
|||||||
@@ -411,7 +411,7 @@ export default function AdminTaskDetailPage() {
|
|||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
key: 'cloud',
|
key: 'cloud',
|
||||||
label: '快手 Cloud',
|
label: 'kuaishou-lewan',
|
||||||
children: flow ? (
|
children: flow ? (
|
||||||
<KuaishouCloudPanel
|
<KuaishouCloudPanel
|
||||||
flow={flow}
|
flow={flow}
|
||||||
@@ -427,7 +427,7 @@ export default function AdminTaskDetailPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Empty description="无快手 Cloud 履约上下文" />
|
<Empty description="无 kuaishou-lewan 履约上下文" />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -552,7 +552,7 @@ function TaskActionPanel({
|
|||||||
|
|
||||||
{hasCloudActions ? (
|
{hasCloudActions ? (
|
||||||
<section className="task-action-group">
|
<section className="task-action-group">
|
||||||
<Typography.Text type="secondary">快手 Cloud</Typography.Text>
|
<Typography.Text type="secondary">kuaishou-lewan</Typography.Text>
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
{canManageTaskLifecycle && operations.canPrepareKuaishouCloudFulfillment ? (
|
{canManageTaskLifecycle && operations.canPrepareKuaishouCloudFulfillment ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -871,7 +871,7 @@ function resolveDeliveryStepSummary(detail: AdminTaskDetail) {
|
|||||||
function resolveTaskExecutorDisplay(value: unknown) {
|
function resolveTaskExecutorDisplay(value: unknown) {
|
||||||
const executorKey = String(value || '').trim()
|
const executorKey = String(value || '').trim()
|
||||||
if (executorKey === 'kuaishou_ct_assisted') {
|
if (executorKey === 'kuaishou_ct_assisted') {
|
||||||
return { label: '快手 Cloud', color: 'blue' }
|
return { label: 'kuaishou-lewan', color: 'blue' }
|
||||||
}
|
}
|
||||||
if (executorKey === 'kuaishou-industry') {
|
if (executorKey === 'kuaishou-industry') {
|
||||||
return { label: '行业电子凭证', color: 'cyan' }
|
return { label: '行业电子凭证', color: 'cyan' }
|
||||||
@@ -1129,7 +1129,7 @@ function KuaishouCloudPanel({
|
|||||||
return (
|
return (
|
||||||
<section className="kuaishou-cloud-stack">
|
<section className="kuaishou-cloud-stack">
|
||||||
<Card
|
<Card
|
||||||
title="快手 Cloud 履约"
|
title="kuaishou-lewan 履约"
|
||||||
extra={
|
extra={
|
||||||
<Button
|
<Button
|
||||||
icon={<ReloadOutlined />}
|
icon={<ReloadOutlined />}
|
||||||
|
|||||||
@@ -424,7 +424,7 @@ function canRebindListTask(item: AdminTaskListItem, canManageTaskLifecycle: bool
|
|||||||
function resolveTaskExecutorDisplay(value: unknown) {
|
function resolveTaskExecutorDisplay(value: unknown) {
|
||||||
const executorKey = String(value || '').trim()
|
const executorKey = String(value || '').trim()
|
||||||
if (executorKey === 'kuaishou_ct_assisted') {
|
if (executorKey === 'kuaishou_ct_assisted') {
|
||||||
return { label: '快手 Cloud', color: 'blue' }
|
return { label: 'kuaishou-lewan', color: 'blue' }
|
||||||
}
|
}
|
||||||
if (executorKey === 'kuaishou-industry') {
|
if (executorKey === 'kuaishou-industry') {
|
||||||
return { label: '行业电子凭证', color: 'cyan' }
|
return { label: '行业电子凭证', color: 'cyan' }
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
|
ArrowDownOutlined,
|
||||||
|
ArrowUpOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
@@ -30,9 +32,12 @@ import {
|
|||||||
fetchAdminCloudtentaclesOverrideRules,
|
fetchAdminCloudtentaclesOverrideRules,
|
||||||
fetchAdminCloudtentaclesSkuList,
|
fetchAdminCloudtentaclesSkuList,
|
||||||
fetchAdminCloudtentaclesSourceConfig,
|
fetchAdminCloudtentaclesSourceConfig,
|
||||||
|
fetchAdminFulfillmentRoutingConfig,
|
||||||
fetchAdminKuaishouFeifeiConfig,
|
fetchAdminKuaishouFeifeiConfig,
|
||||||
matchAdminKuaishouFeifeiProduct,
|
matchAdminKuaishouFeifeiProduct,
|
||||||
|
previewAdminFulfillmentRouting,
|
||||||
saveAdminCloudtentaclesOverrideRules,
|
saveAdminCloudtentaclesOverrideRules,
|
||||||
|
saveAdminFulfillmentRoutingConfig,
|
||||||
syncAdminKuaishouFeifeiProducts,
|
syncAdminKuaishouFeifeiProducts,
|
||||||
} from '@/services/admin'
|
} from '@/services/admin'
|
||||||
import type {
|
import type {
|
||||||
@@ -42,6 +47,9 @@ import type {
|
|||||||
AdminCloudtentaclesSessionsMap,
|
AdminCloudtentaclesSessionsMap,
|
||||||
AdminCloudtentaclesSkuItem,
|
AdminCloudtentaclesSkuItem,
|
||||||
AdminCloudtentaclesSourceItem,
|
AdminCloudtentaclesSourceItem,
|
||||||
|
AdminFulfillmentRoutingConfig,
|
||||||
|
AdminFulfillmentRoutingPreviewResult,
|
||||||
|
AdminFulfillmentRoutingRule,
|
||||||
AdminKuaishouFeifeiConfigResponse,
|
AdminKuaishouFeifeiConfigResponse,
|
||||||
AdminKuaishouFeifeiMatchResult,
|
AdminKuaishouFeifeiMatchResult,
|
||||||
AdminKuaishouFeifeiProductRule,
|
AdminKuaishouFeifeiProductRule,
|
||||||
@@ -51,18 +59,27 @@ import { hasAdminRole } from '@/utils/admin-auth'
|
|||||||
type EditableOverrideRule = Omit<AdminCloudtentaclesOverrideRule, 'normalizedProductName'> & {
|
type EditableOverrideRule = Omit<AdminCloudtentaclesOverrideRule, 'normalizedProductName'> & {
|
||||||
normalizedProductName?: string
|
normalizedProductName?: string
|
||||||
}
|
}
|
||||||
|
type EditableRoutingRule = Omit<AdminFulfillmentRoutingRule, 'normalizedProductName'> & {
|
||||||
|
normalizedProductName?: string
|
||||||
|
}
|
||||||
|
|
||||||
type SourceRow = AdminCloudtentaclesSourceItem & {
|
type SourceRow = AdminCloudtentaclesSourceItem & {
|
||||||
session: AdminCloudtentaclesSessionItem | null
|
session: AdminCloudtentaclesSessionItem | null
|
||||||
ready: boolean
|
ready: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ROUTING_EXECUTORS = [
|
||||||
|
{ key: 'kuaishou_ct_assisted', label: 'kuaishou-lewan', color: 'blue' },
|
||||||
|
{ key: 'kuaishou_feifei', label: 'kuaishou-feifei', color: 'purple' },
|
||||||
|
{ key: 'manual_dispatch', label: '人工履约', color: 'orange' },
|
||||||
|
]
|
||||||
|
|
||||||
export default function AdminPlatformFulfillmentPage() {
|
export default function AdminPlatformFulfillmentPage() {
|
||||||
return (
|
return (
|
||||||
<section className="page-stack">
|
<section className="page-stack">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="履约配置"
|
title="履约配置"
|
||||||
description="维护 91 卡券商品到 kuaishou-feifei 与 cloudtentacles 的履约映射规则。"
|
description="维护 91 卡券商品到 kuaishou-feifei 与 kuaishou-lewan 的履约映射规则。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!hasAdminRole('admin') ? (
|
{!hasAdminRole('admin') ? (
|
||||||
@@ -74,6 +91,11 @@ export default function AdminPlatformFulfillmentPage() {
|
|||||||
className="platform-tabs"
|
className="platform-tabs"
|
||||||
destroyOnHidden={false}
|
destroyOnHidden={false}
|
||||||
items={[
|
items={[
|
||||||
|
{
|
||||||
|
key: 'fulfillment-routing',
|
||||||
|
label: '履约路由',
|
||||||
|
children: <FulfillmentRoutingPanel />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'kuaishou-feifei',
|
key: 'kuaishou-feifei',
|
||||||
label: 'kuaishou-feifei',
|
label: 'kuaishou-feifei',
|
||||||
@@ -81,7 +103,7 @@ export default function AdminPlatformFulfillmentPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'kuaishou-cloud',
|
key: 'kuaishou-cloud',
|
||||||
label: 'kuaishou-cloud',
|
label: 'kuaishou-lewan',
|
||||||
children: <KuaishouCloudFulfillmentPanel />,
|
children: <KuaishouCloudFulfillmentPanel />,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
@@ -91,6 +113,384 @@ export default function AdminPlatformFulfillmentPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FulfillmentRoutingPanel() {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [previewing, setPreviewing] = useState(false)
|
||||||
|
const [errorMessage, setErrorMessage] = useState('')
|
||||||
|
const [filePath, setFilePath] = useState('')
|
||||||
|
const [enabled, setEnabled] = useState(true)
|
||||||
|
const [priority, setPriority] = useState<string[]>(['kuaishou_ct_assisted', 'kuaishou_feifei'])
|
||||||
|
const [unmatchedExecutorKey, setUnmatchedExecutorKey] = useState('')
|
||||||
|
const [executors, setExecutors] = useState<AdminFulfillmentRoutingConfig['executors']>({})
|
||||||
|
const [rules, setRules] = useState<EditableRoutingRule[]>([])
|
||||||
|
const [previewInput, setPreviewInput] = useState('')
|
||||||
|
const [previewResult, setPreviewResult] = useState<AdminFulfillmentRoutingPreviewResult | null>(null)
|
||||||
|
|
||||||
|
const enabledRuleCount = rules.filter((rule) => rule.enabled !== false).length
|
||||||
|
const selectedExecutorKey = previewResult?.fulfillmentRoute?.selectedExecutorKey || ''
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadConfig()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
setLoading(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchAdminFulfillmentRoutingConfig()
|
||||||
|
hydrateConfig(response.data)
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error instanceof Error ? error.message : '读取履约路由配置失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfig() {
|
||||||
|
setSaving(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await saveAdminFulfillmentRoutingConfig({
|
||||||
|
enabled,
|
||||||
|
defaultExecutorPriority: priority,
|
||||||
|
unmatchedExecutorKey,
|
||||||
|
executors,
|
||||||
|
rules: rules.map((rule) => ({
|
||||||
|
id: rule.id,
|
||||||
|
enabled: rule.enabled !== false,
|
||||||
|
productName: rule.productName,
|
||||||
|
normalizedProductName: rule.normalizedProductName || '',
|
||||||
|
matchType: rule.matchType,
|
||||||
|
executorKey: rule.executorKey,
|
||||||
|
priority: rule.priority,
|
||||||
|
notes: rule.notes,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
hydrateConfig(response.data)
|
||||||
|
showSuccess('履约路由配置已保存')
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '保存履约路由配置失败'
|
||||||
|
setErrorMessage(message)
|
||||||
|
showError(message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function previewRoute() {
|
||||||
|
const productName = previewInput.trim()
|
||||||
|
if (!productName) {
|
||||||
|
showError('请输入 91 商品名')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreviewing(true)
|
||||||
|
setPreviewResult(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await previewAdminFulfillmentRouting(productName)
|
||||||
|
setPreviewResult(response.data)
|
||||||
|
} catch (error) {
|
||||||
|
showError(error instanceof Error ? error.message : '预览履约路由失败')
|
||||||
|
} finally {
|
||||||
|
setPreviewing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrateConfig(data: AdminFulfillmentRoutingConfig) {
|
||||||
|
setFilePath(data.filePath || '')
|
||||||
|
setEnabled(data.enabled !== false)
|
||||||
|
setPriority(normalizeRoutingPriority(data.defaultExecutorPriority))
|
||||||
|
setUnmatchedExecutorKey(data.unmatchedExecutorKey || '')
|
||||||
|
setExecutors(normalizeRoutingExecutors(data.executors))
|
||||||
|
setRules(Array.isArray(data.rules) ? data.rules.map((rule) => normalizeRoutingRule(rule)) : [])
|
||||||
|
}
|
||||||
|
|
||||||
|
function movePriority(executorKey: string, offset: -1 | 1) {
|
||||||
|
const currentIndex = priority.indexOf(executorKey)
|
||||||
|
const nextIndex = currentIndex + offset
|
||||||
|
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= priority.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = [...priority]
|
||||||
|
const [item] = next.splice(currentIndex, 1)
|
||||||
|
next.splice(nextIndex, 0, item)
|
||||||
|
setPriority(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateExecutorEnabled(executorKey: string, nextEnabled: boolean) {
|
||||||
|
setExecutors({
|
||||||
|
...executors,
|
||||||
|
[executorKey]: {
|
||||||
|
enabled: nextEnabled,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRule(index: number, patch: Partial<EditableRoutingRule>) {
|
||||||
|
setRules((current) =>
|
||||||
|
current.map((rule, ruleIndex) =>
|
||||||
|
ruleIndex === index
|
||||||
|
? {
|
||||||
|
...rule,
|
||||||
|
...patch,
|
||||||
|
}
|
||||||
|
: rule,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRule() {
|
||||||
|
const productName = previewInput.trim()
|
||||||
|
const nextId = `${Date.now()}-${rules.length + 1}`
|
||||||
|
setRules([
|
||||||
|
{
|
||||||
|
id: nextId,
|
||||||
|
enabled: true,
|
||||||
|
productName,
|
||||||
|
normalizedProductName: '',
|
||||||
|
matchType: 'exact',
|
||||||
|
executorKey: 'kuaishou_feifei',
|
||||||
|
priority: 100,
|
||||||
|
notes: '',
|
||||||
|
},
|
||||||
|
...rules,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRule(index: number) {
|
||||||
|
setRules((current) => current.filter((_, ruleIndex) => ruleIndex !== index))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Spin />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="platform-panel-stack">
|
||||||
|
{errorMessage ? <Alert type="error" showIcon message={errorMessage} /> : null}
|
||||||
|
|
||||||
|
<div className="metric-grid four">
|
||||||
|
<MetricCard label="路由状态" value={enabled ? '启用' : '停用'} detail={filePath || '默认配置'} />
|
||||||
|
<MetricCard label="商品规则" value={String(rules.length)} detail={`启用 ${enabledRuleCount} 条`} />
|
||||||
|
<MetricCard
|
||||||
|
label="全局优先级"
|
||||||
|
value={`${priority.length} 个通道`}
|
||||||
|
detail={priority.map(formatRoutingExecutorLabel).join(' > ') || '未配置'}
|
||||||
|
/>
|
||||||
|
<Card>
|
||||||
|
<Space wrap>
|
||||||
|
<Button icon={<ReloadOutlined />} loading={loading} onClick={loadConfig}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={saveConfig}>
|
||||||
|
保存配置
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card title="通道开关">
|
||||||
|
<div className="platform-routing-grid">
|
||||||
|
<div className="field-switch">
|
||||||
|
<span>履约路由</span>
|
||||||
|
<Switch checked={enabled} checkedChildren="启用" unCheckedChildren="停用" onChange={setEnabled} />
|
||||||
|
</div>
|
||||||
|
{ROUTING_EXECUTORS.map((executor) => (
|
||||||
|
<div key={executor.key} className="field-switch">
|
||||||
|
<span>{executor.label}</span>
|
||||||
|
<Switch
|
||||||
|
checked={executors[executor.key]?.enabled !== false}
|
||||||
|
checkedChildren="启用"
|
||||||
|
unCheckedChildren="停用"
|
||||||
|
onChange={(checked) => updateExecutorEnabled(executor.key, checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="全局优先级">
|
||||||
|
<Space direction="vertical" className="full-width" size={10}>
|
||||||
|
{priority.map((executorKey, index) => (
|
||||||
|
<div key={executorKey} className="platform-routing-priority-row">
|
||||||
|
<Space>
|
||||||
|
<Tag color={resolveRoutingExecutorColor(executorKey)}>
|
||||||
|
{index + 1}. {formatRoutingExecutorLabel(executorKey)}
|
||||||
|
</Tag>
|
||||||
|
</Space>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<ArrowUpOutlined />}
|
||||||
|
disabled={index === 0}
|
||||||
|
onClick={() => movePriority(executorKey, -1)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<ArrowDownOutlined />}
|
||||||
|
disabled={index === priority.length - 1}
|
||||||
|
onClick={() => movePriority(executorKey, 1)}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Select
|
||||||
|
value={unmatchedExecutorKey}
|
||||||
|
options={[
|
||||||
|
{ label: '未命中时保持未配置', value: '' },
|
||||||
|
{ label: '未命中时转人工履约', value: 'manual_dispatch' },
|
||||||
|
]}
|
||||||
|
onChange={(value) => setUnmatchedExecutorKey(String(value || ''))}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title="路由预览"
|
||||||
|
extra={
|
||||||
|
<Button icon={<PlusOutlined />} onClick={addRule}>
|
||||||
|
新增规则
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Space.Compact className="full-width">
|
||||||
|
<Input
|
||||||
|
allowClear
|
||||||
|
value={previewInput}
|
||||||
|
placeholder="91 商品名 / productNo"
|
||||||
|
onChange={(event) => setPreviewInput(event.target.value)}
|
||||||
|
onPressEnter={previewRoute}
|
||||||
|
/>
|
||||||
|
<Button icon={<SearchOutlined />} loading={previewing} onClick={previewRoute}>
|
||||||
|
预览路由
|
||||||
|
</Button>
|
||||||
|
</Space.Compact>
|
||||||
|
|
||||||
|
{previewResult ? (
|
||||||
|
<Alert
|
||||||
|
className="platform-section-gap"
|
||||||
|
type={previewResult.isConfigured ? 'success' : 'warning'}
|
||||||
|
showIcon
|
||||||
|
message={
|
||||||
|
previewResult.isConfigured
|
||||||
|
? `最终路由:${formatRoutingExecutorLabel(selectedExecutorKey)}`
|
||||||
|
: '未命中可用履约通道'
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
<Space direction="vertical" size={4}>
|
||||||
|
<Typography.Text>
|
||||||
|
{previewResult.fulfillmentRoute?.reason || previewResult.normalizedProductName}
|
||||||
|
</Typography.Text>
|
||||||
|
<Space wrap>
|
||||||
|
{previewResult.fulfillmentRoute?.candidates.map((candidate) => (
|
||||||
|
<Tag
|
||||||
|
key={candidate.executorKey}
|
||||||
|
color={candidate.available ? 'green' : 'default'}
|
||||||
|
>
|
||||||
|
{formatRoutingExecutorLabel(candidate.executorKey)}
|
||||||
|
{candidate.available ? ' 可用' : ' 未命中'}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title="商品路由规则"
|
||||||
|
extra={
|
||||||
|
<Space wrap>
|
||||||
|
<Button icon={<PlusOutlined />} onClick={addRule}>
|
||||||
|
新增规则
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={saveConfig}>
|
||||||
|
保存规则
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{rules.length === 0 ? (
|
||||||
|
<Empty description="暂无商品路由规则" />
|
||||||
|
) : (
|
||||||
|
<Space direction="vertical" className="full-width" size={12}>
|
||||||
|
{rules.map((rule, index) => (
|
||||||
|
<div key={rule.id} className="platform-route-rule">
|
||||||
|
<div className="platform-route-rule-head">
|
||||||
|
<Space wrap>
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={rule.enabled !== false}
|
||||||
|
onChange={(checked) => updateRule(index, { enabled: checked })}
|
||||||
|
/>
|
||||||
|
<Typography.Text strong>{rule.productName || '未命名规则'}</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
size="small"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() => removeRule(index)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="platform-route-rule-grid">
|
||||||
|
<Input
|
||||||
|
value={rule.productName}
|
||||||
|
placeholder="91 商品名 / productNo"
|
||||||
|
onChange={(event) => updateRule(index, { productName: event.target.value })}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={rule.matchType}
|
||||||
|
options={[
|
||||||
|
{ label: '精确', value: 'exact' },
|
||||||
|
{ label: '包含', value: 'contains' },
|
||||||
|
]}
|
||||||
|
onChange={(matchType) =>
|
||||||
|
updateRule(index, {
|
||||||
|
matchType: matchType === 'contains' ? 'contains' : 'exact',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={rule.executorKey}
|
||||||
|
options={ROUTING_EXECUTORS.map((executor) => ({
|
||||||
|
label: executor.label,
|
||||||
|
value: executor.key,
|
||||||
|
}))}
|
||||||
|
onChange={(executorKey) =>
|
||||||
|
updateRule(index, { executorKey: String(executorKey || '') })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InputNumber
|
||||||
|
value={rule.priority}
|
||||||
|
min={0}
|
||||||
|
max={9999}
|
||||||
|
onChange={(priorityValue) =>
|
||||||
|
updateRule(index, { priority: Number(priorityValue || 0) || 0 })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={rule.notes}
|
||||||
|
placeholder="备注"
|
||||||
|
onChange={(event) => updateRule(index, { notes: event.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function KuaishouFeifeiFulfillmentPanel() {
|
function KuaishouFeifeiFulfillmentPanel() {
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [syncing, setSyncing] = useState(false)
|
const [syncing, setSyncing] = useState(false)
|
||||||
@@ -791,6 +1191,58 @@ function MetricCard({ label, value, detail }: { label: string; value: string; de
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRoutingPriority(value: unknown) {
|
||||||
|
const rawItems = Array.isArray(value) ? value : []
|
||||||
|
const allowed = ROUTING_EXECUTORS
|
||||||
|
.filter((executor) => executor.key !== 'manual_dispatch')
|
||||||
|
.map((executor) => executor.key)
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const items = rawItems
|
||||||
|
.map((item) => String(item || '').trim())
|
||||||
|
.filter((item) => allowed.includes(item) && !seen.has(item) && (seen.add(item), true))
|
||||||
|
|
||||||
|
return items.length > 0 ? items : ['kuaishou_ct_assisted', 'kuaishou_feifei']
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRoutingExecutors(value: unknown): AdminFulfillmentRoutingConfig['executors'] {
|
||||||
|
const source = value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as AdminFulfillmentRoutingConfig['executors']
|
||||||
|
: {}
|
||||||
|
|
||||||
|
return ROUTING_EXECUTORS.reduce<AdminFulfillmentRoutingConfig['executors']>((result, executor) => {
|
||||||
|
result[executor.key] = {
|
||||||
|
enabled: source[executor.key]?.enabled !== false,
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRoutingRule(rule: AdminFulfillmentRoutingRule): EditableRoutingRule {
|
||||||
|
const productName = String(rule.productName || '').trim()
|
||||||
|
const executorKey = String(rule.executorKey || '').trim()
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(rule.id || `${productName}-${executorKey}` || createRuleId()).trim(),
|
||||||
|
enabled: rule.enabled !== false,
|
||||||
|
productName,
|
||||||
|
normalizedProductName: String(rule.normalizedProductName || '').trim(),
|
||||||
|
matchType: rule.matchType === 'contains' ? 'contains' : 'exact',
|
||||||
|
executorKey: executorKey || 'kuaishou_feifei',
|
||||||
|
priority: Number(rule.priority || 0) || 0,
|
||||||
|
notes: String(rule.notes || '').trim(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRoutingExecutorLabel(executorKey: unknown) {
|
||||||
|
const key = String(executorKey || '').trim()
|
||||||
|
return ROUTING_EXECUTORS.find((executor) => executor.key === key)?.label || key || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRoutingExecutorColor(executorKey: unknown) {
|
||||||
|
const key = String(executorKey || '').trim()
|
||||||
|
return ROUTING_EXECUTORS.find((executor) => executor.key === key)?.color || 'default'
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeFeifeiRule(
|
function normalizeFeifeiRule(
|
||||||
rule: Partial<AdminKuaishouFeifeiProductRule>,
|
rule: Partial<AdminKuaishouFeifeiProductRule>,
|
||||||
): AdminKuaishouFeifeiProductRule {
|
): AdminKuaishouFeifeiProductRule {
|
||||||
|
|||||||
@@ -669,7 +669,7 @@ function ClaimHeaderCard({
|
|||||||
return (
|
return (
|
||||||
<Card className="claim-header-card">
|
<Card className="claim-header-card">
|
||||||
<div className="claim-header-copy">
|
<div className="claim-header-copy">
|
||||||
<span className="eyebrow">快手 Cloud 客户领取</span>
|
<span className="eyebrow">kuaishou-lewan 客户领取</span>
|
||||||
<h1>{product?.title || '商品领取'}</h1>
|
<h1>{product?.title || '商品领取'}</h1>
|
||||||
<p>订单号:{order?.platformOrderId || '-'}</p>
|
<p>订单号:{order?.platformOrderId || '-'}</p>
|
||||||
<ClaimProductItems product={product} compact />
|
<ClaimProductItems product={product} compact />
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { apiGet, apiPost } from '@/lib/http'
|
||||||
|
import type {
|
||||||
|
AdminFulfillmentRoutingConfig,
|
||||||
|
AdminFulfillmentRoutingPreviewResult,
|
||||||
|
} from '@/types/admin'
|
||||||
|
|
||||||
|
export function fetchAdminFulfillmentRoutingConfig() {
|
||||||
|
return apiGet<AdminFulfillmentRoutingConfig>(
|
||||||
|
'/api/v1/admin/platform-config/fulfillment-routing',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAdminFulfillmentRoutingConfig(payload: Partial<AdminFulfillmentRoutingConfig>) {
|
||||||
|
return apiPost<AdminFulfillmentRoutingConfig>(
|
||||||
|
'/api/v1/admin/platform-config/fulfillment-routing',
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previewAdminFulfillmentRouting(productName: string) {
|
||||||
|
return apiPost<AdminFulfillmentRoutingPreviewResult>(
|
||||||
|
'/api/v1/admin/platform-config/fulfillment-routing/preview',
|
||||||
|
{ productName },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,3 +5,4 @@ export * from './kuaishou-eticket'
|
|||||||
export * from './kuaishou-industry'
|
export * from './kuaishou-industry'
|
||||||
export * from './kuaishou-feifei'
|
export * from './kuaishou-feifei'
|
||||||
export * from './cloudtentacles'
|
export * from './cloudtentacles'
|
||||||
|
export * from './fulfillment-routing'
|
||||||
|
|||||||
@@ -885,6 +885,37 @@ select {
|
|||||||
align-items: end;
|
align-items: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.platform-routing-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-routing-priority-row,
|
||||||
|
.platform-route-rule-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-route-rule {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #edf0f5;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f8fafc;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-route-rule-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 1fr) 110px minmax(160px, 0.7fr) 110px;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.field-switch {
|
.field-switch {
|
||||||
min-height: 54px;
|
min-height: 54px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
@@ -1065,10 +1096,17 @@ select {
|
|||||||
.metric-grid.four,
|
.metric-grid.four,
|
||||||
.metric-grid.five,
|
.metric-grid.five,
|
||||||
.platform-form-grid,
|
.platform-form-grid,
|
||||||
|
.platform-routing-grid,
|
||||||
|
.platform-route-rule-grid,
|
||||||
.platform-split-layout {
|
.platform-split-layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.platform-routing-priority-row,
|
||||||
|
.platform-route-rule-head {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
.platform-side-card {
|
.platform-side-card {
|
||||||
position: static;
|
position: static;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,4 +85,8 @@ export type {
|
|||||||
AdminCloudtentaclesSkuListResult,
|
AdminCloudtentaclesSkuListResult,
|
||||||
AdminCloudtentaclesDeliveryRecordItem,
|
AdminCloudtentaclesDeliveryRecordItem,
|
||||||
AdminCloudtentaclesDeliveryRecordListResult,
|
AdminCloudtentaclesDeliveryRecordListResult,
|
||||||
|
AdminFulfillmentRoutingConfig,
|
||||||
|
AdminFulfillmentRoutingExecutorConfig,
|
||||||
|
AdminFulfillmentRoutingPreviewResult,
|
||||||
|
AdminFulfillmentRoutingRule,
|
||||||
} from './platform-config'
|
} from './platform-config'
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
export interface AdminFulfillmentRoutingExecutorConfig {
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminFulfillmentRoutingRule {
|
||||||
|
id: string
|
||||||
|
enabled: boolean
|
||||||
|
productName: string
|
||||||
|
normalizedProductName: string
|
||||||
|
matchType: 'exact' | 'contains'
|
||||||
|
executorKey: string
|
||||||
|
priority: number
|
||||||
|
notes: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminFulfillmentRoutingConfig {
|
||||||
|
filePath: string
|
||||||
|
enabled: boolean
|
||||||
|
defaultExecutorPriority: string[]
|
||||||
|
unmatchedExecutorKey: string
|
||||||
|
executors: Record<string, AdminFulfillmentRoutingExecutorConfig>
|
||||||
|
rules: AdminFulfillmentRoutingRule[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminFulfillmentRoutingPreviewResult {
|
||||||
|
productName: string
|
||||||
|
normalizedProductName: string
|
||||||
|
resolvedSkuCode: string
|
||||||
|
resolvedSkuName: string
|
||||||
|
isConfigured: boolean
|
||||||
|
fulfillmentRoute: null | {
|
||||||
|
selectedExecutorKey: string
|
||||||
|
selectedRuleId: string
|
||||||
|
reason: string
|
||||||
|
matchedRule: null | {
|
||||||
|
id: string
|
||||||
|
productName: string
|
||||||
|
executorKey: string
|
||||||
|
priority: number
|
||||||
|
}
|
||||||
|
candidates: Array<{
|
||||||
|
executorKey: string
|
||||||
|
available: boolean
|
||||||
|
reason?: string
|
||||||
|
}>
|
||||||
|
skipped: Array<{
|
||||||
|
executorKey: string
|
||||||
|
reason: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
cloudtentacles: Record<string, unknown> | null
|
||||||
|
kuaishouFeifei: Record<string, unknown> | null
|
||||||
|
}
|
||||||
@@ -73,3 +73,10 @@ export type {
|
|||||||
AdminCloudtentaclesDeliveryRecordItem,
|
AdminCloudtentaclesDeliveryRecordItem,
|
||||||
AdminCloudtentaclesDeliveryRecordListResult,
|
AdminCloudtentaclesDeliveryRecordListResult,
|
||||||
} from './cloudtentacles'
|
} from './cloudtentacles'
|
||||||
|
|
||||||
|
export type {
|
||||||
|
AdminFulfillmentRoutingConfig,
|
||||||
|
AdminFulfillmentRoutingExecutorConfig,
|
||||||
|
AdminFulfillmentRoutingPreviewResult,
|
||||||
|
AdminFulfillmentRoutingRule,
|
||||||
|
} from './fulfillment-routing'
|
||||||
|
|||||||
Reference in New Issue
Block a user