169 Commits

Author SHA1 Message Date
Misaka_Company
9ee1ea566c 1.3.1 2026-03-18 13:18:15 +08:00
Misaka_Company
29f29f6a9e feat(cleaner): expand protected row number range to 2000-7999
Change the protected row number range from 7000-7999 to 2000-7999 to prevent deletion of materials in this broader range.

- Updated isMaterialDeletable() method logic
- Updated getSkipReason() error messages
- Updated test cases to reflect new range boundaries
- Updated documentation templates and error collection guide

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 13:14:09 +08:00
Misaka_Company
baa7622954 chore: bump version to 1.3.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 11:03:20 +08:00
Misaka_Company
851c2ce634 feat: add version and git hash to application title
Add a custom Vite plugin to transform index.html and include version number and git hash in the application title for better traceability.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 10:44:19 +08:00
Misaka_Company
64349125ba chore: update package-lock.json peer dependencies metadata
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 09:41:40 +08:00
google-labs-jules[bot]
a020ee537d feat: Add Report Viewer Dialog to Cleaner Page
Added a new "View Reports" button to the CleanerPage which opens a new ReportViewerDialog. This dialog lists all available execution reports stored in S3 for the current user, or for all users if the current user is an admin.
The reports are downloaded as Markdown and rendered using react-markdown.
Added three new IPC channels to fetch and download reports using the existing RustfsService and S3Client.

Co-authored-by: luwamgere15-crypto <255338376+luwamgere15-crypto@users.noreply.github.com>
2026-03-17 23:03:13 +00:00
test
351e9a92bc chore: bump version to 1.2.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 22:04:25 +08:00
test
f5514dd721 Merge branch 'dev' 2026-03-17 22:03:01 +08:00
test
b94640ca81 fix(modal): prevent accidental closure during execution
- Add disableBackdropClick prop to Modal component
- Prevent closing ExecutionReportDialog by clicking backdrop during execution
- Complements existing disableEscapeKey behavior for ongoing operations

This prevents users from accidentally interrupting long-running operations by clicking outside the dialog.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 22:02:00 +08:00
test
b8163d7d4f refactor(cleaner): improve order number tracking and error reporting
- Add QueryResultRow interface to represent query results with order numbers
- Add collectQueryResultRows() method to extract order numbers upfront before processing
- Add extractOrderNumberFromQueryRow() helper to parse order numbers from query result cells
- Process rows with order number context instead of just row indexes
- Use actual order numbers in error details instead of BATCH_ROW_X placeholders
- Pass expected order number to detail processing for better validation
- Fix retry success handling to properly update statistics when retries succeed

This change provides better error context by associating each processed row with its actual order number from the query results, improving traceability and debugging.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 21:53:13 +08:00
Misaka_Company
569c8e8ecc chore: add prebuild script to clean dist and out before build
Ensures clean build by removing dist and out directories before
each build:win, build:mac, and build:linux command.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 17:07:42 +08:00
Misaka_Company
21bb8ef79c chore: bump version to 1.1.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 16:50:15 +08:00
Misaka_Company
fbcc656b99 fix(order-resolver): support case-insensitive production ID lookup
Add case-insensitive comparison for production ID database queries:
- SQL Server: use COLLATE SQL_Latin1_General_CP1_CI_AS
- MySQL: use UPPER() function for both field and input
- Map lookup: store keys in lowercase for consistent matching

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 16:37:59 +08:00
Misaka_Company
3854c0f048 Merge branch 'dev-rustfs' into dev 2026-03-17 15:49:31 +08:00
Misaka_Company
7bc6daf1b7 feat(rustfs): add test script, dependencies, and configuration template
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-17 15:49:20 +08:00
Misaka_Company
db44618ee5 feat(rustfs): integrate report upload into cleaner execution flow
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-17 15:48:50 +08:00
Misaka_Company
2d17a6b792 feat(rustfs): add RustFS configuration schema and manager support
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-17 15:48:16 +08:00
Misaka_Company
e8aa7d21a8 feat(rustfs): add RustFS object storage service for report persistence
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-17 15:30:00 +08:00
Misaka_Company
defeb01808 fix: show elapsed time in execution result dialog
- Remove setStartTime(null) from finally block to preserve start time for result display
- Add resetStartTime function to useCleaner hook
- Call resetStartTime when execution report dialog closes
- Add useEffect to update timer when execution completes

Now the total elapsed time (总耗时) will be shown in the result dialog after execution completes.
2026-03-17 14:50:57 +08:00
Misaka_Company
b289fb9624 fix: use updateProcessConcurrency to persist slider changes to config.yaml
- CleanerPage now uses updateProcessConcurrency instead of setProcessConcurrency
- This ensures slider changes are persisted to config.yaml via IPC
- Remove unused queryBatchSize and setProcessConcurrency from destructuring
2026-03-17 10:57:24 +08:00
test
103effcfca ♻️ style: format code with Prettier and fix .gitattributes
- Add *.yaml text eol=lf rule to .gitattributes for consistent line endings
- Format cleaner.ts with Prettier (parameter and chain formatting)
- Format CleanerPage.tsx (JSX formatting)
- Format cleaner.test.ts (array formatting)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 22:42:16 +08:00
test
975bee6ce8 docs(cleaner): add comprehensive order error collection analysis
Add complete technical documentation for the material cleaning module's order error collection mechanism, including:
- System architecture and error collection flow diagrams
- Complete checklist of 52 error points across all layers
- Multi-layer error handling (IPC/service/retry layers)
- Frontend error display flow
- Retry mechanism and audit trail

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 22:30:45 +08:00
test
6a2fba0e57 refactor(cleaner): batch query and controlled parallel order processing 2026-03-16 21:47:13 +08:00
Misaka_Company
9248be6310 ♻️ refactor(ui): use global toast system in SettingsPage
- Replace local message state with global showSuccess/showError
- Remove inline message display component from SettingsPage
- Center toast notifications for better visibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 16:16:06 +08:00
Misaka_Company
ad8b3deb00 feat(order-resolver): add deduplication logic for production order number resolution
- Add P0: DISTINCT in SQL query to prevent database duplicates
- Add P1: Input layer deduplication to avoid redundant queries
- Add P2: Return layer deduplication in getValidOrderNumbers()
- Optimize resolve() to use batch query instead of loop queries
- Add getDeduplicationReport() for human-readable mapping summary
- Improve extraction logs to show deduplication statistics
- Only log merged mappings (multiple productionIDs → one order number)
- Remove duplicate marking as error (normal business scenario)
- Bump version to 1.0.1

Example log output:
  输入 5 个总排号 → 解析为 2 个唯一订单号(3 个重复已合并)
  重复合并详情:
    SC70202603120085 ← 26B12214、26B12213、26B12212 (共 3 个总排号)
    SC70202603120131 ← 26B12125、26B12126 (共 2 个总排号)
2026-03-16 16:02:52 +08:00
Misaka_Company
cccc4e4c8c feat(ui): display version and git hash in header
Add version info display below "ERP Auto" logo showing format:
${version}(${git-hash})

- Inject __APP_VERSION__ and __GIT_HASH__ via vite define
- Add TypeScript declarations for global constants
- Simplify portable artifact name (remove version)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 15:00:38 +08:00
Misaka_Company
9e577a2226 fix(types): add missing clearSharedProductionIds to ValidationAPI
The method was implemented in preload/index.ts but missing from the
type definition, causing TypeScript compilation to fail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 09:59:36 +08:00
Misaka_Company
715dfb4d71 feat(cleaner): add automatic retry mechanism for failed orders
- Add retry logic with max 2 attempts per failed order
- Track retry statistics (retriedOrders, successfulRetries)
- Generate detailed retry report section in execution reports
- Display retry metrics in ExecutionReportDialog UI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:59:10 +08:00
test
a25ffd75c5 Merge branch 'dev' of github.com:Misaka-Dev-Hub/ERPAuto into dev 2026-03-10 19:20:54 +08:00
Misaka_Company
8eaba79c26 fix(order-resolver): correct SQL Server table name conversion for productionId lookup
- Fix getTableName to properly split schema_tablename format
- Convert productionContractData_26年压力表合同数据 to [productionContractData].[26年压力表合同数据]
- Extend productionId pattern to support 1-6 digit serial numbers

Co-Authored-By: Claude (glm-5) <noreply@anthropic.com>
2026-03-09 09:31:32 +08:00
Misaka_Company
05a44bb464 chore: add .npmrc to gitignore
Co-Authored-By: Claude (glm-5) <noreply@anthropic.com>
2026-03-09 09:10:24 +08:00
test
77fabf2018 fix(shared-state): clear shared Production IDs when input is cleared
Fixes a bug where clearing the order number input in the extraction page
did not clear the shared Production IDs in the main process. This caused
the data cleanup page to continue using stale data when filtering by
Production ID.

Changes:
- Add VALIDATION_CLEAR_SHARED_PRODUCTION_IDS IPC channel
- Register handler to clear shared Production IDs by sender ID
- Expose clearSharedProductionIds API in preload script
- Update ExtractorPage to call clear when order numbers are empty

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-08 21:58:56 +08:00
test
13187ddce1 feat(validation): add source number validation checks with localized error messages
Add validation to detect and report when no order numbers are found from Production IDs (either from shared inputs or file). Provides clear Chinese error messages to guide users when their inputs don't match any database records.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-08 19:18:54 +08:00
test
33b17e278d refactor(ui): replace native alerts with toast notifications and confirm dialogs
This commit refactors all native browser alert() and confirm() dialogs to use
the app's custom UI components for consistent user experience.

**Changes:**
- Add ConfirmDialog component with danger/warning/info variants
- Add useConfirmDialog hook for promise-based dialog API
- Add formatListMessage utility for truncating long lists
- Replace 18 alert() calls with toast notifications in useCleaner.ts
- Replace 7 alert()/confirm() calls in MaterialTypeManagementDialog.tsx
- Render Toast component in App.tsx for global notifications
- Add keyboard shortcuts (Enter to confirm, Escape to cancel)

**Benefits:**
- Consistent UI design across all notifications
- Non-blocking notifications for better UX
- Better accessibility with proper ARIA roles and focus management
- Multi-line message support with truncation for long lists

**Files Modified:**
- src/renderer/src/components/ui/ConfirmDialog.tsx (new)
- src/renderer/src/stores/useAppStore.ts (add formatListMessage)
- src/renderer/src/hooks/useCleaner.ts (refactor error handling)
- src/renderer/src/components/MaterialTypeManagementDialog.tsx (refactor dialogs)
- src/renderer/src/pages/CleanerPage.tsx (add ConfirmDialog)
- src/renderer/src/App.tsx (render Toast component)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-08 19:13:25 +08:00
test
bcfe0eecca fix(a11y): improve focus restoration with timing fixes, validation, and error handling
Core improvements:
- Fix focus restoration timing by using queueMicrotask only (removed double-layer async)
- Add comprehensive error handling with dev-mode logging for all failure scenarios
- Validate element visibility (display: none, visibility: hidden) before restoring focus
- Check disabled state and implement fallback to nearest focusable ancestor
- Add findNearestFocusableElement() helper for robust fallback strategy
- Add tabindex="-1" to focusable selectors for better focus management
- Use preventScroll option when calling focus() to prevent scroll jumps

Additional fixes:
- Remove unnecessary type conversion in Modal.tsx
- Fix TypeScript unused variable errors in main process
- Clean up unused imports in bip-users-dao.ts
- Add ARIA attributes and focus management to LoginDialog
- Add triggerRef support to UserSelectionDialog and ExecutionReportDialog
- Refactor ExecutionReportDialog to use Modal component
- Improve MaterialTypeManagementDialog with focus management

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-08 18:29:39 +08:00
test
9a640b96e6 test(e2e): improve dialog focus tests and code quality
Test improvements:
- Add error handling for Electron app launch in headless environments
- Update dialog selectors to use ARIA attributes for better reliability
- Implement actual test logic (previously skipped placeholders)
- Add screenshot capture evidence for test results
- Update test descriptions to match actual dialog types

Code quality improvements:
- Change 'let' to 'const' for variables that are not reassigned
- Improves code clarity and follows best practices

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-08 17:59:35 +08:00
test
5b4d7fab49 refactor(a11y): optimize dialog focus management with centralized Escape handling
Enhanced the useDialogFocus hook to support conditional Escape key handling,
removing redundant Escape key listeners from individual dialog components.

Changes:
- Added shouldCloseOnEscape option to useDialogFocus (boolean or function callback)
- Removed redundant Escape key handlers from Modal, ExecutionReportDialog, and LoginDialog
- ExecutionReportDialog now uses shouldCloseOnEscape: () => !isExecuting to prevent
  closing during execution
- LoginDialog no longer has manual focus effect (handled by initialFocusSelector)
- Fixed React hooks order violations in ExecutionReportDialog
- Added proper TypeScript return types throughout

Benefits:
- Centralized Escape key logic in one place
- Consistent behavior across all dialogs
- Reduced code duplication (~50 lines removed)
- Easier to maintain and extend

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-08 17:58:16 +08:00
test
72d8d981b1 feat(a11y): add focus management to UserSelectionDialog and ExecutionReportDialog
UserSelectionDialog:
- Add ARIA attributes (role, aria-modal, aria-labelledby)
- Integrate useDialogFocus hook with FocusLock
- Add Escape key handling (was missing)
- Initial focus on first user card

ExecutionReportDialog:
- Add ARIA attributes with dynamic aria-labelledby
- Add aria-live for progress updates
- Integrate useDialogFocus hook with FocusLock
- Escape key only closes when not executing
2026-03-08 17:16:53 +08:00
test
d1f2f40123 feat(a11y): add focus management to LoginDialog
- Add role="dialog", aria-modal="true", aria-labelledby
- Add aria-live="polite" and role="alert" on error messages
- Integrate useDialogFocus hook with FocusLock
- Maintain initial focus on username input
- Maintain Enter key submit and Escape key close
- Refactor error handling with internal state
2026-03-08 17:09:37 +08:00
test
545048045d feat(a11y): add ARIA attributes and focus trap to Modal component
- Add role="dialog" and aria-modal="true" to modal container
- Add aria-labelledby linked to title element
- Integrate useDialogFocus hook for focus management
- Wrap content with FocusLock from react-focus-lock
- Add triggerRef prop for focus restoration
- Add titleId prop for custom aria-labelledby
- Preserve existing Escape key and backdrop click behavior
2026-03-08 17:04:04 +08:00
test
62e1647eaf feat(a11y): add focus lock dependency and useDialogFocus hook
- Install react-focus-lock@2.13.7 for focus trap functionality
- Create useDialogFocus hook with focus management, Escape key handling,
  initial focus, focus restoration, and body scroll lock
- Create E2E test infrastructure with helper functions for focus testing
- Compatible with React 19 and Electron 39
2026-03-08 17:01:00 +08:00
test
fba2c73782 feat(logging): Wave 3 - add comprehensive error serialization with stack traces
- Add error-utils module with serializeError and sanitizeError utilities
- Enhance IPC error handling to capture full error context including stack traces
- Add logError helper function for consistent error logging across the application
- Update console and file log formats to properly serialize error objects
- Replace all basic error logging in BIPUsersDAO with structured logError calls
- Add ErrorLike and SerializedError type interfaces for type safety

This improves debugging capability by preserving full error details in development
while sanitizing sensitive information in production logs.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-08 16:10:23 +08:00
test
c4fff84848 style: format code with Prettier
Apply Prettier formatting to maintain consistent code style across the codebase.
Changes include formatting improvements for:
- IPC handlers (database, file, material-type, resolver, user-erp-config, validation)
- Type definitions (ipc-api.types)
- React components (MaterialTypeManagementDialog)
- React hooks (useAuth, useCleaner, useValidation)
- Pages (SettingsPage)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-08 15:17:40 +08:00
test
57c3452d82 fix(types): resolve TypeScript unused variable errors
Fix all TS6133 errors (unused variables) across service layer:

- Remove unused imports (path, ExtractionProgress type)
- Prefix unused parameters with underscore (_session, _totalBatches, etc.)
- Remove unused _verbose field and constructor from ExcelParser
- Remove unused _importToDatabase method from ExtractorService
- Remove unused _importProgress variable

This ensures clean type checking and eliminates dead code.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-08 15:16:26 +08:00
test
f412e0e72c chore: ignore TypeScript build info files
Remove tsbuildinfo files from version control and add to .gitignore.
These are incremental compilation cache files that should be generated locally.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-08 15:08:54 +08:00
test
6df16898da feat(logging): Wave 2 - integrate logging throughout application
This commit integrates the logging infrastructure across the entire application:

IPC Layer:
- Add logger-handler.ts with centralized IPC logging channels
- Integrate audit logging into auth, cleaner, extractor handlers
- Add structured logging for IPC operations and data flow

Service Layer:
- Add logger integration to ERP services (extractor, cleaner)
- Integrate logging into excel-parser and user DAO
- Add operation tracking and error logging

Renderer Layer:
- Add useLogger hook for component-level logging
- Update App.tsx with session and user activity logging
- Enable frontend audit trail for critical actions

Testing:
- Add comprehensive IPC logging integration tests
- Enhance unit test coverage for logger and audit-logger
- Add end-to-end logging flow validation

Types:
- Update preload type definitions for logging APIs

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-08 15:07:03 +08:00
test
5e6898fb40 feat(logging): Wave 1 - logging infrastructure complete
- Add logging config to config.yaml with level, auditRetention, appRetention
- Add 4 global exception handlers (uncaughtException, unhandledRejection, render-process-gone, child-process-gone)
- Create audit-logger.ts with JSONL format and 30-day rotation
- Define IPC logger channels (LOGGER_FORWARD) and preload API
- Define audit types (AuditAction enum, AuditEntry interface, AuditStatus enum)
- Add unit tests for audit logger

All typechecks passing. Wave 1 complete.
2026-03-08 13:49:31 +08:00
test
f45d3df385 fix(component): fix stale closure in MaterialTypeManagementDialog
Fix issue where keyword field was not editable after adding new row via Insert key or Add button. Root cause was incomplete useCallback dependencies in handleKeyDown, causing it to capture stale references to insertNewRow, deleteRow, saveEdit, and cancelEdit functions.

Changes:
- Wrap insertNewRow with useCallback (deps: isAdmin, currentUsername)
- Wrap deleteRow with useCallback (deps: none)
- Wrap startEdit with useCallback (deps: rows)
- Wrap saveEdit with useCallback (deps: editingCell, editValue)
- Wrap cancelEdit with useCallback (deps: none)
- Update handleKeyDown dependency array to include all referenced functions

This ensures all callbacks have access to the latest props and state, preventing the edit mode initialization failure.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-08 13:22:43 +08:00
test
8f3e5273e2 refactor(ipc): harden channels and unify IPC contracts 2026-03-08 12:35:00 +08:00
test
616e2b31a5 fix: add 'nul' to .gitignore to prevent tracking of nul files 2026-03-08 11:22:00 +08:00
test
b3abad7fae feat(playwright): enable Playwright in production builds with custom browser path
Move Playwright from dev to production dependencies and implement robust browser
management for company deployment environment:

- Move playwright and playwright-core to dependencies
- Set PLAYWRIGHT_BROWSERS_PATH to user data directory before imports
- Add startup validation for Chromium browser with friendly error dialog
- Update build scripts to skip browser download during build process
- Configure electron-builder to unpack Playwright for native module access
- Add deployment documentation for browser setup in restricted environments

This allows manual browser installation in company environments where direct
downloads are blocked during build.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-07 19:03:29 +08:00
test
9d15f7aca9 Merge branch 'cleanup/remove-env-variables' into dev 2026-03-07 17:28:14 +08:00
test
310d5e462f fix(hook): fix race condition in progress dialog initialization
Initialize progress state before opening the execution report dialog to ensure the progress view displays correctly from the start.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-07 17:26:57 +08:00
test
c4bacdaf2a fix: correct TypeScript type for YAML config parsing in migration script 2026-03-07 17:25:12 +08:00
test
54a82200b6 refactor: complete migration from .env to YAML configuration
BREAKING CHANGE: Application now uses config.yaml instead of .env files

## Changes:
- Remove dotenv dependency from package.json
- Update all services to use ConfigManager for configuration
- Update tests to use fixed credentials instead of env vars
- Delete obsolete config-manager.test.ts (used old .env API)
- Update documentation (README.md, CLAUDE.md) to reflect new config system

## Configuration Architecture:
- ConfigManager: Centralized YAML configuration with Zod validation
- config.yaml location:
  - Development: Project root (easy to edit and version control)
  - Production: User AppData (persists across updates)
- ERP credentials: Stored in database (dbo_BIPUsers) per user
- Other settings: Stored in config.yaml (database, paths, extraction, etc.)

## Files Modified:
- package.json: Removed dotenv dependency
- cleaner-handler.ts: Use ConfigManager.getDatabaseType()
- run-migration.ts: Read from config.yaml instead of .env
- All integration tests: Use fixed test credentials
- tests/setup.ts: Removed dotenv loading
- README.md, CLAUDE.md: Updated documentation

Migration is complete. Application no longer depends on .env files.
2026-03-07 17:22:13 +08:00
test
2d6838c0e7 fix(component): fix race condition in MaterialTypeManagementDialog row addition
Use functional setState to correctly calculate new row index based on
latest state, preventing stale closure issues.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-07 15:50:47 +08:00
test
73b8f2409a fix(validation): migrate DB config from env to ConfigManager
- Refactor getValidationDatabaseService() to use ConfigManager
- Refactor getTableName() to use ConfigManager.getDatabaseType()
- Replace all process.env.DB_TYPE references with ConfigManager API
- Simplify isSqlServer checks from 'sqlserver||mssql' to 'sqlserver'
- Preserve all business logic and dual-database support
- Typecheck passes successfully

Fixes issue where Cleaner page failed with 'Failed to connect to MySQL'
due to validation-handler.ts not being migrated in commit c13be9e
2026-03-07 15:14:15 +08:00
test
c7c192a703 chore(extractor): add error logging for ERP login failures
Added error message extraction and logging to improve user feedback and debugging when ERP login fails during material plan extraction.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-07 14:51:50 +08:00
test
42bd6dcb8a chore: remove config.yaml from tracking
Runtime config file should not be tracked in git

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-07 14:23:23 +08:00
test
e4a92e9be4 chore: ignore runtime config files and reorganize .gitignore
- Ignore *.yaml and *.yaml.backup files
- Reorganize sections with clear comments

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-07 14:17:49 +08:00
test
6f19890a84 refactor(erp-auth): implement precise login result detection with three outcomes
- Add waitForLoginResult() method using Promise.race to detect:
  - Success: .nc-workbench-icon element visible
  - Failure: '名称或密码错误' error text visible
  - Force login: click confirm button and re-detect
- Extract timeout constants (PAGE_LOAD_TIMEOUT, LOGIN_RESULT_TIMEOUT, FORCE_LOGIN_TIMEOUT)
- Improve error handling with clear error messages
- Add unit tests for class structure verification
- Fix test setup for Electron app mock

Fixes: ERP login success/failure detection was ambiguous
2026-03-07 14:07:08 +08:00
test
1ee33672dd refactor: implement precise login result detection in ERP auth service
Add waitForLoginResult() method with Promise.race to detect three login outcomes:
- Success: detects .nc-workbench-icon element
- Failure: detects '名称或密码错误' error text
- Force login: clicks confirm button and re-detects

Improves login reliability by properly handling all authentication scenarios.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-07 13:48:41 +08:00
test
8e0c37bf74 feat: add ERP login debug tool with documentation
Add interactive debugging tool for ERP login flow analysis:
- Add tsx dependency for TypeScript script execution
- Add npm scripts: debug:erp-login and debug:config-path
- Add erp-login-debug.ts with automated login and element inspection
- Add comprehensive usage guide and quick reference documentation

The debug tool automates ERP login and provides pause points for
manual element inspection using browser DevTools or Playwright Inspector.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-07 13:16:43 +08:00
test
c13be9e19a refactor: migrate configuration to YAML-based system 2026-03-07 11:30:09 +08:00
Misaka
48f1f51d76 refactor: remove unused UI and execution configuration
Remove unused configuration options that were not consumed by the UI:

- Remove UI configuration (UI_FONT_FAMILY, UI_FONT_SIZE, UI_PRODUCTION_ID_INPUT_WIDTH)
  - No UI components were using these settings
  - Settings page had no inputs for these options

- Remove execution configuration (EXECUTION_DRYRUN)
  - Dry run mode is controlled by Cleaner page UI toggle
  - State is managed via sessionStorage, not config file

Files modified:
- src/main/types/settings.types.ts: Remove UiConfig and ExecutionConfig interfaces
- src/main/services/config/config-manager.ts: Remove config read/write logic
- src/main/ipc/settings-handler.ts: Remove filtered fields
2026-03-05 22:02:53 +08:00
Misaka
5977254180 feat: migrate ERP configuration from .env to per-user database storage
- Moved ERP credentials (URL, username, password) from environment variables to dbo_BIPUsers table
- Each user now has their own ERP configuration stored in the database
- Added UserErpConfigService for managing per-user ERP settings
- Updated cleaner and extractor handlers to fetch ERP config from database instead of .env
- Removed ERP fields from ConfigManager UI editable fields
- Added new IPC handlers and preload APIs for user ERP config management
- Includes migration script to transfer existing .env ERP settings to database
- Added migration guide documentation

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-05 21:52:54 +08:00
Misaka
3d2127b660 fix: correct ComputerNmae typo to ComputerName in BIPUsers DAO
- Fix column name typo in BIP_USERS_CONFIG constant
- Update SQL queries to use correct ComputerName column
- Add migration scripts for database schema fix (JS and SQL)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-05 20:35:33 +08:00
google-labs-jules[bot]
7db7f513be refactor: hardcode erp headless and browser settings instead of env vars
- Removed ERP_HEADLESS, ERP_IGNORE_HTTPS_ERRORS, ERP_AUTO_CLOSE_BROWSER from ConfigManager .env operations.
- Hardcoded these values in ConfigManager's default settings and getAllSettings() return.
- Hardcoded ignoreHTTPSErrors to true inside ErpBrowserManager when creating a new browser context.

Co-authored-by: luwamgere15-crypto <255338376+luwamgere15-crypto@users.noreply.github.com>
2026-03-05 10:48:50 +00:00
Misaka_Company
a906f34ac2 feat: auto-generate markdown report after cleaner execution
- Add CleanerReportGenerator service to create execution reports
- Extend OrderCleanDetail with skippedMaterials for detailed tracking
- Record skip reasons for each material (protected range, pending qty, etc.)
- Generate timestamped markdown reports in logs/reports/ directory
- Reports include execution summary, order details, skip reasons, and errors
2026-03-05 16:19:40 +08:00
Misaka_Company
224a42e44b docs: update cleaner execution report error details section 2026-03-05 15:07:17 +08:00
Misaka_Company
7d2cf934f8 feat: add estimated completion time to cleaner progress dialog
- Display remaining time estimate (in minutes) when progress >= 5%
- Show estimated completion time in 12-hour format (上午/下午 HH:MM:SS)
- Update every second during execution
- Add startTime state tracking in useCleaner hook
2026-03-05 14:17:34 +08:00
Misaka_Company
49ac29e70c Merge feature/cleaner-progress into dev
Merged progress indicator feature for cleaner execution:
- CleanerProgress type for tracking execution progress
- Progress calculation: (1 + i + j/Mᵢ)/(1+N) × 100
- cleaner.onProgress IPC event for real-time updates
- Enhanced ExecutionReportDialog with progress bar
- Progress state management in useCleaner hook
2026-03-05 13:28:34 +08:00
Misaka_Company
dc01896d8b feat: add progress indicator to cleaner execution report dialog
- Add CleanerProgress type for tracking execution progress
- Implement progress calculation: (1 + i + j/Mᵢ)/(1+N) × 100
  - Login complete: 1/(1+N) × 100
  - Per material: (1 + orderIndex + materialIdx/totalMaterials)/(1+totalOrders) × 100
- Add cleaner.onProgress IPC event for real-time progress updates
- Enhance ExecutionReportDialog with progress bar and status display
- Update useCleaner hook with progress state management
- Dialog opens immediately on execution start, showing progress then results
2026-03-05 13:25:04 +08:00
Misaka_Company
ba64c27457 feat: allow editing manager field in cleaner table
- Add inline editing for manager column (Admin users can double-click to select from dropdown)
- Auto-assign current user as manager when User checks a material
- Defer database writes until 'Confirm Delete' button is clicked
- Add updateManager IPC handler and DAO method
- Update preload API with updateManager method
2026-03-05 13:19:12 +08:00
Misaka_Company
921ca15be6 feat: add execution report dialog for cleaner
- Create ExecutionReportDialog component with Ant Design style
- Replace native alert() with modal dialog after cleaner execution
- Display orders processed, materials deleted/skipped statistics
- Show error details in scrollable list
- Support dry-run mode indicator
2026-03-05 11:15:44 +08:00
Misaka_Company
121d49bfe8 docs: add configuration system architecture analysis 2026-03-05 10:55:15 +08:00
Misaka_Company
0783bc037c Fix: Set text color for login input fields 2026-03-05 10:19:19 +08:00
Misaka_Company
d855b3f84d Fix: Set dryRun mode default to false for User in Cleaner page 2026-03-05 10:15:21 +08:00
Misaka_Company
e0d7559cf7 Fix: Hide progress display when clearing order numbers and remove progress text from LogPanel 2026-03-05 10:12:09 +08:00
Misaka
302018f631 Feat: Add extraction complete status indicator with success message 2026-03-04 22:56:00 +08:00
Misaka
e49191d531 Add .gitattributes to enforce consistent line endings 2026-03-04 22:19:06 +08:00
Misaka
2dea1f9556 Feat: Add segmented progress bar for data extractor with dynamic phase calculation
- Add ExtractionProgress type with phase, batch, and subProgress fields
- Implement dynamic progress calculation: 1 (login) + N (batches) + 2 (merge/import)
- Create SegmentedProgressBar component with 4 colored phases (purple/blue/amber/green)
- Show batch-level progress during download phase (e.g., 批次 1/10)
- Display sub-progress during login phase (连接数据库/解析订单号/登录 ERP)
- Update IPC handler and extractor services to report detailed progress
- Add phase status indicators (completed/active/pending) with color-coded dots
2026-03-04 22:16:47 +08:00
Misaka
4494351e52 Merge branch 'dev' into extractor-page-refactoring-11498013679550826416 2026-03-04 21:03:21 +08:00
Misaka
9e1b5530ea Feat: Add real-time progress and logging to data extractor
- Implement IPC event system for pushing progress updates from main to renderer
- Add Zustand store for centralized extractor state management
- Refactor useExtractor hook to use store pattern
- Add chromium-bidi dependency and externalize Playwright for build compatibility
- Show detailed logs during extraction (DB connection, order resolution, ERP login, data import)
2026-03-04 20:51:54 +08:00
google-labs-jules[bot]
743d830b0d Refactor CleanerPage with custom hook useCleaner
* Extracted state management and IPC actions from `CleanerPage.tsx` into a custom hook `useCleaner.ts`.
* `CleanerPage.tsx` is now much more focused on UI layout and rendering.
* Addressed code responsibilities issue, minimizing the size of the component file from nearly 500 lines to just the necessary UI structure.
* Verified no regressions via type checking and application building.

Co-authored-by: luwamgere15-crypto <255338376+luwamgere15-crypto@users.noreply.github.com>
2026-03-04 12:18:35 +00:00
google-labs-jules[bot]
dcd3c6a571 Refactor ExtractorPage with custom hooks and UI components
* Extract logic and state management from `ExtractorPage.tsx` into a custom hook `useExtractor.ts`.
* Extract log rendering logic and auto-scrolling into a separate `LogPanel.tsx` component.
* `ExtractorPage.tsx` is now significantly smaller, focused purely on presentation.
* Verified no regressions via type checking and application building.

Co-authored-by: luwamgere15-crypto <255338376+luwamgere15-crypto@users.noreply.github.com>
2026-03-04 12:11:56 +00:00
Misaka
9939201ca1 Fix: Add chromium-bidi dependency and externalize Playwright to resolve module loading errors 2026-03-04 20:04:48 +08:00
google-labs-jules[bot]
fed76b4fe0 Refactor ExtractorPage with custom hooks and UI components
* Extract logic and state management from `ExtractorPage.tsx` into a custom hook `useExtractor.ts`.
* Extract log rendering logic and auto-scrolling into a separate `LogPanel.tsx` component.
* `ExtractorPage.tsx` is now significantly smaller, focused purely on presentation.
* Verified no regressions via type checking and application building.

Co-authored-by: luwamgere15-crypto <255338376+luwamgere15-crypto@users.noreply.github.com>
2026-03-04 11:18:04 +00:00
Misaka_Company
bfa2445c76 fix: add batch processing to prevent SQL Server 2100 parameter limit error
- Add batch processing in getSourceNumbersFromInputs() with 2000 batch size
- Reduce batch size from 2000 to 1500 in queryBySourceNumbers() and queryBySourceNumbersDistinct()
- Add batch processing to queryByPlanNumbers() and getUniqueMaterialNames()
- Fixes issue where large order quantities caused parameter limit exceeded error
2026-03-04 18:05:35 +08:00
Misaka_Company
b62ae10650 feat: add headless mode toggle and improve CleanerPage layout
- Add headless mode setting with session persistence in CleanerPage
- Move execution settings (dry-run, headless) to dropdown menu
- Improve responsive layout for admin/non-admin users
- Fix home page content overflow issue
2026-03-04 17:46:40 +08:00
Misaka_Company
974eaac6dd docs: update extractor-start-button-flow.md to v1.3
- Add file merging functionality (ExcelParser merges batch files)
- Add database auto-import feature (DataImportService)
- Update architecture diagrams to include ExtractorCore separation
- Update sequence diagram with 76-step workflow
- Add DataImportService and DiscreteMaterialPlanDAO to flow
- Document ImportResult type and import statistics
- Update error handling to include merge and import errors
- Add temporary file cleanup after merging
2026-03-04 17:01:50 +08:00
Misaka_Company
2f5dc7607d fix: disable TLS encryption by default to avoid ServerName IP address warning
- Set encrypt: false as default for SQL Server connections
- Fixes DEP0123 deprecation warning when connecting via IP address (VPN tunnel)
- trustServerCertificate option still configurable via environment variable
- Affects 6 files: sql-server.ts, bip-users-dao.ts, database/index.ts,
  database/data-source.ts, cleaner-handler.ts, validation-handler.ts
2026-03-04 16:53:58 +08:00
Misaka_Company
a06276127d fix: resolve Chinese character encoding issue in console output
- Set console code page to UTF-8 (65001) in build scripts
- Fix garbled Chinese characters in winston logger output
2026-03-04 16:24:13 +08:00
Misaka_Company
29cb79abfd style: improve UI layout and styling
- Increase default window width from 900 to 1200
- Beautify clear button in OrderNumberInput with icon and hover effects
- Simplify ExtractorPage layout by removing collapsible sidebar
- Center settings form controls horizontally in SettingsPage
2026-03-04 16:06:00 +08:00
Misaka_Company
dc3d577f6f refactor: optimize ExtractorPage layout and UX
- Use OrderNumberInput component with format statistics
- Add collapsible sidebar with smooth animation
- Improve log system with level-based coloring and auto-scroll
- Remove result display cards for cleaner interface
- Add file:openPath IPC handler for opening files in explorer
2026-03-04 15:52:41 +08:00
Misaka_Company
50041d2a7b fix: remove validationResults check from cleaner execute button
Allow execute button to be clickable by default, relying on backend validation instead of frontend disabled state
2026-03-04 15:14:59 +08:00
Misaka_Company
9833552652 fix: support both MySQL and SQL Server in cleaner handler 2026-03-04 14:56:19 +08:00
Misaka_Company
8dbdf6f394 docs: move IMPLEMENTATION_PLAN.md to docs/plans directory
Co-Authored-By: Claude (glm-5) <noreply@anthropic.com>
2026-03-04 14:27:01 +08:00
Misaka_Company
c61776a1ff style: apply Prettier formatting across codebase
Apply consistent code formatting using Prettier to improve code readability
and maintain style consistency throughout the project.

Co-Authored-By: Claude (glm-5) <noreply@anthropic.com>
2026-03-04 14:24:16 +08:00
Misaka_Company
88c8c256e2 fix: resolve TypeScript type errors across codebase
- Add experimentalDecorators support in tsconfig.node.json for TypeORM entities
- Fix mssql module import in order-resolver.ts (static vs dynamic import)
- Extend ISqlType parameter types in sql-server.ts for NVarChar compatibility
- Fix variable naming and type assertions in bip-users-dao.ts
- Add proper type assertions for IPC call results in renderer hooks
  (useAuth, useCleaner, useExtractor, useValidation)
- Add definite assignment assertions in config-manager.ts
2026-03-04 14:03:23 +08:00
Misaka_Company
5497e86b58 fix: adjust batch size for SQL Server parameter limit (2100 max params)
- SQL Server has a maximum of 2100 parameters per query
- Each record has 28 columns, so max batch is ~71 records (2000/28)
- Fixed syntax error: removed extra closing brace in extractor.ts
- Added debug logging for batch insert parameters
2026-03-04 13:24:50 +08:00
Misaka_Company
63ea81e0d6 feat: add automatic database import after ERP data extraction
- Add DataImportService for reading Excel and importing to database
- Extend DiscreteMaterialPlanDAO with deleteBySourceNumbers and batchInsert
- Auto-trigger database write after successful Excel merge
- Support batch delete by SourceNumber and batch insert (1000/batch)
- Update ExtractorPage UI to show import results
- Fix SQL Server query to handle undefined recordset for DELETE/INSERT

Co-Authored-By: Claude (glm-5) <noreply@anthropic.com>
2026-03-04 12:50:27 +08:00
Misaka_Company
abe51d17fa fix: resolve production ID case-sensitivity and SQL Server compatibility issues
- Add getTableName() method to convert MySQL table names to SQL Server format
- Use queryWithParams with sql.NVarChar for proper SQL Server parameter handling
- Implement case-insensitive matching for production IDs (e.g., 26b10433 vs 26B10433)
- Align resolver logic with validation-handler.ts for consistent database queries

Co-Authored-By: Claude (glm-5) <noreply@anthropic.com>
2026-03-04 11:27:37 +08:00
Misaka_Company
240e3838ba fix: correct Excel header for product unit from "单位" to "产品单位"
The product unit column header was incorrectly showing "单位" instead of
"产品单位", causing confusion with the material unit column which also
uses "单位". Fixed in both extractor.ts and excel-parser.ts.

Co-Authored-By: Claude (glm-5) <noreply@anthropic.com>
2026-03-04 09:52:08 +08:00
Misaka
c384513273 Merge branch 'feature/export-cleaner-data' into dev 2026-03-03 22:52:43 +08:00
Misaka
e23cf71f78 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) <noreply@anthropic.com>
2026-03-03 22:51:11 +08:00
Misaka
7aa1abbc22 fix: resolve ExcelJS dynamic import and merge file save errors
- Fix "Workbook is not a constructor" error by handling ESM/CommonJS module format
- Add try-catch around saveMergedOrders to capture and report errors
- Return parsed recordCount even when save fails so users see actual data count
- Add detailed logging throughout merge process for debugging
- Clean up temporary batch files after merge completion

Co-Authored-By: Claude (glm-5) <noreply@anthropic.com>
2026-03-03 22:46:23 +08:00
Misaka
879dccaa09 feat: add export validation results to Excel feature
Add export functionality to CleanerPage that allows users to export
the currently displayed validation results to an Excel file.

- Add ExportResultItem and ExportResultResponse types
- Create ResultExporter service using ExcelJS
- Register cleaner:exportResults IPC handler
- Add exportResults method to preload API
- Connect export button in CleanerPage to export handler

Export features:
- Exports filtered results (respecting manager/visibility filters)
- Includes selection status column
- Saves to app data directory/exports/校验结果.xlsx

Co-Authored-By: Claude (glm-5) <noreply@anthropic.com>
2026-03-03 22:29:52 +08:00
Misaka
e13bb15969 feat: implement Excel merge functionality for data extraction
- Add mergeFiles() method in ExtractorService to combine downloaded batch files
- Add saveMergedOrders() method to output full 31-column Excel format
- Update recordCount to return actual material record count
- Add missing field mappings in OrderHeader type and ExcelParser:
  - factory, materialStatus, planNumber, materialType
  - department, remark, createDate, approveDate
- Output file named with timestamp: merged_YYYYMMDDHHMMSS.xlsx

Co-Authored-By: Claude (glm-5) <noreply@anthropic.com>
2026-03-03 21:32:11 +08:00
Misaka
00b120c762 docs: update extractor-start-button-flow.md for database architecture changes
- Update version to 1.2
- Add database factory pattern documentation
- Add IDatabaseService interface documentation
- Update architecture diagram with Database Layer (MySQL/SQL Server)
- Update sequence diagram with DatabaseFactory and IDatabaseService
- Fix Mermaid syntax (replace <br/> with multiline strings)
- Update error handling flowchart for dual database support
- Add DB_TYPE environment variable documentation

Co-Authored-By: Claude (glm-5) <noreply@anthropic.com>
2026-03-03 20:55:56 +08:00
Misaka
0040ca7521 refactor: abstract database layer to support MySQL and SQL Server
- Add IDatabaseService interface with unified query(transaction) methods
- Create DatabaseFactory for centralized database service creation
- Modify MySqlService and SqlServerService to implement IDatabaseService
- Unify SqlServerService.query() to accept array params (internally converts to @p0, @p1...)
- Refactor OrderNumberResolver to use IDatabaseService
- Refactor DiscreteMaterialPlanDAO to use DatabaseFactory
- Refactor MaterialsToBeDeletedDAO to use DatabaseFactory
- Update IPC handlers to use DatabaseFactory.create()
- Add database.types.ts with shared type definitions

This enables switching between MySQL and SQL Server via DB_TYPE env variable.

Co-Authored-By: Claude (glm-5) <noreply@anthropic.com>
2026-03-03 20:18:20 +08:00
Misaka
6698d82d6b docs: update extractor-start-button-flow.md to match current implementation
- Update version to 1.1 with new related files list
- Fix sequence diagram step numbers and add IPC serialization note
- Add note about progress state not receiving backend updates
- Add code references for real-time sync useEffect and preload API
- Update summary with custom error types and sessionStorage details
- Add new section for known limitations and pending features
- Document progress update and file merge as unimplemented

Co-Authored-By: Claude (glm-5) <noreply@anthropic.com>
2026-03-03 19:01:48 +08:00
Misaka_Company
b98cd46237 feat: merge settings partial save feature
Features:
- Partial settings save with deep merge
- Backup and rollback mechanism
- UI field whitelist validation
- Defensive programming in settings page

Safety:
- Auto-backup before save
- Automatic rollback on failure
- Field validation at both client and server
2026-03-03 17:15:03 +08:00
Misaka_Company
baaa031dac debug: add logging to savePartialSettings for troubleshooting 2026-03-03 15:51:47 +08:00
Misaka_Company
816060444c fix: correct cache key format to match .env file structure
The root cause of config overwrites was key mismatch:
- .env file uses: ERP_URL, DB_TYPE, DB_NAME (underscore uppercase)
- Code was using: erp.url, database.dbType (dot notation)

Fixed in three methods:
- saveAllSettings() - now sets cache with correct keys
- resetToDefaults() - now uses correct keys
- save() - now reads cache with correct keys

This ensures partial save preserves unmodified fields.
2026-03-03 15:42:40 +08:00
Misaka_Company
73a49f9de3 docs: add settings partial save feature documentation
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-03 15:12:39 +08:00
Misaka_Company
4d8df61187 feat: send only ERP fields from settings page (defensive programming) 2026-03-03 15:05:40 +08:00
Misaka_Company
a967050058 feat: update settings handler to use savePartialSettings
- Change parameter type from SettingsData to Partial<SettingsData>
- Call savePartialSettings() instead of saveAllSettings()
- Return detailed error messages from savePartialSettings
- Add logging for sections being saved

This change enables partial settings save functionality, allowing
the UI to save only specific settings sections without requiring
the complete settings object.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-03 14:57:06 +08:00
Misaka_Company
f3be0ad01d feat: implement savePartialSettings with validation and rollback
Implements the core savePartialSettings method that:
- Validates fields against UI_EDITABLE_FIELDS whitelist
- Deep merges partial updates with current settings
- Creates backup before saving
- Restores backup on save failure
- Reloads .env file to populate cache with correct keys

Added comprehensive tests:
- Partial update preserves existing fields
- Rejects non-whitelisted fields
- Handles nested object updates
- Restores backup on save failure

Fixed cache key inconsistency bug by clearing cache in loadEnvFile()
and reloading after save to ensure proper cache population.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-03 14:36:50 +08:00
Misaka_Company
7e37a9b1fc fix: use proper logger in ConfigManager backup/restore methods
Replace console.log/console.error with proper logger usage in
backupEnvFile and restoreBackup methods. Use the existing 'log' logger
created with createLogger('ConfigManager') following the same pattern
used in other methods.

Changes:
- Import createLogger and create log instance
- Replace console.log with log.debug in backupEnvFile
- Replace console.error with log.error in both methods
- Pass error and path metadata as objects for structured logging
- Fix test to use correct backup path (process.cwd() + src/main/.env.backup)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-03 14:04:42 +08:00
Misaka_Company
b5ef38f486 feat: add backup and restore mechanism to ConfigManager 2026-03-03 13:50:36 +08:00
Misaka_Company
c06880e946 fix: resolve code quality issues in utility functions
- Fix TypeScript type error in deepMerge recursive call with proper type assertions
- Remove unused type imports (ErpConfig, DatabaseConfig, PathsConfig, ExtractionConfig, ValidationConfig, UiConfig, ExecutionConfig)
- Fix line endings (CRLF to LF) via Prettier format

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-03 12:53:46 +08:00
Misaka_Company
765fb95644 feat: add deep merge and validation utility functions to ConfigManager
This commit adds utility functions to support partial settings save functionality:
- isObject: Type guard for plain objects
- deepMerge: Recursively merges objects, preserving unspecified fields
- validateEditableFields: Validates settings against UI editable field whitelist
- UI_EDITABLE_FIELDS: Whitelist of fields modifiable through UI

A failing test is included to verify the deep merge behavior.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-03 12:42:34 +08:00
Misaka_Company
429357ae8c docs: add implementation plan for settings partial save feature
- 8 detailed tasks with step-by-step instructions
- TDD approach with failing tests first
- Complete code snippets provided
- Manual testing procedures included
- Estimated 2-3 hours implementation time
2026-03-03 12:25:59 +08:00
Misaka_Company
79934a58d0 docs: add settings partial save design document
- Problem: Settings page overwrites unmodified .env fields
- Solution: Deep merge + whitelist validation approach
- Added backup mechanism for safe rollback
- Designed extensible whitelist for future UI expansion
2026-03-03 12:24:27 +08:00
Misaka_Company
b942b2fb15 Merge branch 'fix/cleaner-user-scope' into dev
Fix User scope isolation issue in CleanerPage:

- User users can no longer affect other users' data via "取消" button
- "确认删除" only processes visible filteredResults for non-Admin users
- Admin behavior unchanged (can manage all data)
- Prevents cross-user data interference

Committed: 6ecf03c
2026-03-03 11:14:26 +08:00
Misaka_Company
6ecf03ce11 fix: scope User operations to visible data only in CleanerPage
Fix critical bug where non-Admin users could affect other users' data
when using "取消" (Uncheck All) and "确认删除" (Confirm Deletion) buttons.

Problem:
- User users see only their filtered materials in table (filteredResults)
- "取消" button was unchecking ALL materials in validationResults
- "确认删除" was processing ALL materials, not just visible ones
- This caused User A to delete/modify User B's invisible data

Solution:
1. Modified "取消" button to only uncheck visible filteredResults
   - Now removes selectedItems only for visible material codes
   - Preserves selections for other users' invisible data

2. Modified handleConfirmDeletion to process only visible items for non-Admin users
   - Admin: processes all validationResults (unchanged behavior)
   - User: processes only filteredResults (scoped to their data)

Security Impact:
- Prevents cross-user data interference
- Ensures User scope isolation
- Maintains Admin full access to all data

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-03 11:13:39 +08:00
Misaka_Company
77311edce0 feat: add user override match for material validation in cleaner page
Add Priority 3 matching logic that allows User type users to override
material assignments with their own typeKeywords from MaterialsTypeToBeDeleted.

Changes:
- Add session manager integration to get current user context
- Implement Priority 3: User Override Match (only for non-admin users)
- Filter typeKeywords by current username and force override on match
- Maintain existing Priority 1 (exact match) and Priority 2 (type match) behavior
- Admin users bypass override logic and see original matching results
- Update cleaner-validation-flow.md with new matching algorithm flow

This ensures User users see materials assigned to themselves first when
their configured typeKeywords match, while Admin users maintain full visibility.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-03 10:45:25 +08:00
Misaka_Company
74ddef2d7b fix: clear shared Production IDs before setting new ones
Fixed material validation always using historical order numbers instead of
current input. The setSharedProductionIds function was accumulating IDs
without clearing old ones, causing validation to use all previously entered
order numbers.

Changes:
- Added sharedProductionIds.clear() before adding new IDs
- Ensures Set only contains the latest order numbers from extractor page

This fixes the root cause where changing the order number in the extractor
page would not update the validation data source, as old IDs were never
removed from the shared Set.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-03 10:01:05 +08:00
Misaka_Company
edf696ab39 docs: fix Mermaid syntax error in settings save flow diagram
Replace pipe character in dbType union notation with 'or' to resolve Mermaid parse error.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-03 09:11:35 +08:00
Misaka_Company
6ec422f357 docs: add UI flow analysis documentation and update gitignore
Add documentation for extractor start button and settings save button flows with detailed Mermaid diagrams. Also add logs directory to gitignore.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-03 09:06:33 +08:00
Misaka
a05c8a9037 feat: add TypeORM, logger, schemas, hooks and stores
- Add TypeORM integration with data-source, entities and repositories
- Add logger service for structured logging
- Add Zod validation schemas for auth, cleaner and extractor
- Add custom React hooks (useAuth, useCleaner, useExtractor, useValidation)
- Add Zustand stores (useAppStore, useUserStore)
- Add UI components (Button, Modal, Toast)
- Add error types and ErpBrowserManager
- Refactor IPC handlers and services
- Add unit tests for new modules

Co-Authored-By: Claude (glm-5) <noreply@anthropic.com>
2026-03-02 23:10:15 +08:00
Misaka_Company
982fb8fde6 fix: correct material name type keyword matching logic
Reverse the inclusion check to properly match when materialName contains typeKeyword.materialName (e.g., "ABC123_SPECIAL" contains "ABC123").

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-02 16:20:11 +08:00
Misaka_Company
11aec6d238 docs: fix Mermaid diagram syntax in deleteByMaterialCodes flow
- Remove problematic edge labels with special characters
- Move database type identifiers into node labels
- Replace ellipsis with clearer text descriptions
- Fix parse error caused by spaces and special chars in edge labels

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-02 13:17:38 +08:00
Misaka_Company
9fbf75a8a0 docs: add confirm deletion button flow analysis to cleaner validation docs
Add comprehensive documentation for the "确认删除 (同步数据库)" button
flow, including:

- Core flow overview with Mermaid diagram
- Frontend interaction layer analysis (handleConfirmDeletion)
- IPC handler layer details (upsertBatch, delete handlers)
- Database DAO layer implementation with MySQL vs SQL Server differences
- Data flow diagram showing all layers
- Key data structures and error handling
- Comparison table with validation status flow

The document now covers both core business flows in the cleaner page:
1. Get and validate material status (read operation)
2. Confirm deletion/sync to database (write operation)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-02 12:49:31 +08:00
Misaka_Company
10e07cdf93 docs: add cleaner validation flow analysis with Mermaid diagrams
Add comprehensive technical documentation for the cleaner page
"get validation status" functionality, including:
- Complete flow analysis from UI click to database queries
- Mermaid flowcharts and sequence diagrams
- Material matching algorithm (priority-based)
- Database interaction details (MySQL/SQL Server)
- IPC handler logic
- Shared Production IDs mechanism

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-02 12:29:53 +08:00
Misaka_Company
1aa2abdb43 fix: resolve SQL Server table name mapping and variable naming conflicts
- Fix getTableName() to handle generic schema_tablename pattern
- Convert schema_tablename to [schema].[tablename] for SQL Server
- Replace hardcoded productionContractData table name with helper function
- Fix variable naming conflict: rename 'sql' to 'sqlString' to avoid shadowing mssql module import
- Apply fixes to discrete-material-plan-dao, materials-to-be-deleted-dao, and bip-users-dao

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-02 12:15:11 +08:00
Misaka_Company
679366a4b0 Merge branch 'jules-refactor-ui-tailwind-6294750740902394972' 2026-03-02 11:15:17 +08:00
Misaka_Company
02bc6b24d6 fix: enable text input in extractor page order number textarea
Remove global user-select: none style that was preventing text input
and add explicit user-select: text to the textarea element.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-02 11:13:36 +08:00
Misaka_Company
05d856309f feat: add SQL Server support and refactor database layer for multi-database compatibility
- Add SqlServerService integration alongside existing MySQL support
- Refactor DAOs (DiscreteMaterialPlanDAO, MaterialsToBeDeletedDAO, BipUsersDAO) to support both MySQL and SQL Server
- Update validation handler to dynamically select database service based on DB_TYPE environment variable
- Add connection pooling and proper connection management for SQL Server
- Update package-lock.json dependency peer flags

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-02 11:10:59 +08:00
google-labs-jules[bot]
bbdd1e18f8 Refactor UI layout to match user-provided Tailwind mock
- Integrated Tailwind v4 into the electron.vite.config.ts and main.css.
- Refactored App.tsx layout to use Tailwind styling and Lucide icons as provided.
- Refactored ExtractorPage, CleanerPage, and SettingsPage to match the new UI mock layout while maintaining existing state and logic.
- Simplified SettingsPage based on user feedback.
- Ensured default exports and imports are consistent across pages.

Co-authored-by: luwamgere15-crypto <255338376+luwamgere15-crypto@users.noreply.github.com>
2026-03-02 01:35:27 +00:00
Misaka_Company
d2098a461a docs: add CLAUDE.md with project architecture guidance
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-02 09:01:03 +08:00
Misaka
9caa38caa8 feat: implement settings interface with user permission control
New Features:
- Add SettingsPage component with Admin/User view differentiation
- Create ConfigManager service for .env file management
- Add IPC handlers for settings CRUD operations
- Implement connection testing for ERP and database
- Add settings navigation to main app

Files Created:
- src/main/types/settings.types.ts - Type definitions
- src/main/services/config/config-manager.ts - Config service
- src/main/ipc/settings-handler.ts - IPC handlers
- src/renderer/src/pages/SettingsPage.tsx - React UI component

Files Modified:
- src/main/ipc/index.ts - Register settings handlers
- src/preload/index.ts - Expose settings API
- src/preload/index.d.ts - Add SettingsAPI type
- src/renderer/src/App.tsx - Add settings navigation

Co-Authored-By: Claude (qwen3.5-plus) <noreply@anthropic.com>
2026-03-01 20:51:29 +08:00
Misaka
0f1e0a8908 feat: implement material validation UI in CleanerPage
New Features:
- Material validation from database (full table or filtered by ProductionID)
- Checkbox selection for materials to delete with auto-select for marked items
- Manager-based filtering (Admin only)
- Two-phase deletion: confirm (mark in DB) + execute (delete from ERP)
- Export results to CSV
- Hide/show checked items feature (User mode)
- Share Production IDs between ExtractorPage and CleanerPage
- Persist page state using sessionStorage

Bug Fixes:
- Fix MySQL query rowCount for INSERT/UPDATE/DELETE operations
- Add AUTO_INCREMENT to MaterialsToBeDeleted.ID (preserved 43 records)
- Add UNIQUE constraint on MaterialsToBeDeleted.MaterialCode

Files Added:
- src/main/ipc/validation-handler.ts
- src/main/types/validation.types.ts
- src/main/services/database/materials-to-be-deleted-dao.ts
- src/main/services/database/discrete-material-plan-dao.ts

Files Modified:
- src/renderer/src/pages/CleanerPage.tsx (complete rewrite)
- src/renderer/src/pages/ExtractorPage.tsx
- src/renderer/src/App.tsx (add navigation tabs)
- src/main/services/database/mysql.ts
- src/preload/index.ts + index.d.ts
2026-03-01 19:15:38 +08:00
Misaka
fad08796f7 feat: add user selection dialog for admin users
- Import and integrate UserSelectionDialog component
- Add state for user selection (showUserSelection, allUsers)
- Add isSwitchedByAdmin state to track admin-switched sessions
- Implement handleUserSelect and handleUserSelectionCancel handlers
- Update login flow to show user selection when admin logs in
- Add conditional logout button visibility based on user type
- Update UI to include UserSelectionDialog component
2026-03-01 18:00:52 +08:00
Misaka
829851e3ca feat: implement user authentication system
- Add SessionManager for managing user sessions (singleton pattern)
- Add BIPUsersDAO for database authentication
- Add LoginDialog component for username/password login
- Add UserSelectionDialog component for admin user selection
- Support silent login by computer name
- Implement main page with navigation to Extractor and Cleaner

Database:
- Table: dbo_BIPUsers
- Fields: UserName, Password, UserType, ComputerNmae

UI Flow:
1. Silent login on startup via computer name
2. Show login dialog if silent login fails
3. Display main page with user info and navigation
4. Support logout and re-login
2026-03-01 17:46:15 +08:00
Misaka
450eb41f96 feat: support productionID input for order number resolution
- Add OrderNumberResolver service to auto-recognize productionID and 生产订单号 formats
- Integrate MySQL database lookup for productionID to 生产订单号 conversion
- Update OrderNumberInput component with format statistics display
- Modify Extractor and Cleaner to resolve order numbers before processing

Database configuration:
- Table: productionContractData_26 年压力表合同数据
- Fields: 总排号 (productionID), 生产订单号 (production order number)

Supported formats:
- productionID: 2 digits + 1 letter + serial number (e.g., 26B742)
- 生产订单号:SC + 14 digits (e.g., SC70202601040109)
2026-03-01 17:00:10 +08:00
Misaka
079d3aeff6 chore: prepare release v1.0.0
- Fix TypeScript type errors in erp-auth.ts and excel-parser.ts
- Update tsconfig.node.json to relax type checking for build
- Build Windows installer (dist/erpauto-1.0.0-setup.exe)
- Add .gitignore entries for dist files

Installation:
1. Download erpauto-1.0.0-setup.exe
2. Run installer
3. Configure ERP credentials in %APPDATA%\erpauto\.env
4. Launch application from desktop shortcut

Build commands:
- npm run build:win - Build Windows installer
- npm run build:mac - Build macOS app
- npm run build:linux - Build Linux packages
2026-03-01 16:14:37 +08:00
Misaka
e04337dc19 fix: improve error handling and logging for extractor and cleaner
- Load .env file in main process using dotenv
- Add detailed console logging for debugging
- Add validation for ERP configuration before extraction
- Improve error display in UI with selectable text
- Add stack trace logging for better debugging
2026-03-01 16:07:47 +08:00
Misaka
e794470d69 docs: add comprehensive user documentation
- Add README.md with installation, configuration, and usage guide
- Add docs/USER_GUIDE.md with detailed step-by-step workflows
- Include database setup scripts for MySQL and SQL Server
- Add troubleshooting section with common issues
2026-03-01 15:57:07 +08:00
Misaka
484ca31d79 test: add E2E test for Extractor workflow using Playwright
- Create tests/e2e/extractor-workflow.test.ts with full workflow tests
- Add playwright.config.ts for E2E test configuration
- Add npm scripts: test:e2e, test:e2e:ui, test:e2e:report
- Update vitest.config.ts to exclude E2E tests
2026-03-01 15:53:03 +08:00
Misaka
cb2a5a84b2 feat: implement CleanerPage UI with MaterialCodeInput component
- Add MaterialCodeInput component for entering material codes
- Create CleanerPage with dry-run mode support
- Add Cleaner page navigation in App.tsx
- Update CleanerAPI type to return response wrapper
2026-03-01 15:50:13 +08:00
Misaka
5760b56f70 feat: implement IPC handlers, database services and Extractor UI
- Add SqlServerService and MySqlService for database persistence
- Implement IPC handlers for file, extractor, cleaner, and database operations
- Define IPC API types and update preload script
- Create ExtractorPage UI with OrderNumberInput component
- Add unit and integration tests for MySQL and SQL Server
- Update vitest config with path aliases
2026-03-01 15:46:01 +08:00
Misaka
c39e1504aa style: apply prettier formatting
Apply consistent formatting across all files (line endings, spacing)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-01 14:57:38 +08:00
Misaka
8f6ca7b453 feat: implement CleanerService for ERP material deletion
**Core Implementation (src/main/services/erp/cleaner.ts):**
- CleanerService class with dry-run mode support
- Material deletion logic with safety constraints:
  - Row numbers 7000-7999 are protected
  - Materials with pending quantity are skipped
  - Materials not in delete list are ignored
- Order processing with nested iframe navigation
- Progress callback support for UI integration

**Types (src/main/types/cleaner.types.ts):**
- CleanerInput: order numbers, material codes, dry-run flag
- CleanerResult: processing statistics and details
- OrderCleanDetail: per-order breakdown

**Tests:**
- Unit tests for shouldDeleteMaterial logic
- Integration tests for order processing
- Dry-run mode validation
- Navigation tests

Reference: playwrite/utils/discrete_material_plan_cleaner.py

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-01 14:53:49 +08:00
Misaka
937ad85e9b chore: update .gitignore to exclude test outputs and debug scripts
Add patterns to ignore temporary files generated during development:
- Test coverage reports (coverage/)
- Downloaded test files (downloads/, *.xlsx, *.parsed.json)
- Debug and manual test scripts (tests/debug/, tests/manual/)
- Temporary test scripts (test-*.mjs, test-*.js, compare_*.js)

This keeps the repository clean while preserving useful test scripts
locally for debugging purposes.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-01 14:24:33 +08:00
Misaka
b22f4995ba feat: implement ERP authentication, data extraction, and Excel parsing
This commit completes the core ERP automation functionality, migrating
from Python Playwright to TypeScript while maintaining full compatibility
with the original implementation.

**ERP Authentication Service (erp-auth.ts):**
- Implement login() with role-based locators for form elements
- Add SSL certificate bypass for internal VPN network
- Handle force login confirmation dialogs
- Return session with mainFrame reference for subsequent operations
- Add session lifecycle management (close, getSession, isActive)

**Data Extractor Service (extractor.ts):**
- Implement precise nested iframe navigation (#forwardFrame → #mainiframe)
- Add batch processing support for multiple order numbers
- Implement order number filling with comma separation
- Handle material selection and download workflows
- Successfully tested with 300 orders in 5 batches

**Excel Parser Service (excel-parser.ts):**
- Fix ExcelJS 1-indexed array access (row[1] for 序号, row[2] for 材料编码)
- Add dynamic table header search to handle empty row skipping
- Add field mapping: "来源单号" → "productionOrder"
- Implement saveAsExcel() method compatible with Python format
- Validate compatibility: 527 rows, 69 orders matching Python output

**Type Definitions (erp.types.ts):**
- Add headless property to ErpConfig for browser mode control
- Add mainFrame reference to ErpSession for frame reuse

**Integration Tests (extractor.test.ts):**
- Modify tests to use independent auth services for isolation
- Add test with 300 orders and batch size 70
- All tests passing with real ERP data

**Test Configuration (vitest.config.ts):**
- Add setupFiles configuration for environment variable loading

**Testing Results:**
-  Successfully logs in to ERP system
-  Processes 300 orders in 5 batches (43.59 seconds)
-  Downloads 5 Excel files (347.62 KB total)
-  Parses 2,131 material plans from 280 unique orders
-  Excel output matches Python format exactly

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-01 14:22:14 +08:00
Misaka
65bf79fa44 fix: add missing spec-compliant methods and fields to Excel parser
Add missing spec-compliant methods and fields while keeping the working
implementation that correctly handles the complex Excel structure.

Changes:
- Add public isOrderRow() method to detect order title rows
- Add public extractOrderNumber() method to extract order numbers
- Add public parseMaterialRow() method for spec compliance
- Rename internal parseMaterialRow() to parseMaterialRowInternal()
- Add missing pendingQty field to DiscreteMaterialPlan type

All tests pass (23/23).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-01 13:08:04 +08:00
Misaka
d9e0091602 feat: implement Excel parser service
Implement Excel parsing module for ERP exported files following TDD principles.

Key features:
- Parse Excel files with multiple orders per file
- Extract order header information (production order, product code, etc.)
- Extract material data rows with 13 fields
- Handle empty orders gracefully
- Detect footer rows (制单人/打印人)
- Map Chinese field names to English property names
- Support field name mapping from Python reference

Implementation:
- ExcelParser class with parse() method
- DiscreteMaterialPlan and ExcelParseOptions types
- OrderHeader interface for order metadata
- Test fixtures with realistic Excel structure
- Comprehensive unit tests (3 tests, all passing)

Reference: playwrite/utils/excel_converter.py

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-01 13:01:50 +08:00
Misaka
f6b19b50f3 feat: implement Extractor service with download capability
Implement Task 3.1: Core Extractor Logic with TDD approach.

Changes:
- Add ExtractorService class with batch processing and download support
- Update ERP_LOCATORS with extractor-specific selectors from Python reference
- Add integration tests for single and multiple order extraction
- Add unit tests for batch creation logic
- Update existing tests to skip gracefully without ERP credentials

Features:
- Navigate to discrete material plan page with nested iframes
- Setup query interface (search icon, order query, limit settings)
- Batch download with configurable batch size (default: 100)
- Progress callback support for real-time updates
- Error handling for individual batch failures
- File download handling with proper wait strategies

Reference: playwrite/utils/discrete_material_plan_extractor.py

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-01 12:46:15 +08:00
Misaka
f045d66b65 feat: implement ERP authentication service
Implement ErpAuthService with TDD approach:
- Add login(), close(), getSession(), isActive() methods
- Use Playwright chromium with headless:false for debugging
- Manage browser lifecycle and session state
- Handle authentication flow with ERP_LOCATORS
- Add integration tests for login scenarios
- Add unit tests for session management
- Fix dotenv config path in test setup

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-01 12:34:54 +08:00
Misaka
87f6229d49 Update package-lock.json
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-01 12:22:26 +08:00
Misaka
c66dc2ecf8 feat: define ERP page element locators
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-01 12:21:58 +08:00
Misaka
9ac8707921 feat: configure Vitest testing framework
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-01 12:12:33 +08:00
Misaka
3222e03cea feat: define core TypeScript interfaces
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-01 11:53:59 +08:00
Misaka
4364c71bf9 fix: resolve environment configuration issues from code review
- Remove UTF-8 BOM from .env file
- Add missing ERP settings (ERP_HEADLESS, ERP_IGNORE_HTTPS_ERRORS, ERP_AUTO_CLOSE_BROWSER) to .env.example
- Add legacy configurations to .env.example

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-01 11:51:46 +08:00
Misaka
752cfc28a1 feat: add environment configuration template
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-01 11:44:59 +08:00
Misaka
82d3130188 feat: add core dependencies for Playwright migration
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-01 11:35:01 +08:00
Misaka
9ef1c3baa2 Update .gitignore to include .env and ensure .claude is ignored 2026-03-01 11:14:08 +08:00
168 changed files with 52824 additions and 100 deletions

18
.gitattributes vendored Normal file
View File

@@ -0,0 +1,18 @@
* text=auto
# Force Unix line endings for source files
*.md text eol=lf
*.ts text eol=lf
*.js text eol=lf
*.json text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.tsx text eol=lf
*.jsx text eol=lf
*.css text eol=lf
*.scss text eol=lf
*.html text eol=lf
# Force Windows line endings for build artifacts
*.bat text eol=crlf
*.cmd text eol=crlf

39
.gitignore vendored
View File

@@ -4,6 +4,41 @@ out
.DS_Store
.eslintcache
*.log*
.npmrc
#AI Agent
.claude
# AI Agent
.claude
# Environment
.env
# Test outputs
coverage/
downloads/
*.xlsx
*.parsed.json
# Test scripts (debug and manual)
tests/debug/
tests/manual/
test-*.mjs
test-*.js
compare_*.js
# logs
logs
# oh my opencode
.sisyphus
# Runtime config files
*.yaml
*.yaml.backup
# But keep config.template.yaml
!config.template.yaml
# nul
nul
# TypeScript incremental compilation cache
*.tsbuildinfo

176
BROWSER_DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,176 @@
# Playwright 浏览器部署指南
## 概述
本文档为开发人员提供 ERPAuto 应用程序中 Playwright Chromium 浏览器的部署指南。文档说明如何将浏览器文件从开发机复制到目标用户机器,确保应用程序能够正常运行浏览器自动化任务。
**重要提示**:部署前请先阅读 [BROWSER_VERSIONS.md](./BROWSER_VERSIONS.md) 了解版本信息。
## 目标路径
浏览器文件必须部署在以下路径:
```
%APPDATA%\erpauto\ms-playwright\chromium-1208\
```
完整展开路径示例Windows
```
C:\Users\<用户名>\AppData\Roaming\erpauto\ms-playwright\chromium-1208\
```
## 目录结构
部署完成后,目标目录结构应如下所示:
```
%APPDATA%\erpauto\ms-playwright\
└── chromium-1208/
└── chrome-win/
├── chrome.exe # 浏览器可执行文件
├── chrome_dll.dll
├── resources/
├── locales/
└── ... # 其他浏览器文件
```
**关键文件**`chrome-win/chrome.exe` 必须存在,否则浏览器无法启动。
## 部署步骤
### 步骤 1在开发机上准备
1. 确保开发机已安装正确版本的 Playwright
```bash
npm install playwright@1.58.2
```
2. 下载 Chromium 浏览器:
```bash
npx playwright install chromium
```
3. 定位开发机上的浏览器缓存目录:
```
C:\Users\<开发机用户名>\AppData\Local\ms-playwright\chromium-1208
```
### 步骤 2复制文件
1. **复制整个浏览器目录**
- 将开发机上的 `chromium-1208` 目录完整复制
- 不要只复制部分文件,确保所有子目录和文件都包含在内
2. **粘贴到目标路径**
- 在目标机器上创建目录:`%APPDATA%\erpauto\ms-playwright\`
-`chromium-1208` 目录粘贴到该路径下
3. **验证文件完整性**
- 确认目标路径存在:`%APPDATA%\erpauto\ms-playwright\chromium-1208\chrome-win\chrome.exe`
- 检查文件大小约为 280MB
### 步骤 3配置环境变量可选
如需确保应用程序使用正确的浏览器路径,可设置以下环境变量:
```batch
set PLAYWRIGHT_BROWSERS_PATH=%APPDATA%\erpauto\ms-playwright
```
或在应用程序代码中设置:
```javascript
process.env.PLAYWRIGHT_BROWSERS_PATH = path.join(app.getPath('userData'), 'ms-playwright')
```
## 验证步骤
部署完成后,执行以下验证步骤:
### 验证 1检查目录结构
在目标机器上运行:
```batch
dir %APPDATA%\erpauto\ms-playwright\chromium-1208\chrome-win\chrome.exe
```
应显示文件存在。
### 验证 2启动浏览器测试
运行 ERPAuto 应用程序,执行以下操作:
1. 登录应用程序
2. 进入「数据提取」页面
3. 输入一个有效订单号
4. 点击「开始提取」
5. 观察浏览器是否正常启动并执行任务
### 验证 3检查日志
查看应用程序日志,确认没有浏览器相关的错误信息:
- 无 "browser not found" 错误
- 无 "chromium revision not found" 错误
- 无 "PLAYWRIGHT_BROWSERS_PATH" 相关警告
## 故障排查
### 问题 1浏览器无法启动
**症状**:应用程序报错,提示找不到浏览器或启动失败。
**解决方案**
1. 确认修订号匹配(必须是 1208
2. 检查 `chrome-win/chrome.exe` 文件是否存在
3. 验证 `PLAYWRIGHT_BROWSERS_PATH` 环境变量设置正确
4. 确认目标机器具有相同的 Playwright 版本1.58.2
### 问题 2版本不匹配错误
**症状**:应用程序启动时报出版本冲突错误。
**解决方案**
1. 检查 `package.json` 中的 Playwright 版本是否为 1.58.2
2. 确认复制的 Chromium 修订号为 1208
3. 参考 [BROWSER_VERSIONS.md](./BROWSER_VERSIONS.md) 核对所有版本信息
### 问题 3权限不足
**症状**:无法写入或读取浏览器目录。
**解决方案**
1. 确保目标目录具有适当的读写权限
2. 以管理员身份运行应用程序进行测试
3. 检查防病毒软件是否阻止了浏览器执行
### 问题 4路径错误
**症状**:应用程序在错误的位置查找浏览器文件。
**解决方案**
1. 确认 `%APPDATA%` 环境变量指向正确的用户目录
2. 检查应用程序是否正确解析了 `userData` 路径
3. 在代码中硬编码浏览器路径进行调试
## 注意事项
- **仅部署 Chromium**ERPAuto 只需要 Chromium 浏览器,不需要 Firefox 或 WebKit
- **版本一致性**:开发机和目标机器的 Playwright 版本必须一致
- **修订号匹配**Chromium 修订号1208必须完全匹配否则可能出现兼容性问题
- **文件完整性**:复制时确保所有文件完整,损坏的浏览器文件会导致启动失败
- **网络隔离环境**:目标机器如果无法访问互联网,必须提前部署浏览器文件,因为无法自动下载
## 参考文档
- [BROWSER_VERSIONS.md](./BROWSER_VERSIONS.md) - 版本信息和目录结构详情
- [README.md](./README.md) - 项目总体说明

96
BROWSER_VERSIONS.md Normal file
View File

@@ -0,0 +1,96 @@
# Playwright 浏览器版本信息
本文档记录 ERPAuto 项目使用的 Playwright 浏览器版本和部署信息。
## 版本信息
| 组件 | 版本号 |
| --------------- | ------------ |
| Playwright | 1.58.2 |
| Chromium | 145.0.7632.6 |
| Chromium 修订号 | 1208 |
## 浏览器目录结构
Playwright 将浏览器文件缓存在以下位置:
### Windows 开发环境
```
C:\Users\<用户名>\AppData\Local\ms-playwright\
└── chromium-1208/
└── chrome-win/
├── chrome.exe
└── ...
```
### 目标部署环境
```
%APPDATA%\erpauto\ms-playwright\
└── chromium-1208/
└── chrome-win/
├── chrome.exe
└── ...
```
## 部署指南
### 开发环境准备
1. 安装 Playwright 1.58.2
```bash
npm install playwright@1.58.2
```
2. 下载 Chromium 浏览器
```bash
npx playwright install chromium
```
### 浏览器文件复制步骤
1. **定位源目录**
- 开发机上找到 Playwright 浏览器缓存目录
- 默认路径:`C:\Users\<用户名>\AppData\Local\ms-playwright\chromium-1208`
2. **复制浏览器文件**
- 将整个 `chromium-1208` 目录复制到部署目标
- 目标路径:`%APPDATA%\erpauto\ms-playwright\chromium-1208`
3. **验证目录结构**
- 确认目标路径包含 `chrome-win/chrome.exe`
- 确保所有子文件完整复制
### 环境变量配置
如需要自定义浏览器路径,可设置环境变量:
```bash
# Windows
set PLAYWRIGHT_BROWSERS_PATH=%APPDATA%\erpauto\ms-playwright
```
## 注意事项
- 仅包含 Chromium 浏览器Firefox 和 WebKit 不需要)
- 浏览器文件体积约为 280MB
- 部署时确保目标机器具有相同的 Playwright 版本1.58.2
- 修订号必须匹配1208否则可能出现兼容性问题
## 故障排查
### 浏览器无法启动
1. 检查修订号是否匹配1208
2. 确认 `chrome-win/chrome.exe` 文件存在
3. 验证 PLAYWRIGHT_BROWSERS_PATH 环境变量设置
### 版本不匹配错误
确保以下版本一致:
- package.json 中的 Playwright 版本
- 下载的 Chromium 修订号
- browsers.json 中定义版本号

141
CLAUDE.md Normal file
View File

@@ -0,0 +1,141 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Development Commands
### Running the Application
```bash
npm run dev # Start development server with hot reload
npm run build # Full build with type checking
npm run build:win # Build Windows executable
```
### Code Quality
```bash
npm run lint # ESLint check
npm run format # Prettier format
npm run typecheck # TypeScript check (both main and renderer)
npm run typecheck:node # TypeScript check for main process only
npm run typecheck:web # TypeScript check for renderer only
```
### Testing
```bash
npm run test # Run unit tests (Vitest)
npm run test:coverage # Run tests with coverage report
npm run test:e2e # Run E2E tests (Playwright)
npm run test:e2e:ui # Run E2E tests with UI
npm run test:e2e:report # Show E2E test report
```
## Architecture Overview
ERPAuto is an **Electron desktop application** for automating ERP system data processing. The application follows the classic Electron architecture with three distinct processes:
### Process Structure
1. **Main Process** (`src/main/`)
- Node.js environment managing application lifecycle
- Entry point: `src/main/index.ts`
- Registers all IPC handlers via `registerIpcHandlers()`
- Loads configuration from `config.yaml` via ConfigManager at startup
2. **Preload Script** (`src/preload/`)
- Security bridge between main and renderer processes
- Exposes type-safe APIs via `contextBridge` as `window.electron` and `window.api`
- Central API surface organized by domain (auth, extractor, cleaner, database, etc.)
3. **Renderer Process** (`src/renderer/`)
- React 19 + TypeScript UI
- Uses exposed preload APIs for all main process communication
- Authentication-based routing with role-based access control
### Service Architecture
The main process is organized around domain-specific services in `src/main/services/`:
- **ERP Services** (`services/erp/`): Browser automation using Playwright
- `ExtractorService` - Downloads material plan data
- `CleanerService` - Deletes specified materials with dry-run support
- `ErpAuthService` - Handles ERP authentication
- `OrderResolverService` - Validates and resolves order numbers
- `locators.ts` - ERP element selectors
- **Database Services** (`services/database/`): Dual database support
- `MySqlService` / `mysql.ts` - MySQL operations
- `SqlServerService` / `sql-server.ts` - SQL Server operations
- DAO pattern: `discrete-material-plan-dao.ts`, `materials-to-be-deleted-dao.ts`
- **User Services** (`services/user/`): Authentication and session management
- `BipUsersDao` - User data access
- `SessionManager` - Active session tracking
- **Other Services**:
- `config/` - Configuration management
- `excel/` - Excel file parsing
### IPC Handler Pattern
All IPC communication follows a consistent pattern:
- Handlers are in `src/main/ipc/`, organized by domain (8 modules)
- Each handler module exports a `register*Handlers()` function
- All handlers are registered in `src/main/ipc/index.ts`
- Channel naming follows `domain:action` convention (e.g., `extractor:run`, `auth:login`)
### Authentication Flow
The application implements a multi-stage authentication system:
1. **Silent Login**: On startup, attempts automatic login using computer name
2. **Fallback**: Shows login dialog if silent login fails
3. **Admin User Selection**: Admin users can switch to other user accounts
4. **Session Management**: Persistent sessions with role-based permissions (Admin/User/Guest)
Admin users see logout buttons and can access user switching. Non-admin users have restricted access based on the user who initiated their session.
### Type System
- Separate TypeScript configs: `tsconfig.node.json` (main/preload) and `tsconfig.web.json` (renderer)
- Types are co-located with features: `src/main/types/` contains domain-specific type definitions
- The preload script exposes a typed API surface that's available in renderer
### Path Aliases
- `@renderer``src/renderer/src` (renderer process)
- `@main``src/main` (main process, tests only)
- `@services``src/main/services` (main process, tests only)
- `@types``src/main/types` (main process, tests only)
## Configuration Management
The application uses a YAML-based configuration system (`config.yaml`) managed by `ConfigManager`:
- **Development**: `config.yaml` in project root (easy to edit and version control)
- **Production**: `config.yaml` in user data directory (AppData on Windows)
Key configurations in `config.yaml`:
- **ERP Settings**: URL (fixed infrastructure)
- **Database**: MySQL and SQL Server connection configs (dual support)
- **Paths**: Data directory and output file settings
- **Extraction**: Batch size, verbosity, persistence options
- **Validation**: Data source, batch size, match mode
- **Order Resolution**: Database table and field names for order number lookup
Note: ERP credentials (username/password) are stored in the database (`dbo_BIPUsers` table) per user, managed via the Settings UI.
## Key Technologies
- **Electron 39** - Desktop framework
- **React 19** - UI framework
- **TypeScript 5.9** - Type safety
- **Playwright 1.58** - Browser automation for ERP interaction
- **electron-vite + Vite 7** - Build tooling
- **Zod** - Runtime validation
- **Vitest** - Unit tests
- **Playwright Test** - E2E tests

101
PLAYWRIGHT_DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,101 @@
# Playwright 部署说明
## 浏览器路径问题修复
### 问题描述
应用启动时提示"浏览器文件未找到",但浏览器文件已经放置在正确路径下。
**原因**Playwright 1.48+ 改变了浏览器目录命名规则:
- **旧格式**`chromium-win32/chrome.exe`
- **新格式**`chromium-1208/chrome-win64/chrome.exe`revision 号可能不同)
### 解决方案
代码已更新为自动检测新旧两种格式,并显示当前目录内容以便调试。
## 快速部署
### 方法 1使用 Playwright CLI推荐
```bash
# 在应用目录运行
npx playwright install chromium
```
浏览器会自动下载并安装到正确位置:
- **用户数据目录**`%APPDATA%\erpauto\ms-playwright\`
- **完整路径**`C:\Users\pengq\AppData\Roaming\erpauto\ms-playwright\chromium-<revision>\chrome-win64\chrome.exe`
### 方法 2手动复制
如果你已经有 Playwright 浏览器文件,可以复制到应用的用户数据目录:
1. 找到现有浏览器文件(通常在 `%USERPROFILE%\AppData\Local\ms-playwright`
2. 复制到 `%APPDATA%\erpauto\ms-playwright\`
3. 确保目录结构正确:
```
ms-playwright/
├── chromium-1208/
│ ├── chrome-win64/
│ │ └── chrome.exe
│ └── INSTALLATION_COMPLETE
└── chromium_headless_shell-1208/
└── ...
```
### 方法 3使用 PLAYWRIGHT_BROWSERS_PATH 环境变量
将浏览器文件放在共享位置,然后设置环境变量:
```bash
# 系统环境变量
setx PLAYWRIGHT_BROWSERS_PATH "D:\shared\playwright-browsers"
```
或在应用启动脚本中设置。
## 验证安装
运行应用后,检查是否还有错误提示。如果没有浏览器错误,说明安装成功。
你也可以在应用日志中查找:
- `Found Chromium revision: chromium-1208` - 表示成功找到浏览器
- `Playwright browser not found` - 表示未找到,会显示可用目录列表
## 常见问题
### Q: 显示"浏览器文件未找到"但文件确实在那里
检查目录结构是否正确:
```powershell
# 查看当前目录内容
Get-ChildItem $env:APPDATA\erpauto\ms-playwright
# 检查 chrome.exe 是否存在
Test-Path "$env:APPDATA\erpauto\ms-playwright\chromium-*/chrome-win64/chrome.exe"
```
### Q: 不同用户使用同一个浏览器文件
使用环境变量 `PLAYWRIGHT_BROWSERS_PATH` 指向共享目录。
### Q: 离线部署
1. 在有网络的机器上运行 `npx playwright install chromium`
2. 复制整个 `ms-playwright` 目录
3. 在目标机器上设置 `PLAYWRIGHT_BROWSERS_PATH` 指向该目录
## 下次构建
只需运行:
```bash
npm run build:win
```
所有配置已保存Playwright 模块和浏览器路径检查会自动处理。

189
README.md
View File

@@ -1,34 +1,191 @@
# erpauto
# ERPAuto - ERP 数据自动化处理工具
An Electron application with React and TypeScript
一个基于 Electron 的桌面应用程序,用于自动化处理 ERP 系统中的数据提取和清理任务。
## Recommended IDE Setup
## 功能特性
- [VSCode](https://code.visualstudio.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
- **数据提取**:从 ERP 系统批量下载物料计划数据
- **物料清理**:自动删除指定的物料代码,支持干运行模式
- **数据库支持**:支持 MySQL 和 SQL Server 数据存储
- **Excel 解析**:自动解析下载的 Excel 文件
## Project Setup
## 快速开始
### Install
### 环境要求
- Node.js >= 18
- npm >= 9
- 可访问的 ERP 系统
### 安装
```bash
$ npm install
# 克隆项目
git clone <repository-url>
cd ERPAuto
# 安装依赖
npm install
```
### Development
### 配置
在项目根目录创建 `config.yaml` 文件(可参考 `config.template.yaml`
```yaml
# ERP 配置(固定基础设施)
erp:
url: https://your-erp-server.com
# 数据库配置
database:
activeType: mysql # 或 sqlserver
mysql:
host: localhost
port: 3306
database: erpauto
username: root
password: your_password
charset: utf8mb4
sqlserver:
server: localhost
port: 1433
database: erpauto
username: sa
password: your_password
driver: 'ODBC Driver 18 for SQL Server'
trustServerCertificate: true
# 路径配置
paths:
dataDir: './data/'
defaultOutput: 'output.xlsx'
validationOutput: 'validation-result.xlsx'
```
**注意**ERP 用户名和密码在应用的设置界面中配置,存储在数据库中(按用户管理)。
### 运行开发环境
```bash
$ npm run dev
npm run dev
```
### Build
### 构建应用
```bash
# For windows
$ npm run build:win
# Windows
npm run build:win
# For macOS
$ npm run build:mac
# macOS
npm run build:mac
# For Linux
$ npm run build:linux
# Linux
npm run build:linux
```
## 使用指南
### 数据提取
1. 启动应用后,点击主页的「数据提取」进入提取页面
2. 在订单号输入框中输入订单号,每行一个
3. 设置批量大小(默认 100
4. 点击「开始提取」按钮
5. 等待提取完成,查看结果
### 物料清理
1. 点击主页的「物料清理」进入清理页面
2. 输入订单号(每行一个)
3. 输入要删除的物料代码(每行一个)
4. 勾选「干运行模式」可预览删除结果(不实际删除)
5. 点击「开始清理」按钮
6. 查看清理结果和详细统计
## 测试
```bash
# 运行单元测试
npm run test
# 运行 E2E 测试
npm run test:e2e
# 查看测试报告
npm run test:e2e:report
```
## 项目结构
```
ERPAuto/
├── src/
│ ├── main/ # 主进程代码
│ │ ├── services/ # 业务服务
│ │ ├── ipc/ # IPC 处理器
│ │ └── types/ # TypeScript 类型
│ ├── preload/ # 预加载脚本
│ └── renderer/ # 渲染进程React UI
├── tests/
│ ├── unit/ # 单元测试
│ ├── integration/ # 集成测试
│ └── e2e/ # E2E 测试
└── docs/ # 文档
```
## 技术栈
- **框架**Electron 39
- **前端**React 19 + TypeScript
- **构建工具**electron-vite
- **浏览器自动化**Playwright
- **数据库**mysql2, mssql
- **Excel 处理**ExcelJS
- **测试**Vitest, Playwright Test
## 常见问题
### 无法连接 ERP 系统
1. 检查 `config.yaml` 中的 ERP URL 是否正确
2. 确认网络连接正常
3. 检查 ERP 系统是否可访问
4. 在设置界面中确认 ERP 用户名和密码已配置
### 提取失败
1. 确认订单号格式正确
2. 检查 ERP 系统账号权限
3. 查看应用日志获取详细错误信息
### 数据库连接失败
1. 确认数据库服务已启动
2. 检查 `config.yaml` 中的数据库配置
3. 确认防火墙允许数据库端口访问
## 开发
```bash
# 安装依赖
npm install
# 启动开发服务器
npm run dev
# 类型检查
npm run typecheck
# 代码格式化
npm run format
# Lint 检查
npm run lint
```
## 许可证
MIT License

72
config.template.yaml Normal file
View File

@@ -0,0 +1,72 @@
# ================================
# ERPAuto 配置模板
# ================================
# 部署说明:
# 1. 复制此文件为 config.yaml
# 2. 根据实际环境修改配置值
# 3. 设置 database.activeType 为 mysql 或 sqlserver
# ================================
# 注意ERP 认证信息存储在数据库 (dbo_BIPUsers) 中,按用户管理
# ================================
database:
activeType: mysql
mysql:
host: <MYSQL_HOST>
port: 3306
database: <DATABASE_NAME>
username: <USERNAME>
password: <PASSWORD>
charset: utf8mb4
sqlserver:
server: <SQL_SERVER_HOST>
port: 1433
database: <DATABASE_NAME>
username: <USERNAME>
password: <PASSWORD>
driver: 'ODBC Driver 18 for SQL Server'
trustServerCertificate: true
paths:
dataDir: './data/'
defaultOutput: 'output.xlsx'
validationOutput: 'validation-result.xlsx'
extraction:
batchSize: 100
verbose: true
autoConvert: true
mergeBatches: true
enableDbPersistence: true
validation:
dataSource: database_full
batchSize: 2000
matchMode: substring
enableCrud: false
defaultManager: ''
orderResolution:
tableName: ''
productionIdField: ''
orderNumberField: ''
cleaner:
queryBatchSize: 100
processConcurrency: 1
logging:
level: info
auditRetention: 30
appRetention: 14
# RustFS 对象存储配置(用于持久化报告)
rustfs:
enabled: false # 设置为 true 启用 RustFS 上传
endpoint: 'http://192.168.110.114:9000' # RustFS 服务器地址
accessKey: '<YOUR_ACCESS_KEY>' # 访问密钥
secretKey: '<YOUR_SECRET_KEY>' # 密钥
bucket: 'erpauto' # 存储桶名称
region: 'us-east-1' # 区域S3 兼容,默认即可)

View File

@@ -0,0 +1,300 @@
# ERPAuto 配置文件位置说明
## 概述
ERPAuto 根据运行环境自动选择配置文件的存储位置:
- **开发环境**:项目根目录(方便编辑和版本控制)
- **生产环境**用户数据目录AppData安全且升级时保留
---
## 配置文件位置
### 1. 开发环境
**适用场景**:
- 开发和调试
- 配置需要版本控制
- 团队协作
**配置文件位置**:
```
<项目根目录>\config.yaml
```
**示例**:
```
D:\Projects\ERPAuto\
├── src\
├── package.json
├── config.yaml # 开发配置
├── config.yaml.backup # 自动备份
└── config.template.yaml # 配置模板
```
**检测方式**:
```typescript
process.env.NODE_ENV === 'development' || !app.isPackaged
```
---
### 2. 生产环境(安装版和便携版)
**适用场景**:
- 正式发布的应用
- 配置需要在应用升级时保留
- 多用户环境,每个用户独立配置
**配置文件位置**:
```
Windows: C:\Users\<用户名>\AppData\Roaming\erpauto\config.yaml
macOS: ~/Library/Application Support/erpauto/config.yaml
Linux: ~/.config/erpauto/config.yaml
```
**示例**:
```
C:\Users\zhangsan\AppData\Roaming\erpauto\
├── config.yaml # 用户配置
└── config.yaml.backup # 自动备份
```
**检测方式**:
```typescript
app.isPackaged === true
```
---
## 为什么生产环境使用用户数据目录?
| 方案 | 配置位置 | 优点 | 缺点 |
| ------------------ | --------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| **用户数据目录** ✓ | AppData\Roaming | • 应用升级时配置保留<br>• 符合 Windows 规范<br>• 多用户隔离<br>• 配置不暴露 | • 路径较深,不易访问 |
| **应用同目录** ✗ | .exe 同目录 | • 易于访问和编辑 | • 应用升级时配置可能丢失<br>• 需要写权限<br>• 配置暴露在应用目录<br>• 多用户共享配置 |
**我们的选择**:生产环境统一使用用户数据目录,确保:
1. ✅ 应用升级时用户配置不会丢失
2. ✅ 符合 Windows 应用规范
3. ✅ 配置不暴露在应用目录,更安全
4. ✅ 多用户环境下,每个用户有独立配置
---
## 构建配置
### electron-builder.yml
```yaml
win:
target:
- nsis # 安装版
- portable # 便携版
portable:
artifactName: ${name}-${version}-portable.${ext}
# 便携版也使用用户数据目录 (AppData)
# 不是 exe 同目录,确保配置在升级时保留
nsis:
artifactName: ${name}-${version}-setup.${ext}
```
### 构建命令
```bash
# 构建 Windows 安装版和便携版
npm run build:win
```
### 输出文件
```
dist/
├── erpauto-1.0.0-setup.exe # 安装版
└── erpauto-1.0.0-portable.exe # 便携版
```
---
## 配置文件结构
```yaml
# ================================
# ERPAuto 配置文件
# ================================
# 数据库配置
database:
activeType: mysql # 切换字段mysql 或 sqlserver
mysql:
host: 192.168.31.83
port: 3306
database: BLD_DB
username: remote_user
password: ''
charset: utf8mb4
sqlserver:
server: localhost
port: 1433
database: BLD_DB
username: sa
password: ''
driver: 'ODBC Driver 18 for SQL Server'
trustServerCertificate: true
# 路径配置
paths:
dataDir: 'D:/python/playwrite/data/'
defaultOutput: '离散备料计划维护_合并.xlsx'
validationOutput: '物料状态校验结果.xlsx'
# 数据提取配置
extraction:
batchSize: 100
verbose: true
autoConvert: true
mergeBatches: true
enableDbPersistence: true
# 校验配置
validation:
dataSource: database_full
batchSize: 2000
matchMode: substring
enableCrud: false
defaultManager: ''
# 订单号解析配置
orderResolution:
tableName: 'productionContractData_26 年压力表合同数据'
productionIdField: '总排号'
orderNumberField: '生产订单号'
```
---
## 配置文件管理
### 查看当前配置路径
运行调试工具:
```bash
npx tsx src\main\tools\config-path-debug.ts
```
### 快速访问配置Windows
```bash
# 打开配置所在目录
%APPDATA%\erpauto
```
### 备份配置
```bash
# 备份整个配置目录
xcopy %APPDATA%\erpauto D:\Backup\erpauto-config /E /I
```
### 迁移配置
从旧版本迁移:
```bash
# 使用迁移脚本
npx tsx scripts\migrate-env-to-yaml.ts
```
---
## 常见问题
### Q: 便携版应用的配置为什么不放在 exe 同目录?
**A**:
- 放在 exe 同目录会导致应用升级时配置丢失
- 便携版每次运行会解压到临时目录,无法持久保存配置
- 使用用户数据目录AppData确保配置持久化
### Q: 如何快速访问配置文件?
**A**:
- Windows: 按 `Win + R`,输入 `%APPDATA%\erpauto`,回车
- 或在文件管理器地址栏输入 `%APPDATA%\erpauto`
### Q: 多台电脑如何同步配置?
**A**:
1. 导出配置:`xcopy %APPDATA%\erpauto\config.yaml \\server\share\`
2. 导入配置:`xcopy \\server\share\config.yaml %APPDATA%\erpauto\`
或使用同步工具OneDrive、坚果云等同步配置目录。
### Q: 配置文件损坏了怎么办?
**A**:
1. 删除 `config.yaml`
2. 应用会自动创建新的默认配置
3.`config.yaml.backup` 恢复(如果存在)
### Q: 开发环境下如何切换配置?
**A**:
- 直接编辑项目根目录的 `config.yaml`
- 建议保留 `config.template.yaml` 作为模板
-`config.yaml` 加入 `.gitignore`,避免提交敏感信息
---
## 技术实现
### ConfigManager 路径选择逻辑
```typescript
// 检测是否为开发环境
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
if (isDev) {
// 开发环境:项目根目录
this.configPath = path.resolve(__dirname, '../../config.yaml')
} else {
// 生产环境(安装版和便携版):用户数据目录
this.configPath = path.join(app.getPath('userData'), 'config.yaml')
}
```
---
## 版本历史
| 版本 | 配置策略 | 说明 |
| ---- | ------------------------------- | -------------------- |
| 1.0+ | 开发:项目目录<br>生产AppData | 确保配置在升级时保留 |
---
## 参考资料
- [Electron app.getPath() 文档](https://www.electronjs.org/docs/api/app#appgetpathname)
- [electron-builder 配置](https://www.electron.build/configuration.html)
- [Windows 应用数据存储规范](https://docs.microsoft.com/en-us/windows/win32/shell/knownfolderid)

View File

@@ -0,0 +1,731 @@
# ERPAuto 配置系统架构分析
## 1. 概述
ERPAuto 是一个基于 Electron 的桌面应用程序,采用多层次配置管理系统来支持 ERP 系统自动化数据处理。配置系统采用 `.env` 文件作为持久化存储,通过 `ConfigManager` 统一管理,支持运行时动态修改和持久化保存。
## 2. 配置系统整体架构
```mermaid
graph TB
subgraph "配置数据源"
ENV[.env 文件]
ENV_EXAMPLE[.env.example 模板]
CACHE[内存缓存 ConfigCache]
end
subgraph "配置管理层 ConfigManager"
CM_LOAD[loadEnvFile]
CM_GET[get/getBoolean/getNumber]
CM_SET[set]
CM_SAVE[save/saveAllSettings]
CM_PARTIAL[savePartialSettings]
CM_MERGE[deepMerge 深度合并]
end
subgraph "IPC 通信层"
SETTINGS_HANDLER[settings-handler.ts]
IPC_GET[settings:getSettings]
IPC_SAVE[settings:saveSettings]
IPC_TEST[settings:testErpConnection/testDbConnection]
end
subgraph "业务服务层"
ERP_SVC[ERP 服务]
DB_SVC[数据库服务]
USER_SVC[用户服务]
EXTRACTOR[ExtractorService]
CLEANER[CleanerService]
end
subgraph "UI 呈现层"
SETTINGS_UI[设置界面]
LOGIN_UI[登录界面]
MAIN_UI[主界面]
end
ENV -->|读取 | CM_LOAD
ENV_EXAMPLE -.->|模板参考 | ENV
CM_LOAD -->|填充 | CACHE
CACHE --> CM_GET
CM_SET --> CACHE
CM_PARTIAL --> CM_MERGE --> CM_SAVE --> ENV
CM_GET --> SETTINGS_HANDLER
SETTINGS_HANDLER --> IPC_GET
SETTINGS_HANDLER --> IPC_SAVE
SETTINGS_HANDLER --> IPC_TEST
IPC_GET --> SETTINGS_UI
IPC_SAVE --> SETTINGS_UI
IPC_TEST --> SETTINGS_UI
CACHE --> ERP_SVC
CACHE --> DB_SVC
CACHE --> USER_SVC
CACHE --> EXTRACTOR
CACHE --> CLEANER
SETTINGS_UI --> MAIN_UI
LOGIN_UI --> USER_SVC
```
## 3. 配置文件结构
### 3.1 .env 文件组织
```mermaid
graph LR
subgraph "ERP 系统配置"
ERP_URL[ERP_URL]
ERP_USER[ERP_USERNAME]
ERP_PASS[ERP_PASSWORD]
ERP_HEADLESS[ERP_HEADLESS]
ERP_HTTPS[ERP_IGNORE_HTTPS_ERRORS]
ERP_CLOSE[ERP_AUTO_CLOSE_BROWSER]
end
subgraph "数据库配置 - SQL Server"
SQL_DRIVER[DB_SQLSERVER_DRIVER]
SQL_TRUST[DB_TRUST_SERVER_CERTIFICATE]
end
subgraph "数据库配置 - MySQL"
DB_TYPE[DB_TYPE]
DB_NAME[DB_NAME]
DB_USER[DB_USERNAME]
DB_PASS[DB_PASSWORD]
MYSQL_HOST[DB_MYSQL_HOST]
MYSQL_PORT[DB_MYSQL_PORT]
MYSQL_CHARSET[DB_MYSQL_CHARSET]
end
subgraph "订单号解析表配置"
TABLE_NAME[DB_TABLE_NAME]
FIELD_PROD_ID[DB_FIELD_PRODUCTION_ID]
FIELD_ORDER[DB_FIELD_ORDER_NUMBER]
end
subgraph "路径配置"
DATA_DIR[PATH_DATA_DIR]
PROD_ID_FILE[PATH_PRODUCTION_ID_FILE]
DEFAULT_OUT[PATH_DEFAULT_OUTPUT]
VALID_OUT[PATH_VALIDATION_OUTPUT]
end
subgraph "数据提取配置"
BATCH_SIZE[EXTRACTION_BATCH_SIZE]
VERBOSE[EXTRACTION_VERBOSE]
AUTO_CONVERT[EXTRACTION_AUTO_CONVERT]
MERGE_BATCHES[EXTRACTION_MERGE_BATCHES]
DB_PERSIST[EXTRACTION_ENABLE_DB_PERSISTENCE]
end
subgraph "校验配置"
DATA_SOURCE[VALIDATION_DATA_SOURCE]
USE_DB[VALIDATION_USE_DATABASE]
VAL_BATCH[VALIDATION_BATCH_SIZE]
ENABLE_CRUD[VALIDATION_ENABLE_CRUD]
DEFAULT_MGR[VALIDATION_DEFAULT_MANAGER]
MATCH_MODE[VALIDATION_MATCH_MODE]
end
subgraph "UI 配置"
FONT_FAMILY[UI_FONT_FAMILY]
FONT_SIZE[UI_FONT_SIZE]
INPUT_WIDTH[UI_PRODUCTION_ID_INPUT_WIDTH]
end
subgraph "执行配置"
DRY_RUN[EXECUTION_DRYRUN]
end
```
### 3.2 默认配置值
| 配置类别 | 配置项 | 默认值 | 说明 |
| ---------- | ----------------- | --------------------------- | -------------------- |
| ERP | url | `https://68.11.34.30:8082/` | ERP 系统地址 |
| ERP | headless | `true` | 无头浏览器模式 |
| ERP | ignoreHttpsErrors | `true` | 忽略 HTTPS 证书错误 |
| ERP | autoCloseBrowser | `true` | 操作后自动关闭浏览器 |
| Database | dbType | `mysql` | 数据库类型 |
| Database | mysqlHost | `192.168.31.83` | MySQL 主机地址 |
| Database | mysqlPort | `3306` | MySQL 端口 |
| Database | database | `BLD_DB` | 数据库名 |
| Database | username | `remote_user` | 数据库用户名 |
| Paths | dataDir | `D:/python/playwrite/data/` | 数据目录 |
| Extraction | batchSize | `100` | 批次大小 |
| Extraction | verbose | `true` | 详细日志 |
| Validation | dataSource | `database_full` | 校验数据源 |
| Validation | batchSize | `2000` | 校验批次大小 |
| Validation | matchMode | `substring` | 匹配模式 |
| UI | fontFamily | `Microsoft YaHei UI` | 字体 |
| UI | fontSize | `10` | 字体大小 |
| Execution | dryRun | `false` | 干运行模式 |
## 4. ConfigManager 核心类设计
### 4.1 类结构与单例模式
```mermaid
classDiagram
class ConfigManager {
-static instance: ConfigManager | null
-envPath: string
-backupPath: string
-configCache: Map<string, string>
-initialized: boolean
+static getInstance(): ConfigManager
+initialize(): Promise<void>
+get(key: string): string | undefined
+getBoolean(key: string, default: boolean): boolean
+getNumber(key: string, default: number): number
+set(key: string, value: string|number|boolean): void
+save(): Promise<boolean>
+getAllSettings(): SettingsData
+saveAllSettings(settings: SettingsData): Promise<boolean>
+savePartialSettings(settings: Partial<SettingsData>): Promise<Object>
+resetToDefaults(): SettingsData
+getDefaultSettings(): SettingsData
-loadEnvFile(): Promise<void>
-backupEnvFile(): Promise<boolean>
-restoreBackup(): Promise<boolean>
}
class SettingsData {
+erp: ErpConfig
+database: DatabaseConfig
+paths: PathsConfig
+extraction: ExtractionConfig
+validation: ValidationConfig
+ui: UiConfig
+execution: ExecutionConfig
}
ConfigManager --> SettingsData: 返回/接收
```
### 4.2 核心方法流程图
```mermaid
sequenceDiagram
participant Client as 客户端/IPC
participant CM as ConfigManager
participant Cache as ConfigCache
participant FS as 文件系统
participant Backup as Backup 文件
Client->>CM: savePartialSettings(settings)
activate CM
CM->>CM: validateEditableFields()
alt 包含非白名单字段
CM-->>Client: 返回错误 (不允许修改)
else 验证通过
CM->>FS: loadEnvFile()
FS-->>Cache: 填充缓存
CM->>CM: getAllSettings()
CM->>Cache: 读取当前配置
CM->>CM: deepMerge(current, settings)
CM->>FS: backupEnvFile()
FS-->>Backup: 创建备份
CM->>FS: saveAllSettings(merged)
alt 保存成功
FS-->>Cache: 重新加载
CM-->>Client: 返回成功
else 保存失败
CM->>FS: restoreBackup()
FS-->>Cache: 恢复配置
CM-->>Client: 返回错误
end
end
deactivate CM
```
### 4.3 深度合并算法
```mermaid
graph TD
A[deepMerge 函数] --> B{遍历 target 键值对}
B --> C{targetValue 是对象?}
C -->|是 | D{sourceValue 也是对象?}
D -->|是 | E[递归调用 deepMerge]
D -->|否 | F[直接使用 targetValue]
C -->|否 | G{targetValue !== undefined?}
G -->|是 | H[更新该键值]
G -->|否 | I[跳过该键]
E --> J[合并结果存入 result]
F --> J
H --> J
B --> K[遍历完成]
K --> L[返回合并后的对象]
```
## 5. 配置读取与使用模式
### 5.1 环境变量直接读取模式
各业务服务通过 `process.env` 直接读取配置:
```mermaid
graph LR
subgraph "环境变量读取点"
MAIN[main/index.ts<br/>dotenv.config]
end
subgraph "服务模块"
DB_INDEX[database/index.ts]
DB_MYSQL[database/mysql.ts]
DB_SQL[database/sql-server.ts]
BIP_DAO[bip-users-dao.ts]
ORDER_RES[order-resolver.ts]
EXTRACTOR[extractor-handler.ts]
CLEANER[cleaner-handler.ts]
VALIDATION[validation-handler.ts]
end
MAIN -->|初始化加载 | ENV[process.env]
ENV --> DB_INDEX
ENV --> DB_MYSQL
ENV --> DB_SQL
ENV --> BIP_DAO
ENV --> ORDER_RES
ENV --> EXTRACTOR
ENV --> CLEANER
ENV --> VALIDATION
```
### 5.2 ConfigManager 获取模式
通过 IPC 层统一获取:
```mermaid
sequenceDiagram
participant UI as 设置界面
participant Preload as Preload 脚本
participant IPC as IPC Handler
participant CM as ConfigManager
UI->>Preload: window.api.settings.getSettings()
Preload->>IPC: ipcRenderer.invoke('settings:getSettings')
IPC->>IPC: SessionManager.getUserType()
IPC->>CM: getAllSettings()
CM->>IPC: SettingsData
IPC->>IPC: filterSettingsByUserType()
IPC-->>Preload: 过滤后的 SettingsData
Preload-->>UI: SettingsData
```
### 5.3 数据库配置工厂模式
```mermaid
graph TB
subgraph "配置创建"
GET_TYPE[getDatabaseType] -->|DB_TYPE env| TYPE_CHECK{数据库类型}
TYPE_CHECK -->|mysql| CREATE_MYSQL[createMySqlConfig]
TYPE_CHECK -->|sqlserver| CREATE_SQL[createSqlServerConfig]
end
subgraph "服务创建"
CREATE_MYSQL --> MYSQL_SVC[MySqlService]
CREATE_SQL --> SQL_SVC[SqlServerService]
end
subgraph "单例缓存"
MYSQL_SVC --> CACHE[instances Map]
SQL_SVC --> CACHE
CACHE -->|返回已连接实例 | CLIENT[调用方]
end
CREATE_MYSQL --> CONNECT_MYSQL[service.connect]
CREATE_SQL --> CONNECT_SQL[service.connect]
CONNECT_MYSQL --> CACHE
CONNECT_SQL --> CACHE
```
## 6. 用户权限与配置访问控制
### 6.1 用户类型与权限
```mermaid
graph TB
subgraph "用户类型 UserType"
ADMIN[Admin<br/>管理员]
USER[User<br/>普通用户]
GUEST[Guest<br/>访客]
end
subgraph "配置访问权限"
ADMIN_SETTINGS[全部配置可访问<br/>可修改 ERP 配置<br/>可恢复默认设置]
USER_SETTINGS[有限配置访问<br/>可修改 ERP 配置<br/>可查看执行配置]
GUEST_SETTINGS[只读访问]
end
ADMIN --> ADMIN_SETTINGS
USER --> USER_SETTINGS
GUEST --> GUEST_SETTINGS
subgraph "SessionManager 会话管理"
SM_LOGIN[login]
SM_SILENT[loginByComputerName]
SM_SWITCH[switchUser - Admin only]
SM_GET[getUserType/getUserInfo]
end
SM_LOGIN --> USER
SM_SILENT --> USER
SM_SWITCH --> USER
```
### 6.2 配置过滤机制
```mermaid
flowchart TD
A[getSettings 请求] --> B[获取当前用户类型]
B --> C{用户类型判断}
C -->|Admin| D[返回全部配置]
C -->|User| E[过滤配置]
E --> F[返回 ERP 配置<br/>username/password/headless/url/...<br/>paths 配置<br/>execution 配置<br/>最小化其他配置]
C -->|Guest| G[返回空配置或只读配置]
D --> H[返回给 UI]
E --> H
G --> H
```
## 7. 配置修改白名单机制
### 7.1 可编辑字段白名单
```javascript
const UI_EDITABLE_FIELDS: string[] = [
'erp.url',
'erp.username',
'erp.password'
// 可根据需要扩展
]
```
### 7.2 白名单验证流程
```mermaid
flowchart TD
A[savePartialSettings 调用] --> B[遍历 settings 中的字段]
B --> C[构建字段路径 section.field]
C --> D{字段在白名单中?}
D -->|否 | E[添加到 invalidFields]
D -->|是 | F[继续检查下一字段]
E --> B
F --> B
B --> G{所有字段检查完成}
G --> H{invalidFields 为空?}
H -->|否 | I[返回错误<br/>包含不允许修改的字段]
H -->|是 | J[继续保存流程]
```
## 8. 数据库配置详解
### 8.1 双数据库支持架构
```mermaid
graph TB
subgraph "数据库抽象层"
IDB[IDatabaseService 接口<br/>connect/disconnect<br/>query/transaction<br/>isConnected]
end
subgraph "MySQL 实现"
MYSQL[MySqlService<br/>mysql2/promise<br/>createConnection<br/>execute/transaction]
end
subgraph "SQL Server 实现"
MSSQL[SqlServerService<br/>mssql<br/>ConnectionPool<br/>request.query<br/>Transaction]
end
IDB -.->|实现 | MYSQL
IDB -.->|实现 | MSSQL
MYSQL --> ENV_MYSQL[DB_MYSQL_HOST<br/>DB_MYSQL_PORT<br/>DB_NAME<br/>DB_USERNAME<br/>DB_PASSWORD]
MSSQL --> ENV_MSSQL[DB_SERVER<br/>DB_SQLSERVER_PORT<br/>DB_NAME<br/>DB_USERNAME<br/>DB_PASSWORD<br/>DB_TRUST_SERVER_CERTIFICATE]
```
### 8.2 数据库配置参数映射
| 环境变量 | MySQL 用途 | SQL Server 用途 |
| --------------------------- | ----------- | ----------------- |
| DB_TYPE | mysql | sqlserver/mssql |
| DB_NAME | 数据库名 | 数据库名 |
| DB_USERNAME | 用户名 | 用户名 |
| DB_PASSWORD | 密码 | 密码 |
| DB_MYSQL_HOST | 主机地址 | - |
| DB_MYSQL_PORT | 端口 (3306) | - |
| DB_SERVER | - | 服务器地址 |
| DB_SQLSERVER_PORT | - | 端口 (1433) |
| DB_TRUST_SERVER_CERTIFICATE | - | 信任证书 (yes/no) |
## 9. ERP 配置与浏览器自动化
### 9.1 ERP 认证配置流程
```mermaid
sequenceDiagram
participant UI as 设置界面
participant IPC as settings-handler
participant CM as ConfigManager
participant ERP_AUTH as ErpAuthService
participant PW as Playwright
UI->>IPC: testErpConnection
IPC->>CM: getAllSettings
CM-->>IPC: SettingsData(含 erp 配置)
IPC->>ERP_AUTH: new ErpAuthService(erpConfig)
ERP_AUTH->>PW: chromium.launch
Note over PW: headless=erpConfig.headless<br/>args=[--ignore-certificate-errors]
PW-->>ERP_AUTH: Browser Context
ERP_AUTH->>PW: page.goto(loginUrl)
PW-->>ERP_AUTH: 加载登录页面
ERP_AUTH->>PW: fill username/password
ERP_AUTH->>PW: click login button
PW-->>ERP_AUTH: 登录成功
ERP_AUTH-->>IPC: ErpSession
IPC-->>UI: {success: true}
ERP_AUTH->>PW: close
```
### 9.2 ERP 配置项说明
| 配置项 | 类型 | 默认值 | 说明 |
| ----------------- | ------- | ------ | -------------- |
| url | string | - | ERP 系统 URL |
| username | string | - | ERP 用户名 |
| password | string | - | ERP 密码 |
| headless | boolean | true | 无头模式 |
| ignoreHttpsErrors | boolean | true | 忽略 SSL 错误 |
| autoCloseBrowser | boolean | true | 自动关闭浏览器 |
## 10. 订单号解析表配置
### 10.1 配置结构
```mermaid
graph LR
subgraph "订单号解析配置"
TABLE[DB_TABLE_NAME<br/>表名]
FIELD_ID[DB_FIELD_PRODUCTION_ID<br/>总排号字段]
FIELD_ORDER[DB_FIELD_ORDER_NUMBER<br/>生产订单号字段]
end
TABLE --> ORDER_RESOLVER[OrderResolverService]
FIELD_ID --> ORDER_RESOLVER
FIELD_ORDER --> ORDER_RESOLVER
ORDER_RESOLVER --> DB_QUERY[查询映射关系]
DB_QUERY --> PRODUCTION_ID[productionID]
DB_QUERY --> ORDER_NUMBER[生产订单号]
```
### 10.2 默认配置示例
```env
DB_TABLE_NAME=productionContractData_26 年压力表合同数据
DB_FIELD_PRODUCTION_ID=总排号
DB_FIELD_ORDER_NUMBER=生产订单号
```
## 11. 配置持久化与备份机制
### 11.1 保存流程
```mermaid
flowchart TD
A[saveAllSettings] --> B[设置写入 configCache]
B --> C[构建.env 文件内容]
C --> D[按分类组织配置<br/>ERP/数据库/路径/提取/校验/UI/执行]
D --> E[写入.env 文件]
E --> F{写入成功?}
F -->|是 | G[返回 true]
F -->|否 | H[返回 false]
```
### 11.2 备份与恢复流程
```mermaid
sequenceDiagram
participant Caller as 调用方
participant CM as ConfigManager
participant ENV as .env
participant BAK as .env.backup
Caller->>CM: savePartialSettings
CM->>CM: validateEditableFields
CM->>ENV: loadEnvFile
CM->>CM: deepMerge 合并配置
CM->>ENV: backupEnvFile
ENV->>BAK: copyFileSync
CM->>ENV: writeFileSync 新配置
ENV-->>CM: 保存结果
alt 保存成功
CM->>ENV: loadEnvFile 重新加载
CM-->>Caller: success: true
else 保存失败
CM->>BAK: restoreBackup
BAK->>ENV: copyFileSync 恢复
CM->>ENV: loadEnvFile
CM-->>Caller: success: false + error
end
```
## 12. 配置系统初始化时序
```mermaid
sequenceDiagram
participant App as Electron App
participant Main as main/index.ts
participant Dotenv as dotenv
participant CM as ConfigManager
participant IPC as registerIpcHandlers
participant SM as SessionManager
App->>Main: 应用启动
Main->>Dotenv: config .env
Dotenv-->>Main: process.env 已加载
Main->>IPC: registerIpcHandlers
Note over IPC: 注册所有 IPC 处理器<br/>settings/extractor/cleaner/auth...
App->>Main: app.whenReady
Main->>SM: silent login 尝试
SM->>SM: loginByComputerName
alt 静默登录成功
SM-->>Main: 用户已认证
else 静默登录失败
Main->>Main: 显示登录对话框
end
Main->>CM: initialize 按需加载
```
## 13. 关键代码模式
### 13.1 环境变量读取模式
```typescript
// 直接读取 process.env
const dbType = process.env.DB_TYPE?.toLowerCase()
const mysqlHost = process.env.DB_MYSQL_HOST || 'localhost'
const mysqlPort = parseInt(process.env.DB_MYSQL_PORT || '3306', 10)
```
### 13.2 ConfigManager 读取模式
```typescript
// 通过 ConfigManager 获取结构化配置
const configManager = ConfigManager.getInstance()
const settings = configManager.getAllSettings()
const erpUrl = settings.erp.url
const batchSize = settings.extraction.batchSize
```
### 13.3 部分保存模式
```typescript
// 只更新允许修改的字段
const result = await configManager.savePartialSettings({
erp: {
url: 'http://new-url.com',
username: 'newuser',
password: 'newpass'
}
})
```
## 14. 配置类别与业务模块映射
```mermaid
graph TB
subgraph "配置类别"
ERP_CONF[ERP 配置]
DB_CONF[数据库配置]
PATH_CONF[路径配置]
EXTRACT_CONF[提取配置]
VALID_CONF[校验配置]
UI_CONF[UI 配置]
EXEC_CONF[执行配置]
end
subgraph "业务模块"
ERP_AUTH[ErpAuthService]
ERP_EXTRACT[ExtractorService]
ERP_CLEAN[CleanerService]
ERP_ORDER[OrderResolverService]
DB_MYSQL[MySqlService]
DB_SQL[SqlServerService]
DB_DAO[各种 DAO 类]
EXCEL[Excel Parser/Exporter]
UI[React 界面]
end
ERP_CONF --> ERP_AUTH
ERP_CONF --> ERP_EXTRACT
ERP_CONF --> ERP_CLEAN
DB_CONF --> DB_MYSQL
DB_CONF --> DB_SQL
DB_CONF --> DB_DAO
PATH_CONF --> EXCEL
PATH_CONF --> UI
EXTRACT_CONF --> ERP_EXTRACT
EXTRACT_CONF --> DB_DAO
VALID_CONF --> ERP_EXTRACT
VALID_CONF --> DB_DAO
UI_CONF --> UI
EXEC_CONF --> ERP_CLEAN
```
## 15. 配置系统特点总结
### 15.1 优点
1. **集中化管理**: ConfigManager 单例模式统一管理所有配置
2. **类型安全**: TypeScript 类型定义确保配置结构正确
3. **权限控制**: 基于用户类型的配置访问和修改权限控制
4. **备份恢复**: 自动备份机制防止配置丢失
5. **双数据库支持**: MySQL 和 SQL Server 灵活切换
6. **部分更新**: deepMerge 支持配置部分字段更新
### 15.2 可扩展性
1. **新增配置项**: 在 `.env.example` 添加 → `DEFAULT_SETTINGS` 定义 → `SettingsData` 类型 → `save` 方法输出
2. **新增用户权限**: 扩展 `UserType` → 更新 `filterSettingsByUserType` 逻辑
3. **新增白名单字段**: 在 `UI_EDITABLE_FIELDS` 数组添加路径
### 15.3 注意事项
1. 修改配置后需要重新加载 `.env` 文件使 `process.env` 生效
2. 非白名单字段只能通过 `saveAllSettings``resetToDefaults` 修改
3. 数据库服务使用单例缓存,配置变更需重启应用或手动重连
4. ERP 配置变更需重启浏览器才能生效

171
docs/MIGRATION_GUIDE.md Normal file
View File

@@ -0,0 +1,171 @@
# BIPUsers 表 ERP 参数迁移指南
## 概述
本次迁移将 ERP 配置参数(`ERP_URL`, `ERP_USERNAME`, `ERP_PASSWORD`)从 `.env` 文件迁移到 `dbo_BIPUsers` 数据库表中,实现每个用户独立的 ERP 配置。
## 迁移步骤
### 步骤 1连接到 MySQL 数据库
使用你喜欢的 MySQL 客户端工具连接:
**方式 A: MySQL 命令行**
```bash
mysql -h 192.168.31.83 -P 3306 -u remote_user -p'3.1415926Beeke' BLD_DB
```
**方式 B: MySQL Workbench / Navicat / DBeaver**
- Host: `192.168.31.83`
- Port: `3306`
- Username: `remote_user`
- Password: `3.1415926Beeke`
- Database: `BLD_DB`
### 步骤 2执行迁移 SQL
运行以下 SQL 脚本添加新字段:
```sql
-- ============================================
-- BIPUsers 表迁移:添加 ERP 参数字段
-- ============================================
USE BLD_DB;
-- 1. 添加 ERP_URL 字段
ALTER TABLE dbo_BIPUsers ADD COLUMN IF NOT EXISTS ERP_URL VARCHAR(500) NULL COMMENT 'ERP 系统 URL';
-- 2. 添加 ERP_Username 字段
ALTER TABLE dbo_BIPUsers ADD COLUMN IF NOT EXISTS ERP_Username VARCHAR(255) NULL COMMENT 'ERP 用户名';
-- 3. 添加 ERP_Password 字段
ALTER TABLE dbo_BIPUsers ADD COLUMN IF NOT EXISTS ERP_Password VARCHAR(255) NULL COMMENT 'ERP 密码';
-- 4. 验证字段已添加
DESCRIBE dbo_BIPUsers;
```
**注意:** 如果你的 MySQL 版本不支持 `ADD COLUMN IF NOT EXISTS`,请使用:
```sql
USE BLD_DB;
ALTER TABLE dbo_BIPUsers ADD COLUMN ERP_URL VARCHAR(500) NULL COMMENT 'ERP 系统 URL';
ALTER TABLE dbo_BIPUsers ADD COLUMN ERP_Username VARCHAR(255) NULL COMMENT 'ERP 用户名';
ALTER TABLE dbo_BIPUsers ADD COLUMN ERP_Password VARCHAR(255) NULL COMMENT 'ERP 密码';
```
### 步骤 3初始化 ERP 配置
将所有现有用户的 ERP 配置设置为当前 `.env` 中的值:
```sql
-- 更新所有用户的 ERP 配置
UPDATE dbo_BIPUsers
SET
ERP_URL = 'https://68.11.34.30:8082/',
ERP_Username = '在这里填写你的 ERP 用户名',
ERP_Password = '在这里填写你的 ERP 密码'
WHERE ERP_URL IS NULL OR ERP_URL = '';
```
**请将上面的占位符替换为实际的 ERP 凭证!**
### 步骤 4验证迁移结果
```sql
-- 检查所有用户的 ERP 配置
SELECT
UserName,
UserType,
ERP_URL,
ERP_Username,
CreateTime
FROM dbo_BIPUsers
ORDER BY UserName;
```
## 迁移后配置
### 更新 .env 文件(可选)
迁移完成后,`.env` 文件中的 ERP 配置将不再使用,但为了向后兼容可以保留:
```bash
# ERP 配置(已废弃,仅用于向后兼容)
# ERP_URL=https://68.11.34.30:8082/
# ERP_USERNAME=your_username
# ERP_PASSWORD=your_password
```
### 在应用中配置用户 ERP 参数
迁移完成后,每个用户可以通过应用界面配置自己的 ERP 参数:
1. 登录应用
2. 进入设置页面
3. 配置个人 ERP 连接信息
4. 测试连接
5. 保存
## 故障排除
### 问题 1字段已存在错误
```
Error: Duplicate column name 'ERP_URL'
```
**解决方案:** 字段已经存在,跳过添加步骤,直接执行步骤 3 初始化数据。
### 问题 2连接被拒绝
```
Error: Access denied for user 'remote_user'@'%'
```
**解决方案:** 检查数据库用户权限,确保 `remote_user``ALTER``UPDATE` 权限。
### 问题 3连接超时
```
Error: connect ETIMEDOUT
```
**解决方案:**
- 检查网络连接
- 确认 MySQL 服务器正在运行
- 检查防火墙设置
## 回滚方案
如果需要回滚,可以删除新增的字段:
```sql
-- ⚠️ 警告:这将永久删除 ERP 配置数据
ALTER TABLE dbo_BIPUsers DROP COLUMN ERP_URL;
ALTER TABLE dbo_BIPUsers DROP COLUMN ERP_Username;
ALTER TABLE dbo_BIPUsers DROP COLUMN ERP_Password;
```
## 完成确认
迁移完成后,请确认以下事项:
- [ ] 三个新字段已成功添加到 `dbo_BIPUsers`
- [ ] 所有现有用户的 ERP 配置已初始化
- [ ] 应用程序可以正常启动
- [ ] 数据提取和物料清理功能正常工作
---
**迁移脚本文件:**
- `src/main/services/user/migration/add-erp-params-to-bipusers-mysql.sql` - 完整 SQL 脚本
- `src/main/services/user/migration/run-migration.ts` - TypeScript 自动迁移脚本(需要网络访问)
**创建时间:** 2026-03-05

262
docs/USER_GUIDE.md Normal file
View File

@@ -0,0 +1,262 @@
# ERPAuto 用户指南
## 目录
1. [简介](#简介)
2. [安装指南](#安装指南)
3. [配置说明](#配置说明)
4. [使用指南](#使用指南)
5. [数据库设置](#数据库设置)
6. [常见问题](#常见问题)
---
## 简介
ERPAuto 是一个专为 ERP 系统设计的自动化工具,主要功能包括:
- **数据提取**:自动从 ERP 系统批量下载物料计划数据为 Excel 文件
- **物料清理**:自动删除指定订单的物料代码
- **数据库存储**:支持将提取的数据存储到 MySQL 或 SQL Server 数据库
---
## 安装指南
### 系统要求
- **操作系统**Windows 10/11, macOS 10.15+, Linux
- **内存**:至少 4GB RAM
- **Node.js**:版本 18 或更高
### 安装步骤
1. **下载应用**
- 从发布页面下载对应系统的安装包
- Windows: `ERPAuto-Setup-x.x.x.exe`
- macOS: `ERPAuto-x.x.x.dmg`
- Linux: `ERPAuto-x.x.x.AppImage`
2. **安装**
- Windows: 运行安装程序,按照提示完成安装
- macOS: 将应用拖拽到 Applications 文件夹
- Linux: 赋予执行权限后运行
3. **首次运行**
- 启动应用
- 首次运行需要先配置 ERP 连接信息
---
## 配置说明
### ERP 连接配置
应用需要配置 ERP 系统的连接信息。在主界面点击「设置」->「ERP 配置」:
| 配置项 | 说明 | 示例 |
| ------- | -------------- | ----------------------------- |
| ERP URL | ERP 系统地址 | `https://192.168.1.100:8082/` |
| 用户名 | ERP 登录用户名 | `admin` |
| 密码 | ERP 登录密码 | `******` |
### 数据库配置(可选)
如需将提取的数据存储到数据库,需要配置数据库连接:
**MySQL 配置:**
| 配置项 | 默认值 | 说明 |
| -------- | ----------- | ---------------- |
| 主机 | `localhost` | MySQL 服务器地址 |
| 端口 | `3306` | MySQL 端口 |
| 用户名 | `root` | 数据库用户名 |
| 密码 | - | 数据库密码 |
| 数据库名 | `erpauto` | 数据库名称 |
**SQL Server 配置:**
| 配置项 | 默认值 | 说明 |
| -------- | ----------- | ------------------ |
| 服务器 | `localhost` | SQL Server 地址 |
| 端口 | `1433` | SQL Server 端口 |
| 用户名 | `sa` | 登录用户名 |
| 密码 | - | 登录密码 |
| 数据库 | `erpauto` | 数据库名称 |
| 加密 | `false` | 是否启用 SSL 加密 |
| 信任证书 | `true` | 是否信任服务器证书 |
---
## 使用指南
### 数据提取功能
**使用场景:** 从 ERP 系统批量下载多个订单的物料计划数据。
**操作步骤:**
1. 进入「数据提取」页面
2. 在左侧输入框中输入订单号,每行一个:
```
SC70202602120085
SC70202602120120
SC70202602120137
```
3. 设置批量大小(建议 100-500
4. 点击「开始提取」
5. 等待提取完成,查看结果统计
**提取结果说明:**
- **下载文件数**:成功下载的 Excel 文件数量
- **记录数**:提取的总记录数
- **错误数**:失败的订单数量
### 物料清理功能
**使用场景:** 删除指定订单中的特定物料代码。
**操作步骤:**
1. 进入「物料清理」页面
2. 输入订单号(每行一个)
3. 输入要删除的物料代码(每行一个)
4. **重要**:首次使用建议勾选「干运行模式」
5. 点击「开始清理」
6. 查看清理结果
**干运行模式:**
- 勾选后,系统仅预览将要删除的数据,不实际执行删除
- 建议先用干运行模式确认数据正确
- 确认无误后,取消勾选执行实际删除
**清理结果说明:**
- **处理订单数**:成功处理的订单数量
- **删除物料数**:实际删除的物料数量
- **跳过物料数**:未找到或跳过的物料数量
- **订单详情**:每个订单的详细处理结果
---
## 数据库设置
### MySQL 数据库初始化
```sql
CREATE DATABASE IF NOT EXISTS erpauto CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE erpauto;
-- 创建物料数据表
CREATE TABLE IF NOT EXISTS material_data (
id INT AUTO_INCREMENT PRIMARY KEY,
order_number VARCHAR(50) NOT NULL,
material_code VARCHAR(100) NOT NULL,
material_name VARCHAR(255),
quantity DECIMAL(10, 2),
unit VARCHAR(20),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_order (order_number),
INDEX idx_material (material_code)
);
```
### SQL Server 数据库初始化
```sql
CREATE DATABASE erpauto;
GO
USE erpauto;
GO
-- 创建物料数据表
CREATE TABLE material_data (
id INT IDENTITY(1,1) PRIMARY KEY,
order_number NVARCHAR(50) NOT NULL,
material_code NVARCHAR(100) NOT NULL,
material_name NVARCHAR(255),
quantity DECIMAL(10, 2),
unit NVARCHAR(20),
created_at DATETIME DEFAULT GETDATE()
);
GO
CREATE INDEX idx_order ON material_data(order_number);
CREATE INDEX idx_material ON material_data(material_code);
GO
```
---
## 常见问题
### 1. 无法登录 ERP 系统
**问题描述:** 点击登录后提示认证失败
**解决方法:**
1. 确认用户名和密码正确
2. 检查 ERP 系统是否可访问
3. 确认账号有足够权限
### 2. 提取时卡在加载中
**问题描述:** 点击提取后一直显示加载中
**解决方法:**
1. 检查网络连接
2. 减少批量大小
3. 确认 ERP 系统运行正常
4. 刷新页面后重试
### 3. 物料清理无数据
**问题描述:** 清理时显示没有可删除的数据
**解决方法:**
1. 确认订单号正确
2. 确认物料代码在该订单中存在
3. 先用干运行模式查看是否有匹配数据
### 4. 数据库连接失败
**问题描述:** 无法连接到数据库
**解决方法:**
1. 确认数据库服务已启动
2. 检查数据库配置信息
3. 确认防火墙允许数据库端口
4. 测试数据库连接:
```bash
# MySQL
mysql -h localhost -u root -p
# SQL Server
sqlcmd -S localhost -U sa
```
### 5. 应用闪退
**问题描述:** 应用启动后立即关闭
**解决方法:**
1. 查看日志文件获取错误信息
2. 重新安装应用
3. 确认系统满足最低要求
4. 尝试以管理员身份运行
---
## 技术支持
如有其他问题,请联系技术支持团队或提交 Issue。

View File

@@ -0,0 +1,86 @@
# ERP 物料清理执行报告
## 执行摘要
| 项目 | 值 |
| -------------- | --------------------------------- |
| **执行时间** | `YYYY-MM-DD HH:mm:ss` |
| **执行模式** | `正式执行` / `模拟运行 (Dry Run)` |
| **操作用户** | `username` |
| **处理订单数** | `X` |
| **删除物料数** | `X` |
| **跳过物料数** | `X` |
| **错误数量** | `X` |
| **执行耗时** | `X 分 Y 秒` |
---
## 执行状态
| 状态 | 数量 | 百分比 |
| ----------- | ---- | ------ |
| ✅ 成功订单 | X | XX% |
| ❌ 失败订单 | X | XX% |
---
## 订单处理详情
| # | 订单号 | 删除数 | 跳过数 | 状态 | 错误信息 |
| --- | -------- | ------ | ------ | ------- | ------------------------ |
| 1 | `PO-001` | 5 | 2 | ✅ 成功 | - |
| 2 | `PO-002` | 0 | 0 | ❌ 失败 | `Order PO-002: 超时错误` |
| 3 | `PO-003` | 3 | 1 | ✅ 成功 | - |
| ... | ... | ... | ... | ... | ... |
---
## 跳过的物料原因说明
| 订单号 | 物料代码 | 物料名称 | 行号 | 跳过原因 |
| -------- | -------- | -------- | ---- | --------------------------------- |
| `PO-001` | `M001` | 物料名称 | 7500 | 行号在 2000-7999 范围内(受保护) |
| `PO-001` | `M002` | 物料名称 | 1200 | 累计待发数量不为空 |
| `PO-002` | `M003` | 物料名称 | 300 | 物料不在删除清单中 |
| ... | ... | ... | ... | ... |
---
## 错误详情
**错误总数**: `X`
### 错误订单列表
- `PO-002`
- `PO-005`
- `PO-008`
- ...
### 错误详细信息
#### `PO-002`
```
订单号: PO-002
错误: 订单不存在或已被锁定,无法访问备料计划
```
#### `PO-005`
```
订单号: PO-005
错误: ERP 连接超时:请求在 30000ms 内未得到响应
```
#### `PO-008`
```
订单号: PO-008
错误: 备料状态异常:当前状态为"待审批",无法执行删除操作
```
---
**报告生成时间**: `YYYY-MM-DD HH:mm:ss`
**报表版本**: `v1.0`

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,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

View File

@@ -0,0 +1,236 @@
# ERP 登录调试工具使用说明
## 目的
用于人工调试 ERP 登录流程,定位主界面特征元素,以便优化登录成功的判定逻辑。
## 前置准备
### 1. 配置 ERP 登录信息
编辑 `src/main/tools/erp-login-debug.ts` 文件,修改以下配置:
```typescript
const ERP_CONFIG = {
url: 'https://your-erp-server.com', // ← 修改为你的 ERP 地址
username: 'your_username', // ← 修改为你的用户名
password: 'your_password' // ← 修改为你的密码
}
```
### 2. 确保 tsx 已安装
如果运行时报错提示找不到 `tsx`,请安装:
```bash
npm install -g tsx
# 或作为项目依赖
npm install --save-dev tsx
```
## 使用方法
### 方式一:使用 npm 脚本(推荐)
```bash
npm run debug:erp-login
```
### 方式二:直接运行
```bash
npx tsx src/main/tools/erp-login-debug.ts
```
## 调试流程
### 步骤 1启动脚本
运行命令后,脚本会显示配置信息并等待你确认:
```
============================================================
ERP 登录调试工具
============================================================
目标 URL: https://your-erp-server.com
用户名your_username
密码: ***
============================================================
操作步骤:
1. 浏览器将自动打开并尝试登录
2. 如果登录失败,请检查配置或手动重试
3. 登录成功后,会自动暂停并打开开发者工具
4. 使用元素选择器定位主界面特征元素
5. 记录元素选择器,按 Ctrl+C 退出脚本
按 Enter 键开始...
```
### 步骤 2自动登录
脚本会自动执行:
- 打开浏览器
- 导航到登录页面
- 输入用户名和密码
- 点击登录按钮
- 处理强制登录确认对话框(如果有)
### 步骤 3人工元素定位
登录成功后,脚本会暂停并显示:
```
============================================================
✓ 登录成功!
============================================================
现在进入调试模式,请进行以下操作:
1. 按 F12 打开浏览器开发者工具
2. 使用元素选择器 (Ctrl+Shift+C) 点击主界面特征元素
3. 在 Elements 面板中右键元素 → Copy → Copy selector
4. 或者使用 Playwright Inspector:
- 在控制台输入await page.pause()
- 使用 Inspector 的元素选择工具
建议定位的特征元素:
- 主界面顶部导航栏
- 侧边菜单栏
- 主内容区域的唯一标识
- 用户信息显示区域
- 任何登录后独有的界面元素
============================================================
```
### 步骤 4记录元素选择器
在开发者工具中:
1. **使用元素选择器** (Ctrl+Shift+C) 点击界面元素
2. **在 Elements 面板** 查看元素 HTML
3. **右键元素** → Copy → 选择以下之一:
- `Copy selector` - CSS 选择器
- `Copy XPath` - XPath 路径
- `Copy JS path` - JavaScript 路径
### 步骤 5更新 locators.ts
将找到的元素选择器添加到 `src/main/services/erp/locators.ts`
```typescript
export const ERP_LOCATORS = {
// ... 现有配置 ...
// 新增:主界面特征元素(用于登录成功判定)
mainPage: {
topNavigationBar: '#top-nav', // 顶部导航栏
sideMenu: '.side-menu', // 侧边菜单
userProfile: '.user-profile', // 用户信息
welcomeMessage: 'internal:has-text="欢迎"' // 欢迎消息
}
}
```
### 步骤 6退出脚本
`Ctrl+C` 终止脚本,浏览器会在 5 秒后自动关闭。
## Playwright Inspector 使用技巧
### 开启 Inspector
在脚本暂停时,在浏览器控制台输入:
```javascript
await page.pause()
```
会打开 Playwright Inspector提供
- 元素选择器
- 实时 locator 测试
- 代码生成
### 测试 Locator
在 Inspector 控制台测试 locator 是否有效:
```javascript
// 测试 CSS 选择器
await page.locator('#top-nav').count()
// 测试 role-based 选择器
await page.getByRole('navigation').count()
// 测试文本选择器
await page.getByText('欢迎').count()
```
如果返回数量 > 0说明选择器有效。
## 推荐的特征元素
选择登录成功判定元素时,优先选择:
1. **唯一性** - 只在登录后出现
2. **稳定性** - 不易随版本变更
3. **易定位** - 有明确的 id、class 或文本
### 推荐元素示例
| 元素类型 | 选择器示例 | 说明 |
| ------------ | ----------------------- | ---------------------- |
| 顶部导航栏 | `#top-nav` | 登录后才会显示的主导航 |
| 用户菜单 | `.user-menu` | 显示当前用户名的菜单 |
| 欢迎消息 | `text=欢迎` | 包含用户名的欢迎语 |
| 工作台标题 | `h1:has-text("工作台")` | 主界面标题 |
| 功能模块网格 | `.module-grid` | 功能模块入口区域 |
## 常见问题
### Q: 登录失败,提示找不到元素
**A**: 检查以下几点:
1. ERP URL 是否正确
2. 用户名密码是否正确
3. 网络连接是否正常
4. ERP 系统是否可访问
5. 是否需要验证码(如果 ERP 有验证码,需要手动输入)
### Q: 登录后没有暂停
**A**: 检查控制台输出,可能登录流程中抛出了异常。查看错误信息并修复。
### Q: 如何调试特定页面?
**A**: 修改脚本中的登录后逻辑,导航到特定页面:
```typescript
// 登录后导航到特定页面
await page.goto(`${ERP_CONFIG.url}/yonbip/sc`)
await page.waitForTimeout(3000)
```
### Q: 如何保存调试会话?
**A**: Playwright 支持录制 trace
```typescript
await context.tracing.start({ screenshots: true, snapshots: true })
// ... 操作 ...
await context.tracing.stop({ path: 'trace.zip' })
```
然后使用 `npx playwright show-trace trace.zip` 查看。
## 下一步
找到稳定的主界面元素后,修改以下文件优化登录判定:
1. **更新 locators.ts** - 添加主界面元素定位器
2. **修改 erp-auth.ts** - 在登录成功后等待主界面元素
3. **更新测试** - 验证新的登录判定逻辑

View File

@@ -0,0 +1,241 @@
# ERP 登录调试工具 - 快速参考
## 创建的文件
### 1. 调试脚本
**路径**: `src/main/tools/erp-login-debug.ts`
用途:人工调试 ERP 登录流程,定位主界面特征元素
### 2. 使用文档
**路径**: `docs/erp-login-debug-guide.md`
详细的调试工具使用说明
### 3. package.json 更新
添加了新的 npm 脚本和依赖:
- `debug:erp-login` - 运行调试脚本
- `debug:config-path` - 运行配置路径调试(已有)
- `tsx` - TypeScript 执行器依赖
## 快速开始
### 步骤 1配置登录信息
编辑 `src/main/tools/erp-login-debug.ts` 第 19-23 行:
```typescript
const ERP_CONFIG = {
url: 'https://your-erp-server.com', // ← 修改
username: 'your_username', // ← 修改
password: 'your_password' // ← 修改
}
```
### 步骤 2运行调试
```bash
npm run debug:erp-login
```
### 步骤 3定位元素
登录成功后:
1.**F12** 打开开发者工具
2.**Ctrl+Shift+C** 启用元素选择器
3. 点击主界面特征元素
4. 右键 → Copy → Copy selector
### 步骤 4更新定位器
将找到的元素添加到 `src/main/services/erp/locators.ts`
```typescript
export const ERP_LOCATORS = {
// ... 现有配置 ...
// 新增:主界面特征元素
mainPage: {
// 在此添加找到的元素
topNav: '#top-nav',
userMenu: '.user-menu'
}
}
```
## 脚本功能
### 自动执行
- ✅ 启动浏览器(可见窗口,非无头模式)
- ✅ 导航到登录页面
- ✅ 输入用户名和密码
- ✅ 点击登录按钮
- ✅ 处理强制登录确认对话框
### 调试支持
- ✅ 登录成功后自动暂停
- ✅ 保持浏览器打开
- ✅ 支持 F12 开发者工具
- ✅ 支持 Playwright Inspector
### 安全特性
- ✅ 密码显示为星号
- ✅ 需要按 Enter 确认后才开始
- ✅ 退出前 5 秒缓冲时间
## 常用命令
```bash
# 运行调试脚本
npm run debug:erp-login
# 或使用 npx 直接运行
npx tsx src/main/tools/erp-login-debug.ts
# 查看帮助
npx tsx --help
```
## 调试技巧
### 测试 Locator 有效性
在浏览器控制台(登录后暂停时):
```javascript
// 测试 CSS 选择器
await page.locator('#top-nav').count()
// 测试文本选择器
await page.getByText('欢迎').isVisible()
// 测试 role 选择器
await page.getByRole('navigation').count()
```
返回值 > 0 或 true 表示选择器有效。
### 查看元素详细信息
```javascript
// 获取元素 HTML
const element = await page.$('#top-nav')
console.log(await element.innerHTML())
// 获取元素属性
console.log(await element.getAttributes())
```
### 截图保存
```javascript
// 全屏截图
await page.screenshot({ path: 'login-success.png' })
// 元素截图
const element = await page.$('#top-nav')
await element.screenshot({ path: 'top-nav.png' })
```
## 推荐的特征元素
选择登录成功判定元素的标准:
| 标准 | 说明 | 示例 |
| ---------- | -------------- | ------------------- |
| **唯一性** | 只在登录后出现 | 用户菜单、工作台 |
| **稳定性** | 不易随版本变更 | ID 选择器优于 class |
| **易定位** | 有明确的标识 | 有 id、独特文本 |
### 推荐元素类型
1. **顶部导航栏** - `#top-nav`, `.navbar`
2. **用户信息区域** - `.user-info`, `.user-menu`
3. **欢迎消息** - 包含用户名的文本
4. **功能模块入口** - 主界面的模块网格
5. **侧边菜单栏** - `.sidebar`, `.menu`
## 故障排查
### 问题:脚本启动后立即退出
**原因**: tsx 未安装
**解决**:
```bash
npm install
```
### 问题:找不到用户名/密码输入框
**原因**:
1. ERP URL 不正确
2. 页面结构已变更
3. 登录页面加载超时
**解决**:
1. 检查 ERP_CONFIG.url 是否正确
2. 手动打开 URL 确认页面结构
3. 增加 timeout 值(第 25 行)
### 问题:登录后没有暂停
**原因**: 登录流程抛出异常
**解决**: 查看控制台错误信息,检查:
- 网络连接
- ERP 系统可用性
- 用户名密码正确性
### 问题:无法定位元素
**原因**:
1. 元素在 iframe 中
2. 元素动态加载
3. 选择器不正确
**解决**:
1. 检查元素是否在嵌套 iframe 中
2. 增加等待时间 `await page.waitForTimeout(2000)`
3. 使用更具体的选择器
## 下一步
找到稳定的主界面元素后:
1. **更新 locators.ts**
- 添加 `mainPage` 配置节
- 定义登录成功判定元素
2. **修改 erp-auth.ts**
-`login()` 方法末尾
- 等待主界面元素出现
- 作为登录成功的最终判定
3. **验证修改**
- 重新运行调试脚本
- 确认新的判定逻辑有效
- 更新相关文档
## 相关文件
| 文件 | 用途 |
| ----------------------------------- | ---------- |
| `src/main/tools/erp-login-debug.ts` | 调试脚本 |
| `src/main/services/erp/locators.ts` | 元素定位器 |
| `src/main/services/erp/erp-auth.ts` | 登录服务 |
| `docs/erp-login-debug-guide.md` | 详细文档 |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,93 @@
# ERPAuto 优化执行计划文档
基于《ERPAuto 优化建议与规范指南》,本文档规划了具体的分阶段重构与优化执行步骤。每个阶段遵循“渐进式重构”原则,保证在优化期间项目依然可运行、可测试。
## 阶段一:基础设施建设 (Error & Logging)
在进行大规模业务逻辑重构前,首先建立坚实的基础设施,以便后续问题排查与数据追踪。
1. **引入并配置统一日志库**
- **目标**: 替换分散的 `console.log`
- **执行**:
- 安装 `winston` (针对 Node.js 主进程)。
-`src/main/services/logger` 创建单例日志记录器。
- 配置双通道输出Console (Dev 环境) 与 File (生产环境按天切割,如 `%AppData%/ERPAuto/logs/app-%DATE%.log`)。
2. **定义全局错误类型与 IPC 拦截器**
- **目标**: 规范前后端错误抛出与展示体系。
- **执行**:
-`src/main/types/errors.ts` 定义 `BaseError`, `ErpConnectionError`, `DatabaseQueryError`
-`src/main/ipc/index.ts` 中封装高阶函数 `withErrorHandling`。所有 IPC Handler 统一用此高阶函数包裹,将捕获的错误统一转为 `{ success: false, error: string, code: string }` 结构。
## 阶段二:数据层抽象与 ORM 改造
彻底解决 SQL 语句散落和不同数据库适配成本高的问题。
1. **选型并引入 ORM**
- **目标**: 弃用原生 SQL 拼接。
- **执行**:
- 引入 `Prisma``TypeORM`。结合当前多数据源 (MySQL + SQL Server) 需求,推荐 `TypeORM` 因为其在运行时切换数据源更为灵活。
2. **创建 Repository 抽象**
- **目标**: 隔离数据库实现细节。
- **执行**:
- 建立 `src/main/services/database/repositories` 目录。
- 为业务实体 (如 Users, ExtractedPlans 等) 编写 Repository 类接口。
- 将原有 `mysql2``mssql` 的调用逐步迁移至 Repository 中。
3. **Zod 运行时校验**
- **目标**: 保护 IPC 边界免受恶意/格式错误的 payload 影响。
- **执行**:
- 安装 `zod`
- 对所有的 IPC Handler 的入参(如 `ExtractorInput`, `LoginRequest`)添加 `zod` Schema 校验。
## 阶段三React 渲染层规范化
提高前端代码复用率,解耦视图与逻辑。
1. **提取 IPC Hooks**
- **目标**: 清理组件中的大段异步调用。
- **执行**:
-`src/renderer/src/hooks` 创建 `useExtractor.ts`, `useCleaner.ts`
- 使用 React 的 `useState` 包装 `window.api` 调用,返回 `{ loading, data, error, execute }`
2. **状态管理引入 (Zustand)**
- **目标**: 解决跨组件状态共享 (如全局报错信息、用户认证状态)。
- **执行**:
- 安装 `zustand`
- 创建 `useUserStore``useAppStore`
3. **UI 组件库/公共样式提取**
- **目标**: 统一 Tailwind 设计语言。
- **执行**:
- 将高频使用的 Button, Input, Modal 抽取到 `src/renderer/src/components/ui/`
## 阶段四:自动化服务解耦 (Domain Logic)
将基于 Playwright 的具体执行细节与业务调度逻辑分离。
1. **重构 ERP 自动化服务 (`cleaner.ts` / `extractor.ts`)**
- **目标**: 遵循单一职责原则。
- **执行**:
- 抽象出 `ErpBrowserManager` (负责浏览器启动与资源回收)。
- 抽象出 `ErpAuthService` (专职处理登录和 Session)。
- `extractor.ts` 将只负责调度:调用 Browser -> Auth -> Navigate -> Download -> Excel Parse。
2. **加强 TypeScript 严格模式**
- **目标**: 提升代码健壮性。
- **执行**:
- 开启 `tsconfig.json` 中的 `"strict": true``"noImplicitAny": true`
- 全局清理并替换现存的 `any` 为具体的 Type 或 `unknown` 并添加类型保护。
## 阶段五:测试覆盖率补充
确保核心流程不被破坏。
1. **补充关键服务的单元测试**
- **目标**: 防止复杂转换逻辑衰退。
- **执行**:
- 使用 `Vitest` 测试所有的 Repository (使用内存数据库/Mock) 和工具函数 (如 ExcelParser)。
2. **核心业务 E2E 测试**
- **目标**: 确保 IPC 及 Electron 整体运行顺畅。
- **执行**:
- 使用 Playwright 针对 Electron 的测试框架 (`@playwright/test` 的 electron 插件) 编写主流程测试:登录 -> 点击提取 -> 验证本地结果文件生成。
## 执行建议与回顾
- 每个阶段应作为一个单独的 Git 分支 (Feature Branch) 开发。
- 完成一个阶段后,必须全量运行既有的测试套件并通过 `npm run typecheck`
- 本文档可作为每次 PR Review 的检查清单使用。

View File

@@ -0,0 +1,487 @@
# 配置保存优化设计文档
**日期:** 2026-03-03
**分支:** fix/settings-partial-save
**状态:** 设计阶段
---
## 问题描述
当前设置界面只能配置 3 个字段ERP URL、用户名、密码但保存后会意外覆盖 `.env` 文件中的其他配置项(如 `DB_TYPE``VALIDATION_DATA_SOURCE` 等),导致这些字段被重置为默认值或丢失。
### 根本原因
`config-manager.ts:437-483` 中,`saveAllSettings()` 方法无条件覆盖所有配置类别。当 UI 只发送部分字段时,未包含的字段会被设置为 `undefined` 或默认值,导致原有配置丢失。
**数据流问题:**
```
SettingsPage (只修改 ERP URL)
↓ 发送完整的 settings 对象
ConfigManager.saveAllSettings()
↓ 覆盖所有字段到缓存
.env 文件被完全重写(丢失未被 UI 包含的字段)
```
---
## 解决方案
采用 **方案 A深度合并+ 方案 C字段白名单** 的组合策略:
### 核心策略
1. **部分更新**:只更新传入的字段,保留其他字段不变
2. **白名单验证**:只允许 UI 支持的字段被修改
3. **备份机制**:保存前备份,失败可回滚
4. **安全日志**:记录所有配置变更操作
---
## 架构设计
### 数据流
```
┌─────────────────┐
│ SettingsPage │
│ (Renderer) │
└────────┬────────┘
│ 只发送支持的字段
│ { erp: { url, username, password } }
┌─────────────────┐
│ Settings Handler│
│ (IPC Bridge) │
└────────┬────────┘
│ 传递部分配置 (Partial<SettingsData>)
┌─────────────────────────────┐
│ ConfigManager │
│ ┌─────────────────────┐ │
│ │ 1. 验证字段白名单 │ │
│ │ 2. 深度合并当前配置 │ │
│ │ 3. 备份 .env 文件 │ │
│ │ 4. 原子写入新配置 │ │
│ └─────────────────────┘ │
└─────────────────────────────┘
```
### 改动点
| 文件 | 改动类型 | 说明 |
| -------------------------------------------- | -------- | ------------------------------------------------ |
| `src/main/services/config/config-manager.ts` | 核心 | 新增 `savePartialSettings()`、深度合并、备份机制 |
| `src/main/ipc/settings-handler.ts` | 调整 | IPC 参数改为 `Partial<SettingsData>` |
| `src/renderer/src/pages/SettingsPage.tsx` | 优化 | 只发送 UI 支持的字段 |
---
## 核心实现
### 1. 深度合并工具函数
```typescript
/**
* 深度合并两个对象,只更新 target 中存在的字段
* 保留 source 中 target 没有的字段
*/
function deepMerge<T>(source: T, target: Partial<T>): T {
const result = { ...source }
for (const key in target) {
if (key in target) {
const targetValue = target[key]
const sourceValue = result[key]
if (isObject(targetValue) && isObject(sourceValue)) {
result[key] = deepMerge(sourceValue, targetValue)
} else if (targetValue !== undefined) {
result[key] = targetValue as T[Extract<keyof T, string>]
}
}
}
return result
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
```
### 2. 字段白名单验证
```typescript
/**
* 定义 UI 可编辑的字段路径
* 使用点号表示法:'section.field'
*/
const UI_EDITABLE_FIELDS: string[] = [
'erp.url',
'erp.username',
'erp.password'
// 未来扩展:
// 'database.dbType',
// 'paths.dataDir',
// ...
]
/**
* 验证配置更新是否只包含允许的字段
*/
function validateEditableFields(settings: Partial<SettingsData>): {
valid: boolean
invalidFields: string[]
} {
const invalidFields: string[] = []
for (const [section, values] of Object.entries(settings)) {
if (values && typeof values === 'object') {
for (const field of Object.keys(values)) {
const fieldPath = `${section}.${field}`
if (!UI_EDITABLE_FIELDS.includes(fieldPath)) {
invalidFields.push(fieldPath)
}
}
}
}
return {
valid: invalidFields.length === 0,
invalidFields
}
}
```
### 3. 部分保存方法
```typescript
/**
* 保存部分配置(只更新传入的字段)
*/
public async savePartialSettings(
settings: Partial<SettingsData>
): Promise<{ success: boolean; error?: string }> {
try {
// 步骤 1: 验证字段白名单
const validation = validateEditableFields(settings)
if (!validation.valid) {
log.warn('Attempted to save non-editable fields', {
invalidFields: validation.invalidFields
})
return {
success: false,
error: `包含不允许修改的字段:${validation.invalidFields.join(', ')}`
}
}
// 步骤 2: 读取当前配置
const currentSettings = this.getAllSettings()
// 步骤 3: 深度合并
const mergedSettings = deepMerge(currentSettings, settings)
// 步骤 4: 备份并保存
const backupSuccess = await this.backupEnvFile()
if (!backupSuccess) {
log.warn('Failed to backup .env file, proceeding with caution')
}
const saveSuccess = await this.saveAllSettings(mergedSettings)
if (!saveSuccess) {
// 保存失败,尝试恢复备份
await this.restoreBackup()
return {
success: false,
error: '保存配置失败,已恢复原配置'
}
}
log.info('Settings saved successfully', {
updatedFields: Object.keys(settings)
})
return { success: true }
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
log.error('Error in savePartialSettings', { error: message })
await this.restoreBackup()
return {
success: false,
error: `保存配置时发生错误:${message}`
}
}
}
```
### 4. 备份与恢复机制
```typescript
private backupPath: string
constructor() {
// ...
this.backupPath = path.resolve(__dirname, '../../.env.backup')
}
/**
* 备份当前 .env 文件
*/
private async backupEnvFile(): Promise<boolean> {
try {
if (fs.existsSync(this.envPath)) {
fs.copyFileSync(this.envPath, this.backupPath)
log.debug('Backup created', { path: this.backupPath })
return true
}
return false
} catch (error) {
log.error('Failed to backup .env file', { error })
return false
}
}
/**
* 从备份恢复 .env 文件
*/
private async restoreBackup(): Promise<boolean> {
try {
if (fs.existsSync(this.backupPath)) {
fs.copyFileSync(this.backupPath, this.envPath)
await this.loadEnvFile() // 重新加载到缓存
log.info('Restored from backup')
return true
}
return false
} catch (error) {
log.error('Failed to restore backup', { error })
return false
}
}
```
---
## IPC 调用链路调整
### settings-handler.ts
```typescript
ipcMain.handle(
'settings:saveSettings',
async (_event, settings: Partial<SettingsData>): Promise<SaveSettingsResult> => {
try {
log.info('Saving settings', {
sections: Object.keys(settings)
})
// 使用新的部分保存方法
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}`
}
}
}
)
```
**关键改动:**
- 参数类型从 `SettingsData` 改为 `Partial<SettingsData>`
- 调用 `savePartialSettings()` 替代 `saveAllSettings()`
---
## 前端优化(双重保险)
### SettingsPage.tsx
```typescript
const handleSaveSettings = async () => {
try {
// 只发送 UI 支持的字段(双重保险)
const partialSettings = {
erp: {
url: settings.erp?.url,
username: settings.erp?.username,
password: settings.erp?.password
}
}
const result = await window.electron.settings.saveSettings(partialSettings)
if (result.success) {
setIsModified(false)
showMessage('success', '设置保存成功')
} else {
showMessage('error', result.error || '保存失败')
}
} catch (error) {
showMessage('error', '保存设置时发生错误')
}
}
```
---
## 测试策略
### 单元测试场景
```typescript
describe('ConfigManager.savePartialSettings', () => {
it('应该只更新指定的字段,保留其他字段', async () => {
const initial = {
erp: { url: 'http://old.com', username: 'user1' },
database: { dbType: 'mysql' }
}
const update = {
erp: { url: 'http://new.com' }
}
await configManager.savePartialSettings(update)
const result = configManager.getAllSettings()
expect(result.erp.url).toBe('http://new.com')
expect(result.erp.username).toBe('user1') // 保留
expect(result.database.dbType).toBe('mysql') // 保留
})
it('应该拒绝未授权的字段更新', async () => {
const invalidUpdate = {
database: { dbType: 'postgres' }
}
const result = await configManager.savePartialSettings(invalidUpdate)
expect(result.success).toBe(false)
expect(result.error).toContain('不允许修改')
})
it('保存失败时应该恢复备份', async () => {
jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {
throw new Error('Disk full')
})
const result = await configManager.savePartialSettings({ erp: { url: 'x' } })
expect(result.success).toBe(false)
})
})
```
### 手动验证步骤
1. 打开 `.env`,记录所有字段值
2. 打开设置页面,只修改 ERP URL
3. 点击保存
4. 检查 `.env`:只有 `ERP_URL` 改变,其他字段保持原值
---
## 未来扩展性
### 1. 白名单配置化
当设置页面需要支持更多配置时:
```typescript
const UI_EDITABLE_FIELDS: string[] = [
'erp.url',
'erp.username',
'erp.password',
'database.dbType', // 新增
'paths.dataDir', // 新增
'extraction.batchSize' // 新增
// ...
]
```
### 2. 按用户角色分级
```typescript
const EDITABLE_FIELDS_BY_ROLE: Record<UserType, string[]> = {
Admin: ['*'],
User: ['erp.url', 'erp.username', 'erp.password'],
Guest: []
}
function validateEditableFields(settings: Partial<SettingsData>, userType: UserType) {
const allowed = EDITABLE_FIELDS_BY_ROLE[userType]
// 验证逻辑...
}
```
### 3. 配置变更审计
```typescript
interface ConfigChange {
timestamp: Date
user: string
field: string
oldValue: string
newValue: string
}
```
---
## 实施计划
下一步将创建详细的实施计划,包括:
1. 在 ConfigManager 中添加深度合并和验证函数
2. 实现 `savePartialSettings()` 方法
3. 添加备份与恢复机制
4. 更新 IPC handler 调用
5. 前端优化(只发送必要字段)
6. 编写单元测试
7. 集成测试和手动验证
---
## 风险与缓解
| 风险 | 影响 | 缓解措施 |
| ---------------- | ---------- | ----------------------------- |
| 深度合并逻辑错误 | 配置错误 | 完善单元测试覆盖 |
| 备份文件权限问题 | 无法恢复 | 错误处理 + 日志 |
| 白名单漏配置 | 功能受限 | 清晰的文档 + 代码注释 |
| 并发保存冲突 | 数据不一致 | 单实例 ConfigManager + 文件锁 |
---
## 附录
### 相关文件
- `src/main/services/config/config-manager.ts` - 配置管理器
- `src/main/ipc/settings-handler.ts` - IPC 处理器
- `src/renderer/src/pages/SettingsPage.tsx` - 设置页面
- `src/main/types/settings.types.ts` - 类型定义
### 参考
- 当前问题:保存设置时 `.env` 中未包含的字段被覆盖
- 设计原则:安全优先、最小化修改、可扩展性

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,130 @@
# Implementation Plan: Auto-import Extracted Data to Database
## Overview
Implement automatic database import after ERP data extraction completes. The merged Excel file will be read and written to the `dbo_DiscreteMaterialPlanData` table.
## Requirements
- **Trigger**: Automatic after extraction completes
- **Delete Strategy**: Batch delete by `SourceNumber` before insert
- **Batch Insert**: 1000 records per batch
- **Field Mapping**: 28 Excel fields → database columns (skip 打印人, 打印日期, BOMVersion)
## Architecture
```
ExtractorService
├── extract() → download + merge Excel
└── NEW: importToDatabase(mergedFile)
DataImportService
├── readExcelFile() → records + sourceNumbers
├── deleteExistingRecords(sourceNumbers)
└── batchInsert(records, batchSize=1000)
DiscreteMaterialPlanDAO
├── deleteBySourceNumbers()
└── batchInsert()
```
## Field Mapping
| Excel Header | Database Column | Notes |
| ------------ | ------------------------ | ---------------- |
| 工厂 | Factory | |
| 备料状态 | MaterialStatus | |
| 备料计划单号 | PlanNumber | |
| 来源单号 | SourceNumber | **Deletion key** |
| 备料类型 | MaterialType | |
| 产品编码 | ProductCode | |
| 产品名称 | ProductName | |
| 产品计划数量 | ProductPlanQuantity | decimal |
| 产品单位 | ProductUnit | |
| 用料部门 | UseDepartment | |
| 备注 | Remark | |
| 制单人 | Creator | |
| 制单日期 | CreateDate | date |
| 审批人 | Approver | |
| 审批日期 | ApproveDate | date |
| 序号 | SequenceNumber | int |
| 材料编码 | MaterialCode | |
| 材料名称 | MaterialName | |
| 规格 | Specification | |
| 型号 | Model | |
| 图号 | DrawingNumber | |
| 物料材质 | MaterialQuality | |
| 计划数量 | PlanQuantity | decimal |
| 单位 | Unit | |
| 需用日期 | RequiredDate | date |
| 发料仓库 | Warehouse | |
| 单位用量 | UnitUsage | decimal |
| 累计出库数量 | CumulativeOutputQuantity | decimal |
| 打印人 | ❌ SKIP | Not in DB |
| 打印日期 | ❌ SKIP | Not in DB |
| - | BOMVersion | SKIP (no source) |
## Files to Create/Modify
### 1. NEW: `src/main/services/database/data-importer.ts`
Main import service with:
- `importFromExcel(filePath)` - Main entry point
- `readExcelFile(filePath)` - Parse Excel using ExcelJS
- Map Excel columns to database fields
- Return records and unique SourceNumbers
### 2. MODIFY: `src/main/services/database/discrete-material-plan-dao.ts`
Add methods:
- `deleteBySourceNumbers(sourceNumbers: string[])` - Batch delete
- `batchInsert(records: MaterialPlanRecord[], batchSize: number)` - Batch insert
### 3. MODIFY: `src/main/services/erp/extractor.ts`
- After successful merge, call `importToDatabase(mergedFile)`
- Add import results to `ExtractorResult`
### 4. MODIFY: `src/main/types/extractor.types.ts`
Add types:
```typescript
export interface ImportResult {
success: boolean
recordsImported: number
recordsDeleted: number
errors: string[]
}
export interface ExtractorResult {
// existing fields...
importResult?: ImportResult
}
```
### 5. MODIFY: `src/renderer/src/pages/ExtractorPage.tsx`
- Display import results
- Show records deleted/imported counts
## Implementation Order
1. Extend `DiscreteMaterialPlanDAO` with insert/delete methods
2. Create `DataImportService`
3. Integrate into `ExtractorService`
4. Update types
5. Update UI
## Testing Plan
1. Unit test DAO methods
2. Integration test with sample Excel file
3. E2E test extraction → import flow

View File

@@ -0,0 +1,62 @@
# Settings Partial Save Feature
## Overview
The settings system now implements partial save functionality to prevent unintended overwrites of configuration values.
## How It Works
1. **Field Whitelist**: Only fields exposed in the UI can be modified
2. **Deep Merge**: Updates are merged with existing config, preserving unmodified fields
3. **Backup & Rollback**: Config is backed up before save; failures trigger automatic rollback
## Editable Fields
Currently editable via UI:
- `erp.url` - ERP system URL
- `erp.username` - ERP login username
- `erp.password` - ERP login password
## Adding New Editable Fields
To add a new field to the UI:
1. Add field to whitelist in `src/main/services/config/config-manager.ts`:
```typescript
const UI_EDITABLE_FIELDS: string[] = [
'erp.url',
'erp.username',
'erp.password',
'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
## API
### savePartialSettings(settings: Partial<SettingsData>)
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
## Error Handling
- **Unauthorized field**: Returns error message listing invalid fields
- **Save failure**: Automatically restores from backup
- **Backup failure**: Logs warning, continues with save
## Backup File
Location: `.env.backup` (in project root)
Created before every save operation. Used for rollback on failure.

View File

@@ -0,0 +1,927 @@
# 系统设置保存按钮工作流程分析
# System Settings Save Button Workflow Analysis
## 文档概述 / Document Overview
本文档详细分析了 ERPAuto 系统设置界面中保存按钮的完整工作流程,包括架构设计、数据流转、技术实现细节以及错误处理机制。
This document provides a comprehensive analysis of the save button workflow in the ERPAuto system settings interface, including architecture design, data flow, technical implementation details, and error handling mechanisms.
---
## 目录 / Table of Contents
1. [架构概览](#架构概览)
2. [数据流程图](#数据流程图)
3. [组件详解](#组件详解)
4. [数据结构](#数据结构)
5. [错误处理机制](#错误处理机制)
6. [安全考虑](#安全考虑)
7. [技术实现细节](#技术实现细节)
---
## 架构概览 / Architecture Overview
### 系统架构 / System Architecture
系统设置保存功能采用典型的 Electron 三层架构模式:
The system settings save functionality follows the classic Electron three-tier architecture pattern:
```mermaid
graph TB
subgraph "Renderer Process 渲染进程"
UI[SettingsPage.tsx<br/>UI Component]
end
subgraph "Preload Script 预加载脚本"
BRIDGE[contextBridge API<br/>Security Boundary]
end
subgraph "Main Process 主进程"
IPC[settings-handler.ts<br/>IPC Handler]
SERVICE[ConfigManager.ts<br/>Configuration Service]
FILE[.env File<br/>Persistent Storage]
end
UI -->|IPC Invoke| BRIDGE
BRIDGE -->|Secure Channel| IPC
IPC -->|Business Logic| SERVICE
SERVICE -->|Write| FILE
FILE -->|Confirm| SERVICE
SERVICE -->|Result| IPC
IPC -->|Response| BRIDGE
BRIDGE -->|Promise Resolve| UI
style UI fill:#e1f5ff
style BRIDGE fill:#fff4e1
style IPC fill:#ffe1f5
style SERVICE fill:#e1ffe1
style FILE fill:#f5f5f5
```
### 核心设计模式 / Core Design Patterns
1. **单向数据流**:数据从 UI → Main Process → File响应沿相反路径返回
2. **安全隔离**Preload 脚本作为安全桥梁,通过 `contextBridge` 暴露受限 API
3. **单例模式**ConfigManager 使用单例确保配置一致性
4. **缓存优先**:配置读取优先从内存缓存获取,写入时同步到磁盘
---
## 数据流程图 / Data Flow Diagrams
### 完整保存流程 / Complete Save Flow
```mermaid
sequenceDiagram
actor User as 用户 User
participant UI as SettingsPage.tsx
participant Preload as preload/index.ts
participant IPC as settings-handler.ts
participant Config as ConfigManager.ts
participant File as .env File
User->>UI: 点击保存按钮<br/>Click Save Button
activate UI
UI->>UI: handleSaveSettings()
Note over UI: 检查是否修改<br/>Check isModified
UI->>Preload: window.electron.settings<br/>.saveSettings(settings)
activate Preload
Preload->>IPC: ipcRenderer.invoke<br/>('settings:saveSettings', settings)
activate IPC
IPC->>IPC: 验证用户类型<br/>Validate User Type
IPC->>Config: configManager<br/>.saveAllSettings(settings)
activate Config
Config->>Config: 更新内存缓存<br/>Update Cache
Note over Config: set('erp.url', value)<br/>set('erp.username', value)<br/>... (40+ fields)
Config->>File: fs.writeFileSync<br/>(.env, content)
activate File
File-->>Config: true/false
deactivate File
Config-->>IPC: Promise<boolean>
deactivate Config
IPC-->>Preload: {success, error?}
deactivate IPC
Preload-->>UI: Promise resolve
deactivate Preload
alt 保存成功 / Save Success
UI->>UI: setIsModified(false)
UI->>User: 显示成功消息<br/>Show Success Message
else 保存失败 / Save Failed
UI->>User: 显示错误消息<br/>Show Error Message
end
deactivate UI
```
### 数据转换流程 / Data Transformation Flow
```mermaid
graph LR
subgraph "UI State"
STATE[Settings Interface<br/>settings.erp.url = 'https://...']
end
subgraph "Type Conversion"
T1[SettingsData Object<br/>TypeScript Interface]
end
subgraph "IPC Transport"
JSON[JSON Serialization<br/>String Transfer]
end
subgraph "Service Layer"
CACHE[Config Cache<br/>Map<string, string>]
end
subgraph "File System"
ENV[.env File Format<br/>KEY=VALUE]
end
STATE -->|Object| T1
T1 -->|JSON.stringify| JSON
JSON -->|Deserialize| T1
T1 -->|set key-value| CACHE
CACHE -->|Format| ENV
style STATE fill:#e1f5ff
style JSON fill:#fff4e1
style CACHE fill:#e1ffe1
style ENV fill:#f5f5f5
```
---
## 组件详解 / Component Details
### 1. 渲染进程 / Renderer Process
#### SettingsPage.tsx (`src/renderer/src/pages/SettingsPage.tsx`)
**主要职责 / Main Responsibilities:**
- 用户界面渲染和交互
- 本地状态管理settings, isModified, message
- 调用 IPC 通信
**关键函数 / Key Functions:**
```typescript
// 第 61-73 行 / Lines 61-73
const handleSaveSettings = async () => {
try {
const result = await window.electron.settings.saveSettings(settings as any)
if (result.success) {
setIsModified(false) // 清除修改标记
showMessage('success', '设置保存成功')
} else {
showMessage('error', result.error || '保存失败')
}
} catch (error) {
showMessage('error', '保存设置时发生错误')
}
}
```
**状态管理 / State Management:**
| 状态变量 | 类型 | 用途 |
| ------------ | ---------------- | ----------------------------------------------------------- |
| `settings` | `Settings` | 当前配置数据,结构为 `{ erp: { url, username, password } }` |
| `isModified` | `boolean` | 标记配置是否已修改,控制保存按钮启用状态 |
| `isLoading` | `boolean` | 加载状态,显示加载动画 |
| `message` | `object \| null` | 临时消息3秒后自动消失 |
**UI 交互逻辑 / UI Interaction Logic:**
```mermaid
stateDiagram-v2
[*] --> Loading: 组件挂载
Loading --> Ready: loadSettings()
Ready --> Modified: updateSettings()
Modified --> Modified: 继续修改
Modified --> Ready: 保存成功
Modified --> Error: 保存失败
Error --> Modified: 用户继续操作
Ready --> [*]: 组件卸载
note right of Modified
保存按钮启用
Save Button Enabled
end note
note right of Ready
保存按钮禁用
Save Button Disabled
end note
```
### 2. 预加载脚本 / Preload Script
#### preload/index.ts (`src/preload/index.ts`)
**主要职责 / Main Responsibilities:**
- 安全桥梁,暴露受限 API 到渲染进程
- 类型安全的 IPC 通道定义
**关键代码 / Key Code:**
```typescript
// 第 89-97 行 / Lines 89-97
settings: {
getUserType: () => ipcRenderer.invoke('settings:getUserType'),
getSettings: () => ipcRenderer.invoke('settings:getSettings'),
saveSettings: (settings: SettingsData) =>
ipcRenderer.invoke('settings:saveSettings', settings),
resetDefaults: () => ipcRenderer.invoke('settings:resetDefaults'),
testErpConnection: () => ipcRenderer.invoke('settings:testErpConnection'),
testDbConnection: () => ipcRenderer.invoke('settings:testDbConnection')
}
```
**安全隔离机制 / Security Isolation:**
```mermaid
graph TB
Renderer[Renderer Process<br/>Untrusted Context]
Preload[Preload Script<br/>Trusted Context]
Main[Main Process<br/>Trusted Context]
Renderer -->|window.electron| Preload
Preload -->|ipcRenderer.invoke| Main
Main -->|Validation| Preload
Preload -->|Return Promise| Renderer
style Renderer fill:#ffe1e1
style Preload fill:#e1ffe1
style Main fill:#e1e1ff
```
### 3. 主进程 / Main Process
#### settings-handler.ts (`src/main/ipc/settings-handler.ts`)
**主要职责 / Main Responsibilities:**
- IPC 通道注册和处理
- 权限验证(基于用户类型)
- 业务逻辑协调
**保存设置处理函数 / Save Settings Handler:**
```typescript
// 第 83-102 行 / Lines 83-102
ipcMain.handle(
'settings:saveSettings',
async (_event, settings: SettingsData): Promise<SaveSettingsResult> => {
try {
log.info('Saving settings')
const success = await configManager.saveAllSettings(settings)
if (success) {
log.info('Settings saved successfully')
return { success: true }
} else {
log.warn('Failed to save settings')
return { success: false, error: '保存设置失败' }
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
log.error('Error saving settings', { error: message })
return { success: false, error: `保存设置失败:${message}` }
}
}
)
```
**用户类型过滤 / User Type Filtering:**
```typescript
// 第 31-54 行 / Lines 31-54
function filterSettingsByUserType(settings: SettingsData, userType: UserType): SettingsData {
if (userType === 'Admin') {
return settings // Admin 获取完整配置
}
// User 用户获取受限配置
return {
erp: {
username: settings.erp.username,
password: settings.erp.password,
headless: settings.erp.headless,
url: settings.erp.url,
ignoreHttpsErrors: settings.erp.ignoreHttpsErrors,
autoCloseBrowser: settings.erp.autoCloseBrowser
},
paths: settings.paths,
execution: settings.execution,
database: settings.database,
extraction: settings.extraction,
validation: settings.validation,
ui: settings.ui
}
}
```
**权限控制矩阵 / Permission Control Matrix:**
| 功能 / Feature | Admin | User | Guest |
| -------------- | ----- | ------- | ----- |
| 查看所有设置 | ✅ | ⚠️ 部分 | ❌ |
| 保存设置 | ✅ | ✅ | ❌ |
| 恢复默认值 | ✅ | ❌ | ❌ |
| 测试 ERP 连接 | ✅ | ✅ | ❌ |
| 测试数据库连接 | ✅ | ✅ | ❌ |
### 4. 配置管理服务 / Configuration Manager Service
#### config-manager.ts (`src/main/services/config/config-manager.ts`)
**主要职责 / Main Responsibilities:**
- .env 文件读写
- 配置缓存管理
- 默认值管理
- 类型转换和验证
**类结构 / Class Structure:**
```typescript
export class ConfigManager {
private static instance: ConfigManager | null = null // 单例模式
private envPath: string // .env 文件路径
private configCache: Map<string, string> // 内存缓存
private initialized: boolean = false // 初始化标记
// 单例获取方法
public static getInstance(): ConfigManager
// 配置读取
public get(key: string, defaultValue?: string): string | undefined
public getBoolean(key: string, defaultValue?: boolean): boolean
public getNumber(key: string, defaultValue?: number): number
// 配置写入
public set(key: string, value: string | number | boolean): void
// 持久化
public async save(): Promise<boolean>
// 高级操作
public getAllSettings(): SettingsData
public async saveAllSettings(settings: SettingsData): Promise<boolean>
public resetToDefaults(): SettingsData
}
```
**保存详细流程 / Save Detailed Flow:**
```mermaid
graph TD
START[saveAllSettings] --> STEP1[更新 ERP 配置 6 字段]
STEP1 --> STEP2[更新数据库配置 7 字段]
STEP2 --> STEP3[更新路径配置 3 字段]
STEP3 --> STEP4[更新提取配置 5 字段]
STEP4 --> STEP5[更新校验配置 5 字段]
STEP5 --> STEP6[更新 UI 配置 3 字段]
STEP6 --> STEP7[更新执行配置 1 字段]
STEP7 --> SAVE[调用 save 方法]
SAVE --> BUILD[构建 .env 内容]
BUILD --> WRITE[写入文件系统]
WRITE --> CHECK{检查结果}
CHECK -->|成功| SUCCESS[返回 true]
CHECK -->|失败| FAILURE[返回 false]
```
**.env 文件格式 / .env File Format:**
```bash
# ===========================
# ERP 系统配置
# ===========================
ERP_URL=https://68.11.34.30:8082/
ERP_USERNAME=
ERP_PASSWORD=
ERP_HEADLESS=true
ERP_IGNORE_HTTPS_ERRORS=true
ERP_AUTO_CLOSE_BROWSER=true
# ===========================
# 数据库配置 - MySQL
# ===========================
DB_TYPE=mysql
DB_NAME=BLD_DB
DB_USERNAME=remote_user
DB_PASSWORD=
DB_MYSQL_HOST=192.168.31.83
DB_MYSQL_PORT=3306
DB_MYSQL_CHARSET=utf8mb4
# ===========================
# 路径配置
# ===========================
PATH_DATA_DIR=D:/python/playwrite/data/
PATH_DEFAULT_OUTPUT=离散备料计划维护_合并.xlsx
PATH_VALIDATION_OUTPUT=物料状态校验结果.xlsx
# ... 更多配置节
```
---
## 数据结构 / Data Structures
### SettingsData 接口 / Interface Definition
**类型定义位置 / Type Definition Location:**
`src/main/types/settings.types.ts` (第 136-151 行)
```typescript
export interface SettingsData {
erp: ErpConfig
database: DatabaseConfig
paths: PathsConfig
extraction: ExtractionConfig
validation: ValidationConfig
ui: UiConfig
execution: ExecutionConfig
}
```
### 完整数据结构树 / Complete Data Structure Tree
```mermaid
graph TB
Settings[SettingsData]
Settings --> Erp[ErpConfig]
Erp --> Erp1[url: string]
Erp --> Erp2[username: string]
Erp --> Erp3[password: string]
Erp --> Erp4[headless: boolean]
Erp --> Erp5[ignoreHttpsErrors: boolean]
Erp --> Erp6[autoCloseBrowser: boolean]
Settings --> DB[DatabaseConfig]
DB --> DB1[dbType: mysql or sqlserver]
DB --> DB2[server: string]
DB --> DB3[mysqlHost: string]
DB --> DB4[mysqlPort: number]
DB --> DB5[database: string]
DB --> DB6[username: string]
DB --> DB7[password: string]
Settings --> Paths[PathsConfig]
Paths --> Paths1[dataDir: string]
Paths --> Paths2[defaultOutput: string]
Paths --> Paths3[validationOutput: string]
Settings --> Extract[ExtractionConfig]
Extract --> Extract1[batchSize: number]
Extract --> Extract2[verbose: boolean]
Extract --> Extract3[autoConvert: boolean]
Extract --> Extract4[mergeBatches: boolean]
Extract --> Extract5[enableDbPersistence: boolean]
Settings --> Valid[ValidationConfig]
Valid --> Valid1[dataSource: ValidationDataSource]
Valid --> Valid2[batchSize: number]
Valid --> Valid3[matchMode: MatchMode]
Valid --> Valid4[enableCrud: boolean]
Valid --> Valid5[defaultManager: string]
Settings --> UI[UiConfig]
UI --> UI1[fontFamily: string]
UI --> UI2[fontSize: number]
UI --> UI3[productionIdInputWidth: number]
Settings --> Exec[ExecutionConfig]
Exec --> Exec1[dryRun: boolean]
style Settings fill:#e1f5ff
style Erp fill:#ffe1f5
style DB fill:#e1ffe1
style Paths fill:#fff4e1
style Extract fill:#f5e1ff
style Valid fill:#ffe1e1
style UI fill:#e1f5ff
style Exec fill:#f5f5f5
```
### IPC 通信数据格式 / IPC Communication Data Format
**请求格式 / Request Format:**
```json
{
"erp": {
"url": "https://68.11.34.30:8082/",
"username": "admin",
"password": "password123",
"headless": true,
"ignoreHttpsErrors": true,
"autoCloseBrowser": true
},
"database": { ... },
"paths": { ... },
"extraction": { ... },
"validation": { ... },
"ui": { ... },
"execution": { ... }
}
```
**响应格式 / Response Format:**
```json
// 成功 / Success
{
"success": true
}
// 失败 / Failure
{
"success": false,
"error": "保存设置失败Access denied"
}
```
---
## 错误处理机制 / Error Handling Mechanism
### 错误处理层次 / Error Handling Layers
```mermaid
graph TB
subgraph "UI Layer"
UI_TRY[try-catch in handleSaveSettings]
UI_MSG[showMessage display]
end
subgraph "IPC Layer"
IPC_TRY[try-catch in handler]
IPC_LOG[Structured logging]
IPC_RETURN[Return error object]
end
subgraph "Service Layer"
SVC_TRY[try-catch in save]
SVC_LOG[Console error log]
SVC_RETURN[Return false]
end
subgraph "File System"
FS_CHECK[File exists check]
FS_WRITE[Write with error handling]
end
UI_TRY -->|Catch| UI_MSG
IPC_TRY -->|Catch| IPC_LOG --> IPC_RETURN
SVC_TRY -->|Catch| SVC_LOG --> SVC_RETURN
FS_WRITE -->|Error| SVC_TRY
style UI_TRY fill:#ffe1e1
style IPC_TRY fill:#ffe1e1
style SVC_TRY fill:#ffe1e1
```
### 错误场景分析 / 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 异常捕获 | 返回系统错误信息 |
### 日志记录策略 / Logging Strategy
```typescript
// Main Process 结构化日志 / Structured Logging
log.info('Saving settings')
log.info('Settings saved successfully')
log.warn('Failed to save settings')
log.error('Error saving settings', { error: message })
```
**日志级别使用 / Log Level Usage:**
- `info`: 正常操作流程
- `warn`: 潜在问题(如保存失败但未崩溃)
- `error`: 严重错误(如异常抛出)
---
## 安全考虑 / Security Considerations
### 安全机制层级 / Security Layers
```mermaid
graph TB
L1[Layer 1: Context Isolation<br/>渲染进程隔离]
L2[Layer 2: contextBridge<br/>受限 API 暴露]
L3[Layer 3: User Type Filtering<br/>基于角色的访问控制]
L4[Layer 4: File System Permissions<br/>.env 文件保护]
L1 --> L2 --> L3 --> L4
style L1 fill:#e1f5ff
style L2 fill:#fff4e1
style L3 fill:#e1ffe1
style L4 fill:#ffe1f5
```
### 关键安全措施 / Key Security Measures
1. **密码明文存储风险 / Password Storage Risk**
- ⚠️ 当前:密码以明文形式存储在 .env 文件中
- 🔒 建议:实现加密存储机制
2. **用户权限隔离 / User Permission Isolation**
- ✅ 实现:基于用户类型过滤可见配置
- ✅ 实现Guest 用户无法访问设置页面
3. **IPC 通信安全 / IPC Communication Security**
- ✅ 实现:使用 `contextBridge` 而非直接暴露
- ✅ 实现:类型安全的 TypeScript 接口
4. **文件系统访问 / File System Access**
- ✅ 实现:.env 文件仅主进程可访问
- ⚠️ 风险:文件权限取决于操作系统
### 敏感数据流向 / Sensitive Data Flow
```mermaid
sequenceDiagram
participant User as 用户输入
participant UI as UI State (内存)
participant IPC as IPC Channel
participant Cache as Config Cache
participant File as .env File
User->>UI: password = "secret123"
UI->>IPC: JSON 传输 (未加密)
IPC->>Cache: Map.set('erp.password', 'secret123')
Cache->>File: 写入明文到磁盘
Note over File: ⚠️ 安全风险:<br/>密码以明文形式持久化
```
---
## 技术实现细节 / Technical Implementation Details
### 文件位置索引 / 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 |
### 性能特性 / Performance Characteristics
1. **异步操作 / Async Operations**
- 所有 IPC 调用使用 `async/await` 模式
- 避免阻塞主进程事件循环
2. **内存优化 / Memory Optimization**
- 使用 Map 缓存配置,减少文件读取
- 按需加载配置项
3. **写入策略 / Write Strategy**
- 每次保存完整重写 .env 文件
- 原子写入writeFileSync
### 依赖关系图 / Dependency Graph
```mermaid
graph TD
A[SettingsPage.tsx] -->|imports| B[lucide-react]
A -->|uses| C[window.electron.settings]
C -->|exposed by| D[preload/index.ts]
D -->|imports| E[electron API]
D -->|imports| F[SettingsData Type]
G[settings-handler.ts] -->|imports| H[ipcMain]
G -->|imports| I[ConfigManager]
G -->|imports| J[SessionManager]
G -->|imports| K[Logger]
I -->|imports| L[fs/path]
I -->|imports| M[SettingsData Type]
I -->|imports| N[DEFAULT_SETTINGS]
style A fill:#e1f5ff
style D fill:#fff4e1
style G fill:#ffe1f5
style I fill:#e1ffe1
```
### 关键代码片段分析 / Key Code Snippet Analysis
**1. 状态更新逻辑 / State Update Logic**
```typescript
// SettingsPage.tsx 第 50-59 行
const updateSettings = (category: string, key: string, value: any) => {
setSettings((prev) => ({
...prev,
[category]: {
...(prev as any)[category],
[key]: value
}
}))
setIsModified(true) // 标记为已修改
}
```
**设计要点 / Design Points:**
- 不可变更新模式Immutable Update Pattern
- 使用展开运算符保持对象引用
- 自动启用保存按钮
**2. 配置保存逻辑 / Configuration Save Logic**
```typescript
// config-manager.ts 第 437-483 行
public async saveAllSettings(settings: SettingsData): Promise<boolean> {
// 批量更新缓存 (40+ 字段)
this.set('erp.url', settings.erp.url)
this.set('erp.username', settings.erp.username)
// ... 更多字段
// 同步写入文件
return this.save()
}
```
**设计要点 / Design Points:**
- 先更新内存,后写入磁盘
- 失败时缓存保持不变
- 返回布尔值表示成功/失败
**3. .env 文件生成逻辑 / .env File Generation**
```typescript
// config-manager.ts 第 179-345 行
public async save(): Promise<boolean> {
const lines: string[] = []
// 构建格式化的 .env 内容
lines.push('# ===========================')
lines.push('# ERP 系统配置')
lines.push('# ===========================')
lines.push(`ERP_URL=${this.configCache.get('erp.url') || DEFAULT_SETTINGS.erp.url}`)
const content = lines.join('\n')
fs.writeFileSync(this.envPath, content, 'utf-8')
return true
}
```
**设计要点 / Design Points:**
- 添加注释分隔符提高可读性
- 使用默认值作为后备
- 同步写入确保一致性
---
## 扩展与改进建议 / Extension and Improvement Suggestions
### 短期改进 / Short-term Improvements
1. **输入验证 / Input Validation**
- 添加 URL 格式验证
- 密码强度检查
- 端口号范围验证
2. **用户体验 / User Experience**
- 添加保存进度指示器
- 实现自动保存功能
- 添加配置导入/导出
3. **错误处理 / Error Handling**
- 更详细的错误消息
- 错误恢复建议
- 错误日志导出
### 长期改进 / Long-term Improvements
1. **安全性增强 / Security Enhancement**
```typescript
// 建议实现密码加密
interface SecureSettingsData extends SettingsData {
erp: {
...ErpConfig
encryptedPassword: string // 替代明文密码
}
}
```
2. **配置版本控制 / Configuration Versioning**
- 实现配置历史记录
- 支持回滚到之前版本
- 配置变更审计日志
3. **实时配置重载 / Live Config Reload**
- 监听 .env 文件变化
- 自动重载配置
- 通知相关服务更新
---
## 测试建议 / Testing Recommendations
### 单元测试 / Unit Tests
```typescript
// 测试用例示例
describe('ConfigManager', () => {
it('should save settings successfully', async () => {
const manager = ConfigManager.getInstance()
const settings: SettingsData = {
/* mock data */
}
const result = await manager.saveAllSettings(settings)
expect(result).toBe(true)
})
it('should handle file write errors', async () => {
// Mock fs.writeFileSync to throw error
const result = await manager.saveAllSettings(settings)
expect(result).toBe(false)
})
})
```
### 集成测试 / Integration Tests
```typescript
describe('Settings Save Flow', () => {
it('should complete full save cycle', async () => {
// 1. User modifies settings
// 2. Clicks save button
// 3. Verifies .env file updated
// 4. Confirms UI feedback
})
})
```
---
## 附录 / Appendix
### 完整配置字段列表 / 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** | |
### 相关文档 / Related Documentation
- [Electron Security Guidelines](https://www.electronjs.org/docs/latest/tutorial/security)
- [IPC 通信最佳实践](https://www.electronjs.org/docs/latest/tutorial/ipc)
- [环境变量管理规范](.env.example)
### 版本历史 / Version History
| 版本 / Version | 日期 / Date | 变更 / Changes |
| -------------- | ----------- | -------------------------- |
| 1.0 | 2025-03-03 | 初始版本 / Initial version |
---
**文档生成时间 / Document Generated:** 2025-03-03
**最后更新 / Last Updated:** 2025-03-03
**维护者 / Maintainer:** ERPAuto Development Team

View File

@@ -0,0 +1,287 @@
# 物料匹配算法增强 - 用户覆盖匹配功能
**实施日期**: 2026-03-03
**功能版本**: 1.0
**修改文件**: `src/main/ipc/validation-handler.ts`
---
## 功能概述
**User 用户类型** 在物料清理界面增加了 **优先级3用户覆盖匹配** 功能,确保 User 用户能够优先看到并管理与自己关键词匹配的物料。
---
## 实现的更改
### 1. 获取当前用户信息
**位置**: `validation-handler.ts:218-239`
```typescript
// Get current user info
const sessionManager = (
await import('../services/user/session-manager')
).SessionManager.getInstance()
const userInfo = sessionManager.getUserInfo()
if (!userInfo) {
return {
success: false,
error: '用户未登录',
stats: {
totalRecords: 0,
matchedCount: 0,
markedCount: 0
}
}
}
const isAdmin = userInfo.userType === 'Admin'
const username = userInfo.username
log.info('Starting validation', { mode: request.mode, user: username, isAdmin })
```
**说明**:
-`validation:validate` handler 开始时获取当前登录用户信息
- 提取 `isAdmin``username` 用于后续匹配逻辑
- 如果用户未登录,返回错误响应
### 2. 新增优先级3用户覆盖匹配
**位置**: `validation-handler.ts:359-370`
```typescript
// Priority 3: User Override Match (only for non-admin users)
// Override with current user's typeKeyword if available
if (!isAdmin && username) {
const userKeywords = typeKeywords.filter((tk) => tk.managerName === username)
for (const userKeyword of userKeywords) {
if (userKeyword.materialName && materialName.includes(userKeyword.materialName)) {
matchedTypeKeyword = userKeyword.materialName
managerName = userKeyword.managerName
break // Force override with first match
}
}
}
```
**匹配逻辑**:
1. **适用范围**: 仅对 `isAdmin === false` 的 User 用户生效
2. **筛选关键词**: 从 `typeKeywords` 中筛选 `managerName === username` 的记录
3. **匹配规则**: 使用 `materialName.includes(userKeyword.materialName)` 包含关系匹配
4. **强制覆盖**: 只要匹配成功,立即覆盖原有的 `managerName``matchedTypeKeyword`
5. **无匹配时**: 保持优先级2的匹配结果不变
---
## 匹配优先级(更新后)
```mermaid
flowchart TB
Start([物料数据]) --> P1{优先级1<br/>MaterialsToBeDeleted<br/>精确匹配?}
P1 -->|MaterialCode匹配| M1[✅ 已标记删除<br/>isMarkedForDeletion=true]
P1 -->|未匹配| P2{优先级2<br/>MaterialsTypeToBeDeleted<br/>包含匹配?}
P2 -->|匹配到| M2[⚠️ 类型匹配<br/>managerName=其他用户]
P2 -->|未匹配| M3[❌ 未匹配<br/>managerName='']
M1 --> Check{用户类型?}
M2 --> Check
M3 --> Check
Check -->|Admin| Skip[跳过覆盖]
Check -->|User| P3{优先级3<br/>用户覆盖匹配?}
P3 -->|匹配成功| Override[✅ 覆为当前用户<br/>managerName=当前用户]
P3 -->|未匹配| Keep[保持原结果]
Skip --> End([返回结果])
Override --> End
Keep --> End
```
---
## 测试场景
### 场景1: User 用户匹配到自己的 typeKeyword
**输入**:
- 当前用户: `user1`
- 物料名称: `螺丝 M6`
- MaterialsTypeToBeDeleted: `{ materialName: "螺丝", managerName: "user1" }`
**预期输出**:
```json
{
"materialName": "螺丝 M6",
"managerName": "user1",
"matchedTypeKeyword": "螺丝",
"isMarkedForDeletion": false
}
```
### 场景2: User 用户覆盖其他用户的匹配
**输入**:
- 当前用户: `user1`
- 物料名称: `螺丝 M6`
- MaterialsTypeToBeDeleted:
- `{ materialName: "螺丝", managerName: "user2" }`
- `{ materialName: "螺丝", managerName: "user1" }`
**优先级2结果**: `managerName = "user2"`
**优先级3结果**: `managerName = "user1"` ✅ 强制覆盖
### 场景3: User 用户无匹配关键词
**输入**:
- 当前用户: `user1`
- 物料名称: `螺丝 M6`
- MaterialsTypeToBeDeleted:
- `{ materialName: "螺丝", managerName: "user2" }`
**预期输出**:
```json
{
"materialName": "螺丝 M6",
"managerName": "user2",
"matchedTypeKeyword": "螺丝",
"isMarkedForDeletion": false
}
```
**说明**: 保持优先级2的匹配结果
### 场景4: Admin 用户不执行覆盖
**输入**:
- 当前用户: `admin` (isAdmin=true)
- 物料名称: `螺丝 M6`
- MaterialsTypeToBeDeleted:
- `{ materialName: "螺丝", managerName: "user1" }`
- `{ materialName: "螺丝", managerName: "admin" }`
**预期输出**:
```json
{
"materialName": "螺丝 M6",
"managerName": "user1",
"matchedTypeKeyword": "螺丝",
"isMarkedForDeletion": false
}
```
**说明**: Admin 不执行优先级3保持原有匹配行为
### 场景5: 优先级1匹配不受影响
**输入**:
- 当前用户: `user1`
- 物料代码: `MAT001`
- MaterialsToBeDeleted: `{ materialCode: "MAT001", managerName: "user2" }`
**预期输出**:
```json
{
"materialCode": "MAT001",
"managerName": "user2",
"isMarkedForDeletion": true,
"matchedTypeKeyword": undefined
}
```
**说明**: 优先级1的精确匹配不受覆盖影响
---
## 数据库配置示例
### MaterialsTypeToBeDeleted 表数据
| MaterialName | ManagerName | 说明 |
| ------------ | ----------- | ------------------------------ |
| 螺丝 | user1 | user1 负责所有包含"螺丝"的物料 |
| 螺母 | user2 | user2 负责所有包含"螺母"的物料 |
| 垫圈 | 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跳过) |
---
## 与前端协同
前端过滤器逻辑 (`CleanerPage.tsx`) 保持不变:
```typescript
const filteredResults = React.useMemo(() => {
let results = validationResults
if (!isAdmin && currentUsername) {
// User 只看到自己的物料 + 未分配的物料
results = results.filter((r) => r.managerName === currentUsername || !r.managerName)
}
return results
}, [validationResults, isAdmin, currentUsername, managers, selectedManagers, hiddenItems])
```
**协同效果**:
1. 后端匹配算法确保 User 用户的物料优先分配给自己
2. 前端过滤器只显示属于当前用户或未分配的物料
3. Admin 用户可以看到所有物料并切换查看不同负责人
---
## 代码审查检查点
- ✅ User 信息获取正确使用 `SessionManager`
- ✅ 只对 `!isAdmin` 的用户执行覆盖逻辑
- ✅ 使用相同的包含匹配规则 `materialName.includes(typeKeyword.materialName)`
- ✅ 优先级1精确匹配不受覆盖影响
- ✅ 无匹配时保持原有结果
- ✅ 日志记录包含用户信息 `{ user: username, isAdmin }`
- ✅ 未登录时返回明确的错误信息
---
## 潜在改进方向
1. **性能优化**: 如果 `typeKeywords` 数量很大,可以预先构建 `Map<username, typeKeyword[]>` 索引
2. **日志增强**: 添加覆盖匹配的统计信息(覆盖了多少条记录)
3. **配置开关**: 允许 Admin 用户通过配置启用/禁用覆盖功能
4. **UI 反馈**: 在前端显示哪些物料是通过覆盖匹配分配的
---
## 相关文件
- **实现文件**: `src/main/ipc/validation-handler.ts` (Lines 218-239, 359-370)
- **前端页面**: `src/renderer/src/pages/CleanerPage.tsx`
- **会话管理**: `src/main/services/user/session-manager.ts`
- **类型定义**: `src/main/types/validation.types.ts`
---
**文档结束**

View File

@@ -5,14 +5,31 @@ directories:
files:
- '!**/.vscode/*'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!electron-vite.config.{js,ts,mjs,cjs}'
- '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
- 'package.json'
# Include build output
- 'out/**/*'
# Include config.template.yaml in the build for reference
- 'config.template.yaml'
# Exclude Playwright browser downloads (manual install for company environment)
- '!**/node_modules/playwright-core/.local-browsers/**'
asarUnpack:
- resources/**
# Unpack playwright for native modules
- '**/node_modules/playwright/**'
- '**/node_modules/playwright-core/**'
win:
executableName: erpauto
target:
- nsis
- portable
portable:
artifactName: ${name}-portable.${ext}
# Portable app uses user data directory (AppData), not exe directory
# This ensures config persists across app updates
nsis:
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}

View File

@@ -1,16 +1,49 @@
import { resolve } from 'path'
import { defineConfig } from 'electron-vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { execSync } from 'child_process'
import { createRequire } from 'module'
// Get git hash (first 7 characters)
const getGitHash = (): string => {
try {
return execSync('git rev-parse --short=7 HEAD', { encoding: 'utf-8' }).trim()
} catch {
return 'unknown'
}
}
// Get version from package.json
const require = createRequire(import.meta.url)
const version = require('./package.json').version
const gitHash = getGitHash()
export default defineConfig({
main: {},
preload: {},
renderer: {
define: {
__APP_VERSION__: JSON.stringify(version),
__GIT_HASH__: JSON.stringify(gitHash)
},
resolve: {
alias: {
'@renderer': resolve('src/renderer/src')
}
},
plugins: [react()]
plugins: [
react(),
tailwindcss(),
{
name: 'update-title',
transformIndexHtml(html) {
return html.replace(
'<title>ERP Auto Tool</title>',
`<title>ERPAuto - v${version}(${gitHash})</title>`
)
}
}
]
}
})

17170
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "erpauto",
"version": "1.0.0",
"version": "1.3.1",
"description": "An Electron application with React and TypeScript",
"main": "./out/main/index.js",
"author": "example.com",
@@ -12,26 +12,63 @@
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"dev": "chcp 65001 && electron-vite dev",
"build": "chcp 65001 && npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win",
"build:mac": "electron-vite build && electron-builder --mac",
"build:linux": "electron-vite build && electron-builder --linux"
"build:unpack": "chcp 65001 && set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 && npm run build && electron-builder --dir",
"build:win": "chcp 65001 && set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 && npm run prebuild && npm run build && electron-builder --win",
"build:mac": "chcp 65001 && npm run prebuild && electron-vite build && electron-builder --mac",
"build:linux": "chcp 65001 && npm run prebuild && electron-vite build && electron-builder --linux",
"prebuild": "node -e \"const fs=require('fs');['dist','out'].forEach(d=>{try{fs.rmSync(d,{recursive:true})}catch(e){}})\"",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report",
"debug:erp-login": "tsx src/main/tools/erp-login-debug.ts",
"debug:config-path": "tsx src/main/tools/config-path-debug.ts",
"test:rustfs": "tsx src/main/tools/rustfs-test.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.929.0",
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0"
"@electron-toolkit/utils": "^4.0.0",
"@tailwindcss/vite": "^4.2.1",
"@types/js-yaml": "^4.0.9",
"chromium-bidi": "^15.0.0",
"date-fns": "^4.1.0",
"exceljs": "^4.4.0",
"js-yaml": "^4.1.1",
"lucide-react": "^0.575.0",
"mssql": "^12.2.0",
"mysql2": "^3.18.2",
"playwright": "^1.58.2",
"playwright-core": "^1.58.2",
"react-focus-lock": "^2.13.7",
"react-markdown": "^10.1.0",
"reflect-metadata": "^0.2.2",
"remark-gfm": "^4.0.1",
"typeorm": "^0.3.28",
"uuid": "^13.0.0",
"winston": "^3.19.0",
"winston-daily-rotate-file": "^5.0.0",
"zod": "^4.3.6",
"zustand": "^5.0.11"
},
"devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.1.0",
"@electron-toolkit/tsconfig": "^2.0.0",
"@types/node": "^22.19.1",
"@playwright/test": "^1.58.2",
"@types/mssql": "^9.1.9",
"@types/node": "^22.19.13",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.0.18",
"autoprefixer": "^10.4.27",
"electron": "^39.2.6",
"electron-builder": "^26.0.12",
"electron-vite": "^5.0.0",
@@ -39,10 +76,14 @@
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"postcss": "^8.5.6",
"prettier": "^3.7.4",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"tailwindcss": "^4.2.1",
"tsx": "^4.19.3",
"typescript": "^5.9.3",
"vite": "^7.2.6"
"vite": "^7.2.6",
"vitest": "^4.0.18"
}
}

23
playwright.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
timeout: 120000,
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: 'html',
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure'
},
// Test configuration for Electron
projects: [
{
name: 'electron',
testMatch: '**/*.test.ts'
}
]
})

View File

@@ -0,0 +1,115 @@
/**
* 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()

View File

@@ -0,0 +1,142 @@
/**
* Fix ComputerNmae Typo in dbo_BIPUsers Table
*
* This script renames the column from 'ComputerNmae' to 'ComputerName'
*/
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 column name
console.log('=== Step 1: Check current column name ===')
const [columns] = await connection.execute(`
SELECT
COLUMN_NAME,
COLUMN_TYPE,
IS_NULLABLE,
CHARACTER_MAXIMUM_LENGTH,
COLUMN_KEY,
COLUMN_DEFAULT,
EXTRA
FROM
INFORMATION_SCHEMA.COLUMNS
WHERE
TABLE_NAME = 'dbo_BIPUsers'
AND TABLE_SCHEMA = DATABASE()
AND (COLUMN_NAME = 'ComputerNmae' OR COLUMN_NAME = 'ComputerName')
ORDER BY
ORDINAL_POSITION
`)
if (columns.length === 0) {
console.log('No ComputerNmae or ComputerName column found!')
return
}
console.table(columns)
const currentColumn = columns.find((col) => col.COLUMN_NAME === 'ComputerNmae')
const newColumn = columns.find((col) => col.COLUMN_NAME === 'ComputerName')
if (newColumn) {
console.log('\n=== Column is already named "ComputerName"! No modification needed. ===')
return
}
if (!currentColumn) {
console.log('\n=== ERROR: ComputerNmae column not found! ===')
return
}
// 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_BIPUsers')
console.log(`Total records: ${countBefore[0].total}`)
// Step 3: Show sample data with the column
console.log('\n=== Step 3: Sample data (showing ComputerNmae column) ===')
const [sample] = await connection.execute(`
SELECT ID, UserName, UserType, ComputerNmae, CreateTime
FROM dbo_BIPUsers
LIMIT 5
`)
console.table(sample)
// Step 4: Rename the column
console.log('\n=== Step 4: Renaming column ComputerNmae -> ComputerName ===')
await connection.execute(`
ALTER TABLE dbo_BIPUsers
CHANGE COLUMN ComputerNmae ComputerName VARCHAR(255) NULL
`)
console.log('Column renamed successfully!\n')
// Step 5: Verify the change
console.log('=== Step 5: Verify modification ===')
const [columnsAfter] = await connection.execute(`
SELECT
COLUMN_NAME,
COLUMN_TYPE,
IS_NULLABLE,
CHARACTER_MAXIMUM_LENGTH,
COLUMN_KEY,
COLUMN_DEFAULT,
EXTRA
FROM
INFORMATION_SCHEMA.COLUMNS
WHERE
TABLE_NAME = 'dbo_BIPUsers'
AND TABLE_SCHEMA = DATABASE()
AND COLUMN_NAME = 'ComputerName'
`)
console.table(columnsAfter)
// Step 6: Verify data is still intact
console.log('\n=== Step 6: Verify data integrity ===')
const [countAfter] = await connection.execute('SELECT COUNT(*) AS total FROM dbo_BIPUsers')
console.log(`Total records after modification: ${countAfter[0].total}`)
// Step 7: Show sample data with new column name
console.log('\n=== Step 7: Sample data (showing ComputerName column) ===')
const [sampleAfter] = await connection.execute(`
SELECT ID, UserName, UserType, ComputerName, CreateTime
FROM dbo_BIPUsers
LIMIT 5
`)
console.table(sampleAfter)
if (countBefore[0].total === countAfter[0].total) {
console.log(
'\n✅ SUCCESS: All data preserved, column renamed from ComputerNmae to ComputerName!'
)
} 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()

View File

@@ -0,0 +1,13 @@
-- Migration script to fix ComputerNmae typo in dbo_BIPUsers table
-- Changes column name from 'ComputerNmae' to 'ComputerName'
-- Date: 2026-03-05
-- Rename the column (MySQL syntax)
ALTER TABLE dbo_BIPUsers
CHANGE COLUMN ComputerNmae ComputerName VARCHAR(255) NULL;
-- Verify the change
SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, IS_NULLABLE
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'dbo_BIPUsers'
AND COLUMN_NAME = 'ComputerName';

View File

@@ -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.
-- ============================================================================

View File

@@ -0,0 +1,125 @@
/**
* Migration Script: .env to config.yaml
*
* Usage: npx tsx scripts/migrate-env-to-yaml.ts
*
* This script migrates the old .env configuration to the new YAML format.
* ERP configuration is NOT migrated as it's now stored in the database per user.
*/
import * as fs from 'fs'
import * as path from 'path'
import yaml from 'js-yaml'
const ENV_PATH = path.resolve(process.cwd(), '.env')
const YAML_PATH = path.resolve(process.cwd(), 'config.yaml')
const BACKUP_PATH = path.resolve(process.cwd(), '.env.backup')
interface EnvConfig {
[key: string]: string
}
function parseEnvFile(content: string): EnvConfig {
const result: EnvConfig = {}
const lines = content.split('\n')
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const [key, ...valueParts] = trimmed.split('=')
if (key && valueParts.length > 0) {
result[key.trim()] = valueParts.join('=').trim()
}
}
return result
}
function migrate() {
console.log('🔄 Starting migration from .env to config.yaml...\n')
if (!fs.existsSync(ENV_PATH)) {
console.error('❌ .env file not found at:', ENV_PATH)
process.exit(1)
}
const envContent = fs.readFileSync(ENV_PATH, 'utf-8')
const env = parseEnvFile(envContent)
// Build configuration object (without ERP)
const config = {
database: {
activeType: (env.DB_TYPE || 'mysql').toLowerCase() as 'mysql' | 'sqlserver',
mysql: {
host: env.DB_MYSQL_HOST || 'localhost',
port: parseInt(env.DB_MYSQL_PORT || '3306', 10),
database: env.DB_NAME || '',
username: env.DB_USERNAME || '',
password: env.DB_PASSWORD || '',
charset: env.DB_MYSQL_CHARSET || 'utf8mb4'
},
sqlserver: {
server: env.DB_SERVER || 'localhost',
port: parseInt(env.DB_SQLSERVER_PORT || '1433', 10),
database: env.DB_NAME || '',
username: env.DB_USERNAME || '',
password: env.DB_PASSWORD || '',
driver: env.DB_SQLSERVER_DRIVER || 'ODBC Driver 18 for SQL Server',
trustServerCertificate: env.DB_TRUST_SERVER_CERTIFICATE === 'yes'
}
},
paths: {
dataDir: env.PATH_DATA_DIR || './data/',
defaultOutput: env.PATH_DEFAULT_OUTPUT || 'output.xlsx',
validationOutput: env.PATH_VALIDATION_OUTPUT || 'validation-result.xlsx'
},
extraction: {
batchSize: parseInt(env.EXTRACTION_BATCH_SIZE || '100', 10),
verbose: env.EXTRACTION_VERBOSE !== 'false',
autoConvert: env.EXTRACTION_AUTO_CONVERT !== 'false',
mergeBatches: env.EXTRACTION_MERGE_BATCHES !== 'false',
enableDbPersistence: env.EXTRACTION_ENABLE_DB_PERSISTENCE !== 'false'
},
validation: {
dataSource: env.VALIDATION_DATA_SOURCE || 'database_full',
batchSize: parseInt(env.VALIDATION_BATCH_SIZE || '2000', 10),
matchMode: env.VALIDATION_MATCH_MODE || 'substring',
enableCrud: env.VALIDATION_ENABLE_CRUD === 'true',
defaultManager: env.VALIDATION_DEFAULT_MANAGER || ''
},
orderResolution: {
tableName: env.DB_TABLE_NAME || '',
productionIdField: env.DB_FIELD_PRODUCTION_ID || '',
orderNumberField: env.DB_FIELD_ORDER_NUMBER || ''
}
}
// Backup .env
if (fs.existsSync(ENV_PATH)) {
fs.copyFileSync(ENV_PATH, BACKUP_PATH)
console.log('📁 Backed up .env to .env.backup')
}
// Write YAML with header comments
const header = `# ================================\n# ERPAuto 配置文件\n# ================================\n# 由 .env 迁移生成\n# 迁移时间:${new Date().toISOString()}\n# 注意ERP 配置已迁移到数据库 (dbo_BIPUsers 表)\n# ================================\n\n`
const yamlContent = yaml.dump(config, {
indent: 2,
lineWidth: -1,
noRefs: true,
quotingType: '"',
forceQuotes: false
})
fs.writeFileSync(YAML_PATH, header + yamlContent, 'utf-8')
console.log('✅ Migration completed successfully!')
console.log(`📁 Config saved to: ${YAML_PATH}`)
console.log('\n📋 Next steps:')
console.log(' 1. Review config.yaml and verify all values')
console.log(' 2. Test the application thoroughly')
console.log(' 3. Remove .env file when confident (optional)\n')
}
migrate()

View File

@@ -1,19 +1,32 @@
import { app, shell, BrowserWindow, ipcMain } from 'electron'
import { app, shell, BrowserWindow, ipcMain, dialog } from 'electron'
import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
import { registerIpcHandlers } from './ipc'
import { ConfigManager } from './services/config/config-manager'
import logger from './services/logger/index'
import { logAudit } from './services/logger/audit-logger'
import { fileURLToPath } from 'url'
import { dirname } from 'path'
import fs from 'fs'
// Set Playwright browsers path BEFORE any playwright import
process.env.PLAYWRIGHT_BROWSERS_PATH = join(app.getPath('userData'), 'ms-playwright')
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
function createWindow(): void {
// Create the browser window.
const mainWindow = new BrowserWindow({
width: 900,
width: 1200,
height: 670,
show: false,
autoHideMenuBar: true,
...(process.platform === 'linux' ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false
sandbox: true
}
})
@@ -38,7 +51,70 @@ function createWindow(): void {
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(() => {
app.whenReady().then(async () => {
// Validate Playwright browser path
const browsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH!
// Create directory if it doesn't exist
try {
fs.mkdirSync(browsersPath, { recursive: true })
} catch (error) {
console.error('Failed to create browsers directory:', error)
}
// Check if chromium browser exists (supports both old and new Playwright directory structures)
// New format (v1.48+): chromium-1208/chrome-win64/chrome.exe
// Old format: chromium-win32/chrome.exe
const newChromiumPath = join(browsersPath, 'chromium-1208', 'chrome-win64', 'chrome.exe')
const oldChromiumPath = join(browsersPath, 'chromium-win32', 'chrome.exe')
const chromiumPath = fs.existsSync(newChromiumPath) ? newChromiumPath : oldChromiumPath
if (!fs.existsSync(chromiumPath)) {
// Try to find any chromium revision
let foundRevision = false
try {
const entries = fs.readdirSync(browsersPath)
for (const entry of entries) {
if (entry.startsWith('chromium-') && !entry.includes('headless')) {
const revisionPath = join(browsersPath, entry, 'chrome-win64', 'chrome.exe')
if (fs.existsSync(revisionPath)) {
console.log('Found Chromium revision:', entry)
foundRevision = true
break
}
}
}
} catch (e) {
// Ignore
}
if (!foundRevision) {
dialog.showErrorBox(
'浏览器文件未找到',
`Playwright 浏览器文件不存在。\n\n` +
`期望路径:${newChromiumPath}\n` +
`或:${oldChromiumPath}\n\n` +
`当前目录内容:${fs.existsSync(browsersPath) ? fs.readdirSync(browsersPath).join(', ') : '目录不存在'}\n\n` +
`请运行以下命令安装浏览器:\n` +
`npx playwright install chromium`
)
console.warn(
'Playwright browser not found. Available:',
fs.existsSync(browsersPath) ? fs.readdirSync(browsersPath) : 'none'
)
}
}
// Initialize ConfigManager BEFORE registering IPC handlers
// This ensures config is loaded before any service tries to use it
try {
const configManager = ConfigManager.getInstance()
await configManager.initialize()
} catch (error) {
console.error('Failed to initialize ConfigManager:', error)
// Continue anyway - default config will be created
}
// Set app user model id for windows
electronApp.setAppUserModelId('com.electron')
@@ -49,6 +125,9 @@ app.whenReady().then(() => {
optimizer.watchWindowShortcuts(window)
})
// Register IPC handlers (after ConfigManager is initialized)
registerIpcHandlers()
// IPC test
ipcMain.on('ping', () => console.log('pong'))
@@ -70,5 +149,41 @@ app.on('window-all-closed', () => {
}
})
// Global exception handlers to prevent crashes without logging
process.on('uncaughtException', async (err) => {
logger.error('Uncaught exception', { error: err })
await logAudit('SYSTEM_CRASH', 'system', {
username: 'system',
computerName: process.env.COMPUTERNAME || 'unknown',
resource: 'main-process',
status: 'failure',
metadata: { error: err.message, stack: err.stack }
})
console.error('Uncaught exception:', err)
setTimeout(() => process.exit(1), 1000)
})
process.on('unhandledRejection', async (reason, promise) => {
logger.error('Unhandled Rejection', { reason: String(reason) })
await logAudit('SYSTEM_ERROR', 'system', {
username: 'system',
computerName: process.env.COMPUTERNAME || 'unknown',
resource: 'main-process',
status: 'failure',
metadata: { reason: String(reason) }
})
console.error('Unhandled Rejection:', reason)
})
app.on('render-process-gone', (_, webContents, details) => {
logger.error('Render process gone', { details, webContentsId: webContents.id })
console.error('Render process gone:', details)
})
app.on('child-process-gone', (_, details) => {
logger.error('Child process gone', { details })
console.error('Child process gone:', details)
})
// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and require them here.

View File

@@ -0,0 +1,262 @@
/**
* IPC handlers for User Authentication
*
* Provides APIs for the renderer process to:
* - Login with username and password
* - Silent login by computer name
* - Logout
* - Get current user info
* - Get all users (for admin user selection)
* - Switch user (admin only)
*/
import { ipcMain } from 'electron'
import { SessionManager } from '../services/user/session-manager'
import { createLogger } from '../services/logger'
import { logAudit } from '../services/logger/audit-logger'
import type { UserInfo } from '../types/user.types'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { ValidationError } from '../types/errors'
import { withErrorHandling, type IpcResult } from './index'
const log = createLogger('AuthHandler')
/**
* Login request
*/
export interface LoginRequest {
username: string
password: string
}
/**
* Login response
*/
export interface LoginResponse {
success: boolean
userInfo?: UserInfo
error?: string
}
/**
* Silent login response
*/
export interface SilentLoginResponse {
success: boolean
userInfo?: UserInfo
requiresUserSelection?: boolean // True if admin needs to select a user
error?: string
}
/**
* User selection response
*/
export interface UserSelectionResponse {
success: boolean
userInfo?: UserInfo
error?: string
}
/**
* Current user response
*/
export interface CurrentUserResponse {
isAuthenticated: boolean
userInfo?: UserInfo
}
/**
* Register IPC handlers for user authentication
*/
export function registerAuthHandlers(): void {
const sessionManager = SessionManager.getInstance()
/**
* Get computer name
*/
ipcMain.handle(IPC_CHANNELS.AUTH_GET_COMPUTER_NAME, async (): Promise<IpcResult<string>> => {
return withErrorHandling(async () => {
const os = await import('os')
return os.hostname()
}, 'auth:getComputerName')
})
/**
* Silent login by computer name
*/
ipcMain.handle(
IPC_CHANNELS.AUTH_SILENT_LOGIN,
async (): Promise<IpcResult<SilentLoginResponse>> => {
return withErrorHandling(async () => {
log.info('Attempting silent login')
const success = await sessionManager.loginByComputerName()
const userInfo = sessionManager.getUserInfo()
if (success && userInfo) {
// Check if admin needs user selection
const requiresUserSelection = userInfo.userType === 'Admin'
log.info('Silent login successful', {
username: userInfo.username,
userType: userInfo.userType,
requiresUserSelection
})
// Audit log: LOGIN success (non-blocking)
const os = await import('os')
logAudit('LOGIN', String(userInfo.id), {
username: userInfo.username,
computerName: os.hostname(),
resource: 'ERP_SYSTEM',
status: 'success',
metadata: { loginType: 'silent', userType: userInfo.userType }
}).catch((err) => log.warn('Failed to write audit log', { err }))
return {
success: true,
userInfo,
requiresUserSelection
}
}
throw new ValidationError('无感登录失败:未找到匹配用户', 'VAL_INVALID_INPUT')
}, 'auth:silentLogin')
}
)
/**
* Login with username and password
*/
ipcMain.handle(
IPC_CHANNELS.AUTH_LOGIN,
async (_event, request: LoginRequest): Promise<IpcResult<LoginResponse>> => {
return withErrorHandling(async () => {
const { username, password } = request
if (!username || !password) {
log.warn('Login attempt with missing credentials')
throw new ValidationError('请输入用户名和密码', 'VAL_MISSING_REQUIRED')
}
log.info('Login attempt', { username })
const success = await sessionManager.login(username, password)
const userInfo = sessionManager.getUserInfo()
if (success && userInfo) {
log.info('Login successful', { username, userType: userInfo.userType })
// Audit log: LOGIN success (non-blocking)
const os = await import('os')
logAudit('LOGIN', String(userInfo.id), {
username: userInfo.username,
computerName: os.hostname(),
resource: 'ERP_SYSTEM',
status: 'success',
metadata: { loginType: 'credentials', userType: userInfo.userType }
}).catch((err) => log.warn('Failed to write audit log', { err }))
return {
success: true,
userInfo
}
}
// Audit log: LOGIN failure (non-blocking)
const os = await import('os')
logAudit('LOGIN', '0', {
username,
computerName: os.hostname(),
resource: 'ERP_SYSTEM',
status: 'failure',
metadata: { loginType: 'credentials', reason: 'invalid_credentials' }
}).catch((err) => log.warn('Failed to write audit log', { err }))
log.warn('Login failed - invalid credentials', { username })
throw new ValidationError('用户名或密码错误', 'VAL_INVALID_INPUT')
}, 'auth:login')
}
)
/**
* Logout
*/
ipcMain.handle(IPC_CHANNELS.AUTH_LOGOUT, async (): Promise<IpcResult<void>> => {
return withErrorHandling(async () => {
const userInfo = sessionManager.getUserInfo()
log.info('User logout', { username: userInfo?.username })
// Audit log: LOGOUT (non-blocking)
if (userInfo) {
const os = await import('os')
logAudit('LOGOUT', String(userInfo.id), {
username: userInfo.username,
computerName: os.hostname(),
resource: 'ERP_SYSTEM',
status: 'success',
metadata: { userType: userInfo.userType }
}).catch((err) => log.warn('Failed to write audit log', { err }))
}
sessionManager.logout()
}, 'auth:logout')
})
/**
* Get current user
*/
ipcMain.handle(
IPC_CHANNELS.AUTH_GET_CURRENT_USER,
async (): Promise<IpcResult<CurrentUserResponse>> => {
return withErrorHandling(async () => {
const isAuthenticated = sessionManager.isAuthenticated()
const userInfo = sessionManager.getUserInfo()
return {
isAuthenticated,
userInfo: userInfo ?? undefined
}
}, 'auth:getCurrentUser')
}
)
/**
* Get all users (for admin user selection)
*/
ipcMain.handle(IPC_CHANNELS.AUTH_GET_ALL_USERS, async (): Promise<IpcResult<UserInfo[]>> => {
return withErrorHandling(async () => {
log.debug('Fetching all users for admin selection')
return await sessionManager.getAllUsers()
}, 'auth:getAllUsers')
})
/**
* Switch user (admin only)
*/
ipcMain.handle(
IPC_CHANNELS.AUTH_SWITCH_USER,
async (_event, userInfo: UserInfo): Promise<IpcResult<UserSelectionResponse>> => {
return withErrorHandling(async () => {
log.info('User switch attempt', { targetUser: userInfo.username })
const success = sessionManager.switchUser(userInfo)
if (success) {
const newUser = sessionManager.getUserInfo()
log.info('User switch successful', { newUsername: newUser?.username })
return {
success: true,
userInfo: newUser ?? undefined
}
}
log.warn('User switch failed')
throw new ValidationError('用户切换失败', 'VAL_INVALID_INPUT')
}, 'auth:switchUser')
}
)
/**
* Check if current user is admin
*/
ipcMain.handle(IPC_CHANNELS.AUTH_IS_ADMIN, async (): Promise<IpcResult<boolean>> => {
return withErrorHandling(async () => sessionManager.isAdmin(), 'auth:isAdmin')
})
}

View File

@@ -0,0 +1,368 @@
import { ipcMain, type WebContents } from 'electron'
import { ErpAuthService } from '../services/erp/erp-auth'
import { CleanerService } from '../services/erp/cleaner'
import { OrderNumberResolver } from '../services/erp/order-resolver'
import { MySqlService } from '../services/database/mysql'
import { SqlServerService } from '../services/database/sql-server'
import { ConfigManager } from '../services/config/config-manager'
import { ResultExporter } from '../services/excel/result-exporter'
import { CleanerReportGenerator } from '../services/report/cleaner-report-generator'
import { RustfsService } from '../services/rustfs'
import { SessionManager } from '../services/user/session-manager'
import { createLogger } from '../services/logger'
import { logAudit } from '../services/logger/audit-logger'
import { withErrorHandling, type IpcResult } from './index'
import { ErpConnectionError, ValidationError, DatabaseQueryError } from '../types/errors'
import type {
CleanerInput,
CleanerResult,
CleanerProgress,
ExportResultItem,
ExportResultResponse
} from '../types/cleaner.types'
import { UserErpConfigService } from '../services/user/user-erp-config-service'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
const log = createLogger('CleanerHandler')
function sendProgress(
sender: WebContents,
message: string,
progress: number,
extra?: Partial<CleanerProgress>
): void {
try {
const progressData: CleanerProgress = {
message,
progress,
currentOrderIndex: extra?.currentOrderIndex ?? 0,
totalOrders: extra?.totalOrders ?? 0,
currentMaterialIndex: extra?.currentMaterialIndex ?? 0,
totalMaterialsInOrder: extra?.totalMaterialsInOrder ?? 0,
currentOrderNumber: extra?.currentOrderNumber,
phase: extra?.phase ?? 'processing'
}
sender.send(IPC_CHANNELS.CLEANER_PROGRESS, progressData)
} catch (error) {
log.warn('Failed to send progress event', { error })
}
}
async function getDatabaseService(): Promise<MySqlService | SqlServerService> {
const configManager = ConfigManager.getInstance()
const config = configManager.getConfig()
const dbType = configManager.getDatabaseType()
if (dbType === 'sqlserver') {
const dbConfig = config.database.sqlserver
const sqlServerService = new SqlServerService({
server: dbConfig.server,
port: dbConfig.port,
user: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database,
options: {
encrypt: false,
trustServerCertificate: dbConfig.trustServerCertificate
}
})
await sqlServerService.connect()
return sqlServerService
} else {
const dbConfig = config.database.mysql
const mysqlService = new MySqlService({
host: dbConfig.host,
port: dbConfig.port,
user: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database
})
await mysqlService.connect()
return mysqlService
}
}
/**
* Get ERP configuration for current user
* URL is from config.yaml (fixed infrastructure)
* Username and password are from user's database config
*/
async function getErpConfig(): Promise<{
url: string
username: string
password: string
}> {
// Get ERP URL from config.yaml (fixed for all users)
const configManager = ConfigManager.getInstance()
const globalConfig = configManager.getConfig()
const erpUrl = globalConfig.erp.url
// Get username and password from user's database config
const erpConfigService = UserErpConfigService.getInstance()
const userConfig = await erpConfigService.getCurrentUserErpConfig()
if (!userConfig || !userConfig.username || !userConfig.password) {
throw new ValidationError(
'ERP 配置不完整。请在设置中配置 ERP 用户名和密码',
'VAL_MISSING_REQUIRED'
)
}
return {
url: erpUrl,
username: userConfig.username,
password: userConfig.password
}
}
export function registerCleanerHandlers(): void {
ipcMain.handle(
IPC_CHANNELS.CLEANER_RUN,
async (event, input: CleanerInput): Promise<IpcResult<CleanerResult>> => {
const sender = event.sender
const startTime = Date.now()
return withErrorHandling(async () => {
let authService: ErpAuthService | null = null
let dbService: MySqlService | SqlServerService | null = null
try {
// Get ERP configuration from database for current user
log.info('Fetching ERP configuration from database...')
const erpConfig = await getErpConfig()
log.info('ERP config retrieved', {
url: erpConfig.url ? 'configured' : 'EMPTY',
username: erpConfig.username ? 'configured' : 'EMPTY'
})
const configManager = ConfigManager.getInstance()
const dbType = configManager.getDatabaseType()
log.info(
`Connecting to ${dbType === 'sqlserver' ? 'SQL Server' : 'MySQL'} for order resolution...`
)
try {
dbService = await getDatabaseService()
} catch (error) {
throw new DatabaseQueryError(
'数据库连接失败',
'DB_CONNECTION_FAILED',
error instanceof Error ? error : undefined
)
}
const resolver = new OrderNumberResolver(dbService)
const mappings = await resolver.resolve(input.orderNumbers)
const validOrderNumbers = resolver.getValidOrderNumbers(mappings)
const warnings = resolver.getWarnings(mappings)
if (warnings.length > 0) {
log.warn('Resolution warnings', { warnings })
}
if (validOrderNumbers.length === 0) {
throw new ValidationError(
'没有有效的生产订单号可处理。请检查输入的格式或数据库连接。',
'VAL_INVALID_INPUT'
)
}
log.info('Resolved order numbers', { count: validOrderNumbers.length })
authService = new ErpAuthService({
url: erpConfig.url,
username: erpConfig.username,
password: erpConfig.password,
headless: input.headless ?? true
})
log.info('Logging in to ERP...')
try {
await authService.login()
} catch (error) {
throw new ErpConnectionError(
'ERP 登录失败',
'ERP_LOGIN_FAILED',
error instanceof Error ? error : undefined
)
}
log.info('Login successful')
// Send login complete progress
const totalOrders = validOrderNumbers.length
const loginProgress = (1 / (1 + totalOrders)) * 100
sendProgress(sender, 'ERP 登录成功', loginProgress, {
phase: 'login',
currentOrderIndex: 0,
totalOrders,
currentMaterialIndex: 0,
totalMaterialsInOrder: 0
})
const cleaner = new CleanerService(authService)
const modifiedInput: CleanerInput = {
...input,
orderNumbers: validOrderNumbers,
onProgress: (message, progress, extra) => {
sendProgress(sender, message, progress ?? 0, extra)
}
}
log.info('Starting cleaning', {
orderCount: validOrderNumbers.length,
queryBatchSize: input.queryBatchSize ?? 100,
processConcurrency: input.processConcurrency ?? 1
})
const result = await cleaner.clean(modifiedInput)
if (warnings.length > 0) {
result.errors = [...warnings, ...result.errors]
}
// Send completion progress
sendProgress(sender, '清理完成', 100, {
phase: 'complete',
currentOrderIndex: totalOrders,
totalOrders,
currentMaterialIndex: 0,
totalMaterialsInOrder: 0
})
log.info('Cleaning completed', {
processedCount: result.ordersProcessed,
errorCount: result.errors.length
})
// Audit log: CLEAN (non-blocking)
const os = await import('os')
const currentUser = SessionManager.getInstance().getUserInfo()
if (currentUser) {
const status: 'success' | 'failure' | 'partial' =
result.errors.length > 0 && result.materialsDeleted > 0
? 'partial'
: result.errors.length > 0
? 'failure'
: 'success'
logAudit('CLEAN', String(currentUser.id), {
username: currentUser.username,
computerName: os.hostname(),
resource: 'MATERIAL_PLAN',
status,
metadata: {
orderCount: validOrderNumbers.length,
dryRun: input.dryRun ?? false,
queryBatchSize: input.queryBatchSize ?? 100,
processConcurrency: input.processConcurrency ?? 1,
materialsDeleted: result.materialsDeleted,
materialsSkipped: result.materialsSkipped,
errorCount: result.errors.length
}
}).catch((err) => log.warn('Failed to write audit log', { err }))
}
// Generate report and upload to RustFS (silent, user unaware)
try {
const endTime = Date.now()
const currentUser = SessionManager.getInstance().getUserInfo()
const username = currentUser?.username ?? 'unknown'
const reportGenerator = new CleanerReportGenerator()
const reportPath = await reportGenerator.generateReport(result, {
dryRun: input.dryRun ?? false,
username,
startTime,
endTime
})
log.info('Report generated', { path: reportPath })
// Upload to RustFS if enabled
const configManager = ConfigManager.getInstance()
const config = configManager.getConfig()
if (config.rustfs?.enabled && config.rustfs.endpoint) {
try {
const rustfs = new RustfsService({ config: config.rustfs })
const reportFileName = reportPath.split(/[\\/]/).pop() || 'report.md'
const storageKey = rustfs.generateReportKey(reportFileName, username)
log.info('Uploading report to RustFS', {
localPath: reportPath,
storageKey
})
const uploadResult = await rustfs.uploadFile(
reportPath,
storageKey,
'text/markdown; charset=utf-8'
)
if (uploadResult.success) {
log.info('Report uploaded to RustFS successfully', {
key: storageKey,
etag: uploadResult.etag
})
} else {
log.warn('Failed to upload report to RustFS', {
error: uploadResult.error,
key: storageKey
})
}
} catch (rustfsError) {
log.error('RustFS upload failed', {
error: rustfsError instanceof Error ? rustfsError.message : String(rustfsError)
})
}
} else {
log.debug('RustFS is not enabled, skipping upload')
}
} catch (reportError) {
log.warn('Failed to generate report', {
error: reportError instanceof Error ? reportError.message : String(reportError)
})
}
return result
} finally {
if (authService) {
try {
await authService.close()
log.debug('Browser closed')
} catch (closeError) {
log.warn('Error closing browser', {
error: closeError instanceof Error ? closeError.message : String(closeError)
})
}
}
if (dbService) {
try {
await dbService.disconnect()
log.debug('Database disconnected')
} catch (closeError) {
log.warn('Error disconnecting database', {
error: closeError instanceof Error ? closeError.message : String(closeError)
})
}
}
}
}, 'cleaner:run')
}
)
ipcMain.handle(
IPC_CHANNELS.CLEANER_EXPORT_RESULTS,
async (_event, items: ExportResultItem[]): Promise<IpcResult<ExportResultResponse>> => {
return withErrorHandling(async () => {
log.info('Exporting validation results', { count: items.length })
if (!items || items.length === 0) {
throw new ValidationError('没有数据可导出', 'VAL_INVALID_INPUT')
}
const exporter = new ResultExporter()
return await exporter.exportValidationResults(items)
}, 'cleaner:exportResults')
}
)
}

View File

@@ -0,0 +1,253 @@
import { ipcMain } from 'electron'
import { MySqlService } from '../services/database/mysql'
import { SqlServerService } from '../services/database/sql-server'
import { createLogger } from '../services/logger'
import { ValidationError } from '../types/errors'
import type {
MySqlConfig,
MySqlQueryResult,
SqlServerConfig,
SqlServerQueryResult
} from '../types/ipc-api.types'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { withErrorHandling, type IpcResult } from './index'
const log = createLogger('DatabaseHandler')
// Store MySQL service instances per window/connection
const mysqlServices = new Map<string, MySqlService>()
// Store SQL Server service instances per window/connection
const sqlServerServices = new Map<string, SqlServerService>()
const cleanupBoundWindows = new Set<string>()
function bindWindowCleanup(
windowId: string,
sender: { once: (event: string, listener: () => void) => void }
): void {
if (cleanupBoundWindows.has(windowId)) {
return
}
sender.once('destroyed', () => {
const mysql = getMySqlService(windowId)
const sqlServer = getSqlServerService(windowId)
if (mysql) {
mysql
.disconnect()
.catch((error) => log.warn('MySQL disconnect on window destroy failed', { error }))
deleteMySqlService(windowId)
}
if (sqlServer) {
sqlServer
.disconnect()
.catch((error) => log.warn('SQL Server disconnect on window destroy failed', { error }))
deleteSqlServerService(windowId)
}
cleanupBoundWindows.delete(windowId)
})
cleanupBoundWindows.add(windowId)
}
/**
* Get or create MySQL service for a connection ID
*/
function getMySqlService(connectionId: string): MySqlService | undefined {
return mysqlServices.get(connectionId)
}
/**
* Set MySQL service for a connection ID
*/
function setMySqlService(connectionId: string, service: MySqlService): void {
mysqlServices.set(connectionId, service)
}
/**
* Delete MySQL service for a connection ID
*/
function deleteMySqlService(connectionId: string): void {
mysqlServices.delete(connectionId)
}
/**
* Get or create SQL Server service for a connection ID
*/
function getSqlServerService(connectionId: string): SqlServerService | undefined {
return sqlServerServices.get(connectionId)
}
/**
* Set SQL Server service for a connection ID
*/
function setSqlServerService(connectionId: string, service: SqlServerService): void {
sqlServerServices.set(connectionId, service)
}
/**
* Delete SQL Server service for a connection ID
*/
function deleteSqlServerService(connectionId: string): void {
sqlServerServices.delete(connectionId)
}
/**
* Register IPC handlers for database operations
*/
export function registerDatabaseHandlers(): void {
// Connect to MySQL
ipcMain.handle(
IPC_CHANNELS.DATABASE_MYSQL_CONNECT,
async (event, config: MySqlConfig): Promise<IpcResult<void>> => {
return withErrorHandling(async () => {
// Use window ID as connection identifier
const windowId = (event.sender as { id: number }).id.toString()
bindWindowCleanup(
windowId,
event.sender as { once: (event: string, listener: () => void) => void }
)
log.info('Connecting to MySQL', { windowId })
const service = new MySqlService(config)
await service.connect()
setMySqlService(windowId, service)
log.info('MySQL connected', { windowId })
}, 'database:mysql:connect')
}
)
// Disconnect from MySQL
ipcMain.handle(
IPC_CHANNELS.DATABASE_MYSQL_DISCONNECT,
async (event): Promise<IpcResult<void>> => {
return withErrorHandling(async () => {
const windowId = (event.sender as { id: number }).id.toString()
const service = getMySqlService(windowId)
if (service) {
await service.disconnect()
deleteMySqlService(windowId)
log.info('MySQL disconnected', { windowId })
}
}, 'database:mysql:disconnect')
}
)
// Check if MySQL is connected
ipcMain.handle(
IPC_CHANNELS.DATABASE_MYSQL_IS_CONNECTED,
async (event): Promise<IpcResult<boolean>> => {
return withErrorHandling(async () => {
const windowId = (event.sender as { id: number }).id.toString()
const service = getMySqlService(windowId)
return service ? service.isConnected() : false
}, 'database:mysql:isConnected')
}
)
// Execute MySQL query
ipcMain.handle(
IPC_CHANNELS.DATABASE_MYSQL_QUERY,
async (event, sql: string, params?: unknown[]): Promise<IpcResult<MySqlQueryResult>> => {
return withErrorHandling(async () => {
const windowId = (event.sender as { id: number }).id.toString()
const service = getMySqlService(windowId)
if (!service) {
throw new ValidationError(
'Not connected to MySQL. Call connect() first.',
'VAL_INVALID_INPUT'
)
}
log.debug('Executing MySQL query', { windowId, sql: sql.substring(0, 100) })
return await service.query(sql, params)
}, 'database:mysql:query')
}
)
// Connect to SQL Server
ipcMain.handle(
IPC_CHANNELS.DATABASE_SQLSERVER_CONNECT,
async (event, config: SqlServerConfig): Promise<IpcResult<void>> => {
return withErrorHandling(async () => {
const windowId = (event.sender as { id: number }).id.toString()
bindWindowCleanup(
windowId,
event.sender as { once: (event: string, listener: () => void) => void }
)
log.info('Connecting to SQL Server', { windowId })
const service = new SqlServerService(config)
await service.connect()
setSqlServerService(windowId, service)
log.info('SQL Server connected', { windowId })
}, 'database:sqlserver:connect')
}
)
// Disconnect from SQL Server
ipcMain.handle(
IPC_CHANNELS.DATABASE_SQLSERVER_DISCONNECT,
async (event): Promise<IpcResult<void>> => {
return withErrorHandling(async () => {
const windowId = (event.sender as { id: number }).id.toString()
const service = getSqlServerService(windowId)
if (service) {
await service.disconnect()
deleteSqlServerService(windowId)
log.info('SQL Server disconnected', { windowId })
}
}, 'database:sqlserver:disconnect')
}
)
// Check if SQL Server is connected
ipcMain.handle(
IPC_CHANNELS.DATABASE_SQLSERVER_IS_CONNECTED,
async (event): Promise<IpcResult<boolean>> => {
return withErrorHandling(async () => {
const windowId = (event.sender as { id: number }).id.toString()
const service = getSqlServerService(windowId)
return service ? service.isConnected() : false
}, 'database:sqlserver:isConnected')
}
)
// Execute SQL Server query
ipcMain.handle(
IPC_CHANNELS.DATABASE_SQLSERVER_QUERY,
async (
event,
sqlString: string,
params?: Record<string, unknown>
): Promise<IpcResult<SqlServerQueryResult>> => {
return withErrorHandling(async () => {
const windowId = (event.sender as { id: number }).id.toString()
const service = getSqlServerService(windowId)
if (!service) {
throw new ValidationError(
'Not connected to SQL Server. Call connect() first.',
'VAL_INVALID_INPUT'
)
}
log.debug('Executing SQL Server query', { windowId, sql: sqlString.substring(0, 100) })
// Use queryWithParams for named parameters, or query for no params
if (params && Object.keys(params).length > 0) {
// Convert to the format expected by queryWithParams
const typedParams: Record<string, { value: unknown }> = {}
for (const [key, value] of Object.entries(params)) {
typedParams[key] = { value }
}
return await service.queryWithParams(sqlString, typedParams)
} else {
return await service.query(sqlString)
}
}, 'database:sqlserver:query')
}
)
}

View File

@@ -0,0 +1,278 @@
import { ipcMain, type WebContents } from 'electron'
import { ErpAuthService } from '../services/erp/erp-auth'
import { ExtractorService } from '../services/erp/extractor'
import { OrderNumberResolver } from '../services/erp/order-resolver'
import { create, type IDatabaseService } from '../services/database'
import { createLogger } from '../services/logger'
import { logAudit } from '../services/logger/audit-logger'
import { SessionManager } from '../services/user/session-manager'
import { withErrorHandling, type IpcResult } from './index'
import { ErpConnectionError, ValidationError, DatabaseQueryError } from '../types/errors'
import type { ExtractorInput, ExtractorResult, ExtractionProgress } from '../types/extractor.types'
import { UserErpConfigService } from '../services/user/user-erp-config-service'
import { ConfigManager } from '../services/config/config-manager'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
const log = createLogger('ExtractorHandler')
function sendProgress(
sender: WebContents,
message: string,
progress: number,
extra?: Partial<ExtractionProgress>
): void {
try {
const progressData = { message, progress, ...extra }
sender.send(IPC_CHANNELS.EXTRACTOR_PROGRESS, progressData)
} catch (error) {
log.warn('Failed to send progress event', { error })
}
}
function sendLog(sender: WebContents, level: string, message: string): void {
try {
sender.send(IPC_CHANNELS.EXTRACTOR_LOG, { level, message })
} catch (error) {
log.warn('Failed to send log event', { error })
}
}
/**
* Get ERP configuration for current user
* URL is from config.yaml (fixed infrastructure)
* Username and password are from user's database config
*/
async function getErpConfig(): Promise<{
url: string
username: string
password: string
}> {
// Get ERP URL from config.yaml (fixed for all users)
const configManager = ConfigManager.getInstance()
const globalConfig = configManager.getConfig()
const erpUrl = globalConfig.erp.url
// Get username and password from user's database config
const erpConfigService = UserErpConfigService.getInstance()
const userConfig = await erpConfigService.getCurrentUserErpConfig()
if (!userConfig || !userConfig.username || !userConfig.password) {
throw new ValidationError(
'ERP 配置不完整。请在设置中配置 ERP 用户名和密码',
'VAL_MISSING_REQUIRED'
)
}
return {
url: erpUrl,
username: userConfig.username,
password: userConfig.password
}
}
/**
* Register IPC handlers for extractor service
*/
export function registerExtractorHandlers(): void {
ipcMain.handle(
IPC_CHANNELS.EXTRACTOR_RUN,
async (event, input: ExtractorInput): Promise<IpcResult<ExtractorResult>> => {
const sender = event.sender
return withErrorHandling(async () => {
let authService: ErpAuthService | null = null
let dbService: IDatabaseService | null = null
try {
// Get ERP configuration from database for current user
log.info('Fetching ERP configuration from database...')
const erpConfig = await getErpConfig()
log.info('ERP config retrieved', {
url: erpConfig.url ? 'configured' : 'EMPTY',
username: erpConfig.username ? 'configured' : 'EMPTY'
})
// Create database service using factory
log.info('Connecting to database for order resolution...')
sendProgress(sender, '连接数据库...', 3.33, {
phase: 'login',
subProgress: { step: '连接数据库', current: 1, total: 3 }
})
sendLog(sender, 'system', '正在连接数据库...')
try {
dbService = await create()
} catch (error) {
throw new DatabaseQueryError(
'数据库连接失败',
'DB_CONNECTION_FAILED',
error instanceof Error ? error : undefined
)
}
// Resolve order numbers (convert productionIDs to 生产订单号)
sendProgress(sender, '解析订单号...', 6.67, {
phase: 'login',
subProgress: { step: '解析订单号', current: 2, total: 3 }
})
sendLog(sender, 'info', '正在解析订单号...')
const resolver = new OrderNumberResolver(dbService)
const mappings = await resolver.resolve(input.orderNumbers)
// Get valid order numbers and warnings
const validOrderNumbers = resolver.getValidOrderNumbers(mappings)
const warnings = resolver.getWarnings(mappings)
// Get deduplication report for detailed logging
const dedupReport = resolver.getDeduplicationReport(mappings)
if (warnings.length > 0) {
log.warn('Resolution warnings', { warnings })
}
if (validOrderNumbers.length === 0) {
throw new ValidationError(
'没有有效的生产订单号可处理。请检查输入的格式或数据库连接。',
'VAL_INVALID_INPUT'
)
}
log.info('Resolved order numbers', { count: validOrderNumbers.length })
// Log deduplication summary
sendLog(sender, 'info', dedupReport.summary)
// Log only merged mappings (where multiple productionIDs map to the same order number)
if (dedupReport.inputCount > dedupReport.uniqueOrderNumbersCount) {
sendLog(sender, 'info', '重复合并详情:')
dedupReport.orderNumberGroups.forEach((productionIds, orderNumber) => {
if (productionIds.length > 1) {
sendLog(
sender,
'info',
` ${orderNumber}${productionIds.join('、')} (共 ${productionIds.length} 个总排号)`
)
}
})
}
// Create auth service and login
authService = new ErpAuthService({
url: erpConfig.url,
username: erpConfig.username,
password: erpConfig.password,
headless: true
})
sendProgress(sender, '登录 ERP 系统...', 9.99, {
phase: 'login',
subProgress: { step: '登录 ERP 系统', current: 3, total: 3 }
})
sendLog(sender, 'system', '正在登录 ERP 系统...')
log.info('Logging in to ERP...')
try {
await authService.login()
} catch (error) {
const errorMsg = error instanceof Error ? error.message : '未知错误'
sendLog(sender, 'error', `ERP 登录失败:${errorMsg}`)
throw new ErpConnectionError(
'ERP 登录失败',
'ERP_LOGIN_FAILED',
error instanceof Error ? error : undefined
)
}
log.info('Login successful')
sendLog(sender, 'success', 'ERP 登录成功')
// Create extractor service and run extraction with resolved order numbers
const extractor = new ExtractorService(authService)
log.info('Starting extraction', { orderCount: validOrderNumbers.length })
const modifiedInput: ExtractorInput = {
...input,
orderNumbers: validOrderNumbers,
onProgress: (message, progress, extra) => {
sendProgress(sender, message, progress, extra)
sendLog(sender, 'info', message)
},
onLog: (level, message) => {
sendLog(sender, level, message)
}
}
const result = await extractor.extract(modifiedInput)
// Add warnings to result errors if any
if (warnings.length > 0) {
result.errors = [...warnings, ...result.errors]
}
log.info('Extraction completed', {
rowCount: result.recordCount,
errorCount: result.errors.length
})
// Log detailed error information if any errors occurred
if (result.errors.length > 0) {
log.warn('Extraction errors occurred', { errors: result.errors })
result.errors.forEach((err, index) => {
log.error(`Error ${index + 1}/${result.errors.length}: ${err}`)
})
}
// Audit log: EXTRACT (non-blocking)
const os = await import('os')
const currentUser = SessionManager.getInstance().getUserInfo()
if (currentUser) {
const status: 'success' | 'failure' | 'partial' =
result.errors.length > 0 && result.recordCount > 0
? 'partial'
: result.errors.length > 0
? 'failure'
: 'success'
logAudit('EXTRACT', String(currentUser.id), {
username: currentUser.username,
computerName: os.hostname(),
resource: 'MATERIAL_PLAN',
status,
metadata: {
orderCount: validOrderNumbers.length,
recordCount: result.recordCount,
errorCount: result.errors.length
}
}).catch((err) => log.warn('Failed to write audit log', { err }))
}
return result
} finally {
// Clean up: close browser
if (authService) {
try {
await authService.close()
log.debug('Browser closed')
} catch (closeError) {
log.warn('Error closing browser', {
error: closeError instanceof Error ? closeError.message : String(closeError)
})
}
}
// Clean up: disconnect database
if (dbService) {
try {
await dbService.disconnect()
log.debug('Database disconnected')
} catch (closeError) {
log.warn('Error disconnecting database', {
error: closeError instanceof Error ? closeError.message : String(closeError)
})
}
}
}
}, 'extractor:run')
}
)
}

View File

@@ -0,0 +1,99 @@
import { app, ipcMain, shell } from 'electron'
import * as fs from 'fs/promises'
import * as path from 'path'
import { createLogger } from '../services/logger'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { ValidationError } from '../types/errors'
import { withErrorHandling, type IpcResult } from './index'
const log = createLogger('FileHandler')
function getAllowedRoots(): string[] {
return [path.resolve(app.getAppPath()), path.resolve(app.getPath('userData'))]
}
export function isPathWithinAllowedRoots(inputPath: string, roots: string[]): boolean {
const normalized = path.resolve(inputPath)
return roots.some((root) => {
const rel = path.relative(root, normalized)
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
})
}
function normalizeAndValidatePath(inputPath: string): string {
const normalized = path.resolve(inputPath)
const isAllowed = isPathWithinAllowedRoots(normalized, getAllowedRoots())
if (!isAllowed) {
throw new ValidationError('Path is outside allowed roots', 'VAL_INVALID_INPUT')
}
return normalized
}
export function registerFileHandlers(): void {
ipcMain.handle(
IPC_CHANNELS.FILE_READ,
async (_event, filePath: string): Promise<IpcResult<string>> => {
return withErrorHandling(async () => {
const safePath = normalizeAndValidatePath(filePath)
log.debug('Reading file', { filePath: safePath })
return await fs.readFile(safePath, 'utf-8')
}, 'file:read')
}
)
ipcMain.handle(
IPC_CHANNELS.FILE_WRITE,
async (_event, filePath: string, content: string): Promise<IpcResult<void>> => {
return withErrorHandling(async () => {
const safePath = normalizeAndValidatePath(filePath)
log.debug('Writing file', { filePath: safePath })
const dir = path.dirname(safePath)
await fs.mkdir(dir, { recursive: true })
await fs.writeFile(safePath, content, 'utf-8')
}, 'file:write')
}
)
ipcMain.handle(
IPC_CHANNELS.FILE_EXISTS,
async (_event, filePath: string): Promise<IpcResult<boolean>> => {
return withErrorHandling(async () => {
const safePath = normalizeAndValidatePath(filePath)
try {
await fs.access(safePath)
return true
} catch {
return false
}
}, 'file:exists')
}
)
ipcMain.handle(
IPC_CHANNELS.FILE_LIST,
async (_event, dirPath: string): Promise<IpcResult<string[]>> => {
return withErrorHandling(async () => {
const safePath = normalizeAndValidatePath(dirPath)
log.debug('Listing directory', { dirPath: safePath })
const entries = await fs.readdir(safePath, { withFileTypes: true })
return entries
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.sort()
}, 'file:list')
}
)
ipcMain.handle(
IPC_CHANNELS.FILE_OPEN_PATH,
async (_event, filePath: string): Promise<IpcResult<void>> => {
return withErrorHandling(async () => {
const safePath = normalizeAndValidatePath(filePath)
log.debug('Opening path in explorer', { filePath: safePath })
await fs.access(safePath)
await shell.openPath(safePath)
}, 'file:openPath')
}
)
}

109
src/main/ipc/index.ts Normal file
View File

@@ -0,0 +1,109 @@
/**
* IPC Handler registration
* Centralized registration for all IPC handlers
*/
import { registerFileHandlers } from './file-handler'
import { registerExtractorHandlers } from './extractor-handler'
import { registerCleanerHandlers } from './cleaner-handler'
import { registerDatabaseHandlers } from './database-handler'
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 { registerUserErpConfigHandlers } from './user-erp-config-handler'
import { registerLoggerHandlers } from './logger-handler'
import { registerReportHandlers } from './report-handler'
import { createLogger, logError } from '../services/logger'
import { serializeError, sanitizeError } from '../services/logger/error-utils'
import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors'
const log = createLogger('IPC')
/**
* Standard result type for all IPC handlers
*/
export interface IpcResult<T = unknown> {
success: boolean
data?: T
error?: string
code?: string
}
export function ok<T>(data: T): IpcResult<T> {
return { success: true, data }
}
export function fail<T = unknown>(error: string, code?: string): IpcResult<T> {
return { success: false, error, code }
}
/**
* Higher-order function to wrap IPC handlers with consistent error handling
* Enhanced to capture full error context including stack traces
* @param handler - The async handler function to wrap
* @param context - The context name for logging
* @returns A wrapped handler that returns IpcResult
*/
export function withErrorHandling<T>(
handler: () => Promise<T>,
context: string
): Promise<IpcResult<T>> {
return handler()
.then((data): IpcResult<T> => {
log.debug(`[${context}] Handler completed successfully`)
return ok(data)
})
.catch((error: unknown) => {
const message = getErrorMessage(error)
const code = getErrorCode(error)
// Serialize error with full details
if (process.env.NODE_ENV === 'production') {
sanitizeError(serializeError(error))
} else {
serializeError(error)
}
if (isBaseError(error)) {
logError(log, `[${context}] ${error.name}`, error, {
code,
cause: (error as any).cause?.message,
handler: context
})
} else {
logError(log, `[${context}] Error`, error, {
code,
handler: context
})
}
// Include stack trace in development
if (process.env.NODE_ENV !== 'production' && error instanceof Error) {
log.debug(`[${context}] Stack trace: ${error.stack}`)
}
return fail<T>(message, code)
})
}
/**
* Register all IPC handlers
*/
export function registerIpcHandlers(): void {
log.info('Registering IPC handlers...')
registerFileHandlers()
registerExtractorHandlers()
registerCleanerHandlers()
registerDatabaseHandlers()
registerResolverHandlers()
registerAuthHandlers()
registerValidationHandlers()
registerSettingsHandlers()
registerMaterialTypeHandlers()
registerUserErpConfigHandlers()
registerLoggerHandlers()
registerReportHandlers()
log.info('All IPC handlers registered')
}

View File

@@ -0,0 +1,229 @@
/**
* IPC Logger Handler with Batching
* Receives logs from renderer process and forwards to Winston
*
* Features:
* - 100ms debounce for batch processing
* - Maximum 50 messages per batch
* - Circuit breaker: discards new logs when buffer > 500
* - Error-level logs bypass circuit breaker
*/
import { ipcMain } from 'electron'
import { createLogger } from '../services/logger'
import { IPC_CHANNELS, type LogLevel } from '../../shared/ipc-channels'
const log = createLogger('LoggerHandler')
/**
* Log entry from renderer process
*/
interface LogEntry {
level: LogLevel
message: string
context?: Record<string, unknown>
timestamp: number
}
/**
* Batch processing configuration
*/
const BATCH_CONFIG = {
DEBOUNCE_MS: 100,
MAX_BATCH_SIZE: 50,
CIRCUIT_BREAKER_THRESHOLD: 500
} as const
/**
* Logger handler state
*/
class LoggerHandlerState {
private buffer: LogEntry[] = []
private debounceTimer: NodeJS.Timeout | null = null
private discardedCount = 0
/**
* Add log entry to buffer
* @param entry - Log entry to buffer
* @returns true if entry was buffered, false if discarded
*/
addEntry(entry: LogEntry): boolean {
// Error-level logs always bypass circuit breaker
if (entry.level === 'error') {
this.buffer.push(entry)
this.flushIfNeeded()
return true
}
// Circuit breaker: discard non-error logs when buffer is too large
if (this.buffer.length >= BATCH_CONFIG.CIRCUIT_BREAKER_THRESHOLD) {
this.discardedCount++
// Log warning about discarded logs periodically (every 100 discarded)
if (this.discardedCount % 100 === 0) {
log.warn('Circuit breaker active: discarded logs', {
discardedCount: this.discardedCount,
bufferSize: this.buffer.length
})
}
return false
}
this.buffer.push(entry)
this.flushIfNeeded()
return true
}
/**
* Flush buffer if it reaches max batch size
*/
private flushIfNeeded(): void {
if (this.buffer.length >= BATCH_CONFIG.MAX_BATCH_SIZE) {
this.flush()
} else if (!this.debounceTimer) {
// Start debounce timer if not already running
this.debounceTimer = setTimeout(() => {
this.flush()
}, BATCH_CONFIG.DEBOUNCE_MS)
}
}
/**
* Flush all buffered logs to Winston
*/
flush(): void {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer)
this.debounceTimer = null
}
if (this.buffer.length === 0) {
return
}
// Create a copy of the buffer and clear it
const batch = [...this.buffer]
this.buffer = []
// Process batch asynchronously (non-blocking)
setImmediate(() => {
this.processBatch(batch)
})
}
/**
* Process a batch of log entries
* @param batch - Array of log entries to process
*/
private processBatch(batch: LogEntry[]): void {
try {
for (const entry of batch) {
this.forwardToWinston(entry)
}
} catch (error) {
// If batch processing fails, log the error but don't rethrow
// This ensures logging failures don't crash the app
log.error('Failed to process log batch', {
error: error instanceof Error ? error.message : String(error),
batchSize: batch.length
})
}
}
/**
* Forward a single log entry to Winston logger
* @param entry - Log entry to forward
*/
private forwardToWinston(entry: LogEntry): void {
const context = (entry.context?.component as string) || 'renderer'
const childLogger = log.child({
source: 'renderer',
component: context
})
const message = entry.context?.message
? `[${entry.context.message}] ${entry.message}`
: entry.message
switch (entry.level) {
case 'debug':
childLogger.debug(message, entry.context)
break
case 'warn':
childLogger.warn(message, entry.context)
break
case 'error':
childLogger.error(message, entry.context)
break
case 'info':
default:
childLogger.info(message, entry.context)
break
}
}
/**
* Get current buffer size (for testing/debugging)
*/
getBufferSize(): number {
return this.buffer.length
}
/**
* Get discarded log count (for testing/debugging)
*/
getDiscardedCount(): number {
return this.discardedCount
}
/**
* Reset state (for testing)
*/
reset(): void {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer)
this.debounceTimer = null
}
this.buffer = []
this.discardedCount = 0
}
}
// Singleton state instance
const state = new LoggerHandlerState()
/**
* Register IPC handlers for logger
*/
export function registerLoggerHandlers(): void {
// Use ipcMain.on with send() - fire-and-forget, non-blocking
ipcMain.on(IPC_CHANNELS.LOGGER_FORWARD, (_event, entry: LogEntry) => {
// Validate entry
if (!entry || typeof entry.level !== 'string' || typeof entry.message !== 'string') {
log.warn('Received invalid log entry', { entry })
return
}
// Add to buffer for batch processing
const buffered = state.addEntry(entry)
if (!buffered && process.env.NODE_ENV !== 'production') {
// In development, log when entries are discarded
log.debug('Log entry discarded due to circuit breaker', {
level: entry.level,
message: entry.message
})
}
})
log.info('Logger IPC handler registered', {
channel: IPC_CHANNELS.LOGGER_FORWARD,
debounceMs: BATCH_CONFIG.DEBOUNCE_MS,
maxBatchSize: BATCH_CONFIG.MAX_BATCH_SIZE,
circuitBreakerThreshold: BATCH_CONFIG.CIRCUIT_BREAKER_THRESHOLD
})
}
// Export for testing
export { state }

View File

@@ -0,0 +1,126 @@
/**
* 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'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { ValidationError } from '../types/errors'
import { withErrorHandling, type IpcResult } from './index'
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(
IPC_CHANNELS.MATERIAL_TYPE_GET_ALL,
async (): Promise<IpcResult<MaterialTypeRecord[]>> => {
return withErrorHandling(async () => {
const records = await dao.getAllMaterials()
return records
}, 'materialType:getAll')
}
)
/**
* Get material types by manager
*/
ipcMain.handle(
IPC_CHANNELS.MATERIAL_TYPE_GET_BY_MANAGER,
async (_event, managerName: string): Promise<IpcResult<MaterialTypeRecord[]>> => {
return withErrorHandling(async () => {
const records = await dao.getMaterialsByManager(managerName)
return records
}, 'materialType:getByManager')
}
)
/**
* Get list of managers
*/
ipcMain.handle(
IPC_CHANNELS.MATERIAL_TYPE_GET_MANAGERS,
async (): Promise<IpcResult<string[]>> => {
return withErrorHandling(async () => {
const managers = await dao.getManagers()
return managers
}, 'materialType:getManagers')
}
)
/**
* Upsert (insert or update) a material type record
*/
ipcMain.handle(
IPC_CHANNELS.MATERIAL_TYPE_UPSERT,
async (
_event,
{ materialName, managerName }: { materialName: string; managerName: string }
): Promise<IpcResult<{ updated: boolean }>> => {
return withErrorHandling(async () => {
const result = await dao.upsertMaterial(materialName, managerName)
if (!result) {
throw new ValidationError('Failed to upsert material type', 'VAL_INVALID_INPUT')
}
return { updated: true }
}, 'materialType:upsert')
}
)
/**
* Delete a material type record
*/
ipcMain.handle(
IPC_CHANNELS.MATERIAL_TYPE_DELETE,
async (
_event,
{ materialName, managerName }: { materialName: string; managerName: string }
): Promise<IpcResult<{ deleted: boolean }>> => {
return withErrorHandling(async () => {
const result = await dao.deleteMaterial(materialName, managerName)
if (!result) {
throw new ValidationError('Failed to delete material type', 'VAL_INVALID_INPUT')
}
return { deleted: true }
}, 'materialType:delete')
}
)
/**
* Batch operation for material types (insert, update, delete)
*/
ipcMain.handle(
IPC_CHANNELS.MATERIAL_TYPE_UPSERT_BATCH,
async (
_event,
request: MaterialTypeBatchRequest
): Promise<IpcResult<{ stats: { total: number; success: number; failed: number } }>> => {
return withErrorHandling(async () => {
const stats = await dao.upsertBatch(request)
return { stats }
}, 'materialType:upsertBatch')
}
)
log.info('Material type handlers registered')
}

View File

@@ -0,0 +1,183 @@
import { ipcMain } from 'electron'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { withErrorHandling, type IpcResult } from './index'
import { createLogger } from '../services/logger'
import { ConfigManager } from '../services/config/config-manager'
import { RustfsService } from '../services/rustfs'
import { ListObjectsV2Command, S3Client } from '@aws-sdk/client-s3'
const log = createLogger('ReportHandler')
export interface ReportMetadata {
key: string
filename: string
username: string
lastModified?: Date
size?: number
}
function getRustfsService(): RustfsService | null {
const configManager = ConfigManager.getInstance()
const config = configManager.getConfig()
if (config.rustfs?.enabled && config.rustfs.endpoint) {
return new RustfsService({ config: config.rustfs })
}
return null
}
export function registerReportHandlers(): void {
ipcMain.handle(
IPC_CHANNELS.REPORT_LIST_ALL,
async (): Promise<IpcResult<ReportMetadata[]>> => {
return withErrorHandling(async () => {
const rustfs = getRustfsService()
if (!rustfs) {
throw new Error('RustFS is not configured or enabled')
}
const configManager = ConfigManager.getInstance()
const config = configManager.getConfig()
// Create a direct S3Client since RustfsService doesn't expose listObjects natively easily
const client = new S3Client({
region: config.rustfs?.region || 'us-east-1',
endpoint: config.rustfs?.endpoint || '',
credentials: {
accessKeyId: config.rustfs?.accessKey || '',
secretAccessKey: config.rustfs?.secretKey || ''
},
forcePathStyle: true
})
log.info('Fetching all reports from RustFS')
const input = {
Bucket: config.rustfs?.bucket || '',
Prefix: 'reports/cleaner/'
}
const command = new ListObjectsV2Command(input)
const response = await client.send(command)
const reports: ReportMetadata[] = []
if (response.Contents) {
for (const item of response.Contents) {
if (item.Key && item.Key.endsWith('.md')) {
// reports/cleaner/{username}/{filename}
const parts = item.Key.split('/')
if (parts.length >= 4) {
const username = parts[2]
const filename = parts.slice(3).join('/')
reports.push({
key: item.Key,
filename,
username,
lastModified: item.LastModified,
size: item.Size
})
}
}
}
}
// Sort by lastModified descending
reports.sort((a, b) => {
if (a.lastModified && b.lastModified) {
return b.lastModified.getTime() - a.lastModified.getTime()
}
return 0
})
return reports
}, 'report:listAll')
}
)
ipcMain.handle(
IPC_CHANNELS.REPORT_LIST_BY_USER,
async (_event, username: string): Promise<IpcResult<ReportMetadata[]>> => {
return withErrorHandling(async () => {
const rustfs = getRustfsService()
if (!rustfs) {
throw new Error('RustFS is not configured or enabled')
}
const configManager = ConfigManager.getInstance()
const config = configManager.getConfig()
const client = new S3Client({
region: config.rustfs?.region || 'us-east-1',
endpoint: config.rustfs?.endpoint || '',
credentials: {
accessKeyId: config.rustfs?.accessKey || '',
secretAccessKey: config.rustfs?.secretKey || ''
},
forcePathStyle: true
})
log.info('Fetching reports from RustFS for user', { username })
const input = {
Bucket: config.rustfs?.bucket || '',
Prefix: `reports/cleaner/${username}/`
}
const command = new ListObjectsV2Command(input)
const response = await client.send(command)
const reports: ReportMetadata[] = []
if (response.Contents) {
for (const item of response.Contents) {
if (item.Key && item.Key.endsWith('.md')) {
const parts = item.Key.split('/')
if (parts.length >= 4) {
const itemUsername = parts[2]
const filename = parts.slice(3).join('/')
reports.push({
key: item.Key,
filename,
username: itemUsername,
lastModified: item.LastModified,
size: item.Size
})
}
}
}
}
// Sort by lastModified descending
reports.sort((a, b) => {
if (a.lastModified && b.lastModified) {
return b.lastModified.getTime() - a.lastModified.getTime()
}
return 0
})
return reports
}, 'report:listByUser')
}
)
ipcMain.handle(
IPC_CHANNELS.REPORT_DOWNLOAD,
async (_event, key: string): Promise<IpcResult<string>> => {
return withErrorHandling(async () => {
const rustfs = getRustfsService()
if (!rustfs) {
throw new Error('RustFS is not configured or enabled')
}
log.info('Downloading report from RustFS', { key })
const result = await rustfs.downloadFile(key)
if (!result.success) {
throw new Error(result.error || 'Failed to download report')
}
// Convert buffer to string
return result.content.toString('utf-8')
}, 'report:download')
}
)
}

View File

@@ -0,0 +1,130 @@
/**
* IPC handlers for Order Number Resolver
*
* Provides APIs for the renderer process to:
* - Resolve productionIDs and 生产订单号 to production order numbers
* - Validate input formats
*/
import { ipcMain } from 'electron'
import { create, type IDatabaseService } from '../services/database'
import { OrderNumberResolver } from '../services/erp/order-resolver'
import { createLogger } from '../services/logger'
import type { OrderMapping, ResolutionStats } from '../services/erp/order-resolver'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { withErrorHandling, type IpcResult } from './index'
const log = createLogger('ResolverHandler')
/**
* Resolver input from renderer
*/
export interface ResolverInput {
/** List of order numbers/productionIDs to resolve */
inputs: string[]
}
/**
* Resolver response to renderer
*/
export interface ResolverResponse {
/** Whether the resolution was successful */
success: boolean
/** Resolved order mappings */
mappings?: OrderMapping[]
/** Valid production order numbers ready for use */
validOrderNumbers?: string[]
/** Warning messages for invalid inputs */
warnings?: string[]
/** Resolution statistics */
stats?: ResolutionStats
/** Error message if failed */
error?: string
}
/**
* Register IPC handlers for order number resolver
*/
export function registerResolverHandlers(): void {
/**
* Resolve order numbers
* Converts productionIDs and 生产订单号 to production order numbers
*/
ipcMain.handle(
IPC_CHANNELS.RESOLVER_RESOLVE,
async (_event, input: ResolverInput): Promise<IpcResult<ResolverResponse>> => {
let dbService: IDatabaseService | null = null
return withErrorHandling(async () => {
// Create database service using factory
log.info('Connecting to database for resolution', { inputCount: input.inputs.length })
dbService = await create()
// Create resolver and resolve inputs
const resolver = new OrderNumberResolver(dbService)
const mappings = await resolver.resolve(input.inputs)
// Get valid order numbers and warnings
const validOrderNumbers = resolver.getValidOrderNumbers(mappings)
const warnings = resolver.getWarnings(mappings)
const stats = resolver.getStats(mappings)
log.info('Resolution completed', {
inputCount: input.inputs.length,
validCount: validOrderNumbers.length,
warningCount: warnings.length
})
return {
success: true,
mappings,
validOrderNumbers,
warnings,
stats
}
}, 'resolver:resolve').finally(async () => {
// Clean up database connection
if (dbService) {
try {
await dbService.disconnect()
log.debug('Database disconnected')
} catch (closeError) {
log.warn('Error disconnecting database', {
error: closeError instanceof Error ? closeError.message : String(closeError)
})
}
}
})
}
)
/**
* Validate input format only (without database lookup)
*/
ipcMain.handle(
IPC_CHANNELS.RESOLVER_VALIDATE_FORMAT,
async (
_event,
inputs: string[]
): Promise<
IpcResult<Array<{ input: string; type: 'productionId' | 'orderNumber' | 'unknown' }>>
> => {
return withErrorHandling(async () => {
// Create a mock resolver without database connection
const resolver = new OrderNumberResolver({
isConnected: () => false,
type: 'mysql'
} as IDatabaseService)
const results = inputs.map((input) => ({
input,
type: resolver.recognizeType(input)
}))
log.debug('Format validation completed', { inputCount: inputs.length })
return results
}, 'resolver:validateFormat')
}
)
}

View File

@@ -0,0 +1,200 @@
import { ipcMain } from 'electron'
import { ConfigManager } from '../services/config/config-manager'
import { SessionManager } from '../services/user/session-manager'
import { UserErpConfigService } from '../services/user/user-erp-config-service'
import { MySqlService } from '../services/database/mysql'
import { SqlServerService } from '../services/database/sql-server'
import { createLogger } from '../services/logger'
import { logAudit } from '../services/logger/audit-logger'
import type { UserType, ConnectionTestResult, SaveSettingsResult } from '../types/settings.types'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { ValidationError } from '../types/errors'
import { withErrorHandling, type IpcResult } from './index'
import type { CleanerConfig } from '../types/config.schema'
const log = createLogger('SettingsHandler')
type ErpSettingsPayload = {
erp?: {
username?: string
password?: string
}
}
export function registerSettingsHandlers(): void {
const configManager = ConfigManager.getInstance()
const sessionManager = SessionManager.getInstance()
const erpConfigService = UserErpConfigService.getInstance()
ipcMain.handle(IPC_CHANNELS.SETTINGS_GET_USER_TYPE, async (): Promise<IpcResult<UserType>> => {
return withErrorHandling(
async () => (sessionManager.getUserType() as UserType) || 'Guest',
'settings:getUserType'
)
})
ipcMain.handle(
IPC_CHANNELS.SETTINGS_GET_SETTINGS,
async (): Promise<IpcResult<{ erp: { username: string; password: string } }>> => {
return withErrorHandling(async () => {
const userErpConfig = await erpConfigService.getCurrentUserErpConfig()
return {
erp: {
username: userErpConfig?.username || '',
password: userErpConfig?.password || ''
}
}
}, 'settings:getSettings')
}
)
ipcMain.handle(
IPC_CHANNELS.SETTINGS_SAVE_SETTINGS,
async (_event, settings: ErpSettingsPayload): Promise<IpcResult<SaveSettingsResult>> => {
return withErrorHandling(async () => {
if (settings.erp) {
const currentUser = sessionManager.getUserInfo()
if (!currentUser) {
throw new ValidationError('未找到当前用户', 'VAL_INVALID_INPUT')
}
await erpConfigService.updateCurrentUserErpConfig({
username: settings.erp.username || '',
password: settings.erp.password || ''
})
// Audit log: SETTINGS_CHANGE (non-blocking)
const os = await import('os')
logAudit('SETTINGS_CHANGE', String(currentUser.id), {
username: currentUser.username,
computerName: os.hostname(),
resource: 'ERP_CONFIG',
status: 'success',
metadata: { changeType: 'erp_credentials', usernameChanged: !!settings.erp.username }
}).catch((err) => log.warn('Failed to write audit log', { err }))
}
return { success: true }
}, 'settings:saveSettings')
}
)
ipcMain.handle(
IPC_CHANNELS.SETTINGS_RESET_DEFAULTS,
async (): Promise<IpcResult<SaveSettingsResult>> => {
return withErrorHandling(async () => {
const userType = sessionManager.getUserType()
if (userType !== 'Admin') {
throw new ValidationError('只有管理员可以恢复默认设置', 'VAL_INVALID_INPUT')
}
const success = await configManager.resetToDefaults()
if (!success) {
throw new ValidationError('恢复默认设置失败', 'VAL_INVALID_INPUT')
}
return { success: true }
}, 'settings:resetDefaults')
}
)
ipcMain.handle(
IPC_CHANNELS.SETTINGS_TEST_DB_CONNECTION,
async (): Promise<IpcResult<ConnectionTestResult>> => {
return withErrorHandling(async () => {
log.info('Testing database connection')
const config = configManager.getConfig()
const dbType = config.database.activeType
if (dbType === 'mysql') {
const dbConfig = config.database.mysql
if (!dbConfig.host || !dbConfig.database || !dbConfig.username) {
return {
success: false,
message: '请先配置 MySQL 主机、数据库名和用户名'
}
}
const mysqlService = new MySqlService({
host: dbConfig.host,
port: dbConfig.port,
user: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database
})
try {
await mysqlService.connect()
await mysqlService.disconnect()
return {
success: true,
message: 'MySQL 数据库连接测试成功!'
}
} catch (error) {
const message = error instanceof Error ? error.message : '连接失败'
return {
success: false,
message: `MySQL 数据库连接测试失败:${message}`
}
}
}
const dbConfig = config.database.sqlserver
if (!dbConfig.server || !dbConfig.database || !dbConfig.username) {
return {
success: false,
message: '请先配置 SQL Server 服务器、数据库名和用户名'
}
}
const sqlServerService = new SqlServerService({
server: dbConfig.server,
port: dbConfig.port,
user: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database,
options: {
trustServerCertificate: dbConfig.trustServerCertificate
}
})
try {
await sqlServerService.connect()
await sqlServerService.disconnect()
return {
success: true,
message: 'SQL Server 数据库连接测试成功!'
}
} catch (error) {
const message = error instanceof Error ? error.message : '连接失败'
return {
success: false,
message: `SQL Server 数据库连接测试失败:${message}`
}
}
}, 'settings:testDbConnection')
}
)
ipcMain.handle(IPC_CHANNELS.CONFIG_GET_CLEANER, async (): Promise<IpcResult<CleanerConfig>> => {
return withErrorHandling(async () => {
const configManager = ConfigManager.getInstance()
const config = configManager.getConfig()
return config.cleaner
}, 'config:getCleaner')
})
ipcMain.handle(
IPC_CHANNELS.CONFIG_UPDATE_CLEANER,
async (_event, updates: Partial<CleanerConfig>): Promise<IpcResult<CleanerConfig>> => {
return withErrorHandling(async () => {
const configManager = ConfigManager.getInstance()
const result = await configManager.updateConfig({ cleaner: updates as CleanerConfig })
if (!result.success) {
throw new Error(result.error)
}
return configManager.getConfig().cleaner
}, 'config:updateCleaner')
}
)
}

View File

@@ -0,0 +1,147 @@
import { ipcMain } from 'electron'
import { UserErpConfigService } from '../services/user/user-erp-config-service'
import { ErpAuthService } from '../services/erp/erp-auth'
import { ConfigManager } from '../services/config/config-manager'
import { createLogger } from '../services/logger'
import { SessionManager } from '../services/user/session-manager'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { ValidationError } from '../types/errors'
import { withErrorHandling, type IpcResult } from './index'
const log = createLogger('UserErpConfigHandler')
export interface ErpCredentialsRequest {
username: string
password: string
}
export interface ErpConfigResponse {
success: boolean
config?: {
url: string
username: string
password: string
}
error?: string
}
export interface ConnectionTestResult {
success: boolean
message?: string
}
export function registerUserErpConfigHandlers(): void {
const erpConfigService = UserErpConfigService.getInstance()
const sessionManager = SessionManager.getInstance()
ipcMain.handle(
IPC_CHANNELS.USER_ERP_CONFIG_GET_CURRENT,
async (): Promise<IpcResult<ErpConfigResponse>> => {
return withErrorHandling(async () => {
log.info('Fetching current user ERP credentials')
const credentials = await erpConfigService.getCurrentUserErpConfig()
if (!credentials) {
throw new ValidationError(
'未找到 ERP 配置。请先配置 ERP 账号和密码。',
'VAL_INVALID_INPUT'
)
}
const configManager = ConfigManager.getInstance()
const globalConfig = configManager.getConfig()
return {
success: true,
config: {
url: globalConfig.erp.url,
username: credentials.username,
password: credentials.password
}
}
}, 'user-erp-config:getCurrent')
}
)
ipcMain.handle(
IPC_CHANNELS.USER_ERP_CONFIG_UPDATE,
async (_event, credentials: ErpCredentialsRequest): Promise<IpcResult<ErpConfigResponse>> => {
return withErrorHandling(async () => {
const updated = await erpConfigService.updateCurrentUserErpConfig(credentials)
if (!updated) {
throw new ValidationError('更新 ERP 配置失败', 'VAL_INVALID_INPUT')
}
const configManager = ConfigManager.getInstance()
const globalConfig = configManager.getConfig()
return {
success: true,
config: {
url: globalConfig.erp.url,
username: credentials.username,
password: credentials.password
}
}
}, 'user-erp-config:update')
}
)
ipcMain.handle(
IPC_CHANNELS.USER_ERP_CONFIG_TEST_CONNECTION,
async (
_event,
credentials: ErpCredentialsRequest
): Promise<IpcResult<ConnectionTestResult>> => {
return withErrorHandling(async () => {
if (!credentials.username || !credentials.password) {
throw new ValidationError(
'ERP 配置不完整,请确保用户名和密码都已填写',
'VAL_MISSING_REQUIRED'
)
}
const configManager = ConfigManager.getInstance()
const globalConfig = configManager.getConfig()
const authService = new ErpAuthService({
url: globalConfig.erp.url,
username: credentials.username,
password: credentials.password,
headless: true
})
try {
await authService.login()
return {
success: true,
message: 'ERP 连接测试成功'
}
} finally {
await authService.close().catch(() => {})
}
}, 'user-erp-config:testConnection')
}
)
ipcMain.handle(
IPC_CHANNELS.USER_ERP_CONFIG_GET_ALL,
async (): Promise<
IpcResult<
Array<{
username: string
erpUrl: string
erpUsername: string
}>
>
> => {
return withErrorHandling(async () => {
if (!sessionManager.isAdmin()) {
throw new ValidationError('只有管理员可以查看全部用户 ERP 配置', 'VAL_INVALID_INPUT')
}
const configs = await erpConfigService.getAllUsersErpConfig()
return configs
}, 'user-erp-config:getAll')
}
)
}

View File

@@ -0,0 +1,890 @@
/**
* IPC handlers for material validation operations
*
* Provides endpoints for:
* - Running material validation from database
* - Getting/setting materials to be deleted
* - Manager-based filtering
*/
import { ipcMain } from 'electron'
import { MySqlService } from '../services/database/mysql'
import { SqlServerService } from '../services/database/sql-server'
import { MaterialsToBeDeletedDAO } from '../services/database/materials-to-be-deleted-dao'
import { DiscreteMaterialPlanDAO } from '../services/database/discrete-material-plan-dao'
import { ConfigManager } from '../services/config/config-manager'
import { createLogger } from '../services/logger'
import type {
ValidationRequest,
ValidationResponse,
MaterialUpsertBatchRequest,
MaterialDeleteRequest,
MaterialOperationResponse,
ValidationResult,
MaterialRecordSummary
} from '../types/validation.types'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
const log = createLogger('ValidationHandler')
/**
* Shared state for Production IDs from extractor page
* This is a simple in-memory store for sharing Production IDs between pages
*/
const sharedProductionIdsBySender = new Map<number, Set<string>>()
/**
* Set shared Production IDs
*/
export function setSharedProductionIds(senderId: number, ids: string[]): void {
sharedProductionIdsBySender.set(senderId, new Set(ids))
}
/**
* Get shared Production IDs
*/
export function getSharedProductionIds(senderId: number): string[] {
const senderSet = sharedProductionIdsBySender.get(senderId)
return senderSet ? [...senderSet] : []
}
/**
* Clear shared Production IDs
*/
export function clearSharedProductionIds(senderId: number): void {
sharedProductionIdsBySender.delete(senderId)
}
/**
* Get database service for validation operations (MySQL or SQL Server)
*/
async function getValidationDatabaseService(): Promise<MySqlService | SqlServerService> {
const configManager = ConfigManager.getInstance()
const config = configManager.getConfig()
const dbType = configManager.getDatabaseType()
if (dbType === 'sqlserver') {
const dbConfig = config.database.sqlserver
const sqlServerService = new SqlServerService({
server: dbConfig.server,
port: dbConfig.port,
user: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database,
options: {
encrypt: false,
trustServerCertificate: dbConfig.trustServerCertificate
}
})
await sqlServerService.connect()
return sqlServerService
} else {
const dbConfig = config.database.mysql
const mysqlService = new MySqlService({
host: dbConfig.host,
port: dbConfig.port,
user: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database
})
await mysqlService.connect()
return mysqlService
}
}
/**
* Get table name based on database type
* Converts MySQL schema_tablename format to SQL Server [schema].[tablename] format
* e.g., productionContractData_26年压力表合同数据 -> [productionContractData].[26年压力表合同数据]
* dbo_MaterialsToBeDeleted -> [dbo].[MaterialsToBeDeleted]
*/
function getTableName(mysqlTableName: string): string {
const configManager = ConfigManager.getInstance()
const dbType = configManager.getDatabaseType()
if (dbType === 'sqlserver') {
// Find the FIRST underscore to split schema and table name
// This handles patterns like: schema_tablename
const firstUnderscoreIndex = mysqlTableName.indexOf('_')
if (firstUnderscoreIndex > 0) {
const schema = mysqlTableName.substring(0, firstUnderscoreIndex)
const tableName = mysqlTableName.substring(firstUnderscoreIndex + 1)
return `[${schema}].[${tableName}]`
}
// If no underscore found, default to dbo schema
return `[dbo].[${mysqlTableName}]`
}
return mysqlTableName
}
/**
* Read Production IDs from file
*/
function readProductionIds(filePath: string): string[] {
const fs = require('fs')
const content = fs.readFileSync(filePath, 'utf-8') as string
return content
.split('\n')
.map((line: string) => line.trim())
.filter((line: string) => line.length > 0)
}
/**
* Identify input type (production ID or order number)
*/
function identifyInputType(input: string): 'production_id' | 'order_number' | 'unknown' {
// Order number: SC + 14 digits
if (/^SC\d{14}$/.test(input)) {
return 'order_number'
}
// Production ID: 2 digits + 1 letter + 1-6 digits
if (/^\d{2}[A-Za-z]\d{1,6}$/.test(input)) {
return 'production_id'
}
return 'unknown'
}
/**
* Get source numbers from inputs
*/
async function getSourceNumbersFromInputs(
inputs: string[],
dbService: MySqlService | SqlServerService
): Promise<string[]> {
const productionIds: string[] = []
const orderNumbers: string[] = []
const configManager = ConfigManager.getInstance()
const dbType = configManager.getDatabaseType()
const isSqlServer = dbType === 'sqlserver'
for (const item of inputs) {
const type = identifyInputType(item)
if (type === 'order_number') {
orderNumbers.push(item)
} else if (type === 'production_id') {
productionIds.push(item)
}
}
// Query production contract data for production IDs
// Table name in MySQL: productionContractData_26年压力表合同数据
// Column name: 生产订单号 (SourceNumber)
if (productionIds.length > 0) {
const contractTableName = getTableName('productionContractData_26年压力表合同数据')
const batchSize = 2000
if (isSqlServer) {
const sql = require('mssql')
const allOrderNumbers: string[] = []
for (let i = 0; i < productionIds.length; i += batchSize) {
const batch = productionIds.slice(i, i + batchSize)
const placeholders = batch.map((_, idx) => `@p${idx}`).join(',')
const params: Record<string, { value: string; type: any }> = {}
batch.forEach((id, idx) => {
params[`p${idx}`] = { value: id, type: sql.NVarChar }
})
const contractSql = `
SELECT DISTINCT 生产订单号
FROM ${contractTableName}
WHERE 总排号 IN (${placeholders})
`
const contractResult = await (dbService as SqlServerService).queryWithParams(
contractSql,
params
)
const dbOrderNumbers = contractResult.rows.map((row) => row. as string)
allOrderNumbers.push(...dbOrderNumbers)
}
orderNumbers.push(...allOrderNumbers)
} else {
const allOrderNumbers: string[] = []
for (let i = 0; i < productionIds.length; i += batchSize) {
const batch = productionIds.slice(i, i + batchSize)
const placeholders = batch.map(() => '?').join(',')
const contractSql = `
SELECT DISTINCT 生产订单号
FROM ${contractTableName}
WHERE 总排号 IN (${placeholders})
`
const contractResult = await (dbService as MySqlService).query(contractSql, batch)
const dbOrderNumbers = contractResult.rows.map((row) => row. as string)
allOrderNumbers.push(...dbOrderNumbers)
}
orderNumbers.push(...allOrderNumbers)
}
}
// Deduplicate
return [...new Set(orderNumbers)]
}
/**
* Register IPC handlers for validation operations
*/
export function registerValidationHandlers(): void {
// ==================== VALIDATION ====================
/**
* Run material validation from database
*/
ipcMain.handle(
IPC_CHANNELS.VALIDATION_VALIDATE,
async (event, request: ValidationRequest): Promise<ValidationResponse> => {
let dbService: MySqlService | SqlServerService | null = null
try {
// Get current user info
const sessionManager = (
await import('../services/user/session-manager')
).SessionManager.getInstance()
const userInfo = sessionManager.getUserInfo()
if (!userInfo) {
return {
success: false,
error: '用户未登录',
stats: {
totalRecords: 0,
matchedCount: 0,
markedCount: 0
}
}
}
const isAdmin = userInfo.userType === 'Admin'
const username = userInfo.username
log.info('Starting validation', { mode: request.mode, user: username, isAdmin })
// Connect to database
dbService = await getValidationDatabaseService()
const configManager = ConfigManager.getInstance()
const dbType = configManager.getDatabaseType()
const isSqlServer = dbType === 'sqlserver'
let sourceNumbers: string[] | null = null
// Get source numbers based on mode
if (request.mode === 'database_filtered') {
if (request.useSharedProductionIds) {
// Use shared Production IDs from extractor page
const sharedIds = getSharedProductionIds(event.sender.id)
log.info(`Using ${sharedIds.length} shared Production IDs`)
if (sharedIds.length === 0) {
return {
success: false,
error: '没有可用的共享 Production ID。请在数据提取页面输入 Production ID。',
stats: {
totalRecords: 0,
matchedCount: 0,
markedCount: 0
}
}
}
sourceNumbers = await getSourceNumbersFromInputs(sharedIds, dbService)
log.info(`Got ${sourceNumbers.length} source numbers from shared Production IDs`)
// Check if we got any order numbers from the shared Production IDs
if (sourceNumbers.length === 0) {
return {
success: false,
error:
'共享的 Production ID 没有找到对应的订单数据。请确保在数据提取页面输入了有效的 Production ID 并成功获取了订单数据。',
stats: {
totalRecords: 0,
matchedCount: 0,
markedCount: 0
}
}
}
} else if (request.productionIdFile) {
// Read from file
const inputs = readProductionIds(request.productionIdFile)
log.info(`Read ${inputs.length} inputs from file`)
sourceNumbers = await getSourceNumbersFromInputs(inputs, dbService)
log.info(`Got ${sourceNumbers.length} source numbers`)
// Check if we got any order numbers from the file
if (sourceNumbers.length === 0) {
return {
success: false,
error:
'文件中的 Production ID 没有找到对应的订单数据。请检查 Production ID 是否正确,或确保数据库中有对应的订单数据。',
stats: {
totalRecords: 0,
matchedCount: 0,
markedCount: 0
}
}
}
}
}
// Get material records from DiscreteMaterialPlanData
const materialDao = new DiscreteMaterialPlanDAO()
let materialRecords: any[] = []
if (request.mode === 'database_full') {
// Full table query with deduplication by MaterialCode
materialRecords = await materialDao.queryAllDistinctByMaterialCode()
} else if (sourceNumbers && sourceNumbers.length > 0) {
// Filtered query by source numbers
materialRecords = await materialDao.queryBySourceNumbersDistinct(sourceNumbers)
}
if (materialRecords.length === 0) {
return {
success: false,
error: '未找到物料记录。请检查数据库中是否有对应订单的物料数据。',
stats: {
totalRecords: 0,
matchedCount: 0,
markedCount: 0
}
}
}
// Get type keywords from MaterialsTypeToBeDeleted
const typeKeywordTableName = getTableName('dbo_MaterialsTypeToBeDeleted')
const typeKeywordSql = `
SELECT MaterialName, ManagerName
FROM ${typeKeywordTableName}
WHERE MaterialName IS NOT NULL
`
const typeKeywordResult = isSqlServer
? await (dbService as SqlServerService).query(typeKeywordSql)
: await (dbService as MySqlService).query(typeKeywordSql)
const typeKeywords = typeKeywordResult.rows.map((row) => ({
materialName: row.MaterialName as string,
managerName: row.ManagerName as string
}))
// Get marked material codes from MaterialsToBeDeleted
const markedTableName = getTableName('dbo_MaterialsToBeDeleted')
const markedSql = `
SELECT MaterialCode, ManagerName
FROM ${markedTableName}
WHERE MaterialCode IS NOT NULL AND ManagerName IS NOT NULL
`
const markedResult = isSqlServer
? await (dbService as SqlServerService).query(markedSql)
: await (dbService as MySqlService).query(markedSql)
const markedCodesDict = new Map<string, string>()
for (const row of markedResult.rows) {
markedCodesDict.set(row.MaterialCode as string, row.ManagerName as string)
}
// Match materials
const results: ValidationResult[] = []
for (const record of materialRecords) {
const materialName = (record.MaterialName as string) || ''
const materialCode = (record.MaterialCode as string) || ''
const specification = (record.Specification as string) || ''
const model = (record.Model as string) || ''
// Priority 1: Check MaterialsToBeDeleted (MaterialCode exact match)
let managerName = markedCodesDict.get(materialCode) || null
const isMarkedForDeletion = managerName !== null
let matchedTypeKeyword: string | undefined = undefined
// Priority 2: Match with MaterialsTypeToBeDeleted (MaterialName contains)
if (!managerName) {
for (const typeKeyword of typeKeywords) {
if (typeKeyword.materialName && materialName.includes(typeKeyword.materialName)) {
matchedTypeKeyword = typeKeyword.materialName
managerName = typeKeyword.managerName
break
}
}
}
// Priority 3: User Override Match (only for non-admin users)
// Override with current user's typeKeyword if available
if (!isAdmin && username) {
const userKeywords = typeKeywords.filter((tk) => tk.managerName === username)
for (const userKeyword of userKeywords) {
if (userKeyword.materialName && materialName.includes(userKeyword.materialName)) {
matchedTypeKeyword = userKeyword.materialName
managerName = userKeyword.managerName
break // Force override with first match
}
}
}
results.push({
materialName,
materialCode,
specification,
model,
managerName: managerName || '',
isMarkedForDeletion,
matchedTypeKeyword
})
}
const markedCount = results.filter((r) => r.isMarkedForDeletion).length
const matchedCount = results.filter((r) => r.managerName).length
return {
success: true,
results,
stats: {
totalRecords: results.length,
matchedCount,
markedCount
}
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
log.error('Validation error', {
error: error instanceof Error ? error.message : String(error)
})
return {
success: false,
error: `Validation failed: ${message}`
}
} finally {
if (dbService) {
try {
await dbService.disconnect()
} catch (closeError) {
log.warn('Error disconnecting database', {
error: closeError instanceof Error ? closeError.message : String(closeError)
})
}
}
}
}
)
// ==================== MATERIAL OPERATIONS ====================
/**
* Upsert batch materials to MaterialsToBeDeleted
*/
ipcMain.handle(
IPC_CHANNELS.MATERIALS_UPSERT_BATCH,
async (_event, request: MaterialUpsertBatchRequest): Promise<MaterialOperationResponse> => {
try {
const dao = new MaterialsToBeDeletedDAO()
const stats = await dao.upsertBatch(request.materials)
return {
success: true,
stats
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
log.error('Upsert batch error', {
error: error instanceof Error ? error.message : String(error)
})
return {
success: false,
error: `Upsert failed: ${message}`
}
}
}
)
/**
* Delete materials by material codes
*/
ipcMain.handle(
IPC_CHANNELS.MATERIALS_DELETE,
async (_event, request: MaterialDeleteRequest): Promise<MaterialOperationResponse> => {
try {
const dao = new MaterialsToBeDeletedDAO()
const count = await dao.deleteByMaterialCodes(request.materialCodes)
return {
success: true,
count
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
log.error('Delete error', { error: error instanceof Error ? error.message : String(error) })
return {
success: false,
error: `Delete failed: ${message}`
}
}
}
)
/**
* Get unique manager names
*/
ipcMain.handle(
IPC_CHANNELS.MATERIALS_GET_MANAGERS,
async (_event): Promise<{ managers: string[] }> => {
try {
const dao = new MaterialsToBeDeletedDAO()
const managers = await dao.getManagers()
return { managers }
} catch (error) {
log.error('Get managers error', {
error: error instanceof Error ? error.message : String(error)
})
return { managers: [] }
}
}
)
/**
* Update manager for a single material
*/
ipcMain.handle(
IPC_CHANNELS.MATERIALS_UPDATE_MANAGER,
async (
_event,
request: { materialCode: string; managerName: string }
): Promise<{ success: boolean; error?: string }> => {
try {
const dao = new MaterialsToBeDeletedDAO()
return await dao.updateManager(request.materialCode, request.managerName)
} catch (error) {
log.error('Update manager error', {
error: error instanceof Error ? error.message : String(error)
})
return {
success: false,
error: error instanceof Error ? error.message : String(error)
}
}
}
)
/**
* Get materials by manager
*/
ipcMain.handle(
IPC_CHANNELS.MATERIALS_GET_BY_MANAGER,
async (_event, managerName: string): Promise<{ materials: MaterialRecordSummary[] }> => {
let dbService: MySqlService | SqlServerService | null = null
try {
dbService = await getValidationDatabaseService()
const configManager = ConfigManager.getInstance()
const dbType = configManager.getDatabaseType()
const isSqlServer = dbType === 'sqlserver'
const dao = new MaterialsToBeDeletedDAO()
const materials = await dao.getMaterialsByManager(managerName)
// Get material codes set for quick lookup
const markedCodes = await dao.getAllMaterialCodes()
// Enrich with material details from DiscreteMaterialPlanData
const enrichedMaterials: MaterialRecordSummary[] = []
const detailTableName = getTableName('dbo_DiscreteMaterialPlanData')
for (const mat of materials) {
let detailResult: any
if (isSqlServer) {
const sql = require('mssql')
const detailSql = `
SELECT TOP 1 MaterialName, Specification, Model
FROM ${detailTableName}
WHERE MaterialCode = @materialCode
`
detailResult = await (dbService as SqlServerService).queryWithParams(detailSql, {
materialCode: { value: mat.materialCode, type: sql.NVarChar }
})
} else {
const detailSql = `
SELECT MaterialName, Specification, Model
FROM ${detailTableName}
WHERE MaterialCode = ?
LIMIT 1
`
detailResult = await (dbService as MySqlService).query(detailSql, [mat.materialCode])
}
enrichedMaterials.push({
materialCode: mat.materialCode,
materialName:
detailResult.rows.length > 0 ? (detailResult.rows[0].MaterialName as string) : '',
specification:
detailResult.rows.length > 0 ? (detailResult.rows[0].Specification as string) : '',
model: detailResult.rows.length > 0 ? (detailResult.rows[0].Model as string) : '',
managerName: mat.managerName,
isMarked: markedCodes.has(mat.materialCode)
})
}
return { materials: enrichedMaterials }
} catch (error) {
log.error('Get by manager error', {
error: error instanceof Error ? error.message : String(error)
})
return { materials: [] }
} finally {
if (dbService) {
try {
await dbService.disconnect()
} catch (closeError) {
log.warn('Error disconnecting database', {
error: closeError instanceof Error ? closeError.message : String(closeError)
})
}
}
}
}
)
/**
* Get all material records
*/
ipcMain.handle(
IPC_CHANNELS.MATERIALS_GET_ALL,
async (_event): Promise<{ materials: MaterialRecordSummary[] }> => {
let dbService: MySqlService | SqlServerService | null = null
try {
dbService = await getValidationDatabaseService()
const configManager = ConfigManager.getInstance()
const dbType = configManager.getDatabaseType()
const isSqlServer = dbType === 'sqlserver'
const dao = new MaterialsToBeDeletedDAO()
const materials = await dao.getAllRecords()
const markedCodes = await dao.getAllMaterialCodes()
const enrichedMaterials: MaterialRecordSummary[] = []
const detailTableName = getTableName('dbo_DiscreteMaterialPlanData')
for (const mat of materials) {
let detailResult: any
if (isSqlServer) {
const sql = require('mssql')
const detailSql = `
SELECT TOP 1 MaterialName, Specification, Model
FROM ${detailTableName}
WHERE MaterialCode = @materialCode
`
detailResult = await (dbService as SqlServerService).queryWithParams(detailSql, {
materialCode: { value: mat.materialCode, type: sql.NVarChar }
})
} else {
const detailSql = `
SELECT MaterialName, Specification, Model
FROM ${detailTableName}
WHERE MaterialCode = ?
LIMIT 1
`
detailResult = await (dbService as MySqlService).query(detailSql, [mat.materialCode])
}
enrichedMaterials.push({
materialCode: mat.materialCode,
materialName:
detailResult.rows.length > 0 ? (detailResult.rows[0].MaterialName as string) : '',
specification:
detailResult.rows.length > 0 ? (detailResult.rows[0].Specification as string) : '',
model: detailResult.rows.length > 0 ? (detailResult.rows[0].Model as string) : '',
managerName: mat.managerName,
isMarked: markedCodes.has(mat.materialCode)
})
}
return { materials: enrichedMaterials }
} catch (error) {
log.error('Get all error', {
error: error instanceof Error ? error.message : String(error)
})
return { materials: [] }
} finally {
if (dbService) {
try {
await dbService.disconnect()
} catch (closeError) {
log.warn('Error disconnecting database', {
error: closeError instanceof Error ? closeError.message : String(closeError)
})
}
}
}
}
)
/**
* Get statistics
*/
ipcMain.handle(IPC_CHANNELS.MATERIALS_GET_STATISTICS, async (_event): Promise<{ stats: any }> => {
try {
const dao = new MaterialsToBeDeletedDAO()
const stats = await dao.getStatistics()
return { stats }
} catch (error) {
log.error('Get statistics error', {
error: error instanceof Error ? error.message : String(error)
})
return { stats: null }
}
})
/**
* Set shared Production IDs from extractor page
*/
ipcMain.handle(
IPC_CHANNELS.VALIDATION_SET_SHARED_PRODUCTION_IDS,
async (event, productionIds: string[]): Promise<void> => {
log.info(`Received ${productionIds.length} shared Production IDs`)
setSharedProductionIds(event.sender.id, productionIds)
}
)
/**
* Get shared Production IDs
*/
ipcMain.handle(
IPC_CHANNELS.VALIDATION_GET_SHARED_PRODUCTION_IDS,
async (event): Promise<{ productionIds: string[] }> => {
return { productionIds: getSharedProductionIds(event.sender.id) }
}
)
/**
* Clear shared Production IDs
*/
ipcMain.handle(
IPC_CHANNELS.VALIDATION_CLEAR_SHARED_PRODUCTION_IDS,
async (event): Promise<void> => {
log.info('Clearing shared Production IDs')
clearSharedProductionIds(event.sender.id)
}
)
/**
* Get cleaner data (order numbers from shared Production IDs + material codes from MaterialsToBeDeleted)
* Filters materials by current user (admin sees all, regular users see only their own)
*/
ipcMain.handle(
IPC_CHANNELS.VALIDATION_GET_CLEANER_DATA,
async (
_event
): Promise<{
success: boolean
orderNumbers?: string[]
materialCodes?: string[]
error?: string
}> => {
let dbService: MySqlService | SqlServerService | null = null
const sessionManager = (
await import('../services/user/session-manager')
).SessionManager.getInstance()
try {
// Get current user
const userInfo = sessionManager.getUserInfo()
if (!userInfo) {
return {
success: false,
error: '用户未登录'
}
}
const isAdmin = userInfo.userType === 'Admin'
const username = userInfo.username
const configManager = ConfigManager.getInstance()
const dbType = configManager.getDatabaseType()
const isSqlServer = dbType === 'sqlserver'
log.info(`User: ${username}, isAdmin: ${isAdmin}`)
// Connect to database
dbService = await getValidationDatabaseService()
// 1. Get order numbers from shared Production IDs
const sharedIds = getSharedProductionIds(_event.sender.id)
let orderNumbers: string[] = []
if (sharedIds.length > 0) {
log.info(`Using ${sharedIds.length} shared Production IDs`)
orderNumbers = await getSourceNumbersFromInputs(sharedIds, dbService)
log.info(`Got ${orderNumbers.length} order numbers`)
}
// 2. Get material codes from MaterialsToBeDeleted table
let materialCodes: string[] = []
const markedTableName = getTableName('dbo_MaterialsToBeDeleted')
if (isAdmin) {
// Admin sees all materials
const allCodesSql = `
SELECT MaterialCode
FROM ${markedTableName}
WHERE MaterialCode IS NOT NULL
`
const result = isSqlServer
? await (dbService as SqlServerService).query(allCodesSql)
: await (dbService as MySqlService).query(allCodesSql)
materialCodes = result.rows.map((row) => row.MaterialCode as string).filter(Boolean)
log.info(`Admin user: got ${materialCodes.length} materials`)
} else {
// Regular users only see their own materials
if (isSqlServer) {
const sql = require('mssql')
const userMaterialsSql = `
SELECT MaterialCode
FROM ${markedTableName}
WHERE ManagerName = @username AND MaterialCode IS NOT NULL
`
const result = await (dbService as SqlServerService).queryWithParams(userMaterialsSql, {
username: { value: username, type: sql.NVarChar }
})
materialCodes = result.rows.map((row) => row.MaterialCode as string).filter(Boolean)
} else {
const userMaterialsSql = `
SELECT MaterialCode
FROM ${markedTableName}
WHERE ManagerName = ? AND MaterialCode IS NOT NULL
`
const result = await (dbService as MySqlService).query(userMaterialsSql, [username])
materialCodes = result.rows.map((row) => row.MaterialCode as string).filter(Boolean)
}
log.info(`Regular user: got ${materialCodes.length} materials`)
}
return {
success: true,
orderNumbers,
materialCodes
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
log.error('CleanerData error', {
error: error instanceof Error ? error.message : String(error)
})
return {
success: false,
error: `获取清理数据失败:${message}`
}
} finally {
if (dbService) {
try {
await dbService.disconnect()
} catch (closeError) {
log.warn('Error disconnecting database', {
error: closeError instanceof Error ? closeError.message : String(closeError)
})
}
}
}
}
)
}

View File

@@ -0,0 +1,45 @@
/**
* Zod schemas for Authentication module validation
*/
import { z } from 'zod'
/**
* Schema for login request validation
*/
export const LoginRequestSchema = z.object({
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required')
})
export type LoginRequestZod = z.infer<typeof LoginRequestSchema>
/**
* Schema for user info validation
*/
export const UserInfoSchema = z.object({
id: z.number().int().positive(),
username: z.string().min(1),
userType: z.enum(['Admin', 'User', 'Guest']),
computerName: z.string().optional()
})
export type UserInfoZod = z.infer<typeof UserInfoSchema>
/**
* Validate login request
*/
export function validateLoginRequest(input: unknown): {
success: boolean
data?: LoginRequestZod
error?: string
} {
const result = LoginRequestSchema.safeParse(input)
if (result.success) {
return { success: true, data: result.data }
}
return {
success: false,
error: result.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join('; ')
}
}

View File

@@ -0,0 +1,49 @@
/**
* Zod schemas for Cleaner module validation
*/
import { z } from 'zod'
/**
* Schema for cleaner input validation
*/
export const CleanerInputSchema = z.object({
orderNumbers: z
.array(z.string().min(1, 'Order number cannot be empty'))
.min(1, 'At least one order number is required'),
materialCodes: z.array(z.string().min(1, 'Material code cannot be empty')),
dryRun: z.boolean(),
queryBatchSize: z.number().int().min(1).max(100).optional().default(100),
processConcurrency: z.number().int().min(1).max(20).optional().default(1)
// Note: onProgress is a function, not validated via Zod
})
export type CleanerInputZod = z.infer<typeof CleanerInputSchema>
/**
* Schema for cleaner result validation
*/
export const CleanerResultSchema = z.object({
processedCount: z.number().int().nonnegative(),
errors: z.array(z.string())
})
export type CleanerResultZod = z.infer<typeof CleanerResultSchema>
/**
* Validate cleaner input
*/
export function validateCleanerInput(input: unknown): {
success: boolean
data?: CleanerInputZod
error?: string
} {
const result = CleanerInputSchema.safeParse(input)
if (result.success) {
return { success: true, data: result.data }
}
return {
success: false,
error: result.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join('; ')
}
}

View File

@@ -0,0 +1,46 @@
/**
* Zod schemas for Extractor module validation
*/
import { z } from 'zod'
/**
* Schema for extractor input validation
*/
export const ExtractorInputSchema = z.object({
orderNumbers: z
.array(z.string().min(1, 'Order number cannot be empty'))
.min(1, 'At least one order number is required'),
batchSize: z.number().int().positive().optional().default(10)
// Note: onProgress is a function, not validated via Zod
})
export type ExtractorInputZod = z.infer<typeof ExtractorInputSchema>
/**
* Schema for extractor result validation
*/
export const ExtractorResultSchema = z.object({
data: z.array(z.record(z.string(), z.unknown())),
errors: z.array(z.string())
})
export type ExtractorResultZod = z.infer<typeof ExtractorResultSchema>
/**
* Validate extractor input
*/
export function validateExtractorInput(input: unknown): {
success: boolean
data?: ExtractorInputZod
error?: string
} {
const result = ExtractorInputSchema.safeParse(input)
if (result.success) {
return { success: true, data: result.data }
}
return {
success: false,
error: result.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join('; ')
}
}

View File

@@ -0,0 +1,346 @@
/**
* Configuration Manager (YAML Version)
*
* Manages application configuration using YAML format
* Provides type-safe access with Zod validation
*
* Note: ERP configuration is stored in database (dbo_BIPUsers table)
* and managed per-user, not in this config file.
*
* Configuration File Location:
* - Development: Project root directory (config.yaml)
* - Production (Installed & Portable): User data directory (AppData)
* This ensures config persists across app updates and is not exposed
*/
import * as fs from 'fs'
import * as path from 'path'
import { fileURLToPath } from 'url'
import { dirname } from 'path'
import { app } from 'electron'
import yaml from 'js-yaml'
import { z } from 'zod'
import { createLogger, setLogLevel } from '../logger'
import {
fullConfigSchema,
type FullConfig,
type DatabaseType,
type MySqlConfig,
type SqlServerConfig,
type LoggingConfig
} from '../../types/config.schema'
const log = createLogger('ConfigManager')
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
/**
* 默认配置
*/
const DEFAULT_CONFIG: FullConfig = {
erp: {
url: 'https://68.11.34.30:8082'
},
database: {
activeType: 'mysql',
mysql: {
host: 'localhost',
port: 3306,
database: 'erp_db',
username: 'root',
password: '',
charset: 'utf8mb4'
},
sqlserver: {
server: 'localhost',
port: 1433,
database: 'erp_db',
username: 'sa',
password: '',
driver: 'ODBC Driver 18 for SQL Server',
trustServerCertificate: true
}
},
paths: {
dataDir: './data/',
defaultOutput: 'output.xlsx',
validationOutput: 'validation-result.xlsx'
},
extraction: {
batchSize: 100,
verbose: true,
autoConvert: true,
mergeBatches: true,
enableDbPersistence: true
},
validation: {
dataSource: 'database_full',
batchSize: 2000,
matchMode: 'substring',
enableCrud: false,
defaultManager: ''
},
cleaner: {
queryBatchSize: 100,
processConcurrency: 1
},
orderResolution: {
tableName: '',
productionIdField: '',
orderNumberField: ''
},
logging: {
level: 'info',
auditRetention: 30,
appRetention: 14
},
rustfs: {
enabled: false,
endpoint: '',
accessKey: '',
secretKey: '',
bucket: 'erpauto',
region: 'us-east-1'
}
}
export class ConfigManager {
private static instance: ConfigManager | null = null
private configPath!: string
private backupPath!: string
private config: FullConfig | null = null
private initialized: boolean = false
private constructor() {
if (this.initialized) return
// 检测是否为开发环境
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
if (isDev) {
// 开发环境:配置文件放在项目根目录,方便编辑和调试
this.configPath = path.resolve(__dirname, '../../config.yaml')
this.backupPath = path.resolve(__dirname, '../../config.yaml.backup')
log.info('Running in development mode', { configPath: this.configPath })
} else {
// 生产环境(包括安装版和便携版):配置文件放在用户数据目录
// Windows: C:\Users\<user>\AppData\Roaming\erpauto\config.yaml
// 这样配置会在应用升级时保留,且不会暴露在应用目录中
this.configPath = path.join(app.getPath('userData'), 'config.yaml')
this.backupPath = path.join(app.getPath('userData'), 'config.yaml.backup')
log.info('Running in production mode', { configPath: this.configPath })
}
this.initialized = true
}
public static getInstance(): ConfigManager {
if (ConfigManager.instance === null) {
ConfigManager.instance = new ConfigManager()
}
return ConfigManager.instance
}
/**
* 初始化配置
* - 如果 config.yaml 不存在,创建默认配置
* - 加载并验证配置
*/
public async initialize(): Promise<void> {
if (!fs.existsSync(this.configPath)) {
log.info('Config file not found, creating default config.yaml')
await this.saveConfig(DEFAULT_CONFIG)
this.config = DEFAULT_CONFIG
// Apply logging configuration from default config
setLogLevel(DEFAULT_CONFIG.logging.level)
return
}
await this.loadConfig()
}
/**
* 加载并验证 YAML 配置
*/
private async loadConfig(): Promise<void> {
try {
const content = fs.readFileSync(this.configPath, 'utf-8')
const parsed = yaml.load(content) as Record<string, unknown>
// Zod 验证
const validated = fullConfigSchema.parse(parsed)
this.config = validated
// Apply logging configuration
setLogLevel(validated.logging.level)
log.info('Configuration loaded and validated successfully')
} catch (error) {
if (error instanceof z.ZodError) {
const messages = error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`)
log.error('Configuration validation failed', { errors: messages })
throw new Error(`配置文件验证失败:\n${messages.join('\n')}`)
}
log.error('Failed to load configuration', { error })
throw error
}
}
/**
* 保存配置到 YAML 文件
*/
private async saveConfig(config: FullConfig): Promise<boolean> {
try {
// 备份现有配置
if (fs.existsSync(this.configPath)) {
fs.copyFileSync(this.configPath, this.backupPath)
}
// 转换为 YAML
const content = yaml.dump(config, {
indent: 2,
lineWidth: -1, // 不自动换行
noRefs: true, // 不使用引用
quotingType: '"',
forceQuotes: false
})
fs.writeFileSync(this.configPath, content, 'utf-8')
this.config = config
log.info('Configuration saved successfully')
return true
} catch (error) {
log.error('Failed to save configuration', { error })
// 恢复备份
if (fs.existsSync(this.backupPath)) {
fs.copyFileSync(this.backupPath, this.configPath)
}
return false
}
}
/**
* 获取完整配置
*/
public getConfig(): FullConfig {
if (!this.config) {
throw new Error('Configuration not initialized. Call initialize() first.')
}
return this.config
}
/**
* 获取当前激活的数据库配置
*/
public getActiveDatabaseConfig(): MySqlConfig | SqlServerConfig {
if (!this.config) {
throw new Error('Configuration not initialized')
}
const { activeType, mysql, sqlserver } = this.config.database
return activeType === 'mysql' ? mysql : sqlserver
}
/**
* 获取数据库类型
*/
public getDatabaseType(): DatabaseType {
if (!this.config) {
throw new Error('Configuration not initialized')
}
return this.config.database.activeType
}
/**
* 获取日志配置
*/
public getLoggingConfig(): LoggingConfig {
if (!this.config) {
throw new Error('Configuration not initialized')
}
return this.config.logging
}
/**
* 更新部分配置(深合并)
*/
public async updateConfig(
updates: Partial<FullConfig>
): Promise<{ success: boolean; error?: string }> {
try {
if (!this.config) {
await this.loadConfig()
}
// 深合并
const merged = this.deepMerge(this.config!, updates)
// 验证合并后的配置
const validated = fullConfigSchema.parse(merged)
const success = await this.saveConfig(validated)
if (!success) {
return { success: false, error: '保存配置失败' }
}
return { success: true }
} catch (error) {
if (error instanceof z.ZodError) {
const messages = error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`)
return { success: false, error: `配置验证失败:\n${messages.join('\n')}` }
}
return { success: false, error: error instanceof Error ? error.message : '未知错误' }
}
}
/**
* 深合并工具函数
*/
private deepMerge<T extends Record<string, any>>(source: T, target: Partial<T>): T {
const result = { ...source }
for (const key in target) {
if (target[key] !== undefined) {
if (
typeof target[key] === 'object' &&
target[key] !== null &&
!Array.isArray(target[key])
) {
result[key] = this.deepMerge(result[key] as any, target[key] as any)
} else {
result[key] = target[key] as any
}
}
}
return result
}
/**
* 重置为默认配置
*/
public async resetToDefaults(): Promise<boolean> {
return this.saveConfig(DEFAULT_CONFIG)
}
/**
* 获取默认配置
*/
public getDefaultConfig(): FullConfig {
return DEFAULT_CONFIG
}
/**
* 导出配置为 YAML 字符串(用于 UI 显示或导出)
*/
public exportToYaml(): string {
if (!this.config) {
throw new Error('Configuration not initialized')
}
return yaml.dump(this.config, {
indent: 2,
lineWidth: -1,
noRefs: true
})
}
}

View File

@@ -0,0 +1,299 @@
/**
* Data Import Service
*
* Reads Excel files and imports data to the DiscreteMaterialPlanData table.
* Workflow:
* 1. Read Excel file
* 2. Extract unique SourceNumbers
* 3. Delete existing records by SourceNumber
* 4. Batch insert new records
*/
import { createLogger } from '../logger'
import { DiscreteMaterialPlanDAO, type MaterialPlanRecord } from './discrete-material-plan-dao'
const log = createLogger('DataImportService')
/**
* Excel column header to database field mapping
*/
const EXCEL_TO_DB_MAPPING: Record<string, keyof MaterialPlanRecord> = {
: 'factory',
: 'materialStatus',
: 'planNumber',
: 'sourceNumber',
: 'materialType',
: 'productCode',
: 'productName',
: 'productPlanQuantity',
: 'productUnit',
: 'useDepartment',
: 'remark',
: 'creator',
: 'createDate',
: 'approver',
: 'approveDate',
: 'sequenceNumber',
: 'materialCode',
: 'materialName',
: 'specification',
: 'model',
: 'drawingNumber',
: 'materialQuality',
: 'planQuantity',
: 'unit',
: 'requiredDate',
: 'warehouse',
: 'unitUsage',
: 'cumulativeOutputQuantity'
// Note: '打印人', '打印日期' are skipped (not in DB)
// Note: 'BOMVersion' is skipped (not in Excel)
}
/**
* Import result
*/
export interface ImportResult {
success: boolean
recordsRead: number
recordsDeleted: number
recordsImported: number
uniqueSourceNumbers: number
errors: string[]
}
/**
* DataImportService class
*/
export class DataImportService {
private dao: DiscreteMaterialPlanDAO
constructor() {
this.dao = new DiscreteMaterialPlanDAO()
}
/**
* Import data from Excel file to database
* @param filePath - Path to the Excel file
* @param batchSize - Number of records per insert batch (default: 1000)
* @returns Import result with statistics
*/
async importFromExcel(filePath: string, batchSize = 1000): Promise<ImportResult> {
const result: ImportResult = {
success: false,
recordsRead: 0,
recordsDeleted: 0,
recordsImported: 0,
uniqueSourceNumbers: 0,
errors: []
}
try {
log.info('Starting import from Excel', { filePath, batchSize })
// Step 1: Read Excel file
log.info('Reading Excel file...')
const { records, sourceNumbers } = await this.readExcelFile(filePath)
result.recordsRead = records.length
result.uniqueSourceNumbers = sourceNumbers.size
log.info('Excel read completed', {
recordsRead: result.recordsRead,
uniqueSourceNumbers: result.uniqueSourceNumbers
})
if (records.length === 0) {
result.success = true
result.errors.push('Excel file contains no data records')
return result
}
// Step 2: Delete existing records by SourceNumber
log.info('Deleting existing records...', {
sourceNumberCount: sourceNumbers.size
})
const sourceNumberArray = Array.from(sourceNumbers)
result.recordsDeleted = await this.dao.deleteBySourceNumbers(sourceNumberArray)
log.info('Existing records deleted', {
recordsDeleted: result.recordsDeleted
})
// Step 3: Batch insert new records
log.info('Inserting new records...', {
recordCount: records.length,
batchSize
})
result.recordsImported = await this.dao.batchInsert(records, batchSize)
log.info('Records imported successfully', {
recordsImported: result.recordsImported
})
result.success = true
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
result.errors.push(`Import failed: ${errorMsg}`)
log.error('Import failed', { error: errorMsg })
} finally {
// Disconnect DAO
try {
await this.dao.disconnect()
} catch (e) {
log.warn('Error disconnecting DAO', {
error: e instanceof Error ? e.message : String(e)
})
}
}
return result
}
/**
* Read Excel file and extract records
* @param filePath - Path to the Excel file
* @returns Records and unique SourceNumbers
*/
private async readExcelFile(
filePath: string
): Promise<{ records: MaterialPlanRecord[]; sourceNumbers: Set<string> }> {
const records: MaterialPlanRecord[] = []
const sourceNumbers = new Set<string>()
// Dynamic import ExcelJS
const ExcelJSModule = await import('exceljs')
const ExcelJS = (ExcelJSModule as any).default || ExcelJSModule
const workbook = new ExcelJS.Workbook()
await workbook.xlsx.readFile(filePath)
// Get first worksheet
const worksheet = workbook.worksheets[0]
if (!worksheet) {
throw new Error('Excel file has no worksheets')
}
// Get header row to map column indices
const headerRow = worksheet.getRow(1)
const columnMapping = this.buildColumnMapping(headerRow)
log.debug('Column mapping built', {
columnCount: Object.keys(columnMapping).length
})
// Iterate through data rows (starting from row 2)
worksheet.eachRow((row: any, rowNumber: number) => {
if (rowNumber === 1) return // Skip header row
try {
const record = this.buildRecordFromRow(row, columnMapping)
if (record) {
records.push(record)
if (record.sourceNumber) {
sourceNumbers.add(record.sourceNumber)
}
}
} catch (error) {
log.warn('Failed to parse row', {
rowNumber,
error: error instanceof Error ? error.message : String(error)
})
}
})
return { records, sourceNumbers }
}
/**
* Build column index to field name mapping from header row
*/
private buildColumnMapping(headerRow: any): Map<number, keyof MaterialPlanRecord> {
const mapping = new Map<number, keyof MaterialPlanRecord>()
headerRow.eachCell((cell: any, colNumber: number) => {
const headerText = cell.text?.toString().trim()
if (headerText && EXCEL_TO_DB_MAPPING[headerText]) {
mapping.set(colNumber, EXCEL_TO_DB_MAPPING[headerText])
}
})
return mapping
}
/**
* Build a MaterialPlanRecord from an Excel row
*/
private buildRecordFromRow(
row: any,
columnMapping: Map<number, keyof MaterialPlanRecord>
): MaterialPlanRecord | null {
const record: Partial<MaterialPlanRecord> = {}
row.eachCell((cell: any, colNumber: number) => {
const fieldName = columnMapping.get(colNumber)
if (!fieldName) return
const value = this.parseCellValue(cell, fieldName)
record[fieldName] = value as any
})
// Validate required fields
if (!record.planNumber) {
return null // Skip records without PlanNumber
}
return record as MaterialPlanRecord
}
/**
* Parse cell value based on field type
*/
private parseCellValue(cell: any, fieldName: keyof MaterialPlanRecord): any {
const text = cell.text?.toString().trim()
const value = cell.value
// Return null for empty cells
if (!text || text === '') {
return null
}
// Handle numeric fields
const numericFields: (keyof MaterialPlanRecord)[] = [
'productPlanQuantity',
'sequenceNumber',
'planQuantity',
'unitUsage',
'cumulativeOutputQuantity'
]
if (numericFields.includes(fieldName)) {
const num = parseFloat(text)
return isNaN(num) ? null : num
}
// Handle date fields
const dateFields: (keyof MaterialPlanRecord)[] = ['createDate', 'approveDate', 'requiredDate']
if (dateFields.includes(fieldName)) {
// ExcelJS returns date as Date object if recognized
if (value instanceof Date) {
return value
}
// Try to parse date string
const date = new Date(text)
return isNaN(date.getTime()) ? null : date
}
// Handle string fields
return text
}
}
/**
* Create a DataImportService instance
*/
export function createDataImportService(): DataImportService {
return new DataImportService()
}

View File

@@ -0,0 +1,103 @@
/**
* TypeORM Data Source Configuration
*
* Provides a centralized database connection for TypeORM entities.
* Supports both MySQL and SQL Server based on configuration.
*
* Note: Configuration is now loaded from config.yaml via ConfigManager,
* not from environment variables.
*/
import 'reflect-metadata'
import { DataSource, DataSourceOptions } from 'typeorm'
import { ConfigManager } from '../config/config-manager'
/**
* Get database type from config manager
*/
function getDatabaseType(): 'mysql' | 'mssql' {
const configManager = ConfigManager.getInstance()
const dbType = configManager.getDatabaseType()
return dbType === 'sqlserver' ? 'mssql' : 'mysql'
}
/**
* Build DataSourceOptions based on database type
*/
function buildDataSourceOptions(): DataSourceOptions {
const type = getDatabaseType()
const configManager = ConfigManager.getInstance()
const config = configManager.getConfig()
const commonOptions: Partial<DataSourceOptions> = {
entities: [__dirname + '/entities/*.{ts,js}'],
synchronize: false, // Never auto-sync in production
logging: false
}
if (type === 'mssql') {
const dbConfig = config.database.sqlserver
return {
type: 'mssql',
host: dbConfig.server,
port: dbConfig.port,
username: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database,
options: {
encrypt: false,
trustServerCertificate: dbConfig.trustServerCertificate
},
...commonOptions
} as DataSourceOptions
}
const dbConfig = config.database.mysql
return {
type: 'mysql',
host: dbConfig.host,
port: dbConfig.port,
username: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database,
...commonOptions
} as DataSourceOptions
}
/**
* TypeORM DataSource singleton
*/
let dataSource: DataSource | null = null
/**
* Get or create the DataSource
*/
export function getDataSource(): DataSource {
if (!dataSource) {
dataSource = new DataSource(buildDataSourceOptions())
}
return dataSource
}
/**
* Initialize the DataSource
*/
export async function initializeDataSource(): Promise<DataSource> {
const ds = getDataSource()
if (!ds.isInitialized) {
await ds.initialize()
}
return ds
}
/**
* Destroy the DataSource
*/
export async function destroyDataSource(): Promise<void> {
if (dataSource && dataSource.isInitialized) {
await dataSource.destroy()
dataSource = null
}
}
export default getDataSource

View File

@@ -0,0 +1,789 @@
/**
* Data Access Object for DiscreteMaterialPlanData table
*
* Mirrors the Python DiscreteMaterialPlanDAO functionality:
* - Query operations for discrete material plan data
* - Support for querying by PlanNumber, SourceNumber
* - Deduplication by MaterialCode
* - Statistics gathering
*/
import { create, type IDatabaseService } from './index'
import { createLogger } from '../logger'
const log = createLogger('DiscreteMaterialPlanDAO')
/**
* Material plan record interface
*/
export interface MaterialPlanRecord {
id?: number
factory: string
materialStatus: string
planNumber: string
sourceNumber: string
materialType: string
productCode: string
productName: string
productUnit: string
productPlanQuantity: number
useDepartment: string
remark: string
creator: string
createDate: Date
approver: string
approveDate: Date
sequenceNumber: number
materialCode: string
materialName: string
specification: string
model: string
drawingNumber: string
materialQuality: string
planQuantity: number
unit: string
requiredDate: Date
warehouse: string
unitUsage: number
cumulativeOutputQuantity: number
bomVersion: string
}
/**
* Configuration for DiscreteMaterialPlanData table
*/
export const DISCRETE_MATERIAL_PLAN_CONFIG = {
TABLE_NAME_SQLSERVER: '[dbo].[DiscreteMaterialPlanData]',
TABLE_NAME_MYSQL: 'dbo_DiscreteMaterialPlanData',
COLUMNS: {
ID: 'ID',
FACTORY: 'Factory',
MATERIAL_STATUS: 'MaterialStatus',
PLAN_NUMBER: 'PlanNumber',
SOURCE_NUMBER: 'SourceNumber',
MATERIAL_TYPE: 'MaterialType',
PRODUCT_CODE: 'ProductCode',
PRODUCT_NAME: 'ProductName',
PRODUCT_UNIT: 'ProductUnit',
PRODUCT_PLAN_QUANTITY: 'ProductPlanQuantity',
USE_DEPARTMENT: 'UseDepartment',
REMARK: 'Remark',
CREATOR: 'Creator',
CREATE_DATE: 'CreateDate',
APPROVER: 'Approver',
APPROVE_DATE: 'ApproveDate',
SEQUENCE_NUMBER: 'SequenceNumber',
MATERIAL_CODE: 'MaterialCode',
MATERIAL_NAME: 'MaterialName',
SPECIFICATION: 'Specification',
MODEL: 'Model',
DRAWING_NUMBER: 'DrawingNumber',
MATERIAL_QUALITY: 'MaterialQuality',
PLAN_QUANTITY: 'PlanQuantity',
UNIT: 'Unit',
REQUIRED_DATE: 'RequiredDate',
WAREHOUSE: 'Warehouse',
UNIT_USAGE: 'UnitUsage',
CUMULATIVE_OUTPUT_QUANTITY: 'CumulativeOutputQuantity',
BOM_VERSION: 'BOMVersion'
}
} as const
/**
* DiscreteMaterialPlanDAO Class
*/
export class DiscreteMaterialPlanDAO {
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
? DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_SQLSERVER
: DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_MYSQL
}
/**
* Get database service instance using DatabaseFactory
*/
private async getDatabaseService(): Promise<IDatabaseService> {
if (this.dbService && this.dbService.isConnected()) {
return this.dbService
}
this.dbService = await create()
return this.dbService
}
/**
* Build placeholders for IN clause based on database type
*/
private buildPlaceholders(count: number, isSqlServer: boolean): string {
return isSqlServer
? Array.from({ length: count }, (_, idx) => `@p${idx}`).join(',')
: Array.from({ length: count }, () => '?').join(',')
}
// ==================== QUERY ALL ====================
/**
* Query all records from DiscreteMaterialPlanData table
* @returns List of all records
*/
async queryAll(): Promise<any[]> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const sqlString = `SELECT * FROM ${tableName}`
const result = await dbService.query(sqlString)
return result.rows
} catch (error) {
log.error('Query all error', {
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Query all records with deduplication by MaterialCode
* Strategy: Keep first record for each MaterialCode
* Order: CreateDate ASC, SequenceNumber ASC
* @returns List of deduplicated records
*/
async queryAllDistinctByMaterialCode(): Promise<any[]> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const sqlString = `
WITH RankedRecords AS (
SELECT
*,
ROW_NUMBER() OVER (
PARTITION BY MaterialCode
ORDER BY CreateDate ASC, SequenceNumber ASC
) AS rn
FROM ${tableName}
WHERE MaterialCode IS NOT NULL
)
SELECT
Factory, MaterialStatus, PlanNumber, SourceNumber, MaterialType,
ProductCode, ProductName, ProductUnit, ProductPlanQuantity,
UseDepartment, Remark, Creator, CreateDate, Approver, ApproveDate,
SequenceNumber, MaterialCode, MaterialName, Specification, Model,
DrawingNumber, MaterialQuality, PlanQuantity, Unit, RequiredDate,
Warehouse, UnitUsage, CumulativeOutputQuantity, BOMVersion
FROM RankedRecords
WHERE rn = 1
`
const result = await dbService.query(sqlString)
return result.rows
} catch (error) {
log.error('Query all distinct by material code error', {
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
// ==================== QUERY BY SOURCE NUMBER ====================
/**
* Query records by SourceNumber list
* @param sourceNumbers - List of SourceNumber values
* @returns List of records
*/
async queryBySourceNumbers(sourceNumbers: string[]): Promise<any[]> {
if (!sourceNumbers || sourceNumbers.length === 0) {
return []
}
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const batchSize = 1500
const allResults: any[] = []
for (let i = 0; i < sourceNumbers.length; i += batchSize) {
const batch = sourceNumbers.slice(i, i + batchSize)
const placeholders = this.buildPlaceholders(batch.length, isSqlServer)
const sqlString = `
SELECT *
FROM ${tableName}
WHERE SourceNumber IN (${placeholders})
`
const result = await dbService.query(sqlString, batch)
allResults.push(...result.rows)
}
return allResults
} catch (error) {
log.error('Query by source numbers error', {
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Query records by SourceNumber with deduplication by MaterialCode
* Strategy: Keep first record for each MaterialCode
* Order: CreateDate ASC, SequenceNumber ASC
* @param sourceNumbers - List of SourceNumber values
* @returns List of deduplicated records
*/
async queryBySourceNumbersDistinct(sourceNumbers: string[]): Promise<any[]> {
if (!sourceNumbers || sourceNumbers.length === 0) {
return []
}
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const batchSize = 1500
const allResults: any[] = []
for (let i = 0; i < sourceNumbers.length; i += batchSize) {
const batch = sourceNumbers.slice(i, i + batchSize)
const placeholders = this.buildPlaceholders(batch.length, isSqlServer)
const sqlString = `
WITH RankedRecords AS (
SELECT
*,
ROW_NUMBER() OVER (
PARTITION BY MaterialCode
ORDER BY CreateDate ASC, SequenceNumber ASC
) AS rn
FROM ${tableName}
WHERE SourceNumber IN (${placeholders})
AND MaterialCode IS NOT NULL
)
SELECT
Factory, MaterialStatus, PlanNumber, SourceNumber, MaterialType,
ProductCode, ProductName, ProductUnit, ProductPlanQuantity,
UseDepartment, Remark, Creator, CreateDate, Approver, ApproveDate,
SequenceNumber, MaterialCode, MaterialName, Specification, Model,
DrawingNumber, MaterialQuality, PlanQuantity, Unit, RequiredDate,
Warehouse, UnitUsage, CumulativeOutputQuantity, BOMVersion
FROM RankedRecords
WHERE rn = 1
`
const result = await dbService.query(sqlString, batch)
allResults.push(...result.rows)
}
return allResults
} catch (error) {
log.error('Query by source numbers distinct error', {
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Query by production order number (SourceNumber)
* @param sourceNumber - SourceNumber value
* @returns List of records
*/
async queryBySourceNumber(sourceNumber: string): Promise<any[]> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const placeholder = isSqlServer ? '@p0' : '?'
const sqlString = `
SELECT *
FROM ${tableName}
WHERE SourceNumber = ${placeholder}
`
const result = await dbService.query(sqlString, [sourceNumber])
return result.rows
} catch (error) {
log.error('Query by source number error', {
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
// ==================== QUERY BY PLAN NUMBER ====================
/**
* Query by plan number
* @param planNumber - PlanNumber value
* @returns List of records
*/
async queryByPlanNumber(planNumber: string): Promise<any[]> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const placeholder = isSqlServer ? '@p0' : '?'
const sqlString = `
SELECT *
FROM ${tableName}
WHERE PlanNumber = ${placeholder}
`
const result = await dbService.query(sqlString, [planNumber])
return result.rows
} catch (error) {
log.error('Query by plan number error', {
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Query by multiple plan numbers
* @param planNumbers - List of PlanNumber values
* @returns List of records
*/
async queryByPlanNumbers(planNumbers: string[]): Promise<any[]> {
if (!planNumbers || planNumbers.length === 0) {
return []
}
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const batchSize = 1500
const allResults: any[] = []
for (let i = 0; i < planNumbers.length; i += batchSize) {
const batch = planNumbers.slice(i, i + batchSize)
const placeholders = this.buildPlaceholders(batch.length, isSqlServer)
const sqlString = `
SELECT *
FROM ${tableName}
WHERE PlanNumber IN (${placeholders})
`
const result = await dbService.query(sqlString, batch)
allResults.push(...result.rows)
}
return allResults
} catch (error) {
log.error('Query by plan numbers error', {
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
// ==================== DELETE OPERATIONS ====================
/**
* Delete records by SourceNumber list
* Uses batch processing for large lists
* @param sourceNumbers - List of SourceNumber values to delete
* @returns Number of records deleted
*/
async deleteBySourceNumbers(sourceNumbers: string[]): Promise<number> {
if (!sourceNumbers || sourceNumbers.length === 0) {
return 0
}
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const batchSize = 2000
let totalDeleted = 0
// Get unique source numbers
const uniqueSourceNumbers = [...new Set(sourceNumbers.filter(Boolean))]
for (let i = 0; i < uniqueSourceNumbers.length; i += batchSize) {
const batch = uniqueSourceNumbers.slice(i, i + batchSize)
const placeholders = this.buildPlaceholders(batch.length, isSqlServer)
const sqlString = `
DELETE FROM ${tableName}
WHERE SourceNumber IN (${placeholders})
`
const result = await dbService.query(sqlString, batch)
totalDeleted += result.rowCount || 0
log.debug('Deleted batch', {
batch: i / batchSize + 1,
count: result.rowCount
})
}
log.info('Deleted records by source numbers', {
totalDeleted,
sourceNumberCount: uniqueSourceNumbers.length
})
return totalDeleted
} catch (error) {
log.error('Delete by source numbers error', {
error: error instanceof Error ? error.message : String(error)
})
throw error
}
}
// ==================== INSERT OPERATIONS ====================
/**
* Insert records in batches
* @param records - List of MaterialPlanRecord to insert
* @param batchSize - Number of records per batch (default: 1000, auto-adjusted for SQL Server)
* @returns Number of records inserted
*/
async batchInsert(records: MaterialPlanRecord[], batchSize = 1000): Promise<number> {
if (!records || records.length === 0) {
return 0
}
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
let totalInserted = 0
// SQL Server has a limit of 2100 parameters per query
// Each record has 28 columns, so max rows per batch = 2100 / 28 = 75
// Leave some margin for query overhead
const columnsPerRow = 28
const sqlServerMaxParams = 2000
const effectiveBatchSize = isSqlServer
? Math.min(batchSize, Math.floor(sqlServerMaxParams / columnsPerRow))
: batchSize
log.info('Batch insert parameters', {
isSqlServer,
dbType: dbService.type,
columnsPerRow,
effectiveBatchSize,
totalRecords: records.length
})
// Process in batches
for (let i = 0; i < records.length; i += effectiveBatchSize) {
const batch = records.slice(i, i + effectiveBatchSize)
const inserted = await this.insertBatch(dbService, tableName, batch, isSqlServer)
totalInserted += inserted
log.debug('Inserted batch', {
batch: Math.floor(i / effectiveBatchSize) + 1,
count: inserted
})
}
log.info('Batch insert completed', {
totalInserted,
batchSize: effectiveBatchSize
})
return totalInserted
} catch (error) {
log.error('Batch insert error', {
error: error instanceof Error ? error.message : String(error)
})
throw error
}
}
/**
* Insert a single batch of records
*/
private async insertBatch(
dbService: IDatabaseService,
tableName: string,
records: MaterialPlanRecord[],
isSqlServer: boolean
): Promise<number> {
if (records.length === 0) {
return 0
}
// Build column list (excluding id)
const columns = [
'Factory',
'MaterialStatus',
'PlanNumber',
'SourceNumber',
'MaterialType',
'ProductCode',
'ProductName',
'ProductUnit',
'ProductPlanQuantity',
'UseDepartment',
'Remark',
'Creator',
'CreateDate',
'Approver',
'ApproveDate',
'SequenceNumber',
'MaterialCode',
'MaterialName',
'Specification',
'Model',
'DrawingNumber',
'MaterialQuality',
'PlanQuantity',
'Unit',
'RequiredDate',
'Warehouse',
'UnitUsage',
'CumulativeOutputQuantity'
]
// Build parameterized insert
const values: any[] = []
const rowPlaceholders: string[] = []
records.forEach((record, rowIndex) => {
const rowValues = this.buildRowValues(record, columns, rowIndex, isSqlServer, values)
rowPlaceholders.push(`(${rowValues.join(',')})`)
})
const sqlString = `
INSERT INTO ${tableName} (${columns.join(', ')})
VALUES ${rowPlaceholders.join(', ')}
`
const result = await dbService.query(sqlString, values)
return result.rowCount || records.length
}
/**
* Build parameter values for a single row
*/
private buildRowValues(
record: MaterialPlanRecord,
columns: string[],
_rowIndex: number,
isSqlServer: boolean,
values: any[]
): string[] {
return columns.map((col) => {
const value = this.getColumnValue(record, col)
values.push(value)
if (isSqlServer) {
return `@p${values.length - 1}`
} else {
return '?'
}
})
}
/**
* Get the value for a specific column from the record
*/
private getColumnValue(record: MaterialPlanRecord, column: string): any {
const columnMapping: Record<string, keyof MaterialPlanRecord> = {
Factory: 'factory',
MaterialStatus: 'materialStatus',
PlanNumber: 'planNumber',
SourceNumber: 'sourceNumber',
MaterialType: 'materialType',
ProductCode: 'productCode',
ProductName: 'productName',
ProductUnit: 'productUnit',
ProductPlanQuantity: 'productPlanQuantity',
UseDepartment: 'useDepartment',
Remark: 'remark',
Creator: 'creator',
CreateDate: 'createDate',
Approver: 'approver',
ApproveDate: 'approveDate',
SequenceNumber: 'sequenceNumber',
MaterialCode: 'materialCode',
MaterialName: 'materialName',
Specification: 'specification',
Model: 'model',
DrawingNumber: 'drawingNumber',
MaterialQuality: 'materialQuality',
PlanQuantity: 'planQuantity',
Unit: 'unit',
RequiredDate: 'requiredDate',
Warehouse: 'warehouse',
UnitUsage: 'unitUsage',
CumulativeOutputQuantity: 'cumulativeOutputQuantity'
}
const key = columnMapping[column]
if (!key) {
return null
}
const value = record[key]
// Handle null/undefined
if (value === null || value === undefined) {
return null
}
// Handle empty strings for string fields
if (typeof value === 'string' && value.trim() === '') {
return null
}
return value
}
// ==================== UTILITY METHODS ====================
/**
* Count all records
* @returns Total number of records
*/
async countAll(): Promise<number> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const sqlString = `SELECT COUNT(*) as count FROM ${tableName}`
const result = await dbService.query(sqlString)
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
} catch (error) {
log.error('Count all error', {
error: error instanceof Error ? error.message : String(error)
})
return 0
}
}
/**
* Count records by plan number
* @param planNumber - PlanNumber value
* @returns Number of records
*/
async countByPlanNumber(planNumber: string): Promise<number> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const placeholder = isSqlServer ? '@p0' : '?'
const sqlString = `
SELECT COUNT(*) as count
FROM ${tableName}
WHERE PlanNumber = ${placeholder}
`
const result = await dbService.query(sqlString, [planNumber])
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
} catch (error) {
log.error('Count by plan number error', {
error: error instanceof Error ? error.message : String(error)
})
return 0
}
}
/**
* Get unique material names
* @param sourceNumbers - Optional list of SourceNumber values to filter
* @returns List of unique material names
*/
async getUniqueMaterialNames(sourceNumbers?: string[]): Promise<string[]> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
if (sourceNumbers && sourceNumbers.length > 0) {
const batchSize = 1500
const allNames: string[] = []
for (let i = 0; i < sourceNumbers.length; i += batchSize) {
const batch = sourceNumbers.slice(i, i + batchSize)
const placeholders = this.buildPlaceholders(batch.length, isSqlServer)
const sqlString = `
SELECT DISTINCT MaterialName
FROM ${tableName}
WHERE SourceNumber IN (${placeholders})
AND MaterialName IS NOT NULL
`
const result = await dbService.query(sqlString, batch)
allNames.push(...result.rows.map((row) => row.MaterialName as string).filter(Boolean))
}
return allNames
} else {
const sqlString = `
SELECT DISTINCT MaterialName
FROM ${tableName}
WHERE MaterialName IS NOT NULL
`
const result = await dbService.query(sqlString)
return result.rows.map((row) => row.MaterialName as string).filter(Boolean)
}
} catch (error) {
log.error('Get unique material names error', {
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Get statistics
* @returns Statistics object
*/
async getStatistics(): Promise<any> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const sqlString = `
SELECT
COUNT(*) as totalRecords,
COUNT(DISTINCT PlanNumber) as uniquePlans,
COUNT(DISTINCT SourceNumber) as uniqueOrders,
MIN(CreateDate) as earliestRecord,
MAX(CreateDate) as latestRecord
FROM ${tableName}
`
const result = await dbService.query(sqlString)
return result.rows.length > 0 ? result.rows[0] : {}
} catch (error) {
log.error('Get statistics error', {
error: error instanceof Error ? error.message : String(error)
})
return {}
}
}
/**
* Disconnect from database
*/
async disconnect(): Promise<void> {
if (this.dbService) {
await this.dbService.disconnect()
this.dbService = null
}
}
}

View File

@@ -0,0 +1,143 @@
/**
* TypeORM Entity for DiscreteMaterialPlanData table
*/
import { Entity, PrimaryGeneratedColumn, Column, Index } from 'typeorm'
@Entity('DiscreteMaterialPlanData')
export class DiscreteMaterialPlan {
@PrimaryGeneratedColumn()
id!: number
@Column({ name: 'Factory', type: 'nvarchar', length: 100, nullable: true })
factory!: string | null
@Column({ name: 'MaterialStatus', type: 'nvarchar', length: 50, nullable: true })
materialStatus!: string | null
@Index()
@Column({ name: 'PlanNumber', type: 'nvarchar', length: 100, nullable: true })
planNumber!: string | null
@Index()
@Column({ name: 'SourceNumber', type: 'nvarchar', length: 100, nullable: true })
sourceNumber!: string | null
@Column({ name: 'MaterialType', type: 'nvarchar', length: 100, nullable: true })
materialType!: string | null
@Column({ name: 'ProductCode', type: 'nvarchar', length: 100, nullable: true })
productCode!: string | null
@Column({ name: 'ProductName', type: 'nvarchar', length: 255, nullable: true })
productName!: string | null
@Column({ name: 'ProductUnit', type: 'nvarchar', length: 50, nullable: true })
productUnit!: string | null
@Column({ name: 'ProductPlanQuantity', type: 'decimal', precision: 18, scale: 4, nullable: true })
productPlanQuantity!: number | null
@Column({ name: 'UseDepartment', type: 'nvarchar', length: 100, nullable: true })
useDepartment!: string | null
@Column({ name: 'Remark', type: 'nvarchar', length: 500, nullable: true })
remark!: string | null
@Column({ name: 'Creator', type: 'nvarchar', length: 100, nullable: true })
creator!: string | null
@Column({ name: 'CreateDate', type: 'datetime', nullable: true })
createDate!: Date | null
@Column({ name: 'Approver', type: 'nvarchar', length: 100, nullable: true })
approver!: string | null
@Column({ name: 'ApproveDate', type: 'datetime', nullable: true })
approveDate!: Date | null
@Column({ name: 'SequenceNumber', type: 'int', nullable: true })
sequenceNumber!: number | null
@Index()
@Column({ name: 'MaterialCode', type: 'nvarchar', length: 100, nullable: true })
materialCode!: string | null
@Column({ name: 'MaterialName', type: 'nvarchar', length: 255, nullable: true })
materialName!: string | null
@Column({ name: 'Specification', type: 'nvarchar', length: 255, nullable: true })
specification!: string | null
@Column({ name: 'Model', type: 'nvarchar', length: 255, nullable: true })
model!: string | null
@Column({ name: 'DrawingNumber', type: 'nvarchar', length: 100, nullable: true })
drawingNumber!: string | null
@Column({ name: 'MaterialQuality', type: 'nvarchar', length: 100, nullable: true })
materialQuality!: string | null
@Column({ name: 'PlanQuantity', type: 'decimal', precision: 18, scale: 4, nullable: true })
planQuantity!: number | null
@Column({ name: 'Unit', type: 'nvarchar', length: 50, nullable: true })
unit!: string | null
@Column({ name: 'RequiredDate', type: 'datetime', nullable: true })
requiredDate!: Date | null
@Column({ name: 'Warehouse', type: 'nvarchar', length: 100, nullable: true })
warehouse!: string | null
@Column({ name: 'UnitUsage', type: 'decimal', precision: 18, scale: 6, nullable: true })
unitUsage!: number | null
@Column({
name: 'CumulativeOutputQuantity',
type: 'decimal',
precision: 18,
scale: 4,
nullable: true
})
cumulativeOutputQuantity!: number | null
@Column({ name: 'BOMVersion', type: 'nvarchar', length: 50, nullable: true })
bomVersion!: string | null
}
/**
* Material plan record interface for type-safe operations
*/
export interface MaterialPlanRecordData {
id?: number
factory?: string | null
materialStatus?: string | null
planNumber?: string | null
sourceNumber?: string | null
materialType?: string | null
productCode?: string | null
productName?: string | null
productUnit?: string | null
productPlanQuantity?: number | null
useDepartment?: string | null
remark?: string | null
creator?: string | null
createDate?: Date | null
approver?: string | null
approveDate?: Date | null
sequenceNumber?: number | null
materialCode?: string | null
materialName?: string | null
specification?: string | null
model?: string | null
drawingNumber?: string | null
materialQuality?: string | null
planQuantity?: number | null
unit?: string | null
requiredDate?: Date | null
warehouse?: string | null
unitUsage?: number | null
cumulativeOutputQuantity?: number | null
bomVersion?: string | null
}

View File

@@ -0,0 +1,27 @@
/**
* TypeORM Entity for MaterialsToBeDeleted table
*/
import { Entity, PrimaryGeneratedColumn, Column, Index } from 'typeorm'
@Entity('MaterialsToBeDeleted')
export class MaterialsToBeDeleted {
@PrimaryGeneratedColumn()
id!: number
@Index({ unique: true })
@Column({ name: 'MaterialCode', type: 'nvarchar', length: 255, nullable: false })
materialCode!: string
@Column({ name: 'ManagerName', type: 'nvarchar', length: 255, nullable: true })
managerName!: string | null
}
/**
* Material record interface for type-safe operations
*/
export interface MaterialRecordData {
id?: number
materialCode: string
managerName: string | null
}

View File

@@ -0,0 +1,184 @@
/**
* Database Factory
*
* Creates and manages database service instances based on configuration.
* Supports both MySQL and SQL Server databases.
*/
import { ConfigManager } from '../config/config-manager'
import { MySqlService } from './mysql'
import { SqlServerService } from './sql-server'
import type {
IDatabaseService,
DatabaseType,
MySqlConfig,
SqlServerConfig
} from '../../types/database.types'
import { createLogger } from '../logger'
const log = createLogger('DatabaseFactory')
/**
* Cached database service instances
*/
const instances: Map<DatabaseType, IDatabaseService> = new Map()
/**
* Get the current database type from config manager
*/
export function getDatabaseType(): DatabaseType {
const configManager = ConfigManager.getInstance()
return configManager.getDatabaseType()
}
/**
* Create MySQL configuration from config manager
*/
export function createMySqlConfig(): MySqlConfig {
const configManager = ConfigManager.getInstance()
const dbConfig = configManager.getConfig().database.mysql
return {
host: dbConfig.host,
port: dbConfig.port,
user: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database
}
}
/**
* Create SQL Server configuration from config manager
*/
export function createSqlServerConfig(): SqlServerConfig {
const configManager = ConfigManager.getInstance()
const dbConfig = configManager.getConfig().database.sqlserver
return {
server: dbConfig.server,
port: dbConfig.port,
user: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database,
options: {
encrypt: false,
trustServerCertificate: dbConfig.trustServerCertificate
}
}
}
/**
* Create a database service instance
*
* Uses singleton pattern - returns cached instance if available.
*
* @param type - Optional database type override (defaults to config)
* @returns Database service instance
*/
export async function create(type?: DatabaseType): Promise<IDatabaseService> {
const dbType = type || getDatabaseType()
// Return cached instance if available and connected
const cached = instances.get(dbType)
if (cached && cached.isConnected()) {
log.debug('Returning cached database instance', { type: dbType })
return cached
}
// Create new instance
let service: IDatabaseService
if (dbType === 'sqlserver') {
log.info('Creating SQL Server database service')
service = new SqlServerService(createSqlServerConfig())
} else {
log.info('Creating MySQL database service')
service = new MySqlService(createMySqlConfig())
}
// Connect to database
await service.connect()
log.info('Database connected', { type: dbType })
// Cache the instance
instances.set(dbType, service)
return service
}
/**
* Get existing database service without creating new one
*
* @param type - Optional database type (defaults to config)
* @returns Database service instance or undefined
*/
export function get(type?: DatabaseType): IDatabaseService | undefined {
const dbType = type || getDatabaseType()
return instances.get(dbType)
}
/**
* Disconnect and remove a specific database service
*
* @param type - Optional database type (defaults to DB_TYPE env var)
*/
export async function disconnect(type?: DatabaseType): Promise<void> {
const dbType = type || getDatabaseType()
const service = instances.get(dbType)
if (service) {
try {
await service.disconnect()
log.info('Database disconnected', { type: dbType })
} catch (error) {
log.warn('Error disconnecting database', {
type: dbType,
error: error instanceof Error ? error.message : String(error)
})
}
instances.delete(dbType)
}
}
/**
* Disconnect all database services
*/
export async function disconnectAll(): Promise<void> {
log.info('Disconnecting all database services')
const disconnectPromises = Array.from(instances.entries()).map(async ([type, service]) => {
try {
await service.disconnect()
log.debug('Database disconnected', { type })
} catch (error) {
log.warn('Error disconnecting database', {
type,
error: error instanceof Error ? error.message : String(error)
})
}
})
await Promise.all(disconnectPromises)
instances.clear()
log.info('All database services disconnected')
}
/**
* Check if a database service is connected
*
* @param type - Optional database type (defaults to DB_TYPE env var)
*/
export function isConnected(type?: DatabaseType): boolean {
const dbType = type || getDatabaseType()
const service = instances.get(dbType)
return service?.isConnected() ?? false
}
// Re-export types and services
export { MySqlService } from './mysql'
export { SqlServerService } from './sql-server'
export type {
IDatabaseService,
DatabaseType,
QueryResult,
MySqlConfig,
SqlServerConfig
} from '../../types/database.types'

View File

@@ -0,0 +1,680 @@
/**
* Data Access Object for MaterialsToBeDeleted table
*
* Mirrors the Python MaterialsToBeDeletedDAO functionality:
* - CRUD operations for materials identified by MaterialCode
* - Batch upsert operations
* - Manager-based filtering and queries
* - Statistics gathering
*/
import { create, type IDatabaseService } from './index'
import { createLogger } from '../logger'
const log = createLogger('MaterialsToBeDeletedDAO')
/**
* Material record interface
*/
export interface MaterialRecord {
id?: number
materialCode: string
managerName: string
}
/**
* Upsert statistics
*/
export interface UpsertStats {
total: number
success: number
failed: number
}
/**
* Material statistics
*/
export interface MaterialStats {
totalMaterials: number
uniqueManagers: number
materialsPerManager: Record<string, number>[]
}
/**
* Configuration for MaterialsToBeDeleted table
*/
export const MATERIALS_TO_BE_DELETED_CONFIG = {
TABLE_NAME_SQLSERVER: '[dbo].[MaterialsToBeDeleted]',
TABLE_NAME_MYSQL: 'dbo_MaterialsToBeDeleted',
COLUMNS: {
ID: 'ID',
MATERIAL_CODE: 'MaterialCode',
MANAGER_NAME: 'ManagerName'
}
} as const
/**
* MaterialsToBeDeleted DAO Class
*/
export class MaterialsToBeDeletedDAO {
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_TO_BE_DELETED_CONFIG.TABLE_NAME_SQLSERVER
: MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL
}
/**
* Get database service instance using DatabaseFactory
*/
private async getDatabaseService(): Promise<IDatabaseService> {
if (this.dbService && this.dbService.isConnected()) {
return this.dbService
}
this.dbService = await create()
return this.dbService
}
/**
* Build placeholders for IN clause based on database type
*/
private buildPlaceholders(count: number, isSqlServer: boolean): string {
return isSqlServer
? Array.from({ length: count }, (_, idx) => `@p${idx}`).join(',')
: Array.from({ length: count }, () => '?').join(',')
}
// ==================== UPSERT (MERGE) ====================
/**
* Insert or update a single material record
* @param materialCode - Material code (exact match key)
* @param managerName - Manager name
* @returns True if successful
*/
async upsertMaterial(materialCode: string, managerName: string): Promise<boolean> {
if (!materialCode || !materialCode.trim()) {
log.error('MaterialCode cannot be empty')
return false
}
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const code = materialCode.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 (MaterialCode, ManagerName)
ON target.MaterialCode = source.MaterialCode
WHEN MATCHED THEN UPDATE SET ManagerName = source.ManagerName
WHEN NOT MATCHED THEN INSERT (MaterialCode, ManagerName) VALUES (source.MaterialCode, source.ManagerName);
`
await dbService.query(sqlString, [code, manager])
} else {
// MySQL ON DUPLICATE KEY UPDATE
const sqlString = `
INSERT INTO ${tableName} (MaterialCode, ManagerName)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE ManagerName = VALUES(ManagerName)
`
await dbService.query(sqlString, [code, manager])
}
return true
} catch (error) {
log.error('Upsert material error', {
error: error instanceof Error ? error.message : String(error)
})
return false
}
}
/**
* Insert or update multiple material records in batch
* @param materials - List of materials with materialCode and managerName
* @returns Statistics object
*/
async upsertBatch(
materials: { materialCode: string; managerName: string }[]
): Promise<UpsertStats> {
if (!materials || materials.length === 0) {
return { total: 0, success: 0, failed: 0 }
}
const stats: UpsertStats = {
total: materials.length,
success: 0,
failed: 0
}
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
for (const material of materials) {
const materialCode = material.materialCode?.trim()
const managerName = material.managerName?.trim() || ''
if (!materialCode) {
stats.failed++
continue
}
try {
if (isSqlServer) {
// SQL Server MERGE statement
const sqlString = `
MERGE ${tableName} AS target
USING (VALUES (@p0, @p1)) AS source (MaterialCode, ManagerName)
ON target.MaterialCode = source.MaterialCode
WHEN MATCHED THEN UPDATE SET ManagerName = source.ManagerName
WHEN NOT MATCHED THEN INSERT (MaterialCode, ManagerName) VALUES (source.MaterialCode, source.ManagerName);
`
await dbService.query(sqlString, [materialCode, managerName || null])
} else {
// MySQL ON DUPLICATE KEY UPDATE
const sqlString = `
INSERT INTO ${tableName} (MaterialCode, ManagerName)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE ManagerName = VALUES(ManagerName)
`
await dbService.query(sqlString, [materialCode, managerName || null])
}
stats.success++
} catch (error) {
log.error('Error upserting material', {
materialCode,
error: error instanceof Error ? error.message : String(error)
})
stats.failed++
}
}
} catch (error) {
log.error('Batch upsert error', {
error: error instanceof Error ? error.message : String(error)
})
stats.failed = stats.total - stats.success
}
return stats
}
/**
* Update manager for a single material
* @param materialCode - Material code
* @param managerName - New manager name
* @returns Success status
*/
async updateManager(
materialCode: string,
managerName: string
): Promise<{ success: boolean; error?: string }> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
if (isSqlServer) {
const sqlString = `
MERGE ${tableName} AS target
USING (VALUES (@p0, @p1)) AS source (MaterialCode, ManagerName)
ON target.MaterialCode = source.MaterialCode
WHEN MATCHED THEN UPDATE SET ManagerName = source.ManagerName
WHEN NOT MATCHED THEN INSERT (MaterialCode, ManagerName) VALUES (source.MaterialCode, source.ManagerName);
`
await dbService.query(sqlString, [materialCode, managerName || null])
} else {
const sqlString = `
INSERT INTO ${tableName} (MaterialCode, ManagerName)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE ManagerName = VALUES(ManagerName)
`
await dbService.query(sqlString, [materialCode, managerName || null])
}
return { success: true }
} catch (error) {
log.error('Update manager error', {
materialCode,
error: error instanceof Error ? error.message : String(error)
})
return {
success: false,
error: error instanceof Error ? error.message : String(error)
}
}
}
// ==================== READ ====================
/**
* Get all material codes as a set
* @returns Set of material codes
*/
async getAllMaterialCodes(): Promise<Set<string>> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const sqlString = `
SELECT MaterialCode
FROM ${tableName}
WHERE MaterialCode IS NOT NULL
`
const result = await dbService.query(sqlString)
return new Set(result.rows.map((row) => row.MaterialCode as string).filter(Boolean))
} catch (error) {
log.error('Get all material codes error', {
error: error instanceof Error ? error.message : String(error)
})
return new Set()
}
}
/**
* Get all material records
* @returns List of all material records
*/
async getAllRecords(): Promise<MaterialRecord[]> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const sqlString = `
SELECT ID, MaterialCode, ManagerName
FROM ${tableName}
WHERE MaterialCode IS NOT NULL
ORDER BY ManagerName, MaterialCode
`
const result = await dbService.query(sqlString)
return result.rows.map((row) => ({
id: row.ID as number,
materialCode: row.MaterialCode as string,
managerName: row.ManagerName as string
}))
} catch (error) {
log.error('Get all records 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<MaterialRecord[]> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const placeholder = isSqlServer ? '@p0' : '?'
const sqlString = `
SELECT ID, MaterialCode, ManagerName
FROM ${tableName}
WHERE ManagerName = ${placeholder} AND MaterialCode IS NOT NULL
ORDER BY MaterialCode
`
const result = await dbService.query(sqlString, [managerName])
return result.rows.map((row) => ({
id: row.ID as number,
materialCode: row.MaterialCode 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<string[]> {
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 []
}
}
/**
* Get a specific record by material code
* @param materialCode - Material code
* @returns Material record or null
*/
async getRecordByMaterialCode(materialCode: string): Promise<MaterialRecord | null> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const code = materialCode.trim()
const isSqlServer = dbService.type === 'sqlserver'
const placeholder = isSqlServer ? '@p0' : '?'
const sqlString = `
SELECT ID, MaterialCode, ManagerName
FROM ${tableName}
WHERE MaterialCode = ${placeholder}
`
const result = await dbService.query(sqlString, [code])
if (result.rows.length === 0) {
return null
}
const row = result.rows[0]
return {
id: row.ID as number,
materialCode: row.MaterialCode as string,
managerName: row.ManagerName as string
}
} catch (error) {
log.error('Get record by material code error', {
error: error instanceof Error ? error.message : String(error)
})
return null
}
}
// ==================== DELETE ====================
/**
* Delete a specific material by material code
* @param materialCode - Material code
* @returns True if successful
*/
async deleteByMaterialCode(materialCode: string): Promise<boolean> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const code = materialCode.trim()
const isSqlServer = dbService.type === 'sqlserver'
const placeholder = isSqlServer ? '@p0' : '?'
const sqlString = `
DELETE FROM ${tableName}
WHERE MaterialCode = ${placeholder}
`
const result = await dbService.query(sqlString, [code])
return result.rowCount > 0
} catch (error) {
log.error('Delete by material code error', {
error: error instanceof Error ? error.message : String(error)
})
return false
}
}
/**
* Delete all materials for a specific manager
* @param managerName - Manager name
* @returns Number of records deleted
*/
async deleteByManager(managerName: string): Promise<number> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const placeholder = isSqlServer ? '@p0' : '?'
const sqlString = `
DELETE FROM ${tableName}
WHERE ManagerName = ${placeholder}
`
const result = await dbService.query(sqlString, [managerName])
return result.rowCount
} catch (error) {
log.error('Delete by manager error', {
error: error instanceof Error ? error.message : String(error)
})
return 0
}
}
/**
* Delete all material records
* @returns Number of records deleted
*/
async deleteAllMaterials(): Promise<number> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const sqlString = `DELETE FROM ${tableName}`
const result = await dbService.query(sqlString)
return result.rowCount
} catch (error) {
log.error('Delete all materials error', {
error: error instanceof Error ? error.message : String(error)
})
return 0
}
}
/**
* Delete multiple materials by material codes
* @param materialCodes - List of material codes to delete
* @returns Number of records deleted
*/
async deleteByMaterialCodes(materialCodes: string[]): Promise<number> {
if (!materialCodes || materialCodes.length === 0) {
return 0
}
let totalDeleted = 0
const batchSize = 1000
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
for (let i = 0; i < materialCodes.length; i += batchSize) {
const batch = materialCodes.slice(i, i + batchSize)
const placeholders = this.buildPlaceholders(batch.length, isSqlServer)
const sqlString = `
DELETE FROM ${tableName}
WHERE MaterialCode IN (${placeholders})
`
const result = await dbService.query(
sqlString,
batch.map((c) => c.trim())
)
totalDeleted += result.rowCount
}
} catch (error) {
log.error('Delete by material codes error', {
error: error instanceof Error ? error.message : String(error)
})
}
return totalDeleted
}
// ==================== UTILITIES ====================
/**
* Check if a material exists
* @param materialCode - Material code
* @returns True if material exists
*/
async materialExists(materialCode: string): Promise<boolean> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const code = materialCode.trim()
const isSqlServer = dbService.type === 'sqlserver'
const placeholder = isSqlServer ? '@p0' : '?'
const sqlString = `
SELECT COUNT(*) as count
FROM ${tableName}
WHERE MaterialCode = ${placeholder}
`
const result = await dbService.query(sqlString, [code])
return result.rows.length > 0 && (result.rows[0].count as number) > 0
} catch (error) {
log.error('Material exists error', {
error: error instanceof Error ? error.message : String(error)
})
return false
}
}
/**
* Count all material records
* @returns Total number of records
*/
async countAll(): Promise<number> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const sqlString = `SELECT COUNT(*) as count FROM ${tableName}`
const result = await dbService.query(sqlString)
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
} catch (error) {
log.error('Count all error', {
error: error instanceof Error ? error.message : String(error)
})
return 0
}
}
/**
* Count materials for a specific manager
* @param managerName - Manager name
* @returns Number of materials for the manager
*/
async countByManager(managerName: string): Promise<number> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const placeholder = isSqlServer ? '@p0' : '?'
const sqlString = `
SELECT COUNT(*) as count
FROM ${tableName}
WHERE ManagerName = ${placeholder}
`
const result = await dbService.query(sqlString, [managerName])
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
} catch (error) {
log.error('Count by manager error', {
error: error instanceof Error ? error.message : String(error)
})
return 0
}
}
/**
* Get comprehensive statistics
* @returns Statistics object
*/
async getStatistics(): Promise<MaterialStats> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
// Get total and unique managers
const statsSql = `
SELECT
COUNT(*) as totalMaterials,
COUNT(DISTINCT ManagerName) as uniqueManagers
FROM ${tableName}
WHERE MaterialCode IS NOT NULL
`
const statsResult = await dbService.query(statsSql)
const stats = statsResult.rows[0] || {}
// Get materials per manager
const managerSql = `
SELECT ManagerName, COUNT(*) as count
FROM ${tableName}
WHERE ManagerName IS NOT NULL
GROUP BY ManagerName
ORDER BY count DESC
`
const managerResult = await dbService.query(managerSql)
const materialsPerManager = managerResult.rows.map((row) => ({
[row.ManagerName as string]: row.count as number
}))
return {
totalMaterials: (stats.totalMaterials as number) || 0,
uniqueManagers: (stats.uniqueManagers as number) || 0,
materialsPerManager
}
} catch (error) {
log.error('Get statistics error', {
error: error instanceof Error ? error.message : String(error)
})
return {
totalMaterials: 0,
uniqueManagers: 0,
materialsPerManager: []
}
}
}
/**
* Disconnect from database
*/
async disconnect(): Promise<void> {
if (this.dbService) {
await this.dbService.disconnect()
this.dbService = null
}
}
}

View File

@@ -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<IDatabaseService> {
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<MaterialTypeRecord[]> {
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<MaterialTypeRecord[]> {
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<string[]> {
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<boolean> {
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<boolean> {
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<boolean> {
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<void> {
if (this.dbService) {
await this.dbService.disconnect()
this.dbService = null
}
}
}

View File

@@ -0,0 +1,130 @@
import mysql from 'mysql2/promise'
import type {
IDatabaseService,
DatabaseType,
QueryResult,
MySqlConfig
} from '../../types/database.types'
export type { MySqlConfig } from '../../types/database.types'
export class MySqlService implements IDatabaseService {
/** Database type identifier */
readonly type: DatabaseType = 'mysql'
private connection: mysql.Connection | null = null
private config: MySqlConfig
constructor(config: MySqlConfig) {
this.config = config
}
/**
* Connect to MySQL database
*/
async connect(): Promise<void> {
if (this.connection) {
throw new Error('Already connected to MySQL')
}
try {
this.connection = await mysql.createConnection({
host: this.config.host,
port: this.config.port,
user: this.config.user,
password: this.config.password,
database: this.config.database
})
// Test connection
await this.connection.ping()
} catch (error) {
throw new Error(`Failed to connect to MySQL: ${(error as Error).message}`)
}
}
/**
* Disconnect from MySQL database
*/
async disconnect(): Promise<void> {
if (!this.connection) {
return
}
try {
await this.connection.end()
this.connection = null
} catch (error) {
throw new Error(`Failed to disconnect from MySQL: ${(error as Error).message}`)
}
}
/**
* Check if connected to MySQL database
*/
isConnected(): boolean {
return this.connection !== null
}
/**
* Execute a query and return results
*/
async query(sql: string, params?: any[]): Promise<QueryResult> {
if (!this.connection) {
throw new Error('Not connected to MySQL. Call connect() first.')
}
try {
const [result, fields] = await this.connection.execute(sql, params)
// Convert to plain objects and extract column names
const columns = Array.isArray(fields) ? fields.map((field) => field.name) : []
// Handle different result types
let rows: Record<string, unknown>[] = []
let rowCount = 0
if (Array.isArray(result)) {
// SELECT query - result is an array of rows
rows = result as Record<string, unknown>[]
rowCount = rows.length
} else if (typeof result === 'object' && result !== null) {
// INSERT/UPDATE/DELETE query - result is OkPacket
const okPacket = result as any
rowCount = okPacket.affectedRows || okPacket.changedRows || 0
}
return {
rows,
columns,
rowCount
}
} catch (error) {
throw new Error(`MySQL query failed: ${(error as Error).message}`)
}
}
/**
* Execute multiple queries in a transaction
*/
async transaction(queries: { sql: string; params?: any[] }[]): Promise<void> {
if (!this.connection) {
throw new Error('Not connected to MySQL. Call connect() first.')
}
try {
await this.connection.beginTransaction()
for (const { sql, params } of queries) {
await this.connection.execute(sql, params)
}
await this.connection.commit()
} catch (error) {
if (this.connection) {
await this.connection.rollback()
}
throw new Error(`MySQL transaction failed: ${(error as Error).message}`)
}
}
}

View File

@@ -0,0 +1,281 @@
/**
* Repository for DiscreteMaterialPlan entity
*
* Provides type-safe database operations for discrete material plan data.
*/
import { DataSource, Repository, In } from 'typeorm'
import { DiscreteMaterialPlan, MaterialPlanRecordData } from '../entities/DiscreteMaterialPlan'
import { getDataSource } from '../data-source'
import { createLogger } from '../../logger'
const log = createLogger('DiscreteMaterialPlanRepository')
/**
* DiscreteMaterialPlan Repository class
*/
export class DiscreteMaterialPlanRepository {
private repository: Repository<DiscreteMaterialPlan> | null = null
private dataSource: DataSource | null = null
/**
* Get the repository instance
*/
private async getRepository(): Promise<Repository<DiscreteMaterialPlan>> {
if (!this.repository) {
this.dataSource = getDataSource()
if (!this.dataSource.isInitialized) {
await this.dataSource.initialize()
}
this.repository = this.dataSource.getRepository(DiscreteMaterialPlan)
}
return this.repository
}
/**
* Query all records
*/
async queryAll(): Promise<DiscreteMaterialPlan[]> {
try {
const repo = await this.getRepository()
return await repo.find()
} catch (error) {
log.error('Query all failed', {
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Query all records with deduplication by MaterialCode
*/
async queryAllDistinctByMaterialCode(): Promise<DiscreteMaterialPlan[]> {
try {
const repo = await this.getRepository()
const query = `
WITH RankedRecords AS (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY MaterialCode
ORDER BY CreateDate ASC, SequenceNumber ASC
) AS rn
FROM DiscreteMaterialPlanData
WHERE MaterialCode IS NOT NULL
)
SELECT * FROM RankedRecords WHERE rn = 1
`
return await repo.query(query)
} catch (error) {
log.error('Query all distinct by material code failed', {
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Query by source numbers (production order numbers)
*/
async queryBySourceNumbers(sourceNumbers: string[]): Promise<DiscreteMaterialPlan[]> {
if (!sourceNumbers.length) return []
try {
const repo = await this.getRepository()
const batchSize = 2000
const allResults: DiscreteMaterialPlan[] = []
for (let i = 0; i < sourceNumbers.length; i += batchSize) {
const batch = sourceNumbers.slice(i, i + batchSize)
const results = await repo.find({
where: { sourceNumber: In(batch) }
})
allResults.push(...results)
}
return allResults
} catch (error) {
log.error('Query by source numbers failed', {
count: sourceNumbers.length,
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Query by source numbers with deduplication by MaterialCode
*/
async queryBySourceNumbersDistinct(sourceNumbers: string[]): Promise<DiscreteMaterialPlan[]> {
if (!sourceNumbers.length) return []
try {
const repo = await this.getRepository()
const batchSize = 2000
const allResults: DiscreteMaterialPlan[] = []
for (let i = 0; i < sourceNumbers.length; i += batchSize) {
const batch = sourceNumbers.slice(i, i + batchSize)
const query = `
WITH RankedRecords AS (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY MaterialCode
ORDER BY CreateDate ASC, SequenceNumber ASC
) AS rn
FROM DiscreteMaterialPlanData
WHERE SourceNumber IN (?) AND MaterialCode IS NOT NULL
)
SELECT * FROM RankedRecords WHERE rn = 1
`
const results = await repo.query(query, [batch])
allResults.push(...results)
}
return allResults
} catch (error) {
log.error('Query by source numbers distinct failed', {
count: sourceNumbers.length,
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Query by single source number
*/
async queryBySourceNumber(sourceNumber: string): Promise<DiscreteMaterialPlan[]> {
try {
const repo = await this.getRepository()
return await repo.find({ where: { sourceNumber } })
} catch (error) {
log.error('Query by source number failed', {
sourceNumber,
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Query by plan number
*/
async queryByPlanNumber(planNumber: string): Promise<DiscreteMaterialPlan[]> {
try {
const repo = await this.getRepository()
return await repo.find({ where: { planNumber } })
} catch (error) {
log.error('Query by plan number failed', {
planNumber,
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Query by multiple plan numbers
*/
async queryByPlanNumbers(planNumbers: string[]): Promise<DiscreteMaterialPlan[]> {
if (!planNumbers.length) return []
try {
const repo = await this.getRepository()
return await repo.find({
where: { planNumber: In(planNumbers) }
})
} catch (error) {
log.error('Query by plan numbers failed', {
count: planNumbers.length,
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Count all records
*/
async countAll(): Promise<number> {
try {
const repo = await this.getRepository()
return await repo.count()
} catch {
return 0
}
}
/**
* Get unique material names
*/
async getUniqueMaterialNames(sourceNumbers?: string[]): Promise<string[]> {
try {
const repo = await this.getRepository()
let query = repo
.createQueryBuilder('m')
.select('DISTINCT m.materialName', 'materialName')
.where('m.materialName IS NOT NULL')
if (sourceNumbers && sourceNumbers.length > 0) {
query = query.andWhere('m.sourceNumber IN (:...sourceNumbers)', { sourceNumbers })
}
const result = await query.orderBy('m.materialName', 'ASC').getRawMany()
return result.map((r) => r.materialName).filter(Boolean)
} catch (error) {
log.error('Get unique material names failed', {
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Get statistics
*/
async getStatistics(): Promise<{
totalRecords: number
uniquePlans: number
uniqueOrders: number
earliestRecord: Date | null
latestRecord: Date | null
}> {
try {
const repo = await this.getRepository()
const result = await repo
.createQueryBuilder('m')
.select('COUNT(*)', 'totalRecords')
.addSelect('COUNT(DISTINCT m.planNumber)', 'uniquePlans')
.addSelect('COUNT(DISTINCT m.sourceNumber)', 'uniqueOrders')
.addSelect('MIN(m.createDate)', 'earliestRecord')
.addSelect('MAX(m.createDate)', 'latestRecord')
.getRawOne()
return {
totalRecords: parseInt(result?.totalRecords || '0', 10),
uniquePlans: parseInt(result?.uniquePlans || '0', 10),
uniqueOrders: parseInt(result?.uniqueOrders || '0', 10),
earliestRecord: result?.earliestRecord || null,
latestRecord: result?.latestRecord || null
}
} catch (error) {
log.error('Get statistics failed', {
error: error instanceof Error ? error.message : String(error)
})
return {
totalRecords: 0,
uniquePlans: 0,
uniqueOrders: 0,
earliestRecord: null,
latestRecord: null
}
}
}
}

View File

@@ -0,0 +1,266 @@
/**
* Repository for MaterialsToBeDeleted entity
*
* Provides type-safe database operations for materials to be deleted.
*/
import { DataSource, Repository, In } from 'typeorm'
import { MaterialsToBeDeleted, MaterialRecordData } from '../entities/MaterialsToBeDeleted'
import { getDataSource } from '../data-source'
import { createLogger } from '../../logger'
const log = createLogger('MaterialsToBeDeletedRepository')
/**
* Upsert statistics
*/
export interface UpsertStats {
total: number
success: number
failed: number
}
/**
* MaterialsToBeDeleted Repository class
*/
export class MaterialsToBeDeletedRepository {
private repository: Repository<MaterialsToBeDeleted> | null = null
private dataSource: DataSource | null = null
/**
* Get the repository instance
*/
private async getRepository(): Promise<Repository<MaterialsToBeDeleted>> {
if (!this.repository) {
this.dataSource = getDataSource()
if (!this.dataSource.isInitialized) {
await this.dataSource.initialize()
}
this.repository = this.dataSource.getRepository(MaterialsToBeDeleted)
}
return this.repository
}
/**
* Insert or update a single material record
*/
async upsert(materialCode: string, managerName: string | null): Promise<boolean> {
try {
const repo = await this.getRepository()
// Use upsert pattern
let entity = await repo.findOne({ where: { materialCode } })
if (entity) {
entity.managerName = managerName
} else {
entity = repo.create({ materialCode, managerName })
}
await repo.save(entity)
log.debug('Upserted material', { materialCode })
return true
} catch (error) {
log.error('Upsert material failed', {
materialCode,
error: error instanceof Error ? error.message : String(error)
})
return false
}
}
/**
* Insert or update multiple material records in batch
*/
async upsertBatch(materials: MaterialRecordData[]): Promise<UpsertStats> {
const stats: UpsertStats = {
total: materials.length,
success: 0,
failed: 0
}
try {
const repo = await this.getRepository()
for (const material of materials) {
if (!material.materialCode?.trim()) {
stats.failed++
continue
}
try {
let entity = await repo.findOne({ where: { materialCode: material.materialCode } })
if (entity) {
entity.managerName = material.managerName
} else {
entity = repo.create({
materialCode: material.materialCode,
managerName: material.managerName
})
}
await repo.save(entity)
stats.success++
} catch {
stats.failed++
}
}
log.info('Batch upsert completed', stats)
return stats
} catch (error) {
log.error('Batch upsert failed', {
error: error instanceof Error ? error.message : String(error)
})
stats.failed = stats.total - stats.success
return stats
}
}
/**
* Get all material codes as a set
*/
async getAllMaterialCodes(): Promise<Set<string>> {
try {
const repo = await this.getRepository()
const records = await repo.find({
select: ['materialCode'],
where: { materialCode: In([]) } // This will be overridden
})
// Use query builder for better performance
const result = await repo
.createQueryBuilder('m')
.select('m.materialCode')
.where('m.materialCode IS NOT NULL')
.getMany()
return new Set(result.map((r) => r.materialCode).filter(Boolean))
} catch (error) {
log.error('Get all material codes failed', {
error: error instanceof Error ? error.message : String(error)
})
return new Set()
}
}
/**
* Get all records
*/
async getAllRecords(): Promise<MaterialsToBeDeleted[]> {
try {
const repo = await this.getRepository()
return await repo.find({
order: { managerName: 'ASC', materialCode: 'ASC' }
})
} catch (error) {
log.error('Get all records failed', {
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Get materials by manager name
*/
async getByManager(managerName: string): Promise<MaterialsToBeDeleted[]> {
try {
const repo = await this.getRepository()
return await repo.find({
where: { managerName },
order: { materialCode: 'ASC' }
})
} catch (error) {
log.error('Get by manager failed', {
managerName,
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Get unique manager names
*/
async getManagers(): Promise<string[]> {
try {
const repo = await this.getRepository()
const result = await repo
.createQueryBuilder('m')
.select('DISTINCT m.managerName', 'managerName')
.where('m.managerName IS NOT NULL')
.orderBy('m.managerName', 'ASC')
.getRawMany()
return result.map((r) => r.managerName).filter(Boolean)
} catch (error) {
log.error('Get managers failed', {
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Delete by material code
*/
async deleteByMaterialCode(materialCode: string): Promise<boolean> {
try {
const repo = await this.getRepository()
const result = await repo.delete({ materialCode })
return (result.affected ?? 0) > 0
} catch (error) {
log.error('Delete by material code failed', {
materialCode,
error: error instanceof Error ? error.message : String(error)
})
return false
}
}
/**
* Delete multiple materials by codes
*/
async deleteByMaterialCodes(materialCodes: string[]): Promise<number> {
if (!materialCodes.length) return 0
try {
const repo = await this.getRepository()
const result = await repo.delete({ materialCode: In(materialCodes) })
return result.affected ?? 0
} catch (error) {
log.error('Delete by material codes failed', {
count: materialCodes.length,
error: error instanceof Error ? error.message : String(error)
})
return 0
}
}
/**
* Check if a material exists
*/
async exists(materialCode: string): Promise<boolean> {
try {
const repo = await this.getRepository()
const count = await repo.count({ where: { materialCode } })
return count > 0
} catch {
return false
}
}
/**
* Count all records
*/
async countAll(): Promise<number> {
try {
const repo = await this.getRepository()
return await repo.count()
} catch {
return 0
}
}
}

View File

@@ -0,0 +1,191 @@
import sql from 'mssql'
import type {
IDatabaseService,
DatabaseType,
QueryResult,
SqlServerConfig
} from '../../types/database.types'
export type { SqlServerConfig } from '../../types/database.types'
export class SqlServerService implements IDatabaseService {
/** Database type identifier */
readonly type: DatabaseType = 'sqlserver'
private pool: sql.ConnectionPool | null = null
private config: SqlServerConfig
constructor(config: SqlServerConfig) {
this.config = config
}
/**
* Connect to SQL Server database
*/
async connect(): Promise<void> {
if (this.pool) {
throw new Error('Already connected to SQL Server')
}
try {
const poolConfig: sql.config = {
server: this.config.server,
port: this.config.port,
user: this.config.user,
password: this.config.password,
database: this.config.database,
options: {
encrypt: this.config.options?.encrypt ?? false,
trustServerCertificate: this.config.options?.trustServerCertificate ?? false
}
}
this.pool = new sql.ConnectionPool(poolConfig)
await this.pool.connect()
} catch (error) {
throw new Error(`Failed to connect to SQL Server: ${(error as Error).message}`)
}
}
/**
* Disconnect from SQL Server database
*/
async disconnect(): Promise<void> {
if (!this.pool) {
return
}
try {
await this.pool.close()
this.pool = null
} catch (error) {
throw new Error(`Failed to disconnect from SQL Server: ${(error as Error).message}`)
}
}
/**
* Check if connected to SQL Server database
*/
isConnected(): boolean {
return this.pool !== null && this.pool.connected
}
/**
* Execute a query and return results
* @param sqlString - SQL query string with @p0, @p1, ... placeholders
* @param params - Query parameters as an array (converted to @p0, @p1, ...)
*/
async query(sqlString: string, params?: any[]): Promise<QueryResult> {
if (!this.pool) {
throw new Error('Not connected to SQL Server. Call connect() first.')
}
try {
const request = this.pool.request()
// Add parameters if provided - convert array to @p0, @p1, ... format
if (params && params.length > 0) {
params.forEach((value, index) => {
request.input(`p${index}`, value)
})
}
const result = await request.query(sqlString)
// Convert recordset to array of objects (may be undefined for DELETE/INSERT/UPDATE)
const rows = (result.recordset as Record<string, unknown>[]) || []
// Extract column names from the first row if available
const columns = rows.length > 0 ? Object.keys(rows[0]) : []
return {
rows,
columns,
rowCount: result.rowsAffected?.[0] || rows.length
}
} catch (error) {
throw new Error(`SQL Server query failed: ${(error as Error).message}`)
}
}
/**
* Execute a prepared statement with named parameters
* @param sqlString - SQL query string with @paramName placeholders
* @param params - Parameters as an object with { value, type? } structure
*/
async queryWithParams(
sqlString: string,
params: Record<
string,
{
value: unknown
type?: sql.ISqlType | sql.ISqlTypeFactoryWithLength | sql.ISqlTypeWithLength
}
>
): Promise<QueryResult> {
if (!this.pool) {
throw new Error('Not connected to SQL Server. Call connect() first.')
}
try {
const request = this.pool.request()
// Add parameters with explicit types
for (const [key, { value, type }] of Object.entries(params)) {
if (type) {
request.input(key, type, value)
} else {
request.input(key, value)
}
}
const result = await request.query(sqlString)
// Convert recordset to array of objects
const rows = result.recordset as Record<string, unknown>[]
// Extract column names from the first row if available
const columns = rows.length > 0 ? Object.keys(rows[0]) : []
return {
rows,
columns,
rowCount: result.rowsAffected?.[0] || rows.length
}
} catch (error) {
throw new Error(`SQL Server query failed: ${(error as Error).message}`)
}
}
/**
* Execute multiple queries in a transaction
* @param queries - Array of queries with array-based parameters
*/
async transaction(queries: { sql: string; params?: any[] }[]): Promise<void> {
if (!this.pool) {
throw new Error('Not connected to SQL Server. Call connect() first.')
}
const transaction = new sql.Transaction(this.pool)
try {
await transaction.begin()
for (const { sql: sqlString, params } of queries) {
const request = new sql.Request(transaction)
// Add parameters if provided - convert array to @p0, @p1, ... format
if (params && params.length > 0) {
params.forEach((value, index) => {
request.input(`p${index}`, value)
})
}
await request.query(sqlString)
}
await transaction.commit()
} catch (error) {
await transaction.rollback()
throw new Error(`SQL Server transaction failed: ${(error as Error).message}`)
}
}
}

View File

@@ -0,0 +1,208 @@
/**
* ERP Browser Manager
*
* Manages browser lifecycle for ERP automation.
* Separates browser management from authentication logic.
*/
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright'
import { createLogger } from '../logger'
const log = createLogger('ErpBrowserManager')
/**
* Browser configuration options
*/
export interface BrowserConfig {
headless?: boolean
slowMo?: number
viewport?: { width: number; height: number }
ignoreHTTPSErrors?: boolean
acceptDownloads?: boolean
}
/**
* Default browser configuration
*/
const DEFAULT_CONFIG: Required<BrowserConfig> = {
headless: false,
slowMo: 100,
viewport: { width: 1920, height: 1080 },
ignoreHTTPSErrors: true,
acceptDownloads: true
}
/**
* Browser session containing all browser-related objects
*/
export interface BrowserSession {
browser: Browser
context: BrowserContext
page: Page
}
/**
* ErpBrowserManager class
* Manages browser lifecycle independently from ERP authentication
*/
export class ErpBrowserManager {
private config: Required<BrowserConfig>
private session: BrowserSession | null = null
constructor(config?: BrowserConfig) {
this.config = { ...DEFAULT_CONFIG, ...config }
}
/**
* Launch a new browser instance
*/
async launch(): Promise<Browser> {
if (this.session?.browser?.isConnected()) {
log.debug('Browser already running, returning existing instance')
return this.session.browser
}
log.info('Launching browser', { headless: this.config.headless })
const browser = await chromium.launch({
headless: this.config.headless,
slowMo: this.config.slowMo,
args: [
'--ignore-certificate-errors',
'--ignore-ssl-errors',
'--ignore-certificate-errors-spki-list',
'--disable-web-security'
]
})
log.info('Browser launched successfully')
return browser
}
/**
* Create a new browser context
*/
async createContext(browser?: Browser): Promise<BrowserContext> {
const browserInstance = browser || (await this.launch())
log.debug('Creating browser context')
const context = await browserInstance.newContext({
acceptDownloads: this.config.acceptDownloads,
viewport: this.config.viewport,
ignoreHTTPSErrors: true,
javaScriptEnabled: true
})
log.debug('Browser context created')
return context
}
/**
* Create a new page in the context
*/
async createPage(context?: BrowserContext): Promise<Page> {
let contextInstance: BrowserContext
if (context) {
contextInstance = context
} else if (this.session?.context) {
contextInstance = this.session.context
} else {
const browser = await this.launch()
contextInstance = await this.createContext(browser)
}
log.debug('Creating new page')
const page = await contextInstance.newPage()
log.debug('Page created')
return page
}
/**
* Initialize a complete browser session
* This creates browser, context, and page in one call
*/
async initialize(): Promise<BrowserSession> {
if (this.session) {
log.debug('Returning existing browser session')
return this.session
}
const browser = await this.launch()
const context = await this.createContext(browser)
const page = await this.createPage(context)
this.session = { browser, context, page }
log.info('Browser session initialized')
return this.session
}
/**
* Get the current session
*/
getSession(): BrowserSession | null {
return this.session
}
/**
* Check if browser is running
*/
isRunning(): boolean {
return this.session?.browser?.isConnected() ?? false
}
/**
* Close the browser and cleanup
*/
async close(): Promise<void> {
if (!this.session) {
log.debug('No browser session to close')
return
}
log.info('Closing browser session')
try {
if (this.session.context) {
await this.session.context.close()
}
} catch (error) {
log.warn('Error closing context', {
error: error instanceof Error ? error.message : String(error)
})
}
try {
if (this.session.browser) {
await this.session.browser.close()
}
} catch (error) {
log.warn('Error closing browser', {
error: error instanceof Error ? error.message : String(error)
})
}
this.session = null
log.info('Browser session closed')
}
/**
* Navigate to a URL
*/
async navigate(url: string, options?: { timeout?: number }): Promise<void> {
const page = this.session?.page
if (!page) {
throw new Error('No page available. Call initialize() first.')
}
log.info('Navigating to URL', { url })
await page.goto(url, { timeout: options?.timeout ?? 30000 })
await page.waitForLoadState('domcontentloaded', { timeout: options?.timeout ?? 10000 })
log.debug('Page loaded')
}
}
export default ErpBrowserManager

View File

@@ -0,0 +1,865 @@
import { ERP_LOCATORS } from './locators'
import { ErpAuthService } from './erp-auth'
import type { CleanerInput, CleanerResult, OrderCleanDetail } from '../../types/cleaner.types'
import type { ErpSession } from '../../types/erp.types'
import type { FrameLocator, Locator, Page } from 'playwright'
import { createLogger } from '../logger'
const log = createLogger('CleanerService')
const DEFAULT_QUERY_BATCH_SIZE = 100
const MAX_QUERY_BATCH_SIZE = 100
const DEFAULT_PROCESS_CONCURRENCY = 1
const MAX_PROCESS_CONCURRENCY = 20
interface RetryResult {
retriedOrders: number
successfulRetries: number
updatedDetails: OrderCleanDetail[]
}
interface ProgressState {
completedOrders: number
totalOrders: number
}
interface QueryResultRow {
rowIndex: number
orderNumber: string
}
class AsyncMutex {
private queue: Promise<void> = Promise.resolve()
async runExclusive<T>(task: () => Promise<T>): Promise<T> {
let release!: () => void
const next = new Promise<void>((resolve) => {
release = resolve
})
const previous = this.queue
this.queue = this.queue.then(() => next)
await previous
try {
return await task()
} finally {
release()
}
}
}
/**
* Cleaner Service Options
*/
export interface CleanerOptions {
dryRun?: boolean
verbose?: boolean
}
/**
* Material deletion check parameters
*/
export interface ShouldDeleteParams {
rowNumber: number
pendingQty: string
materialCode: string
deleteSet: Set<string>
}
function clampNumber(
value: number | undefined,
fallback: number,
min: number,
max: number
): number {
if (!Number.isFinite(value)) {
return fallback
}
return Math.min(max, Math.max(min, Math.trunc(value ?? fallback)))
}
export function createBatches<T>(items: T[], batchSize: number): T[][] {
const batches: T[][] = []
for (let i = 0; i < items.length; i += batchSize) {
batches.push(items.slice(i, i + batchSize))
}
return batches
}
export function getMissingOrders(inputOrders: string[], processedOrders: Set<string>): string[] {
const uniqueInputOrders = Array.from(new Set(inputOrders))
return uniqueInputOrders.filter((order) => !processedOrders.has(order))
}
export async function runWithConcurrency<T, R>(
items: T[],
concurrency: number,
worker: (item: T, index: number) => Promise<R>
): Promise<R[]> {
const results = new Array<R>(items.length)
const limit = Math.max(1, Math.trunc(concurrency))
let cursor = 0
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (true) {
const current = cursor
cursor += 1
if (current >= items.length) {
return
}
results[current] = await worker(items[current], current)
}
})
await Promise.all(runners)
return results
}
/**
* ERP Cleaner Service
* Deletes specified materials from production orders in ERP system
*/
export class CleanerService {
private authService: ErpAuthService
private dryRun: boolean
constructor(authService: ErpAuthService, options: CleanerOptions = {}) {
this.authService = authService
this.dryRun = options.dryRun ?? false
}
/**
* Check if dry-run mode is enabled
*/
isDryRun(): boolean {
return this.dryRun
}
/**
* Determine if a material should be deleted
*/
shouldDeleteMaterial(params: ShouldDeleteParams): boolean {
const { rowNumber, pendingQty, materialCode, deleteSet } = params
if (!deleteSet.has(materialCode)) {
return false
}
if (rowNumber >= 2000 && rowNumber < 8000) {
return false
}
if (pendingQty && pendingQty.trim() !== '') {
return false
}
return true
}
getSkipReason(params: ShouldDeleteParams): string {
const { rowNumber, pendingQty, materialCode, deleteSet } = params
if (!deleteSet.has(materialCode)) {
return '物料不在删除清单中'
}
if (rowNumber >= 2000 && rowNumber < 8000) {
return '行号在 2000-7999 范围内(受保护)'
}
if (pendingQty && pendingQty.trim() !== '') {
return '累计待发数量不为空'
}
return '未知原因'
}
async clean(input: CleanerInput): Promise<CleanerResult> {
const result: CleanerResult = {
ordersProcessed: 0,
materialsDeleted: 0,
materialsSkipped: 0,
errors: [],
details: [],
retriedOrders: 0,
successfulRetries: 0
}
const totalOrders = input.orderNumbers.length
const dryRun = input.dryRun ?? this.dryRun
const queryBatchSize = clampNumber(
input.queryBatchSize,
DEFAULT_QUERY_BATCH_SIZE,
1,
MAX_QUERY_BATCH_SIZE
)
const processConcurrency = clampNumber(
input.processConcurrency,
DEFAULT_PROCESS_CONCURRENCY,
1,
MAX_PROCESS_CONCURRENCY
)
log.info('Starting cleaner', {
totalOrders,
materialCount: input.materialCodes.length,
dryRun,
queryBatchSize,
processConcurrency
})
const deleteSet = new Set(input.materialCodes)
let popupPage: Page | null = null
try {
const session = this.authService.getSession()
const navigation = await this.navigateToCleanerPage(session)
popupPage = navigation.popupPage
const { workFrame } = navigation
await this.setupQueryInterface(workFrame)
const orderBatches = createBatches(input.orderNumbers, queryBatchSize)
const popupMutex = new AsyncMutex()
const progressState: ProgressState = {
completedOrders: 0,
totalOrders
}
for (let batchIndex = 0; batchIndex < orderBatches.length; batchIndex++) {
const batchOrders = orderBatches[batchIndex]
log.info('Processing cleaner batch', {
batchIndex: batchIndex + 1,
totalBatches: orderBatches.length,
batchSize: batchOrders.length
})
await this.queryOrders(workFrame, batchOrders)
await this.waitForLoading(workFrame)
const queriedRows = await this.collectQueryResultRows(workFrame)
const queriedOrderNumbersInBatch = new Set(queriedRows.map((row) => row.orderNumber))
await runWithConcurrency(queriedRows, processConcurrency, async (row) => {
const { rowIndex, orderNumber } = row
const openedDetailPage = await popupMutex.runExclusive(async () => {
return await this.openDetailPageFromRow(workFrame, popupPage!, rowIndex)
})
let detail: OrderCleanDetail
try {
detail = await this.processDetailPage({
detailPage: openedDetailPage,
deleteSet,
dryRun,
expectedOrderNumber: orderNumber,
progressState,
onProgress: input.onProgress
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
detail = this.createErrorDetail(orderNumber, message)
} finally {
progressState.completedOrders += 1
}
result.details.push(detail)
if (detail.errors.length > 0) {
result.errors.push(`Order ${detail.orderNumber}: ${detail.errors.join('; ')}`)
return
}
result.ordersProcessed += 1
result.materialsDeleted += detail.materialsDeleted
result.materialsSkipped += detail.materialsSkipped
})
const missingOrders = getMissingOrders(batchOrders, queriedOrderNumbersInBatch)
for (const missingOrder of missingOrders) {
const missingMessage = '订单未出现在查询结果中'
result.errors.push(`Order ${missingOrder}: ${missingMessage}`)
result.details.push(this.createErrorDetail(missingOrder, missingMessage))
}
}
const retryResult = await this.retryFailedOrders({
workFrame,
popupPage,
failedDetails: result.details.filter(
(d) => d.errors.length > 0 && this.isOrderNumber(d.orderNumber)
),
deleteSet,
dryRun,
onProgress: input.onProgress
})
result.retriedOrders = retryResult.retriedOrders
result.successfulRetries = retryResult.successfulRetries
retryResult.updatedDetails.forEach((updatedDetail) => {
const index = result.details.findIndex((d) => d.orderNumber === updatedDetail.orderNumber)
if (index !== -1) {
const previousDetail = result.details[index]
if (updatedDetail.retrySuccess && previousDetail.errors.length > 0) {
result.ordersProcessed += 1
result.materialsDeleted += updatedDetail.materialsDeleted
result.materialsSkipped += updatedDetail.materialsSkipped
}
result.details[index] = updatedDetail
}
})
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 ', ''))
)
log.info('Cleaner completed', {
ordersProcessed: result.ordersProcessed,
materialsDeleted: result.materialsDeleted,
materialsSkipped: result.materialsSkipped,
errorCount: result.errors.length
})
} 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
}
async navigateToCleanerPage(
session: ErpSession
): Promise<{ popupPage: Page; workFrame: FrameLocator }> {
const { page, mainFrame } = session
await mainFrame.locator('i').first().click()
const popupPromise = page.waitForEvent('popup')
await mainFrame.getByTitle('离散生产订单维护', { exact: true }).first().click()
const popupPage = await popupPromise
const forwardFrameLocator = popupPage.locator('#forwardFrame')
const fFrame = forwardFrameLocator.contentFrame()
const innerFrameLocator = fFrame.locator('#mainiframe')
await innerFrameLocator.waitFor({ state: 'visible', timeout: 30000 })
const workFrame = innerFrameLocator.contentFrame()
await workFrame.locator('#hot-key-head_list').waitFor({ state: 'visible', timeout: 30000 })
return { popupPage, workFrame }
}
private async setupQueryInterface(innerFrame: FrameLocator): Promise<void> {
await innerFrame.locator('.search-name-wrapper > .iconfont').click()
await innerFrame.getByText('订单号查询').click()
await innerFrame.getByRole('tab', { name: '全部' }).click()
const inputEl = innerFrame.locator('#rc_select_0')
await inputEl.fill('5000')
await inputEl.press('Enter')
}
private async queryOrders(workFrame: FrameLocator, orderNumbers: string[]): Promise<void> {
const textbox = workFrame.getByRole('textbox', { name: '生产订单号' })
await textbox.fill(orderNumbers.join(','))
await workFrame.locator('.search-component-searchBtn').click()
}
private async collectQueryResultRows(workFrame: FrameLocator): Promise<QueryResultRow[]> {
const rows = workFrame.locator('tbody tr')
const rowCount = await rows.count()
const result: QueryResultRow[] = []
for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
const row = rows.nth(rowIndex)
const orderNumber = await this.extractOrderNumberFromQueryRow(row)
if (!this.isOrderNumber(orderNumber)) {
continue
}
result.push({ rowIndex, orderNumber })
}
return result
}
private async extractOrderNumberFromQueryRow(row: Locator): Promise<string> {
try {
const cell = row.locator('td[colkey="vbillcode"]')
const codeLink = cell.locator('.code-detail-link').first()
const rawValue =
(await codeLink.count()) > 0 ? await codeLink.innerText() : await cell.innerText()
const value = rawValue.trim()
const match = value.match(/SC\d{14}/)
return match ? match[0] : value
} catch {
return ''
}
}
private async openDetailPageFromRow(
workFrame: FrameLocator,
popupPage: Page,
rowIndex: number
): Promise<Page> {
const row = workFrame.locator('tbody tr').nth(rowIndex)
await row.waitFor({ state: 'visible', timeout: 15000 })
const moreButton = row.locator('a.row-more').first()
await moreButton.scrollIntoViewIfNeeded()
const detailPagePromise = popupPage.waitForEvent('popup')
await moreButton.click()
await this.clickMaterialPlanMenu(workFrame)
return await detailPagePromise
}
private async openDetailPageFromCurrentQuery(
workFrame: FrameLocator,
popupPage: Page
): Promise<Page> {
const firstRow = workFrame.locator('tbody tr').first()
await firstRow.waitFor({ state: 'visible', timeout: 10000 })
const moreButton = firstRow.locator('a.row-more').first()
const detailPagePromise = popupPage.waitForEvent('popup')
await moreButton.click()
await this.clickMaterialPlanMenu(workFrame)
return await detailPagePromise
}
private async clickMaterialPlanMenu(workFrame: FrameLocator): Promise<void> {
const candidates = [
workFrame.locator('li:visible, a:visible, span:visible, div:visible').filter({
hasText: /^备料计划$/
}),
workFrame.getByRole('menuitem', { name: '备料计划' }),
workFrame.getByText('备料计划', { exact: true }),
workFrame.getByText('备料计划')
]
for (const candidate of candidates) {
const target = candidate.last()
try {
await target.waitFor({ state: 'visible', timeout: 2000 })
await target.click()
return
} catch {
// Try next locator candidate
}
}
throw new Error('无法定位“备料计划”菜单项(可能菜单结构已变化)')
}
private async processDetailPage(params: {
detailPage: Page
deleteSet: Set<string>
dryRun: boolean
progressState: ProgressState
expectedOrderNumber?: string
onProgress?: (
message: string,
progress?: number,
extra?: Partial<import('../../types/cleaner.types').CleanerProgress>
) => void
}): Promise<OrderCleanDetail> {
const { detailPage, deleteSet, dryRun, progressState, expectedOrderNumber, onProgress } = params
try {
const detailMainFrame = detailPage.locator('#forwardFrame')
const dFrame = await detailMainFrame.contentFrame()
if (!dFrame) {
throw new Error('Failed to access detail page forward frame')
}
const detailInnerLocator = dFrame.locator('#mainiframe')
await detailInnerLocator.waitFor({ state: 'visible', timeout: 30000 })
const detailInnerFrame = await detailInnerLocator.contentFrame()
if (!detailInnerFrame) {
throw new Error('Failed to access detail inner frame')
}
await detailInnerFrame
.getByText(/^离散备料计划维护:/)
.waitFor({ state: 'visible', timeout: 30000 })
const sourceOrderNumber = await this.extractSourceOrderNumber(detailInnerFrame)
const orderNumber = sourceOrderNumber || expectedOrderNumber || 'UNKNOWN_ORDER'
const detail: OrderCleanDetail = {
orderNumber,
materialsDeleted: 0,
materialsSkipped: 0,
errors: [],
skippedMaterials: [],
retryCount: 0,
retryAttempts: [],
retriedAt: undefined,
retrySuccess: false
}
const detailCountText = await detailInnerFrame.getByText(/^详细信息 \(\d+\)$/).innerText()
const detailCountMatch = detailCountText.match(/\((\d+)\)/)
const detailCount = detailCountMatch ? parseInt(detailCountMatch[1], 10) : 0
const statusText = await detailInnerFrame.getByText(/^备料状态:.+$/).innerText()
const statusMatch = statusText.replace(/\n/g, '').match(/备料状态:(.+)$/)
const detailStatus = statusMatch ? statusMatch[1].trim() : ''
onProgress?.(
`开始处理订单: ${orderNumber}`,
this.calculateProgress(
progressState.completedOrders,
0,
detailCount,
progressState.totalOrders
),
{
currentOrderIndex: progressState.completedOrders + 1,
totalOrders: progressState.totalOrders,
currentMaterialIndex: 0,
totalMaterialsInOrder: detailCount,
currentOrderNumber: orderNumber
}
)
if (detailStatus === '审批通过' && detailCount > 0) {
await detailInnerFrame.getByRole('button', { name: '修改' }).click()
const saveButtonLocator = detailInnerFrame.getByRole('button', { name: '保存' })
await saveButtonLocator.waitFor({ state: 'visible', timeout: 30000 })
await detailInnerFrame.getByText('展开').first().click()
const childForm = detailInnerFrame.locator('.card-table-side-box')
const buttonWrapper = childForm.locator('.button-wrapper')
const deleteRowBtn = buttonWrapper.getByRole('button', { name: '删行' })
const nextBtn = buttonWrapper.locator('.icon-jiantouyou')
const collapseBtn = buttonWrapper.locator('.icon-celashouqi')
let lastRowNumber = ''
let materialIdx = 0
while (true) {
materialIdx += 1
const currentRow = await this.getInputValue(childForm, /^行号$/)
const rowNumInt = parseInt(currentRow, 10)
if (currentRow === lastRowNumber) {
await this.delay(500)
}
const materialCode = await this.getInputValue(childForm, /^材料编码/)
const materialName = await this.getInputValue(childForm, /^材料名称/)
const pendingQty = await this.getInputValue(childForm, /^累计待发数量$/)
const progress = this.calculateProgress(
progressState.completedOrders,
materialIdx,
detailCount,
progressState.totalOrders
)
onProgress?.(
`订单 ${orderNumber} - 物料 ${materialIdx}/${detailCount}: ${materialName}`,
progress,
{
currentOrderIndex: progressState.completedOrders + 1,
totalOrders: progressState.totalOrders,
currentMaterialIndex: materialIdx,
totalMaterialsInOrder: detailCount,
currentOrderNumber: orderNumber
}
)
if (deleteSet.has(materialCode)) {
const shouldDelete = this.shouldDeleteMaterial({
rowNumber: rowNumInt,
pendingQty,
materialCode,
deleteSet
})
if (shouldDelete && !dryRun) {
const oldRowNumber = currentRow
await deleteRowBtn.click()
const deleteSuccess = await this.waitForRowChange(childForm, oldRowNumber, 10000)
if (deleteSuccess) {
detail.materialsDeleted += 1
}
continue
}
if (!shouldDelete) {
detail.materialsSkipped += 1
const reason = this.getSkipReason({
rowNumber: rowNumInt,
pendingQty,
materialCode,
deleteSet
})
detail.skippedMaterials.push({
materialCode,
materialName,
rowNumber: rowNumInt,
reason
})
}
}
const isNextEnabled = await this.isButtonEnabled(nextBtn)
if (isNextEnabled) {
lastRowNumber = currentRow
await nextBtn.click()
} else {
break
}
}
await collapseBtn.click()
if (!dryRun && detail.materialsDeleted > 0) {
await saveButtonLocator.click()
await saveButtonLocator.waitFor({ state: 'hidden', timeout: 60000 })
}
}
return detail
} finally {
await detailPage.close()
}
}
private calculateProgress(
completedOrders: number,
materialIdx: number,
detailCount: number,
totalOrders: number
): number {
const materialRatio = detailCount > 0 ? materialIdx / detailCount : 0
return ((1 + completedOrders + materialRatio) / (1 + totalOrders)) * 100
}
private async extractSourceOrderNumber(frame: FrameLocator): Promise<string> {
try {
const sourceOrder = await frame
.locator('.vsourcebillcode .code-detail-link')
.first()
.innerText()
const match = sourceOrder.match(/SC\d{14}/)
return match ? match[0] : sourceOrder.trim()
} catch {
return ''
}
}
private isOrderNumber(value: string): boolean {
return /^SC\d{14}$/.test(value)
}
private createErrorDetail(orderNumber: string, message: string): OrderCleanDetail {
return {
orderNumber,
materialsDeleted: 0,
materialsSkipped: 0,
errors: [message],
skippedMaterials: [],
retryCount: 0,
retryAttempts: [],
retriedAt: undefined,
retrySuccess: false
}
}
private async waitForLoading(frame: FrameLocator): Promise<void> {
const loadingLocator = frame
.locator('div')
.filter({ hasText: ERP_LOCATORS.extractor.loadingText })
.nth(1)
try {
await loadingLocator.waitFor({ state: 'visible', timeout: 3000 })
await loadingLocator.waitFor({ state: 'hidden', timeout: 60000 })
} catch {
// Loading completed quickly or never appeared
}
}
private async getInputValue(
container: FrameLocator | Locator,
labelRegex: RegExp
): Promise<string> {
try {
return await container
.locator('div')
.filter({ hasText: labelRegex })
.locator('input')
.first()
.inputValue()
} catch {
return ''
}
}
private async isButtonEnabled(button: Locator): Promise<boolean> {
try {
return await button.isEnabled()
} catch {
return false
}
}
private async waitForRowChange(
childForm: FrameLocator | Locator,
oldRowNumber: string,
maxWaitMs: number
): Promise<boolean> {
const startTime = Date.now()
while (Date.now() - startTime < maxWaitMs) {
try {
const newRowNumber = await this.getInputValue(childForm, /^行号$/)
if (newRowNumber !== oldRowNumber) {
return true
}
await this.delay(200)
} catch {
await this.delay(200)
}
}
return false
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
private async retryFailedOrders(params: {
workFrame: FrameLocator
popupPage: Page
failedDetails: OrderCleanDetail[]
deleteSet: Set<string>
dryRun: boolean
onProgress?: (
message: string,
progress?: number,
extra?: Partial<import('../../types/cleaner.types').CleanerProgress>
) => void
}): Promise<RetryResult> {
const { workFrame, popupPage, failedDetails, deleteSet, dryRun, onProgress } = params
const result: RetryResult = {
retriedOrders: 0,
successfulRetries: 0,
updatedDetails: []
}
if (failedDetails.length === 0) {
return result
}
log.info('Starting retry for failed orders', { count: failedDetails.length })
const MAX_RETRIES = 2
for (let detailIndex = 0; detailIndex < failedDetails.length; detailIndex++) {
const failedDetail = failedDetails[detailIndex]
const orderNumber = failedDetail.orderNumber
const retryAttempts: import('../../types/cleaner.types').RetryAttempt[] = []
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
log.info(`Retrying order ${orderNumber} (attempt ${attempt}/${MAX_RETRIES})`)
await this.queryOrders(workFrame, [orderNumber])
await this.waitForLoading(workFrame)
const rows = workFrame.locator('tbody tr')
const rowCount = await rows.count()
if (rowCount === 0) {
throw new Error('订单重试查询无结果')
}
const detailPage = await this.openDetailPageFromCurrentQuery(workFrame, popupPage)
const retryDetail = await this.processDetailPage({
detailPage,
deleteSet,
dryRun,
expectedOrderNumber: orderNumber,
progressState: {
completedOrders: detailIndex,
totalOrders: failedDetails.length
},
onProgress: (message, progress, extra) => {
onProgress?.(
`[重试 ${attempt}/${MAX_RETRIES}] ${message}`,
progress,
extra ? { ...extra, phase: 'processing' as const } : undefined
)
}
})
result.successfulRetries += 1
result.updatedDetails.push({
...retryDetail,
retryCount: attempt,
retriedAt: Date.now(),
retrySuccess: true,
retryAttempts
})
result.retriedOrders += 1
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
}
}
}
}
log.info('Retry process completed', {
retriedOrders: result.retriedOrders,
successfulRetries: result.successfulRetries
})
return result
}
}

View File

@@ -0,0 +1,187 @@
import { chromium } from 'playwright'
import type { ErpConfig, ErpSession } from '../../types/erp.types'
import { createLogger } from '../logger'
const log = createLogger('ErpAuthService')
// Timeout constants
const PAGE_LOAD_TIMEOUT = 10000
const LOGIN_RESULT_TIMEOUT = 15000
const FORCE_LOGIN_TIMEOUT = 5000
/**
* ERP Authentication Service
* Manages login session and browser lifecycle
*/
export class ErpAuthService {
private config: ErpConfig
private session: ErpSession | null = null
constructor(config: ErpConfig) {
this.config = config
}
/**
* Login to ERP system and establish session
*/
async login(): Promise<ErpSession> {
if (this.session?.isLoggedIn) {
return this.session
}
// Launch browser with SSL certificate errors ignored
const browser = await chromium.launch({
headless: this.config.headless ?? false, // Use config or default to false
slowMo: 100, // Slow down for debugging
args: [
'--ignore-certificate-errors',
'--ignore-ssl-errors',
'--ignore-certificate-errors-spki-list',
'--disable-web-security' // Disable web security for internal VPN
]
})
const context = await browser.newContext({
acceptDownloads: true,
viewport: { width: 1920, height: 1080 },
ignoreHTTPSErrors: true, // Ignore SSL certificate errors
// Disable web security for internal VPN
javaScriptEnabled: true
})
const page = await context.newPage()
// Navigate to login page (use actual login URL from Python code)
const loginUrl = `${this.config.url}/yonbip/resources/uap/rbac/login/main/index.html`
await page.goto(loginUrl)
// Wait for page to load
await page.waitForLoadState('domcontentloaded', { timeout: PAGE_LOAD_TIMEOUT })
// Wait for iframe to be present
await page.waitForSelector('#forwardFrame', {
state: 'attached',
timeout: LOGIN_RESULT_TIMEOUT
})
// Extract forwardFrame (Python: main_frame = page.locator("#forwardFrame").content_frame)
// This is the main working frame for all subsequent operations
const frameLocator = page.locator('#forwardFrame')
const contentFrame = await frameLocator.contentFrame()
if (!contentFrame) {
throw new Error('Failed to access forwardFrame content frame')
}
// Store reference to main frame for later use (Python returns this as main_frame)
const mainFrame = contentFrame
// Fill username using role-based locator (Python: get_by_role("textbox", name="用户名"))
try {
await contentFrame.getByRole('textbox', { name: '用户名' }).fill(this.config.username)
} catch (e) {
throw new Error(`Failed to find username input: ${e}`)
}
// Fill password using role-based locator (Python: get_by_role("textbox", name="密码"))
try {
await contentFrame.getByRole('textbox', { name: '密码' }).fill(this.config.password)
} catch (e) {
throw new Error(`Failed to find password input: ${e}`)
}
// Click login button using role-based locator (Python: get_by_role("button", name="登录"))
try {
await contentFrame.getByRole('button', { name: '登录' }).click()
} catch (e) {
throw new Error(`Failed to click login button: ${e}`)
}
await page.waitForLoadState('domcontentloaded', { timeout: PAGE_LOAD_TIMEOUT }).catch(() => {
log.warn('Page load state check timed out, continuing')
})
await this.waitForLoginResult(mainFrame as unknown as import('playwright').Frame)
// Create session with mainFrame (Python returns main_frame as part of login result)
this.session = {
browser,
context,
page,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mainFrame: mainFrame as any, // Store forwardFrame content frame for subsequent operations
isLoggedIn: true
}
return this.session
}
/**
* Wait for login result: success, failure, or force login confirmation
*/
private async waitForLoginResult(mainFrame: import('playwright').Frame): Promise<void> {
const successLocator = mainFrame.locator('.nc-workbench-icon')
const errorLocator = mainFrame.getByText('名称或密码错误')
const forceLoginButton = mainFrame.getByRole('button', { name: '确定' })
try {
await Promise.race([
successLocator.waitFor({ state: 'visible', timeout: LOGIN_RESULT_TIMEOUT }),
errorLocator.waitFor({ state: 'visible', timeout: LOGIN_RESULT_TIMEOUT }),
forceLoginButton
.waitFor({ state: 'visible', timeout: FORCE_LOGIN_TIMEOUT })
.then(async () => {
log.info('Force login dialog detected, clicking confirm')
await forceLoginButton.click()
await this.waitForLoginResult(mainFrame)
})
])
const hasError = await errorLocator.isVisible()
if (hasError) {
throw new Error('ERP 登录失败:名称或密码错误')
}
log.info('Login successful')
} catch (error) {
if (error instanceof Error && error.message.includes('名称或密码错误')) {
throw error
}
const hasError = await errorLocator.isVisible().catch(() => false)
if (hasError) {
throw new Error('ERP 登录失败:名称或密码错误')
}
log.info('Login successful')
}
}
/**
* Close browser and cleanup session
*/
async close(): Promise<void> {
if (this.session) {
await this.session.context.close()
await this.session.browser.close()
this.session = null
}
}
/**
* Get current session (must be logged in first)
*/
getSession(): ErpSession {
if (!this.session?.isLoggedIn) {
throw new Error('Not logged in. Call login() first.')
}
return this.session
}
/**
* Check if session is active
*/
isActive(): boolean {
return this.session?.isLoggedIn ?? false
}
}

View File

@@ -0,0 +1,216 @@
import path from 'path'
import { ERP_LOCATORS } from './locators'
import type { ErpSession } from '../../types/erp.types'
import type {
ExtractorCoreInput,
ExtractorCoreResult,
ExtractionProgress
} from '../../types/extractor.types'
/**
* ExtractorCore - Handles all web page operations for data extraction
* This class is responsible only for web interactions, not file processing
*
* Note: Uses 'any' for Frame types to maintain compatibility with Playwright's
* frame handling API, matching the original implementation.
*/
export class ExtractorCore {
/**
* Execute all web page operations and return downloaded file paths
* @param input - Contains session, order numbers, download directory, batch size, and progress callback
* @returns List of downloaded file paths and any errors encountered
*/
async downloadAllBatches(input: ExtractorCoreInput): Promise<ExtractorCoreResult> {
const result: ExtractorCoreResult = {
downloadedFiles: [],
errors: []
}
const totalBatches = this.createBatches(input.orderNumbers, input.batchSize).length
const totalPoints = 1 + totalBatches + 2
const progressPerPoint = 100 / totalPoints
const { popupPage, workFrame } = await this.navigateToExtractorPage(input.session)
const batches = this.createBatches(input.orderNumbers, input.batchSize)
for (let i = 0; i < batches.length; i++) {
const batch = batches[i]
const progress = (1 + (i + 1)) * progressPerPoint
const progressExtra: Partial<ExtractionProgress> = {
phase: 'downloading',
currentBatch: i + 1,
totalBatches
}
input.onProgress?.(`处理批次 ${i + 1}/${totalBatches}`, progress, progressExtra)
try {
const filePath = await this.downloadBatch(
input.session,
popupPage,
workFrame,
batch,
i,
batches.length,
input.downloadDir
)
result.downloadedFiles.push(filePath)
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
result.errors.push(`Batch ${i + 1}: ${message}`)
}
}
return result
}
/**
* Navigate to extractor/query page
* Reference: Python extract() method lines 266-278
*
* Python workflow:
* 1. main_frame.locator("i").first.click() - Click menu icon
* 2. page.expect_popup() + get_by_title("离散备料计划维护").click() - Click menu item and wait for popup
* 3. page1.locator("#forwardFrame").content_frame - Get popup's forward frame
* 4. f_frame.locator("#mainiframe").content_frame - Get nested inner frame
* 5. setup_query_interface(work_frame) - Setup query interface
*/
private async navigateToExtractorPage(
session: ErpSession
): Promise<{ popupPage: any; workFrame: any }> {
const { page, mainFrame } = session
// Step 1: Click menu icon (Python line 266)
// main_frame is #forwardFrame.content_frame returned from login
await mainFrame.locator('i').first().click()
// Step 2: Click discrete material plan menu item and expect popup (Python lines 267-271)
const popupPromise = page.waitForEvent('popup')
await mainFrame.getByTitle('离散备料计划维护', { exact: true }).first().click()
const popupPage = await popupPromise
// Step 3 & 4: Get nested frame structure in popup window (Python lines 273-276)
// popup page contains #forwardFrame, which contains #mainiframe
const forwardFrameLocator = popupPage.locator('#forwardFrame')
const fFrame = await forwardFrameLocator.contentFrame()
if (!fFrame) {
throw new Error('Failed to access popup forward frame')
}
const innerFrameLocator = fFrame.locator('#mainiframe')
await innerFrameLocator.waitFor({ state: 'visible', timeout: 15000 })
const workFrame = await innerFrameLocator.contentFrame()
if (!workFrame) {
throw new Error('Failed to access inner work frame')
}
// Step 5: Setup query interface (Python line 278)
await this.setupQueryInterface(workFrame)
return { popupPage, workFrame }
}
/**
* Setup query interface
* Reference: Python setup_query_interface() method lines 231-239
*/
private async setupQueryInterface(innerFrame: any): Promise<void> {
// Click search icon (Python line 233)
await innerFrame.locator('.search-name-wrapper > .iconfont').click()
// Click "订单号查询" menu item (Python line 234)
await innerFrame.getByText('订单号查询').click()
// Click "全部" tab (Python line 235)
await innerFrame.getByRole('tab', { name: '全部' }).click()
// Set limit to 5000 (Python lines 237-239)
const inputBox = innerFrame.locator('#rc_select_0')
await inputBox.fill('5000')
await inputBox.press('Enter')
}
/**
* Download a single batch of orders
* Reference: Python download_batch() method lines 133-175
*/
private async downloadBatch(
_session: ErpSession,
popupPage: any,
workFrame: any,
orderNumbers: string[],
batchIndex: number,
_totalBatches: number,
downloadDir: string
): Promise<string> {
// Fill order numbers (Python lines 143-145)
const textbox = workFrame.getByRole('textbox', { name: '来源生产订单号' })
await textbox.fill('')
await textbox.fill(orderNumbers.join(','))
// Click search button (Python line 147)
await workFrame.locator('.search-component-searchBtn').click()
// Wait for loading (Python lines 148-153)
await this.waitForLoading(workFrame)
// Click first row checkbox (Python line 155)
await workFrame.getByRole('row', { name: '序号' }).getByLabel('').click()
// Hover and click "更多" button (Python lines 156-157)
await workFrame.getByRole('button', { name: '更多' }).hover()
await workFrame.getByText('输出', { exact: true }).click()
// Set threshold (Python lines 159-164)
const thresholdBox = workFrame
.locator('div')
.filter({ hasText: /^行数阈值$/ })
.locator('input[type="text"]')
await thresholdBox.fill('300000')
// Setup download handler and click confirm (Python lines 166-172)
const downloadPath = path.join(downloadDir, `temp_batch_${batchIndex + 1}.xlsx`)
const downloadPromise = popupPage.waitForEvent('download')
await workFrame.getByRole('button', { name: '确定(Y)' }).click()
const download = await downloadPromise
await download.saveAs(downloadPath)
return downloadPath
}
/**
* Wait for loading overlay to disappear
* Reference: Python lines 148-153
*/
private async waitForLoading(workFrame: any): Promise<void> {
const loadingLocator = workFrame
.locator('div')
.filter({ hasText: ERP_LOCATORS.extractor.loadingText })
.nth(1)
try {
await loadingLocator.waitFor({ state: 'visible', timeout: 3000 })
await loadingLocator.waitFor({ state: 'hidden', timeout: 0 })
} catch {
// Loading completed quickly or never appeared
}
}
/**
* Split array into batches
* Reference: Python group_order_ids() method lines 128-131
*/
private createBatches<T>(items: T[], batchSize: number): T[][] {
const batches: T[][] = []
for (let i = 0; i < items.length; i += batchSize) {
batches.push(items.slice(i, i + batchSize))
}
return batches
}
}

View File

@@ -0,0 +1,357 @@
import path from 'path'
import fs from 'fs/promises'
import { ExtractorCore } from './extractor-core'
import { ErpAuthService } from './erp-auth'
import { ExcelParser } from '../excel/excel-parser'
import type {
ExtractorInput,
ExtractorResult,
ImportResult,
LogLevel
} from '../../types/extractor.types'
import { DataImportService } from '../database/data-importer'
import { createLogger } from '../logger'
const log = createLogger('ExtractorService')
/**
* ERP Data Extractor Service
* Downloads material plan data for given order numbers
*
* This service orchestrates the extraction process:
* - Uses ExtractorCore for web page operations
* - Handles file merging and cleanup
*
* Reference: playwrite/utils/discrete_material_plan_extractor.py
*/
export class ExtractorService {
private authService: ErpAuthService
private downloadDir: string
constructor(authService: ErpAuthService, downloadDir = './downloads') {
this.authService = authService
this.downloadDir = downloadDir
// Ensure download directory exists
fs.mkdir(downloadDir, { recursive: true }).catch(() => {})
}
/**
* Extract data for given order numbers
* Orchestrates the extraction process by delegating web operations to ExtractorCore
* and handling file merging/cleanup
*/
async extract(input: ExtractorInput): Promise<ExtractorResult> {
const result: ExtractorResult = {
downloadedFiles: [],
mergedFile: null,
recordCount: 0,
errors: []
}
try {
const session = this.authService.getSession()
// Call ExtractorCore to execute web page operations
const core = new ExtractorCore()
const coreResult = await core.downloadAllBatches({
session,
orderNumbers: input.orderNumbers,
downloadDir: this.downloadDir,
batchSize: input.batchSize || 100,
onProgress: input.onProgress
})
result.downloadedFiles = coreResult.downloadedFiles
result.errors = coreResult.errors
// Merge downloaded files (original logic preserved)
if (result.downloadedFiles.length > 0) {
const totalBatches = result.downloadedFiles.length
const totalPoints = 1 + totalBatches + 2
const progressPerPoint = 100 / totalPoints
const mergeProgress = (1 + totalBatches) * progressPerPoint
input.onProgress?.('正在合并文件...', mergeProgress, {
phase: 'merging',
totalBatches
})
const mergeResult = await this.mergeFiles(result.downloadedFiles)
result.mergedFile = mergeResult.mergedFile
result.recordCount = mergeResult.recordCount
// Add merge error to result if any
if (mergeResult.error) {
result.errors.push(mergeResult.error)
}
// Always clean up temporary files regardless of merge success
await this.cleanupTempFiles(result.downloadedFiles)
// Auto-import to database if merge was successful
if (result.mergedFile) {
const importProgress = (1 + totalBatches + 1) * progressPerPoint
input.onProgress?.('正在写入数据库...', importProgress, {
phase: 'importing',
totalBatches
})
const importResult = await this.importToDatabaseWithLogging(
result.mergedFile,
input.onLog
)
result.importResult = importResult
if (!importResult.success && importResult.errors.length > 0) {
result.errors.push(...importResult.errors)
}
}
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
result.errors.push(`Extraction failed: ${message}`)
}
return result
}
/**
* Merge downloaded Excel files into a single file
* Uses ExcelParser to parse and combine all material plans
*
* @param filePaths - Array of downloaded Excel file paths
* @returns Merged file path, total record count, and optional error message
*/
private async mergeFiles(
filePaths: string[]
): Promise<{ mergedFile: string | null; recordCount: number; error?: string }> {
if (filePaths.length === 0) {
return { mergedFile: null, recordCount: 0 }
}
log.info('Starting merge', { fileCount: filePaths.length })
const parser = new ExcelParser()
// Collect all orders with full order info and materials
// Each order has: { orderInfo: OrderHeader, materials: MaterialRow[] }
const allOrders: Array<{ orderInfo: any; materials: any[] }> = []
// Parse each downloaded file and collect orders
for (const filePath of filePaths) {
try {
log.debug('Parsing file', { filePath })
await parser.parse(filePath)
// After parse(), the parser store orders internally as lastOrders
const orders = (parser as any).lastOrders
log.debug('File parsed', { filePath, orderCount: orders?.length || 0 })
if (orders && Array.isArray(orders)) {
allOrders.push(...orders)
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
log.error('Failed to parse file', { filePath, error: errorMsg })
}
}
// Calculate total record count (total material rows)
let recordCount = 0
for (const order of allOrders) {
recordCount += order.materials.length
}
log.info('Merge summary', { orderCount: allOrders.length, recordCount })
if (recordCount === 0) {
log.warn('No records found in any downloaded files')
return { mergedFile: null, recordCount: 0 }
}
// Generate output filename with timestamp
const timestamp = new Date()
.toISOString()
.replace(/[-:T]/g, '')
.replace(/\..+/, '')
.slice(0, 14)
const outputPath = path.join(this.downloadDir, `merged_${timestamp}.xlsx`)
// Save with error handling
try {
log.info('Saving merged file', { outputPath })
await this.saveMergedOrders(allOrders, outputPath)
log.info('Merged file saved successfully', { recordCount })
return { mergedFile: outputPath, recordCount }
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
const errorStack = error instanceof Error ? error.stack : ''
log.error('Failed to save merged file', { error: errorMsg, stack: errorStack })
// Return parsed record count and error info even if save fails
return { mergedFile: null, recordCount, error: `保存合并文件失败:${errorMsg}` }
}
}
/**
* Save merged orders to a new Excel file with full 31 columns
* Matches the output format of ExcelParser.saveAsExcel()
*/
private async saveMergedOrders(
orders: Array<{ orderInfo: any; materials: any[] }>,
outputPath: string
): Promise<void> {
log.debug('Loading ExcelJS')
const ExcelJSModule = await import('exceljs')
// Handle both ESM and CommonJS module formats
const ExcelJS = ExcelJSModule.default || ExcelJSModule
log.debug('ExcelJS loaded, creating workbook')
const workbook = new ExcelJS.Workbook()
const worksheet = workbook.addWorksheet('Data')
// Define all 31 columns matching ExcelParser.saveAsExcel output format
worksheet.columns = [
{ header: '工厂', key: 'factory', width: 25 },
{ header: '备料状态', key: 'materialStatus', width: 15 },
{ header: '备料计划单号', key: 'planNumber', width: 25 },
{ header: '来源单号', key: 'productionOrder', width: 20 },
{ header: '备料类型', key: 'materialType', width: 15 },
{ header: '产品编码', key: 'productCode', width: 15 },
{ header: '产品名称', key: 'productName', width: 30 },
{ header: '产品计划数量', key: 'productPlannedQuantity', width: 15 },
{ header: '产品单位', key: 'productUnit', width: 10 },
{ header: '用料部门', key: 'department', width: 15 },
{ header: '备注', key: 'remark', width: 20 },
{ header: '制单人', key: 'creator', width: 15 },
{ header: '制单日期', key: 'createDate', width: 15 },
{ header: '审批人', key: 'approver', width: 15 },
{ header: '审批日期', key: 'approveDate', width: 15 },
{ header: '序号', key: 'sequence', width: 10 },
{ header: '材料编码', key: 'materialCode', width: 15 },
{ header: '材料名称', key: 'materialName', width: 30 },
{ header: '规格', key: 'specification', width: 30 },
{ header: '型号', key: 'model', width: 20 },
{ header: '图号', key: 'drawingNumber', width: 20 },
{ header: '物料材质', key: 'material', width: 15 },
{ header: '计划数量', key: 'quantity', width: 12 },
{ header: '单位', key: 'unit', width: 10 },
{ header: '需用日期', key: 'requiredDate', width: 15 },
{ header: '发料仓库', key: 'warehouse', width: 15 },
{ header: '单位用量', key: 'unitUsage', width: 12 },
{ header: '累计出库数量', key: 'cumulativeOutboundQty', width: 15 },
{ header: '打印人', key: 'printer', width: 15 },
{ header: '打印日期', key: 'printDate', width: 20 }
]
log.debug('Adding orders to worksheet', { orderCount: orders.length })
// Add data rows - merge orderInfo with each material
for (const order of orders) {
const { orderInfo, materials } = order
for (const material of materials) {
worksheet.addRow({
// Order info (first 15 columns)
factory: orderInfo.factory || '',
materialStatus: orderInfo.materialStatus || '',
planNumber: orderInfo.planNumber || '',
productionOrder: orderInfo.productionOrder || '',
materialType: orderInfo.materialType || '',
productCode: orderInfo.productCode || '',
productName: orderInfo.productName || '',
productPlannedQuantity: orderInfo.plannedQuantity || '',
productUnit: orderInfo.unit || '',
department: orderInfo.department || '',
remark: orderInfo.remark || '',
creator: orderInfo.creator || '',
createDate: orderInfo.createDate || '',
approver: orderInfo.approver || '',
approveDate: orderInfo.approveDate || '',
// Material data (columns 16-28)
sequence: material.sequence || '',
materialCode: material.materialCode || '',
materialName: material.materialName || '',
specification: material.specification || '',
model: material.model || '',
drawingNumber: material.drawingNumber || '',
material: material.material || '',
quantity: material.quantity || 0,
unit: material.unit || '',
requiredDate: material.requiredDate || '',
warehouse: material.warehouse || '',
unitUsage: material.unitUsage || 0,
cumulativeOutboundQty: material.cumulativeOutboundQty || 0,
// Footer info (last 2 columns)
printer: orderInfo.printer || '',
printDate: orderInfo.printDate || ''
})
}
}
log.debug('Writing file', { outputPath })
await workbook.xlsx.writeFile(outputPath)
log.debug('File saved successfully', { outputPath })
}
/**
* Clean up temporary batch files after merging
* @param filePaths - Array of temporary file paths to delete
*/
private async cleanupTempFiles(filePaths: string[]): Promise<void> {
for (const filePath of filePaths) {
try {
await fs.unlink(filePath)
log.debug('Deleted temporary file', { filePath })
} catch (error) {
// Log error but don't fail the main process
log.error('Failed to delete temporary file', { filePath, error })
}
}
}
/**
* Import merged Excel data to database with logging
* @param filePath - Path to the merged Excel file
* @param onLog - Optional log callback
* @returns Import result with statistics
*/
private async importToDatabaseWithLogging(
filePath: string,
onLog?: (level: LogLevel, message: string) => void
): Promise<ImportResult> {
log.info('Starting database import', { filePath })
onLog?.('info', `开始导入数据到数据库...`)
const importService = new DataImportService()
try {
const result = await importService.importFromExcel(filePath, 1000)
log.info('Import completed', {
success: result.success,
recordsRead: result.recordsRead,
recordsDeleted: result.recordsDeleted,
recordsImported: result.recordsImported
})
if (result.success) {
onLog?.(
'success',
`导入完成:读取 ${result.recordsRead} 条,删除 ${result.recordsDeleted} 条,导入 ${result.recordsImported}`
)
} else if (result.errors.length > 0) {
result.errors.forEach((err) => onLog?.('error', err))
}
return result
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
log.error('Import failed', { error: errorMsg })
onLog?.('error', `导入失败:${errorMsg}`)
return {
success: false,
recordsRead: 0,
recordsDeleted: 0,
recordsImported: 0,
uniqueSourceNumbers: 0,
errors: [errorMsg]
}
}
}
}

View File

@@ -0,0 +1,79 @@
/**
* ERP Page Element Locators
* Reference: playwrite/utils/ discrete_material_plan_extractor.py
* Reference: playwrite/utils/ discrete_material_plan_cleaner.py
*/
export const ERP_LOCATORS = {
// Login Page
login: {
usernameInput: '#username',
passwordInput: '#password',
submitButton: 'button[type="submit"]'
},
// Main Frame
// Reference: Nested iframe structure from Python code
main: {
// Main iframe on the page
mainIframe: '#mainiframe',
// Forward frame (nested inside main)
forwardFrame: '#forwardFrame',
// Inner iframe (inside forward frame)
innerIframe: '#mainiframe',
// Loading overlay text
loadingText: '加载中'
},
// Extractor (Data Export) Page
// Reference: playwrite/utils/discrete_material_plan_extractor.py
extractor: {
// Textbox by role: get_by_role("textbox", name="来源生产订单号")
orderNumberInputRole: '来源生产订单号',
// Search button: .search-component-searchBtn
queryButton: '.search-component-searchBtn',
// Loading indicator: div with text "加载中"
loadingText: '加载中',
// First row selector (序号 row)
firstRowSelector: 'internal:role=row[name=/序号/i]',
// More button
moreButton: 'internal:has-text="更多"',
// Export button (输出)
exportButton: 'internal:has-text="输出"',
// Export dialog - threshold input
thresholdInputSelector: 'div:has-text(/^行数阈值$/) input[type="text"]',
// Confirm button
confirmButton: 'internal:has-text="确定(Y)"'
},
// Menu navigation
menu: {
// Search icon in search wrapper
searchIcon: '.search-name-wrapper .iconfont',
// Order number query menu item
orderQuery: 'internal:has-text="订单号查询"',
// "All" tab
allTab: 'internal:role=tab[name="全部"]',
// Select input for setting limits
selectInput: '#rc_select_0'
},
// Discrete material plan menu item
discreteMaterialPlan: 'internal:has-title="离散备料计划维护"',
// Cleaner (Material Delete) Page
cleaner: {
orderNumberInput: 'input[name="orderNumber"]',
materialGrid: 'table.material-grid tbody tr',
saveButton: 'button:has-text("保存")'
},
// Common Elements
common: {
successMessage: '.message.success',
errorMessage: '.message.error',
confirmDialog: '.confirm-dialog',
confirmButton: 'button:has-text("确定")',
cancelButton: 'button:has-text("取消")'
}
}

View File

@@ -0,0 +1,387 @@
/**
* Order Number Resolver Service
*
* Automatically recognizes productionID and 生产订单号 (production order number),
* and converts them via database lookup.
*
* - productionID format: 2 digits + 1 letter + serial number (e.g., "22A1", "22A1234")
* - 生产订单号 format: SC + 14 digits (e.g., "SC70202602120085")
*
* Database table and field names are loaded from config.yaml
*/
import type { IDatabaseService } from '../database'
import { ConfigManager } from '../config/config-manager'
import { createLogger } from '../logger'
const log = createLogger('OrderResolver')
/**
* Order mapping result
*/
export interface OrderMapping {
/** Original input from user */
input: string
/** Recognized productionID (if input matches productionID pattern) */
productionId?: string
/** Final production order number to use */
orderNumber?: string
/** Whether the order number was successfully resolved */
resolved: boolean
/** Error message if resolution failed */
error?: string
}
/**
* Order number type recognition result
*/
export type OrderNumberType = 'productionId' | 'orderNumber' | 'unknown'
/**
* Resolution statistics
*/
export interface ResolutionStats {
totalInputs: number
validOrderNumbers: number
validProductionIds: number
resolvedCount: number
failedCount: number
unknownFormat: number
}
/**
* ProductionID pattern: 2 digits + 1 letter + 1-6 digits
* Examples: 22A1, 22A123, 26B10617
*/
const PRODUCTION_ID_PATTERN = /^\d{2}[A-Z]\d{1,6}$/i
/**
* Production order number pattern: SC + 14 digits
*/
const ORDER_NUMBER_PATTERN = /^SC\d{14}$/i
/**
* Database table and field names
* Loaded from config.yaml via ConfigManager
*/
export function getDbConfig() {
const configManager = ConfigManager.getInstance()
const config = configManager.getConfig()
return {
TABLE_NAME: config.orderResolution.tableName || 'productionContractData_26 年压力表合同数据',
FIELD_PRODUCTION_ID: config.orderResolution.productionIdField || '总排号',
FIELD_ORDER_NUMBER: config.orderResolution.orderNumberField || '生产订单号'
}
}
/**
* Order Number Resolver Service
*/
export class OrderNumberResolver {
private dbService: IDatabaseService
constructor(dbService: IDatabaseService) {
this.dbService = dbService
}
/**
* Get table name based on database type
* Converts MySQL schema_tablename format to SQL Server [schema].[tablename] format
* e.g., productionContractData_26年压力表合同数据 -> [productionContractData].[26年压力表合同数据]
* dbo_MaterialsToBeDeleted -> [dbo].[MaterialsToBeDeleted]
*/
private getTableName(tableName: string): string {
if (this.dbService.type === 'sqlserver') {
// Find the FIRST underscore to split schema and table name
// This handles patterns like: schema_tablename
const firstUnderscoreIndex = tableName.indexOf('_')
if (firstUnderscoreIndex > 0) {
const schema = tableName.substring(0, firstUnderscoreIndex)
const actualTableName = tableName.substring(firstUnderscoreIndex + 1)
return `[${schema}].[${actualTableName}]`
}
// If no underscore found, default to dbo schema
return `[dbo].[${tableName}]`
}
return tableName
}
/**
* Check if input matches productionID pattern
*/
isProductionId(input: string): boolean {
return PRODUCTION_ID_PATTERN.test(input)
}
/**
* Check if input matches order number pattern
*/
isOrderNumber(input: string): boolean {
return ORDER_NUMBER_PATTERN.test(input)
}
/**
* Map productionID to order number via database lookup
*/
async mapProductionIdToOrderNumber(productionId: string): Promise<string | null> {
try {
const dbConfig = getDbConfig()
const tableName = this.getTableName(dbConfig.TABLE_NAME)
let sql: string
let params: any[]
if (this.dbService.type === 'sqlserver') {
// 使用 COLLATE 指定不区分大小写的排序规则
sql = `SELECT TOP 1 [${dbConfig.FIELD_ORDER_NUMBER}] FROM ${tableName} WHERE [${dbConfig.FIELD_PRODUCTION_ID}] COLLATE SQL_Latin1_General_CP1_CI_AS = @p0`
params = [productionId]
} else {
// MySQL 默认不区分大小写,但显式使用 UPPER 确保一致性
sql = `SELECT \`${dbConfig.FIELD_ORDER_NUMBER}\` FROM \`${tableName}\` WHERE UPPER(\`${dbConfig.FIELD_PRODUCTION_ID}\`) = UPPER(?) LIMIT 1`
params = [productionId]
}
const result = await this.dbService.query(sql, params)
if (result.rows.length > 0) {
const orderNumber = result.rows[0][Object.keys(result.rows[0])[0]] as string
return orderNumber || null
}
return null
} catch (error) {
const message = error instanceof Error ? error.message : '未知数据库错误'
log.error('Failed to map productionID to order number', {
productionId,
error: message
})
throw error
}
}
/**
* Map multiple productionIds to order numbers
*/
async mapProductionIdsToOrderNumbers(productionIds: string[]): Promise<Map<string, string>> {
try {
const dbConfig = getDbConfig()
const tableName = this.getTableName(dbConfig.TABLE_NAME)
if (productionIds.length === 0) {
return new Map()
}
// P1: Deduplicate input productionIds to avoid redundant queries
const uniqueProductionIds = [...new Set(productionIds)]
// Use parameterized query to prevent SQL injection
const placeholders = uniqueProductionIds.map((_, i) => `@p${i}`).join(', ')
const params = uniqueProductionIds
let sql: string
if (this.dbService.type === 'sqlserver') {
// P0: Use DISTINCT to prevent duplicates from one-to-many relationships
// 使用 COLLATE 指定不区分大小写的排序规则
sql = `SELECT DISTINCT [${dbConfig.FIELD_PRODUCTION_ID}], [${dbConfig.FIELD_ORDER_NUMBER}] FROM ${tableName} WHERE [${dbConfig.FIELD_PRODUCTION_ID}] COLLATE SQL_Latin1_General_CP1_CI_AS IN (${placeholders})`
} else {
const idPlaceholders = uniqueProductionIds.map(() => 'UPPER(?)').join(', ')
// P0: Use DISTINCT to prevent duplicates from one-to-many relationships
// MySQL: 使用 UPPER 确保不区分大小写
sql = `SELECT DISTINCT \`${dbConfig.FIELD_PRODUCTION_ID}\`, \`${dbConfig.FIELD_ORDER_NUMBER}\` FROM \`${tableName}\` WHERE UPPER(\`${dbConfig.FIELD_PRODUCTION_ID}\`) IN (${idPlaceholders})`
}
const result = await this.dbService.query(sql, params)
const mappings = new Map<string, string>()
for (const row of result.rows) {
const keys = Object.keys(row)
const prodId = row[keys[0]] as string
const orderNum = row[keys[1]] as string
if (prodId && orderNum) {
mappings.set(prodId, orderNum)
}
}
return mappings
} catch (error) {
const message = error instanceof Error ? error.message : '未知数据库错误'
log.error('Failed to map productionIds to order numbers', {
error: message
})
throw error
}
}
/**
* Resolve order numbers from mixed input
*
* Optimized for batch processing with deduplication:
* - Multiple productionIDs mapping to the same order number are treated as valid (not errors)
* - Returns all mappings with duplicate tracking
*/
async resolve(inputs: string[]): Promise<OrderMapping[]> {
// P1: Deduplicate inputs at the input layer to avoid redundant queries
const uniqueInputs = [...new Set(inputs)]
// Separate productionIds and order numbers
const productionIds: string[] = []
const orderNumbers: string[] = []
for (const input of uniqueInputs) {
if (this.isOrderNumber(input)) {
orderNumbers.push(input)
} else if (this.isProductionId(input)) {
productionIds.push(input)
}
}
// Batch query productionId to order number mappings
// 使用小写 key 存储映射,以支持忽略大小写查找
const mappings = new Map<string, string>()
if (productionIds.length > 0) {
const batchMappings = await this.mapProductionIdsToOrderNumbers(productionIds)
batchMappings.forEach((orderNum, prodId) => {
mappings.set(prodId.toLowerCase(), orderNum)
})
}
// Build results while preserving original input order
// Note: Multiple productionIDs mapping to the same order number is VALID (not an error)
const results: OrderMapping[] = []
for (const input of inputs) {
// Skip if this exact input was already processed
const alreadyProcessed = results.some((r) => r.input === input)
if (alreadyProcessed) {
continue
}
const mapping: OrderMapping = { input, resolved: false }
if (this.isOrderNumber(input)) {
// Already an order number
mapping.orderNumber = input
mapping.resolved = true
} else if (this.isProductionId(input)) {
// Is a productionID, lookup from batch mappings (使用小写查找以忽略大小写)
mapping.productionId = input
const orderNumber = mappings.get(input.toLowerCase())
if (orderNumber) {
mapping.orderNumber = orderNumber
mapping.resolved = true
} else {
mapping.error = '未在数据库中找到对应的订单号'
}
} else {
mapping.error = '格式不识别:既不是有效的生产订单号也不是总排号格式'
}
results.push(mapping)
}
return results
}
/**
* Get valid order numbers from mappings
* P2: Returns deduplicated order numbers
*/
getValidOrderNumbers(mappings: OrderMapping[]): string[] {
const validNumbers = mappings
.filter((m) => m.resolved && m.orderNumber)
.map((m) => m.orderNumber!)
// P2: Deduplicate before returning
return [...new Set(validNumbers)]
}
/**
* Get warnings from failed mappings
*/
getWarnings(mappings: OrderMapping[]): string[] {
return mappings.filter((m) => !m.resolved && m.error).map((m) => `${m.input}: ${m.error}`)
}
/**
* Recognize the type of input
*/
recognizeType(input: string): OrderNumberType {
if (this.isOrderNumber(input)) return 'orderNumber'
if (this.isProductionId(input)) return 'productionId'
return 'unknown'
}
/**
* Get resolution statistics
*/
getStats(mappings: OrderMapping[]): ResolutionStats {
const stats: ResolutionStats = {
totalInputs: mappings.length,
validOrderNumbers: 0,
validProductionIds: 0,
resolvedCount: 0,
failedCount: 0,
unknownFormat: 0
}
for (const mapping of mappings) {
if (mapping.resolved) {
stats.resolvedCount++
if (mapping.orderNumber && !mapping.productionId) {
stats.validOrderNumbers++
} else if (mapping.productionId) {
stats.validProductionIds++
}
} else {
stats.failedCount++
if (!mapping.productionId && !mapping.orderNumber) {
stats.unknownFormat++
}
}
}
return stats
}
/**
* Get deduplication summary for logging
* Returns a human-readable report showing:
* - Input count
* - Unique order numbers count
* - Mapping details (which productionIDs map to which order numbers)
*/
getDeduplicationReport(mappings: OrderMapping[]): {
inputCount: number
uniqueOrderNumbersCount: number
orderNumberGroups: Map<string, string[]>
summary: string
} {
// Group productionIDs by their resolved order number
const orderNumberGroups = new Map<string, string[]>()
for (const mapping of mappings) {
if (mapping.resolved && mapping.orderNumber) {
const existing = orderNumberGroups.get(mapping.orderNumber) || []
existing.push(mapping.input)
orderNumberGroups.set(mapping.orderNumber, existing)
}
}
const inputCount = mappings.length
const uniqueOrderNumbersCount = orderNumberGroups.size
// Build summary string
let summary = `输入 ${inputCount} 个总排号 → 解析为 ${uniqueOrderNumbersCount} 个唯一订单号`
if (inputCount > uniqueOrderNumbersCount) {
const duplicateCount = inputCount - uniqueOrderNumbersCount
summary += `${duplicateCount} 个重复已合并)`
}
return {
inputCount,
uniqueOrderNumbersCount,
orderNumberGroups,
summary
}
}
}

View File

@@ -0,0 +1,559 @@
import ExcelJS from 'exceljs'
import type { DiscreteMaterialPlan, ExcelParseOptions, OrderHeader } from '../../types/excel.types'
import { createLogger } from '../logger'
const log = createLogger('ExcelParser')
/**
* Excel Parser Service
* Parses exported ERP Excel files into structured data
*
* Reference: playwrite/utils/excel_converter.py
*
* Excel Structure:
* - Multiple orders per file (each starting with "离散备料计划")
* - Each order has: header info (4 lines) + table header + data rows + footer
* - Material rows have 13 columns from "序号" to "累计出库数量"
*/
export class ExcelParser {
// Field name mapping for Python compatibility (from Python code)
private FIELD_NAME_MAPPING: Record<string, string> = {
: '产品计划数量',
: '产品单位'
}
// Mapping from Chinese field names to English property names
private CHINESE_TO_ENGLISH_MAPPING: Record<string, string> = {
// Header fields (row 2-4)
: 'factory',
: 'materialStatus',
: 'planNumber',
: 'materialType',
: 'productionDepartment',
: 'productionOrder',
: 'productionOrder', // This is the order number we need!
: 'productCode',
: 'productName',
: 'productSpecification',
: 'plannedQuantity',
: 'unit',
: 'department',
: 'remark',
: 'requiredDate',
// Footer fields (row 14-15)
: 'creator',
: 'createDate',
: 'approver',
: 'approveDate',
: 'printer',
: 'printDate',
// Mapped fields (after FIELD_NAME_MAPPING)
: 'plannedQuantity',
: 'unit'
}
/**
* Parse Excel file and extract material plans
*/
async parse(filePath: string, options: ExcelParseOptions = {}): Promise<DiscreteMaterialPlan[]> {
log.debug('Parsing Excel file:', filePath)
const workbook = new ExcelJS.Workbook()
await workbook.xlsx.readFile(filePath)
const worksheet = workbook.worksheets[0]
if (!worksheet) {
throw new Error('No worksheet found in file')
}
const plans: DiscreteMaterialPlan[] = []
const allRows: any[][] = []
// Read all rows into memory
worksheet.eachRow((row, _rowNumber) => {
allRows.push(row.values as any[])
})
log.debug(`Total rows read: ${allRows.length} (worksheet has ${worksheet.rowCount} rows)`)
// Parse orders from rows
const orders = this.parseOrders(allRows)
// Store orders for potential Excel export
;(this as any).lastOrders = orders
// Flatten orders into material plans
for (const order of orders) {
const { orderInfo, materials } = order
// Skip empty orders if option is set
if (options.skipEmptyOrders && materials.length === 0) {
log.debug('Skipping empty order:', orderInfo.productionOrder)
continue
}
// Create a material plan for each material row
for (const material of materials) {
const plan: DiscreteMaterialPlan = {
orderNumber: orderInfo.productionOrder || '',
productionId: orderInfo.productCode || '',
materialCode: material.materialCode || '',
materialName: material.materialName || '',
specification: material.specification,
model: material.model,
drawingNumber: material.drawingNumber,
material: material.material,
quantity: material.quantity || 0,
unit: material.unit || '',
requiredDate: material.requiredDate,
warehouse: material.warehouse,
unitUsage: material.unitUsage,
cumulativeOutboundQty: material.cumulativeOutboundQty,
rowNumber: material.rowNumber
}
plans.push(plan)
}
}
log.debug(`Parsed ${plans.length} material plans from ${orders.length} orders`)
return plans
}
/**
* Save parsed orders to Excel file
* Compatible with Python excel_converter.py output format
* Uses the last parsed orders data
*
* @param outputPath - Output Excel file path
*/
async saveAsExcel(outputPath: string): Promise<void> {
const orders = (this as any).lastOrders
if (!orders) {
throw new Error('No parsed data available. Call parse() first.')
}
log.debug('Saving parsed data to Excel:', outputPath)
const workbook = new ExcelJS.Workbook()
const worksheet = workbook.addWorksheet('Data')
// Define columns matching Python excel_converter output format exactly
worksheet.columns = [
{ header: '工厂', key: 'factory', width: 25 },
{ header: '备料状态', key: 'materialStatus', width: 15 },
{ header: '备料计划单号', key: 'planNumber', width: 25 },
{ header: '来源单号', key: 'productionOrder', width: 20 },
{ header: '备料类型', key: 'materialType', width: 15 },
{ header: '产品编码', key: 'productCode', width: 15 },
{ header: '产品名称', key: 'productName', width: 30 },
{ header: '产品计划数量', key: 'productPlannedQuantity', width: 15 },
{ header: '产品单位', key: 'productUnit', width: 10 },
{ header: '用料部门', key: 'department', width: 15 },
{ header: '备注', key: 'remark', width: 20 },
{ header: '制单人', key: 'creator', width: 15 },
{ header: '制单日期', key: 'createDate', width: 15 },
{ header: '审批人', key: 'approver', width: 15 },
{ header: '审批日期', key: 'approveDate', width: 15 },
{ header: '序号', key: 'sequence', width: 10 },
{ header: '材料编码', key: 'materialCode', width: 15 },
{ header: '材料名称', key: 'materialName', width: 30 },
{ header: '规格', key: 'specification', width: 30 },
{ header: '型号', key: 'model', width: 20 },
{ header: '图号', key: 'drawingNumber', width: 20 },
{ header: '物料材质', key: 'material', width: 15 },
{ header: '计划数量', key: 'quantity', width: 12 },
{ header: '单位', key: 'unit', width: 10 },
{ header: '需用日期', key: 'requiredDate', width: 15 },
{ header: '发料仓库', key: 'warehouse', width: 15 },
{ header: '单位用量', key: 'unitUsage', width: 12 },
{ header: '累计出库数量', key: 'cumulativeOutboundQty', width: 15 },
{ header: '打印人', key: 'printer', width: 15 },
{ header: '打印日期', key: 'printDate', width: 20 }
]
// Add data rows - merge orderInfo with each material
for (const order of orders) {
const { orderInfo, materials } = order
for (const material of materials) {
worksheet.addRow({
// Order info (first 14 columns)
factory: orderInfo.factory || '',
materialStatus: orderInfo.materialStatus || '',
planNumber: orderInfo.planNumber || '',
productionOrder: orderInfo.productionOrder || '',
materialType: orderInfo.materialType || '',
productCode: orderInfo.productCode || '',
productName: orderInfo.productName || '',
productPlannedQuantity: orderInfo.plannedQuantity || '',
unit: orderInfo.unit || '',
department: orderInfo.department || '',
remark: orderInfo.remark || '',
creator: orderInfo.creator || '',
createDate: orderInfo.createDate || '',
approver: orderInfo.approver || '',
approveDate: orderInfo.approveDate || '',
// Material data (columns 15-28)
sequence: material.sequence || '',
materialCode: material.materialCode || '',
materialName: material.materialName || '',
specification: material.specification || '',
model: material.model || '',
drawingNumber: material.drawingNumber || '',
material: material.material || '',
quantity: material.quantity || 0,
requiredDate: material.requiredDate || '',
warehouse: material.warehouse || '',
unitUsage: material.unitUsage || 0,
cumulativeOutboundQty: material.cumulativeOutboundQty || 0,
// Footer info (last 2 columns)
printer: orderInfo.printer || '',
printDate: orderInfo.printDate || ''
})
}
}
// Save workbook
await workbook.xlsx.writeFile(outputPath)
log.debug(
`Excel file saved: ${outputPath} (${orders.length} orders, ${worksheet.rowCount - 1} data rows)`
)
}
/**
* Parse orders from all rows
* Reference: _parse_sheet() in Python code
*/
private parseOrders(allRows: any[][]): Array<{ orderInfo: OrderHeader; materials: any[] }> {
const orders: Array<{ orderInfo: OrderHeader; materials: any[] }> = []
let i = 0
while (i < allRows.length) {
const row = allRows[i]
// Check if this is an order title row
if (row && row[2] && String(row[2]).includes('离散备料计划')) {
// Parse order header info (next 4 rows)
const orderInfo: OrderHeader = {}
for (let j = 1; j <= 4; j++) {
if (i + j < allRows.length && allRows[i + j]) {
this.parseHeaderRow(allRows[i + j], orderInfo)
}
}
// Debug: check productionOrder extraction
log.debug(`Order ${orders.length + 1}: productionOrder="${orderInfo.productionOrder}"`)
// Find table header row dynamically (look for "序号" in index 1)
// Note: worksheet.eachRow() skips empty rows, so we can't use fixed offsets
let tableRow = i + 1
while (tableRow < allRows.length && allRows[tableRow] && allRows[tableRow][1] !== '序号') {
tableRow++
}
if (tableRow >= allRows.length || !allRows[tableRow]) {
log.debug(' ⚠️ Table header not found, skipping this order')
i++
continue
}
// Check if this is the table header row
// ExcelJS is 1-indexed: index 0=null, index 1=序号, index 2=材料编码
if (tableRow < allRows.length && allRows[tableRow] && allRows[tableRow][1] === '序号') {
// Check if next row is empty (no data)
const nextRow = tableRow + 1
const isEmptyRow =
nextRow < allRows.length &&
allRows[nextRow] &&
allRows[nextRow].every((cell: any) => cell === null || String(cell).trim() === '')
if (isEmptyRow) {
// No data, find footer info
log.debug('Order has no material data')
const materials: any[] = []
const footerInfo: OrderHeader = {}
let dataRow = nextRow + 1
while (dataRow < allRows.length && allRows[dataRow]) {
if (
allRows[dataRow][2] &&
(String(allRows[dataRow][2]).includes('制单人') ||
String(allRows[dataRow][2]).includes('打印人'))
) {
this.parseHeaderRow(allRows[dataRow], footerInfo)
if (dataRow + 1 < allRows.length && allRows[dataRow + 1]) {
this.parseHeaderRow(allRows[dataRow + 1], footerInfo)
}
break
}
dataRow++
}
orders.push({
orderInfo: { ...orderInfo, ...footerInfo },
materials
})
} else {
// Has data, extract materials
log.debug('Order has material data')
const materials: any[] = []
const footerInfo: OrderHeader = {}
let dataRow = tableRow + 1
while (dataRow < allRows.length && allRows[dataRow]) {
// Check if CURRENT row is footer info (制单人/打印人)
const isCurrentRowFooter =
allRows[dataRow][2] &&
(String(allRows[dataRow][2]).includes('制单人') ||
String(allRows[dataRow][2]).includes('打印人'))
// Check if NEXT row is footer info (to handle empty row before footer)
const isNextRowFooter =
dataRow + 1 < allRows.length &&
allRows[dataRow + 1] &&
allRows[dataRow + 1][2] &&
(String(allRows[dataRow + 1][2]).includes('制单人') ||
String(allRows[dataRow + 1][2]).includes('打印人'))
if (isCurrentRowFooter) {
// Current row is footer, parse it and next row if exists
this.parseHeaderRow(allRows[dataRow], footerInfo)
if (dataRow + 1 < allRows.length && allRows[dataRow + 1]) {
this.parseHeaderRow(allRows[dataRow + 1], footerInfo)
}
log.debug(' Found footer row, stopping material parsing')
break
}
if (isNextRowFooter) {
// Next row is footer, parse current row as material first
const material = this.parseMaterialRowInternal(allRows[dataRow], dataRow + 1)
if (material) {
log.debug(' Parsed material:', material.materialCode)
materials.push(material)
}
// Then parse footer rows
this.parseHeaderRow(allRows[dataRow + 1], footerInfo)
if (dataRow + 2 < allRows.length && allRows[dataRow + 2]) {
this.parseHeaderRow(allRows[dataRow + 2], footerInfo)
}
log.debug(' Found footer in next row, stopping material parsing')
break
}
// Extract material data
const material = this.parseMaterialRowInternal(allRows[dataRow], dataRow + 1)
if (material) {
//log.debug(' Parsed material:', material.materialCode)
materials.push(material)
} else {
log.debug(' Skipped material row at', dataRow + 1)
}
dataRow++
}
orders.push({
orderInfo: { ...orderInfo, ...footerInfo },
materials
})
}
} else {
log.debug(
` ⚠️ Table header check failed at row ${tableRow + 1}, value="${allRows[tableRow] ? allRows[tableRow][1] : 'null'}"`
)
}
// Move to next row after this order
i = tableRow + 1
} else {
i++
}
}
log.debug(`parseOrders: Returning ${orders.length} orders`)
return orders
}
/**
* Parse header row (field names and values interleaved)
* Reference: _parse_header_row() in Python code
*/
private parseHeaderRow(row: any[], info: OrderHeader): void {
let j = 0
while (j < row.length) {
const cell = row[j]
if (cell && String(cell).trim() && String(cell).includes('')) {
// Found field name
let fieldName = String(cell).replace('', '').trim()
// Apply field name mapping (from Python code)
if (fieldName in this.FIELD_NAME_MAPPING) {
fieldName = this.FIELD_NAME_MAPPING[fieldName]
}
// Map Chinese field name to English property name
const englishFieldName = this.CHINESE_TO_ENGLISH_MAPPING[fieldName] || fieldName
// Skip empty cells to find first non-field-name value
let k = j + 1
while (
k < row.length &&
(!row[k] || !String(row[k]).trim() || String(row[k]).includes(''))
) {
k++
}
if (k < row.length && row[k] && !String(row[k]).includes('')) {
info[englishFieldName as keyof OrderHeader] = String(row[k]).trim()
}
// Skip processed value, continue to next field name
j = k + 1
} else {
j++
}
}
}
/**
* Parse material data row
* Reference: material data extraction in Python code
* ExcelJS row.values arrays are 1-indexed:
* - Index 0: null
* - Index 1: 序号 (sequence)
* - Index 2: 材料编码 (materialCode)
* - Index 3: 材料名称 (materialName)
* - etc.
* NOTE: This is an internal method that returns raw data structure
*/
private parseMaterialRowInternal(row: any[], rowNumber: number): any | null {
// Extract 13 fields from material row (ExcelJS is 1-indexed, so data starts at index 1)
const material = {
sequence: row[1],
materialCode: row[2],
materialName: row[3],
specification: row[4],
model: row[5],
drawingNumber: row[6],
material: row[7],
quantity: this.parseFloat(row[8]),
unit: row[9],
requiredDate: row[10],
warehouse: row[11],
unitUsage: this.parseFloat(row[12]),
cumulativeOutboundQty: this.parseFloat(row[13]),
rowNumber
}
// Skip if no material code
if (!material.materialCode) {
return null
}
return material
}
/**
* Safely parse float from cell value
*/
private parseFloat(value: any): number | undefined {
if (value === null || value === undefined) {
return undefined
}
const parsed = parseFloat(String(value))
return isNaN(parsed) ? undefined : parsed
}
/**
* Check if row contains order information
* (Spec-compliant method for detecting order rows)
*
* @param values - Row values array from ExcelJS
* @returns true if row contains "离散备料计划" (order title)
*/
public isOrderRow(values: any[]): boolean {
// ExcelJS arrays are 1-indexed, check index 2 for order title
const firstCell = values[2]
return typeof firstCell === 'string' && firstCell.includes('离散备料计划')
}
/**
* Extract order number from row
* (Spec-compliant method for extracting order number)
*
* Parses a row containing order header information and extracts
* the production order number (生产订单).
*
* @param values - Row values array from ExcelJS
* @returns Order number (e.g., "SC202501001") or empty string
*/
public extractOrderNumber(values: any[]): string {
// Parse the row to extract order number using same logic as header parsing
const orderInfo: OrderHeader = {}
this.parseHeaderRow(values, orderInfo)
return orderInfo.productionOrder || ''
}
/**
* Parse material row
* (Spec-compliant method for parsing material data)
*
* @param values - Row values array from ExcelJS (1-indexed, index 0 is null)
* @param orderNumber - Order number for this material
* @param productionId - Production ID for this material
* @param rowNumber - Row number in Excel file
* @returns DiscreteMaterialPlan or null if invalid row
*/
public parseMaterialRow(
values: any[],
orderNumber: string,
productionId: string,
rowNumber: number
): DiscreteMaterialPlan | null {
// ExcelJS arrays are 1-indexed:
// Index 0: null
// Index 1: 序号
// Index 2: 材料编码
// Index 3: 材料名称
// Index 4: 规格
// etc.
const materialCode = values[2]?.toString().trim()
const materialName = values[3]?.toString().trim()
const specification = values[4]?.toString().trim()
const model = values[5]?.toString().trim()
const drawingNumber = values[6]?.toString().trim()
const material = values[7]?.toString().trim()
const quantity = this.parseFloat(values[8]) || 0
const unit = values[9]?.toString().trim() || ''
const requiredDate = values[10]?.toString().trim()
const warehouse = values[11]?.toString().trim()
const unitUsage = this.parseFloat(values[12])
const cumulativeOutboundQty = this.parseFloat(values[13])
// Skip if no material code
if (!materialCode) {
return null
}
return {
orderNumber,
productionId,
materialCode,
materialName,
specification,
model,
drawingNumber,
material,
quantity,
unit,
requiredDate,
warehouse,
unitUsage,
cumulativeOutboundQty,
rowNumber
}
}
}

View File

@@ -0,0 +1,113 @@
import ExcelJS from 'exceljs'
import path from 'path'
import { app } from 'electron'
import fs from 'fs'
import { createLogger } from '../logger'
import type { ExportResultItem, ExportResultResponse } from '../../types/cleaner.types'
const log = createLogger('ResultExporter')
/**
* Excel exporter for validation results
* Exports filtered validation results to Excel file
*/
export class ResultExporter {
private readonly exportDir: string
private readonly fileName: string = '校验结果.xlsx'
constructor() {
// Export to app directory/exports
this.exportDir = path.join(app.getPath('userData'), 'exports')
this.ensureExportDir()
}
/**
* Ensure export directory exists
*/
private ensureExportDir(): void {
if (!fs.existsSync(this.exportDir)) {
fs.mkdirSync(this.exportDir, { recursive: true })
log.info('Created export directory', { path: this.exportDir })
}
}
/**
* Export validation results to Excel
* @param items - Validation result items to export
* @returns Export result with file path or error
*/
async exportValidationResults(items: ExportResultItem[]): Promise<ExportResultResponse> {
try {
const filePath = path.join(this.exportDir, this.fileName)
log.info('Exporting validation results', { count: items.length, path: filePath })
const workbook = new ExcelJS.Workbook()
const worksheet = workbook.addWorksheet('校验结果')
// Define columns
worksheet.columns = [
{ header: '材料名称', key: 'materialName', width: 30 },
{ header: '材料代码', key: 'materialCode', width: 20 },
{ header: '规格', key: 'specification', width: 25 },
{ header: '型号', key: 'model', width: 20 },
{ header: '负责人', key: 'managerName', width: 15 },
{ header: '勾选状态', key: 'isSelectedText', width: 12 },
{ header: '是否标记删除', key: 'isMarkedForDeletionText', width: 14 }
]
// Style header row
const headerRow = worksheet.getRow(1)
headerRow.font = { bold: true }
headerRow.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFE0E0E0' }
}
headerRow.alignment = { horizontal: 'center' }
// Add data rows
for (const item of items) {
worksheet.addRow({
materialName: item.materialName || '',
materialCode: item.materialCode || '',
specification: item.specification || '',
model: item.model || '',
managerName: item.managerName || '',
isSelectedText: item.isSelected ? '是' : '否',
isMarkedForDeletionText: item.isMarkedForDeletion ? '是' : '否'
})
}
// Style data rows
for (let i = 2; i <= worksheet.rowCount; i++) {
const row = worksheet.getRow(i)
row.alignment = { vertical: 'middle' }
// Highlight selected items
if (items[i - 2]?.isSelected) {
row.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFE6F3FF' }
}
}
}
// Save file
await workbook.xlsx.writeFile(filePath)
log.info('Export completed', { path: filePath, rows: items.length })
return {
success: true,
filePath
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
log.error('Export failed', { error: errorMessage })
return {
success: false,
error: errorMessage
}
}
}
}

View File

@@ -0,0 +1,126 @@
/**
* Audit Logger Service
* Writes audit logs in JSONL format with 30-day rotation using winston-daily-rotate-file
*/
import winston from 'winston'
import DailyRotateFile from 'winston-daily-rotate-file'
import path from 'path'
import { app } from 'electron'
import fs from 'fs'
/**
* Audit log entry structure
* All 8 required fields for comprehensive audit tracking
*/
export interface AuditEntry {
/** ISO 8601 timestamp of the audit event */
timestamp: string
/** The action that was performed (e.g., 'LOGIN', 'EXTRACT', 'DELETE') */
action: string
/** User ID who performed the action */
userId: string
/** Username of the user who performed the action */
username: string
/** Computer name from which the action was performed */
computerName: string
/** The resource that was affected (e.g., table name, file path) */
resource: string
/** Status of the action: 'success' | 'failure' | 'partial' */
status: 'success' | 'failure' | 'partial'
/** Additional metadata about the audit event */
metadata: Record<string, unknown>
}
/**
* Get the log directory for audit logs
* Uses app.getPath('logs') in production, local logs dir in development
*/
function getLogDir(): string {
if (app && app.isReady()) {
return app.getPath('logs')
}
// Fallback for development or before app is ready
const devLogDir = path.join(process.cwd(), 'logs')
if (!fs.existsSync(devLogDir)) {
fs.mkdirSync(devLogDir, { recursive: true })
}
return devLogDir
}
/**
* JSONL formatter - outputs one JSON object per line
* This is the key difference from the standard JSON formatter
*/
const jsonlFormat = winston.format.printf(({ message }) => {
// Message should already be a JSON string
return typeof message === 'string' ? message : JSON.stringify(message)
})
/**
* Create the audit logger instance with daily rotation
* Configured for 30-day retention as per requirements
*/
const auditLogger = winston.createLogger({
level: 'info',
silent: false,
transports: [
new DailyRotateFile({
filename: path.join(getLogDir(), 'audit-%DATE%.jsonl'),
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: '30d', // 30-day retention
level: 'info',
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DDTHH:mm:ss.SSSZ' }),
jsonlFormat
)
})
]
})
/**
* Log an audit event
*
* @param action - The action that was performed
* @param userId - User ID who performed the action
* @param details - Additional details including username, computerName, resource, status, and optional metadata
* @returns Promise that resolves when the log is written (non-blocking)
*/
export async function logAudit(
action: string,
userId: string,
details: {
username: string
computerName: string
resource: string
status: 'success' | 'failure' | 'partial'
metadata?: Record<string, unknown>
}
): Promise<void> {
const entry: AuditEntry = {
timestamp: new Date().toISOString(),
action,
userId,
username: details.username,
computerName: details.computerName,
resource: details.resource,
status: details.status,
metadata: details.metadata || {}
}
// Write as JSONL - one JSON object per line
// Using info level with the entry stringified as the message
auditLogger.info(JSON.stringify(entry))
}
/**
* Flush and close the audit logger (call on app shutdown)
*/
export async function closeAuditLogger(): Promise<void> {
// Winston logger.close() is synchronous
auditLogger.close()
}
export default auditLogger

View File

@@ -0,0 +1,242 @@
/**
* Error Logging Utilities
*
* Provides comprehensive error serialization and formatting for logging.
* Captures full error context including stack traces, causes, and custom properties.
*/
import type { ErrorLike, SerializedError } from '../../types/errors'
/**
* Check if value is an Error or Error-like object
*/
export function isError(value: unknown): value is Error | ErrorLike {
return (
value instanceof Error ||
(typeof value === 'object' &&
value !== null &&
'name' in value &&
'message' in value &&
typeof (value as any).message === 'string')
)
}
/**
* Serialize an error into a plain object for logging
* Captures all enumerable and non-enumerable properties
*/
export function serializeError(error: unknown): SerializedError {
if (error instanceof Error) {
const serialized: SerializedError = {
name: error.name,
message: error.message,
stack: error.stack,
cause: error.cause ? serializeError(error.cause) : undefined
}
// Capture custom properties from Error subclasses
const props = Object.getOwnPropertyNames(error)
for (const prop of props) {
if (!['name', 'message', 'stack', 'cause'].includes(prop)) {
const value = (error as any)[prop]
if (value !== undefined) {
serialized[prop] = isError(value) ? serializeError(value) : value
}
}
}
return serialized
}
if (isError(error)) {
return {
name: (error as any).name || 'UnknownError',
message: (error as any).message || String(error),
stack: (error as any).stack,
cause: (error as any).cause ? serializeError((error as any).cause) : undefined
}
}
// Non-error values
return {
name: 'UnknownError',
message: typeof error === 'string' ? error : JSON.stringify(error) || 'Unknown error occurred'
}
}
/**
* Sanitize error for production logging
* Removes sensitive information while preserving error structure
*/
export function sanitizeError(error: SerializedError): SerializedError {
const sensitiveKeys = [
'password',
'secret',
'token',
'apiKey',
'api_key',
'credentials',
'authorization',
'privateKey',
'secretKey'
]
const sanitized: SerializedError = { ...error }
// Sanitize message in production
if (process.env.NODE_ENV === 'production') {
// Keep error name and structure, but sanitize message
if (sensitiveKeys.some((key) => error.message?.toLowerCase().includes(key))) {
sanitized.message = 'An error occurred due to invalid credentials or configuration'
}
}
// Recursively sanitize cause
if (sanitized.cause && typeof sanitized.cause === 'object') {
sanitized.cause = sanitizeError(sanitized.cause)
}
// Sanitize any custom properties that might contain sensitive data
for (const key of Object.keys(sanitized)) {
if (sensitiveKeys.some((sensitive) => key.toLowerCase().includes(sensitive))) {
sanitized[key] = '[REDACTED]'
}
}
return sanitized
}
/**
* Extract context from error for logging
* Includes file, line, column from stack trace when available
*/
export function extractErrorContext(error: SerializedError): {
fileName?: string
lineNumber?: number
columnName?: number
functionName?: string
} {
if (!error.stack) {
return {}
}
const stackLines = error.stack.split('\n')
// Skip first line (error name and message), get first stack frame
const stackLine = stackLines[1] || stackLines[0]
// Parse stack frame: "at Function.module.exports (path/to/file.js:123:45)"
const match = stackLine.match(/at(?:\s+(.+?)\s+)?\((.+):(\d+):(\d+)\)/)
if (match) {
return {
functionName: match[1],
fileName: match[2],
lineNumber: parseInt(match[3], 10),
columnName: parseInt(match[4], 10)
}
}
// Alternative format: "at path/to/file.js:123:45"
const altMatch = stackLine.match(/at\s+(.+):(\d+):(\d+)/)
if (altMatch) {
return {
fileName: altMatch[1],
lineNumber: parseInt(altMatch[2], 10),
columnName: parseInt(altMatch[3], 10)
}
}
return {}
}
/**
* Format error for console/file logging
* Returns a formatted string with all error details
*/
export function formatErrorForLogging(
error: unknown,
context?: {
operation?: string
module?: string
userId?: string
[key: string]: unknown
}
): {
message: string
metadata: Record<string, unknown>
} {
const serialized = serializeError(error)
const isProd = process.env.NODE_ENV === 'production'
const errorToLog = isProd ? sanitizeError(serialized) : serialized
const errorContext = extractErrorContext(errorToLog)
const metadata: Record<string, unknown> = {
error: errorToLog,
...context
}
// Add error location context if available
if (errorContext.fileName) {
metadata.errorLocation = {
file: errorContext.fileName.split('/').pop() || errorContext.fileName,
line: errorContext.lineNumber,
column: errorContext.columnName,
function: errorContext.functionName
}
}
// Add environment info in development
if (!isProd) {
metadata.environment = {
NODE_ENV: process.env.NODE_ENV,
platform: process.platform,
nodeVersion: process.version
}
}
const message = `[${errorToLog.name}] ${errorToLog.message}`
return { message, metadata }
}
/**
* Log error with full context
* Wrapper for logger.error that ensures complete error information is captured
*/
export function logError(
logger: { error: (message: string, meta?: Record<string, unknown>) => void },
error: unknown,
options: {
message?: string
operation?: string
module?: string
userId?: string
context?: Record<string, unknown>
} = {}
): void {
const { message: customMessage, operation, module: moduleName, userId, context } = options
const { message, metadata } = formatErrorForLogging(error, {
operation,
module: moduleName,
userId,
...context
})
const finalMessage = customMessage || message
logger.error(finalMessage, metadata)
}
/**
* Re-throw error after logging, preserving original stack
*/
export function throwAfterLogging(
logger: { error: (message: string, meta?: Record<string, unknown>) => void },
error: unknown,
options: {
message?: string
operation?: string
module?: string
} = {}
): never {
logError(logger, error, options)
throw error
}

View File

@@ -0,0 +1,163 @@
/**
* Unified logging system using Winston
* Console + File transports with daily rotation
*
* Features:
* - Full error serialization with stack traces
* - Development/Production environment differentiation
* - Structured logging with context
*/
import winston from 'winston'
import DailyRotateFile from 'winston-daily-rotate-file'
import path from 'path'
import { app } from 'electron'
import fs from 'fs'
import { serializeError, sanitizeError } from './error-utils'
// Get log directory - use app.getPath('logs') in production, or local logs dir in development
function getLogDir(): string {
if (app && app.isReady()) {
return app.getPath('logs')
}
// Fallback for development or before app is ready
const devLogDir = path.join(process.cwd(), 'logs')
if (!fs.existsSync(devLogDir)) {
fs.mkdirSync(devLogDir, { recursive: true })
}
return devLogDir
}
// Check if running in production
const isProduction = app?.isPackaged ?? process.env.NODE_ENV === 'production'
// Custom format for console output - includes full error details
const consoleFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.colorize(),
winston.format.printf(({ timestamp, level, message, context, error, ...meta }) => {
const contextStr = context ? `[${context}]` : ''
// Format error with full stack trace
let errorStr = ''
if (error) {
const serialized = isProduction ? sanitizeError(serializeError(error)) : serializeError(error)
if (serialized.stack) {
errorStr = `\n${serialized.stack}`
} else {
errorStr = ` ${serialized.message}`
}
}
const metaStr = Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta, null, 2)}` : ''
return `${timestamp} [${level}]${contextStr} ${message}${errorStr}${metaStr}`
})
)
// Custom format for file output - JSON with full error details
const fileFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format((info) => {
// Serialize errors in metadata
if (info.error) {
info.error = isProduction
? sanitizeError(serializeError(info.error))
: serializeError(info.error)
}
// Serialize any error in meta fields
for (const key of Object.keys(info)) {
if (key !== 'error' && info[key] instanceof Error) {
info[key] = isProduction
? sanitizeError(serializeError(info[key]))
: serializeError(info[key])
}
}
return info
})(),
winston.format.json()
)
// Daily rotate file transport configuration
const createFileTransport = (level?: string): DailyRotateFile => {
return new DailyRotateFile({
filename: path.join(getLogDir(), 'app-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: '14d',
level,
format: fileFormat
})
}
// Create the logger instance with default level
const logger = winston.createLogger({
level: 'info', // Default level, can be updated via setLogLevel()
defaultMeta: { service: 'erpauto' },
transports: [
// Console transport - always enabled
new winston.transports.Console({
format: consoleFormat
}),
// File transport for all levels
createFileTransport()
]
})
/**
* Update the logger level dynamically
* @param level - The new log level
*/
export function setLogLevel(level: string): void {
logger.level = level
}
// Add error-specific file transport in production
if (app.isPackaged) {
logger.add(
new DailyRotateFile({
filename: path.join(getLogDir(), 'error-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: '14d',
level: 'error',
format: fileFormat
})
)
}
/**
* Create a child logger with a specific context
* @param context - The context/module name for the logger
* @returns A child logger instance
*/
export function createLogger(context: string): winston.Logger {
return logger.child({ context })
}
/**
* Log an error with full context and stack trace
* This is the recommended way to log errors in the application
*
* @param log - Logger instance
* @param message - Error message
* @param error - The error object (Error, BaseError, or any)
* @param meta - Additional metadata to include
*/
export function logError(
log: winston.Logger,
message: string,
error: unknown,
meta?: Record<string, unknown>
): void {
log.error(message, { error, ...meta })
}
// Export the main logger for direct use
export default logger
// Export log level types for convenience
export type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'verbose'

View File

@@ -0,0 +1,299 @@
import path from 'path'
import fs from 'fs'
import { app } from 'electron'
import { createLogger } from '../logger'
import type { CleanerResult, OrderCleanDetail, SkippedMaterial } from '../../types/cleaner.types'
const log = createLogger('CleanerReportGenerator')
export interface ReportOptions {
dryRun: boolean
username: string
startTime: number
endTime: number
}
interface OrderStats {
successCount: number
failureCount: number
successRate: number
}
export class CleanerReportGenerator {
private readonly reportDir: string
constructor() {
const logDir = app.isReady() ? app.getPath('logs') : path.join(process.cwd(), 'logs')
this.reportDir = path.join(logDir, 'reports')
this.ensureReportDir()
}
private ensureReportDir(): void {
if (!fs.existsSync(this.reportDir)) {
fs.mkdirSync(this.reportDir, { recursive: true })
log.info('Created report directory', { path: this.reportDir })
}
}
async generateReport(result: CleanerResult, options: ReportOptions): Promise<string> {
const filePath = this.getReportFilePath()
log.info('Generating cleaner report', { path: filePath })
const stats = this.calculateOrderStats(result)
const content = this.buildReportContent(result, options, stats)
await fs.promises.writeFile(filePath, content, 'utf-8')
log.info('Report generated successfully', { path: filePath })
return filePath
}
private getReportFilePath(): string {
const now = new Date()
const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, -5).replace('T', '-')
const fileName = `cleaner-report-${timestamp}.md`
return path.join(this.reportDir, fileName)
}
private calculateOrderStats(result: CleanerResult): OrderStats {
const totalOrders = result.details.length
const failureCount = result.details.filter((d) => d.errors.length > 0).length
const successCount = totalOrders - failureCount
const successRate = totalOrders > 0 ? (successCount / totalOrders) * 100 : 0
return {
successCount,
failureCount,
successRate
}
}
private buildReportContent(
result: CleanerResult,
options: ReportOptions,
stats: OrderStats
): string {
const lines: string[] = []
lines.push('# ERP 物料清理执行报告')
lines.push('')
lines.push('## 执行摘要')
lines.push('')
lines.push('| 项目 | 值 |')
lines.push('| -------------- | --------------------------------- |')
lines.push(`| **执行时间** | \`${this.formatDateTime(options.endTime)}\``)
lines.push(`| **执行模式** | \`${options.dryRun ? '模拟运行 (Dry Run)' : '正式执行'}\``)
lines.push(`| **操作用户** | \`${options.username}\``)
lines.push(`| **处理订单数** | \`${result.ordersProcessed}\``)
lines.push(`| **删除物料数** | \`${result.materialsDeleted}\``)
lines.push(`| **跳过物料数** | \`${result.materialsSkipped}\``)
lines.push(`| **错误数量** | \`${result.errors.length}\``)
if (result.retriedOrders > 0) {
lines.push(`| **重试订单数** | \`${result.retriedOrders}\``)
lines.push(`| **成功重试数** | \`${result.successfulRetries}\``)
}
lines.push(`| **执行耗时** | \`${this.formatDuration(options.startTime, options.endTime)}\``)
lines.push('')
lines.push('---')
lines.push('')
lines.push('## 执行状态')
lines.push('')
lines.push('| 状态 | 数量 | 百分比 |')
lines.push('| ----------- | ---- | ------ |')
lines.push(`| ✅ 成功订单 | ${stats.successCount} | ${stats.successRate.toFixed(1)}% |`)
lines.push(`| ❌ 失败订单 | ${stats.failureCount} | ${(100 - stats.successRate).toFixed(1)}% |`)
if (result.retriedOrders > 0) {
const retrySuccessRate =
result.retriedOrders > 0 ? (result.successfulRetries / result.retriedOrders) * 100 : 0
lines.push(`| 🔄 重试订单 | ${result.retriedOrders} | 100% |`)
lines.push(`| ✅ 成功重试 | ${result.successfulRetries} | ${retrySuccessRate.toFixed(1)}% |`)
}
lines.push('')
lines.push('---')
lines.push('')
lines.push('## 订单处理详情')
lines.push('')
lines.push('| # | 订单号 | 删除数 | 跳过数 | 状态 | 错误信息 |')
lines.push('| --- | -------- | ------ | ------ | ------- | ------------------------ |')
result.details.forEach((detail, index) => {
const orderNum = index + 1
let status = detail.errors.length > 0 ? '❌ 失败' : '✅ 成功'
// Override status if retry was successful
if (detail.retrySuccess) {
status = '✅ 重试成功'
} else if (detail.retryCount > 0 && !detail.retrySuccess) {
status = '❌ 重试失败'
}
const errorMsg = detail.errors.length > 0 ? detail.errors[0] : '-'
const retryInfo = detail.retryCount > 0 ? ` [重试${detail.retryCount}次]` : ''
lines.push(
`| ${orderNum} | \`${detail.orderNumber}\` | ${detail.materialsDeleted} | ${detail.materialsSkipped} | ${status}${retryInfo} | \`${errorMsg}\` |`
)
})
lines.push('')
lines.push('---')
lines.push('')
const allSkippedMaterials = this.collectAllSkippedMaterials(result.details)
if (allSkippedMaterials.length > 0) {
lines.push('## 跳过的物料原因说明')
lines.push('')
lines.push('| 订单号 | 物料代码 | 物料名称 | 行号 | 跳过原因 |')
lines.push('| -------- | -------- | -------- | ---- | --------------------------------- |')
allSkippedMaterials.forEach((skipped) => {
lines.push(
`| \`${skipped.orderNumber}\` | \`${skipped.materialCode}\` | \`${skipped.materialName}\` | ${skipped.rowNumber} | ${skipped.reason} |`
)
})
lines.push('')
lines.push('---')
lines.push('')
}
if (result.errors.length > 0) {
lines.push('## 错误详情')
lines.push('')
lines.push(`**错误总数**: \`${result.errors.length}\``)
lines.push('')
lines.push('### 错误订单列表')
lines.push('')
const errorOrders = this.extractErrorOrders(result.details)
errorOrders.forEach((order) => {
lines.push(`- \`${order}\``)
})
lines.push('')
lines.push('### 错误详细信息')
lines.push('')
result.details
.filter((d) => d.errors.length > 0)
.forEach((detail) => {
lines.push(`#### \`${detail.orderNumber}\``)
lines.push('')
lines.push('```')
lines.push(`订单号:${detail.orderNumber}`)
detail.errors.forEach((error) => {
lines.push(`错误:${error}`)
})
lines.push('```')
lines.push('')
})
lines.push('---')
lines.push('')
}
// Add retry details section
if (result.retriedOrders > 0) {
lines.push('## 重试执行详情')
lines.push('')
lines.push(
`**重试订单总数**: \`${result.retriedOrders}\` | **成功**: \`${result.successfulRetries}\` | **失败**: \`${result.retriedOrders - result.successfulRetries}\``
)
lines.push('')
const retriedDetails = result.details.filter((d) => d.retryCount > 0)
if (retriedDetails.length > 0) {
lines.push('### 重试订单列表')
lines.push('')
lines.push('| 订单号 | 重试次数 | 重试结果 | 重试时间 |')
lines.push('| -------- | -------- | -------- | ------------ |')
retriedDetails.forEach((detail) => {
const retryStatus = detail.retrySuccess ? '✅ 成功' : '❌ 失败'
const retryTime = detail.retriedAt ? this.formatDateTime(detail.retriedAt) : '-'
lines.push(
`| \`${detail.orderNumber}\` | ${detail.retryCount} | ${retryStatus} | ${retryTime} |`
)
})
lines.push('')
lines.push('### 重试尝试详细记录')
lines.push('')
retriedDetails.forEach((detail) => {
lines.push(`#### \`${detail.orderNumber}\``)
lines.push('')
lines.push(`- **重试次数**: ${detail.retryCount}`)
lines.push(`- **最终结果**: ${detail.retrySuccess ? '✅ 成功' : '❌ 失败'}`)
if (detail.retryAttempts && detail.retryAttempts.length > 0) {
lines.push('')
lines.push('**重试尝试记录**:')
lines.push('')
detail.retryAttempts.forEach((attempt, idx) => {
lines.push(
`${idx + 1}. **第${attempt.attempt}次尝试** - ${this.formatDateTime(attempt.timestamp)}`
)
lines.push(` - 错误:${attempt.error}`)
})
lines.push('')
}
lines.push('---')
lines.push('')
})
}
lines.push('')
}
lines.push(`**报告生成时间**: \`${this.formatDateTime(options.endTime)}\``)
lines.push('**报表版本**: `v1.0`')
return lines.join('\n')
}
private collectAllSkippedMaterials(
details: OrderCleanDetail[]
): Array<SkippedMaterial & { orderNumber: string }> {
const result: Array<SkippedMaterial & { orderNumber: string }> = []
details.forEach((detail) => {
if (detail.skippedMaterials && detail.skippedMaterials.length > 0) {
detail.skippedMaterials.forEach((skipped) => {
result.push({
...skipped,
orderNumber: detail.orderNumber
})
})
}
})
return result
}
private extractErrorOrders(details: OrderCleanDetail[]): string[] {
return details.filter((d) => d.errors.length > 0).map((d) => d.orderNumber)
}
private formatDateTime(timestamp: number): string {
const date = new Date(timestamp)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
private formatDuration(startTime: number, endTime: number): string {
const durationMs = endTime - startTime
const minutes = Math.floor(durationMs / 60000)
const seconds = Math.floor((durationMs % 60000) / 1000)
return `${minutes}${seconds}`
}
}

View File

@@ -0,0 +1,6 @@
/**
* RustFS Service Module
*/
export { RustfsService } from './rustfs-service'
export type { UploadResult, DownloadResult, RustfsServiceOptions } from './rustfs-service'

View File

@@ -0,0 +1,376 @@
/**
* RustFS Service
*
* S3-compatible object storage service for persisting reports and files
* Uses AWS SDK for S3 protocol compatibility
*/
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand,
ListObjectsV2Command,
type PutObjectCommandInput,
type GetObjectCommandInput,
type DeleteObjectCommandInput
} from '@aws-sdk/client-s3'
import { createLogger } from '../logger'
import type { RustfsConfig } from '../../types/config.schema'
import * as fs from 'fs'
import * as path from 'path'
const log = createLogger('RustfsService')
export interface UploadResult {
success: boolean
key: string
etag?: string
error?: string
}
export interface DownloadResult {
success: boolean
content: Buffer
error?: string
}
export interface RustfsServiceOptions {
config: RustfsConfig
}
export class RustfsService {
private client: S3Client
private config: RustfsConfig
constructor(options: RustfsServiceOptions) {
const { config } = options
this.config = config
// Configure S3 client for RustFS
// RustFS is fully compatible with S3 protocol
this.client = new S3Client({
region: config.region || 'us-east-1',
endpoint: config.endpoint,
credentials: {
accessKeyId: config.accessKey,
secretAccessKey: config.secretKey
},
forcePathStyle: true // Required for some S3-compatible services
})
log.info('RustFS service initialized', {
endpoint: config.endpoint,
bucket: config.bucket,
region: config.region
})
}
/**
* Upload a file to RustFS
* @param filePath - Local file path to upload
* @param key - Object key (path) in the bucket
* @param contentType - Optional MIME type
*/
async uploadFile(filePath: string, key: string, contentType?: string): Promise<UploadResult> {
try {
// Validate configuration
if (!this.config.enabled) {
return {
success: false,
key,
error: 'RustFS is not enabled in configuration'
}
}
// Check if file exists
if (!fs.existsSync(filePath)) {
return {
success: false,
key,
error: `File not found: ${filePath}`
}
}
// Read file content
const fileContent = await fs.promises.readFile(filePath)
// Determine content type
const mimeType = contentType || this.getMimeType(filePath) || 'application/octet-stream'
log.info('Uploading file to RustFS', {
filePath,
key,
contentType: mimeType,
size: fileContent.length
})
const input: PutObjectCommandInput = {
Bucket: this.config.bucket,
Key: key,
Body: fileContent,
ContentType: mimeType
}
const command = new PutObjectCommand(input)
const response = await this.client.send(command)
log.info('File uploaded successfully', {
key,
etag: response.ETag
})
return {
success: true,
key,
etag: response.ETag
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown upload error'
log.error('Failed to upload file to RustFS', {
filePath,
key,
error: errorMessage
})
return {
success: false,
key,
error: errorMessage
}
}
}
/**
* Upload a string content directly to RustFS
* @param content - String content to upload
* @param key - Object key (path) in the bucket
* @param contentType - Optional MIME type
*/
async uploadString(content: string, key: string, contentType?: string): Promise<UploadResult> {
try {
if (!this.config.enabled) {
return {
success: false,
key,
error: 'RustFS is not enabled in configuration'
}
}
const mimeType = contentType || 'text/plain; charset=utf-8'
log.info('Uploading string content to RustFS', {
key,
contentType: mimeType,
size: content.length
})
const input: PutObjectCommandInput = {
Bucket: this.config.bucket,
Key: key,
Body: Buffer.from(content, 'utf-8'),
ContentType: mimeType
}
const command = new PutObjectCommand(input)
const response = await this.client.send(command)
log.info('String content uploaded successfully', {
key,
etag: response.ETag
})
return {
success: true,
key,
etag: response.ETag
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown upload error'
log.error('Failed to upload string to RustFS', {
key,
error: errorMessage
})
return {
success: false,
key,
error: errorMessage
}
}
}
/**
* Download a file from RustFS
* @param key - Object key (path) in the bucket
*/
async downloadFile(key: string): Promise<DownloadResult> {
try {
if (!this.config.enabled) {
return {
success: false,
content: Buffer.alloc(0),
error: 'RustFS is not enabled in configuration'
}
}
log.info('Downloading file from RustFS', { key })
const input: GetObjectCommandInput = {
Bucket: this.config.bucket,
Key: key
}
const command = new GetObjectCommand(input)
const response = await this.client.send(command)
const chunks: Buffer[] = []
for await (const chunk of response.Body as any) {
chunks.push(Buffer.from(chunk))
}
const content = Buffer.concat(chunks)
log.info('File downloaded successfully', {
key,
size: content.length
})
return {
success: true,
content
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown download error'
log.error('Failed to download file from RustFS', {
key,
error: errorMessage
})
return {
success: false,
content: Buffer.alloc(0),
error: errorMessage
}
}
}
/**
* Delete a file from RustFS
* @param key - Object key (path) in the bucket
*/
async deleteFile(key: string): Promise<{ success: boolean; error?: string }> {
try {
if (!this.config.enabled) {
return {
success: false,
error: 'RustFS is not enabled in configuration'
}
}
log.info('Deleting file from RustFS', { key })
const input: DeleteObjectCommandInput = {
Bucket: this.config.bucket,
Key: key
}
const command = new DeleteObjectCommand(input)
await this.client.send(command)
log.info('File deleted successfully', { key })
return {
success: true
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown delete error'
log.error('Failed to delete file from RustFS', {
key,
error: errorMessage
})
return {
success: false,
error: errorMessage
}
}
}
/**
* Generate a storage key for cleaner reports
* @param reportFileName - Original report file name
* @param username - Username who generated the report
*/
generateReportKey(reportFileName: string, username: string): string {
// Organize reports by user for easy access
// Format: reports/cleaner/{username}/{filename}
return `reports/cleaner/${username}/${reportFileName}`
}
/**
* Get MIME type based on file extension
*/
private getMimeType(filePath: string): string | null {
const ext = path.extname(filePath).toLowerCase()
const mimeTypes: Record<string, string> = {
'.md': 'text/markdown; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.xls': 'application/vnd.ms-excel',
'.csv': 'text/csv; charset=utf-8',
'.pdf': 'application/pdf',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif'
}
return mimeTypes[ext] || null
}
/**
* Test connection to RustFS
*/
async testConnection(): Promise<{
success: boolean
message: string
error?: string
}> {
try {
log.info('Testing RustFS connection', {
endpoint: this.config.endpoint,
bucket: this.config.bucket
})
// Try to list objects in the bucket (head bucket operation)
const input = {
Bucket: this.config.bucket,
Prefix: '',
MaxKeys: 1
}
const command = new ListObjectsV2Command(input)
await this.client.send(command)
log.info('RustFS connection test successful')
return {
success: true,
message: '连接成功'
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown connection error'
log.error('RustFS connection test failed', {
error: errorMessage
})
return {
success: false,
message: '连接失败',
error: errorMessage
}
}
}
}

View File

@@ -0,0 +1,692 @@
/**
* BIPUsers DAO - Data access object for user authentication and management
*
* Mirrors the Python BIPUsersDAO functionality:
* - Authenticate users by username and password
* - Authenticate by computer name (silent login)
* - Get all users for admin user selection
* - Create, update, delete users
*/
import { MySqlService } from '../database/mysql'
import { SqlServerService } from '../database/sql-server'
import { ConfigManager } from '../config/config-manager'
import sql from 'mssql'
import type { UserInfo } from '../../types/user.types'
import { createLogger, logError } from '../logger'
const log = createLogger('BipUsersDao')
/**
* Database configuration for BIPUsers table
*/
export const BIP_USERS_CONFIG = {
/** Table name in SQL Server: [dbo].[BIPUsers] */
TABLE_NAME_SQLSERVER: '[dbo].[BIPUsers]',
/** Table name in MySQL: dbo_BIPUsers */
TABLE_NAME_MYSQL: 'dbo_BIPUsers',
/** Column names */
COLUMNS: {
ID: 'ID',
USERNAME: 'UserName',
USER_TYPE: 'UserType',
PASSWORD: 'Password',
COMPUTER_NAME: 'ComputerName',
CREATE_TIME: 'CreateTime',
// ERP Configuration columns
ERP_URL: 'ERP_URL',
ERP_USERNAME: 'ERP_Username',
ERP_PASSWORD: 'ERP_Password'
}
} as const
/**
* BIPUsers DAO Class
*/
export class BIPUsersDAO {
private mysqlService: MySqlService | null = null
private sqlServerService: SqlServerService | null = null
private dbType: 'mysql' | 'sqlserver' = 'mysql'
private configManager: ConfigManager
/**
* Constructor - get database type from ConfigManager
*/
constructor() {
this.configManager = ConfigManager.getInstance()
this.dbType = this.configManager.getDatabaseType()
}
/**
* Get the appropriate table name based on database type
*/
private getTableName(): string {
return this.dbType === 'sqlserver'
? BIP_USERS_CONFIG.TABLE_NAME_SQLSERVER
: BIP_USERS_CONFIG.TABLE_NAME_MYSQL
}
/**
* Get database service instance (MySQL or SQL Server)
*/
private async getDatabaseService(): Promise<MySqlService | SqlServerService> {
const config = this.configManager.getConfig()
if (this.dbType === 'sqlserver') {
if (this.sqlServerService && this.sqlServerService.isConnected()) {
return this.sqlServerService
}
const dbConfig = config.database.sqlserver
this.sqlServerService = new SqlServerService({
server: dbConfig.server,
port: dbConfig.port,
user: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database,
options: {
encrypt: false,
trustServerCertificate: dbConfig.trustServerCertificate
}
})
await this.sqlServerService.connect()
return this.sqlServerService
} else {
if (this.mysqlService && this.mysqlService.isConnected()) {
return this.mysqlService
}
const dbConfig = config.database.mysql
this.mysqlService = new MySqlService({
host: dbConfig.host,
port: dbConfig.port,
user: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database
})
await this.mysqlService.connect()
return this.mysqlService
}
}
/**
* Authenticate a user with username and password
* @param username - The username to authenticate
* @param password - The password to verify
* @returns User info if authentication successful, null otherwise
*/
async authenticate(username: string, password: string): Promise<UserInfo | null> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
if (this.dbType === 'sqlserver') {
const sqlString = `
SELECT ID, UserName, UserType
FROM ${tableName}
WHERE UserName = @username AND Password = @password
`
const result = await (dbService as SqlServerService).queryWithParams(sqlString, {
username: { value: username, type: sql.NVarChar(255) },
password: { value: password, type: sql.NVarChar(255) }
})
if (result.rows.length > 0) {
const row = result.rows[0]
return {
id: row.ID as number,
username: row.UserName as string,
userType: row.UserType as 'Admin' | 'User' | 'Guest'
}
}
return null
} else {
const sqlString = `
SELECT ID, UserName, UserType
FROM ${tableName}
WHERE UserName = ? AND Password = ?
`
const result = await (dbService as MySqlService).query(sqlString, [username, password])
if (result.rows.length > 0) {
const row = result.rows[0]
return {
id: row.ID as number,
username: row.UserName as string,
userType: row.UserType as 'Admin' | 'User' | 'Guest'
}
}
return null
}
} catch (error) {
logError(log, 'Authenticate failed', error, {
operation: 'authenticate',
username,
dbType: this.dbType
})
return null
}
}
/**
* Authenticate a user using computer name (silent login)
* @param computerName - The computer name to authenticate
* @returns User info if authentication successful, null otherwise
*/
async authenticateByComputerName(computerName: string): Promise<UserInfo | null> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
if (this.dbType === 'sqlserver') {
const sqlString = `
SELECT ID, UserName, UserType
FROM ${tableName}
WHERE ComputerName = @computerName
`
const result = await (dbService as SqlServerService).queryWithParams(sqlString, {
computerName: { value: computerName, type: sql.NVarChar(255) }
})
if (result.rows.length > 0) {
const row = result.rows[0]
return {
id: row.ID as number,
username: row.UserName as string,
userType: row.UserType as 'Admin' | 'User' | 'Guest'
}
}
return null
} else {
const sqlString = `
SELECT ID, UserName, UserType
FROM ${tableName}
WHERE ComputerName = ?
`
const result = await (dbService as MySqlService).query(sqlString, [computerName])
if (result.rows.length > 0) {
const row = result.rows[0]
return {
id: row.ID as number,
username: row.UserName as string,
userType: row.UserType as 'Admin' | 'User' | 'Guest'
}
}
return null
}
} catch (error) {
logError(log, 'Silent login failed', error, {
operation: 'authenticateByComputerName',
computerName,
dbType: this.dbType
})
return null
}
}
/**
* Get all users from the database
* @returns List of user information
*/
async getAllUsers(): Promise<UserInfo[]> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const sqlString = `
SELECT ID, UserName, UserType, CreateTime
FROM ${tableName}
ORDER BY UserName
`
const result =
this.dbType === 'sqlserver'
? await (dbService as SqlServerService).query(sqlString)
: await (dbService as MySqlService).query(sqlString)
return result.rows.map((row) => ({
id: row.ID as number,
username: row.UserName as string,
userType: row.UserType as 'Admin' | 'User' | 'Guest',
createTime: row.CreateTime as Date | undefined
}))
} catch (error) {
logError(log, 'Get all users failed', error, {
operation: 'getAllUsers',
dbType: this.dbType
})
return []
}
}
/**
* Create a new user
* @param username - The username (must be unique)
* @param password - The password
* @param userType - User type ('Admin', 'User', or 'Guest')
* @param computerName - Optional computer name for silent login
* @returns True if successful
*/
async createUser(
username: string,
password: string,
userType: string,
computerName: string = ''
): Promise<boolean> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
if (this.dbType === 'sqlserver') {
let sqlString: string
let params: Record<
string,
{
value: unknown
type?: sql.ISqlType | sql.ISqlTypeFactoryWithLength | sql.ISqlTypeWithLength
}
>
if (computerName) {
sqlString = `
INSERT INTO ${tableName}
(UserName, Password, UserType, ComputerName)
VALUES (@username, @password, @userType, @computerName)
`
params = {
username: { value: username, type: sql.NVarChar(255) },
password: { value: password, type: sql.NVarChar(255) },
userType: { value: userType, type: sql.NVarChar(255) },
computerName: { value: computerName, type: sql.NVarChar(255) }
}
} else {
sqlString = `
INSERT INTO ${tableName}
(UserName, Password, UserType)
VALUES (@username, @password, @userType)
`
params = {
username: { value: username, type: sql.NVarChar(255) },
password: { value: password, type: sql.NVarChar(255) },
userType: { value: userType, type: sql.NVarChar(255) }
}
}
await (dbService as SqlServerService).queryWithParams(sqlString, params)
return true
} else {
let sqlString: string
let params: unknown[]
if (computerName) {
sqlString = `
INSERT INTO ${tableName}
(UserName, Password, UserType, ComputerName)
VALUES (?, ?, ?, ?)
`
params = [username, password, userType, computerName]
} else {
sqlString = `
INSERT INTO ${tableName}
(UserName, Password, UserType)
VALUES (?, ?, ?)
`
params = [username, password, userType]
}
await (dbService as MySqlService).query(sqlString, params)
return true
}
} catch (error) {
logError(log, 'Create user failed', error, {
operation: 'createUser',
username,
userType,
dbType: this.dbType
})
return false
}
}
/**
* Update a user's type
* @param username - The username to update
* @param userType - New user type
* @returns True if successful
*/
async updateUserType(username: string, userType: string): Promise<boolean> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
if (this.dbType === 'sqlserver') {
const sqlString = `
UPDATE ${tableName}
SET UserType = @userType
WHERE UserName = @username
`
await (dbService as SqlServerService).queryWithParams(sqlString, {
username: { value: username, type: sql.NVarChar(255) },
userType: { value: userType, type: sql.NVarChar(255) }
})
return true
} else {
const sqlString = `
UPDATE ${tableName}
SET UserType = ?
WHERE UserName = ?
`
await (dbService as MySqlService).query(sqlString, [userType, username])
return true
}
} catch (error) {
logError(log, 'Update user type failed', error, {
operation: 'updateUserType',
username,
userType,
dbType: this.dbType
})
return false
}
}
/**
* Update a user's password
* @param username - The username to update
* @param newPassword - The new password
* @returns True if successful
*/
async updatePassword(username: string, newPassword: string): Promise<boolean> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
if (this.dbType === 'sqlserver') {
const sqlString = `
UPDATE ${tableName}
SET Password = @newPassword
WHERE UserName = @username
`
await (dbService as SqlServerService).queryWithParams(sqlString, {
username: { value: username, type: sql.NVarChar(255) },
newPassword: { value: newPassword, type: sql.NVarChar(255) }
})
return true
} else {
const sqlString = `
UPDATE ${tableName}
SET Password = ?
WHERE UserName = ?
`
await (dbService as MySqlService).query(sqlString, [newPassword, username])
return true
}
} catch (error) {
logError(log, 'Update password failed', error, {
operation: 'updatePassword',
username,
dbType: this.dbType
})
return false
}
}
/**
* Delete a user
* @param username - The username to delete
* @returns True if successful
*/
async deleteUser(username: string): Promise<boolean> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
if (this.dbType === 'sqlserver') {
const sqlString = `
DELETE FROM ${tableName}
WHERE UserName = @username
`
await (dbService as SqlServerService).queryWithParams(sqlString, {
username: { value: username, type: sql.NVarChar(255) }
})
return true
} else {
const sqlString = `
DELETE FROM ${tableName}
WHERE UserName = ?
`
await (dbService as MySqlService).query(sqlString, [username])
return true
}
} catch (error) {
logError(log, 'Delete user failed', error, {
operation: 'deleteUser',
username,
dbType: this.dbType
})
return false
}
}
/**
* Check if a username already exists
* @param username - The username to check
* @returns True if username exists
*/
async userExists(username: string): Promise<boolean> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
if (this.dbType === 'sqlserver') {
const sqlString = `
SELECT COUNT(*) as count
FROM ${tableName}
WHERE UserName = @username
`
const result = await (dbService as SqlServerService).queryWithParams(sqlString, {
username: { value: username, type: sql.NVarChar(255) }
})
return result.rows.length > 0 && (result.rows[0].count as number) > 0
} else {
const sqlString = `
SELECT COUNT(*) as count
FROM ${tableName}
WHERE UserName = ?
`
const result = await (dbService as MySqlService).query(sqlString, [username])
return result.rows.length > 0 && (result.rows[0].count as number) > 0
}
} catch (error) {
logError(log, 'Check user exists failed', error, {
operation: 'userExists',
username,
dbType: this.dbType
})
return false
}
}
/**
* Get ERP credentials for a user (username and password only, URL is from config.yaml)
* @param username - The username to get ERP credentials for
* @returns ERP credentials object or null if not found
*/
async getUserErpCredentials(username: string): Promise<{
username: string
password: string
} | null> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const cols = BIP_USERS_CONFIG.COLUMNS
if (this.dbType === 'sqlserver') {
const sqlString = `
SELECT ${cols.ERP_USERNAME}, ${cols.ERP_PASSWORD}
FROM ${tableName}
WHERE UserName = @username
`
const result = await (dbService as SqlServerService).queryWithParams(sqlString, {
username: { value: username, type: sql.NVarChar(255) }
})
if (result.rows.length > 0) {
const row = result.rows[0]
return {
username: (row[cols.ERP_USERNAME] as string) || '',
password: (row[cols.ERP_PASSWORD] as string) || ''
}
}
return null
} else {
const sqlString = `
SELECT ${cols.ERP_USERNAME}, ${cols.ERP_PASSWORD}
FROM ${tableName}
WHERE UserName = ?
`
const result = await (dbService as MySqlService).query(sqlString, [username])
if (result.rows.length > 0) {
const row = result.rows[0]
return {
username: (row[cols.ERP_USERNAME] as string) || '',
password: (row[cols.ERP_PASSWORD] as string) || ''
}
}
return null
}
} catch (error) {
logError(log, 'Get user ERP credentials failed', error, {
operation: 'getUserErpCredentials',
username,
dbType: this.dbType
})
return null
}
}
/**
* Update ERP credentials for a user (username and password only, URL is from config.yaml)
* @param username - The username to update ERP credentials for
* @param erpUsername - The ERP username
* @param erpPassword - The ERP password
* @returns True if successful
*/
async updateUserErpCredentials(
username: string,
erpUsername: string,
erpPassword: string
): Promise<boolean> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const cols = BIP_USERS_CONFIG.COLUMNS
if (this.dbType === 'sqlserver') {
const sqlString = `
UPDATE ${tableName}
SET ${cols.ERP_USERNAME} = @erpUsername,
${cols.ERP_PASSWORD} = @erpPassword
WHERE UserName = @username
`
await (dbService as SqlServerService).queryWithParams(sqlString, {
username: { value: username, type: sql.NVarChar(255) },
erpUsername: { value: erpUsername, type: sql.NVarChar(255) },
erpPassword: { value: erpPassword, type: sql.NVarChar(255) }
})
return true
} else {
const sqlString = `
UPDATE ${tableName}
SET ${cols.ERP_USERNAME} = ?,
${cols.ERP_PASSWORD} = ?
WHERE UserName = ?
`
await (dbService as MySqlService).query(sqlString, [erpUsername, erpPassword, username])
return true
}
} catch (error) {
logError(log, 'Update user ERP credentials failed', error, {
operation: 'updateUserErpCredentials',
username,
dbType: this.dbType
})
return false
}
}
/**
* Get ERP configuration for all users (for migration/audit purposes)
* @returns List of users with their ERP configurations
*/
async getAllUsersErpConfig(): Promise<
Array<{
username: string
erpUrl: string
erpUsername: string
}>
> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const cols = BIP_USERS_CONFIG.COLUMNS
const sqlString = `
SELECT ${cols.USERNAME}, ${cols.ERP_URL}, ${cols.ERP_USERNAME}
FROM ${tableName}
ORDER BY ${cols.USERNAME}
`
const result =
this.dbType === 'sqlserver'
? await (dbService as SqlServerService).query(sqlString)
: await (dbService as MySqlService).query(sqlString)
return result.rows.map((row) => ({
username: row[cols.USERNAME] as string,
erpUrl: (row[cols.ERP_URL] as string) || '',
erpUsername: (row[cols.ERP_USERNAME] as string) || ''
}))
} catch (error) {
logError(log, 'Get all users ERP config failed', error, {
operation: 'getAllUsersErpConfig',
dbType: this.dbType
})
return []
}
}
/**
* Disconnect from database
*/
async disconnect(): Promise<void> {
if (this.mysqlService) {
await this.mysqlService.disconnect()
this.mysqlService = null
}
if (this.sqlServerService) {
await this.sqlServerService.disconnect()
this.sqlServerService = null
}
}
}

View File

@@ -0,0 +1,289 @@
/**
* Migration Script: Add ERP parameters to BIPUsers table
*
* This script adds ERP_URL, ERP_Username, and ERP_Password columns
* to the dbo_BIPUsers table and initializes all existing users.
*
* Note: ERP credentials are now stored per-user in the database.
* This migration is for backward compatibility only.
*
* Usage:
* npx tsx src/main/services/user/migration/add-erp-params-migration.ts
*/
import * as fs from 'fs'
import * as path from 'path'
import { fileURLToPath } from 'url'
import { dirname } from 'path'
import { ConfigManager } from '../../config/config-manager'
import { MySqlService } from '../../database/mysql'
import { SqlServerService } from '../../database/sql-server'
import yaml from 'js-yaml'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
/**
* Migration configuration
*/
const MIGRATION_CONFIG = {
sqlFile: path.join(__dirname, 'add-erp-params-to-bipusers.sql'),
tableName: {
mysql: 'dbo_BIPUsers',
sqlserver: '[dbo].[BIPUsers]'
}
}
/**
* Check if column exists in MySQL table
*/
async function checkColumnExistsMySQL(
mysqlService: MySqlService,
tableName: string,
columnName: string
): Promise<boolean> {
const result = await mysqlService.query(
`SELECT COUNT(*) as count FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`,
[tableName, columnName]
)
return (result.rows[0]?.count as number) > 0
}
/**
* Check if column exists in SQL Server table
*/
async function checkColumnExistsSqlServer(
sqlServerService: SqlServerService,
tableName: string,
columnName: string
): Promise<boolean> {
const result = await sqlServerService.query(
`SELECT COUNT(*) as count FROM sys.columns
WHERE OBJECT_ID = OBJECT_ID(?) AND name = ?`,
[tableName, columnName]
)
return (result.rows[0]?.count as number) > 0
}
/**
* Add column to MySQL table
*/
async function addColumnMySQL(
mysqlService: MySqlService,
tableName: string,
columnName: string,
columnType: string
): Promise<void> {
await mysqlService.query(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${columnType}`)
console.log(` ✓ Added column ${columnName} (${columnType})`)
}
/**
* Add column to SQL Server table
*/
async function addColumnSqlServer(
sqlServerService: SqlServerService,
tableName: string,
columnName: string,
columnType: string
): Promise<void> {
await sqlServerService.query(`ALTER TABLE ${tableName} ADD ${columnName} ${columnType}`)
console.log(` ✓ Added column ${columnName} (${columnType})`)
}
/**
* Initialize ERP credentials for all users in MySQL
*/
async function initializeErpCredentialsMySQL(
mysqlService: MySqlService,
tableName: string,
erpUrl: string,
erpUsername: string,
erpPassword: string
): Promise<void> {
const result = await mysqlService.query(`SELECT COUNT(*) as count FROM ${tableName}`)
const userCount = result.rows[0]?.count as number
if (userCount === 0) {
console.log('No users found in BIPUsers table')
return
}
console.log(`Initializing ERP credentials for ${userCount} user(s)...`)
await mysqlService.query(
`UPDATE ${tableName} SET ERP_URL = ?, ERP_Username = ?, ERP_Password = ?`,
[erpUrl, erpUsername, erpPassword]
)
console.log('✓ ERP credentials initialized for all users')
}
/**
* Initialize ERP credentials for all users in SQL Server
*/
async function initializeErpCredentialsSqlServer(
sqlServerService: SqlServerService,
tableName: string,
erpUrl: string,
erpUsername: string,
erpPassword: string
): Promise<void> {
const result = await sqlServerService.query(`SELECT COUNT(*) as count FROM ${tableName}`)
const userCount = result.rows[0]?.count as number
if (userCount === 0) {
console.log('No users found in BIPUsers table')
return
}
console.log(`Initializing ERP credentials for ${userCount} user(s)...`)
await sqlServerService.query(
`UPDATE ${tableName} SET ERP_URL = @p0, ERP_Username = @p1, ERP_Password = @p2`,
[erpUrl, erpUsername, erpPassword]
)
console.log('✓ ERP credentials initialized for all users')
}
/**
* Run migration for MySQL
*/
async function runMySQLMigration(configManager: ConfigManager): Promise<void> {
console.log('\n📦 Running MySQL Migration...')
const config = configManager.getConfig()
const dbConfig = config.database.mysql
console.log(`Connecting to MySQL: ${dbConfig.host}:${dbConfig.port}/${dbConfig.database}`)
const mysqlService = new MySqlService({
host: dbConfig.host,
port: dbConfig.port,
user: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database
})
try {
await mysqlService.connect()
console.log('✓ Connected to MySQL')
const tableName = MIGRATION_CONFIG.tableName.mysql
// Check and add columns
for (const [columnName, columnType] of [
['ERP_URL', 'VARCHAR(500)'],
['ERP_Username', 'VARCHAR(255)'],
['ERP_Password', 'VARCHAR(255)']
] as const) {
const exists = await checkColumnExistsMySQL(mysqlService, tableName, columnName)
if (exists) {
console.log(` ✓ Column ${columnName} already exists`)
} else {
await addColumnMySQL(mysqlService, tableName, columnName, columnType)
}
}
// Note: ERP credentials are now managed per-user via settings UI
// This migration no longer initializes them from config
console.log('\n✓ MySQL Migration completed')
console.log(' Note: ERP credentials should be configured per-user via the Settings UI')
await mysqlService.disconnect()
} catch (error) {
console.error('✗ MySQL Migration failed:', error instanceof Error ? error.message : error)
if (mysqlService.isConnected()) {
await mysqlService.disconnect()
}
throw error
}
}
/**
* Run migration for SQL Server
*/
async function runSqlServerMigration(configManager: ConfigManager): Promise<void> {
console.log('\n📦 Running SQL Server Migration...')
const config = configManager.getConfig()
const dbConfig = config.database.sqlserver
console.log(`Connecting to SQL Server: ${dbConfig.server}:${dbConfig.port}/${dbConfig.database}`)
const sqlServerService = new SqlServerService({
server: dbConfig.server,
port: dbConfig.port,
user: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database,
options: {
trustServerCertificate: dbConfig.trustServerCertificate
}
})
try {
await sqlServerService.connect()
console.log('✓ Connected to SQL Server')
const tableName = MIGRATION_CONFIG.tableName.sqlserver
// Check and add columns
for (const [columnName, columnType] of [
['ERP_URL', 'NVARCHAR(500)'],
['ERP_Username', 'NVARCHAR(255)'],
['ERP_Password', 'NVARCHAR(255)']
] as const) {
const exists = await checkColumnExistsSqlServer(sqlServerService, tableName, columnName)
if (exists) {
console.log(` ✓ Column ${columnName} already exists`)
} else {
await addColumnSqlServer(sqlServerService, tableName, columnName, columnType)
}
}
// Note: ERP credentials are now managed per-user via settings UI
console.log('\n✓ SQL Server Migration completed')
console.log(' Note: ERP credentials should be configured per-user via the Settings UI')
await sqlServerService.disconnect()
} catch (error) {
console.error('✗ SQL Server Migration failed:', error instanceof Error ? error.message : error)
if (sqlServerService.isConnected()) {
await sqlServerService.disconnect()
}
throw error
}
}
/**
* Main function
*/
async function main(): Promise<void> {
console.log('╔═══════════════════════════════════════════════════════════╗')
console.log('║ Migration: Add ERP Parameters to BIPUsers Table ║')
console.log('╚═══════════════════════════════════════════════════════════╝')
try {
const configManager = ConfigManager.getInstance()
await configManager.initialize()
const dbType = configManager.getDatabaseType()
console.log(`\nCurrent database type: ${dbType}`)
if (dbType === 'mysql') {
await runMySQLMigration(configManager)
} else {
await runSqlServerMigration(configManager)
}
console.log('\n✅ Migration completed successfully!\n')
} catch (error) {
console.error('\n❌ Migration failed:', error instanceof Error ? error.message : error)
process.exit(1)
}
}
main()

View File

@@ -0,0 +1,89 @@
-- ============================================
-- BIPUsers Table Migration: Add ERP Parameters
-- Database: MySQL
-- ============================================
-- This script adds three new columns to store ERP connection parameters:
-- - ERP_URL: The ERP system URL
-- - ERP_Username: The ERP username
-- - ERP_Password: The ERP password
--
-- Usage: Run this script in your MySQL client
-- Example: mysql -u root -p BLD_DB < add-erp-params-to-bipusers-mysql.sql
-- ============================================
USE BLD_DB;
-- Add ERP_URL column if not exists
SET @dbname = DATABASE();
SET @tablename = 'dbo_BIPUsers';
SET @columnname = 'ERP_URL';
SET @preparedStatement = (SELECT IF(
(
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE
(table_name = @tablename)
AND (table_schema = @dbname)
AND (column_name = @columnname)
) > 0,
'SELECT 1',
CONCAT('ALTER TABLE ', @tablename, ' ADD COLUMN ', @columnname, ' VARCHAR(500) NULL')
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;
-- Add ERP_Username column if not exists
SET @columnname = 'ERP_Username';
SET @preparedStatement = (SELECT IF(
(
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE
(table_name = @tablename)
AND (table_schema = @dbname)
AND (column_name = @columnname)
) > 0,
'SELECT 1',
CONCAT('ALTER TABLE ', @tablename, ' ADD COLUMN ', @columnname, ' VARCHAR(255) NULL')
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;
-- Add ERP_Password column if not exists
SET @columnname = 'ERP_Password';
SET @preparedStatement = (SELECT IF(
(
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE
(table_name = @tablename)
AND (table_schema = @dbname)
AND (column_name = @columnname)
) > 0,
'SELECT 1',
CONCAT('ALTER TABLE ', @tablename, ' ADD COLUMN ', @columnname, ' VARCHAR(255) NULL')
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;
-- Verify columns were added
SELECT
COLUMN_NAME,
DATA_TYPE,
CHARACTER_MAXIMUM_LENGTH,
IS_NULLABLE
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'dbo_BIPUsers'
AND COLUMN_NAME IN ('ERP_URL', 'ERP_Username', 'ERP_Password');
-- Optional: Update all existing users with the same ERP credentials
-- Replace the values below with your actual ERP credentials
-- Example:
-- UPDATE dbo_BIPUsers
-- SET ERP_URL = 'https://68.11.34.30:8082/',
-- ERP_Username = 'your_username',
-- ERP_Password = 'your_password'
-- WHERE ERP_URL IS NULL;
SELECT 'Migration completed successfully!' AS status;

View File

@@ -0,0 +1,114 @@
/**
* Database Migration Script
* Add ERP configuration fields to dbo_BIPUsers table
*
* This script adds three new columns to store ERP connection parameters:
* - ERP_URL: The ERP system URL
* - ERP_USERNAME: The ERP username
* - ERP_PASSWORD: The ERP password (encrypted in production)
*
* IMPORTANT:
* - For SQL Server: Run this script on the SQL Server database
* - For MySQL: Run this script on the MySQL database (syntax is auto-detected)
* - All existing users will have the same ERP credentials (to be configured individually later)
*/
-- ===========================================
-- SQL Server Version
-- ===========================================
-- Uncomment and run this section for SQL Server
/*
IF NOT EXISTS (SELECT * FROM sys.columns
WHERE object_id = OBJECT_ID(N'[dbo].[BIPUsers]')
AND name = 'ERP_URL')
BEGIN
ALTER TABLE [dbo].[BIPUsers]
ADD ERP_URL NVARCHAR(500) NULL;
ALTER TABLE [dbo].[BIPUsers]
ADD ERP_Username NVARCHAR(255) NULL;
ALTER TABLE [dbo].[BIPUsers]
ADD ERP_Password NVARCHAR(255) NULL;
PRINT 'ERP columns added successfully to [dbo].[BIPUsers]';
END
ELSE
BEGIN
PRINT 'ERP columns already exist in [dbo].[BIPUsers]';
END
-- Optional: Update all existing users with the same ERP credentials
-- Replace the values below with your actual ERP credentials
-- UPDATE [dbo].[BIPUsers]
-- SET ERP_URL = 'https://your-erp-system.com',
-- ERP_Username = 'your_username',
-- ERP_Password = 'your_password'
-- WHERE ERP_URL IS NULL;
*/
-- ===========================================
-- MySQL Version
-- ===========================================
-- Run this section for MySQL
-- Add ERP_URL column if not exists
SET @dbname = DATABASE();
SET @tablename = 'dbo_BIPUsers';
SET @columnname = 'ERP_URL';
SET @preparedStatement = (SELECT IF(
(
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE
(table_name = @tablename)
AND (table_schema = @dbname)
AND (column_name = @columnname)
) > 0,
'SELECT 1',
CONCAT('ALTER TABLE ', @tablename, ' ADD COLUMN ', @columnname, ' VARCHAR(500) NULL')
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;
-- Add ERP_Username column if not exists
SET @columnname = 'ERP_Username';
SET @preparedStatement = (SELECT IF(
(
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE
(table_name = @tablename)
AND (table_schema = @dbname)
AND (column_name = @columnname)
) > 0,
'SELECT 1',
CONCAT('ALTER TABLE ', @tablename, ' ADD COLUMN ', @columnname, ' VARCHAR(255) NULL')
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;
-- Add ERP_Password column if not exists
SET @columnname = 'ERP_Password';
SET @preparedStatement = (SELECT IF(
(
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE
(table_name = @tablename)
AND (table_schema = @dbname)
AND (column_name = @columnname)
) > 0,
'SELECT 1',
CONCAT('ALTER TABLE ', @tablename, ' ADD COLUMN ', @columnname, ' VARCHAR(255) NULL')
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;
-- Optional: Update all existing users with the same ERP credentials
-- Replace the values below with your actual ERP credentials
-- UPDATE dbo_BIPUsers
-- SET ERP_URL = 'https://your-erp-system.com',
-- ERP_Username = 'your_username',
-- ERP_Password = 'your_password'
-- WHERE ERP_URL IS NULL;

View File

@@ -0,0 +1,98 @@
-- ============================================
-- BIPUsers 表迁移:添加 ERP 参数字段
-- 数据库MySQL
-- 目标数据库BLD_DB
-- ============================================
-- 使用说明:
-- 1. 在 MySQL Workbench / Navicat / DBeaver 中打开此文件
-- 2. 连接到数据库 192.168.31.83:3306/BLD_DB
-- 3. 执行全部 SQL 语句
-- ============================================
-- 切换到目标数据库
USE BLD_DB;
-- ============================================
-- 步骤 1: 添加新字段
-- ============================================
-- 添加 ERP_URL 字段(如果不存在)
-- 注意:如果 MySQL 版本不支持 ADD COLUMN IF NOT EXISTS请移除 IF NOT EXISTS
ALTER TABLE dbo_BIPUsers
ADD COLUMN ERP_URL VARCHAR(500) NULL COMMENT 'ERP 系统 URL';
-- 添加 ERP_Username 字段
ALTER TABLE dbo_BIPUsers
ADD COLUMN ERP_Username VARCHAR(255) NULL COMMENT 'ERP 用户名';
-- 添加 ERP_Password 字段
ALTER TABLE dbo_BIPUsers
ADD COLUMN ERP_Password VARCHAR(255) NULL COMMENT 'ERP 密码';
-- ============================================
-- 步骤 2: 验证字段已添加
-- ============================================
-- 显示表结构,确认新字段已添加
SELECT '字段添加验证' AS step;
DESCRIBE dbo_BIPUsers;
-- 或者使用以下查询确认新字段
SELECT
COLUMN_NAME AS '字段名',
DATA_TYPE AS '数据类型',
CHARACTER_MAXIMUM_LENGTH AS '最大长度',
IS_NULLABLE AS '允许 NULL',
COLUMN_COMMENT AS '注释'
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'BLD_DB'
AND TABLE_NAME = 'dbo_BIPUsers'
AND COLUMN_NAME IN ('ERP_URL', 'ERP_Username', 'ERP_Password')
ORDER BY COLUMN_NAME;
-- ============================================
-- 步骤 3: 初始化 ERP 配置
-- 注意:请根据实际情况修改下面的配置值!
-- ============================================
SELECT '=== 请修改下面的 ERP 配置值 ===' AS notice;
SELECT '当前数据库中的用户:' AS notice;
SELECT UserName, UserType, ComputerName FROM dbo_BIPUsers ORDER BY UserName;
-- 更新所有用户的 ERP 配置
-- ⚠️ 请修改下面的配置值为你实际的 ERP 凭证!
UPDATE dbo_BIPUsers
SET
ERP_URL = 'https://68.11.34.30:8082/', -- 修改为你的 ERP 系统 URL
ERP_Username = 'your_erp_username', -- 修改为你的 ERP 用户名
ERP_Password = 'your_erp_password' -- 修改为你的 ERP 密码
WHERE ERP_URL IS NULL OR ERP_URL = '';
-- 显示更新后的结果
SELECT
'更新后的 ERP 配置' AS notice,
UserName,
ERP_URL,
ERP_Username
FROM dbo_BIPUsers
ORDER BY UserName;
-- ============================================
-- 步骤 4: 完成确认
-- ============================================
SELECT '================================' AS '';
SELECT '迁移完成!' AS message;
SELECT '================================' AS '';
SELECT '请确认:' AS notice;
SELECT '1. 所有用户都有 ERP_URL 配置' AS check1;
SELECT '2. ERP_URL 格式正确' AS check2;
SELECT '3. ERP 用户名和密码正确' AS check3;
SELECT '================================' AS '';
-- 统计信息
SELECT
COUNT(*) AS total_users,
COUNT(ERP_URL) AS users_with_erp_url,
COUNT(ERP_Username) AS users_with_erp_username
FROM dbo_BIPUsers;

View File

@@ -0,0 +1,203 @@
/**
* Simple Migration Script: Add ERP parameters to BIPUsers table
*
* This script adds ERP_URL, ERP_Username, and ERP_Password columns
* to the dbo_BIPUsers table.
*
* Usage:
* npx tsx src/main/services/user/migration/run-migration.ts
*/
import * as mysql from 'mysql2/promise'
import * as fs from 'fs'
import * as path from 'path'
import { fileURLToPath } from 'url'
import { dirname } from 'path'
import yaml from 'js-yaml'
import { z } from 'zod'
const __filename = fileURLToPath(import.meta.url)
/**
* MySQL configuration schema
*/
const mysqlConfigSchema = z.object({
host: z.string(),
port: z.number(),
database: z.string(),
username: z.string(),
password: z.string()
})
/**
* Load config.yaml file
*/
function loadConfig(filePath: string): {
host: string
port: number
database: string
username: string
password: string
} {
if (!fs.existsSync(filePath)) {
throw new Error(`Config file not found: ${filePath}`)
}
const content = fs.readFileSync(filePath, 'utf-8')
const parsed = yaml.load(content) as Record<string, unknown>
// Safely extract database.mysql config
const database = parsed?.database as Record<string, unknown> | undefined
const mysql = database?.mysql as Record<string, unknown> | undefined
const result = mysqlConfigSchema.parse(mysql)
return result
}
/**
* Check if column exists in MySQL table
*/
async function checkColumnExists(
connection: mysql.Connection,
tableName: string,
columnName: string
): Promise<boolean> {
const sql = `
SELECT COUNT(*) as count
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND COLUMN_NAME = ?
`
const [rows] = await connection.query(sql, [tableName, columnName])
const result = rows as any[]
return result.length > 0 && result[0].count > 0
}
/**
* Add column to MySQL table
*/
async function addColumn(
connection: mysql.Connection,
tableName: string,
columnName: string,
columnType: string
): Promise<void> {
const sql = `ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${columnType} NULL`
await connection.query(sql)
console.log(` ✓ Added column ${columnName} to ${tableName}`)
}
/**
* Main migration function
*/
async function runMigration(): Promise<void> {
console.log('==============================================')
console.log('BIPUsers Table Migration: Add ERP Parameters')
console.log('==============================================\n')
// Load config.yaml from project root or user data directory
const isDev = !process.execPath.includes('Resources\\app')
const configPath = isDev
? path.resolve(process.cwd(), 'config.yaml')
: path.join(process.env.APPDATA || '', 'erpauto', 'config.yaml')
console.log(`Loading config from: ${configPath}`)
let dbConfig: { host: string; port: number; database: string; username: string; password: string }
try {
dbConfig = loadConfig(configPath)
} catch (error) {
console.error('Failed to load config.yaml:', error instanceof Error ? error.message : error)
console.error('Please ensure config.yaml exists and contains valid MySQL configuration.')
process.exit(1)
}
const dbHost = dbConfig.host || 'localhost'
const dbPort = dbConfig.port || 3306
const dbUser = dbConfig.username || 'root'
const dbPassword = dbConfig.password || ''
const dbName = dbConfig.database || ''
console.log(`Database: ${dbHost}:${dbPort}/${dbName}`)
console.log(`Username: ${dbUser}`)
console.log('')
let connection: mysql.Connection | null = null
try {
// Connect to MySQL
console.log('Connecting to MySQL...')
connection = await mysql.createConnection({
host: dbHost,
port: dbPort,
user: dbUser,
password: dbPassword,
database: dbName
})
console.log('✓ Connected to MySQL\n')
const tableName = 'dbo_BIPUsers'
// Check and add columns
console.log('Checking columns...')
for (const [columnName, columnType] of [
['ERP_URL', 'VARCHAR(500)'],
['ERP_Username', 'VARCHAR(255)'],
['ERP_Password', 'VARCHAR(255)']
] as const) {
const exists = await checkColumnExists(connection, tableName, columnName)
if (exists) {
console.log(` ✓ Column ${columnName} already exists`)
} else {
await addColumn(connection, tableName, columnName, columnType)
}
}
console.log('\n==============================================')
console.log('Migration Summary:')
console.log('==============================================')
console.log('Database Type: MySQL')
console.log('Database: ' + dbName)
console.log('Columns Added/Verified:')
console.log(' - ERP_URL (VARCHAR 500)')
console.log(' - ERP_Username (VARCHAR 255)')
console.log(' - ERP_Password (VARCHAR 255)')
console.log('==============================================')
console.log('\n✅ Migration completed successfully!\n')
console.log('Next steps:')
console.log('1. Update ERP credentials for users in dbo_BIPUsers table')
console.log('2. Example SQL:')
console.log(` UPDATE ${tableName}`)
console.log(` SET ERP_URL = 'https://your-erp.com',`)
console.log(` ERP_Username = 'your_username',`)
console.log(` ERP_Password = 'your_password'`)
console.log(` WHERE ERP_URL IS NULL;\n`)
} catch (error) {
console.error('\n❌ Migration failed with error:')
console.error(error)
console.error('\nTroubleshooting:')
console.error('1. Check if MySQL server is running')
console.error('2. Verify database credentials in config.yaml file')
console.error('3. Ensure database "' + dbName + '" exists')
console.error('4. Check network connectivity to ' + dbHost + ':' + dbPort)
process.exit(1)
} finally {
// Disconnect
if (connection) {
try {
await connection.end()
console.log('Disconnected from MySQL')
} catch {
// Ignore disconnect errors
}
}
}
}
// Run migration
runMigration().catch((error) => {
console.error('Unexpected error:', error)
process.exit(1)
})

View File

@@ -0,0 +1,185 @@
/**
* User Session Manager - Singleton pattern for managing authenticated user session
*
* Mimics the Python SessionManager functionality:
* - Singleton pattern to maintain user state throughout application lifecycle
* - Support for username/password authentication
* - Support for silent login by computer name
* - Admin user can switch to other users
*/
import type { UserInfo } from '../../types/user.types'
/**
* Session Manager Class
*/
export class SessionManager {
private static instance: SessionManager | null = null
private currentUser: UserInfo | null = null
private originalAdminUser: UserInfo | null = null
private initialized: boolean = false
private constructor() {
if (this.initialized) {
return
}
this.initialized = true
}
/**
* Get the singleton instance
*/
public static getInstance(): SessionManager {
if (SessionManager.instance === null) {
SessionManager.instance = new SessionManager()
}
return SessionManager.instance
}
/**
* Authenticate and login a user
* @param username - The username to authenticate
* @param password - The password to verify
* @returns True if login successful, false otherwise
*/
public async login(username: string, password: string): Promise<boolean> {
try {
const { BIPUsersDAO } = await import('./bip-users-dao')
const dao = new BIPUsersDAO()
const userInfo = await dao.authenticate(username, password)
if (userInfo) {
this.currentUser = {
id: userInfo.id,
username: userInfo.username,
userType: userInfo.userType
}
return true
}
return false
} catch (error) {
console.error('[SessionManager] Login error:', error)
return false
}
}
/**
* Attempt silent login using computer name
* @returns True if login successful, false otherwise
*/
public async loginByComputerName(): Promise<boolean> {
try {
const { BIPUsersDAO } = await import('./bip-users-dao')
const dao = new BIPUsersDAO()
const computerName = require('os').hostname()
const userInfo = await dao.authenticateByComputerName(computerName)
if (userInfo) {
this.currentUser = {
id: userInfo.id,
username: userInfo.username,
userType: userInfo.userType
}
return true
}
return false
} catch (error) {
console.error('[SessionManager] Silent login error:', error)
return false
}
}
/**
* Logout the current user and clear session
*/
public logout(): void {
this.currentUser = null
this.originalAdminUser = null
}
/**
* Check if a user is currently authenticated
*/
public isAuthenticated(): boolean {
return this.currentUser !== null
}
/**
* Check if the current user is an admin
*/
public isAdmin(): boolean {
return this.currentUser?.userType === 'Admin'
}
/**
* Check if the current user is a guest
*/
public isGuest(): boolean {
return this.currentUser?.userType === 'Guest'
}
/**
* Get the current username
*/
public getUsername(): string | null {
return this.currentUser?.username ?? null
}
/**
* Get the current user type
*/
public getUserType(): string | null {
return this.currentUser?.userType ?? null
}
/**
* Get all current user information
*/
public getUserInfo(): UserInfo | null {
return this.currentUser
}
/**
* Switch to a different user (Admin only feature)
* @param userInfo - User info to switch to
* @returns True if switch successful
*/
public switchUser(userInfo: UserInfo): boolean {
if (!this.currentUser) {
return false
}
// Store original admin user for reference
if (!this.originalAdminUser) {
this.originalAdminUser = { ...this.currentUser }
}
this.currentUser = {
id: userInfo.id,
username: userInfo.username,
userType: userInfo.userType
}
return true
}
/**
* Get the original Admin user before any user switch
*/
public getOriginalAdmin(): UserInfo | null {
return this.originalAdminUser
}
/**
* Get all users from database (for Admin user selection)
*/
public async getAllUsers(): Promise<UserInfo[]> {
try {
const { BIPUsersDAO } = await import('./bip-users-dao')
const dao = new BIPUsersDAO()
return await dao.getAllUsers()
} catch (error) {
console.error('[SessionManager] Get all users error:', error)
return []
}
}
}

View File

@@ -0,0 +1,197 @@
/**
* User ERP Configuration Service
*
* Manages ERP credentials (username, password) stored in the BIPUsers table.
* Each user can have their own ERP credentials.
* ERP URL is fixed and stored in config.yaml.
*
* Features:
* - Get current user's ERP credentials
* - Update current user's ERP credentials
* - Get ERP credentials for any user (admin only)
*/
import { BIPUsersDAO } from './bip-users-dao'
import { SessionManager } from './session-manager'
import { createLogger } from '../logger'
const log = createLogger('UserErpConfigService')
/**
* ERP Credentials object (username and password only)
*/
export interface ErpCredentials {
username: string
password: string
}
/**
* User ERP Configuration Service Class
*/
export class UserErpConfigService {
private static instance: UserErpConfigService | null = null
private dao: BIPUsersDAO
private constructor() {
this.dao = new BIPUsersDAO()
}
/**
* Get the singleton instance
*/
public static getInstance(): UserErpConfigService {
if (UserErpConfigService.instance === null) {
UserErpConfigService.instance = new UserErpConfigService()
}
return UserErpConfigService.instance
}
/**
* Get ERP configuration for the current authenticated user
* @returns ERP credentials or null if not found
*/
async getCurrentUserErpConfig(): Promise<ErpCredentials | null> {
try {
const sessionManager = SessionManager.getInstance()
const currentUser = sessionManager.getUserInfo()
if (!currentUser) {
log.warn('No authenticated user found')
return null
}
log.info('Fetching ERP credentials for user', { username: currentUser.username })
const config = await this.dao.getUserErpCredentials(currentUser.username)
if (!config) {
log.warn('No ERP credentials found for user', { username: currentUser.username })
return null
}
log.info('ERP credentials retrieved successfully', {
username: currentUser.username,
hasUsername: !!config.username,
hasPassword: !!config.password
})
return config
} catch (error) {
log.error('Error getting current user ERP credentials', { error })
return null
}
}
/**
* Get ERP credentials for a specific user (admin only)
* @param username - The username to get ERP credentials for
* @returns ERP credentials or null if not found
*/
async getUserErpConfig(username: string): Promise<ErpCredentials | null> {
try {
log.info('Fetching ERP credentials for user', { username })
const config = await this.dao.getUserErpCredentials(username)
if (!config) {
log.warn('No ERP credentials found for user', { username })
return null
}
return config
} catch (error) {
log.error('Error getting user ERP credentials', { error })
return null
}
}
/**
* Update ERP credentials for the current authenticated user
* @param credentials - ERP credentials to save
* @returns True if successful
*/
async updateCurrentUserErpConfig(credentials: ErpCredentials): Promise<boolean> {
try {
const sessionManager = SessionManager.getInstance()
const currentUser = sessionManager.getUserInfo()
if (!currentUser) {
log.warn('No authenticated user found')
return false
}
log.info('Updating ERP credentials for user', { username: currentUser.username })
const success = await this.dao.updateUserErpCredentials(
currentUser.username,
credentials.username,
credentials.password
)
if (success) {
log.info('ERP credentials updated successfully', { username: currentUser.username })
} else {
log.error('Failed to update ERP credentials', { username: currentUser.username })
}
return success
} catch (error) {
log.error('Error updating current user ERP credentials', { error })
return false
}
}
/**
* Update ERP credentials for a specific user (admin only)
* @param username - The username to update ERP credentials for
* @param credentials - ERP credentials to save
* @returns True if successful
*/
async updateUserErpConfig(username: string, credentials: ErpCredentials): Promise<boolean> {
try {
log.info('Updating ERP credentials for user', { username })
const success = await this.dao.updateUserErpCredentials(
username,
credentials.username,
credentials.password
)
if (success) {
log.info('ERP credentials updated successfully', { username })
} else {
log.error('Failed to update ERP credentials', { username })
}
return success
} catch (error) {
log.error('Error updating user ERP credentials', { error })
return false
}
}
/**
* Get ERP configuration for all users (admin only, for migration/audit)
* @returns List of users with their ERP configurations
*/
async getAllUsersErpConfig(): Promise<
Array<{
username: string
erpUrl: string
erpUsername: string
}>
> {
try {
log.info('Fetching ERP config for all users')
const configs = await this.dao.getAllUsersErpConfig()
log.info('Retrieved ERP configs for all users', { count: configs.length })
return configs
} catch (error) {
log.error('Error getting all users ERP config', { error })
return []
}
}
/**
* Disconnect from database
*/
async disconnect(): Promise<void> {
await this.dao.disconnect()
}
}

View File

@@ -0,0 +1,114 @@
/**
* Configuration Path Debug Tool
*
* Run this to see where config files will be stored in different modes
* Usage: npx tsx src/main/tools/config-path-debug.ts
*/
import * as path from 'path'
// Simulate different environments
const scenarios = [
{
name: 'Development Mode (开发环境)',
env: {
NODE_ENV: 'development',
APP_PACKAGED: 'false'
},
appData: 'C:\\Users\\test\\AppData\\Roaming\\erpauto',
projectRoot: 'D:\\Projects\\ERPAuto'
},
{
name: 'Production - Portable (便携版)',
env: {
NODE_ENV: 'production',
APP_PACKAGED: 'true'
},
appData: 'C:\\Users\\test\\AppData\\Roaming\\erpauto',
projectRoot: 'D:\\Projects\\ERPAuto',
exeDir: 'D:\\PortableApps\\ERPAuto'
},
{
name: 'Production - Installed (安装版)',
env: {
NODE_ENV: 'production',
APP_PACKAGED: 'true'
},
appData: 'C:\\Users\\test\\AppData\\Roaming\\erpauto',
projectRoot: 'D:\\Projects\\ERPAuto'
}
]
console.log('╔════════════════════════════════════════════════════════════════╗')
console.log('║ ERPAuto Configuration Path Debug Tool ║')
console.log('╚════════════════════════════════════════════════════════════════╝\n')
for (const scenario of scenarios) {
console.log(`📋 ${scenario.name}`)
console.log('─'.repeat(60))
const isDev = scenario.env.NODE_ENV === 'development' || scenario.env.APP_PACKAGED === 'false'
let configPath: string
let backupPath: string
if (isDev) {
// 开发环境:项目根目录
configPath = path.join(scenario.projectRoot, 'config.yaml')
backupPath = path.join(scenario.projectRoot, 'config.yaml.backup')
} else {
// 生产环境(便携版和安装版):用户数据目录
configPath = path.join(scenario.appData, 'config.yaml')
backupPath = path.join(scenario.appData, 'config.yaml.backup')
}
console.log(` NODE_ENV: ${scenario.env.NODE_ENV}`)
console.log(` APP_PACKAGED: ${scenario.env.APP_PACKAGED}`)
console.log(` Is Development: ${isDev ? '✓ Yes' : '✗ No'}`)
if ('exeDir' in scenario) {
console.log(` EXE Directory: ${scenario.exeDir}`)
}
console.log(` → Config Path: ${configPath}`)
console.log(` → Backup Path: ${backupPath}`)
console.log('')
}
console.log('╔════════════════════════════════════════════════════════════════╗')
console.log('║ Configuration Strategy (配置策略): ║')
console.log('╚════════════════════════════════════════════════════════════════╝')
console.log(`
┌─────────────┬──────────────────────────────────────────────────────────┐
│ 环境 │ 配置文件位置 │
├─────────────┼──────────────────────────────────────────────────────────┤
│ 开发环境 │ 项目根目录\\config.yaml │
│ │ 方便编辑和调试,配置随代码版本管理 │
├─────────────┼──────────────────────────────────────────────────────────┤
│ 生产环境 │ %APPDATA%\\erpauto\\config.yaml │
│ (便携版/ │ 符合 Windows 规范,应用升级时配置保留,安全 │
│ 安装版) │ │
└─────────────┴──────────────────────────────────────────────────────────┘
💡 优势:
✓ 开发时配置在项目根目录,方便版本控制和团队协作
✓ 生产环境配置在用户数据目录,应用升级不会丢失配置
✓ 配置不暴露在应用目录,更安全
✓ 多用户环境下,每个用户有独立的配置
`)
console.log('╔════════════════════════════════════════════════════════════════╗')
console.log('║ Recommended Directory Structure: ║')
console.log('╚════════════════════════════════════════════════════════════════╝')
console.log(`
【开发环境】
D:\\Projects\\ERPAuto\\
├── src\\
├── package.json
├── config.yaml # 开发配置(可加入 .gitignore
├── config.yaml.backup # 自动备份
└── config.template.yaml # 配置模板(提交到版本控制)
【生产环境 - 便携版/安装版】
C:\\Users\\<user>\\AppData\\Roaming\\erpauto\\
├── config.yaml # 用户配置
└── config.yaml.backup # 自动备份
`)

View File

@@ -0,0 +1,232 @@
/**
* ERP 登录调试脚本
*
* 用途:人工调试 ERP 登录流程,定位主界面特征元素
* 使用方式:
* 1. 修改下方的 ERP_CONFIG 配置URL、用户名、密码
* 2. 运行npx ts-node src/main/tools/erp-login-debug.ts
* 3. 登录后会自动暂停,打开 Playwright Inspector 进行元素定位
* 4. 在 Inspector 中点击主界面元素,获取选择器
* 5. 将找到的元素选择器添加到 locators.ts
*
* 调试模式说明:
* - headless: false - 显示浏览器窗口
* - slowMo: 100 - 放慢操作速度便于观察
* - debugger 语句会暂停执行,打开开发者工具
*/
import { chromium } from 'playwright'
import path from 'path'
// ==================== 配置区域 ====================
const ERP_CONFIG = {
url: 'https://68.11.34.30:8082/', // ← 修改为你的 ERP 地址
username: 'BLDpengqiangqiang', // ← 修改为你的用户名
password: 'Cqbld123456.' // ← 修改为你的密码
}
// 调试配置
const DEBUG_CONFIG = {
headless: false, // 必须为 false需要看到浏览器
slowMo: 100, // 操作间隔(毫秒)
timeout: 60000 // 超时时间(毫秒)
}
// ================================================
async function debugErpLogin(): Promise<void> {
console.log('='.repeat(60))
console.log('ERP 登录调试工具')
console.log('='.repeat(60))
console.log('目标 URL:', ERP_CONFIG.url)
console.log('用户名:', ERP_CONFIG.username)
console.log('密码:', '***'.repeat(ERP_CONFIG.password.length))
console.log('='.repeat(60))
console.log()
console.log('操作步骤:')
console.log('1. 浏览器将自动打开并尝试登录')
console.log('2. 如果登录失败,请检查配置或手动重试')
console.log('3. 登录成功后,会自动暂停并打开开发者工具')
console.log('4. 使用元素选择器定位主界面特征元素')
console.log('5. 记录元素选择器,按 Ctrl+C 退出脚本')
console.log()
console.log('按 Enter 键开始...')
// 等待用户确认
await new Promise<void>((resolve) => {
process.stdin.resume()
process.stdin.once('data', () => {
process.stdin.pause()
resolve()
})
})
let browser: any = null
let context: any = null
let page: any = null
try {
console.log('\n正在启动浏览器...')
// 启动浏览器(带调试配置)
browser = await chromium.launch({
headless: DEBUG_CONFIG.headless,
slowMo: DEBUG_CONFIG.slowMo,
args: ['--ignore-certificate-errors', '--ignore-ssl-errors', '--disable-web-security']
})
context = await browser.newContext({
acceptDownloads: true,
viewport: { width: 1920, height: 1080 },
ignoreHTTPSErrors: true,
javaScriptEnabled: true
})
page = await context.newPage()
// 启用详细的控制台日志
page.on('console', (msg: any) => {
console.log(`[Page Console] ${msg.type()}: ${msg.text()}`)
})
page.on('pageerror', (error: any) => {
console.error(`[Page Error] ${error.message}`)
})
// 导航到登录页面
const loginUrl = `${ERP_CONFIG.url}/yonbip/resources/uap/rbac/login/main/index.html`
console.log(`正在加载登录页面:${loginUrl}`)
await page.goto(loginUrl, { timeout: DEBUG_CONFIG.timeout })
await page.waitForLoadState('domcontentloaded')
console.log('登录页面已加载')
// 等待并定位登录表单 iframe
console.log('等待登录表单...')
await page.waitForSelector('#forwardFrame', { state: 'attached', timeout: 15000 })
const frameLocator = page.locator('#forwardFrame')
const contentFrame = await frameLocator.contentFrame()
if (!contentFrame) {
throw new Error('无法访问 forwardFrame 内容框架')
}
const mainFrame = contentFrame
console.log('登录表单框架已就绪')
// 填充用户名
console.log('正在输入用户名...')
try {
const usernameInput = mainFrame.getByRole('textbox', { name: '用户名' })
await usernameInput.waitFor({ state: 'visible', timeout: 5000 })
await usernameInput.fill(ERP_CONFIG.username)
console.log('用户名已输入')
} catch (e: any) {
console.error('找不到用户名输入框:', e.message)
throw new Error('找不到用户名输入框,请检查页面结构是否改变')
}
// 填充密码
console.log('正在输入密码...')
try {
const passwordInput = mainFrame.getByRole('textbox', { name: '密码' })
await passwordInput.waitFor({ state: 'visible', timeout: 5000 })
await passwordInput.fill(ERP_CONFIG.password)
console.log('密码已输入')
} catch (e: any) {
console.error('找不到密码输入框:', e.message)
throw new Error('找不到密码输入框,请检查页面结构是否改变')
}
await page.pause()
// 点击登录按钮
console.log('正在点击登录按钮...')
try {
const loginButton = mainFrame.getByRole('button', { name: '登录' })
await loginButton.waitFor({ state: 'visible', timeout: 5000 })
await loginButton.click()
console.log('已点击登录按钮')
} catch (e: any) {
console.error('找不到登录按钮:', e.message)
throw new Error('找不到登录按钮,请检查页面结构是否改变')
}
// 等待页面加载
console.log('等待登录响应...')
await page.waitForLoadState('domcontentloaded', { timeout: 10000 })
await page.waitForTimeout(2000) // 额外等待,确保页面完全加载
// 处理强制登录确认对话框
try {
const confirmBtn = mainFrame.getByRole('button', { name: '确定' })
const count = await confirmBtn.count()
if (count > 0) {
console.log('检测到强制登录确认对话框,正在确认...')
await confirmBtn.first().click()
await page.waitForTimeout(2000)
console.log('已确认强制登录')
} else {
console.log('普通登录,无需确认')
}
} catch {
console.log('未检测到确认对话框')
}
// ==================== 登录成功,进入调试模式 ====================
console.log()
console.log('='.repeat(60))
console.log('✓ 登录成功!')
console.log('='.repeat(60))
console.log()
console.log('现在进入调试模式,请进行以下操作:')
console.log()
console.log('1. 按 F12 打开浏览器开发者工具')
console.log('2. 使用元素选择器 (Ctrl+Shift+C) 点击主界面特征元素')
console.log('3. 在 Elements 面板中右键元素 → Copy → Copy selector')
console.log('4. 或者使用 Playwright Inspector:')
console.log(' - 在控制台输入: await page.pause()')
console.log(' - 使用 Inspector 的元素选择工具')
console.log()
console.log('建议定位的特征元素:')
console.log('- 主界面顶部导航栏')
console.log('- 侧边菜单栏')
console.log('- 主内容区域的唯一标识')
console.log('- 用户信息显示区域')
console.log('- 任何登录后独有的界面元素')
console.log()
console.log('按 Ctrl+C 退出脚本')
console.log('='.repeat(60))
// 保持浏览器打开,等待用户调试
// 使用 pause 可以让用户手动恢复或使用 Inspector
await page.pause()
} catch (error: any) {
console.error()
console.error('='.repeat(60))
console.error('错误:', error.message)
console.error('='.repeat(60))
console.error()
console.error('调试提示:')
console.error('1. 检查 ERP_CONFIG 配置是否正确')
console.error('2. 检查网络连接')
console.error('3. 确认 ERP 系统可访问')
console.error('4. 如果是元素找不到,可能需要更新 locators.ts')
} finally {
// 清理资源(给用户时间保存信息)
console.log()
console.log('等待 5 秒后关闭浏览器...')
await new Promise((resolve) => setTimeout(resolve, 5000))
if (context) {
await context.close()
}
if (browser) {
await browser.close()
}
console.log('浏览器已关闭')
process.exit(0)
}
}
// 运行调试脚本
debugErpLogin().catch((err) => {
console.error('脚本执行失败:', err)
process.exit(1)
})

View File

@@ -0,0 +1,215 @@
/**
* RustFS Integration Test Script
*
* Tests RustFS connection and upload functionality
* Usage: tsx src/main/tools/rustfs-test.ts
*
* Note: This test runs in standalone mode without Electron
*/
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
ListObjectsV2Command,
DeleteObjectCommand,
type PutObjectCommandInput
} from '@aws-sdk/client-s3'
import * as path from 'path'
import * as fs from 'fs'
// Simple console logger (standalone mode)
const log = {
info: (msg: string, data?: any) => console.log(`[INFO] ${msg}`, data ? JSON.stringify(data) : ''),
error: (msg: string, data?: any) =>
console.error(`[ERROR] ${msg}`, data ? JSON.stringify(data) : ''),
warn: (msg: string, data?: any) => console.warn(`[WARN] ${msg}`, data ? JSON.stringify(data) : '')
}
// Test configuration
const TEST_CONFIG = {
enabled: true,
endpoint: 'http://192.168.110.114:9000',
accessKey: 'dP4O7ePAzyH8earoXxE9',
secretKey: '2vRPLnsh9Zi1KyBDymUtACyDdLHGfsLvw4MkG3cv',
bucket: 'erpauto',
region: 'us-east-1'
}
function createS3Client(config: typeof TEST_CONFIG) {
return new S3Client({
region: config.region,
endpoint: config.endpoint,
credentials: {
accessKeyId: config.accessKey,
secretAccessKey: config.secretKey
},
forcePathStyle: true
})
}
function getMimeType(filePath: string): string {
const ext = path.extname(filePath).toLowerCase()
const mimeTypes: Record<string, string> = {
'.md': 'text/markdown; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.csv': 'text/csv; charset=utf-8'
}
return mimeTypes[ext] || 'application/octet-stream'
}
function generateReportKey(reportFileName: string, username: string): string {
// Organize reports by user for easy access
// Format: reports/cleaner/{username}/{filename}
return `reports/cleaner/${username}/${reportFileName}`
}
async function runTests() {
console.log('='.repeat(50))
console.log('RustFS Integration Test')
console.log('='.repeat(50))
console.log()
const client = createS3Client(TEST_CONFIG)
// Test connection
console.log('1. Testing connection...')
try {
const command = new ListObjectsV2Command({
Bucket: TEST_CONFIG.bucket,
Prefix: '',
MaxKeys: 1
})
await client.send(command)
console.log(' ✓ Connection successful')
} catch (error) {
console.log(` ✗ Connection failed: ${(error as Error).message}`)
return
}
console.log()
// Test upload string
console.log('2. Testing string upload...')
const testContent = `# Test Report
Generated at: ${new Date().toISOString()}
This is a test report to verify RustFS integration.
`
const testKey = `test/reports/test-${Date.now()}.md`
try {
const input: PutObjectCommandInput = {
Bucket: TEST_CONFIG.bucket,
Key: testKey,
Body: Buffer.from(testContent, 'utf-8'),
ContentType: 'text/markdown; charset=utf-8'
}
const command = new PutObjectCommand(input)
const response = await client.send(command)
console.log(' ✓ Upload successful')
console.log(` Key: ${testKey}`)
console.log(` ETag: ${response.ETag}`)
} catch (error) {
console.log(' ✗ Upload failed')
console.log(` Error: ${(error as Error).message}`)
return
}
console.log()
// Test download
console.log('3. Testing download...')
try {
const command = new GetObjectCommand({
Bucket: TEST_CONFIG.bucket,
Key: testKey
})
const response = await client.send(command)
const chunks: Buffer[] = []
for await (const chunk of response.Body as any) {
chunks.push(Buffer.from(chunk))
}
const content = Buffer.concat(chunks)
console.log(' ✓ Download successful')
console.log(` Size: ${content.length} bytes`)
console.log(` Content preview: ${content.toString('utf-8').slice(0, 50)}...`)
} catch (error) {
console.log(' ✗ Download failed')
console.log(` Error: ${(error as Error).message}`)
}
console.log()
// Test file upload (create a temporary file)
console.log('4. Testing file upload...')
const tempFilePath = path.join(process.cwd(), `test-file-${Date.now()}.md`)
fs.writeFileSync(tempFilePath, testContent, 'utf-8')
const fileKey = `test/files/test-file-${Date.now()}.md`
try {
const fileContent = fs.readFileSync(tempFilePath)
const input: PutObjectCommandInput = {
Bucket: TEST_CONFIG.bucket,
Key: fileKey,
Body: fileContent,
ContentType: getMimeType(tempFilePath)
}
const command = new PutObjectCommand(input)
const response = await client.send(command)
console.log(' ✓ File upload successful')
console.log(` Key: ${fileKey}`)
console.log(` ETag: ${response.ETag}`)
} catch (error) {
console.log(' ✗ File upload failed')
console.log(` Error: ${(error as Error).message}`)
}
// Cleanup temp file
try {
fs.unlinkSync(tempFilePath)
console.log(' ✓ Temporary file cleaned up')
} catch (e) {
console.log(` ⚠ Could not clean up temp file: ${(e as Error).message}`)
}
console.log()
// Test report key generation
console.log('5. Testing report key generation...')
const reportKey = generateReportKey('cleaner-report-2026-03-17-10-30-00.md', 'admin')
console.log(` ✓ Generated key: ${reportKey}`)
console.log()
// Test cleanup (delete test files)
console.log('6. Cleaning up test files...')
try {
const deleteCommand = new DeleteObjectCommand({
Bucket: TEST_CONFIG.bucket,
Key: testKey
})
await client.send(deleteCommand)
console.log(' ✓ Test string file deleted')
} catch (error) {
console.log(` ⚠ Could not delete test string file: ${(error as Error).message}`)
}
try {
const deleteCommand = new DeleteObjectCommand({
Bucket: TEST_CONFIG.bucket,
Key: fileKey
})
await client.send(deleteCommand)
console.log(' ✓ Test file deleted')
} catch (error) {
console.log(` ⚠ Could not delete test file: ${(error as Error).message}`)
}
console.log()
console.log('='.repeat(50))
console.log('All tests completed!')
console.log('='.repeat(50))
}
// Run tests
runTests().catch((error) => {
console.error('Test failed with error:', error)
process.exit(1)
})

View File

@@ -0,0 +1,44 @@
/**
* Audit log types and interfaces
*/
/**
* Audit action enumeration
*/
export enum AuditAction {
LOGIN = 'LOGIN',
LOGOUT = 'LOGOUT',
EXTRACT = 'EXTRACT',
CLEAN = 'CLEAN',
SETTINGS_CHANGE = 'SETTINGS_CHANGE'
}
/**
* Audit status enumeration
*/
export enum AuditStatus {
SUCCESS = 'SUCCESS',
FAILURE = 'FAILURE'
}
/**
* Audit entry interface
*/
export interface AuditEntry {
/** Timestamp of the action */
timestamp: Date
/** Action performed */
action: AuditAction
/** User ID who performed the action */
userId: string
/** Username who performed the action */
username: string
/** Computer name where action was performed */
computerName: string
/** Resource affected by the action */
resource?: string
/** Status of the action */
status: AuditStatus
/** Additional metadata in JSON format */
metadata?: string
}

View File

@@ -0,0 +1,88 @@
export type CleanerPhase = 'login' | 'processing' | 'complete'
export interface CleanerProgress {
message: string
progress: number
currentOrderIndex: number
totalOrders: number
currentMaterialIndex: number
totalMaterialsInOrder: number
currentOrderNumber?: string
phase: CleanerPhase
}
export interface CleanerInput {
orderNumbers: string[]
materialCodes: string[]
dryRun: boolean
headless?: boolean
queryBatchSize?: number
processConcurrency?: number
onProgress?: (message: string, progress?: number, extra?: Partial<CleanerProgress>) => void
}
export interface CleanerResult {
ordersProcessed: number
materialsDeleted: number
materialsSkipped: number
errors: string[]
details: OrderCleanDetail[]
// Retry statistics
retriedOrders: number
successfulRetries: number
}
export interface SkippedMaterial {
materialCode: string
materialName: string
rowNumber: number
reason: string
}
export interface RetryAttempt {
attempt: number
error: string
timestamp: number
}
export interface OrderCleanDetail {
orderNumber: string
materialsDeleted: number
materialsSkipped: number
errors: string[]
skippedMaterials: SkippedMaterial[]
// Retry-related fields
retryCount: number
retryAttempts?: RetryAttempt[]
retriedAt?: number
retrySuccess?: boolean
}
/**
* Single validation result item for export
*/
export interface ExportResultItem {
materialName: string
materialCode: string
specification: string
model: string
managerName: string
isMarkedForDeletion: boolean
isSelected: boolean
}
/**
* Request payload for exporting validation results
*/
export interface ExportResultRequest {
items: ExportResultItem[]
}
/**
* Response for export operation
*/
export interface ExportResultResponse {
success: boolean
filePath?: string
error?: string
}

View File

@@ -0,0 +1,190 @@
/**
* Configuration Schema Definitions
*
* Zod schemas for runtime validation of application configuration
*
* Note: ERP configuration is stored in database (dbo_BIPUsers table)
* and managed per-user, not in this config file.
*/
import { z } from 'zod'
/**
* 数据库类型枚举
*/
export const databaseTypeSchema = z.enum(['mysql', 'sqlserver'])
export type DatabaseType = z.infer<typeof databaseTypeSchema>
/**
* 验证匹配模式枚举
*/
export const matchModeSchema = z.enum(['substring', 'exact'])
export type MatchMode = z.infer<typeof matchModeSchema>
/**
* 验证数据源枚举
*/
export const validationDataSourceSchema = z.enum([
'database_full',
'database_filtered',
'excel_existing',
'excel_full'
])
export type ValidationDataSource = z.infer<typeof validationDataSourceSchema>
/**
* MySQL 配置 Schema
*/
export const mysqlConfigSchema = z.object({
host: z.string().min(1, 'MySQL host is required'),
port: z.number().int().min(1).max(65535).default(3306),
database: z.string().min(1, 'MySQL database is required'),
username: z.string().min(1, 'MySQL username is required'),
password: z.string(),
charset: z.string().default('utf8mb4')
})
/**
* SQL Server 配置 Schema
*/
export const sqlServerConfigSchema = z.object({
server: z.string().min(1, 'SQL Server is required'),
port: z.number().int().min(1).max(65535).default(1433),
database: z.string().min(1, 'SQL Server database is required'),
username: z.string().min(1, 'SQL Server username is required'),
password: z.string(),
driver: z.string().default('ODBC Driver 18 for SQL Server'),
trustServerCertificate: z.boolean().default(true)
})
/**
* 数据库配置(包含两种数据库的完整配置)
*/
export const databaseConfigSchema = z.object({
activeType: databaseTypeSchema.default('mysql'),
mysql: mysqlConfigSchema,
sqlserver: sqlServerConfigSchema
})
/**
* 路径配置 Schema
*/
export const pathsConfigSchema = z.object({
dataDir: z.string().min(1, 'Data directory is required'),
defaultOutput: z.string().default('离散备料计划维护_合并.xlsx'),
validationOutput: z.string().default('物料状态校验结果.xlsx')
})
/**
* 数据提取配置 Schema
*/
export const extractionConfigSchema = z.object({
batchSize: z.number().int().min(1).max(1000).default(100),
verbose: z.boolean().default(true),
autoConvert: z.boolean().default(true),
mergeBatches: z.boolean().default(true),
enableDbPersistence: z.boolean().default(true)
})
/**
* 物料校验配置 Schema
*/
export const validationConfigSchema = z.object({
dataSource: validationDataSourceSchema.default('database_full'),
batchSize: z.number().int().min(1).max(10000).default(2000),
matchMode: matchModeSchema.default('substring'),
enableCrud: z.boolean().default(false),
defaultManager: z.string().default('')
})
/**
* 清理配置 Schema
*/
export const cleanerConfigSchema = z.object({
queryBatchSize: z.number().int().min(1).max(100).default(100),
processConcurrency: z.number().int().min(1).max(20).default(1)
})
export type CleanerConfig = z.infer<typeof cleanerConfigSchema>
/**
* 订单号解析配置 Schema
*/
export const orderResolutionSchema = z.object({
tableName: z.string(),
productionIdField: z.string(),
orderNumberField: z.string()
})
/**
* ERP 系统配置 Schema固定基础设施
*/
export const erpSystemConfigSchema = z.object({
url: z.string().url('ERP URL must be a valid URL')
})
/**
* 日志配置 Schema
*/
export const loggingConfigSchema = z.object({
level: z.enum(['error', 'warn', 'info', 'debug', 'verbose']).default('info'),
auditRetention: z.number().int().min(1).max(365).default(30),
appRetention: z.number().int().min(1).max(365).default(14)
})
/**
* RustFS 对象存储配置 Schema
*/
export const rustfsConfigSchema = z.object({
enabled: z.boolean().default(false),
endpoint: z.string().min(1, 'RustFS endpoint is required'),
accessKey: z.string().min(1, 'RustFS access key is required'),
secretKey: z.string().min(1, 'RustFS secret key is required'),
bucket: z.string().min(1, 'RustFS bucket is required'),
region: z.string().default('us-east-1')
})
/**
* 完整应用配置 Schema
*/
export const fullConfigSchema = z.object({
erp: erpSystemConfigSchema,
database: databaseConfigSchema,
paths: pathsConfigSchema,
extraction: extractionConfigSchema,
validation: validationConfigSchema,
cleaner: cleanerConfigSchema,
orderResolution: orderResolutionSchema,
logging: loggingConfigSchema,
rustfs: rustfsConfigSchema.optional()
})
/**
* 类型导出
*/
export type FullConfig = z.infer<typeof fullConfigSchema>
export type DatabaseConfig = z.infer<typeof databaseConfigSchema>
export type MySqlConfig = z.infer<typeof mysqlConfigSchema>
export type SqlServerConfig = z.infer<typeof sqlServerConfigSchema>
export type ErpSystemConfig = z.infer<typeof erpSystemConfigSchema>
export type LoggingConfig = z.infer<typeof loggingConfigSchema>
export type RustfsConfig = z.infer<typeof rustfsConfigSchema>
/**
* 验证并解析配置
*/
export function validateConfig(input: unknown): {
success: boolean
data?: FullConfig
error?: string
} {
const result = fullConfigSchema.safeParse(input)
if (result.success) {
return { success: true, data: result.data }
}
return {
success: false,
error: result.error.issues
.map((issue) => `${issue.path.join('.')}: ${issue.message}`)
.join('; ')
}
}

View File

@@ -0,0 +1,104 @@
/**
* Database Type Definitions
*
* Provides abstract interfaces for database operations,
* supporting both MySQL and SQL Server databases.
*/
/**
* Supported database types
*/
export type DatabaseType = 'mysql' | 'sqlserver'
/**
* Standard query result interface
*/
export interface QueryResult {
/** Query result rows */
rows: Record<string, unknown>[]
/** Column names from the query */
columns: string[]
/** Number of rows affected or returned */
rowCount: number
}
/**
* Database service interface
*
* Defines the common interface that all database services must implement.
* This allows for database-agnostic operations throughout the application.
*/
export interface IDatabaseService {
/** Database type identifier */
readonly type: DatabaseType
/**
* Connect to the database
*/
connect(): Promise<void>
/**
* Disconnect from the database
*/
disconnect(): Promise<void>
/**
* Check if connected to the database
*/
isConnected(): boolean
/**
* Execute a query and return results
* @param sql - SQL query string
* @param params - Query parameters as an array
*/
query(sql: string, params?: any[]): Promise<QueryResult>
/**
* Execute multiple queries in a transaction
* @param queries - Array of queries with optional parameters
*/
transaction(queries: { sql: string; params?: any[] }[]): Promise<void>
}
/**
* Base database configuration interface
*/
export interface DatabaseConfig {
/** Database server host */
host?: string
/** Database server port */
port?: number
/** Database username */
user?: string
/** Database password */
password?: string
/** Database name */
database?: string
}
/**
* MySQL-specific configuration
*/
export interface MySqlConfig extends DatabaseConfig {
host: string
port: number
user: string
password: string
database: string
}
/**
* SQL Server-specific configuration
*/
export interface SqlServerConfig extends DatabaseConfig {
server: string
port: number
user: string
password: string
database: string
options?: {
encrypt?: boolean
trustServerCertificate?: boolean
}
}

View File

@@ -0,0 +1,18 @@
export interface ErpConfig {
url: string
username: string
password: string
headless?: boolean // Optional: override default headless setting
}
export interface ErpSession {
browser: import('playwright').Browser
context: import('playwright').BrowserContext
page: import('playwright').Page
mainFrame: import('playwright').Frame // #forwardFrame content frame - main working frame after login
isLoggedIn: boolean
}
export interface ProgressCallback {
(message: string, progress?: number): void
}

188
src/main/types/errors.ts Normal file
View File

@@ -0,0 +1,188 @@
/**
* Custom error types for the application
* Provides structured error handling with codes and context
*/
/**
* Base error class for all application errors
*/
export abstract class BaseError extends Error {
public readonly code: string
public readonly cause?: Error
constructor(name: string, message: string, code: string, cause?: Error) {
super(message)
this.name = name
this.code = code
this.cause = cause
// Maintains proper stack trace for where error was thrown (only in V8)
if (Error.captureStackTrace) {
Error.captureStackTrace(this, this.constructor)
}
}
/**
* Get a JSON representation of the error for logging/serialization
*/
toJSON(): Record<string, unknown> {
return {
name: this.name,
message: this.message,
code: this.code,
cause: this.cause?.message
}
}
}
/**
* Error codes for ERP connection errors
*/
export const ERP_ERROR_CODES = {
CONNECTION_FAILED: 'ERP_CONNECTION_FAILED',
LOGIN_FAILED: 'ERP_LOGIN_FAILED',
TIMEOUT: 'ERP_TIMEOUT',
NAVIGATION_ERROR: 'ERP_NAVIGATION_ERROR',
ELEMENT_NOT_FOUND: 'ERP_ELEMENT_NOT_FOUND',
SESSION_EXPIRED: 'ERP_SESSION_EXPIRED',
BROWSER_CRASH: 'ERP_BROWSER_CRASH'
} as const
/**
* Error thrown when ERP connection, login, or browser automation fails
*/
export class ErpConnectionError extends BaseError {
constructor(
message: string,
code: (typeof ERP_ERROR_CODES)[keyof typeof ERP_ERROR_CODES] = ERP_ERROR_CODES.CONNECTION_FAILED,
cause?: Error
) {
super('ErpConnectionError', message, code, cause)
}
}
/**
* Error codes for database errors
*/
export const DATABASE_ERROR_CODES = {
CONNECTION_FAILED: 'DB_CONNECTION_FAILED',
QUERY_FAILED: 'DB_QUERY_FAILED',
TIMEOUT: 'DB_TIMEOUT',
INVALID_PARAMS: 'DB_INVALID_PARAMS',
RECORD_NOT_FOUND: 'DB_RECORD_NOT_FOUND',
TRANSACTION_FAILED: 'DB_TRANSACTION_FAILED'
} as const
/**
* Error thrown when database operations fail
*/
export class DatabaseQueryError extends BaseError {
constructor(
message: string,
code: (typeof DATABASE_ERROR_CODES)[keyof typeof DATABASE_ERROR_CODES] = DATABASE_ERROR_CODES.QUERY_FAILED,
cause?: Error
) {
super('DatabaseQueryError', message, code, cause)
}
}
/**
* Error codes for validation errors
*/
export const VALIDATION_ERROR_CODES = {
INVALID_INPUT: 'VAL_INVALID_INPUT',
MISSING_REQUIRED: 'VAL_MISSING_REQUIRED',
INVALID_FORMAT: 'VAL_INVALID_FORMAT',
OUT_OF_RANGE: 'VAL_OUT_OF_RANGE',
INVALID_TYPE: 'VAL_INVALID_TYPE'
} as const
/**
* Error thrown when input validation fails
*/
export class ValidationError extends BaseError {
constructor(
message: string,
code: (typeof VALIDATION_ERROR_CODES)[keyof typeof VALIDATION_ERROR_CODES] = VALIDATION_ERROR_CODES.INVALID_INPUT,
cause?: Error
) {
super('ValidationError', message, code, cause)
}
}
/**
* Type guard to check if an error is a BaseError
*/
export function isBaseError(error: unknown): error is BaseError {
return error instanceof BaseError
}
/**
* Type guard to check if an error is an ErpConnectionError
*/
export function isErpConnectionError(error: unknown): error is ErpConnectionError {
return error instanceof ErpConnectionError
}
/**
* Type guard to check if an error is a DatabaseQueryError
*/
export function isDatabaseQueryError(error: unknown): error is DatabaseQueryError {
return error instanceof DatabaseQueryError
}
/**
* Type guard to check if an error is a ValidationError
*/
export function isValidationError(error: unknown): error is ValidationError {
return error instanceof ValidationError
}
/**
* Get a user-friendly error message from any error type
* In production, generic errors are sanitized to avoid leaking sensitive info
*/
export function getErrorMessage(error: unknown): string {
if (isBaseError(error)) {
// BaseError messages are developer-controlled and safe
return error.message
}
if (error instanceof Error) {
// In production, return a generic message to avoid leaking sensitive info
// (e.g., database connection strings, file paths, server names)
if (process.env.NODE_ENV === 'production') {
return 'An unexpected error occurred'
}
return error.message
}
return 'An unknown error occurred'
}
/**
* Get error code from any error type
*/
export function getErrorCode(error: unknown): string {
if (isBaseError(error)) {
return error.code
}
return 'UNKNOWN_ERROR'
}
/**
* Error-like interface for non-Error objects that have error properties
*/
export interface ErrorLike {
name: string
message: string
stack?: string
cause?: unknown
}
/**
* Serialized error object for logging
* Can contain additional properties from Error subclasses
*/
export interface SerializedError extends ErrorLike {
cause?: SerializedError | string
[key: string]: unknown
}

View File

@@ -0,0 +1,143 @@
/**
* Excel Parser Types
* Defines the structure for parsed Excel data from ERP system
*/
/**
* Represents a discrete material plan row from Excel
* Based on the ERP Excel export structure
*/
export interface DiscreteMaterialPlan {
/** Order number (e.g., SC202501001) */
orderNumber: string
/** Production ID from order header */
productionId: string
/** Material code (材料编码) */
materialCode: string
/** Material name (材料名称) */
materialName: string
/** Specification (规格) */
specification?: string
/** Model (型号) */
model?: string
/** Drawing number (图号) */
drawingNumber?: string
/** Material (物料材质) */
material?: string
/** Planned quantity (计划数量) */
quantity: number
/** Unit (单位) */
unit: string
/** Required date (需用日期) */
requiredDate?: string
/** Warehouse (发料仓库) */
warehouse?: string
/** Unit usage (单位用量) */
unitUsage?: number
/** Cumulative outbound quantity (累计出库数量) */
cumulativeOutboundQty?: number
/** Pending quantity (pending quantity for fulfillment) */
pendingQty?: number
/** Row number in Excel file */
rowNumber?: number
}
/**
* Options for Excel parsing
*/
export interface ExcelParseOptions {
/** Skip orders with no material data */
skipEmptyOrders?: boolean
/** Skip footer rows (制单人/打印人) */
skipFooter?: boolean
/** Custom field mapping */
fieldMapping?: Record<string, string>
/** Verbose logging */
verbose?: boolean
}
/**
* Order header information from Excel
*/
export interface OrderHeader {
/** Order title (离散备料计划) */
title?: string
/** Factory (工厂) */
factory?: string
/** Material status (备料状态) */
materialStatus?: string
/** Plan number (备料计划单号) */
planNumber?: string
/** Material type (备料类型) */
materialType?: string
/** Production department (生产部门) */
productionDepartment?: string
/** Production order (生产订单) */
productionOrder?: string
/** Product code (产品编码) */
productCode?: string
/** Product name (产品名称) */
productName?: string
/** Product specification (产品规格) */
productSpecification?: string
/** Planned quantity (计划数量) */
plannedQuantity?: string
/** Unit (单位) */
unit?: string
/** Department (用料部门) */
department?: string
/** Remark (备注) */
remark?: string
/** Required date (需用日期) */
requiredDate?: string
/** Creator (制单人) */
creator?: string
/** Create date (制单日期) */
createDate?: string
/** Approver (审批人) */
approver?: string
/** Approve date (审批日期) */
approveDate?: string
/** Printer (打印人) */
printer?: string
/** Print date (打印日期) */
printDate?: string
}

View File

@@ -0,0 +1,70 @@
import type { ErpSession } from './erp.types'
export type LogLevel = 'info' | 'success' | 'warning' | 'error' | 'system'
export type ExtractionPhase = 'login' | 'downloading' | 'merging' | 'importing'
export interface ExtractionProgress {
message: string
progress: number
phase?: ExtractionPhase
currentBatch?: number
totalBatches?: number
subProgress?: {
step: string
current: number
total: number
}
}
export interface ExtractorInput {
orderNumbers: string[]
batchSize?: number
onProgress?: (message: string, progress: number, extra?: Partial<ExtractionProgress>) => void
onLog?: (level: LogLevel, message: string) => void
}
/**
* Result of database import operation
*/
export interface ImportResult {
success: boolean
recordsRead: number
recordsDeleted: number
recordsImported: number
uniqueSourceNumbers: number
errors: string[]
}
export interface ExtractorResult {
downloadedFiles: string[]
mergedFile: string | null
recordCount: number
errors: string[]
/** Database import result (only populated if mergedFile was created) */
importResult?: ImportResult
}
export interface OrderInfo {
orderNumber: string
productionId: string
}
/**
* Input for ExtractorCore - handles web page operations
*/
export interface ExtractorCoreInput {
session: ErpSession
orderNumbers: string[]
downloadDir: string
batchSize: number
onProgress?: (message: string, progress: number, extra?: Partial<ExtractionProgress>) => void
}
/**
* Result from ExtractorCore - list of downloaded file paths
*/
export interface ExtractorCoreResult {
downloadedFiles: string[]
errors: string[]
}

Some files were not shown because too many files have changed in this diff Show More