Compare commits

..

6 Commits

Author SHA1 Message Date
3238817185 ✨ feat(file-check): 忽略无效扫码结果并完善汇报材料 2026-10-22 16:41:57 +08:00
07841fe4fe 📝 docs(report): 添加 AI 编程汇报材料
* 添加面向非技术听众的完整汇报稿与页面文案
* 提供明亮风格 HTML 演示页及调试情景交互
* 加入本地 WPF 运行截图、代码对比与测试证据
2026-08-01 16:43:57 +08:00
c67509ec82 ✨ feat(file-check): 添加 PLC 应答后命中文件删除
* 新增默认关闭的删除开关,仅在收到 PLC 放行应答后执行

* 本地与 SFTP Provider 校验根目录并支持幂等删除

* 补充工作流、设置页和文件删除相关测试与文档
2026-08-01 16:42:28 +08:00
404ebadbe7 ✨ feat(*): 添加 TCP 扫码枪连接测试功能及相关日志记录 2026-06-10 09:28:27 +08:00
17839a9e76 ✨ feat(*): 更新 PLC 相关配置与结果代码类型
- 修改 appConfig.json 中 PLC 和扫描仪的 IP 地址及字节序配置
- 将多个地方的 ResultCode 类型从 short 更新为 ushort,以确保与 PLC 通信一致
- 更新相关服务和模型以适应新的 ResultCode 类型
- 调整单元测试以验证新配置和类型的正确性
2026-06-02 17:59:04 +08:00
27e2c9d5d0 ✨ feat(*): 更新 PLC 相关功能与配置
- 在 appConfig.json 中添加 EndianFormat 配置项并更新相关输入输出地址
- 修改 ResultCode 类型为 short,确保与 PLC 通信一致
- 新增 DesignTimePlcService 以支持设计时的 PLC 测试写入功能
- 更新 SystemSettingViewModel 和 DashboardPage 以支持手动测试写入
- 调整多个视图模型和服务以适应新的 PLC 写入逻辑
- 更新相关单元测试以验证新功能的正确性
2026-06-02 17:26:24 +08:00
46 changed files with 5053 additions and 190 deletions

View File

@@ -1,32 +0,0 @@
{
"permissions": {
"allow": [
"Bash(git:*)",
"Bash(printf '\\\\n---\\\\n')",
"Bash(dotnet new:*)",
"Bash(dotnet sln:*)",
"Bash(dotnet build:*)",
"Bash(dotnet add:*)",
"Bash(powershell -NoProfile -Command \"[Reflection.Assembly]::LoadFrom\\('C:/Users/yunxiao.zhu/.nuget/packages/iotclient/1.0.42/lib/netstandard2.0/IoTClient.dll'\\).GetTypes\\(\\) | Where-Object { $_.Name -like '*Modbus*' -or $_.FullName -like '*Modbus*' } | Select-Object -ExpandProperty FullName\")",
"Bash(powershell -NoProfile -Command '[Reflection.Assembly]::LoadFrom\\(\"C:/Users/yunxiao.zhu/.nuget/packages/iotclient/1.0.42/lib/netstandard2.0/IoTClient.dll\"\\).GetType\\(\"IoTClient.Clients.Modbus.ModbusTcpClient\"\\).GetMethods\\(\\) | Where-Object { $_.Name -in @\\(\"Connect\",\"ReadDiscrete\",\"Write\",\"ReadUInt16\"\\) } | ForEach-Object { $_.ToString\\(\\) }')",
"Bash(powershell -NoProfile -Command '$asm=[Reflection.Assembly]::LoadFrom\\(\"C:/Users/yunxiao.zhu/.nuget/packages/iotclient/1.0.42/lib/netstandard2.0/IoTClient.dll\"\\); $type=$asm.GetType\\(\"IoTClient.Clients.Modbus.ModbusTcpClient\"\\); $type.GetMethods\\([System.Reflection.BindingFlags]::Instance -bor [System.Reflection.BindingFlags]::Public -bor [System.Reflection.BindingFlags]::DeclaredOnly\\) | ForEach-Object { $_.ToString\\(\\) }')",
"Bash(powershell -NoProfile -Command '$asm=[Reflection.Assembly]::LoadFrom\\(\"C:/Users/yunxiao.zhu/.nuget/packages/iotclient/1.0.42/lib/netstandard2.0/IoTClient.dll\"\\); $type=$asm.GetType\\(\"IoTClient.Clients.Modbus.ModbusTcpClient\"\\); $type.GetInterfaces\\(\\) | ForEach-Object { $_.FullName }')",
"Bash(powershell -NoProfile -Command '$asm=[Reflection.Assembly]::LoadFrom\\(\"C:/Users/yunxiao.zhu/.nuget/packages/iotclient/1.0.42/lib/netstandard2.0/IoTClient.dll\"\\); $type=$asm.GetType\\(\"IoTClient.Clients.Modbus.ModbusTcpClient\"\\); \"BASE: $\\($type.BaseType.FullName\\)\"; $type.GetMethods\\([System.Reflection.BindingFlags]::Instance -bor [System.Reflection.BindingFlags]::Public\\) | Where-Object { $_.Name -match \"Connect|Close|Dispose|Disconnect|Open|Start|Stop|Release\" } | ForEach-Object { $_.DeclaringType.FullName + \" :: \" + $_.ToString\\(\\) }')",
"Bash(powershell -NoProfile -Command '$asm=[Reflection.Assembly]::LoadFrom\\(\"C:/Users/yunxiao.zhu/.nuget/packages/iotclient/1.0.42/lib/netstandard2.0/IoTClient.dll\"\\); $type=$asm.GetType\\(\"IoTClient.Clients.Modbus.ModbusTcpClient\"\\); $type.GetConstructors\\(\\) | ForEach-Object { $_.ToString\\(\\) }')",
"Read(//d D:/Dev/Codes/MFD_Solution/Axi_Omron/src/**)",
"Bash(dotnet restore:*)",
"Bash(systemctl status *)",
"Bash(service ssh *)",
"Bash(sshd -V)",
"Bash(apt list *)",
"Bash(dpkg -l)",
"Bash(sudo apt-get *)",
"Bash(sudo -n true)",
"Bash(mkdir -p /tmp/pcb)",
"Read(//tmp/**)",
"Bash(taskkill /F /IM \"AxiOmron.PcbCheck.exe\")",
"Bash(powershell -Command \"Stop-Process -Id 23292 -Force -ErrorAction SilentlyContinue\")",
"Bash(dotnet publish *)"
]
}
}

View File

@@ -38,6 +38,7 @@ Axi_Omron/
- `Plc.PollIntervalMs`: `200` - `Plc.PollIntervalMs`: `200`
- `Plc.ReleasePulseMs`: `500` - `Plc.ReleasePulseMs`: `500`
- `Plc.ReleaseAckTimeoutMs`: `2000` - `Plc.ReleaseAckTimeoutMs`: `2000`
- `FileCheck.DeleteMatchedFileAfterPlcAck`: `false`;启用后仅在收到 PLC 放行应答时删除本次精确命中的产品 ID 文件
点位映射示例: 点位映射示例:

View File

@@ -129,4 +129,5 @@ flowchart TD
- SFTP 文件未找到达到重试上限后,写入 `ResultCode=20` 并放行。 - SFTP 文件未找到达到重试上限后,写入 `ResultCode=20` 并放行。
- SFTP 连接失败、认证失败、根目录不存在等系统异常写入 `ResultCode=90`,不自动放行。 - SFTP 连接失败、认证失败、根目录不存在等系统异常写入 `ResultCode=90`,不自动放行。
- 两把扫码枪均失败时调用安灯报警;安灯接口失败不阻断 `ResultCode=20` 的 NG 放行。 - 两把扫码枪均失败时调用安灯报警;安灯接口失败不阻断 `ResultCode=20` 的 NG 放行。
- `FileCheck.DeleteMatchedFileAfterPlcAck=true` 时,仅在收到 PLC 放行应答后删除本次文件校验精确命中的产品 ID 文件;应答超时、未命中文件或删除失败时不误删其他文件。
- 放行完成后等待 PLC 复位信号 `D1002`,再清 `D1051`、`D1052`、`D1100`。 - 放行完成后等待 PLC 复位信号 `D1002`,再清 `D1051`、`D1052`、`D1100`。

View File

@@ -0,0 +1,141 @@
# 《3 个月,我从写 if-else 到 Vibe Coding》汇报稿
> 建议时长:8~10 分钟<br>
> 面向听众:以非开发人员为主<br>
> 核心目标:让听众听懂 Vibe Coding 是什么、上手难不难、怎样调试、有哪些风险。
---
## 一、起点:Vibe Coding 到底是什么(约 1 分钟)
大家好,我今天汇报的主题是:**《3 个月,我从写 if-else 到 Vibe Coding》**。
先解释一下什么是 Vibe Coding。
过去写程序,我需要自己把需求翻译成一行一行的代码。现在,我可以先用自然语言告诉 AI:我要做什么、程序应该怎样运行、出现异常时怎么办。AI 帮我阅读项目、生成代码、运行测试,我再检查结果、继续调整。
它有点像和一名速度很快的程序员结对工作。以前我主要负责“写代码”,现在我更多负责“把问题讲清楚”和“判断结果对不对”。
这三个月,我用这种方式完成了一个 PCB 目检放行上位机。它会接收产线信号、读取条码、检查产品文件,然后决定 PCB 是否放行,并保存过程记录。
---
## 二、我用 AI 做了什么(约 4 分钟)
### 1. 先让 AI 理解业务
这个项目不是简单做一个界面。它要同时和 PLC、两把扫码枪、文件服务器以及报警系统通信。
一块 PCB 到位后,程序需要完成五步:
1. 接收 PLC 的到位信号。
2. 触发两把扫码枪读取条码。
3. 根据条码检查产品文件。
4. 把检查结果发给 PLC,并等待 PLC 确认。
5. 保存记录,等待下一块 PCB。
我没有对 AI 说“帮我写个上位机”就结束,而是先让它把这五步画成流程,列出每一步的成功、失败和超时情况,再逐步实现。
这也是 Vibe Coding 的第一个注意点:**需求越模糊,AI 写得越快,返工也可能越快。**
### 2. 从小功能逐步搭起来
我先让 AI 搭出可以运行的界面,然后逐个接入扫码、文件检查、PLC 通信和运行记录。每完成一小步,就先运行和验证,再继续下一步。
对有代码基础的人来说,Vibe Coding 的入门难度并不高。会安装开发工具、会运行项目、能看懂基本报错,通常很快就能开始。
但“能生成代码”和“能交付程序”不是同一件事:
- 入门很容易:会用自然语言描述需求,就能做出一个原型。
- 做到可用,需要知道怎样拆分任务、补充上下文和检查结果。
- 做到可靠,仍然需要业务知识、调试能力和风险判断。
所以它降低的是写代码的门槛,没有消除软件工程本身的难度。
### 3. 一次真实的调试过程
项目中有一个需求:PCB 放行以后,删除本次使用过的产品文件。
程序日志显示“放行信号写入成功”,但文件没有删除。最直接的想法,是让 AI 把删除代码放到“写入成功”后面。
但我没有直接这样改,而是让 AI 做三件事:
1. 根据日志找到放行信号在哪里写入。
2. 沿着程序流程查找 PLC 确认信号在哪里读取。
3. 分别模拟“收到确认”和“确认超时”两种情况。
最后发现,写入成功只代表上位机把消息发出去了,不代表 PLC 已经完成放行。就像邮件显示发送成功,不代表对方已经阅读并执行。
因此正确逻辑是:只有收到 PLC 的确认,才允许删除文件;如果等待超时,文件必须保留。同时,这个删除功能默认关闭,避免现场配置错误造成误删。
这个 Bug 最后不是靠猜某一行代码解决的,而是通过**看日志、追流程、做对比测试**定位的。
---
## 三、用 AI 怎样调试程序(约 2 分钟)
很多人担心:代码是 AI 写的,出问题以后怎么办?
我现在使用一套比较简单的调试方法,可以概括为五步。
### 第一步:先复现
先确认在什么操作下必然出现问题。不要只告诉 AI“程序坏了”,要告诉它“我做了什么、期望看到什么、实际发生了什么”。
### 第二步:给证据
把错误提示、运行日志、相关配置和最近修改交给 AI。证据越完整,AI 越不容易靠猜。
### 第三步:让 AI 解释流程
不要一上来就说“直接修复”。先让 AI 回答:这个功能从哪里开始,经过哪些步骤,失败发生在哪一步。确认它理解正确以后再修改。
### 第四步:一次只改一个问题
修改范围越大,越难判断到底是哪一处起作用。先做最小修改,再重新运行。
### 第五步:同时验证成功和失败
程序正常运行只是一个场景。还要验证超时、断网、输入错误等情况,确保程序不会在异常时做出危险动作。
我常用的一句话是:
> “先不要改代码。请根据日志找到调用流程,说明最可能的三个原因,以及如何逐个验证。”
这句话通常比“帮我修一下”更有效。
---
## 四、学习路径和注意点(约 2 分钟)
我的学习方式是四步:看一点、拆一个、搭起来、解决真实问题。
我先通过视频了解 AI 编程工具的基本用法,再找一个项目观察它的结构,然后自己搭一个小项目。真正提升最大的阶段,是拿它解决一个真实问题,因为真实问题会遇到设备、网络、超时和错误数据,而不是只有演示里的成功路径。
最后,我总结了四个注意点:
1. **不要一次提出过大的需求。** 把任务拆小,每一步都能运行和检查。
2. **不要盲目接受 AI 的修改。** 至少要看改了哪些文件、为什么这样改。
3. **不要把本地运行成功当成现场验证。** 模拟测试不能代替真实设备和真实网络。
4. **删除、付款、放行等高风险动作要默认关闭。** 必须有明确条件和人工可控的开关。
所以,我对 Vibe Coding 的结论是:**上手并不难,做出原型很快;真正的能力,是会描述、会验证、会调试,也知道什么时候不能完全相信 AI。**
AI 没有让我不再需要技术,而是让我可以把更多时间放在理解问题和验证结果上。
我的汇报结束,谢谢大家。
---
## 现场演示建议
演示只保留两个动作,控制在 2 分钟内:
1. 播放 PCB 从到位、扫码、文件检查到放行的模拟流程。
2. 展示两种调试结果:收到 PLC 确认时允许删除;确认超时时文件保留。
演示时不需要展示大段代码,只需要展示日志或测试结果,并用“邮件发送成功不等于对方已经执行”的比喻解释问题。
如果现场演示失败,可以直接说:
> “这也是调试的一部分。现在我会先保留现场状态和日志,而不是连续修改代码。演示环境没有连接真实设备,下面展示我提前准备的测试结果。”

View File

@@ -0,0 +1,533 @@
# AxiOmron.PcbCheck 汇报演讲稿
> 主题:从初学者需求,到完整项目计划<br>
> 项目:AxiOmron.PcbCheck / PCB 目检放行上位机<br>
> 建议时长:10~12 分钟<br>
> 对应页面:[axiomron-pcbcheck-report-draft.html](./axiomron-pcbcheck-report-draft.html)<br>
> 内容来源:`docs/开发流程.txt`、`docs/纯plan.md`、当前项目实际工程
## 使用说明
这份文件不是简单的页面文案,而是现场可以直接使用的演讲稿:
- “屏显文案”对应 HTML 页面上看到的文字。
- “演讲主词”是建议直接讲出来的内容,可以按现场时间适当删减。
- “转场”用于从当前页面自然过渡到下一页。
- “操作/提示”用于现场演示,避免临场忘记重点。
- 全程只展示本地模拟和软件证据,不连接真实生产设备。
这次汇报的主线是:
```text
模糊需求 → 对话追问 → 补齐边界 → 完整计划 → 分阶段实现 → 测试验收
```
整场汇报不需要把代码讲得很深,重点是说明:我如何从一个初学者的功能描述,逐步学会定义问题、判断风险和验证结果。
---
## 01. 封面|3 个月,我从写 if-else 到 Vibe Coding
**预计时长:40 秒**
### 屏显文案
**3 个月,**<br>
**我从写 if-else**<br>
**到 Vibe Coding**
用 AI 协作搭建一套 PCB 目检放行上位机:把 PLC、双扫码、文件校验、安灯和追溯记录,收束为一条可解释的现场闭环。
- 产品形态:WPF 工业上位机
- 技术基线:.NET 8 / MVVM
- 汇报重点:需求演进、调试方法、工程边界
### 演讲主词
大家好,今天我想分享的不是“AI 帮我生成了多少代码”,而是我如何从一个很简单的现场需求,逐步把它变成一份完整的项目计划,并最终做成一个可以运行的上位机项目。
三个月前,遇到扫码、查文件、通知 PLC 这种需求时,我很容易直接进入 if-else:扫码成功怎么办,文件找不到怎么办,PLC 超时怎么办。
后来我逐渐发现,工业软件最重要的不是先写出一段代码,而是先把现场规则、设备边界、异常情况和验收方式问清楚。AI 可以加快实现,但方向和判断仍然需要我自己负责。
### 转场
所以我们先回到起点,看一下最开始的需求到底是什么样的。
---
## 02. 初始需求|我只想让它先跑起来
**预计时长:55 秒**
### 屏显文案
**最开始,我只想让它**<br>
**先跑起来。**
> “我需要写一个程序:PCB 到位后扫码,根据条码检查文件是否存在,再把结果返回给 PLC。”
初版要求:
1. PCB 到位后开始处理。
2. 读取一个条码。
3. 根据条码检查对应文件是否存在。
4. 文件存在显示“正常”。
5. 文件不存在显示“异常”。
6. 页面显示当前状态、条码、处理结果和日志。
7. 保存最近的处理记录。
8. 先使用模拟数据,不连接真实设备。
9. 页面提供“正常”“异常”“设备故障”三种模拟情况。
### 演讲主词
这不是一个错误的需求,反而非常适合作为第一版。
它已经说清楚了业务主线:PCB 到位以后开始处理,读到条码,根据条码查文件,文件存在就是正常,不存在就是异常,同时页面要能显示状态和日志,而且一开始先用模拟数据。
对初学者来说,这已经足够启动一个原型了。问题是,它还没有进入现场工程的层次。
它没有说明 PLC 的哪个信号算到位,两把扫描枪到底怎样通信,文件具体怎样命名,也没有说明异常时如何恢复、结果保持多久、什么条件下才算真正完成。
所以我当时的第一版想法是:先把正常流程跑通,再用模拟按钮把正常、异常和设备故障演示出来。
### 转场
但是,当我准备真正开始写设备通信代码时,就会遇到一个问题:还缺哪些信息?
---
## 03. 第一次对话|还需要哪些信息才能开始写程序
**预计时长:1 分钟**
### 屏显文案
**真正开始写代码前,**<br>
**我先问:还缺什么?**
> “还需要哪些信息才能开始写程序?”
需要补齐四类事实:
| 类别 | 关键问题 |
| --- | --- |
| PLC | 型号、协议、点位、保持/脉冲、复位、应答、超时和异常恢复。 |
| 双扫描枪 | 通信角色、端口、结束符、编码、超时、双枪关系和断线行为。 |
| 文件规则 | 条码格式、文件名规则、匹配方式、目录类型、空文件和正在写入的处理。 |
| 程序与记录 | 界面、配置、日志、权限、模拟入口、部署方式和是否需要数据库。 |
### 演讲主词
这一步的价值,不是让 AI 变得更会猜,而是让它少猜。
首先是 PLC。需要知道 PLC 的型号和通信协议,哪个地址代表 PCB 到位,哪些地址由上位机写入,信号是保持型还是脉冲型,PLC 有没有复位和业务应答。
然后是扫描枪。两把枪是同时触发还是有先后?它们是 TCP Server 还是 TCP Client?返回有没有结束符?如果没有结束符,程序怎么知道一条条码已经接收完成?
第三是文件规则。条码是完整文件名,还是要转换成文件名?文件不存在和目录无法访问,是不是同一种异常?文件正在生成时应该等待、重试还是直接失败?
最后是程序怎么使用。操作员需要看到什么,工程师需要哪些日志,哪些操作需要权限,模拟入口怎样保留,记录是否要长期保存。
这些问题如果不提前确认,代码可能在本地看起来正确,到了现场却因为一个点位、一个报文边界或一个异常分类而产生错误判断。
### 转场
当这些问题被逐项问出来,需求就开始从“功能列表”变成“工程契约”。
---
## 04. 需求完善|从功能描述到工程契约
**预计时长:1 分钟**
### 屏显文案
**需求不再只是“做什么”,**<br>
**还要说清楚“边界在哪里”。**
| 契约 | 需要明确的边界 |
| --- | --- |
| PLC 契约 | 到位、复位、结果、应答、任务去重和确认超时。 |
| 扫描枪契约 | 双路并发、无结束符、总超时、首个有效结果和迟到数据。 |
| 文件契约 | 条码清洗、路径安全、文件存在、目录异常和结果分类。 |
| 应用契约 | 设备状态、当前任务、日志、统计、模拟场景和权限边界。 |
需求演进链路:
`功能描述 → 条件边界 → 失败策略 → 验收证据`
### 演讲主词
需求完善以后,变化最大的地方是:每个功能都开始有前置条件和失败策略。
比如“把结果返回给 PLC”不再只是一句写入代码,而要继续问:结果保持多久?PLC 是否会给业务应答?确认超时怎么办?复位由谁触发?如果到位信号一直保持为真,是否会重复触发同一块 PCB?
“读取一个条码”也不再只是调用一次 Receive。要确认两把枪是否同时触发,是否允许任意一把成功,没有结束符时如何判断报文结束,迟到的数据是否会影响下一轮任务。
文件检查也要分清楚:文件存在、文件不存在、目录不可访问、路径越界、条码格式错误,这些不能全部压成一个简单的异常。
最终,需求被整理成一条链路:先描述功能,再补充条件边界,然后规定失败策略,最后为每个关键判断准备验收证据。
### 转场
当问题和边界都被整理出来以后,对话的结果就不再是一段零散的代码,而是一份可以执行的项目计划。
---
## 05. 完整项目计划|对话的终点是一份可执行的计划
**预计时长:1 分钟**
### 屏显文案
**对话的终点,**<br>
**是一份可以执行的计划。**
| 层面 | 最终形成的内容 |
| --- | --- |
| 目标 | 让 PCB 从到位、扫码、文件校验到 PLC 结果反馈,形成可解释、可追溯的业务闭环。 |
| 流程 | 到位触发 → 双枪扫描 → 条码校验 → 文件判断 → 结果保持 → 应答/复位 → 记录完成。 |
| 架构 | 当前项目落地为 .NET 8 + WPF;设备通信、流程编排、界面状态、配置和日志保持清晰边界。 |
| 测试与验收 | 单元测试和 Fake 设备先验证逻辑,再使用真实设备完成现场联调。 |
计划的价值:把“我想要一个程序”翻译成目标、状态、接口、阶段、测试和交付门槛。
### 演讲主词
最后形成的不是一张简单流程图,而是一份完整的项目计划。
第一层是目标:让一块 PCB 从到位、扫码、文件校验,到结果反馈和记录完成,形成一条能够解释、能够追溯的业务闭环。
第二层是流程:什么时候开始,什么时候扫描,什么时候查文件,结果什么时候写回,什么时候等待 PLC 应答,什么时候复位并进入下一轮。
第三层是架构:当前仓库的实际落地基线是 .NET 8 加 WPF。设备通信、流程编排、界面状态、配置和日志各自有清晰边界,便于模拟、测试和后续替换设备实现。
第四层是测试和验收:先用单元测试和 Fake 设备验证软件逻辑,再使用真实 PLC、扫描枪和文件服务做现场联调。
这里有一个需要主动说明的历史差异:早期计划文本里出现过 WinForms,但当前工程实际采用 WPF。计划不是一次写死的合同,事实确认后可以修正;重要的是把历史方案和最终落地方案区分清楚。
### 转场
有了目标、流程、架构和验收标准,下一步就是按风险顺序把大计划拆成多个小闭环。
---
## 06. 开发阶段|七个小闭环,逐步降低风险
**预计时长:55 秒**
### 屏显文案
**计划被拆成七个小闭环,**<br>
**每一步都有可以验证的产物。**
| 阶段 | 产物 |
| --- | --- |
| 01 需求与协议确认 | 点位、握手、报文、结果码和文件规则。 |
| 02 项目骨架 | WPF、配置、日志、依赖注入和基础状态。 |
| 03 PLC 通信 | 读写、超时、断线、重连和模拟接口。 |
| 04 双扫描枪 | 并发触发、无结束符、首个有效结果。 |
| 05 流程编排 | 任务去重、文件安全、结果保持和恢复。 |
| 06 状态界面 | 设备、任务、统计、日志和操作权限。 |
| 07 测试与交付 | 单测、Fake 流程、异常测试和现场验收。 |
### 演讲主词
这个项目不是一次性把所有功能堆出来,而是按风险顺序搭建。
第一阶段先确认事实,避免在错误协议或错误点位上继续开发。第二阶段建立项目骨架,让配置、日志、界面和业务流程有边界。第三、第四阶段分别处理 PLC 和双扫描枪通信,把设备问题尽量隔离在接口和服务里。
第五阶段才把到位、扫码、查文件、结果保持和复位串成完整流程。第六阶段把状态和结果展示给使用者。最后用单元测试、Fake 流程、异常测试和真实设备联调完成交付。
每个阶段都有可以检查的产物,所以遇到问题时,可以定位是需求没确认、通信没稳定、流程没定义,还是验收证据不足,而不是把所有问题都归结为“代码有 bug”。
### 转场
这也说明了 Vibe Coding 的真正价值:它可以加快每个小闭环,但不能替我们决定闭环是否成立。
---
## 07. Vibe Coding 上手难度|能跑不等于可靠
**预计时长:50 秒**
### 屏显文案
**上手不难,**<br>
**难的是从“能跑”走到“可靠”。**
| 阶段 | 需要的能力 |
| --- | --- |
| 做出原型 | 会描述需求、会运行项目,就能很快看到第一个结果。 |
| 做到可用 | 会拆小任务、补充上下文,并检查 AI 实际改了什么。 |
| 做到可靠 | 理解业务风险,验证断网、超时、错误输入和恢复路径。 |
> 它降低的是写代码的门槛,没有消除软件工程本身的难度。
### 演讲主词
从体验上说,Vibe Coding 的确降低了入门门槛。
会描述目标、会运行项目,就可以很快做出一个原型。再进一步,需要会拆小任务、补充上下文,并且确认 AI 实际修改了什么。真正做到可靠,则必须理解业务风险,主动验证断网、超时、错误输入和恢复路径。
工业软件最难的地方,往往不是正常时能不能跑,而是异常时会不会误判、重复触发、误放行,或者丢失现场证据。
所以我现在的理解是:AI 像一名速度很快的协作程序员,负责加速实现;我负责方向、边界和最终验收。
### 转场
下面看一下这份计划最终如何落到一个可以运行的项目界面和处理流程上。
---
## 08. 实际项目运行|程序真的运行起来了
**预计时长:1 分钟**
### 屏显文案
**这不是概念图,**<br>
**程序真的运行起来了。**
一块 PCB 的 5 步旅程:
1. PLC 通知 PCB 到位。
2. 两把扫码枪读取条码。
3. 根据条码检查产品文件。
4. 发送结果并等待确认。
5. 保存记录并回到空闲。
> 本地运行界面是软件证据,不等同于真实 PLC、扫描枪和文件服务现场验收。
### 现场操作
1. 展示 WPF 主界面中的设备状态、当前任务、文件校验、流程和日志。
2. 按页面右侧的 5 个步骤,说明一块 PCB 如何经过系统。
3. 重点停在第 4 步,说明写入结果以后还要等待 PLC 业务确认。
4. 强调当前是本地展示环境,没有连接真实生产设备。
### 演讲主词
到这里,前面讨论的计划已经对应到了实际工程中的界面和服务。
页面不是只显示一个最终的 OK 或 NG,而是把设备状态、当前任务、条码、文件检查、流程阶段和日志放在一起。这样发生异常时,工程师能够看到问题发生在哪一步,而不只是看到一个结果颜色。
这一页展示的是软件可以运行、流程可以观察,但它仍然不是现场验收证明。真实 PLC、真实扫描枪、真实文件服务和生产网络权限,还需要在现场单独验证。
### 转场
真正让我意识到工程边界的,是下面这个调试案例:信号明明写成功了,为什么文件仍然不能立即删除?
---
## 09. 真实调试案例|先问清楚何时可以删
**预计时长:1 分 10 秒**
### 屏显文案
**信号写成功了,**<br>
**为什么文件不能删?**
最初想法:
```text
写入放行信号 → 删除文件
```
调试后的流程:
```text
写入放行信号 → 等待 PLC 业务确认
→ 确认成功后,才按配置执行后续动作
```
> PLC 写入成功,不等于产线已经完成业务确认。
### 现场操作
1. 进入第 9 页。
2. 点击“收到 PLC 确认”,观察状态变成“业务已确认”,文件允许按配置处理。
3. 点击“等待确认超时”,观察状态变成“确认超时”,文件保持不动。
4. 对听众强调:写入成功和业务完成是两个不同层次的结果。
### 演讲主词
最初的想法很直接:写入放行信号成功以后,就把本轮文件删掉。
后来调试时发现,这里混淆了两个概念:协议操作成功,和业务动作已经完成。
这可以类比成发邮件。系统显示“发送成功”,只表示邮件已经交给发送系统,并不代表对方已经阅读,更不代表对方已经执行了邮件里的内容。
在这个项目里,FINS 写入成功只能说明上位机完成了一次通信操作。只有 PLC 返回业务确认,才能说明产线已经确认了放行结果。确认成功以后,才可以根据配置决定是否执行后续清理;如果确认超时,就必须保留文件,不继续执行危险动作。
这个案例让我改变了使用 AI 的方式。以前我可能会直接让 AI 补一行 `DeleteFile`;现在我会先让它追踪完整调用链,确认前置条件、超时行为、默认开关和失败后果。
### 转场
当问题变复杂时,我不会只把错误信息丢给 AI,而是使用一套固定的证据驱动调试方法。
---
## 10. 五步调试法|证据越完整,AI 越不容易靠猜
**预计时长:1 分钟**
### 屏显文案
**程序出错时,**<br>
**我用这套五步调试法。**
1. **先复现**:说明做了什么、期望什么、实际发生什么。
2. **给证据**:提供报错、日志、配置和最近的修改。
3. **讲流程**:先让 AI 解释调用过程,不急着修改。
4. **小步改**:一次只处理一个原因,修改后立即运行。
5. **正反测**:既测试成功,也测试超时、断网和错误输入。
常用提示:
> “先不要改代码。请根据日志找到调用流程,列出最可能的三个原因,以及如何逐个验证。”
### 演讲主词
我的调试方式可以概括成五步。
第一,先复现,把做了什么、期望什么、实际发生什么说清楚。第二,给证据,包括日志、异常、配置和最近的修改。第三,让 AI 先解释调用流程,不要一上来就修改代码。
第四,一次只改一个原因,修改以后立即运行。第五,不只测试成功场景,还要测试超时、断网、错误输入和功能开关关闭的情况。
我常用的提示词是:“先不要改代码,请根据日志找到调用流程,列出最可能的三个原因,以及如何逐个验证。”
这样做的好处是,每次修改都能回答三个问题:为什么改、改了什么、怎么证明改对了。
### 操作/提示
- 页面上的测试证据展示了 PLC 确认成功、确认超时和功能开关关闭三种结果。
- 不需要逐字讲出所有测试名称,只需说明“成功和失败都要验证”。
### 转场
方法解决了如何调试,边界解决的是哪些事情不能省、哪些结果不能被误读。
---
## 11. 工程边界与验收|这几条不能省
**预计时长:55 秒**
### 屏显文案
**Vibe Coding 很快,**<br>
**但这些不能省。**
| 规则 | 现场含义 |
| --- | --- |
| 任务要拆小 | 每一步都应该能运行、能观察、能撤回。 |
| 修改要看懂 | 确认改了哪些文件、为什么改、影响到哪里。 |
| 模拟不等于现场 | Fake 设备只能先验证逻辑,不能替代真实设备联调。 |
| 高风险动作默认关闭 | 删除、放行等动作必须有明确条件和可控开关。 |
交付边界:
- 异常可恢复:断线、超时、目录不可访问都有状态、日志和恢复策略。
- 过程可追溯:每一轮任务都能回看输入、判断、结果和失败原因。
- 现场才算验收:真实点位、通信边界、权限和连续生产稳定性必须确认。
### 演讲主词
这一页是我认为最重要的工程边界。
第一,任务要拆小,每一步都要能运行、能观察、能撤回。第二,修改要看懂,至少知道改了哪些文件、为什么改、影响了什么。
第三,模拟不等于现场。Fake PLC、Fake 扫描枪和本地文件可以帮助我们先验证逻辑,但不能替代真实设备、真实网络和真实权限。
第四,高风险动作默认关闭。删除文件、放行、清理现场依据,都要有明确条件、可控开关和失败策略。
所以交付标准不是“页面能打开”,而是异常可恢复、过程可追溯,并且真实设备完成现场验收。
### 被问到现场验证时
可以直接回答:
> 当前已有本地构建、定向测试和可控模拟流程证据;真实 PLC、扫描枪、文件服务、网络权限和连续生产稳定性仍需要现场联调验收,不能用模拟结果替代。
### 转场
最后用三句话总结这次从初始需求到项目计划的变化。
---
## 12. 总结|从一句话需求到可运行项目
**预计时长:50 秒**
### 屏显文案
**从一句话需求,**<br>
**到可验证的完整计划。**
最后再到一个能运行、能追溯、知道边界的项目。
- **会提问**:把需求里的空白和风险问出来。
- **会拆解**:把自然语言变成流程、阶段和验收标准。
- **会验证**:把本地证据与真实现场验收分开。
### 收尾讲述
这次最大的变化,不是 AI 替我写了多少代码,而是我开始能把一个模糊的现场需求,拆成设备契约、状态流程、异常边界、开发阶段和验收证据。
以前我更关注“能不能生成代码”,现在我更关注“问题有没有定义清楚、边界有没有说明白、结果有没有被验证”。
AI 让开发速度更快,但没有替代工程判断。真正的成长,是我开始能定义问题、判断风险、验证结果,并把一句话需求逐步变成一份完整计划和一个可以运行的项目。
谢谢大家。
---
## 问答备忘
### 问:这套程序现在是不是已经连接真实 PLC 了?
答:本次汇报展示的是本地可控环境和软件运行证据,没有连接真实生产设备。真实 PLC 点位、通信边界、权限和连续生产稳定性仍需要现场联调验收。
### 问:AI 是不是已经替代了开发人员?
答:AI 加快了查找、实现和测试,但没有替代需求确认、风险判断和验收责任。尤其是放行、删除和复位这类动作,必须由业务人员确认规则。
### 问:为什么计划里出现过 WinForms,最后项目是 WPF?
答:WinForms 是早期计划中的技术选择,随着实际工程基线和项目结构确认,当前实现落地为 .NET 8 + WPF。这个变化被保留并明确说明,避免把历史方案误认为最终实现。
### 问:为什么不直接把正常流程做完再说?
答:正常流程可以快速做出原型,但工业现场真正容易出问题的是超时、断线、重复触发、结果未确认和权限异常。提前定义这些边界,可以减少后期返工和现场风险。
### 问:项目现在能不能直接上产线?
答:不能仅凭本地模拟结果判断可以上产线。需要完成真实 PLC、双扫描枪、文件服务、网络权限、异常恢复和连续生产稳定性验证后,才能按现场验收结果决定。
---
## 现场演示检查清单
### 开始前
- 确认浏览器打开的是 `docs/axiomron-pcbcheck-report-draft.html`。
- 确认页面能看到 12 个圆点和“下一页”按钮。
- 确认第 9 页的两个调试按钮可以点击。
- 不打开真实 PLC、真实扫描枪、真实文件服务器或生产目录。
### 演示中
- 第 2 页讲完初始需求后,停顿一下,强调“这已经能做原型,但还不能直接上现场”。
- 第 3~6 页是本次新增主线,要讲清“追问如何变成完整计划”。
- 第 8 页展示项目运行证据,但明确它不是现场验收。
- 第 9 页先点“收到 PLC 确认”,再点“等待确认超时”。
- 第 11 页主动说明模拟测试和现场验收的区别。
### 结束后
- 用“会提问、会拆解、会验证”收尾。
- 如果时间不足,优先保留第 2、3、5、9、11、12 页。
- 如果时间充足,再完整讲第 4、6、7、8、10 页。

BIN
docs/assets/真实UI.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

File diff suppressed because it is too large Load Diff

104
docs/开发流程.txt Normal file
View File

@@ -0,0 +1,104 @@
1. PCB 到位后开始处理;
2. 读取一个条码;
3. 根据条码检查对应文件是否存在;
4. 文件存在显示“正常”;
5. 文件不存在显示“异常”;
6. 页面显示当前状态、条码、处理结果和日志;
7. 保存最近的处理记录;
8. 先使用模拟数据,不连接真实设备;
9. 页面上增加按钮,让我可以模拟“正常”“异常”“设备故障”三种情况。
我需要写一个程序完成下面的功能,
跟欧姆龙的plc通信,等待plc传递过来信号,通知我pcb板到位了.我触发两个网络的扫描枪扫描pcb板的二维码
根据条码检查对应指定文件夹里的文件是否存在,然后把结果返回给plc.
还需要哪些信息才能开始写程序?
主人,这个功能可以开始设计,但要先确认以下信息,尤其是 PLC 点位和握手流程。
PLC 信息PLC 型号:CP、CJ、CS、NJ、NX 等
通信协议:FINS/TCP、FINS/UDP、EtherNet/IP 或 OPC UA
PLC IP、端口、网络号、节点号
“PCB 到位”信号地址和数据类型
上位机需要写回的地址:扫描中
扫描成功
文件存在
文件不存在
条码错误
扫描超时
双枪条码不一致
PLC 对信号的要求:保持型、脉冲型,还是需要上位机清零
完整握手时序:触发、响应、复位、超时、异常恢复
最好提供 PLC 点位表或梯形图截图。不能只凭历史地址开发,现场需确认真实点位。
两把网络扫描枪信息品牌和型号
通信方式:TCP Server、TCP Client、UDP,还是串口转网口
每把扫描枪的 IP 和端口
条码结束符:CR、LF、CRLF 或无结束符
条码编码:ASCII、UTF-8 等
扫描超时时间
两把枪的作用:是否同时扫描同一个 PCB
是否分别扫描正反面或两个位置
两个条码是否必须一致
两把枪是否有先后顺序
扫描失败、重复扫描、断线后的处理方式
最好提供扫描枪通信协议手册和一组实际原始报文。
条码与文件规则PCB 二维码示例
条码长度、格式、是否区分大小写
条码如何转换成文件名或文件夹名
文件的完整路径规则
文件扩展名,例如 .csv、.json、.xml、.jpg
文件夹是本地目录、共享目录,还是 SFTP
文件存在的判断条件:精确文件名
模糊匹配
必须匹配一个文件
匹配多个文件也算成功
文件存在但为空、损坏或正在写入时如何处理
是否需要在 PLC 确认后删除或移动文件
返回 PLC 的结果定义
请明确每个结果对应的 PLC 值,例如:
结果 PLC 值
空闲 0
扫描中 1
文件存在/OK 2
文件不存在/NG 3
条码不一致 4
通信异常 5
还需要确认结果是写入一个字、多个 bit,还是写入字符串。
程序形态使用 WPF 还是 WinForms
目标 .NET 版本
是否需要操作员界面
是否需要显示 PLC、两把扫描枪和文件服务器状态
是否需要配置界面
日志保存位置和保留天数
是否需要开机自启动
是否部署为普通桌面程序还是 Windows Service
是否需要数据库保存扫描记录
我建议默认采用:
WPF + .NET 8
PLC、扫描枪、文件检查分别封装成独立通信服务
后台任务监听 PLC,不阻塞界面
所有 IP、端口、地址、超时和文件规则放入配置文件
先用模拟 PLC、模拟扫描枪和临时目录验证流程,再进行现场 PLC/扫描枪联调
最少需要提供的资料
你可以先给我这些,我就能开始搭建第一版:
PLC 型号、IP、通信协议
PLC 点位表或触发/返回时序
两把扫描枪型号、IP、端口
两把扫描枪实际扫描结果示例
PCB 条码示例
文件夹路径示例和文件名示例
OK/NG 返回值定义
希望使用 WPF 还是 WinForms
已有欧姆龙项目经验表明,PLC 网络写入成功不等于 PLC 业务确认,因此握手中必须明确应答信号、超时和复位流程;本地模拟验证也不能替代真实 PLC、扫描枪和文件服务器验收。
主人 任务完成了

682
docs/纯plan.md Normal file
View File

@@ -0,0 +1,682 @@
# 欧姆龙 PLC PCB 条码文件判定系统项目计划
## 1. 项目目标
开发一个 Windows WinForms 上位机程序,实现:
1. 通过 FINS/TCP 与欧姆龙 PLC 通信。
2. 等待 PLC 发出 PCB 到位信号。
3. 同时触发两把 TCP Server 扫描枪。
4. 两把扫描枪分别扫描 PCB 正面和反面,任意一把返回有效条码即可。
5. 根据条码检查本地目录中的 `<条码>.txt` 文件。
6. 将检查结果返回 PLC。
7. 通过 WinForms 界面显示 PLC、扫描枪、文件检查和当前任务状态。
8. 对通信超时、断线、重复触发和异常情况进行处理并记录日志。
## 2. 当前需求基线
### PLC 通信
| 项目 | 配置 |
|---|---|
| 协议 | Omron FINS/TCP |
| PLC IP | `192.168.2.100` |
| 端口 | `9600` |
| UnitAddress | `0` |
| SourceNodeAddress | `0` |
| EndianFormat | `ABCD` |
| PLC 区域 | D 区 |
### PLC 点位
| 名称 | 地址 | 类型 | 初步用途 |
|---|---:|---|---|
| PcbArrived | D101 | Bool | PCB 到位信号 |
| PlcReset | D102 | Bool | PLC 复位本轮流程 |
| PlcAckRelease | D103 | Bool | PLC 确认放行结果 |
| PcBusy | D51 | Bool | 上位机忙碌状态 |
| ReleasePermit | D52 | Bool | 是否允许放行 |
| ResultCode | D401 | Word | 扫描和文件检查结果 |
初版按 D 区字值 `0/1` 处理 Bool:
```text
0 = False
1 = True
```
如果 PLC 实际使用 `D101.00` 这类 bit 位,需要在开发前调整 FINS 读写实现。
### 扫描枪
| 项目 | 扫描枪 1 | 扫描枪 2 |
|---|---|---|
| IP | `192.168.2.13` | `192.168.2.14` |
| 端口 | `2001` | `2001` |
| 设备角色 | PCB 正面 | PCB 反面 |
| 通信模式 | TCP Server | TCP Server |
| 上位机角色 | TCP Client | TCP Client |
| 触发指令 | `T` | `T` |
| 返回结束符 | 无 | 无 |
| 超时时间 | 2000 ms | 2000 ms |
### 文件检查
```text
根目录:D:\test
文件规则:D:\test\<条码>.txt
```
例如条码为:
```text
PCB202608040001
```
检查文件:
```text
D:\test\PCB202608040001.txt
```
## 3. 业务流程
```mermaid
flowchart TD
A["等待 D101 到位信号"] --> B{"D101 是否为 True"}
B -- 否 --> A
B -- 是 --> C["生成任务号并锁存本轮触发"]
C --> D["写入 D51 = 1,进入扫描中"]
D --> E["并行连接两把扫描枪并发送 T"]
E --> F{"是否收到有效条码"}
F -- 是 --> G["记录第一条有效条码"]
F -- 否且未超时 --> E
F -- 超时 --> H["写入扫描超时结果"]
G --> I["检查 D:\\test\\条码.txt"]
I --> J{"文件是否存在"}
J -- 是 --> K["D401 写入通过结果,D52 = 1"]
J -- 否 --> L["D401 写入不通过结果,D52 = 0"]
K --> M["等待 PLC 确认或复位"]
L --> M
H --> M
M --> N{"D102 PlcReset 是否为 True"}
N -- 否 --> M
N -- 是 --> O["清除 D51、D52 和本轮结果"]
O --> P["等待 D101 恢复 False"]
P --> A
```
## 4. 详细握手策略
### 等待到位
- 程序持续读取 `D101`。
- `D101=True` 时只创建一次任务。
- 在本轮任务结束前,即使 `D101` 一直保持 True,也不能重复触发扫描。
- 程序重启时,如果发现 `D101` 已经为 True,默认不立即重复处理,等待 PLC 先复位到 False。
### 扫描阶段
- 设置 `D51=1`。
- 两把扫描枪并行发送 `T`。
- 任意一把返回非空条码后结束等待。
- 另一把扫描枪的迟到结果记录为本轮无效数据,不参与下一轮。
- 两把扫描枪都无返回时,2000 ms 后判定扫描超时。
### 文件判断阶段
- 对条码进行去除空白、长度检查和非法文件名字符检查。
- 禁止条码产生 `..`、路径分隔符或越过 `D:\test` 的路径。
- 使用精确路径检查 `<根目录>\<条码>.txt`。
- 文件存在即判定为允许放行。
- 文件不存在即判定为不允许放行。
### 结果保持阶段
- `D401` 结果写入后保持。
- 文件存在时 `D52=1`。
- 文件不存在或扫描失败时 `D52=0`。
- `D51` 在本轮任务期间保持为 1。
- 收到 `D102=True` 后清除本轮输出。
- `D103` 用于记录 PLC 是否确认放行结果;其是否作为必须等待的条件,需要 PLC 程序最终确认。
## 5. 建议结果编码
结果编码必须最终与 PLC 程序确认,建议如下:
| ResultCode | 含义 |
|---:|---|
| 0 | 空闲 |
| 1 | 扫描中 |
| 2 | 文件存在,允许放行 |
| 3 | 文件不存在,不允许放行 |
| 4 | 两把扫描枪均超时 |
| 5 | 扫描枪通信异常 |
| 6 | 条码为空或格式错误 |
| 7 | PLC 通信异常 |
| 8 | PLC 复位 |
| 9 | 系统异常 |
建议 `ReleasePermit` 只表达一个含义:
```text
D52 = 1:允许放行
D52 = 0:不允许放行
```
具体 `ResultCode` 用于区分“不存在文件”“扫描超时”“通信异常”等原因。
## 6. 系统架构
建议采用分层结构:
```text
src/
PcbCheck.Desktop/
PcbCheck.Application/
PcbCheck.Domain/
PcbCheck.Devices/
PcbCheck.Infrastructure/
tests/
PcbCheck.Tests/
PcbCheck.IntegrationTests/
docs/
architecture.md
plc-protocol.md
scanner-protocol.md
deployment.md
acceptance-checklist.md
```
### PcbCheck.Desktop
负责:
- WinForms 窗体
- 当前状态显示
- 设备连接状态显示
- 当前条码显示
- 文件检查结果显示
- 任务耗时和最近结果显示
- 错误提示
- 手动连接、断开和复位操作
界面层不能直接拼装 FINS 报文或操作 TCP 流。
### PcbCheck.Application
负责:
- PCB 检测流程编排
- PLC 到位触发处理
- 双扫描枪并行扫描
- 条码选择策略
- 文件检查调用
- PLC 结果写回
- 超时和异常状态处理
- 任务去重和复位控制
核心流程建议命名为:
```text
PcbInspectionWorkflow
```
### PcbCheck.Domain
负责:
- 任务状态
- 设备状态
- 扫描结果
- 文件检查结果
- PLC 结果编码
- 业务异常类型
该层不引用 WinForms、TCP、FINS、文件系统或第三方 SDK。
### PcbCheck.Devices
负责:
- FINS/TCP PLC 客户端
- TCP 扫描枪客户端
- Fake PLC
- Fake 扫描枪
- 设备连接状态和重连机制
建议接口:
```text
IFinsPlcClient
ITcpScannerClient
IIndustrialDevice
```
### PcbCheck.Infrastructure
负责:
- 本地文件检查
- 配置加载
- 日志
- 任务记录
- 文件路径安全校验
- 系统时间和基础设施封装
## 7. 扫描枪无结束符处理方案
扫描枪返回不带结束符,不能简单地认为一次 TCP `Receive` 就是一条完整条码,因为 TCP 可能出现:
- 一条报文拆成多次接收;
- 多条报文合并接收;
- 返回后保持连接不断开。
因此开发前必须确认扫描枪的实际行为:
### 优先方案
扫描枪返回条码后主动关闭本次 TCP 连接。程序读取到连接关闭即认为一条数据完成。
### 备用方案
如果扫描枪保持长连接,则使用:
- 最大报文长度限制;
- 接收缓冲区;
- 字节间空闲超时;
- 2000 ms 总超时;
- 收到数据后短暂等待后判定报文结束。
该参数需要配置化,不能写死在界面代码中。
## 8. WinForms 界面计划
### 设备状态区
显示:
- PLC:已连接、连接中、断开、故障
- 扫描枪 1:已连接、断开、超时
- 扫描枪 2:已连接、断开、超时
- 本地目录:存在、不可访问
### 当前任务区
显示:
- 当前流程状态
- 当前任务号
- PCB 到位状态
- 当前条码
- 条码来源:正面/反面
- 文件完整路径
- 文件是否存在
- ResultCode
- ReleasePermit
- 本轮耗时
### 统计区
显示:
- 总扫描数
- 通过数量
- 不通过数量
- 扫描超时数量
- 扫描枪通信异常数量
- PLC 通信异常数量
- 最近一次任务时间
### 操作区
建议提供:
- 连接设备
- 断开设备
- 清除报警
- 手动复位
- 打开日志目录
- 打开文件根目录
- 进入配置页面
手动写 PLC 点位的功能应限制权限,避免普通操作员误操作生产信号。
## 9. 配置文件计划
所有通信参数和业务参数放入配置文件,不硬编码在程序中。
```json
{
"Plc": {
"Host": "192.168.2.100",
"Port": 9600,
"UnitAddress": 0,
"SourceNodeAddress": 0,
"EndianFormat": "ABCD",
"MemoryArea": "D",
"Inputs": {
"PcbArrived": 101,
"PlcReset": 102,
"PlcAckRelease": 103
},
"Outputs": {
"PcBusy": 51,
"ReleasePermit": 52
},
"Registers": {
"ResultCode": 401
}
},
"Scanners": [
{
"Name": "FrontScanner",
"Host": "192.168.2.13",
"Port": 2001,
"Trigger": "T",
"TimeoutMs": 2000,
"Encoding": "ASCII"
},
{
"Name": "BackScanner",
"Host": "192.168.2.14",
"Port": 2001,
"Trigger": "T",
"TimeoutMs": 2000,
"Encoding": "ASCII"
}
],
"FileCheck": {
"RootPath": "D:\\test",
"FileNamePattern": "{Barcode}.txt",
"IgnoreCase": false
},
"Workflow": {
"WaitPlcReset": true,
"RequireReleaseAck": false,
"ClearResultAfterReset": true
}
}
```
`RequireReleaseAck` 在 PLC 握手确认后再决定是否启用,默认关闭。
## 10. 日志要求
每一轮 PCB 任务生成唯一任务号,日志至少记录:
- 任务号
- 开始时间和结束时间
- PLC 地址和读写结果
- 到位信号变化
- 两把扫描枪触发内容
- 两把扫描枪原始返回内容
- 选中的条码和来源设备
- 文件检查路径和结果
- ResultCode
- ReleasePermit
- PlcAckRelease
- PlcReset
- 每个步骤耗时
- 异常消息和通信错误
日志按日期滚动保存,例如:
```text
logs\
2026-08-04\
application.log
communication.log
task.log
```
## 11. 异常和恢复策略
### PLC 断线
- 状态显示为 PLC 断开。
- 停止触发扫描。
- 进入自动重连。
- 重连成功后重新读取当前状态。
- 不重复执行已经完成的任务。
### 扫描枪断线
- 单把扫描枪断线时,仍允许另一把扫描枪继续工作。
- 两把均断线时,任务判定为通信异常。
- 支持自动重连。
- 记录具体设备名称和异常原因。
### PLC 到位信号长时间保持
- 通过任务状态和任务锁避免重复触发。
- 本轮流程未复位前不再次发送 `T`。
- 复位后要求 `D101` 先恢复 False,再允许下一轮触发。
### 文件目录不可访问
- 不判定为普通“文件不存在”。
- 返回独立的系统异常码。
- 界面显示目录访问错误。
- 日志记录完整路径和系统异常。
### 程序重启
- 启动时先连接 PLC 和扫描枪。
- 如果 `D101=True`,默认等待 PLC 复位,不自动重扫。
- 如果存在上一次未完成任务,记录为中断任务,不自动伪造结果。
## 12. 开发阶段
### 阶段一:需求和协议确认
确认:
- D 区 Bool 是字值 0/1 还是 bit;
- `D103 PlcAckRelease` 的真实含义;
- `D102 PlcReset` 的触发和清除时序;
- `D401` 结果编码;
- 扫描枪返回后是否断开 TCP;
- 扫描枪编码格式;
- 条码最大长度和合法字符;
- 两把枪同时返回时的处理规则。
交付物:
- PLC 点位表
- PLC 握手时序图
- 扫描枪报文说明
- 结果编码表
- 文件规则说明
### 阶段二:项目骨架
实现:
- WinForms 工程
- 分层项目结构
- 配置加载
- 日志系统
- 依赖注入
- 领域状态和结果模型
- 基础状态界面
### 阶段三:FINS/TCP 通信
实现:
- FINS/TCP 连接
- D 区 Bool 读取
- D 区 Bool 写入
- D401 结果写入
- 连接超时
- 读写超时
- 断线重连
- 原始通信日志
- Fake PLC 测试实现
### 阶段四:双扫描枪通信
实现:
- 两个 TCP Client
- 自动连接和重连
- 同时发送 `T`
- 无结束符报文接收
- 2000 ms 超时
- 首个有效结果选择
- 空结果和异常处理
- Fake 扫描枪测试实现
### 阶段五:文件检查和流程编排
实现:
- 条码清洗
- 文件名安全校验
- `D:\test\<条码>.txt` 精确检查
- PLC 信号与扫描枪流程串联
- 结果保持
- PLC 复位
- 任务去重
- 异常恢复
### 阶段六:WinForms 状态界面
实现:
- PLC 状态
- 两把扫描枪状态
- 文件目录状态
- 当前任务显示
- 结果和统计显示
- 日志查看入口
- 报警显示
- 手动复位和连接控制
### 阶段七:测试和交付
实现:
- 单元测试
- Fake 设备流程测试
- 通信异常测试
- 现场联调
- 发布包
- 配置模板
- 操作说明书
- 维护说明书
- 验收记录
## 13. 测试计划
### 单元测试
覆盖:
- ResultCode 映射
- 文件路径拼接
- 条码非法字符校验
- 文件存在和不存在
- D101 长保持防重复
- PLC 复位流程
- 扫描超时
- 首个有效条码选择
- 两把扫描枪同时返回
- 连接断开和重连
### 协议测试
覆盖:
- FINS/TCP 建连
- D 区读取
- D 区写入
- D401 数值写入
- ABCD 字节序
- 错误响应
- 超时响应
- PLC 断电重连
### 模拟流程测试
覆盖:
1. 正常扫描,文件存在。
2. 正常扫描,文件不存在。
3. 正面扫描成功,反面无结果。
4. 反面扫描成功,正面无结果。
5. 两把均无结果。
6. 一把断线,另一把成功。
7. 两把均断线。
8. D101 长时间保持 True。
9. 扫描过程中收到 D102。
10. PLC 结果确认。
11. 程序重启时 D101 为 True。
12. 文件目录不存在或无权限。
### 现场测试
硬件测试必须在现场或真实设备环境中执行,模拟测试不能替代现场验收。
现场至少验证:
- PLC 实际 IP 和 FINS 节点;
- D 区真实点位;
- 真实 Bool 表达方式;
- 真实扫描枪触发报文;
- 真实返回报文边界;
- 两把扫描枪同时触发;
- PLC 复位和放行确认;
- 连续生产 30~60 个 PCB 的稳定性。
## 14. 交付标准
满足以下条件才算完成:
- Release 构建成功。
- 单元测试全部通过。
- Fake PLC 和 Fake 扫描枪流程测试通过。
- 所有设备操作均有超时和取消机制。
- UI 线程无阻塞通信。
- PLC 断线能够显示并重连。
- 扫描枪断线能够显示并恢复。
- D101 长保持不会重复扫描。
- 文件检查路径不可越界。
- 结果能够正确保持和复位。
- 日志能够定位每一轮任务。
- 配置文件不包含硬编码生产密码。
- 完成真实 PLC、扫描枪和本地目录现场验收。
## 15. 预计开发周期
在 PLC 和扫描枪可及时提供测试的前提下:
| 阶段 | 预计时间 |
|---|---:|
| 需求和协议确认 | 0.5~1 天 |
| 项目骨架和配置 | 0.5~1 天 |
| FINS/TCP 通信 | 1~2 天 |
| 双扫描枪通信 | 1~2 天 |
| 流程和文件判断 | 1 天 |
| WinForms 界面 | 1~2 天 |
| 测试和修复 | 1~2 天 |
| 现场联调 | 1~2 天 |
预计开发周期为 6~10 个工作日,现场联调时间取决于 PLC 点位、扫描枪协议和设备可用情况。
## 16. 当前开发门槛
正式编写现场通信代码前,必须最终确认:
1. D101、D102、D103、D51、D52 是否使用 D 字的 0/1。
2. 扫描枪返回后是否主动断开 TCP。
3. `D401` 的正式结果编码。
4. `D103 PlcAckRelease` 的正式握手含义。
5. 文件不存在时是否直接结束,还是继续等待另一把扫描枪。
6. PLC 程序是否要求 `D51`、`D52` 和 `D401` 保持到 `D102` 复位。
7. 真实条码编码和最大长度。
本计划采用设备接口隔离、后台异步通信、超时与重连、Fake 设备测试和真实设备验收分离的方式,便于先完成可测试的软件闭环,再进行现场通信确认。

View File

@@ -0,0 +1,119 @@
param(
[string]$Scanner1Host = "192.168.1.13",
[int]$Scanner1Port = 2001,
[string]$Scanner2Host = "192.168.1.14",
[int]$Scanner2Port = 2001,
[int]$TimeoutMs = 5000,
[switch]$Parallel
)
$ErrorActionPreference = "Stop"
function Test-TcpClientConnection {
param(
[Parameter(Mandatory = $true)]
[string]$Name,
[Parameter(Mandatory = $true)]
[string]$HostName,
[Parameter(Mandatory = $true)]
[int]$Port,
[Parameter(Mandatory = $true)]
[int]$ConnectTimeoutMs
)
$client = [System.Net.Sockets.TcpClient]::new()
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
try {
Write-Host "[$Name] Connecting to $HostName`:$Port, TimeoutMs=$ConnectTimeoutMs ..."
$connectTask = $client.ConnectAsync($HostName, $Port)
$timeoutTask = [System.Threading.Tasks.Task]::Delay($ConnectTimeoutMs)
$completedTask = [System.Threading.Tasks.Task]::WhenAny($connectTask, $timeoutTask).GetAwaiter().GetResult()
$stopwatch.Stop()
if ($completedTask -ne $connectTask) {
return [pscustomobject]@{
Name = $Name
Target = "$HostName`:$Port"
Succeeded = $false
ElapsedMs = $stopwatch.ElapsedMilliseconds
LocalEndPoint = ""
RemoteEndPoint = ""
ErrorType = "Timeout"
ErrorMessage = "ConnectAsync did not complete within $ConnectTimeoutMs ms."
}
}
if ($connectTask.IsFaulted) {
$exception = $connectTask.Exception.GetBaseException()
return [pscustomobject]@{
Name = $Name
Target = "$HostName`:$Port"
Succeeded = $false
ElapsedMs = $stopwatch.ElapsedMilliseconds
LocalEndPoint = ""
RemoteEndPoint = ""
ErrorType = $exception.GetType().FullName
ErrorMessage = $exception.Message
}
}
return [pscustomobject]@{
Name = $Name
Target = "$HostName`:$Port"
Succeeded = $client.Connected
ElapsedMs = $stopwatch.ElapsedMilliseconds
LocalEndPoint = $client.Client.LocalEndPoint.ToString()
RemoteEndPoint = $client.Client.RemoteEndPoint.ToString()
ErrorType = ""
ErrorMessage = ""
}
}
catch {
$stopwatch.Stop()
return [pscustomobject]@{
Name = $Name
Target = "$HostName`:$Port"
Succeeded = $false
ElapsedMs = $stopwatch.ElapsedMilliseconds
LocalEndPoint = ""
RemoteEndPoint = ""
ErrorType = $_.Exception.GetType().FullName
ErrorMessage = $_.Exception.Message
}
}
finally {
$client.Dispose()
}
}
Write-Host "=== Scanner TCP Client Diagnostic ==="
Write-Host "Machine: $env:COMPUTERNAME"
Write-Host "User: $env:USERNAME"
Write-Host "Time: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff')"
Write-Host "Mode: $(if ($Parallel) { 'Parallel' } else { 'Sequential' })"
Write-Host ""
if ($Parallel) {
$job1 = Start-Job -ScriptBlock ${function:Test-TcpClientConnection} -ArgumentList "Scanner1", $Scanner1Host, $Scanner1Port, $TimeoutMs
$job2 = Start-Job -ScriptBlock ${function:Test-TcpClientConnection} -ArgumentList "Scanner2", $Scanner2Host, $Scanner2Port, $TimeoutMs
$results = Receive-Job -Job $job1, $job2 -Wait
Remove-Job -Job $job1, $job2
}
else {
$results = @(
Test-TcpClientConnection -Name "Scanner1" -HostName $Scanner1Host -Port $Scanner1Port -ConnectTimeoutMs $TimeoutMs
Test-TcpClientConnection -Name "Scanner2" -HostName $Scanner2Host -Port $Scanner2Port -ConnectTimeoutMs $TimeoutMs
)
}
foreach ($result in $results) {
Write-Host "--- $($result.Name) ---"
$result | Format-List
}
Write-Host ""
Write-Host "Tip: if Sequential succeeds but Parallel times out, the device or network may reject concurrent short connections."
Write-Host "Tip: close TCP debugging tools before running this script, because some scanners allow only one client connection."

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -5,6 +5,8 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF> <UseWPF>true</UseWPF>
<ApplicationIcon>Assets\AppIcon.ico</ApplicationIcon>
<InterceptorsNamespaces>$(InterceptorsNamespaces);Microsoft.Extensions.Configuration.Binder.SourceGeneration</InterceptorsNamespaces>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -18,6 +20,10 @@
<PackageReference Include="SSH.NET" Version="2020.0.2" /> <PackageReference Include="SSH.NET" Version="2020.0.2" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Resource Include="Assets\AppIcon.ico" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<None Update="appConfig.json"> <None Update="appConfig.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>

View File

@@ -20,14 +20,15 @@ public sealed class ResultCodeToTagTextConverter : IValueConverter
/// <param name="parameter">扩展参数。</param> /// <param name="parameter">扩展参数。</param>
/// <param name="culture">当前区域信息。</param> /// <param name="culture">当前区域信息。</param>
/// <returns>简短标签文本。</returns> /// <returns>简短标签文本。</returns>
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{ {
ushort code = value switch ushort code = value switch
{ {
ushort u => u, ushort s => s,
int i => (ushort)i, short s when s >= 0 => (ushort)s,
int i when i >= 0 && i <= ushort.MaxValue => (ushort)i,
WorkflowResultCode rc => (ushort)rc, WorkflowResultCode rc => (ushort)rc,
_ => (ushort)0 _ => 0
}; };
return code switch return code switch
@@ -47,7 +48,7 @@ public sealed class ResultCodeToTagTextConverter : IValueConverter
/// <param name="parameter">扩展参数。</param> /// <param name="parameter">扩展参数。</param>
/// <param name="culture">当前区域信息。</param> /// <param name="culture">当前区域信息。</param>
/// <returns>抛出不支持异常。</returns> /// <returns>抛出不支持异常。</returns>
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{ {
throw new NotSupportedException(); throw new NotSupportedException();
} }

View File

@@ -0,0 +1,64 @@
using AxiOmron.PcbCheck.Models;
using AxiOmron.PcbCheck.Services.Interfaces;
namespace AxiOmron.PcbCheck.DesignTime;
/// <summary>
/// 为设计器提供 PLC 通信能力的设计时占位服务,所有方法均为无副作用实现。
/// </summary>
public sealed class DesignTimePlcService : IPlcService
{
/// <summary>
/// 返回设计时输入信号快照。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>空的输入信号快照。</returns>
public Task<PlcSignalSnapshot> ReadSignalsAsync(CancellationToken cancellationToken)
{
return Task.FromResult(new PlcSignalSnapshot());
}
/// <summary>
/// 返回设计时监控镜像快照。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>空的监控镜像快照。</returns>
public Task<PlcMonitorSnapshot> ReadMonitorSnapshotAsync(CancellationToken cancellationToken)
{
return Task.FromResult(new PlcMonitorSnapshot());
}
/// <summary>
/// 模拟写入流程状态,不执行实际设备通信。
/// </summary>
/// <param name="state">待写入状态。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>已完成任务。</returns>
public Task WriteStateAsync(PlcProcessState state, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
/// <summary>
/// 模拟 PLC 重连,不执行实际设备通信。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>已完成任务。</returns>
public Task ForceReconnectAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
/// <summary>
/// 返回设计时测试写入成功结果,不执行实际设备通信。
/// </summary>
/// <param name="address">目标地址。</param>
/// <param name="type">变量类型。</param>
/// <param name="value">写入值文本。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>固定成功结果。</returns>
public Task<PlcWriteResult> WriteRawValueAsync(string address, PlcWriteType type, string value, CancellationToken cancellationToken)
{
return Task.FromResult(PlcWriteResult.Success(address, type, value));
}
}

View File

@@ -25,6 +25,21 @@ public sealed class DesignTimeSftpLookupService : IFileCheckService
}); });
} }
/// <summary>
/// 返回设计时产品 ID 文件删除结果,不执行实际文件操作。
/// </summary>
/// <param name="filePath">设计时文件路径。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>固定成功结果。</returns>
public Task<FileDeleteOutcome> DeleteMatchedFileAsync(string filePath, CancellationToken cancellationToken)
{
return Task.FromResult(new FileDeleteOutcome
{
IsSuccess = true,
FilePath = filePath
});
}
/// <summary> /// <summary>
/// 返回设计时文件校验通道测试结果。 /// 返回设计时文件校验通道测试结果。
/// </summary> /// </summary>

View File

@@ -15,6 +15,7 @@ public sealed class DesignTimeViewModelLocator
private readonly DesignTimeAppConfigService _appConfigService = new(); private readonly DesignTimeAppConfigService _appConfigService = new();
private readonly DesignTimeAdminUnlockDialogService _adminUnlockDialogService = new(); private readonly DesignTimeAdminUnlockDialogService _adminUnlockDialogService = new();
private readonly DesignTimeSftpLookupService _sftpLookupService = new(); private readonly DesignTimeSftpLookupService _sftpLookupService = new();
private readonly DesignTimePlcService _plcService = new();
private MainWindowViewModel? _mainWindowViewModel; private MainWindowViewModel? _mainWindowViewModel;
private SystemSettingViewModel? _systemSettingViewModel; private SystemSettingViewModel? _systemSettingViewModel;
@@ -28,7 +29,7 @@ public sealed class DesignTimeViewModelLocator
/// 获取系统设置设计时视图模型。 /// 获取系统设置设计时视图模型。
/// </summary> /// </summary>
public SystemSettingViewModel SystemSettingViewModel public SystemSettingViewModel SystemSettingViewModel
=> _systemSettingViewModel ??= new SystemSettingViewModel(_appConfigService, _sftpLookupService, _appStateStore, _dispatcherService); => _systemSettingViewModel ??= new SystemSettingViewModel(_appConfigService, _sftpLookupService, _appStateStore, _dispatcherService, _plcService);
/// <summary> /// <summary>
/// 创建首页设计时视图模型。 /// 创建首页设计时视图模型。

View File

@@ -10,6 +10,7 @@
Height="950" Height="950"
MinWidth="1400" MinWidth="1400"
MinHeight="860" MinHeight="860"
Icon="Assets/AppIcon.ico"
Background="{DynamicResource {x:Static hc:ResourceToken.BackgroundBrush}}" Background="{DynamicResource {x:Static hc:ResourceToken.BackgroundBrush}}"
WindowStartupLocation="CenterScreen"> WindowStartupLocation="CenterScreen">
<Grid> <Grid>

View File

@@ -0,0 +1,105 @@
namespace AxiOmron.PcbCheck.Models;
/// <summary>
/// 表示 PLC 测试写入支持的变量类型。
/// </summary>
public enum PlcWriteType
{
/// <summary>
/// 布尔位。
/// </summary>
Bool = 0,
/// <summary>
/// 有符号 16 位整数。
/// </summary>
Int16 = 1,
/// <summary>
/// 无符号 16 位整数。
/// </summary>
UInt16 = 2,
/// <summary>
/// 有符号 32 位整数。
/// </summary>
Int32 = 3,
/// <summary>
/// 无符号 32 位整数。
/// </summary>
UInt32 = 4,
/// <summary>
/// 单精度浮点数。
/// </summary>
Float = 5
}
/// <summary>
/// 表示一次 PLC 测试写入的执行结果。
/// </summary>
public sealed record PlcWriteResult
{
/// <summary>
/// 获取一个值,指示写入是否成功。
/// </summary>
public bool IsSuccess { get; init; }
/// <summary>
/// 获取面向用户的结果说明。
/// </summary>
public string Message { get; init; } = string.Empty;
/// <summary>
/// 获取本次写入的目标地址。
/// </summary>
public string Address { get; init; } = string.Empty;
/// <summary>
/// 获取本次写入的变量类型名称。
/// </summary>
public string TypeName { get; init; } = string.Empty;
/// <summary>
/// 获取本次实际写入的文本值。
/// </summary>
public string WrittenText { get; init; } = string.Empty;
/// <summary>
/// 创建一个表示写入成功的结果。
/// </summary>
/// <param name="address">目标地址。</param>
/// <param name="type">变量类型。</param>
/// <param name="writtenText">实际写入的文本值。</param>
/// <returns>成功结果对象。</returns>
public static PlcWriteResult Success(string address, PlcWriteType type, string writtenText)
{
return new PlcWriteResult
{
IsSuccess = true,
Address = address,
TypeName = type.ToString(),
WrittenText = writtenText,
Message = $"写入成功:{address} = {writtenText} ({type})"
};
}
/// <summary>
/// 创建一个表示写入失败的结果。
/// </summary>
/// <param name="address">目标地址。</param>
/// <param name="type">变量类型。</param>
/// <param name="message">失败说明。</param>
/// <returns>失败结果对象。</returns>
public static PlcWriteResult Failure(string address, PlcWriteType type, string message)
{
return new PlcWriteResult
{
IsSuccess = false,
Address = address,
TypeName = type.ToString(),
Message = message
};
}
}

View File

@@ -344,6 +344,33 @@ public sealed class FileCheckOutcome
public string ErrorMessage { get; set; } = string.Empty; public string ErrorMessage { get; set; } = string.Empty;
} }
/// <summary>
/// 表示一次产品 ID 文件删除结果。
/// </summary>
public sealed class FileDeleteOutcome
{
/// <summary>
/// 获取或设置删除操作是否达到目标状态。
/// 文件已经不存在时也视为成功,以保证应答重试场景下的幂等性。
/// </summary>
public bool IsSuccess { get; set; }
/// <summary>
/// 获取或设置执行删除前文件是否已经不存在。
/// </summary>
public bool WasAlreadyMissing { get; set; }
/// <summary>
/// 获取或设置本次删除针对的文件路径。
/// </summary>
public string FilePath { get; set; } = string.Empty;
/// <summary>
/// 获取或设置删除失败时的错误描述。
/// </summary>
public string ErrorMessage { get; set; } = string.Empty;
}
/// <summary> /// <summary>
/// 表示一次统一文件校验通道测试结果。 /// 表示一次统一文件校验通道测试结果。
/// </summary> /// </summary>

View File

@@ -87,6 +87,12 @@ public sealed class FileCheckOptions
/// </summary> /// </summary>
public int MaxRetryCount { get; set; } = 3; public int MaxRetryCount { get; set; } = 3;
/// <summary>
/// 获取或设置是否在收到 PLC 放行应答后删除本次命中的产品 ID 文件。
/// 默认关闭;启用后仅删除当前文件校验通道返回且位于配置根目录内的精确命中文件。
/// </summary>
public bool DeleteMatchedFileAfterPlcAck { get; set; }
/// <summary> /// <summary>
/// 获取或设置本地磁盘文件校验配置。 /// 获取或设置本地磁盘文件校验配置。
/// </summary> /// </summary>
@@ -129,6 +135,11 @@ public sealed class PlcOptions
/// </summary> /// </summary>
public byte SourceNodeAddress { get; set; } = 25; public byte SourceNodeAddress { get; set; } = 25;
/// <summary>
/// 获取或设置 PLC 寄存器字节序,默认使用 ABCD 以避免 UInt16 结果代码发生高低字节交换。
/// </summary>
public string? EndianFormat { get; set; } = "ABCD";
/// <summary> /// <summary>
/// 获取或设置轮询周期,单位为毫秒。 /// 获取或设置轮询周期,单位为毫秒。
/// </summary> /// </summary>

View File

@@ -1,15 +1,13 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<Project> <!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
<PropertyGroup> <Project>
<Configuration>Release</Configuration> <PropertyGroup>
<Platform>Any CPU</Platform> <Configuration>Release</Configuration>
<PublishDir>bin\Release\net8.0-windows\publish\</PublishDir> <Platform>Any CPU</Platform>
<PublishProtocol>FileSystem</PublishProtocol> <PublishDir>bin\Release\net8.0-windows\publish\</PublishDir>
<TargetFramework>net8.0-windows</TargetFramework> <PublishProtocol>FileSystem</PublishProtocol>
<RuntimeIdentifier>win-x64</RuntimeIdentifier> <_TargetId>Folder</_TargetId>
<SelfContained>false</SelfContained> <TargetFramework>net8.0-windows</TargetFramework>
<PublishSingleFile>true</PublishSingleFile> <SelfContained>false</SelfContained>
<IncludeNativeLibrariesForSelfExtract>false</IncludeNativeLibrariesForSelfExtract> </PropertyGroup>
<EnableCompressionInSingleFile>false</EnableCompressionInSingleFile> </Project>
</PropertyGroup>
</Project>

View File

@@ -1,10 +0,0 @@
{
"profiles": {
"AxiOmron.PcbCheck": {
"commandName": "Project",
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -1,4 +1,5 @@
using AxiOmron.PcbCheck.Models; using AxiOmron.PcbCheck.Models;
using AxiOmron.PcbCheck.Utils;
namespace AxiOmron.PcbCheck.Services.Implementations; namespace AxiOmron.PcbCheck.Services.Implementations;
@@ -18,17 +19,20 @@ public static class DualScannerResultMerger
ArgumentNullException.ThrowIfNull(first); ArgumentNullException.ThrowIfNull(first);
ArgumentNullException.ThrowIfNull(second); ArgumentNullException.ThrowIfNull(second);
if (first.IsSuccess && second.IsSuccess) bool firstHasUsableBarcode = HasUsableBarcode(first);
bool secondHasUsableBarcode = HasUsableBarcode(second);
if (firstHasUsableBarcode && secondHasUsableBarcode)
{ {
return MergeBothSucceeded(first, second); return MergeBothSucceeded(first, second);
} }
if (first.IsSuccess) if (firstHasUsableBarcode)
{ {
return CreateSuccess("扫码枪1", first, second); return CreateSuccess("扫码枪1", first, second);
} }
if (second.IsSuccess) if (secondHasUsableBarcode)
{ {
return CreateSuccess("扫码枪2", second, first); return CreateSuccess("扫码枪2", second, first);
} }
@@ -84,6 +88,18 @@ public static class DualScannerResultMerger
}; };
} }
/// <summary>
/// 判断扫码结果是否同时满足成功标记和有效条码要求。
/// </summary>
/// <param name="result">待判断的扫码结果。</param>
/// <returns>结果标记成功且条码不是空值或 <c>noRead</c> 时返回 <see langword="true"/>。</returns>
private static bool HasUsableBarcode(ScanOperationResult result)
{
return result.IsSuccess
&& !string.IsNullOrWhiteSpace(result.Barcode)
&& !BarcodeCleaner.IsNoReadResponse(result.Barcode);
}
/// <summary> /// <summary>
/// 构造包含两路扫码原始信息的追溯文本。 /// 构造包含两路扫码原始信息的追溯文本。
/// </summary> /// </summary>

View File

@@ -29,7 +29,6 @@ internal static class FileCheckPathMatcher
public static bool IsMatch(string fileName, string candidateName, string barcode) public static bool IsMatch(string fileName, string candidateName, string barcode)
{ {
return fileName.Equals(candidateName, StringComparison.OrdinalIgnoreCase) return fileName.Equals(candidateName, StringComparison.OrdinalIgnoreCase)
|| WildcardMatcher.IsMatch(fileName, candidateName) || WildcardMatcher.IsMatch(fileName, candidateName);
|| fileName.Contains(barcode, StringComparison.OrdinalIgnoreCase);
} }
} }

View File

@@ -42,6 +42,23 @@ public sealed class FileCheckService : IFileCheckService
return provider.CheckFileAsync(barcode, _config, cancellationToken); return provider.CheckFileAsync(barcode, _config, cancellationToken);
} }
/// <summary>
/// 通过当前文件校验通道删除本次精确命中的产品 ID 文件。
/// </summary>
/// <param name="filePath">文件校验返回的精确命中文件路径。</param>
/// <param name="cancellationToken">取消令牌。取消后终止尚未完成的删除操作。</param>
/// <returns>文件删除结果。</returns>
public Task<FileDeleteOutcome> DeleteMatchedFileAsync(string filePath, CancellationToken cancellationToken)
{
IFileCheckProvider? provider = ResolveProvider(_config.FileCheck.Provider);
if (provider is null)
{
return Task.FromResult(CreateUnsupportedProviderDeleteOutcome(_config.FileCheck.Provider, filePath));
}
return provider.DeleteFileAsync(filePath, _config, cancellationToken);
}
/// <summary> /// <summary>
/// 测试当前或指定配置下的文件校验通道可用性。 /// 测试当前或指定配置下的文件校验通道可用性。
/// </summary> /// </summary>
@@ -88,4 +105,20 @@ public sealed class FileCheckService : IFileCheckService
ErrorMessage = $"不支持的文件校验通道: {provider}" ErrorMessage = $"不支持的文件校验通道: {provider}"
}; };
} }
/// <summary>
/// 创建不支持通道的文件删除结果。
/// </summary>
/// <param name="provider">通道类型。</param>
/// <param name="filePath">待删除文件路径。</param>
/// <returns>文件删除结果。</returns>
private static FileDeleteOutcome CreateUnsupportedProviderDeleteOutcome(FileCheckProvider provider, string filePath)
{
return new FileDeleteOutcome
{
IsSuccess = false,
FilePath = filePath,
ErrorMessage = $"不支持的文件校验通道: {provider}"
};
}
} }

View File

@@ -61,6 +61,33 @@ public sealed class LocalFileCheckProvider : IFileCheckProvider
return await Task.Run(() => CheckInternal(barcode, config, cancellationToken), cancellationToken).ConfigureAwait(false); return await Task.Run(() => CheckInternal(barcode, config, cancellationToken), cancellationToken).ConfigureAwait(false);
} }
/// <summary>
/// 删除本地文件校验返回的精确命中文件。
/// 删除前会校验目标必须位于配置的本地根目录内,文件已不存在时按幂等成功处理。
/// </summary>
/// <param name="filePath">文件校验返回的精确命中文件路径。</param>
/// <param name="config">根配置对象。</param>
/// <param name="cancellationToken">取消令牌。取消后终止尚未完成的删除操作。</param>
/// <returns>文件删除结果。</returns>
public async Task<FileDeleteOutcome> DeleteFileAsync(string filePath, AppConfig config, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(config);
if (string.IsNullOrWhiteSpace(filePath))
{
return new FileDeleteOutcome
{
IsSuccess = false,
FilePath = filePath,
ErrorMessage = "待删除的本地产品 ID 文件路径为空。"
};
}
return await Task.Run(
() => DeleteInternal(filePath, config.FileCheck.Local.RootPath, cancellationToken),
cancellationToken)
.ConfigureAwait(false);
}
/// <summary> /// <summary>
/// 测试本地文件校验目录是否可访问。 /// 测试本地文件校验目录是否可访问。
/// </summary> /// </summary>
@@ -129,6 +156,83 @@ public sealed class LocalFileCheckProvider : IFileCheckProvider
} }
} }
/// <summary>
/// 在同步上下文中安全删除本地产品 ID 文件。
/// </summary>
/// <param name="filePath">待删除文件路径。</param>
/// <param name="rootPath">配置的本地根目录。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>文件删除结果。</returns>
private FileDeleteOutcome DeleteInternal(string filePath, string rootPath, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
string fullRootPath = Path.GetFullPath(rootPath);
string fullFilePath = Path.GetFullPath(filePath);
if (!IsPathWithinRoot(fullFilePath, fullRootPath))
{
return new FileDeleteOutcome
{
IsSuccess = false,
FilePath = fullFilePath,
ErrorMessage = $"拒绝删除本地根目录之外的文件: {fullFilePath}"
};
}
if (!File.Exists(fullFilePath))
{
return new FileDeleteOutcome
{
IsSuccess = true,
WasAlreadyMissing = true,
FilePath = fullFilePath
};
}
File.Delete(fullFilePath);
return new FileDeleteOutcome
{
IsSuccess = true,
FilePath = fullFilePath
};
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex) when (ex is IOException
or UnauthorizedAccessException
or System.Security.SecurityException
or ArgumentException
or NotSupportedException)
{
_logger.LogError(ex, "删除本地产品 ID 文件异常,路径={FilePath}", true, filePath);
return new FileDeleteOutcome
{
IsSuccess = false,
FilePath = filePath,
ErrorMessage = $"删除本地产品 ID 文件失败: {ex.Message}"
};
}
}
/// <summary>
/// 判断目标文件是否位于配置根目录内,防止外部路径被误删。
/// </summary>
/// <param name="fullFilePath">目标文件绝对路径。</param>
/// <param name="fullRootPath">配置根目录绝对路径。</param>
/// <returns>目标位于根目录内返回 <see langword="true"/>;否则返回 <see langword="false"/>。</returns>
private static bool IsPathWithinRoot(string fullFilePath, string fullRootPath)
{
string relativePath = Path.GetRelativePath(fullRootPath, fullFilePath);
return !Path.IsPathRooted(relativePath)
&& !string.Equals(relativePath, "..", StringComparison.Ordinal)
&& !relativePath.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal)
&& !relativePath.StartsWith($"..{Path.AltDirectorySeparatorChar}", StringComparison.Ordinal);
}
/// <summary> /// <summary>
/// 验证本地根目录配置和可访问性。 /// 验证本地根目录配置和可访问性。
/// </summary> /// </summary>

View File

@@ -155,6 +155,54 @@ public sealed class OmronFinsPlcService : IPlcService, IDisposable
} }
} }
/// <summary>
/// 向指定地址写入一个原始值,供调试界面手动测试使用。
/// 复用与回读相同的 <see cref="_ioLock"/> 串行化,且不修改 <see cref="_lastWrittenState"/>,因此不会干扰后台轮询回读与流程状态机写入。
/// </summary>
/// <param name="address">完整的 FINS 地址字符串,例如 <c>D100</c> 或 <c>D100.0</c>。</param>
/// <param name="type">写入的变量类型。</param>
/// <param name="value">待写入值的文本表示。</param>
/// <param name="cancellationToken">取消令牌。取消后立即终止本次写入并返回已取消任务。</param>
/// <returns>包含成功状态与说明的写入结果。</returns>
public async Task<PlcWriteResult> WriteRawValueAsync(string address, PlcWriteType type, string value, CancellationToken cancellationToken)
{
string trimmedAddress = address.Trim();
if (string.IsNullOrEmpty(trimmedAddress))
{
return PlcWriteResult.Failure(trimmedAddress, type, "写入失败:地址不能为空。");
}
if (!TryParseWriteValue(type, value, out object parsedValue, out string parseError))
{
return PlcWriteResult.Failure(trimmedAddress, type, $"写入失败:{parseError}");
}
await _ioLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
cancellationToken.ThrowIfCancellationRequested();
EnsureConnected();
WriteRawValueUnsafe(trimmedAddress, parsedValue);
string writtenText = FormatWrittenText(parsedValue);
_logger.LogInformation("测试写入 PLC 成功,地址={Address},类型={Type},值={Value}", true, trimmedAddress, type, writtenText);
return PlcWriteResult.Success(trimmedAddress, type, writtenText);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
// 仅记录并返回失败结果,不断开连接,避免干扰后台轮询回读循环。
_logger.LogWarning(ex, "测试写入 PLC 失败,地址={Address},类型={Type}", true, trimmedAddress, type);
return PlcWriteResult.Failure(trimmedAddress, type, $"写入失败:{ex.Message}");
}
finally
{
_ioLock.Release();
}
}
/// <summary> /// <summary>
/// 释放 PLC 通信资源。 /// 释放 PLC 通信资源。
/// </summary> /// </summary>
@@ -181,7 +229,8 @@ public sealed class OmronFinsPlcService : IPlcService, IDisposable
} }
DisconnectUnsafe(); DisconnectUnsafe();
var client = new OmronFinsClient(_options.Host, _options.Port, _options.ConnectTimeoutMs, EndianFormat.ABCD) EndianFormat endianFormat = ResolveEndianFormat(_options);
var client = new OmronFinsClient(_options.Host, _options.Port, _options.ConnectTimeoutMs, endianFormat)
{ {
UnitAddress = _options.UnitAddress, UnitAddress = _options.UnitAddress,
SA1 = _options.SourceNodeAddress SA1 = _options.SourceNodeAddress
@@ -189,7 +238,28 @@ public sealed class OmronFinsPlcService : IPlcService, IDisposable
var openResult = client.Open(); var openResult = client.Open();
EnsureSuccess(openResult.IsSucceed, openResult.Err, "连接 OMRON FINS PLC 失败"); EnsureSuccess(openResult.IsSucceed, openResult.Err, "连接 OMRON FINS PLC 失败");
_client = client; _client = client;
_logger.LogInformation("已通过 IoTClient FINS 连接 PLC {Host}:{Port}", false, _options.Host, _options.Port); _logger.LogInformation("已通过 IoTClient FINS 连接 PLC {Host}:{Port},字节序={EndianFormat}", false, _options.Host, _options.Port, endianFormat);
}
/// <summary>
/// 解析 PLC 字节序配置。
/// </summary>
/// <param name="options">PLC 通信配置。</param>
/// <returns>IoTClient 使用的字节序枚举。</returns>
/// <exception cref="ArgumentNullException">当 <paramref name="options"/> 为空时抛出。</exception>
/// <exception cref="InvalidOperationException">当字节序配置为空或无法识别时抛出。</exception>
internal static EndianFormat ResolveEndianFormat(PlcOptions options)
{
ArgumentNullException.ThrowIfNull(options);
string? configuredValue = options.EndianFormat?.Trim();
if (string.IsNullOrWhiteSpace(configuredValue)
|| !Enum.TryParse(configuredValue, ignoreCase: true, out EndianFormat endianFormat))
{
throw new InvalidOperationException($"PLC 字节序配置无效:{options.EndianFormat}。支持值:ABCD、BADC、CDAB、DCBA。");
}
return endianFormat;
} }
/// <summary> /// <summary>
@@ -206,7 +276,7 @@ public sealed class OmronFinsPlcService : IPlcService, IDisposable
} }
/// <summary> /// <summary>
/// 读取单个 D 区无符号整数。 /// 读取单个 D 区无符号 16 位整数。
/// </summary> /// </summary>
/// <param name="address">FINS 地址。</param> /// <param name="address">FINS 地址。</param>
/// <returns>读取到的寄存器值。</returns> /// <returns>读取到的寄存器值。</returns>
@@ -225,9 +295,10 @@ public sealed class OmronFinsPlcService : IPlcService, IDisposable
private void WriteChangedState(PlcProcessState state) private void WriteChangedState(PlcProcessState state)
{ {
var previous = _lastWrittenState; var previous = _lastWrittenState;
ushort? previousResultCode = previous is null ? null : previous.ResultCode;
WriteBooleanIfChanged(previous?.PcBusy, state.PcBusy, ToDataMemoryAddress(_options.Outputs.PcBusy)); WriteBooleanIfChanged(previous?.PcBusy, state.PcBusy, ToDataMemoryAddress(_options.Outputs.PcBusy));
WriteBooleanIfChanged(previous?.ReleasePermit, state.ReleasePermit, ToDataMemoryAddress(_options.Outputs.ReleasePermit)); WriteBooleanIfChanged(previous?.ReleasePermit, state.ReleasePermit, ToDataMemoryAddress(_options.Outputs.ReleasePermit));
WriteUInt16IfChanged(previous?.ResultCode, state.ResultCode, ToDataMemoryAddress(_options.Registers.ResultCode)); WriteUInt16IfChanged(previousResultCode, state.ResultCode, ToDataMemoryAddress(_options.Registers.ResultCode));
} }
/// <summary> /// <summary>
@@ -249,7 +320,7 @@ public sealed class OmronFinsPlcService : IPlcService, IDisposable
} }
/// <summary> /// <summary>
/// 仅当值发生变化时写入单个 D 区无符号整数。 /// 仅当值发生变化时写入单个 D 区无符号 16 位整数。
/// </summary> /// </summary>
/// <param name="previous">上一值。</param> /// <param name="previous">上一值。</param>
/// <param name="current">当前值。</param> /// <param name="current">当前值。</param>
@@ -266,6 +337,147 @@ public sealed class OmronFinsPlcService : IPlcService, IDisposable
EnsureSuccess(result.IsSucceed, result.Err, $"写入 FINS UInt16 失败,地址={address},值={current}"); EnsureSuccess(result.IsSucceed, result.Err, $"写入 FINS UInt16 失败,地址={address},值={current}");
} }
/// <summary>
/// 按变量类型将文本值解析为强类型对象。
/// </summary>
/// <param name="type">变量类型。</param>
/// <param name="value">待解析的文本值。</param>
/// <param name="parsedValue">解析成功时返回的强类型值。</param>
/// <param name="error">解析失败时返回的错误说明。</param>
/// <returns>解析成功返回 <see langword="true"/>;否则返回 <see langword="false"/>。</returns>
private static bool TryParseWriteValue(PlcWriteType type, string value, out object parsedValue, out string error)
{
parsedValue = default!;
error = string.Empty;
string text = value.Trim();
if (string.IsNullOrEmpty(text))
{
error = "写入值不能为空。";
return false;
}
switch (type)
{
case PlcWriteType.Bool:
if (TryParseBoolean(text, out bool boolValue))
{
parsedValue = boolValue;
return true;
}
error = "布尔值仅支持 1/0、true/false。";
return false;
case PlcWriteType.Int16:
if (short.TryParse(text, out short shortValue))
{
parsedValue = shortValue;
return true;
}
error = $"无法将 '{text}' 解析为 Int16。";
return false;
case PlcWriteType.UInt16:
if (ushort.TryParse(text, out ushort ushortValue))
{
parsedValue = ushortValue;
return true;
}
error = $"无法将 '{text}' 解析为 UInt16。";
return false;
case PlcWriteType.Int32:
if (int.TryParse(text, out int intValue))
{
parsedValue = intValue;
return true;
}
error = $"无法将 '{text}' 解析为 Int32。";
return false;
case PlcWriteType.UInt32:
if (uint.TryParse(text, out uint uintValue))
{
parsedValue = uintValue;
return true;
}
error = $"无法将 '{text}' 解析为 UInt32。";
return false;
case PlcWriteType.Float:
if (float.TryParse(text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float floatValue))
{
parsedValue = floatValue;
return true;
}
error = $"无法将 '{text}' 解析为 Float。";
return false;
default:
error = $"不支持的变量类型:{type}。";
return false;
}
}
/// <summary>
/// 解析布尔文本,接受 1/0 与 true/false(忽略大小写)。
/// </summary>
/// <param name="text">待解析文本。</param>
/// <param name="result">解析结果。</param>
/// <returns>解析成功返回 <see langword="true"/>;否则返回 <see langword="false"/>。</returns>
private static bool TryParseBoolean(string text, out bool result)
{
switch (text.ToLowerInvariant())
{
case "1":
case "true":
result = true;
return true;
case "0":
case "false":
result = false;
return true;
default:
result = false;
return false;
}
}
/// <summary>
/// 按强类型值调用 IoTClient 对应写入重载。
/// </summary>
/// <param name="address">FINS 地址。</param>
/// <param name="parsedValue">已解析的强类型值。</param>
private void WriteRawValueUnsafe(string address, object parsedValue)
{
var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。");
var result = parsedValue switch
{
bool boolValue => client.Write(address, boolValue),
short shortValue => client.Write(address, shortValue),
ushort ushortValue => client.Write(address, ushortValue),
int intValue => client.Write(address, intValue),
uint uintValue => client.Write(address, uintValue),
float floatValue => client.Write(address, floatValue),
_ => throw new InvalidOperationException($"不支持的写入值类型:{parsedValue.GetType().Name}。")
};
EnsureSuccess(result.IsSucceed, result.Err, $"写入 FINS 值失败,地址={address}");
}
/// <summary>
/// 将强类型值格式化为用于展示的文本。
/// </summary>
/// <param name="parsedValue">已解析的强类型值。</param>
/// <returns>文本表示。</returns>
private static string FormatWrittenText(object parsedValue)
{
return parsedValue switch
{
float floatValue => floatValue.ToString(System.Globalization.CultureInfo.InvariantCulture),
_ => parsedValue.ToString() ?? string.Empty
};
}
/// <summary> /// <summary>
/// 将数字形式 D 区地址转换为 IoTClient FINS 地址字符串。 /// 将数字形式 D 区地址转换为 IoTClient FINS 地址字符串。
/// </summary> /// </summary>

View File

@@ -60,6 +60,33 @@ public sealed class SftpLookupService : IFileCheckProvider
return await Task.Run(() => CheckInternal(barcode, config, cancellationToken), cancellationToken).ConfigureAwait(false); return await Task.Run(() => CheckInternal(barcode, config, cancellationToken), cancellationToken).ConfigureAwait(false);
} }
/// <summary>
/// 删除 SFTP 文件校验返回的精确命中文件。
/// 删除前会校验目标必须位于配置的 SFTP 根目录内,文件已不存在时按幂等成功处理。
/// </summary>
/// <param name="filePath">文件校验返回的精确命中文件路径。</param>
/// <param name="config">根配置对象。</param>
/// <param name="cancellationToken">取消令牌。取消后终止尚未完成的删除操作。</param>
/// <returns>文件删除结果。</returns>
public async Task<FileDeleteOutcome> DeleteFileAsync(string filePath, AppConfig config, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(config);
if (string.IsNullOrWhiteSpace(filePath))
{
return new FileDeleteOutcome
{
IsSuccess = false,
FilePath = filePath,
ErrorMessage = "待删除的 SFTP 产品 ID 文件路径为空。"
};
}
return await Task.Run(
() => DeleteInternal(filePath, config.Sftp, cancellationToken),
cancellationToken)
.ConfigureAwait(false);
}
/// <summary> /// <summary>
/// 测试当前或指定配置下的 SFTP 连接可用性。 /// 测试当前或指定配置下的 SFTP 连接可用性。
/// </summary> /// </summary>
@@ -178,6 +205,83 @@ public sealed class SftpLookupService : IFileCheckProvider
} }
} }
/// <summary>
/// 在同步上下文中安全删除 SFTP 产品 ID 文件。
/// </summary>
/// <param name="filePath">待删除文件路径。</param>
/// <param name="options">SFTP 配置。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>文件删除结果。</returns>
private FileDeleteOutcome DeleteInternal(string filePath, SftpOptions options, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
string rootPath = NormalizeDirectory(options.RootPath);
string normalizedFilePath = NormalizeRemotePath(filePath);
if (!IsRemotePathWithinRoot(normalizedFilePath, rootPath))
{
return new FileDeleteOutcome
{
IsSuccess = false,
FilePath = normalizedFilePath,
ErrorMessage = $"拒绝删除 SFTP 根目录之外的文件: {normalizedFilePath}"
};
}
using var client = CreateClient(options);
ConnectClient(client, options);
cancellationToken.ThrowIfCancellationRequested();
if (!client.Exists(normalizedFilePath))
{
return new FileDeleteOutcome
{
IsSuccess = true,
WasAlreadyMissing = true,
FilePath = normalizedFilePath
};
}
client.DeleteFile(normalizedFilePath);
if (client.Exists(normalizedFilePath))
{
return new FileDeleteOutcome
{
IsSuccess = false,
FilePath = normalizedFilePath,
ErrorMessage = $"SFTP 删除命令执行后文件仍然存在: {normalizedFilePath}"
};
}
return new FileDeleteOutcome
{
IsSuccess = true,
FilePath = normalizedFilePath
};
}
catch (OperationCanceledException)
{
throw;
}
catch (SshAuthenticationException ex)
{
_logger.LogError(ex, "删除 SFTP 产品 ID 文件时认证失败,路径={FilePath}", true, filePath);
return CreateDeleteFailure(filePath, $"SFTP 认证失败: {ex.Message}");
}
catch (SshConnectionException ex)
{
_logger.LogError(ex, "删除 SFTP 产品 ID 文件时连接失败,路径={FilePath}", true, filePath);
return CreateDeleteFailure(filePath, $"SFTP 连接失败: {ex.Message}");
}
catch (Exception ex)
{
_logger.LogError(ex, "删除 SFTP 产品 ID 文件异常,路径={FilePath}", true, filePath);
return CreateDeleteFailure(filePath, $"删除 SFTP 产品 ID 文件失败: {ex.Message}");
}
}
/// <summary> /// <summary>
/// 根据指定配置创建 SFTP 客户端。 /// 根据指定配置创建 SFTP 客户端。
/// </summary> /// </summary>
@@ -216,10 +320,60 @@ public sealed class SftpLookupService : IFileCheckProvider
/// <param name="path">原始目录路径。</param> /// <param name="path">原始目录路径。</param>
/// <returns>标准化目录路径。</returns> /// <returns>标准化目录路径。</returns>
private static string NormalizeDirectory(string path) private static string NormalizeDirectory(string path)
{
string normalizedPath = path.Replace('\\', '/');
return normalizedPath.Length > 1 ? normalizedPath.TrimEnd('/') : normalizedPath;
}
/// <summary>
/// 统一远程文件路径格式。
/// </summary>
/// <param name="path">原始远程文件路径。</param>
/// <returns>标准化远程文件路径。</returns>
private static string NormalizeRemotePath(string path)
{ {
return path.Replace('\\', '/').TrimEnd('/'); return path.Replace('\\', '/').TrimEnd('/');
} }
/// <summary>
/// 判断远程文件是否位于配置的 SFTP 根目录内,防止外部路径被误删。
/// </summary>
/// <param name="filePath">标准化后的远程文件路径。</param>
/// <param name="rootPath">标准化后的 SFTP 根目录。</param>
/// <returns>目标位于根目录内返回 <see langword="true"/>;否则返回 <see langword="false"/>。</returns>
private static bool IsRemotePathWithinRoot(string filePath, string rootPath)
{
if (string.IsNullOrWhiteSpace(filePath) || string.IsNullOrWhiteSpace(rootPath))
{
return false;
}
if (filePath.Split('/', StringSplitOptions.RemoveEmptyEntries).Any(segment => segment == ".."))
{
return false;
}
string rootPrefix = rootPath.EndsWith("/", StringComparison.Ordinal) ? rootPath : $"{rootPath}/";
return !string.Equals(filePath, rootPath, StringComparison.Ordinal)
&& filePath.StartsWith(rootPrefix, StringComparison.Ordinal);
}
/// <summary>
/// 创建 SFTP 文件删除失败结果。
/// </summary>
/// <param name="filePath">删除目标路径。</param>
/// <param name="errorMessage">错误描述。</param>
/// <returns>文件删除失败结果。</returns>
private static FileDeleteOutcome CreateDeleteFailure(string filePath, string errorMessage)
{
return new FileDeleteOutcome
{
IsSuccess = false,
FilePath = filePath,
ErrorMessage = errorMessage
};
}
/// <summary> /// <summary>
/// 在同步上下文中执行 SFTP 连通性测试。 /// 在同步上下文中执行 SFTP 连通性测试。
/// </summary> /// </summary>

View File

@@ -76,10 +76,19 @@ public sealed class TcpDualScannerService : IScannerService
/// <returns>两把扫码枪均可连接返回 <see langword="true"/>;否则返回 <see langword="false"/>。</returns> /// <returns>两把扫码枪均可连接返回 <see langword="true"/>;否则返回 <see langword="false"/>。</returns>
public async Task<bool> TestConnectionAsync(CancellationToken cancellationToken) public async Task<bool> TestConnectionAsync(CancellationToken cancellationToken)
{ {
Task<bool> first = TestEndpointAsync(_options.Scanner1, cancellationToken); _logger.LogInformation(
Task<bool> second = TestEndpointAsync(_options.Scanner2, cancellationToken); "开始测试 TCP 扫码枪连接(串行): {Scanner1Name}={Scanner1Host}:{Scanner1Port}, {Scanner2Name}={Scanner2Host}:{Scanner2Port}, ConnectTimeoutMs={ConnectTimeoutMs}",
bool[] results = await Task.WhenAll(first, second).ConfigureAwait(false); false,
return results.All(static result => result); _options.Scanner1.Name,
_options.Scanner1.Host,
_options.Scanner1.Port,
_options.Scanner2.Name,
_options.Scanner2.Host,
_options.Scanner2.Port,
_options.ConnectTimeoutMs);
bool first = await TestEndpointAsync(_options.Scanner1, cancellationToken).ConfigureAwait(false);
bool second = await TestEndpointAsync(_options.Scanner2, cancellationToken).ConfigureAwait(false);
return first && second;
} }
/// <summary> /// <summary>
@@ -113,14 +122,17 @@ public sealed class TcpDualScannerService : IScannerService
string rawMessage = await ReadResponseAsync(stream, _options.ReadTimeoutMs, cancellationToken).ConfigureAwait(false); string rawMessage = await ReadResponseAsync(stream, _options.ReadTimeoutMs, cancellationToken).ConfigureAwait(false);
string barcode = BarcodeCleaner.Clean(rawMessage); string barcode = BarcodeCleaner.Clean(rawMessage);
if (string.IsNullOrEmpty(barcode)) bool isNoReadResponse = BarcodeCleaner.IsNoReadResponse(barcode);
if (string.IsNullOrEmpty(barcode) || isNoReadResponse)
{ {
return new ScanOperationResult return new ScanOperationResult
{ {
IsSuccess = false, IsSuccess = false,
DeviceConnected = true, DeviceConnected = true,
RawMessage = rawMessage, RawMessage = rawMessage,
ErrorMessage = $"{endpoint.Name} 返回空字符串或仅包含控制字符。", ErrorMessage = isNoReadResponse
? $"{endpoint.Name} 返回 noRead,未读到有效条码。"
: $"{endpoint.Name} 返回空字符串或仅包含控制字符。",
DurationMs = stopwatch.ElapsedMilliseconds DurationMs = stopwatch.ElapsedMilliseconds
}; };
} }
@@ -171,15 +183,35 @@ public sealed class TcpDualScannerService : IScannerService
/// <returns>可连接返回 <see langword="true"/>。</returns> /// <returns>可连接返回 <see langword="true"/>。</returns>
private async Task<bool> TestEndpointAsync(TcpScannerEndpointOptions endpoint, CancellationToken cancellationToken) private async Task<bool> TestEndpointAsync(TcpScannerEndpointOptions endpoint, CancellationToken cancellationToken)
{ {
var stopwatch = Stopwatch.StartNew();
try try
{ {
using var client = new TcpClient(); using var client = new TcpClient();
await ConnectAsync(client, endpoint, cancellationToken).ConfigureAwait(false); await ConnectAsync(client, endpoint, cancellationToken).ConfigureAwait(false);
stopwatch.Stop();
_logger.LogInformation(
"{ScannerName} TCP 连接测试成功,目标={Host}:{Port}, LocalEndPoint={LocalEndPoint}, RemoteEndPoint={RemoteEndPoint}, ElapsedMs={ElapsedMs}",
false,
endpoint.Name,
endpoint.Host,
endpoint.Port,
client.Client.LocalEndPoint?.ToString() ?? string.Empty,
client.Client.RemoteEndPoint?.ToString() ?? string.Empty,
stopwatch.ElapsedMilliseconds);
return client.Connected; return client.Connected;
} }
catch (Exception ex) when (ex is not OperationCanceledException) catch (Exception ex) when (ex is not OperationCanceledException)
{ {
_logger.LogWarning(ex, "{ScannerName} TCP 连接测试失败", false, endpoint.Name); stopwatch.Stop();
_logger.LogWarning(
ex,
"{ScannerName} TCP 连接测试失败,目标={Host}:{Port}, ConnectTimeoutMs={ConnectTimeoutMs}, ElapsedMs={ElapsedMs}",
false,
endpoint.Name,
endpoint.Host,
endpoint.Port,
_options.ConnectTimeoutMs,
stopwatch.ElapsedMilliseconds);
return false; return false;
} }
} }
@@ -193,9 +225,17 @@ public sealed class TcpDualScannerService : IScannerService
/// <returns>表示连接完成的任务。</returns> /// <returns>表示连接完成的任务。</returns>
private async Task ConnectAsync(TcpClient client, TcpScannerEndpointOptions endpoint, CancellationToken cancellationToken) private async Task ConnectAsync(TcpClient client, TcpScannerEndpointOptions endpoint, CancellationToken cancellationToken)
{ {
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cancellationToken.ThrowIfCancellationRequested();
timeoutCts.CancelAfter(TimeSpan.FromMilliseconds(_options.ConnectTimeoutMs)); try
await client.ConnectAsync(endpoint.Host, endpoint.Port, timeoutCts.Token).ConfigureAwait(false); {
await client.ConnectAsync(endpoint.Host, endpoint.Port)
.WaitAsync(TimeSpan.FromMilliseconds(_options.ConnectTimeoutMs), cancellationToken)
.ConfigureAwait(false);
}
catch (TimeoutException ex)
{
throw new TimeoutException($"{endpoint.Name} TCP 连接超时,目标={endpoint.Host}:{endpoint.Port}。", ex);
}
} }
/// <summary> /// <summary>

View File

@@ -29,6 +29,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
private bool _releaseSent; private bool _releaseSent;
private DateTimeOffset? _currentBoardStartedAt; private DateTimeOffset? _currentBoardStartedAt;
private string _currentBarcode = string.Empty; private string _currentBarcode = string.Empty;
private string _matchedProductFilePath = string.Empty;
private int _scanTryCount; private int _scanTryCount;
private int _sftpTryCount; private int _sftpTryCount;
private WorkflowResultCode _resultCode = WorkflowResultCode.None; private WorkflowResultCode _resultCode = WorkflowResultCode.None;
@@ -701,6 +702,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
lock (_stateSyncRoot) lock (_stateSyncRoot)
{ {
_resultCode = WorkflowResultCode.Passed; _resultCode = WorkflowResultCode.Passed;
_matchedProductFilePath = outcome.MatchedFilePath;
} }
await ApplyProcessStateAsync( await ApplyProcessStateAsync(
@@ -819,6 +821,13 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
{ {
_appLogger.LogWarning("放行应答等待超时,按脉冲策略自动清除放行位。", true); _appLogger.LogWarning("放行应答等待超时,按脉冲策略自动清除放行位。", true);
} }
else
{
_appLogger.LogInformation(
"已收到 PLC 放行应答,地址=D{Address}。",
true,
_config.Plc.Inputs.PlcAckRelease);
}
await Task.Delay(_config.Plc.ReleasePulseMs, cancellationToken).ConfigureAwait(false); await Task.Delay(_config.Plc.ReleasePulseMs, cancellationToken).ConfigureAwait(false);
await ApplyProcessStateAsync( await ApplyProcessStateAsync(
@@ -831,6 +840,12 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
}, },
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
// 先结束 PLC 放行脉冲,再执行可能较慢的本地或 SFTP 删除,避免文件 I/O 延长放行位保持时间。
if (ackReceived)
{
await DeleteMatchedProductFileAfterAckIfEnabledAsync(cancellationToken).ConfigureAwait(false);
}
var completedAt = DateTimeOffset.Now; var completedAt = DateTimeOffset.Now;
UpdateSnapshot(snapshot => UpdateSnapshot(snapshot =>
{ {
@@ -860,6 +875,84 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
_appLogger.LogInformation($"单板流程完成,结果={record.ResultDescription}", true); _appLogger.LogInformation($"单板流程完成,结果={record.ResultDescription}", true);
} }
/// <summary>
/// 在收到 PLC 放行应答后,按配置删除本次文件校验精确命中的产品 ID 文件。
/// 删除失败只记录错误,不回滚已经由 PLC 确认的物理放行动作。
/// </summary>
/// <param name="cancellationToken">取消令牌。取消后终止尚未完成的删除操作并结束当前流程。</param>
/// <returns>表示删除处理完成的任务。</returns>
private async Task DeleteMatchedProductFileAfterAckIfEnabledAsync(CancellationToken cancellationToken)
{
if (!_config.FileCheck.DeleteMatchedFileAfterPlcAck)
{
return;
}
string filePath;
string barcode;
lock (_stateSyncRoot)
{
filePath = _matchedProductFilePath;
barcode = _currentBarcode;
}
if (string.IsNullOrWhiteSpace(filePath))
{
_appLogger.LogWarning(
"已收到 PLC 放行应答,但本次流程没有可删除的产品 ID 文件,条码={Barcode}。",
true,
barcode);
return;
}
try
{
FileDeleteOutcome outcome = await _fileCheckService
.DeleteMatchedFileAsync(filePath, cancellationToken)
.ConfigureAwait(false);
if (!outcome.IsSuccess)
{
_appLogger.LogError(
"PLC 放行应答后删除产品 ID 文件失败,条码={Barcode},路径={FilePath},原因={ErrorMessage}",
true,
barcode,
outcome.FilePath,
outcome.ErrorMessage);
return;
}
if (outcome.WasAlreadyMissing)
{
_appLogger.LogWarning(
"PLC 放行应答后产品 ID 文件已不存在,无需重复删除,条码={Barcode},路径={FilePath}",
true,
barcode,
outcome.FilePath);
return;
}
_appLogger.LogInformation(
"PLC 放行应答后已删除产品 ID 文件,条码={Barcode},路径={FilePath}",
true,
barcode,
outcome.FilePath);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_appLogger.LogError(
ex,
"PLC 放行应答后删除产品 ID 文件异常,条码={Barcode},路径={FilePath}",
true,
barcode,
filePath);
}
}
/// <summary> /// <summary>
/// 在应用启动时恢复当天处理记录与统计信息。 /// 在应用启动时恢复当天处理记录与统计信息。
/// </summary> /// </summary>
@@ -1015,6 +1108,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
_currentBoardStartedAt = DateTimeOffset.Now; _currentBoardStartedAt = DateTimeOffset.Now;
_releaseSent = false; _releaseSent = false;
_currentBarcode = string.Empty; _currentBarcode = string.Empty;
_matchedProductFilePath = string.Empty;
_scanTryCount = 0; _scanTryCount = 0;
_sftpTryCount = 0; _sftpTryCount = 0;
_resultCode = WorkflowResultCode.Processing; _resultCode = WorkflowResultCode.Processing;
@@ -1035,6 +1129,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
_releaseSent = false; _releaseSent = false;
_currentBoardStartedAt = null; _currentBoardStartedAt = null;
_currentBarcode = string.Empty; _currentBarcode = string.Empty;
_matchedProductFilePath = string.Empty;
_scanTryCount = 0; _scanTryCount = 0;
_sftpTryCount = 0; _sftpTryCount = 0;
_resultCode = WorkflowResultCode.None; _resultCode = WorkflowResultCode.None;

View File

@@ -124,6 +124,17 @@ public interface IPlcService
/// <param name="cancellationToken">取消令牌。</param> /// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示重连完成的任务。</returns> /// <returns>表示重连完成的任务。</returns>
Task ForceReconnectAsync(CancellationToken cancellationToken); Task ForceReconnectAsync(CancellationToken cancellationToken);
/// <summary>
/// 向指定地址写入一个原始值,供调试界面手动测试使用。
/// 该写入复用与回读相同的 IO 串行化机制,且不会影响流程状态机的写入缓存,因此不会干扰后台轮询回读。
/// </summary>
/// <param name="address">完整的 FINS 地址字符串,例如 <c>D100</c> 或 <c>D100.0</c>。</param>
/// <param name="type">写入的变量类型。</param>
/// <param name="value">待写入值的文本表示,由实现按 <paramref name="type"/> 解析。</param>
/// <param name="cancellationToken">取消令牌。取消后立即终止本次写入并返回已取消任务。</param>
/// <returns>包含成功状态与说明的写入结果。</returns>
Task<PlcWriteResult> WriteRawValueAsync(string address, PlcWriteType type, string value, CancellationToken cancellationToken);
} }
/// <summary> /// <summary>
@@ -166,6 +177,14 @@ public interface IFileCheckService
/// <returns>文件校验结果。</returns> /// <returns>文件校验结果。</returns>
Task<FileCheckOutcome> CheckFileAsync(string barcode, CancellationToken cancellationToken); Task<FileCheckOutcome> CheckFileAsync(string barcode, CancellationToken cancellationToken);
/// <summary>
/// 通过当前文件校验通道删除本次精确命中的产品 ID 文件。
/// </summary>
/// <param name="filePath">文件校验返回的精确命中文件路径。</param>
/// <param name="cancellationToken">取消令牌。取消后终止尚未完成的删除操作。</param>
/// <returns>文件删除结果。</returns>
Task<FileDeleteOutcome> DeleteMatchedFileAsync(string filePath, CancellationToken cancellationToken);
/// <summary> /// <summary>
/// 测试当前或指定配置下的文件校验通道可用性。 /// 测试当前或指定配置下的文件校验通道可用性。
/// </summary> /// </summary>
@@ -194,6 +213,16 @@ public interface IFileCheckProvider
/// <returns>文件校验结果。</returns> /// <returns>文件校验结果。</returns>
Task<FileCheckOutcome> CheckFileAsync(string barcode, AppConfig config, CancellationToken cancellationToken); Task<FileCheckOutcome> CheckFileAsync(string barcode, AppConfig config, CancellationToken cancellationToken);
/// <summary>
/// 删除当前通道中由文件校验返回的精确命中文件。
/// 实现必须限制删除目标位于当前配置根目录内。
/// </summary>
/// <param name="filePath">文件校验返回的精确命中文件路径。</param>
/// <param name="config">根配置对象。</param>
/// <param name="cancellationToken">取消令牌。取消后终止尚未完成的删除操作。</param>
/// <returns>文件删除结果。</returns>
Task<FileDeleteOutcome> DeleteFileAsync(string filePath, AppConfig config, CancellationToken cancellationToken);
/// <summary> /// <summary>
/// 测试当前通道配置是否可用。 /// 测试当前通道配置是否可用。
/// </summary> /// </summary>

View File

@@ -33,6 +33,8 @@ internal static class StringEscapeHelper
/// </summary> /// </summary>
internal static class BarcodeCleaner internal static class BarcodeCleaner
{ {
private const string NoReadToken = "noread";
/// <summary> /// <summary>
/// 去除条码中的首尾空白与控制字符。 /// 去除条码中的首尾空白与控制字符。
/// </summary> /// </summary>
@@ -56,6 +58,16 @@ internal static class BarcodeCleaner
return builder.ToString().Trim(); return builder.ToString().Trim();
} }
/// <summary>
/// 判断扫码枪返回值是否表示未读到条码。
/// </summary>
/// <param name="value">已清洗或待判断的扫码枪返回值。</param>
/// <returns>返回值为不区分大小写的 <c>noRead</c> 时返回 <see langword="true"/>;否则返回 <see langword="false"/>。</returns>
public static bool IsNoReadResponse(string? value)
{
return string.Equals(value?.Trim(), NoReadToken, StringComparison.OrdinalIgnoreCase);
}
} }
/// <summary> /// <summary>

View File

@@ -182,6 +182,12 @@ public partial class MainWindowViewModel : ObservableObject
[ObservableProperty] [ObservableProperty]
private string _lastLogUpdateTime = "-"; private string _lastLogUpdateTime = "-";
/// <summary>
/// 获取或设置运行日志区域是否展开;该状态仅影响首页布局,不参与业务流程。
/// </summary>
[ObservableProperty]
private bool _isLogAreaExpanded;
/// <summary> /// <summary>
/// 获取或设置今日异常日志条数。 /// 获取或设置今日异常日志条数。
/// </summary> /// </summary>
@@ -367,6 +373,15 @@ public partial class MainWindowViewModel : ObservableObject
LastLogUpdateTime = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss"); LastLogUpdateTime = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss");
} }
/// <summary>
/// 切换运行日志区域展开状态,收起或恢复上方状态概览区域。
/// </summary>
[RelayCommand]
private void ToggleLogArea()
{
IsLogAreaExpanded = !IsLogAreaExpanded;
}
/// <summary> /// <summary>
/// 打开追踪区命令占位。后续应由导航服务跳转至追踪页面。 /// 打开追踪区命令占位。后续应由导航服务跳转至追踪页面。
/// </summary> /// </summary>
@@ -542,6 +557,7 @@ public partial class MainWindowViewModel : ObservableObject
await AppendLogOnUiAsync(entry).ConfigureAwait(false); await AppendLogOnUiAsync(entry).ConfigureAwait(false);
} }
/// <summary>
/// 处理管理员解锁倒计时,超时后自动恢复锁定。 /// 处理管理员解锁倒计时,超时后自动恢复锁定。
/// </summary> /// </summary>
/// <param name="sender">事件源。</param> /// <param name="sender">事件源。</param>

View File

@@ -4,7 +4,6 @@ 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;
@@ -17,6 +16,7 @@ public partial class SystemSettingViewModel : ObservableObject
private readonly IFileCheckService _fileCheckService; private readonly IFileCheckService _fileCheckService;
private readonly IAppStateStore _appStateStore; private readonly IAppStateStore _appStateStore;
private readonly IDispatcherService _dispatcherService; private readonly IDispatcherService _dispatcherService;
private readonly IPlcService _plcService;
/// <summary> /// <summary>
/// 初始化系统设置视图模型。 /// 初始化系统设置视图模型。
@@ -25,20 +25,24 @@ public partial class SystemSettingViewModel : ObservableObject
/// <param name="fileCheckService">文件校验通道测试服务。</param> /// <param name="fileCheckService">文件校验通道测试服务。</param>
/// <param name="appStateStore">运行态快照存储。</param> /// <param name="appStateStore">运行态快照存储。</param>
/// <param name="dispatcherService">UI 线程调度服务。</param> /// <param name="dispatcherService">UI 线程调度服务。</param>
/// <param name="plcService">PLC 通信服务,用于调试界面的测试写入。</param>
public SystemSettingViewModel( public SystemSettingViewModel(
IAppConfigService appConfigService, IAppConfigService appConfigService,
IFileCheckService fileCheckService, IFileCheckService fileCheckService,
IAppStateStore appStateStore, IAppStateStore appStateStore,
IDispatcherService dispatcherService) IDispatcherService dispatcherService,
IPlcService plcService)
{ {
_appConfigService = appConfigService ?? throw new ArgumentNullException(nameof(appConfigService)); _appConfigService = appConfigService ?? throw new ArgumentNullException(nameof(appConfigService));
_fileCheckService = fileCheckService ?? throw new ArgumentNullException(nameof(fileCheckService)); _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));
_plcService = plcService ?? throw new ArgumentNullException(nameof(plcService));
EditableConfig = _appConfigService.Load(); EditableConfig = _appConfigService.Load();
ConfigPath = _appConfigService.GetConfigPath(); ConfigPath = _appConfigService.GetConfigPath();
PlcMonitorItems = new ObservableCollection<PlcMonitorItem>(); PlcMonitorItems = new ObservableCollection<PlcMonitorItem>();
FileCheckProviders = Enum.GetValues<FileCheckProvider>(); FileCheckProviders = Enum.GetValues<FileCheckProvider>();
PlcWriteTypes = Enum.GetValues<PlcWriteType>();
ApplySnapshot(_appStateStore.GetSnapshot()); ApplySnapshot(_appStateStore.GetSnapshot());
_appStateStore.SnapshotChanged += OnSnapshotChanged; _appStateStore.SnapshotChanged += OnSnapshotChanged;
StatusMessage = "已加载配置。"; StatusMessage = "已加载配置。";
@@ -54,6 +58,11 @@ public partial class SystemSettingViewModel : ObservableObject
/// </summary> /// </summary>
public IReadOnlyList<FileCheckProvider> FileCheckProviders { get; } public IReadOnlyList<FileCheckProvider> FileCheckProviders { get; }
/// <summary>
/// 获取 PLC 测试写入支持的变量类型可选项。
/// </summary>
public IReadOnlyList<PlcWriteType> PlcWriteTypes { get; }
/// <summary> /// <summary>
/// 获取或设置可编辑配置对象。 /// 获取或设置可编辑配置对象。
/// </summary> /// </summary>
@@ -79,6 +88,37 @@ public partial class SystemSettingViewModel : ObservableObject
[ObservableProperty] [ObservableProperty]
private bool _isTestingFileCheckConnection; private bool _isTestingFileCheckConnection;
/// <summary>
/// 获取或设置测试写入的目标地址,例如 D100、D100.0。
/// </summary>
[ObservableProperty]
private string _plcWriteAddress = string.Empty;
/// <summary>
/// 获取或设置测试写入的变量类型。
/// </summary>
[ObservableProperty]
private PlcWriteType _selectedPlcWriteType = PlcWriteType.Int16;
/// <summary>
/// 获取或设置测试写入的值文本。
/// </summary>
[ObservableProperty]
private string _plcWriteValue = string.Empty;
/// <summary>
/// 获取或设置测试写入的状态提示文本。
/// </summary>
[ObservableProperty]
private string _plcWriteStatusMessage = string.Empty;
/// <summary>
/// 获取或设置当前是否正在执行 PLC 测试写入。
/// </summary>
[NotifyCanExecuteChangedFor(nameof(WritePlcValueCommand))]
[ObservableProperty]
private bool _isWritingPlc;
/// <summary> /// <summary>
/// 保存当前配置。 /// 保存当前配置。
/// </summary> /// </summary>
@@ -143,6 +183,44 @@ public partial class SystemSettingViewModel : ObservableObject
/// </summary> /// </summary>
private bool CanTestFileCheckConnection => !IsTestingFileCheckConnection; private bool CanTestFileCheckConnection => !IsTestingFileCheckConnection;
/// <summary>
/// 执行一次 PLC 测试写入。该写入复用回读相同的 IO 串行化,不会干扰后台轮询回读。
/// </summary>
/// <returns>表示写入完成的任务。</returns>
[RelayCommand(CanExecute = nameof(CanWritePlcValue))]
private async Task WritePlcValueAsync()
{
if (string.IsNullOrWhiteSpace(PlcWriteAddress))
{
PlcWriteStatusMessage = "请先输入写入地址。";
return;
}
IsWritingPlc = true;
PlcWriteStatusMessage = "正在写入...";
try
{
PlcWriteResult result = await _plcService
.WriteRawValueAsync(PlcWriteAddress, SelectedPlcWriteType, PlcWriteValue, CancellationToken.None);
PlcWriteStatusMessage = result.Message;
}
catch (Exception ex)
{
PlcWriteStatusMessage = $"写入失败:{ex.Message}";
}
finally
{
IsWritingPlc = false;
}
}
/// <summary>
/// 判断当前是否允许执行 PLC 测试写入。
/// </summary>
private bool CanWritePlcValue => !IsWritingPlc;
/// <summary> /// <summary>
/// 将运行态快照中的 PLC 监控数据同步到当前视图模型。 /// 将运行态快照中的 PLC 监控数据同步到当前视图模型。
/// </summary> /// </summary>

View File

@@ -639,11 +639,12 @@
<Grid Grid.Row="2" Grid.Column="0"> <Grid Grid.Row="2" Grid.Column="0">
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="16" /> <RowDefinition Height="Auto" />
<RowDefinition Height="*" /> <RowDefinition Height="*" />
</Grid.RowDefinitions> </Grid.RowDefinitions>
<Grid Grid.Row="0"> <Grid Grid.Row="0"
Visibility="{Binding IsLogAreaExpanded, Converter={StaticResource BooleanToVisibilityConverter}, ConverterParameter=Invert}">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="1.12*" /> <ColumnDefinition Width="1.12*" />
<ColumnDefinition Width="12" /> <ColumnDefinition Width="12" />
@@ -775,6 +776,10 @@
</Border> </Border>
</Grid> </Grid>
<Border Grid.Row="1"
Height="16"
Visibility="{Binding IsLogAreaExpanded, Converter={StaticResource BooleanToVisibilityConverter}, ConverterParameter=Invert}" />
<Border Grid.Row="2" Style="{StaticResource ModernCardStyle}"> <Border Grid.Row="2" Style="{StaticResource ModernCardStyle}">
<Grid> <Grid>
<Grid.RowDefinitions> <Grid.RowDefinitions>
@@ -809,17 +814,29 @@
<ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" />
<ColumnDefinition Width="8" /> <ColumnDefinition Width="8" />
<ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" />
<ColumnDefinition Width="8" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<Button Grid.Column="0" <Button Grid.Column="0"
Style="{StaticResource ToolbarButtonStyle}"
Command="{Binding ToggleLogAreaCommand}">
<Grid>
<TextBlock Text="展开日志"
Visibility="{Binding IsLogAreaExpanded, Converter={StaticResource BooleanToVisibilityConverter}, ConverterParameter=Invert}" />
<TextBlock Text="恢复概览"
Visibility="{Binding IsLogAreaExpanded, Converter={StaticResource BooleanToVisibilityConverter}}" />
</Grid>
</Button>
<Button Grid.Column="2"
Content="全部" Content="全部"
Style="{StaticResource ToolbarButtonStyle}" Style="{StaticResource ToolbarButtonStyle}"
Command="{Binding ShowAllLogsCommand}" /> Command="{Binding ShowAllLogsCommand}" />
<Button Grid.Column="2" <Button Grid.Column="4"
Content="仅错误" Content="仅错误"
Style="{StaticResource ToolbarButtonStyle}" Style="{StaticResource ToolbarButtonStyle}"
Command="{Binding ShowErrorLogsCommand}" /> Command="{Binding ShowErrorLogsCommand}" />
<Button Grid.Column="4" <Button Grid.Column="6"
Content="导出" Content="导出"
Style="{StaticResource ToolbarButtonStyle}" Style="{StaticResource ToolbarButtonStyle}"
Command="{Binding ExportLogsCommand}" /> Command="{Binding ExportLogsCommand}" />

View File

@@ -98,6 +98,9 @@
<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.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.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}" /> <TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="最大重试次数" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.FileCheck.MaxRetryCount, UpdateSourceTrigger=PropertyChanged}" />
<CheckBox Content="收到 PLC 放行应答后删除命中的产品 ID 文件"
IsChecked="{Binding EditableConfig.FileCheck.DeleteMatchedFileAfterPlcAck}"
Margin="0,10,0,0" />
<TextBlock Text="SFTP 配置" Margin="0,16,0,2" FontWeight="SemiBold" Foreground="#1F2937" /> <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}" />
@@ -140,6 +143,8 @@
<TabItem Header="PLC监控"> <TabItem Header="PLC监控">
<Grid Margin="16"> <Grid Margin="16">
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="12" />
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="12" /> <RowDefinition Height="12" />
<RowDefinition Height="*" /> <RowDefinition Height="*" />
@@ -153,6 +158,60 @@
</StackPanel> </StackPanel>
<Border Grid.Row="2" <Border Grid.Row="2"
Background="White"
BorderBrush="#E8ECF3"
BorderThickness="1"
CornerRadius="10"
Padding="16,14">
<StackPanel>
<TextBlock Text="测试写入" FontSize="15" FontWeight="SemiBold" Foreground="#1F2937" />
<TextBlock Text="手动向指定地址写入测试值,仅用于调试,不影响实时回读。"
Margin="0,4,0,0"
Foreground="#6B7280" />
<Grid Margin="0,12,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2*" />
<ColumnDefinition Width="12" />
<ColumnDefinition Width="1.4*" />
<ColumnDefinition Width="12" />
<ColumnDefinition Width="2*" />
<ColumnDefinition Width="12" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0"
Style="{StaticResource TextBoxExtend}"
hc:InfoElement.Title="地址"
hc:InfoElement.TitlePlacement="Top"
hc:InfoElement.Placeholder="如 D100、D100.0"
Text="{Binding PlcWriteAddress, UpdateSourceTrigger=PropertyChanged}" />
<ComboBox Grid.Column="2"
Style="{StaticResource ComboBoxExtend}"
hc:InfoElement.Title="变量类型"
hc:InfoElement.TitlePlacement="Top"
ItemsSource="{Binding PlcWriteTypes}"
SelectedItem="{Binding SelectedPlcWriteType}" />
<TextBox Grid.Column="4"
Style="{StaticResource TextBoxExtend}"
hc:InfoElement.Title="写入值"
hc:InfoElement.TitlePlacement="Top"
hc:InfoElement.Placeholder="Bool 用 1/0 或 true/false"
Text="{Binding PlcWriteValue, UpdateSourceTrigger=PropertyChanged}" />
<Button Grid.Column="6"
VerticalAlignment="Bottom"
Style="{StaticResource ButtonPrimary}"
Padding="18,8"
MinHeight="36"
Content="写入"
Command="{Binding WritePlcValueCommand}" />
</Grid>
<TextBlock Text="{Binding PlcWriteStatusMessage}"
Margin="0,10,0,0"
TextWrapping="Wrap"
Foreground="#2563EB" />
</StackPanel>
</Border>
<Border Grid.Row="4"
Background="White" Background="White"
BorderBrush="#E8ECF3" BorderBrush="#E8ECF3"
BorderThickness="1" BorderThickness="1"

View File

@@ -1,91 +1,93 @@
{ {
"Plc": { "Plc": {
"Host": "127.0.0.1", "Host": "192.168.2.100",
"Port": 9600, "Port": 9600,
"UnitAddress": 0, "UnitAddress": 0,
"SourceNodeAddress": 25, "SourceNodeAddress": 0,
"PollIntervalMs": 200, "EndianFormat": "ABCD",
"ConnectTimeoutMs": 3000, "PollIntervalMs": 200,
"ReleasePulseMs": 500, "ConnectTimeoutMs": 3000,
"ReleaseAckTimeoutMs": 2000, "ReleasePulseMs": 500,
"Inputs": { "ReleaseAckTimeoutMs": 2000,
"PcbArrived": 1001, "Inputs": {
"PlcReset": 1002, "PcbArrived": 101,
"PlcAckRelease": 1003 "PlcReset": 102,
}, "PlcAckRelease": 103
"Outputs": { },
"PcBusy": 1051, "Outputs": {
"ReleasePermit": 1052 "PcBusy": 51,
}, "ReleasePermit": 52
"Registers": { },
"ResultCode": 1100 "Registers": {
} "ResultCode": 401
}, }
"Scanner": { },
"Scanner1": { "Scanner": {
"Name": "扫码枪1", "Scanner1": {
"Host": "127.0.0.1", "Name": "\u626B\u7801\u67AA1",
"Port": 9001 "Host": "192.168.2.13",
}, "Port": 2001
"Scanner2": { },
"Name": "扫码枪2", "Scanner2": {
"Host": "127.0.0.1", "Name": "\u626B\u7801\u67AA2",
"Port": 9002 "Host": "192.168.2.14",
}, "Port": 2001
"ConnectTimeoutMs": 3000, },
"ReadTimeoutMs": 3000, "ConnectTimeoutMs": 3000,
"TriggerCommand": "T", "TriggerCommand": "T",
"ResponseTerminator": "\\r", "ResponseTerminator": "",
"TriggerInParallel": true "TriggerInParallel": true,
}, "ReadTimeoutMs": 5000
"FileCheck": { },
"Provider": "Sftp", "FileCheck": {
"FileNamePattern": "${barcode}.txt", "Provider": "Local",
"RetryIntervalSeconds": 2, "FileNamePattern": "${barcode}.txt",
"MaxRetryCount": 3, "RetryIntervalSeconds": 2,
"Local": { "MaxRetryCount": 3,
"RootPath": "D:\\PcbCheck\\Files" "DeleteMatchedFileAfterPlcAck": false,
} "Local": {
}, "RootPath": "D:\\test"
"Sftp": { }
"Host": "127.0.0.1", },
"Port": 22, "Sftp": {
"Username": "uaesadmin", "Host": "127.0.0.1",
"Password": "uaes,123", "Port": 22,
"PrivateKeyPath": "", "Username": "uaesadmin",
"PrivateKeyPassphrase": "", "Password": "uaes,123",
"RootPath": "/home/uaesadmin/", "PrivateKeyPath": "",
"FileNamePattern": "${barcode}.txt", "PrivateKeyPassphrase": "",
"RetryIntervalSeconds": 2, "RootPath": "/home/uaesadmin/",
"MaxRetryCount": 3, "FileNamePattern": "${barcode}.txt",
"ConnectTimeoutMs": 3000 "RetryIntervalSeconds": 2,
}, "MaxRetryCount": 3,
"Andon": { "ConnectTimeoutMs": 3000
"Enable": true, },
"Url": "https://shp.prod.aliyun.mit.uaes.com/api/mit/MAE/Station/Status/alarm", "Andon": {
"Method": "POST", "Enable": false,
"TimeoutMs": 3000, "Url": "https://shp.prod.aliyun.mit.uaes.com/api/mit/MAE/Station/Status/alarm",
"AndonTheme": "SS7_Auto_Andon", "Method": "POST",
"Eid": "PR1965269806334783488", "TimeoutMs": 3000,
"FromSign": "IOT", "AndonTheme": "SS7_Auto_Andon",
"ServiceId": "4dccd822-86b2-47dd-89bd-ca472ba0d14d", "Eid": "PR1965269806334783488",
"EnableScanFailAlarm": true, "FromSign": "IOT",
"EnableFileNotFoundAlarm": false, "ServiceId": "4dccd822-86b2-47dd-89bd-ca472ba0d14d",
"Headers": { "EnableScanFailAlarm": false,
"Accept": "application/json" "EnableFileNotFoundAlarm": false,
} "Headers": {
}, "Accept": "application/json"
"Workflow": { }
"RequireManualResetAfterFault": true, },
"MaxUiLogEntries": 200, "Workflow": {
"MaxBoardRecords": 100 "RequireManualResetAfterFault": true,
}, "MaxUiLogEntries": 200,
"RecordPersistence": { "MaxBoardRecords": 100
"DirectoryPath": "Data\\Records", },
"DayChangeCheckIntervalSeconds": 30 "RecordPersistence": {
}, "DirectoryPath": "Data\\Records",
"Security": { "DayChangeCheckIntervalSeconds": 30
"AdminPassword": "AxiOmron@123", },
"AdminSessionTimeoutMinutes": 15 "Security": {
} "AdminPassword": "AxiOmron@123",
"AdminSessionTimeoutMinutes": 15
}
} }

View File

@@ -35,6 +35,35 @@ public sealed class DualScannerResultMergerTests
Assert.Contains("扫码枪2", result.RawMessage); Assert.Contains("扫码枪2", result.RawMessage);
} }
/// <summary>
/// 第一把扫码枪返回 noRead、第二把扫码枪返回有效条码时,应舍弃 noRead 并使用第二把扫码枪结果。
/// </summary>
[Fact]
public void Merge_ShouldUseSecondBarcode_WhenFirstScannerReturnsNoRead()
{
var first = new ScanOperationResult
{
IsSuccess = true,
DeviceConnected = true,
Barcode = "noRead",
RawMessage = "noRead"
};
var second = new ScanOperationResult
{
IsSuccess = true,
DeviceConnected = true,
Barcode = "PCB-SECOND",
RawMessage = "PCB-SECOND"
};
ScanOperationResult result = DualScannerResultMerger.Merge(first, second);
Assert.True(result.IsSuccess);
Assert.Equal("PCB-SECOND", result.Barcode);
Assert.Contains("使用扫码枪2", result.RawMessage);
Assert.DoesNotContain("使用扫码枪1", result.RawMessage);
}
/// <summary> /// <summary>
/// 两把扫码枪都成功但内容不一致时,应优先使用扫码枪1条码并保留不一致说明。 /// 两把扫码枪都成功但内容不一致时,应优先使用扫码枪1条码并保留不一致说明。
/// </summary> /// </summary>

View File

@@ -89,20 +89,20 @@ public sealed class LocalFileCheckProviderTests : IDisposable
} }
/// <summary> /// <summary>
/// 文件名包含条码时,应返回命中文件路径。 /// 文件名只包含条码但不等于模板生成文件名时,不应命中。
/// </summary> /// </summary>
/// <returns>异步测试任务。</returns> /// <returns>异步测试任务。</returns>
[Fact] [Fact]
public async Task CheckFileAsync_ShouldMatchFileNameContainingBarcode() public async Task CheckFileAsync_ShouldNotMatchFileNameContainingBarcodeOnly()
{ {
string filePath = Path.Combine(_rootPath, "prefix-PCB-002-result.dat"); string filePath = Path.Combine(_rootPath, "abcd10.txt");
await File.WriteAllTextAsync(filePath, "ok"); await File.WriteAllTextAsync(filePath, "ok");
var provider = CreateProvider(); var provider = CreateProvider();
FileCheckOutcome outcome = await provider.CheckFileAsync("PCB-002", CreateConfig(_rootPath), CancellationToken.None); FileCheckOutcome outcome = await provider.CheckFileAsync("abcd1", CreateConfig(_rootPath), CancellationToken.None);
Assert.True(outcome.Exists); Assert.False(outcome.Exists);
Assert.Equal(filePath, outcome.MatchedFilePath); Assert.Empty(outcome.MatchedFilePath);
} }
/// <summary> /// <summary>
@@ -124,6 +124,56 @@ public sealed class LocalFileCheckProviderTests : IDisposable
Assert.Equal(filePath, outcome.MatchedFilePath); Assert.Equal(filePath, outcome.MatchedFilePath);
} }
/// <summary>
/// 删除目标位于配置根目录内时,应删除文件并返回成功。
/// </summary>
/// <returns>异步测试任务。</returns>
[Fact]
public async Task DeleteFileAsync_ShouldDeleteFile_WhenFileIsWithinConfiguredRoot()
{
string filePath = Path.Combine(_rootPath, "PCB-DELETE-001.txt");
await File.WriteAllTextAsync(filePath, "ok");
var provider = CreateProvider();
FileDeleteOutcome outcome = await provider.DeleteFileAsync(
filePath,
CreateConfig(_rootPath),
CancellationToken.None);
Assert.True(outcome.IsSuccess);
Assert.False(outcome.WasAlreadyMissing);
Assert.False(File.Exists(filePath));
}
/// <summary>
/// 删除目标位于配置根目录之外时,应拒绝删除以防止误删其他文件。
/// </summary>
/// <returns>异步测试任务。</returns>
[Fact]
public async Task DeleteFileAsync_ShouldRejectFile_WhenFileIsOutsideConfiguredRoot()
{
string parentPath = Directory.GetParent(_rootPath)!.FullName;
string filePath = Path.Combine(parentPath, $"{Guid.NewGuid():N}.txt");
await File.WriteAllTextAsync(filePath, "keep");
var provider = CreateProvider();
try
{
FileDeleteOutcome outcome = await provider.DeleteFileAsync(
filePath,
CreateConfig(_rootPath),
CancellationToken.None);
Assert.False(outcome.IsSuccess);
Assert.Contains("根目录之外", outcome.ErrorMessage);
Assert.True(File.Exists(filePath));
}
finally
{
File.Delete(filePath);
}
}
/// <summary> /// <summary>
/// 创建本地文件校验提供程序。 /// 创建本地文件校验提供程序。
/// </summary> /// </summary>

View File

@@ -131,10 +131,31 @@ public sealed class MainWindowViewModelTests
} }
} }
/// <summary>
/// 日志区域展开命令应只切换界面折叠状态,不影响运行数据。
/// </summary>
[Fact]
public void ToggleLogArea_ShouldSwitchExpandedState()
{
var stateStore = new AppStateStore();
var viewModel = CreateViewModel(stateStore);
Assert.False(viewModel.IsLogAreaExpanded);
viewModel.ToggleLogAreaCommand.Execute(null);
Assert.True(viewModel.IsLogAreaExpanded);
viewModel.ToggleLogAreaCommand.Execute(null);
Assert.False(viewModel.IsLogAreaExpanded);
}
/// <summary> /// <summary>
/// 创建测试用首页视图模型。 /// 创建测试用首页视图模型。
/// </summary> /// </summary>
/// <param name="stateStore">运行态存储。</param> /// <param name="stateStore">运行态存储。</param>
/// <param name="dispatcherService">可选的 UI 调度服务。</param>
/// <returns>首页视图模型。</returns> /// <returns>首页视图模型。</returns>
private static MainWindowViewModel CreateViewModel(IAppStateStore stateStore, IDispatcherService? dispatcherService = null) private static MainWindowViewModel CreateViewModel(IAppStateStore stateStore, IDispatcherService? dispatcherService = null)
{ {

View File

@@ -0,0 +1,41 @@
using AxiOmron.PcbCheck.Options;
using AxiOmron.PcbCheck.Services.Implementations;
using IoTClient.Enums;
using Xunit;
namespace AxiOmron.PcbCheck.Tests;
/// <summary>
/// 验证欧姆龙 FINS PLC 服务的通信参数解析行为。
/// </summary>
public sealed class OmronFinsPlcServiceTests
{
/// <summary>
/// 默认字节序应使用 ABCD,避免 UInt16 结果代码发生高低字节交换。
/// </summary>
[Fact]
public void ResolveEndianFormat_ShouldUseAbcd_WhenConfigurationIsDefault()
{
var options = new PlcOptions();
EndianFormat result = OmronFinsPlcService.ResolveEndianFormat(options);
Assert.Equal(EndianFormat.ABCD, result);
}
/// <summary>
/// 非法字节序配置应抛出明确异常,避免使用错误端序静默读写 PLC 寄存器。
/// </summary>
[Fact]
public void ResolveEndianFormat_ShouldThrow_WhenConfigurationIsInvalid()
{
var options = new PlcOptions
{
EndianFormat = "XYZ"
};
var exception = Assert.Throws<InvalidOperationException>(() => OmronFinsPlcService.ResolveEndianFormat(options));
Assert.Contains("PLC 字节序配置无效", exception.Message);
}
}

View File

@@ -0,0 +1,33 @@
using AxiOmron.PcbCheck.Models;
using AxiOmron.PcbCheck.ViewModels;
using Xunit;
namespace AxiOmron.PcbCheck.Tests;
/// <summary>
/// 验证结果代码在模型与界面层统一使用 16 位无符号整数。
/// </summary>
public sealed class ResultCodeTypeTests
{
/// <summary>
/// 结果代码枚举应以 UInt16 作为底层类型,匹配 PLC 的 ResultCode 定义。
/// </summary>
[Fact]
public void WorkflowResultCode_ShouldUseUInt16UnderlyingType()
{
Assert.Equal(typeof(ushort), Enum.GetUnderlyingType(typeof(WorkflowResultCode)));
}
/// <summary>
/// 结果代码属性应统一使用 UInt16,避免经过界面或快照时被误建模为 Int16 或 Int32。
/// </summary>
[Theory]
[InlineData(typeof(RuntimeSnapshot))]
[InlineData(typeof(PlcProcessState))]
[InlineData(typeof(BoardProcessRecord))]
[InlineData(typeof(MainWindowViewModel))]
public void ResultCodeProperties_ShouldUseUInt16(Type ownerType)
{
Assert.Equal(typeof(ushort), ownerType.GetProperty("ResultCode")?.PropertyType);
}
}

View File

@@ -34,7 +34,7 @@ public sealed class SystemSettingViewModelTests
StatusMessage = "SFTP 连接成功,根目录可访问。" StatusMessage = "SFTP 连接成功,根目录可访问。"
} }
}; };
var viewModel = new SystemSettingViewModel(configService, fileCheckService, stateStore, dispatcherService); var viewModel = new SystemSettingViewModel(configService, fileCheckService, stateStore, dispatcherService, new FakePlcService());
await viewModel.TestFileCheckConnectionCommand.ExecuteAsync(null); await viewModel.TestFileCheckConnectionCommand.ExecuteAsync(null);
@@ -60,7 +60,7 @@ public sealed class SystemSettingViewModelTests
StatusMessage = "SFTP 连接失败: timeout" StatusMessage = "SFTP 连接失败: timeout"
} }
}; };
var viewModel = new SystemSettingViewModel(configService, fileCheckService, stateStore, dispatcherService); var viewModel = new SystemSettingViewModel(configService, fileCheckService, stateStore, dispatcherService, new FakePlcService());
await viewModel.TestFileCheckConnectionCommand.ExecuteAsync(null); await viewModel.TestFileCheckConnectionCommand.ExecuteAsync(null);
@@ -87,7 +87,7 @@ public sealed class SystemSettingViewModelTests
}); });
}); });
var viewModel = new SystemSettingViewModel(configService, new FakeFileCheckService(), stateStore, dispatcherService); var viewModel = new SystemSettingViewModel(configService, new FakeFileCheckService(), stateStore, dispatcherService, new FakePlcService());
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 FakeFileCheckService(), stateStore, dispatcherService); var viewModel = new SystemSettingViewModel(configService, new FakeFileCheckService(), stateStore, dispatcherService, new FakePlcService());
_ = CollectionViewSource.GetDefaultView(viewModel.PlcMonitorItems); _ = CollectionViewSource.GetDefaultView(viewModel.PlcMonitorItems);
Exception? capturedException = null; Exception? capturedException = null;
@@ -160,7 +160,8 @@ public sealed class SystemSettingViewModelTests
configService, configService,
new FakeFileCheckService(), new FakeFileCheckService(),
stateStore, stateStore,
new CanceledDispatcherService()); new CanceledDispatcherService(),
new FakePlcService());
var synchronizationContext = new ExceptionCapturingSynchronizationContext(); var synchronizationContext = new ExceptionCapturingSynchronizationContext();
SynchronizationContext? originalContext = SynchronizationContext.Current; SynchronizationContext? originalContext = SynchronizationContext.Current;
@@ -290,12 +291,48 @@ public sealed class SystemSettingViewModelTests
throw new NotSupportedException(); throw new NotSupportedException();
} }
public Task<FileDeleteOutcome> DeleteMatchedFileAsync(string filePath, CancellationToken cancellationToken)
{
throw new NotSupportedException();
}
public Task<FileCheckTestOutcome> TestConnectionAsync(AppConfig? config, CancellationToken cancellationToken) public Task<FileCheckTestOutcome> TestConnectionAsync(AppConfig? config, CancellationToken cancellationToken)
{ {
return Task.FromResult(TestOutcome); return Task.FromResult(TestOutcome);
} }
} }
/// <summary>
/// 提供系统设置页测试用 PLC 服务替身。
/// </summary>
private sealed class FakePlcService : IPlcService
{
public Task<PlcSignalSnapshot> ReadSignalsAsync(CancellationToken cancellationToken)
{
return Task.FromResult(new PlcSignalSnapshot());
}
public Task<PlcMonitorSnapshot> ReadMonitorSnapshotAsync(CancellationToken cancellationToken)
{
return Task.FromResult(new PlcMonitorSnapshot());
}
public Task WriteStateAsync(PlcProcessState state, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task ForceReconnectAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task<PlcWriteResult> WriteRawValueAsync(string address, PlcWriteType type, string value, CancellationToken cancellationToken)
{
return Task.FromResult(PlcWriteResult.Success(address, type, value));
}
}
/// <summary> /// <summary>
/// 提供立即执行的测试用 Dispatcher,实现与设计时调度一致的同步行为。 /// 提供立即执行的测试用 Dispatcher,实现与设计时调度一致的同步行为。
/// </summary> /// </summary>

View File

@@ -0,0 +1,194 @@
using System.Net;
using System.Net.Sockets;
using AxiOmron.PcbCheck.Options;
using AxiOmron.PcbCheck.Services.Implementations;
using Xunit;
namespace AxiOmron.PcbCheck.Tests;
/// <summary>
/// 验证 TCP 双扫码枪服务的连接探活行为。
/// </summary>
public sealed class TcpDualScannerServiceTests
{
/// <summary>
/// 连接测试成功时,日志应包含本机出口和远端地址,便于和部署机脚本结果对照。
/// </summary>
/// <returns>异步测试任务。</returns>
[Fact]
public async Task TestConnectionAsync_ShouldLogLocalAndRemoteEndpoint_WhenConnectSucceeds()
{
using var firstListener = new TcpListener(IPAddress.Loopback, 0);
using var secondListener = new TcpListener(IPAddress.Loopback, 0);
firstListener.Start();
secondListener.Start();
Task<TcpClient> firstAccept = firstListener.AcceptTcpClientAsync();
Task<TcpClient> secondAccept = secondListener.AcceptTcpClientAsync();
var logger = new FakeAppLogger<TcpDualScannerService>();
var service = new TcpDualScannerService(
new AppConfig
{
Scanner = new ScannerOptions
{
Scanner1 = new TcpScannerEndpointOptions
{
Name = "扫码枪1",
Host = IPAddress.Loopback.ToString(),
Port = ((IPEndPoint)firstListener.LocalEndpoint).Port
},
Scanner2 = new TcpScannerEndpointOptions
{
Name = "扫码枪2",
Host = IPAddress.Loopback.ToString(),
Port = ((IPEndPoint)secondListener.LocalEndpoint).Port
},
ConnectTimeoutMs = 5000
}
},
logger);
bool connected = await service.TestConnectionAsync(CancellationToken.None);
using TcpClient firstAcceptedClient = await firstAccept;
using TcpClient secondAcceptedClient = await secondAccept;
Assert.True(connected);
Assert.Contains(logger.InformationEntries, entry => entry.Message.Contains("LocalEndPoint={LocalEndPoint}", StringComparison.Ordinal)
&& entry.Message.Contains("RemoteEndPoint={RemoteEndPoint}", StringComparison.Ordinal)
&& entry.Args.Contains("扫码枪1"));
Assert.Contains(logger.InformationEntries, entry => entry.Message.Contains("LocalEndPoint={LocalEndPoint}", StringComparison.Ordinal)
&& entry.Message.Contains("RemoteEndPoint={RemoteEndPoint}", StringComparison.Ordinal)
&& entry.Args.Contains("扫码枪2"));
}
/// <summary>
/// 连接测试失败时,日志应包含实际读取到的目标地址和端口,便于排查部署配置差异。
/// </summary>
/// <returns>异步测试任务。</returns>
[Fact]
public async Task TestConnectionAsync_ShouldLogEndpointAddress_WhenConnectFails()
{
var logger = new FakeAppLogger<TcpDualScannerService>();
var service = new TcpDualScannerService(
new AppConfig
{
Scanner = new ScannerOptions
{
Scanner1 = new TcpScannerEndpointOptions
{
Name = "扫码枪1",
Host = "192.0.2.10",
Port = 2001
},
Scanner2 = new TcpScannerEndpointOptions
{
Name = "扫码枪2",
Host = "192.0.2.11",
Port = 2002
},
ConnectTimeoutMs = 1
}
},
logger);
await service.TestConnectionAsync(CancellationToken.None);
Assert.Contains(logger.WarningEntries, entry => entry.Message.Contains("{Host}:{Port}", StringComparison.Ordinal)
&& entry.Args.Contains("192.0.2.10")
&& entry.Args.Contains(2001));
Assert.Contains(logger.WarningEntries, entry => entry.Message.Contains("{Host}:{Port}", StringComparison.Ordinal)
&& entry.Args.Contains("192.0.2.11")
&& entry.Args.Contains(2002));
}
/// <summary>
/// 连接超时时,探活应返回离线结果,而不是向启动流程传播取消异常。
/// </summary>
/// <returns>异步测试任务。</returns>
[Fact]
public async Task TestConnectionAsync_ShouldReturnFalse_WhenConnectTimesOut()
{
var service = new TcpDualScannerService(
new AppConfig
{
Scanner = new ScannerOptions
{
Scanner1 = new TcpScannerEndpointOptions
{
Name = "扫码枪1",
Host = "192.0.2.1",
Port = 2001
},
Scanner2 = new TcpScannerEndpointOptions
{
Name = "扫码枪2",
Host = "192.0.2.2",
Port = 2001
},
ConnectTimeoutMs = 1
}
},
new FakeAppLogger<TcpDualScannerService>());
bool connected = await service.TestConnectionAsync(CancellationToken.None);
Assert.False(connected);
}
/// <summary>
/// 调用方主动取消时,探活应继续向上层传播取消,便于应用关闭流程及时收尾。
/// </summary>
/// <returns>异步测试任务。</returns>
[Fact]
public async Task TestConnectionAsync_ShouldThrow_WhenCallerCancels()
{
var service = new TcpDualScannerService(
new AppConfig
{
Scanner = new ScannerOptions
{
Scanner1 = new TcpScannerEndpointOptions
{
Name = "扫码枪1",
Host = "192.0.2.1",
Port = 2001
},
Scanner2 = new TcpScannerEndpointOptions
{
Name = "扫码枪2",
Host = "192.0.2.2",
Port = 2001
},
ConnectTimeoutMs = 3000
}
},
new FakeAppLogger<TcpDualScannerService>());
using var cancellationTokenSource = new CancellationTokenSource();
cancellationTokenSource.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => service.TestConnectionAsync(cancellationTokenSource.Token));
}
private sealed class FakeAppLogger<TCategoryName> : AxiOmron.PcbCheck.Services.Interfaces.IAppLogger<TCategoryName>
{
public List<LogEntry> InformationEntries { get; } = new();
public List<LogEntry> WarningEntries { get; } = new();
public void LogError(string message, bool showInUi = false, params object?[] args) { }
public void LogError(Exception exception, string message, bool showInUi = false, params object?[] args) { }
public void LogInformation(string message, bool showInUi = false, params object?[] args)
{
InformationEntries.Add(new LogEntry(message, args));
}
public void LogWarning(string message, bool showInUi = false, params object?[] args) { }
public void LogWarning(Exception exception, string message, bool showInUi = false, params object?[] args)
{
WarningEntries.Add(new LogEntry(message, args));
}
}
private sealed record LogEntry(string Message, object?[] Args);
}

View File

@@ -157,6 +157,178 @@ 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>
/// 启用应答后删除功能时,收到 PLC 放行应答后应删除本次精确命中的产品 ID 文件。
/// </summary>
/// <returns>异步测试任务。</returns>
[Fact]
public async Task ExecuteAsync_ShouldDeleteMatchedFile_WhenReleaseAckReceivedAndDeletionEnabled()
{
const string matchedFilePath = "/pcb/PCB-DELETE-001.txt";
var fileCheckService = new FakeFileCheckService
{
CheckOutcome = new FileCheckOutcome
{
Exists = true,
ConnectionSucceeded = true,
MatchedFilePath = matchedFilePath
}
};
var service = new WorkflowHostedService(
new SequencedPlcService(new[]
{
new PlcSignalSnapshot { PcbArrived = true, PlcAckRelease = true },
new PlcSignalSnapshot { PcbArrived = false, PlcAckRelease = true }
}),
new CountingScannerService
{
Result = new ScanOperationResult
{
IsSuccess = true,
DeviceConnected = true,
Barcode = "PCB-DELETE-001"
}
},
fileCheckService,
new FakeAndonService(),
new AppStateStore(),
new AppConfig
{
Plc = new PlcOptions
{
PollIntervalMs = 20,
ReleaseAckTimeoutMs = 100,
ReleasePulseMs = 10
},
FileCheck = new FileCheckOptions
{
MaxRetryCount = 0,
DeleteMatchedFileAfterPlcAck = true
}
},
new FakeAppLogger<WorkflowHostedService>());
await service.StartAsync(CancellationToken.None);
await Task.Delay(200);
await service.StopAsync(CancellationToken.None);
Assert.Equal(matchedFilePath, Assert.Single(fileCheckService.DeletedFilePaths));
}
/// <summary>
/// 未收到 PLC 放行应答时,即使启用删除功能也不得删除产品 ID 文件。
/// </summary>
/// <returns>异步测试任务。</returns>
[Fact]
public async Task ExecuteAsync_ShouldNotDeleteMatchedFile_WhenReleaseAckTimesOut()
{
var fileCheckService = new FakeFileCheckService
{
CheckOutcome = new FileCheckOutcome
{
Exists = true,
ConnectionSucceeded = true,
MatchedFilePath = "/pcb/PCB-KEEP-001.txt"
}
};
var service = new WorkflowHostedService(
new SequencedPlcService(new[]
{
new PlcSignalSnapshot { PcbArrived = true, PlcAckRelease = false },
new PlcSignalSnapshot { PcbArrived = false, PlcAckRelease = false }
}),
new CountingScannerService
{
Result = new ScanOperationResult
{
IsSuccess = true,
DeviceConnected = true,
Barcode = "PCB-KEEP-001"
}
},
fileCheckService,
new FakeAndonService(),
new AppStateStore(),
new AppConfig
{
Plc = new PlcOptions
{
PollIntervalMs = 20,
ReleaseAckTimeoutMs = 50,
ReleasePulseMs = 10
},
FileCheck = new FileCheckOptions
{
MaxRetryCount = 0,
DeleteMatchedFileAfterPlcAck = true
}
},
new FakeAppLogger<WorkflowHostedService>());
await service.StartAsync(CancellationToken.None);
await Task.Delay(200);
await service.StopAsync(CancellationToken.None);
Assert.Empty(fileCheckService.DeletedFilePaths);
}
/// <summary>
/// 删除开关关闭时,即使收到 PLC 放行应答也不得删除产品 ID 文件。
/// </summary>
/// <returns>异步测试任务。</returns>
[Fact]
public async Task ExecuteAsync_ShouldNotDeleteMatchedFile_WhenDeletionDisabled()
{
var fileCheckService = new FakeFileCheckService
{
CheckOutcome = new FileCheckOutcome
{
Exists = true,
ConnectionSucceeded = true,
MatchedFilePath = "/pcb/PCB-KEEP-002.txt"
}
};
var service = new WorkflowHostedService(
new SequencedPlcService(new[]
{
new PlcSignalSnapshot { PcbArrived = true, PlcAckRelease = true },
new PlcSignalSnapshot { PcbArrived = false, PlcAckRelease = true }
}),
new CountingScannerService
{
Result = new ScanOperationResult
{
IsSuccess = true,
DeviceConnected = true,
Barcode = "PCB-KEEP-002"
}
},
fileCheckService,
new FakeAndonService(),
new AppStateStore(),
new AppConfig
{
Plc = new PlcOptions
{
PollIntervalMs = 20,
ReleaseAckTimeoutMs = 100,
ReleasePulseMs = 10
},
FileCheck = new FileCheckOptions
{
MaxRetryCount = 0,
DeleteMatchedFileAfterPlcAck = false
}
},
new FakeAppLogger<WorkflowHostedService>());
await service.StartAsync(CancellationToken.None);
await Task.Delay(200);
await service.StopAsync(CancellationToken.None);
Assert.Empty(fileCheckService.DeletedFilePaths);
}
/// <summary> /// <summary>
/// 双扫码汇总后,应使用汇总条码继续执行统一文件校验。 /// 双扫码汇总后,应使用汇总条码继续执行统一文件校验。
/// </summary> /// </summary>
@@ -418,6 +590,7 @@ public sealed class WorkflowHostedServiceTests
public Task<PlcMonitorSnapshot> ReadMonitorSnapshotAsync(CancellationToken cancellationToken) => Task.FromResult(new PlcMonitorSnapshot()); public Task<PlcMonitorSnapshot> ReadMonitorSnapshotAsync(CancellationToken cancellationToken) => Task.FromResult(new PlcMonitorSnapshot());
public Task<PlcSignalSnapshot> ReadSignalsAsync(CancellationToken cancellationToken) => Task.FromResult(new PlcSignalSnapshot()); public Task<PlcSignalSnapshot> ReadSignalsAsync(CancellationToken cancellationToken) => Task.FromResult(new PlcSignalSnapshot());
public Task WriteStateAsync(PlcProcessState state, CancellationToken cancellationToken) => Task.CompletedTask; public Task WriteStateAsync(PlcProcessState state, CancellationToken cancellationToken) => Task.CompletedTask;
public Task<PlcWriteResult> WriteRawValueAsync(string address, PlcWriteType type, string value, CancellationToken cancellationToken) => Task.FromResult(PlcWriteResult.Success(address, type, value));
} }
private sealed class ProbingPlcService : IPlcService private sealed class ProbingPlcService : IPlcService
@@ -460,6 +633,11 @@ public sealed class WorkflowHostedServiceTests
{ {
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task<PlcWriteResult> WriteRawValueAsync(string address, PlcWriteType type, string value, CancellationToken cancellationToken)
{
return Task.FromResult(PlcWriteResult.Success(address, type, value));
}
} }
private sealed class FailingPlcService : IPlcService private sealed class FailingPlcService : IPlcService
@@ -483,6 +661,11 @@ public sealed class WorkflowHostedServiceTests
{ {
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task<PlcWriteResult> WriteRawValueAsync(string address, PlcWriteType type, string value, CancellationToken cancellationToken)
{
throw new InvalidOperationException("PLC unreachable");
}
} }
private sealed class SequencedPlcService : IPlcService private sealed class SequencedPlcService : IPlcService
@@ -529,6 +712,11 @@ public sealed class WorkflowHostedServiceTests
WrittenStates.Add(state.Clone()); WrittenStates.Add(state.Clone());
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task<PlcWriteResult> WriteRawValueAsync(string address, PlcWriteType type, string value, CancellationToken cancellationToken)
{
return Task.FromResult(PlcWriteResult.Success(address, type, value));
}
} }
private sealed class FakeScannerService : IScannerService private sealed class FakeScannerService : IScannerService
@@ -561,14 +749,28 @@ public sealed class WorkflowHostedServiceTests
public FileCheckOutcome CheckOutcome { get; set; } = new(); public FileCheckOutcome CheckOutcome { get; set; } = new();
public FileDeleteOutcome DeleteOutcome { get; set; } = new()
{
IsSuccess = true
};
public string LastBarcode { get; private set; } = string.Empty; public string LastBarcode { get; private set; } = string.Empty;
public List<string> DeletedFilePaths { get; } = new();
public Task<FileCheckOutcome> CheckFileAsync(string barcode, CancellationToken cancellationToken) public Task<FileCheckOutcome> CheckFileAsync(string barcode, CancellationToken cancellationToken)
{ {
LastBarcode = barcode; LastBarcode = barcode;
return Task.FromResult(CheckOutcome); return Task.FromResult(CheckOutcome);
} }
public Task<FileDeleteOutcome> DeleteMatchedFileAsync(string filePath, CancellationToken cancellationToken)
{
DeletedFilePaths.Add(filePath);
DeleteOutcome.FilePath = filePath;
return Task.FromResult(DeleteOutcome);
}
public Task<FileCheckTestOutcome> TestConnectionAsync(AppConfig? config, CancellationToken cancellationToken) public Task<FileCheckTestOutcome> TestConnectionAsync(AppConfig? config, CancellationToken cancellationToken)
{ {
return Task.FromResult(TestOutcome); return Task.FromResult(TestOutcome);