Files
order_site/apps/backend/scripts/normalize-internal-sku-configs.ts
T

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())