diff --git a/README.md b/README.md index 66160c1..4b1e27a 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Axi_Omron/ - `Plc.PollIntervalMs`: `200` - `Plc.ReleasePulseMs`: `500` - `Plc.ReleaseAckTimeoutMs`: `2000` +- `FileCheck.DeleteMatchedFileAfterPlcAck`: `false`;启用后仅在收到 PLC 放行应答时删除本次精确命中的产品 ID 文件 点位映射示例: diff --git a/docs/2026-05-17-pcb-check-final-workflow.md b/docs/2026-05-17-pcb-check-final-workflow.md index 1962ade..59c5f45 100644 --- a/docs/2026-05-17-pcb-check-final-workflow.md +++ b/docs/2026-05-17-pcb-check-final-workflow.md @@ -129,4 +129,5 @@ flowchart TD - SFTP 文件未找到达到重试上限后,写入 `ResultCode=20` 并放行。 - SFTP 连接失败、认证失败、根目录不存在等系统异常写入 `ResultCode=90`,不自动放行。 - 两把扫码枪均失败时调用安灯报警;安灯接口失败不阻断 `ResultCode=20` 的 NG 放行。 +- `FileCheck.DeleteMatchedFileAfterPlcAck=true` 时,仅在收到 PLC 放行应答后删除本次文件校验精确命中的产品 ID 文件;应答超时、未命中文件或删除失败时不误删其他文件。 - 放行完成后等待 PLC 复位信号 `D1002`,再清 `D1051`、`D1052`、`D1100`。 diff --git a/src/AxiOmron.PcbCheck/DesignTime/DesignTimeSftpLookupService.cs b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeSftpLookupService.cs index 69f5258..14f8d62 100644 --- a/src/AxiOmron.PcbCheck/DesignTime/DesignTimeSftpLookupService.cs +++ b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeSftpLookupService.cs @@ -25,6 +25,21 @@ public sealed class DesignTimeSftpLookupService : IFileCheckService }); } + /// + /// 返回设计时产品 ID 文件删除结果,不执行实际文件操作。 + /// + /// 设计时文件路径。 + /// 取消令牌。 + /// 固定成功结果。 + public Task DeleteMatchedFileAsync(string filePath, CancellationToken cancellationToken) + { + return Task.FromResult(new FileDeleteOutcome + { + IsSuccess = true, + FilePath = filePath + }); + } + /// /// 返回设计时文件校验通道测试结果。 /// diff --git a/src/AxiOmron.PcbCheck/Models/WorkflowModels.cs b/src/AxiOmron.PcbCheck/Models/WorkflowModels.cs index a4fb15a..4623652 100644 --- a/src/AxiOmron.PcbCheck/Models/WorkflowModels.cs +++ b/src/AxiOmron.PcbCheck/Models/WorkflowModels.cs @@ -344,6 +344,33 @@ public sealed class FileCheckOutcome public string ErrorMessage { get; set; } = string.Empty; } +/// +/// 表示一次产品 ID 文件删除结果。 +/// +public sealed class FileDeleteOutcome +{ + /// + /// 获取或设置删除操作是否达到目标状态。 + /// 文件已经不存在时也视为成功,以保证应答重试场景下的幂等性。 + /// + public bool IsSuccess { get; set; } + + /// + /// 获取或设置执行删除前文件是否已经不存在。 + /// + public bool WasAlreadyMissing { get; set; } + + /// + /// 获取或设置本次删除针对的文件路径。 + /// + public string FilePath { get; set; } = string.Empty; + + /// + /// 获取或设置删除失败时的错误描述。 + /// + public string ErrorMessage { get; set; } = string.Empty; +} + /// /// 表示一次统一文件校验通道测试结果。 /// diff --git a/src/AxiOmron.PcbCheck/Options/AppConfig.cs b/src/AxiOmron.PcbCheck/Options/AppConfig.cs index 1ef4701..95101fd 100644 --- a/src/AxiOmron.PcbCheck/Options/AppConfig.cs +++ b/src/AxiOmron.PcbCheck/Options/AppConfig.cs @@ -87,6 +87,12 @@ public sealed class FileCheckOptions /// public int MaxRetryCount { get; set; } = 3; + /// + /// 获取或设置是否在收到 PLC 放行应答后删除本次命中的产品 ID 文件。 + /// 默认关闭;启用后仅删除当前文件校验通道返回且位于配置根目录内的精确命中文件。 + /// + public bool DeleteMatchedFileAfterPlcAck { get; set; } + /// /// 获取或设置本地磁盘文件校验配置。 /// diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/FileCheckService.cs b/src/AxiOmron.PcbCheck/Services/Implementations/FileCheckService.cs index 24fed6b..7ea3f09 100644 --- a/src/AxiOmron.PcbCheck/Services/Implementations/FileCheckService.cs +++ b/src/AxiOmron.PcbCheck/Services/Implementations/FileCheckService.cs @@ -42,6 +42,23 @@ public sealed class FileCheckService : IFileCheckService return provider.CheckFileAsync(barcode, _config, cancellationToken); } + /// + /// 通过当前文件校验通道删除本次精确命中的产品 ID 文件。 + /// + /// 文件校验返回的精确命中文件路径。 + /// 取消令牌。取消后终止尚未完成的删除操作。 + /// 文件删除结果。 + public Task DeleteMatchedFileAsync(string filePath, CancellationToken cancellationToken) + { + IFileCheckProvider? provider = ResolveProvider(_config.FileCheck.Provider); + if (provider is null) + { + return Task.FromResult(CreateUnsupportedProviderDeleteOutcome(_config.FileCheck.Provider, filePath)); + } + + return provider.DeleteFileAsync(filePath, _config, cancellationToken); + } + /// /// 测试当前或指定配置下的文件校验通道可用性。 /// @@ -88,4 +105,20 @@ public sealed class FileCheckService : IFileCheckService ErrorMessage = $"不支持的文件校验通道: {provider}" }; } + + /// + /// 创建不支持通道的文件删除结果。 + /// + /// 通道类型。 + /// 待删除文件路径。 + /// 文件删除结果。 + private static FileDeleteOutcome CreateUnsupportedProviderDeleteOutcome(FileCheckProvider provider, string filePath) + { + return new FileDeleteOutcome + { + IsSuccess = false, + FilePath = filePath, + ErrorMessage = $"不支持的文件校验通道: {provider}" + }; + } } diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/LocalFileCheckProvider.cs b/src/AxiOmron.PcbCheck/Services/Implementations/LocalFileCheckProvider.cs index b18dae4..079fe0a 100644 --- a/src/AxiOmron.PcbCheck/Services/Implementations/LocalFileCheckProvider.cs +++ b/src/AxiOmron.PcbCheck/Services/Implementations/LocalFileCheckProvider.cs @@ -61,6 +61,33 @@ public sealed class LocalFileCheckProvider : IFileCheckProvider return await Task.Run(() => CheckInternal(barcode, config, cancellationToken), cancellationToken).ConfigureAwait(false); } + /// + /// 删除本地文件校验返回的精确命中文件。 + /// 删除前会校验目标必须位于配置的本地根目录内,文件已不存在时按幂等成功处理。 + /// + /// 文件校验返回的精确命中文件路径。 + /// 根配置对象。 + /// 取消令牌。取消后终止尚未完成的删除操作。 + /// 文件删除结果。 + public async Task DeleteFileAsync(string filePath, AppConfig config, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(config); + if (string.IsNullOrWhiteSpace(filePath)) + { + return new FileDeleteOutcome + { + IsSuccess = false, + FilePath = filePath, + ErrorMessage = "待删除的本地产品 ID 文件路径为空。" + }; + } + + return await Task.Run( + () => DeleteInternal(filePath, config.FileCheck.Local.RootPath, cancellationToken), + cancellationToken) + .ConfigureAwait(false); + } + /// /// 测试本地文件校验目录是否可访问。 /// @@ -129,6 +156,83 @@ public sealed class LocalFileCheckProvider : IFileCheckProvider } } + /// + /// 在同步上下文中安全删除本地产品 ID 文件。 + /// + /// 待删除文件路径。 + /// 配置的本地根目录。 + /// 取消令牌。 + /// 文件删除结果。 + private FileDeleteOutcome DeleteInternal(string filePath, string rootPath, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + string fullRootPath = Path.GetFullPath(rootPath); + string fullFilePath = Path.GetFullPath(filePath); + if (!IsPathWithinRoot(fullFilePath, fullRootPath)) + { + return new FileDeleteOutcome + { + IsSuccess = false, + FilePath = fullFilePath, + ErrorMessage = $"拒绝删除本地根目录之外的文件: {fullFilePath}" + }; + } + + if (!File.Exists(fullFilePath)) + { + return new FileDeleteOutcome + { + IsSuccess = true, + WasAlreadyMissing = true, + FilePath = fullFilePath + }; + } + + File.Delete(fullFilePath); + return new FileDeleteOutcome + { + IsSuccess = true, + FilePath = fullFilePath + }; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or System.Security.SecurityException + or ArgumentException + or NotSupportedException) + { + _logger.LogError(ex, "删除本地产品 ID 文件异常,路径={FilePath}", true, filePath); + return new FileDeleteOutcome + { + IsSuccess = false, + FilePath = filePath, + ErrorMessage = $"删除本地产品 ID 文件失败: {ex.Message}" + }; + } + } + + /// + /// 判断目标文件是否位于配置根目录内,防止外部路径被误删。 + /// + /// 目标文件绝对路径。 + /// 配置根目录绝对路径。 + /// 目标位于根目录内返回 ;否则返回 。 + private static bool IsPathWithinRoot(string fullFilePath, string fullRootPath) + { + string relativePath = Path.GetRelativePath(fullRootPath, fullFilePath); + return !Path.IsPathRooted(relativePath) + && !string.Equals(relativePath, "..", StringComparison.Ordinal) + && !relativePath.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal) + && !relativePath.StartsWith($"..{Path.AltDirectorySeparatorChar}", StringComparison.Ordinal); + } + /// /// 验证本地根目录配置和可访问性。 /// diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/SftpLookupService.cs b/src/AxiOmron.PcbCheck/Services/Implementations/SftpLookupService.cs index eae5cd4..afba485 100644 --- a/src/AxiOmron.PcbCheck/Services/Implementations/SftpLookupService.cs +++ b/src/AxiOmron.PcbCheck/Services/Implementations/SftpLookupService.cs @@ -60,6 +60,33 @@ public sealed class SftpLookupService : IFileCheckProvider return await Task.Run(() => CheckInternal(barcode, config, cancellationToken), cancellationToken).ConfigureAwait(false); } + /// + /// 删除 SFTP 文件校验返回的精确命中文件。 + /// 删除前会校验目标必须位于配置的 SFTP 根目录内,文件已不存在时按幂等成功处理。 + /// + /// 文件校验返回的精确命中文件路径。 + /// 根配置对象。 + /// 取消令牌。取消后终止尚未完成的删除操作。 + /// 文件删除结果。 + public async Task DeleteFileAsync(string filePath, AppConfig config, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(config); + if (string.IsNullOrWhiteSpace(filePath)) + { + return new FileDeleteOutcome + { + IsSuccess = false, + FilePath = filePath, + ErrorMessage = "待删除的 SFTP 产品 ID 文件路径为空。" + }; + } + + return await Task.Run( + () => DeleteInternal(filePath, config.Sftp, cancellationToken), + cancellationToken) + .ConfigureAwait(false); + } + /// /// 测试当前或指定配置下的 SFTP 连接可用性。 /// @@ -178,6 +205,83 @@ public sealed class SftpLookupService : IFileCheckProvider } } + /// + /// 在同步上下文中安全删除 SFTP 产品 ID 文件。 + /// + /// 待删除文件路径。 + /// SFTP 配置。 + /// 取消令牌。 + /// 文件删除结果。 + private FileDeleteOutcome DeleteInternal(string filePath, SftpOptions options, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + string rootPath = NormalizeDirectory(options.RootPath); + string normalizedFilePath = NormalizeRemotePath(filePath); + if (!IsRemotePathWithinRoot(normalizedFilePath, rootPath)) + { + return new FileDeleteOutcome + { + IsSuccess = false, + FilePath = normalizedFilePath, + ErrorMessage = $"拒绝删除 SFTP 根目录之外的文件: {normalizedFilePath}" + }; + } + + using var client = CreateClient(options); + ConnectClient(client, options); + cancellationToken.ThrowIfCancellationRequested(); + + if (!client.Exists(normalizedFilePath)) + { + return new FileDeleteOutcome + { + IsSuccess = true, + WasAlreadyMissing = true, + FilePath = normalizedFilePath + }; + } + + client.DeleteFile(normalizedFilePath); + if (client.Exists(normalizedFilePath)) + { + return new FileDeleteOutcome + { + IsSuccess = false, + FilePath = normalizedFilePath, + ErrorMessage = $"SFTP 删除命令执行后文件仍然存在: {normalizedFilePath}" + }; + } + + return new FileDeleteOutcome + { + IsSuccess = true, + FilePath = normalizedFilePath + }; + } + catch (OperationCanceledException) + { + throw; + } + catch (SshAuthenticationException ex) + { + _logger.LogError(ex, "删除 SFTP 产品 ID 文件时认证失败,路径={FilePath}", true, filePath); + return CreateDeleteFailure(filePath, $"SFTP 认证失败: {ex.Message}"); + } + catch (SshConnectionException ex) + { + _logger.LogError(ex, "删除 SFTP 产品 ID 文件时连接失败,路径={FilePath}", true, filePath); + return CreateDeleteFailure(filePath, $"SFTP 连接失败: {ex.Message}"); + } + catch (Exception ex) + { + _logger.LogError(ex, "删除 SFTP 产品 ID 文件异常,路径={FilePath}", true, filePath); + return CreateDeleteFailure(filePath, $"删除 SFTP 产品 ID 文件失败: {ex.Message}"); + } + } + /// /// 根据指定配置创建 SFTP 客户端。 /// @@ -216,10 +320,60 @@ public sealed class SftpLookupService : IFileCheckProvider /// 原始目录路径。 /// 标准化目录路径。 private static string NormalizeDirectory(string path) + { + string normalizedPath = path.Replace('\\', '/'); + return normalizedPath.Length > 1 ? normalizedPath.TrimEnd('/') : normalizedPath; + } + + /// + /// 统一远程文件路径格式。 + /// + /// 原始远程文件路径。 + /// 标准化远程文件路径。 + private static string NormalizeRemotePath(string path) { return path.Replace('\\', '/').TrimEnd('/'); } + /// + /// 判断远程文件是否位于配置的 SFTP 根目录内,防止外部路径被误删。 + /// + /// 标准化后的远程文件路径。 + /// 标准化后的 SFTP 根目录。 + /// 目标位于根目录内返回 ;否则返回 。 + private static bool IsRemotePathWithinRoot(string filePath, string rootPath) + { + if (string.IsNullOrWhiteSpace(filePath) || string.IsNullOrWhiteSpace(rootPath)) + { + return false; + } + + if (filePath.Split('/', StringSplitOptions.RemoveEmptyEntries).Any(segment => segment == "..")) + { + return false; + } + + string rootPrefix = rootPath.EndsWith("/", StringComparison.Ordinal) ? rootPath : $"{rootPath}/"; + return !string.Equals(filePath, rootPath, StringComparison.Ordinal) + && filePath.StartsWith(rootPrefix, StringComparison.Ordinal); + } + + /// + /// 创建 SFTP 文件删除失败结果。 + /// + /// 删除目标路径。 + /// 错误描述。 + /// 文件删除失败结果。 + private static FileDeleteOutcome CreateDeleteFailure(string filePath, string errorMessage) + { + return new FileDeleteOutcome + { + IsSuccess = false, + FilePath = filePath, + ErrorMessage = errorMessage + }; + } + /// /// 在同步上下文中执行 SFTP 连通性测试。 /// diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs b/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs index f34f8a7..26bbb23 100644 --- a/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs +++ b/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs @@ -29,6 +29,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS private bool _releaseSent; private DateTimeOffset? _currentBoardStartedAt; private string _currentBarcode = string.Empty; + private string _matchedProductFilePath = string.Empty; private int _scanTryCount; private int _sftpTryCount; private WorkflowResultCode _resultCode = WorkflowResultCode.None; @@ -701,6 +702,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS lock (_stateSyncRoot) { _resultCode = WorkflowResultCode.Passed; + _matchedProductFilePath = outcome.MatchedFilePath; } await ApplyProcessStateAsync( @@ -819,6 +821,13 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS { _appLogger.LogWarning("放行应答等待超时,按脉冲策略自动清除放行位。", true); } + else + { + _appLogger.LogInformation( + "已收到 PLC 放行应答,地址=D{Address}。", + true, + _config.Plc.Inputs.PlcAckRelease); + } await Task.Delay(_config.Plc.ReleasePulseMs, cancellationToken).ConfigureAwait(false); await ApplyProcessStateAsync( @@ -831,6 +840,12 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS }, cancellationToken).ConfigureAwait(false); + // 先结束 PLC 放行脉冲,再执行可能较慢的本地或 SFTP 删除,避免文件 I/O 延长放行位保持时间。 + if (ackReceived) + { + await DeleteMatchedProductFileAfterAckIfEnabledAsync(cancellationToken).ConfigureAwait(false); + } + var completedAt = DateTimeOffset.Now; UpdateSnapshot(snapshot => { @@ -860,6 +875,84 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS _appLogger.LogInformation($"单板流程完成,结果={record.ResultDescription}", true); } + /// + /// 在收到 PLC 放行应答后,按配置删除本次文件校验精确命中的产品 ID 文件。 + /// 删除失败只记录错误,不回滚已经由 PLC 确认的物理放行动作。 + /// + /// 取消令牌。取消后终止尚未完成的删除操作并结束当前流程。 + /// 表示删除处理完成的任务。 + private async Task DeleteMatchedProductFileAfterAckIfEnabledAsync(CancellationToken cancellationToken) + { + if (!_config.FileCheck.DeleteMatchedFileAfterPlcAck) + { + return; + } + + string filePath; + string barcode; + lock (_stateSyncRoot) + { + filePath = _matchedProductFilePath; + barcode = _currentBarcode; + } + + if (string.IsNullOrWhiteSpace(filePath)) + { + _appLogger.LogWarning( + "已收到 PLC 放行应答,但本次流程没有可删除的产品 ID 文件,条码={Barcode}。", + true, + barcode); + return; + } + + try + { + FileDeleteOutcome outcome = await _fileCheckService + .DeleteMatchedFileAsync(filePath, cancellationToken) + .ConfigureAwait(false); + + if (!outcome.IsSuccess) + { + _appLogger.LogError( + "PLC 放行应答后删除产品 ID 文件失败,条码={Barcode},路径={FilePath},原因={ErrorMessage}", + true, + barcode, + outcome.FilePath, + outcome.ErrorMessage); + return; + } + + if (outcome.WasAlreadyMissing) + { + _appLogger.LogWarning( + "PLC 放行应答后产品 ID 文件已不存在,无需重复删除,条码={Barcode},路径={FilePath}", + true, + barcode, + outcome.FilePath); + return; + } + + _appLogger.LogInformation( + "PLC 放行应答后已删除产品 ID 文件,条码={Barcode},路径={FilePath}", + true, + barcode, + outcome.FilePath); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _appLogger.LogError( + ex, + "PLC 放行应答后删除产品 ID 文件异常,条码={Barcode},路径={FilePath}", + true, + barcode, + filePath); + } + } + /// /// 在应用启动时恢复当天处理记录与统计信息。 /// @@ -1015,6 +1108,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS _currentBoardStartedAt = DateTimeOffset.Now; _releaseSent = false; _currentBarcode = string.Empty; + _matchedProductFilePath = string.Empty; _scanTryCount = 0; _sftpTryCount = 0; _resultCode = WorkflowResultCode.Processing; @@ -1035,6 +1129,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS _releaseSent = false; _currentBoardStartedAt = null; _currentBarcode = string.Empty; + _matchedProductFilePath = string.Empty; _scanTryCount = 0; _sftpTryCount = 0; _resultCode = WorkflowResultCode.None; diff --git a/src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs b/src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs index a48befc..114cdbc 100644 --- a/src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs +++ b/src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs @@ -177,6 +177,14 @@ public interface IFileCheckService /// 文件校验结果。 Task CheckFileAsync(string barcode, CancellationToken cancellationToken); + /// + /// 通过当前文件校验通道删除本次精确命中的产品 ID 文件。 + /// + /// 文件校验返回的精确命中文件路径。 + /// 取消令牌。取消后终止尚未完成的删除操作。 + /// 文件删除结果。 + Task DeleteMatchedFileAsync(string filePath, CancellationToken cancellationToken); + /// /// 测试当前或指定配置下的文件校验通道可用性。 /// @@ -205,6 +213,16 @@ public interface IFileCheckProvider /// 文件校验结果。 Task CheckFileAsync(string barcode, AppConfig config, CancellationToken cancellationToken); + /// + /// 删除当前通道中由文件校验返回的精确命中文件。 + /// 实现必须限制删除目标位于当前配置根目录内。 + /// + /// 文件校验返回的精确命中文件路径。 + /// 根配置对象。 + /// 取消令牌。取消后终止尚未完成的删除操作。 + /// 文件删除结果。 + Task DeleteFileAsync(string filePath, AppConfig config, CancellationToken cancellationToken); + /// /// 测试当前通道配置是否可用。 /// diff --git a/src/AxiOmron.PcbCheck/Views/Pages/SystemSettingsPage.xaml b/src/AxiOmron.PcbCheck/Views/Pages/SystemSettingsPage.xaml index 218e74e..84c4157 100644 --- a/src/AxiOmron.PcbCheck/Views/Pages/SystemSettingsPage.xaml +++ b/src/AxiOmron.PcbCheck/Views/Pages/SystemSettingsPage.xaml @@ -98,6 +98,9 @@ + diff --git a/src/AxiOmron.PcbCheck/appConfig.json b/src/AxiOmron.PcbCheck/appConfig.json index 65c240b..df3e65b 100644 --- a/src/AxiOmron.PcbCheck/appConfig.json +++ b/src/AxiOmron.PcbCheck/appConfig.json @@ -44,6 +44,7 @@ "FileNamePattern": "${barcode}.txt", "RetryIntervalSeconds": 2, "MaxRetryCount": 3, + "DeleteMatchedFileAfterPlcAck": false, "Local": { "RootPath": "D:\\test" } @@ -89,4 +90,4 @@ "AdminPassword": "AxiOmron@123", "AdminSessionTimeoutMinutes": 15 } -} \ No newline at end of file +} diff --git a/tests/AxiOmron.PcbCheck.Tests/LocalFileCheckProviderTests.cs b/tests/AxiOmron.PcbCheck.Tests/LocalFileCheckProviderTests.cs index 4b7e972..4a353d7 100644 --- a/tests/AxiOmron.PcbCheck.Tests/LocalFileCheckProviderTests.cs +++ b/tests/AxiOmron.PcbCheck.Tests/LocalFileCheckProviderTests.cs @@ -124,6 +124,56 @@ public sealed class LocalFileCheckProviderTests : IDisposable Assert.Equal(filePath, outcome.MatchedFilePath); } + /// + /// 删除目标位于配置根目录内时,应删除文件并返回成功。 + /// + /// 异步测试任务。 + [Fact] + public async Task DeleteFileAsync_ShouldDeleteFile_WhenFileIsWithinConfiguredRoot() + { + string filePath = Path.Combine(_rootPath, "PCB-DELETE-001.txt"); + await File.WriteAllTextAsync(filePath, "ok"); + var provider = CreateProvider(); + + FileDeleteOutcome outcome = await provider.DeleteFileAsync( + filePath, + CreateConfig(_rootPath), + CancellationToken.None); + + Assert.True(outcome.IsSuccess); + Assert.False(outcome.WasAlreadyMissing); + Assert.False(File.Exists(filePath)); + } + + /// + /// 删除目标位于配置根目录之外时,应拒绝删除以防止误删其他文件。 + /// + /// 异步测试任务。 + [Fact] + public async Task DeleteFileAsync_ShouldRejectFile_WhenFileIsOutsideConfiguredRoot() + { + string parentPath = Directory.GetParent(_rootPath)!.FullName; + string filePath = Path.Combine(parentPath, $"{Guid.NewGuid():N}.txt"); + await File.WriteAllTextAsync(filePath, "keep"); + var provider = CreateProvider(); + + try + { + FileDeleteOutcome outcome = await provider.DeleteFileAsync( + filePath, + CreateConfig(_rootPath), + CancellationToken.None); + + Assert.False(outcome.IsSuccess); + Assert.Contains("根目录之外", outcome.ErrorMessage); + Assert.True(File.Exists(filePath)); + } + finally + { + File.Delete(filePath); + } + } + /// /// 创建本地文件校验提供程序。 /// diff --git a/tests/AxiOmron.PcbCheck.Tests/SystemSettingViewModelTests.cs b/tests/AxiOmron.PcbCheck.Tests/SystemSettingViewModelTests.cs index b5f9746..354e318 100644 --- a/tests/AxiOmron.PcbCheck.Tests/SystemSettingViewModelTests.cs +++ b/tests/AxiOmron.PcbCheck.Tests/SystemSettingViewModelTests.cs @@ -291,6 +291,11 @@ public sealed class SystemSettingViewModelTests throw new NotSupportedException(); } + public Task DeleteMatchedFileAsync(string filePath, CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + public Task TestConnectionAsync(AppConfig? config, CancellationToken cancellationToken) { return Task.FromResult(TestOutcome); diff --git a/tests/AxiOmron.PcbCheck.Tests/WorkflowHostedServiceTests.cs b/tests/AxiOmron.PcbCheck.Tests/WorkflowHostedServiceTests.cs index 05b7c8f..dbf91c6 100644 --- a/tests/AxiOmron.PcbCheck.Tests/WorkflowHostedServiceTests.cs +++ b/tests/AxiOmron.PcbCheck.Tests/WorkflowHostedServiceTests.cs @@ -157,6 +157,178 @@ public sealed class WorkflowHostedServiceTests Assert.All(snapshot.PlcMonitorItems, item => Assert.NotEqual(default, item.LastUpdatedAt)); } + /// + /// 启用应答后删除功能时,收到 PLC 放行应答后应删除本次精确命中的产品 ID 文件。 + /// + /// 异步测试任务。 + [Fact] + public async Task ExecuteAsync_ShouldDeleteMatchedFile_WhenReleaseAckReceivedAndDeletionEnabled() + { + const string matchedFilePath = "/pcb/PCB-DELETE-001.txt"; + var fileCheckService = new FakeFileCheckService + { + CheckOutcome = new FileCheckOutcome + { + Exists = true, + ConnectionSucceeded = true, + MatchedFilePath = matchedFilePath + } + }; + var service = new WorkflowHostedService( + new SequencedPlcService(new[] + { + new PlcSignalSnapshot { PcbArrived = true, PlcAckRelease = true }, + new PlcSignalSnapshot { PcbArrived = false, PlcAckRelease = true } + }), + new CountingScannerService + { + Result = new ScanOperationResult + { + IsSuccess = true, + DeviceConnected = true, + Barcode = "PCB-DELETE-001" + } + }, + fileCheckService, + new FakeAndonService(), + new AppStateStore(), + new AppConfig + { + Plc = new PlcOptions + { + PollIntervalMs = 20, + ReleaseAckTimeoutMs = 100, + ReleasePulseMs = 10 + }, + FileCheck = new FileCheckOptions + { + MaxRetryCount = 0, + DeleteMatchedFileAfterPlcAck = true + } + }, + new FakeAppLogger()); + + await service.StartAsync(CancellationToken.None); + await Task.Delay(200); + await service.StopAsync(CancellationToken.None); + + Assert.Equal(matchedFilePath, Assert.Single(fileCheckService.DeletedFilePaths)); + } + + /// + /// 未收到 PLC 放行应答时,即使启用删除功能也不得删除产品 ID 文件。 + /// + /// 异步测试任务。 + [Fact] + public async Task ExecuteAsync_ShouldNotDeleteMatchedFile_WhenReleaseAckTimesOut() + { + var fileCheckService = new FakeFileCheckService + { + CheckOutcome = new FileCheckOutcome + { + Exists = true, + ConnectionSucceeded = true, + MatchedFilePath = "/pcb/PCB-KEEP-001.txt" + } + }; + var service = new WorkflowHostedService( + new SequencedPlcService(new[] + { + new PlcSignalSnapshot { PcbArrived = true, PlcAckRelease = false }, + new PlcSignalSnapshot { PcbArrived = false, PlcAckRelease = false } + }), + new CountingScannerService + { + Result = new ScanOperationResult + { + IsSuccess = true, + DeviceConnected = true, + Barcode = "PCB-KEEP-001" + } + }, + fileCheckService, + new FakeAndonService(), + new AppStateStore(), + new AppConfig + { + Plc = new PlcOptions + { + PollIntervalMs = 20, + ReleaseAckTimeoutMs = 50, + ReleasePulseMs = 10 + }, + FileCheck = new FileCheckOptions + { + MaxRetryCount = 0, + DeleteMatchedFileAfterPlcAck = true + } + }, + new FakeAppLogger()); + + await service.StartAsync(CancellationToken.None); + await Task.Delay(200); + await service.StopAsync(CancellationToken.None); + + Assert.Empty(fileCheckService.DeletedFilePaths); + } + + /// + /// 删除开关关闭时,即使收到 PLC 放行应答也不得删除产品 ID 文件。 + /// + /// 异步测试任务。 + [Fact] + public async Task ExecuteAsync_ShouldNotDeleteMatchedFile_WhenDeletionDisabled() + { + var fileCheckService = new FakeFileCheckService + { + CheckOutcome = new FileCheckOutcome + { + Exists = true, + ConnectionSucceeded = true, + MatchedFilePath = "/pcb/PCB-KEEP-002.txt" + } + }; + var service = new WorkflowHostedService( + new SequencedPlcService(new[] + { + new PlcSignalSnapshot { PcbArrived = true, PlcAckRelease = true }, + new PlcSignalSnapshot { PcbArrived = false, PlcAckRelease = true } + }), + new CountingScannerService + { + Result = new ScanOperationResult + { + IsSuccess = true, + DeviceConnected = true, + Barcode = "PCB-KEEP-002" + } + }, + fileCheckService, + new FakeAndonService(), + new AppStateStore(), + new AppConfig + { + Plc = new PlcOptions + { + PollIntervalMs = 20, + ReleaseAckTimeoutMs = 100, + ReleasePulseMs = 10 + }, + FileCheck = new FileCheckOptions + { + MaxRetryCount = 0, + DeleteMatchedFileAfterPlcAck = false + } + }, + new FakeAppLogger()); + + await service.StartAsync(CancellationToken.None); + await Task.Delay(200); + await service.StopAsync(CancellationToken.None); + + Assert.Empty(fileCheckService.DeletedFilePaths); + } + /// /// 双扫码汇总后,应使用汇总条码继续执行统一文件校验。 /// @@ -577,14 +749,28 @@ public sealed class WorkflowHostedServiceTests public FileCheckOutcome CheckOutcome { get; set; } = new(); + public FileDeleteOutcome DeleteOutcome { get; set; } = new() + { + IsSuccess = true + }; + public string LastBarcode { get; private set; } = string.Empty; + public List DeletedFilePaths { get; } = new(); + public Task CheckFileAsync(string barcode, CancellationToken cancellationToken) { LastBarcode = barcode; return Task.FromResult(CheckOutcome); } + public Task DeleteMatchedFileAsync(string filePath, CancellationToken cancellationToken) + { + DeletedFilePaths.Add(filePath); + DeleteOutcome.FilePath = filePath; + return Task.FromResult(DeleteOutcome); + } + public Task TestConnectionAsync(AppConfig? config, CancellationToken cancellationToken) { return Task.FromResult(TestOutcome);