diff --git a/docs/cleaner-role-based-flow.md b/docs/cleaner-role-based-flow.md new file mode 100644 index 0000000..5a7c9a6 --- /dev/null +++ b/docs/cleaner-role-based-flow.md @@ -0,0 +1,308 @@ +# 清理器角色差异流程 — Admin vs User + +**文档版本**: 1.1 +**创建日期**: 2026-04-06 +**面向对象**: 开发人员 + +## 概述 + +清理器(Cleaner)在决定"哪些物料需要被清除"时,Admin 和 User 两个角色存在系统性的差异。这些差异贯穿三个阶段:**初始化 → 校验确认 → 执行清理**。 + +本文档使用 Mermaid 图表说明每个阶段的角色分支逻辑。 + +--- + +## 全局流程概览 + +```mermaid +flowchart TB + subgraph init["阶段一:页面初始化"] + I1([页面加载]) --> I2{角色判断} + I2 -->|Admin| I3["管理员列表 ← 全部负责人
默认选中全部"] + I2 -->|User| I4["管理员列表 ← 空
默认选中仅自己"] + end + + subgraph validate["阶段二:校验 → 勾选 → 同步数据库"] + V1([点击校验]) --> V2["后端查询物料
(不区分角色)"] + V2 --> V3["物料匹配算法
(User 有覆盖匹配)"] + V3 --> V4{角色判断} + V4 -->|Admin| V5["显示全部物料
侧边栏可按负责人筛选"] + V4 -->|User| V6["仅显示自己的物料
+ 无负责人的物料"] + V5 --> V7["用户勾选/取消勾选"] + V6 --> V7 + V7 --> V8{点击同步数据库} + V8 --> V9{角色判断} + V9 -->|Admin| V10["处理范围:全部校验结果"] + V9 -->|User| V11["处理范围:仅筛选后结果"] + end + + subgraph execute["阶段三:执行清理(ERP 删除)"] + E1([点击执行清理]) --> E2["getCleanerData(selectedManagers)
获取物料代码"] + E2 --> E3{角色判断} + E3 -->|Admin| E3a{selectedManagers
非空?} + E3a -->|"是"| E4["SQL WHERE ManagerName IN (选中)
从 MaterialsToBeDeleted 获取"] + E3a -->|"否"| E4b["从 DiscreteMaterialPlanData
按 orderNumbers 获取"] + E3 -->|User| E5["SQL WHERE ManagerName = 用户
仅获取自己的物料代码"] + E4 --> E6["传递给 runCleaner 执行"] + E4b --> E6 + E5 --> E6 + E6 --> E7([在 ERP 中删除物料]) + end + + init --> validate --> execute +``` + +--- + +## 阶段一:页面初始化 + +**源码位置**: `src/renderer/src/hooks/cleaner/api.ts:25-52` 与 `src/renderer/src/hooks/useCleaner.ts:98-112` + +```mermaid +flowchart TB + Start([页面加载]) --> GetAdmin["调用 auth:isAdmin
判断是否管理员"] + GetAdmin --> GetUser["调用 auth:getCurrentUser
获取当前用户名"] + GetUser --> RoleCheck{isAdmin?} + + RoleCheck -->|Admin| GetManagers["调用 materials:getManagers
获取全部负责人列表"] + GetManagers --> SelectAll["selectedManagers ← 全部负责人
(默认全选)"] + SelectAll --> RenderSidebar["渲染 CleanerSidebar
显示负责人复选框"] + + RoleCheck -->|User| SetSelf["selectedManagers ← {currentUsername}
(仅选中自己)"] + SetSelf --> NoSidebar["不渲染 CleanerSidebar
无侧边栏"] + + RenderSidebar --> Ready([就绪]) + NoSidebar --> Ready +``` + +**差异总结**: + +| 维度 | Admin | User | +|------|-------|------| +| 侧边栏 | 有 CleanerSidebar | 无 | +| 管理员列表 | 查询全部负责人 | 不查询 | +| 默认选中 | 所有负责人 | 仅自己 | + +--- + +## 阶段二:校验 → 勾选 → 同步数据库 + +### 2.1 物料校验(后端,不区分角色) + +**源码位置**: `src/main/services/validation/validation-application-service.ts` + +校验阶段后端查询不区分角色,Admin 和 User 拿到相同的物料数据。区别在于**匹配算法**: + +```mermaid +flowchart TB + Start([遍历每条物料记录]) --> P1{"优先级1
MaterialsToBeDeleted
精确匹配 MaterialCode?"} + + P1 -->|"匹配"| SetManager["managerName ← 表中记录
isMarkedForDeletion = true"] + P1 -->|"未匹配"| P2{"优先级2
MaterialsTypeToBeDeleted
MaterialName 包含匹配?"} + + P2 -->|"匹配"| SetType["managerName ← 类型关键词负责人
matchedTypeKeyword ← 匹配项"] + P2 -->|"未匹配"| SetNull["managerName = null"] + + SetManager --> RoleCheck{角色?} + SetType --> RoleCheck + SetNull --> RoleCheck + + RoleCheck -->|"Admin"| Skip["跳过覆盖
使用当前结果"] + RoleCheck -->|"User"| P3{"优先级3(User 覆盖)
自己的类型关键词匹配?"} + + P3 -->|"匹配"| Override["强制覆盖
managerName ← 当前用户"] + P3 -->|"未匹配"| Keep["保持当前结果"] + Skip --> Next(["下一条物料"]) + Override --> Next + Keep --> Next +``` + +**匹配优先级说明**: + +| 优先级 | 数据源 | 匹配方式 | 适用角色 | +|--------|--------|----------|----------| +| 1(最高) | `MaterialsToBeDeleted` | MaterialCode 精确匹配 | 全部 | +| 2 | `MaterialsTypeToBeDeleted` | MaterialName 包含匹配 | 全部 | +| 3(User 覆盖) | 当前用户的类型关键词 | MaterialName 包含匹配 | 仅 User | + +> **优先级 3 的作用**:当某个物料按优先级 2 被分配给其他负责人,但当前 User 有匹配的类型关键词时,会强制覆盖为自己的。这确保 User 不会为他人操作物料。 + +### 2.2 前端显示过滤 + +**源码位置**: `src/renderer/src/hooks/cleaner/helpers.ts:34-57` + +校验结果返回前端后,会根据角色进行显示过滤: + +```mermaid +flowchart TB + Input([校验结果 validationResults]) --> RoleCheck{角色判断} + + RoleCheck -->|Admin| FilterManagers["按侧边栏选中的负责人过滤
selectedManagers.has(managerName)
|| !managerName"] + RoleCheck -->|User| FilterSelf["仅显示自己的 + 无负责人的
managerName === currentUsername
|| !managerName"] + + FilterManagers --> FilterHidden["排除已隐藏的物料
!hiddenItems.has(materialCode)"] + FilterSelf --> FilterHidden + + FilterHidden --> Output([filteredResults
用于表格显示]) +``` + +### 2.3 确认删除(同步数据库) + +**源码位置**: `src/renderer/src/hooks/useCleaner.ts:289-344` + +```mermaid +flowchart TB + Start([点击确认删除]) --> RoleScope{角色判断} + + RoleScope -->|Admin| UseAll["resultsToProcess = validationResults
处理全部校验结果"] + RoleScope -->|User| UseFiltered["resultsToProcess = filteredResults
仅处理筛选后结果"] + + UseAll --> BuildPlan["buildDeletionPlan(resultsToProcess, selectedItems)"] + UseFiltered --> BuildPlan + + BuildPlan --> Loop["遍历 resultsToProcess"] + Loop --> Check{物料是否勾选?} + + Check -->|"已勾选"| HasManager{有负责人?} + Check -->|"未勾选"| ToDelete["加入 materialsToDelete
从数据库移除标记"] + + HasManager -->|"有"| ToUpsert["加入 materialsToUpsert
写入/更新到数据库"] + HasManager -->|"无"| Missing["加入 missingManager
阻止操作"] + + ToUpsert --> Save["调用 materials:upsertBatch"] + ToDelete --> Del["调用 materials:delete"] + Missing --> Warn(["弹窗警告:缺少负责人"]) + Save --> Done([完成]) + Del --> Done +``` + +**关键代码**: + +```typescript +// Admin 处理全部结果,User 只处理筛选后的结果 +const resultsToProcess = isAdmin ? validationResults : filteredResults +``` + +**差异总结**: + +| 维度 | Admin | User | +|------|-------|------| +| 处理范围 | `validationResults`(全部) | `filteredResults`(自己的+无负责人的) | +| 可操作物料 | 所有负责人的物料 | 仅自己的 + 无负责人的 | +| 能否修改他人数据 | 是 | 否 | + +--- + +## 阶段三:执行清理(ERP 删除) + +**源码位置**: +- 前端调用: `src/renderer/src/hooks/cleaner/api.ts:116-166` +- 获取数据: `src/main/services/validation/validation-application-service.ts:497-655` +- 执行删除: `src/main/services/cleaner/cleaner-application-service.ts` + +```mermaid +sequenceDiagram + participant UI as 前端 useCleaner + participant API as api.ts + participant Main as 主进程 + participant DB as 数据库 + participant ERP as ERP 系统 + + UI->>API: runCleanerExecution({ dryRun, selectedManagers, ... }) + API->>Main: getCleanerData({ selectedManagers }) + + alt Admin + selectedManagers 非空 + Main->>DB: SELECT MaterialCode FROM MaterialsToBeDeleted
WHERE ManagerName IN (@manager0, @manager1, ...) + Note over Main,DB: 按选中的负责人过滤
从 MaterialsToBeDeleted 获取 + else Admin + selectedManagers 为空 + Main->>DB: SELECT DISTINCT MaterialCode FROM DiscreteMaterialPlanData
WHERE SourceNumber IN (orderNumbers) + Note over Main,DB: 按订单号查询
从 DiscreteMaterialPlanData 获取 + else User + Main->>DB: SELECT MaterialCode FROM MaterialsToBeDeleted
WHERE ManagerName = @username + Note over Main,DB: 按 ManagerName 过滤
仅获取自己的物料代码 + end + + DB-->>Main: materialCodes[] + Main-->>API: { orderNumbers, materialCodes } + + Note over API: 传入角色过滤后的 materialCodes + API->>Main: cleaner.runCleaner({ orderNumbers, materialCodes, ... }) + + Main->>ERP: 按订单遍历,删除指定物料 + ERP-->>Main: 删除结果 + Main-->>API: CleanerResult + API-->>UI: 显示执行报告 +``` + +**SQL 差异**: + +```mermaid +flowchart TB + subgraph AdminWithMgr["Admin + selectedManagers 非空"] + A1["SELECT MaterialCode
FROM MaterialsToBeDeleted
WHERE ManagerName IN (@manager0, ...)
AND MaterialCode IS NOT NULL"] + end + + subgraph AdminNoMgr["Admin + selectedManagers 为空"] + A2["SELECT DISTINCT MaterialCode
FROM DiscreteMaterialPlanData
WHERE SourceNumber IN (orderNumbers)"] + end + + subgraph User["User 查询"] + U1["SELECT MaterialCode
FROM MaterialsToBeDeleted
WHERE ManagerName = @username
AND MaterialCode IS NOT NULL"] + end + + AdminWithMgr --> |"按选中负责人过滤"| Result([传入 runCleaner]) + AdminNoMgr --> |"按订单号查 DiscreteMaterialPlanData"| Result + User --> |"仅返回自己的物料代码"| Result +``` + +**差异总结**: + +| 维度 | Admin(有 selectedManagers) | Admin(无 selectedManagers) | User | +|------|---------------------------|----------------------------|------| +| 数据源 | `MaterialsToBeDeleted` | `DiscreteMaterialPlanData` | `MaterialsToBeDeleted` | +| 查询条件 | `WHERE ManagerName IN (...)` | `WHERE SourceNumber IN (orderNumbers)` | `WHERE ManagerName = @username` | +| 可删除物料 | 选中负责人的物料 | 订单关联的全部物料 | 仅自己标记的物料 | +| 无订单号时 | — | 返回空数组 | — | + +--- + +## 数据安全边界 + +角色隔离在**三个层面**同时生效,形成纵深防御: + +```mermaid +flowchart TB + subgraph layer1["第一层:前端过滤"] + L1["filterValidationResults()
User 仅看到自己的物料"] + end + + subgraph layer2["第二层:同步范围"] + L2["handleConfirmDeletion()
User 仅同步 filteredResults"] + end + + subgraph layer3["第三层:后端查询"] + L3["loadMaterialCodesForCleaner()
Admin: WHERE ManagerName IN (selectedManagers)
User: SQL WHERE ManagerName = user"] + end + + L1 -->|"防止误操作"| L2 + L2 -->|"缩小同步范围"| L3 + L3 -->|"最终保证"| Safe([User 无法删除他人物料]) +``` + +> **注意**:`runCleaner()` 本身不做角色过滤,它信任上游传入的 `materialCodes` 已经过角色过滤。安全性由 `getCleanerData()` 的 SQL 查询保证。 + +--- + +## 涉及文件索引 + +| 文件 | 关键函数/逻辑 | 行号 | +|------|---------------|------| +| `src/renderer/src/hooks/cleaner/api.ts` | `initializeCleanerPage()`, `runCleanerExecution()` | 25-52, 116-166 | +| `src/renderer/src/hooks/useCleaner.ts` | `handleConfirmDeletion()`, 初始化逻辑 | 98-120, 289-345 | +| `src/renderer/src/hooks/cleaner/helpers.ts` | `filterValidationResults()`, `buildDeletionPlan()` | 34-57, 59-92 | +| `src/main/services/validation/validation-application-service.ts` | `getCleanerData()`, `loadMaterialCodesForCleaner()`, `queryMaterialCodesByManagers()` | 232-305, 497-604, 606-655 | +| `src/main/services/cleaner/cleaner-application-service.ts` | `runCleaner()` | 31-168 | +| `src/main/ipc/cleaner-handler.ts` | `CLEANER_RUN` handler | 16-22 | +| `src/main/ipc/validation-handler.ts` | `getCleanerData` handler | 194-223 | +| `src/preload/api/validation.ts` | `getCleanerData()` IPC 桥接 | 11-12 | +| `src/renderer/src/pages/CleanerPage.tsx` | 页面组件,条件渲染侧边栏 | 74-82 | diff --git a/src/main/ipc/validation-handler.ts b/src/main/ipc/validation-handler.ts index 65e3819..46327d4 100644 --- a/src/main/ipc/validation-handler.ts +++ b/src/main/ipc/validation-handler.ts @@ -194,7 +194,8 @@ export function registerValidationHandlers(): void { ipcMain.handle( IPC_CHANNELS.VALIDATION_GET_CLEANER_DATA, async ( - event + event, + params?: { selectedManagers?: string[] } ): Promise<{ success: boolean orderNumbers?: string[] @@ -213,7 +214,11 @@ export function registerValidationHandlers(): void { } } - return validationApplicationService.getCleanerData(userInfo, event.sender.id) + return validationApplicationService.getCleanerData( + userInfo, + event.sender.id, + params?.selectedManagers ?? [] + ) } ) } diff --git a/src/main/services/validation/validation-application-service.ts b/src/main/services/validation/validation-application-service.ts index 99e8e3a..d64f80f 100644 --- a/src/main/services/validation/validation-application-service.ts +++ b/src/main/services/validation/validation-application-service.ts @@ -231,7 +231,8 @@ export class ValidationApplicationService { async getCleanerData( userInfo: UserInfo, - senderId: number + senderId: number, + selectedManagers: string[] = [] ): Promise<{ success: boolean orderNumbers?: string[] @@ -250,6 +251,7 @@ export class ValidationApplicationService { userId: userInfo.id, username, isAdmin, + selectedManagers, requestId }) @@ -270,7 +272,13 @@ export class ValidationApplicationService { }) } - const materialCodes = await this.loadMaterialCodesForCleaner(dbService, username, isAdmin) + const materialCodes = await this.loadMaterialCodesForCleaner( + dbService, + username, + isAdmin, + selectedManagers, + orderNumbers + ) log.info('Cleaner data retrieved', { userId: userInfo.id, orderCount: orderNumbers.length, @@ -489,27 +497,54 @@ export class ValidationApplicationService { private async loadMaterialCodesForCleaner( dbService: ValidationDatabaseService, username: string, - isAdmin: boolean + isAdmin: boolean, + selectedManagers: string[], + orderNumbers: string[] ): Promise { const markedTableName = getValidationTableName('dbo_MaterialsToBeDeleted') - if (isAdmin) { - const result = await dbService.query( - ` - SELECT MaterialCode - FROM ${markedTableName} - WHERE MaterialCode IS NOT NULL - ` + // Admin with selected managers: filter MaterialsToBeDeleted by ManagerName IN (selectedManagers) + if (isAdmin && selectedManagers && selectedManagers.length > 0) { + const materialCodes = await this.queryMaterialCodesByManagers( + dbService, + markedTableName, + selectedManagers ) - const materialCodes = result.rows.map((row) => row.MaterialCode as string).filter(Boolean) - log.info(`Admin user: got ${materialCodes.length} materials`, { + log.info(`Admin with selected managers: got ${materialCodes.length} materials`, { userId: username, isAdmin: true, + selectedManagers, materialCount: materialCodes.length }) return materialCodes } + // Admin without selected managers: query all materials from DiscreteMaterialPlanData by orderNumbers + if (isAdmin) { + if (orderNumbers.length === 0) { + log.warn('Admin without selected managers but no orderNumbers available', { + userId: username + }) + return [] + } + const materialDao = new DiscreteMaterialPlanDAO() + const records = await materialDao.queryBySourceNumbersDistinct(orderNumbers) + const materialCodes = [ + ...new Set(records.map((r) => r.MaterialCode as string).filter(Boolean)) + ] + log.info( + `Admin without selected managers: got ${materialCodes.length} materials from DiscreteMaterialPlanData`, + { + userId: username, + isAdmin: true, + orderCount: orderNumbers.length, + materialCount: materialCodes.length + } + ) + return materialCodes + } + + // Regular user: filter MaterialsToBeDeleted by ManagerName = username if (dbService.type === 'sqlserver') { const sql = await import('mssql') const result = await (dbService as SqlServerService).queryWithParams( @@ -568,6 +603,57 @@ export class ValidationApplicationService { return materialCodes } + private async queryMaterialCodesByManagers( + dbService: ValidationDatabaseService, + tableName: string, + managers: string[] + ): Promise { + if (dbService.type === 'sqlserver') { + const sql = await import('mssql') + const params: Record = {} + const paramNames = managers.map((m, i) => { + const name = `@manager${i}` + params[`manager${i}`] = { value: m, type: sql.default.NVarChar } + return name + }) + const result = await (dbService as SqlServerService).queryWithParams( + ` + SELECT MaterialCode + FROM ${tableName} + WHERE ManagerName IN (${paramNames.join(', ')}) AND MaterialCode IS NOT NULL + `, + params + ) + return result.rows + .map((row: Record) => row.MaterialCode as string) + .filter(Boolean) + } + + if (dbService.type === 'postgresql') { + const placeholders = managers.map((_, i) => `$${i + 1}`).join(', ') + const result = await dbService.query( + ` + SELECT "MaterialCode" + FROM ${tableName} + WHERE "ManagerName" IN (${placeholders}) AND "MaterialCode" IS NOT NULL + `, + managers + ) + return result.rows.map((row) => row.MaterialCode as string).filter(Boolean) + } + + const placeholders = managers.map(() => '?').join(', ') + const result = await dbService.query( + ` + SELECT MaterialCode + FROM ${tableName} + WHERE ManagerName IN (${placeholders}) AND MaterialCode IS NOT NULL + `, + managers + ) + return result.rows.map((row) => row.MaterialCode as string).filter(Boolean) + } + private async disconnectQuietly(dbService: ValidationDatabaseService): Promise { try { await dbService.disconnect() diff --git a/src/preload/api/validation.ts b/src/preload/api/validation.ts index fda5b39..7e3301f 100644 --- a/src/preload/api/validation.ts +++ b/src/preload/api/validation.ts @@ -8,5 +8,6 @@ export const validationApi = { invokeIpc(IPC_CHANNELS.VALIDATION_SET_SHARED_PRODUCTION_IDS, productionIds), getSharedProductionIds: () => invokeIpc(IPC_CHANNELS.VALIDATION_GET_SHARED_PRODUCTION_IDS), clearSharedProductionIds: () => invokeIpc(IPC_CHANNELS.VALIDATION_CLEAR_SHARED_PRODUCTION_IDS), - getCleanerData: () => invokeIpc(IPC_CHANNELS.VALIDATION_GET_CLEANER_DATA) + getCleanerData: (params?: { selectedManagers?: string[] }) => + invokeIpc(IPC_CHANNELS.VALIDATION_GET_CLEANER_DATA, params ?? { selectedManagers: [] }) } as const diff --git a/src/renderer/src/hooks/cleaner/api.ts b/src/renderer/src/hooks/cleaner/api.ts index 20432fb..5840423 100644 --- a/src/renderer/src/hooks/cleaner/api.ts +++ b/src/renderer/src/hooks/cleaner/api.ts @@ -118,8 +118,11 @@ export async function runCleanerExecution(params: { headless: boolean queryBatchSize: number processConcurrency: number + selectedManagers: string[] }): Promise { - const cleanerDataResult = await window.electron.validation.getCleanerData() + const cleanerDataResult = await window.electron.validation.getCleanerData({ + selectedManagers: params.selectedManagers + }) const cleanerData = cleanerDataResult.success ? (cleanerDataResult.data as CleanerDataPayload | null) : null diff --git a/src/renderer/src/hooks/useCleaner.ts b/src/renderer/src/hooks/useCleaner.ts index 3fbdd40..3882ca9 100644 --- a/src/renderer/src/hooks/useCleaner.ts +++ b/src/renderer/src/hooks/useCleaner.ts @@ -376,7 +376,8 @@ export function useCleaner() { dryRun, headless, queryBatchSize, - processConcurrency + processConcurrency, + selectedManagers: Array.from(selectedManagers) }) setReportData(result) } catch (err) { diff --git a/tests/unit/services/validation/validation-application-service.test.ts b/tests/unit/services/validation/validation-application-service.test.ts index 7e7dfb5..55c1062 100644 --- a/tests/unit/services/validation/validation-application-service.test.ts +++ b/tests/unit/services/validation/validation-application-service.test.ts @@ -324,26 +324,56 @@ describe('ValidationApplicationService', () => { // ─── getCleanerData ─── describe('getCleanerData', () => { - it('should return order numbers and material codes for admin user', async () => { + it('Admin with selected managers should query MaterialsToBeDeleted by ManagerName IN', async () => { mockSharedIdsGet.mockReturnValue(['PROD001']) const userInfo = { id: 1, username: 'admin', userType: 'Admin' } as any - const result = await service.getCleanerData(userInfo, 1) + const result = await service.getCleanerData(userInfo, 1, ['Mgr', 'Other']) expect(result.success).toBe(true) expect(result.orderNumbers).toBeDefined() expect(result.orderNumbers!.length).toBeGreaterThan(0) + // materialCodes come from MaterialsToBeDeleted query (mock returns M1) expect(result.materialCodes).toBeDefined() + expect(result.materialCodes).toContain('M1') }) - it('should return empty order numbers when no shared IDs', async () => { + it('Admin without selected managers should query DiscreteMaterialPlanData by orderNumbers', async () => { + mockSharedIdsGet.mockReturnValue(['PROD001']) + const userInfo = { id: 1, username: 'admin', userType: 'Admin' } as any + + const result = await service.getCleanerData(userInfo, 1, []) + + expect(result.success).toBe(true) + expect(result.orderNumbers).toBeDefined() + // materialCodes come from DiscreteMaterialPlanDAO.queryBySourceNumbersDistinct + // mock returns MF1 + expect(result.materialCodes).toBeDefined() + expect(result.materialCodes).toContain('MF1') + }) + + it('Admin without selected managers and no orderNumbers should return empty material codes', async () => { mockSharedIdsGet.mockReturnValue([]) const userInfo = { id: 1, username: 'admin', userType: 'Admin' } as any + const result = await service.getCleanerData(userInfo, 1, []) + + expect(result.success).toBe(true) + expect(result.orderNumbers).toEqual([]) + expect(result.materialCodes).toEqual([]) + }) + + it('regular user should filter by ManagerName = username', async () => { + mockSharedIdsGet.mockReturnValue(['PROD001']) + const userInfo = { id: 2, username: 'guest', userType: 'User' } as any + const result = await service.getCleanerData(userInfo, 1) expect(result.success).toBe(true) - expect(result.orderNumbers).toEqual([]) + // materialCodes from MaterialsToBeDeleted WHERE ManagerName = 'guest' + // mock returns M1 for any MaterialsToBeDeleted query + expect(result.materialCodes).toBeDefined() + expect(result.materialCodes).toContain('M1') }) it('should handle errors gracefully', async () => {