furniture-3d-system-Wu/furniture-3d-system/scripts/archive-jst-unresolved-non-integrated-products.js

1210 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* eslint-disable @typescript-eslint/no-require-imports */
const fs = require('node:fs')
const path = require('node:path')
const { Pool } = require('pg')
const {
assertApplyExecutionEvidence,
assertValidAuditText,
cleanupDatabaseIdentity,
readText,
writeJsonReport,
} = require('./apply-master-data-cleanup-plan')
const ROOT_DIR = path.resolve(__dirname, '..')
const ARCHIVE_PLAN_MODE = 'jst-unresolved-non-integrated-product-archive-plan'
const APPLY_CONFIRMATION = 'I_UNDERSTAND_THIS_ARCHIVES_JST_RESIDUAL_DATA'
const DEFAULT_CONFIRMED_BY = '聚水潭历史残留清理确认'
const QUALITY_DECISION_STATUS_APPROVED_INCOMPLETE = 'APPROVED_INCOMPLETE'
const QUALITY_DECISION_STATUS_RESOLVED = 'RESOLVED'
const QUALITY_DECISION_TARGET_SKU_ITEM = 'IntakeNonIntegratedSkuItem'
const QUALITY_DECISION_TARGET_PRODUCT_PROFILE = 'IntakeNonIntegratedProductProfile'
const ARCHIVE_PLAN_TOP_LEVEL_KEYS = new Set([
'mode',
'generatedAt',
'databaseIdentity',
'sourceMatchReport',
'sourceCategoryCleanupPlan',
'confirmation',
'confirmedBy',
'summary',
'archiveCandidates',
'blockedProducts',
'excludedResolvedProducts',
])
const ARCHIVE_CANDIDATE_KEYS = new Set([
'productId',
'spu',
'sourceSpu',
'productName',
'current',
'archiveReason',
'matchClassification',
'evidence',
'referenceSummary',
])
function parseArgs(argv = process.argv.slice(2)) {
const args = {
apply: false,
planPath: '',
outputPath: '',
matchReportPath: '',
categoryCleanupPlanPath: '',
confirmedBy: DEFAULT_CONFIRMED_BY,
confirm: '',
expectedDatabaseIdentity: '',
expectedBackupSha256: '',
limit: 0,
summaryStdout: false,
}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
if (arg === '--apply') {
args.apply = true
continue
}
if (arg === '--plan') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) throw new Error('缺少 --plan <json-file>。')
args.planPath = value
index += 1
continue
}
if (arg.startsWith('--plan=')) {
args.planPath = arg.slice('--plan='.length)
if (!args.planPath) throw new Error('缺少 --plan <json-file>。')
continue
}
if (arg === '--output') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) throw new Error('缺少 --output <json-file>。')
args.outputPath = value
index += 1
continue
}
if (arg.startsWith('--output=')) {
args.outputPath = arg.slice('--output='.length)
if (!args.outputPath) throw new Error('缺少 --output <json-file>。')
continue
}
if (arg === '--match-report') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) throw new Error('缺少 --match-report <json-file>。')
args.matchReportPath = value
index += 1
continue
}
if (arg.startsWith('--match-report=')) {
args.matchReportPath = arg.slice('--match-report='.length)
if (!args.matchReportPath) throw new Error('缺少 --match-report <json-file>。')
continue
}
if (arg === '--category-cleanup-plan') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) {
throw new Error('缺少 --category-cleanup-plan <json-file>。')
}
args.categoryCleanupPlanPath = value
index += 1
continue
}
if (arg.startsWith('--category-cleanup-plan=')) {
args.categoryCleanupPlanPath = arg.slice('--category-cleanup-plan='.length)
if (!args.categoryCleanupPlanPath) {
throw new Error('缺少 --category-cleanup-plan <json-file>。')
}
continue
}
if (arg === '--confirmed-by') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) throw new Error('缺少 --confirmed-by <name>。')
args.confirmedBy = value
index += 1
continue
}
if (arg.startsWith('--confirmed-by=')) {
args.confirmedBy = arg.slice('--confirmed-by='.length)
if (!args.confirmedBy.trim()) throw new Error('缺少 --confirmed-by <name>。')
continue
}
if (arg === '--confirm') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) throw new Error('缺少 --confirm <token>。')
args.confirm = value
index += 1
continue
}
if (arg.startsWith('--confirm=')) {
args.confirm = arg.slice('--confirm='.length)
if (!args.confirm) throw new Error('缺少 --confirm <token>。')
continue
}
if (arg === '--expected-database-identity') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) {
throw new Error('缺少 --expected-database-identity <identity>。')
}
args.expectedDatabaseIdentity = value
index += 1
continue
}
if (arg.startsWith('--expected-database-identity=')) {
args.expectedDatabaseIdentity = arg.slice('--expected-database-identity='.length)
if (!args.expectedDatabaseIdentity) {
throw new Error('缺少 --expected-database-identity <identity>。')
}
continue
}
if (arg === '--expected-backup-sha256') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) {
throw new Error('缺少 --expected-backup-sha256 <sha256>。')
}
args.expectedBackupSha256 = value.toLowerCase()
index += 1
continue
}
if (arg.startsWith('--expected-backup-sha256=')) {
args.expectedBackupSha256 = arg.slice('--expected-backup-sha256='.length).toLowerCase()
if (!args.expectedBackupSha256) {
throw new Error('缺少 --expected-backup-sha256 <sha256>。')
}
continue
}
if (arg === '--limit') {
args.limit = parsePositiveInteger(argv[index + 1], '--limit')
index += 1
continue
}
if (arg.startsWith('--limit=')) {
args.limit = parsePositiveInteger(arg.slice('--limit='.length), '--limit')
continue
}
if (arg === '--summary-stdout') {
args.summaryStdout = true
continue
}
throw new Error(`未知参数:${arg}`)
}
if (args.apply && !args.planPath) throw new Error('真正落库必须使用 --plan <json-file>。')
if (args.confirm && !args.apply) throw new Error('--confirm 只允许和 --apply 一起使用。')
if (!args.planPath && !args.matchReportPath) {
throw new Error('生成归档计划必须提供 --match-report <report.json>,避免误归档可补分类的数据。')
}
if (!args.confirmedBy.trim()) throw new Error('缺少 --confirmed-by <name>。')
return args
}
function parsePositiveInteger(value, name) {
const number = Number(value)
if (!Number.isInteger(number) || number <= 0) {
throw new Error(`${name} 必须是正整数。`)
}
return number
}
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 readJsonFile(filePath) {
const absolutePath = path.resolve(ROOT_DIR, filePath)
return JSON.parse(fs.readFileSync(absolutePath, 'utf8'))
}
function uniqueValues(values) {
return Array.from(new Set(values.map(readText).filter(Boolean)))
}
function normalizeJstSource(value) {
return readText(value).toLowerCase().replace(/[-\s]+/g, '_')
}
function isJstSourceValue(value) {
const normalized = normalizeJstSource(value)
return normalized === 'jst_sku' || normalized === 'jstsku'
}
function normalizeSkuRows(value) {
if (Array.isArray(value)) return value
if (!value) return []
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
return []
}
function normalizeProductRow(row) {
const skuRows = normalizeSkuRows(row.skuRows).map((item) => ({
id: readText(item.id),
skuItemId: readText(item.skuItemId),
sourceSkuId: readText(item.sourceSkuId),
skuCode: readText(item.skuCode),
barcode: readText(item.barcode),
source: readText(item.source),
sourceTable: readText(item.sourceTable),
}))
const identityReasons = []
const productId = readText(row.productId)
const sourceSpu = readText(row.sourceSpu)
if (productId.startsWith('jst-stock-')) identityReasons.push('product-id:jst-stock-prefix')
if (sourceSpu) identityReasons.push('profile.sourceSpu')
if (isJstSourceValue(row.profileSource)) identityReasons.push('profile.source:jst_sku')
if (skuRows.some((item) => isJstSourceValue(item.source) || isJstSourceValue(item.sourceTable))) {
identityReasons.push('skuItems.source:jst_sku')
}
return {
productId,
spu: readText(row.spu),
sourceSpu,
productName: readText(row.productName),
productCategory: readText(row.productCategory),
productDevelopmentKind: readText(row.productDevelopmentKind),
sourceCategory: readText(row.sourceCategory),
sourceItemType: readText(row.sourceItemType),
profileSource: readText(row.profileSource),
skuItemCount: Number(row.skuItemCount || skuRows.length || 0),
pricingRowCount: Number(row.pricingRowCount || 0),
skuRows,
jstIdentityReasons: uniqueValues(identityReasons),
}
}
function isJstArchiveIdentity(product) {
return product.jstIdentityReasons.length > 0
}
function loadMatchClassification(matchReportPath, categoryCleanupPlanPath = '') {
const uniqueResolvedProductIds = new Set()
const conflictingProductIds = new Set()
const unresolvedProductIds = new Set()
const missingFromReportProductIds = new Set()
if (matchReportPath) {
const report = readJsonFile(matchReportPath)
for (const row of report.uniqueMatches || []) {
const productId = readText(row.productId)
if (productId) uniqueResolvedProductIds.add(productId)
}
for (const row of report.conflictMatches || []) {
const productId = readText(row.productId)
if (productId) conflictingProductIds.add(productId)
}
for (const row of report.unresolvedProducts || []) {
const productId = readText(row.productId)
if (productId) unresolvedProductIds.add(productId)
}
}
if (categoryCleanupPlanPath) {
const plan = readJsonFile(categoryCleanupPlanPath)
for (const row of plan.productProfiles || []) {
const productId = readText(row.productId)
if (productId) uniqueResolvedProductIds.add(productId)
}
}
return {
uniqueResolvedProductIds,
conflictingProductIds,
unresolvedProductIds,
missingFromReportProductIds,
}
}
function classifyMatch(productId, matchClassification) {
if (matchClassification.uniqueResolvedProductIds.has(productId)) return 'unique-resolved'
if (matchClassification.conflictingProductIds.has(productId)) return 'conflicting-source-category'
if (matchClassification.unresolvedProductIds.has(productId)) return 'unresolved-source-category'
matchClassification.missingFromReportProductIds.add(productId)
return 'missing-from-match-report'
}
function emptyReferenceSummary() {
return {
total: 0,
approvalWorkflowCount: 0,
qualityInspectionCount: 0,
preSkuReferenceCount: 0,
draftGroupReferenceCount: 0,
references: [],
}
}
function cloneReferenceSummary(referenceSummary = emptyReferenceSummary()) {
return {
total: Number(referenceSummary.total || 0),
approvalWorkflowCount: Number(referenceSummary.approvalWorkflowCount || 0),
qualityInspectionCount: Number(referenceSummary.qualityInspectionCount || 0),
preSkuReferenceCount: Number(referenceSummary.preSkuReferenceCount || 0),
draftGroupReferenceCount: Number(referenceSummary.draftGroupReferenceCount || 0),
references: Array.isArray(referenceSummary.references) ? referenceSummary.references : [],
}
}
function addReference(referenceSummaries, productId, reference) {
const summary = referenceSummaries.get(productId) || emptyReferenceSummary()
const count = Number(reference.count || 0)
summary.total += count
if (reference.type === 'approval-workflow') summary.approvalWorkflowCount += count
if (reference.type === 'quality-inspection') summary.qualityInspectionCount += count
if (reference.type === 'pre-sku') summary.preSkuReferenceCount += count
if (reference.type === 'draft-group') summary.draftGroupReferenceCount += count
summary.references.push({
type: reference.type,
count,
samples: Array.isArray(reference.samples) ? reference.samples.slice(0, 8) : [],
})
referenceSummaries.set(productId, summary)
}
function buildCurrentEvidence(product) {
return {
productCategory: product.productCategory,
productDevelopmentKind: product.productDevelopmentKind,
skuItemCount: product.skuItemCount,
pricingRowCount: product.pricingRowCount,
missingFields: ['productCategory'],
}
}
function buildCandidateEvidence(product) {
return {
source: 'jst-unresolved-non-integrated-product-archive',
jstIdentityReasons: product.jstIdentityReasons,
sourceCategory: product.sourceCategory,
sourceItemType: product.sourceItemType,
skuSamples: product.skuRows.slice(0, 8).map((row) => ({
id: row.id,
skuItemId: row.skuItemId,
sourceSkuId: row.sourceSkuId,
skuCode: row.skuCode,
barcode: row.barcode,
source: row.source,
sourceTable: row.sourceTable,
})),
note: '聚水潭来源匹配报告未能唯一补齐正式分类;本计划只做软归档,不物理删除产品记录。',
}
}
function buildArchiveCandidate(product, matchClassification, referenceSummary) {
return {
productId: product.productId,
spu: product.spu,
sourceSpu: product.sourceSpu,
productName: product.productName,
current: buildCurrentEvidence(product),
archiveReason:
matchClassification === 'conflicting-source-category'
? '来源库存在冲突分类,不能自动补齐,按历史残留软归档。'
: '来源库找不到可唯一落到正式分类字典的分类,按历史残留软归档。',
matchClassification,
evidence: buildCandidateEvidence(product),
referenceSummary,
}
}
function buildArchivePlanFromRows({
products,
matchClassification,
referenceSummaries = new Map(),
databaseIdentity = '',
sourceMatchReport = '',
sourceCategoryCleanupPlan = '',
confirmedBy = DEFAULT_CONFIRMED_BY,
generatedAt = new Date().toISOString(),
}) {
const archiveCandidates = []
const blockedProducts = []
const excludedResolvedProducts = []
const nonJstProducts = []
for (const product of products) {
const normalizedProduct = normalizeProductRow(product)
const productId = normalizedProduct.productId
const classification = classifyMatch(productId, matchClassification)
const referenceSummary = cloneReferenceSummary(referenceSummaries.get(productId))
if (!isJstArchiveIdentity(normalizedProduct)) {
nonJstProducts.push(productId)
blockedProducts.push({
productId,
spu: normalizedProduct.spu,
sourceSpu: normalizedProduct.sourceSpu,
productName: normalizedProduct.productName,
blockReason: '没有结构化聚水潭来源身份,不能按 JST 历史残留归档。',
matchClassification: classification,
referenceSummary,
})
continue
}
if (classification === 'unique-resolved') {
excludedResolvedProducts.push({
productId,
spu: normalizedProduct.spu,
sourceSpu: normalizedProduct.sourceSpu,
productName: normalizedProduct.productName,
reason: '来源库唯一命中正式分类字典,应走分类补齐计划,不进入归档。',
})
continue
}
if (classification === 'missing-from-match-report') {
blockedProducts.push({
productId,
spu: normalizedProduct.spu,
sourceSpu: normalizedProduct.sourceSpu,
productName: normalizedProduct.productName,
blockReason: '不在聚水潭来源匹配报告中,缺少“找不到/冲突”证据。',
matchClassification: classification,
referenceSummary,
})
continue
}
if (referenceSummary.total > 0) {
blockedProducts.push({
productId,
spu: normalizedProduct.spu,
sourceSpu: normalizedProduct.sourceSpu,
productName: normalizedProduct.productName,
blockReason: '仍被后续业务链路引用,不能自动归档。',
matchClassification: classification,
referenceSummary,
})
continue
}
archiveCandidates.push(
buildArchiveCandidate(normalizedProduct, classification, referenceSummary),
)
}
return {
mode: ARCHIVE_PLAN_MODE,
generatedAt,
databaseIdentity,
sourceMatchReport,
sourceCategoryCleanupPlan,
confirmation: APPLY_CONFIRMATION,
confirmedBy: readText(confirmedBy),
summary: {
scannedProducts: products.length,
archiveCandidateCount: archiveCandidates.length,
blockedProductCount: blockedProducts.length,
excludedResolvedProductCount: excludedResolvedProducts.length,
nonJstProductCount: nonJstProducts.length,
unresolvedCandidateCount: archiveCandidates.filter(
(row) => row.matchClassification === 'unresolved-source-category',
).length,
conflictingCandidateCount: archiveCandidates.filter(
(row) => row.matchClassification === 'conflicting-source-category',
).length,
missingFromReportCount: matchClassification.missingFromReportProductIds.size,
},
archiveCandidates,
blockedProducts,
excludedResolvedProducts,
}
}
async function fetchMissingJstProductRows(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",
profile."sourceSpu",
profile."productName",
profile."productCategory",
profile."productDevelopmentKind",
profile."sourceCategory",
profile."sourceItemType",
profile."source" AS "profileSource",
COALESCE(sku_stats.sku_item_count, 0)::integer AS "skuItemCount",
COALESCE(pricing_stats.pricing_row_count, 0)::integer AS "pricingRowCount",
COALESCE(sku_stats.sku_rows, '[]'::jsonb) AS "skuRows"
FROM "IntakeNonIntegratedProduct" product
JOIN "IntakeNonIntegratedProductProfile" profile
ON profile."productId" = product."id"
LEFT JOIN LATERAL (
SELECT
COUNT(*)::integer AS sku_item_count,
jsonb_agg(
jsonb_build_object(
'id', sku."id",
'skuItemId', sku."skuItemId",
'sourceSkuId', sku."sourceSkuId",
'skuCode', sku."skuCode",
'barcode', sku."barcode",
'source', sku."source",
'sourceTable', sku."sourceTable"
)
ORDER BY sku."sortOrder", sku."id"
) AS sku_rows
FROM "IntakeNonIntegratedSkuItem" sku
WHERE sku."productId" = product."id"
) sku_stats ON TRUE
LEFT JOIN LATERAL (
SELECT COUNT(*)::integer AS pricing_row_count
FROM "IntakeNonIntegratedPricingRow" pricing
WHERE pricing."productId" = product."id"
) pricing_stats ON TRUE
WHERE product."deletedAt" IS NULL
AND NULLIF(btrim(profile."productCategory"), '') IS NULL
AND (
product."id" LIKE 'jst-stock-%'
OR NULLIF(btrim(profile."sourceSpu"), '') IS NOT NULL
OR EXISTS (
SELECT 1
FROM "IntakeNonIntegratedSkuItem" identity_sku
WHERE identity_sku."productId" = product."id"
AND (
lower(replace(identity_sku."sourceTable", '-', '_')) IN ('jst_sku', 'jstsku')
OR lower(replace(identity_sku."source", '-', '_')) IN ('jst_sku', 'jstsku')
)
)
)
ORDER BY profile."sourceSpu", product."spu", product."id"
${limitSql}
`, values)
return result.rows.map(normalizeProductRow)
}
function buildExactReferenceTokens(product) {
return uniqueValues([
product.productId,
product.spu,
product.sourceSpu,
...product.skuRows.flatMap((row) => [
row.id,
row.skuItemId,
row.sourceSkuId,
row.skuCode,
row.barcode,
]),
])
}
function buildFuzzyReferenceTokens(product) {
return buildExactReferenceTokens(product).filter((token) => token.length >= 8)
}
function buildTokenRows(products, tokenBuilder) {
return products.flatMap((product) =>
tokenBuilder(product).map((token) => ({
productId: product.productId,
token,
})),
)
}
async function fetchReferenceSummaries(client, products) {
const referenceSummaries = new Map(
products.map((product) => [product.productId, emptyReferenceSummary()]),
)
const productIds = products.map((product) => product.productId)
if (productIds.length === 0) return referenceSummaries
const approvalResult = await client.query(`
SELECT
"productId",
COUNT(*)::integer AS count,
array_remove(array_agg(DISTINCT "status"::text), NULL) AS samples
FROM "ApprovalWorkflow"
WHERE "productId" = ANY($1::text[])
GROUP BY "productId"
`, [productIds])
for (const row of approvalResult.rows) {
addReference(referenceSummaries, readText(row.productId), {
type: 'approval-workflow',
count: row.count,
samples: row.samples || [],
})
}
const exactTokens = buildTokenRows(products, buildExactReferenceTokens)
if (exactTokens.length > 0) {
const qualityResult = await client.query(`
WITH tokens AS (
SELECT *
FROM jsonb_to_recordset($1::jsonb) AS token("productId" text, "token" text)
)
SELECT
token."productId",
COUNT(DISTINCT inspection."id")::integer AS count,
array_remove(array_agg(DISTINCT inspection."recordKey"), NULL) AS samples
FROM tokens token
JOIN "ProductSkuQualityInspection" inspection
ON inspection."sourceProductId" = token."token"
OR inspection."sourceProductSpu" = token."token"
OR inspection."sourceSkuItemId" = token."token"
GROUP BY token."productId"
`, [JSON.stringify(exactTokens)])
for (const row of qualityResult.rows) {
addReference(referenceSummaries, readText(row.productId), {
type: 'quality-inspection',
count: row.count,
samples: row.samples || [],
})
}
}
const fuzzyTokens = buildTokenRows(products, buildFuzzyReferenceTokens)
if (fuzzyTokens.length > 0) {
const preSkuResult = await client.query(`
WITH tokens AS (
SELECT *
FROM jsonb_to_recordset($1::jsonb) AS token("productId" text, "token" text)
)
SELECT
token."productId",
COUNT(DISTINCT pre_sku."id")::integer AS count,
array_remove(array_agg(DISTINCT pre_sku."id"), NULL) AS samples
FROM tokens token
JOIN "PreSku" pre_sku
ON position(token."token" in concat_ws(
' ',
pre_sku."sourceCandidateKey",
pre_sku."familySelection"::text,
pre_sku."variantSelection"::text,
COALESCE(pre_sku."recommendedHardware"::text, ''),
pre_sku."previewNodes"::text,
pre_sku."displaySummary"::text,
pre_sku."bomPreview"::text,
COALESCE(pre_sku."explosionPreview"::text, ''),
pre_sku."installStepPreview"::text,
COALESCE(pre_sku."packagingSnapshot"::text, '')
)) > 0
JOIN "DraftGroup" draft_group ON draft_group."id" = pre_sku."draftGroupId"
WHERE pre_sku."status"::text <> 'ARCHIVED'
AND draft_group."status"::text <> 'ARCHIVED'
GROUP BY token."productId"
`, [JSON.stringify(fuzzyTokens)])
for (const row of preSkuResult.rows) {
addReference(referenceSummaries, readText(row.productId), {
type: 'pre-sku',
count: row.count,
samples: row.samples || [],
})
}
const draftGroupResult = await client.query(`
WITH tokens AS (
SELECT *
FROM jsonb_to_recordset($1::jsonb) AS token("productId" text, "token" text)
)
SELECT
token."productId",
COUNT(DISTINCT draft_group."id")::integer AS count,
array_remove(array_agg(DISTINCT draft_group."code"), NULL) AS samples
FROM tokens token
JOIN "DraftGroup" draft_group
ON position(token."token" in concat_ws(
' ',
draft_group."code",
draft_group."name",
COALESCE(draft_group."summary"::text, ''),
COALESCE(draft_group."notes", '')
)) > 0
WHERE draft_group."status"::text <> 'ARCHIVED'
GROUP BY token."productId"
`, [JSON.stringify(fuzzyTokens)])
for (const row of draftGroupResult.rows) {
addReference(referenceSummaries, readText(row.productId), {
type: 'draft-group',
count: row.count,
samples: row.samples || [],
})
}
}
return referenceSummaries
}
async function buildArchivePlan(client, options, databaseIdentity = '') {
const products = await fetchMissingJstProductRows(client, options)
const referenceSummaries = await fetchReferenceSummaries(client, products)
const matchClassification = loadMatchClassification(
options.matchReportPath,
options.categoryCleanupPlanPath,
)
return buildArchivePlanFromRows({
products,
matchClassification,
referenceSummaries,
databaseIdentity,
sourceMatchReport: options.matchReportPath,
sourceCategoryCleanupPlan: options.categoryCleanupPlanPath,
confirmedBy: options.confirmedBy,
})
}
function assertNoUnknownArchivePlanKeys(plan) {
const unknownKeys = Object.keys(plan).filter((key) => !ARCHIVE_PLAN_TOP_LEVEL_KEYS.has(key))
if (unknownKeys.length > 0) {
throw new Error(`归档计划存在未知顶层字段:${unknownKeys.join(', ')}。`)
}
}
function validateArchiveCandidate(candidate, index) {
const scope = `archiveCandidates[${index}]`
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
throw new Error(`${scope} 必须是对象。`)
}
const unknownKeys = Object.keys(candidate).filter((key) => !ARCHIVE_CANDIDATE_KEYS.has(key))
if (unknownKeys.length > 0) {
throw new Error(`${scope} 存在未知字段:${unknownKeys.join(', ')}。`)
}
if (!readText(candidate.productId)) throw new Error(`${scope}.productId 不能为空。`)
if (!readText(candidate.spu)) throw new Error(`${scope}.spu 不能为空。`)
if (!readText(candidate.productName)) throw new Error(`${scope}.productName 不能为空。`)
if (readText(candidate.current?.productCategory)) {
throw new Error(`${scope} 当前 productCategory 已有值,不能作为缺分类残留归档。`)
}
if (!Array.isArray(candidate.current?.missingFields) ||
!candidate.current.missingFields.includes('productCategory')) {
throw new Error(`${scope}.current.missingFields 必须包含 productCategory。`)
}
if (!['unresolved-source-category', 'conflicting-source-category'].includes(
readText(candidate.matchClassification),
)) {
throw new Error(`${scope}.matchClassification 必须是 unresolved 或 conflicting。`)
}
if (!readText(candidate.archiveReason)) throw new Error(`${scope}.archiveReason 不能为空。`)
const identityReasons = candidate.evidence?.jstIdentityReasons
if (!Array.isArray(identityReasons) || identityReasons.length === 0) {
throw new Error(`${scope}.evidence.jstIdentityReasons 不能为空。`)
}
const referenceTotal = Number(candidate.referenceSummary?.total || 0)
if (referenceTotal !== 0) {
throw new Error(`${scope}.referenceSummary.total 必须为 0;仍有引用的数据不能自动归档。`)
}
}
function validateArchivePlanShape(plan) {
if (!plan || typeof plan !== 'object' || Array.isArray(plan)) {
throw new Error('归档计划必须是 JSON 对象。')
}
assertNoUnknownArchivePlanKeys(plan)
if (plan.mode !== ARCHIVE_PLAN_MODE) {
throw new Error(`归档计划 mode 必须是 ${ARCHIVE_PLAN_MODE}。`)
}
if (!Array.isArray(plan.archiveCandidates)) {
throw new Error('归档计划 archiveCandidates 必须是数组。')
}
if (!Array.isArray(plan.blockedProducts)) {
throw new Error('归档计划 blockedProducts 必须是数组。')
}
if (!Array.isArray(plan.excludedResolvedProducts)) {
throw new Error('归档计划 excludedResolvedProducts 必须是数组。')
}
plan.archiveCandidates.forEach(validateArchiveCandidate)
return {
candidateCount: plan.archiveCandidates.length,
blockedProductCount: plan.blockedProducts.length,
excludedResolvedProductCount: plan.excludedResolvedProducts.length,
}
}
function assertArchiveApplyReady(args, plan) {
if (!args.apply) return
if (args.confirm !== APPLY_CONFIRMATION) {
throw new Error(`真正落库必须传入 --confirm ${APPLY_CONFIRMATION}。`)
}
if (plan.confirmation !== APPLY_CONFIRMATION) {
throw new Error(`真正落库必须在计划文件中设置 confirmation="${APPLY_CONFIRMATION}"。`)
}
if (!readText(plan.confirmedBy)) {
throw new Error('真正落库必须在计划文件中填写 confirmedBy,记录业务确认人。')
}
assertValidAuditText(plan.confirmedBy, '聚水潭历史残留归档计划 confirmedBy')
if (plan.archiveCandidates.length === 0) {
throw new Error('真正落库必须至少包含一条可归档候选。')
}
}
async function getArchiveHealthSummary(client) {
const result = await client.query(`
SELECT
(
SELECT COUNT(*)::integer
FROM "IntakeNonIntegratedProduct" product
JOIN "IntakeNonIntegratedProductProfile" profile
ON profile."productId" = product."id"
WHERE product."deletedAt" IS NULL
AND NULLIF(btrim(profile."productCategory"), '') IS NULL
) AS active_missing_product_category,
(
SELECT COUNT(*)::integer
FROM "IntakeNonIntegratedProduct" product
JOIN "IntakeNonIntegratedProductProfile" profile
ON profile."productId" = product."id"
WHERE product."deletedAt" IS NULL
AND NULLIF(btrim(profile."productCategory"), '') IS NULL
AND (
product."id" LIKE 'jst-stock-%'
OR NULLIF(btrim(profile."sourceSpu"), '') IS NOT NULL
OR EXISTS (
SELECT 1
FROM "IntakeNonIntegratedSkuItem" sku
WHERE sku."productId" = product."id"
AND (
lower(replace(sku."sourceTable", '-', '_')) IN ('jst_sku', 'jstsku')
OR lower(replace(sku."source", '-', '_')) IN ('jst_sku', 'jstsku')
)
)
)
) AS active_jst_missing_product_category,
(
SELECT COUNT(*)::integer
FROM "IntakeNonIntegratedSkuItem" sku
JOIN "IntakeNonIntegratedProduct" product ON product."id" = sku."productId"
WHERE product."deletedAt" IS NULL
AND NULLIF(btrim(sku."model"), '') IS NULL
) AS active_sku_missing_model,
(
SELECT COUNT(*)::integer
FROM "IntakeNonIntegratedSkuItem" sku
JOIN "IntakeNonIntegratedProduct" product ON product."id" = sku."productId"
WHERE product."deletedAt" IS NULL
AND NULLIF(btrim(sku."skuCode"), '') IS NULL
) AS active_sku_missing_sku_code,
(
SELECT COUNT(*)::integer
FROM "IntakeNonIntegratedSkuItem" sku
JOIN "IntakeNonIntegratedProduct" product ON product."id" = sku."productId"
WHERE product."deletedAt" IS NULL
AND NULLIF(btrim(sku."specName"), '') IS NULL
) AS active_sku_missing_spec_name
`)
const row = result.rows[0] || {}
return {
activeMissingProductCategory: Number(row.active_missing_product_category || 0),
activeJstMissingProductCategory: Number(row.active_jst_missing_product_category || 0),
activeSkuMissingModel: Number(row.active_sku_missing_model || 0),
activeSkuMissingSkuCode: Number(row.active_sku_missing_sku_code || 0),
activeSkuMissingSpecName: Number(row.active_sku_missing_spec_name || 0),
}
}
async function fetchArchiveProductsByIds(client, productIds) {
if (productIds.length === 0) return []
const result = await client.query(`
SELECT
product."id" AS "productId",
product."spu",
profile."sourceSpu",
profile."productName",
profile."productCategory",
profile."productDevelopmentKind",
profile."sourceCategory",
profile."sourceItemType",
profile."source" AS "profileSource",
COALESCE(sku_stats.sku_item_count, 0)::integer AS "skuItemCount",
COALESCE(pricing_stats.pricing_row_count, 0)::integer AS "pricingRowCount",
COALESCE(sku_stats.sku_rows, '[]'::jsonb) AS "skuRows"
FROM "IntakeNonIntegratedProduct" product
JOIN "IntakeNonIntegratedProductProfile" profile
ON profile."productId" = product."id"
LEFT JOIN LATERAL (
SELECT
COUNT(*)::integer AS sku_item_count,
jsonb_agg(
jsonb_build_object(
'id', sku."id",
'skuItemId', sku."skuItemId",
'sourceSkuId', sku."sourceSkuId",
'skuCode', sku."skuCode",
'barcode', sku."barcode",
'source', sku."source",
'sourceTable', sku."sourceTable"
)
ORDER BY sku."sortOrder", sku."id"
) AS sku_rows
FROM "IntakeNonIntegratedSkuItem" sku
WHERE sku."productId" = product."id"
) sku_stats ON TRUE
LEFT JOIN LATERAL (
SELECT COUNT(*)::integer AS pricing_row_count
FROM "IntakeNonIntegratedPricingRow" pricing
WHERE pricing."productId" = product."id"
) pricing_stats ON TRUE
WHERE product."id" = ANY($1::text[])
AND product."deletedAt" IS NULL
`, [productIds])
return result.rows.map(normalizeProductRow)
}
async function resolveQualityDecisionsForArchivedProduct(client, product, actor) {
const skuDecisionTargetIds = uniqueValues(
product.skuRows.flatMap((row) => [row.id, row.skuItemId]),
)
const result = await client.query(`
UPDATE "MasterDataQualityDecision"
SET
"status" = $4,
"resolvedBy" = $5,
"resolvedAt" = NOW(),
"updatedAt" = NOW()
WHERE "status" = $6
AND (
("targetType" = $1 AND "targetId" = $2)
OR ("targetType" = $3 AND "targetId" = ANY($7::text[]))
)
RETURNING "id", "targetType", "targetId"
`, [
QUALITY_DECISION_TARGET_PRODUCT_PROFILE,
product.productId,
QUALITY_DECISION_TARGET_SKU_ITEM,
QUALITY_DECISION_STATUS_RESOLVED,
actor,
QUALITY_DECISION_STATUS_APPROVED_INCOMPLETE,
skuDecisionTargetIds,
])
return {
resolvedQualityDecisionCount: result.rowCount || 0,
resolvedQualityDecisionIds: result.rows.map((row) => readText(row.id)),
}
}
async function applyArchiveCandidate(client, candidate, options = {}) {
const actor = readText(options.actor) || DEFAULT_CONFIRMED_BY
const rows = await fetchArchiveProductsByIds(client, [candidate.productId])
const product = rows[0]
if (!product) {
return {
productId: candidate.productId,
skipped: true,
skipReason: 'product-not-active-or-not-found',
}
}
if (readText(product.productCategory)) {
return {
productId: candidate.productId,
skipped: true,
skipReason: 'product-category-already-filled',
}
}
if (!isJstArchiveIdentity(product)) {
throw new Error(`产品 ${candidate.productId} 当前不具备聚水潭来源身份,拒绝归档。`)
}
const referenceSummaries = await fetchReferenceSummaries(client, [product])
const referenceSummary = cloneReferenceSummary(referenceSummaries.get(product.productId))
if (referenceSummary.total > 0) {
throw new Error(`产品 ${candidate.productId} 当前仍被后续业务链路引用,拒绝归档。`)
}
const pricingDelete = await client.query(`
DELETE FROM "IntakeNonIntegratedPricingRow"
WHERE "productId" = $1
`, [candidate.productId])
const skuDelete = await client.query(`
DELETE FROM "IntakeNonIntegratedSkuItem"
WHERE "productId" = $1
`, [candidate.productId])
const productUpdate = await client.query(`
UPDATE "IntakeNonIntegratedProduct"
SET
"deletedAt" = COALESCE("deletedAt", NOW()),
"updatedBy" = $2,
"updatedAt" = NOW()
WHERE "id" = $1
AND "deletedAt" IS NULL
RETURNING "id", "deletedAt"
`, [candidate.productId, actor])
const qualityDecisionResult = await resolveQualityDecisionsForArchivedProduct(
client,
product,
actor,
)
return {
productId: candidate.productId,
skipped: false,
archived: productUpdate.rowCount === 1,
deletedPricingRows: pricingDelete.rowCount || 0,
deletedSkuItems: skuDelete.rowCount || 0,
...qualityDecisionResult,
}
}
async function applyArchivePlan(client, plan, options = {}) {
const actor = readText(plan.confirmedBy) || DEFAULT_CONFIRMED_BY
const results = []
for (const candidate of plan.archiveCandidates) {
results.push(await applyArchiveCandidate(client, candidate, { ...options, actor }))
}
return {
total: results.length,
archived: results.filter((result) => result.archived).length,
skipped: results.filter((result) => result.skipped).length,
deletedSkuItems: results.reduce((sum, result) => sum + Number(result.deletedSkuItems || 0), 0),
deletedPricingRows: results.reduce(
(sum, result) => sum + Number(result.deletedPricingRows || 0),
0,
),
resolvedQualityDecisionCount: results.reduce(
(sum, result) => sum + Number(result.resolvedQualityDecisionCount || 0),
0,
),
results,
}
}
function readArchivePlan(planPath) {
return readJsonFile(planPath)
}
async function run(options) {
const databaseUrl = loadDatabaseUrl()
if (!databaseUrl) throw new Error('缺少 DATABASE_URL。')
const databaseIdentity = cleanupDatabaseIdentity(databaseUrl)
const pool = new Pool({ connectionString: databaseUrl })
const client = await pool.connect()
try {
await client.query('BEGIN')
const executionEvidence = assertApplyExecutionEvidence(options, { databaseUrl })
if (!options.planPath) {
const plan = await buildArchivePlan(client, options, databaseIdentity)
validateArchivePlanShape(plan)
await client.query('ROLLBACK')
const outputFile = options.outputPath ? writeJsonReport(options.outputPath, plan) : ''
const result = { ...plan.summary, outputFile }
if (options.summaryStdout) {
console.log(JSON.stringify(result, null, 2))
} else {
console.log(`已生成聚水潭历史残留软归档计划:${outputFile || '<stdout-disabled>'}`)
}
return { ...plan, outputFile }
}
const plan = readArchivePlan(options.planPath)
const validation = validateArchivePlanShape(plan)
assertArchiveApplyReady(options, plan)
const beforeSummary = await getArchiveHealthSummary(client)
const resultSummary = await applyArchivePlan(client, plan, {
apply: options.apply,
})
const afterSummary = await getArchiveHealthSummary(client)
if (options.apply) {
await client.query('COMMIT')
} else {
await client.query('ROLLBACK')
}
const report = {
mode: options.apply ? 'apply' : 'dry-run',
planMode: plan.mode,
committed: options.apply,
rolledBack: !options.apply,
validation,
beforeSummary,
afterSummary,
executionEvidence: options.apply ? executionEvidence : null,
resultSummary,
confirmedBy: options.apply ? plan.confirmedBy : '',
}
const outputFile = options.outputPath ? writeJsonReport(options.outputPath, report) : ''
const result = { ...report, outputFile }
console.log(JSON.stringify(result, null, 2))
return result
} catch (error) {
await client.query('ROLLBACK').catch(() => undefined)
throw error
} finally {
client.release()
await pool.end()
}
}
async function main() {
const options = parseArgs(process.argv.slice(2))
await run(options)
}
if (require.main === module) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
})
}
module.exports = {
APPLY_CONFIRMATION,
ARCHIVE_PLAN_MODE,
DEFAULT_CONFIRMED_BY,
parseArgs,
normalizeJstSource,
isJstSourceValue,
normalizeProductRow,
isJstArchiveIdentity,
loadMatchClassification,
classifyMatch,
emptyReferenceSummary,
addReference,
buildArchivePlanFromRows,
validateArchivePlanShape,
assertArchiveApplyReady,
buildExactReferenceTokens,
buildFuzzyReferenceTokens,
fetchMissingJstProductRows,
fetchReferenceSummaries,
buildArchivePlan,
getArchiveHealthSummary,
applyArchiveCandidate,
applyArchivePlan,
run,
}