From 238e97d110156a10d481e7ee75147afacc7e7342 Mon Sep 17 00:00:00 2001 From: "zhengxuan.zhang" Date: Tue, 21 Apr 2026 07:32:28 +0800 Subject: [PATCH] =?UTF-8?q?CNC=E6=95=B0=E6=8D=AE=E5=AD=98=E5=82=A8?= =?UTF-8?q?=E9=97=AE=E9=A2=98=EF=BC=8C=E5=8C=85=E6=8B=AC=E4=B8=AD=E9=97=B4?= =?UTF-8?q?=E7=9A=84=E5=A4=84=E7=90=86=E6=83=85=E5=86=B5=E7=9A=84=E7=BC=93?= =?UTF-8?q?=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/InspectionResultStoreTests.cs | 340 ++++++ XplorePlane/App.xaml.cs | 4 +- XplorePlane/Models/InspectionResultModels.cs | 116 ++ .../IInspectionResultStore.cs | 24 + .../InspectionResultStore.cs | 1006 +++++++++++++++++ XplorePlane/Views/Cnc/CncEditorWindow.xaml | 8 +- XplorePlane/Views/Cnc/CncPageView.xaml | 484 ++++++-- 7 files changed, 1871 insertions(+), 111 deletions(-) create mode 100644 XplorePlane.Tests/Services/InspectionResultStoreTests.cs create mode 100644 XplorePlane/Models/InspectionResultModels.cs create mode 100644 XplorePlane/Services/InspectionResults/IInspectionResultStore.cs create mode 100644 XplorePlane/Services/InspectionResults/InspectionResultStore.cs diff --git a/XplorePlane.Tests/Services/InspectionResultStoreTests.cs b/XplorePlane.Tests/Services/InspectionResultStoreTests.cs new file mode 100644 index 0000000..640eeea --- /dev/null +++ b/XplorePlane.Tests/Services/InspectionResultStoreTests.cs @@ -0,0 +1,340 @@ +using Moq; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using XP.Common.Configs; +using XP.Common.Database.Implementations; +using XP.Common.Database.Interfaces; +using XP.Common.Logging.Interfaces; +using XplorePlane.Models; +using XplorePlane.Services.InspectionResults; +using Xunit; + +namespace XplorePlane.Tests.Services +{ + public class InspectionResultStoreTests : IDisposable + { + private readonly string _tempRoot; + private readonly Mock _mockLogger; + private readonly IDbContext _dbContext; + private readonly InspectionResultStore _store; + + public InspectionResultStoreTests() + { + _tempRoot = Path.Combine(Path.GetTempPath(), "XplorePlane.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempRoot); + + _mockLogger = new Mock(); + _mockLogger.Setup(l => l.ForModule(It.IsAny())).Returns(_mockLogger.Object); + _mockLogger.Setup(l => l.ForModule()).Returns(_mockLogger.Object); + + var sqliteConfig = new SqliteConfig + { + DbFilePath = Path.Combine(_tempRoot, "inspection-results.db"), + CreateIfNotExists = true, + EnableWalMode = false, + EnableSqlLogging = false + }; + + _dbContext = new SqliteContext(sqliteConfig, _mockLogger.Object); + _store = new InspectionResultStore(_dbContext, _mockLogger.Object, Path.Combine(_tempRoot, "assets")); + } + + [Fact] + public async Task FullRun_WithTwoNodes_CanRoundTripDetailAndQuery() + { + var startedAt = new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc); + var run = new InspectionRunRecord + { + ProgramName = "NewCncProgram", + WorkpieceId = "QFN_1", + SerialNumber = "SN-001", + StartedAt = startedAt + }; + + var runSource = CreateTempFile("run-source.bmp", "run-source"); + await _store.BeginRunAsync(run, new InspectionAssetWriteRequest + { + AssetType = InspectionAssetType.RunSourceImage, + SourceFilePath = runSource, + FileFormat = "bmp" + }); + + var pipelineA = BuildPipeline("Recipe-A", ("GaussianBlur", 0), ("Threshold", 1)); + var node1Id = Guid.NewGuid(); + await _store.AppendNodeResultAsync( + new InspectionNodeResult + { + RunId = run.RunId, + NodeId = node1Id, + NodeIndex = 1, + NodeName = "检测节点1", + PipelineId = pipelineA.Id, + PipelineName = pipelineA.Name, + NodePass = true, + DurationMs = 135 + }, + new[] + { + new InspectionMetricResult + { + MetricKey = "bridge.rate", + MetricName = "Bridge Rate", + MetricValue = 0.12, + Unit = "%", + UpperLimit = 0.2, + IsPass = true, + DisplayOrder = 1 + }, + new InspectionMetricResult + { + MetricKey = "void.area", + MetricName = "Void Area", + MetricValue = 5.6, + Unit = "px", + UpperLimit = 8, + IsPass = true, + DisplayOrder = 2 + } + }, + new PipelineExecutionSnapshot + { + PipelineName = pipelineA.Name, + PipelineDefinitionJson = JsonSerializer.Serialize(pipelineA) + }, + new[] + { + new InspectionAssetWriteRequest + { + AssetType = InspectionAssetType.NodeInputImage, + SourceFilePath = CreateTempFile("node1-input.bmp", "node1-input"), + FileFormat = "bmp" + }, + new InspectionAssetWriteRequest + { + AssetType = InspectionAssetType.NodeResultImage, + SourceFilePath = CreateTempFile("node1-result.bmp", "node1-result"), + FileFormat = "bmp" + } + }); + + var pipelineB = BuildPipeline("Recipe-B", ("MeanFilter", 0), ("ContourDetection", 1)); + var node2Id = Guid.NewGuid(); + await _store.AppendNodeResultAsync( + new InspectionNodeResult + { + RunId = run.RunId, + NodeId = node2Id, + NodeIndex = 2, + NodeName = "检测节点2", + PipelineId = pipelineB.Id, + PipelineName = pipelineB.Name, + NodePass = false, + Status = InspectionNodeStatus.Failed, + DurationMs = 240 + }, + new[] + { + new InspectionMetricResult + { + MetricKey = "solder.height", + MetricName = "Solder Height", + MetricValue = 1.7, + Unit = "mm", + LowerLimit = 1.8, + IsPass = false, + DisplayOrder = 1 + } + }, + new PipelineExecutionSnapshot + { + PipelineName = pipelineB.Name, + PipelineDefinitionJson = JsonSerializer.Serialize(pipelineB) + }, + new[] + { + new InspectionAssetWriteRequest + { + AssetType = InspectionAssetType.NodeResultImage, + SourceFilePath = CreateTempFile("node2-result.bmp", "node2-result"), + FileFormat = "bmp" + } + }); + + await _store.CompleteRunAsync(run.RunId); + + var queried = await _store.QueryRunsAsync(new InspectionRunQuery + { + ProgramName = "NewCncProgram", + WorkpieceId = "QFN_1", + PipelineName = "Recipe-A" + }); + + var detail = await _store.GetRunDetailAsync(run.RunId); + + Assert.Single(queried); + Assert.Equal(run.RunId, queried[0].RunId); + Assert.False(detail.Run.OverallPass); + Assert.Equal(2, detail.Run.NodeCount); + Assert.Equal(2, detail.Nodes.Count); + Assert.Equal(3, detail.Metrics.Count); + Assert.Equal(4, detail.Assets.Count); + Assert.Equal(2, detail.PipelineSnapshots.Count); + Assert.Contains(detail.Nodes, n => n.NodeId == node1Id && n.NodePass); + Assert.Contains(detail.Nodes, n => n.NodeId == node2Id && !n.NodePass); + Assert.All(detail.PipelineSnapshots, snapshot => Assert.False(string.IsNullOrWhiteSpace(snapshot.PipelineHash))); + + var manifestPath = Path.Combine(_tempRoot, "assets", detail.Run.ResultRootPath.Replace('/', Path.DirectorySeparatorChar), "manifest.json"); + Assert.True(File.Exists(manifestPath)); + } + + [Fact] + public async Task AppendNodeResult_MissingAsset_DoesNotCrashAndMarksAssetMissing() + { + var run = new InspectionRunRecord + { + ProgramName = "Program-A", + WorkpieceId = "Part-01", + SerialNumber = "SN-404" + }; + + await _store.BeginRunAsync(run); + + var nodeId = Guid.NewGuid(); + await _store.AppendNodeResultAsync( + new InspectionNodeResult + { + RunId = run.RunId, + NodeId = nodeId, + NodeIndex = 1, + NodeName = "缺图节点", + PipelineId = Guid.NewGuid(), + PipelineName = "Recipe-Missing", + NodePass = true + }, + new[] + { + new InspectionMetricResult + { + MetricKey = "metric.only", + MetricName = "Metric Only", + MetricValue = 1, + Unit = "pcs", + IsPass = true, + DisplayOrder = 1 + } + }, + new PipelineExecutionSnapshot + { + PipelineName = "Recipe-Missing", + PipelineDefinitionJson = "{\"nodes\":[\"gaussian\"]}" + }, + new[] + { + new InspectionAssetWriteRequest + { + AssetType = InspectionAssetType.NodeResultImage, + SourceFilePath = Path.Combine(_tempRoot, "missing-file.bmp"), + FileFormat = "bmp" + } + }); + + var detail = await _store.GetRunDetailAsync(run.RunId); + var node = Assert.Single(detail.Nodes); + + Assert.Equal(InspectionNodeStatus.AssetMissing, node.Status); + Assert.Single(detail.Metrics); + Assert.Empty(detail.Assets); + } + + [Fact] + public async Task PipelineSnapshot_IsStoredAsExecutionSnapshot_NotDependentOnLaterChanges() + { + var run = new InspectionRunRecord + { + ProgramName = "Program-Snapshot", + WorkpieceId = "Part-02", + SerialNumber = "SN-SNAP" + }; + + await _store.BeginRunAsync(run); + + var pipeline = BuildPipeline("Recipe-Snapshot", ("GaussianBlur", 0), ("ContourDetection", 1)); + var snapshotJson = JsonSerializer.Serialize(pipeline); + var originalHash = ComputeExpectedHash(snapshotJson); + + await _store.AppendNodeResultAsync( + new InspectionNodeResult + { + RunId = run.RunId, + NodeId = Guid.NewGuid(), + NodeIndex = 1, + NodeName = "快照节点", + PipelineId = pipeline.Id, + PipelineName = pipeline.Name, + NodePass = true + }, + pipelineSnapshot: new PipelineExecutionSnapshot + { + PipelineName = pipeline.Name, + PipelineDefinitionJson = snapshotJson + }); + + pipeline.Name = "Recipe-Snapshot-Changed"; + pipeline.Nodes[0].OperatorKey = "MeanFilter"; + + var detail = await _store.GetRunDetailAsync(run.RunId); + var snapshot = Assert.Single(detail.PipelineSnapshots); + + Assert.Equal("Recipe-Snapshot", snapshot.PipelineName); + Assert.Equal(snapshotJson, snapshot.PipelineDefinitionJson); + Assert.Equal(originalHash, snapshot.PipelineHash); + } + + public void Dispose() + { + _dbContext.Dispose(); + if (Directory.Exists(_tempRoot)) + { + try + { + Directory.Delete(_tempRoot, true); + } + catch (IOException) + { + // SQLite file handles may release slightly after test teardown. + } + } + } + + private string CreateTempFile(string fileName, string content) + { + var path = Path.Combine(_tempRoot, fileName); + File.WriteAllText(path, content); + return path; + } + + private static PipelineModel BuildPipeline(string name, params (string OperatorKey, int Order)[] nodes) + { + return new PipelineModel + { + Name = name, + Nodes = nodes.Select(node => new PipelineNodeModel + { + OperatorKey = node.OperatorKey, + Order = node.Order + }).ToList() + }; + } + + private static string ComputeExpectedHash(string value) + { + using var sha = System.Security.Cryptography.SHA256.Create(); + var bytes = System.Text.Encoding.UTF8.GetBytes(value); + return Convert.ToHexString(sha.ComputeHash(bytes)); + } + } +} diff --git a/XplorePlane/App.xaml.cs b/XplorePlane/App.xaml.cs index 6f1bd73..c9258a7 100644 --- a/XplorePlane/App.xaml.cs +++ b/XplorePlane/App.xaml.cs @@ -36,6 +36,7 @@ using XplorePlane.Services.Camera; using XplorePlane.Services.Cnc; using XplorePlane.Services.Matrix; using XplorePlane.Services.Measurement; +using XplorePlane.Services.InspectionResults; using XplorePlane.Services.Recipe; using XplorePlane.ViewModels; using XplorePlane.ViewModels.Cnc; @@ -317,6 +318,7 @@ namespace XplorePlane containerRegistry.RegisterSingleton(); containerRegistry.RegisterSingleton(); containerRegistry.RegisterSingleton(); + containerRegistry.RegisterSingleton(); // ── CNC / 矩阵 ViewModel(瞬态)── containerRegistry.Register(); @@ -354,4 +356,4 @@ namespace XplorePlane base.ConfigureModuleCatalog(moduleCatalog); } } -} \ No newline at end of file +} diff --git a/XplorePlane/Models/InspectionResultModels.cs b/XplorePlane/Models/InspectionResultModels.cs new file mode 100644 index 0000000..9f439b2 --- /dev/null +++ b/XplorePlane/Models/InspectionResultModels.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; + +namespace XplorePlane.Models +{ + public enum InspectionAssetType + { + RunSourceImage, + NodeInputImage, + NodeResultImage + } + + public enum InspectionNodeStatus + { + Succeeded, + Failed, + PartialSuccess, + AssetMissing + } + + public class InspectionRunRecord + { + public Guid RunId { get; set; } = Guid.NewGuid(); + public string ProgramName { get; set; } = string.Empty; + public string WorkpieceId { get; set; } = string.Empty; + public string SerialNumber { get; set; } = string.Empty; + public DateTime StartedAt { get; set; } = DateTime.UtcNow; + public DateTime? CompletedAt { get; set; } + public bool OverallPass { get; set; } + public string SourceImagePath { get; set; } = string.Empty; + public string ResultRootPath { get; set; } = string.Empty; + public int NodeCount { get; set; } + } + + public class InspectionNodeResult + { + public Guid RunId { get; set; } + public Guid NodeId { get; set; } = Guid.NewGuid(); + public int NodeIndex { get; set; } + public string NodeName { get; set; } = string.Empty; + public Guid PipelineId { get; set; } + public string PipelineName { get; set; } = string.Empty; + public string PipelineVersionHash { get; set; } = string.Empty; + public bool NodePass { get; set; } + public string SourceImagePath { get; set; } = string.Empty; + public string ResultImagePath { get; set; } = string.Empty; + public InspectionNodeStatus Status { get; set; } = InspectionNodeStatus.Succeeded; + public long DurationMs { get; set; } + } + + public class InspectionMetricResult + { + public Guid RunId { get; set; } + public Guid NodeId { get; set; } + public string MetricKey { get; set; } = string.Empty; + public string MetricName { get; set; } = string.Empty; + public double MetricValue { get; set; } + public string Unit { get; set; } = string.Empty; + public double? LowerLimit { get; set; } + public double? UpperLimit { get; set; } + public bool IsPass { get; set; } + public int DisplayOrder { get; set; } + } + + public class InspectionAssetRecord + { + public Guid RunId { get; set; } + public Guid? NodeId { get; set; } + public InspectionAssetType AssetType { get; set; } + public string RelativePath { get; set; } = string.Empty; + public string FileFormat { get; set; } = string.Empty; + public int Width { get; set; } + public int Height { get; set; } + } + + public class PipelineExecutionSnapshot + { + public Guid RunId { get; set; } + public Guid NodeId { get; set; } + public string PipelineName { get; set; } = string.Empty; + public string PipelineDefinitionJson { get; set; } = string.Empty; + public string PipelineHash { get; set; } = string.Empty; + } + + public class InspectionAssetWriteRequest + { + public InspectionAssetType AssetType { get; set; } + public string FileName { get; set; } = string.Empty; + public string SourceFilePath { get; set; } = string.Empty; + public byte[] Content { get; set; } + public string FileFormat { get; set; } = string.Empty; + public int Width { get; set; } + public int Height { get; set; } + } + + public class InspectionRunQuery + { + public string ProgramName { get; set; } = string.Empty; + public string WorkpieceId { get; set; } = string.Empty; + public string SerialNumber { get; set; } = string.Empty; + public string PipelineName { get; set; } = string.Empty; + public DateTime? From { get; set; } + public DateTime? To { get; set; } + public int? Skip { get; set; } + public int? Take { get; set; } + } + + public class InspectionRunDetail + { + public InspectionRunRecord Run { get; set; } = new(); + public IReadOnlyList Nodes { get; set; } = Array.Empty(); + public IReadOnlyList Metrics { get; set; } = Array.Empty(); + public IReadOnlyList Assets { get; set; } = Array.Empty(); + public IReadOnlyList PipelineSnapshots { get; set; } = Array.Empty(); + } +} diff --git a/XplorePlane/Services/InspectionResults/IInspectionResultStore.cs b/XplorePlane/Services/InspectionResults/IInspectionResultStore.cs new file mode 100644 index 0000000..7944723 --- /dev/null +++ b/XplorePlane/Services/InspectionResults/IInspectionResultStore.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using XplorePlane.Models; + +namespace XplorePlane.Services.InspectionResults +{ + public interface IInspectionResultStore + { + Task BeginRunAsync(InspectionRunRecord runRecord, InspectionAssetWriteRequest runSourceAsset = null); + + Task AppendNodeResultAsync( + InspectionNodeResult nodeResult, + IEnumerable metrics = null, + PipelineExecutionSnapshot pipelineSnapshot = null, + IEnumerable assets = null); + + Task CompleteRunAsync(Guid runId, bool? overallPass = null, DateTime? completedAt = null); + + Task> QueryRunsAsync(InspectionRunQuery query = null); + + Task GetRunDetailAsync(Guid runId); + } +} diff --git a/XplorePlane/Services/InspectionResults/InspectionResultStore.cs b/XplorePlane/Services/InspectionResults/InspectionResultStore.cs new file mode 100644 index 0000000..88c046f --- /dev/null +++ b/XplorePlane/Services/InspectionResults/InspectionResultStore.cs @@ -0,0 +1,1006 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using XP.Common.Database.Interfaces; +using XP.Common.Logging.Interfaces; +using XplorePlane.Models; + +namespace XplorePlane.Services.InspectionResults +{ + public class InspectionResultStore : IInspectionResultStore + { + private readonly IDbContext _db; + private readonly ILoggerService _logger; + private readonly string _baseDirectory; + private bool _initialized; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter() } + }; + + private const string CreateTableSql = @" +CREATE TABLE IF NOT EXISTS inspection_runs ( + run_id TEXT PRIMARY KEY, + program_name TEXT NOT NULL, + workpiece_id TEXT NOT NULL, + serial_number TEXT NOT NULL, + started_at TEXT NOT NULL, + completed_at TEXT NULL, + overall_pass INTEGER NOT NULL DEFAULT 0, + source_image_path TEXT NOT NULL, + result_root_path TEXT NOT NULL, + node_count INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_inspection_runs_started_at ON inspection_runs(started_at); +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 TABLE IF NOT EXISTS inspection_node_results ( + run_id TEXT NOT NULL, + node_id TEXT NOT NULL, + node_index INTEGER NOT NULL, + node_name TEXT NOT NULL, + pipeline_id TEXT NOT NULL, + pipeline_name TEXT NOT NULL, + pipeline_version_hash TEXT NOT NULL, + node_pass INTEGER NOT NULL DEFAULT 0, + source_image_path TEXT NOT NULL, + result_image_path TEXT NOT NULL, + status TEXT NOT NULL, + duration_ms INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (run_id, node_id) +); +CREATE INDEX IF NOT EXISTS idx_inspection_nodes_run_id ON inspection_node_results(run_id); +CREATE INDEX IF NOT EXISTS idx_inspection_nodes_pipeline_name ON inspection_node_results(pipeline_name); + +CREATE TABLE IF NOT EXISTS inspection_metric_results ( + run_id TEXT NOT NULL, + node_id TEXT NOT NULL, + metric_key TEXT NOT NULL, + metric_name TEXT NOT NULL, + metric_value REAL NOT NULL, + unit TEXT NOT NULL, + lower_limit REAL NULL, + upper_limit REAL NULL, + is_pass INTEGER NOT NULL DEFAULT 0, + display_order INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (run_id, node_id, metric_key) +); +CREATE INDEX IF NOT EXISTS idx_inspection_metrics_run_node ON inspection_metric_results(run_id, node_id); + +CREATE TABLE IF NOT EXISTS inspection_assets ( + run_id TEXT NOT NULL, + node_id TEXT NULL, + asset_type TEXT NOT NULL, + relative_path TEXT NOT NULL, + file_format TEXT NOT NULL, + width INTEGER NOT NULL DEFAULT 0, + height INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (run_id, node_id, asset_type, relative_path) +); +CREATE INDEX IF NOT EXISTS idx_inspection_assets_run_node ON inspection_assets(run_id, node_id); + +CREATE TABLE IF NOT EXISTS pipeline_execution_snapshots ( + run_id TEXT NOT NULL, + node_id TEXT NOT NULL, + pipeline_name TEXT NOT NULL, + pipeline_definition_json TEXT NOT NULL, + 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);"; + + 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) +VALUES ( + @run_id, @program_name, @workpiece_id, @serial_number, @started_at, @completed_at, + @overall_pass, @source_image_path, @result_root_path, @node_count)"; + + private const string InsertNodeSql = @" +INSERT OR REPLACE INTO inspection_node_results ( + run_id, node_id, node_index, node_name, pipeline_id, pipeline_name, pipeline_version_hash, + node_pass, source_image_path, result_image_path, status, duration_ms) +VALUES ( + @run_id, @node_id, @node_index, @node_name, @pipeline_id, @pipeline_name, @pipeline_version_hash, + @node_pass, @source_image_path, @result_image_path, @status, @duration_ms)"; + + private const string InsertMetricSql = @" +INSERT OR REPLACE INTO inspection_metric_results ( + run_id, node_id, metric_key, metric_name, metric_value, unit, + lower_limit, upper_limit, is_pass, display_order) +VALUES ( + @run_id, @node_id, @metric_key, @metric_name, @metric_value, @unit, + @lower_limit, @upper_limit, @is_pass, @display_order)"; + + private const string InsertAssetSql = @" +INSERT OR REPLACE INTO inspection_assets ( + run_id, node_id, asset_type, relative_path, file_format, width, height) +VALUES ( + @run_id, @node_id, @asset_type, @relative_path, @file_format, @width, @height)"; + + private const string InsertSnapshotSql = @" +INSERT OR REPLACE INTO pipeline_execution_snapshots ( + run_id, node_id, pipeline_name, pipeline_definition_json, pipeline_hash) +VALUES ( + @run_id, @node_id, @pipeline_name, @pipeline_definition_json, @pipeline_hash)"; + + 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 UpdateRunSql = @" +UPDATE inspection_runs +SET completed_at = @completed_at, + overall_pass = @overall_pass, + node_count = @node_count, + source_image_path = @source_image_path +WHERE run_id = @run_id"; + + public InspectionResultStore(IDbContext db, ILoggerService logger, string baseDirectory = null) + { + ArgumentNullException.ThrowIfNull(db); + ArgumentNullException.ThrowIfNull(logger); + + _db = db; + _logger = logger.ForModule(); + _baseDirectory = baseDirectory ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "XplorePlane", + "InspectionResults"); + } + + public async Task BeginRunAsync(InspectionRunRecord runRecord, InspectionAssetWriteRequest runSourceAsset = null) + { + ArgumentNullException.ThrowIfNull(runRecord); + await EnsureInitializedAsync().ConfigureAwait(false); + + NormalizeRunRecord(runRecord); + ValidateRunRecord(runRecord); + + var runDirectory = GetRunDirectory(runRecord.StartedAt, runRecord.RunId); + Directory.CreateDirectory(Path.Combine(runDirectory, "run")); + Directory.CreateDirectory(Path.Combine(runDirectory, "nodes")); + + if (runSourceAsset != null) + { + var savedAsset = await TryPersistAssetAsync( + runRecord.RunId, + null, + nodeIndex: null, + nodeName: null, + runRecord.ResultRootPath, + runSourceAsset).ConfigureAwait(false); + + if (savedAsset != null) + { + runRecord.SourceImagePath = savedAsset.RelativePath; + await SaveAssetRecordAsync(savedAsset).ConfigureAwait(false); + } + } + + var result = await _db.ExecuteNonQueryAsync(InsertRunSql, new Dictionary + { + ["run_id"] = runRecord.RunId.ToString("D"), + ["program_name"] = runRecord.ProgramName, + ["workpiece_id"] = runRecord.WorkpieceId, + ["serial_number"] = runRecord.SerialNumber, + ["started_at"] = runRecord.StartedAt.ToString("o"), + ["completed_at"] = runRecord.CompletedAt?.ToString("o"), + ["overall_pass"] = runRecord.OverallPass ? 1 : 0, + ["source_image_path"] = runRecord.SourceImagePath, + ["result_root_path"] = runRecord.ResultRootPath, + ["node_count"] = runRecord.NodeCount + }).ConfigureAwait(false); + + if (!result.IsSuccess) + { + _logger.Error(result.Exception!, "创建检测实例失败 | Failed to create inspection run: {Message}", result.Message); + throw new InvalidOperationException($"创建检测实例失败: {result.Message}", result.Exception); + } + } + + public async Task AppendNodeResultAsync( + InspectionNodeResult nodeResult, + IEnumerable metrics = null, + PipelineExecutionSnapshot pipelineSnapshot = null, + IEnumerable assets = null) + { + ArgumentNullException.ThrowIfNull(nodeResult); + await EnsureInitializedAsync().ConfigureAwait(false); + ValidateNodeResult(nodeResult); + await EnsureRunExistsAsync(nodeResult.RunId).ConfigureAwait(false); + + var assetRecords = new List(); + var assetFailureMode = (InspectionNodeStatus?)null; + foreach (var asset in assets ?? Enumerable.Empty()) + { + var savedAsset = await TryPersistAssetAsync( + nodeResult.RunId, + nodeResult.NodeId, + nodeResult.NodeIndex, + nodeResult.NodeName, + await GetRunRootRelativePathAsync(nodeResult.RunId).ConfigureAwait(false), + asset).ConfigureAwait(false); + + if (savedAsset == null) + { + assetFailureMode ??= ResolveAssetFailureMode(asset); + continue; + } + + assetRecords.Add(savedAsset); + + if (asset.AssetType == InspectionAssetType.NodeInputImage && string.IsNullOrWhiteSpace(nodeResult.SourceImagePath)) + { + nodeResult.SourceImagePath = savedAsset.RelativePath; + } + + if (asset.AssetType == InspectionAssetType.NodeResultImage && string.IsNullOrWhiteSpace(nodeResult.ResultImagePath)) + { + nodeResult.ResultImagePath = savedAsset.RelativePath; + } + } + + if (pipelineSnapshot != null) + { + pipelineSnapshot.RunId = nodeResult.RunId; + pipelineSnapshot.NodeId = nodeResult.NodeId; + if (string.IsNullOrWhiteSpace(pipelineSnapshot.PipelineName)) + { + pipelineSnapshot.PipelineName = nodeResult.PipelineName; + } + + if (string.IsNullOrWhiteSpace(pipelineSnapshot.PipelineHash)) + { + pipelineSnapshot.PipelineHash = ComputeSha256(pipelineSnapshot.PipelineDefinitionJson); + } + + if (string.IsNullOrWhiteSpace(nodeResult.PipelineVersionHash)) + { + nodeResult.PipelineVersionHash = pipelineSnapshot.PipelineHash; + } + } + + if (string.IsNullOrWhiteSpace(nodeResult.PipelineVersionHash)) + { + nodeResult.PipelineVersionHash = ComputeSha256(nodeResult.PipelineName); + } + + if (assetFailureMode.HasValue && nodeResult.Status == InspectionNodeStatus.Succeeded) + { + nodeResult.Status = assetFailureMode.Value; + } + + var metricsList = (metrics ?? Enumerable.Empty()) + .Select(metric => + { + metric.RunId = nodeResult.RunId; + metric.NodeId = nodeResult.NodeId; + return metric; + }) + .ToList(); + + await _db.ExecuteNonQueryAsync(DeleteNodeMetricsSql, new Dictionary + { + ["run_id"] = nodeResult.RunId.ToString("D"), + ["node_id"] = nodeResult.NodeId.ToString("D") + }).ConfigureAwait(false); + + await _db.ExecuteNonQueryAsync(DeleteNodeAssetsSql, new Dictionary + { + ["run_id"] = nodeResult.RunId.ToString("D"), + ["node_id"] = nodeResult.NodeId.ToString("D") + }).ConfigureAwait(false); + + var saveNode = await _db.ExecuteNonQueryAsync(InsertNodeSql, new Dictionary + { + ["run_id"] = nodeResult.RunId.ToString("D"), + ["node_id"] = nodeResult.NodeId.ToString("D"), + ["node_index"] = nodeResult.NodeIndex, + ["node_name"] = nodeResult.NodeName, + ["pipeline_id"] = nodeResult.PipelineId.ToString("D"), + ["pipeline_name"] = nodeResult.PipelineName, + ["pipeline_version_hash"] = nodeResult.PipelineVersionHash, + ["node_pass"] = nodeResult.NodePass ? 1 : 0, + ["source_image_path"] = nodeResult.SourceImagePath, + ["result_image_path"] = nodeResult.ResultImagePath, + ["status"] = nodeResult.Status.ToString(), + ["duration_ms"] = nodeResult.DurationMs + }).ConfigureAwait(false); + + if (!saveNode.IsSuccess) + { + _logger.Error(saveNode.Exception!, "保存节点检测结果失败 | Failed to save inspection node result: {Message}", saveNode.Message); + throw new InvalidOperationException($"保存节点检测结果失败: {saveNode.Message}", saveNode.Exception); + } + + foreach (var metric in metricsList) + { + var metricResult = await _db.ExecuteNonQueryAsync(InsertMetricSql, new Dictionary + { + ["run_id"] = metric.RunId.ToString("D"), + ["node_id"] = metric.NodeId.ToString("D"), + ["metric_key"] = metric.MetricKey, + ["metric_name"] = metric.MetricName, + ["metric_value"] = metric.MetricValue, + ["unit"] = metric.Unit, + ["lower_limit"] = metric.LowerLimit, + ["upper_limit"] = metric.UpperLimit, + ["is_pass"] = metric.IsPass ? 1 : 0, + ["display_order"] = metric.DisplayOrder + }).ConfigureAwait(false); + + if (!metricResult.IsSuccess) + { + _logger.Error(metricResult.Exception!, "保存检测指标失败 | Failed to save inspection metric: {Message}", metricResult.Message); + throw new InvalidOperationException($"保存检测指标失败: {metricResult.Message}", metricResult.Exception); + } + } + + foreach (var assetRecord in assetRecords) + { + await SaveAssetRecordAsync(assetRecord).ConfigureAwait(false); + } + + if (pipelineSnapshot != null) + { + var snapshotResult = await _db.ExecuteNonQueryAsync(InsertSnapshotSql, new Dictionary + { + ["run_id"] = pipelineSnapshot.RunId.ToString("D"), + ["node_id"] = pipelineSnapshot.NodeId.ToString("D"), + ["pipeline_name"] = pipelineSnapshot.PipelineName, + ["pipeline_definition_json"] = pipelineSnapshot.PipelineDefinitionJson, + ["pipeline_hash"] = pipelineSnapshot.PipelineHash + }).ConfigureAwait(false); + + if (!snapshotResult.IsSuccess) + { + _logger.Error(snapshotResult.Exception!, "保存流水线快照失败 | Failed to save pipeline snapshot: {Message}", snapshotResult.Message); + throw new InvalidOperationException($"保存流水线快照失败: {snapshotResult.Message}", snapshotResult.Exception); + } + } + } + + public async Task CompleteRunAsync(Guid runId, bool? overallPass = null, DateTime? completedAt = null) + { + await EnsureInitializedAsync().ConfigureAwait(false); + await EnsureRunExistsAsync(runId).ConfigureAwait(false); + + var detail = await GetRunDetailAsync(runId).ConfigureAwait(false); + var run = detail.Run; + run.CompletedAt = completedAt ?? DateTime.UtcNow; + run.NodeCount = detail.Nodes.Count; + run.OverallPass = overallPass ?? detail.Nodes.All(node => node.NodePass); + + 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, + ["node_count"] = run.NodeCount, + ["source_image_path"] = run.SourceImagePath + }).ConfigureAwait(false); + + if (!update.IsSuccess) + { + _logger.Error(update.Exception!, "完成检测实例失败 | Failed to complete inspection run: {Message}", update.Message); + throw new InvalidOperationException($"完成检测实例失败: {update.Message}", update.Exception); + } + + detail.Run = run; + await WriteManifestAsync(detail).ConfigureAwait(false); + } + + public async Task> QueryRunsAsync(InspectionRunQuery query = null) + { + await EnsureInitializedAsync().ConfigureAwait(false); + query ??= new InspectionRunQuery(); + + var sql = new StringBuilder(); + sql.Append("SELECT DISTINCT r.run_id, r.program_name, r.workpiece_id, r.serial_number, r.started_at, r.completed_at, r.overall_pass, r.source_image_path, r.result_root_path, r.node_count FROM inspection_runs r"); + var conditions = new List(); + var parameters = new Dictionary(); + + if (!string.IsNullOrWhiteSpace(query.PipelineName)) + { + sql.Append(" INNER JOIN inspection_node_results n ON n.run_id = r.run_id"); + conditions.Add("n.pipeline_name = @pipeline_name"); + parameters["pipeline_name"] = query.PipelineName; + } + + if (!string.IsNullOrWhiteSpace(query.ProgramName)) + { + conditions.Add("r.program_name = @program_name"); + parameters["program_name"] = query.ProgramName; + } + + if (!string.IsNullOrWhiteSpace(query.WorkpieceId)) + { + conditions.Add("r.workpiece_id = @workpiece_id"); + parameters["workpiece_id"] = query.WorkpieceId; + } + + if (!string.IsNullOrWhiteSpace(query.SerialNumber)) + { + conditions.Add("r.serial_number = @serial_number"); + parameters["serial_number"] = query.SerialNumber; + } + + if (query.From.HasValue) + { + conditions.Add("r.started_at >= @from"); + parameters["from"] = query.From.Value.ToString("o"); + } + + if (query.To.HasValue) + { + conditions.Add("r.started_at <= @to"); + parameters["to"] = query.To.Value.ToString("o"); + } + + if (conditions.Count > 0) + { + sql.Append(" WHERE "); + sql.Append(string.Join(" AND ", conditions)); + } + + sql.Append(" ORDER BY r.started_at DESC"); + + if (query.Take.HasValue) + { + sql.Append(" LIMIT @take"); + parameters["take"] = query.Take.Value; + + if (query.Skip.GetValueOrDefault() > 0) + { + sql.Append(" OFFSET @skip"); + parameters["skip"] = query.Skip.Value; + } + } + + var (result, data) = await _db.QueryListAsync(sql.ToString(), parameters).ConfigureAwait(false); + if (!result.IsSuccess) + { + _logger.Error(result.Exception!, "查询检测实例失败 | Failed to query inspection runs: {Message}", result.Message); + throw new InvalidOperationException($"查询检测实例失败: {result.Message}", result.Exception); + } + + return data.Select(MapRun).ToList().AsReadOnly(); + } + + public async Task GetRunDetailAsync(Guid runId) + { + await EnsureInitializedAsync().ConfigureAwait(false); + + var (runResult, runs) = await _db.QueryListAsync( + "SELECT run_id, program_name, workpiece_id, serial_number, started_at, completed_at, overall_pass, source_image_path, result_root_path, node_count FROM inspection_runs WHERE run_id = @run_id", + new Dictionary { ["run_id"] = runId.ToString("D") }).ConfigureAwait(false); + + if (!runResult.IsSuccess) + { + _logger.Error(runResult.Exception!, "加载检测实例失败 | Failed to load inspection run: {Message}", runResult.Message); + throw new InvalidOperationException($"加载检测实例失败: {runResult.Message}", runResult.Exception); + } + + if (runs.Count == 0) + { + throw new InvalidOperationException($"检测实例不存在: {runId}"); + } + + var run = MapRun(runs[0]); + + var (nodeResult, nodeRows) = await _db.QueryListAsync( + "SELECT run_id, node_id, node_index, node_name, pipeline_id, pipeline_name, pipeline_version_hash, node_pass, source_image_path, result_image_path, status, duration_ms FROM inspection_node_results WHERE run_id = @run_id ORDER BY node_index ASC", + new Dictionary { ["run_id"] = runId.ToString("D") }).ConfigureAwait(false); + + var (metricResult, metricRows) = await _db.QueryListAsync( + "SELECT run_id, node_id, metric_key, metric_name, metric_value, unit, COALESCE(CAST(lower_limit AS TEXT), '') AS lower_limit, COALESCE(CAST(upper_limit AS TEXT), '') AS upper_limit, is_pass, display_order FROM inspection_metric_results WHERE run_id = @run_id ORDER BY node_id ASC, display_order ASC", + new Dictionary { ["run_id"] = runId.ToString("D") }).ConfigureAwait(false); + + var (assetResult, assetRows) = await _db.QueryListAsync( + "SELECT run_id, node_id, asset_type, relative_path, file_format, width, height FROM inspection_assets WHERE run_id = @run_id ORDER BY node_id ASC, asset_type ASC", + new Dictionary { ["run_id"] = runId.ToString("D") }).ConfigureAwait(false); + + var (snapshotResult, snapshotRows) = await _db.QueryListAsync( + "SELECT run_id, node_id, pipeline_name, pipeline_definition_json, pipeline_hash FROM pipeline_execution_snapshots WHERE run_id = @run_id ORDER BY node_id ASC", + new Dictionary { ["run_id"] = runId.ToString("D") }).ConfigureAwait(false); + + ThrowIfQueryFailed(nodeResult, "加载检测节点结果失败"); + ThrowIfQueryFailed(metricResult, "加载检测指标结果失败"); + ThrowIfQueryFailed(assetResult, "加载检测资产结果失败"); + ThrowIfQueryFailed(snapshotResult, "加载流水线快照失败"); + + return new InspectionRunDetail + { + Run = run, + Nodes = nodeRows.Select(MapNode).ToList().AsReadOnly(), + Metrics = metricRows.Select(MapMetric).ToList().AsReadOnly(), + Assets = assetRows.Select(MapAsset).ToList().AsReadOnly(), + PipelineSnapshots = snapshotRows.Select(MapSnapshot).ToList().AsReadOnly() + }; + } + + private async Task EnsureInitializedAsync() + { + if (_initialized) + { + return; + } + + Directory.CreateDirectory(_baseDirectory); + var result = await _db.ExecuteNonQueryAsync(CreateTableSql).ConfigureAwait(false); + if (!result.IsSuccess) + { + _logger.Error(result.Exception!, "初始化检测结果归档失败 | Failed to initialize inspection result store: {Message}", result.Message); + throw new InvalidOperationException($"初始化检测结果归档失败: {result.Message}", result.Exception); + } + + _initialized = true; + } + + private async Task EnsureRunExistsAsync(Guid runId) + { + var (result, value) = await _db.ExecuteScalarAsync( + "SELECT COUNT(*) FROM inspection_runs WHERE run_id = @run_id", + new Dictionary { ["run_id"] = runId.ToString("D") }).ConfigureAwait(false); + + if (!result.IsSuccess) + { + _logger.Error(result.Exception!, "校验检测实例失败 | Failed to validate inspection run: {Message}", result.Message); + throw new InvalidOperationException($"校验检测实例失败: {result.Message}", result.Exception); + } + + if (value <= 0) + { + throw new InvalidOperationException($"检测实例不存在: {runId}"); + } + } + + private async Task SaveAssetRecordAsync(InspectionAssetRecord assetRecord) + { + var saveAsset = await _db.ExecuteNonQueryAsync(InsertAssetSql, new Dictionary + { + ["run_id"] = assetRecord.RunId.ToString("D"), + ["node_id"] = assetRecord.NodeId?.ToString("D"), + ["asset_type"] = assetRecord.AssetType.ToString(), + ["relative_path"] = assetRecord.RelativePath, + ["file_format"] = assetRecord.FileFormat, + ["width"] = assetRecord.Width, + ["height"] = assetRecord.Height + }).ConfigureAwait(false); + + if (!saveAsset.IsSuccess) + { + _logger.Error(saveAsset.Exception!, "保存检测资产索引失败 | Failed to save inspection asset record: {Message}", saveAsset.Message); + throw new InvalidOperationException($"保存检测资产索引失败: {saveAsset.Message}", saveAsset.Exception); + } + } + + private async Task TryPersistAssetAsync( + Guid runId, + Guid? nodeId, + int? nodeIndex, + string nodeName, + string runRelativeRoot, + InspectionAssetWriteRequest assetRequest) + { + try + { + if (assetRequest == null) + { + return null; + } + + var bytes = assetRequest.Content; + if (bytes == null) + { + if (string.IsNullOrWhiteSpace(assetRequest.SourceFilePath) || !File.Exists(assetRequest.SourceFilePath)) + { + throw new FileNotFoundException("资产源文件不存在", assetRequest.SourceFilePath); + } + + bytes = await File.ReadAllBytesAsync(assetRequest.SourceFilePath).ConfigureAwait(false); + } + + var relativePath = BuildAssetRelativePath(runRelativeRoot, nodeIndex, nodeName, assetRequest); + var absolutePath = Path.Combine(_baseDirectory, relativePath); + var targetDirectory = Path.GetDirectoryName(absolutePath); + if (!string.IsNullOrWhiteSpace(targetDirectory)) + { + Directory.CreateDirectory(targetDirectory); + } + + await File.WriteAllBytesAsync(absolutePath, bytes).ConfigureAwait(false); + + return new InspectionAssetRecord + { + RunId = runId, + NodeId = nodeId, + AssetType = assetRequest.AssetType, + RelativePath = relativePath.Replace('\\', '/'), + FileFormat = ResolveFileFormat(assetRequest), + Width = assetRequest.Width, + Height = assetRequest.Height + }; + } + catch (Exception ex) + { + _logger.Warn("检测资产保存失败 | Failed to persist inspection asset: {Message}", ex.Message); + return null; + } + } + + private async Task WriteManifestAsync(InspectionRunDetail detail) + { + var runDirectory = Path.Combine(_baseDirectory, detail.Run.ResultRootPath); + Directory.CreateDirectory(runDirectory); + var manifestPath = Path.Combine(runDirectory, "manifest.json"); + var json = JsonSerializer.Serialize(detail, JsonOptions); + await File.WriteAllTextAsync(manifestPath, json).ConfigureAwait(false); + } + + private string BuildAssetRelativePath(string runRelativeRoot, int? nodeIndex, string nodeName, InspectionAssetWriteRequest assetRequest) + { + if (assetRequest.AssetType == InspectionAssetType.RunSourceImage) + { + return Path.Combine(runRelativeRoot, "run", ResolveAssetFileName(assetRequest, "source")); + } + + var nodeFolder = $"{nodeIndex.GetValueOrDefault():D3}_{SanitizePathSegment(nodeName)}"; + return Path.Combine(runRelativeRoot, "nodes", nodeFolder, ResolveNodeAssetFileName(assetRequest)); + } + + private string ResolveNodeAssetFileName(InspectionAssetWriteRequest assetRequest) + { + var extension = ResolveExtension(assetRequest); + return assetRequest.AssetType switch + { + InspectionAssetType.NodeInputImage => $"input{extension}", + InspectionAssetType.NodeResultImage => $"result_overlay{extension}", + _ => ResolveAssetFileName(assetRequest, "asset") + }; + } + + private string ResolveAssetFileName(InspectionAssetWriteRequest assetRequest, string fallbackName) + { + var extension = ResolveExtension(assetRequest); + if (!string.IsNullOrWhiteSpace(assetRequest.FileName)) + { + return Path.GetFileName(assetRequest.FileName); + } + + return $"{fallbackName}{extension}"; + } + + private string ResolveExtension(InspectionAssetWriteRequest assetRequest) + { + if (!string.IsNullOrWhiteSpace(assetRequest.FileName)) + { + var extension = Path.GetExtension(assetRequest.FileName); + if (!string.IsNullOrWhiteSpace(extension)) + { + return extension; + } + } + + if (!string.IsNullOrWhiteSpace(assetRequest.SourceFilePath)) + { + var extension = Path.GetExtension(assetRequest.SourceFilePath); + if (!string.IsNullOrWhiteSpace(extension)) + { + return extension; + } + } + + if (!string.IsNullOrWhiteSpace(assetRequest.FileFormat)) + { + return "." + assetRequest.FileFormat.Trim().TrimStart('.'); + } + + return ".bin"; + } + + private string ResolveFileFormat(InspectionAssetWriteRequest assetRequest) + { + if (!string.IsNullOrWhiteSpace(assetRequest.FileFormat)) + { + return assetRequest.FileFormat.Trim().TrimStart('.'); + } + + var extension = ResolveExtension(assetRequest); + return extension.Trim().TrimStart('.'); + } + + private string GetRunDirectory(DateTime startedAt, Guid runId) + { + return Path.Combine(_baseDirectory, GetRunRelativeRoot(runId, startedAt)); + } + + private async Task GetRunRootRelativePathAsync(Guid runId) + { + var (result, value) = await _db.ExecuteScalarAsync( + "SELECT result_root_path FROM inspection_runs WHERE run_id = @run_id", + new Dictionary { ["run_id"] = runId.ToString("D") }).ConfigureAwait(false); + + if (!result.IsSuccess) + { + _logger.Error(result.Exception!, "读取检测实例目录失败 | Failed to get inspection run root path: {Message}", result.Message); + throw new InvalidOperationException($"读取检测实例目录失败: {result.Message}", result.Exception); + } + + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException($"检测实例目录不存在: {runId}"); + } + + return value.Replace('\\', '/'); + } + + private string GetRunRelativeRoot(Guid runId, DateTime? startedAt = null) + { + if (startedAt.HasValue) + { + return Path.Combine( + "Results", + startedAt.Value.ToString("yyyy"), + startedAt.Value.ToString("MM"), + startedAt.Value.ToString("dd"), + runId.ToString("D")); + } + + return Path.Combine("Results", runId.ToString("D")); + } + + private static string ComputeSha256(string value) + { + using var sha = SHA256.Create(); + var bytes = Encoding.UTF8.GetBytes(value ?? string.Empty); + var hash = sha.ComputeHash(bytes); + return Convert.ToHexString(hash); + } + + private static string SanitizePathSegment(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return "unnamed"; + } + + var invalidChars = Path.GetInvalidFileNameChars(); + var sanitized = new string(value.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray()); + return sanitized.Replace(' ', '_'); + } + + private static InspectionNodeStatus ResolveAssetFailureMode(InspectionAssetWriteRequest assetRequest) + { + if (!string.IsNullOrWhiteSpace(assetRequest?.SourceFilePath) && !File.Exists(assetRequest.SourceFilePath)) + { + return InspectionNodeStatus.AssetMissing; + } + + return InspectionNodeStatus.PartialSuccess; + } + + private static void NormalizeRunRecord(InspectionRunRecord runRecord) + { + if (runRecord.RunId == Guid.Empty) + { + runRecord.RunId = Guid.NewGuid(); + } + + if (runRecord.StartedAt == default) + { + runRecord.StartedAt = DateTime.UtcNow; + } + + runRecord.ResultRootPath = Path.Combine( + "Results", + runRecord.StartedAt.ToString("yyyy"), + runRecord.StartedAt.ToString("MM"), + runRecord.StartedAt.ToString("dd"), + runRecord.RunId.ToString("D")).Replace('\\', '/'); + } + + private static void ValidateRunRecord(InspectionRunRecord runRecord) + { + if (string.IsNullOrWhiteSpace(runRecord.ProgramName)) + { + throw new ArgumentException("ProgramName 不能为空", nameof(runRecord)); + } + } + + private static void ValidateNodeResult(InspectionNodeResult nodeResult) + { + if (nodeResult.RunId == Guid.Empty) + { + throw new ArgumentException("NodeResult 必须带 RunId", nameof(nodeResult)); + } + + if (nodeResult.NodeId == Guid.Empty) + { + throw new ArgumentException("NodeResult 必须带 NodeId", nameof(nodeResult)); + } + + if (string.IsNullOrWhiteSpace(nodeResult.NodeName)) + { + throw new ArgumentException("NodeResult 必须带 NodeName", nameof(nodeResult)); + } + } + + private static InspectionRunRecord MapRun(InspectionRunRow row) + { + return new InspectionRunRecord + { + RunId = Guid.Parse(row.run_id), + ProgramName = row.program_name, + WorkpieceId = row.workpiece_id, + SerialNumber = row.serial_number, + StartedAt = DateTime.Parse(row.started_at, null, System.Globalization.DateTimeStyles.RoundtripKind), + CompletedAt = string.IsNullOrWhiteSpace(row.completed_at) + ? null + : DateTime.Parse(row.completed_at, null, System.Globalization.DateTimeStyles.RoundtripKind), + OverallPass = row.overall_pass != 0, + SourceImagePath = row.source_image_path, + ResultRootPath = row.result_root_path, + NodeCount = row.node_count + }; + } + + private static InspectionNodeResult MapNode(InspectionNodeRow row) + { + _ = Enum.TryParse(row.status, out var status); + return new InspectionNodeResult + { + RunId = Guid.Parse(row.run_id), + NodeId = Guid.Parse(row.node_id), + NodeIndex = row.node_index, + NodeName = row.node_name, + PipelineId = Guid.Parse(row.pipeline_id), + PipelineName = row.pipeline_name, + PipelineVersionHash = row.pipeline_version_hash, + NodePass = row.node_pass != 0, + SourceImagePath = row.source_image_path, + ResultImagePath = row.result_image_path, + Status = status, + DurationMs = row.duration_ms + }; + } + + private static InspectionMetricResult MapMetric(InspectionMetricRow row) + { + return new InspectionMetricResult + { + RunId = Guid.Parse(row.run_id), + NodeId = Guid.Parse(row.node_id), + MetricKey = row.metric_key, + MetricName = row.metric_name, + MetricValue = row.metric_value, + Unit = row.unit, + LowerLimit = ParseNullableDouble(row.lower_limit), + UpperLimit = ParseNullableDouble(row.upper_limit), + IsPass = row.is_pass != 0, + DisplayOrder = row.display_order + }; + } + + private static double? ParseNullableDouble(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + return double.Parse(value, System.Globalization.CultureInfo.InvariantCulture); + } + + private static void ThrowIfQueryFailed(XP.Common.Database.Interfaces.IDbExecuteResult result, string message) + { + if (!result.IsSuccess) + { + throw new InvalidOperationException(message, result.Exception); + } + } + + private static InspectionAssetRecord MapAsset(InspectionAssetRow row) + { + _ = Enum.TryParse(row.asset_type, out var assetType); + return new InspectionAssetRecord + { + RunId = Guid.Parse(row.run_id), + NodeId = string.IsNullOrWhiteSpace(row.node_id) ? null : Guid.Parse(row.node_id), + AssetType = assetType, + RelativePath = row.relative_path, + FileFormat = row.file_format, + Width = row.width, + Height = row.height + }; + } + + private static PipelineExecutionSnapshot MapSnapshot(PipelineSnapshotRow row) + { + return new PipelineExecutionSnapshot + { + RunId = Guid.Parse(row.run_id), + NodeId = Guid.Parse(row.node_id), + PipelineName = row.pipeline_name, + PipelineDefinitionJson = row.pipeline_definition_json, + PipelineHash = row.pipeline_hash + }; + } + + internal class InspectionRunRow + { + public string run_id { get; set; } = string.Empty; + public string program_name { get; set; } = string.Empty; + public string workpiece_id { get; set; } = string.Empty; + public string serial_number { get; set; } = string.Empty; + public string started_at { get; set; } = string.Empty; + public string completed_at { get; set; } = string.Empty; + public int overall_pass { get; set; } + public string source_image_path { get; set; } = string.Empty; + public string result_root_path { get; set; } = string.Empty; + public int node_count { get; set; } + } + + internal class InspectionNodeRow + { + public string run_id { get; set; } = string.Empty; + public string node_id { get; set; } = string.Empty; + public int node_index { get; set; } + public string node_name { get; set; } = string.Empty; + public string pipeline_id { get; set; } = string.Empty; + public string pipeline_name { get; set; } = string.Empty; + public string pipeline_version_hash { get; set; } = string.Empty; + public int node_pass { get; set; } + public string source_image_path { get; set; } = string.Empty; + public string result_image_path { get; set; } = string.Empty; + public string status { get; set; } = string.Empty; + public long duration_ms { get; set; } + } + + internal class InspectionMetricRow + { + public string run_id { get; set; } = string.Empty; + public string node_id { get; set; } = string.Empty; + public string metric_key { get; set; } = string.Empty; + public string metric_name { get; set; } = string.Empty; + public double metric_value { get; set; } + public string unit { get; set; } = string.Empty; + public string lower_limit { get; set; } = string.Empty; + public string upper_limit { get; set; } = string.Empty; + public int is_pass { get; set; } + public int display_order { get; set; } + } + + internal class InspectionAssetRow + { + public string run_id { get; set; } = string.Empty; + public string node_id { get; set; } = string.Empty; + public string asset_type { get; set; } = string.Empty; + public string relative_path { get; set; } = string.Empty; + public string file_format { get; set; } = string.Empty; + public int width { get; set; } + public int height { get; set; } + } + + internal class PipelineSnapshotRow + { + public string run_id { get; set; } = string.Empty; + public string node_id { get; set; } = string.Empty; + public string pipeline_name { get; set; } = string.Empty; + public string pipeline_definition_json { get; set; } = string.Empty; + public string pipeline_hash { get; set; } = string.Empty; + } + } +} diff --git a/XplorePlane/Views/Cnc/CncEditorWindow.xaml b/XplorePlane/Views/Cnc/CncEditorWindow.xaml index 7496f51..1386766 100644 --- a/XplorePlane/Views/Cnc/CncEditorWindow.xaml +++ b/XplorePlane/Views/Cnc/CncEditorWindow.xaml @@ -4,10 +4,10 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:cnc="clr-namespace:XplorePlane.Views.Cnc" Title="CNC 编辑器" - Width="544" - Height="750" - MinWidth="544" - MinHeight="750" + Width="1180" + Height="780" + MinWidth="1040" + MinHeight="720" ResizeMode="CanResize" ShowInTaskbar="False" WindowStartupLocation="CenterOwner"> diff --git a/XplorePlane/Views/Cnc/CncPageView.xaml b/XplorePlane/Views/Cnc/CncPageView.xaml index db3b2f1..10a65e5 100644 --- a/XplorePlane/Views/Cnc/CncPageView.xaml +++ b/XplorePlane/Views/Cnc/CncPageView.xaml @@ -8,7 +8,7 @@ xmlns:prism="http://prismlibrary.com/" xmlns:vm="clr-namespace:XplorePlane.ViewModels.Cnc" d:DesignHeight="760" - d:DesignWidth="1040" + d:DesignWidth="1180" prism:ViewModelLocator.AutoWireViewModel="True" mc:Ignorable="d"> @@ -17,19 +17,35 @@ - - - - + + + + + + Microsoft YaHei UI + + @@ -41,10 +57,46 @@ + + + + + + + + + - + + + @@ -65,116 +119,116 @@ + Text="{Binding ProgramName, TargetNullValue=NewCncProgram}" /> + Margin="0,3,0,0" + FontFamily="{StaticResource UiFont}" + FontSize="10.5" + Foreground="#666666" + Text="模块节点下会自动显示标记、等待、消息等子节点" /> - + - + + + + + + +