Files
order_site/apps/backend/src/services/order/delivery-task-service.ts
T
2026-05-27 19:54:00 +08:00

523 lines
17 KiB
TypeScript

import { createTask, listTasksByOrderId, updateTask } from '../../repositories/task-repo.js'
import { getFulfillmentProfileByKey } from '../../repositories/fulfillment-profile-repo.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 { 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 DeliveryTaskRow = TaskRow & {
skuCode?: string
skuName?: string
}
type DeliveryTaskDeps = {
createTask?: typeof createTask
listTasksByOrderId?: typeof listTasksByOrderId
updateTask?: typeof updateTask
getFulfillmentProfileByKey?: (profileKey: string) => Promise<FulfillmentBindingLike | 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'
| 'createTaskClaimToken'
| 'notifyTaskAutoManualReview'
| 'nowIso'
>>
type TaskContext = {
[key: string]: unknown
}
export async function syncDeliveryTasksForOrder(
order: OrderRow,
orderItems: OrderItemRow[],
): Promise<TaskRow[]> {
return syncDeliveryTasksForOrderWithDeps(order, orderItems)
}
export async function syncDeliveryTasksForOrderWithDeps(
order: OrderRow,
orderItems: OrderItemRow[],
deps: DeliveryTaskDeps = {},
): Promise<TaskRow[]> {
const {
createTask: createDeliveryTask = createTask,
listTasksByOrderId: listTasks = listTasksByOrderId,
updateTask: updateDeliveryTask = updateTask,
getFulfillmentProfileByKey: getProfileByKey = getFulfillmentProfileByKey,
createTaskClaimToken: createClaimToken = createTaskClaimToken,
notifyTaskAutoManualReview: notifyManualReview = notifyTaskAutoManualReview,
nowIso: getNowIso = nowIso,
randomId: createRandomId = randomId,
} = deps
const runtimeDeps: RuntimeDeliveryTaskDeps = {
updateTask: updateDeliveryTask,
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]))
const preparedTasks = await 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)))
return preparedTasks.filter(isTaskRow)
}
const tasks: DeliveryTaskRow[] = []
for (const item of orderItems) {
const profile = await resolveDynamicCloudtentaclesProfile(item, getProfileByKey)
if (!profile) {
continue
}
const itemSnapshot = parseJsonObject(item.item_snapshot_json)
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)
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()
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,
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,
kuaishouCloudFulfillment: isKuaishouCloudExecutor(profile.executor_key)
? {
flowType: 'kuaishou_cloud_fulfillment',
configId: String(fulfillmentConfig.configId || '').trim(),
internalSkuCode: item.sku_code,
internalSkuName: item.sku_name,
deliveryItems,
ticket: {
code: '',
status: 'pending',
capturedAt: null,
capturedBy: null,
verifiedAt: null,
oid: '',
formToken: '',
leftCount: 0,
goodsTitle: '',
},
binding: {
prepareStatus: 'pending',
cloudSourceKeys: normalizeStringArray(cloudtentaclesConfig.cloudSourceKeys),
resolvedSourceKey: String(cloudtentaclesConfig.resolvedSourceKey || '').trim(),
skuId: primaryDeliveryItem.cloudSkuId,
skuName: primaryDeliveryItem.cloudSkuName,
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 || itemSnapshot.shopId || order.shop_id || '').trim(),
shopName: String(kuaishouConsumeConfig.shopName || kuaishouShopConfig.kshopName || itemSnapshot.shopName || order.shop_name || '').trim(),
autoConsumeEnabled: kuaishouConsumeConfig.autoConsumeAfterDispatch === true,
consumedAt: null,
errorMessage: '',
},
notes: String(fulfillmentConfig.notes || '').trim(),
}
: null,
}),
createdAt,
updatedAt: createdAt,
})
if (task) {
tasks.push({
...task,
skuCode: item.sku_code,
skuName: item.sku_name,
})
}
}
}
if (order.pay_status !== 'paid') {
return tasks
}
const preparedTasks = await Promise.all(tasks.map((task) => preparePaidTask(task, runtimeDeps)))
return preparedTasks.filter(isTaskRow)
}
async function preparePaidTask(
task: DeliveryTaskRow,
deps: Partial<RuntimeDeliveryTaskDeps> = {},
): Promise<TaskRow | DeliveryTaskRow | null> {
const {
updateTask: updateDeliveryTask = updateTask,
createTaskClaimToken: createClaimToken = createTaskClaimToken,
notifyTaskAutoManualReview: notifyManualReview = notifyTaskAutoManualReview,
nowIso: getNowIso = nowIso,
} = deps
const now = getNowIso()
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',
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',
user_action_status: 'not_required',
last_error: lastError,
updated_at: now,
})
await notifyManualReview({
task: updatedTask || task,
reason: lastError,
source: 'manual_dispatch_profile',
})
return updatedTask
}
return task
}
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 {}
}
}
async function resolveDynamicCloudtentaclesProfile(
item: OrderItemRow,
getProfileByKey: (profileKey: string) => Promise<FulfillmentBindingLike | null>,
): Promise<FulfillmentBindingLike | null> {
const snapshot = parseJsonObject(item.item_snapshot_json)
const cloudtentacles = parseJsonObject(snapshot.cloudtentacles)
const cloudSourceKeys = normalizeStringArray(cloudtentacles.cloudSourceKeys)
const deliveryItems = normalizeCloudDeliveryItems({
deliveryItems: cloudtentacles.deliveryItems,
skuId: cloudtentacles.cloudSkuId,
skuName: cloudtentacles.cloudSkuName || item.sku_name,
})
const primaryDeliveryItem = deliveryItems[0] || {
cloudSkuId: 0,
cloudSkuName: '',
quantity: 1,
}
if (!primaryDeliveryItem.cloudSkuId || !primaryDeliveryItem.cloudSkuName || cloudSourceKeys.length === 0) {
return null
}
const profile = await getProfileByKey('kuaishou_ct_assisted')
if (!profile) {
return null
}
return {
...profile,
profile_id: Number(profile.id || profile.profile_id || 0),
profile_key: 'kuaishou_ct_assisted',
profile_name: String(profile.profile_name || profile.name || '快手 cloud 履约').trim(),
executor_key: 'kuaishou_ct_assisted',
requires_claim: false,
auto_dispatch: false,
config_json: {
flowType: 'kuaishou_cloud_fulfillment',
configId: `${String(cloudtentacles.matchMode || 'cloudtentacles_name').trim()}:${String(cloudtentacles.normalizedProductName || primaryDeliveryItem.cloudSkuId).trim()}`,
cloudtentacles: {
cloudSourceKeys,
skuId: primaryDeliveryItem.cloudSkuId,
skuName: primaryDeliveryItem.cloudSkuName,
resolvedSourceKey: String(cloudtentacles.resolvedSourceKey || '').trim(),
deliveryItems,
vnKey: '1',
autoBuyEnabled: true,
minAssetReserve: 0,
autoReturnNumberAfterDispatch: true,
},
kuaishouConsume: {
shopId: String(snapshot.shopId || '').trim(),
shopName: String(snapshot.shopName || '').trim(),
autoConsumeAfterDispatch: false,
},
notes: cloudtentacles.matchMode === 'cloudtentacles_override'
? '91卡券商品名命中 cloudtentacles 覆盖规则'
: '91卡券商品名自动匹配 cloudtentacles 商品',
},
}
}
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
return []
}
return Array.from(
new Set(value.map((item) => String(item || '').trim()).filter(Boolean)),
)
}
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<number, { cloudSkuId: number; cloudSkuName: string; quantity: number }>()
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)
}
function isKuaishouCloudExecutor(value: unknown): boolean {
return String(value || '').trim() === 'kuaishou_ct_assisted'
}