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

View File

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

View File

@@ -169,6 +169,7 @@ export interface ShouldDeleteParams {
pendingQty: string pendingQty: string
materialCode: string materialCode: string
deleteSet: Set<string> deleteSet: Set<string>
enableRowProtection: boolean
} }
function clampNumber( function clampNumber(
@@ -244,13 +245,13 @@ export class CleanerService {
* Determine if a material should be deleted * Determine if a material should be deleted
*/ */
shouldDeleteMaterial(params: ShouldDeleteParams): boolean { shouldDeleteMaterial(params: ShouldDeleteParams): boolean {
const { rowNumber, pendingQty, materialCode, deleteSet } = params const { rowNumber, pendingQty, materialCode, deleteSet, enableRowProtection } = params
if (!deleteSet.has(materialCode)) { if (!deleteSet.has(materialCode)) {
return false return false
} }
if (rowNumber >= 2000 && rowNumber < 8000) { if (enableRowProtection && rowNumber >= 2000 && rowNumber < 8000) {
return false return false
} }
@@ -262,12 +263,12 @@ export class CleanerService {
} }
getSkipReason(params: ShouldDeleteParams): string { getSkipReason(params: ShouldDeleteParams): string {
const { rowNumber, pendingQty, materialCode, deleteSet } = params const { rowNumber, pendingQty, materialCode, deleteSet, enableRowProtection } = params
if (!deleteSet.has(materialCode)) { if (!deleteSet.has(materialCode)) {
return '物料不在删除清单中' return '物料不在删除清单中'
} }
if (rowNumber >= 2000 && rowNumber < 8000) { if (enableRowProtection && rowNumber >= 2000 && rowNumber < 8000) {
return '行号在 2000-7999 范围内(受保护)' return '行号在 2000-7999 范围内(受保护)'
} }
if (pendingQty && pendingQty.trim() !== '') { if (pendingQty && pendingQty.trim() !== '') {
@@ -317,6 +318,7 @@ export class CleanerService {
input.sessionRefreshOrderThreshold && input.sessionRefreshOrderThreshold > 0 input.sessionRefreshOrderThreshold && input.sessionRefreshOrderThreshold > 0
? Math.trunc(input.sessionRefreshOrderThreshold) ? Math.trunc(input.sessionRefreshOrderThreshold)
: DEFAULT_SESSION_REFRESH_ORDER_THRESHOLD : DEFAULT_SESSION_REFRESH_ORDER_THRESHOLD
const enableRowProtection = input.enableRowProtection ?? true
log.info('Starting cleaner', { log.info('Starting cleaner', {
totalOrders, totalOrders,
@@ -446,6 +448,7 @@ export class CleanerService {
detailPage: openedDetailPage, detailPage: openedDetailPage,
deleteSet, deleteSet,
dryRun, dryRun,
enableRowProtection,
expectedOrderNumber: orderNumber, expectedOrderNumber: orderNumber,
progressState, progressState,
onProgress: input.onProgress onProgress: input.onProgress
@@ -567,6 +570,7 @@ export class CleanerService {
), ),
deleteSet, deleteSet,
dryRun, dryRun,
enableRowProtection,
onProgress: input.onProgress onProgress: input.onProgress
}) })
@@ -1149,6 +1153,7 @@ export class CleanerService {
detailPage: Page detailPage: Page
deleteSet: Set<string> deleteSet: Set<string>
dryRun: boolean dryRun: boolean
enableRowProtection: boolean
progressState: ProgressState progressState: ProgressState
expectedOrderNumber?: string expectedOrderNumber?: string
onProgress?: ( onProgress?: (
@@ -1157,7 +1162,7 @@ export class CleanerService {
extra?: Partial<import('../../types/cleaner.types').CleanerProgress> extra?: Partial<import('../../types/cleaner.types').CleanerProgress>
) => void ) => void
}): Promise<OrderCleanDetail> { }): Promise<OrderCleanDetail> {
const { detailPage, deleteSet, dryRun, progressState, expectedOrderNumber, onProgress } = params const { detailPage, deleteSet, dryRun, enableRowProtection, progressState, expectedOrderNumber, onProgress } = params
const processStartTime = Date.now() const processStartTime = Date.now()
log.info('[ORDER_START] 开始处理订单', { log.info('[ORDER_START] 开始处理订单', {
@@ -1482,7 +1487,8 @@ export class CleanerService {
rowNumber: rowNumInt, rowNumber: rowNumInt,
pendingQty, pendingQty,
materialCode, materialCode,
deleteSet deleteSet,
enableRowProtection
}) })
if (shouldDelete && !dryRun) { if (shouldDelete && !dryRun) {
@@ -1559,7 +1565,8 @@ export class CleanerService {
rowNumber: rowNumInt, rowNumber: rowNumInt,
pendingQty, pendingQty,
materialCode, materialCode,
deleteSet deleteSet,
enableRowProtection
}) })
log.debug('[物料跳过] 物料不满足删除条件', { log.debug('[物料跳过] 物料不满足删除条件', {
orderNumber, orderNumber,
@@ -2097,13 +2104,14 @@ export class CleanerService {
failedDetails: OrderCleanDetail[] failedDetails: OrderCleanDetail[]
deleteSet: Set<string> deleteSet: Set<string>
dryRun: boolean dryRun: boolean
enableRowProtection: boolean
onProgress?: ( onProgress?: (
message: string, message: string,
progress?: number, progress?: number,
extra?: Partial<import('../../types/cleaner.types').CleanerProgress> extra?: Partial<import('../../types/cleaner.types').CleanerProgress>
) => void ) => void
}): Promise<RetryResult> { }): Promise<RetryResult> {
const { workFrame, popupPage, failedDetails, deleteSet, dryRun, onProgress } = params const { workFrame, popupPage, failedDetails, deleteSet, dryRun, enableRowProtection, onProgress } = params
const result: RetryResult = { const result: RetryResult = {
retriedOrders: 0, retriedOrders: 0,
@@ -2204,6 +2212,7 @@ export class CleanerService {
detailPage, detailPage,
deleteSet, deleteSet,
dryRun, dryRun,
enableRowProtection,
expectedOrderNumber: orderNumber, expectedOrderNumber: orderNumber,
progressState: { progressState: {
ordersStarted: detailIndex, ordersStarted: detailIndex,

View File

@@ -20,6 +20,7 @@ export interface CleanerInput {
queryBatchSize?: number queryBatchSize?: number
processConcurrency?: number processConcurrency?: number
sessionRefreshOrderThreshold?: number sessionRefreshOrderThreshold?: number
enableRowProtection?: boolean
onProgress?: (message: string, progress?: number, extra?: Partial<CleanerProgress>) => void 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({ export const cleanerConfigSchema = z.object({
queryBatchSize: z.number().int().min(1).max(100).default(100), queryBatchSize: z.number().int().min(1).max(100).default(100),
processConcurrency: z.number().int().min(1).max(20).default(1), 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> export type CleanerConfig = z.infer<typeof cleanerConfigSchema>

View File

@@ -8,6 +8,8 @@ interface CleanerExecutionBarProps {
setDryRun: (value: boolean) => void setDryRun: (value: boolean) => void
headless: boolean headless: boolean
setHeadless: (value: boolean) => void setHeadless: (value: boolean) => void
enableRowProtection: boolean
setEnableRowProtection: (value: boolean) => void
processConcurrency: number processConcurrency: number
updateProcessConcurrency: (value: number) => void updateProcessConcurrency: (value: number) => void
showSettingsMenu: boolean showSettingsMenu: boolean
@@ -30,7 +32,9 @@ export function CleanerExecutionBar({
setShowSettingsMenu, setShowSettingsMenu,
handleExecuteDeletion, handleExecuteDeletion,
isRunning, isRunning,
executeButtonRef executeButtonRef,
enableRowProtection,
setEnableRowProtection
}: CleanerExecutionBarProps): React.JSX.Element { }: CleanerExecutionBarProps): React.JSX.Element {
return ( 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"> <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> </button>
</div> </div>
</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 className="border-t border-slate-100 pt-3 space-y-3">
<div> <div>
<div className="text-sm font-medium text-slate-800"></div> <div className="text-sm font-medium text-slate-800"></div>

View File

@@ -123,6 +123,7 @@ export async function runCleanerExecution(params: {
queryBatchSize: number queryBatchSize: number
processConcurrency: number processConcurrency: number
sessionRefreshOrderThreshold: number sessionRefreshOrderThreshold: number
enableRowProtection: boolean
selectedManagers: string[] selectedManagers: string[]
}): Promise<CleanerReportData> { }): Promise<CleanerReportData> {
const cleanerDataResult = await window.electron.validation.getCleanerData({ const cleanerDataResult = await window.electron.validation.getCleanerData({
@@ -154,7 +155,8 @@ export async function runCleanerExecution(params: {
headless: params.headless, headless: params.headless,
queryBatchSize: params.queryBatchSize, queryBatchSize: params.queryBatchSize,
processConcurrency: params.processConcurrency, processConcurrency: params.processConcurrency,
sessionRefreshOrderThreshold: params.sessionRefreshOrderThreshold sessionRefreshOrderThreshold: params.sessionRefreshOrderThreshold,
enableRowProtection: params.enableRowProtection
}) })
const cleanerRunData = response.success ? (response.data as CleanerRunPayload | null) : null 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 [queryBatchSize, setQueryBatchSize] = useState(100)
const [processConcurrency, setProcessConcurrency] = useState(1) const [processConcurrency, setProcessConcurrency] = useState(1)
const [sessionRefreshOrderThreshold, setSessionRefreshOrderThreshold] = useState(160) const [sessionRefreshOrderThreshold, setSessionRefreshOrderThreshold] = useState(160)
const [enableRowProtection, setEnableRowProtection] = useState(() =>
getStoredBoolean('cleaner_enableRowProtection', true)
)
const [showSettingsMenu, setShowSettingsMenu] = useState(false) const [showSettingsMenu, setShowSettingsMenu] = useState(false)
// Inline editing state for manager field (Admin only) // Inline editing state for manager field (Admin only)
@@ -158,6 +161,10 @@ export function useCleaner() {
sessionStorage.setItem('cleaner_headless', headless.toString()) sessionStorage.setItem('cleaner_headless', headless.toString())
}, [headless]) }, [headless])
useEffect(() => {
sessionStorage.setItem('cleaner_enableRowProtection', enableRowProtection.toString())
}, [enableRowProtection])
const updateProcessConcurrency = async (value: number) => { const updateProcessConcurrency = async (value: number) => {
const clamped = Math.max(1, Math.min(20, value)) const clamped = Math.max(1, Math.min(20, value))
setProcessConcurrency(clamped) setProcessConcurrency(clamped)
@@ -380,6 +387,7 @@ export function useCleaner() {
queryBatchSize, queryBatchSize,
processConcurrency, processConcurrency,
sessionRefreshOrderThreshold, sessionRefreshOrderThreshold,
enableRowProtection,
selectedManagers: Array.from(selectedManagers) selectedManagers: Array.from(selectedManagers)
}) })
setReportData(result) setReportData(result)
@@ -445,6 +453,8 @@ export function useCleaner() {
setProcessConcurrency, setProcessConcurrency,
sessionRefreshOrderThreshold, sessionRefreshOrderThreshold,
setSessionRefreshOrderThreshold, setSessionRefreshOrderThreshold,
enableRowProtection,
setEnableRowProtection,
updateProcessConcurrency, updateProcessConcurrency,
showSettingsMenu, showSettingsMenu,
setShowSettingsMenu, setShowSettingsMenu,

View File

@@ -42,6 +42,8 @@ const CleanerPage: React.FC = () => {
setHeadless, setHeadless,
processConcurrency, processConcurrency,
updateProcessConcurrency, updateProcessConcurrency,
enableRowProtection,
setEnableRowProtection,
showSettingsMenu, showSettingsMenu,
setShowSettingsMenu, setShowSettingsMenu,
filteredResults, filteredResults,
@@ -124,6 +126,8 @@ const CleanerPage: React.FC = () => {
setDryRun={setDryRun} setDryRun={setDryRun}
headless={headless} headless={headless}
setHeadless={setHeadless} setHeadless={setHeadless}
enableRowProtection={enableRowProtection}
setEnableRowProtection={setEnableRowProtection}
processConcurrency={processConcurrency} processConcurrency={processConcurrency}
updateProcessConcurrency={updateProcessConcurrency} updateProcessConcurrency={updateProcessConcurrency}
showSettingsMenu={showSettingsMenu} showSettingsMenu={showSettingsMenu}

View File

@@ -23,7 +23,8 @@ describe('CleanerService - Helper Methods', () => {
rowNumber: 100, rowNumber: 100,
pendingQty: '', pendingQty: '',
materialCode: 'MAT001', materialCode: 'MAT001',
deleteSet deleteSet,
enableRowProtection: true
}) })
expect(result).toBe(true) expect(result).toBe(true)
@@ -34,7 +35,8 @@ describe('CleanerService - Helper Methods', () => {
rowNumber: 100, rowNumber: 100,
pendingQty: '', pendingQty: '',
materialCode: 'MAT999', materialCode: 'MAT999',
deleteSet deleteSet,
enableRowProtection: true
}) })
expect(result).toBe(false) expect(result).toBe(false)
@@ -45,7 +47,8 @@ describe('CleanerService - Helper Methods', () => {
rowNumber: 5000, rowNumber: 5000,
pendingQty: '', pendingQty: '',
materialCode: 'MAT001', materialCode: 'MAT001',
deleteSet deleteSet,
enableRowProtection: true
}) })
expect(result).toBe(false) expect(result).toBe(false)
@@ -56,7 +59,8 @@ describe('CleanerService - Helper Methods', () => {
rowNumber: 100, rowNumber: 100,
pendingQty: '5', pendingQty: '5',
materialCode: 'MAT001', materialCode: 'MAT001',
deleteSet deleteSet,
enableRowProtection: true
}) })
expect(result).toBe(false) expect(result).toBe(false)
@@ -67,7 +71,8 @@ describe('CleanerService - Helper Methods', () => {
rowNumber: 100, rowNumber: 100,
pendingQty: ' ', pendingQty: ' ',
materialCode: 'MAT001', materialCode: 'MAT001',
deleteSet deleteSet,
enableRowProtection: true
}) })
expect(result).toBe(true) // whitespace-only is treated as empty after trim expect(result).toBe(true) // whitespace-only is treated as empty after trim
@@ -80,7 +85,8 @@ describe('CleanerService - Helper Methods', () => {
rowNumber: 1999, rowNumber: 1999,
pendingQty: '', pendingQty: '',
materialCode: 'MAT001', materialCode: 'MAT001',
deleteSet deleteSet,
enableRowProtection: true
}) })
).toBe(true) ).toBe(true)
@@ -90,7 +96,8 @@ describe('CleanerService - Helper Methods', () => {
rowNumber: 2000, rowNumber: 2000,
pendingQty: '', pendingQty: '',
materialCode: 'MAT001', materialCode: 'MAT001',
deleteSet deleteSet,
enableRowProtection: true
}) })
).toBe(false) ).toBe(false)
@@ -100,7 +107,8 @@ describe('CleanerService - Helper Methods', () => {
rowNumber: 7999, rowNumber: 7999,
pendingQty: '', pendingQty: '',
materialCode: 'MAT001', materialCode: 'MAT001',
deleteSet deleteSet,
enableRowProtection: true
}) })
).toBe(false) ).toBe(false)
@@ -110,10 +118,47 @@ describe('CleanerService - Helper Methods', () => {
rowNumber: 8000, rowNumber: 8000,
pendingQty: '', pendingQty: '',
materialCode: 'MAT001', materialCode: 'MAT001',
deleteSet deleteSet,
enableRowProtection: true
}) })
).toBe(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()', () => { describe('getSkipReason()', () => {
@@ -125,7 +170,8 @@ describe('CleanerService - Helper Methods', () => {
rowNumber: 3000, rowNumber: 3000,
pendingQty: '', pendingQty: '',
materialCode: 'MAT001', materialCode: 'MAT001',
deleteSet deleteSet,
enableRowProtection: true
}) })
expect(reason).toBe('行号在 2000-7999 范围内(受保护)') expect(reason).toBe('行号在 2000-7999 范围内(受保护)')
@@ -136,13 +182,15 @@ describe('CleanerService - Helper Methods', () => {
rowNumber: 100, rowNumber: 100,
pendingQty: '', pendingQty: '',
materialCode: 'MAT001', materialCode: 'MAT001',
deleteSet deleteSet,
enableRowProtection: true
}) })
const reason = cleaner.getSkipReason({ const reason = cleaner.getSkipReason({
rowNumber: 100, rowNumber: 100,
pendingQty: '', pendingQty: '',
materialCode: 'MAT001', materialCode: 'MAT001',
deleteSet deleteSet,
enableRowProtection: true
}) })
expect(result).toBe(true) expect(result).toBe(true)
@@ -154,7 +202,8 @@ describe('CleanerService - Helper Methods', () => {
rowNumber: 100, rowNumber: 100,
pendingQty: '', pendingQty: '',
materialCode: 'MAT999', materialCode: 'MAT999',
deleteSet deleteSet,
enableRowProtection: true
}) })
expect(reason).toBe('物料不在删除清单中') expect(reason).toBe('物料不在删除清单中')
@@ -165,7 +214,8 @@ describe('CleanerService - Helper Methods', () => {
rowNumber: 100, rowNumber: 100,
pendingQty: '10', pendingQty: '10',
materialCode: 'MAT001', materialCode: 'MAT001',
deleteSet deleteSet,
enableRowProtection: true
}) })
expect(reason).toBe('累计待发数量不为空') expect(reason).toBe('累计待发数量不为空')