plan: modify add_two_node plan for material unload

This commit is contained in:
yxz321
2026-05-20 19:19:02 +08:00
parent 2fd8f0d3f1
commit 212f9ec448

View File

@@ -1,11 +1,11 @@
# Peptide Station 新增个节点:等待订单完成 + 人工下料 # Peptide Station 新增个节点:等待订单完成 + 下料确认 + take-out 同步
> 日期: 2026-05-20 > 日期: 2026-05-20
> 目标文件: [peptide_station.py](../unilabos/devices/workstation/bioyond_studio/peptide_station/peptide_station.py) > 目标文件: [peptide_station.py](../unilabos/devices/workstation/bioyond_studio/peptide_station/peptide_station.py)
> 参考实现: > 参考实现:
> - [bioyond_cell_workstation.py](/Users/dp/python/yxz/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py)`wait_for_order_finish`、`get_material_info` > - [bioyond_cell_workstation.py](/Users/dp/python/yxz/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py)`wait_for_order_finish`、`get_material_info`
> - [bioyond_rpc.py L782-800](../unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py)`take_out` > - [bioyond_rpc.py L782-824](../unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py)`take_out`
> - 前端约束: [manual-confirm-detail.tsx](/Users/dp/go/code/Uni-Lab-Cloud/web/src/app/@enterprise/(new)/leap-lab/components/notification-draw/manual-confirm-detail.tsx)、[services/manual-confirm.ts](/Users/dp/go/code/Uni-Lab-Cloud/web/src/services/manual-confirm.ts) > - [workstation_architecture.md](../docs/developer_guide/examples/workstation_architecture.md)HTTP 报送进入 workstation运行态记录保存在 workstation 内存)
> 状态: 仅需求草稿,不写代码 > 状态: 仅需求草稿,不写代码
--- ---
@@ -14,159 +14,347 @@
`BioyondPeptideStation` 当前实验流程在 `start_experiment`manual_confirm 启动调度器)之后即结束,缺少: `BioyondPeptideStation` 当前实验流程在 `start_experiment`manual_confirm 启动调度器)之后即结束,缺少:
1. **等待奔耀回报实验完成**:调度器跑完后,奔耀通过 LIMS 推送 `POST /report/order_finish` 回调;目前 peptide_station 没有把这条推送封装成 action 节点,下游无法在工作流图上"卡住"等结果,也拿不到 `usedMaterials` 等下游所需信息。 1. **等待奔耀回报实验完成**:调度器跑完后,奔耀通过 LIMS 推送 `POST /report/order_finish` 回调;目前 peptide_station 没有把这条推送封装成 action 节点,下游无法在工作流图上等结果,也拿不到 `usedMaterials` 等下游所需信息。
2. **下料引导**:实验完成后操作员需要把样品/产物从仓位里取出,下料前需要看到每个物料对应的 **仓库 / 坐标 (posX/posY/posZ) / 单位 / 物料名称**;下料完成后还需要回写一笔到奔耀(调用 `take-out` 接口),让奔耀清空相应库位状态。 2. **下料引导**:实验完成后操作员需要把样品/产物从仓位里取出,下料前需要看到每个物料对应的 **仓库 / 位 / 物料名称 / 数量**;下料完成后还需要回写奔耀(调用 `take-out` 接口),让奔耀清空相应库位状态。
因此在 `peptide_station.py` 增加两个 action 节点,串在 `start_experiment` 之后: 本轮新增三个 action 节点,串在 `start_experiment` 之后:
``` ```text
submit_experiment_dayN submit_experiment_dayN
start_experiment(manual_confirm 上料) -> start_experiment(manual_confirm 上料)
wait_for_order_finish (生成 unloadTable) -> wait_for_order_finish (等回调 + 生成 unloadTable)
unload_materials (manual_confirm 下料 + 调 take-out) -> confirm_unload_materials (manual_confirm 下料确认)
-> take_out_materials (调用 take-out 同步奔耀)
``` ```
--- ---
## 二、关键设计决策 ## 二、关键设计决策
### D1. `unloadTable` 必须由节点 1 产出,不能放节点 2 ### D1. 本轮只支持单订单,`order_ids` 只做占位
前端 manual_confirm 弹窗(`manual-confirm-detail.tsx`)的数据来源只有两类: 当前不实现多订单等待、乱序回调缓存、并发 wait 隔离。节点输入以 `order_id` 为主,`order_code` 可作为调试兜底。
| 数据 | 来源 | 用途 | `order_ids` 可在 handle/返回值中保留为占位字段,但实现只处理第一笔或直接忽略多订单列表。多订单、乱序回调、跨节点重跑复用缓存放到后续迭代。
|------|------|------|
| `detail.schema` / `detail.uiSchema` | **当前**节点的 goal schema | 给操作员勾选的表单 |
| `previousNodeResult.data.param` | **上一个**节点输出 param`getPreviousNodeResult(task_uuid, node_uuid)` | 在弹窗里渲染表格、列表等只读信息 |
**前端拿不到节点 2 内部"半中间"生成的临时数据**——节点 2 一旦进入 manual_confirm 阻塞状态,它的 param 就只能是 `goal_default`(操作员勾选项),不能在阻塞前先调一次 `material-info` ### D2. `start_experiment` 需要显式透传订单字段
→ 因此必须把 `material-info` 查询、`unloadTable` 组装全部前移到节点 1节点 1 在 LIMS 推送到达后立即查 `material-info`,把 `unloadTable` 作为输出 param 的一部分;节点 2 启动时前端自动通过 `getPreviousNodeResult` 拿到 `unloadTable` 渲染。 当前 `start_experiment` 只有输入 handles缺少输出 handles如果下游 wait 节点要接在 `start_experiment` 之后,必须让 `start_experiment` 透传:
### D2. 节点 2 的执行时序:先人工下料,再调 take-out - `order_id`
- `order_ids`(占位)
- `order_code`
- `resultTable`
节点 2 不是"调用 take-out 让奔耀帮忙下料",而是"操作员物理下料完成后,调 take-out 通知奔耀同步状态"。完整时序: 实现时给 `start_experiment` 增加对应 `ActionOutputHandle`,并在返回值里保留这些字段。`submit_experiment_dayN``start_experiment` 嵌套字典也应包含 `order_code`,便于工作流编辑器连线。
``` ### D3. `unloadTable` 必须与 `resultTable` 字段一致
节点 2 (manual_confirm) 启动
`unloadTable` 不新增 `posX/posY/posZ/unit` 列,直接复用现有 `RESULT_TABLE_COLUMNS` 的四列:
前端弹窗显示 unloadTable + "确认下料完成" 按钮 ```python
RESULT_TABLE_COLUMNS = [
▼ 操作员物理下料 (从仓位里取出样品) {"name": "设备", "key": "whName"},
{"name": "位置", "key": "locationCode"},
▼ 操作员勾选 "已完成" → 点击"批准" {"name": "物料名称", "key": "materialName"},
{"name": "数量", "key": "quantity"},
▼ POST /api/v1/lab/workflow/manual-confirm/action {action: "approve"} ]
▼ manual_confirm goal 解除阻塞 → 节点 2 函数体继续执行
▼ 调用 self.hardware_interface.take_out(order_id, preintake_ids, material_ids)
│ (= POST /api/lims/order/take-out, 文档见 docx/SUYVd65Ykov2prxsnOVcH5Eln7N)
节点 2 返回,下游继续
``` ```
`take-out` 入参 `preintakeIds` / `materialIds` 由节点 1 输出的 `used_materials` 拆分得到(`preintakeId` 当前协议在 `usedMaterials` 中没有,全部传 `[]``materialIds` 来自每条 `usedMaterials.materialId`)。 `submit_experiment_dayN` 现有上料确认表 `resultTable` 形状如下;`unloadTable` 也必须保持同一 shape只改 `tableName` 和行数据来源:
```json
{
"data": [
{
"whName": "自动化堆栈",
"locationCode": "A1",
"materialName": "96孔板",
"quantity": "1"
}
],
"columns": [
{"name": "设备", "key": "whName"},
{"name": "位置", "key": "locationCode"},
{"name": "物料名称", "key": "materialName"},
{"name": "数量", "key": "quantity"}
],
"tableName": "resultTable"
}
```
`material-info` 官方 schema 中位置坐标字段是 `locations[].x/y/z`,不是 `posX/posY/posZ`。本轮下料表不展示坐标。
### D4. manual_confirm 只做人确认take-out 放到普通 action
`confirm_unload_materials` 只负责展示下料表、等待操作员确认并透传数据;真正的 `take-out` 调用放到后续普通 action `take_out_materials`
这样更接近 UniLab manual_confirm 的推荐模式manual_confirm 是人机确认检查点,副作用由独立普通 action 执行。
### D5. 本轮不做 unload context 缓存
虽然 workstation architecture 文档支持在 workstation 内存保存 HTTP 报送记录,但本轮暂不实现 `unload_context_cache` 或 order report 缓存。
因此本轮限制如下:
- `wait_for_order_finish` 只等待本次进入节点之后到达的 `/report/order_finish`
- 如果 push 早于 wait 节点到达,本轮不自动补救。
- 如果用户在 `confirm_unload_materials` approve 时忘记勾选,节点失败;当前架构不支持原地重新弹出同一个 manual_confirm也不在本轮实现失败节点重跑。
- 后续若要支持重跑复用,应在 `BioyondPeptideStation` 实例上新增 station runtime 的 `unload_context_cache`,按 `orderCode` 缓存 `unloadTable/material_ids/order_id` 等上下文。
--- ---
## 三、节点 1`wait_for_order_finish`(等推送 + 生成 unloadTable ## 三、节点 1`wait_for_order_finish`(等推送 + 生成 unloadTable
### 行为 ### 行为
1. 入口先把 `order_code`(或从 `order_id` 反查到的 orderCode记入 `self.last_order_code`,并 `clear()` 一个 `threading.Event` 1. 解析单订单目标:
2. 阻塞在 `self.order_finish_event.wait(timeout=...)` 等 LIMS 推送 - 首选 `order_id`
3. 由基类 `WorkstationHTTPService._handle_order_finish_report``self.process_order_finish_report(report_request, used_materials)` 触发 pushpeptide_station **override** `process_order_finish_report` - 如果没有 `order_code`,通过 `self.hardware_interface.order_report(order_id)` 取返回数据中的 `code` 作为 `orderCode`
- `super().process_order_finish_report(...)` 保留父类行为(`resource_synchronizer.sync_from_external()` 等) - `order_ids` 仅占位,本轮不实现多订单循环
-`report_request.data` 存入 `self.last_order_report`orderCode 匹配时 `self.order_finish_event.set()` 2. 设置 `self.last_order_code = order_code``self.last_order_report = None`,并 `self.order_finish_event.clear()`
4. 解除阻塞后,按 `data.status` 解析返回值(沿用 bioyond_cell 语义):`"30"→success` / `"-11"→abnormal_stop` / `"-12"→manual_stop` / 其它 `unknown_<status>` / 超时 `timeout` 3. 阻塞在 `self.order_finish_event.wait(timeout=timeout_seconds)` 等 LIMS 推送
5. **【关键新增】对每条 `usedMaterials.materialId` 调用 `self.hardware_interface.material_info(material_id)`**(带本地缓存避免重复请求),组装 `unloadTable`(结构见 §五)。`material-info` 接口失败的物料行 `whName/posX/posY/posZ/unit/materialName` 用空串占位,并把 materialId 追加到 `unload_summary.missing_material_info`**不抛异常**。 4. peptide_station override `process_order_finish_report(report_request, used_materials)`
6.`order_id``order_code``order_finish_status``order_finish_report``used_materials``unloadTable``unload_summary` 一起作为输出 handle 暴露 - 先调用 `super().process_order_finish_report(...)` 保留父类行为(状态发布、物料同步等)
-`report_request.data.orderCode == self.last_order_code` 时,把 `report_request.data` 存入 `self.last_order_report`,并 `set()` event。
- 非当前订单推送只记录日志,本轮不缓存。
5. 解除阻塞后解析 `status`
- `"30"` -> `success`
- `"-11"` -> `abnormal_stop`
- `"-12"` -> `manual_stop`
- 其它 -> `unknown_<status>`
- 超时 -> `timeout`
6.`report.usedMaterials[].materialId` 调用 `self.hardware_interface.material_info(material_id)`,带本地函数级 `material_info_cache` 避免重复请求。
7. 组装 `unloadTable``material_ids``preintake_ids``unload_summary` 并作为输出 handles 暴露。
### 入参goal_default ### 入参goal_default
| 参数 | 类型 | 说明 | | 参数 | 类型 | 说明 |
|------|------|------| |------|------|------|
| `order_id` | `str` | 来自 `start_experiment` 输出 handle `order_id`(首选);用于回查 orderCode | | `order_id` | `str` | 来自 `start_experiment` 透传输出,必填优先 |
| `order_ids` | `List[str]` | 来自 `start_experiment` 输出 handle `order_ids`;多订单时按顺序逐个等 | | `order_code` | `str` | 调试兜底;若已知 orderCode 可跳过 `order_report` 反查 |
| `order_code` | `str` | 兜底入参CLI 调试时若已知 orderCode 可直接传 | | `order_ids` | `List[str]` | 占位字段;本轮不实现多订单 |
| `timeout_seconds` | `int` | 默认 `36000`10h,与 bioyond_cell 一致 | | `timeout_seconds` | `int` | 默认 `36000`10h |
| `poll_mode` | `bool` | 默认 `False``True` 时改用 `wait_for_order_finish_polling` 风格0.5s 轮询,避免阻塞 ROS2 feedback 派发) | | `poll_mode` | `bool` | 默认 `False`如需要可沿用 bioyond_cell 的轮询等待风格 |
### 输出 handles ### 输出 handles
| key | data_type | 说明 | | key | data_type | 说明 |
|-----|-----------|------| |-----|-----------|------|
| `order_finish_status` | `str` | `success` / `abnormal_stop` / `manual_stop` / `timeout` / `unknown_*` | | `order_finish_status` | `str` | `success` / `abnormal_stop` / `manual_stop` / `timeout` / `unknown_*` |
| `order_finish_report` | `json` | 完整 `report_request.data`,含 `orderCode/orderName/startTime/endTime/status/usedMaterials` | | `order_finish_report` | `json` | 完整 `report_request.data` |
| `used_materials` | `json` | 解析后的 `usedMaterials` 列表(每项 `materialId/locationId/typeMode/usedQuantity` | | `used_materials` | `json` | JSON 化后的 `usedMaterials` 列表 |
| `material_ids` | `json` | `List[str]``used_materials` 抽出 materialId,供节点 2 直接传给 `take-out` | | `material_ids` | `json` | 从 `used_materials` 抽出`materialId` 列表,可为空 |
| `preintake_ids` | `json` | `List[str]`,当前协议为空列表,预留扩展点 | | `preintake_ids` | `json` | 本轮默认 `[]`,保留扩展点 |
| `unloadTable` | `table` | **下料表,前端在节点 2 manual_confirm 弹窗中通过 `getPreviousNodeResult` 拿到并渲染** | | `unloadTable` | `table` | 下料表,字段与 `resultTable` 一致 |
| `unload_summary` | `json` | `{ "order_code": ..., "total_items": N, "missing_material_info": [materialId,...] }` | | `unload_summary` | `json` | `{ "order_code": ..., "total_items": N, "missing_material_info": [...] }` |
| `order_id` / `order_code` | `str` | 透传给节点 2 | | `order_id` | `str` | 透传给后续节点 |
| `order_code` | `str` | 透传给后续节点 |
| `order_ids` | `json` | 占位透传 |
### `unloadTable` 组装规则
返回结构:
```json
{
"data": [
{
"whName": "自动化堆栈",
"locationCode": "A1",
"materialName": "多肽产物",
"quantity": "10 mg"
}
],
"columns": [
{"name": "设备", "key": "whName"},
{"name": "位置", "key": "locationCode"},
{"name": "物料名称", "key": "materialName"},
{"name": "数量", "key": "quantity"}
],
"tableName": "unloadTable"
}
```
每行字段:
| key | 数据来源 |
|-----|----------|
| `whName` | `material_info.locations` 中匹配 `usedMaterials.locationId` 的 location 的 `whName`;匹配不到取第一条 location 的 `whName`;失败为空串 |
| `locationCode` | 匹配 location 的 `code`;匹配不到取第一条 location 的 `code`;再兜底 `usedMaterials.locationId` |
| `materialName` | `material_info.name`;失败为空串 |
| `quantity` | `usedMaterials.usedQuantity`,若 `material_info.unit` 存在则拼成字符串(如 `"10 mg"` |
`material-info` 失败时不抛异常,对应行尽量保留 `locationCode` / `quantity``whName``materialName` 用空串,并把 `materialId` 放入 `unload_summary.missing_material_info`
### 接口依赖 ### 接口依赖
| 接口 | 调用方式 | 用途 | | 接口 | 调用方式 | 用途 |
|------|----------|------| |------|----------|------|
| `process_order_finish_report` 钩子 | 基类已注册 | 接 LIMS push | | `process_order_finish_report` 钩子 | 基类 HTTP 服务已注册 | 接 LIMS push |
| `POST /api/lims/storage/material-info` | `self.hardware_interface.material_info(material_id)` | 查物料的 whName/posX/posY/posZ/unit | | `POST /api/lims/order/order-report` | `self.hardware_interface.order_report(order_id)` | 从 `order_id` 反查 `orderCode` |
| `POST /api/lims/storage/material-info` | `self.hardware_interface.material_info(material_id)` | 查 `whName/locationCode/materialName/unit` |
### 实现要点(不写代码,仅设计) #### `order_report(order_id)` API 形式
请求:
```json
{
"apiKey": "B10B5995",
"requestTime": "2026-05-20T10:50:00.123Z",
"data": "<orderId UUID>"
}
```
响应中本节点只依赖 `data.code`
```json
{
"code": 1,
"message": null,
"timestamp": 1779255000000,
"data": {
"id": "<orderId UUID>",
"name": "实验260520-103000",
"code": "EXP260520-103000",
"status": 30,
"statusName": "完成"
}
}
```
`BioyondV1RPC.order_report(order_id)` 已经返回响应中的 `data`,所以实现中应读取 `raw.get("code")`,不是 `raw["data"]["code"]`
#### `material_info(material_id)` API 形式
请求:
```json
{
"apiKey": "B10B5995",
"requestTime": "2026-05-20T10:50:00.123Z",
"data": "<materialId UUID>"
}
```
响应中本节点依赖:
```json
{
"id": "<materialId UUID>",
"name": "多肽产物",
"unit": "mg",
"locations": [
{
"id": "<locationId UUID>",
"whid": "<warehouse UUID>",
"whName": "自动化堆栈",
"code": "A1",
"x": 1,
"y": 1,
"z": 1,
"quantity": 10
}
]
}
```
`BioyondV1RPC.material_info(material_id)` 已经返回响应中的 `data`。字段映射:
| unloadTable key | 来源 |
|-----------------|------|
| `whName` | 匹配 `locationId``locations[].whName` |
| `locationCode` | 匹配 `locationId``locations[].code` |
| `materialName` | `name` |
| `quantity` | `usedMaterials[].usedQuantity` + `unit` |
### 实现要点
- `BioyondPeptideStation.__init__` 末尾追加 `self.order_finish_event = threading.Event()``self.last_order_code = None``self.last_order_report = None` - `BioyondPeptideStation.__init__` 末尾追加 `self.order_finish_event = threading.Event()``self.last_order_code = None``self.last_order_report = None`
- 新增 `process_order_finish_report` override`super().process_order_finish_report(...)`,再做 orderCode 匹配 + `set()` - 新增 `process_order_finish_report` override`super()`,再做单订单匹配
- 抽内部辅助 `_build_unload_table(used_materials)`:参考现有 `_build_result_table` + `_resolve_wh_name_by_material_id` 的写法,加 `material_info_cache` 字典避免重复请求 - `used_materials` 参数是 `MaterialUsage` dataclass 列表;输出前必须转成 JSON dict
- 多订单(`order_ids` 长度 > 1按顺序逐个 wait最后把每个订单的 `usedMaterials` 合并成一张大表,附加 `orderCode` 列方便操作员区分;任何一个订单 `abnormal_stop` 立即返回 `abnormal_stop` 并把当前 report 一起带出 - `unloadTable` 复用 `RESULT_TABLE_COLUMNS`,不新增 `UNLOAD_TABLE_COLUMNS`
- 装饰器 `@action(always_free=True, description="等待订单完成回调并预生成下料表", handles=[...])`
--- ---
## 四、节点 2`unload_materials`(人工下料 + 调 take-out 同步 ## 四、节点 2`confirm_unload_materials`(人工下料确认
### 行为 ### 行为
1. 接收节点 1 输出的 `order_id` / `material_ids` / `preintake_ids` / `unloadTable`unloadTable 前端会自动从上一个节点 param 渲染,节点本身只需要透传/兜底显示) 1. 接收节点 1 输出的 `order_id` / `order_code` / `material_ids` / `preintake_ids` / `unloadTable`
2. 进入 `NodeType.MANUAL_CONFIRM` 阻塞,操作员在前端: 2. 进入 `NodeType.MANUAL_CONFIRM` 阻塞,操作员根据 `unloadTable` 物理下料。
-`unloadTable` 列出的每行物料 → 物理下料; 3. 操作员勾选 `materials_unloaded=True` 并 approve 后,节点函数体继续。
- 全部下料完成后,勾选 goal 表单中的 `materials_unloaded=True` 4. 校验 `materials_unloaded == True`
- 点击"批准"按钮,发 `POST /api/v1/lab/workflow/manual-confirm/action {action: "approve"}` - 为 True返回确认结果并透传 `order_id/material_ids/preintake_ids/unloadTable` 给节点 3
3. manual_confirm 解除阻塞后,节点函数体继续执行: - 为 False`RuntimeError("下料未确认,拒绝继续 take-out")`
- 校验 `materials_unloaded == True`,否则抛 `RuntimeError("下料未确认,拒绝结束节点")`(与 `start_experiment` 中"未确认上料"处理一致)。
- 调用 `self.hardware_interface.take_out(order_id, preintake_ids=preintake_ids, material_ids=material_ids)`
-`take_out` 返回值(含 `code`/`message`)原样收进 `take_out_result``code != 1` 时记 warning 但不抛异常(已经物理下料,回写失败需要人工兜底,不应让节点失败)。
4. 返回 `{success, take_out_result, unloaded_count, ...}`
### 入参goal_default ### 入参goal_default
| 参数 | 类型 | 说明 | | 参数 | 类型 | 说明 |
|------|------|------| |------|------|------|
| `order_id` | `str` | 来自节点 1 输出 handle,必填 | | `order_id` | `str` | 来自节点 1必填 |
| `material_ids` | `List[str]` | 来自节点 1 输出 handle必填 | | `order_code` | `str` | 来自节点 1,日志/排错用 |
| `preintake_ids` | `List[str]` | 来自节点 1 输出 handle默认 `[]` | | `material_ids` | `List[str]` | 来自节点 1,可为空 |
| `unloadTable` | `table` | 来自节点 1 输出 handle本节点函数体几乎不用纯粹透传给前端弹窗前端走 `getPreviousNodeResult` 主动拉,节点本身仍 declare 这个 input handle 让连线显式可见) | | `preintake_ids` | `List[str]` | 来自节点 1默认 `[]` |
| `materials_unloaded` | `bool` | manual_confirm 占位字段,操作员勾选后置为 `True` | | `unloadTable` | `table` | 来自节点 1供人工确认展示 |
| `timeout_seconds` | `int` | 默认 `3600`1h | | `materials_unloaded` | `bool` | manual_confirm 勾选字段,默认 `False` |
| `assignee_user_ids` | `List[str]` | `placeholder_keys={"assignee_user_ids": "unilabos_manual_confirm"}` 占位 | | `timeout_seconds` | `int` | 默认 `3600` |
| `assignee_user_ids` | `List[str]` | `placeholder_keys={"assignee_user_ids": "unilabos_manual_confirm"}` |
### 输出 handles ### 输出 handles
| key | data_type | 说明 | | key | data_type | 说明 |
|-----|-----------|------| |-----|-----------|------|
| `take_out_result` | `json` | `take-out` 接口原始响应 `{code, message, data}` | | `unload_confirmed` | `bool` | 是否已人工确认下料 |
| `unloaded_count` | `int` | 实际通知奔耀的物料数量(`len(material_ids)` | | `order_id` | `str` | 透传 |
| `success` | `bool` | `take-out` HTTP/业务都成功 → `True`;其余 `False` | | `order_code` | `str` | 透传 |
| `material_ids` | `json` | 透传 |
| `preintake_ids` | `json` | 透传 |
| `unloadTable` | `table` | 透传 |
### 实现要点
- 装饰器使用 `node_type=NodeType.MANUAL_CONFIRM`
- `always_free=True``placeholder_keys``feedback_interval=300` 与现有 `start_experiment` 保持一致。
- 本节点不调用 `take_out`,只做确认与透传。
- 忘记勾选后不会自动重新显示下料指引;本轮不实现缓存或失败节点原地重跑。
---
## 五、节点 3`take_out_materials`(调用 take-out 同步奔耀)
### 行为
1. 接收节点 2 透传的 `order_id` / `material_ids` / `preintake_ids`
2. 校验 `order_id` 非空。
3. 调用 `self.hardware_interface.take_out(order_id, preintake_ids=preintake_ids, material_ids=material_ids)`
4. 返回 `take_out_result``unloaded_count``success`
### 入参
| 参数 | 类型 | 说明 |
|------|------|------|
| `order_id` | `str` | 必填 |
| `material_ids` | `List[str]` | 可为空;为空时由奔耀按 `orderId` 处理的能力以后现场确认 |
| `preintake_ids` | `List[str]` | 可为空,默认 `[]` |
| `order_code` | `str` | 日志/排错用 |
### 输出 handles
| key | data_type | 说明 |
|-----|-----------|------|
| `take_out_result` | `json` | `take-out` 原始响应 `{code, message, timestamp, data}` |
| `unloaded_count` | `int` | `len(material_ids)` |
| `success` | `bool` | `take_out_result.code == 1``data` 不为 False |
### 接口依赖 ### 接口依赖
| 接口 | 调用方式 | 用途 | | 接口 | 调用方式 | 用途 |
|------|----------|------| |------|----------|------|
| `POST /api/lims/order/take-out` | `self.hardware_interface.take_out(order_id, preintake_ids, material_ids)` | 通知奔耀同步取出来源文档docx/SUYVd65Ykov2prxsnOVcH5Eln7N | | `POST /api/lims/order/take-out` | `self.hardware_interface.take_out(order_id, preintake_ids, material_ids)` | 通知奔耀同步取出 |
请求体格式(参考 [verify_bioyond_takeout_and_error.py L137-175](../scripts/verify_bioyond_takeout_and_error.py) 请求体 schemahelper script 已核对
```json ```json
{ {
@@ -180,30 +368,25 @@ submit_experiment_dayN
} }
``` ```
### 实现要点(不写代码,仅设计) 响应 schema
- 装饰器: ```json
- `node_type=NodeType.MANUAL_CONFIRM` {
- `placeholder_keys={"assignee_user_ids": "unilabos_manual_confirm"}` "code": 1,
- `goal_default={"materials_unloaded": False, "timeout_seconds": 3600, "assignee_user_ids": []}` "message": null,
- `feedback_interval=300` "timestamp": 1779255000000,
- 输入 handles 含 `order_id` / `material_ids` / `preintake_ids` / `unloadTable` "data": true
- `take-out` 失败时返回结构清晰,不要让节点抛异常打断工作流;让人工去 LIMS 后台手动复位。 }
```
--- `BioyondV1RPC.take_out(...)` 返回完整响应包,因此 `take_out_materials` 应保留原始包到 `take_out_result`
## 五、`unloadTable` 列定义(节点 1 产出,节点 2 仅透传) ### 实现要点
| 列名(中文) | key | 数据来源 | - 本轮不修改 `sample_waste_removal`,保持 backward compatibility。
|--------------|-----|----------| - 新节点只调用现有完整能力的 `take_out(...)`
| 仓库名称 | `whName` | `material_info.locations[0].whName` | - `preintake_ids` / `material_ids` 都按可选列表处理,默认 `[]`
| 坐标 X | `posX` | `material_info.locations[0].posX` | - `take-out` 返回 `code != 1` 时返回 `success=False` 并记录 warning是否抛异常留作开放问题。
| 坐标 Y | `posY` | `material_info.locations[0].posY` |
| 坐标 Z | `posZ` | `material_info.locations[0].posZ` |
| 单位 | `unit` | `material_info.unit`(顶层)或 `locations[0].unit` |
| 物料名称 | `materialName` | `material_info.name` |
> 多订单合并时在前面追加 `orderCode` 列。新增模块常量 `UNLOAD_TABLE_COLUMNS`,与现有上料确认表 `RESULT_TABLE_COLUMNS` 并存。
--- ---
@@ -212,143 +395,182 @@ submit_experiment_dayN
```mermaid ```mermaid
flowchart LR flowchart LR
submit["submit_experiment_dayN"] --> start["start_experiment<br/>manual_confirm: 上料"] submit["submit_experiment_dayN"] --> start["start_experiment<br/>manual_confirm: 上料"]
start -->|order_id, order_ids| wait["wait_for_order_finish<br/>① 阻塞等推送<br/>② 调 material-info 组装 unloadTable"] start -->|order_id, order_code| wait["wait_for_order_finish<br/>等 order_finish + 生成 unloadTable"]
wait -->|order_id, material_ids,<br/>preintake_ids, unloadTable| unload["unload_materials<br/>manual_confirm: 操作员下料<br/>→ 调 take-out 同步奔耀"] wait -->|order_id, material_ids,<br/>preintake_ids, unloadTable| confirm["confirm_unload_materials<br/>manual_confirm: 操作员下料确认"]
confirm -->|order_id, material_ids,<br/>preintake_ids| takeout["take_out_materials<br/>调用 take-out 同步奔耀"]
bioyond["奔耀 LIMS"] -.HTTP POST /report/order_finish.-> wait bioyond["奔耀 LIMS"] -.HTTP POST /report/order_finish.-> wait
wait -.material-info.-> bioyond wait -.material-info.-> bioyond
unload -.take-out.-> bioyond takeout -.take-out.-> bioyond
``` ```
--- ---
## 七、影响面与兼容性 ## 七、影响面与兼容性
- **`peptide_station.py`** 增加:常量 `UNLOAD_TABLE_COLUMNS``__init__` 中事件字段、`process_order_finish_report` override、两个新 action、`_build_unload_table` 等私有方法。 - **`peptide_station.py`**
- **基类 `station.py` 不动**override 中保留 `super().process_order_finish_report()` 调用 - 修改 `start_experiment`:增加 `order_id/order_code/order_ids/resultTable` 输出 handles并在返回值透传
- **HTTP 服务**:基类已自动启动,无需修改 `WorkstationHTTPService` - 增加 `wait_for_order_finish``confirm_unload_materials``take_out_materials` 三个 action
- **`bioyond_rpc.py` 不动**`take_out` / `material_info` 已封装 - 增加 `process_order_finish_report` override
- **测试** (`tests/test_peptide_station_contracts.py`):补 4 类用例 - 增加 `_build_unload_table(...)` 等私有辅助方法。
1. `process_order_finish_report` orderCode 匹配 / 不匹配场景下 event 是否被触发 - **`bioyond_rpc.py` 不动**
2. `wait_for_order_finish` 在事件已 set 时立即返回,超时返回 `timeout`,且产出的 `unloadTable` 列顺序、字段名严格匹配 - `take_out` 已有完整 schema 能力。
3. `wait_for_order_finish` 中某条 `material-info` 失败时,`unloadTable` 用空串占位 + `unload_summary.missing_material_info` 含该 materialId - `sample_waste_removal` 本轮不改,保持兼容。
4. `unload_materials``materials_unloaded=False` 时报错;`materials_unloaded=True` 时正确调用 `hardware_interface.take_out(order_id, preintake_ids, material_ids)`,并正确把响应放进 `take_out_result` - **基类 `station.py` 不动**
- override 中保留 `super().process_order_finish_report(...)` 调用。
- **HTTP 服务不动**
- `WorkstationHTTPService` 已支持 `/report/order_finish`
- **本轮不做缓存**
- 不新增 `unload_context_cache`
- 不支持 push 早于 wait 的自动补救。
- 不支持失败 manual_confirm 原地重跑。
- **测试**:补在现有路径 `unilabos/devices/workstation/bioyond_studio/peptide_station/tests/test_peptide_station_contracts.py`
1. `start_experiment` 输出 handles/返回值透传 `order_id/order_code/order_ids/resultTable`
2. `process_order_finish_report` orderCode 匹配 / 不匹配时 event 是否触发。
3. `wait_for_order_finish` 单订单成功、超时、状态映射、`used_materials` JSON 化。
4. `_build_unload_table` 列顺序严格等于 `RESULT_TABLE_COLUMNS`,且无 `posX/posY/posZ/unit` 列。
5. `material-info` 失败时不抛异常,`missing_material_info` 正确记录。
6. `confirm_unload_materials` 未勾选时报错,勾选后透传下游字段且不调用 `take_out`
7. `take_out_materials` 调用 `hardware_interface.take_out(order_id, preintake_ids, material_ids)`,不调用 `sample_waste_removal`
--- ---
## 八、待人类确认的开放问题 ## 八、待人类确认的开放问题
1. **过滤产物 vs 全量**`usedMaterials` 同时包含试剂、耗材、样品(`typeMode` 区分),下料表是否需要默认排除试剂/耗材?还是先全量列出,让操作员自己看?当前默认全量列出,列里带 `typeMode` 调试字段 1. **过滤产物 vs 全量**`usedMaterials` 同时包含试剂、耗材、样品(`typeMode` 区分),下料表是否需要默认排除试剂/耗材?当前默认全量列出
2. **失败重入语义**:若 `wait_for_order_finish` 超时,是否需要支持下一次启动该节点时"复用上一次推送"(即 event 已 set 时立即返回上一次 reportbioyond_cell 不支持,每次进入都会先 `clear()`;建议保持一致 2. **take-out 失败是否阻塞工作流**:本计划暂定返回 `success=False` 并 warning不抛异常如果希望奔耀仓位状态必须一致可改为抛 `RuntimeError`
3. **`take-out` 失败的兜底**:当前设计是仅 warn 不抛异常。如果你希望失败必须阻塞工作流(避免奔耀仓位脏数据),改为抛 `RuntimeError`,让操作员重试或人工介入 3. **后续缓存/重跑能力**:如果要支持 push 早到、忘勾选后重跑复用 `unloadTable`,后续应在 `BioyondPeptideStation` station runtime 上实现 `unload_context_cache`,但本轮不做
4. **多订单**:本轮只保留 `order_ids` 占位,不实现多订单等待、乱序回调或并发 wait。
> 以上 3 点不阻塞节点骨架开发,可在实现阶段再问。
--- ---
## 附录 A`orderCode` 字段的作用与匹配语义 ## 附录 AAPI schema 核对摘要
`orderCode` 是奔耀 LIMS 体系里**订单的"业务编号"**(字符串,如 `EXP260520-103045`),与 `orderId`UUID共存但用途不同。本设计里它有 4 个职责 使用 `temp_benyao/scripts/api_helper.py --root temp_benyao/peptide` 核对
### A.1 创建订单时由 unilabos 端生成 ### A.1 `POST /api/lims/storage/material-info`
`orderCode` **不是** LIMS 自动生成的,而是 peptide_station 在创建订单时本地拼出来后随请求体一起提交 请求
参见 [`peptide_station.py` `_build_order_identity`](../unilabos/devices/workstation/bioyond_studio/peptide_station/peptide_station.py)L1240 附近): ```json
{
```python "apiKey": "B10B5995",
def _build_order_identity(self, day_key: str, order_name_override: Any = None) -> Tuple[str, str]: "requestTime": "2026-05-20T10:50:00.123Z",
stamp = datetime.now().strftime("%y%m%d-%H%M%S") "data": "<materialId UUID>"
order_code = f"EXP{stamp}" }
...
``` ```
随后 `_create_order_payload``orderCode` 放进 `create_order` 的请求体里。LIMS 收到后会持久化这个值,并在以后所有回推消息里带上它。 响应关键字段:
### A.2 它是 `/report/order_finish` 推送的"业务 key" ```json
{
奔耀通过 `POST /report/order_finish` 推送回来时,`request.data` 里同时含有: "id": "<materialId UUID>",
"typeName": "样品",
- `orderCode`(业务编号字符串,对应我们创建时填的) "code": "MAT-001",
- `orderName`(人类可读名称) "barCode": "BC-001",
- `status`30 / -11 / -12 等) "name": "多肽产物",
- `usedMaterials[]`(每项含 `materialId``locationId``typeMode``usedQuantity` "quantity": 10,
"lockQuantity": 0,
`orderId` (UUID) 在 `create_order` 返回的 allocation_map 里以 key 形式给出,但 push 报文里通常不带或不可靠,**业务侧识别同一笔订单只能依赖 `orderCode`**。 "unit": "mg",
"status": 1,
### A.3 节点 1 用它做"多订单并发隔离" "isUse": true,
"locations": [
工作站 HTTP 服务是**进程级单例**,所有 wait 节点共用同一条推送通道: {
"id": "<locationId UUID>",
``` "whid": "<warehouse UUID>",
A 节点等 EXP-001 ─┐ "whName": "自动化堆栈",
B 节点等 EXP-002 ─┼─► /report/order_finish (单一进程入口) "code": "A1",
C 节点等 EXP-003 (并行) ─┘ "x": 1,
"y": 1,
"z": 1,
"quantity": 10
}
],
"detail": []
}
``` ```
如果不按 `orderCode` 过滤,任何一笔订单完成都会唤醒所有 wait 节点,导致 A 节点拿到 B 节点的 report。因此节点 1 复刻 [`bioyond_cell_workstation.py` L168-170](/Users/dp/python/yxz/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py) 的过滤模式: 注意schema 没有 `posX/posY/posZ`,本轮也不展示坐标。
``` ### A.2 `POST /api/lims/order/take-out`
进入 wait_for_order_finish:
self.last_order_code = <要等的 orderCode>
self.order_finish_event.clear()
self.order_finish_event.wait(timeout)
回调 process_order_finish_report: 请求:
if report.orderCode == self.last_order_code:
self.last_order_report = report ```json
self.order_finish_event.set() {
else: "apiKey": "B10B5995",
# 不属于本次等待,忽略(或仅记日志) "requestTime": "2026-05-20T10:50:00.123Z",
"data": {
"orderId": "<orderId UUID>",
"preintakeIds": [],
"materialIds": ["<materialId UUID>"]
}
}
``` ```
`last_order_code` 是 peptide_station 实例上的 **互斥状态字段**,等价于"当前进程正在等的那一笔"。 响应:
### A.4 节点 1 入参允许传 `order_id` 的原因 ```json
{
`start_experiment` 的输出 handle 是 `order_id` / `order_ids`UUID上游工作流图里只能拿到 UUID。所以节点 1 设计成: "code": 1,
"message": null,
``` "timestamp": 1779255000000,
上游 order_id (UUID) "data": true
}
▼ 内部反查
get_order_report(order_id) → data.code (即 orderCode)
self.last_order_code = orderCode
进入阻塞等待
``` ```
这层"UUID → orderCode"翻译对工作流编辑器用户透明。CLI 调试时若已经知道 orderCode也可以直接传 `order_code` 入参跳过反查 源码中已有 `BioyondV1RPC.take_out(order_id, preintake_ids=None, material_ids=None)`,本轮复用它
### A.5 节点 2 中 `orderCode` 的角色 ### A.3 `/report/order_finish`
下料节点 **不参与匹配**,只做透传 + 日志 `/report/order_finish` 不在 Peptide JSON OpenAPI specs 中schema 依据
| 用途 | 字段 | - `unilabos/devices/workstation/workstation_http_service.py`
|------|------| - `temp_benyao/peptide/docs/reference/api_manual.md`
| 日志 / 排错 | 节点函数日志中带上 orderCode |
| 多订单分组列 | `unloadTable` 中可选的 `orderCode` 列(多订单合并时) |
下料节点真正调用 `take-out` 时用的是 `orderId`UUID不依赖 orderCode。 关键字段:
### A.6 与其他 ID 字段的对照速查 ```json
{
"token": "token-from-lims",
"request_time": "2026-05-20 10:50:00.123",
"data": {
"orderCode": "EXP260520-103000",
"orderName": "实验260520-103000",
"startTime": "2026-05-20 09:00:00",
"endTime": "2026-05-20 10:50:00",
"status": "30",
"usedMaterials": [
{
"materialId": "<materialId UUID>",
"locationId": "<locationId UUID>",
"typeMode": "1",
"usedQuantity": 10
}
]
}
}
```
| 字段 | 类型 | 谁生成 | 用在哪 | `WorkstationHTTPService` 会把 `usedMaterials[]` 转成 `MaterialUsage` dataclass 列表传给 `process_order_finish_report(report_request, used_materials)`peptide 输出 `used_materials` handle 前需要转回 JSON dict
|------|------|--------|--------|
| `orderCode` | 字符串(业务编号) | unilabos 端 (`_build_order_identity`) | `/report/order_finish` 匹配、人工排错 | ```json
| `orderId` | UUID | LIMS 端 | LIMS 内部 API 入参(`get_order_report` / `reset_order_status` / `take-out` 等) | [
| `orderName` | 字符串(中文名称) | unilabos 端 | 仅展示用 | {
| `materialId` | UUID | LIMS 端 | `material-info` 查询、入/出库、`take-out.materialIds` | "materialId": "<materialId UUID>",
| `locationId` | UUID | LIMS 端 | 入/出库、`reset_location` | "locationId": "<locationId UUID>",
| `preintakeId` | UUID | LIMS 端 | `take-out.preintakeIds`(当前协议在 `usedMaterials` 中无对应字段,全部传 `[]` | "typeMode": "1",
"usedQuantity": 10
}
]
```
--- ---
## 附录 B前端 manual_confirm 的数据通道(说明 D1 决策的来源) ## 附录 B本轮不实现的内容
参见 [`manual-confirm-detail.tsx`](/Users/dp/go/code/Uni-Lab-Cloud/web/src/app/@enterprise/(new)/leap-lab/components/notification-draw/manual-confirm-detail.tsx) - 不做 station runtime 的 `unload_context_cache`
- 不做多订单。
- `detail.schema` / `detail.uiSchema`:来自当前节点的 goal schema渲染表单`materials_unloaded` checkbox 在这里) - 不做 push 早到后的补救
- `previousNodeResult.data.param`:通过 [`services/manual-confirm.ts L20-28`](/Users/dp/go/code/Uni-Lab-Cloud/web/src/services/manual-confirm.ts) 的 `getPreviousNodeResult(task_uuid, node_uuid)`(即 `GET /api/v1/lab/workflow/task/{task_uuid}/node/{node_uuid}/param`)拿。当前前端只针对 `coin_cell_code` / `mount_resource` 做了硬编码渲染,但 param 里其它字段也都会一并下发,可被后续前端扩展直接使用 - 不做 failed manual_confirm 原地重跑
- 不改前端。
→ 因此节点 1 把 `unloadTable` 写进自己的输出 param节点 2 manual_confirm 弹窗能拿得到;如果放节点 2 内部生成,前端就拉不到 - 不改 `sample_waste_removal`