Replace the Extract → Write Excel → Read Excel → Import DB flow with direct record-to-database persistence. The extractor now builds MaterialPlanRecord[] from parsed orders and imports them without the round-trip through a merged Excel file. Key changes: - Add importFromRecords() to DataImportService for record-based import - Add SQL Server OPENJSON batch insert and atomic replace operations in DiscreteMaterialPlanDAO for efficient bulk writes - Extract common import logic into private importRecords() method - Configure explicit request/connection timeouts for SQL Server - Add unit tests for direct record import path Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
429 lines
14 KiB
TypeScript
429 lines
14 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
import { ExtractorService } from '../../../../src/main/services/erp/extractor'
|
|
import { ErpAuthService } from '../../../../src/main/services/erp/erp-auth'
|
|
import fs from 'fs/promises'
|
|
import type { ExtractorInput, ImportResult } from '../../../../src/main/types/extractor.types'
|
|
import type { ExcelParser } from '../../../../src/main/services/excel/excel-parser'
|
|
import type { DataImportService } from '../../../../src/main/services/database/data-importer'
|
|
|
|
// Mock external dependencies
|
|
vi.mock('fs/promises', () => ({
|
|
default: {
|
|
mkdir: vi.fn().mockResolvedValue(undefined),
|
|
unlink: vi.fn().mockResolvedValue(undefined)
|
|
},
|
|
mkdir: vi.fn().mockResolvedValue(undefined),
|
|
unlink: vi.fn().mockResolvedValue(undefined)
|
|
}))
|
|
|
|
vi.mock('../../../../src/main/services/logger', () => {
|
|
const mockLogger = {
|
|
info: vi.fn(),
|
|
debug: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn()
|
|
}
|
|
|
|
return {
|
|
default: mockLogger,
|
|
createLogger: vi.fn(() => mockLogger),
|
|
withRequestContext: vi.fn(async (fn) => fn()),
|
|
getRequestId: vi.fn(() => 'test-request-id')
|
|
}
|
|
})
|
|
|
|
vi.mock('../../../../src/main/services/logger/performance-monitor', () => {
|
|
return {
|
|
trackDuration: vi.fn(async (fn) => ({ result: await fn() }))
|
|
}
|
|
})
|
|
|
|
// Mock ExcelParser - reset in beforeEach
|
|
let mockExcelParserInstance: any
|
|
vi.mock('../../../../src/main/services/excel/excel-parser', () => ({
|
|
ExcelParser: function ExcelParser() {
|
|
return mockExcelParserInstance
|
|
}
|
|
}))
|
|
|
|
// Mock DataImportService - reset in beforeEach
|
|
let mockDataImportInstance: any
|
|
vi.mock('../../../../src/main/services/database/data-importer', () => ({
|
|
DataImportService: function DataImportService() {
|
|
return mockDataImportInstance
|
|
}
|
|
}))
|
|
|
|
// Mock ExtractorCore - reset in beforeEach
|
|
let mockExtractorCoreInstance: any
|
|
vi.mock('../../../../src/main/services/erp/extractor-core', () => ({
|
|
ExtractorCore: function ExtractorCore() {
|
|
return mockExtractorCoreInstance
|
|
}
|
|
}))
|
|
|
|
describe('ExtractorService', () => {
|
|
let mockAuthService: ErpAuthService
|
|
let mockSession: any
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
|
|
mockSession = { cookie: 'test-cookie' }
|
|
mockAuthService = {
|
|
getSession: vi.fn(() => mockSession)
|
|
} as unknown as ErpAuthService
|
|
|
|
// Initialize mock instances
|
|
mockExcelParserInstance = {
|
|
parse: vi.fn().mockResolvedValue(undefined),
|
|
_lastOrders: [] as Array<{ orderInfo: any; materials: any[] }>,
|
|
get lastOrders() {
|
|
return this._lastOrders
|
|
},
|
|
set lastOrders(val) {
|
|
this._lastOrders = val
|
|
}
|
|
}
|
|
|
|
// Explicitly reset lastOrders
|
|
mockExcelParserInstance.lastOrders = []
|
|
|
|
mockDataImportInstance = {
|
|
importFromExcel: vi.fn().mockResolvedValue({
|
|
success: true,
|
|
recordsRead: 0,
|
|
recordsDeleted: 0,
|
|
recordsImported: 0,
|
|
uniqueSourceNumbers: 0,
|
|
errors: []
|
|
} as ImportResult),
|
|
importFromRecords: vi.fn().mockResolvedValue({
|
|
success: true,
|
|
recordsRead: 0,
|
|
recordsDeleted: 0,
|
|
recordsImported: 0,
|
|
uniqueSourceNumbers: 0,
|
|
errors: []
|
|
} as ImportResult)
|
|
}
|
|
|
|
const mockDownloadAllBatches = vi.fn().mockResolvedValue({
|
|
downloadedFiles: [],
|
|
errors: []
|
|
})
|
|
mockExtractorCoreInstance = {
|
|
downloadAllBatches: mockDownloadAllBatches
|
|
}
|
|
})
|
|
|
|
// TODO: Complex extract() flow tests need integration test setup
|
|
|
|
describe('Constructor', () => {
|
|
it('should create instance with default download dir', () => {
|
|
const service = new ExtractorService(mockAuthService)
|
|
expect(service).toBeInstanceOf(ExtractorService)
|
|
})
|
|
|
|
it('should create instance with custom download dir', () => {
|
|
const service = new ExtractorService(mockAuthService, './custom-downloads')
|
|
expect(service).toBeInstanceOf(ExtractorService)
|
|
})
|
|
|
|
it('should ensure download directory exists', async () => {
|
|
// Clear fs.mkdir mock history before creating instance
|
|
vi.mocked(fs.mkdir).mockClear()
|
|
|
|
// Create instance (constructor calls fs.mkdir asynchronously)
|
|
new ExtractorService(mockAuthService, './test-downloads')
|
|
|
|
// Flush microtask queue so constructor's async mkdir resolves
|
|
await new Promise((resolve) => setImmediate(resolve))
|
|
expect(fs.mkdir).toHaveBeenCalledWith('./test-downloads', { recursive: true })
|
|
})
|
|
})
|
|
|
|
describe('extract() - Basic Behavior', () => {
|
|
it('should return result object', async () => {
|
|
const service = new ExtractorService(mockAuthService, './test-downloads')
|
|
const input: ExtractorInput = { orderNumbers: [] }
|
|
|
|
const result = await service.extract(input)
|
|
|
|
expect(result).toBeDefined()
|
|
expect(typeof result).toBe('object')
|
|
})
|
|
|
|
it('should handle empty order numbers', async () => {
|
|
const service = new ExtractorService(mockAuthService, './test-downloads')
|
|
|
|
await expect(service.extract({ orderNumbers: [] })).resolves.toBeDefined()
|
|
})
|
|
|
|
it('should capture errors from ExtractorCore', async () => {
|
|
mockExtractorCoreInstance.downloadAllBatches.mockResolvedValue({
|
|
downloadedFiles: [],
|
|
errors: ['Download failed']
|
|
})
|
|
|
|
const service = new ExtractorService(mockAuthService, './test-downloads')
|
|
const result = await service.extract({ orderNumbers: ['ORD001'] })
|
|
|
|
expect(result.downloadedFiles).toEqual([])
|
|
expect(result.errors).toContain('Download failed')
|
|
})
|
|
|
|
it('should handle extraction errors gracefully', async () => {
|
|
mockExtractorCoreInstance.downloadAllBatches.mockRejectedValue(new Error('Network error'))
|
|
|
|
const service = new ExtractorService(mockAuthService, './test-downloads')
|
|
const result = await service.extract({ orderNumbers: ['ORD001'] })
|
|
|
|
expect(Array.isArray(result.errors)).toBe(true)
|
|
})
|
|
|
|
it('should import parsed records directly instead of re-reading merged Excel', async () => {
|
|
mockExtractorCoreInstance.downloadAllBatches.mockResolvedValue({
|
|
downloadedFiles: ['./file1.xlsx'],
|
|
errors: []
|
|
})
|
|
mockExcelParserInstance.parse = vi.fn().mockImplementation(() => {
|
|
mockExcelParserInstance._lastOrders = [
|
|
{
|
|
orderInfo: {
|
|
factory: '工厂A',
|
|
planNumber: 'PLAN001',
|
|
productionOrder: 'ORD001',
|
|
productCode: 'P001',
|
|
productName: '产品A',
|
|
plannedQuantity: '10',
|
|
unit: 'PCS'
|
|
},
|
|
materials: [
|
|
{
|
|
sequence: 1,
|
|
materialCode: 'MAT001',
|
|
materialName: '物料A',
|
|
quantity: 2,
|
|
unit: 'PCS'
|
|
}
|
|
]
|
|
}
|
|
]
|
|
return Promise.resolve()
|
|
})
|
|
mockDataImportInstance.importFromRecords.mockResolvedValue({
|
|
success: true,
|
|
recordsRead: 1,
|
|
recordsDeleted: 0,
|
|
recordsImported: 1,
|
|
uniqueSourceNumbers: 1,
|
|
errors: []
|
|
} as ImportResult)
|
|
|
|
const service = new ExtractorService(mockAuthService, './test-downloads')
|
|
vi.spyOn(service as any, 'saveMergedOrders').mockResolvedValue(undefined)
|
|
|
|
const result = await service.extract({
|
|
orderNumbers: ['ORD001'],
|
|
onProgress: vi.fn(),
|
|
onLog: vi.fn()
|
|
})
|
|
|
|
expect(result.importResult?.success).toBe(true)
|
|
expect(mockDataImportInstance.importFromRecords).toHaveBeenCalledTimes(1)
|
|
expect(mockDataImportInstance.importFromExcel).not.toHaveBeenCalled()
|
|
expect(mockDataImportInstance.importFromRecords.mock.calls[0][0][0]).toMatchObject({
|
|
planNumber: 'PLAN001',
|
|
sourceNumber: 'ORD001',
|
|
materialCode: 'MAT001',
|
|
planQuantity: 2
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('mergeFiles()', () => {
|
|
it('should return null when no files to merge', async () => {
|
|
const service = new ExtractorService(mockAuthService)
|
|
|
|
// @ts-ignore - accessing private method for testing
|
|
const result = await service.mergeFiles([], ['ORD001'])
|
|
|
|
expect(result.mergedFile).toBeNull()
|
|
expect(result.recordCount).toBe(0)
|
|
expect(result.orderRecordCounts).toEqual([])
|
|
})
|
|
|
|
it('should handle single file', async () => {
|
|
mockExcelParserInstance.lastOrders = [
|
|
{
|
|
orderInfo: { productionOrder: 'ORD001' },
|
|
materials: [{ materialCode: 'MAT001', quantity: 10 }]
|
|
}
|
|
]
|
|
|
|
const service = new ExtractorService(mockAuthService, './test-downloads')
|
|
vi.spyOn(service as any, 'saveMergedOrders').mockResolvedValue(undefined)
|
|
|
|
// @ts-ignore - accessing private method for testing
|
|
const result = await service.mergeFiles(['./file1.xlsx'], ['ORD001'])
|
|
|
|
expect(result.recordCount).toBe(1)
|
|
expect(result.orderRecordCounts).toEqual([{ orderNumber: 'ORD001', recordCount: 1 }])
|
|
})
|
|
|
|
it('should handle multiple files', async () => {
|
|
// Mock parse to return different data for each file
|
|
let callCount = 0
|
|
mockExcelParserInstance.parse = vi.fn().mockImplementation(() => {
|
|
callCount++
|
|
if (callCount === 1) {
|
|
mockExcelParserInstance._lastOrders = [
|
|
{
|
|
orderInfo: { productionOrder: 'ORD001' },
|
|
materials: [{ materialCode: 'MAT001', quantity: 5 }]
|
|
}
|
|
]
|
|
} else {
|
|
mockExcelParserInstance._lastOrders = [
|
|
{
|
|
orderInfo: { productionOrder: 'ORD002' },
|
|
materials: [
|
|
{ materialCode: 'MAT002', quantity: 10 },
|
|
{ materialCode: 'MAT003', quantity: 15 }
|
|
]
|
|
}
|
|
]
|
|
}
|
|
return Promise.resolve()
|
|
})
|
|
|
|
const service = new ExtractorService(mockAuthService, './test-downloads')
|
|
vi.spyOn(service as any, 'saveMergedOrders').mockResolvedValue(undefined)
|
|
|
|
// @ts-ignore - accessing private method for testing
|
|
const result = await service.mergeFiles(
|
|
['./file1.xlsx', './file2.xlsx'],
|
|
['ORD001', 'ORD002']
|
|
)
|
|
|
|
expect(result.recordCount).toBe(3)
|
|
expect(result.orderRecordCounts).toHaveLength(2)
|
|
expect(result.orderRecordCounts[0]).toEqual({ orderNumber: 'ORD001', recordCount: 1 })
|
|
expect(result.orderRecordCounts[1]).toEqual({ orderNumber: 'ORD002', recordCount: 2 })
|
|
})
|
|
})
|
|
|
|
describe('cleanupTempFiles()', () => {
|
|
it('should delete all temporary files', async () => {
|
|
const service = new ExtractorService(mockAuthService)
|
|
const files = ['./temp1.xlsx', './temp2.xlsx', './temp3.xlsx']
|
|
|
|
// @ts-ignore - accessing private method for testing
|
|
await service.cleanupTempFiles(files, ['ORD001'])
|
|
|
|
expect(fs.unlink).toHaveBeenCalledTimes(3)
|
|
expect(fs.unlink).toHaveBeenCalledWith('./temp1.xlsx')
|
|
expect(fs.unlink).toHaveBeenCalledWith('./temp2.xlsx')
|
|
expect(fs.unlink).toHaveBeenCalledWith('./temp3.xlsx')
|
|
})
|
|
|
|
it('should handle deletion errors gracefully', async () => {
|
|
vi.mocked(fs.unlink).mockRejectedValue(new Error('File not found'))
|
|
|
|
const service = new ExtractorService(mockAuthService)
|
|
const files = ['./temp1.xlsx', './temp2.xlsx']
|
|
|
|
// @ts-ignore - accessing private method for testing
|
|
await expect(service.cleanupTempFiles(files, ['ORD001'])).resolves.not.toThrow()
|
|
|
|
expect(fs.unlink).toHaveBeenCalledTimes(2)
|
|
})
|
|
})
|
|
|
|
describe('importToDatabaseWithLogging()', () => {
|
|
it('should return success result', async () => {
|
|
mockDataImportInstance.importFromExcel.mockResolvedValue({
|
|
success: true,
|
|
recordsRead: 100,
|
|
recordsDeleted: 50,
|
|
recordsImported: 50,
|
|
uniqueSourceNumbers: 5,
|
|
errors: []
|
|
})
|
|
|
|
const service = new ExtractorService(mockAuthService)
|
|
const onLog = vi.fn()
|
|
|
|
// @ts-ignore - accessing private method for testing
|
|
const result = await service.importToDatabaseWithLogging('./merged.xlsx', onLog)
|
|
|
|
expect(result.success).toBe(true)
|
|
expect(result.recordsRead).toBe(100)
|
|
expect(result.recordsImported).toBe(50)
|
|
expect(onLog).toHaveBeenCalledWith('success', expect.stringContaining('导入完成'))
|
|
})
|
|
|
|
it('should handle import failure', async () => {
|
|
mockDataImportInstance.importFromExcel.mockRejectedValue(
|
|
new Error('Database connection failed')
|
|
)
|
|
|
|
const service = new ExtractorService(mockAuthService)
|
|
const onLog = vi.fn()
|
|
|
|
// @ts-ignore - accessing private method for testing
|
|
const result = await service.importToDatabaseWithLogging('./merged.xlsx', onLog)
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(result.errors.some((e) => e.includes('Database connection failed'))).toBe(true)
|
|
expect(onLog).toHaveBeenCalledWith('error', expect.stringContaining('导入失败'))
|
|
})
|
|
|
|
it('should handle import with errors in result', async () => {
|
|
mockDataImportInstance.importFromExcel.mockResolvedValue({
|
|
success: false,
|
|
recordsRead: 50,
|
|
recordsDeleted: 0,
|
|
recordsImported: 0,
|
|
uniqueSourceNumbers: 0,
|
|
errors: ['Validation failed', 'Duplicate records']
|
|
})
|
|
|
|
const service = new ExtractorService(mockAuthService)
|
|
const onLog = vi.fn()
|
|
|
|
// @ts-ignore - accessing private method for testing
|
|
const result = await service.importToDatabaseWithLogging('./merged.xlsx', onLog)
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(result.errors).toEqual(['Validation failed', 'Duplicate records'])
|
|
expect(onLog).toHaveBeenCalledTimes(3)
|
|
})
|
|
|
|
it('should wrap import in trackDuration', async () => {
|
|
mockDataImportInstance.importFromExcel.mockResolvedValue({
|
|
success: true,
|
|
recordsRead: 10,
|
|
recordsDeleted: 0,
|
|
recordsImported: 10,
|
|
uniqueSourceNumbers: 1,
|
|
errors: []
|
|
})
|
|
|
|
const { trackDuration } =
|
|
await import('../../../../src/main/services/logger/performance-monitor')
|
|
const service = new ExtractorService(mockAuthService)
|
|
const onLog = vi.fn()
|
|
|
|
// @ts-ignore - accessing private method for testing
|
|
await service.importToDatabaseWithLogging('./merged.xlsx', onLog)
|
|
|
|
expect(trackDuration).toHaveBeenCalledWith(
|
|
expect.any(Function),
|
|
expect.objectContaining({ operationName: 'Database Import' })
|
|
)
|
|
})
|
|
})
|
|
})
|