Files
order_site/apps/backend/src/services/fulfillment/planner.ts
T
yml2213 042574dea3 继续收紧 TypeScript:ClaimIdentity 契约与 flow 出口类型
- 新增 types/claim-identity,统一 ClaimIdentity / Payload / AdminSummary
- 导出 KuaishouCloudFlow、KuaishouFeifeiFlow 作为 normalize 出口
- 生产路径 JsonObject 统一从 types/json 导入,去掉 rebind as any
- 前后端 claimIdentity 类型字段对齐
2026-07-10 15:56:25 +08:00

487 lines
15 KiB
TypeScript

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<FulfillmentBindingLike | null>
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<FulfillmentTaskPlan | null> {
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<FulfillmentBindingLike | null> {
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<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(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<FulfillmentBindingLike | null> {
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<FulfillmentBindingLike | null> {
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<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())
}