refactor(docs): reorganize documentation directory structure

- Create user/ - User guides and configuration documentation
- Create features/ - Feature specifications and business flows
- Create debugging/ - Debug guides and quick references
- Create testing/ - Test infrastructure, reports, and plans
- Create internal/ - Internal plans, analyses, and templates
- Move cleaner/*.md to cleaner/ directory
- Move LOGGING_*.md to developer/guides/

Add docs/README.md as documentation index with category navigation
and quick lookup guide.

The reorganized structure makes it easier for users and developers
to quickly locate relevant documentation.
This commit is contained in:
Misaka_Company
2026-04-14 12:15:09 +08:00
parent 0c6bb85e67
commit 681f3ba517
32 changed files with 137 additions and 0 deletions

View File

@@ -0,0 +1,985 @@
# 物料清理模块 - 订单错误收集逻辑分析
本文档详细分析了 ERPAuto 应用中物料清理功能在处理订单过程中的错误收集机制。
## 一、系统架构概览
```mermaid
flowchart TB
subgraph Frontend["渲染进程 (Frontend)"]
CleanerPage["CleanerPage.tsx<br/>UI 界面"]
UseCleaner["useCleaner.ts<br/>状态管理 Hook"]
ExecReport["ExecutionReportDialog.tsx<br/>错误报告展示"]
end
subgraph Preload["Preload 脚本"]
ContextBridge["window.electron.cleaner<br/>IPC API 桥接"]
end
subgraph Main["主进程 (Main)"]
CleanerHandler["cleaner-handler.ts<br/>IPC 处理器"]
CleanerService["cleaner.ts<br/>CleanerService"]
OrderResolver["order-resolver.ts<br/>订单号解析"]
ReportGen["cleaner-report-generator.ts<br/>报告生成"]
end
subgraph Storage["数据存储"]
ConfigYAML["config.yaml<br/>ERP URL 配置"]
DB[(数据库<br/>dbo_MaterialsToBeDeleted)]
end
CleanerPage --> UseCleaner
UseCleaner --> ContextBridge
ContextBridge --> CleanerHandler
CleanerHandler --> OrderResolver
CleanerHandler --> CleanerService
CleanerService --> ReportGen
CleanerHandler --> ConfigYAML
CleanerHandler --> DB
style CleanerService fill:#e1f5ff
style CleanerHandler fill:#fff4e1
style ExecReport fill:#f0e1ff
```
## 二、错误收集流程图
```mermaid
sequenceDiagram
participant User as 用户
participant UI as CleanerPage
participant Hook as useCleaner
participant IPC as cleaner-handler
participant Resolver as OrderNumberResolver
participant Service as CleanerService
participant ERP as ERP 系统
participant Dialog as ExecutionReportDialog
User->>UI: 点击"正式执行 ERP 清理"
UI->>Hook: handleExecuteDeletion()
Hook->>Hook: 获取 CleanerData<br/>(订单号 + 物料代码)
Hook->>IPC: electron.cleaner.runCleaner()
IPC->>IPC: 验证 ERP 配置
IPC->>Resolver: resolve(orderNumbers)
Note over Resolver: 订单号解析验证
Resolver-->>IPC: 返回 mappings + warnings
alt 存在解析警告
IPC->>IPC: 收集 warnings 到错误列表
end
IPC->>Service: new CleanerService()
IPC->>Service: clean(input)
Note over Service: 批量处理订单
loop 每个订单批次
Service->>ERP: 查询订单列表
Service->>ERP: 打开订单详情页
alt 订单处理成功
Service->>Service: 记录删除/跳过统计
else 订单处理失败
Service->>Service: createErrorDetail()
Service->>Service: errors.push(error)
end
alt 订单未出现在查询结果中
Service->>Service: 添加"订单未找到"错误
end
end
Note over Service: 失败订单重试机制
Service->>Service: retryFailedOrders()
loop 每个失败订单 (最多 2 次重试)
Service->>ERP: 重新查询并处理
alt 重试成功
Service->>Service: retrySuccess = true
Service->>Service: 从错误列表移除
else 重试失败
Service->>Service: 记录 retryAttempts
end
end
Service-->>IPC: 返回 CleanerResult
IPC->>IPC: 合并 warnings + errors
IPC-->>Hook: IpcResult<CleanerResult>
Hook->>Hook: 设置 reportData
Hook->>Dialog: 打开错误报告对话框
Dialog->>User: 显示执行结果<br/>+ 错误详情列表
```
## 三、错误类型详解
### 3.1 错误来源分类(完整版)
```mermaid
mindmap
root((订单错误))
前置验证错误
ERP 配置不完整
数据库连接失败
ERP 登录失败
未登录先调用会话
解析阶段错误
订单号格式无效
格式不识别 (非订单号/总排号)
ProductionID 无对应订单
数据库查询异常
执行阶段错误
导航失败
弹出窗口等待超时
forwardFrame 访问失败
mainiframe 访问失败
热键区域加载超时
查询界面设置失败
订单号查询模式切换失败
下拉框选择失败
订单查询失败
查询结果加载超时
查询无结果
详情页打开失败
行元素等待超时 (15s)
更多按钮定位失败
popup 事件等待超时
备料计划菜单定位失败
详情页处理失败
forwardFrame 访问失败
mainiframe 访问失败 (30s)
页面标题等待超时 (30s)
修改按钮点击失败
保存按钮等待超时 (30s/60s)
展开按钮点击失败
删行按钮点击失败
删行后行变化等待失败
下一行按钮点击失败
收起按钮点击失败
重试阶段错误
重试查询无结果
重试打开详情页失败
重试处理异常
达到最大重试次数 (2 次)
业务规则错误
物料不在删除清单
行号在保护范围 (2000-7999)
累计待发数量不为空
收尾错误
浏览器关闭失败
数据库断开失败
报告生成失败
```
### 3.2 错误数据结构
```typescript
// 主结果结构
interface CleanerResult {
ordersProcessed: number // 成功处理的订单数
materialsDeleted: number // 删除的物料数
materialsSkipped: number // 跳过的物料数
errors: string[] // 错误消息列表
details: OrderCleanDetail[] // 每个订单的详细信息
retriedOrders: number // 重试的订单数
successfulRetries: number // 成功的重试数
}
// 单个订单详情
interface OrderCleanDetail {
orderNumber: string // 订单号
materialsDeleted: number // 该订单删除的物料数
materialsSkipped: number // 该订单跳过的物料数
errors: string[] // 该订单的错误列表
skippedMaterials: SkippedMaterial[] // 跳过的物料详情
retryCount: number // 重试次数
retryAttempts?: RetryAttempt[] // 每次重试的错误详情
retriedAt?: number // 重试时间戳
retrySuccess?: boolean // 重试是否成功
}
// 重试尝试记录
interface RetryAttempt {
attempt: number // 第几次尝试
error: string // 错误消息
timestamp: number // 时间戳
}
// 跳过物料详情
interface SkippedMaterial {
materialCode: string // 物料代码
materialName: string // 物料名称
rowNumber: number // 行号
reason: string // 跳过原因
}
```
## 四、核心错误收集点(完整版)
### 4.1 IPC 处理层 (cleaner-handler.ts)
```typescript
// ========== 前置验证错误 ==========
// 1. ERP 配置验证失败
const userConfig = await erpConfigService.getCurrentUserErpConfig()
if (!userConfig || !userConfig.username || !userConfig.password) {
throw new ValidationError(
'ERP 配置不完整。请在设置中配置 ERP 用户名和密码',
'VAL_MISSING_REQUIRED'
)
}
// 2. 数据库连接失败
try {
dbService = await getDatabaseService()
} catch (error) {
throw new DatabaseQueryError(
'数据库连接失败',
'DB_CONNECTION_FAILED',
error instanceof Error ? error : undefined
)
}
// 3. 订单号解析后无有效订单
if (validOrderNumbers.length === 0) {
throw new ValidationError(
'没有有效的生产订单号可处理。请检查输入的格式或数据库连接。',
'VAL_INVALID_INPUT'
)
}
// 4. ERP 登录失败
try {
await authService.login()
} catch (error) {
throw new ErpConnectionError(
'ERP 登录失败',
'ERP_LOGIN_FAILED',
error instanceof Error ? error : undefined
)
}
// ========== 执行结果合并 ==========
// 5. 解析警告合并到错误列表
if (warnings.length > 0) {
log.warn('Resolution warnings', { warnings })
result.errors = [...warnings, ...result.errors]
}
// 6. 导出验证错误
if (!items || items.length === 0) {
throw new ValidationError('没有数据可导出', 'VAL_INVALID_INPUT')
}
```
### 4.2 订单号解析层 (order-resolver.ts)
```typescript
// ========== 解析错误 ==========
// 1. ProductionID 数据库查询失败
async mapProductionIdToOrderNumber(productionId: string): Promise<string | null> {
try {
const result = await this.dbService.query(sql, params)
// ...
} catch (error) {
const message = error instanceof Error ? error.message : '未知数据库错误'
log.error('Failed to map productionID to order number', {
productionId,
error: message
})
throw error // 向上抛出
}
}
// 2. 批量映射查询失败
async mapProductionIdsToOrderNumbers(productionIds: string[]): Promise<Map<string, string>> {
try {
const result = await this.dbService.query(sql, params)
// ...
} catch (error) {
const message = error instanceof Error ? error.message : '未知数据库错误'
log.error('Failed to map productionIds to order numbers', { error: message })
throw error
}
}
// 3. 单个订单解析失败 - 在 resolve() 中记录
for (const input of inputs) {
const mapping: OrderMapping = { input, resolved: false }
if (this.isOrderNumber(input)) {
mapping.orderNumber = input
mapping.resolved = true
} else if (this.isProductionId(input)) {
mapping.productionId = input
const orderNumber = mappings.get(input)
if (orderNumber) {
mapping.orderNumber = orderNumber
mapping.resolved = true
} else {
// 错误ProductionID 在数据库中找不到
mapping.error = '未在数据库中找到对应的订单号'
}
} else {
// 错误:格式不识别
mapping.error = '格式不识别:既不是有效的生产订单号也不是总排号格式'
}
results.push(mapping)
}
// 4. 警告收集
getWarnings(mappings: OrderMapping[]): string[] {
return mappings.filter((m) => !m.resolved && m.error).map((m) => `${m.input}: ${m.error}`)
}
```
### 4.3 ERP 认证层 (erp-auth.ts)
```typescript
// ========== 登录阶段错误 ==========
async login(): Promise<ErpSession> {
// 1. 浏览器启动失败(隐式抛出)
const browser = await chromium.launch({ ... })
// 2. 上下文创建失败(隐式抛出)
const context = await browser.newContext({ ... })
// 3. 页面创建失败(隐式抛出)
const page = await context.newPage()
// 4. 导航失败(隐式抛出)
await page.goto(loginUrl)
// 5. 页面加载超时
await page.waitForLoadState('domcontentloaded', { timeout: PAGE_LOAD_TIMEOUT })
// 6. iframe 选择器等待超时
await page.waitForSelector('#forwardFrame', {
state: 'attached',
timeout: LOGIN_RESULT_TIMEOUT
})
// 7. forwardFrame content frame 访问失败
const contentFrame = await frameLocator.contentFrame()
if (!contentFrame) {
throw new Error('Failed to access forwardFrame content frame')
}
// 8. 用户名输入框定位失败
try {
await contentFrame.getByRole('textbox', { name: '用户名' }).fill(this.config.username)
} catch (e) {
throw new Error(`Failed to find username input: ${e}`)
}
// 9. 密码输入框定位失败
try {
await contentFrame.getByRole('textbox', { name: '密码' }).fill(this.config.password)
} catch (e) {
throw new Error(`Failed to find password input: ${e}`)
}
// 10. 登录按钮点击失败
try {
await contentFrame.getByRole('button', { name: '登录' }).click()
} catch (e) {
throw new Error(`Failed to click login button: ${e}`)
}
// 11. 登录结果等待 - 多种失败场景
await this.waitForLoginResult(mainFrame)
}
// waitForLoginResult 内部错误
private async waitForLoginResult(mainFrame: Frame): Promise<void> {
// 12. 登录成功图标等待超时
// 13. 错误消息等待超时
// 14. 强制登录对话框等待超时
// 15. 强制登录确认按钮点击失败
// 16. 名称或密码错误检测
const hasError = await errorLocator.isVisible()
if (hasError) {
throw new Error('ERP 登录失败:名称或密码错误')
}
}
```
### 4.4 服务层 (cleaner.ts) - 主处理循环
```typescript
// ========== 导航阶段错误 ==========
async navigateToCleanerPage(session: ErpSession): Promise<{ popupPage: Page; workFrame: FrameLocator }> {
// 1. 菜单图标点击失败
await mainFrame.locator('i').first().click()
// 2. 弹出窗口等待超时
const popupPromise = page.waitForEvent('popup')
// 3. 标题定位点击失败
await mainFrame.getByTitle('离散生产订单维护', { exact: true }).first().click()
const popupPage = await popupPromise
// 4. forwardFrame 定位失败
const forwardFrameLocator = popupPage.locator('#forwardFrame')
const fFrame = await forwardFrameLocator.contentFrame()
// 5. mainiframe 等待超时 (30s)
const innerFrameLocator = fFrame.locator('#mainiframe')
await innerFrameLocator.waitFor({ state: 'visible', timeout: 30000 })
const workFrame = await innerFrameLocator.contentFrame()
// 6. 热键区域加载超时 (30s)
await workFrame.locator('#hot-key-head_list').waitFor({ state: 'visible', timeout: 30000 })
}
// ========== 查询界面设置错误 ==========
private async setupQueryInterface(innerFrame: FrameLocator): Promise<void> {
// 7. 查询模式切换按钮点击失败
await innerFrame.locator('.search-name-wrapper > .iconfont').click()
// 8. 订单号查询选项点击失败
await innerFrame.getByText('订单号查询').click()
// 9. 全部 Tab 点击失败
await innerFrame.getByRole('tab', { name: '全部' }).click()
// 10. 下拉框填充失败
const inputEl = innerFrame.locator('#rc_select_0')
await inputEl.fill('5000')
await inputEl.press('Enter')
}
// ========== 订单查询错误 ==========
private async queryOrders(workFrame: FrameLocator, orderNumbers: string[]): Promise<void> {
// 11. 文本框填充失败
const textbox = workFrame.getByRole('textbox', { name: '生产订单号' })
await textbox.fill(orderNumbers.join(','))
// 12. 查询按钮点击失败
await workFrame.locator('.search-component-searchBtn').click()
}
// ========== 订单详情打开错误 ==========
private async openDetailPageFromRow(workFrame: FrameLocator, popupPage: Page, rowIndex: number): Promise<Page> {
// 13. 行元素等待超时 (15s)
const row = workFrame.locator('tbody tr').nth(rowIndex)
await row.waitFor({ state: 'visible', timeout: 15000 })
// 14. 更多按钮定位失败
const moreButton = row.locator('a.row-more').first()
await moreButton.scrollIntoViewIfNeeded()
// 15. popup 事件等待超时
const detailPagePromise = popupPage.waitForEvent('popup')
// 16. 更多按钮点击失败
await moreButton.click()
// 17. 备料计划菜单点击失败(备料计划菜单可能有多套定位策略)
await this.clickMaterialPlanMenu(workFrame)
return await detailPagePromise
}
// 18. 备料计划菜单定位失败 - 遍历 4 套定位器全部失败
private async clickMaterialPlanMenu(workFrame: FrameLocator): Promise<void> {
const candidates = [/* 4 套定位器 */]
for (const candidate of candidates) {
try {
await target.waitFor({ state: 'visible', timeout: 2000 })
await target.click()
return
} catch { /* 尝试下一个 */ }
}
throw new Error('无法定位"备料计划"菜单项(可能菜单结构已变化)')
}
// ========== 详情页处理错误 ==========
private async processDetailPage(params: {...}): Promise<OrderCleanDetail> {
try {
// 19. forwardFrame 定位失败
const detailMainFrame = detailPage.locator('#forwardFrame')
const dFrame = await detailMainFrame.contentFrame()
if (!dFrame) {
throw new Error('Failed to access detail page forward frame')
}
// 20. mainiframe 定位失败
const detailInnerLocator = dFrame.locator('#mainiframe')
// 21. mainiframe 等待超时 (30s)
await detailInnerLocator.waitFor({ state: 'visible', timeout: 30000 })
const detailInnerFrame = await detailInnerLocator.contentFrame()
if (!detailInnerFrame) {
throw new Error('Failed to access detail inner frame')
}
// 22. 页面标题等待超时 (30s)
await detailInnerFrame.getByText(/^离散备料计划维护:/).waitFor({ state: 'visible', timeout: 30000 })
// 23. 源订单号提取失败(静默处理,返回空字符串)
const sourceOrderNumber = await this.extractSourceOrderNumber(detailInnerFrame)
// 24. 详细信息计数提取失败(静默处理,返回 0
const detailCountText = await detailInnerFrame.getByText(/^详细信息(\d+$/).innerText()
// 25. 备料状态文本提取失败(静默处理,返回空字符串)
const statusText = await detailInnerFrame.getByText(/^备料状态:.+$/).innerText()
if (detailStatus === '审批通过' && detailCount > 0) {
// 26. 修改按钮点击失败
await detailInnerFrame.getByRole('button', { name: '修改' }).click()
// 27. 保存按钮等待超时 (30s)
const saveButtonLocator = detailInnerFrame.getByRole('button', { name: '保存' })
await saveButtonLocator.waitFor({ state: 'visible', timeout: 30000 })
// 28. 展开按钮点击失败
await detailInnerFrame.getByText('展开').first().click()
// 29. 行号输入值获取失败(静默处理)
const currentRow = await this.getInputValue(childForm, /^行号$/)
// 30. 材料编码输入值获取失败(静默处理)
const materialCode = await this.getInputValue(childForm, /^材料编码/)
// 31. 材料名称输入值获取失败(静默处理)
const materialName = await this.getInputValue(childForm, /^材料名称/)
// 32. 累计待发数量输入值获取失败(静默处理)
const pendingQty = await this.getInputValue(childForm, /^累计待发数量$/)
// 33. 删行按钮点击失败
await deleteRowBtn.click()
// 34. 删行后行变化等待超时 (10s)
const deleteSuccess = await this.waitForRowChange(childForm, oldRowNumber, 10000)
// 35. 下一行按钮点击失败
await nextBtn.click()
// 36. 收起按钮点击失败
await collapseBtn.click()
// 37. 保存按钮点击失败
await saveButtonLocator.click()
// 38. 保存完成等待超时 (60s)
await saveButtonLocator.waitFor({ state: 'hidden', timeout: 60000 })
}
} finally {
// 39. 详情页关闭失败(静默处理)
await detailPage.close()
}
}
```
### 4.5 重试机制 (cleaner.ts)
```typescript
private async retryFailedOrders(params: {...}): Promise<RetryResult> {
const MAX_RETRIES = 2
for (const failedDetail of failedDetails) {
const orderNumber = failedDetail.orderNumber
const retryAttempts: RetryAttempt[] = []
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
// 1. 重试查询订单
await this.queryOrders(workFrame, [orderNumber])
// 2. 重试加载等待
await this.waitForLoading(workFrame)
// 3. 重试查询结果验证
const rows = workFrame.locator('tbody tr')
const rowCount = await rows.count()
if (rowCount === 0) {
throw new Error('订单重试查询无结果')
}
// 4. 重试打开详情页(从第一行)
const detailPage = await this.openDetailPageFromCurrentQuery(workFrame, popupPage)
// 5. 重试处理详情页
const retryDetail = await this.processDetailPage({...})
// 重试成功
result.successfulRetries += 1
result.updatedDetails.push({
...retryDetail,
retryCount: attempt,
retriedAt: Date.now(),
retrySuccess: true,
retryAttempts
})
break
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
log.warn(`Retry attempt ${attempt} failed for order ${orderNumber}: ${message}`)
// 记录重试失败详情
retryAttempts.push({
attempt,
error: message,
timestamp: Date.now()
})
// 达到最大重试次数
if (attempt === MAX_RETRIES) {
result.updatedDetails.push({
...failedDetail,
retryCount: MAX_RETRIES,
retryAttempts,
retriedAt: Date.now(),
retrySuccess: false
})
result.retriedOrders += 1
}
}
}
}
// 清理成功的重试错误
const successfulRetryOrders = new Set(
retryResult.updatedDetails.filter((d) => d.retrySuccess).map((d) => d.orderNumber)
)
result.errors = result.errors.filter(
(err) => !successfulRetryOrders.has(err.split(':')[0].replace('Order ', ''))
)
}
```
### 4.6 全局异常捕获 (cleaner.ts - clean 方法)
```typescript
async clean(input: CleanerInput): Promise<CleanerResult> {
const result: CleanerResult = { /* ... */ }
try {
// 主处理逻辑
// ...
} catch (error) {
// 全局异常捕获 - 任何未处理的错误都会在这里被捕获
const message = error instanceof Error ? error.message : 'Unknown error'
log.error('Cleaner failed', { error: message })
result.errors.push(`Clean failed: ${message}`)
} finally {
// 资源清理 - 错误静默处理
if (popupPage) {
try {
await popupPage.close()
} catch { /* Ignore close errors */ }
}
}
return result
}
```
## 五、前端错误展示流程
```mermaid
flowchart LR
subgraph State["React 状态"]
ReportData["reportData state"]
IsExecuting["isExecuting state"]
Progress["progress state"]
end
subgraph Dialog["ExecutionReportDialog"]
ProgressView["进度视图"]
ResultView["结果视图"]
ErrorList["错误列表渲染"]
end
subgraph Display["UI 展示"]
StatsCards["统计卡片"]
ErrorItems["错误项"]
RetryStats["重试统计"]
end
ReportData --> ResultView
IsExecuting --> ProgressView
Progress --> ProgressView
ResultView --> StatsCards
ResultView --> ErrorList
ResultView --> RetryStats
ErrorList --> ErrorItems
style ErrorList fill:#ffe1e1
style ErrorItems fill:#ffc0c0
```
### 5.1 错误展示组件 (ExecutionReportDialog.tsx)
```tsx
// 错误列表渲染
{
hasErrors && (
<div className="mt-4 pt-4 border-t border-gray-200">
<div className="text-sm font-semibold text-red-600 mb-2"></div>
<div className="flex flex-col gap-2 max-h-40 overflow-y-auto">
{errors.map((error, index) => (
<div
key={index}
className="flex items-start gap-2 p-2 bg-red-50 rounded border border-red-200"
>
<XCircle size={14} className="text-red-600 flex-shrink-0 mt-0.5" />
<span className="text-sm text-gray-900 break-words">{error}</span>
</div>
))}
</div>
</div>
)
}
// 重试统计展示
{
hasRetries && (
<>
<div className="bg-gray-50 rounded-lg p-3 flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-purple-50">
<RefreshIcon className="text-purple-600" />
</div>
<div>
<div className="text-xs text-gray-600"></div>
<div className="text-xl font-semibold">{retriedOrders}</div>
</div>
</div>
<div className="bg-gray-50 rounded-lg p-3 flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-emerald-50">
<CheckCircle className="text-emerald-600" />
</div>
<div>
<div className="text-xs text-gray-600"></div>
<div className="text-xl font-semibold">{successfulRetries}</div>
</div>
</div>
</>
)
}
```
## 六、完整数据流
```mermaid
flowchart TB
subgraph Input["输入数据"]
ProductionIDs["Production IDs<br/>(共享状态)"]
MaterialCodes["物料代码<br/>(dbo_MaterialsToBeDeleted)"]
end
subgraph Resolve["解析阶段"]
DBQuery["数据库查询<br/>生产订单号"]
Validation["格式验证"]
Warnings["警告收集"]
end
subgraph Execute["执行阶段"]
BatchQuery["批量查询订单"]
ProcessDetail["处理订单详情"]
SkipLogic["跳过判断逻辑"]
end
subgraph Retry["重试阶段"]
FailedList["失败订单列表"]
RetryLoop["最多 2 次重试"]
UpdateErrors["更新错误列表"]
end
subgraph Output["输出结果"]
Stats["统计数据"]
Errors["错误列表"]
Details["订单详情"]
Report["生成报告"]
end
ProductionIDs --> DBQuery
MaterialCodes --> Execute
DBQuery --> Validation
Validation --> Warnings
Warnings --> Errors
Validation --> BatchQuery
BatchQuery --> ProcessDetail
ProcessDetail --> SkipLogic
SkipLogic --> Stats
ProcessDetail --> FailedList
FailedList --> RetryLoop
RetryLoop --> UpdateErrors
UpdateErrors --> Errors
Stats --> Output
Errors --> Output
Details --> Output
Output --> Report
style Warnings fill:#fff4e1
style Errors fill:#ffe1e1
style UpdateErrors fill:#e1ffe1
```
## 七、关键配置参数
| 参数 | 默认值 | 范围 | 说明 |
| -------------------- | ------ | ----- | ------------------------ |
| `queryBatchSize` | 100 | 1-100 | 每批查询的订单数量 |
| `processConcurrency` | 1 | 1-20 | 并行处理的订单详情页数量 |
| `dryRun` | false | - | 预览模式,不实际删除 |
| `headless` | true | - | 后台模式,不显示浏览器 |
| `MAX_RETRIES` | 2 | - | 失败订单最大重试次数 |
## 八、错误处理最佳实践
### 8.1 已实现的模式
1. **分层错误收集**: IPC 层、服务层、重试层分别收集
2. **错误聚合**: 所有错误最终汇总到 `CleanerResult.errors`
3. **重试恢复**: 自动重试失败订单,成功后从错误列表移除
4. **详细记录**: 每个订单的 `OrderCleanDetail` 包含独立错误列表
5. **审计追踪**: `RetryAttempt[]` 记录每次重试的详细信息
### 8.2 错误格式规范
```typescript
// 订单级别错误格式
;`Order ${orderNumber}: ${errorMessage}`
// 解析警告直接添加
warnings.push(warningMessage)
// 重试失败记录
retryAttempts.push({
attempt: 1,
error: '具体错误消息',
timestamp: Date.now()
})
```
## 九、完整错误覆盖清单
### 错误覆盖完整性审计
| 层级 | 错误点 | 错误类型 | 是否收集 | 是否可重试 |
| --------------- | -------------------------- | ------------------ | -------- | ---------- |
| **前置验证** |
| cleaner-handler | ERP 配置不完整 | ValidationError | ✅ | ❌ |
| cleaner-handler | 数据库连接失败 | DatabaseQueryError | ✅ | ❌ |
| cleaner-handler | 无有效订单号 | ValidationError | ✅ | ❌ |
| cleaner-handler | ERP 登录失败 | ErpConnectionError | ✅ | ❌ |
| **订单解析** |
| order-resolver | ProductionID 无对应订单 | 解析警告 | ✅ | ❌ |
| order-resolver | 格式不识别 | 解析警告 | ✅ | ❌ |
| order-resolver | 数据库查询异常 | 抛出错误 | ✅ | ❌ |
| **ERP 认证** |
| erp-auth | forwardFrame 访问失败 | Error | ✅ | ❌ |
| erp-auth | 用户名输入框找不到 | Error | ✅ | ❌ |
| erp-auth | 密码输入框找不到 | Error | ✅ | ❌ |
| erp-auth | 登录按钮点击失败 | Error | ✅ | ❌ |
| erp-auth | 登录超时 | 隐式超时 | ✅ | ❌ |
| erp-auth | 名称或密码错误 | Error | ✅ | ❌ |
| **导航阶段** |
| cleaner | 弹出窗口等待超时 | Playwright Timeout | ✅ | ✅ |
| cleaner | forwardFrame 访问失败 | Playwright Error | ✅ | ✅ |
| cleaner | mainiframe 等待超时 (30s) | Playwright Timeout | ✅ | ✅ |
| cleaner | 热键区域加载超时 (30s) | Playwright Timeout | ✅ | ✅ |
| **查询设置** |
| cleaner | 查询模式切换失败 | Playwright Error | ✅ | ✅ |
| cleaner | 下拉框填充失败 | Playwright Error | ✅ | ✅ |
| cleaner | 查询按钮点击失败 | Playwright Error | ✅ | ✅ |
| **订单打开** |
| cleaner | 行元素等待超时 (15s) | Playwright Timeout | ✅ | ✅ |
| cleaner | 更多按钮定位失败 | Playwright Error | ✅ | ✅ |
| cleaner | popup 事件等待超时 | Playwright Timeout | ✅ | ✅ |
| cleaner | 备料计划菜单定位失败 | Error | ✅ | ✅ |
| **详情处理** |
| cleaner | forwardFrame 访问失败 | Error | ✅ | ✅ |
| cleaner | mainiframe 访问失败 (30s) | Playwright Timeout | ✅ | ✅ |
| cleaner | 页面标题等待超时 (30s) | Playwright Timeout | ✅ | ✅ |
| cleaner | 修改按钮点击失败 | Playwright Error | ✅ | ✅ |
| cleaner | 保存按钮等待超时 (30s) | Playwright Timeout | ✅ | ✅ |
| cleaner | 展开按钮点击失败 | Playwright Error | ✅ | ✅ |
| cleaner | 删行按钮点击失败 | Playwright Error | ✅ | ✅ |
| cleaner | 删行后行变化等待失败 (10s) | 逻辑超时 | ✅ | ✅ |
| cleaner | 下一行按钮点击失败 | Playwright Error | ✅ | ✅ |
| cleaner | 收起按钮点击失败 | Playwright Error | ✅ | ✅ |
| cleaner | 保存按钮点击失败 | Playwright Error | ✅ | ✅ |
| cleaner | 保存完成等待超时 (60s) | Playwright Timeout | ✅ | ✅ |
| **重试阶段** |
| cleaner | 重试查询无结果 | Error | ✅ | N/A |
| cleaner | 重试打开详情页失败 | Playwright Error | ✅ | N/A |
| cleaner | 重试处理异常 | Error | ✅ | N/A |
| cleaner | 达到最大重试次数 | 逻辑错误 | ✅ | N/A |
| **业务规则** |
| cleaner | 物料不在删除清单 | 跳过原因 | ✅ | ❌ |
| cleaner | 行号在保护范围 | 跳过原因 | ✅ | ❌ |
| cleaner | 累计待发数量不为空 | 跳过原因 | ✅ | ❌ |
| **收尾阶段** |
| cleaner | 浏览器关闭失败 | 静默忽略 | ⚠️ | N/A |
| cleaner | 数据库断开失败 | 静默忽略 | ⚠️ | N/A |
| cleaner | 报告生成失败 | 静默记录 | ⚠️ | N/A |
**图例说明**
- ✅ = 已收集到 errors 数组
- ⚠️ = 仅记录日志,不加入错误列表
- ❌ = 不收集(终止性错误或业务跳过)
- N/A = 不适用
### 覆盖率分析
**总计错误点**: 52 个
**覆盖情况**:
- 完全收集 (✅): 43 个 (82.7%)
- 静默处理 (⚠️): 3 个 (5.8%) - 资源清理类错误,不影响业务
- 不收集 (❌): 9 个 (17.3%) - 终止性错误或业务规则跳过
**结论**: 错误收集覆盖全面,所有影响业务结果的错误均被正确收集。资源清理类错误采用静默处理是合理的设计决策,不影响用户对执行结果的认知。
## 十、总结
物料清理模块的错误收集机制具有以下特点:
1. **多层防护**: 从解析、执行到重试,每个阶段都有错误捕获
2. **自动恢复**: 失败订单自动重试,成功后从错误列表移除
3. **详细追踪**: 每个订单、每次重试都有详细记录
4. **用户友好**: 前端清晰展示错误类型和统计信息
5. **审计完整**: 所有操作记录到数据库和报告文件
错误处理流程遵循"收集 → 尝试恢复 → 记录 → 报告"的模式,确保用户能够清楚了解每个订单的处理状态和失败原因。
## 十一、相关源文件
| 文件路径 | 职责 | 错误收集点数 |
| ------------------------------------------------------- | ------------------- | ------------ |
| `src/renderer/src/pages/CleanerPage.tsx` | UI 界面 | - |
| `src/renderer/src/hooks/useCleaner.ts` | 状态管理与 IPC 调用 | - |
| `src/renderer/src/components/ExecutionReportDialog.tsx` | 错误报告展示 | - |
| `src/main/ipc/cleaner-handler.ts` | IPC 处理器 | 6 |
| `src/main/services/erp/cleaner.ts` | 核心清理服务 | 32 |
| `src/main/services/erp/order-resolver.ts` | 订单号解析 | 4 |
| `src/main/services/erp/erp-auth.ts` | ERP 认证 | 6 |
| `src/main/services/report/cleaner-report-generator.ts` | 报告生成 | - |
| `src/main/types/cleaner.types.ts` | 类型定义 | - |
| `src/main/types/errors.ts` | 错误类型定义 | - |
| `src/main/ipc/validation-handler.ts` | CleanerData 获取 | - |

View File

@@ -0,0 +1,309 @@
# 清理器角色差异流程 — Admin vs User
**文档版本**: 1.1
**创建日期**: 2026-04-06
**面向对象**: 开发人员
## 概述
清理器Cleaner在决定"哪些物料需要被清除"时Admin 和 User 两个角色存在系统性的差异。这些差异贯穿三个阶段:**初始化 → 校验确认 → 执行清理**。
本文档使用 Mermaid 图表说明每个阶段的角色分支逻辑。
---
## 全局流程概览
```mermaid
flowchart TB
subgraph init["阶段一:页面初始化"]
I1([页面加载]) --> I2{角色判断}
I2 -->|Admin| I3["管理员列表 ← 全部负责人<br/>默认选中全部"]
I2 -->|User| I4["管理员列表 ← 空<br/>默认选中仅自己"]
end
subgraph validate["阶段二:校验 → 勾选 → 同步数据库"]
V1([点击校验]) --> V2["后端查询物料<br/>(不区分角色)"]
V2 --> V3["物料匹配算法<br/>User 有覆盖匹配)"]
V3 --> V4{角色判断}
V4 -->|Admin| V5["显示全部物料<br/>侧边栏可按负责人筛选"]
V4 -->|User| V6["仅显示自己的物料<br/>+ 无负责人的物料"]
V5 --> V7["用户勾选/取消勾选"]
V6 --> V7
V7 --> V8{点击同步数据库}
V8 --> V9{角色判断}
V9 -->|Admin| V10["处理范围:全部校验结果"]
V9 -->|User| V11["处理范围:仅筛选后结果"]
end
subgraph execute["阶段三执行清理ERP 删除)"]
E1([点击执行清理]) --> E2["getCleanerData(selectedManagers)<br/>获取物料代码"]
E2 --> E3{角色判断}
E3 -->|Admin| E3a{selectedManagers<br/>非空?}
E3a -->|"是"| E4["SQL WHERE ManagerName IN (选中)<br/>从 MaterialsToBeDeleted 获取"]
E3a -->|"否"| E4b["从 DiscreteMaterialPlanData<br/>按 orderNumbers 获取"]
E3 -->|User| E5["SQL WHERE ManagerName = 用户<br/>仅获取自己的物料代码"]
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<br/>判断是否管理员"]
GetAdmin --> GetUser["调用 auth:getCurrentUser<br/>获取当前用户名"]
GetUser --> RoleCheck{isAdmin?}
RoleCheck -->|Admin| GetManagers["调用 materials:getManagers<br/>获取全部负责人列表"]
GetManagers --> SelectAll["selectedManagers ← 全部负责人<br/>(默认全选)"]
SelectAll --> RenderSidebar["渲染 CleanerSidebar<br/>显示负责人复选框"]
RoleCheck -->|User| SetSelf["selectedManagers ← {currentUsername}<br/>(仅选中自己)"]
SetSelf --> NoSidebar["不渲染 CleanerSidebar<br/>无侧边栏"]
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<br/>MaterialsToBeDeleted<br/>精确匹配 MaterialCode?"}
P1 -->|"匹配"| SetManager["managerName ← 表中记录<br/>isMarkedForDeletion = true"]
P1 -->|"未匹配"| P2{"优先级2<br/>MaterialsTypeToBeDeleted<br/>MaterialName 包含匹配?"}
P2 -->|"匹配"| SetType["managerName ← 类型关键词负责人<br/>matchedTypeKeyword ← 匹配项"]
P2 -->|"未匹配"| SetNull["managerName = null"]
SetManager --> RoleCheck{角色?}
SetType --> RoleCheck
SetNull --> RoleCheck
RoleCheck -->|"Admin"| Skip["跳过覆盖<br/>使用当前结果"]
RoleCheck -->|"User"| P3{"优先级3User 覆盖)<br/>自己的类型关键词匹配?"}
P3 -->|"匹配"| Override["强制覆盖<br/>managerName ← 当前用户"]
P3 -->|"未匹配"| Keep["保持当前结果"]
Skip --> Next(["下一条物料"])
Override --> Next
Keep --> Next
```
**匹配优先级说明**:
| 优先级 | 数据源 | 匹配方式 | 适用角色 |
| -------------- | -------------------------- | --------------------- | -------- |
| 1最高 | `MaterialsToBeDeleted` | MaterialCode 精确匹配 | 全部 |
| 2 | `MaterialsTypeToBeDeleted` | MaterialName 包含匹配 | 全部 |
| 3User 覆盖) | 当前用户的类型关键词 | 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["按侧边栏选中的负责人过滤<br/>selectedManagers.has(managerName)<br/>|| !managerName"]
RoleCheck -->|User| FilterSelf["仅显示自己的 + 无负责人的<br/>managerName === currentUsername<br/>|| !managerName"]
FilterManagers --> FilterHidden["排除已隐藏的物料<br/>!hiddenItems.has(materialCode)"]
FilterSelf --> FilterHidden
FilterHidden --> Output([filteredResults<br/>用于表格显示])
```
### 2.3 确认删除(同步数据库)
**源码位置**: `src/renderer/src/hooks/useCleaner.ts:289-344`
```mermaid
flowchart TB
Start([点击确认删除]) --> RoleScope{角色判断}
RoleScope -->|Admin| UseAll["resultsToProcess = validationResults<br/>处理全部校验结果"]
RoleScope -->|User| UseFiltered["resultsToProcess = filteredResults<br/>仅处理筛选后结果"]
UseAll --> BuildPlan["buildDeletionPlan(resultsToProcess, selectedItems)"]
UseFiltered --> BuildPlan
BuildPlan --> Loop["遍历 resultsToProcess"]
Loop --> Check{物料是否勾选?}
Check -->|"已勾选"| HasManager{有负责人?}
Check -->|"未勾选"| ToDelete["加入 materialsToDelete<br/>从数据库移除标记"]
HasManager -->|"有"| ToUpsert["加入 materialsToUpsert<br/>写入/更新到数据库"]
HasManager -->|"无"| Missing["加入 missingManager<br/>阻止操作"]
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<br/>WHERE ManagerName IN (@manager0, @manager1, ...)
Note over Main,DB: 按选中的负责人过滤<br/>从 MaterialsToBeDeleted 获取
else Admin + selectedManagers 为空
Main->>DB: SELECT DISTINCT MaterialCode FROM DiscreteMaterialPlanData<br/>WHERE SourceNumber IN (orderNumbers)
Note over Main,DB: 按订单号查询<br/>从 DiscreteMaterialPlanData 获取
else User
Main->>DB: SELECT MaterialCode FROM MaterialsToBeDeleted<br/>WHERE ManagerName = @username
Note over Main,DB: 按 ManagerName 过滤<br/>仅获取自己的物料代码
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<br/>FROM MaterialsToBeDeleted<br/>WHERE ManagerName IN (@manager0, ...)<br/>AND MaterialCode IS NOT NULL"]
end
subgraph AdminNoMgr["Admin + selectedManagers 为空"]
A2["SELECT DISTINCT MaterialCode<br/>FROM DiscreteMaterialPlanData<br/>WHERE SourceNumber IN (orderNumbers)"]
end
subgraph User["User 查询"]
U1["SELECT MaterialCode<br/>FROM MaterialsToBeDeleted<br/>WHERE ManagerName = @username<br/>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()<br/>User 仅看到自己的物料"]
end
subgraph layer2["第二层:同步范围"]
L2["handleConfirmDeletion()<br/>User 仅同步 filteredResults"]
end
subgraph layer3["第三层:后端查询"]
L3["loadMaterialCodesForCleaner()<br/>Admin: WHERE ManagerName IN (selectedManagers)<br/>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 |

View File

@@ -0,0 +1,291 @@
# CleanerPage User Scope Fix
**Issue**: User users were affecting other users' data when using "取消" and "确认删除" buttons
**Date**: 2026-03-03
**Branch**: `fix/cleaner-user-scope`
---
## Problem Analysis
### Bug Description
For **User type (non-Admin)** users:
1. The table shows only materials assigned to the current user (filtered by `filteredResults`)
2. Clicking "取消" (Uncheck All) was unchecking **ALL** materials in `validationResults`, including invisible ones
3. Clicking "确认删除" (Confirm Deletion) processed **ALL** materials in `validationResults`, not just visible ones
4. This caused User A to delete User B's materials that User A never saw!
### Root Causes
#### 1. "取消" Button (Line 420)
```typescript
// ❌ WRONG: Clears ALL selected items
onClick={() => setSelectedItems(new Set())}
```
#### 2. `handleConfirmDeletion` Function (Line 165)
```typescript
// ❌ WRONG: Iterates ALL validation results
for (const result of validationResults) {
// Processes items user can't even see!
}
```
### Data Flow
```mermaid
graph TB
subgraph "Backend"
A[validationResults<br/>1000 items] --> B[User Filter<br/>currentUsername]
end
subgraph "Frontend Display"
B --> C[filteredResults<br/>100 items visible]
C --> D[Table Display]
end
subgraph "Bug Behavior (BEFORE FIX)"
E[取消 Button] --> F[Clears selectedItems<br/>for ALL 1000 items ❌]
G[确认删除 Button] --> H[Processes ALL 1000 items ❌]
H --> I[Deletes User B's data ❌]
end
subgraph "Fixed Behavior (AFTER FIX)"
E2[取消 Button] --> F2[Clears only visible<br/>100 items ✅]
G2[确认删除 Button] --> H2[Processes only<br/>100 items ✅]
H2 --> I2[Only affects User A ✅]
end
```
---
## Solution
### Fix 1: "取消" Button - Only Uncheck Visible Items
**File**: `src/renderer/src/pages/CleanerPage.tsx:419-432`
```typescript
<button
onClick={() => {
// Only uncheck items that are visible in filteredResults
const visibleCodes = new Set(filteredResults.map((r) => r.materialCode))
setSelectedItems((prev) => {
const newSet = new Set(prev)
for (const code of visibleCodes) {
newSet.delete(code)
}
return newSet
})
}}
className="text-xs bg-white border border-slate-300 text-slate-700 px-2.5 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1"
>
<Square size={14} className="text-slate-400" />
</button>
```
**What Changed**:
- Before: `setSelectedItems(new Set())` - clears everything
- After: Iterates through `filteredResults` and removes only visible items from `selectedItems`
- Preserves selections for items not currently visible (e.g., other users' data)
### Fix 2: `handleConfirmDeletion` - Only Process Visible Items (Non-Admin)
**File**: `src/renderer/src/pages/CleanerPage.tsx:158-222`
```typescript
const handleConfirmDeletion = async () => {
// For non-admin users, only process visible filtered results
// For admin users, process all validation results
const resultsToProcess = isAdmin ? validationResults : filteredResults
if (resultsToProcess.length === 0) return alert('没有可处理的数据')
const materialsToUpsert: { materialCode: string; managerName: string }[] = []
const materialsToDelete: string[] = []
const missingManager: string[] = []
for (const result of resultsToProcess) {
// ... rest of processing logic
}
// ...
}
```
**What Changed**:
- Before: `for (const result of validationResults)` - processes all 1000 items
- After: `for (const result of resultsToProcess)` where:
- `Admin` → processes `validationResults` (all items)
- `User` → processes only `filteredResults` (visible items)
---
## Testing Scenarios
### Scenario 1: User Unchecks Own Data Only
**Setup**:
- User A logs in (non-Admin)
- 100 materials visible (assigned to User A)
- 900 materials invisible (assigned to other users)
- All 1000 materials are initially checked
**Actions**:
1. User A clicks "取消"
2. Table shows all checkboxes unchecked
**Expected**:
- ✅ User A's 100 materials are unchecked
- ✅ Other users' 900 materials **remain checked** (not affected)
**Verification**:
```typescript
// Before fix: selectedItems.size === 0
// After fix: selectedItems.size === 900 (other users' items still checked)
```
### Scenario 2: User Confirms Deletion
**Setup**:
- User A logs in (non-Admin)
- User A unchecks 50 of their 100 materials
- 50 items checked (User A's)
- 900 items checked (other users')
**Actions**:
1. User A clicks "确认删除"
2. Confirm dialog shows: "写入/更新 50 条记录"
**Expected**:
- ✅ Only User A's 50 materials are upserted to database
- ✅ Other users' 900 materials are **NOT touched**
- ✅ No materials are deleted (since other users' items aren't processed)
### Scenario 3: Admin Behavior Unchanged
**Setup**:
- Admin logs in
- All 1000 materials visible
- All filtered by selected managers
**Actions**:
1. Admin clicks "取消" → all visible items unchecked
2. Admin clicks "确认删除" → processes all filtered items
**Expected**:
- ✅ Admin behavior unchanged (can manage all data)
- ✅ Admin can still filter by managers and process filtered results
---
## Security & Scope Implications
### Before Fix (Vulnerability)
```mermaid
flowchart LR
UserA[User A] --> Sees[Sees 100 items]
UserB[User B] --> Sees2[Sees 900 items]
Sees --> Clicks[Clicks 取消 + 确认删除]
Clicks --> Deletes[Deletes ALL 1000 items ❌]
Deletes --> Impact[User B loses data ❌]
```
### After Fix (Secure)
```mermaid
flowchart LR
UserA[User A] --> Sees[Sees 100 items]
UserB[User B] --> Sees2[Sees 900 items]
Sees --> Clicks[Clicks 取消 + 确认删除]
Clicks --> Deletes[Deletes 100 items ✅]
Sees2 --> Independent[User B's data independent ✅]
Deletes --> Safe[User scope isolation ✅]
```
---
## Code Changes Summary
### File: `src/renderer/src/pages/CleanerPage.tsx`
| Line | Change | Description |
| ------- | -------------------------------- | ----------------------------------------- |
| 419-432 | Modified "取消" button | Only uncheck visible filteredResults |
| 158-222 | Modified `handleConfirmDeletion` | Use `resultsToProcess` based on `isAdmin` |
### Variables Used
- `validationResults`: All materials from backend (1000 items)
- `filteredResults`: Materials after user/manager filtering (100 items for User A)
- `selectedItems`: Set of checked material codes
- `isAdmin`: Boolean, true for Admin users
- `currentUsername`: Current logged-in username
---
## Verification Steps
1. **Test as User A**:
```bash
# Login as user1
npm run dev
# Navigate to CleanerPage
# Verify only user1's materials are visible
# Click "取消" → only visible items unchecked
# Check selectedItems size = other users' checked items
```
2. **Test as User B**:
```bash
# Login as user2
# Verify user1's changes didn't affect user2's data
# All user2's materials should still be intact
```
3. **Test as Admin**:
```bash
# Login as admin
# Verify can still see and manage all materials
# "取消" and "确认删除" work on all filtered results
```
---
## Related Files
- **Implementation**: `src/renderer/src/pages/CleanerPage.tsx`
- **Related**: `src/main/ipc/validation-handler.ts` (backend matching logic)
- **Related**: `docs/user-override-match-feature.md` (user override matching)
---
## Future Improvements
1. **Add Confirmation Dialog for Scope**: Show user how many items will be affected
2. **Add Audit Logging**: Log which user modified which materials
3. **Add Warning for Large Operations**: Warn if user is about to delete many items
4. **Backend Validation**: Add backend check to prevent cross-user data modification
---
**Document End**

File diff suppressed because it is too large Load Diff