7 Commits

Author SHA1 Message Date
Misaka_Company
3cbe9eef12 1.7.1 2026-04-01 08:36:50 +08:00
Misaka_Company
5b310d944b docs: add release notes for version 1.7.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 08:36:09 +08:00
Misaka
6e04f21b10 fix(extractor): track per-order RecordCount in operation history
Previously updateBatchStatus wrote the batch-level total recordCount to
every row, causing the detail view to show misleading identical counts.
Now mergeFiles collects per-order material counts, the handler writes
each order's count individually via updateRecordStatus, and batch
aggregation uses SUM instead of MAX for accurate totals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 20:42:22 +08:00
Misaka
17fbd7d251 fix(extractor): resolve MySQL LIMIT placeholder error in operation history query
MySQL binary protocol prepared statements (connection.execute()) do not
support ? placeholders in LIMIT/OFFSET clauses, causing "Incorrect
arguments to mysqld_stmt_execute". Embed validated integer values directly
for MySQL while keeping parameterized queries for SQL Server.

Also apply React best practices to ExtractorOperationHistoryModal:
- Hoist formatDateTime to module level
- Wrap async handlers with useCallback for stable effect dependencies
- Import shared types instead of duplicating definitions
- Use ternary for conditional rendering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 19:42:44 +08:00
Misaka_Company
c6eb60ada7 1.7.0 2026-03-31 15:28:26 +08:00
Misaka_Company
571ec2325f docs: add release notes for version 1.7.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 15:28:08 +08:00
Misaka_Company
557ed174c3 feat(extractor): add operation history tracking
Add a new operation history feature for the extractor module that tracks
all extraction operations with persistent database storage.

Features:
- Records extraction operations with batch tracking (UUID-based)
- Preserves production ID to order number mapping
- Shows batch statistics (orders, records, success/failure counts)
- Expandable details for each batch showing individual order records
- User-based permission: Admin sees all records, User sees own records only
- Delete functionality with permission validation

Database:
- New ExtractorOperationHistory table schema
- Supports both SQL Server and MySQL
- Indexed on BatchId, UserId, and OperationTime

Files:
- Add DAO class for history operations
- Add IPC handler with permission checks
- Add preload API wrapper
- Add React modal component with expandable batch details
- Integrate history recording into extractor handler
- Add operation history button to ExtractorPage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 15:22:53 +08:00
17 changed files with 1380 additions and 16 deletions

18
docs/releases/1.7.0.md Normal file
View File

@@ -0,0 +1,18 @@
# 1.7.0
## 核心功能
- 新增提取操作历史记录功能,每次执行提取后自动保存订单号和总排号。
- 支持查看历史批次详情,包含操作时间、订单数、记录数、成功/失败统计。
- 批次记录可展开查看,显示总排号与订单号的对应关系。
## 界面与交互
- 提取页面新增"操作历史"按钮,点击打开历史记录对话框。
- 管理员可查看所有用户的历史记录,普通用户仅查看自己的记录。
- 支持删除历史批次,管理员可删除任意批次,普通用户仅可删除自己的记录。
## 数据存储
- 新增数据库表 `ExtractorOperationHistory`,支持 SQL Server 和 MySQL。
- 需执行数据库脚本创建表结构(详见项目文档)。

6
docs/releases/1.7.1.md Normal file
View File

@@ -0,0 +1,6 @@
# 1.7.1
## 问题修复
- 修复 MySQL 数据库下操作历史查询报错问题。
- 优化历史记录数据结构,支持按订单统计记录数量。

4
package-lock.json generated
View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import { ErpAuthService } from '../services/erp/erp-auth'
import { ExtractorService } from '../services/erp/extractor'
import { OrderNumberResolver } from '../services/erp/order-resolver'
import { create, type IDatabaseService } from '../services/database'
import { ExtractorOperationHistoryDAO } from '../services/database/extractor-operation-history-dao'
import { createLogger } from '../services/logger'
import { logAudit } from '../services/logger/audit-logger'
import { SessionManager } from '../services/user/session-manager'
@@ -12,6 +13,7 @@ import type { ExtractorInput, ExtractorResult, ExtractionProgress } from '../typ
import { UserErpConfigService } from '../services/user/user-erp-config-service'
import { ConfigManager } from '../services/config/config-manager'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { randomUUID } from 'crypto'
const log = createLogger('ExtractorHandler')
@@ -141,6 +143,29 @@ export function registerExtractorHandlers(): void {
log.info('Resolved order numbers', { count: validOrderNumbers.length })
// Initialize operation history recording
const currentUser = SessionManager.getInstance().getUserInfo()
const historyDao = new ExtractorOperationHistoryDAO()
const batchId = randomUUID()
// Save order records to history (preserve productionId -> orderNumber mapping)
if (currentUser) {
const orderRecords = mappings.map((m) => ({
productionId: m.productionId || null,
orderNumber: m.orderNumber || m.input
}))
await historyDao.insertBatchRecords(
batchId,
currentUser.id,
currentUser.username,
orderRecords
)
log.info('Operation history batch created', {
batchId,
recordCount: orderRecords.length
})
}
// Log deduplication summary
sendLog(sender, 'info', dedupReport.summary)
@@ -223,11 +248,29 @@ export function registerExtractorHandlers(): void {
})
}
// Update operation history batch status
if (currentUser) {
const status: 'success' | 'failed' | 'partial' =
result.errors.length > 0 && result.recordCount > 0
? 'partial'
: result.errors.length > 0
? 'failed'
: 'success'
// Write per-order record counts
for (const { orderNumber, recordCount } of result.orderRecordCounts) {
await historyDao.updateRecordStatus(batchId, orderNumber, status, undefined, recordCount)
}
// Update batch status without recordCount (per-order counts are set individually)
await historyDao.updateBatchStatus(batchId, status)
log.info('Operation history batch status updated', { batchId, status })
}
// Audit log: EXTRACT (non-blocking)
const os = await import('os')
const currentUser = SessionManager.getInstance().getUserInfo()
if (currentUser) {
const status: 'success' | 'failure' | 'partial' =
const auditStatus: 'success' | 'failure' | 'partial' =
result.errors.length > 0 && result.recordCount > 0
? 'partial'
: result.errors.length > 0
@@ -237,7 +280,7 @@ export function registerExtractorHandlers(): void {
username: currentUser.username,
computerName: os.hostname(),
resource: 'MATERIAL_PLAN',
status,
status: auditStatus,
metadata: {
orderCount: validOrderNumbers.length,
recordCount: result.recordCount,

View File

@@ -17,6 +17,7 @@ import { registerLoggerHandlers } from './logger-handler'
import { registerReportHandlers } from './report-handler'
import { registerUpdateHandlers } from './update-handler'
import { registerPlaywrightBrowserHandlers } from './playwright-browser'
import { registerOperationHistoryHandlers } from './operation-history-handler'
import { createLogger, logError } from '../services/logger'
import { serializeError, sanitizeError } from '../services/logger/error-utils'
import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors'
@@ -107,5 +108,6 @@ export function registerIpcHandlers(): void {
registerReportHandlers()
registerUpdateHandlers()
registerPlaywrightBrowserHandlers()
registerOperationHistoryHandlers()
log.info('All IPC handlers registered')
}

View File

@@ -0,0 +1,124 @@
/**
* IPC Handler for Extractor Operation History
*
* Handles IPC requests for operation history management:
* - Get batch list (filtered by user for non-admin users)
* - Get batch details
* - Delete batches
*/
import { ipcMain } from 'electron'
import { ExtractorOperationHistoryDAO } from '../services/database/extractor-operation-history-dao'
import { SessionManager } from '../services/user/session-manager'
import { withErrorHandling, type IpcResult } from './index'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { createLogger } from '../services/logger'
import type {
BatchStats,
OperationHistoryRecord,
GetBatchesOptions
} from '../types/operation-history.types'
const log = createLogger('OperationHistoryHandler')
/**
* Register IPC handlers for operation history
*/
export function registerOperationHistoryHandlers(): void {
const dao = new ExtractorOperationHistoryDAO()
/**
* Get batches list
* Admin users get all batches, regular users get only their own
*/
ipcMain.handle(
IPC_CHANNELS.OPERATION_HISTORY_GET_BATCHES,
async (event, options?: GetBatchesOptions): Promise<IpcResult<BatchStats[]>> => {
return withErrorHandling(async () => {
const currentUser = SessionManager.getInstance().getUserInfo()
if (!currentUser) {
throw new Error('用户未登录')
}
// Admin gets all batches, User gets only their own
const userId = currentUser.userType === 'Admin' ? undefined : currentUser.id
log.info('Getting operation history batches', {
userId: currentUser.id,
userType: currentUser.userType,
filtered: userId !== undefined
})
const batches = await dao.getBatches(userId, options)
return batches
}, 'operationHistory:getBatches')
}
)
/**
* Get batch details
* Users can only view their own batch details, admins can view all
*/
ipcMain.handle(
IPC_CHANNELS.OPERATION_HISTORY_GET_BATCH_DETAILS,
async (event, batchId: string): Promise<IpcResult<OperationHistoryRecord[]>> => {
return withErrorHandling(async () => {
const currentUser = SessionManager.getInstance().getUserInfo()
if (!currentUser) {
throw new Error('用户未登录')
}
log.info('Getting batch details', { batchId, userId: currentUser.id })
const details = await dao.getBatchDetails(batchId)
// For non-admin users, verify they own this batch
if (currentUser.userType !== 'Admin' && details.length > 0) {
const batchOwnerId = details[0].userId
if (batchOwnerId !== currentUser.id) {
throw new Error('没有权限查看此批次详情')
}
}
return details
}, 'operationHistory:getBatchDetails')
}
)
/**
* Delete a batch
* Users can only delete their own batches, admins can delete any
*/
ipcMain.handle(
IPC_CHANNELS.OPERATION_HISTORY_DELETE_BATCH,
async (event, batchId: string): Promise<IpcResult<{ deleted: boolean }>> => {
return withErrorHandling(async () => {
const currentUser = SessionManager.getInstance().getUserInfo()
if (!currentUser) {
throw new Error('用户未登录')
}
const isAdmin = currentUser.userType === 'Admin'
log.info('Deleting batch', {
batchId,
userId: currentUser.id,
isAdmin
})
const result = await dao.deleteBatch(batchId, currentUser.id, isAdmin)
if (!result.success) {
throw new Error(result.error || '删除批次失败')
}
return { deleted: true }
}, 'operationHistory:deleteBatch')
}
)
log.info('Operation history IPC handlers registered')
}

View File

@@ -0,0 +1,608 @@
/**
* Data Access Object for ExtractorOperationHistory table
*
* Handles database operations for tracking extraction operation history:
* - Batch record insertion
* - Batch status updates
* - Querying batches (with user filtering for non-admin users)
* - Getting batch details
* - Deleting batches
*/
import { create, type IDatabaseService } from './index'
import { createLogger } from '../logger'
import type {
OperationHistoryRecord,
BatchStats,
InsertBatchRecordInput,
UpdateBatchStatusResult,
GetBatchesOptions
} from '../../types/operation-history.types'
const log = createLogger('ExtractorOperationHistoryDAO')
/**
* Configuration for ExtractorOperationHistory table
*/
export const EXTRACTOR_OPERATION_HISTORY_CONFIG = {
TABLE_NAME_SQLSERVER: '[dbo].[ExtractorOperationHistory]',
TABLE_NAME_MYSQL: 'dbo_ExtractorOperationHistory',
COLUMNS: {
ID: 'ID',
BATCH_ID: 'BatchId',
USER_ID: 'UserId',
USERNAME: 'Username',
PRODUCTION_ID: 'ProductionId',
ORDER_NUMBER: 'OrderNumber',
OPERATION_TIME: 'OperationTime',
STATUS: 'Status',
RECORD_COUNT: 'RecordCount',
ERROR_MESSAGE: 'ErrorMessage'
}
} as const
/**
* ExtractorOperationHistory DAO Class
*/
export class ExtractorOperationHistoryDAO {
private dbService: IDatabaseService | null = null
/**
* Get the appropriate table name based on database type
*/
private getTableName(): string {
const isSqlServer = this.dbService?.type === 'sqlserver'
return isSqlServer
? EXTRACTOR_OPERATION_HISTORY_CONFIG.TABLE_NAME_SQLSERVER
: EXTRACTOR_OPERATION_HISTORY_CONFIG.TABLE_NAME_MYSQL
}
/**
* 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
}
/**
* Build placeholders for IN clause based on database type
*/
private buildPlaceholders(count: number, isSqlServer: boolean): string {
return isSqlServer
? Array.from({ length: count }, (_, idx) => `@p${idx}`).join(',')
: Array.from({ length: count }, () => '?').join(',')
}
// ==================== INSERT ====================
/**
* Insert batch records for a single extraction operation
* @param batchId - Unique batch identifier
* @param userId - User ID performing the operation
* @param username - Username performing the operation
* @param records - Array of order records to insert
* @returns True if successful
*/
async insertBatchRecords(
batchId: string,
userId: number,
username: string,
records: InsertBatchRecordInput[]
): Promise<boolean> {
if (!records || records.length === 0) {
log.warn('No records to insert')
return false
}
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
for (const record of records) {
try {
if (isSqlServer) {
const sqlString = `
INSERT INTO ${tableName}
(BatchId, UserId, Username, ProductionId, OrderNumber, OperationTime, Status)
VALUES
(@p0, @p1, @p2, @p3, @p4, GETDATE(), 'pending')
`
await dbService.query(sqlString, [
batchId,
userId,
username,
record.productionId || null,
record.orderNumber
])
} else {
const sqlString = `
INSERT INTO ${tableName}
(BatchId, UserId, Username, ProductionId, OrderNumber, OperationTime, Status)
VALUES
(?, ?, ?, ?, ?, NOW(), 'pending')
`
await dbService.query(sqlString, [
batchId,
userId,
username,
record.productionId || null,
record.orderNumber
])
}
} catch (error) {
log.error('Error inserting individual record', {
batchId,
orderNumber: record.orderNumber,
error: error instanceof Error ? error.message : String(error)
})
}
}
log.info('Batch records inserted', { batchId, count: records.length })
return true
} catch (error) {
log.error('Insert batch records error', {
error: error instanceof Error ? error.message : String(error)
})
return false
}
}
// ==================== UPDATE ====================
/**
* Update the status of all records in a batch
* @param batchId - Batch identifier
* @param status - New status (success, failed, partial)
* @returns Update result
*/
async updateBatchStatus(
batchId: string,
status: string
): Promise<UpdateBatchStatusResult> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const sqlString = `
UPDATE ${tableName}
SET Status = ${isSqlServer ? '@p0' : '?'}
WHERE BatchId = ${isSqlServer ? '@p1' : '?'}
`
const params = [status, batchId]
await dbService.query(sqlString, params)
log.info('Batch status updated', { batchId, status })
return { success: true, updatedCount: 1 }
} catch (error) {
log.error('Update batch status error', {
batchId,
error: error instanceof Error ? error.message : String(error)
})
return { success: false, updatedCount: 0 }
}
}
/**
* Update a single record's status, error message, and optional record count
* @param batchId - Batch identifier
* @param orderNumber - Order number
* @param status - New status
* @param errorMessage - Optional error message
* @param recordCount - Optional per-order record count
* @returns True if successful
*/
async updateRecordStatus(
batchId: string,
orderNumber: string,
status: string,
errorMessage?: string,
recordCount?: number
): Promise<boolean> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
let sqlString: string
let params: (string | number | null)[]
if (recordCount !== undefined) {
sqlString = `
UPDATE ${tableName}
SET Status = ${isSqlServer ? '@p0' : '?'},
ErrorMessage = ${isSqlServer ? '@p1' : '?'},
RecordCount = ${isSqlServer ? '@p2' : '?'}
WHERE BatchId = ${isSqlServer ? '@p3' : '?'}
AND OrderNumber = ${isSqlServer ? '@p4' : '?'}
`
params = [status, errorMessage || null, recordCount, batchId, orderNumber]
} else {
sqlString = `
UPDATE ${tableName}
SET Status = ${isSqlServer ? '@p0' : '?'},
ErrorMessage = ${isSqlServer ? '@p1' : '?'}
WHERE BatchId = ${isSqlServer ? '@p2' : '?'}
AND OrderNumber = ${isSqlServer ? '@p3' : '?'}
`
params = [status, errorMessage || null, batchId, orderNumber]
}
await dbService.query(sqlString, params)
return true
} catch (error) {
log.error('Update record status error', {
batchId,
orderNumber,
error: error instanceof Error ? error.message : String(error)
})
return false
}
}
// ==================== READ ====================
/**
* Get batch statistics with optional user filtering
* @param userId - Optional user ID for filtering (Admin gets all, User gets own)
* @param options - Query options (limit, offset)
* @returns Array of batch statistics
*/
async getBatches(userId?: number, options?: GetBatchesOptions): Promise<BatchStats[]> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
let sqlString = `
SELECT
BatchId,
UserId,
Username,
MIN(OperationTime) as OperationTime,
MAX(Status) as Status,
COUNT(*) as TotalOrders,
SUM(COALESCE(RecordCount, 0)) as TotalRecords,
SUM(CASE WHEN Status = 'success' THEN 1 ELSE 0 END) as SuccessCount,
SUM(CASE WHEN Status = 'failed' THEN 1 ELSE 0 END) as FailedCount
FROM ${tableName}
`
const params: (number | string)[] = []
if (userId !== undefined) {
sqlString += isSqlServer ? ` WHERE UserId = @p0 ` : ` WHERE UserId = ? `
params.push(userId)
}
sqlString += `
GROUP BY BatchId, UserId, Username
ORDER BY OperationTime DESC
`
if (options?.limit) {
const safeLimit = Math.floor(options.limit)
const safeOffset = options.offset !== undefined ? Math.floor(options.offset) : undefined
if (isSqlServer) {
// SQL Server: use parameterized OFFSET/FETCH
const offsetIndex = params.length
if (safeOffset !== undefined) {
params.push(safeOffset)
}
params.push(safeLimit)
if (safeOffset !== undefined) {
sqlString += ` OFFSET @p${offsetIndex} ROWS FETCH NEXT @p${offsetIndex + 1} ROWS ONLY`
} else {
sqlString += ` OFFSET 0 ROWS FETCH NEXT @p${offsetIndex} ROWS ONLY`
}
} else {
// MySQL: embed validated integer values directly.
// connection.execute() uses binary protocol prepared statements,
// which do not reliably support ? placeholders in LIMIT/OFFSET clauses.
if (safeOffset !== undefined) {
sqlString += ` LIMIT ${safeLimit} OFFSET ${safeOffset}`
} else {
sqlString += ` LIMIT ${safeLimit}`
}
}
}
const result = await dbService.query(sqlString, params)
return result.rows.map((row) => ({
batchId: row.BatchId as string,
userId: row.UserId as number,
username: row.Username as string,
operationTime: row.OperationTime
? new Date(row.OperationTime as string).toISOString()
: new Date().toISOString(),
status: row.Status as string,
totalOrders: row.TotalOrders as number,
totalRecords: (row.TotalRecords as number) || 0,
successCount: (row.SuccessCount as number) || 0,
failedCount: (row.FailedCount as number) || 0
}))
} catch (error) {
log.error('Get batches error', {
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Get detailed records for a specific batch
* @param batchId - Batch identifier
* @returns Array of operation records
*/
async getBatchDetails(batchId: string): Promise<OperationHistoryRecord[]> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const placeholder = isSqlServer ? '@p0' : '?'
const sqlString = `
SELECT
ID,
BatchId,
UserId,
Username,
ProductionId,
OrderNumber,
OperationTime,
Status,
RecordCount,
ErrorMessage
FROM ${tableName}
WHERE BatchId = ${placeholder}
ORDER BY ID
`
const result = await dbService.query(sqlString, [batchId])
return result.rows.map((row) => ({
id: row.ID as number,
batchId: row.BatchId as string,
userId: row.UserId as number,
username: row.Username as string,
productionId: row.ProductionId as string | null,
orderNumber: row.OrderNumber as string,
operationTime: new Date(row.OperationTime as string),
status: row.Status as string,
recordCount: row.RecordCount as number | null,
errorMessage: row.ErrorMessage as string | null
}))
} catch (error) {
log.error('Get batch details error', {
batchId,
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Get a single batch's statistics
* @param batchId - Batch identifier
* @returns Batch statistics or null
*/
async getBatchStats(batchId: string): Promise<BatchStats | null> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const placeholder = isSqlServer ? '@p0' : '?'
const sqlString = `
SELECT
BatchId,
UserId,
Username,
MIN(OperationTime) as OperationTime,
MAX(Status) as Status,
COUNT(*) as TotalOrders,
SUM(COALESCE(RecordCount, 0)) as TotalRecords,
SUM(CASE WHEN Status = 'success' THEN 1 ELSE 0 END) as SuccessCount,
SUM(CASE WHEN Status = 'failed' THEN 1 ELSE 0 END) as FailedCount
FROM ${tableName}
WHERE BatchId = ${placeholder}
GROUP BY BatchId, UserId, Username
`
const result = await dbService.query(sqlString, [batchId])
if (result.rows.length === 0) {
return null
}
const row = result.rows[0]
return {
batchId: row.BatchId as string,
userId: row.UserId as number,
username: row.Username as string,
operationTime: row.OperationTime
? new Date(row.OperationTime as string).toISOString()
: new Date().toISOString(),
status: row.Status as string,
totalOrders: row.TotalOrders as number,
totalRecords: (row.TotalRecords as number) || 0,
successCount: (row.SuccessCount as number) || 0,
failedCount: (row.FailedCount as number) || 0
}
} catch (error) {
log.error('Get batch stats error', {
batchId,
error: error instanceof Error ? error.message : String(error)
})
return null
}
}
// ==================== DELETE ====================
/**
* Delete a batch with permission checking
* @param batchId - Batch identifier
* @param requestingUserId - User ID requesting the deletion
* @param isAdmin - Whether the requesting user is an admin
* @returns True if successful
*/
async deleteBatch(
batchId: string,
requestingUserId: number,
isAdmin: boolean
): Promise<{ success: boolean; error?: string }> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
// First check if the batch exists and if the user has permission
const batchStats = await this.getBatchStats(batchId)
if (!batchStats) {
return { success: false, error: '批次不存在' }
}
// Non-admin users can only delete their own batches
if (!isAdmin && batchStats.userId !== requestingUserId) {
return { success: false, error: '没有权限删除此批次' }
}
// Delete the batch
const placeholder = isSqlServer ? '@p0' : '?'
const sqlString = `
DELETE FROM ${tableName}
WHERE BatchId = ${placeholder}
`
const result = await dbService.query(sqlString, [batchId])
log.info('Batch deleted', { batchId, rowCount: result.rowCount })
return { success: true }
} catch (error) {
log.error('Delete batch error', {
batchId,
error: error instanceof Error ? error.message : String(error)
})
return {
success: false,
error: error instanceof Error ? error.message : String(error)
}
}
}
/**
* Delete all batches for a specific user
* @param userId - User ID
* @returns Number of batches deleted
*/
async deleteByUser(userId: number): Promise<number> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const placeholder = isSqlServer ? '@p0' : '?'
const sqlString = `
DELETE FROM ${tableName}
WHERE UserId = ${placeholder}
`
const result = await dbService.query(sqlString, [userId])
return result.rowCount
} catch (error) {
log.error('Delete by user error', {
userId,
error: error instanceof Error ? error.message : String(error)
})
return 0
}
}
// ==================== UTILITIES ====================
/**
* Check if a batch exists
* @param batchId - Batch identifier
* @returns True if batch exists
*/
async batchExists(batchId: string): Promise<boolean> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const placeholder = isSqlServer ? '@p0' : '?'
const sqlString = `
SELECT COUNT(*) as count
FROM ${tableName}
WHERE BatchId = ${placeholder}
`
const result = await dbService.query(sqlString, [batchId])
return result.rows.length > 0 && (result.rows[0].count as number) > 0
} catch (error) {
log.error('Batch exists error', {
batchId,
error: error instanceof Error ? error.message : String(error)
})
return false
}
}
/**
* Count total batches with optional user filtering
* @param userId - Optional user ID for filtering
* @returns Total number of batches
*/
async countBatches(userId?: number): Promise<number> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
let sqlString = `
SELECT COUNT(DISTINCT BatchId) as count
FROM ${tableName}
`
const params: number[] = []
if (userId !== undefined) {
sqlString += isSqlServer ? ` WHERE UserId = @p0 ` : ` WHERE UserId = ? `
params.push(userId)
}
const result = await dbService.query(sqlString, params)
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
} catch (error) {
log.error('Count batches error', {
error: error instanceof Error ? error.message : String(error)
})
return 0
}
}
/**
* Disconnect from database
*/
async disconnect(): Promise<void> {
if (this.dbService) {
await this.dbService.disconnect()
this.dbService = null
}
}
}

View File

@@ -46,7 +46,8 @@ export class ExtractorService {
downloadedFiles: [],
mergedFile: null,
recordCount: 0,
errors: []
errors: [],
orderRecordCounts: []
}
try {
@@ -79,6 +80,7 @@ export class ExtractorService {
const mergeResult = await this.mergeFiles(result.downloadedFiles)
result.mergedFile = mergeResult.mergedFile
result.recordCount = mergeResult.recordCount
result.orderRecordCounts = mergeResult.orderRecordCounts
// Add merge error to result if any
if (mergeResult.error) {
@@ -123,9 +125,14 @@ export class ExtractorService {
*/
private async mergeFiles(
filePaths: string[]
): Promise<{ mergedFile: string | null; recordCount: number; error?: string }> {
): Promise<{
mergedFile: string | null
recordCount: number
error?: string
orderRecordCounts: Array<{ orderNumber: string; recordCount: number }>
}> {
if (filePaths.length === 0) {
return { mergedFile: null, recordCount: 0 }
return { mergedFile: null, recordCount: 0, orderRecordCounts: [] }
}
log.info('Starting merge', { fileCount: filePaths.length })
@@ -154,15 +161,21 @@ export class ExtractorService {
// Calculate total record count (total material rows)
let recordCount = 0
const orderRecordCounts: Array<{ orderNumber: string; recordCount: number }> = []
for (const order of allOrders) {
recordCount += order.materials.length
const count = order.materials.length
recordCount += count
orderRecordCounts.push({
orderNumber: order.orderInfo.productionOrder || '',
recordCount: count
})
}
log.info('Merge summary', { orderCount: allOrders.length, recordCount })
if (recordCount === 0) {
log.warn('No records found in any downloaded files')
return { mergedFile: null, recordCount: 0 }
return { mergedFile: null, recordCount: 0, orderRecordCounts }
}
// Generate output filename with timestamp
@@ -178,13 +191,13 @@ export class ExtractorService {
log.info('Saving merged file', { outputPath })
await this.saveMergedOrders(allOrders, outputPath)
log.info('Merged file saved successfully', { recordCount })
return { mergedFile: outputPath, recordCount }
return { mergedFile: outputPath, recordCount, orderRecordCounts }
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
const errorStack = error instanceof Error ? error.stack : ''
log.error('Failed to save merged file', { error: errorMsg, stack: errorStack })
// Return parsed record count and error info even if save fails
return { mergedFile: null, recordCount, error: `保存合并文件失败:${errorMsg}` }
return { mergedFile: null, recordCount, orderRecordCounts, error: `保存合并文件失败:${errorMsg}` }
}
}

View File

@@ -43,6 +43,8 @@ export interface ExtractorResult {
errors: string[]
/** Database import result (only populated if mergedFile was created) */
importResult?: ImportResult
/** Per-order material row counts */
orderRecordCounts: Array<{ orderNumber: string; recordCount: number }>
}
export interface OrderInfo {

View File

@@ -0,0 +1,86 @@
/**
* Operation History Type Definitions
*
* Type definitions for the Extractor Operation History feature.
* Tracks extraction operations with batch and individual order record details.
*/
/**
* Individual operation history record
*/
export interface OperationHistoryRecord {
/** Auto-increment ID */
id?: number
/** Batch ID - shared among all orders in a single extraction operation */
batchId: string
/** User ID who performed the operation */
userId: number
/** Username who performed the operation */
username: string
/** Original input production ID (e.g., "22A1"), null if input was already an order number */
productionId: string | null
/** Resolved order number (e.g., "SC70202602120085") */
orderNumber: string
/** When the operation was performed */
operationTime: Date
/** Operation status: pending, success, failed, partial */
status: string
/** Number of records extracted for this order */
recordCount: number | null
/** Error message if operation failed */
errorMessage: string | null
}
/**
* Batch statistics - aggregated view of a batch operation
*/
export interface BatchStats {
/** Unique batch identifier */
batchId: string
/** User ID who performed the operation */
userId: number
/** Username who performed the operation */
username: string
/** When the operation started */
operationTime: string
/** Overall batch status: pending, success, failed, partial */
status: string
/** Total number of orders in the batch */
totalOrders: number
/** Total records extracted across all orders */
totalRecords: number
/** Number of orders that succeeded */
successCount: number
/** Number of orders that failed */
failedCount: number
}
/**
* Input for inserting batch records
*/
export interface InsertBatchRecordInput {
/** Original input production ID (e.g., "22A1") */
productionId: string | null
/** Resolved order number (e.g., "SC70202602120085") */
orderNumber: string
}
/**
* Result for batch status update
*/
export interface UpdateBatchStatusResult {
/** Whether the update was successful */
success: boolean
/** Number of records updated */
updatedCount: number
}
/**
* Options for querying batches
*/
export interface GetBatchesOptions {
/** Maximum number of batches to return */
limit?: number
/** Number of batches to skip (for pagination) */
offset?: number
}

View File

@@ -17,6 +17,7 @@ import {
} from './materials'
import { loggerApi } from './logger'
import { playwrightBrowserApi } from './browser-download'
import { operationHistoryApi } from './operation-history'
export const api = {
process: processApi,
@@ -35,7 +36,8 @@ export const api = {
logger: loggerApi,
report: reportApi,
update: updateApi,
playwrightBrowser: playwrightBrowserApi
playwrightBrowser: playwrightBrowserApi,
operationHistory: operationHistoryApi
} as const
export type ElectronApi = typeof api

View File

@@ -0,0 +1,30 @@
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { invokeIpc } from '../lib/ipc'
import type {
BatchStats,
OperationHistoryRecord,
GetBatchesOptions
} from '../../main/types/operation-history.types'
import type { IpcResult } from '../../main/types/ipc.types'
export const operationHistoryApi = {
/**
* Get list of operation batches
* Admin users receive all batches, regular users only their own
*/
getBatches: (options?: GetBatchesOptions): Promise<IpcResult<BatchStats[]>> =>
invokeIpc(IPC_CHANNELS.OPERATION_HISTORY_GET_BATCHES, options),
/**
* Get detailed records for a specific batch
*/
getBatchDetails: (batchId: string): Promise<IpcResult<OperationHistoryRecord[]>> =>
invokeIpc(IPC_CHANNELS.OPERATION_HISTORY_GET_BATCH_DETAILS, batchId),
/**
* Delete a batch
* Admin users can delete any batch, regular users only their own
*/
deleteBatch: (batchId: string): Promise<IpcResult<{ deleted: boolean }>> =>
invokeIpc(IPC_CHANNELS.OPERATION_HISTORY_DELETE_BATCH, batchId)
} as const

View File

@@ -157,6 +157,37 @@ export interface PlaywrightBrowserAPI {
onProgress: (callback: (data: DownloadProgress) => void) => () => void
}
export interface OperationHistoryAPI {
getBatches: (options?: { limit?: number; offset?: number }) => Promise<IpcResult<BatchStats[]>>
getBatchDetails: (batchId: string) => Promise<IpcResult<OperationHistoryRecord[]>>
deleteBatch: (batchId: string) => Promise<IpcResult<{ deleted: boolean }>>
}
export interface BatchStats {
batchId: string
userId: number
username: string
operationTime: string
status: string
totalOrders: number
totalRecords: number
successCount: number
failedCount: number
}
export interface OperationHistoryRecord {
id?: number
batchId: string
userId: number
username: string
productionId: string | null
orderNumber: string
operationTime: Date
status: string
recordCount: number | null
errorMessage: string | null
}
export interface ProcessAPI {
versions: {
electron: string
@@ -185,6 +216,7 @@ declare global {
report: ReportAPI
update: UpdateAPI
playwrightBrowser: PlaywrightBrowserAPI
operationHistory: OperationHistoryAPI
}
api: unknown
}

View File

@@ -0,0 +1,373 @@
/**
* Extractor Operation History Modal
*
* Displays extraction operation history with batch statistics and details.
* Admin users see all users' records, regular users see only their own.
*/
import React, { useState, useEffect, useCallback } from 'react'
import { Modal } from './ui/Modal'
import {
RefreshCw,
Trash2,
ChevronDown,
ChevronRight,
CheckCircle,
XCircle,
Clock
} from 'lucide-react'
import type { UserInfo } from './UserSelectionDialog'
import type {
BatchStats,
OperationHistoryRecord
} from '../../../main/types/operation-history.types'
interface ExtractorOperationHistoryModalProps {
isOpen: boolean
onClose: () => void
user?: UserInfo | null
}
const statusStyles: Record<string, string> = {
success: 'bg-green-100 text-green-700',
partial: 'bg-amber-100 text-amber-700',
failed: 'bg-red-100 text-red-700',
pending: 'bg-gray-100 text-gray-700'
}
const statusLabels: Record<string, string> = {
success: '成功',
partial: '部分成功',
failed: '失败',
pending: '进行中'
}
const statusIcons: Record<string, React.ReactNode> = {
success: <CheckCircle size={16} className="text-green-600" />,
partial: <Clock size={16} className="text-amber-600" />,
failed: <XCircle size={16} className="text-red-600" />,
pending: <Clock size={16} className="text-gray-500" />
}
const formatDateTime = (dateStr: string) => {
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryModalProps> = ({
isOpen,
onClose,
user
}) => {
const [batches, setBatches] = useState<BatchStats[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [expandedBatches, setExpandedBatches] = useState<Set<string>>(new Set())
const [batchDetails, setBatchDetails] = useState<Map<string, OperationHistoryRecord[]>>(new Map())
const [deleting, setDeleting] = useState<Set<string>>(new Set())
const isAdmin = user?.userType === 'Admin'
const fetchBatches = useCallback(async () => {
setLoading(true)
setError(null)
try {
const result = await window.electron.operationHistory.getBatches({ limit: 100 })
if (result.success && result.data) {
setBatches(result.data)
} else {
setError(result.error || '获取历史记录失败')
}
} catch (err) {
setError(err instanceof Error ? err.message : '获取历史记录失败')
} finally {
setLoading(false)
}
}, [])
const fetchBatchDetails = useCallback(
async (batchId: string) => {
// If already loaded, don't fetch again
if (batchDetails.has(batchId)) {
return
}
try {
const result = await window.electron.operationHistory.getBatchDetails(batchId)
if (result.success && result.data) {
setBatchDetails((prev) => new Map(prev).set(batchId, result.data!))
}
} catch (err) {
console.error('Failed to fetch batch details:', err)
}
},
[batchDetails]
)
// Fetch batches when modal opens
useEffect(() => {
if (isOpen) {
void fetchBatches()
}
}, [isOpen, fetchBatches])
const toggleBatchExpansion = (batchId: string) => {
setExpandedBatches((prev) => {
const newSet = new Set(prev)
if (newSet.has(batchId)) {
newSet.delete(batchId)
} else {
newSet.add(batchId)
void fetchBatchDetails(batchId)
}
return newSet
})
}
const handleDeleteBatch = async (batchId: string) => {
if (deleting.has(batchId)) return
const confirmed = confirm('确定要删除此批次记录吗?此操作不可撤销。')
if (!confirmed) return
setDeleting((prev) => new Set(prev).add(batchId))
try {
const result = await window.electron.operationHistory.deleteBatch(batchId)
if (result.success) {
// Remove from local state
setBatches((prev) => prev.filter((b) => b.batchId !== batchId))
setBatchDetails((prev) => {
const newMap = new Map(prev)
newMap.delete(batchId)
return newMap
})
setExpandedBatches((prev) => {
const newSet = new Set(prev)
newSet.delete(batchId)
return newSet
})
} else {
alert(result.error || '删除失败')
}
} catch (err) {
alert(err instanceof Error ? err.message : '删除失败')
} finally {
setDeleting((prev) => {
const newSet = new Set(prev)
newSet.delete(batchId)
return newSet
})
}
}
if (!isOpen) return null
return (
<Modal isOpen={isOpen} onClose={onClose} title="操作历史" size="3xl">
<div className="flex flex-col h-[70vh]">
{/* Toolbar */}
<div className="flex items-center justify-between mb-4 pb-4 border-b border-gray-200">
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">
{isAdmin ? (
<span className="text-amber-600 font-medium"></span>
) : (
<span></span>
)}
</span>
{batches.length > 0 && (
<span className="text-sm text-gray-500"> {batches.length} </span>
)}
</div>
<button
className="p-2 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50"
onClick={() => void fetchBatches()}
disabled={loading}
title="刷新"
>
<RefreshCw size={18} className={loading ? 'animate-spin' : ''} />
</button>
</div>
{/* Error message */}
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{error}
</div>
)}
{/* Batch list */}
<div className="flex-1 overflow-y-auto">
{loading && batches.length === 0 ? (
<div className="flex items-center justify-center h-32 text-gray-500">...</div>
) : batches.length === 0 ? (
<div className="flex items-center justify-center h-32 text-gray-500"></div>
) : (
<div className="flex flex-col gap-3">
{batches.map((batch) => {
const isExpanded = expandedBatches.has(batch.batchId)
const details = batchDetails.get(batch.batchId) || []
const isDeleting = deleting.has(batch.batchId)
return (
<div
key={batch.batchId}
className="border border-gray-200 rounded-lg overflow-hidden"
>
{/* Batch summary */}
<div
className={`flex items-center justify-between p-4 cursor-pointer transition-colors ${
isExpanded ? 'bg-gray-50' : 'hover:bg-gray-50'
}`}
onClick={() => toggleBatchExpansion(batch.batchId)}
>
<div className="flex items-center gap-4 flex-1">
<button className="p-1 hover:bg-gray-200 rounded">
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
</button>
<div className="flex-1 grid grid-cols-6 gap-4 text-sm">
<div>
<div className="text-gray-500 text-xs"></div>
<div className="font-medium text-gray-900">
{formatDateTime(batch.operationTime)}
</div>
</div>
<div>
<div className="text-gray-500 text-xs"></div>
<div className="font-medium text-gray-900">{batch.username}</div>
</div>
<div>
<div className="text-gray-500 text-xs"></div>
<div className="flex items-center gap-1">
{statusIcons[batch.status] || statusIcons.pending}
<span
className={`px-2 py-0.5 rounded text-xs font-medium ${
statusStyles[batch.status] || statusStyles.pending
}`}
>
{statusLabels[batch.status] || batch.status}
</span>
</div>
</div>
<div>
<div className="text-gray-500 text-xs"></div>
<div className="font-medium text-gray-900">{batch.totalOrders}</div>
</div>
<div>
<div className="text-gray-500 text-xs"></div>
<div className="font-medium text-gray-900">{batch.totalRecords}</div>
</div>
<div>
<div className="text-gray-500 text-xs">/</div>
<div className="font-medium text-gray-900">
<span className="text-green-600">{batch.successCount}</span>
{batch.failedCount > 0 && (
<>
{' / '}
<span className="text-red-600">{batch.failedCount}</span>
</>
)}
</div>
</div>
</div>
</div>
<button
className="p-2 hover:bg-red-50 text-gray-400 hover:text-red-600 rounded transition-colors disabled:opacity-50"
onClick={(e) => {
e.stopPropagation()
void handleDeleteBatch(batch.batchId)
}}
disabled={isDeleting}
title="删除批次"
>
<Trash2 size={16} className={isDeleting ? 'animate-pulse' : ''} />
</button>
</div>
{/* Batch details */}
{isExpanded && details.length > 0 && (
<div className="border-t border-gray-200 bg-white">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<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">
</th>
<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">
</th>
<th className="px-4 py-2 text-left font-medium text-gray-600">
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{details.map((detail) => (
<tr key={detail.id} className="hover:bg-gray-50">
<td className="px-4 py-2 text-gray-900">
{detail.productionId || '-'}
</td>
<td className="px-4 py-2 text-gray-900 font-mono text-xs">
{detail.orderNumber}
</td>
<td className="px-4 py-2">
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${
statusStyles[detail.status] || statusStyles.pending
}`}
>
{statusIcons[detail.status]}
{statusLabels[detail.status] || detail.status}
</span>
</td>
<td className="px-4 py-2 text-gray-900">
{detail.recordCount ?? '-'}
</td>
<td className="px-4 py-2 text-red-600 text-xs max-w-xs truncate">
{detail.errorMessage || '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)
})}
</div>
)}
</div>
{/* Footer */}
<div className="pt-4 border-t border-gray-200 flex justify-end">
<button
className="px-6 py-2 rounded-md bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium transition-colors"
onClick={onClose}
>
</button>
</div>
</div>
</Modal>
)
}
export default ExtractorOperationHistoryModal

View File

@@ -1,14 +1,18 @@
import React from 'react'
import { Download, Play, CheckCircle } from 'lucide-react'
import { Download, Play, CheckCircle, History } from 'lucide-react'
import OrderNumberInput from '../components/OrderNumberInput'
import { useExtractor } from '../hooks/useExtractor'
import { usePersistentTextState } from '../hooks/usePersistentTextState'
import { useSharedProductionIds } from '../hooks/useSharedProductionIds'
import LogPanel from '../components/ui/LogPanel'
import { SegmentedProgressBar } from '../components/ui/SegmentedProgressBar'
import ExtractorOperationHistoryModal from '../components/ExtractorOperationHistoryModal'
import { useUserStore } from '../stores/useUserStore'
const ExtractorPage: React.FC = () => {
const [orderNumbers, setOrderNumbers] = usePersistentTextState('extractor_orderNumbers')
const [showHistoryModal, setShowHistoryModal] = React.useState(false)
const user = useUserStore((state) => state.user)
const {
isRunning,
@@ -67,6 +71,14 @@ const ExtractorPage: React.FC = () => {
</div>
<div className="flex items-center gap-4">
<button
className="bg-slate-100 hover:bg-slate-200 text-slate-700 px-4 py-2.5 rounded-lg flex items-center gap-2 font-medium transition-colors"
onClick={() => setShowHistoryModal(true)}
disabled={isRunning}
>
<History size={18} />
</button>
<button
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white px-6 py-2.5 rounded-lg flex items-center gap-2 font-medium shadow-sm transition-colors"
onClick={handleExtract}
@@ -78,6 +90,14 @@ const ExtractorPage: React.FC = () => {
</div>
</div>
{showHistoryModal ? (
<ExtractorOperationHistoryModal
isOpen={showHistoryModal}
onClose={() => setShowHistoryModal(false)}
user={user}
/>
) : null}
{!isRunning && isComplete && (
<div className="bg-green-50 rounded-xl p-8 flex items-center justify-center gap-4 shadow-md">
<CheckCircle className="text-green-600" size={35} />

View File

@@ -111,7 +111,12 @@ export const IPC_CHANNELS = {
PLAYWRIGHT_BROWSER_DOWNLOAD: 'playwright-browser:download',
PLAYWRIGHT_BROWSER_CANCEL: 'playwright-browser:cancel',
PLAYWRIGHT_BROWSER_PROGRESS: 'playwright-browser:progress',
PLAYWRIGHT_BROWSER_CHECK: 'playwright-browser:check'
PLAYWRIGHT_BROWSER_CHECK: 'playwright-browser:check',
// Operation History
OPERATION_HISTORY_GET_BATCHES: 'operationHistory:getBatches',
OPERATION_HISTORY_GET_BATCH_DETAILS: 'operationHistory:getBatchDetails',
OPERATION_HISTORY_DELETE_BATCH: 'operationHistory:deleteBatch'
} as const
/**