feat(cleaner): add configurable row number protection toggle

This commit is contained in:
Misaka_Company
2026-08-04 09:43:31 +08:00
parent 98567d69f0
commit 056bf3908c
10 changed files with 125 additions and 26 deletions

View File

@@ -66,6 +66,7 @@ cleaner:
queryBatchSize: 100
processConcurrency: 1
sessionRefreshOrderThreshold: 160 # 会在 batch 边界检查;达到或超过阈值后,在当前 batch 完成后重建浏览器会话
enableRowProtection: true # 行号保护:禁止删除行号 2000-7999 范围内的物料,关闭后不再检查行号范围
logging:
level: info

View File

@@ -100,7 +100,8 @@ const DEFAULT_CONFIG: FullConfig = {
cleaner: {
queryBatchSize: 100,
processConcurrency: 1,
sessionRefreshOrderThreshold: 160
sessionRefreshOrderThreshold: 160,
enableRowProtection: true
},
orderResolution: {
tableName: '',

View File

@@ -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,

View File

@@ -20,6 +20,7 @@ export interface CleanerInput {
queryBatchSize?: number
processConcurrency?: number
sessionRefreshOrderThreshold?: number
enableRowProtection?: boolean
onProgress?: (message: string, progress?: number, extra?: Partial<CleanerProgress>) => void
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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,

View File

@@ -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}

View File

@@ -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('累计待发数量不为空')