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

View File

@@ -31,6 +31,7 @@ export interface CleanerOrderRecord {
batchId: string
attemptNumber: number
orderNumber: string
productionId: string | null
status: string
materialsDeleted: number
materialsSkipped: number
@@ -87,6 +88,9 @@ export interface InsertCleanerExecutionInput {
/** 插入订单记录的输入 */
export interface InsertOrderInput {
orderNumber: string
productionId?: string
initialStatus?: string
errorMessage?: string
}
/** 插入物料明细的输入 */

View File

@@ -73,6 +73,8 @@ export interface OrderCleanDetail {
materialsFailed: number
failedMaterials: FailedMaterial[]
uncertainDeletions: number
// Missing order flag: true when order not found in ERP query results
notFound?: boolean
}
export enum DeletionOutcome {

View File

@@ -67,7 +67,9 @@ const statusStyles: Record<string, string> = {
partial: 'bg-amber-100 text-amber-700',
failed: 'bg-red-100 text-red-700',
crashed: 'bg-red-100 text-red-700',
pending: 'bg-gray-100 text-gray-700'
pending: 'bg-gray-100 text-gray-700',
not_found: 'bg-orange-100 text-orange-700',
erp_not_found: 'bg-orange-100 text-orange-700'
}
const statusLabels: Record<string, string> = {
@@ -75,7 +77,9 @@ const statusLabels: Record<string, string> = {
partial: '部分成功',
failed: '失败',
crashed: '崩溃',
pending: '进行中'
pending: '进行中',
not_found: '未找到',
erp_not_found: 'ERP不存在'
}
const statusIcons: Record<string, React.ReactNode> = {
@@ -83,7 +87,9 @@ const statusIcons: Record<string, React.ReactNode> = {
partial: <Clock size={16} className="text-amber-600" />,
failed: <XCircle size={16} className="text-red-600" />,
crashed: <XCircle size={16} className="text-red-600" />,
pending: <Clock size={16} className="text-gray-500" />
pending: <Clock size={16} className="text-gray-500" />,
not_found: <XCircle size={16} className="text-orange-600" />,
erp_not_found: <XCircle size={16} className="text-orange-600" />
}
const formatDateTime = (dateStr: string | Date | null | undefined): string => {
@@ -429,6 +435,9 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left font-medium text-gray-600 w-8" />
<th className="px-4 py-2 text-left font-medium text-gray-600">
</th>
<th className="px-4 py-2 text-left font-medium text-gray-600">
<div className="flex items-center gap-2">
@@ -493,7 +502,10 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
)}
</td>
<td className="px-4 py-2 text-gray-900 font-mono text-xs">
{order.orderNumber}
{order.productionId || '-'}
</td>
<td className="px-4 py-2 text-gray-900 font-mono text-xs">
{order.status === 'not_found' ? '-' : order.orderNumber}
</td>
<td className="px-4 py-2">
<span
@@ -549,7 +561,7 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
{/* Material details */}
{isOrderExpanded && (
<tr>
<td colSpan={9} className="bg-gray-50/50 px-8 py-3">
<td colSpan={10} className="bg-gray-50/50 px-8 py-3">
{isLoadingMaterials ? (
<div className="text-xs text-gray-500">
...
@@ -709,6 +721,8 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
}
}, [logger])
useEffect(() => {
if (isOpen) {
void fetchBatches()
@@ -826,6 +840,7 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
)}
</div>
{/* Footer */}
{/* Footer */}
<div className="pt-4 border-t border-gray-200 flex justify-end">
<button

View File

@@ -105,6 +105,7 @@ export interface CleanerHistoryOrderRecord {
batchId: string
attemptNumber: number
orderNumber: string
productionId: string | null
status: string
materialsDeleted: number
materialsSkipped: number

View File

@@ -242,7 +242,7 @@ describe('CleanerApplicationService', () => {
).rejects.toBeInstanceOf(DatabaseQueryError)
})
it('should reject with ValidationError when no valid order numbers are provided', async () => {
it('should return empty result when no valid order numbers are provided', async () => {
;(service as any).getErpConfig = vi
.fn()
.mockResolvedValue({ url: 'u', username: 'x', password: 'p' })
@@ -250,9 +250,13 @@ describe('CleanerApplicationService', () => {
disconnect: vi.fn().mockResolvedValue(undefined)
})
await expect(
service.runCleaner({ send: vi.fn() } as any, makeInput({ orderNumbers: [] }))
).rejects.toThrow('没有有效的生产订单号可处理')
const result = await service.runCleaner(
{ send: vi.fn() } as any,
makeInput({ orderNumbers: [] })
)
expect(result.ordersProcessed).toBe(0)
expect(result.materialsDeleted).toBe(0)
expect(result.details).toEqual([])
})
it('should process orders containing empty strings without crashing', async () => {