后端补充履约任务服务测试
This commit is contained in:
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
@@ -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、自动发货
|
||||||
|
|
||||||
## 执行原则
|
## 执行原则
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user