fix(fulfillment): 封堵重复载体并完善统一SKU映射

This commit is contained in:
yml2213
2026-11-03 12:28:05 +08:00
parent 3ba808b814
commit e6763882f5
27 changed files with 1506 additions and 45 deletions
@@ -84,7 +84,7 @@ async function main() {
), '')) ~ '^[0-9]+$')::int AS numeric_only,
count(*) FILTER (WHERE btrim(COALESCE(resolve_internal_sku(
manual_sku_key, sku_name, sku_code
), '')) ~ '[一-龥]')::int AS with_cjk
), '')) ~ '[㐀-䶿一-鿿豈-﫿𠀀-𯨟𰀀-𲎯]')::int AS with_cjk
FROM order_items
WHERE fulfillment_status = 'assigned'
`,
@@ -21,8 +21,8 @@ async function main() {
SELECT o.provider, o.shop_id, oi.sku_code, MIN(oi.sku_name) AS sku_name, count(*)::int AS total
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE COALESCE(canonicalize_internal_sku(oi.manual_sku_key), '') !~ '[一-龥]'
AND btrim(COALESCE(oi.sku_name, '')) ~ '[一-龥]'
WHERE COALESCE(canonicalize_internal_sku(oi.manual_sku_key), '') !~ '[㐀-䶿一-鿿豈-﫿𠀀-𯨟𰀀-𲎯]'
AND btrim(COALESCE(oi.sku_name, '')) ~ '[㐀-䶿一-鿿豈-﫿𠀀-𯨟𰀀-𲎯]'
GROUP BY 1, 2, 3
ORDER BY 5 DESC
`,
@@ -37,8 +37,8 @@ async function main() {
SELECT o.provider, o.shop_id, oi.sku_code, count(*)::int AS total
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE COALESCE(canonicalize_internal_sku(oi.manual_sku_key), '') !~ '[一-龥]'
AND btrim(COALESCE(oi.sku_name, '')) !~ '[一-龥]'
WHERE COALESCE(canonicalize_internal_sku(oi.manual_sku_key), '') !~ '[㐀-䶿一-鿿豈-﫿𠀀-𯨟𰀀-𲎯]'
AND btrim(COALESCE(oi.sku_name, '')) !~ '[㐀-䶿一-鿿豈-﫿𠀀-𯨟𰀀-𲎯]'
GROUP BY 1, 2, 3
ORDER BY 4 DESC
`,
@@ -44,7 +44,7 @@ async function resolveInternalSku(rule: LegacyRule): Promise<string> {
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.sku_code = $1
AND btrim(COALESCE(oi.manual_sku_key, '')) ~ '[一-龥]'
AND btrim(COALESCE(oi.manual_sku_key, '')) ~ '[㐀-䶿一-鿿豈-﫿𠀀-𯨟𰀀-𲎯]'
${scope}
LIMIT 2
`,
@@ -0,0 +1,201 @@
/**
* 合并规范化后重复的内部 SKU 渠道配置。
*
* 默认只读;确认后加 --apply。
* 用法:npx tsx scripts/normalize-internal-sku-configs.ts [--apply]
*/
import { closeDb, query, withTransaction } from '../src/db/client.js'
import { canonicalizeInternalSku } from '../src/services/fulfillment/internal-sku-resolver.js'
type ChannelRow = {
id: number
internal_sku: string
channel: string
priority: number
is_fallback: boolean
enabled: boolean
canonical_product_id: number | null
config_json: Record<string, unknown>
source: string
note: string
created_by_admin_user_id: number | null
}
type DuplicateGroup = {
internalSku: string
channel: string
rows: ChannelRow[]
}
type MappingRow = {
id: number
provider: string
shop_id: string
match_type: string
match_value: string
internal_sku: string
priority: number
enabled: boolean
source: string
note: string
}
async function main() {
const apply = process.argv.includes('--apply')
const result = await query<ChannelRow>(
`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
ORDER BY canonicalize_internal_sku(internal_sku), channel, is_fallback, priority, id`,
)
const groups = new Map<string, DuplicateGroup>()
for (const row of result.rows) {
const internalSku = canonicalizeInternalSku(row.internal_sku)
const key = `${internalSku}|${row.channel}`
const group = groups.get(key) || { internalSku, channel: row.channel, rows: [] }
group.rows.push({ ...row, id: Number(row.id), priority: Number(row.priority) })
groups.set(key, group)
}
const duplicates = [...groups.values()].filter((group) => group.rows.length > 1)
const mappingRows = await query<MappingRow>(
`SELECT id, provider, shop_id, match_type, match_value, internal_sku,
priority, enabled, source, note
FROM internal_sku_mappings
ORDER BY provider, shop_id, match_type, canonicalize_internal_sku(match_value), enabled DESC, priority, id`,
)
const duplicateMappings = new Map<string, MappingRow[]>()
for (const row of mappingRows.rows) {
const key = [
row.provider,
row.shop_id,
row.match_type,
canonicalizeInternalSku(row.match_value),
].join('|')
const list = duplicateMappings.get(key) || []
list.push({ ...row, id: Number(row.id), priority: Number(row.priority) })
duplicateMappings.set(key, list)
}
const mappingDuplicates = [...duplicateMappings.values()].filter((rows) => rows.length > 1)
if (apply) {
for (const group of duplicates) {
await withTransaction(async (client) => {
const rows = (
await client.query<ChannelRow>(
`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 id = ANY($1::bigint[])
ORDER BY is_fallback, priority, id
FOR UPDATE`,
[group.rows.map((row) => row.id)],
)
).rows.map((row) => ({ ...row, id: Number(row.id), priority: Number(row.priority) }))
if (rows.length <= 1) return
const keeper = rows[0]!
const primaryRows = rows.filter((row) => !row.is_fallback)
const configJson = rows.reduce<Record<string, unknown>>(
(merged, row) => ({ ...merged, ...(row.config_json || {}) }),
{},
)
const source = rows.find((row) => row.source === 'operator')?.source || keeper.source
const note = [
...new Set(rows.map((row) => String(row.note || '').trim()).filter(Boolean)),
].join(' / ')
await client.query(`DELETE FROM internal_sku_channels WHERE id = ANY($1::bigint[])`, [
rows.slice(1).map((row) => row.id),
])
await client.query(
`UPDATE internal_sku_channels
SET internal_sku = $2,
priority = $3,
is_fallback = $4,
enabled = $5,
canonical_product_id = $6,
config_json = $7::jsonb,
source = $8,
note = $9,
created_by_admin_user_id = $10,
updated_at = NOW()
WHERE id = $1`,
[
keeper.id,
group.internalSku,
Math.min(...(primaryRows.length > 0 ? primaryRows : rows).map((row) => row.priority)),
primaryRows.length === 0,
rows.some((row) => row.enabled),
rows.find((row) => row.canonical_product_id != null)?.canonical_product_id || null,
JSON.stringify(configJson),
source,
note,
rows.find((row) => row.created_by_admin_user_id != null)?.created_by_admin_user_id ||
null,
],
)
})
}
for (const rows of mappingDuplicates) {
await withTransaction(async (client) => {
const keeper = rows[0]!
await client.query(`DELETE FROM internal_sku_mappings WHERE id = ANY($1::bigint[])`, [
rows.slice(1).map((row) => row.id),
])
await client.query(
`UPDATE internal_sku_mappings
SET match_value = $2, internal_sku = $3, priority = $4,
enabled = $5, source = $6, note = $7, updated_at = NOW()
WHERE id = $1`,
[
keeper.id,
canonicalizeInternalSku(keeper.match_value),
canonicalizeInternalSku(keeper.internal_sku),
Math.min(...rows.map((row) => row.priority)),
rows.some((row) => row.enabled),
keeper.source,
[...new Set(rows.map((row) => String(row.note || '').trim()).filter(Boolean))].join(
' / ',
),
],
)
})
}
}
const duplicateManualConfigs = await query<{ label: string; total: number }>(
`SELECT canonicalize_internal_sku(sku_key) AS label, count(*)::int AS total
FROM manual_fulfillment_configs
GROUP BY canonicalize_internal_sku(sku_key)
HAVING count(*) > 1
ORDER BY count(*) DESC, label
LIMIT 20`,
)
console.log(
JSON.stringify(
{
apply,
duplicateGroups: duplicates.length,
duplicateRows: duplicates.reduce((total, group) => total + group.rows.length, 0),
duplicateMappingGroups: mappingDuplicates.length,
duplicateMappingRows: mappingDuplicates.reduce((total, rows) => total + rows.length, 0),
sample: duplicates.slice(0, 20).map((group) => ({
internalSku: group.internalSku,
channel: group.channel,
ids: group.rows.map((row) => row.id),
})),
duplicateManualConfigs: duplicateManualConfigs.rows,
},
null,
2,
),
)
}
main()
.catch((error) => {
console.error(error)
process.exitCode = 1
})
.finally(() => closeDb())
@@ -160,6 +160,54 @@ async function main() {
)
).rows
checks.ambiguousCarrierConfigs = (
await query<CountRow>(
`SELECT canonicalize_internal_sku(canonical_name) || '/' || channel AS label,
count(DISTINCT canonical_sku)::int AS total
FROM (
SELECT canonical_name, canonical_sku,
CASE
WHEN canonical_sku LIKE 'aff:%' THEN 'affiliate_dash'
WHEN canonical_sku LIKE 'tpl:%' OR canonical_sku LIKE 'wo:%' THEN 'work_order'
WHEN canonical_sku LIKE 'tpv:%' THEN 'third_party_voucher'
END AS channel
FROM product_catalog
WHERE status = 'active'
AND (canonical_sku LIKE 'aff:%' OR canonical_sku LIKE 'tpl:%'
OR canonical_sku LIKE 'wo:%' OR canonical_sku LIKE 'tpv:%')
) carrier
WHERE channel IS NOT NULL
GROUP BY canonicalize_internal_sku(canonical_name), channel
HAVING count(DISTINCT canonical_sku) > 1
ORDER BY count(DISTINCT canonical_sku) DESC, label`,
)
).rows
checks.ambiguousManualConfigs = (
await query<CountRow>(
`SELECT canonicalize_internal_sku(sku_key) AS label, count(*)::int AS total
FROM manual_fulfillment_configs
WHERE enabled = TRUE
GROUP BY canonicalize_internal_sku(sku_key)
HAVING count(*) > 1
ORDER BY count(*) DESC, label`,
)
).rows
checks.ambiguousInternalSkuMappings = (
await query<CountRow>(
`SELECT provider || '/' || shop_id || '/' || match_type || '/' ||
canonicalize_internal_sku(match_value) AS label,
count(*)::int AS total
FROM internal_sku_mappings
WHERE enabled = TRUE
GROUP BY provider, shop_id, match_type, canonicalize_internal_sku(match_value)
HAVING count(*) > 1
ORDER BY count(*) DESC, label
LIMIT 200`,
)
).rows
checks.pendingWithoutChineseInternalSku = (
await query<CountRow>(
`SELECT 'pending_without_chinese_internal_sku' AS label, count(*)::int AS total
@@ -262,6 +310,12 @@ async function main() {
failed.push('存在规范化后重复的内部 SKU 渠道配置')
if ((checks.incompleteCarrierConfigs as CountRow[]).length > 0)
failed.push('存在缺少 workRuleKey 或人工履约配置的启用渠道')
if ((checks.ambiguousCarrierConfigs as CountRow[]).length > 0)
failed.push('存在同一中文内部 SKU 的多个载体配置')
if ((checks.ambiguousManualConfigs as CountRow[]).length > 0)
failed.push('存在规范化后重复的人工履约配置')
if ((checks.ambiguousInternalSkuMappings as CountRow[]).length > 0)
failed.push('存在规范化后重复的内部 SKU 映射')
if ((checks.pendingWithoutChineseInternalSku as CountRow).total > 0)
failed.push('存在无法归一到中文内部 SKU 的 pending 订单项')
if ((checks.enabledLegacyRules as CountRow[]).length > 0)