diff --git a/apps/backend/src/services/admin/platform-config/cloudtentacles/mappers.test.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles/mappers.test.ts index 3bb77f9d..1d2780cd 100644 --- a/apps/backend/src/services/admin/platform-config/cloudtentacles/mappers.test.ts +++ b/apps/backend/src/services/admin/platform-config/cloudtentacles/mappers.test.ts @@ -106,6 +106,7 @@ test('mapAdminKuaishouCloudFulfillmentSource normalizes items and defaults', () cloudSourceKeys: ['account_a', 'account_b'], cloudSkuId: 0, cloudSkuName: '', + deliveryItems: [], vnKey: '', autoBuyEnabled: true, minAssetReserve: 0, diff --git a/apps/backend/src/services/admin/platform-config/cloudtentacles/mappers.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles/mappers.ts index 557cf93f..8ccd6f45 100644 --- a/apps/backend/src/services/admin/platform-config/cloudtentacles/mappers.ts +++ b/apps/backend/src/services/admin/platform-config/cloudtentacles/mappers.ts @@ -62,6 +62,7 @@ export function mapAdminCloudtentaclesSession(session: JsonObject = {}) { } export function mapAdminKuaishouCloudFulfillmentItem(item: JsonObject = {}) { + const deliveryItems = normalizeDeliveryItems(item); return { id: String(item.id || "").trim(), enabled: item.enabled !== false, @@ -82,6 +83,7 @@ export function mapAdminKuaishouCloudFulfillmentItem(item: JsonObject = {}) { : [], cloudSkuId: Number(item.cloudSkuId || 0) || 0, cloudSkuName: String(item.cloudSkuName || "").trim(), + deliveryItems, vnKey: String(item.vnKey || "").trim(), autoBuyEnabled: item.autoBuyEnabled !== false, minAssetReserve: Number(item.minAssetReserve || 0) || 0, @@ -101,3 +103,42 @@ export function mapAdminKuaishouCloudFulfillmentSource(config: JsonObject = {}) ), }; } + +function normalizeDeliveryItems(item: JsonObject = {}) { + const rawItems = Array.isArray(item.deliveryItems) ? item.deliveryItems : []; + const normalizedItems = rawItems + .map((value) => normalizeDeliveryItem(value)) + .filter(Boolean); + + if (normalizedItems.length > 0) { + return normalizedItems; + } + + const cloudSkuId = Number(item.cloudSkuId || 0) || 0; + if (cloudSkuId <= 0) { + return []; + } + + return [ + { + cloudSkuId, + cloudSkuName: String(item.cloudSkuName || "").trim(), + quantity: 1, + }, + ]; +} + +function normalizeDeliveryItem(value: unknown) { + const source = value && typeof value === "object" ? value as JsonObject : {}; + const cloudSkuId = Number(source.cloudSkuId || source.skuId || 0) || 0; + if (cloudSkuId <= 0) { + return null; + } + + const quantity = Number(source.quantity || 1) || 1; + return { + cloudSkuId, + cloudSkuName: String(source.cloudSkuName || source.skuName || "").trim(), + quantity: Math.max(1, Math.round(quantity)), + }; +} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts index afcf4753..788f22fc 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts @@ -35,6 +35,7 @@ export function normalizeKuaishouCloudFlow(value: unknown) { source.consume && typeof source.consume === "object" ? source.consume : {}; const ticket = source.ticket && typeof source.ticket === "object" ? source.ticket : {}; + const deliveryItems = normalizeDeliveryItems(source, binding); const roleName = String(role.name || binding.roleName || "").trim(); const roleId = String(role.rid || binding.roleId || "").trim(); @@ -48,6 +49,7 @@ export function normalizeKuaishouCloudFlow(value: unknown) { configId: String(source.configId || "").trim(), internalSkuCode: String(source.internalSkuCode || "").trim(), internalSkuName: String(source.internalSkuName || "").trim(), + deliveryItems, ticket: { code: String(ticket.code || "").trim(), status: String(ticket.status || "pending").trim() || "pending", @@ -100,6 +102,7 @@ export function normalizeKuaishouCloudFlow(value: unknown) { assetBefore: Number(purchase.assetBefore || 0) || 0, assetAfter: Number(purchase.assetAfter || 0) || 0, purchaseAt: purchase.purchaseAt || null, + items: Array.isArray(purchase.items) ? purchase.items : [], }, dispatch: { status: String(dispatch.status || "pending").trim() || "pending", @@ -107,6 +110,7 @@ export function normalizeKuaishouCloudFlow(value: unknown) { dispatchBy: dispatch.dispatchBy || null, sendType: Number(dispatch.sendType || 0) || 0, note: String(dispatch.note || "").trim(), + items: Array.isArray(dispatch.items) ? dispatch.items : [], }, returnNumber: { status: String(returnNumber.status || "pending").trim() || "pending", @@ -126,6 +130,13 @@ export function normalizeKuaishouCloudFlow(value: unknown) { }; } +export function normalizeKuaishouCloudDeliveryItems(value: unknown) { + const source: JsonObject = value && typeof value === "object" ? value as JsonObject : {}; + const binding = + source.binding && typeof source.binding === "object" ? source.binding : {}; + return normalizeDeliveryItems(source, binding); +} + export function normalizeKuaishouCloudRoleInfo(value: unknown) { const rawInfo: JsonObject | null = value && typeof value === "object" ? value as JsonObject : null; const nestedBindInfo = @@ -204,3 +215,61 @@ export function normalizeStringArray(value: unknown) { } return []; } + +function normalizeDeliveryItems(source: JsonObject, binding: JsonObject) { + const rawItems = Array.isArray(source.deliveryItems) ? source.deliveryItems : []; + const items = rawItems + .map((item) => normalizeDeliveryItem(item)) + .filter(Boolean) as Array<{ cloudSkuId: number; cloudSkuName: string; quantity: number }>; + + if (items.length > 0) { + return mergeDeliveryItems(items); + } + + const skuId = Number(binding.skuId || 0) || 0; + if (!skuId) { + return []; + } + + return [ + { + cloudSkuId: skuId, + cloudSkuName: String(binding.skuName || "").trim(), + quantity: 1, + }, + ]; +} + +function normalizeDeliveryItem(value: unknown) { + const source = value && typeof value === "object" ? value as JsonObject : {}; + const cloudSkuId = Number(source.cloudSkuId || source.skuId || 0) || 0; + if (!Number.isInteger(cloudSkuId) || cloudSkuId <= 0) { + return null; + } + + const quantity = Number(source.quantity || 1) || 1; + return { + cloudSkuId, + cloudSkuName: String(source.cloudSkuName || source.skuName || "").trim(), + quantity: Number.isInteger(quantity) && quantity > 0 ? quantity : 1, + }; +} + +function mergeDeliveryItems( + items: Array<{ cloudSkuId: number; cloudSkuName: string; quantity: number }> +) { + 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()); +} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/index.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/index.ts index de687416..b83124ee 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/index.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/index.ts @@ -45,6 +45,16 @@ import { } from "./task-context.js"; import type { TaskRow } from "../../../types/repository/rows.js"; +type CloudDeliveryPlanItem = { + cloudSkuId: number; + cloudSkuName: string; + quantity: number; + skuItem: JsonObject | null; + knapsackItem: JsonObject | null; + knapsackCount: number; + missingCount: number; +}; + export { isKuaishouCloudBindUrlFresh, isKuaishouCloudTask, @@ -167,8 +177,18 @@ export async function prepareKuaishouCloudFulfillmentTask( }, }; - const knapsackItem = resolvedBinding.knapsackItem; - let usedKnapsack = Number(knapsackItem?.count || 0) > 0; + const deliveryPlan = resolveKuaishouCloudDeliveryPlan(flowWithResolvedBinding, { + skuItems: Array.isArray(skuList.items) ? skuList.items : [], + knapsackItems: Array.isArray(knapsack.items) ? knapsack.items : [], + }); + if (deliveryPlan.items.length === 0) { + throw createHttpError("当前任务缺少 cloud 发货物品配置", { + statusCode: 409, + errorCode: "kuaishou_cloud_missing_delivery_items", + }); + } + + let usedKnapsack = deliveryPlan.items.every((item) => item.missingCount <= 0); let purchaseTriggered = false; let assetBefore = 0; let assetAfter = 0; @@ -182,11 +202,12 @@ export async function prepareKuaishouCloudFulfillmentTask( } const asset = await getCloudtentaclesAsset(cloudContext); - const targetSku = resolvedBinding.skuItem; - - if (!targetSku) { + const missingSku = deliveryPlan.items.find( + (item) => item.missingCount > 0 && !item.skuItem + ); + if (missingSku) { throw createHttpError( - `cloudtentacles 未找到 SKU ${flowWithResolvedBinding.binding.skuId}`, + `cloudtentacles 未找到 SKU ${missingSku.cloudSkuId}`, { statusCode: 404, errorCode: "kuaishou_cloud_sku_not_found", @@ -195,7 +216,11 @@ export async function prepareKuaishouCloudFulfillmentTask( } assetBefore = Number(asset.asset || 0) || 0; - const targetPrice = Number(targetSku.price || 0) || 0; + const targetPrice = deliveryPlan.items.reduce( + (sum, item) => + sum + Math.max(0, item.missingCount) * Number(item.skuItem?.price || 0), + 0 + ); const requiredAsset = targetPrice + flowWithResolvedBinding.purchase.minAssetReserve; if (assetBefore < requiredAsset) { @@ -204,7 +229,10 @@ export async function prepareKuaishouCloudFulfillmentTask( flow: flowWithResolvedBinding, assetBefore, requiredAsset, - skuName: targetSku.name, + skuName: deliveryPlan.items + .filter((item) => item.missingCount > 0) + .map((item) => item.cloudSkuName || `SKU ${item.cloudSkuId}`) + .join("、"), }); throw createHttpError( `余额不足,当前 ${assetBefore},至少需要 ${requiredAsset}`, @@ -215,11 +243,17 @@ export async function prepareKuaishouCloudFulfillmentTask( ); } - await buyCloudtentaclesSku({ - ...cloudContext, - id: flowWithResolvedBinding.binding.skuId, - count: 1, - }); + for (const item of deliveryPlan.items) { + if (item.missingCount <= 0) { + continue; + } + + await buyCloudtentaclesSku({ + ...cloudContext, + id: item.cloudSkuId, + count: item.missingCount, + }); + } purchaseTriggered = true; const assetResult = await getCloudtentaclesAsset(cloudContext); @@ -265,6 +299,13 @@ export async function prepareKuaishouCloudFulfillmentTask( purchaseTriggered, assetBefore, assetAfter, + items: deliveryPlan.items.map((item) => ({ + cloudSkuId: item.cloudSkuId, + cloudSkuName: item.cloudSkuName, + requiredCount: item.quantity, + knapsackCount: item.knapsackCount, + purchasedCount: Math.max(0, item.missingCount), + })), purchaseAt: purchaseTriggered ? now : flowWithResolvedBinding.purchase.purchaseAt, @@ -296,6 +337,13 @@ export async function prepareKuaishouCloudFulfillmentTask( vnPhoneMasked: maskPhone(preparedBinding.vnPhone), purchaseTriggered, usedKnapsack, + deliveryItems: deliveryPlan.items.map((item) => ({ + cloudSkuId: item.cloudSkuId, + cloudSkuName: item.cloudSkuName, + quantity: item.quantity, + knapsackCount: item.knapsackCount, + purchasedCount: Math.max(0, item.missingCount), + })), resolvedByName: resolvedBinding.resolvedByName, actor, }, @@ -766,6 +814,69 @@ export async function refreshKuaishouCloudTaskRoleInfo( }; } +function resolveKuaishouCloudDeliveryPlan( + flow: JsonObject, + { + skuItems = [], + knapsackItems = [], + }: { skuItems?: unknown[]; knapsackItems?: unknown[] } = {} +): { items: CloudDeliveryPlanItem[] } { + const normalizedSkuItems = Array.isArray(skuItems) + ? skuItems.filter(isCloudSkuLikeItem) + : []; + const normalizedKnapsackItems = Array.isArray(knapsackItems) + ? knapsackItems.filter(isCloudSkuLikeItem) + : []; + const deliveryItems = Array.isArray(flow.deliveryItems) + ? flow.deliveryItems + : []; + + return { + items: deliveryItems + .map((item: unknown) => { + const source = item && typeof item === "object" ? item as JsonObject : {}; + const cloudSkuId = Number(source.cloudSkuId || source.skuId || 0) || 0; + const quantity = Number(source.quantity || 1) || 1; + if (!cloudSkuId || !Number.isInteger(quantity) || quantity <= 0) { + return null; + } + + const skuItem = + normalizedSkuItems.find( + (sku) => Number(sku.id || 0) === cloudSkuId + ) || null; + const knapsackItem = + normalizedKnapsackItems.find( + (sku) => Number(sku.id || 0) === cloudSkuId + ) || null; + const knapsackCount = Math.max(0, Number(knapsackItem?.count || 0) || 0); + const cloudSkuName = String( + source.cloudSkuName || + source.skuName || + skuItem?.name || + knapsackItem?.name || + "" + ).trim(); + + return { + cloudSkuId, + cloudSkuName, + quantity, + skuItem, + knapsackItem, + knapsackCount, + missingCount: Math.max(0, quantity - knapsackCount), + }; + }) + .filter((item): item is CloudDeliveryPlanItem => Boolean(item)), + }; +} + +function isCloudSkuLikeItem(item: unknown): item is JsonObject { + const current = item && typeof item === "object" ? item as JsonObject : {}; + return Number(current.id || 0) > 0; +} + export { dispatchKuaishouCloudFulfillmentTask, returnKuaishouCloudFulfillmentTask, diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts index 47756f90..0d3dded2 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts @@ -23,6 +23,19 @@ import { syncKuaishouCloudRoleInfoBeforeDispatch } from "./dispatch-role-sync.js import { normalizeActor, parseTaskContext } from "./task-context.js"; import type { TaskRow } from "../../../types/repository/rows.js"; +type DispatchDeliveryItem = { + cloudSkuId: number; + cloudSkuName: string; + quantity: number; +}; + +type DispatchResultItem = DispatchDeliveryItem & { + unitIndex: number; + sendType: number; + note: string; + responseMessage: string; +}; + export async function dispatchKuaishouCloudFulfillmentTask( task: TaskRow, options: JsonObject = {} @@ -80,12 +93,51 @@ export async function dispatchKuaishouCloudFulfillmentTask( const syncedFlow = synced.flow; const syncedTaskContext = synced.taskContext; - const dispatchResult = await useCloudtentaclesSku({ - ...cloudContext, - id: syncedFlow.binding.skuId, - virtualNumberId: syncedFlow.binding.vnId, - phone: syncedFlow.binding.vnPhone, - }); + const deliveryItems = resolveDispatchDeliveryItems(syncedFlow); + if (deliveryItems.length === 0) { + throw createHttpError("当前任务缺少 cloud 发货物品配置", { + statusCode: 409, + errorCode: "kuaishou_cloud_missing_delivery_items", + }); + } + + const dispatchResults: DispatchResultItem[] = []; + for (const item of deliveryItems) { + for (let index = 0; index < item.quantity; index += 1) { + const dispatchResult = await useCloudtentaclesSku({ + ...cloudContext, + id: item.cloudSkuId, + virtualNumberId: syncedFlow.binding.vnId, + phone: syncedFlow.binding.vnPhone, + }); + dispatchResults.push({ + cloudSkuId: item.cloudSkuId, + cloudSkuName: item.cloudSkuName, + unitIndex: index + 1, + quantity: item.quantity, + sendType: Number(dispatchResult.sendType || 0) || 0, + note: String(dispatchResult.note || "").trim(), + responseMessage: String(dispatchResult.responseMessage || "").trim(), + }); + } + } + const firstDispatchResult: DispatchResultItem = dispatchResults[0] || { + cloudSkuId: syncedFlow.binding.skuId, + cloudSkuName: syncedFlow.binding.skuName, + quantity: 1, + unitIndex: 1, + sendType: 0, + note: "", + responseMessage: "", + }; + const dispatchSummary = + dispatchResults.length > 1 + ? `cloudtentacles 发货成功,共 ${dispatchResults.length} 次` + : String( + firstDispatchResult.responseMessage || + firstDispatchResult.note || + "cloudtentacles 发货成功" + ).trim(); const nextContext = { ...syncedTaskContext, @@ -102,12 +154,9 @@ export async function dispatchKuaishouCloudFulfillmentTask( status: "success", dispatchAt: now, dispatchBy: actor, - sendType: Number(dispatchResult.sendType || 0) || 0, - note: String( - dispatchResult.note || - dispatchResult.responseMessage || - "cloudtentacles 发货成功" - ).trim(), + sendType: Number(firstDispatchResult.sendType || 0) || 0, + note: dispatchSummary, + items: dispatchResults, }, }, }; @@ -116,11 +165,7 @@ export async function dispatchKuaishouCloudFulfillmentTask( task_status: "dispatched_pending_return", delivery_status: "delivered", result_code: "kuaishou_cloud_dispatched", - result_message: String( - dispatchResult.responseMessage || - dispatchResult.note || - "cloudtentacles 发货成功" - ).trim(), + result_message: dispatchSummary, user_action_status: "not_required", last_error: "", context_json: JSON.stringify(nextContext), @@ -142,8 +187,10 @@ export async function dispatchKuaishouCloudFulfillmentTask( skuId: syncedFlow.binding.skuId, vnId: syncedFlow.binding.vnId, vnPhoneMasked: maskPhone(syncedFlow.binding.vnPhone), - sendType: dispatchResult.sendType, - note: dispatchResult.note, + sendType: firstDispatchResult.sendType, + note: dispatchSummary, + deliveryItems, + dispatchResults, actor, }, now @@ -173,6 +220,43 @@ export async function dispatchKuaishouCloudFulfillmentTask( }; } +function resolveDispatchDeliveryItems(flow: JsonObject): DispatchDeliveryItem[] { + const rawItems = Array.isArray(flow.deliveryItems) ? flow.deliveryItems : []; + const items = rawItems + .map((item: unknown) => { + const source = item && typeof item === "object" ? item as JsonObject : {}; + const cloudSkuId = Number(source.cloudSkuId || source.skuId || 0) || 0; + const quantity = Number(source.quantity || 1) || 1; + if (!Number.isInteger(cloudSkuId) || cloudSkuId <= 0) { + return null; + } + + return { + cloudSkuId, + cloudSkuName: String(source.cloudSkuName || source.skuName || "").trim(), + quantity: Number.isInteger(quantity) && quantity > 0 ? quantity : 1, + }; + }) + .filter((item): item is DispatchDeliveryItem => Boolean(item)); + + if (items.length > 0) { + return items; + } + + const fallbackSkuId = Number(flow?.binding?.skuId || 0) || 0; + if (!fallbackSkuId) { + return []; + } + + return [ + { + cloudSkuId: fallbackSkuId, + cloudSkuName: String(flow?.binding?.skuName || "").trim(), + quantity: 1, + }, + ]; +} + export async function returnKuaishouCloudFulfillmentTask( task: TaskRow, options: JsonObject = {} diff --git a/apps/backend/src/services/order/delivery-task-service.ts b/apps/backend/src/services/order/delivery-task-service.ts index 02f37978..45c177d3 100644 --- a/apps/backend/src/services/order/delivery-task-service.ts +++ b/apps/backend/src/services/order/delivery-task-service.ts @@ -131,6 +131,12 @@ export async function syncDeliveryTasksForOrderWithDeps( const cloudtentaclesConfig = parseJsonObject(fulfillmentConfig.cloudtentacles) const kuaishouConsumeConfig = parseJsonObject(fulfillmentConfig.kuaishouConsume) const kuaishouShopConfig = parseJsonObject(fulfillmentConfig.kuaishouShop) + const deliveryItems = normalizeCloudDeliveryItems(cloudtentaclesConfig) + const primaryDeliveryItem = deliveryItems[0] || { + cloudSkuId: Number(cloudtentaclesConfig.skuId || 0) || 0, + cloudSkuName: String(cloudtentaclesConfig.skuName || '').trim(), + quantity: 1, + } for (let index = 0; index < quantity; index += 1) { const createdAt = getNowIso() @@ -172,6 +178,7 @@ export async function syncDeliveryTasksForOrderWithDeps( configId: String(fulfillmentConfig.configId || '').trim(), internalSkuCode: item.sku_code, internalSkuName: item.sku_name, + deliveryItems, ticket: { code: '', status: 'pending', @@ -186,8 +193,8 @@ export async function syncDeliveryTasksForOrderWithDeps( binding: { prepareStatus: 'pending', cloudSourceKeys: normalizeStringArray(cloudtentaclesConfig.cloudSourceKeys), - skuId: Number(cloudtentaclesConfig.skuId || 0) || 0, - skuName: String(cloudtentaclesConfig.skuName || '').trim(), + skuId: primaryDeliveryItem.cloudSkuId, + skuName: primaryDeliveryItem.cloudSkuName, vnKey: '1', vnId: 0, vnPhone: '', @@ -394,6 +401,68 @@ function normalizeStringArray(value: unknown): string[] { ) } +function normalizeCloudDeliveryItems(value: JsonObject): Array<{ + cloudSkuId: number + cloudSkuName: string + quantity: number +}> { + const rawItems = Array.isArray(value.deliveryItems) ? value.deliveryItems : [] + const items = rawItems + .map((item) => normalizeCloudDeliveryItem(item)) + .filter((item): item is { cloudSkuId: number; cloudSkuName: string; quantity: number } => Boolean(item)) + + if (items.length > 0) { + return mergeCloudDeliveryItems(items) + } + + const cloudSkuId = Number(value.skuId || 0) || 0 + if (!cloudSkuId) { + return [] + } + + return [{ + cloudSkuId, + cloudSkuName: String(value.skuName || '').trim(), + quantity: 1, + }] +} + +function normalizeCloudDeliveryItem(value: unknown) { + const source = value && typeof value === 'object' && !Array.isArray(value) + ? value as JsonObject + : {} + const cloudSkuId = Number(source.cloudSkuId || source.skuId || 0) || 0 + if (!Number.isInteger(cloudSkuId) || cloudSkuId <= 0) { + return null + } + + const quantity = Number(source.quantity || 1) || 1 + return { + cloudSkuId, + cloudSkuName: String(source.cloudSkuName || source.skuName || '').trim(), + quantity: Number.isInteger(quantity) && quantity > 0 ? quantity : 1, + } +} + +function mergeCloudDeliveryItems( + items: Array<{ cloudSkuId: number; cloudSkuName: string; quantity: number }>, +) { + 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()) +} + function isTaskRow(task: TaskRow | DeliveryTaskRow | null | undefined): task is TaskRow { return Boolean(task && Number(task.id || 0) > 0) } diff --git a/apps/backend/src/services/order/kuaishou-cloud-fulfillment-config-service.test.ts b/apps/backend/src/services/order/kuaishou-cloud-fulfillment-config-service.test.ts index 8503975b..b0cfd8ba 100644 --- a/apps/backend/src/services/order/kuaishou-cloud-fulfillment-config-service.test.ts +++ b/apps/backend/src/services/order/kuaishou-cloud-fulfillment-config-service.test.ts @@ -32,6 +32,13 @@ test('mapKuaishouCloudFulfillmentItemsToBindings maps 91kaquan rules', () => { profileKey: 'kuaishou_ct_assisted', enabled: true, priority: 100, + deliveryItems: [ + { + cloudSkuId: 28, + cloudSkuName: '', + quantity: 1, + }, + ], config: { flowType: 'kuaishou_cloud_fulfillment', configId: '', @@ -39,6 +46,13 @@ test('mapKuaishouCloudFulfillmentItemsToBindings maps 91kaquan rules', () => { cloudSourceKeys: ['account_a', 'account_b'], skuId: 28, skuName: '', + deliveryItems: [ + { + cloudSkuId: 28, + cloudSkuName: '', + quantity: 1, + }, + ], vnKey: '1', autoBuyEnabled: true, minAssetReserve: 0, @@ -63,3 +77,32 @@ test('mapKuaishouCloudFulfillmentItemsToBindings maps 91kaquan rules', () => { ], ) }) + +test('mapKuaishouCloudFulfillmentItemsToBindings maps package delivery items', () => { + const [binding] = mapKuaishouCloudFulfillmentItemsToBindings({ + items: [ + { + enabled: true, + internalSkuCode: 'package-1', + internalSkuName: '套餐 1', + externalSkuCode: 'P001', + cloudSourceKeys: ['account_a'], + deliveryItems: [ + { cloudSkuId: 73, cloudSkuName: '物品 A', quantity: 1 }, + { cloudSkuId: 76, cloudSkuName: '物品 B', quantity: 50 }, + ], + }, + ], + }) + + assert.deepEqual(binding?.deliveryItems, [ + { cloudSkuId: 73, cloudSkuName: '物品 A', quantity: 1 }, + { cloudSkuId: 76, cloudSkuName: '物品 B', quantity: 50 }, + ]) + assert.deepEqual(binding?.config.cloudtentacles.deliveryItems, [ + { cloudSkuId: 73, cloudSkuName: '物品 A', quantity: 1 }, + { cloudSkuId: 76, cloudSkuName: '物品 B', quantity: 50 }, + ]) + assert.equal(binding?.config.cloudtentacles.skuId, 73) + assert.equal(binding?.config.cloudtentacles.skuName, '物品 A') +}) diff --git a/apps/backend/src/services/order/kuaishou-cloud-fulfillment-config-service.ts b/apps/backend/src/services/order/kuaishou-cloud-fulfillment-config-service.ts index 79e40ae8..b9060bc9 100644 --- a/apps/backend/src/services/order/kuaishou-cloud-fulfillment-config-service.ts +++ b/apps/backend/src/services/order/kuaishou-cloud-fulfillment-config-service.ts @@ -12,6 +12,12 @@ const KUAISHOU_CLOUD_FULFILLMENT_FILE_PATH = path.join( type JsonObject = Record; +type DeliveryItem = { + cloudSkuId: number; + cloudSkuName: string; + quantity: number; +}; + export function getKuaishouCloudFulfillmentFilePath() { return KUAISHOU_CLOUD_FULFILLMENT_FILE_PATH; } @@ -42,13 +48,19 @@ export function mapKuaishouCloudFulfillmentItemsToBindings(config: JsonObject = profileKey: "kuaishou_ct_assisted", enabled: item.enabled !== false, priority: normalizePriority(item.priority), + deliveryItems: normalizeDeliveryItems(item), config: { flowType: "kuaishou_cloud_fulfillment", configId: String(item.id || "").trim(), cloudtentacles: { cloudSourceKeys: normalizeStringArray(item.cloudSourceKeys), - skuId: normalizePositiveInteger(item.cloudSkuId), - skuName: String(item.cloudSkuName || "").trim(), + skuId: + normalizeDeliveryItems(item)[0]?.cloudSkuId || + normalizePositiveInteger(item.cloudSkuId), + skuName: + normalizeDeliveryItems(item)[0]?.cloudSkuName || + String(item.cloudSkuName || "").trim(), + deliveryItems: normalizeDeliveryItems(item), vnKey: "1", autoBuyEnabled: item.autoBuyEnabled !== false, minAssetReserve: normalizeNonNegativeInteger(item.minAssetReserve, 0), @@ -109,6 +121,7 @@ function normalizeKuaishouCloudFulfillmentItem(rawValue: unknown) { const internalSkuCode = String(rawValue.internalSkuCode || "").trim(); const cloudSkuId = normalizePositiveInteger(rawValue.cloudSkuId); + const deliveryItems = normalizeDeliveryItems(rawValue); const cloudSourceKeys = normalizeStringArray(rawValue.cloudSourceKeys); const externalSkuCode = String(rawValue.externalSkuCode || "").trim(); const externalItemId = String(rawValue.externalItemId || "").trim(); @@ -128,7 +141,7 @@ function normalizeKuaishouCloudFulfillmentItem(rawValue: unknown) { return null; } - if (!cloudSkuId) { + if (deliveryItems.length === 0) { return null; } @@ -157,8 +170,11 @@ function normalizeKuaishouCloudFulfillmentItem(rawValue: unknown) { rawValue.resolvedSkuName || rawValue.internalSkuName || "" ).trim(), cloudSourceKeys, - cloudSkuId, - cloudSkuName: String(rawValue.cloudSkuName || "").trim(), + cloudSkuId: deliveryItems[0]?.cloudSkuId || cloudSkuId, + cloudSkuName: + deliveryItems[0]?.cloudSkuName || + String(rawValue.cloudSkuName || "").trim(), + deliveryItems, vnKey: "1", autoBuyEnabled: rawValue.autoBuyEnabled !== false, minAssetReserve: normalizeNonNegativeInteger(rawValue.minAssetReserve, 0), @@ -185,6 +201,64 @@ function normalizeNonNegativeInteger(value: unknown, fallback: number) { return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback; } +function normalizeDeliveryItems(value: JsonObject): DeliveryItem[] { + const rawItems = Array.isArray(value.deliveryItems) ? value.deliveryItems : []; + const items = rawItems + .map((item: unknown) => normalizeDeliveryItem(item)) + .filter(Boolean) as DeliveryItem[]; + + if (items.length > 0) { + return mergeDeliveryItems(items); + } + + const cloudSkuId = normalizePositiveInteger(value.cloudSkuId); + if (!cloudSkuId) { + return []; + } + + return [ + { + cloudSkuId, + cloudSkuName: String(value.cloudSkuName || "").trim(), + quantity: 1, + }, + ]; +} + +function normalizeDeliveryItem(value: unknown): DeliveryItem | null { + if (!isPlainObject(value)) { + return null; + } + + const cloudSkuId = normalizePositiveInteger(value.cloudSkuId || value.skuId); + if (!cloudSkuId) { + return null; + } + + return { + cloudSkuId, + cloudSkuName: String(value.cloudSkuName || value.skuName || "").trim(), + quantity: normalizePositiveInteger(value.quantity) || 1, + }; +} + +function mergeDeliveryItems(items: DeliveryItem[]): DeliveryItem[] { + 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()); +} + function normalizePriority(value: unknown) { const parsed = Number(value); if (!Number.isFinite(parsed)) { diff --git a/apps/frontend/src/types/admin/index.ts b/apps/frontend/src/types/admin/index.ts index d02c2884..e95fe021 100644 --- a/apps/frontend/src/types/admin/index.ts +++ b/apps/frontend/src/types/admin/index.ts @@ -66,6 +66,7 @@ export type { AdminCloudtentaclesSkuListResult, AdminCloudtentaclesDeliveryRecordItem, AdminCloudtentaclesDeliveryRecordListResult, + AdminKuaishouCloudDeliveryItem, AdminKuaishouCloudFulfillmentItem, AdminKuaishouCloudFulfillmentConfig, } from './platform-config' diff --git a/apps/frontend/src/types/admin/platform-config/index.ts b/apps/frontend/src/types/admin/platform-config/index.ts index 097e72d8..9581062d 100644 --- a/apps/frontend/src/types/admin/platform-config/index.ts +++ b/apps/frontend/src/types/admin/platform-config/index.ts @@ -49,6 +49,7 @@ export type { } from './cloudtentacles' export type { + AdminKuaishouCloudDeliveryItem, AdminKuaishouCloudFulfillmentItem, AdminKuaishouCloudFulfillmentConfig, } from './kuaishou-cloud-fulfillment' diff --git a/apps/frontend/src/types/admin/platform-config/kuaishou-cloud-fulfillment.ts b/apps/frontend/src/types/admin/platform-config/kuaishou-cloud-fulfillment.ts index 6cce132d..e9318652 100644 --- a/apps/frontend/src/types/admin/platform-config/kuaishou-cloud-fulfillment.ts +++ b/apps/frontend/src/types/admin/platform-config/kuaishou-cloud-fulfillment.ts @@ -1,3 +1,9 @@ +export interface AdminKuaishouCloudDeliveryItem { + cloudSkuId: number + cloudSkuName: string + quantity: number +} + export interface AdminKuaishouCloudFulfillmentItem { id: string enabled: boolean @@ -14,6 +20,7 @@ export interface AdminKuaishouCloudFulfillmentItem { cloudSourceKeys: string[] cloudSkuId: number cloudSkuName: string + deliveryItems: AdminKuaishouCloudDeliveryItem[] vnKey: string autoBuyEnabled: boolean minAssetReserve: number 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 77cbadc0..820b48e5 100644 --- a/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/AdminKuaishouCloudFulfillment.css +++ b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/AdminKuaishouCloudFulfillment.css @@ -429,6 +429,34 @@ padding: var(--space-4); } +.delivery-items-editor { + display: grid; + gap: var(--space-3); + padding: var(--space-4); +} + +.delivery-items-head { + display: flex; + justify-content: space-between; + gap: var(--space-3); + align-items: center; + color: var(--text-primary); + font-weight: 700; +} + +.delivery-item-row { + display: grid; + grid-template-columns: minmax(220px, 1fr) minmax(160px, 0.7fr) 132px 48px; + gap: var(--space-3); + align-items: center; +} + +.delivery-item-sku, +.delivery-item-name, +.delivery-item-count { + width: 100%; +} + .field-wide { grid-column: 1 / -1; } @@ -501,6 +529,7 @@ .rule-editor-main, .strategy-grid, .mapping-editor, + .delivery-item-row, .mapping-grid, .mapping-extra { grid-template-columns: 1fr; 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 f6f36555..7f569b8d 100644 --- a/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/AdminKuaishouCloudFulfillmentView.vue +++ b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/AdminKuaishouCloudFulfillmentView.vue @@ -13,6 +13,25 @@ const config = useKuaishouCloudConfig() const ninetyone = useKuaishouCloudNinetyone(config) const cloudSku = useKuaishouCloudSku(config.cloudtentaclesSourceOptions) +function handlePrimaryCloudSkuSelected(item: (typeof config.items.value)[number], value: number | string | undefined) { + cloudSku.handleCloudSkuSelected(item, value) + config.syncDeliveryItemsFromPrimaryCloudSku(item) +} + +function handleDeliveryItemCloudSkuSelected( + item: (typeof config.items.value)[number], + index: number, + value: number | string | undefined, +) { + const deliveryItem = item.deliveryItems[index] + if (!deliveryItem) { + return + } + + cloudSku.handleDeliveryItemCloudSkuSelected(item, deliveryItem, value) + config.syncPrimaryCloudSkuFromDeliveryItems(item) +} + onMounted(config.loadConfigs) @@ -61,6 +80,7 @@ onMounted(config.loadConfigs) :get-cloud-source-summary="config.getCloudSourceSummary" :get-cloud-sku-summary="config.getCloudSkuSummary" :get-consume-shop-summary="config.getConsumeShopSummary" + :get-delivery-plan-summary="config.getDeliveryPlanSummary" :get-cloud-sku-options="cloudSku.getCloudSkuOptions" :format-cloud-sku-option-label="cloudSku.formatCloudSkuOptionLabel" :get-kuaishou-consume-shop-options="config.getKuaishouConsumeShopOptions" @@ -74,12 +94,15 @@ onMounted(config.loadConfigs) @add-item="config.addItem()" @save-configs="config.saveConfigs()" @toggle-collapsed="config.toggleCollapsed($event)" - @handle-cloud-sku-selected="(item, value) => cloudSku.handleCloudSkuSelected(item, value)" + @handle-cloud-sku-selected="handlePrimaryCloudSkuSelected" + @handle-delivery-item-cloud-sku-selected="handleDeliveryItemCloudSkuSelected" @handle-cloud-sku-dropdown-visible=" (item, visible) => cloudSku.handleCloudSkuDropdownVisible(item, visible) " @handle-kuaishou-consume-shop-selected="config.handleKuaishouConsumeShopSelected($event)" @handle-cloud-source-keys-changed="config.handleCloudSourceKeysChanged($event)" + @add-delivery-item="config.addDeliveryItem($event)" + @remove-delivery-item="(item, index) => config.removeDeliveryItem(item, index)" @remove-item="config.removeItem($event)" /> diff --git a/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/components/AdminKuaishouCloudRuleCard.vue b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/components/AdminKuaishouCloudRuleCard.vue index b16057e6..dbe124e4 100644 --- a/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/components/AdminKuaishouCloudRuleCard.vue +++ b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/components/AdminKuaishouCloudRuleCard.vue @@ -19,6 +19,7 @@ defineProps<{ getCloudSourceSummary: (item: EditableItem) => string getCloudSkuSummary: (item: EditableItem) => string getConsumeShopSummary: (item: EditableItem) => string + getDeliveryPlanSummary: (item: EditableItem) => string getCloudSkuOptions: (item: EditableItem) => AdminCloudtentaclesSkuItem[] formatCloudSkuOptionLabel: (sku: AdminCloudtentaclesSkuItem) => string getKuaishouConsumeShopOptions: () => AdminKuaishouEticketShopConfigItem[] @@ -28,11 +29,21 @@ defineProps<{ const emit = defineEmits<{ toggleCollapsed: [] handleCloudSkuSelected: [value: number | string | undefined] + handleDeliveryItemCloudSkuSelected: [index: number, value: number | string | undefined] handleCloudSkuDropdownVisible: [visible: boolean] handleKuaishouConsumeShopSelected: [] handleCloudSourceKeysChanged: [] + addDeliveryItem: [] + removeDeliveryItem: [index: number] remove: [] }>() + +function isAdvancedDeliveryConfigured(item: EditableItem) { + return ( + item.deliveryItems.length > 1 || + item.deliveryItems.some((deliveryItem) => Number(deliveryItem.quantity || 1) > 1) + ) +}