Compare commits
2 Commits
1.3.0
...
02ae7369c8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02ae7369c8 | ||
|
|
9ee9c7281c |
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -6,7 +6,6 @@
|
|||||||
*.js text eol=lf
|
*.js text eol=lf
|
||||||
*.json text eol=lf
|
*.json text eol=lf
|
||||||
*.yml text eol=lf
|
*.yml text eol=lf
|
||||||
*.yaml text eol=lf
|
|
||||||
*.tsx text eol=lf
|
*.tsx text eol=lf
|
||||||
*.jsx text eol=lf
|
*.jsx text eol=lf
|
||||||
*.css text eol=lf
|
*.css text eol=lf
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
npm run dev # Start development server with hot reload
|
npm run dev # Start development server with hot reload
|
||||||
npm run build # Full build with type checking
|
npm run build # Full build with type checking
|
||||||
npm run build:win # Build Windows executable
|
npm run build:win # Build Windows executable
|
||||||
|
npm run build:mac # Build macOS DMG
|
||||||
|
npm run build:linux # Build Linux AppImage
|
||||||
```
|
```
|
||||||
|
|
||||||
### Code Quality
|
### Code Quality
|
||||||
|
|||||||
@@ -52,21 +52,3 @@ orderResolution:
|
|||||||
tableName: ''
|
tableName: ''
|
||||||
productionIdField: ''
|
productionIdField: ''
|
||||||
orderNumberField: ''
|
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 兼容,默认即可)
|
|
||||||
|
|||||||
@@ -1,985 +0,0 @@
|
|||||||
# 物料清理模块 - 订单错误收集逻辑分析
|
|
||||||
|
|
||||||
本文档详细分析了 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 次)
|
|
||||||
业务规则错误
|
|
||||||
物料不在删除清单
|
|
||||||
行号在保护范围 (7000-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 获取 | - |
|
|
||||||
@@ -27,7 +27,7 @@ win:
|
|||||||
- nsis
|
- nsis
|
||||||
- portable
|
- portable
|
||||||
portable:
|
portable:
|
||||||
artifactName: ${name}-portable.${ext}
|
artifactName: ${name}-${version}-portable.${ext}
|
||||||
# Portable app uses user data directory (AppData), not exe directory
|
# Portable app uses user data directory (AppData), not exe directory
|
||||||
# This ensures config persists across app updates
|
# This ensures config persists across app updates
|
||||||
nsis:
|
nsis:
|
||||||
|
|||||||
@@ -2,48 +2,16 @@ import { resolve } from 'path'
|
|||||||
import { defineConfig } from 'electron-vite'
|
import { defineConfig } from 'electron-vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
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({
|
export default defineConfig({
|
||||||
main: {},
|
main: {},
|
||||||
preload: {},
|
preload: {},
|
||||||
renderer: {
|
renderer: {
|
||||||
define: {
|
|
||||||
__APP_VERSION__: JSON.stringify(version),
|
|
||||||
__GIT_HASH__: JSON.stringify(gitHash)
|
|
||||||
},
|
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@renderer': resolve('src/renderer/src')
|
'@renderer': resolve('src/renderer/src')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [react(), tailwindcss()]
|
||||||
react(),
|
|
||||||
tailwindcss(),
|
|
||||||
{
|
|
||||||
name: 'update-title',
|
|
||||||
transformIndexHtml(html) {
|
|
||||||
return html.replace(
|
|
||||||
'<title>ERP Auto Tool</title>',
|
|
||||||
`<title>ERPAuto - v${version}(${gitHash})</title>`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
3148
package-lock.json
generated
3148
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "erpauto",
|
"name": "erpauto",
|
||||||
"version": "1.3.0",
|
"version": "1.0.0",
|
||||||
"description": "An Electron application with React and TypeScript",
|
"description": "An Electron application with React and TypeScript",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "example.com",
|
"author": "example.com",
|
||||||
@@ -16,10 +16,9 @@
|
|||||||
"build": "chcp 65001 && npm run typecheck && electron-vite build",
|
"build": "chcp 65001 && npm run typecheck && electron-vite build",
|
||||||
"postinstall": "electron-builder install-app-deps",
|
"postinstall": "electron-builder install-app-deps",
|
||||||
"build:unpack": "chcp 65001 && set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 && npm run build && electron-builder --dir",
|
"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:win": "chcp 65001 && set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 && npm run build && electron-builder --win",
|
||||||
"build:mac": "chcp 65001 && npm run prebuild && electron-vite build && electron-builder --mac",
|
"build:mac": "chcp 65001 && electron-vite build && electron-builder --mac",
|
||||||
"build:linux": "chcp 65001 && npm run prebuild && electron-vite build && electron-builder --linux",
|
"build:linux": "chcp 65001 && 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": "vitest",
|
||||||
"test:run": "vitest run",
|
"test:run": "vitest run",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
@@ -27,11 +26,9 @@
|
|||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
"test:e2e:report": "playwright show-report",
|
"test:e2e:report": "playwright show-report",
|
||||||
"debug:erp-login": "tsx src/main/tools/erp-login-debug.ts",
|
"debug:erp-login": "tsx src/main/tools/erp-login-debug.ts",
|
||||||
"debug:config-path": "tsx src/main/tools/config-path-debug.ts",
|
"debug:config-path": "tsx src/main/tools/config-path-debug.ts"
|
||||||
"test:rustfs": "tsx src/main/tools/rustfs-test.ts"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.929.0",
|
|
||||||
"@electron-toolkit/preload": "^3.0.2",
|
"@electron-toolkit/preload": "^3.0.2",
|
||||||
"@electron-toolkit/utils": "^4.0.0",
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
@@ -46,9 +43,7 @@
|
|||||||
"playwright": "^1.58.2",
|
"playwright": "^1.58.2",
|
||||||
"playwright-core": "^1.58.2",
|
"playwright-core": "^1.58.2",
|
||||||
"react-focus-lock": "^2.13.7",
|
"react-focus-lock": "^2.13.7",
|
||||||
"react-markdown": "^10.1.0",
|
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"remark-gfm": "^4.0.1",
|
|
||||||
"typeorm": "^0.3.28",
|
"typeorm": "^0.3.28",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"winston": "^3.19.0",
|
"winston": "^3.19.0",
|
||||||
|
|||||||
85
playwright-report/index.html
Normal file
85
playwright-report/index.html
Normal file
File diff suppressed because one or more lines are too long
@@ -7,7 +7,6 @@ import { SqlServerService } from '../services/database/sql-server'
|
|||||||
import { ConfigManager } from '../services/config/config-manager'
|
import { ConfigManager } from '../services/config/config-manager'
|
||||||
import { ResultExporter } from '../services/excel/result-exporter'
|
import { ResultExporter } from '../services/excel/result-exporter'
|
||||||
import { CleanerReportGenerator } from '../services/report/cleaner-report-generator'
|
import { CleanerReportGenerator } from '../services/report/cleaner-report-generator'
|
||||||
import { RustfsService } from '../services/rustfs'
|
|
||||||
import { SessionManager } from '../services/user/session-manager'
|
import { SessionManager } from '../services/user/session-manager'
|
||||||
import { createLogger } from '../services/logger'
|
import { createLogger } from '../services/logger'
|
||||||
import { logAudit } from '../services/logger/audit-logger'
|
import { logAudit } from '../services/logger/audit-logger'
|
||||||
@@ -211,11 +210,7 @@ export function registerCleanerHandlers(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info('Starting cleaning', {
|
log.info('Starting cleaning', { orderCount: validOrderNumbers.length })
|
||||||
orderCount: validOrderNumbers.length,
|
|
||||||
queryBatchSize: input.queryBatchSize ?? 100,
|
|
||||||
processConcurrency: input.processConcurrency ?? 1
|
|
||||||
})
|
|
||||||
const result = await cleaner.clean(modifiedInput)
|
const result = await cleaner.clean(modifiedInput)
|
||||||
|
|
||||||
if (warnings.length > 0) {
|
if (warnings.length > 0) {
|
||||||
@@ -254,8 +249,6 @@ export function registerCleanerHandlers(): void {
|
|||||||
metadata: {
|
metadata: {
|
||||||
orderCount: validOrderNumbers.length,
|
orderCount: validOrderNumbers.length,
|
||||||
dryRun: input.dryRun ?? false,
|
dryRun: input.dryRun ?? false,
|
||||||
queryBatchSize: input.queryBatchSize ?? 100,
|
|
||||||
processConcurrency: input.processConcurrency ?? 1,
|
|
||||||
materialsDeleted: result.materialsDeleted,
|
materialsDeleted: result.materialsDeleted,
|
||||||
materialsSkipped: result.materialsSkipped,
|
materialsSkipped: result.materialsSkipped,
|
||||||
errorCount: result.errors.length
|
errorCount: result.errors.length
|
||||||
@@ -263,7 +256,7 @@ export function registerCleanerHandlers(): void {
|
|||||||
}).catch((err) => log.warn('Failed to write audit log', { err }))
|
}).catch((err) => log.warn('Failed to write audit log', { err }))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate report and upload to RustFS (silent, user unaware)
|
// Generate report (silent, user unaware)
|
||||||
try {
|
try {
|
||||||
const endTime = Date.now()
|
const endTime = Date.now()
|
||||||
const currentUser = SessionManager.getInstance().getUserInfo()
|
const currentUser = SessionManager.getInstance().getUserInfo()
|
||||||
@@ -277,47 +270,6 @@ export function registerCleanerHandlers(): void {
|
|||||||
endTime
|
endTime
|
||||||
})
|
})
|
||||||
log.info('Report generated', { path: reportPath })
|
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) {
|
} catch (reportError) {
|
||||||
log.warn('Failed to generate report', {
|
log.warn('Failed to generate report', {
|
||||||
error: reportError instanceof Error ? reportError.message : String(reportError)
|
error: reportError instanceof Error ? reportError.message : String(reportError)
|
||||||
|
|||||||
@@ -125,9 +125,6 @@ export function registerExtractorHandlers(): void {
|
|||||||
const validOrderNumbers = resolver.getValidOrderNumbers(mappings)
|
const validOrderNumbers = resolver.getValidOrderNumbers(mappings)
|
||||||
const warnings = resolver.getWarnings(mappings)
|
const warnings = resolver.getWarnings(mappings)
|
||||||
|
|
||||||
// Get deduplication report for detailed logging
|
|
||||||
const dedupReport = resolver.getDeduplicationReport(mappings)
|
|
||||||
|
|
||||||
if (warnings.length > 0) {
|
if (warnings.length > 0) {
|
||||||
log.warn('Resolution warnings', { warnings })
|
log.warn('Resolution warnings', { warnings })
|
||||||
}
|
}
|
||||||
@@ -140,23 +137,7 @@ export function registerExtractorHandlers(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.info('Resolved order numbers', { count: validOrderNumbers.length })
|
log.info('Resolved order numbers', { count: validOrderNumbers.length })
|
||||||
|
sendLog(sender, 'info', `已解析 ${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
|
// Create auth service and login
|
||||||
authService = new ErpAuthService({
|
authService = new ErpAuthService({
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { registerSettingsHandlers } from './settings-handler'
|
|||||||
import { registerMaterialTypeHandlers } from './material-type-handler'
|
import { registerMaterialTypeHandlers } from './material-type-handler'
|
||||||
import { registerUserErpConfigHandlers } from './user-erp-config-handler'
|
import { registerUserErpConfigHandlers } from './user-erp-config-handler'
|
||||||
import { registerLoggerHandlers } from './logger-handler'
|
import { registerLoggerHandlers } from './logger-handler'
|
||||||
import { registerReportHandlers } from './report-handler'
|
|
||||||
import { createLogger, logError } from '../services/logger'
|
import { createLogger, logError } from '../services/logger'
|
||||||
import { serializeError, sanitizeError } from '../services/logger/error-utils'
|
import { serializeError, sanitizeError } from '../services/logger/error-utils'
|
||||||
import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors'
|
import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors'
|
||||||
@@ -104,6 +103,5 @@ export function registerIpcHandlers(): void {
|
|||||||
registerMaterialTypeHandlers()
|
registerMaterialTypeHandlers()
|
||||||
registerUserErpConfigHandlers()
|
registerUserErpConfigHandlers()
|
||||||
registerLoggerHandlers()
|
registerLoggerHandlers()
|
||||||
registerReportHandlers()
|
|
||||||
log.info('All IPC handlers registered')
|
log.info('All IPC handlers registered')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,183 +0,0 @@
|
|||||||
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')
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ import type { UserType, ConnectionTestResult, SaveSettingsResult } from '../type
|
|||||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||||
import { ValidationError } from '../types/errors'
|
import { ValidationError } from '../types/errors'
|
||||||
import { withErrorHandling, type IpcResult } from './index'
|
import { withErrorHandling, type IpcResult } from './index'
|
||||||
import type { CleanerConfig } from '../types/config.schema'
|
|
||||||
|
|
||||||
const log = createLogger('SettingsHandler')
|
const log = createLogger('SettingsHandler')
|
||||||
|
|
||||||
@@ -175,26 +174,4 @@ export function registerSettingsHandlers(): void {
|
|||||||
}, 'settings:testDbConnection')
|
}, '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')
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -296,8 +296,7 @@ export function registerValidationHandlers(): void {
|
|||||||
if (sourceNumbers.length === 0) {
|
if (sourceNumbers.length === 0) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error:
|
error: '共享的 Production ID 没有找到对应的订单数据。请确保在数据提取页面输入了有效的 Production ID 并成功获取了订单数据。',
|
||||||
'共享的 Production ID 没有找到对应的订单数据。请确保在数据提取页面输入了有效的 Production ID 并成功获取了订单数据。',
|
|
||||||
stats: {
|
stats: {
|
||||||
totalRecords: 0,
|
totalRecords: 0,
|
||||||
matchedCount: 0,
|
matchedCount: 0,
|
||||||
@@ -316,8 +315,7 @@ export function registerValidationHandlers(): void {
|
|||||||
if (sourceNumbers.length === 0) {
|
if (sourceNumbers.length === 0) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error:
|
error: '文件中的 Production ID 没有找到对应的订单数据。请检查 Production ID 是否正确,或确保数据库中有对应的订单数据。',
|
||||||
'文件中的 Production ID 没有找到对应的订单数据。请检查 Production ID 是否正确,或确保数据库中有对应的订单数据。',
|
|
||||||
stats: {
|
stats: {
|
||||||
totalRecords: 0,
|
totalRecords: 0,
|
||||||
matchedCount: 0,
|
matchedCount: 0,
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ export const CleanerInputSchema = z.object({
|
|||||||
.min(1, 'At least one order number is required'),
|
.min(1, 'At least one order number is required'),
|
||||||
materialCodes: z.array(z.string().min(1, 'Material code cannot be empty')),
|
materialCodes: z.array(z.string().min(1, 'Material code cannot be empty')),
|
||||||
dryRun: z.boolean(),
|
dryRun: z.boolean(),
|
||||||
queryBatchSize: z.number().int().min(1).max(100).optional().default(100),
|
concurrency: z.number().int().min(1).max(20).optional()
|
||||||
processConcurrency: z.number().int().min(1).max(20).optional().default(1)
|
|
||||||
// Note: onProgress is a function, not validated via Zod
|
// Note: onProgress is a function, not validated via Zod
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -81,10 +81,6 @@ const DEFAULT_CONFIG: FullConfig = {
|
|||||||
enableCrud: false,
|
enableCrud: false,
|
||||||
defaultManager: ''
|
defaultManager: ''
|
||||||
},
|
},
|
||||||
cleaner: {
|
|
||||||
queryBatchSize: 100,
|
|
||||||
processConcurrency: 1
|
|
||||||
},
|
|
||||||
orderResolution: {
|
orderResolution: {
|
||||||
tableName: '',
|
tableName: '',
|
||||||
productionIdField: '',
|
productionIdField: '',
|
||||||
@@ -94,14 +90,6 @@ const DEFAULT_CONFIG: FullConfig = {
|
|||||||
level: 'info',
|
level: 'info',
|
||||||
auditRetention: 30,
|
auditRetention: 30,
|
||||||
appRetention: 14
|
appRetention: 14
|
||||||
},
|
|
||||||
rustfs: {
|
|
||||||
enabled: false,
|
|
||||||
endpoint: '',
|
|
||||||
accessKey: '',
|
|
||||||
secretKey: '',
|
|
||||||
bucket: 'erpauto',
|
|
||||||
region: 'us-east-1'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -132,12 +132,10 @@ export class OrderNumberResolver {
|
|||||||
let params: any[]
|
let params: any[]
|
||||||
|
|
||||||
if (this.dbService.type === 'sqlserver') {
|
if (this.dbService.type === 'sqlserver') {
|
||||||
// 使用 COLLATE 指定不区分大小写的排序规则
|
sql = `SELECT TOP 1 [${dbConfig.FIELD_ORDER_NUMBER}] FROM ${tableName} WHERE [${dbConfig.FIELD_PRODUCTION_ID}] = @p0`
|
||||||
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]
|
params = [productionId]
|
||||||
} else {
|
} else {
|
||||||
// MySQL 默认不区分大小写,但显式使用 UPPER 确保一致性
|
sql = `SELECT \`${dbConfig.FIELD_ORDER_NUMBER}\` FROM \`${tableName}\` WHERE \`${dbConfig.FIELD_PRODUCTION_ID}\` = ? LIMIT 1`
|
||||||
sql = `SELECT \`${dbConfig.FIELD_ORDER_NUMBER}\` FROM \`${tableName}\` WHERE UPPER(\`${dbConfig.FIELD_PRODUCTION_ID}\`) = UPPER(?) LIMIT 1`
|
|
||||||
params = [productionId]
|
params = [productionId]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,23 +169,16 @@ export class OrderNumberResolver {
|
|||||||
return new Map()
|
return new Map()
|
||||||
}
|
}
|
||||||
|
|
||||||
// P1: Deduplicate input productionIds to avoid redundant queries
|
|
||||||
const uniqueProductionIds = [...new Set(productionIds)]
|
|
||||||
|
|
||||||
// Use parameterized query to prevent SQL injection
|
// Use parameterized query to prevent SQL injection
|
||||||
const placeholders = uniqueProductionIds.map((_, i) => `@p${i}`).join(', ')
|
const placeholders = productionIds.map((_, i) => `@p${i}`).join(', ')
|
||||||
const params = uniqueProductionIds
|
const params = productionIds
|
||||||
|
|
||||||
let sql: string
|
let sql: string
|
||||||
if (this.dbService.type === 'sqlserver') {
|
if (this.dbService.type === 'sqlserver') {
|
||||||
// P0: Use DISTINCT to prevent duplicates from one-to-many relationships
|
sql = `SELECT [${dbConfig.FIELD_PRODUCTION_ID}], [${dbConfig.FIELD_ORDER_NUMBER}] FROM ${tableName} WHERE [${dbConfig.FIELD_PRODUCTION_ID}] IN (${placeholders})`
|
||||||
// 使用 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 {
|
} else {
|
||||||
const idPlaceholders = uniqueProductionIds.map(() => 'UPPER(?)').join(', ')
|
const idPlaceholders = productionIds.map(() => '?').join(', ')
|
||||||
// P0: Use DISTINCT to prevent duplicates from one-to-many relationships
|
sql = `SELECT \`${dbConfig.FIELD_PRODUCTION_ID}\`, \`${dbConfig.FIELD_ORDER_NUMBER}\` FROM \`${tableName}\` WHERE \`${dbConfig.FIELD_PRODUCTION_ID}\` IN (${idPlaceholders})`
|
||||||
// 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 result = await this.dbService.query(sql, params)
|
||||||
@@ -214,48 +205,11 @@ export class OrderNumberResolver {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve order numbers from mixed input
|
* 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[]> {
|
async resolve(inputs: string[]): Promise<OrderMapping[]> {
|
||||||
// P1: Deduplicate inputs at the input layer to avoid redundant queries
|
const mappings: OrderMapping[] = []
|
||||||
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) {
|
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 }
|
const mapping: OrderMapping = { input, resolved: false }
|
||||||
|
|
||||||
if (this.isOrderNumber(input)) {
|
if (this.isOrderNumber(input)) {
|
||||||
@@ -263,35 +217,35 @@ export class OrderNumberResolver {
|
|||||||
mapping.orderNumber = input
|
mapping.orderNumber = input
|
||||||
mapping.resolved = true
|
mapping.resolved = true
|
||||||
} else if (this.isProductionId(input)) {
|
} else if (this.isProductionId(input)) {
|
||||||
// Is a productionID, lookup from batch mappings (使用小写查找以忽略大小写)
|
// Is a productionID, need to lookup
|
||||||
mapping.productionId = input
|
mapping.productionId = input
|
||||||
const orderNumber = mappings.get(input.toLowerCase())
|
try {
|
||||||
if (orderNumber) {
|
const orderNumber = await this.mapProductionIdToOrderNumber(input)
|
||||||
mapping.orderNumber = orderNumber
|
if (orderNumber) {
|
||||||
mapping.resolved = true
|
mapping.orderNumber = orderNumber
|
||||||
} else {
|
mapping.resolved = true
|
||||||
mapping.error = '未在数据库中找到对应的订单号'
|
} else {
|
||||||
|
mapping.error = '未在数据库中找到对应的订单号'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
mapping.error = error instanceof Error ? error.message : '数据库查询失败'
|
||||||
|
log.warn('Failed to resolve productionID', { productionId: input, error })
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
mapping.error = '格式不识别:既不是有效的生产订单号也不是总排号格式'
|
mapping.error = '格式不识别:既不是有效的生产订单号也不是总排号格式'
|
||||||
}
|
}
|
||||||
|
|
||||||
results.push(mapping)
|
mappings.push(mapping)
|
||||||
}
|
}
|
||||||
|
|
||||||
return results
|
return mappings
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get valid order numbers from mappings
|
* Get valid order numbers from mappings
|
||||||
* P2: Returns deduplicated order numbers
|
|
||||||
*/
|
*/
|
||||||
getValidOrderNumbers(mappings: OrderMapping[]): string[] {
|
getValidOrderNumbers(mappings: OrderMapping[]): string[] {
|
||||||
const validNumbers = mappings
|
return mappings.filter((m) => m.resolved && m.orderNumber).map((m) => m.orderNumber!)
|
||||||
.filter((m) => m.resolved && m.orderNumber)
|
|
||||||
.map((m) => m.orderNumber!)
|
|
||||||
// P2: Deduplicate before returning
|
|
||||||
return [...new Set(validNumbers)]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -341,47 +295,4 @@ export class OrderNumberResolver {
|
|||||||
|
|
||||||
return stats
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,10 +89,6 @@ export class CleanerReportGenerator {
|
|||||||
lines.push(`| **删除物料数** | \`${result.materialsDeleted}\``)
|
lines.push(`| **删除物料数** | \`${result.materialsDeleted}\``)
|
||||||
lines.push(`| **跳过物料数** | \`${result.materialsSkipped}\``)
|
lines.push(`| **跳过物料数** | \`${result.materialsSkipped}\``)
|
||||||
lines.push(`| **错误数量** | \`${result.errors.length}\``)
|
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(`| **执行耗时** | \`${this.formatDuration(options.startTime, options.endTime)}\``)
|
||||||
lines.push('')
|
lines.push('')
|
||||||
lines.push('---')
|
lines.push('---')
|
||||||
@@ -104,12 +100,6 @@ export class CleanerReportGenerator {
|
|||||||
lines.push('| ----------- | ---- | ------ |')
|
lines.push('| ----------- | ---- | ------ |')
|
||||||
lines.push(`| ✅ 成功订单 | ${stats.successCount} | ${stats.successRate.toFixed(1)}% |`)
|
lines.push(`| ✅ 成功订单 | ${stats.successCount} | ${stats.successRate.toFixed(1)}% |`)
|
||||||
lines.push(`| ❌ 失败订单 | ${stats.failureCount} | ${(100 - 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('')
|
||||||
@@ -121,19 +111,10 @@ export class CleanerReportGenerator {
|
|||||||
|
|
||||||
result.details.forEach((detail, index) => {
|
result.details.forEach((detail, index) => {
|
||||||
const orderNum = index + 1
|
const orderNum = index + 1
|
||||||
let status = detail.errors.length > 0 ? '❌ 失败' : '✅ 成功'
|
const 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 errorMsg = detail.errors.length > 0 ? detail.errors[0] : '-'
|
||||||
const retryInfo = detail.retryCount > 0 ? ` [重试${detail.retryCount}次]` : ''
|
|
||||||
lines.push(
|
lines.push(
|
||||||
`| ${orderNum} | \`${detail.orderNumber}\` | ${detail.materialsDeleted} | ${detail.materialsSkipped} | ${status}${retryInfo} | \`${errorMsg}\` |`
|
`| ${orderNum} | \`${detail.orderNumber}\` | ${detail.materialsDeleted} | ${detail.materialsSkipped} | ${status} | \`${errorMsg}\` |`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -194,62 +175,6 @@ export class CleanerReportGenerator {
|
|||||||
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(`**报告生成时间**: \`${this.formatDateTime(options.endTime)}\``)
|
||||||
lines.push('**报表版本**: `v1.0`')
|
lines.push('**报表版本**: `v1.0`')
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
/**
|
|
||||||
* RustFS Service Module
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { RustfsService } from './rustfs-service'
|
|
||||||
export type { UploadResult, DownloadResult, RustfsServiceOptions } from './rustfs-service'
|
|
||||||
@@ -1,376 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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)
|
|
||||||
})
|
|
||||||
@@ -16,8 +16,7 @@ export interface CleanerInput {
|
|||||||
materialCodes: string[]
|
materialCodes: string[]
|
||||||
dryRun: boolean
|
dryRun: boolean
|
||||||
headless?: boolean
|
headless?: boolean
|
||||||
queryBatchSize?: number
|
concurrency?: number
|
||||||
processConcurrency?: number
|
|
||||||
onProgress?: (message: string, progress?: number, extra?: Partial<CleanerProgress>) => void
|
onProgress?: (message: string, progress?: number, extra?: Partial<CleanerProgress>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,9 +26,6 @@ export interface CleanerResult {
|
|||||||
materialsSkipped: number
|
materialsSkipped: number
|
||||||
errors: string[]
|
errors: string[]
|
||||||
details: OrderCleanDetail[]
|
details: OrderCleanDetail[]
|
||||||
// Retry statistics
|
|
||||||
retriedOrders: number
|
|
||||||
successfulRetries: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkippedMaterial {
|
export interface SkippedMaterial {
|
||||||
@@ -39,23 +35,12 @@ export interface SkippedMaterial {
|
|||||||
reason: string
|
reason: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RetryAttempt {
|
|
||||||
attempt: number
|
|
||||||
error: string
|
|
||||||
timestamp: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OrderCleanDetail {
|
export interface OrderCleanDetail {
|
||||||
orderNumber: string
|
orderNumber: string
|
||||||
materialsDeleted: number
|
materialsDeleted: number
|
||||||
materialsSkipped: number
|
materialsSkipped: number
|
||||||
errors: string[]
|
errors: string[]
|
||||||
skippedMaterials: SkippedMaterial[]
|
skippedMaterials: SkippedMaterial[]
|
||||||
// Retry-related fields
|
|
||||||
retryCount: number
|
|
||||||
retryAttempts?: RetryAttempt[]
|
|
||||||
retriedAt?: number
|
|
||||||
retrySuccess?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -97,15 +97,6 @@ export const validationConfigSchema = z.object({
|
|||||||
defaultManager: z.string().default('')
|
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
|
* 订单号解析配置 Schema
|
||||||
*/
|
*/
|
||||||
@@ -131,18 +122,6 @@ export const loggingConfigSchema = z.object({
|
|||||||
appRetention: z.number().int().min(1).max(365).default(14)
|
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
|
* 完整应用配置 Schema
|
||||||
*/
|
*/
|
||||||
@@ -152,10 +131,8 @@ export const fullConfigSchema = z.object({
|
|||||||
paths: pathsConfigSchema,
|
paths: pathsConfigSchema,
|
||||||
extraction: extractionConfigSchema,
|
extraction: extractionConfigSchema,
|
||||||
validation: validationConfigSchema,
|
validation: validationConfigSchema,
|
||||||
cleaner: cleanerConfigSchema,
|
|
||||||
orderResolution: orderResolutionSchema,
|
orderResolution: orderResolutionSchema,
|
||||||
logging: loggingConfigSchema,
|
logging: loggingConfigSchema
|
||||||
rustfs: rustfsConfigSchema.optional()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -167,7 +144,6 @@ export type MySqlConfig = z.infer<typeof mysqlConfigSchema>
|
|||||||
export type SqlServerConfig = z.infer<typeof sqlServerConfigSchema>
|
export type SqlServerConfig = z.infer<typeof sqlServerConfigSchema>
|
||||||
export type ErpSystemConfig = z.infer<typeof erpSystemConfigSchema>
|
export type ErpSystemConfig = z.infer<typeof erpSystemConfigSchema>
|
||||||
export type LoggingConfig = z.infer<typeof loggingConfigSchema>
|
export type LoggingConfig = z.infer<typeof loggingConfigSchema>
|
||||||
export type RustfsConfig = z.infer<typeof rustfsConfigSchema>
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证并解析配置
|
* 验证并解析配置
|
||||||
|
|||||||
@@ -168,25 +168,3 @@ export interface DatabaseAPI {
|
|||||||
params?: Record<string, unknown>
|
params?: Record<string, unknown>
|
||||||
) => Promise<IpcResult<SqlServerQueryResult>>
|
) => Promise<IpcResult<SqlServerQueryResult>>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Report service APIs
|
|
||||||
*/
|
|
||||||
export interface ReportAPI {
|
|
||||||
/**
|
|
||||||
* List all reports across all users (Admin only typically)
|
|
||||||
*/
|
|
||||||
listAll: () => Promise<IpcResult<{ key: string; filename: string; username: string; lastModified?: Date; size?: number }[]>>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List reports for a specific user
|
|
||||||
* @param username - Username to list reports for
|
|
||||||
*/
|
|
||||||
listByUser: (username: string) => Promise<IpcResult<{ key: string; filename: string; username: string; lastModified?: Date; size?: number }[]>>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Download a specific report by key
|
|
||||||
* @param key - Report object key in RustFS
|
|
||||||
*/
|
|
||||||
download: (key: string) => Promise<IpcResult<string>>
|
|
||||||
}
|
|
||||||
|
|||||||
11
src/preload/index.d.ts
vendored
11
src/preload/index.d.ts
vendored
@@ -1,4 +1,4 @@
|
|||||||
import type { FileAPI, ExtractorAPI, CleanerAPI, DatabaseAPI, ReportAPI } from '../main/types/ipc-api.types'
|
import type { FileAPI, ExtractorAPI, CleanerAPI, DatabaseAPI } from '../main/types/ipc-api.types'
|
||||||
import type { ResolverInput, ResolverResponse } from '../main/ipc/resolver-handler'
|
import type { ResolverInput, ResolverResponse } from '../main/ipc/resolver-handler'
|
||||||
import type { UserInfo } from '../main/types/user.types'
|
import type { UserInfo } from '../main/types/user.types'
|
||||||
import type {
|
import type {
|
||||||
@@ -21,7 +21,6 @@ import type {
|
|||||||
} from '../main/types/settings.types'
|
} from '../main/types/settings.types'
|
||||||
import type { IpcResult } from '../main/ipc'
|
import type { IpcResult } from '../main/ipc'
|
||||||
import type { LogLevel } from '../shared/ipc-channels'
|
import type { LogLevel } from '../shared/ipc-channels'
|
||||||
import type { CleanerConfig } from '../main/types/config.schema'
|
|
||||||
|
|
||||||
export interface ResolverAPI {
|
export interface ResolverAPI {
|
||||||
resolve: (input: ResolverInput) => Promise<IpcResult<ResolverResponse>>
|
resolve: (input: ResolverInput) => Promise<IpcResult<ResolverResponse>>
|
||||||
@@ -47,7 +46,6 @@ export interface ValidationAPI {
|
|||||||
validate: (request: ValidationRequest) => Promise<IpcResult<ValidationResponse>>
|
validate: (request: ValidationRequest) => Promise<IpcResult<ValidationResponse>>
|
||||||
setSharedProductionIds: (productionIds: string[]) => Promise<IpcResult<void>>
|
setSharedProductionIds: (productionIds: string[]) => Promise<IpcResult<void>>
|
||||||
getSharedProductionIds: () => Promise<IpcResult<{ productionIds: string[] }>>
|
getSharedProductionIds: () => Promise<IpcResult<{ productionIds: string[] }>>
|
||||||
clearSharedProductionIds: () => Promise<IpcResult<void>>
|
|
||||||
getCleanerData: () => Promise<
|
getCleanerData: () => Promise<
|
||||||
IpcResult<{
|
IpcResult<{
|
||||||
orderNumbers: string[]
|
orderNumbers: string[]
|
||||||
@@ -111,11 +109,6 @@ export interface UserErpConfigAPI {
|
|||||||
getAll: () => Promise<IpcResult<Array<{ username: string; erpUrl: string; erpUsername: string }>>>
|
getAll: () => Promise<IpcResult<Array<{ username: string; erpUrl: string; erpUsername: string }>>>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigAPI {
|
|
||||||
getCleaner: () => Promise<IpcResult<CleanerConfig>>
|
|
||||||
updateCleaner: (updates: Partial<CleanerConfig>) => Promise<IpcResult<CleanerConfig>>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoggerAPI {
|
export interface LoggerAPI {
|
||||||
log: (level: LogLevel, message: string, context?: Record<string, unknown>) => void
|
log: (level: LogLevel, message: string, context?: Record<string, unknown>) => void
|
||||||
}
|
}
|
||||||
@@ -143,9 +136,7 @@ declare global {
|
|||||||
settings: SettingsAPI
|
settings: SettingsAPI
|
||||||
materialType: MaterialTypeAPI
|
materialType: MaterialTypeAPI
|
||||||
userErpConfig: UserErpConfigAPI
|
userErpConfig: UserErpConfigAPI
|
||||||
config: ConfigAPI
|
|
||||||
logger: LoggerAPI
|
logger: LoggerAPI
|
||||||
report: ReportAPI
|
|
||||||
}
|
}
|
||||||
api: unknown
|
api: unknown
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import type {
|
|||||||
} from '../main/types/validation.types'
|
} from '../main/types/validation.types'
|
||||||
import type { IpcResult } from '../main/ipc'
|
import type { IpcResult } from '../main/ipc'
|
||||||
import { IPC_CHANNELS, type LogLevel } from '../shared/ipc-channels'
|
import { IPC_CHANNELS, type LogLevel } from '../shared/ipc-channels'
|
||||||
import type { CleanerConfig } from '../main/types/config.schema'
|
|
||||||
|
|
||||||
type ErpSettingsPayload = {
|
type ErpSettingsPayload = {
|
||||||
erp?: {
|
erp?: {
|
||||||
@@ -196,12 +195,6 @@ const api = {
|
|||||||
getAll: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.USER_ERP_CONFIG_GET_ALL)
|
getAll: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.USER_ERP_CONFIG_GET_ALL)
|
||||||
},
|
},
|
||||||
|
|
||||||
config: {
|
|
||||||
getCleaner: (): Promise<IpcResult<CleanerConfig>> => invokeIpc(IPC_CHANNELS.CONFIG_GET_CLEANER),
|
|
||||||
updateCleaner: (updates: Partial<CleanerConfig>): Promise<IpcResult<CleanerConfig>> =>
|
|
||||||
invokeIpc(IPC_CHANNELS.CONFIG_UPDATE_CLEANER, updates)
|
|
||||||
},
|
|
||||||
|
|
||||||
logger: {
|
logger: {
|
||||||
log: (level: LogLevel, message: string, context?: Record<string, unknown>): void => {
|
log: (level: LogLevel, message: string, context?: Record<string, unknown>): void => {
|
||||||
ipcRenderer.send(IPC_CHANNELS.LOGGER_FORWARD, {
|
ipcRenderer.send(IPC_CHANNELS.LOGGER_FORWARD, {
|
||||||
@@ -211,13 +204,6 @@ const api = {
|
|||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
|
||||||
|
|
||||||
report: {
|
|
||||||
listAll: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.REPORT_LIST_ALL),
|
|
||||||
listByUser: (username: string): Promise<IpcResult> =>
|
|
||||||
invokeIpc(IPC_CHANNELS.REPORT_LIST_BY_USER, username),
|
|
||||||
download: (key: string): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.REPORT_DOWNLOAD, key)
|
|
||||||
}
|
}
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
|||||||
@@ -332,17 +332,12 @@ function App(): React.JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex flex-col cursor-pointer"
|
className="flex items-center gap-2 text-white font-bold text-lg cursor-pointer"
|
||||||
onClick={() => setCurrentPage('home')}
|
onClick={() => setCurrentPage('home')}
|
||||||
style={{ WebkitAppRegion: 'no-drag' } as any}
|
style={{ WebkitAppRegion: 'no-drag' } as any}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 text-white font-bold text-lg">
|
<LayoutDashboard size={22} className="text-blue-500" />
|
||||||
<LayoutDashboard size={22} className="text-blue-500" />
|
<span>ERP Auto</span>
|
||||||
<span>ERP Auto</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-slate-400 ml-7">
|
|
||||||
{__APP_VERSION__}({__GIT_HASH__})
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -34,9 +34,6 @@ interface ExecutionReportDialogProps {
|
|||||||
progress?: CleanerProgress | null
|
progress?: CleanerProgress | null
|
||||||
startTime?: number | null
|
startTime?: number | null
|
||||||
triggerRef?: React.RefObject<HTMLElement | null>
|
triggerRef?: React.RefObject<HTMLElement | null>
|
||||||
// Retry-related props
|
|
||||||
retriedOrders?: number
|
|
||||||
successfulRetries?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
|
export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
|
||||||
@@ -50,18 +47,14 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
|
|||||||
isExecuting = false,
|
isExecuting = false,
|
||||||
progress = null,
|
progress = null,
|
||||||
startTime = null,
|
startTime = null,
|
||||||
triggerRef,
|
triggerRef
|
||||||
retriedOrders = 0,
|
|
||||||
successfulRetries = 0
|
|
||||||
}) => {
|
}) => {
|
||||||
const [now, setNow] = React.useState(() => Date.now())
|
const [now, setNow] = React.useState(() => Date.now())
|
||||||
|
|
||||||
const hasErrors = errors.length > 0
|
const hasErrors = errors.length > 0
|
||||||
const hasRetries = retriedOrders > 0
|
|
||||||
const showProgress = isExecuting && progress
|
const showProgress = isExecuting && progress
|
||||||
const isProgressing = !!showProgress
|
const isProgressing = !!showProgress
|
||||||
|
|
||||||
// Update timer during progress
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!showProgress || !startTime) return
|
if (!showProgress || !startTime) return
|
||||||
|
|
||||||
@@ -72,27 +65,6 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
|
|||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [showProgress, startTime])
|
}, [showProgress, startTime])
|
||||||
|
|
||||||
// Update time when execution completes
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (showProgress === false && startTime && isExecuting === false) {
|
|
||||||
setNow(Date.now())
|
|
||||||
}
|
|
||||||
}, [showProgress, isExecuting, startTime])
|
|
||||||
|
|
||||||
const elapsedTime = React.useMemo(() => {
|
|
||||||
if (!startTime) return null
|
|
||||||
|
|
||||||
const elapsedMs = now - startTime
|
|
||||||
const elapsedSeconds = Math.floor(elapsedMs / 1000)
|
|
||||||
const minutes = Math.floor(elapsedSeconds / 60)
|
|
||||||
const seconds = elapsedSeconds % 60
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalSeconds: elapsedSeconds,
|
|
||||||
formatted: minutes > 0 ? `${minutes}分${seconds}秒` : `${seconds}秒`
|
|
||||||
}
|
|
||||||
}, [startTime, now])
|
|
||||||
|
|
||||||
const estimatedTime = React.useMemo(() => {
|
const estimatedTime = React.useMemo(() => {
|
||||||
if (!showProgress || !startTime || !progress) return null
|
if (!showProgress || !startTime || !progress) return null
|
||||||
|
|
||||||
@@ -138,7 +110,6 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
|
|||||||
triggerRef={triggerRef}
|
triggerRef={triggerRef}
|
||||||
isAlertDialog={isProgressing}
|
isAlertDialog={isProgressing}
|
||||||
disableEscapeKey={isProgressing}
|
disableEscapeKey={isProgressing}
|
||||||
disableBackdropClick={isProgressing}
|
|
||||||
ariaDescribedBy={isProgressing ? 'execution-dialog-progress-desc' : undefined}
|
ariaDescribedBy={isProgressing ? 'execution-dialog-progress-desc' : undefined}
|
||||||
initialFocusSelector={!isProgressing ? '.btn-report-close' : undefined}
|
initialFocusSelector={!isProgressing ? '.btn-report-close' : undefined}
|
||||||
>
|
>
|
||||||
@@ -227,15 +198,6 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{elapsedTime && (
|
|
||||||
<div className="flex flex-col items-center gap-2 p-3 bg-blue-50 rounded-lg border border-blue-200 mb-2">
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<span className="text-gray-600">已运行</span>
|
|
||||||
<span className="font-semibold text-blue-600">{elapsedTime.formatted}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{estimatedTime && (
|
{estimatedTime && (
|
||||||
<div className="flex flex-col items-center gap-2 p-3 bg-green-50 rounded-lg border border-green-200">
|
<div className="flex flex-col items-center gap-2 p-3 bg-green-50 rounded-lg border border-green-200">
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
@@ -297,71 +259,8 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasRetries && (
|
|
||||||
<>
|
|
||||||
<div className="bg-gray-50 rounded-lg p-3 flex items-center gap-3 border border-gray-200">
|
|
||||||
<div className="w-9 h-9 rounded-lg bg-purple-50 flex items-center justify-center flex-shrink-0">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
className="text-purple-600"
|
|
||||||
>
|
|
||||||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
|
||||||
<path d="M3 3v5h5" />
|
|
||||||
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
|
|
||||||
<path d="M16 21h5v-5" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-xs text-gray-600">重试订单</div>
|
|
||||||
<div className="text-xl font-semibold text-gray-900">{retriedOrders}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-gray-50 rounded-lg p-3 flex items-center gap-3 border border-gray-200">
|
|
||||||
<div className="w-9 h-9 rounded-lg bg-emerald-50 flex items-center justify-center flex-shrink-0">
|
|
||||||
<CheckCircle size={20} className="text-emerald-600" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-xs text-gray-600">成功重试</div>
|
|
||||||
<div className="text-xl font-semibold text-gray-900">{successfulRetries}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{elapsedTime && (
|
|
||||||
<div className="flex items-center justify-center gap-2 p-3 bg-blue-50 rounded-lg border border-blue-200 text-blue-700 text-sm mb-3">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
className="flex-shrink-0"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<polyline points="12 6 12 12 16 14" />
|
|
||||||
</svg>
|
|
||||||
<span>
|
|
||||||
总耗时:<span className="font-semibold">{elapsedTime.formatted}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasErrors && (
|
{hasErrors && (
|
||||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
<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="text-sm font-semibold text-red-600 mb-2">错误详情</div>
|
||||||
|
|||||||
@@ -1,188 +0,0 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
|
||||||
import { X, FileText, Loader2 } from 'lucide-react'
|
|
||||||
import ReactMarkdown from 'react-markdown'
|
|
||||||
import remarkGfm from 'remark-gfm'
|
|
||||||
|
|
||||||
interface ReportMetadata {
|
|
||||||
key: string
|
|
||||||
filename: string
|
|
||||||
username: string
|
|
||||||
lastModified?: Date
|
|
||||||
size?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ReportViewerDialogProps {
|
|
||||||
isOpen: boolean
|
|
||||||
onClose: () => void
|
|
||||||
isAdmin: boolean
|
|
||||||
currentUsername: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ReportViewerDialog: React.FC<ReportViewerDialogProps> = ({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
isAdmin,
|
|
||||||
currentUsername
|
|
||||||
}) => {
|
|
||||||
const [reports, setReports] = useState<ReportMetadata[]>([])
|
|
||||||
const [selectedReportKey, setSelectedReportKey] = useState<string>('')
|
|
||||||
const [reportContent, setReportContent] = useState<string>('')
|
|
||||||
const [isLoadingList, setIsLoadingList] = useState<boolean>(false)
|
|
||||||
const [isLoadingContent, setIsLoadingContent] = useState<boolean>(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
loadReports()
|
|
||||||
} else {
|
|
||||||
// Reset state when closed
|
|
||||||
setReports([])
|
|
||||||
setSelectedReportKey('')
|
|
||||||
setReportContent('')
|
|
||||||
setError(null)
|
|
||||||
}
|
|
||||||
}, [isOpen, isAdmin, currentUsername])
|
|
||||||
|
|
||||||
const loadReports = async () => {
|
|
||||||
setIsLoadingList(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
let result
|
|
||||||
if (isAdmin) {
|
|
||||||
result = await window.electron.report.listAll()
|
|
||||||
} else {
|
|
||||||
result = await window.electron.report.listByUser(currentUsername)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setReports(result.data)
|
|
||||||
} else {
|
|
||||||
setError(result.error || '无法获取报告列表')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError('获取报告列表时发生错误')
|
|
||||||
} finally {
|
|
||||||
setIsLoadingList(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleReportChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
||||||
const key = e.target.value
|
|
||||||
setSelectedReportKey(key)
|
|
||||||
|
|
||||||
if (!key) {
|
|
||||||
setReportContent('')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoadingContent(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const result = await window.electron.report.download(key)
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setReportContent(result.data)
|
|
||||||
} else {
|
|
||||||
setError(result.error || '无法获取报告内容')
|
|
||||||
setReportContent('')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError('获取报告内容时发生错误')
|
|
||||||
setReportContent('')
|
|
||||||
} finally {
|
|
||||||
setIsLoadingContent(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isOpen) return null
|
|
||||||
|
|
||||||
// Format date to local string
|
|
||||||
const formatDate = (dateString?: Date | string) => {
|
|
||||||
if (!dateString) return '未知时间'
|
|
||||||
const date = typeof dateString === 'string' ? new Date(dateString) : dateString
|
|
||||||
return date.toLocaleString('zh-CN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/50 backdrop-blur-sm animate-in fade-in duration-200">
|
|
||||||
<div className="bg-white rounded-2xl shadow-2xl w-[900px] max-w-[90vw] h-[80vh] flex flex-col border border-slate-200 overflow-hidden animate-in zoom-in-95 duration-200">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 bg-slate-50 flex-shrink-0">
|
|
||||||
<div className="flex items-center gap-2 text-slate-800">
|
|
||||||
<FileText size={20} className="text-blue-600" />
|
|
||||||
<h2 className="text-lg font-semibold">执行报告浏览器</h2>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="text-slate-400 hover:text-slate-600 hover:bg-slate-200/50 p-1.5 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<X size={20} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Controls */}
|
|
||||||
<div className="px-6 py-4 border-b border-slate-200 bg-white flex-shrink-0">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<label className="text-sm font-medium text-slate-700 flex-shrink-0">选择报告:</label>
|
|
||||||
<div className="relative flex-1 max-w-2xl">
|
|
||||||
<select
|
|
||||||
value={selectedReportKey}
|
|
||||||
onChange={handleReportChange}
|
|
||||||
disabled={isLoadingList}
|
|
||||||
className="w-full appearance-none bg-slate-50 border border-slate-300 text-slate-700 py-2 pl-3 pr-10 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 disabled:opacity-50 text-sm"
|
|
||||||
>
|
|
||||||
<option value="">请选择要查看的报告...</option>
|
|
||||||
{reports.map((report) => (
|
|
||||||
<option key={report.key} value={report.key}>
|
|
||||||
{isAdmin ? `[${report.username}] ` : ''}
|
|
||||||
{report.filename} ({formatDate(report.lastModified)})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-slate-500">
|
|
||||||
<svg
|
|
||||||
className="fill-current h-4 w-4"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{isLoadingList && <Loader2 size={16} className="text-blue-500 animate-spin" />}
|
|
||||||
</div>
|
|
||||||
{error && <div className="mt-3 text-sm text-red-600 flex items-center gap-1.5 bg-red-50 p-2 rounded">{error}</div>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 bg-slate-50 overflow-hidden relative">
|
|
||||||
{isLoadingContent ? (
|
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-slate-500 bg-white/80 z-10">
|
|
||||||
<Loader2 size={32} className="animate-spin text-blue-500 mb-4" />
|
|
||||||
<p>正在加载报告内容...</p>
|
|
||||||
</div>
|
|
||||||
) : reportContent ? (
|
|
||||||
<div className="h-full overflow-y-auto p-8">
|
|
||||||
<div className="prose prose-slate prose-sm max-w-none bg-white p-8 rounded-xl shadow-sm border border-slate-200">
|
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{reportContent}</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-slate-400">
|
|
||||||
<FileText size={48} className="mb-4 text-slate-300 opacity-50" />
|
|
||||||
<p>请在上方选择一个报告进行浏览</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ReportViewerDialog
|
|
||||||
@@ -107,7 +107,11 @@ export function ConfirmDialog({
|
|||||||
<Button variant="secondary" onClick={onCancel}>
|
<Button variant="secondary" onClick={onCancel}>
|
||||||
{cancelText}
|
{cancelText}
|
||||||
</Button>
|
</Button>
|
||||||
<Button data-autofocus="true" variant={styles.buttonVariant} onClick={onConfirm}>
|
<Button
|
||||||
|
data-autofocus="true"
|
||||||
|
variant={styles.buttonVariant}
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
{confirmText}
|
{confirmText}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,14 +125,14 @@ export function ConfirmDialog({
|
|||||||
*/
|
*/
|
||||||
export function useConfirmDialog() {
|
export function useConfirmDialog() {
|
||||||
const [config, setConfig] = useState<
|
const [config, setConfig] = useState<
|
||||||
| (Omit<ConfirmDialogProps, 'isOpen' | 'onConfirm' | 'onCancel'> & {
|
(Omit<ConfirmDialogProps, 'isOpen' | 'onConfirm' | 'onCancel'> & {
|
||||||
resolve: (value: boolean) => void
|
resolve: (value: boolean) => void
|
||||||
})
|
}) | null>(null)
|
||||||
| null
|
|
||||||
>(null)
|
|
||||||
|
|
||||||
const confirm = useCallback(
|
const confirm = useCallback(
|
||||||
(options: Omit<ConfirmDialogProps, 'isOpen' | 'onConfirm' | 'onCancel'>): Promise<boolean> => {
|
(
|
||||||
|
options: Omit<ConfirmDialogProps, 'isOpen' | 'onConfirm' | 'onCancel'>
|
||||||
|
): Promise<boolean> => {
|
||||||
return new Promise<boolean>((resolve) => {
|
return new Promise<boolean>((resolve) => {
|
||||||
setConfig({
|
setConfig({
|
||||||
...options,
|
...options,
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ interface ModalProps {
|
|||||||
isAlertDialog?: boolean
|
isAlertDialog?: boolean
|
||||||
/** Whether to disable escape key handling (e.g., during execution) */
|
/** Whether to disable escape key handling (e.g., during execution) */
|
||||||
disableEscapeKey?: boolean
|
disableEscapeKey?: boolean
|
||||||
/** Whether to disable closing when clicking on backdrop (e.g., during execution) */
|
|
||||||
disableBackdropClick?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sizeStyles: Record<string, string> = {
|
const sizeStyles: Record<string, string> = {
|
||||||
@@ -53,8 +51,7 @@ export function Modal({
|
|||||||
initialFocusSelector,
|
initialFocusSelector,
|
||||||
ariaDescribedBy,
|
ariaDescribedBy,
|
||||||
isAlertDialog = false,
|
isAlertDialog = false,
|
||||||
disableEscapeKey = false,
|
disableEscapeKey = false
|
||||||
disableBackdropClick = false
|
|
||||||
}: ModalProps): React.JSX.Element | null {
|
}: ModalProps): React.JSX.Element | null {
|
||||||
const dialogRef = useRef<HTMLDivElement>(null)
|
const dialogRef = useRef<HTMLDivElement>(null)
|
||||||
const [generatedId] = useState(
|
const [generatedId] = useState(
|
||||||
@@ -90,7 +87,7 @@ export function Modal({
|
|||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
|
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
|
||||||
onClick={disableBackdropClick ? undefined : onClose}
|
onClick={onClose}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export function Toast() {
|
|||||||
if (toasts.length === 0) return null
|
if (toasts.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 flex flex-col gap-2">
|
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
||||||
{toasts.map((toast) => (
|
{toasts.map((toast) => (
|
||||||
<ToastItem
|
<ToastItem
|
||||||
key={toast.id}
|
key={toast.id}
|
||||||
|
|||||||
3
src/renderer/src/env.d.ts
vendored
3
src/renderer/src/env.d.ts
vendored
@@ -1,4 +1 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
declare const __APP_VERSION__: string
|
|
||||||
declare const __GIT_HASH__: string
|
|
||||||
|
|||||||
@@ -65,8 +65,6 @@ export function useCleaner() {
|
|||||||
materialsDeleted: number
|
materialsDeleted: number
|
||||||
materialsSkipped: number
|
materialsSkipped: number
|
||||||
errors: string[]
|
errors: string[]
|
||||||
retriedOrders?: number
|
|
||||||
successfulRetries?: number
|
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
// Progress state
|
// Progress state
|
||||||
@@ -78,8 +76,10 @@ export function useCleaner() {
|
|||||||
const saved = sessionStorage.getItem('cleaner_headless')
|
const saved = sessionStorage.getItem('cleaner_headless')
|
||||||
return saved ? saved === 'true' : true
|
return saved ? saved === 'true' : true
|
||||||
})
|
})
|
||||||
const [queryBatchSize, setQueryBatchSize] = useState(100)
|
const [concurrency, setConcurrency] = useState(() => {
|
||||||
const [processConcurrency, setProcessConcurrency] = useState(1)
|
const saved = sessionStorage.getItem('cleaner_concurrency')
|
||||||
|
return saved ? parseInt(saved, 10) : 1
|
||||||
|
})
|
||||||
const [showSettingsMenu, setShowSettingsMenu] = useState(false)
|
const [showSettingsMenu, setShowSettingsMenu] = useState(false)
|
||||||
|
|
||||||
// Inline editing state for manager field (Admin only)
|
// Inline editing state for manager field (Admin only)
|
||||||
@@ -164,22 +164,6 @@ export function useCleaner() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Load cleaner config from config.yaml on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const loadCleanerConfig = async () => {
|
|
||||||
try {
|
|
||||||
const result = await window.electron.config.getCleaner()
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setQueryBatchSize(result.data.queryBatchSize)
|
|
||||||
setProcessConcurrency(result.data.processConcurrency)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load cleaner config:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loadCleanerConfig()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
sessionStorage.setItem('cleaner_dryRun', dryRun.toString())
|
sessionStorage.setItem('cleaner_dryRun', dryRun.toString())
|
||||||
}, [dryRun])
|
}, [dryRun])
|
||||||
@@ -188,15 +172,9 @@ export function useCleaner() {
|
|||||||
sessionStorage.setItem('cleaner_headless', headless.toString())
|
sessionStorage.setItem('cleaner_headless', headless.toString())
|
||||||
}, [headless])
|
}, [headless])
|
||||||
|
|
||||||
const updateProcessConcurrency = async (value: number) => {
|
useEffect(() => {
|
||||||
const clamped = Math.max(1, Math.min(20, value))
|
sessionStorage.setItem('cleaner_concurrency', concurrency.toString())
|
||||||
setProcessConcurrency(clamped)
|
}, [concurrency])
|
||||||
try {
|
|
||||||
await window.electron.config.updateCleaner({ processConcurrency: clamped })
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to update cleaner config:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
sessionStorage.setItem('cleaner_validationMode', valMode)
|
sessionStorage.setItem('cleaner_validationMode', valMode)
|
||||||
@@ -448,8 +426,7 @@ export function useCleaner() {
|
|||||||
materialCodes: materialCodeList,
|
materialCodes: materialCodeList,
|
||||||
dryRun,
|
dryRun,
|
||||||
headless,
|
headless,
|
||||||
queryBatchSize,
|
concurrency
|
||||||
processConcurrency
|
|
||||||
})
|
})
|
||||||
const cleanerRunData = response.success ? (response.data as any) : null
|
const cleanerRunData = response.success ? (response.data as any) : null
|
||||||
|
|
||||||
@@ -458,9 +435,7 @@ export function useCleaner() {
|
|||||||
ordersProcessed: cleanerRunData.ordersProcessed,
|
ordersProcessed: cleanerRunData.ordersProcessed,
|
||||||
materialsDeleted: cleanerRunData.materialsDeleted,
|
materialsDeleted: cleanerRunData.materialsDeleted,
|
||||||
materialsSkipped: cleanerRunData.materialsSkipped,
|
materialsSkipped: cleanerRunData.materialsSkipped,
|
||||||
errors: cleanerRunData.errors,
|
errors: cleanerRunData.errors
|
||||||
retriedOrders: cleanerRunData.retriedOrders,
|
|
||||||
successfulRetries: cleanerRunData.successfulRetries
|
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
throw new Error(response.error || '清理失败')
|
throw new Error(response.error || '清理失败')
|
||||||
@@ -471,15 +446,10 @@ export function useCleaner() {
|
|||||||
setIsRunning(false)
|
setIsRunning(false)
|
||||||
setIsExecuting(false)
|
setIsExecuting(false)
|
||||||
setProgress(null)
|
setProgress(null)
|
||||||
// Note: Don't clear startTime here - it's needed for the execution report dialog
|
setStartTime(null)
|
||||||
// startTime will be reset when the dialog closes and a new execution starts
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetStartTime = useCallback(() => {
|
|
||||||
setStartTime(null)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleExportResults = async () => {
|
const handleExportResults = async () => {
|
||||||
if (filteredResults.length === 0) {
|
if (filteredResults.length === 0) {
|
||||||
showWarning('没有数据可导出')
|
showWarning('没有数据可导出')
|
||||||
@@ -538,11 +508,8 @@ export function useCleaner() {
|
|||||||
setIsTypeDialogOpen,
|
setIsTypeDialogOpen,
|
||||||
headless,
|
headless,
|
||||||
setHeadless,
|
setHeadless,
|
||||||
queryBatchSize,
|
concurrency,
|
||||||
setQueryBatchSize,
|
setConcurrency,
|
||||||
processConcurrency,
|
|
||||||
setProcessConcurrency,
|
|
||||||
updateProcessConcurrency,
|
|
||||||
showSettingsMenu,
|
showSettingsMenu,
|
||||||
setShowSettingsMenu,
|
setShowSettingsMenu,
|
||||||
filteredResults,
|
filteredResults,
|
||||||
@@ -559,7 +526,6 @@ export function useCleaner() {
|
|||||||
handleAssignManagerOnSelect,
|
handleAssignManagerOnSelect,
|
||||||
progress,
|
progress,
|
||||||
startTime,
|
startTime,
|
||||||
resetStartTime,
|
|
||||||
handleValidation,
|
handleValidation,
|
||||||
handleCheckboxToggle,
|
handleCheckboxToggle,
|
||||||
handleConfirmDeletion,
|
handleConfirmDeletion,
|
||||||
|
|||||||
@@ -239,9 +239,7 @@ export function useDialogFocus(options: UseDialogFocusOptions): UseDialogFocusRe
|
|||||||
|
|
||||||
if (style.visibility === 'hidden') {
|
if (style.visibility === 'hidden') {
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
console.warn(
|
console.warn('[useDialogFocus] Trigger element is visibility: hidden, cannot restore focus')
|
||||||
'[useDialogFocus] Trigger element is visibility: hidden, cannot restore focus'
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,10 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Settings2,
|
Settings2,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet
|
||||||
FileText
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import MaterialTypeManagementDialog from '../components/MaterialTypeManagementDialog'
|
import MaterialTypeManagementDialog from '../components/MaterialTypeManagementDialog'
|
||||||
import ExecutionReportDialog from '../components/ExecutionReportDialog'
|
import ExecutionReportDialog from '../components/ExecutionReportDialog'
|
||||||
import ReportViewerDialog from '../components/ReportViewerDialog'
|
|
||||||
import { ConfirmDialog } from '../components/ui/ConfirmDialog'
|
import { ConfirmDialog } from '../components/ui/ConfirmDialog'
|
||||||
import { useCleaner } from '../hooks/useCleaner'
|
import { useCleaner } from '../hooks/useCleaner'
|
||||||
|
|
||||||
@@ -48,8 +46,8 @@ const CleanerPage: React.FC = () => {
|
|||||||
setIsTypeDialogOpen,
|
setIsTypeDialogOpen,
|
||||||
headless,
|
headless,
|
||||||
setHeadless,
|
setHeadless,
|
||||||
processConcurrency,
|
concurrency,
|
||||||
updateProcessConcurrency,
|
setConcurrency,
|
||||||
showSettingsMenu,
|
showSettingsMenu,
|
||||||
setShowSettingsMenu,
|
setShowSettingsMenu,
|
||||||
filteredResults,
|
filteredResults,
|
||||||
@@ -66,7 +64,6 @@ const CleanerPage: React.FC = () => {
|
|||||||
handleAssignManagerOnSelect,
|
handleAssignManagerOnSelect,
|
||||||
progress,
|
progress,
|
||||||
startTime,
|
startTime,
|
||||||
resetStartTime,
|
|
||||||
handleValidation,
|
handleValidation,
|
||||||
handleCheckboxToggle,
|
handleCheckboxToggle,
|
||||||
handleConfirmDeletion,
|
handleConfirmDeletion,
|
||||||
@@ -75,8 +72,6 @@ const CleanerPage: React.FC = () => {
|
|||||||
confirmDialog
|
confirmDialog
|
||||||
} = useCleaner()
|
} = useCleaner()
|
||||||
|
|
||||||
const [isReportViewerOpen, setIsReportViewerOpen] = React.useState(false)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col xl:flex-row gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
<div className="h-full flex flex-col xl:flex-row gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
{/* 左栏:数据源与执行控制区 (仅 Admin 可见) */}
|
{/* 左栏:数据源与执行控制区 (仅 Admin 可见) */}
|
||||||
@@ -273,12 +268,6 @@ const CleanerPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<FileSpreadsheet size={14} /> {isExporting ? '导出中...' : '导出结果'}
|
<FileSpreadsheet size={14} /> {isExporting ? '导出中...' : '导出结果'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={() => setIsReportViewerOpen(true)}
|
|
||||||
className="text-xs bg-emerald-50 border border-emerald-200 text-emerald-700 px-3 py-1.5 rounded shadow-sm hover:bg-emerald-100 flex items-center gap-1.5 font-medium transition-colors"
|
|
||||||
>
|
|
||||||
<FileText size={14} /> 查看报告
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -471,25 +460,27 @@ const CleanerPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-slate-100 pt-3 space-y-3">
|
<div className="border-t border-slate-100 pt-3">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-sm font-medium text-slate-800">并行处理数量</div>
|
<div>
|
||||||
<div className="text-xs text-slate-500 mt-0.5">
|
<div className="text-sm font-medium text-slate-800">
|
||||||
同时处理详情页数量,范围 1-20
|
并发数量 (Concurrency)
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex items-center gap-3">
|
<div className="text-xs text-slate-500 mt-0.5">
|
||||||
<input
|
同时处理的订单数量 (1-20)
|
||||||
type="range"
|
</div>
|
||||||
min={1}
|
|
||||||
max={20}
|
|
||||||
value={processConcurrency}
|
|
||||||
onChange={(e) => updateProcessConcurrency(Number(e.target.value))}
|
|
||||||
className="flex-1 h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium text-slate-700 w-8 text-center">
|
|
||||||
{processConcurrency}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="20"
|
||||||
|
value={concurrency}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = parseInt(e.target.value, 10)
|
||||||
|
if (!isNaN(val)) setConcurrency(Math.min(Math.max(val, 1), 20))
|
||||||
|
}}
|
||||||
|
className="w-16 ml-4 px-2 py-1 text-sm border border-slate-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -520,10 +511,7 @@ const CleanerPage: React.FC = () => {
|
|||||||
{/* Execution Report Dialog */}
|
{/* Execution Report Dialog */}
|
||||||
<ExecutionReportDialog
|
<ExecutionReportDialog
|
||||||
isOpen={isReportDialogOpen}
|
isOpen={isReportDialogOpen}
|
||||||
onClose={() => {
|
onClose={() => setIsReportDialogOpen(false)}
|
||||||
setIsReportDialogOpen(false)
|
|
||||||
resetStartTime()
|
|
||||||
}}
|
|
||||||
ordersProcessed={reportData?.ordersProcessed}
|
ordersProcessed={reportData?.ordersProcessed}
|
||||||
materialsDeleted={reportData?.materialsDeleted}
|
materialsDeleted={reportData?.materialsDeleted}
|
||||||
materialsSkipped={reportData?.materialsSkipped}
|
materialsSkipped={reportData?.materialsSkipped}
|
||||||
@@ -533,16 +521,6 @@ const CleanerPage: React.FC = () => {
|
|||||||
progress={progress}
|
progress={progress}
|
||||||
startTime={startTime}
|
startTime={startTime}
|
||||||
triggerRef={executeButtonRef}
|
triggerRef={executeButtonRef}
|
||||||
retriedOrders={reportData?.retriedOrders}
|
|
||||||
successfulRetries={reportData?.successfulRetries}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Report Viewer Dialog */}
|
|
||||||
<ReportViewerDialog
|
|
||||||
isOpen={isReportViewerOpen}
|
|
||||||
onClose={() => setIsReportViewerOpen(false)}
|
|
||||||
isAdmin={isAdmin}
|
|
||||||
currentUsername={currentUsername}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Confirmation Dialog */}
|
{/* Confirmation Dialog */}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const ExtractorPage: React.FC = () => {
|
|||||||
window.electron.validation.setSharedProductionIds(orderNumberList)
|
window.electron.validation.setSharedProductionIds(orderNumberList)
|
||||||
} else {
|
} else {
|
||||||
// Clear shared Production IDs when input is cleared
|
// Clear shared Production IDs when input is cleared
|
||||||
window.electron.validation.clearSharedProductionIds()
|
window.electron.validation.setSharedProductionIds([])
|
||||||
}
|
}
|
||||||
}, [orderNumbers])
|
}, [orderNumbers])
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { Settings as SettingsIcon, Save, User, Key } from 'lucide-react'
|
import { Settings as SettingsIcon, Save, User, Key } from 'lucide-react'
|
||||||
import { showSuccess, showError } from '../stores/useAppStore'
|
|
||||||
|
|
||||||
interface ErpCredentials {
|
interface ErpCredentials {
|
||||||
username: string
|
username: string
|
||||||
@@ -14,6 +13,10 @@ const SettingsPage: React.FC = () => {
|
|||||||
})
|
})
|
||||||
const [isModified, setIsModified] = useState(false)
|
const [isModified, setIsModified] = useState(false)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [message, setMessage] = useState<{
|
||||||
|
type: 'success' | 'error' | 'info'
|
||||||
|
text: string
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadCredentials()
|
loadCredentials()
|
||||||
@@ -35,16 +38,21 @@ const SettingsPage: React.FC = () => {
|
|||||||
password: config.erp.password || ''
|
password: config.erp.password || ''
|
||||||
})
|
})
|
||||||
} else if (!response.success) {
|
} else if (!response.success) {
|
||||||
showError(response.error || '加载 ERP 配置失败')
|
showMessage('error', response.error || '加载 ERP 配置失败')
|
||||||
}
|
}
|
||||||
setIsModified(false)
|
setIsModified(false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError('加载 ERP 配置失败')
|
showMessage('error', '加载 ERP 配置失败')
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showMessage = (type: 'success' | 'error' | 'info', text: string) => {
|
||||||
|
setMessage({ type, text })
|
||||||
|
setTimeout(() => setMessage(null), 3000)
|
||||||
|
}
|
||||||
|
|
||||||
const handleSaveCredentials = async () => {
|
const handleSaveCredentials = async () => {
|
||||||
try {
|
try {
|
||||||
// Save ERP credentials to database (current user's config)
|
// Save ERP credentials to database (current user's config)
|
||||||
@@ -60,12 +68,12 @@ const SettingsPage: React.FC = () => {
|
|||||||
|
|
||||||
if (result.success && saveData?.success !== false) {
|
if (result.success && saveData?.success !== false) {
|
||||||
setIsModified(false)
|
setIsModified(false)
|
||||||
showSuccess('ERP 账号密码保存成功')
|
showMessage('success', 'ERP 账号密码保存成功')
|
||||||
} else {
|
} else {
|
||||||
showError(result.error || saveData?.error || '保存失败')
|
showMessage('error', result.error || saveData?.error || '保存失败')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError('保存配置时发生错误')
|
showMessage('error', '保存配置时发生错误')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +90,18 @@ const SettingsPage: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center animate-in fade-in slide-in-from-bottom-4 duration-500 mt-6">
|
<div className="flex justify-center animate-in fade-in slide-in-from-bottom-4 duration-500 mt-6">
|
||||||
|
{message && (
|
||||||
|
<div
|
||||||
|
className={`fixed top-5 left-1/2 -translate-x-1/2 px-6 py-3 rounded-lg shadow-lg z-[10000] text-sm font-medium transition-all ${
|
||||||
|
message.type === 'success'
|
||||||
|
? 'bg-emerald-50 text-emerald-600 border border-emerald-200'
|
||||||
|
: 'bg-red-50 text-red-600 border border-red-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="w-full max-w-xl bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
<div className="w-full max-w-xl bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||||
<div className="border-b border-slate-100 bg-slate-50 px-6 py-5">
|
<div className="border-b border-slate-100 bg-slate-50 px-6 py-5">
|
||||||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-800">
|
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-800">
|
||||||
|
|||||||
@@ -84,19 +84,8 @@ export const IPC_CHANNELS = {
|
|||||||
USER_ERP_CONFIG_TEST_CONNECTION: 'user-erp-config:testConnection',
|
USER_ERP_CONFIG_TEST_CONNECTION: 'user-erp-config:testConnection',
|
||||||
USER_ERP_CONFIG_GET_ALL: 'user-erp-config:getAll',
|
USER_ERP_CONFIG_GET_ALL: 'user-erp-config:getAll',
|
||||||
|
|
||||||
// Config
|
|
||||||
CONFIG_GET: 'config:get',
|
|
||||||
CONFIG_UPDATE: 'config:update',
|
|
||||||
CONFIG_GET_CLEANER: 'config:getCleaner',
|
|
||||||
CONFIG_UPDATE_CLEANER: 'config:updateCleaner',
|
|
||||||
|
|
||||||
// Logger
|
// Logger
|
||||||
LOGGER_FORWARD: 'logger:forward',
|
LOGGER_FORWARD: 'logger:forward'
|
||||||
|
|
||||||
// Report
|
|
||||||
REPORT_LIST_ALL: 'report:listAll',
|
|
||||||
REPORT_LIST_BY_USER: 'report:listByUser',
|
|
||||||
REPORT_DOWNLOAD: 'report:download'
|
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
6
test-results/.last-run.json
Normal file
6
test-results/.last-run.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"failedTests": [
|
||||||
|
"c6b74b79254217b7c306-e293eb560bc105062fa9"
|
||||||
|
]
|
||||||
|
}
|
||||||
52
tests/e2e/verify.test.ts
Normal file
52
tests/e2e/verify.test.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
import { _electron as electron } from 'playwright'
|
||||||
|
|
||||||
|
test('Verify Concurrency Setting in CleanerPage', async () => {
|
||||||
|
// Launch Electron app
|
||||||
|
const electronApp = await electron.launch({
|
||||||
|
args: ['.', '--no-sandbox', '--disable-gpu'],
|
||||||
|
env: { ...process.env, NODE_ENV: 'development' }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get the main window
|
||||||
|
const window = await electronApp.firstWindow()
|
||||||
|
|
||||||
|
// Wait for the app to load
|
||||||
|
await window.waitForLoadState('domcontentloaded')
|
||||||
|
|
||||||
|
// Let the app initialize fully
|
||||||
|
await window.waitForTimeout(3000)
|
||||||
|
|
||||||
|
// Navigate to CleanerPage
|
||||||
|
const cleanerTab = await window.getByText('物料清理')
|
||||||
|
await cleanerTab.waitFor({ state: 'visible' }).catch(() => {})
|
||||||
|
await cleanerTab.click().catch(() => {})
|
||||||
|
|
||||||
|
// Wait a bit for the page to transition
|
||||||
|
await window.waitForTimeout(1000)
|
||||||
|
|
||||||
|
// Click "执行设置" (Execution Settings) button
|
||||||
|
const settingsBtn = await window.getByRole('button', { name: /执行设置/ })
|
||||||
|
await settingsBtn.waitFor({ state: 'visible' })
|
||||||
|
await settingsBtn.click()
|
||||||
|
|
||||||
|
// Wait for the settings menu to appear and the Concurrency input to be visible
|
||||||
|
await window.waitForTimeout(500)
|
||||||
|
|
||||||
|
// Find the input containing concurrency text, or just the number input
|
||||||
|
const concurrencyInput = await window.locator('input[type="number"]').first()
|
||||||
|
await concurrencyInput.waitFor({ state: 'visible' })
|
||||||
|
|
||||||
|
// Assert default value is 1
|
||||||
|
await expect(concurrencyInput).toHaveValue('1')
|
||||||
|
|
||||||
|
// Set to 5 and test
|
||||||
|
await concurrencyInput.fill('5')
|
||||||
|
await expect(concurrencyInput).toHaveValue('5')
|
||||||
|
|
||||||
|
// Take screenshot showing the open menu and value
|
||||||
|
await window.screenshot({ path: '/home/jules/verification/cleaner-concurrency.png' })
|
||||||
|
|
||||||
|
// Close app
|
||||||
|
await electronApp.close()
|
||||||
|
})
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import {
|
import { CleanerService } from '../../src/main/services/erp/cleaner'
|
||||||
createBatches,
|
|
||||||
getMissingOrders,
|
|
||||||
runWithConcurrency
|
|
||||||
} from '../../src/main/services/erp/cleaner'
|
|
||||||
import type { ShouldDeleteParams } from '../../src/main/services/erp/cleaner'
|
import type { ShouldDeleteParams } from '../../src/main/services/erp/cleaner'
|
||||||
|
|
||||||
describe('Cleaner Service (Unit)', () => {
|
describe('Cleaner Service (Unit)', () => {
|
||||||
@@ -136,31 +132,4 @@ describe('Cleaner Service (Unit)', () => {
|
|||||||
).toBe(false)
|
).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('batch and concurrency helpers', () => {
|
|
||||||
it('should split orders into batches', () => {
|
|
||||||
const batches = createBatches(['A', 'B', 'C', 'D', 'E'], 2)
|
|
||||||
expect(batches).toEqual([['A', 'B'], ['C', 'D'], ['E']])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should identify missing orders', () => {
|
|
||||||
const missing = getMissingOrders(['SC1', 'SC2', 'SC3'], new Set(['SC1', 'SC3']))
|
|
||||||
expect(missing).toEqual(['SC2'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should respect concurrency limit', async () => {
|
|
||||||
const items = [1, 2, 3, 4, 5, 6]
|
|
||||||
let running = 0
|
|
||||||
let peak = 0
|
|
||||||
await runWithConcurrency(items, 2, async () => {
|
|
||||||
running += 1
|
|
||||||
peak = Math.max(peak, running)
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
||||||
running -= 1
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(peak).toBeLessThanOrEqual(2)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -138,34 +138,6 @@ describe('Cleaner Schema', () => {
|
|||||||
|
|
||||||
expect(result.success).toBe(false)
|
expect(result.success).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should apply defaults for queryBatchSize and processConcurrency', () => {
|
|
||||||
const input = {
|
|
||||||
orderNumbers: ['SC12345678901234'],
|
|
||||||
materialCodes: ['MAT001'],
|
|
||||||
dryRun: false
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = CleanerInputSchema.safeParse(input)
|
|
||||||
|
|
||||||
expect(result.success).toBe(true)
|
|
||||||
if (result.success) {
|
|
||||||
expect(result.data.queryBatchSize).toBe(100)
|
|
||||||
expect(result.data.processConcurrency).toBe(1)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should reject out-of-range processConcurrency', () => {
|
|
||||||
const input = {
|
|
||||||
orderNumbers: ['SC12345678901234'],
|
|
||||||
materialCodes: ['MAT001'],
|
|
||||||
dryRun: false,
|
|
||||||
processConcurrency: 21
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = CleanerInputSchema.safeParse(input)
|
|
||||||
expect(result.success).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('validateCleanerInput', () => {
|
describe('validateCleanerInput', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user