feat(cleaner-history): record missing orders with production ID tracking

Record ALL input orders in history, including resolution failures (not_found)
and ERP query misses (erp_not_found). Add ProductionId column to track original
总排号 input. Add 总排号 column and new status styles to the history UI. Fix
empty result caching that prevented retry on transient query failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Misaka_Company
2026-04-14 10:08:15 +08:00
parent 95eb44979a
commit 6f49596467
9 changed files with 278 additions and 39 deletions

View File

@@ -23,7 +23,8 @@ import type {
ExportResultItem,
ExportResultResponse
} from '../../types/cleaner.types'
import type { InsertMaterialDetailInput } from '../../types/cleaner-history.types'
import type { InsertMaterialDetailInput, InsertOrderInput } from '../../types/cleaner-history.types'
import type { OrderMapping } from '../../types/order-resolver.types'
const log = createLogger('CleanerApplicationService')
@@ -72,16 +73,16 @@ export class CleanerApplicationService {
log.warn('Resolution warnings', { warnings })
}
if (validOrderNumbers.length === 0) {
throw new ValidationError(
'没有有效的生产订单号可处理。请检查输入的格式或数据库连接。',
'VAL_INVALID_INPUT'
)
}
// Build order inputs from ALL mappings (including resolution failures)
const orderInputs = this.buildOrderInputs(mappings)
log.info('Resolved order numbers', { count: validOrderNumbers.length })
log.info('Resolved order numbers', {
total: mappings.length,
resolved: validOrderNumbers.length,
failed: mappings.length - validOrderNumbers.length
})
// Insert execution record and order records into database
// Insert execution record and ALL order records into database
const currentUser = SessionManager.getInstance().getUserInfo()
if (currentUser) {
await historyDao.insertExecution({
@@ -90,14 +91,41 @@ export class CleanerApplicationService {
userId: currentUser.id,
username: currentUser.username,
isDryRun: input.dryRun ?? false,
totalOrders: validOrderNumbers.length,
totalOrders: orderInputs.length,
appVersion
})
await historyDao.insertOrderRecords(
batchId,
1,
validOrderNumbers.map((order) => ({ orderNumber: order }))
)
await historyDao.insertOrderRecords(batchId, 1, orderInputs)
}
// If no valid order numbers, update execution to failed and return early
if (validOrderNumbers.length === 0) {
if (currentUser) {
await historyDao.updateExecutionStatus(
batchId,
1,
'failed',
0,
0,
0,
0,
0,
new Date(),
warnings.join('\n') || '没有有效的生产订单号可处理'
)
}
const emptyResult: CleanerResult = {
ordersProcessed: 0,
materialsDeleted: 0,
materialsSkipped: 0,
errors: [...warnings],
details: [],
retriedOrders: 0,
successfulRetries: 0,
materialsFailed: 0,
uncertainDeletions: 0
}
await this.recordCleanupAudit(0, input, emptyResult)
return emptyResult
}
authService = new ErpAuthService({
@@ -203,14 +231,10 @@ export class CleanerApplicationService {
userId: currentUser.id,
username: currentUser.username,
isDryRun: input.dryRun ?? false,
totalOrders: validOrderNumbers.length,
totalOrders: orderInputs.length,
appVersion
})
await historyDao.insertOrderRecords(
batchId,
2,
validOrderNumbers.map((order) => ({ orderNumber: order }))
)
await historyDao.insertOrderRecords(batchId, 2, orderInputs)
}
cleaner = new CleanerService(authService)
@@ -378,6 +402,38 @@ export class CleanerApplicationService {
}
}
/**
* Build order inputs from ALL mappings, including resolution failures.
* Deduplicates by orderNumber for resolved mappings, includes all failed mappings.
*/
private buildOrderInputs(mappings: OrderMapping[]): InsertOrderInput[] {
const inputs: InsertOrderInput[] = []
const seenOrderNumbers = new Set<string>()
for (const mapping of mappings) {
if (mapping.resolved && mapping.orderNumber) {
// Deduplicate resolved mappings by order number
if (!seenOrderNumbers.has(mapping.orderNumber)) {
seenOrderNumbers.add(mapping.orderNumber)
inputs.push({
orderNumber: mapping.orderNumber,
productionId: mapping.productionId
})
}
} else {
// Resolution failure: use original input as orderNumber identifier
inputs.push({
orderNumber: mapping.input,
productionId: mapping.productionId,
initialStatus: 'not_found',
errorMessage: mapping.error || '未在数据库中找到对应的订单号'
})
}
}
return inputs
}
private async recordCleanupAudit(
orderCount: number,
input: CleanerInput,
@@ -428,7 +484,11 @@ export class CleanerApplicationService {
batchId,
attemptNumber,
detail.orderNumber,
detail.errors.length > 0 ? 'failed' : 'success',
detail.notFound
? 'erp_not_found'
: detail.errors.length > 0
? 'failed'
: 'success',
detail.materialsDeleted,
detail.materialsSkipped,
detail.materialsFailed,

View File

@@ -65,6 +65,7 @@ export const CLEANER_ORDER_CONFIG = {
BATCH_ID: 'BatchId',
ATTEMPT_NUMBER: 'AttemptNumber',
ORDER_NUMBER: 'OrderNumber',
PRODUCTION_ID: 'ProductionId',
STATUS: 'Status',
MATERIALS_DELETED: 'MaterialsDeleted',
MATERIALS_SKIPPED: 'MaterialsSkipped',
@@ -324,18 +325,26 @@ export class CleanerOperationHistoryDAO {
for (const order of orders) {
try {
const status = order.initialStatus || 'pending'
const sqlString = `
INSERT INTO ${tableName}
(BatchId, AttemptNumber, OrderNumber, Status,
(BatchId, AttemptNumber, OrderNumber, ProductionId, Status,
MaterialsDeleted, MaterialsSkipped, MaterialsFailed,
UncertainDeletions, RetryCount, RetrySuccess, ErrorMessage)
VALUES
(${dialect.param(0)}, ${dialect.param(1)}, ${dialect.param(2)}, 'pending',
0, 0, 0, 0, 0, 0, NULL)
(${dialect.param(0)}, ${dialect.param(1)}, ${dialect.param(2)}, ${dialect.param(3)}, ${dialect.param(4)},
0, 0, 0, 0, 0, 0, ${dialect.param(5)})
`
await trackDuration(
async () =>
await dbService.query(sqlString, [batchId, attemptNumber, order.orderNumber]),
await dbService.query(sqlString, [
batchId,
attemptNumber,
order.orderNumber,
order.productionId || null,
status,
order.errorMessage || null
]),
{
operationName: 'CleanerOperationHistoryDAO.insertOrderRecords',
context: { tableName, operationType: 'INSERT', batchId, attemptNumber }
@@ -730,7 +739,7 @@ export class CleanerOperationHistoryDAO {
// Query orders
const orderSql = `
SELECT
ID, BatchId, AttemptNumber, OrderNumber, Status,
ID, BatchId, AttemptNumber, OrderNumber, ProductionId, Status,
MaterialsDeleted, MaterialsSkipped, MaterialsFailed,
UncertainDeletions, RetryCount, RetrySuccess, ErrorMessage
FROM ${orderTable}
@@ -778,6 +787,7 @@ export class CleanerOperationHistoryDAO {
batchId: row.BatchId as string,
attemptNumber: row.AttemptNumber as number,
orderNumber: row.OrderNumber as string,
productionId: (row.ProductionId as string) || null,
status: row.Status as string,
materialsDeleted: row.MaterialsDeleted as number,
materialsSkipped: row.MaterialsSkipped as number,

View File

@@ -307,7 +307,7 @@ export class CleanerService {
for (const missingOrder of missingOrders) {
const missingMessage = '订单未出现在查询结果中'
result.errors.push(`Order ${missingOrder}: ${missingMessage}`)
result.details.push(this.createErrorDetail(missingOrder, missingMessage))
result.details.push(this.createErrorDetail(missingOrder, missingMessage, true))
}
},
{
@@ -1115,7 +1115,7 @@ export class CleanerService {
return /^SC\d{14}$/.test(value)
}
private createErrorDetail(orderNumber: string, message: string): OrderCleanDetail {
private createErrorDetail(orderNumber: string, message: string, notFound: boolean = false): OrderCleanDetail {
return {
orderNumber,
materialsDeleted: 0,
@@ -1129,7 +1129,8 @@ export class CleanerService {
retrySuccess: false,
materialsFailed: 0,
failedMaterials: [],
uncertainDeletions: 0
uncertainDeletions: 0,
notFound
}
}

View File

@@ -0,0 +1,142 @@
/**
* Migration Script: Add ProductionId column to CleanerOrderHistory table
*
* This script adds the ProductionId column to the ERPAuto.CleanerOrderHistory
* table for tracking the original production ID (总排号) input.
*
* Usage:
* npx tsx src/main/services/user/migration/add-productionid-to-cleaner-order.ts
*/
import { ConfigManager } from '../../config/config-manager'
import { MySqlService } from '../../database/mysql'
import { SqlServerService } from '../../database/sql-server'
import { PostgreSqlService } from '../../database/postgresql'
import { createLogger } from '../../logger'
const log = createLogger('Migration')
async function runMySQLMigration(): Promise<void> {
const configManager = ConfigManager.getInstance()
await configManager.initialize()
const dbConfig = configManager.getConfig().database.mysql
const service = new MySqlService({
host: dbConfig.host,
port: dbConfig.port,
user: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database
})
try {
await service.connect()
console.log('Connected to MySQL')
// Check if column already exists
const check = await service.query(
`SELECT COUNT(*) as cnt FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'CleanerOrderHistory' AND COLUMN_NAME = 'ProductionId'`
)
if ((check.rows[0]?.cnt as number) > 0) {
console.log('Column ProductionId already exists, skipping.')
return
}
await service.query(
`ALTER TABLE CleanerOrderHistory ADD COLUMN ProductionId VARCHAR(50) NULL`
)
console.log('Added ProductionId column to CleanerOrderHistory.')
} finally {
if (service.isConnected()) await service.disconnect()
}
}
async function runSqlServerMigration(): Promise<void> {
const configManager = ConfigManager.getInstance()
await configManager.initialize()
const dbConfig = configManager.getConfig().database.sqlserver
const service = new SqlServerService({
server: dbConfig.server,
port: dbConfig.port,
user: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database,
options: { trustServerCertificate: dbConfig.trustServerCertificate }
})
try {
await service.connect()
console.log('Connected to SQL Server')
// Check if column already exists
const check = await service.query(
`SELECT COUNT(*) as cnt FROM sys.columns WHERE OBJECT_ID = OBJECT_ID('ERPAuto.CleanerOrderHistory') AND name = 'ProductionId'`
)
if ((check.rows[0]?.cnt as number) > 0) {
console.log('Column ProductionId already exists, skipping.')
return
}
await service.query(
`ALTER TABLE [ERPAuto].[CleanerOrderHistory] ADD ProductionId NVARCHAR(50) NULL`
)
console.log('Added ProductionId column to CleanerOrderHistory.')
} finally {
if (service.isConnected()) await service.disconnect()
}
}
async function runPostgreSqlMigration(): Promise<void> {
const configManager = ConfigManager.getInstance()
await configManager.initialize()
const dbConfig = configManager.getConfig().database.postgresql
const service = new PostgreSqlService({
host: dbConfig.host,
port: dbConfig.port,
user: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database
})
try {
await service.connect()
console.log('Connected to PostgreSQL')
await service.query(
`ALTER TABLE "ERPAuto"."CleanerOrderHistory" ADD COLUMN IF NOT EXISTS "ProductionId" VARCHAR(50) NULL`
)
console.log('Added ProductionId column to CleanerOrderHistory.')
} finally {
if (service.isConnected()) await service.disconnect()
}
}
async function main(): Promise<void> {
console.log('Migration: Add ProductionId to CleanerOrderHistory')
const configManager = ConfigManager.getInstance()
await configManager.initialize()
const dbType = configManager.getDatabaseType()
console.log(`Database type: ${dbType}`)
if (dbType === 'mysql') {
await runMySQLMigration()
} else if (dbType === 'sqlserver') {
await runSqlServerMigration()
} else if (dbType === 'postgresql') {
await runPostgreSqlMigration()
} else {
console.error(`Unsupported database type: ${dbType}`)
process.exit(1)
}
console.log('Done.')
}
main().catch((err) => {
console.error('Migration failed:', err)
process.exit(1)
})