From 4d5b0d6fc66d624307cc85558384b43c2e2f72c9 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 9 Jul 2026 10:49:33 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=B1=A5=E7=BA=A6=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E5=B9=B6=E9=87=8D=E5=91=BD=E5=90=8D=E4=B9=90=E7=8E=A9?= =?UTF-8?q?=E9=80=9A=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/README.md | 4 +- .../scripts/mock-kuaishou-cloud-claim.ts | 8 +- apps/backend/src/domain/task-status.test.ts | 2 +- .../src/routes/admin/platform-config.ts | 2 + .../platform-config/fulfillment-routing.ts | 59 +++ .../fulfillment-routing-service.ts | 51 ++ .../admin/write/kuaishou-cloud-actions.ts | 8 +- .../fulfillment-bootstrap-service.ts | 2 +- .../claim/kuaishou-cloud-claim-service.ts | 8 +- .../fulfillment/kuaishou-cloud/index.ts | 10 +- .../kuaishou-cloud/task-finalization.ts | 6 +- .../src/services/fulfillment/planner.test.ts | 72 ++- .../src/services/fulfillment/planner.ts | 53 +- .../fulfillment/product-resolution-service.ts | 64 ++- .../routing-config-service.test.ts | 91 ++++ .../fulfillment/routing-config-service.ts | 451 +++++++++++++++++ .../notification/domain-notifications.ts | 8 +- .../order/delivery-task-service.test.ts | 4 +- .../src/pages/admin/AdminTaskDetailPage.tsx | 10 +- .../src/pages/admin/AdminTasksPage.tsx | 2 +- .../platform/AdminPlatformFulfillmentPage.tsx | 456 +++++++++++++++++- apps/frontend/src/pages/claim/ClaimPage.tsx | 2 +- .../platform-config/fulfillment-routing.ts | 25 + .../services/admin/platform-config/index.ts | 1 + apps/frontend/src/styles/main.css | 38 ++ apps/frontend/src/types/admin/index.ts | 4 + .../platform-config/fulfillment-routing.ts | 53 ++ .../src/types/admin/platform-config/index.ts | 7 + 28 files changed, 1448 insertions(+), 53 deletions(-) create mode 100644 apps/backend/src/routes/admin/platform-config/fulfillment-routing.ts create mode 100644 apps/backend/src/services/admin/platform-config/fulfillment-routing-service.ts create mode 100644 apps/backend/src/services/fulfillment/routing-config-service.test.ts create mode 100644 apps/backend/src/services/fulfillment/routing-config-service.ts create mode 100644 apps/frontend/src/services/admin/platform-config/fulfillment-routing.ts create mode 100644 apps/frontend/src/types/admin/platform-config/fulfillment-routing.ts diff --git a/apps/backend/README.md b/apps/backend/README.md index 0caa9f8e..7e520eb0 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -2,7 +2,7 @@ 快手轻量后端服务。 -后端负责订单入库、履约任务编排、快手 Cloud / 91 卡券 / 快手小店核销配置、后台管理接口和数据库迁移。默认入口就是快手轻量后端。 +后端负责订单入库、履约任务编排、kuaishou-lewan / 91 卡券 / 快手小店核销配置、后台管理接口和数据库迁移。默认入口就是快手轻量后端。 ## 快速启动 @@ -65,7 +65,7 @@ npm test - `src/app.ts`:Express 应用组装 - `src/routes`:API 路由 - `src/services/order`:订单、商品匹配和履约任务编排 -- `src/services/fulfillment`:快手 Cloud 履约编排 +- `src/services/fulfillment`:kuaishou-lewan 履约编排 - `src/services/admin`:后台读写、权限、配置管理 - `src/repositories`:数据库访问 - `src/db/migrations`:新库初始化脚本 diff --git a/apps/backend/scripts/mock-kuaishou-cloud-claim.ts b/apps/backend/scripts/mock-kuaishou-cloud-claim.ts index 7c09a858..b98341a7 100644 --- a/apps/backend/scripts/mock-kuaishou-cloud-claim.ts +++ b/apps/backend/scripts/mock-kuaishou-cloud-claim.ts @@ -176,7 +176,7 @@ try { updated_at: now, }) - console.log('已生成快手 Cloud 领取 mock 数据:') + console.log('已生成 kuaishou-lewan 领取 mock 数据:') console.log(` 91订单号:${orderNo}`) console.log(` 商品:${productName}`) console.log(` 当前步骤:${step}`) @@ -197,7 +197,7 @@ async function ensureKuaishouCloudProfile(timestamp: string) { const profile = await upsertFulfillmentProfile({ profileKey: 'kuaishou_ct_assisted', - name: '快手 cloud 履约', + name: 'kuaishou-lewan 履约', executorKey: 'kuaishou_ct_assisted', requiresClaim: false, autoDispatch: false, @@ -243,7 +243,7 @@ function buildMockTaskContext({ return { profileKey: 'kuaishou_ct_assisted', - profileName: '快手 cloud 履约', + profileName: 'kuaishou-lewan 履约', skuCode: productName, skuName: productName, kuaishouCloudFulfillment: { @@ -426,7 +426,7 @@ function buildFrontendClaimUrl(token: string, rawBaseUrl: unknown) { function printHelp() { console.log(` -生成快手 Cloud 领取 mock 数据: +生成 kuaishou-lewan 领取 mock 数据: npm run mock:claim npm run mock:claim -- --step=binding diff --git a/apps/backend/src/domain/task-status.test.ts b/apps/backend/src/domain/task-status.test.ts index 0b29282f..077185d0 100644 --- a/apps/backend/src/domain/task-status.test.ts +++ b/apps/backend/src/domain/task-status.test.ts @@ -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) diff --git a/apps/backend/src/routes/admin/platform-config.ts b/apps/backend/src/routes/admin/platform-config.ts index e5296820..4a02d62d 100644 --- a/apps/backend/src/routes/admin/platform-config.ts +++ b/apps/backend/src/routes/admin/platform-config.ts @@ -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; diff --git a/apps/backend/src/routes/admin/platform-config/fulfillment-routing.ts b/apps/backend/src/routes/admin/platform-config/fulfillment-routing.ts new file mode 100644 index 00000000..7f5c7336 --- /dev/null +++ b/apps/backend/src/routes/admin/platform-config/fulfillment-routing.ts @@ -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 diff --git a/apps/backend/src/services/admin/platform-config/fulfillment-routing-service.ts b/apps/backend/src/services/admin/platform-config/fulfillment-routing-service.ts new file mode 100644 index 00000000..e1adecef --- /dev/null +++ b/apps/backend/src/services/admin/platform-config/fulfillment-routing-service.ts @@ -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, + } +} diff --git a/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts b/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts index b6dc1dcf..5c9ec9d8 100644 --- a/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts +++ b/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts @@ -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', }) diff --git a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts index 26eb666d..e6e4d145 100644 --- a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts +++ b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts @@ -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, diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts index 44b2d676..3adb2a22 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts @@ -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', }) diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/index.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/index.ts index 5e4bdd86..84ff1986 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/index.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/index.ts @@ -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', }) diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts index 056e0968..cdf7485b 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts @@ -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", }); diff --git a/apps/backend/src/services/fulfillment/planner.test.ts b/apps/backend/src/services/fulfillment/planner.test.ts index 5d6664b4..1f801f95 100644 --- a/apps/backend/src/services/fulfillment/planner.test.ts +++ b/apps/backend/src/services/fulfillment/planner.test.ts @@ -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(), diff --git a/apps/backend/src/services/fulfillment/planner.ts b/apps/backend/src/services/fulfillment/planner.ts index bb828957..736f9e18 100644 --- a/apps/backend/src/services/fulfillment/planner.ts +++ b/apps/backend/src/services/fulfillment/planner.ts @@ -231,6 +231,21 @@ async function resolveDynamicFulfillmentProfile( item: OrderItemRow, getProfileByKey: FulfillmentProfileResolver, ): Promise { + 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 { + 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 {} diff --git a/apps/backend/src/services/fulfillment/product-resolution-service.ts b/apps/backend/src/services/fulfillment/product-resolution-service.ts index 4ff24aad..dc3d19cb 100644 --- a/apps/backend/src/services/fulfillment/product-resolution-service.ts +++ b/apps/backend/src/services/fulfillment/product-resolution-service.ts @@ -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, } } diff --git a/apps/backend/src/services/fulfillment/routing-config-service.test.ts b/apps/backend/src/services/fulfillment/routing-config-service.test.ts new file mode 100644 index 00000000..ce5ebb85 --- /dev/null +++ b/apps/backend/src/services/fulfillment/routing-config-service.test.ts @@ -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, '命中商品路由规则') +}) diff --git a/apps/backend/src/services/fulfillment/routing-config-service.ts b/apps/backend/src/services/fulfillment/routing-config-service.ts new file mode 100644 index 00000000..89272e24 --- /dev/null +++ b/apps/backend/src/services/fulfillment/routing-config-service.ts @@ -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 + +export type FulfillmentRoutingRuleMatchType = 'exact' | 'contains' + +export type FulfillmentRoutingExecutorConfig = { + enabled: boolean +} + +export type FulfillmentRoutingRule = { + id: string + enabled: boolean + productName: string + normalizedProductName: string + matchType: FulfillmentRoutingRuleMatchType + executorKey: string + priority: number + notes: string +} + +export type FulfillmentRoutingConfig = { + enabled: boolean + defaultExecutorPriority: string[] + unmatchedExecutorKey: string + executors: Record + rules: FulfillmentRoutingRule[] +} + +export type FulfillmentRoutingCandidate = { + executorKey: string + available: boolean + reason?: string +} + +export type FulfillmentRouteResult = { + selectedExecutorKey: string + selectedRuleId: string + reason: string + matchedRule: null | { + id: string + productName: string + executorKey: string + priority: number + } + candidates: FulfillmentRoutingCandidate[] + skipped: Array<{ + executorKey: string + reason: string + }> +} + +const FULFILLMENT_ROUTING_CONFIG_FILE_PATH = path.join( + PROJECT_ROOT, + 'data', + 'fulfillment-routing-config.json', +) + +const ROUTABLE_EXECUTOR_KEYS: string[] = [ + FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD, + FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI, + FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH, +] + +const DEFAULT_EXECUTOR_PRIORITY = [ + FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD, + FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI, +] + +export function getFulfillmentRoutingConfigFilePath() { + return FULFILLMENT_ROUTING_CONFIG_FILE_PATH +} + +export function getFulfillmentRoutingConfig(): FulfillmentRoutingConfig { + return readJsonFile( + FULFILLMENT_ROUTING_CONFIG_FILE_PATH, + createDefaultFulfillmentRoutingConfig, + normalizeFulfillmentRoutingConfig, + ) +} + +export function saveFulfillmentRoutingConfig(rawValue: unknown): FulfillmentRoutingConfig { + return writeJsonFile( + FULFILLMENT_ROUTING_CONFIG_FILE_PATH, + rawValue, + normalizeFulfillmentRoutingConfig, + ) +} + +export function normalizeFulfillmentRoutingConfig(rawValue: unknown): FulfillmentRoutingConfig { + const source = isPlainObject(rawValue) ? rawValue : {} + const sourceExecutors = isPlainObject(source.executors) ? source.executors : {} + const rules = Array.isArray(source.rules) ? source.rules : [] + const defaultExecutorPriority = normalizeExecutorPriority( + source.defaultExecutorPriority || source.executorPriority, + ) + const unmatchedExecutorKey = normalizeExecutorKey(source.unmatchedExecutorKey) + + return { + enabled: source.enabled !== false, + defaultExecutorPriority: + defaultExecutorPriority.length > 0 ? defaultExecutorPriority : [...DEFAULT_EXECUTOR_PRIORITY], + unmatchedExecutorKey: + unmatchedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH ? unmatchedExecutorKey : '', + executors: ROUTABLE_EXECUTOR_KEYS.reduce>( + (result, executorKey) => { + const executorConfig = isPlainObject(sourceExecutors[executorKey]) + ? sourceExecutors[executorKey] as JsonObject + : {} + result[executorKey] = { + enabled: executorConfig.enabled !== false, + } + return result + }, + {}, + ), + rules: rules + .map((rule) => normalizeFulfillmentRoutingRule(rule)) + .filter((rule): rule is FulfillmentRoutingRule => Boolean(rule)) + .sort((left, right) => right.priority - left.priority), + } +} + +export function resolveFulfillmentRoute({ + productName, + candidates, + config = getFulfillmentRoutingConfig(), +}: { + productName: unknown + candidates: FulfillmentRoutingCandidate[] + config?: FulfillmentRoutingConfig +}): FulfillmentRouteResult { + const normalizedConfig = normalizeFulfillmentRoutingConfig(config) + const normalizedCandidates = normalizeRoutingCandidates(candidates) + const skipped: FulfillmentRouteResult['skipped'] = [] + + if (normalizedConfig.enabled === false) { + return resolveByExecutorPriority({ + priority: DEFAULT_EXECUTOR_PRIORITY, + candidates: normalizedCandidates, + config: createDefaultFulfillmentRoutingConfig(), + skipped, + reasonPrefix: '履约路由未启用,使用历史顺序', + }) + } + + const matchedRule = findMatchedRoutingRule(productName, normalizedConfig.rules) + if (matchedRule) { + const selected = resolveExecutorSelection({ + executorKey: matchedRule.executorKey, + candidates: normalizedCandidates, + config: normalizedConfig, + skipped, + reason: '命中商品路由规则', + matchedRule, + }) + if (selected.selectedExecutorKey) { + return selected + } + + return { + ...selected, + reason: selected.reason || '商品路由规则目标通道不可用', + } + } + + const selected = resolveByExecutorPriority({ + priority: normalizedConfig.defaultExecutorPriority, + candidates: normalizedCandidates, + config: normalizedConfig, + skipped, + reasonPrefix: '按全局优先级命中', + }) + if (selected.selectedExecutorKey) { + return selected + } + + if (normalizedConfig.unmatchedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH) { + const fallback = resolveExecutorSelection({ + executorKey: FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH, + candidates: normalizedCandidates, + config: normalizedConfig, + skipped, + reason: '未命中自动履约通道,进入人工履约', + matchedRule: null, + }) + if (fallback.selectedExecutorKey) { + return fallback + } + } + + return { + selectedExecutorKey: '', + selectedRuleId: '', + reason: '未命中可用履约通道', + matchedRule: null, + candidates: normalizedCandidates, + skipped, + } +} + +function resolveByExecutorPriority({ + priority, + candidates, + config, + skipped, + reasonPrefix, +}: { + priority: string[] + candidates: FulfillmentRoutingCandidate[] + config: FulfillmentRoutingConfig + skipped: FulfillmentRouteResult['skipped'] + reasonPrefix: string +}) { + for (const executorKey of priority) { + const selected = resolveExecutorSelection({ + executorKey, + candidates, + config, + skipped, + reason: reasonPrefix, + matchedRule: null, + }) + if (selected.selectedExecutorKey) { + return selected + } + } + + return { + selectedExecutorKey: '', + selectedRuleId: '', + reason: '', + matchedRule: null, + candidates, + skipped, + } +} + +function resolveExecutorSelection({ + executorKey, + candidates, + config, + skipped, + reason, + matchedRule, +}: { + executorKey: string + candidates: FulfillmentRoutingCandidate[] + config: FulfillmentRoutingConfig + skipped: FulfillmentRouteResult['skipped'] + reason: string + matchedRule: FulfillmentRoutingRule | null +}): FulfillmentRouteResult { + const normalizedExecutorKey = normalizeExecutorKey(executorKey) + if (!normalizedExecutorKey) { + return createEmptyRouteResult(candidates, skipped) + } + + if (config.executors[normalizedExecutorKey]?.enabled === false) { + skipped.push({ + executorKey: normalizedExecutorKey, + reason: '履约通道已停用', + }) + return createEmptyRouteResult(candidates, skipped) + } + + if (normalizedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH) { + return createSelectedRouteResult({ + executorKey: normalizedExecutorKey, + reason, + matchedRule, + candidates, + skipped, + }) + } + + const candidate = candidates.find((item) => item.executorKey === normalizedExecutorKey) + if (!candidate?.available) { + skipped.push({ + executorKey: normalizedExecutorKey, + reason: candidate?.reason || '未命中该通道商品配置', + }) + return createEmptyRouteResult(candidates, skipped) + } + + return createSelectedRouteResult({ + executorKey: normalizedExecutorKey, + reason, + matchedRule, + candidates, + skipped, + }) +} + +function createSelectedRouteResult({ + executorKey, + reason, + matchedRule, + candidates, + skipped, +}: { + executorKey: string + reason: string + matchedRule: FulfillmentRoutingRule | null + candidates: FulfillmentRoutingCandidate[] + skipped: FulfillmentRouteResult['skipped'] +}): FulfillmentRouteResult { + return { + selectedExecutorKey: executorKey, + selectedRuleId: matchedRule?.id || '', + reason, + matchedRule: matchedRule + ? { + id: matchedRule.id, + productName: matchedRule.productName, + executorKey: matchedRule.executorKey, + priority: matchedRule.priority, + } + : null, + candidates, + skipped, + } +} + +function createEmptyRouteResult( + candidates: FulfillmentRoutingCandidate[], + skipped: FulfillmentRouteResult['skipped'], +): FulfillmentRouteResult { + return { + selectedExecutorKey: '', + selectedRuleId: '', + reason: '', + matchedRule: null, + candidates, + skipped, + } +} + +function findMatchedRoutingRule(productName: unknown, rules: FulfillmentRoutingRule[]) { + const normalizedProductName = normalizeCloudtentaclesMatchName(productName) + if (!normalizedProductName) { + return null + } + + return rules.find((rule) => { + if (rule.enabled === false || !rule.normalizedProductName) { + return false + } + + if (rule.matchType === 'contains') { + return normalizedProductName.includes(rule.normalizedProductName) + } + + return normalizedProductName === rule.normalizedProductName + }) || null +} + +function normalizeFulfillmentRoutingRule(rawValue: unknown): FulfillmentRoutingRule | null { + const source = isPlainObject(rawValue) ? rawValue : {} + const productName = String(source.productName || source.name || '').trim() + const normalizedProductName = + normalizeCloudtentaclesMatchName(source.normalizedProductName) || + normalizeCloudtentaclesMatchName(productName) + const executorKey = normalizeExecutorKey(source.executorKey) + + if (!productName || !normalizedProductName || !executorKey) { + return null + } + + return { + id: String(source.id || `${normalizedProductName}:${executorKey}`).trim() || + `${normalizedProductName}:${executorKey}`, + enabled: source.enabled !== false, + productName, + normalizedProductName, + matchType: source.matchType === 'contains' ? 'contains' : 'exact', + executorKey, + priority: normalizeInteger(source.priority, 100), + notes: String(source.notes || '').trim(), + } +} + +function normalizeRoutingCandidates(candidates: FulfillmentRoutingCandidate[]) { + const result = new Map() + + for (const candidate of Array.isArray(candidates) ? candidates : []) { + const executorKey = normalizeExecutorKey(candidate.executorKey) + if (!executorKey || executorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH) { + continue + } + result.set(executorKey, { + executorKey, + available: candidate.available === true, + reason: String(candidate.reason || '').trim(), + }) + } + + return Array.from(result.values()) +} + +function normalizeExecutorPriority(value: unknown) { + const rawItems = Array.isArray(value) ? value : [] + const seen = new Set() + const items: string[] = [] + + for (const item of rawItems) { + const executorKey = normalizeExecutorKey(item) + if (!executorKey || seen.has(executorKey) || executorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH) { + continue + } + seen.add(executorKey) + items.push(executorKey) + } + + return items +} + +function normalizeExecutorKey(value: unknown) { + const executorKey = String(value || '').trim() + return ROUTABLE_EXECUTOR_KEYS.includes(executorKey) ? executorKey : '' +} + +function normalizeInteger(value: unknown, fallback: number) { + const parsed = Number(value) + return Number.isInteger(parsed) ? parsed : fallback +} + +function createDefaultFulfillmentRoutingConfig(): FulfillmentRoutingConfig { + return { + enabled: true, + defaultExecutorPriority: [...DEFAULT_EXECUTOR_PRIORITY], + unmatchedExecutorKey: '', + executors: ROUTABLE_EXECUTOR_KEYS.reduce>( + (result, executorKey) => { + result[executorKey] = { enabled: true } + return result + }, + {}, + ), + rules: [], + } +} + +function isPlainObject(value: unknown): value is JsonObject { + return Object.prototype.toString.call(value) === '[object Object]' +} diff --git a/apps/backend/src/services/notification/domain-notifications.ts b/apps/backend/src/services/notification/domain-notifications.ts index f14c17ad..069a52db 100644 --- a/apps/backend/src/services/notification/domain-notifications.ts +++ b/apps/backend/src/services/notification/domain-notifications.ts @@ -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() || '-'}`, diff --git a/apps/backend/src/services/order/delivery-task-service.test.ts b/apps/backend/src/services/order/delivery-task-service.test.ts index ea56e3db..a45dd5c0 100644 --- a/apps/backend/src/services/order/delivery-task-service.test.ts +++ b/apps/backend/src/services/order/delivery-task-service.test.ts @@ -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) => { diff --git a/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx b/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx index 4c8ad1c6..68f129d4 100644 --- a/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx +++ b/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx @@ -411,7 +411,7 @@ export default function AdminTaskDetailPage() { items={[ { key: 'cloud', - label: '快手 Cloud', + label: 'kuaishou-lewan', children: flow ? ( ) : ( - + ), }, { @@ -552,7 +552,7 @@ function TaskActionPanel({ {hasCloudActions ? (
- 快手 Cloud + kuaishou-lewan {canManageTaskLifecycle && operations.canPrepareKuaishouCloudFulfillment ? ( + + + + + + +
+
+ 履约路由 + +
+ {ROUTING_EXECUTORS.map((executor) => ( +
+ {executor.label} + updateExecutorEnabled(executor.key, checked)} + /> +
+ ))} +
+
+ + + + {priority.map((executorKey, index) => ( +
+ + + {index + 1}. {formatRoutingExecutorLabel(executorKey)} + + + +
+ ))} + setPreviewInput(event.target.value)} + onPressEnter={previewRoute} + /> + + + + {previewResult ? ( + + + {previewResult.fulfillmentRoute?.reason || previewResult.normalizedProductName} + + + {previewResult.fulfillmentRoute?.candidates.map((candidate) => ( + + {formatRoutingExecutorLabel(candidate.executorKey)} + {candidate.available ? ' 可用' : ' 未命中'} + + ))} + +
+ } + /> + ) : null} +
+ + + + + + } + > + {rules.length === 0 ? ( + + ) : ( + + {rules.map((rule, index) => ( +
+
+ + updateRule(index, { enabled: checked })} + /> + {rule.productName || '未命名规则'} + + +
+
+ updateRule(index, { productName: event.target.value })} + /> + ({ + label: executor.label, + value: executor.key, + }))} + onChange={(executorKey) => + updateRule(index, { executorKey: String(executorKey || '') }) + } + /> + + updateRule(index, { priority: Number(priorityValue || 0) || 0 }) + } + /> +
+ updateRule(index, { notes: event.target.value })} + /> +
+ ))} +
+ )} +
+
+ ) +} + function KuaishouFeifeiFulfillmentPanel() { const [loading, setLoading] = useState(true) 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() + 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((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( rule: Partial, ): AdminKuaishouFeifeiProductRule { diff --git a/apps/frontend/src/pages/claim/ClaimPage.tsx b/apps/frontend/src/pages/claim/ClaimPage.tsx index 3090f489..613f6582 100644 --- a/apps/frontend/src/pages/claim/ClaimPage.tsx +++ b/apps/frontend/src/pages/claim/ClaimPage.tsx @@ -669,7 +669,7 @@ function ClaimHeaderCard({ return (
- 快手 Cloud 客户领取 + kuaishou-lewan 客户领取

{product?.title || '商品领取'}

订单号:{order?.platformOrderId || '-'}

diff --git a/apps/frontend/src/services/admin/platform-config/fulfillment-routing.ts b/apps/frontend/src/services/admin/platform-config/fulfillment-routing.ts new file mode 100644 index 00000000..433c7ab3 --- /dev/null +++ b/apps/frontend/src/services/admin/platform-config/fulfillment-routing.ts @@ -0,0 +1,25 @@ +import { apiGet, apiPost } from '@/lib/http' +import type { + AdminFulfillmentRoutingConfig, + AdminFulfillmentRoutingPreviewResult, +} from '@/types/admin' + +export function fetchAdminFulfillmentRoutingConfig() { + return apiGet( + '/api/v1/admin/platform-config/fulfillment-routing', + ) +} + +export function saveAdminFulfillmentRoutingConfig(payload: Partial) { + return apiPost( + '/api/v1/admin/platform-config/fulfillment-routing', + payload, + ) +} + +export function previewAdminFulfillmentRouting(productName: string) { + return apiPost( + '/api/v1/admin/platform-config/fulfillment-routing/preview', + { productName }, + ) +} diff --git a/apps/frontend/src/services/admin/platform-config/index.ts b/apps/frontend/src/services/admin/platform-config/index.ts index 1b0af90f..66720b9d 100644 --- a/apps/frontend/src/services/admin/platform-config/index.ts +++ b/apps/frontend/src/services/admin/platform-config/index.ts @@ -5,3 +5,4 @@ export * from './kuaishou-eticket' export * from './kuaishou-industry' export * from './kuaishou-feifei' export * from './cloudtentacles' +export * from './fulfillment-routing' diff --git a/apps/frontend/src/styles/main.css b/apps/frontend/src/styles/main.css index 789026eb..7141d3d5 100644 --- a/apps/frontend/src/styles/main.css +++ b/apps/frontend/src/styles/main.css @@ -885,6 +885,37 @@ select { 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 { min-height: 54px; padding: 10px 12px; @@ -1065,10 +1096,17 @@ select { .metric-grid.four, .metric-grid.five, .platform-form-grid, + .platform-routing-grid, + .platform-route-rule-grid, .platform-split-layout { grid-template-columns: 1fr; } + .platform-routing-priority-row, + .platform-route-rule-head { + display: grid; + } + .platform-side-card { position: static; } diff --git a/apps/frontend/src/types/admin/index.ts b/apps/frontend/src/types/admin/index.ts index fa5be748..da7be670 100644 --- a/apps/frontend/src/types/admin/index.ts +++ b/apps/frontend/src/types/admin/index.ts @@ -85,4 +85,8 @@ export type { AdminCloudtentaclesSkuListResult, AdminCloudtentaclesDeliveryRecordItem, AdminCloudtentaclesDeliveryRecordListResult, + AdminFulfillmentRoutingConfig, + AdminFulfillmentRoutingExecutorConfig, + AdminFulfillmentRoutingPreviewResult, + AdminFulfillmentRoutingRule, } from './platform-config' diff --git a/apps/frontend/src/types/admin/platform-config/fulfillment-routing.ts b/apps/frontend/src/types/admin/platform-config/fulfillment-routing.ts new file mode 100644 index 00000000..52f42965 --- /dev/null +++ b/apps/frontend/src/types/admin/platform-config/fulfillment-routing.ts @@ -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 + 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 | null + kuaishouFeifei: Record | null +} diff --git a/apps/frontend/src/types/admin/platform-config/index.ts b/apps/frontend/src/types/admin/platform-config/index.ts index 85b2af30..23b3ab06 100644 --- a/apps/frontend/src/types/admin/platform-config/index.ts +++ b/apps/frontend/src/types/admin/platform-config/index.ts @@ -73,3 +73,10 @@ export type { AdminCloudtentaclesDeliveryRecordItem, AdminCloudtentaclesDeliveryRecordListResult, } from './cloudtentacles' + +export type { + AdminFulfillmentRoutingConfig, + AdminFulfillmentRoutingExecutorConfig, + AdminFulfillmentRoutingPreviewResult, + AdminFulfillmentRoutingRule, +} from './fulfillment-routing'