修复接单模板动态匹配并移除冗余运维文档
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user