Files
order_site/apps/backend/src/services/fulfillment/kuaishou-cloud/dispatch-fulfillment.ts
T
yml2213 1216f252de 修复 lewan 发货后 claim 轮询换号污染绑定与 UID 展示
发货成功时写入 shipped 身份快照,并冻结绑定资源变更;后台 UID 匹配优先使用发货快照,避免误报不一致。
2026-07-16 15:52:54 +08:00

267 lines
8.6 KiB
TypeScript

import { createTaskEvent } from '../../../repositories/task-event-repo.js'
import { updateTask } from '../../../repositories/task-repo.js'
import { createHttpError } from '../../../utils/http.js'
import { nowIso } from '../../../utils/time.js'
import { TASK_STATUS, normalizeTaskStatus } from '../../../domain/task-status.js'
import type { TaskRow } from '../../../types/repository/rows.js'
import { getClaimIdentityFromTask } from '../../claim/claim-identity.js'
import {
buildKuaishouCloudShippedSnapshot,
isIndustryEVoucherTask,
isKuaishouCloudTask,
maskCode,
maskPhone,
normalizeKuaishouCloudFlow,
type JsonObject,
} from './domain.js'
import { resolvePersistedCloudtentaclesContextBySourceKeys } from './cloudtentacles-context.js'
import { syncKuaishouCloudRoleInfoBeforeDispatch } from './dispatch-role-sync.js'
import { markKuaishouCloudDispatchFailed } from './dispatch-failure.js'
import {
prepareStockAndDispatch,
resolveCloudSkuDispatchLockKeys,
withCloudSkuDispatchLocks,
} from './dispatch-stock.js'
import {
resolveDispatchDeliveryItems,
type DispatchResultItem,
type DispatchStockResult,
} from './dispatch-types.js'
import { returnKuaishouCloudFulfillmentTask } from './return-fulfillment.js'
import { normalizeActor, parseTaskContext } from './task-context.js'
/**
* lewan 自动/后台发货主用例:
* UID 闸门同步角色 → 库存与采购 → cloudtentacles 下发 → 可选自动退号收尾。
*/
export async function dispatchKuaishouCloudFulfillmentTask(
task: TaskRow,
options: JsonObject = {},
) {
if (!isKuaishouCloudTask(task)) {
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
statusCode: 409,
errorCode: 'kuaishou_cloud_task_invalid',
})
}
const actor = normalizeActor(options.actor)
const now = nowIso()
const taskContext = parseTaskContext(task)
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment)
const cloudContext = resolvePersistedCloudtentaclesContextBySourceKeys([
flow.binding.resolvedSourceKey,
...flow.binding.cloudSourceKeys,
])
if (!flow.binding.skuId || !flow.binding.vnId || !flow.binding.vnPhone) {
throw createHttpError('当前任务还没有准备好绑定资源,请先完成绑定资源准备', {
statusCode: 409,
errorCode: 'kuaishou_cloud_not_prepared',
})
}
const persistedTicketCode = String(flow.ticket.code || '').trim()
const voucherContext = isPlainObject(taskContext.kuaishouIndustryVoucher)
? taskContext.kuaishouIndustryVoucher
: {}
const industryVoucherCode = String(
voucherContext.voucherCode || voucherContext.eticketId || '',
).trim()
const hasIndustryVoucherForDispatch =
Boolean(industryVoucherCode) || isIndustryEVoucherTask(task)
const resolvedTicketCode = persistedTicketCode || industryVoucherCode
if (!resolvedTicketCode && !hasIndustryVoucherForDispatch) {
throw createHttpError('旧快手小店核销流程已停用,请改用行业电子凭证处理', {
statusCode: 409,
errorCode: 'kuaishou_cloud_missing_ticket_code',
})
}
if (
flow.dispatch.status === 'success' &&
normalizeTaskStatus(task.task_status) === TASK_STATUS.DISPATCHED_PENDING_RETURN
) {
return { task, flow }
}
const synced = await syncKuaishouCloudRoleInfoBeforeDispatch(task, {
now,
actor,
source: options.source || 'system_before_dispatch',
cloudContext,
taskContext,
flow,
})
const syncedFlow = synced.flow
const syncedTaskContext = synced.taskContext
const deliveryItems = resolveDispatchDeliveryItems(syncedFlow as JsonObject)
if (deliveryItems.length === 0) {
throw createHttpError('当前任务缺少 cloud 发货物品配置', {
statusCode: 409,
errorCode: 'kuaishou_cloud_missing_delivery_items',
})
}
let dispatchResults: DispatchResultItem[]
let stockResult: DispatchStockResult
try {
;({ dispatchResults, stockResult } = await withCloudSkuDispatchLocks(
resolveCloudSkuDispatchLockKeys(cloudContext.resolvedSourceKey, deliveryItems),
() =>
prepareStockAndDispatch({
task,
flow: syncedFlow as JsonObject,
cloudContext,
deliveryItems,
}),
))
} catch (error) {
await markKuaishouCloudDispatchFailed(
task,
syncedTaskContext as JsonObject,
syncedFlow as JsonObject,
error,
{
actor,
source: options.source || 'system',
},
)
throw error
}
const firstDispatchResult: DispatchResultItem = dispatchResults[0] || {
cloudSkuId: syncedFlow.binding.skuId,
cloudSkuName: syncedFlow.binding.skuName,
quantity: 1,
unitIndex: 1,
sendType: 0,
note: '',
responseMessage: '',
}
const dispatchSummary =
dispatchResults.length > 1
? `cloudtentacles 发货成功,共 ${dispatchResults.length} 次`
: String(
firstDispatchResult.responseMessage ||
firstDispatchResult.note ||
'cloudtentacles 发货成功',
).trim()
const claimIdentity = getClaimIdentityFromTask(synced.task || task)
const shippedSnapshot = buildKuaishouCloudShippedSnapshot({
expectedUid: claimIdentity.expectedUid,
roleId: syncedFlow.binding.roleId || syncedFlow.role.rid,
roleName: syncedFlow.binding.roleName || syncedFlow.role.name,
vnId: syncedFlow.binding.vnId,
vnPhone: syncedFlow.binding.vnPhone,
vnKey: syncedFlow.binding.vnKey,
dispatchedAt: now,
})
const nextContext = {
...syncedTaskContext,
kuaishouCloudFulfillment: {
...syncedFlow,
ticket: {
...syncedFlow.ticket,
code: resolvedTicketCode,
capturedAt: resolvedTicketCode
? syncedFlow.ticket.capturedAt || now
: syncedFlow.ticket.capturedAt,
capturedBy: syncedFlow.ticket.capturedBy,
},
dispatch: {
...syncedFlow.dispatch,
status: 'success',
dispatchAt: now,
dispatchBy: actor,
sendType: Number(firstDispatchResult.sendType || 0) || 0,
note: dispatchSummary,
items: dispatchResults,
},
// 冻结发货身份:后续 claim 轮询/过期换号不得改写展示与匹配依据
shipped: syncedFlow.shipped || shippedSnapshot,
purchase: {
...syncedFlow.purchase,
usedKnapsack: stockResult.usedKnapsack,
purchaseTriggered: stockResult.purchaseTriggered,
assetBefore: stockResult.assetBefore,
assetAfter: stockResult.assetAfter,
purchaseAt: stockResult.purchaseTriggered
? now
: syncedFlow.purchase.purchaseAt,
items: stockResult.items,
},
},
}
let updatedTask = await updateTask(task.id, {
task_status: TASK_STATUS.DISPATCHED_PENDING_RETURN,
delivery_status: 'delivered',
result_code: 'kuaishou_cloud_dispatched',
result_message: dispatchSummary,
user_action_status: 'not_required',
last_error: '',
context_json: JSON.stringify(nextContext),
updated_at: now,
})
if (!updatedTask) {
throw createHttpError('kuaishou-lewan 发货状态更新失败', {
statusCode: 500,
errorCode: 'kuaishou_cloud_dispatch_update_failed',
})
}
await createTaskEvent(
task.id,
'kuaishou_cloud_dispatched',
{
source: String(options.source || 'system').trim() || 'system',
ticketCodeMasked: maskCode(resolvedTicketCode),
skuId: syncedFlow.binding.skuId,
vnId: syncedFlow.binding.vnId,
vnPhoneMasked: maskPhone(syncedFlow.binding.vnPhone),
expectedUid: shippedSnapshot.expectedUid,
roleId: shippedSnapshot.roleId,
roleName: shippedSnapshot.roleName,
sendType: firstDispatchResult.sendType,
note: dispatchSummary,
deliveryItems,
dispatchResults,
stockResult,
shipped: shippedSnapshot,
actor,
},
now,
)
const shouldAutoFinalize =
options.autoFinalize === true &&
normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment).returnNumber
.autoReturnEnabled === true
if (shouldAutoFinalize) {
const finalizeResult = await returnKuaishouCloudFulfillmentTask(updatedTask, {
actor,
source: options.source || 'system_auto_finalize',
// 发货后自动收尾:退号并尝试核销(与 admin 清理退号不同)
consumeIndustryVoucher: true,
})
updatedTask = finalizeResult.task
}
return {
task: updatedTask,
flow: normalizeKuaishouCloudFlow(
parseTaskContext(updatedTask).kuaishouCloudFulfillment,
),
}
}
function isPlainObject(value: unknown): value is JsonObject {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}