feat(fulfillment): 统一中文内部 SKU 履约路由

This commit is contained in:
yml2213
2026-11-03 11:49:38 +08:00
parent f447b5d73a
commit 3ba808b814
49 changed files with 1643 additions and 1963 deletions
@@ -0,0 +1,134 @@
-- 126_internal_sku_canonical_routing.sql
--
-- 履约渠道的唯一标准键是规范化后的中文内部 SKU。
-- 平台 SKU 只能用于入口映射,不能再成为渠道判定或兜底键。
CREATE OR REPLACE FUNCTION canonicalize_internal_sku(p_value TEXT)
RETURNS TEXT
LANGUAGE SQL
IMMUTABLE
PARALLEL SAFE
AS $$
SELECT NULLIF(
LOWER(
REGEXP_REPLACE(
BTRIM(NORMALIZE(COALESCE(p_value, ''), NFKC)),
'\s+',
'',
'g'
)
),
''
)
$$;
COMMENT ON FUNCTION canonicalize_internal_sku(TEXT) IS
'内部 SKU 唯一规范化:NFKC、去全部空白、转小写';
CREATE OR REPLACE FUNCTION resolve_internal_sku(
p_manual_sku_key TEXT,
p_sku_name TEXT,
p_sku_code TEXT
) RETURNS TEXT
LANGUAGE SQL
IMMUTABLE
PARALLEL SAFE
AS $$
SELECT COALESCE(
CASE
WHEN canonicalize_internal_sku(p_manual_sku_key) ~ '[一-龥]'
THEN canonicalize_internal_sku(p_manual_sku_key)
END,
CASE
WHEN canonicalize_internal_sku(p_sku_name) ~ '[一-龥]'
THEN canonicalize_internal_sku(p_sku_name)
END
)
$$;
COMMENT ON FUNCTION resolve_internal_sku(TEXT, TEXT, TEXT) IS
'订单项唯一内部 SKU 解析:只接受规范化后的中文 SKU,平台 SKU 不参与兜底';
COMMENT ON COLUMN order_items.manual_sku_key IS
'原始内部 SKU 匹配键(skuNick -> outSkuSn -> sku_code -> sku_name);渠道判定统一使用 resolve_internal_sku() 的中文规范值';
CREATE OR REPLACE FUNCTION normalize_internal_sku_channel_key()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
NEW.internal_sku := canonicalize_internal_sku(NEW.internal_sku);
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION normalize_manual_fulfillment_config_key()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
NEW.sku_key := canonicalize_internal_sku(NEW.sku_key);
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION normalize_manual_sku_registry_key()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
NEW.sku_key := canonicalize_internal_sku(NEW.sku_key);
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION normalize_internal_sku_mapping_keys()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
NEW.match_value := canonicalize_internal_sku(NEW.match_value);
NEW.internal_sku := canonicalize_internal_sku(NEW.internal_sku);
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_normalize_internal_sku_channel_key ON internal_sku_channels;
CREATE TRIGGER trg_normalize_internal_sku_channel_key
BEFORE INSERT OR UPDATE OF internal_sku ON internal_sku_channels
FOR EACH ROW EXECUTE FUNCTION normalize_internal_sku_channel_key();
DROP TRIGGER IF EXISTS trg_normalize_manual_fulfillment_config_key ON manual_fulfillment_configs;
CREATE TRIGGER trg_normalize_manual_fulfillment_config_key
BEFORE INSERT OR UPDATE OF sku_key ON manual_fulfillment_configs
FOR EACH ROW EXECUTE FUNCTION normalize_manual_fulfillment_config_key();
DROP TRIGGER IF EXISTS trg_normalize_manual_sku_registry_key ON manual_sku_registry;
CREATE TRIGGER trg_normalize_manual_sku_registry_key
BEFORE INSERT OR UPDATE OF sku_key ON manual_sku_registry
FOR EACH ROW EXECUTE FUNCTION normalize_manual_sku_registry_key();
DROP TRIGGER IF EXISTS trg_normalize_internal_sku_mapping_keys ON internal_sku_mappings;
CREATE TRIGGER trg_normalize_internal_sku_mapping_keys
BEFORE INSERT OR UPDATE OF match_value, internal_sku ON internal_sku_mappings
FOR EACH ROW EXECUTE FUNCTION normalize_internal_sku_mapping_keys();
CREATE INDEX IF NOT EXISTS idx_internal_sku_channels_canonical
ON internal_sku_channels (canonicalize_internal_sku(internal_sku), priority, channel)
WHERE enabled = TRUE;
CREATE INDEX IF NOT EXISTS idx_manual_fulfillment_configs_canonical_sku
ON manual_fulfillment_configs (canonicalize_internal_sku(sku_key))
WHERE enabled = TRUE;
CREATE INDEX IF NOT EXISTS idx_internal_sku_mappings_canonical_match
ON internal_sku_mappings (match_type, canonicalize_internal_sku(match_value))
WHERE enabled = TRUE;
CREATE INDEX IF NOT EXISTS idx_product_catalog_canonical_name
ON product_catalog (canonicalize_internal_sku(canonical_name))
WHERE status = 'active';
CREATE INDEX IF NOT EXISTS idx_order_items_pending_internal_sku
ON order_items (resolve_internal_sku(manual_sku_key, sku_name, sku_code))
WHERE fulfillment_status = 'pending';
@@ -84,8 +84,6 @@ router.post(
if (body.internalSku) {
return assignChannelByInternalSku({
provider: String(body.provider || ''),
shopId: String(body.shopId || ''),
internalSku: String(body.internalSku),
channel,
adminUserId,
@@ -4,7 +4,6 @@ import { requireAdminRoles } from './session.js'
import kuaishouIndustryRouter from './platform-config/kuaishou-industry.js'
import ninetyoneRouter from './platform-config/ninetyone.js'
import notificationsRouter from './platform-config/notifications.js'
import fulfillmentRoutingRouter from './platform-config/fulfillment-routing.js'
import affiliateDashRouter from './platform-config/affiliate-dash.js'
import douyinMashidaRouter from './platform-config/douyin-mashida.js'
import pddRouter from './platform-config/pdd.js'
@@ -15,7 +14,6 @@ router.use('/platform-config', kuaishouIndustryRouter)
router.use('/platform-config', requireAdminRoles(['admin']))
router.use('/platform-config', notificationsRouter)
router.use('/platform-config', ninetyoneRouter)
router.use('/platform-config', fulfillmentRoutingRouter)
router.use('/platform-config', affiliateDashRouter)
router.use('/platform-config', pddRouter)
// 抖音码速达:注册在 requireAdminRoles(['admin']) 之后,自动继承 admin 守卫
@@ -1,53 +0,0 @@
import { Router } from 'express'
import {
getAdminFulfillmentRoutingConfig,
previewAdminFulfillmentRouting,
updateAdminFulfillmentRoutingConfig,
} from '../../../services/admin/platform-config/fulfillment-routing-service.js'
import { createJsonHandler } from '../session.js'
import type { JsonRecord } from '../../../types/json.js'
const router = Router()
router.get(
'/fulfillment-routing',
createJsonHandler(() => getAdminFulfillmentRoutingConfig(), {
successMessage: 'ok',
errorMessage: '读取履约路由配置失败',
scope: '[admin/platform-config/fulfillment-routing]',
}),
)
router.post(
'/fulfillment-routing',
createJsonHandler((req) => updateAdminFulfillmentRoutingConfig(req.body as JsonRecord), {
successMessage: '履约路由配置已保存',
errorMessage: '保存履约路由配置失败',
scope: '[admin/platform-config/fulfillment-routing]',
audit: (_req, data) => {
const result = data as JsonRecord
return {
action: 'platform_fulfillment_routing_updated',
targetType: 'platform_config',
targetId: 'fulfillment_routing',
data: {
filePath: String(result.filePath || '').trim(),
enabled: result.enabled !== false,
ruleCount: Array.isArray(result.rules) ? result.rules.length : 0,
},
}
},
}),
)
router.post(
'/fulfillment-routing/preview',
createJsonHandler((req) => previewAdminFulfillmentRouting(req.body as JsonRecord), {
successMessage: 'ok',
errorMessage: '预览履约路由失败',
scope: '[admin/platform-config/fulfillment-routing/preview]',
}),
)
export default router
@@ -1,50 +0,0 @@
import {
getFulfillmentRoutingConfig,
getFulfillmentRoutingConfigFilePath,
saveFulfillmentRoutingConfig,
} from '../../fulfillment/routing-config-service.js'
import { resolveOrderItemForFulfillment } from '../../fulfillment/product-resolution-service.js'
import { normalizeProductMatchName } from '../../order/product-match-utils.js'
import type { JsonRecord } from '../../../types/json.js'
export function getAdminFulfillmentRoutingConfig() {
return {
filePath: getFulfillmentRoutingConfigFilePath(),
...getFulfillmentRoutingConfig(),
}
}
export async function updateAdminFulfillmentRoutingConfig(payload: JsonRecord = {}) {
return {
filePath: getFulfillmentRoutingConfigFilePath(),
...(await saveFulfillmentRoutingConfig(payload)),
}
}
export async function previewAdminFulfillmentRouting(payload: JsonRecord = {}) {
const productName = String(payload.productName || payload.skuName || '').trim()
const item = await resolveOrderItemForFulfillment({
provider: '91kaquan',
platform: 'kuaishou',
item: {
itemId: productName,
externalItemId: productName,
skuCode: productName,
skuName: productName,
externalSkuCode: productName,
externalSkuName: productName,
quantity: 1,
},
})
const snapshot = item.snapshot || {}
return {
productName,
normalizedProductName: normalizeProductMatchName(productName),
resolvedSkuCode: item.skuCode,
resolvedSkuName: item.skuName,
isConfigured: item.isConfigured,
fulfillmentRoute: snapshot.fulfillmentRoute || null,
affiliateDash: snapshot.affiliateDash || null,
}
}
@@ -52,7 +52,8 @@ export function productSourceFilterSql(
WHERE live_channel.enabled = TRUE
AND live_channel.is_fallback = FALSE
AND live_channel.channel <> 'manual'
AND BTRIM(live_channel.internal_sku) = BTRIM(${nameColumn})
AND canonicalize_internal_sku(live_channel.internal_sku)
= canonicalize_internal_sku(${nameColumn})
)
)
)`
@@ -51,7 +51,8 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {}
WHERE live_channel.enabled = TRUE
AND live_channel.is_fallback = FALSE
AND live_channel.channel <> 'manual'
AND BTRIM(live_channel.internal_sku) = BTRIM(p.canonical_name)
AND canonicalize_internal_sku(live_channel.internal_sku)
= canonicalize_internal_sku(p.canonical_name)
)
)
)
@@ -116,7 +117,8 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {}
FROM internal_sku_channels c
WHERE c.enabled = TRUE
AND c.is_fallback = FALSE
AND BTRIM(c.internal_sku) = BTRIM(p.canonical_name)
AND canonicalize_internal_sku(c.internal_sku)
= canonicalize_internal_sku(p.canonical_name)
ORDER BY c.priority, c.channel
LIMIT 1
) AS active_primary_channel,
@@ -295,7 +295,10 @@ test('人工客服筛选排除已有其他主渠道的旧 man 商品', () => {
const sql = productSourceFilterSql(1)
assert.ok(sql.includes(`$1 = 'manual'`))
assert.ok(sql.includes("live_channel.channel <> 'manual'"))
assert.ok(sql.includes('BTRIM(live_channel.internal_sku) = BTRIM(p.canonical_name)'))
assert.ok(
sql.includes('canonicalize_internal_sku(live_channel.internal_sku)') &&
sql.includes('canonicalize_internal_sku(p.canonical_name)'),
)
})
test('成本中心总览异常统计必须走异常表关联,不能全表扫描订单项子查询', async () => {
@@ -9,7 +9,7 @@ import { nowIso } from '../../utils/time.js'
* · 新:同一 scope 聚合为一条问题头,明细放 cost_exception_items,
* 新单进来只加计数,解决后自动关闭。
*
* scope_key 约定:`reason|provider|shop_id|sku|channel`,由调用方拼好。
* scope_key 统一以中文内部 SKU 为聚合维度;平台 SKU/店铺只放 detail,不参与分组。
*/
export const COST_EXCEPTION_REASONS = [
@@ -5,6 +5,7 @@ import { createHttpError } from '../../utils/http.js'
import { formatShanghaiDate, shiftDate } from '../../utils/finance-date-range.js'
import { nowIso } from '../../utils/time.js'
import { upsertInternalSkuChannel } from '../fulfillment/internal-sku-channel-service.js'
import { canonicalizeInternalSku, containsCjk } from '../fulfillment/internal-sku-resolver.js'
/**
* 人工履约 SKU 配置服务。
@@ -244,7 +245,8 @@ export async function listManualSkus(input: Record<string, unknown> = {}) {
JOIN orders o ON o.id = oi.order_id
WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual'
AND o.provider = $${params.length}
AND resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) = r.sku_key
AND resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)
= canonicalize_internal_sku(r.sku_key)
)`)
}
@@ -253,7 +255,8 @@ export async function listManualSkus(input: Record<string, unknown> = {}) {
`
SELECT COUNT(*)::int AS total
FROM manual_sku_registry r
LEFT JOIN manual_fulfillment_configs cfg ON cfg.sku_key = r.sku_key
LEFT JOIN manual_fulfillment_configs cfg
ON canonicalize_internal_sku(cfg.sku_key) = canonicalize_internal_sku(r.sku_key)
WHERE ${where}
`,
params,
@@ -272,7 +275,8 @@ export async function listManualSkus(input: Record<string, unknown> = {}) {
cfg.canonical_product_id AS config_canonical_product_id,
cfg.enabled AS config_enabled
FROM manual_sku_registry r
LEFT JOIN manual_fulfillment_configs cfg ON cfg.sku_key = r.sku_key
LEFT JOIN manual_fulfillment_configs cfg
ON canonicalize_internal_sku(cfg.sku_key) = canonicalize_internal_sku(r.sku_key)
WHERE ${where}
ORDER BY r.item_count DESC, r.sku_key ASC
LIMIT $${listParams.length - 1} OFFSET $${listParams.length}
@@ -309,7 +313,8 @@ export async function getManualSkuOverview() {
AS unconfigured_items,
MAX(r.updated_at) AS last_scan_at
FROM manual_sku_registry r
LEFT JOIN manual_fulfillment_configs cfg ON cfg.sku_key = r.sku_key
LEFT JOIN manual_fulfillment_configs cfg
ON canonicalize_internal_sku(cfg.sku_key) = canonicalize_internal_sku(r.sku_key)
`,
)
const row = result.rows[0]
@@ -326,9 +331,9 @@ export async function getManualSkuOverview() {
/** 保存(新增或更新)一个人工 SKU 的履约配置。 */
export async function saveManualFulfillmentConfig(payload: JsonObject) {
const skuKey = String(payload.skuKey ?? payload.sku_key ?? '').trim()
if (!skuKey) {
throw createHttpError('内部 SKU 不能为空', {
const skuKey = canonicalizeInternalSku(payload.skuKey ?? payload.sku_key)
if (!skuKey || !containsCjk(skuKey)) {
throw createHttpError('必须使用包含中文的内部 SKU', {
statusCode: 400,
errorCode: 'manual_config_sku_key_required',
})
@@ -346,39 +351,47 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
const unitPrices = await loadPlatformUnitPriceFen()
const now = nowIso()
const result = await query<{
id: number
sku_key: string
canonical_product_id: number | null
platform_key: string
credit_quantity: number
cost_mode: string
enabled: boolean
note: string
created_at: string
updated_at: string
}>(
`
INSERT INTO manual_fulfillment_configs (
sku_key, canonical_product_id, platform_key, credit_quantity,
cost_mode, enabled, note, created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $8
)
ON CONFLICT (sku_key) DO UPDATE
SET canonical_product_id = EXCLUDED.canonical_product_id,
platform_key = EXCLUDED.platform_key,
credit_quantity = EXCLUDED.credit_quantity,
cost_mode = EXCLUDED.cost_mode,
enabled = EXCLUDED.enabled,
note = EXCLUDED.note,
updated_at = EXCLUDED.updated_at
RETURNING *
`,
[skuKey, canonicalProductId, platformKey, creditQuantity, costMode, enabled, note, now],
)
const config = await withTransaction(async (client) => {
const existing = await client.query<{ id: number }>(
`SELECT id FROM manual_fulfillment_configs
WHERE canonicalize_internal_sku(sku_key) = $1
ORDER BY id
LIMIT 1
FOR UPDATE`,
[skuKey],
)
const existingId = Number(existing.rows[0]?.id || 0)
const result = existingId
? await client.query(
`UPDATE manual_fulfillment_configs
SET sku_key = $2, canonical_product_id = $3, platform_key = $4,
credit_quantity = $5, cost_mode = $6, enabled = $7, note = $8,
updated_at = $9
WHERE id = $1
RETURNING *`,
[
existingId,
skuKey,
canonicalProductId,
platformKey,
creditQuantity,
costMode,
enabled,
note,
now,
],
)
: await client.query(
`INSERT INTO manual_fulfillment_configs (
sku_key, canonical_product_id, platform_key, credit_quantity,
cost_mode, enabled, note, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)
RETURNING *`,
[skuKey, canonicalProductId, platformKey, creditQuantity, costMode, enabled, note, now],
)
return requiredRow(result.rows[0], '保存人工履约配置失败')
})
const config = requiredRow(result.rows[0], '保存人工履约配置失败')
await syncRegistryConfigId(config.sku_key, config.id)
// 同步渠道规则:人工配置就是「这个内部 SKU 走人工渠道」的唯一声明,
// 少了这一步,新配的 SKU 会一直停在待分类。
@@ -397,7 +410,7 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
/** 删除一份人工履约配置(SKU 回到未配置状态)。 */
export async function deleteManualFulfillmentConfig(skuKey: string) {
const key = String(skuKey || '').trim()
const key = canonicalizeInternalSku(skuKey)
if (!key) {
throw createHttpError('内部 SKU 不能为空', {
statusCode: 400,
@@ -405,7 +418,9 @@ export async function deleteManualFulfillmentConfig(skuKey: string) {
})
}
const deleted = await query(
`DELETE FROM manual_fulfillment_configs WHERE sku_key = $1 RETURNING id`,
`DELETE FROM manual_fulfillment_configs
WHERE canonicalize_internal_sku(sku_key) = $1
RETURNING id`,
[key],
)
await upsertInternalSkuChannel({
@@ -444,10 +459,12 @@ export async function rematchManualFulfillments(input: Record<string, unknown> =
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN manual_fulfillment_configs cfg
ON cfg.sku_key = resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)
ON canonicalize_internal_sku(cfg.sku_key) = resolve_internal_sku(
oi.manual_sku_key, oi.sku_name, oi.sku_code
)
WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual'
AND cfg.enabled = TRUE
AND ($1 = '' OR cfg.sku_key = $1)
AND ($1 = '' OR canonicalize_internal_sku(cfg.sku_key) = $1)
AND ($2 = FALSE OR oi.fulfillment_config_id IS NULL)
AND (
$3::date IS NULL
@@ -532,17 +549,18 @@ async function attachCostVersionsForManualConfigs() {
/** 配置保存/删除后同步登记表上的 config_id。 */
async function syncRegistryConfigId(skuKey: string, configId: number | null) {
await query(`UPDATE manual_sku_registry SET config_id = $2, updated_at = $3 WHERE sku_key = $1`, [
skuKey,
configId,
nowIso(),
])
await query(
`UPDATE manual_sku_registry
SET config_id = $2, updated_at = $3
WHERE canonicalize_internal_sku(sku_key) = $1`,
[canonicalizeInternalSku(skuKey), configId, nowIso()],
)
}
/** 读取单个 SKU 的配置详情(含平台标签与成本折算)。 */
export async function getManualFulfillmentConfig(skuKey: string) {
const unitPrices = await loadPlatformUnitPriceFen()
const key = String(skuKey || '').trim()
const key = canonicalizeInternalSku(skuKey)
const result = await query<{
id: number
sku_key: string
@@ -554,7 +572,13 @@ export async function getManualFulfillmentConfig(skuKey: string) {
note: string
created_at: string
updated_at: string
}>(`SELECT * FROM manual_fulfillment_configs WHERE sku_key = $1 LIMIT 1`, [key])
}>(
`SELECT * FROM manual_fulfillment_configs
WHERE canonicalize_internal_sku(sku_key) = $1
ORDER BY id
LIMIT 1`,
[key],
)
const row = result.rows[0]
if (!row) return null
return mapManualConfig(row, unitPrices)
@@ -737,7 +761,9 @@ async function syncManualChannelRule(config: {
`
SELECT EXISTS (
SELECT 1 FROM internal_sku_channels
WHERE btrim(internal_sku) = $1 AND enabled = TRUE AND channel <> 'manual'
WHERE canonicalize_internal_sku(internal_sku) = $1
AND enabled = TRUE
AND channel <> 'manual'
) AS exists
`,
[skuKey],
@@ -161,7 +161,8 @@ export async function listAdminProducts(payload: JsonObject = {}) {
FROM internal_sku_channels c
WHERE c.enabled = TRUE
AND c.is_fallback = FALSE
AND BTRIM(c.internal_sku) = BTRIM(p.canonical_name)
AND canonicalize_internal_sku(c.internal_sku)
= canonicalize_internal_sku(p.canonical_name)
ORDER BY c.priority, c.channel
LIMIT 1
) AS active_primary_channel,
@@ -521,7 +522,8 @@ export async function archiveSupersededManualProducts(payload: JsonObject = {})
WHERE c.enabled = TRUE
AND c.is_fallback = FALSE
AND c.channel <> 'manual'
AND BTRIM(c.internal_sku) = BTRIM(p.canonical_name)
AND canonicalize_internal_sku(c.internal_sku)
= canonicalize_internal_sku(p.canonical_name)
ORDER BY c.priority, c.channel
LIMIT 1
) live ON TRUE
@@ -557,7 +559,8 @@ export async function archiveSupersededManualProducts(payload: JsonObject = {})
updated_at = NOW()
FROM unnest($1::text[], $2::bigint[], $3::bigint[])
AS input(internal_sku, source_product_id, target_product_id)
WHERE c.internal_sku = input.internal_sku
WHERE canonicalize_internal_sku(c.internal_sku)
= canonicalize_internal_sku(input.internal_sku)
AND c.channel = 'manual'
AND c.canonical_product_id = input.source_product_id
`,
@@ -50,7 +50,8 @@ const index = buildMappingIndex(
mappings,
[{ sku: 'aff-sku', productId: 21 }],
[{ ruleKey: 'rule-abc', productId: 31 }],
[{ sku: 'voucher-sku', productId: 41 }],
[{ sku: '中文卡券', productId: 41 }],
[{ canonicalName: '套装-测试', productId: 51 }],
)
function item(patch: Record<string, unknown> = {}) {
@@ -132,13 +133,31 @@ test('商品归一:人工渠道直接使用配置里指定的商品', () => {
test('商品归一:第三方卡券按 tpv:SKU 直接命中', () => {
assert.equal(
resolveProductId(
item({ fulfillment_channel: 'third_party_voucher', sku_code: 'voucher-sku' }),
item({
fulfillment_channel: 'third_party_voucher',
sku_code: 'platform-voucher-code',
internal_sku: '中文卡券',
}),
index,
),
41,
)
})
test('商品归一:中文内部 SKU 命中标准商品,不依赖平台 SKU', () => {
assert.equal(
resolveProductId(
item({
fulfillment_channel: 'affiliate_dash',
sku_code: 'unknown',
internal_sku: '套装-测试',
}),
index,
),
51,
)
})
test('三方卡券自动建商品时优先使用内部中文 SKU', () => {
assert.equal(
voucherCanonicalSku({
@@ -156,6 +175,6 @@ test('三方卡券自动建商品时优先使用内部中文 SKU', () => {
}),
'tpv:11月改名卡',
)
assert.equal(voucherCanonicalSku({ manual_sku_key: '', sku_name: '', sku_code: 'x' }), 'tpv:x')
assert.equal(voucherCanonicalSku({ manual_sku_key: '', sku_name: '', sku_code: 'x' }), null)
assert.equal(voucherCanonicalSku({}), null)
})
@@ -2,6 +2,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'
import { canonicalizeInternalSku, containsCjk } from '../fulfillment/internal-sku-resolver.js'
/**
* 管道 B:商品归一。
@@ -9,12 +10,10 @@ import { upsertInternalSkuChannel } from '../fulfillment/internal-sku-channel-se
* 只写 `order_items.canonical_product_id`,不改渠道、不碰成本;
* 例外:三方卡券缺商品时按内部中文 SKU 自动建 `tpv:` 商品并补渠道规则。
*
* 解析顺序(与旧的四条回填路径统一):
* 1. 自动发货渠道:`aff:<sku_code>` 商品直接命中(该渠道的商品就是 SKU 本身);
* 2. 精确映射:provider + shop + sku_code;
* 3. 精确映射(店铺无关);
* 4. 编码+名称映射:provider + shop + normalized_sku_name;
* 5. 编码系列 → 名称系列。
* 解析顺序:
* 1. 人工/接单/自动发货/卡券的明确载体参数;
* 2. 中文内部 SKU 直接命中 `product_catalog.canonical_name`;
* 3. 平台 SKU 映射只作为历史数据兜底。
*
* 全部未命中 → 保持为空,写一条 SKU 级 `product_mapping_missing`。
*/
@@ -37,6 +36,7 @@ export type MappingIndex = {
affSkus: Map<string, number>
voucherSkus: Map<string, number>
templateRules: Map<string, number>
byCanonicalName: Map<string, number>
}
type ItemRow = {
@@ -46,6 +46,7 @@ type ItemRow = {
sku_code: string
sku_name: string
manual_sku_key: string | null
internal_sku?: string
normalized_sku_name: string | null
normalized_sku_series: string | null
fulfillment_channel: string | null
@@ -62,6 +63,7 @@ export function buildMappingIndex(
affiliateProducts: Array<{ sku: string; productId: number }>,
templateProducts: Array<{ ruleKey: string; productId: number }> = [],
voucherProducts: Array<{ sku: string; productId: number }> = [],
canonicalProducts: Array<{ canonicalName: string; productId: number }> = [],
): MappingIndex {
const index: MappingIndex = {
bySku: new Map(),
@@ -71,6 +73,7 @@ export function buildMappingIndex(
affSkus: new Map(),
voucherSkus: new Map(),
templateRules: new Map(),
byCanonicalName: new Map(),
}
for (const mapping of mappings) {
@@ -110,19 +113,26 @@ export function buildMappingIndex(
index.voucherSkus.set(String(product.sku).trim(), product.productId)
}
for (const product of canonicalProducts) {
const canonicalName = canonicalizeInternalSku(product.canonicalName)
if (canonicalName && !index.byCanonicalName.has(canonicalName)) {
index.byCanonicalName.set(canonicalName, product.productId)
}
}
return index
}
/** 三方卡券的内部 SKU:优先中文,其次名称/平台码。 */
/** 三方卡券只接受中文内部 SKU;平台码不能作为标准商品键。 */
export function pickVoucherInternalSku(item: {
manual_sku_key?: string | null
sku_name?: string | null
sku_code?: string | null
}): string {
const candidates = [item.manual_sku_key, item.sku_name, item.sku_code]
.map((value) => String(value || '').trim())
.map(canonicalizeInternalSku)
.filter(Boolean)
return candidates.find((value) => /[\u4e00-\u9fff]/.test(value)) || candidates[0] || ''
return candidates.find(containsCjk) || ''
}
export function voucherCanonicalSku(item: {
@@ -212,6 +222,7 @@ export function resolveProductId(item: ItemRow, index: MappingIndex): number | n
const skuCode = String(item.sku_code || '').trim()
const nameKey = String(item.normalized_sku_name || '').trim()
const seriesKey = String(item.normalized_sku_series || '').trim()
const internalSku = canonicalizeInternalSku(item.internal_sku || item.manual_sku_key)
// 人工渠道:配置里已经指定商品,直接用,不需要再建映射。
if (item.fulfillment_channel === 'manual' && item.manual_config_product_id) {
@@ -230,11 +241,17 @@ export function resolveProductId(item: ItemRow, index: MappingIndex): number | n
}
// 第三方卡券:与自动发货同构,商品编码 tpv:<sku>。
if (item.fulfillment_channel === 'third_party_voucher' && skuCode) {
const voucher = index.voucherSkus.get(skuCode)
if (item.fulfillment_channel === 'third_party_voucher' && internalSku) {
const voucher = index.voucherSkus.get(internalSku)
if (voucher) return voucher
}
// 中文内部 SKU 是标准商品键;平台 SKU 映射只作为历史数据兜底。
if (internalSku) {
const canonical = index.byCanonicalName.get(internalSku)
if (canonical) return canonical
}
const exactWithShop = index.bySku.get(key(provider, shopId, skuCode))
if (exactWithShop) return exactWithShop
const exactGlobal = index.bySku.get(key(provider, '', skuCode))
@@ -300,6 +317,7 @@ export async function normalizeOrderItemProducts(
oi.sku_code,
oi.sku_name,
oi.manual_sku_key,
resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku,
oi.normalized_sku_name,
oi.normalized_sku_series,
oi.fulfillment_channel,
@@ -339,7 +357,7 @@ export async function normalizeOrderItemProducts(
result.unmatched += 1
await upsertAggregatedException({
reason: 'product_mapping_missing',
scopeKey: `product_mapping_missing|${item.provider}|${item.shop_id}|${item.sku_code}|${item.fulfillment_channel || '-'}`,
scopeKey: `product_mapping_missing|-|-|${canonicalizeInternalSku(item.internal_sku || item.manual_sku_key || item.sku_code)}|${item.fulfillment_channel || '-'}`,
orderItemId: item.id,
detail: { skuName: item.sku_name, channel: item.fulfillment_channel },
})
@@ -402,6 +420,17 @@ async function loadMappingIndex(): Promise<MappingIndex> {
`,
)
const canonicalProducts = await query<{ canonical_name: string; id: number }>(
`
SELECT canonicalize_internal_sku(canonical_name) AS canonical_name,
MIN(id)::bigint AS id
FROM product_catalog
WHERE status = 'active' AND btrim(COALESCE(canonical_name, '')) <> ''
GROUP BY canonicalize_internal_sku(canonical_name)
HAVING canonicalize_internal_sku(canonical_name) IS NOT NULL
`,
)
return buildMappingIndex(
mappings.rows,
affiliateProducts.rows.map((row) => ({
@@ -416,6 +445,10 @@ async function loadMappingIndex(): Promise<MappingIndex> {
sku: String(row.canonical_sku).slice(4),
productId: Number(row.id),
})),
canonicalProducts.rows.map((row) => ({
canonicalName: row.canonical_name,
productId: Number(row.id),
})),
)
}
@@ -438,6 +471,7 @@ export async function normalizeProductsForItems(itemIds: number[]): Promise<{
oi.sku_code,
oi.sku_name,
oi.manual_sku_key,
resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku,
oi.normalized_sku_name,
oi.normalized_sku_series,
oi.fulfillment_channel,
@@ -465,7 +499,7 @@ export async function normalizeProductsForItems(itemIds: number[]): Promise<{
unmatched += 1
await upsertAggregatedException({
reason: 'product_mapping_missing',
scopeKey: `product_mapping_missing|${item.provider}|${item.shop_id}|${item.sku_code}|${item.fulfillment_channel || '-'}`,
scopeKey: `product_mapping_missing|-|-|${canonicalizeInternalSku(item.internal_sku || item.manual_sku_key || item.sku_code)}|${item.fulfillment_channel || '-'}`,
orderItemId: item.id,
detail: { skuName: item.sku_name, channel: item.fulfillment_channel },
})
@@ -619,8 +619,7 @@ async function resolveCreditOwnerAdminUserId(
/**
* 确认前按 SKU 匹配人工履约配置。
*
* 与 `rematchManualFulfillments` 使用同一套键:
* spec_json.skuNick -> spec_json.outSkuSn -> sku_code -> sku_name。
* 与 `rematchManualFulfillments` 使用同一套键:规范中文内部 SKU。
* 这样即使配置晚于订单创建,新单在确认时也能自动挂上渠道。
*/
async function matchManualFulfillmentConfigWithClient(
@@ -637,11 +636,8 @@ async function matchManualFulfillmentConfigWithClient(
FROM order_items oi
JOIN manual_fulfillment_configs cfg ON cfg.enabled = TRUE
AND (
cfg.sku_key = COALESCE(
NULLIF(oi.spec_json->>'skuNick', ''),
NULLIF(oi.spec_json->>'outSkuSn', ''),
NULLIF(oi.sku_code, ''),
NULLIF(oi.sku_name, '')
canonicalize_internal_sku(cfg.sku_key) = resolve_internal_sku(
oi.manual_sku_key, oi.sku_name, oi.sku_code
)
OR (
cfg.canonical_product_id IS NOT NULL
@@ -48,7 +48,9 @@ export async function runAffiliateFallback(
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
LEFT JOIN manual_fulfillment_configs cfg
ON cfg.sku_key = resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)
ON canonicalize_internal_sku(cfg.sku_key) = resolve_internal_sku(
oi.manual_sku_key, oi.sku_name, oi.sku_code
)
AND cfg.enabled
WHERE oi.fulfillment_status = 'assigned'
AND oi.fulfillment_channel = 'affiliate_dash'
@@ -58,7 +60,7 @@ export async function runAffiliateFallback(
WHERE channel.enabled = TRUE
AND channel.channel = 'manual'
AND channel.is_fallback = TRUE
AND btrim(channel.internal_sku) = resolve_internal_sku(
AND canonicalize_internal_sku(channel.internal_sku) = resolve_internal_sku(
oi.manual_sku_key, oi.sku_name, oi.sku_code
)
)
@@ -5,7 +5,7 @@ import { syncWorkerOrdersForSourceOrder } from '../worker-platform/sync-worker-o
import { INACTIVE_CARRIER_TASK_STATUSES, type FulfillmentChannel } from './channel-types.js'
import { applyIntakeChannelDecision } from './intake-channel-decision-service.js'
import type { ResolvedFulfillmentItem } from './product-resolution-service.js'
import { resolveInternalSkuKey } from './internal-sku-resolver.js'
import { canonicalizeInternalSku, resolveInternalSkuKey } from './internal-sku-resolver.js'
function parseObject(value: unknown): Record<string, unknown> {
if (value && typeof value === 'object' && !Array.isArray(value)) {
@@ -82,7 +82,8 @@ export async function ensureCarrierForOrderItem(input: {
const config = await query<{ id: number; canonical_product_id: number | null }>(
`SELECT id, canonical_product_id
FROM manual_fulfillment_configs
WHERE sku_key = resolve_internal_sku($1, $2, $3) AND enabled = TRUE
WHERE canonicalize_internal_sku(sku_key) = resolve_internal_sku($1, $2, $3)
AND enabled = TRUE
LIMIT 1`,
[item.manual_sku_key || '', item.sku_name, item.sku_code],
)
@@ -99,10 +100,10 @@ export async function ensureCarrierForOrderItem(input: {
return { ready: true, created: false, reason: 'manual_config_ready', message: '' }
}
return {
ready: true,
ready: false,
created: false,
reason: 'manual_config_missing',
message: '人工渠道已选定,但缺少人工履约配置',
message: '人工渠道已指定,但该内部 SKU 尚未配置人工履约',
}
}
@@ -114,23 +115,33 @@ export async function ensureCarrierForOrderItem(input: {
if (await hasWorkOrder(Number(item.id))) {
return { ready: true, created: false, reason: 'work_order_exists', message: '' }
}
const internalSku = resolveInternalSkuKey({
manualSkuKey: item.manual_sku_key,
skuCode: item.sku_code,
skuName: item.sku_name,
spec: parseObject(item.spec_json),
snapshot: parseObject(item.item_snapshot_json),
})
const internalSku = canonicalizeInternalSku(
resolveInternalSkuKey({
manualSkuKey: item.manual_sku_key,
skuCode: item.sku_code,
skuName: item.sku_name,
spec: parseObject(item.spec_json),
snapshot: parseObject(item.item_snapshot_json),
}),
)
const channelConfig = await query<{ config_json: Record<string, unknown> }>(
`SELECT config_json
FROM internal_sku_channels
WHERE btrim(internal_sku) = $1 AND channel = 'work_order' AND enabled = TRUE
WHERE canonicalize_internal_sku(internal_sku) = $1 AND channel = 'work_order' AND enabled = TRUE
LIMIT 1`,
[internalSku],
)
const preferredWorkRuleKey = String(
channelConfig.rows[0]?.config_json?.workRuleKey || '',
).trim()
if (!preferredWorkRuleKey) {
return {
ready: false,
created: false,
reason: 'work_rule_missing',
message: '接单平台渠道缺少 workRuleKey,请先补全统一 SKU 配置',
}
}
const snapshot = parseObject(item.item_snapshot_json)
const classification = parseObject(snapshot.classification)
const route = parseObject(snapshot.fulfillmentRoute)
@@ -151,7 +162,7 @@ export async function ensureCarrierForOrderItem(input: {
source: 'carrier_creation',
autoOnly: true,
skipChannelAssign: true,
...(preferredWorkRuleKey ? { preferredWorkRuleKey } : {}),
preferredWorkRuleKey,
})
const ready = (await hasWorkOrder(Number(item.id))) || result.created.length > 0
if (ready) {
@@ -100,7 +100,7 @@ async function loadCandidateItems(input: {
) AS channels
FROM internal_sku_channels channel
WHERE channel.enabled = TRUE
AND btrim(channel.internal_sku) = resolve_internal_sku(
AND canonicalize_internal_sku(channel.internal_sku) = resolve_internal_sku(
oi.manual_sku_key, oi.sku_name, oi.sku_code
)
) capability ON jsonb_array_length(capability.channels) > 0
@@ -1,11 +1,7 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
resolveChannelDecision,
type ChannelCandidateItem,
type ChannelRule,
} from './channel-assign-service.js'
import { resolveChannelDecision, type ChannelCandidateItem } from './channel-assign-service.js'
import type { InternalSkuChannelRow } from './internal-sku-channel-service.js'
function buildItem(patch: Partial<ChannelCandidateItem> = {}): ChannelCandidateItem {
@@ -36,7 +32,7 @@ function buildChannel(patch: Partial<InternalSkuChannelRow> = {}): InternalSkuCh
is_fallback: false,
enabled: true,
canonical_product_id: null,
config_json: {},
config_json: { manualConfigId: 1 },
source: 'test',
note: '',
created_by_admin_user_id: null,
@@ -44,151 +40,79 @@ function buildChannel(patch: Partial<InternalSkuChannelRow> = {}): InternalSkuCh
}
}
function buildRule(patch: Partial<ChannelRule> = {}): ChannelRule {
return {
id: 1,
provider: '',
shop_id: '',
match_key_type: 'sku_code',
match_key: 'suit_xxx',
channel: 'affiliate_dash',
priority: 60,
enabled: true,
...patch,
}
}
test('渠道判定:真实载体优先于冲突的旧规则', () => {
const decision = resolveChannelDecision(buildItem({ has_work_order: true }), [
buildRule({ channel: 'third_party_voucher', priority: 10 }),
])
test('渠道判定:真实载体优先于统一渠道配置', () => {
const decision = resolveChannelDecision(
buildItem({
has_work_order: true,
channels: [buildChannel({ channel: 'third_party_voucher' })],
}),
)
assert.equal(decision.channel, 'work_order')
assert.equal(decision.source, 'carrier_evidence')
assert.equal(decision.conflict, false)
})
test('渠道判定:无规则时按载体证据判定', () => {
const workOrder = resolveChannelDecision(buildItem({ has_work_order: true }), [])
test('渠道判定:无配置时按真实载体判定', () => {
const workOrder = resolveChannelDecision(buildItem({ has_work_order: true }))
assert.equal(workOrder.channel, 'work_order')
assert.equal(workOrder.source, 'carrier_evidence')
const affiliate = resolveChannelDecision(buildItem({ has_affiliate_task: true }), [])
const affiliate = resolveChannelDecision(buildItem({ has_affiliate_task: true }))
assert.equal(affiliate.channel, 'affiliate_dash')
assert.equal(affiliate.source, 'carrier_evidence')
const manual = resolveChannelDecision(buildItem({ has_manual_task: true }))
assert.equal(manual.channel, 'manual')
})
test('渠道判定:既无规则也无载体时保持 pending,不兜底人工', () => {
const decision = resolveChannelDecision(buildItem(), [])
test('渠道判定:无配置、无载体时保持 pending,不再看平台 SKU 规则', () => {
const decision = resolveChannelDecision(buildItem())
assert.equal(decision.channel, null)
assert.equal(decision.conflict, false)
assert.equal(decision.pendingReason, 'no_channel_capability')
})
test('渠道判定:同优先级多渠道判为冲突,不静默选择', () => {
const decision = resolveChannelDecision(buildItem(), [
buildRule({ id: 1, channel: 'affiliate_dash', priority: 10 }),
buildRule({ id: 2, channel: 'manual', priority: 10 }),
])
assert.equal(decision.channel, null)
assert.equal(decision.conflict, true)
assert.deepEqual(decision.conflictChannels.sort(), ['affiliate_dash', 'manual'])
})
test('渠道判定:不同优先级时取优先级更小的', () => {
const decision = resolveChannelDecision(buildItem(), [
buildRule({ id: 1, channel: 'third_party_voucher', priority: 60 }),
buildRule({ id: 2, channel: 'manual', priority: 70 }),
])
assert.equal(decision.channel, 'third_party_voucher')
})
test('渠道判定:provider / shop 限定生效,空串表示不限', () => {
const scoped = buildRule({ provider: 'pdd', shop_id: 'shop-b' })
assert.equal(resolveChannelDecision(buildItem(), [scoped]).channel, null)
const wide = buildRule({ provider: '', shop_id: '', channel: 'third_party_voucher' })
assert.equal(resolveChannelDecision(buildItem(), [wide]).channel, 'third_party_voucher')
})
test('渠道判定:内部 SKU 与 SKU 名称两种键都能命中', () => {
const byInternal = buildRule({
match_key_type: 'internal_sku',
match_key: 'nick-001',
channel: 'manual',
})
assert.equal(
resolveChannelDecision(buildItem({ manual_sku_key: 'nick-001', internal_sku: 'nick-001' }), [
byInternal,
]).channel,
'manual',
)
const byName = buildRule({
match_key_type: 'sku_name',
match_key: '套装-测试',
channel: 'third_party_voucher',
})
assert.equal(
resolveChannelDecision(buildItem({ normalized_sku_name: '套装-测试' }), [byName]).channel,
'third_party_voucher',
)
})
test('渠道判定:停用的规则不参与', () => {
const decision = resolveChannelDecision(buildItem(), [buildRule({ enabled: false })])
assert.equal(decision.channel, null)
})
test('渠道判定:卡券/人工能力可兜底,自动发货/接单必须靠载体', () => {
const voucher = resolveChannelDecision(
buildItem({ channels: [buildChannel({ channel: 'third_party_voucher' })] }),
[],
)
assert.equal(voucher.channel, 'third_party_voucher')
assert.equal(voucher.source, 'auto_mapping')
const manual = resolveChannelDecision(buildItem({ channels: [buildChannel()] }), [])
assert.equal(manual.channel, 'manual')
assert.equal(manual.source, 'auto_mapping')
const affiliate = resolveChannelDecision(
buildItem({ channels: [buildChannel({ channel: 'affiliate_dash' })] }),
[],
)
assert.equal(affiliate.channel, null, '自动发货能力没有任务时不能只改财务口径')
const workOrder = resolveChannelDecision(
buildItem({ channels: [buildChannel({ channel: 'work_order' })] }),
[],
)
assert.equal(workOrder.channel, null, '接单能力没有工单时不能只改财务口径')
})
test('渠道判定:内部 SKU 配置优先于旧 sku_code 规则', () => {
const decision = resolveChannelDecision(
buildItem({ channels: [buildChannel({ channel: 'third_party_voucher' })] }),
[buildRule({ channel: 'manual' })],
)
assert.equal(decision.channel, 'third_party_voucher')
assert.equal(decision.source, 'auto_mapping')
})
test('渠道判定:内部 SKU 同级多渠道判为冲突', () => {
test('渠道判定:统一配置同优先级多渠道判为冲突', () => {
const decision = resolveChannelDecision(
buildItem({
channels: [buildChannel({ channel: 'work_order' }), buildChannel({ channel: 'manual' })],
channels: [
buildChannel({ channel: 'work_order' }),
buildChannel({ id: 2, channel: 'manual' }),
],
}),
[],
)
assert.equal(decision.channel, null)
assert.equal(decision.conflict, true)
assert.deepEqual(decision.conflictChannels.sort(), ['manual', 'work_order'])
})
test('渠道判定:人工任务作为人工载体证据', () => {
const decision = resolveChannelDecision(buildItem({ has_manual_task: true }), [])
assert.equal(decision.channel, 'manual')
assert.equal(decision.source, 'carrier_evidence')
test('渠道判定:卡券能力可以直接落到财务渠道', () => {
const decision = resolveChannelDecision(
buildItem({ channels: [buildChannel({ channel: 'third_party_voucher', config_json: {} })] }),
)
assert.equal(decision.channel, 'third_party_voucher')
assert.equal(decision.source, 'auto_mapping')
})
test('渠道判定:人工能力只有配置存在时才算载体就绪', () => {
const ready = resolveChannelDecision(buildItem({ channels: [buildChannel()] }))
assert.equal(ready.channel, 'manual')
const missing = resolveChannelDecision(
buildItem({ channels: [buildChannel({ config_json: {} })] }),
)
assert.equal(missing.channel, null)
assert.equal(missing.pendingReason, 'no_carrier')
})
test('渠道判定:自动发货/接单能力没有真实载体时继续保持 pending', () => {
const affiliate = resolveChannelDecision(
buildItem({ channels: [buildChannel({ channel: 'affiliate_dash', config_json: {} })] }),
)
assert.equal(affiliate.channel, null)
assert.equal(affiliate.pendingReason, 'no_carrier')
const workOrder = resolveChannelDecision(
buildItem({ channels: [buildChannel({ channel: 'work_order', config_json: {} })] }),
)
assert.equal(workOrder.channel, null)
assert.equal(workOrder.pendingReason, 'no_carrier')
})
@@ -14,13 +14,11 @@ import { orderItemBasisDates, refreshFactsForDates } from '../finance/refresh-fa
import {
FULFILLMENT_CHANNELS,
INACTIVE_CARRIER_TASK_STATUSES,
MATCH_KEY_TYPES,
RULE_SOURCES,
isFulfillmentChannel,
isRuleSource,
type FulfillmentChannel,
type FulfillmentSource,
type MatchKeyType,
type RuleSource,
} from './channel-types.js'
import {
@@ -33,6 +31,7 @@ import {
} from './internal-sku-channel-service.js'
import { ensureCarrierForOrderItem } from './carrier-creation-service.js'
import { TASK_STATUS } from '../../domain/task-status.js'
import { canonicalizeInternalSku, containsCjk } from './internal-sku-resolver.js'
/**
* 管道 A:渠道判定。
@@ -44,8 +43,7 @@ import { TASK_STATUS } from '../../domain/task-status.js'
* 1. 运营锁定 → 跳过
* 2. 已有且唯一的真实载体 → 直接按载体落渠道;多载体 → 冲突
* 3. internal_sku_channels:一个主渠道 + 显式兜底;多主渠道 → 冲突
* 4. 旧 sku_channel_rules 仅作兼容回退,且自动发货/接单仍必须有真实载体
* 5. 都不满足 → 保留 pending 并写明确原因
* 4. 没有统一配置或载体不满足 → 保留 pending 并写明确原因
*/
/** 数据库函数 `resolve_internal_sku` 是订单项内部 SKU 的唯一 SQL 口径。 */
@@ -54,13 +52,15 @@ const ACTIVE_CARRIER_TASK_SQL = `task.task_status NOT IN (${INACTIVE_CARRIER_TAS
export type ChannelRule = {
id: number
provider: string
shop_id: string
match_key_type: MatchKeyType
match_key_type: 'internal_sku'
match_key: string
channel: FulfillmentChannel
priority: number
enabled: boolean
is_fallback: boolean
source: string
config_json: Record<string, unknown>
note: string
}
export type ChannelRulePage = {
@@ -96,22 +96,6 @@ export type ChannelDecision = {
pendingReason: 'no_internal_sku' | 'no_channel_capability' | 'no_carrier' | ''
}
function normalizeKey(value: unknown): string {
return String(value ?? '').trim()
}
function matchesScope(rule: ChannelRule, item: ChannelCandidateItem): boolean {
if (rule.provider && rule.provider !== item.provider) return false
if (rule.shop_id && rule.shop_id !== item.shop_id) return false
return true
}
function itemKey(item: ChannelCandidateItem, keyType: MatchKeyType): string {
if (keyType === 'sku_code') return normalizeKey(item.sku_code)
if (keyType === 'internal_sku') return normalizeKey(item.internal_sku || item.manual_sku_key)
return normalizeKey(item.normalized_sku_name) || normalizeKey(item.sku_name).toLowerCase()
}
function resolvePendingReason(item: ChannelCandidateItem): {
reason: string
detail: Record<string, unknown>
@@ -155,10 +139,7 @@ function emptyDecision(pendingReason: ChannelDecision['pendingReason'] = '') {
}
/** 纯函数:给定规则与订单项,得出渠道决策(便于单测与文档对照)。 */
export function resolveChannelDecision(
item: ChannelCandidateItem,
rules: ChannelRule[],
): ChannelDecision {
export function resolveChannelDecision(item: ChannelCandidateItem): ChannelDecision {
const carrierChannels: FulfillmentChannel[] = [
...(item.has_work_order ? ['work_order' as const] : []),
...(item.has_affiliate_task ? ['affiliate_dash' as const] : []),
@@ -204,7 +185,21 @@ export function resolveChannelDecision(
}
if (plan.primary) {
if (plan.primary.channel === 'manual' || plan.primary.channel === 'third_party_voucher') {
if (plan.primary.channel === 'manual') {
if (!Number(plan.primary.config_json.manualConfigId || 0)) {
return emptyDecision('no_carrier')
}
return {
channel: plan.primary.channel,
source: 'auto_mapping',
conflict: false,
conflictChannels: [],
matchedRuleIds: [],
pendingChannel: plan.primary.channel,
pendingReason: '',
}
}
if (plan.primary.channel === 'third_party_voucher') {
return {
channel: plan.primary.channel,
source: 'auto_mapping',
@@ -217,44 +212,6 @@ export function resolveChannelDecision(
}
return emptyDecision('no_carrier')
}
const hits = rules.filter(
(rule) =>
rule.enabled &&
matchesScope(rule, item) &&
itemKey(item, rule.match_key_type) === normalizeKey(rule.match_key),
)
if (hits.length > 0) {
const minPriority = Math.min(...hits.map((rule) => rule.priority))
const best = hits.filter((rule) => rule.priority === minPriority)
const channels = [...new Set(best.map((rule) => rule.channel))]
if (channels.length > 1) {
return {
channel: null,
source: '',
conflict: true,
conflictChannels: channels,
matchedRuleIds: best.map((rule) => rule.id),
pendingChannel: null,
pendingReason: 'no_channel_capability',
}
}
const channel = channels[0]!
if (channel === 'affiliate_dash' || channel === 'work_order') {
return emptyDecision('no_carrier')
}
return {
channel,
source: 'auto_rule',
conflict: false,
conflictChannels: [],
matchedRuleIds: best.map((rule) => rule.id),
pendingChannel: channel,
pendingReason: '',
}
}
return emptyDecision('no_channel_capability')
}
@@ -287,7 +244,6 @@ export async function assignPendingOrderItemChannels(
: await readWatermarkItemId('channel_assign')
let reachedEnd = false
const rules = await loadEnabledRules()
const result: AssignChannelsResult = {
scanned: 0,
assigned: 0,
@@ -362,9 +318,9 @@ export async function assignPendingOrderItemChannels(
for (const row of rows.rows) {
const item: ChannelCandidateItem = {
...row,
channels: channelsByInternalSku.get(normalizeKey(row.internal_sku)) || [],
channels: channelsByInternalSku.get(canonicalizeInternalSku(row.internal_sku)) || [],
}
const decision = resolveChannelDecision(item, rules)
const decision = resolveChannelDecision(item)
if (decision.channel) affectedDates.add(item.basis_date)
if (decision.conflict) {
result.conflicted += 1
@@ -384,7 +340,7 @@ export async function assignPendingOrderItemChannels(
)
await upsertAggregatedException({
reason: 'routing_conflict',
scopeKey: `routing_conflict|${item.provider}|${item.shop_id}|${item.sku_code}|-`,
scopeKey: `routing_conflict|-|-|${item.internal_sku}|-`,
orderItemId: item.id,
detail: { channels: decision.conflictChannels, ruleIds: decision.matchedRuleIds },
})
@@ -630,6 +586,23 @@ export async function switchChannelForOrderItems(input: {
throw createHttpError('请选择订单项', { statusCode: 400, errorCode: 'order_item_required' })
}
const skuRows = await query<{ internal_sku: string }>(
`SELECT DISTINCT resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku
FROM order_items oi
WHERE oi.id = ANY($1::bigint[])`,
[ids],
)
const switchInternalSkus = skuRows.rows
.map((row) => canonicalizeInternalSku(row.internal_sku))
.filter(Boolean)
if (switchInternalSkus.length !== 1) {
throw createHttpError('切换渠道必须基于同一个中文内部 SKU', {
statusCode: 400,
errorCode:
switchInternalSkus.length === 0 ? 'internal_sku_required' : 'multiple_internal_skus',
})
}
const items = (
await query<OrderItemRow>(`SELECT * FROM order_items WHERE id = ANY($1::bigint[])`, [ids])
).rows
@@ -784,6 +757,19 @@ export async function switchChannelForOrderItems(input: {
WHERE id = ANY($1::bigint[])`,
[readyIds, input.adminUserId],
)
if (assigned.updated > 0) {
const internalSku = switchInternalSkus[0]!
const carrier = await resolveInternalSkuCarrierConfig(internalSku, input.channel)
await setInternalSkuPrimaryChannel({
internalSku,
channel: input.channel,
canonicalProductId: carrier.canonicalProductId,
configJson: carrier.configJson,
source: 'operator',
note: input.note,
adminUserId: input.adminUserId,
})
}
return {
switched: assigned.updated,
failed: errors.length + assigned.carrierFailed,
@@ -807,21 +793,34 @@ export async function assignChannelToOrderItems(input: {
if (ids.length === 0) {
throw createHttpError('请选择订单项', { statusCode: 400, errorCode: 'order_item_required' })
}
const result = await assignOrderItemsAfterCarrierReady({
orderItemIds: ids,
const rows = await query<{ internal_sku: string }>(
`SELECT resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku
FROM order_items oi
WHERE oi.id = ANY($1::bigint[])`,
[ids],
)
const internalSkus = [
...new Set(rows.rows.map((row) => canonicalizeInternalSku(row.internal_sku)).filter(Boolean)),
]
if (internalSkus.length === 0) {
throw createHttpError('所选订单项没有中文内部 SKU', {
statusCode: 400,
errorCode: 'internal_sku_required',
})
}
if (internalSkus.length > 1) {
throw createHttpError('所选订单项包含多个内部 SKU,请按内部 SKU 指定渠道', {
statusCode: 400,
errorCode: 'multiple_internal_skus',
})
}
const result = await assignChannelByInternalSku({
internalSku: internalSkus[0]!,
channel: input.channel,
source: 'operator_assigned',
lock: true,
adminUserId: input.adminUserId,
note: input.note,
})
await query(
`UPDATE order_items
SET fulfillment_decided_by = $2, fulfillment_decided_at = NOW()
WHERE id = ANY($1::bigint[])`,
[ids, input.adminUserId],
)
return result
return { updated: result.updated, carrierFailed: result.carrierFailed }
}
/** 运营解锁:允许自动判定重新接管。 */
@@ -836,10 +835,10 @@ export async function unlockChannel(orderItemIds: number[]): Promise<{ updated:
}
export type PendingGroupRow = {
provider: string
shop_id: string
internal_sku: string
sku_name: string
providers: string[]
shop_ids: string[]
sku_codes: string[]
pending_reasons: string[]
item_count: number
@@ -848,7 +847,7 @@ export type PendingGroupRow = {
last_seen_at: string
}
/** 待分类队列:按内部 SKU 聚合(同一中文 SKU 的不同平台码合并为一行)。 */
/** 待分类队列:按全局内部 SKU 聚合(跨平台、跨店铺合并为一行)。 */
export async function listPendingChannelGroups(input: {
page?: number
pageSize?: number
@@ -862,7 +861,7 @@ export async function listPendingChannelGroups(input: {
if (keyword) {
params.push(`%${keyword}%`)
clauses.push(
`(oi.sku_code ILIKE $${params.length} OR oi.sku_name ILIKE $${params.length} OR oi.manual_sku_key ILIKE $${params.length})`,
`(${INTERNAL_SKU_SQL} ILIKE $${params.length} OR oi.sku_code ILIKE $${params.length} OR oi.sku_name ILIKE $${params.length} OR oi.manual_sku_key ILIKE $${params.length})`,
)
}
const where = clauses.join(' AND ')
@@ -874,7 +873,7 @@ export async function listPendingChannelGroups(input: {
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE ${where}
GROUP BY o.provider, o.shop_id, ${INTERNAL_SKU_SQL}
GROUP BY ${INTERNAL_SKU_SQL}
) grouped
`,
params,
@@ -884,10 +883,10 @@ export async function listPendingChannelGroups(input: {
const rows = await query<PendingGroupRow>(
`
SELECT
o.provider,
o.shop_id,
${INTERNAL_SKU_SQL} AS internal_sku,
MIN(oi.sku_name) AS sku_name,
array_agg(DISTINCT o.provider) AS providers,
array_agg(DISTINCT o.shop_id) AS shop_ids,
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,
@@ -897,7 +896,7 @@ export async function listPendingChannelGroups(input: {
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE ${where}
GROUP BY o.provider, o.shop_id, ${INTERNAL_SKU_SQL}
GROUP BY ${INTERNAL_SKU_SQL}
ORDER BY item_count DESC, internal_sku
LIMIT $${params.length - 1} OFFSET $${params.length}
`,
@@ -909,8 +908,6 @@ export async function listPendingChannelGroups(input: {
/** 按内部 SKU 批量指定(同一中文 SKU 跨平台码一起分流)。 */
export async function assignChannelByInternalSku(input: {
provider: string
shopId: string
internalSku: string
channel: string
adminUserId: number | null
@@ -920,9 +917,9 @@ export async function assignChannelByInternalSku(input: {
if (!isFulfillmentChannel(input.channel)) {
throw createHttpError('渠道无效', { statusCode: 400, errorCode: 'invalid_channel' })
}
const internalSku = String(input.internalSku || '').trim()
if (!internalSku) {
throw createHttpError('内部 SKU 不能为空', {
const internalSku = canonicalizeInternalSku(input.internalSku)
if (!internalSku || !containsCjk(internalSku)) {
throw createHttpError('必须使用包含中文的内部 SKU', {
statusCode: 400,
errorCode: 'internal_sku_required',
})
@@ -940,29 +937,25 @@ export async function assignChannelByInternalSku(input: {
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
WHERE o.id = oi.order_id
AND o.provider = $1
AND o.shop_id = $2
AND ${INTERNAL_SKU_SQL} = $3
AND ${INTERNAL_SKU_SQL} = $1
AND (
oi.fulfillment_status = 'pending'
OR (oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = $4)
OR (oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = $2)
)
`,
[input.provider, input.shopId, internalSku, input.channel],
[internalSku, input.channel],
)
const product = await query<{ id: number | null }>(
`SELECT MIN(id) AS id
FROM product_catalog
WHERE status = 'active' AND btrim(canonical_name) = $1`,
[internalSku],
)
const carrier = await resolveInternalSkuCarrierConfig(internalSku, input.channel)
await setInternalSkuPrimaryChannel({
internalSku,
channel: input.channel,
canonicalProductId: product.rows[0]?.id == null ? null : Number(product.rows[0].id),
canonicalProductId: carrier.canonicalProductId,
source: 'operator',
configJson: { note: String(input.note || '').slice(0, 200) },
configJson: {
...carrier.configJson,
note: String(input.note || '').slice(0, 200),
},
note: input.note,
adminUserId: input.adminUserId,
})
@@ -987,7 +980,7 @@ export async function assignChannelByInternalSku(input: {
return { ...applied, ruleCreated }
}
/** 按 SKU 批量指定(待分类队列的主操作)。 */
/** 按平台 SKU 定位内部 SKU 后全局指定渠道。 */
export async function assignChannelBySku(input: {
provider: string
shopId: string
@@ -1018,7 +1011,9 @@ export async function assignChannelBySku(input: {
)
const internalSkus = [
...new Set(idsResult.rows.map((row) => String(row.internal_sku || '').trim()).filter(Boolean)),
...new Set(
idsResult.rows.map((row) => canonicalizeInternalSku(row.internal_sku)).filter(Boolean),
),
]
if (internalSkus.length === 0) {
return { updated: 0, ruleCreated: false, carrierFailed: 0 }
@@ -1030,8 +1025,6 @@ export async function assignChannelBySku(input: {
})
}
return assignChannelByInternalSku({
provider: input.provider,
shopId: input.shopId,
internalSku: internalSkus[0]!,
channel: input.channel,
adminUserId: input.adminUserId,
@@ -1062,9 +1055,10 @@ function buildChannelRuleFilter(input: ChannelRuleFilterInput) {
const CHANNEL_RULE_FROM_SQL = `
FROM (
SELECT id, ''::text AS provider, ''::text AS shop_id,
'internal_sku'::text AS match_key_type, internal_sku AS match_key,
channel, priority, enabled
SELECT id,
'internal_sku'::text AS match_key_type,
canonicalize_internal_sku(internal_sku) AS match_key,
channel, priority, enabled, is_fallback, source, config_json, note
FROM internal_sku_channels
) internal_rules
`
@@ -1081,7 +1075,8 @@ export async function listChannelRules(input: ChannelRuleFilterInput = {}): Prom
const { where, params } = buildChannelRuleFilter(input)
const rows = await query<ChannelRule>(
`
SELECT id, provider, shop_id, match_key_type, match_key, channel, priority, enabled
SELECT id, match_key_type, match_key, channel, priority, enabled,
is_fallback, source, config_json, note
${CHANNEL_RULE_FROM_SQL}
${where}
ORDER BY priority, channel, match_key, id
@@ -1106,7 +1101,8 @@ export async function listChannelRulesPage(
),
query<ChannelRule>(
`
SELECT id, provider, shop_id, match_key_type, match_key, channel, priority, enabled
SELECT id, match_key_type, match_key, channel, priority, enabled,
is_fallback, source, config_json, note
${CHANNEL_RULE_FROM_SQL}
${where}
ORDER BY priority, channel, match_key, id
@@ -1123,6 +1119,56 @@ export async function listChannelRulesPage(
}
}
async function resolveInternalSkuCarrierConfig(
internalSku: string,
channel: string,
): Promise<{ canonicalProductId: number | null; configJson: Record<string, unknown> }> {
const key = canonicalizeInternalSku(internalSku)
if (!key) return { canonicalProductId: null, configJson: {} }
if (channel === 'manual') {
const manual = await query<{ id: number; canonical_product_id: number | null }>(
`SELECT id, canonical_product_id
FROM manual_fulfillment_configs
WHERE canonicalize_internal_sku(sku_key) = $1 AND enabled = TRUE
ORDER BY id
LIMIT 1`,
[key],
)
return {
canonicalProductId: Number(manual.rows[0]?.canonical_product_id || 0) || null,
configJson: manual.rows[0] ? { manualConfigId: Number(manual.rows[0].id) } : {},
}
}
const prefixes =
channel === 'affiliate_dash' ? ['aff:'] : channel === 'work_order' ? ['tpl:', 'wo:'] : ['tpv:']
const product = await query<{ id: number; canonical_sku: string }>(
`SELECT id, canonical_sku
FROM product_catalog
WHERE status = 'active'
AND (canonical_sku LIKE $2 OR canonical_sku LIKE $3)
AND canonicalize_internal_sku(canonical_name) = $1
ORDER BY id
LIMIT 1`,
[key, `${prefixes[0]}%`, `${prefixes[1] || prefixes[0]}%`],
)
const row = product.rows[0]
if (!row) return { canonicalProductId: null, configJson: {} }
const rawSku = String(row.canonical_sku)
const matchedPrefix = prefixes.find((prefix) => rawSku.startsWith(prefix)) || prefixes[0]!
const carrierKey = rawSku.slice(matchedPrefix.length)
return {
canonicalProductId: Number(row.id),
configJson:
channel === 'affiliate_dash'
? { affiliateSku: carrierKey }
: channel === 'work_order'
? { workRuleKey: carrierKey }
: { voucherSku: carrierKey },
}
}
export async function upsertChannelRule(input: {
id?: number
matchKeyType: string
@@ -1144,12 +1190,16 @@ export async function upsertChannelRule(input: {
throw createHttpError('渠道无效', { statusCode: 400, errorCode: 'invalid_channel' })
}
const source: RuleSource = isRuleSource(input.source) ? input.source : 'operator'
const matchKey = String(input.matchKey || '').trim()
if (!matchKey) {
throw createHttpError('匹配键不能为空', { statusCode: 400, errorCode: 'match_key_required' })
const matchKey = canonicalizeInternalSku(input.matchKey)
if (!matchKey || !containsCjk(matchKey)) {
throw createHttpError('匹配键必须是包含中文的内部 SKU', {
statusCode: 400,
errorCode: 'internal_sku_required',
})
}
const priority = Math.trunc(Number(input.priority) || 100)
const carrier = await resolveInternalSkuCarrierConfig(matchKey, input.channel)
const id = await upsertInternalSkuChannel({
internalSku: matchKey,
channel: input.channel,
@@ -1160,6 +1210,8 @@ export async function upsertChannelRule(input: {
note: String(input.note || '').slice(0, 300),
adminUserId: input.adminUserId,
replacePriority: true,
canonicalProductId: carrier.canonicalProductId,
configJson: carrier.configJson,
})
return { id }
}
@@ -1169,6 +1221,7 @@ export async function deleteChannelRule(ruleId: number): Promise<{ deleted: bool
if (id <= 0) {
throw createHttpError('规则 id 无效', { statusCode: 400, errorCode: 'invalid_rule_id' })
}
await assertRulesDeletable([id])
return { deleted: await deleteInternalSkuChannelById(id) }
}
@@ -1183,29 +1236,34 @@ export async function deleteChannelRules(idsInput: number[]): Promise<{
errorCode: 'channel_rule_ids_required',
})
}
await assertRulesDeletable(idsInput)
const result = await deleteInternalSkuChannelsByIds(idsInput)
return { deletedCount: result.deletedIds.length, ...result }
}
async function loadEnabledRules(): Promise<ChannelRule[]> {
const rows = await query<ChannelRule>(
`
SELECT id, provider, shop_id, match_key_type, match_key, channel, priority, enabled
FROM sku_channel_rules
WHERE enabled = TRUE
`,
async function assertRulesDeletable(idsInput: number[]): Promise<void> {
const ids = [...new Set(idsInput.map(Number).filter((id) => Number.isInteger(id) && id > 0))]
if (ids.length === 0) return
const rows = await query<{ id: number; source: string; match_key: string; channel: string }>(
`SELECT id, source, canonicalize_internal_sku(internal_sku) AS match_key, channel
FROM internal_sku_channels WHERE id = ANY($1::bigint[])`,
[ids],
)
return rows.rows.map((row) => ({
...row,
id: Number(row.id),
priority: Number(row.priority),
}))
const protectedRow = rows.rows.find(
(row) => row.source !== 'operator' && !row.source.startsWith('rule:'),
)
if (protectedRow) {
throw createHttpError(
`「${protectedRow.match_key}」的 ${protectedRow.channel} 配置由 ${protectedRow.source} 生成,请到对应配置页停用,不能在渠道页直接删除`,
{ statusCode: 409, errorCode: 'system_channel_rule_protected' },
)
}
}
async function loadInternalSkuChannelsForKeys(
keys: string[],
): Promise<Map<string, InternalSkuChannelRow[]>> {
const normalized = [...new Set(keys.map(normalizeKey).filter(Boolean))]
const normalized = [...new Set(keys.map(canonicalizeInternalSku).filter(Boolean))]
const result = new Map<string, InternalSkuChannelRow[]>()
if (normalized.length === 0) return result
@@ -1214,13 +1272,13 @@ async function loadInternalSkuChannelsForKeys(
SELECT id, internal_sku, channel, priority, is_fallback, enabled,
canonical_product_id, config_json, source, note, created_by_admin_user_id
FROM internal_sku_channels
WHERE enabled = TRUE AND btrim(internal_sku) = ANY($1::text[])
ORDER BY internal_sku, priority, channel
WHERE enabled = TRUE AND canonicalize_internal_sku(internal_sku) = ANY($1::text[])
ORDER BY canonicalize_internal_sku(internal_sku), priority, channel, id
`,
[normalized],
)
for (const row of rows.rows) {
const key = normalizeKey(row.internal_sku)
const key = canonicalizeInternalSku(row.internal_sku)
const list = result.get(key) || []
list.push(row)
result.set(key, list)
@@ -1228,13 +1286,13 @@ async function loadInternalSkuChannelsForKeys(
return result
}
export const CHANNEL_RULE_KEY_TYPES = MATCH_KEY_TYPES
export const CHANNEL_RULE_KEY_TYPES = ['internal_sku'] as const
export const CHANNEL_RULE_SOURCES = RULE_SOURCES
export const CHANNEL_VALUES = FULFILLMENT_CHANNELS
/** 为单个卡券 SKU 建规则(商品创建时调用,幂等)。 */
export async function ensureVoucherRuleForSku(skuCode: string): Promise<boolean> {
const key = String(skuCode || '').trim()
const key = canonicalizeInternalSku(skuCode)
if (!key) return false
const product = await query<{ id: number }>(
`SELECT id FROM product_catalog
@@ -1273,7 +1331,7 @@ export async function ensureVoucherChannelRules(): Promise<{
FROM product_catalog product
WHERE product.status = 'active'
AND product.canonical_sku LIKE 'tpv:%'
AND BTRIM(channel.internal_sku) = BTRIM(product.canonical_name)
AND canonicalize_internal_sku(channel.internal_sku) = canonicalize_internal_sku(product.canonical_name)
AND channel.channel <> 'third_party_voucher'
AND channel.enabled = TRUE
AND channel.is_fallback = FALSE`,
@@ -1285,7 +1343,7 @@ export async function ensureVoucherChannelRules(): Promise<{
canonical_product_id, config_json, source, created_at, updated_at
)
SELECT
BTRIM(p.canonical_name), 'third_party_voucher', 10, FALSE, TRUE,
canonicalize_internal_sku(p.canonical_name), 'third_party_voucher', 10, FALSE, TRUE,
p.id, jsonb_build_object('voucherSku', substring(p.canonical_sku from 5)),
'voucher', NOW(), NOW()
FROM product_catalog p
@@ -24,6 +24,7 @@ test(
ensureVoucherRuleForSku,
listChannelRules,
listChannelRulesPage,
listPendingChannelGroups,
switchChannelForOrderItems,
upsertChannelRule,
} = await import('./channel-assign-service.js')
@@ -32,20 +33,28 @@ test(
const manualSku = `集成测试人工-${suffix}`
const missingAffiliateSku = `集成测试自动-${suffix}`
const voucherSku = `集成测试卡券-${suffix}`
const triggerSku = `集成测试规范-${suffix}`
const orderIds: number[] = []
const orderItemIds: number[] = []
const internalSkus = [manualSku, missingAffiliateSku, voucherSku]
const crossShopSku = `集成测试跨店-${suffix}`
const internalSkus = [manualSku, missingAffiliateSku, voucherSku, crossShopSku, triggerSku]
let generatedVoucherProductId: number | null = null
async function createPendingOrderItem(sku: string) {
async function createPendingOrderItem(
sku: string,
options: { provider?: string; platform?: string; shopId?: string } = {},
) {
const provider = options.provider || '91kaquan'
const platform = options.platform || 'kuaishou-91'
const targetShopId = options.shopId || shopId
const order = await query<{ id: number }>(
`INSERT INTO orders (
provider, platform, shop_id, platform_order_id, order_status, pay_status,
total_amount, paid_at, created_at, updated_at
) VALUES (
'91kaquan', 'kuaishou-91', $1, $2, 'paid', 'paid', 1000, NOW(), NOW(), NOW()
$1, $2, $3, $4, 'paid', 'paid', 1000, NOW(), NOW(), NOW()
) RETURNING id`,
[shopId, `IT-ROUTING-${suffix}-${orderItemIds.length + 1}`],
[provider, platform, targetShopId, `IT-ROUTING-${suffix}-${orderItemIds.length + 1}`],
)
const orderId = Number(order.rows[0]!.id)
orderIds.push(orderId)
@@ -63,6 +72,27 @@ test(
}
try {
const canonicalResolution = await query<{
internal_sku: string | null
platform_only: string | null
}>(
`SELECT
resolve_internal_sku('platform-code', ' 套装-浪漫 天命 ', 'platform-code') AS internal_sku,
resolve_internal_sku('platform-code', 'Platform Title', 'platform-code') AS platform_only`,
)
assert.equal(canonicalResolution.rows[0]?.internal_sku, '套装-浪漫天命')
assert.equal(canonicalResolution.rows[0]?.platform_only, null)
await query(
`INSERT INTO internal_sku_channels (internal_sku, channel, priority, is_fallback, enabled, source)
VALUES ($1, 'manual', 90, TRUE, TRUE, 'integration_test')`,
[` 集成测试 规范-${suffix} `],
)
const normalizedInsert = await query<{ internal_sku: string }>(
`SELECT internal_sku FROM internal_sku_channels WHERE source = 'integration_test'`,
)
assert.equal(normalizedInsert.rows[0]?.internal_sku, triggerSku)
const manualItemId = await createPendingOrderItem(manualSku)
await query(
`INSERT INTO manual_fulfillment_configs (
@@ -72,8 +102,6 @@ test(
)
const manualAssigned = await assignChannelByInternalSku({
provider: '91kaquan',
shopId,
internalSku: manualSku,
channel: 'manual',
adminUserId: null,
@@ -124,8 +152,6 @@ test(
const missingAffiliateItemId = await createPendingOrderItem(missingAffiliateSku)
const affiliateFailed = await assignChannelByInternalSku({
provider: '91kaquan',
shopId,
internalSku: missingAffiliateSku,
channel: 'affiliate_dash',
adminUserId: null,
@@ -146,6 +172,25 @@ test(
assert.equal(pendingItem.rows[0]?.fulfillment_channel, null)
assert.equal(pendingItem.rows[0]?.fulfillment_pending_reason, 'no_carrier')
const crossShopA = `it-routing-a-${suffix}`
const crossShopB = `it-routing-b-${suffix}`
await createPendingOrderItem(crossShopSku, { shopId: crossShopA })
await createPendingOrderItem(crossShopSku, {
provider: 'pdd',
platform: 'pdd',
shopId: crossShopB,
})
const pendingGroups = await listPendingChannelGroups({
keyword: crossShopSku,
page: 1,
pageSize: 10,
})
assert.equal(pendingGroups.total, 1)
assert.equal(pendingGroups.items[0]?.internal_sku, crossShopSku)
assert.equal(pendingGroups.items[0]?.item_count, 2)
assert.deepEqual(pendingGroups.items[0]?.providers.sort(), ['91kaquan', 'pdd'])
assert.deepEqual(pendingGroups.items[0]?.shop_ids.sort(), [crossShopA, crossShopB])
await ensureVoucherRuleForSku(voucherSku)
const voucherChannel = await query<{ channel: string; source: string }>(
`SELECT channel, source FROM internal_sku_channels
@@ -0,0 +1,148 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { applyIntakeChannelPlan } from './intake-channel-decision-service.js'
import type {
InternalSkuChannelPlan,
InternalSkuChannelRow,
} from './internal-sku-channel-service.js'
import type { ResolvedFulfillmentItem } from './product-resolution-service.js'
function buildItem(): ResolvedFulfillmentItem {
return {
itemId: '1',
externalItemId: '1',
externalSkuCode: 'platform-code',
externalSkuName: '幸运币2个',
skuCode: 'platform-code',
skuName: '幸运币2个',
spec: { skuNick: '幸运币2个' },
snapshot: { internalSku: '幸运币2个' },
isConfigured: false,
}
}
function channel(patch: Partial<InternalSkuChannelRow> = {}): InternalSkuChannelRow {
return {
id: 1,
internal_sku: '幸运币2个',
channel: 'affiliate_dash',
priority: 10,
is_fallback: false,
enabled: true,
canonical_product_id: null,
config_json: {},
source: 'operator',
note: '',
created_by_admin_user_id: null,
...patch,
}
}
function plan(primary: InternalSkuChannelRow | null): InternalSkuChannelPlan {
return {
primary,
fallbacks: [],
attempts: primary ? [primary] : [],
conflicts: [],
}
}
test('下单决策:自动发货只从统一 SKU 配置生成任务路由', () => {
const item = applyIntakeChannelPlan(
buildItem(),
plan(channel({ config_json: { affiliateSku: 'lucky_coin_x2' } })),
)
assert.equal(item.isConfigured, true)
assert.equal(item.skuCode, 'lucky_coin_x2')
assert.equal(item.snapshot.internalSkuChannel, 'affiliate_dash')
assert.deepEqual(item.snapshot.fulfillmentRoute, {
selectedExecutorKey: 'affiliate_dash',
selectedRuleId: '',
reason: '内部 SKU 渠道配置指定自动发货',
matchedRule: null,
candidates: [],
skipped: [],
})
assert.deepEqual(item.snapshot.affiliateDash, {
matchMode: 'affiliate_dash_sku_mapping',
productNo: '幸运币2个',
sku: 'lucky_coin_x2',
skuName: '幸运币2个',
})
})
test('下单决策:自动发货缺载体参数时不建任何任务', () => {
const item = applyIntakeChannelPlan(
{
...buildItem(),
skuName: '不存在商品',
snapshot: { internalSku: '不存在商品' },
spec: { skuNick: '不存在商品' },
},
plan(channel({ internal_sku: '不存在商品' })),
)
assert.equal(item.isConfigured, false)
assert.equal(item.snapshot.internalSkuChannelReason, 'missing_affiliate_dash_mapping')
})
test('下单决策:人工渠道只有配置存在时才建人工任务', () => {
const ready = applyIntakeChannelPlan(
buildItem(),
plan(channel({ channel: 'manual', config_json: { manualConfigId: 9 } })),
)
assert.equal(ready.isConfigured, true)
assert.equal(
(ready.snapshot.fulfillmentRoute as Record<string, unknown>).selectedExecutorKey,
'manual_dispatch',
)
const missing = applyIntakeChannelPlan(
buildItem(),
plan(channel({ channel: 'manual', config_json: {} })),
)
assert.equal(missing.isConfigured, false)
assert.equal(missing.snapshot.internalSkuChannelReason, 'manual_config_missing')
})
test('下单决策:接单平台和卡券不提前建任务', () => {
for (const targetChannel of ['work_order', 'third_party_voucher'] as const) {
const item = applyIntakeChannelPlan(
buildItem(),
plan(channel({ channel: targetChannel, config_json: {} })),
)
assert.equal(item.isConfigured, false)
assert.equal(item.snapshot.internalSkuChannel, targetChannel)
assert.equal(
(item.snapshot.fulfillmentRoute as Record<string, unknown>).selectedExecutorKey,
'',
)
assert.deepEqual(item.snapshot.classification, {
channel: targetChannel,
canonicalSku: '幸运币2个',
})
}
})
test('下单决策:冲突或无配置时清空旧路由,不能由旧逻辑抢占', () => {
const base = buildItem()
base.snapshot = {
...base.snapshot,
fulfillmentRoute: { selectedExecutorKey: 'affiliate_dash' },
affiliateDash: { sku: 'legacy' },
}
const conflict = applyIntakeChannelPlan(base, {
primary: null,
fallbacks: [],
attempts: [],
conflicts: [channel()],
})
assert.equal(conflict.isConfigured, false)
assert.equal(conflict.snapshot.internalSkuChannelReason, 'routing_conflict')
assert.equal(
(conflict.snapshot.fulfillmentRoute as Record<string, unknown>).selectedExecutorKey,
'',
)
assert.equal(conflict.snapshot.affiliateDash, null)
})
@@ -1,4 +1,9 @@
import { listInternalSkuChannels, planInternalSkuChannels } from './internal-sku-channel-service.js'
import {
listInternalSkuChannels,
planInternalSkuChannels,
type InternalSkuChannelPlan,
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'
@@ -11,140 +16,227 @@ type IntakeChannelDecisionInput = {
shopId?: string
}
type IntakeChannelDecisionOptions = {
preferredChannel?: FulfillmentChannel
}
function parseObject(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
function emptyRoute(reason: string) {
return {
selectedExecutorKey: '',
selectedRuleId: '',
reason,
matchedRule: null,
candidates: [],
skipped: [],
}
}
function classification(channel: FulfillmentChannel | 'unmatched', internalSku: string) {
return { channel, canonicalSku: internalSku }
}
function withChannelSnapshot(input: {
item: ResolvedFulfillmentItem
internalSku: string
channel: FulfillmentChannel | 'unmatched'
source?: string
reason?: string
route?: Record<string, unknown>
affiliateDash?: Record<string, unknown> | null
configured: boolean
skuCode?: string
}): ResolvedFulfillmentItem {
const snapshot = parseObject(input.item.snapshot)
return {
...input.item,
...(input.skuCode ? { skuCode: input.skuCode } : {}),
spec: {
...(input.item.spec && typeof input.item.spec === 'object' ? input.item.spec : {}),
skuNick: input.internalSku,
},
isConfigured: input.configured,
snapshot: {
...snapshot,
internalSku: input.internalSku,
...(input.source ? { internalSkuChannelSource: input.source } : {}),
...(input.channel !== 'unmatched' ? { internalSkuChannel: input.channel } : {}),
...(input.reason ? { internalSkuChannelReason: input.reason } : {}),
fulfillmentRoute: input.route || emptyRoute(input.reason || ''),
affiliateDash: input.affiliateDash ?? null,
classification: classification(input.channel, input.internalSku),
},
}
}
/**
* 下单入口读取内部 SKU 渠道决策。
*
* 目前只补“自动发货”载体:若内部 SKU 能力是 affiliate_dash 且能解析出 dash sku,
* 直接写 fulfillmentRoute + affiliateDash,让 delivery-task-service 建任务。
* 接单平台仍由 work_order 匹配器创建;卡券/人工无任务,由财务侧兜底。
* 纯函数:把内部 SKU 渠道计划应用到订单项。
* 渠道选择只允许来自 internal_sku_channels,旧路由不能抢占。
*/
export function applyIntakeChannelPlan(
item: ResolvedFulfillmentItem,
plan: InternalSkuChannelPlan,
options: IntakeChannelDecisionOptions = {},
): ResolvedFulfillmentItem {
const internalSku = resolveInternalSkuKey(item)
if (!internalSku) {
return withChannelSnapshot({
item,
internalSku: '',
channel: 'unmatched',
reason: 'no_internal_sku',
configured: false,
})
}
if (plan.conflicts.length > 0) {
return withChannelSnapshot({
item,
internalSku,
channel: 'unmatched',
reason: 'routing_conflict',
configured: false,
})
}
const preferred = options.preferredChannel
? plan.attempts.find((candidate) => candidate.channel === options.preferredChannel)
: undefined
const primary = preferred || plan.primary
if (!primary) {
return withChannelSnapshot({
item,
internalSku,
channel: 'unmatched',
reason: 'no_channel_capability',
configured: false,
})
}
const channel = primary.channel
const carrierParams = parseObject(primary.config_json)
if (channel === 'affiliate_dash') {
const dashSku =
String(carrierParams.affiliateSku || '').trim() ||
matchAffiliateDashSkuByInternalSku(internalSku)
if (!dashSku) {
return withChannelSnapshot({
item,
internalSku,
channel,
source: primary.source,
reason: 'missing_affiliate_dash_mapping',
configured: false,
})
}
return withChannelSnapshot({
item,
internalSku,
channel,
source: primary.source,
reason: '内部 SKU 渠道配置指定自动发货',
configured: true,
skuCode: dashSku,
route: {
selectedExecutorKey: 'affiliate_dash',
selectedRuleId: '',
reason: '内部 SKU 渠道配置指定自动发货',
matchedRule: null,
candidates: [],
skipped: [],
},
affiliateDash: {
matchMode: 'affiliate_dash_sku_mapping',
productNo: internalSku,
sku: dashSku,
skuName: internalSku,
},
})
}
if (channel === 'manual') {
if (!Number(primary.config_json.manualConfigId || 0)) {
return withChannelSnapshot({
item,
internalSku,
channel,
source: primary.source,
reason: 'manual_config_missing',
configured: false,
})
}
return withChannelSnapshot({
item,
internalSku,
channel,
source: primary.source,
reason: '内部 SKU 渠道配置指定人工客服',
configured: true,
route: {
selectedExecutorKey: 'manual_dispatch',
selectedRuleId: '',
reason: '内部 SKU 渠道配置指定人工客服',
matchedRule: null,
candidates: [],
skipped: [],
},
})
}
// 接单平台由发单入口读取同一份 internal_sku_channels 创建工单;
// 卡券无执行任务。两者都不能留下 affiliate/manual 路由。
return withChannelSnapshot({
item,
internalSku,
channel,
source: primary.source,
reason:
channel === 'work_order'
? '内部 SKU 渠道配置指定接单平台'
: '内部 SKU 渠道配置指定第三方卡券',
configured: false,
})
}
/** 下单入口读取唯一渠道源:internal_sku_channels。 */
export async function applyIntakeChannelDecision(
item: ResolvedFulfillmentItem,
_input: IntakeChannelDecisionInput,
options: { preferredChannel?: FulfillmentChannel } = {},
options: IntakeChannelDecisionOptions = {},
): Promise<ResolvedFulfillmentItem> {
const internalSku = resolveInternalSkuKey(item)
if (!internalSku) return item
if (!internalSku) {
return withChannelSnapshot({
item,
internalSku: '',
channel: 'unmatched',
reason: 'no_internal_sku',
configured: false,
})
}
let plan
let channels: InternalSkuChannelRow[]
try {
plan = planInternalSkuChannels(await listInternalSkuChannels(internalSku))
channels = await listInternalSkuChannels(internalSku)
} catch (error) {
// 单测/旧库没有 118 迁移时不影响下单主流程,生产由迁移保证。
logWarn('[intake-channel-decision]', '读取内部 SKU 渠道失败', {
internalSku,
error: error instanceof Error ? error.message : String(error),
})
return item
}
if (plan.conflicts.length > 0) {
return {
...item,
snapshot: {
...parseObject(item.snapshot),
internalSku,
internalSkuChannelReason: 'routing_conflict',
internalSkuConflictChannels: plan.conflicts.map((channel) => channel.channel),
},
}
return withChannelSnapshot({
item,
internalSku,
channel: 'unmatched',
reason: 'channel_config_unavailable',
configured: false,
})
}
const preferred = options.preferredChannel
? plan.attempts.find((candidate) => candidate.channel === options.preferredChannel) ||
(options.preferredChannel === 'manual' || options.preferredChannel === 'third_party_voucher'
? null
: undefined)
: undefined
const primary = preferred === null ? null : preferred || plan.primary
if (!primary) 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 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()
const configuredAffiliateSku = String(primary.config_json.affiliateSku || '').trim()
const desiredChannel = options.preferredChannel || primary.channel
if (desiredChannel === 'affiliate_dash' || selected === 'affiliate_dash') {
const dashSku =
existingAffiliateSku ||
configuredAffiliateSku ||
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 (
(desiredChannel === 'work_order' || desiredChannel === 'third_party_voucher') &&
isDefaultManualFallback
) {
nextSnapshot.fulfillmentRoute = {
...route,
selectedExecutorKey: '',
reason: `内部 SKU 渠道能力为 ${desiredChannel},不使用默认人工兜底`,
matchedRule: null,
}
return { ...item, isConfigured: false, snapshot: nextSnapshot }
}
if (desiredChannel === 'manual' && isDefaultManualFallback) {
nextSnapshot.fulfillmentRoute = {
...route,
selectedExecutorKey: 'manual_dispatch',
reason: '内部 SKU 渠道能力指定人工客服',
matchedRule: null,
}
return { ...item, isConfigured: true, snapshot: nextSnapshot }
}
return { ...item, snapshot: nextSnapshot }
return applyIntakeChannelPlan(item, planInternalSkuChannels(channels), options)
}
@@ -63,3 +63,13 @@ test('渠道计划:仅有一个兜底渠道时仍可作为实际主渠道', ()
['manual'],
)
})
test('渠道计划:历史规范化重复的同一渠道会去重,不误判为冲突', () => {
const plan = planInternalSkuChannels([
channel({ id: 1, channel: 'manual', priority: 90, is_fallback: true }),
channel({ id: 2, channel: 'manual', priority: 10 }),
])
assert.equal(plan.primary?.channel, 'manual')
assert.equal(plan.primary?.id, 2)
assert.deepEqual(plan.conflicts, [])
})
@@ -1,6 +1,6 @@
import { query, withTransaction } from '../../db/client.js'
import type { FulfillmentChannel } from './channel-types.js'
import { compareFulfillmentChannel } from './internal-sku-resolver.js'
import { canonicalizeInternalSku, compareFulfillmentChannel } from './internal-sku-resolver.js'
export type InternalSkuChannelRow = {
id: number
@@ -26,14 +26,14 @@ export type InternalSkuChannelPlan = {
export async function listInternalSkuChannels(
internalSku: string,
): Promise<InternalSkuChannelRow[]> {
const key = String(internalSku || '').trim()
const key = canonicalizeInternalSku(internalSku)
if (!key) return []
const result = await query<InternalSkuChannelRow>(
`
SELECT id, internal_sku, channel, priority, is_fallback, enabled, canonical_product_id,
config_json, source, note, created_by_admin_user_id
FROM internal_sku_channels
WHERE btrim(internal_sku) = $1 AND enabled = TRUE
WHERE canonicalize_internal_sku(internal_sku) = $1 AND enabled = TRUE
ORDER BY
priority ASC,
channel ASC
@@ -43,14 +43,58 @@ export async function listInternalSkuChannels(
return result.rows
}
/** 批量读取内部 SKU 渠道计划,供下单入口与发单入口共用。 */
export async function loadInternalSkuChannelPlans(
internalSkus: string[],
): Promise<Map<string, InternalSkuChannelPlan>> {
const keys = [...new Set(internalSkus.map(canonicalizeInternalSku).filter(Boolean))]
const result = new Map<string, InternalSkuChannelPlan>()
if (keys.length === 0) return result
const rows = await query<InternalSkuChannelRow>(
`
SELECT id, internal_sku, channel, priority, is_fallback, enabled,
canonical_product_id, config_json, source, note, created_by_admin_user_id
FROM internal_sku_channels
WHERE enabled = TRUE AND canonicalize_internal_sku(internal_sku) = ANY($1::text[])
ORDER BY canonicalize_internal_sku(internal_sku), priority, channel, id
`,
[keys],
)
const grouped = new Map<string, InternalSkuChannelRow[]>()
for (const row of rows.rows) {
const key = canonicalizeInternalSku(row.internal_sku)
const channels = grouped.get(key) || []
channels.push(row)
grouped.set(key, channels)
}
for (const [key, channels] of grouped) result.set(key, planInternalSkuChannels(channels))
return result
}
/**
* 渠道计划:同一时间只允许一个主渠道;同级多渠道视为配置冲突。
* is_fallback 渠道只在主渠道不可用或失败后按 priority 顺序尝试。
*/
export function planInternalSkuChannels(input: InternalSkuChannelRow[]): InternalSkuChannelPlan {
const channels = input
// 历史数据可能因空格/全半角差异残留同一渠道的多行。按规范化 SKU 读取后,
// 同一渠道只保留优先级最高的一行,避免把同一能力误判成冲突。
const byChannel = new Map<FulfillmentChannel, InternalSkuChannelRow>()
for (const row of input.filter((item) => item.enabled)) {
const current = byChannel.get(row.channel)
if (
!current ||
Number(row.is_fallback) < Number(current.is_fallback) ||
(row.is_fallback === current.is_fallback && row.priority < current.priority) ||
(row.is_fallback === current.is_fallback &&
row.priority === current.priority &&
row.id < current.id)
) {
byChannel.set(row.channel, row)
}
}
const channels = [...byChannel.values()]
.filter((row) => row.enabled)
.slice()
.sort(
(a, b) =>
a.priority - b.priority ||
@@ -96,7 +140,7 @@ export async function upsertInternalSkuChannel(input: {
adminUserId?: number | null | undefined
replacePriority?: boolean
}): Promise<number> {
const internalSku = String(input.internalSku || '').trim()
const internalSku = canonicalizeInternalSku(input.internalSku)
if (!internalSku) return 0
return withTransaction(async (client) => {
if (input.enabled !== false && input.isFallback !== true) {
@@ -105,7 +149,7 @@ export async function upsertInternalSkuChannel(input: {
SET is_fallback = TRUE,
priority = GREATEST(priority, 90),
updated_at = NOW()
WHERE btrim(internal_sku) = $1
WHERE canonicalize_internal_sku(internal_sku) = $1
AND channel <> $2
AND enabled = TRUE
AND is_fallback = FALSE`,
@@ -164,7 +208,7 @@ export async function setInternalSkuPrimaryChannel(input: {
note?: string | undefined
adminUserId?: number | null
}): Promise<void> {
const internalSku = String(input.internalSku || '').trim()
const internalSku = canonicalizeInternalSku(input.internalSku)
if (!internalSku) return
await upsertInternalSkuChannel({
@@ -190,9 +234,9 @@ export async function disableInternalSkuChannel(input: {
`
UPDATE internal_sku_channels
SET enabled = FALSE, updated_at = NOW()
WHERE btrim(internal_sku) = $1 AND channel = $2
WHERE canonicalize_internal_sku(internal_sku) = $1 AND channel = $2
`,
[String(input.internalSku || '').trim(), input.channel],
[canonicalizeInternalSku(input.internalSku), input.channel],
)
return result.rowCount || 0
}
@@ -1,5 +1,5 @@
import { query } from '../../db/client.js'
import { containsCjk } from './internal-sku-resolver.js'
import { query, withTransaction } from '../../db/client.js'
import { canonicalizeInternalSku, containsCjk } from './internal-sku-resolver.js'
export type InternalSkuMappingMatchType =
| 'sku_code'
@@ -20,9 +20,7 @@ export type ResolvedInternalSkuMapping = {
}
export function normalizeInternalSkuMappingValue(value: unknown): string {
return String(value ?? '')
.replace(/\s+/g, '')
.trim()
return canonicalizeInternalSku(value)
}
/**
@@ -51,7 +49,8 @@ export async function resolveInternalSkuMapping(input: {
SELECT m.id, m.internal_sku, m.source
FROM internal_sku_mappings m
JOIN unnest($3::text[], $4::text[]) AS c(match_type, match_value)
ON m.match_type = c.match_type AND m.match_value = c.match_value
ON m.match_type = c.match_type
AND canonicalize_internal_sku(m.match_value) = c.match_value
WHERE m.enabled = TRUE
AND (m.provider = '' OR m.provider = $1)
AND (m.shop_id = '' OR m.shop_id = $2)
@@ -70,7 +69,7 @@ export async function resolveInternalSkuMapping(input: {
if (!row) return null
return {
id: Number(row.id),
internalSku: String(row.internal_sku || '').trim(),
internalSku: canonicalizeInternalSku(row.internal_sku),
source: String(row.source || 'manual'),
}
}
@@ -85,40 +84,50 @@ export async function upsertInternalSkuMapping(input: {
source?: string
note?: string
}): Promise<number> {
const result = await query<{ id: number }>(
`
INSERT INTO internal_sku_mappings (
provider, shop_id, match_type, match_value, internal_sku,
priority, enabled, source, note, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, TRUE, $7, $8, NOW(), NOW())
ON CONFLICT (provider, shop_id, match_type, match_value)
DO UPDATE SET
internal_sku = EXCLUDED.internal_sku,
priority = EXCLUDED.priority,
enabled = TRUE,
source = EXCLUDED.source,
note = EXCLUDED.note,
updated_at = NOW()
RETURNING id
`,
[
String(input.provider || '').trim(),
String(input.shopId || '').trim(),
input.matchType,
normalizeInternalSkuMappingValue(input.matchValue),
String(input.internalSku || '').trim(),
Number.isFinite(Number(input.priority)) ? Number(input.priority) : 100,
String(input.source || 'manual').trim() || 'manual',
String(input.note || '').trim(),
],
)
return Number(result.rows[0]?.id || 0)
const provider = String(input.provider || '').trim()
const shopId = String(input.shopId || '').trim()
const matchValue = normalizeInternalSkuMappingValue(input.matchValue)
const internalSku = canonicalizeInternalSku(input.internalSku)
const priority = Number.isFinite(Number(input.priority)) ? Number(input.priority) : 100
const source = String(input.source || 'manual').trim() || 'manual'
const note = String(input.note || '').trim()
return withTransaction(async (client) => {
const existing = await client.query<{ id: number }>(
`SELECT id FROM internal_sku_mappings
WHERE provider = $1 AND shop_id = $2 AND match_type = $3
AND canonicalize_internal_sku(match_value) = $4
ORDER BY id
LIMIT 1
FOR UPDATE`,
[provider, shopId, input.matchType, matchValue],
)
const existingId = Number(existing.rows[0]?.id || 0)
const result = existingId
? await client.query<{ id: number }>(
`UPDATE internal_sku_mappings
SET match_value = $2, internal_sku = $3, priority = $4,
enabled = TRUE, source = $5, note = $6, updated_at = NOW()
WHERE id = $1
RETURNING id`,
[existingId, matchValue, internalSku, priority, source, note],
)
: await client.query<{ id: number }>(
`INSERT INTO internal_sku_mappings (
provider, shop_id, match_type, match_value, internal_sku,
priority, enabled, source, note, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, TRUE, $7, $8, NOW(), NOW())
RETURNING id`,
[provider, shopId, input.matchType, matchValue, internalSku, priority, source, note],
)
return Number(result.rows[0]?.id || 0)
})
}
/**
* 订单项级内部 SKU 补全:
* - 现有 skuNick/outSkuSn/skuCode/skuName 已有中文 → 不查库;
* - 只有纯数字/空值 → 查 internal_sku_mappings,命中则返回中文内部 SKU。
* - 没有中文时 → 查 internal_sku_mappings,把任何平台码映射成中文内部 SKU。
*/
export async function resolveInternalSkuForOrderItem(input: {
provider?: string | null
@@ -128,12 +137,10 @@ export async function resolveInternalSkuForOrderItem(input: {
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 directCandidates = [spec.skuNick, spec.outSkuSn, input.skuCode, input.skuName]
.map(normalizeInternalSkuMappingValue)
.filter(Boolean)
if (directCandidates.some(containsCjk)) return null
const mapping = await resolveInternalSkuMapping({
provider: input.provider ?? null,
@@ -146,5 +153,9 @@ export async function resolveInternalSkuForOrderItem(input: {
],
})
if (!mapping) return null
return { internalSku: mapping.internalSku, source: mapping.source, mappingId: mapping.id }
return {
internalSku: canonicalizeInternalSku(mapping.internalSku),
source: mapping.source,
mappingId: mapping.id,
}
}
@@ -1,16 +1,16 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { resolveInternalSkuKey } from './internal-sku-resolver.js'
import { canonicalizeInternalSku, resolveInternalSkuKey } from './internal-sku-resolver.js'
test('内部 SKU:入口显式值最优先', () => {
test('内部 SKU:中文显式值最优先', () => {
assert.equal(
resolveInternalSkuKey({
snapshot: { internalSku: '映射后的内部SKU' },
snapshot: { internalSku: '映射后的内部SKU' },
manualSkuKey: '平台码123',
skuName: '商品名',
}),
'映射后的内部SKU',
'映射后的内部sku',
)
})
@@ -19,7 +19,7 @@ test('内部 SKU:纯数字平台码不会压过中文商品名', () => {
resolveInternalSkuKey({
manualSkuKey: '5003',
skuCode: '5003',
skuName: '幸运币2个',
skuName: '幸运币2个',
spec: { outSkuSn: '5003' },
}),
'幸运币2个',
@@ -31,13 +31,13 @@ test('内部 SKU:中文 skuNick 优先于 outSkuSn 和平台码', () => {
resolveInternalSkuKey({
skuCode: 'english-platform-code',
skuName: '展示标题',
spec: { skuNick: '内部中文SKU', outSkuSn: '规格码' },
spec: { skuNick: '内部 中文 SKU', outSkuSn: '规格码' },
}),
'内部中文SKU',
'内部中文sku',
)
})
test('内部 SKU:没有中文时按历史生成列顺序回退', () => {
test('内部 SKU:没有中文时返回空,平台码和英文码不能作为兜底', () => {
assert.equal(
resolveInternalSkuKey({
manualSkuKey: 'manual-key',
@@ -45,6 +45,11 @@ test('内部 SKU:没有中文时按历史生成列顺序回退', () => {
skuName: 'sku-name',
spec: { skuNick: 'nick', outSkuSn: 'out' },
}),
'manual-key',
'',
)
})
test('内部 SKU 规范化:NFKC + 去全部空白 + 小写', () => {
assert.equal(canonicalizeInternalSku(' 套装-浪漫天命 '), '套装-浪漫天命')
assert.equal(canonicalizeInternalSku(' M416-仓鼠 灰灰 '), 'm416-仓鼠灰灰')
})
@@ -14,38 +14,41 @@ function asObject(value: unknown): JsonObject {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {}
}
export function normalizeInternalSku(value: unknown): string {
return String(value ?? '').trim()
/**
* 内部 SKU 的唯一文本归一:NFKC、去全部空白、转小写。
* 与数据库函数 canonicalize_internal_sku() 必须保持同口径。
*/
export function canonicalizeInternalSku(value: unknown): string {
return String(value ?? '')
.normalize('NFKC')
.replace(/\s+/gu, '')
.trim()
.toLowerCase()
}
export function containsCjk(value: string): boolean {
return /[\u4e00-\u9fa5]/.test(value)
return /\p{Script=Han}/u.test(value)
}
/**
* 内部 SKU 的唯一解析口径。
*
* 1. 入口显式写入的 snapshot.internalSku 最权威;
* 2. 旧数据优先使用中文键,避免纯数字平台码压过中文 skuNick/sku_name;
* 3. 没有中文时按生成列的历史顺序回退。
* 只接受包含中文的内部 SKU。平台码、英文码和空值都不能成为渠道判定键;
* 它们必须先经过 internal_sku_mappings / 中文商品名归一。
*/
export function resolveInternalSkuKey(input: InternalSkuResolverInput): string {
const snapshot = asObject(input.snapshot)
const spec = asObject(input.spec)
const explicit = normalizeInternalSku(snapshot.internalSku)
if (explicit) return explicit
const candidates = [
snapshot.internalSku,
input.manualSkuKey,
spec.skuNick,
spec.outSkuSn,
input.skuCode,
input.skuName,
].map(canonicalizeInternalSku)
const manual = normalizeInternalSku(input.manualSkuKey)
const skuNick = normalizeInternalSku(spec.skuNick)
const outSkuSn = normalizeInternalSku(spec.outSkuSn)
const skuCode = normalizeInternalSku(input.skuCode)
const skuName = normalizeInternalSku(input.skuName)
return (
[manual, skuNick, outSkuSn, skuName].find((value) => value && containsCjk(value)) ||
[manual, skuNick, outSkuSn, skuCode, skuName].find(Boolean) ||
''
)
return candidates.find((value) => value && containsCjk(value)) || ''
}
/** 渠道选择里使用的稳定顺序,仅用于同优先级时生成诊断,不用于静默改渠道。 */
@@ -3,7 +3,6 @@ import test from 'node:test'
import {
matchAffiliateDashSku,
matchAffiliateDashSkuPassthrough,
matchDouyinMashidaAffiliateDashSku,
normalizeAffiliateDashMappingKey,
resolveOrderItemForFulfillment,
@@ -64,24 +63,6 @@ test('normalizeAffiliateDashMappingKey NFKC + 去空白 + 小写', () => {
assert.equal(normalizeAffiliateDashMappingKey('M416-仓鼠灰灰'), 'm416-仓鼠灰灰')
})
test('matchAffiliateDashSkuPassthrough sku 一致时命中', () => {
const skus = new Set(['suit_alan_walker', 'lucky_coin_x90'])
assert.equal(matchAffiliateDashSkuPassthrough('suit_alan_walker', skus), true)
assert.equal(matchAffiliateDashSkuPassthrough('lucky_coin_x90', skus), true)
})
test('matchAffiliateDashSkuPassthrough 不在商品列表时未命中', () => {
const skus = new Set(['suit_alan_walker'])
assert.equal(matchAffiliateDashSkuPassthrough('not_a_real_sku', skus), false)
assert.equal(matchAffiliateDashSkuPassthrough('', skus), false)
assert.equal(matchAffiliateDashSkuPassthrough('suit_alan_walker', null), false)
assert.equal(matchAffiliateDashSkuPassthrough('suit_alan_walker', undefined), false)
})
test('matchAffiliateDashSkuPassthrough 空集合未命中', () => {
assert.equal(matchAffiliateDashSkuPassthrough('suit_alan_walker', new Set()), false)
})
test('resolveAffiliateDashDisplayProductName 英文 sku 反查中文', () => {
assert.equal(
resolveAffiliateDashDisplayProductName('suit_romantic_destiny', 'suit_romantic_destiny'),
@@ -100,136 +81,81 @@ test('reverseAffiliateDashSkuToProductNo 未命中返回空串', () => {
assert.equal(reverseAffiliateDashSkuToProductNo(''), '')
})
// ---- 抖音码速达(抖店)订单分流:复用 affiliate_dash 通道 ----
// ---- 所有平台统一:入口只归一到中文内部 SKU ----
const DOUYIN_SKIN_TITLE = '【测试专用】和平精英皮肤小店入口出售返场吃鸡皮肤浪漫天命专用'
function buildDouyinItem(internalSku: string, productName = DOUYIN_SKIN_TITLE) {
function buildPlatformItem(input: {
itemId?: string
skuCode: string
skuName: string
productTitle?: string
}) {
return {
itemId: '6930318258716179555',
externalItemId: '6930318258716179555',
externalSkuCode: internalSku,
externalSkuName: productName,
skuCode: internalSku,
skuName: productName,
itemId: input.itemId || 'item-1',
externalItemId: input.itemId || 'item-1',
externalSkuCode: input.skuCode,
externalSkuName: input.skuName,
skuCode: input.skuCode,
skuName: input.skuName,
quantity: 1,
}
}
function selectedExecutorKeyOf(
item: Awaited<ReturnType<typeof resolveOrderItemForFulfillment>>,
): string {
const route = item.snapshot.fulfillmentRoute as
| { selectedExecutorKey?: string }
| null
| undefined
return String(route?.selectedExecutorKey || '')
}
test('matchDouyinMashidaAffiliateDashSku 只认权威映射表并做归一化', () => {
assert.equal(matchDouyinMashidaAffiliateDashSku('幸运币2个'), 'lucky_coin_x2')
assert.equal(matchDouyinMashidaAffiliateDashSku(' 幸运币2个 '), 'lucky_coin_x2')
assert.equal(matchDouyinMashidaAffiliateDashSku('不存在的皮肤'), '')
assert.equal(matchDouyinMashidaAffiliateDashSku(''), '')
})
test('抖音订单:内部 SKU 命中映射表 → 复用 affiliate_dash 通道自动发货', async () => {
const item = await resolveOrderItemForFulfillment({
provider: 'douyin_mashida',
platform: 'doudian',
item: buildDouyinItem('幸运币2个'),
})
assert.equal(item.isConfigured, true)
assert.equal(selectedExecutorKeyOf(item), 'affiliate_dash')
// 抖音的 sku_code 是**分类键(内部 SKU)**:命中映射后也不再被英文 sku 覆写
assert.equal(item.skuCode, '幸运币2个')
assert.equal(item.skuName, '幸运币2个')
// 英文 sku 只落在快照里(affiliate_dash 执行器/规划器要用它)
assert.deepEqual(item.snapshot.affiliateDash, {
matchMode: 'affiliate_dash_sku_mapping',
productNo: '幸运币2个',
sku: 'lucky_coin_x2',
skuName: '幸运币2个',
})
// 引流长标题另存 productTitle(不再占 skuName)
assert.equal(item.snapshot.productTitle, DOUYIN_SKIN_TITLE)
// 分类结果(发单层据此维护「一个单子只有一个执行载体」)
assert.deepEqual(item.snapshot.classification, {
channel: 'affiliate_dash',
canonicalSku: '幸运币2个',
matchMode: 'affiliate_dash_sku_mapping',
})
assert.equal(resolveProductImageUrl('幸运币2个'), '/images/products/lucky_coin_x2.webp')
})
test('抖音订单:内部 SKU 未命中映射表 → 无执行载体(既不建任务也不写死人工)', async () => {
const item = await resolveOrderItemForFulfillment({
provider: 'douyin_mashida',
platform: 'doudian',
item: buildDouyinItem('不存在的皮肤'),
})
// 错误流程(已废弃):未命中写死 manual_dispatch → 强行建人工任务、抢占工单判定。
// 正确流程:未命中不选执行器 → 由发单层先判接单模板(命中 → 工单),都不中即「待人工」。
assert.equal(selectedExecutorKeyOf(item), '')
assert.equal(item.isConfigured, false)
assert.equal(item.snapshot.affiliateDash, null)
assert.equal(item.skuCode, '不存在的皮肤')
assert.equal(item.skuName, '不存在的皮肤')
assert.deepEqual(item.snapshot.classification, {
channel: 'unmatched',
canonicalSku: '不存在的皮肤',
matchMode: '',
})
})
test('抖音订单:平台闸门只认 douyin_mashida + doudian,其它组合仍不配置', async () => {
const wrongPlatform = await resolveOrderItemForFulfillment({
provider: 'douyin_mashida',
platform: 'kuaishou',
item: buildDouyinItem('幸运币2个'),
})
assert.equal(wrongPlatform.isConfigured, false)
assert.equal(wrongPlatform.snapshot.fulfillmentRoute, null)
const wrongProvider = await resolveOrderItemForFulfillment({
provider: 'other',
platform: 'doudian',
item: buildDouyinItem('幸运币2个'),
})
assert.equal(wrongProvider.isConfigured, false)
assert.equal(wrongProvider.snapshot.fulfillmentRoute, null)
})
test('抖音订单:不触发 affiliate-dash 平台的 sku 透传(严格按内部 SKU 分流)', async () => {
const item = await resolveOrderItemForFulfillment({
provider: 'douyin_mashida',
platform: 'doudian',
item: buildDouyinItem('lucky_coin_x2'),
})
// 英文 sku 不是内部 SKU,抖音侧不做透传匹配 → 无执行载体
assert.equal(selectedExecutorKeyOf(item), '')
assert.equal(item.isConfigured, false)
assert.equal(item.snapshot.affiliateDash, null)
})
test('91 订单:不写抖音分类字段(历史快照形状与路由行为不变)', async () => {
test('入口归一:91 平台先得到中文内部 SKU,不在此处决定渠道', async () => {
const item = await resolveOrderItemForFulfillment({
provider: '91kaquan',
platform: 'kuaishou',
item: {
itemId: '幸运币2个',
externalItemId: '幸运币2个',
externalSkuCode: '幸运币2个',
externalSkuName: '幸运币2个',
skuCode: '幸运币2个',
skuName: '幸运币2个',
quantity: 1,
},
item: buildPlatformItem({ skuCode: 'external-001', skuName: '幸运币2个' }),
})
assert.equal('classification' in item.snapshot, false)
assert.equal('productTitle' in item.snapshot, false)
assert.equal(item.skuName, '幸运币2个')
assert.equal(item.snapshot.internalSku, '幸运币2个')
assert.equal(item.spec?.skuNick, '幸运币2个')
assert.equal(item.snapshot.fulfillmentRoute, undefined)
assert.equal(item.snapshot.affiliateDash, undefined)
assert.equal(item.isConfigured, false)
})
test('入口归一:PDD 数字平台码由中文商品名归一,不把数字当内部 SKU', async () => {
const item = await resolveOrderItemForFulfillment({
provider: 'pdd',
platform: 'pdd',
item: buildPlatformItem({ skuCode: '5003', skuName: '幸运币2个' }),
})
assert.equal(item.snapshot.internalSku, '幸运币2个')
assert.equal(item.skuName, '幸运币2个')
assert.equal(item.skuCode, '5003')
})
test('入口归一:抖音长标题不进内部 SKU,使用 sku_code 中的中文内部 SKU', async () => {
const title = '【测试专用】和平精英皮肤小店入口出售返场吃鸡皮肤浪漫天命专用'
const item = await resolveOrderItemForFulfillment({
provider: 'douyin_mashida',
platform: 'doudian',
item: buildPlatformItem({ skuCode: '浪漫天命', skuName: title }),
})
assert.equal(item.snapshot.internalSku, '浪漫天命')
assert.equal(item.skuName, '浪漫天命')
assert.equal(item.snapshot.productTitle, title)
assert.equal(item.snapshot.fulfillmentRoute, undefined)
assert.equal(item.snapshot.affiliateDash, undefined)
assert.equal(item.isConfigured, false)
})
test('入口归一:所有平台使用同一条中文 SKU 逻辑', async () => {
const cases = [
{ provider: '91kaquan', platform: 'kuaishou' },
{ provider: 'pdd', platform: 'pdd' },
{ provider: 'douyin_mashida', platform: 'doudian' },
{ provider: 'unknown', platform: 'unknown' },
]
for (const source of cases) {
const item = await resolveOrderItemForFulfillment({
...source,
item: buildPlatformItem({ skuCode: 'platform-code', skuName: ' 套装-浪漫天命 ' }),
})
assert.equal(item.snapshot.internalSku, '套装-浪漫天命')
assert.equal(item.isConfigured, false)
}
})
@@ -1,18 +1,10 @@
import {
getFulfillmentRoutingConfig,
normalizeInternalSkuMatchKey,
resolveFulfillmentRoute,
type FulfillmentRouteResult,
} from './routing-config-service.js'
import { getAffiliateDashConfig } from '../platforms/affiliate-dash/config.js'
import { listAllAffiliateDashProducts } from '../platforms/affiliate-dash/product-service.js'
import { mergeAffiliateDashSkuMapping } from '../platforms/affiliate-dash/sku-mapping.js'
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 { resolveInternalSkuKey } from './internal-sku-resolver.js'
import { logIntegration } from '../../utils/logger.js'
import { canonicalizeInternalSku, resolveInternalSkuKey } from './internal-sku-resolver.js'
import { loadInternalSkuChannelPlans } from './internal-sku-channel-service.js'
export type FulfillmentItem = {
itemId?: string
@@ -42,33 +34,7 @@ export type AffiliateDashSkuMatch = {
sku: string
productNo: string
skuName: string
matchMode: 'affiliate_dash_sku_mapping' | 'affiliate_dash_sku_passthrough'
}
/**
* 抖音按 SKU 分类的结果(写进 `order_items.item_snapshot_json.classification`)。
*
* 只表达**履约侧**的判定:`affiliate_dash` = 这个单子的执行载体已定为自动发货;
* `unmatched` = 没命中自动发货(后续由发单层决定是否派打手,都不中就是人工)。
* 发单层据此维护「一个单子只有一个执行载体」的不变式。
*/
export type FulfillmentItemClassification = {
channel: 'affiliate_dash' | 'unmatched'
/** 分类键:抖音取内部 SKU(canonical SKU) */
canonicalSku: string
matchMode: string
}
type FulfillmentItemCandidate = {
externalItemId: string
externalSkuCode: string
externalSkuName: string
externalSkuNameNormalized: string
resolvedSkuCode: string
affiliateDashMatch: AffiliateDashSkuMatch | null
fulfillmentRoute: FulfillmentRouteResult | null
isConfigured: boolean
classification?: FulfillmentItemClassification | null
matchMode: 'affiliate_dash_sku_mapping'
}
export type ResolvedFulfillmentItem = FulfillmentItem & {
@@ -82,89 +48,54 @@ export type ResolvedFulfillmentItem = FulfillmentItem & {
isConfigured: boolean
}
/**
* 订单入口只负责把各种平台字段统一归一到中文内部 SKU。
*
* 渠道选择、载体配置和路由优先级都不在这里判断;它们统一读取
* `internal_sku_channels`,避免平台 SKU 或旧路由抢先创建载体。
*/
export async function resolveOrderItemForFulfillment({
provider = '',
platform = '',
shopId = '',
item = {},
}: ResolveOrderItemForFulfillmentInput): Promise<ResolvedFulfillmentItem> {
const candidate = await resolveConfiguredItemCandidate({
provider,
platform,
item,
})
const {
externalItemId,
externalSkuCode,
externalSkuName,
externalSkuNameNormalized,
affiliateDashMatch,
fulfillmentRoute,
classification,
} = candidate
const externalItemId = pickFirstNonEmpty([item.externalItemId, item.itemId])
const externalSkuCode = pickFirstNonEmpty([item.externalSkuCode, item.skuCode, externalItemId])
const externalSkuName = pickFirstNonEmpty([item.externalSkuName, item.skuName, externalSkuCode])
const externalSkuNameNormalized = normalizeProductName(externalSkuName)
const spec = isPlainObject(item.spec) ? item.spec : {}
const selectedExecutorKey = fulfillmentRoute?.selectedExecutorKey || ''
// 抖音:`sku_code` 是**分类键**(内部 SKU),必须稳定,不能被英文 sku 覆写;
// 91 保持历史行为(命中映射后用英文 sku 覆盖),避免影响线上 32.9 万单。
const isDouyinMashida = isDouyinMashidaOrder(provider, platform)
const selectedAffiliateDash =
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH && !isDouyinMashida
const resolvedSkuCode = pickFirstNonEmpty([
selectedAffiliateDash ? affiliateDashMatch?.sku : '',
externalSkuCode,
externalItemId,
])
// 运营侧展示用中文商品名;英文 sku 只写在 skuCode。
// 抖音:展示用内部 SKU(商品标识),引流长标题另存 snapshot.productTitle。
const resolvedSkuName = pickFirstNonEmpty([
selectedAffiliateDash ? affiliateDashMatch?.productNo || affiliateDashMatch?.skuName : '',
isDouyinMashida ? externalSkuCode : '',
item.skuName,
externalSkuName,
resolvedSkuCode,
])
const internalSkuMapping = await resolveInternalSkuForOrderItem({
provider,
shopId,
spec: item.spec ?? null,
spec,
skuCode: externalSkuCode,
skuName: externalSkuName,
})
const mappedSpec = internalSkuMapping
? { ...(isPlainObject(item.spec) ? item.spec : {}), skuNick: internalSkuMapping.internalSku }
: item.spec
? { ...spec, skuNick: internalSkuMapping.internalSku }
: spec
const resolvedInternalSku = resolveInternalSkuKey({
spec: mappedSpec,
snapshot: item.snapshot,
skuCode: externalSkuCode,
skuName: externalSkuName,
})
// 统一把内部 SKU 固化到 spec.skuNick,使 manual_sku_key 生成列和所有后续管道使用同一键。
const nextSpec = resolvedInternalSku
? { ...(isPlainObject(mappedSpec) ? mappedSpec : {}), skuNick: resolvedInternalSku }
? { ...mappedSpec, skuNick: resolvedInternalSku }
: mappedSpec
const snapshot = {
const snapshot: Record<string, unknown> = {
...(isPlainObject(item.snapshot) ? item.snapshot : {}),
externalItemId,
externalSkuCode,
externalSkuName,
externalSkuNameNormalized,
resolvedSkuCode,
matchMode:
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH
? affiliateDashMatch?.matchMode || ''
: '',
fulfillmentRoute,
affiliateDash: affiliateDashMatch
? {
matchMode: affiliateDashMatch.matchMode,
productNo: affiliateDashMatch.productNo,
sku: affiliateDashMatch.sku,
skuName: affiliateDashMatch.skuName,
}
: null,
isConfigured: candidate.isConfigured,
resolvedSkuCode: externalSkuCode,
...(externalSkuName && externalSkuName !== resolvedInternalSku
? { productTitle: externalSkuName }
: {}),
...(resolvedInternalSku ? { internalSku: resolvedInternalSku } : {}),
...(internalSkuMapping
? {
@@ -172,12 +103,6 @@ export async function resolveOrderItemForFulfillment({
internalSkuMappingId: internalSkuMapping.mappingId,
}
: {}),
// 抖音:引流长标题单独存档(skuName 已改存内部 SKU,标题不能丢)
...(isDouyinMashida
? { productTitle: pickFirstNonEmpty([item.skuName, externalSkuName]) }
: {}),
// 抖音:分类结果,供发单层维护「一个单子只有一个执行载体」与后台诊断
...(classification ? { classification } : {}),
}
return {
@@ -187,29 +112,36 @@ export async function resolveOrderItemForFulfillment({
externalItemId,
externalSkuCode,
externalSkuName,
skuCode: resolvedSkuCode,
skuName: resolvedSkuName,
skuCode: externalSkuCode,
skuName: resolvedInternalSku || externalSkuName,
snapshot,
isConfigured: candidate.isConfigured,
isConfigured: false,
}
}
export async function hasConfiguredOrderItems({
provider = '',
platform = '',
shopId = '',
items = [],
}: HasConfiguredOrderItemsInput): Promise<boolean> {
const candidates = await Promise.all(
(Array.isArray(items) ? items : []).map((item) =>
resolveConfiguredItemCandidate({
const resolved = await Promise.all(
items.map((item) =>
resolveOrderItemForFulfillment({
provider,
platform,
shopId,
item,
}),
),
)
return candidates.some((item) => item.isConfigured)
const plans = await loadInternalSkuChannelPlans(
resolved.map((item) => String(item.snapshot.internalSku || '')),
)
return resolved.some((item) => {
const key = canonicalizeInternalSku(item.snapshot.internalSku)
return Boolean(key && plans.get(key)?.primary)
})
}
export function normalizeProductName(value: unknown): string {
@@ -219,177 +151,19 @@ export function normalizeProductName(value: unknown): string {
function pickFirstNonEmpty(values: unknown[]): string {
for (const value of values) {
const normalized = String(value || '').trim()
if (normalized) {
return normalized
}
if (normalized) return normalized
}
return ''
}
async function resolveConfiguredItemCandidate({
provider = '',
platform = '',
item = {},
}: ResolveOrderItemForFulfillmentInput): Promise<FulfillmentItemCandidate> {
const externalItemId = pickFirstNonEmpty([item.externalItemId, item.itemId])
const externalSkuCode = pickFirstNonEmpty([item.externalSkuCode, item.skuCode, externalItemId])
const externalSkuName = pickFirstNonEmpty([item.externalSkuName, item.skuName, externalSkuCode])
const externalSkuNameNormalized = normalizeProductName(externalSkuName)
if (isOpen91KuaishouOrder(provider, platform)) {
const affiliateDashMatch = await resolveAffiliateDashSkuByProductNo(item)
const affiliateDashConfig = getAffiliateDashConfig()
const preferredExecutorKey =
affiliateDashMatch && affiliateDashConfig.preferredMatchEnabled !== false
? FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH
: ''
const fulfillmentRoute = resolveFulfillmentRoute({
productName: externalSkuName,
preferredExecutorKey,
candidates: [
{
executorKey: FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH,
available: Boolean(affiliateDashMatch),
reason: affiliateDashMatch ? '' : '未命中 affiliate-dash sku 映射',
},
],
})
const selectedExecutorKey = fulfillmentRoute.selectedExecutorKey
const resolvedSkuCode = pickFirstNonEmpty([
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH
? affiliateDashMatch?.sku
: '',
externalSkuCode,
externalItemId,
])
return {
externalItemId,
externalSkuCode,
externalSkuName,
externalSkuNameNormalized,
resolvedSkuCode,
affiliateDashMatch,
fulfillmentRoute,
isConfigured: Boolean(selectedExecutorKey),
}
}
if (isDouyinMashidaOrder(provider, platform)) {
return resolveDouyinMashidaItemCandidate({
item,
externalItemId,
externalSkuCode,
externalSkuName,
externalSkuNameNormalized,
})
}
const resolvedSkuCode = pickFirstNonEmpty([externalSkuCode, externalItemId])
return {
externalItemId,
externalSkuCode,
externalSkuName,
externalSkuNameNormalized,
resolvedSkuCode,
affiliateDashMatch: null,
fulfillmentRoute: null,
isConfigured: false,
}
}
/**
* 抖音码速达(抖店)订单分流 —— **按 SKU 分类**。
*
* 平台字段口径:`sku_order_list[].code` = **内部 SKU**(如「幸运币2个」,才是真正的商品),
* `product_name` = 引流长标题(如「【测试专用】和平精英皮肤小店入口…」)。
*
* 判定顺序(与 91/快手 同一套框架):
* 1. 内部 SKU 命中映射(常量表 + 后台增量)→ 指定 `affiliate_dash`(皮肤/道具自动发货);
* 2. 未命中 → **不写死兜底**,交给 `resolveFulfillmentRoute`:商品路由规则 → 全局优先级
* → `unmatchedExecutorKey`(生产配置为空串 → 不选执行器 = 不建履约任务);
* 3. 都不命中 → 分类为 `unmatched`:由发单层决定是否派打手(命中接单模板 → 建工单),
* 两者都不中就是人工(订单进「待人工」队列,判据 = 无任务且无工单)。
*
* ⚠️ 历史实现(错误流程,已废弃):未命中时**写死** `manual_dispatch`,
* 导致未命中单被强行建人工任务(91 是「不建任务」),且抢占了工单层的判定。
*/
function resolveDouyinMashidaItemCandidate({
item,
externalItemId,
externalSkuCode,
externalSkuName,
externalSkuNameNormalized,
}: {
item: FulfillmentItem
externalItemId: string
externalSkuCode: string
externalSkuName: string
externalSkuNameNormalized: string
}): FulfillmentItemCandidate {
const internalSku = pickFirstNonEmpty([externalSkuCode, externalItemId])
const productTitle = pickFirstNonEmpty([externalSkuName, internalSku])
// 内部 SKU(平台 code,如「幸运币2个」)→ affiliate-dash 英文 SKU(与商品图同一张权威表)。
// 皮肤/礼包类商品的交付**直接复用现成的 affiliate_dash 通道**:
// 买家输 UID → 出二维码 → 扫码绑定 → 比对「绑定 UID == 填的 UID」→ 发货完成。
const dashSku = matchDouyinMashidaAffiliateDashSku(internalSku)
const affiliateDashMatch: AffiliateDashSkuMatch | null = dashSku
? {
sku: dashSku,
productNo: internalSku,
skuName: internalSku,
matchMode: 'affiliate_dash_sku_mapping',
}
: null
const fulfillmentRoute = resolveFulfillmentRoute({
productName: productTitle,
internalSku,
// 命中映射 = 运营显式意图 → 指定 affiliate_dash;
// 未命中**不设 preferred**,让规则/优先级决定。
preferredExecutorKey: affiliateDashMatch ? FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH : '',
candidates: [
{
executorKey: FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH,
available: Boolean(affiliateDashMatch),
reason: affiliateDashMatch ? '' : '内部 SKU 未命中商品映射',
},
],
// ⚠️ 抖音**不允许未命中兜底 `manual_dispatch`**(覆盖配置里的 unmatchedExecutorKey):
// 履约判定发生在发单判定**之前**,若这里先建人工任务,命中接单模板的单就会出现
// 「人工任务 + 工单」双载体,破坏「一个单子只有一个执行载体」的不变式。
// 抖音的「人工」= 无执行载体(无任务无工单)→ 订单进「待人工」队列;
// 运营仍可用商品路由规则显式把某个 SKU 指到人工(规则优先于本兜底设置)。
config: { ...getFulfillmentRoutingConfig(), unmatchedExecutorKey: '' },
})
return {
externalItemId,
externalSkuCode,
externalSkuName,
externalSkuNameNormalized,
// 内部 SKU 就是「商品」:保留中文内部 SKU 作为 skuCode,展示与商品图都靠它。
resolvedSkuCode: internalSku || pickFirstNonEmpty([externalItemId, item.itemId]),
affiliateDashMatch,
fulfillmentRoute,
isConfigured: Boolean(fulfillmentRoute.selectedExecutorKey),
classification: {
channel: affiliateDashMatch ? 'affiliate_dash' : 'unmatched',
canonicalSku: internalSku,
matchMode: affiliateDashMatch?.matchMode || '',
},
}
}
/**
* 内部 SKU → affiliate-dash 英文 sku。
*
* 映射来源 = **权威常量表 + 后台增量覆盖**(`affiliate-dash` 配置面板的 `skuMapping`),
* 与 91 分支同一套来源:新增抖音商品在后台加一行即可,不必改代码发版。
* 映射来源是权威常量表 + 后台增量覆盖;只用于已确定 affiliate_dash
* 渠道后的载体参数,不参与渠道选择。
*/
export function matchAffiliateDashSkuByInternalSku(internalSku: unknown): string {
const raw = String(internalSku ?? '').trim()
const raw = canonicalizeInternalSku(internalSku)
if (!raw) return ''
return matchAffiliateDashSku(
mergeAffiliateDashSkuMapping(getAffiliateDashConfig().skuMapping),
@@ -398,117 +172,18 @@ export function matchAffiliateDashSkuByInternalSku(internalSku: unknown): string
}
export function matchDouyinMashidaAffiliateDashSku(internalSku: unknown): string {
const raw = String(internalSku ?? '').trim()
if (!raw) {
return ''
}
return matchAffiliateDashSku(
mergeAffiliateDashSkuMapping(getAffiliateDashConfig().skuMapping),
raw,
)
}
async function resolveAffiliateDashSkuByProductNo(
item: FulfillmentItem,
): Promise<AffiliateDashSkuMatch | null> {
const config = getAffiliateDashConfig()
if (config.enabled === false || !config.baseUrl) {
return null
}
const snapshot = isPlainObject(item.snapshot) ? item.snapshot : {}
const productNo = pickFirstNonEmpty([
snapshot.productNo,
item.externalSkuCode,
item.externalItemId,
])
if (!productNo) {
return null
}
// ① 权威中英表(+ 配置增量覆盖)② 英文 sku 透传兜底
const mapping = mergeAffiliateDashSkuMapping(config.skuMapping)
const mappedSku = matchAffiliateDashSku(mapping, String(productNo))
if (mappedSku) {
return {
sku: mappedSku,
productNo: String(productNo),
skuName: String(item.externalSkuName || '').trim(),
matchMode: 'affiliate_dash_sku_mapping',
}
}
const rawProductNo = String(productNo).trim()
const skuSet = await getAffiliateDashSkuSet()
if (matchAffiliateDashSkuPassthrough(rawProductNo, skuSet)) {
return {
sku: rawProductNo,
productNo: rawProductNo,
skuName: String(item.externalSkuName || '').trim(),
matchMode: 'affiliate_dash_sku_passthrough',
}
}
return null
}
/** 透传判定:productNo 与 affiliate_dash 商品 sku 完全一致。 */
export function matchAffiliateDashSkuPassthrough(
productNo: string,
availableSkus: ReadonlySet<string> | null | undefined,
): boolean {
const raw = String(productNo || '').trim()
return Boolean(raw && availableSkus?.has(raw))
}
const AFFILIATE_DASH_SKU_CACHE_TTL_MS = 5 * 60 * 1000
let affiliateDashSkuCache: { skus: Set<string>; fetchedAt: number } | null = null
async function getAffiliateDashSkuSet(): Promise<Set<string>> {
const now = Date.now()
if (
affiliateDashSkuCache &&
now - affiliateDashSkuCache.fetchedAt < AFFILIATE_DASH_SKU_CACHE_TTL_MS
) {
return affiliateDashSkuCache.skus
}
try {
const result = await listAllAffiliateDashProducts()
const skus = new Set<string>(
result.list.map((product) => String(product.sku || '').trim()).filter(Boolean),
)
affiliateDashSkuCache = { skus, fetchedAt: now }
return skus
} catch (error) {
logIntegration(
'[affiliate-dash]',
'affiliate-dash 商品列表拉取失败,透传降级为未命中',
{
cachedSkuCount: affiliateDashSkuCache?.skus.size || 0,
error: error instanceof Error ? error.message : String(error),
},
{ level: 'warn' },
)
return affiliateDashSkuCache?.skus || new Set<string>()
}
return matchAffiliateDashSkuByInternalSku(internalSku)
}
/**
* 91 商品编码为中文名(如「幸运币90个」),映射键查询做归一化:
* NFKC(全角→半角)→ 去所有空白 → 小写。先原样精确,再归一化遍历。
* 权威表见 platforms/affiliate-dash/sku-mapping.ts。
* 内部中文 SKU 映射键查询:NFKC → 去全部空白 → 小写。
*/
export function matchAffiliateDashSku(mapping: AffiliateDashSkuMapping, productNo: string): string {
const raw = String(productNo || '').trim()
if (!raw) {
return ''
}
if (!raw) return ''
const direct = String(mapping[raw] || '').trim()
if (direct) {
return direct
}
if (direct) return direct
const normalized = normalizeAffiliateDashMappingKey(raw)
for (const [key, sku] of Object.entries(mapping || {})) {
@@ -520,21 +195,7 @@ export function matchAffiliateDashSku(mapping: AffiliateDashSkuMapping, productN
}
export function normalizeAffiliateDashMappingKey(value: unknown): string {
return normalizeInternalSkuMatchKey(value)
}
function isOpen91KuaishouOrder(provider: unknown, platform: unknown) {
return (
String(provider || '').trim() === '91kaquan' && String(platform || '').trim() === 'kuaishou'
)
}
/** 抖音码速达:provider=douyin_mashida / platform=doudian(见 douyin-mashida/order-mapper.ts)。 */
function isDouyinMashidaOrder(provider: unknown, platform: unknown) {
return (
String(provider || '').trim() === 'douyin_mashida' &&
String(platform || '').trim() === 'doudian'
)
return canonicalizeInternalSku(value)
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
@@ -97,7 +97,7 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
: await readWatermarkItemId('carrier_dispatch')
let maxId = 0
const configKeysResult = await query<{ sku_key: string }>(
'SELECT sku_key FROM manual_fulfillment_configs WHERE enabled',
'SELECT canonicalize_internal_sku(sku_key) AS sku_key FROM manual_fulfillment_configs WHERE enabled',
)
const configKeys = configKeysResult.rows.map((row) => row.sku_key)
@@ -108,7 +108,9 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
cfg.id AS config_id
FROM order_items oi
JOIN manual_fulfillment_configs cfg
ON cfg.sku_key = resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)
ON canonicalize_internal_sku(cfg.sku_key) = resolve_internal_sku(
oi.manual_sku_key, oi.sku_name, oi.sku_code
)
AND cfg.enabled
WHERE oi.fulfillment_status = 'assigned'
AND oi.fulfillment_channel = 'manual'
@@ -209,9 +211,15 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
}
// 自动发货 / 接单平台被运营指定后却没有载体:不能静默卡住,进问题队列。
const missingCarrierRaw = await query<{ id: number; channel: string; sku_code: string }>(
const missingCarrierRaw = await query<{
id: number
channel: string
sku_code: string
internal_sku: string
}>(
`
SELECT oi.id, oi.fulfillment_channel AS channel, oi.sku_code
SELECT oi.id, oi.fulfillment_channel AS channel, oi.sku_code,
resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku
FROM order_items oi
LEFT JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled'
WHERE oi.fulfillment_status = 'assigned'
@@ -249,16 +257,22 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
for (const row of missingCarrier.rows) {
exceptionEntries.push({
reason: 'carrier_missing',
scopeKey: `carrier_missing|${row.channel}|${row.sku_code}`,
scopeKey: `carrier_missing|${row.internal_sku}|${row.channel}`,
orderItemId: Number(row.id),
detail: { channel: row.channel, skuCode: row.sku_code },
detail: { channel: row.channel, internalSku: row.internal_sku, platformSku: row.sku_code },
})
}
// 已分配订单出现两个真实执行方时不能继续算正常渠道,必须进冲突队列人工处置。
const conflictingCarriers = await query<{ id: number; channel: string; sku_code: string }>(
const conflictingCarriers = await query<{
id: number
channel: string
sku_code: string
internal_sku: string
}>(
`
SELECT oi.id, oi.fulfillment_channel AS channel, oi.sku_code
SELECT oi.id, oi.fulfillment_channel AS channel, oi.sku_code,
resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku
FROM order_items oi
WHERE oi.fulfillment_status = 'assigned'
AND (
@@ -284,9 +298,14 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
for (const row of conflictingCarriers.rows) {
exceptionEntries.push({
reason: 'routing_conflict',
scopeKey: `routing_conflict|${row.channel}|${row.sku_code}|carrier`,
scopeKey: `routing_conflict|${row.internal_sku}|${row.channel}|carrier`,
orderItemId: Number(row.id),
detail: { channel: row.channel, skuCode: row.sku_code, issue: 'multiple_carriers' },
detail: {
channel: row.channel,
internalSku: row.internal_sku,
platformSku: row.sku_code,
issue: 'multiple_carriers',
},
})
}
if (exceptionEntries.length > 0) {
@@ -338,7 +357,9 @@ async function resolveCarrierDispatchExceptions(): Promise<number> {
UPDATE order_items oi
SET fulfillment_config_id = cfg.id, updated_at = NOW()
FROM manual_fulfillment_configs cfg
WHERE cfg.sku_key = resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)
WHERE canonicalize_internal_sku(cfg.sku_key) = resolve_internal_sku(
oi.manual_sku_key, oi.sku_name, oi.sku_code
)
AND cfg.enabled
AND oi.fulfillment_status = 'assigned'
AND oi.fulfillment_channel = 'manual'
@@ -1,34 +1,73 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { isAffiliateDashCarrierSnapshot } from './sync-worker-orders-service.js'
import type {
InternalSkuChannelPlan,
InternalSkuChannelRow,
} from '../fulfillment/internal-sku-channel-service.js'
import { resolveWorkOrderChannelGate } from './sync-worker-orders-service.js'
// 载体唯一不变式:一个单子只有一个执行载体。
// 命中 affiliate_dash 自动发货的订单项(抖音按 SKU 分类后在 snapshot 标注)不再建工单。
function channel(patch: Partial<InternalSkuChannelRow> = {}): InternalSkuChannelRow {
return {
id: 1,
internal_sku: '幸运币2个',
channel: 'work_order',
priority: 10,
is_fallback: false,
enabled: true,
canonical_product_id: null,
config_json: { workRuleKey: 'rule-lucky-coin' },
source: 'operator',
note: '',
created_by_admin_user_id: null,
...patch,
}
}
test('载体唯一守卫:分类为 affiliate_dash 的订单项不建工单', () => {
function plan(primary: InternalSkuChannelRow | null): InternalSkuChannelPlan {
return {
primary,
fallbacks: [],
attempts: primary ? [primary] : [],
conflicts: [],
}
}
test('工单门禁:只有内部 SKU 明确配置 work_order 且提供模板才放行', () => {
assert.deepEqual(resolveWorkOrderChannelGate(plan(channel())), {
allowed: true,
reason: '',
workRuleKey: 'rule-lucky-coin',
})
})
test('工单门禁:其它统一渠道一律不放行', () => {
for (const targetChannel of ['affiliate_dash', 'manual', 'third_party_voucher'] as const) {
assert.equal(
resolveWorkOrderChannelGate(plan(channel({ channel: targetChannel, config_json: {} })))
.reason,
`internal_sku_channel_${targetChannel}`,
)
}
})
test('工单门禁:无配置、冲突、缺模板、模板不一致都不放行', () => {
assert.equal(resolveWorkOrderChannelGate(undefined).reason, 'no_channel_capability')
assert.equal(
isAffiliateDashCarrierSnapshot({ classification: { channel: 'affiliate_dash' } }),
true,
resolveWorkOrderChannelGate({
primary: null,
fallbacks: [],
attempts: [],
conflicts: [channel()],
}).reason,
'routing_conflict',
)
assert.equal(
resolveWorkOrderChannelGate(plan(channel({ config_json: {} }))).reason,
'work_rule_missing',
)
assert.equal(
resolveWorkOrderChannelGate(plan(channel()), 'another-rule').reason,
'work_rule_mismatch',
)
})
test('载体唯一守卫:多余的空白/大小写仍然命中(保守判定)', () => {
assert.equal(
isAffiliateDashCarrierSnapshot({ classification: { channel: ' affiliate_dash ' } }),
true,
)
})
test('载体唯一守卫:未命中分类 / 无字段 / 脏数据一律放行(不误伤发单)', () => {
assert.equal(isAffiliateDashCarrierSnapshot({ classification: { channel: 'unmatched' } }), false)
assert.equal(isAffiliateDashCarrierSnapshot({ classification: { channel: '' } }), false)
assert.equal(isAffiliateDashCarrierSnapshot({ classification: {} }), false)
assert.equal(isAffiliateDashCarrierSnapshot({ classification: 'affiliate_dash' }), false)
assert.equal(isAffiliateDashCarrierSnapshot({ classification: null }), false)
assert.equal(isAffiliateDashCarrierSnapshot({ classification: ['affiliate_dash'] }), false)
assert.equal(isAffiliateDashCarrierSnapshot({}), false)
assert.equal(isAffiliateDashCarrierSnapshot(null), false)
assert.equal(isAffiliateDashCarrierSnapshot(undefined), false)
assert.equal(isAffiliateDashCarrierSnapshot('affiliate_dash'), false)
})
@@ -12,7 +12,6 @@ import {
getLatestWorkProductMatchLogByOrderItemId,
getWorkOrderByOrderItemId,
listWorkOrdersByPlatformOrderId,
listWorkProductRuleMappings,
listWorkProductRules,
type WorkOrderRow,
} from '../../repositories/worker-platform/index.js'
@@ -38,14 +37,48 @@ import {
mapWorkOrderAdmin,
normalizeRequirementFields,
resolveKuaishouWorkProductMatchContext,
resolveMatchingProductRuleDecision,
resolveSkuNameQuantity,
resolveWorkOrderRulePricing,
resolveWorkOrderSharingEnabled,
} from './mappers.js'
import { getWorkerProductMatchConfig } from './worker-product-match-config-service.js'
import { registerWorkOrderShopName } from './work-order-shop-options-cache.js'
import { assignPendingOrderItemChannels } from '../fulfillment/channel-assign-service.js'
import {
loadInternalSkuChannelPlans,
type InternalSkuChannelPlan,
} from '../fulfillment/internal-sku-channel-service.js'
import {
canonicalizeInternalSku,
resolveInternalSkuKey,
} from '../fulfillment/internal-sku-resolver.js'
export function resolveWorkOrderChannelGate(
plan: InternalSkuChannelPlan | undefined,
preferredWorkRuleKey?: string,
): { allowed: boolean; reason: string; workRuleKey: string } {
if (!plan?.primary) {
return {
allowed: false,
reason: plan?.conflicts.length ? 'routing_conflict' : 'no_channel_capability',
workRuleKey: '',
}
}
if (plan.primary.channel !== 'work_order') {
return {
allowed: false,
reason: `internal_sku_channel_${plan.primary.channel}`,
workRuleKey: '',
}
}
const configuredWorkRuleKey = String(plan.primary.config_json.workRuleKey || '').trim()
if (!configuredWorkRuleKey) {
return { allowed: false, reason: 'work_rule_missing', workRuleKey: '' }
}
if (preferredWorkRuleKey && String(preferredWorkRuleKey).trim() !== configuredWorkRuleKey) {
return { allowed: false, reason: 'work_rule_mismatch', workRuleKey: '' }
}
return { allowed: true, reason: '', workRuleKey: configuredWorkRuleKey }
}
export async function syncWorkerOrdersForSourceOrder(
order: OrderRow,
@@ -58,59 +91,76 @@ export async function syncWorkerOrdersForSourceOrder(
preferredWorkRuleKey?: string
} = {},
) {
const [rules, mappings] = await Promise.all([
listWorkProductRules({ enabled: true }),
listWorkProductRuleMappings({ enabled: true }),
])
const productMatchConfig = getWorkerProductMatchConfig()
const rules = await listWorkProductRules({ enabled: true })
const created: WorkOrderRow[] = []
const skipped: Array<{ orderItemId: number; reason: string }> = []
const decisions: Array<Record<string, unknown>> = []
const internalSkuByItemId = new Map<number, string>()
for (const item of orderItems) {
internalSkuByItemId.set(
Number(item.id),
resolveInternalSkuKey({
manualSkuKey: item.manual_sku_key,
skuCode: item.sku_code,
skuName: item.sku_name,
spec: safeParseJson(item.spec_json),
snapshot: safeParseJson(item.item_snapshot_json),
}),
)
}
const channelPlans = await loadInternalSkuChannelPlans([...internalSkuByItemId.values()])
for (const item of orderItems) {
// 载体唯一不变式(一个单子只有一个执行载体):
// 命中 affiliate_dash 自动发货的订单项(抖音在 snapshot.classification 标注)不再建工单。
if (isAffiliateDashCarrierSnapshot(safeParseJson(item.item_snapshot_json))) {
skipped.push({ orderItemId: Number(item.id), reason: 'affiliate_dash_carrier_taken' })
const internalSku = canonicalizeInternalSku(internalSkuByItemId.get(Number(item.id)))
const channelPlan = channelPlans.get(internalSku)
if (!internalSku) {
skipped.push({ orderItemId: Number(item.id), reason: 'no_internal_sku' })
decisions.push(
buildWorkerConsumerDecision(item, {
result: 'skipped',
reason: 'affiliate_dash_carrier_taken',
reason: 'no_internal_sku',
}),
)
continue
}
const channelGate = resolveWorkOrderChannelGate(channelPlan, options.preferredWorkRuleKey)
if (!channelGate.allowed) {
skipped.push({ orderItemId: Number(item.id), reason: channelGate.reason })
decisions.push(
buildWorkerConsumerDecision(item, {
result: 'skipped',
reason: channelGate.reason,
internalSku,
}),
)
continue
}
const preferredRuleKey = String(options.preferredWorkRuleKey || '').trim()
const preferredRuleKey = channelGate.workRuleKey
const preferredRule = preferredRuleKey
? rules.find((rule) => String(rule.rule_key || '').trim() === preferredRuleKey) || null
: null
const match =
preferredRuleKey && !preferredRule
? {
rule: null,
mappingId: null,
reason: 'unmatched' as const,
score: 0,
candidates: [],
diagnosis: [
{
dimension: 'internal_sku_channel',
reason: 'configured_work_rule_missing',
},
],
}
: preferredRule
? {
rule: preferredRule,
mappingId: null,
reason: 'matched' as const,
score: 1,
candidates: [{ ruleKey: preferredRuleKey, score: 1, mappingId: null }],
}
: resolveMatchingProductRuleDecision(order, item, rules, mappings, {
legacyFallbackEnabled: productMatchConfig.legacyFallbackEnabled,
})
const match = !preferredRule
? {
rule: null,
mappingId: null,
reason: 'unmatched' as const,
score: 0,
candidates: [],
diagnosis: [
{
dimension: 'internal_sku_channel',
reason: 'configured_work_rule_missing',
},
],
}
: {
rule: preferredRule,
mappingId: null,
reason: 'matched' as const,
score: 1,
candidates: [{ ruleKey: preferredRuleKey, score: 1, mappingId: null }],
}
const matchContext = resolveKuaishouWorkProductMatchContext(item)
const snapshot = safeParseJson(item.item_snapshot_json)
const sellerId =
@@ -176,7 +226,7 @@ export async function syncWorkerOrdersForSourceOrder(
})
}
if (!match.rule) {
const reason = match.reason === 'ambiguous' ? 'rule_match_ambiguous' : 'rule_not_matched'
const reason = 'work_rule_not_found'
skipped.push({
orderItemId: Number(item.id),
reason,
@@ -435,26 +485,6 @@ async function resolveDouyinMashidaWorkOrderVoucher(
}
}
/**
* 该订单项的执行载体是否**已被 affiliate_dash 自动发货占用**。
*
* 依据:抖音按 SKU 分类后写入的 `snapshot.classification.channel === 'affiliate_dash'`
* (见 `fulfillment/product-resolution-service.ts`)。
* 一个单子只能有一个执行载体:自动发货与派打手互斥,命中前者时不再建工单。
*/
export function isAffiliateDashCarrierSnapshot(snapshot: unknown): boolean {
if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
return false
}
const classification = (snapshot as { classification?: unknown }).classification
if (!classification || typeof classification !== 'object' || Array.isArray(classification)) {
return false
}
return String((classification as { channel?: unknown }).channel || '').trim() === 'affiliate_dash'
}
function buildWorkerConsumerDecision(
item: OrderItemRow,
detail: Record<string, unknown>,