fix(fulfillment): 封堵重复载体并完善统一SKU映射
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user