Compare commits
5 Commits
c384513273
...
88c8c256e2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88c8c256e2 | ||
|
|
5497e86b58 | ||
|
|
63ea81e0d6 | ||
|
|
abe51d17fa | ||
|
|
240e3838ba |
119
IMPLEMENTATION_PLAN.md
Normal file
119
IMPLEMENTATION_PLAN.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# Implementation Plan: Auto-import Extracted Data to Database
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Implement automatic database import after ERP data extraction completes. The merged Excel file will be read and written to the `dbo_DiscreteMaterialPlanData` table.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
- **Trigger**: Automatic after extraction completes
|
||||||
|
- **Delete Strategy**: Batch delete by `SourceNumber` before insert
|
||||||
|
- **Batch Insert**: 1000 records per batch
|
||||||
|
- **Field Mapping**: 28 Excel fields → database columns (skip 打印人, 打印日期, BOMVersion)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
ExtractorService
|
||||||
|
│
|
||||||
|
├── extract() → download + merge Excel
|
||||||
|
│
|
||||||
|
└── NEW: importToDatabase(mergedFile)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
DataImportService
|
||||||
|
│
|
||||||
|
├── readExcelFile() → records + sourceNumbers
|
||||||
|
├── deleteExistingRecords(sourceNumbers)
|
||||||
|
└── batchInsert(records, batchSize=1000)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
DiscreteMaterialPlanDAO
|
||||||
|
├── deleteBySourceNumbers()
|
||||||
|
└── batchInsert()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Field Mapping
|
||||||
|
|
||||||
|
| Excel Header | Database Column | Notes |
|
||||||
|
|-------------|-----------------|-------|
|
||||||
|
| 工厂 | Factory | |
|
||||||
|
| 备料状态 | MaterialStatus | |
|
||||||
|
| 备料计划单号 | PlanNumber | |
|
||||||
|
| 来源单号 | SourceNumber | **Deletion key** |
|
||||||
|
| 备料类型 | MaterialType | |
|
||||||
|
| 产品编码 | ProductCode | |
|
||||||
|
| 产品名称 | ProductName | |
|
||||||
|
| 产品计划数量 | ProductPlanQuantity | decimal |
|
||||||
|
| 产品单位 | ProductUnit | |
|
||||||
|
| 用料部门 | UseDepartment | |
|
||||||
|
| 备注 | Remark | |
|
||||||
|
| 制单人 | Creator | |
|
||||||
|
| 制单日期 | CreateDate | date |
|
||||||
|
| 审批人 | Approver | |
|
||||||
|
| 审批日期 | ApproveDate | date |
|
||||||
|
| 序号 | SequenceNumber | int |
|
||||||
|
| 材料编码 | MaterialCode | |
|
||||||
|
| 材料名称 | MaterialName | |
|
||||||
|
| 规格 | Specification | |
|
||||||
|
| 型号 | Model | |
|
||||||
|
| 图号 | DrawingNumber | |
|
||||||
|
| 物料材质 | MaterialQuality | |
|
||||||
|
| 计划数量 | PlanQuantity | decimal |
|
||||||
|
| 单位 | Unit | |
|
||||||
|
| 需用日期 | RequiredDate | date |
|
||||||
|
| 发料仓库 | Warehouse | |
|
||||||
|
| 单位用量 | UnitUsage | decimal |
|
||||||
|
| 累计出库数量 | CumulativeOutputQuantity | decimal |
|
||||||
|
| 打印人 | ❌ SKIP | Not in DB |
|
||||||
|
| 打印日期 | ❌ SKIP | Not in DB |
|
||||||
|
| - | BOMVersion | SKIP (no source) |
|
||||||
|
|
||||||
|
## Files to Create/Modify
|
||||||
|
|
||||||
|
### 1. NEW: `src/main/services/database/data-importer.ts`
|
||||||
|
Main import service with:
|
||||||
|
- `importFromExcel(filePath)` - Main entry point
|
||||||
|
- `readExcelFile(filePath)` - Parse Excel using ExcelJS
|
||||||
|
- Map Excel columns to database fields
|
||||||
|
- Return records and unique SourceNumbers
|
||||||
|
|
||||||
|
### 2. MODIFY: `src/main/services/database/discrete-material-plan-dao.ts`
|
||||||
|
Add methods:
|
||||||
|
- `deleteBySourceNumbers(sourceNumbers: string[])` - Batch delete
|
||||||
|
- `batchInsert(records: MaterialPlanRecord[], batchSize: number)` - Batch insert
|
||||||
|
|
||||||
|
### 3. MODIFY: `src/main/services/erp/extractor.ts`
|
||||||
|
- After successful merge, call `importToDatabase(mergedFile)`
|
||||||
|
- Add import results to `ExtractorResult`
|
||||||
|
|
||||||
|
### 4. MODIFY: `src/main/types/extractor.types.ts`
|
||||||
|
Add types:
|
||||||
|
```typescript
|
||||||
|
export interface ImportResult {
|
||||||
|
success: boolean
|
||||||
|
recordsImported: number
|
||||||
|
recordsDeleted: number
|
||||||
|
errors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtractorResult {
|
||||||
|
// existing fields...
|
||||||
|
importResult?: ImportResult
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. MODIFY: `src/renderer/src/pages/ExtractorPage.tsx`
|
||||||
|
- Display import results
|
||||||
|
- Show records deleted/imported counts
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. Extend `DiscreteMaterialPlanDAO` with insert/delete methods
|
||||||
|
2. Create `DataImportService`
|
||||||
|
3. Integrate into `ExtractorService`
|
||||||
|
4. Update types
|
||||||
|
5. Update UI
|
||||||
|
|
||||||
|
## Testing Plan
|
||||||
|
1. Unit test DAO methods
|
||||||
|
2. Integration test with sample Excel file
|
||||||
|
3. E2E test extraction → import flow
|
||||||
@@ -147,8 +147,8 @@ function validateEditableFields(settings: Partial<SettingsData>): {
|
|||||||
*/
|
*/
|
||||||
export class ConfigManager {
|
export class ConfigManager {
|
||||||
private static instance: ConfigManager | null = null
|
private static instance: ConfigManager | null = null
|
||||||
private envPath: string
|
private envPath!: string
|
||||||
private backupPath: string
|
private backupPath!: string
|
||||||
private configCache: Map<string, string> = new Map()
|
private configCache: Map<string, string> = new Map()
|
||||||
private initialized: boolean = false
|
private initialized: boolean = false
|
||||||
|
|
||||||
@@ -214,6 +214,8 @@ export class ConfigManager {
|
|||||||
* @param key - Configuration key
|
* @param key - Configuration key
|
||||||
* @param defaultValue - Default value if key doesn't exist
|
* @param defaultValue - Default value if key doesn't exist
|
||||||
*/
|
*/
|
||||||
|
public get(key: string): string | undefined
|
||||||
|
public get(key: string, defaultValue: string): string
|
||||||
public get(key: string, defaultValue?: string): string | undefined {
|
public get(key: string, defaultValue?: string): string | undefined {
|
||||||
return this.configCache.get(key) ?? defaultValue
|
return this.configCache.get(key) ?? defaultValue
|
||||||
}
|
}
|
||||||
|
|||||||
304
src/main/services/database/data-importer.ts
Normal file
304
src/main/services/database/data-importer.ts
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
/**
|
||||||
|
* 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 path from 'path'
|
||||||
|
import { createLogger } from '../logger'
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -383,6 +383,263 @@ export class DiscreteMaterialPlanDAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== DELETE OPERATIONS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete records by SourceNumber list
|
||||||
|
* Uses batch processing for large lists
|
||||||
|
* @param sourceNumbers - List of SourceNumber values to delete
|
||||||
|
* @returns Number of records deleted
|
||||||
|
*/
|
||||||
|
async deleteBySourceNumbers(sourceNumbers: string[]): Promise<number> {
|
||||||
|
if (!sourceNumbers || sourceNumbers.length === 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dbService = await this.getDatabaseService()
|
||||||
|
const tableName = this.getTableName()
|
||||||
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
|
const batchSize = 2000
|
||||||
|
let totalDeleted = 0
|
||||||
|
|
||||||
|
// Get unique source numbers
|
||||||
|
const uniqueSourceNumbers = [...new Set(sourceNumbers.filter(Boolean))]
|
||||||
|
|
||||||
|
for (let i = 0; i < uniqueSourceNumbers.length; i += batchSize) {
|
||||||
|
const batch = uniqueSourceNumbers.slice(i, i + batchSize)
|
||||||
|
const placeholders = this.buildPlaceholders(batch.length, isSqlServer)
|
||||||
|
|
||||||
|
const sqlString = `
|
||||||
|
DELETE FROM ${tableName}
|
||||||
|
WHERE SourceNumber IN (${placeholders})
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await dbService.query(sqlString, batch)
|
||||||
|
totalDeleted += result.rowCount || 0
|
||||||
|
|
||||||
|
log.debug('Deleted batch', {
|
||||||
|
batch: i / batchSize + 1,
|
||||||
|
count: result.rowCount
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('Deleted records by source numbers', {
|
||||||
|
totalDeleted,
|
||||||
|
sourceNumberCount: uniqueSourceNumbers.length
|
||||||
|
})
|
||||||
|
|
||||||
|
return totalDeleted
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Delete by source numbers error', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== INSERT OPERATIONS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert records in batches
|
||||||
|
* @param records - List of MaterialPlanRecord to insert
|
||||||
|
* @param batchSize - Number of records per batch (default: 1000, auto-adjusted for SQL Server)
|
||||||
|
* @returns Number of records inserted
|
||||||
|
*/
|
||||||
|
async batchInsert(records: MaterialPlanRecord[], batchSize = 1000): Promise<number> {
|
||||||
|
if (!records || records.length === 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dbService = await this.getDatabaseService()
|
||||||
|
const tableName = this.getTableName()
|
||||||
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
|
let totalInserted = 0
|
||||||
|
|
||||||
|
// SQL Server has a limit of 2100 parameters per query
|
||||||
|
// Each record has 28 columns, so max rows per batch = 2100 / 28 = 75
|
||||||
|
// Leave some margin for query overhead
|
||||||
|
const columnsPerRow = 28
|
||||||
|
const sqlServerMaxParams = 2000
|
||||||
|
const effectiveBatchSize = isSqlServer
|
||||||
|
? Math.min(batchSize, Math.floor(sqlServerMaxParams / columnsPerRow))
|
||||||
|
: batchSize
|
||||||
|
|
||||||
|
log.info('Batch insert parameters', {
|
||||||
|
isSqlServer,
|
||||||
|
dbType: dbService.type,
|
||||||
|
columnsPerRow,
|
||||||
|
effectiveBatchSize,
|
||||||
|
totalRecords: records.length
|
||||||
|
})
|
||||||
|
|
||||||
|
// Process in batches
|
||||||
|
for (let i = 0; i < records.length; i += effectiveBatchSize) {
|
||||||
|
const batch = records.slice(i, i + effectiveBatchSize)
|
||||||
|
const inserted = await this.insertBatch(dbService, tableName, batch, isSqlServer)
|
||||||
|
totalInserted += inserted
|
||||||
|
|
||||||
|
log.debug('Inserted batch', {
|
||||||
|
batch: Math.floor(i / effectiveBatchSize) + 1,
|
||||||
|
count: inserted
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('Batch insert completed', {
|
||||||
|
totalInserted,
|
||||||
|
batchSize: effectiveBatchSize
|
||||||
|
})
|
||||||
|
|
||||||
|
return totalInserted
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Batch insert error', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a single batch of records
|
||||||
|
*/
|
||||||
|
private async insertBatch(
|
||||||
|
dbService: IDatabaseService,
|
||||||
|
tableName: string,
|
||||||
|
records: MaterialPlanRecord[],
|
||||||
|
isSqlServer: boolean
|
||||||
|
): Promise<number> {
|
||||||
|
if (records.length === 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build column list (excluding id)
|
||||||
|
const columns = [
|
||||||
|
'Factory',
|
||||||
|
'MaterialStatus',
|
||||||
|
'PlanNumber',
|
||||||
|
'SourceNumber',
|
||||||
|
'MaterialType',
|
||||||
|
'ProductCode',
|
||||||
|
'ProductName',
|
||||||
|
'ProductUnit',
|
||||||
|
'ProductPlanQuantity',
|
||||||
|
'UseDepartment',
|
||||||
|
'Remark',
|
||||||
|
'Creator',
|
||||||
|
'CreateDate',
|
||||||
|
'Approver',
|
||||||
|
'ApproveDate',
|
||||||
|
'SequenceNumber',
|
||||||
|
'MaterialCode',
|
||||||
|
'MaterialName',
|
||||||
|
'Specification',
|
||||||
|
'Model',
|
||||||
|
'DrawingNumber',
|
||||||
|
'MaterialQuality',
|
||||||
|
'PlanQuantity',
|
||||||
|
'Unit',
|
||||||
|
'RequiredDate',
|
||||||
|
'Warehouse',
|
||||||
|
'UnitUsage',
|
||||||
|
'CumulativeOutputQuantity'
|
||||||
|
]
|
||||||
|
|
||||||
|
// Build parameterized insert
|
||||||
|
const values: any[] = []
|
||||||
|
const rowPlaceholders: string[] = []
|
||||||
|
|
||||||
|
records.forEach((record, rowIndex) => {
|
||||||
|
const rowValues = this.buildRowValues(record, columns, rowIndex, isSqlServer, values)
|
||||||
|
rowPlaceholders.push(`(${rowValues.join(',')})`)
|
||||||
|
})
|
||||||
|
|
||||||
|
const sqlString = `
|
||||||
|
INSERT INTO ${tableName} (${columns.join(', ')})
|
||||||
|
VALUES ${rowPlaceholders.join(', ')}
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await dbService.query(sqlString, values)
|
||||||
|
return result.rowCount || records.length
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build parameter values for a single row
|
||||||
|
*/
|
||||||
|
private buildRowValues(
|
||||||
|
record: MaterialPlanRecord,
|
||||||
|
columns: string[],
|
||||||
|
rowIndex: number,
|
||||||
|
isSqlServer: boolean,
|
||||||
|
values: any[]
|
||||||
|
): string[] {
|
||||||
|
return columns.map((col) => {
|
||||||
|
const value = this.getColumnValue(record, col)
|
||||||
|
values.push(value)
|
||||||
|
|
||||||
|
if (isSqlServer) {
|
||||||
|
return `@p${values.length - 1}`
|
||||||
|
} else {
|
||||||
|
return '?'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the value for a specific column from the record
|
||||||
|
*/
|
||||||
|
private getColumnValue(record: MaterialPlanRecord, column: string): any {
|
||||||
|
const columnMapping: Record<string, keyof MaterialPlanRecord> = {
|
||||||
|
Factory: 'factory',
|
||||||
|
MaterialStatus: 'materialStatus',
|
||||||
|
PlanNumber: 'planNumber',
|
||||||
|
SourceNumber: 'sourceNumber',
|
||||||
|
MaterialType: 'materialType',
|
||||||
|
ProductCode: 'productCode',
|
||||||
|
ProductName: 'productName',
|
||||||
|
ProductUnit: 'productUnit',
|
||||||
|
ProductPlanQuantity: 'productPlanQuantity',
|
||||||
|
UseDepartment: 'useDepartment',
|
||||||
|
Remark: 'remark',
|
||||||
|
Creator: 'creator',
|
||||||
|
CreateDate: 'createDate',
|
||||||
|
Approver: 'approver',
|
||||||
|
ApproveDate: 'approveDate',
|
||||||
|
SequenceNumber: 'sequenceNumber',
|
||||||
|
MaterialCode: 'materialCode',
|
||||||
|
MaterialName: 'materialName',
|
||||||
|
Specification: 'specification',
|
||||||
|
Model: 'model',
|
||||||
|
DrawingNumber: 'drawingNumber',
|
||||||
|
MaterialQuality: 'materialQuality',
|
||||||
|
PlanQuantity: 'planQuantity',
|
||||||
|
Unit: 'unit',
|
||||||
|
RequiredDate: 'requiredDate',
|
||||||
|
Warehouse: 'warehouse',
|
||||||
|
UnitUsage: 'unitUsage',
|
||||||
|
CumulativeOutputQuantity: 'cumulativeOutputQuantity'
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = columnMapping[column]
|
||||||
|
if (!key) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = record[key]
|
||||||
|
|
||||||
|
// Handle null/undefined
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle empty strings for string fields
|
||||||
|
if (typeof value === 'string' && value.trim() === '') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== UTILITY METHODS ====================
|
// ==================== UTILITY METHODS ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -92,8 +92,8 @@ export class SqlServerService implements IDatabaseService {
|
|||||||
|
|
||||||
const result = await request.query(sqlString)
|
const result = await request.query(sqlString)
|
||||||
|
|
||||||
// Convert recordset to array of objects
|
// Convert recordset to array of objects (may be undefined for DELETE/INSERT/UPDATE)
|
||||||
const rows = result.recordset as Record<string, unknown>[]
|
const rows = (result.recordset as Record<string, unknown>[]) || []
|
||||||
// Extract column names from the first row if available
|
// Extract column names from the first row if available
|
||||||
const columns = rows.length > 0 ? Object.keys(rows[0]) : []
|
const columns = rows.length > 0 ? Object.keys(rows[0]) : []
|
||||||
|
|
||||||
@@ -114,7 +114,13 @@ export class SqlServerService implements IDatabaseService {
|
|||||||
*/
|
*/
|
||||||
async queryWithParams(
|
async queryWithParams(
|
||||||
sqlString: string,
|
sqlString: string,
|
||||||
params: Record<string, { value: unknown; type?: sql.ISqlType }>
|
params: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
value: unknown
|
||||||
|
type?: sql.ISqlType | sql.ISqlTypeFactoryWithLength | sql.ISqlTypeWithLength
|
||||||
|
}
|
||||||
|
>
|
||||||
): Promise<QueryResult> {
|
): Promise<QueryResult> {
|
||||||
if (!this.pool) {
|
if (!this.pool) {
|
||||||
throw new Error('Not connected to SQL Server. Call connect() first.')
|
throw new Error('Not connected to SQL Server. Call connect() first.')
|
||||||
|
|||||||
204
src/main/services/erp/extractor-core.ts
Normal file
204
src/main/services/erp/extractor-core.ts
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import path from 'path'
|
||||||
|
import { ERP_LOCATORS } from './locators'
|
||||||
|
import type { ErpSession } from '../../types/erp.types'
|
||||||
|
import type { ExtractorCoreInput, ExtractorCoreResult } from '../../types/extractor.types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ExtractorCore - Handles all web page operations for data extraction
|
||||||
|
* This class is responsible only for web interactions, not file processing
|
||||||
|
*
|
||||||
|
* Note: Uses 'any' for Frame types to maintain compatibility with Playwright's
|
||||||
|
* frame handling API, matching the original implementation.
|
||||||
|
*/
|
||||||
|
export class ExtractorCore {
|
||||||
|
/**
|
||||||
|
* Execute all web page operations and return downloaded file paths
|
||||||
|
* @param input - Contains session, order numbers, download directory, batch size, and progress callback
|
||||||
|
* @returns List of downloaded file paths and any errors encountered
|
||||||
|
*/
|
||||||
|
async downloadAllBatches(input: ExtractorCoreInput): Promise<ExtractorCoreResult> {
|
||||||
|
const result: ExtractorCoreResult = {
|
||||||
|
downloadedFiles: [],
|
||||||
|
errors: []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to extractor page and get popup page + work frame
|
||||||
|
const { popupPage, workFrame } = await this.navigateToExtractorPage(input.session)
|
||||||
|
|
||||||
|
// Process orders in batches
|
||||||
|
const batches = this.createBatches(input.orderNumbers, input.batchSize)
|
||||||
|
|
||||||
|
for (let i = 0; i < batches.length; i++) {
|
||||||
|
const batch = batches[i]
|
||||||
|
const progress = ((i + 1) / batches.length) * 100
|
||||||
|
|
||||||
|
input.onProgress?.(`Processing batch ${i + 1}/${batches.length}`, progress)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const filePath = await this.downloadBatch(
|
||||||
|
input.session,
|
||||||
|
popupPage,
|
||||||
|
workFrame,
|
||||||
|
batch,
|
||||||
|
i,
|
||||||
|
batches.length,
|
||||||
|
input.downloadDir
|
||||||
|
)
|
||||||
|
result.downloadedFiles.push(filePath)
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
result.errors.push(`Batch ${i + 1}: ${message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to extractor/query page
|
||||||
|
* Reference: Python extract() method lines 266-278
|
||||||
|
*
|
||||||
|
* Python workflow:
|
||||||
|
* 1. main_frame.locator("i").first.click() - Click menu icon
|
||||||
|
* 2. page.expect_popup() + get_by_title("离散备料计划维护").click() - Click menu item and wait for popup
|
||||||
|
* 3. page1.locator("#forwardFrame").content_frame - Get popup's forward frame
|
||||||
|
* 4. f_frame.locator("#mainiframe").content_frame - Get nested inner frame
|
||||||
|
* 5. setup_query_interface(work_frame) - Setup query interface
|
||||||
|
*/
|
||||||
|
private async navigateToExtractorPage(
|
||||||
|
session: ErpSession
|
||||||
|
): Promise<{ popupPage: any; workFrame: any }> {
|
||||||
|
const { page, mainFrame } = session
|
||||||
|
|
||||||
|
// Step 1: Click menu icon (Python line 266)
|
||||||
|
// main_frame is #forwardFrame.content_frame returned from login
|
||||||
|
await mainFrame.locator('i').first().click()
|
||||||
|
|
||||||
|
// Step 2: Click discrete material plan menu item and expect popup (Python lines 267-271)
|
||||||
|
const popupPromise = page.waitForEvent('popup')
|
||||||
|
await mainFrame.getByTitle('离散备料计划维护', { exact: true }).first().click()
|
||||||
|
const popupPage = await popupPromise
|
||||||
|
|
||||||
|
// Step 3 & 4: Get nested frame structure in popup window (Python lines 273-276)
|
||||||
|
// popup page contains #forwardFrame, which contains #mainiframe
|
||||||
|
const forwardFrameLocator = popupPage.locator('#forwardFrame')
|
||||||
|
const fFrame = await forwardFrameLocator.contentFrame()
|
||||||
|
|
||||||
|
if (!fFrame) {
|
||||||
|
throw new Error('Failed to access popup forward frame')
|
||||||
|
}
|
||||||
|
|
||||||
|
const innerFrameLocator = fFrame.locator('#mainiframe')
|
||||||
|
await innerFrameLocator.waitFor({ state: 'visible', timeout: 15000 })
|
||||||
|
const workFrame = await innerFrameLocator.contentFrame()
|
||||||
|
|
||||||
|
if (!workFrame) {
|
||||||
|
throw new Error('Failed to access inner work frame')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Setup query interface (Python line 278)
|
||||||
|
await this.setupQueryInterface(workFrame)
|
||||||
|
|
||||||
|
return { popupPage, workFrame }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup query interface
|
||||||
|
* Reference: Python setup_query_interface() method lines 231-239
|
||||||
|
*/
|
||||||
|
private async setupQueryInterface(innerFrame: any): Promise<void> {
|
||||||
|
// Click search icon (Python line 233)
|
||||||
|
await innerFrame.locator('.search-name-wrapper > .iconfont').click()
|
||||||
|
|
||||||
|
// Click "订单号查询" menu item (Python line 234)
|
||||||
|
await innerFrame.getByText('订单号查询').click()
|
||||||
|
|
||||||
|
// Click "全部" tab (Python line 235)
|
||||||
|
await innerFrame.getByRole('tab', { name: '全部' }).click()
|
||||||
|
|
||||||
|
// Set limit to 5000 (Python lines 237-239)
|
||||||
|
const inputBox = innerFrame.locator('#rc_select_0')
|
||||||
|
await inputBox.fill('5000')
|
||||||
|
await inputBox.press('Enter')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a single batch of orders
|
||||||
|
* Reference: Python download_batch() method lines 133-175
|
||||||
|
*/
|
||||||
|
private async downloadBatch(
|
||||||
|
session: ErpSession,
|
||||||
|
popupPage: any,
|
||||||
|
workFrame: any,
|
||||||
|
orderNumbers: string[],
|
||||||
|
batchIndex: number,
|
||||||
|
totalBatches: number,
|
||||||
|
downloadDir: string
|
||||||
|
): Promise<string> {
|
||||||
|
// Fill order numbers (Python lines 143-145)
|
||||||
|
const textbox = workFrame.getByRole('textbox', { name: '来源生产订单号' })
|
||||||
|
await textbox.fill('')
|
||||||
|
await textbox.fill(orderNumbers.join(','))
|
||||||
|
|
||||||
|
// Click search button (Python line 147)
|
||||||
|
await workFrame.locator('.search-component-searchBtn').click()
|
||||||
|
|
||||||
|
// Wait for loading (Python lines 148-153)
|
||||||
|
await this.waitForLoading(workFrame)
|
||||||
|
|
||||||
|
// Click first row checkbox (Python line 155)
|
||||||
|
await workFrame.getByRole('row', { name: '序号' }).getByLabel('').click()
|
||||||
|
|
||||||
|
// Hover and click "更多" button (Python lines 156-157)
|
||||||
|
await workFrame.getByRole('button', { name: '更多' }).hover()
|
||||||
|
await workFrame.getByText('输出', { exact: true }).click()
|
||||||
|
|
||||||
|
// Set threshold (Python lines 159-164)
|
||||||
|
const thresholdBox = workFrame
|
||||||
|
.locator('div')
|
||||||
|
.filter({ hasText: /^行数阈值$/ })
|
||||||
|
.locator('input[type="text"]')
|
||||||
|
await thresholdBox.fill('300000')
|
||||||
|
|
||||||
|
// Setup download handler and click confirm (Python lines 166-172)
|
||||||
|
const downloadPath = path.join(downloadDir, `temp_batch_${batchIndex + 1}.xlsx`)
|
||||||
|
|
||||||
|
const downloadPromise = popupPage.waitForEvent('download')
|
||||||
|
await workFrame.getByRole('button', { name: '确定(Y)' }).click()
|
||||||
|
|
||||||
|
const download = await downloadPromise
|
||||||
|
await download.saveAs(downloadPath)
|
||||||
|
|
||||||
|
return downloadPath
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for loading overlay to disappear
|
||||||
|
* Reference: Python lines 148-153
|
||||||
|
*/
|
||||||
|
private async waitForLoading(workFrame: any): Promise<void> {
|
||||||
|
const loadingLocator = workFrame
|
||||||
|
.locator('div')
|
||||||
|
.filter({ hasText: ERP_LOCATORS.extractor.loadingText })
|
||||||
|
.nth(1)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadingLocator.waitFor({ state: 'visible', timeout: 3000 })
|
||||||
|
await loadingLocator.waitFor({ state: 'hidden', timeout: 0 })
|
||||||
|
} catch {
|
||||||
|
// Loading completed quickly or never appeared
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split array into batches
|
||||||
|
* Reference: Python group_order_ids() method lines 128-131
|
||||||
|
*/
|
||||||
|
private createBatches<T>(items: T[], batchSize: number): T[][] {
|
||||||
|
const batches: T[][] = []
|
||||||
|
for (let i = 0; i < items.length; i += batchSize) {
|
||||||
|
batches.push(items.slice(i, i + batchSize))
|
||||||
|
}
|
||||||
|
return batches
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import fs from 'fs/promises'
|
import fs from 'fs/promises'
|
||||||
import { ERP_LOCATORS } from './locators'
|
import { ExtractorCore } from './extractor-core'
|
||||||
import { ErpAuthService } from './erp-auth'
|
import { ErpAuthService } from './erp-auth'
|
||||||
import { ExcelParser } from '../excel/excel-parser'
|
import { ExcelParser } from '../excel/excel-parser'
|
||||||
import type { ExtractorInput, ExtractorResult } from '../../types/extractor.types'
|
import type { ExtractorInput, ExtractorResult, ImportResult } from '../../types/extractor.types'
|
||||||
import type { ErpSession } from '../../types/erp.types'
|
import { DataImportService } from '../database/data-importer'
|
||||||
import type { DiscreteMaterialPlan } from '../../types/excel.types'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ERP Data Extractor Service
|
* ERP Data Extractor Service
|
||||||
* Downloads material plan data for given order numbers
|
* Downloads material plan data for given order numbers
|
||||||
*
|
*
|
||||||
|
* This service orchestrates the extraction process:
|
||||||
|
* - Uses ExtractorCore for web page operations
|
||||||
|
* - Handles file merging and cleanup
|
||||||
|
*
|
||||||
* Reference: playwrite/utils/discrete_material_plan_extractor.py
|
* Reference: playwrite/utils/discrete_material_plan_extractor.py
|
||||||
*/
|
*/
|
||||||
export class ExtractorService {
|
export class ExtractorService {
|
||||||
@@ -27,6 +30,8 @@ export class ExtractorService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract data for given order numbers
|
* Extract data for given order numbers
|
||||||
|
* Orchestrates the extraction process by delegating web operations to ExtractorCore
|
||||||
|
* and handling file merging/cleanup
|
||||||
*/
|
*/
|
||||||
async extract(input: ExtractorInput): Promise<ExtractorResult> {
|
async extract(input: ExtractorInput): Promise<ExtractorResult> {
|
||||||
const result: ExtractorResult = {
|
const result: ExtractorResult = {
|
||||||
@@ -39,36 +44,20 @@ export class ExtractorService {
|
|||||||
try {
|
try {
|
||||||
const session = this.authService.getSession()
|
const session = this.authService.getSession()
|
||||||
|
|
||||||
// Navigate to extractor page and get popup page + work frame
|
// Call ExtractorCore to execute web page operations
|
||||||
const { popupPage, workFrame } = await this.navigateToExtractorPage(session)
|
const core = new ExtractorCore()
|
||||||
|
const coreResult = await core.downloadAllBatches({
|
||||||
// Process orders in batches
|
|
||||||
const batchSize = input.batchSize || 100
|
|
||||||
const batches = this.createBatches(input.orderNumbers, batchSize)
|
|
||||||
|
|
||||||
for (let i = 0; i < batches.length; i++) {
|
|
||||||
const batch = batches[i]
|
|
||||||
const progress = ((i + 1) / batches.length) * 100
|
|
||||||
|
|
||||||
input.onProgress?.(`Processing batch ${i + 1}/${batches.length}`, progress)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const filePath = await this.downloadBatch(
|
|
||||||
session,
|
session,
|
||||||
popupPage,
|
orderNumbers: input.orderNumbers,
|
||||||
workFrame,
|
downloadDir: this.downloadDir,
|
||||||
batch,
|
batchSize: input.batchSize || 100,
|
||||||
i,
|
onProgress: input.onProgress
|
||||||
batches.length
|
})
|
||||||
)
|
|
||||||
result.downloadedFiles.push(filePath)
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
result.errors.push(`Batch ${i + 1}: ${message}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge downloaded files into a single Excel file
|
result.downloadedFiles = coreResult.downloadedFiles
|
||||||
|
result.errors = coreResult.errors
|
||||||
|
|
||||||
|
// Merge downloaded files (original logic preserved)
|
||||||
if (result.downloadedFiles.length > 0) {
|
if (result.downloadedFiles.length > 0) {
|
||||||
input.onProgress?.('正在合并文件...', 95)
|
input.onProgress?.('正在合并文件...', 95)
|
||||||
const mergeResult = await this.mergeFiles(result.downloadedFiles)
|
const mergeResult = await this.mergeFiles(result.downloadedFiles)
|
||||||
@@ -82,6 +71,17 @@ export class ExtractorService {
|
|||||||
|
|
||||||
// Always clean up temporary files regardless of merge success
|
// Always clean up temporary files regardless of merge success
|
||||||
await this.cleanupTempFiles(result.downloadedFiles)
|
await this.cleanupTempFiles(result.downloadedFiles)
|
||||||
|
|
||||||
|
// Auto-import to database if merge was successful
|
||||||
|
if (result.mergedFile) {
|
||||||
|
input.onProgress?.('正在写入数据库...', 98)
|
||||||
|
const importResult = await this.importToDatabase(result.mergedFile)
|
||||||
|
result.importResult = importResult
|
||||||
|
|
||||||
|
if (!importResult.success && importResult.errors.length > 0) {
|
||||||
|
result.errors.push(...importResult.errors)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||||
@@ -91,153 +91,6 @@ export class ExtractorService {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigate to extractor/query page
|
|
||||||
* Reference: Python extract() method lines 266-278
|
|
||||||
*
|
|
||||||
* Python workflow:
|
|
||||||
* 1. main_frame.locator("i").first.click() - Click menu icon
|
|
||||||
* 2. page.expect_popup() + get_by_title("离散备料计划维护").click() - Click menu item and wait for popup
|
|
||||||
* 3. page1.locator("#forwardFrame").content_frame - Get popup's forward frame
|
|
||||||
* 4. f_frame.locator("#mainiframe").content_frame - Get nested inner frame
|
|
||||||
* 5. setup_query_interface(work_frame) - Setup query interface
|
|
||||||
*/
|
|
||||||
private async navigateToExtractorPage(
|
|
||||||
session: ErpSession
|
|
||||||
): Promise<{ popupPage: any; workFrame: any }> {
|
|
||||||
const { page, mainFrame } = session
|
|
||||||
|
|
||||||
// Step 1: Click menu icon (Python line 266)
|
|
||||||
// main_frame is #forwardFrame.content_frame returned from login
|
|
||||||
await mainFrame.locator('i').first().click()
|
|
||||||
|
|
||||||
// Step 2: Click discrete material plan menu item and expect popup (Python lines 267-271)
|
|
||||||
const popupPromise = page.waitForEvent('popup')
|
|
||||||
await mainFrame.getByTitle('离散备料计划维护', { exact: true }).first().click()
|
|
||||||
const popupPage = await popupPromise
|
|
||||||
|
|
||||||
// Step 3 & 4: Get nested frame structure in popup window (Python lines 273-276)
|
|
||||||
// popup page contains #forwardFrame, which contains #mainiframe
|
|
||||||
const forwardFrameLocator = popupPage.locator('#forwardFrame')
|
|
||||||
const fFrame = await forwardFrameLocator.contentFrame()
|
|
||||||
|
|
||||||
if (!fFrame) {
|
|
||||||
throw new Error('Failed to access popup forward frame')
|
|
||||||
}
|
|
||||||
|
|
||||||
const innerFrameLocator = fFrame.locator('#mainiframe')
|
|
||||||
await innerFrameLocator.waitFor({ state: 'visible', timeout: 15000 })
|
|
||||||
const workFrame = await innerFrameLocator.contentFrame()
|
|
||||||
|
|
||||||
if (!workFrame) {
|
|
||||||
throw new Error('Failed to access inner work frame')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 5: Setup query interface (Python line 278)
|
|
||||||
await this.setupQueryInterface(workFrame)
|
|
||||||
|
|
||||||
return { popupPage, workFrame }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup query interface
|
|
||||||
* Reference: Python setup_query_interface() method lines 231-239
|
|
||||||
*/
|
|
||||||
private async setupQueryInterface(innerFrame: any): Promise<void> {
|
|
||||||
// Click search icon (Python line 233)
|
|
||||||
await innerFrame.locator('.search-name-wrapper > .iconfont').click()
|
|
||||||
|
|
||||||
// Click "订单号查询" menu item (Python line 234)
|
|
||||||
await innerFrame.getByText('订单号查询').click()
|
|
||||||
|
|
||||||
// Click "全部" tab (Python line 235)
|
|
||||||
await innerFrame.getByRole('tab', { name: '全部' }).click()
|
|
||||||
|
|
||||||
// Set limit to 5000 (Python lines 237-239)
|
|
||||||
const inputBox = innerFrame.locator('#rc_select_0')
|
|
||||||
await inputBox.fill('5000')
|
|
||||||
await inputBox.press('Enter')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Download a single batch of orders
|
|
||||||
* Reference: Python download_batch() method lines 133-175
|
|
||||||
*/
|
|
||||||
private async downloadBatch(
|
|
||||||
session: ErpSession,
|
|
||||||
popupPage: any,
|
|
||||||
workFrame: any,
|
|
||||||
orderNumbers: string[],
|
|
||||||
batchIndex: number,
|
|
||||||
totalBatches: number
|
|
||||||
): Promise<string> {
|
|
||||||
// Fill order numbers (Python lines 143-145)
|
|
||||||
const textbox = workFrame.getByRole('textbox', { name: '来源生产订单号' })
|
|
||||||
await textbox.fill('')
|
|
||||||
await textbox.fill(orderNumbers.join(','))
|
|
||||||
|
|
||||||
// Click search button (Python line 147)
|
|
||||||
await workFrame.locator('.search-component-searchBtn').click()
|
|
||||||
|
|
||||||
// Wait for loading (Python lines 148-153)
|
|
||||||
await this.waitForLoading(workFrame)
|
|
||||||
|
|
||||||
// Click first row checkbox (Python line 155)
|
|
||||||
await workFrame.getByRole('row', { name: '序号' }).getByLabel('').click()
|
|
||||||
|
|
||||||
// Hover and click "更多" button (Python lines 156-157)
|
|
||||||
await workFrame.getByRole('button', { name: '更多' }).hover()
|
|
||||||
await workFrame.getByText('输出', { exact: true }).click()
|
|
||||||
|
|
||||||
// Set threshold (Python lines 159-164)
|
|
||||||
const thresholdBox = workFrame
|
|
||||||
.locator('div')
|
|
||||||
.filter({ hasText: /^行数阈值$/ })
|
|
||||||
.locator('input[type="text"]')
|
|
||||||
await thresholdBox.fill('300000')
|
|
||||||
|
|
||||||
// Setup download handler and click confirm (Python lines 166-172)
|
|
||||||
const downloadPath = path.join(this.downloadDir, `temp_batch_${batchIndex + 1}.xlsx`)
|
|
||||||
|
|
||||||
const downloadPromise = popupPage.waitForEvent('download')
|
|
||||||
await workFrame.getByRole('button', { name: '确定(Y)' }).click()
|
|
||||||
|
|
||||||
const download = await downloadPromise
|
|
||||||
await download.saveAs(downloadPath)
|
|
||||||
|
|
||||||
return downloadPath
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait for loading overlay to disappear
|
|
||||||
* Reference: Python lines 148-153
|
|
||||||
*/
|
|
||||||
private async waitForLoading(workFrame: any): Promise<void> {
|
|
||||||
const loadingLocator = workFrame
|
|
||||||
.locator('div')
|
|
||||||
.filter({ hasText: ERP_LOCATORS.extractor.loadingText })
|
|
||||||
.nth(1)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await loadingLocator.waitFor({ state: 'visible', timeout: 3000 })
|
|
||||||
await loadingLocator.waitFor({ state: 'hidden', timeout: 0 })
|
|
||||||
} catch {
|
|
||||||
// Loading completed quickly or never appeared
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Split array into batches
|
|
||||||
* Reference: Python group_order_ids() method lines 128-131
|
|
||||||
*/
|
|
||||||
private createBatches<T>(items: T[], batchSize: number): T[][] {
|
|
||||||
const batches: T[][] = []
|
|
||||||
for (let i = 0; i < items.length; i += batchSize) {
|
|
||||||
batches.push(items.slice(i, i + batchSize))
|
|
||||||
}
|
|
||||||
return batches
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merge downloaded Excel files into a single file
|
* Merge downloaded Excel files into a single file
|
||||||
* Uses ExcelParser to parse and combine all material plans
|
* Uses ExcelParser to parse and combine all material plans
|
||||||
@@ -340,7 +193,7 @@ export class ExtractorService {
|
|||||||
{ header: '产品编码', key: 'productCode', width: 15 },
|
{ header: '产品编码', key: 'productCode', width: 15 },
|
||||||
{ header: '产品名称', key: 'productName', width: 30 },
|
{ header: '产品名称', key: 'productName', width: 30 },
|
||||||
{ header: '产品计划数量', key: 'productPlannedQuantity', width: 15 },
|
{ header: '产品计划数量', key: 'productPlannedQuantity', width: 15 },
|
||||||
{ header: '单位', key: 'productUnit', width: 10 },
|
{ header: '产品单位', key: 'productUnit', width: 10 },
|
||||||
{ header: '用料部门', key: 'department', width: 15 },
|
{ header: '用料部门', key: 'department', width: 15 },
|
||||||
{ header: '备注', key: 'remark', width: 20 },
|
{ header: '备注', key: 'remark', width: 20 },
|
||||||
{ header: '制单人', key: 'creator', width: 15 },
|
{ header: '制单人', key: 'creator', width: 15 },
|
||||||
@@ -428,4 +281,40 @@ export class ExtractorService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import merged Excel data to database
|
||||||
|
* @param filePath - Path to the merged Excel file
|
||||||
|
* @returns Import result with statistics
|
||||||
|
*/
|
||||||
|
private async importToDatabase(filePath: string): Promise<ImportResult> {
|
||||||
|
console.log(`[Extractor] Starting database import from: ${filePath}`)
|
||||||
|
|
||||||
|
const importService = new DataImportService()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await importService.importFromExcel(filePath, 1000)
|
||||||
|
|
||||||
|
console.log(`[Extractor] Import completed`, {
|
||||||
|
success: result.success,
|
||||||
|
recordsRead: result.recordsRead,
|
||||||
|
recordsDeleted: result.recordsDeleted,
|
||||||
|
recordsImported: result.recordsImported
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||||
|
console.error(`[Extractor] Import failed: ${errorMsg}`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
recordsRead: 0,
|
||||||
|
recordsDeleted: 0,
|
||||||
|
recordsImported: 0,
|
||||||
|
uniqueSourceNumbers: 0,
|
||||||
|
errors: [errorMsg]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { IDatabaseService } from '../database'
|
import type { IDatabaseService } from '../database'
|
||||||
|
import { SqlServerService } from '../database/sql-server'
|
||||||
|
import { createLogger } from '../logger'
|
||||||
|
import sql from 'mssql'
|
||||||
|
|
||||||
|
const log = createLogger('OrderResolver')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Order mapping result
|
* Order mapping result
|
||||||
@@ -77,6 +82,24 @@ export class OrderNumberResolver {
|
|||||||
this.dbService = dbService
|
this.dbService = dbService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get table name based on database type
|
||||||
|
* Converts MySQL schema_tablename format to SQL Server [schema].[tablename] format
|
||||||
|
* e.g., productionContractData_26年压力表合同数据 -> [productionContractData].[26年压力表合同数据]
|
||||||
|
*/
|
||||||
|
private getTableName(mysqlTableName: string): string {
|
||||||
|
if (this.dbService.type === 'sqlserver') {
|
||||||
|
const firstUnderscoreIndex = mysqlTableName.indexOf('_')
|
||||||
|
if (firstUnderscoreIndex > 0) {
|
||||||
|
const schema = mysqlTableName.substring(0, firstUnderscoreIndex)
|
||||||
|
const tableName = mysqlTableName.substring(firstUnderscoreIndex + 1)
|
||||||
|
return `[${schema}].[${tableName}]`
|
||||||
|
}
|
||||||
|
return `[dbo].[${mysqlTableName}]`
|
||||||
|
}
|
||||||
|
return mysqlTableName
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recognize the type of an input string
|
* Recognize the type of an input string
|
||||||
* @param input - The input string to recognize
|
* @param input - The input string to recognize
|
||||||
@@ -217,35 +240,64 @@ export class OrderNumberResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build query - use different placeholder style based on database type
|
|
||||||
const isSqlServer = this.dbService.type === 'sqlserver'
|
const isSqlServer = this.dbService.type === 'sqlserver'
|
||||||
const placeholders = isSqlServer
|
const tableName = this.getTableName(DB_CONFIG.TABLE_NAME)
|
||||||
? productionIds.map((_, idx) => `@p${idx}`).join(', ')
|
|
||||||
: productionIds.map(() => '?').join(', ')
|
log.debug('Resolving production IDs', {
|
||||||
|
count: productionIds.length,
|
||||||
|
dbType: this.dbService.type
|
||||||
|
})
|
||||||
|
|
||||||
|
let result
|
||||||
|
|
||||||
|
if (isSqlServer) {
|
||||||
|
// Use queryWithParams for SQL Server with explicit parameter types
|
||||||
|
const placeholders = productionIds.map((_, idx) => `@p${idx}`).join(', ')
|
||||||
|
const params: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
value: string
|
||||||
|
type: sql.ISqlType | sql.ISqlTypeFactoryWithLength | sql.ISqlTypeWithLength
|
||||||
|
}
|
||||||
|
> = {}
|
||||||
|
|
||||||
|
productionIds.forEach((id, idx) => {
|
||||||
|
params[`p${idx}`] = { value: id, type: sql.NVarChar(255) }
|
||||||
|
})
|
||||||
|
|
||||||
// Use appropriate quoting for table/field names
|
|
||||||
const quote = isSqlServer ? '' : '`'
|
|
||||||
const query = `
|
const query = `
|
||||||
SELECT ${quote}${DB_CONFIG.FIELD_PRODUCTION_ID}${quote}, ${quote}${DB_CONFIG.FIELD_ORDER_NUMBER}${quote}
|
SELECT ${DB_CONFIG.FIELD_PRODUCTION_ID}, ${DB_CONFIG.FIELD_ORDER_NUMBER}
|
||||||
FROM ${quote}${DB_CONFIG.TABLE_NAME}${quote}
|
FROM ${tableName}
|
||||||
WHERE ${quote}${DB_CONFIG.FIELD_PRODUCTION_ID}${quote} IN (${placeholders})
|
WHERE ${DB_CONFIG.FIELD_PRODUCTION_ID} IN (${placeholders})
|
||||||
`
|
`
|
||||||
|
|
||||||
const result = await this.dbService.query(query, productionIds)
|
result = await (this.dbService as SqlServerService).queryWithParams(query, params)
|
||||||
|
} else {
|
||||||
|
// Use standard query for MySQL
|
||||||
|
const placeholders = productionIds.map(() => '?').join(', ')
|
||||||
|
const query = `
|
||||||
|
SELECT \`${DB_CONFIG.FIELD_PRODUCTION_ID}\`, \`${DB_CONFIG.FIELD_ORDER_NUMBER}\`
|
||||||
|
FROM ${tableName}
|
||||||
|
WHERE \`${DB_CONFIG.FIELD_PRODUCTION_ID}\` IN (${placeholders})
|
||||||
|
`
|
||||||
|
result = await this.dbService.query(query, productionIds)
|
||||||
|
}
|
||||||
|
|
||||||
// Create a map for quick lookup
|
// Create a map for quick lookup (use lowercase key for case-insensitive matching)
|
||||||
const resultMap = new Map<string, string>()
|
const resultMap = new Map<string, string>()
|
||||||
for (const row of result.rows) {
|
for (const row of result.rows) {
|
||||||
const prodId = row[DB_CONFIG.FIELD_PRODUCTION_ID] as string
|
const prodId = row[DB_CONFIG.FIELD_PRODUCTION_ID] as string
|
||||||
const orderNum = row[DB_CONFIG.FIELD_ORDER_NUMBER] as string
|
const orderNum = row[DB_CONFIG.FIELD_ORDER_NUMBER] as string
|
||||||
if (prodId && orderNum) {
|
if (prodId && orderNum) {
|
||||||
resultMap.set(prodId, orderNum)
|
// Store with lowercase key for case-insensitive matching
|
||||||
|
resultMap.set(prodId.toLowerCase(), orderNum)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build mappings
|
// Build mappings
|
||||||
for (const pid of productionIds) {
|
for (const pid of productionIds) {
|
||||||
const orderNumber = resultMap.get(pid)
|
// Use lowercase for case-insensitive lookup
|
||||||
|
const orderNumber = resultMap.get(pid.toLowerCase())
|
||||||
|
|
||||||
if (orderNumber) {
|
if (orderNumber) {
|
||||||
mappings.push({
|
mappings.push({
|
||||||
@@ -292,21 +344,44 @@ export class OrderNumberResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build query - use different placeholder style based on database type
|
|
||||||
const isSqlServer = this.dbService.type === 'sqlserver'
|
const isSqlServer = this.dbService.type === 'sqlserver'
|
||||||
const placeholders = isSqlServer
|
const tableName = this.getTableName(DB_CONFIG.TABLE_NAME)
|
||||||
? orderNumbers.map((_, idx) => `@p${idx}`).join(', ')
|
|
||||||
: orderNumbers.map(() => '?').join(', ')
|
let result
|
||||||
|
|
||||||
|
if (isSqlServer) {
|
||||||
|
// Use queryWithParams for SQL Server with explicit parameter types
|
||||||
|
const placeholders = orderNumbers.map((_, idx) => `@p${idx}`).join(', ')
|
||||||
|
const params: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
value: string
|
||||||
|
type: sql.ISqlType | sql.ISqlTypeFactoryWithLength | sql.ISqlTypeWithLength
|
||||||
|
}
|
||||||
|
> = {}
|
||||||
|
|
||||||
|
orderNumbers.forEach((id, idx) => {
|
||||||
|
params[`p${idx}`] = { value: id, type: sql.NVarChar(255) }
|
||||||
|
})
|
||||||
|
|
||||||
// Use appropriate quoting for table/field names
|
|
||||||
const quote = isSqlServer ? '' : '`'
|
|
||||||
const query = `
|
const query = `
|
||||||
SELECT ${quote}${DB_CONFIG.FIELD_ORDER_NUMBER}${quote}
|
SELECT ${DB_CONFIG.FIELD_ORDER_NUMBER}
|
||||||
FROM ${quote}${DB_CONFIG.TABLE_NAME}${quote}
|
FROM ${tableName}
|
||||||
WHERE ${quote}${DB_CONFIG.FIELD_ORDER_NUMBER}${quote} IN (${placeholders})
|
WHERE ${DB_CONFIG.FIELD_ORDER_NUMBER} IN (${placeholders})
|
||||||
`
|
`
|
||||||
|
|
||||||
const result = await this.dbService.query(query, orderNumbers)
|
result = await (this.dbService as SqlServerService).queryWithParams(query, params)
|
||||||
|
} else {
|
||||||
|
// Use standard query for MySQL
|
||||||
|
const placeholders = orderNumbers.map(() => '?').join(', ')
|
||||||
|
const query = `
|
||||||
|
SELECT \`${DB_CONFIG.FIELD_ORDER_NUMBER}\`
|
||||||
|
FROM ${tableName}
|
||||||
|
WHERE \`${DB_CONFIG.FIELD_ORDER_NUMBER}\` IN (${placeholders})
|
||||||
|
`
|
||||||
|
result = await this.dbService.query(query, orderNumbers)
|
||||||
|
}
|
||||||
|
|
||||||
return result.rows.map((row) => row[DB_CONFIG.FIELD_ORDER_NUMBER] as string)
|
return result.rows.map((row) => row[DB_CONFIG.FIELD_ORDER_NUMBER] as string)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[OrderResolver] Failed to verify order numbers:', error)
|
console.warn('[OrderResolver] Failed to verify order numbers:', error)
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ export class ExcelParser {
|
|||||||
{ header: '产品编码', key: 'productCode', width: 15 },
|
{ header: '产品编码', key: 'productCode', width: 15 },
|
||||||
{ header: '产品名称', key: 'productName', width: 30 },
|
{ header: '产品名称', key: 'productName', width: 30 },
|
||||||
{ header: '产品计划数量', key: 'productPlannedQuantity', width: 15 },
|
{ header: '产品计划数量', key: 'productPlannedQuantity', width: 15 },
|
||||||
{ header: '单位', key: 'productUnit', width: 10 },
|
{ header: '产品单位', key: 'productUnit', width: 10 },
|
||||||
{ header: '用料部门', key: 'department', width: 15 },
|
{ header: '用料部门', key: 'department', width: 15 },
|
||||||
{ header: '备注', key: 'remark', width: 20 },
|
{ header: '备注', key: 'remark', width: 20 },
|
||||||
{ header: '制单人', key: 'creator', width: 15 },
|
{ header: '制单人', key: 'creator', width: 15 },
|
||||||
|
|||||||
@@ -121,8 +121,8 @@ export class BIPUsersDAO {
|
|||||||
`
|
`
|
||||||
|
|
||||||
const result = await (dbService as SqlServerService).queryWithParams(sqlString, {
|
const result = await (dbService as SqlServerService).queryWithParams(sqlString, {
|
||||||
username: { value: username, type: sql.NVarChar },
|
username: { value: username, type: sql.NVarChar(255) },
|
||||||
password: { value: password, type: sql.NVarChar }
|
password: { value: password, type: sql.NVarChar(255) }
|
||||||
})
|
})
|
||||||
|
|
||||||
if (result.rows.length > 0) {
|
if (result.rows.length > 0) {
|
||||||
@@ -177,7 +177,7 @@ export class BIPUsersDAO {
|
|||||||
`
|
`
|
||||||
|
|
||||||
const result = await (dbService as SqlServerService).queryWithParams(sqlString, {
|
const result = await (dbService as SqlServerService).queryWithParams(sqlString, {
|
||||||
computerName: { value: computerName, type: sql.NVarChar }
|
computerName: { value: computerName, type: sql.NVarChar(255) }
|
||||||
})
|
})
|
||||||
|
|
||||||
if (result.rows.length > 0) {
|
if (result.rows.length > 0) {
|
||||||
@@ -265,49 +265,55 @@ export class BIPUsersDAO {
|
|||||||
const tableName = this.getTableName()
|
const tableName = this.getTableName()
|
||||||
|
|
||||||
if (this.dbType === 'sqlserver') {
|
if (this.dbType === 'sqlserver') {
|
||||||
let sql: string
|
let sqlString: string
|
||||||
let params: Record<string, { value: unknown; type?: sql.ISqlType }>
|
let params: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
value: unknown
|
||||||
|
type?: sql.ISqlType | sql.ISqlTypeFactoryWithLength | sql.ISqlTypeWithLength
|
||||||
|
}
|
||||||
|
>
|
||||||
|
|
||||||
if (computerName) {
|
if (computerName) {
|
||||||
sql = `
|
sqlString = `
|
||||||
INSERT INTO ${tableName}
|
INSERT INTO ${tableName}
|
||||||
(UserName, Password, UserType, ComputerNmae)
|
(UserName, Password, UserType, ComputerNmae)
|
||||||
VALUES (@username, @password, @userType, @computerName)
|
VALUES (@username, @password, @userType, @computerName)
|
||||||
`
|
`
|
||||||
params = {
|
params = {
|
||||||
username: { value: username, type: sql.NVarChar },
|
username: { value: username, type: sql.NVarChar(255) },
|
||||||
password: { value: password, type: sql.NVarChar },
|
password: { value: password, type: sql.NVarChar(255) },
|
||||||
userType: { value: userType, type: sql.NVarChar },
|
userType: { value: userType, type: sql.NVarChar(255) },
|
||||||
computerName: { value: computerName, type: sql.NVarChar }
|
computerName: { value: computerName, type: sql.NVarChar(255) }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
sql = `
|
sqlString = `
|
||||||
INSERT INTO ${tableName}
|
INSERT INTO ${tableName}
|
||||||
(UserName, Password, UserType)
|
(UserName, Password, UserType)
|
||||||
VALUES (@username, @password, @userType)
|
VALUES (@username, @password, @userType)
|
||||||
`
|
`
|
||||||
params = {
|
params = {
|
||||||
username: { value: username, type: sql.NVarChar },
|
username: { value: username, type: sql.NVarChar(255) },
|
||||||
password: { value: password, type: sql.NVarChar },
|
password: { value: password, type: sql.NVarChar(255) },
|
||||||
userType: { value: userType, type: sql.NVarChar }
|
userType: { value: userType, type: sql.NVarChar(255) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await (dbService as SqlServerService).queryWithParams(sqlString, params)
|
await (dbService as SqlServerService).queryWithParams(sqlString, params)
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
let sql: string
|
let sqlString: string
|
||||||
let params: any[]
|
let params: unknown[]
|
||||||
|
|
||||||
if (computerName) {
|
if (computerName) {
|
||||||
sql = `
|
sqlString = `
|
||||||
INSERT INTO ${tableName}
|
INSERT INTO ${tableName}
|
||||||
(UserName, Password, UserType, ComputerNmae)
|
(UserName, Password, UserType, ComputerNmae)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
`
|
`
|
||||||
params = [username, password, userType, computerName]
|
params = [username, password, userType, computerName]
|
||||||
} else {
|
} else {
|
||||||
sql = `
|
sqlString = `
|
||||||
INSERT INTO ${tableName}
|
INSERT INTO ${tableName}
|
||||||
(UserName, Password, UserType)
|
(UserName, Password, UserType)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
@@ -343,8 +349,8 @@ export class BIPUsersDAO {
|
|||||||
`
|
`
|
||||||
|
|
||||||
await (dbService as SqlServerService).queryWithParams(sqlString, {
|
await (dbService as SqlServerService).queryWithParams(sqlString, {
|
||||||
username: { value: username, type: sql.NVarChar },
|
username: { value: username, type: sql.NVarChar(255) },
|
||||||
userType: { value: userType, type: sql.NVarChar }
|
userType: { value: userType, type: sql.NVarChar(255) }
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
@@ -382,8 +388,8 @@ export class BIPUsersDAO {
|
|||||||
`
|
`
|
||||||
|
|
||||||
await (dbService as SqlServerService).queryWithParams(sqlString, {
|
await (dbService as SqlServerService).queryWithParams(sqlString, {
|
||||||
username: { value: username, type: sql.NVarChar },
|
username: { value: username, type: sql.NVarChar(255) },
|
||||||
newPassword: { value: newPassword, type: sql.NVarChar }
|
newPassword: { value: newPassword, type: sql.NVarChar(255) }
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
@@ -419,7 +425,7 @@ export class BIPUsersDAO {
|
|||||||
`
|
`
|
||||||
|
|
||||||
await (dbService as SqlServerService).queryWithParams(sqlString, {
|
await (dbService as SqlServerService).queryWithParams(sqlString, {
|
||||||
username: { value: username, type: sql.NVarChar }
|
username: { value: username, type: sql.NVarChar(255) }
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
@@ -455,7 +461,7 @@ export class BIPUsersDAO {
|
|||||||
`
|
`
|
||||||
|
|
||||||
const result = await (dbService as SqlServerService).queryWithParams(sqlString, {
|
const result = await (dbService as SqlServerService).queryWithParams(sqlString, {
|
||||||
username: { value: username, type: sql.NVarChar }
|
username: { value: username, type: sql.NVarChar(255) }
|
||||||
})
|
})
|
||||||
return result.rows.length > 0 && (result.rows[0].count as number) > 0
|
return result.rows.length > 0 && (result.rows[0].count as number) > 0
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,17 +1,52 @@
|
|||||||
|
import type { ErpSession } from './erp.types'
|
||||||
|
|
||||||
export interface ExtractorInput {
|
export interface ExtractorInput {
|
||||||
orderNumbers: string[]
|
orderNumbers: string[]
|
||||||
batchSize?: number
|
batchSize?: number
|
||||||
onProgress?: (message: string, progress: number) => void
|
onProgress?: (message: string, progress: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of database import operation
|
||||||
|
*/
|
||||||
|
export interface ImportResult {
|
||||||
|
success: boolean
|
||||||
|
recordsRead: number
|
||||||
|
recordsDeleted: number
|
||||||
|
recordsImported: number
|
||||||
|
uniqueSourceNumbers: number
|
||||||
|
errors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface ExtractorResult {
|
export interface ExtractorResult {
|
||||||
downloadedFiles: string[]
|
downloadedFiles: string[]
|
||||||
mergedFile: string | null
|
mergedFile: string | null
|
||||||
recordCount: number
|
recordCount: number
|
||||||
errors: string[]
|
errors: string[]
|
||||||
|
/** Database import result (only populated if mergedFile was created) */
|
||||||
|
importResult?: ImportResult
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OrderInfo {
|
export interface OrderInfo {
|
||||||
orderNumber: string
|
orderNumber: string
|
||||||
productionId: string
|
productionId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input for ExtractorCore - handles web page operations
|
||||||
|
*/
|
||||||
|
export interface ExtractorCoreInput {
|
||||||
|
session: ErpSession
|
||||||
|
orderNumbers: string[]
|
||||||
|
downloadDir: string
|
||||||
|
batchSize: number
|
||||||
|
onProgress?: (message: string, progress: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result from ExtractorCore - list of downloaded file paths
|
||||||
|
*/
|
||||||
|
export interface ExtractorCoreResult {
|
||||||
|
downloadedFiles: string[]
|
||||||
|
errors: string[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,7 +53,11 @@ export function useAuth(): UseAuthReturn {
|
|||||||
setState((prev) => ({ ...prev, loading: true, error: null }))
|
setState((prev) => ({ ...prev, loading: true, error: null }))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await window.electron.ipcRenderer.invoke('auth:login', credentials)
|
const result = (await window.electron.ipcRenderer.invoke('auth:login', credentials)) as {
|
||||||
|
success: boolean
|
||||||
|
userInfo?: UserInfo
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
if (result.success && result.userInfo) {
|
if (result.success && result.userInfo) {
|
||||||
setState({
|
setState({
|
||||||
@@ -85,7 +89,12 @@ export function useAuth(): UseAuthReturn {
|
|||||||
setState((prev) => ({ ...prev, loading: true, error: null }))
|
setState((prev) => ({ ...prev, loading: true, error: null }))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await window.electron.ipcRenderer.invoke('auth:silentLogin')
|
const result = (await window.electron.ipcRenderer.invoke('auth:silentLogin')) as {
|
||||||
|
success: boolean
|
||||||
|
userInfo?: UserInfo
|
||||||
|
error?: string
|
||||||
|
requiresUserSelection?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
if (result.success && result.userInfo) {
|
if (result.success && result.userInfo) {
|
||||||
setState({
|
setState({
|
||||||
@@ -126,14 +135,18 @@ export function useAuth(): UseAuthReturn {
|
|||||||
|
|
||||||
const getCurrentUser = useCallback(async (): Promise<UserInfo | null> => {
|
const getCurrentUser = useCallback(async (): Promise<UserInfo | null> => {
|
||||||
try {
|
try {
|
||||||
const result = await window.electron.ipcRenderer.invoke('auth:getCurrentUser')
|
const result = (await window.electron.ipcRenderer.invoke('auth:getCurrentUser')) as {
|
||||||
|
isAuthenticated: boolean
|
||||||
|
userInfo?: UserInfo
|
||||||
|
}
|
||||||
if (result.isAuthenticated && result.userInfo) {
|
if (result.isAuthenticated && result.userInfo) {
|
||||||
|
const userInfo = result.userInfo
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
user: result.userInfo,
|
user: userInfo,
|
||||||
isAuthenticated: true
|
isAuthenticated: true
|
||||||
}))
|
}))
|
||||||
return result.userInfo
|
return userInfo
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
} catch {
|
} catch {
|
||||||
@@ -143,7 +156,7 @@ export function useAuth(): UseAuthReturn {
|
|||||||
|
|
||||||
const getAllUsers = useCallback(async (): Promise<UserInfo[]> => {
|
const getAllUsers = useCallback(async (): Promise<UserInfo[]> => {
|
||||||
try {
|
try {
|
||||||
return await window.electron.ipcRenderer.invoke('auth:getAllUsers')
|
return (await window.electron.ipcRenderer.invoke('auth:getAllUsers')) as UserInfo[]
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -153,7 +166,11 @@ export function useAuth(): UseAuthReturn {
|
|||||||
setState((prev) => ({ ...prev, loading: true, error: null }))
|
setState((prev) => ({ ...prev, loading: true, error: null }))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await window.electron.ipcRenderer.invoke('auth:switchUser', userInfo)
|
const result = (await window.electron.ipcRenderer.invoke('auth:switchUser', userInfo)) as {
|
||||||
|
success: boolean
|
||||||
|
userInfo?: UserInfo
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
if (result.success && result.userInfo) {
|
if (result.success && result.userInfo) {
|
||||||
setState({
|
setState({
|
||||||
@@ -180,7 +197,7 @@ export function useAuth(): UseAuthReturn {
|
|||||||
|
|
||||||
const isAdmin = useCallback(async (): Promise<boolean> => {
|
const isAdmin = useCallback(async (): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
return await window.electron.ipcRenderer.invoke('auth:isAdmin')
|
return (await window.electron.ipcRenderer.invoke('auth:isAdmin')) as boolean
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,9 +44,13 @@ export function useCleaner(): UseCleanerReturn {
|
|||||||
setState({ loading: true, data: null, error: null })
|
setState({ loading: true, data: null, error: null })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await window.electron.ipcRenderer.invoke('cleaner:run', input)
|
const result = (await window.electron.ipcRenderer.invoke('cleaner:run', input)) as {
|
||||||
|
success: boolean
|
||||||
|
data?: CleanerResult
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success && result.data) {
|
||||||
setState({ loading: false, data: result.data, error: null })
|
setState({ loading: false, data: result.data, error: null })
|
||||||
return result.data
|
return result.data
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -43,9 +43,13 @@ export function useExtractor(): UseExtractorReturn {
|
|||||||
setState({ loading: true, data: null, error: null })
|
setState({ loading: true, data: null, error: null })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await window.electron.ipcRenderer.invoke('extractor:run', input)
|
const result = (await window.electron.ipcRenderer.invoke('extractor:run', input)) as {
|
||||||
|
success: boolean
|
||||||
|
data?: ExtractorResult
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success && result.data) {
|
||||||
setState({ loading: false, data: result.data, error: null })
|
setState({ loading: false, data: result.data, error: null })
|
||||||
return result.data
|
return result.data
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -68,13 +68,18 @@ export function useValidation(): UseValidationReturn {
|
|||||||
setState((prev) => ({ ...prev, loading: true, error: null }))
|
setState((prev) => ({ ...prev, loading: true, error: null }))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await window.electron.ipcRenderer.invoke('validation:validate', request)
|
const result = (await window.electron.ipcRenderer.invoke(
|
||||||
|
'validation:validate',
|
||||||
|
request
|
||||||
|
)) as ValidationResponse
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
const results = result.results || null
|
||||||
|
const stats = result.stats || null
|
||||||
setState({
|
setState({
|
||||||
loading: false,
|
loading: false,
|
||||||
data: result.results || null,
|
data: results,
|
||||||
stats: result.stats || null,
|
stats: stats,
|
||||||
error: null
|
error: null
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
@@ -105,7 +110,9 @@ export function useValidation(): UseValidationReturn {
|
|||||||
|
|
||||||
const getSharedProductionIds = useCallback(async (): Promise<string[]> => {
|
const getSharedProductionIds = useCallback(async (): Promise<string[]> => {
|
||||||
try {
|
try {
|
||||||
const result = await window.electron.ipcRenderer.invoke('validation:getSharedProductionIds')
|
const result = (await window.electron.ipcRenderer.invoke(
|
||||||
|
'validation:getSharedProductionIds'
|
||||||
|
)) as { productionIds?: string[] }
|
||||||
return result?.productionIds || []
|
return result?.productionIds || []
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
@@ -117,7 +124,11 @@ export function useValidation(): UseValidationReturn {
|
|||||||
materialCodes: string[]
|
materialCodes: string[]
|
||||||
} | null> => {
|
} | null> => {
|
||||||
try {
|
try {
|
||||||
const result = await window.electron.ipcRenderer.invoke('validation:getCleanerData')
|
const result = (await window.electron.ipcRenderer.invoke('validation:getCleanerData')) as {
|
||||||
|
success: boolean
|
||||||
|
orderNumbers?: string[]
|
||||||
|
materialCodes?: string[]
|
||||||
|
}
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
return {
|
return {
|
||||||
orderNumbers: result.orderNumbers || [],
|
orderNumbers: result.orderNumbers || [],
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { Download, Play, Terminal } from 'lucide-react'
|
import { Download, Play, Terminal, Database } from 'lucide-react'
|
||||||
|
|
||||||
|
// Import result type (matches the type from main process)
|
||||||
|
interface ImportResult {
|
||||||
|
success: boolean
|
||||||
|
recordsRead: number
|
||||||
|
recordsDeleted: number
|
||||||
|
recordsImported: number
|
||||||
|
uniqueSourceNumbers: number
|
||||||
|
errors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
// Extractor result type (matches the type from main process)
|
// Extractor result type (matches the type from main process)
|
||||||
interface ExtractorResult {
|
interface ExtractorResult {
|
||||||
@@ -7,6 +17,7 @@ interface ExtractorResult {
|
|||||||
mergedFile: string | null
|
mergedFile: string | null
|
||||||
recordCount: number
|
recordCount: number
|
||||||
errors: string[]
|
errors: string[]
|
||||||
|
importResult?: ImportResult
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExtractorProgress {
|
interface ExtractorProgress {
|
||||||
@@ -212,6 +223,54 @@ const ExtractorPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Database import results */}
|
||||||
|
{result?.importResult && (
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-6 flex flex-col gap-4">
|
||||||
|
<h3 className="font-semibold text-lg border-b pb-2 flex items-center gap-2">
|
||||||
|
<Database size={20} className={result.importResult.success ? 'text-emerald-600' : 'text-red-500'} />
|
||||||
|
<span className={result.importResult.success ? 'text-emerald-600' : 'text-red-500'}>
|
||||||
|
数据库写入结果
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<div className="bg-slate-50 p-4 rounded-lg border border-slate-100 flex flex-col items-center justify-center">
|
||||||
|
<span className="text-slate-500 text-sm">读取记录</span>
|
||||||
|
<span className="text-2xl font-bold text-slate-800">
|
||||||
|
{result.importResult.recordsRead}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-50 p-4 rounded-lg border border-slate-100 flex flex-col items-center justify-center">
|
||||||
|
<span className="text-slate-500 text-sm">删除旧记录</span>
|
||||||
|
<span className="text-2xl font-bold text-amber-600">
|
||||||
|
{result.importResult.recordsDeleted}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-50 p-4 rounded-lg border border-slate-100 flex flex-col items-center justify-center">
|
||||||
|
<span className="text-slate-500 text-sm">写入新记录</span>
|
||||||
|
<span className="text-2xl font-bold text-emerald-600">
|
||||||
|
{result.importResult.recordsImported}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-50 p-4 rounded-lg border border-slate-100 flex flex-col items-center justify-center">
|
||||||
|
<span className="text-slate-500 text-sm">来源单号数</span>
|
||||||
|
<span className="text-2xl font-bold text-blue-600">
|
||||||
|
{result.importResult.uniqueSourceNumbers}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{result.importResult.errors.length > 0 && (
|
||||||
|
<div className="bg-red-50 p-4 rounded-lg border border-red-200">
|
||||||
|
<span className="text-red-600 text-sm font-medium block mb-1">错误信息</span>
|
||||||
|
<ul className="text-sm text-red-500 list-disc list-inside">
|
||||||
|
{result.importResult.errors.map((err, idx) => (
|
||||||
|
<li key={idx}>{err}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="bg-slate-900 rounded-xl shadow-lg border border-slate-700 overflow-hidden flex flex-col h-[500px]">
|
<div className="bg-slate-900 rounded-xl shadow-lg border border-slate-700 overflow-hidden flex flex-col h-[500px]">
|
||||||
<div className="bg-slate-800 px-4 py-2 flex items-center justify-between border-b border-slate-700">
|
<div className="bg-slate-800 px-4 py-2 flex items-center justify-between border-b border-slate-700">
|
||||||
<div className="flex items-center gap-2 text-slate-400 text-sm">
|
<div className="flex items-center gap-2 text-slate-400 text-sm">
|
||||||
|
|||||||
119
tests/unit/data-importer.test.ts
Normal file
119
tests/unit/data-importer.test.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for DataImportService
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { DataImportService } from '../../../src/main/services/database/data-importer'
|
||||||
|
|
||||||
|
// Mock the logger
|
||||||
|
vi.mock('../../../src/main/services/logger', () => ({
|
||||||
|
createLogger: () => ({
|
||||||
|
info: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn()
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock the database factory
|
||||||
|
vi.mock('../../../src/main/services/database', () => ({
|
||||||
|
create: vi.fn().mockResolvedValue({
|
||||||
|
type: 'mysql',
|
||||||
|
isConnected: () => true,
|
||||||
|
query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }),
|
||||||
|
disconnect: vi.fn().mockResolvedValue(undefined)
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Create mock worksheet
|
||||||
|
const mockWorksheet = {
|
||||||
|
getRow: vi.fn().mockReturnValue({
|
||||||
|
eachCell: vi.fn((callback) => {
|
||||||
|
callback({ text: '工厂' }, 1)
|
||||||
|
callback({ text: '来源单号' }, 2)
|
||||||
|
callback({ text: '备料计划单号' }, 3)
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
eachRow: vi.fn((callback) => {
|
||||||
|
// Skip header row (rowNumber 1)
|
||||||
|
// Add data rows
|
||||||
|
callback(
|
||||||
|
{
|
||||||
|
eachCell: vi.fn((cellCallback) => {
|
||||||
|
cellCallback({ text: '工厂A', value: '工厂A' }, 1)
|
||||||
|
cellCallback({ text: 'PO-001', value: 'PO-001' }, 2)
|
||||||
|
cellCallback({ text: 'PLAN-001', value: 'PLAN-001' }, 3)
|
||||||
|
}),
|
||||||
|
text: '工厂A,PO-001,PLAN-001'
|
||||||
|
},
|
||||||
|
2
|
||||||
|
)
|
||||||
|
callback(
|
||||||
|
{
|
||||||
|
eachCell: vi.fn((cellCallback) => {
|
||||||
|
cellCallback({ text: '工厂A', value: '工厂A' }, 1)
|
||||||
|
cellCallback({ text: 'PO-002', value: 'PO-002' }, 2)
|
||||||
|
cellCallback({ text: 'PLAN-002', value: 'PLAN-002' }, 3)
|
||||||
|
}),
|
||||||
|
text: '工厂A,PO-002,PLAN-002'
|
||||||
|
},
|
||||||
|
3
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock ExcelJS with a proper class constructor
|
||||||
|
class MockWorkbook {
|
||||||
|
worksheets = [mockWorksheet]
|
||||||
|
xlsx = {
|
||||||
|
readFile: vi.fn().mockResolvedValue(undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('exceljs', () => {
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
Workbook: MockWorkbook
|
||||||
|
},
|
||||||
|
Workbook: MockWorkbook
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('DataImportService', () => {
|
||||||
|
let service: DataImportService
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new DataImportService()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('importFromExcel', () => {
|
||||||
|
it('should return result with expected structure', async () => {
|
||||||
|
const result = await service.importFromExcel('/path/to/test.xlsx', 1000)
|
||||||
|
|
||||||
|
// Check that we got a result with expected structure
|
||||||
|
expect(result).toHaveProperty('success')
|
||||||
|
expect(result).toHaveProperty('recordsRead')
|
||||||
|
expect(result).toHaveProperty('recordsDeleted')
|
||||||
|
expect(result).toHaveProperty('recordsImported')
|
||||||
|
expect(result).toHaveProperty('uniqueSourceNumbers')
|
||||||
|
expect(result).toHaveProperty('errors')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return records read count', async () => {
|
||||||
|
const result = await service.importFromExcel('/path/to/test.xlsx', 1000)
|
||||||
|
|
||||||
|
expect(result.recordsRead).toBeGreaterThanOrEqual(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return unique source numbers count', async () => {
|
||||||
|
const result = await service.importFromExcel('/path/to/test.xlsx', 1000)
|
||||||
|
|
||||||
|
expect(result.uniqueSourceNumbers).toBeGreaterThanOrEqual(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -15,6 +15,8 @@
|
|||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"noImplicitReturns": true,
|
"noImplicitReturns": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"esModuleInterop": true
|
"esModuleInterop": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user