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:
2026-04-19 15:39:31 +08:00
parent d70b94e904
commit 9a2211ccaa
17 changed files with 1802 additions and 89 deletions

View File

@@ -228,6 +228,139 @@ public sealed class WorkflowHostedServiceTests
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
{
public Task ForceReconnectAsync(CancellationToken cancellationToken) => Task.CompletedTask;
@@ -394,6 +527,42 @@ public sealed class WorkflowHostedServiceTests
=> 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>
{
public void LogError(string message, bool showInUi = false, params object?[] args) { }