import { getFulfillmentProfileByKey } from '../../repositories/fulfillment-profile-repo.js' import type { JsonObject } from '../../types/json.js' import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js' import { FULFILLMENT_EXECUTOR_KEYS, isKuaishouCloudExecutor, isKuaishouFeifeiExecutor, } from './executors/types.js' export 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 } export type FulfillmentProfileResolver = ( profileKey: string, ) => Promise export type FulfillmentTaskPlan = { profile: FulfillmentBindingLike profileId: number profileKey: string profileName: string executorKey: string requiresClaim: boolean autoDispatch: boolean context: JsonObject } export async function planFulfillmentTaskForOrderItem({ order, item, getProfileByKey = getFulfillmentProfileByKey, }: { order: OrderRow item: OrderItemRow getProfileByKey?: FulfillmentProfileResolver }): Promise { const profile = await resolveDynamicFulfillmentProfile(item, getProfileByKey) if (!profile) { return null } const profileId = Number(profile.profile_id || profile.id || 0) const profileKey = String(profile.profile_key || '').trim() const profileName = String(profile.profile_name || profile.name || '').trim() const executorKey = String(profile.executor_key || FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH) .trim() || FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH const fulfillmentConfig = parseJsonObject(profile.config_json) const context = buildFulfillmentTaskContext({ order, item, profile, profileKey, profileName, executorKey, fulfillmentConfig, }) return { profile, profileId, profileKey, profileName, executorKey, requiresClaim: Boolean(profile.requires_claim), autoDispatch: Boolean(profile.auto_dispatch), context, } } function buildFulfillmentTaskContext({ order, item, profileKey, profileName, executorKey, fulfillmentConfig, }: { order: OrderRow item: OrderItemRow profile: FulfillmentBindingLike profileKey: string profileName: string executorKey: string fulfillmentConfig: JsonObject }) { const itemSnapshot = parseJsonObject(item.item_snapshot_json) const cloudtentaclesConfig = parseJsonObject(fulfillmentConfig.cloudtentacles) const kuaishouFeifeiConfig = parseJsonObject(fulfillmentConfig.kuaishouFeifei) const kuaishouConsumeConfig = parseJsonObject(fulfillmentConfig.kuaishouConsume) const kuaishouShopConfig = parseJsonObject(fulfillmentConfig.kuaishouShop) const cloudSourceKeys = normalizeStringArray(cloudtentaclesConfig.cloudSourceKeys) const resolvedCloudSourceKey = cloudSourceKeys.length === 1 ? String(cloudtentaclesConfig.resolvedSourceKey || cloudSourceKeys[0] || '').trim() : '' const deliveryItems = normalizeCloudDeliveryItems(cloudtentaclesConfig) const primaryDeliveryItem = deliveryItems[0] || { cloudSkuId: Number(cloudtentaclesConfig.skuId || 0) || 0, cloudSkuName: String(cloudtentaclesConfig.skuName || '').trim(), quantity: 1, } return { profileKey, profileName, skuCode: item.sku_code, skuName: item.sku_name, kuaishouCloudFulfillment: isKuaishouCloudExecutor(executorKey) ? { 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, resolvedSourceKey: resolvedCloudSourceKey, 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, kuaishouFeifei: isKuaishouFeifeiExecutor(executorKey) ? { flowType: 'kuaishou_feifei', productCode: String(kuaishouFeifeiConfig.productCode || '').trim(), productName: String(kuaishouFeifeiConfig.productName || item.sku_name || '').trim(), platformOrderNo: '', orderNo: '', rechargeStatus: 0, rechargeStatusLabel: '', rechargeResultMessage: '', claimUrl: '', consumeStatus: 'pending', h5: { entryUrl: '', rechargeUrl: '', }, lastSyncedAt: null, } : null, } } async function resolveDynamicFulfillmentProfile( item: OrderItemRow, getProfileByKey: FulfillmentProfileResolver, ): Promise { const selectedExecutorKey = resolveSelectedFulfillmentExecutorKey(item) if (selectedExecutorKey) { if (selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD) { return resolveDynamicCloudtentaclesProfile(item, getProfileByKey) } if (selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI) { return resolveDynamicKuaishouFeifeiProfile(item, getProfileByKey) } if (selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH) { return resolveDynamicManualDispatchProfile(item, getProfileByKey) } } return ( await resolveDynamicCloudtentaclesProfile(item, getProfileByKey) || await resolveDynamicKuaishouFeifeiProfile(item, getProfileByKey) ) } async function resolveDynamicCloudtentaclesProfile( item: OrderItemRow, getProfileByKey: FulfillmentProfileResolver, ): 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(FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD) if (!profile) { return null } return { ...profile, profile_id: Number(profile.id || profile.profile_id || 0), profile_key: FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD, profile_name: String(profile.profile_name || profile.name || 'kuaishou-lewan 履约').trim(), executor_key: FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD, 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: cloudSourceKeys.length === 1 ? String(cloudtentacles.resolvedSourceKey || cloudSourceKeys[0] || '').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 商品', }, } } async function resolveDynamicKuaishouFeifeiProfile( item: OrderItemRow, getProfileByKey: FulfillmentProfileResolver, ): Promise { const snapshot = parseJsonObject(item.item_snapshot_json) const feifei = parseJsonObject(snapshot.kuaishouFeifei) const productCode = String(feifei.productCode || '').trim() if (!productCode) { return null } const profile = await getProfileByKey(FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI) if (!profile) { return null } return { ...profile, profile_id: Number(profile.id || profile.profile_id || 0), profile_key: FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI, profile_name: String(profile.profile_name || profile.name || 'kuaishou-feifei 履约').trim(), executor_key: FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI, requires_claim: true, auto_dispatch: false, config_json: { flowType: 'kuaishou_feifei', configId: `kuaishou_feifei:${productCode}`, kuaishouFeifei: { productCode, productName: String(feifei.skuName || feifei.productName || item.sku_name || '').trim(), matchMode: String(feifei.matchMode || 'kuaishou_feifei_name').trim(), }, notes: '91卡券商品名自动匹配 kuaishou-feifei 商品映射', }, } } async function resolveDynamicManualDispatchProfile( item: OrderItemRow, getProfileByKey: FulfillmentProfileResolver, ): Promise { const profile = await getProfileByKey('manual_review') if (!profile) { return null } return { ...profile, profile_id: Number(profile.id || profile.profile_id || 0), profile_key: 'manual_review', profile_name: String(profile.profile_name || profile.name || '人工发货').trim(), executor_key: FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH, requires_claim: false, auto_dispatch: false, config_json: { flowType: 'manual_dispatch', configId: `manual_dispatch:${item.sku_code || item.id}`, manualDispatch: { source: 'fulfillment_routing', skuCode: item.sku_code, skuName: item.sku_name, }, notes: '履约路由规则指定人工履约', }, } } function resolveSelectedFulfillmentExecutorKey(item: OrderItemRow) { const snapshot = parseJsonObject(item.item_snapshot_json) const route = parseJsonObject(snapshot.fulfillmentRoute) return String(route.selectedExecutorKey || '').trim() } 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 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()) }