优化履约路由并重命名乐玩通道

This commit is contained in:
yml2213
2026-07-09 10:49:33 +08:00
parent 71602683d2
commit 4d5b0d6fc6
28 changed files with 1448 additions and 53 deletions
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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'
+38
View File
@@ -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;
} }
+4
View File
@@ -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'