feat(finance): 统一内部 SKU 驱动的履约与财务分流
- 新增 internal_sku_mappings / internal_sku_channels,平台字段统一映射到内部中文 SKU - 下单入口按内部 SKU 渠道决策修正默认人工路由,自动发货补全 dash sku - 财务 channel_assign 读取统一渠道表;卡券/人工能力兜底,自动发货/接单要求真实载体 - carrier reconciliation 自动补自动发货任务和接单工单 - pending 落明确原因:无内部 SKU / 无渠道能力 / 无履约载体 - 合并、重判、旧 sku_code 规则清理、部署后 preflight 核对脚本 - 待分类队列按内部 SKU 聚合并展示原因
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
-- 118_internal_sku_channels.sql
|
||||
--
|
||||
-- 内部中文 SKU 的渠道决策单一真源。
|
||||
-- 从商品渠道能力 + 现有 internal_sku 全局规则回填;后续 intake/finance 都读这张表。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS internal_sku_channels (
|
||||
internal_sku TEXT NOT NULL,
|
||||
channel TEXT NOT NULL CHECK (channel IN ('work_order', 'affiliate_dash', 'third_party_voucher', 'manual')),
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
is_fallback BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE SET NULL,
|
||||
config_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
source TEXT NOT NULL DEFAULT 'backfill',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (internal_sku, channel)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_internal_sku_channels_priority
|
||||
ON internal_sku_channels (internal_sku, priority, channel)
|
||||
WHERE enabled = TRUE;
|
||||
|
||||
COMMENT ON TABLE internal_sku_channels IS
|
||||
'内部中文 SKU 渠道能力:priority 越小越优先,is_fallback 表示兜底渠道';
|
||||
|
||||
WITH capability_source AS (
|
||||
SELECT
|
||||
btrim(p.canonical_name) AS internal_sku,
|
||||
c.channel,
|
||||
MIN(c.priority) AS priority,
|
||||
BOOL_AND(c.is_fallback) AS is_fallback,
|
||||
MIN(p.id) AS canonical_product_id
|
||||
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) <> ''
|
||||
GROUP BY 1, 2
|
||||
)
|
||||
INSERT INTO internal_sku_channels (
|
||||
internal_sku, channel, priority, is_fallback, canonical_product_id, source
|
||||
)
|
||||
SELECT internal_sku, channel, priority, is_fallback, canonical_product_id, 'capability'
|
||||
FROM capability_source
|
||||
ON CONFLICT (internal_sku, channel) DO UPDATE SET
|
||||
priority = LEAST(internal_sku_channels.priority, EXCLUDED.priority),
|
||||
is_fallback = internal_sku_channels.is_fallback AND EXCLUDED.is_fallback,
|
||||
canonical_product_id = COALESCE(internal_sku_channels.canonical_product_id, EXCLUDED.canonical_product_id),
|
||||
updated_at = NOW();
|
||||
|
||||
WITH rule_source AS (
|
||||
SELECT btrim(match_key) AS internal_sku, channel, MIN(priority) AS priority
|
||||
FROM sku_channel_rules
|
||||
WHERE match_key_type = 'internal_sku'
|
||||
AND provider = '' AND shop_id = ''
|
||||
AND enabled = TRUE
|
||||
AND btrim(match_key) <> ''
|
||||
GROUP BY 1, 2
|
||||
)
|
||||
INSERT INTO internal_sku_channels (
|
||||
internal_sku, channel, priority, is_fallback, canonical_product_id, source
|
||||
)
|
||||
SELECT internal_sku, channel, priority, FALSE, NULL, 'rule'
|
||||
FROM rule_source
|
||||
ON CONFLICT (internal_sku, channel) DO UPDATE SET
|
||||
priority = LEAST(internal_sku_channels.priority, EXCLUDED.priority),
|
||||
is_fallback = FALSE,
|
||||
updated_at = NOW();
|
||||
@@ -0,0 +1,14 @@
|
||||
-- 119_fulfillment_pending_reason.sql
|
||||
--
|
||||
-- 待分类不再是一个模糊状态:落明确原因,供队列/审计区分。
|
||||
-- no_internal_sku 没有内部中文 SKU
|
||||
-- no_channel_capability 有内部 SKU,但没有渠道能力/规则
|
||||
-- no_carrier 有渠道能力,但缺任务/工单载体
|
||||
|
||||
ALTER TABLE order_items
|
||||
ADD COLUMN IF NOT EXISTS fulfillment_pending_reason TEXT,
|
||||
ADD COLUMN IF NOT EXISTS fulfillment_pending_detail JSONB;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_order_items_pending_reason
|
||||
ON order_items (fulfillment_pending_reason)
|
||||
WHERE fulfillment_status = 'pending';
|
||||
@@ -1,6 +1,7 @@
|
||||
import { query } from '../../db/client.js'
|
||||
import { resolveExceptionsForItems, upsertAggregatedException } from './cost-exception-service.js'
|
||||
import { readWatermarkItemId } from '../watermark-store.js'
|
||||
import { upsertInternalSkuChannel } from '../fulfillment/internal-sku-channel-service.js'
|
||||
|
||||
/**
|
||||
* 管道 B:商品归一。
|
||||
@@ -191,6 +192,15 @@ async function ensureVoucherProductForItem(item: ItemRow): Promise<number | null
|
||||
[productId],
|
||||
)
|
||||
|
||||
await upsertInternalSkuChannel({
|
||||
internalSku,
|
||||
channel: 'third_party_voucher',
|
||||
priority: 10,
|
||||
isFallback: false,
|
||||
canonicalProductId: productId,
|
||||
source: 'auto',
|
||||
}).catch(() => undefined)
|
||||
|
||||
// 同一内部中文 SKU 跨平台码统一分流到卡券渠道。
|
||||
await query(
|
||||
`
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { query } from '../../db/client.js'
|
||||
import { getOrderById } from '../../repositories/order-repo.js'
|
||||
import { syncDeliveryTasksForOrder } from '../order/delivery-task-service.js'
|
||||
import { syncWorkerOrdersForSourceOrder } from '../worker-platform/sync-worker-orders-service.js'
|
||||
import { applyIntakeChannelDecision } from './intake-channel-decision-service.js'
|
||||
import type { ResolvedFulfillmentItem } from './product-resolution-service.js'
|
||||
import { logWarn } from '../../utils/logger.js'
|
||||
|
||||
type PendingCarrierItem = {
|
||||
id: number
|
||||
order_id: number
|
||||
sku_code: string
|
||||
sku_name: string
|
||||
quantity: number
|
||||
item_snapshot_json: unknown
|
||||
spec_json: unknown
|
||||
channel: 'affiliate_dash' | 'work_order'
|
||||
}
|
||||
|
||||
function parseObject(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(String(value || '{}'))
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为“内部 SKU 有渠道能力、但还没有载体”的 pending 单补载体。
|
||||
*
|
||||
* - 接单平台:走现有工单匹配器,能匹配模板就建工单;
|
||||
* - 自动发货:补 fulfillmentRoute/affiliateDash 后建任务;
|
||||
* - 补不出来的继续保留 no_carrier,等运营补模板/映射。
|
||||
*
|
||||
* 财务渠道判定不在本服务内做;调用方在补完载体后再跑一次 channel_assign。
|
||||
*/
|
||||
export async function reconcilePendingCarriers(input: { limit?: number } = {}): Promise<{
|
||||
scanned: number
|
||||
workOrdersCreated: number
|
||||
affiliateTasksCreated: number
|
||||
remaining: number
|
||||
}> {
|
||||
const limit = Math.min(500, Math.max(1, Math.trunc(Number(input.limit) || 200)))
|
||||
const rows = await query<PendingCarrierItem>(
|
||||
`
|
||||
SELECT oi.id, oi.order_id, oi.sku_code, oi.sku_name, oi.quantity,
|
||||
oi.item_snapshot_json, oi.spec_json,
|
||||
cap.channel
|
||||
FROM order_items oi
|
||||
JOIN LATERAL (
|
||||
SELECT c.channel
|
||||
FROM internal_sku_channels c
|
||||
WHERE c.enabled = TRUE
|
||||
AND c.channel IN ('affiliate_dash', 'work_order')
|
||||
AND btrim(c.internal_sku) = COALESCE(NULLIF(btrim(oi.manual_sku_key), ''), btrim(oi.sku_name))
|
||||
ORDER BY
|
||||
c.priority ASC,
|
||||
CASE c.channel
|
||||
WHEN 'affiliate_dash' THEN 1
|
||||
WHEN 'work_order' THEN 2
|
||||
ELSE 9
|
||||
END,
|
||||
c.channel ASC
|
||||
LIMIT 1
|
||||
) cap ON TRUE
|
||||
WHERE oi.fulfillment_status = 'pending'
|
||||
AND oi.fulfillment_locked = FALSE
|
||||
AND NOT EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'
|
||||
)
|
||||
ORDER BY oi.id
|
||||
LIMIT $1
|
||||
`,
|
||||
[limit],
|
||||
)
|
||||
|
||||
let workOrdersCreated = 0
|
||||
let affiliateTasksCreated = 0
|
||||
const byOrder = new Map<number, PendingCarrierItem[]>()
|
||||
for (const row of rows.rows) {
|
||||
const list = byOrder.get(Number(row.order_id)) || []
|
||||
list.push(row)
|
||||
byOrder.set(Number(row.order_id), list)
|
||||
}
|
||||
|
||||
for (const [orderId, items] of byOrder) {
|
||||
const order = await getOrderById(orderId)
|
||||
if (!order) continue
|
||||
const itemRows = await query<{
|
||||
id: number
|
||||
order_id: number
|
||||
sku_code: string
|
||||
sku_name: string
|
||||
quantity: number
|
||||
item_snapshot_json: unknown
|
||||
spec_json: unknown
|
||||
}>(
|
||||
`SELECT id, order_id, sku_code, sku_name, quantity, item_snapshot_json, spec_json
|
||||
FROM order_items WHERE order_id = $1`,
|
||||
[orderId],
|
||||
)
|
||||
const itemById = new Map(itemRows.rows.map((item) => [Number(item.id), item]))
|
||||
|
||||
for (const target of items) {
|
||||
const item = itemById.get(Number(target.id))
|
||||
if (!item) continue
|
||||
|
||||
try {
|
||||
if (target.channel === 'work_order') {
|
||||
const result = await syncWorkerOrdersForSourceOrder(order, [item as never], {
|
||||
source: 'carrier_reconcile',
|
||||
autoOnly: true,
|
||||
})
|
||||
workOrdersCreated += result.created.length
|
||||
continue
|
||||
}
|
||||
|
||||
const resolved = {
|
||||
...item,
|
||||
itemId: String(item.id),
|
||||
externalItemId: String(item.id),
|
||||
externalSkuCode: item.sku_code,
|
||||
externalSkuName: item.sku_name,
|
||||
skuCode: item.sku_code,
|
||||
skuName: item.sku_name,
|
||||
spec: parseObject(item.spec_json),
|
||||
snapshot: parseObject(item.item_snapshot_json),
|
||||
isConfigured: false,
|
||||
} as unknown as ResolvedFulfillmentItem
|
||||
const enriched = await applyIntakeChannelDecision(resolved, {
|
||||
provider: order.provider,
|
||||
platform: order.platform,
|
||||
shopId: order.shop_id,
|
||||
})
|
||||
const snapshot = parseObject(enriched.snapshot)
|
||||
const route = parseObject(snapshot.fulfillmentRoute)
|
||||
if (route.selectedExecutorKey !== 'affiliate_dash') continue
|
||||
|
||||
await query(
|
||||
`UPDATE order_items
|
||||
SET sku_code = $2,
|
||||
spec_json = $3,
|
||||
item_snapshot_json = $4,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[
|
||||
item.id,
|
||||
String(enriched.skuCode || item.sku_code),
|
||||
JSON.stringify(enriched.spec || {}),
|
||||
JSON.stringify(snapshot),
|
||||
],
|
||||
)
|
||||
const tasks = await syncDeliveryTasksForOrder(order, [
|
||||
{
|
||||
...item,
|
||||
sku_code: String(enriched.skuCode || item.sku_code),
|
||||
spec_json: enriched.spec || {},
|
||||
item_snapshot_json: snapshot,
|
||||
} as never,
|
||||
])
|
||||
affiliateTasksCreated += tasks.length
|
||||
} catch (error) {
|
||||
logWarn('[carrier-reconciliation]', '补履约载体失败', {
|
||||
orderItemId: target.id,
|
||||
channel: target.channel,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const remainingResult = await query<{ count: number }>(
|
||||
`
|
||||
SELECT count(*)::int AS count
|
||||
FROM order_items oi
|
||||
WHERE oi.fulfillment_status = 'pending'
|
||||
AND oi.fulfillment_pending_reason = 'no_carrier'
|
||||
`,
|
||||
)
|
||||
|
||||
return {
|
||||
scanned: rows.rows.length,
|
||||
workOrdersCreated,
|
||||
affiliateTasksCreated,
|
||||
remaining: Number(remainingResult.rows[0]?.count || 0),
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,12 @@ function buildItem(patch: Partial<ChannelCandidateItem> = {}): ChannelCandidateI
|
||||
sku_code: 'suit_xxx',
|
||||
sku_name: '套装-测试',
|
||||
manual_sku_key: 'suit_xxx',
|
||||
internal_sku: '套装-测试',
|
||||
normalized_sku_name: '套装-测试',
|
||||
has_work_order: false,
|
||||
has_affiliate_task: false,
|
||||
has_manual_task: false,
|
||||
capability_channel: null,
|
||||
basis_date: '2026-10-31',
|
||||
...patch,
|
||||
}
|
||||
@@ -98,7 +101,9 @@ test('渠道判定:内部 SKU 与 SKU 名称两种键都能命中', () => {
|
||||
channel: 'manual',
|
||||
})
|
||||
assert.equal(
|
||||
resolveChannelDecision(buildItem({ manual_sku_key: 'nick-001' }), [byInternal]).channel,
|
||||
resolveChannelDecision(buildItem({ manual_sku_key: 'nick-001', internal_sku: 'nick-001' }), [
|
||||
byInternal,
|
||||
]).channel,
|
||||
'manual',
|
||||
)
|
||||
|
||||
@@ -117,3 +122,28 @@ test('渠道判定:停用的规则不参与', () => {
|
||||
const decision = resolveChannelDecision(buildItem(), [buildRule({ enabled: false })])
|
||||
assert.equal(decision.channel, null)
|
||||
})
|
||||
|
||||
test('渠道判定:卡券/人工能力可兜底,自动发货/接单必须靠载体', () => {
|
||||
const voucher = resolveChannelDecision(
|
||||
buildItem({ capability_channel: 'third_party_voucher' }),
|
||||
[],
|
||||
)
|
||||
assert.equal(voucher.channel, 'third_party_voucher')
|
||||
assert.equal(voucher.source, 'auto_mapping')
|
||||
|
||||
const manual = resolveChannelDecision(buildItem({ capability_channel: 'manual' }), [])
|
||||
assert.equal(manual.channel, 'manual')
|
||||
assert.equal(manual.source, 'auto_mapping')
|
||||
|
||||
const affiliate = resolveChannelDecision(buildItem({ capability_channel: 'affiliate_dash' }), [])
|
||||
assert.equal(affiliate.channel, null, '自动发货能力没有任务时不能只改财务口径')
|
||||
|
||||
const workOrder = resolveChannelDecision(buildItem({ capability_channel: 'work_order' }), [])
|
||||
assert.equal(workOrder.channel, null, '接单能力没有工单时不能只改财务口径')
|
||||
})
|
||||
|
||||
test('渠道判定:人工任务作为人工载体证据', () => {
|
||||
const decision = resolveChannelDecision(buildItem({ has_manual_task: true }), [])
|
||||
assert.equal(decision.channel, 'manual')
|
||||
assert.equal(decision.source, 'carrier_evidence')
|
||||
})
|
||||
|
||||
@@ -63,6 +63,7 @@ export type ChannelCandidateItem = {
|
||||
normalized_sku_name: string | null
|
||||
has_work_order: boolean
|
||||
has_affiliate_task: boolean
|
||||
has_manual_task: boolean
|
||||
capability_channel: FulfillmentChannel | null
|
||||
basis_date: string
|
||||
}
|
||||
@@ -91,6 +92,25 @@ function itemKey(item: ChannelCandidateItem, keyType: MatchKeyType): string {
|
||||
return normalizeKey(item.normalized_sku_name) || normalizeKey(item.sku_name).toLowerCase()
|
||||
}
|
||||
|
||||
function resolvePendingReason(item: ChannelCandidateItem): {
|
||||
reason: string
|
||||
detail: Record<string, unknown>
|
||||
} {
|
||||
if (!String(item.internal_sku || '').trim()) {
|
||||
return { reason: 'no_internal_sku', detail: { skuCode: item.sku_code } }
|
||||
}
|
||||
if (!item.capability_channel) {
|
||||
return {
|
||||
reason: 'no_channel_capability',
|
||||
detail: { internalSku: item.internal_sku },
|
||||
}
|
||||
}
|
||||
return {
|
||||
reason: 'no_carrier',
|
||||
detail: { internalSku: item.internal_sku, channel: item.capability_channel },
|
||||
}
|
||||
}
|
||||
|
||||
/** 纯函数:给定规则与订单项,得出渠道决策(便于单测与文档对照)。 */
|
||||
export function resolveChannelDecision(
|
||||
item: ChannelCandidateItem,
|
||||
@@ -144,6 +164,15 @@ export function resolveChannelDecision(
|
||||
matchedRuleIds: [],
|
||||
}
|
||||
}
|
||||
if (item.has_manual_task) {
|
||||
return {
|
||||
channel: 'manual',
|
||||
source: 'carrier_evidence',
|
||||
conflict: false,
|
||||
conflictChannels: [],
|
||||
matchedRuleIds: [],
|
||||
}
|
||||
}
|
||||
|
||||
// 卡券/人工没有必须创建的履约任务,可直接用商品渠道能力兜底;
|
||||
// 自动发货/接单平台仍要求真实载体,避免只改口径不派单。
|
||||
@@ -173,6 +202,8 @@ export type AssignChannelsResult = {
|
||||
affectedDates: string[]
|
||||
/** 本轮新判定的订单项(供立即计算成本,最多 2000 条)。 */
|
||||
assignedItemIds: number[]
|
||||
/** 待分类原因计数。 */
|
||||
pendingReasons: Record<string, number>
|
||||
}
|
||||
|
||||
/** 批量判定:默认只扫 pending 且未锁定的订单项。 */
|
||||
@@ -198,6 +229,7 @@ export async function assignPendingOrderItemChannels(
|
||||
byChannel: {},
|
||||
affectedDates: [],
|
||||
assignedItemIds: [],
|
||||
pendingReasons: {},
|
||||
}
|
||||
const affectedDates = new Set<string>()
|
||||
|
||||
@@ -222,17 +254,20 @@ export async function assignPendingOrderItemChannels(
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'
|
||||
) AS has_affiliate_task,
|
||||
EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch'
|
||||
) AS has_manual_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}
|
||||
FROM internal_sku_channels c
|
||||
WHERE c.enabled = TRUE
|
||||
AND btrim(c.internal_sku) = ${INTERNAL_SKU_SQL}
|
||||
ORDER BY
|
||||
c.priority ASC,
|
||||
CASE c.channel
|
||||
WHEN 'affiliate_dash' THEN 1
|
||||
WHEN 'work_order' THEN 2
|
||||
@@ -240,7 +275,7 @@ export async function assignPendingOrderItemChannels(
|
||||
WHEN 'manual' THEN 4
|
||||
ELSE 9
|
||||
END,
|
||||
c.priority ASC
|
||||
c.channel ASC
|
||||
LIMIT 1
|
||||
) cap ON TRUE
|
||||
WHERE oi.fulfillment_status = 'pending'
|
||||
@@ -282,6 +317,16 @@ export async function assignPendingOrderItemChannels(
|
||||
}
|
||||
if (!decision.channel) {
|
||||
result.stillPending += 1
|
||||
const pending = resolvePendingReason(item)
|
||||
result.pendingReasons[pending.reason] = (result.pendingReasons[pending.reason] || 0) + 1
|
||||
await query(
|
||||
`UPDATE order_items
|
||||
SET fulfillment_pending_reason = $2,
|
||||
fulfillment_pending_detail = $3,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1 AND fulfillment_status = 'pending'`,
|
||||
[item.id, pending.reason, JSON.stringify(pending.detail)],
|
||||
)
|
||||
continue
|
||||
}
|
||||
assignments.push({ id: item.id, channel: decision.channel, source: decision.source })
|
||||
@@ -297,6 +342,8 @@ export async function assignPendingOrderItemChannels(
|
||||
fulfillment_status = 'assigned',
|
||||
fulfillment_source = input.source,
|
||||
fulfillment_decided_at = NOW(),
|
||||
fulfillment_pending_reason = NULL,
|
||||
fulfillment_pending_detail = NULL,
|
||||
updated_at = NOW()
|
||||
FROM unnest($1::bigint[], $2::text[], $3::text[])
|
||||
AS input(id, channel, source)
|
||||
@@ -385,6 +432,7 @@ export type PendingGroupRow = {
|
||||
internal_sku: string
|
||||
sku_name: string
|
||||
sku_codes: string[]
|
||||
pending_reasons: string[]
|
||||
item_count: number
|
||||
revenue_fen: number
|
||||
first_seen_at: string
|
||||
@@ -432,6 +480,7 @@ export async function listPendingChannelGroups(input: {
|
||||
${INTERNAL_SKU_SQL} AS internal_sku,
|
||||
MIN(oi.sku_name) AS sku_name,
|
||||
array_agg(DISTINCT oi.sku_code) AS sku_codes,
|
||||
array_remove(array_agg(DISTINCT oi.fulfillment_pending_reason), NULL) AS pending_reasons,
|
||||
COUNT(*)::int AS item_count,
|
||||
COALESCE(SUM(oi.revenue_amount_fen), 0)::bigint AS revenue_fen,
|
||||
MIN(oi.created_at) AS first_seen_at,
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
listInternalSkuChannels,
|
||||
type InternalSkuChannelRow,
|
||||
} from './internal-sku-channel-service.js'
|
||||
import type { ResolvedFulfillmentItem } from './product-resolution-service.js'
|
||||
import { matchAffiliateDashSkuByInternalSku } from './product-resolution-service.js'
|
||||
import { logWarn } from '../../utils/logger.js'
|
||||
|
||||
type IntakeChannelDecisionInput = {
|
||||
provider?: string
|
||||
platform?: string
|
||||
shopId?: string
|
||||
}
|
||||
|
||||
function parseObject(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {}
|
||||
}
|
||||
|
||||
function resolveInternalSku(item: ResolvedFulfillmentItem): string {
|
||||
const snapshot = parseObject(item.snapshot)
|
||||
const spec = parseObject(item.spec)
|
||||
return String(
|
||||
snapshot.internalSku || spec.skuNick || spec.outSkuSn || item.skuCode || item.skuName || '',
|
||||
).trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* 下单入口读取内部 SKU 渠道决策。
|
||||
*
|
||||
* 目前只补“自动发货”载体:若内部 SKU 能力是 affiliate_dash 且能解析出 dash sku,
|
||||
* 直接写 fulfillmentRoute + affiliateDash,让 delivery-task-service 建任务。
|
||||
* 接单平台仍由 work_order 匹配器创建;卡券/人工无任务,由财务侧兜底。
|
||||
*/
|
||||
export async function applyIntakeChannelDecision(
|
||||
item: ResolvedFulfillmentItem,
|
||||
_input: IntakeChannelDecisionInput,
|
||||
): Promise<ResolvedFulfillmentItem> {
|
||||
const internalSku = resolveInternalSku(item)
|
||||
if (!internalSku) return item
|
||||
|
||||
let channels: InternalSkuChannelRow[] = []
|
||||
try {
|
||||
channels = await listInternalSkuChannels(internalSku)
|
||||
} catch (error) {
|
||||
// 单测/旧库没有 118 迁移时不影响下单主流程,生产由迁移保证。
|
||||
logWarn('[intake-channel-decision]', '读取内部 SKU 渠道失败', {
|
||||
internalSku,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return item
|
||||
}
|
||||
if (channels.length === 0) return item
|
||||
|
||||
const snapshot = parseObject(item.snapshot)
|
||||
const route = parseObject(snapshot.fulfillmentRoute)
|
||||
const selected = String(route.selectedExecutorKey || '').trim()
|
||||
// 路由配置未命中时会默认落 manual_dispatch;这不是运营显式规则,不能覆盖内部 SKU 能力。
|
||||
const isDefaultManualFallback = selected === 'manual_dispatch' && !route.matchedRule
|
||||
const primary = channels[0]!
|
||||
const nextSnapshot: Record<string, unknown> = {
|
||||
...snapshot,
|
||||
internalSku: String(snapshot.internalSku || internalSku),
|
||||
internalSkuChannel: primary.channel,
|
||||
internalSkuChannelSource: primary.source,
|
||||
}
|
||||
|
||||
const canOverrideDefaultManual = !selected || isDefaultManualFallback
|
||||
const existingAffiliateSku = String(parseObject(snapshot.affiliateDash).sku || '').trim()
|
||||
|
||||
if (primary.channel === 'affiliate_dash' || selected === 'affiliate_dash') {
|
||||
const dashSku = existingAffiliateSku || matchAffiliateDashSkuByInternalSku(internalSku)
|
||||
if (dashSku) {
|
||||
if (canOverrideDefaultManual || selected === 'affiliate_dash') {
|
||||
nextSnapshot.fulfillmentRoute = {
|
||||
...route,
|
||||
selectedExecutorKey: 'affiliate_dash',
|
||||
selectedRuleId: String(route.selectedRuleId || ''),
|
||||
reason: canOverrideDefaultManual
|
||||
? '内部 SKU 渠道能力指定自动发货'
|
||||
: String(route.reason || '自动发货路由'),
|
||||
matchedRule: route.matchedRule ?? null,
|
||||
candidates: Array.isArray(route.candidates) ? route.candidates : [],
|
||||
skipped: Array.isArray(route.skipped) ? route.skipped : [],
|
||||
}
|
||||
nextSnapshot.affiliateDash = {
|
||||
matchMode: 'affiliate_dash_sku_mapping',
|
||||
productNo: internalSku,
|
||||
sku: dashSku,
|
||||
skuName: internalSku,
|
||||
}
|
||||
// 商品归一按 aff:<dashSku> 找商品;内部中文 SKU 固定落 spec.skuNick。
|
||||
return {
|
||||
...item,
|
||||
skuCode: dashSku,
|
||||
spec: {
|
||||
...(item.spec && typeof item.spec === 'object' ? item.spec : {}),
|
||||
skuNick: internalSku,
|
||||
},
|
||||
isConfigured: true,
|
||||
snapshot: nextSnapshot,
|
||||
}
|
||||
}
|
||||
}
|
||||
nextSnapshot.internalSkuChannelReason = 'missing_affiliate_dash_mapping'
|
||||
return { ...item, snapshot: nextSnapshot }
|
||||
}
|
||||
|
||||
// 内部能力是接单/卡券,但路由只是默认人工兜底:清掉人工任务,交给工单或财务能力兜底。
|
||||
if (
|
||||
(primary.channel === 'work_order' || primary.channel === 'third_party_voucher') &&
|
||||
isDefaultManualFallback
|
||||
) {
|
||||
nextSnapshot.fulfillmentRoute = {
|
||||
...route,
|
||||
selectedExecutorKey: '',
|
||||
reason: `内部 SKU 渠道能力为 ${primary.channel},不使用默认人工兜底`,
|
||||
matchedRule: null,
|
||||
}
|
||||
return { ...item, isConfigured: false, snapshot: nextSnapshot }
|
||||
}
|
||||
|
||||
return { ...item, snapshot: nextSnapshot }
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { query } from '../../db/client.js'
|
||||
import type { FulfillmentChannel } from './channel-types.js'
|
||||
|
||||
export type InternalSkuChannelRow = {
|
||||
internal_sku: string
|
||||
channel: FulfillmentChannel
|
||||
priority: number
|
||||
is_fallback: boolean
|
||||
enabled: boolean
|
||||
canonical_product_id: number | null
|
||||
source: string
|
||||
}
|
||||
|
||||
export async function listInternalSkuChannels(
|
||||
internalSku: string,
|
||||
): Promise<InternalSkuChannelRow[]> {
|
||||
const key = String(internalSku || '').trim()
|
||||
if (!key) return []
|
||||
const result = await query<InternalSkuChannelRow>(
|
||||
`
|
||||
SELECT internal_sku, channel, priority, is_fallback, enabled, canonical_product_id, source
|
||||
FROM internal_sku_channels
|
||||
WHERE btrim(internal_sku) = $1 AND enabled = TRUE
|
||||
ORDER BY
|
||||
priority ASC,
|
||||
CASE channel
|
||||
WHEN 'affiliate_dash' THEN 1
|
||||
WHEN 'work_order' THEN 2
|
||||
WHEN 'third_party_voucher' THEN 3
|
||||
WHEN 'manual' THEN 4
|
||||
ELSE 9
|
||||
END,
|
||||
channel ASC
|
||||
`,
|
||||
[key],
|
||||
)
|
||||
return result.rows
|
||||
}
|
||||
|
||||
/** 财务侧可直接兜底的渠道:卡券/人工不要求新建履约任务。 */
|
||||
export async function resolveInternalSkuFallbackChannel(
|
||||
internalSku: string,
|
||||
): Promise<FulfillmentChannel | null> {
|
||||
const channels = await listInternalSkuChannels(internalSku)
|
||||
const safe = channels.find(
|
||||
(row) => row.channel === 'third_party_voucher' || row.channel === 'manual',
|
||||
)
|
||||
return safe?.channel || null
|
||||
}
|
||||
|
||||
export async function upsertInternalSkuChannel(input: {
|
||||
internalSku: string
|
||||
channel: FulfillmentChannel
|
||||
priority?: number
|
||||
isFallback?: boolean
|
||||
canonicalProductId?: number | null
|
||||
source?: string
|
||||
}): Promise<void> {
|
||||
const internalSku = String(input.internalSku || '').trim()
|
||||
if (!internalSku) return
|
||||
await query(
|
||||
`
|
||||
INSERT INTO internal_sku_channels (
|
||||
internal_sku, channel, priority, is_fallback, enabled,
|
||||
canonical_product_id, source, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, TRUE, $5, $6, NOW(), NOW())
|
||||
ON CONFLICT (internal_sku, channel) DO UPDATE SET
|
||||
priority = LEAST(internal_sku_channels.priority, EXCLUDED.priority),
|
||||
is_fallback = internal_sku_channels.is_fallback AND EXCLUDED.is_fallback,
|
||||
canonical_product_id = COALESCE(EXCLUDED.canonical_product_id, internal_sku_channels.canonical_product_id),
|
||||
enabled = TRUE,
|
||||
updated_at = NOW()
|
||||
`,
|
||||
[
|
||||
internalSku,
|
||||
input.channel,
|
||||
Number.isFinite(Number(input.priority)) ? Number(input.priority) : 100,
|
||||
input.isFallback === true,
|
||||
input.canonicalProductId ?? null,
|
||||
String(input.source || 'auto').trim() || 'auto',
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -377,6 +377,15 @@ function resolveDouyinMashidaItemCandidate({
|
||||
* 映射来源 = **权威常量表 + 后台增量覆盖**(`affiliate-dash` 配置面板的 `skuMapping`),
|
||||
* 与 91 分支同一套来源:新增抖音商品在后台加一行即可,不必改代码发版。
|
||||
*/
|
||||
export function matchAffiliateDashSkuByInternalSku(internalSku: unknown): string {
|
||||
const raw = String(internalSku ?? '').trim()
|
||||
if (!raw) return ''
|
||||
return matchAffiliateDashSku(
|
||||
mergeAffiliateDashSkuMapping(getAffiliateDashConfig().skuMapping),
|
||||
raw,
|
||||
)
|
||||
}
|
||||
|
||||
export function matchDouyinMashidaAffiliateDashSku(internalSku: unknown): string {
|
||||
const raw = String(internalSku ?? '').trim()
|
||||
if (!raw) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { withTransaction } from '../../db/client.js'
|
||||
import { syncDeliveryTasksForOrder } from './delivery-task-service.js'
|
||||
import { resolveOrderItemForFulfillment } from '../fulfillment/product-resolution-service.js'
|
||||
import { applyIntakeChannelDecision } from '../fulfillment/intake-channel-decision-service.js'
|
||||
import {
|
||||
resolveOrderFulfillmentReadiness,
|
||||
syncOrderFulfillmentAttachments,
|
||||
@@ -113,14 +114,19 @@ export async function upsertOrderFromSource(
|
||||
})
|
||||
|
||||
const resolvedItems = await Promise.all(
|
||||
event.items.map((item) =>
|
||||
resolveOrderItemForFulfillment({
|
||||
event.items.map(async (item) => {
|
||||
const resolved = await resolveOrderItemForFulfillment({
|
||||
provider: event.provider,
|
||||
platform: event.platform,
|
||||
shopId: event.shopId,
|
||||
item,
|
||||
}),
|
||||
),
|
||||
})
|
||||
return applyIntakeChannelDecision(resolved, {
|
||||
provider: event.provider,
|
||||
platform: event.platform,
|
||||
shopId: event.shopId,
|
||||
})
|
||||
}),
|
||||
)
|
||||
const resolvedFulfillmentItems = resolvedItems as FulfillmentOrderItem[]
|
||||
const configuredItems = resolvedFulfillmentItems.filter((item) => item.isConfigured)
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { JsonObject } from '../../types/json.js'
|
||||
import { query } from '../../db/client.js'
|
||||
import { assignPendingOrderItemChannels } from '../fulfillment/channel-assign-service.js'
|
||||
import { runAffiliateFallback } from '../fulfillment/affiliate-fallback-service.js'
|
||||
import { reconcilePendingCarriers } from '../fulfillment/carrier-reconciliation-service.js'
|
||||
import {
|
||||
resolveExceptionsForItemsByReason,
|
||||
resolveOrphanCostExceptions,
|
||||
@@ -84,6 +85,10 @@ export async function runChannelAssignJob(job: JsonObject = {}) {
|
||||
export async function runCarrierDispatchJob(job: JsonObject = {}) {
|
||||
const config = jobConfig(job)
|
||||
const limit = clampInt(config.limit, 200, 1, 2000)
|
||||
const reconciled = await reconcilePendingCarriers({ limit: Math.min(limit, 200) })
|
||||
if (reconciled.affiliateTasksCreated > 0) {
|
||||
await assignPendingOrderItemChannels({ batchSize: 200, maxBatches: 1 }).catch(() => undefined)
|
||||
}
|
||||
const cursor =
|
||||
config.afterId != null
|
||||
? Math.max(0, Math.trunc(Number(config.afterId)))
|
||||
|
||||
Reference in New Issue
Block a user