后端迁移履约任务服务
This commit is contained in:
@@ -0,0 +1,494 @@
|
||||
import { createTask, listTasksByOrderId, updateTask } from '../../repositories/task-repo.js'
|
||||
import {
|
||||
listFulfillmentProfileRequirements,
|
||||
resolveFulfillmentBinding,
|
||||
} from '../../repositories/fulfillment-profile-repo.js'
|
||||
import { reserveInventoryForTask } from './inventory-service.js'
|
||||
import { createTaskClaimToken } from '../claim/claim-service.js'
|
||||
import { notifyTaskAutoManualReview } from '../notification/domain-notifications.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { randomId } from '../../utils/random.js'
|
||||
import type { InventoryItemRow, OrderItemRow, OrderRow, TaskRow } from '../../types/repository-rows.js'
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
|
||||
type ClaimTokenLike = {
|
||||
token: string
|
||||
expired_at: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type FulfillmentBindingLike = {
|
||||
id: number
|
||||
profile_id?: number
|
||||
profile_key?: string
|
||||
profile_name?: string
|
||||
name?: string
|
||||
executor_key?: string
|
||||
requires_claim?: boolean
|
||||
auto_dispatch?: boolean
|
||||
config_json?: string | JsonObject
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type FulfillmentRequirementLike = {
|
||||
role_key?: string
|
||||
roleKey?: string
|
||||
credential_type?: string
|
||||
credentialType?: string
|
||||
is_required?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type DeliveryTaskRow = TaskRow & {
|
||||
skuCode?: string
|
||||
skuName?: string
|
||||
}
|
||||
|
||||
type DeliveryTaskDeps = {
|
||||
createTask?: typeof createTask
|
||||
listTasksByOrderId?: typeof listTasksByOrderId
|
||||
updateTask?: typeof updateTask
|
||||
listFulfillmentProfileRequirements?: (
|
||||
profileId: number | string,
|
||||
) => Promise<FulfillmentRequirementLike[]>
|
||||
resolveFulfillmentBinding?: (input: {
|
||||
skuCode: string
|
||||
provider?: string
|
||||
platform?: string
|
||||
shopId?: string
|
||||
}) => Promise<FulfillmentBindingLike | null>
|
||||
reserveInventoryForTask?: (input: {
|
||||
skuCode: string
|
||||
taskId: number | string
|
||||
credentialType?: string
|
||||
roleKey?: string
|
||||
}) => Promise<InventoryItemRow | null>
|
||||
createTaskClaimToken?: (taskId: number | string) => Promise<ClaimTokenLike>
|
||||
notifyTaskAutoManualReview?: (payload: {
|
||||
task: unknown
|
||||
reason: string
|
||||
source: string
|
||||
}) => Promise<unknown> | unknown
|
||||
nowIso?: () => string
|
||||
randomId?: (prefix?: string) => string
|
||||
}
|
||||
|
||||
type RuntimeDeliveryTaskDeps = Required<Pick<
|
||||
DeliveryTaskDeps,
|
||||
'updateTask'
|
||||
| 'reserveInventoryForTask'
|
||||
| 'createTaskClaimToken'
|
||||
| 'notifyTaskAutoManualReview'
|
||||
| 'nowIso'
|
||||
>>
|
||||
|
||||
type TaskContext = {
|
||||
inventorySkuCode?: string
|
||||
primaryRequirement?: {
|
||||
roleKey?: string
|
||||
credentialType?: string
|
||||
} | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export async function syncDeliveryTasksForOrder(
|
||||
order: OrderRow,
|
||||
orderItems: OrderItemRow[],
|
||||
): Promise<Array<TaskRow | null>> {
|
||||
return syncDeliveryTasksForOrderWithDeps(order, orderItems)
|
||||
}
|
||||
|
||||
export async function syncDeliveryTasksForOrderWithDeps(
|
||||
order: OrderRow,
|
||||
orderItems: OrderItemRow[],
|
||||
deps: DeliveryTaskDeps = {},
|
||||
): Promise<Array<TaskRow | null>> {
|
||||
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: RuntimeDeliveryTaskDeps = {
|
||||
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') {
|
||||
return existingTasks
|
||||
}
|
||||
|
||||
const itemMap = new Map(orderItems.map((item) => [item.id, item]))
|
||||
return Promise.all(existingTasks.map((task) => preparePaidTask({
|
||||
...task,
|
||||
skuCode: itemMap.get(task.order_item_id)?.sku_code || '',
|
||||
skuName: itemMap.get(task.order_item_id)?.sku_name || '',
|
||||
}, runtimeDeps)))
|
||||
}
|
||||
|
||||
const tasks: DeliveryTaskRow[] = []
|
||||
|
||||
for (const item of orderItems) {
|
||||
const binding = await resolveBinding({
|
||||
skuCode: item.sku_code,
|
||||
provider: order.provider,
|
||||
platform: order.platform,
|
||||
shopId: order.shop_id,
|
||||
})
|
||||
|
||||
if (!binding) {
|
||||
continue
|
||||
}
|
||||
const profile = binding
|
||||
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)
|
||||
const cloudtentaclesConfig = parseJsonObject(fulfillmentConfig.cloudtentacles)
|
||||
const kuaishouConsumeConfig = parseJsonObject(fulfillmentConfig.kuaishouConsume)
|
||||
const kuaishouShopConfig = parseJsonObject(fulfillmentConfig.kuaishouShop)
|
||||
|
||||
for (let index = 0; index < quantity; index += 1) {
|
||||
const createdAt = getNowIso()
|
||||
const initialStatus = order.pay_status === 'paid'
|
||||
? resolvePaidTaskStatus(profile)
|
||||
: 'pending_payment'
|
||||
|
||||
const task = await createDeliveryTask({
|
||||
orderId: order.id,
|
||||
orderItemId: item.id,
|
||||
unitIndex: index + 1,
|
||||
provider: order.provider,
|
||||
platform: order.platform,
|
||||
shopId: order.shop_id,
|
||||
shopName: order.shop_name,
|
||||
platformOrderId: order.platform_order_id,
|
||||
taskNo: createRandomId('DT'),
|
||||
profileId: Number(profile.profile_id || profile.id),
|
||||
executorKey: String(profile.executor_key || 'manual_dispatch'),
|
||||
taskStatus: initialStatus,
|
||||
inventoryStatus: profile.requires_claim ? 'pending' : 'not_required',
|
||||
deliveryStatus: 'pending',
|
||||
resultCode: '',
|
||||
resultMessage: '',
|
||||
claimToken: '',
|
||||
claimExpiresAt: null,
|
||||
automationMode: profile.auto_dispatch ? 'automatic' : 'manual',
|
||||
requiresClaim: Boolean(profile.requires_claim),
|
||||
userActionStatus: profile.requires_claim ? 'pending_claim' : 'not_required',
|
||||
attemptCount: 0,
|
||||
lastError: '',
|
||||
contextJson: JSON.stringify({
|
||||
profileKey: String(profile.profile_key || ''),
|
||||
profileName: String(profile.profile_name || profile.name || ''),
|
||||
skuCode: item.sku_code,
|
||||
skuName: item.sku_name,
|
||||
inventorySkuCode: item.sku_code,
|
||||
primaryRequirement: primaryRequirement
|
||||
? {
|
||||
roleKey: String(primaryRequirement.role_key || primaryRequirement.roleKey || 'primary_code'),
|
||||
credentialType: String(primaryRequirement.credential_type || primaryRequirement.credentialType || 'tencent_code'),
|
||||
}
|
||||
: null,
|
||||
kuaishouCloudFulfillment: isKuaishouCloudExecutor(profile.executor_key)
|
||||
? {
|
||||
flowType: 'kuaishou_cloud_fulfillment',
|
||||
configId: String(fulfillmentConfig.configId || '').trim(),
|
||||
internalSkuCode: item.sku_code,
|
||||
internalSkuName: item.sku_name,
|
||||
ticket: {
|
||||
code: '',
|
||||
status: 'pending',
|
||||
capturedAt: null,
|
||||
capturedBy: null,
|
||||
verifiedAt: null,
|
||||
oid: '',
|
||||
formToken: '',
|
||||
leftCount: 0,
|
||||
goodsTitle: '',
|
||||
},
|
||||
binding: {
|
||||
prepareStatus: 'pending',
|
||||
cloudSourceKey: String(cloudtentaclesConfig.cloudSourceKey || 'default').trim() || 'default',
|
||||
skuId: Number(cloudtentaclesConfig.skuId || 0) || 0,
|
||||
skuName: String(cloudtentaclesConfig.skuName || '').trim(),
|
||||
vnKey: '1',
|
||||
vnId: 0,
|
||||
vnPhone: '',
|
||||
bindUrl: '',
|
||||
bindPreparedAt: null,
|
||||
bindExpiresAt: null,
|
||||
bindProbeAt: null,
|
||||
bindProbeStatus: '',
|
||||
bindProbeMessage: '',
|
||||
},
|
||||
role: {
|
||||
status: 'pending',
|
||||
name: '',
|
||||
rid: '',
|
||||
refreshedAt: null,
|
||||
errorMessage: '',
|
||||
rawInfo: null,
|
||||
},
|
||||
purchase: {
|
||||
autoBuyEnabled: cloudtentaclesConfig.autoBuyEnabled !== false,
|
||||
minAssetReserve: Number(cloudtentaclesConfig.minAssetReserve || 0) || 0,
|
||||
usedKnapsack: false,
|
||||
purchaseTriggered: false,
|
||||
assetBefore: 0,
|
||||
assetAfter: 0,
|
||||
purchaseAt: null,
|
||||
},
|
||||
dispatch: {
|
||||
status: 'pending',
|
||||
dispatchAt: null,
|
||||
dispatchBy: null,
|
||||
sendType: 0,
|
||||
note: '',
|
||||
},
|
||||
returnNumber: {
|
||||
status: 'pending',
|
||||
returnedAt: null,
|
||||
returnedBy: null,
|
||||
autoReturnEnabled: cloudtentaclesConfig.autoReturnNumberAfterDispatch === true,
|
||||
},
|
||||
consume: {
|
||||
status: 'pending',
|
||||
shopId: String(kuaishouConsumeConfig.shopId || kuaishouShopConfig.shopId || '').trim(),
|
||||
shopName: String(kuaishouConsumeConfig.shopName || kuaishouShopConfig.kshopName || order.shop_name || '').trim(),
|
||||
autoConsumeEnabled: kuaishouConsumeConfig.autoConsumeAfterDispatch === true,
|
||||
consumedAt: null,
|
||||
errorMessage: '',
|
||||
},
|
||||
notes: String(fulfillmentConfig.notes || '').trim(),
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
})
|
||||
|
||||
tasks.push({
|
||||
...task,
|
||||
skuCode: item.sku_code,
|
||||
skuName: item.sku_name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (order.pay_status !== 'paid') {
|
||||
return tasks
|
||||
}
|
||||
|
||||
return Promise.all(tasks.map((task) => preparePaidTask(task, runtimeDeps)))
|
||||
}
|
||||
|
||||
async function preparePaidTask(
|
||||
task: DeliveryTaskRow,
|
||||
deps: Partial<RuntimeDeliveryTaskDeps> = {},
|
||||
): Promise<TaskRow | DeliveryTaskRow | null> {
|
||||
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()
|
||||
|
||||
if (['link_generated', 'claimed', 'role_confirmed', 'redeeming', 'redeemed', 'closed'].includes(task.task_status)) {
|
||||
return task
|
||||
}
|
||||
|
||||
if (isKuaishouCloudExecutor(task.executor_key)) {
|
||||
if ([
|
||||
'pending_binding_prepare',
|
||||
'waiting_binding',
|
||||
'dispatched_pending_return',
|
||||
'completed',
|
||||
'manual_review',
|
||||
'failed',
|
||||
].includes(String(task.task_status || '').trim())) {
|
||||
if (!task.primary_claim_token_id && !String(task.claim_token || '').trim()) {
|
||||
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',
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
return task
|
||||
}
|
||||
|
||||
const claimToken = await createClaimToken(task.id)
|
||||
|
||||
return updateDeliveryTask(task.id, {
|
||||
task_status: 'pending_binding_prepare',
|
||||
inventory_status: 'not_required',
|
||||
claim_token: claimToken.token,
|
||||
claim_expires_at: claimToken.expired_at,
|
||||
user_action_status: 'pending_claim',
|
||||
last_error: task.last_error || '领取链接已生成,等待客户提交核销码',
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
if (!task.requires_claim || String(task.executor_key || '').trim() === 'manual_dispatch') {
|
||||
const lastError = task.last_error || '当前任务需要人工履约处理'
|
||||
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 notifyManualReview({
|
||||
task: updatedTask || task,
|
||||
reason: lastError,
|
||||
source: 'manual_dispatch_profile',
|
||||
})
|
||||
return updatedTask
|
||||
}
|
||||
|
||||
if (!inventorySkuCode) {
|
||||
const lastError = '未匹配到 SKU,无法为任务分配库存凭据'
|
||||
const updatedTask = await updateDeliveryTask(task.id, {
|
||||
task_status: 'manual_review',
|
||||
last_error: lastError,
|
||||
updated_at: now,
|
||||
})
|
||||
await notifyManualReview({
|
||||
task: updatedTask || task,
|
||||
reason: lastError,
|
||||
source: 'missing_sku',
|
||||
})
|
||||
return updatedTask
|
||||
}
|
||||
|
||||
if (task.primary_inventory_item_id && task.primary_claim_token_id) {
|
||||
return updateDeliveryTask(task.id, {
|
||||
task_status: 'link_generated',
|
||||
last_error: '',
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
if (!primaryRequirement?.credentialType) {
|
||||
const lastError = '履约档案未配置库存要求,无法自动分配库存'
|
||||
const updatedTask = await updateDeliveryTask(task.id, {
|
||||
task_status: 'manual_review',
|
||||
last_error: lastError,
|
||||
updated_at: now,
|
||||
})
|
||||
await notifyManualReview({
|
||||
task: updatedTask || task,
|
||||
reason: lastError,
|
||||
source: 'missing_inventory_requirement',
|
||||
})
|
||||
return updatedTask
|
||||
}
|
||||
|
||||
const reserved = await reserveInventory({
|
||||
skuCode: inventorySkuCode,
|
||||
taskId: task.id,
|
||||
credentialType: primaryRequirement.credentialType,
|
||||
roleKey: primaryRequirement.roleKey || 'primary_code',
|
||||
})
|
||||
|
||||
if (!reserved) {
|
||||
return updateDeliveryTask(task.id, {
|
||||
task_status: 'waiting_inventory',
|
||||
inventory_status: 'pending',
|
||||
last_error: '库存不足,等待可用库存凭据',
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
const claimToken = await createClaimToken(task.id)
|
||||
|
||||
return updateDeliveryTask(task.id, {
|
||||
task_status: 'link_generated',
|
||||
inventory_status: 'reserved',
|
||||
claim_token: claimToken.token,
|
||||
claim_expires_at: claimToken.expired_at,
|
||||
user_action_status: 'pending_claim',
|
||||
last_error: '',
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
function resolvePaidTaskStatus(profile: FulfillmentBindingLike | null | undefined): string {
|
||||
if (Boolean(profile?.requires_claim)) {
|
||||
return 'paid'
|
||||
}
|
||||
|
||||
if (isKuaishouCloudExecutor(profile?.executor_key)) {
|
||||
return 'pending_binding_prepare'
|
||||
}
|
||||
|
||||
if (String(profile?.executor_key || '').trim() === 'manual_dispatch') {
|
||||
return 'manual_review'
|
||||
}
|
||||
|
||||
return 'paid'
|
||||
}
|
||||
|
||||
function parseTaskContext(task: { context_json?: unknown } | null | undefined): TaskContext {
|
||||
const value = task?.context_json
|
||||
|
||||
if (!value) {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return value as TaskContext
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(String(value || '{}'))
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as TaskContext : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonObject(value: unknown): JsonObject {
|
||||
if (!value) {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as JsonObject
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(String(value || '{}'))
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as JsonObject : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function isKuaishouCloudExecutor(value: unknown): boolean {
|
||||
return String(value || '').trim() === 'kuaishou_ct_assisted'
|
||||
}
|
||||
Reference in New Issue
Block a user