feat(cleaner): add CleanerOperationHistoryDAO for three-table persistence

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Misaka_Company
2026-04-13 11:29:54 +08:00
parent b363a53d8a
commit a924e8a4e8

View File

@@ -0,0 +1,983 @@
/**
* Data Access Object for Cleaner operation history (three-table design)
*
* Handles database operations for tracking cleaner operation history across:
* - CleanerExecution: execution-level records (one per attempt)
* - CleanerOrderHistory: order-level records (one per order per attempt)
* - CleanerMaterialDetail: material-level records (one per material per order per attempt)
*/
import { create, type IDatabaseService } from './index'
import { createDialect, type SqlDialect } from './dialects'
import { createLogger, getRequestId, trackDuration } from '../logger'
import type {
CleanerExecutionRecord,
CleanerOrderRecord,
CleanerMaterialRecord,
CleanerBatchStats,
InsertCleanerExecutionInput,
InsertOrderInput,
InsertMaterialDetailInput,
GetCleanerBatchesOptions
} from '../../types/cleaner-history.types'
const log = createLogger('CleanerOperationHistoryDAO')
/**
* Format datetime value from database to ISO string
* mssql driver returns Date objects in UTC format
*/
function formatDateTime(value: unknown): string {
if (value instanceof Date) {
return value.toISOString()
}
return value ? String(value) : new Date().toISOString()
}
/**
* Configuration constants for all three Cleaner history tables
*/
export const CLEANER_EXECUTION_CONFIG = {
COLUMNS: {
ID: 'ID',
BATCH_ID: 'BatchId',
ATTEMPT_NUMBER: 'AttemptNumber',
USER_ID: 'UserId',
USERNAME: 'Username',
OPERATION_TIME: 'OperationTime',
END_TIME: 'EndTime',
STATUS: 'Status',
IS_DRY_RUN: 'IsDryRun',
TOTAL_ORDERS: 'TotalOrders',
ORDERS_PROCESSED: 'OrdersProcessed',
TOTAL_MATERIALS_DELETED: 'TotalMaterialsDeleted',
TOTAL_MATERIALS_SKIPPED: 'TotalMaterialsSkipped',
TOTAL_MATERIALS_FAILED: 'TotalMaterialsFailed',
TOTAL_UNCERTAIN_DELETIONS: 'TotalUncertainDeletions',
ERROR_MESSAGE: 'ErrorMessage',
APP_VERSION: 'AppVersion'
}
} as const
export const CLEANER_ORDER_CONFIG = {
COLUMNS: {
ID: 'ID',
BATCH_ID: 'BatchId',
ATTEMPT_NUMBER: 'AttemptNumber',
ORDER_NUMBER: 'OrderNumber',
STATUS: 'Status',
MATERIALS_DELETED: 'MaterialsDeleted',
MATERIALS_SKIPPED: 'MaterialsSkipped',
MATERIALS_FAILED: 'MaterialsFailed',
UNCERTAIN_DELETIONS: 'UncertainDeletions',
RETRY_COUNT: 'RetryCount',
RETRY_SUCCESS: 'RetrySuccess',
ERROR_MESSAGE: 'ErrorMessage'
}
} as const
export const CLEANER_MATERIAL_CONFIG = {
COLUMNS: {
ID: 'ID',
BATCH_ID: 'BatchId',
ATTEMPT_NUMBER: 'AttemptNumber',
ORDER_NUMBER: 'OrderNumber',
MATERIAL_CODE: 'MaterialCode',
MATERIAL_NAME: 'MaterialName',
ROW_NUMBER: 'RowNumber',
RESULT: 'Result',
REASON: 'Reason',
ATTEMPT_COUNT: 'AttemptCount',
FINAL_ERROR_CATEGORY: 'FinalErrorCategory'
}
} as const
/**
* CleanerOperationHistory DAO Class
*/
export class CleanerOperationHistoryDAO {
private dbService: IDatabaseService | null = null
private dialect: SqlDialect | null = null
private getDialect(): SqlDialect {
if (!this.dialect) {
this.dialect = createDialect(this.dbService!.type)
}
return this.dialect
}
/**
* Get the appropriate table name based on database type
*/
private getExecutionTableName(): string {
return this.getDialect().quoteTableName('ERPAuto', 'CleanerExecution')
}
private getOrderTableName(): string {
return this.getDialect().quoteTableName('ERPAuto', 'CleanerOrderHistory')
}
private getMaterialTableName(): string {
return this.getDialect().quoteTableName('ERPAuto', 'CleanerMaterialDetail')
}
/**
* Get database service instance using DatabaseFactory
*/
private async getDatabaseService(): Promise<IDatabaseService> {
if (this.dbService && this.dbService.isConnected()) {
return this.dbService
}
this.dbService = await create()
return this.dbService
}
// ==================== EXECUTION INSERT ====================
/**
* Insert a new execution record
* @param input - Execution input data
* @returns True if successful
*/
async insertExecution(input: InsertCleanerExecutionInput): Promise<boolean> {
const requestId = getRequestId() || `insert-exec-${Date.now()}`
try {
const dbService = await this.getDatabaseService()
const tableName = this.getExecutionTableName()
const dialect = this.getDialect()
log.info('Execution insertion started', {
tableName,
operationType: 'INSERT',
requestId,
batchId: input.batchId,
attemptNumber: input.attemptNumber,
userId: input.userId,
username: input.username
})
const sqlString = `
INSERT INTO ${tableName}
(BatchId, AttemptNumber, UserId, Username, OperationTime, Status, IsDryRun,
TotalOrders, OrdersProcessed, TotalMaterialsDeleted, TotalMaterialsSkipped,
TotalMaterialsFailed, TotalUncertainDeletions, ErrorMessage, AppVersion)
VALUES
(${dialect.param(0)}, ${dialect.param(1)}, ${dialect.param(2)}, ${dialect.param(3)},
${dialect.currentTimestamp()}, 'pending', ${dialect.param(4)},
${dialect.param(5)}, 0, 0, 0, 0, 0, NULL, ${dialect.param(6)})
`
const params = [
input.batchId,
input.attemptNumber,
input.userId,
input.username,
input.isDryRun ? 1 : 0,
input.totalOrders,
input.appVersion
]
await trackDuration(async () => await dbService.query(sqlString, params), {
operationName: 'CleanerOperationHistoryDAO.insertExecution',
context: { tableName, operationType: 'INSERT', batchId: input.batchId }
})
log.info('Execution inserted', {
tableName,
operationType: 'INSERT',
requestId,
batchId: input.batchId,
attemptNumber: input.attemptNumber
})
return true
} catch (error) {
log.error('Insert execution error', {
tableName: this.getExecutionTableName(),
operationType: 'INSERT',
requestId,
batchId: input.batchId,
error: error instanceof Error ? error.message : String(error)
})
return false
}
}
// ==================== EXECUTION UPDATE ====================
/**
* Update execution status and counters
*/
async updateExecutionStatus(
batchId: string,
attemptNumber: number,
status: string,
ordersProcessed: number,
materialsDeleted: number,
materialsSkipped: number,
materialsFailed: number,
uncertainDeletions: number,
endTime: Date,
errorMessage?: string
): Promise<boolean> {
const requestId = getRequestId() || `update-exec-${Date.now()}`
try {
const dbService = await this.getDatabaseService()
const tableName = this.getExecutionTableName()
const dialect = this.getDialect()
const sqlString = `
UPDATE ${tableName}
SET Status = ${dialect.param(0)},
OrdersProcessed = ${dialect.param(1)},
TotalMaterialsDeleted = ${dialect.param(2)},
TotalMaterialsSkipped = ${dialect.param(3)},
TotalMaterialsFailed = ${dialect.param(4)},
TotalUncertainDeletions = ${dialect.param(5)},
EndTime = ${dialect.param(6)},
ErrorMessage = ${dialect.param(7)}
WHERE BatchId = ${dialect.param(8)}
AND AttemptNumber = ${dialect.param(9)}
`
const params = [
status,
ordersProcessed,
materialsDeleted,
materialsSkipped,
materialsFailed,
uncertainDeletions,
endTime,
errorMessage || null,
batchId,
attemptNumber
]
await trackDuration(async () => await dbService.query(sqlString, params), {
operationName: 'CleanerOperationHistoryDAO.updateExecutionStatus',
context: { tableName, operationType: 'UPDATE', batchId, attemptNumber }
})
log.info('Execution status updated', {
tableName,
operationType: 'UPDATE',
requestId,
batchId,
attemptNumber,
status
})
return true
} catch (error) {
log.error('Update execution status error', {
tableName: this.getExecutionTableName(),
operationType: 'UPDATE',
requestId,
batchId,
attemptNumber,
error: error instanceof Error ? error.message : String(error)
})
return false
}
}
// ==================== ORDER INSERT ====================
/**
* Insert order records for a specific execution
* @param batchId - Batch identifier
* @param attemptNumber - Attempt number
* @param orders - Array of order inputs
* @returns True if successful
*/
async insertOrderRecords(
batchId: string,
attemptNumber: number,
orders: InsertOrderInput[]
): Promise<boolean> {
if (!orders || orders.length === 0) {
log.warn('No orders to insert', {
batchId,
attemptNumber,
tableName: this.getOrderTableName(),
requestId: getRequestId()
})
return false
}
const requestId = getRequestId() || `insert-orders-${Date.now()}`
try {
const dbService = await this.getDatabaseService()
const tableName = this.getOrderTableName()
const dialect = this.getDialect()
log.info('Order records insertion started', {
tableName,
operationType: 'INSERT',
requestId,
batchId,
attemptNumber,
orderCount: orders.length
})
for (const order of orders) {
try {
const sqlString = `
INSERT INTO ${tableName}
(BatchId, AttemptNumber, OrderNumber, 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)
`
await trackDuration(
async () =>
await dbService.query(sqlString, [batchId, attemptNumber, order.orderNumber]),
{
operationName: 'CleanerOperationHistoryDAO.insertOrderRecords',
context: { tableName, operationType: 'INSERT', batchId, attemptNumber }
}
)
} catch (error) {
log.error('Error inserting individual order record', {
tableName,
operationType: 'INSERT',
requestId,
batchId,
attemptNumber,
orderNumber: order.orderNumber,
error: error instanceof Error ? error.message : String(error)
})
}
}
log.info('Order records inserted', {
tableName,
operationType: 'INSERT',
requestId,
batchId,
attemptNumber,
count: orders.length
})
return true
} catch (error) {
log.error('Insert order records error', {
tableName: this.getOrderTableName(),
operationType: 'INSERT',
requestId,
batchId,
attemptNumber,
orderCount: orders.length,
error: error instanceof Error ? error.message : String(error)
})
return false
}
}
// ==================== ORDER UPDATE ====================
/**
* Update order status and counters
*/
async updateOrderStatus(
batchId: string,
attemptNumber: number,
orderNumber: string,
status: string,
materialsDeleted: number,
materialsSkipped: number,
materialsFailed: number,
uncertainDeletions: number,
retryCount: number,
retrySuccess: boolean,
errorMessage?: string
): Promise<boolean> {
const requestId = getRequestId() || `update-order-${Date.now()}`
try {
const dbService = await this.getDatabaseService()
const tableName = this.getOrderTableName()
const dialect = this.getDialect()
const sqlString = `
UPDATE ${tableName}
SET Status = ${dialect.param(0)},
MaterialsDeleted = ${dialect.param(1)},
MaterialsSkipped = ${dialect.param(2)},
MaterialsFailed = ${dialect.param(3)},
UncertainDeletions = ${dialect.param(4)},
RetryCount = ${dialect.param(5)},
RetrySuccess = ${dialect.param(6)},
ErrorMessage = ${dialect.param(7)}
WHERE BatchId = ${dialect.param(8)}
AND AttemptNumber = ${dialect.param(9)}
AND OrderNumber = ${dialect.param(10)}
`
const params = [
status,
materialsDeleted,
materialsSkipped,
materialsFailed,
uncertainDeletions,
retryCount,
retrySuccess ? 1 : 0,
errorMessage || null,
batchId,
attemptNumber,
orderNumber
]
await trackDuration(async () => await dbService.query(sqlString, params), {
operationName: 'CleanerOperationHistoryDAO.updateOrderStatus',
context: {
tableName,
operationType: 'UPDATE',
batchId,
attemptNumber,
orderNumber
}
})
log.info('Order status updated', {
tableName,
operationType: 'UPDATE',
requestId,
batchId,
attemptNumber,
orderNumber,
status
})
return true
} catch (error) {
log.error('Update order status error', {
tableName: this.getOrderTableName(),
operationType: 'UPDATE',
requestId,
batchId,
attemptNumber,
orderNumber,
error: error instanceof Error ? error.message : String(error)
})
return false
}
}
// ==================== MATERIAL INSERT ====================
/**
* Insert material detail records for a specific order
* @param batchId - Batch identifier
* @param attemptNumber - Attempt number
* @param details - Array of material detail inputs
* @returns True if successful
*/
async insertMaterialDetails(
batchId: string,
attemptNumber: number,
details: InsertMaterialDetailInput[]
): Promise<boolean> {
if (!details || details.length === 0) {
log.warn('No material details to insert', {
batchId,
attemptNumber,
tableName: this.getMaterialTableName(),
requestId: getRequestId()
})
return false
}
const requestId = getRequestId() || `insert-materials-${Date.now()}`
try {
const dbService = await this.getDatabaseService()
const tableName = this.getMaterialTableName()
const dialect = this.getDialect()
log.info('Material details insertion started', {
tableName,
operationType: 'INSERT',
requestId,
batchId,
attemptNumber,
detailCount: details.length
})
for (const detail of details) {
try {
const sqlString = `
INSERT INTO ${tableName}
(BatchId, AttemptNumber, OrderNumber, MaterialCode, MaterialName,
RowNumber, Result, Reason, AttemptCount, FinalErrorCategory)
VALUES
(${dialect.param(0)}, ${dialect.param(1)}, ${dialect.param(2)},
${dialect.param(3)}, ${dialect.param(4)}, ${dialect.param(5)},
${dialect.param(6)}, ${dialect.param(7)}, ${dialect.param(8)},
${dialect.param(9)})
`
await trackDuration(
async () =>
await dbService.query(sqlString, [
batchId,
attemptNumber,
detail.orderNumber,
detail.materialCode,
detail.materialName,
detail.rowNumber,
detail.result,
detail.reason || null,
detail.attemptCount,
detail.finalErrorCategory || null
]),
{
operationName: 'CleanerOperationHistoryDAO.insertMaterialDetails',
context: {
tableName,
operationType: 'INSERT',
batchId,
attemptNumber,
materialCode: detail.materialCode
}
}
)
} catch (error) {
log.error('Error inserting individual material detail', {
tableName,
operationType: 'INSERT',
requestId,
batchId,
attemptNumber,
orderNumber: detail.orderNumber,
materialCode: detail.materialCode,
error: error instanceof Error ? error.message : String(error)
})
}
}
log.info('Material details inserted', {
tableName,
operationType: 'INSERT',
requestId,
batchId,
attemptNumber,
count: details.length
})
return true
} catch (error) {
log.error('Insert material details error', {
tableName: this.getMaterialTableName(),
operationType: 'INSERT',
requestId,
batchId,
attemptNumber,
detailCount: details.length,
error: error instanceof Error ? error.message : String(error)
})
return false
}
}
// ==================== QUERY: GET BATCHES ====================
/**
* Get batch statistics with optional user filtering
* @param userId - Optional user ID for filtering
* @param options - Query options (limit, offset, usernames)
* @returns Array of batch statistics
*/
async getBatches(
userId?: number,
options?: GetCleanerBatchesOptions
): Promise<CleanerBatchStats[]> {
try {
const dbService = await this.getDatabaseService()
const execTable = this.getExecutionTableName()
const orderTable = this.getOrderTableName()
const dialect = this.getDialect()
let sqlString = `
SELECT
e.BatchId,
e.UserId,
e.Username,
MIN(e.OperationTime) as OperationTime,
MAX(CASE WHEN e.AttemptNumber = latest.max_attempt THEN e.Status ELSE NULL END) as Status,
COUNT(DISTINCT e.AttemptNumber) as TotalAttempts,
MAX(e.TotalOrders) as TotalOrders,
MAX(CASE WHEN e.AttemptNumber = latest.max_attempt THEN e.OrdersProcessed ELSE 0 END) as OrdersProcessed,
MAX(CASE WHEN e.AttemptNumber = latest.max_attempt THEN e.TotalMaterialsDeleted ELSE 0 END) as TotalMaterialsDeleted,
MAX(CASE WHEN e.AttemptNumber = latest.max_attempt THEN e.TotalMaterialsFailed ELSE 0 END) as TotalMaterialsFailed,
MAX(e.IsDryRun) as IsDryRun,
ISNULL(SUM(CASE WHEN o.Status = 'success' THEN 1 ELSE 0 END), 0) as SuccessCount,
ISNULL(SUM(CASE WHEN o.Status = 'failed' THEN 1 ELSE 0 END), 0) as FailedCount
FROM ${execTable} e
INNER JOIN (
SELECT BatchId, MAX(AttemptNumber) as max_attempt
FROM ${execTable}
GROUP BY BatchId
) latest ON e.BatchId = latest.BatchId
LEFT JOIN ${orderTable} o ON e.BatchId = o.BatchId AND e.AttemptNumber = latest.max_attempt
`
const params: (number | string)[] = []
if (userId !== undefined) {
sqlString += ` WHERE e.UserId = ${dialect.param(params.length)} `
params.push(userId)
} else if (options?.usernames && options.usernames.length > 0) {
sqlString += ` WHERE e.Username IN (${dialect.params(options.usernames.length)}) `
params.push(...options.usernames)
}
sqlString += `
GROUP BY e.BatchId, e.UserId, e.Username, latest.max_attempt
ORDER BY OperationTime DESC
`
if (options?.limit) {
const safeLimit = Math.floor(options.limit)
const safeOffset = options.offset !== undefined ? Math.floor(options.offset) : undefined
const result = dialect.paginate({
sql: sqlString,
limit: safeLimit,
offset: safeOffset,
paramIndex: params.length
})
sqlString = result.sql
if (dialect.dbType === 'sqlserver') {
if (safeOffset !== undefined) {
params.push(safeOffset)
}
params.push(safeLimit)
}
}
const result = await trackDuration(async () => await dbService.query(sqlString, params), {
operationName: 'CleanerOperationHistoryDAO.getBatches',
context: { operationType: 'SELECT', userId }
})
return result.result.rows.map((row) => ({
batchId: row.BatchId as string,
userId: row.UserId as number,
username: row.Username as string,
operationTime: formatDateTime(row.OperationTime),
status: row.Status as string,
totalAttempts: row.TotalAttempts as number,
totalOrders: (row.TotalOrders as number) || 0,
ordersProcessed: (row.OrdersProcessed as number) || 0,
totalMaterialsDeleted: (row.TotalMaterialsDeleted as number) || 0,
totalMaterialsFailed: (row.TotalMaterialsFailed as number) || 0,
successCount: (row.SuccessCount as number) || 0,
failedCount: (row.FailedCount as number) || 0,
isDryRun: !!(row.IsDryRun as number)
}))
} catch (error) {
log.error('Get batches error', {
operationType: 'SELECT',
requestId: getRequestId(),
userId,
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
// ==================== QUERY: GET BATCH DETAILS ====================
/**
* Get detailed records for a specific batch
* @param batchId - Batch identifier
* @returns Object with executions and orders arrays
*/
async getBatchDetails(
batchId: string
): Promise<{ executions: CleanerExecutionRecord[]; orders: CleanerOrderRecord[] }> {
const requestId = getRequestId() || `details-${Date.now()}`
try {
const dbService = await this.getDatabaseService()
const execTable = this.getExecutionTableName()
const orderTable = this.getOrderTableName()
const dialect = this.getDialect()
// Query executions
const execSql = `
SELECT
ID, BatchId, AttemptNumber, UserId, Username,
OperationTime, EndTime, Status, IsDryRun,
TotalOrders, OrdersProcessed,
TotalMaterialsDeleted, TotalMaterialsSkipped, TotalMaterialsFailed,
TotalUncertainDeletions, ErrorMessage, AppVersion
FROM ${execTable}
WHERE BatchId = ${dialect.param(0)}
ORDER BY AttemptNumber
`
const execResult = await trackDuration(
async () => await dbService.query(execSql, [batchId]),
{
operationName: 'CleanerOperationHistoryDAO.getBatchDetails.executions',
context: { operationType: 'SELECT', batchId }
}
)
// Query orders
const orderSql = `
SELECT
ID, BatchId, AttemptNumber, OrderNumber, Status,
MaterialsDeleted, MaterialsSkipped, MaterialsFailed,
UncertainDeletions, RetryCount, RetrySuccess, ErrorMessage
FROM ${orderTable}
WHERE BatchId = ${dialect.param(0)}
ORDER BY AttemptNumber, ID
`
const orderResult = await trackDuration(
async () => await dbService.query(orderSql, [batchId]),
{
operationName: 'CleanerOperationHistoryDAO.getBatchDetails.orders',
context: { operationType: 'SELECT', batchId }
}
)
const executions: CleanerExecutionRecord[] = execResult.result.rows.map((row) => ({
id: row.ID as number,
batchId: row.BatchId as string,
attemptNumber: row.AttemptNumber as number,
userId: row.UserId as number,
username: row.Username as string,
operationTime: row.OperationTime instanceof Date ? row.OperationTime : new Date(row.OperationTime as string),
endTime: row.EndTime ? (row.EndTime instanceof Date ? row.EndTime : new Date(row.EndTime as string)) : null,
status: row.Status as string,
isDryRun: !!(row.IsDryRun as number),
totalOrders: row.TotalOrders as number,
ordersProcessed: row.OrdersProcessed as number,
totalMaterialsDeleted: row.TotalMaterialsDeleted as number,
totalMaterialsSkipped: row.TotalMaterialsSkipped as number,
totalMaterialsFailed: row.TotalMaterialsFailed as number,
totalUncertainDeletions: row.TotalUncertainDeletions as number,
errorMessage: row.ErrorMessage as string | null,
appVersion: row.AppVersion as string | null
}))
const orders: CleanerOrderRecord[] = orderResult.result.rows.map((row) => ({
id: row.ID as number,
batchId: row.BatchId as string,
attemptNumber: row.AttemptNumber as number,
orderNumber: row.OrderNumber as string,
status: row.Status as string,
materialsDeleted: row.MaterialsDeleted as number,
materialsSkipped: row.MaterialsSkipped as number,
materialsFailed: row.MaterialsFailed as number,
uncertainDeletions: row.UncertainDeletions as number,
retryCount: row.RetryCount as number,
retrySuccess: !!(row.RetrySuccess as number),
errorMessage: row.ErrorMessage as string | null
}))
log.info('Batch details retrieved', {
operationType: 'SELECT',
requestId,
batchId,
executionCount: executions.length,
orderCount: orders.length
})
return { executions, orders }
} catch (error) {
log.error('Get batch details error', {
operationType: 'SELECT',
requestId,
batchId,
error: error instanceof Error ? error.message : String(error)
})
return { executions: [], orders: [] }
}
}
// ==================== QUERY: GET MATERIAL DETAILS ====================
/**
* Get material details for a specific order in an attempt
* @param batchId - Batch identifier
* @param attemptNumber - Attempt number
* @param orderNumber - Order number
* @returns Array of material records
*/
async getMaterialDetails(
batchId: string,
attemptNumber: number,
orderNumber: string
): Promise<CleanerMaterialRecord[]> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getMaterialTableName()
const dialect = this.getDialect()
const sqlString = `
SELECT
ID, BatchId, AttemptNumber, OrderNumber,
MaterialCode, MaterialName, RowNumber,
Result, Reason, AttemptCount, FinalErrorCategory
FROM ${tableName}
WHERE BatchId = ${dialect.param(0)}
AND AttemptNumber = ${dialect.param(1)}
AND OrderNumber = ${dialect.param(2)}
`
const result = await trackDuration(
async () => await dbService.query(sqlString, [batchId, attemptNumber, orderNumber]),
{
operationName: 'CleanerOperationHistoryDAO.getMaterialDetails',
context: { operationType: 'SELECT', batchId, attemptNumber, orderNumber }
}
)
return result.result.rows.map((row) => ({
id: row.ID as number,
batchId: row.BatchId as string,
attemptNumber: row.AttemptNumber as number,
orderNumber: row.OrderNumber as string,
materialCode: row.MaterialCode as string,
materialName: row.MaterialName as string,
rowNumber: row.RowNumber as number,
result: row.Result as string,
reason: row.Reason as string | null,
attemptCount: row.AttemptCount as number,
finalErrorCategory: row.FinalErrorCategory as string | null
}))
} catch (error) {
log.error('Get material details error', {
tableName: this.getMaterialTableName(),
operationType: 'SELECT',
requestId: getRequestId(),
batchId,
attemptNumber,
orderNumber,
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
// ==================== DELETE ====================
/**
* Delete a batch with permission checking across all three tables
* @param batchId - Batch identifier
* @param requestingUserId - User ID requesting the deletion
* @param isAdmin - Whether the requesting user is an admin
* @returns Result with success flag and optional error message
*/
async deleteBatch(
batchId: string,
requestingUserId: number,
isAdmin: boolean
): Promise<{ success: boolean; error?: string }> {
const requestId = getRequestId() || `delete-${Date.now()}`
try {
const dbService = await this.getDatabaseService()
const execTable = this.getExecutionTableName()
const orderTable = this.getOrderTableName()
const materialTable = this.getMaterialTableName()
const dialect = this.getDialect()
// Check if batch exists
const checkSql = `
SELECT TOP 1 UserId
FROM ${execTable}
WHERE BatchId = ${dialect.param(0)}
`
const checkResult = await trackDuration(
async () => await dbService.query(checkSql, [batchId]),
{
operationName: 'CleanerOperationHistoryDAO.deleteBatch.check',
context: { operationType: 'SELECT', batchId }
}
)
if (checkResult.result.rows.length === 0) {
return { success: false, error: '批次不存在' }
}
// Permission check: non-admin can only delete own batches
const batchUserId = checkResult.result.rows[0].UserId as number
if (!isAdmin && batchUserId !== requestingUserId) {
return { success: false, error: '没有权限删除此批次' }
}
// Delete from CleanerMaterialDetail first (potential FK)
const deleteMaterialSql = `
DELETE FROM ${materialTable}
WHERE BatchId = ${dialect.param(0)}
`
await trackDuration(async () => await dbService.query(deleteMaterialSql, [batchId]), {
operationName: 'CleanerOperationHistoryDAO.deleteBatch.materials',
context: { operationType: 'DELETE', batchId }
})
// Delete from CleanerOrderHistory
const deleteOrderSql = `
DELETE FROM ${orderTable}
WHERE BatchId = ${dialect.param(0)}
`
await trackDuration(async () => await dbService.query(deleteOrderSql, [batchId]), {
operationName: 'CleanerOperationHistoryDAO.deleteBatch.orders',
context: { operationType: 'DELETE', batchId }
})
// Delete from CleanerExecution
const deleteExecSql = `
DELETE FROM ${execTable}
WHERE BatchId = ${dialect.param(0)}
`
const deleteResult = await trackDuration(
async () => await dbService.query(deleteExecSql, [batchId]),
{
operationName: 'CleanerOperationHistoryDAO.deleteBatch.executions',
context: { operationType: 'DELETE', batchId, requestingUserId }
}
)
log.info('Batch deleted', {
operationType: 'DELETE',
requestId,
batchId,
rowCount: deleteResult.result.rowCount
})
return { success: true }
} catch (error) {
log.error('Delete batch error', {
operationType: 'DELETE',
requestId,
batchId,
error: error instanceof Error ? error.message : String(error)
})
return {
success: false,
error: error instanceof Error ? error.message : String(error)
}
}
}
// ==================== UTILITIES ====================
/**
* Disconnect from database
*/
async disconnect(): Promise<void> {
if (this.dbService) {
await this.dbService.disconnect()
this.dbService = null
this.dialect = null
}
}
}