diff --git a/furniture-3d-system/package.json b/furniture-3d-system/package.json index 5be29a8e..80170db8 100644 --- a/furniture-3d-system/package.json +++ b/furniture-3d-system/package.json @@ -26,6 +26,7 @@ "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", + "db:archive-jst-unresolved-products": "node scripts/archive-jst-unresolved-non-integrated-products.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", diff --git a/furniture-3d-system/scripts/archive-jst-unresolved-non-integrated-products.js b/furniture-3d-system/scripts/archive-jst-unresolved-non-integrated-products.js new file mode 100644 index 00000000..f9c78224 --- /dev/null +++ b/furniture-3d-system/scripts/archive-jst-unresolved-non-integrated-products.js @@ -0,0 +1,1209 @@ +/* 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 。') + args.planPath = value + index += 1 + continue + } + if (arg.startsWith('--plan=')) { + args.planPath = arg.slice('--plan='.length) + if (!args.planPath) throw new Error('缺少 --plan 。') + continue + } + if (arg === '--output') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) throw new Error('缺少 --output 。') + args.outputPath = value + index += 1 + continue + } + if (arg.startsWith('--output=')) { + args.outputPath = arg.slice('--output='.length) + if (!args.outputPath) throw new Error('缺少 --output 。') + continue + } + if (arg === '--match-report') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) throw new Error('缺少 --match-report 。') + 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 。') + continue + } + if (arg === '--category-cleanup-plan') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) { + throw new Error('缺少 --category-cleanup-plan 。') + } + 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 。') + } + continue + } + if (arg === '--confirmed-by') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) throw new Error('缺少 --confirmed-by 。') + 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 。') + continue + } + if (arg === '--confirm') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) throw new Error('缺少 --confirm 。') + args.confirm = value + index += 1 + continue + } + if (arg.startsWith('--confirm=')) { + args.confirm = arg.slice('--confirm='.length) + if (!args.confirm) throw new Error('缺少 --confirm 。') + continue + } + if (arg === '--expected-database-identity') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) { + throw new Error('缺少 --expected-database-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 。') + } + continue + } + if (arg === '--expected-backup-sha256') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) { + throw new Error('缺少 --expected-backup-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 。') + } + 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 。') + if (args.confirm && !args.apply) throw new Error('--confirm 只允许和 --apply 一起使用。') + if (!args.planPath && !args.matchReportPath) { + throw new Error('生成归档计划必须提供 --match-report ,避免误归档可补分类的数据。') + } + if (!args.confirmedBy.trim()) throw new Error('缺少 --confirmed-by 。') + + 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 || ''}`) + } + 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, +} diff --git a/furniture-3d-system/scripts/verify-master-data-code-boundaries.js b/furniture-3d-system/scripts/verify-master-data-code-boundaries.js index d0525e4f..d4d45716 100644 --- a/furniture-3d-system/scripts/verify-master-data-code-boundaries.js +++ b/furniture-3d-system/scripts/verify-master-data-code-boundaries.js @@ -831,6 +831,10 @@ const approvedScriptMasterDataWritePaths = new Map([ 'scripts/apply-pre-sku-formalization-repair.js', '准 SKU 生命周期专项修复入口;只允许处理 currentEvidence/evidenceFingerprint 匹配的历史错位,不作为正式入库替代入口。', ], + [ + 'scripts/archive-jst-unresolved-non-integrated-products.js', + '聚水潭历史残留软归档入口;只允许处理来源匹配报告证明无法唯一补分类且无下游引用的非集成产品,默认 dry-run,--apply 必须带确认、库身份和备份 SHA。', + ], [ 'scripts/migrate-historical-assets-to-qiniu.ts', '七牛历史素材一次性迁移脚本;默认 dry-run,--apply 前必须确认迁移计划、备份和素材替换范围,只允许更新旧素材 URL 引用。', @@ -1103,6 +1107,10 @@ const approvedRawSqlMasterDataWritePaths = new Map([ 'scripts/apply-pre-sku-formalization-repair.js', '准 SKU 生命周期专项修复计划使用原生 SQL 更新 PreSku 状态、ProductSKU.sourcePreSkuId 和 DraftGroup 状态;默认 dry-run,--apply 必须带业务确认、当前证据、库身份和备份 SHA。', ], + [ + 'scripts/archive-jst-unresolved-non-integrated-products.js', + '聚水潭历史残留软归档计划使用原生 SQL 软删除非集成产品、清理其结构化 SKU/核价行并关闭开放质量决策;默认 dry-run,--apply 必须带业务确认、库身份和备份 SHA。', + ], [ 'src/server/services/spu-number-reservation.service.ts', 'SPU 号段服务使用原生 SQL 在同一事务内初始化和推进 SpuNumberCounter,并依赖行锁保证并发安全。', @@ -1141,6 +1149,15 @@ const approvedRawSqlMasterDataWriteTableScopes = new Map( 'ProductSKU', ], ], + [ + 'scripts/archive-jst-unresolved-non-integrated-products.js', + [ + 'IntakeNonIntegratedProduct', + 'IntakeNonIntegratedPricingRow', + 'IntakeNonIntegratedSkuItem', + 'MasterDataQualityDecision', + ], + ], ['src/server/services/spu-number-reservation.service.ts', ['SpuNumberCounter']], ].map(([file, tableNames]) => [file, new Set(tableNames)]), ) @@ -1162,6 +1179,10 @@ const approvedRawSqlMasterDataReadPaths = new Map([ 'scripts/match-jst-product-category-cleanup-plan.js', '聚水潭来源分类匹配脚本只允许只读读取非集成产品/profile/SKU 和正式分类字典,结合 JST 来源库生成可审计 cleanup plan;不得直接写库或关键词猜分类。', ], + [ + 'scripts/archive-jst-unresolved-non-integrated-products.js', + '聚水潭历史残留软归档脚本允许读取非集成产品/profile/SKU、审批流、质检、草稿组和准 SKU 引用,用于归档前证据校验和 dry-run 对账。', + ], [ 'scripts/apply-pre-sku-formalization-repair.js', '准 SKU 生命周期专项修复入口允许读取 PreSku、ProductSKU、DraftGroup、包装和库位关系,用于 currentEvidence 校验、dry-run 和 apply 前事务内复核。', @@ -1233,6 +1254,20 @@ const approvedRawSqlMasterDataReadTableScopes = new Map([ 'SpuNumberCounter', ]), ], + [ + 'scripts/archive-jst-unresolved-non-integrated-products.js', + new Set([ + 'ApprovalWorkflow', + 'DraftGroup', + 'IntakeNonIntegratedPricingRow', + 'IntakeNonIntegratedProduct', + 'IntakeNonIntegratedProductProfile', + 'IntakeNonIntegratedSkuItem', + 'MasterDataQualityDecision', + 'PreSku', + 'ProductSkuQualityInspection', + ]), + ], [ 'scripts/apply-pre-sku-formalization-repair.js', new Set([ diff --git a/furniture-3d-system/tests/scripts/archive-jst-unresolved-non-integrated-products.test.js b/furniture-3d-system/tests/scripts/archive-jst-unresolved-non-integrated-products.test.js new file mode 100644 index 00000000..5b05fd61 --- /dev/null +++ b/furniture-3d-system/tests/scripts/archive-jst-unresolved-non-integrated-products.test.js @@ -0,0 +1,281 @@ +const { + APPLY_CONFIRMATION, + ARCHIVE_PLAN_MODE, + addReference, + assertArchiveApplyReady, + buildArchivePlanFromRows, + buildExactReferenceTokens, + buildFuzzyReferenceTokens, + classifyMatch, + emptyReferenceSummary, + isJstArchiveIdentity, + isJstSourceValue, + normalizeProductRow, + parseArgs, + validateArchivePlanShape, +} = require('../../scripts/archive-jst-unresolved-non-integrated-products') + +function product(overrides = {}) { + return normalizeProductRow({ + productId: 'jst-stock-product-1', + spu: 'SPU001', + sourceSpu: '1001', + productName: '聚水潭存量桌', + productCategory: '', + productDevelopmentKind: 'product', + sourceCategory: '', + sourceItemType: '成品', + profileSource: 'PAYLOAD_SYNC', + skuItemCount: 1, + pricingRowCount: 1, + skuRows: [ + { + id: 'sku-row-1', + skuItemId: 'sku-item-1', + sourceSkuId: 'BAR001', + skuCode: 'SKU001', + barcode: 'BAR001', + source: 'jst_sku', + sourceTable: 'jst_sku', + }, + ], + ...overrides, + }) +} + +function classification({ + unique = [], + conflicting = [], + unresolved = [], +} = {}) { + return { + uniqueResolvedProductIds: new Set(unique), + conflictingProductIds: new Set(conflicting), + unresolvedProductIds: new Set(unresolved), + missingFromReportProductIds: new Set(), + } +} + +describe('archive-jst-unresolved-non-integrated-products', () => { + it('parses plan generation, dry-run, and guarded apply arguments', () => { + expect(parseArgs([ + '--match-report', + 'report.json', + '--category-cleanup-plan=cleanup-plan.json', + '--output', + 'archive-plan.json', + '--confirmed-by', + '吴勇杰', + '--limit', + '20', + '--summary-stdout', + ])).toEqual({ + apply: false, + planPath: '', + outputPath: 'archive-plan.json', + matchReportPath: 'report.json', + categoryCleanupPlanPath: 'cleanup-plan.json', + confirmedBy: '吴勇杰', + confirm: '', + expectedDatabaseIdentity: '', + expectedBackupSha256: '', + limit: 20, + summaryStdout: true, + }) + expect(parseArgs([ + '--apply', + '--plan=archive-plan.json', + '--confirm', + APPLY_CONFIRMATION, + '--expected-database-identity', + 'postgresql|furniture|localhost|5432|furniture3d|', + '--expected-backup-sha256', + 'A'.repeat(64), + ])).toEqual(expect.objectContaining({ + apply: true, + planPath: 'archive-plan.json', + confirm: APPLY_CONFIRMATION, + expectedBackupSha256: 'a'.repeat(64), + })) + expect(() => parseArgs([])).toThrow('--match-report') + expect(() => parseArgs(['--confirm', APPLY_CONFIRMATION, '--match-report', 'report.json'])) + .toThrow('--apply') + expect(() => parseArgs(['--limit=0', '--match-report=report.json'])).toThrow('正整数') + }) + + it('recognizes JST source identity from stable id, profile source, and structured SKU rows', () => { + expect(isJstSourceValue('jst-sku')).toBe(true) + expect(isJstSourceValue('JST_SKU')).toBe(true) + expect(isJstArchiveIdentity(product({ productId: 'jst-stock-abc', sourceSpu: '' }))).toBe(true) + expect(isJstArchiveIdentity(product({ + productId: 'product-1', + sourceSpu: '', + profileSource: 'jst_sku', + skuRows: [], + }))).toBe(true) + expect(isJstArchiveIdentity(product({ + productId: 'product-1', + sourceSpu: '', + profileSource: '', + skuRows: [{ source: '', sourceTable: 'jst_sku' }], + }))).toBe(true) + expect(isJstArchiveIdentity(product({ + productId: 'product-1', + sourceSpu: '', + profileSource: '', + skuRows: [], + }))).toBe(false) + }) + + it('archives unresolved JST residuals but excludes uniquely resolved category matches', () => { + const unresolved = product({ productId: 'jst-stock-unresolved', spu: 'SPU001' }) + const unique = product({ productId: 'jst-stock-unique', spu: 'SPU002' }) + const matches = classification({ + unresolved: ['jst-stock-unresolved'], + unique: ['jst-stock-unique'], + }) + + const plan = buildArchivePlanFromRows({ + products: [unresolved, unique], + matchClassification: matches, + databaseIdentity: 'postgresql|furniture|localhost|5432|furniture3d|', + confirmedBy: '吴勇杰', + generatedAt: '2026-06-05T00:00:00.000Z', + }) + + expect(plan).toEqual(expect.objectContaining({ + mode: ARCHIVE_PLAN_MODE, + databaseIdentity: 'postgresql|furniture|localhost|5432|furniture3d|', + confirmation: APPLY_CONFIRMATION, + confirmedBy: '吴勇杰', + summary: expect.objectContaining({ + scannedProducts: 2, + archiveCandidateCount: 1, + excludedResolvedProductCount: 1, + }), + })) + expect(plan.archiveCandidates[0]).toEqual(expect.objectContaining({ + productId: 'jst-stock-unresolved', + matchClassification: 'unresolved-source-category', + current: expect.objectContaining({ + productCategory: '', + missingFields: ['productCategory'], + }), + referenceSummary: expect.objectContaining({ total: 0 }), + })) + expect(plan.excludedResolvedProducts[0]).toEqual(expect.objectContaining({ + productId: 'jst-stock-unique', + })) + expect(validateArchivePlanShape(plan)).toEqual({ + candidateCount: 1, + blockedProductCount: 0, + excludedResolvedProductCount: 1, + }) + }) + + it('keeps conflicting products archiveable only when no downstream references exist', () => { + const conflicted = product({ productId: 'jst-stock-conflict' }) + const referenceSummary = emptyReferenceSummary() + addReference(new Map([['jst-stock-conflict', referenceSummary]]), 'jst-stock-conflict', { + type: 'quality-inspection', + count: 1, + samples: ['QI-001'], + }) + const referenceSummaries = new Map([['jst-stock-conflict', referenceSummary]]) + const matches = classification({ conflicting: ['jst-stock-conflict'] }) + + const plan = buildArchivePlanFromRows({ + products: [conflicted], + matchClassification: matches, + referenceSummaries, + confirmedBy: '吴勇杰', + }) + + expect(plan.summary).toEqual(expect.objectContaining({ + archiveCandidateCount: 0, + blockedProductCount: 1, + })) + expect(plan.blockedProducts[0]).toEqual(expect.objectContaining({ + productId: 'jst-stock-conflict', + blockReason: '仍被后续业务链路引用,不能自动归档。', + matchClassification: 'conflicting-source-category', + referenceSummary: expect.objectContaining({ + total: 1, + qualityInspectionCount: 1, + }), + })) + }) + + it('blocks products missing from the JST match report instead of guessing', () => { + const matches = classification({ unresolved: [] }) + const plan = buildArchivePlanFromRows({ + products: [product({ productId: 'jst-stock-missing-report' })], + matchClassification: matches, + confirmedBy: '吴勇杰', + }) + + expect(classifyMatch('jst-stock-missing-report', matches)).toBe('missing-from-match-report') + expect(plan.summary).toEqual(expect.objectContaining({ + archiveCandidateCount: 0, + blockedProductCount: 1, + missingFromReportCount: 1, + })) + expect(plan.blockedProducts[0].blockReason).toContain('匹配报告') + }) + + it('rejects apply mode without explicit confirmation and audit actor', () => { + const plan = buildArchivePlanFromRows({ + products: [product({ productId: 'jst-stock-unresolved' })], + matchClassification: classification({ unresolved: ['jst-stock-unresolved'] }), + confirmedBy: '吴勇杰', + }) + + expect(() => assertArchiveApplyReady({ apply: true, confirm: '' }, plan)).toThrow('--confirm') + expect(() => assertArchiveApplyReady( + { apply: true, confirm: APPLY_CONFIRMATION }, + { ...plan, confirmation: '' }, + )).toThrow('confirmation') + expect(() => assertArchiveApplyReady( + { apply: true, confirm: APPLY_CONFIRMATION }, + { ...plan, confirmedBy: 'UNKNOWN' }, + )).toThrow('占位值或默认值') + expect(() => assertArchiveApplyReady( + { apply: true, confirm: APPLY_CONFIRMATION }, + plan, + )).not.toThrow() + }) + + it('builds exact and fuzzy reference tokens from product and SKU identity', () => { + const row = product({ + productId: 'jst-stock-product-abcdef123456', + spu: 'SPU001', + sourceSpu: '1001', + skuRows: [ + { + id: 'sku-row-abcdef123456', + skuItemId: 'sku-item-1', + sourceSkuId: 'BAR001', + skuCode: 'SKU001', + barcode: 'BAR001', + source: 'jst_sku', + sourceTable: 'jst_sku', + }, + ], + }) + + expect(buildExactReferenceTokens(row)).toEqual(expect.arrayContaining([ + 'jst-stock-product-abcdef123456', + 'SPU001', + '1001', + 'sku-row-abcdef123456', + 'sku-item-1', + 'BAR001', + 'SKU001', + ])) + expect(buildFuzzyReferenceTokens(row)).toEqual(expect.arrayContaining([ + 'jst-stock-product-abcdef123456', + 'sku-row-abcdef123456', + ])) + expect(buildFuzzyReferenceTokens(row)).not.toContain('SPU001') + }) +})