7 Commits

Author SHA1 Message Date
Misaka_Company
f36c88aa89 fix(extractor): display operation time in local timezone
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 13:53:54 +08:00
Misaka_Company
dbb8e4904e perf: optimize order resolution history writes 2026-04-28 13:39:08 +08:00
Misaka_Company
1ffa0650a6 1.14.1 2026-04-28 12:17:15 +08:00
Misaka_Company
72dba32a52 docs: add release notes for version 1.14.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 12:17:11 +08:00
Misaka_Company
98865f5d7e fix(cleaner): preserve production IDs in operation history
The 总排号 field was always empty because getCleanerData() resolved
production IDs to order numbers before passing them to the cleaner,
losing the original inputs. Now originalInputs are carried through
the full chain so the resolver can properly set productionId.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 11:57:47 +08:00
Misaka_Company
5ff99cdd0f refactor: use dot notation for table name config (schema.table instead of schema_table)
Replace underscore-based table name splitting with dot-based splitting
to match the standard schema.tablename format, removing MySQL compatibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 11:10:03 +08:00
Misaka
664f26d63f fix(cleaner): guard missing session refresh config 2026-04-24 19:11:21 +08:00
19 changed files with 214 additions and 133 deletions

View File

@@ -188,21 +188,26 @@ flowchart LR
**表名转换逻辑**: **表名转换逻辑**:
```typescript ```typescript
// MySQL: dbo_MaterialsToBeDeleted // 输入格式: dbo.MaterialsToBeDeleted
// SQL Server: [dbo].[MaterialsToBeDeleted] // SQL Server: [dbo].[MaterialsToBeDeleted]
function getTableName(mysqlTableName: string): string { // PostgreSQL: "dbo"."MaterialsToBeDeleted"
const dbType = process.env.DB_TYPE?.toLowerCase() function getValidationTableName(dottedTableName: string): string {
if (dbType === 'sqlserver' || dbType === 'mssql') { const configManager = ConfigManager.getInstance()
// 找到第一个下划线分割schema和表名 const dbType = configManager.getDatabaseType()
const firstUnderscoreIndex = mysqlTableName.indexOf('_')
if (firstUnderscoreIndex > 0) { const dotIndex = dottedTableName.indexOf('.')
const schema = mysqlTableName.substring(0, firstUnderscoreIndex) if (dotIndex > 0) {
const tableName = mysqlTableName.substring(firstUnderscoreIndex + 1) const schema = dottedTableName.substring(0, dotIndex)
const tableName = dottedTableName.substring(dotIndex + 1)
if (dbType === 'sqlserver') {
return `[${schema}].[${tableName}]` return `[${schema}].[${tableName}]`
} }
return `[dbo].[${mysqlTableName}]` return `"${schema}"."${tableName}"`
} }
return mysqlTableName if (dbType === 'sqlserver') {
return `[dbo].[${dottedTableName}]`
}
return `"public"."${dottedTableName}"`
} }
``` ```

View File

@@ -541,7 +541,7 @@ graph LR
### 10.2 默认配置示例 ### 10.2 默认配置示例
```env ```env
DB_TABLE_NAME=productionContractData_26 年压力表合同数据 DB_TABLE_NAME=ERPAuto.vw_productionContractData
DB_FIELD_PRODUCTION_ID=总排号 DB_FIELD_PRODUCTION_ID=总排号
DB_FIELD_ORDER_NUMBER=生产订单号 DB_FIELD_ORDER_NUMBER=生产订单号
``` ```

10
docs/releases/1.14.1.md Normal file
View File

@@ -0,0 +1,10 @@
# 1.14.1
## 问题修复
- 修复清理任务操作历史中「总排号」始终为空的问题,原始生产编号现在能正确保留并显示在历史记录中。
- 修复会话重建配置缺失时清理任务可能异常中断的问题,提升配置不完整场景下的运行稳定性。
## 改进
- 表名配置统一使用 `schema.tablename` 标准点分写法,与数据库标准格式保持一致。

View File

@@ -180,7 +180,7 @@ validation:
# 订单号解析配置 # 订单号解析配置
orderResolution: orderResolution:
tableName: 'productionContractData_26 年压力表合同数据' tableName: 'ERPAuto.vw_productionContractData'
productionIdField: '总排号' productionIdField: '总排号'
orderNumberField: '生产订单号' orderNumberField: '生产订单号'
``` ```

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "erpauto", "name": "erpauto",
"version": "1.14.0", "version": "1.14.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "erpauto", "name": "erpauto",
"version": "1.14.0", "version": "1.14.1",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.929.0", "@aws-sdk/client-s3": "^3.929.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "erpauto", "name": "erpauto",
"version": "1.14.0", "version": "1.14.1",
"description": "An Electron application with React and TypeScript", "description": "An Electron application with React and TypeScript",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "example.com", "author": "example.com",

View File

@@ -126,7 +126,9 @@ export function registerExtractorHandlers(): void {
sendLog(sender, 'info', '正在解析订单号...') sendLog(sender, 'info', '正在解析订单号...')
const resolver = new OrderNumberResolver(dbService) const resolver = new OrderNumberResolver(dbService)
const resolutionStart = Date.now()
const mappings = await resolver.resolve(input.orderNumbers) const mappings = await resolver.resolve(input.orderNumbers)
const resolutionDurationMs = Date.now() - resolutionStart
// Get valid order numbers and warnings // Get valid order numbers and warnings
const validOrderNumbers = resolver.getValidOrderNumbers(mappings) const validOrderNumbers = resolver.getValidOrderNumbers(mappings)
@@ -146,7 +148,16 @@ export function registerExtractorHandlers(): void {
) )
} }
log.info('Resolved order numbers', { count: validOrderNumbers.length }) log.info('Resolved order numbers', {
inputCount: input.orderNumbers.length,
count: validOrderNumbers.length,
durationMs: resolutionDurationMs
})
sendLog(
sender,
'info',
`订单号解析完成:${validOrderNumbers.length}/${input.orderNumbers.length} 个有效,耗时 ${(resolutionDurationMs / 1000).toFixed(2)}`
)
// Initialize operation history recording // Initialize operation history recording
const currentUser = SessionManager.getInstance().getUserInfo() const currentUser = SessionManager.getInstance().getUserInfo()
@@ -155,6 +166,7 @@ export function registerExtractorHandlers(): void {
// Save order records to history (preserve productionId -> orderNumber mapping) // Save order records to history (preserve productionId -> orderNumber mapping)
if (currentUser) { if (currentUser) {
const historyInsertStart = Date.now()
const orderRecords = mappings.map((m) => ({ const orderRecords = mappings.map((m) => ({
productionId: m.productionId || null, productionId: m.productionId || null,
orderNumber: m.orderNumber || m.input orderNumber: m.orderNumber || m.input
@@ -165,9 +177,11 @@ export function registerExtractorHandlers(): void {
currentUser.username, currentUser.username,
orderRecords orderRecords
) )
const historyInsertDurationMs = Date.now() - historyInsertStart
log.info('Operation history batch created', { log.info('Operation history batch created', {
batchId, batchId,
recordCount: orderRecords.length recordCount: orderRecords.length,
durationMs: historyInsertDurationMs
}) })
} }

View File

@@ -27,6 +27,7 @@ import type { InsertMaterialDetailInput, InsertOrderInput } from '../../types/cl
import type { OrderMapping } from '../../types/order-resolver.types' import type { OrderMapping } from '../../types/order-resolver.types'
const log = createLogger('CleanerApplicationService') const log = createLogger('CleanerApplicationService')
const DEFAULT_SESSION_REFRESH_ORDER_THRESHOLD = 160
export class CleanerApplicationService { export class CleanerApplicationService {
async runCleaner( async runCleaner(
@@ -65,7 +66,8 @@ export class CleanerApplicationService {
} }
const resolver = new OrderNumberResolver(dbService) const resolver = new OrderNumberResolver(dbService)
const mappings = await resolver.resolve(input.orderNumbers) const inputsToResolve = input.originalInputs?.length ? input.originalInputs : input.orderNumbers
const mappings = await resolver.resolve(inputsToResolve)
const validOrderNumbers = resolver.getValidOrderNumbers(mappings) const validOrderNumbers = resolver.getValidOrderNumbers(mappings)
const warnings = resolver.getWarnings(mappings) const warnings = resolver.getWarnings(mappings)
@@ -148,9 +150,8 @@ export class CleanerApplicationService {
log.info('Login successful') log.info('Login successful')
const totalOrders = validOrderNumbers.length const totalOrders = validOrderNumbers.length
const cleanerConfig = configManager.getConfig().cleaner
const effectiveSessionRefreshOrderThreshold = const effectiveSessionRefreshOrderThreshold =
input.sessionRefreshOrderThreshold ?? cleanerConfig.sessionRefreshOrderThreshold this.resolveSessionRefreshOrderThreshold(input, configManager)
this.sendProgress(eventSender, 'ERP 登录成功', (1 / (1 + totalOrders)) * 100, { this.sendProgress(eventSender, 'ERP 登录成功', (1 / (1 + totalOrders)) * 100, {
phase: 'login', phase: 'login',
currentOrderIndex: 0, currentOrderIndex: 0,
@@ -455,9 +456,10 @@ export class CleanerApplicationService {
: result.errors.length > 0 : result.errors.length > 0
? AuditStatus.FAILURE ? AuditStatus.FAILURE
: AuditStatus.SUCCESS : AuditStatus.SUCCESS
const cleanerConfig = ConfigManager.getInstance().getConfig().cleaner const effectiveSessionRefreshOrderThreshold = this.resolveSessionRefreshOrderThreshold(
const effectiveSessionRefreshOrderThreshold = input,
input.sessionRefreshOrderThreshold ?? cleanerConfig.sessionRefreshOrderThreshold ConfigManager.getInstance()
)
logAuditWithCurrentUser(AuditAction.CLEAN, 'MATERIAL_PLAN', status, { logAuditWithCurrentUser(AuditAction.CLEAN, 'MATERIAL_PLAN', status, {
orderCount, orderCount,
@@ -471,6 +473,17 @@ export class CleanerApplicationService {
}) })
} }
private resolveSessionRefreshOrderThreshold(
input: CleanerInput,
configManager: Pick<ConfigManager, 'getConfig'>
): number {
return (
input.sessionRefreshOrderThreshold ??
configManager.getConfig().cleaner?.sessionRefreshOrderThreshold ??
DEFAULT_SESSION_REFRESH_ORDER_THRESHOLD
)
}
/** /**
* Save attempt results to database: update order statuses, insert material details, * Save attempt results to database: update order statuses, insert material details,
* and update execution status. * and update execution status.

View File

@@ -126,35 +126,47 @@ export class ExtractorOperationHistoryDAO {
recordCount: records.length recordCount: records.length
}) })
for (const record of records) { const columnsPerRecord = 5
const batchSize = Math.max(1, dialect.maxBatchRows(columnsPerRecord))
for (let offset = 0; offset < records.length; offset += batchSize) {
const batch = records.slice(offset, offset + batchSize)
try { try {
const valuesSql: string[] = []
const params: (string | number | null)[] = []
batch.forEach((record, index) => {
const paramOffset = index * columnsPerRecord
valuesSql.push(
`(${dialect.param(paramOffset)}, ${dialect.param(paramOffset + 1)}, ${dialect.param(paramOffset + 2)}, ${dialect.param(paramOffset + 3)}, ${dialect.param(paramOffset + 4)}, ${dialect.currentTimestamp()}, 'pending')`
)
params.push(batchId, userId, username, record.productionId || null, record.orderNumber)
})
const sqlString = ` const sqlString = `
INSERT INTO ${tableName} INSERT INTO ${tableName}
(BatchId, UserId, Username, ProductionId, OrderNumber, OperationTime, Status) (BatchId, UserId, Username, ProductionId, OrderNumber, OperationTime, Status)
VALUES VALUES
(${dialect.param(0)}, ${dialect.param(1)}, ${dialect.param(2)}, ${dialect.param(3)}, ${dialect.param(4)}, ${dialect.currentTimestamp()}, 'pending') ${valuesSql.join(',\n ')}
` `
await trackDuration( await trackDuration(async () => await dbService.query(sqlString, params), {
async () => operationName: 'ExtractorOperationHistoryDAO.insertBatchRecords',
await dbService.query(sqlString, [ context: {
batchId, tableName,
userId, operationType: 'INSERT',
username, batchId,
record.productionId || null, batchOffset: offset,
record.orderNumber batchCount: batch.length
]),
{
operationName: 'ExtractorOperationHistoryDAO.insertBatchRecords',
context: { tableName, operationType: 'INSERT', batchId }
} }
) })
} catch (error) { } catch (error) {
log.error('Error inserting individual record', { log.error('Error inserting record batch', {
tableName, tableName,
operationType: 'INSERT', operationType: 'INSERT',
requestId, requestId,
batchId, batchId,
orderNumber: record.orderNumber, batchOffset: offset,
batchCount: batch.length,
error: error instanceof Error ? error.message : String(error) error: error instanceof Error ? error.message : String(error)
}) })
} }

View File

@@ -32,6 +32,8 @@ const PRODUCTION_ID_PATTERN = /^\d{2}[A-Z]\d{1,6}$/i
*/ */
const ORDER_NUMBER_PATTERN = /^SC\d{14}$/i const ORDER_NUMBER_PATTERN = /^SC\d{14}$/i
const RESOLUTION_QUERY_BATCH_SIZE = 1000
/** /**
* Database table and field names * Database table and field names
* Loaded from config.yaml via ConfigManager * Loaded from config.yaml via ConfigManager
@@ -40,7 +42,7 @@ export function getDbConfig() {
const configManager = ConfigManager.getInstance() const configManager = ConfigManager.getInstance()
const config = configManager.getConfig() const config = configManager.getConfig()
return { return {
TABLE_NAME: config.orderResolution.tableName || 'productionContractData_26 年压力表合同数据', TABLE_NAME: config.orderResolution.tableName || 'ERPAuto.vw_productionContractData',
FIELD_PRODUCTION_ID: config.orderResolution.productionIdField || '总排号', FIELD_PRODUCTION_ID: config.orderResolution.productionIdField || '总排号',
FIELD_ORDER_NUMBER: config.orderResolution.orderNumberField || '生产订单号' FIELD_ORDER_NUMBER: config.orderResolution.orderNumberField || '生产订单号'
} }
@@ -56,37 +58,38 @@ export class OrderNumberResolver {
this.dbService = dbService this.dbService = dbService
} }
private chunk<T>(items: T[], size: number): T[][] {
const chunks: T[][] = []
for (let index = 0; index < items.length; index += size) {
chunks.push(items.slice(index, index + size))
}
return chunks
}
/** /**
* Get table name based on database type * Get table name based on database type
* Converts schema_tablename format to database-specific quoting: * Converts schema.tablename format to database-specific quoting:
* - SQL Server: [schema].[tablename] * - SQL Server: [schema].[tablename]
* - PostgreSQL: "schema"."tablename" * - PostgreSQL: "schema"."tablename"
* - MySQL: schema_tablename (as-is) * e.g., ERPAuto.vw_productionContractData ->
* e.g., productionContractData_26年压力表合同数据 -> * SQL Server: [ERPAuto].[vw_productionContractData]
* SQL Server: [productionContractData].[26年压力表合同数据] * PostgreSQL: "ERPAuto"."vw_productionContractData"
* PostgreSQL: "productionContractData"."26年压力表合同数据"
* MySQL: productionContractData_26年压力表合同数据
*/ */
private getTableName(tableName: string): string { private getTableName(tableName: string): string {
if (this.dbService.type === 'sqlserver' || this.dbService.type === 'postgresql') { const dotIndex = tableName.indexOf('.')
// Find the FIRST underscore to split schema and table name if (dotIndex > 0) {
// This handles patterns like: schema_tablename const schema = tableName.substring(0, dotIndex)
const firstUnderscoreIndex = tableName.indexOf('_') const actualTableName = tableName.substring(dotIndex + 1)
if (firstUnderscoreIndex > 0) {
const schema = tableName.substring(0, firstUnderscoreIndex)
const actualTableName = tableName.substring(firstUnderscoreIndex + 1)
if (this.dbService.type === 'sqlserver') {
return `[${schema}].[${actualTableName}]`
}
return `"${schema}"."${actualTableName}"`
}
// If no underscore found, default schema
if (this.dbService.type === 'sqlserver') { if (this.dbService.type === 'sqlserver') {
return `[dbo].[${tableName}]` return `[${schema}].[${actualTableName}]`
} }
return `"public"."${tableName}"` return `"${schema}"."${actualTableName}"`
} }
return tableName // No dot found — use default schema
if (this.dbService.type === 'sqlserver') {
return `[dbo].[${tableName}]`
}
return `"public"."${tableName}"`
} }
/** /**
@@ -163,36 +166,36 @@ export class OrderNumberResolver {
// P1: Deduplicate input productionIds to avoid redundant queries // P1: Deduplicate input productionIds to avoid redundant queries
const uniqueProductionIds = [...new Set(productionIds)] const uniqueProductionIds = [...new Set(productionIds)]
// Use parameterized query to prevent SQL injection
const placeholders = uniqueProductionIds.map((_, i) => `@p${i}`).join(', ')
const params = uniqueProductionIds
let sql: string
if (this.dbService.type === 'sqlserver') {
// P0: Use DISTINCT to prevent duplicates from one-to-many relationships
// 使用 COLLATE 指定不区分大小写的排序规则
sql = `SELECT DISTINCT [${dbConfig.FIELD_PRODUCTION_ID}], [${dbConfig.FIELD_ORDER_NUMBER}] FROM ${tableName} WHERE [${dbConfig.FIELD_PRODUCTION_ID}] COLLATE SQL_Latin1_General_CP1_CI_AS IN (${placeholders})`
} else if (this.dbService.type === 'postgresql') {
// PostgreSQL: 使用双引号保护中文标识符UPPER 实现不区分大小写
// 注意getTableName() 已返回带双引号的表名,不应再加引号
const pgPlaceholders = uniqueProductionIds.map((_, i) => `UPPER($${i + 1})`).join(', ')
sql = `SELECT DISTINCT "${dbConfig.FIELD_PRODUCTION_ID}", "${dbConfig.FIELD_ORDER_NUMBER}" FROM ${tableName} WHERE UPPER("${dbConfig.FIELD_PRODUCTION_ID}") IN (${pgPlaceholders})`
} else {
const idPlaceholders = uniqueProductionIds.map(() => 'UPPER(?)').join(', ')
// P0: Use DISTINCT to prevent duplicates from one-to-many relationships
// MySQL: 使用 UPPER 确保不区分大小写
sql = `SELECT DISTINCT \`${dbConfig.FIELD_PRODUCTION_ID}\`, \`${dbConfig.FIELD_ORDER_NUMBER}\` FROM \`${tableName}\` WHERE UPPER(\`${dbConfig.FIELD_PRODUCTION_ID}\`) IN (${idPlaceholders})`
}
const result = await this.dbService.query(sql, params)
const mappings = new Map<string, string>() const mappings = new Map<string, string>()
for (const row of result.rows) { const batches = this.chunk(uniqueProductionIds, RESOLUTION_QUERY_BATCH_SIZE)
const keys = Object.keys(row)
const prodId = row[keys[0]] as string for (const batch of batches) {
const orderNum = row[keys[1]] as string let sql: string
if (prodId && orderNum) { const params = batch
mappings.set(prodId, orderNum)
if (this.dbService.type === 'sqlserver') {
const placeholders = batch.map((_, i) => `@p${i}`).join(', ')
// P0: Use DISTINCT to prevent duplicates from one-to-many relationships.
sql = `SELECT DISTINCT [${dbConfig.FIELD_PRODUCTION_ID}], [${dbConfig.FIELD_ORDER_NUMBER}] FROM ${tableName} WHERE [${dbConfig.FIELD_PRODUCTION_ID}] COLLATE SQL_Latin1_General_CP1_CI_AS IN (${placeholders})`
} else if (this.dbService.type === 'postgresql') {
// PostgreSQL: 使用双引号保护中文标识符UPPER 实现不区分大小写。
const pgPlaceholders = batch.map((_, i) => `UPPER($${i + 1})`).join(', ')
sql = `SELECT DISTINCT "${dbConfig.FIELD_PRODUCTION_ID}", "${dbConfig.FIELD_ORDER_NUMBER}" FROM ${tableName} WHERE UPPER("${dbConfig.FIELD_PRODUCTION_ID}") IN (${pgPlaceholders})`
} else {
const idPlaceholders = batch.map(() => 'UPPER(?)').join(', ')
// MySQL: 使用 UPPER 确保不区分大小写。
sql = `SELECT DISTINCT \`${dbConfig.FIELD_PRODUCTION_ID}\`, \`${dbConfig.FIELD_ORDER_NUMBER}\` FROM \`${tableName}\` WHERE UPPER(\`${dbConfig.FIELD_PRODUCTION_ID}\`) IN (${idPlaceholders})`
}
const result = await this.dbService.query(sql, params)
for (const row of result.rows) {
const keys = Object.keys(row)
const prodId = row[keys[0]] as string
const orderNum = row[keys[1]] as string
if (prodId && orderNum) {
mappings.set(prodId, orderNum)
}
} }
} }
@@ -242,13 +245,14 @@ export class OrderNumberResolver {
// Build results while preserving original input order // Build results while preserving original input order
// Note: Multiple productionIDs mapping to the same order number is VALID (not an error) // Note: Multiple productionIDs mapping to the same order number is VALID (not an error)
const results: OrderMapping[] = [] const results: OrderMapping[] = []
const processedInputs = new Set<string>()
for (const input of inputs) { for (const input of inputs) {
// Skip if this exact input was already processed // Skip if this exact input was already processed
const alreadyProcessed = results.some((r) => r.input === input) if (processedInputs.has(input)) {
if (alreadyProcessed) {
continue continue
} }
processedInputs.add(input)
const mapping: OrderMapping = { input, resolved: false } const mapping: OrderMapping = { input, resolved: false }

View File

@@ -41,7 +41,7 @@ export async function getSourceNumbersFromInputs(
} }
if (productionIds.length > 0) { if (productionIds.length > 0) {
const contractTableName = getValidationTableName('productionContractData_26年压力表合同数据') const contractTableName = getValidationTableName('ERPAuto.vw_productionContractData')
const batchSize = 2000 const batchSize = 2000
if (dbType === 'sqlserver') { if (dbType === 'sqlserver') {

View File

@@ -236,6 +236,7 @@ export class ValidationApplicationService {
): Promise<{ ): Promise<{
success: boolean success: boolean
orderNumbers?: string[] orderNumbers?: string[]
originalInputs?: string[]
materialCodes?: string[] materialCodes?: string[]
error?: string error?: string
}> { }> {
@@ -288,6 +289,7 @@ export class ValidationApplicationService {
return { return {
success: true, success: true,
orderNumbers, orderNumbers,
originalInputs: sharedIds,
materialCodes materialCodes
} }
} catch (error) { } catch (error) {
@@ -325,7 +327,7 @@ export class ValidationApplicationService {
} }
private async loadTypeKeywords(dbService: ValidationDatabaseService): Promise<TypeKeyword[]> { private async loadTypeKeywords(dbService: ValidationDatabaseService): Promise<TypeKeyword[]> {
const typeKeywordTableName = getValidationTableName('dbo_MaterialsTypeToBeDeleted') const typeKeywordTableName = getValidationTableName('dbo.MaterialsTypeToBeDeleted')
const sql = ` const sql = `
SELECT MaterialName, ManagerName SELECT MaterialName, ManagerName
FROM ${typeKeywordTableName} FROM ${typeKeywordTableName}
@@ -341,7 +343,7 @@ export class ValidationApplicationService {
private async loadMarkedCodes( private async loadMarkedCodes(
dbService: ValidationDatabaseService dbService: ValidationDatabaseService
): Promise<Map<string, string>> { ): Promise<Map<string, string>> {
const markedTableName = getValidationTableName('dbo_MaterialsToBeDeleted') const markedTableName = getValidationTableName('dbo.MaterialsToBeDeleted')
const sql = ` const sql = `
SELECT MaterialCode, ManagerName SELECT MaterialCode, ManagerName
FROM ${markedTableName} FROM ${markedTableName}
@@ -416,7 +418,7 @@ export class ValidationApplicationService {
try { try {
dbService = await createValidationDatabaseService() dbService = await createValidationDatabaseService()
const detailTableName = getValidationTableName('dbo_DiscreteMaterialPlanData') const detailTableName = getValidationTableName('dbo.DiscreteMaterialPlanData')
const enrichedMaterials: MaterialRecordSummary[] = [] const enrichedMaterials: MaterialRecordSummary[] = []
log.info(`Enriching ${materials.length} materials with details`) log.info(`Enriching ${materials.length} materials with details`)
@@ -501,7 +503,7 @@ export class ValidationApplicationService {
selectedManagers: string[], selectedManagers: string[],
orderNumbers: string[] orderNumbers: string[]
): Promise<string[]> { ): Promise<string[]> {
const markedTableName = getValidationTableName('dbo_MaterialsToBeDeleted') const markedTableName = getValidationTableName('dbo.MaterialsToBeDeleted')
// Admin with selected managers: filter MaterialsToBeDeleted by ManagerName IN (selectedManagers) // Admin with selected managers: filter MaterialsToBeDeleted by ManagerName IN (selectedManagers)
if (isAdmin && selectedManagers && selectedManagers.length > 0) { if (isAdmin && selectedManagers && selectedManagers.length > 0) {

View File

@@ -52,25 +52,22 @@ export async function createValidationDatabaseService(): Promise<ValidationDatab
return mysqlService return mysqlService
} }
export function getValidationTableName(mysqlTableName: string): string { export function getValidationTableName(dottedTableName: string): string {
const configManager = ConfigManager.getInstance() const configManager = ConfigManager.getInstance()
const dbType = configManager.getDatabaseType() const dbType = configManager.getDatabaseType()
if (dbType === 'sqlserver' || dbType === 'postgresql') { const dotIndex = dottedTableName.indexOf('.')
const firstUnderscoreIndex = mysqlTableName.indexOf('_') if (dotIndex > 0) {
if (firstUnderscoreIndex > 0) { const schema = dottedTableName.substring(0, dotIndex)
const schema = mysqlTableName.substring(0, firstUnderscoreIndex) const tableName = dottedTableName.substring(dotIndex + 1)
const tableName = mysqlTableName.substring(firstUnderscoreIndex + 1)
if (dbType === 'sqlserver') {
return `[${schema}].[${tableName}]`
}
return `"${schema}"."${tableName}"`
}
if (dbType === 'sqlserver') { if (dbType === 'sqlserver') {
return `[dbo].[${mysqlTableName}]` return `[${schema}].[${tableName}]`
} }
return `"public"."${mysqlTableName}"` return `"${schema}"."${tableName}"`
} }
// No dot found — use default schema
return mysqlTableName if (dbType === 'sqlserver') {
return `[dbo].[${dottedTableName}]`
}
return `"public"."${dottedTableName}"`
} }

View File

@@ -13,6 +13,7 @@ export interface CleanerProgress {
export interface CleanerInput { export interface CleanerInput {
orderNumbers: string[] orderNumbers: string[]
originalInputs?: string[]
materialCodes: string[] materialCodes: string[]
dryRun: boolean dryRun: boolean
headless?: boolean headless?: boolean

View File

@@ -62,12 +62,12 @@ const formatDateTime = (dateStr: string) => {
return dateStr // Return original if invalid return dateStr // Return original if invalid
} }
// Use UTC methods to display the time as stored in database (without timezone conversion) // Use local time for display
const year = date.getUTCFullYear() const year = date.getFullYear()
const month = String(date.getUTCMonth() + 1).padStart(2, '0') const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getUTCDate()).padStart(2, '0') const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getUTCHours()).padStart(2, '0') const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getUTCMinutes()).padStart(2, '0') const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}` return `${year}-${month}-${day} ${hours}:${minutes}`
} }

View File

@@ -10,6 +10,7 @@ import type { CleanerExportItem, MaterialBatchChange } from './helpers'
interface CleanerDataPayload { interface CleanerDataPayload {
success?: boolean success?: boolean
orderNumbers?: string[] orderNumbers?: string[]
originalInputs?: string[]
materialCodes?: string[] materialCodes?: string[]
} }
@@ -147,6 +148,7 @@ export async function runCleanerExecution(params: {
const response = await window.electron.cleaner.runCleaner({ const response = await window.electron.cleaner.runCleaner({
orderNumbers: orderNumberList, orderNumbers: orderNumberList,
originalInputs: cleanerData?.originalInputs,
materialCodes: materialCodeList, materialCodes: materialCodeList,
dryRun: params.dryRun, dryRun: params.dryRun,
headless: params.headless, headless: params.headless,

View File

@@ -279,6 +279,14 @@ describe('CleanerApplicationService', () => {
expect(result.ordersProcessed).toBe(1) expect(result.ordersProcessed).toBe(1)
}) })
it('should fall back to default session refresh threshold when cleaner config is missing', async () => {
const eventSender: any = { send: vi.fn() }
await service.runCleaner(eventSender, makeInput())
expect(lastCleanerInput?.sessionRefreshOrderThreshold).toBe(160)
})
it('should close ERP browser on success', async () => { it('should close ERP browser on success', async () => {
await service.runCleaner({ send: vi.fn() } as any, makeInput()) await service.runCleaner({ send: vi.fn() } as any, makeInput())

View File

@@ -173,6 +173,25 @@ describe('OrderNumberResolver', () => {
// Should be optimized to query unique values only // Should be optimized to query unique values only
expect(mockDbService.query).toHaveBeenCalledTimes(1) expect(mockDbService.query).toHaveBeenCalledTimes(1)
}) })
it('splits large mapping queries into bounded batches', async () => {
const largeInput = Array.from({ length: 1001 }, (_, i) => `22A${i}`)
vi.mocked(mockDbService.query).mockImplementation(async (_sql, params = []) => ({
rows: params.map((prodId, i) => ({
总排号: prodId,
: `SC7020260212${String(i).padStart(5, '0')}`
})),
columns: ['总排号', '生产订单号'],
rowCount: params.length
}))
await resolver.mapProductionIdsToOrderNumbers(largeInput)
expect(mockDbService.query).toHaveBeenCalledTimes(2)
expect(vi.mocked(mockDbService.query).mock.calls[0][1]).toHaveLength(1000)
expect(vi.mocked(mockDbService.query).mock.calls[1][1]).toHaveLength(1)
})
}) })
describe('error handling', () => { describe('error handling', () => {

View File

@@ -125,31 +125,25 @@ describe('ValidationDatabaseService', () => {
}) })
describe('getValidationTableName', () => { describe('getValidationTableName', () => {
it('returns table name unchanged for mysql', async () => { it('converts schema.table to [schema].[table] for sqlserver', async () => {
currentDbType = 'mysql'
const mod = await import('../../../../src/main/services/validation/validation-database')
expect(mod.getValidationTableName('MaterialsToBeDeleted')).toBe('MaterialsToBeDeleted')
})
it('converts schema_table to [schema].[table] for sqlserver', async () => {
currentDbType = 'sqlserver' currentDbType = 'sqlserver'
const mod = await import('../../../../src/main/services/validation/validation-database') const mod = await import('../../../../src/main/services/validation/validation-database')
expect(mod.getValidationTableName('dbo_Materials')).toBe('[dbo].[Materials]') expect(mod.getValidationTableName('dbo.Materials')).toBe('[dbo].[Materials]')
}) })
it('wraps nameless table in [dbo].[name] for sqlserver', async () => { it('wraps dotless table in [dbo].[name] for sqlserver', async () => {
currentDbType = 'sqlserver' currentDbType = 'sqlserver'
const mod = await import('../../../../src/main/services/validation/validation-database') const mod = await import('../../../../src/main/services/validation/validation-database')
expect(mod.getValidationTableName('Materials')).toBe('[dbo].[Materials]') expect(mod.getValidationTableName('Materials')).toBe('[dbo].[Materials]')
}) })
it('converts schema_table to "schema"."table" for postgresql', async () => { it('converts schema.table to "schema"."table" for postgresql', async () => {
currentDbType = 'postgresql' currentDbType = 'postgresql'
const mod = await import('../../../../src/main/services/validation/validation-database') const mod = await import('../../../../src/main/services/validation/validation-database')
expect(mod.getValidationTableName('public_Materials')).toBe('"public"."Materials"') expect(mod.getValidationTableName('public.Materials')).toBe('"public"."Materials"')
}) })
it('wraps nameless table in "public"."name" for postgresql', async () => { it('wraps dotless table in "public"."name" for postgresql', async () => {
currentDbType = 'postgresql' currentDbType = 'postgresql'
const mod = await import('../../../../src/main/services/validation/validation-database') const mod = await import('../../../../src/main/services/validation/validation-database')
expect(mod.getValidationTableName('Materials')).toBe('"public"."Materials"') expect(mod.getValidationTableName('Materials')).toBe('"public"."Materials"')