# 溯源码对接说明 v0.2（2026-09-19，给打码机厂家）

农场的电脑一头连打码机，一头连我们的服务器。它从服务器下载码，交给打码机打印，再通过 WebSocket 报告打了哪些、打废了哪些。服务器也可以随时问它现在打到哪一条。示例程序 `3_client_farm_printer_sample.py` 已经把这些都跑通了，你们要换的是两处：PrinterAdapter.print_code() 换成真机调用，CameraInspector 换成相机取图和你们的检查方法（见 4.5）。用别的语言写也可以，照这份说明实现就行。

服务器地址下面写作 `<BASE>`，联调时另行告知。

## 1  码的格式

- 8 位，字符只有 0-9 和 A-Z，全大写。例：F07A8K9Q。
- 打字母数字时分两行，每行 4 位：第一行 F07A，第二行 8K9Q。下载接口里的 text_lines 已经拆好。
- 打二维码时，二维码内容就是下载接口里的 qr_content，例如 `https://softwareagentcenter.com/trace-code/T/F07A8K9Q`，原样编码，不要改大小写。容错等级用 H。
- 码由我们生成。每个码属于一个农场、一个批次，批次里的序号 seq 从 0 开始，按 seq 顺序打印。

## 2  账号

联调用的测试账号在门户页面 `<BASE>/` 自助申请，farm_id 以 SANDBOX 开头，每批最多 1000 个码，累计 20000 个。正式农场由我们开通。每个农场一个 farm_id 和一个 api_key。每台打码机自己起一个 device_id（比如 P1、P2），同一农场内不重复。HTTP 请求带两个头：`X-Farm-Id`、`X-Api-Key`。

## 3  HTTP 接口

- 申请一批码：`POST <BASE>/api/batches`，JSON `{"count": 5000, "print_as": "text", "device_id": "P1"}`。print_as 取 text（两行字）或 qr（二维码）。一台打码机一批，不要两台打同一批。
- 列出本机未打完的批次：`GET <BASE>/api/batches?device_id=P1&open_only=true`。
- 下载码：`GET <BASE>/api/batches/{batch_id}/codes?offset=0&limit=5000`，返回 `codes` 数组，每项有 seq、code、text_lines、qr_content、status。加 `&format=csv` 返回 CSV。重复下载结果一样。
- 查本农场汇总：`GET <BASE>/api/status`，返回签发数、已打数、作废数、剩余数，以及每台设备是否在线。
- 查一台设备此刻打到哪：`GET <BASE>/api/devices/P1/live-status`，服务器会经 WebSocket 去问那台电脑，5 秒内没回返回 504。

## 4  WebSocket

地址 `<BASE 换成 ws:// 或 wss://>/ws/printer`，消息都是 JSON 文本。连上后 10 秒内先发 hello：

```json
{"type": "hello", "farm_id": "F001", "api_key": "...", "device_id": "P1", "software": "厂家软件/1.0", "printer_model": "型号"}
```

服务器回 welcome，里面的 open_batches 是服务器记录的本机未打完批次，每项带 printed、void、remaining、next_seq，可以用来和本地核对。

打印回报，按连续区间报，建议每几秒报一次：

```json
{"type": "printed", "request_id": "唯一串", "batch_id": "F001-260919-7K2Q", "from_seq": 0, "to_seq": 199, "ts": 1790000000.0}
```

打废的码：

```json
{"type": "void", "request_id": "唯一串", "batch_id": "F001-260919-7K2Q", "seqs": [37, 88], "reason": "print_failed"}
```

区间也可以换成 `"seqs": [...]` 或 `"codes": ["F07A8K9Q", ...]`。服务器每条都回 ack：

```json
{"type": "ack", "request_id": "同上", "ok": true, "accepted": 200, "duplicate": 0, "already_void": 0, "batch_printed": 200, "batch_void": 0, "batch_remaining": 4800}
```

服务器发给你们的另外两种消息：`{"type": "batch_ready", ...}` 表示有新批次可以下载；`{"type": "status_request", "request_id": "..."}` 要求你们马上回：

```json
{"type": "status", "reply_to": "status_request 里的 request_id", "state": "printing", "current_batch": "...", "current_seq": 1234, "local_todo": 3766, "ink_pct": 82}
```

心跳每 30 秒一次：`{"type": "heartbeat", "state": "printing", "ink_pct": 82}`，字段随意加，服务器原样记下。

## 4.5  暂停、状态与打码照片

打码后拍照检查，打得不合格（字迹缺损、二维码解不出）就作废这个码。连续几个不合格，多半是喷头堵了，由农场电脑在本地自己暂停，不必等平台。示例程序是连续 3 个就暂停，判断方法在 CameraInspector 里，按你们的相机和检查方法替换。

示例程序里的检查是模拟的，不分析照片，只用于演示上传和自动暂停。实际检查建议：二维码用 OpenCV 的 cv2.QRCodeDetector 解码，与 qr_content 比对；8 位字码做 OCV（光学字符校验），核对打出来的是不是应打的那 8 个字符，比通用 OCR 稳，也快，每枚几十毫秒以内才跟得上产线；喷头堵塞可以从字迹区域的墨点覆盖率和横向断线提前看出来。如果你们已有带 OCV 的视觉检测相机，直接接它的结果即可。

设备自己改变状态时（本地判断暂停、现场按键暂停或继续、停止）主动报一次：

```json
{"type": "state", "state": "paused", "by": "local", "reason": "连续 3 个检查不合格：字迹缺损，疑似喷头堵塞"}
```

by 取 local（本地判断）、operator（现场操作）、server（平台指令）。平台也可以下令暂停或继续，收到后执行并立即回复：

```json
{"type": "control", "request_id": "...", "action": "pause", "reason": "联调控制台暂停"}
{"type": "control_ack", "reply_to": "control 里的 request_id", "ok": true, "state": "paused", "pause_by": "server"}
```

照片走 HTTP：`POST <BASE>/api/images?device_id=P1&batch_id=...&seq=37&ok=0&reason=字迹缺损`，带 X-Farm-Id / X-Api-Key，Content-Type 为 image/jpeg 或 image/png，请求体是图像字节，单张不超过 2 MB。不合格的全部上传，合格的抽样上传（示例程序每 200 个一张）。照片上传失败不影响打印。

## 4.6  售出核销

门店卖出时扫码核销：`POST <BASE>/api/sale`，带 X-Farm-Id / X-Api-Key，JSON `{"code": "F07A8K9Q", "by": "某门店 1 号收银台"}`。返回的 result 是 sold（核销成功）、already_sold（已经售出过）、not_printed、void 或 not_found。一个码只能核销一次，already_sold 说明有人回收真码再用或码被仿冒，平台流水会标红。消费者查询时显示售出时间；售出后被查询次数明显偏多，查询页会提示谨防仿冒。

测试阶段由码所属农场的账号核销。正式上线要改成零售商账号，并且只有登记收货的那家零售商能核销，见 egg_tracking notes 4.3。

## 5  必须做到的四条

1. 一个码只打一次。断电重启后从本地记录接着打，不能从批次开头重来。上次正在打、不确定有没有打出来的那一条，当作已打处理，不要再打。两枚蛋同码会被判成克隆。本地记录文件要放在重装软件也不会被清掉的位置：它丢了，已打但还没回报的那几秒的码会被当成没打过。
2. 回报没收到 ack 就留着，重连后用同一个 request_id 重发。服务器按 request_id 去重，重发不会多算。
3. 断网时照常打印（码是提前下载好的），回报存在本地，网通了再补。
4. 打废、剔除的码报 void，这些码作废，不再使用。
