✨ feat(*): 添加 PCB 检测记录的 CSV 持久化存储功能
* 新增 CsvBoardRecordRepository 实现按日期分卷的 CSV 记录存储 * 新增 RecordPersistenceModels 定义板件检测记录数据结构 * 在 WorkflowHostedService 中集成检测完成后的记录持久化 * 更新 MainWindowViewModel 支持记录查询与异常标记 * 更新 AppStateStore 添加记录存储相关状态管理 * 新增 DashboardPage UI 元素展示记录存储状态 * 更新 SystemSettingViewModel 与 appConfig 添加存储路径配置 * 注册 TimeProvider 与 IBoardRecordRepository 到 DI 容器 * 新增 CsvBoardRecordRepositoryTests 与 MainWindowViewModelTests * 配置 Release 模式 portable PDB 并在发布时自动移除
This commit is contained in:
@@ -116,12 +116,14 @@ public partial class App : Application
|
|||||||
services.AddSingleton<IAppStateStore, AppStateStore>();
|
services.AddSingleton<IAppStateStore, AppStateStore>();
|
||||||
services.AddSingleton(typeof(IAppLogger<>), typeof(AppLogger<>));
|
services.AddSingleton(typeof(IAppLogger<>), typeof(AppLogger<>));
|
||||||
services.AddSingleton<IDispatcherService, DispatcherService>();
|
services.AddSingleton<IDispatcherService, DispatcherService>();
|
||||||
|
services.AddSingleton(TimeProvider.System);
|
||||||
services.AddHttpClient(nameof(AndonService));
|
services.AddHttpClient(nameof(AndonService));
|
||||||
|
|
||||||
services.AddSingleton<IPlcService, ModbusTcpPlcService>();
|
services.AddSingleton<IPlcService, ModbusTcpPlcService>();
|
||||||
services.AddSingleton<IScannerService, SerialScannerService>();
|
services.AddSingleton<IScannerService, SerialScannerService>();
|
||||||
services.AddSingleton<ISftpLookupService, SftpLookupService>();
|
services.AddSingleton<ISftpLookupService, SftpLookupService>();
|
||||||
services.AddSingleton<IAndonService, AndonService>();
|
services.AddSingleton<IAndonService, AndonService>();
|
||||||
|
services.AddSingleton<IBoardRecordRepository, CsvBoardRecordRepository>();
|
||||||
services.AddSingleton<WorkflowHostedService>();
|
services.AddSingleton<WorkflowHostedService>();
|
||||||
services.AddSingleton<IWorkflowControlService>(sp => sp.GetRequiredService<WorkflowHostedService>());
|
services.AddSingleton<IWorkflowControlService>(sp => sp.GetRequiredService<WorkflowHostedService>());
|
||||||
services.AddHostedService(sp => sp.GetRequiredService<WorkflowHostedService>());
|
services.AddHostedService(sp => sp.GetRequiredService<WorkflowHostedService>());
|
||||||
|
|||||||
@@ -94,6 +94,28 @@ public sealed class DesignTimeAppStateStore : IAppStateStore
|
|||||||
ExceptionSummary = "SFTP 文件查询超时"
|
ExceptionSummary = "SFTP 文件查询超时"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_snapshot.RecordStatisticDate = DateOnly.FromDateTime(now.LocalDateTime.Date);
|
||||||
|
_snapshot.TodayProcessCount = _records.Count;
|
||||||
|
_snapshot.TodayOkCount = _records.Count(record => record.ResultCode == (ushort)WorkflowResultCode.Passed);
|
||||||
|
_snapshot.TodayNgCount = _records.Count(record => record.ResultCode != (ushort)WorkflowResultCode.Passed);
|
||||||
|
_snapshot.RecordsLastUpdatedAt = now;
|
||||||
|
foreach (BoardProcessRecord record in _records)
|
||||||
|
{
|
||||||
|
_snapshot.BoardRecords.Add(new BoardProcessRecord
|
||||||
|
{
|
||||||
|
StartedAt = record.StartedAt,
|
||||||
|
CompletedAt = record.CompletedAt,
|
||||||
|
Barcode = record.Barcode,
|
||||||
|
ScanTryCount = record.ScanTryCount,
|
||||||
|
SftpTryCount = record.SftpTryCount,
|
||||||
|
ResultCode = record.ResultCode,
|
||||||
|
ResultDescription = record.ResultDescription,
|
||||||
|
ReleaseSent = record.ReleaseSent,
|
||||||
|
AlarmRaised = record.AlarmRaised,
|
||||||
|
ExceptionSummary = record.ExceptionSummary
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -180,6 +202,52 @@ public sealed class DesignTimeAppStateStore : IAppStateStore
|
|||||||
_snapshotChanged?.Invoke(this, _snapshot.Clone());
|
_snapshotChanged?.Invoke(this, _snapshot.Clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 使用新的最近记录与统计结果替换当前设计时历史区状态。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="records">最近记录集合。</param>
|
||||||
|
/// <param name="statistics">统计结果。</param>
|
||||||
|
public void ReplaceRecords(IReadOnlyList<BoardProcessRecord> records, BoardRecordStatistics statistics)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(records);
|
||||||
|
ArgumentNullException.ThrowIfNull(statistics);
|
||||||
|
|
||||||
|
_snapshot.BoardRecords.Clear();
|
||||||
|
foreach (BoardProcessRecord record in records)
|
||||||
|
{
|
||||||
|
_snapshot.BoardRecords.Add(new BoardProcessRecord
|
||||||
|
{
|
||||||
|
StartedAt = record.StartedAt,
|
||||||
|
CompletedAt = record.CompletedAt,
|
||||||
|
Barcode = record.Barcode,
|
||||||
|
ScanTryCount = record.ScanTryCount,
|
||||||
|
SftpTryCount = record.SftpTryCount,
|
||||||
|
ResultCode = record.ResultCode,
|
||||||
|
ResultDescription = record.ResultDescription,
|
||||||
|
ReleaseSent = record.ReleaseSent,
|
||||||
|
AlarmRaised = record.AlarmRaised,
|
||||||
|
ExceptionSummary = record.ExceptionSummary
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateRecordStatistics(statistics);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新当前设计时统计结果。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="statistics">统计结果。</param>
|
||||||
|
public void UpdateRecordStatistics(BoardRecordStatistics statistics)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(statistics);
|
||||||
|
_snapshot.RecordStatisticDate = statistics.StatisticDate;
|
||||||
|
_snapshot.TodayProcessCount = statistics.TotalCount;
|
||||||
|
_snapshot.TodayOkCount = statistics.OkCount;
|
||||||
|
_snapshot.TodayNgCount = statistics.NgCount;
|
||||||
|
_snapshot.RecordsLastUpdatedAt = statistics.LastUpdatedAt;
|
||||||
|
_snapshotChanged?.Invoke(this, _snapshot.Clone());
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 追加一条设计时日志并通知订阅者。
|
/// 追加一条设计时日志并通知订阅者。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
80
src/AxiOmron.PcbCheck/Models/RecordPersistenceModels.cs
Normal file
80
src/AxiOmron.PcbCheck/Models/RecordPersistenceModels.cs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
namespace AxiOmron.PcbCheck.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 表示当前统计日期对应的处理记录聚合结果。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BoardRecordStatistics
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置统计日期。
|
||||||
|
/// </summary>
|
||||||
|
public DateOnly StatisticDate { get; set; } = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置处理总数。
|
||||||
|
/// </summary>
|
||||||
|
public int TotalCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置 OK 数。
|
||||||
|
/// </summary>
|
||||||
|
public int OkCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置 NG 数。
|
||||||
|
/// </summary>
|
||||||
|
public int NgCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置最近一次统计更新时间。
|
||||||
|
/// </summary>
|
||||||
|
public DateTimeOffset LastUpdatedAt { get; set; } = DateTimeOffset.Now;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建当前统计对象的副本。
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>统计对象副本。</returns>
|
||||||
|
public BoardRecordStatistics Clone()
|
||||||
|
{
|
||||||
|
return new BoardRecordStatistics
|
||||||
|
{
|
||||||
|
StatisticDate = StatisticDate,
|
||||||
|
TotalCount = TotalCount,
|
||||||
|
OkCount = OkCount,
|
||||||
|
NgCount = NgCount,
|
||||||
|
LastUpdatedAt = LastUpdatedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 表示一次当前日期数据加载结果。
|
||||||
|
/// </summary>
|
||||||
|
public class BoardRecordLoadResult
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置最近记录集合,按完成时间倒序排列。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<BoardProcessRecord> RecentRecords { get; set; } = Array.Empty<BoardProcessRecord>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置当前统计结果。
|
||||||
|
/// </summary>
|
||||||
|
public BoardRecordStatistics Statistics { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 表示一次处理记录追加写入后的结果。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BoardRecordAppendResult : BoardRecordLoadResult
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置本次持久化是否成功。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsPersisted { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置失败原因。
|
||||||
|
/// </summary>
|
||||||
|
public string ErrorMessage { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -10,6 +10,11 @@ public sealed class RuntimeSnapshot
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public IList<PlcMonitorItem> PlcMonitorItems { get; } = new List<PlcMonitorItem>();
|
public IList<PlcMonitorItem> PlcMonitorItems { get; } = new List<PlcMonitorItem>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取最近处理记录集合。
|
||||||
|
/// </summary>
|
||||||
|
public IList<BoardProcessRecord> BoardRecords { get; } = new List<BoardProcessRecord>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取或设置 PLC 连接状态文本。
|
/// 获取或设置 PLC 连接状态文本。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -80,6 +85,31 @@ public sealed class RuntimeSnapshot
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTimeOffset LastUpdatedAt { get; set; } = DateTimeOffset.Now;
|
public DateTimeOffset LastUpdatedAt { get; set; } = DateTimeOffset.Now;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置当前统计日期。
|
||||||
|
/// </summary>
|
||||||
|
public DateOnly? RecordStatisticDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置今日处理总数。
|
||||||
|
/// </summary>
|
||||||
|
public int TodayProcessCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置今日 OK 数。
|
||||||
|
/// </summary>
|
||||||
|
public int TodayOkCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置今日 NG 数。
|
||||||
|
/// </summary>
|
||||||
|
public int TodayNgCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置处理记录数据最近更新时间。
|
||||||
|
/// </summary>
|
||||||
|
public DateTimeOffset RecordsLastUpdatedAt { get; set; } = DateTimeOffset.MinValue;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 创建当前快照的副本。
|
/// 创建当前快照的副本。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -101,7 +131,12 @@ public sealed class RuntimeSnapshot
|
|||||||
LastTriggeredAt = LastTriggeredAt,
|
LastTriggeredAt = LastTriggeredAt,
|
||||||
LastCompletedAt = LastCompletedAt,
|
LastCompletedAt = LastCompletedAt,
|
||||||
IsBusy = IsBusy,
|
IsBusy = IsBusy,
|
||||||
LastUpdatedAt = LastUpdatedAt
|
LastUpdatedAt = LastUpdatedAt,
|
||||||
|
RecordStatisticDate = RecordStatisticDate,
|
||||||
|
TodayProcessCount = TodayProcessCount,
|
||||||
|
TodayOkCount = TodayOkCount,
|
||||||
|
TodayNgCount = TodayNgCount,
|
||||||
|
RecordsLastUpdatedAt = RecordsLastUpdatedAt
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (PlcMonitorItem item in PlcMonitorItems)
|
foreach (PlcMonitorItem item in PlcMonitorItems)
|
||||||
@@ -109,6 +144,33 @@ public sealed class RuntimeSnapshot
|
|||||||
clone.PlcMonitorItems.Add(item.Clone());
|
clone.PlcMonitorItems.Add(item.Clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (BoardProcessRecord record in BoardRecords)
|
||||||
|
{
|
||||||
|
clone.BoardRecords.Add(CloneRecord(record));
|
||||||
|
}
|
||||||
|
|
||||||
return clone;
|
return clone;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建处理记录对象的副本。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="record">待复制的处理记录。</param>
|
||||||
|
/// <returns>处理记录副本。</returns>
|
||||||
|
private static BoardProcessRecord CloneRecord(BoardProcessRecord record)
|
||||||
|
{
|
||||||
|
return new BoardProcessRecord
|
||||||
|
{
|
||||||
|
StartedAt = record.StartedAt,
|
||||||
|
CompletedAt = record.CompletedAt,
|
||||||
|
Barcode = record.Barcode,
|
||||||
|
ScanTryCount = record.ScanTryCount,
|
||||||
|
SftpTryCount = record.SftpTryCount,
|
||||||
|
ResultCode = record.ResultCode,
|
||||||
|
ResultDescription = record.ResultDescription,
|
||||||
|
ReleaseSent = record.ReleaseSent,
|
||||||
|
AlarmRaised = record.AlarmRaised,
|
||||||
|
ExceptionSummary = record.ExceptionSummary
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ public sealed class AppConfig
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public WorkflowOptions Workflow { get; set; } = new();
|
public WorkflowOptions Workflow { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置处理记录持久化配置。
|
||||||
|
/// </summary>
|
||||||
|
public RecordPersistenceOptions RecordPersistence { get; set; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取或设置安全控制配置。
|
/// 获取或设置安全控制配置。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -286,6 +291,22 @@ public sealed class WorkflowOptions
|
|||||||
public int MaxBoardRecords { get; set; } = 100;
|
public int MaxBoardRecords { get; set; } = 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 表示处理记录 CSV 持久化配置。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RecordPersistenceOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置记录目录相对路径或绝对路径。
|
||||||
|
/// </summary>
|
||||||
|
public string DirectoryPath { get; set; } = "Data\\Records";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置跨天检查周期,单位为秒。
|
||||||
|
/// </summary>
|
||||||
|
public int DayChangeCheckIntervalSeconds { get; set; } = 30;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 表示简易管理员控制配置。
|
/// 表示简易管理员控制配置。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -57,6 +57,62 @@ public sealed class AppStateStore : IAppStateStore
|
|||||||
SnapshotChanged?.Invoke(this, clonedSnapshot);
|
SnapshotChanged?.Invoke(this, clonedSnapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 使用新的最近记录与统计结果替换当前历史区状态。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="records">最近记录集合。</param>
|
||||||
|
/// <param name="statistics">统计结果。</param>
|
||||||
|
public void ReplaceRecords(IReadOnlyList<BoardProcessRecord> records, BoardRecordStatistics statistics)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(records);
|
||||||
|
ArgumentNullException.ThrowIfNull(statistics);
|
||||||
|
|
||||||
|
UpdateSnapshot(snapshot =>
|
||||||
|
{
|
||||||
|
snapshot.BoardRecords.Clear();
|
||||||
|
foreach (BoardProcessRecord record in records)
|
||||||
|
{
|
||||||
|
snapshot.BoardRecords.Add(new BoardProcessRecord
|
||||||
|
{
|
||||||
|
StartedAt = record.StartedAt,
|
||||||
|
CompletedAt = record.CompletedAt,
|
||||||
|
Barcode = record.Barcode,
|
||||||
|
ScanTryCount = record.ScanTryCount,
|
||||||
|
SftpTryCount = record.SftpTryCount,
|
||||||
|
ResultCode = record.ResultCode,
|
||||||
|
ResultDescription = record.ResultDescription,
|
||||||
|
ReleaseSent = record.ReleaseSent,
|
||||||
|
AlarmRaised = record.AlarmRaised,
|
||||||
|
ExceptionSummary = record.ExceptionSummary
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot.RecordStatisticDate = statistics.StatisticDate;
|
||||||
|
snapshot.TodayProcessCount = statistics.TotalCount;
|
||||||
|
snapshot.TodayOkCount = statistics.OkCount;
|
||||||
|
snapshot.TodayNgCount = statistics.NgCount;
|
||||||
|
snapshot.RecordsLastUpdatedAt = statistics.LastUpdatedAt;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新当前历史区统计结果。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="statistics">统计结果。</param>
|
||||||
|
public void UpdateRecordStatistics(BoardRecordStatistics statistics)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(statistics);
|
||||||
|
|
||||||
|
UpdateSnapshot(snapshot =>
|
||||||
|
{
|
||||||
|
snapshot.RecordStatisticDate = statistics.StatisticDate;
|
||||||
|
snapshot.TodayProcessCount = statistics.TotalCount;
|
||||||
|
snapshot.TodayOkCount = statistics.OkCount;
|
||||||
|
snapshot.TodayNgCount = statistics.NgCount;
|
||||||
|
snapshot.RecordsLastUpdatedAt = statistics.LastUpdatedAt;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 追加一条 UI 日志。
|
/// 追加一条 UI 日志。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,504 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using AxiOmron.PcbCheck.Models;
|
||||||
|
using AxiOmron.PcbCheck.Options;
|
||||||
|
using AxiOmron.PcbCheck.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace AxiOmron.PcbCheck.Services.Implementations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 提供按天分文件的 CSV 处理记录持久化能力。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CsvBoardRecordRepository : IBoardRecordRepository
|
||||||
|
{
|
||||||
|
private const string HeaderLine = "StartedAt,CompletedAt,Barcode,ScanTryCount,SftpTryCount,ResultCode,ResultDescription,ReleaseSent,AlarmRaised,ExceptionSummary";
|
||||||
|
private const string TimestampFormat = "yyyy-MM-dd HH:mm:ss.fff";
|
||||||
|
private readonly RecordPersistenceOptions _options;
|
||||||
|
private readonly int _maxRecentRecordCount;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly IAppLogger<CsvBoardRecordRepository>? _appLogger;
|
||||||
|
private readonly SemaphoreSlim _syncLock = new(1, 1);
|
||||||
|
private BoardRecordLoadResult? _cachedCurrentDayResult;
|
||||||
|
private DateOnly? _cachedCurrentDay;
|
||||||
|
private DateTimeOffset _nextDayChangeCheckAt = DateTimeOffset.MinValue;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化 CSV 仓储。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="config">应用配置。</param>
|
||||||
|
/// <param name="timeProvider">时间提供器。</param>
|
||||||
|
/// <param name="appLogger">可选应用日志服务。</param>
|
||||||
|
public CsvBoardRecordRepository(
|
||||||
|
AppConfig config,
|
||||||
|
TimeProvider timeProvider,
|
||||||
|
IAppLogger<CsvBoardRecordRepository>? appLogger = null)
|
||||||
|
: this(config?.RecordPersistence ?? throw new ArgumentNullException(nameof(config)), config.Workflow.MaxBoardRecords, timeProvider, appLogger)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化 CSV 仓储。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">记录持久化配置。</param>
|
||||||
|
/// <param name="timeProvider">时间提供器。</param>
|
||||||
|
/// <param name="appLogger">可选应用日志服务。</param>
|
||||||
|
public CsvBoardRecordRepository(
|
||||||
|
RecordPersistenceOptions options,
|
||||||
|
TimeProvider timeProvider,
|
||||||
|
IAppLogger<CsvBoardRecordRepository>? appLogger = null)
|
||||||
|
: this(options, 100, timeProvider, appLogger)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化 CSV 仓储。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">记录持久化配置。</param>
|
||||||
|
/// <param name="maxRecentRecordCount">最近记录最大条数。</param>
|
||||||
|
/// <param name="timeProvider">时间提供器。</param>
|
||||||
|
/// <param name="appLogger">可选应用日志服务。</param>
|
||||||
|
/// <exception cref="ArgumentNullException">当 <paramref name="options"/> 或 <paramref name="timeProvider"/> 为 <see langword="null"/> 时抛出。</exception>
|
||||||
|
private CsvBoardRecordRepository(
|
||||||
|
RecordPersistenceOptions options,
|
||||||
|
int maxRecentRecordCount,
|
||||||
|
TimeProvider timeProvider,
|
||||||
|
IAppLogger<CsvBoardRecordRepository>? appLogger)
|
||||||
|
{
|
||||||
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||||
|
_maxRecentRecordCount = Math.Max(1, maxRecentRecordCount);
|
||||||
|
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||||
|
_appLogger = appLogger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 加载当前本地日期对应的处理记录与统计数据。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">取消令牌。取消后立即终止读取操作,并返回已取消任务。</param>
|
||||||
|
/// <returns>当前日期的最近记录与统计结果。</returns>
|
||||||
|
public async Task<BoardRecordLoadResult> LoadCurrentDayAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _syncLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await LoadDayCoreAsync(GetCurrentLocalDate(), cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_syncLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 当检测到跨天时重新加载当前日期数据;若日期未变化则返回 <see langword="null"/>。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">取消令牌。取消后立即终止检查与读取操作,并返回已取消任务。</param>
|
||||||
|
/// <returns>日期变化时返回新的加载结果;否则返回 <see langword="null"/>。</returns>
|
||||||
|
public async Task<BoardRecordLoadResult?> ReloadCurrentDayIfDateChangedAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _syncLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DateTimeOffset now = _timeProvider.GetLocalNow();
|
||||||
|
if (now < _nextDayChangeCheckAt)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_nextDayChangeCheckAt = now.AddSeconds(Math.Max(1, _options.DayChangeCheckIntervalSeconds));
|
||||||
|
DateOnly currentDate = GetCurrentLocalDate();
|
||||||
|
if (_cachedCurrentDay.HasValue && _cachedCurrentDay.Value == currentDate)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await LoadDayCoreAsync(currentDate, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_syncLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将一条处理记录追加写入其完成日 CSV,并返回当前日期对应的最新列表与统计。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="record">待持久化的处理记录。</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌。取消后立即终止写入操作,并返回已取消任务。</param>
|
||||||
|
/// <returns>追加结果;若写入失败,返回的列表与统计保持在最近一次成功持久化后的真值。</returns>
|
||||||
|
/// <exception cref="ArgumentNullException">当 <paramref name="record"/> 为 <see langword="null"/> 时抛出。</exception>
|
||||||
|
public async Task<BoardRecordAppendResult> AppendAsync(BoardProcessRecord record, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(record);
|
||||||
|
|
||||||
|
await _syncLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DateOnly currentDate = GetCurrentLocalDate();
|
||||||
|
if (!_cachedCurrentDay.HasValue || _cachedCurrentDay.Value != currentDate)
|
||||||
|
{
|
||||||
|
await LoadDayCoreAsync(currentDate, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DateOnly recordDate = DateOnly.FromDateTime(record.CompletedAt.LocalDateTime.Date);
|
||||||
|
string path = GetDailyFilePath(recordDate);
|
||||||
|
string? directory = Path.GetDirectoryName(path);
|
||||||
|
if (!string.IsNullOrWhiteSpace(directory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool fileExists = File.Exists(path);
|
||||||
|
await using (var stream = new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite))
|
||||||
|
await using (var writer = new StreamWriter(stream, new UTF8Encoding(true)))
|
||||||
|
{
|
||||||
|
if (!fileExists)
|
||||||
|
{
|
||||||
|
await writer.WriteLineAsync(HeaderLine).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await writer.WriteLineAsync(ToCsvLine(record)).ConfigureAwait(false);
|
||||||
|
await writer.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
BoardRecordLoadResult refreshed = await LoadDayCoreAsync(currentDate, cancellationToken).ConfigureAwait(false);
|
||||||
|
return CreateAppendResult(true, string.Empty, refreshed);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_appLogger?.LogError(ex, "处理记录 CSV 追加失败");
|
||||||
|
BoardRecordLoadResult current = _cachedCurrentDayResult ?? CreateEmptyResult(currentDate);
|
||||||
|
return CreateAppendResult(false, ex.Message, current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_syncLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 读取指定日期的 CSV 文件并刷新缓存。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="date">目标日期。</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
|
/// <returns>读取结果。</returns>
|
||||||
|
private async Task<BoardRecordLoadResult> LoadDayCoreAsync(DateOnly date, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
string path = GetDailyFilePath(date);
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
BoardRecordLoadResult empty = CreateEmptyResult(date);
|
||||||
|
Cache(date, empty);
|
||||||
|
return CloneResult(empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string[] lines = await File.ReadAllLinesAsync(path, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (lines.Length == 0)
|
||||||
|
{
|
||||||
|
BoardRecordLoadResult empty = CreateEmptyResult(date);
|
||||||
|
Cache(date, empty);
|
||||||
|
return CloneResult(empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
string header = lines[0].Trim('\uFEFF');
|
||||||
|
if (!string.Equals(header, HeaderLine, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_appLogger?.LogError($"处理记录 CSV 表头不匹配,文件={path}");
|
||||||
|
BoardRecordLoadResult empty = CreateEmptyResult(date);
|
||||||
|
Cache(date, empty);
|
||||||
|
return CloneResult(empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
var records = new List<BoardProcessRecord>();
|
||||||
|
for (int index = 1; index < lines.Length; index++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
string line = lines[index];
|
||||||
|
if (string.IsNullOrWhiteSpace(line))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryParseRecord(line, out BoardProcessRecord? record))
|
||||||
|
{
|
||||||
|
_appLogger?.LogWarning($"处理记录 CSV 第 {index + 1} 行解析失败,已跳过。", true);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
records.Add(record!);
|
||||||
|
}
|
||||||
|
|
||||||
|
records.Sort((left, right) => left.CompletedAt.CompareTo(right.CompletedAt));
|
||||||
|
IReadOnlyList<BoardProcessRecord> recentRecords = records
|
||||||
|
.Skip(Math.Max(0, records.Count - _maxRecentRecordCount))
|
||||||
|
.Reverse()
|
||||||
|
.Select(CloneRecord)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var statistics = new BoardRecordStatistics
|
||||||
|
{
|
||||||
|
StatisticDate = date,
|
||||||
|
TotalCount = records.Count,
|
||||||
|
OkCount = records.Count(record => record.ResultCode == (ushort)WorkflowResultCode.Passed),
|
||||||
|
NgCount = records.Count(record => record.ResultCode != (ushort)WorkflowResultCode.Passed
|
||||||
|
&& record.ResultCode != (ushort)WorkflowResultCode.Processing
|
||||||
|
&& record.ResultCode != (ushort)WorkflowResultCode.None),
|
||||||
|
LastUpdatedAt = _timeProvider.GetLocalNow()
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = new BoardRecordLoadResult
|
||||||
|
{
|
||||||
|
RecentRecords = recentRecords,
|
||||||
|
Statistics = statistics
|
||||||
|
};
|
||||||
|
|
||||||
|
Cache(date, result);
|
||||||
|
return CloneResult(result);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_appLogger?.LogError(ex, "读取当日处理记录 CSV 失败");
|
||||||
|
BoardRecordLoadResult empty = CreateEmptyResult(date);
|
||||||
|
Cache(date, empty);
|
||||||
|
return CloneResult(empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 刷新当前日期缓存。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="date">缓存日期。</param>
|
||||||
|
/// <param name="result">缓存结果。</param>
|
||||||
|
private void Cache(DateOnly date, BoardRecordLoadResult result)
|
||||||
|
{
|
||||||
|
_cachedCurrentDay = date;
|
||||||
|
_cachedCurrentDayResult = CloneResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建空结果。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="date">统计日期。</param>
|
||||||
|
/// <returns>空结果对象。</returns>
|
||||||
|
private BoardRecordLoadResult CreateEmptyResult(DateOnly date)
|
||||||
|
{
|
||||||
|
return new BoardRecordLoadResult
|
||||||
|
{
|
||||||
|
RecentRecords = Array.Empty<BoardProcessRecord>(),
|
||||||
|
Statistics = new BoardRecordStatistics
|
||||||
|
{
|
||||||
|
StatisticDate = date,
|
||||||
|
LastUpdatedAt = _timeProvider.GetLocalNow()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建追加结果对象。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="isPersisted">是否写入成功。</param>
|
||||||
|
/// <param name="errorMessage">错误消息。</param>
|
||||||
|
/// <param name="result">当前日期结果。</param>
|
||||||
|
/// <returns>追加结果对象。</returns>
|
||||||
|
private static BoardRecordAppendResult CreateAppendResult(bool isPersisted, string errorMessage, BoardRecordLoadResult result)
|
||||||
|
{
|
||||||
|
return new BoardRecordAppendResult
|
||||||
|
{
|
||||||
|
IsPersisted = isPersisted,
|
||||||
|
ErrorMessage = errorMessage,
|
||||||
|
RecentRecords = result.RecentRecords.Select(CloneRecord).ToList(),
|
||||||
|
Statistics = result.Statistics.Clone()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 克隆加载结果对象。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="result">源结果。</param>
|
||||||
|
/// <returns>结果副本。</returns>
|
||||||
|
private static BoardRecordLoadResult CloneResult(BoardRecordLoadResult result)
|
||||||
|
{
|
||||||
|
return new BoardRecordLoadResult
|
||||||
|
{
|
||||||
|
RecentRecords = result.RecentRecords.Select(CloneRecord).ToList(),
|
||||||
|
Statistics = result.Statistics.Clone()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取当前本地日期。
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>当前本地日期。</returns>
|
||||||
|
private DateOnly GetCurrentLocalDate()
|
||||||
|
{
|
||||||
|
return DateOnly.FromDateTime(_timeProvider.GetLocalNow().LocalDateTime.Date);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取指定日期的 CSV 文件路径。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="date">目标日期。</param>
|
||||||
|
/// <returns>CSV 文件绝对路径。</returns>
|
||||||
|
private string GetDailyFilePath(DateOnly date)
|
||||||
|
{
|
||||||
|
string directoryPath = Path.IsPathRooted(_options.DirectoryPath)
|
||||||
|
? _options.DirectoryPath
|
||||||
|
: Path.Combine(AppContext.BaseDirectory, _options.DirectoryPath);
|
||||||
|
|
||||||
|
return Path.Combine(directoryPath, $"{date:yyyy-MM-dd}.csv");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将处理记录转换为单行 CSV 文本。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="record">待转换记录。</param>
|
||||||
|
/// <returns>CSV 文本。</returns>
|
||||||
|
private static string ToCsvLine(BoardProcessRecord record)
|
||||||
|
{
|
||||||
|
return string.Join(
|
||||||
|
",",
|
||||||
|
Escape(record.StartedAt.ToString(TimestampFormat)),
|
||||||
|
Escape(record.CompletedAt.ToString(TimestampFormat)),
|
||||||
|
Escape(record.Barcode),
|
||||||
|
record.ScanTryCount.ToString(),
|
||||||
|
record.SftpTryCount.ToString(),
|
||||||
|
record.ResultCode.ToString(),
|
||||||
|
Escape(record.ResultDescription),
|
||||||
|
record.ReleaseSent.ToString(),
|
||||||
|
record.AlarmRaised.ToString(),
|
||||||
|
Escape(record.ExceptionSummary));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 解析单行 CSV 记录。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="line">CSV 文本。</param>
|
||||||
|
/// <param name="record">解析成功的记录对象。</param>
|
||||||
|
/// <returns>解析成功返回 <see langword="true"/>;否则返回 <see langword="false"/>。</returns>
|
||||||
|
private static bool TryParseRecord(string line, out BoardProcessRecord? record)
|
||||||
|
{
|
||||||
|
record = null;
|
||||||
|
List<string> fields = ParseCsvLine(line);
|
||||||
|
if (fields.Count != 10)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!DateTimeOffset.TryParse(fields[0], out DateTimeOffset startedAt)
|
||||||
|
|| !DateTimeOffset.TryParse(fields[1], out DateTimeOffset completedAt)
|
||||||
|
|| !int.TryParse(fields[3], out int scanTryCount)
|
||||||
|
|| !int.TryParse(fields[4], out int sftpTryCount)
|
||||||
|
|| !ushort.TryParse(fields[5], out ushort resultCode)
|
||||||
|
|| !bool.TryParse(fields[7], out bool releaseSent)
|
||||||
|
|| !bool.TryParse(fields[8], out bool alarmRaised))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
record = new BoardProcessRecord
|
||||||
|
{
|
||||||
|
StartedAt = startedAt,
|
||||||
|
CompletedAt = completedAt,
|
||||||
|
Barcode = fields[2],
|
||||||
|
ScanTryCount = scanTryCount,
|
||||||
|
SftpTryCount = sftpTryCount,
|
||||||
|
ResultCode = resultCode,
|
||||||
|
ResultDescription = fields[6],
|
||||||
|
ReleaseSent = releaseSent,
|
||||||
|
AlarmRaised = alarmRaised,
|
||||||
|
ExceptionSummary = fields[9]
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 解析 CSV 行。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="line">CSV 文本。</param>
|
||||||
|
/// <returns>字段列表。</returns>
|
||||||
|
private static List<string> ParseCsvLine(string line)
|
||||||
|
{
|
||||||
|
var result = new List<string>();
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
bool inQuotes = false;
|
||||||
|
|
||||||
|
for (int index = 0; index < line.Length; index++)
|
||||||
|
{
|
||||||
|
char current = line[index];
|
||||||
|
if (current == '"')
|
||||||
|
{
|
||||||
|
if (inQuotes && index + 1 < line.Length && line[index + 1] == '"')
|
||||||
|
{
|
||||||
|
builder.Append('"');
|
||||||
|
index++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current == ',' && !inQuotes)
|
||||||
|
{
|
||||||
|
result.Add(builder.ToString());
|
||||||
|
builder.Clear();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.Append(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Add(builder.ToString());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 对 CSV 字段执行转义。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">原始字段文本。</param>
|
||||||
|
/// <returns>转义后的文本。</returns>
|
||||||
|
private static string Escape(string value)
|
||||||
|
{
|
||||||
|
value ??= string.Empty;
|
||||||
|
if (!value.Contains(',') && !value.Contains('"') && !value.Contains('\r') && !value.Contains('\n'))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"\"{value.Replace("\"", "\"\"")}\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建处理记录副本。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="record">待复制记录。</param>
|
||||||
|
/// <returns>记录副本。</returns>
|
||||||
|
private static BoardProcessRecord CloneRecord(BoardProcessRecord record)
|
||||||
|
{
|
||||||
|
return new BoardProcessRecord
|
||||||
|
{
|
||||||
|
StartedAt = record.StartedAt,
|
||||||
|
CompletedAt = record.CompletedAt,
|
||||||
|
Barcode = record.Barcode,
|
||||||
|
ScanTryCount = record.ScanTryCount,
|
||||||
|
SftpTryCount = record.SftpTryCount,
|
||||||
|
ResultCode = record.ResultCode,
|
||||||
|
ResultDescription = record.ResultDescription,
|
||||||
|
ReleaseSent = record.ReleaseSent,
|
||||||
|
AlarmRaised = record.AlarmRaised,
|
||||||
|
ExceptionSummary = record.ExceptionSummary
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
|||||||
private readonly IScannerService _scannerService;
|
private readonly IScannerService _scannerService;
|
||||||
private readonly ISftpLookupService _sftpLookupService;
|
private readonly ISftpLookupService _sftpLookupService;
|
||||||
private readonly IAndonService _andonService;
|
private readonly IAndonService _andonService;
|
||||||
|
private readonly IBoardRecordRepository _boardRecordRepository;
|
||||||
private readonly IAppStateStore _stateStore;
|
private readonly IAppStateStore _stateStore;
|
||||||
private readonly AppConfig _config;
|
private readonly AppConfig _config;
|
||||||
private readonly IAppLogger<WorkflowHostedService> _appLogger;
|
private readonly IAppLogger<WorkflowHostedService> _appLogger;
|
||||||
@@ -52,6 +53,30 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
|||||||
IAppStateStore stateStore,
|
IAppStateStore stateStore,
|
||||||
AppConfig config,
|
AppConfig config,
|
||||||
IAppLogger<WorkflowHostedService> appLogger)
|
IAppLogger<WorkflowHostedService> appLogger)
|
||||||
|
: this(plcService, scannerService, sftpLookupService, andonService, stateStore, config, appLogger, new NullBoardRecordRepository())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化流程后台服务。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="plcService">PLC 服务。</param>
|
||||||
|
/// <param name="scannerService">扫码枪服务。</param>
|
||||||
|
/// <param name="sftpLookupService">SFTP 校验服务。</param>
|
||||||
|
/// <param name="andonService">安灯服务。</param>
|
||||||
|
/// <param name="stateStore">运行态存储。</param>
|
||||||
|
/// <param name="config">应用配置。</param>
|
||||||
|
/// <param name="appLogger">日志记录器。</param>
|
||||||
|
/// <param name="boardRecordRepository">处理记录仓储。</param>
|
||||||
|
public WorkflowHostedService(
|
||||||
|
IPlcService plcService,
|
||||||
|
IScannerService scannerService,
|
||||||
|
ISftpLookupService sftpLookupService,
|
||||||
|
IAndonService andonService,
|
||||||
|
IAppStateStore stateStore,
|
||||||
|
AppConfig config,
|
||||||
|
IAppLogger<WorkflowHostedService> appLogger,
|
||||||
|
IBoardRecordRepository boardRecordRepository)
|
||||||
{
|
{
|
||||||
_plcService = plcService ?? throw new ArgumentNullException(nameof(plcService));
|
_plcService = plcService ?? throw new ArgumentNullException(nameof(plcService));
|
||||||
_scannerService = scannerService ?? throw new ArgumentNullException(nameof(scannerService));
|
_scannerService = scannerService ?? throw new ArgumentNullException(nameof(scannerService));
|
||||||
@@ -60,6 +85,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
|||||||
_stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore));
|
_stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore));
|
||||||
_config = config ?? throw new ArgumentNullException(nameof(config));
|
_config = config ?? throw new ArgumentNullException(nameof(config));
|
||||||
_appLogger = appLogger ?? throw new ArgumentNullException(nameof(appLogger));
|
_appLogger = appLogger ?? throw new ArgumentNullException(nameof(appLogger));
|
||||||
|
_boardRecordRepository = boardRecordRepository ?? throw new ArgumentNullException(nameof(boardRecordRepository));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -70,6 +96,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
|||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
_appLogger.LogInformation("流程服务已启动,等待 PLC 到位信号。", true);
|
_appLogger.LogInformation("流程服务已启动,等待 PLC 到位信号。", true);
|
||||||
|
await RestoreBoardRecordsAsync(stoppingToken).ConfigureAwait(false);
|
||||||
await ProbePlcOnStartupAsync(stoppingToken).ConfigureAwait(false);
|
await ProbePlcOnStartupAsync(stoppingToken).ConfigureAwait(false);
|
||||||
await ProbeSftpOnStartupAsync(stoppingToken).ConfigureAwait(false);
|
await ProbeSftpOnStartupAsync(stoppingToken).ConfigureAwait(false);
|
||||||
await ProbeScannerOnStartupAsync(stoppingToken).ConfigureAwait(false);
|
await ProbeScannerOnStartupAsync(stoppingToken).ConfigureAwait(false);
|
||||||
@@ -79,6 +106,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
await RefreshBoardRecordsIfDateChangedAsync(stoppingToken).ConfigureAwait(false);
|
||||||
var signals = await _plcService.ReadSignalsAsync(stoppingToken).ConfigureAwait(false);
|
var signals = await _plcService.ReadSignalsAsync(stoppingToken).ConfigureAwait(false);
|
||||||
HandleSignalSnapshot(signals);
|
HandleSignalSnapshot(signals);
|
||||||
await RefreshPlcMonitorSnapshotAsync(stoppingToken).ConfigureAwait(false);
|
await RefreshPlcMonitorSnapshotAsync(stoppingToken).ConfigureAwait(false);
|
||||||
@@ -844,10 +872,84 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
_stateStore.AddRecord(record);
|
await PersistBoardRecordAsync(record, cancellationToken).ConfigureAwait(false);
|
||||||
_appLogger.LogInformation($"单板流程完成,结果={record.ResultDescription}", true);
|
_appLogger.LogInformation($"单板流程完成,结果={record.ResultDescription}", true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 在应用启动时恢复当天处理记录与统计信息。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
|
/// <returns>表示恢复完成的任务。</returns>
|
||||||
|
private async Task RestoreBoardRecordsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
BoardRecordLoadResult loadResult = await _boardRecordRepository.LoadCurrentDayAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
_stateStore.ReplaceRecords(loadResult.RecentRecords, loadResult.Statistics);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_appLogger.LogError(ex, "启动时恢复当日处理记录失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 在检测到日期切换时刷新当天处理记录与统计信息。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
|
/// <returns>表示刷新完成的任务。</returns>
|
||||||
|
private async Task RefreshBoardRecordsIfDateChangedAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
BoardRecordLoadResult? loadResult = await _boardRecordRepository.ReloadCurrentDayIfDateChangedAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
if (loadResult is not null)
|
||||||
|
{
|
||||||
|
_stateStore.ReplaceRecords(loadResult.RecentRecords, loadResult.Statistics);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_appLogger.LogError(ex, "刷新当日处理记录失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将流程完成记录持久化到 CSV,并刷新首页历史区。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="record">待持久化的处理记录。</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
|
/// <returns>表示持久化处理完成的任务。</returns>
|
||||||
|
private async Task PersistBoardRecordAsync(BoardProcessRecord record, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
BoardRecordAppendResult appendResult = await _boardRecordRepository.AppendAsync(record, cancellationToken).ConfigureAwait(false);
|
||||||
|
_stateStore.ReplaceRecords(appendResult.RecentRecords, appendResult.Statistics);
|
||||||
|
if (!appendResult.IsPersisted)
|
||||||
|
{
|
||||||
|
_appLogger.LogError($"处理记录持久化失败: {appendResult.ErrorMessage}", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_appLogger.LogError(ex, "处理记录持久化失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 在发生系统级异常时进入故障状态。
|
/// 在发生系统级异常时进入故障状态。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -1134,4 +1236,41 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
|||||||
_ => "未知结果"
|
_ => "未知结果"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 提供空实现的处理记录仓储,供未接入持久化时兜底使用。
|
||||||
|
/// </summary>
|
||||||
|
private sealed class NullBoardRecordRepository : IBoardRecordRepository
|
||||||
|
{
|
||||||
|
public Task<BoardRecordLoadResult> LoadCurrentDayAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new BoardRecordLoadResult
|
||||||
|
{
|
||||||
|
Statistics = new BoardRecordStatistics
|
||||||
|
{
|
||||||
|
StatisticDate = DateOnly.FromDateTime(DateTime.Today),
|
||||||
|
LastUpdatedAt = DateTimeOffset.Now
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<BoardRecordLoadResult?> ReloadCurrentDayIfDateChangedAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult<BoardRecordLoadResult?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<BoardRecordAppendResult> AppendAsync(BoardProcessRecord record, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(record);
|
||||||
|
return Task.FromResult(new BoardRecordAppendResult
|
||||||
|
{
|
||||||
|
IsPersisted = true,
|
||||||
|
Statistics = new BoardRecordStatistics
|
||||||
|
{
|
||||||
|
StatisticDate = DateOnly.FromDateTime(DateTime.Today),
|
||||||
|
LastUpdatedAt = DateTimeOffset.Now
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,6 +230,35 @@ public interface IWorkflowControlService
|
|||||||
Task TestAndonAsync(CancellationToken cancellationToken);
|
Task TestAndonAsync(CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 定义单板处理记录的 CSV 持久化能力。
|
||||||
|
/// </summary>
|
||||||
|
public interface IBoardRecordRepository
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 加载当前本地日期对应的处理记录与统计数据。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">取消令牌。取消后立即终止读取操作,并返回已取消任务。</param>
|
||||||
|
/// <returns>当前日期的最近记录与统计结果。</returns>
|
||||||
|
Task<BoardRecordLoadResult> LoadCurrentDayAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 当检测到跨天时重新加载当前日期数据;若日期未变化则返回 <see langword="null"/>。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">取消令牌。取消后立即终止检查与读取操作,并返回已取消任务。</param>
|
||||||
|
/// <returns>日期变化时返回新的加载结果;否则返回 <see langword="null"/>。</returns>
|
||||||
|
Task<BoardRecordLoadResult?> ReloadCurrentDayIfDateChangedAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将一条处理记录追加写入其完成日 CSV,并返回当前日期对应的最新列表与统计。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="record">待持久化的处理记录。</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌。取消后立即终止写入操作,并返回已取消任务。</param>
|
||||||
|
/// <returns>追加结果;若写入失败,返回的列表与统计保持在最近一次成功持久化后的真值。</returns>
|
||||||
|
/// <exception cref="ArgumentNullException">当 <paramref name="record"/> 为 <see langword="null"/> 时抛出。</exception>
|
||||||
|
Task<BoardRecordAppendResult> AppendAsync(BoardProcessRecord record, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 定义管理员解锁密码弹窗交互能力。
|
/// 定义管理员解锁密码弹窗交互能力。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -276,6 +305,21 @@ public interface IAppStateStore
|
|||||||
/// <param name="updateAction">用于修改快照的更新委托。</param>
|
/// <param name="updateAction">用于修改快照的更新委托。</param>
|
||||||
void UpdateSnapshot(Action<RuntimeSnapshot> updateAction);
|
void UpdateSnapshot(Action<RuntimeSnapshot> updateAction);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 使用一批最近记录与统计结果替换当前历史区状态。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="records">最近记录集合,通常已按完成时间倒序排列。</param>
|
||||||
|
/// <param name="statistics">与当前日期对应的统计结果。</param>
|
||||||
|
/// <exception cref="ArgumentNullException">当 <paramref name="records"/> 或 <paramref name="statistics"/> 为 <see langword="null"/> 时抛出。</exception>
|
||||||
|
void ReplaceRecords(IReadOnlyList<BoardProcessRecord> records, BoardRecordStatistics statistics);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新当前历史区统计结果,不修改最近记录集合。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="statistics">最新统计结果。</param>
|
||||||
|
/// <exception cref="ArgumentNullException">当 <paramref name="statistics"/> 为 <see langword="null"/> 时抛出。</exception>
|
||||||
|
void UpdateRecordStatistics(BoardRecordStatistics statistics);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 追加一条 UI 日志。
|
/// 追加一条 UI 日志。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
private readonly SecurityOptions _securityOptions;
|
private readonly SecurityOptions _securityOptions;
|
||||||
private readonly IAdminUnlockDialogService _adminUnlockDialogService;
|
private readonly IAdminUnlockDialogService _adminUnlockDialogService;
|
||||||
private readonly DispatcherTimer _adminUnlockTimer;
|
private readonly DispatcherTimer _adminUnlockTimer;
|
||||||
|
private DateTimeOffset _lastAppliedRecordDataUpdatedAt = DateTimeOffset.MinValue;
|
||||||
private DateTimeOffset? _adminUnlockedUntil;
|
private DateTimeOffset? _adminUnlockedUntil;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -62,10 +63,8 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
|
|
||||||
_stateStore.SnapshotChanged += OnSnapshotChanged;
|
_stateStore.SnapshotChanged += OnSnapshotChanged;
|
||||||
_stateStore.LogAdded += OnLogAdded;
|
_stateStore.LogAdded += OnLogAdded;
|
||||||
_stateStore.RecordAdded += OnRecordAdded;
|
|
||||||
ApplySnapshot(_stateStore.GetSnapshot());
|
ApplySnapshot(_stateStore.GetSnapshot());
|
||||||
RecalculateLogStatistics();
|
RecalculateLogStatistics();
|
||||||
RecalculateProcessStatistics();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -225,6 +224,12 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _lastProcessUpdateTime = "-";
|
private string _lastProcessUpdateTime = "-";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置当前处理记录统计日期文本。
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _recordStatisticDateText = "-";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取或设置管理员功能是否已解锁。
|
/// 获取或设置管理员功能是否已解锁。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -377,7 +382,7 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void RefreshProcessRecords()
|
private void RefreshProcessRecords()
|
||||||
{
|
{
|
||||||
RecalculateProcessStatistics();
|
LastProcessUpdateTime = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -399,12 +404,40 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
LastCompletedAt = snapshot.LastCompletedAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-";
|
LastCompletedAt = snapshot.LastCompletedAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-";
|
||||||
IsBusy = snapshot.IsBusy;
|
IsBusy = snapshot.IsBusy;
|
||||||
LastUpdatedAt = snapshot.LastUpdatedAt.ToString("yyyy-MM-dd HH:mm:ss");
|
LastUpdatedAt = snapshot.LastUpdatedAt.ToString("yyyy-MM-dd HH:mm:ss");
|
||||||
|
RecordStatisticDateText = snapshot.RecordStatisticDate?.ToString("yyyy-MM-dd") ?? "-";
|
||||||
|
TodayProcessCount = snapshot.TodayProcessCount;
|
||||||
|
TodayOkCount = snapshot.TodayOkCount;
|
||||||
|
TodayNgCount = snapshot.TodayNgCount;
|
||||||
|
|
||||||
PlcMonitorItems.Clear();
|
PlcMonitorItems.Clear();
|
||||||
foreach (PlcMonitorItem item in snapshot.PlcMonitorItems)
|
foreach (PlcMonitorItem item in snapshot.PlcMonitorItems)
|
||||||
{
|
{
|
||||||
PlcMonitorItems.Add(item.Clone());
|
PlcMonitorItems.Add(item.Clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (snapshot.RecordsLastUpdatedAt != _lastAppliedRecordDataUpdatedAt)
|
||||||
|
{
|
||||||
|
_lastAppliedRecordDataUpdatedAt = snapshot.RecordsLastUpdatedAt;
|
||||||
|
RecentBoards.Clear();
|
||||||
|
foreach (BoardProcessRecord record in snapshot.BoardRecords
|
||||||
|
.OrderByDescending(item => item.CompletedAt)
|
||||||
|
.Take(_workflowOptions.MaxBoardRecords))
|
||||||
|
{
|
||||||
|
RecentBoards.Add(new BoardProcessRecord
|
||||||
|
{
|
||||||
|
StartedAt = record.StartedAt,
|
||||||
|
CompletedAt = record.CompletedAt,
|
||||||
|
Barcode = record.Barcode,
|
||||||
|
ScanTryCount = record.ScanTryCount,
|
||||||
|
SftpTryCount = record.SftpTryCount,
|
||||||
|
ResultCode = record.ResultCode,
|
||||||
|
ResultDescription = record.ResultDescription,
|
||||||
|
ReleaseSent = record.ReleaseSent,
|
||||||
|
AlarmRaised = record.AlarmRaised,
|
||||||
|
ExceptionSummary = record.ExceptionSummary
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -450,7 +483,8 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
/// <param name="e">集合变化参数。</param>
|
/// <param name="e">集合变化参数。</param>
|
||||||
private void OnRecentBoardsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
private void OnRecentBoardsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||||
{
|
{
|
||||||
RecalculateProcessStatistics();
|
HasProcessRecords = RecentBoards.Count > 0;
|
||||||
|
LastProcessUpdateTime = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -488,42 +522,6 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
LastLogUpdateTime = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss");
|
LastLogUpdateTime = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 根据当前处理记录集合重新计算处理区的统计信息与最后刷新时间。
|
|
||||||
/// </summary>
|
|
||||||
private void RecalculateProcessStatistics()
|
|
||||||
{
|
|
||||||
DateTime today = DateTime.Today;
|
|
||||||
int totalToday = 0;
|
|
||||||
int okToday = 0;
|
|
||||||
int ngToday = 0;
|
|
||||||
|
|
||||||
foreach (BoardProcessRecord record in RecentBoards)
|
|
||||||
{
|
|
||||||
if (record.CompletedAt.LocalDateTime.Date != today)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
totalToday++;
|
|
||||||
if (record.ResultCode == (ushort)WorkflowResultCode.Passed)
|
|
||||||
{
|
|
||||||
okToday++;
|
|
||||||
}
|
|
||||||
else if (record.ResultCode != (ushort)WorkflowResultCode.Processing
|
|
||||||
&& record.ResultCode != (ushort)WorkflowResultCode.None)
|
|
||||||
{
|
|
||||||
ngToday++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TodayProcessCount = totalToday;
|
|
||||||
TodayOkCount = okToday;
|
|
||||||
TodayNgCount = ngToday;
|
|
||||||
HasProcessRecords = RecentBoards.Count > 0;
|
|
||||||
LastProcessUpdateTime = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 处理运行态快照变化事件。
|
/// 处理运行态快照变化事件。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -531,7 +529,7 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
/// <param name="snapshot">最新快照。</param>
|
/// <param name="snapshot">最新快照。</param>
|
||||||
private async void OnSnapshotChanged(object? sender, RuntimeSnapshot snapshot)
|
private async void OnSnapshotChanged(object? sender, RuntimeSnapshot snapshot)
|
||||||
{
|
{
|
||||||
await _dispatcherService.InvokeAsync(() => ApplySnapshot(snapshot)).ConfigureAwait(false);
|
await ApplySnapshotOnUiAsync(snapshot).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -541,34 +539,9 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
/// <param name="entry">新增日志。</param>
|
/// <param name="entry">新增日志。</param>
|
||||||
private async void OnLogAdded(object? sender, UiLogEntry entry)
|
private async void OnLogAdded(object? sender, UiLogEntry entry)
|
||||||
{
|
{
|
||||||
await _dispatcherService.InvokeAsync(() =>
|
await AppendLogOnUiAsync(entry).ConfigureAwait(false);
|
||||||
{
|
|
||||||
Logs.Insert(0, entry);
|
|
||||||
while (Logs.Count > _workflowOptions.MaxUiLogEntries)
|
|
||||||
{
|
|
||||||
Logs.RemoveAt(Logs.Count - 1);
|
|
||||||
}
|
|
||||||
}).ConfigureAwait(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 处理新增单板记录事件。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="sender">事件源。</param>
|
|
||||||
/// <param name="record">新增单板记录。</param>
|
|
||||||
private async void OnRecordAdded(object? sender, BoardProcessRecord record)
|
|
||||||
{
|
|
||||||
await _dispatcherService.InvokeAsync(() =>
|
|
||||||
{
|
|
||||||
RecentBoards.Insert(0, record);
|
|
||||||
while (RecentBoards.Count > _workflowOptions.MaxBoardRecords)
|
|
||||||
{
|
|
||||||
RecentBoards.RemoveAt(RecentBoards.Count - 1);
|
|
||||||
}
|
|
||||||
}).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 处理管理员解锁倒计时,超时后自动恢复锁定。
|
/// 处理管理员解锁倒计时,超时后自动恢复锁定。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="sender">事件源。</param>
|
/// <param name="sender">事件源。</param>
|
||||||
@@ -641,4 +614,45 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
}).ConfigureAwait(false);
|
}).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 在 UI 线程中安全应用最新运行态快照;若应用关闭导致 Dispatcher 调度被取消,则忽略本次刷新。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="snapshot">待应用的运行态快照。</param>
|
||||||
|
/// <returns>表示刷新流程结束的任务。</returns>
|
||||||
|
private async Task ApplySnapshotOnUiAsync(RuntimeSnapshot snapshot)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _dispatcherService.InvokeAsync(() => ApplySnapshot(snapshot)).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// 应用关闭或 Dispatcher 停止时,取消属于预期行为,避免 async void 事件将其上抛为未处理异常。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 在 UI 线程中安全追加日志;若应用关闭导致 Dispatcher 调度被取消,则忽略本次日志刷新。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="entry">待追加的日志项。</param>
|
||||||
|
/// <returns>表示日志刷新流程结束的任务。</returns>
|
||||||
|
private async Task AppendLogOnUiAsync(UiLogEntry entry)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _dispatcherService.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
Logs.Insert(0, entry);
|
||||||
|
while (Logs.Count > _workflowOptions.MaxUiLogEntries)
|
||||||
|
{
|
||||||
|
Logs.RemoveAt(Logs.Count - 1);
|
||||||
|
}
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// 应用关闭或 Dispatcher 停止时,取消属于预期行为,避免 async void 事件将其上抛为未处理异常。
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,6 +156,23 @@ public partial class SystemSettingViewModel : ObservableObject
|
|||||||
/// <param name="snapshot">最新快照。</param>
|
/// <param name="snapshot">最新快照。</param>
|
||||||
private async void OnSnapshotChanged(object? sender, RuntimeSnapshot snapshot)
|
private async void OnSnapshotChanged(object? sender, RuntimeSnapshot snapshot)
|
||||||
{
|
{
|
||||||
await _dispatcherService.InvokeAsync(() => ApplySnapshot(snapshot)).ConfigureAwait(false);
|
await ApplySnapshotOnUiAsync(snapshot).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 在 UI 线程中安全应用最新运行态快照;若应用关闭导致 Dispatcher 调度被取消,则忽略本次刷新。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="snapshot">待应用的运行态快照。</param>
|
||||||
|
/// <returns>表示刷新流程结束的任务。</returns>
|
||||||
|
private async Task ApplySnapshotOnUiAsync(RuntimeSnapshot snapshot)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _dispatcherService.InvokeAsync(() => ApplySnapshot(snapshot)).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// 应用关闭或 Dispatcher 停止时,取消属于预期行为,避免 async void 事件将其上抛为未处理异常。
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1127,25 +1127,33 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid Grid.Row="6">
|
<Grid Grid.Row="6">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
<ColumnDefinition Width="Auto" />
|
<ColumnDefinition Width="Auto" />
|
||||||
</Grid.ColumnDefinitions>
|
<ColumnDefinition Width="24" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
<TextBlock Grid.Column="0"
|
</Grid.ColumnDefinitions>
|
||||||
Style="{StaticResource CardFooterTextStyle}">
|
|
||||||
<Run Text="展示最近 " />
|
<TextBlock Grid.Column="0"
|
||||||
<Run Text="{Binding RecentProcessRecords.Count, Mode=OneWay}" FontWeight="SemiBold" />
|
Style="{StaticResource CardFooterTextStyle}">
|
||||||
<Run Text=" 条处理结果" />
|
<Run Text="展示最近 " />
|
||||||
</TextBlock>
|
<Run Text="{Binding RecentProcessRecords.Count, Mode=OneWay}" FontWeight="SemiBold" />
|
||||||
|
<Run Text=" 条处理结果" />
|
||||||
<TextBlock Grid.Column="1"
|
</TextBlock>
|
||||||
Style="{StaticResource CardFooterTextStyle}">
|
|
||||||
<Run Text="最后刷新:" />
|
<TextBlock Grid.Column="1"
|
||||||
<Run Text="{Binding LastProcessUpdateTime, Mode=OneWay}" />
|
Style="{StaticResource CardFooterTextStyle}">
|
||||||
</TextBlock>
|
<Run Text="统计日期:" />
|
||||||
</Grid>
|
<Run Text="{Binding RecordStatisticDateText, Mode=OneWay}" />
|
||||||
|
</TextBlock>
|
||||||
|
|
||||||
|
<TextBlock Grid.Column="3"
|
||||||
|
Style="{StaticResource CardFooterTextStyle}">
|
||||||
|
<Run Text="最后刷新:" />
|
||||||
|
<Run Text="{Binding LastProcessUpdateTime, Mode=OneWay}" />
|
||||||
|
</TextBlock>
|
||||||
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -64,6 +64,10 @@
|
|||||||
"MaxUiLogEntries": 200,
|
"MaxUiLogEntries": 200,
|
||||||
"MaxBoardRecords": 100
|
"MaxBoardRecords": 100
|
||||||
},
|
},
|
||||||
|
"RecordPersistence": {
|
||||||
|
"DirectoryPath": "Data\\Records",
|
||||||
|
"DayChangeCheckIntervalSeconds": 30
|
||||||
|
},
|
||||||
"Security": {
|
"Security": {
|
||||||
"AdminPassword": "AxiOmron@123",
|
"AdminPassword": "AxiOmron@123",
|
||||||
"AdminSessionTimeoutMinutes": 15
|
"AdminSessionTimeoutMinutes": 15
|
||||||
|
|||||||
207
tests/AxiOmron.PcbCheck.Tests/CsvBoardRecordRepositoryTests.cs
Normal file
207
tests/AxiOmron.PcbCheck.Tests/CsvBoardRecordRepositoryTests.cs
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
using AxiOmron.PcbCheck.Models;
|
||||||
|
using AxiOmron.PcbCheck.Options;
|
||||||
|
using AxiOmron.PcbCheck.Services.Implementations;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AxiOmron.PcbCheck.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证 CSV 处理记录仓储的持久化、恢复与坏行容错行为。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CsvBoardRecordRepositoryTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _tempDirectory;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化测试临时目录。
|
||||||
|
/// </summary>
|
||||||
|
public CsvBoardRecordRepositoryTests()
|
||||||
|
{
|
||||||
|
_tempDirectory = Path.Combine(Path.GetTempPath(), "AxiOmron.PcbCheck.Tests", Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(_tempDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 首次追加记录后,应创建按天 CSV,并能从当天文件恢复记录与统计。
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task AppendAsync_ShouldCreateDailyCsv_AndLoadTodayAsync_ShouldRecoverStatistics()
|
||||||
|
{
|
||||||
|
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 4, 19, 8, 0, 0, TimeSpan.FromHours(8)));
|
||||||
|
var repository = new CsvBoardRecordRepository(
|
||||||
|
new RecordPersistenceOptions
|
||||||
|
{
|
||||||
|
DirectoryPath = _tempDirectory,
|
||||||
|
DayChangeCheckIntervalSeconds = 30
|
||||||
|
},
|
||||||
|
timeProvider);
|
||||||
|
|
||||||
|
await repository.AppendAsync(
|
||||||
|
CreateRecord("PCB-001", new DateTimeOffset(2026, 4, 19, 8, 0, 0, TimeSpan.FromHours(8)), (ushort)WorkflowResultCode.Passed),
|
||||||
|
CancellationToken.None);
|
||||||
|
await repository.AppendAsync(
|
||||||
|
CreateRecord("PCB-002", new DateTimeOffset(2026, 4, 19, 8, 5, 0, TimeSpan.FromHours(8)), (ushort)WorkflowResultCode.NgReleased),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
BoardRecordLoadResult result = await repository.LoadCurrentDayAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(2, result.RecentRecords.Count);
|
||||||
|
Assert.Equal(2, result.Statistics.TotalCount);
|
||||||
|
Assert.Equal(1, result.Statistics.OkCount);
|
||||||
|
Assert.Equal(1, result.Statistics.NgCount);
|
||||||
|
Assert.Equal(new DateOnly(2026, 4, 19), result.Statistics.StatisticDate);
|
||||||
|
Assert.True(File.Exists(Path.Combine(_tempDirectory, "2026-04-19.csv")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 当 CSV 中存在坏行时,应跳过坏行并继续恢复其余有效记录。
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task LoadCurrentDayAsync_ShouldSkipBrokenRows_AndContinue()
|
||||||
|
{
|
||||||
|
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 4, 19, 8, 0, 0, TimeSpan.FromHours(8)));
|
||||||
|
string filePath = Path.Combine(_tempDirectory, "2026-04-19.csv");
|
||||||
|
await File.WriteAllTextAsync(
|
||||||
|
filePath,
|
||||||
|
"""
|
||||||
|
StartedAt,CompletedAt,Barcode,ScanTryCount,SftpTryCount,ResultCode,ResultDescription,ReleaseSent,AlarmRaised,ExceptionSummary
|
||||||
|
broken,row
|
||||||
|
2026-04-19 08:00:00.000,2026-04-19 08:01:00.000,PCB-001,1,1,10,OK,True,False,
|
||||||
|
""");
|
||||||
|
|
||||||
|
var repository = new CsvBoardRecordRepository(
|
||||||
|
new RecordPersistenceOptions
|
||||||
|
{
|
||||||
|
DirectoryPath = _tempDirectory,
|
||||||
|
DayChangeCheckIntervalSeconds = 30
|
||||||
|
},
|
||||||
|
timeProvider);
|
||||||
|
|
||||||
|
BoardRecordLoadResult result = await repository.LoadCurrentDayAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Single(result.RecentRecords);
|
||||||
|
Assert.Equal("PCB-001", result.RecentRecords[0].Barcode);
|
||||||
|
Assert.Equal(1, result.Statistics.TotalCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 当系统时间跨过 0 点时,应自动切换到新一天数据视图。
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task ReloadCurrentDayIfDateChangedAsync_ShouldSwitchToNewDay_WhenClockMovesPastMidnight()
|
||||||
|
{
|
||||||
|
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 4, 19, 23, 59, 50, TimeSpan.FromHours(8)));
|
||||||
|
var repository = new CsvBoardRecordRepository(
|
||||||
|
new RecordPersistenceOptions
|
||||||
|
{
|
||||||
|
DirectoryPath = _tempDirectory,
|
||||||
|
DayChangeCheckIntervalSeconds = 1
|
||||||
|
},
|
||||||
|
timeProvider);
|
||||||
|
|
||||||
|
await repository.AppendAsync(
|
||||||
|
CreateRecord("PCB-OLD", new DateTimeOffset(2026, 4, 19, 23, 59, 55, TimeSpan.FromHours(8)), (ushort)WorkflowResultCode.Passed),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
timeProvider.SetLocalNow(new DateTimeOffset(2026, 4, 20, 0, 0, 10, TimeSpan.FromHours(8)));
|
||||||
|
|
||||||
|
BoardRecordLoadResult? result = await repository.ReloadCurrentDayIfDateChangedAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Empty(result.RecentRecords);
|
||||||
|
Assert.Equal(new DateOnly(2026, 4, 20), result.Statistics.StatisticDate);
|
||||||
|
Assert.Equal(0, result.Statistics.TotalCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 释放测试产生的临时目录。
|
||||||
|
/// </summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_tempDirectory))
|
||||||
|
{
|
||||||
|
Directory.Delete(_tempDirectory, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建测试用处理记录。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="barcode">条码。</param>
|
||||||
|
/// <param name="completedAt">完成时间。</param>
|
||||||
|
/// <param name="resultCode">结果代码。</param>
|
||||||
|
/// <returns>测试记录。</returns>
|
||||||
|
private static BoardProcessRecord CreateRecord(string barcode, DateTimeOffset completedAt, ushort resultCode)
|
||||||
|
{
|
||||||
|
return new BoardProcessRecord
|
||||||
|
{
|
||||||
|
StartedAt = completedAt.AddSeconds(-5),
|
||||||
|
CompletedAt = completedAt,
|
||||||
|
Barcode = barcode,
|
||||||
|
ScanTryCount = 1,
|
||||||
|
SftpTryCount = 1,
|
||||||
|
ResultCode = resultCode,
|
||||||
|
ResultDescription = resultCode == (ushort)WorkflowResultCode.Passed ? "OK" : "NG",
|
||||||
|
ReleaseSent = true,
|
||||||
|
AlarmRaised = false,
|
||||||
|
ExceptionSummary = string.Empty
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 提供可控本地时间的测试时钟。
|
||||||
|
/// </summary>
|
||||||
|
private sealed class FakeTimeProvider : TimeProvider
|
||||||
|
{
|
||||||
|
private DateTimeOffset _localNow;
|
||||||
|
private TimeZoneInfo _localTimeZone;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化测试时钟。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="localNow">当前本地时间。</param>
|
||||||
|
public FakeTimeProvider(DateTimeOffset localNow)
|
||||||
|
{
|
||||||
|
_localNow = localNow;
|
||||||
|
_localTimeZone = TimeZoneInfo.CreateCustomTimeZone(
|
||||||
|
$"Fake-{localNow.Offset}",
|
||||||
|
localNow.Offset,
|
||||||
|
"Fake Local",
|
||||||
|
"Fake Local");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取当前 UTC 时间。
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>当前 UTC 时间。</returns>
|
||||||
|
public override DateTimeOffset GetUtcNow()
|
||||||
|
{
|
||||||
|
return _localNow.ToUniversalTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取当前本地时区。
|
||||||
|
/// </summary>
|
||||||
|
public override TimeZoneInfo LocalTimeZone
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
return _localTimeZone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 设置当前本地时间。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="localNow">新的本地时间。</param>
|
||||||
|
public void SetLocalNow(DateTimeOffset localNow)
|
||||||
|
{
|
||||||
|
_localNow = localNow;
|
||||||
|
_localTimeZone = TimeZoneInfo.CreateCustomTimeZone(
|
||||||
|
$"Fake-{localNow.Offset}",
|
||||||
|
localNow.Offset,
|
||||||
|
"Fake Local",
|
||||||
|
"Fake Local");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
236
tests/AxiOmron.PcbCheck.Tests/MainWindowViewModelTests.cs
Normal file
236
tests/AxiOmron.PcbCheck.Tests/MainWindowViewModelTests.cs
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
using AxiOmron.PcbCheck.Models;
|
||||||
|
using AxiOmron.PcbCheck.Options;
|
||||||
|
using AxiOmron.PcbCheck.Services.Implementations;
|
||||||
|
using AxiOmron.PcbCheck.Services.Interfaces;
|
||||||
|
using AxiOmron.PcbCheck.ViewModels;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AxiOmron.PcbCheck.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证首页视图模型对处理记录和统计区域的绑定行为。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MainWindowViewModelTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 构造时应使用运行态快照中的统计值,而不是仅根据最近 100 条列表重新计算。
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_ShouldUseSnapshotStatistics_InsteadOfRecentListCount()
|
||||||
|
{
|
||||||
|
var stateStore = new AppStateStore();
|
||||||
|
stateStore.UpdateSnapshot(snapshot =>
|
||||||
|
{
|
||||||
|
snapshot.RecordStatisticDate = new DateOnly(2026, 4, 19);
|
||||||
|
snapshot.TodayProcessCount = 150;
|
||||||
|
snapshot.TodayOkCount = 120;
|
||||||
|
snapshot.TodayNgCount = 30;
|
||||||
|
});
|
||||||
|
|
||||||
|
var viewModel = CreateViewModel(stateStore);
|
||||||
|
|
||||||
|
Assert.Equal(150, viewModel.TodayProcessCount);
|
||||||
|
Assert.Equal(120, viewModel.TodayOkCount);
|
||||||
|
Assert.Equal(30, viewModel.TodayNgCount);
|
||||||
|
Assert.Equal("2026-04-19", viewModel.RecordStatisticDateText);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 构造时应从快照恢复最近 100 条记录,并按完成时间倒序展示。
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_ShouldLoadRecentRecords_FromSnapshot_AndLimitTo100()
|
||||||
|
{
|
||||||
|
var stateStore = new AppStateStore();
|
||||||
|
var records = Enumerable.Range(1, 120)
|
||||||
|
.Select(index => new BoardProcessRecord
|
||||||
|
{
|
||||||
|
StartedAt = new DateTimeOffset(2026, 4, 19, 8, 0, 0, TimeSpan.FromHours(8)).AddMinutes(index).AddSeconds(-10),
|
||||||
|
CompletedAt = new DateTimeOffset(2026, 4, 19, 8, 0, 0, TimeSpan.FromHours(8)).AddMinutes(index),
|
||||||
|
Barcode = $"PCB-{index:D3}",
|
||||||
|
ScanTryCount = 1,
|
||||||
|
SftpTryCount = 1,
|
||||||
|
ResultCode = index % 2 == 0 ? (ushort)WorkflowResultCode.Passed : (ushort)WorkflowResultCode.NgReleased,
|
||||||
|
ResultDescription = index % 2 == 0 ? "OK" : "NG",
|
||||||
|
ReleaseSent = true,
|
||||||
|
AlarmRaised = false
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
stateStore.ReplaceRecords(
|
||||||
|
records,
|
||||||
|
new BoardRecordStatistics
|
||||||
|
{
|
||||||
|
StatisticDate = new DateOnly(2026, 4, 19),
|
||||||
|
TotalCount = 120,
|
||||||
|
OkCount = 60,
|
||||||
|
NgCount = 60
|
||||||
|
});
|
||||||
|
|
||||||
|
var viewModel = CreateViewModel(stateStore);
|
||||||
|
|
||||||
|
Assert.Equal(100, viewModel.RecentProcessRecords.Count);
|
||||||
|
Assert.Equal("PCB-120", viewModel.RecentProcessRecords[0].Barcode);
|
||||||
|
Assert.Equal("PCB-021", viewModel.RecentProcessRecords[^1].Barcode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 当 UI 调度已被取消时,快照事件不应将取消异常继续抛到同步上下文。
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void SnapshotChanged_ShouldIgnoreCanceledDispatcherTask()
|
||||||
|
{
|
||||||
|
var stateStore = new AppStateStore();
|
||||||
|
var viewModel = CreateViewModel(stateStore, new CanceledDispatcherService());
|
||||||
|
var synchronizationContext = new ExceptionCapturingSynchronizationContext();
|
||||||
|
SynchronizationContext? originalContext = SynchronizationContext.Current;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
SynchronizationContext.SetSynchronizationContext(synchronizationContext);
|
||||||
|
|
||||||
|
stateStore.UpdateSnapshot(snapshot => snapshot.WorkflowStateText = "运行中");
|
||||||
|
|
||||||
|
Assert.Null(synchronizationContext.CapturedException);
|
||||||
|
Assert.Equal("空闲等待", viewModel.WorkflowStateText);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
SynchronizationContext.SetSynchronizationContext(originalContext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 当 UI 调度已被取消时,日志事件不应将取消异常继续抛到同步上下文。
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void LogAdded_ShouldIgnoreCanceledDispatcherTask()
|
||||||
|
{
|
||||||
|
var stateStore = new AppStateStore();
|
||||||
|
var viewModel = CreateViewModel(stateStore, new CanceledDispatcherService());
|
||||||
|
var synchronizationContext = new ExceptionCapturingSynchronizationContext();
|
||||||
|
SynchronizationContext? originalContext = SynchronizationContext.Current;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
SynchronizationContext.SetSynchronizationContext(synchronizationContext);
|
||||||
|
|
||||||
|
stateStore.AddLog(new UiLogEntry
|
||||||
|
{
|
||||||
|
Timestamp = DateTimeOffset.Now,
|
||||||
|
Level = "Information",
|
||||||
|
Message = "测试日志"
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Null(synchronizationContext.CapturedException);
|
||||||
|
Assert.Empty(viewModel.Logs);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
SynchronizationContext.SetSynchronizationContext(originalContext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建测试用首页视图模型。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="stateStore">运行态存储。</param>
|
||||||
|
/// <returns>首页视图模型。</returns>
|
||||||
|
private static MainWindowViewModel CreateViewModel(IAppStateStore stateStore, IDispatcherService? dispatcherService = null)
|
||||||
|
{
|
||||||
|
return new MainWindowViewModel(
|
||||||
|
stateStore,
|
||||||
|
dispatcherService ?? new ImmediateDispatcherService(),
|
||||||
|
new FakeWorkflowControlService(),
|
||||||
|
new AppConfig
|
||||||
|
{
|
||||||
|
Workflow = new WorkflowOptions
|
||||||
|
{
|
||||||
|
MaxBoardRecords = 100,
|
||||||
|
MaxUiLogEntries = 200
|
||||||
|
},
|
||||||
|
Security = new SecurityOptions
|
||||||
|
{
|
||||||
|
AdminPassword = "test"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
new FakeAdminUnlockDialogService());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 捕获 async void 事件处理器回抛到同步上下文中的异常。
|
||||||
|
/// </summary>
|
||||||
|
private sealed class ExceptionCapturingSynchronizationContext : SynchronizationContext
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置捕获到的异常。
|
||||||
|
/// </summary>
|
||||||
|
public Exception? CapturedException { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 立即执行回调并记录内部异常,避免测试进程直接崩溃。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="d">待执行回调。</param>
|
||||||
|
/// <param name="state">回调状态。</param>
|
||||||
|
public override void Post(SendOrPostCallback d, object? state)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(d);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
d(state);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
CapturedException = ex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 提供立即执行的测试调度服务。
|
||||||
|
/// </summary>
|
||||||
|
private sealed class ImmediateDispatcherService : IDispatcherService
|
||||||
|
{
|
||||||
|
public Task InvokeAsync(Action action)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(action);
|
||||||
|
action();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 提供始终返回已取消任务的测试调度服务,用于模拟应用关闭阶段的 Dispatcher 中止。
|
||||||
|
/// </summary>
|
||||||
|
private sealed class CanceledDispatcherService : IDispatcherService
|
||||||
|
{
|
||||||
|
public Task InvokeAsync(Action action)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(action);
|
||||||
|
return Task.FromCanceled(new CancellationToken(canceled: true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 提供空实现的流程控制服务。
|
||||||
|
/// </summary>
|
||||||
|
private sealed class FakeWorkflowControlService : IWorkflowControlService
|
||||||
|
{
|
||||||
|
public Task ReconnectPlcAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task ReconnectScannerAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task ResetAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task TestAndonAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 提供空实现的管理员解锁弹窗服务。
|
||||||
|
/// </summary>
|
||||||
|
private sealed class FakeAdminUnlockDialogService : IAdminUnlockDialogService
|
||||||
|
{
|
||||||
|
public bool ShowUnlockDialog(string expectedPassword, TimeSpan sessionTimeout) => true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -148,6 +148,46 @@ public sealed class SystemSettingViewModelTests
|
|||||||
Assert.Null(backgroundException);
|
Assert.Null(backgroundException);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 当 UI 调度已被取消时,系统设置页的快照事件不应将取消异常继续抛到同步上下文。
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void SnapshotChanged_ShouldIgnoreCanceledDispatcherTask()
|
||||||
|
{
|
||||||
|
var configService = new FakeAppConfigService();
|
||||||
|
var stateStore = new AppStateStore();
|
||||||
|
var viewModel = new SystemSettingViewModel(
|
||||||
|
configService,
|
||||||
|
new FakeSftpLookupService(),
|
||||||
|
stateStore,
|
||||||
|
new CanceledDispatcherService());
|
||||||
|
var synchronizationContext = new ExceptionCapturingSynchronizationContext();
|
||||||
|
SynchronizationContext? originalContext = SynchronizationContext.Current;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
SynchronizationContext.SetSynchronizationContext(synchronizationContext);
|
||||||
|
|
||||||
|
stateStore.UpdateSnapshot(snapshot =>
|
||||||
|
{
|
||||||
|
snapshot.PlcMonitorItems.Add(new PlcMonitorItem
|
||||||
|
{
|
||||||
|
GroupName = "Inputs",
|
||||||
|
Name = "PcbArrived",
|
||||||
|
CurrentValue = "True",
|
||||||
|
LastUpdatedAt = DateTimeOffset.Now
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Null(synchronizationContext.CapturedException);
|
||||||
|
Assert.Empty(viewModel.PlcMonitorItems);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
SynchronizationContext.SetSynchronizationContext(originalContext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 在 STA 线程中执行指定委托,并将内部异常重新抛回当前测试线程。
|
/// 在 STA 线程中执行指定委托,并将内部异常重新抛回当前测试线程。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -269,6 +309,48 @@ public sealed class SystemSettingViewModelTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 捕获 async void 事件处理器回抛到同步上下文中的异常。
|
||||||
|
/// </summary>
|
||||||
|
private sealed class ExceptionCapturingSynchronizationContext : SynchronizationContext
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置捕获到的异常。
|
||||||
|
/// </summary>
|
||||||
|
public Exception? CapturedException { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 立即执行回调并记录内部异常,避免测试进程直接崩溃。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="d">待执行回调。</param>
|
||||||
|
/// <param name="state">回调状态。</param>
|
||||||
|
public override void Post(SendOrPostCallback d, object? state)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(d);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
d(state);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
CapturedException = ex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 提供始终返回已取消任务的测试调度服务,用于模拟应用关闭阶段的 Dispatcher 中止。
|
||||||
|
/// </summary>
|
||||||
|
private sealed class CanceledDispatcherService : IDispatcherService
|
||||||
|
{
|
||||||
|
public Task InvokeAsync(Action action)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(action);
|
||||||
|
return Task.FromCanceled(new CancellationToken(canceled: true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 提供绑定到指定 Dispatcher 的测试调度服务,用于验证跨线程回切行为。
|
/// 提供绑定到指定 Dispatcher 的测试调度服务,用于验证跨线程回切行为。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -228,6 +228,139 @@ public sealed class WorkflowHostedServiceTests
|
|||||||
Assert.Equal(1, scannerService.TriggerCount);
|
Assert.Equal(1, scannerService.TriggerCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 应用启动时,应从记录仓储恢复当天最近记录与统计信息。
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>异步测试任务。</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task StartAsync_ShouldRestoreTodayRecords_FromRepository()
|
||||||
|
{
|
||||||
|
var stateStore = new AppStateStore();
|
||||||
|
var recordRepository = new FakeBoardRecordRepository
|
||||||
|
{
|
||||||
|
LoadResult = new BoardRecordLoadResult
|
||||||
|
{
|
||||||
|
RecentRecords =
|
||||||
|
[
|
||||||
|
new BoardProcessRecord
|
||||||
|
{
|
||||||
|
StartedAt = new DateTimeOffset(2026, 4, 19, 8, 0, 0, TimeSpan.FromHours(8)),
|
||||||
|
CompletedAt = new DateTimeOffset(2026, 4, 19, 8, 1, 0, TimeSpan.FromHours(8)),
|
||||||
|
Barcode = "PCB-RESTORED",
|
||||||
|
ScanTryCount = 1,
|
||||||
|
SftpTryCount = 1,
|
||||||
|
ResultCode = (ushort)WorkflowResultCode.Passed,
|
||||||
|
ResultDescription = "OK",
|
||||||
|
ReleaseSent = true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
Statistics = new BoardRecordStatistics
|
||||||
|
{
|
||||||
|
StatisticDate = new DateOnly(2026, 4, 19),
|
||||||
|
TotalCount = 1,
|
||||||
|
OkCount = 1,
|
||||||
|
NgCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var service = new WorkflowHostedService(
|
||||||
|
new FakePlcService(),
|
||||||
|
new FakeScannerService(),
|
||||||
|
new FakeSftpLookupService(),
|
||||||
|
new FakeAndonService(),
|
||||||
|
stateStore,
|
||||||
|
new AppConfig
|
||||||
|
{
|
||||||
|
Plc = new PlcOptions
|
||||||
|
{
|
||||||
|
PollIntervalMs = 20
|
||||||
|
}
|
||||||
|
},
|
||||||
|
new FakeAppLogger<WorkflowHostedService>(),
|
||||||
|
recordRepository);
|
||||||
|
|
||||||
|
await service.StartAsync(CancellationToken.None);
|
||||||
|
await Task.Delay(80);
|
||||||
|
await service.StopAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
RuntimeSnapshot snapshot = stateStore.GetSnapshot();
|
||||||
|
Assert.Single(snapshot.BoardRecords);
|
||||||
|
Assert.Equal("PCB-RESTORED", snapshot.BoardRecords[0].Barcode);
|
||||||
|
Assert.Equal(1, snapshot.TodayProcessCount);
|
||||||
|
Assert.Equal(1, snapshot.TodayOkCount);
|
||||||
|
Assert.Equal(0, snapshot.TodayNgCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 当 CSV 持久化失败时,不应让主流程进入故障态。
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>异步测试任务。</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteAsync_ShouldNotEnterFault_WhenCsvAppendFails()
|
||||||
|
{
|
||||||
|
var stateStore = new AppStateStore();
|
||||||
|
var plcService = new SequencedPlcService(new[]
|
||||||
|
{
|
||||||
|
new PlcSignalSnapshot { PcbArrived = true, PlcAckRelease = true },
|
||||||
|
new PlcSignalSnapshot { PcbArrived = false, PlcAckRelease = true }
|
||||||
|
});
|
||||||
|
var scannerService = new CountingScannerService
|
||||||
|
{
|
||||||
|
Result = new ScanOperationResult
|
||||||
|
{
|
||||||
|
IsSuccess = true,
|
||||||
|
DeviceConnected = true,
|
||||||
|
Barcode = "PCB-CSV-FAIL"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var recordRepository = new FakeBoardRecordRepository
|
||||||
|
{
|
||||||
|
AppendException = new IOException("disk full")
|
||||||
|
};
|
||||||
|
|
||||||
|
var service = new WorkflowHostedService(
|
||||||
|
plcService,
|
||||||
|
scannerService,
|
||||||
|
new FakeSftpLookupService
|
||||||
|
{
|
||||||
|
CheckOutcome = new SftpCheckOutcome
|
||||||
|
{
|
||||||
|
Exists = true,
|
||||||
|
ConnectionSucceeded = true,
|
||||||
|
MatchedFilePath = "/pcb/PCB-CSV-FAIL.txt"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
new FakeAndonService(),
|
||||||
|
stateStore,
|
||||||
|
new AppConfig
|
||||||
|
{
|
||||||
|
Plc = new PlcOptions
|
||||||
|
{
|
||||||
|
PollIntervalMs = 20,
|
||||||
|
ReleaseAckTimeoutMs = 100,
|
||||||
|
ReleasePulseMs = 10
|
||||||
|
},
|
||||||
|
Scanner = new ScannerOptions
|
||||||
|
{
|
||||||
|
MaxScanAttempts = 1
|
||||||
|
},
|
||||||
|
Sftp = new SftpOptions
|
||||||
|
{
|
||||||
|
MaxRetryCount = 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
new FakeAppLogger<WorkflowHostedService>(),
|
||||||
|
recordRepository);
|
||||||
|
|
||||||
|
await service.StartAsync(CancellationToken.None);
|
||||||
|
await Task.Delay(200);
|
||||||
|
await service.StopAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
RuntimeSnapshot snapshot = stateStore.GetSnapshot();
|
||||||
|
Assert.NotEqual(WorkflowState.Faulted, snapshot.WorkflowState);
|
||||||
|
Assert.Equal((ushort)WorkflowResultCode.Passed, snapshot.ResultCode);
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class FakePlcService : IPlcService
|
private sealed class FakePlcService : IPlcService
|
||||||
{
|
{
|
||||||
public Task ForceReconnectAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
public Task ForceReconnectAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
@@ -394,6 +527,42 @@ public sealed class WorkflowHostedServiceTests
|
|||||||
=> Task.FromResult(new AndonOperationResult());
|
=> Task.FromResult(new AndonOperationResult());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class FakeBoardRecordRepository : IBoardRecordRepository
|
||||||
|
{
|
||||||
|
public BoardRecordLoadResult LoadResult { get; set; } = new();
|
||||||
|
|
||||||
|
public BoardRecordAppendResult AppendResult { get; set; } = new()
|
||||||
|
{
|
||||||
|
IsPersisted = true,
|
||||||
|
Statistics = new BoardRecordStatistics
|
||||||
|
{
|
||||||
|
StatisticDate = DateOnly.FromDateTime(DateTime.Today)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public Exception? AppendException { get; set; }
|
||||||
|
|
||||||
|
public Task<BoardRecordLoadResult> LoadCurrentDayAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(LoadResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<BoardRecordLoadResult?> ReloadCurrentDayIfDateChangedAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult<BoardRecordLoadResult?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<BoardRecordAppendResult> AppendAsync(BoardProcessRecord record, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (AppendException is not null)
|
||||||
|
{
|
||||||
|
throw AppendException;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(AppendResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class FakeAppLogger<TCategoryName> : IAppLogger<TCategoryName>
|
private sealed class FakeAppLogger<TCategoryName> : IAppLogger<TCategoryName>
|
||||||
{
|
{
|
||||||
public void LogError(string message, bool showInUi = false, params object?[] args) { }
|
public void LogError(string message, bool showInUi = false, params object?[] args) { }
|
||||||
|
|||||||
Reference in New Issue
Block a user