修复接单模板动态匹配并移除冗余运维文档

This commit is contained in:
yml
2026-11-15 00:00:04 +08:00
parent 572c43ce0e
commit 36f52a7f68
20 changed files with 627 additions and 1330 deletions
@@ -0,0 +1,100 @@
-- 166_work_order_dynamic_template_routing.sql
--
-- 接单平台路由只声明“由接单模板匹配”,具体模板必须按真实订单匹配。
-- 旧版统一渠道配置曾把内部 SKU 名称直接写成 workRuleKey,并生成
-- tpl:<内部SKU> 草稿商品;包含匹配、SKU 数量系列和店铺绑定因此永远走不到。
--
-- 本迁移:
-- 1. 清除不存在模板对应的 workRuleKey 和错误商品关联;
-- 2. 归档统一渠道配置遗留的假 tpl 草稿商品;
-- 3. 用实际接单模板商品补齐正式履约配置;
-- 4. 增加数据库闸门,未来非空 workRuleKey 必须是真实模板 key。
WITH invalid_routes AS (
SELECT route.id
FROM sku_channel_routes route
WHERE route.channel = 'work_order'
AND BTRIM(COALESCE(route.carrier_config_json ->> 'workRuleKey', '')) <> ''
AND NOT EXISTS (
SELECT 1
FROM work_product_rules rule
WHERE rule.rule_key = BTRIM(route.carrier_config_json ->> 'workRuleKey')
)
)
UPDATE sku_channel_routes route
SET carrier_config_json = (route.carrier_config_json - 'workRuleKey')
|| '{"templateSelection":"dynamic"}'::jsonb,
canonical_product_id = NULL,
note = CASE
WHEN route.note = '' THEN '迁移 166:清除不存在的接单模板 key'
ELSE LEFT(route.note || ';迁移 166:清除不存在的接单模板 key', 300)
END,
updated_at = NOW()
FROM invalid_routes invalid
WHERE route.id = invalid.id;
UPDATE product_catalog product
SET status = 'archived',
attributes_json = COALESCE(product.attributes_json, '{}'::jsonb) || jsonb_build_object(
'archivedAt', NOW(),
'archiveReason', 'invalid_work_order_draft_product'
),
updated_at = NOW()
WHERE product.source_kind = 'work_order'
AND product.canonical_sku LIKE 'tpl:%'
AND product.attributes_json ->> 'draft' = 'true'
AND NOT EXISTS (
SELECT 1
FROM work_product_rules rule
WHERE rule.canonical_product_id = product.id
);
UPDATE fulfillments fulfillment
SET canonical_product_id = rule.canonical_product_id,
updated_at = NOW()
FROM work_orders work_order
JOIN work_product_rules rule ON rule.id = work_order.product_rule_id
WHERE work_order.order_item_id = fulfillment.order_item_id
AND work_order.status <> 'cancelled'
AND rule.canonical_product_id IS NOT NULL
AND fulfillment.canonical_product_id IS DISTINCT FROM rule.canonical_product_id;
CREATE OR REPLACE FUNCTION assert_work_order_route_template_key()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
template_key TEXT;
BEGIN
IF NEW.channel <> 'work_order' THEN
RETURN NEW;
END IF;
template_key := BTRIM(COALESCE(NEW.carrier_config_json ->> 'workRuleKey', ''));
IF template_key <> '' AND NOT EXISTS (
SELECT 1
FROM work_product_rules rule
WHERE rule.rule_key = template_key
) THEN
RAISE EXCEPTION
'work-order route % references missing template key %',
NEW.id,
template_key
USING ERRCODE = '23503',
HINT = 'Leave workRuleKey empty for dynamic template matching or bind an existing rule_key.';
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_sku_channel_routes_work_order_template_key
ON sku_channel_routes;
CREATE TRIGGER trg_sku_channel_routes_work_order_template_key
BEFORE INSERT OR UPDATE OF channel, carrier_config_json
ON sku_channel_routes
FOR EACH ROW
EXECUTE FUNCTION assert_work_order_route_template_key();
COMMENT ON FUNCTION assert_work_order_route_template_key() IS
'接单平台路由的非空 workRuleKey 必须指向真实模板;空值表示按订单动态匹配';
@@ -61,7 +61,7 @@ test('改动分类:进得了镜像的部署文件要带上对应一端', { ski
test('改动分类:进不了镜像的改动不触发重建', { skip: !hasDeployScripts }, () => {
assert.equal(classify([]), '')
assert.equal(classify(['README.md', 'docs/部署启动与数据库重建.md']), '')
assert.equal(classify(['README.md', 'docs/数据库SQL约束与运维.md']), '')
assert.equal(
classify([
'deploy/ubuntu-deploy.sh',
@@ -282,14 +282,6 @@ export async function ensureCarrierForOrderItem(input: {
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)
@@ -136,11 +136,9 @@ function resolvePendingReason(item: ChannelCandidateItem): {
const carrierIssue =
primary.channel === 'manual' && !Number(primaryConfig.manualConfigId || 0)
? 'manual_config_missing'
: primary.channel === 'work_order' && !String(primaryConfig.workRuleKey || '').trim()
? 'work_rule_missing'
: primary.channel === 'affiliate_dash' && !String(primaryConfig.affiliateSku || '').trim()
? 'affiliate_mapping_missing'
: 'carrier_not_attempted'
: primary.channel === 'affiliate_dash' && !String(primaryConfig.affiliateSku || '').trim()
? 'affiliate_mapping_missing'
: 'carrier_not_attempted'
return {
reason: 'no_carrier',
detail: {
@@ -793,8 +791,11 @@ export async function switchChannelForOrderItems(input: {
})
}
const targetInternalSku = switchInternalSkus[0]!
const draftProductId = await ensureDraftProductForChannel(targetInternalSku, input.channel)
if (!draftProductId) {
const draftProductId =
input.channel === 'work_order'
? null
: await ensureDraftProductForChannel(targetInternalSku, input.channel)
if (input.channel !== 'work_order' && !draftProductId) {
throw createHttpError('渠道标准商品创建或启用失败,请先在商品配置中处理', {
statusCode: 409,
errorCode: 'channel_standard_product_required',
@@ -980,6 +981,8 @@ export async function switchChannelForOrderItems(input: {
source: 'operator',
note: input.note,
adminUserId: input.adminUserId,
clearCanonicalProduct: input.channel === 'work_order',
replaceConfig: input.channel === 'work_order',
})
}
return {
@@ -2021,8 +2024,11 @@ export async function assignChannelByInternalSku(input: {
],
)
const draftProductId = await ensureDraftProductForChannel(internalSku, input.channel)
if (!draftProductId) {
const draftProductId =
input.channel === 'work_order'
? null
: await ensureDraftProductForChannel(internalSku, input.channel)
if (input.channel !== 'work_order' && !draftProductId) {
throw createHttpError('渠道标准商品创建或启用失败,请先在商品配置中处理', {
statusCode: 409,
errorCode: 'channel_standard_product_required',
@@ -2040,6 +2046,8 @@ export async function assignChannelByInternalSku(input: {
},
note: input.note,
adminUserId: input.adminUserId,
clearCanonicalProduct: input.channel === 'work_order',
replaceConfig: input.channel === 'work_order',
})
const ruleCreated = true
@@ -2238,6 +2246,7 @@ async function ensureDraftProductForChannel(
internalSku: string,
channel: FulfillmentChannel,
): Promise<number | null> {
if (channel === 'work_order') return null
const key = canonicalizeInternalSku(internalSku)
if (!key) return null
const definition = {
@@ -2247,7 +2256,6 @@ async function ensureDraftProductForChannel(
unit: '件',
sourceKind: 'affiliate_dash',
},
work_order: { prefix: 'tpl:', category: '接单', unit: '件', sourceKind: 'work_order' },
manual: { prefix: 'man:', category: '人工', unit: '件', sourceKind: 'manual' },
on_account: { prefix: 'man:', category: '统一结算', unit: '件', sourceKind: 'manual' },
third_party_voucher: {
@@ -2342,14 +2350,14 @@ async function resolveInternalSkuCarrierConfig(
}
}
if (channel === 'work_order') {
// 接单模板按真实订单匹配,不能把内部 SKU 伪造成 workRuleKey。
// 空配置表示动态匹配;生成工单后再把实际模板商品写回正式履约。
return { canonicalProductId: null, configJson: { templateSelection: 'dynamic' } }
}
const prefixes =
channel === 'affiliate_dash'
? ['aff:']
: channel === 'work_order'
? ['tpl:', 'wo:']
: channel === 'on_account'
? ['man:']
: ['tpv:']
channel === 'affiliate_dash' ? ['aff:'] : channel === 'on_account' ? ['man:'] : ['tpv:']
const product = await query<{ id: number; canonical_sku: string }>(
`SELECT id, canonical_sku
FROM product_catalog
@@ -2385,11 +2393,9 @@ async function resolveInternalSkuCarrierConfig(
configJson:
channel === 'affiliate_dash'
? { affiliateSku: carrierKey }
: channel === 'work_order'
? { workRuleKey: carrierKey }
: channel === 'on_account'
? { settlementSku: carrierKey }
: { voucherSku: carrierKey },
: channel === 'on_account'
? { settlementSku: carrierKey }
: { voucherSku: carrierKey },
}
}
@@ -2422,8 +2428,11 @@ export async function upsertChannelRule(input: {
})
}
const draftProductId = await ensureDraftProductForChannel(matchKey, input.channel)
if (!draftProductId) {
const draftProductId =
input.channel === 'work_order'
? null
: await ensureDraftProductForChannel(matchKey, input.channel)
if (input.channel !== 'work_order' && !draftProductId) {
throw createHttpError('渠道标准商品创建或启用失败,请先在商品配置中处理', {
statusCode: 409,
errorCode: 'channel_standard_product_required',
@@ -2443,8 +2452,10 @@ export async function upsertChannelRule(input: {
note: String(input.note || '').slice(0, 300),
adminUserId: input.adminUserId,
replacePriority: true,
canonicalProductId: draftProductId,
canonicalProductId: carrier.canonicalProductId ?? draftProductId,
configJson: carrier.configJson,
clearCanonicalProduct: input.channel === 'work_order',
replaceConfig: input.channel === 'work_order',
})
return { id }
}
@@ -161,6 +161,8 @@ type InternalSkuChannelUpsertInput = {
note?: string | undefined
adminUserId?: number | null | undefined
replacePriority?: boolean
clearCanonicalProduct?: boolean
replaceConfig?: boolean
}
async function upsertInternalSkuChannelWithExecutor(
@@ -202,8 +204,14 @@ async function upsertInternalSkuChannelWithExecutor(
END,
route_role = EXCLUDED.route_role,
enabled = EXCLUDED.enabled,
canonical_product_id = COALESCE(EXCLUDED.canonical_product_id, sku_channel_routes.canonical_product_id),
carrier_config_json = sku_channel_routes.carrier_config_json || EXCLUDED.carrier_config_json,
canonical_product_id = CASE
WHEN $12::boolean THEN NULL
ELSE COALESCE(EXCLUDED.canonical_product_id, sku_channel_routes.canonical_product_id)
END,
carrier_config_json = CASE
WHEN $13::boolean THEN EXCLUDED.carrier_config_json
ELSE sku_channel_routes.carrier_config_json || EXCLUDED.carrier_config_json
END,
source = EXCLUDED.source,
note = CASE WHEN EXCLUDED.note <> '' THEN EXCLUDED.note ELSE sku_channel_routes.note END,
created_by_admin_user_id = COALESCE(EXCLUDED.created_by_admin_user_id, sku_channel_routes.created_by_admin_user_id),
@@ -222,6 +230,8 @@ async function upsertInternalSkuChannelWithExecutor(
String(input.note || '').slice(0, 300),
input.adminUserId ?? null,
input.replacePriority === true,
input.clearCanonicalProduct === true,
input.replaceConfig === true,
],
)
return Number(result.rows[0]?.id || 0)
@@ -251,6 +261,8 @@ export async function setInternalSkuPrimaryChannel(input: {
configJson?: Record<string, unknown> | undefined
note?: string | undefined
adminUserId?: number | null
clearCanonicalProduct?: boolean
replaceConfig?: boolean
}): Promise<void> {
const internalSku = canonicalizeInternalSku(input.internalSku)
if (!internalSku) return
@@ -267,6 +279,10 @@ export async function setInternalSkuPrimaryChannel(input: {
note: input.note,
adminUserId: input.adminUserId,
replacePriority: true,
...(input.clearCanonicalProduct === undefined
? {}
: { clearCanonicalProduct: input.clearCanonicalProduct }),
...(input.replaceConfig === undefined ? {} : { replaceConfig: input.replaceConfig }),
})
}
@@ -123,7 +123,7 @@ test(
channel: 'work_order',
priority: 10,
isFallback: false,
configJson: { workRuleKey: `rule-${suffix}` },
configJson: { templateSelection: 'dynamic' },
source: 'integration_test',
replacePriority: true,
})
@@ -0,0 +1,357 @@
/**
* 接单平台动态模板匹配集成测试(仅隔离 PostgreSQL)。
*
* FULFILLMENT_ROUTING_TEST_DATABASE_URL=postgres://... \
* npx tsx --test src/services/fulfillment/work-order-dynamic-routing.integration.test.ts
*/
import assert from 'node:assert/strict'
import test from 'node:test'
const TEST_DATABASE_URL = String(process.env.FULFILLMENT_ROUTING_TEST_DATABASE_URL || '').trim()
test(
'接单平台空 workRuleKey 走动态匹配,支持包含、SKU 数量系列和真实模板商品',
{ skip: !TEST_DATABASE_URL ? '未设置 FULFILLMENT_ROUTING_TEST_DATABASE_URL' : false },
async () => {
process.env.DATABASE_URL = TEST_DATABASE_URL
const { closeDb, query, withTransaction } = await import('../../db/client.js')
const { upsertChannelRule, assignPendingOrderItemChannels } =
await import('./channel-assign-service.js')
const { syncWorkerOrdersForSourceOrder } =
await import('../worker-platform/sync-worker-orders-service.js')
const { getOrderById } = await import('../../repositories/order-repo.js')
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
const shopId = `it-dynamic-${suffix}`
const internalSkus = [`摄影师约瑟夫-光影【单独紫皮】3个-${suffix}`, `突击兵密钥3个-${suffix}`]
const orderIds: number[] = []
const orderItemIds: number[] = []
const ruleIds: number[] = []
const productIds: number[] = []
const routeIds: number[] = []
async function createOrderItem(internalSku: string, title: string) {
const order = await query<{ id: number }>(
`INSERT INTO orders (
provider, platform, shop_id, shop_name, 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()
) RETURNING id`,
[shopId, `IT-DYNAMIC-${suffix}-${orderItemIds.length + 1}`],
)
const orderId = Number(order.rows[0]!.id)
orderIds.push(orderId)
const item = await query<{ id: number }>(
`INSERT INTO order_items (
order_id, sku_code, sku_name, quantity, spec_json, item_snapshot_json,
revenue_amount_fen, revenue_basis, created_at, updated_at
) VALUES (
$1, $2, $2, 3, $3::jsonb, $3::jsonb, 1000, 'order_total', NOW(), NOW()
) RETURNING id`,
[
orderId,
internalSku,
JSON.stringify({
skuNick: internalSku,
kuaishouSendCode: {
itemTitle: title,
skuNick: internalSku,
sellerId: shopId,
},
}),
],
)
const orderItemId = Number(item.rows[0]!.id)
orderItemIds.push(orderItemId)
return { orderId, orderItemId }
}
async function createTemplate(input: {
ruleKey: string
productName: string
mappingType: 'product_default' | 'sku_series'
matchValue: string
sellerIds?: string[]
}) {
const product = await query<{ id: number }>(
`INSERT INTO product_catalog (
canonical_sku, canonical_name, category, unit_name, source_kind, status,
attributes_json, created_at, updated_at
) VALUES (
$1, $2, '接单测试', '件', 'work_order', 'active',
'{"source":"work_product_rule"}'::jsonb, NOW(), NOW()
) RETURNING id`,
[`tpl:${input.ruleKey}`, input.productName],
)
const productId = Number(product.rows[0]!.id)
productIds.push(productId)
const rule = await query<{ id: number }>(
`INSERT INTO work_product_rules (
rule_key, provider, platform, shop_id, sku_code, product_name, match_type,
category_id, enabled, auto_create, reward_amount, unit_price_fen,
requirement_json, sort_order, created_at, updated_at, pricing_mode,
canonical_product_id, sharing_enabled, sharing_auto_from_order
) VALUES (
$1, '', '', '', '', $2, 'contains',
NULL, TRUE, TRUE, 0, 100,
'{}'::jsonb, 1, NOW(), NOW(), 'unit',
$3, TRUE, TRUE
) RETURNING id`,
[input.ruleKey, input.productName, productId],
)
const ruleId = Number(rule.rows[0]!.id)
ruleIds.push(ruleId)
const itemTitle = input.mappingType === 'product_default' ? input.matchValue : ''
const skuNick = input.mappingType === 'sku_series' ? input.matchValue : ''
await query(
`INSERT INTO work_product_rule_mappings (
rule_id, seller_ids_json, rel_item_id, item_title, rel_sku_id, sku_nick,
mapping_type, enabled, created_at, updated_at, sku_match_mode
) VALUES (
$1, $2::jsonb, '', $3, '', $4,
$5, TRUE, NOW(), NOW(), 'fuzzy'
)`,
[
ruleId,
JSON.stringify(input.sellerIds || [shopId]),
itemTitle,
skuNick,
input.mappingType,
],
)
return { ruleId, productId }
}
try {
const containsItem = await createOrderItem(
internalSkus[0]!,
`摄影师约瑟夫-光影【单独紫皮】3个-${suffix}`,
)
const seriesItem = await createOrderItem(
internalSkus[1]!,
`和平精英-突击兵密钥秘钥礼盒-${suffix}`,
)
const containsTemplate = await createTemplate({
ruleKey: `photo-${suffix}`,
productName: `摄影师约瑟夫-光影【单独紫皮】-${suffix}`,
mappingType: 'product_default',
matchValue: `摄影师约瑟夫-光影【单独紫皮】`,
})
const seriesTemplate = await createTemplate({
ruleKey: `aq-${suffix}`,
productName: `突击兵-${suffix}`,
mappingType: 'sku_series',
matchValue: '突击兵密钥',
})
await createTemplate({
ruleKey: `aq-wrong-shop-${suffix}`,
productName: `突击兵错误店铺-${suffix}`,
mappingType: 'sku_series',
matchValue: '突击兵密钥',
sellerIds: [`wrong-shop-${suffix}`],
})
for (const internalSku of internalSkus) {
const route = await upsertChannelRule({
matchKeyType: 'internal_sku',
matchKey: internalSku,
channel: 'work_order',
priority: 10,
enabled: true,
note: '动态接单模板集成测试',
adminUserId: null,
})
routeIds.push(route.id)
}
const routes = await query<{
id: number
canonical_product_id: number | null
config_json: Record<string, unknown>
}>(
`SELECT id, canonical_product_id, carrier_config_json AS config_json
FROM sku_channel_routes
WHERE id = ANY($1::bigint[])
ORDER BY id`,
[routeIds],
)
assert.ok(routes.rows.every((route) => route.canonical_product_id === null))
assert.ok(routes.rows.every((route) => route.config_json.workRuleKey === undefined))
const fakeProducts = await query<{ count: number }>(
`SELECT COUNT(*)::int AS count
FROM product_catalog
WHERE canonical_sku = ANY($1::text[])`,
[internalSkus.map((sku) => `tpl:${sku}`)],
)
assert.equal(Number(fakeProducts.rows[0]?.count), 0)
const containsOrder = await getOrderById(containsItem.orderId)
const seriesOrder = await getOrderById(seriesItem.orderId)
assert.ok(containsOrder && seriesOrder)
const containsOrderItem = await query<Record<string, unknown>>(
'SELECT * FROM order_items WHERE id = $1',
[containsItem.orderItemId],
)
const seriesOrderItem = await query<Record<string, unknown>>(
'SELECT * FROM order_items WHERE id = $1',
[seriesItem.orderItemId],
)
const containsResult = await syncWorkerOrdersForSourceOrder(
containsOrder,
[containsOrderItem.rows[0] as never],
{
source: 'integration_test',
autoOnly: true,
skipChannelAssign: true,
},
)
const seriesResult = await syncWorkerOrdersForSourceOrder(
seriesOrder,
[seriesOrderItem.rows[0] as never],
{
source: 'integration_test',
autoOnly: true,
skipChannelAssign: true,
},
)
assert.equal(containsResult.createdCount, 1, JSON.stringify(containsResult))
assert.equal(seriesResult.createdCount, 1, JSON.stringify(seriesResult))
const workOrders = await query<{
order_item_id: number
product_rule_id: number
sharing_total_quantity: number
}>(
`SELECT order_item_id, product_rule_id, sharing_total_quantity
FROM work_orders
WHERE order_item_id = ANY($1::bigint[])
ORDER BY order_item_id`,
[orderItemIds],
)
assert.deepEqual(
workOrders.rows.map((row) => Number(row.product_rule_id)).sort((a, b) => a - b),
[containsTemplate.ruleId, seriesTemplate.ruleId].sort((a, b) => a - b),
)
assert.ok(
workOrders.rows.every((row) => Number(row.sharing_total_quantity) === 3),
JSON.stringify(workOrders.rows),
)
await assignPendingOrderItemChannels({
orderItemIds,
batchSize: 10,
maxBatches: 1,
})
const fulfilled = await query<{
id: number
fulfillment_status: string
fulfillment_channel: string
canonical_product_id: number | null
}>(
`SELECT id, fulfillment_status, fulfillment_channel, canonical_product_id
FROM v_order_item_current
WHERE id = ANY($1::bigint[])
ORDER BY id`,
[orderItemIds],
)
assert.ok(
fulfilled.rows.every(
(row) =>
row.fulfillment_status === 'assigned' && row.fulfillment_channel === 'work_order',
),
)
assert.deepEqual(
fulfilled.rows.map((row) => Number(row.canonical_product_id)).sort((a, b) => a - b),
[containsTemplate.productId, seriesTemplate.productId].sort((a, b) => a - b),
)
const finalFulfillments = await query<{
order_item_id: number
issue_code: string
pending_reason: string | null
}>(
`SELECT order_item_id, issue_code, pending_reason
FROM fulfillments
WHERE order_item_id = ANY($1::bigint[])
ORDER BY order_item_id`,
[orderItemIds],
)
assert.ok(
finalFulfillments.rows.every((row) => row.issue_code === '' && row.pending_reason === null),
)
await assert.rejects(
() =>
query(
`UPDATE sku_channel_routes
SET carrier_config_json = '{"workRuleKey":"missing-template"}'::jsonb,
updated_at = NOW()
WHERE id = $1`,
[routeIds[0]],
),
/references missing template key/,
)
} finally {
await withTransaction(async (client) => {
await client.query("SET LOCAL session_replication_role = 'replica'")
if (orderItemIds.length > 0) {
await client.query('DELETE FROM cost_snapshots WHERE order_item_id = ANY($1::bigint[])', [
orderItemIds,
])
await client.query(
`DELETE FROM work_order_events
WHERE work_order_id IN (
SELECT id FROM work_orders WHERE order_item_id = ANY($1::bigint[])
)`,
[orderItemIds],
)
await client.query('DELETE FROM work_orders WHERE order_item_id = ANY($1::bigint[])', [
orderItemIds,
])
await client.query('DELETE FROM order_items WHERE id = ANY($1::bigint[])', [orderItemIds])
}
if (orderIds.length > 0) {
await client.query('DELETE FROM orders WHERE id = ANY($1::bigint[])', [orderIds])
}
if (ruleIds.length > 0) {
await client.query(
'DELETE FROM work_product_match_logs WHERE rule_id = ANY($1::bigint[])',
[ruleIds],
)
await client.query(
'DELETE FROM work_product_rule_mappings WHERE rule_id = ANY($1::bigint[])',
[ruleIds],
)
await client.query(
'DELETE FROM cost_versions WHERE work_product_rule_id = ANY($1::bigint[])',
[ruleIds],
)
await client.query('DELETE FROM work_product_rules WHERE id = ANY($1::bigint[])', [
ruleIds,
])
}
if (productIds.length > 0) {
await client.query(
'DELETE FROM cost_snapshots WHERE canonical_product_id = ANY($1::bigint[])',
[productIds],
)
await client.query('DELETE FROM product_catalog WHERE id = ANY($1::bigint[])', [
productIds,
])
}
if (routeIds.length > 0) {
await client.query('DELETE FROM sku_channel_routes WHERE id = ANY($1::bigint[])', [
routeIds,
])
}
await client.query(
`DELETE FROM internal_skus
WHERE internal_sku_key = ANY($1::text[])`,
[internalSkus.map((sku) => sku.normalize('NFKC').toLowerCase())],
)
})
await closeDb()
}
},
)
@@ -36,12 +36,34 @@ function plan(primary: InternalSkuChannelRow | null): InternalSkuChannelPlan {
}
}
test('工单门禁:只有内部 SKU 明确配置 work_order 且提供模板才放行', () => {
test('工单门禁:内部 SKU 配置 work_order 后放行,显式模板 key 可选', () => {
assert.deepEqual(resolveWorkOrderChannelGate(plan(channel())), {
allowed: true,
reason: '',
workRuleKey: 'rule-lucky-coin',
})
assert.deepEqual(resolveWorkOrderChannelGate(plan(channel({ config_json: {} }))), {
allowed: true,
reason: '',
workRuleKey: '',
})
assert.deepEqual(
resolveWorkOrderChannelGate(
plan(
channel({
config_json: {
templateSelection: 'dynamic',
workRuleKey: 'last-matched-rule',
},
}),
),
),
{
allowed: true,
reason: '',
workRuleKey: '',
},
)
})
test('工单门禁:其它统一渠道一律不放行', () => {
@@ -54,7 +76,7 @@ test('工单门禁:其它统一渠道一律不放行', () => {
}
})
test('工单门禁:无配置、冲突、缺模板、模板不一致都不放行', () => {
test('工单门禁:无配置、冲突和显式模板不一致不放行', () => {
assert.equal(resolveWorkOrderChannelGate(undefined).reason, 'no_channel_capability')
assert.equal(
resolveWorkOrderChannelGate({
@@ -65,10 +87,6 @@ test('工单门禁:无配置、冲突、缺模板、模板不一致都不放
}).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',
@@ -45,6 +45,7 @@ import {
} from './mappers.js'
import { registerWorkOrderShopName } from './work-order-shop-options-cache.js'
import { assignPendingOrderItemChannels } from '../fulfillment/channel-assign-service.js'
import { attachFulfillmentConfigurations } from '../fulfillment/fulfillment-state-service.js'
import {
loadInternalSkuChannelPlans,
upsertInternalSkuChannel,
@@ -106,9 +107,14 @@ export function resolveWorkOrderChannelGate(
workRuleKey: '',
}
}
const configuredWorkRuleKey = String(plan.primary.config_json.workRuleKey || '').trim()
const primaryConfig = plan.primary.config_json || {}
const configuredWorkRuleKey = String(primaryConfig.workRuleKey || '').trim()
if (primaryConfig.templateSelection === 'dynamic') {
return { allowed: true, reason: '', workRuleKey: '' }
}
if (!configuredWorkRuleKey) {
return { allowed: false, reason: 'work_rule_missing', workRuleKey: '' }
// 空 key 是合法配置:按真实订单运行模板匹配,兼容包含匹配、SKU 数量系列等动态规则。
return { allowed: true, reason: '', workRuleKey: '' }
}
if (preferredWorkRuleKey && String(preferredWorkRuleKey).trim() !== configuredWorkRuleKey) {
return { allowed: false, reason: 'work_rule_mismatch', workRuleKey: '' }
@@ -184,14 +190,16 @@ export async function syncWorkerOrdersForSourceOrder(
priority: 10,
isFallback: false,
enabled: true,
configJson: { workRuleKey: matchedDecision.rule.rule_key },
configJson: {},
source: 'template',
note: `自动匹配接单模板 ${matchedDecision.rule.rule_key}`,
clearCanonicalProduct: true,
replaceConfig: true,
})
channelGate = {
allowed: true,
reason: '',
workRuleKey: matchedDecision.rule.rule_key,
workRuleKey: '',
}
}
}
@@ -211,29 +219,32 @@ export async function syncWorkerOrdersForSourceOrder(
const preferredRule = preferredRuleKey
? rules.find((rule) => String(rule.rule_key || '').trim() === preferredRuleKey) || null
: null
const match =
const dynamicMatch =
matchedDecision ||
(!preferredRule
resolveMatchingProductRuleDecision(order, item, rules, ruleMappings, {
legacyFallbackEnabled,
})
const match = preferredRule
? {
rule: preferredRule,
mappingId: null,
reason: 'matched' as const,
score: 1,
candidates: [{ ruleKey: preferredRuleKey, score: 1, mappingId: null }],
diagnosis: [] as [],
}
: preferredRuleKey
? {
rule: null,
mappingId: null,
reason: 'unmatched' as const,
score: 0,
candidates: [],
...dynamicMatch,
diagnosis: [
{
dimension: 'internal_sku_channel',
reason: 'configured_work_rule_missing',
},
...(dynamicMatch.diagnosis || []),
],
}
: {
rule: preferredRule,
mappingId: null,
reason: 'matched' as const,
score: 1,
candidates: [{ ruleKey: preferredRuleKey, score: 1, mappingId: null }],
})
: dynamicMatch
const matchContext = resolveKuaishouWorkProductMatchContext(item)
const snapshot = safeParseJson(item.item_snapshot_json)
const sellerId =
@@ -318,6 +329,23 @@ export async function syncWorkerOrdersForSourceOrder(
continue
}
const rule = match.rule
if (!preferredRule) {
await upsertInternalSkuChannel({
internalSku,
channel: 'work_order',
priority: Number(channelPlan?.primary?.priority || 10),
isFallback: false,
enabled: true,
configJson: {
templateSelection: 'dynamic',
workRuleKey: rule.rule_key,
},
source: 'template',
note: `动态匹配接单模板 ${rule.rule_key}`,
clearCanonicalProduct: true,
replaceConfig: true,
})
}
if (options.autoOnly && !rule.auto_create) {
skipped.push({ orderItemId: Number(item.id), reason: 'auto_create_disabled' })
decisions.push(
@@ -452,6 +480,17 @@ export async function syncWorkerOrdersForSourceOrder(
}
created.push(workOrder)
if (rule.canonical_product_id) {
await attachFulfillmentConfigurations({
mappings: [
{
orderItemId: Number(item.id),
canonicalProductId: Number(rule.canonical_product_id),
},
],
onlyWhenCanonicalProductDifferent: true,
})
}
// 店铺第一次产生工单时要能出现在后台「店铺」筛选里;老店铺出单不会动缓存。
registerWorkOrderShopName(order.shop_name)
decisions.push(