|
|
@@ -2,53 +2,112 @@
|
|
|
import * as ExcelJS from 'exceljs';
|
|
|
import { ReadSectionData, LayoutSectionField, SectionTypeEnum, FieldTypeEnum, ImportProgress } from './types';
|
|
|
|
|
|
+// Simple logger utility for debugging
|
|
|
+const logger = {
|
|
|
+ debug: (message: string, ...args: any[]) => {
|
|
|
+ console.debug(`[ExcelReaderService] ${new Date().toISOString()} - ${message}`, ...args);
|
|
|
+ },
|
|
|
+ info: (message: string, ...args: any[]) => {
|
|
|
+ console.info(`[ExcelReaderService] ${new Date().toISOString()} - ${message}`, ...args);
|
|
|
+ },
|
|
|
+ warn: (message: string, ...args: any[]) => {
|
|
|
+ console.warn(`[ExcelReaderService] ${new Date().toISOString()} - ${message}`, ...args);
|
|
|
+ },
|
|
|
+ error: (message: string, ...args: any[]) => {
|
|
|
+ console.error(`[ExcelReaderService] ${new Date().toISOString()} - ${message}`, ...args);
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
export class ExcelReaderService {
|
|
|
async readExcelFile(
|
|
|
fileBuffer: Buffer,
|
|
|
layoutConfig: any,
|
|
|
onProgress: (progress: ImportProgress) => void
|
|
|
): Promise<ReadSectionData[]> {
|
|
|
- const workbook = new ExcelJS.Workbook();
|
|
|
- await workbook.xlsx.load(fileBuffer as any);
|
|
|
-
|
|
|
- const results: ReadSectionData[] = [];
|
|
|
- const totalSections = layoutConfig.sections?.length || 0;
|
|
|
-
|
|
|
- // Initialize progress
|
|
|
- onProgress({
|
|
|
- importId: 0, // Will be set by caller
|
|
|
- status: 'processing',
|
|
|
- currentSection: '',
|
|
|
- currentRow: 0,
|
|
|
- totalRows: 0,
|
|
|
- errors: [],
|
|
|
- processedSections: 0,
|
|
|
- totalSections
|
|
|
+ logger.info('Starting Excel file import', {
|
|
|
+ fileSize: fileBuffer.length,
|
|
|
+ layoutConfigSections: layoutConfig.sections?.length || 0
|
|
|
});
|
|
|
|
|
|
- for (let sectionIndex = 0; sectionIndex < totalSections; sectionIndex++) {
|
|
|
- const section = layoutConfig.sections[sectionIndex];
|
|
|
- const worksheet = workbook.getWorksheet(section.sheetName);
|
|
|
+ const startTime = Date.now();
|
|
|
+
|
|
|
+ try {
|
|
|
+ const workbook = new ExcelJS.Workbook();
|
|
|
+ logger.debug('Loading Excel workbook from buffer...');
|
|
|
+ await workbook.xlsx.load(fileBuffer as any);
|
|
|
+ logger.info('Excel workbook loaded successfully', {
|
|
|
+ worksheets: workbook.worksheets.map(ws => ({ name: ws.name, rowCount: ws.rowCount }))
|
|
|
+ });
|
|
|
|
|
|
- if (!worksheet) {
|
|
|
- onProgress({
|
|
|
- importId: 0,
|
|
|
- status: 'processing',
|
|
|
- currentSection: section.name,
|
|
|
- currentRow: 0,
|
|
|
- totalRows: 0,
|
|
|
- errors: [`Worksheet '${section.sheetName}' not found`],
|
|
|
- processedSections: sectionIndex + 1,
|
|
|
- totalSections
|
|
|
+ const results: ReadSectionData[] = [];
|
|
|
+ const totalSections = layoutConfig.sections?.length || 0;
|
|
|
+
|
|
|
+ logger.info('Processing Excel import', { totalSections });
|
|
|
+
|
|
|
+ // Initialize progress
|
|
|
+ onProgress({
|
|
|
+ importId: 0, // Will be set by caller
|
|
|
+ status: 'processing',
|
|
|
+ currentSection: '',
|
|
|
+ currentRow: 0,
|
|
|
+ totalRows: 0,
|
|
|
+ errors: [],
|
|
|
+ processedSections: 0,
|
|
|
+ totalSections
|
|
|
+ });
|
|
|
+
|
|
|
+ for (let sectionIndex = 0; sectionIndex < totalSections; sectionIndex++) {
|
|
|
+ const section = layoutConfig.sections[sectionIndex];
|
|
|
+ logger.debug(`Processing section ${sectionIndex + 1}/${totalSections}`, {
|
|
|
+ sectionName: section.name,
|
|
|
+ sheetName: section.sheetName,
|
|
|
+ startingRow: section.startingRow,
|
|
|
+ endingRow: section.endingRow
|
|
|
+ });
|
|
|
+
|
|
|
+ const worksheet = workbook.getWorksheet(section.sheetName);
|
|
|
+
|
|
|
+ if (!worksheet) {
|
|
|
+ const error = `Worksheet '${section.sheetName}' not found`;
|
|
|
+ logger.warn(error, { availableWorksheets: workbook.worksheets.map(ws => ws.name) });
|
|
|
+
|
|
|
+ onProgress({
|
|
|
+ importId: 0,
|
|
|
+ status: 'processing',
|
|
|
+ currentSection: section.name,
|
|
|
+ currentRow: 0,
|
|
|
+ totalRows: 0,
|
|
|
+ errors: [error],
|
|
|
+ processedSections: sectionIndex + 1,
|
|
|
+ totalSections
|
|
|
+ });
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ const sectionData = await this.processSection(worksheet, section, sectionIndex, totalSections, onProgress);
|
|
|
+ results.push(sectionData);
|
|
|
+
|
|
|
+ logger.info(`Section ${section.name} processed successfully`, {
|
|
|
+ rowsProcessed: sectionData.data.length,
|
|
|
+ fields: sectionData.fields.length
|
|
|
});
|
|
|
- continue;
|
|
|
}
|
|
|
|
|
|
- const sectionData = await this.processSection(worksheet, section, sectionIndex, totalSections, onProgress);
|
|
|
- results.push(sectionData);
|
|
|
- }
|
|
|
+ const totalTime = Date.now() - startTime;
|
|
|
+ logger.info('Excel file import completed', {
|
|
|
+ totalSections: results.length,
|
|
|
+ totalRows: results.reduce((sum, section) => sum + section.data.length, 0),
|
|
|
+ totalTimeMs: totalTime
|
|
|
+ });
|
|
|
|
|
|
- return results;
|
|
|
+ return results;
|
|
|
+ } catch (error) {
|
|
|
+ logger.error('Error reading Excel file', {
|
|
|
+ error: error instanceof Error ? error.message : String(error),
|
|
|
+ stack: error instanceof Error ? error.stack : undefined
|
|
|
+ });
|
|
|
+ throw error;
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
private async processSection(
|
|
|
@@ -58,40 +117,101 @@ export class ExcelReaderService {
|
|
|
totalSections: number,
|
|
|
onProgress: (progress: ImportProgress) => void
|
|
|
): Promise<ReadSectionData> {
|
|
|
- const startingRow = section.startingRow || 1;
|
|
|
+ const sectionStartTime = Date.now();
|
|
|
+ logger.info(`Starting section processing`, {
|
|
|
+ sectionName: section.name,
|
|
|
+ sheetName: section.sheetName,
|
|
|
+ sectionIndex: sectionIndex + 1,
|
|
|
+ totalSections
|
|
|
+ });
|
|
|
+
|
|
|
+ const startingRow = section.startingRow || 2; // Default to 2 to skip header
|
|
|
const endingRow = section.endingRow || worksheet.rowCount;
|
|
|
|
|
|
- // Get headers from the first row (assuming row 1 has headers)
|
|
|
- const headers: string[] = [];
|
|
|
- const headerRow = worksheet.getRow(1);
|
|
|
- headerRow.eachCell((cell) => {
|
|
|
- headers.push(cell.text || '');
|
|
|
+ logger.debug('Section configuration', {
|
|
|
+ sectionName: section.name,
|
|
|
+ startingRow,
|
|
|
+ endingRow,
|
|
|
+ totalRowsInSheet: worksheet.rowCount,
|
|
|
+ fieldsCount: section.fields?.length || 0
|
|
|
});
|
|
|
-
|
|
|
+
|
|
|
// Process data rows
|
|
|
const data: Record<string, any>[] = [];
|
|
|
const totalRows = endingRow - startingRow + 1;
|
|
|
|
|
|
+ let processedRows = 0;
|
|
|
+ let skippedRows = 0;
|
|
|
+
|
|
|
for (let rowNum = startingRow; rowNum <= endingRow; rowNum++) {
|
|
|
const row = worksheet.getRow(rowNum);
|
|
|
- if (!row.hasValues) continue;
|
|
|
+
|
|
|
+ if (!row.hasValues) {
|
|
|
+ skippedRows++;
|
|
|
+ logger.debug(`Skipping empty row ${rowNum}`);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
|
|
|
const rowData: Record<string, any> = {};
|
|
|
+ let fieldsProcessed = 0;
|
|
|
|
|
|
// Map cell values based on field configuration
|
|
|
for (const field of section.fields || []) {
|
|
|
- const cellAddress = this.parseCellAddress(field.cellPosition);
|
|
|
- const cell = row.getCell(cellAddress.col);
|
|
|
-
|
|
|
- if (cell && cell.value !== null && cell.value !== undefined) {
|
|
|
- rowData[field.importTableColumnName] = this.convertCellValue(
|
|
|
- cell.value,
|
|
|
- field.parsedType || FieldTypeEnum.String
|
|
|
- );
|
|
|
+ try {
|
|
|
+ const cellAddress = this.parseCellAddress(field.cellPosition);
|
|
|
+ const cell = row.getCell(cellAddress.col);
|
|
|
+
|
|
|
+ logger.debug(`Processing field`, {
|
|
|
+ fieldName: field.name,
|
|
|
+ cellPosition: field.cellPosition,
|
|
|
+ cellAddress,
|
|
|
+ rawValue: cell?.value,
|
|
|
+ rowNum
|
|
|
+ });
|
|
|
+
|
|
|
+ if (cell && cell.value !== null && cell.value !== undefined) {
|
|
|
+ const value = this.convertCellValue(
|
|
|
+ cell.value,
|
|
|
+ field.parsedType || FieldTypeEnum.String
|
|
|
+ );
|
|
|
+
|
|
|
+ logger.debug(`Value converted`, {
|
|
|
+ fieldName: field.name,
|
|
|
+ originalValue: cell.value,
|
|
|
+ convertedValue: value,
|
|
|
+ fieldType: field.parsedType || FieldTypeEnum.String
|
|
|
+ });
|
|
|
+
|
|
|
+ // Map to the correct column name for Prisma model
|
|
|
+ const columnName = field.importTableColumnName;
|
|
|
+ rowData[columnName] = value;
|
|
|
+ fieldsProcessed++;
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ logger.error(`Error processing field ${field.name} at row ${rowNum}`, {
|
|
|
+ error: error instanceof Error ? error.message : String(error),
|
|
|
+ field,
|
|
|
+ rowNum
|
|
|
+ });
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- data.push(rowData);
|
|
|
+ // Only add non-empty rows
|
|
|
+ if (Object.keys(rowData).length > 0) {
|
|
|
+ data.push(rowData);
|
|
|
+ processedRows++;
|
|
|
+
|
|
|
+ if (processedRows <= 5 || processedRows % 100 === 0) {
|
|
|
+ logger.debug(`Row processed`, {
|
|
|
+ rowNum,
|
|
|
+ fieldsProcessed,
|
|
|
+ rowDataKeys: Object.keys(rowData),
|
|
|
+ dataLength: data.length
|
|
|
+ });
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ logger.debug(`Skipping row with no valid data`, { rowNum });
|
|
|
+ }
|
|
|
|
|
|
// Update progress every 100 rows
|
|
|
if (rowNum % 100 === 0 || rowNum === endingRow) {
|
|
|
@@ -108,7 +228,17 @@ export class ExcelReaderService {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- return {
|
|
|
+ const sectionTime = Date.now() - sectionStartTime;
|
|
|
+ logger.info(`Section processing completed`, {
|
|
|
+ sectionName: section.name,
|
|
|
+ processedRows,
|
|
|
+ skippedRows,
|
|
|
+ totalRows,
|
|
|
+ dataRows: data.length,
|
|
|
+ processingTimeMs: sectionTime
|
|
|
+ });
|
|
|
+
|
|
|
+ const result = {
|
|
|
id: section.id || 0,
|
|
|
name: section.name || '',
|
|
|
tableName: section.tableName || '',
|
|
|
@@ -120,91 +250,176 @@ export class ExcelReaderService {
|
|
|
fields: this.mapFields(section.fields || []),
|
|
|
data
|
|
|
};
|
|
|
+
|
|
|
+ logger.debug('Section result', {
|
|
|
+ sectionName: section.name,
|
|
|
+ resultSummary: {
|
|
|
+ id: result.id,
|
|
|
+ name: result.name,
|
|
|
+ tableName: result.tableName,
|
|
|
+ dataRows: result.data.length,
|
|
|
+ fields: result.fields.length
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ return result;
|
|
|
}
|
|
|
|
|
|
private parseCellAddress(cellPosition: string): { row: number; col: number } {
|
|
|
+ logger.debug(`Parsing cell address: ${cellPosition}`);
|
|
|
+
|
|
|
const match = cellPosition.match(/([A-Z]+)(\d+)/);
|
|
|
- if (!match) return { row: 1, col: 1 };
|
|
|
+ if (!match) {
|
|
|
+ logger.warn(`Invalid cell position format: ${cellPosition}, using default 1,1`);
|
|
|
+ return { row: 1, col: 1 };
|
|
|
+ }
|
|
|
|
|
|
const col = match[1].charCodeAt(0) - 'A'.charCodeAt(0) + 1;
|
|
|
const row = parseInt(match[2]);
|
|
|
|
|
|
+ logger.debug(`Parsed cell address`, {
|
|
|
+ original: cellPosition,
|
|
|
+ row,
|
|
|
+ col
|
|
|
+ });
|
|
|
+
|
|
|
return { row, col };
|
|
|
}
|
|
|
|
|
|
private mapSectionType(type: string): SectionTypeEnum {
|
|
|
- switch (type?.toLowerCase()) {
|
|
|
- case 'grid':
|
|
|
- return SectionTypeEnum.Grid;
|
|
|
- case 'properties':
|
|
|
- return SectionTypeEnum.Properties;
|
|
|
- default:
|
|
|
- return SectionTypeEnum.Unknown;
|
|
|
- }
|
|
|
+ logger.debug(`Mapping section type: ${type}`);
|
|
|
+
|
|
|
+ const mappedType = (() => {
|
|
|
+ switch (type?.toLowerCase()) {
|
|
|
+ case 'grid':
|
|
|
+ return SectionTypeEnum.Grid;
|
|
|
+ case 'properties':
|
|
|
+ return SectionTypeEnum.Properties;
|
|
|
+ default:
|
|
|
+ return SectionTypeEnum.Unknown;
|
|
|
+ }
|
|
|
+ })();
|
|
|
+
|
|
|
+ logger.debug(`Section type mapped`, {
|
|
|
+ originalType: type,
|
|
|
+ mappedType: SectionTypeEnum[mappedType]
|
|
|
+ });
|
|
|
+
|
|
|
+ return mappedType;
|
|
|
}
|
|
|
|
|
|
private mapFields(fields: any[]): LayoutSectionField[] {
|
|
|
- return fields.map((field, index) => ({
|
|
|
- id: field.id || index,
|
|
|
- cellPosition: field.cellPosition || '',
|
|
|
- name: field.name || '',
|
|
|
- dataType: field.dataType || 'string',
|
|
|
- dataTypeFormat: field.dataTypeFormat,
|
|
|
- importTableColumnName: field.importTableColumnName || field.name || `column_${index}`,
|
|
|
- importColumnOrderNumber: field.importColumnOrderNumber || index,
|
|
|
- parsedType: this.mapFieldType(field.dataType)
|
|
|
- }));
|
|
|
+ logger.debug(`Mapping ${fields.length} fields`);
|
|
|
+
|
|
|
+ const mappedFields = fields.map((field, index) => {
|
|
|
+ const mappedField = {
|
|
|
+ id: field.id || index,
|
|
|
+ cellPosition: field.cellPosition || '',
|
|
|
+ name: field.name || '',
|
|
|
+ dataType: field.dataType || 'string',
|
|
|
+ dataTypeFormat: field.dataTypeFormat,
|
|
|
+ importTableColumnName: field.importTableColumnName || field.name || `column_${index}`,
|
|
|
+ importColumnOrderNumber: field.importColumnOrderNumber || index,
|
|
|
+ parsedType: this.mapFieldType(field.dataType)
|
|
|
+ };
|
|
|
+
|
|
|
+ logger.debug(`Field mapped`, {
|
|
|
+ index,
|
|
|
+ originalName: field.name,
|
|
|
+ mappedName: mappedField.name,
|
|
|
+ cellPosition: mappedField.cellPosition,
|
|
|
+ parsedType: FieldTypeEnum[mappedField.parsedType]
|
|
|
+ });
|
|
|
+
|
|
|
+ return mappedField;
|
|
|
+ });
|
|
|
+
|
|
|
+ return mappedFields;
|
|
|
}
|
|
|
|
|
|
private mapFieldType(dataType: string): FieldTypeEnum {
|
|
|
const type = dataType?.toLowerCase();
|
|
|
|
|
|
- switch (type) {
|
|
|
- case 'time':
|
|
|
- return FieldTypeEnum.Time;
|
|
|
- case 'decimal':
|
|
|
- case 'number':
|
|
|
- case 'float':
|
|
|
- return FieldTypeEnum.Decimal;
|
|
|
- case 'date':
|
|
|
- return FieldTypeEnum.Date;
|
|
|
- case 'int':
|
|
|
- case 'integer':
|
|
|
- case 'numeric':
|
|
|
- return FieldTypeEnum.Numeric;
|
|
|
- default:
|
|
|
- return FieldTypeEnum.String;
|
|
|
- }
|
|
|
+ const mappedType = (() => {
|
|
|
+ switch (type) {
|
|
|
+ case 'time':
|
|
|
+ return FieldTypeEnum.Time;
|
|
|
+ case 'decimal':
|
|
|
+ case 'number':
|
|
|
+ case 'float':
|
|
|
+ return FieldTypeEnum.Decimal;
|
|
|
+ case 'date':
|
|
|
+ return FieldTypeEnum.Date;
|
|
|
+ case 'int':
|
|
|
+ case 'integer':
|
|
|
+ case 'numeric':
|
|
|
+ return FieldTypeEnum.Numeric;
|
|
|
+ default:
|
|
|
+ return FieldTypeEnum.String;
|
|
|
+ }
|
|
|
+ })();
|
|
|
+
|
|
|
+ logger.debug(`Field type mapped`, {
|
|
|
+ originalDataType: dataType,
|
|
|
+ mappedType: FieldTypeEnum[mappedType]
|
|
|
+ });
|
|
|
+
|
|
|
+ return mappedType;
|
|
|
}
|
|
|
|
|
|
private convertCellValue(value: any, fieldType: FieldTypeEnum): any {
|
|
|
- if (value === null || value === undefined) return null;
|
|
|
-
|
|
|
- switch (fieldType) {
|
|
|
- case FieldTypeEnum.Time:
|
|
|
- if (typeof value === 'number') {
|
|
|
- // Excel time is fraction of a day
|
|
|
- return value * 24 * 60 * 60 * 1000; // Convert to milliseconds
|
|
|
- }
|
|
|
- return value;
|
|
|
-
|
|
|
- case FieldTypeEnum.Decimal:
|
|
|
- return parseFloat(value.toString()) || 0;
|
|
|
-
|
|
|
- case FieldTypeEnum.Date:
|
|
|
- if (typeof value === 'number') {
|
|
|
- // Excel date is days since 1900-01-01
|
|
|
- const excelEpoch = new Date(1900, 0, 1);
|
|
|
- return new Date(excelEpoch.getTime() + (value - 1) * 24 * 60 * 60 * 1000);
|
|
|
- }
|
|
|
- return new Date(value);
|
|
|
-
|
|
|
- case FieldTypeEnum.Numeric:
|
|
|
- return parseInt(value.toString()) || 0;
|
|
|
-
|
|
|
- case FieldTypeEnum.String:
|
|
|
- default:
|
|
|
- return value.toString();
|
|
|
+ if (value === null || value === undefined) {
|
|
|
+ logger.debug(`Converting null/undefined value to null`, { fieldType: FieldTypeEnum[fieldType] });
|
|
|
+ return null;
|
|
|
}
|
|
|
+
|
|
|
+ logger.debug(`Converting cell value`, {
|
|
|
+ originalValue: value,
|
|
|
+ originalType: typeof value,
|
|
|
+ targetFieldType: FieldTypeEnum[fieldType]
|
|
|
+ });
|
|
|
+
|
|
|
+ const convertedValue = (() => {
|
|
|
+ switch (fieldType) {
|
|
|
+ case FieldTypeEnum.Time:
|
|
|
+ if (typeof value === 'number') {
|
|
|
+ // Excel time is fraction of a day
|
|
|
+ const result = value * 24 * 60 * 60 * 1000; // Convert to milliseconds
|
|
|
+ logger.debug(`Time conversion`, { original: value, converted: result });
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+ return value;
|
|
|
+
|
|
|
+ case FieldTypeEnum.Decimal:
|
|
|
+ const decimalResult = parseFloat(value.toString()) || 0;
|
|
|
+ logger.debug(`Decimal conversion`, { original: value, converted: decimalResult });
|
|
|
+ return decimalResult;
|
|
|
+
|
|
|
+ case FieldTypeEnum.Date:
|
|
|
+ if (typeof value === 'number') {
|
|
|
+ // Excel date is days since 1900-01-01
|
|
|
+ const excelEpoch = new Date(1900, 0, 1);
|
|
|
+ const dateResult = new Date(excelEpoch.getTime() + (value - 1) * 24 * 60 * 60 * 1000);
|
|
|
+ logger.debug(`Date conversion`, { original: value, converted: dateResult });
|
|
|
+ return dateResult;
|
|
|
+ }
|
|
|
+ const dateResult = new Date(value);
|
|
|
+ logger.debug(`Date conversion from string`, { original: value, converted: dateResult });
|
|
|
+ return dateResult;
|
|
|
+
|
|
|
+ case FieldTypeEnum.Numeric:
|
|
|
+ const numericResult = parseInt(value.toString()) || 0;
|
|
|
+ logger.debug(`Numeric conversion`, { original: value, converted: numericResult });
|
|
|
+ return numericResult;
|
|
|
+
|
|
|
+ case FieldTypeEnum.String:
|
|
|
+ default:
|
|
|
+ const stringResult = value.toString();
|
|
|
+ logger.debug(`String conversion`, { original: value, converted: stringResult });
|
|
|
+ return stringResult;
|
|
|
+ }
|
|
|
+ })();
|
|
|
+
|
|
|
+ return convertedValue;
|
|
|
}
|
|
|
}
|