From 343cb242341a351cae2cf34e04d8b8b7c163a20c Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Tue, 14 Apr 2026 14:03:58 +0800 Subject: [PATCH] chore: run pretier format across project - Format TypeScript source files - Format documentation files - Update eslint config formatting --- ...026-04-13-cleaner-db-persistence-design.md | 128 +++---- .../2026-04-13-cleaner-db-persistence-plan.md | 103 +++++- .../2026-04-13-cleaner-outer-retry-design.md | 35 +- .../cleaner/cleaner-application-service.ts | 6 +- src/main/services/erp/cleaner.ts | 349 ++++++++++++++---- src/main/services/erp/erp-auth.ts | 36 +- src/main/services/erp/erp-error-context.ts | 22 +- src/main/services/erp/extractor-core.ts | 18 +- .../add-productionid-to-cleaner-order.ts | 4 +- .../cleaner/cleaner-skipped-materials.test.ts | 2 +- 10 files changed, 519 insertions(+), 184 deletions(-) diff --git a/docs/plans/2026-04-13-cleaner-db-persistence-design.md b/docs/plans/2026-04-13-cleaner-db-persistence-design.md index 37852b5..edb4ada 100644 --- a/docs/plans/2026-04-13-cleaner-db-persistence-design.md +++ b/docs/plans/2026-04-13-cleaner-db-persistence-design.md @@ -13,14 +13,14 @@ Extractor 已有成熟的数据库持久化模式(`ExtractorOperationHistory` ## 设计决策 -| 决策项 | 选择 | 理由 | -|--------|------|------| -| 表结构 | 独立建表,不与 Extractor 共用 | Cleaner 数据结构差异大(双层、物料级详情),独立更清晰 | -| 记录粒度 | 执行 + 订单 + 物料三层 | 执行表存全局信息,订单表存订单汇总,物料表存操作明细 | -| 批次标识 | `BatchId`(UUID),与 Extractor 一致 | 标准、简洁,不需要嵌入时间戳 | -| 重试记录 | 不覆盖,每次尝试独立写入,用 `AttemptNumber` 区分 | 保留完整审计链,为后续智能跳过提供数据基础 | -| 报告文件 | 移除 Markdown 报告和 RustFS 上传 | 数据库完全替代,报告相关代码(CleanerReportGenerator、generateAndUploadReport)删除 | -| 前端历史 | 独立 CleanerOperationHistoryModal,复用 Extractor 的 UI 模式 | 放在 CleanerPage 上,与 Extractor 的"操作历史"按钮对齐 | +| 决策项 | 选择 | 理由 | +| -------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------- | +| 表结构 | 独立建表,不与 Extractor 共用 | Cleaner 数据结构差异大(双层、物料级详情),独立更清晰 | +| 记录粒度 | 执行 + 订单 + 物料三层 | 执行表存全局信息,订单表存订单汇总,物料表存操作明细 | +| 批次标识 | `BatchId`(UUID),与 Extractor 一致 | 标准、简洁,不需要嵌入时间戳 | +| 重试记录 | 不覆盖,每次尝试独立写入,用 `AttemptNumber` 区分 | 保留完整审计链,为后续智能跳过提供数据基础 | +| 报告文件 | 移除 Markdown 报告和 RustFS 上传 | 数据库完全替代,报告相关代码(CleanerReportGenerator、generateAndUploadReport)删除 | +| 前端历史 | 独立 CleanerOperationHistoryModal,复用 Extractor 的 UI 模式 | 放在 CleanerPage 上,与 Extractor 的"操作历史"按钮对齐 | ## 数据库表结构 @@ -32,25 +32,25 @@ Extractor 已有成熟的数据库持久化模式(`ExtractorOperationHistory` 一次清理操作(含重试)的全局信息。每次尝试一行记录。 -| 列名 | 类型 | 说明 | -|------|------|------| -| ID | INT IDENTITY | 自增主键 | -| BatchId | UNIQUEIDENTIFIER | 批次 ID,一次清理操作(含重试)共享 | -| AttemptNumber | INT | 第几次尝试(1=首次,2=外层重试) | -| UserId | INT | 操作用户 ID | -| Username | NVARCHAR(255) | 操作用户名 | -| OperationTime | DATETIME | 操作时间 | -| EndTime | DATETIME | 结束时间 | -| Status | NVARCHAR(50) | pending / success / failed / partial / crashed | -| IsDryRun | BIT | 是否模拟运行 | -| TotalOrders | INT | 订单总数 | -| OrdersProcessed | INT | 已处理订单数 | -| TotalMaterialsDeleted | INT | 总删除物料数 | -| TotalMaterialsSkipped | INT | 总跳过物料数 | -| TotalMaterialsFailed | INT | 总失败物料数 | -| TotalUncertainDeletions | INT | 总不确定删除数 | -| ErrorMessage | NVARCHAR(MAX) | 全局错误信息(如外层崩溃原因) | -| AppVersion | NVARCHAR(20) | 应用版本号 | +| 列名 | 类型 | 说明 | +| ----------------------- | ---------------- | ---------------------------------------------- | +| ID | INT IDENTITY | 自增主键 | +| BatchId | UNIQUEIDENTIFIER | 批次 ID,一次清理操作(含重试)共享 | +| AttemptNumber | INT | 第几次尝试(1=首次,2=外层重试) | +| UserId | INT | 操作用户 ID | +| Username | NVARCHAR(255) | 操作用户名 | +| OperationTime | DATETIME | 操作时间 | +| EndTime | DATETIME | 结束时间 | +| Status | NVARCHAR(50) | pending / success / failed / partial / crashed | +| IsDryRun | BIT | 是否模拟运行 | +| TotalOrders | INT | 订单总数 | +| OrdersProcessed | INT | 已处理订单数 | +| TotalMaterialsDeleted | INT | 总删除物料数 | +| TotalMaterialsSkipped | INT | 总跳过物料数 | +| TotalMaterialsFailed | INT | 总失败物料数 | +| TotalUncertainDeletions | INT | 总不确定删除数 | +| ErrorMessage | NVARCHAR(MAX) | 全局错误信息(如外层崩溃原因) | +| AppVersion | NVARCHAR(20) | 应用版本号 | ### 2. `CleanerOrderHistory`(订单级) @@ -58,20 +58,20 @@ Extractor 已有成熟的数据库持久化模式(`ExtractorOperationHistory` 每个订单在每次尝试中的执行结果。每个订单每次尝试一行记录。 -| 列名 | 类型 | 说明 | -|------|------|------| -| ID | INT IDENTITY | 自增主键 | -| BatchId | UNIQUEIDENTIFIER | 关联执行表 BatchId | -| AttemptNumber | INT | 关联执行表 AttemptNumber | -| OrderNumber | NVARCHAR(255) | 订单号 | -| Status | NVARCHAR(50) | pending / success / failed | -| MaterialsDeleted | INT | 删除物料数 | -| MaterialsSkipped | INT | 跳过物料数 | -| MaterialsFailed | INT | 删除失败物料数 | -| UncertainDeletions | INT | 不确定删除数 | -| RetryCount | INT | 内层重试次数 | -| RetrySuccess | BIT | 内层重试是否成功 | -| ErrorMessage | NVARCHAR(MAX) | 错误信息 | +| 列名 | 类型 | 说明 | +| ------------------ | ---------------- | -------------------------- | +| ID | INT IDENTITY | 自增主键 | +| BatchId | UNIQUEIDENTIFIER | 关联执行表 BatchId | +| AttemptNumber | INT | 关联执行表 AttemptNumber | +| OrderNumber | NVARCHAR(255) | 订单号 | +| Status | NVARCHAR(50) | pending / success / failed | +| MaterialsDeleted | INT | 删除物料数 | +| MaterialsSkipped | INT | 跳过物料数 | +| MaterialsFailed | INT | 删除失败物料数 | +| UncertainDeletions | INT | 不确定删除数 | +| RetryCount | INT | 内层重试次数 | +| RetrySuccess | BIT | 内层重试是否成功 | +| ErrorMessage | NVARCHAR(MAX) | 错误信息 | 关联方式:`BatchId + AttemptNumber` 关联执行表。 @@ -81,19 +81,19 @@ Extractor 已有成熟的数据库持久化模式(`ExtractorOperationHistory` 每个物料在每次尝试中的操作明细。 -| 列名 | 类型 | 说明 | -|------|------|------| -| ID | INT IDENTITY | 自增主键 | -| BatchId | UNIQUEIDENTIFIER | 关联执行表 BatchId | -| AttemptNumber | INT | 关联执行表 AttemptNumber | -| OrderNumber | NVARCHAR(255) | 所属订单号 | -| MaterialCode | NVARCHAR(255) | 物料代码 | -| MaterialName | NVARCHAR(255) | 物料名称 | -| RowNumber | INT | 行号 | -| Result | NVARCHAR(50) | deleted / skipped / failed / uncertain | -| Reason | NVARCHAR(MAX) | 跳过/失败原因 | -| AttemptCount | INT | 删除尝试次数 | -| FinalErrorCategory | NVARCHAR(50) | 最终错误分类 | +| 列名 | 类型 | 说明 | +| ------------------ | ---------------- | -------------------------------------- | +| ID | INT IDENTITY | 自增主键 | +| BatchId | UNIQUEIDENTIFIER | 关联执行表 BatchId | +| AttemptNumber | INT | 关联执行表 AttemptNumber | +| OrderNumber | NVARCHAR(255) | 所属订单号 | +| MaterialCode | NVARCHAR(255) | 物料代码 | +| MaterialName | NVARCHAR(255) | 物料名称 | +| RowNumber | INT | 行号 | +| Result | NVARCHAR(50) | deleted / skipped / failed / uncertain | +| Reason | NVARCHAR(MAX) | 跳过/失败原因 | +| AttemptCount | INT | 删除尝试次数 | +| FinalErrorCategory | NVARCHAR(50) | 最终错误分类 | 关联方式:`BatchId + AttemptNumber + OrderNumber` 关联订单表。 @@ -102,18 +102,21 @@ Extractor 已有成熟的数据库持久化模式(`ExtractorOperationHistory` 首次执行到第 80 个订单时崩溃,外层重试成功完成全部 211 个订单: **CleanerExecution** + ``` BatchId=uuid-1, Attempt=1, Status=crashed, TotalOrders=211, Processed=80, ... BatchId=uuid-1, Attempt=2, Status=success, TotalOrders=211, Processed=211, ... ``` **CleanerOrderHistory**(Attempt=1 中部分记录) + ``` BatchId=uuid-1, Attempt=1, Order=SC001, Status=success, Deleted=5, Skipped=1 BatchId=uuid-1, Attempt=1, Order=SC080, Status=crashed, Error=查询超时 ``` **CleanerOrderHistory**(Attempt=2 中部分记录) + ``` BatchId=uuid-1, Attempt=2, Order=SC001, Status=success, Deleted=5, Skipped=1 BatchId=uuid-1, Attempt=2, Order=SC080, Status=success, Deleted=3, Skipped=0 @@ -121,6 +124,7 @@ BatchId=uuid-1, Attempt=2, Order=SC211, Status=success, Deleted=2, Skipped=0 ``` **CleanerMaterialDetail**(SC080 在 Attempt=2 中的物料) + ``` BatchId=uuid-1, Attempt=2, Order=SC080, Material=MAT-001, Result=deleted BatchId=uuid-1, Attempt=2, Order=SC080, Material=MAT-002, Result=skipped, Reason=不可删除 @@ -218,14 +222,14 @@ BatchId=uuid-1, Attempt=2, Order=SC080, Material=MAT-002, Result=skipped, Reason ## 移除的概念 -| 概念 | 原因 | -|------|------| -| ExecutionId(CLN-时间戳-随机) | 为文件名设计,数据库用 UUID | -| generateExecutionId() | 随 ExecutionId 一起移除 | -| CleanerReportGenerator | Markdown 报告生成器,被数据库替代 | -| generateAndUploadReport() | RustFS 上传链路,被数据库写入替代 | -| 报告文件名去重 | 数据库 UUID 天然唯一 | -| 重试覆盖旧报告 | 数据库保留所有尝试记录 | +| 概念 | 原因 | +| ------------------------------ | --------------------------------- | +| ExecutionId(CLN-时间戳-随机) | 为文件名设计,数据库用 UUID | +| generateExecutionId() | 随 ExecutionId 一起移除 | +| CleanerReportGenerator | Markdown 报告生成器,被数据库替代 | +| generateAndUploadReport() | RustFS 上传链路,被数据库写入替代 | +| 报告文件名去重 | 数据库 UUID 天然唯一 | +| 重试覆盖旧报告 | 数据库保留所有尝试记录 | ## 不涉及的部分 diff --git a/docs/plans/2026-04-13-cleaner-db-persistence-plan.md b/docs/plans/2026-04-13-cleaner-db-persistence-plan.md index c8b24ae..0d2c210 100644 --- a/docs/plans/2026-04-13-cleaner-db-persistence-plan.md +++ b/docs/plans/2026-04-13-cleaner-db-persistence-plan.md @@ -13,6 +13,7 @@ ## Task 1: 新增类型定义 **Files:** + - Create: `src/main/types/cleaner-history.types.ts` **Step 1: 创建类型文件** @@ -146,6 +147,7 @@ feat(cleaner): add type definitions for cleaner operation history ## Task 2: 新增 DAO 层 **Files:** + - Create: `src/main/services/database/cleaner-operation-history-dao.ts` **Step 1: 创建 DAO 文件** @@ -165,47 +167,101 @@ export class CleanerOperationHistoryDAO { } async insertExecution(input: InsertCleanerExecutionInput): Promise - async updateExecutionStatus(batchId: string, attemptNumber: number, status: string, ordersProcessed: number, materialsDeleted: number, materialsSkipped: number, materialsFailed: number, uncertainDeletions: number, endTime: Date, errorMessage?: string): Promise + async updateExecutionStatus( + batchId: string, + attemptNumber: number, + status: string, + ordersProcessed: number, + materialsDeleted: number, + materialsSkipped: number, + materialsFailed: number, + uncertainDeletions: number, + endTime: Date, + errorMessage?: string + ): Promise // ===== 订单表 ===== private getOrderTableName(): string { return this.getDialect().quoteTableName('ERPAuto', 'CleanerOrderHistory') } - async insertOrderRecords(batchId: string, attemptNumber: number, orders: InsertOrderInput[]): Promise - async updateOrderStatus(batchId: string, attemptNumber: number, orderNumber: string, status: string, materialsDeleted: number, materialsSkipped: number, materialsFailed: number, uncertainDeletions: number, retryCount: number, retrySuccess: boolean, errorMessage?: string): Promise + async insertOrderRecords( + batchId: string, + attemptNumber: number, + orders: InsertOrderInput[] + ): Promise + async updateOrderStatus( + batchId: string, + attemptNumber: number, + orderNumber: string, + status: string, + materialsDeleted: number, + materialsSkipped: number, + materialsFailed: number, + uncertainDeletions: number, + retryCount: number, + retrySuccess: boolean, + errorMessage?: string + ): Promise // ===== 物料表 ===== private getMaterialTableName(): string { return this.getDialect().quoteTableName('ERPAuto', 'CleanerMaterialDetail') } - async insertMaterialDetails(batchId: string, attemptNumber: number, details: InsertMaterialDetailInput[]): Promise + async insertMaterialDetails( + batchId: string, + attemptNumber: number, + details: InsertMaterialDetailInput[] + ): Promise // ===== 查询 ===== - async getBatches(userId?: number, options?: GetCleanerBatchesOptions): Promise - async getBatchDetails(batchId: string): Promise<{ executions: CleanerExecutionRecord[]; orders: CleanerOrderRecord[] }> - async getMaterialDetails(batchId: string, attemptNumber: number, orderNumber: string): Promise + async getBatches( + userId?: number, + options?: GetCleanerBatchesOptions + ): Promise + async getBatchDetails( + batchId: string + ): Promise<{ executions: CleanerExecutionRecord[]; orders: CleanerOrderRecord[] }> + async getMaterialDetails( + batchId: string, + attemptNumber: number, + orderNumber: string + ): Promise // ===== 删除 ===== - async deleteBatch(batchId: string, requestingUserId: number, isAdmin: boolean): Promise<{ success: boolean; error?: string }> + async deleteBatch( + batchId: string, + requestingUserId: number, + isAdmin: boolean + ): Promise<{ success: boolean; error?: string }> // ===== 列询执行级记录 ===== - async getMaterialDetails(batchId: string, attemptNumber: number, orderNumber: string): Promise + async getMaterialDetails( + batchId: string, + attemptNumber: number, + orderNumber: string + ): Promise // ===== 删除 ===== - async deleteBatch(batchId: string, requestingUserId: number, isAdmin: boolean): Promise<{ success: boolean; error?: string }> + async deleteBatch( + batchId: string, + requestingUserId: number, + isAdmin: boolean + ): Promise<{ success: boolean; error?: string }> async disconnect(): Promise } ``` `getBatches` 查询逻辑: + - `GROUP BY BatchId`,取 `MAX(AttemptNumber)` 对应的执行记录状态作为最终状态 - 汇总订单级的 success/failed 计数 - 支持 userId 过滤(普通用户)和 usernames 过滤(管理员) - 支持分页 `getBatchDetails` 查询逻辑: + - 返回某 BatchId 下所有 execution 记录 + order 记录 - 前端用 attemptNumber 区分不同尝试 @@ -227,6 +283,7 @@ feat(cleaner): add CleanerOperationHistoryDAO for three-table persistence ## Task 3: 新增 IPC channels **Files:** + - Modify: `src/shared/ipc-channels.ts` **Step 1: 添加 cleaner history channels** @@ -252,6 +309,7 @@ feat(cleaner): add IPC channels for cleaner operation history ## Task 4: 新增 IPC handler **Files:** + - Create: `src/main/ipc/cleaner-history-handler.ts` - Modify: `src/main/ipc/index.ts` — 注册新 handler @@ -282,14 +340,24 @@ export function registerCleanerHistoryHandlers(): void { ipcMain.handle( IPC_CHANNELS.CLEANER_HISTORY_GET_BATCH_DETAILS, - async (event, batchId: string): Promise> => { + async ( + event, + batchId: string + ): Promise< + IpcResult<{ executions: CleanerExecutionRecord[]; orders: CleanerOrderRecord[] }> + > => { // ... 与 operation-history-handler 的 getBatchDetails 模式一致 } ) ipcMain.handle( IPC_CHANNELS.CLEANER_HISTORY_GET_MATERIAL_DETAILS, - async (event, batchId: string, attemptNumber: number, orderNumber: string): Promise> => { + async ( + event, + batchId: string, + attemptNumber: number, + orderNumber: string + ): Promise> => { // ... } ) @@ -323,6 +391,7 @@ feat(cleaner): add IPC handlers for cleaner operation history ## Task 5: 新增 Preload API **Files:** + - Modify: `src/preload/api/cleaner.ts` — 新增 history 方法 - Modify: `src/preload/index.d.ts` — 新增类型声明 @@ -374,6 +443,7 @@ feat(cleaner): add preload API for cleaner operation history ## Task 6: 改造 CleanerApplicationService — 写入数据库记录 **Files:** + - Modify: `src/main/services/cleaner/cleaner-application-service.ts` 这是核心变更。`runCleaner` 方法需要: @@ -403,6 +473,7 @@ async runCleaner( **Step 4: 执行后更新订单记录和写入物料明细** 清理完成后遍历 `result.details`(`OrderCleanDetail[]`),对每个订单: + - 调用 `historyDao.updateOrderStatus(...)` 更新订单结果 - 调用 `historyDao.insertMaterialDetails(...)` 写入物料明细(skipped + failed 材料全部写入) @@ -413,6 +484,7 @@ async runCleaner( **Step 6: 外层重试改造** 当 `result.crashed` 时: + 1. 调用 `historyDao.updateExecutionStatus(batchId, 1, 'crashed', ...)` 标记首次尝试为 crashed 2. 调用 `historyDao.insertExecution({ batchId, attemptNumber: 2, ... })` 创建第二次尝试 3. 调用 `historyDao.insertOrderRecords(batchId, 2, orders)` 写入第二次尝试的 pending 订单 @@ -435,11 +507,13 @@ refactor(cleaner): replace report generation with database persistence ## Task 7: 改造 cleaner-handler.ts — 执行前后写入 **Files:** + - Modify: `src/main/ipc/cleaner-handler.ts` **Step 1: 修改 CLEANER_RUN handler** 在调用 `cleanerService.runCleaner()` 之前: + 1. 获取当前用户信息 2. `batchId = randomUUID()` 3. 创建 `CleanerOperationHistoryDAO` 实例 @@ -469,6 +543,7 @@ refactor(cleaner): write execution records to database in IPC handler ## Task 8: 删除 Markdown 报告生成器 **Files:** + - Delete: `src/main/services/report/cleaner-report-generator.ts` **Step 1: 删除文件** @@ -495,6 +570,7 @@ refactor(cleaner): remove Markdown report generator ## Task 9: 前端 — 新增操作历史弹窗 **Files:** + - Create: `src/renderer/src/components/CleanerOperationHistoryModal.tsx` - Modify: `src/renderer/src/pages/CleanerPage.tsx` @@ -531,6 +607,7 @@ feat(cleaner): add operation history modal with database-backed records ## Task 10: 更新 renderer 类型定义 **Files:** + - Modify: `src/renderer/src/hooks/cleaner/types.ts` **Step 1: 添加 history 相关类型** @@ -553,6 +630,7 @@ feat(cleaner): add renderer types for cleaner operation history ## Task 11: 清理旧代码 **Files:** + - Modify: `src/renderer/src/hooks/cleaner/types.ts` — 移除 `CleanerReportData.crashed`(如果不再需要) - 检查 `ReportViewerDialog.tsx`、`ReportAnalysisDialog.tsx` 是否仍被 Cleaner 使用 @@ -564,6 +642,7 @@ feat(cleaner): add renderer types for cleaner operation history **Step 2: 评估 ReportViewerDialog 和 ReportAnalysisDialog** 这两个组件目前用于查看 Markdown 报告文件。如果 Cleaner 不再使用它们: + - 在 CleanerPage 中移除相关按钮和引用 - 不删除组件本身(Extractor 可能仍在使用,后续统一清理) diff --git a/docs/plans/2026-04-13-cleaner-outer-retry-design.md b/docs/plans/2026-04-13-cleaner-outer-retry-design.md index aadae3e..d3ec71b 100644 --- a/docs/plans/2026-04-13-cleaner-outer-retry-design.md +++ b/docs/plans/2026-04-13-cleaner-outer-retry-design.md @@ -8,13 +8,13 @@ ## 设计决策 -| 决策项 | 选择 | 理由 | -|---|---|---| -| 重试层级 | CleanerApplicationService | 崩溃后浏览器不可用,必须重新登录 | -| 重试范围 | 全部订单重新跑 | 简单可靠,物料删除是幂等操作 | -| 最大重试次数 | 1 次 | 覆盖瞬态故障,不过度消耗时间 | -| 触发条件 | result.crashed === true | 仅 outer catch 触发时才重试 | -| 报告去重 | 执行 ID | 用户点击执行时生成,重试不变 | +| 决策项 | 选择 | 理由 | +| ------------ | ------------------------- | -------------------------------- | +| 重试层级 | CleanerApplicationService | 崩溃后浏览器不可用,必须重新登录 | +| 重试范围 | 全部订单重新跑 | 简单可靠,物料删除是幂等操作 | +| 最大重试次数 | 1 次 | 覆盖瞬态故障,不过度消耗时间 | +| 触发条件 | result.crashed === true | 仅 outer catch 触发时才重试 | +| 报告去重 | 执行 ID | 用户点击执行时生成,重试不变 | ## 变更清单 @@ -25,7 +25,7 @@ ```typescript export interface CleanerResult { // ... 现有字段 - crashed?: boolean // true = outer catch triggered, 流程级崩溃 + crashed?: boolean // true = outer catch triggered, 流程级崩溃 } ``` @@ -87,6 +87,7 @@ runCleaner(eventSender, input) { 生成时机: `runCleaner()` 入口处,在 ERP 登录之前。重试时同一个 executionId 不变。 用途: + - 报告文件名: `cleaner-report-CLN-20260410112930-A7FK.md` - RustFS 存储路径中包含该 ID,重试时覆盖同一文件 - 报告内容中显示该 ID @@ -98,13 +99,13 @@ runCleaner(eventSender, input) { 在执行摘要表格中新增字段: ```markdown -| 项目 | 值 | -| ---------------- | --------------------------------- | -| **执行 ID** | `CLN-20260410112930-A7FK` | ← 新增 -| **应用版本** | `1.11.1` | ← 新增 -| **执行时间** | `2026-04-10 11:29:30` | -| **执行模式** | `正式执行` | -| ... | ... | +| 项目 | 值 | +| ------------ | ------------------------- | ------ | +| **执行 ID** | `CLN-20260410112930-A7FK` | ← 新增 | +| **应用版本** | `1.11.1` | ← 新增 | +| **执行时间** | `2026-04-10 11:29:30` | +| **执行模式** | `正式执行` | +| ... | ... | ``` - **执行 ID**: 从 ReportOptions 传入 @@ -118,8 +119,8 @@ export interface ReportOptions { username: string startTime: number endTime: number - executionId: string // ← 新增 - appVersion: string // ← 新增 + executionId: string // ← 新增 + appVersion: string // ← 新增 } ``` diff --git a/src/main/services/cleaner/cleaner-application-service.ts b/src/main/services/cleaner/cleaner-application-service.ts index d27cea9..31f4bfc 100644 --- a/src/main/services/cleaner/cleaner-application-service.ts +++ b/src/main/services/cleaner/cleaner-application-service.ts @@ -484,11 +484,7 @@ export class CleanerApplicationService { batchId, attemptNumber, detail.orderNumber, - detail.notFound - ? 'erp_not_found' - : detail.errors.length > 0 - ? 'failed' - : 'success', + detail.notFound ? 'erp_not_found' : detail.errors.length > 0 ? 'failed' : 'success', detail.materialsDeleted, detail.materialsSkipped, detail.materialsFailed, diff --git a/src/main/services/erp/cleaner.ts b/src/main/services/erp/cleaner.ts index 15aa603..c99dcd3 100644 --- a/src/main/services/erp/cleaner.ts +++ b/src/main/services/erp/cleaner.ts @@ -26,8 +26,12 @@ interface RetryResult { } interface ProgressState { - completedOrders: number + ordersStarted: number // 开始处理的订单数 + ordersCompleted: number // 已完成订单数 totalOrders: number + progressText?: string // "ordersCompleted/totalOrders (xx%)" + lastCompletedOrder?: string // 最后完成的订单号 + lastActivityTime?: number // 最后活动时间戳(健康检查用) } interface QueryResultRow { @@ -56,6 +60,48 @@ class AsyncMutex { } } +/** + * 并发追踪器 - 监控 worker 状态和 mutex 等待情况 + */ +class ConcurrencyTracker { + private activeWorkers = 0 + private waitQueue = 0 + private mutexWaitCount = 0 + + workerStarted() { + this.activeWorkers++ + log.verbose('[CONCURRENCY] Worker started', { + activeWorkers: this.activeWorkers, + waitQueue: this.waitQueue, + waitingForPopupMutex: this.mutexWaitCount > 0 + }) + } + + workerCompleted() { + this.activeWorkers-- + log.verbose('[CONCURRENCY] Worker completed', { + activeWorkers: this.activeWorkers, + queueRemaining: this.waitQueue + }) + } + + waitingForMutex() { + this.mutexWaitCount++ + log.warn('[CONCURRENCY] Worker waiting for popup mutex', { + mutexWaitCount: this.mutexWaitCount, + activeWorkers: this.activeWorkers + }) + } + + acquiredMutex() { + this.mutexWaitCount-- + log.verbose('[CONCURRENCY] Worker acquired popup mutex', { + mutexWaitCount: this.mutexWaitCount, + activeWorkers: this.activeWorkers + }) + } +} + /** * Cleaner Service Options */ @@ -242,7 +288,8 @@ export class CleanerService { const orderBatches = createBatches(input.orderNumbers, queryBatchSize) const popupMutex = new AsyncMutex() const progressState: ProgressState = { - completedOrders: 0, + ordersStarted: 0, + ordersCompleted: 0, totalOrders } @@ -257,58 +304,131 @@ export class CleanerService { totalMaterials }) + // [新增] 健康检查定时器 - 检测长时间无进展 + let lastActivityTime = Date.now() + progressState.lastActivityTime = lastActivityTime + const healthCheckInterval = setInterval(() => { + const secondsSinceLastActivity = (Date.now() - lastActivityTime) / 1000 + + if (secondsSinceLastActivity > 60) { + log.warn('[HEALTH_CHECK] 长时间无进展', { + ordersCompleted: progressState.ordersCompleted, + totalOrders: progressState.totalOrders, + lastCompletedOrder: progressState.lastCompletedOrder, + noProgressSeconds: secondsSinceLastActivity, + suspectedStuck: secondsSinceLastActivity > 180, + healthStatus: secondsSinceLastActivity > 180 ? 'critical' : 'warning' + }) + } + }, 30000) // 每 30 秒检查一次 + + // [新增] 为每个 batch 创建并发追踪器 + const tracker = new ConcurrencyTracker() + // Track batch processing duration with 5s slow threshold await trackDuration( async () => { - await this.queryOrders(workFrame, batchOrders) - await this.waitForLoading(workFrame) - - const queriedRows = await this.collectQueryResultRows(workFrame) - const queriedOrderNumbersInBatch = new Set(queriedRows.map((row) => row.orderNumber)) - - await runWithConcurrency(queriedRows, processConcurrency, async (row) => { - const { rowIndex, orderNumber } = row - const openedDetailPage = await popupMutex.runExclusive(async () => { - return await this.openDetailPageFromRow(workFrame, popupPage!, rowIndex) - }) - - let detail: OrderCleanDetail - try { - detail = await this.processDetailPage({ - detailPage: openedDetailPage, - deleteSet, - dryRun, - expectedOrderNumber: orderNumber, - progressState, - onProgress: input.onProgress - }) - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' - detail = this.createErrorDetail(orderNumber, message) - } finally { - progressState.completedOrders += 1 - } - - result.details.push(detail) - - if (detail.errors.length > 0) { - result.errors.push(`Order ${detail.orderNumber}: ${detail.errors.join('; ')}`) - return - } - - result.ordersProcessed += 1 - result.materialsDeleted += detail.materialsDeleted - result.materialsSkipped += detail.materialsSkipped - result.materialsFailed += detail.materialsFailed - result.uncertainDeletions += detail.uncertainDeletions + // Phase 1: Query orders + await trackDuration(async () => await this.queryOrders(workFrame, batchOrders), { + operationName: 'query', + message: '执行订单查询', + slowThresholdMs: 3000, + context: { orderCount: batchOrders.length } }) - const missingOrders = getMissingOrders(batchOrders, queriedOrderNumbersInBatch) - for (const missingOrder of missingOrders) { - const missingMessage = '订单未出现在查询结果中' - result.errors.push(`Order ${missingOrder}: ${missingMessage}`) - result.details.push(this.createErrorDetail(missingOrder, missingMessage, true)) - } + // Phase 2: Wait for loading complete + await trackDuration(async () => await this.waitForLoading(workFrame), { + operationName: 'wait_loading', + message: '等待加载完成', + slowThresholdMs: 5000 + }) + + // Phase 3: Collect query results + const collectResult = await trackDuration( + async () => await this.collectQueryResultRows(workFrame), + { + operationName: 'collect_results', + message: '收集查询结果', + slowThresholdMs: 2000 + } + ) + const queriedRows = collectResult.result + const queriedOrderNumbersInBatch = new Set(queriedRows.map((row) => row.orderNumber)) + + // Phase 4: Process all orders in batch + await trackDuration( + async () => { + await runWithConcurrency(queriedRows, processConcurrency, async (row) => { + const { rowIndex, orderNumber } = row + + // [新增] Worker 开始追踪 + tracker.workerStarted() + + try { + const openedDetailPage = await popupMutex.runExclusive(async () => { + // [新增] Mutex 等待追踪 + tracker.waitingForMutex() + const page = await this.openDetailPageFromRow(workFrame, popupPage!, rowIndex) + // [新增] Mutex 获取追踪 + tracker.acquiredMutex() + return page + }) + + let detail: OrderCleanDetail + try { + detail = await this.processDetailPage({ + detailPage: openedDetailPage, + deleteSet, + dryRun, + expectedOrderNumber: orderNumber, + progressState, + onProgress: input.onProgress + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + detail = this.createErrorDetail(orderNumber, message) + } finally { + progressState.ordersStarted += 1 + progressState.ordersCompleted += 1 + progressState.lastCompletedOrder = orderNumber + lastActivityTime = Date.now() // [新增] 健康检查:更新活动时间 + } + + result.details.push(detail) + + if (detail.errors.length > 0) { + result.errors.push(`Order ${detail.orderNumber}: ${detail.errors.join('; ')}`) + return + } + + result.ordersProcessed += 1 + result.materialsDeleted += detail.materialsDeleted + result.materialsSkipped += detail.materialsSkipped + result.materialsFailed += detail.materialsFailed + result.uncertainDeletions += detail.uncertainDeletions + } finally { + // [新增] Worker 完成追踪 + tracker.workerCompleted() + } + }) + + // Handle missing orders + const missingOrders = getMissingOrders(batchOrders, queriedOrderNumbersInBatch) + for (const missingOrder of missingOrders) { + const missingMessage = '订单未出现在查询结果中' + result.errors.push(`Order ${missingOrder}: ${missingMessage}`) + result.details.push(this.createErrorDetail(missingOrder, missingMessage, true)) + } + }, + { + operationName: 'process_all_orders_in_batch', + message: `处理批次中所有${queriedRows.length}个订单`, + slowThresholdMs: 10000 + } + ) + + // Phase 5: Save batch results (already included in process_all_orders_in_batch) + // No separate save step needed as results are accumulated in result object }, { operationName: `batch-${batchIndex + 1}-${orderBatches[batchIndex].length}-orders`, @@ -323,6 +443,9 @@ export class CleanerService { } } ) + + // [新增] 清理健康检查定时器 + clearInterval(healthCheckInterval) } const retryResult = await this.retryFailedOrders({ @@ -381,7 +504,16 @@ export class CleanerService { dryRun, orderNumbers: input.orderNumbers, materialCodes: input.materialCodes, - ...(popupPage ? await capturePageContext(popupPage, undefined, 'cleaner.outerCatch') : {}) + ...(popupPage + ? await capturePageContext( + popupPage, + undefined, + 'cleaner.outerCatch', + undefined, + undefined, + 'outer_catch' + ) + : {}) }) result.errors.push(`Clean failed: ${message}`) result.crashed = true @@ -438,7 +570,14 @@ export class CleanerService { log.error('[导航失败] forwardFrame 为空', { elapsedMs: Date.now() - navStartTime, pageUrl: popupPage.url(), - contextData: await capturePageContext(popupPage, undefined, 'nav.forwardFrame') + contextData: await capturePageContext( + popupPage, + undefined, + 'nav.forwardFrame', + undefined, + undefined, + 'nav_forward_frame' + ) }) throw new Error('无法访问弹出窗口的 forwardFrame') } @@ -457,7 +596,14 @@ export class CleanerService { log.error('[导航失败] workFrame 为空', { elapsedMs: Date.now() - navStartTime, pageUrl: popupPage.url(), - contextData: await capturePageContext(popupPage, undefined, 'nav.workFrame') + contextData: await capturePageContext( + popupPage, + undefined, + 'nav.workFrame', + undefined, + undefined, + 'nav_work_frame' + ) }) throw new Error('无法访问内部工作框架') } @@ -680,14 +826,40 @@ export class CleanerService { const { detailPage, deleteSet, dryRun, progressState, expectedOrderNumber, onProgress } = params const processStartTime = Date.now() + log.info('[ORDER_START] 开始处理订单', { + orderIndex: progressState.ordersStarted, + orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`, + orderNumber: expectedOrderNumber, + phase: 'starting', + elapsedMs: Date.now() - processStartTime + }) + log.info('[订单详情处理开始]', { expectedOrderNumber, dryRun, deleteSetSize: deleteSet.size, - completedOrders: progressState.completedOrders, + ordersStarted: progressState.ordersStarted, + ordersCompleted: progressState.ordersCompleted, totalOrders: progressState.totalOrders }) + let detailCount = 0 + const detail: OrderCleanDetail = { + orderNumber: expectedOrderNumber || 'UNKNOWN', + materialsDeleted: 0, + materialsSkipped: 0, + errors: [], + skippedMaterials: [], + deletedMaterials: [], + retryCount: 0, + retryAttempts: [], + retriedAt: undefined, + retrySuccess: false, + materialsFailed: 0, + failedMaterials: [], + uncertainDeletions: 0 + } + try { // Step 1: Access forward frame log.debug('[详情页面 Step 1] 准备访问 forwardFrame') @@ -699,7 +871,14 @@ export class CleanerService { log.error('[详情页面失败] forwardFrame 访问失败', { elapsedMs: Date.now() - processStartTime, pageUrl: detailPage.url(), - contextData: await capturePageContext(detailPage) + contextData: await capturePageContext( + detailPage, + undefined, + 'processDetail.forwardFrame', + expectedOrderNumber, + undefined, + 'process_detail_forward_frame' + ) }) throw new Error(errorMsg) } @@ -721,7 +900,10 @@ export class CleanerService { contextData: await capturePageContext( detailPage, undefined, - 'processDetail.detailInnerFrame' + 'processDetail.detailInnerFrame', + expectedOrderNumber, + undefined, + 'process_detail_inner_frame' ) }) throw new Error(errorMsg) @@ -752,21 +934,8 @@ export class CleanerService { usedFallback: !sourceOrderNumber && !!expectedOrderNumber }) - const detail: OrderCleanDetail = { - orderNumber, - materialsDeleted: 0, - materialsSkipped: 0, - errors: [], - skippedMaterials: [], - deletedMaterials: [], - retryCount: 0, - retryAttempts: [], - retriedAt: undefined, - retrySuccess: false, - materialsFailed: 0, - failedMaterials: [], - uncertainDeletions: 0 - } + // 更新 orderNumber 为实际提取的值或 fallback + detail.orderNumber = orderNumber // Step 5: Get material counts and status log.debug('[详情页面 Step 5] 读取物料数量和状态') @@ -788,13 +957,13 @@ export class CleanerService { onProgress?.( `开始处理订单:${orderNumber}`, this.calculateProgress( - progressState.completedOrders, + progressState.ordersStarted, 0, detailCount, progressState.totalOrders ), { - currentOrderIndex: progressState.completedOrders + 1, + currentOrderIndex: progressState.ordersStarted + 1, totalOrders: progressState.totalOrders, currentMaterialIndex: 0, totalMaterialsInOrder: detailCount, @@ -861,7 +1030,7 @@ export class CleanerService { const pendingQty = await this.getInputValue(childForm, /^累计待发数量$/) const progress = this.calculateProgress( - progressState.completedOrders, + progressState.ordersStarted, materialIdx, detailCount, progressState.totalOrders @@ -871,7 +1040,7 @@ export class CleanerService { `订单 ${orderNumber} - 物料 ${materialIdx}/${detailCount}: ${materialName}`, progress, { - currentOrderIndex: progressState.completedOrders + 1, + currentOrderIndex: progressState.ordersStarted + 1, totalOrders: progressState.totalOrders, currentMaterialIndex: materialIdx, totalMaterialsInOrder: detailCount, @@ -1078,10 +1247,31 @@ export class CleanerService { orderNumber: expectedOrderNumber || 'UNKNOWN', error: message, elapsedMs: Date.now() - processStartTime, - contextData: await capturePageContext(detailPage, undefined, 'processDetail.error') + contextData: await capturePageContext( + detailPage, + undefined, + 'processDetail.error', + expectedOrderNumber, + undefined, + 'process_detail_error' + ) }) throw error } finally { + const totalOrderTime = Date.now() - processStartTime + log.info('[ORDER_COMPLETE] 订单处理完成', { + orderIndex: progressState.ordersStarted, + orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`, + ordersCompleted: progressState.ordersCompleted, + orderNumber: expectedOrderNumber || 'UNKNOWN', + totalMaterials: detailCount, + deleted: detail?.materialsDeleted ?? 0, + skipped: detail?.materialsSkipped ?? 0, + failed: detail?.materialsFailed ?? 0, + totalOrderTimeMs: totalOrderTime, + isSlow: totalOrderTime > 30000 + }) + log.debug('[详情页面清理] 准备关闭详情页面', { pageUrl: detailPage.url() }) await detailPage.close() log.debug('[详情页面清理完成] 详情页已关闭') @@ -1115,7 +1305,11 @@ export class CleanerService { return /^SC\d{14}$/.test(value) } - private createErrorDetail(orderNumber: string, message: string, notFound: boolean = false): OrderCleanDetail { + private createErrorDetail( + orderNumber: string, + message: string, + notFound: boolean = false + ): OrderCleanDetail { return { orderNumber, materialsDeleted: 0, @@ -1589,7 +1783,8 @@ export class CleanerService { dryRun, expectedOrderNumber: orderNumber, progressState: { - completedOrders: detailIndex, + ordersStarted: detailIndex, + ordersCompleted: 0, totalOrders: failedDetails.length }, onProgress: (message, progress, extra) => { diff --git a/src/main/services/erp/erp-auth.ts b/src/main/services/erp/erp-auth.ts index 5895791..5ba502e 100644 --- a/src/main/services/erp/erp-auth.ts +++ b/src/main/services/erp/erp-auth.ts @@ -82,7 +82,14 @@ export class ErpAuthService { if (!contentFrame) { log.error('Failed to access forwardFrame content frame', { - ...(await capturePageContext(page)) + ...(await capturePageContext( + page, + undefined, + 'auth.forwardFrame', + undefined, + undefined, + 'auth_forward_frame' + )) }) throw new Error('Failed to access forwardFrame content frame') } @@ -96,7 +103,14 @@ export class ErpAuthService { } catch (e) { log.error('Failed to find username input', { error: e instanceof Error ? e.message : String(e), - ...(await capturePageContext(page, undefined, 'login.username')) + ...(await capturePageContext( + page, + undefined, + 'login.username', + undefined, + undefined, + 'login_username' + )) }) throw new Error(`Failed to find username input: ${e}`) } @@ -107,7 +121,14 @@ export class ErpAuthService { } catch (e) { log.error('Failed to find password input', { error: e instanceof Error ? e.message : String(e), - ...(await capturePageContext(page, undefined, 'login.password')) + ...(await capturePageContext( + page, + undefined, + 'login.password', + undefined, + undefined, + 'login_password' + )) }) throw new Error(`Failed to find password input: ${e}`) } @@ -118,7 +139,14 @@ export class ErpAuthService { } catch (e) { log.error('Failed to click login button', { error: e instanceof Error ? e.message : String(e), - ...(await capturePageContext(page, undefined, 'login.button')) + ...(await capturePageContext( + page, + undefined, + 'login.button', + undefined, + undefined, + 'login_button' + )) }) throw new Error(`Failed to click login button: ${e}`) } diff --git a/src/main/services/erp/erp-error-context.ts b/src/main/services/erp/erp-error-context.ts index 0a948c5..71cd7e1 100644 --- a/src/main/services/erp/erp-error-context.ts +++ b/src/main/services/erp/erp-error-context.ts @@ -16,6 +16,9 @@ export interface ErpErrorContext { targetSelector?: string step?: string screenshotPath?: string + orderId?: string + materialCode?: string + errorStage?: string } /** @@ -69,11 +72,18 @@ async function captureScreenshot(page: Page, step?: string): Promise { const ctx: ErpErrorContext = {} @@ -98,6 +108,16 @@ export async function capturePageContext( ctx.step = step } + if (orderId) { + ctx.orderId = orderId + } + + if (materialCode) { + ctx.materialCode = materialCode + } + + ctx.errorStage = stage + ctx.screenshotPath = await captureScreenshot(page, step) return ctx diff --git a/src/main/services/erp/extractor-core.ts b/src/main/services/erp/extractor-core.ts index 11daad0..d094424 100644 --- a/src/main/services/erp/extractor-core.ts +++ b/src/main/services/erp/extractor-core.ts @@ -116,7 +116,14 @@ export class ExtractorCore { if (!fFrame) { log.error('Failed to access popup forward frame', { - ...(await capturePageContext(popupPage, undefined, 'navigate.forwardFrame')) + ...(await capturePageContext( + popupPage, + undefined, + 'navigate.forwardFrame', + undefined, + undefined, + 'navigate_forward_frame' + )) }) throw new Error('Failed to access popup forward frame') } @@ -128,7 +135,14 @@ export class ExtractorCore { if (!workFrame) { log.error('Failed to access inner work frame', { - ...(await capturePageContext(popupPage, undefined, 'navigate.innerFrame')) + ...(await capturePageContext( + popupPage, + undefined, + 'navigate.innerFrame', + undefined, + undefined, + 'navigate_inner_frame' + )) }) throw new Error('Failed to access inner work frame') } diff --git a/src/main/services/user/migration/add-productionid-to-cleaner-order.ts b/src/main/services/user/migration/add-productionid-to-cleaner-order.ts index 8317746..8f98036 100644 --- a/src/main/services/user/migration/add-productionid-to-cleaner-order.ts +++ b/src/main/services/user/migration/add-productionid-to-cleaner-order.ts @@ -42,9 +42,7 @@ async function runMySQLMigration(): Promise { return } - await service.query( - `ALTER TABLE CleanerOrderHistory ADD COLUMN ProductionId VARCHAR(50) NULL` - ) + await service.query(`ALTER TABLE CleanerOrderHistory ADD COLUMN ProductionId VARCHAR(50) NULL`) console.log('Added ProductionId column to CleanerOrderHistory.') } finally { if (service.isConnected()) await service.disconnect() diff --git a/tests/unit/services/cleaner/cleaner-skipped-materials.test.ts b/tests/unit/services/cleaner/cleaner-skipped-materials.test.ts index ec2775b..0584217 100644 --- a/tests/unit/services/cleaner/cleaner-skipped-materials.test.ts +++ b/tests/unit/services/cleaner/cleaner-skipped-materials.test.ts @@ -91,7 +91,7 @@ vi.mock('../../../../src/main/services/erp/erp-auth', () => ({ })) vi.mock('../../../../src/main/services/database/cleaner-operation-history-dao', () => ({ -CleanerOperationHistoryDAO: class { + CleanerOperationHistoryDAO: class { async getBatchDetails(batchId: string) { return { executions: [{ attemptNumber: 1, isDryRun: false }], orders: [] } }