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
@@ -132,6 +132,32 @@ async function main() {
ORDER BY count(*) DESC, internal_sku`,
)
const internalChannels = await query<{
channel: string
source: string
total: number
}>(
`SELECT channel, source, count(*)::int AS total
FROM internal_sku_channels
WHERE enabled = TRUE
GROUP BY channel, source
ORDER BY channel, source`,
)
const canonicalChannelDuplicates = await query<{
internal_sku: string
channel: string
total: number
}>(
`SELECT canonicalize_internal_sku(internal_sku) AS internal_sku,
channel, count(*)::int AS total
FROM internal_sku_channels
WHERE enabled = TRUE
GROUP BY canonicalize_internal_sku(internal_sku), channel
HAVING count(*) > 1
ORDER BY count(*) DESC, internal_sku, channel`,
)
const assignedCarrierConflicts = await query<{
order_item_id: number
channel: string
@@ -197,7 +223,9 @@ async function main() {
rules: rules.rows,
duplicateProducts: duplicateProducts.rows[0],
capabilities: capabilities.rows,
internalChannels: internalChannels.rows,
internalChannelConflicts: internalChannelConflicts.rows,
canonicalChannelDuplicates: canonicalChannelDuplicates.rows,
assignedCarrierConflicts: assignedCarrierConflicts.rows,
byProvider: byProvider.rows,
},
@@ -1,8 +1,7 @@
/**
* 从 PDD 纯数字内部 SKU 的订单项回填中文映射(只读扫描 + 幂等写入)。
* 从平台 SKU 非中文、但商品名含中文的订单项回填中文内部 SKU 映射。
*
* 只处理 sku_name 含中文、sku_code 为纯数字的情况;
* 找不到中文名的纯数字码会列出来,等运营补映射。
* 数字码、英文码一视同仁;找不到中文名的平台码会列出来,等运营补映射。
*
* 用法:npx tsx scripts/backfill-internal-sku-mappings.ts [--apply]
*/
@@ -22,7 +21,7 @@ async function main() {
SELECT o.provider, o.shop_id, oi.sku_code, MIN(oi.sku_name) AS sku_name, count(*)::int AS total
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE btrim(COALESCE(oi.manual_sku_key, '')) ~ '^[0-9]+$'
WHERE COALESCE(canonicalize_internal_sku(oi.manual_sku_key), '') !~ '[一-龥]'
AND btrim(COALESCE(oi.sku_name, '')) ~ '[一-龥]'
GROUP BY 1, 2, 3
ORDER BY 5 DESC
@@ -38,7 +37,7 @@ async function main() {
SELECT o.provider, o.shop_id, oi.sku_code, count(*)::int AS total
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE btrim(COALESCE(oi.manual_sku_key, '')) ~ '^[0-9]+$'
WHERE COALESCE(canonicalize_internal_sku(oi.manual_sku_key), '') !~ '[一-龥]'
AND btrim(COALESCE(oi.sku_name, '')) !~ '[一-龥]'
GROUP BY 1, 2, 3
ORDER BY 4 DESC
@@ -118,6 +118,62 @@ async function main() {
)
).rows
checks.canonicalChannelDuplicates = (
await query<CountRow>(
`SELECT canonicalize_internal_sku(internal_sku) AS label, count(*)::int AS total
FROM internal_sku_channels
WHERE enabled = TRUE
GROUP BY canonicalize_internal_sku(internal_sku), channel
HAVING count(*) > 1
ORDER BY count(*) DESC, label`,
)
).rows
checks.incompleteCarrierConfigs = (
await query<CountRow>(
`SELECT channel AS label, count(*)::int AS total
FROM internal_sku_channels
WHERE enabled = TRUE
AND (
(
channel = 'work_order'
AND (
COALESCE(config_json->>'workRuleKey', '') = ''
OR NOT EXISTS (
SELECT 1 FROM work_product_rules rule
WHERE rule.rule_key = config_json->>'workRuleKey'
AND rule.enabled = TRUE
)
)
)
OR (
channel = 'manual'
AND NOT EXISTS (
SELECT 1 FROM manual_fulfillment_configs cfg
WHERE cfg.enabled = TRUE
AND canonicalize_internal_sku(cfg.sku_key)
= canonicalize_internal_sku(internal_sku_channels.internal_sku)
)
)
)
GROUP BY channel ORDER BY count(*) DESC`,
)
).rows
checks.pendingWithoutChineseInternalSku = (
await query<CountRow>(
`SELECT 'pending_without_chinese_internal_sku' AS label, count(*)::int AS total
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.fulfillment_status = 'pending'
AND ($1::boolean OR o.shop_id NOT LIKE 'sim-%')
AND COALESCE(resolve_internal_sku(
oi.manual_sku_key, oi.sku_name, oi.sku_code
), '') = ''`,
[includeDemo],
)
).rows[0]
checks.enabledLegacyRules = (
await query<CountRow>(
`SELECT match_key_type || '/' || channel AS label, count(*)::int AS total
@@ -202,6 +258,12 @@ async function main() {
failed.push('存在同时挂载多个真实履约载体的已分配订单项')
if ((checks.internalChannelConflicts as CountRow[]).length > 0)
failed.push('存在多个非兜底主渠道')
if ((checks.canonicalChannelDuplicates as CountRow[]).length > 0)
failed.push('存在规范化后重复的内部 SKU 渠道配置')
if ((checks.incompleteCarrierConfigs as CountRow[]).length > 0)
failed.push('存在缺少 workRuleKey 或人工履约配置的启用渠道')
if ((checks.pendingWithoutChineseInternalSku as CountRow).total > 0)
failed.push('存在无法归一到中文内部 SKU 的 pending 订单项')
if ((checks.enabledLegacyRules as CountRow[]).length > 0)
failed.push('仍有启用的 sku_code 兼容规则,未完全收敛到内部 SKU')
if ((checks.pendingWithoutReason as CountRow).total > 0)
@@ -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>,
+4 -4
View File
@@ -35,9 +35,6 @@ const AdminDouyinMashidaCertificatePage = lazy(
const AdminLoginPage = lazy(() => import('@/pages/admin/AdminLoginPage'))
const AdminOrderDetailPage = lazy(() => import('@/pages/admin/AdminOrderDetailPage'))
const AdminOrdersPage = lazy(() => import('@/pages/admin/AdminOrdersPage'))
const AdminPlatformFulfillmentPage = lazy(
() => import('@/pages/admin/platform/AdminPlatformFulfillmentPage'),
)
const AdminPlatformShopsPage = lazy(() => import('@/pages/admin/platform/AdminPlatformShopsPage'))
const AdminTaskDetailPage = lazy(() => import('@/pages/admin/AdminTaskDetailPage'))
const AdminTasksPage = lazy(() => import('@/pages/admin/AdminTasksPage'))
@@ -187,7 +184,10 @@ function AdminApplication() {
<Route element={<RequireRole roles={['admin']} />}>
<Route path="users" element={<AdminUsersPage />} />
<Route path="platform-shops" element={<AdminPlatformShopsPage />} />
<Route path="platform-fulfillment" element={<AdminPlatformFulfillmentPage />} />
<Route
path="platform-fulfillment"
element={<Navigate to="/admin/fulfillment-channel" replace />}
/>
<Route path="audit-logs" element={<AdminAuditLogsPage />} />
</Route>
</Route>
@@ -14,7 +14,6 @@ import {
OrderedListOutlined,
SafetyCertificateOutlined,
SettingOutlined,
ShopOutlined,
SoundOutlined,
TeamOutlined,
TrophyOutlined,
@@ -287,7 +286,6 @@ export default function AdminLayout() {
children: [
{ key: '/admin/users', icon: <TeamOutlined />, label: '后台用户' },
{ key: '/admin/platform-shops', icon: <SettingOutlined />, label: '平台配置' },
{ key: '/admin/platform-fulfillment', icon: <ShopOutlined />, label: '履约配置' },
],
},
{
@@ -34,7 +34,7 @@ import { useEffect, useState } from 'react'
import PercentTable from '@/components/common/PercentTable'
import FinanceSubNav from '@/components/admin/FinanceSubNav'
import {
assignChannelBySku,
assignChannelByInternalSku,
deleteChannelRules,
fetchChannelExceptions,
fetchChannelRules,
@@ -64,6 +64,14 @@ const CHANNEL_COLOR: Record<string, string> = {
manual: 'orange',
}
const CHANNEL_SOURCE_LABEL: Record<string, string> = {
operator: '运营配置',
manual_config: '人工 SKU 配置',
voucher: '第三方卡券',
capability: '商品能力迁移',
backfill: '历史回填',
}
const MATCH_KEY_OPTIONS = [{ value: 'internal_sku', label: '内部 SKU' }]
export default function AdminFulfillmentChannelPage() {
@@ -97,9 +105,7 @@ function PendingPanel() {
const assignMutation = useMutation({
mutationFn: (row: PendingChannelGroup) =>
assignChannelBySku({
provider: row.provider,
shopId: row.shop_id,
assignChannelByInternalSku({
internalSku: row.internal_sku,
channel: selections[rowKey(row)] || '',
}),
@@ -126,8 +132,19 @@ function PendingPanel() {
})
const columns = [
{ title: '来源', dataIndex: 'provider', width: 120 },
{ title: '店铺', dataIndex: 'shop_id', width: 140 },
{
title: '来源',
dataIndex: 'providers',
width: 120,
render: (value: string[] | undefined) => value?.join(' / ') || '-',
},
{
title: '店铺',
dataIndex: 'shop_ids',
width: 180,
ellipsis: true,
render: (value: string[] | undefined) => value?.join(' / ') || '-',
},
{ title: '内部SKU', dataIndex: 'internal_sku', width: 220 },
{
title: '平台SKU',
@@ -284,7 +301,7 @@ function RulesPanel() {
void queryClient.invalidateQueries({ queryKey: ['fulfillment-rules'] })
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
},
onError: () => message.error('删除规则失败'),
onError: (error) => message.error(error instanceof Error ? error.message : '删除规则失败'),
})
function confirmDeleteRules(ids: number[]) {
@@ -319,6 +336,19 @@ function RulesPanel() {
),
},
{ title: '优先级', dataIndex: 'priority', width: 90 },
{
title: '载体参数',
width: 220,
ellipsis: true,
render: (_: unknown, row: ChannelRule) => formatCarrierParams(row),
},
{
title: '来源',
dataIndex: 'source',
width: 140,
render: (value: string) =>
CHANNEL_SOURCE_LABEL[value] || (value.startsWith('rule:') ? '规则迁移' : value || '-'),
},
{
title: '启用',
dataIndex: 'enabled',
@@ -333,6 +363,7 @@ function RulesPanel() {
danger
size="small"
loading={deleteMutation.isPending}
disabled={!canDeleteChannelRule(row)}
onClick={() => confirmDeleteRules([row.id])}
>
删除
@@ -377,6 +408,7 @@ function RulesPanel() {
rowSelection={{
selectedRowKeys: selectedRuleIds,
onChange: (keys) => setSelectedRuleIds(keys.map(Number)),
getCheckboxProps: (row: ChannelRule) => ({ disabled: !canDeleteChannelRule(row) }),
}}
pagination={buildAdminTablePagination({
current: query.data?.data?.page || page,
@@ -441,11 +473,12 @@ function ExceptionsPanel() {
const REASON_OPTIONS = [
{ value: '', label: '全部' },
{ value: 'classification_missing', label: '待分类' },
{ value: 'routing_conflict', label: '规则冲突' },
{ value: 'routing_conflict', label: '渠道冲突' },
{ value: 'product_mapping_missing', label: '未归一' },
{ value: 'cost_missing', label: '成本缺失' },
{ value: 'manual_config_missing', label: '人工 SKU 未配置' },
{ value: 'manual_cost_missing', label: '人工未确认履约' },
{ value: 'carrier_missing', label: '缺少履约载体' },
]
const columns = [
@@ -469,7 +502,7 @@ function ExceptionsPanel() {
return (
<Card
title="问题队列(按 SKU 聚合)"
title="问题队列(按中文内部 SKU 聚合)"
extra={
<Space>
<Select
@@ -608,5 +641,21 @@ function PipelinesPanel() {
}
function rowKey(row: PendingChannelGroup) {
return `${row.provider}|${row.shop_id}|${row.internal_sku}`
return row.internal_sku
}
function formatCarrierParams(row: ChannelRule) {
const key =
row.channel === 'affiliate_dash'
? row.config_json.affiliateSku
: row.channel === 'work_order'
? row.config_json.workRuleKey
: row.channel === 'manual'
? row.config_json.manualConfigId
: row.config_json.voucherSku
return key ? String(key) : '待补全'
}
function canDeleteChannelRule(row: ChannelRule) {
return row.source === 'operator' || row.source.startsWith('rule:')
}
@@ -1,525 +0,0 @@
import {
ArrowDownOutlined,
ArrowUpOutlined,
DeleteOutlined,
PlusOutlined,
ReloadOutlined,
SaveOutlined,
SearchOutlined,
} from '@ant-design/icons'
import {
Alert,
Button,
Card,
Empty,
Input,
InputNumber,
Select,
Space,
Spin,
Switch,
Tag,
Typography,
} from 'antd'
import type { TableColumnsType } from 'antd'
import { useEffect, useMemo, useState } from 'react'
import PageHeader from '@/components/admin/PageHeader'
import { showError, showSuccess } from '@/lib/feedback'
import {
fetchAdminFulfillmentRoutingConfig,
previewAdminFulfillmentRouting,
saveAdminFulfillmentRoutingConfig,
} from '@/services/admin'
import type {
AdminFulfillmentRoutingConfig,
AdminFulfillmentRoutingPreviewResult,
AdminFulfillmentRoutingRule,
} from '@/types/admin'
import { hasAdminRole } from '@/utils/admin-auth'
import { buildAdminLocalTablePagination } from '@/utils/admin-pagination'
type EditableRoutingRule = Omit<AdminFulfillmentRoutingRule, 'normalizedProductName'> & {
normalizedProductName?: string
}
const ROUTING_EXECUTORS = [
{ key: 'affiliate_dash', label: '联盟发货', color: 'gold' },
{ key: 'manual_dispatch', label: '人工履约', color: 'default' },
]
export default function AdminPlatformFulfillmentPage() {
return (
<section className="page-stack">
<PageHeader
title="履约配置"
description="维护 91 卡券商品到 affiliate-dash / 人工履约的路由规则。"
/>
{!hasAdminRole('admin') ? (
<Card>
<Empty description="仅管理员可以查看履约配置" />
</Card>
) : (
<FulfillmentRoutingPanel />
)}
</section>
)
}
function FulfillmentRoutingPanel() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [previewing, setPreviewing] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const [enabled, setEnabled] = useState(true)
const [priority, setPriority] = useState<string[]>(['affiliate_dash'])
const [unmatchedExecutorKey, setUnmatchedExecutorKey] = useState('')
const [executors, setExecutors] = useState<AdminFulfillmentRoutingConfig['executors']>({})
const [rules, setRules] = useState<EditableRoutingRule[]>([])
const [previewInput, setPreviewInput] = useState('')
const [previewResult, setPreviewResult] = useState<AdminFulfillmentRoutingPreviewResult | null>(
null,
)
const enabledRuleCount = rules.filter((rule) => rule.enabled !== false).length
const selectedExecutorKey = previewResult?.fulfillmentRoute?.selectedExecutorKey || ''
useEffect(() => {
void loadConfig()
}, [])
async function loadConfig() {
setLoading(true)
setErrorMessage('')
try {
const response = await fetchAdminFulfillmentRoutingConfig()
hydrateConfig(response.data)
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : '读取履约路由配置失败')
} finally {
setLoading(false)
}
}
async function saveConfig() {
setSaving(true)
setErrorMessage('')
try {
const response = await saveAdminFulfillmentRoutingConfig({
enabled,
defaultExecutorPriority: priority,
unmatchedExecutorKey,
executors,
rules: rules.map((rule) => ({
id: rule.id,
enabled: rule.enabled !== false,
productName: rule.productName,
normalizedProductName: rule.normalizedProductName || '',
matchType: rule.matchType,
executorKey: rule.executorKey,
priority: rule.priority,
notes: rule.notes,
})),
})
hydrateConfig(response.data)
showSuccess('履约路由配置已保存')
} catch (error) {
const message = error instanceof Error ? error.message : '保存履约路由配置失败'
setErrorMessage(message)
showError(message)
} finally {
setSaving(false)
}
}
async function previewRoute() {
const productName = previewInput.trim()
if (!productName) {
showError('请输入 91 商品名')
return
}
setPreviewing(true)
setPreviewResult(null)
try {
const response = await previewAdminFulfillmentRouting(productName)
setPreviewResult(response.data)
} catch (error) {
showError(error instanceof Error ? error.message : '预览履约路由失败')
} finally {
setPreviewing(false)
}
}
function hydrateConfig(data: AdminFulfillmentRoutingConfig) {
setEnabled(data.enabled !== false)
setPriority(normalizeRoutingPriority(data.defaultExecutorPriority))
setUnmatchedExecutorKey(data.unmatchedExecutorKey || '')
setExecutors(normalizeRoutingExecutors(data.executors))
setRules(Array.isArray(data.rules) ? data.rules.map((rule) => normalizeRoutingRule(rule)) : [])
}
function movePriority(executorKey: string, offset: -1 | 1) {
const currentIndex = priority.indexOf(executorKey)
const nextIndex = currentIndex + offset
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= priority.length) {
return
}
const next = [...priority]
const [item] = next.splice(currentIndex, 1)
next.splice(nextIndex, 0, item)
setPriority(next)
}
function updateExecutorEnabled(executorKey: string, nextEnabled: boolean) {
setExecutors({
...executors,
[executorKey]: {
enabled: nextEnabled,
},
})
}
function updateRule(index: number, patch: Partial<EditableRoutingRule>) {
setRules((current) =>
current.map((rule, ruleIndex) =>
ruleIndex === index
? {
...rule,
...patch,
}
: rule,
),
)
}
function addRule() {
const productName = previewInput.trim()
const nextId = `${Date.now()}-${rules.length + 1}`
setRules([
{
id: nextId,
enabled: true,
productName,
normalizedProductName: '',
matchType: 'exact',
executorKey: 'affiliate_dash',
priority: 100,
notes: '',
},
...rules,
])
}
function removeRule(index: number) {
setRules((current) => current.filter((_, ruleIndex) => ruleIndex !== index))
}
if (loading) {
return <Spin />
}
return (
<section className="platform-panel-stack">
{errorMessage ? <Alert type="error" showIcon message={errorMessage} /> : null}
<div className="metric-grid four">
<MetricCard label="路由状态" value={enabled ? '启用' : '停用'} />
<MetricCard
label="商品规则"
value={String(rules.length)}
detail={`启用 ${enabledRuleCount} 条`}
/>
<MetricCard
label="全局优先级"
value={`${priority.length} 个通道`}
detail={priority.map(formatRoutingExecutorLabel).join(' > ') || '未配置'}
/>
<Card>
<Space wrap>
<Button icon={<ReloadOutlined />} loading={loading} onClick={loadConfig}>
刷新
</Button>
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={saveConfig}>
保存配置
</Button>
</Space>
</Card>
</div>
<Card title="通道开关">
<div className="platform-routing-grid">
<div className="field-switch">
<span>履约路由</span>
<Switch
checked={enabled}
checkedChildren="启用"
unCheckedChildren="停用"
onChange={setEnabled}
/>
</div>
{ROUTING_EXECUTORS.map((executor) => (
<div key={executor.key} className="field-switch">
<span>{executor.label}</span>
<Switch
checked={executors[executor.key]?.enabled !== false}
checkedChildren="启用"
unCheckedChildren="停用"
onChange={(checked) => updateExecutorEnabled(executor.key, checked)}
/>
</div>
))}
</div>
</Card>
<Card title="全局优先级">
<Space direction="vertical" className="full-width" size={10}>
{priority.map((executorKey, index) => (
<div key={executorKey} className="platform-routing-priority-row">
<Space>
<Tag color={resolveRoutingExecutorColor(executorKey)}>
{index + 1}. {formatRoutingExecutorLabel(executorKey)}
</Tag>
</Space>
<Space>
<Button
size="small"
icon={<ArrowUpOutlined />}
disabled={index === 0}
onClick={() => movePriority(executorKey, -1)}
/>
<Button
size="small"
icon={<ArrowDownOutlined />}
disabled={index === priority.length - 1}
onClick={() => movePriority(executorKey, 1)}
/>
</Space>
</div>
))}
<Select
value={unmatchedExecutorKey}
options={[
{ label: '未命中时保持未配置', value: '' },
{ label: '未命中时转人工履约', value: 'manual_dispatch' },
]}
onChange={(value) => setUnmatchedExecutorKey(String(value || ''))}
/>
</Space>
</Card>
<Card
title="路由预览"
extra={
<Button icon={<PlusOutlined />} onClick={addRule}>
新增规则
</Button>
}
>
<Space.Compact className="full-width">
<Input
allowClear
value={previewInput}
placeholder="91 商品名 / productNo"
onChange={(event) => setPreviewInput(event.target.value)}
onPressEnter={previewRoute}
/>
<Button icon={<SearchOutlined />} loading={previewing} onClick={previewRoute}>
预览路由
</Button>
</Space.Compact>
{previewResult ? (
<Alert
className="platform-section-gap"
type={previewResult.isConfigured ? 'success' : 'warning'}
showIcon
message={
previewResult.isConfigured
? `最终路由:${formatRoutingExecutorLabel(selectedExecutorKey)}`
: '未命中可用履约通道'
}
description={
<Space direction="vertical" size={4}>
<Typography.Text>
{previewResult.fulfillmentRoute?.reason || previewResult.normalizedProductName}
</Typography.Text>
<Space wrap>
{previewResult.fulfillmentRoute?.candidates.map((candidate) => (
<Tag
key={candidate.executorKey}
color={candidate.available ? 'green' : 'default'}
>
{formatRoutingExecutorLabel(candidate.executorKey)}
{candidate.available ? ' 可用' : ' 未命中'}
</Tag>
))}
</Space>
</Space>
}
/>
) : null}
</Card>
<Card
title="商品路由规则"
extra={
<Space wrap>
<Button icon={<PlusOutlined />} onClick={addRule}>
新增规则
</Button>
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={saveConfig}>
保存规则
</Button>
</Space>
}
>
{rules.length === 0 ? (
<Empty description="暂无商品路由规则" />
) : (
<Space direction="vertical" className="full-width" size={12}>
{rules.map((rule, index) => (
<div key={rule.id} className="platform-route-rule">
<div className="platform-route-rule-head">
<Space wrap>
<Switch
size="small"
checked={rule.enabled !== false}
onChange={(checked) => updateRule(index, { enabled: checked })}
/>
<Typography.Text strong>{rule.productName || '未命名规则'}</Typography.Text>
</Space>
<Button
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => removeRule(index)}
>
删除
</Button>
</div>
<div className="platform-route-rule-grid">
<Input
value={rule.productName}
placeholder="91 商品名 / productNo"
onChange={(event) => updateRule(index, { productName: event.target.value })}
/>
<Select
value={rule.matchType}
options={[
{ label: '精确', value: 'exact' },
{ label: '包含', value: 'contains' },
]}
onChange={(matchType) =>
updateRule(index, {
matchType: matchType === 'contains' ? 'contains' : 'exact',
})
}
/>
<Select
value={rule.executorKey}
options={ROUTING_EXECUTORS.map((executor) => ({
label: executor.label,
value: executor.key,
}))}
onChange={(executorKey) =>
updateRule(index, { executorKey: String(executorKey || '') })
}
/>
<InputNumber
value={rule.priority}
min={0}
max={9999}
onChange={(priorityValue) =>
updateRule(index, { priority: Number(priorityValue || 0) || 0 })
}
/>
</div>
<Input
value={rule.notes}
placeholder="备注"
onChange={(event) => updateRule(index, { notes: event.target.value })}
/>
</div>
))}
</Space>
)}
</Card>
</section>
)
}
function MetricCard({ label, value, detail }: { label: string; value: string; detail?: string }) {
return (
<Card className="metric-card">
<span>{label}</span>
<strong>{value}</strong>
{detail ? <small>{detail}</small> : null}
</Card>
)
}
function normalizeRoutingPriority(value: unknown) {
const rawItems = Array.isArray(value) ? value : []
const allowed = ROUTING_EXECUTORS.filter((executor) => executor.key !== 'manual_dispatch').map(
(executor) => executor.key,
)
const seen = new Set<string>()
const items = rawItems
.map((item) => String(item || '').trim())
.filter((item) => allowed.includes(item) && !seen.has(item) && (seen.add(item), true))
return items.length > 0 ? items : ['affiliate_dash']
}
function normalizeRoutingExecutors(value: unknown): AdminFulfillmentRoutingConfig['executors'] {
const source =
value && typeof value === 'object' && !Array.isArray(value)
? (value as AdminFulfillmentRoutingConfig['executors'])
: {}
return ROUTING_EXECUTORS.reduce<AdminFulfillmentRoutingConfig['executors']>(
(result, executor) => {
result[executor.key] = {
enabled: source[executor.key]?.enabled !== false,
}
return result
},
{},
)
}
function normalizeRoutingRule(rule: AdminFulfillmentRoutingRule): EditableRoutingRule {
const productName = String(rule.productName || '').trim()
const executorKey = String(rule.executorKey || '').trim()
return {
id: String(rule.id || `${productName}-${executorKey}` || createRuleId()).trim(),
enabled: rule.enabled !== false,
productName,
normalizedProductName: String(rule.normalizedProductName || '').trim(),
matchType: rule.matchType === 'contains' ? 'contains' : 'exact',
executorKey: executorKey || 'affiliate_dash',
priority: Number(rule.priority || 0) || 0,
notes: String(rule.notes || '').trim(),
}
}
function formatRoutingExecutorLabel(executorKey: unknown) {
const key = String(executorKey || '').trim()
return ROUTING_EXECUTORS.find((executor) => executor.key === key)?.label || key || '-'
}
function resolveRoutingExecutorColor(executorKey: unknown) {
const key = String(executorKey || '').trim()
return ROUTING_EXECUTORS.find((executor) => executor.key === key)?.color || 'default'
}
function createRuleId() {
return `rule_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
}
@@ -2,10 +2,10 @@ import { apiDelete, apiGet, apiPost } from '@/lib/http'
/** 待分类队列(按内部 SKU 聚合)。 */
export type PendingChannelGroup = {
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
@@ -29,6 +29,10 @@ export type ChannelRule = {
channel: 'work_order' | 'affiliate_dash' | 'third_party_voucher' | 'manual'
priority: number
enabled: boolean
is_fallback: boolean
source: string
config_json: Record<string, unknown>
note: string
}
export type ChannelRuleListResult = {
@@ -98,9 +102,7 @@ export function fetchChannelExceptions(
return apiGet<ChannelExceptionListResult>('/api/v1/admin/fulfillment/exceptions', params)
}
export function assignChannelBySku(payload: {
provider: string
shopId: string
export function assignChannelByInternalSku(payload: {
internalSku: string
channel: string
note?: string
@@ -1,23 +0,0 @@
import { apiGet, apiPost } from '@/lib/http'
import type {
AdminFulfillmentRoutingConfig,
AdminFulfillmentRoutingPreviewResult,
} from '@/types/admin'
export function fetchAdminFulfillmentRoutingConfig() {
return apiGet<AdminFulfillmentRoutingConfig>('/api/v1/admin/platform-config/fulfillment-routing')
}
export function saveAdminFulfillmentRoutingConfig(payload: Partial<AdminFulfillmentRoutingConfig>) {
return apiPost<AdminFulfillmentRoutingConfig>(
'/api/v1/admin/platform-config/fulfillment-routing',
payload,
)
}
export function previewAdminFulfillmentRouting(productName: string) {
return apiPost<AdminFulfillmentRoutingPreviewResult>(
'/api/v1/admin/platform-config/fulfillment-routing/preview',
{ productName },
)
}
@@ -2,6 +2,5 @@ export * from './notifications'
export * from './scheduled-jobs'
export * from './kuaishou-industry'
export * from './affiliate-dash'
export * from './fulfillment-routing'
export * from './pdd'
export * from './douyin-mashida'
-4
View File
@@ -124,10 +124,6 @@ export type {
AdminKuaishouIndustryRefreshTokenResponse,
AdminKuaishouIndustryAuthorizationCodePayload,
AdminKuaishouIndustryExchangeCodeResponse,
AdminFulfillmentRoutingConfig,
AdminFulfillmentRoutingExecutorConfig,
AdminFulfillmentRoutingPreviewResult,
AdminFulfillmentRoutingRule,
AdminAffiliateDashConfig,
AdminAffiliateDashEffectiveConfig,
AdminAffiliateDashConfigResponse,
@@ -1,53 +0,0 @@
export interface AdminFulfillmentRoutingExecutorConfig {
enabled: boolean
}
export interface AdminFulfillmentRoutingRule {
id: string
enabled: boolean
productName: string
normalizedProductName: string
matchType: 'exact' | 'contains'
executorKey: string
priority: number
notes: string
}
export interface AdminFulfillmentRoutingConfig {
filePath: string
enabled: boolean
defaultExecutorPriority: string[]
unmatchedExecutorKey: string
executors: Record<string, AdminFulfillmentRoutingExecutorConfig>
rules: AdminFulfillmentRoutingRule[]
}
export interface AdminFulfillmentRoutingPreviewResult {
productName: string
normalizedProductName: string
resolvedSkuCode: string
resolvedSkuName: string
isConfigured: boolean
fulfillmentRoute: null | {
selectedExecutorKey: string
selectedRuleId: string
reason: string
matchedRule: null | {
id: string
productName: string
executorKey: string
priority: number
}
candidates: Array<{
executorKey: string
available: boolean
reason?: string
}>
skipped: Array<{
executorKey: string
reason: string
}>
}
cloudtentacles: Record<string, unknown> | null
kuaishouFeifei: Record<string, unknown> | null
}
@@ -36,13 +36,6 @@ export type {
AdminAffiliateDashWallet,
} from './affiliate-dash'
export type {
AdminFulfillmentRoutingConfig,
AdminFulfillmentRoutingExecutorConfig,
AdminFulfillmentRoutingPreviewResult,
AdminFulfillmentRoutingRule,
} from './fulfillment-routing'
export type {
AdminPddStoreItem,
AdminPddConfig,