后端补充履约任务服务测试

This commit is contained in:
yml
2026-05-21 14:31:56 +08:00
parent c870414ad6
commit 8bb0223d2f
3 changed files with 231 additions and 27 deletions
@@ -10,7 +10,32 @@ import { nowIso } from '../../utils/time.js'
import { randomId } from '../../utils/random.js' import { randomId } from '../../utils/random.js'
export async function syncDeliveryTasksForOrder(order, orderItems) { 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 (existingTasks.length > 0) {
if (order.pay_status !== 'paid') { if (order.pay_status !== 'paid') {
@@ -22,13 +47,13 @@ export async function syncDeliveryTasksForOrder(order, orderItems) {
...task, ...task,
skuCode: itemMap.get(task.order_item_id)?.sku_code || '', skuCode: itemMap.get(task.order_item_id)?.sku_code || '',
skuName: itemMap.get(task.order_item_id)?.sku_name || '', skuName: itemMap.get(task.order_item_id)?.sku_name || '',
}))) }, runtimeDeps)))
} }
const tasks = [] const tasks = []
for (const item of orderItems) { for (const item of orderItems) {
const binding = await resolveFulfillmentBinding({ const binding = await resolveBinding({
skuCode: item.sku_code, skuCode: item.sku_code,
provider: order.provider, provider: order.provider,
platform: order.platform, platform: order.platform,
@@ -39,7 +64,7 @@ export async function syncDeliveryTasksForOrder(order, orderItems) {
continue continue
} }
const profile = binding 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 primaryRequirement = requirements.find((requirement) => requirement.is_required !== false) || requirements[0] || null
const quantity = Math.max(1, Number(item.quantity || 1)) const quantity = Math.max(1, Number(item.quantity || 1))
const fulfillmentConfig = parseJsonObject(profile.config_json) const fulfillmentConfig = parseJsonObject(profile.config_json)
@@ -48,12 +73,12 @@ export async function syncDeliveryTasksForOrder(order, orderItems) {
: {} : {}
for (let index = 0; index < quantity; index += 1) { for (let index = 0; index < quantity; index += 1) {
const createdAt = nowIso() const createdAt = getNowIso()
const initialStatus = order.pay_status === 'paid' const initialStatus = order.pay_status === 'paid'
? resolvePaidTaskStatus(profile) ? resolvePaidTaskStatus(profile)
: 'pending_payment' : 'pending_payment'
const task = await createTask({ const task = await createDeliveryTask({
orderId: order.id, orderId: order.id,
orderItemId: item.id, orderItemId: item.id,
unitIndex: index + 1, unitIndex: index + 1,
@@ -62,7 +87,7 @@ export async function syncDeliveryTasksForOrder(order, orderItems) {
shopId: order.shop_id, shopId: order.shop_id,
shopName: order.shop_name, shopName: order.shop_name,
platformOrderId: order.platform_order_id, platformOrderId: order.platform_order_id,
taskNo: randomId('DT'), taskNo: createRandomId('DT'),
profileId: Number(profile.profile_id || profile.id), profileId: Number(profile.profile_id || profile.id),
executorKey: String(profile.executor_key || 'manual_dispatch'), executorKey: String(profile.executor_key || 'manual_dispatch'),
taskStatus: initialStatus, taskStatus: initialStatus,
@@ -179,11 +204,19 @@ export async function syncDeliveryTasksForOrder(order, orderItems) {
return tasks return tasks
} }
return Promise.all(tasks.map((task) => preparePaidTask(task))) return Promise.all(tasks.map((task) => preparePaidTask(task, runtimeDeps)))
} }
async function preparePaidTask(task) { async function preparePaidTask(task, deps = {}) {
const now = nowIso() 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 taskContext = parseTaskContext(task)
const primaryRequirement = taskContext.primaryRequirement || null const primaryRequirement = taskContext.primaryRequirement || null
const inventorySkuCode = String(taskContext.inventorySkuCode || task.skuCode || '').trim() const inventorySkuCode = String(taskContext.inventorySkuCode || task.skuCode || '').trim()
@@ -202,8 +235,8 @@ async function preparePaidTask(task) {
'failed', 'failed',
].includes(String(task.task_status || '').trim())) { ].includes(String(task.task_status || '').trim())) {
if (!task.primary_claim_token_id && !String(task.claim_token || '').trim()) { if (!task.primary_claim_token_id && !String(task.claim_token || '').trim()) {
const claimToken = await createTaskClaimToken(task.id) const claimToken = await createClaimToken(task.id)
return updateTask(task.id, { return updateDeliveryTask(task.id, {
claim_token: claimToken.token, claim_token: claimToken.token,
claim_expires_at: claimToken.expired_at, claim_expires_at: claimToken.expired_at,
user_action_status: 'pending_claim', user_action_status: 'pending_claim',
@@ -214,9 +247,9 @@ async function preparePaidTask(task) {
return 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', task_status: 'pending_binding_prepare',
inventory_status: 'not_required', inventory_status: 'not_required',
claim_token: claimToken.token, claim_token: claimToken.token,
@@ -229,14 +262,14 @@ async function preparePaidTask(task) {
if (!task.requires_claim || String(task.executor_key || '').trim() === 'manual_dispatch') { if (!task.requires_claim || String(task.executor_key || '').trim() === 'manual_dispatch') {
const lastError = task.last_error || '当前任务需要人工履约处理' const lastError = task.last_error || '当前任务需要人工履约处理'
const updatedTask = await updateTask(task.id, { const updatedTask = await updateDeliveryTask(task.id, {
task_status: 'manual_review', task_status: 'manual_review',
inventory_status: 'not_required', inventory_status: 'not_required',
user_action_status: 'not_required', user_action_status: 'not_required',
last_error: lastError, last_error: lastError,
updated_at: now, updated_at: now,
}) })
await notifyTaskAutoManualReview({ await notifyManualReview({
task: updatedTask || task, task: updatedTask || task,
reason: lastError, reason: lastError,
source: 'manual_dispatch_profile', source: 'manual_dispatch_profile',
@@ -246,12 +279,12 @@ async function preparePaidTask(task) {
if (!inventorySkuCode) { if (!inventorySkuCode) {
const lastError = '未匹配到 SKU,无法为任务分配库存凭据' const lastError = '未匹配到 SKU,无法为任务分配库存凭据'
const updatedTask = await updateTask(task.id, { const updatedTask = await updateDeliveryTask(task.id, {
task_status: 'manual_review', task_status: 'manual_review',
last_error: lastError, last_error: lastError,
updated_at: now, updated_at: now,
}) })
await notifyTaskAutoManualReview({ await notifyManualReview({
task: updatedTask || task, task: updatedTask || task,
reason: lastError, reason: lastError,
source: 'missing_sku', source: 'missing_sku',
@@ -260,7 +293,7 @@ async function preparePaidTask(task) {
} }
if (task.primary_inventory_item_id && task.primary_claim_token_id) { if (task.primary_inventory_item_id && task.primary_claim_token_id) {
return updateTask(task.id, { return updateDeliveryTask(task.id, {
task_status: 'link_generated', task_status: 'link_generated',
last_error: '', last_error: '',
updated_at: now, updated_at: now,
@@ -269,12 +302,12 @@ async function preparePaidTask(task) {
if (!primaryRequirement?.credentialType) { if (!primaryRequirement?.credentialType) {
const lastError = '履约档案未配置库存要求,无法自动分配库存' const lastError = '履约档案未配置库存要求,无法自动分配库存'
const updatedTask = await updateTask(task.id, { const updatedTask = await updateDeliveryTask(task.id, {
task_status: 'manual_review', task_status: 'manual_review',
last_error: lastError, last_error: lastError,
updated_at: now, updated_at: now,
}) })
await notifyTaskAutoManualReview({ await notifyManualReview({
task: updatedTask || task, task: updatedTask || task,
reason: lastError, reason: lastError,
source: 'missing_inventory_requirement', source: 'missing_inventory_requirement',
@@ -282,7 +315,7 @@ async function preparePaidTask(task) {
return updatedTask return updatedTask
} }
const reserved = await reserveInventoryForTask({ const reserved = await reserveInventory({
skuCode: inventorySkuCode, skuCode: inventorySkuCode,
taskId: task.id, taskId: task.id,
credentialType: primaryRequirement.credentialType, credentialType: primaryRequirement.credentialType,
@@ -290,7 +323,7 @@ async function preparePaidTask(task) {
}) })
if (!reserved) { if (!reserved) {
return updateTask(task.id, { return updateDeliveryTask(task.id, {
task_status: 'waiting_inventory', task_status: 'waiting_inventory',
inventory_status: 'pending', inventory_status: 'pending',
last_error: '库存不足,等待可用库存凭据', 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', task_status: 'link_generated',
inventory_status: 'reserved', inventory_status: 'reserved',
claim_token: claimToken.token, claim_token: claimToken.token,
@@ -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',
},
},
])
})
+11 -2
View File
@@ -288,14 +288,23 @@
- `npm run typecheck` - `npm run typecheck`
- `npm run build` - `npm run build`
- `npm test` 共 125 个用例通过 - `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 模块 1. 继续迁移服务层中最核心、最常改的订单 / 履约 / claim 模块
2. 逐步移除服务层 `@ts-nocheck`,优先处理 webhook service、自动发货、delivery task 2. 迁移 delivery task 服务到 `.ts`
3. 为 delivery task 补直接单测后再迁移,避免只依赖订单链路间接覆盖 3. 逐步移除服务层 `@ts-nocheck`,优先处理 webhook service、自动发货
## 执行原则 ## 执行原则