1210 lines
41 KiB
JavaScript
1210 lines
41 KiB
JavaScript
/* 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,
|
||
}
|