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 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 createTaskClaimToken?: (taskId: number | string) => Promise notifyTaskAutoManualReview?: (payload: { task: unknown reason: string source: string }) => Promise | unknown nowIso?: () => string randomId?: (prefix?: string) => string } type RuntimeDeliveryTaskDeps = Required> type TaskContext = { [key: string]: unknown } export async function syncDeliveryTasksForOrder( order: OrderRow, orderItems: OrderItemRow[], ): Promise { return syncDeliveryTasksForOrderWithDeps(order, orderItems) } export async function syncDeliveryTasksForOrderWithDeps( order: OrderRow, orderItems: OrderItemRow[], deps: DeliveryTaskDeps = {}, ): Promise { 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 = {}, ): Promise { 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, ): Promise { 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() 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' }