fix: restore cleaner history in postgresql
This commit is contained in:
@@ -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: '没有权限删除此批次' }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
19
src/renderer/src/components/cleaner-history-load-state.ts
Normal file
19
src/renderer/src/components/cleaner-history-load-state.ts
Normal 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'
|
||||||
|
}
|
||||||
@@ -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} />}
|
||||||
|
|||||||
29
tests/unit/cleaner-history-load-state.test.ts
Normal file
29
tests/unit/cleaner-history-load-state.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
107
tests/unit/cleaner-page-history-lazy.test.tsx
Normal file
107
tests/unit/cleaner-page-history-lazy.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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"')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user