From e23cf71f789c82b085a5e3009420d38918974480 Mon Sep 17 00:00:00 2001 From: Misaka Date: Tue, 3 Mar 2026 22:51:11 +0800 Subject: [PATCH] feat: add material type management feature - Add MaterialTypeManagementDialog component for managing material type keywords - Add MaterialsTypeToBeDeletedDAO for database operations - Add material-type-handler IPC handlers - Update CleanerPage with type management button - Add database fix scripts for AUTO_INCREMENT - Update documentation for settings partial save and validation flow Co-Authored-By: Claude (glm-5) --- docs/cleaner-user-scope-fix.md | 25 +- docs/cleaner-validation-flow.md | 18 +- docs/extractor-start-button-flow.md | 50 +- ...2026-03-03-settings-partial-save-design.md | 37 +- ...03-settings-partial-save-implementation.md | 229 +++++--- docs/settings-partial-save.md | 4 +- docs/settings-save-button-flow.md | 110 ++-- docs/user-override-match-feature.md | 37 +- ...97718a299488a3d6c7b9753a8bc7cf9-audit.json | 15 - scripts/fix-auto-increment.js | 117 ++++ scripts/fix-materials-type-auto-increment.sql | 82 +++ src/main/ipc/index.ts | 2 + src/main/ipc/material-type-handler.ts | 154 ++++++ src/main/services/config/config-manager.ts | 8 +- .../materials-type-to-be-deleted-dao.ts | 376 +++++++++++++ src/main/types/validation.types.ts | 18 + src/preload/index.d.ts | 51 +- src/preload/index.ts | 20 +- .../MaterialTypeManagementDialog.tsx | 523 ++++++++++++++++++ src/renderer/src/components/ui/Modal.tsx | 6 +- src/renderer/src/pages/CleanerPage.tsx | 17 +- src/renderer/src/pages/ExtractorPage.tsx | 8 + .../services/config/config-manager.test.ts | 70 ++- 23 files changed, 1764 insertions(+), 213 deletions(-) delete mode 100644 logs/.869a8c37397718a299488a3d6c7b9753a8bc7cf9-audit.json create mode 100644 scripts/fix-auto-increment.js create mode 100644 scripts/fix-materials-type-auto-increment.sql create mode 100644 src/main/ipc/material-type-handler.ts create mode 100644 src/main/services/database/materials-type-to-be-deleted-dao.ts create mode 100644 src/renderer/src/components/MaterialTypeManagementDialog.tsx diff --git a/docs/cleaner-user-scope-fix.md b/docs/cleaner-user-scope-fix.md index d9df7d7..8ef617b 100644 --- a/docs/cleaner-user-scope-fix.md +++ b/docs/cleaner-user-scope-fix.md @@ -12,6 +12,7 @@ ### 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 @@ -20,12 +21,14 @@ For **User type (non-Admin)** users: ### 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) { @@ -87,6 +90,7 @@ graph TB ``` **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) @@ -115,6 +119,7 @@ const handleConfirmDeletion = async () => { ``` **What Changed**: + - Before: `for (const result of validationResults)` - processes all 1000 items - After: `for (const result of resultsToProcess)` where: - `Admin` → processes `validationResults` (all items) @@ -127,20 +132,24 @@ const handleConfirmDeletion = async () => { ### 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) @@ -149,16 +158,19 @@ const handleConfirmDeletion = async () => { ### 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) @@ -166,15 +178,18 @@ const handleConfirmDeletion = async () => { ### 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 @@ -183,6 +198,7 @@ const handleConfirmDeletion = async () => { ## Security & Scope Implications ### Before Fix (Vulnerability) + ```mermaid flowchart LR UserA[User A] --> Sees[Sees 100 items] @@ -193,6 +209,7 @@ flowchart LR ``` ### After Fix (Secure) + ```mermaid flowchart LR UserA[User A] --> Sees[Sees 100 items] @@ -209,9 +226,9 @@ flowchart LR ### File: `src/renderer/src/pages/CleanerPage.tsx` -| Line | Change | Description | -|------|--------|-------------| -| 419-432 | Modified "取消" button | Only uncheck visible filteredResults | +| Line | Change | Description | +| ------- | -------------------------------- | ----------------------------------------- | +| 419-432 | Modified "取消" button | Only uncheck visible filteredResults | | 158-222 | Modified `handleConfirmDeletion` | Use `resultsToProcess` based on `isAdmin` | ### Variables Used @@ -227,6 +244,7 @@ flowchart LR ## Verification Steps 1. **Test as User A**: + ```bash # Login as user1 npm run dev @@ -237,6 +255,7 @@ flowchart LR ``` 2. **Test as User B**: + ```bash # Login as user2 # Verify user1's changes didn't affect user2's data diff --git a/docs/cleaner-validation-flow.md b/docs/cleaner-validation-flow.md index e01ab13..2968a8a 100644 --- a/docs/cleaner-validation-flow.md +++ b/docs/cleaner-validation-flow.md @@ -1245,15 +1245,15 @@ flowchart TB ## 文件索引 -| 文件路径 | 说明 | 关键行号 | -| ----------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------ | -| `src/renderer/src/pages/CleanerPage.tsx` | 前端清理页面 | 117-155 (handleValidation)
166-226 (handleConfirmDeletion) | -| `src/main/ipc/validation-handler.ts` | IPC处理器 | 212-400 (validation:validate)
407-420 (materials:upsertBatch)
425-447 (materials:delete) | -| `src/main/ipc/validation-handler.ts` | 用户信息获取 | 218-237 (获取当前用户 isAdmin username) | -| `src/main/ipc/validation-handler.ts` | 物料匹配算法 | 343-382 (优先级1-3匹配逻辑) | -| `src/main/services/database/discrete-material-plan-dao.ts` | 物料计划DAO | 191-227 (queryAllDistinctByMaterialCode) | -| `src/main/services/database/discrete-material-plan-dao.ts` | 物料计划DAO | 294-377 (queryBySourceNumbersDistinct) | -| `src/main/services/database/materials-to-be-deleted-dao.ts` | 待删除物料DAO | 180-240 (upsertBatch)
248-268 (getAllMaterialCodes)
539-586 (deleteByMaterialCodes) | +| 文件路径 | 说明 | 关键行号 | +| ----------------------------------------------------------- | ------------- | ---------------------------------------------------------------------------------------------- | +| `src/renderer/src/pages/CleanerPage.tsx` | 前端清理页面 | 117-155 (handleValidation)
166-226 (handleConfirmDeletion) | +| `src/main/ipc/validation-handler.ts` | IPC处理器 | 212-400 (validation:validate)
407-420 (materials:upsertBatch)
425-447 (materials:delete) | +| `src/main/ipc/validation-handler.ts` | 用户信息获取 | 218-237 (获取当前用户 isAdmin username) | +| `src/main/ipc/validation-handler.ts` | 物料匹配算法 | 343-382 (优先级1-3匹配逻辑) | +| `src/main/services/database/discrete-material-plan-dao.ts` | 物料计划DAO | 191-227 (queryAllDistinctByMaterialCode) | +| `src/main/services/database/discrete-material-plan-dao.ts` | 物料计划DAO | 294-377 (queryBySourceNumbersDistinct) | +| `src/main/services/database/materials-to-be-deleted-dao.ts` | 待删除物料DAO | 180-240 (upsertBatch)
248-268 (getAllMaterialCodes)
539-586 (deleteByMaterialCodes) | --- diff --git a/docs/extractor-start-button-flow.md b/docs/extractor-start-button-flow.md index 202ed10..a30703d 100644 --- a/docs/extractor-start-button-flow.md +++ b/docs/extractor-start-button-flow.md @@ -4,6 +4,7 @@ > **更新日期**: 2026-03-03 > **适用范围**: ERPAuto v1.0+ > **相关文件**: +> > - `src/renderer/src/pages/ExtractorPage.tsx` (UI层) > - `src/preload/index.ts` (IPC API 暴露) > - `src/main/ipc/extractor-handler.ts` (IPC处理层) @@ -298,15 +299,15 @@ stateDiagram-v2 ### 状态变量说明 -| 状态变量 | 类型 | 说明 | 持久化 | -|---------|------|------|--------| -| `orderNumbers` | string | 用户输入的订单号列表 | ✅ sessionStorage | -| `batchSize` | number | 每批处理的订单数量 (默认100) | ✅ sessionStorage | -| `isRunning` | boolean | 是否正在执行提取 | ❌ 内存状态 | -| `progress` | ExtractorProgress \| null | 当前进度信息 (当前实现中未从后端接收) | ❌ 内存状态 | -| `result` | ExtractorResult \| null | 提取结果 | ❌ 内存状态 | -| `error` | string \| null | 错误信息 | ❌ 内存状态 | -| `logs` | string[] | 执行日志列表 | ❌ 内存状态 | +| 状态变量 | 类型 | 说明 | 持久化 | +| -------------- | ------------------------- | ------------------------------------- | ----------------- | +| `orderNumbers` | string | 用户输入的订单号列表 | ✅ sessionStorage | +| `batchSize` | number | 每批处理的订单数量 (默认100) | ✅ sessionStorage | +| `isRunning` | boolean | 是否正在执行提取 | ❌ 内存状态 | +| `progress` | ExtractorProgress \| null | 当前进度信息 (当前实现中未从后端接收) | ❌ 内存状态 | +| `result` | ExtractorResult \| null | 提取结果 | ❌ 内存状态 | +| `error` | string \| null | 错误信息 | ❌ 内存状态 | +| `logs` | string[] | 执行日志列表 | ❌ 内存状态 | > **注意**: `progress` 状态目前未从后端接收实时更新。虽然 `ExtractorService` 内部调用 `onProgress` 回调,但函数无法通过 IPC 序列化传递。后续可通过 IPC 事件通道实现实时进度更新。 @@ -381,13 +382,13 @@ flowchart TD ### 错误类型与处理策略 -| 错误类型 | 触发条件 | 用户反馈 | 恢复策略 | -|---------|---------|---------|---------| -| `ValidationError` | 订单号为空、配置不完整、无有效订单号 | 显示红色错误消息 | 修正输入后重试 | -| `DatabaseQueryError` | 数据库连接失败 (MySQL/SQL Server) | 显示数据库连接错误 | 检查数据库配置 | -| `ErpConnectionError` | ERP登录失败 | 显示ERP登录错误 | 检查ERP凭据 | -| `BatchError` | 单个批次处理失败 | 记录到错误列表,继续处理 | 查看错误详情 | -| `SystemError` | 未知系统错误 | 显示通用错误消息 | 查看日志 | +| 错误类型 | 触发条件 | 用户反馈 | 恢复策略 | +| -------------------- | ------------------------------------ | ------------------------ | -------------- | +| `ValidationError` | 订单号为空、配置不完整、无有效订单号 | 显示红色错误消息 | 修正输入后重试 | +| `DatabaseQueryError` | 数据库连接失败 (MySQL/SQL Server) | 显示数据库连接错误 | 检查数据库配置 | +| `ErpConnectionError` | ERP登录失败 | 显示ERP登录错误 | 检查ERP凭据 | +| `BatchError` | 单个批次处理失败 | 记录到错误列表,继续处理 | 查看错误详情 | +| `SystemError` | 未知系统错误 | 显示通用错误消息 | 查看日志 | --- @@ -471,6 +472,7 @@ flowchart LR ### 数据转换详情 **阶段1: 用户输入 → Production IDs** + ``` 输入: "PO-20231024-001\nPO-20231024-002\nPO-20231024-003" ↓ 分割 + trim + 过滤 @@ -480,6 +482,7 @@ flowchart LR ``` **阶段2: Production IDs → 生产订单号** + ``` 输入: ["PO-20231024-001", "PO-20231024-002", "INVALID"] ↓ MySQL查询 (production_order表) @@ -494,6 +497,7 @@ flowchart LR ``` **阶段3: 生产订单号 → 批次** + ``` 输入: ["MO-001", "MO-002", ..., "MO-250"] (250个) 批次大小: 100 @@ -504,6 +508,7 @@ flowchart LR ``` **阶段4: 批次 → ERP查询字符串** + ``` 批次: ["MO-001", "MO-002", "MO-003"] ↓ 逗号连接 @@ -582,6 +587,7 @@ useEffect(() => { ``` > **设计说明**: 订单号通过两种方式存储到共享状态: +> > 1. `useEffect` 在用户输入时实时更新 > 2. `handleExtract` 在提取开始前再次确认存储 > @@ -609,7 +615,7 @@ ipcMain.handle( // 2. 使用数据库工厂创建服务实例 (支持 MySQL 和 SQL Server) try { - dbService = await create() // 工厂方法,根据 DB_TYPE 自动选择数据库 + dbService = await create() // 工厂方法,根据 DB_TYPE 自动选择数据库 } catch (error) { throw new DatabaseQueryError('数据库连接失败', 'DB_CONNECTION_FAILED', error) } @@ -659,7 +665,7 @@ ipcMain.handle( * 支持 MySQL 和 SQL Server 双数据库 */ export async function create(type?: DatabaseType): Promise { - const dbType = type || getDatabaseType() // 从 DB_TYPE 环境变量读取 + const dbType = type || getDatabaseType() // 从 DB_TYPE 环境变量读取 // 返回缓存的实例(单例模式) const cached = instances.get(dbType) @@ -677,7 +683,7 @@ export async function create(type?: DatabaseType): Promise { } await service.connect() - instances.set(dbType, service) // 缓存实例 + instances.set(dbType, service) // 缓存实例 return service } @@ -835,7 +841,7 @@ extractor: { export interface ExtractorInput { orderNumbers: string[] batchSize?: number - onProgress?: (message: string, progress: number) => void // 注意: 函数无法通过IPC传递 + onProgress?: (message: string, progress: number) => void // 注意: 函数无法通过IPC传递 } export interface ExtractorResult { @@ -918,12 +924,14 @@ export interface ExtractorResult { **原因**: IPC 通信无法序列化函数,`onProgress` 回调无法传递到主进程。 **当前实现**: + ```typescript // extractor.ts 中调用但无效 input.onProgress?.(`Processing batch ${i + 1}/${batches.length}`, progress) ``` **建议实现方案**: + ```typescript // 方案: 使用 IPC 事件通道 @@ -941,7 +949,7 @@ extractor: { useEffect(() => { window.electron.extractor.onProgress((data) => { setProgress(data) - setLogs(prev => [...prev, `[${new Date().toLocaleTimeString()}] ${data.message}`]) + setLogs((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${data.message}`]) }) }, []) ``` diff --git a/docs/plans/2026-03-03-settings-partial-save-design.md b/docs/plans/2026-03-03-settings-partial-save-design.md index caf59d3..b354b7f 100644 --- a/docs/plans/2026-03-03-settings-partial-save-design.md +++ b/docs/plans/2026-03-03-settings-partial-save-design.md @@ -15,6 +15,7 @@ 在 `config-manager.ts:437-483` 中,`saveAllSettings()` 方法无条件覆盖所有配置类别。当 UI 只发送部分字段时,未包含的字段会被设置为 `undefined` 或默认值,导致原有配置丢失。 **数据流问题:** + ``` SettingsPage (只修改 ERP URL) ↓ 发送完整的 settings 对象 @@ -69,11 +70,11 @@ ConfigManager.saveAllSettings() ### 改动点 -| 文件 | 改动类型 | 说明 | -|------|---------|------| -| `src/main/services/config/config-manager.ts` | 核心 | 新增 `savePartialSettings()`、深度合并、备份机制 | -| `src/main/ipc/settings-handler.ts` | 调整 | IPC 参数改为 `Partial` | -| `src/renderer/src/pages/SettingsPage.tsx` | 优化 | 只发送 UI 支持的字段 | +| 文件 | 改动类型 | 说明 | +| -------------------------------------------- | -------- | ------------------------------------------------ | +| `src/main/services/config/config-manager.ts` | 核心 | 新增 `savePartialSettings()`、深度合并、备份机制 | +| `src/main/ipc/settings-handler.ts` | 调整 | IPC 参数改为 `Partial` | +| `src/renderer/src/pages/SettingsPage.tsx` | 优化 | 只发送 UI 支持的字段 | --- @@ -120,7 +121,7 @@ function isObject(value: unknown): value is Record { const UI_EDITABLE_FIELDS: string[] = [ 'erp.url', 'erp.username', - 'erp.password', + 'erp.password' // 未来扩展: // 'database.dbType', // 'paths.dataDir', @@ -305,6 +306,7 @@ ipcMain.handle( ``` **关键改动:** + - 参数类型从 `SettingsData` 改为 `Partial` - 调用 `savePartialSettings()` 替代 `saveAllSettings()` @@ -409,9 +411,9 @@ const UI_EDITABLE_FIELDS: string[] = [ 'erp.url', 'erp.username', 'erp.password', - 'database.dbType', // 新增 - 'paths.dataDir', // 新增 - 'extraction.batchSize', // 新增 + 'database.dbType', // 新增 + 'paths.dataDir', // 新增 + 'extraction.batchSize' // 新增 // ... ] ``` @@ -425,10 +427,7 @@ const EDITABLE_FIELDS_BY_ROLE: Record = { Guest: [] } -function validateEditableFields( - settings: Partial, - userType: UserType -) { +function validateEditableFields(settings: Partial, userType: UserType) { const allowed = EDITABLE_FIELDS_BY_ROLE[userType] // 验证逻辑... } @@ -464,12 +463,12 @@ interface ConfigChange { ## 风险与缓解 -| 风险 | 影响 | 缓解措施 | -|------|------|---------| -| 深度合并逻辑错误 | 配置错误 | 完善单元测试覆盖 | -| 备份文件权限问题 | 无法恢复 | 错误处理 + 日志 | -| 白名单漏配置 | 功能受限 | 清晰的文档 + 代码注释 | -| 并发保存冲突 | 数据不一致 | 单实例 ConfigManager + 文件锁 | +| 风险 | 影响 | 缓解措施 | +| ---------------- | ---------- | ----------------------------- | +| 深度合并逻辑错误 | 配置错误 | 完善单元测试覆盖 | +| 备份文件权限问题 | 无法恢复 | 错误处理 + 日志 | +| 白名单漏配置 | 功能受限 | 清晰的文档 + 代码注释 | +| 并发保存冲突 | 数据不一致 | 单实例 ConfigManager + 文件锁 | --- diff --git a/docs/plans/2026-03-03-settings-partial-save-implementation.md b/docs/plans/2026-03-03-settings-partial-save-implementation.md index 239ae57..7aadb0f 100644 --- a/docs/plans/2026-03-03-settings-partial-save-implementation.md +++ b/docs/plans/2026-03-03-settings-partial-save-implementation.md @@ -13,6 +13,7 @@ ## Task 1: Add Utility Functions to ConfigManager **Files:** + - Modify: `src/main/services/config/config-manager.ts` **Step 1: Write failing test for deep merge** @@ -31,11 +32,38 @@ describe('ConfigManager - deep merge utilities', () => { // Setup initial state const initial: SettingsData = { - erp: { url: 'http://old.com', username: 'user1', password: 'pass1', headless: true, ignoreHttpsErrors: true, autoCloseBrowser: true }, - database: { dbType: 'mysql', server: '', mysqlHost: 'localhost', mysqlPort: 3306, database: 'db', username: 'user', password: '' }, + erp: { + url: 'http://old.com', + username: 'user1', + password: 'pass1', + headless: true, + ignoreHttpsErrors: true, + autoCloseBrowser: true + }, + database: { + dbType: 'mysql', + server: '', + mysqlHost: 'localhost', + mysqlPort: 3306, + database: 'db', + username: 'user', + password: '' + }, paths: { dataDir: '/data', defaultOutput: 'out.xlsx', validationOutput: 'validation.xlsx' }, - extraction: { batchSize: 100, verbose: true, autoConvert: true, mergeBatches: true, enableDbPersistence: true }, - validation: { dataSource: 'database_full', batchSize: 2000, matchMode: 'substring', enableCrud: false, defaultManager: '' }, + extraction: { + batchSize: 100, + verbose: true, + autoConvert: true, + mergeBatches: true, + enableDbPersistence: true + }, + validation: { + dataSource: 'database_full', + batchSize: 2000, + matchMode: 'substring', + enableCrud: false, + defaultManager: '' + }, ui: { fontFamily: 'Arial', fontSize: 12, productionIdInputWidth: 20 }, execution: { dryRun: false } } @@ -113,7 +141,7 @@ function deepMerge(source: T, target: Partial): T { const UI_EDITABLE_FIELDS: string[] = [ 'erp.url', 'erp.username', - 'erp.password', + 'erp.password' // Add more fields as UI expands ] @@ -163,6 +191,7 @@ git commit -m "feat: add deep merge and validation utility functions to ConfigMa ## Task 2: Add Backup and Restore Mechanism **Files:** + - Modify: `src/main/services/config/config-manager.ts` **Step 1: Write test for backup functionality** @@ -302,6 +331,7 @@ git commit -m "feat: add backup and restore mechanism to ConfigManager" ## Task 3: Implement savePartialSettings Method **Files:** + - Modify: `src/main/services/config/config-manager.ts` **Step 1: Write comprehensive test for savePartialSettings** @@ -316,11 +346,38 @@ describe('ConfigManager.savePartialSettings', () => { // Setup initial state with multiple categories await manager.saveAllSettings({ - erp: { url: 'http://old.com', username: 'user1', password: 'pass1', headless: true, ignoreHttpsErrors: true, autoCloseBrowser: true }, - database: { dbType: 'mysql', server: '', mysqlHost: '192.168.1.1', mysqlPort: 3306, database: 'testdb', username: 'dbuser', password: '' }, + erp: { + url: 'http://old.com', + username: 'user1', + password: 'pass1', + headless: true, + ignoreHttpsErrors: true, + autoCloseBrowser: true + }, + database: { + dbType: 'mysql', + server: '', + mysqlHost: '192.168.1.1', + mysqlPort: 3306, + database: 'testdb', + username: 'dbuser', + password: '' + }, paths: { dataDir: '/old/path', defaultOutput: 'out.xlsx', validationOutput: 'val.xlsx' }, - extraction: { batchSize: 50, verbose: true, autoConvert: true, mergeBatches: true, enableDbPersistence: true }, - validation: { dataSource: 'database_full', batchSize: 1000, matchMode: 'exact', enableCrud: false, defaultManager: '' }, + extraction: { + batchSize: 50, + verbose: true, + autoConvert: true, + mergeBatches: true, + enableDbPersistence: true + }, + validation: { + dataSource: 'database_full', + batchSize: 1000, + matchMode: 'exact', + enableCrud: false, + defaultManager: '' + }, ui: { fontFamily: 'Tahoma', fontSize: 14, productionIdInputWidth: 25 }, execution: { dryRun: true } }) @@ -367,11 +424,38 @@ describe('ConfigManager.savePartialSettings', () => { await manager.initialize() await manager.saveAllSettings({ - erp: { url: 'http://test.com', username: 'u', password: 'p', headless: false, ignoreHttpsErrors: false, autoCloseBrowser: false }, - database: { dbType: 'mysql', server: '', mysqlHost: 'localhost', mysqlPort: 3306, database: 'db', username: 'user', password: '' }, + erp: { + url: 'http://test.com', + username: 'u', + password: 'p', + headless: false, + ignoreHttpsErrors: false, + autoCloseBrowser: false + }, + database: { + dbType: 'mysql', + server: '', + mysqlHost: 'localhost', + mysqlPort: 3306, + database: 'db', + username: 'user', + password: '' + }, paths: { dataDir: '/data', defaultOutput: 'out.xlsx', validationOutput: 'val.xlsx' }, - extraction: { batchSize: 100, verbose: true, autoConvert: true, mergeBatches: true, enableDbPersistence: true }, - validation: { dataSource: 'database_full', batchSize: 2000, matchMode: 'substring', enableCrud: false, defaultManager: '' }, + extraction: { + batchSize: 100, + verbose: true, + autoConvert: true, + mergeBatches: true, + enableDbPersistence: true + }, + validation: { + dataSource: 'database_full', + batchSize: 2000, + matchMode: 'substring', + enableCrud: false, + defaultManager: '' + }, ui: { fontFamily: 'Arial', fontSize: 12, productionIdInputWidth: 20 }, execution: { dryRun: false } }) @@ -516,6 +600,7 @@ git commit -m "feat: implement savePartialSettings with validation and rollback" ## Task 4: Update IPC Handler to Use Partial Save **Files:** + - Modify: `src/main/ipc/settings-handler.ts` **Step 1: Update settings:saveSettings handler** @@ -523,42 +608,42 @@ git commit -m "feat: implement savePartialSettings with validation and rollback" Find the `settings:saveSettings` handler (around line 83) and replace it: ```typescript - /** - * Save settings (updated to use partial save) - */ - ipcMain.handle( - 'settings:saveSettings', - async (_event, settings: Partial): Promise => { - try { - log.info('Saving settings', { - sections: Object.keys(settings) +/** + * Save settings (updated to use partial save) + */ +ipcMain.handle( + 'settings:saveSettings', + async (_event, settings: Partial): Promise => { + try { + log.info('Saving settings', { + sections: Object.keys(settings) + }) + + // Use partial save method + const result = await configManager.savePartialSettings(settings) + + if (result.success) { + log.info('Settings saved successfully') + return { success: true } + } else { + log.warn('Failed to save settings', { + error: result.error }) - - // Use partial save method - const result = await configManager.savePartialSettings(settings) - - if (result.success) { - log.info('Settings saved successfully') - return { success: true } - } else { - log.warn('Failed to save settings', { - error: result.error - }) - return { - success: false, - error: result.error || '保存设置失败' - } - } - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' - log.error('Error saving settings', { error: message }) return { success: false, - error: `保存设置失败:${message}` + error: result.error || '保存设置失败' } } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + log.error('Error saving settings', { error: message }) + return { + success: false, + error: `保存设置失败:${message}` + } } - ) + } +) ``` **Step 2: Run typecheck** @@ -580,6 +665,7 @@ git commit -m "feat: update settings handler to use savePartialSettings" ## Task 5: Update Frontend to Send Only Necessary Fields **Files:** + - Modify: `src/renderer/src/pages/SettingsPage.tsx` **Step 1: Update handleSaveSettings to send partial settings** @@ -587,29 +673,29 @@ git commit -m "feat: update settings handler to use savePartialSettings" Find the `handleSaveSettings` function (around line 61) and replace it: ```typescript - const handleSaveSettings = async () => { - try { - // Only send UI-supported fields (double safety) - const partialSettings = { - erp: { - url: settings.erp?.url, - username: settings.erp?.username, - password: settings.erp?.password - } +const handleSaveSettings = async () => { + try { + // Only send UI-supported fields (double safety) + const partialSettings = { + erp: { + url: settings.erp?.url, + username: settings.erp?.username, + password: settings.erp?.password } - - const result = await window.electron.settings.saveSettings(partialSettings as any) - - if (result.success) { - setIsModified(false) - showMessage('success', '设置保存成功') - } else { - showMessage('error', result.error || '保存失败') - } - } catch (error) { - showMessage('error', '保存设置时发生错误') } + + const result = await window.electron.settings.saveSettings(partialSettings as any) + + if (result.success) { + setIsModified(false) + showMessage('success', '设置保存成功') + } else { + showMessage('error', result.error || '保存失败') + } + } catch (error) { + showMessage('error', '保存设置时发生错误') } +} ``` **Step 2: Run typecheck** @@ -631,6 +717,7 @@ git commit -m "feat: send only ERP fields from settings page (defensive programm ## Task 6: Manual Testing and Verification **Files:** + - Manual test procedure **Step 1: Prepare test environment** @@ -701,6 +788,7 @@ cat .env ``` Expected results: + - `ERP_URL` should be `http://modified-test.com` (CHANGED) - `DB_TYPE` should still be `mysql` (PRESERVED) - `VALIDATION_MATCH_MODE` should still be `substring` (PRESERVED) @@ -790,6 +878,7 @@ git commit -m "test: add manual test report for settings partial save" ## Task 7: Update Documentation **Files:** + - Create: `docs/settings-partial-save.md` - Update: `README.md` (if applicable) @@ -797,7 +886,7 @@ git commit -m "test: add manual test report for settings partial save" Create `docs/settings-partial-save.md`: -```markdown +````markdown # Settings Partial Save Feature ## Overview @@ -813,6 +902,7 @@ The settings system now implements partial save functionality to prevent uninten ## Editable Fields Currently editable via UI: + - `erp.url` - ERP system URL - `erp.username` - ERP login username - `erp.password` - ERP login password @@ -828,9 +918,10 @@ const UI_EDITABLE_FIELDS: string[] = [ 'erp.url', 'erp.username', 'erp.password', - 'database.dbType', // Add new field here + 'database.dbType' // Add new field here ] ``` +```` 2. Add UI input in `src/renderer/src/pages/SettingsPage.tsx` 3. Update `handleSaveSettings` to include the new field @@ -844,6 +935,7 @@ Saves only the provided fields, preserving all existing configuration. **Returns:** `{ success: boolean, error?: string }` **Validation:** + - Checks whitelist before applying changes - Returns error for unauthorized fields @@ -858,7 +950,8 @@ Saves only the provided fields, preserving all existing configuration. Location: `.env.backup` (in project root) Created before every save operation. Used for rollback on failure. -``` + +```` **Step 2: Update CLAUDE.md if needed** @@ -870,13 +963,14 @@ Add to "Development Commands" or "Architecture Overview" sections if there's rel cd D:/Node/ERPAuto-settings-fix git add docs/settings-partial-save.md git commit -m "docs: add settings partial save feature documentation" -``` +```` --- ## Task 8: Final Verification and Cleanup **Files:** + - All modified files **Step 1: Run full test suite** @@ -937,6 +1031,7 @@ This implementation plan fixes the settings save issue through: **Total estimated implementation time:** 2-3 hours **Key files modified:** + - `src/main/services/config/config-manager.ts` (core logic) - `src/main/ipc/settings-handler.ts` (IPC layer) - `src/renderer/src/pages/SettingsPage.tsx` (frontend) diff --git a/docs/settings-partial-save.md b/docs/settings-partial-save.md index 6e745a5..4b2d46f 100644 --- a/docs/settings-partial-save.md +++ b/docs/settings-partial-save.md @@ -13,6 +13,7 @@ The settings system now implements partial save functionality to prevent uninten ## Editable Fields Currently editable via UI: + - `erp.url` - ERP system URL - `erp.username` - ERP login username - `erp.password` - ERP login password @@ -28,7 +29,7 @@ const UI_EDITABLE_FIELDS: string[] = [ 'erp.url', 'erp.username', 'erp.password', - 'database.dbType', // Add new field here + 'database.dbType' // Add new field here ] ``` @@ -44,6 +45,7 @@ Saves only the provided fields, preserving all existing configuration. **Returns:** `{ success: boolean, error?: string }` **Validation:** + - Checks whitelist before applying changes - Returns error for unauthorized fields diff --git a/docs/settings-save-button-flow.md b/docs/settings-save-button-flow.md index 0fe767e..688b15b 100644 --- a/docs/settings-save-button-flow.md +++ b/docs/settings-save-button-flow.md @@ -1,4 +1,5 @@ # 系统设置保存按钮工作流程分析 + # System Settings Save Button Workflow Analysis ## 文档概述 / Document Overview @@ -171,6 +172,7 @@ graph LR #### SettingsPage.tsx (`src/renderer/src/pages/SettingsPage.tsx`) **主要职责 / Main Responsibilities:** + - 用户界面渲染和交互 - 本地状态管理(settings, isModified, message) - 调用 IPC 通信 @@ -183,7 +185,7 @@ const handleSaveSettings = async () => { try { const result = await window.electron.settings.saveSettings(settings as any) if (result.success) { - setIsModified(false) // 清除修改标记 + setIsModified(false) // 清除修改标记 showMessage('success', '设置保存成功') } else { showMessage('error', result.error || '保存失败') @@ -196,12 +198,12 @@ const handleSaveSettings = async () => { **状态管理 / State Management:** -| 状态变量 | 类型 | 用途 | -|---------|------|------| -| `settings` | `Settings` | 当前配置数据,结构为 `{ erp: { url, username, password } }` | -| `isModified` | `boolean` | 标记配置是否已修改,控制保存按钮启用状态 | -| `isLoading` | `boolean` | 加载状态,显示加载动画 | -| `message` | `object \| null` | 临时消息,3秒后自动消失 | +| 状态变量 | 类型 | 用途 | +| ------------ | ---------------- | ----------------------------------------------------------- | +| `settings` | `Settings` | 当前配置数据,结构为 `{ erp: { url, username, password } }` | +| `isModified` | `boolean` | 标记配置是否已修改,控制保存按钮启用状态 | +| `isLoading` | `boolean` | 加载状态,显示加载动画 | +| `message` | `object \| null` | 临时消息,3秒后自动消失 | **UI 交互逻辑 / UI Interaction Logic:** @@ -232,6 +234,7 @@ stateDiagram-v2 #### preload/index.ts (`src/preload/index.ts`) **主要职责 / Main Responsibilities:** + - 安全桥梁,暴露受限 API 到渲染进程 - 类型安全的 IPC 通道定义 @@ -273,6 +276,7 @@ graph TB #### settings-handler.ts (`src/main/ipc/settings-handler.ts`) **主要职责 / Main Responsibilities:** + - IPC 通道注册和处理 - 权限验证(基于用户类型) - 业务逻辑协调 @@ -334,19 +338,20 @@ function filterSettingsByUserType(settings: SettingsData, userType: UserType): S **权限控制矩阵 / Permission Control Matrix:** -| 功能 / Feature | Admin | User | Guest | -|---------------|-------|------|-------| -| 查看所有设置 | ✅ | ⚠️ 部分 | ❌ | -| 保存设置 | ✅ | ✅ | ❌ | -| 恢复默认值 | ✅ | ❌ | ❌ | -| 测试 ERP 连接 | ✅ | ✅ | ❌ | -| 测试数据库连接 | ✅ | ✅ | ❌ | +| 功能 / Feature | Admin | User | Guest | +| -------------- | ----- | ------- | ----- | +| 查看所有设置 | ✅ | ⚠️ 部分 | ❌ | +| 保存设置 | ✅ | ✅ | ❌ | +| 恢复默认值 | ✅ | ❌ | ❌ | +| 测试 ERP 连接 | ✅ | ✅ | ❌ | +| 测试数据库连接 | ✅ | ✅ | ❌ | ### 4. 配置管理服务 / Configuration Manager Service #### config-manager.ts (`src/main/services/config/config-manager.ts`) **主要职责 / Main Responsibilities:** + - .env 文件读写 - 配置缓存管理 - 默认值管理 @@ -356,10 +361,10 @@ function filterSettingsByUserType(settings: SettingsData, userType: UserType): S ```typescript export class ConfigManager { - private static instance: ConfigManager | null = null // 单例模式 - private envPath: string // .env 文件路径 - private configCache: Map // 内存缓存 - private initialized: boolean = false // 初始化标记 + private static instance: ConfigManager | null = null // 单例模式 + private envPath: string // .env 文件路径 + private configCache: Map // 内存缓存 + private initialized: boolean = false // 初始化标记 // 单例获取方法 public static getInstance(): ConfigManager @@ -519,6 +524,7 @@ graph TB ### IPC 通信数据格式 / IPC Communication Data Format **请求格式 / Request Format:** + ```json { "erp": { @@ -539,6 +545,7 @@ graph TB ``` **响应格式 / Response Format:** + ```json // 成功 / Success { @@ -594,13 +601,13 @@ graph TB ### 错误场景分析 / Error Scenario Analysis -| 错误场景 / Error Scenario | 触发位置 / Location | 处理方式 / Handling | 用户反馈 / User Feedback | -|--------------------------|-------------------|-------------------|----------------------| -| IPC 通信失败 | Renderer | try-catch | 显示"保存设置时发生错误" | -| 权限不足 | Main Process | 检查 UserType | 返回权限错误信息 | -| 文件写入失败 | ConfigManager | fs.writeFileSync 捕获 | 返回"保存设置失败" | -| 无效数据类型 | IPC Handler | TypeScript 类型检查 | 返回验证错误 | -| 磁盘空间不足 | File System | OS 异常捕获 | 返回系统错误信息 | +| 错误场景 / Error Scenario | 触发位置 / Location | 处理方式 / Handling | 用户反馈 / User Feedback | +| ------------------------- | ------------------- | --------------------- | ------------------------ | +| IPC 通信失败 | Renderer | try-catch | 显示"保存设置时发生错误" | +| 权限不足 | Main Process | 检查 UserType | 返回权限错误信息 | +| 文件写入失败 | ConfigManager | fs.writeFileSync 捕获 | 返回"保存设置失败" | +| 无效数据类型 | IPC Handler | TypeScript 类型检查 | 返回验证错误 | +| 磁盘空间不足 | File System | OS 异常捕获 | 返回系统错误信息 | ### 日志记录策略 / Logging Strategy @@ -613,6 +620,7 @@ log.error('Error saving settings', { error: message }) ``` **日志级别使用 / Log Level Usage:** + - `info`: 正常操作流程 - `warn`: 潜在问题(如保存失败但未崩溃) - `error`: 严重错误(如异常抛出) @@ -680,14 +688,14 @@ sequenceDiagram ### 文件位置索引 / File Location Index -| 组件 / Component | 文件路径 / File Path | 关键行数 / Key Lines | -|-----------------|---------------------|-------------------| -| UI 组件 | `src/renderer/src/pages/SettingsPage.tsx` | 61-73 (保存处理) | -| 预加载脚本 | `src/preload/index.ts` | 89-97 (API 定义) | -| IPC 处理器 | `src/main/ipc/settings-handler.ts` | 83-102 (保存处理) | -| 配置管理器 | `src/main/services/config/config-manager.ts` | 437-483 (保存方法) | -| 类型定义 | `src/main/types/settings.types.ts` | 136-171 (接口定义) | -| IPC 注册 | `src/main/ipc/index.ts` | 导入 settings-handler | +| 组件 / Component | 文件路径 / File Path | 关键行数 / Key Lines | +| ---------------- | -------------------------------------------- | --------------------- | +| UI 组件 | `src/renderer/src/pages/SettingsPage.tsx` | 61-73 (保存处理) | +| 预加载脚本 | `src/preload/index.ts` | 89-97 (API 定义) | +| IPC 处理器 | `src/main/ipc/settings-handler.ts` | 83-102 (保存处理) | +| 配置管理器 | `src/main/services/config/config-manager.ts` | 437-483 (保存方法) | +| 类型定义 | `src/main/types/settings.types.ts` | 136-171 (接口定义) | +| IPC 注册 | `src/main/ipc/index.ts` | 导入 settings-handler | ### 性能特性 / Performance Characteristics @@ -743,11 +751,12 @@ const updateSettings = (category: string, key: string, value: any) => { [key]: value } })) - setIsModified(true) // 标记为已修改 + setIsModified(true) // 标记为已修改 } ``` **设计要点 / Design Points:** + - 不可变更新模式(Immutable Update Pattern) - 使用展开运算符保持对象引用 - 自动启用保存按钮 @@ -768,6 +777,7 @@ public async saveAllSettings(settings: SettingsData): Promise { ``` **设计要点 / Design Points:** + - 先更新内存,后写入磁盘 - 失败时缓存保持不变 - 返回布尔值表示成功/失败 @@ -792,6 +802,7 @@ public async save(): Promise { ``` **设计要点 / Design Points:** + - 添加注释分隔符提高可读性 - 使用默认值作为后备 - 同步写入确保一致性 @@ -820,6 +831,7 @@ public async save(): Promise { ### 长期改进 / Long-term Improvements 1. **安全性增强 / Security Enhancement** + ```typescript // 建议实现密码加密 interface SecureSettingsData extends SettingsData { @@ -851,7 +863,9 @@ public async save(): Promise { describe('ConfigManager', () => { it('should save settings successfully', async () => { const manager = ConfigManager.getInstance() - const settings: SettingsData = { /* mock data */ } + const settings: SettingsData = { + /* mock data */ + } const result = await manager.saveAllSettings(settings) expect(result).toBe(true) }) @@ -883,16 +897,16 @@ describe('Settings Save Flow', () => { ### 完整配置字段列表 / Complete Configuration Field List -| 类别 / Category | 字段数 / Field Count | 字段列表 / Field List | -|---------------|---------------------|-------------------| -| ERP | 6 | url, username, password, headless, ignoreHttpsErrors, autoCloseBrowser | -| Database | 7 | dbType, server, mysqlHost, mysqlPort, database, username, password | -| Paths | 3 | dataDir, defaultOutput, validationOutput | -| Extraction | 5 | batchSize, verbose, autoConvert, mergeBatches, enableDbPersistence | -| Validation | 5 | dataSource, batchSize, matchMode, enableCrud, defaultManager | -| UI | 3 | fontFamily, fontSize, productionIdInputWidth | -| Execution | 1 | dryRun | -| **总计 / Total** | **30** | | +| 类别 / Category | 字段数 / Field Count | 字段列表 / Field List | +| ---------------- | -------------------- | ---------------------------------------------------------------------- | +| ERP | 6 | url, username, password, headless, ignoreHttpsErrors, autoCloseBrowser | +| Database | 7 | dbType, server, mysqlHost, mysqlPort, database, username, password | +| Paths | 3 | dataDir, defaultOutput, validationOutput | +| Extraction | 5 | batchSize, verbose, autoConvert, mergeBatches, enableDbPersistence | +| Validation | 5 | dataSource, batchSize, matchMode, enableCrud, defaultManager | +| UI | 3 | fontFamily, fontSize, productionIdInputWidth | +| Execution | 1 | dryRun | +| **总计 / Total** | **30** | | ### 相关文档 / Related Documentation @@ -902,9 +916,9 @@ describe('Settings Save Flow', () => { ### 版本历史 / Version History -| 版本 / Version | 日期 / Date | 变更 / Changes | -|---------------|------------|--------------| -| 1.0 | 2025-03-03 | 初始版本 / Initial version | +| 版本 / Version | 日期 / Date | 变更 / Changes | +| -------------- | ----------- | -------------------------- | +| 1.0 | 2025-03-03 | 初始版本 / Initial version | --- diff --git a/docs/user-override-match-feature.md b/docs/user-override-match-feature.md index 5ae1530..c677fcb 100644 --- a/docs/user-override-match-feature.md +++ b/docs/user-override-match-feature.md @@ -44,6 +44,7 @@ log.info('Starting validation', { mode: request.mode, user: username, isAdmin }) ``` **说明**: + - 在 `validation:validate` handler 开始时获取当前登录用户信息 - 提取 `isAdmin` 和 `username` 用于后续匹配逻辑 - 如果用户未登录,返回错误响应 @@ -68,6 +69,7 @@ if (!isAdmin && username) { ``` **匹配逻辑**: + 1. **适用范围**: 仅对 `isAdmin === false` 的 User 用户生效 2. **筛选关键词**: 从 `typeKeywords` 中筛选 `managerName === username` 的记录 3. **匹配规则**: 使用 `materialName.includes(userKeyword.materialName)` 包含关系匹配 @@ -109,11 +111,13 @@ flowchart TB ### 场景1: User 用户匹配到自己的 typeKeyword **输入**: + - 当前用户: `user1` - 物料名称: `螺丝 M6` - MaterialsTypeToBeDeleted: `{ materialName: "螺丝", managerName: "user1" }` **预期输出**: + ```json { "materialName": "螺丝 M6", @@ -126,6 +130,7 @@ flowchart TB ### 场景2: User 用户覆盖其他用户的匹配 **输入**: + - 当前用户: `user1` - 物料名称: `螺丝 M6` - MaterialsTypeToBeDeleted: @@ -138,12 +143,14 @@ flowchart TB ### 场景3: User 用户无匹配关键词 **输入**: + - 当前用户: `user1` - 物料名称: `螺丝 M6` - MaterialsTypeToBeDeleted: - `{ materialName: "螺丝", managerName: "user2" }` **预期输出**: + ```json { "materialName": "螺丝 M6", @@ -152,11 +159,13 @@ flowchart TB "isMarkedForDeletion": false } ``` + **说明**: 保持优先级2的匹配结果 ### 场景4: Admin 用户不执行覆盖 **输入**: + - 当前用户: `admin` (isAdmin=true) - 物料名称: `螺丝 M6` - MaterialsTypeToBeDeleted: @@ -164,6 +173,7 @@ flowchart TB - `{ materialName: "螺丝", managerName: "admin" }` **预期输出**: + ```json { "materialName": "螺丝 M6", @@ -172,16 +182,19 @@ flowchart TB "isMarkedForDeletion": false } ``` + **说明**: Admin 不执行优先级3,保持原有匹配行为 ### 场景5: 优先级1匹配不受影响 **输入**: + - 当前用户: `user1` - 物料代码: `MAT001` - MaterialsToBeDeleted: `{ materialCode: "MAT001", managerName: "user2" }` **预期输出**: + ```json { "materialCode": "MAT001", @@ -190,6 +203,7 @@ flowchart TB "matchedTypeKeyword": undefined } ``` + **说明**: 优先级1的精确匹配不受覆盖影响 --- @@ -198,22 +212,22 @@ flowchart TB ### MaterialsTypeToBeDeleted 表数据 -| MaterialName | ManagerName | 说明 | -|--------------|-------------|------| +| MaterialName | ManagerName | 说明 | +| ------------ | ----------- | ------------------------------ | | 螺丝 | user1 | user1 负责所有包含"螺丝"的物料 | | 螺母 | user2 | user2 负责所有包含"螺母"的物料 | -| 垫圈 | user1 | user1 也负责"垫圈"类物料 | -| 电缆 | admin | admin 负责电缆类物料 | +| 垫圈 | user1 | user1 也负责"垫圈"类物料 | +| 电缆 | admin | admin 负责电缆类物料 | ### 匹配结果示例 -| 物料名称 | 当前用户 | 原匹配 (优先级2) | 覆盖后 (优先级3) | -|------------|---------|----------------|----------------| -| 螺丝 M6 | user1 | user2 | **user1** ✅ | -| 螺母 M8 | user1 | user2 | user2 (无匹配) | -| 垫圈 φ10 | user1 | user2 | **user1** ✅ | -| 电缆 5m | user1 | admin | user1 (无匹配) | -| 螺丝 M6 | admin | user2 | user2 (Admin跳过) | +| 物料名称 | 当前用户 | 原匹配 (优先级2) | 覆盖后 (优先级3) | +| -------- | -------- | ---------------- | ----------------- | +| 螺丝 M6 | user1 | user2 | **user1** ✅ | +| 螺母 M8 | user1 | user2 | user2 (无匹配) | +| 垫圈 φ10 | user1 | user2 | **user1** ✅ | +| 电缆 5m | user1 | admin | user1 (无匹配) | +| 螺丝 M6 | admin | user2 | user2 (Admin跳过) | --- @@ -233,6 +247,7 @@ const filteredResults = React.useMemo(() => { ``` **协同效果**: + 1. 后端匹配算法确保 User 用户的物料优先分配给自己 2. 前端过滤器只显示属于当前用户或未分配的物料 3. Admin 用户可以看到所有物料并切换查看不同负责人 diff --git a/logs/.869a8c37397718a299488a3d6c7b9753a8bc7cf9-audit.json b/logs/.869a8c37397718a299488a3d6c7b9753a8bc7cf9-audit.json deleted file mode 100644 index f11d31e..0000000 --- a/logs/.869a8c37397718a299488a3d6c7b9753a8bc7cf9-audit.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "keep": { - "days": true, - "amount": 14 - }, - "auditLog": "D:\\FileLib\\Projects\\CodeMigration\\ERPAuto\\logs\\.869a8c37397718a299488a3d6c7b9753a8bc7cf9-audit.json", - "files": [ - { - "date": 1772462852547, - "name": "D:\\FileLib\\Projects\\CodeMigration\\ERPAuto\\logs\\app-2026-03-02.log", - "hash": "baa4ab4c2dfd6ec62a003e44496d2f428a4ad4037ce2529a8da14da1b91ef7a2" - } - ], - "hashType": "sha256" -} diff --git a/scripts/fix-auto-increment.js b/scripts/fix-auto-increment.js new file mode 100644 index 0000000..b6b8b28 --- /dev/null +++ b/scripts/fix-auto-increment.js @@ -0,0 +1,117 @@ +/** + * Fix MaterialsTypeToBeDeleted Table - Add AUTO_INCREMENT to ID + * + * This script modifies the ID column to be AUTO_INCREMENT while preserving data + */ + +const mysql = require('mysql2/promise'); + +async function main() { + const config = { + host: '192.168.31.83', + port: 3306, + user: 'remote_user', + password: '3.1415926Beeke', + database: 'BLD_DB' + }; + + let connection; + + try { + console.log('Connecting to MySQL...'); + connection = await mysql.createConnection(config); + console.log('Connected successfully!\n'); + + // Step 1: Check current table structure + console.log('=== Step 1: Current table structure ==='); + const [columns] = await connection.execute(` + SELECT + COLUMN_NAME, + COLUMN_TYPE, + IS_NULLABLE, + COLUMN_KEY, + COLUMN_DEFAULT, + EXTRA + FROM + INFORMATION_SCHEMA.COLUMNS + WHERE + TABLE_NAME = 'dbo_MaterialsTypeToBeDeleted' + AND TABLE_SCHEMA = DATABASE() + ORDER BY + ORDINAL_POSITION + `); + console.table(columns); + + // Step 2: Count records before modification + console.log('\n=== Step 2: Record count before modification ==='); + const [countBefore] = await connection.execute( + 'SELECT COUNT(*) AS total FROM dbo_MaterialsTypeToBeDeleted' + ); + console.log(`Total records: ${countBefore[0].total}`); + + // Step 3: Show sample data + console.log('\n=== Step 3: Sample data ==='); + const [sample] = await connection.execute( + 'SELECT * FROM dbo_MaterialsTypeToBeDeleted LIMIT 5' + ); + console.table(sample); + + // Step 4: Check if ID is already AUTO_INCREMENT + const idColumn = columns.find((col) => col.COLUMN_NAME === 'ID'); + if (idColumn && idColumn.EXTRA.includes('auto_increment')) { + console.log('\n=== ID is already AUTO_INCREMENT! No modification needed. ==='); + return; + } + + // Step 5: Modify the ID column + console.log('\n=== Step 4: Modifying ID column to AUTO_INCREMENT ==='); + await connection.execute(` + ALTER TABLE dbo_MaterialsTypeToBeDeleted + MODIFY COLUMN ID INT NOT NULL AUTO_INCREMENT + `); + console.log('Modification completed successfully!\n'); + + // Step 6: Verify the change + console.log('=== Step 5: Verify modification ==='); + const [columnsAfter] = await connection.execute(` + SELECT + COLUMN_NAME, + COLUMN_TYPE, + IS_NULLABLE, + COLUMN_KEY, + EXTRA + FROM + INFORMATION_SCHEMA.COLUMNS + WHERE + TABLE_NAME = 'dbo_MaterialsTypeToBeDeleted' + AND TABLE_SCHEMA = DATABASE() + AND COLUMN_NAME = 'ID' + `); + console.table(columnsAfter); + + // Step 7: Verify data is still intact + console.log('\n=== Step 6: Verify data integrity ==='); + const [countAfter] = await connection.execute( + 'SELECT COUNT(*) AS total FROM dbo_MaterialsTypeToBeDeleted' + ); + console.log(`Total records after modification: ${countAfter[0].total}`); + + if (countBefore[0].total === countAfter[0].total) { + console.log('\n✅ SUCCESS: All data preserved, AUTO_INCREMENT added to ID column!'); + } else { + console.log('\n⚠️ WARNING: Record count changed! Please check data.'); + } + } catch (error) { + console.error('\n❌ Error:', error.message); + if (error.code) { + console.error('Error code:', error.code); + } + } finally { + if (connection) { + await connection.end(); + console.log('\nConnection closed.'); + } + } +} + +main(); \ No newline at end of file diff --git a/scripts/fix-materials-type-auto-increment.sql b/scripts/fix-materials-type-auto-increment.sql new file mode 100644 index 0000000..b7820ea --- /dev/null +++ b/scripts/fix-materials-type-auto-increment.sql @@ -0,0 +1,82 @@ +-- ============================================================================ +-- Script: Fix MaterialsTypeToBeDeleted Table - Add AUTO_INCREMENT to ID +-- Description: Modify the ID column to be AUTO_INCREMENT while preserving data +-- Database: MySQL +-- ============================================================================ + +-- Step 1: Check current table structure +SELECT + COLUMN_NAME, + COLUMN_TYPE, + IS_NULLABLE, + COLUMN_KEY, + COLUMN_DEFAULT, + EXTRA +FROM + INFORMATION_SCHEMA.COLUMNS +WHERE + TABLE_NAME = 'dbo_MaterialsTypeToBeDeleted' + AND TABLE_SCHEMA = DATABASE() +ORDER BY + ORDINAL_POSITION; + +-- Step 2: View current data before modification +SELECT COUNT(*) AS total_records FROM dbo_MaterialsTypeToBeDeleted; +SELECT * FROM dbo_MaterialsTypeToBeDeleted LIMIT 10; + +-- Step 3: Check if ID is already AUTO_INCREMENT +SELECT + COLUMN_NAME, + EXTRA +FROM + INFORMATION_SCHEMA.COLUMNS +WHERE + TABLE_NAME = 'dbo_MaterialsTypeToBeDeleted' + AND TABLE_SCHEMA = DATABASE() + AND COLUMN_NAME = 'ID'; + +-- ============================================================================ +-- Step 4: Modify the ID column to AUTO_INCREMENT +-- Note: This assumes ID is already the PRIMARY KEY +-- If not, you may need to add PRIMARY KEY constraint first +-- ============================================================================ + +-- Option A: If ID is already PRIMARY KEY (most likely case) +ALTER TABLE dbo_MaterialsTypeToBeDeleted +MODIFY COLUMN ID INT NOT NULL AUTO_INCREMENT; + +-- Option B: If ID is NOT PRIMARY KEY (uncomment if needed) +-- First check if there's an existing primary key +-- SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS +-- WHERE TABLE_NAME = 'dbo_MaterialsTypeToBeDeleted' +-- AND TABLE_SCHEMA = DATABASE() AND COLUMN_KEY = 'PRI'; +-- +-- If no primary key exists: +-- ALTER TABLE dbo_MaterialsTypeToBeDeleted +-- MODIFY COLUMN ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY; + +-- Step 5: Verify the change +SELECT + COLUMN_NAME, + COLUMN_TYPE, + IS_NULLABLE, + COLUMN_KEY, + EXTRA +FROM + INFORMATION_SCHEMA.COLUMNS +WHERE + TABLE_NAME = 'dbo_MaterialsTypeToBeDeleted' + AND TABLE_SCHEMA = DATABASE() + AND COLUMN_NAME = 'ID'; + +-- Step 6: Verify data is still intact +SELECT COUNT(*) AS total_records_after FROM dbo_MaterialsTypeToBeDeleted; + +-- ============================================================================ +-- Expected Results: +-- After running this script, the ID column should show: +-- EXTRA: 'auto_increment' +-- +-- This will allow INSERT statements to omit the ID field, and MySQL will +-- automatically generate the next sequential ID value. +-- ============================================================================ \ No newline at end of file diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index d79b4a7..58ba777 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -11,6 +11,7 @@ import { registerResolverHandlers } from './resolver-handler' import { registerAuthHandlers } from './auth-handler' import { registerValidationHandlers } from './validation-handler' import { registerSettingsHandlers } from './settings-handler' +import { registerMaterialTypeHandlers } from './material-type-handler' import { createLogger } from '../services/logger' import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors' @@ -73,5 +74,6 @@ export function registerIpcHandlers(): void { registerAuthHandlers() registerValidationHandlers() registerSettingsHandlers() + registerMaterialTypeHandlers() log.info('All IPC handlers registered') } diff --git a/src/main/ipc/material-type-handler.ts b/src/main/ipc/material-type-handler.ts new file mode 100644 index 0000000..933e78c --- /dev/null +++ b/src/main/ipc/material-type-handler.ts @@ -0,0 +1,154 @@ +/** + * IPC handlers for material type management operations + * + * Provides endpoints for: + * - Getting all material type records + * - Getting records by manager + * - Getting list of managers + * - Upserting (insert/update) records + * - Deleting records + * - Batch operations + */ + +import { ipcMain } from 'electron' +import { + MaterialsTypeToBeDeletedDAO, + type MaterialTypeRecord, + type MaterialTypeBatchRequest +} from '../services/database/materials-type-to-be-deleted-dao' +import { createLogger } from '../services/logger' + +const log = createLogger('MaterialTypeHandler') + +/** + * Register IPC handlers for material type operations + */ +export function registerMaterialTypeHandlers(): void { + const dao = new MaterialsTypeToBeDeletedDAO() + + /** + * Get all material type records + */ + ipcMain.handle( + 'materialType:getAll', + async (): Promise<{ success: boolean; data?: MaterialTypeRecord[]; error?: string }> => { + try { + const records = await dao.getAllMaterials() + return { success: true, data: records } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + log.error('Get all material types error', { error: message }) + return { success: false, error: message } + } + } + ) + + /** + * Get material types by manager + */ + ipcMain.handle( + 'materialType:getByManager', + async ( + _event, + managerName: string + ): Promise<{ success: boolean; data?: MaterialTypeRecord[]; error?: string }> => { + try { + const records = await dao.getMaterialsByManager(managerName) + return { success: true, data: records } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + log.error('Get material types by manager error', { error: message }) + return { success: false, error: message } + } + } + ) + + /** + * Get list of managers + */ + ipcMain.handle( + 'materialType:getManagers', + async (): Promise<{ success: boolean; data?: string[]; error?: string }> => { + try { + const managers = await dao.getManagers() + return { success: true, data: managers } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + log.error('Get managers error', { error: message }) + return { success: false, error: message } + } + } + ) + + /** + * Upsert (insert or update) a material type record + */ + ipcMain.handle( + 'materialType:upsert', + async ( + _event, + { materialName, managerName }: { materialName: string; managerName: string } + ): Promise<{ success: boolean; error?: string }> => { + try { + const result = await dao.upsertMaterial(materialName, managerName) + if (!result) { + return { success: false, error: 'Failed to upsert material type' } + } + return { success: true } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + log.error('Upsert material type error', { error: message }) + return { success: false, error: message } + } + } + ) + + /** + * Delete a material type record + */ + ipcMain.handle( + 'materialType:delete', + async ( + _event, + { materialName, managerName }: { materialName: string; managerName: string } + ): Promise<{ success: boolean; error?: string }> => { + try { + const result = await dao.deleteMaterial(materialName, managerName) + if (!result) { + return { success: false, error: 'Failed to delete material type' } + } + return { success: true } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + log.error('Delete material type error', { error: message }) + return { success: false, error: message } + } + } + ) + + /** + * Batch operation for material types (insert, update, delete) + */ + ipcMain.handle( + 'materialType:upsertBatch', + async ( + _event, + request: MaterialTypeBatchRequest + ): Promise<{ + success: boolean + stats?: { total: number; success: number; failed: number } + error?: string + }> => { + try { + const stats = await dao.upsertBatch(request) + return { success: true, stats } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + log.error('Batch upsert material types error', { error: message }) + return { success: false, error: message } + } + } + ) + + log.info('Material type handlers registered') +} diff --git a/src/main/services/config/config-manager.ts b/src/main/services/config/config-manager.ts index c313456..d27ba9d 100644 --- a/src/main/services/config/config-manager.ts +++ b/src/main/services/config/config-manager.ts @@ -291,12 +291,8 @@ export class ConfigManager { lines.push('# ===========================') lines.push('# 数据库配置 - MySQL (切换时使用)') lines.push('# ===========================') - lines.push( - `DB_TYPE=${this.configCache.get('DB_TYPE') || DEFAULT_SETTINGS.database.dbType}` - ) - lines.push( - `DB_NAME=${this.configCache.get('DB_NAME') || DEFAULT_SETTINGS.database.database}` - ) + lines.push(`DB_TYPE=${this.configCache.get('DB_TYPE') || DEFAULT_SETTINGS.database.dbType}`) + lines.push(`DB_NAME=${this.configCache.get('DB_NAME') || DEFAULT_SETTINGS.database.database}`) lines.push( `DB_USERNAME=${this.configCache.get('DB_USERNAME') || DEFAULT_SETTINGS.database.username}` ) diff --git a/src/main/services/database/materials-type-to-be-deleted-dao.ts b/src/main/services/database/materials-type-to-be-deleted-dao.ts new file mode 100644 index 0000000..0e68b1c --- /dev/null +++ b/src/main/services/database/materials-type-to-be-deleted-dao.ts @@ -0,0 +1,376 @@ +/** + * Data Access Object for MaterialsTypeToBeDeleted table + * + * Manages material type keywords for identifying materials to be deleted. + * Used for matching material names against type keywords to assign managers. + */ + +import { create, type IDatabaseService } from './index' +import { createLogger } from '../logger' + +const log = createLogger('MaterialsTypeToBeDeletedDAO') + +/** + * Material type record interface + */ +export interface MaterialTypeRecord { + id?: number + materialName: string + managerName: string +} + +/** + * Batch update request + */ +export interface MaterialTypeBatchRequest { + toInsert: MaterialTypeRecord[] + toUpdate: { old: MaterialTypeRecord; new: MaterialTypeRecord }[] + toDelete: MaterialTypeRecord[] +} + +/** + * Configuration for MaterialsTypeToBeDeleted table + */ +export const MATERIALS_TYPE_TO_BE_DELETED_CONFIG = { + TABLE_NAME_SQLSERVER: '[dbo].[MaterialsTypeToBeDeleted]', + TABLE_NAME_MYSQL: 'dbo_MaterialsTypeToBeDeleted', + COLUMNS: { + ID: 'ID', + MATERIAL_NAME: 'MaterialName', + MANAGER_NAME: 'ManagerName' + } +} as const + +/** + * MaterialsTypeToBeDeleted DAO Class + */ +export class MaterialsTypeToBeDeletedDAO { + private dbService: IDatabaseService | null = null + + /** + * Get the appropriate table name based on database type + */ + private getTableName(): string { + const isSqlServer = this.dbService?.type === 'sqlserver' + return isSqlServer + ? MATERIALS_TYPE_TO_BE_DELETED_CONFIG.TABLE_NAME_SQLSERVER + : MATERIALS_TYPE_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL + } + + /** + * Get database service instance using DatabaseFactory + */ + private async getDatabaseService(): Promise { + if (this.dbService && this.dbService.isConnected()) { + return this.dbService + } + + this.dbService = await create() + return this.dbService + } + + // ==================== READ ==================== + + /** + * Get all material type records + * @returns List of all material type records + */ + async getAllMaterials(): Promise { + try { + const dbService = await this.getDatabaseService() + const tableName = this.getTableName() + + const sqlString = ` + SELECT ID, MaterialName, ManagerName + FROM ${tableName} + WHERE MaterialName IS NOT NULL + ORDER BY ManagerName, MaterialName + ` + + const result = await dbService.query(sqlString) + return result.rows.map((row) => ({ + id: row.ID as number, + materialName: row.MaterialName as string, + managerName: row.ManagerName as string + })) + } catch (error) { + log.error('Get all materials error', { + error: error instanceof Error ? error.message : String(error) + }) + return [] + } + } + + /** + * Get all materials for a specific manager + * @param managerName - Manager name + * @returns List of materials for the manager + */ + async getMaterialsByManager(managerName: string): Promise { + try { + const dbService = await this.getDatabaseService() + const tableName = this.getTableName() + const isSqlServer = dbService.type === 'sqlserver' + + const placeholder = isSqlServer ? '@p0' : '?' + const sqlString = ` + SELECT ID, MaterialName, ManagerName + FROM ${tableName} + WHERE ManagerName = ${placeholder} AND MaterialName IS NOT NULL + ORDER BY MaterialName + ` + + const result = await dbService.query(sqlString, [managerName]) + return result.rows.map((row) => ({ + id: row.ID as number, + materialName: row.MaterialName as string, + managerName: row.ManagerName as string + })) + } catch (error) { + log.error('Get materials by manager error', { + error: error instanceof Error ? error.message : String(error) + }) + return [] + } + } + + /** + * Get list of unique manager names + * @returns List of unique manager names + */ + async getManagers(): Promise { + try { + const dbService = await this.getDatabaseService() + const tableName = this.getTableName() + + const sqlString = ` + SELECT DISTINCT ManagerName + FROM ${tableName} + WHERE ManagerName IS NOT NULL + ORDER BY ManagerName + ` + + const result = await dbService.query(sqlString) + return result.rows.map((row) => row.ManagerName as string).filter(Boolean) + } catch (error) { + log.error('Get managers error', { + error: error instanceof Error ? error.message : String(error) + }) + return [] + } + } + + // ==================== UPSERT ==================== + + /** + * Insert or update a material type record + * @param materialName - Material name (type keyword) + * @param managerName - Manager name + * @returns True if successful + */ + async upsertMaterial(materialName: string, managerName: string): Promise { + if (!materialName || !materialName.trim()) { + log.error('MaterialName cannot be empty') + return false + } + + try { + const dbService = await this.getDatabaseService() + const tableName = this.getTableName() + const name = materialName.trim() + const manager = managerName?.trim() || null + const isSqlServer = dbService.type === 'sqlserver' + + if (isSqlServer) { + // SQL Server MERGE statement + const sqlString = ` + MERGE ${tableName} AS target + USING (VALUES (@p0, @p1)) AS source (MaterialName, ManagerName) + ON target.MaterialName = source.MaterialName + WHEN MATCHED THEN UPDATE SET ManagerName = source.ManagerName + WHEN NOT MATCHED THEN INSERT (MaterialName, ManagerName) VALUES (source.MaterialName, source.ManagerName); + ` + + await dbService.query(sqlString, [name, manager]) + } else { + // MySQL ON DUPLICATE KEY UPDATE + const sqlString = ` + INSERT INTO ${tableName} (MaterialName, ManagerName) + VALUES (?, ?) + ON DUPLICATE KEY UPDATE ManagerName = VALUES(ManagerName) + ` + + await dbService.query(sqlString, [name, manager]) + } + + return true + } catch (error) { + log.error('Upsert material error', { + error: error instanceof Error ? error.message : String(error) + }) + return false + } + } + + // ==================== DELETE ==================== + + /** + * Delete a specific material type record + * @param materialName - Material name + * @param managerName - Manager name (optional, for verification) + * @returns True if successful + */ + async deleteMaterial(materialName: string, managerName?: string): Promise { + try { + const dbService = await this.getDatabaseService() + const tableName = this.getTableName() + const name = materialName.trim() + const isSqlServer = dbService.type === 'sqlserver' + + let sqlString: string + let params: (string | null)[] + + if (managerName) { + const placeholder1 = isSqlServer ? '@p0' : '?' + const placeholder2 = isSqlServer ? '@p1' : '?' + sqlString = ` + DELETE FROM ${tableName} + WHERE MaterialName = ${placeholder1} AND ManagerName = ${placeholder2} + ` + params = [name, managerName.trim()] + } else { + const placeholder = isSqlServer ? '@p0' : '?' + sqlString = ` + DELETE FROM ${tableName} + WHERE MaterialName = ${placeholder} + ` + params = [name] + } + + const result = await dbService.query(sqlString, params) + return result.rowCount > 0 + } catch (error) { + log.error('Delete material error', { + error: error instanceof Error ? error.message : String(error) + }) + return false + } + } + + // ==================== UPDATE ==================== + + /** + * Update a material type record (change name and/or manager) + * @param oldName - Current material name + * @param oldManager - Current manager name + * @param newName - New material name + * @param newManager - New manager name + * @returns True if successful + */ + async updateMaterial( + oldName: string, + oldManager: string, + newName: string, + newManager: string + ): Promise { + try { + const dbService = await this.getDatabaseService() + const tableName = this.getTableName() + const isSqlServer = dbService.type === 'sqlserver' + + if (isSqlServer) { + const sqlString = ` + UPDATE ${tableName} + SET MaterialName = @p0, ManagerName = @p1 + WHERE MaterialName = @p2 AND ManagerName = @p3 + ` + const result = await dbService.query(sqlString, [ + newName.trim(), + newManager.trim(), + oldName.trim(), + oldManager.trim() + ]) + return result.rowCount > 0 + } else { + const sqlString = ` + UPDATE ${tableName} + SET MaterialName = ?, ManagerName = ? + WHERE MaterialName = ? AND ManagerName = ? + ` + const result = await dbService.query(sqlString, [ + newName.trim(), + newManager.trim(), + oldName.trim(), + oldManager.trim() + ]) + return result.rowCount > 0 + } + } catch (error) { + log.error('Update material error', { + error: error instanceof Error ? error.message : String(error) + }) + return false + } + } + + // ==================== BATCH OPERATIONS ==================== + + /** + * Process batch changes (insert, update, delete) + * @param request - Batch request with toInsert, toUpdate, toDelete arrays + * @returns Statistics object + */ + async upsertBatch( + request: MaterialTypeBatchRequest + ): Promise<{ total: number; success: number; failed: number }> { + const stats = { total: 0, success: 0, failed: 0 } + + try { + // Process inserts + for (const record of request.toInsert) { + stats.total++ + const success = await this.upsertMaterial(record.materialName, record.managerName) + if (success) stats.success++ + else stats.failed++ + } + + // Process updates + for (const update of request.toUpdate) { + stats.total++ + const success = await this.updateMaterial( + update.old.materialName, + update.old.managerName, + update.new.materialName, + update.new.managerName + ) + if (success) stats.success++ + else stats.failed++ + } + + // Process deletes + for (const record of request.toDelete) { + stats.total++ + const success = await this.deleteMaterial(record.materialName, record.managerName) + if (success) stats.success++ + else stats.failed++ + } + + return stats + } catch (error) { + log.error('Batch upsert error', { + error: error instanceof Error ? error.message : String(error) + }) + return stats + } + } + + /** + * Disconnect from database + */ + async disconnect(): Promise { + if (this.dbService) { + await this.dbService.disconnect() + this.dbService = null + } + } +} diff --git a/src/main/types/validation.types.ts b/src/main/types/validation.types.ts index c1f8d6e..17d07d0 100644 --- a/src/main/types/validation.types.ts +++ b/src/main/types/validation.types.ts @@ -103,3 +103,21 @@ export interface MaterialRecordSummary { managerName: string isMarked: boolean } + +/** + * Material type record for MaterialsTypeToBeDeleted table + */ +export interface MaterialTypeRecord { + id?: number + materialName: string + managerName: string +} + +/** + * Material type batch request for batch operations + */ +export interface MaterialTypeBatchRequest { + toInsert: MaterialTypeRecord[] + toUpdate: { old: MaterialTypeRecord; new: MaterialTypeRecord }[] + toDelete: MaterialTypeRecord[] +} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index d6cfcb5..8e836db 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -8,7 +8,12 @@ import type { UserSelectionResponse, CurrentUserResponse } from '../main/ipc/auth-handler' -import type { ValidationRequest, ValidationResponse } from '../main/types/validation.types' +import type { + ValidationRequest, + ValidationResponse, + MaterialTypeRecord, + MaterialTypeBatchRequest +} from '../main/types/validation.types' import type { SettingsData, UserType, @@ -178,6 +183,49 @@ export interface SettingsAPI { testDbConnection: () => Promise } +/** + * Material Type API + */ +export interface MaterialTypeAPI { + /** + * Get all material type records + */ + getAll: () => Promise<{ success: boolean; data?: MaterialTypeRecord[]; error?: string }> + /** + * Get material types by manager + * @param managerName - Manager name + */ + getByManager: ( + managerName: string + ) => Promise<{ success: boolean; data?: MaterialTypeRecord[]; error?: string }> + /** + * Get list of managers + */ + getManagers: () => Promise<{ success: boolean; data?: string[]; error?: string }> + /** + * Upsert (insert or update) a material type record + */ + upsert: ( + materialName: string, + managerName: string + ) => Promise<{ success: boolean; error?: string }> + /** + * Delete a material type record + */ + delete: ( + materialName: string, + managerName: string + ) => Promise<{ success: boolean; error?: string }> + /** + * Batch operation for material types + */ + upsertBatch: (request: MaterialTypeBatchRequest) => Promise<{ + success: boolean + stats?: { total: number; success: number; failed: number } + error?: string + }> +} + declare global { interface Window { electron: { @@ -205,6 +253,7 @@ declare global { validation: ValidationAPI materials: MaterialsAPI settings: SettingsAPI + materialType: MaterialTypeAPI } api: unknown } diff --git a/src/preload/index.ts b/src/preload/index.ts index e81a2c5..8bc8273 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -6,7 +6,11 @@ import type { CleanerInput } from '../main/types/cleaner.types' import type { ResolverInput } from '../main/ipc/resolver-handler' import type { LoginRequest } from '../main/ipc/auth-handler' import type { UserInfo } from '../main/types/user.types' -import type { ValidationRequest } from '../main/types/validation.types' +import type { + ValidationRequest, + MaterialTypeRecord, + MaterialTypeBatchRequest +} from '../main/types/validation.types' import type { SettingsData } from '../main/types/settings.types' // Custom APIs for renderer @@ -94,6 +98,20 @@ const api = { resetDefaults: () => ipcRenderer.invoke('settings:resetDefaults'), testErpConnection: () => ipcRenderer.invoke('settings:testErpConnection'), testDbConnection: () => ipcRenderer.invoke('settings:testDbConnection') + }, + + // Material Type service + materialType: { + getAll: () => ipcRenderer.invoke('materialType:getAll'), + getByManager: (managerName: string) => + ipcRenderer.invoke('materialType:getByManager', managerName), + getManagers: () => ipcRenderer.invoke('materialType:getManagers'), + upsert: (materialName: string, managerName: string) => + ipcRenderer.invoke('materialType:upsert', { materialName, managerName }), + delete: (materialName: string, managerName: string) => + ipcRenderer.invoke('materialType:delete', { materialName, managerName }), + upsertBatch: (request: MaterialTypeBatchRequest) => + ipcRenderer.invoke('materialType:upsertBatch', request) } } as const diff --git a/src/renderer/src/components/MaterialTypeManagementDialog.tsx b/src/renderer/src/components/MaterialTypeManagementDialog.tsx new file mode 100644 index 0000000..1887d29 --- /dev/null +++ b/src/renderer/src/components/MaterialTypeManagementDialog.tsx @@ -0,0 +1,523 @@ +/** + * Material Type Management Dialog + * + * Provides a dialog for managing material type keywords used to identify + * materials for deletion. Admin users can see all records and filter by manager. + * Regular users can only see and edit their own records. + */ + +import React, { useState, useEffect, useCallback, useRef } from 'react' +import { Plus, Trash2, Save, RotateCcw, Users } from 'lucide-react' +import { Modal } from './ui/Modal' + +interface MaterialTypeRecord { + id?: number + materialName: string + managerName: string +} + +interface RowState { + record: MaterialTypeRecord + state: 'original' | 'new' | 'modified' | 'deleted' + originalRecord?: MaterialTypeRecord +} + +interface MaterialTypeManagementDialogProps { + isOpen: boolean + onClose: () => void + isAdmin: boolean + currentUsername: string +} + +export const MaterialTypeManagementDialog: React.FC = ({ + isOpen, + onClose, + isAdmin, + currentUsername +}) => { + const [rows, setRows] = useState([]) + const [managers, setManagers] = useState([]) + const [selectedManagers, setSelectedManagers] = useState>(new Set()) + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [editingCell, setEditingCell] = useState<{ rowIndex: number; field: string } | null>(null) + const [editValue, setEditValue] = useState('') + const [selectedRowIndex, setSelectedRowIndex] = useState(null) + + const tableRef = useRef(null) + const inputRef = useRef(null) + + // Calculate pending changes count + const pendingCount = rows.filter( + (r) => r.state === 'new' || r.state === 'modified' || r.state === 'deleted' + ).length + + // Load data when dialog opens + useEffect(() => { + if (isOpen) { + loadData() + } + }, [isOpen, isAdmin, currentUsername]) + + // Focus input when editing starts + useEffect(() => { + if (editingCell && inputRef.current) { + inputRef.current.focus() + inputRef.current.select() + } + }, [editingCell]) + + const loadData = async () => { + setLoading(true) + try { + // Load managers list + const managersResult = await window.electron.materialType.getManagers() + if (managersResult.success && managersResult.data) { + setManagers(managersResult.data) + if (isAdmin) { + setSelectedManagers(new Set(managersResult.data)) + } + } + + // Load records + let records: MaterialTypeRecord[] = [] + if (isAdmin) { + const result = await window.electron.materialType.getAll() + if (result.success && result.data) { + records = result.data + } + } else { + const result = await window.electron.materialType.getByManager(currentUsername) + if (result.success && result.data) { + records = result.data + } + } + + setRows( + records.map((record) => ({ + record, + state: 'original' as const, + originalRecord: { ...record } + })) + ) + setSelectedRowIndex(null) + } catch (error) { + console.error('Failed to load material types:', error) + } finally { + setLoading(false) + } + } + + // Filter rows by selected managers (admin only) + const filteredRows = React.useMemo(() => { + if (!isAdmin) return rows + if (selectedManagers.size === 0) return rows + return rows.filter((row) => selectedManagers.has(row.record.managerName) || row.state === 'new') + }, [rows, isAdmin, selectedManagers]) + + // Handle keyboard events + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (editingCell) { + if (event.key === 'Enter') { + saveEdit() + } else if (event.key === 'Escape') { + cancelEdit() + } + return + } + + if (event.key === 'Insert') { + event.preventDefault() + insertNewRow() + } else if (event.key === 'Delete' && selectedRowIndex !== null) { + event.preventDefault() + deleteRow(selectedRowIndex) + } + }, + [editingCell, selectedRowIndex] + ) + + // Insert new row + const insertNewRow = () => { + const newRow: RowState = { + record: { + materialName: '', + managerName: isAdmin ? '' : currentUsername + }, + state: 'new' + } + setRows((prev) => [...prev, newRow]) + // Start editing the material name cell + const newIndex = rows.length + setTimeout(() => { + setEditingCell({ rowIndex: newIndex, field: 'materialName' }) + setEditValue('') + }, 0) + } + + // Delete row + const deleteRow = (index: number) => { + setRows((prev) => { + const newRows = [...prev] + const row = newRows[index] + if (row.state === 'new') { + // Remove new rows directly + newRows.splice(index, 1) + } else { + // Mark existing rows as deleted + newRows[index] = { ...row, state: 'deleted' } + } + return newRows + }) + setSelectedRowIndex(null) + } + + // Start editing a cell + const startEdit = (rowIndex: number, field: string) => { + const row = rows[rowIndex] + if (row.state === 'deleted') return + + setEditingCell({ rowIndex, field }) + setEditValue(row.record[field as keyof MaterialTypeRecord] as string) + } + + // Save edit + const saveEdit = () => { + if (!editingCell) return + + const { rowIndex, field } = editingCell + setRows((prev) => { + const newRows = [...prev] + const row = newRows[rowIndex] + const newValue = editValue.trim() + + // Update the record + newRows[rowIndex] = { + ...row, + record: { + ...row.record, + [field]: newValue + }, + state: row.state === 'new' ? 'new' : 'modified' + } + return newRows + }) + + setEditingCell(null) + setEditValue('') + } + + // Cancel edit + const cancelEdit = () => { + setEditingCell(null) + setEditValue('') + } + + // Save all changes + const handleSave = async () => { + const toInsert: MaterialTypeRecord[] = [] + const toUpdate: { old: MaterialTypeRecord; new: MaterialTypeRecord }[] = [] + const toDelete: MaterialTypeRecord[] = [] + + for (const row of rows) { + if (row.state === 'new' && row.record.materialName.trim()) { + toInsert.push(row.record) + } else if (row.state === 'modified' && row.originalRecord) { + toUpdate.push({ old: row.originalRecord, new: row.record }) + } else if (row.state === 'deleted' && row.originalRecord) { + toDelete.push(row.originalRecord) + } + } + + if (toInsert.length === 0 && toUpdate.length === 0 && toDelete.length === 0) { + alert('没有需要保存的更改') + return + } + + const confirmParts: string[] = [] + if (toInsert.length > 0) confirmParts.push(`新增 ${toInsert.length} 条记录`) + if (toUpdate.length > 0) confirmParts.push(`更新 ${toUpdate.length} 条记录`) + if (toDelete.length > 0) confirmParts.push(`删除 ${toDelete.length} 条记录`) + + if (!window.confirm(`确认以下操作?\n\n${confirmParts.join('\n')}`)) return + + setSaving(true) + try { + const result = await window.electron.materialType.upsertBatch({ + toInsert, + toUpdate, + toDelete + }) + + if (result.success) { + alert( + `保存完成!\n成功:${result.stats?.success || 0} 条\n失败:${result.stats?.failed || 0} 条` + ) + await loadData() + } else { + alert(`保存失败:${result.error || '未知错误'}`) + } + } catch (error) { + alert(`保存失败:${error instanceof Error ? error.message : '未知错误'}`) + } finally { + setSaving(false) + } + } + + // Reset changes + const handleReset = () => { + if (pendingCount === 0) return + if (!window.confirm('确定要放弃所有未保存的更改吗?')) return + loadData() + } + + // Handle close with unsaved changes warning + const handleClose = () => { + if (pendingCount > 0) { + if (!window.confirm('有未保存的更改,确定要关闭吗?')) return + } + onClose() + } + + // Get row background color + const getRowStyle = (row: RowState): React.CSSProperties => { + if (row.state === 'deleted') { + return { backgroundColor: '#fee2e2', textDecoration: 'line-through', opacity: 0.6 } + } + if (row.state === 'new') { + return { backgroundColor: '#dcfce7' } + } + if (row.state === 'modified') { + return { backgroundColor: '#fef9c3' } + } + return {} + } + + return ( + +
+ {/* Manager filter (admin only) */} + {isAdmin && ( +
+
+
+ + 按负责人筛选 +
+
+ + +
+
+
+ {managers.map((manager) => ( + + ))} +
+
+ )} + + {/* Toolbar */} +
+
+ + + +
+
+ {pendingCount > 0 && ( + + {pendingCount} 项待保存 + + )} + +
+
+ + {/* Table */} +
+ {loading ? ( +
加载中...
+ ) : ( + + + + + + + + + {filteredRows.filter((r) => r.state !== 'deleted').length === 0 ? ( + + + + ) : ( + filteredRows + .filter((r) => r.state !== 'deleted') + .map((row, index) => { + const originalIndex = rows.indexOf(row) + const isSelected = selectedRowIndex === originalIndex + const isEditingMaterial = + editingCell?.rowIndex === originalIndex && + editingCell?.field === 'materialName' + const isEditingManager = + editingCell?.rowIndex === originalIndex && + editingCell?.field === 'managerName' + + return ( + setSelectedRowIndex(originalIndex)} + > + + + + ) + }) + )} + +
+ 物料名称关键词 + 负责人
+ 暂无数据,点击"新增"按钮添加物料类型关键词 +
+ {isEditingMaterial ? ( + setEditValue(e.target.value)} + onBlur={saveEdit} + onKeyDown={(e) => { + if (e.key === 'Enter') saveEdit() + if (e.key === 'Escape') cancelEdit() + }} + className="w-full px-2 py-1 border border-blue-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + ) : ( +
startEdit(originalIndex, 'materialName')} + > + {row.record.materialName || ( + 双击编辑 + )} +
+ )} +
+ {isEditingManager ? ( + isAdmin ? ( + + ) : ( + setEditValue(e.target.value)} + onBlur={saveEdit} + onKeyDown={(e) => { + if (e.key === 'Enter') saveEdit() + if (e.key === 'Escape') cancelEdit() + }} + className="w-full px-2 py-1 border border-blue-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + ) + ) : ( +
startEdit(originalIndex, 'managerName')} + > + {row.record.managerName || ( + 双击编辑 + )} +
+ )} +
+ )} +
+ + {/* Footer info */} +
+ + 双击单元格编辑 | Insert 新增 | Delete 删除 + {isAdmin && ' | 绿色=新增 | 黄色=已修改'} + + 共 {filteredRows.filter((r) => r.state !== 'deleted').length} 条记录 +
+
+
+ ) +} + +export default MaterialTypeManagementDialog diff --git a/src/renderer/src/components/ui/Modal.tsx b/src/renderer/src/components/ui/Modal.tsx index 1e9d785..b112601 100644 --- a/src/renderer/src/components/ui/Modal.tsx +++ b/src/renderer/src/components/ui/Modal.tsx @@ -12,7 +12,7 @@ interface ModalProps { onClose: () => void title?: string children: React.ReactNode - size?: 'sm' | 'md' | 'lg' | 'xl' + size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' showCloseButton?: boolean } @@ -20,7 +20,9 @@ const sizeStyles: Record = { sm: 'max-w-sm', md: 'max-w-md', lg: 'max-w-lg', - xl: 'max-w-xl' + xl: 'max-w-xl', + '2xl': 'max-w-2xl', + '3xl': 'max-w-3xl' } export function Modal({ diff --git a/src/renderer/src/pages/CleanerPage.tsx b/src/renderer/src/pages/CleanerPage.tsx index 8fad336..fdc2ae8 100644 --- a/src/renderer/src/pages/CleanerPage.tsx +++ b/src/renderer/src/pages/CleanerPage.tsx @@ -15,6 +15,7 @@ import { Settings2, FileSpreadsheet } from 'lucide-react' +import MaterialTypeManagementDialog from '../components/MaterialTypeManagementDialog' /** * Material validation result interface @@ -60,6 +61,9 @@ const CleanerPage: React.FC = () => { const [sharedProductionIdsCount, setSharedProductionIdsCount] = useState(0) console.log(sharedProductionIdsCount) + // Material type management dialog state + const [isTypeDialogOpen, setIsTypeDialogOpen] = useState(false) + // Check admin status and get shared Production IDs on mount React.useEffect(() => { const initializePage = async () => { @@ -470,7 +474,10 @@ const CleanerPage: React.FC = () => {
-
+ + {/* Material Type Management Dialog */} + setIsTypeDialogOpen(false)} + isAdmin={isAdmin} + currentUsername={currentUsername} + /> ) } diff --git a/src/renderer/src/pages/ExtractorPage.tsx b/src/renderer/src/pages/ExtractorPage.tsx index c7b3310..e9b008c 100644 --- a/src/renderer/src/pages/ExtractorPage.tsx +++ b/src/renderer/src/pages/ExtractorPage.tsx @@ -201,6 +201,14 @@ const ExtractorPage: React.FC = () => { + {result.mergedFile && ( +
+ 合并文件路径 + + {result.mergedFile} + +
+ )} )} diff --git a/tests/main/services/config/config-manager.test.ts b/tests/main/services/config/config-manager.test.ts index 1a5f5f6..0f07f8b 100644 --- a/tests/main/services/config/config-manager.test.ts +++ b/tests/main/services/config/config-manager.test.ts @@ -101,11 +101,38 @@ describe('ConfigManager.savePartialSettings', () => { // Setup initial state with multiple categories await manager.saveAllSettings({ - erp: { url: 'http://old.com', username: 'user1', password: 'pass1', headless: true, ignoreHttpsErrors: true, autoCloseBrowser: true }, - database: { dbType: 'mysql', server: '', mysqlHost: '192.168.1.1', mysqlPort: 3306, database: 'testdb', username: 'dbuser', password: '' }, + erp: { + url: 'http://old.com', + username: 'user1', + password: 'pass1', + headless: true, + ignoreHttpsErrors: true, + autoCloseBrowser: true + }, + database: { + dbType: 'mysql', + server: '', + mysqlHost: '192.168.1.1', + mysqlPort: 3306, + database: 'testdb', + username: 'dbuser', + password: '' + }, paths: { dataDir: '/old/path', defaultOutput: 'out.xlsx', validationOutput: 'val.xlsx' }, - extraction: { batchSize: 50, verbose: true, autoConvert: true, mergeBatches: true, enableDbPersistence: true }, - validation: { dataSource: 'database_full', batchSize: 1000, matchMode: 'exact', enableCrud: false, defaultManager: '' }, + extraction: { + batchSize: 50, + verbose: true, + autoConvert: true, + mergeBatches: true, + enableDbPersistence: true + }, + validation: { + dataSource: 'database_full', + batchSize: 1000, + matchMode: 'exact', + enableCrud: false, + defaultManager: '' + }, ui: { fontFamily: 'Tahoma', fontSize: 14, productionIdInputWidth: 25 }, execution: { dryRun: true } }) @@ -160,11 +187,38 @@ describe('ConfigManager.savePartialSettings', () => { await manager.save() await manager.saveAllSettings({ - erp: { url: 'http://test.com', username: 'u', password: 'p', headless: false, ignoreHttpsErrors: false, autoCloseBrowser: false }, - database: { dbType: 'mysql', server: '', mysqlHost: 'localhost', mysqlPort: 3306, database: 'db', username: 'user', password: '' }, + erp: { + url: 'http://test.com', + username: 'u', + password: 'p', + headless: false, + ignoreHttpsErrors: false, + autoCloseBrowser: false + }, + database: { + dbType: 'mysql', + server: '', + mysqlHost: 'localhost', + mysqlPort: 3306, + database: 'db', + username: 'user', + password: '' + }, paths: { dataDir: '/data', defaultOutput: 'out.xlsx', validationOutput: 'val.xlsx' }, - extraction: { batchSize: 100, verbose: true, autoConvert: true, mergeBatches: true, enableDbPersistence: true }, - validation: { dataSource: 'database_full', batchSize: 2000, matchMode: 'substring', enableCrud: false, defaultManager: '' }, + extraction: { + batchSize: 100, + verbose: true, + autoConvert: true, + mergeBatches: true, + enableDbPersistence: true + }, + validation: { + dataSource: 'database_full', + batchSize: 2000, + matchMode: 'substring', + enableCrud: false, + defaultManager: '' + }, ui: { fontFamily: 'Arial', fontSize: 12, productionIdInputWidth: 20 }, execution: { dryRun: false } })