202 lines
6.9 KiB
TypeScript
202 lines
6.9 KiB
TypeScript
/**
|
|
* 合并规范化后重复的内部 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())
|