Replace magic strings with AuditAction/AuditStatus enums across all consumers, add logAuditWithCurrentUser() convenience wrapper, extend audit coverage to data import, result export, app update, and ERP credentials operations, and harden crash handlers with try/catch to prevent audit failures from cascading. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
316 lines
8.7 KiB
TypeScript
316 lines
8.7 KiB
TypeScript
/**
|
|
* Data Import Service
|
|
*
|
|
* Reads Excel files and imports data to the DiscreteMaterialPlanData table.
|
|
* Workflow:
|
|
* 1. Read Excel file
|
|
* 2. Extract unique SourceNumbers
|
|
* 3. Delete existing records by SourceNumber
|
|
* 4. Batch insert new records
|
|
*/
|
|
|
|
import { createLogger } from '../logger'
|
|
import { logAuditWithCurrentUser } from '../logger/audit-logger'
|
|
import { AuditAction, AuditStatus } from '../../types/audit.types'
|
|
import { DiscreteMaterialPlanDAO, type MaterialPlanRecord } from './discrete-material-plan-dao'
|
|
|
|
const log = createLogger('DataImportService')
|
|
|
|
/**
|
|
* Excel column header to database field mapping
|
|
*/
|
|
const EXCEL_TO_DB_MAPPING: Record<string, keyof MaterialPlanRecord> = {
|
|
工厂: 'factory',
|
|
备料状态: 'materialStatus',
|
|
备料计划单号: 'planNumber',
|
|
来源单号: 'sourceNumber',
|
|
备料类型: 'materialType',
|
|
产品编码: 'productCode',
|
|
产品名称: 'productName',
|
|
产品计划数量: 'productPlanQuantity',
|
|
产品单位: 'productUnit',
|
|
用料部门: 'useDepartment',
|
|
备注: 'remark',
|
|
制单人: 'creator',
|
|
制单日期: 'createDate',
|
|
审批人: 'approver',
|
|
审批日期: 'approveDate',
|
|
序号: 'sequenceNumber',
|
|
材料编码: 'materialCode',
|
|
材料名称: 'materialName',
|
|
规格: 'specification',
|
|
型号: 'model',
|
|
图号: 'drawingNumber',
|
|
物料材质: 'materialQuality',
|
|
计划数量: 'planQuantity',
|
|
单位: 'unit',
|
|
需用日期: 'requiredDate',
|
|
发料仓库: 'warehouse',
|
|
单位用量: 'unitUsage',
|
|
累计出库数量: 'cumulativeOutputQuantity'
|
|
// Note: '打印人', '打印日期' are skipped (not in DB)
|
|
// Note: 'BOMVersion' is skipped (not in Excel)
|
|
}
|
|
|
|
/**
|
|
* Import result
|
|
*/
|
|
export interface ImportResult {
|
|
success: boolean
|
|
recordsRead: number
|
|
recordsDeleted: number
|
|
recordsImported: number
|
|
uniqueSourceNumbers: number
|
|
errors: string[]
|
|
}
|
|
|
|
/**
|
|
* DataImportService class
|
|
*/
|
|
export class DataImportService {
|
|
private dao: DiscreteMaterialPlanDAO
|
|
|
|
constructor() {
|
|
this.dao = new DiscreteMaterialPlanDAO()
|
|
}
|
|
|
|
/**
|
|
* Import data from Excel file to database
|
|
* @param filePath - Path to the Excel file
|
|
* @param batchSize - Number of records per insert batch (default: 1000)
|
|
* @returns Import result with statistics
|
|
*/
|
|
async importFromExcel(filePath: string, batchSize = 1000): Promise<ImportResult> {
|
|
const result: ImportResult = {
|
|
success: false,
|
|
recordsRead: 0,
|
|
recordsDeleted: 0,
|
|
recordsImported: 0,
|
|
uniqueSourceNumbers: 0,
|
|
errors: []
|
|
}
|
|
|
|
try {
|
|
log.info('Starting import from Excel', { filePath, batchSize })
|
|
|
|
// Step 1: Read Excel file
|
|
log.info('Reading Excel file...')
|
|
const { records, sourceNumbers } = await this.readExcelFile(filePath)
|
|
result.recordsRead = records.length
|
|
result.uniqueSourceNumbers = sourceNumbers.size
|
|
|
|
log.info('Excel read completed', {
|
|
recordsRead: result.recordsRead,
|
|
uniqueSourceNumbers: result.uniqueSourceNumbers
|
|
})
|
|
|
|
if (records.length === 0) {
|
|
result.success = true
|
|
result.errors.push('Excel file contains no data records')
|
|
return result
|
|
}
|
|
|
|
// Step 2: Delete existing records by SourceNumber
|
|
log.info('Deleting existing records...', {
|
|
sourceNumberCount: sourceNumbers.size
|
|
})
|
|
|
|
const sourceNumberArray = Array.from(sourceNumbers)
|
|
result.recordsDeleted = await this.dao.deleteBySourceNumbers(sourceNumberArray)
|
|
|
|
log.info('Existing records deleted', {
|
|
recordsDeleted: result.recordsDeleted
|
|
})
|
|
|
|
// Step 3: Batch insert new records
|
|
log.info('Inserting new records...', {
|
|
recordCount: records.length,
|
|
batchSize
|
|
})
|
|
|
|
result.recordsImported = await this.dao.batchInsert(records, batchSize)
|
|
|
|
log.info('Records imported successfully', {
|
|
recordsImported: result.recordsImported
|
|
})
|
|
|
|
result.success = true
|
|
} catch (error) {
|
|
const errorMsg = error instanceof Error ? error.message : String(error)
|
|
result.errors.push(`Import failed: ${errorMsg}`)
|
|
log.error('Import failed', { error: errorMsg })
|
|
} finally {
|
|
// Disconnect DAO
|
|
try {
|
|
await this.dao.disconnect()
|
|
} catch (e) {
|
|
log.warn('Error disconnecting DAO', {
|
|
error: e instanceof Error ? e.message : String(e)
|
|
})
|
|
}
|
|
}
|
|
|
|
// Audit log: DATA_IMPORT
|
|
logAuditWithCurrentUser(
|
|
AuditAction.DATA_IMPORT,
|
|
'MATERIAL_PLAN',
|
|
result.success ? AuditStatus.SUCCESS : AuditStatus.FAILURE,
|
|
{
|
|
recordsRead: result.recordsRead,
|
|
recordsDeleted: result.recordsDeleted,
|
|
recordsImported: result.recordsImported,
|
|
uniqueSourceNumbers: result.uniqueSourceNumbers,
|
|
errorCount: result.errors.length
|
|
}
|
|
)
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Read Excel file and extract records
|
|
* @param filePath - Path to the Excel file
|
|
* @returns Records and unique SourceNumbers
|
|
*/
|
|
private async readExcelFile(
|
|
filePath: string
|
|
): Promise<{ records: MaterialPlanRecord[]; sourceNumbers: Set<string> }> {
|
|
const records: MaterialPlanRecord[] = []
|
|
const sourceNumbers = new Set<string>()
|
|
|
|
// Dynamic import ExcelJS
|
|
const ExcelJSModule = await import('exceljs')
|
|
const ExcelJS = (ExcelJSModule as any).default || ExcelJSModule
|
|
|
|
const workbook = new ExcelJS.Workbook()
|
|
await workbook.xlsx.readFile(filePath)
|
|
|
|
// Get first worksheet
|
|
const worksheet = workbook.worksheets[0]
|
|
if (!worksheet) {
|
|
throw new Error('Excel file has no worksheets')
|
|
}
|
|
|
|
// Get header row to map column indices
|
|
const headerRow = worksheet.getRow(1)
|
|
const columnMapping = this.buildColumnMapping(headerRow)
|
|
|
|
log.debug('Column mapping built', {
|
|
columnCount: Object.keys(columnMapping).length
|
|
})
|
|
|
|
// Iterate through data rows (starting from row 2)
|
|
worksheet.eachRow((row: any, rowNumber: number) => {
|
|
if (rowNumber === 1) return // Skip header row
|
|
|
|
try {
|
|
const record = this.buildRecordFromRow(row, columnMapping)
|
|
if (record) {
|
|
records.push(record)
|
|
if (record.sourceNumber) {
|
|
sourceNumbers.add(record.sourceNumber)
|
|
}
|
|
}
|
|
} catch (error) {
|
|
log.warn('Failed to parse row', {
|
|
rowNumber,
|
|
error: error instanceof Error ? error.message : String(error)
|
|
})
|
|
}
|
|
})
|
|
|
|
return { records, sourceNumbers }
|
|
}
|
|
|
|
/**
|
|
* Build column index to field name mapping from header row
|
|
*/
|
|
private buildColumnMapping(headerRow: any): Map<number, keyof MaterialPlanRecord> {
|
|
const mapping = new Map<number, keyof MaterialPlanRecord>()
|
|
|
|
headerRow.eachCell((cell: any, colNumber: number) => {
|
|
const headerText = cell.text?.toString().trim()
|
|
if (headerText && EXCEL_TO_DB_MAPPING[headerText]) {
|
|
mapping.set(colNumber, EXCEL_TO_DB_MAPPING[headerText])
|
|
}
|
|
})
|
|
|
|
return mapping
|
|
}
|
|
|
|
/**
|
|
* Build a MaterialPlanRecord from an Excel row
|
|
*/
|
|
private buildRecordFromRow(
|
|
row: any,
|
|
columnMapping: Map<number, keyof MaterialPlanRecord>
|
|
): MaterialPlanRecord | null {
|
|
const record: Partial<MaterialPlanRecord> = {}
|
|
|
|
row.eachCell((cell: any, colNumber: number) => {
|
|
const fieldName = columnMapping.get(colNumber)
|
|
if (!fieldName) return
|
|
|
|
const value = this.parseCellValue(cell, fieldName)
|
|
record[fieldName] = value as any
|
|
})
|
|
|
|
// Validate required fields
|
|
if (!record.planNumber) {
|
|
return null // Skip records without PlanNumber
|
|
}
|
|
|
|
return record as MaterialPlanRecord
|
|
}
|
|
|
|
/**
|
|
* Parse cell value based on field type
|
|
*/
|
|
private parseCellValue(cell: any, fieldName: keyof MaterialPlanRecord): any {
|
|
const text = cell.text?.toString().trim()
|
|
const value = cell.value
|
|
|
|
// Return null for empty cells
|
|
if (!text || text === '') {
|
|
return null
|
|
}
|
|
|
|
// Handle numeric fields
|
|
const numericFields: (keyof MaterialPlanRecord)[] = [
|
|
'productPlanQuantity',
|
|
'sequenceNumber',
|
|
'planQuantity',
|
|
'unitUsage',
|
|
'cumulativeOutputQuantity'
|
|
]
|
|
|
|
if (numericFields.includes(fieldName)) {
|
|
const num = parseFloat(text)
|
|
return isNaN(num) ? null : num
|
|
}
|
|
|
|
// Handle date fields
|
|
const dateFields: (keyof MaterialPlanRecord)[] = ['createDate', 'approveDate', 'requiredDate']
|
|
|
|
if (dateFields.includes(fieldName)) {
|
|
// ExcelJS returns date as Date object if recognized
|
|
if (value instanceof Date) {
|
|
return value
|
|
}
|
|
// Try to parse date string
|
|
const date = new Date(text)
|
|
return isNaN(date.getTime()) ? null : date
|
|
}
|
|
|
|
// Handle string fields
|
|
return text
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a DataImportService instance
|
|
*/
|
|
export function createDataImportService(): DataImportService {
|
|
return new DataImportService()
|
|
}
|