diff --git a/backend/scripts/exportManualReviewCandidates.js b/backend/scripts/exportManualReviewCandidates.js new file mode 100644 index 0000000..d80dd60 --- /dev/null +++ b/backend/scripts/exportManualReviewCandidates.js @@ -0,0 +1,231 @@ +const fs = require('fs') +const path = require('path') +const ExcelJS = require('exceljs') + +const SOURCE_PATH = path.resolve( + __dirname, + '../../temp_migration_output/multi_unit_child_repair_2026-03-24_01-16-47/manual_review_candidates.csv' +) +const OUTPUT_PATH = path.resolve( + __dirname, + '../../行政手动处理清单-最终仅剩8条-2026-03-24.xlsx' +) + +const OBSOLETE_FILES = [ + path.resolve(__dirname, '../../父子资产补建-行政手动处理清单-2026-03-23.xlsx'), + path.resolve(__dirname, '../../行政手动补录清单-2026-03-23_当前待人工处理.xlsx'), + path.resolve(__dirname, '../../子资产多件拆分-行政手动处理清单-2026-03-23.xlsx') +] + +const REASON_MAP = { + outer_capacity_not_equal_to_inner_times_quantity: + '表格总容量与单条容量乘数量对不上,系统不能在不猜测的前提下自动拆分。', + child_type_and_model_are_inconsistent: '子资产类型与型号内容不一致,系统不能自动处理。', + pattern_detected_but_no_safe_split_rule: '识别到多件格式,但现有信息不足以安全拆分。', + storage_plus_pattern_is_not_single_capacity_pair: + '硬盘型号中混有容量和具体型号,不能只按容量安全拆分。' +} + +const SPECIAL_REASON_OVERRIDES = { + 'JS-PC-2127': + '原表错行:该行硬件字段整体错位,不能按当前行内容自动落库,需行政按原始资料人工核对后录入。' +} + +const normalize = (value) => String(value == null ? '' : value) + +const extractMeta = (description, key) => { + const text = normalize(description) + const matched = text.match(new RegExp(`(?:^|;\\s*)${key}=([^;]*)`)) + return matched ? matched[1].trim() : '' +} + +async function readCsvRows(csvPath) { + const workbook = new ExcelJS.Workbook() + const sheet = await workbook.csv.readFile(csvPath) + const headers = sheet.getRow(1).values.slice(1).map((item) => normalize(item)) + const rows = [] + + for (let rowNumber = 2; rowNumber <= sheet.rowCount; rowNumber += 1) { + const row = sheet.getRow(rowNumber) + if (row.cellCount === 0) { + continue + } + + const record = {} + headers.forEach((header, index) => { + record[header] = normalize(row.getCell(index + 1).text) + }) + + if (Object.values(record).some((value) => value !== '')) { + rows.push(record) + } + } + + return { headers, rows } +} + +function styleHeaderRow(row) { + row.font = { bold: true } + row.alignment = { vertical: 'middle', horizontal: 'center' } +} + +function resolveReasonDescription(row) { + return SPECIAL_REASON_OVERRIDES[row.parent_code] || REASON_MAP[row.reason] || row.reason +} + +function buildTimestampedOutputPath() { + const now = new Date() + const pad = (value) => String(value).padStart(2, '0') + const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}` + return path.resolve(__dirname, `../../行政手动处理清单-最终仅剩8条-${stamp}.xlsx`) +} + +async function writeWorkbookWithFallback(workbook) { + try { + await workbook.xlsx.writeFile(OUTPUT_PATH) + return OUTPUT_PATH + } catch (error) { + if (error && error.code === 'EBUSY') { + const fallbackPath = buildTimestampedOutputPath() + await workbook.xlsx.writeFile(fallbackPath) + return fallbackPath + } + throw error + } +} + +async function exportWorkbook() { + const { headers, rows } = await readCsvRows(SOURCE_PATH) + + const workbook = new ExcelJS.Workbook() + workbook.creator = 'Codex' + workbook.created = new Date() + + const taskSheet = workbook.addWorksheet('人工处理清单') + taskSheet.columns = [ + { header: '源表行号', key: 'source_row', width: 10 }, + { header: '父资产编码', key: 'parent_code', width: 16 }, + { header: '父资产名称', key: 'parent_name', width: 12 }, + { header: '配件字段', key: 'field_key', width: 12 }, + { header: '子资产类型', key: 'child_type', width: 12 }, + { header: '系统当前子资产编码', key: 'current_child_code', width: 18 }, + { header: '表格原始型号', key: 'source_child_model', width: 42 }, + { header: '表格部门', key: 'workbook_department', width: 18 }, + { header: '表格使用人', key: 'workbook_owner', width: 12 }, + { header: '表格位置', key: 'workbook_location', width: 18 }, + { header: '原因代码', key: 'reason', width: 44 }, + { header: '人工处理原因', key: 'reason_desc', width: 50 } + ] + taskSheet.views = [{ state: 'frozen', ySplit: 1 }] + taskSheet.autoFilter = 'A1:L1' + styleHeaderRow(taskSheet.getRow(1)) + + rows.forEach((row) => { + taskSheet.addRow({ + source_row: row.source_row, + parent_code: row.parent_code, + parent_name: row.parent_name, + field_key: row.field_key, + child_type: row.child_type, + current_child_code: row.current_child_code, + source_child_model: row.source_child_model, + workbook_department: extractMeta(row.planned_description, 'workbook_department'), + workbook_owner: extractMeta(row.planned_description, 'workbook_owner'), + workbook_location: extractMeta(row.planned_description, 'workbook_location'), + reason: row.reason, + reason_desc: resolveReasonDescription(row) + }) + }) + taskSheet.eachRow((row, rowNumber) => { + row.alignment = { vertical: 'top', wrapText: true } + if (rowNumber > 1) { + row.height = 22 + } + }) + + const rawSheet = workbook.addWorksheet('原始数据') + rawSheet.columns = headers.map((header) => ({ + header, + key: header, + width: Math.max(14, header.length + 2) + })) + rawSheet.views = [{ state: 'frozen', ySplit: 1 }] + styleHeaderRow(rawSheet.getRow(1)) + rows.forEach((row) => rawSheet.addRow(row)) + rawSheet.eachRow((row) => { + row.alignment = { vertical: 'top', wrapText: true } + }) + + const actualOutputPath = await writeWorkbookWithFallback(workbook) + + const verifyBook = new ExcelJS.Workbook() + await verifyBook.xlsx.readFile(actualOutputPath) + const verifyRawSheet = verifyBook.getWorksheet('原始数据') + const verifyTaskSheet = verifyBook.getWorksheet('人工处理清单') + + const verifiedRows = [] + for (let rowNumber = 2; rowNumber <= verifyRawSheet.rowCount; rowNumber += 1) { + const row = verifyRawSheet.getRow(rowNumber) + if (row.cellCount === 0) { + continue + } + + const record = {} + headers.forEach((header, index) => { + record[header] = normalize(row.getCell(index + 1).text) + }) + + if (Object.values(record).some((value) => value !== '')) { + verifiedRows.push(record) + } + } + + if (verifiedRows.length !== rows.length) { + throw new Error(`校验失败:原始数据行数不一致,source=${rows.length}, xlsx=${verifiedRows.length}`) + } + + rows.forEach((row, index) => { + headers.forEach((header) => { + if (normalize(verifiedRows[index][header]) !== normalize(row[header])) { + throw new Error(`校验失败:第 ${index + 1} 行字段 ${header} 不一致`) + } + }) + }) + + const taskRowCount = Math.max(verifyTaskSheet.rowCount - 1, 0) + if (taskRowCount !== rows.length) { + throw new Error(`校验失败:人工处理清单行数不一致,source=${rows.length}, xlsx=${taskRowCount}`) + } + + const removedFiles = [] + const skippedFiles = [] + for (const filePath of OBSOLETE_FILES) { + if (fs.existsSync(filePath)) { + try { + fs.unlinkSync(filePath) + removedFiles.push(filePath) + } catch (error) { + skippedFiles.push({ + filePath, + code: error && error.code ? error.code : 'UNKNOWN' + }) + } + } + } + + return { + outputPath: actualOutputPath, + rowCount: rows.length, + removedFiles, + skippedFiles + } +} + +exportWorkbook() + .then((result) => { + console.log(JSON.stringify({ verified: true, ...result }, null, 2)) + }) + .catch((error) => { + console.error(error) + process.exit(1) + }) diff --git a/backend/scripts/hardwareQuantitySplit.js b/backend/scripts/hardwareQuantitySplit.js new file mode 100644 index 0000000..f40e7b5 --- /dev/null +++ b/backend/scripts/hardwareQuantitySplit.js @@ -0,0 +1,534 @@ +const normalizeText = (value) => + String(value == null ? '' : value) + .replace(/\r/g, ' ') + .replace(/\n/g, ' ') + .replace(/\s+/g, ' ') + .trim() + +const cleanModelText = (value) => + normalizeText(value) + .replace(/\s+([,,/])/g, '$1') + .replace(/([((])\s+/g, '$1') + .replace(/\s+([))])/g, '$1') + .replace(/\s+/g, ' ') + .trim() + +const MULTI_UNIT_HINT_PATTERNS = [ + { pattern: 'cpu_x_multiplier', regex: /[((]\s*[xX]\s*[2-9]\d*\s*[))]\s*$/ }, + { pattern: 'suffix_quantity_label', regex: /\*\s*[2-9]\d*\s*(个|条|根|块|张|颗)\s*$/ }, + { pattern: 'bracket_capacity_multiplier', regex: /[((][^()()]*\d+\s*[A-Za-z]{0,3}\s*\*\s*[2-9]\d*[^()()]*[))]/i }, + { pattern: 'inline_capacity_multiplier', regex: /(?:^|\s)\d+\s*[A-Za-z]{0,3}\s*\*\s*[2-9]\d*(?:\s|$)/i }, + { pattern: 'suffix_multiplier_with_paren_unit', regex: /\*\s*[2-9]\d*\s*[((]\s*\d+\s*[A-Za-z]{1,3}\s*[))]\s*$/i }, + { pattern: 'plus_capacity_pair', regex: /\d+(?:\.\d+)?\s*[A-Za-z]{0,3}\s*\+\s*\d+(?:\.\d+)?\s*[A-Za-z]{0,3}/i } +] + +const CAPACITY_TOKEN_REGEX = /(\d+(?:\.\d+)?)\s*([A-Za-z]{1,3})/g + +const normalizeCapacityUnit = (value) => { + const unit = String(value || '').trim().toUpperCase() + if (unit === 'GB') return 'G' + if (unit === 'TB') return 'T' + return unit +} + +const parseCapacityToken = (value) => { + const matched = normalizeText(value).match(/^(\d+(?:\.\d+)?)\s*([A-Za-z]{0,3})$/) + if (!matched) { + return null + } + + return { + raw: matched[0], + value: Number(matched[1]), + unit: matched[2] || '', + normalizedUnit: normalizeCapacityUnit(matched[2] || '') + } +} + +const formatCapacityToken = (value, unit) => { + const numeric = Number(value) + const rendered = Number.isInteger(numeric) ? String(numeric) : String(numeric) + return `${rendered}${unit || ''}` +} + +const findLastCapacityTokenWithUnit = (value) => { + const text = String(value || '') + let matched = null + let current = null + + while ((current = CAPACITY_TOKEN_REGEX.exec(text)) !== null) { + matched = { + raw: current[0], + value: Number(current[1]), + unit: current[2], + normalizedUnit: normalizeCapacityUnit(current[2]), + index: current.index, + length: current[0].length + } + } + + CAPACITY_TOKEN_REGEX.lastIndex = 0 + return matched +} + +const findFirstCapacityTokenWithUnit = (value) => { + const text = String(value || '') + const matched = CAPACITY_TOKEN_REGEX.exec(text) + CAPACITY_TOKEN_REGEX.lastIndex = 0 + if (!matched) { + return null + } + + return { + raw: matched[0], + value: Number(matched[1]), + unit: matched[2], + normalizedUnit: normalizeCapacityUnit(matched[2]), + index: matched.index, + length: matched[0].length + } +} + +const countCapacityTokensWithUnit = (value) => { + const text = String(value || '') + const matched = text.match(/(\d+(?:\.\d+)?)\s*([A-Za-z]{1,3})/g) + return matched ? matched.length : 0 +} + +const splitAroundFirstCapacityToken = (value) => { + const text = normalizeText(value) + const token = findFirstCapacityTokenWithUnit(text) + if (!token) { + return null + } + + return { + prefix: normalizeText(text.slice(0, token.index)), + token, + suffix: normalizeText(text.slice(token.index + token.length)) + } +} + +const buildModelText = (prefix, token, suffix) => + cleanModelText(`${prefix || ''}${formatCapacityToken(token.value, token.unit)}${suffix ? ` ${suffix}` : ''}`) + +const isMemoryChildType = (childType) => normalizeText(childType) === '内存条' +const isStorageChildType = (childType) => ['固态硬盘', '机械硬盘'].includes(normalizeText(childType)) + +const canShareMemoryPrefix = (value) => { + const text = normalizeText(value) + return Boolean(text) && !/[+/]/.test(text) && !/\d/.test(text) +} + +const canShareMemorySuffix = (value) => { + const text = normalizeText(value) + return Boolean(text) && !/[+/]/.test(text) && !/[\\/]/.test(text) +} + +const replaceTokenByIndex = (value, token, replacement) => + `${value.slice(0, token.index)}${replacement}${value.slice(token.index + token.length)}` + +const removeBundleKeyword = (value) => + normalizeText(value) + .replace(/^套装/i, '') + .replace(/套装/gi, ' ') + .replace(/\s+/g, ' ') + .trim() + +const detectMultiUnitHint = (value) => { + const text = normalizeText(value) + if (!text) { + return null + } + + return MULTI_UNIT_HINT_PATTERNS.find((item) => item.regex.test(text))?.pattern || null +} + +const resolveBracketCapacitySplit = (value) => { + const text = normalizeText(value) + const matched = text.match(/^(.*?)[((]\s*(\d+\s*[A-Za-z]{0,3}|\d+)\s*\*\s*([2-9]\d*)\s*[))](.*)$/i) + if (!matched) { + return null + } + + const prefix = matched[1] || '' + const rawInner = matched[2] || '' + const quantity = Number(matched[3] || 0) + const suffix = matched[4] || '' + const inner = parseCapacityToken(rawInner) + const outer = findLastCapacityTokenWithUnit(prefix) + + if (!inner || !outer || quantity < 2) { + return { + isMultiUnit: true, + isAutoSplittable: false, + reason: 'missing_outer_or_inner_capacity_token', + pattern: 'bracket_capacity_multiplier', + originalModel: text + } + } + + const unitToken = formatCapacityToken(inner.value, inner.unit || outer.unit) + if (outer.value !== inner.value * quantity) { + return { + isMultiUnit: true, + isAutoSplittable: false, + reason: 'outer_capacity_not_equal_to_inner_times_quantity', + pattern: 'bracket_capacity_multiplier', + originalModel: text, + quantity + } + } + + const updatedPrefix = replaceTokenByIndex(prefix, outer, unitToken) + const unitModel = cleanModelText(`${updatedPrefix}${removeBundleKeyword(suffix) ? ` ${removeBundleKeyword(suffix)}` : ''}`) + + return { + isMultiUnit: true, + isAutoSplittable: true, + quantity, + unitModel, + pattern: 'bracket_capacity_multiplier', + originalModel: text + } +} + +const resolveInlineCapacitySplit = (value) => { + const text = normalizeText(value) + const matched = text.match(/^(.*?)(\d+\s*[A-Za-z]{0,3}|\d+)\s*\*\s*([2-9]\d*)\s*$/i) + if (!matched) { + return null + } + + const prefix = matched[1] || '' + const rawInner = matched[2] || '' + const quantity = Number(matched[3] || 0) + const inner = parseCapacityToken(rawInner) + const outer = findLastCapacityTokenWithUnit(prefix) + + if (!inner || !outer || quantity < 2) { + return { + isMultiUnit: true, + isAutoSplittable: false, + reason: 'missing_outer_or_inner_capacity_token', + pattern: 'inline_capacity_multiplier', + originalModel: text + } + } + + const unitToken = formatCapacityToken(inner.value, inner.unit || outer.unit) + if (outer.value !== inner.value * quantity) { + return { + isMultiUnit: true, + isAutoSplittable: false, + reason: 'outer_capacity_not_equal_to_inner_times_quantity', + pattern: 'inline_capacity_multiplier', + originalModel: text, + quantity + } + } + + const updatedPrefix = replaceTokenByIndex(prefix, outer, unitToken) + const unitModel = cleanModelText(updatedPrefix) + + return { + isMultiUnit: true, + isAutoSplittable: true, + quantity, + unitModel, + pattern: 'inline_capacity_multiplier', + originalModel: text + } +} + +const resolveSuffixMultiplierWithParenUnit = (value) => { + const text = normalizeText(value) + const matched = text.match(/^(.*)\*\s*([2-9]\d*)\s*[((]\s*(\d+\s*[A-Za-z]{1,3})\s*[))]\s*$/i) + if (!matched) { + return null + } + + const prefix = normalizeText(matched[1] || '') + const quantity = Number(matched[2] || 0) + const rawInner = normalizeText(matched[3] || '') + if (quantity < 2 || !prefix || !rawInner) { + return { + isMultiUnit: true, + isAutoSplittable: false, + reason: 'invalid_suffix_multiplier_with_paren_unit', + pattern: 'suffix_multiplier_with_paren_unit', + originalModel: text + } + } + + if (!prefix.includes(rawInner)) { + return { + isMultiUnit: true, + isAutoSplittable: false, + reason: 'paren_unit_not_present_in_prefix', + pattern: 'suffix_multiplier_with_paren_unit', + originalModel: text, + quantity + } + } + + return { + isMultiUnit: true, + isAutoSplittable: true, + quantity, + unitModel: cleanModelText(prefix), + pattern: 'suffix_multiplier_with_paren_unit', + originalModel: text + } +} + +const resolveCpuXMultiplier = (value) => { + const text = normalizeText(value) + const matched = text.match(/^(.*?)[((]\s*[xX]\s*([2-9]\d*)\s*[))]\s*$/) + if (!matched) { + return null + } + + return { + isMultiUnit: true, + isAutoSplittable: true, + quantity: Number(matched[2] || 0), + unitModel: cleanModelText(matched[1]), + pattern: 'cpu_x_multiplier', + originalModel: text + } +} + +const resolveSuffixQuantityLabel = (value) => { + const text = normalizeText(value) + const matched = text.match(/^(.*?)\s*\*\s*([2-9]\d*)\s*(个|条|根|块|张|颗)\s*$/) + if (!matched) { + return null + } + + return { + isMultiUnit: true, + isAutoSplittable: true, + quantity: Number(matched[2] || 0), + unitModel: cleanModelText(matched[1]), + pattern: 'suffix_quantity_label', + originalModel: text + } +} + +const resolveMemoryPlusSplit = (childType, value) => { + if (!isMemoryChildType(childType)) { + return null + } + + const text = normalizeText(value) + const parts = text.split('+') + if (parts.length !== 2) { + return null + } + + const left = splitAroundFirstCapacityToken(parts[0]) + const right = splitAroundFirstCapacityToken(parts[1]) + if (!left || !right || countCapacityTokensWithUnit(parts[0]) !== 1 || countCapacityTokensWithUnit(parts[1]) !== 1) { + return { + isMultiUnit: true, + isAutoSplittable: false, + quantity: 0, + unitModel: '', + reason: 'memory_plus_pattern_is_not_single_capacity_pair', + pattern: 'plus_capacity_pair', + originalModel: text + } + } + + const leftPrefix = left.prefix || (canShareMemoryPrefix(right.prefix) ? right.prefix : '') + const rightPrefix = right.prefix || (canShareMemoryPrefix(left.prefix) ? left.prefix : '') + const leftSuffix = left.suffix || (canShareMemorySuffix(right.suffix) ? right.suffix : '') + const rightSuffix = right.suffix || (canShareMemorySuffix(left.suffix) ? left.suffix : '') + + const models = [ + buildModelText(leftPrefix, left.token, leftSuffix), + buildModelText(rightPrefix, right.token, rightSuffix) + ] + + return { + isMultiUnit: true, + isAutoSplittable: true, + quantity: models.length, + unitModel: models[0] === models[1] ? models[0] : '', + unitModels: models, + pattern: 'plus_capacity_pair', + originalModel: text + } +} + +const resolveStorageSimplePlusSplit = (childType, value) => { + if (!isStorageChildType(childType)) { + return null + } + + const text = normalizeText(value) + const parts = text.split('+') + if (parts.length !== 2) { + return null + } + + const left = splitAroundFirstCapacityToken(parts[0]) + const right = splitAroundFirstCapacityToken(parts[1]) + if (!left || !right || countCapacityTokensWithUnit(parts[0]) !== 1 || countCapacityTokensWithUnit(parts[1]) !== 1) { + return { + isMultiUnit: true, + isAutoSplittable: false, + quantity: 0, + unitModel: '', + reason: 'storage_plus_pattern_is_not_single_capacity_pair', + pattern: 'plus_capacity_pair', + originalModel: text + } + } + + if (left.prefix || left.suffix || right.prefix || right.suffix) { + return { + isMultiUnit: true, + isAutoSplittable: false, + quantity: 0, + unitModel: '', + reason: 'storage_plus_pattern_contains_extra_model_details', + pattern: 'plus_capacity_pair', + originalModel: text + } + } + + const models = [ + formatCapacityToken(left.token.value, left.token.unit), + formatCapacityToken(right.token.value, right.token.unit) + ] + + return { + isMultiUnit: true, + isAutoSplittable: true, + quantity: models.length, + unitModel: models[0] === models[1] ? models[0] : '', + unitModels: models, + pattern: 'plus_capacity_pair', + originalModel: text + } +} + +const isTypeAndModelConsistent = (childType, model) => { + const normalizedType = normalizeText(childType) + const normalizedModel = normalizeText(model).toUpperCase() + if (!normalizedType || !normalizedModel) { + return true + } + + if ((normalizedType === '固态硬盘' || normalizedType === '机械硬盘') && /(DDR\d|MHZ|内存|套装)/i.test(normalizedModel)) { + return false + } + + return true +} + +const splitHardwareQuantity = (childType, value) => { + const text = normalizeText(value) + if (!text) { + return { + isMultiUnit: false, + isAutoSplittable: false, + quantity: 1, + unitModel: '' + } + } + + const hintPattern = detectMultiUnitHint(text) + if (!hintPattern) { + return { + isMultiUnit: false, + isAutoSplittable: false, + quantity: 1, + unitModel: cleanModelText(text), + pattern: 'single_unit', + originalModel: text + } + } + + const strategies = [ + (type, text) => resolveCpuXMultiplier(text), + (type, text) => resolveSuffixQuantityLabel(text), + (type, text) => resolveBracketCapacitySplit(text), + (type, text) => resolveInlineCapacitySplit(text), + (type, text) => resolveSuffixMultiplierWithParenUnit(text), + resolveMemoryPlusSplit, + resolveStorageSimplePlusSplit + ] + + for (const strategy of strategies) { + const result = strategy(childType, text) + if (!result) { + continue + } + + if (result.isAutoSplittable && !isTypeAndModelConsistent(childType, result.unitModel)) { + return { + ...result, + isAutoSplittable: false, + reason: 'child_type_and_model_are_inconsistent' + } + } + + return result + } + + return { + isMultiUnit: true, + isAutoSplittable: false, + quantity: 0, + unitModel: '', + reason: 'pattern_detected_but_no_safe_split_rule', + pattern: hintPattern, + originalModel: text + } +} + +const expandHardwareModels = (childType, value) => { + const result = splitHardwareQuantity(childType, value) + if (!result.isMultiUnit) { + return { + ...result, + models: result.unitModel ? [result.unitModel] : [] + } + } + + if (!result.isAutoSplittable || !result.unitModel || !result.quantity) { + if (Array.isArray(result.unitModels) && result.unitModels.length > 0) { + return { + ...result, + models: result.unitModels.map((item) => cleanModelText(item)).filter(Boolean) + } + } + return { + ...result, + models: [] + } + } + + if (Array.isArray(result.unitModels) && result.unitModels.length > 0) { + return { + ...result, + models: result.unitModels.map((item) => cleanModelText(item)).filter(Boolean) + } + } + + return { + ...result, + models: Array.from({ length: result.quantity }, () => result.unitModel) + } +} + +module.exports = { + normalizeText, + cleanModelText, + detectMultiUnitHint, + splitHardwareQuantity, + expandHardwareModels +} diff --git a/backend/scripts/importParentChildMigrationToLive.js b/backend/scripts/importParentChildMigrationToLive.js new file mode 100644 index 0000000..768d561 --- /dev/null +++ b/backend/scripts/importParentChildMigrationToLive.js @@ -0,0 +1,854 @@ +const fs = require('fs') +const path = require('path') + +const ExcelJS = require(path.resolve(__dirname, '../node_modules/exceljs')) +const { resolveStorageLocationMatch } = require('./locationNormalization') + +const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output') +const DEFAULT_API_BASE = 'https://www.jsgdzc.cn/api' +const MIGRATION_MARKER = 'parent_child_excel_20260323' +const PARENT_BUILDABLE_STATUSES = new Set(['可用', '已借出', '维护中']) + +const parseArgs = () => { + const args = process.argv.slice(2) + const options = { + mode: 'preflight', + planDir: '', + apiBase: DEFAULT_API_BASE, + username: '', + password: '', + limit: 0, + parentCode: '', + sourceRow: 0 + } + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] + if (arg === '--mode') { + options.mode = args[index + 1] || options.mode + index += 1 + } else if (arg === '--plan-dir') { + options.planDir = args[index + 1] || '' + index += 1 + } else if (arg === '--api-base') { + options.apiBase = args[index + 1] || DEFAULT_API_BASE + index += 1 + } else if (arg === '--username') { + options.username = args[index + 1] || '' + index += 1 + } else if (arg === '--password') { + options.password = args[index + 1] || '' + index += 1 + } else if (arg === '--limit') { + options.limit = Number(args[index + 1] || 0) || 0 + index += 1 + } else if (arg === '--parent-code') { + options.parentCode = args[index + 1] || '' + index += 1 + } else if (arg === '--source-row') { + options.sourceRow = Number(args[index + 1] || 0) || 0 + index += 1 + } + } + + return options +} + +const normalizeText = (value) => + String(value == null ? '' : value) + .replace(/\r/g, ' ') + .replace(/\n/g, ' ') + .replace(/\s+/g, ' ') + .trim() + +const normalizeLookupKey = (value) => normalizeText(value).toUpperCase() + +const normalizeMode = (value) => { + const mode = normalizeText(value).toLowerCase() + if (mode === 'apply') { + return 'apply' + } + return 'preflight' +} + +const resolvePlanDir = (value) => { + if (normalizeText(value)) { + const resolved = path.isAbsolute(value) ? value : path.resolve(process.cwd(), value) + if (!fs.existsSync(resolved)) { + throw new Error(`计划目录不存在: ${resolved}`) + } + return resolved + } + + const candidates = fs + .readdirSync(OUTPUT_ROOT, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name.startsWith('parent_child_dry_run_')) + .map((entry) => entry.name) + .sort() + + if (!candidates.length) { + throw new Error(`未找到 parent_child_dry_run 输出目录: ${OUTPUT_ROOT}`) + } + + return path.join(OUTPUT_ROOT, candidates[candidates.length - 1]) +} + +const ensureOutputDir = (prefix) => { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19) + const outputDir = path.join(OUTPUT_ROOT, `${prefix}_${timestamp}`) + fs.mkdirSync(outputDir, { recursive: true }) + return outputDir +} + +const readJsonFile = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8')) + +const writeJson = (filePath, value) => { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`) +} + +const escapeCsv = (value) => { + const stringValue = String(value == null ? '' : value) + if (/[,"\n]/.test(stringValue)) { + return `"${stringValue.replace(/"/g, '""')}"` + } + return stringValue +} + +const writeCsv = (filePath, headers, rows) => { + const lines = [headers.map(escapeCsv).join(',')] + for (const row of rows) { + lines.push(headers.map((header) => escapeCsv(row[header] ?? '')).join(',')) + } + fs.writeFileSync(filePath, `\uFEFF${lines.join('\n')}`) +} + +const buildSummaryRows = (summary) => + Object.entries(summary).map(([key, value]) => ({ + key, + value: Array.isArray(value) || (value && typeof value === 'object') ? JSON.stringify(value) : value + })) + +const writeWorkbook = async (filePath, sheets) => { + const workbook = new ExcelJS.Workbook() + workbook.creator = 'Codex' + workbook.created = new Date() + + for (const sheet of sheets) { + const rows = Array.isArray(sheet.rows) && sheet.rows.length ? sheet.rows : [{}] + const headers = [...new Set(rows.flatMap((row) => Object.keys(row)))] + const worksheet = workbook.addWorksheet(sheet.name) + worksheet.columns = headers.map((header) => ({ + header, + key: header, + width: Math.min(Math.max(header.length + 4, 16), 48) + })) + rows.forEach((row) => worksheet.addRow(row)) + worksheet.getRow(1).font = { bold: true } + worksheet.views = [{ state: 'frozen', ySplit: 1 }] + } + + await workbook.xlsx.writeFile(filePath) +} + +const loadPlan = (planDir, options) => { + const reportPath = path.join(planDir, 'report.json') + const summaryPath = path.join(planDir, 'summary.json') + + if (!fs.existsSync(reportPath)) { + throw new Error(`未找到 report.json: ${reportPath}`) + } + + const report = readJsonFile(reportPath) + const summary = fs.existsSync(summaryPath) ? readJsonFile(summaryPath) : report.summary || {} + let plannedChildren = Array.isArray(report.plannedChildren) ? report.plannedChildren : [] + + if (normalizeText(options.parentCode)) { + const parentCodeKey = normalizeLookupKey(options.parentCode) + plannedChildren = plannedChildren.filter((item) => normalizeLookupKey(item.parent_code) === parentCodeKey) + } + + if (options.sourceRow > 0) { + plannedChildren = plannedChildren.filter((item) => Number(item.source_row) === Number(options.sourceRow)) + } + + if (options.limit > 0) { + plannedChildren = plannedChildren.slice(0, options.limit) + } + + return { + planDir, + report, + summary, + plannedChildren + } +} + +const sanitizeTokenValue = (value) => normalizeText(value).replace(/;/g, ',') + +const buildActionKey = (item) => + [ + normalizeLookupKey(item.parent_code), + normalizeLookupKey(item.field_key), + Number(item.source_row) || 0, + Number(item.split_index) || 1 + ].join('||') + +const buildChildTypeModelKey = (type, model) => + `${normalizeLookupKey(type)}||${normalizeLookupKey(model)}` + +const buildMigrationTokens = (item) => ({ + migration_marker: MIGRATION_MARKER, + source_row: String(Number(item.source_row) || 0), + parent_code: sanitizeTokenValue(item.parent_code), + field_key: sanitizeTokenValue(item.field_key), + split_index: String(Number(item.split_index) || 1) +}) + +const buildChildDescription = (item) => { + const tokens = [ + ['migration_marker', MIGRATION_MARKER], + ['source_row', Number(item.source_row) || 0], + ['parent_code', item.parent_code], + ['field_key', item.field_key], + ['child_type', item.child_type], + ['child_model', item.child_model], + ['source_child_model', item.source_child_model || item.child_model], + ['split_index', Number(item.split_index) || 1], + ['split_quantity', Number(item.split_quantity) || 1], + ['split_pattern', item.split_pattern || 'single_unit'], + ['parent_name', item.parent_name], + ['parent_type', item.parent_type], + ['workbook_department', item.workbook_department], + ['workbook_owner', item.workbook_owner], + ['workbook_location', item.workbook_location] + ] + + return tokens.map(([key, value]) => `${key}=${sanitizeTokenValue(value)}`).join('; ') +} + +const buildMountNote = (item) => + Number(item.split_quantity) > 1 + ? `根据8.xlsx第${item.source_row}行补建${item.child_name || item.child_type}第${item.split_index}/${item.split_quantity}件,字段=${item.field_key},原始型号=${sanitizeTokenValue(item.source_child_model || item.child_model)},拆分后型号=${sanitizeTokenValue(item.child_model)}` + : `根据8.xlsx第${item.source_row}行补建${item.child_name || item.child_type},字段=${item.field_key},型号=${sanitizeTokenValue(item.child_model)}` + +const descriptionMatchesPlan = (description, item) => { + const normalizedDescription = normalizeText(description) + if (!normalizedDescription) { + return false + } + + const tokens = buildMigrationTokens(item) + return Object.entries(tokens).every(([key, value]) => + normalizedDescription.includes(`${key}=${sanitizeTokenValue(value)}`) + ) +} + +const buildCookieHeader = (response) => { + if (typeof response.headers.getSetCookie === 'function') { + return response.headers.getSetCookie().map((item) => item.split(';')[0]).join('; ') + } + + const setCookie = response.headers.get('set-cookie') + return setCookie ? setCookie.split(';')[0] : '' +} + +class ApiSession { + constructor({ apiBase, username, password }) { + this.apiBase = normalizeText(apiBase).replace(/\/$/, '') + this.username = normalizeText(username) + this.password = String(password == null ? '' : password) + this.cookieHeader = '' + } + + async login() { + if (!this.apiBase || !this.username || !this.password) { + throw new Error('缺少 API 登录参数') + } + + const response = await fetch(`${this.apiBase}/auth/local/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: this.username, + password: this.password + }) + }) + + const text = await response.text() + if (!response.ok) { + throw new Error(`登录失败: status=${response.status} body=${text}`) + } + + this.cookieHeader = buildCookieHeader(response) + if (!this.cookieHeader) { + throw new Error('登录成功但未拿到 session cookie') + } + + return JSON.parse(text) + } + + async requestJson(endpoint, options = {}, retry = true) { + if (!this.cookieHeader) { + await this.login() + } + + const headers = { + ...(options.headers || {}), + Cookie: this.cookieHeader + } + + const response = await fetch(`${this.apiBase}${endpoint}`, { + ...options, + headers + }) + const text = await response.text() + + if (response.status === 401 && retry) { + await this.login() + return this.requestJson(endpoint, options, false) + } + + if (!response.ok) { + throw new Error(`请求失败: ${endpoint} status=${response.status} body=${text}`) + } + + return JSON.parse(text) + } +} + +const unwrapListPayload = (payload) => { + if (Array.isArray(payload)) { + return payload + } + + if (payload && Array.isArray(payload.list)) { + return payload.list + } + + if (payload?.data && Array.isArray(payload.data.list)) { + return payload.data.list + } + + throw new Error('接口返回结构不符合预期,未找到 list') +} + +const fetchLiveData = async (session, outputDir, tag) => { + const [equipmentPayload, assetTypePayload, storageLocationPayload] = await Promise.all([ + session.requestJson('/equipment/list?page=1&pageSize=5000'), + session.requestJson('/asset-types/list?page=1&pageSize=200'), + session.requestJson('/storage-locations/list?page=1&pageSize=200') + ]) + + const equipmentSourcePath = path.join(outputDir, `live_equipment_${tag}.json`) + const assetTypeSourcePath = path.join(outputDir, `live_asset_types_${tag}.json`) + const storageLocationSourcePath = path.join(outputDir, `live_storage_locations_${tag}.json`) + writeJson(equipmentSourcePath, equipmentPayload) + writeJson(assetTypeSourcePath, assetTypePayload) + writeJson(storageLocationSourcePath, storageLocationPayload) + + return { + equipmentList: unwrapListPayload(equipmentPayload), + assetTypes: unwrapListPayload(assetTypePayload), + storageLocations: unwrapListPayload(storageLocationPayload), + equipmentSourcePath, + assetTypeSourcePath, + storageLocationSourcePath + } +} + +const buildLiveRefs = (liveData) => { + const topLevelByCode = new Map() + const topLevelItems = Array.isArray(liveData.equipmentList) ? liveData.equipmentList : [] + + for (const item of topLevelItems) { + const code = normalizeLookupKey(item.equipment_code) + if (code) { + topLevelByCode.set(code, item) + } + } + + const assetTypeByName = new Map() + for (const item of liveData.assetTypes || []) { + assetTypeByName.set(normalizeLookupKey(item.name), item) + } + + const locationByName = new Map() + for (const item of liveData.storageLocations || []) { + locationByName.set(normalizeLookupKey(item.name), item) + } + + return { + topLevelItems, + topLevelByCode, + assetTypeByName, + locationByName + } +} + +const summarizeByReason = (rows, reasonKey = 'reason') => { + const counts = {} + for (const row of rows) { + const reason = normalizeText(row[reasonKey]) || 'unknown' + counts[reason] = (counts[reason] || 0) + 1 + } + return counts +} + +const analyzePlan = (plan, liveData) => { + const refs = buildLiveRefs(liveData) + const actions = [] + const blocked = [] + const satisfied = [] + const mountedChildQueueCache = new Map() + + const getMountedChildQueue = (parent, key) => { + const cacheKey = `${parent.id}||${key}` + if (mountedChildQueueCache.has(cacheKey)) { + return mountedChildQueueCache.get(cacheKey) + } + + const queue = (Array.isArray(parent.children) ? parent.children : []).filter( + (child) => buildChildTypeModelKey(child.equipment_type, child.equipment_model) === key + ) + mountedChildQueueCache.set(cacheKey, queue) + return queue + } + + for (const item of plan.plannedChildren) { + const parentCodeKey = normalizeLookupKey(item.parent_code) + const parent = refs.topLevelByCode.get(parentCodeKey) + const actionBase = { + action_key: buildActionKey(item), + source_row: item.source_row, + parent_code: item.parent_code, + parent_name: item.parent_name, + parent_type: item.parent_type, + parent_status: item.parent_status, + child_type: item.child_type, + child_name: item.child_name, + child_model: item.child_model, + source_child_model: item.source_child_model || item.child_model, + field_key: item.field_key, + split_index: Number(item.split_index) || 1, + split_quantity: Number(item.split_quantity) || 1, + split_pattern: item.split_pattern || 'single_unit', + notes: item.notes, + workbook_department: item.workbook_department, + workbook_owner: item.workbook_owner, + workbook_location: item.workbook_location, + planned_description: buildChildDescription(item), + planned_mount_note: buildMountNote(item) + } + + const block = (reason, extra = {}) => { + blocked.push({ + ...actionBase, + reason, + ...extra + }) + } + + if (!parent) { + block('live_parent_not_found') + continue + } + + const liveParentType = refs.assetTypeByName.get(normalizeLookupKey(parent.equipment_type)) + if (!liveParentType?.can_be_parent) { + block('live_parent_type_not_parent_capable', { live_parent_type: parent.equipment_type }) + continue + } + + if (parent.parent_id != null) { + block('live_parent_is_child', { live_parent_id: parent.parent_id }) + continue + } + + if (!PARENT_BUILDABLE_STATUSES.has(normalizeText(parent.status))) { + block('live_parent_status_not_buildable', { live_parent_status: parent.status }) + continue + } + + const childTypeConfig = refs.assetTypeByName.get(normalizeLookupKey(item.child_type)) + if (!childTypeConfig?.can_be_child) { + block('child_asset_type_not_child_capable') + continue + } + + const expectedChildKey = buildChildTypeModelKey(item.child_type, item.child_model) + const matchingMountedChildQueue = getMountedChildQueue(parent, expectedChildKey) + const matchingMountedChild = matchingMountedChildQueue.shift() + + if (matchingMountedChild) { + satisfied.push({ + ...actionBase, + reason: 'already_mounted_matching_type_model', + live_parent_id: parent.id, + live_child_id: matchingMountedChild.id, + live_child_code: matchingMountedChild.equipment_code, + live_child_status: matchingMountedChild.status + }) + continue + } + + const orphanMatches = refs.topLevelItems.filter((equipment) => descriptionMatchesPlan(equipment.description, item)) + + if (orphanMatches.length > 1) { + block('duplicate_script_created_orphans', { + matched_orphan_codes: orphanMatches.map((equipment) => equipment.equipment_code).join(' | ') + }) + continue + } + + if (orphanMatches.length === 1) { + const orphan = orphanMatches[0] + + if (buildChildTypeModelKey(orphan.equipment_type, orphan.equipment_model) !== expectedChildKey) { + block('script_created_orphan_model_mismatch', { + matched_orphan_code: orphan.equipment_code, + matched_orphan_type: orphan.equipment_type, + matched_orphan_model: orphan.equipment_model + }) + continue + } + + if (orphan.parent_id != null) { + block('script_created_orphan_already_mounted_elsewhere', { + matched_orphan_code: orphan.equipment_code, + matched_orphan_parent_id: orphan.parent_id + }) + continue + } + + if (normalizeText(orphan.status) !== '可用' || Number(orphan.remaining_quantity) <= 0) { + block('script_created_orphan_not_mountable', { + matched_orphan_code: orphan.equipment_code, + matched_orphan_status: orphan.status, + matched_orphan_remaining_quantity: orphan.remaining_quantity + }) + continue + } + + if (orphan.current_borrow) { + block('script_created_orphan_has_active_borrow', { + matched_orphan_code: orphan.equipment_code + }) + continue + } + + actions.push({ + ...actionBase, + action_type: 'mount_existing', + live_parent_id: parent.id, + existing_child_id: orphan.id, + existing_child_code: orphan.equipment_code, + parent_storage_location_id: parent.storage_location_id, + parent_department_id: parent.department_id + }) + continue + } + + const fallbackLocationResolution = !parent.storage_location_id + ? resolveStorageLocationMatch(item.workbook_location, refs.locationByName) + : { match: null, normalizedValue: '', rule: '' } + const resolvedStorageLocationId = parent.storage_location_id || fallbackLocationResolution.match?.id || null + const resolvedStorageLocationName = + parent.storageLocation?.name || + fallbackLocationResolution.match?.name || + '' + + if (!resolvedStorageLocationId) { + block('live_parent_missing_storage_location', { + live_parent_id: parent.id, + workbook_location_resolution: fallbackLocationResolution.normalizedValue || '', + workbook_location_rule: fallbackLocationResolution.rule || '' + }) + continue + } + + actions.push({ + ...actionBase, + action_type: 'create_and_mount', + live_parent_id: parent.id, + parent_storage_location_id: resolvedStorageLocationId, + parent_storage_location_name: resolvedStorageLocationName, + parent_storage_location_source: parent.storage_location_id ? 'live_parent' : 'workbook_fallback', + parent_department_id: parent.department_id + }) + } + + const summary = { + planned_child_count: plan.plannedChildren.length, + actionable_count: actions.length, + actionable_create_and_mount_count: actions.filter((item) => item.action_type === 'create_and_mount').length, + actionable_mount_existing_count: actions.filter((item) => item.action_type === 'mount_existing').length, + satisfied_count: satisfied.length, + blocked_count: blocked.length, + blocked_reason_counts: summarizeByReason(blocked), + satisfied_reason_counts: summarizeByReason(satisfied) + } + + return { + summary, + actions, + blocked, + satisfied + } +} + +const buildCreateChildPayload = (action) => ({ + entry_mode: 'child', + equipment_name: action.child_name, + equipment_model: action.child_model, + equipment_type: action.child_type, + code_generation_mode: 'auto', + unit_price: 0, + total_quantity: 1, + remaining_quantity: 1, + status: '可用', + storage_location_id: action.parent_storage_location_id, + department_id: action.parent_department_id || null, + description: action.planned_description +}) + +const applyActions = async (session, analysis) => { + const results = [] + const errors = [] + + for (let index = 0; index < analysis.actions.length; index += 1) { + const action = analysis.actions[index] + const progress = `${index + 1}/${analysis.actions.length}` + console.log(`[${progress}] ${action.action_type} ${action.parent_code} ${action.child_type} ${action.child_model}`) + + let createdChild = null + let stage = 'mount' + + try { + let childId = action.existing_child_id || null + let childCode = action.existing_child_code || '' + + if (action.action_type === 'create_and_mount') { + stage = 'create' + const createResult = await session.requestJson('/equipment', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(buildCreateChildPayload(action)) + }) + + createdChild = createResult?.data || null + childId = createdChild?.id || null + childCode = normalizeText(createdChild?.equipment_code) + + if (!childId || !childCode) { + throw new Error('创建子资产返回的数据不完整') + } + } + + stage = 'mount' + await session.requestJson(`/mount/${action.live_parent_id}/mount`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + child_id: childId, + notes: action.planned_mount_note + }) + }) + + results.push({ + ...action, + apply_status: 'success', + child_id: childId, + child_code: childCode, + created_new_child: action.action_type === 'create_and_mount' ? 'yes' : 'no' + }) + } catch (error) { + let cleanupStatus = 'not_needed' + let cleanupMessage = '' + + if (action.action_type === 'create_and_mount' && createdChild?.id) { + cleanupStatus = 'pending' + try { + await session.requestJson(`/equipment/${createdChild.id}`, { + method: 'DELETE' + }) + cleanupStatus = 'deleted' + } catch (cleanupError) { + cleanupStatus = 'delete_failed' + cleanupMessage = cleanupError?.message || String(cleanupError) + } + } + + errors.push({ + ...action, + apply_status: 'failed', + failed_stage: stage, + error_message: error?.message || String(error), + created_child_id: createdChild?.id || '', + created_child_code: createdChild?.equipment_code || '', + cleanup_status: cleanupStatus, + cleanup_message: cleanupMessage + }) + break + } + } + + return { + results, + errors, + stopped_early: errors.length > 0 + } +} + +const writeAnalysisOutputs = async ({ + outputDir, + plan, + liveData, + analysis, + execution = null, + postVerify = null, + mode +}) => { + const manifest = { + generated_at: new Date().toISOString(), + mode, + plan_dir: plan.planDir, + source_plan_summary: plan.summary, + selected_planned_child_count: plan.plannedChildren.length, + live_total_equipment: liveData.equipmentList.length, + live_total_asset_types: liveData.assetTypes.length, + live_total_storage_locations: (liveData.storageLocations || []).length, + analysis_summary: analysis.summary, + execution_summary: execution + ? { + success_count: execution.results.length, + error_count: execution.errors.length, + stopped_early: execution.stopped_early + } + : null, + post_verify_summary: postVerify?.summary || null + } + + writeJson(path.join(outputDir, 'manifest.json'), manifest) + writeJson(path.join(outputDir, 'analysis_summary.json'), analysis.summary) + writeJson(path.join(outputDir, 'blocked.json'), analysis.blocked) + writeJson(path.join(outputDir, 'satisfied.json'), analysis.satisfied) + writeJson(path.join(outputDir, 'actions.json'), analysis.actions) + + writeCsv( + path.join(outputDir, 'actions.csv'), + Object.keys(analysis.actions[0] || { action_key: '', action_type: '', parent_code: '', child_type: '', child_model: '' }), + analysis.actions + ) + writeCsv( + path.join(outputDir, 'blocked.csv'), + Object.keys(analysis.blocked[0] || { action_key: '', reason: '', parent_code: '', child_type: '', child_model: '' }), + analysis.blocked + ) + writeCsv( + path.join(outputDir, 'satisfied.csv'), + Object.keys(analysis.satisfied[0] || { action_key: '', reason: '', parent_code: '', child_type: '', child_model: '' }), + analysis.satisfied + ) + + const workbookSheets = [ + { name: 'Summary', rows: buildSummaryRows(manifest) }, + { name: 'Actions', rows: analysis.actions }, + { name: 'Blocked', rows: analysis.blocked }, + { name: 'Satisfied', rows: analysis.satisfied }, + { name: 'OriginalSkippedRows', rows: plan.report.skippedRows || [] } + ] + + if (execution) { + writeJson(path.join(outputDir, 'apply_results.json'), execution.results) + writeJson(path.join(outputDir, 'apply_errors.json'), execution.errors) + writeCsv( + path.join(outputDir, 'apply_results.csv'), + Object.keys(execution.results[0] || { action_key: '', apply_status: '', child_code: '' }), + execution.results + ) + writeCsv( + path.join(outputDir, 'apply_errors.csv'), + Object.keys(execution.errors[0] || { action_key: '', apply_status: '', error_message: '' }), + execution.errors + ) + workbookSheets.push({ name: 'ApplyResults', rows: execution.results }) + workbookSheets.push({ name: 'ApplyErrors', rows: execution.errors }) + } + + if (postVerify) { + writeJson(path.join(outputDir, 'post_verify.json'), postVerify) + workbookSheets.push({ name: 'PostVerifySummary', rows: buildSummaryRows(postVerify.summary || {}) }) + workbookSheets.push({ name: 'PostVerifyBlocked', rows: postVerify.blocked || [] }) + workbookSheets.push({ name: 'PostVerifyActions', rows: postVerify.actions || [] }) + workbookSheets.push({ name: 'PostVerifySatisfied', rows: postVerify.satisfied || [] }) + } + + await writeWorkbook(path.join(outputDir, 'parent_child_live_migration.xlsx'), workbookSheets) +} + +const run = async () => { + const options = parseArgs() + const mode = normalizeMode(options.mode) + const plan = loadPlan(resolvePlanDir(options.planDir), options) + const outputDir = ensureOutputDir( + mode === 'apply' ? 'parent_child_live_apply' : 'parent_child_live_preflight' + ) + + if (!plan.plannedChildren.length) { + throw new Error('筛选后没有可处理的 plannedChildren') + } + + const session = new ApiSession({ + apiBase: options.apiBase, + username: options.username, + password: options.password + }) + + const liveBefore = await fetchLiveData(session, outputDir, 'before') + const analysis = analyzePlan(plan, liveBefore) + + let execution = null + let postVerify = null + + if (mode === 'apply' && analysis.actions.length > 0) { + execution = await applyActions(session, analysis) + const liveAfter = await fetchLiveData(session, outputDir, 'after') + postVerify = analyzePlan(plan, liveAfter) + } + + await writeAnalysisOutputs({ + outputDir, + plan, + liveData: liveBefore, + analysis, + execution, + postVerify, + mode + }) + + console.log( + JSON.stringify( + { + outputDir, + mode, + selectedPlannedChildCount: plan.plannedChildren.length, + analysisSummary: analysis.summary, + executionSummary: execution + ? { + successCount: execution.results.length, + errorCount: execution.errors.length, + stoppedEarly: execution.stopped_early + } + : null, + postVerifySummary: postVerify ? postVerify.summary : null + }, + null, + 2 + ) + ) +} + +run().catch((error) => { + console.error(error?.stack ? error.stack : String(error)) + process.exit(1) +}) diff --git a/backend/scripts/prepareParentChildMigrationDryRun.js b/backend/scripts/prepareParentChildMigrationDryRun.js new file mode 100644 index 0000000..56b337a --- /dev/null +++ b/backend/scripts/prepareParentChildMigrationDryRun.js @@ -0,0 +1,733 @@ +const fs = require('fs') +const path = require('path') + +const XLSX = require(path.resolve(__dirname, '../../frontend/node_modules/xlsx')) +const ExcelJS = require(path.resolve(__dirname, '../node_modules/exceljs')) +const { expandHardwareModels } = require('./hardwareQuantitySplit') + +const WORKBOOK_PATH = path.resolve(__dirname, '../../8.xlsx') +const SHEET_NAME = '📜固定资产明细' +const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output') + +const HEADER_MAP = { + assetCode: '资产编码', + currentStatus: '当前状态', + assetName: '资产名称', + assetCategory: '资产分类', + department: '部门', + owner: '当前使用员工', + address: '地址', + cpu: 'cpu', + motherboard: '主板', + memory: '内存', + ssd: '固态硬盘', + hdd: '机械硬盘', + gpu: '显卡', + parentRecord: '父记录' +} + +const CHILD_FIELD_SPECS = [ + { fieldKey: 'cpu', childType: 'cpu', childName: 'CPU' }, + { fieldKey: 'motherboard', childType: '主板', childName: '主板' }, + { fieldKey: 'memory', childType: '内存条', childName: '内存条' }, + { fieldKey: 'ssd', childType: '固态硬盘', childName: '固态硬盘' }, + { fieldKey: 'hdd', childType: '机械硬盘', childName: '机械硬盘' }, + { fieldKey: 'gpu', childType: '显卡', childName: '显卡' } +] + +const PARENT_BUILDABLE_STATUSES = new Set(['可用', '已借出', '维护中']) + +const ASSET_TYPE_ALIASES = { + 笔记本电脑: '笔记本' +} + +const parseArgs = () => { + const args = process.argv.slice(2) + const options = { + workbookPath: WORKBOOK_PATH, + equipmentJsonPath: '', + assetTypesJsonPath: '', + apiBase: '', + username: '', + password: '' + } + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] + if (arg === '--workbook') { + options.workbookPath = args[index + 1] || WORKBOOK_PATH + index += 1 + } else if (arg === '--equipment-json') { + options.equipmentJsonPath = args[index + 1] || '' + index += 1 + } else if (arg === '--asset-types-json') { + options.assetTypesJsonPath = args[index + 1] || '' + index += 1 + } else if (arg === '--api-base') { + options.apiBase = args[index + 1] || '' + index += 1 + } else if (arg === '--username') { + options.username = args[index + 1] || '' + index += 1 + } else if (arg === '--password') { + options.password = args[index + 1] || '' + index += 1 + } + } + + return options +} + +const normalizeText = (value) => + String(value == null ? '' : value) + .replace(/\r/g, ' ') + .replace(/\n/g, ' ') + .replace(/\s+/g, ' ') + .trim() + +const normalizeLookupKey = (value) => normalizeText(value).toUpperCase() + +const ensureOutputDir = () => { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19) + const outputDir = path.join(OUTPUT_ROOT, `parent_child_dry_run_${timestamp}`) + fs.mkdirSync(outputDir, { recursive: true }) + return outputDir +} + +const resolvePathArg = (value, fallbackPath = '') => { + const target = normalizeText(value || fallbackPath) + if (!target) { + return '' + } + + return path.isAbsolute(target) ? target : path.resolve(process.cwd(), target) +} + +const findLatestJsonByPrefix = (prefix) => { + if (!fs.existsSync(OUTPUT_ROOT)) { + return '' + } + + const matches = fs + .readdirSync(OUTPUT_ROOT, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.startsWith(prefix) && entry.name.endsWith('.json')) + .map((entry) => entry.name) + .sort() + + if (!matches.length) { + return '' + } + + return path.join(OUTPUT_ROOT, matches[matches.length - 1]) +} + +const writeJson = (filePath, value) => { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`) +} + +const escapeCsv = (value) => { + const stringValue = String(value == null ? '' : value) + if (/[,"\n]/.test(stringValue)) { + return `"${stringValue.replace(/"/g, '""')}"` + } + return stringValue +} + +const writeCsv = (filePath, headers, rows) => { + const lines = [headers.map(escapeCsv).join(',')] + for (const row of rows) { + lines.push(headers.map((header) => escapeCsv(row[header] ?? '')).join(',')) + } + fs.writeFileSync(filePath, `\uFEFF${lines.join('\n')}`) +} + +const writeWorkbook = async (filePath, sheets) => { + const workbook = new ExcelJS.Workbook() + workbook.creator = 'Codex' + workbook.created = new Date() + + for (const sheet of sheets) { + const rows = Array.isArray(sheet.rows) && sheet.rows.length ? sheet.rows : [{}] + const headers = [...new Set(rows.flatMap((row) => Object.keys(row)))] + const worksheet = workbook.addWorksheet(sheet.name) + worksheet.columns = headers.map((header) => ({ + header, + key: header, + width: Math.min(Math.max(header.length + 4, 16), 40) + })) + rows.forEach((row) => worksheet.addRow(row)) + worksheet.getRow(1).font = { bold: true } + worksheet.views = [{ state: 'frozen', ySplit: 1 }] + } + + await workbook.xlsx.writeFile(filePath) +} + +const normalizeAssetTypeName = (value) => { + const normalized = normalizeText(value) + return ASSET_TYPE_ALIASES[normalized] || normalized +} + +const loadWorkbookRows = (workbookPath) => { + const workbook = XLSX.readFile(workbookPath, { raw: false }) + const worksheet = workbook.Sheets[SHEET_NAME] + if (!worksheet) { + throw new Error(`未找到工作表: ${SHEET_NAME}`) + } + + const rows = XLSX.utils.sheet_to_json(worksheet, { header: 1, raw: false, defval: '' }) + if (!rows.length) { + return [] + } + + const headerIndex = {} + rows[0].forEach((header, index) => { + headerIndex[normalizeText(header)] = index + }) + + const records = [] + for (let index = 1; index < rows.length; index += 1) { + const row = rows[index] + const sourceRow = index + 1 + const record = { + sourceRow, + assetCodeRaw: normalizeText(row[headerIndex[HEADER_MAP.assetCode]]), + currentStatusRaw: normalizeText(row[headerIndex[HEADER_MAP.currentStatus]]), + assetNameRaw: normalizeText(row[headerIndex[HEADER_MAP.assetName]]), + assetCategoryRaw: normalizeText(row[headerIndex[HEADER_MAP.assetCategory]]), + departmentRaw: normalizeText(row[headerIndex[HEADER_MAP.department]]), + ownerRaw: normalizeText(row[headerIndex[HEADER_MAP.owner]]), + addressRaw: normalizeText(row[headerIndex[HEADER_MAP.address]]), + cpuRaw: normalizeText(row[headerIndex[HEADER_MAP.cpu]]), + motherboardRaw: normalizeText(row[headerIndex[HEADER_MAP.motherboard]]), + memoryRaw: normalizeText(row[headerIndex[HEADER_MAP.memory]]), + ssdRaw: normalizeText(row[headerIndex[HEADER_MAP.ssd]]), + hddRaw: normalizeText(row[headerIndex[HEADER_MAP.hdd]]), + gpuRaw: normalizeText(row[headerIndex[HEADER_MAP.gpu]]), + parentRecordRaw: normalizeText(row[headerIndex[HEADER_MAP.parentRecord]]) + } + + const hasAnyHardware = CHILD_FIELD_SPECS.some((item) => normalizeText(record[`${item.fieldKey}Raw`])) + if (!hasAnyHardware) { + continue + } + + records.push(record) + } + + return records +} + +const readJsonFile = (filePath) => { + const raw = fs.readFileSync(filePath, 'utf8') + return JSON.parse(raw) +} + +const unwrapListPayload = (payload) => { + if (Array.isArray(payload)) { + return payload + } + + if (payload && Array.isArray(payload.list)) { + return payload.list + } + + if (payload?.data && Array.isArray(payload.data.list)) { + return payload.data.list + } + + throw new Error('JSON 结构不符合预期,未找到 list') +} + +const fetchJson = async (url, options = {}) => { + const response = await fetch(url, options) + const text = await response.text() + if (!response.ok) { + throw new Error(`请求失败: ${url} status=${response.status} body=${text}`) + } + return { response, data: JSON.parse(text) } +} + +const buildCookieHeader = (response) => { + if (typeof response.headers.getSetCookie === 'function') { + return response.headers.getSetCookie().map((item) => item.split(';')[0]).join('; ') + } + + const setCookie = response.headers.get('set-cookie') + return setCookie ? setCookie.split(';')[0] : '' +} + +const fetchLiveData = async (options, outputDir) => { + const apiBase = normalizeText(options.apiBase) + if (!apiBase || !normalizeText(options.username) || !normalizeText(options.password)) { + throw new Error('缺少 live API 登录参数') + } + + const loginResult = await fetchJson(`${apiBase}/auth/local/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: options.username, + password: options.password + }) + }) + + const cookieHeader = buildCookieHeader(loginResult.response) + if (!cookieHeader) { + throw new Error('登录成功但未获取到 session cookie') + } + + const commonHeaders = { Cookie: cookieHeader } + const [equipmentResult, assetTypeResult] = await Promise.all([ + fetchJson(`${apiBase}/equipment/list?page=1&pageSize=5000`, { headers: commonHeaders }), + fetchJson(`${apiBase}/asset-types/list?page=1&pageSize=200`, { headers: commonHeaders }) + ]) + + const equipmentJsonPath = path.join(outputDir, 'live_equipment_source.json') + const assetTypesJsonPath = path.join(outputDir, 'live_asset_types_source.json') + writeJson(equipmentJsonPath, equipmentResult.data) + writeJson(assetTypesJsonPath, assetTypeResult.data) + + return { + equipmentList: unwrapListPayload(equipmentResult.data), + assetTypes: unwrapListPayload(assetTypeResult.data), + source: { + mode: 'live-api', + apiBase, + equipmentJsonPath, + assetTypesJsonPath + } + } +} + +const loadLiveData = async (options, outputDir) => { + if (normalizeText(options.apiBase)) { + return fetchLiveData(options, outputDir) + } + + const equipmentJsonPath = resolvePathArg( + options.equipmentJsonPath, + findLatestJsonByPrefix('live_equipment_list_parent_child_dry_run_') + ) + const assetTypesJsonPath = resolvePathArg( + options.assetTypesJsonPath, + findLatestJsonByPrefix('live_asset_types_parent_child_dry_run_') + ) + + if (!equipmentJsonPath || !fs.existsSync(equipmentJsonPath)) { + throw new Error('未找到 live 设备快照,请传入 --equipment-json 或使用 --api-base 登录拉取') + } + + if (!assetTypesJsonPath || !fs.existsSync(assetTypesJsonPath)) { + throw new Error('未找到 live 资产类型快照,请传入 --asset-types-json 或使用 --api-base 登录拉取') + } + + return { + equipmentList: unwrapListPayload(readJsonFile(equipmentJsonPath)), + assetTypes: unwrapListPayload(readJsonFile(assetTypesJsonPath)), + source: { + mode: 'snapshot', + equipmentJsonPath, + assetTypesJsonPath + } + } +} + +const buildReferenceMaps = (equipmentList, assetTypes) => { + const liveByCode = new Map() + for (const item of equipmentList) { + const code = normalizeLookupKey(item.equipment_code) + if (code) { + liveByCode.set(code, item) + } + } + + const assetTypeByName = new Map() + for (const item of assetTypes) { + assetTypeByName.set(normalizeLookupKey(item.name), item) + } + + return { + liveByCode, + assetTypeByName + } +} + +const buildDuplicateCodeSet = (rows) => { + const counts = new Map() + for (const row of rows) { + const code = normalizeLookupKey(row.assetCodeRaw) + if (!code) { + continue + } + counts.set(code, (counts.get(code) || 0) + 1) + } + + return new Set([...counts.entries()].filter(([, count]) => count > 1).map(([code]) => code)) +} + +const normalizeChildModel = (value) => normalizeText(value) + +const buildExistingChildKey = (child) => + `${normalizeLookupKey(child.equipment_type)}||${normalizeLookupKey(child.equipment_model)}` + +const pushCount = (counter, key, increment = 1) => { + counter[key] = (counter[key] || 0) + increment +} + +const analyzeRows = (workbookRows, refs) => { + const duplicateCodes = buildDuplicateCodeSet(workbookRows) + const plannedParents = [] + const plannedChildren = [] + const skippedRows = [] + const childSkips = [] + + const rowSkipReasonCounts = {} + const childSkipReasonCounts = {} + const plannedChildTypeCounts = {} + const plannedParentStatusCounts = {} + + for (const row of workbookRows) { + const workbookCode = normalizeLookupKey(row.assetCodeRaw) + const workbookAssetType = normalizeAssetTypeName(row.assetNameRaw) + const hardwareValues = [] + const manualSplitChildren = [] + + for (const spec of CHILD_FIELD_SPECS) { + const sourceModel = normalizeChildModel(row[`${spec.fieldKey}Raw`]) + if (!sourceModel) { + continue + } + + const expanded = expandHardwareModels(spec.childType, sourceModel) + if (expanded.isMultiUnit && !expanded.isAutoSplittable) { + manualSplitChildren.push({ + spec, + sourceModel, + reason: expanded.reason || 'child_model_requires_manual_split', + splitPattern: expanded.pattern || '' + }) + continue + } + + if (!expanded.models.length) { + continue + } + + expanded.models.forEach((model, index) => { + hardwareValues.push({ + spec, + model, + sourceModel, + splitIndex: expanded.isMultiUnit ? index + 1 : 1, + splitQuantity: expanded.isMultiUnit ? expanded.quantity : 1, + splitPattern: expanded.pattern || 'single_unit' + }) + }) + } + + const rowContext = { + source_row: row.sourceRow, + asset_code: row.assetCodeRaw, + asset_name: row.assetNameRaw, + mapped_asset_type: workbookAssetType, + current_status_raw: row.currentStatusRaw, + department: row.departmentRaw, + owner: row.ownerRaw, + address: row.addressRaw, + hardware_fields: hardwareValues.map((item) => `${item.spec.childName}:${item.model}`).join(' | ') + } + + const skipRow = (reason, extra = {}) => { + pushCount(rowSkipReasonCounts, reason) + skippedRows.push({ + ...rowContext, + reason, + ...extra + }) + } + + if (!workbookCode) { + skipRow('missing_workbook_asset_code') + continue + } + + if (duplicateCodes.has(workbookCode)) { + skipRow('duplicate_workbook_asset_code') + continue + } + + const workbookTypeConfig = refs.assetTypeByName.get(normalizeLookupKey(workbookAssetType)) + if (!workbookTypeConfig) { + skipRow('workbook_asset_type_not_found_in_live', { live_type_name: '' }) + continue + } + + if (!workbookTypeConfig.can_be_parent) { + skipRow('workbook_asset_type_not_parent_capable', { live_type_name: workbookTypeConfig.name }) + continue + } + + const liveParent = refs.liveByCode.get(workbookCode) + if (!liveParent) { + skipRow('live_parent_not_found', { live_type_name: '' }) + continue + } + + if (normalizeLookupKey(liveParent.equipment_type) !== normalizeLookupKey(workbookTypeConfig.name)) { + skipRow('live_parent_type_mismatch', { live_type_name: liveParent.equipment_type }) + continue + } + + const liveTypeConfig = refs.assetTypeByName.get(normalizeLookupKey(liveParent.equipment_type)) + if (!liveTypeConfig || !liveTypeConfig.can_be_parent) { + skipRow('live_parent_type_not_parent_capable', { live_type_name: liveParent.equipment_type }) + continue + } + + if (liveParent.parent_id != null) { + skipRow('live_parent_is_already_child', { live_type_name: liveParent.equipment_type }) + continue + } + + if (!PARENT_BUILDABLE_STATUSES.has(normalizeText(liveParent.status))) { + skipRow('live_parent_status_not_buildable', { + live_type_name: liveParent.equipment_type, + live_parent_status: liveParent.status + }) + continue + } + + const existingChildren = Array.isArray(liveParent.children) ? liveParent.children : [] + const existingChildQueues = new Map() + for (const child of existingChildren) { + const key = buildExistingChildKey(child) + const queue = existingChildQueues.get(key) || [] + queue.push(child) + existingChildQueues.set(key, queue) + } + const rowPlannedChildren = [] + + for (const manualItem of manualSplitChildren) { + pushCount(childSkipReasonCounts, manualItem.reason) + childSkips.push({ + ...rowContext, + child_type: manualItem.spec.childType, + child_name: manualItem.spec.childName, + child_model: manualItem.sourceModel, + source_child_model: manualItem.sourceModel, + split_index: '', + split_quantity: '', + split_pattern: manualItem.splitPattern, + reason: manualItem.reason + }) + } + + for (const { spec, model, sourceModel, splitIndex, splitQuantity, splitPattern } of hardwareValues) { + const childTypeConfig = refs.assetTypeByName.get(normalizeLookupKey(spec.childType)) + if (!childTypeConfig || !childTypeConfig.can_be_child) { + pushCount(childSkipReasonCounts, 'child_asset_type_not_child_capable') + childSkips.push({ + ...rowContext, + child_type: spec.childType, + child_name: spec.childName, + child_model: model, + source_child_model: sourceModel, + split_index: splitIndex, + split_quantity: splitQuantity, + split_pattern: splitPattern, + reason: 'child_asset_type_not_child_capable' + }) + continue + } + + const existingKey = `${normalizeLookupKey(spec.childType)}||${normalizeLookupKey(model)}` + const existingQueue = existingChildQueues.get(existingKey) || [] + if (existingQueue.length > 0) { + existingQueue.shift() + existingChildQueues.set(existingKey, existingQueue) + pushCount(childSkipReasonCounts, 'existing_child_same_type_and_model') + childSkips.push({ + ...rowContext, + child_type: spec.childType, + child_name: spec.childName, + child_model: model, + source_child_model: sourceModel, + split_index: splitIndex, + split_quantity: splitQuantity, + split_pattern: splitPattern, + reason: 'existing_child_same_type_and_model' + }) + continue + } + + const plannedChild = { + source_row: row.sourceRow, + parent_code: liveParent.equipment_code, + parent_id: liveParent.id, + parent_name: liveParent.equipment_name, + parent_type: liveParent.equipment_type, + parent_status: liveParent.status, + parent_department: liveParent.department?.name || '', + parent_location: liveParent.storageLocation?.name || '', + parent_owner: liveParent.current_borrow?.person_nickname || liveParent.current_borrow?.person_name || '', + workbook_department: row.departmentRaw, + workbook_owner: row.ownerRaw, + workbook_location: row.addressRaw, + field_key: spec.fieldKey, + child_type: spec.childType, + child_name: spec.childName, + child_model: model, + source_child_model: sourceModel, + split_index: splitIndex, + split_quantity: splitQuantity, + split_pattern: splitPattern, + planned_unit_price: 0, + planned_status: liveParent.status === '已借出' || liveParent.status === '维护中' ? liveParent.status : '可用', + planned_code_generation_mode: 'auto', + notes: `根据8.xlsx第${row.sourceRow}行补建` + } + + rowPlannedChildren.push(plannedChild) + plannedChildren.push(plannedChild) + pushCount(plannedChildTypeCounts, spec.childType) + } + + if (!rowPlannedChildren.length) { + skipRow('no_new_children_after_dedup', { + live_type_name: liveParent.equipment_type, + live_parent_status: liveParent.status + }) + continue + } + + plannedParents.push({ + source_row: row.sourceRow, + parent_code: liveParent.equipment_code, + parent_id: liveParent.id, + parent_name: liveParent.equipment_name, + parent_type: liveParent.equipment_type, + parent_status: liveParent.status, + child_count: rowPlannedChildren.length, + child_types: rowPlannedChildren.map((item) => item.child_type).join(' | '), + workbook_department: row.departmentRaw, + live_department: liveParent.department?.name || '', + workbook_location: row.addressRaw, + live_location: liveParent.storageLocation?.name || '', + workbook_owner: row.ownerRaw, + live_owner: liveParent.current_borrow?.person_nickname || liveParent.current_borrow?.person_name || '' + }) + pushCount(plannedParentStatusCounts, liveParent.status) + } + + return { + plannedParents, + plannedChildren, + skippedRows, + childSkips, + rowSkipReasonCounts, + childSkipReasonCounts, + plannedChildTypeCounts, + plannedParentStatusCounts + } +} + +const buildSummaryRows = (summary) => + Object.entries(summary).map(([key, value]) => ({ + key, + value: Array.isArray(value) || typeof value === 'object' ? JSON.stringify(value) : value + })) + +const run = async () => { + const options = parseArgs() + const outputDir = ensureOutputDir() + const workbookPath = resolvePathArg(options.workbookPath, WORKBOOK_PATH) + + if (!fs.existsSync(workbookPath)) { + throw new Error(`工作簿不存在: ${workbookPath}`) + } + + const liveData = await loadLiveData(options, outputDir) + const workbookRows = loadWorkbookRows(workbookPath) + const refs = buildReferenceMaps(liveData.equipmentList, liveData.assetTypes) + const analysis = analyzeRows(workbookRows, refs) + + const summary = { + generated_at: new Date().toISOString(), + workbook_path: workbookPath, + workbook_sheet: SHEET_NAME, + live_source_mode: liveData.source.mode, + live_equipment_source: liveData.source.equipmentJsonPath || '', + live_asset_types_source: liveData.source.assetTypesJsonPath || '', + workbook_rows_with_hardware_fields: workbookRows.length, + live_total_equipment: liveData.equipmentList.length, + live_total_asset_types: liveData.assetTypes.length, + planned_parent_count: analysis.plannedParents.length, + planned_child_count: analysis.plannedChildren.length, + skipped_row_count: analysis.skippedRows.length, + skipped_child_count: analysis.childSkips.length, + planned_parent_status_counts: analysis.plannedParentStatusCounts, + planned_child_type_counts: analysis.plannedChildTypeCounts, + skipped_row_reason_counts: analysis.rowSkipReasonCounts, + skipped_child_reason_counts: analysis.childSkipReasonCounts, + notes: [ + 'dry-run 只生成报告,不写入 live', + '子资产编码将在正式写入时自动生成,本次不预占编号', + '子资产价值按当前业务口径统一规划为 0' + ] + } + + const report = { + summary, + plannedParents: analysis.plannedParents, + plannedChildren: analysis.plannedChildren, + skippedRows: analysis.skippedRows, + skippedChildren: analysis.childSkips + } + + writeJson(path.join(outputDir, 'summary.json'), summary) + writeJson(path.join(outputDir, 'report.json'), report) + writeCsv( + path.join(outputDir, 'planned_parents.csv'), + Object.keys(analysis.plannedParents[0] || { parent_code: '', child_count: '' }), + analysis.plannedParents + ) + writeCsv( + path.join(outputDir, 'planned_children.csv'), + Object.keys(analysis.plannedChildren[0] || { parent_code: '', child_type: '', child_model: '' }), + analysis.plannedChildren + ) + writeCsv( + path.join(outputDir, 'skipped_rows.csv'), + Object.keys(analysis.skippedRows[0] || { asset_code: '', reason: '' }), + analysis.skippedRows + ) + writeCsv( + path.join(outputDir, 'skipped_children.csv'), + Object.keys(analysis.childSkips[0] || { parent_code: '', child_type: '', reason: '' }), + analysis.childSkips + ) + await writeWorkbook(path.join(outputDir, 'parent_child_dry_run.xlsx'), [ + { name: 'Summary', rows: buildSummaryRows(summary) }, + { name: 'PlannedParents', rows: analysis.plannedParents }, + { name: 'PlannedChildren', rows: analysis.plannedChildren }, + { name: 'SkippedRows', rows: analysis.skippedRows }, + { name: 'SkippedChildren', rows: analysis.childSkips } + ]) + + console.log( + JSON.stringify( + { + outputDir, + summary + }, + null, + 2 + ) + ) +} + +run().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/backend/scripts/repairMultiUnitMountedChildren.js b/backend/scripts/repairMultiUnitMountedChildren.js new file mode 100644 index 0000000..3294057 --- /dev/null +++ b/backend/scripts/repairMultiUnitMountedChildren.js @@ -0,0 +1,893 @@ +const fs = require('fs') +const path = require('path') + +const ExcelJS = require(path.resolve(__dirname, '../node_modules/exceljs')) +const { splitHardwareQuantity, expandHardwareModels, normalizeText } = require('./hardwareQuantitySplit') + +const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output') +const DEFAULT_API_BASE = 'https://www.jsgdzc.cn/api' +const REPAIR_MARKER = 'multi_unit_split_20260323' + +const parseArgs = () => { + const args = process.argv.slice(2) + const options = { + mode: 'preflight', + sourcePath: '', + apiBase: DEFAULT_API_BASE, + username: '', + password: '', + limit: 0, + parentCode: '', + sourceRow: 0 + } + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] + if (arg === '--mode') { + options.mode = args[index + 1] || options.mode + index += 1 + } else if (arg === '--source') { + options.sourcePath = args[index + 1] || '' + index += 1 + } else if (arg === '--api-base') { + options.apiBase = args[index + 1] || DEFAULT_API_BASE + index += 1 + } else if (arg === '--username') { + options.username = args[index + 1] || '' + index += 1 + } else if (arg === '--password') { + options.password = args[index + 1] || '' + index += 1 + } else if (arg === '--limit') { + options.limit = Number(args[index + 1] || 0) || 0 + index += 1 + } else if (arg === '--parent-code') { + options.parentCode = args[index + 1] || '' + index += 1 + } else if (arg === '--source-row') { + options.sourceRow = Number(args[index + 1] || 0) || 0 + index += 1 + } + } + + return options +} + +const normalizeLookupKey = (value) => normalizeText(value).toUpperCase() + +const sanitizeTokenValue = (value) => normalizeText(value).replace(/;/g, ',') + +const findLatestApplyResultsPath = () => { + if (!fs.existsSync(OUTPUT_ROOT)) { + return '' + } + + const candidates = fs + .readdirSync(OUTPUT_ROOT, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name.startsWith('parent_child_live_apply_')) + .map((entry) => path.join(OUTPUT_ROOT, entry.name, 'apply_results.json')) + .filter((filePath) => fs.existsSync(filePath)) + .sort() + + return candidates[candidates.length - 1] || '' +} + +const resolveSourcePath = (value) => { + const normalized = normalizeText(value) + if (normalized) { + const resolved = path.isAbsolute(normalized) ? normalized : path.resolve(process.cwd(), normalized) + if (!fs.existsSync(resolved)) { + throw new Error(`source 文件不存在: ${resolved}`) + } + return resolved + } + + const latest = findLatestApplyResultsPath() + if (!latest) { + throw new Error(`未找到可用的 apply_results.json: ${OUTPUT_ROOT}`) + } + return latest +} + +const ensureOutputDir = (prefix) => { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19) + const outputDir = path.join(OUTPUT_ROOT, `${prefix}_${timestamp}`) + fs.mkdirSync(outputDir, { recursive: true }) + return outputDir +} + +const readJsonFile = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8')) + +const writeJson = (filePath, value) => { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`) +} + +const escapeCsv = (value) => { + const stringValue = String(value == null ? '' : value) + if (/[,"\n]/.test(stringValue)) { + return `"${stringValue.replace(/"/g, '""')}"` + } + return stringValue +} + +const writeCsv = (filePath, headers, rows) => { + const lines = [headers.map(escapeCsv).join(',')] + for (const row of rows) { + lines.push(headers.map((header) => escapeCsv(row[header] ?? '')).join(',')) + } + fs.writeFileSync(filePath, `\uFEFF${lines.join('\n')}`) +} + +const buildSummaryRows = (summary) => + Object.entries(summary).map(([key, value]) => ({ + key, + value: Array.isArray(value) || (value && typeof value === 'object') ? JSON.stringify(value) : value + })) + +const writeWorkbook = async (filePath, sheets) => { + const workbook = new ExcelJS.Workbook() + workbook.creator = 'Codex' + workbook.created = new Date() + + for (const sheet of sheets) { + const rows = Array.isArray(sheet.rows) && sheet.rows.length ? sheet.rows : [{}] + const headers = [...new Set(rows.flatMap((row) => Object.keys(row)))] + const worksheet = workbook.addWorksheet(sheet.name) + worksheet.columns = headers.map((header) => ({ + header, + key: header, + width: Math.min(Math.max(header.length + 4, 16), 60) + })) + rows.forEach((row) => worksheet.addRow(row)) + worksheet.getRow(1).font = { bold: true } + worksheet.views = [{ state: 'frozen', ySplit: 1 }] + } + + await workbook.xlsx.writeFile(filePath) +} + +const buildCookieHeader = (response) => { + if (typeof response.headers.getSetCookie === 'function') { + return response.headers.getSetCookie().map((item) => item.split(';')[0]).join('; ') + } + + const setCookie = response.headers.get('set-cookie') + return setCookie ? setCookie.split(';')[0] : '' +} + +class ApiSession { + constructor({ apiBase, username, password }) { + this.apiBase = normalizeText(apiBase).replace(/\/$/, '') + this.username = normalizeText(username) + this.password = String(password == null ? '' : password) + this.cookieHeader = '' + } + + async login() { + if (!this.apiBase || !this.username || !this.password) { + throw new Error('缺少 API 登录参数') + } + + const response = await fetch(`${this.apiBase}/auth/local/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: this.username, + password: this.password + }) + }) + + const text = await response.text() + if (!response.ok) { + throw new Error(`登录失败: status=${response.status} body=${text}`) + } + + this.cookieHeader = buildCookieHeader(response) + if (!this.cookieHeader) { + throw new Error('登录成功但未拿到 session cookie') + } + + return JSON.parse(text) + } + + async requestJson(endpoint, options = {}, retry = true) { + if (!this.cookieHeader) { + await this.login() + } + + const headers = { + ...(options.headers || {}), + Cookie: this.cookieHeader + } + + const response = await fetch(`${this.apiBase}${endpoint}`, { + ...options, + headers + }) + const text = await response.text() + + if (response.status === 401 && retry) { + await this.login() + return this.requestJson(endpoint, options, false) + } + + if (!response.ok) { + throw new Error(`请求失败: ${endpoint} status=${response.status} body=${text}`) + } + + return JSON.parse(text) + } +} + +const unwrapPayload = (payload) => { + if (Array.isArray(payload)) { + return payload + } + + if (payload?.data) { + return payload.data + } + + if (payload?.list) { + return payload.list + } + + return payload +} + +const summarizeByReason = (rows, reasonKey = 'reason') => { + const counts = {} + for (const row of rows) { + const reason = normalizeText(row[reasonKey]) || 'unknown' + counts[reason] = (counts[reason] || 0) + 1 + } + return counts +} + +const stripRepairTokens = (description) => + normalizeText(description) + .split(';') + .map((item) => item.trim()) + .filter( + (item) => + item && + !/^repair_marker=/i.test(item) && + !/^repair_source_row=/i.test(item) && + !/^repair_parent_code=/i.test(item) && + !/^repair_field_key=/i.test(item) && + !/^repair_original_model=/i.test(item) && + !/^repair_unit_model=/i.test(item) && + !/^repair_unit_models=/i.test(item) && + !/^repair_quantity=/i.test(item) && + !/^repair_split_index=/i.test(item) + ) + .join('; ') + +const buildRepairDescription = (description, action, splitIndex, unitModel) => { + const base = stripRepairTokens(description) + const tokens = [ + ['repair_marker', REPAIR_MARKER], + ['repair_source_row', action.source_row], + ['repair_parent_code', action.parent_code], + ['repair_field_key', action.field_key], + ['repair_original_model', action.source_child_model], + ['repair_unit_model', unitModel], + ['repair_unit_models', action.desired_models.join(' | ')], + ['repair_quantity', action.quantity], + ['repair_split_index', splitIndex] + ].map(([key, value]) => `${key}=${sanitizeTokenValue(value)}`) + + return [base, ...tokens].filter(Boolean).join('; ') +} + +const buildRepairMountNote = (action, splitIndex, unitModel) => + `多件拆分修正:根据8.xlsx第${action.source_row}行,将${sanitizeTokenValue(action.source_child_model)}拆为第${splitIndex}/${action.quantity}件,型号=${sanitizeTokenValue(unitModel)}` + +const loadSourceRows = (sourcePath, options) => { + let rows = readJsonFile(sourcePath) + if (!Array.isArray(rows)) { + throw new Error(`source 文件结构不正确,应为数组: ${sourcePath}`) + } + + if (normalizeText(options.parentCode)) { + const parentCodeKey = normalizeLookupKey(options.parentCode) + rows = rows.filter((item) => normalizeLookupKey(item.parent_code) === parentCodeKey) + } + + if (options.sourceRow > 0) { + rows = rows.filter((item) => Number(item.source_row) === Number(options.sourceRow)) + } + + if (options.limit > 0) { + rows = rows.slice(0, options.limit) + } + + return rows +} + +const countModelKeys = (models) => { + const counts = new Map() + for (const model of models) { + const key = normalizeLookupKey(model) + if (!key) { + continue + } + counts.set(key, { + key, + model, + count: (counts.get(key)?.count || 0) + 1 + }) + } + return counts +} + +const findDesiredModelIndexes = (models, targetModel) => + models + .map((model, index) => ({ + model, + index: index + 1 + })) + .filter((item) => normalizeLookupKey(item.model) === normalizeLookupKey(targetModel)) + .map((item) => item.index) + +const buildRepairCandidates = (rows) => { + const autoFixable = [] + const manualReview = [] + + for (const row of rows) { + if (normalizeText(row.apply_status) && normalizeText(row.apply_status) !== 'success') { + continue + } + + const split = splitHardwareQuantity(row.child_type, row.child_model) + const expanded = expandHardwareModels(row.child_type, row.child_model) + if (!split.isMultiUnit) { + continue + } + + const base = { + source_row: row.source_row, + parent_code: row.parent_code, + parent_name: row.parent_name, + parent_type: row.parent_type, + field_key: row.field_key, + child_type: row.child_type, + child_name: row.child_name, + source_child_model: row.child_model, + current_child_id: row.child_id, + current_child_code: row.child_code, + live_parent_id: row.live_parent_id, + planned_description: row.planned_description || '', + parent_storage_location_id: row.parent_storage_location_id || '', + parent_department_id: row.parent_department_id || '' + } + + if (!split.isAutoSplittable) { + manualReview.push({ + ...base, + quantity: split.quantity || '', + unit_model: '', + desired_models: '', + split_pattern: split.pattern || '', + reason: split.reason || 'multi_unit_model_requires_manual_review' + }) + continue + } + + if ((expanded.models || []).length !== 2) { + manualReview.push({ + ...base, + quantity: split.quantity, + unit_model: split.unitModel, + desired_models: (expanded.models || []).join(' | '), + split_pattern: split.pattern || '', + reason: 'quantity_greater_than_two_not_auto_applied' + }) + continue + } + + autoFixable.push({ + ...base, + quantity: expanded.models.length, + unit_model: split.unitModel, + desired_models: expanded.models, + desired_models_json: JSON.stringify(expanded.models), + split_pattern: split.pattern || '' + }) + } + + return { + autoFixable, + manualReview + } +} + +const analyzeCurrentState = async (session, candidates) => { + const parentIds = [...new Set(candidates.map((item) => Number(item.live_parent_id)).filter((item) => item > 0))] + const childIds = [...new Set(candidates.map((item) => Number(item.current_child_id)).filter((item) => item > 0))] + + const parentMap = new Map() + const childMap = new Map() + + for (const parentId of parentIds) { + const payload = await session.requestJson(`/equipment/${parentId}`) + parentMap.set(parentId, unwrapPayload(payload)) + } + + for (const childId of childIds) { + const payload = await session.requestJson(`/equipment/${childId}`) + childMap.set(childId, unwrapPayload(payload)) + } + + const actionable = [] + const blocked = [] + const satisfied = [] + + for (const candidate of candidates) { + const desiredModels = Array.isArray(candidate.desired_models) + ? candidate.desired_models + : JSON.parse(candidate.desired_models_json || '[]') + const desiredModelCounts = countModelKeys(desiredModels) + const parent = parentMap.get(Number(candidate.live_parent_id)) + const currentChild = childMap.get(Number(candidate.current_child_id)) + const base = { + ...candidate, + desired_models: desiredModels, + current_child_status: currentChild?.status || '', + current_child_model: currentChild?.equipment_model || '', + current_child_parent_id: currentChild?.parent_id || '' + } + + if (!parent) { + blocked.push({ + ...base, + reason: 'live_parent_not_found' + }) + continue + } + + if (!currentChild) { + blocked.push({ + ...base, + reason: 'current_child_not_found' + }) + continue + } + + if (Number(currentChild.parent_id) !== Number(candidate.live_parent_id)) { + blocked.push({ + ...base, + reason: 'current_child_not_mounted_to_expected_parent' + }) + continue + } + + const sameTypeChildren = (Array.isArray(parent.children) ? parent.children : []).filter( + (child) => + normalizeLookupKey(child.equipment_type) === normalizeLookupKey(candidate.child_type) + ) + + const currentModelKey = normalizeLookupKey(currentChild.equipment_model) + const originalModelKey = normalizeLookupKey(candidate.source_child_model) + const siblingDesiredCounts = new Map() + for (const child of sameTypeChildren) { + if (Number(child.id) === Number(candidate.current_child_id)) { + continue + } + + const key = normalizeLookupKey(child.equipment_model) + const desired = desiredModelCounts.get(key) + if (!desired) { + continue + } + + siblingDesiredCounts.set(key, { + key, + model: desired.model, + count: (siblingDesiredCounts.get(key)?.count || 0) + 1 + }) + } + + const totalSiblingDesiredCount = [...siblingDesiredCounts.values()].reduce((sum, item) => sum + item.count, 0) + const missingModelsForAssignment = (assignedModel) => { + const assignedKey = normalizeLookupKey(assignedModel) + const countsAfterAssign = new Map(siblingDesiredCounts) + if (desiredModelCounts.has(assignedKey)) { + countsAfterAssign.set(assignedKey, { + key: assignedKey, + model: desiredModelCounts.get(assignedKey).model, + count: (countsAfterAssign.get(assignedKey)?.count || 0) + 1 + }) + } + + const totalDesiredAfterAssign = [...countsAfterAssign.values()].reduce((sum, item) => sum + item.count, 0) + if (totalDesiredAfterAssign > desiredModels.length) { + return null + } + + const missing = [] + for (const desired of desiredModelCounts.values()) { + const currentCount = countsAfterAssign.get(desired.key)?.count || 0 + for (let index = currentCount; index < desired.count; index += 1) { + missing.push(desired.model) + } + } + + return missing + } + + const assignmentOptions = [] + if (desiredModelCounts.has(currentModelKey)) { + assignmentOptions.push({ + targetModel: currentChild.equipment_model, + updateExisting: false + }) + } + if (currentModelKey === originalModelKey) { + for (const desiredModel of desiredModels) { + if (!assignmentOptions.some((item) => normalizeLookupKey(item.targetModel) === normalizeLookupKey(desiredModel))) { + assignmentOptions.push({ + targetModel: desiredModel, + updateExisting: true + }) + } + } + } + + if (assignmentOptions.length === 0) { + blocked.push({ + ...base, + reason: 'current_child_model_is_not_repairable' + }) + continue + } + + const candidatePlans = assignmentOptions + .map((option) => { + const missingModels = missingModelsForAssignment(option.targetModel) + if (!missingModels || missingModels.length > 1) { + return null + } + + return { + ...option, + missingModels, + desiredMatchingSiblingCount: totalSiblingDesiredCount + } + }) + .filter(Boolean) + .sort((left, right) => { + if (left.missingModels.length !== right.missingModels.length) { + return left.missingModels.length - right.missingModels.length + } + if (left.updateExisting !== right.updateExisting) { + return left.updateExisting ? -1 : 1 + } + return desiredModels.findIndex((item) => normalizeLookupKey(item) === normalizeLookupKey(left.targetModel)) + - desiredModels.findIndex((item) => normalizeLookupKey(item) === normalizeLookupKey(right.targetModel)) + }) + + const selectedPlan = candidatePlans[0] + if (!selectedPlan) { + blocked.push({ + ...base, + reason: 'current_state_requires_manual_review' + }) + continue + } + + if (!selectedPlan.updateExisting && selectedPlan.missingModels.length === 0) { + satisfied.push({ + ...base, + reason: 'already_split_to_expected_quantity' + }) + continue + } + + const currentTargetIndexes = findDesiredModelIndexes(desiredModels, selectedPlan.targetModel) + const createIndexes = selectedPlan.missingModels[0] + ? findDesiredModelIndexes(desiredModels, selectedPlan.missingModels[0]) + : [] + const currentTargetIndex = currentTargetIndexes[0] || 1 + const createIndex = selectedPlan.missingModels[0] + ? ( + normalizeLookupKey(selectedPlan.missingModels[0]) === normalizeLookupKey(selectedPlan.targetModel) && + createIndexes.length > 1 + ? createIndexes.find((item) => item !== currentTargetIndex) || createIndexes[0] + : createIndexes[0] + ) + : '' + + actionable.push({ + ...base, + action_type: + selectedPlan.updateExisting && selectedPlan.missingModels.length === 1 + ? 'update_existing_and_create_one' + : selectedPlan.updateExisting + ? 'update_existing_only' + : 'create_one_missing_unit_child', + current_target_model: selectedPlan.targetModel, + create_model: selectedPlan.missingModels[0] || '', + current_target_index: currentTargetIndex, + create_index: createIndex + }) + } + + return { + parentMap, + childMap, + actionable, + blocked, + satisfied + } +} + +const buildCreateChildPayload = (action, parent, sourceDescription, splitIndex, unitModel) => ({ + entry_mode: 'child', + equipment_name: action.child_name, + equipment_model: unitModel, + equipment_type: action.child_type, + code_generation_mode: 'auto', + unit_price: 0, + total_quantity: 1, + remaining_quantity: 1, + status: '可用', + storage_location_id: parent.storage_location_id, + department_id: parent.department_id || null, + description: buildRepairDescription(sourceDescription, action, splitIndex, unitModel) +}) + +const applyRepairs = async (session, analysis) => { + const results = [] + const errors = [] + + for (let index = 0; index < analysis.actionable.length; index += 1) { + const action = analysis.actionable[index] + const progress = `${index + 1}/${analysis.actionable.length}` + console.log(`[${progress}] repair ${action.parent_code} ${action.child_type} ${action.source_child_model}`) + + let createdChildId = null + let createdChildCode = '' + let existingChildUpdated = false + let originalDescription = '' + let originalModel = '' + + try { + const parentPayload = await session.requestJson(`/equipment/${action.live_parent_id}`) + const parent = unwrapPayload(parentPayload) + const childPayload = await session.requestJson(`/equipment/${action.current_child_id}`) + const currentChild = unwrapPayload(childPayload) + + if (!parent || !currentChild) { + throw new Error('父资产或当前子资产不存在') + } + + originalDescription = currentChild.description || '' + originalModel = currentChild.equipment_model || '' + + if (action.action_type === 'update_existing_and_create_one' || action.action_type === 'update_existing_only') { + await session.requestJson(`/equipment/${action.current_child_id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + equipment_model: action.current_target_model, + description: buildRepairDescription( + originalDescription, + action, + Number(action.current_target_index) || 1, + action.current_target_model + ) + }) + }) + existingChildUpdated = true + } + + if (action.create_model) { + const createResult = await session.requestJson('/equipment', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify( + buildCreateChildPayload( + action, + parent, + originalDescription, + Number(action.create_index) || 2, + action.create_model + ) + ) + }) + + const createdChild = unwrapPayload(createResult) + createdChildId = Number(createdChild?.id) || null + createdChildCode = normalizeText(createdChild?.equipment_code) + if (!createdChildId || !createdChildCode) { + throw new Error('新建补充子资产返回结果不完整') + } + + await session.requestJson(`/mount/${action.live_parent_id}/mount`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + child_id: createdChildId, + notes: buildRepairMountNote( + action, + Number(action.create_index) || 2, + action.create_model + ) + }) + }) + } + + results.push({ + ...action, + apply_status: 'success', + created_child_id: createdChildId || '', + created_child_code: createdChildCode || '' + }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + + if (createdChildId) { + try { + await session.requestJson(`/equipment/${createdChildId}`, { + method: 'DELETE' + }) + } catch (cleanupError) { + console.error(`[cleanup] 删除新建子资产失败 id=${createdChildId}`, cleanupError) + } + } + + if (existingChildUpdated) { + try { + await session.requestJson(`/equipment/${action.current_child_id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + equipment_model: originalModel, + description: originalDescription + }) + }) + } catch (rollbackError) { + console.error(`[rollback] 回滚原子资产失败 id=${action.current_child_id}`, rollbackError) + } + } + + errors.push({ + ...action, + apply_status: 'error', + error_message: errorMessage, + created_child_id: createdChildId || '', + created_child_code: createdChildCode || '' + }) + } + } + + return { + results, + errors + } +} + +const run = async () => { + const options = parseArgs() + const sourcePath = resolveSourcePath(options.sourcePath) + const sourceRows = loadSourceRows(sourcePath, options) + const outputDir = ensureOutputDir('multi_unit_child_repair') + const sourceCandidates = buildRepairCandidates(sourceRows) + + const summaryBase = { + generated_at: new Date().toISOString(), + mode: options.mode, + source_path: sourcePath, + api_base: options.apiBase, + source_total_rows: sourceRows.length, + detected_multi_unit_rows: sourceCandidates.autoFixable.length + sourceCandidates.manualReview.length, + auto_fixable_candidate_count: sourceCandidates.autoFixable.length, + manual_review_candidate_count: sourceCandidates.manualReview.length + } + + let analysis = { + actionable: [], + blocked: [], + satisfied: [] + } + let applyResult = { + results: [], + errors: [] + } + + if (sourceCandidates.autoFixable.length > 0) { + const session = new ApiSession({ + apiBase: options.apiBase, + username: options.username, + password: options.password + }) + analysis = await analyzeCurrentState(session, sourceCandidates.autoFixable) + + if (normalizeText(options.mode).toLowerCase() === 'apply' && analysis.actionable.length > 0) { + applyResult = await applyRepairs(session, analysis) + } + } + + const summary = { + ...summaryBase, + actionable_count: analysis.actionable.length, + blocked_count: analysis.blocked.length, + satisfied_count: analysis.satisfied.length, + manual_review_reason_counts: summarizeByReason(sourceCandidates.manualReview), + blocked_reason_counts: summarizeByReason(analysis.blocked), + satisfied_reason_counts: summarizeByReason(analysis.satisfied), + apply_success_count: applyResult.results.length, + apply_error_count: applyResult.errors.length + } + + const report = { + summary, + autoFixableCandidates: sourceCandidates.autoFixable, + manualReviewCandidates: sourceCandidates.manualReview, + actionable: analysis.actionable, + blocked: analysis.blocked, + satisfied: analysis.satisfied, + applyResults: applyResult.results, + applyErrors: applyResult.errors + } + + writeJson(path.join(outputDir, 'summary.json'), summary) + writeJson(path.join(outputDir, 'report.json'), report) + writeCsv( + path.join(outputDir, 'manual_review_candidates.csv'), + Object.keys(sourceCandidates.manualReview[0] || { parent_code: '', child_type: '', source_child_model: '', reason: '' }), + sourceCandidates.manualReview + ) + writeCsv( + path.join(outputDir, 'actionable.csv'), + Object.keys(analysis.actionable[0] || { parent_code: '', child_type: '', source_child_model: '', unit_model: '', action_type: '' }), + analysis.actionable + ) + writeCsv( + path.join(outputDir, 'blocked.csv'), + Object.keys(analysis.blocked[0] || { parent_code: '', child_type: '', source_child_model: '', reason: '' }), + analysis.blocked + ) + writeCsv( + path.join(outputDir, 'satisfied.csv'), + Object.keys(analysis.satisfied[0] || { parent_code: '', child_type: '', source_child_model: '', reason: '' }), + analysis.satisfied + ) + writeCsv( + path.join(outputDir, 'apply_results.csv'), + Object.keys(applyResult.results[0] || { parent_code: '', child_type: '', source_child_model: '', created_child_code: '', apply_status: '' }), + applyResult.results + ) + writeCsv( + path.join(outputDir, 'apply_errors.csv'), + Object.keys(applyResult.errors[0] || { parent_code: '', child_type: '', source_child_model: '', error_message: '' }), + applyResult.errors + ) + + await writeWorkbook(path.join(outputDir, 'multi_unit_child_repair.xlsx'), [ + { name: 'Summary', rows: buildSummaryRows(summary) }, + { name: 'ManualReview', rows: sourceCandidates.manualReview }, + { name: 'Actionable', rows: analysis.actionable }, + { name: 'Blocked', rows: analysis.blocked }, + { name: 'Satisfied', rows: analysis.satisfied }, + { name: 'ApplyResults', rows: applyResult.results }, + { name: 'ApplyErrors', rows: applyResult.errors } + ]) + + console.log( + JSON.stringify( + { + outputDir, + summary + }, + null, + 2 + ) + ) +} + +run().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/backend/src/controllers/mountController.ts b/backend/src/controllers/mountController.ts index 1f69275..d67e136 100644 --- a/backend/src/controllers/mountController.ts +++ b/backend/src/controllers/mountController.ts @@ -3,11 +3,23 @@ import prisma from '../utils/prisma' import { successResponse, errorResponse } from '../utils/response' import { ensureNoActiveBorrowRecords } from '../utils/equipmentGuards' import { + calculateEquipmentValues, calculateChildrenTotalValueFromRecords, refreshParentTotalValueWithChildren } from '../utils/assetValue' -const ensureMountableEquipment = async ( +type MountLifecycleStatus = '可用' | '已借出' | '维护中' | '待报废' | '已报废' + +const PARENT_MOUNTABLE_STATUSES = new Set(['可用', '已借出', '维护中']) +const DETACHED_CHILD_ALLOWED_STATUSES = new Set(['可用', '维护中', '待报废', '已报废']) + +const ensureParentMountOperable = (equipment: { status: string }, label: string) => { + if (!PARENT_MOUNTABLE_STATUSES.has(equipment.status as MountLifecycleStatus)) { + throw new Error(`${label}当前状态不可挂载或更换配件`) + } +} + +const ensureChildAttachable = async ( equipment: { id: number; status: string; remaining_quantity: number }, label: string ) => { @@ -18,6 +30,51 @@ const ensureMountableEquipment = async ( await ensureNoActiveBorrowRecords(equipment.id, `${label}存在未归还记录,当前不可挂载`) } +const resolveDetachedChildStatus = (status?: string): MountLifecycleStatus => { + const normalized = (status || '').trim() + if (!normalized) { + return '可用' + } + + if (!DETACHED_CHILD_ALLOWED_STATUSES.has(normalized as MountLifecycleStatus)) { + throw new Error('拆下配件状态不合法') + } + + return normalized as MountLifecycleStatus +} + +const buildEquipmentStateUpdate = ( + equipment: { unit_price: unknown; total_quantity: number; status: string }, + targetStatus: MountLifecycleStatus +) => { + const totalQuantity = Math.max(1, Number(equipment.total_quantity) || 1) + const remainingQuantity = targetStatus === '已借出' ? 0 : totalQuantity + const values = calculateEquipmentValues({ + unitPrice: Number(equipment.unit_price) || 0, + totalQuantity, + remainingQuantity, + status: targetStatus + }) + + return { + status: targetStatus, + remaining_quantity: remainingQuantity, + total_value: values.total_value, + borrowed_value: values.borrowed_value, + available_value: values.available_value + } +} + +const buildMountedChildStateUpdate = ( + parent: { status: string }, + child: { unit_price: unknown; total_quantity: number; status: string } +) => { + const inheritedStatus = parent.status === '已借出' || parent.status === '维护中' + ? (parent.status as MountLifecycleStatus) + : '可用' + return buildEquipmentStateUpdate(child, inheritedStatus) +} + const ensureNoMountCycle = async (parentId: number, childId: number) => { if (parentId === childId) { throw new Error('父资产和子资产不能是同一条记录') @@ -68,7 +125,7 @@ export const mountChild = async (req: Request, res: Response) => { } try { - await ensureMountableEquipment(parent, '父资产') + ensureParentMountOperable(parent, '父资产') } catch (error: any) { return errorResponse(res, error.message || '父资产状态校验失败', 422) } @@ -85,7 +142,7 @@ export const mountChild = async (req: Request, res: Response) => { } try { - await ensureMountableEquipment(child, '子资产') + await ensureChildAttachable(child, '子资产') } catch (error: any) { return errorResponse(res, error.message || '子资产状态校验失败', 422) } @@ -102,7 +159,8 @@ export const mountChild = async (req: Request, res: Response) => { where: { id: child_id }, data: { parent_id: numericParentId, - mount_status: 'mounted' + mount_status: 'mounted', + ...buildMountedChildStateUpdate(parent, child) } }) @@ -145,8 +203,13 @@ export const mountChild = async (req: Request, res: Response) => { export const unmountChild = async (req: Request, res: Response) => { try { const numericParentId = Number(req.params.parentId) - const { child_id, notes } = req.body as { child_id: number; notes?: string } + const { child_id, notes, released_child_status } = req.body as { + child_id: number + notes?: string + released_child_status?: string + } const userId = Number(res.locals.user?.id) + const detachedChildStatus = resolveDetachedChildStatus(released_child_status) if (!userId) { return errorResponse(res, '用户未登录', 401) @@ -170,8 +233,7 @@ export const unmountChild = async (req: Request, res: Response) => { } try { - await ensureMountableEquipment(parent, '父资产') - await ensureMountableEquipment(child, '子资产') + ensureParentMountOperable(parent, '父资产') } catch (error: any) { return errorResponse(res, error.message || '卸载状态校验失败', 422) } @@ -181,7 +243,8 @@ export const unmountChild = async (req: Request, res: Response) => { where: { id: child_id }, data: { parent_id: null, - mount_status: 'unmounted' + mount_status: 'unmounted', + ...buildEquipmentStateUpdate(child, detachedChildStatus) } }) @@ -207,12 +270,14 @@ export const unmountChild = async (req: Request, res: Response) => { export const replaceChild = async (req: Request, res: Response) => { try { const numericParentId = Number(req.params.parentId) - const { old_child_id, new_child_id, notes } = req.body as { + const { old_child_id, new_child_id, notes, released_child_status } = req.body as { old_child_id: number new_child_id: number notes?: string + released_child_status?: string } const userId = Number(res.locals.user?.id) + const detachedChildStatus = resolveDetachedChildStatus(released_child_status) if (!userId) { return errorResponse(res, '用户未登录', 401) @@ -246,8 +311,7 @@ export const replaceChild = async (req: Request, res: Response) => { } try { - await ensureMountableEquipment(parent, '父资产') - await ensureMountableEquipment(oldChild, '旧配件') + ensureParentMountOperable(parent, '父资产') } catch (error: any) { return errorResponse(res, error.message || '挂载状态校验失败', 422) } @@ -267,7 +331,7 @@ export const replaceChild = async (req: Request, res: Response) => { } try { - await ensureMountableEquipment(newChild, '新配件') + await ensureChildAttachable(newChild, '新配件') } catch (error: any) { return errorResponse(res, error.message || '新配件状态校验失败', 422) } @@ -277,7 +341,8 @@ export const replaceChild = async (req: Request, res: Response) => { where: { id: old_child_id }, data: { parent_id: null, - mount_status: 'unmounted' + mount_status: 'unmounted', + ...buildEquipmentStateUpdate(oldChild, detachedChildStatus) } }) @@ -285,7 +350,8 @@ export const replaceChild = async (req: Request, res: Response) => { where: { id: new_child_id }, data: { parent_id: numericParentId, - mount_status: 'mounted' + mount_status: 'mounted', + ...buildMountedChildStateUpdate(parent, newChild) } }) @@ -438,10 +504,12 @@ export const getAvailableChildren = async (req: Request, res: Response) => { } if (keyword) { + const normalizedKeyword = String(keyword).trim() where.OR = [ - { equipment_name: { contains: keyword as string } }, - { equipment_code: { contains: keyword as string } }, - { equipment_model: { contains: keyword as string } } + { equipment_name: { contains: normalizedKeyword } }, + { equipment_code: { contains: normalizedKeyword } }, + { equipment_model: { contains: normalizedKeyword } }, + { equipment_type: { contains: normalizedKeyword } } ] } diff --git a/backend/src/controllers/statisticsController.ts b/backend/src/controllers/statisticsController.ts index 77f9e53..e7d77ec 100644 --- a/backend/src/controllers/statisticsController.ts +++ b/backend/src/controllers/statisticsController.ts @@ -26,6 +26,15 @@ type DisplayValueSummary = { maintenance_value: number } +type InventoryByTypeRow = { + equipment_type: string + total_quantity: number + available_quantity: number + borrowed_quantity: number + mountable_available_quantity: number + is_child_type: boolean +} + const toNumber = (value: unknown) => { if (value === null || value === undefined) return 0 const parsed = Number(value) @@ -82,17 +91,28 @@ export const getDashboardStats = async (req: Request, res: Response) => { const startOfToday = new Date(new Date().setHours(0, 0, 0, 0)) const [ + childAssetTypes, equipmentTotals, availableInventoryTotals, borrowableInventoryTotals, maintenanceTotals, inventoryByType, + mountableInventoryByType, displayValueRows, todayBorrowCount, todayReturnCount, todayBorrowRecords, todayReturnRecords ] = await Promise.all([ + prisma.assetType.findMany({ + where: { + can_be_child: true, + status: 'active' + }, + select: { + name: true + } + }), prisma.equipment.aggregate({ _sum: { total_quantity: true, @@ -134,6 +154,23 @@ export const getDashboardStats = async (req: Request, res: Response) => { remaining_quantity: true } }), + prisma.equipment.groupBy({ + by: ['equipment_type'], + where: { + parent_id: null, + mount_status: { in: ['none', 'unmounted'] }, + status: '可用', + remaining_quantity: { gt: 0 }, + borrow_records: { + none: { + status: '已借出' + } + } + }, + _sum: { + remaining_quantity: true + } + }), prisma.equipment.findMany({ where: { parent_id: null @@ -213,6 +250,11 @@ export const getDashboardStats = async (req: Request, res: Response) => { const usageRate = totalEquipmentQuantity > 0 ? (borrowedEquipmentQuantity / totalEquipmentQuantity) * 100 : 0 const valueUsageRate = totalValue > 0 ? (borrowedValue / totalValue) * 100 : 0 + const childTypeNames = new Set(childAssetTypes.map((item) => item.name)) + const mountableInventoryByTypeMap = mountableInventoryByType.reduce((map, item) => { + map.set(item.equipment_type || '其他', toNumber(item._sum.remaining_quantity)) + return map + }, new Map()) successResponse(res, { total_equipment: totalEquipmentQuantity, @@ -232,11 +274,13 @@ export const getDashboardStats = async (req: Request, res: Response) => { inventory_by_type: Array.from( inventoryByType.reduce((map, item) => { const equipmentType = item.equipment_type || '其他' - const current = map.get(equipmentType) || { + const current: InventoryByTypeRow = map.get(equipmentType) || { equipment_type: equipmentType, total_quantity: 0, available_quantity: 0, - borrowed_quantity: 0 + borrowed_quantity: 0, + mountable_available_quantity: mountableInventoryByTypeMap.get(equipmentType) || 0, + is_child_type: childTypeNames.has(equipmentType) } const totalQuantity = toNumber(item._sum.total_quantity) const remainingQuantity = toNumber(item._sum.remaining_quantity) @@ -252,12 +296,7 @@ export const getDashboardStats = async (req: Request, res: Response) => { map.set(equipmentType, current) return map - }, new Map()).values() + }, new Map()).values() ) .sort((a, b) => b.total_quantity - a.total_quantity) .filter((item) => item.total_quantity > 0) diff --git a/frontend/src/api/mount.ts b/frontend/src/api/mount.ts index 53d0630..d1184e6 100644 --- a/frontend/src/api/mount.ts +++ b/frontend/src/api/mount.ts @@ -1,5 +1,6 @@ import api from './index' import type { ApiResponse } from '@/types' +import type { EquipmentStatus } from '@/types' export interface MountChildRequest { child_id: number @@ -9,12 +10,14 @@ export interface MountChildRequest { export interface UnmountChildRequest { child_id: number notes?: string + released_child_status?: Extract } export interface ReplaceChildRequest { old_child_id: number new_child_id: number notes?: string + released_child_status?: Extract } export interface ChildEquipment { diff --git a/frontend/src/components/MountHistory.vue b/frontend/src/components/MountHistory.vue index ac1092f..b94cf92 100644 --- a/frontend/src/components/MountHistory.vue +++ b/frontend/src/components/MountHistory.vue @@ -43,9 +43,6 @@ 配件更换:{{ record.child?.equipment_name }} -
- 备注:{{ record.notes }} -
{{ record.operator?.name || record.operator?.username || '-' }} diff --git a/frontend/src/components/MountManager.vue b/frontend/src/components/MountManager.vue index 51154ef..8632e96 100644 --- a/frontend/src/components/MountManager.vue +++ b/frontend/src/components/MountManager.vue @@ -17,7 +17,7 @@
@@ -28,7 +28,10 @@ {{ child.equipment_type }}
- {{ child.equipment_code }} · ¥{{ formatPrice(child.unit_price) }} + {{ child.equipment_code }} · 型号:{{ child.equipment_model || '未填写型号' }} +
+
+ 单价:¥{{ formatPrice(child.unit_price) }}
@@ -62,7 +65,7 @@

选择要挂载的配件

-

只能挂载当前可用且没有未归还记录的配件资产。

+

这里只显示空闲可挂载的独立配件:未挂载、当前可用、且没有未归还记录。已经挂在主机上的配件不会出现在这里。挂载后会继承父资产当前状态。

@@ -70,16 +73,16 @@ v-model="searchKeyword" class="input" type="text" - placeholder="搜索配件名称或编码" + placeholder="搜索名称、编码、型号或类型" @input="fetchAvailableChildren" />
- 没有可挂载的配件 + {{ availableChildrenEmptyMessage }}
{{ item.equipment_type }}
- {{ item.equipment_code }} · ¥{{ formatPrice(item.unit_price) }} + {{ item.equipment_code }} · 型号:{{ item.equipment_model || '未填写型号' }} +
+
+ 单价:¥{{ formatPrice(item.unit_price) }}
@@ -105,7 +111,7 @@

更换配件:{{ replacingChild?.equipment_name }}

-

新配件需要同样处于可用状态,且不能已经挂载到别的父资产上。

+

这里只显示空闲可挂载的新配件:未挂载、当前可用、且没有未归还记录。替换时会同步让新配件继承父资产状态,并为旧配件选择拆下后的状态。

@@ -113,16 +119,16 @@ v-model="searchKeyword" class="input" type="text" - placeholder="搜索新配件" + placeholder="搜索名称、编码、型号或类型" @input="fetchAvailableChildren" />
- 没有可用的配件 + {{ availableChildrenEmptyMessage }}
{{ item.equipment_type }}
- {{ item.equipment_code }} · ¥{{ formatPrice(item.unit_price) }} + {{ item.equipment_code }} · 型号:{{ item.equipment_model || '未填写型号' }} +
+
+ 单价:¥{{ formatPrice(item.unit_price) }}
@@ -147,9 +156,10 @@