Files
Axi_Omron/docs/superpowers/plans/2026-04-19-csv-record-persistence.md
yunxiao.zhu ce356bffff ✨ feat(*): 重构PCB放行流程并接入双扫码校验
- 将流程改为 OMRON FINS + TCP 双扫码 + SFTP 文件校验
- 新增文件校验与扫码结果汇总实现,补充对应单测
- 同步更新流程设计文档、配置和系统设置页面文案
2026-05-17 14:08:05 +08:00

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