# 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** ```csharp [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** ```csharp 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 records, BoardRecordStatistics statistics); void UpdateRecordStatistics(BoardRecordStatistics statistics); } ``` - [ ] **Step 4: Extend runtime snapshot and state store implementations** ```csharp public sealed class RuntimeSnapshot { public IList BoardRecords { get; } = new List(); public DateOnly? RecordStatisticDate { get; set; } public int TodayProcessCount { get; set; } public int TodayOkCount { get; set; } public int TodayNgCount { get; set; } } public void ReplaceRecords(IReadOnlyList 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** ```csharp [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** ```csharp 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 LoadCurrentDayAsync(CancellationToken cancellationToken); Task AppendAsync(BoardProcessRecord record, CancellationToken cancellationToken); } ``` - [ ] **Step 4: Write minimal CSV repository implementation** ```csharp public sealed class CsvBoardRecordRepository : IBoardRecordRepository { public async Task 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** ```csharp [Fact] public async Task StartAsync_ShouldRestoreTodayRecords_FromRepository() { var stateStore = new AppStateStore(); var repository = new FakeBoardRecordRepository { LoadResult = new BoardRecordLoadResult { Records = new List { 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** ```csharp 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** ```csharp 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** ```csharp [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** ```csharp 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** ```xml ``` - [ ] **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** ```csharp [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** ```csharp 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** ```csharp services.AddSingleton(); ``` - [ ] **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