/** * 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 = { 工厂: '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 { 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 }> { const records: MaterialPlanRecord[] = [] const sourceNumbers = new Set() // 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 { const mapping = new Map() 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 ): MaterialPlanRecord | null { const record: Partial = {} 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() }