diff --git a/XplorePlane/Doc/CNC多检测结果归档.md b/XplorePlane/Doc/CNC多检测结果归档.md index fd167f3..eb786d4 100644 --- a/XplorePlane/Doc/CNC多检测结果归档.md +++ b/XplorePlane/Doc/CNC多检测结果归档.md @@ -45,6 +45,8 @@ - 图像或附件索引 - `PipelineExecutionSnapshot` - 节点执行时使用的 Pipeline 快照 +- `InspectionRunEvent` + - 运行过程事件记录,用于完整回放执行时间线 默认图片保留策略: @@ -119,7 +121,7 @@ Results/{yyyy}/{MM}/{dd}/{RunId}/ ## 4. 数据库表设计 -当前实现包含 5 张主表。 +当前实现包含 6 张主表。 ### 4.1 `inspection_runs` @@ -134,10 +136,21 @@ Results/{yyyy}/{MM}/{dd}/{RunId}/ | `started_at` | `TEXT` | 开始时间,ISO 8601 | | `completed_at` | `TEXT` | 结束时间,ISO 8601,可空 | | `overall_pass` | `INTEGER` | 整体判定,`0/1` | +| `status` | `TEXT` | 运行状态:`pending / running / completed / stopped / error` | | `source_image_path` | `TEXT` | 原图相对路径 | | `result_root_path` | `TEXT` | 本次结果包根目录相对路径 | | `node_count` | `INTEGER` | 节点数量 | +`status` 字段说明: + +| 状态值 | 含义 | +|---|---| +| `pending` | 等待执行(默认初始值) | +| `running` | 正在执行中 | +| `completed` | 正常完成 | +| `stopped` | 用户手动停止 | +| `error` | 异常终止 | + 样例数据: | run_id | program_name | workpiece_id | serial_number | started_at | completed_at | overall_pass | source_image_path | result_root_path | node_count | @@ -243,6 +256,42 @@ Results/{yyyy}/{MM}/{dd}/{RunId}/ | `7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a` | `11111111-1111-1111-1111-111111111111` | `Recipe-A` | `A1B2C3...` | | `7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a` | `22222222-2222-2222-2222-222222222222` | `Recipe-B` | `D4E5F6...` | +### 4.6 `inspection_run_events` + +用途:记录运行过程中的事件流水,用于完整回放一次 CNC 运行的时间线,便于故障排查和性能分析。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | `INTEGER` | 主键,自增 | +| `run_id` | `TEXT` | 所属检测实例 ID | +| `node_id` | `TEXT` | 关联节点 ID,运行级事件时为空 | +| `event_type` | `TEXT` | 事件类型 | +| `event_time` | `TEXT` | 事件时间,ISO 8601 | +| `payload_json` | `TEXT` | 事件附加数据,JSON 格式,可空 | + +约定的 `event_type`: + +| 事件类型 | 含义 | +|---|---| +| `RunStarted` | 运行开始 | +| `NodeStarted` | 节点开始执行 | +| `NodeCompleted` | 节点执行完成 | +| `NodeFailed` | 节点执行失败 | +| `RunStopped` | 运行被用户停止 | +| `RunCompleted` | 运行正常完成 | +| `RunError` | 运行异常终止 | + +样例数据: + +| id | run_id | node_id | event_type | event_time | payload_json | +|---:|---|---|---|---|---| +| `1` | `7d7d8d7d-...` | | `RunStarted` | `2026-04-21T10:00:00.0000000Z` | | +| `2` | `7d7d8d7d-...` | `11111111-...` | `NodeStarted` | `2026-04-21T10:00:00.1000000Z` | | +| `3` | `7d7d8d7d-...` | `11111111-...` | `NodeCompleted` | `2026-04-21T10:00:00.2350000Z` | | +| `4` | `7d7d8d7d-...` | `22222222-...` | `NodeStarted` | `2026-04-21T10:00:00.2400000Z` | | +| `5` | `7d7d8d7d-...` | `22222222-...` | `NodeFailed` | `2026-04-21T10:00:00.4800000Z` | `{"error":"Solder height out of range"}` | +| `6` | `7d7d8d7d-...` | | `RunCompleted` | `2026-04-21T10:00:03.2000000Z` | | + --- ## 5. `manifest.json` 示例 @@ -261,6 +310,7 @@ Results/{yyyy}/{MM}/{dd}/{RunId}/ "StartedAt": "2026-04-21T10:00:00.0000000Z", "CompletedAt": "2026-04-21T10:00:03.2000000Z", "OverallPass": false, + "Status": "completed", "SourceImagePath": "Results/2026/04/21/7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a/run/source.bmp", "ResultRootPath": "Results/2026/04/21/7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a", "NodeCount": 2 @@ -318,6 +368,40 @@ Results/{yyyy}/{MM}/{dd}/{RunId}/ "IsPass": false, "DisplayOrder": 1 } + ], + "Events": [ + { + "Id": 1, + "RunId": "7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a", + "NodeId": null, + "EventType": "RunStarted", + "EventTime": "2026-04-21T10:00:00.0000000Z", + "PayloadJson": "" + }, + { + "Id": 2, + "RunId": "7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a", + "NodeId": "11111111-1111-1111-1111-111111111111", + "EventType": "NodeStarted", + "EventTime": "2026-04-21T10:00:00.1000000Z", + "PayloadJson": "" + }, + { + "Id": 3, + "RunId": "7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a", + "NodeId": "11111111-1111-1111-1111-111111111111", + "EventType": "NodeCompleted", + "EventTime": "2026-04-21T10:00:00.2350000Z", + "PayloadJson": "" + }, + { + "Id": 6, + "RunId": "7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a", + "NodeId": null, + "EventType": "RunCompleted", + "EventTime": "2026-04-21T10:00:03.2000000Z", + "PayloadJson": "" + } ] } ``` @@ -355,11 +439,12 @@ Results/{yyyy}/{MM}/{dd}/{RunId}/ 得到: -- `Run` +- `Run`(含 `Status` 字段) - `Nodes` - `Metrics` - `Assets` - `PipelineSnapshots` +- `Events`(运行事件时间线) 即可直接组装报告: @@ -395,15 +480,19 @@ Results/{yyyy}/{MM}/{dd}/{RunId}/ 当前服务接口: - `BeginRunAsync(...)` - - 创建实例记录和结果目录 + - 创建实例记录和结果目录,设置状态为 `running`,记录 `RunStarted` 事件 - `AppendNodeResultAsync(...)` - 写入节点结果、指标、图片索引、Pipeline 快照 - `CompleteRunAsync(...)` - - 回填结束时间、整体判定,并写出 `manifest.json` + - 回填结束时间、整体判定,设置状态为 `completed` 或 `stopped`,记录对应事件,并写出 `manifest.json` +- `AppendRunEventAsync(...)` + - 追加运行事件记录,用于完整回放执行时间线 +- `QueryRunEventsAsync(...)` + - 查询指定运行的所有事件记录 - `QueryRunsAsync(...)` - 查询检测实例列表 - `GetRunDetailAsync(...)` - - 查询单个实例的完整报告数据 + - 查询单个实例的完整报告数据(含事件列表) 当前 DI 注册: @@ -498,8 +587,9 @@ Results/{yyyy}/{MM}/{dd}/{RunId}/ 对于后续报告模块,这套结构已经可以直接支持: -- 历史列表查询 +- 历史列表查询(支持按状态筛选) - 单次检测报告生成 - 结果图展示 - 节点级指标展示 - 历史结果可追溯 +- 执行过程时间线回放(故障排查、性能分析) diff --git a/XplorePlane/Models/InspectionResultModels.cs b/XplorePlane/Models/InspectionResultModels.cs index 9f439b2..bde9ab4 100644 --- a/XplorePlane/Models/InspectionResultModels.cs +++ b/XplorePlane/Models/InspectionResultModels.cs @@ -10,6 +10,44 @@ namespace XplorePlane.Models NodeResultImage } + /// + /// 检测运行状态枚举 + /// + public enum InspectionRunStatus + { + /// 等待执行 + Pending, + /// 正在执行 + Running, + /// 正常完成 + Completed, + /// 用户停止 + Stopped, + /// 异常终止 + Error + } + + /// + /// 运行事件类型枚举 + /// + public enum InspectionRunEventType + { + /// 运行开始 + RunStarted, + /// 节点开始执行 + NodeStarted, + /// 节点执行完成 + NodeCompleted, + /// 节点执行失败 + NodeFailed, + /// 运行被用户停止 + RunStopped, + /// 运行正常完成 + RunCompleted, + /// 运行异常终止 + RunError + } + public enum InspectionNodeStatus { Succeeded, @@ -27,6 +65,7 @@ namespace XplorePlane.Models public DateTime StartedAt { get; set; } = DateTime.UtcNow; public DateTime? CompletedAt { get; set; } public bool OverallPass { get; set; } + public InspectionRunStatus Status { get; set; } = InspectionRunStatus.Pending; public string SourceImagePath { get; set; } = string.Empty; public string ResultRootPath { get; set; } = string.Empty; public int NodeCount { get; set; } @@ -112,5 +151,19 @@ namespace XplorePlane.Models public IReadOnlyList Metrics { get; set; } = Array.Empty(); public IReadOnlyList Assets { get; set; } = Array.Empty(); public IReadOnlyList PipelineSnapshots { get; set; } = Array.Empty(); + public IReadOnlyList Events { get; set; } = Array.Empty(); + } + + /// + /// 运行事件记录,用于完整回放一次 CNC 运行的时间线 + /// + public class InspectionRunEvent + { + public long Id { get; set; } + public Guid RunId { get; set; } + public Guid? NodeId { get; set; } + public InspectionRunEventType EventType { get; set; } + public DateTime EventTime { get; set; } = DateTime.UtcNow; + public string PayloadJson { get; set; } = string.Empty; } } diff --git a/XplorePlane/Services/InspectionResults/IInspectionResultStore.cs b/XplorePlane/Services/InspectionResults/IInspectionResultStore.cs index 7944723..794afdc 100644 --- a/XplorePlane/Services/InspectionResults/IInspectionResultStore.cs +++ b/XplorePlane/Services/InspectionResults/IInspectionResultStore.cs @@ -17,6 +17,16 @@ namespace XplorePlane.Services.InspectionResults Task CompleteRunAsync(Guid runId, bool? overallPass = null, DateTime? completedAt = null); + /// + /// 追加运行事件记录,用于完整回放一次 CNC 运行的时间线。 + /// + Task AppendRunEventAsync(Guid runId, Guid? nodeId, InspectionRunEventType eventType, string payloadJson = null); + + /// + /// 查询指定运行的所有事件记录。 + /// + Task> QueryRunEventsAsync(Guid runId); + Task> QueryRunsAsync(InspectionRunQuery query = null); Task GetRunDetailAsync(Guid runId); diff --git a/XplorePlane/Services/InspectionResults/InspectionResultStore.cs b/XplorePlane/Services/InspectionResults/InspectionResultStore.cs index e4237d0..f825299 100644 --- a/XplorePlane/Services/InspectionResults/InspectionResultStore.cs +++ b/XplorePlane/Services/InspectionResults/InspectionResultStore.cs @@ -37,6 +37,7 @@ CREATE TABLE IF NOT EXISTS inspection_runs ( started_at TEXT NOT NULL, completed_at TEXT NULL, overall_pass INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending', source_image_path TEXT NOT NULL, result_root_path TEXT NOT NULL, node_count INTEGER NOT NULL DEFAULT 0 @@ -45,6 +46,7 @@ CREATE INDEX IF NOT EXISTS idx_inspection_runs_started_at ON inspection_runs(sta CREATE INDEX IF NOT EXISTS idx_inspection_runs_program_name ON inspection_runs(program_name); CREATE INDEX IF NOT EXISTS idx_inspection_runs_workpiece_id ON inspection_runs(workpiece_id); CREATE INDEX IF NOT EXISTS idx_inspection_runs_serial_number ON inspection_runs(serial_number); +CREATE INDEX IF NOT EXISTS idx_inspection_runs_status ON inspection_runs(status); CREATE TABLE IF NOT EXISTS inspection_node_results ( run_id TEXT NOT NULL, @@ -99,15 +101,27 @@ CREATE TABLE IF NOT EXISTS pipeline_execution_snapshots ( pipeline_hash TEXT NOT NULL, PRIMARY KEY (run_id, node_id) ); -CREATE INDEX IF NOT EXISTS idx_pipeline_snapshots_run_id ON pipeline_execution_snapshots(run_id);"; +CREATE INDEX IF NOT EXISTS idx_pipeline_snapshots_run_id ON pipeline_execution_snapshots(run_id); + +CREATE TABLE IF NOT EXISTS inspection_run_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL, + node_id TEXT NULL, + event_type TEXT NOT NULL, + event_time TEXT NOT NULL, + payload_json TEXT NULL, + FOREIGN KEY (run_id) REFERENCES inspection_runs(run_id) +); +CREATE INDEX IF NOT EXISTS idx_run_events_run_id ON inspection_run_events(run_id); +CREATE INDEX IF NOT EXISTS idx_run_events_event_type ON inspection_run_events(event_type);"; private const string InsertRunSql = @" INSERT INTO inspection_runs ( run_id, program_name, workpiece_id, serial_number, started_at, completed_at, - overall_pass, source_image_path, result_root_path, node_count) + overall_pass, status, source_image_path, result_root_path, node_count) VALUES ( @run_id, @program_name, @workpiece_id, @serial_number, @started_at, @completed_at, - @overall_pass, @source_image_path, @result_root_path, @node_count)"; + @overall_pass, @status, @source_image_path, @result_root_path, @node_count)"; private const string InsertNodeSql = @" INSERT OR REPLACE INTO inspection_node_results ( @@ -140,10 +154,19 @@ VALUES ( private const string DeleteNodeMetricsSql = "DELETE FROM inspection_metric_results WHERE run_id = @run_id AND node_id = @node_id"; private const string DeleteNodeAssetsSql = "DELETE FROM inspection_assets WHERE run_id = @run_id AND node_id = @node_id"; + private const string InsertEventSql = @" +INSERT INTO inspection_run_events (run_id, node_id, event_type, event_time, payload_json) +VALUES (@run_id, @node_id, @event_type, @event_time, @payload_json)"; + + private const string QueryEventsSql = @" +SELECT id, run_id, node_id, event_type, event_time, payload_json +FROM inspection_run_events WHERE run_id = @run_id ORDER BY id ASC"; + private const string UpdateRunSql = @" UPDATE inspection_runs SET completed_at = @completed_at, overall_pass = @overall_pass, + status = @status, node_count = @node_count, source_image_path = @source_image_path WHERE run_id = @run_id"; @@ -207,6 +230,7 @@ WHERE run_id = @run_id"; ["started_at"] = runRecord.StartedAt.ToString("o"), ["completed_at"] = runRecord.CompletedAt?.ToString("o"), ["overall_pass"] = runRecord.OverallPass ? 1 : 0, + ["status"] = runRecord.Status.ToString().ToLowerInvariant(), ["source_image_path"] = runRecord.SourceImagePath, ["result_root_path"] = runRecord.ResultRootPath, ["node_count"] = runRecord.NodeCount @@ -217,6 +241,9 @@ WHERE run_id = @run_id"; _logger.Error(result.Exception!, "创建检测实例失败 | Failed to create inspection run: {Message}", result.Message); throw new InvalidOperationException($"创建检测实例失败: {result.Message}", result.Exception); } + + // 记录运行开始事件 + await AppendRunEventAsync(runRecord.RunId, null, InspectionRunEventType.RunStarted).ConfigureAwait(false); } public async Task AppendNodeResultAsync( @@ -392,11 +419,22 @@ WHERE run_id = @run_id"; run.NodeCount = detail.Nodes.Count; run.OverallPass = overallPass ?? detail.Nodes.All(node => node.NodePass); + // 根据 overallPass 参数确定最终状态 + if (overallPass == null) + { + run.Status = InspectionRunStatus.Stopped; + } + else + { + run.Status = InspectionRunStatus.Completed; + } + var update = await _db.ExecuteNonQueryAsync(UpdateRunSql, new Dictionary { ["run_id"] = run.RunId.ToString("D"), ["completed_at"] = run.CompletedAt?.ToString("o"), ["overall_pass"] = run.OverallPass ? 1 : 0, + ["status"] = run.Status.ToString().ToLowerInvariant(), ["node_count"] = run.NodeCount, ["source_image_path"] = run.SourceImagePath }).ConfigureAwait(false); @@ -407,6 +445,10 @@ WHERE run_id = @run_id"; throw new InvalidOperationException($"完成检测实例失败: {update.Message}", update.Exception); } + // 记录运行完成/停止事件 + var eventType = overallPass == null ? InspectionRunEventType.RunStopped : InspectionRunEventType.RunCompleted; + await AppendRunEventAsync(runId, null, eventType).ConfigureAwait(false); + detail.Run = run; await WriteManifestAsync(detail).ConfigureAwait(false); } @@ -540,6 +582,66 @@ WHERE run_id = @run_id"; }; } + /// + /// 追加运行事件记录,用于完整回放一次 CNC 运行的时间线。 + /// + public async Task AppendRunEventAsync(Guid runId, Guid? nodeId, InspectionRunEventType eventType, string payloadJson = null) + { + await EnsureInitializedAsync().ConfigureAwait(false); + + var result = await _db.ExecuteNonQueryAsync(InsertEventSql, new Dictionary + { + ["run_id"] = runId.ToString("D"), + ["node_id"] = nodeId?.ToString("D"), + ["event_type"] = eventType.ToString(), + ["event_time"] = DateTime.UtcNow.ToString("o"), + ["payload_json"] = payloadJson + }).ConfigureAwait(false); + + if (!result.IsSuccess) + { + _logger.Warn("记录运行事件失败 | Failed to append run event: {Message}", result.Message); + } + } + + /// + /// 查询指定运行的所有事件记录。 + /// + public async Task> QueryRunEventsAsync(Guid runId) + { + await EnsureInitializedAsync().ConfigureAwait(false); + + var result = await _db.QueryListAsync(QueryEventsSql, new Dictionary + { + ["run_id"] = runId.ToString("D") + }).ConfigureAwait(false); + + if (!result.Result.IsSuccess) + { + return Array.Empty(); + } + + var events = new List(); + foreach (var row in result.Data) + { + events.Add(MapEvent(row)); + } + return events; + } + + private static InspectionRunEvent MapEvent(InspectionRunEventRow row) + { + return new InspectionRunEvent + { + Id = row.id, + RunId = Guid.Parse(row.run_id), + NodeId = string.IsNullOrWhiteSpace(row.node_id) ? null : Guid.Parse(row.node_id), + EventType = Enum.TryParse(row.event_type, out var et) ? et : InspectionRunEventType.RunStarted, + EventTime = DateTime.Parse(row.event_time, null, System.Globalization.DateTimeStyles.RoundtripKind), + PayloadJson = row.payload_json ?? string.Empty + }; + } + private async Task EnsureInitializedAsync() { if (_initialized) @@ -813,6 +915,9 @@ WHERE run_id = @run_id"; runRecord.StartedAt = DateTime.UtcNow; } + // BeginRun 时状态应为 Running + runRecord.Status = InspectionRunStatus.Running; + runRecord.ResultRootPath = Path.Combine( "Results", runRecord.StartedAt.ToString("yyyy"), @@ -860,12 +965,25 @@ WHERE run_id = @run_id"; ? null : DateTime.Parse(row.completed_at, null, System.Globalization.DateTimeStyles.RoundtripKind), OverallPass = row.overall_pass != 0, + Status = ParseRunStatus(row.status), SourceImagePath = row.source_image_path, ResultRootPath = row.result_root_path, NodeCount = row.node_count }; } + private static InspectionRunStatus ParseRunStatus(string status) + { + return status?.ToLowerInvariant() switch + { + "running" => InspectionRunStatus.Running, + "completed" => InspectionRunStatus.Completed, + "stopped" => InspectionRunStatus.Stopped, + "error" => InspectionRunStatus.Error, + _ => InspectionRunStatus.Pending + }; + } + private static InspectionNodeResult MapNode(InspectionNodeRow row) { _ = Enum.TryParse(row.status, out var status); @@ -957,6 +1075,7 @@ WHERE run_id = @run_id"; public string started_at { get; set; } = string.Empty; public string completed_at { get; set; } = string.Empty; public int overall_pass { get; set; } + public string status { get; set; } = "pending"; public string source_image_path { get; set; } = string.Empty; public string result_root_path { get; set; } = string.Empty; public int node_count { get; set; } @@ -1011,5 +1130,15 @@ WHERE run_id = @run_id"; public string pipeline_definition_json { get; set; } = string.Empty; public string pipeline_hash { get; set; } = string.Empty; } + + internal class InspectionRunEventRow + { + public long id { get; set; } + public string run_id { get; set; } = string.Empty; + public string node_id { get; set; } = string.Empty; + public string event_type { get; set; } = string.Empty; + public string event_time { get; set; } = string.Empty; + public string payload_json { get; set; } = string.Empty; + } } }