fix(fulfillment): 统一渠道规则与标准商品绑定
This commit is contained in:
@@ -0,0 +1,139 @@
|
|||||||
|
-- 136_reconcile_channel_products_and_work_costs.sql
|
||||||
|
--
|
||||||
|
-- 渠道商品与成本模型归一:
|
||||||
|
-- 1. 已启用的第三方卡券规则必须有 active tpv: 标准商品;
|
||||||
|
-- 2. 接单报酬成本版本不得挂在 man: 人工商品上;无快照引用的历史错误版本退休;
|
||||||
|
-- 3. 保留有快照引用的历史数据,不删除订单、成本版本或成本快照。
|
||||||
|
|
||||||
|
-- 卡券规则已有、但标准商品缺失时补建商品。
|
||||||
|
INSERT INTO product_catalog (
|
||||||
|
canonical_sku, canonical_name, category, unit_name, status,
|
||||||
|
attributes_json, created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'tpv:' || canonicalize_internal_sku(channel.internal_sku),
|
||||||
|
BTRIM(channel.internal_sku),
|
||||||
|
'卡券',
|
||||||
|
'张',
|
||||||
|
'active',
|
||||||
|
'{"source":"voucher_rule","auto_created":true,"reconciled":true}'::jsonb,
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
FROM internal_sku_channels channel
|
||||||
|
WHERE channel.channel = 'third_party_voucher'
|
||||||
|
AND channel.enabled = TRUE
|
||||||
|
AND canonicalize_internal_sku(channel.internal_sku) <> ''
|
||||||
|
AND char_length('tpv:' || canonicalize_internal_sku(channel.internal_sku)) <= 64
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM product_catalog product
|
||||||
|
WHERE product.status = 'active'
|
||||||
|
AND product.canonical_sku LIKE 'tpv:%'
|
||||||
|
AND canonicalize_internal_sku(substring(product.canonical_sku from 5))
|
||||||
|
= canonicalize_internal_sku(channel.internal_sku)
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM internal_sku_channels other
|
||||||
|
WHERE other.enabled = TRUE
|
||||||
|
AND other.is_fallback = FALSE
|
||||||
|
AND other.channel <> 'third_party_voucher'
|
||||||
|
AND canonicalize_internal_sku(other.internal_sku)
|
||||||
|
= canonicalize_internal_sku(channel.internal_sku)
|
||||||
|
)
|
||||||
|
ON CONFLICT (canonical_sku) DO UPDATE SET
|
||||||
|
status = 'active',
|
||||||
|
canonical_name = CASE
|
||||||
|
WHEN BTRIM(product_catalog.canonical_name) = '' THEN EXCLUDED.canonical_name
|
||||||
|
ELSE product_catalog.canonical_name
|
||||||
|
END,
|
||||||
|
updated_at = EXCLUDED.updated_at;
|
||||||
|
|
||||||
|
-- 卡券规则统一挂到 active 标准商品,并补齐商品渠道能力。
|
||||||
|
UPDATE internal_sku_channels channel
|
||||||
|
SET canonical_product_id = product.id,
|
||||||
|
priority = 10,
|
||||||
|
is_fallback = FALSE,
|
||||||
|
config_json = channel.config_json || jsonb_build_object(
|
||||||
|
'voucherSku',
|
||||||
|
canonicalize_internal_sku(channel.internal_sku)
|
||||||
|
),
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM product_catalog product
|
||||||
|
WHERE channel.channel = 'third_party_voucher'
|
||||||
|
AND channel.enabled = TRUE
|
||||||
|
AND product.status = 'active'
|
||||||
|
AND product.canonical_sku LIKE 'tpv:%'
|
||||||
|
AND canonicalize_internal_sku(substring(product.canonical_sku from 5))
|
||||||
|
= canonicalize_internal_sku(channel.internal_sku)
|
||||||
|
AND (
|
||||||
|
channel.canonical_product_id IS DISTINCT FROM product.id
|
||||||
|
OR channel.priority <> 10
|
||||||
|
OR channel.is_fallback
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO product_channel_capabilities (
|
||||||
|
product_id, channel, priority, is_fallback, enabled, source, created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
channel.canonical_product_id,
|
||||||
|
'third_party_voucher',
|
||||||
|
10,
|
||||||
|
FALSE,
|
||||||
|
TRUE,
|
||||||
|
'voucher',
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
FROM internal_sku_channels channel
|
||||||
|
JOIN product_catalog product
|
||||||
|
ON product.id = channel.canonical_product_id
|
||||||
|
AND product.status = 'active'
|
||||||
|
WHERE channel.channel = 'third_party_voucher'
|
||||||
|
AND channel.enabled = TRUE
|
||||||
|
AND channel.canonical_product_id IS NOT NULL
|
||||||
|
ON CONFLICT (product_id, channel) DO UPDATE SET
|
||||||
|
enabled = TRUE,
|
||||||
|
priority = 10,
|
||||||
|
is_fallback = FALSE,
|
||||||
|
updated_at = EXCLUDED.updated_at;
|
||||||
|
|
||||||
|
-- 无法补齐标准商品的启用卡券规则必须停用,否则会继续把订单路由到无商品渠道。
|
||||||
|
UPDATE internal_sku_channels channel
|
||||||
|
SET enabled = FALSE,
|
||||||
|
is_fallback = FALSE,
|
||||||
|
note = CASE
|
||||||
|
WHEN BTRIM(channel.note) = '' THEN '标准商品缺失,迁移 136 自动停用'
|
||||||
|
ELSE channel.note
|
||||||
|
END,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE channel.channel = 'third_party_voucher'
|
||||||
|
AND channel.enabled = TRUE
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM product_catalog product
|
||||||
|
WHERE product.id = channel.canonical_product_id
|
||||||
|
AND product.status = 'active'
|
||||||
|
AND product.canonical_sku LIKE 'tpv:%'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 早期错误地把“接单报酬”成本挂到了人工商品。无快照引用时直接退休。
|
||||||
|
UPDATE cost_versions version
|
||||||
|
SET status = 'retired',
|
||||||
|
change_reason = CASE
|
||||||
|
WHEN version.change_reason = '' THEN '清理错误挂到人工商品的接单报酬成本版本'
|
||||||
|
ELSE version.change_reason
|
||||||
|
END,
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM cost_items item,
|
||||||
|
product_catalog product
|
||||||
|
WHERE version.cost_item_id = item.id
|
||||||
|
AND version.canonical_product_id = product.id
|
||||||
|
AND version.status = 'active'
|
||||||
|
AND item.cost_model = 'work_order_reward'
|
||||||
|
AND product.canonical_sku LIKE 'man:%'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM cost_snapshots snapshot WHERE snapshot.cost_version_id = version.id
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN internal_sku_channels.canonical_product_id IS
|
||||||
|
'渠道规则关联的标准商品;启用规则必须指向 active 标准商品,历史归档仍保留关联';
|
||||||
@@ -556,13 +556,13 @@ export async function assignOrderItemsAfterCarrierReady(input: {
|
|||||||
} else {
|
} else {
|
||||||
await query(
|
await query(
|
||||||
`UPDATE order_items
|
`UPDATE order_items
|
||||||
SET fulfillment_channel = $3,
|
SET fulfillment_channel = NULL,
|
||||||
fulfillment_status = 'pending',
|
fulfillment_status = 'pending',
|
||||||
fulfillment_source = 'operator_assigned',
|
fulfillment_source = 'operator_assigned',
|
||||||
fulfillment_locked = TRUE,
|
fulfillment_locked = TRUE,
|
||||||
fulfillment_pending_reason = 'no_carrier',
|
fulfillment_pending_reason = 'no_carrier',
|
||||||
fulfillment_pending_detail = $2::jsonb,
|
fulfillment_pending_detail = $2::jsonb,
|
||||||
canonical_product_id = COALESCE($4, canonical_product_id),
|
canonical_product_id = COALESCE($3, canonical_product_id),
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $1 AND fulfillment_status <> 'archived'`,
|
WHERE id = $1 AND fulfillment_status <> 'archived'`,
|
||||||
[
|
[
|
||||||
@@ -573,7 +573,6 @@ export async function assignOrderItemsAfterCarrierReady(input: {
|
|||||||
message: carrier.message,
|
message: carrier.message,
|
||||||
carrierIssue: carrier.reason,
|
carrierIssue: carrier.reason,
|
||||||
}),
|
}),
|
||||||
input.channel,
|
|
||||||
input.canonicalProductId || null,
|
input.canonicalProductId || null,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -761,6 +760,14 @@ export async function switchChannelForOrderItems(input: {
|
|||||||
switchInternalSkus.length === 0 ? 'internal_sku_required' : 'multiple_internal_skus',
|
switchInternalSkus.length === 0 ? 'internal_sku_required' : 'multiple_internal_skus',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
const targetInternalSku = switchInternalSkus[0]!
|
||||||
|
const draftProductId = await ensureDraftProductForChannel(targetInternalSku, input.channel)
|
||||||
|
if (!draftProductId) {
|
||||||
|
throw createHttpError('渠道标准商品创建或启用失败,请先在商品配置中处理', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'channel_standard_product_required',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const items = (
|
const items = (
|
||||||
await query<OrderItemRow>(`SELECT * FROM order_items WHERE id = ANY($1::bigint[])`, [ids])
|
await query<OrderItemRow>(`SELECT * FROM order_items WHERE id = ANY($1::bigint[])`, [ids])
|
||||||
@@ -909,6 +916,7 @@ export async function switchChannelForOrderItems(input: {
|
|||||||
source: 'operator_assigned',
|
source: 'operator_assigned',
|
||||||
lock: true,
|
lock: true,
|
||||||
note: input.note,
|
note: input.note,
|
||||||
|
canonicalProductId: draftProductId,
|
||||||
})
|
})
|
||||||
await query(
|
await query(
|
||||||
`UPDATE order_items
|
`UPDATE order_items
|
||||||
@@ -917,12 +925,11 @@ export async function switchChannelForOrderItems(input: {
|
|||||||
[readyIds, input.adminUserId],
|
[readyIds, input.adminUserId],
|
||||||
)
|
)
|
||||||
if (assigned.updated > 0) {
|
if (assigned.updated > 0) {
|
||||||
const internalSku = switchInternalSkus[0]!
|
const carrier = await resolveInternalSkuCarrierConfig(targetInternalSku, input.channel)
|
||||||
const carrier = await resolveInternalSkuCarrierConfig(internalSku, input.channel)
|
|
||||||
await setInternalSkuPrimaryChannel({
|
await setInternalSkuPrimaryChannel({
|
||||||
internalSku,
|
internalSku: targetInternalSku,
|
||||||
channel: input.channel,
|
channel: input.channel,
|
||||||
canonicalProductId: carrier.canonicalProductId,
|
canonicalProductId: draftProductId,
|
||||||
configJson: carrier.configJson,
|
configJson: carrier.configJson,
|
||||||
source: 'operator',
|
source: 'operator',
|
||||||
note: input.note,
|
note: input.note,
|
||||||
@@ -1580,21 +1587,18 @@ export async function assignChannelByInternalSku(input: {
|
|||||||
[internalSku, input.channel],
|
[internalSku, input.channel],
|
||||||
)
|
)
|
||||||
|
|
||||||
const draftProductId = await ensureDraftProductForChannel(internalSku, input.channel).catch(
|
const draftProductId = await ensureDraftProductForChannel(internalSku, input.channel)
|
||||||
(error) => {
|
if (!draftProductId) {
|
||||||
logWarn('[channel-assign]', '自动创建渠道草稿商品失败', {
|
throw createHttpError('渠道标准商品创建或启用失败,请先在商品配置中处理', {
|
||||||
internalSku,
|
statusCode: 409,
|
||||||
channel: input.channel,
|
errorCode: 'channel_standard_product_required',
|
||||||
error: error instanceof Error ? error.message : String(error),
|
})
|
||||||
})
|
}
|
||||||
return null
|
|
||||||
},
|
|
||||||
)
|
|
||||||
const carrier = await resolveInternalSkuCarrierConfig(internalSku, input.channel)
|
const carrier = await resolveInternalSkuCarrierConfig(internalSku, input.channel)
|
||||||
await setInternalSkuPrimaryChannel({
|
await setInternalSkuPrimaryChannel({
|
||||||
internalSku,
|
internalSku,
|
||||||
channel: input.channel,
|
channel: input.channel,
|
||||||
canonicalProductId: carrier.canonicalProductId,
|
canonicalProductId: draftProductId,
|
||||||
source: 'operator',
|
source: 'operator',
|
||||||
configJson: {
|
configJson: {
|
||||||
...carrier.configJson,
|
...carrier.configJson,
|
||||||
@@ -1818,7 +1822,18 @@ async function ensureDraftProductForChannel(
|
|||||||
$5::jsonb, $6, $6
|
$5::jsonb, $6, $6
|
||||||
)
|
)
|
||||||
ON CONFLICT (canonical_sku) DO UPDATE
|
ON CONFLICT (canonical_sku) DO UPDATE
|
||||||
SET updated_at = product_catalog.updated_at
|
SET status = 'active',
|
||||||
|
canonical_name = CASE
|
||||||
|
WHEN BTRIM(product_catalog.canonical_name) = '' THEN EXCLUDED.canonical_name
|
||||||
|
ELSE product_catalog.canonical_name
|
||||||
|
END,
|
||||||
|
attributes_json = (
|
||||||
|
product_catalog.attributes_json
|
||||||
|
- 'deletedAt'
|
||||||
|
- 'deletedBy'
|
||||||
|
- 'archivedAt'
|
||||||
|
) || jsonb_build_object('reactivatedAt', EXCLUDED.updated_at),
|
||||||
|
updated_at = EXCLUDED.updated_at
|
||||||
RETURNING id, status
|
RETURNING id, status
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
@@ -1891,7 +1906,7 @@ async function resolveInternalSkuCarrierConfig(
|
|||||||
FROM product_catalog
|
FROM product_catalog
|
||||||
WHERE status = 'active'
|
WHERE status = 'active'
|
||||||
AND (canonical_sku LIKE $2 OR canonical_sku LIKE $3)
|
AND (canonical_sku LIKE $2 OR canonical_sku LIKE $3)
|
||||||
AND canonicalize_internal_sku(canonical_name) = $1
|
AND canonicalize_internal_sku(substring(canonical_sku from 5)) = $1
|
||||||
ORDER BY id`,
|
ORDER BY id`,
|
||||||
[key, `${prefixes[0]}%`, `${prefixes[1] || prefixes[0]}%`],
|
[key, `${prefixes[0]}%`, `${prefixes[1] || prefixes[0]}%`],
|
||||||
)
|
)
|
||||||
@@ -1958,19 +1973,28 @@ export async function upsertChannelRule(input: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const priority = Math.trunc(Number(input.priority) || 100)
|
const draftProductId = await ensureDraftProductForChannel(matchKey, input.channel)
|
||||||
|
if (!draftProductId) {
|
||||||
|
throw createHttpError('渠道标准商品创建或启用失败,请先在商品配置中处理', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'channel_standard_product_required',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const priority =
|
||||||
|
input.channel === 'third_party_voucher' ? 10 : Math.trunc(Number(input.priority) || 100)
|
||||||
const carrier = await resolveInternalSkuCarrierConfig(matchKey, input.channel)
|
const carrier = await resolveInternalSkuCarrierConfig(matchKey, input.channel)
|
||||||
const id = await upsertInternalSkuChannel({
|
const id = await upsertInternalSkuChannel({
|
||||||
internalSku: matchKey,
|
internalSku: matchKey,
|
||||||
channel: input.channel,
|
channel: input.channel,
|
||||||
priority,
|
priority,
|
||||||
isFallback: priority >= 90,
|
isFallback: input.channel === 'third_party_voucher' ? false : priority >= 90,
|
||||||
enabled: input.enabled !== false,
|
enabled: input.enabled !== false,
|
||||||
source: source === 'operator' ? 'operator' : `rule:${source}`,
|
source: source === 'operator' ? 'operator' : `rule:${source}`,
|
||||||
note: String(input.note || '').slice(0, 300),
|
note: String(input.note || '').slice(0, 300),
|
||||||
adminUserId: input.adminUserId,
|
adminUserId: input.adminUserId,
|
||||||
replacePriority: true,
|
replacePriority: true,
|
||||||
canonicalProductId: carrier.canonicalProductId,
|
canonicalProductId: draftProductId,
|
||||||
configJson: carrier.configJson,
|
configJson: carrier.configJson,
|
||||||
})
|
})
|
||||||
return { id }
|
return { id }
|
||||||
@@ -2079,15 +2103,18 @@ export async function ensureVoucherRuleForSku(skuCode: string): Promise<boolean>
|
|||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[`tpv:${key}`],
|
[`tpv:${key}`],
|
||||||
)
|
)
|
||||||
|
const productId = Number(product.rows[0]?.id || 0)
|
||||||
|
if (!productId) return false
|
||||||
await upsertInternalSkuChannel({
|
await upsertInternalSkuChannel({
|
||||||
internalSku: key,
|
internalSku: key,
|
||||||
channel: 'third_party_voucher',
|
channel: 'third_party_voucher',
|
||||||
priority: 10,
|
priority: 10,
|
||||||
isFallback: false,
|
isFallback: false,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
canonicalProductId: Number(product.rows[0]?.id || 0) || null,
|
canonicalProductId: productId,
|
||||||
configJson: { voucherSku: key },
|
configJson: { voucherSku: key },
|
||||||
source: 'voucher',
|
source: 'voucher',
|
||||||
|
replacePriority: true,
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -2110,7 +2137,8 @@ export async function ensureVoucherChannelRules(): Promise<{
|
|||||||
FROM product_catalog product
|
FROM product_catalog product
|
||||||
WHERE product.status = 'active'
|
WHERE product.status = 'active'
|
||||||
AND product.canonical_sku LIKE 'tpv:%'
|
AND product.canonical_sku LIKE 'tpv:%'
|
||||||
AND canonicalize_internal_sku(channel.internal_sku) = canonicalize_internal_sku(product.canonical_name)
|
AND canonicalize_internal_sku(channel.internal_sku) =
|
||||||
|
canonicalize_internal_sku(substring(product.canonical_sku from 5))
|
||||||
AND channel.channel <> 'third_party_voucher'
|
AND channel.channel <> 'third_party_voucher'
|
||||||
AND channel.enabled = TRUE
|
AND channel.enabled = TRUE
|
||||||
AND channel.is_fallback = FALSE`,
|
AND channel.is_fallback = FALSE`,
|
||||||
@@ -2122,17 +2150,21 @@ export async function ensureVoucherChannelRules(): Promise<{
|
|||||||
canonical_product_id, config_json, source, created_at, updated_at
|
canonical_product_id, config_json, source, created_at, updated_at
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
canonicalize_internal_sku(p.canonical_name), 'third_party_voucher', 10, FALSE, TRUE,
|
canonicalize_internal_sku(substring(p.canonical_sku from 5)),
|
||||||
|
'third_party_voucher', 10, FALSE, TRUE,
|
||||||
p.id, jsonb_build_object('voucherSku', substring(p.canonical_sku from 5)),
|
p.id, jsonb_build_object('voucherSku', substring(p.canonical_sku from 5)),
|
||||||
'voucher', NOW(), NOW()
|
'voucher', NOW(), NOW()
|
||||||
FROM product_catalog p
|
FROM product_catalog p
|
||||||
WHERE p.status = 'active'
|
WHERE p.status = 'active'
|
||||||
AND p.canonical_sku LIKE 'tpv:%'
|
AND p.canonical_sku LIKE 'tpv:%'
|
||||||
AND BTRIM(COALESCE(p.canonical_name, '')) <> ''
|
AND canonicalize_internal_sku(substring(p.canonical_sku from 5)) <> ''
|
||||||
ON CONFLICT (internal_sku, channel) DO UPDATE SET
|
ON CONFLICT (internal_sku, channel) DO UPDATE SET
|
||||||
|
priority = 10,
|
||||||
|
is_fallback = FALSE,
|
||||||
enabled = TRUE,
|
enabled = TRUE,
|
||||||
canonical_product_id = COALESCE(EXCLUDED.canonical_product_id, internal_sku_channels.canonical_product_id),
|
canonical_product_id = EXCLUDED.canonical_product_id,
|
||||||
config_json = internal_sku_channels.config_json || EXCLUDED.config_json,
|
config_json = internal_sku_channels.config_json || EXCLUDED.config_json,
|
||||||
|
source = EXCLUDED.source,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ test(
|
|||||||
const retryWorkOrderSku = `集成测试重试接单-${suffix}`
|
const retryWorkOrderSku = `集成测试重试接单-${suffix}`
|
||||||
const manualRemoveSku = `集成测试移除人工-${suffix}`
|
const manualRemoveSku = `集成测试移除人工-${suffix}`
|
||||||
const protectedRuleSku = `集成测试强制删除-${suffix}`
|
const protectedRuleSku = `集成测试强制删除-${suffix}`
|
||||||
|
const missingVoucherSku = `集成测试无商品卡券-${suffix}`
|
||||||
|
const reactivatedVoucherSku = `集成测试归档卡券-${suffix}`
|
||||||
const retryWorkOrderRuleKey = `it-retry-${suffix}`
|
const retryWorkOrderRuleKey = `it-retry-${suffix}`
|
||||||
const orderIds: number[] = []
|
const orderIds: number[] = []
|
||||||
const orderItemIds: number[] = []
|
const orderItemIds: number[] = []
|
||||||
@@ -69,8 +71,10 @@ test(
|
|||||||
retryWorkOrderSku,
|
retryWorkOrderSku,
|
||||||
manualRemoveSku,
|
manualRemoveSku,
|
||||||
protectedRuleSku,
|
protectedRuleSku,
|
||||||
|
missingVoucherSku,
|
||||||
|
reactivatedVoucherSku,
|
||||||
]
|
]
|
||||||
let generatedVoucherProductId: number | null = null
|
const createdVoucherProductIds: number[] = []
|
||||||
let autoManualProductId: number | null = null
|
let autoManualProductId: number | null = null
|
||||||
let retryWorkOrderRuleId: number | null = null
|
let retryWorkOrderRuleId: number | null = null
|
||||||
let fallbackProfileId: number | null = null
|
let fallbackProfileId: number | null = null
|
||||||
@@ -251,8 +255,6 @@ test(
|
|||||||
'third_party_voucher',
|
'third_party_voucher',
|
||||||
)
|
)
|
||||||
assert.equal(switchedItem.rows[0]?.item_snapshot_json.isConfigured, false)
|
assert.equal(switchedItem.rows[0]?.item_snapshot_json.isConfigured, false)
|
||||||
generatedVoucherProductId = Number(switchedItem.rows[0]?.canonical_product_id || 0) || null
|
|
||||||
|
|
||||||
const missingAffiliateItemId = await createPendingOrderItem(missingAffiliateSku)
|
const missingAffiliateItemId = await createPendingOrderItem(missingAffiliateSku)
|
||||||
const affiliateFailed = await assignChannelByInternalSku({
|
const affiliateFailed = await assignChannelByInternalSku({
|
||||||
internalSku: missingAffiliateSku,
|
internalSku: missingAffiliateSku,
|
||||||
@@ -644,13 +646,47 @@ test(
|
|||||||
['closed', 'paid'],
|
['closed', 'paid'],
|
||||||
)
|
)
|
||||||
|
|
||||||
await ensureVoucherRuleForSku(voucherSku)
|
assert.equal(await ensureVoucherRuleForSku(missingVoucherSku), false)
|
||||||
const voucherChannel = await query<{ channel: string; source: string }>(
|
const missingVoucherRules = await query<{ count: number }>(
|
||||||
`SELECT channel, source FROM internal_sku_channels
|
`SELECT count(*)::int AS count FROM internal_sku_channels
|
||||||
|
WHERE internal_sku = $1 AND channel = 'third_party_voucher'`,
|
||||||
|
[missingVoucherSku],
|
||||||
|
)
|
||||||
|
assert.equal(missingVoucherRules.rows[0]?.count, 0)
|
||||||
|
|
||||||
|
const voucherProduct = await query<{ id: number }>(
|
||||||
|
`INSERT INTO product_catalog (
|
||||||
|
canonical_sku, canonical_name, category, unit_name, status,
|
||||||
|
attributes_json, created_at, updated_at
|
||||||
|
) VALUES ($1, $2, '卡券', '张', 'active', '{}'::jsonb, NOW(), NOW())
|
||||||
|
RETURNING id`,
|
||||||
|
[`tpv:${voucherSku}`, `卡券展示名-${suffix}`],
|
||||||
|
)
|
||||||
|
createdVoucherProductIds.push(Number(voucherProduct.rows[0]!.id))
|
||||||
|
assert.equal(await ensureVoucherRuleForSku(voucherSku), true)
|
||||||
|
const voucherChannel = await query<{
|
||||||
|
channel: string
|
||||||
|
source: string
|
||||||
|
canonical_product_id: string | null
|
||||||
|
priority: number
|
||||||
|
is_fallback: boolean
|
||||||
|
config_json: Record<string, unknown>
|
||||||
|
}>(
|
||||||
|
`SELECT channel, source, canonical_product_id, priority, is_fallback, config_json
|
||||||
|
FROM internal_sku_channels
|
||||||
WHERE internal_sku = $1 ORDER BY channel`,
|
WHERE internal_sku = $1 ORDER BY channel`,
|
||||||
[voucherSku],
|
[voucherSku],
|
||||||
)
|
)
|
||||||
assert.deepEqual(voucherChannel.rows, [{ channel: 'third_party_voucher', source: 'voucher' }])
|
assert.equal(voucherChannel.rows.length, 1)
|
||||||
|
assert.equal(voucherChannel.rows[0]?.channel, 'third_party_voucher')
|
||||||
|
assert.equal(voucherChannel.rows[0]?.source, 'voucher')
|
||||||
|
assert.equal(
|
||||||
|
Number(voucherChannel.rows[0]?.canonical_product_id),
|
||||||
|
createdVoucherProductIds[0],
|
||||||
|
)
|
||||||
|
assert.equal(voucherChannel.rows[0]?.priority, 10)
|
||||||
|
assert.equal(voucherChannel.rows[0]?.is_fallback, false)
|
||||||
|
assert.equal(voucherChannel.rows[0]?.config_json.voucherSku, voucherSku)
|
||||||
const legacyVoucherRules = await query<{ count: number }>(
|
const legacyVoucherRules = await query<{ count: number }>(
|
||||||
`SELECT count(*)::int AS count FROM sku_channel_rules
|
`SELECT count(*)::int AS count FROM sku_channel_rules
|
||||||
WHERE match_key = $1`,
|
WHERE match_key = $1`,
|
||||||
@@ -692,6 +728,50 @@ test(
|
|||||||
const deletedRules = await deleteChannelRules([savedRule.id])
|
const deletedRules = await deleteChannelRules([savedRule.id])
|
||||||
assert.equal(deletedRules.deletedCount, 1)
|
assert.equal(deletedRules.deletedCount, 1)
|
||||||
assert.deepEqual(deletedRules.deletedIds, [savedRule.id])
|
assert.deepEqual(deletedRules.deletedIds, [savedRule.id])
|
||||||
|
|
||||||
|
const archivedVoucherProduct = await query<{ id: number }>(
|
||||||
|
`INSERT INTO product_catalog (
|
||||||
|
canonical_sku, canonical_name, category, unit_name, status,
|
||||||
|
attributes_json, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, '卡券', '张', 'archived',
|
||||||
|
'{"deletedAt":"2026-01-01T00:00:00.000Z","deletedBy":"integration_test"}'::jsonb,
|
||||||
|
NOW(), NOW()
|
||||||
|
)
|
||||||
|
RETURNING id`,
|
||||||
|
[`tpv:${reactivatedVoucherSku}`, reactivatedVoucherSku],
|
||||||
|
)
|
||||||
|
const archivedVoucherProductId = Number(archivedVoucherProduct.rows[0]!.id)
|
||||||
|
createdVoucherProductIds.push(archivedVoucherProductId)
|
||||||
|
const reactivatedRule = await upsertChannelRule({
|
||||||
|
matchKeyType: 'internal_sku',
|
||||||
|
matchKey: reactivatedVoucherSku,
|
||||||
|
channel: 'third_party_voucher',
|
||||||
|
priority: 80,
|
||||||
|
enabled: true,
|
||||||
|
note: '集成测试重新激活归档商品',
|
||||||
|
adminUserId: null,
|
||||||
|
})
|
||||||
|
const reactivated = await query<{
|
||||||
|
status: string
|
||||||
|
attributes_json: Record<string, unknown>
|
||||||
|
canonical_product_id: string | null
|
||||||
|
priority: number
|
||||||
|
is_fallback: boolean
|
||||||
|
}>(
|
||||||
|
`SELECT product.status, product.attributes_json,
|
||||||
|
channel.canonical_product_id, channel.priority, channel.is_fallback
|
||||||
|
FROM internal_sku_channels channel
|
||||||
|
JOIN product_catalog product ON product.id = channel.canonical_product_id
|
||||||
|
WHERE channel.id = $1`,
|
||||||
|
[reactivatedRule.id],
|
||||||
|
)
|
||||||
|
assert.equal(reactivated.rows[0]?.status, 'active')
|
||||||
|
assert.equal(Number(reactivated.rows[0]?.canonical_product_id), archivedVoucherProductId)
|
||||||
|
assert.equal(reactivated.rows[0]?.priority, 10)
|
||||||
|
assert.equal(reactivated.rows[0]?.is_fallback, false)
|
||||||
|
assert.equal(reactivated.rows[0]?.attributes_json.deletedAt, undefined)
|
||||||
|
assert.equal(typeof reactivated.rows[0]?.attributes_json.reactivatedAt, 'string')
|
||||||
} finally {
|
} finally {
|
||||||
if (orderItemIds.length > 0) {
|
if (orderItemIds.length > 0) {
|
||||||
await query(`DELETE FROM cost_exception_items WHERE order_item_id = ANY($1::bigint[])`, [
|
await query(`DELETE FROM cost_exception_items WHERE order_item_id = ANY($1::bigint[])`, [
|
||||||
@@ -731,32 +811,40 @@ test(
|
|||||||
WHERE provider = '91kaquan' AND shop_id = $1 AND match_key = ANY($2::text[])`,
|
WHERE provider = '91kaquan' AND shop_id = $1 AND match_key = ANY($2::text[])`,
|
||||||
[shopId, internalSkus],
|
[shopId, internalSkus],
|
||||||
)
|
)
|
||||||
if (generatedVoucherProductId) {
|
const generatedProducts = await query<{ id: string }>(
|
||||||
await query(`DELETE FROM sales_daily_facts WHERE canonical_product_id = $1`, [
|
`SELECT id
|
||||||
generatedVoucherProductId,
|
FROM product_catalog
|
||||||
|
WHERE canonicalize_internal_sku(substring(canonical_sku from 5)) = ANY($1::text[])`,
|
||||||
|
[internalSkus],
|
||||||
|
)
|
||||||
|
const generatedProductIds = generatedProducts.rows.map((row) => Number(row.id))
|
||||||
|
if (generatedProductIds.length > 0) {
|
||||||
|
await query(
|
||||||
|
`DELETE FROM cost_snapshots
|
||||||
|
WHERE canonical_product_id = ANY($1::bigint[])
|
||||||
|
OR cost_version_id IN (
|
||||||
|
SELECT id FROM cost_versions WHERE canonical_product_id = ANY($1::bigint[])
|
||||||
|
)`,
|
||||||
|
[generatedProductIds],
|
||||||
|
)
|
||||||
|
await query(
|
||||||
|
`DELETE FROM sales_daily_facts WHERE canonical_product_id = ANY($1::bigint[])`,
|
||||||
|
[generatedProductIds],
|
||||||
|
)
|
||||||
|
await query(
|
||||||
|
`DELETE FROM channel_product_mappings WHERE canonical_product_id = ANY($1::bigint[])`,
|
||||||
|
[generatedProductIds],
|
||||||
|
)
|
||||||
|
await query(`DELETE FROM cost_versions WHERE canonical_product_id = ANY($1::bigint[])`, [
|
||||||
|
generatedProductIds,
|
||||||
])
|
])
|
||||||
await query(`DELETE FROM cost_snapshots WHERE canonical_product_id = $1`, [
|
await query(
|
||||||
generatedVoucherProductId,
|
`DELETE FROM product_channel_capabilities WHERE product_id = ANY($1::bigint[])`,
|
||||||
|
[generatedProductIds],
|
||||||
|
)
|
||||||
|
await query(`DELETE FROM product_catalog WHERE id = ANY($1::bigint[])`, [
|
||||||
|
generatedProductIds,
|
||||||
])
|
])
|
||||||
await query(`DELETE FROM channel_product_mappings WHERE canonical_product_id = $1`, [
|
|
||||||
generatedVoucherProductId,
|
|
||||||
])
|
|
||||||
await query(`DELETE FROM cost_versions WHERE canonical_product_id = $1`, [
|
|
||||||
generatedVoucherProductId,
|
|
||||||
])
|
|
||||||
await query(`DELETE FROM product_catalog WHERE id = $1 AND canonical_sku = $2`, [
|
|
||||||
generatedVoucherProductId,
|
|
||||||
`tpv:${manualSku}`,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
if (autoManualProductId) {
|
|
||||||
await query(`DELETE FROM sales_daily_facts WHERE canonical_product_id = $1`, [
|
|
||||||
autoManualProductId,
|
|
||||||
])
|
|
||||||
await query(`DELETE FROM cost_versions WHERE canonical_product_id = $1`, [
|
|
||||||
autoManualProductId,
|
|
||||||
])
|
|
||||||
await query(`DELETE FROM product_catalog WHERE id = $1`, [autoManualProductId])
|
|
||||||
}
|
}
|
||||||
if (retryWorkOrderRuleId) {
|
if (retryWorkOrderRuleId) {
|
||||||
await query(`DELETE FROM work_product_match_logs WHERE rule_id = $1`, [
|
await query(`DELETE FROM work_product_match_logs WHERE rule_id = $1`, [
|
||||||
|
|||||||
Reference in New Issue
Block a user