fix: restore cleaner history in postgresql

This commit is contained in:
Misaka
2026-04-14 20:49:29 +08:00
parent 936c98a023
commit 5b43d5a60c
7 changed files with 369 additions and 62 deletions

View File

@@ -122,6 +122,15 @@ export class CleanerOperationHistoryDAO {
return this.getDialect().quoteTableName('ERPAuto', 'CleanerMaterialDetail') return this.getDialect().quoteTableName('ERPAuto', 'CleanerMaterialDetail')
} }
private getIsDryRunAggregateSql(): string {
const dialect = this.getDialect()
if (dialect.dbType === 'postgresql') {
return `MAX(CASE WHEN e.IsDryRun THEN 1 ELSE 0 END)`
}
return `MAX(CAST(e.IsDryRun AS INT))`
}
/** /**
* Get database service instance using DatabaseFactory * Get database service instance using DatabaseFactory
*/ */
@@ -619,9 +628,9 @@ export class CleanerOperationHistoryDAO {
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.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.TotalMaterialsDeleted ELSE 0 END) as TotalMaterialsDeleted,
MAX(CASE WHEN e.AttemptNumber = latest.max_attempt THEN e.TotalMaterialsFailed ELSE 0 END) as TotalMaterialsFailed, MAX(CASE WHEN e.AttemptNumber = latest.max_attempt THEN e.TotalMaterialsFailed ELSE 0 END) as TotalMaterialsFailed,
MAX(CAST(e.IsDryRun AS INT)) as IsDryRun, ${this.getIsDryRunAggregateSql()} as IsDryRun,
ISNULL(SUM(CASE WHEN o.Status = 'success' THEN 1 ELSE 0 END), 0) as SuccessCount, COALESCE(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 COALESCE(SUM(CASE WHEN o.Status = 'failed' THEN 1 ELSE 0 END), 0) as FailedCount
FROM ${execTable} e FROM ${execTable} e
INNER JOIN ( INNER JOIN (
SELECT BatchId, MAX(AttemptNumber) as max_attempt SELECT BatchId, MAX(AttemptNumber) as max_attempt
@@ -906,27 +915,13 @@ export class CleanerOperationHistoryDAO {
const materialTable = this.getMaterialTableName() const materialTable = this.getMaterialTableName()
const dialect = this.getDialect() const dialect = this.getDialect()
// Check if batch exists const details = await this.getBatchDetails(batchId)
const checkSql = ` if (details.executions.length === 0) {
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: '批次不存在' } return { success: false, error: '批次不存在' }
} }
// Permission check: non-admin can only delete own batches // Permission check: non-admin can only delete own batches
const batchUserId = checkResult.result.rows[0].UserId as number const batchUserId = details.executions[0].userId
if (!isAdmin && batchUserId !== requestingUserId) { if (!isAdmin && batchUserId !== requestingUserId) {
return { success: false, error: '没有权限删除此批次' } return { success: false, error: '没有权限删除此批次' }
} }

View File

@@ -6,7 +6,7 @@
* Admin users see all users' records, regular users see only their own. * Admin users see all users' records, regular users see only their own.
*/ */
import React, { useState, useEffect, useCallback, useRef } from 'react' import React, { useState, useEffect, useCallback } from 'react'
import { Modal } from './ui/Modal' import { Modal } from './ui/Modal'
import { useLogger } from '../hooks/useLogger' import { useLogger } from '../hooks/useLogger'
import { import {
@@ -28,6 +28,11 @@ import type {
CleanerHistoryOrderRecord, CleanerHistoryOrderRecord,
CleanerHistoryMaterialRecord CleanerHistoryMaterialRecord
} from '../hooks/cleaner/types' } from '../hooks/cleaner/types'
import {
canStartHistoryLoad,
getNextHistoryLoadState,
type HistoryLoadState
} from './cleaner-history-load-state'
// The preload API returns Date for time fields, but IPC serialization converts them to strings. // The preload API returns Date for time fields, but IPC serialization converts them to strings.
// Use a local type that accommodates both to satisfy TypeScript. // Use a local type that accommodates both to satisfy TypeScript.
@@ -145,46 +150,53 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
() => new Map() () => new Map()
) )
const [loadingMaterials, setLoadingMaterials] = useState<Set<string>>(() => new Set()) const [loadingMaterials, setLoadingMaterials] = useState<Set<string>>(() => new Set())
const [materialLoadStates, setMaterialLoadStates] = useState<Map<string, HistoryLoadState>>(
() => new Map()
)
const [detailsLoadState, setDetailsLoadState] = useState<HistoryLoadState>('idle')
const [isDeleting, setIsDeleting] = useState(false) const [isDeleting, setIsDeleting] = useState(false)
const detailsLoadedRef = useRef(false)
const loadedMaterialsRef = useRef<Set<string>>(new Set())
const logger = useLogger('BatchItem') const logger = useLogger('BatchItem')
// Fetch batch details when first expanded const fetchDetails = useCallback(async () => {
useEffect(() => { if (!canStartHistoryLoad(detailsLoadState)) return
if (!isExpanded || detailsLoadedRef.current) return
detailsLoadedRef.current = true
const fetchDetails = async () => { setDetailsLoadState((prev) => getNextHistoryLoadState(prev, 'start'))
try { try {
const result = await window.electron.cleaner.getHistoryBatchDetails(batch.batchId) const result = await window.electron.cleaner.getHistoryBatchDetails(batch.batchId)
if (result.success && result.data) { if (result.success && result.data) {
setExecutions(result.data.executions) setExecutions(result.data.executions)
setOrders(result.data.orders) setOrders(result.data.orders)
const execs = result.data.executions const execs = result.data.executions
if (execs.length > 0) { if (execs.length > 0) {
setCurrentAttempt(Math.max(...execs.map((e) => e.attemptNumber))) setCurrentAttempt(Math.max(...execs.map((e) => e.attemptNumber)))
}
} }
} catch (err) {
logger.error('Failed to fetch batch details', {
error: err instanceof Error ? err.message : String(err),
batchId: batch.batchId
})
}
}
void fetchDetails() setDetailsLoadState('success')
}, [isExpanded, batch.batchId, logger]) } else {
setDetailsLoadState('error')
}
} catch (err) {
setDetailsLoadState('error')
logger.error('Failed to fetch batch details', {
error: err instanceof Error ? err.message : String(err),
batchId: batch.batchId
})
}
}, [batch.batchId, detailsLoadState, logger])
const fetchMaterials = useCallback( const fetchMaterials = useCallback(
async (attemptNumber: number, orderNumber: string) => { async (attemptNumber: number, orderNumber: string) => {
const cacheKey = `${attemptNumber}:${orderNumber}` const cacheKey = `${attemptNumber}:${orderNumber}`
if (loadedMaterialsRef.current.has(cacheKey)) return const loadState = materialLoadStates.get(cacheKey) ?? 'idle'
loadedMaterialsRef.current.add(cacheKey) if (!canStartHistoryLoad(loadState)) return
setMaterialLoadStates((prev) => {
const next = new Map(prev)
next.set(cacheKey, getNextHistoryLoadState(prev.get(cacheKey) ?? 'idle', 'start'))
return next
})
setLoadingMaterials((prev) => new Set(prev).add(cacheKey)) setLoadingMaterials((prev) => new Set(prev).add(cacheKey))
try { try {
const result = await window.electron.cleaner.getHistoryMaterialDetails( const result = await window.electron.cleaner.getHistoryMaterialDetails(
@@ -194,8 +206,24 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
) )
if (result.success && result.data) { if (result.success && result.data) {
setOrderMaterials((prev) => new Map(prev).set(cacheKey, result.data!)) setOrderMaterials((prev) => new Map(prev).set(cacheKey, result.data!))
setMaterialLoadStates((prev) => {
const next = new Map(prev)
next.set(cacheKey, 'success')
return next
})
} else {
setMaterialLoadStates((prev) => {
const next = new Map(prev)
next.set(cacheKey, 'error')
return next
})
} }
} catch (err) { } catch (err) {
setMaterialLoadStates((prev) => {
const next = new Map(prev)
next.set(cacheKey, 'error')
return next
})
logger.error('Failed to fetch material details', { logger.error('Failed to fetch material details', {
error: err instanceof Error ? err.message : String(err), error: err instanceof Error ? err.message : String(err),
batchId: batch.batchId, batchId: batch.batchId,
@@ -210,9 +238,17 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
}) })
} }
}, },
[batch.batchId, logger] [batch.batchId, logger, materialLoadStates]
) )
const toggleBatchExpansion = () => {
const nextExpanded = !isExpanded
setIsExpanded(nextExpanded)
if (nextExpanded) {
void fetchDetails()
}
}
const toggleOrderExpansion = (attemptNumber: number, orderNumber: string) => { const toggleOrderExpansion = (attemptNumber: number, orderNumber: string) => {
const cacheKey = `${attemptNumber}:${orderNumber}` const cacheKey = `${attemptNumber}:${orderNumber}`
const isCurrentlyExpanded = expandedOrders.has(cacheKey) const isCurrentlyExpanded = expandedOrders.has(cacheKey)
@@ -281,7 +317,7 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
className={`flex items-center justify-between p-4 cursor-pointer transition-colors ${ className={`flex items-center justify-between p-4 cursor-pointer transition-colors ${
isExpanded ? 'bg-gray-50' : 'hover:bg-gray-50' isExpanded ? 'bg-gray-50' : 'hover:bg-gray-50'
}`} }`}
onClick={() => setIsExpanded((prev) => !prev)} onClick={toggleBatchExpansion}
> >
<div className="flex items-center gap-4 flex-1"> <div className="flex items-center gap-4 flex-1">
<button className="p-1 hover:bg-gray-200 rounded"> <button className="p-1 hover:bg-gray-200 rounded">
@@ -415,7 +451,13 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
)} )}
{/* Order table */} {/* Order table */}
{filteredOrders.length > 0 ? ( {detailsLoadState === 'loading' && executions.length === 0 && orders.length === 0 ? (
<div className="px-4 py-6 text-center text-sm text-gray-500">...</div>
) : detailsLoadState === 'error' && executions.length === 0 && orders.length === 0 ? (
<div className="px-4 py-6 text-center text-sm text-red-600">
</div>
) : filteredOrders.length > 0 ? (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-gray-50"> <thead className="bg-gray-50">
@@ -452,6 +494,7 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
const isOrderExpanded = expandedOrders.has(orderKey) const isOrderExpanded = expandedOrders.has(orderKey)
const materials = orderMaterials.get(orderKey) || [] const materials = orderMaterials.get(orderKey) || []
const isLoadingMaterials = loadingMaterials.has(orderKey) const isLoadingMaterials = loadingMaterials.has(orderKey)
const materialLoadState = materialLoadStates.get(orderKey) ?? 'idle'
return ( return (
<React.Fragment key={orderKey}> <React.Fragment key={orderKey}>
@@ -531,6 +574,10 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
<td colSpan={11} className="bg-gray-50/50 px-8 py-3"> <td colSpan={11} className="bg-gray-50/50 px-8 py-3">
{isLoadingMaterials ? ( {isLoadingMaterials ? (
<div className="text-xs text-gray-500">...</div> <div className="text-xs text-gray-500">...</div>
) : materialLoadState === 'error' ? (
<div className="text-xs text-red-600">
</div>
) : materials.length > 0 ? ( ) : materials.length > 0 ? (
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead> <thead>

View File

@@ -0,0 +1,19 @@
export type HistoryLoadState = 'idle' | 'loading' | 'success' | 'error'
export const canStartHistoryLoad = (state: HistoryLoadState): boolean =>
state === 'idle' || state === 'error'
export const getNextHistoryLoadState = (
currentState: HistoryLoadState,
event: 'start' | 'success' | 'error'
): HistoryLoadState => {
if (event === 'start') {
return canStartHistoryLoad(currentState) ? 'loading' : currentState
}
if (event === 'success') {
return 'success'
}
return 'error'
}

View File

@@ -167,17 +167,19 @@ const CleanerPage: React.FC = () => {
/> />
</Suspense> </Suspense>
<Suspense fallback={null}> {showHistoryModal ? (
<CleanerOperationHistoryModal <Suspense fallback={null}>
isOpen={showHistoryModal} <CleanerOperationHistoryModal
onClose={() => setShowHistoryModal(false)} isOpen={showHistoryModal}
user={ onClose={() => setShowHistoryModal(false)}
currentUsername user={
? { username: currentUsername, userType: isAdmin ? 'Admin' : 'User' } currentUsername
: null ? { username: currentUsername, userType: isAdmin ? 'Admin' : 'User' }
} : null
/> }
</Suspense> />
</Suspense>
) : null}
{/* Confirmation Dialog */} {/* Confirmation Dialog */}
{confirmDialog && <ConfirmDialog {...confirmDialog} />} {confirmDialog && <ConfirmDialog {...confirmDialog} />}

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import {
canStartHistoryLoad,
getNextHistoryLoadState
} from '../../src/renderer/src/components/cleaner-history-load-state'
describe('cleaner history load state helpers', () => {
it('allows initial and failed loads to retry', () => {
expect(canStartHistoryLoad('idle')).toBe(true)
expect(canStartHistoryLoad('error')).toBe(true)
})
it('prevents duplicate requests after loading starts or succeeds', () => {
expect(canStartHistoryLoad('loading')).toBe(false)
expect(canStartHistoryLoad('success')).toBe(false)
})
it('retries after an error but keeps successful loads cached', () => {
const failedState = getNextHistoryLoadState('loading', 'error')
const retryState = getNextHistoryLoadState(failedState, 'start')
const successState = getNextHistoryLoadState(retryState, 'success')
const blockedState = getNextHistoryLoadState(successState, 'start')
expect(failedState).toBe('error')
expect(retryState).toBe('loading')
expect(successState).toBe('success')
expect(blockedState).toBe('success')
})
})

View File

@@ -0,0 +1,107 @@
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi, beforeEach } from 'vitest'
const { mockHistoryModalModuleLoad } = vi.hoisted(() => ({
mockHistoryModalModuleLoad: vi.fn()
}))
vi.mock('../../src/renderer/src/hooks/useCleaner', () => ({
useCleaner: () => ({
isAdmin: false,
currentUsername: 'tester',
dryRun: false,
setDryRun: vi.fn(),
valMode: 'database_full',
setValMode: vi.fn(),
validationResults: [],
selectedItems: new Set<string>(),
setSelectedItems: vi.fn(),
setHiddenItems: vi.fn(),
managers: [],
selectedManagers: [],
setSelectedManagers: vi.fn(),
isRunning: false,
isExecuting: false,
isValidationRunning: false,
isExporting: false,
isTypeDialogOpen: false,
setIsTypeDialogOpen: vi.fn(),
headless: false,
setHeadless: vi.fn(),
processConcurrency: 1,
updateProcessConcurrency: vi.fn(),
showSettingsMenu: false,
setShowSettingsMenu: vi.fn(),
filteredResults: [],
isReportDialogOpen: false,
setIsReportDialogOpen: vi.fn(),
reportData: null,
editingCell: null,
editValue: '',
setEditValue: vi.fn(),
inputRef: { current: null },
startEdit: vi.fn(),
saveEdit: vi.fn(),
cancelEdit: vi.fn(),
handleAssignManagerOnSelect: vi.fn(),
progress: null,
startTime: null,
resetStartTime: vi.fn(),
handleValidation: vi.fn(),
handleCheckboxToggle: vi.fn(),
handleConfirmDeletion: vi.fn(),
handleExecuteDeletion: vi.fn(),
handleExportResults: vi.fn(),
confirmDialog: null
})
}))
vi.mock('../../src/renderer/src/components/cleaner/CleanerExecutionBar', () => ({
CleanerExecutionBar: () => React.createElement('div', null, 'execution-bar')
}))
vi.mock('../../src/renderer/src/components/cleaner/CleanerResultsTable', () => ({
CleanerResultsTable: () => React.createElement('div', null, 'results-table')
}))
vi.mock('../../src/renderer/src/components/cleaner/CleanerSidebar', () => ({
CleanerSidebar: () => React.createElement('aside', null, 'sidebar')
}))
vi.mock('../../src/renderer/src/components/cleaner/CleanerToolbar', () => ({
CleanerToolbar: () => React.createElement('div', null, 'toolbar')
}))
vi.mock('../../src/renderer/src/components/ui/ConfirmDialog', () => ({
ConfirmDialog: () => React.createElement('div', null, 'confirm-dialog')
}))
vi.mock('../../src/renderer/src/components/MaterialTypeManagementDialog', () => ({
default: () => React.createElement('div', null, 'type-dialog')
}))
vi.mock('../../src/renderer/src/components/ExecutionReportDialog', () => ({
default: () => React.createElement('div', null, 'report-dialog')
}))
vi.mock('../../src/renderer/src/components/CleanerOperationHistoryModal', () => {
mockHistoryModalModuleLoad()
return {
default: () => React.createElement('div', null, 'history-dialog')
}
})
import CleanerPage from '../../src/renderer/src/pages/CleanerPage'
describe('CleanerPage history modal lazy loading', () => {
beforeEach(() => {
mockHistoryModalModuleLoad.mockClear()
})
it('does not load the history modal module on the initial render', () => {
renderToStaticMarkup(React.createElement(CleanerPage))
expect(mockHistoryModalModuleLoad).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,108 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const queryMock = vi.fn()
const createMock = vi.fn()
const trackDurationMock = vi.fn(async (fn: () => Promise<unknown>) => ({ result: await fn() }))
vi.mock('../../../../src/main/services/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn()
}),
getRequestId: () => 'test-request-id',
trackDuration: trackDurationMock
}))
vi.mock('../../../../src/main/services/database/index', () => ({
create: createMock
}))
describe('CleanerOperationHistoryDAO (PostgreSQL compatibility)', () => {
beforeEach(() => {
vi.clearAllMocks()
createMock.mockResolvedValue({
type: 'postgresql',
isConnected: () => true,
query: queryMock,
disconnect: vi.fn()
})
})
it('uses PostgreSQL-compatible aggregation in getBatches', async () => {
queryMock.mockResolvedValue({
rows: [],
columns: [],
rowCount: 0
})
const { CleanerOperationHistoryDAO } = await import(
'../../../../src/main/services/database/cleaner-operation-history-dao'
)
const dao = new CleanerOperationHistoryDAO()
await dao.getBatches(undefined, { limit: 10 })
expect(queryMock).toHaveBeenCalledTimes(1)
const sql = queryMock.mock.calls[0][0] as string
expect(sql).toContain('COALESCE(SUM(CASE WHEN o.Status = \'success\' THEN 1 ELSE 0 END), 0)')
expect(sql).toContain('COALESCE(SUM(CASE WHEN o.Status = \'failed\' THEN 1 ELSE 0 END), 0)')
expect(sql).toContain('MAX(CASE WHEN e.IsDryRun THEN 1 ELSE 0 END) as IsDryRun')
expect(sql).not.toContain('ISNULL(')
})
it('avoids SQL Server TOP syntax when checking delete permissions', async () => {
queryMock
.mockResolvedValueOnce({
rows: [
{
ID: 1,
BatchId: 'batch-1',
AttemptNumber: 1,
UserId: 7,
Username: 'tester',
OperationTime: new Date('2026-04-14T10:00:00.000Z'),
EndTime: null,
Status: 'success',
IsDryRun: false,
TotalOrders: 1,
OrdersProcessed: 1,
TotalMaterialsDeleted: 1,
TotalMaterialsSkipped: 0,
TotalMaterialsFailed: 0,
TotalUncertainDeletions: 0,
ErrorMessage: null,
AppVersion: '1.12.3'
}
],
columns: [],
rowCount: 1
})
.mockResolvedValueOnce({
rows: [],
columns: [],
rowCount: 0
})
.mockResolvedValue({
rows: [],
columns: [],
rowCount: 1
})
const { CleanerOperationHistoryDAO } = await import(
'../../../../src/main/services/database/cleaner-operation-history-dao'
)
const dao = new CleanerOperationHistoryDAO()
const result = await dao.deleteBatch('batch-1', 7, false)
expect(result).toEqual({ success: true })
const executedSql = queryMock.mock.calls.map(([sql]) => sql as string).join('\n')
expect(executedSql).not.toContain('TOP 1')
expect(executedSql).toContain('FROM "ERPAuto"."CleanerExecution"')
expect(executedSql).toContain('DELETE FROM "ERPAuto"."CleanerMaterialDetail"')
})
})