✨ feat(*): 重构PCB放行流程并接入双扫码校验
- 将流程改为 OMRON FINS + TCP 双扫码 + SFTP 文件校验 - 新增文件校验与扫码结果汇总实现,补充对应单测 - 同步更新流程设计文档、配置和系统设置页面文案
This commit is contained in:
@@ -25,7 +25,8 @@
|
|||||||
"Bash(mkdir -p /tmp/pcb)",
|
"Bash(mkdir -p /tmp/pcb)",
|
||||||
"Read(//tmp/**)",
|
"Read(//tmp/**)",
|
||||||
"Bash(taskkill /F /IM \"AxiOmron.PcbCheck.exe\")",
|
"Bash(taskkill /F /IM \"AxiOmron.PcbCheck.exe\")",
|
||||||
"Bash(powershell -Command \"Stop-Process -Id 23292 -Force -ErrorAction SilentlyContinue\")"
|
"Bash(powershell -Command \"Stop-Process -Id 23292 -Force -ErrorAction SilentlyContinue\")",
|
||||||
|
"Bash(dotnet publish *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
132
docs/2026-05-17-pcb-check-final-workflow.md
Normal file
132
docs/2026-05-17-pcb-check-final-workflow.md
Normal 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`。
|
||||||
406
docs/superpowers/plans/2026-04-19-csv-record-persistence.md
Normal file
406
docs/superpowers/plans/2026-04-19-csv-record-persistence.md
Normal 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
|
||||||
@@ -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
149
docs/正式的工作流程
Normal 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]
|
||||||
|
|
||||||
|
```
|
||||||
@@ -119,9 +119,11 @@ public partial class App : Application
|
|||||||
services.AddSingleton(TimeProvider.System);
|
services.AddSingleton(TimeProvider.System);
|
||||||
services.AddHttpClient(nameof(AndonService));
|
services.AddHttpClient(nameof(AndonService));
|
||||||
|
|
||||||
services.AddSingleton<IPlcService, ModbusTcpPlcService>();
|
services.AddSingleton<IPlcService, OmronFinsPlcService>();
|
||||||
services.AddSingleton<IScannerService, SerialScannerService>();
|
services.AddSingleton<IScannerService, TcpDualScannerService>();
|
||||||
services.AddSingleton<ISftpLookupService, SftpLookupService>();
|
services.AddSingleton<IFileCheckProvider, SftpLookupService>();
|
||||||
|
services.AddSingleton<IFileCheckProvider, LocalFileCheckProvider>();
|
||||||
|
services.AddSingleton<IFileCheckService, FileCheckService>();
|
||||||
services.AddSingleton<IAndonService, AndonService>();
|
services.AddSingleton<IAndonService, AndonService>();
|
||||||
services.AddSingleton<IBoardRecordRepository, CsvBoardRecordRepository>();
|
services.AddSingleton<IBoardRecordRepository, CsvBoardRecordRepository>();
|
||||||
services.AddSingleton<WorkflowHostedService>();
|
services.AddSingleton<WorkflowHostedService>();
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
<PackageReference Include="NLog" Version="6.0.2" />
|
<PackageReference Include="NLog" Version="6.0.2" />
|
||||||
<PackageReference Include="NLog.Extensions.Logging" Version="6.0.2" />
|
<PackageReference Include="NLog.Extensions.Logging" Version="6.0.2" />
|
||||||
<PackageReference Include="SSH.NET" Version="2020.0.2" />
|
<PackageReference Include="SSH.NET" Version="2020.0.2" />
|
||||||
<PackageReference Include="System.IO.Ports" Version="9.0.0" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ public sealed class DesignTimeAppConfigService : IAppConfigService
|
|||||||
ArgumentNullException.ThrowIfNull(config);
|
ArgumentNullException.ThrowIfNull(config);
|
||||||
_config.Plc = config.Plc;
|
_config.Plc = config.Plc;
|
||||||
_config.Scanner = config.Scanner;
|
_config.Scanner = config.Scanner;
|
||||||
|
_config.FileCheck = config.FileCheck;
|
||||||
_config.Sftp = config.Sftp;
|
_config.Sftp = config.Sftp;
|
||||||
_config.Andon = config.Andon;
|
_config.Andon = config.Andon;
|
||||||
_config.Workflow = config.Workflow;
|
_config.Workflow = config.Workflow;
|
||||||
@@ -63,8 +64,9 @@ public sealed class DesignTimeAppConfigService : IAppConfigService
|
|||||||
Plc = new PlcOptions
|
Plc = new PlcOptions
|
||||||
{
|
{
|
||||||
Host = "192.168.10.25",
|
Host = "192.168.10.25",
|
||||||
Port = 502,
|
Port = 9600,
|
||||||
UnitId = 1,
|
UnitAddress = 0,
|
||||||
|
SourceNodeAddress = 25,
|
||||||
PollIntervalMs = 200,
|
PollIntervalMs = 200,
|
||||||
ConnectTimeoutMs = 3000,
|
ConnectTimeoutMs = 3000,
|
||||||
ReleasePulseMs = 450,
|
ReleasePulseMs = 450,
|
||||||
@@ -72,15 +74,34 @@ public sealed class DesignTimeAppConfigService : IAppConfigService
|
|||||||
},
|
},
|
||||||
Scanner = new ScannerOptions
|
Scanner = new ScannerOptions
|
||||||
{
|
{
|
||||||
PortName = "COM3",
|
Scanner1 = new TcpScannerEndpointOptions
|
||||||
BaudRate = 9600,
|
{
|
||||||
DataBits = 8,
|
Name = "扫码枪1",
|
||||||
Parity = "None",
|
Host = "192.168.10.31",
|
||||||
StopBits = "One",
|
Port = 9001
|
||||||
|
},
|
||||||
|
Scanner2 = new TcpScannerEndpointOptions
|
||||||
|
{
|
||||||
|
Name = "扫码枪2",
|
||||||
|
Host = "192.168.10.32",
|
||||||
|
Port = 9002
|
||||||
|
},
|
||||||
|
ConnectTimeoutMs = 3000,
|
||||||
ReadTimeoutMs = 2500,
|
ReadTimeoutMs = 2500,
|
||||||
TriggerCommand = "SCAN\\r",
|
TriggerCommand = "T",
|
||||||
ResponseTerminator = "\\r",
|
ResponseTerminator = "\\r",
|
||||||
MaxScanAttempts = 3
|
TriggerInParallel = true
|
||||||
|
},
|
||||||
|
FileCheck = new FileCheckOptions
|
||||||
|
{
|
||||||
|
Provider = FileCheckProvider.Sftp,
|
||||||
|
FileNamePattern = "${barcode}.txt",
|
||||||
|
RetryIntervalSeconds = 2,
|
||||||
|
MaxRetryCount = 3,
|
||||||
|
Local = new LocalFileCheckOptions
|
||||||
|
{
|
||||||
|
RootPath = @"D:\PcbCheck\Files"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
Sftp = new SftpOptions
|
Sftp = new SftpOptions
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ using AxiOmron.PcbCheck.Services.Interfaces;
|
|||||||
namespace AxiOmron.PcbCheck.DesignTime;
|
namespace AxiOmron.PcbCheck.DesignTime;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 为设计器提供 SFTP 测试结果的设计时服务。
|
/// 为设计器提供文件校验通道测试结果的设计时服务。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class DesignTimeSftpLookupService : ISftpLookupService
|
public sealed class DesignTimeSftpLookupService : IFileCheckService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 返回设计时文件校验结果。
|
/// 返回设计时文件校验结果。
|
||||||
@@ -15,9 +15,9 @@ public sealed class DesignTimeSftpLookupService : ISftpLookupService
|
|||||||
/// <param name="barcode">条码。</param>
|
/// <param name="barcode">条码。</param>
|
||||||
/// <param name="cancellationToken">取消令牌。</param>
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
/// <returns>固定设计时结果。</returns>
|
/// <returns>固定设计时结果。</returns>
|
||||||
public Task<SftpCheckOutcome> CheckFileAsync(string barcode, CancellationToken cancellationToken)
|
public Task<FileCheckOutcome> CheckFileAsync(string barcode, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return Task.FromResult(new SftpCheckOutcome
|
return Task.FromResult(new FileCheckOutcome
|
||||||
{
|
{
|
||||||
Exists = true,
|
Exists = true,
|
||||||
ConnectionSucceeded = true,
|
ConnectionSucceeded = true,
|
||||||
@@ -26,19 +26,19 @@ public sealed class DesignTimeSftpLookupService : ISftpLookupService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 返回设计时连接测试结果。
|
/// 返回设计时文件校验通道测试结果。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="options">SFTP 配置。</param>
|
/// <param name="config">根配置。</param>
|
||||||
/// <param name="cancellationToken">取消令牌。</param>
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
/// <returns>固定成功结果。</returns>
|
/// <returns>固定成功结果。</returns>
|
||||||
public Task<SftpConnectionTestOutcome> TestConnectionAsync(SftpOptions? options, CancellationToken cancellationToken)
|
public Task<FileCheckTestOutcome> TestConnectionAsync(AppConfig? config, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return Task.FromResult(new SftpConnectionTestOutcome
|
return Task.FromResult(new FileCheckTestOutcome
|
||||||
{
|
{
|
||||||
IsSuccess = true,
|
IsSuccess = true,
|
||||||
ConnectionSucceeded = true,
|
ConnectionSucceeded = true,
|
||||||
RootPathAccessible = true,
|
RootPathAccessible = true,
|
||||||
StatusMessage = "设计时 SFTP 连接成功。"
|
StatusMessage = "设计时文件校验通道可用。"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ public sealed class RuntimeSnapshot
|
|||||||
public string ScannerStatus { get; set; } = "未验证";
|
public string ScannerStatus { get; set; } = "未验证";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取或设置 SFTP 连接状态文本。
|
/// 获取或设置文件校验通道状态文本。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string SftpStatus { get; set; } = "未验证";
|
public string SftpStatus { get; set; } = "未验证";
|
||||||
|
|
||||||
|
|||||||
@@ -31,22 +31,22 @@ public enum WorkflowState
|
|||||||
ScanFailedReleased = 4,
|
ScanFailedReleased = 4,
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 正在检查 SFTP。
|
/// 正在检查文件。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
CheckingSftp = 5,
|
CheckingSftp = 5,
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 正在等待 SFTP 重试。
|
/// 正在等待文件校验重试。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
WaitingSftpRetry = 6,
|
WaitingSftpRetry = 6,
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// SFTP 校验通过。
|
/// 文件校验通过。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
SftpPassed = 7,
|
SftpPassed = 7,
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// SFTP 超时后放行。
|
/// 文件校验超时后放行。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
SftpTimeoutReleased = 8,
|
SftpTimeoutReleased = 8,
|
||||||
|
|
||||||
@@ -118,12 +118,12 @@ public enum AlarmCode : ushort
|
|||||||
ScanFailed = 1001,
|
ScanFailed = 1001,
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// SFTP 文件超时未找到。
|
/// 文件校验超时未找到。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
FileNotFound = 1002,
|
FileNotFound = 1002,
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// SFTP 连接异常。
|
/// 文件校验通道异常。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
SftpFault = 1003,
|
SftpFault = 1003,
|
||||||
|
|
||||||
@@ -309,9 +309,9 @@ public sealed class ScanOperationResult
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 表示一次 SFTP 文件校验结果。
|
/// 表示一次统一文件校验结果。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SftpCheckOutcome
|
public sealed class FileCheckOutcome
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取或设置目标文件是否存在。
|
/// 获取或设置目标文件是否存在。
|
||||||
@@ -345,9 +345,9 @@ public sealed class SftpCheckOutcome
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 表示一次 SFTP 连接测试结果。
|
/// 表示一次统一文件校验通道测试结果。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SftpConnectionTestOutcome
|
public sealed class FileCheckTestOutcome
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取或设置连接测试是否成功。
|
/// 获取或设置连接测试是否成功。
|
||||||
@@ -489,7 +489,7 @@ public sealed class BoardProcessRecord
|
|||||||
public int ScanTryCount { get; set; }
|
public int ScanTryCount { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取或设置 SFTP 查询次数。
|
/// 获取或设置文件校验查询次数;字段名保留 SftpTryCount 以兼容历史 CSV。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int SftpTryCount { get; set; }
|
public int SftpTryCount { get; set; }
|
||||||
|
|
||||||
@@ -538,7 +538,7 @@ internal static class WorkflowStateExtensions
|
|||||||
WorkflowState.Scanning => "正在扫码",
|
WorkflowState.Scanning => "正在扫码",
|
||||||
WorkflowState.ScanRetrying => "扫码失败,等待重试",
|
WorkflowState.ScanRetrying => "扫码失败,等待重试",
|
||||||
WorkflowState.ScanFailedReleased => "扫码失败放行",
|
WorkflowState.ScanFailedReleased => "扫码失败放行",
|
||||||
WorkflowState.CheckingSftp => "正在检查 SFTP 文件",
|
WorkflowState.CheckingSftp => "正在检查文件",
|
||||||
WorkflowState.WaitingSftpRetry => "文件未命中,等待重试",
|
WorkflowState.WaitingSftpRetry => "文件未命中,等待重试",
|
||||||
WorkflowState.SftpPassed => "文件已找到,准备放行",
|
WorkflowState.SftpPassed => "文件已找到,准备放行",
|
||||||
WorkflowState.SftpTimeoutReleased => "文件未找到超时放行",
|
WorkflowState.SftpTimeoutReleased => "文件未找到超时放行",
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ public sealed class AppConfig
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public ScannerOptions Scanner { get; set; } = new();
|
public ScannerOptions Scanner { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置统一文件校验配置。
|
||||||
|
/// </summary>
|
||||||
|
public FileCheckOptions FileCheck { get; set; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取或设置 SFTP 校验配置。
|
/// 获取或设置 SFTP 校验配置。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -41,6 +46,64 @@ public sealed class AppConfig
|
|||||||
public SecurityOptions Security { get; set; } = new();
|
public SecurityOptions Security { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 表示文件校验通道类型。
|
||||||
|
/// </summary>
|
||||||
|
public enum FileCheckProvider
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 使用 SFTP 目录作为文件校验来源。
|
||||||
|
/// </summary>
|
||||||
|
Sftp,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 使用本地磁盘目录作为文件校验来源。
|
||||||
|
/// </summary>
|
||||||
|
Local
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 表示统一文件校验配置。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FileCheckOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置当前启用的文件校验通道。
|
||||||
|
/// </summary>
|
||||||
|
public FileCheckProvider Provider { get; set; } = FileCheckProvider.Sftp;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置文件名匹配模板。
|
||||||
|
/// </summary>
|
||||||
|
public string FileNamePattern { get; set; } = "${barcode}.txt";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置首次未命中后的重试间隔,单位为秒。
|
||||||
|
/// </summary>
|
||||||
|
public int RetryIntervalSeconds { get; set; } = 2;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置首次未命中后的最大重试次数。
|
||||||
|
/// </summary>
|
||||||
|
public int MaxRetryCount { get; set; } = 3;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置本地磁盘文件校验配置。
|
||||||
|
/// </summary>
|
||||||
|
public LocalFileCheckOptions Local { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 表示本地磁盘文件校验配置。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LocalFileCheckOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置本地文件校验根目录。
|
||||||
|
/// </summary>
|
||||||
|
public string RootPath { get; set; } = "D:\\PcbCheck\\Files";
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 表示 PLC 通信参数与点位映射配置。
|
/// 表示 PLC 通信参数与点位映射配置。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -54,12 +117,17 @@ public sealed class PlcOptions
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取或设置 PLC 端口。
|
/// 获取或设置 PLC 端口。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int Port { get; set; } = 502;
|
public int Port { get; set; } = 9600;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取或设置 Modbus 从站号。
|
/// 获取或设置 FINS 目标单元地址。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public byte UnitId { get; set; } = 1;
|
public byte UnitAddress { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置上位机节点编号。
|
||||||
|
/// </summary>
|
||||||
|
public byte SourceNodeAddress { get; set; } = 25;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取或设置轮询周期,单位为毫秒。
|
/// 获取或设置轮询周期,单位为毫秒。
|
||||||
@@ -98,54 +166,75 @@ public sealed class PlcOptions
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 表示扫码枪串口参数配置。
|
/// 表示双 TCP 扫码枪参数配置。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ScannerOptions
|
public sealed class ScannerOptions
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取或设置串口号。
|
/// 获取或设置扫码枪1配置。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string PortName { get; set; } = "COM1";
|
public TcpScannerEndpointOptions Scanner1 { get; set; } = new()
|
||||||
|
{
|
||||||
|
Name = "扫码枪1",
|
||||||
|
Host = "127.0.0.1",
|
||||||
|
Port = 9001
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取或设置波特率。
|
/// 获取或设置扫码枪2配置。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int BaudRate { get; set; } = 9600;
|
public TcpScannerEndpointOptions Scanner2 { get; set; } = new()
|
||||||
|
{
|
||||||
|
Name = "扫码枪2",
|
||||||
|
Host = "127.0.0.1",
|
||||||
|
Port = 9002
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取或设置数据位。
|
/// 获取或设置 TCP 连接超时,单位为毫秒。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int DataBits { get; set; } = 8;
|
public int ConnectTimeoutMs { get; set; } = 3000;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取或设置校验位名称。
|
/// 获取或设置扫码枪触发命令,支持转义字符。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Parity { get; set; } = "None";
|
public string TriggerCommand { get; set; } = "T";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取或设置停止位名称。
|
/// 获取或设置返回报文结束符,支持转义字符;为空时读取到首批非空数据即返回。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string StopBits { get; set; } = "One";
|
public string ResponseTerminator { get; set; } = "\\r";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置是否并发触发两把扫码枪。
|
||||||
|
/// </summary>
|
||||||
|
public bool TriggerInParallel { get; set; } = true;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取或设置单次扫码超时,单位为毫秒。
|
/// 获取或设置单次扫码超时,单位为毫秒。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int ReadTimeoutMs { get; set; } = 3000;
|
public int ReadTimeoutMs { get; set; } = 3000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 表示单把 TCP 扫码枪连接参数。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TcpScannerEndpointOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置扫码枪名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取或设置触发命令,支持转义字符。
|
/// 获取或设置扫码枪 IP 或主机名。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string TriggerCommand { get; set; } = "SCAN\\r";
|
public string Host { get; set; } = "127.0.0.1";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取或设置返回报文结束符,支持转义字符。
|
/// 获取或设置扫码枪 TCP 端口。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string ResponseTerminator { get; set; } = "\\r";
|
public int Port { get; set; } = 9001;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取或设置最大扫码尝试次数。
|
|
||||||
/// </summary>
|
|
||||||
public int MaxScanAttempts { get; set; } = 3;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -331,33 +420,33 @@ public sealed class PlcInputAddressOptions
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取或设置 PCB 到位点位地址。
|
/// 获取或设置 PCB 到位点位地址。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int PcbArrived { get; set; } = 10001;
|
public int PcbArrived { get; set; } = 1001;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取或设置 PLC 复位点位地址。
|
/// 获取或设置 PLC 复位点位地址。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int PlcReset { get; set; } = 10002;
|
public int PlcReset { get; set; } = 1002;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取或设置 PLC 放行应答点位地址。
|
/// 获取或设置 PLC 放行应答点位地址。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int PlcAckRelease { get; set; } = 10003;
|
public int PlcAckRelease { get; set; } = 1003;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 表示 PLC 输出线圈地址配置。
|
/// 表示 PLC 输出地址配置。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class PlcOutputAddressOptions
|
public sealed class PlcOutputAddressOptions
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取或设置 PC 忙碌位地址。
|
/// 获取或设置 PC 忙碌位地址。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int PcBusy { get; set; } = 51;
|
public int PcBusy { get; set; } = 1051;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取或设置放行位地址。
|
/// 获取或设置放行位地址。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int ReleasePermit { get; set; } = 52;
|
public int ReleasePermit { get; set; } = 1052;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -368,7 +457,5 @@ public sealed class PlcRegisterAddressOptions
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取或设置结果代码寄存器地址。
|
/// 获取或设置结果代码寄存器地址。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int ResultCode { get; set; } = 40001;
|
public int ResultCode { get; set; } = 1100;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using AxiOmron.PcbCheck.Options;
|
using AxiOmron.PcbCheck.Options;
|
||||||
using AxiOmron.PcbCheck.Services.Interfaces;
|
using AxiOmron.PcbCheck.Services.Interfaces;
|
||||||
|
|
||||||
@@ -13,7 +14,11 @@ public sealed class AppConfigService : IAppConfigService
|
|||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
PropertyNameCaseInsensitive = true,
|
PropertyNameCaseInsensitive = true,
|
||||||
WriteIndented = true
|
WriteIndented = true,
|
||||||
|
Converters =
|
||||||
|
{
|
||||||
|
new JsonStringEnumConverter()
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
using AxiOmron.PcbCheck.Models;
|
||||||
|
|
||||||
|
namespace AxiOmron.PcbCheck.Services.Implementations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 提供双扫码枪结果汇总能力。
|
||||||
|
/// </summary>
|
||||||
|
public static class DualScannerResultMerger
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 按“任一成功即可、双成功不一致时优先扫码枪1”的规则汇总两路扫码结果。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="first">扫码枪1结果。</param>
|
||||||
|
/// <param name="second">扫码枪2结果。</param>
|
||||||
|
/// <returns>汇总后的扫码结果。</returns>
|
||||||
|
public static ScanOperationResult Merge(ScanOperationResult first, ScanOperationResult second)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(first);
|
||||||
|
ArgumentNullException.ThrowIfNull(second);
|
||||||
|
|
||||||
|
if (first.IsSuccess && second.IsSuccess)
|
||||||
|
{
|
||||||
|
return MergeBothSucceeded(first, second);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (first.IsSuccess)
|
||||||
|
{
|
||||||
|
return CreateSuccess("扫码枪1", first, second);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (second.IsSuccess)
|
||||||
|
{
|
||||||
|
return CreateSuccess("扫码枪2", second, first);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ScanOperationResult
|
||||||
|
{
|
||||||
|
IsSuccess = false,
|
||||||
|
IsSystemError = first.IsSystemError && second.IsSystemError,
|
||||||
|
DeviceConnected = first.DeviceConnected || second.DeviceConnected,
|
||||||
|
RawMessage = BuildRawMessage(first, second),
|
||||||
|
ErrorMessage = $"扫码枪1失败: {first.ErrorMessage}; 扫码枪2失败: {second.ErrorMessage}",
|
||||||
|
DurationMs = Math.Max(first.DurationMs, second.DurationMs)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 汇总两把扫码枪均成功的场景。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="first">扫码枪1结果。</param>
|
||||||
|
/// <param name="second">扫码枪2结果。</param>
|
||||||
|
/// <returns>汇总后的扫码结果。</returns>
|
||||||
|
private static ScanOperationResult MergeBothSucceeded(ScanOperationResult first, ScanOperationResult second)
|
||||||
|
{
|
||||||
|
var sameBarcode = string.Equals(first.Barcode, second.Barcode, StringComparison.Ordinal);
|
||||||
|
return new ScanOperationResult
|
||||||
|
{
|
||||||
|
IsSuccess = true,
|
||||||
|
DeviceConnected = first.DeviceConnected || second.DeviceConnected,
|
||||||
|
Barcode = first.Barcode,
|
||||||
|
RawMessage = BuildRawMessage(first, second),
|
||||||
|
ErrorMessage = sameBarcode ? string.Empty : $"两把扫码枪条码不一致,已优先使用扫码枪1条码。扫码枪2={second.Barcode}",
|
||||||
|
DurationMs = Math.Max(first.DurationMs, second.DurationMs)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建单路成功的汇总结果。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sourceName">成功来源名称。</param>
|
||||||
|
/// <param name="success">成功结果。</param>
|
||||||
|
/// <param name="other">另一路结果。</param>
|
||||||
|
/// <returns>汇总后的扫码结果。</returns>
|
||||||
|
private static ScanOperationResult CreateSuccess(string sourceName, ScanOperationResult success, ScanOperationResult other)
|
||||||
|
{
|
||||||
|
return new ScanOperationResult
|
||||||
|
{
|
||||||
|
IsSuccess = true,
|
||||||
|
DeviceConnected = success.DeviceConnected || other.DeviceConnected,
|
||||||
|
Barcode = success.Barcode,
|
||||||
|
RawMessage = $"使用{sourceName}; {BuildRawMessage(sourceName == "扫码枪1" ? success : other, sourceName == "扫码枪1" ? other : success)}",
|
||||||
|
ErrorMessage = other.IsSuccess ? string.Empty : other.ErrorMessage,
|
||||||
|
DurationMs = Math.Max(success.DurationMs, other.DurationMs)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 构造包含两路扫码原始信息的追溯文本。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="first">扫码枪1结果。</param>
|
||||||
|
/// <param name="second">扫码枪2结果。</param>
|
||||||
|
/// <returns>两路原始信息。</returns>
|
||||||
|
private static string BuildRawMessage(ScanOperationResult first, ScanOperationResult second)
|
||||||
|
{
|
||||||
|
return $"扫码枪1: 条码={first.Barcode}, 原始={first.RawMessage}, 错误={first.ErrorMessage}; 扫码枪2: 条码={second.Barcode}, 原始={second.RawMessage}, 错误={second.ErrorMessage}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using AxiOmron.PcbCheck.Utils;
|
||||||
|
|
||||||
|
namespace AxiOmron.PcbCheck.Services.Implementations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 提供文件校验通道共用的文件名模板与匹配规则。
|
||||||
|
/// </summary>
|
||||||
|
internal static class FileCheckPathMatcher
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 根据条码和模板构建预期文件名。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="barcode">条码内容。</param>
|
||||||
|
/// <param name="pattern">文件名模板。</param>
|
||||||
|
/// <returns>预期文件名或匹配模式。</returns>
|
||||||
|
public static string BuildExpectedFileName(string barcode, string pattern)
|
||||||
|
{
|
||||||
|
string effectivePattern = string.IsNullOrWhiteSpace(pattern) ? "${barcode}.txt" : pattern;
|
||||||
|
return effectivePattern.Replace("${barcode}", barcode, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 判断文件名是否符合当前条码和候选模板。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fileName">待检查文件名。</param>
|
||||||
|
/// <param name="candidateName">由模板生成的候选文件名。</param>
|
||||||
|
/// <param name="barcode">条码内容。</param>
|
||||||
|
/// <returns>匹配返回 <see langword="true"/>;否则返回 <see langword="false"/>。</returns>
|
||||||
|
public static bool IsMatch(string fileName, string candidateName, string barcode)
|
||||||
|
{
|
||||||
|
return fileName.Equals(candidateName, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| WildcardMatcher.IsMatch(fileName, candidateName)
|
||||||
|
|| fileName.Contains(barcode, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using AxiOmron.PcbCheck.Models;
|
||||||
|
using AxiOmron.PcbCheck.Options;
|
||||||
|
using AxiOmron.PcbCheck.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace AxiOmron.PcbCheck.Services.Implementations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 根据当前配置将文件校验请求路由到具体通道提供程序。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FileCheckService : IFileCheckService
|
||||||
|
{
|
||||||
|
private readonly AppConfig _config;
|
||||||
|
private readonly IReadOnlyDictionary<FileCheckProvider, IFileCheckProvider> _providers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化文件校验路由服务。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="config">应用根配置。</param>
|
||||||
|
/// <param name="providers">文件校验通道提供程序集合。</param>
|
||||||
|
/// <exception cref="ArgumentNullException">当 <paramref name="config"/> 或 <paramref name="providers"/> 为 <see langword="null"/> 时抛出。</exception>
|
||||||
|
public FileCheckService(AppConfig config, IEnumerable<IFileCheckProvider> providers)
|
||||||
|
{
|
||||||
|
_config = config ?? throw new ArgumentNullException(nameof(config));
|
||||||
|
ArgumentNullException.ThrowIfNull(providers);
|
||||||
|
_providers = providers.ToDictionary(provider => provider.Provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 按条码检查目标文件是否存在。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="barcode">条码内容。</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌。取消后立即终止当前通道校验。</param>
|
||||||
|
/// <returns>文件校验结果。</returns>
|
||||||
|
public Task<FileCheckOutcome> CheckFileAsync(string barcode, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
IFileCheckProvider? provider = ResolveProvider(_config.FileCheck.Provider);
|
||||||
|
if (provider is null)
|
||||||
|
{
|
||||||
|
return Task.FromResult(CreateUnsupportedProviderOutcome(_config.FileCheck.Provider));
|
||||||
|
}
|
||||||
|
|
||||||
|
return provider.CheckFileAsync(barcode, _config, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 测试当前或指定配置下的文件校验通道可用性。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="config">待测试的根配置;为 <see langword="null"/> 时使用当前应用配置。</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌。取消后立即终止当前通道测试。</param>
|
||||||
|
/// <returns>通道测试结果。</returns>
|
||||||
|
public Task<FileCheckTestOutcome> TestConnectionAsync(AppConfig? config, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
AppConfig effectiveConfig = config ?? _config;
|
||||||
|
IFileCheckProvider? provider = ResolveProvider(effectiveConfig.FileCheck.Provider);
|
||||||
|
if (provider is null)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new FileCheckTestOutcome
|
||||||
|
{
|
||||||
|
IsConfigurationError = true,
|
||||||
|
StatusMessage = $"不支持的文件校验通道: {effectiveConfig.FileCheck.Provider}"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return provider.TestConnectionAsync(effectiveConfig, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 根据通道类型查找对应提供程序。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="provider">通道类型。</param>
|
||||||
|
/// <returns>提供程序;不存在时返回 <see langword="null"/>。</returns>
|
||||||
|
private IFileCheckProvider? ResolveProvider(FileCheckProvider provider)
|
||||||
|
{
|
||||||
|
return _providers.TryGetValue(provider, out IFileCheckProvider? service) ? service : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建不支持通道的文件校验结果。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="provider">通道类型。</param>
|
||||||
|
/// <returns>文件校验结果。</returns>
|
||||||
|
private static FileCheckOutcome CreateUnsupportedProviderOutcome(FileCheckProvider provider)
|
||||||
|
{
|
||||||
|
return new FileCheckOutcome
|
||||||
|
{
|
||||||
|
Exists = false,
|
||||||
|
IsConfigurationError = true,
|
||||||
|
ErrorMessage = $"不支持的文件校验通道: {provider}"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
using System.IO;
|
||||||
|
using AxiOmron.PcbCheck.Models;
|
||||||
|
using AxiOmron.PcbCheck.Options;
|
||||||
|
using AxiOmron.PcbCheck.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace AxiOmron.PcbCheck.Services.Implementations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 提供本地磁盘目录的文件存在性校验能力。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LocalFileCheckProvider : IFileCheckProvider
|
||||||
|
{
|
||||||
|
private readonly IAppLogger<LocalFileCheckProvider> _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化本地文件校验提供程序。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">日志记录器。</param>
|
||||||
|
public LocalFileCheckProvider(IAppLogger<LocalFileCheckProvider> logger)
|
||||||
|
{
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取当前提供程序对应的通道类型。
|
||||||
|
/// </summary>
|
||||||
|
public FileCheckProvider Provider => FileCheckProvider.Local;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 按条码检查本地目录中是否存在目标文件。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="barcode">条码内容。</param>
|
||||||
|
/// <param name="config">根配置对象。</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌。取消后立即终止目录枚举。</param>
|
||||||
|
/// <returns>文件校验结果。</returns>
|
||||||
|
public async Task<FileCheckOutcome> CheckFileAsync(string barcode, AppConfig config, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(config);
|
||||||
|
if (string.IsNullOrWhiteSpace(barcode))
|
||||||
|
{
|
||||||
|
return new FileCheckOutcome
|
||||||
|
{
|
||||||
|
Exists = false,
|
||||||
|
IsConfigurationError = true,
|
||||||
|
ErrorMessage = "条码为空,无法执行本地文件查询。"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
FileCheckTestOutcome testOutcome = ValidateRootPath(config.FileCheck.Local.RootPath);
|
||||||
|
if (!testOutcome.IsSuccess)
|
||||||
|
{
|
||||||
|
return new FileCheckOutcome
|
||||||
|
{
|
||||||
|
Exists = false,
|
||||||
|
IsConfigurationError = testOutcome.IsConfigurationError,
|
||||||
|
IsSystemError = testOutcome.IsSystemError,
|
||||||
|
ErrorMessage = testOutcome.StatusMessage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return await Task.Run(() => CheckInternal(barcode, config, cancellationToken), cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 测试本地文件校验目录是否可访问。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="config">根配置对象。</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌。取消后立即终止测试。</param>
|
||||||
|
/// <returns>通道测试结果。</returns>
|
||||||
|
public Task<FileCheckTestOutcome> TestConnectionAsync(AppConfig config, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(config);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
return Task.FromResult(ValidateRootPath(config.FileCheck.Local.RootPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 在同步上下文中执行本地文件查找。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="barcode">条码内容。</param>
|
||||||
|
/// <param name="config">根配置对象。</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
|
/// <returns>文件校验结果。</returns>
|
||||||
|
private FileCheckOutcome CheckInternal(string barcode, AppConfig config, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string rootPath = Path.GetFullPath(config.FileCheck.Local.RootPath);
|
||||||
|
string candidateName = FileCheckPathMatcher.BuildExpectedFileName(barcode, config.FileCheck.FileNamePattern);
|
||||||
|
string? matchedPath = Directory.EnumerateFiles(rootPath)
|
||||||
|
.FirstOrDefault(path =>
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
return FileCheckPathMatcher.IsMatch(Path.GetFileName(path), candidateName, barcode);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchedPath is null)
|
||||||
|
{
|
||||||
|
return new FileCheckOutcome
|
||||||
|
{
|
||||||
|
Exists = false,
|
||||||
|
ConnectionSucceeded = true,
|
||||||
|
ErrorMessage = $"未找到与条码 {barcode} 匹配的本地文件。"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return new FileCheckOutcome
|
||||||
|
{
|
||||||
|
Exists = true,
|
||||||
|
ConnectionSucceeded = true,
|
||||||
|
MatchedFilePath = matchedPath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "本地文件校验异常");
|
||||||
|
return new FileCheckOutcome
|
||||||
|
{
|
||||||
|
Exists = false,
|
||||||
|
IsSystemError = true,
|
||||||
|
ErrorMessage = $"本地文件校验异常: {ex.Message}"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证本地根目录配置和可访问性。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="rootPath">本地根目录。</param>
|
||||||
|
/// <returns>通道测试结果。</returns>
|
||||||
|
private static FileCheckTestOutcome ValidateRootPath(string rootPath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(rootPath))
|
||||||
|
{
|
||||||
|
return new FileCheckTestOutcome
|
||||||
|
{
|
||||||
|
IsConfigurationError = true,
|
||||||
|
StatusMessage = "本地文件校验目录未配置。"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
string fullPath;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
fullPath = Path.GetFullPath(rootPath);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
|
||||||
|
{
|
||||||
|
return new FileCheckTestOutcome
|
||||||
|
{
|
||||||
|
IsConfigurationError = true,
|
||||||
|
StatusMessage = $"本地文件校验目录配置无效: {ex.Message}"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Directory.Exists(fullPath))
|
||||||
|
{
|
||||||
|
return new FileCheckTestOutcome
|
||||||
|
{
|
||||||
|
IsConfigurationError = true,
|
||||||
|
StatusMessage = $"本地文件校验目录不存在: {fullPath}"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return new FileCheckTestOutcome
|
||||||
|
{
|
||||||
|
IsSuccess = true,
|
||||||
|
ConnectionSucceeded = true,
|
||||||
|
RootPathAccessible = true,
|
||||||
|
StatusMessage = $"本地文件校验目录可访问: {fullPath}"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,28 +1,29 @@
|
|||||||
using AxiOmron.PcbCheck.Models;
|
using AxiOmron.PcbCheck.Models;
|
||||||
using AxiOmron.PcbCheck.Options;
|
using AxiOmron.PcbCheck.Options;
|
||||||
using AxiOmron.PcbCheck.Services.Interfaces;
|
using AxiOmron.PcbCheck.Services.Interfaces;
|
||||||
using IoTClient.Clients.Modbus;
|
using IoTClient.Clients.PLC;
|
||||||
using IoTClient.Enums;
|
using IoTClient.Enums;
|
||||||
|
|
||||||
namespace AxiOmron.PcbCheck.Services.Implementations;
|
namespace AxiOmron.PcbCheck.Services.Implementations;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 提供基于 IoTClient ModbusTcpClient 的 PLC 读写能力。
|
/// 提供基于 IoTClient OmronFinsClient 的 PLC 读写能力。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ModbusTcpPlcService : IPlcService, IDisposable
|
public sealed class OmronFinsPlcService : IPlcService, IDisposable
|
||||||
{
|
{
|
||||||
private readonly PlcOptions _options;
|
private readonly PlcOptions _options;
|
||||||
private readonly IAppLogger<ModbusTcpPlcService> _logger;
|
private readonly IAppLogger<OmronFinsPlcService> _logger;
|
||||||
private readonly SemaphoreSlim _ioLock = new(1, 1);
|
private readonly SemaphoreSlim _ioLock = new(1, 1);
|
||||||
private ModbusTcpClient? _client;
|
private OmronFinsClient? _client;
|
||||||
private PlcProcessState? _lastWrittenState;
|
private PlcProcessState? _lastWrittenState;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 初始化 PLC 通信服务。
|
/// 初始化 OMRON FINS PLC 通信服务。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="config">应用根配置。</param>
|
/// <param name="config">应用根配置。</param>
|
||||||
/// <param name="logger">日志记录器。</param>
|
/// <param name="logger">日志记录器。</param>
|
||||||
public ModbusTcpPlcService(AppConfig config, IAppLogger<ModbusTcpPlcService> logger)
|
public OmronFinsPlcService(AppConfig config, IAppLogger<OmronFinsPlcService> logger)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(config);
|
ArgumentNullException.ThrowIfNull(config);
|
||||||
_options = config.Plc;
|
_options = config.Plc;
|
||||||
@@ -44,9 +45,9 @@ public sealed class ModbusTcpPlcService : IPlcService, IDisposable
|
|||||||
|
|
||||||
return new PlcSignalSnapshot
|
return new PlcSignalSnapshot
|
||||||
{
|
{
|
||||||
PcbArrived = ReadDiscrete(_options.Inputs.PcbArrived),
|
PcbArrived = ReadBoolean(ToDataMemoryAddress(_options.Inputs.PcbArrived)),
|
||||||
PlcReset = ReadDiscrete(_options.Inputs.PlcReset),
|
PlcReset = ReadBoolean(ToDataMemoryAddress(_options.Inputs.PlcReset)),
|
||||||
PlcAckRelease = ReadDiscrete(_options.Inputs.PlcAckRelease),
|
PlcAckRelease = ReadBoolean(ToDataMemoryAddress(_options.Inputs.PlcAckRelease)),
|
||||||
CapturedAt = DateTimeOffset.Now
|
CapturedAt = DateTimeOffset.Now
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -79,16 +80,16 @@ public sealed class ModbusTcpPlcService : IPlcService, IDisposable
|
|||||||
{
|
{
|
||||||
Inputs = new PlcSignalSnapshot
|
Inputs = new PlcSignalSnapshot
|
||||||
{
|
{
|
||||||
PcbArrived = ReadDiscrete(_options.Inputs.PcbArrived),
|
PcbArrived = ReadBoolean(ToDataMemoryAddress(_options.Inputs.PcbArrived)),
|
||||||
PlcReset = ReadDiscrete(_options.Inputs.PlcReset),
|
PlcReset = ReadBoolean(ToDataMemoryAddress(_options.Inputs.PlcReset)),
|
||||||
PlcAckRelease = ReadDiscrete(_options.Inputs.PlcAckRelease),
|
PlcAckRelease = ReadBoolean(ToDataMemoryAddress(_options.Inputs.PlcAckRelease)),
|
||||||
CapturedAt = capturedAt
|
CapturedAt = capturedAt
|
||||||
},
|
},
|
||||||
Outputs = new PlcProcessState
|
Outputs = new PlcProcessState
|
||||||
{
|
{
|
||||||
PcBusy = ReadCoil(_options.Outputs.PcBusy),
|
PcBusy = ReadBoolean(ToDataMemoryAddress(_options.Outputs.PcBusy)),
|
||||||
ReleasePermit = ReadCoil(_options.Outputs.ReleasePermit),
|
ReleasePermit = ReadBoolean(ToDataMemoryAddress(_options.Outputs.ReleasePermit)),
|
||||||
ResultCode = ReadRegister(_options.Registers.ResultCode)
|
ResultCode = ReadUInt16(ToDataMemoryAddress(_options.Registers.ResultCode))
|
||||||
},
|
},
|
||||||
CapturedAt = capturedAt
|
CapturedAt = capturedAt
|
||||||
};
|
};
|
||||||
@@ -170,7 +171,7 @@ public sealed class ModbusTcpPlcService : IPlcService, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 确保 IoTClient Modbus TCP 客户端已连接。
|
/// 确保 IoTClient FINS 客户端已连接。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void EnsureConnected()
|
private void EnsureConnected()
|
||||||
{
|
{
|
||||||
@@ -180,73 +181,62 @@ public sealed class ModbusTcpPlcService : IPlcService, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
DisconnectUnsafe();
|
DisconnectUnsafe();
|
||||||
var client = new ModbusTcpClient(_options.Host, _options.Port, _options.ConnectTimeoutMs, EndianFormat.ABCD, false);
|
var client = new OmronFinsClient(_options.Host, _options.Port, _options.ConnectTimeoutMs, EndianFormat.ABCD)
|
||||||
|
{
|
||||||
|
UnitAddress = _options.UnitAddress,
|
||||||
|
SA1 = _options.SourceNodeAddress
|
||||||
|
};
|
||||||
var openResult = client.Open();
|
var openResult = client.Open();
|
||||||
EnsureSuccess(openResult.IsSucceed, openResult.Err, "连接 PLC 失败");
|
EnsureSuccess(openResult.IsSucceed, openResult.Err, "连接 OMRON FINS PLC 失败");
|
||||||
_client = client;
|
_client = client;
|
||||||
_logger.LogInformation("已通过 IoTClient 连接 PLC {Host}:{Port}", false, _options.Host, _options.Port);
|
_logger.LogInformation("已通过 IoTClient FINS 连接 PLC {Host}:{Port}", false, _options.Host, _options.Port);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 读取单个离散输入位。
|
/// 读取单个 D 区布尔值。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="address">离散输入地址。</param>
|
/// <param name="address">FINS 地址。</param>
|
||||||
/// <returns>读取到的布尔值。</returns>
|
/// <returns>读取到的布尔值。</returns>
|
||||||
private bool ReadDiscrete(int address)
|
private bool ReadBoolean(string address)
|
||||||
{
|
{
|
||||||
var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。");
|
var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。");
|
||||||
var result = client.ReadDiscrete(address, _options.UnitId, 2);
|
var result = client.ReadBoolean(address);
|
||||||
EnsureSuccess(result.IsSucceed, result.Err, $"读取离散输入失败,地址={address}");
|
EnsureSuccess(result.IsSucceed, result.Err, $"读取 FINS 布尔值失败,地址={address}");
|
||||||
return result.Value;
|
return result.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 读取单个输出线圈位。
|
/// 读取单个 D 区无符号整数。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="address">线圈地址。</param>
|
/// <param name="address">FINS 地址。</param>
|
||||||
/// <returns>读取到的布尔值。</returns>
|
|
||||||
private bool ReadCoil(int address)
|
|
||||||
{
|
|
||||||
var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。");
|
|
||||||
var result = client.ReadCoil(address, _options.UnitId, 1);
|
|
||||||
EnsureSuccess(result.IsSucceed, result.Err, $"读取线圈失败,地址={address}");
|
|
||||||
return result.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 读取单个保持寄存器。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="address">寄存器地址。</param>
|
|
||||||
/// <returns>读取到的寄存器值。</returns>
|
/// <returns>读取到的寄存器值。</returns>
|
||||||
private ushort ReadRegister(int address)
|
private ushort ReadUInt16(string address)
|
||||||
{
|
{
|
||||||
var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。");
|
var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。");
|
||||||
var result = client.ReadUInt16(address, _options.UnitId, 3);
|
var result = client.ReadUInt16(address);
|
||||||
EnsureSuccess(result.IsSucceed, result.Err, $"读取保持寄存器失败,地址={address}");
|
EnsureSuccess(result.IsSucceed, result.Err, $"读取 FINS UInt16 失败,地址={address}");
|
||||||
return result.Value;
|
return result.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 写入所有发生变化的线圈位与寄存器。
|
/// 写入所有发生变化的输出位与寄存器。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="state">目标状态。</param>
|
/// <param name="state">目标状态。</param>
|
||||||
private void WriteChangedState(PlcProcessState state)
|
private void WriteChangedState(PlcProcessState state)
|
||||||
{
|
{
|
||||||
var previous = _lastWrittenState;
|
var previous = _lastWrittenState;
|
||||||
|
WriteBooleanIfChanged(previous?.PcBusy, state.PcBusy, ToDataMemoryAddress(_options.Outputs.PcBusy));
|
||||||
WriteSingleCoilIfChanged(previous?.PcBusy, state.PcBusy, _options.Outputs.PcBusy);
|
WriteBooleanIfChanged(previous?.ReleasePermit, state.ReleasePermit, ToDataMemoryAddress(_options.Outputs.ReleasePermit));
|
||||||
WriteSingleCoilIfChanged(previous?.ReleasePermit, state.ReleasePermit, _options.Outputs.ReleasePermit);
|
WriteUInt16IfChanged(previous?.ResultCode, state.ResultCode, ToDataMemoryAddress(_options.Registers.ResultCode));
|
||||||
|
|
||||||
WriteSingleRegisterIfChanged(previous?.ResultCode, state.ResultCode, _options.Registers.ResultCode);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 仅当值发生变化时写入单个线圈位。
|
/// 仅当值发生变化时写入单个 D 区布尔值。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="previous">上一值。</param>
|
/// <param name="previous">上一值。</param>
|
||||||
/// <param name="current">当前值。</param>
|
/// <param name="current">当前值。</param>
|
||||||
/// <param name="address">线圈地址。</param>
|
/// <param name="address">FINS 地址。</param>
|
||||||
private void WriteSingleCoilIfChanged(bool? previous, bool current, int address)
|
private void WriteBooleanIfChanged(bool? previous, bool current, string address)
|
||||||
{
|
{
|
||||||
if (previous.HasValue && previous.Value == current)
|
if (previous.HasValue && previous.Value == current)
|
||||||
{
|
{
|
||||||
@@ -254,17 +244,17 @@ public sealed class ModbusTcpPlcService : IPlcService, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。");
|
var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。");
|
||||||
var result = client.Write(address.ToString(), current, _options.UnitId, 5);
|
var result = client.Write(address, current);
|
||||||
EnsureSuccess(result.IsSucceed, result.Err, $"写入线圈失败,地址={address},值={current}");
|
EnsureSuccess(result.IsSucceed, result.Err, $"写入 FINS 布尔值失败,地址={address},值={current}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 仅当值发生变化时写入单个保持寄存器。
|
/// 仅当值发生变化时写入单个 D 区无符号整数。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="previous">上一值。</param>
|
/// <param name="previous">上一值。</param>
|
||||||
/// <param name="current">当前值。</param>
|
/// <param name="current">当前值。</param>
|
||||||
/// <param name="address">保持寄存器地址。</param>
|
/// <param name="address">FINS 地址。</param>
|
||||||
private void WriteSingleRegisterIfChanged(ushort? previous, ushort current, int address)
|
private void WriteUInt16IfChanged(ushort? previous, ushort current, string address)
|
||||||
{
|
{
|
||||||
if (previous.HasValue && previous.Value == current)
|
if (previous.HasValue && previous.Value == current)
|
||||||
{
|
{
|
||||||
@@ -272,8 +262,18 @@ public sealed class ModbusTcpPlcService : IPlcService, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。");
|
var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。");
|
||||||
var result = client.Write(address.ToString(), current, _options.UnitId);
|
var result = client.Write(address, current);
|
||||||
EnsureSuccess(result.IsSucceed, result.Err, $"写入保持寄存器失败,地址={address},值={current}");
|
EnsureSuccess(result.IsSucceed, result.Err, $"写入 FINS UInt16 失败,地址={address},值={current}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将数字形式 D 区地址转换为 IoTClient FINS 地址字符串。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="address">D 区地址编号。</param>
|
||||||
|
/// <returns>FINS 地址字符串。</returns>
|
||||||
|
private static string ToDataMemoryAddress(int address)
|
||||||
|
{
|
||||||
|
return $"D{address}";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1,319 +0,0 @@
|
|||||||
using System.Diagnostics;
|
|
||||||
using System.IO;
|
|
||||||
using System.IO.Ports;
|
|
||||||
using System.Text;
|
|
||||||
using AxiOmron.PcbCheck.Models;
|
|
||||||
using AxiOmron.PcbCheck.Options;
|
|
||||||
using AxiOmron.PcbCheck.Services.Interfaces;
|
|
||||||
using AxiOmron.PcbCheck.Utils;
|
|
||||||
|
|
||||||
namespace AxiOmron.PcbCheck.Services.Implementations;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 提供串口扫码枪的触发与读取能力。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class SerialScannerService : IScannerService, IDisposable
|
|
||||||
{
|
|
||||||
private readonly ScannerOptions _options;
|
|
||||||
private readonly IAppLogger<SerialScannerService> _logger;
|
|
||||||
private readonly SemaphoreSlim _ioLock = new(1, 1);
|
|
||||||
private SerialPort? _serialPort;
|
|
||||||
private bool _disposed;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 初始化扫码枪服务。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="config">应用根配置。</param>
|
|
||||||
/// <param name="logger">日志记录器。</param>
|
|
||||||
public SerialScannerService(AppConfig config, IAppLogger<SerialScannerService> logger)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(config);
|
|
||||||
_options = config.Scanner;
|
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 触发一次扫码。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">取消令牌。</param>
|
|
||||||
/// <returns>扫码结果。</returns>
|
|
||||||
public async Task<ScanOperationResult> TriggerScanAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var lockTaken = false;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _ioLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
lockTaken = true;
|
|
||||||
return await TriggerScanInternalAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "扫码操作失败");
|
|
||||||
return new ScanOperationResult
|
|
||||||
{
|
|
||||||
IsSuccess = false,
|
|
||||||
IsSystemError = ex is not OperationCanceledException,
|
|
||||||
DeviceConnected = false,
|
|
||||||
ErrorMessage = ex.Message
|
|
||||||
};
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (lockTaken)
|
|
||||||
{
|
|
||||||
_ioLock.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 测试扫码枪连接。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">取消令牌。</param>
|
|
||||||
/// <returns>连接正常返回 <see langword="true"/>;否则返回 <see langword="false"/>。</returns>
|
|
||||||
public async Task<bool> TestConnectionAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var lockTaken = false;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _ioLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
lockTaken = true;
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
|
||||||
EnsurePortOpen();
|
|
||||||
return _serialPort is { IsOpen: true };
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "扫码枪连接测试失败");
|
|
||||||
ClosePortUnsafe();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (lockTaken)
|
|
||||||
{
|
|
||||||
_ioLock.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 主动断开并重建扫码枪连接。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">取消令牌。</param>
|
|
||||||
/// <returns>表示重连完成的任务。</returns>
|
|
||||||
public async Task ForceReconnectAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var lockTaken = false;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _ioLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
lockTaken = true;
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
|
||||||
ClosePortUnsafe();
|
|
||||||
EnsurePortOpen();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "扫码枪强制重连失败");
|
|
||||||
ClosePortUnsafe();
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (lockTaken)
|
|
||||||
{
|
|
||||||
_ioLock.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 释放扫码枪串口资源。
|
|
||||||
/// </summary>
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
if (_disposed)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_disposed = true;
|
|
||||||
ClosePortUnsafe();
|
|
||||||
_ioLock.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 在异步上下文中执行一次扫码流程。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">取消令牌。</param>
|
|
||||||
/// <returns>扫码结果。</returns>
|
|
||||||
private async Task<ScanOperationResult> TriggerScanInternalAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var stopwatch = Stopwatch.StartNew();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
EnsurePortOpen();
|
|
||||||
var port = _serialPort ?? throw new InvalidOperationException("扫码枪串口尚未初始化。");
|
|
||||||
port.DiscardInBuffer();
|
|
||||||
port.DiscardOutBuffer();
|
|
||||||
port.Write(StringEscapeHelper.Unescape(_options.TriggerCommand));
|
|
||||||
|
|
||||||
var rawMessage = await ReadUntilTerminatorAsync(
|
|
||||||
port,
|
|
||||||
StringEscapeHelper.Unescape(_options.ResponseTerminator),
|
|
||||||
_options.ReadTimeoutMs,
|
|
||||||
cancellationToken).ConfigureAwait(false);
|
|
||||||
var barcode = BarcodeCleaner.Clean(rawMessage);
|
|
||||||
if (string.IsNullOrEmpty(barcode))
|
|
||||||
{
|
|
||||||
return new ScanOperationResult
|
|
||||||
{
|
|
||||||
IsSuccess = false,
|
|
||||||
DeviceConnected = true,
|
|
||||||
RawMessage = rawMessage,
|
|
||||||
ErrorMessage = "扫码返回空字符串或仅包含控制字符。",
|
|
||||||
DurationMs = stopwatch.ElapsedMilliseconds
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ScanOperationResult
|
|
||||||
{
|
|
||||||
IsSuccess = true,
|
|
||||||
DeviceConnected = true,
|
|
||||||
Barcode = barcode,
|
|
||||||
RawMessage = rawMessage,
|
|
||||||
DurationMs = stopwatch.ElapsedMilliseconds
|
|
||||||
};
|
|
||||||
}
|
|
||||||
catch (TimeoutException ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "扫码等待超时");
|
|
||||||
return new ScanOperationResult
|
|
||||||
{
|
|
||||||
IsSuccess = false,
|
|
||||||
DeviceConnected = true,
|
|
||||||
ErrorMessage = "扫码超时。",
|
|
||||||
DurationMs = stopwatch.ElapsedMilliseconds
|
|
||||||
};
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "扫码枪执行失败");
|
|
||||||
ClosePortUnsafe();
|
|
||||||
return new ScanOperationResult
|
|
||||||
{
|
|
||||||
IsSuccess = false,
|
|
||||||
IsSystemError = true,
|
|
||||||
DeviceConnected = false,
|
|
||||||
ErrorMessage = ex.Message,
|
|
||||||
DurationMs = stopwatch.ElapsedMilliseconds
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 确保串口已打开并按当前配置初始化。
|
|
||||||
/// </summary>
|
|
||||||
private void EnsurePortOpen()
|
|
||||||
{
|
|
||||||
if (_serialPort is { IsOpen: true })
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ClosePortUnsafe();
|
|
||||||
|
|
||||||
var availablePorts = SerialPort.GetPortNames();
|
|
||||||
if (!availablePorts.Contains(_options.PortName, StringComparer.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
_logger.LogWarning("本地不存在串口 {PortName},可用串口: {AvailablePorts}", false, _options.PortName, string.Join(", ", availablePorts));
|
|
||||||
throw new IOException($"串口 {_options.PortName} 不存在。可用串口: {string.Join(", ", availablePorts)}");
|
|
||||||
}
|
|
||||||
|
|
||||||
var parity = Enum.Parse<Parity>(_options.Parity, true);
|
|
||||||
var stopBits = Enum.Parse<StopBits>(_options.StopBits, true);
|
|
||||||
var serialPort = new SerialPort(_options.PortName, _options.BaudRate, parity, _options.DataBits, stopBits)
|
|
||||||
{
|
|
||||||
ReadTimeout = 200,
|
|
||||||
WriteTimeout = 1000,
|
|
||||||
Encoding = Encoding.ASCII,
|
|
||||||
DtrEnable = true,
|
|
||||||
RtsEnable = true,
|
|
||||||
NewLine = StringEscapeHelper.Unescape(_options.ResponseTerminator)
|
|
||||||
};
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
serialPort.Open();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "打开串口 {PortName} 失败", false, _options.PortName);
|
|
||||||
serialPort.Dispose();
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
|
|
||||||
_serialPort = serialPort;
|
|
||||||
_logger.LogInformation("已连接扫码枪串口 {PortName}", false, _options.PortName);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 从串口读取直到遇到终止符或超时。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="port">串口实例。</param>
|
|
||||||
/// <param name="terminator">终止符。</param>
|
|
||||||
/// <param name="timeoutMs">总超时时间,单位为毫秒。</param>
|
|
||||||
/// <param name="cancellationToken">取消令牌。</param>
|
|
||||||
/// <returns>读取到的原始字符串。</returns>
|
|
||||||
private static async Task<string> ReadUntilTerminatorAsync(SerialPort port, string terminator, int timeoutMs, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var stopwatch = Stopwatch.StartNew();
|
|
||||||
var buffer = new StringBuilder();
|
|
||||||
|
|
||||||
while (stopwatch.ElapsedMilliseconds < timeoutMs)
|
|
||||||
{
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
|
||||||
var fragment = port.ReadExisting();
|
|
||||||
if (!string.IsNullOrEmpty(fragment))
|
|
||||||
{
|
|
||||||
buffer.Append(fragment);
|
|
||||||
if (string.IsNullOrEmpty(terminator) || buffer.ToString().Contains(terminator, StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
return buffer.ToString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await Task.Delay(20, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new TimeoutException("扫码枪在规定时间内未返回完整报文。");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 关闭当前串口并释放资源。
|
|
||||||
/// </summary>
|
|
||||||
private void ClosePortUnsafe()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (_serialPort?.IsOpen == true)
|
|
||||||
{
|
|
||||||
_serialPort.Close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// 忽略关闭异常。
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_serialPort?.Dispose();
|
|
||||||
_serialPort = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
using AxiOmron.PcbCheck.Models;
|
using AxiOmron.PcbCheck.Models;
|
||||||
using AxiOmron.PcbCheck.Options;
|
using AxiOmron.PcbCheck.Options;
|
||||||
using AxiOmron.PcbCheck.Services.Interfaces;
|
using AxiOmron.PcbCheck.Services.Interfaces;
|
||||||
using AxiOmron.PcbCheck.Utils;
|
|
||||||
using Renci.SshNet;
|
using Renci.SshNet;
|
||||||
using Renci.SshNet.Common;
|
using Renci.SshNet.Common;
|
||||||
|
|
||||||
@@ -10,9 +9,8 @@ namespace AxiOmron.PcbCheck.Services.Implementations;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 提供 SFTP 文件存在性校验能力。
|
/// 提供 SFTP 文件存在性校验能力。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SftpLookupService : ISftpLookupService
|
public sealed class SftpLookupService : IFileCheckProvider
|
||||||
{
|
{
|
||||||
private readonly SftpOptions _options;
|
|
||||||
private readonly IAppLogger<SftpLookupService> _logger;
|
private readonly IAppLogger<SftpLookupService> _logger;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -20,24 +18,28 @@ public sealed class SftpLookupService : ISftpLookupService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="config">应用根配置。</param>
|
/// <param name="config">应用根配置。</param>
|
||||||
/// <param name="logger">日志记录器。</param>
|
/// <param name="logger">日志记录器。</param>
|
||||||
public SftpLookupService(AppConfig config, IAppLogger<SftpLookupService> logger)
|
public SftpLookupService(IAppLogger<SftpLookupService> logger)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(config);
|
|
||||||
_options = config.Sftp;
|
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取当前提供程序对应的通道类型。
|
||||||
|
/// </summary>
|
||||||
|
public FileCheckProvider Provider => FileCheckProvider.Sftp;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 按条码检查目标文件是否存在。
|
/// 按条码检查目标文件是否存在。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="barcode">条码内容。</param>
|
/// <param name="barcode">条码内容。</param>
|
||||||
/// <param name="cancellationToken">取消令牌。</param>
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
/// <returns>文件校验结果。</returns>
|
/// <returns>文件校验结果。</returns>
|
||||||
public async Task<SftpCheckOutcome> CheckFileAsync(string barcode, CancellationToken cancellationToken)
|
public async Task<FileCheckOutcome> CheckFileAsync(string barcode, AppConfig config, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(config);
|
||||||
if (string.IsNullOrWhiteSpace(barcode))
|
if (string.IsNullOrWhiteSpace(barcode))
|
||||||
{
|
{
|
||||||
return new SftpCheckOutcome
|
return new FileCheckOutcome
|
||||||
{
|
{
|
||||||
Exists = false,
|
Exists = false,
|
||||||
IsConfigurationError = true,
|
IsConfigurationError = true,
|
||||||
@@ -45,9 +47,9 @@ public sealed class SftpLookupService : ISftpLookupService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(_options.Host) || string.IsNullOrWhiteSpace(_options.RootPath))
|
if (string.IsNullOrWhiteSpace(config.Sftp.Host) || string.IsNullOrWhiteSpace(config.Sftp.RootPath))
|
||||||
{
|
{
|
||||||
return new SftpCheckOutcome
|
return new FileCheckOutcome
|
||||||
{
|
{
|
||||||
Exists = false,
|
Exists = false,
|
||||||
IsConfigurationError = true,
|
IsConfigurationError = true,
|
||||||
@@ -55,21 +57,22 @@ public sealed class SftpLookupService : ISftpLookupService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return await Task.Run(() => CheckInternal(barcode, cancellationToken), cancellationToken).ConfigureAwait(false);
|
return await Task.Run(() => CheckInternal(barcode, config, cancellationToken), cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 测试当前或指定配置下的 SFTP 连接可用性。
|
/// 测试当前或指定配置下的 SFTP 连接可用性。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="options">待测试的 SFTP 配置;为 <see langword="null"/> 时使用当前应用配置。</param>
|
/// <param name="config">待测试的根配置。</param>
|
||||||
/// <param name="cancellationToken">取消令牌。若在连接过程中取消,将终止测试。</param>
|
/// <param name="cancellationToken">取消令牌。若在连接过程中取消,将终止测试。</param>
|
||||||
/// <returns>连接测试结果。</returns>
|
/// <returns>连接测试结果。</returns>
|
||||||
public async Task<SftpConnectionTestOutcome> TestConnectionAsync(SftpOptions? options, CancellationToken cancellationToken)
|
public async Task<FileCheckTestOutcome> TestConnectionAsync(AppConfig config, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
SftpOptions effectiveOptions = options ?? _options;
|
ArgumentNullException.ThrowIfNull(config);
|
||||||
|
SftpOptions effectiveOptions = config.Sftp;
|
||||||
if (string.IsNullOrWhiteSpace(effectiveOptions.Host) || string.IsNullOrWhiteSpace(effectiveOptions.RootPath))
|
if (string.IsNullOrWhiteSpace(effectiveOptions.Host) || string.IsNullOrWhiteSpace(effectiveOptions.RootPath))
|
||||||
{
|
{
|
||||||
return new SftpConnectionTestOutcome
|
return new FileCheckTestOutcome
|
||||||
{
|
{
|
||||||
IsConfigurationError = true,
|
IsConfigurationError = true,
|
||||||
StatusMessage = "SFTP 配置缺失 Host 或 RootPath。"
|
StatusMessage = "SFTP 配置缺失 Host 或 RootPath。"
|
||||||
@@ -85,18 +88,19 @@ public sealed class SftpLookupService : ISftpLookupService
|
|||||||
/// <param name="barcode">条码。</param>
|
/// <param name="barcode">条码。</param>
|
||||||
/// <param name="cancellationToken">取消令牌。</param>
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
/// <returns>查询结果。</returns>
|
/// <returns>查询结果。</returns>
|
||||||
private SftpCheckOutcome CheckInternal(string barcode, CancellationToken cancellationToken)
|
private FileCheckOutcome CheckInternal(string barcode, AppConfig config, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var client = CreateClient(_options);
|
SftpOptions options = config.Sftp;
|
||||||
ConnectClient(client, _options);
|
using var client = CreateClient(options);
|
||||||
|
ConnectClient(client, options);
|
||||||
|
|
||||||
if (!client.IsConnected)
|
if (!client.IsConnected)
|
||||||
{
|
{
|
||||||
return new SftpCheckOutcome
|
return new FileCheckOutcome
|
||||||
{
|
{
|
||||||
Exists = false,
|
Exists = false,
|
||||||
IsSystemError = true,
|
IsSystemError = true,
|
||||||
@@ -104,11 +108,11 @@ public sealed class SftpLookupService : ISftpLookupService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
var candidateName = BuildExpectedFileName(barcode);
|
string candidateName = FileCheckPathMatcher.BuildExpectedFileName(barcode, config.FileCheck.FileNamePattern);
|
||||||
var rootPath = NormalizeDirectory(_options.RootPath);
|
var rootPath = NormalizeDirectory(options.RootPath);
|
||||||
if (!client.Exists(rootPath))
|
if (!client.Exists(rootPath))
|
||||||
{
|
{
|
||||||
return new SftpCheckOutcome
|
return new FileCheckOutcome
|
||||||
{
|
{
|
||||||
Exists = false,
|
Exists = false,
|
||||||
IsConfigurationError = true,
|
IsConfigurationError = true,
|
||||||
@@ -119,14 +123,11 @@ public sealed class SftpLookupService : ISftpLookupService
|
|||||||
|
|
||||||
var matched = client.ListDirectory(rootPath)
|
var matched = client.ListDirectory(rootPath)
|
||||||
.Where(entry => !entry.IsDirectory && !entry.IsSymbolicLink)
|
.Where(entry => !entry.IsDirectory && !entry.IsSymbolicLink)
|
||||||
.FirstOrDefault(entry =>
|
.FirstOrDefault(entry => FileCheckPathMatcher.IsMatch(entry.Name, candidateName, barcode));
|
||||||
entry.Name.Equals(candidateName, StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| WildcardMatcher.IsMatch(entry.Name, candidateName)
|
|
||||||
|| entry.Name.Contains(barcode, StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
if (matched is null)
|
if (matched is null)
|
||||||
{
|
{
|
||||||
return new SftpCheckOutcome
|
return new FileCheckOutcome
|
||||||
{
|
{
|
||||||
Exists = false,
|
Exists = false,
|
||||||
ConnectionSucceeded = true,
|
ConnectionSucceeded = true,
|
||||||
@@ -134,7 +135,7 @@ public sealed class SftpLookupService : ISftpLookupService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return new SftpCheckOutcome
|
return new FileCheckOutcome
|
||||||
{
|
{
|
||||||
Exists = true,
|
Exists = true,
|
||||||
ConnectionSucceeded = true,
|
ConnectionSucceeded = true,
|
||||||
@@ -148,7 +149,7 @@ public sealed class SftpLookupService : ISftpLookupService
|
|||||||
catch (SshAuthenticationException ex)
|
catch (SshAuthenticationException ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "SFTP 认证失败");
|
_logger.LogError(ex, "SFTP 认证失败");
|
||||||
return new SftpCheckOutcome
|
return new FileCheckOutcome
|
||||||
{
|
{
|
||||||
Exists = false,
|
Exists = false,
|
||||||
IsSystemError = true,
|
IsSystemError = true,
|
||||||
@@ -158,7 +159,7 @@ public sealed class SftpLookupService : ISftpLookupService
|
|||||||
catch (SshConnectionException ex)
|
catch (SshConnectionException ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "SFTP 连接失败");
|
_logger.LogError(ex, "SFTP 连接失败");
|
||||||
return new SftpCheckOutcome
|
return new FileCheckOutcome
|
||||||
{
|
{
|
||||||
Exists = false,
|
Exists = false,
|
||||||
IsSystemError = true,
|
IsSystemError = true,
|
||||||
@@ -168,7 +169,7 @@ public sealed class SftpLookupService : ISftpLookupService
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "SFTP 查询异常");
|
_logger.LogError(ex, "SFTP 查询异常");
|
||||||
return new SftpCheckOutcome
|
return new FileCheckOutcome
|
||||||
{
|
{
|
||||||
Exists = false,
|
Exists = false,
|
||||||
IsSystemError = true,
|
IsSystemError = true,
|
||||||
@@ -177,15 +178,6 @@ public sealed class SftpLookupService : ISftpLookupService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 根据当前配置创建 SFTP 客户端。
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>SFTP 客户端实例。</returns>
|
|
||||||
private SftpClient CreateClient()
|
|
||||||
{
|
|
||||||
return CreateClient(_options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 根据指定配置创建 SFTP 客户端。
|
/// 根据指定配置创建 SFTP 客户端。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -218,17 +210,6 @@ public sealed class SftpLookupService : ISftpLookupService
|
|||||||
client.Connect();
|
client.Connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 根据条码和模板构建预期文件名。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="barcode">条码。</param>
|
|
||||||
/// <returns>预期文件名或匹配模式。</returns>
|
|
||||||
private string BuildExpectedFileName(string barcode)
|
|
||||||
{
|
|
||||||
var pattern = string.IsNullOrWhiteSpace(_options.FileNamePattern) ? "${barcode}.txt" : _options.FileNamePattern;
|
|
||||||
return pattern.Replace("${barcode}", barcode, StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 统一目录路径格式。
|
/// 统一目录路径格式。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -245,7 +226,7 @@ public sealed class SftpLookupService : ISftpLookupService
|
|||||||
/// <param name="options">待测试的配置。</param>
|
/// <param name="options">待测试的配置。</param>
|
||||||
/// <param name="cancellationToken">取消令牌。</param>
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
/// <returns>连接测试结果。</returns>
|
/// <returns>连接测试结果。</returns>
|
||||||
private SftpConnectionTestOutcome TestConnectionInternal(SftpOptions options, CancellationToken cancellationToken)
|
private FileCheckTestOutcome TestConnectionInternal(SftpOptions options, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
@@ -256,7 +237,7 @@ public sealed class SftpLookupService : ISftpLookupService
|
|||||||
|
|
||||||
if (!client.IsConnected)
|
if (!client.IsConnected)
|
||||||
{
|
{
|
||||||
return new SftpConnectionTestOutcome
|
return new FileCheckTestOutcome
|
||||||
{
|
{
|
||||||
IsSystemError = true,
|
IsSystemError = true,
|
||||||
StatusMessage = "SFTP 未能建立连接。"
|
StatusMessage = "SFTP 未能建立连接。"
|
||||||
@@ -266,7 +247,7 @@ public sealed class SftpLookupService : ISftpLookupService
|
|||||||
var rootPath = NormalizeDirectory(options.RootPath);
|
var rootPath = NormalizeDirectory(options.RootPath);
|
||||||
if (!client.Exists(rootPath))
|
if (!client.Exists(rootPath))
|
||||||
{
|
{
|
||||||
return new SftpConnectionTestOutcome
|
return new FileCheckTestOutcome
|
||||||
{
|
{
|
||||||
IsConfigurationError = true,
|
IsConfigurationError = true,
|
||||||
ConnectionSucceeded = true,
|
ConnectionSucceeded = true,
|
||||||
@@ -274,7 +255,7 @@ public sealed class SftpLookupService : ISftpLookupService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return new SftpConnectionTestOutcome
|
return new FileCheckTestOutcome
|
||||||
{
|
{
|
||||||
IsSuccess = true,
|
IsSuccess = true,
|
||||||
ConnectionSucceeded = true,
|
ConnectionSucceeded = true,
|
||||||
@@ -289,7 +270,7 @@ public sealed class SftpLookupService : ISftpLookupService
|
|||||||
catch (SshAuthenticationException ex)
|
catch (SshAuthenticationException ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "SFTP 认证失败");
|
_logger.LogError(ex, "SFTP 认证失败");
|
||||||
return new SftpConnectionTestOutcome
|
return new FileCheckTestOutcome
|
||||||
{
|
{
|
||||||
IsSystemError = true,
|
IsSystemError = true,
|
||||||
StatusMessage = $"SFTP 认证失败: {ex.Message}"
|
StatusMessage = $"SFTP 认证失败: {ex.Message}"
|
||||||
@@ -298,7 +279,7 @@ public sealed class SftpLookupService : ISftpLookupService
|
|||||||
catch (SshConnectionException ex)
|
catch (SshConnectionException ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "SFTP 连接失败");
|
_logger.LogError(ex, "SFTP 连接失败");
|
||||||
return new SftpConnectionTestOutcome
|
return new FileCheckTestOutcome
|
||||||
{
|
{
|
||||||
IsSystemError = true,
|
IsSystemError = true,
|
||||||
StatusMessage = $"SFTP 连接失败: {ex.Message}"
|
StatusMessage = $"SFTP 连接失败: {ex.Message}"
|
||||||
@@ -307,7 +288,7 @@ public sealed class SftpLookupService : ISftpLookupService
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "SFTP 连接测试异常");
|
_logger.LogError(ex, "SFTP 连接测试异常");
|
||||||
return new SftpConnectionTestOutcome
|
return new FileCheckTestOutcome
|
||||||
{
|
{
|
||||||
IsSystemError = true,
|
IsSystemError = true,
|
||||||
StatusMessage = ex.Message
|
StatusMessage = ex.Message
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Text;
|
||||||
|
using AxiOmron.PcbCheck.Models;
|
||||||
|
using AxiOmron.PcbCheck.Options;
|
||||||
|
using AxiOmron.PcbCheck.Services.Interfaces;
|
||||||
|
using AxiOmron.PcbCheck.Utils;
|
||||||
|
|
||||||
|
namespace AxiOmron.PcbCheck.Services.Implementations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 提供两把 TCP 扫码枪的触发、读取与结果汇总能力。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TcpDualScannerService : IScannerService
|
||||||
|
{
|
||||||
|
private readonly ScannerOptions _options;
|
||||||
|
private readonly IAppLogger<TcpDualScannerService> _logger;
|
||||||
|
private readonly SemaphoreSlim _ioLock = new(1, 1);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化双 TCP 扫码枪服务。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="config">应用根配置。</param>
|
||||||
|
/// <param name="logger">日志记录器。</param>
|
||||||
|
public TcpDualScannerService(AppConfig config, IAppLogger<TcpDualScannerService> logger)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(config);
|
||||||
|
_options = config.Scanner;
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 触发两把扫码枪扫码,并按双扫码规则返回汇总结果。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">取消令牌。取消后终止等待并向调用方传播取消。</param>
|
||||||
|
/// <returns>双扫码汇总结果。</returns>
|
||||||
|
public async Task<ScanOperationResult> TriggerScanAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _ioLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ScanOperationResult first;
|
||||||
|
ScanOperationResult second;
|
||||||
|
if (_options.TriggerInParallel)
|
||||||
|
{
|
||||||
|
Task<ScanOperationResult> firstTask = TriggerSingleScannerAsync(_options.Scanner1, cancellationToken);
|
||||||
|
Task<ScanOperationResult> secondTask = TriggerSingleScannerAsync(_options.Scanner2, cancellationToken);
|
||||||
|
await Task.WhenAll(firstTask, secondTask).ConfigureAwait(false);
|
||||||
|
first = await firstTask.ConfigureAwait(false);
|
||||||
|
second = await secondTask.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
first = await TriggerSingleScannerAsync(_options.Scanner1, cancellationToken).ConfigureAwait(false);
|
||||||
|
second = await TriggerSingleScannerAsync(_options.Scanner2, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
ScanOperationResult merged = DualScannerResultMerger.Merge(first, second);
|
||||||
|
if (merged.IsSuccess && !string.IsNullOrWhiteSpace(merged.ErrorMessage))
|
||||||
|
{
|
||||||
|
_logger.LogWarning(merged.ErrorMessage, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_ioLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 测试两把扫码枪连接。任一失败均返回失败。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">取消令牌。取消后终止连接测试。</param>
|
||||||
|
/// <returns>两把扫码枪均可连接返回 <see langword="true"/>;否则返回 <see langword="false"/>。</returns>
|
||||||
|
public async Task<bool> TestConnectionAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Task<bool> first = TestEndpointAsync(_options.Scanner1, cancellationToken);
|
||||||
|
Task<bool> second = TestEndpointAsync(_options.Scanner2, cancellationToken);
|
||||||
|
bool[] results = await Task.WhenAll(first, second).ConfigureAwait(false);
|
||||||
|
return results.All(static result => result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// TCP 扫码枪采用短连接触发,无需保持重连状态。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
|
/// <returns>表示操作完成的任务。</returns>
|
||||||
|
public async Task ForceReconnectAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_ = await TestConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 触发单把扫码枪并读取返回。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="endpoint">扫码枪连接参数。</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
|
/// <returns>单把扫码枪结果。</returns>
|
||||||
|
private async Task<ScanOperationResult> TriggerSingleScannerAsync(TcpScannerEndpointOptions endpoint, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var stopwatch = Stopwatch.StartNew();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var client = new TcpClient();
|
||||||
|
await ConnectAsync(client, endpoint, cancellationToken).ConfigureAwait(false);
|
||||||
|
await using NetworkStream stream = client.GetStream();
|
||||||
|
|
||||||
|
byte[] commandBytes = Encoding.ASCII.GetBytes(StringEscapeHelper.Unescape(_options.TriggerCommand));
|
||||||
|
await stream.WriteAsync(commandBytes, cancellationToken).ConfigureAwait(false);
|
||||||
|
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
string rawMessage = await ReadResponseAsync(stream, _options.ReadTimeoutMs, cancellationToken).ConfigureAwait(false);
|
||||||
|
string barcode = BarcodeCleaner.Clean(rawMessage);
|
||||||
|
if (string.IsNullOrEmpty(barcode))
|
||||||
|
{
|
||||||
|
return new ScanOperationResult
|
||||||
|
{
|
||||||
|
IsSuccess = false,
|
||||||
|
DeviceConnected = true,
|
||||||
|
RawMessage = rawMessage,
|
||||||
|
ErrorMessage = $"{endpoint.Name} 返回空字符串或仅包含控制字符。",
|
||||||
|
DurationMs = stopwatch.ElapsedMilliseconds
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ScanOperationResult
|
||||||
|
{
|
||||||
|
IsSuccess = true,
|
||||||
|
DeviceConnected = true,
|
||||||
|
Barcode = barcode,
|
||||||
|
RawMessage = rawMessage,
|
||||||
|
DurationMs = stopwatch.ElapsedMilliseconds
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (TimeoutException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "{ScannerName} 扫码等待超时", true, endpoint.Name);
|
||||||
|
return new ScanOperationResult
|
||||||
|
{
|
||||||
|
IsSuccess = false,
|
||||||
|
DeviceConnected = true,
|
||||||
|
ErrorMessage = $"{endpoint.Name} 扫码超时。",
|
||||||
|
DurationMs = stopwatch.ElapsedMilliseconds
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "{ScannerName} 扫码失败", true, endpoint.Name);
|
||||||
|
return new ScanOperationResult
|
||||||
|
{
|
||||||
|
IsSuccess = false,
|
||||||
|
IsSystemError = true,
|
||||||
|
DeviceConnected = false,
|
||||||
|
ErrorMessage = $"{endpoint.Name} 通信失败: {ex.Message}",
|
||||||
|
DurationMs = stopwatch.ElapsedMilliseconds
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 测试单个扫码枪端点是否可连接。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="endpoint">扫码枪连接参数。</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
|
/// <returns>可连接返回 <see langword="true"/>。</returns>
|
||||||
|
private async Task<bool> TestEndpointAsync(TcpScannerEndpointOptions endpoint, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var client = new TcpClient();
|
||||||
|
await ConnectAsync(client, endpoint, cancellationToken).ConfigureAwait(false);
|
||||||
|
return client.Connected;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "{ScannerName} TCP 连接测试失败", false, endpoint.Name);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 按配置超时建立 TCP 连接。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="client">TCP 客户端。</param>
|
||||||
|
/// <param name="endpoint">扫码枪连接参数。</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
|
/// <returns>表示连接完成的任务。</returns>
|
||||||
|
private async Task ConnectAsync(TcpClient client, TcpScannerEndpointOptions endpoint, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
timeoutCts.CancelAfter(TimeSpan.FromMilliseconds(_options.ConnectTimeoutMs));
|
||||||
|
await client.ConnectAsync(endpoint.Host, endpoint.Port, timeoutCts.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 读取扫码枪响应,直到遇到终止符、读到首批数据或超时。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="stream">网络流。</param>
|
||||||
|
/// <param name="timeoutMs">读取超时,单位为毫秒。</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
|
/// <returns>原始响应文本。</returns>
|
||||||
|
private async Task<string> ReadResponseAsync(NetworkStream stream, int timeoutMs, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
string terminator = StringEscapeHelper.Unescape(_options.ResponseTerminator);
|
||||||
|
var buffer = new byte[1024];
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
var stopwatch = Stopwatch.StartNew();
|
||||||
|
|
||||||
|
while (stopwatch.ElapsedMilliseconds < timeoutMs)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (!stream.DataAvailable)
|
||||||
|
{
|
||||||
|
await Task.Delay(20, cancellationToken).ConfigureAwait(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (read <= 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.Append(Encoding.ASCII.GetString(buffer, 0, read));
|
||||||
|
if (string.IsNullOrEmpty(terminator) || builder.ToString().Contains(terminator, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (builder.Length > 0 && string.IsNullOrEmpty(terminator))
|
||||||
|
{
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TimeoutException("扫码枪在规定时间内未返回完整报文。");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
|||||||
{
|
{
|
||||||
private readonly IPlcService _plcService;
|
private readonly IPlcService _plcService;
|
||||||
private readonly IScannerService _scannerService;
|
private readonly IScannerService _scannerService;
|
||||||
private readonly ISftpLookupService _sftpLookupService;
|
private readonly IFileCheckService _fileCheckService;
|
||||||
private readonly IAndonService _andonService;
|
private readonly IAndonService _andonService;
|
||||||
private readonly IBoardRecordRepository _boardRecordRepository;
|
private readonly IBoardRecordRepository _boardRecordRepository;
|
||||||
private readonly IAppStateStore _stateStore;
|
private readonly IAppStateStore _stateStore;
|
||||||
@@ -40,7 +40,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="plcService">PLC 服务。</param>
|
/// <param name="plcService">PLC 服务。</param>
|
||||||
/// <param name="scannerService">扫码枪服务。</param>
|
/// <param name="scannerService">扫码枪服务。</param>
|
||||||
/// <param name="sftpLookupService">SFTP 校验服务。</param>
|
/// <param name="fileCheckService">文件校验服务。</param>
|
||||||
/// <param name="andonService">安灯服务。</param>
|
/// <param name="andonService">安灯服务。</param>
|
||||||
/// <param name="stateStore">运行态存储。</param>
|
/// <param name="stateStore">运行态存储。</param>
|
||||||
/// <param name="config">应用配置。</param>
|
/// <param name="config">应用配置。</param>
|
||||||
@@ -48,12 +48,12 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
|||||||
public WorkflowHostedService(
|
public WorkflowHostedService(
|
||||||
IPlcService plcService,
|
IPlcService plcService,
|
||||||
IScannerService scannerService,
|
IScannerService scannerService,
|
||||||
ISftpLookupService sftpLookupService,
|
IFileCheckService fileCheckService,
|
||||||
IAndonService andonService,
|
IAndonService andonService,
|
||||||
IAppStateStore stateStore,
|
IAppStateStore stateStore,
|
||||||
AppConfig config,
|
AppConfig config,
|
||||||
IAppLogger<WorkflowHostedService> appLogger)
|
IAppLogger<WorkflowHostedService> appLogger)
|
||||||
: this(plcService, scannerService, sftpLookupService, andonService, stateStore, config, appLogger, new NullBoardRecordRepository())
|
: this(plcService, scannerService, fileCheckService, andonService, stateStore, config, appLogger, new NullBoardRecordRepository())
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="plcService">PLC 服务。</param>
|
/// <param name="plcService">PLC 服务。</param>
|
||||||
/// <param name="scannerService">扫码枪服务。</param>
|
/// <param name="scannerService">扫码枪服务。</param>
|
||||||
/// <param name="sftpLookupService">SFTP 校验服务。</param>
|
/// <param name="fileCheckService">文件校验服务。</param>
|
||||||
/// <param name="andonService">安灯服务。</param>
|
/// <param name="andonService">安灯服务。</param>
|
||||||
/// <param name="stateStore">运行态存储。</param>
|
/// <param name="stateStore">运行态存储。</param>
|
||||||
/// <param name="config">应用配置。</param>
|
/// <param name="config">应用配置。</param>
|
||||||
@@ -71,7 +71,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
|||||||
public WorkflowHostedService(
|
public WorkflowHostedService(
|
||||||
IPlcService plcService,
|
IPlcService plcService,
|
||||||
IScannerService scannerService,
|
IScannerService scannerService,
|
||||||
ISftpLookupService sftpLookupService,
|
IFileCheckService fileCheckService,
|
||||||
IAndonService andonService,
|
IAndonService andonService,
|
||||||
IAppStateStore stateStore,
|
IAppStateStore stateStore,
|
||||||
AppConfig config,
|
AppConfig config,
|
||||||
@@ -80,7 +80,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
|||||||
{
|
{
|
||||||
_plcService = plcService ?? throw new ArgumentNullException(nameof(plcService));
|
_plcService = plcService ?? throw new ArgumentNullException(nameof(plcService));
|
||||||
_scannerService = scannerService ?? throw new ArgumentNullException(nameof(scannerService));
|
_scannerService = scannerService ?? throw new ArgumentNullException(nameof(scannerService));
|
||||||
_sftpLookupService = sftpLookupService ?? throw new ArgumentNullException(nameof(sftpLookupService));
|
_fileCheckService = fileCheckService ?? throw new ArgumentNullException(nameof(fileCheckService));
|
||||||
_andonService = andonService ?? throw new ArgumentNullException(nameof(andonService));
|
_andonService = andonService ?? throw new ArgumentNullException(nameof(andonService));
|
||||||
_stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore));
|
_stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore));
|
||||||
_config = config ?? throw new ArgumentNullException(nameof(config));
|
_config = config ?? throw new ArgumentNullException(nameof(config));
|
||||||
@@ -98,7 +98,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
|||||||
_appLogger.LogInformation("流程服务已启动,等待 PLC 到位信号。", true);
|
_appLogger.LogInformation("流程服务已启动,等待 PLC 到位信号。", true);
|
||||||
await RestoreBoardRecordsAsync(stoppingToken).ConfigureAwait(false);
|
await RestoreBoardRecordsAsync(stoppingToken).ConfigureAwait(false);
|
||||||
await ProbePlcOnStartupAsync(stoppingToken).ConfigureAwait(false);
|
await ProbePlcOnStartupAsync(stoppingToken).ConfigureAwait(false);
|
||||||
await ProbeSftpOnStartupAsync(stoppingToken).ConfigureAwait(false);
|
await ProbeFileCheckOnStartupAsync(stoppingToken).ConfigureAwait(false);
|
||||||
await ProbeScannerOnStartupAsync(stoppingToken).ConfigureAwait(false);
|
await ProbeScannerOnStartupAsync(stoppingToken).ConfigureAwait(false);
|
||||||
PublishRuntimeState(WorkflowState.Idle, "等待 PCB 到位");
|
PublishRuntimeState(WorkflowState.Idle, "等待 PCB 到位");
|
||||||
|
|
||||||
@@ -171,15 +171,15 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 在应用启动后执行一次 SFTP 探活,仅更新状态与日志,不阻断程序运行。
|
/// 在应用启动后执行一次文件校验通道探活,仅更新状态与日志,不阻断程序运行。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="cancellationToken">取消令牌。若应用正在关闭,则中止本次探活。</param>
|
/// <param name="cancellationToken">取消令牌。若应用正在关闭,则中止本次探活。</param>
|
||||||
/// <returns>表示探活完成的任务。</returns>
|
/// <returns>表示探活完成的任务。</returns>
|
||||||
public async Task ProbeSftpOnStartupAsync(CancellationToken cancellationToken)
|
public async Task ProbeFileCheckOnStartupAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
SftpConnectionTestOutcome outcome = await _sftpLookupService
|
FileCheckTestOutcome outcome = await _fileCheckService
|
||||||
.TestConnectionAsync(null, cancellationToken)
|
.TestConnectionAsync(null, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
@@ -196,20 +196,20 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
|||||||
|
|
||||||
if (outcome.IsSuccess)
|
if (outcome.IsSuccess)
|
||||||
{
|
{
|
||||||
_appLogger.LogInformation($"启动时 SFTP 探活成功: {outcome.StatusMessage}", true);
|
_appLogger.LogInformation($"启动时文件校验通道探活成功: {outcome.StatusMessage}", true);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_appLogger.LogWarning($"启动时 SFTP 探活失败: {outcome.StatusMessage}", true);
|
_appLogger.LogWarning($"启动时文件校验通道探活失败: {outcome.StatusMessage}", true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
_appLogger.LogInformation("启动时 SFTP 探活已取消。");
|
_appLogger.LogInformation("启动时文件校验通道探活已取消。");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_appLogger.LogError(ex, "启动时 SFTP 探活异常");
|
_appLogger.LogError(ex, "启动时文件校验通道探活异常");
|
||||||
UpdateSnapshot(snapshot => snapshot.SftpStatus = "异常");
|
UpdateSnapshot(snapshot => snapshot.SftpStatus = "异常");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -582,7 +582,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
|||||||
|
|
||||||
if (!string.IsNullOrEmpty(barcode))
|
if (!string.IsNullOrEmpty(barcode))
|
||||||
{
|
{
|
||||||
await ExecuteSftpFlowAsync(barcode, cancellationToken).ConfigureAwait(false);
|
await ExecuteFileCheckFlowAsync(barcode, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IsFaultLatched())
|
if (IsFaultLatched())
|
||||||
@@ -604,17 +604,15 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
|||||||
/// <param name="cancellationToken">取消令牌。</param>
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
/// <returns>扫码成功则返回条码;否则返回空字符串。</returns>
|
/// <returns>扫码成功则返回条码;否则返回空字符串。</returns>
|
||||||
private async Task<string> ExecuteScanFlowAsync(CancellationToken cancellationToken)
|
private async Task<string> ExecuteScanFlowAsync(CancellationToken cancellationToken)
|
||||||
{
|
|
||||||
for (var attempt = 1; attempt <= _config.Scanner.MaxScanAttempts; attempt++)
|
|
||||||
{
|
{
|
||||||
lock (_stateSyncRoot)
|
lock (_stateSyncRoot)
|
||||||
{
|
{
|
||||||
_scanTryCount = attempt;
|
_scanTryCount = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ApplyProcessStateAsync(
|
await ApplyProcessStateAsync(
|
||||||
WorkflowState.Scanning,
|
WorkflowState.Scanning,
|
||||||
$"正在执行第 {attempt} 次扫码",
|
"正在触发两把 TCP 扫码枪",
|
||||||
_ => { },
|
_ => { },
|
||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
@@ -639,24 +637,13 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
|||||||
|
|
||||||
await ApplyProcessStateAsync(
|
await ApplyProcessStateAsync(
|
||||||
WorkflowState.CheckingSftp,
|
WorkflowState.CheckingSftp,
|
||||||
$"扫码成功: {scanResult.Barcode}",
|
$"双扫码汇总成功: {scanResult.Barcode}",
|
||||||
_ => { },
|
_ => { },
|
||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
_appLogger.LogInformation($"第 {attempt} 次扫码成功,条码={scanResult.Barcode}", true);
|
_appLogger.LogInformation($"双扫码汇总成功,条码={scanResult.Barcode},明细={scanResult.RawMessage}", true);
|
||||||
return scanResult.Barcode;
|
return scanResult.Barcode;
|
||||||
}
|
}
|
||||||
|
|
||||||
_appLogger.LogWarning($"第 {attempt} 次扫码失败: {scanResult.ErrorMessage}", true);
|
|
||||||
if (attempt < _config.Scanner.MaxScanAttempts)
|
|
||||||
{
|
|
||||||
await ApplyProcessStateAsync(
|
|
||||||
WorkflowState.ScanRetrying,
|
|
||||||
$"扫码失败,等待第 {attempt + 1} 次重试",
|
|
||||||
_ => { },
|
|
||||||
cancellationToken).ConfigureAwait(false);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
lock (_stateSyncRoot)
|
lock (_stateSyncRoot)
|
||||||
{
|
{
|
||||||
_resultCode = WorkflowResultCode.NgReleased;
|
_resultCode = WorkflowResultCode.NgReleased;
|
||||||
@@ -664,7 +651,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
|||||||
|
|
||||||
await ApplyProcessStateAsync(
|
await ApplyProcessStateAsync(
|
||||||
WorkflowState.ScanFailedReleased,
|
WorkflowState.ScanFailedReleased,
|
||||||
"扫码连续失败 3 次,进入报警放行",
|
"两把扫码枪均失败,进入报警放行",
|
||||||
_ => { },
|
_ => { },
|
||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
@@ -673,7 +660,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
|||||||
{
|
{
|
||||||
AlarmType = "ScanFailed",
|
AlarmType = "ScanFailed",
|
||||||
AlarmCode = (ushort)AlarmCode.ScanFailed,
|
AlarmCode = (ushort)AlarmCode.ScanFailed,
|
||||||
AlarmMessage = "扫码连续失败 3 次",
|
AlarmMessage = "两把扫码枪均扫码失败",
|
||||||
Barcode = string.Empty,
|
Barcode = string.Empty,
|
||||||
TriggeredAt = DateTimeOffset.Now
|
TriggeredAt = DateTimeOffset.Now
|
||||||
},
|
},
|
||||||
@@ -682,18 +669,15 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
|||||||
return string.Empty;
|
return string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
return string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 执行 SFTP 文件校验流程。
|
/// 执行文件校验流程。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="barcode">条码内容。</param>
|
/// <param name="barcode">条码内容。</param>
|
||||||
/// <param name="cancellationToken">取消令牌。</param>
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
/// <returns>表示流程完成的任务。</returns>
|
/// <returns>表示流程完成的任务。</returns>
|
||||||
private async Task ExecuteSftpFlowAsync(string barcode, CancellationToken cancellationToken)
|
private async Task ExecuteFileCheckFlowAsync(string barcode, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
for (var attempt = 0; attempt <= _config.Sftp.MaxRetryCount; attempt++)
|
for (var attempt = 0; attempt <= _config.FileCheck.MaxRetryCount; attempt++)
|
||||||
{
|
{
|
||||||
lock (_stateSyncRoot)
|
lock (_stateSyncRoot)
|
||||||
{
|
{
|
||||||
@@ -702,11 +686,11 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
|||||||
|
|
||||||
await ApplyProcessStateAsync(
|
await ApplyProcessStateAsync(
|
||||||
WorkflowState.CheckingSftp,
|
WorkflowState.CheckingSftp,
|
||||||
$"正在执行第 {_sftpTryCount} 次 SFTP 校验",
|
$"正在执行第 {_sftpTryCount} 次文件校验",
|
||||||
_ => { },
|
_ => { },
|
||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
var outcome = await _sftpLookupService.CheckFileAsync(barcode, cancellationToken).ConfigureAwait(false);
|
var outcome = await _fileCheckService.CheckFileAsync(barcode, cancellationToken).ConfigureAwait(false);
|
||||||
UpdateSnapshot(snapshot =>
|
UpdateSnapshot(snapshot =>
|
||||||
{
|
{
|
||||||
snapshot.SftpStatus = outcome.ConnectionSucceeded ? "在线" : (outcome.IsSystemError ? "异常" : "未验证");
|
snapshot.SftpStatus = outcome.ConnectionSucceeded ? "在线" : (outcome.IsSystemError ? "异常" : "未验证");
|
||||||
@@ -724,7 +708,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
|||||||
$"文件已找到: {outcome.MatchedFilePath}",
|
$"文件已找到: {outcome.MatchedFilePath}",
|
||||||
_ => { },
|
_ => { },
|
||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
_appLogger.LogInformation($"SFTP 文件命中成功: {outcome.MatchedFilePath}", true);
|
_appLogger.LogInformation($"文件校验命中成功: {outcome.MatchedFilePath}", true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -740,15 +724,15 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attempt < _config.Sftp.MaxRetryCount)
|
if (attempt < _config.FileCheck.MaxRetryCount)
|
||||||
{
|
{
|
||||||
_appLogger.LogWarning($"第 {_sftpTryCount} 次 SFTP 未命中: {outcome.ErrorMessage}", true);
|
_appLogger.LogWarning($"第 {_sftpTryCount} 次文件校验未命中: {outcome.ErrorMessage}", true);
|
||||||
await ApplyProcessStateAsync(
|
await ApplyProcessStateAsync(
|
||||||
WorkflowState.WaitingSftpRetry,
|
WorkflowState.WaitingSftpRetry,
|
||||||
$"文件未命中,{_config.Sftp.RetryIntervalSeconds} 秒后重试",
|
$"文件未命中,{_config.FileCheck.RetryIntervalSeconds} 秒后重试",
|
||||||
_ => { },
|
_ => { },
|
||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
await Task.Delay(TimeSpan.FromSeconds(_config.Sftp.RetryIntervalSeconds), cancellationToken).ConfigureAwait(false);
|
await Task.Delay(TimeSpan.FromSeconds(_config.FileCheck.RetryIntervalSeconds), cancellationToken).ConfigureAwait(false);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -762,7 +746,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
|||||||
"文件未找到超时,按规则放行",
|
"文件未找到超时,按规则放行",
|
||||||
_ => { },
|
_ => { },
|
||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
_appLogger.LogWarning($"SFTP 文件未命中达到上限,条码={barcode}", true);
|
_appLogger.LogWarning($"文件校验未命中达到上限,条码={barcode}", true);
|
||||||
|
|
||||||
if (_config.Andon.EnableFileNotFoundAlarm)
|
if (_config.Andon.EnableFileNotFoundAlarm)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -154,9 +154,9 @@ public interface IScannerService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 定义 SFTP 校验能力。
|
/// 定义统一文件校验能力。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface ISftpLookupService
|
public interface IFileCheckService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 按条码检查目标文件是否存在。
|
/// 按条码检查目标文件是否存在。
|
||||||
@@ -164,15 +164,43 @@ public interface ISftpLookupService
|
|||||||
/// <param name="barcode">条码内容。</param>
|
/// <param name="barcode">条码内容。</param>
|
||||||
/// <param name="cancellationToken">取消令牌。</param>
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
/// <returns>文件校验结果。</returns>
|
/// <returns>文件校验结果。</returns>
|
||||||
Task<SftpCheckOutcome> CheckFileAsync(string barcode, CancellationToken cancellationToken);
|
Task<FileCheckOutcome> CheckFileAsync(string barcode, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 测试当前或指定配置下的 SFTP 连接可用性。
|
/// 测试当前或指定配置下的文件校验通道可用性。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="options">待测试的 SFTP 配置;传入 <see langword="null"/> 时使用当前应用配置。</param>
|
/// <param name="config">待测试的根配置;传入 <see langword="null"/> 时使用当前应用配置。</param>
|
||||||
/// <param name="cancellationToken">取消令牌。若在连接过程中取消,将终止本次测试且不更新状态。</param>
|
/// <param name="cancellationToken">取消令牌。若在连接过程中取消,将终止本次测试且不更新状态。</param>
|
||||||
/// <returns>连接测试结果。</returns>
|
/// <returns>通道测试结果。</returns>
|
||||||
Task<SftpConnectionTestOutcome> TestConnectionAsync(SftpOptions? options, CancellationToken cancellationToken);
|
Task<FileCheckTestOutcome> TestConnectionAsync(AppConfig? config, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 定义单个文件校验通道提供程序。
|
||||||
|
/// </summary>
|
||||||
|
public interface IFileCheckProvider
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取当前提供程序对应的通道类型。
|
||||||
|
/// </summary>
|
||||||
|
FileCheckProvider Provider { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 按条码检查目标文件是否存在。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="barcode">条码内容。</param>
|
||||||
|
/// <param name="config">根配置对象。</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
|
/// <returns>文件校验结果。</returns>
|
||||||
|
Task<FileCheckOutcome> CheckFileAsync(string barcode, AppConfig config, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 测试当前通道配置是否可用。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="config">根配置对象。</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
|
/// <returns>通道测试结果。</returns>
|
||||||
|
Task<FileCheckTestOutcome> TestConnectionAsync(AppConfig config, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
private string _scannerStatus = "未验证";
|
private string _scannerStatus = "未验证";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取或设置 SFTP 状态文本。
|
/// 获取或设置文件校验通道状态文本。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _sftpStatus = "未验证";
|
private string _sftpStatus = "未验证";
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using AxiOmron.PcbCheck.Options;
|
|||||||
using AxiOmron.PcbCheck.Services.Interfaces;
|
using AxiOmron.PcbCheck.Services.Interfaces;
|
||||||
using AxiOmron.PcbCheck.Models;
|
using AxiOmron.PcbCheck.Models;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace AxiOmron.PcbCheck.ViewModels;
|
namespace AxiOmron.PcbCheck.ViewModels;
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ namespace AxiOmron.PcbCheck.ViewModels;
|
|||||||
public partial class SystemSettingViewModel : ObservableObject
|
public partial class SystemSettingViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
private readonly IAppConfigService _appConfigService;
|
private readonly IAppConfigService _appConfigService;
|
||||||
private readonly ISftpLookupService _sftpLookupService;
|
private readonly IFileCheckService _fileCheckService;
|
||||||
private readonly IAppStateStore _appStateStore;
|
private readonly IAppStateStore _appStateStore;
|
||||||
private readonly IDispatcherService _dispatcherService;
|
private readonly IDispatcherService _dispatcherService;
|
||||||
|
|
||||||
@@ -21,22 +22,23 @@ public partial class SystemSettingViewModel : ObservableObject
|
|||||||
/// 初始化系统设置视图模型。
|
/// 初始化系统设置视图模型。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="appConfigService">配置读写服务。</param>
|
/// <param name="appConfigService">配置读写服务。</param>
|
||||||
/// <param name="sftpLookupService">SFTP 连接测试服务。</param>
|
/// <param name="fileCheckService">文件校验通道测试服务。</param>
|
||||||
/// <param name="appStateStore">运行态快照存储。</param>
|
/// <param name="appStateStore">运行态快照存储。</param>
|
||||||
/// <param name="dispatcherService">UI 线程调度服务。</param>
|
/// <param name="dispatcherService">UI 线程调度服务。</param>
|
||||||
public SystemSettingViewModel(
|
public SystemSettingViewModel(
|
||||||
IAppConfigService appConfigService,
|
IAppConfigService appConfigService,
|
||||||
ISftpLookupService sftpLookupService,
|
IFileCheckService fileCheckService,
|
||||||
IAppStateStore appStateStore,
|
IAppStateStore appStateStore,
|
||||||
IDispatcherService dispatcherService)
|
IDispatcherService dispatcherService)
|
||||||
{
|
{
|
||||||
_appConfigService = appConfigService ?? throw new ArgumentNullException(nameof(appConfigService));
|
_appConfigService = appConfigService ?? throw new ArgumentNullException(nameof(appConfigService));
|
||||||
_sftpLookupService = sftpLookupService ?? throw new ArgumentNullException(nameof(sftpLookupService));
|
_fileCheckService = fileCheckService ?? throw new ArgumentNullException(nameof(fileCheckService));
|
||||||
_appStateStore = appStateStore ?? throw new ArgumentNullException(nameof(appStateStore));
|
_appStateStore = appStateStore ?? throw new ArgumentNullException(nameof(appStateStore));
|
||||||
_dispatcherService = dispatcherService ?? throw new ArgumentNullException(nameof(dispatcherService));
|
_dispatcherService = dispatcherService ?? throw new ArgumentNullException(nameof(dispatcherService));
|
||||||
EditableConfig = _appConfigService.Load();
|
EditableConfig = _appConfigService.Load();
|
||||||
ConfigPath = _appConfigService.GetConfigPath();
|
ConfigPath = _appConfigService.GetConfigPath();
|
||||||
PlcMonitorItems = new ObservableCollection<PlcMonitorItem>();
|
PlcMonitorItems = new ObservableCollection<PlcMonitorItem>();
|
||||||
|
FileCheckProviders = Enum.GetValues<FileCheckProvider>();
|
||||||
ApplySnapshot(_appStateStore.GetSnapshot());
|
ApplySnapshot(_appStateStore.GetSnapshot());
|
||||||
_appStateStore.SnapshotChanged += OnSnapshotChanged;
|
_appStateStore.SnapshotChanged += OnSnapshotChanged;
|
||||||
StatusMessage = "已加载配置。";
|
StatusMessage = "已加载配置。";
|
||||||
@@ -47,6 +49,11 @@ public partial class SystemSettingViewModel : ObservableObject
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public ObservableCollection<PlcMonitorItem> PlcMonitorItems { get; }
|
public ObservableCollection<PlcMonitorItem> PlcMonitorItems { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取文件校验通道可选项。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<FileCheckProvider> FileCheckProviders { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取或设置可编辑配置对象。
|
/// 获取或设置可编辑配置对象。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -66,11 +73,11 @@ public partial class SystemSettingViewModel : ObservableObject
|
|||||||
private string _configPath = string.Empty;
|
private string _configPath = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取或设置当前是否正在测试 SFTP 连接。
|
/// 获取或设置当前是否正在测试文件校验通道。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[NotifyCanExecuteChangedFor(nameof(TestSftpConnectionCommand))]
|
[NotifyCanExecuteChangedFor(nameof(TestFileCheckConnectionCommand))]
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _isTestingSftpConnection;
|
private bool _isTestingFileCheckConnection;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 保存当前配置。
|
/// 保存当前配置。
|
||||||
@@ -109,32 +116,32 @@ public partial class SystemSettingViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 测试当前界面配置下的 SFTP 连接。
|
/// 测试当前界面配置下的文件校验通道。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>表示测试完成的任务。</returns>
|
/// <returns>表示测试完成的任务。</returns>
|
||||||
[RelayCommand(CanExecute = nameof(CanTestSftpConnection))]
|
[RelayCommand(CanExecute = nameof(CanTestFileCheckConnection))]
|
||||||
private async Task TestSftpConnectionAsync()
|
private async Task TestFileCheckConnectionAsync()
|
||||||
{
|
{
|
||||||
IsTestingSftpConnection = true;
|
IsTestingFileCheckConnection = true;
|
||||||
StatusMessage = "正在测试 SFTP 连接...";
|
StatusMessage = "正在测试文件校验通道...";
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
SftpConnectionTestOutcome outcome = await _sftpLookupService
|
FileCheckTestOutcome outcome = await _fileCheckService
|
||||||
.TestConnectionAsync(EditableConfig.Sftp, CancellationToken.None);
|
.TestConnectionAsync(EditableConfig, CancellationToken.None);
|
||||||
|
|
||||||
StatusMessage = outcome.StatusMessage;
|
StatusMessage = outcome.StatusMessage;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
IsTestingSftpConnection = false;
|
IsTestingFileCheckConnection = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 判断当前是否允许执行 SFTP 测试连接。
|
/// 判断当前是否允许执行文件校验通道测试。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private bool CanTestSftpConnection => !IsTestingSftpConnection;
|
private bool CanTestFileCheckConnection => !IsTestingFileCheckConnection;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 将运行态快照中的 PLC 监控数据同步到当前视图模型。
|
/// 将运行态快照中的 PLC 监控数据同步到当前视图模型。
|
||||||
|
|||||||
@@ -570,7 +570,7 @@
|
|||||||
<StackPanel Grid.Row="0" Orientation="Horizontal">
|
<StackPanel Grid.Row="0" Orientation="Horizontal">
|
||||||
<Border Style="{StaticResource CompactAccentBarStyle}" />
|
<Border Style="{StaticResource CompactAccentBarStyle}" />
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<TextBlock Text="SFTP" Style="{StaticResource ModernCardTitleStyle}" />
|
<TextBlock Text="文件校验" Style="{StaticResource ModernCardTitleStyle}" />
|
||||||
<TextBlock Text="文件校验通道" Style="{StaticResource ModernCardSubtitleStyle}" />
|
<TextBlock Text="文件校验通道" Style="{StaticResource ModernCardSubtitleStyle}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -58,7 +58,8 @@
|
|||||||
<StackPanel Margin="16">
|
<StackPanel Margin="16">
|
||||||
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="PLC 主机" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.Host, UpdateSourceTrigger=PropertyChanged}" />
|
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="PLC 主机" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.Host, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="端口" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.Port, UpdateSourceTrigger=PropertyChanged}" />
|
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="端口" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.Port, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="从站号" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.UnitId, UpdateSourceTrigger=PropertyChanged}" />
|
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="目标单元" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.UnitAddress, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
|
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="上位机节点" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.SourceNodeAddress, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="轮询周期(ms)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.PollIntervalMs, UpdateSourceTrigger=PropertyChanged}" />
|
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="轮询周期(ms)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.PollIntervalMs, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="连接超时(ms)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.ConnectTimeoutMs, UpdateSourceTrigger=PropertyChanged}" />
|
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="连接超时(ms)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.ConnectTimeoutMs, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="放行脉冲(ms)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.ReleasePulseMs, UpdateSourceTrigger=PropertyChanged}" />
|
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="放行脉冲(ms)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Plc.ReleasePulseMs, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
@@ -70,36 +71,48 @@
|
|||||||
<TabItem Header="扫码枪">
|
<TabItem Header="扫码枪">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Margin="16">
|
<StackPanel Margin="16">
|
||||||
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="串口号" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.PortName, UpdateSourceTrigger=PropertyChanged}" />
|
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="扫码枪1名称" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.Scanner1.Name, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="波特率" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.BaudRate, UpdateSourceTrigger=PropertyChanged}" />
|
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="扫码枪1主机" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.Scanner1.Host, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="数据位" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.DataBits, UpdateSourceTrigger=PropertyChanged}" />
|
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="扫码枪1端口" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.Scanner1.Port, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="校验位" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.Parity, UpdateSourceTrigger=PropertyChanged}" />
|
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="扫码枪2名称" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.Scanner2.Name, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="停止位" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.StopBits, UpdateSourceTrigger=PropertyChanged}" />
|
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="扫码枪2主机" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.Scanner2.Host, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="超时(ms)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.ReadTimeoutMs, UpdateSourceTrigger=PropertyChanged}" />
|
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="扫码枪2端口" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.Scanner2.Port, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
|
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="连接超时(ms)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.ConnectTimeoutMs, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
|
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="读取超时(ms)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.ReadTimeoutMs, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="触发命令" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.TriggerCommand, UpdateSourceTrigger=PropertyChanged}" />
|
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="触发命令" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.TriggerCommand, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="返回结束符" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.ResponseTerminator, UpdateSourceTrigger=PropertyChanged}" />
|
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="返回结束符" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Scanner.ResponseTerminator, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
|
<CheckBox Content="并发触发两把扫码枪" IsChecked="{Binding EditableConfig.Scanner.TriggerInParallel}" Margin="0,6,0,0" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
<TabItem Header="SFTP">
|
<TabItem Header="文件校验">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Margin="16">
|
<StackPanel Margin="16">
|
||||||
|
<ComboBox Style="{StaticResource ComboBoxExtend}"
|
||||||
|
hc:InfoElement.Title="校验通道"
|
||||||
|
hc:InfoElement.TitlePlacement="Left"
|
||||||
|
Margin="0,6,0,0"
|
||||||
|
ItemsSource="{Binding FileCheckProviders}"
|
||||||
|
SelectedItem="{Binding EditableConfig.FileCheck.Provider, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
|
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="文件名模板" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.FileCheck.FileNamePattern, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
|
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="重试间隔(秒)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.FileCheck.RetryIntervalSeconds, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
|
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="最大重试次数" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.FileCheck.MaxRetryCount, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
|
<TextBlock Text="SFTP 配置" Margin="0,16,0,2" FontWeight="SemiBold" Foreground="#1F2937" />
|
||||||
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="主机" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.Host, UpdateSourceTrigger=PropertyChanged}" />
|
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="主机" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.Host, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="端口" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.Port, UpdateSourceTrigger=PropertyChanged}" />
|
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="端口" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.Port, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="用户名" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.Username, UpdateSourceTrigger=PropertyChanged}" />
|
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="用户名" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.Username, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="密码" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.Password, UpdateSourceTrigger=PropertyChanged}" />
|
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="密码" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.Password, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="私钥路径" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.PrivateKeyPath, UpdateSourceTrigger=PropertyChanged}" />
|
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="私钥路径" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.PrivateKeyPath, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="根目录" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.RootPath, UpdateSourceTrigger=PropertyChanged}" />
|
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="SFTP 根目录" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.RootPath, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="文件名模板" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.FileNamePattern, UpdateSourceTrigger=PropertyChanged}" />
|
<TextBlock Text="本地目录配置" Margin="0,16,0,2" FontWeight="SemiBold" Foreground="#1F2937" />
|
||||||
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="重试间隔(秒)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.RetryIntervalSeconds, UpdateSourceTrigger=PropertyChanged}" />
|
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="本地根目录" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.FileCheck.Local.RootPath, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="最大重试次数" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.MaxRetryCount, UpdateSourceTrigger=PropertyChanged}" />
|
|
||||||
<StackPanel Orientation="Horizontal" Margin="0,12,0,0">
|
<StackPanel Orientation="Horizontal" Margin="0,12,0,0">
|
||||||
<Button Style="{StaticResource ButtonDefault}"
|
<Button Style="{StaticResource ButtonDefault}"
|
||||||
Padding="18,8"
|
Padding="18,8"
|
||||||
MinHeight="36"
|
MinHeight="36"
|
||||||
Content="测试连接"
|
Content="测试校验通道"
|
||||||
Command="{Binding TestSftpConnectionCommand}" />
|
Command="{Binding TestFileCheckConnectionCommand}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|||||||
@@ -1,9 +1,28 @@
|
|||||||
{
|
{
|
||||||
"Plc": {
|
"Plc": {
|
||||||
"Host": "127.0.0.1"
|
"Host": "127.0.0.1",
|
||||||
|
"Port": 9600
|
||||||
},
|
},
|
||||||
"Scanner": {
|
"Scanner": {
|
||||||
"PortName": "COM3"
|
"Scanner1": {
|
||||||
|
"Name": "扫码枪1",
|
||||||
|
"Host": "127.0.0.1",
|
||||||
|
"Port": 9001
|
||||||
|
},
|
||||||
|
"Scanner2": {
|
||||||
|
"Name": "扫码枪2",
|
||||||
|
"Host": "127.0.0.1",
|
||||||
|
"Port": 9002
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"FileCheck": {
|
||||||
|
"Provider": "Sftp",
|
||||||
|
"FileNamePattern": "${barcode}.txt",
|
||||||
|
"RetryIntervalSeconds": 2,
|
||||||
|
"MaxRetryCount": 3,
|
||||||
|
"Local": {
|
||||||
|
"RootPath": "D:\\PcbCheck\\Files"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Sftp": {
|
"Sftp": {
|
||||||
"Host": "127.0.0.1",
|
"Host": "127.0.0.1",
|
||||||
|
|||||||
@@ -1,35 +1,51 @@
|
|||||||
{
|
{
|
||||||
"Plc": {
|
"Plc": {
|
||||||
"Host": "127.0.0.1",
|
"Host": "127.0.0.1",
|
||||||
"Port": 502,
|
"Port": 9600,
|
||||||
"UnitId": 1,
|
"UnitAddress": 0,
|
||||||
|
"SourceNodeAddress": 25,
|
||||||
"PollIntervalMs": 200,
|
"PollIntervalMs": 200,
|
||||||
"ConnectTimeoutMs": 3000,
|
"ConnectTimeoutMs": 3000,
|
||||||
"ReleasePulseMs": 500,
|
"ReleasePulseMs": 500,
|
||||||
"ReleaseAckTimeoutMs": 2000,
|
"ReleaseAckTimeoutMs": 2000,
|
||||||
"Inputs": {
|
"Inputs": {
|
||||||
"PcbArrived": 0,
|
"PcbArrived": 1001,
|
||||||
"PlcReset": 1,
|
"PlcReset": 1002,
|
||||||
"PlcAckRelease": 2
|
"PlcAckRelease": 1003
|
||||||
},
|
},
|
||||||
"Outputs": {
|
"Outputs": {
|
||||||
"PcBusy": 51,
|
"PcBusy": 1051,
|
||||||
"ReleasePermit": 52
|
"ReleasePermit": 1052
|
||||||
},
|
},
|
||||||
"Registers": {
|
"Registers": {
|
||||||
"ResultCode": 0
|
"ResultCode": 1100
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Scanner": {
|
"Scanner": {
|
||||||
"PortName": "COM1",
|
"Scanner1": {
|
||||||
"BaudRate": 9600,
|
"Name": "扫码枪1",
|
||||||
"DataBits": 8,
|
"Host": "127.0.0.1",
|
||||||
"Parity": "None",
|
"Port": 9001
|
||||||
"StopBits": "One",
|
},
|
||||||
|
"Scanner2": {
|
||||||
|
"Name": "扫码枪2",
|
||||||
|
"Host": "127.0.0.1",
|
||||||
|
"Port": 9002
|
||||||
|
},
|
||||||
|
"ConnectTimeoutMs": 3000,
|
||||||
"ReadTimeoutMs": 3000,
|
"ReadTimeoutMs": 3000,
|
||||||
"TriggerCommand": "SCAN\\r",
|
"TriggerCommand": "T",
|
||||||
"ResponseTerminator": "\\r",
|
"ResponseTerminator": "\\r",
|
||||||
"MaxScanAttempts": 3
|
"TriggerInParallel": true
|
||||||
|
},
|
||||||
|
"FileCheck": {
|
||||||
|
"Provider": "Sftp",
|
||||||
|
"FileNamePattern": "${barcode}.txt",
|
||||||
|
"RetryIntervalSeconds": 2,
|
||||||
|
"MaxRetryCount": 3,
|
||||||
|
"Local": {
|
||||||
|
"RootPath": "D:\\PcbCheck\\Files"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Sftp": {
|
"Sftp": {
|
||||||
"Host": "127.0.0.1",
|
"Host": "127.0.0.1",
|
||||||
|
|||||||
48
tests/AxiOmron.PcbCheck.Tests/AppConfigServiceTests.cs
Normal file
48
tests/AxiOmron.PcbCheck.Tests/AppConfigServiceTests.cs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
using AxiOmron.PcbCheck.Options;
|
||||||
|
using AxiOmron.PcbCheck.Services.Implementations;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AxiOmron.PcbCheck.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证应用配置文件读写行为。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AppConfigServiceTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 配置文件使用字符串保存文件校验通道时,应能还原为枚举值。
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldDeserializeFileCheckProvider_WhenProviderIsString()
|
||||||
|
{
|
||||||
|
var service = new AppConfigService();
|
||||||
|
string configPath = service.GetConfigPath();
|
||||||
|
string? originalJson = File.Exists(configPath) ? File.ReadAllText(configPath) : null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllText(configPath, """
|
||||||
|
{
|
||||||
|
"FileCheck": {
|
||||||
|
"Provider": "Sftp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
AppConfig config = service.Load();
|
||||||
|
|
||||||
|
Assert.Equal(FileCheckProvider.Sftp, config.FileCheck.Provider);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (originalJson is null)
|
||||||
|
{
|
||||||
|
File.Delete(configPath);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
File.WriteAllText(configPath, originalJson);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using AxiOmron.PcbCheck.Models;
|
||||||
|
using AxiOmron.PcbCheck.Services.Implementations;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AxiOmron.PcbCheck.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证双扫码枪结果汇总规则。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DualScannerResultMergerTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 任一扫码枪成功时,应使用成功条码继续后续 SFTP 流程。
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Merge_ShouldUseSecondBarcode_WhenOnlySecondScannerSucceeds()
|
||||||
|
{
|
||||||
|
var first = new ScanOperationResult
|
||||||
|
{
|
||||||
|
IsSuccess = false,
|
||||||
|
DeviceConnected = true,
|
||||||
|
ErrorMessage = "扫码枪1超时"
|
||||||
|
};
|
||||||
|
var second = new ScanOperationResult
|
||||||
|
{
|
||||||
|
IsSuccess = true,
|
||||||
|
DeviceConnected = true,
|
||||||
|
Barcode = "PCB-SECOND"
|
||||||
|
};
|
||||||
|
|
||||||
|
ScanOperationResult result = DualScannerResultMerger.Merge(first, second);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal("PCB-SECOND", result.Barcode);
|
||||||
|
Assert.Contains("扫码枪2", result.RawMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 两把扫码枪都成功但内容不一致时,应优先使用扫码枪1条码并保留不一致说明。
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Merge_ShouldPreferFirstBarcode_WhenBothScannersReturnDifferentBarcodes()
|
||||||
|
{
|
||||||
|
var first = new ScanOperationResult
|
||||||
|
{
|
||||||
|
IsSuccess = true,
|
||||||
|
DeviceConnected = true,
|
||||||
|
Barcode = "PCB-FIRST"
|
||||||
|
};
|
||||||
|
var second = new ScanOperationResult
|
||||||
|
{
|
||||||
|
IsSuccess = true,
|
||||||
|
DeviceConnected = true,
|
||||||
|
Barcode = "PCB-SECOND"
|
||||||
|
};
|
||||||
|
|
||||||
|
ScanOperationResult result = DualScannerResultMerger.Merge(first, second);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal("PCB-FIRST", result.Barcode);
|
||||||
|
Assert.Contains("不一致", result.ErrorMessage);
|
||||||
|
Assert.Contains("PCB-SECOND", result.RawMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
183
tests/AxiOmron.PcbCheck.Tests/LocalFileCheckProviderTests.cs
Normal file
183
tests/AxiOmron.PcbCheck.Tests/LocalFileCheckProviderTests.cs
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
using AxiOmron.PcbCheck.Models;
|
||||||
|
using AxiOmron.PcbCheck.Options;
|
||||||
|
using AxiOmron.PcbCheck.Services.Implementations;
|
||||||
|
using AxiOmron.PcbCheck.Services.Interfaces;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AxiOmron.PcbCheck.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证本地磁盘文件校验提供程序的目录检查与文件匹配行为。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LocalFileCheckProviderTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _rootPath;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化临时测试目录。
|
||||||
|
/// </summary>
|
||||||
|
public LocalFileCheckProviderTests()
|
||||||
|
{
|
||||||
|
_rootPath = Path.Combine(Path.GetTempPath(), "AxiOmronLocalFileCheckTests", Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(_rootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 清理临时测试目录。
|
||||||
|
/// </summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_rootPath))
|
||||||
|
{
|
||||||
|
Directory.Delete(_rootPath, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 本地根目录为空时,应返回配置异常。
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>异步测试任务。</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task TestConnectionAsync_ShouldReturnConfigurationError_WhenRootPathIsEmpty()
|
||||||
|
{
|
||||||
|
var provider = CreateProvider();
|
||||||
|
var config = new AppConfig
|
||||||
|
{
|
||||||
|
FileCheck = new FileCheckOptions
|
||||||
|
{
|
||||||
|
Local = new LocalFileCheckOptions { RootPath = string.Empty }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
FileCheckTestOutcome outcome = await provider.TestConnectionAsync(config, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(outcome.IsConfigurationError);
|
||||||
|
Assert.Contains("本地文件校验目录未配置", outcome.StatusMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 本地根目录不存在时,应返回配置异常。
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>异步测试任务。</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task TestConnectionAsync_ShouldReturnConfigurationError_WhenRootPathDoesNotExist()
|
||||||
|
{
|
||||||
|
var provider = CreateProvider();
|
||||||
|
var config = CreateConfig(Path.Combine(_rootPath, "missing"));
|
||||||
|
|
||||||
|
FileCheckTestOutcome outcome = await provider.TestConnectionAsync(config, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(outcome.IsConfigurationError);
|
||||||
|
Assert.Contains("本地文件校验目录不存在", outcome.StatusMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 文件名精确匹配模板时,应返回命中文件路径。
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>异步测试任务。</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task CheckFileAsync_ShouldMatchExactFileName()
|
||||||
|
{
|
||||||
|
string filePath = Path.Combine(_rootPath, "PCB-001.txt");
|
||||||
|
await File.WriteAllTextAsync(filePath, "ok");
|
||||||
|
var provider = CreateProvider();
|
||||||
|
|
||||||
|
FileCheckOutcome outcome = await provider.CheckFileAsync("PCB-001", CreateConfig(_rootPath), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(outcome.Exists);
|
||||||
|
Assert.Equal(filePath, outcome.MatchedFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 文件名包含条码时,应返回命中文件路径。
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>异步测试任务。</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task CheckFileAsync_ShouldMatchFileNameContainingBarcode()
|
||||||
|
{
|
||||||
|
string filePath = Path.Combine(_rootPath, "prefix-PCB-002-result.dat");
|
||||||
|
await File.WriteAllTextAsync(filePath, "ok");
|
||||||
|
var provider = CreateProvider();
|
||||||
|
|
||||||
|
FileCheckOutcome outcome = await provider.CheckFileAsync("PCB-002", CreateConfig(_rootPath), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(outcome.Exists);
|
||||||
|
Assert.Equal(filePath, outcome.MatchedFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 文件名模板包含通配符时,应按通配符匹配。
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>异步测试任务。</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task CheckFileAsync_ShouldMatchWildcardPattern()
|
||||||
|
{
|
||||||
|
string filePath = Path.Combine(_rootPath, "PCB-003_result.txt");
|
||||||
|
await File.WriteAllTextAsync(filePath, "ok");
|
||||||
|
var provider = CreateProvider();
|
||||||
|
AppConfig config = CreateConfig(_rootPath);
|
||||||
|
config.FileCheck.FileNamePattern = "${barcode}_*.txt";
|
||||||
|
|
||||||
|
FileCheckOutcome outcome = await provider.CheckFileAsync("PCB-003", config, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(outcome.Exists);
|
||||||
|
Assert.Equal(filePath, outcome.MatchedFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建本地文件校验提供程序。
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>本地文件校验提供程序。</returns>
|
||||||
|
private static LocalFileCheckProvider CreateProvider()
|
||||||
|
{
|
||||||
|
return new LocalFileCheckProvider(new FakeAppLogger<LocalFileCheckProvider>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建本地文件校验测试配置。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="rootPath">本地根目录。</param>
|
||||||
|
/// <returns>测试配置。</returns>
|
||||||
|
private static AppConfig CreateConfig(string rootPath)
|
||||||
|
{
|
||||||
|
return new AppConfig
|
||||||
|
{
|
||||||
|
FileCheck = new FileCheckOptions
|
||||||
|
{
|
||||||
|
Provider = FileCheckProvider.Local,
|
||||||
|
FileNamePattern = "${barcode}.txt",
|
||||||
|
Local = new LocalFileCheckOptions
|
||||||
|
{
|
||||||
|
RootPath = rootPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 提供本地文件校验测试用的空日志实现。
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TCategoryName">日志分类类型。</typeparam>
|
||||||
|
private sealed class FakeAppLogger<TCategoryName> : IAppLogger<TCategoryName>
|
||||||
|
{
|
||||||
|
public void LogError(string message, bool showInUi = true, params object?[] args)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void LogError(Exception exception, string message, bool showInUi = true, params object?[] args)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void LogInformation(string message, bool showInUi = true, params object?[] args)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void LogWarning(string message, bool showInUi = true, params object?[] args)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void LogWarning(Exception exception, string message, bool showInUi = true, params object?[] args)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,58 +11,58 @@ using Xunit;
|
|||||||
namespace AxiOmron.PcbCheck.Tests;
|
namespace AxiOmron.PcbCheck.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 验证系统设置页视图模型中的 SFTP 测试连接行为。
|
/// 验证系统设置页视图模型中的文件校验通道测试行为。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SystemSettingViewModelTests
|
public sealed class SystemSettingViewModelTests
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 当 SFTP 测试成功时,应更新成功状态文本。
|
/// 当文件校验通道测试成功时,应更新成功状态文本。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>异步测试任务。</returns>
|
/// <returns>异步测试任务。</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TestSftpConnectionAsync_ShouldReportSuccess_WhenConnectionSucceeds()
|
public async Task TestFileCheckConnectionAsync_ShouldReportSuccess_WhenConnectionSucceeds()
|
||||||
{
|
{
|
||||||
var configService = new FakeAppConfigService();
|
var configService = new FakeAppConfigService();
|
||||||
var stateStore = new AppStateStore();
|
var stateStore = new AppStateStore();
|
||||||
var dispatcherService = new ImmediateDispatcherService();
|
var dispatcherService = new ImmediateDispatcherService();
|
||||||
var sftpLookupService = new FakeSftpLookupService
|
var fileCheckService = new FakeFileCheckService
|
||||||
{
|
{
|
||||||
TestOutcome = new SftpConnectionTestOutcome
|
TestOutcome = new FileCheckTestOutcome
|
||||||
{
|
{
|
||||||
IsSuccess = true,
|
IsSuccess = true,
|
||||||
RootPathAccessible = true,
|
RootPathAccessible = true,
|
||||||
StatusMessage = "SFTP 连接成功,根目录可访问。"
|
StatusMessage = "SFTP 连接成功,根目录可访问。"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
var viewModel = new SystemSettingViewModel(configService, sftpLookupService, stateStore, dispatcherService);
|
var viewModel = new SystemSettingViewModel(configService, fileCheckService, stateStore, dispatcherService);
|
||||||
|
|
||||||
await viewModel.TestSftpConnectionCommand.ExecuteAsync(null);
|
await viewModel.TestFileCheckConnectionCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
Assert.Equal("SFTP 连接成功,根目录可访问。", viewModel.StatusMessage);
|
Assert.Equal("SFTP 连接成功,根目录可访问。", viewModel.StatusMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 当 SFTP 测试失败时,应将失败原因展示到状态文本。
|
/// 当文件校验通道测试失败时,应将失败原因展示到状态文本。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>异步测试任务。</returns>
|
/// <returns>异步测试任务。</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TestSftpConnectionAsync_ShouldReportFailure_WhenConnectionFails()
|
public async Task TestFileCheckConnectionAsync_ShouldReportFailure_WhenConnectionFails()
|
||||||
{
|
{
|
||||||
var configService = new FakeAppConfigService();
|
var configService = new FakeAppConfigService();
|
||||||
var stateStore = new AppStateStore();
|
var stateStore = new AppStateStore();
|
||||||
var dispatcherService = new ImmediateDispatcherService();
|
var dispatcherService = new ImmediateDispatcherService();
|
||||||
var sftpLookupService = new FakeSftpLookupService
|
var fileCheckService = new FakeFileCheckService
|
||||||
{
|
{
|
||||||
TestOutcome = new SftpConnectionTestOutcome
|
TestOutcome = new FileCheckTestOutcome
|
||||||
{
|
{
|
||||||
IsSuccess = false,
|
IsSuccess = false,
|
||||||
IsSystemError = true,
|
IsSystemError = true,
|
||||||
StatusMessage = "SFTP 连接失败: timeout"
|
StatusMessage = "SFTP 连接失败: timeout"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
var viewModel = new SystemSettingViewModel(configService, sftpLookupService, stateStore, dispatcherService);
|
var viewModel = new SystemSettingViewModel(configService, fileCheckService, stateStore, dispatcherService);
|
||||||
|
|
||||||
await viewModel.TestSftpConnectionCommand.ExecuteAsync(null);
|
await viewModel.TestFileCheckConnectionCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
Assert.Equal("SFTP 连接失败: timeout", viewModel.StatusMessage);
|
Assert.Equal("SFTP 连接失败: timeout", viewModel.StatusMessage);
|
||||||
}
|
}
|
||||||
@@ -87,7 +87,7 @@ public sealed class SystemSettingViewModelTests
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
var viewModel = new SystemSettingViewModel(configService, new FakeSftpLookupService(), stateStore, dispatcherService);
|
var viewModel = new SystemSettingViewModel(configService, new FakeFileCheckService(), stateStore, dispatcherService);
|
||||||
|
|
||||||
Assert.Single(viewModel.PlcMonitorItems);
|
Assert.Single(viewModel.PlcMonitorItems);
|
||||||
Assert.Equal("PcbArrived", viewModel.PlcMonitorItems[0].Name);
|
Assert.Equal("PcbArrived", viewModel.PlcMonitorItems[0].Name);
|
||||||
@@ -104,7 +104,7 @@ public sealed class SystemSettingViewModelTests
|
|||||||
var configService = new FakeAppConfigService();
|
var configService = new FakeAppConfigService();
|
||||||
var stateStore = new AppStateStore();
|
var stateStore = new AppStateStore();
|
||||||
var dispatcherService = new CapturedDispatcherService(Dispatcher.CurrentDispatcher);
|
var dispatcherService = new CapturedDispatcherService(Dispatcher.CurrentDispatcher);
|
||||||
var viewModel = new SystemSettingViewModel(configService, new FakeSftpLookupService(), stateStore, dispatcherService);
|
var viewModel = new SystemSettingViewModel(configService, new FakeFileCheckService(), stateStore, dispatcherService);
|
||||||
_ = CollectionViewSource.GetDefaultView(viewModel.PlcMonitorItems);
|
_ = CollectionViewSource.GetDefaultView(viewModel.PlcMonitorItems);
|
||||||
|
|
||||||
Exception? capturedException = null;
|
Exception? capturedException = null;
|
||||||
@@ -158,7 +158,7 @@ public sealed class SystemSettingViewModelTests
|
|||||||
var stateStore = new AppStateStore();
|
var stateStore = new AppStateStore();
|
||||||
var viewModel = new SystemSettingViewModel(
|
var viewModel = new SystemSettingViewModel(
|
||||||
configService,
|
configService,
|
||||||
new FakeSftpLookupService(),
|
new FakeFileCheckService(),
|
||||||
stateStore,
|
stateStore,
|
||||||
new CanceledDispatcherService());
|
new CanceledDispatcherService());
|
||||||
var synchronizationContext = new ExceptionCapturingSynchronizationContext();
|
var synchronizationContext = new ExceptionCapturingSynchronizationContext();
|
||||||
@@ -281,16 +281,16 @@ public sealed class SystemSettingViewModelTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class FakeSftpLookupService : ISftpLookupService
|
private sealed class FakeFileCheckService : IFileCheckService
|
||||||
{
|
{
|
||||||
public SftpConnectionTestOutcome TestOutcome { get; set; } = new();
|
public FileCheckTestOutcome TestOutcome { get; set; } = new();
|
||||||
|
|
||||||
public Task<SftpCheckOutcome> CheckFileAsync(string barcode, CancellationToken cancellationToken)
|
public Task<FileCheckOutcome> CheckFileAsync(string barcode, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
throw new NotSupportedException();
|
throw new NotSupportedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<SftpConnectionTestOutcome> TestConnectionAsync(SftpOptions? options, CancellationToken cancellationToken)
|
public Task<FileCheckTestOutcome> TestConnectionAsync(AppConfig? config, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return Task.FromResult(TestOutcome);
|
return Task.FromResult(TestOutcome);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ using Xunit;
|
|||||||
namespace AxiOmron.PcbCheck.Tests;
|
namespace AxiOmron.PcbCheck.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 验证流程后台服务的 SFTP 启动探活行为。
|
/// 验证流程后台服务的文件校验启动探活行为。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class WorkflowHostedServiceTests
|
public sealed class WorkflowHostedServiceTests
|
||||||
{
|
{
|
||||||
@@ -16,15 +16,15 @@ public sealed class WorkflowHostedServiceTests
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>异步测试任务。</returns>
|
/// <returns>异步测试任务。</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ProbeSftpOnStartupAsync_ShouldUpdateSnapshot_WhenConnectionFails()
|
public async Task ProbeFileCheckOnStartupAsync_ShouldUpdateSnapshot_WhenConnectionFails()
|
||||||
{
|
{
|
||||||
var stateStore = new AppStateStore();
|
var stateStore = new AppStateStore();
|
||||||
var service = new WorkflowHostedService(
|
var service = new WorkflowHostedService(
|
||||||
new FakePlcService(),
|
new FakePlcService(),
|
||||||
new FakeScannerService(),
|
new FakeScannerService(),
|
||||||
new FakeSftpLookupService
|
new FakeFileCheckService
|
||||||
{
|
{
|
||||||
TestOutcome = new SftpConnectionTestOutcome
|
TestOutcome = new FileCheckTestOutcome
|
||||||
{
|
{
|
||||||
IsSuccess = false,
|
IsSuccess = false,
|
||||||
IsSystemError = true,
|
IsSystemError = true,
|
||||||
@@ -36,7 +36,7 @@ public sealed class WorkflowHostedServiceTests
|
|||||||
new AppConfig(),
|
new AppConfig(),
|
||||||
new FakeAppLogger<WorkflowHostedService>());
|
new FakeAppLogger<WorkflowHostedService>());
|
||||||
|
|
||||||
await service.ProbeSftpOnStartupAsync(CancellationToken.None);
|
await service.ProbeFileCheckOnStartupAsync(CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal("异常", stateStore.GetSnapshot().SftpStatus);
|
Assert.Equal("异常", stateStore.GetSnapshot().SftpStatus);
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ public sealed class WorkflowHostedServiceTests
|
|||||||
var service = new WorkflowHostedService(
|
var service = new WorkflowHostedService(
|
||||||
plcService,
|
plcService,
|
||||||
new FakeScannerService(),
|
new FakeScannerService(),
|
||||||
new FakeSftpLookupService(),
|
new FakeFileCheckService(),
|
||||||
new FakeAndonService(),
|
new FakeAndonService(),
|
||||||
stateStore,
|
stateStore,
|
||||||
new AppConfig(),
|
new AppConfig(),
|
||||||
@@ -78,7 +78,7 @@ public sealed class WorkflowHostedServiceTests
|
|||||||
var service = new WorkflowHostedService(
|
var service = new WorkflowHostedService(
|
||||||
new FailingPlcService(),
|
new FailingPlcService(),
|
||||||
new FakeScannerService(),
|
new FakeScannerService(),
|
||||||
new FakeSftpLookupService(),
|
new FakeFileCheckService(),
|
||||||
new FakeAndonService(),
|
new FakeAndonService(),
|
||||||
stateStore,
|
stateStore,
|
||||||
new AppConfig(),
|
new AppConfig(),
|
||||||
@@ -111,9 +111,9 @@ public sealed class WorkflowHostedServiceTests
|
|||||||
Barcode = "PCB-001"
|
Barcode = "PCB-001"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
var sftpService = new FakeSftpLookupService
|
var fileCheckService = new FakeFileCheckService
|
||||||
{
|
{
|
||||||
CheckOutcome = new SftpCheckOutcome
|
CheckOutcome = new FileCheckOutcome
|
||||||
{
|
{
|
||||||
Exists = true,
|
Exists = true,
|
||||||
ConnectionSucceeded = true,
|
ConnectionSucceeded = true,
|
||||||
@@ -124,7 +124,7 @@ public sealed class WorkflowHostedServiceTests
|
|||||||
var service = new WorkflowHostedService(
|
var service = new WorkflowHostedService(
|
||||||
plcService,
|
plcService,
|
||||||
scannerService,
|
scannerService,
|
||||||
sftpService,
|
fileCheckService,
|
||||||
new FakeAndonService(),
|
new FakeAndonService(),
|
||||||
stateStore,
|
stateStore,
|
||||||
new AppConfig
|
new AppConfig
|
||||||
@@ -135,11 +135,7 @@ public sealed class WorkflowHostedServiceTests
|
|||||||
ReleaseAckTimeoutMs = 100,
|
ReleaseAckTimeoutMs = 100,
|
||||||
ReleasePulseMs = 10
|
ReleasePulseMs = 10
|
||||||
},
|
},
|
||||||
Scanner = new ScannerOptions
|
FileCheck = new FileCheckOptions
|
||||||
{
|
|
||||||
MaxScanAttempts = 1
|
|
||||||
},
|
|
||||||
Sftp = new SftpOptions
|
|
||||||
{
|
{
|
||||||
MaxRetryCount = 0
|
MaxRetryCount = 0
|
||||||
}
|
}
|
||||||
@@ -161,6 +157,69 @@ public sealed class WorkflowHostedServiceTests
|
|||||||
Assert.All(snapshot.PlcMonitorItems, item => Assert.NotEqual(default, item.LastUpdatedAt));
|
Assert.All(snapshot.PlcMonitorItems, item => Assert.NotEqual(default, item.LastUpdatedAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 双扫码汇总后,应使用汇总条码继续执行统一文件校验。
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>异步测试任务。</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteAsync_ShouldCheckFileWithMergedBarcode_WhenScannerServiceReturnsMergedBarcode()
|
||||||
|
{
|
||||||
|
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-MERGED",
|
||||||
|
RawMessage = "使用扫码枪2"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var fileCheckService = new FakeFileCheckService
|
||||||
|
{
|
||||||
|
CheckOutcome = new FileCheckOutcome
|
||||||
|
{
|
||||||
|
Exists = true,
|
||||||
|
ConnectionSucceeded = true,
|
||||||
|
MatchedFilePath = "/pcb/PCB-MERGED.txt"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var service = new WorkflowHostedService(
|
||||||
|
plcService,
|
||||||
|
scannerService,
|
||||||
|
fileCheckService,
|
||||||
|
new FakeAndonService(),
|
||||||
|
stateStore,
|
||||||
|
new AppConfig
|
||||||
|
{
|
||||||
|
Plc = new PlcOptions
|
||||||
|
{
|
||||||
|
PollIntervalMs = 20,
|
||||||
|
ReleaseAckTimeoutMs = 100,
|
||||||
|
ReleasePulseMs = 10
|
||||||
|
},
|
||||||
|
FileCheck = new FileCheckOptions
|
||||||
|
{
|
||||||
|
MaxRetryCount = 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
new FakeAppLogger<WorkflowHostedService>());
|
||||||
|
|
||||||
|
await service.StartAsync(CancellationToken.None);
|
||||||
|
await Task.Delay(200);
|
||||||
|
await service.StopAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(1, scannerService.TriggerCount);
|
||||||
|
Assert.Equal("PCB-MERGED", fileCheckService.LastBarcode);
|
||||||
|
Assert.Contains(plcService.WrittenStates, state => state.ResultCode == (ushort)WorkflowResultCode.Passed);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 当到位信号保持高电平时,即使执行软件复位,也不应被视为新的上升沿重复触发流程。
|
/// 当到位信号保持高电平时,即使执行软件复位,也不应被视为新的上升沿重复触发流程。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -189,9 +248,9 @@ public sealed class WorkflowHostedServiceTests
|
|||||||
var service = new WorkflowHostedService(
|
var service = new WorkflowHostedService(
|
||||||
plcService,
|
plcService,
|
||||||
scannerService,
|
scannerService,
|
||||||
new FakeSftpLookupService
|
new FakeFileCheckService
|
||||||
{
|
{
|
||||||
CheckOutcome = new SftpCheckOutcome
|
CheckOutcome = new FileCheckOutcome
|
||||||
{
|
{
|
||||||
Exists = true,
|
Exists = true,
|
||||||
ConnectionSucceeded = true,
|
ConnectionSucceeded = true,
|
||||||
@@ -208,11 +267,7 @@ public sealed class WorkflowHostedServiceTests
|
|||||||
ReleaseAckTimeoutMs = 100,
|
ReleaseAckTimeoutMs = 100,
|
||||||
ReleasePulseMs = 10
|
ReleasePulseMs = 10
|
||||||
},
|
},
|
||||||
Scanner = new ScannerOptions
|
FileCheck = new FileCheckOptions
|
||||||
{
|
|
||||||
MaxScanAttempts = 1
|
|
||||||
},
|
|
||||||
Sftp = new SftpOptions
|
|
||||||
{
|
{
|
||||||
MaxRetryCount = 0
|
MaxRetryCount = 0
|
||||||
}
|
}
|
||||||
@@ -266,7 +321,7 @@ public sealed class WorkflowHostedServiceTests
|
|||||||
var service = new WorkflowHostedService(
|
var service = new WorkflowHostedService(
|
||||||
new FakePlcService(),
|
new FakePlcService(),
|
||||||
new FakeScannerService(),
|
new FakeScannerService(),
|
||||||
new FakeSftpLookupService(),
|
new FakeFileCheckService(),
|
||||||
new FakeAndonService(),
|
new FakeAndonService(),
|
||||||
stateStore,
|
stateStore,
|
||||||
new AppConfig
|
new AppConfig
|
||||||
@@ -321,9 +376,9 @@ public sealed class WorkflowHostedServiceTests
|
|||||||
var service = new WorkflowHostedService(
|
var service = new WorkflowHostedService(
|
||||||
plcService,
|
plcService,
|
||||||
scannerService,
|
scannerService,
|
||||||
new FakeSftpLookupService
|
new FakeFileCheckService
|
||||||
{
|
{
|
||||||
CheckOutcome = new SftpCheckOutcome
|
CheckOutcome = new FileCheckOutcome
|
||||||
{
|
{
|
||||||
Exists = true,
|
Exists = true,
|
||||||
ConnectionSucceeded = true,
|
ConnectionSucceeded = true,
|
||||||
@@ -340,11 +395,7 @@ public sealed class WorkflowHostedServiceTests
|
|||||||
ReleaseAckTimeoutMs = 100,
|
ReleaseAckTimeoutMs = 100,
|
||||||
ReleasePulseMs = 10
|
ReleasePulseMs = 10
|
||||||
},
|
},
|
||||||
Scanner = new ScannerOptions
|
FileCheck = new FileCheckOptions
|
||||||
{
|
|
||||||
MaxScanAttempts = 1
|
|
||||||
},
|
|
||||||
Sftp = new SftpOptions
|
|
||||||
{
|
{
|
||||||
MaxRetryCount = 0
|
MaxRetryCount = 0
|
||||||
}
|
}
|
||||||
@@ -504,15 +555,21 @@ public sealed class WorkflowHostedServiceTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class FakeSftpLookupService : ISftpLookupService
|
private sealed class FakeFileCheckService : IFileCheckService
|
||||||
{
|
{
|
||||||
public SftpConnectionTestOutcome TestOutcome { get; set; } = new();
|
public FileCheckTestOutcome TestOutcome { get; set; } = new();
|
||||||
|
|
||||||
public SftpCheckOutcome CheckOutcome { get; set; } = new();
|
public FileCheckOutcome CheckOutcome { get; set; } = new();
|
||||||
|
|
||||||
public Task<SftpCheckOutcome> CheckFileAsync(string barcode, CancellationToken cancellationToken) => Task.FromResult(CheckOutcome);
|
public string LastBarcode { get; private set; } = string.Empty;
|
||||||
|
|
||||||
public Task<SftpConnectionTestOutcome> TestConnectionAsync(SftpOptions? options, CancellationToken cancellationToken)
|
public Task<FileCheckOutcome> CheckFileAsync(string barcode, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
LastBarcode = barcode;
|
||||||
|
return Task.FromResult(CheckOutcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<FileCheckTestOutcome> TestConnectionAsync(AppConfig? config, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return Task.FromResult(TestOutcome);
|
return Task.FromResult(TestOutcome);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user