Compare commits
7 Commits
v1.14.0
...
f36c88aa89
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f36c88aa89 | ||
|
|
dbb8e4904e | ||
|
|
1ffa0650a6 | ||
|
|
72dba32a52 | ||
|
|
98865f5d7e | ||
|
|
5ff99cdd0f | ||
|
|
664f26d63f |
@@ -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}"`
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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
10
docs/releases/1.14.1.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# 1.14.1
|
||||||
|
|
||||||
|
## 问题修复
|
||||||
|
|
||||||
|
- 修复清理任务操作历史中「总排号」始终为空的问题,原始生产编号现在能正确保留并显示在历史记录中。
|
||||||
|
- 修复会话重建配置缺失时清理任务可能异常中断的问题,提升配置不完整场景下的运行稳定性。
|
||||||
|
|
||||||
|
## 改进
|
||||||
|
|
||||||
|
- 表名配置统一使用 `schema.tablename` 标准点分写法,与数据库标准格式保持一致。
|
||||||
@@ -180,7 +180,7 @@ validation:
|
|||||||
|
|
||||||
# 订单号解析配置
|
# 订单号解析配置
|
||||||
orderResolution:
|
orderResolution:
|
||||||
tableName: 'productionContractData_26 年压力表合同数据'
|
tableName: 'ERPAuto.vw_productionContractData'
|
||||||
productionIdField: '总排号'
|
productionIdField: '总排号'
|
||||||
orderNumberField: '生产订单号'
|
orderNumberField: '生产订单号'
|
||||||
```
|
```
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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}"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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"')
|
||||||
|
|||||||
Reference in New Issue
Block a user