5 Commits

Author SHA1 Message Date
Misaka_Company
88c8c256e2 fix: resolve TypeScript type errors across codebase
- Add experimentalDecorators support in tsconfig.node.json for TypeORM entities
- Fix mssql module import in order-resolver.ts (static vs dynamic import)
- Extend ISqlType parameter types in sql-server.ts for NVarChar compatibility
- Fix variable naming and type assertions in bip-users-dao.ts
- Add proper type assertions for IPC call results in renderer hooks
  (useAuth, useCleaner, useExtractor, useValidation)
- Add definite assignment assertions in config-manager.ts
2026-03-04 14:03:23 +08:00
Misaka_Company
5497e86b58 fix: adjust batch size for SQL Server parameter limit (2100 max params)
- SQL Server has a maximum of 2100 parameters per query
- Each record has 28 columns, so max batch is ~71 records (2000/28)
- Fixed syntax error: removed extra closing brace in extractor.ts
- Added debug logging for batch insert parameters
2026-03-04 13:24:50 +08:00
Misaka_Company
63ea81e0d6 feat: add automatic database import after ERP data extraction
- Add DataImportService for reading Excel and importing to database
- Extend DiscreteMaterialPlanDAO with deleteBySourceNumbers and batchInsert
- Auto-trigger database write after successful Excel merge
- Support batch delete by SourceNumber and batch insert (1000/batch)
- Update ExtractorPage UI to show import results
- Fix SQL Server query to handle undefined recordset for DELETE/INSERT

Co-Authored-By: Claude (glm-5) <noreply@anthropic.com>
2026-03-04 12:50:27 +08:00
Misaka_Company
abe51d17fa fix: resolve production ID case-sensitivity and SQL Server compatibility issues
- Add getTableName() method to convert MySQL table names to SQL Server format
- Use queryWithParams with sql.NVarChar for proper SQL Server parameter handling
- Implement case-insensitive matching for production IDs (e.g., 26b10433 vs 26B10433)
- Align resolver logic with validation-handler.ts for consistent database queries

Co-Authored-By: Claude (glm-5) <noreply@anthropic.com>
2026-03-04 11:27:37 +08:00
Misaka_Company
240e3838ba fix: correct Excel header for product unit from "单位" to "产品单位"
The product unit column header was incorrectly showing "单位" instead of
"产品单位", causing confusion with the material unit column which also
uses "单位". Fixed in both extractor.ts and excel-parser.ts.

Co-Authored-By: Claude (glm-5) <noreply@anthropic.com>
2026-03-04 09:52:08 +08:00
18 changed files with 1369 additions and 256 deletions

119
IMPLEMENTATION_PLAN.md Normal file
View 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

View File

@@ -147,8 +147,8 @@ function validateEditableFields(settings: Partial<SettingsData>): {
*/
export class ConfigManager {
private static instance: ConfigManager | null = null
private envPath: string
private backupPath: string
private envPath!: string
private backupPath!: string
private configCache: Map<string, string> = new Map()
private initialized: boolean = false
@@ -214,6 +214,8 @@ export class ConfigManager {
* @param key - Configuration key
* @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 {
return this.configCache.get(key) ?? defaultValue
}

View 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()
}

View File

@@ -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 ====================
/**

View File

@@ -92,8 +92,8 @@ export class SqlServerService implements IDatabaseService {
const result = await request.query(sqlString)
// Convert recordset to array of objects
const rows = result.recordset as Record<string, unknown>[]
// Convert recordset to array of objects (may be undefined for DELETE/INSERT/UPDATE)
const rows = (result.recordset as Record<string, unknown>[]) || []
// Extract column names from the first row if available
const columns = rows.length > 0 ? Object.keys(rows[0]) : []
@@ -114,7 +114,13 @@ export class SqlServerService implements IDatabaseService {
*/
async queryWithParams(
sqlString: string,
params: Record<string, { value: unknown; type?: sql.ISqlType }>
params: Record<
string,
{
value: unknown
type?: sql.ISqlType | sql.ISqlTypeFactoryWithLength | sql.ISqlTypeWithLength
}
>
): Promise<QueryResult> {
if (!this.pool) {
throw new Error('Not connected to SQL Server. Call connect() first.')

View 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
}
}

View File

@@ -1,16 +1,19 @@
import path from 'path'
import fs from 'fs/promises'
import { ERP_LOCATORS } from './locators'
import { ExtractorCore } from './extractor-core'
import { ErpAuthService } from './erp-auth'
import { ExcelParser } from '../excel/excel-parser'
import type { ExtractorInput, ExtractorResult } from '../../types/extractor.types'
import type { ErpSession } from '../../types/erp.types'
import type { DiscreteMaterialPlan } from '../../types/excel.types'
import type { ExtractorInput, ExtractorResult, ImportResult } from '../../types/extractor.types'
import { DataImportService } from '../database/data-importer'
/**
* ERP Data Extractor Service
* 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
*/
export class ExtractorService {
@@ -27,6 +30,8 @@ export class ExtractorService {
/**
* 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> {
const result: ExtractorResult = {
@@ -39,36 +44,20 @@ export class ExtractorService {
try {
const session = this.authService.getSession()
// Navigate to extractor page and get popup page + work frame
const { popupPage, workFrame } = await this.navigateToExtractorPage(session)
// Call ExtractorCore to execute web page operations
const core = new ExtractorCore()
const coreResult = await core.downloadAllBatches({
session,
orderNumbers: input.orderNumbers,
downloadDir: this.downloadDir,
batchSize: input.batchSize || 100,
onProgress: input.onProgress
})
// Process orders in batches
const batchSize = input.batchSize || 100
const batches = this.createBatches(input.orderNumbers, batchSize)
result.downloadedFiles = coreResult.downloadedFiles
result.errors = coreResult.errors
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,
popupPage,
workFrame,
batch,
i,
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
// Merge downloaded files (original logic preserved)
if (result.downloadedFiles.length > 0) {
input.onProgress?.('正在合并文件...', 95)
const mergeResult = await this.mergeFiles(result.downloadedFiles)
@@ -82,6 +71,17 @@ export class ExtractorService {
// Always clean up temporary files regardless of merge success
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) {
const message = error instanceof Error ? error.message : 'Unknown error'
@@ -91,153 +91,6 @@ export class ExtractorService {
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
* Uses ExcelParser to parse and combine all material plans
@@ -340,7 +193,7 @@ export class ExtractorService {
{ header: '产品编码', key: 'productCode', width: 15 },
{ header: '产品名称', key: 'productName', width: 30 },
{ header: '产品计划数量', key: 'productPlannedQuantity', width: 15 },
{ header: '单位', key: 'productUnit', width: 10 },
{ header: '产品单位', key: 'productUnit', width: 10 },
{ header: '用料部门', key: 'department', width: 15 },
{ header: '备注', key: 'remark', width: 20 },
{ 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]
}
}
}
}

View File

@@ -12,6 +12,11 @@
*/
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
@@ -77,6 +82,24 @@ export class OrderNumberResolver {
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
* @param input - The input string to recognize
@@ -217,35 +240,64 @@ export class OrderNumberResolver {
}
try {
// Build query - use different placeholder style based on database type
const isSqlServer = this.dbService.type === 'sqlserver'
const placeholders = isSqlServer
? productionIds.map((_, idx) => `@p${idx}`).join(', ')
: productionIds.map(() => '?').join(', ')
const tableName = this.getTableName(DB_CONFIG.TABLE_NAME)
// Use appropriate quoting for table/field names
const quote = isSqlServer ? '' : '`'
const query = `
SELECT ${quote}${DB_CONFIG.FIELD_PRODUCTION_ID}${quote}, ${quote}${DB_CONFIG.FIELD_ORDER_NUMBER}${quote}
FROM ${quote}${DB_CONFIG.TABLE_NAME}${quote}
WHERE ${quote}${DB_CONFIG.FIELD_PRODUCTION_ID}${quote} IN (${placeholders})
`
log.debug('Resolving production IDs', {
count: productionIds.length,
dbType: this.dbService.type
})
const result = await this.dbService.query(query, productionIds)
let result
// Create a map for quick lookup
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) }
})
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 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 (use lowercase key for case-insensitive matching)
const resultMap = new Map<string, string>()
for (const row of result.rows) {
const prodId = row[DB_CONFIG.FIELD_PRODUCTION_ID] as string
const orderNum = row[DB_CONFIG.FIELD_ORDER_NUMBER] as string
if (prodId && orderNum) {
resultMap.set(prodId, orderNum)
// Store with lowercase key for case-insensitive matching
resultMap.set(prodId.toLowerCase(), orderNum)
}
}
// Build mappings
for (const pid of productionIds) {
const orderNumber = resultMap.get(pid)
// Use lowercase for case-insensitive lookup
const orderNumber = resultMap.get(pid.toLowerCase())
if (orderNumber) {
mappings.push({
@@ -292,21 +344,44 @@ export class OrderNumberResolver {
}
try {
// Build query - use different placeholder style based on database type
const isSqlServer = this.dbService.type === 'sqlserver'
const placeholders = isSqlServer
? orderNumbers.map((_, idx) => `@p${idx}`).join(', ')
: orderNumbers.map(() => '?').join(', ')
const tableName = this.getTableName(DB_CONFIG.TABLE_NAME)
// Use appropriate quoting for table/field names
const quote = isSqlServer ? '' : '`'
const query = `
SELECT ${quote}${DB_CONFIG.FIELD_ORDER_NUMBER}${quote}
FROM ${quote}${DB_CONFIG.TABLE_NAME}${quote}
WHERE ${quote}${DB_CONFIG.FIELD_ORDER_NUMBER}${quote} IN (${placeholders})
`
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) }
})
const query = `
SELECT ${DB_CONFIG.FIELD_ORDER_NUMBER}
FROM ${tableName}
WHERE ${DB_CONFIG.FIELD_ORDER_NUMBER} IN (${placeholders})
`
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)
}
const result = await this.dbService.query(query, orderNumbers)
return result.rows.map((row) => row[DB_CONFIG.FIELD_ORDER_NUMBER] as string)
} catch (error) {
console.warn('[OrderResolver] Failed to verify order numbers:', error)

View File

@@ -158,7 +158,7 @@ export class ExcelParser {
{ header: '产品编码', key: 'productCode', width: 15 },
{ header: '产品名称', key: 'productName', width: 30 },
{ header: '产品计划数量', key: 'productPlannedQuantity', width: 15 },
{ header: '单位', key: 'productUnit', width: 10 },
{ header: '产品单位', key: 'productUnit', width: 10 },
{ header: '用料部门', key: 'department', width: 15 },
{ header: '备注', key: 'remark', width: 20 },
{ header: '制单人', key: 'creator', width: 15 },

View File

@@ -121,8 +121,8 @@ export class BIPUsersDAO {
`
const result = await (dbService as SqlServerService).queryWithParams(sqlString, {
username: { value: username, type: sql.NVarChar },
password: { value: password, type: sql.NVarChar }
username: { value: username, type: sql.NVarChar(255) },
password: { value: password, type: sql.NVarChar(255) }
})
if (result.rows.length > 0) {
@@ -177,7 +177,7 @@ export class BIPUsersDAO {
`
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) {
@@ -265,49 +265,55 @@ export class BIPUsersDAO {
const tableName = this.getTableName()
if (this.dbType === 'sqlserver') {
let sql: string
let params: Record<string, { value: unknown; type?: sql.ISqlType }>
let sqlString: string
let params: Record<
string,
{
value: unknown
type?: sql.ISqlType | sql.ISqlTypeFactoryWithLength | sql.ISqlTypeWithLength
}
>
if (computerName) {
sql = `
sqlString = `
INSERT INTO ${tableName}
(UserName, Password, UserType, ComputerNmae)
VALUES (@username, @password, @userType, @computerName)
`
params = {
username: { value: username, type: sql.NVarChar },
password: { value: password, type: sql.NVarChar },
userType: { value: userType, type: sql.NVarChar },
computerName: { value: computerName, type: sql.NVarChar }
username: { value: username, type: sql.NVarChar(255) },
password: { value: password, type: sql.NVarChar(255) },
userType: { value: userType, type: sql.NVarChar(255) },
computerName: { value: computerName, type: sql.NVarChar(255) }
}
} else {
sql = `
sqlString = `
INSERT INTO ${tableName}
(UserName, Password, UserType)
VALUES (@username, @password, @userType)
`
params = {
username: { value: username, type: sql.NVarChar },
password: { value: password, type: sql.NVarChar },
userType: { value: userType, type: sql.NVarChar }
username: { value: username, type: sql.NVarChar(255) },
password: { value: password, type: sql.NVarChar(255) },
userType: { value: userType, type: sql.NVarChar(255) }
}
}
await (dbService as SqlServerService).queryWithParams(sqlString, params)
return true
} else {
let sql: string
let params: any[]
let sqlString: string
let params: unknown[]
if (computerName) {
sql = `
sqlString = `
INSERT INTO ${tableName}
(UserName, Password, UserType, ComputerNmae)
VALUES (?, ?, ?, ?)
`
params = [username, password, userType, computerName]
} else {
sql = `
sqlString = `
INSERT INTO ${tableName}
(UserName, Password, UserType)
VALUES (?, ?, ?)
@@ -343,8 +349,8 @@ export class BIPUsersDAO {
`
await (dbService as SqlServerService).queryWithParams(sqlString, {
username: { value: username, type: sql.NVarChar },
userType: { value: userType, type: sql.NVarChar }
username: { value: username, type: sql.NVarChar(255) },
userType: { value: userType, type: sql.NVarChar(255) }
})
return true
} else {
@@ -382,8 +388,8 @@ export class BIPUsersDAO {
`
await (dbService as SqlServerService).queryWithParams(sqlString, {
username: { value: username, type: sql.NVarChar },
newPassword: { value: newPassword, type: sql.NVarChar }
username: { value: username, type: sql.NVarChar(255) },
newPassword: { value: newPassword, type: sql.NVarChar(255) }
})
return true
} else {
@@ -419,7 +425,7 @@ export class BIPUsersDAO {
`
await (dbService as SqlServerService).queryWithParams(sqlString, {
username: { value: username, type: sql.NVarChar }
username: { value: username, type: sql.NVarChar(255) }
})
return true
} else {
@@ -455,7 +461,7 @@ export class BIPUsersDAO {
`
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
} else {

View File

@@ -1,17 +1,52 @@
import type { ErpSession } from './erp.types'
export interface ExtractorInput {
orderNumbers: string[]
batchSize?: number
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 {
downloadedFiles: string[]
mergedFile: string | null
recordCount: number
errors: string[]
/** Database import result (only populated if mergedFile was created) */
importResult?: ImportResult
}
export interface OrderInfo {
orderNumber: 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[]
}

View File

@@ -53,7 +53,11 @@ export function useAuth(): UseAuthReturn {
setState((prev) => ({ ...prev, loading: true, error: null }))
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) {
setState({
@@ -85,7 +89,12 @@ export function useAuth(): UseAuthReturn {
setState((prev) => ({ ...prev, loading: true, error: null }))
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) {
setState({
@@ -126,14 +135,18 @@ export function useAuth(): UseAuthReturn {
const getCurrentUser = useCallback(async (): Promise<UserInfo | null> => {
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) {
const userInfo = result.userInfo
setState((prev) => ({
...prev,
user: result.userInfo,
user: userInfo,
isAuthenticated: true
}))
return result.userInfo
return userInfo
}
return null
} catch {
@@ -143,7 +156,7 @@ export function useAuth(): UseAuthReturn {
const getAllUsers = useCallback(async (): Promise<UserInfo[]> => {
try {
return await window.electron.ipcRenderer.invoke('auth:getAllUsers')
return (await window.electron.ipcRenderer.invoke('auth:getAllUsers')) as UserInfo[]
} catch {
return []
}
@@ -153,7 +166,11 @@ export function useAuth(): UseAuthReturn {
setState((prev) => ({ ...prev, loading: true, error: null }))
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) {
setState({
@@ -180,7 +197,7 @@ export function useAuth(): UseAuthReturn {
const isAdmin = useCallback(async (): Promise<boolean> => {
try {
return await window.electron.ipcRenderer.invoke('auth:isAdmin')
return (await window.electron.ipcRenderer.invoke('auth:isAdmin')) as boolean
} catch {
return false
}

View File

@@ -44,9 +44,13 @@ export function useCleaner(): UseCleanerReturn {
setState({ loading: true, data: null, error: null })
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 })
return result.data
} else {

View File

@@ -43,9 +43,13 @@ export function useExtractor(): UseExtractorReturn {
setState({ loading: true, data: null, error: null })
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 })
return result.data
} else {

View File

@@ -68,13 +68,18 @@ export function useValidation(): UseValidationReturn {
setState((prev) => ({ ...prev, loading: true, error: null }))
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) {
const results = result.results || null
const stats = result.stats || null
setState({
loading: false,
data: result.results || null,
stats: result.stats || null,
data: results,
stats: stats,
error: null
})
return result
@@ -105,7 +110,9 @@ export function useValidation(): UseValidationReturn {
const getSharedProductionIds = useCallback(async (): Promise<string[]> => {
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 || []
} catch {
return []
@@ -117,7 +124,11 @@ export function useValidation(): UseValidationReturn {
materialCodes: string[]
} | null> => {
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) {
return {
orderNumbers: result.orderNumbers || [],

View File

@@ -1,5 +1,15 @@
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)
interface ExtractorResult {
@@ -7,6 +17,7 @@ interface ExtractorResult {
mergedFile: string | null
recordCount: number
errors: string[]
importResult?: ImportResult
}
interface ExtractorProgress {
@@ -212,6 +223,54 @@ const ExtractorPage: React.FC = () => {
</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-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">

View 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)
})
})
})

View File

@@ -15,6 +15,8 @@
"noUnusedParameters": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}