feat(finance): 下单入口补内部 SKU,卡券/人工按商品能力兜底

- 纯数字/空内部 SKU 入口查 internal_sku_mappings,补 spec.skuNick
- 快照记录 internalSku/source,便于审计
- 财务 channel_assign 对卡券/人工使用商品渠道能力兜底(source=auto_mapping)
- 自动发货/接单仍要求真实任务/工单载体,避免只改口径
This commit is contained in:
yml2213
2026-11-02 13:52:40 +08:00
parent a501f9f2e0
commit 531a23d78b
4 changed files with 94 additions and 1 deletions
@@ -63,6 +63,7 @@ export type ChannelCandidateItem = {
normalized_sku_name: string | null
has_work_order: boolean
has_affiliate_task: boolean
capability_channel: FulfillmentChannel | null
basis_date: string
}
@@ -144,6 +145,18 @@ export function resolveChannelDecision(
}
}
// 卡券/人工没有必须创建的履约任务,可直接用商品渠道能力兜底;
// 自动发货/接单平台仍要求真实载体,避免只改口径不派单。
if (item.capability_channel === 'third_party_voucher' || item.capability_channel === 'manual') {
return {
channel: item.capability_channel,
source: 'auto_mapping',
conflict: false,
conflictChannels: [],
matchedRuleIds: [],
}
}
return { channel: null, source: '', conflict: false, conflictChannels: [], matchedRuleIds: [] }
}
@@ -208,9 +221,28 @@ export async function assignPendingOrderItemChannels(
EXISTS (
SELECT 1 FROM fulfillment_tasks ft
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'
) AS has_affiliate_task
) AS has_affiliate_task,
cap.channel AS capability_channel
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
LEFT JOIN LATERAL (
SELECT c.channel
FROM product_catalog p
JOIN product_channel_capabilities c
ON c.product_id = p.id AND c.enabled = TRUE
WHERE p.status = 'active'
AND btrim(p.canonical_name) = ${INTERNAL_SKU_SQL}
ORDER BY
CASE c.channel
WHEN 'affiliate_dash' THEN 1
WHEN 'work_order' THEN 2
WHEN 'third_party_voucher' THEN 3
WHEN 'manual' THEN 4
ELSE 9
END,
c.priority ASC
LIMIT 1
) cap ON TRUE
WHERE oi.fulfillment_status = 'pending'
AND oi.fulfillment_locked = FALSE
AND oi.id > $1
@@ -113,3 +113,41 @@ export async function upsertInternalSkuMapping(input: {
)
return Number(result.rows[0]?.id || 0)
}
function containsCjk(value: string): boolean {
return /[\u4e00-\u9fa5]/.test(value)
}
/**
* 订单项级内部 SKU 补全:
* - 现有 skuNick/outSkuSn/skuCode/skuName 已有中文 → 不查库;
* - 只有纯数字/空值 → 查 internal_sku_mappings,命中则返回中文内部 SKU。
*/
export async function resolveInternalSkuForOrderItem(input: {
provider?: string | null
shopId?: string | null
spec?: Record<string, unknown> | null
skuCode?: unknown
skuName?: unknown
}): Promise<{ internalSku: string; source: string; mappingId: number } | null> {
const spec = input.spec && typeof input.spec === 'object' ? input.spec : {}
const current = normalizeInternalSkuMappingValue(
spec.skuNick || spec.outSkuSn || input.skuCode || input.skuName || '',
)
if (current && containsCjk(current)) return null
// 只有纯数字/空值才需要查映射;英文 SKU 是合法的内部键,不能误当平台码。
if (current && !/^[0-9]+$/.test(current)) return null
const mapping = await resolveInternalSkuMapping({
provider: input.provider ?? null,
shopId: input.shopId ?? null,
candidates: [
{ matchType: 'sku_nick', value: spec.skuNick },
{ matchType: 'out_sku_sn', value: spec.outSkuSn },
{ matchType: 'sku_code', value: input.skuCode },
{ matchType: 'sku_name', value: input.skuName },
],
})
if (!mapping) return null
return { internalSku: mapping.internalSku, source: mapping.source, mappingId: mapping.id }
}
@@ -10,6 +10,7 @@ import { mergeAffiliateDashSkuMapping } from '../platforms/affiliate-dash/sku-ma
import type { AffiliateDashSkuMapping } from '../../types/runtime-config.js'
import { FULFILLMENT_EXECUTOR_KEYS } from './executors/types.js'
import { normalizeProductMatchName } from '../order/product-match-utils.js'
import { resolveInternalSkuForOrderItem } from './internal-sku-mapping-service.js'
import { logIntegration } from '../../utils/logger.js'
export type FulfillmentItem = {
@@ -28,6 +29,7 @@ export type FulfillmentItem = {
type ResolveOrderItemForFulfillmentInput = {
provider?: string
platform?: string
shopId?: string
item?: FulfillmentItem
}
@@ -82,6 +84,7 @@ export type ResolvedFulfillmentItem = FulfillmentItem & {
export async function resolveOrderItemForFulfillment({
provider = '',
platform = '',
shopId = '',
item = {},
}: ResolveOrderItemForFulfillmentInput): Promise<ResolvedFulfillmentItem> {
const candidate = await resolveConfiguredItemCandidate({
@@ -119,6 +122,17 @@ export async function resolveOrderItemForFulfillment({
externalSkuName,
resolvedSkuCode,
])
const internalSkuMapping = await resolveInternalSkuForOrderItem({
provider,
shopId,
spec: item.spec ?? null,
skuCode: externalSkuCode,
skuName: externalSkuName,
})
const nextSpec = internalSkuMapping
? { ...(isPlainObject(item.spec) ? item.spec : {}), skuNick: internalSkuMapping.internalSku }
: item.spec
const snapshot = {
...(isPlainObject(item.snapshot) ? item.snapshot : {}),
externalItemId,
@@ -140,6 +154,13 @@ export async function resolveOrderItemForFulfillment({
}
: null,
isConfigured: candidate.isConfigured,
...(internalSkuMapping
? {
internalSku: internalSkuMapping.internalSku,
internalSkuSource: internalSkuMapping.source,
internalSkuMappingId: internalSkuMapping.mappingId,
}
: {}),
// 抖音:引流长标题单独存档(skuName 已改存内部 SKU,标题不能丢)
...(isDouyinMashida
? { productTitle: pickFirstNonEmpty([item.skuName, externalSkuName]) }
@@ -150,6 +171,7 @@ export async function resolveOrderItemForFulfillment({
return {
...item,
...(nextSpec ? { spec: nextSpec } : {}),
itemId: externalItemId,
externalItemId,
externalSkuCode,
@@ -117,6 +117,7 @@ export async function upsertOrderFromSource(
resolveOrderItemForFulfillment({
provider: event.provider,
platform: event.platform,
shopId: event.shopId,
item,
}),
),