From c1f3a48634f6df43b4e49e66cb65eddd053482ba Mon Sep 17 00:00:00 2001 From: yml Date: Wed, 27 May 2026 13:25:15 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81cloudtentacles=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/platform-config/cloudtentacles.ts | 40 ++++ .../platform-config/cloudtentacles/index.ts | 22 ++ .../order/cloudtentacles-match-utils.ts | 9 + .../cloudtentacles-name-match-service.test.ts | 88 +++++++ .../cloudtentacles-name-match-service.ts | 141 ++++++++++- ...oudtentacles-override-rule-service.test.ts | 70 ++++++ .../cloudtentacles-override-rule-service.ts | 173 ++++++++++++++ .../order/delivery-task-service.test.ts | 89 +++++++ .../services/order/delivery-task-service.ts | 34 +-- .../services/order/product-match-service.ts | 1 + apps/backend/src/types/admin/route-inputs.ts | 2 + apps/backend/src/types/admin/write-inputs.ts | 20 ++ .../admin/platform-config/cloudtentacles.ts | 30 +++ apps/frontend/src/types/admin/index.ts | 3 + .../admin/platform-config/cloudtentacles.ts | 22 ++ .../src/types/admin/platform-config/index.ts | 3 + .../AdminKuaishouCloudFulfillment.css | 58 ++++- .../AdminKuaishouCloudFulfillmentView.vue | 223 +++++++++++++++++- 18 files changed, 997 insertions(+), 31 deletions(-) create mode 100644 apps/backend/src/services/order/cloudtentacles-match-utils.ts create mode 100644 apps/backend/src/services/order/cloudtentacles-override-rule-service.test.ts create mode 100644 apps/backend/src/services/order/cloudtentacles-override-rule-service.ts diff --git a/apps/backend/src/routes/admin/platform-config/cloudtentacles.ts b/apps/backend/src/routes/admin/platform-config/cloudtentacles.ts index b311c9d4..7edd814d 100644 --- a/apps/backend/src/routes/admin/platform-config/cloudtentacles.ts +++ b/apps/backend/src/routes/admin/platform-config/cloudtentacles.ts @@ -7,6 +7,7 @@ import { deleteAdminCloudtentaclesSource, fetchAdminCloudtentaclesVirtualNumberCode, generateAdminCloudtentaclesLoginCode, + getAdminCloudtentaclesOverrideRules, getAdminCloudtentaclesAsset, getAdminCloudtentaclesBindUrl, getAdminCloudtentaclesCategories, @@ -17,6 +18,7 @@ import { runAdminCloudtentaclesFullFlow, sendAdminCloudtentaclesSmsCode, testAdminCloudtentaclesLogin, + updateAdminCloudtentaclesOverrideRules, updateAdminCloudtentaclesSourceConfig, useAdminCloudtentaclesSku, validateAdminCloudtentaclesSession, @@ -25,6 +27,7 @@ import { import type { AdminCloudtentaclesCatalogQueryRouteBody, AdminCloudtentaclesFullFlowRouteBody, + AdminCloudtentaclesOverrideRuleConfigRouteBody, AdminCloudtentaclesSendSmsCodeRouteBody, AdminCloudtentaclesSkuBuyRouteBody, AdminCloudtentaclesSkuUseRouteBody, @@ -96,6 +99,43 @@ router.post( ) ); +router.get( + "/cloudtentacles/override-rules", + createJsonHandler(() => getAdminCloudtentaclesOverrideRules(), { + successMessage: "ok", + errorMessage: "读取 cloudtentacles 覆盖规则失败", + scope: "[admin/platform-config/cloudtentacles/override-rules]", + }) +); + +router.post( + "/cloudtentacles/override-rules", + createJsonHandler( + (req) => + updateAdminCloudtentaclesOverrideRules( + req.body as AdminCloudtentaclesOverrideRuleConfigRouteBody + ), + { + successMessage: "cloudtentacles 覆盖规则已保存", + errorMessage: "保存 cloudtentacles 覆盖规则失败", + scope: "[admin/platform-config/cloudtentacles/override-rules]", + audit: (_req, data) => { + const result = data as JsonRecord; + return { + action: "platform_cloudtentacles_override_rules_updated", + targetType: "platform_config", + targetId: "cloudtentacles_override_rules", + data: { + filePath: String(result.filePath || "").trim(), + enabled: result.enabled !== false, + ruleCount: Array.isArray(result.rules) ? result.rules.length : 0, + }, + }; + }, + } + ) +); + router.post( "/cloudtentacles/send-sms-code", createJsonHandler( diff --git a/apps/backend/src/services/admin/platform-config/cloudtentacles/index.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles/index.ts index a7df2746..aaf38242 100644 --- a/apps/backend/src/services/admin/platform-config/cloudtentacles/index.ts +++ b/apps/backend/src/services/admin/platform-config/cloudtentacles/index.ts @@ -39,6 +39,11 @@ import { verifyCloudtentaclesLoginCode, } from "../../../platforms/cloudtentacles/virtual-number-service.js"; import { runCloudtentaclesFullDebugFlow } from "../../../platforms/cloudtentacles/debug-flow-service.js"; +import { + getCloudtentaclesOverrideRuleConfig, + getCloudtentaclesOverrideRuleFilePath, + saveCloudtentaclesOverrideRuleConfig, +} from "../../../order/cloudtentacles-override-rule-service.js"; import { hasCloudtentaclesCredentialContextChanged, pickFirstNonEmpty, @@ -62,6 +67,7 @@ import type { AdminCloudtentaclesCatalogQueryInput, AdminCloudtentaclesDeliveryRecordQueryInput, AdminCloudtentaclesFullFlowInput, + AdminCloudtentaclesOverrideRuleConfigInput, AdminCloudtentaclesSendSmsCodeInput, AdminCloudtentaclesSkuBuyInput, AdminCloudtentaclesSkuUseInput, @@ -115,6 +121,22 @@ export function listAdminCloudtentaclesRecordSources() { }; } +export function getAdminCloudtentaclesOverrideRules() { + return { + filePath: getCloudtentaclesOverrideRuleFilePath(), + ...getCloudtentaclesOverrideRuleConfig(), + }; +} + +export function updateAdminCloudtentaclesOverrideRules( + payload: AdminCloudtentaclesOverrideRuleConfigInput = {} +) { + return { + filePath: getCloudtentaclesOverrideRuleFilePath(), + ...saveCloudtentaclesOverrideRuleConfig(payload), + }; +} + export function updateAdminCloudtentaclesSourceConfig( payload: AdminCloudtentaclesSourceConfigInput = {} ) { diff --git a/apps/backend/src/services/order/cloudtentacles-match-utils.ts b/apps/backend/src/services/order/cloudtentacles-match-utils.ts new file mode 100644 index 00000000..7fabd04d --- /dev/null +++ b/apps/backend/src/services/order/cloudtentacles-match-utils.ts @@ -0,0 +1,9 @@ +export function normalizeCloudtentaclesMatchName(value: unknown) { + return String(value || '') + .toLowerCase() + .replace(/[【】\[\]()()]/g, ' ') + .replace(/(自动发货|秒发|极速发货|官方直充|官方充值)/gi, ' ') + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .replace(/\s+/g, ' ') + .trim() +} diff --git a/apps/backend/src/services/order/cloudtentacles-name-match-service.test.ts b/apps/backend/src/services/order/cloudtentacles-name-match-service.test.ts index 7056dc91..04d24d70 100644 --- a/apps/backend/src/services/order/cloudtentacles-name-match-service.test.ts +++ b/apps/backend/src/services/order/cloudtentacles-name-match-service.test.ts @@ -57,3 +57,91 @@ test('resolveCloudtentaclesSkuByProductName matches SKU name with logged-in sour assert.deepEqual(result?.cloudSourceKeys, ['account-a']) assert.equal(result?.matchMode, 'cloudtentacles_name') }) + +test('resolveCloudtentaclesSkuByProductName prefers override rule with multiple delivery items', async () => { + const result = await resolveCloudtentaclesSkuByProductName('套装1', { + listCloudtentaclesSources: () => ({ + enabled: true, + sources: [ + { + key: 'account-a', + label: '账号 A', + enabled: true, + baseUrl: 'https://cloud.example.com', + deviceId: '-', + deviceType: 0, + }, + ], + }), + getCloudtentaclesSessionStateByKey: () => ({ + token: 'token-a', + baseUrl: 'https://cloud.example.com', + username: 'user-a', + phone: '', + loggedInAt: '2026-05-27T12:00:00.000Z', + deviceId: '-', + deviceType: 0, + }), + listCloudtentaclesSku: async () => ({ + baseUrl: 'https://cloud.example.com', + itemCount: 3, + rawItems: [], + items: [ + { + id: 28, + name: '商品A', + inventory: 10, + price: 360, + }, + { + id: 29, + name: '商品B', + inventory: 20, + price: 888, + }, + { + id: 30, + name: '套装1', + inventory: 1, + price: 999, + }, + ], + }), + resolveCloudtentaclesOverrideRule: () => ({ + id: 'combo-1', + enabled: true, + productName: '套装1', + normalizedProductName: normalizeCloudtentaclesMatchName('套装1'), + sourceKey: '', + deliveryItems: [ + { + cloudSkuId: 28, + cloudSkuName: '商品A', + quantity: 1, + }, + { + cloudSkuId: 0, + cloudSkuName: '商品B', + quantity: 20, + }, + ], + notes: '', + }), + }) + + assert.equal(result?.matchMode, 'cloudtentacles_override') + assert.equal(result?.cloudSkuId, 28) + assert.equal(result?.cloudSkuName, '商品A') + assert.deepEqual(result?.deliveryItems, [ + { + cloudSkuId: 28, + cloudSkuName: '商品A', + quantity: 1, + }, + { + cloudSkuId: 29, + cloudSkuName: '商品B', + quantity: 20, + }, + ]) +}) diff --git a/apps/backend/src/services/order/cloudtentacles-name-match-service.ts b/apps/backend/src/services/order/cloudtentacles-name-match-service.ts index 425e0156..de66cd6e 100644 --- a/apps/backend/src/services/order/cloudtentacles-name-match-service.ts +++ b/apps/backend/src/services/order/cloudtentacles-name-match-service.ts @@ -7,6 +7,11 @@ import { import { listCloudtentaclesSku, } from '../platforms/cloudtentacles/catalog-service.js' +import { + resolveCloudtentaclesOverrideRule, + type CloudtentaclesOverrideRule, +} from './cloudtentacles-override-rule-service.js' +import { normalizeCloudtentaclesMatchName } from './cloudtentacles-match-utils.js' type JsonObject = Record @@ -38,10 +43,17 @@ type CloudtentaclesNameMatchDeps = { listCloudtentaclesSources?: typeof listCloudtentaclesSources getCloudtentaclesSessionStateByKey?: typeof getCloudtentaclesSessionStateByKey listCloudtentaclesSku?: typeof listCloudtentaclesSku + resolveCloudtentaclesOverrideRule?: typeof resolveCloudtentaclesOverrideRule +} + +export type CloudtentaclesResolvedDeliveryItem = { + cloudSkuId: number + cloudSkuName: string + quantity: number } export type CloudtentaclesNameMatchResult = { - matchMode: 'cloudtentacles_name' + matchMode: 'cloudtentacles_name' | 'cloudtentacles_override' productName: string normalizedProductName: string cloudSkuId: number @@ -50,6 +62,7 @@ export type CloudtentaclesNameMatchResult = { cloudSkuInventory: number cloudSourceKeys: string[] resolvedSourceKey: string + deliveryItems: CloudtentaclesResolvedDeliveryItem[] skuSnapshot: JsonObject } @@ -65,6 +78,7 @@ export async function resolveCloudtentaclesSkuByProductName( const listSources = deps.listCloudtentaclesSources || listCloudtentaclesSources const getSessionByKey = deps.getCloudtentaclesSessionStateByKey || getCloudtentaclesSessionStateByKey const listSku = deps.listCloudtentaclesSku || listCloudtentaclesSku + const resolveOverrideRule = deps.resolveCloudtentaclesOverrideRule || resolveCloudtentaclesOverrideRule const sourcesConfig = listSources() if (sourcesConfig.enabled === false) { @@ -79,6 +93,14 @@ export async function resolveCloudtentaclesSkuByProductName( return null } + const overrideRule = resolveOverrideRule(productName) + const overrideMatch = overrideRule + ? await resolveOverrideMatch(productName, normalizedProductName, overrideRule, sourceContexts, listSku) + : null + if (overrideMatch) { + return overrideMatch + } + const cloudSourceKeys = sourceContexts.map((context) => context.sourceKey) for (const context of sourceContexts) { @@ -109,6 +131,13 @@ export async function resolveCloudtentaclesSkuByProductName( cloudSkuInventory: Number(matchedSku.inventory || 0) || 0, cloudSourceKeys, resolvedSourceKey: context.sourceKey, + deliveryItems: [ + { + cloudSkuId, + cloudSkuName, + quantity: 1, + }, + ], skuSnapshot: { ...matchedSku }, } } catch { @@ -119,16 +148,6 @@ export async function resolveCloudtentaclesSkuByProductName( return null } -export function normalizeCloudtentaclesMatchName(value: unknown) { - return String(value || '') - .toLowerCase() - .replace(/[【】\[\]()()]/g, ' ') - .replace(/(自动发货|秒发|极速发货|官方直充|官方充值)/gi, ' ') - .replace(/[^\p{L}\p{N}]+/gu, ' ') - .replace(/\s+/g, ' ') - .trim() -} - function buildCloudtentaclesSourceContext( source: CloudtentaclesSource, getSessionByKey: typeof getCloudtentaclesSessionStateByKey, @@ -163,3 +182,103 @@ function findCloudtentaclesSkuByName(items: CloudtentaclesSku[], productName: un null ) } + +async function resolveOverrideMatch( + productName: unknown, + normalizedProductName: string, + rule: CloudtentaclesOverrideRule, + sourceContexts: Array>>, + listSku: typeof listCloudtentaclesSku, +): Promise { + const candidates = rule.sourceKey + ? sourceContexts.filter((context) => context.sourceKey === rule.sourceKey) + : sourceContexts + + if (candidates.length === 0) { + return null + } + + for (const context of candidates) { + try { + const skuList = await listSku(context) + const items = Array.isArray(skuList.items) ? skuList.items : [] + const deliveryItems = resolveOverrideDeliveryItems(rule, items) + + if (deliveryItems.length !== rule.deliveryItems.length || !deliveryItems[0]) { + continue + } + + const firstDeliveryItem = deliveryItems[0] + const firstSku = items.find((item) => Number(item.id || 0) === firstDeliveryItem.cloudSkuId) || {} + const cloudSourceKeys = rule.sourceKey + ? [context.sourceKey] + : sourceContexts.map((sourceContext) => sourceContext.sourceKey) + + return { + matchMode: 'cloudtentacles_override', + productName: String(productName || '').trim(), + normalizedProductName, + cloudSkuId: firstDeliveryItem.cloudSkuId, + cloudSkuName: firstDeliveryItem.cloudSkuName, + cloudSkuPrice: Number(firstSku.price || 0) || 0, + cloudSkuInventory: Number(firstSku.inventory || 0) || 0, + cloudSourceKeys, + resolvedSourceKey: context.sourceKey, + deliveryItems, + skuSnapshot: { + overrideRuleId: rule.id, + overrideRuleProductName: rule.productName, + deliveryItems, + firstSku, + }, + } + } catch { + // 当前账号不可用时继续尝试下一个已登录账号。 + } + } + + return null +} + +function resolveOverrideDeliveryItems(rule: CloudtentaclesOverrideRule, skuItems: CloudtentaclesSku[]) { + const resolvedItems: CloudtentaclesResolvedDeliveryItem[] = [] + + for (const ruleItem of rule.deliveryItems) { + const matchedSku = ruleItem.cloudSkuId > 0 + ? skuItems.find((item) => Number(item.id || 0) === ruleItem.cloudSkuId) + : findCloudtentaclesSkuByName(skuItems, ruleItem.cloudSkuName) + const cloudSkuId = Number(matchedSku?.id || 0) + const cloudSkuName = String(matchedSku?.name || '').trim() + + if (!Number.isInteger(cloudSkuId) || cloudSkuId <= 0 || !cloudSkuName) { + return [] + } + + resolvedItems.push({ + cloudSkuId, + cloudSkuName, + quantity: Math.max(1, Number(ruleItem.quantity || 1)), + }) + } + + return mergeResolvedDeliveryItems(resolvedItems) +} + +function mergeResolvedDeliveryItems(items: CloudtentaclesResolvedDeliveryItem[]) { + const merged = new Map() + + for (const item of items) { + const existing = merged.get(item.cloudSkuId) + if (existing) { + existing.quantity += item.quantity + existing.cloudSkuName = existing.cloudSkuName || item.cloudSkuName + continue + } + + merged.set(item.cloudSkuId, { ...item }) + } + + return Array.from(merged.values()) +} + +export { normalizeCloudtentaclesMatchName } diff --git a/apps/backend/src/services/order/cloudtentacles-override-rule-service.test.ts b/apps/backend/src/services/order/cloudtentacles-override-rule-service.test.ts new file mode 100644 index 00000000..50306db4 --- /dev/null +++ b/apps/backend/src/services/order/cloudtentacles-override-rule-service.test.ts @@ -0,0 +1,70 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + normalizeCloudtentaclesOverrideRuleConfig, +} from './cloudtentacles-override-rule-service.js' + +test('normalizeCloudtentaclesOverrideRuleConfig merges duplicated delivery items', () => { + const config = normalizeCloudtentaclesOverrideRuleConfig({ + enabled: true, + rules: [ + { + productName: '套装1', + deliveryItems: [ + { + cloudSkuId: 28, + cloudSkuName: '商品A', + quantity: 1, + }, + { + cloudSkuId: 28, + cloudSkuName: '商品A', + quantity: 2, + }, + { + cloudSkuName: '商品B', + quantity: 20, + }, + ], + }, + ], + }) + + assert.equal(config.enabled, true) + assert.equal(config.rules.length, 1) + assert.deepEqual(config.rules[0]?.deliveryItems, [ + { + cloudSkuId: 28, + cloudSkuName: '商品A', + quantity: 3, + }, + { + cloudSkuId: 0, + cloudSkuName: '商品B', + quantity: 20, + }, + ]) +}) + +test('normalizeCloudtentaclesOverrideRuleConfig drops invalid rules', () => { + const config = normalizeCloudtentaclesOverrideRuleConfig({ + rules: [ + { + productName: '', + deliveryItems: [ + { + cloudSkuId: 28, + quantity: 1, + }, + ], + }, + { + productName: '有效商品', + deliveryItems: [], + }, + ], + }) + + assert.deepEqual(config.rules, []) +}) diff --git a/apps/backend/src/services/order/cloudtentacles-override-rule-service.ts b/apps/backend/src/services/order/cloudtentacles-override-rule-service.ts new file mode 100644 index 00000000..8c04b2c4 --- /dev/null +++ b/apps/backend/src/services/order/cloudtentacles-override-rule-service.ts @@ -0,0 +1,173 @@ +import path from 'node:path' + +import { PROJECT_ROOT } from '../../config/runtime.js' +import { readJsonFile, writeJsonFile } from '../../utils/json-file-store.js' +import { normalizeCloudtentaclesMatchName } from './cloudtentacles-match-utils.js' + +const CLOUDTENTACLES_OVERRIDE_RULE_FILE_PATH = path.join( + PROJECT_ROOT, + 'data', + 'cloudtentacles-override-rules.json', +) + +type JsonObject = Record + +export type CloudtentaclesOverrideDeliveryItem = { + cloudSkuId: number + cloudSkuName: string + quantity: number +} + +export type CloudtentaclesOverrideRule = { + id: string + enabled: boolean + productName: string + normalizedProductName: string + sourceKey: string + deliveryItems: CloudtentaclesOverrideDeliveryItem[] + notes: string +} + +export type CloudtentaclesOverrideRuleConfig = { + enabled: boolean + rules: CloudtentaclesOverrideRule[] +} + +export function getCloudtentaclesOverrideRuleFilePath() { + return CLOUDTENTACLES_OVERRIDE_RULE_FILE_PATH +} + +export function getCloudtentaclesOverrideRuleConfig(): CloudtentaclesOverrideRuleConfig { + return readJsonFile( + CLOUDTENTACLES_OVERRIDE_RULE_FILE_PATH, + createDefaultCloudtentaclesOverrideRuleConfig, + normalizeCloudtentaclesOverrideRuleConfig, + ) +} + +export function saveCloudtentaclesOverrideRuleConfig(rawValue: unknown): CloudtentaclesOverrideRuleConfig { + return writeJsonFile( + CLOUDTENTACLES_OVERRIDE_RULE_FILE_PATH, + rawValue, + normalizeCloudtentaclesOverrideRuleConfig, + ) +} + +export function resolveCloudtentaclesOverrideRule(productName: unknown) { + const config = getCloudtentaclesOverrideRuleConfig() + if (config.enabled === false) { + return null + } + + const normalizedProductName = normalizeCloudtentaclesMatchName(productName) + if (!normalizedProductName) { + return null + } + + return config.rules.find((rule) => + rule.enabled !== false && + rule.normalizedProductName === normalizedProductName && + rule.deliveryItems.length > 0 + ) || null +} + +export function normalizeCloudtentaclesOverrideRuleConfig(rawValue: unknown): CloudtentaclesOverrideRuleConfig { + const source = isPlainObject(rawValue) ? rawValue : {} + const rules = Array.isArray(source.rules) ? source.rules : [] + + return { + enabled: source.enabled !== false, + rules: rules + .map((item: unknown) => normalizeCloudtentaclesOverrideRule(item)) + .filter((item): item is CloudtentaclesOverrideRule => Boolean(item)), + } +} + +function normalizeCloudtentaclesOverrideRule(rawValue: unknown): CloudtentaclesOverrideRule | null { + if (!isPlainObject(rawValue)) { + return null + } + + const productName = String(rawValue.productName || rawValue.name || '').trim() + const normalizedProductName = normalizeCloudtentaclesMatchName(productName) + const deliveryItems = normalizeDeliveryItems(rawValue.deliveryItems) + + if (!productName || !normalizedProductName || deliveryItems.length === 0) { + return null + } + + return { + id: String(rawValue.id || normalizedProductName).trim() || normalizedProductName, + enabled: rawValue.enabled !== false, + productName, + normalizedProductName, + sourceKey: String(rawValue.sourceKey || '').trim(), + deliveryItems, + notes: String(rawValue.notes || '').trim(), + } +} + +function normalizeDeliveryItems(value: unknown) { + const rawItems = Array.isArray(value) ? value : [] + const items = rawItems + .map((item) => normalizeDeliveryItem(item)) + .filter((item): item is CloudtentaclesOverrideDeliveryItem => Boolean(item)) + + return mergeDeliveryItems(items) +} + +function normalizeDeliveryItem(value: unknown): CloudtentaclesOverrideDeliveryItem | null { + const source = isPlainObject(value) ? value : {} + const cloudSkuId = normalizePositiveInteger(source.cloudSkuId || source.skuId, 0) + const cloudSkuName = String(source.cloudSkuName || source.skuName || '').trim() + const quantity = normalizePositiveInteger(source.quantity, 1) + + if (!cloudSkuId && !cloudSkuName) { + return null + } + + return { + cloudSkuId, + cloudSkuName, + quantity, + } +} + +function mergeDeliveryItems(items: CloudtentaclesOverrideDeliveryItem[]) { + const merged = new Map() + + for (const item of items) { + const key = item.cloudSkuId > 0 ? `id:${item.cloudSkuId}` : `name:${normalizeCloudtentaclesMatchName(item.cloudSkuName)}` + const existing = merged.get(key) + if (existing) { + existing.quantity += item.quantity + existing.cloudSkuName = existing.cloudSkuName || item.cloudSkuName + existing.cloudSkuId = existing.cloudSkuId || item.cloudSkuId + continue + } + + merged.set(key, { ...item }) + } + + return Array.from(merged.values()) +} + +function normalizePositiveInteger(value: unknown, fallback: number) { + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed <= 0) { + return fallback + } + + return parsed +} + +function createDefaultCloudtentaclesOverrideRuleConfig(): CloudtentaclesOverrideRuleConfig { + return { + enabled: true, + rules: [], + } +} + +function isPlainObject(value: unknown): value is JsonObject { + return Object.prototype.toString.call(value) === '[object Object]' +} 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 1fcccd98..7eddf458 100644 --- a/apps/backend/src/services/order/delivery-task-service.test.ts +++ b/apps/backend/src/services/order/delivery-task-service.test.ts @@ -194,3 +194,92 @@ test('syncDeliveryTasksForOrderWithDeps creates kuaishou cloud task from cloudte assert.equal(result[0]?.claim_token, 'claim-token') assert.equal(updates[0]?.patch.claim_token, 'claim-token') }) + +test('syncDeliveryTasksForOrderWithDeps keeps override delivery items and quantities', async () => { + const createdTasks = [] + + const dynamicOrderItems = [ + { + id: 22, + order_id: 10, + sku_code: '套装1', + sku_name: '套装1', + quantity: 1, + spec_json: '{}', + item_snapshot_json: JSON.stringify({ + matchMode: 'cloudtentacles_override', + cloudtentacles: { + matchMode: 'cloudtentacles_override', + normalizedProductName: '套装1', + cloudSkuId: 28, + cloudSkuName: '商品A', + cloudSourceKeys: ['account-a'], + resolvedSourceKey: 'account-a', + deliveryItems: [ + { + cloudSkuId: 28, + cloudSkuName: '商品A', + quantity: 1, + }, + { + cloudSkuId: 29, + cloudSkuName: '商品B', + quantity: 20, + }, + ], + }, + }), + }, + ] + + await syncDeliveryTasksForOrderWithDeps(paidOrder, dynamicOrderItems, { + listTasksByOrderId: async () => [], + getFulfillmentProfileByKey: async () => ({ + id: 2, + profile_key: 'kuaishou_ct_assisted', + name: '快手 cloud 履约', + executor_key: 'kuaishou_ct_assisted', + requires_claim: false, + auto_dispatch: false, + config_json: '{}', + }), + createTask: async (input) => { + createdTasks.push(input) + return { + id: 32, + order_id: input.orderId, + order_item_id: input.orderItemId, + task_status: input.taskStatus, + executor_key: input.executorKey, + requires_claim: input.requiresClaim, + claim_token: '', + primary_claim_token_id: null, + last_error: '', + context_json: input.contextJson, + } + }, + createTaskClaimToken: async () => ({ + token: 'claim-token', + expired_at: '2026-04-15T12:00:00.000Z', + }), + updateTask: async (taskId, patch) => ({ id: taskId, ...patch }), + nowIso: () => '2026-04-14T12:04:00.000Z', + randomId: () => 'DT-OVERRIDE', + }) + + const context = JSON.parse(createdTasks[0].contextJson) + assert.equal(context.kuaishouCloudFulfillment.binding.skuId, 28) + assert.equal(context.kuaishouCloudFulfillment.binding.resolvedSourceKey, 'account-a') + assert.deepEqual(context.kuaishouCloudFulfillment.deliveryItems, [ + { + cloudSkuId: 28, + cloudSkuName: '商品A', + quantity: 1, + }, + { + cloudSkuId: 29, + cloudSkuName: '商品B', + quantity: 20, + }, + ]) +}) diff --git a/apps/backend/src/services/order/delivery-task-service.ts b/apps/backend/src/services/order/delivery-task-service.ts index fc57f6cf..a19d0553 100644 --- a/apps/backend/src/services/order/delivery-task-service.ts +++ b/apps/backend/src/services/order/delivery-task-service.ts @@ -180,6 +180,7 @@ export async function syncDeliveryTasksForOrderWithDeps( binding: { prepareStatus: 'pending', cloudSourceKeys: normalizeStringArray(cloudtentaclesConfig.cloudSourceKeys), + resolvedSourceKey: String(cloudtentaclesConfig.resolvedSourceKey || '').trim(), skuId: primaryDeliveryItem.cloudSkuId, skuName: primaryDeliveryItem.cloudSkuName, vnKey: '1', @@ -384,11 +385,19 @@ async function resolveDynamicCloudtentaclesProfile( ): Promise { const snapshot = parseJsonObject(item.item_snapshot_json) const cloudtentacles = parseJsonObject(snapshot.cloudtentacles) - const cloudSkuId = Number(cloudtentacles.cloudSkuId || 0) || 0 - const cloudSkuName = String(cloudtentacles.cloudSkuName || item.sku_name || '').trim() const cloudSourceKeys = normalizeStringArray(cloudtentacles.cloudSourceKeys) + const deliveryItems = normalizeCloudDeliveryItems({ + deliveryItems: cloudtentacles.deliveryItems, + skuId: cloudtentacles.cloudSkuId, + skuName: cloudtentacles.cloudSkuName || item.sku_name, + }) + const primaryDeliveryItem = deliveryItems[0] || { + cloudSkuId: 0, + cloudSkuName: '', + quantity: 1, + } - if (!cloudSkuId || !cloudSkuName || cloudSourceKeys.length === 0) { + if (!primaryDeliveryItem.cloudSkuId || !primaryDeliveryItem.cloudSkuName || cloudSourceKeys.length === 0) { return null } @@ -407,18 +416,13 @@ async function resolveDynamicCloudtentaclesProfile( auto_dispatch: false, config_json: { flowType: 'kuaishou_cloud_fulfillment', - configId: `cloudtentacles_name:${cloudSkuId}`, + configId: `${String(cloudtentacles.matchMode || 'cloudtentacles_name').trim()}:${String(cloudtentacles.normalizedProductName || primaryDeliveryItem.cloudSkuId).trim()}`, cloudtentacles: { cloudSourceKeys, - skuId: cloudSkuId, - skuName: cloudSkuName, - deliveryItems: [ - { - cloudSkuId, - cloudSkuName, - quantity: 1, - }, - ], + skuId: primaryDeliveryItem.cloudSkuId, + skuName: primaryDeliveryItem.cloudSkuName, + resolvedSourceKey: String(cloudtentacles.resolvedSourceKey || '').trim(), + deliveryItems, vnKey: '1', autoBuyEnabled: true, minAssetReserve: 0, @@ -429,7 +433,9 @@ async function resolveDynamicCloudtentaclesProfile( shopName: '', autoConsumeAfterDispatch: false, }, - notes: '91卡券商品名自动匹配 cloudtentacles 商品', + notes: cloudtentacles.matchMode === 'cloudtentacles_override' + ? '91卡券商品名命中 cloudtentacles 覆盖规则' + : '91卡券商品名自动匹配 cloudtentacles 商品', }, } } diff --git a/apps/backend/src/services/order/product-match-service.ts b/apps/backend/src/services/order/product-match-service.ts index 801e23a6..4802415d 100644 --- a/apps/backend/src/services/order/product-match-service.ts +++ b/apps/backend/src/services/order/product-match-service.ts @@ -96,6 +96,7 @@ export async function resolveOrderItemForFulfillment({ cloudSkuInventory: cloudtentaclesNameMatch.cloudSkuInventory, cloudSourceKeys: cloudtentaclesNameMatch.cloudSourceKeys, resolvedSourceKey: cloudtentaclesNameMatch.resolvedSourceKey, + deliveryItems: cloudtentaclesNameMatch.deliveryItems, skuSnapshot: cloudtentaclesNameMatch.skuSnapshot, } : null, diff --git a/apps/backend/src/types/admin/route-inputs.ts b/apps/backend/src/types/admin/route-inputs.ts index 6d03f73e..ba0e22e8 100644 --- a/apps/backend/src/types/admin/route-inputs.ts +++ b/apps/backend/src/types/admin/route-inputs.ts @@ -7,6 +7,7 @@ import type { AdminCloudtentaclesCatalogQueryInput, AdminCloudtentaclesDeliveryRecordQueryInput, AdminCloudtentaclesFullFlowInput, + AdminCloudtentaclesOverrideRuleConfigInput, AdminCloudtentaclesSendSmsCodeInput, AdminCloudtentaclesSkuBuyInput, AdminCloudtentaclesSkuUseInput, @@ -37,6 +38,7 @@ export type AdminKuaishouEticketDetailQueryRouteBody = AdminKuaishouEticketDetai export type AdminKuaishouEticketConsumeRouteBody = AdminKuaishouEticketConsumeInput export type AdminKuaishouEticketShopInfoRouteBody = AdminKuaishouEticketShopInfoInput export type AdminCloudtentaclesSourceConfigRouteBody = AdminCloudtentaclesSourceConfigInput +export type AdminCloudtentaclesOverrideRuleConfigRouteBody = AdminCloudtentaclesOverrideRuleConfigInput export type AdminCloudtentaclesSendSmsCodeRouteBody = AdminCloudtentaclesSendSmsCodeInput export type AdminCloudtentaclesTestLoginRouteBody = AdminCloudtentaclesTestLoginInput export type AdminCloudtentaclesValidateSessionRouteBody = AdminCloudtentaclesValidateSessionInput diff --git a/apps/backend/src/types/admin/write-inputs.ts b/apps/backend/src/types/admin/write-inputs.ts index 5cdf143b..d2ccf678 100644 --- a/apps/backend/src/types/admin/write-inputs.ts +++ b/apps/backend/src/types/admin/write-inputs.ts @@ -108,6 +108,26 @@ export type AdminCloudtentaclesSourceConfigInput = { sources?: AdminCloudtentaclesSourceConfigInput[] } +export type AdminCloudtentaclesOverrideDeliveryItemInput = { + cloudSkuId?: number | string + cloudSkuName?: string + quantity?: number | string +} + +export type AdminCloudtentaclesOverrideRuleInput = { + id?: string + enabled?: boolean + productName?: string + sourceKey?: string + deliveryItems?: AdminCloudtentaclesOverrideDeliveryItemInput[] + notes?: string +} + +export type AdminCloudtentaclesOverrideRuleConfigInput = { + enabled?: boolean + rules?: AdminCloudtentaclesOverrideRuleInput[] +} + export type AdminCloudtentaclesSendSmsCodeInput = { sourceKey?: string baseUrl?: string diff --git a/apps/frontend/src/services/admin/platform-config/cloudtentacles.ts b/apps/frontend/src/services/admin/platform-config/cloudtentacles.ts index 01fc54a0..2df066bd 100644 --- a/apps/frontend/src/services/admin/platform-config/cloudtentacles.ts +++ b/apps/frontend/src/services/admin/platform-config/cloudtentacles.ts @@ -2,6 +2,7 @@ import { apiDelete, apiGet, apiPost } from '@/lib/http' import type { AdminCloudtentaclesLoginTestResult, AdminCloudtentaclesDeliveryRecordListResult, + AdminCloudtentaclesOverrideRuleConfig, AdminCloudtentaclesSkuListResult, AdminCloudtentaclesSendSmsResult, AdminCloudtentaclesSourceConfigResponse, @@ -33,6 +34,35 @@ export function deleteAdminCloudtentaclesSource(sourceKey: string) { ) } +export function fetchAdminCloudtentaclesOverrideRules() { + return apiGet( + '/api/v1/admin/platform-config/cloudtentacles/override-rules', + ) +} + +export function saveAdminCloudtentaclesOverrideRules( + payload: { + enabled: boolean + rules: Array<{ + id?: string + enabled?: boolean + productName?: string + sourceKey?: string + deliveryItems?: Array<{ + cloudSkuId?: number + cloudSkuName?: string + quantity?: number + }> + notes?: string + }> + }, +) { + return apiPost( + '/api/v1/admin/platform-config/cloudtentacles/override-rules', + payload, + ) +} + export function sendAdminCloudtentaclesSmsCode(payload: { baseUrl?: string username?: string diff --git a/apps/frontend/src/types/admin/index.ts b/apps/frontend/src/types/admin/index.ts index a9628de3..9ace322b 100644 --- a/apps/frontend/src/types/admin/index.ts +++ b/apps/frontend/src/types/admin/index.ts @@ -54,6 +54,9 @@ export type { AdminCloudtentaclesSourceItem, AdminCloudtentaclesSourcesConfig, AdminCloudtentaclesSourceConfigResponse, + AdminCloudtentaclesOverrideDeliveryItem, + AdminCloudtentaclesOverrideRule, + AdminCloudtentaclesOverrideRuleConfig, AdminCloudtentaclesSessionsMap, AdminCloudtentaclesSourceConfig, AdminCloudtentaclesPersistedSession, diff --git a/apps/frontend/src/types/admin/platform-config/cloudtentacles.ts b/apps/frontend/src/types/admin/platform-config/cloudtentacles.ts index 572ba21d..b6b8e094 100644 --- a/apps/frontend/src/types/admin/platform-config/cloudtentacles.ts +++ b/apps/frontend/src/types/admin/platform-config/cloudtentacles.ts @@ -41,6 +41,28 @@ export interface AdminCloudtentaclesSourceConfigResponse { sessions: AdminCloudtentaclesSessionsMap } +export interface AdminCloudtentaclesOverrideDeliveryItem { + cloudSkuId: number + cloudSkuName: string + quantity: number +} + +export interface AdminCloudtentaclesOverrideRule { + id: string + enabled: boolean + productName: string + normalizedProductName: string + sourceKey: string + deliveryItems: AdminCloudtentaclesOverrideDeliveryItem[] + notes: string +} + +export interface AdminCloudtentaclesOverrideRuleConfig { + filePath: string + enabled: boolean + rules: AdminCloudtentaclesOverrideRule[] +} + export interface AdminCloudtentaclesSendSmsResult { baseUrl: string username: string diff --git a/apps/frontend/src/types/admin/platform-config/index.ts b/apps/frontend/src/types/admin/platform-config/index.ts index a09f09bb..7927b341 100644 --- a/apps/frontend/src/types/admin/platform-config/index.ts +++ b/apps/frontend/src/types/admin/platform-config/index.ts @@ -34,6 +34,9 @@ export type { AdminCloudtentaclesSourceItem, AdminCloudtentaclesSourcesConfig, AdminCloudtentaclesSourceConfigResponse, + AdminCloudtentaclesOverrideDeliveryItem, + AdminCloudtentaclesOverrideRule, + AdminCloudtentaclesOverrideRuleConfig, AdminCloudtentaclesSessionsMap, AdminCloudtentaclesSourceConfig, AdminCloudtentaclesPersistedSession, diff --git a/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/AdminKuaishouCloudFulfillment.css b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/AdminKuaishouCloudFulfillment.css index 88a7ccb4..f229302f 100644 --- a/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/AdminKuaishouCloudFulfillment.css +++ b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/AdminKuaishouCloudFulfillment.css @@ -9,7 +9,7 @@ .fulfillment-overview { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)) minmax(120px, auto); + grid-template-columns: repeat(4, minmax(0, 1fr)) minmax(120px, auto); gap: var(--space-3); align-items: stretch; } @@ -182,15 +182,67 @@ word-break: break-all; } +.override-panel { + display: grid; + gap: var(--space-4); + min-width: 0; + padding-top: var(--space-2); +} + +.override-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + justify-content: flex-end; + align-items: center; +} + +.override-rule-list { + display: grid; + gap: var(--space-3); +} + +.override-rule-item { + display: grid; + gap: var(--space-3); + min-width: 0; + padding: var(--space-3); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--bg-surface); +} + +.override-rule-head { + display: grid; + grid-template-columns: auto minmax(220px, 1fr) minmax(160px, 220px) auto; + gap: var(--space-2); + align-items: center; +} + +.delivery-item-list { + display: grid; + gap: var(--space-2); +} + +.delivery-item-row { + display: grid; + grid-template-columns: minmax(220px, 1.1fr) minmax(180px, 1fr) 120px auto; + gap: var(--space-2); + align-items: center; +} + @media (max-width: 960px) { .fulfillment-overview, .fulfillment-layout, - .match-checker { + .match-checker, + .override-rule-head, + .delivery-item-row { grid-template-columns: 1fr; } .overview-actions, - .catalog-toolbar { + .catalog-toolbar, + .override-actions { justify-content: stretch; } diff --git a/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/AdminKuaishouCloudFulfillmentView.vue b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/AdminKuaishouCloudFulfillmentView.vue index bf0cab04..7b7af0b3 100644 --- a/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/AdminKuaishouCloudFulfillmentView.vue +++ b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/AdminKuaishouCloudFulfillmentView.vue @@ -2,10 +2,14 @@ import { computed, onMounted, reactive, ref, watch } from 'vue' import { + fetchAdminCloudtentaclesOverrideRules, fetchAdminCloudtentaclesSkuList, fetchAdminCloudtentaclesSourceConfig, + saveAdminCloudtentaclesOverrideRules, } from '@/services/admin' import type { + AdminCloudtentaclesOverrideDeliveryItem, + AdminCloudtentaclesOverrideRule, AdminCloudtentaclesSessionItem, AdminCloudtentaclesSkuItem, AdminCloudtentaclesSourceItem, @@ -17,14 +21,23 @@ type MatchState = { input: string } +type EditableOverrideRule = Omit & { + normalizedProductName?: string +} + const loading = ref(true) const skuLoading = ref(false) +const overrideLoading = ref(false) +const overrideSaving = ref(false) const errorMessage = ref('') const skuErrorMessage = ref('') +const overrideErrorMessage = ref('') const selectedSourceKey = ref('') const sources = ref([]) const sessions = ref({}) const skuItems = ref([]) +const overrideEnabled = ref(true) +const overrideRules = ref([]) const matchState = reactive({ input: '', }) @@ -65,6 +78,8 @@ const matchedSku = computed(() => { ) }) +const overrideRuleCount = computed(() => overrideRules.value.length) + watch(selectedSourceKey, (sourceKey) => { if (sourceKey) { void loadSkuList(sourceKey) @@ -85,9 +100,16 @@ async function loadPage() { errorMessage.value = '' try { - const response = await fetchAdminCloudtentaclesSourceConfig() - sources.value = Array.isArray(response.data.sources) ? response.data.sources : [] - sessions.value = response.data.sessions || {} + const [sourceResponse, overrideResponse] = await Promise.all([ + fetchAdminCloudtentaclesSourceConfig(), + fetchAdminCloudtentaclesOverrideRules(), + ]) + sources.value = Array.isArray(sourceResponse.data.sources) ? sourceResponse.data.sources : [] + sessions.value = sourceResponse.data.sessions || {} + overrideEnabled.value = overrideResponse.data.enabled !== false + overrideRules.value = Array.isArray(overrideResponse.data.rules) + ? overrideResponse.data.rules.map((rule) => normalizeEditableRule(rule)) + : [] selectedSourceKey.value = resolveDefaultSourceKey() if (!selectedSourceKey.value) { @@ -100,6 +122,33 @@ async function loadPage() { } } +async function saveOverrideRules() { + overrideSaving.value = true + overrideErrorMessage.value = '' + + try { + const response = await saveAdminCloudtentaclesOverrideRules({ + enabled: overrideEnabled.value, + rules: overrideRules.value.map((rule) => ({ + id: rule.id, + enabled: rule.enabled, + productName: rule.productName, + sourceKey: rule.sourceKey, + deliveryItems: rule.deliveryItems, + notes: rule.notes, + })), + }) + overrideEnabled.value = response.data.enabled !== false + overrideRules.value = Array.isArray(response.data.rules) + ? response.data.rules.map((rule) => normalizeEditableRule(rule)) + : [] + } catch (error) { + overrideErrorMessage.value = error instanceof Error ? error.message : '保存 cloudtentacles 覆盖规则失败' + } finally { + overrideSaving.value = false + } +} + async function loadSkuList(sourceKey = selectedSourceKey.value) { if (!sourceKey) { skuItems.value = [] @@ -124,6 +173,45 @@ function refreshAll() { void loadPage() } +function addOverrideRule() { + overrideRules.value.unshift({ + id: createRuleId(), + enabled: true, + productName: matchState.input.trim(), + normalizedProductName: normalizeMatchName(matchState.input), + sourceKey: selectedSourceKey.value, + deliveryItems: [createDeliveryItem()], + notes: '', + }) +} + +function removeOverrideRule(index: number) { + overrideRules.value.splice(index, 1) +} + +function addDeliveryItem(rule: EditableOverrideRule) { + rule.deliveryItems.push(createDeliveryItem()) +} + +function removeDeliveryItem(rule: EditableOverrideRule, index: number) { + rule.deliveryItems.splice(index, 1) + if (rule.deliveryItems.length === 0) { + rule.deliveryItems.push(createDeliveryItem()) + } +} + +function handleRuleSourceChange(sourceKey: string) { + if (sourceKey) { + selectedSourceKey.value = sourceKey + } +} + +function handleDeliverySkuChange(item: AdminCloudtentaclesOverrideDeliveryItem, skuId: number) { + const sku = skuItems.value.find((current) => Number(current.id || 0) === Number(skuId || 0)) + item.cloudSkuId = Number(skuId || 0) || 0 + item.cloudSkuName = String(sku?.name || item.cloudSkuName || '').trim() +} + function resolveDefaultSourceKey() { const current = sourceRows.value.find((source) => source.key === selectedSourceKey.value) if (current?.ready) { @@ -156,6 +244,10 @@ function formatCloudPrice(value: unknown) { return Number.isFinite(price) ? String(price) : '-' } +function formatSkuOption(sku: AdminCloudtentaclesSkuItem) { + return `#${sku.id} ${sku.name}` +} + function getSourceTagType(source: { ready: boolean; enabled?: boolean }) { if (source.ready) return 'success' if (source.enabled === false) return 'info' @@ -168,6 +260,37 @@ function getSourceStatusText(source: { ready: boolean; enabled?: boolean; sessio if (!source.session?.hasToken) return '未登录' return '待检查' } + +function normalizeEditableRule(rule: AdminCloudtentaclesOverrideRule): EditableOverrideRule { + return { + id: String(rule.id || createRuleId()).trim(), + enabled: rule.enabled !== false, + productName: String(rule.productName || '').trim(), + normalizedProductName: String(rule.normalizedProductName || '').trim(), + sourceKey: String(rule.sourceKey || '').trim(), + deliveryItems: Array.isArray(rule.deliveryItems) && rule.deliveryItems.length > 0 + ? rule.deliveryItems.map((item) => ({ + cloudSkuId: Number(item.cloudSkuId || 0) || 0, + cloudSkuName: String(item.cloudSkuName || '').trim(), + quantity: Math.max(1, Number(item.quantity || 1) || 1), + })) + : [createDeliveryItem()], + notes: String(rule.notes || '').trim(), + } +} + +function createDeliveryItem(): AdminCloudtentaclesOverrideDeliveryItem { + const firstSku = skuItems.value[0] + return { + cloudSkuId: Number(firstSku?.id || 0) || 0, + cloudSkuName: String(firstSku?.name || '').trim(), + quantity: 1, + } +} + +function createRuleId() { + return `rule_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` +} + +
+
+
+

商品覆盖规则

+

套装、多数量商品会优先按这里发货;没有规则时继续同名匹配。

+
+
+ + 新增规则 + + 保存规则 + +
+
+ + + + + +
+
+
+ + + + + + + + 删除 + +
+ +
+
+ + + + + + + 删除 + +
+ 增加发货商品 +
+ + +
+
+