Compare commits
4 Commits
17839a9e76
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 3238817185 | |||
| 07841fe4fe | |||
| c67509ec82 | |||
| 404ebadbe7 |
@@ -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 *)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 文件
|
||||||
|
|
||||||
点位映射示例:
|
点位映射示例:
|
||||||
|
|
||||||
|
|||||||
@@ -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`。
|
||||||
|
|||||||
141
docs/AxiOmron_PCBCheck_完整汇报稿.md
Normal file
141
docs/AxiOmron_PCBCheck_完整汇报稿.md
Normal 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 确认时允许删除;确认超时时文件保留。
|
||||||
|
|
||||||
|
演示时不需要展示大段代码,只需要展示日志或测试结果,并用“邮件发送成功不等于对方已经执行”的比喻解释问题。
|
||||||
|
|
||||||
|
如果现场演示失败,可以直接说:
|
||||||
|
|
||||||
|
> “这也是调试的一部分。现在我会先保留现场状态和日志,而不是连续修改代码。演示环境没有连接真实设备,下面展示我提前准备的测试结果。”
|
||||||
533
docs/AxiOmron_PCBCheck_汇报文案.md
Normal file
533
docs/AxiOmron_PCBCheck_汇报文案.md
Normal 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
BIN
docs/assets/真实UI.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 294 KiB |
1622
docs/axiomron-pcbcheck-report-draft.html
Normal file
1622
docs/axiomron-pcbcheck-report-draft.html
Normal file
File diff suppressed because it is too large
Load Diff
104
docs/开发流程.txt
Normal file
104
docs/开发流程.txt
Normal 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
682
docs/纯plan.md
Normal 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 设备测试和真实设备验收分离的方式,便于先完成可测试的软件闭环,再进行现场通信确认。
|
||||||
119
scripts/Test-ScannerTcpConnection.ps1
Normal file
119
scripts/Test-ScannerTcpConnection.ps1
Normal 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."
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -177,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>
|
||||||
@@ -205,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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}" />
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
"FileNamePattern": "${barcode}.txt",
|
"FileNamePattern": "${barcode}.txt",
|
||||||
"RetryIntervalSeconds": 2,
|
"RetryIntervalSeconds": 2,
|
||||||
"MaxRetryCount": 3,
|
"MaxRetryCount": 3,
|
||||||
|
"DeleteMatchedFileAfterPlcAck": false,
|
||||||
"Local": {
|
"Local": {
|
||||||
"RootPath": "D:\\test"
|
"RootPath": "D:\\test"
|
||||||
}
|
}
|
||||||
@@ -89,4 +90,4 @@
|
|||||||
"AdminPassword": "AxiOmron@123",
|
"AdminPassword": "AxiOmron@123",
|
||||||
"AdminSessionTimeoutMinutes": 15
|
"AdminSessionTimeoutMinutes": 15
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -291,6 +291,11 @@ 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);
|
||||||
|
|||||||
194
tests/AxiOmron.PcbCheck.Tests/TcpDualScannerServiceTests.cs
Normal file
194
tests/AxiOmron.PcbCheck.Tests/TcpDualScannerServiceTests.cs
Normal 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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -577,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);
|
||||||
|
|||||||
Reference in New Issue
Block a user