✨ feat(file-check): 添加 PLC 应答后命中文件删除

* 新增默认关闭的删除开关,仅在收到 PLC 放行应答后执行

* 本地与 SFTP Provider 校验根目录并支持幂等删除

* 补充工作流、设置页和文件删除相关测试与文档
This commit is contained in:
2026-08-01 16:42:28 +08:00
parent 404ebadbe7
commit c67509ec82
15 changed files with 700 additions and 1 deletions

View File

@@ -38,6 +38,7 @@ Axi_Omron/
- `Plc.PollIntervalMs`: `200`
- `Plc.ReleasePulseMs`: `500`
- `Plc.ReleaseAckTimeoutMs`: `2000`
- `FileCheck.DeleteMatchedFileAfterPlcAck`: `false`;启用后仅在收到 PLC 放行应答时删除本次精确命中的产品 ID 文件
点位映射示例:

View File

@@ -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`。

View File

@@ -25,6 +25,21 @@ public sealed class DesignTimeSftpLookupService : IFileCheckService
});
}
/// <summary>
/// 返回设计时产品 ID 文件删除结果,不执行实际文件操作。
/// </summary>
/// <param name="filePath">设计时文件路径。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>固定成功结果。</returns>
public Task<FileDeleteOutcome> DeleteMatchedFileAsync(string filePath, CancellationToken cancellationToken)
{
return Task.FromResult(new FileDeleteOutcome
{
IsSuccess = true,
FilePath = filePath
});
}
/// <summary>
/// 返回设计时文件校验通道测试结果。
/// </summary>

View File

@@ -344,6 +344,33 @@ public sealed class FileCheckOutcome
public string ErrorMessage { get; set; } = string.Empty;
}
/// <summary>
/// 表示一次产品 ID 文件删除结果。
/// </summary>
public sealed class FileDeleteOutcome
{
/// <summary>
/// 获取或设置删除操作是否达到目标状态。
/// 文件已经不存在时也视为成功,以保证应答重试场景下的幂等性。
/// </summary>
public bool IsSuccess { get; set; }
/// <summary>
/// 获取或设置执行删除前文件是否已经不存在。
/// </summary>
public bool WasAlreadyMissing { get; set; }
/// <summary>
/// 获取或设置本次删除针对的文件路径。
/// </summary>
public string FilePath { get; set; } = string.Empty;
/// <summary>
/// 获取或设置删除失败时的错误描述。
/// </summary>
public string ErrorMessage { get; set; } = string.Empty;
}
/// <summary>
/// 表示一次统一文件校验通道测试结果。
/// </summary>

View File

@@ -87,6 +87,12 @@ public sealed class FileCheckOptions
/// </summary>
public int MaxRetryCount { get; set; } = 3;
/// <summary>
/// 获取或设置是否在收到 PLC 放行应答后删除本次命中的产品 ID 文件。
/// 默认关闭;启用后仅删除当前文件校验通道返回且位于配置根目录内的精确命中文件。
/// </summary>
public bool DeleteMatchedFileAfterPlcAck { get; set; }
/// <summary>
/// 获取或设置本地磁盘文件校验配置。
/// </summary>

View File

@@ -42,6 +42,23 @@ public sealed class FileCheckService : IFileCheckService
return provider.CheckFileAsync(barcode, _config, cancellationToken);
}
/// <summary>
/// 通过当前文件校验通道删除本次精确命中的产品 ID 文件。
/// </summary>
/// <param name="filePath">文件校验返回的精确命中文件路径。</param>
/// <param name="cancellationToken">取消令牌。取消后终止尚未完成的删除操作。</param>
/// <returns>文件删除结果。</returns>
public Task<FileDeleteOutcome> 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);
}
/// <summary>
/// 测试当前或指定配置下的文件校验通道可用性。
/// </summary>
@@ -88,4 +105,20 @@ public sealed class FileCheckService : IFileCheckService
ErrorMessage = $"不支持的文件校验通道: {provider}"
};
}
/// <summary>
/// 创建不支持通道的文件删除结果。
/// </summary>
/// <param name="provider">通道类型。</param>
/// <param name="filePath">待删除文件路径。</param>
/// <returns>文件删除结果。</returns>
private static FileDeleteOutcome CreateUnsupportedProviderDeleteOutcome(FileCheckProvider provider, string filePath)
{
return new FileDeleteOutcome
{
IsSuccess = false,
FilePath = filePath,
ErrorMessage = $"不支持的文件校验通道: {provider}"
};
}
}

View File

@@ -61,6 +61,33 @@ public sealed class LocalFileCheckProvider : IFileCheckProvider
return await Task.Run(() => CheckInternal(barcode, config, cancellationToken), cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// 删除本地文件校验返回的精确命中文件。
/// 删除前会校验目标必须位于配置的本地根目录内,文件已不存在时按幂等成功处理。
/// </summary>
/// <param name="filePath">文件校验返回的精确命中文件路径。</param>
/// <param name="config">根配置对象。</param>
/// <param name="cancellationToken">取消令牌。取消后终止尚未完成的删除操作。</param>
/// <returns>文件删除结果。</returns>
public async Task<FileDeleteOutcome> 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);
}
/// <summary>
/// 测试本地文件校验目录是否可访问。
/// </summary>
@@ -129,6 +156,83 @@ public sealed class LocalFileCheckProvider : IFileCheckProvider
}
}
/// <summary>
/// 在同步上下文中安全删除本地产品 ID 文件。
/// </summary>
/// <param name="filePath">待删除文件路径。</param>
/// <param name="rootPath">配置的本地根目录。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>文件删除结果。</returns>
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}"
};
}
}
/// <summary>
/// 判断目标文件是否位于配置根目录内,防止外部路径被误删。
/// </summary>
/// <param name="fullFilePath">目标文件绝对路径。</param>
/// <param name="fullRootPath">配置根目录绝对路径。</param>
/// <returns>目标位于根目录内返回 <see langword="true"/>;否则返回 <see langword="false"/>。</returns>
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);
}
/// <summary>
/// 验证本地根目录配置和可访问性。
/// </summary>

View File

@@ -60,6 +60,33 @@ public sealed class SftpLookupService : IFileCheckProvider
return await Task.Run(() => CheckInternal(barcode, config, cancellationToken), cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// 删除 SFTP 文件校验返回的精确命中文件。
/// 删除前会校验目标必须位于配置的 SFTP 根目录内,文件已不存在时按幂等成功处理。
/// </summary>
/// <param name="filePath">文件校验返回的精确命中文件路径。</param>
/// <param name="config">根配置对象。</param>
/// <param name="cancellationToken">取消令牌。取消后终止尚未完成的删除操作。</param>
/// <returns>文件删除结果。</returns>
public async Task<FileDeleteOutcome> 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);
}
/// <summary>
/// 测试当前或指定配置下的 SFTP 连接可用性。
/// </summary>
@@ -178,6 +205,83 @@ public sealed class SftpLookupService : IFileCheckProvider
}
}
/// <summary>
/// 在同步上下文中安全删除 SFTP 产品 ID 文件。
/// </summary>
/// <param name="filePath">待删除文件路径。</param>
/// <param name="options">SFTP 配置。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>文件删除结果。</returns>
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}");
}
}
/// <summary>
/// 根据指定配置创建 SFTP 客户端。
/// </summary>
@@ -216,10 +320,60 @@ public sealed class SftpLookupService : IFileCheckProvider
/// <param name="path">原始目录路径。</param>
/// <returns>标准化目录路径。</returns>
private static string NormalizeDirectory(string path)
{
string normalizedPath = path.Replace('\\', '/');
return normalizedPath.Length > 1 ? normalizedPath.TrimEnd('/') : normalizedPath;
}
/// <summary>
/// 统一远程文件路径格式。
/// </summary>
/// <param name="path">原始远程文件路径。</param>
/// <returns>标准化远程文件路径。</returns>
private static string NormalizeRemotePath(string path)
{
return path.Replace('\\', '/').TrimEnd('/');
}
/// <summary>
/// 判断远程文件是否位于配置的 SFTP 根目录内,防止外部路径被误删。
/// </summary>
/// <param name="filePath">标准化后的远程文件路径。</param>
/// <param name="rootPath">标准化后的 SFTP 根目录。</param>
/// <returns>目标位于根目录内返回 <see langword="true"/>;否则返回 <see langword="false"/>。</returns>
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);
}
/// <summary>
/// 创建 SFTP 文件删除失败结果。
/// </summary>
/// <param name="filePath">删除目标路径。</param>
/// <param name="errorMessage">错误描述。</param>
/// <returns>文件删除失败结果。</returns>
private static FileDeleteOutcome CreateDeleteFailure(string filePath, string errorMessage)
{
return new FileDeleteOutcome
{
IsSuccess = false,
FilePath = filePath,
ErrorMessage = errorMessage
};
}
/// <summary>
/// 在同步上下文中执行 SFTP 连通性测试。
/// </summary>

View File

@@ -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);
}
/// <summary>
/// 在收到 PLC 放行应答后,按配置删除本次文件校验精确命中的产品 ID 文件。
/// 删除失败只记录错误,不回滚已经由 PLC 确认的物理放行动作。
/// </summary>
/// <param name="cancellationToken">取消令牌。取消后终止尚未完成的删除操作并结束当前流程。</param>
/// <returns>表示删除处理完成的任务。</returns>
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);
}
}
/// <summary>
/// 在应用启动时恢复当天处理记录与统计信息。
/// </summary>
@@ -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;

View File

@@ -177,6 +177,14 @@ public interface IFileCheckService
/// <returns>文件校验结果。</returns>
Task<FileCheckOutcome> CheckFileAsync(string barcode, CancellationToken cancellationToken);
/// <summary>
/// 通过当前文件校验通道删除本次精确命中的产品 ID 文件。
/// </summary>
/// <param name="filePath">文件校验返回的精确命中文件路径。</param>
/// <param name="cancellationToken">取消令牌。取消后终止尚未完成的删除操作。</param>
/// <returns>文件删除结果。</returns>
Task<FileDeleteOutcome> DeleteMatchedFileAsync(string filePath, CancellationToken cancellationToken);
/// <summary>
/// 测试当前或指定配置下的文件校验通道可用性。
/// </summary>
@@ -205,6 +213,16 @@ public interface IFileCheckProvider
/// <returns>文件校验结果。</returns>
Task<FileCheckOutcome> CheckFileAsync(string barcode, AppConfig config, CancellationToken cancellationToken);
/// <summary>
/// 删除当前通道中由文件校验返回的精确命中文件。
/// 实现必须限制删除目标位于当前配置根目录内。
/// </summary>
/// <param name="filePath">文件校验返回的精确命中文件路径。</param>
/// <param name="config">根配置对象。</param>
/// <param name="cancellationToken">取消令牌。取消后终止尚未完成的删除操作。</param>
/// <returns>文件删除结果。</returns>
Task<FileDeleteOutcome> DeleteFileAsync(string filePath, AppConfig config, CancellationToken cancellationToken);
/// <summary>
/// 测试当前通道配置是否可用。
/// </summary>

View File

@@ -98,6 +98,9 @@
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="文件名模板" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.FileCheck.FileNamePattern, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="重试间隔(秒)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.FileCheck.RetryIntervalSeconds, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="最大重试次数" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.FileCheck.MaxRetryCount, UpdateSourceTrigger=PropertyChanged}" />
<CheckBox Content="收到 PLC 放行应答后删除命中的产品 ID 文件"
IsChecked="{Binding EditableConfig.FileCheck.DeleteMatchedFileAfterPlcAck}"
Margin="0,10,0,0" />
<TextBlock Text="SFTP 配置" Margin="0,16,0,2" FontWeight="SemiBold" Foreground="#1F2937" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="主机" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.Host, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="端口" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.Port, UpdateSourceTrigger=PropertyChanged}" />

View File

@@ -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
}
}
}

View File

@@ -124,6 +124,56 @@ public sealed class LocalFileCheckProviderTests : IDisposable
Assert.Equal(filePath, outcome.MatchedFilePath);
}
/// <summary>
/// 删除目标位于配置根目录内时,应删除文件并返回成功。
/// </summary>
/// <returns>异步测试任务。</returns>
[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));
}
/// <summary>
/// 删除目标位于配置根目录之外时,应拒绝删除以防止误删其他文件。
/// </summary>
/// <returns>异步测试任务。</returns>
[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);
}
}
/// <summary>
/// 创建本地文件校验提供程序。
/// </summary>

View File

@@ -291,6 +291,11 @@ public sealed class SystemSettingViewModelTests
throw new NotSupportedException();
}
public Task<FileDeleteOutcome> DeleteMatchedFileAsync(string filePath, CancellationToken cancellationToken)
{
throw new NotSupportedException();
}
public Task<FileCheckTestOutcome> TestConnectionAsync(AppConfig? config, CancellationToken cancellationToken)
{
return Task.FromResult(TestOutcome);

View File

@@ -157,6 +157,178 @@ public sealed class WorkflowHostedServiceTests
Assert.All(snapshot.PlcMonitorItems, item => Assert.NotEqual(default, item.LastUpdatedAt));
}
/// <summary>
/// 启用应答后删除功能时,收到 PLC 放行应答后应删除本次精确命中的产品 ID 文件。
/// </summary>
/// <returns>异步测试任务。</returns>
[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<WorkflowHostedService>());
await service.StartAsync(CancellationToken.None);
await Task.Delay(200);
await service.StopAsync(CancellationToken.None);
Assert.Equal(matchedFilePath, Assert.Single(fileCheckService.DeletedFilePaths));
}
/// <summary>
/// 未收到 PLC 放行应答时,即使启用删除功能也不得删除产品 ID 文件。
/// </summary>
/// <returns>异步测试任务。</returns>
[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<WorkflowHostedService>());
await service.StartAsync(CancellationToken.None);
await Task.Delay(200);
await service.StopAsync(CancellationToken.None);
Assert.Empty(fileCheckService.DeletedFilePaths);
}
/// <summary>
/// 删除开关关闭时,即使收到 PLC 放行应答也不得删除产品 ID 文件。
/// </summary>
/// <returns>异步测试任务。</returns>
[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<WorkflowHostedService>());
await service.StartAsync(CancellationToken.None);
await Task.Delay(200);
await service.StopAsync(CancellationToken.None);
Assert.Empty(fileCheckService.DeletedFilePaths);
}
/// <summary>
/// 双扫码汇总后,应使用汇总条码继续执行统一文件校验。
/// </summary>
@@ -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<string> DeletedFilePaths { get; } = new();
public Task<FileCheckOutcome> CheckFileAsync(string barcode, CancellationToken cancellationToken)
{
LastBarcode = barcode;
return Task.FromResult(CheckOutcome);
}
public Task<FileDeleteOutcome> DeleteMatchedFileAsync(string filePath, CancellationToken cancellationToken)
{
DeletedFilePaths.Add(filePath);
DeleteOutcome.FilePath = filePath;
return Task.FromResult(DeleteOutcome);
}
public Task<FileCheckTestOutcome> TestConnectionAsync(AppConfig? config, CancellationToken cancellationToken)
{
return Task.FromResult(TestOutcome);