426 lines
14 KiB
TypeScript
426 lines
14 KiB
TypeScript
import { createTask, listTasksByOrderId, updateTask } from '../../repositories/task-repo.js'
|
|
import { createTaskEvent } from '../../repositories/task-event-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 {
|
|
planFulfillmentTaskForOrderItem,
|
|
type FulfillmentBindingLike,
|
|
} from '../fulfillment/planner.js'
|
|
import { preparePaidFulfillmentTask } from '../fulfillment/executors/registry.js'
|
|
import {
|
|
FULFILLMENT_EXECUTOR_KEYS,
|
|
type FulfillmentPrepareDeps,
|
|
} from '../fulfillment/executors/types.js'
|
|
import { nowIso } from '../../utils/time.js'
|
|
import { randomId } from '../../utils/random.js'
|
|
import { logInfo, logWarn } from '../../utils/logger.js'
|
|
import { TASK_STATUS, resolveInitialPaidTaskStatus } from '../../domain/task-status.js'
|
|
import type { JsonObject } from '../../types/json.js'
|
|
import type { OrderItemRow, OrderRow, TaskRow } from '../../types/repository/rows.js'
|
|
import {
|
|
findConflictingCarrier,
|
|
loadActiveCarrierEvidence,
|
|
} from '../fulfillment/carrier-evidence-service.js'
|
|
import {
|
|
isInactiveCarrierTaskStatus,
|
|
type FulfillmentChannel,
|
|
} from '../fulfillment/channel-types.js'
|
|
|
|
type ClaimTokenLike = {
|
|
token: string
|
|
expired_at: string
|
|
[key: string]: unknown
|
|
}
|
|
|
|
type DeliveryTaskRow = TaskRow & {
|
|
skuCode?: string
|
|
skuName?: string
|
|
}
|
|
|
|
type DeliveryTaskDeps = {
|
|
createTask?: typeof createTask
|
|
listTasksByOrderId?: typeof listTasksByOrderId
|
|
loadActiveCarrierEvidence?: typeof loadActiveCarrierEvidence
|
|
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
|
|
createTaskEvent?: typeof createTaskEvent
|
|
logInfo?: typeof logInfo
|
|
logWarn?: typeof logWarn
|
|
allowInactiveTaskRetry?: boolean
|
|
}
|
|
|
|
export async function syncDeliveryTasksForOrder(
|
|
order: OrderRow,
|
|
orderItems: OrderItemRow[],
|
|
options: { allowInactiveTaskRetry?: boolean } = {},
|
|
): Promise<TaskRow[]> {
|
|
return syncDeliveryTasksForOrderWithDeps(order, orderItems, options)
|
|
}
|
|
|
|
export async function syncDeliveryTasksForOrderWithDeps(
|
|
order: OrderRow,
|
|
orderItems: OrderItemRow[],
|
|
deps: DeliveryTaskDeps = {},
|
|
): Promise<TaskRow[]> {
|
|
const {
|
|
createTask: createDeliveryTask = createTask,
|
|
listTasksByOrderId: listTasks = listTasksByOrderId,
|
|
loadActiveCarrierEvidence: loadCarrierEvidence = loadActiveCarrierEvidence,
|
|
updateTask: updateDeliveryTask = updateTask,
|
|
getFulfillmentProfileByKey: getProfileByKey = getFulfillmentProfileByKey,
|
|
createTaskClaimToken: createClaimToken = createTaskClaimToken,
|
|
notifyTaskAutoManualReview: notifyManualReview = notifyTaskAutoManualReview,
|
|
nowIso: getNowIso = nowIso,
|
|
randomId: createRandomId = randomId,
|
|
createTaskEvent: appendTaskEvent = createTaskEvent,
|
|
logInfo: writeInfoLog = logInfo,
|
|
logWarn: writeWarnLog = logWarn,
|
|
allowInactiveTaskRetry = false,
|
|
} = deps
|
|
|
|
const runtimeDeps: FulfillmentPrepareDeps = {
|
|
updateTask: updateDeliveryTask,
|
|
createTaskClaimToken: createClaimToken,
|
|
notifyTaskAutoManualReview: notifyManualReview,
|
|
nowIso: getNowIso,
|
|
}
|
|
|
|
const existingTasks = await listTasks(order.id)
|
|
const tasks: DeliveryTaskRow[] = [...existingTasks]
|
|
const existingTaskKeys = new Set(
|
|
(allowInactiveTaskRetry
|
|
? existingTasks.filter((task) => !isInactiveCarrierTaskStatus(task.task_status))
|
|
: existingTasks
|
|
).map(buildTaskUnitKey),
|
|
)
|
|
const carrierEvidence = await loadCarrierEvidence(orderItems.map((item) => Number(item.id)))
|
|
|
|
writeInfoLog('[delivery-task-service]', '开始核对订单履约任务完整性', {
|
|
...buildOrderLogContext(order),
|
|
orderItemCount: orderItems.length,
|
|
existingTaskCount: existingTasks.length,
|
|
})
|
|
|
|
for (const item of orderItems) {
|
|
const quantity = Math.max(1, Number(item.quantity || 1))
|
|
const missingUnitIndexes = Array.from({ length: quantity }, (_, index) => index + 1).filter(
|
|
(unitIndex) => !existingTaskKeys.has(buildOrderItemUnitKey(item.id, unitIndex)),
|
|
)
|
|
|
|
if (missingUnitIndexes.length === 0) {
|
|
continue
|
|
}
|
|
|
|
let plan
|
|
try {
|
|
plan = await planFulfillmentTaskForOrderItem({
|
|
order,
|
|
item,
|
|
getProfileByKey,
|
|
})
|
|
} catch (error) {
|
|
writeWarnLog('[delivery-task-service]', '订单项履约任务规划执行异常', {
|
|
...buildOrderLogContext(order),
|
|
...buildOrderItemLogContext(item),
|
|
missingUnitIndexes,
|
|
planningDiagnostic: buildPlanningDiagnostic(item),
|
|
error: error instanceof Error ? error.message : String(error),
|
|
})
|
|
throw error
|
|
}
|
|
|
|
if (!plan) {
|
|
writeWarnLog('[delivery-task-service]', '订单项已配置履约路由但无法生成任务计划', {
|
|
...buildOrderLogContext(order),
|
|
...buildOrderItemLogContext(item),
|
|
missingUnitIndexes,
|
|
planningDiagnostic: buildPlanningDiagnostic(item),
|
|
})
|
|
continue
|
|
}
|
|
|
|
const plannedChannel = executorKeyToFulfillmentChannel(plan.executorKey)
|
|
if (plannedChannel) {
|
|
const assignedChannel = String(item.fulfillment_channel || '').trim()
|
|
if (item.fulfillment_status === 'assigned' && assignedChannel !== plannedChannel) {
|
|
writeInfoLog('[delivery-task-service]', '订单项已分配其它渠道,跳过载体补建', {
|
|
...buildOrderItemLogContext(item),
|
|
assignedChannel,
|
|
plannedChannel,
|
|
})
|
|
continue
|
|
}
|
|
|
|
const conflictingCarrier = findConflictingCarrier(
|
|
carrierEvidence.get(Number(item.id)),
|
|
plannedChannel,
|
|
)
|
|
if (conflictingCarrier) {
|
|
writeWarnLog('[delivery-task-service]', '订单项已有其它真实载体,跳过载体补建', {
|
|
...buildOrderItemLogContext(item),
|
|
existingCarrier: conflictingCarrier,
|
|
plannedChannel,
|
|
})
|
|
continue
|
|
}
|
|
}
|
|
|
|
for (const unitIndex of missingUnitIndexes) {
|
|
const createdAt = getNowIso()
|
|
const initialStatus =
|
|
order.pay_status === 'paid'
|
|
? resolvePaidTaskStatus(plan.profile)
|
|
: TASK_STATUS.PENDING_PAYMENT
|
|
|
|
let createdFresh = true
|
|
let task: TaskRow | null
|
|
try {
|
|
task = await createDeliveryTask({
|
|
orderId: order.id,
|
|
orderItemId: item.id,
|
|
unitIndex,
|
|
provider: order.provider,
|
|
platform: order.platform,
|
|
shopId: order.shop_id,
|
|
shopName: order.shop_name,
|
|
platformOrderId: order.platform_order_id,
|
|
taskNo: createRandomId('DT'),
|
|
profileId: plan.profileId,
|
|
executorKey: plan.executorKey,
|
|
taskStatus: initialStatus,
|
|
deliveryStatus: 'pending',
|
|
resultCode: '',
|
|
resultMessage: '',
|
|
claimToken: '',
|
|
claimExpiresAt: null,
|
|
automationMode: plan.autoDispatch ? 'automatic' : 'manual',
|
|
requiresClaim: plan.requiresClaim,
|
|
userActionStatus: plan.requiresClaim ? 'pending_claim' : 'not_required',
|
|
attemptCount: 0,
|
|
lastError: '',
|
|
contextJson: JSON.stringify(plan.context),
|
|
createdAt,
|
|
updatedAt: createdAt,
|
|
})
|
|
} catch (error) {
|
|
const concurrentTasks = await listTasks(order.id)
|
|
task =
|
|
concurrentTasks.find(
|
|
(candidate) =>
|
|
(allowInactiveTaskRetry
|
|
? !isInactiveCarrierTaskStatus(candidate.task_status)
|
|
: true) &&
|
|
buildTaskUnitKey(candidate) === buildOrderItemUnitKey(item.id, unitIndex),
|
|
) || null
|
|
if (!task) {
|
|
writeWarnLog('[delivery-task-service]', '履约任务创建执行异常', {
|
|
...buildOrderLogContext(order),
|
|
...buildOrderItemLogContext(item),
|
|
unitIndex,
|
|
profileId: plan.profileId,
|
|
profileKey: plan.profileKey,
|
|
executorKey: plan.executorKey,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
})
|
|
throw error
|
|
}
|
|
|
|
createdFresh = false
|
|
writeInfoLog('[delivery-task-service]', '并发同步已创建相同履约任务,本次直接复用', {
|
|
...buildOrderLogContext(order),
|
|
...buildOrderItemLogContext(item),
|
|
unitIndex,
|
|
taskId: task.id,
|
|
taskNo: task.task_no,
|
|
})
|
|
}
|
|
|
|
if (task) {
|
|
const deliveryTask = {
|
|
...task,
|
|
skuCode: item.sku_code,
|
|
skuName: item.sku_name,
|
|
}
|
|
tasks.push(deliveryTask)
|
|
existingTaskKeys.add(buildTaskUnitKey(task))
|
|
if (createdFresh) {
|
|
await appendTaskEvent(
|
|
task.id,
|
|
'fulfillment_task_planned',
|
|
{
|
|
source: 'order_task_reconciliation',
|
|
orderId: order.id,
|
|
platformOrderId: order.platform_order_id,
|
|
orderItemId: item.id,
|
|
unitIndex,
|
|
skuCode: item.sku_code,
|
|
skuName: item.sku_name,
|
|
profileId: plan.profileId,
|
|
profileKey: plan.profileKey,
|
|
executorKey: plan.executorKey,
|
|
automationMode: plan.autoDispatch ? 'automatic' : 'manual',
|
|
initialStatus,
|
|
},
|
|
createdAt,
|
|
)
|
|
}
|
|
} else {
|
|
writeWarnLog('[delivery-task-service]', '履约任务创建未返回记录', {
|
|
...buildOrderLogContext(order),
|
|
...buildOrderItemLogContext(item),
|
|
unitIndex,
|
|
profileId: plan.profileId,
|
|
profileKey: plan.profileKey,
|
|
executorKey: plan.executorKey,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
writeInfoLog('[delivery-task-service]', '订单履约任务完整性核对完成', {
|
|
...buildOrderLogContext(order),
|
|
existingTaskCount: existingTasks.length,
|
|
createdTaskCount: tasks.length - existingTasks.length,
|
|
totalTaskCount: tasks.length,
|
|
expectedTaskCount: orderItems.reduce(
|
|
(total, item) => total + Math.max(1, Number(item.quantity || 1)),
|
|
0,
|
|
),
|
|
})
|
|
|
|
if (order.pay_status !== 'paid') {
|
|
return tasks
|
|
}
|
|
|
|
const preparedTasks = await Promise.all(
|
|
tasks.map((task) => preparePaidTaskWithDiagnostics(order, task, runtimeDeps, writeWarnLog)),
|
|
)
|
|
return preparedTasks.filter(isTaskRow)
|
|
}
|
|
|
|
function executorKeyToFulfillmentChannel(executorKey: string): FulfillmentChannel | null {
|
|
if (executorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH) return 'affiliate_dash'
|
|
if (executorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH) return 'manual'
|
|
return null
|
|
}
|
|
|
|
async function preparePaidTaskWithDiagnostics(
|
|
order: OrderRow,
|
|
task: TaskRow,
|
|
deps: FulfillmentPrepareDeps,
|
|
writeWarnLog: typeof logWarn,
|
|
) {
|
|
try {
|
|
const prepared = await preparePaidFulfillmentTask(task, deps)
|
|
if (!prepared) {
|
|
writeWarnLog('[delivery-task-service]', '已支付履约任务准备未返回任务记录', {
|
|
...buildOrderLogContext(order),
|
|
taskId: task.id,
|
|
taskNo: task.task_no,
|
|
orderItemId: task.order_item_id,
|
|
unitIndex: task.unit_index,
|
|
executorKey: task.executor_key,
|
|
taskStatus: task.task_status,
|
|
})
|
|
}
|
|
return prepared
|
|
} catch (error) {
|
|
writeWarnLog('[delivery-task-service]', '已支付履约任务准备执行异常', {
|
|
...buildOrderLogContext(order),
|
|
taskId: task.id,
|
|
taskNo: task.task_no,
|
|
orderItemId: task.order_item_id,
|
|
unitIndex: task.unit_index,
|
|
executorKey: task.executor_key,
|
|
taskStatus: task.task_status,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
})
|
|
throw error
|
|
}
|
|
}
|
|
|
|
function resolvePaidTaskStatus(profile: FulfillmentBindingLike | null | undefined): string {
|
|
return resolveInitialPaidTaskStatus(profile)
|
|
}
|
|
|
|
function isTaskRow(task: TaskRow | DeliveryTaskRow | null | undefined): task is TaskRow {
|
|
return Boolean(task && Number(task.id || 0) > 0)
|
|
}
|
|
|
|
function buildTaskUnitKey(task: Pick<TaskRow, 'order_item_id' | 'unit_index'>) {
|
|
return buildOrderItemUnitKey(task.order_item_id, task.unit_index)
|
|
}
|
|
|
|
function buildOrderItemUnitKey(orderItemId: number | string, unitIndex: number | string) {
|
|
return `${Number(orderItemId)}:${Math.max(1, Number(unitIndex || 1))}`
|
|
}
|
|
|
|
function buildOrderLogContext(order: OrderRow) {
|
|
return {
|
|
orderId: order.id,
|
|
provider: order.provider,
|
|
platform: order.platform,
|
|
shopId: order.shop_id,
|
|
platformOrderId: order.platform_order_id,
|
|
payStatus: order.pay_status,
|
|
}
|
|
}
|
|
|
|
function buildOrderItemLogContext(item: OrderItemRow) {
|
|
return {
|
|
orderItemId: item.id,
|
|
skuCode: item.sku_code,
|
|
skuName: item.sku_name,
|
|
quantity: item.quantity,
|
|
}
|
|
}
|
|
|
|
function buildPlanningDiagnostic(item: OrderItemRow) {
|
|
const snapshot = parseJsonObject(item.item_snapshot_json)
|
|
const route = parseJsonObject(snapshot.fulfillmentRoute)
|
|
const cloudtentacles = parseJsonObject(snapshot.cloudtentacles)
|
|
const kuaishouFeifei = parseJsonObject(snapshot.kuaishouFeifei)
|
|
const affiliateDash = parseJsonObject(snapshot.affiliateDash)
|
|
const kuaishouSendCode = parseJsonObject(snapshot.kuaishouSendCode)
|
|
|
|
return {
|
|
snapshotIsConfigured: Boolean(snapshot.isConfigured),
|
|
selectedExecutorKey: String(route.selectedExecutorKey || '').trim(),
|
|
routeReason: String(route.reason || '').trim(),
|
|
candidateAvailability: route.candidates || route.candidateAvailability || [],
|
|
sourceItemTitle: String(kuaishouSendCode.itemTitle || '').trim(),
|
|
sourceSkuNick: String(kuaishouSendCode.skuNick || '').trim(),
|
|
cloudSkuId: Number(cloudtentacles.cloudSkuId || 0) || 0,
|
|
cloudSkuName: String(cloudtentacles.cloudSkuName || '').trim(),
|
|
cloudSourceKeys: Array.isArray(cloudtentacles.cloudSourceKeys)
|
|
? cloudtentacles.cloudSourceKeys
|
|
: [],
|
|
feifeiProductCode: String(kuaishouFeifei.productCode || '').trim(),
|
|
affiliateDashSku: String(affiliateDash.sku || '').trim(),
|
|
}
|
|
}
|
|
|
|
function parseJsonObject(value: unknown): JsonObject {
|
|
if (value && 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 {}
|
|
}
|
|
}
|