From 056bf3908c0d551cfdebe274dda74b62d2aa9f66 Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Tue, 4 Aug 2026 09:43:31 +0800 Subject: [PATCH] feat(cleaner): add configurable row number protection toggle --- config.template.yaml | 1 + src/main/services/config/config-manager.ts | 3 +- src/main/services/erp/cleaner.ts | 25 ++++-- src/main/types/cleaner.types.ts | 1 + src/main/types/config.schema.ts | 3 +- .../cleaner/CleanerExecutionBar.tsx | 22 +++++- src/renderer/src/hooks/cleaner/api.ts | 4 +- src/renderer/src/hooks/useCleaner.ts | 10 +++ src/renderer/src/pages/CleanerPage.tsx | 4 + tests/unit/services/erp/cleaner.test.ts | 78 +++++++++++++++---- 10 files changed, 125 insertions(+), 26 deletions(-) diff --git a/config.template.yaml b/config.template.yaml index 7dce15e..7e922e0 100644 --- a/config.template.yaml +++ b/config.template.yaml @@ -66,6 +66,7 @@ cleaner: queryBatchSize: 100 processConcurrency: 1 sessionRefreshOrderThreshold: 160 # 会在 batch 边界检查;达到或超过阈值后,在当前 batch 完成后重建浏览器会话 + enableRowProtection: true # 行号保护:禁止删除行号 2000-7999 范围内的物料,关闭后不再检查行号范围 logging: level: info diff --git a/src/main/services/config/config-manager.ts b/src/main/services/config/config-manager.ts index a113125..43d3114 100644 --- a/src/main/services/config/config-manager.ts +++ b/src/main/services/config/config-manager.ts @@ -100,7 +100,8 @@ const DEFAULT_CONFIG: FullConfig = { cleaner: { queryBatchSize: 100, processConcurrency: 1, - sessionRefreshOrderThreshold: 160 + sessionRefreshOrderThreshold: 160, + enableRowProtection: true }, orderResolution: { tableName: '', diff --git a/src/main/services/erp/cleaner.ts b/src/main/services/erp/cleaner.ts index ff9ec75..4cfab5d 100644 --- a/src/main/services/erp/cleaner.ts +++ b/src/main/services/erp/cleaner.ts @@ -169,6 +169,7 @@ export interface ShouldDeleteParams { pendingQty: string materialCode: string deleteSet: Set + 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 dryRun: boolean + enableRowProtection: boolean progressState: ProgressState expectedOrderNumber?: string onProgress?: ( @@ -1157,7 +1162,7 @@ export class CleanerService { extra?: Partial ) => void }): Promise { - 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 dryRun: boolean + enableRowProtection: boolean onProgress?: ( message: string, progress?: number, extra?: Partial ) => void }): Promise { - 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, diff --git a/src/main/types/cleaner.types.ts b/src/main/types/cleaner.types.ts index d241665..8e9a41e 100644 --- a/src/main/types/cleaner.types.ts +++ b/src/main/types/cleaner.types.ts @@ -20,6 +20,7 @@ export interface CleanerInput { queryBatchSize?: number processConcurrency?: number sessionRefreshOrderThreshold?: number + enableRowProtection?: boolean onProgress?: (message: string, progress?: number, extra?: Partial) => void } diff --git a/src/main/types/config.schema.ts b/src/main/types/config.schema.ts index c1ec970..ecff5de 100644 --- a/src/main/types/config.schema.ts +++ b/src/main/types/config.schema.ts @@ -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 diff --git a/src/renderer/src/components/cleaner/CleanerExecutionBar.tsx b/src/renderer/src/components/cleaner/CleanerExecutionBar.tsx index 35e34bc..40dec75 100644 --- a/src/renderer/src/components/cleaner/CleanerExecutionBar.tsx +++ b/src/renderer/src/components/cleaner/CleanerExecutionBar.tsx @@ -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 (
@@ -82,6 +86,22 @@ export function CleanerExecutionBar({
+
+
+
+
行号保护
+
+ 保护 2000-7999 行号范围的物料不被删除 +
+
+ +
+
并行处理数量
diff --git a/src/renderer/src/hooks/cleaner/api.ts b/src/renderer/src/hooks/cleaner/api.ts index a778b8f..a3afe4b 100644 --- a/src/renderer/src/hooks/cleaner/api.ts +++ b/src/renderer/src/hooks/cleaner/api.ts @@ -123,6 +123,7 @@ export async function runCleanerExecution(params: { queryBatchSize: number processConcurrency: number sessionRefreshOrderThreshold: number + enableRowProtection: boolean selectedManagers: string[] }): Promise { 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 diff --git a/src/renderer/src/hooks/useCleaner.ts b/src/renderer/src/hooks/useCleaner.ts index e152dc1..f51851a 100644 --- a/src/renderer/src/hooks/useCleaner.ts +++ b/src/renderer/src/hooks/useCleaner.ts @@ -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, diff --git a/src/renderer/src/pages/CleanerPage.tsx b/src/renderer/src/pages/CleanerPage.tsx index db98dd4..6b9a49d 100644 --- a/src/renderer/src/pages/CleanerPage.tsx +++ b/src/renderer/src/pages/CleanerPage.tsx @@ -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} diff --git a/tests/unit/services/erp/cleaner.test.ts b/tests/unit/services/erp/cleaner.test.ts index 7a2200e..ef706a8 100644 --- a/tests/unit/services/erp/cleaner.test.ts +++ b/tests/unit/services/erp/cleaner.test.ts @@ -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('累计待发数量不为空')