feat: implement Excel parser service
Implement Excel parsing module for ERP exported files following TDD principles. Key features: - Parse Excel files with multiple orders per file - Extract order header information (production order, product code, etc.) - Extract material data rows with 13 fields - Handle empty orders gracefully - Detect footer rows (制单人/打印人) - Map Chinese field names to English property names - Support field name mapping from Python reference Implementation: - ExcelParser class with parse() method - DiscreteMaterialPlan and ExcelParseOptions types - OrderHeader interface for order metadata - Test fixtures with realistic Excel structure - Comprehensive unit tests (3 tests, all passing) Reference: playwrite/utils/excel_converter.py Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
354
src/main/services/excel/excel-parser.ts
Normal file
354
src/main/services/excel/excel-parser.ts
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
import ExcelJS from 'exceljs';
|
||||||
|
import type {
|
||||||
|
DiscreteMaterialPlan,
|
||||||
|
ExcelParseOptions,
|
||||||
|
OrderHeader,
|
||||||
|
} from '../../types/excel.types';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Excel Parser Service
|
||||||
|
* Parses exported ERP Excel files into structured data
|
||||||
|
*
|
||||||
|
* Reference: playwrite/utils/excel_converter.py
|
||||||
|
*
|
||||||
|
* Excel Structure:
|
||||||
|
* - Multiple orders per file (each starting with "离散备料计划")
|
||||||
|
* - Each order has: header info (4 lines) + table header + data rows + footer
|
||||||
|
* - Material rows have 13 columns from "序号" to "累计出库数量"
|
||||||
|
*/
|
||||||
|
export class ExcelParser {
|
||||||
|
// Field name mapping for Python compatibility (from Python code)
|
||||||
|
private FIELD_NAME_MAPPING: Record<string, string> = {
|
||||||
|
计划数量: '产品计划数量',
|
||||||
|
单位: '产品单位',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mapping from Chinese field names to English property names
|
||||||
|
private CHINESE_TO_ENGLISH_MAPPING: Record<string, string> = {
|
||||||
|
生产部门: 'productionDepartment',
|
||||||
|
生产订单: 'productionOrder',
|
||||||
|
产品编码: 'productCode',
|
||||||
|
产品名称: 'productName',
|
||||||
|
产品规格: 'productSpecification',
|
||||||
|
计划数量: 'plannedQuantity',
|
||||||
|
单位: 'unit',
|
||||||
|
需用日期: 'requiredDate',
|
||||||
|
制单人: 'creator',
|
||||||
|
打印人: 'printer',
|
||||||
|
打印日期: 'printDate',
|
||||||
|
// Mapped fields (after FIELD_NAME_MAPPING)
|
||||||
|
产品计划数量: 'plannedQuantity',
|
||||||
|
产品单位: 'unit',
|
||||||
|
};
|
||||||
|
|
||||||
|
private verbose: boolean;
|
||||||
|
|
||||||
|
constructor(options: ExcelParseOptions = {}) {
|
||||||
|
this.verbose = options.verbose || false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private log(...args: any[]): void {
|
||||||
|
if (this.verbose) {
|
||||||
|
console.log('[ExcelParser]', ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Excel file and extract material plans
|
||||||
|
*/
|
||||||
|
async parse(filePath: string, options: ExcelParseOptions = {}): Promise<DiscreteMaterialPlan[]> {
|
||||||
|
this.log('Parsing Excel file:', filePath);
|
||||||
|
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
await workbook.xlsx.readFile(filePath);
|
||||||
|
|
||||||
|
const worksheet = workbook.worksheets[0];
|
||||||
|
if (!worksheet) {
|
||||||
|
throw new Error('No worksheet found in file');
|
||||||
|
}
|
||||||
|
|
||||||
|
const plans: DiscreteMaterialPlan[] = [];
|
||||||
|
const allRows: any[][] = [];
|
||||||
|
|
||||||
|
// Read all rows into memory
|
||||||
|
worksheet.eachRow((row, rowNumber) => {
|
||||||
|
allRows.push(row.values as any[]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parse orders from rows
|
||||||
|
const orders = this.parseOrders(allRows);
|
||||||
|
|
||||||
|
// Flatten orders into material plans
|
||||||
|
for (const order of orders) {
|
||||||
|
const { orderInfo, materials } = order;
|
||||||
|
|
||||||
|
// Skip empty orders if option is set
|
||||||
|
if (options.skipEmptyOrders && materials.length === 0) {
|
||||||
|
this.log('Skipping empty order:', orderInfo.productionOrder);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a material plan for each material row
|
||||||
|
for (const material of materials) {
|
||||||
|
const plan: DiscreteMaterialPlan = {
|
||||||
|
orderNumber: orderInfo.productionOrder || '',
|
||||||
|
productionId: orderInfo.productCode || '',
|
||||||
|
materialCode: material.materialCode || '',
|
||||||
|
materialName: material.materialName || '',
|
||||||
|
specification: material.specification,
|
||||||
|
model: material.model,
|
||||||
|
drawingNumber: material.drawingNumber,
|
||||||
|
material: material.material,
|
||||||
|
quantity: material.quantity || 0,
|
||||||
|
unit: material.unit || '',
|
||||||
|
requiredDate: material.requiredDate,
|
||||||
|
warehouse: material.warehouse,
|
||||||
|
unitUsage: material.unitUsage,
|
||||||
|
cumulativeOutboundQty: material.cumulativeOutboundQty,
|
||||||
|
rowNumber: material.rowNumber,
|
||||||
|
};
|
||||||
|
plans.push(plan);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log(`Parsed ${plans.length} material plans from ${orders.length} orders`);
|
||||||
|
|
||||||
|
return plans;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse orders from all rows
|
||||||
|
* Reference: _parse_sheet() in Python code
|
||||||
|
*/
|
||||||
|
private parseOrders(allRows: any[][]): Array<{ orderInfo: OrderHeader; materials: any[] }> {
|
||||||
|
const orders: Array<{ orderInfo: OrderHeader; materials: any[] }> = [];
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < allRows.length) {
|
||||||
|
const row = allRows[i];
|
||||||
|
|
||||||
|
// Check if this is an order title row
|
||||||
|
if (row && row[2] && String(row[2]).includes('离散备料计划')) {
|
||||||
|
this.log('Found order at row', i + 1);
|
||||||
|
|
||||||
|
// Parse order header info (next 4 rows)
|
||||||
|
const orderInfo: OrderHeader = {};
|
||||||
|
for (let j = 1; j <= 4; j++) {
|
||||||
|
if (i + j < allRows.length && allRows[i + j]) {
|
||||||
|
this.parseHeaderRow(allRows[i + j], orderInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip empty rows to find table header
|
||||||
|
let tableRow = i + 5;
|
||||||
|
while (
|
||||||
|
tableRow < allRows.length &&
|
||||||
|
(!allRows[tableRow] || !allRows[tableRow][2])
|
||||||
|
) {
|
||||||
|
tableRow++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is the table header row
|
||||||
|
if (
|
||||||
|
tableRow < allRows.length &&
|
||||||
|
allRows[tableRow] &&
|
||||||
|
allRows[tableRow][2] === '序号'
|
||||||
|
) {
|
||||||
|
// Check if next row is empty (no data)
|
||||||
|
const nextRow = tableRow + 1;
|
||||||
|
const isEmptyRow =
|
||||||
|
nextRow < allRows.length &&
|
||||||
|
allRows[nextRow] &&
|
||||||
|
allRows[nextRow].every(
|
||||||
|
(cell: any) => cell === null || String(cell).trim() === ''
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isEmptyRow) {
|
||||||
|
// No data, find footer info
|
||||||
|
this.log('Order has no material data');
|
||||||
|
const materials: any[] = [];
|
||||||
|
const footerInfo: OrderHeader = {};
|
||||||
|
let dataRow = nextRow + 1;
|
||||||
|
|
||||||
|
while (dataRow < allRows.length && allRows[dataRow]) {
|
||||||
|
if (
|
||||||
|
allRows[dataRow][2] &&
|
||||||
|
(String(allRows[dataRow][2]).includes('制单人') ||
|
||||||
|
String(allRows[dataRow][2]).includes('打印人'))
|
||||||
|
) {
|
||||||
|
this.parseHeaderRow(allRows[dataRow], footerInfo);
|
||||||
|
if (dataRow + 1 < allRows.length && allRows[dataRow + 1]) {
|
||||||
|
this.parseHeaderRow(allRows[dataRow + 1], footerInfo);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
dataRow++;
|
||||||
|
}
|
||||||
|
|
||||||
|
orders.push({
|
||||||
|
orderInfo: { ...orderInfo, ...footerInfo },
|
||||||
|
materials,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Has data, extract materials
|
||||||
|
this.log('Order has material data');
|
||||||
|
const materials: any[] = [];
|
||||||
|
const footerInfo: OrderHeader = {};
|
||||||
|
let dataRow = tableRow + 1;
|
||||||
|
|
||||||
|
while (dataRow < allRows.length && allRows[dataRow]) {
|
||||||
|
// Check if CURRENT row is footer info (制单人/打印人)
|
||||||
|
const isCurrentRowFooter =
|
||||||
|
allRows[dataRow][2] &&
|
||||||
|
(String(allRows[dataRow][2]).includes('制单人') ||
|
||||||
|
String(allRows[dataRow][2]).includes('打印人'));
|
||||||
|
|
||||||
|
// Check if NEXT row is footer info (to handle empty row before footer)
|
||||||
|
const isNextRowFooter =
|
||||||
|
dataRow + 1 < allRows.length &&
|
||||||
|
allRows[dataRow + 1] &&
|
||||||
|
allRows[dataRow + 1][2] &&
|
||||||
|
(String(allRows[dataRow + 1][2]).includes('制单人') ||
|
||||||
|
String(allRows[dataRow + 1][2]).includes('打印人'));
|
||||||
|
|
||||||
|
if (isCurrentRowFooter) {
|
||||||
|
// Current row is footer, parse it and next row if exists
|
||||||
|
this.parseHeaderRow(allRows[dataRow], footerInfo);
|
||||||
|
if (dataRow + 1 < allRows.length && allRows[dataRow + 1]) {
|
||||||
|
this.parseHeaderRow(allRows[dataRow + 1], footerInfo);
|
||||||
|
}
|
||||||
|
this.log(' Found footer row, stopping material parsing');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNextRowFooter) {
|
||||||
|
// Next row is footer, parse current row as material first
|
||||||
|
const material = this.parseMaterialRow(allRows[dataRow], dataRow + 1);
|
||||||
|
if (material) {
|
||||||
|
this.log(' Parsed material:', material.materialCode);
|
||||||
|
materials.push(material);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then parse footer rows
|
||||||
|
this.parseHeaderRow(allRows[dataRow + 1], footerInfo);
|
||||||
|
if (dataRow + 2 < allRows.length && allRows[dataRow + 2]) {
|
||||||
|
this.parseHeaderRow(allRows[dataRow + 2], footerInfo);
|
||||||
|
}
|
||||||
|
this.log(' Found footer in next row, stopping material parsing');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract material data
|
||||||
|
const material = this.parseMaterialRow(allRows[dataRow], dataRow + 1);
|
||||||
|
if (material) {
|
||||||
|
this.log(' Parsed material:', material.materialCode);
|
||||||
|
materials.push(material);
|
||||||
|
} else {
|
||||||
|
this.log(' Skipped material row at', dataRow + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
dataRow++;
|
||||||
|
}
|
||||||
|
|
||||||
|
orders.push({
|
||||||
|
orderInfo: { ...orderInfo, ...footerInfo },
|
||||||
|
materials,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to next row after this order
|
||||||
|
i = tableRow + 1;
|
||||||
|
} else {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return orders;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse header row (field names and values interleaved)
|
||||||
|
* Reference: _parse_header_row() in Python code
|
||||||
|
*/
|
||||||
|
private parseHeaderRow(row: any[], info: OrderHeader): void {
|
||||||
|
let j = 0;
|
||||||
|
while (j < row.length) {
|
||||||
|
const cell = row[j];
|
||||||
|
if (cell && String(cell).trim() && String(cell).includes(':')) {
|
||||||
|
// Found field name
|
||||||
|
let fieldName = String(cell).replace(':', '').trim();
|
||||||
|
|
||||||
|
// Apply field name mapping (from Python code)
|
||||||
|
if (fieldName in this.FIELD_NAME_MAPPING) {
|
||||||
|
fieldName = this.FIELD_NAME_MAPPING[fieldName];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map Chinese field name to English property name
|
||||||
|
const englishFieldName = this.CHINESE_TO_ENGLISH_MAPPING[fieldName] || fieldName;
|
||||||
|
|
||||||
|
// Skip empty cells to find first non-field-name value
|
||||||
|
let k = j + 1;
|
||||||
|
while (
|
||||||
|
k < row.length &&
|
||||||
|
(!row[k] || !String(row[k]).trim() || String(row[k]).includes(':'))
|
||||||
|
) {
|
||||||
|
k++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (k < row.length && row[k] && !String(row[k]).includes(':')) {
|
||||||
|
info[englishFieldName as keyof OrderHeader] = String(row[k]).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip processed value, continue to next field name
|
||||||
|
j = k + 1;
|
||||||
|
} else {
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse material data row
|
||||||
|
* Reference: material data extraction in Python code
|
||||||
|
* ExcelJS arrays are 1-indexed (index 0 is null), so data starts at index 2
|
||||||
|
*/
|
||||||
|
private parseMaterialRow(row: any[], rowNumber: number): any | null {
|
||||||
|
// Extract 13 fields from material row (starting at index 2)
|
||||||
|
const material = {
|
||||||
|
sequence: row[2],
|
||||||
|
materialCode: row[3],
|
||||||
|
materialName: row[4],
|
||||||
|
specification: row[5],
|
||||||
|
model: row[6],
|
||||||
|
drawingNumber: row[7],
|
||||||
|
material: row[8],
|
||||||
|
quantity: this.parseFloat(row[9]),
|
||||||
|
unit: row[10],
|
||||||
|
requiredDate: row[11],
|
||||||
|
warehouse: row[12],
|
||||||
|
unitUsage: this.parseFloat(row[13]),
|
||||||
|
cumulativeOutboundQty: this.parseFloat(row[14]),
|
||||||
|
rowNumber,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Skip if no material code
|
||||||
|
if (!material.materialCode) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return material;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely parse float from cell value
|
||||||
|
*/
|
||||||
|
private parseFloat(value: any): number | undefined {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const parsed = parseFloat(String(value));
|
||||||
|
return isNaN(parsed) ? undefined : parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
113
src/main/types/excel.types.ts
Normal file
113
src/main/types/excel.types.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* Excel Parser Types
|
||||||
|
* Defines the structure for parsed Excel data from ERP system
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a discrete material plan row from Excel
|
||||||
|
* Based on the ERP Excel export structure
|
||||||
|
*/
|
||||||
|
export interface DiscreteMaterialPlan {
|
||||||
|
/** Order number (e.g., SC202501001) */
|
||||||
|
orderNumber: string;
|
||||||
|
|
||||||
|
/** Production ID from order header */
|
||||||
|
productionId: string;
|
||||||
|
|
||||||
|
/** Material code (材料编码) */
|
||||||
|
materialCode: string;
|
||||||
|
|
||||||
|
/** Material name (材料名称) */
|
||||||
|
materialName: string;
|
||||||
|
|
||||||
|
/** Specification (规格) */
|
||||||
|
specification?: string;
|
||||||
|
|
||||||
|
/** Model (型号) */
|
||||||
|
model?: string;
|
||||||
|
|
||||||
|
/** Drawing number (图号) */
|
||||||
|
drawingNumber?: string;
|
||||||
|
|
||||||
|
/** Material (物料材质) */
|
||||||
|
material?: string;
|
||||||
|
|
||||||
|
/** Planned quantity (计划数量) */
|
||||||
|
quantity: number;
|
||||||
|
|
||||||
|
/** Unit (单位) */
|
||||||
|
unit: string;
|
||||||
|
|
||||||
|
/** Required date (需用日期) */
|
||||||
|
requiredDate?: string;
|
||||||
|
|
||||||
|
/** Warehouse (发料仓库) */
|
||||||
|
warehouse?: string;
|
||||||
|
|
||||||
|
/** Unit usage (单位用量) */
|
||||||
|
unitUsage?: number;
|
||||||
|
|
||||||
|
/** Cumulative outbound quantity (累计出库数量) */
|
||||||
|
cumulativeOutboundQty?: number;
|
||||||
|
|
||||||
|
/** Row number in Excel file */
|
||||||
|
rowNumber?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for Excel parsing
|
||||||
|
*/
|
||||||
|
export interface ExcelParseOptions {
|
||||||
|
/** Skip orders with no material data */
|
||||||
|
skipEmptyOrders?: boolean;
|
||||||
|
|
||||||
|
/** Skip footer rows (制单人/打印人) */
|
||||||
|
skipFooter?: boolean;
|
||||||
|
|
||||||
|
/** Custom field mapping */
|
||||||
|
fieldMapping?: Record<string, string>;
|
||||||
|
|
||||||
|
/** Verbose logging */
|
||||||
|
verbose?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Order header information from Excel
|
||||||
|
*/
|
||||||
|
export interface OrderHeader {
|
||||||
|
/** Order title (离散备料计划) */
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
/** Production department (生产部门) */
|
||||||
|
productionDepartment?: string;
|
||||||
|
|
||||||
|
/** Production order (生产订单) */
|
||||||
|
productionOrder?: string;
|
||||||
|
|
||||||
|
/** Product code (产品编码) */
|
||||||
|
productCode?: string;
|
||||||
|
|
||||||
|
/** Product name (产品名称) */
|
||||||
|
productName?: string;
|
||||||
|
|
||||||
|
/** Product specification (产品规格) */
|
||||||
|
productSpecification?: string;
|
||||||
|
|
||||||
|
/** Planned quantity (计划数量) */
|
||||||
|
plannedQuantity?: string;
|
||||||
|
|
||||||
|
/** Unit (单位) */
|
||||||
|
unit?: string;
|
||||||
|
|
||||||
|
/** Required date (需用日期) */
|
||||||
|
requiredDate?: string;
|
||||||
|
|
||||||
|
/** Creator (制单人) */
|
||||||
|
creator?: string;
|
||||||
|
|
||||||
|
/** Printer (打印人) */
|
||||||
|
printer?: string;
|
||||||
|
|
||||||
|
/** Print date (打印日期) */
|
||||||
|
printDate?: string;
|
||||||
|
}
|
||||||
156
tests/fixtures/create-fixtures.ts
vendored
Normal file
156
tests/fixtures/create-fixtures.ts
vendored
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import ExcelJS from 'exceljs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create test fixture Excel files for unit tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
async function createTestFixture() {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const worksheet = workbook.addWorksheet('Sheet1');
|
||||||
|
|
||||||
|
// Row 1: Order title
|
||||||
|
worksheet.addRow([null, '离散备料计划']);
|
||||||
|
|
||||||
|
// Row 2-5: Order header info
|
||||||
|
worksheet.addRow([null, '生产部门:', null, '生产车间', '产品编码:', null, 'P001']);
|
||||||
|
worksheet.addRow([null, '生产订单:', null, 'SC202501001', '产品名称:', null, '测试产品A']);
|
||||||
|
worksheet.addRow([null, '产品规格:', null, '标准规格', '计划数量:', null, '100']);
|
||||||
|
worksheet.addRow([null, '单位:', null, '件', '需用日期:', null, '2025-02-15']);
|
||||||
|
|
||||||
|
// Row 6: Empty row before table header
|
||||||
|
worksheet.addRow([]);
|
||||||
|
|
||||||
|
// Row 7: Table header
|
||||||
|
worksheet.addRow([
|
||||||
|
null,
|
||||||
|
'序号',
|
||||||
|
'材料编码',
|
||||||
|
'材料名称',
|
||||||
|
'规格',
|
||||||
|
'型号',
|
||||||
|
'图号',
|
||||||
|
'物料材质',
|
||||||
|
'计划数量',
|
||||||
|
'单位',
|
||||||
|
'需用日期',
|
||||||
|
'发料仓库',
|
||||||
|
'单位用量',
|
||||||
|
'累计出库数量',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Row 8-10: Material data
|
||||||
|
worksheet.addRow([
|
||||||
|
null,
|
||||||
|
1,
|
||||||
|
'M001',
|
||||||
|
'钢材A',
|
||||||
|
'规格1',
|
||||||
|
'型号1',
|
||||||
|
'图号1',
|
||||||
|
'材质1',
|
||||||
|
50,
|
||||||
|
'kg',
|
||||||
|
'2025-02-10',
|
||||||
|
'仓库1',
|
||||||
|
0.5,
|
||||||
|
0,
|
||||||
|
]);
|
||||||
|
worksheet.addRow([
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
'M002',
|
||||||
|
'塑料B',
|
||||||
|
'规格2',
|
||||||
|
'型号2',
|
||||||
|
'图号2',
|
||||||
|
'材质2',
|
||||||
|
100,
|
||||||
|
'kg',
|
||||||
|
'2025-02-12',
|
||||||
|
'仓库1',
|
||||||
|
1.0,
|
||||||
|
20,
|
||||||
|
]);
|
||||||
|
worksheet.addRow([
|
||||||
|
null,
|
||||||
|
3,
|
||||||
|
'M003',
|
||||||
|
'配件C',
|
||||||
|
'规格3',
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
200,
|
||||||
|
'件',
|
||||||
|
'2025-02-14',
|
||||||
|
'仓库2',
|
||||||
|
2.0,
|
||||||
|
50,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Row 11: Footer info
|
||||||
|
worksheet.addRow([null, '制单人:', null, '张三', '打印人:', null, '李四']);
|
||||||
|
worksheet.addRow([null, '打印日期:', null, '2025-01-15']);
|
||||||
|
|
||||||
|
// Save file
|
||||||
|
const filePath = path.resolve(__dirname, 'test-export.xlsx');
|
||||||
|
await workbook.xlsx.writeFile(filePath);
|
||||||
|
console.log('Created test fixture:', filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createEmptyOrdersFixture() {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const worksheet = workbook.addWorksheet('Sheet1');
|
||||||
|
|
||||||
|
// Row 1: Order title
|
||||||
|
worksheet.addRow([null, '离散备料计划']);
|
||||||
|
|
||||||
|
// Row 2-5: Order header info
|
||||||
|
worksheet.addRow([null, '生产部门:', null, '生产车间', '产品编码:', null, 'P002']);
|
||||||
|
worksheet.addRow([null, '生产订单:', null, 'SC202501002', '产品名称:', null, '测试产品B']);
|
||||||
|
worksheet.addRow([null, '产品规格:', null, '特殊规格', '计划数量:', null, '50']);
|
||||||
|
worksheet.addRow([null, '单位:', null, '套', '需用日期:', null, '2025-03-01']);
|
||||||
|
|
||||||
|
// Row 6: Empty row before table header
|
||||||
|
worksheet.addRow([]);
|
||||||
|
|
||||||
|
// Row 7: Table header
|
||||||
|
worksheet.addRow([
|
||||||
|
null,
|
||||||
|
'序号',
|
||||||
|
'材料编码',
|
||||||
|
'材料名称',
|
||||||
|
'规格',
|
||||||
|
'型号',
|
||||||
|
'图号',
|
||||||
|
'物料材质',
|
||||||
|
'计划数量',
|
||||||
|
'单位',
|
||||||
|
'需用日期',
|
||||||
|
'发料仓库',
|
||||||
|
'单位用量',
|
||||||
|
'累计出库数量',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Row 8: Empty row (no data)
|
||||||
|
worksheet.addRow([]);
|
||||||
|
|
||||||
|
// Row 9: Footer info
|
||||||
|
worksheet.addRow([null, '制单人:', null, '王五', '打印人:', null, '赵六']);
|
||||||
|
worksheet.addRow([null, '打印日期:', null, '2025-01-16']);
|
||||||
|
|
||||||
|
// Save file
|
||||||
|
const filePath = path.resolve(__dirname, 'test-empty-orders.xlsx');
|
||||||
|
await workbook.xlsx.writeFile(filePath);
|
||||||
|
console.log('Created empty orders fixture:', filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Creating test fixture Excel files...');
|
||||||
|
await createTestFixture();
|
||||||
|
await createEmptyOrdersFixture();
|
||||||
|
console.log('Done!');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
BIN
tests/fixtures/test-empty-orders.xlsx
vendored
Normal file
BIN
tests/fixtures/test-empty-orders.xlsx
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/test-export.xlsx
vendored
Normal file
BIN
tests/fixtures/test-export.xlsx
vendored
Normal file
Binary file not shown.
59
tests/unit/excel-parser.test.ts
Normal file
59
tests/unit/excel-parser.test.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { ExcelParser } from '../../src/main/services/excel/excel-parser';
|
||||||
|
import type { DiscreteMaterialPlan } from '../../src/main/types/excel.types';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
describe('Excel Parser', () => {
|
||||||
|
it('should parse Excel file and extract material plans', async () => {
|
||||||
|
const parser = new ExcelParser();
|
||||||
|
const filePath = path.resolve(__dirname, '../fixtures/test-export.xlsx');
|
||||||
|
|
||||||
|
const plans = await parser.parse(filePath);
|
||||||
|
|
||||||
|
expect(Array.isArray(plans)).toBe(true);
|
||||||
|
expect(plans.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const firstPlan = plans[0];
|
||||||
|
expect(firstPlan).toHaveProperty('orderNumber');
|
||||||
|
expect(firstPlan).toHaveProperty('materialCode');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse all material fields correctly', async () => {
|
||||||
|
const parser = new ExcelParser();
|
||||||
|
const filePath = path.resolve(__dirname, '../fixtures/test-export.xlsx');
|
||||||
|
|
||||||
|
const plans = await parser.parse(filePath);
|
||||||
|
|
||||||
|
expect(plans.length).toBe(3);
|
||||||
|
|
||||||
|
// Check first material
|
||||||
|
expect(plans[0].orderNumber).toBe('SC202501001');
|
||||||
|
expect(plans[0].materialCode).toBe('M001');
|
||||||
|
expect(plans[0].materialName).toBe('钢材A');
|
||||||
|
expect(plans[0].specification).toBe('规格1');
|
||||||
|
expect(plans[0].model).toBe('型号1');
|
||||||
|
expect(plans[0].drawingNumber).toBe('图号1');
|
||||||
|
expect(plans[0].material).toBe('材质1');
|
||||||
|
expect(plans[0].quantity).toBe(50);
|
||||||
|
expect(plans[0].unit).toBe('kg');
|
||||||
|
expect(plans[0].requiredDate).toBe('2025-02-10');
|
||||||
|
expect(plans[0].warehouse).toBe('仓库1');
|
||||||
|
expect(plans[0].unitUsage).toBe(0.5);
|
||||||
|
expect(plans[0].cumulativeOutboundQty).toBe(0);
|
||||||
|
|
||||||
|
// Check third material (with some empty fields)
|
||||||
|
expect(plans[2].materialCode).toBe('M003');
|
||||||
|
expect(plans[2].materialName).toBe('配件C');
|
||||||
|
expect(plans[2].quantity).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty orders gracefully', async () => {
|
||||||
|
const parser = new ExcelParser();
|
||||||
|
const filePath = path.resolve(__dirname, '../fixtures/test-empty-orders.xlsx');
|
||||||
|
|
||||||
|
const plans = await parser.parse(filePath);
|
||||||
|
|
||||||
|
expect(plans).toBeDefined();
|
||||||
|
expect(plans.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user