feat(fulfillment): 统一中文内部 SKU 履约路由
This commit is contained in:
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user