From 8bb0223d2faae083ded0bc4593d909295ac64de3 Mon Sep 17 00:00:00 2001 From: yml Date: Thu, 21 May 2026 14:31:56 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E7=AB=AF=E8=A1=A5=E5=85=85=E5=B1=A5?= =?UTF-8?q?=E7=BA=A6=E4=BB=BB=E5=8A=A1=E6=9C=8D=E5=8A=A1=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/order/delivery-task-service.js | 83 ++++++--- .../order/delivery-task-service.test.js | 162 ++++++++++++++++++ docs/backend-typescript-migration-plan.md | 13 +- 3 files changed, 231 insertions(+), 27 deletions(-) create mode 100644 apps/backend/src/services/order/delivery-task-service.test.js diff --git a/apps/backend/src/services/order/delivery-task-service.js b/apps/backend/src/services/order/delivery-task-service.js index dba5df39..42cd638a 100644 --- a/apps/backend/src/services/order/delivery-task-service.js +++ b/apps/backend/src/services/order/delivery-task-service.js @@ -10,7 +10,32 @@ import { nowIso } from '../../utils/time.js' import { randomId } from '../../utils/random.js' export async function syncDeliveryTasksForOrder(order, orderItems) { - const existingTasks = await listTasksByOrderId(order.id) + return syncDeliveryTasksForOrderWithDeps(order, orderItems) +} + +export async function syncDeliveryTasksForOrderWithDeps(order, orderItems, deps = {}) { + const { + createTask: createDeliveryTask = createTask, + listTasksByOrderId: listTasks = listTasksByOrderId, + updateTask: updateDeliveryTask = updateTask, + listFulfillmentProfileRequirements: listProfileRequirements = listFulfillmentProfileRequirements, + resolveFulfillmentBinding: resolveBinding = resolveFulfillmentBinding, + reserveInventoryForTask: reserveInventory = reserveInventoryForTask, + createTaskClaimToken: createClaimToken = createTaskClaimToken, + notifyTaskAutoManualReview: notifyManualReview = notifyTaskAutoManualReview, + nowIso: getNowIso = nowIso, + randomId: createRandomId = randomId, + } = deps + + const runtimeDeps = { + updateTask: updateDeliveryTask, + reserveInventoryForTask: reserveInventory, + createTaskClaimToken: createClaimToken, + notifyTaskAutoManualReview: notifyManualReview, + nowIso: getNowIso, + } + + const existingTasks = await listTasks(order.id) if (existingTasks.length > 0) { if (order.pay_status !== 'paid') { @@ -22,13 +47,13 @@ export async function syncDeliveryTasksForOrder(order, orderItems) { ...task, skuCode: itemMap.get(task.order_item_id)?.sku_code || '', skuName: itemMap.get(task.order_item_id)?.sku_name || '', - }))) + }, runtimeDeps))) } const tasks = [] for (const item of orderItems) { - const binding = await resolveFulfillmentBinding({ + const binding = await resolveBinding({ skuCode: item.sku_code, provider: order.provider, platform: order.platform, @@ -39,7 +64,7 @@ export async function syncDeliveryTasksForOrder(order, orderItems) { continue } const profile = binding - const requirements = await listFulfillmentProfileRequirements(profile.profile_id || profile.id) + const requirements = await listProfileRequirements(profile.profile_id || profile.id) const primaryRequirement = requirements.find((requirement) => requirement.is_required !== false) || requirements[0] || null const quantity = Math.max(1, Number(item.quantity || 1)) const fulfillmentConfig = parseJsonObject(profile.config_json) @@ -48,12 +73,12 @@ export async function syncDeliveryTasksForOrder(order, orderItems) { : {} for (let index = 0; index < quantity; index += 1) { - const createdAt = nowIso() + const createdAt = getNowIso() const initialStatus = order.pay_status === 'paid' ? resolvePaidTaskStatus(profile) : 'pending_payment' - const task = await createTask({ + const task = await createDeliveryTask({ orderId: order.id, orderItemId: item.id, unitIndex: index + 1, @@ -62,7 +87,7 @@ export async function syncDeliveryTasksForOrder(order, orderItems) { shopId: order.shop_id, shopName: order.shop_name, platformOrderId: order.platform_order_id, - taskNo: randomId('DT'), + taskNo: createRandomId('DT'), profileId: Number(profile.profile_id || profile.id), executorKey: String(profile.executor_key || 'manual_dispatch'), taskStatus: initialStatus, @@ -179,11 +204,19 @@ export async function syncDeliveryTasksForOrder(order, orderItems) { return tasks } - return Promise.all(tasks.map((task) => preparePaidTask(task))) + return Promise.all(tasks.map((task) => preparePaidTask(task, runtimeDeps))) } -async function preparePaidTask(task) { - const now = nowIso() +async function preparePaidTask(task, deps = {}) { + const { + updateTask: updateDeliveryTask = updateTask, + reserveInventoryForTask: reserveInventory = reserveInventoryForTask, + createTaskClaimToken: createClaimToken = createTaskClaimToken, + notifyTaskAutoManualReview: notifyManualReview = notifyTaskAutoManualReview, + nowIso: getNowIso = nowIso, + } = deps + + const now = getNowIso() const taskContext = parseTaskContext(task) const primaryRequirement = taskContext.primaryRequirement || null const inventorySkuCode = String(taskContext.inventorySkuCode || task.skuCode || '').trim() @@ -202,8 +235,8 @@ async function preparePaidTask(task) { 'failed', ].includes(String(task.task_status || '').trim())) { if (!task.primary_claim_token_id && !String(task.claim_token || '').trim()) { - const claimToken = await createTaskClaimToken(task.id) - return updateTask(task.id, { + const claimToken = await createClaimToken(task.id) + return updateDeliveryTask(task.id, { claim_token: claimToken.token, claim_expires_at: claimToken.expired_at, user_action_status: 'pending_claim', @@ -214,9 +247,9 @@ async function preparePaidTask(task) { return task } - const claimToken = await createTaskClaimToken(task.id) + const claimToken = await createClaimToken(task.id) - return updateTask(task.id, { + return updateDeliveryTask(task.id, { task_status: 'pending_binding_prepare', inventory_status: 'not_required', claim_token: claimToken.token, @@ -229,14 +262,14 @@ async function preparePaidTask(task) { if (!task.requires_claim || String(task.executor_key || '').trim() === 'manual_dispatch') { const lastError = task.last_error || '当前任务需要人工履约处理' - const updatedTask = await updateTask(task.id, { + const updatedTask = await updateDeliveryTask(task.id, { task_status: 'manual_review', inventory_status: 'not_required', user_action_status: 'not_required', last_error: lastError, updated_at: now, }) - await notifyTaskAutoManualReview({ + await notifyManualReview({ task: updatedTask || task, reason: lastError, source: 'manual_dispatch_profile', @@ -246,12 +279,12 @@ async function preparePaidTask(task) { if (!inventorySkuCode) { const lastError = '未匹配到 SKU,无法为任务分配库存凭据' - const updatedTask = await updateTask(task.id, { + const updatedTask = await updateDeliveryTask(task.id, { task_status: 'manual_review', last_error: lastError, updated_at: now, }) - await notifyTaskAutoManualReview({ + await notifyManualReview({ task: updatedTask || task, reason: lastError, source: 'missing_sku', @@ -260,7 +293,7 @@ async function preparePaidTask(task) { } if (task.primary_inventory_item_id && task.primary_claim_token_id) { - return updateTask(task.id, { + return updateDeliveryTask(task.id, { task_status: 'link_generated', last_error: '', updated_at: now, @@ -269,12 +302,12 @@ async function preparePaidTask(task) { if (!primaryRequirement?.credentialType) { const lastError = '履约档案未配置库存要求,无法自动分配库存' - const updatedTask = await updateTask(task.id, { + const updatedTask = await updateDeliveryTask(task.id, { task_status: 'manual_review', last_error: lastError, updated_at: now, }) - await notifyTaskAutoManualReview({ + await notifyManualReview({ task: updatedTask || task, reason: lastError, source: 'missing_inventory_requirement', @@ -282,7 +315,7 @@ async function preparePaidTask(task) { return updatedTask } - const reserved = await reserveInventoryForTask({ + const reserved = await reserveInventory({ skuCode: inventorySkuCode, taskId: task.id, credentialType: primaryRequirement.credentialType, @@ -290,7 +323,7 @@ async function preparePaidTask(task) { }) if (!reserved) { - return updateTask(task.id, { + return updateDeliveryTask(task.id, { task_status: 'waiting_inventory', inventory_status: 'pending', last_error: '库存不足,等待可用库存凭据', @@ -298,9 +331,9 @@ async function preparePaidTask(task) { }) } - const claimToken = await createTaskClaimToken(task.id) + const claimToken = await createClaimToken(task.id) - return updateTask(task.id, { + return updateDeliveryTask(task.id, { task_status: 'link_generated', inventory_status: 'reserved', claim_token: claimToken.token, diff --git a/apps/backend/src/services/order/delivery-task-service.test.js b/apps/backend/src/services/order/delivery-task-service.test.js new file mode 100644 index 00000000..5935aeff --- /dev/null +++ b/apps/backend/src/services/order/delivery-task-service.test.js @@ -0,0 +1,162 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { syncDeliveryTasksForOrderWithDeps } from './delivery-task-service.js' + +const paidOrder = { + id: 10, + provider: 'agiso', + platform: 'xianyu', + shop_id: 'shop-1', + shop_name: '测试店铺', + platform_order_id: 'P10001', + pay_status: 'paid', +} + +const orderItems = [ + { + id: 20, + sku_code: 'dnf-cdk-a', + sku_name: 'DNF CDK A', + quantity: 1, + }, +] + +function createTaskFixture(patch = {}) { + return { + id: 30, + order_item_id: 20, + task_status: 'paid', + executor_key: 'tencent_claim_assisted', + requires_claim: true, + claim_token: '', + primary_claim_token_id: null, + primary_inventory_item_id: null, + last_error: '', + context_json: JSON.stringify({ + inventorySkuCode: 'dnf-cdk-a', + primaryRequirement: { + roleKey: 'primary_code', + credentialType: 'tencent_code', + }, + }), + ...patch, + } +} + +test('syncDeliveryTasksForOrderWithDeps moves paid claim task to waiting inventory when reservation fails', async () => { + const updates = [] + const reserveCalls = [] + + const result = await syncDeliveryTasksForOrderWithDeps(paidOrder, orderItems, { + listTasksByOrderId: async () => [createTaskFixture()], + reserveInventoryForTask: async (payload) => { + reserveCalls.push(payload) + return null + }, + updateTask: async (taskId, patch) => { + updates.push({ taskId, patch }) + return { id: taskId, ...patch } + }, + nowIso: () => '2026-04-14T12:00:00.000Z', + }) + + assert.deepEqual(reserveCalls, [ + { + skuCode: 'dnf-cdk-a', + taskId: 30, + credentialType: 'tencent_code', + roleKey: 'primary_code', + }, + ]) + assert.deepEqual(updates, [ + { + taskId: 30, + patch: { + task_status: 'waiting_inventory', + inventory_status: 'pending', + last_error: '库存不足,等待可用库存凭据', + updated_at: '2026-04-14T12:00:00.000Z', + }, + }, + ]) + assert.equal(result[0]?.task_status, 'waiting_inventory') +}) + +test('syncDeliveryTasksForOrderWithDeps sends manual dispatch tasks to manual review', async () => { + const notifications = [] + const updates = [] + + const result = await syncDeliveryTasksForOrderWithDeps(paidOrder, orderItems, { + listTasksByOrderId: async () => [createTaskFixture({ + executor_key: 'manual_dispatch', + requires_claim: false, + context_json: '{}', + })], + updateTask: async (taskId, patch) => { + updates.push({ taskId, patch }) + return { id: taskId, ...patch } + }, + notifyTaskAutoManualReview: async (payload) => { + notifications.push(payload) + return { ok: true } + }, + nowIso: () => '2026-04-14T12:01:00.000Z', + }) + + assert.equal(result[0]?.task_status, 'manual_review') + assert.equal(updates[0]?.patch.last_error, '当前任务需要人工履约处理') + assert.deepEqual( + notifications.map((item) => ({ + reason: item.reason, + source: item.source, + taskStatus: item.task.task_status, + })), + [ + { + reason: '当前任务需要人工履约处理', + source: 'manual_dispatch_profile', + taskStatus: 'manual_review', + }, + ], + ) +}) + +test('syncDeliveryTasksForOrderWithDeps prepares kuaishou cloud task with claim token', async () => { + const claimToken = { + token: 'claim-token', + expired_at: '2026-04-15T12:00:00.000Z', + } + const updates = [] + + const result = await syncDeliveryTasksForOrderWithDeps(paidOrder, orderItems, { + listTasksByOrderId: async () => [createTaskFixture({ + executor_key: 'kuaishou_ct_assisted', + task_status: 'paid', + requires_claim: true, + context_json: '{}', + })], + createTaskClaimToken: async (taskId) => ({ ...claimToken, taskId }), + updateTask: async (taskId, patch) => { + updates.push({ taskId, patch }) + return { id: taskId, ...patch } + }, + nowIso: () => '2026-04-14T12:02:00.000Z', + }) + + assert.equal(result[0]?.task_status, 'pending_binding_prepare') + assert.deepEqual(updates, [ + { + taskId: 30, + patch: { + task_status: 'pending_binding_prepare', + inventory_status: 'not_required', + claim_token: 'claim-token', + claim_expires_at: '2026-04-15T12:00:00.000Z', + user_action_status: 'pending_claim', + last_error: '领取链接已生成,等待客户提交核销码', + updated_at: '2026-04-14T12:02:00.000Z', + }, + }, + ]) +}) diff --git a/docs/backend-typescript-migration-plan.md b/docs/backend-typescript-migration-plan.md index 39839996..9e411b32 100644 --- a/docs/backend-typescript-migration-plan.md +++ b/docs/backend-typescript-migration-plan.md @@ -288,14 +288,23 @@ - `npm run typecheck` - `npm run build` - `npm test` 共 125 个用例通过 +37. delivery task 服务迁移前补直接测试: + - 新增 `src/services/order/delivery-task-service.test.js` + - 新增 `syncDeliveryTasksForOrderWithDeps`,便于隔离 repository / 库存 / claim token / 通知依赖 + - 覆盖库存不足进入 `waiting_inventory`、人工履约进入 `manual_review`、快手云任务生成 claim token 三个关键分支 +38. Docker 内验证通过: + - `src/services/order/delivery-task-service.test.js` 共 3 个用例通过 + - `npm run typecheck` + - `npm run build` + - `npm test` 共 128 个用例通过 ## 下一步建议 第一批继续推进时,建议按这个顺序: 1. 继续迁移服务层中最核心、最常改的订单 / 履约 / claim 模块 -2. 逐步移除服务层 `@ts-nocheck`,优先处理 webhook service、自动发货、delivery task -3. 为 delivery task 补直接单测后再迁移,避免只依赖订单链路间接覆盖 +2. 迁移 delivery task 服务到 `.ts` +3. 逐步移除服务层 `@ts-nocheck`,优先处理 webhook service、自动发货 ## 执行原则