/** * 合并规范化后重复的内部 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 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( `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() 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( `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() 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( `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>( (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())