✨ feat(*): 重构PCB放行流程并接入双扫码校验

- 将流程改为 OMRON FINS + TCP 双扫码 + SFTP 文件校验
- 新增文件校验与扫码结果汇总实现,补充对应单测
- 同步更新流程设计文档、配置和系统设置页面文案
This commit is contained in:
2026-05-17 14:08:05 +08:00
parent 9a2211ccaa
commit ce356bffff
37 changed files with 4443 additions and 2436 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,132 @@
# PCB 目检放行软件最终工作流程图
## 1. 通信结构
```text
PLC <--OMRON FINS--> C# 上位机 <--TCP Socket--> 扫码枪1
<--TCP Socket--> 扫码枪2
<--SFTP----------> SFTP 服务器
<--HTTP----------> 安灯系统
```
## 2. 主流程图
```mermaid
flowchart TD
A[PLC: D1001 PcbArrived=1] --> B[上位机通过 FINS 读取到位信号]
B --> C{当前是否空闲且未故障}
C -->|否| C1[忽略本次触发并继续监视]
C -->|是| D[写入 D1051 PcBusy=1]
D --> E[写入 D1100 ResultCode=1 处理中]
E --> F[触发扫码枪1: TCP 发送 T]
E --> G[触发扫码枪2: TCP 发送 T]
F --> H[接收扫码枪1结果]
G --> I[接收扫码枪2结果]
H --> J[汇总双扫码结果]
I --> J
J --> K{是否至少一路扫码成功}
K -->|否| L[调用安灯: 两把扫码枪均失败]
L --> M[记录报警结果]
M --> N[写入 D1100 ResultCode=20 NG放行]
K -->|是| O{两路条码是否都成功且不一致}
O -->|是| P[优先使用扫码枪1条码并记录不一致日志]
O -->|否| Q[使用成功条码]
P --> R[SFTP 查找最终条码对应文件]
Q --> R
R --> S{SFTP 校验结果}
S -->|文件存在| T[写入 D1100 ResultCode=10 正常放行]
S -->|文件未找到且未到重试上限| U[等待配置间隔后重试]
U --> R
S -->|文件未找到达到上限| V[写入 D1100 ResultCode=20 NG放行]
S -->|连接/认证/目录异常| W[写入 D1100 ResultCode=90 系统故障]
T --> X[写入 D1052 ReleasePermit=1]
V --> X
N --> X
X --> Y{是否启用 D1003 PlcAckRelease}
Y -->|启用| Z[等待 PLC 写入 D1003=1]
Y -->|不启用| AA[按放行脉冲时间保持]
Z --> AB[等待 PLC 复位]
AA --> AB
AB --> AC[PLC: D1002 PlcReset=1]
AC --> AD[上位机执行复位]
AD --> AE[写入 D1051=0]
AE --> AF[写入 D1052=0]
AF --> AG[写入 D1100=0 Idle]
AG --> AH[回到空闲监视]
W --> AI[进入故障锁存]
AI --> AJ[等待人工/PLC复位]
AJ --> AC
```
## 3. 双扫码判定
```mermaid
flowchart TD
A[扫码枪1结果 + 扫码枪2结果] --> B{扫码枪1成功?}
A --> C{扫码枪2成功?}
B -->|是| D{扫码枪2也成功?}
B -->|否| E{扫码枪2成功?}
D -->|否| F[使用扫码枪1条码]
D -->|是| G{两路条码一致?}
G -->|是| H[使用共同条码]
G -->|否| I[使用扫码枪1条码并记录不一致]
E -->|是| J[使用扫码枪2条码]
E -->|否| K[双扫码失败]
F --> L[进入 SFTP 校验]
H --> L
I --> L
J --> L
K --> M[安灯报警 + NG放行]
```
## 4. PLC 点位与结果码
### 4.1 PLC -> 上位机
| 地址 | 名称 | 含义 |
| --- | --- | --- |
| D1001 | PcbArrived | PCB 到位,请求处理 |
| D1002 | PlcReset | PLC 请求上位机清状态/复位 |
| D1003 | PlcAckRelease | PLC 已收到放行信号,可选 |
### 4.2 上位机 -> PLC
| 地址 | 名称 | 含义 |
| --- | --- | --- |
| D1051 | PcBusy | 上位机正在处理当前 PCB |
| D1052 | ReleasePermit | 放行脉冲信号 |
| D1100 | ResultCode | 本次最终结果代码 |
### 4.3 ResultCode
| 数值 | 含义 | 是否放行 |
| --- | --- | --- |
| 0 | Idle / 无结果 | 否 |
| 1 | 处理中 | 否 |
| 10 | 正常放行 | 是 |
| 20 | NG 放行 | 是 |
| 90 | 系统故障 | 否 |
## 5. 关键规则
- `D1001` 只作为新板触发信号;流程执行中重复到位不得重入。
- 双扫码采用“任一成功即可”。
- 两把扫码枪都成功但条码不一致时,优先使用扫码枪1条码,并记录扫码枪2条码。
- SFTP 文件校验位于扫码成功之后、PLC 放行之前。
- SFTP 文件未找到达到重试上限后,写入 `ResultCode=20` 并放行。
- SFTP 连接失败、认证失败、根目录不存在等系统异常写入 `ResultCode=90`,不自动放行。
- 两把扫码枪均失败时调用安灯报警;安灯接口失败不阻断 `ResultCode=20` 的 NG 放行。
- 放行完成后等待 PLC 复位信号 `D1002`,再清 `D1051`、`D1052`、`D1100`。

View File

@@ -0,0 +1,406 @@
# 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<BoardProcessRecord> records, BoardRecordStatistics statistics);
void UpdateRecordStatistics(BoardRecordStatistics statistics);
}
```
- [ ] **Step 4: Extend runtime snapshot and state store implementations**
```csharp
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**
```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<BoardRecordLoadResult> LoadCurrentDayAsync(CancellationToken cancellationToken);
Task<BoardRecordAppendResult> AppendAsync(BoardProcessRecord record, CancellationToken cancellationToken);
}
```
- [ ] **Step 4: Write minimal CSV repository implementation**
```csharp
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**
```csharp
[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**
```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
<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**
```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<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

View File

@@ -0,0 +1,378 @@
# Axi Omron PCB Check CSV 记录持久化设计
## 1. 设计目标
本设计用于为 `AxiOmron.PcbCheck` 增加“扫码任务与处理结果记录持久化”能力,并满足以下目标:
- 所有处理记录仅使用 CSV 持久化,不引入数据库。
- CSV 按天分文件,文件名固定为 `yyyy-MM-dd.csv`。
- 程序启动时自动从“当天 CSV”回读恢复首页记录与统计数据。
- 首页列表只显示最近 100 条记录。
- 统计区域在每次任务完成后实时更新,且统计口径基于“当天全部有效记录”。
- CSV 读写异常不能影响主流程,不允许因为持久化失败而阻断扫码、SFTP、PLC 放行等核心业务。
## 2. 当前状态
当前系统在 `WorkflowHostedService` 完成单板流程后,会创建 `BoardProcessRecord` 并通过 `IAppStateStore.RecordAdded` 推送到界面。
当前实现存在以下限制:
- 记录仅保存在内存中,程序重启后丢失。
- 首页统计值通过 `RecentBoards` 重新计算,而 `RecentBoards` 仅保留 UI 最近记录,统计口径不等于“当天总量”。
- 跨 0 点后没有自动切换到新一天数据视图。
- 记录读写与统计未形成独立边界,后续扩展导出、恢复和容错能力较弱。
## 3. 设计结论
采用“独立记录仓储服务 + CSV 作为唯一事实源 + UI 只展示最近 100 条”的方案。
核心结论如下:
- 新增记录仓储服务,负责 CSV 追加写入、当天 CSV 回读和当天统计维护。
- `WorkflowHostedService` 仍负责生成 `BoardProcessRecord`,但不再自行承担记录持久化逻辑。
- `MainWindowViewModel` 只负责展示最近 100 条和当前统计结果,不再以 UI 列表作为统计真值来源。
- CSV 是唯一持久化介质;内存集合只是当前界面投影。
- 任意 CSV 异常均只记日志、尽力恢复,不改变主流程状态机,不触发 `Faulted`。
## 4. 范围与非范围
### 4.1 本次范围
- 新增 CSV 记录仓储服务及其接口。
- 启动时回读当天 CSV。
- 首页最近处理记录列表限制为最近 100 条。
- 首页统计区按当天全量有效记录实时计算。
- 跨 0 点自动切换到新一天视图与统计。
- 为 CSV 读写、坏行容错和跨天行为补充自动化测试。
### 4.2 非本次范围
- 不新增数据库、LiteDB、SQLite 或远程存储。
- 不新增“查看历史任意日期记录”的完整追溯页面。
- 不新增 CSV 手工编辑、补录、删除和回写功能。
- 不改变现有主流程业务判定规则。
## 5. 总体架构
### 5.1 新增角色
新增接口与实现:
- `IBoardRecordRepository`
- `CsvBoardRecordRepository`
该服务职责明确且独立:
1. 追加写入一条处理记录到目标日期 CSV。
2. 启动时加载当天 CSV 的全部有效记录。
3. 基于当天全部有效记录提供统计结果。
4. 在检测到跨天时切换当前工作日并重新加载新一天数据。
### 5.2 与现有组件协作关系
- `WorkflowHostedService`
- 生成 `BoardProcessRecord`
- 调用仓储服务追加持久化
- 在持久化成功或失败后继续通知 UI 更新
- `IAppStateStore`
- 继续承担运行时 UI 事件分发
- 新增“初始化记录”和“更新统计”的承载职责
- 不再作为记录真值来源
- `MainWindowViewModel`
- 绑定最近 100 条记录
- 绑定今日统计
- 不再从 `RecentBoards` 推导当天真实总量
## 6. 数据模型设计
### 6.1 沿用的记录模型
沿用现有 `BoardProcessRecord` 作为单板结果对象,字段如下:
- `StartedAt`
- `CompletedAt`
- `Barcode`
- `ScanTryCount`
- `SftpTryCount`
- `ResultCode`
- `ResultDescription`
- `ReleaseSent`
- `AlarmRaised`
- `ExceptionSummary`
### 6.2 新增统计模型
新增轻量统计对象,例如 `BoardRecordStatistics`,用于承载当前显示日的聚合结果:
- `StatisticDate`
- `TotalCount`
- `OkCount`
- `NgCount`
- `LastUpdatedAt`
统计定义如下:
- `ResultCode == (ushort)WorkflowResultCode.Passed` 计为 OK。
- `ResultCode != Passed` 且 `ResultCode != Processing` 且 `ResultCode != None` 计为 NG。
- 坏行、空行、解析失败行不参与统计。
## 7. CSV 设计
### 7.1 存储目录
CSV 文件统一存放于应用运行目录下:
`Data\Records\`
例如:
`Data\Records\2026-04-19.csv`
### 7.2 文件命名规则
- 每天一个文件。
- 文件名固定为 `yyyy-MM-dd.csv`。
- 日期以本地时间为准。
### 7.3 行归属规则
每条记录归属哪个文件,由 `CompletedAt` 的本地日期决定:
- `CompletedAt = 2026-04-19 23:59:59` 写入 `2026-04-19.csv`
- `CompletedAt = 2026-04-20 00:00:01` 写入 `2026-04-20.csv`
若一块板在前一天启动、后一天完成,则归属“完成当天”。
### 7.4 编码与表头
- 文件编码使用 `UTF-8 with BOM`。
- 文件首行写入固定表头。
固定列顺序如下:
`StartedAt,CompletedAt,Barcode,ScanTryCount,SftpTryCount,ResultCode,ResultDescription,ReleaseSent,AlarmRaised,ExceptionSummary`
### 7.5 字段格式
- 时间字段格式:`yyyy-MM-dd HH:mm:ss.fff`
- 布尔字段输出为 `True` / `False`
- 数值字段使用十进制字符串
- 文本字段按标准 CSV 规则转义:
- 包含逗号、双引号、换行时加双引号包裹
- 内部双引号转义为 `""`
## 8. 启动恢复流程
应用启动后,在主窗口显示前完成以下步骤:
1. 计算当前本地日期。
2. 定位当天 CSV 文件。
3. 若文件不存在,则视为当天暂无记录。
4. 若文件存在,则读取全部数据行并逐行解析。
5. 跳过坏行,保留有效记录。
6. 按 `CompletedAt` 升序恢复有效记录,再取最后 100 条,并在首页按完成时间倒序展示。
7. 基于当天全部有效记录计算统计结果。
8. 将“最近 100 条 + 当天统计”写入 `IAppStateStore`,供首页绑定。
若启动恢复失败:
- 记录错误日志。
- 首页记录区显示空列表。
- 统计区显示 0。
- 主流程继续启动,不阻断应用运行。
## 9. 运行时写入与实时更新流程
每次单板流程完成后执行以下步骤:
1. `WorkflowHostedService` 构造 `BoardProcessRecord`。
2. 调用 `IBoardRecordRepository.AppendAsync(record, cancellationToken)`。
3. 仓储服务将记录追加到目标日期 CSV。
4. 若追加成功:
- 更新仓储内部当天统计缓存。
- 更新仓储内部最近记录缓存。
5. 若追加成功,将最新记录与统计通过 `IAppStateStore` 推送给 UI。
6. 若追加失败,则保留上一份“已成功持久化”的历史表与统计,不把失败记录混入历史区和统计区,但当前流程结果仍通过现有运行状态区正常显示。
运行时界面行为约束:
- 首页表格永远只保留最近 100 条。
- 首页统计区始终显示当前本地日期对应的全量统计结果。
- 每次成功持久化的任务完成后统计立即刷新,不依赖手动点击“刷新”。
## 10. 跨 0 点处理流程
### 10.1 切天规则
系统以本地日期为准,在程序不重启的情况下,跨 0 点后自动切换到新一天视图。
### 10.2 切天行为
当检测到当前本地日期已变化时,仓储服务执行:
1. 更新当前工作日。
2. 加载新一天的 `yyyy-MM-dd.csv`。
3. 若新一天文件不存在,则初始化为空列表和 0 统计。
4. 将新一天数据重新推送到 `IAppStateStore`。
### 10.3 检查时机
至少在以下时机检查是否跨天:
- 应用启动时
- 每次追加记录前
- 每次追加记录后
- 应用内增加一个固定周期的轻量日切检查定时器,建议每 30 秒检查一次,避免长时间空闲时界面不切天
### 10.4 跨天中的在制任务
若一块板跨天完成:
- 列表与统计归属其 `CompletedAt` 当天。
- 例如 `23:59:50` 开始、`00:00:05` 完成,则写入次日 CSV,并计入次日统计。
## 11. 健壮性与故障隔离
### 11.1 总原则
CSV 是记录与追溯系统,不是生产流程控制系统。
因此:
- CSV 读写失败不能阻断主流程。
- CSV 异常不能触发流程 `Faulted`。
- CSV 异常只能以日志和界面状态提示形式暴露。
### 11.2 启动读取容错
- 文件不存在:按空数据处理。
- 文件为空:按空数据处理。
- 表头不匹配:记录 error,放弃恢复该文件并返回空结果,不做复杂兼容解析。
- 某一行解析失败:跳过该行,记录 warning,继续处理剩余行。
- 文件占用、权限不足、目录不存在:记录 error,返回空结果。
### 11.3 运行写入容错
- 目录不存在时自动创建目录。
- 单次追加失败:
- 记录 error
- 不影响本次单板主流程完成
- 不进入 `Faulted`
- UI 可更新一条轻量状态提示,例如“今日记录写入失败,请检查磁盘或权限”
- 历史记录表与统计区维持在最近一次成功持久化后的状态,避免界面展示与 CSV 真值不一致
### 11.4 坏行处理策略
坏行定义包括但不限于:
- 列数不匹配
- 时间无法解析
- 数值字段无法解析
- 布尔字段无法解析
处理方式统一为:
- 跳过坏行
- 写 warning 日志
- 继续处理后续行
- 不影响其余记录恢复
## 12. 并发与线程安全
记录仓储服务需保证多线程安全。
设计约束如下:
- 对 CSV 文件读写使用独立的串行锁,例如 `SemaphoreSlim`。
- 同一时刻仅允许一个追加写入执行,避免并发写入造成文件交错。
- 启动恢复与运行时追加不可并发破坏内部缓存。
- 对当天缓存的最近记录和统计更新应保持原子性,避免 UI 看到一半旧数据、一半新数据。
## 13. UI 与状态存储改造
### 13.1 列表展示
首页“处理记录”区只展示最近 100 条。
展示内容保持现有交互风格,不新增复杂筛选器。本次可沿用现有列:
- 完成时间
- 条码
- 结果
### 13.2 统计展示
首页统计区每次任务完成后都自动更新,展示:
- 今日处理数
- OK 数
- NG 数
建议增加“当前统计日期”展示,例如:
`当前统计日期:2026-04-19`
这样在跨 0 点后,操作员能明确看到界面已切换到新一天。
### 13.3 AppStateStore 调整
`IAppStateStore` 需要具备以下能力之一:
- 支持初始化一批历史记录和统计对象
- 或支持仓储服务在启动恢复后逐条推送记录并单独更新统计
推荐增加明确方法,而不是让 `MainWindowViewModel` 自行去猜测“当前是恢复模式还是实时模式”。
## 14. 测试设计
### 14.1 仓储服务测试
至少覆盖以下场景:
1. 首次写入时自动创建目录和文件,并写入表头。
2. 同一天多条记录追加后,CSV 内容顺序正确。
3. 启动回读时能正确恢复当天记录与统计。
4. 坏行存在时,仅跳过坏行,不影响其他行恢复。
5. 跨 0 点后切换到新一天文件,列表与统计归零或切换为新一天数据。
6. 跨天完成记录写入完成日文件,而不是开始日文件。
### 14.2 流程服务测试
至少覆盖以下场景:
1. 流程完成后会调用仓储服务进行持久化。
2. 仓储写入失败时,主流程仍能完成且不进入故障态。
3. 仓储写入成功后,`IAppStateStore` 会收到最新记录和统计。
### 14.3 ViewModel 测试
至少覆盖以下场景:
1. 启动恢复后,首页只显示最近 100 条。
2. 当天统计值来自全量记录,而不是仅来自最近 100 条。
3. 跨天刷新后,列表和统计能切到新一天。
## 15. 实施顺序建议
建议按以下顺序实施:
1. 新增 CSV 仓储接口、实现与统计模型。
2. 为仓储补齐 CSV 读写和坏行容错测试。
3. 将 `WorkflowHostedService` 接入仓储服务。
4. 扩展 `IAppStateStore` 支持初始化历史记录与统计。
5. 调整 `MainWindowViewModel`,改为展示仓储恢复结果和实时统计。
6. 补充跨 0 点与异常隔离测试。
7. 运行构建与测试验证。
## 16. 最终结论
本次改造采用“CSV 仓储服务下沉”的方式实现记录持久化,能够在不引入数据库的前提下满足以下诉求:
- 所有处理记录按天持久化到 CSV。
- 程序重启后自动从当天 CSV 恢复首页数据。
- 首页仅显示最近 100 条记录。
- 统计区按当天全量有效记录实时计算。
- 跨 0 点自动切换到新一天。
- CSV 异常与主流程完全隔离,不影响扫码、SFTP、PLC 放行等核心业务。
该方案边界清晰、可测试、可维护,并为后续增加追溯页面、日期切换和导出能力保留了扩展空间。

149
docs/正式的工作流程 Normal file
View File

@@ -0,0 +1,149 @@
# OMRON 目检放行软件通信流程(FINS + TCP扫码枪)
## 1. 系统通信结构
```text
PLC <--FINS--> C# 上位机 <--TCP--> 扫码枪1
<--TCP--> 扫码枪2
```
* PLC 与上位机:
* 通信协议:OMRON FINS
* 上位机读取 PLC 点位
* 上位机写入 PLC 点位
* 上位机与扫码枪:
* 通信协议:TCP Socket
* 发送触发命令:`T`
* 接收扫码结果字符串
---
# 2. PLC 通信点位定义
## 2.1 PLC -> 上位机(上位机读取)
| 地址 | 名称 | 类型 | 说明 |
| ----- | ------------- | ---- | --------------- |
| D1001 | PcbArrived | Bool | PCB已到位,请求上位机处理 |
| D1002 | PlcReset | Bool | PLC请求上位机清状态/复位 |
| D1003 | PlcAckRelease | Bool | PLC已接收到放行信号(可选) |
预留:
| 地址范围 | 说明 |
| ------------- | ---------------- |
| D1004 ~ D1010 | PLC -> 上位机扩展信号预留 |
---
## 2.2 上位机 -> PLC(上位机写入)
| 地址 | 名称 | 类型 | 说明 |
| ----- | ------------- | ---- | ------------ |
| D1051 | PcBusy | Bool | 上位机正在处理当前PCB |
| D1052 | ReleasePermit | Bool | 放行脉冲信号 |
---
## 2.3 结果寄存器(上位机写入)
| 地址 | 名称 | 类型 | 说明 |
| ----- | ---------- | --- | -------- |
| D1100 | ResultCode | Int | 本次最终结果代码 |
### ResultCode 定义
| 数值 | 含义 |
| -- | ---- |
| 0 | Idle |
| 1 | 处理中 |
| 10 | 正常放行 |
| 20 | NG放行 |
| 90 | 系统故障 |
---
# 3. TCP 扫码枪通信
## 3.1 扫码枪1
### 发送
```text
T
```
### 接收
```text
二维码/条码字符串
```
---
## 3.2 扫码枪2
### 发送
```text
T
```
### 接收
```text
二维码/条码字符串
```
---
# 4. 工序流程图
```mermaid
flowchart TD
A[PLC写入 D1001=PcbArrived=1]
--> B[C#上位机通过FINS读取到位信号]
B --> C[上位机写入 D1051=PcBusy=1]
B --> D[上位机写入 D1100=1 处理中]
D --> E[TCP发送 T 到扫码枪1]
D --> F[TCP发送 T 到扫码枪2]
E --> G[扫码枪1返回扫码结果]
F --> H[扫码枪2返回扫码结果]
G --> I[上位机汇总扫码结果]
H --> I
I --> J{扫码结果判定}
J -->|成功| K[写入 D1100=10 正常放行]
J -->|失败| L[写入 D1100=20 NG放行]
J -->|异常| M[写入 D1100=90 系统故障]
K --> N[写入 D1052=ReleasePermit=1]
L --> N
N --> O[PLC收到放行信号]
O --> P{是否启用确认信号}
P -->|启用| Q[PLC写入 D1003=PlcAckRelease=1]
P -->|不启用| R[进入复位流程]
Q --> R
R --> S[PLC写入 D1002=PlcReset=1]
S --> T[上位机执行复位]
T --> U[写入 D1051=0]
U --> V[写入 D1052=0]
V --> W[写入 D1100=0 Idle]
```