- 将流程改为 OMRON FINS + TCP 双扫码 + SFTP 文件校验 - 新增文件校验与扫码结果汇总实现,补充对应单测 - 同步更新流程设计文档、配置和系统设置页面文案
15 KiB
CSV Record Persistence Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add CSV-only daily record persistence, startup recovery from today's CSV, current-day statistics, and a UI list limited to the most recent 100 records without letting CSV failures break the production workflow.
Architecture: Introduce a dedicated CSV repository as the single source of truth for board history. Keep WorkflowHostedService responsible for producing BoardProcessRecord, let the repository own persistence and daily statistics, and let IAppStateStore/MainWindowViewModel project repository-backed data to the dashboard.
Tech Stack: .NET 8, WPF, xUnit, existing BackgroundService/DI patterns, CSV file I/O via System.IO
Task 1: Add repository-facing models and state store hooks
Files:
-
Modify:
src/AxiOmron.PcbCheck/Models/WorkflowModels.cs -
Modify:
src/AxiOmron.PcbCheck/Models/RuntimeSnapshot.cs -
Modify:
src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs -
Modify:
src/AxiOmron.PcbCheck/Services/Implementations/AppStateStore.cs -
Modify:
src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppStateStore.cs -
Test:
tests/AxiOmron.PcbCheck.Tests/MainWindowViewModelTests.cs -
Step 1: Write the failing ViewModel/state test
[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);
}
- Step 2: Run test to verify it fails
Run: dotnet test tests/AxiOmron.PcbCheck.Tests/AxiOmron.PcbCheck.Tests.csproj --filter MainWindowViewModelTests.Constructor_ShouldUseSnapshotStatistics_InsteadOfRecentListCount
Expected: FAIL because RuntimeSnapshot/MainWindowViewModel do not yet expose repository-backed record statistics.
- Step 3: Add minimal shared models and interface hooks
public sealed class BoardRecordStatistics
{
public DateOnly StatisticDate { get; set; }
public int TotalCount { get; set; }
public int OkCount { get; set; }
public int NgCount { get; set; }
public DateTimeOffset LastUpdatedAt { get; set; } = DateTimeOffset.Now;
}
public interface IAppStateStore
{
void ReplaceRecords(IReadOnlyList<BoardProcessRecord> records, BoardRecordStatistics statistics);
void UpdateRecordStatistics(BoardRecordStatistics statistics);
}
- Step 4: Extend runtime snapshot and state store implementations
public sealed class RuntimeSnapshot
{
public IList<BoardProcessRecord> BoardRecords { get; } = new List<BoardProcessRecord>();
public DateOnly? RecordStatisticDate { get; set; }
public int TodayProcessCount { get; set; }
public int TodayOkCount { get; set; }
public int TodayNgCount { get; set; }
}
public void ReplaceRecords(IReadOnlyList<BoardProcessRecord> records, BoardRecordStatistics statistics)
{
UpdateSnapshot(snapshot =>
{
snapshot.BoardRecords.Clear();
foreach (BoardProcessRecord record in records)
{
snapshot.BoardRecords.Add(record);
}
ApplyStatistics(snapshot, statistics);
});
}
- Step 5: Run the targeted test to verify it passes
Run: dotnet test tests/AxiOmron.PcbCheck.Tests/AxiOmron.PcbCheck.Tests.csproj --filter MainWindowViewModelTests.Constructor_ShouldUseSnapshotStatistics_InsteadOfRecentListCount
Expected: PASS
Task 2: Build the CSV repository with daily files, recovery, and bad-line tolerance
Files:
-
Modify:
src/AxiOmron.PcbCheck/Options/AppConfig.cs -
Modify:
src/AxiOmron.PcbCheck/appConfig.json -
Modify:
src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs -
Create:
src/AxiOmron.PcbCheck/Services/Implementations/CsvBoardRecordRepository.cs -
Test:
tests/AxiOmron.PcbCheck.Tests/CsvBoardRecordRepositoryTests.cs -
Step 1: Write the failing repository tests
[Fact]
public async Task AppendAsync_ShouldCreateDailyCsv_AndLoadTodayAsync_ShouldRecoverStatistics()
{
var repository = CreateRepository("2026-04-19");
await repository.AppendAsync(CreateRecord("PCB-001", new DateTimeOffset(2026, 4, 19, 8, 0, 0, TimeSpan.Zero), (ushort)WorkflowResultCode.Passed), CancellationToken.None);
await repository.AppendAsync(CreateRecord("PCB-002", new DateTimeOffset(2026, 4, 19, 8, 5, 0, TimeSpan.Zero), (ushort)WorkflowResultCode.NgReleased), CancellationToken.None);
BoardRecordLoadResult result = await repository.LoadCurrentDayAsync(CancellationToken.None);
Assert.Equal(2, result.Records.Count);
Assert.Equal(2, result.Statistics.TotalCount);
Assert.Equal(1, result.Statistics.OkCount);
Assert.Equal(1, result.Statistics.NgCount);
}
[Fact]
public async Task LoadCurrentDayAsync_ShouldSkipBrokenRows_AndContinue()
{
File.WriteAllText(_csvPath, "StartedAt,CompletedAt,Barcode,ScanTryCount,SftpTryCount,ResultCode,ResultDescription,ReleaseSent,AlarmRaised,ExceptionSummary\r\nbroken,row\r\n2026-04-19 08:00:00.000,2026-04-19 08:01:00.000,PCB-001,1,1,10,OK,True,False,\r\n");
BoardRecordLoadResult result = await repository.LoadCurrentDayAsync(CancellationToken.None);
Assert.Single(result.Records);
Assert.Equal("PCB-001", result.Records[0].Barcode);
}
- Step 2: Run tests to verify they fail
Run: dotnet test tests/AxiOmron.PcbCheck.Tests/AxiOmron.PcbCheck.Tests.csproj --filter CsvBoardRecordRepositoryTests
Expected: FAIL because the repository and related types do not exist yet.
- Step 3: Add configuration and repository contract
public sealed class RecordPersistenceOptions
{
public string DirectoryPath { get; set; } = "Data\\Records";
public int MaxRecentRecordCount { get; set; } = 100;
public int DayChangeCheckIntervalSeconds { get; set; } = 30;
}
public interface IBoardRecordRepository
{
Task<BoardRecordLoadResult> LoadCurrentDayAsync(CancellationToken cancellationToken);
Task<BoardRecordAppendResult> AppendAsync(BoardProcessRecord record, CancellationToken cancellationToken);
}
- Step 4: Write minimal CSV repository implementation
public sealed class CsvBoardRecordRepository : IBoardRecordRepository
{
public async Task<BoardRecordAppendResult> AppendAsync(BoardProcessRecord record, CancellationToken cancellationToken)
{
string path = GetDailyFilePath(DateOnly.FromDateTime(record.CompletedAt.LocalDateTime.Date));
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
bool writeHeader = !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 (writeHeader)
{
await writer.WriteLineAsync(HeaderLine);
}
await writer.WriteLineAsync(ToCsvLine(record));
return await LoadCurrentDayAsync(cancellationToken);
}
}
- Step 5: Run repository tests to verify they pass
Run: dotnet test tests/AxiOmron.PcbCheck.Tests/AxiOmron.PcbCheck.Tests.csproj --filter CsvBoardRecordRepositoryTests
Expected: PASS
Task 3: Integrate repository with startup recovery and workflow completion
Files:
-
Modify:
src/AxiOmron.PcbCheck/App.xaml.cs -
Modify:
src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs -
Test:
tests/AxiOmron.PcbCheck.Tests/WorkflowHostedServiceTests.cs -
Step 1: Write the failing workflow integration tests
[Fact]
public async Task StartAsync_ShouldRestoreTodayRecords_FromRepository()
{
var stateStore = new AppStateStore();
var repository = new FakeBoardRecordRepository
{
LoadResult = new BoardRecordLoadResult
{
Records = new List<BoardProcessRecord> { CreateRecord("PCB-RESTORED", DateTimeOffset.Now, (ushort)WorkflowResultCode.Passed) },
Statistics = new BoardRecordStatistics { StatisticDate = DateOnly.FromDateTime(DateTime.Today), TotalCount = 1, OkCount = 1, NgCount = 0 }
}
};
var service = CreateWorkflowService(stateStore, repository);
await service.StartAsync(CancellationToken.None);
await service.StopAsync(CancellationToken.None);
RuntimeSnapshot snapshot = stateStore.GetSnapshot();
Assert.Single(snapshot.BoardRecords);
Assert.Equal(1, snapshot.TodayProcessCount);
}
[Fact]
public async Task RunWorkflowOnce_ShouldNotFault_WhenRepositoryAppendFails()
{
var repository = new FakeBoardRecordRepository { AppendException = new IOException("disk full") };
var service = CreateSuccessfulWorkflowService(repository, stateStore);
await service.StartAsync(CancellationToken.None);
await Task.Delay(200);
await service.StopAsync(CancellationToken.None);
Assert.NotEqual(WorkflowState.Faulted, stateStore.GetSnapshot().WorkflowState);
}
- Step 2: Run tests to verify they fail
Run: dotnet test tests/AxiOmron.PcbCheck.Tests/AxiOmron.PcbCheck.Tests.csproj --filter WorkflowHostedServiceTests
Expected: FAIL because WorkflowHostedService does not use the repository yet.
- Step 3: Wire startup recovery into the hosted service
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await RestoreRecordsAsync(stoppingToken).ConfigureAwait(false);
await ProbePlcOnStartupAsync(stoppingToken).ConfigureAwait(false);
await ProbeSftpOnStartupAsync(stoppingToken).ConfigureAwait(false);
PublishRuntimeState(WorkflowState.Idle, "等待 PCB 到位");
// existing polling loop
}
- Step 4: Persist records on completion with fault isolation
try
{
BoardRecordAppendResult appendResult = await _boardRecordRepository.AppendAsync(record, cancellationToken).ConfigureAwait(false);
_stateStore.ReplaceRecords(appendResult.RecentRecords, appendResult.Statistics);
}
catch (Exception ex)
{
_appLogger.LogError(ex, "处理记录写入 CSV 失败");
UpdateSnapshot(snapshot => snapshot.ResultDescription = "处理完成,但记录持久化失败");
}
- Step 5: Run workflow tests to verify they pass
Run: dotnet test tests/AxiOmron.PcbCheck.Tests/AxiOmron.PcbCheck.Tests.csproj --filter WorkflowHostedServiceTests
Expected: PASS
Task 4: Update the dashboard ViewModel and UI bindings for repository-backed history
Files:
-
Modify:
src/AxiOmron.PcbCheck/ViewModels/MainWindowViewModel.cs -
Modify:
src/AxiOmron.PcbCheck/Views/Pages/DashboardPage.xaml -
Test:
tests/AxiOmron.PcbCheck.Tests/MainWindowViewModelTests.cs -
Step 1: Write the failing ViewModel list-limit and date-display tests
[Fact]
public void Constructor_ShouldLoadRecentRecords_FromSnapshot_AndExposeStatisticDate()
{
var stateStore = new AppStateStore();
stateStore.ReplaceRecords(CreateRecords(120), new BoardRecordStatistics
{
StatisticDate = new DateOnly(2026, 4, 19),
TotalCount = 120,
OkCount = 100,
NgCount = 20
});
var viewModel = CreateViewModel(stateStore);
Assert.Equal(100, viewModel.RecentProcessRecords.Count);
Assert.Equal("2026-04-19", viewModel.RecordStatisticDateText);
}
- Step 2: Run tests to verify they fail
Run: dotnet test tests/AxiOmron.PcbCheck.Tests/AxiOmron.PcbCheck.Tests.csproj --filter MainWindowViewModelTests
Expected: FAIL because the ViewModel does not load records/statistic date from the snapshot yet.
- Step 3: Update the ViewModel to hydrate from snapshot-backed records and statistics
private void ApplySnapshot(RuntimeSnapshot snapshot)
{
RecordStatisticDateText = snapshot.RecordStatisticDate?.ToString("yyyy-MM-dd") ?? "-";
TodayProcessCount = snapshot.TodayProcessCount;
TodayOkCount = snapshot.TodayOkCount;
TodayNgCount = snapshot.TodayNgCount;
RecentBoards.Clear();
foreach (BoardProcessRecord record in snapshot.BoardRecords.OrderByDescending(item => item.CompletedAt).Take(_workflowOptions.MaxBoardRecords))
{
RecentBoards.Add(record);
}
}
- Step 4: Surface the current statistic date in the dashboard
<TextBlock Style="{StaticResource CardFooterTextStyle}">
<Run Text="统计日期:" />
<Run Text="{Binding RecordStatisticDateText}" />
</TextBlock>
- Step 5: Run ViewModel tests to verify they pass
Run: dotnet test tests/AxiOmron.PcbCheck.Tests/AxiOmron.PcbCheck.Tests.csproj --filter MainWindowViewModelTests
Expected: PASS
Task 5: Add cross-day refresh and run end-to-end verification
Files:
-
Modify:
src/AxiOmron.PcbCheck/Services/Implementations/CsvBoardRecordRepository.cs -
Modify:
src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs -
Modify:
src/AxiOmron.PcbCheck/App.xaml.cs -
Test:
tests/AxiOmron.PcbCheck.Tests/CsvBoardRecordRepositoryTests.cs -
Test:
tests/AxiOmron.PcbCheck.Tests/WorkflowHostedServiceTests.cs -
Step 1: Write the failing cross-day tests
[Fact]
public async Task LoadCurrentDayAsync_ShouldSwitchToNewDay_WhenClockMovesPastMidnight()
{
var clock = new FakeClock(new DateTimeOffset(2026, 4, 19, 23, 59, 50, TimeSpan.Zero));
var repository = CreateRepository(clock);
await repository.AppendAsync(CreateRecord("PCB-OLD", clock.Now.AddSeconds(5), (ushort)WorkflowResultCode.Passed), CancellationToken.None);
clock.Set(new DateTimeOffset(2026, 4, 20, 0, 0, 10, TimeSpan.Zero));
BoardRecordLoadResult result = await repository.LoadCurrentDayAsync(CancellationToken.None);
Assert.Empty(result.Records);
Assert.Equal(new DateOnly(2026, 4, 20), result.Statistics.StatisticDate);
}
- Step 2: Run tests to verify they fail
Run: dotnet test tests/AxiOmron.PcbCheck.Tests/AxiOmron.PcbCheck.Tests.csproj --filter FullyQualifiedName~CsvBoardRecordRepositoryTests|FullyQualifiedName~WorkflowHostedServiceTests
Expected: FAIL because day switching is not implemented yet.
- Step 3: Implement day-change checks and repository refresh
private async Task RestoreRecordsAsync(CancellationToken cancellationToken)
{
BoardRecordLoadResult loadResult = await _boardRecordRepository.LoadCurrentDayAsync(cancellationToken).ConfigureAwait(false);
_stateStore.ReplaceRecords(loadResult.RecentRecords, loadResult.Statistics);
}
- Step 4: Register the repository in DI and keep the current workspace cleanly wired
services.AddSingleton<IBoardRecordRepository, CsvBoardRecordRepository>();
- Step 5: Run the full test suite and build
Run: dotnet test tests/AxiOmron.PcbCheck.Tests/AxiOmron.PcbCheck.Tests.csproj
Expected: PASS
Run: dotnet build src/AxiOmron.PcbCheck/AxiOmron.PcbCheck.csproj -c Debug
Expected: Build succeeded, 0 errors