GuDingZiChan/backend/scripts/repairMultiUnitMountedChildren.js
2026-03-24 14:21:09 +08:00

894 lines
28 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

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)
})