feat(cleaner): add configurable row number protection toggle
This commit is contained in:
@@ -66,6 +66,7 @@ cleaner:
|
||||
queryBatchSize: 100
|
||||
processConcurrency: 1
|
||||
sessionRefreshOrderThreshold: 160 # 会在 batch 边界检查;达到或超过阈值后,在当前 batch 完成后重建浏览器会话
|
||||
enableRowProtection: true # 行号保护:禁止删除行号 2000-7999 范围内的物料,关闭后不再检查行号范围
|
||||
|
||||
logging:
|
||||
level: info
|
||||
|
||||
@@ -100,7 +100,8 @@ const DEFAULT_CONFIG: FullConfig = {
|
||||
cleaner: {
|
||||
queryBatchSize: 100,
|
||||
processConcurrency: 1,
|
||||
sessionRefreshOrderThreshold: 160
|
||||
sessionRefreshOrderThreshold: 160,
|
||||
enableRowProtection: true
|
||||
},
|
||||
orderResolution: {
|
||||
tableName: '',
|
||||
|
||||
@@ -169,6 +169,7 @@ export interface ShouldDeleteParams {
|
||||
pendingQty: string
|
||||
materialCode: string
|
||||
deleteSet: Set<string>
|
||||
enableRowProtection: boolean
|
||||
}
|
||||
|
||||
function clampNumber(
|
||||
@@ -244,13 +245,13 @@ export class CleanerService {
|
||||
* Determine if a material should be deleted
|
||||
*/
|
||||
shouldDeleteMaterial(params: ShouldDeleteParams): boolean {
|
||||
const { rowNumber, pendingQty, materialCode, deleteSet } = params
|
||||
const { rowNumber, pendingQty, materialCode, deleteSet, enableRowProtection } = params
|
||||
|
||||
if (!deleteSet.has(materialCode)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (rowNumber >= 2000 && rowNumber < 8000) {
|
||||
if (enableRowProtection && rowNumber >= 2000 && rowNumber < 8000) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -262,12 +263,12 @@ export class CleanerService {
|
||||
}
|
||||
|
||||
getSkipReason(params: ShouldDeleteParams): string {
|
||||
const { rowNumber, pendingQty, materialCode, deleteSet } = params
|
||||
const { rowNumber, pendingQty, materialCode, deleteSet, enableRowProtection } = params
|
||||
|
||||
if (!deleteSet.has(materialCode)) {
|
||||
return '物料不在删除清单中'
|
||||
}
|
||||
if (rowNumber >= 2000 && rowNumber < 8000) {
|
||||
if (enableRowProtection && rowNumber >= 2000 && rowNumber < 8000) {
|
||||
return '行号在 2000-7999 范围内(受保护)'
|
||||
}
|
||||
if (pendingQty && pendingQty.trim() !== '') {
|
||||
@@ -317,6 +318,7 @@ export class CleanerService {
|
||||
input.sessionRefreshOrderThreshold && input.sessionRefreshOrderThreshold > 0
|
||||
? Math.trunc(input.sessionRefreshOrderThreshold)
|
||||
: DEFAULT_SESSION_REFRESH_ORDER_THRESHOLD
|
||||
const enableRowProtection = input.enableRowProtection ?? true
|
||||
|
||||
log.info('Starting cleaner', {
|
||||
totalOrders,
|
||||
@@ -446,6 +448,7 @@ export class CleanerService {
|
||||
detailPage: openedDetailPage,
|
||||
deleteSet,
|
||||
dryRun,
|
||||
enableRowProtection,
|
||||
expectedOrderNumber: orderNumber,
|
||||
progressState,
|
||||
onProgress: input.onProgress
|
||||
@@ -567,6 +570,7 @@ export class CleanerService {
|
||||
),
|
||||
deleteSet,
|
||||
dryRun,
|
||||
enableRowProtection,
|
||||
onProgress: input.onProgress
|
||||
})
|
||||
|
||||
@@ -1149,6 +1153,7 @@ export class CleanerService {
|
||||
detailPage: Page
|
||||
deleteSet: Set<string>
|
||||
dryRun: boolean
|
||||
enableRowProtection: boolean
|
||||
progressState: ProgressState
|
||||
expectedOrderNumber?: string
|
||||
onProgress?: (
|
||||
@@ -1157,7 +1162,7 @@ export class CleanerService {
|
||||
extra?: Partial<import('../../types/cleaner.types').CleanerProgress>
|
||||
) => void
|
||||
}): Promise<OrderCleanDetail> {
|
||||
const { detailPage, deleteSet, dryRun, progressState, expectedOrderNumber, onProgress } = params
|
||||
const { detailPage, deleteSet, dryRun, enableRowProtection, progressState, expectedOrderNumber, onProgress } = params
|
||||
const processStartTime = Date.now()
|
||||
|
||||
log.info('[ORDER_START] 开始处理订单', {
|
||||
@@ -1482,7 +1487,8 @@ export class CleanerService {
|
||||
rowNumber: rowNumInt,
|
||||
pendingQty,
|
||||
materialCode,
|
||||
deleteSet
|
||||
deleteSet,
|
||||
enableRowProtection
|
||||
})
|
||||
|
||||
if (shouldDelete && !dryRun) {
|
||||
@@ -1559,7 +1565,8 @@ export class CleanerService {
|
||||
rowNumber: rowNumInt,
|
||||
pendingQty,
|
||||
materialCode,
|
||||
deleteSet
|
||||
deleteSet,
|
||||
enableRowProtection
|
||||
})
|
||||
log.debug('[物料跳过] 物料不满足删除条件', {
|
||||
orderNumber,
|
||||
@@ -2097,13 +2104,14 @@ export class CleanerService {
|
||||
failedDetails: OrderCleanDetail[]
|
||||
deleteSet: Set<string>
|
||||
dryRun: boolean
|
||||
enableRowProtection: boolean
|
||||
onProgress?: (
|
||||
message: string,
|
||||
progress?: number,
|
||||
extra?: Partial<import('../../types/cleaner.types').CleanerProgress>
|
||||
) => void
|
||||
}): Promise<RetryResult> {
|
||||
const { workFrame, popupPage, failedDetails, deleteSet, dryRun, onProgress } = params
|
||||
const { workFrame, popupPage, failedDetails, deleteSet, dryRun, enableRowProtection, onProgress } = params
|
||||
|
||||
const result: RetryResult = {
|
||||
retriedOrders: 0,
|
||||
@@ -2204,6 +2212,7 @@ export class CleanerService {
|
||||
detailPage,
|
||||
deleteSet,
|
||||
dryRun,
|
||||
enableRowProtection,
|
||||
expectedOrderNumber: orderNumber,
|
||||
progressState: {
|
||||
ordersStarted: detailIndex,
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface CleanerInput {
|
||||
queryBatchSize?: number
|
||||
processConcurrency?: number
|
||||
sessionRefreshOrderThreshold?: number
|
||||
enableRowProtection?: boolean
|
||||
onProgress?: (message: string, progress?: number, extra?: Partial<CleanerProgress>) => void
|
||||
}
|
||||
|
||||
|
||||
@@ -118,7 +118,8 @@ export const validationConfigSchema = z.object({
|
||||
export const cleanerConfigSchema = z.object({
|
||||
queryBatchSize: z.number().int().min(1).max(100).default(100),
|
||||
processConcurrency: z.number().int().min(1).max(20).default(1),
|
||||
sessionRefreshOrderThreshold: z.number().int().positive().default(160)
|
||||
sessionRefreshOrderThreshold: z.number().int().positive().default(160),
|
||||
enableRowProtection: z.boolean().default(true)
|
||||
})
|
||||
export type CleanerConfig = z.infer<typeof cleanerConfigSchema>
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ interface CleanerExecutionBarProps {
|
||||
setDryRun: (value: boolean) => void
|
||||
headless: boolean
|
||||
setHeadless: (value: boolean) => void
|
||||
enableRowProtection: boolean
|
||||
setEnableRowProtection: (value: boolean) => void
|
||||
processConcurrency: number
|
||||
updateProcessConcurrency: (value: number) => void
|
||||
showSettingsMenu: boolean
|
||||
@@ -30,7 +32,9 @@ export function CleanerExecutionBar({
|
||||
setShowSettingsMenu,
|
||||
handleExecuteDeletion,
|
||||
isRunning,
|
||||
executeButtonRef
|
||||
executeButtonRef,
|
||||
enableRowProtection,
|
||||
setEnableRowProtection
|
||||
}: CleanerExecutionBarProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="bg-white border-t border-slate-200 p-4 flex justify-between items-center shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)] z-10 flex-shrink-0">
|
||||
@@ -82,6 +86,22 @@ export function CleanerExecutionBar({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-slate-100 pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-800">行号保护</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">
|
||||
保护 2000-7999 行号范围的物料不被删除
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setEnableRowProtection(!enableRowProtection)}
|
||||
className={`transition-colors flex-shrink-0 ml-4 ${enableRowProtection ? 'text-green-500' : 'text-slate-300'}`}
|
||||
>
|
||||
{enableRowProtection ? <ToggleRight size={32} /> : <ToggleLeft size={32} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-slate-100 pt-3 space-y-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-800">并行处理数量</div>
|
||||
|
||||
@@ -123,6 +123,7 @@ export async function runCleanerExecution(params: {
|
||||
queryBatchSize: number
|
||||
processConcurrency: number
|
||||
sessionRefreshOrderThreshold: number
|
||||
enableRowProtection: boolean
|
||||
selectedManagers: string[]
|
||||
}): Promise<CleanerReportData> {
|
||||
const cleanerDataResult = await window.electron.validation.getCleanerData({
|
||||
@@ -154,7 +155,8 @@ export async function runCleanerExecution(params: {
|
||||
headless: params.headless,
|
||||
queryBatchSize: params.queryBatchSize,
|
||||
processConcurrency: params.processConcurrency,
|
||||
sessionRefreshOrderThreshold: params.sessionRefreshOrderThreshold
|
||||
sessionRefreshOrderThreshold: params.sessionRefreshOrderThreshold,
|
||||
enableRowProtection: params.enableRowProtection
|
||||
})
|
||||
|
||||
const cleanerRunData = response.success ? (response.data as CleanerRunPayload | null) : null
|
||||
|
||||
@@ -63,6 +63,9 @@ export function useCleaner() {
|
||||
const [queryBatchSize, setQueryBatchSize] = useState(100)
|
||||
const [processConcurrency, setProcessConcurrency] = useState(1)
|
||||
const [sessionRefreshOrderThreshold, setSessionRefreshOrderThreshold] = useState(160)
|
||||
const [enableRowProtection, setEnableRowProtection] = useState(() =>
|
||||
getStoredBoolean('cleaner_enableRowProtection', true)
|
||||
)
|
||||
const [showSettingsMenu, setShowSettingsMenu] = useState(false)
|
||||
|
||||
// Inline editing state for manager field (Admin only)
|
||||
@@ -158,6 +161,10 @@ export function useCleaner() {
|
||||
sessionStorage.setItem('cleaner_headless', headless.toString())
|
||||
}, [headless])
|
||||
|
||||
useEffect(() => {
|
||||
sessionStorage.setItem('cleaner_enableRowProtection', enableRowProtection.toString())
|
||||
}, [enableRowProtection])
|
||||
|
||||
const updateProcessConcurrency = async (value: number) => {
|
||||
const clamped = Math.max(1, Math.min(20, value))
|
||||
setProcessConcurrency(clamped)
|
||||
@@ -380,6 +387,7 @@ export function useCleaner() {
|
||||
queryBatchSize,
|
||||
processConcurrency,
|
||||
sessionRefreshOrderThreshold,
|
||||
enableRowProtection,
|
||||
selectedManagers: Array.from(selectedManagers)
|
||||
})
|
||||
setReportData(result)
|
||||
@@ -445,6 +453,8 @@ export function useCleaner() {
|
||||
setProcessConcurrency,
|
||||
sessionRefreshOrderThreshold,
|
||||
setSessionRefreshOrderThreshold,
|
||||
enableRowProtection,
|
||||
setEnableRowProtection,
|
||||
updateProcessConcurrency,
|
||||
showSettingsMenu,
|
||||
setShowSettingsMenu,
|
||||
|
||||
@@ -42,6 +42,8 @@ const CleanerPage: React.FC = () => {
|
||||
setHeadless,
|
||||
processConcurrency,
|
||||
updateProcessConcurrency,
|
||||
enableRowProtection,
|
||||
setEnableRowProtection,
|
||||
showSettingsMenu,
|
||||
setShowSettingsMenu,
|
||||
filteredResults,
|
||||
@@ -124,6 +126,8 @@ const CleanerPage: React.FC = () => {
|
||||
setDryRun={setDryRun}
|
||||
headless={headless}
|
||||
setHeadless={setHeadless}
|
||||
enableRowProtection={enableRowProtection}
|
||||
setEnableRowProtection={setEnableRowProtection}
|
||||
processConcurrency={processConcurrency}
|
||||
updateProcessConcurrency={updateProcessConcurrency}
|
||||
showSettingsMenu={showSettingsMenu}
|
||||
|
||||
@@ -23,7 +23,8 @@ describe('CleanerService - Helper Methods', () => {
|
||||
rowNumber: 100,
|
||||
pendingQty: '',
|
||||
materialCode: 'MAT001',
|
||||
deleteSet
|
||||
deleteSet,
|
||||
enableRowProtection: true
|
||||
})
|
||||
|
||||
expect(result).toBe(true)
|
||||
@@ -34,7 +35,8 @@ describe('CleanerService - Helper Methods', () => {
|
||||
rowNumber: 100,
|
||||
pendingQty: '',
|
||||
materialCode: 'MAT999',
|
||||
deleteSet
|
||||
deleteSet,
|
||||
enableRowProtection: true
|
||||
})
|
||||
|
||||
expect(result).toBe(false)
|
||||
@@ -45,7 +47,8 @@ describe('CleanerService - Helper Methods', () => {
|
||||
rowNumber: 5000,
|
||||
pendingQty: '',
|
||||
materialCode: 'MAT001',
|
||||
deleteSet
|
||||
deleteSet,
|
||||
enableRowProtection: true
|
||||
})
|
||||
|
||||
expect(result).toBe(false)
|
||||
@@ -56,7 +59,8 @@ describe('CleanerService - Helper Methods', () => {
|
||||
rowNumber: 100,
|
||||
pendingQty: '5',
|
||||
materialCode: 'MAT001',
|
||||
deleteSet
|
||||
deleteSet,
|
||||
enableRowProtection: true
|
||||
})
|
||||
|
||||
expect(result).toBe(false)
|
||||
@@ -67,7 +71,8 @@ describe('CleanerService - Helper Methods', () => {
|
||||
rowNumber: 100,
|
||||
pendingQty: ' ',
|
||||
materialCode: 'MAT001',
|
||||
deleteSet
|
||||
deleteSet,
|
||||
enableRowProtection: true
|
||||
})
|
||||
|
||||
expect(result).toBe(true) // whitespace-only is treated as empty after trim
|
||||
@@ -80,7 +85,8 @@ describe('CleanerService - Helper Methods', () => {
|
||||
rowNumber: 1999,
|
||||
pendingQty: '',
|
||||
materialCode: 'MAT001',
|
||||
deleteSet
|
||||
deleteSet,
|
||||
enableRowProtection: true
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
@@ -90,7 +96,8 @@ describe('CleanerService - Helper Methods', () => {
|
||||
rowNumber: 2000,
|
||||
pendingQty: '',
|
||||
materialCode: 'MAT001',
|
||||
deleteSet
|
||||
deleteSet,
|
||||
enableRowProtection: true
|
||||
})
|
||||
).toBe(false)
|
||||
|
||||
@@ -100,7 +107,8 @@ describe('CleanerService - Helper Methods', () => {
|
||||
rowNumber: 7999,
|
||||
pendingQty: '',
|
||||
materialCode: 'MAT001',
|
||||
deleteSet
|
||||
deleteSet,
|
||||
enableRowProtection: true
|
||||
})
|
||||
).toBe(false)
|
||||
|
||||
@@ -110,10 +118,47 @@ describe('CleanerService - Helper Methods', () => {
|
||||
rowNumber: 8000,
|
||||
pendingQty: '',
|
||||
materialCode: 'MAT001',
|
||||
deleteSet
|
||||
deleteSet,
|
||||
enableRowProtection: true
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('should allow deletion when row protection is disabled (row in 2000-7999)', () => {
|
||||
const result = cleaner.shouldDeleteMaterial({
|
||||
rowNumber: 5000,
|
||||
pendingQty: '',
|
||||
materialCode: 'MAT001',
|
||||
deleteSet,
|
||||
enableRowProtection: false
|
||||
})
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should still block deletion when row protection is disabled but pendingQty is not empty', () => {
|
||||
const result = cleaner.shouldDeleteMaterial({
|
||||
rowNumber: 5000,
|
||||
pendingQty: '10',
|
||||
materialCode: 'MAT001',
|
||||
deleteSet,
|
||||
enableRowProtection: false
|
||||
})
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should still block deletion when row protection is disabled but material not in delete set', () => {
|
||||
const result = cleaner.shouldDeleteMaterial({
|
||||
rowNumber: 5000,
|
||||
pendingQty: '',
|
||||
materialCode: 'MAT999',
|
||||
deleteSet,
|
||||
enableRowProtection: false
|
||||
})
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSkipReason()', () => {
|
||||
@@ -125,7 +170,8 @@ describe('CleanerService - Helper Methods', () => {
|
||||
rowNumber: 3000,
|
||||
pendingQty: '',
|
||||
materialCode: 'MAT001',
|
||||
deleteSet
|
||||
deleteSet,
|
||||
enableRowProtection: true
|
||||
})
|
||||
|
||||
expect(reason).toBe('行号在 2000-7999 范围内(受保护)')
|
||||
@@ -136,13 +182,15 @@ describe('CleanerService - Helper Methods', () => {
|
||||
rowNumber: 100,
|
||||
pendingQty: '',
|
||||
materialCode: 'MAT001',
|
||||
deleteSet
|
||||
deleteSet,
|
||||
enableRowProtection: true
|
||||
})
|
||||
const reason = cleaner.getSkipReason({
|
||||
rowNumber: 100,
|
||||
pendingQty: '',
|
||||
materialCode: 'MAT001',
|
||||
deleteSet
|
||||
deleteSet,
|
||||
enableRowProtection: true
|
||||
})
|
||||
|
||||
expect(result).toBe(true)
|
||||
@@ -154,7 +202,8 @@ describe('CleanerService - Helper Methods', () => {
|
||||
rowNumber: 100,
|
||||
pendingQty: '',
|
||||
materialCode: 'MAT999',
|
||||
deleteSet
|
||||
deleteSet,
|
||||
enableRowProtection: true
|
||||
})
|
||||
|
||||
expect(reason).toBe('物料不在删除清单中')
|
||||
@@ -165,7 +214,8 @@ describe('CleanerService - Helper Methods', () => {
|
||||
rowNumber: 100,
|
||||
pendingQty: '10',
|
||||
materialCode: 'MAT001',
|
||||
deleteSet
|
||||
deleteSet,
|
||||
enableRowProtection: true
|
||||
})
|
||||
|
||||
expect(reason).toBe('累计待发数量不为空')
|
||||
|
||||
Reference in New Issue
Block a user