chore(master-data): 增加聚水潭分类匹配计划导出

This commit is contained in:
WuYongJie 2026-06-05 10:34:22 +08:00
parent 4ca58ffaa2
commit 4974869f6a
4 changed files with 1049 additions and 0 deletions

View File

@ -25,6 +25,7 @@
"db:cleanup-master-data:validate": "node scripts/apply-master-data-cleanup-plan.js --validate-plan",
"db:export-product-category-review": "node scripts/export-non-integrated-product-category-review.js",
"db:build-product-category-cleanup-plan": "node scripts/build-non-integrated-product-category-cleanup-plan.js",
"db:match-jst-product-category-cleanup-plan": "node scripts/match-jst-product-category-cleanup-plan.js",
"prisma:seed": "tsx prisma/seed.ts",
"open-platform:seed": "tsx scripts/seed-open-platform-demo.ts",
"type-check": "node scripts/run-type-check.js",

View File

@ -0,0 +1,870 @@
/* eslint-disable @typescript-eslint/no-require-imports */
const fs = require('node:fs')
const path = require('node:path')
const mysql = require('mysql2/promise')
const { Pool } = require('pg')
const {
APPLY_CONFIRMATION,
validateCleanupPlanForApply,
writeJsonReport,
} = require('./apply-master-data-cleanup-plan')
const ROOT_DIR = path.resolve(__dirname, '..')
const DEFAULT_OUTPUT_DIR =
'.master-data-cleanup/jst-product-category-match-current'
const DEFAULT_CONFIRMED_BY = '聚水潭来源分类匹配'
const SPU_RULE_LIBRARY_TYPE = 'SPU_RULE'
const SOURCE_PROBES = [
{
label: 'MAY商品快照',
table: 'planning_may_product_snapshot_di',
mode: 'sku',
keyFields: ['may_sku_code', 'product_code', 'spu_code'],
categoryFields: ['category_name'],
nameFields: ['product_name', 'sku_name'],
},
{
label: 'MAY商品评审快照',
table: 'planning_may_product_review_snapshot_di',
mode: 'sku',
keyFields: ['may_sku_code'],
categoryFields: ['category_name'],
nameFields: [],
},
{
label: 'MAY渠道活跃SKU索引',
table: 'planning_may_channel_active_sku_index_di',
mode: 'sku',
keyFields: ['may_sku_code'],
categoryFields: ['category_name'],
nameFields: [],
},
{
label: 'MAY渠道布局快照',
table: 'planning_may_channel_layout_snapshot_di',
mode: 'sku',
keyFields: ['may_sku_code'],
categoryFields: ['category_name'],
nameFields: [],
},
{
label: '淘宝产品记录商品ID',
table: 'taobao_product_records',
mode: 'product',
keyFields: ['item_id', 'requested_item_id', 'source_item_id'],
categoryFields: [
'market_category_name',
'standard_product_type',
'category_level3',
'category_level2',
'fulfillment_category_level3',
'fulfillment_category_level2',
],
nameFields: ['title'],
},
{
label: '淘宝产品记录MAY-SKU',
table: 'taobao_product_records',
mode: 'sku',
keyFields: ['may_sku_code'],
categoryFields: [
'market_category_name',
'standard_product_type',
'category_level3',
'category_level2',
'fulfillment_category_level3',
'fulfillment_category_level2',
],
nameFields: ['title'],
},
{
label: '淘宝产品记录产品名',
table: 'taobao_product_records',
mode: 'name',
keyFields: ['title'],
categoryFields: [
'market_category_name',
'standard_product_type',
'category_level3',
'category_level2',
'fulfillment_category_level3',
'fulfillment_category_level2',
],
nameFields: [],
},
{
label: '市场Top商品',
table: 'ods_lcf_mkt_top_goods_di',
mode: 'product',
keyFields: ['goods_id'],
categoryFields: ['three_category', 'two_category', 'one_category'],
nameFields: ['goods_name'],
},
{
label: '市场洞察商品排行',
table: 'planning_hsds_market_insight_goods_rankings',
mode: 'product',
keyFields: ['goods_id'],
categoryFields: [
'tertiary_category_name',
'secondary_category_name',
'yz_cate_name',
'second_cate_name',
'cate_path_name',
],
nameFields: ['goods_name'],
},
{
label: '排行看板商品项',
table: 'planning_rank_board_item_snapshot_di',
mode: 'product',
keyFields: ['item_id'],
categoryFields: [
'market_category_name',
'category_level3',
'category_level2',
'category_path',
],
nameFields: ['title'],
},
{
label: '图片特征库SKU',
table: 'ai_image_feature_library',
mode: 'sku',
keyFields: ['sku_id'],
categoryFields: ['category_name', 'category_tag'],
nameFields: ['product_name'],
},
]
function readText(value) {
return value === null || value === undefined ? '' : String(value).trim()
}
function normalizeKey(value) {
return readText(value).toUpperCase()
}
function uniqueValues(values) {
return Array.from(new Set(values.map(readText).filter(Boolean)))
}
function uniqueKeys(values) {
return Array.from(new Set(values.map(normalizeKey).filter(Boolean)))
}
function chunk(values, size) {
const chunks = []
for (let index = 0; index < values.length; index += size) {
chunks.push(values.slice(index, index + size))
}
return chunks
}
function parsePositiveInteger(value, name) {
const number = Number(value)
if (!Number.isInteger(number) || number <= 0) {
throw new Error(`${name} 必须是正整数。`)
}
return number
}
function parseArgs(argv = process.argv.slice(2)) {
const options = {
outputDir: DEFAULT_OUTPUT_DIR,
confirmedBy: DEFAULT_CONFIRMED_BY,
limit: 0,
summaryStdout: false,
}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
if (arg === '--output-dir') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) throw new Error('缺少 --output-dir <directory>。')
options.outputDir = value
index += 1
continue
}
if (arg.startsWith('--output-dir=')) {
options.outputDir = arg.slice('--output-dir='.length)
if (!options.outputDir) throw new Error('缺少 --output-dir <directory>。')
continue
}
if (arg === '--confirmed-by') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) throw new Error('缺少 --confirmed-by <name>。')
options.confirmedBy = value
index += 1
continue
}
if (arg.startsWith('--confirmed-by=')) {
options.confirmedBy = arg.slice('--confirmed-by='.length)
if (!options.confirmedBy.trim()) throw new Error('缺少 --confirmed-by <name>。')
continue
}
if (arg === '--limit') {
options.limit = parsePositiveInteger(argv[index + 1], '--limit')
index += 1
continue
}
if (arg.startsWith('--limit=')) {
options.limit = parsePositiveInteger(arg.slice('--limit='.length), '--limit')
continue
}
if (arg === '--summary-stdout') {
options.summaryStdout = true
continue
}
throw new Error(`未知参数:${arg}`)
}
if (!options.confirmedBy.trim()) throw new Error('缺少 --confirmed-by <name>。')
return options
}
function loadEnvValue(name, rootDir = ROOT_DIR) {
if (process.env[name]) return process.env[name]
const envPath = path.join(rootDir, '.env')
if (!fs.existsSync(envPath)) return ''
const content = fs.readFileSync(envPath, 'utf8')
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const separatorIndex = trimmed.indexOf('=')
if (separatorIndex <= 0) continue
const key = trimmed.slice(0, separatorIndex).trim()
if (key !== name) continue
let value = trimmed.slice(separatorIndex + 1).trim()
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
return value
}
return ''
}
function loadDatabaseUrl() {
return loadEnvValue('DATABASE_URL')
}
function createMysqlConnectionConfig() {
const databaseUrl = loadEnvValue('JST_SKU_DATABASE_URL')
if (databaseUrl) return databaseUrl
const host = loadEnvValue('JST_SKU_DB_HOST')
const database = loadEnvValue('JST_SKU_DB_NAME')
const user = loadEnvValue('JST_SKU_DB_USER')
const password = loadEnvValue('JST_SKU_DB_PASSWORD')
if (!host || !database || !user) {
throw new Error('缺少 JST_SKU_DATABASE_URL 或 JST_SKU_DB_HOST/JST_SKU_DB_NAME/JST_SKU_DB_USER。')
}
return {
host,
port: Number(loadEnvValue('JST_SKU_DB_PORT') || '3306'),
database,
user,
password,
charset: loadEnvValue('JST_SKU_DB_CHARSET') || 'utf8mb4',
ssl: loadEnvValue('JST_SKU_DB_SSL') === 'true' ? {} : undefined,
supportBigNumbers: true,
dateStrings: true,
}
}
async function fetchMissingProductCategoryProducts(client, options = {}) {
const values = []
let limitSql = ''
if (options.limit && options.limit > 0) {
values.push(options.limit)
limitSql = `LIMIT $${values.length}`
}
const result = await client.query(`
SELECT
product."id" AS "productId",
product."spu",
product."spuBase",
product."brand",
profile."sourceSpu",
profile."productName",
profile."productCategory",
profile."productDevelopmentKind",
profile."sourceCategory",
profile."sourceItemType"
FROM "IntakeNonIntegratedProductProfile" profile
JOIN "IntakeNonIntegratedProduct" product ON product."id" = profile."productId"
WHERE product."deletedAt" IS NULL
AND NULLIF(btrim(profile."productCategory"), '') IS NULL
ORDER BY profile."sourceSpu", product."spu"
${limitSql}
`, values)
return result.rows.map((row) => ({
productId: readText(row.productId),
spu: readText(row.spu),
spuBase: readText(row.spuBase),
brand: readText(row.brand),
sourceSpu: readText(row.sourceSpu),
productName: readText(row.productName),
productCategory: readText(row.productCategory),
productDevelopmentKind: readText(row.productDevelopmentKind),
sourceCategory: readText(row.sourceCategory),
sourceItemType: readText(row.sourceItemType),
}))
}
async function fetchMissingProductCategorySkuRows(client, productIds) {
if (productIds.length === 0) return []
const result = await client.query(`
SELECT
item."productId",
item."sourceSkuId",
item."barcode",
item."skuCode",
item."model",
item."specName"
FROM "IntakeNonIntegratedSkuItem" item
WHERE item."productId" = ANY($1::text[])
`, [productIds])
return result.rows.map((row) => ({
productId: readText(row.productId),
sourceSkuId: readText(row.sourceSkuId),
barcode: readText(row.barcode),
skuCode: readText(row.skuCode),
model: readText(row.model),
specName: readText(row.specName),
}))
}
async function fetchAllowedProductCategories(client) {
const result = await client.query(`
SELECT DISTINCT btrim("name") AS name
FROM "ProductCategory"
WHERE "isActive" IS TRUE
AND NULLIF(btrim("name"), '') IS NOT NULL
UNION
SELECT DISTINCT btrim("productCategory") AS name
FROM "SpuNumberCounter"
WHERE NULLIF(btrim("productCategory"), '') IS NOT NULL
UNION
SELECT DISTINCT btrim("name") AS name
FROM "IntakeReferenceLibraryItem"
WHERE "libraryType" = $1
AND "isActive" IS TRUE
AND NULLIF(btrim("name"), '') IS NOT NULL
`, [SPU_RULE_LIBRARY_TYPE])
return uniqueValues(result.rows.map((row) => row.name))
}
async function fetchExistingColumns(connection, table, columns) {
const [rows] = await connection.query(
`SELECT column_name AS columnName
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = ?`,
[table],
)
const columnSet = new Set(rows.map((row) => readText(row.columnName)))
return columns.filter((column) => columnSet.has(column))
}
async function fetchRowsByValues(connection, table, fields, keyField, values) {
if (fields.length === 0 || values.length === 0) return []
const rows = []
for (const part of chunk(values, 300)) {
const placeholders = part.map(() => '?').join(', ')
const [batch] = await connection.execute(
`SELECT ${fields.map((field) => `\`${field}\``).join(', ')}
FROM \`${table}\`
WHERE \`${keyField}\` IN (${placeholders})`,
part,
)
rows.push(...batch)
}
return rows
}
function createProductLookup(products) {
const byId = new Map()
const bySourceKey = new Map()
const byName = new Map()
for (const product of products) {
if (product.productId) byId.set(product.productId, product)
for (const key of uniqueKeys([product.sourceSpu, product.spu])) {
bySourceKey.set(key, product)
}
if (product.productName) byName.set(product.productName, product)
}
return { byId, bySourceKey, byName }
}
function createSkuLookup(products, skuRows, jstRows = []) {
const lookup = createProductLookup(products)
const bySku = new Map()
for (const sku of skuRows) {
const product = lookup.byId.get(sku.productId)
if (!product) continue
for (const key of uniqueKeys([sku.sourceSkuId, sku.barcode, sku.skuCode])) {
bySku.set(key, product)
}
}
for (const row of jstRows) {
const product = lookup.bySourceKey.get(normalizeKey(row.__source_spu))
if (!product) continue
const skuCodes = readText(row.sku_codes)
.split(/[,,\s]+/)
.map(normalizeKey)
for (const key of uniqueKeys([row.sku_id, row.sku_code, ...skuCodes])) {
bySku.set(key, product)
}
}
return bySku
}
async function fetchJstSkuRows(connection, sourceKeys) {
const availableColumns = await fetchExistingColumns(connection, 'jst_sku', [
'sku_id',
'i_id',
'name',
'category',
'sku_code',
'sku_codes',
])
if (!availableColumns.includes('sku_id')) {
throw new Error('jst_sku 缺少 sku_id 字段,无法反查聚水潭来源。')
}
const sourceExpression = availableColumns.includes('i_id')
? 'COALESCE(NULLIF(TRIM(CAST(`i_id` AS CHAR)), \'\'), TRIM(CAST(`sku_id` AS CHAR)))'
: 'TRIM(CAST(`sku_id` AS CHAR))'
const rows = []
for (const part of chunk(sourceKeys, 300)) {
if (part.length === 0) continue
const placeholders = part.map(() => '?').join(', ')
const [batch] = await connection.execute(
`SELECT ${availableColumns.map((column) => `\`${column}\``).join(', ')},
${sourceExpression} AS __source_spu
FROM \`jst_sku\`
WHERE ${sourceExpression} IN (${placeholders})`,
part,
)
rows.push(...batch)
}
return rows
}
function resolveProductForProbeRow({ mode, row, keyField, productLookup, skuLookup }) {
if (mode === 'sku') return skuLookup.get(normalizeKey(row[keyField])) || null
if (mode === 'name') return productLookup.byName.get(readText(row[keyField])) || null
return productLookup.bySourceKey.get(normalizeKey(row[keyField])) || null
}
function readProbeRowName(row, nameFields) {
for (const field of nameFields) {
const value = readText(row[field])
if (value) return value
}
return ''
}
function addEvidence(evidenceByProductId, productId, evidence) {
const rows = evidenceByProductId.get(productId) || []
rows.push(evidence)
evidenceByProductId.set(productId, rows)
}
function splitCategoryPath(value) {
return readText(value)
.split(/\s*(?:>|>|,|,|、| - |-|—|–)\s*/)
.map(readText)
.filter(Boolean)
}
function resolveAllowedCategories(rawCategory, allowedCategories) {
const category = readText(rawCategory)
if (!category) return []
if (allowedCategories.has(category)) return [category]
return uniqueValues(splitCategoryPath(category).filter((item) => allowedCategories.has(item)))
}
function normalizeEvidence(rawEvidence, allowedCategories) {
return {
...rawEvidence,
rawCategory: readText(rawEvidence.rawCategory),
resolvedCategories: resolveAllowedCategories(rawEvidence.rawCategory, allowedCategories),
}
}
function summarizeSourceStats(sourceStats, evidenceByProductId) {
return sourceStats.map((stat) => {
const uniqueResolvedProducts = new Set()
const ambiguousResolvedProducts = new Set()
for (const [productId, evidences] of evidenceByProductId.entries()) {
const resolvedCategories = uniqueValues(
evidences
.filter((evidence) => evidence.source === stat.label)
.flatMap((evidence) => evidence.resolvedCategories || []),
)
if (resolvedCategories.length === 1) uniqueResolvedProducts.add(productId)
if (resolvedCategories.length > 1) ambiguousResolvedProducts.add(productId)
}
return {
...stat,
uniqueResolvedProducts: uniqueResolvedProducts.size,
ambiguousResolvedProducts: ambiguousResolvedProducts.size,
}
})
}
async function collectSourceEvidence({
connection,
products,
skuRows,
allowedCategories,
probes = SOURCE_PROBES,
}) {
const productLookup = createProductLookup(products)
const sourceKeys = uniqueKeys(products.flatMap((product) => [product.sourceSpu, product.spu]))
const jstRows = await fetchJstSkuRows(connection, sourceKeys)
const skuLookup = createSkuLookup(products, skuRows, jstRows)
const structuredSkuCodes = uniqueKeys(
skuRows.flatMap((sku) => [sku.sourceSkuId, sku.barcode, sku.skuCode]),
)
const allSkuCodes = uniqueKeys([...structuredSkuCodes, ...Array.from(skuLookup.keys())])
const productNames = uniqueValues(products.map((product) => product.productName))
const evidenceByProductId = new Map()
const sourceStats = []
for (const probe of probes) {
const keyFields = await fetchExistingColumns(connection, probe.table, probe.keyFields)
const categoryFields = await fetchExistingColumns(
connection,
probe.table,
probe.categoryFields,
)
const nameFields = await fetchExistingColumns(connection, probe.table, probe.nameFields)
let rawRows = 0
const productsWithEvidence = new Set()
const productsWithResolved = new Set()
if (keyFields.length > 0 && categoryFields.length > 0) {
const values =
probe.mode === 'sku' ? allSkuCodes : probe.mode === 'name' ? productNames : sourceKeys
for (const keyField of keyFields) {
const fields = uniqueValues([keyField, ...categoryFields, ...nameFields])
const rows = await fetchRowsByValues(connection, probe.table, fields, keyField, values)
rawRows += rows.length
for (const row of rows) {
const product = resolveProductForProbeRow({
mode: probe.mode,
row,
keyField,
productLookup,
skuLookup,
})
if (!product) continue
for (const categoryField of categoryFields) {
const rawCategory = readText(row[categoryField])
if (!rawCategory) continue
const evidence = normalizeEvidence(
{
source: probe.label,
table: probe.table,
mode: probe.mode,
keyField,
key: readText(row[keyField]),
categoryField,
rawCategory,
matchedName: readProbeRowName(row, nameFields),
},
allowedCategories,
)
productsWithEvidence.add(product.productId)
if (evidence.resolvedCategories.length > 0) {
productsWithResolved.add(product.productId)
}
addEvidence(evidenceByProductId, product.productId, evidence)
}
}
}
}
sourceStats.push({
label: probe.label,
table: probe.table,
mode: probe.mode,
keyFields,
categoryFields,
rawRows,
productsWithEvidence: productsWithEvidence.size,
productsWithResolved: productsWithResolved.size,
})
}
return {
evidenceByProductId,
sourceStats: summarizeSourceStats(sourceStats, evidenceByProductId),
jstSummary: {
jstSkuRows: jstRows.length,
jstCategoryNonEmpty: jstRows.filter((row) => readText(row.category)).length,
jstDerivedSkuCodes: skuLookup.size,
},
}
}
function summarizeEvidenceForPlan(evidences, category) {
return evidences
.filter((evidence) => (evidence.resolvedCategories || []).includes(category))
.slice(0, 8)
.map((evidence) => ({
source: evidence.source,
table: evidence.table,
mode: evidence.mode,
keyField: evidence.keyField,
key: evidence.key,
categoryField: evidence.categoryField,
rawCategory: evidence.rawCategory,
matchedName: evidence.matchedName,
}))
}
function buildProductProfilePlanEntry(match) {
return {
productId: match.productId,
spu: match.spu,
sourceSpu: match.sourceSpu,
productName: match.productName,
current: {
productName: match.productName,
productCategory: match.currentProductCategory,
productDevelopmentKind: match.productDevelopmentKind,
missingFields: ['productCategory'],
},
fill: {
productCategory: match.productCategory,
},
evidence: {
source: 'jst-product-category-match',
matchedCategory: match.productCategory,
sourceCount: match.sources.length,
sources: match.sources,
rows: match.sampleEvidence,
},
note: '只读来源库唯一命中正式产品分类字典;冲突和无证据记录不会进入本计划。',
}
}
function buildMatchArtifacts({
products,
evidenceByProductId,
allowedCategories,
confirmedBy = DEFAULT_CONFIRMED_BY,
sourceStats = [],
jstSummary = {},
generatedAt = new Date().toISOString(),
}) {
const uniqueMatches = []
const conflictMatches = []
const unresolvedProducts = []
const categoryCounts = new Map()
for (const product of products) {
const evidences = evidenceByProductId.get(product.productId) || []
const resolvedCategories = uniqueValues(
evidences.flatMap((evidence) => evidence.resolvedCategories || []),
)
resolvedCategories.forEach((category) => {
categoryCounts.set(category, (categoryCounts.get(category) || 0) + 1)
})
if (resolvedCategories.length === 1) {
const productCategory = resolvedCategories[0]
const sources = uniqueValues(
evidences
.filter((evidence) => (evidence.resolvedCategories || []).includes(productCategory))
.map((evidence) => evidence.source),
)
uniqueMatches.push({
productId: product.productId,
spu: product.spu,
sourceSpu: product.sourceSpu,
productName: product.productName,
currentProductCategory: product.productCategory,
productDevelopmentKind: product.productDevelopmentKind,
productCategory,
evidenceCount: evidences.length,
sources,
sampleEvidence: summarizeEvidenceForPlan(evidences, productCategory),
})
continue
}
if (resolvedCategories.length > 1) {
conflictMatches.push({
productId: product.productId,
spu: product.spu,
sourceSpu: product.sourceSpu,
productName: product.productName,
categories: resolvedCategories,
evidence: evidences.slice(0, 12),
})
continue
}
unresolvedProducts.push({
productId: product.productId,
spu: product.spu,
sourceSpu: product.sourceSpu,
productName: product.productName,
evidenceCount: evidences.length,
evidence: evidences.slice(0, 8),
})
}
const cleanupPlan = {
mode: 'jst-product-category-match-cleanup-plan',
confirmation: APPLY_CONFIRMATION,
confirmedBy: confirmedBy.trim(),
skuItems: [],
productProfiles: uniqueMatches.map(buildProductProfilePlanEntry),
qualityDecisionRepairs: [],
preSkuFormalizationMismatches: [],
}
const validation = cleanupPlan.productProfiles.length > 0
? validateCleanupPlanForApply(cleanupPlan)
: null
const categoryHistogram = Array.from(categoryCounts.entries())
.sort((left, right) => right[1] - left[1])
.map(([category, count]) => ({ category, count }))
const summary = {
mode: 'jst-product-category-match',
generatedAt,
missingProducts: products.length,
allowedCategoryCount: allowedCategories.size,
totalProductsWithAnyEvidence: Array.from(evidenceByProductId.keys()).length,
totalProductsWithResolved: uniqueMatches.length + conflictMatches.length,
uniqueResolvedProducts: uniqueMatches.length,
ambiguousResolvedProducts: conflictMatches.length,
unresolvedProducts: unresolvedProducts.length,
cleanupPlanRows: cleanupPlan.productProfiles.length,
cleanupPlanAppliedFields: validation?.productProfiles?.appliedFields ?? 0,
categoryHistogram,
...jstSummary,
}
return {
summary,
sourceStats,
uniqueMatches,
conflictMatches,
unresolvedProducts,
cleanupPlan,
validation,
}
}
async function run(options) {
const databaseUrl = loadDatabaseUrl()
if (!databaseUrl) throw new Error('缺少 DATABASE_URL。')
const pgPool = new Pool({ connectionString: databaseUrl })
const mysqlConnection = await mysql.createConnection(createMysqlConnectionConfig())
try {
const pgClient = await pgPool.connect()
let products
let skuRows
let allowedCategories
try {
products = await fetchMissingProductCategoryProducts(pgClient, options)
skuRows = await fetchMissingProductCategorySkuRows(
pgClient,
products.map((product) => product.productId),
)
allowedCategories = new Set(await fetchAllowedProductCategories(pgClient))
} finally {
pgClient.release()
}
const evidence = await collectSourceEvidence({
connection: mysqlConnection,
products,
skuRows,
allowedCategories,
})
const artifacts = buildMatchArtifacts({
products,
evidenceByProductId: evidence.evidenceByProductId,
allowedCategories,
confirmedBy: options.confirmedBy,
sourceStats: evidence.sourceStats,
jstSummary: evidence.jstSummary,
})
const outputDir = options.outputDir
const summaryFile = writeJsonReport(path.join(outputDir, 'summary.json'), artifacts.summary)
const reportFile = writeJsonReport(path.join(outputDir, 'report.json'), {
summary: artifacts.summary,
sourceStats: artifacts.sourceStats,
uniqueMatches: artifacts.uniqueMatches,
conflictMatches: artifacts.conflictMatches,
unresolvedProducts: artifacts.unresolvedProducts,
})
const cleanupPlanFile = writeJsonReport(
path.join(outputDir, 'cleanup-plan.json'),
artifacts.cleanupPlan,
)
return {
...artifacts.summary,
summaryFile,
reportFile,
cleanupPlanFile,
}
} finally {
await mysqlConnection.end()
await pgPool.end()
}
}
async function main() {
const options = parseArgs(process.argv.slice(2))
const result = await run(options)
if (options.summaryStdout) {
console.log(JSON.stringify(result, null, 2))
} else {
console.log(`已导出聚水潭来源分类匹配报告:${result.reportFile}`)
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
})
}
module.exports = {
DEFAULT_CONFIRMED_BY,
DEFAULT_OUTPUT_DIR,
SOURCE_PROBES,
parseArgs,
readText,
normalizeKey,
splitCategoryPath,
resolveAllowedCategories,
normalizeEvidence,
buildMatchArtifacts,
buildProductProfilePlanEntry,
createProductLookup,
createSkuLookup,
}

View File

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-require-imports */
const fs = require('node:fs')
const path = require('node:path')
@ -1157,6 +1158,10 @@ const approvedRawSqlMasterDataReadPaths = new Map([
'scripts/export-non-integrated-product-category-review.js',
'非集成产品分类审阅导出脚本只允许只读读取产品/profile/SKU 样例,生成业务确认清单和 cleanup plan 模板;不得直接写库或猜测分类。',
],
[
'scripts/match-jst-product-category-cleanup-plan.js',
'聚水潭来源分类匹配脚本只允许只读读取非集成产品/profile/SKU 和正式分类字典,结合 JST 来源库生成可审计 cleanup plan;不得直接写库或关键词猜分类。',
],
[
'scripts/apply-pre-sku-formalization-repair.js',
'准 SKU 生命周期专项修复入口允许读取 PreSku、ProductSKU、DraftGroup、包装和库位关系,用于 currentEvidence 校验、dry-run 和 apply 前事务内复核。',
@ -1217,6 +1222,17 @@ const approvedRawSqlMasterDataReadTableScopes = new Map([
'IntakeNonIntegratedSkuItem',
]),
],
[
'scripts/match-jst-product-category-cleanup-plan.js',
new Set([
'IntakeNonIntegratedProduct',
'IntakeNonIntegratedProductProfile',
'IntakeNonIntegratedSkuItem',
'IntakeReferenceLibraryItem',
'ProductCategory',
'SpuNumberCounter',
]),
],
[
'scripts/apply-pre-sku-formalization-repair.js',
new Set([

View File

@ -0,0 +1,162 @@
const {
buildMatchArtifacts,
createSkuLookup,
normalizeEvidence,
parseArgs,
resolveAllowedCategories,
splitCategoryPath,
} = require('../../scripts/match-jst-product-category-cleanup-plan')
function product(overrides = {}) {
return {
productId: 'product-1',
spu: 'SPU001',
sourceSpu: '1001',
productName: '书桌 A',
productCategory: '',
productDevelopmentKind: 'product',
...overrides,
}
}
function evidence(overrides = {}) {
return normalizeEvidence(
{
source: '市场Top商品',
table: 'ods_lcf_mkt_top_goods_di',
mode: 'product',
keyField: 'goods_id',
key: '1001',
categoryField: 'three_category',
rawCategory: '住宅家具,桌类,书桌',
matchedName: '书桌 A',
...overrides,
},
new Set(['书桌', '电脑桌', '书柜']),
)
}
describe('match-jst-product-category-cleanup-plan', () => {
it('parses CLI options', () => {
expect(parseArgs([
'--output-dir',
'.out',
'--confirmed-by=企划负责人',
'--limit',
'20',
'--summary-stdout',
])).toEqual({
outputDir: '.out',
confirmedBy: '企划负责人',
limit: 20,
summaryStdout: true,
})
expect(() => parseArgs(['--limit=0'])).toThrow('正整数')
})
it('resolves allowed category from a source category path', () => {
const allowed = new Set(['书桌', '电脑桌'])
expect(splitCategoryPath('住宅家具,桌类,书桌')).toEqual(['住宅家具', '桌类', '书桌'])
expect(resolveAllowedCategories('住宅家具 > 桌类 > 书桌', allowed)).toEqual(['书桌'])
expect(resolveAllowedCategories('电脑桌', allowed)).toEqual(['电脑桌'])
expect(resolveAllowedCategories('桌类', allowed)).toEqual([])
})
it('builds a validate-ready cleanup plan for unique resolved matches', () => {
const products = [product()]
const evidenceByProductId = new Map([
['product-1', [evidence(), evidence({ rawCategory: '书桌', source: '市场洞察商品排行' })]],
])
const artifacts = buildMatchArtifacts({
products,
evidenceByProductId,
allowedCategories: new Set(['书桌', '电脑桌']),
confirmedBy: '企划负责人',
sourceStats: [],
jstSummary: { jstSkuRows: 1, jstCategoryNonEmpty: 0 },
generatedAt: '2026-06-05T00:00:00.000Z',
})
expect(artifacts.summary).toMatchObject({
missingProducts: 1,
uniqueResolvedProducts: 1,
ambiguousResolvedProducts: 0,
unresolvedProducts: 0,
cleanupPlanRows: 1,
cleanupPlanAppliedFields: 1,
jstSkuRows: 1,
jstCategoryNonEmpty: 0,
})
expect(artifacts.cleanupPlan).toMatchObject({
confirmation: 'I_UNDERSTAND_THIS_UPDATES_MASTER_DATA',
confirmedBy: '企划负责人',
productProfiles: [
expect.objectContaining({
productId: 'product-1',
sourceSpu: '1001',
fill: { productCategory: '书桌' },
current: expect.objectContaining({
productCategory: '',
productDevelopmentKind: 'product',
missingFields: ['productCategory'],
}),
}),
],
})
expect(artifacts.validation.productProfiles).toMatchObject({
actionableRows: 1,
appliedFields: 1,
remainingMissingFieldCounts: {},
})
})
it('keeps conflicting source categories out of the cleanup plan', () => {
const products = [product()]
const evidenceByProductId = new Map([
[
'product-1',
[
evidence({ rawCategory: '书桌' }),
evidence({ rawCategory: '电脑桌', source: 'MAY商品快照' }),
],
],
])
const artifacts = buildMatchArtifacts({
products,
evidenceByProductId,
allowedCategories: new Set(['书桌', '电脑桌']),
confirmedBy: '企划负责人',
})
expect(artifacts.summary).toMatchObject({
uniqueResolvedProducts: 0,
ambiguousResolvedProducts: 1,
unresolvedProducts: 0,
cleanupPlanRows: 0,
})
expect(artifacts.cleanupPlan.productProfiles).toEqual([])
expect(artifacts.conflictMatches[0]).toMatchObject({
productId: 'product-1',
categories: ['书桌', '电脑桌'],
})
})
it('uses jst sku codes to map source rows back to products', () => {
const products = [product({ sourceSpu: 'i-001' })]
const skuRows = [{ productId: 'product-1', sourceSkuId: '', barcode: '', skuCode: '' }]
const skuLookup = createSkuLookup(products, skuRows, [
{
__source_spu: 'i-001',
sku_id: 'sku-1',
sku_code: 'may-1',
sku_codes: 'alias-1, alias-2',
},
])
expect(skuLookup.get('SKU-1')?.productId).toBe('product-1')
expect(skuLookup.get('MAY-1')?.productId).toBe('product-1')
expect(skuLookup.get('ALIAS-2')?.productId).toBe('product-1')
})
})