chore: initial import of AutoRpa state center and YiSaiQi package
Import the existing state center, web UI, YiSaiQi callback skeleton, and project docs. Ignore local venvs, node_modules, recordings, and the duplicate state_center copy.
This commit is contained in:
commit
26ac889b4a
|
|
@ -0,0 +1,32 @@
|
||||||
|
# OS / editor
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.venv/
|
||||||
|
.venv_test/
|
||||||
|
.whisper_venv/
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
state_center_web/dist/
|
||||||
|
|
||||||
|
# Local runtime / agent
|
||||||
|
.runtime/
|
||||||
|
.claude/
|
||||||
|
.superpowers/
|
||||||
|
|
||||||
|
# Duplicate / scratch
|
||||||
|
state_center_副本/
|
||||||
|
|
||||||
|
# Media (keep docs, drop recordings and extracted frames)
|
||||||
|
*.mp4
|
||||||
|
video_frames_v2/
|
||||||
|
|
@ -0,0 +1,367 @@
|
||||||
|
# 状态中心 ↔ 艺赛旗并行开发方案
|
||||||
|
|
||||||
|
## 核心原则:契约驱动,各自独立,最后对接
|
||||||
|
|
||||||
|
双方只通过 **HTTP 回调契约** 交互,这个契约在交接文档里已经明确定义。在对接前,双方各自用假数据/假回调验证自己的逻辑,互不阻塞。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 对接契约(双方共同遵守)
|
||||||
|
|
||||||
|
### 基础信息
|
||||||
|
- **状态中心地址**:联调时给艺赛旗实际 IP,如 `http://192.168.x.x:8000`
|
||||||
|
- **鉴权**:所有回调必须带 header `X-Agent-Token: <token>`(默认 `dev-agent-token`,上线换正式值)
|
||||||
|
- **幂等键**:`complete` 回调可带 `idempotencyKey`,重发同键不会二次推进
|
||||||
|
|
||||||
|
### 三个回调接口(艺赛旗调状态中心)
|
||||||
|
|
||||||
|
#### 1. 节点开始
|
||||||
|
```bash
|
||||||
|
POST /api/jobs/{jobId}/nodes/{nodeCode}/start
|
||||||
|
Content-Type: application/json
|
||||||
|
X-Agent-Token: dev-agent-token
|
||||||
|
|
||||||
|
{
|
||||||
|
"attemptNo": 1,
|
||||||
|
"executorPlatform": "iS-RPA",
|
||||||
|
"operator": "agent"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 节点成功
|
||||||
|
```bash
|
||||||
|
POST /api/jobs/{jobId}/nodes/{nodeCode}/complete
|
||||||
|
Content-Type: application/json
|
||||||
|
X-Agent-Token: dev-agent-token
|
||||||
|
|
||||||
|
{
|
||||||
|
"attemptNo": 1,
|
||||||
|
"executorRunId": "YD-RUN-001",
|
||||||
|
"idempotencyKey": "uuid-xxx",
|
||||||
|
"businessRefs": {
|
||||||
|
"contractSystemNo": "HT2026...", // 三把钥匙,对应节点必传
|
||||||
|
"rebateOrderNo": "FL2026...",
|
||||||
|
"m5OrderNo": "M5-xxx",
|
||||||
|
"receiptNoticeNo": "SH-xxx"
|
||||||
|
},
|
||||||
|
"summary": { "orderCount": 12 },
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"artifactType": "SOURCE_EXCEL",
|
||||||
|
"storagePath": "/data/jobs/xxx/orders.xlsx",
|
||||||
|
"fileHash": "sha256:abc",
|
||||||
|
"visibleRoles": ["PURCHASER", "PLANNER"],
|
||||||
|
"isMasked": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**返回**:`{"ok": true, "next_node": "OCR_EXTRACT"}` ← 艺赛旗拿到 `next_node` 继续跑下一节点
|
||||||
|
|
||||||
|
#### 3. 节点失败
|
||||||
|
```bash
|
||||||
|
POST /api/jobs/{jobId}/nodes/{nodeCode}/fail
|
||||||
|
Content-Type: application/json
|
||||||
|
X-Agent-Token: dev-agent-token
|
||||||
|
|
||||||
|
{
|
||||||
|
"attemptNo": 1,
|
||||||
|
"errorCode": "SYS_LOGIN_FAIL", // 见交接文档错误码表
|
||||||
|
"errorSummary": "验证码识别失败",
|
||||||
|
"retryable": true, // 系统类填 true,业务类填 false
|
||||||
|
"routeRole": "OPS", // OPS/PLANNER/PURCHASER
|
||||||
|
"artifacts": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**返回**:`{"ok": true, "job_status": "FAILED_RETRYABLE"}`
|
||||||
|
|
||||||
|
### 必须回传的业务编号(三把钥匙)
|
||||||
|
|
||||||
|
| 节点 | 必传字段 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| CONTRACT_CREATE | `contractSystemNo` | 合同编号(后续返利、M5靠它串) |
|
||||||
|
| REBATE_HEADER | `rebateOrderNo` | 返利中心总单号 |
|
||||||
|
| M5_RECEIPT_NOTICE | `receiptNoticeNo` | 收货通知单号(全链终点) |
|
||||||
|
|
||||||
|
不传或传空,状态中心返回 **400 拒绝**。
|
||||||
|
|
||||||
|
### 错误码规范(部分,全表见交接文档)
|
||||||
|
|
||||||
|
| 错误码 | 含义 | retryable | routeRole |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| SYS_LOGIN_FAIL | 登录失败 | true | OPS |
|
||||||
|
| SYS_PAGE_TIMEOUT | 页面超时 | true | OPS |
|
||||||
|
| UI_ELEMENT_NOT_FOUND | 页面改版找不到元素 | false | OPS |
|
||||||
|
| BIZ_CONTRACT_DISABLED | 供应商合同被禁 | false | PURCHASER |
|
||||||
|
| M5_QC_MISSING | M5质管资料缺失 | - | - |
|
||||||
|
| REBATE_DUP_CODE | 返利重复编码 | false | PURCHASER |
|
||||||
|
|
||||||
|
**特殊规则**:`M5_QC_MISSING` 无条件转 `FAILED_MANUAL`,`retryable` 填什么都没用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 状态中心这边(你的任务)
|
||||||
|
|
||||||
|
### 阶段1:独立验证(不依赖艺赛旗)
|
||||||
|
**时间**:1-2天
|
||||||
|
**目标**:后端在真 MySQL 上用假回调跑通全链
|
||||||
|
|
||||||
|
#### 任务
|
||||||
|
1. **环境准备**
|
||||||
|
- Docker 起 MySQL 8,执行 `schema.sql` 建库。
|
||||||
|
- 配 `MYSQL_DSN`,起后端服务。
|
||||||
|
- 前端 `npm run dev` 起来。
|
||||||
|
|
||||||
|
2. **用假回调跑通一单**
|
||||||
|
- 方式1:用 curl 按顺序调回调接口。
|
||||||
|
- 方式2:用前端"开发:节点回调模拟"区域的按钮。
|
||||||
|
- **路径**:建单 → ORDER_FETCH start/complete → PRODUCT_MATCH run(有 MULTI) → 人工确认 → SOURCE_ADD 人工提交 → CONTRACT_CREATE complete(带合同编号) → ... → ARCHIVE_JOB。
|
||||||
|
- **验证点**:
|
||||||
|
- job 状态正确推进(PENDING → RUNNING → WAITING_USER → ...)。
|
||||||
|
- 业务编号正确落库(合同编号、返利总单号、M5单号、收货通知单号)。
|
||||||
|
- job_node 每个节点有历史记录,attempt_no 正确。
|
||||||
|
- user_task 在该创建时创建,提交后关闭。
|
||||||
|
- job_artifact 证据落库。
|
||||||
|
- 条件节点(OCR_EXTRACT/SOURCE_ADD)正确跳过或执行。
|
||||||
|
|
||||||
|
3. **发现并修复真实问题**
|
||||||
|
- SQL 语法问题(如 MySQL 8 的保留字冲突)。
|
||||||
|
- JSON 列序列化问题(Python dict → MySQL JSON → 前端拿到的是对象还是字符串)。
|
||||||
|
- 事务回滚是否正确。
|
||||||
|
- datetime 字段格式(MySQL 存的 `YYYY-MM-DD HH:MM:SS`,前端拿到的格式)。
|
||||||
|
- 幂等逻辑是否真的防重(重复 complete 同一节点,状态不会乱)。
|
||||||
|
|
||||||
|
4. **记录真实 API 响应**
|
||||||
|
- 把 `GET /api/jobs/{id}` 的完整响应存下来,给 UI 优化做参考。
|
||||||
|
|
||||||
|
#### 产出
|
||||||
|
- ✅ 一个能在真 MySQL 上跑通全链的后端(用假回调验证)。
|
||||||
|
- ✅ 一份真实响应示例 + 发现的问题清单。
|
||||||
|
- ✅ 前端能正常展示真实数据(不再依赖 `demoJobDetailMap`)。
|
||||||
|
|
||||||
|
### 阶段2:优化与补全(不依赖艺赛旗)
|
||||||
|
**时间**:2-3天
|
||||||
|
**目标**:UI 优化 + 补缺失接口 + 加调度重试
|
||||||
|
|
||||||
|
#### 任务
|
||||||
|
1. **UI 优化**
|
||||||
|
- 按刚才的方案,分区 + 可折叠 + 调试开关。
|
||||||
|
- 基于阶段1 的真实响应,确保所有字段正确展示。
|
||||||
|
|
||||||
|
2. **补缺失接口**
|
||||||
|
- `GET /api/jobs/{id}/match/unmatched`:返回该 job 未命中的订单行列表。
|
||||||
|
- 其他小接口(如果阶段1 发现有遗漏)。
|
||||||
|
|
||||||
|
3. **调度与重试(任务6)**
|
||||||
|
- 定时建单:每天早上扫前一天订单。
|
||||||
|
- 系统类错误自动重试(`FAILED_RETRYABLE` 状态的 job,重试 1-2 次)。
|
||||||
|
- 超时升级:合同审批超时、人工待办超时转 OPS。
|
||||||
|
|
||||||
|
#### 产出
|
||||||
|
- ✅ 优化后的前端(信息分层清晰,调试模式可开关)。
|
||||||
|
- ✅ 完整的接口(验收点名的都实现)。
|
||||||
|
- ✅ 调度器能定时触发建单和重试。
|
||||||
|
|
||||||
|
### 阶段3:准备对接(等艺赛旗阶段2 完成)
|
||||||
|
**时间**:半天
|
||||||
|
**目标**:给艺赛旗提供联调环境
|
||||||
|
|
||||||
|
#### 任务
|
||||||
|
1. **部署联调环境**
|
||||||
|
- 把后端部署到艺赛旗能访问的机器(或你本机开端口映射)。
|
||||||
|
- 给艺赛旗提供:基础地址(如 `http://192.168.x.x:8000`) + 正式 `X-Agent-Token`。
|
||||||
|
|
||||||
|
2. **准备测试数据**
|
||||||
|
- 在库里建几个不同类型的 job:
|
||||||
|
- 医药城线上单(会跳过 OCR_EXTRACT)。
|
||||||
|
- 线下单(会走 OCR_EXTRACT)。
|
||||||
|
- 有对码 MULTI 的单。
|
||||||
|
- 缺货源的单(会走 SOURCE_ADD)。
|
||||||
|
- 每个 job 停在 ORDER_FETCH 节点,等艺赛旗来调 start/complete 推进。
|
||||||
|
|
||||||
|
3. **写联调检查表**
|
||||||
|
- 逐节点列验收点,双方对着检查表勾。
|
||||||
|
|
||||||
|
#### 产出
|
||||||
|
- ✅ 一个可供艺赛旗调用的联调环境。
|
||||||
|
- ✅ 测试数据 + 联调检查表。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 艺赛旗那边(你要转述给他们)
|
||||||
|
|
||||||
|
### 阶段1:独立开发(不依赖状态中心)
|
||||||
|
**时间**:2-3周(他们的活重)
|
||||||
|
**目标**:每个节点的页面自动化都能独立跑通
|
||||||
|
|
||||||
|
#### 任务
|
||||||
|
1. **理解回调契约**
|
||||||
|
- 仔细读 `给艺赛旗的开发交接.md`。
|
||||||
|
- 确认三个回调接口的字段、错误码表。
|
||||||
|
|
||||||
|
2. **逐节点开发页面自动化**
|
||||||
|
- **节点顺序**(他们的优先级):
|
||||||
|
1. ORDER_FETCH(订单获取):登录医药城/小药药 → 导出 Excel → 存证据。
|
||||||
|
2. CONTRACT_CREATE(合同建单):登录合同系统 → 填表 → 上传 PDF(医药城) → 提交送审 → 拿到合同编号。
|
||||||
|
3. REBATE_HEADER(返利总单):登录返利中心 → 按合同编号查总单 → 填必填项 → 保存 → 拿到返利总单号。
|
||||||
|
4. REBATE_IMPORT(返利细单):下载模板 → 填字段 → 上传 → 读成功/失败行。
|
||||||
|
5. REBATE_SUBMIT(返利送审):点"提交送审"按钮。
|
||||||
|
6. M5_CREATE_ORDER(M5建单):桌面自动化 → 录单 → **停,不保存**。
|
||||||
|
7. CONTRACT_APPROVE_WAIT(合同审批):定时查审批状态(到点被状态中心扫到时才查一次)。
|
||||||
|
|
||||||
|
- **每个节点独立验证**:
|
||||||
|
- 不调状态中心,在自己环境里跑(比如用测试账号登录真实系统,手工给初始数据)。
|
||||||
|
- 验证能拿到业务编号(合同编号、返利总单号等)。
|
||||||
|
- 验证错误情况能识别出正确的错误码(如登录失败是 `SYS_LOGIN_FAIL`,合同被禁是 `BIZ_CONTRACT_DISABLED`)。
|
||||||
|
|
||||||
|
3. **本地模拟回调**
|
||||||
|
- 自己起个假的状态中心(或者直接用 curl 打印 JSON),验证回调 payload 格式对不对。
|
||||||
|
- 示例(他们自己写个小脚本验证):
|
||||||
|
```python
|
||||||
|
# 假的状态中心 mock.py
|
||||||
|
from flask import Flask, request, jsonify
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
@app.route('/api/jobs/<job_id>/nodes/<node_code>/complete', methods=['POST'])
|
||||||
|
def complete(job_id, node_code):
|
||||||
|
data = request.json
|
||||||
|
print(f"收到 {node_code} complete:", data)
|
||||||
|
# 检查必传字段
|
||||||
|
if node_code == 'CONTRACT_CREATE' and not data.get('businessRefs', {}).get('contractSystemNo'):
|
||||||
|
return jsonify({"error": "缺少合同编号"}), 400
|
||||||
|
return jsonify({"ok": True, "next_node": "下一节点"})
|
||||||
|
|
||||||
|
app.run(port=8000)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 产出
|
||||||
|
- ✅ 每个节点的页面自动化脚本(iS-RPA 流程)。
|
||||||
|
- ✅ 能正确识别各种错误并返回对应错误码。
|
||||||
|
- ✅ 能拿到三把钥匙(合同编号、返利总单号、收货通知单号)。
|
||||||
|
|
||||||
|
### 阶段2:准备对接(等状态中心阶段3 准备好)
|
||||||
|
**时间**:1天
|
||||||
|
**目标**:改成调真实状态中心接口
|
||||||
|
|
||||||
|
#### 任务
|
||||||
|
1. **获取联调环境信息**
|
||||||
|
- 从状态中心拿到:基础地址 + `X-Agent-Token`。
|
||||||
|
|
||||||
|
2. **改造回调逻辑**
|
||||||
|
- 把阶段1 的假回调改成调真实状态中心接口。
|
||||||
|
- **关键**:拿到 `complete` 返回的 `next_node`,继续跑下一节点(不要自己写死顺序)。
|
||||||
|
|
||||||
|
3. **小范围试跑**
|
||||||
|
- 先跑一个最简单的节点(如 ORDER_FETCH),验证回调能调通、状态中心能正确推进。
|
||||||
|
|
||||||
|
#### 产出
|
||||||
|
- ✅ 能调通状态中心回调接口的 RPA 脚本。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 联调阶段(双方一起)
|
||||||
|
|
||||||
|
**时间**:3-5天
|
||||||
|
**目标**:端到端跑通一单真实流程
|
||||||
|
|
||||||
|
### 任务
|
||||||
|
1. **按节点逐个验证**
|
||||||
|
- 顺序:ORDER_FETCH → (OCR_EXTRACT) → PRODUCT_MATCH(状态中心内部) → (SOURCE_ADD 人工) → CONTRACT_CREATE → ...
|
||||||
|
- 每个节点验证:
|
||||||
|
- RPA 能正确调 start/complete/fail。
|
||||||
|
- 状态中心正确推进到下一节点或转人工。
|
||||||
|
- 业务编号正确落库。
|
||||||
|
- 证据正确存储。
|
||||||
|
|
||||||
|
2. **跑完整链**
|
||||||
|
- 医药城线上单(跳过 OCR)。
|
||||||
|
- 线下单(走 OCR)。
|
||||||
|
- 有对码 MULTI 的单(走人工确认)。
|
||||||
|
- 缺货源的单(走 SOURCE_ADD 人工)。
|
||||||
|
- 合同审批等待(定时查几次,验证不会死等)。
|
||||||
|
|
||||||
|
3. **异常场景**
|
||||||
|
- 登录失败 → 重试。
|
||||||
|
- 页面改版找不到元素 → 转 OPS。
|
||||||
|
- M5 质管缺失 → 直接 FAILED_MANUAL,不重试。
|
||||||
|
- 返利重复编码 → 该单挂起,整批继续。
|
||||||
|
|
||||||
|
4. **对照验收规范逐条勾**
|
||||||
|
- 打开 `实施与验收规范.md`,每个节点的代码验收 + 业务验收两栏逐条过。
|
||||||
|
|
||||||
|
### 产出
|
||||||
|
- ✅ 一个能端到端跑通的完整流程(从订单获取到归档)。
|
||||||
|
- ✅ 验收报告(哪些通过,哪些有问题)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 时间线(并行开发)
|
||||||
|
|
||||||
|
```
|
||||||
|
第1周 第2周 第3周
|
||||||
|
状态中心 │────阶段1────│───阶段2───│──阶段3──│────联调────│
|
||||||
|
真MySQL跑通 UI优化+补全 准备环境 与艺赛旗对接
|
||||||
|
(1-2天) (2-3天) (半天) (3-5天)
|
||||||
|
|
||||||
|
艺赛旗 │────────────阶段1────────────│─阶段2─│────联调────│
|
||||||
|
独立开发各节点页面自动化 改真回调 与状态中心对接
|
||||||
|
(2-3周) (1天) (3-5天)
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键点**:
|
||||||
|
- 前两周双方完全独立,互不阻塞。
|
||||||
|
- 状态中心先做完(1周内),等艺赛旗追上。
|
||||||
|
- 艺赛旗开发周期长(页面自动化细节多),但不影响状态中心进度。
|
||||||
|
- 第3周开始联调,双方都已在各自环境验证过,对接就是走形式。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 给艺赛旗的检查清单(转述)
|
||||||
|
|
||||||
|
在开始联调前,艺赛旗必须自证能做到:
|
||||||
|
|
||||||
|
### 功能检查
|
||||||
|
- [ ] 能登录医药城/小药药/合同系统/返利中心/M5,并正确处理验证码/超时。
|
||||||
|
- [ ] 能导出订单 Excel 并存到指定路径。
|
||||||
|
- [ ] 能建合同、上传 PDF(医药城)、拿到合同编号。
|
||||||
|
- [ ] 能在返利中心按合同编号查总单、填表、拿到返利总单号。
|
||||||
|
- [ ] 能下载返利细单模板、填字段、上传、读成功/失败行。
|
||||||
|
- [ ] 能点返利送审按钮。
|
||||||
|
- [ ] 能用桌面自动化在 M5 录单(但不保存)。
|
||||||
|
- [ ] 能定时查合同审批状态(不是连续轮询,是到点查一次)。
|
||||||
|
|
||||||
|
### 回调检查
|
||||||
|
- [ ] 知道何时调 start、何时调 complete、何时调 fail。
|
||||||
|
- [ ] complete 时能正确带 `businessRefs`(三把钥匙对应节点必传)。
|
||||||
|
- [ ] fail 时能正确识别错误码(`SYS_*` vs `BIZ_*`,`retryable` 填对)。
|
||||||
|
- [ ] 拿到 `next_node` 后能继续跑下一节点(不写死顺序)。
|
||||||
|
- [ ] 所有回调都带 `X-Agent-Token` header。
|
||||||
|
|
||||||
|
### 异常检查
|
||||||
|
- [ ] 登录失败时返回 `SYS_LOGIN_FAIL`,`retryable=true`。
|
||||||
|
- [ ] 页面改版找不到元素时返回 `UI_ELEMENT_NOT_FOUND`,`retryable=false`,`routeRole=OPS`。
|
||||||
|
- [ ] M5 质管缺失时返回 `M5_QC_MISSING`(状态中心会直接转 FAILED_MANUAL,不重试)。
|
||||||
|
- [ ] 供应商合同被禁时返回 `BIZ_CONTRACT_DISABLED`,`retryable=false`,`routeRole=PURCHASER`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 总结
|
||||||
|
|
||||||
|
**你现在要做的**:
|
||||||
|
1. 马上开始阶段1(真 MySQL 跑通),1-2天搞定。
|
||||||
|
2. 接着做阶段2(UI 优化 + 补全),2-3天搞定。
|
||||||
|
3. 然后做阶段3(准备联调环境),半天搞定。
|
||||||
|
4. **这时你这边已经全部做完**,等艺赛旗追上(他们要 2-3 周)。
|
||||||
|
|
||||||
|
**艺赛旗要做的**(你转述给他们):
|
||||||
|
1. 阶段1:独立开发各节点页面自动化,2-3周。期间不需要状态中心配合,自己用假数据验证。
|
||||||
|
2. 阶段2:拿到状态中心联调环境信息后,改成调真实接口,1天。
|
||||||
|
3. 联调阶段:双方一起按检查表逐条验收,3-5天。
|
||||||
|
|
||||||
|
**不会互相阻塞**:你 1 周内做完你的,艺赛旗 2-3 周做完他们的,最后 3-5 天一起联调。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
要我把这份文档发给艺赛旗吗?或者你先看看有没有要调整的。
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
# SOURCE_ADD / 对码幂等 / Alias 唯一态 Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 让 `SOURCE_ADD` 有明确触发源,让 `PRODUCT_MATCH` 可重复跑且不重复落库,让 alias 沉淀成唯一当前态。
|
||||||
|
|
||||||
|
**Architecture:** 对码仍然留在 `app/main.py` 的状态中心内执行,但把“判定缺货源 / 对码执行 / 人工确认”拆成稳定的写入边界。`product_match` 以 `(job_id, order_item_id)` 作为幂等键,`mapping_config` 只保留一个 active alias 当前态,`job.needs_source_add` 作为 `SOURCE_ADD` 的唯一触发开关。
|
||||||
|
|
||||||
|
**Tech Stack:** Python, FastAPI, MySQL, pymysql, pytest/unittest
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: 让对码重跑幂等
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/Users/yuuuu/Documents/AutoRpa/state_center/sql/schema.sql`
|
||||||
|
- Modify: `/Users/yuuuu/Documents/AutoRpa/state_center/app/main.py`
|
||||||
|
- Test: `/Users/yuuuu/Documents/AutoRpa/state_center/tests/test_match_engine.py` or a new focused test file
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_match_run_replay_does_not_duplicate_product_match_rows():
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `python3 -m pytest /Users/yuuuu/Documents/AutoRpa/state_center/tests -q`
|
||||||
|
Expected: fail because `product_match` lacks a stable dedupe key or the code still inserts duplicates.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Add a unique key on `(job_id, order_item_id)`, switch `match_run` to upsert by that key, and keep `user_task` creation tied to `product_match_id`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `python3 -m pytest /Users/yuuuu/Documents/AutoRpa/state_center/tests -q`
|
||||||
|
Expected: pass for the replay case.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add /Users/yuuuu/Documents/AutoRpa/state_center/sql/schema.sql /Users/yuuuu/Documents/AutoRpa/state_center/app/main.py /Users/yuuuu/Documents/AutoRpa/state_center/tests
|
||||||
|
git commit -m "fix: make product match replay idempotent"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: 让 SOURCE_ADD 有明确触发源
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/Users/yuuuu/Documents/AutoRpa/state_center/app/main.py`
|
||||||
|
- Modify: `/Users/yuuuu/Documents/AutoRpa/state_center/sql/schema.sql`
|
||||||
|
- Modify: `/Users/yuuuu/Documents/AutoRpa/state_center/给Trae的开发交接.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_source_add_only_triggers_when_job_flag_is_set():
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `python3 -m pytest /Users/yuuuu/Documents/AutoRpa/state_center/tests -q`
|
||||||
|
Expected: fail because `needs_source_add` is not yet driven by a concrete write path.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Introduce a single write path for `job.needs_source_add`, use it in `match_run` / confirm flow, and only let `_next_node()` expose `SOURCE_ADD` when that flag is set.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `python3 -m pytest /Users/yuuuu/Documents/AutoRpa/state_center/tests -q`
|
||||||
|
Expected: pass for both the flagged and skipped cases.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add /Users/yuuuu/Documents/AutoRpa/state_center/app/main.py /Users/yuuuu/Documents/AutoRpa/state_center/sql/schema.sql /Users/yuuuu/Documents/AutoRpa/state_center/给Trae的开发交接.md
|
||||||
|
git commit -m "fix: make source add trigger explicit"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: 让 alias 沉淀成唯一当前态
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/Users/yuuuu/Documents/AutoRpa/state_center/app/main.py`
|
||||||
|
- Modify: `/Users/yuuuu/Documents/AutoRpa/state_center/sql/schema.sql`
|
||||||
|
- Modify: `/Users/yuuuu/Documents/AutoRpa/state_center/README.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_alias_confirm_replaces_existing_active_alias_for_same_key():
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `python3 -m pytest /Users/yuuuu/Documents/AutoRpa/state_center/tests -q`
|
||||||
|
Expected: fail because multiple active aliases for the same `config_key` are still possible.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Make confirm flow deactivate the previous active alias for the same key before inserting the new one, and make alias loading deterministic by reading only the latest active row per key.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `python3 -m pytest /Users/yuuuu/Documents/AutoRpa/state_center/tests -q`
|
||||||
|
Expected: pass with one active alias per key.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add /Users/yuuuu/Documents/AutoRpa/state_center/app/main.py /Users/yuuuu/Documents/AutoRpa/state_center/sql/schema.sql /Users/yuuuu/Documents/AutoRpa/state_center/README.md
|
||||||
|
git commit -m "fix: keep alias mapping deterministic"
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
# State Center Web State Machine Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build the browser-based Web state machine for `state_center`: job list, timeline, task list, and task/node actions.
|
||||||
|
|
||||||
|
**Architecture:** Keep the existing FastAPI backend as the source of truth for workflow state. Add small, focused backend endpoints for timeline, jobs, tasks, and dev ingestion, then build a React/Vite frontend that only renders backend-computed state and posts actions back. Do not move business rules into the frontend.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.13, FastAPI, Pydantic, pymysql, pytest, Node.js, Vite, React, TypeScript, plain CSS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add backend task/timeline data contracts
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `state_center/sql/schema.sql`
|
||||||
|
- Modify: `state_center/app/main.py`
|
||||||
|
- Create: `state_center/tests/test_web_state_machine.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add tests for:
|
||||||
|
- `GET /api/jobs/{job_id}/timeline` returns all 13 nodes with backend-computed statuses.
|
||||||
|
- `POST /api/tasks/{task_id}/submit` stores `submitted_result_json` and returns the stored result on duplicate `submitToken`.
|
||||||
|
- `POST /api/jobs/{job_id}/items` rejects unsafe replace-after-match behavior.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `python -m pytest state_center/tests/test_web_state_machine.py -v`
|
||||||
|
Expected: fail because endpoints/fields do not exist yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Add the schema column for stored task results, then implement the backend helpers and endpoints in `main.py`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `python -m pytest state_center/tests/test_web_state_machine.py -v`
|
||||||
|
Expected: pass.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add state_center/sql/schema.sql state_center/app/main.py state_center/tests/test_web_state_machine.py
|
||||||
|
git commit -m "feat: add web state machine backend contracts"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: Add browser-ready frontend app
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `state_center_web/package.json`
|
||||||
|
- Create: `state_center_web/vite.config.ts`
|
||||||
|
- Create: `state_center_web/index.html`
|
||||||
|
- Create: `state_center_web/src/main.tsx`
|
||||||
|
- Create: `state_center_web/src/App.tsx`
|
||||||
|
- Create: `state_center_web/src/api/client.ts`
|
||||||
|
- Create: `state_center_web/src/components/JobList.tsx`
|
||||||
|
- Create: `state_center_web/src/components/JobTimeline.tsx`
|
||||||
|
- Create: `state_center_web/src/components/TaskList.tsx`
|
||||||
|
- Create: `state_center_web/src/styles.css`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add a lightweight frontend build check and a small unit test for timeline status mapping.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `npm test`
|
||||||
|
Expected: fail because the app does not exist yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Create the Vite app, route-less operational UI, and fetch-based API client.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `npm test`
|
||||||
|
Expected: pass.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add state_center_web
|
||||||
|
git commit -m "feat: add web state machine frontend"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: Wire local dev workflow
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `state_center_web/README.md`
|
||||||
|
- Modify: `state_center/README.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Not required; this task is documentation and run instructions only.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write minimal implementation**
|
||||||
|
|
||||||
|
Document the start commands for the FastAPI backend and Vite frontend on Windows/macOS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify manually**
|
||||||
|
|
||||||
|
Start backend and frontend locally, open the browser app, and confirm the timeline and task list render.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add state_center/README.md state_center_web/README.md
|
||||||
|
git commit -m "docs: add web state machine runbook"
|
||||||
|
```
|
||||||
|
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
# 业务优先单页收敛 Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 把状态中心页面收敛成“业务先看懂单据、运维再下钻”的单页结构,首屏只保留结论、关键编号、当前待办和简化流程,证据/节点详情/验收收口默认折叠。
|
||||||
|
|
||||||
|
**Architecture:** 页面以单据概览为主视图,流程图和当前待办保持可见,但证据、节点详情、验收清单移到折叠区。状态计算仍留在 `stateMachine.ts`,组件只消费派生结果,不在 JSX 里推导业务规则。视觉上减少同屏卡片数量,保留一条主叙事线。
|
||||||
|
|
||||||
|
**Tech Stack:** React 18 + TypeScript + CSS + 现有 FastAPI 数据契约
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: 提炼业务概览的数据模型
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `state_center_web/src/stateMachine.ts`
|
||||||
|
- Modify: `state_center_web/src/stateMachine.test.mjs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
test('概览模型只保留首屏必要信息', () => {
|
||||||
|
const model = buildBusinessOverview(job, timeline, tasks, artifacts);
|
||||||
|
assert.equal(model.header.currentStatusText, '已完成');
|
||||||
|
assert.equal(model.header.currentNodeText, '归档');
|
||||||
|
assert.equal(model.referenceChain.length, 5);
|
||||||
|
assert.equal(model.primaryTask.title, '新增货源:重庆亿昊药业');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd state_center_web && node --test --experimental-strip-types src/stateMachine.test.mjs`
|
||||||
|
Expected: FAIL with missing `buildBusinessOverview` export.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Implement `buildBusinessOverview()` in `stateMachine.ts` so it returns:
|
||||||
|
- 首屏头部摘要
|
||||||
|
- 关键编号链
|
||||||
|
- 当前待办
|
||||||
|
- 简化的流程状态
|
||||||
|
- 折叠区所需的计数摘要
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd state_center_web && node --test --experimental-strip-types src/stateMachine.test.mjs`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add state_center_web/src/stateMachine.ts state_center_web/src/stateMachine.test.mjs
|
||||||
|
git commit -m "feat: add business overview view model"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: 重排单页骨架为业务优先布局
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `state_center_web/src/App.tsx`
|
||||||
|
- Modify: `state_center_web/src/components/JobTimeline.tsx`
|
||||||
|
- Modify: `state_center_web/src/components/JobReferenceChain.tsx`
|
||||||
|
- Modify: `state_center_web/src/components/JobAcceptanceChecklist.tsx`
|
||||||
|
- Modify: `state_center_web/src/components/JobNodeDetailList.tsx`
|
||||||
|
- Modify: `state_center_web/src/components/JobEvidenceList.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Use the existing browser mockup as the acceptance target:
|
||||||
|
- 首屏只显示一个业务结论区
|
||||||
|
- 关键编号和当前待办在首屏可见
|
||||||
|
- 证据、节点详情、验收清单默认收起
|
||||||
|
|
||||||
|
Add a component test or state snapshot asserting the new layout consumes the business overview model instead of rendering all detail blocks at equal weight.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd state_center_web && node --test --experimental-strip-types src/*.test.mjs`
|
||||||
|
Expected: FAIL because the page still renders the old dense stack.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Recompose `App.tsx` into:
|
||||||
|
- 顶部单据概览
|
||||||
|
- 中间流程主区 + 当前待办
|
||||||
|
- 底部三个折叠区:证据、节点详情、验收对照
|
||||||
|
|
||||||
|
Keep existing data loading and callbacks, but move the dense panels behind `<details>` or equivalent collapsible controls. Limit the header to the business summary, not all fields.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
- `cd state_center_web && node --test --experimental-strip-types src/*.test.mjs`
|
||||||
|
- `cd state_center_web && ./node_modules/.bin/tsc --noEmit`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add state_center_web/src/App.tsx state_center_web/src/components/JobTimeline.tsx state_center_web/src/components/JobReferenceChain.tsx state_center_web/src/components/JobAcceptanceChecklist.tsx state_center_web/src/components/JobNodeDetailList.tsx state_center_web/src/components/JobEvidenceList.tsx
|
||||||
|
git commit -m "feat: simplify business overview layout"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: 压缩视觉密度并做浏览器验收
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `state_center_web/src/styles.css`
|
||||||
|
- Optional modify: `state_center_web/src/components/*.tsx` only if a small class split is needed
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
No new logic test needed; use a visual acceptance check:
|
||||||
|
- 业务结论区在首屏可见
|
||||||
|
- 折叠区默认关闭
|
||||||
|
- 屏幕上不同时出现过多等重卡片
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd state_center_web && ./node_modules/.bin/vite build`
|
||||||
|
Expected: build passes before visual check, but browser screenshot should still show the dense layout, proving the need for CSS reduction.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Reduce weight in CSS:
|
||||||
|
- 收窄主卡片数量
|
||||||
|
- 给折叠区明显但低优先级的视觉权重
|
||||||
|
- 让编号链和状态结论压在首屏
|
||||||
|
- 让路径、证据、验收清单更像下钻信息,不像主内容
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
- `cd state_center_web && ./node_modules/.bin/vite build`
|
||||||
|
- `cd state_center_web && ./node_modules/.bin/tsc --noEmit`
|
||||||
|
- `cd state_center_web && node --test --experimental-strip-types src/*.test.mjs`
|
||||||
|
|
||||||
|
Then open the local app in the browser and verify:
|
||||||
|
- 首屏只有一条主叙事
|
||||||
|
- 折叠区默认收起
|
||||||
|
- 证据路径可点
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add state_center_web/src/styles.css
|
||||||
|
git commit -m "feat: reduce dashboard density"
|
||||||
|
```
|
||||||
|
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
# State Center Web State Machine Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Build a browser-based Web application for the state center so users can see where each procurement RPA job is, inspect node details, handle open tasks, and manually advance supported human nodes. The Web app must run the same way on Windows and macOS through a standard browser.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
The first version is option B: status diagram + task list + node actions.
|
||||||
|
|
||||||
|
In scope:
|
||||||
|
- A React/Vite frontend under `state_center_web/`.
|
||||||
|
- FastAPI endpoints needed by the frontend for state machine metadata, job search/list, task list, task submit, and manual node completion.
|
||||||
|
- A visual job detail page showing all 13 nodes, current node, executor owner, completed/running/waiting/failed states, and skipped conditional nodes.
|
||||||
|
- A task page for open tasks by role and status.
|
||||||
|
- Basic node action controls for development/demo only: create job, start/complete/fail RPA nodes with token, run product match, submit task actions, and complete manual nodes where allowed.
|
||||||
|
- Cross-platform scripts based on Node/npm and Python commands only; no Electron or OS-specific shell behavior.
|
||||||
|
|
||||||
|
Out of scope for this first version:
|
||||||
|
- Evidence file browser as a full module.
|
||||||
|
- Enterprise WeChat login/JWT exchange.
|
||||||
|
- Real Oracle credentials and real external-system page automation.
|
||||||
|
- Desktop app packaging.
|
||||||
|
|
||||||
|
Because real Oracle credentials are out of scope, local demo must include one explicit fixture path for the goods engine. The fixture loader is development-only and separate from `/api/match/reload-goods`; production still reads goods master data from Oracle only.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The existing FastAPI service remains the single owner of business state. The frontend never computes the next node by itself; it renders the backend state machine metadata and calls backend actions. 艺赛旗 remains a node executor: it calls `start`, `complete`, and `fail`; it does not own the global business state machine.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Browser["Browser Web App"] --> API["FastAPI State Center"]
|
||||||
|
API --> MySQL["MySQL rpa_center"]
|
||||||
|
API -. read only .-> Oracle["Oracle Goods Master"]
|
||||||
|
ISRPA["iS-RPA"] --> API
|
||||||
|
Human["Business User"] --> Browser
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend Design
|
||||||
|
|
||||||
|
Keep `state_center/app/main.py` as the current entry point, but add small, focused endpoints instead of changing the existing callback contract.
|
||||||
|
|
||||||
|
Add:
|
||||||
|
- `GET /api/state-machine`: returns `NODE_ORDER`, transitions, executor map, conditional node set, and display labels.
|
||||||
|
- `GET /api/jobs`: list/search jobs by `status`, `source_type`, `supplier_name`, `date_from`, `date_to`, with compact fields for the dashboard.
|
||||||
|
- `GET /api/jobs/{job_id}/timeline`: returns all 13 nodes with backend-computed display status for each node: `PENDING`, `RUNNING`, `SUCCESS`, `FAILED`, `WAITING_USER`, `WAITING_EXTERNAL`, or `SKIPPED`.
|
||||||
|
- `GET /api/tasks/my`: list tasks by `roleCode` and `status`. For v1, this is a role-filtered dev endpoint, not a user-identity endpoint.
|
||||||
|
- `POST /api/tasks/{task_id}/submit`: accepts `action`, `payload`, `submitToken`, and `operator`.
|
||||||
|
- `POST /api/jobs/{job_id}/items`: development/data-ingest endpoint for order details before real `ORDER_FETCH` integration. Request body is `{"replace": true, "rows": [...]}`. Each row maps to `order_item`: `orderNo`, `rawProductName`, `platformProductCode`, `spec`, `manufacturer`, `approvalNo`, `qty`, `unitPrice`, and `sourceRowNo`. Repeated ingest with `replace=true` deletes current rows for that job, inserts the new rows, and updates `order_no_list_json` from the payload.
|
||||||
|
- `POST /api/dev/match/load-fixture-goods`: development-only endpoint that loads a small in-memory goods fixture so `/api/jobs/{job_id}/match/run` can be demonstrated without Oracle.
|
||||||
|
- `GET /api/jobs/{job_id}/match/unmatched`: not required by v1 if task rows with `candidates_json` cover the UI; this spec treats the task list as the v1 source of truth and supersedes the older unmatched view for this phase.
|
||||||
|
|
||||||
|
`replace=true` is only valid before `PRODUCT_MATCH` has produced any durable downstream state, or it must transactionally clear dependent `product_match`, `user_task`, and related node/task state for that job in the same operation. The first version should prefer the pre-match restriction.
|
||||||
|
|
||||||
|
Human-node lifecycle rules:
|
||||||
|
- Human nodes use these roles in v1:
|
||||||
|
- `SOURCE_ADD` -> `PURCHASER`
|
||||||
|
- `M5_MANUAL_CONFIRM` -> `PURCHASER`
|
||||||
|
- `M5_RECEIPT_NOTICE` -> `PURCHASER`
|
||||||
|
- Entering `SOURCE_ADD`, `M5_MANUAL_CONFIRM`, or `M5_RECEIPT_NOTICE` creates an open `user_task`, inserts a `job_node` row for the node if one does not exist yet, sets `job.status='WAITING_USER'`, and sets `job.current_role` to the matching role.
|
||||||
|
- Leaving a human node happens through `POST /api/tasks/{task_id}/submit` with `CONFIRM_AND_CONTINUE`, not through the RPA callback path.
|
||||||
|
- `SOURCE_ADD` completion clears `job.needs_source_add` and advances to the next node.
|
||||||
|
- `M5_MANUAL_CONFIRM` completion advances to `M5_RECEIPT_NOTICE`.
|
||||||
|
- `M5_RECEIPT_NOTICE` completion requires `payload.receiptNoticeNo`, writes `job.receipt_notice_no`, and advances to `ARCHIVE_JOB`.
|
||||||
|
|
||||||
|
Task submission rules:
|
||||||
|
- `SELECT_CANDIDATE` payload must include `productMatchId` and `goodsId`; it may include `goodsCode`, `baseUnit`, and `saveAsAlias`.
|
||||||
|
- `SELECT_CANDIDATE` delegates to the existing `match_confirm` behavior by mapping `productMatchId` to the existing `match_id` path and passing through `goodsId`, `goodsCode`, `baseUnit`, and `saveAsAlias`.
|
||||||
|
- `CONFIRM_AND_CONTINUE` can complete manual nodes such as `SOURCE_ADD`, `M5_MANUAL_CONFIRM`, and `M5_RECEIPT_NOTICE` when the task is open and the job is on that node.
|
||||||
|
- `RETRY_CURRENT_NODE`, `TRANSFER_MANUAL`, and `TERMINATE_JOB` are dev-mode task actions only in v1 and must write an event plus task submission record; they do not invent new business routing.
|
||||||
|
- `TERMINATE_JOB` sets the job to `CANCELLED` with an event.
|
||||||
|
- `TRANSFER_MANUAL` changes `current_role` on the job and records the requested target role; full reassignment is later.
|
||||||
|
- `RETRY_CURRENT_NODE` leaves the job on the same node and records the retry request; it does not delete or overwrite `job_node` history.
|
||||||
|
- `submitToken` is required.
|
||||||
|
- First submit locks the task row, stores token/action/payload/result, marks the task `SUBMITTED`, and writes the event.
|
||||||
|
- Repeat submit with the same token returns the stored result.
|
||||||
|
- Repeat submit with a different token returns HTTP 409.
|
||||||
|
- `submitted_result_json` is added to `user_task` to hold the replayable response body.
|
||||||
|
|
||||||
|
Authentication in this first version uses the existing `X-Agent-Token` for write/action endpoints so the demo remains consistent with current code. Read-only endpoints can stay open for local development unless project authentication is added.
|
||||||
|
For browser-based business submission in v1, the app is local-dev only and exposes a small operator settings panel where the developer pastes the token once per session. The token stays in browser session state and is never bundled into app code. The dev simulator can use the token directly; ordinary business screens must not hardcode it.
|
||||||
|
|
||||||
|
The browser UI must not embed the agent token in normal production flows. RPA callback simulators are allowed only behind an explicit dev flag and must be visually separated from business user actions.
|
||||||
|
|
||||||
|
## Frontend Design
|
||||||
|
|
||||||
|
Create `state_center_web/` with React, TypeScript, Vite, and plain CSS. The UI is an operational dashboard, not a marketing page.
|
||||||
|
|
||||||
|
Views:
|
||||||
|
- Job list: dense table with job id, supplier, order date, source, status, current node, role, and timestamps.
|
||||||
|
- Job detail: state-machine diagram across the 13 nodes, plus side panel for job summary, node attempts, open tasks, and allowed actions.
|
||||||
|
- Tasks: filterable open/submitted task list with role/status controls and submit forms.
|
||||||
|
- Dev actions: a clearly labeled simulator section for local callback testing only.
|
||||||
|
|
||||||
|
Core frontend files:
|
||||||
|
- `src/api/client.ts`: fetch wrapper and API types.
|
||||||
|
- `src/stateMachine.ts`: visual labels and derived status helpers only; no next-node business logic.
|
||||||
|
- `src/App.tsx`: route/view state and page composition.
|
||||||
|
- `src/components/JobTimeline.tsx`: node diagram.
|
||||||
|
- `src/components/JobList.tsx`: job table.
|
||||||
|
- `src/components/TaskList.tsx`: task table/actions.
|
||||||
|
- `src/styles.css`: responsive operational layout.
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
1. User opens the browser app.
|
||||||
|
2. Frontend loads `/api/state-machine` and `/api/jobs`.
|
||||||
|
3. User selects a job.
|
||||||
|
4. Frontend loads `/api/jobs/{job_id}` and `/api/jobs/{job_id}/timeline` and renders node state from backend-computed data.
|
||||||
|
5. User submits a task/action.
|
||||||
|
6. Backend updates `user_task`, `job`, `job_node`, and `job_event` as needed.
|
||||||
|
7. Frontend reloads job/task data.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
The frontend should show backend `detail` messages directly in a compact error area. Conflicts such as out-of-order callbacks remain HTTP 409. Missing order items before product match remains HTTP 400. Missing token remains HTTP 401.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Backend tests:
|
||||||
|
- State machine metadata includes all 13 nodes and conditional nodes.
|
||||||
|
- Job list returns compact rows.
|
||||||
|
- Task list filters by role/status.
|
||||||
|
- Task submit is idempotent by `task_id + submit_token`.
|
||||||
|
- Manual node submit rejects jobs not currently on that node.
|
||||||
|
- Timeline response returns all 13 nodes with `SKIPPED` computed server-side.
|
||||||
|
- Dev action simulator remains disabled unless the dev flag is enabled.
|
||||||
|
|
||||||
|
Frontend tests:
|
||||||
|
- Timeline marks completed, current, waiting, failed, and skipped nodes from API data.
|
||||||
|
- Task list renders open tasks and calls submit with token/action.
|
||||||
|
|
||||||
|
Manual verification:
|
||||||
|
- Start FastAPI on port 8000.
|
||||||
|
- Start Vite on port 5173.
|
||||||
|
- Create a job, ingest fixture order items, simulate callbacks, open job detail, and verify the diagram follows backend state.
|
||||||
|
- In a local demo build, exercise the dev-only callback simulator without leaking the token into normal business screens.
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Mock 医药城/小药药订单数据生成器
|
||||||
|
用于在没有真实医药平台访问权限时,模拟 ORDER_FETCH 节点的输出
|
||||||
|
"""
|
||||||
|
import pandas as pd
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import random
|
||||||
|
import os
|
||||||
|
|
||||||
|
# 假订单数据模板
|
||||||
|
MOCK_SUPPLIERS = [
|
||||||
|
"重庆亿昊药业",
|
||||||
|
"华润医药",
|
||||||
|
"国药控股",
|
||||||
|
"上海医药"
|
||||||
|
]
|
||||||
|
|
||||||
|
MOCK_PRODUCTS = [
|
||||||
|
{"name": "阿卡波糖片", "spec": "50mg*30片", "manufacturer": "示例制药", "approval_no": "国药准字H00000001"},
|
||||||
|
{"name": "二甲双胍片", "spec": "0.5g*60片", "manufacturer": "示例制药", "approval_no": "国药准字H00000002"},
|
||||||
|
{"name": "格列美脲片", "spec": "2mg*10片", "manufacturer": "示例制药", "approval_no": "国药准字H00000003"},
|
||||||
|
{"name": "阿托伐他汀钙片", "spec": "10mg*7片", "manufacturer": "示例制药", "approval_no": "国药准字H00000004"},
|
||||||
|
]
|
||||||
|
|
||||||
|
def generate_mock_orders(supplier_name: str, order_date: str, source_type: str = "YYC", item_count: int = 5):
|
||||||
|
"""
|
||||||
|
生成模拟订单Excel
|
||||||
|
|
||||||
|
Args:
|
||||||
|
supplier_name: 供应商名称
|
||||||
|
order_date: 订单日期 YYYY-MM-DD
|
||||||
|
source_type: 来源类型 YYC/XYY/OFFLINE
|
||||||
|
item_count: 订单明细行数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
生成的Excel文件路径
|
||||||
|
"""
|
||||||
|
rows = []
|
||||||
|
for i in range(item_count):
|
||||||
|
product = random.choice(MOCK_PRODUCTS)
|
||||||
|
rows.append({
|
||||||
|
"订单号": f"DD-{order_date.replace('-', '')}-{i+1:03d}",
|
||||||
|
"订单日期": order_date,
|
||||||
|
"供应商名称": supplier_name,
|
||||||
|
"药品通用名": product["name"],
|
||||||
|
"规格": product["spec"],
|
||||||
|
"单位": "盒",
|
||||||
|
"数量": random.randint(10, 500),
|
||||||
|
"单价": round(random.uniform(5.0, 200.0), 2),
|
||||||
|
"生产厂家": product["manufacturer"],
|
||||||
|
"批准文号": product["approval_no"],
|
||||||
|
})
|
||||||
|
|
||||||
|
df = pd.DataFrame(rows)
|
||||||
|
|
||||||
|
# 生成文件路径
|
||||||
|
output_dir = "/tmp/mock_rpa_data"
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
|
||||||
|
filename = f"orders_{supplier_name}_{order_date}_{source_type}.xlsx"
|
||||||
|
filepath = os.path.join(output_dir, filename)
|
||||||
|
|
||||||
|
df.to_excel(filepath, index=False, sheet_name="订单明细")
|
||||||
|
print(f"✓ 生成模拟订单: {filepath}")
|
||||||
|
print(f" - 供应商: {supplier_name}")
|
||||||
|
print(f" - 订单日期: {order_date}")
|
||||||
|
print(f" - 明细行数: {len(rows)}")
|
||||||
|
|
||||||
|
return filepath
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# 生成几个测试订单
|
||||||
|
test_date = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
print("生成模拟订单数据...\n")
|
||||||
|
|
||||||
|
# 医药城订单(线上)
|
||||||
|
generate_mock_orders("重庆亿昊药业", test_date, "YYC", item_count=8)
|
||||||
|
|
||||||
|
# 小药药订单(线上)
|
||||||
|
generate_mock_orders("华润医药", test_date, "XYY", item_count=5)
|
||||||
|
|
||||||
|
# 线下手工单
|
||||||
|
generate_mock_orders("国药控股", test_date, "OFFLINE", item_count=3)
|
||||||
|
|
||||||
|
print("\n✓ 全部完成,文件位于: /tmp/mock_rpa_data/")
|
||||||
|
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
ORDER_FETCH 节点 Mock 实现
|
||||||
|
模拟从医药城/小药药导出订单,实际返回本地生成的假数据
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
from mock_orders import generate_mock_orders
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
|
||||||
|
STATE_CENTER_URL = "http://localhost:8000" # 状态中心地址
|
||||||
|
AGENT_TOKEN = "dev-agent-token-12345" # 开发令牌(和state_center的配置一致)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_file_hash(filepath: str) -> str:
|
||||||
|
"""计算文件SHA256哈希"""
|
||||||
|
sha256 = hashlib.sha256()
|
||||||
|
with open(filepath, 'rb') as f:
|
||||||
|
for chunk in iter(lambda: f.read(4096), b''):
|
||||||
|
sha256.update(chunk)
|
||||||
|
return f"sha256:{sha256.hexdigest()}"
|
||||||
|
|
||||||
|
|
||||||
|
def mock_order_fetch(job_id: str, order_date: str, source_type: str = "YYC"):
|
||||||
|
"""
|
||||||
|
模拟 ORDER_FETCH 节点执行
|
||||||
|
|
||||||
|
Args:
|
||||||
|
job_id: 单据ID
|
||||||
|
order_date: 订单日期
|
||||||
|
source_type: 来源类型 YYC/XYY/OFFLINE
|
||||||
|
"""
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"🤖 [Mock RPA] ORDER_FETCH 开始执行")
|
||||||
|
print(f" Job ID: {job_id}")
|
||||||
|
print(f" 订单日期: {order_date}")
|
||||||
|
print(f" 来源: {source_type}")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
# 1. 调用 start 回调
|
||||||
|
print("📞 调用状态中心 /start 回调...")
|
||||||
|
start_resp = requests.post(
|
||||||
|
f"{STATE_CENTER_URL}/api/jobs/{job_id}/nodes/ORDER_FETCH/start",
|
||||||
|
headers={"X-Agent-Token": AGENT_TOKEN},
|
||||||
|
json={
|
||||||
|
"attemptNo": 1,
|
||||||
|
"executorRunId": f"MOCK-RUN-{datetime.now().strftime('%Y%m%d%H%M%S')}",
|
||||||
|
"executorPlatform": "mock-rpa",
|
||||||
|
"operator": "mock-script"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
print(f" 响应: {start_resp.status_code} - {start_resp.json()}\n")
|
||||||
|
|
||||||
|
# 2. 生成假订单数据
|
||||||
|
print("📦 生成模拟订单数据...")
|
||||||
|
supplier_name = "重庆亿昊药业" if source_type == "YYC" else "华润医药"
|
||||||
|
excel_path = generate_mock_orders(supplier_name, order_date, source_type, item_count=8)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 3. 计算文件哈希
|
||||||
|
file_hash = calculate_file_hash(excel_path)
|
||||||
|
|
||||||
|
# 4. 调用 complete 回调
|
||||||
|
print("📞 调用状态中心 /complete 回调...")
|
||||||
|
order_nos = [f"DD-{order_date.replace('-', '')}-{i+1:03d}" for i in range(8)]
|
||||||
|
|
||||||
|
complete_resp = requests.post(
|
||||||
|
f"{STATE_CENTER_URL}/api/jobs/{job_id}/nodes/ORDER_FETCH/complete",
|
||||||
|
headers={"X-Agent-Token": AGENT_TOKEN},
|
||||||
|
json={
|
||||||
|
"attemptNo": 1,
|
||||||
|
"executorRunId": f"MOCK-RUN-{datetime.now().strftime('%Y%m%d%H%M%S')}",
|
||||||
|
"executorPlatform": "mock-rpa",
|
||||||
|
"operator": "mock-script",
|
||||||
|
"summary": {
|
||||||
|
"orderCount": 8,
|
||||||
|
"supplierList": [supplier_name],
|
||||||
|
"orderNoList": order_nos
|
||||||
|
},
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"artifactType": "SOURCE_EXCEL",
|
||||||
|
"storagePath": excel_path,
|
||||||
|
"fileHash": file_hash
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
print(f" 响应: {complete_resp.status_code}")
|
||||||
|
result = complete_resp.json()
|
||||||
|
print(f" next_node: {result.get('next_node')}")
|
||||||
|
print(f" job_status: {result.get('job_status')}\n")
|
||||||
|
|
||||||
|
print(f"✅ ORDER_FETCH 完成!下一节点: {result.get('next_node')}\n")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# 示例:模拟昨天的医药城订单
|
||||||
|
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||||
|
job_id = f"YYC-重庆亿昊药业-{yesterday.replace('-', '')}-001"
|
||||||
|
|
||||||
|
mock_order_fetch(job_id, yesterday, "YYC")
|
||||||
|
|
@ -0,0 +1,147 @@
|
||||||
|
# 艺赛旗 CONTRACT_CREATE 联调骨架
|
||||||
|
|
||||||
|
这个目录放艺赛旗侧可复制到 Windows 虚拟机的代码。第一版目标是先验证:
|
||||||
|
|
||||||
|
1. Windows 虚拟机能访问状态中心。
|
||||||
|
2. `CONTRACT_CREATE` 能按约定调用 `start`。
|
||||||
|
3. 合同系统动作完成后能调用 `complete`,并带 `businessRefs.contractSystemNo`。
|
||||||
|
4. 登录失败、页面改版、合同禁用等异常能调用 `fail`。
|
||||||
|
|
||||||
|
## 合同节点步骤点
|
||||||
|
|
||||||
|
来自 `state_center/给艺赛旗的开发交接.md`:
|
||||||
|
|
||||||
|
1. 打开合同测试系统:`http://contract-test.gzmpc.com:8080/`
|
||||||
|
2. 登录:`test002 / 1`
|
||||||
|
3. 左侧菜单进入 `合同管理 > 合同起草`
|
||||||
|
4. 在最近使用模板中选择 `templateName`,非格式默认 `非格式--采购协议`
|
||||||
|
5. 填签约方信息:客户编码、客户名称
|
||||||
|
6. 填基础信息:合同版本类型、合同类别、结算类型、合同背景概述、合同性质
|
||||||
|
7. 填金额和日期,合同签订日期和合同开始日期必须等于订单日期
|
||||||
|
8. 填货品明细
|
||||||
|
9. 填付款信息与特殊标志:是否重大合同、是否预付款、归属年份、付款方式、付款期限、合同状态、是否包含血液制品
|
||||||
|
10. 医药城 `YYC`:同供应商同日 PDF 先合并成一个再上传
|
||||||
|
11. 小药药 `XYY`:格式合同,跳过合同证文上传
|
||||||
|
12. 提交并读取合同编号
|
||||||
|
13. 调状态中心 `complete`,必传 `businessRefs.contractSystemNo`
|
||||||
|
|
||||||
|
## 先做联通验证
|
||||||
|
|
||||||
|
在项目根目录执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m rpa_yisaiqi.contract_create_node `
|
||||||
|
--state-center-url http://127.0.0.1:8000 `
|
||||||
|
--token dev-agent-token `
|
||||||
|
--job-id YYC-联调测试供应商-20260811-a6e5df `
|
||||||
|
--supplier-name 联调测试供应商 `
|
||||||
|
--order-date 2026-08-11 `
|
||||||
|
--source-type YYC `
|
||||||
|
--contract-system-no HT-20260811-001
|
||||||
|
```
|
||||||
|
|
||||||
|
这条只验证回调链路,合同号由命令行传入。
|
||||||
|
|
||||||
|
## 真实合同起草运行
|
||||||
|
|
||||||
|
先准备合同起草输入 JSON,参考:
|
||||||
|
|
||||||
|
- `examples/contract_draft_yyc.json`
|
||||||
|
- `examples/contract_draft_xyy.json`
|
||||||
|
|
||||||
|
YYC 有多份平台 PDF 时需要合并,先安装:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pip install -r rpa_yisaiqi/requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
然后运行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m rpa_yisaiqi.contract_create_node `
|
||||||
|
--state-center-url http://192.168.x.x:8000 `
|
||||||
|
--token dev-agent-token `
|
||||||
|
--job-id <状态中心job_id> `
|
||||||
|
--supplier-name 重庆亿昊药业 `
|
||||||
|
--order-date 2026-08-11 `
|
||||||
|
--source-type YYC `
|
||||||
|
--draft-path rpa_yisaiqi/examples/contract_draft_yyc.json `
|
||||||
|
--merged-pdf-output C:/rpa/jobs/<状态中心job_id>/contract-merged.pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
这条会走真实起草流程:
|
||||||
|
|
||||||
|
1. 状态中心 `/start`
|
||||||
|
2. 打开合同系统并登录
|
||||||
|
3. 进入 `合同管理 > 合同起草`
|
||||||
|
4. 选择最近使用模板
|
||||||
|
5. 填签约方、基础信息、金额日期、货品明细、付款和特殊标志
|
||||||
|
6. YYC 合并并上传 PDF,XYY 跳过上传
|
||||||
|
7. 提交
|
||||||
|
8. 读取合同编号
|
||||||
|
9. 状态中心 `/complete`
|
||||||
|
|
||||||
|
### 先做 dry-run 预览
|
||||||
|
|
||||||
|
如果你只想先验证页面,不想真的提交,把输入里的 `submitMode` 设成 `dry_run`。这时流程会跑到提交前并输出 `contract-dry-run-ready` 截图,不会点提交,也不会读取真实合同号。
|
||||||
|
|
||||||
|
适合先校准 selector 的情况:
|
||||||
|
|
||||||
|
1. 进入 `合同管理 > 合同起草`
|
||||||
|
2. 选择最近使用模板
|
||||||
|
3. 填签约方、基础信息、金额日期、明细、付款和特殊标志
|
||||||
|
4. 停在提交前
|
||||||
|
|
||||||
|
如果状态中心部署在 macOS 主机,`--state-center-url` 换成 Windows 虚拟机能访问到的地址,例如:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
--state-center-url http://192.168.x.x:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
## 接入真实页面自动化
|
||||||
|
|
||||||
|
真实艺赛旗页面操作按流程图拆在:
|
||||||
|
|
||||||
|
- `flows/contract_create_flow.json`:流程图节点清单
|
||||||
|
- `contract_flow_steps.py`:每个流程图节点对应的动作函数
|
||||||
|
- `yisaiqi_adapter_template.py`:艺赛旗浏览器活动适配模板
|
||||||
|
|
||||||
|
艺赛旗设计器里建议按这个顺序拖流程图:
|
||||||
|
|
||||||
|
1. `S01_CALLBACK_START`:HTTP 请求状态中心 `/start`
|
||||||
|
2. `S02_OPEN_CONTRACT_SYSTEM`:打开合同系统
|
||||||
|
3. `S03_LOGIN`:输入 `j_username`、`j_password`,点击 `btn_submit`
|
||||||
|
4. `S04_ENTER_CONTRACT_DRAFT`:进入 `合同管理 > 合同起草`
|
||||||
|
5. `S05_SELECT_RECENT_TEMPLATE`:选择最近使用模板
|
||||||
|
6. `S06_FILL_SIGNATORY_INFO`:填客户编码、客户名称
|
||||||
|
7. `S07_FILL_CONTRACT_BASIC_FORM`:填合同类别、结算类型、背景概述、合同性质
|
||||||
|
8. `S08_FILL_AMOUNT_AND_DATES`:填金额、签订日期、开始日期、结束日期
|
||||||
|
9. `S09_FILL_GOODS_LINES`:循环填货品明细
|
||||||
|
10. `S10_FILL_PAYMENT_AND_FLAGS`:填付款方式、付款期限、合同状态、是否包含血液制品等
|
||||||
|
11. `S11_MERGE_PDF_IF_YYC`:`sourceType=YYC` 时先合并同供应商同日 PDF;`XYY` 跳过
|
||||||
|
12. `S12_UPLOAD_PDF_IF_YYC`:`sourceType=YYC` 时上传合并 PDF;`XYY` 跳过
|
||||||
|
13. `S13_SUBMIT`:提交
|
||||||
|
14. `S14_READ_CONTRACT_NO`:读取合同编号到变量 `contractSystemNo`
|
||||||
|
15. `S15_CALLBACK_COMPLETE`:HTTP 请求状态中心 `/complete`
|
||||||
|
|
||||||
|
如果只做预览,把这条流程里的 `submitMode` 设成 `dry_run`,或者直接导入 `flows/contract_create_preview_flow.json`。
|
||||||
|
|
||||||
|
如果艺赛旗支持 Python 脚本块,直接把每个 `contract_flow_steps.py` 函数挂到对应流程图节点。
|
||||||
|
如果艺赛旗只支持原生活动,就按 `flows/contract_create_flow.json` 的节点顺序拖活动,用 `yisaiqi_adapter_template.py` 里的方法名做映射。
|
||||||
|
|
||||||
|
外层回调骨架仍然在 `contract_create_node.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
create_contract_in_contract_system(spec)
|
||||||
|
```
|
||||||
|
|
||||||
|
这个函数最后返回合同编号字符串即可。状态中心回调、幂等键、错误码映射已经封在外层。后续接真页面时,可以在这里调用艺赛旗流程图,或让流程图最后把合同编号回传给这个函数。
|
||||||
|
|
||||||
|
## 异常映射
|
||||||
|
|
||||||
|
| 场景 | 异常类 | 回调错误码 | retryable | routeRole |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 登录失败 | `LoginFailed` | `SYS_LOGIN_FAIL` | true | OPS |
|
||||||
|
| 供应商合同被禁/失效 | `SupplierContractDisabled` | `BIZ_CONTRACT_DISABLED` | false | PURCHASER |
|
||||||
|
| PDF 合并能力不支持 | `PdfMergeUnsupported` | `CAP_PDF_MERGE_UNSUPPORTED` | false | OPS |
|
||||||
|
| 页面改版/元素找不到 | `ContractCreateError` | `UI_ELEMENT_NOT_FOUND` | false | OPS |
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
"""YiSaiQi RPA integration helpers."""
|
||||||
|
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
"""HTTP client for YiSaiQi callbacks into the state center."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any, Callable
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
from urllib.parse import quote
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
JsonDict = dict[str, Any]
|
||||||
|
Opener = Callable[[Request, float], Any]
|
||||||
|
|
||||||
|
|
||||||
|
class StateCenterError(RuntimeError):
|
||||||
|
def __init__(self, status: int, detail: Any):
|
||||||
|
super().__init__(f"state center returned {status}: {detail}")
|
||||||
|
self.status = status
|
||||||
|
self.detail = detail
|
||||||
|
|
||||||
|
|
||||||
|
class StateCenterClient:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
token: str,
|
||||||
|
*,
|
||||||
|
timeout: float = 15,
|
||||||
|
opener: Opener | None = None,
|
||||||
|
):
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.token = token
|
||||||
|
self.timeout = timeout
|
||||||
|
self._opener = opener or _urlopen_with_timeout
|
||||||
|
|
||||||
|
def start_node(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
node_code: str,
|
||||||
|
*,
|
||||||
|
attempt_no: int = 1,
|
||||||
|
operator: str = "agent",
|
||||||
|
) -> JsonDict:
|
||||||
|
return self._post(
|
||||||
|
f"/api/jobs/{_path_part(job_id)}/nodes/{_path_part(node_code)}/start",
|
||||||
|
{
|
||||||
|
"attemptNo": attempt_no,
|
||||||
|
"executorPlatform": "iS-RPA",
|
||||||
|
"operator": operator,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def complete_node(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
node_code: str,
|
||||||
|
payload: JsonDict,
|
||||||
|
) -> JsonDict:
|
||||||
|
return self._post(f"/api/jobs/{_path_part(job_id)}/nodes/{_path_part(node_code)}/complete", payload)
|
||||||
|
|
||||||
|
def fail_node(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
node_code: str,
|
||||||
|
payload: JsonDict,
|
||||||
|
) -> JsonDict:
|
||||||
|
return self._post(f"/api/jobs/{_path_part(job_id)}/nodes/{_path_part(node_code)}/fail", payload)
|
||||||
|
|
||||||
|
def _post(self, path: str, payload: JsonDict) -> JsonDict:
|
||||||
|
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||||
|
request = Request(
|
||||||
|
f"{self.base_url}{path}",
|
||||||
|
data=data,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Agent-Token": self.token,
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with self._opener(request, self.timeout) as response:
|
||||||
|
raw = response.read().decode("utf-8")
|
||||||
|
except HTTPError as exc:
|
||||||
|
detail = exc.read().decode("utf-8", errors="replace")
|
||||||
|
raise StateCenterError(exc.code, detail) from exc
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _path_part(value: str) -> str:
|
||||||
|
return quote(value, safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def _urlopen_with_timeout(request: Request, timeout: float) -> Any:
|
||||||
|
return urlopen(request, timeout=timeout)
|
||||||
|
|
@ -0,0 +1,216 @@
|
||||||
|
"""CONTRACT_CREATE node skeleton for YiSaiQi Windows integration.
|
||||||
|
|
||||||
|
This first version proves the state-center callback path and leaves the
|
||||||
|
contract-system page automation behind a single replaceable runner function.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
import webbrowser
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from .callback_client import StateCenterClient
|
||||||
|
from .contract_draft import ContractDraft, load_contract_draft
|
||||||
|
from .contract_flow_steps import ContractFlowContext, run_contract_create_flow
|
||||||
|
from .pdf_utils import ensure_merged_pdf
|
||||||
|
from .yisaiqi_adapter_template import YiSaiQiBrowserAdapter
|
||||||
|
|
||||||
|
NODE_CODE = "CONTRACT_CREATE"
|
||||||
|
CONTRACT_URL = "http://contract-test.gzmpc.com:8080/"
|
||||||
|
|
||||||
|
|
||||||
|
class ContractCreateError(RuntimeError):
|
||||||
|
error_code = "UI_ELEMENT_NOT_FOUND"
|
||||||
|
retryable = False
|
||||||
|
route_role = "OPS"
|
||||||
|
|
||||||
|
|
||||||
|
class LoginFailed(ContractCreateError):
|
||||||
|
error_code = "SYS_LOGIN_FAIL"
|
||||||
|
retryable = True
|
||||||
|
route_role = "OPS"
|
||||||
|
|
||||||
|
|
||||||
|
class SupplierContractDisabled(ContractCreateError):
|
||||||
|
error_code = "BIZ_CONTRACT_DISABLED"
|
||||||
|
retryable = False
|
||||||
|
route_role = "PURCHASER"
|
||||||
|
|
||||||
|
|
||||||
|
class PdfMergeUnsupported(ContractCreateError):
|
||||||
|
error_code = "CAP_PDF_MERGE_UNSUPPORTED"
|
||||||
|
retryable = False
|
||||||
|
route_role = "OPS"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ContractCreateInput:
|
||||||
|
job_id: str
|
||||||
|
supplier_name: str
|
||||||
|
order_date: str
|
||||||
|
source_type: str
|
||||||
|
contract_system_no: str | None = None
|
||||||
|
attempt_no: int = 1
|
||||||
|
contract_url: str = CONTRACT_URL
|
||||||
|
username: str = "test002"
|
||||||
|
password: str = "1"
|
||||||
|
open_browser: bool = False
|
||||||
|
draft_path: str | None = None
|
||||||
|
merged_pdf_output: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
ContractRunner = Callable[[ContractCreateInput], str]
|
||||||
|
|
||||||
|
|
||||||
|
def execute_contract_create(
|
||||||
|
spec: ContractCreateInput,
|
||||||
|
client: StateCenterClient,
|
||||||
|
*,
|
||||||
|
contract_runner: ContractRunner | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
client.start_node(spec.job_id, NODE_CODE, attempt_no=spec.attempt_no)
|
||||||
|
runner = contract_runner or create_contract_in_contract_system
|
||||||
|
try:
|
||||||
|
contract_no = runner(spec)
|
||||||
|
complete_payload = build_complete_payload(spec, contract_no)
|
||||||
|
return client.complete_node(spec.job_id, NODE_CODE, complete_payload)
|
||||||
|
except ContractCreateError as exc:
|
||||||
|
fail_payload = build_fail_payload(spec, exc)
|
||||||
|
return client.fail_node(spec.job_id, NODE_CODE, fail_payload)
|
||||||
|
|
||||||
|
|
||||||
|
def create_contract_in_contract_system(spec: ContractCreateInput) -> str:
|
||||||
|
"""Draft the contract in the contract system and return contract number."""
|
||||||
|
if spec.open_browser:
|
||||||
|
webbrowser.open(spec.contract_url)
|
||||||
|
if spec.contract_system_no:
|
||||||
|
return spec.contract_system_no
|
||||||
|
if not spec.draft_path:
|
||||||
|
raise ContractCreateError("draft_path is required for real contract drafting")
|
||||||
|
draft = load_contract_draft(spec.draft_path)
|
||||||
|
ctx = build_flow_context(spec, draft)
|
||||||
|
browser = YiSaiQiBrowserAdapter()
|
||||||
|
try:
|
||||||
|
return run_contract_create_flow(browser, ctx)
|
||||||
|
except NotImplementedError as exc:
|
||||||
|
raise ContractCreateError(
|
||||||
|
"YiSaiQiBrowserAdapter still needs to be mapped to YiSaiQi designer activities"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def build_flow_context(spec: ContractCreateInput, draft: ContractDraft) -> ContractFlowContext:
|
||||||
|
merged_pdf_path = draft.merged_pdf_path
|
||||||
|
if draft.source_type == "YYC":
|
||||||
|
output_path = spec.merged_pdf_output or str(
|
||||||
|
Path("runtime") / "contracts" / spec.job_id / "yyc-contract-merged.pdf"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
merged_pdf_path = ensure_merged_pdf(draft.platform_pdf_paths, output_path)
|
||||||
|
except (RuntimeError, ValueError) as exc:
|
||||||
|
raise PdfMergeUnsupported(str(exc)) from exc
|
||||||
|
return ContractFlowContext(
|
||||||
|
contract_url=spec.contract_url,
|
||||||
|
username=spec.username,
|
||||||
|
password=spec.password,
|
||||||
|
supplier_name=draft.supplier_name or spec.supplier_name,
|
||||||
|
order_date=draft.order_date or spec.order_date,
|
||||||
|
source_type=draft.source_type or spec.source_type,
|
||||||
|
template_type=draft.template_type,
|
||||||
|
template_name=draft.template_name,
|
||||||
|
customer_code=draft.customer_code,
|
||||||
|
customer_name=draft.customer_name,
|
||||||
|
contract_category=draft.contract_category,
|
||||||
|
settlement_type=draft.settlement_type,
|
||||||
|
contract_background=draft.contract_background,
|
||||||
|
contract_nature=draft.contract_nature,
|
||||||
|
amount_currency=draft.amount_currency,
|
||||||
|
amount=draft.amount,
|
||||||
|
contract_end_date=draft.contract_end_date,
|
||||||
|
is_major_contract=draft.is_major_contract,
|
||||||
|
is_prepayment=draft.is_prepayment,
|
||||||
|
belong_year=draft.belong_year,
|
||||||
|
payment_method=draft.payment_method,
|
||||||
|
payment_days=draft.payment_days,
|
||||||
|
contract_status=draft.contract_status,
|
||||||
|
contains_blood_products=draft.contains_blood_products,
|
||||||
|
submit_mode=draft.submit_mode,
|
||||||
|
goods_lines=draft.goods_lines,
|
||||||
|
merged_pdf_path=merged_pdf_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_complete_payload(spec: ContractCreateInput, contract_no: str) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"attemptNo": spec.attempt_no,
|
||||||
|
"executorRunId": f"YSQ-CONTRACT-{uuid.uuid4().hex[:12]}",
|
||||||
|
"idempotencyKey": f"{spec.job_id}:{NODE_CODE}:{spec.attempt_no}:{contract_no}",
|
||||||
|
"businessRefs": {"contractSystemNo": contract_no},
|
||||||
|
"summary": {
|
||||||
|
"supplierName": spec.supplier_name,
|
||||||
|
"contractDate": spec.order_date,
|
||||||
|
"sourceType": spec.source_type,
|
||||||
|
"contractUrl": spec.contract_url,
|
||||||
|
"uploadRule": "merge_pdf_before_upload" if spec.source_type == "YYC" else "skip_contract_upload",
|
||||||
|
},
|
||||||
|
"artifacts": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_fail_payload(spec: ContractCreateInput, exc: ContractCreateError) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"attemptNo": spec.attempt_no,
|
||||||
|
"errorCode": exc.error_code,
|
||||||
|
"errorSummary": str(exc),
|
||||||
|
"retryable": exc.retryable,
|
||||||
|
"routeRole": exc.route_role,
|
||||||
|
"artifacts": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Run YiSaiQi CONTRACT_CREATE callback skeleton.")
|
||||||
|
parser.add_argument("--state-center-url", default=os.getenv("STATE_CENTER_URL", "http://127.0.0.1:8000"))
|
||||||
|
parser.add_argument("--token", default=os.getenv("AGENT_TOKEN", "dev-agent-token"))
|
||||||
|
parser.add_argument("--job-id", required=True)
|
||||||
|
parser.add_argument("--supplier-name", required=True)
|
||||||
|
parser.add_argument("--order-date", required=True)
|
||||||
|
parser.add_argument("--source-type", choices=("YYC", "XYY", "OFFLINE"), default="YYC")
|
||||||
|
parser.add_argument("--contract-system-no", default=os.getenv("CONTRACT_SYSTEM_NO"))
|
||||||
|
parser.add_argument("--attempt-no", type=int, default=1)
|
||||||
|
parser.add_argument("--contract-url", default=os.getenv("CONTRACT_URL", CONTRACT_URL))
|
||||||
|
parser.add_argument("--username", default=os.getenv("CONTRACT_USERNAME", "test002"))
|
||||||
|
parser.add_argument("--password", default=os.getenv("CONTRACT_PASSWORD", "1"))
|
||||||
|
parser.add_argument("--open-browser", action="store_true")
|
||||||
|
parser.add_argument("--draft-path", default=os.getenv("CONTRACT_DRAFT_PATH"))
|
||||||
|
parser.add_argument("--merged-pdf-output", default=os.getenv("MERGED_PDF_OUTPUT"))
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
spec = ContractCreateInput(
|
||||||
|
job_id=args.job_id,
|
||||||
|
supplier_name=args.supplier_name,
|
||||||
|
order_date=args.order_date,
|
||||||
|
source_type=args.source_type,
|
||||||
|
contract_system_no=args.contract_system_no,
|
||||||
|
attempt_no=args.attempt_no,
|
||||||
|
contract_url=args.contract_url,
|
||||||
|
username=args.username,
|
||||||
|
password=args.password,
|
||||||
|
open_browser=args.open_browser,
|
||||||
|
draft_path=args.draft_path,
|
||||||
|
merged_pdf_output=args.merged_pdf_output,
|
||||||
|
)
|
||||||
|
client = StateCenterClient(args.state_center_url, args.token)
|
||||||
|
result = execute_contract_create(spec, client)
|
||||||
|
print(result)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
"""Contract draft input model for CONTRACT_CREATE."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .contract_flow_steps import ContractGoodsLine
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ContractDraft:
|
||||||
|
supplier_name: str
|
||||||
|
order_date: str
|
||||||
|
source_type: str
|
||||||
|
template_type: str = "FORMAT"
|
||||||
|
template_name: str = ""
|
||||||
|
customer_code: str = ""
|
||||||
|
customer_name: str = ""
|
||||||
|
contract_category: str = ""
|
||||||
|
settlement_type: str = "付款"
|
||||||
|
contract_background: str = ""
|
||||||
|
contract_nature: str = "主合同"
|
||||||
|
amount_currency: str = "人民币"
|
||||||
|
amount: str = ""
|
||||||
|
contract_end_date: str = ""
|
||||||
|
is_major_contract: str = "否"
|
||||||
|
is_prepayment: str = "否"
|
||||||
|
belong_year: str = ""
|
||||||
|
payment_method: str = "账期支付"
|
||||||
|
payment_days: str = "30"
|
||||||
|
contract_status: str = "正式合同"
|
||||||
|
contains_blood_products: str = "否"
|
||||||
|
submit_mode: str = "submit"
|
||||||
|
goods_lines: list[ContractGoodsLine] = field(default_factory=list)
|
||||||
|
platform_pdf_paths: list[str] = field(default_factory=list)
|
||||||
|
merged_pdf_path: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def load_contract_draft(path: str | Path) -> ContractDraft:
|
||||||
|
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||||
|
return contract_draft_from_dict(data)
|
||||||
|
|
||||||
|
|
||||||
|
def contract_draft_from_dict(data: dict[str, Any]) -> ContractDraft:
|
||||||
|
goods_lines = [
|
||||||
|
ContractGoodsLine(
|
||||||
|
operation_code=str(item.get("operationCode", "")),
|
||||||
|
goods_code=str(item.get("goodsCode", "")),
|
||||||
|
goods_name=str(item.get("goodsName", "")),
|
||||||
|
approval_no=str(item.get("approvalNo", "")),
|
||||||
|
spec=str(item.get("spec", "")),
|
||||||
|
unit=str(item.get("unit", "")),
|
||||||
|
quantity=float(item.get("quantity", 0)),
|
||||||
|
)
|
||||||
|
for item in data.get("goodsLines", [])
|
||||||
|
]
|
||||||
|
return ContractDraft(
|
||||||
|
supplier_name=str(data.get("supplierName", "")),
|
||||||
|
order_date=str(data.get("orderDate", "")),
|
||||||
|
source_type=str(data.get("sourceType", "")),
|
||||||
|
template_type=str(data.get("templateType", "FORMAT")),
|
||||||
|
template_name=str(data.get("templateName", "")),
|
||||||
|
customer_code=str(data.get("customerCode", "")),
|
||||||
|
customer_name=str(data.get("customerName", "")),
|
||||||
|
contract_category=str(data.get("contractCategory", "")),
|
||||||
|
settlement_type=str(data.get("settlementType", "付款")),
|
||||||
|
contract_background=str(data.get("contractBackground", "")),
|
||||||
|
contract_nature=str(data.get("contractNature", "主合同")),
|
||||||
|
amount_currency=str(data.get("amountCurrency", "人民币")),
|
||||||
|
amount=str(data.get("amount", "")),
|
||||||
|
contract_end_date=str(data.get("contractEndDate", "")),
|
||||||
|
is_major_contract=str(data.get("isMajorContract", "否")),
|
||||||
|
is_prepayment=str(data.get("isPrepayment", "否")),
|
||||||
|
belong_year=str(data.get("belongYear", "")),
|
||||||
|
payment_method=str(data.get("paymentMethod", "账期支付")),
|
||||||
|
payment_days=str(data.get("paymentDays", "30")),
|
||||||
|
contract_status=str(data.get("contractStatus", "正式合同")),
|
||||||
|
contains_blood_products=str(data.get("containsBloodProducts", "否")),
|
||||||
|
submit_mode=str(data.get("submitMode", "submit")),
|
||||||
|
goods_lines=goods_lines,
|
||||||
|
platform_pdf_paths=[str(path) for path in data.get("platformPdfPaths", [])],
|
||||||
|
merged_pdf_path=data.get("mergedPdfPath"),
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,212 @@
|
||||||
|
"""Flowchart-sized CONTRACT_CREATE browser steps for YiSaiQi.
|
||||||
|
|
||||||
|
Each function is intentionally small enough to map to one YiSaiQi flowchart
|
||||||
|
node or one script block behind a flowchart node.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
|
||||||
|
class BrowserAdapter(Protocol):
|
||||||
|
def open_url(self, url: str) -> None: ...
|
||||||
|
def fill_first(self, selectors: list[str], value: str) -> None: ...
|
||||||
|
def click_first(self, selectors: list[str]) -> None: ...
|
||||||
|
def wait_for_text(self, text: str, timeout_seconds: int = 15) -> None: ...
|
||||||
|
def select_first(self, selectors: list[str], value: str) -> None: ...
|
||||||
|
def upload_file_first(self, selectors: list[str], file_path: str) -> None: ...
|
||||||
|
def read_first(self, selectors: list[str], value_name: str) -> str: ...
|
||||||
|
def screenshot(self, name: str) -> str: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ContractGoodsLine:
|
||||||
|
operation_code: str
|
||||||
|
goods_code: str
|
||||||
|
goods_name: str
|
||||||
|
approval_no: str
|
||||||
|
spec: str
|
||||||
|
unit: str
|
||||||
|
quantity: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ContractFlowContext:
|
||||||
|
contract_url: str
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
supplier_name: str
|
||||||
|
order_date: str
|
||||||
|
source_type: str
|
||||||
|
template_type: str = "FORMAT"
|
||||||
|
template_name: str = ""
|
||||||
|
customer_code: str = ""
|
||||||
|
customer_name: str = ""
|
||||||
|
contract_category: str = ""
|
||||||
|
settlement_type: str = "付款"
|
||||||
|
contract_background: str = ""
|
||||||
|
contract_nature: str = "主合同"
|
||||||
|
amount_currency: str = "人民币"
|
||||||
|
amount: str = ""
|
||||||
|
contract_end_date: str = ""
|
||||||
|
is_major_contract: str = "否"
|
||||||
|
is_prepayment: str = "否"
|
||||||
|
belong_year: str = ""
|
||||||
|
payment_method: str = "账期支付"
|
||||||
|
payment_days: str = "30"
|
||||||
|
contract_status: str = "正式合同"
|
||||||
|
contains_blood_products: str = "否"
|
||||||
|
submit_mode: str = "submit"
|
||||||
|
goods_lines: list[ContractGoodsLine] = field(default_factory=list)
|
||||||
|
merged_pdf_path: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def run_contract_create_flow(browser: BrowserAdapter, ctx: ContractFlowContext) -> str:
|
||||||
|
open_contract_system(browser, ctx)
|
||||||
|
login_contract_system(browser, ctx)
|
||||||
|
enter_contract_create(browser)
|
||||||
|
select_contract_template(browser, ctx)
|
||||||
|
fill_contract_signatory_info(browser, ctx)
|
||||||
|
fill_contract_basic_info(browser, ctx)
|
||||||
|
fill_contract_amount_and_dates(browser, ctx)
|
||||||
|
fill_contract_goods_lines(browser, ctx)
|
||||||
|
fill_contract_payment_info(browser, ctx)
|
||||||
|
upload_contract_document_if_needed(browser, ctx)
|
||||||
|
if not submit_contract_for_approval(browser, ctx):
|
||||||
|
return ""
|
||||||
|
return read_contract_number(browser)
|
||||||
|
|
||||||
|
|
||||||
|
def open_contract_system(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||||
|
browser.open_url(ctx.contract_url)
|
||||||
|
|
||||||
|
|
||||||
|
def login_contract_system(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||||
|
browser.fill_first(['input[name="j_username"]', 'input[placeholder="用户名"]'], ctx.username)
|
||||||
|
browser.fill_first(['input[name="j_password"]', 'input[placeholder="密码"]'], ctx.password)
|
||||||
|
browser.click_first(['input[name="btn_submit"]', 'a[href*="btn_submit"]', 'text=登录'])
|
||||||
|
browser.wait_for_text("合同", timeout_seconds=20)
|
||||||
|
|
||||||
|
|
||||||
|
def enter_contract_create(browser: BrowserAdapter) -> None:
|
||||||
|
browser.click_first(["text=合同管理", "aria=合同管理"])
|
||||||
|
browser.click_first(["text=合同起草", "aria=合同起草"])
|
||||||
|
browser.wait_for_text("最近使用模板", timeout_seconds=20)
|
||||||
|
|
||||||
|
|
||||||
|
def select_contract_template(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||||
|
template_text = ctx.template_name or (
|
||||||
|
"非格式--采购协议" if ctx.template_type == "NON_FORMAT" else "购销协议书(2025)"
|
||||||
|
)
|
||||||
|
browser.click_first([f"text={template_text}", f"text={template_text}...", f"title={template_text}"])
|
||||||
|
browser.wait_for_text("合同起草", timeout_seconds=20)
|
||||||
|
|
||||||
|
|
||||||
|
def fill_contract_signatory_info(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||||
|
customer_name = ctx.customer_name or ctx.supplier_name
|
||||||
|
if ctx.customer_code:
|
||||||
|
browser.fill_first(
|
||||||
|
['label=客户编码', 'input[name="fdCustomerCode"]', 'input[placeholder*="客户编码"]'],
|
||||||
|
ctx.customer_code,
|
||||||
|
)
|
||||||
|
if customer_name:
|
||||||
|
browser.fill_first(
|
||||||
|
['label=客户名称', 'input[name="fdCustomerName"]', 'input[placeholder*="客户名称"]'],
|
||||||
|
customer_name,
|
||||||
|
)
|
||||||
|
browser.screenshot("contract-signatory-info-filled")
|
||||||
|
|
||||||
|
|
||||||
|
def fill_contract_basic_info(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||||
|
browser.select_first(['label=合同版本类型', 'select[name="fdTemplateType"]'], contract_version_label(ctx))
|
||||||
|
if ctx.contract_category:
|
||||||
|
browser.select_first(['label=合同类别', 'select[name="fdContractCategory"]'], ctx.contract_category)
|
||||||
|
browser.select_first(['label=结算类型', 'select[name="fdSettlementType"]'], ctx.settlement_type)
|
||||||
|
if ctx.contract_background:
|
||||||
|
browser.fill_first(
|
||||||
|
['label=合同背景概述', 'textarea[name="fdContractBackground"]'],
|
||||||
|
ctx.contract_background,
|
||||||
|
)
|
||||||
|
browser.click_first([f"label={ctx.contract_nature}", f"text={ctx.contract_nature}"])
|
||||||
|
browser.screenshot("contract-basic-info-filled")
|
||||||
|
|
||||||
|
|
||||||
|
def fill_contract_amount_and_dates(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||||
|
browser.select_first(['label=合同金额', 'select[name="fdCurrency"]'], ctx.amount_currency)
|
||||||
|
if ctx.amount:
|
||||||
|
browser.fill_first(['label=金额(元)', 'input[name="fdAmount"]'], ctx.amount)
|
||||||
|
browser.fill_first(
|
||||||
|
['label=合同签订日期', 'input[name="fdSignDate"]', 'input[placeholder*="合同签订"]'],
|
||||||
|
ctx.order_date,
|
||||||
|
)
|
||||||
|
browser.fill_first(
|
||||||
|
['label=合同开始日期', 'input[name="fdStartDate"]', 'input[placeholder*="合同开始"]'],
|
||||||
|
ctx.order_date,
|
||||||
|
)
|
||||||
|
if ctx.contract_end_date:
|
||||||
|
browser.fill_first(
|
||||||
|
['label=合同结束日期', 'input[name="fdEndDate"]', 'input[placeholder*="合同结束"]'],
|
||||||
|
ctx.contract_end_date,
|
||||||
|
)
|
||||||
|
browser.screenshot("contract-amount-and-dates-filled")
|
||||||
|
|
||||||
|
|
||||||
|
def fill_contract_goods_lines(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||||
|
for index, line in enumerate(ctx.goods_lines, start=1):
|
||||||
|
if index > 1:
|
||||||
|
browser.click_first(["text=新增明细", "text=添加货品", "text=新增行"])
|
||||||
|
prefix = f"明细{index}"
|
||||||
|
browser.fill_first([f'label={prefix}操作码', 'input[name="operationCode"]'], line.operation_code)
|
||||||
|
browser.fill_first([f'label={prefix}货品编码', 'input[name="goodsCode"]'], line.goods_code)
|
||||||
|
browser.fill_first([f'label={prefix}名称', 'input[name="goodsName"]'], line.goods_name)
|
||||||
|
browser.fill_first([f'label={prefix}国药准字', 'input[name="approvalNo"]'], line.approval_no)
|
||||||
|
browser.fill_first([f'label={prefix}规格', 'input[name="spec"]'], line.spec)
|
||||||
|
browser.fill_first([f'label={prefix}单位', 'input[name="unit"]'], line.unit)
|
||||||
|
browser.fill_first([f'label={prefix}数量', 'input[name="quantity"]'], str(line.quantity))
|
||||||
|
browser.screenshot("contract-goods-lines-filled")
|
||||||
|
|
||||||
|
|
||||||
|
def fill_contract_payment_info(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||||
|
browser.select_first(['label=是否重大合同', 'select[name="fdIsMajorContract"]'], ctx.is_major_contract)
|
||||||
|
browser.select_first(['label=是否预付款', 'select[name="fdIsPrepayment"]'], ctx.is_prepayment)
|
||||||
|
if ctx.belong_year:
|
||||||
|
browser.fill_first(['label=合同归属年份', 'input[name="fdBelongYear"]'], ctx.belong_year)
|
||||||
|
browser.select_first(['label=付款方式', 'select[name="fdPaymentMethod"]', 'select[name="paymentMethod"]'], ctx.payment_method)
|
||||||
|
browser.fill_first(['label=付款期限', 'input[name="fdPaymentDays"]', 'input[name="paymentDays"]'], ctx.payment_days)
|
||||||
|
browser.select_first(['label=合同状态', 'select[name="fdContractStatus"]'], ctx.contract_status)
|
||||||
|
browser.select_first(
|
||||||
|
['label=是否包含血液制品', 'select[name="fdContainsBloodProducts"]'],
|
||||||
|
ctx.contains_blood_products,
|
||||||
|
)
|
||||||
|
browser.screenshot("contract-payment-filled")
|
||||||
|
|
||||||
|
|
||||||
|
def upload_contract_document_if_needed(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||||
|
if ctx.source_type != "YYC":
|
||||||
|
return
|
||||||
|
if not ctx.merged_pdf_path:
|
||||||
|
raise ValueError("YYC contract creation requires merged_pdf_path before upload")
|
||||||
|
browser.upload_file_first(['input[type="file"]', "label=合同证文上传"], ctx.merged_pdf_path)
|
||||||
|
browser.screenshot("contract-pdf-uploaded")
|
||||||
|
|
||||||
|
|
||||||
|
def submit_contract_for_approval(browser: BrowserAdapter, ctx: ContractFlowContext) -> bool:
|
||||||
|
if ctx.submit_mode == "dry_run":
|
||||||
|
browser.screenshot("contract-dry-run-ready")
|
||||||
|
return False
|
||||||
|
browser.click_first(["text=提交", "text=提交送审", "text=送审"])
|
||||||
|
browser.wait_for_text("合同编号", timeout_seconds=30)
|
||||||
|
browser.screenshot("contract-submitted")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def read_contract_number(browser: BrowserAdapter) -> str:
|
||||||
|
return browser.read_first(
|
||||||
|
['input[name="contractNo"]', 'input[name="contractSystemNo"]', "label=合同编号", "text=合同编号"],
|
||||||
|
"contract_number",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def contract_version_label(ctx: ContractFlowContext) -> str:
|
||||||
|
return "非格式合同" if ctx.template_type == "NON_FORMAT" else "格式合同"
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
{
|
||||||
|
"supplierName": "小药药供应商",
|
||||||
|
"orderDate": "2026-08-11",
|
||||||
|
"sourceType": "XYY",
|
||||||
|
"templateType": "FORMAT",
|
||||||
|
"templateName": "购销协议书(2025)",
|
||||||
|
"customerCode": "CUST-XYY-001",
|
||||||
|
"customerName": "小药药供应商",
|
||||||
|
"contractCategory": "采购协议",
|
||||||
|
"settlementType": "付款",
|
||||||
|
"contractBackground": "按小药药订单自动起草采购协议",
|
||||||
|
"contractNature": "主合同",
|
||||||
|
"amountCurrency": "人民币",
|
||||||
|
"amount": "9600.00",
|
||||||
|
"contractEndDate": "2026-12-31",
|
||||||
|
"isMajorContract": "否",
|
||||||
|
"isPrepayment": "否",
|
||||||
|
"belongYear": "2026",
|
||||||
|
"paymentMethod": "账期支付",
|
||||||
|
"paymentDays": "30",
|
||||||
|
"contractStatus": "正式合同",
|
||||||
|
"containsBloodProducts": "否",
|
||||||
|
"platformPdfPaths": [],
|
||||||
|
"goodsLines": [
|
||||||
|
{
|
||||||
|
"operationCode": "ADD",
|
||||||
|
"goodsCode": "YP-002",
|
||||||
|
"goodsName": "哈药牌钙铁锌口服液",
|
||||||
|
"approvalNo": "国药准字B00000002",
|
||||||
|
"spec": "10ml*12支",
|
||||||
|
"unit": "盒",
|
||||||
|
"quantity": 24
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
{
|
||||||
|
"supplierName": "重庆亿昊药业",
|
||||||
|
"orderDate": "2026-08-11",
|
||||||
|
"sourceType": "YYC",
|
||||||
|
"templateType": "NON_FORMAT",
|
||||||
|
"templateName": "非格式--采购协议",
|
||||||
|
"customerCode": "CUST-YYC-001",
|
||||||
|
"customerName": "重庆亿昊药业",
|
||||||
|
"contractCategory": "采购协议",
|
||||||
|
"settlementType": "付款",
|
||||||
|
"contractBackground": "按医药城订单自动起草采购协议",
|
||||||
|
"contractNature": "主合同",
|
||||||
|
"amountCurrency": "人民币",
|
||||||
|
"amount": "12000.50",
|
||||||
|
"contractEndDate": "2026-12-31",
|
||||||
|
"isMajorContract": "否",
|
||||||
|
"isPrepayment": "否",
|
||||||
|
"belongYear": "2026",
|
||||||
|
"paymentMethod": "账期支付",
|
||||||
|
"paymentDays": "30",
|
||||||
|
"contractStatus": "正式合同",
|
||||||
|
"containsBloodProducts": "否",
|
||||||
|
"platformPdfPaths": [
|
||||||
|
"C:/rpa/jobs/JOB-1/platform-contract-1.pdf",
|
||||||
|
"C:/rpa/jobs/JOB-1/platform-contract-2.pdf"
|
||||||
|
],
|
||||||
|
"goodsLines": [
|
||||||
|
{
|
||||||
|
"operationCode": "ADD",
|
||||||
|
"goodsCode": "YP-001",
|
||||||
|
"goodsName": "阿卡波糖片",
|
||||||
|
"approvalNo": "国药准字H00000001",
|
||||||
|
"spec": "50mg*30片",
|
||||||
|
"unit": "盒",
|
||||||
|
"quantity": 12
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
{
|
||||||
|
"flowName": "CONTRACT_CREATE_合同建单",
|
||||||
|
"platform": "YiSaiQi iS-RPA",
|
||||||
|
"nodeCode": "CONTRACT_CREATE",
|
||||||
|
"inputs": [
|
||||||
|
"stateCenterUrl",
|
||||||
|
"agentToken",
|
||||||
|
"jobId",
|
||||||
|
"supplierName",
|
||||||
|
"orderDate",
|
||||||
|
"sourceType",
|
||||||
|
"templateType",
|
||||||
|
"templateName",
|
||||||
|
"customerCode",
|
||||||
|
"customerName",
|
||||||
|
"contractCategory",
|
||||||
|
"settlementType",
|
||||||
|
"contractBackground",
|
||||||
|
"amount",
|
||||||
|
"contractEndDate",
|
||||||
|
"paymentMethod",
|
||||||
|
"paymentDays",
|
||||||
|
"submitMode",
|
||||||
|
"goodsLines",
|
||||||
|
"mergedPdfPath"
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
"contractSystemNo",
|
||||||
|
"nextNode"
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"id": "S01_CALLBACK_START",
|
||||||
|
"name": "回调状态中心-start",
|
||||||
|
"script": "StateCenterClient.start_node(jobId, 'CONTRACT_CREATE')"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S02_OPEN_CONTRACT_SYSTEM",
|
||||||
|
"name": "打开合同系统",
|
||||||
|
"script": "open_contract_system(browser, ctx)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S03_LOGIN",
|
||||||
|
"name": "登录合同系统",
|
||||||
|
"script": "login_contract_system(browser, ctx)",
|
||||||
|
"selectors": {
|
||||||
|
"username": "input[name='j_username']",
|
||||||
|
"password": "input[name='j_password']",
|
||||||
|
"submit": "input[name='btn_submit']"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S04_ENTER_CONTRACT_DRAFT",
|
||||||
|
"name": "进入合同起草",
|
||||||
|
"script": "enter_contract_create(browser)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S05_SELECT_RECENT_TEMPLATE",
|
||||||
|
"name": "选择最近使用模板",
|
||||||
|
"script": "select_contract_template(browser, ctx)",
|
||||||
|
"rule": "优先点击最近使用模板中的 templateName;非格式默认非格式--采购协议"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S06_FILL_SIGNATORY_INFO",
|
||||||
|
"name": "填写签约方信息",
|
||||||
|
"script": "fill_contract_signatory_info(browser, ctx)",
|
||||||
|
"fields": [
|
||||||
|
"客户编码",
|
||||||
|
"客户名称"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S07_FILL_CONTRACT_BASIC_FORM",
|
||||||
|
"name": "填写合同基础表单",
|
||||||
|
"script": "fill_contract_basic_info(browser, ctx)",
|
||||||
|
"fields": [
|
||||||
|
"合同版本类型",
|
||||||
|
"合同类别",
|
||||||
|
"结算类型",
|
||||||
|
"合同背景概述",
|
||||||
|
"合同性质"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S08_FILL_AMOUNT_AND_DATES",
|
||||||
|
"name": "填写金额与日期",
|
||||||
|
"script": "fill_contract_amount_and_dates(browser, ctx)",
|
||||||
|
"hardRules": [
|
||||||
|
"合同签订日期=orderDate",
|
||||||
|
"合同开始日期=orderDate"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S09_FILL_GOODS_LINES",
|
||||||
|
"name": "填写货品明细",
|
||||||
|
"script": "fill_contract_goods_lines(browser, ctx)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S10_FILL_PAYMENT_AND_FLAGS",
|
||||||
|
"name": "填写付款信息与特殊标志",
|
||||||
|
"script": "fill_contract_payment_info(browser, ctx)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S11_MERGE_PDF_IF_YYC",
|
||||||
|
"name": "医药城合并同供应商同日PDF",
|
||||||
|
"script": "ensure_merged_pdf(platformPdfPaths, mergedPdfOutput)",
|
||||||
|
"branch": "sourceType=YYC 且 platformPdfPaths 多于一份时执行;单份直接沿用;sourceType=XYY 跳过"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S12_UPLOAD_PDF_IF_YYC",
|
||||||
|
"name": "医药城上传合并合同PDF",
|
||||||
|
"script": "upload_contract_document_if_needed(browser, ctx)",
|
||||||
|
"branch": "sourceType=YYC 才执行;sourceType=XYY 跳过"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S13_SUBMIT",
|
||||||
|
"name": "提交",
|
||||||
|
"script": "submit_contract_for_approval(browser)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S14_READ_CONTRACT_NO",
|
||||||
|
"name": "读取合同编号",
|
||||||
|
"script": "read_contract_number(browser)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S15_CALLBACK_COMPLETE",
|
||||||
|
"name": "回调状态中心-complete",
|
||||||
|
"script": "StateCenterClient.complete_node(jobId, 'CONTRACT_CREATE', businessRefs.contractSystemNo)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "E_LOGIN_FAILED",
|
||||||
|
"name": "异常-登录失败",
|
||||||
|
"script": "StateCenterClient.fail_node(..., errorCode='SYS_LOGIN_FAIL', retryable=true, routeRole='OPS')"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "E_CONTRACT_DISABLED",
|
||||||
|
"name": "异常-供应商合同禁用",
|
||||||
|
"script": "StateCenterClient.fail_node(..., errorCode='BIZ_CONTRACT_DISABLED', retryable=false, routeRole='PURCHASER')"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "E_PDF_MERGE_UNSUPPORTED",
|
||||||
|
"name": "异常-PDF合并能力不支持",
|
||||||
|
"script": "StateCenterClient.fail_node(..., errorCode='CAP_PDF_MERGE_UNSUPPORTED', retryable=false, routeRole='OPS')"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "E_PAGE_CHANGED",
|
||||||
|
"name": "异常-页面改版",
|
||||||
|
"script": "StateCenterClient.fail_node(..., errorCode='UI_ELEMENT_NOT_FOUND', retryable=false, routeRole='OPS')"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"flowName": "CONTRACT_CREATE_预览",
|
||||||
|
"platform": "YiSaiQi iS-RPA",
|
||||||
|
"nodeCode": "CONTRACT_CREATE",
|
||||||
|
"inputs": [
|
||||||
|
"stateCenterUrl",
|
||||||
|
"agentToken",
|
||||||
|
"jobId",
|
||||||
|
"supplierName",
|
||||||
|
"orderDate",
|
||||||
|
"sourceType",
|
||||||
|
"templateType",
|
||||||
|
"templateName",
|
||||||
|
"customerCode",
|
||||||
|
"customerName",
|
||||||
|
"contractCategory",
|
||||||
|
"settlementType",
|
||||||
|
"contractBackground",
|
||||||
|
"amount",
|
||||||
|
"contractEndDate",
|
||||||
|
"paymentMethod",
|
||||||
|
"paymentDays",
|
||||||
|
"submitMode",
|
||||||
|
"goodsLines",
|
||||||
|
"mergedPdfPath"
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
"previewDone"
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
{ "id": "P01", "name": "打开合同系统", "script": "open_contract_system(browser, ctx)" },
|
||||||
|
{ "id": "P02", "name": "登录合同系统", "script": "login_contract_system(browser, ctx)" },
|
||||||
|
{ "id": "P03", "name": "进入合同起草", "script": "enter_contract_create(browser)" },
|
||||||
|
{ "id": "P04", "name": "选择模板", "script": "select_contract_template(browser, ctx)" },
|
||||||
|
{ "id": "P05", "name": "填写签约方", "script": "fill_contract_signatory_info(browser, ctx)" },
|
||||||
|
{ "id": "P06", "name": "填写基础表单", "script": "fill_contract_basic_info(browser, ctx)" },
|
||||||
|
{ "id": "P07", "name": "填写金额日期", "script": "fill_contract_amount_and_dates(browser, ctx)" },
|
||||||
|
{ "id": "P08", "name": "填写明细", "script": "fill_contract_goods_lines(browser, ctx)" },
|
||||||
|
{ "id": "P09", "name": "填写付款与标志", "script": "fill_contract_payment_info(browser, ctx)" },
|
||||||
|
{ "id": "P10", "name": "预览结束", "script": "submit_contract_for_approval(browser, ctx) with submitMode=dry_run" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
"""PDF utilities for YiSaiQi contract drafting."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_merged_pdf(source_paths: list[str], output_path: str) -> str:
|
||||||
|
"""Return one PDF path, merging multiple source PDFs when needed.
|
||||||
|
|
||||||
|
YiSaiQi must upload only one YYC contract document for the same supplier
|
||||||
|
and same order date. A single source PDF can be uploaded directly.
|
||||||
|
"""
|
||||||
|
if not source_paths:
|
||||||
|
raise ValueError("YYC contract creation requires at least one platform PDF")
|
||||||
|
if len(source_paths) == 1:
|
||||||
|
return source_paths[0]
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pypdf import PdfWriter
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError("Install pypdf to merge multiple YYC platform PDFs") from exc
|
||||||
|
|
||||||
|
writer = PdfWriter()
|
||||||
|
for source in source_paths:
|
||||||
|
writer.append(str(Path(source)))
|
||||||
|
|
||||||
|
out = Path(output_path)
|
||||||
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with out.open("wb") as file:
|
||||||
|
writer.write(file)
|
||||||
|
return str(out)
|
||||||
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
pypdf==5.1.0
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
{
|
||||||
|
"knownLoginSelectors": {
|
||||||
|
"username": "input[name='j_username']",
|
||||||
|
"password": "input[name='j_password']",
|
||||||
|
"submit": "input[name='btn_submit']"
|
||||||
|
},
|
||||||
|
"candidateSelectors": {
|
||||||
|
"contractManagementMenu": [
|
||||||
|
"text=合同管理",
|
||||||
|
"aria=合同管理"
|
||||||
|
],
|
||||||
|
"contractDraftMenu": [
|
||||||
|
"text=合同起草",
|
||||||
|
"aria=合同起草"
|
||||||
|
],
|
||||||
|
"recentNonFormatPurchaseTemplate": [
|
||||||
|
"text=非格式--采购协议",
|
||||||
|
"text=非格式--采购协议...",
|
||||||
|
"title=非格式--采购协议"
|
||||||
|
],
|
||||||
|
"customerCode": [
|
||||||
|
"label=客户编码",
|
||||||
|
"input[name='fdCustomerCode']",
|
||||||
|
"input[placeholder*='客户编码']"
|
||||||
|
],
|
||||||
|
"customerName": [
|
||||||
|
"label=客户名称",
|
||||||
|
"input[name='fdCustomerName']",
|
||||||
|
"input[placeholder*='客户名称']"
|
||||||
|
],
|
||||||
|
"contractVersionType": [
|
||||||
|
"label=合同版本类型",
|
||||||
|
"select[name='fdTemplateType']"
|
||||||
|
],
|
||||||
|
"contractCategory": [
|
||||||
|
"label=合同类别",
|
||||||
|
"select[name='fdContractCategory']"
|
||||||
|
],
|
||||||
|
"settlementType": [
|
||||||
|
"label=结算类型",
|
||||||
|
"select[name='fdSettlementType']"
|
||||||
|
],
|
||||||
|
"contractBackground": [
|
||||||
|
"label=合同背景概述",
|
||||||
|
"textarea[name='fdContractBackground']"
|
||||||
|
],
|
||||||
|
"contractNatureMain": [
|
||||||
|
"label=主合同",
|
||||||
|
"text=主合同"
|
||||||
|
],
|
||||||
|
"amountCurrency": [
|
||||||
|
"label=合同金额",
|
||||||
|
"select[name='fdCurrency']"
|
||||||
|
],
|
||||||
|
"amount": [
|
||||||
|
"label=金额(元)",
|
||||||
|
"input[name='fdAmount']"
|
||||||
|
],
|
||||||
|
"signDate": [
|
||||||
|
"label=合同签订日期",
|
||||||
|
"input[name='fdSignDate']",
|
||||||
|
"input[placeholder*='合同签订']"
|
||||||
|
],
|
||||||
|
"beginDate": [
|
||||||
|
"label=合同开始日期",
|
||||||
|
"input[name='fdStartDate']",
|
||||||
|
"input[placeholder*='合同开始']"
|
||||||
|
],
|
||||||
|
"endDate": [
|
||||||
|
"label=合同结束日期",
|
||||||
|
"input[name='fdEndDate']",
|
||||||
|
"input[placeholder*='合同结束']"
|
||||||
|
],
|
||||||
|
"isMajorContract": [
|
||||||
|
"label=是否重大合同",
|
||||||
|
"select[name='fdIsMajorContract']"
|
||||||
|
],
|
||||||
|
"isPrepayment": [
|
||||||
|
"label=是否预付款",
|
||||||
|
"select[name='fdIsPrepayment']"
|
||||||
|
],
|
||||||
|
"belongYear": [
|
||||||
|
"label=合同归属年份",
|
||||||
|
"input[name='fdBelongYear']"
|
||||||
|
],
|
||||||
|
"paymentMethod": [
|
||||||
|
"label=付款方式",
|
||||||
|
"select[name='fdPaymentMethod']",
|
||||||
|
"select[name='paymentMethod']"
|
||||||
|
],
|
||||||
|
"paymentDays": [
|
||||||
|
"label=付款期限",
|
||||||
|
"input[name='fdPaymentDays']",
|
||||||
|
"input[name='paymentDays']"
|
||||||
|
],
|
||||||
|
"contractStatus": [
|
||||||
|
"label=合同状态",
|
||||||
|
"select[name='fdContractStatus']"
|
||||||
|
],
|
||||||
|
"containsBloodProducts": [
|
||||||
|
"label=是否包含血液制品",
|
||||||
|
"select[name='fdContainsBloodProducts']"
|
||||||
|
],
|
||||||
|
"submit": [
|
||||||
|
"text=提交",
|
||||||
|
"text=提交送审",
|
||||||
|
"text=送审"
|
||||||
|
],
|
||||||
|
"contractNumber": [
|
||||||
|
"input[name='contractNo']",
|
||||||
|
"input[name='contractSystemNo']",
|
||||||
|
"label=合同编号",
|
||||||
|
"text=合同编号"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
"""YiSaiQi adapter template for CONTRACT_CREATE flowchart nodes.
|
||||||
|
|
||||||
|
This file is a mapping template. In the YiSaiQi designer, replace each method
|
||||||
|
body with the platform's native browser activities: open webpage, input text,
|
||||||
|
click element, select dropdown, upload file, read text, and screenshot.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class YiSaiQiBrowserAdapter:
|
||||||
|
def open_url(self, url: str) -> None:
|
||||||
|
"""Flowchart node: 浏览器打开网页."""
|
||||||
|
raise NotImplementedError("Map to YiSaiQi browser open-url activity")
|
||||||
|
|
||||||
|
def fill_first(self, selectors: list[str], value: str) -> None:
|
||||||
|
"""Flowchart node: 按候选定位器依次查找,找到后输入文本."""
|
||||||
|
raise NotImplementedError(f"Map to YiSaiQi input-text activity: {selectors=} {value=}")
|
||||||
|
|
||||||
|
def click_first(self, selectors: list[str]) -> None:
|
||||||
|
"""Flowchart node: 按候选定位器依次查找,找到后点击."""
|
||||||
|
raise NotImplementedError(f"Map to YiSaiQi click activity: {selectors=}")
|
||||||
|
|
||||||
|
def wait_for_text(self, text: str, timeout_seconds: int = 15) -> None:
|
||||||
|
"""Flowchart node: 等待页面出现指定文本."""
|
||||||
|
raise NotImplementedError(f"Map to YiSaiQi wait-text activity: {text=} {timeout_seconds=}")
|
||||||
|
|
||||||
|
def select_first(self, selectors: list[str], value: str) -> None:
|
||||||
|
"""Flowchart node: 下拉框选择."""
|
||||||
|
raise NotImplementedError(f"Map to YiSaiQi select activity: {selectors=} {value=}")
|
||||||
|
|
||||||
|
def upload_file_first(self, selectors: list[str], file_path: str) -> None:
|
||||||
|
"""Flowchart node: 文件上传."""
|
||||||
|
raise NotImplementedError(f"Map to YiSaiQi upload-file activity: {selectors=} {file_path=}")
|
||||||
|
|
||||||
|
def read_first(self, selectors: list[str], value_name: str) -> str:
|
||||||
|
"""Flowchart node: 读取页面文本/输入框值并写入流程变量."""
|
||||||
|
raise NotImplementedError(f"Map to YiSaiQi read-text activity: {selectors=} {value_name=}")
|
||||||
|
|
||||||
|
def screenshot(self, name: str) -> str:
|
||||||
|
"""Flowchart node: 截图存证,返回文件路径."""
|
||||||
|
raise NotImplementedError(f"Map to YiSaiQi screenshot activity: {name=}")
|
||||||
|
|
||||||
|
|
@ -0,0 +1,252 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
全链路Mock验证脚本
|
||||||
|
不依赖真实医药城/合同系统,用mock数据跑通:建单→ORDER_FETCH→对码→CONTRACT_CREATE
|
||||||
|
验证状态机推进、合同编号回填、前端能观测到next_node
|
||||||
|
|
||||||
|
用法:
|
||||||
|
export STATE_CENTER_URL="http://127.0.0.1:8000"
|
||||||
|
export AGENT_TOKEN="dev-agent-token"
|
||||||
|
python run_mock_chain.py
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
|
||||||
|
def encode_job_id(job_id: str) -> str:
|
||||||
|
"""job_id含中文,URL路径段需编码"""
|
||||||
|
return quote(job_id, safe="")
|
||||||
|
|
||||||
|
STATE_CENTER_URL = "http://127.0.0.1:8000"
|
||||||
|
AGENT_TOKEN = "dev-agent-token"
|
||||||
|
|
||||||
|
|
||||||
|
def http_post(path: str, payload: dict, token: str | None = None):
|
||||||
|
"""POST请求辅助函数"""
|
||||||
|
url = f"{STATE_CENTER_URL}{path}"
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if token:
|
||||||
|
headers["X-Agent-Token"] = token
|
||||||
|
|
||||||
|
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||||
|
request = Request(url, data=data, headers=headers, method="POST")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urlopen(request, timeout=15) as response:
|
||||||
|
raw = response.read().decode("utf-8")
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except HTTPError as exc:
|
||||||
|
detail = exc.read().decode("utf-8", errors="replace")
|
||||||
|
print(f"❌ HTTP {exc.code}: {detail}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def http_get(path: str):
|
||||||
|
"""GET请求辅助函数"""
|
||||||
|
url = f"{STATE_CENTER_URL}{path}"
|
||||||
|
request = Request(url, method="GET")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urlopen(request, timeout=15) as response:
|
||||||
|
raw = response.read().decode("utf-8")
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except HTTPError as exc:
|
||||||
|
detail = exc.read().decode("utf-8", errors="replace")
|
||||||
|
print(f"❌ HTTP {exc.code}: {detail}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def step(num: int, title: str):
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"【第{num}步】{title}")
|
||||||
|
print('='*60)
|
||||||
|
|
||||||
|
|
||||||
|
def run_chain():
|
||||||
|
order_date = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||||
|
# supplier加唯一后缀,保证每次全新job(避免幂等复用旧状态)
|
||||||
|
supplier = f"重庆亿昊药业-{uuid.uuid4().hex[:4]}"
|
||||||
|
source_type = "YYC" # 医药城线上单
|
||||||
|
|
||||||
|
# ---- 第1步:建单 ----
|
||||||
|
step(1, "建单 POST /api/jobs")
|
||||||
|
resp = http_post("/api/jobs", {
|
||||||
|
"source_type": source_type,
|
||||||
|
"order_date": order_date,
|
||||||
|
"supplier_name": supplier,
|
||||||
|
"created_by": "mock-chain",
|
||||||
|
})
|
||||||
|
job_id = resp["job_id"]
|
||||||
|
print(f"✓ job_id: {job_id}")
|
||||||
|
print(f"✓ first_node: {resp.get('first_node')}")
|
||||||
|
print(f"✓ reused: {resp.get('reused')}")
|
||||||
|
|
||||||
|
# ---- 第2步:加载fixture货品(不依赖Oracle)----
|
||||||
|
step(2, "加载测试货品 POST /api/dev/match/load-fixture-goods")
|
||||||
|
resp = http_post("/api/dev/match/load-fixture-goods", {}, token=AGENT_TOKEN)
|
||||||
|
print(f"✓ 已加载货品数: {resp.get('loaded')}")
|
||||||
|
|
||||||
|
# ---- 第3步:ORDER_FETCH 开始回调 ----
|
||||||
|
step(3, "ORDER_FETCH start 回调")
|
||||||
|
resp = http_post(
|
||||||
|
f"/api/jobs/{encode_job_id(job_id)}/nodes/ORDER_FETCH/start",
|
||||||
|
{"attemptNo": 1, "executorPlatform": "mock-rpa", "operator": "mock-chain"},
|
||||||
|
token=AGENT_TOKEN,
|
||||||
|
)
|
||||||
|
print(f"✓ start响应: {resp}")
|
||||||
|
|
||||||
|
# ---- 第4步:写订单明细(模拟ORDER_FETCH导出的数据)----
|
||||||
|
step(4, "写订单明细 POST /api/jobs/{job_id}/items")
|
||||||
|
order_no = f"DD-{order_date.replace('-', '')}-001"
|
||||||
|
resp = http_post(
|
||||||
|
f"/api/jobs/{encode_job_id(job_id)}/items",
|
||||||
|
{
|
||||||
|
"replace": True,
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"orderNo": order_no,
|
||||||
|
"rawProductName": "阿卡波糖片",
|
||||||
|
"platformProductCode": "YP-001",
|
||||||
|
"spec": "50mg*30片",
|
||||||
|
"manufacturer": "示例制药",
|
||||||
|
"approvalNo": "国药准字H00000001",
|
||||||
|
"qty": 100,
|
||||||
|
"unitPrice": 25.5,
|
||||||
|
"sourceRowNo": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"orderNo": order_no,
|
||||||
|
"rawProductName": "阿莫西林胶囊",
|
||||||
|
"platformProductCode": "YP-002",
|
||||||
|
"spec": "0.25g*24粒",
|
||||||
|
"manufacturer": "示例医药",
|
||||||
|
"approvalNo": "国药准字H00000002",
|
||||||
|
"qty": 50,
|
||||||
|
"unitPrice": 18.0,
|
||||||
|
"sourceRowNo": 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
token=AGENT_TOKEN,
|
||||||
|
)
|
||||||
|
print(f"✓ 写入明细: {resp.get('inserted')} 行")
|
||||||
|
print(f"✓ 订单号: {resp.get('order_nos')}")
|
||||||
|
|
||||||
|
# ---- 第5步:ORDER_FETCH 完成回调 ----
|
||||||
|
step(5, "ORDER_FETCH complete 回调")
|
||||||
|
resp = http_post(
|
||||||
|
f"/api/jobs/{encode_job_id(job_id)}/nodes/ORDER_FETCH/complete",
|
||||||
|
{
|
||||||
|
"attemptNo": 1,
|
||||||
|
"executorRunId": f"MOCK-{uuid.uuid4().hex[:8]}",
|
||||||
|
"summary": {
|
||||||
|
"orderCount": 1,
|
||||||
|
"supplierList": [supplier],
|
||||||
|
"orderNoList": [order_no],
|
||||||
|
},
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"artifactType": "SOURCE_EXCEL",
|
||||||
|
"storagePath": f"/tmp/mock_rpa_data/orders_{supplier}_{order_date}.xlsx",
|
||||||
|
"fileHash": "sha256:mock-order-excel",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
token=AGENT_TOKEN,
|
||||||
|
)
|
||||||
|
print(f"✓ next_node: {resp.get('next_node')}")
|
||||||
|
|
||||||
|
# ---- 第6步:跑对码 ----
|
||||||
|
step(6, "对码 POST /api/jobs/{job_id}/match/run")
|
||||||
|
resp = http_post(f"/api/jobs/{encode_job_id(job_id)}/match/run", {}, token=AGENT_TOKEN)
|
||||||
|
print(f"✓ 对码结果: {json.dumps(resp, ensure_ascii=False)}")
|
||||||
|
|
||||||
|
# ---- 第7步:查当前状态,判断是否需要人工对码 ----
|
||||||
|
step(7, "查单据状态 GET /api/jobs/{job_id}")
|
||||||
|
detail = http_get(f"/api/jobs/{encode_job_id(job_id)}")
|
||||||
|
job = detail.get("job", {})
|
||||||
|
print(f"✓ 当前节点: {job.get('current_node_code')}")
|
||||||
|
print(f"✓ 当前状态: {job.get('status')}")
|
||||||
|
|
||||||
|
tasks = detail.get("tasks", [])
|
||||||
|
if tasks:
|
||||||
|
print(f"⚠ 有 {len(tasks)} 条待办需要人工处理")
|
||||||
|
for t in tasks:
|
||||||
|
print(f" - {t.get('title')} [{t.get('reason_type')}]")
|
||||||
|
|
||||||
|
# ---- 第7.5步:如卡在SOURCE_ADD,模拟人工完成新增货源推进 ----
|
||||||
|
if job.get("current_node_code") == "SOURCE_ADD":
|
||||||
|
step("7.5", "SOURCE_ADD complete(模拟人工新增货源完成)")
|
||||||
|
resp = http_post(
|
||||||
|
f"/api/jobs/{encode_job_id(job_id)}/nodes/SOURCE_ADD/complete",
|
||||||
|
{
|
||||||
|
"attemptNo": 1,
|
||||||
|
"executorRunId": f"MOCK-SRC-{uuid.uuid4().hex[:8]}",
|
||||||
|
"operator": "mock-chain",
|
||||||
|
"summary": {"sourceAdded": True},
|
||||||
|
"artifacts": [],
|
||||||
|
},
|
||||||
|
token=AGENT_TOKEN,
|
||||||
|
)
|
||||||
|
print(f"✓ next_node: {resp.get('next_node')}")
|
||||||
|
|
||||||
|
# ---- 第8步:CONTRACT_CREATE mock(直接传假合同编号)----
|
||||||
|
step(8, "CONTRACT_CREATE mock(传假合同编号,跳过真实页面)")
|
||||||
|
mock_contract_no = f"HT{order_date.replace('-', '')}{uuid.uuid4().hex[:5].upper()}"
|
||||||
|
print(f" 使用mock合同编号: {mock_contract_no}")
|
||||||
|
|
||||||
|
# 先start
|
||||||
|
http_post(
|
||||||
|
f"/api/jobs/{encode_job_id(job_id)}/nodes/CONTRACT_CREATE/start",
|
||||||
|
{"attemptNo": 1, "executorPlatform": "mock-rpa", "operator": "mock-chain"},
|
||||||
|
token=AGENT_TOKEN,
|
||||||
|
)
|
||||||
|
# 再complete,带businessRefs.contractSystemNo
|
||||||
|
resp = http_post(
|
||||||
|
f"/api/jobs/{encode_job_id(job_id)}/nodes/CONTRACT_CREATE/complete",
|
||||||
|
{
|
||||||
|
"attemptNo": 1,
|
||||||
|
"executorRunId": f"MOCK-CONTRACT-{uuid.uuid4().hex[:8]}",
|
||||||
|
"businessRefs": {"contractSystemNo": mock_contract_no},
|
||||||
|
"summary": {
|
||||||
|
"supplierName": supplier,
|
||||||
|
"contractDate": order_date,
|
||||||
|
"sourceType": source_type,
|
||||||
|
"uploadRule": "merge_pdf_before_upload",
|
||||||
|
},
|
||||||
|
"artifacts": [],
|
||||||
|
},
|
||||||
|
token=AGENT_TOKEN,
|
||||||
|
)
|
||||||
|
print(f"✓ next_node: {resp.get('next_node')}")
|
||||||
|
|
||||||
|
# ---- 第9步:验证合同编号回填 ----
|
||||||
|
step(9, "验证合同编号回填 GET /api/jobs/{job_id}")
|
||||||
|
detail = http_get(f"/api/jobs/{encode_job_id(job_id)}")
|
||||||
|
job = detail.get("job", {})
|
||||||
|
print(f"✓ 当前节点: {job.get('current_node_code')}")
|
||||||
|
print(f"✓ 当前状态: {job.get('status')}")
|
||||||
|
contract_no = job.get("contract_system_no")
|
||||||
|
if contract_no == mock_contract_no:
|
||||||
|
print(f"✅ 合同编号回填成功: {contract_no}")
|
||||||
|
else:
|
||||||
|
print(f"⚠ 合同编号: {contract_no}(期望 {mock_contract_no})")
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"🎉 Mock链跑完!job_id = {job_id}")
|
||||||
|
print(f" 前端观测: http://localhost:5173")
|
||||||
|
print('='*60)
|
||||||
|
return job_id
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
run_chain()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ 链路中断: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Project development utilities."""
|
||||||
|
|
@ -0,0 +1,292 @@
|
||||||
|
"""Start and inspect the local state-center integration stack.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/dev.py start
|
||||||
|
python scripts/dev.py status
|
||||||
|
python scripts/dev.py stop
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.error import URLError
|
||||||
|
from urllib.request import urlopen
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
RUNTIME_DIR = ROOT / ".runtime"
|
||||||
|
PID_FILE = RUNTIME_DIR / "dev-pids.json"
|
||||||
|
MYSQL_CONTAINER = "rpa-center-mysql"
|
||||||
|
MYSQL_VOLUME = "rpa-center-mysql-data"
|
||||||
|
MYSQL_IMAGE = "mysql:8"
|
||||||
|
|
||||||
|
|
||||||
|
def build_backend_env(source: dict[str, str] | None = None) -> dict[str, str]:
|
||||||
|
"""Return backend environment with safe local defaults."""
|
||||||
|
env = dict(os.environ if source is None else source)
|
||||||
|
env.setdefault("DEV_MODE", "true")
|
||||||
|
env.setdefault(
|
||||||
|
"MYSQL_DSN",
|
||||||
|
"mysql+pymysql://root:root@127.0.0.1:3306/rpa_center?charset=utf8mb4",
|
||||||
|
)
|
||||||
|
env.setdefault("AGENT_TOKEN", "dev-agent-token")
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def _run(command: list[str], *, check: bool = True, capture: bool = False) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(
|
||||||
|
command,
|
||||||
|
cwd=ROOT,
|
||||||
|
check=check,
|
||||||
|
text=True,
|
||||||
|
capture_output=capture,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _command_exists(command: str) -> bool:
|
||||||
|
return subprocess.run(
|
||||||
|
[command, "--version"],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
check=False,
|
||||||
|
).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def _backend_python() -> str:
|
||||||
|
configured = os.getenv("STATE_CENTER_PYTHON")
|
||||||
|
if configured:
|
||||||
|
return configured
|
||||||
|
local_python = ROOT / ".venv_test" / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
|
||||||
|
if local_python.exists():
|
||||||
|
return str(local_python)
|
||||||
|
return sys.executable
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_backend_runtime() -> str:
|
||||||
|
python = _backend_python()
|
||||||
|
probe = subprocess.run(
|
||||||
|
[python, "-c", "import fastapi, pymysql, uvicorn, cryptography"],
|
||||||
|
cwd=ROOT,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if probe.returncode != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Backend dependencies are missing for {python}. "
|
||||||
|
f"Install them with: {python} -m pip install -r state_center/requirements.txt"
|
||||||
|
)
|
||||||
|
return python
|
||||||
|
|
||||||
|
|
||||||
|
def _docker_container_state() -> str | None:
|
||||||
|
if not _command_exists("docker"):
|
||||||
|
return None
|
||||||
|
result = _run(
|
||||||
|
["docker", "inspect", "-f", "{{.State.Status}}", MYSQL_CONTAINER],
|
||||||
|
check=False,
|
||||||
|
capture=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_mysql() -> None:
|
||||||
|
if not _command_exists("docker"):
|
||||||
|
raise RuntimeError("Docker is required to start the local MySQL container.")
|
||||||
|
|
||||||
|
state = _docker_container_state()
|
||||||
|
if state == "running":
|
||||||
|
return
|
||||||
|
if state:
|
||||||
|
_run(["docker", "start", MYSQL_CONTAINER])
|
||||||
|
return
|
||||||
|
|
||||||
|
_run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"run",
|
||||||
|
"--detach",
|
||||||
|
"--name",
|
||||||
|
MYSQL_CONTAINER,
|
||||||
|
"--env",
|
||||||
|
"MYSQL_ROOT_PASSWORD=root",
|
||||||
|
"--env",
|
||||||
|
"MYSQL_DATABASE=rpa_center",
|
||||||
|
"--publish",
|
||||||
|
"3306:3306",
|
||||||
|
"--volume",
|
||||||
|
f"{MYSQL_VOLUME}:/var/lib/mysql",
|
||||||
|
"--volume",
|
||||||
|
f"{ROOT / 'state_center' / 'sql' / 'schema.sql'}:/docker-entrypoint-initdb.d/001-schema.sql:ro",
|
||||||
|
MYSQL_IMAGE,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _port_open(host: str, port: int) -> bool:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||||
|
sock.settimeout(0.5)
|
||||||
|
return sock.connect_ex((host, port)) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_port(host: str, port: int, timeout: float = 30) -> None:
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if _port_open(host, port):
|
||||||
|
return
|
||||||
|
time.sleep(0.5)
|
||||||
|
raise RuntimeError(f"Timed out waiting for {host}:{port}.")
|
||||||
|
|
||||||
|
|
||||||
|
def _spawn(name: str, command: list[str], cwd: Path, env: dict[str, str]) -> int:
|
||||||
|
RUNTIME_DIR.mkdir(exist_ok=True)
|
||||||
|
log_path = RUNTIME_DIR / f"{name}.log"
|
||||||
|
log_file = log_path.open("a", encoding="utf-8")
|
||||||
|
kwargs: dict[str, object] = {
|
||||||
|
"cwd": cwd,
|
||||||
|
"env": env,
|
||||||
|
"stdout": log_file,
|
||||||
|
"stderr": subprocess.STDOUT,
|
||||||
|
"stdin": subprocess.DEVNULL,
|
||||||
|
}
|
||||||
|
if os.name == "nt":
|
||||||
|
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
||||||
|
else:
|
||||||
|
kwargs["start_new_session"] = True
|
||||||
|
process = subprocess.Popen(command, **kwargs)
|
||||||
|
log_file.close()
|
||||||
|
return process.pid
|
||||||
|
|
||||||
|
|
||||||
|
def _read_pids() -> dict[str, int]:
|
||||||
|
if not PID_FILE.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return {key: int(value) for key, value in json.loads(PID_FILE.read_text()).items()}
|
||||||
|
except (OSError, ValueError, TypeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_pids(pids: dict[str, int]) -> None:
|
||||||
|
RUNTIME_DIR.mkdir(exist_ok=True)
|
||||||
|
PID_FILE.write_text(json.dumps(pids, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _pid_alive(pid: int) -> bool:
|
||||||
|
if pid <= 0:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _service_alive(pid: int, host: str, port: int) -> bool:
|
||||||
|
return _pid_alive(pid) or _port_open(host, port)
|
||||||
|
|
||||||
|
|
||||||
|
def start() -> None:
|
||||||
|
ensure_mysql()
|
||||||
|
wait_for_port("127.0.0.1", 3306)
|
||||||
|
backend_python = _ensure_backend_runtime()
|
||||||
|
|
||||||
|
pids = _read_pids()
|
||||||
|
backend_env = build_backend_env()
|
||||||
|
if not _service_alive(pids.get("backend", -1), "127.0.0.1", 8000):
|
||||||
|
pids["backend"] = _spawn(
|
||||||
|
"backend",
|
||||||
|
[
|
||||||
|
backend_python,
|
||||||
|
"-m",
|
||||||
|
"uvicorn",
|
||||||
|
"state_center.app.main:app",
|
||||||
|
"--host",
|
||||||
|
"0.0.0.0",
|
||||||
|
"--port",
|
||||||
|
"8000",
|
||||||
|
],
|
||||||
|
ROOT,
|
||||||
|
backend_env,
|
||||||
|
)
|
||||||
|
if not _service_alive(pids.get("frontend", -1), "127.0.0.1", 5173):
|
||||||
|
pids["frontend"] = _spawn(
|
||||||
|
"frontend",
|
||||||
|
["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"],
|
||||||
|
ROOT / "state_center_web",
|
||||||
|
os.environ.copy(),
|
||||||
|
)
|
||||||
|
_write_pids(pids)
|
||||||
|
wait_for_port("127.0.0.1", 8000)
|
||||||
|
wait_for_port("127.0.0.1", 5173)
|
||||||
|
print("MySQL: 127.0.0.1:3306")
|
||||||
|
print("Backend: http://127.0.0.1:8000")
|
||||||
|
print("Frontend: http://127.0.0.1:5173")
|
||||||
|
|
||||||
|
|
||||||
|
def status() -> int:
|
||||||
|
db = _port_open("127.0.0.1", 3306)
|
||||||
|
backend = False
|
||||||
|
backend_detail = ""
|
||||||
|
try:
|
||||||
|
with urlopen("http://127.0.0.1:8000/health", timeout=2) as response:
|
||||||
|
payload = json.loads(response.read().decode("utf-8"))
|
||||||
|
backend = response.status == 200 and payload.get("database") == "ok"
|
||||||
|
backend_detail = json.dumps(payload, ensure_ascii=False)
|
||||||
|
except (OSError, URLError, json.JSONDecodeError) as exc:
|
||||||
|
backend_detail = str(exc)
|
||||||
|
frontend = _port_open("127.0.0.1", 5173)
|
||||||
|
print(f"MySQL 3306: {'UP' if db else 'DOWN'}")
|
||||||
|
print(f"Backend 8000: {'UP' if backend else 'DOWN'} {backend_detail}".rstrip())
|
||||||
|
print(f"Frontend 5173: {'UP' if frontend else 'DOWN'}")
|
||||||
|
return 0 if db and backend and frontend else 1
|
||||||
|
|
||||||
|
|
||||||
|
def _stop_pid(pid: int) -> None:
|
||||||
|
try:
|
||||||
|
if os.name == "nt":
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
else:
|
||||||
|
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def stop() -> None:
|
||||||
|
for pid in _read_pids().values():
|
||||||
|
_stop_pid(pid)
|
||||||
|
if _docker_container_state() == "running":
|
||||||
|
_run(["docker", "stop", MYSQL_CONTAINER], check=False)
|
||||||
|
if PID_FILE.exists():
|
||||||
|
PID_FILE.unlink()
|
||||||
|
print("Local integration stack stopped.")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("command", choices=("start", "status", "stop"))
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
if args.command == "start":
|
||||||
|
start()
|
||||||
|
return 0
|
||||||
|
if args.command == "status":
|
||||||
|
return status()
|
||||||
|
stop()
|
||||||
|
return 0
|
||||||
|
except (OSError, RuntimeError, subprocess.CalledProcessError) as exc:
|
||||||
|
print(f"dev launcher error: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
# 状态中心(采购主链RPA一期 · 第1步骨架)
|
||||||
|
|
||||||
|
Python + FastAPI + MySQL。库里只存**流程状态 + 对码累积别名 + 证据**;对码货品主数据在 **Oracle**,直连查(不复制)。
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
```
|
||||||
|
state_center/
|
||||||
|
├── sql/schema.sql 建库脚本(job 那套 + mapping_config + 证据)
|
||||||
|
├── app/
|
||||||
|
│ ├── config.py 配置(MySQL/Oracle 连接、表名列名,从环境变量读)
|
||||||
|
│ ├── db.py MySQL 访问(pymysql)
|
||||||
|
│ ├── match_engine.py 对码引擎(两级命中 + 规格标准化 + Oracle 取数)
|
||||||
|
│ └── main.py FastAPI:建单 + 回调(含幂等/乱序拒绝)+ 合同审批等待 + 整单查询
|
||||||
|
├── tests/test_match_engine.py 对码单测(不依赖 DB/Oracle)
|
||||||
|
└── requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## 起步
|
||||||
|
|
||||||
|
以下命令在 macOS/Linux 的终端执行;Windows PowerShell 只需要把激活命令换成下一段对应写法,接口和浏览器端完全相同。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install -r state_center/requirements.txt
|
||||||
|
|
||||||
|
# 1) 建库(先起个 MySQL 8)
|
||||||
|
mysql -uroot -p < state_center/sql/schema.sql
|
||||||
|
|
||||||
|
# 2) 配连接(默认 root/root@127.0.0.1)
|
||||||
|
export MYSQL_DSN="mysql+pymysql://root:root@127.0.0.1:3306/rpa_center?charset=utf8mb4"
|
||||||
|
|
||||||
|
# 3) 起服务
|
||||||
|
uvicorn state_center.app.main:app --reload --port 8000
|
||||||
|
# 打开 http://127.0.0.1:8000/docs 有全部接口可点
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
py -m venv .venv
|
||||||
|
.\.venv\Scripts\Activate.ps1
|
||||||
|
python -m pip install -r state_center\requirements.txt
|
||||||
|
$env:DEV_MODE = "true"
|
||||||
|
$env:MYSQL_DSN = "mysql+pymysql://root:root@127.0.0.1:3306/rpa_center?charset=utf8mb4"
|
||||||
|
python -m uvicorn state_center.app.main:app --reload --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器端另开一个终端:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd state_center_web
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
然后打开 `http://localhost:5173/`。Windows 和 macOS 均使用标准浏览器访问,不需要 Electron 或系统专用脚本。`DEV_MODE=true` 只用于本地样例货品和节点回调模拟,生产环境不要开启。
|
||||||
|
|
||||||
|
本地样例联调顺序:建单 → 写入订单明细(`POST /api/jobs/{job_id}/items`) → 开发接口装载样例货品(`POST /api/dev/match/load-fixture-goods`) → 运行对码。真实环境把样例接口替换为 Oracle 货品装载即可。
|
||||||
|
|
||||||
|
## 验证第1步(不接真实系统,假回调走全链)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 建单
|
||||||
|
curl -X POST http://127.0.0.1:8000/api/jobs -H 'Content-Type: application/json' \
|
||||||
|
-d '{"source_type":"YYC","order_date":"2026-08-04","supplier_name":"重庆亿昊药业"}'
|
||||||
|
# 拿到 job_id 后,模拟艺赛旗回调(带 token):
|
||||||
|
curl -X POST http://127.0.0.1:8000/api/jobs/<JOB_ID>/nodes/ORDER_FETCH/start \
|
||||||
|
-H 'X-Agent-Token: dev-agent-token' -H 'Content-Type: application/json' -d '{"attemptNo":1}'
|
||||||
|
curl -X POST http://127.0.0.1:8000/api/jobs/<JOB_ID>/nodes/ORDER_FETCH/complete \
|
||||||
|
-H 'X-Agent-Token: dev-agent-token' -H 'Content-Type: application/json' \
|
||||||
|
-d '{"attemptNo":1,"summary":{"orderCount":12}}'
|
||||||
|
# 反复 start/complete 走完各节点,最后 GET 看整单轨迹:
|
||||||
|
curl http://127.0.0.1:8000/api/jobs/<JOB_ID>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 节点编排(13 = 11 主 + 2 条件)
|
||||||
|
|
||||||
|
主链 11 步(每单都走):
|
||||||
|
`ORDER_FETCH → PRODUCT_MATCH → CONTRACT_CREATE → CONTRACT_APPROVE_WAIT → REBATE_HEADER → REBATE_IMPORT → REBATE_SUBMIT → M5_CREATE_ORDER → M5_MANUAL_CONFIRM → M5_RECEIPT_NOTICE → ARCHIVE_JOB`
|
||||||
|
|
||||||
|
2 个条件节点(不满足条件自动跳过,不算漏节点):
|
||||||
|
|
||||||
|
| 条件节点 | 何时执行 | 何时跳过 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `OCR_EXTRACT` | 线下手工单(source_type=OFFLINE) | 线上单(医药城/小药药) |
|
||||||
|
| `SOURCE_ADD` | 对码判定缺货源(needs_source_add=1) | 货源齐全 |
|
||||||
|
|
||||||
|
转移不是"数组下一个",是 `main.py` 里的显式转移表 `TRANSITIONS` + 条件跳过。
|
||||||
|
每个节点标了执行方 `NODE_EXECUTOR`:STATE_CENTER(对码,内部算)/ RPA(艺赛旗点页面)/ HUMAN(一期人工)。
|
||||||
|
所以文档里"11 个节点"和代码里"13 个"是同一套:**11 主 + 2 条件 = 13**。
|
||||||
|
|
||||||
|
## 关键设计点(会审确认)
|
||||||
|
|
||||||
|
- **回调幂等**:同节点同 attempt 重复 complete 不会二次推进(靠 job_node 状态 + `callback_idempotency_key`)。
|
||||||
|
- **乱序拒绝**:回调节点必须是 job 当前节点,否则 409(避免网络重发把状态推乱)。
|
||||||
|
- **合同审批等待**(CONTRACT_APPROVE_WAIT)**不连续轮询**:进 `WAITING_EXTERNAL` 态,每
|
||||||
|
`CONTRACT_WAIT_INTERVAL_MIN`(默认 30 分)由调度器扫 `GET /api/scheduler/due-contract-checks`
|
||||||
|
触发 RPA 查一次,结果回 `POST .../CONTRACT_APPROVE_WAIT/check-result`;查满
|
||||||
|
`CONTRACT_WAIT_MAX_CHECKS`(默认 8 次)还没过就升级 OPS。
|
||||||
|
- **对码状态只有 4 种**:EXACT / ALIAS / MULTI / NONE(无 FUZZY;ALIAS=累积别名命中,非当次人工)。
|
||||||
|
- **PRODUCT_MATCH 是状态中心自己算的节点**(executor=STATE_CENTER),不走 RPA 三回调,靠
|
||||||
|
`POST /api/jobs/{id}/match/run` 推进:跑 `order_item` → 落 `product_match` → MULTI/NONE 建
|
||||||
|
待办转 WAITING_USER,全命中自动往下走;`POST /api/match/{id}/confirm` 人工确认后沉淀
|
||||||
|
`mapping_config` 别名并推进。**前置**:`order_item` 已落库 + `POST /api/match/reload-goods`
|
||||||
|
从 Oracle 装载过货品主数据(这两样是 Trae 任务2/3 的接真数据部分)。
|
||||||
|
- **SOURCE_ADD 触发源明确**:对码拿到 `goods_id` 后,状态中心按历史 `supplier_name + goods_id`
|
||||||
|
判断是否已有货源;没历史则写 `product_match.source_add_needed=1`、同步 `job.needs_source_add=1`
|
||||||
|
并创建一条聚合的 `SOURCE_ADD` 待办。
|
||||||
|
- **对码重跑幂等**:`product_match` 以 `(job_id, order_item_id)` 为当前态唯一键,
|
||||||
|
`match/run` 重跑时 upsert 结果并重建 OPEN 待办,不重复造结果/待办。
|
||||||
|
- **alias 当前态唯一**:`match/confirm` 会先失效同一 `config_key` 的旧 active 记录,再插入新 active;
|
||||||
|
装载 alias 时按 `updated_at/id` 固定顺序读取,历史脏数据也有确定结果。
|
||||||
|
|
||||||
|
## 对码接 Oracle(拿到连接串后)
|
||||||
|
|
||||||
|
1. `pip install oracledb`
|
||||||
|
2. 在 `config.py` / 环境变量填 `ORACLE_DSN/USER/PASSWORD`,把 `ORACLE_GOODS_TABLE` 和各列名改成真实的。
|
||||||
|
3. 开工时 `load_goods_from_oracle(settings)` 拉全量 → `MatchEngine.load_goods(...)`,之后内存匹配。
|
||||||
|
|
||||||
|
## 对码单测
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 state_center/tests/test_match_engine.py # 6/6 passed
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
"""配置:从环境变量读,别把密码写死在代码里。"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
class Settings:
|
||||||
|
# 本地联调功能开关。生产环境保持关闭。
|
||||||
|
DEV_MODE = os.getenv("DEV_MODE", "false").lower() == "true"
|
||||||
|
|
||||||
|
# MySQL(状态中心自己的库)
|
||||||
|
MYSQL_DSN = os.getenv(
|
||||||
|
"MYSQL_DSN",
|
||||||
|
"mysql+pymysql://root:root@127.0.0.1:3306/rpa_center?charset=utf8mb4",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Oracle(他们的货品主数据,只读)。拿到真实连接串后填这里。
|
||||||
|
# 形如: username/password@host:1521/service_name
|
||||||
|
ORACLE_DSN = os.getenv("ORACLE_DSN", "")
|
||||||
|
ORACLE_USER = os.getenv("ORACLE_USER", "")
|
||||||
|
ORACLE_PASSWORD = os.getenv("ORACLE_PASSWORD", "")
|
||||||
|
|
||||||
|
# Oracle 货品主数据表(真实表名/列名拿到后改这里,先按设计文档 A~G 列占位)
|
||||||
|
ORACLE_GOODS_TABLE = os.getenv("ORACLE_GOODS_TABLE", "GOODS_MASTER")
|
||||||
|
ORACLE_COL_COMMON_NAME = os.getenv("ORACLE_COL_COMMON_NAME", "COMMON_NAME") # 通用名
|
||||||
|
ORACLE_COL_APPROVAL_NO = os.getenv("ORACLE_COL_APPROVAL_NO", "APPROVAL_NO") # 批准文号
|
||||||
|
ORACLE_COL_SPEC = os.getenv("ORACLE_COL_SPEC", "SPEC") # 规格
|
||||||
|
ORACLE_COL_UNIT = os.getenv("ORACLE_COL_UNIT", "BASE_UNIT") # 基本单位
|
||||||
|
ORACLE_COL_MANUFACTURER = os.getenv("ORACLE_COL_MANUFACTURER", "MANUFACTURER")# 生产厂家
|
||||||
|
ORACLE_COL_GOODS_ID = os.getenv("ORACLE_COL_GOODS_ID", "GOODS_ID") # 货品ID
|
||||||
|
ORACLE_COL_GOODS_CODE = os.getenv("ORACLE_COL_GOODS_CODE", "GOODS_CODE") # 货品编码
|
||||||
|
|
||||||
|
# 对码货品主数据是否开工时全量 load 进内存(几万行推荐 True,快)
|
||||||
|
MATCH_LOAD_INTO_MEMORY = os.getenv("MATCH_LOAD_INTO_MEMORY", "true").lower() == "true"
|
||||||
|
|
||||||
|
# RPA 回调鉴权 token
|
||||||
|
AGENT_TOKEN = os.getenv("AGENT_TOKEN", "dev-agent-token")
|
||||||
|
|
||||||
|
# 合同审批等待(CONTRACT_APPROVE_WAIT):按业务方案定时查,不连续轮询。
|
||||||
|
# 到点由调度器触发 RPA 去查一次审批状态,没过就再排下一次。
|
||||||
|
CONTRACT_WAIT_INTERVAL_MIN = int(os.getenv("CONTRACT_WAIT_INTERVAL_MIN", "30")) # 每次间隔(分钟),业务方案≈半小时
|
||||||
|
CONTRACT_WAIT_MAX_CHECKS = int(os.getenv("CONTRACT_WAIT_MAX_CHECKS", "8")) # 最多查几次,超了升级OPS(8次≈4小时)
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
"""极简 MySQL 访问层(pymysql)。够第1步用,后面要换 SQLAlchemy 也行。"""
|
||||||
|
import pymysql
|
||||||
|
from pymysql.cursors import DictCursor
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_dsn(dsn: str) -> dict:
|
||||||
|
# mysql+pymysql://user:pass@host:port/db?charset=utf8mb4
|
||||||
|
body = dsn.split("://", 1)[1]
|
||||||
|
creds, rest = body.split("@", 1)
|
||||||
|
user, password = creds.split(":", 1)
|
||||||
|
hostport, dbq = rest.split("/", 1)
|
||||||
|
host, port = (hostport.split(":", 1) + ["3306"])[:2]
|
||||||
|
db = dbq.split("?", 1)[0]
|
||||||
|
return dict(host=host, port=int(port), user=user, password=password, database=db)
|
||||||
|
|
||||||
|
|
||||||
|
def get_conn():
|
||||||
|
cfg = _parse_dsn(settings.MYSQL_DSN)
|
||||||
|
return pymysql.connect(charset="utf8mb4", cursorclass=DictCursor, autocommit=True, **cfg)
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,215 @@
|
||||||
|
"""
|
||||||
|
对码引擎(核心难点)。
|
||||||
|
|
||||||
|
数据源:
|
||||||
|
- 货品主数据在 Oracle(只读)。开工时可全量 load 进内存,后续内存匹配。
|
||||||
|
- mapping_config(PRODUCT):平台奇葩写法→货品ID 的人工确认累积,存在自己 MySQL。
|
||||||
|
|
||||||
|
查找顺序(命中率越用越高):
|
||||||
|
1. 先查 mapping_config 累积别名 → 命中直接出货品ID(ALIAS,累积别名命中,非人工介入)
|
||||||
|
2. 再查货品主数据(内存/Oracle)四要素精确键 → EXACT
|
||||||
|
3. 规格/厂家容错后同商品名多条 → MULTI(出候选)
|
||||||
|
4. 都没有 → NONE(转人工)
|
||||||
|
|
||||||
|
对码结果状态只有 4 种:EXACT / ALIAS / MULTI / NONE。
|
||||||
|
(无 FUZZY:本引擎不产出模糊命中档,拿不准就走 MULTI 出候选让人选。)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 规范化:对付"15×2 vs 30片""干一瓶 vs ×1瓶""进口英文厂家" ----------
|
||||||
|
|
||||||
|
_FULL2HALF = {ord(" "): " ", ord("("): "(", ord(")"): ")", ord("*"): "*", ord("×"): "*"}
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_text(s: Optional[str]) -> str:
|
||||||
|
"""通用规范化:去空格、全半角、大小写、常见标点。"""
|
||||||
|
if not s:
|
||||||
|
return ""
|
||||||
|
s = s.translate(_FULL2HALF)
|
||||||
|
s = s.lower().strip()
|
||||||
|
s = re.sub(r"[\s,,。.、;;::'\"“”‘’]+", "", s)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_manufacturer(s: Optional[str]) -> str:
|
||||||
|
"""厂家名容错:进口药英文名统一大小写、去标点(B.V. / S.r.l. 之类)。"""
|
||||||
|
if not s:
|
||||||
|
return ""
|
||||||
|
s = s.lower().strip()
|
||||||
|
s = re.sub(r"[.\s,]+", "", s) # 去点、空格、逗号
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_spec(s: Optional[str]) -> str:
|
||||||
|
"""
|
||||||
|
规格标准化。把 '15×2' '15*2' 展开成乘积;'30片' '×1瓶' 抽出数字。
|
||||||
|
返回一个可比较的规范串,如 '30' 或 '30片'。
|
||||||
|
这是痛点,规则先覆盖会议里点名的几种,后续可加。
|
||||||
|
"""
|
||||||
|
if not s:
|
||||||
|
return ""
|
||||||
|
raw = s.translate(_FULL2HALF).lower().strip()
|
||||||
|
raw = re.sub(r"\s+", "", raw)
|
||||||
|
|
||||||
|
# 形如 15*2 / 15x2 → 乘积 30
|
||||||
|
m = re.search(r"(\d+(?:\.\d+)?)\*(\d+(?:\.\d+)?)", raw)
|
||||||
|
if m:
|
||||||
|
product = float(m.group(1)) * float(m.group(2))
|
||||||
|
product = int(product) if product == int(product) else product
|
||||||
|
# 保留单位后缀(如 片/瓶),便于跟 '30片' 对齐
|
||||||
|
unit = re.sub(r"[\d*.]+", "", raw)
|
||||||
|
return f"{product}{unit}"
|
||||||
|
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def build_match_key(common_name: str, approval_no: str, spec: str, manufacturer: str) -> str:
|
||||||
|
"""四要素拼接键:通用名 + 批准文号 + 规格 + 厂家(全部规范化)。"""
|
||||||
|
return "|".join([
|
||||||
|
_norm_text(common_name),
|
||||||
|
_norm_text(approval_no),
|
||||||
|
_norm_spec(spec),
|
||||||
|
_norm_manufacturer(manufacturer),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 货品主数据(Oracle 镜像到内存) ----------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GoodsRecord:
|
||||||
|
common_name: str
|
||||||
|
approval_no: str
|
||||||
|
spec: str
|
||||||
|
base_unit: str
|
||||||
|
manufacturer: str
|
||||||
|
goods_id: str
|
||||||
|
goods_code: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MatchResult:
|
||||||
|
order_item_id: int
|
||||||
|
match_key: str
|
||||||
|
match_status: str # EXACT / ALIAS / MULTI / NONE
|
||||||
|
goods_id: Optional[str] = None
|
||||||
|
goods_code: Optional[str] = None
|
||||||
|
base_unit: Optional[str] = None
|
||||||
|
candidates: list = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class MatchEngine:
|
||||||
|
"""
|
||||||
|
内存索引 + 两级查找。
|
||||||
|
goods_index: 四要素键 -> [GoodsRecord] (可能一键多货品,即一对多)
|
||||||
|
name_index: 规范通用名 -> [GoodsRecord] (用于同商品名多规格出候选)
|
||||||
|
alias_map: mapping_config 累积别名 config_key -> (goods_id, goods_code, base_unit)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.goods_index: dict[str, list[GoodsRecord]] = {}
|
||||||
|
self.name_index: dict[str, list[GoodsRecord]] = {}
|
||||||
|
self.alias_map: dict[str, tuple[str, str, str]] = {}
|
||||||
|
|
||||||
|
# -- 装载 --
|
||||||
|
def load_goods(self, records: list[GoodsRecord]) -> None:
|
||||||
|
self.goods_index.clear()
|
||||||
|
self.name_index.clear()
|
||||||
|
for r in records:
|
||||||
|
key = build_match_key(r.common_name, r.approval_no, r.spec, r.manufacturer)
|
||||||
|
self.goods_index.setdefault(key, []).append(r)
|
||||||
|
self.name_index.setdefault(_norm_text(r.common_name), []).append(r)
|
||||||
|
|
||||||
|
def load_aliases(self, aliases: dict[str, tuple[str, str, str]]) -> None:
|
||||||
|
"""aliases: config_key -> (goods_id, goods_code, base_unit)"""
|
||||||
|
self.alias_map = dict(aliases)
|
||||||
|
|
||||||
|
# -- 单行对码 --
|
||||||
|
def match_one(self, order_item_id: int, common_name: str, approval_no: str,
|
||||||
|
spec: str, manufacturer: str) -> MatchResult:
|
||||||
|
key = build_match_key(common_name, approval_no, spec, manufacturer)
|
||||||
|
|
||||||
|
# 1) 累积别名优先(ALIAS = 历史人工确认沉淀的别名命中,不是当次人工介入)
|
||||||
|
if key in self.alias_map:
|
||||||
|
gid, gcode, unit = self.alias_map[key]
|
||||||
|
return MatchResult(order_item_id, key, "ALIAS", gid, gcode, unit)
|
||||||
|
|
||||||
|
# 2) 四要素精确
|
||||||
|
hits = self.goods_index.get(key, [])
|
||||||
|
if len(hits) == 1:
|
||||||
|
r = hits[0]
|
||||||
|
return MatchResult(order_item_id, key, "EXACT", r.goods_id, r.goods_code, r.base_unit)
|
||||||
|
if len(hits) > 1:
|
||||||
|
return MatchResult(order_item_id, key, "MULTI",
|
||||||
|
candidates=[self._cand(r) for r in hits])
|
||||||
|
|
||||||
|
# 3) 同商品名多规格 → 出候选给人工选
|
||||||
|
name_hits = self.name_index.get(_norm_text(common_name), [])
|
||||||
|
if name_hits:
|
||||||
|
return MatchResult(order_item_id, key, "MULTI",
|
||||||
|
candidates=[self._cand(r) for r in name_hits])
|
||||||
|
|
||||||
|
# 4) 无命中
|
||||||
|
return MatchResult(order_item_id, key, "NONE")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _cand(r: GoodsRecord) -> dict:
|
||||||
|
return {
|
||||||
|
"goods_id": r.goods_id,
|
||||||
|
"goods_code": r.goods_code,
|
||||||
|
"common_name": r.common_name,
|
||||||
|
"spec": r.spec,
|
||||||
|
"base_unit": r.base_unit,
|
||||||
|
"manufacturer": r.manufacturer,
|
||||||
|
"approval_no": r.approval_no,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Oracle 取数(拿到真实连接串后启用) ----------
|
||||||
|
|
||||||
|
def load_goods_from_oracle(settings) -> list[GoodsRecord]:
|
||||||
|
"""
|
||||||
|
从 Oracle 只读拉全量货品主数据。
|
||||||
|
需要: pip install oracledb
|
||||||
|
真实表名/列名在 config.py 里改。
|
||||||
|
"""
|
||||||
|
import oracledb # 延迟导入,没配 Oracle 时不影响其他功能
|
||||||
|
|
||||||
|
sql = f"""
|
||||||
|
SELECT {settings.ORACLE_COL_COMMON_NAME},
|
||||||
|
{settings.ORACLE_COL_APPROVAL_NO},
|
||||||
|
{settings.ORACLE_COL_SPEC},
|
||||||
|
{settings.ORACLE_COL_UNIT},
|
||||||
|
{settings.ORACLE_COL_MANUFACTURER},
|
||||||
|
{settings.ORACLE_COL_GOODS_ID},
|
||||||
|
{settings.ORACLE_COL_GOODS_CODE}
|
||||||
|
FROM {settings.ORACLE_GOODS_TABLE}
|
||||||
|
"""
|
||||||
|
conn = oracledb.connect(
|
||||||
|
user=settings.ORACLE_USER,
|
||||||
|
password=settings.ORACLE_PASSWORD,
|
||||||
|
dsn=settings.ORACLE_DSN,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(sql)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for row in rows:
|
||||||
|
out.append(GoodsRecord(
|
||||||
|
common_name=str(row[0] or ""),
|
||||||
|
approval_no=str(row[1] or ""),
|
||||||
|
spec=str(row[2] or ""),
|
||||||
|
base_unit=str(row[3] or ""),
|
||||||
|
manufacturer=str(row[4] or ""),
|
||||||
|
goods_id=str(row[5] or ""),
|
||||||
|
goods_code=str(row[6] or ""),
|
||||||
|
))
|
||||||
|
return out
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
"""Web 版状态机纯逻辑。
|
||||||
|
|
||||||
|
只放和 UI/接口共享的计算,不碰数据库。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
|
||||||
|
NODE_ORDER = [
|
||||||
|
"ORDER_FETCH", "OCR_EXTRACT", "PRODUCT_MATCH", "SOURCE_ADD",
|
||||||
|
"CONTRACT_CREATE", "CONTRACT_APPROVE_WAIT", "REBATE_HEADER",
|
||||||
|
"REBATE_IMPORT", "REBATE_SUBMIT", "M5_CREATE_ORDER",
|
||||||
|
"M5_MANUAL_CONFIRM", "M5_RECEIPT_NOTICE", "ARCHIVE_JOB",
|
||||||
|
]
|
||||||
|
|
||||||
|
TRANSITIONS = {
|
||||||
|
"ORDER_FETCH": "OCR_EXTRACT",
|
||||||
|
"OCR_EXTRACT": "PRODUCT_MATCH",
|
||||||
|
"PRODUCT_MATCH": "SOURCE_ADD",
|
||||||
|
"SOURCE_ADD": "CONTRACT_CREATE",
|
||||||
|
"CONTRACT_CREATE": "CONTRACT_APPROVE_WAIT",
|
||||||
|
"CONTRACT_APPROVE_WAIT": "REBATE_HEADER",
|
||||||
|
"REBATE_HEADER": "REBATE_IMPORT",
|
||||||
|
"REBATE_IMPORT": "REBATE_SUBMIT",
|
||||||
|
"REBATE_SUBMIT": "M5_CREATE_ORDER",
|
||||||
|
"M5_CREATE_ORDER": "M5_MANUAL_CONFIRM",
|
||||||
|
"M5_MANUAL_CONFIRM": "M5_RECEIPT_NOTICE",
|
||||||
|
"M5_RECEIPT_NOTICE": "ARCHIVE_JOB",
|
||||||
|
"ARCHIVE_JOB": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
CONDITIONAL_NODES = {"OCR_EXTRACT", "SOURCE_ADD"}
|
||||||
|
HUMAN_NODE_ROLES = {
|
||||||
|
"SOURCE_ADD": "PURCHASER",
|
||||||
|
"M5_MANUAL_CONFIRM": "PURCHASER",
|
||||||
|
"M5_RECEIPT_NOTICE": "PURCHASER",
|
||||||
|
}
|
||||||
|
|
||||||
|
NODE_EXECUTOR = {
|
||||||
|
"ORDER_FETCH": "RPA",
|
||||||
|
"OCR_EXTRACT": "RPA",
|
||||||
|
"PRODUCT_MATCH": "STATE_CENTER",
|
||||||
|
"SOURCE_ADD": "HUMAN",
|
||||||
|
"CONTRACT_CREATE": "RPA",
|
||||||
|
"CONTRACT_APPROVE_WAIT": "RPA",
|
||||||
|
"REBATE_HEADER": "RPA",
|
||||||
|
"REBATE_IMPORT": "RPA",
|
||||||
|
"REBATE_SUBMIT": "RPA",
|
||||||
|
"M5_CREATE_ORDER": "RPA",
|
||||||
|
"M5_MANUAL_CONFIRM": "HUMAN",
|
||||||
|
"M5_RECEIPT_NOTICE": "HUMAN",
|
||||||
|
"ARCHIVE_JOB": "STATE_CENTER",
|
||||||
|
}
|
||||||
|
|
||||||
|
NODE_LABELS_ZH = {
|
||||||
|
"ORDER_FETCH": "订单获取",
|
||||||
|
"OCR_EXTRACT": "OCR识别",
|
||||||
|
"PRODUCT_MATCH": "对码",
|
||||||
|
"SOURCE_ADD": "新增货源",
|
||||||
|
"CONTRACT_CREATE": "合同建单",
|
||||||
|
"CONTRACT_APPROVE_WAIT": "合同审批等待",
|
||||||
|
"REBATE_HEADER": "返利总单",
|
||||||
|
"REBATE_IMPORT": "返利细单导入",
|
||||||
|
"REBATE_SUBMIT": "返利送审",
|
||||||
|
"M5_CREATE_ORDER": "M5建单",
|
||||||
|
"M5_MANUAL_CONFIRM": "M5人工确认",
|
||||||
|
"M5_RECEIPT_NOTICE": "收货通知单",
|
||||||
|
"ARCHIVE_JOB": "归档",
|
||||||
|
}
|
||||||
|
|
||||||
|
EXECUTOR_LABELS_ZH = {
|
||||||
|
"STATE_CENTER": "状态中心",
|
||||||
|
"RPA": "艺赛旗执行",
|
||||||
|
"HUMAN": "人工处理",
|
||||||
|
}
|
||||||
|
|
||||||
|
STATUS_LABELS_ZH = {
|
||||||
|
"PENDING": "待处理",
|
||||||
|
"RUNNING": "执行中",
|
||||||
|
"SUCCESS": "已完成",
|
||||||
|
"FAILED": "已失败",
|
||||||
|
"WAITING_USER": "等待人工",
|
||||||
|
"WAITING_EXTERNAL": "等待外部",
|
||||||
|
"SKIPPED": "已跳过",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _should_skip(node_code: str, job: Mapping[str, object]) -> bool:
|
||||||
|
if node_code == "OCR_EXTRACT":
|
||||||
|
return job.get("source_type") != "OFFLINE"
|
||||||
|
if node_code == "SOURCE_ADD":
|
||||||
|
return not job.get("needs_source_add")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _node_row_map(job_nodes: Sequence[Mapping[str, object]]) -> dict[str, Mapping[str, object]]:
|
||||||
|
out: dict[str, Mapping[str, object]] = {}
|
||||||
|
for row in job_nodes:
|
||||||
|
node_code = str(row.get("node_code") or "")
|
||||||
|
if node_code and node_code not in out:
|
||||||
|
out[node_code] = row
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _display_status_for_node(job: Mapping[str, object], node_code: str,
|
||||||
|
job_nodes_by_code: Mapping[str, Mapping[str, object]]) -> str:
|
||||||
|
row = job_nodes_by_code.get(node_code)
|
||||||
|
if row:
|
||||||
|
row_status = str(row.get("status") or "PENDING")
|
||||||
|
if row_status in {"SUCCESS", "FAILED", "RUNNING", "WAITING_USER", "WAITING_EXTERNAL"}:
|
||||||
|
return row_status
|
||||||
|
return "PENDING"
|
||||||
|
|
||||||
|
if node_code == job.get("current_node_code"):
|
||||||
|
job_status = str(job.get("status") or "PENDING")
|
||||||
|
if job_status in {"RUNNING", "WAITING_USER", "WAITING_EXTERNAL", "FAILED_RETRYABLE", "FAILED_MANUAL"}:
|
||||||
|
return "WAITING_USER" if job_status == "WAITING_USER" else (
|
||||||
|
"WAITING_EXTERNAL" if job_status == "WAITING_EXTERNAL" else (
|
||||||
|
"RUNNING" if job_status == "RUNNING" else "FAILED"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if job_status == "SUCCESS":
|
||||||
|
return "SUCCESS"
|
||||||
|
return "PENDING"
|
||||||
|
|
||||||
|
if node_code in CONDITIONAL_NODES and _should_skip(node_code, job):
|
||||||
|
return "SKIPPED"
|
||||||
|
|
||||||
|
return "PENDING"
|
||||||
|
|
||||||
|
|
||||||
|
def build_timeline(job: Mapping[str, object], job_nodes: Sequence[Mapping[str, object]]) -> list[dict]:
|
||||||
|
"""Return all nodes with backend-computed display status.
|
||||||
|
|
||||||
|
The frontend should render this directly and never infer node skipping itself.
|
||||||
|
"""
|
||||||
|
by_code = _node_row_map(job_nodes)
|
||||||
|
timeline = []
|
||||||
|
for node_code in NODE_ORDER:
|
||||||
|
row = by_code.get(node_code)
|
||||||
|
timeline.append({
|
||||||
|
"node_code": node_code,
|
||||||
|
"display_status": _display_status_for_node(job, node_code, by_code),
|
||||||
|
"attempt_no": row.get("attempt_no") if row else None,
|
||||||
|
"executor_type": row.get("executor_type") if row else None,
|
||||||
|
"status": row.get("status") if row else None,
|
||||||
|
"started_at": row.get("started_at") if row else None,
|
||||||
|
"ended_at": row.get("ended_at") if row else None,
|
||||||
|
})
|
||||||
|
return timeline
|
||||||
|
|
||||||
|
|
||||||
|
def build_state_machine_meta() -> dict:
|
||||||
|
return {
|
||||||
|
"node_order": NODE_ORDER,
|
||||||
|
"transitions": TRANSITIONS,
|
||||||
|
"conditional_nodes": sorted(CONDITIONAL_NODES),
|
||||||
|
"human_node_roles": HUMAN_NODE_ROLES,
|
||||||
|
"node_executor": NODE_EXECUTOR,
|
||||||
|
"node_labels": NODE_LABELS_ZH,
|
||||||
|
"executor_labels": EXECUTOR_LABELS_ZH,
|
||||||
|
"status_labels": STATUS_LABELS_ZH,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn==0.30.6
|
||||||
|
pydantic==2.9.2
|
||||||
|
pymysql==1.1.1
|
||||||
|
cryptography==50.0.0
|
||||||
|
# 对码接 Oracle 时再装(拿到连接串后):
|
||||||
|
# oracledb==2.4.1
|
||||||
|
|
@ -0,0 +1,205 @@
|
||||||
|
-- 采购主链 RPA 一期 · 状态中心建库脚本
|
||||||
|
-- 库里只存"流程状态 + 对码累积别名 + 证据",不存对码表本身(对码表在 Oracle,直连查)。
|
||||||
|
-- 目标库:MySQL 8
|
||||||
|
|
||||||
|
CREATE DATABASE IF NOT EXISTS rpa_center DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||||
|
USE rpa_center;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- job:一次流程实例 = 同供应商 + 同订单日期 + 同来源 = 一条合同
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS job (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
job_id VARCHAR(64) NOT NULL UNIQUE COMMENT '对外流程单号',
|
||||||
|
business_key VARCHAR(160) NOT NULL COMMENT '来源+供应商+订单日期,幂等用',
|
||||||
|
parent_job_id VARCHAR(64) NULL COMMENT '重跑/加单来源',
|
||||||
|
source_type VARCHAR(16) NOT NULL COMMENT 'YYC医药城/XYY小药药/OFFLINE线下单',
|
||||||
|
order_date DATE NOT NULL COMMENT '订单日期=合同签约日期',
|
||||||
|
supplier_name VARCHAR(128) NOT NULL COMMENT '供应商/商家',
|
||||||
|
order_no_list_json JSON NULL COMMENT '该批订单号数组',
|
||||||
|
contract_template VARCHAR(16) NULL COMMENT 'FORMAT格式/NON_FORMAT非格式承兑',
|
||||||
|
need_pdf_upload TINYINT NOT NULL DEFAULT 0 COMMENT '医药城=1,小药药=0',
|
||||||
|
planner_name VARCHAR(64) NULL COMMENT '计划员',
|
||||||
|
current_node_code VARCHAR(64) NOT NULL COMMENT '当前节点',
|
||||||
|
status VARCHAR(24) NOT NULL COMMENT 'PENDING/RUNNING/WAITING_USER/WAITING_EXTERNAL/SUCCESS/FAILED_RETRYABLE/FAILED_MANUAL/CANCELLED',
|
||||||
|
mapping_version VARCHAR(64) NULL COMMENT '绑定的对码累积版本',
|
||||||
|
needs_source_add TINYINT NOT NULL DEFAULT 0 COMMENT '对码后判定是否缺货源,决定是否走 SOURCE_ADD 条件节点',
|
||||||
|
executor_platform VARCHAR(16) NULL COMMENT '默认执行平台,如 ISRPA',
|
||||||
|
contract_system_no VARCHAR(64) NULL COMMENT '合同系统编号(钥匙1)',
|
||||||
|
rebate_order_no VARCHAR(64) NULL COMMENT '返利中心总单号',
|
||||||
|
m5_order_no VARCHAR(64) NULL COMMENT 'M5采购订单号',
|
||||||
|
receipt_notice_no VARCHAR(64) NULL COMMENT '收货通知单号',
|
||||||
|
current_role VARCHAR(16) NULL COMMENT '当前责任角色',
|
||||||
|
current_assignee VARCHAR(64) NULL COMMENT '当前责任人',
|
||||||
|
auto_save_enabled TINYINT NOT NULL DEFAULT 0 COMMENT 'M5自动保存开关,一期=0',
|
||||||
|
next_check_at DATETIME NULL COMMENT '等待态(如合同审批)下次检查时间,调度器扫这个',
|
||||||
|
wait_check_count INT NOT NULL DEFAULT 0 COMMENT '当前等待态已检查次数,超上限升级OPS',
|
||||||
|
created_by VARCHAR(64) NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL,
|
||||||
|
finished_at DATETIME NULL,
|
||||||
|
KEY idx_business_status (business_key, status),
|
||||||
|
KEY idx_supplier_date (supplier_name, order_date),
|
||||||
|
KEY idx_status (status),
|
||||||
|
KEY idx_next_check (status, next_check_at),
|
||||||
|
KEY idx_created_at (created_at)
|
||||||
|
) COMMENT='流程实例主表';
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- order_item:订单明细行(对码前的原始数据)
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS order_item (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
job_id VARCHAR(64) NOT NULL,
|
||||||
|
order_no VARCHAR(64) NOT NULL COMMENT '订单号(贯穿追溯)',
|
||||||
|
parent_order_no VARCHAR(64) NULL COMMENT '父订单号(加单/拆单)',
|
||||||
|
raw_product_name VARCHAR(255) NOT NULL COMMENT '平台品名',
|
||||||
|
platform_product_code VARCHAR(64) NULL COMMENT '平台产品编码',
|
||||||
|
spec VARCHAR(128) NULL COMMENT '规格(对码痛点:15×2 vs 30片)',
|
||||||
|
manufacturer VARCHAR(255) NULL COMMENT '厂家(含进口英文)',
|
||||||
|
approval_no VARCHAR(64) NULL COMMENT '国药准字/批准文号',
|
||||||
|
qty DECIMAL(14,2) NOT NULL COMMENT '数量',
|
||||||
|
unit_price DECIMAL(14,4) NULL COMMENT '单价',
|
||||||
|
source_row_no INT NULL COMMENT '原始Excel行号',
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
KEY idx_job (job_id),
|
||||||
|
KEY idx_order_no (order_no)
|
||||||
|
) COMMENT='订单明细行';
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- product_match:对码结果(核心难点落库)
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS product_match (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
job_id VARCHAR(64) NOT NULL,
|
||||||
|
order_item_id BIGINT NOT NULL COMMENT '对应订单行',
|
||||||
|
match_key VARCHAR(512) NOT NULL COMMENT '四要素拼接键:通用名+批准文号+规格+厂家',
|
||||||
|
match_status VARCHAR(16) NOT NULL COMMENT 'EXACT精确/ALIAS别名命中/MULTI一对多/NONE未命中(无FUZZY)',
|
||||||
|
goods_id VARCHAR(64) NULL COMMENT '命中的M5货品ID(来自Oracle)',
|
||||||
|
goods_code VARCHAR(64) NULL COMMENT '货品编码',
|
||||||
|
base_unit VARCHAR(32) NULL COMMENT '基本单位',
|
||||||
|
candidates_json JSON NULL COMMENT '一对多候选列表(供H5选择)',
|
||||||
|
source_add_needed TINYINT NOT NULL DEFAULT 0 COMMENT '是否需M5新增货源',
|
||||||
|
confirmed_by VARCHAR(64) NULL COMMENT '人工确认人',
|
||||||
|
confirmed_at DATETIME NULL,
|
||||||
|
mapping_version VARCHAR(64) NULL COMMENT '使用的对码累积版本',
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
UNIQUE KEY uniq_job_order_item (job_id, order_item_id),
|
||||||
|
KEY idx_job (job_id),
|
||||||
|
KEY idx_status (match_status)
|
||||||
|
) COMMENT='对码结果';
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- job_node:节点执行(每节点每次尝试一行,不覆盖历史)
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS job_node (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
job_id VARCHAR(64) NOT NULL,
|
||||||
|
node_code VARCHAR(64) NOT NULL COMMENT 'ORDER_FETCH/PRODUCT_MATCH/...',
|
||||||
|
executor_type VARCHAR(16) NOT NULL DEFAULT 'RPA' COMMENT '执行方:STATE_CENTER编排/RPA执行/HUMAN人工挂起',
|
||||||
|
attempt_no INT NOT NULL DEFAULT 1 COMMENT '第几次尝试',
|
||||||
|
status VARCHAR(24) NOT NULL COMMENT '节点状态',
|
||||||
|
executor_platform VARCHAR(16) NULL COMMENT '实际执行平台',
|
||||||
|
executor_run_id VARCHAR(64) NULL COMMENT '平台侧任务号',
|
||||||
|
started_at DATETIME NULL,
|
||||||
|
ended_at DATETIME NULL,
|
||||||
|
duration_ms BIGINT NULL,
|
||||||
|
error_code VARCHAR(64) NULL,
|
||||||
|
error_summary VARCHAR(500) NULL,
|
||||||
|
role_at_failure VARCHAR(16) NULL,
|
||||||
|
output_json JSON NULL COMMENT '节点输出(业务编号等)',
|
||||||
|
callback_idempotency_key VARCHAR(128) NULL COMMENT '回调幂等键',
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
UNIQUE KEY uniq_job_node_attempt (job_id, node_code, attempt_no),
|
||||||
|
KEY idx_status (status)
|
||||||
|
) COMMENT='节点执行记录';
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- user_task:人工待办
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS user_task (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
task_id VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
job_id VARCHAR(64) NOT NULL,
|
||||||
|
node_code VARCHAR(64) NOT NULL,
|
||||||
|
order_item_id BIGINT NULL COMMENT '对应订单行(对码类待办用)',
|
||||||
|
product_match_id BIGINT NULL COMMENT '对应对码结果行(对码类待办精确关联,confirm 时按它关闭)',
|
||||||
|
role_code VARCHAR(16) NOT NULL COMMENT 'PLANNER/PURCHASER/QC/OPS',
|
||||||
|
assignee_user_id VARCHAR(64) NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
reason_type VARCHAR(32) NOT NULL COMMENT 'MATCH_MULTI/MATCH_NONE/SOURCE_ADD/OCR_LOW_CONF/REBATE_DUP/QC_MISSING/CONTRACT_DISABLED',
|
||||||
|
reason_user_friendly VARCHAR(500) NULL COMMENT '人话说明',
|
||||||
|
why_me_text VARCHAR(255) NULL COMMENT '为什么派给你',
|
||||||
|
editable_fields_json JSON NULL COMMENT '白名单字段',
|
||||||
|
action_schema_json JSON NULL COMMENT '允许动作',
|
||||||
|
comparison_snapshot_json JSON NULL COMMENT '原值/建议/当前对比',
|
||||||
|
candidates_json JSON NULL COMMENT '一对多对码候选',
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'OPEN' COMMENT 'OPEN/SUBMITTED/CANCELLED/EXPIRED',
|
||||||
|
submit_token VARCHAR(64) NULL COMMENT '防重复提交',
|
||||||
|
submitted_action VARCHAR(24) NULL,
|
||||||
|
submitted_payload_json JSON NULL,
|
||||||
|
submitted_result_json JSON NULL COMMENT '提交结果,同 submit_token 重放时返回',
|
||||||
|
submitted_by VARCHAR(64) NULL,
|
||||||
|
submitted_at DATETIME NULL,
|
||||||
|
due_at DATETIME NULL COMMENT '超时升级',
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
KEY idx_job (job_id),
|
||||||
|
KEY idx_role_status (role_code, status),
|
||||||
|
KEY idx_product_match (product_match_id)
|
||||||
|
) COMMENT='人工待办';
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- mapping_config:对码累积别名 + 供应商对照等映射(版本化)
|
||||||
|
-- 注意:这不是对码表的拷贝!对码表在 Oracle。
|
||||||
|
-- 这里存的是"平台奇葩写法 → 货品ID"的人工确认记录,以及供应商对照。
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS mapping_config (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
config_type VARCHAR(24) NOT NULL COMMENT 'PRODUCT对码别名/SUPPLIER供应商对照/SETTLEMENT/PAYMENT_TERM/PLANNER_ROUTE',
|
||||||
|
config_key VARCHAR(512) NOT NULL COMMENT '对码时=四要素拼接键或平台写法',
|
||||||
|
source_value VARCHAR(512) NULL COMMENT '来源值',
|
||||||
|
target_value VARCHAR(512) NULL COMMENT '目标值(对码=货品ID+编码+单位JSON)',
|
||||||
|
extra_json JSON NULL,
|
||||||
|
version_no VARCHAR(64) NULL COMMENT '版本号',
|
||||||
|
is_active TINYINT NOT NULL DEFAULT 1,
|
||||||
|
effective_from DATETIME NULL,
|
||||||
|
effective_to DATETIME NULL,
|
||||||
|
updated_by VARCHAR(64) NULL,
|
||||||
|
updated_at DATETIME NOT NULL,
|
||||||
|
KEY idx_type_key (config_type, config_key),
|
||||||
|
KEY idx_type_key_active (config_type, config_key, is_active, updated_at, id),
|
||||||
|
KEY idx_active (is_active)
|
||||||
|
) COMMENT='映射/对码累积别名(非对码表拷贝)';
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- job_artifact:证据元数据(文件落对象存储,这里存元数据)
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS job_artifact (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
job_id VARCHAR(64) NOT NULL,
|
||||||
|
node_code VARCHAR(64) NULL,
|
||||||
|
attempt_no INT NULL,
|
||||||
|
artifact_type VARCHAR(32) NOT NULL COMMENT 'SOURCE_EXCEL/SOURCE_IMAGE/OCR_JSON/CONTRACT_PDF/MERGED_PDF/SCREENSHOT/TEMPLATE/LOG',
|
||||||
|
storage_path VARCHAR(500) NOT NULL,
|
||||||
|
file_hash VARCHAR(128) NULL,
|
||||||
|
visible_roles VARCHAR(128) NULL COMMENT '可见角色',
|
||||||
|
is_masked TINYINT NOT NULL DEFAULT 0 COMMENT '脱敏标记',
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
KEY idx_job (job_id)
|
||||||
|
) COMMENT='证据元数据';
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- job_event:事件流(审计留痕)
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS job_event (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
job_id VARCHAR(64) NOT NULL,
|
||||||
|
event_type VARCHAR(32) NOT NULL COMMENT 'JOB_CREATED/NODE_STARTED/NODE_SUCCESS/NODE_FAILED/TASK_CREATED/TASK_SUBMITTED/MANUAL_HANDOFF/JOB_FINISHED',
|
||||||
|
node_code VARCHAR(64) NULL,
|
||||||
|
operator_type VARCHAR(16) NOT NULL COMMENT 'SYSTEM/ROBOT/USER',
|
||||||
|
operator VARCHAR(64) NULL,
|
||||||
|
detail_json JSON NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
KEY idx_job (job_id),
|
||||||
|
KEY idx_type (event_type)
|
||||||
|
) COMMENT='事件流';
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
from scripts import dev
|
||||||
|
|
||||||
|
|
||||||
|
def test_backend_runtime_probe_checks_mysql8_auth_dependency(monkeypatch):
|
||||||
|
commands = []
|
||||||
|
|
||||||
|
monkeypatch.setattr(dev, "_backend_python", lambda: "/tmp/backend-python")
|
||||||
|
|
||||||
|
def fake_run(command, **kwargs):
|
||||||
|
commands.append(command)
|
||||||
|
|
||||||
|
class Result:
|
||||||
|
returncode = 0
|
||||||
|
stderr = ""
|
||||||
|
|
||||||
|
return Result()
|
||||||
|
|
||||||
|
monkeypatch.setattr(dev.subprocess, "run", fake_run)
|
||||||
|
|
||||||
|
assert dev._ensure_backend_runtime() == "/tmp/backend-python"
|
||||||
|
assert commands[0] == [
|
||||||
|
"/tmp/backend-python",
|
||||||
|
"-c",
|
||||||
|
"import fastapi, pymysql, uvicorn, cryptography",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_backend_environment_has_local_defaults(monkeypatch):
|
||||||
|
for name in ("DEV_MODE", "MYSQL_DSN", "AGENT_TOKEN"):
|
||||||
|
monkeypatch.delenv(name, raising=False)
|
||||||
|
|
||||||
|
env = dev.build_backend_env({})
|
||||||
|
|
||||||
|
assert env["DEV_MODE"] == "true"
|
||||||
|
assert env["MYSQL_DSN"] == "mysql+pymysql://root:root@127.0.0.1:3306/rpa_center?charset=utf8mb4"
|
||||||
|
assert env["AGENT_TOKEN"] == "dev-agent-token"
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_uses_checked_backend_python(monkeypatch):
|
||||||
|
spawned = []
|
||||||
|
|
||||||
|
monkeypatch.setattr(dev, "ensure_mysql", lambda: None)
|
||||||
|
monkeypatch.setattr(dev, "wait_for_port", lambda host, port: None)
|
||||||
|
monkeypatch.setattr(dev, "_ensure_backend_runtime", lambda: "/tmp/backend-python")
|
||||||
|
monkeypatch.setattr(dev, "_read_pids", lambda: {})
|
||||||
|
monkeypatch.setattr(dev, "_pid_alive", lambda pid: False)
|
||||||
|
monkeypatch.setattr(dev, "_port_open", lambda host, port: False)
|
||||||
|
monkeypatch.setattr(dev, "_write_pids", lambda pids: None)
|
||||||
|
monkeypatch.setattr(dev, "build_backend_env", lambda: {})
|
||||||
|
|
||||||
|
def fake_spawn(name, command, cwd, env):
|
||||||
|
spawned.append((name, command))
|
||||||
|
return len(spawned)
|
||||||
|
|
||||||
|
monkeypatch.setattr(dev, "_spawn", fake_spawn)
|
||||||
|
|
||||||
|
dev.start()
|
||||||
|
|
||||||
|
assert spawned[0] == (
|
||||||
|
"backend",
|
||||||
|
[
|
||||||
|
"/tmp/backend-python",
|
||||||
|
"-m",
|
||||||
|
"uvicorn",
|
||||||
|
"state_center.app.main:app",
|
||||||
|
"--host",
|
||||||
|
"0.0.0.0",
|
||||||
|
"--port",
|
||||||
|
"8000",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_reuses_frontend_when_port_is_already_open(monkeypatch):
|
||||||
|
spawned = []
|
||||||
|
|
||||||
|
monkeypatch.setattr(dev, "ensure_mysql", lambda: None)
|
||||||
|
monkeypatch.setattr(dev, "wait_for_port", lambda host, port: None)
|
||||||
|
monkeypatch.setattr(dev, "_ensure_backend_runtime", lambda: "/tmp/backend-python")
|
||||||
|
monkeypatch.setattr(dev, "_read_pids", lambda: {})
|
||||||
|
monkeypatch.setattr(dev, "_write_pids", lambda pids: None)
|
||||||
|
monkeypatch.setattr(dev, "build_backend_env", lambda: {})
|
||||||
|
monkeypatch.setattr(dev, "_port_open", lambda host, port: port == 5173)
|
||||||
|
|
||||||
|
def fake_spawn(name, command, cwd, env):
|
||||||
|
spawned.append(name)
|
||||||
|
return len(spawned)
|
||||||
|
|
||||||
|
monkeypatch.setattr(dev, "_spawn", fake_spawn)
|
||||||
|
|
||||||
|
dev.start()
|
||||||
|
|
||||||
|
assert spawned == ["backend"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_pid_alive_rejects_invalid_pid():
|
||||||
|
assert dev._pid_alive(-1) is False
|
||||||
|
assert dev._pid_alive(0) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_pid_terminates_process_group_on_posix(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
monkeypatch.setattr(dev.os, "name", "posix")
|
||||||
|
monkeypatch.setattr(dev.os, "getpgid", lambda pid: 4321)
|
||||||
|
monkeypatch.setattr(dev.os, "killpg", lambda pgid, sig: calls.append(("killpg", pgid, sig)))
|
||||||
|
monkeypatch.setattr(dev.os, "kill", lambda pid, sig: calls.append(("kill", pid, sig)))
|
||||||
|
|
||||||
|
dev._stop_pid(1234)
|
||||||
|
|
||||||
|
assert calls == [("killpg", 4321, dev.signal.SIGTERM)]
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
"""对码引擎单测:不依赖 DB / Oracle,纯内存验证四要素匹配 + 规格标准化。
|
||||||
|
运行: python -m pytest state_center/tests/test_match_engine.py -v
|
||||||
|
或直接: python state_center/tests/test_match_engine.py
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||||
|
|
||||||
|
from state_center.app.match_engine import ( # noqa: E402
|
||||||
|
MatchEngine, GoodsRecord, build_match_key, _norm_spec,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _engine():
|
||||||
|
eng = MatchEngine()
|
||||||
|
eng.load_goods([
|
||||||
|
GoodsRecord("阿卡波糖片", "国药准字H19990001", "50mg*30", "盒", "拜耳医药", "G001", "C001"),
|
||||||
|
GoodsRecord("哈药牌钙铁锌口服液", "国药准字Z20000002", "10ml*10", "盒", "哈药集团", "G002", "C002"),
|
||||||
|
# 同名多规格(用于一对多)
|
||||||
|
GoodsRecord("阿卡波糖片", "国药准字H19990001", "50mg*60", "盒", "拜耳医药", "G003", "C003"),
|
||||||
|
])
|
||||||
|
return eng
|
||||||
|
|
||||||
|
|
||||||
|
def test_spec_normalize_product():
|
||||||
|
# 15×2 展开成 30
|
||||||
|
assert _norm_spec("15×2") == _norm_spec("15*2")
|
||||||
|
assert _norm_spec("50mg*30").startswith("1500") or "mg" in _norm_spec("50mg*30")
|
||||||
|
|
||||||
|
|
||||||
|
def test_exact_hit():
|
||||||
|
eng = _engine()
|
||||||
|
r = eng.match_one(1, "哈药牌钙铁锌口服液", "国药准字Z20000002", "10ml*10", "哈药集团")
|
||||||
|
assert r.match_status == "EXACT"
|
||||||
|
assert r.goods_id == "G002"
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_same_name():
|
||||||
|
eng = _engine()
|
||||||
|
# 规格写法对不上任何精确键,但商品名命中多条 → MULTI
|
||||||
|
r = eng.match_one(2, "阿卡波糖片", "国药准字H19990001", "写个对不上的规格", "拜耳医药")
|
||||||
|
assert r.match_status == "MULTI"
|
||||||
|
assert len(r.candidates) >= 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_none():
|
||||||
|
eng = _engine()
|
||||||
|
r = eng.match_one(3, "不存在的药", "X", "1", "无")
|
||||||
|
assert r.match_status == "NONE"
|
||||||
|
|
||||||
|
|
||||||
|
def test_alias_priority():
|
||||||
|
eng = _engine()
|
||||||
|
key = build_match_key("阿卡波糖", "", "30片", "") # 平台奇葩写法
|
||||||
|
eng.load_aliases({key: ("G001", "C001", "盒")})
|
||||||
|
r = eng.match_one(4, "阿卡波糖", "", "30片", "")
|
||||||
|
assert r.match_status == "ALIAS"
|
||||||
|
assert r.goods_id == "G001"
|
||||||
|
|
||||||
|
|
||||||
|
def test_manufacturer_tolerance():
|
||||||
|
# 进口英文厂家:大小写/标点容错
|
||||||
|
k1 = build_match_key("药A", "H1", "1", "Abbott Biologicals B.V.")
|
||||||
|
k2 = build_match_key("药A", "H1", "1", "abbott biologicals bv")
|
||||||
|
assert k1 == k2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import traceback
|
||||||
|
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
|
||||||
|
passed = 0
|
||||||
|
for fn in fns:
|
||||||
|
try:
|
||||||
|
fn()
|
||||||
|
print(f"PASS {fn.__name__}")
|
||||||
|
passed += 1
|
||||||
|
except Exception:
|
||||||
|
print(f"FAIL {fn.__name__}")
|
||||||
|
traceback.print_exc()
|
||||||
|
print(f"\n{passed}/{len(fns)} passed")
|
||||||
|
|
@ -0,0 +1,796 @@
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
def _install_fake_pymysql():
|
||||||
|
pymysql = types.ModuleType("pymysql")
|
||||||
|
cursors = types.ModuleType("pymysql.cursors")
|
||||||
|
|
||||||
|
class DictCursor: # noqa: D401 - test double
|
||||||
|
pass
|
||||||
|
|
||||||
|
def connect(*args, **kwargs): # noqa: D401 - test double
|
||||||
|
raise AssertionError("connect should be monkeypatched in tests")
|
||||||
|
|
||||||
|
cursors.DictCursor = DictCursor
|
||||||
|
pymysql.cursors = cursors
|
||||||
|
pymysql.connect = connect
|
||||||
|
sys.modules["pymysql"] = pymysql
|
||||||
|
sys.modules["pymysql.cursors"] = cursors
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeline_api_returns_backend_computed_nodes(monkeypatch):
|
||||||
|
_install_fake_pymysql()
|
||||||
|
main = importlib.import_module("state_center.app.main")
|
||||||
|
client = TestClient(main.app)
|
||||||
|
|
||||||
|
class FakeCursor:
|
||||||
|
def __init__(self):
|
||||||
|
self._result = []
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def execute(self, sql, params=None):
|
||||||
|
if "FROM job WHERE job_id" in sql:
|
||||||
|
self._result = [{
|
||||||
|
"job_id": "JOB-1",
|
||||||
|
"source_type": "YYC",
|
||||||
|
"current_node_code": "SOURCE_ADD",
|
||||||
|
"status": "WAITING_USER",
|
||||||
|
"needs_source_add": 1,
|
||||||
|
}]
|
||||||
|
elif "FROM job_node" in sql:
|
||||||
|
self._result = [
|
||||||
|
{"node_code": "ORDER_FETCH", "attempt_no": 1, "status": "SUCCESS"},
|
||||||
|
{"node_code": "PRODUCT_MATCH", "attempt_no": 1, "status": "SUCCESS"},
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
self._result = []
|
||||||
|
|
||||||
|
def fetchone(self):
|
||||||
|
return self._result[0] if self._result else None
|
||||||
|
|
||||||
|
def fetchall(self):
|
||||||
|
return list(self._result)
|
||||||
|
|
||||||
|
class FakeConn:
|
||||||
|
def cursor(self):
|
||||||
|
return FakeCursor()
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "get_conn", lambda: FakeConn())
|
||||||
|
|
||||||
|
resp = client.get("/api/jobs/JOB-1/timeline")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
by_code = {item["node_code"]: item for item in body["timeline"]}
|
||||||
|
assert by_code["ORDER_FETCH"]["display_status"] == "SUCCESS"
|
||||||
|
assert by_code["OCR_EXTRACT"]["display_status"] == "SKIPPED"
|
||||||
|
assert by_code["SOURCE_ADD"]["display_status"] == "WAITING_USER"
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_machine_api_returns_chinese_labels(monkeypatch):
|
||||||
|
_install_fake_pymysql()
|
||||||
|
main = importlib.import_module("state_center.app.main")
|
||||||
|
client = TestClient(main.app)
|
||||||
|
|
||||||
|
resp = client.get("/api/state-machine")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["node_labels"]["ORDER_FETCH"] == "订单获取"
|
||||||
|
assert body["executor_labels"]["RPA"] == "艺赛旗执行"
|
||||||
|
assert body["node_executor"]["PRODUCT_MATCH"] == "STATE_CENTER"
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_reports_database_status(monkeypatch):
|
||||||
|
_install_fake_pymysql()
|
||||||
|
main = importlib.import_module("state_center.app.main")
|
||||||
|
client = TestClient(main.app)
|
||||||
|
|
||||||
|
class FakeCursor:
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def execute(self, sql, params=None):
|
||||||
|
assert sql == "SELECT 1"
|
||||||
|
|
||||||
|
def fetchone(self):
|
||||||
|
return {"ok": 1}
|
||||||
|
|
||||||
|
class FakeConn:
|
||||||
|
def cursor(self):
|
||||||
|
return FakeCursor()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "get_conn", lambda: FakeConn())
|
||||||
|
|
||||||
|
response = client.get("/health")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"ok": True, "database": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_submit_confirms_source_add_and_is_idempotent(monkeypatch):
|
||||||
|
_install_fake_pymysql()
|
||||||
|
main = importlib.import_module("state_center.app.main")
|
||||||
|
client = TestClient(main.app)
|
||||||
|
|
||||||
|
state = {
|
||||||
|
"task": {
|
||||||
|
"task_id": "TASK-1",
|
||||||
|
"job_id": "JOB-1",
|
||||||
|
"node_code": "SOURCE_ADD",
|
||||||
|
"role_code": "PURCHASER",
|
||||||
|
"status": "OPEN",
|
||||||
|
"submit_token": None,
|
||||||
|
"submitted_action": None,
|
||||||
|
"submitted_payload_json": None,
|
||||||
|
"submitted_result_json": None,
|
||||||
|
},
|
||||||
|
"job": {
|
||||||
|
"job_id": "JOB-1",
|
||||||
|
"current_node_code": "SOURCE_ADD",
|
||||||
|
"status": "WAITING_USER",
|
||||||
|
"needs_source_add": 1,
|
||||||
|
"current_role": "PURCHASER",
|
||||||
|
"source_type": "YYC",
|
||||||
|
},
|
||||||
|
"job_nodes": [
|
||||||
|
{"node_code": "SOURCE_ADD", "attempt_no": 1, "status": "WAITING_USER"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeCursor:
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def execute(self, sql, params=None):
|
||||||
|
self.last_sql = sql
|
||||||
|
self.last_params = params
|
||||||
|
if "FROM user_task WHERE task_id=%s" in sql:
|
||||||
|
self._result = [state["task"]]
|
||||||
|
elif "FROM job WHERE job_id=%s" in sql and "UPDATE" not in sql:
|
||||||
|
self._result = [state["job"]]
|
||||||
|
elif "submitted_result_json" in sql and "FROM user_task" in sql:
|
||||||
|
self._result = [state["task"]]
|
||||||
|
elif "SELECT node_code,attempt_no,status" in sql:
|
||||||
|
self._result = list(state["job_nodes"])
|
||||||
|
elif sql.startswith("UPDATE user_task SET status='SUBMITTED'"):
|
||||||
|
state["task"]["status"] = "SUBMITTED"
|
||||||
|
state["task"]["submit_token"] = params[0]
|
||||||
|
state["task"]["submitted_action"] = params[1]
|
||||||
|
state["task"]["submitted_payload_json"] = params[2]
|
||||||
|
state["task"]["submitted_result_json"] = params[3]
|
||||||
|
state["task"]["submitted_by"] = params[4]
|
||||||
|
state["task"]["submitted_at"] = params[5]
|
||||||
|
elif sql.startswith("UPDATE job SET needs_source_add=0"):
|
||||||
|
state["job"]["needs_source_add"] = 0
|
||||||
|
elif sql.startswith("UPDATE job SET current_node_code=%s,status='PENDING'"):
|
||||||
|
state["job"]["current_node_code"] = params[0]
|
||||||
|
state["job"]["status"] = "PENDING"
|
||||||
|
elif sql.startswith("UPDATE job_node SET status='SUCCESS'"):
|
||||||
|
state["job_nodes"][0]["status"] = "SUCCESS"
|
||||||
|
elif sql.startswith("INSERT INTO job_event"):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
self._result = []
|
||||||
|
|
||||||
|
def fetchone(self):
|
||||||
|
return self._result[0] if self._result else None
|
||||||
|
|
||||||
|
def fetchall(self):
|
||||||
|
return list(self._result)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _result(self):
|
||||||
|
return getattr(self, "_result_data", [])
|
||||||
|
|
||||||
|
@_result.setter
|
||||||
|
def _result(self, value):
|
||||||
|
self._result_data = value
|
||||||
|
|
||||||
|
class FakeConn:
|
||||||
|
def cursor(self):
|
||||||
|
return FakeCursor()
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "get_conn", lambda: FakeConn())
|
||||||
|
|
||||||
|
headers = {"X-Agent-Token": main.settings.AGENT_TOKEN}
|
||||||
|
first = client.post(
|
||||||
|
"/api/tasks/TASK-1/submit",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"action": "CONFIRM_AND_CONTINUE",
|
||||||
|
"payload": {"sourceAdd": True},
|
||||||
|
"submitToken": "token-1",
|
||||||
|
"operator": "alice",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert first.status_code == 200
|
||||||
|
first_body = first.json()
|
||||||
|
assert first_body["next_node"] == "CONTRACT_CREATE"
|
||||||
|
assert state["task"]["status"] == "SUBMITTED"
|
||||||
|
assert state["task"]["submitted_result_json"] is not None
|
||||||
|
assert state["job"]["current_node_code"] == "CONTRACT_CREATE"
|
||||||
|
assert state["job"]["needs_source_add"] == 0
|
||||||
|
|
||||||
|
second = client.post(
|
||||||
|
"/api/tasks/TASK-1/submit",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"action": "CONFIRM_AND_CONTINUE",
|
||||||
|
"payload": {"sourceAdd": True},
|
||||||
|
"submitToken": "token-1",
|
||||||
|
"operator": "alice",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert second.status_code == 200
|
||||||
|
assert second.json()["idempotent"] is True
|
||||||
|
|
||||||
|
third = client.post(
|
||||||
|
"/api/tasks/TASK-1/submit",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"action": "CONFIRM_AND_CONTINUE",
|
||||||
|
"payload": {"sourceAdd": True},
|
||||||
|
"submitToken": "token-2",
|
||||||
|
"operator": "alice",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert third.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_submit_source_add_creates_missing_node_history(monkeypatch):
|
||||||
|
_install_fake_pymysql()
|
||||||
|
main = importlib.import_module("state_center.app.main")
|
||||||
|
client = TestClient(main.app)
|
||||||
|
|
||||||
|
state = {
|
||||||
|
"task": {
|
||||||
|
"task_id": "TASK-1",
|
||||||
|
"job_id": "JOB-1",
|
||||||
|
"node_code": "SOURCE_ADD",
|
||||||
|
"role_code": "PURCHASER",
|
||||||
|
"status": "OPEN",
|
||||||
|
"submit_token": None,
|
||||||
|
"submitted_result_json": None,
|
||||||
|
},
|
||||||
|
"job": {
|
||||||
|
"job_id": "JOB-1",
|
||||||
|
"current_node_code": "SOURCE_ADD",
|
||||||
|
"status": "WAITING_USER",
|
||||||
|
"needs_source_add": 1,
|
||||||
|
"current_role": "PURCHASER",
|
||||||
|
"source_type": "YYC",
|
||||||
|
},
|
||||||
|
"job_nodes": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeCursor:
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def execute(self, sql, params=None):
|
||||||
|
if "FROM user_task WHERE task_id=%s" in sql:
|
||||||
|
self._result = [state["task"]]
|
||||||
|
elif "FROM job WHERE job_id=%s" in sql and "UPDATE" not in sql:
|
||||||
|
self._result = [state["job"]]
|
||||||
|
elif sql.startswith("UPDATE job SET needs_source_add=0"):
|
||||||
|
state["job"]["needs_source_add"] = 0
|
||||||
|
elif sql.startswith("INSERT INTO job_node"):
|
||||||
|
state["job_nodes"].append({
|
||||||
|
"node_code": params[1],
|
||||||
|
"attempt_no": params[3],
|
||||||
|
"status": "SUCCESS",
|
||||||
|
"executor_type": params[2],
|
||||||
|
})
|
||||||
|
elif sql.startswith("UPDATE job SET current_node_code=%s,status='PENDING'"):
|
||||||
|
state["job"]["current_node_code"] = params[0]
|
||||||
|
state["job"]["status"] = "PENDING"
|
||||||
|
elif sql.startswith("UPDATE user_task SET status='SUBMITTED'"):
|
||||||
|
state["task"]["status"] = "SUBMITTED"
|
||||||
|
state["task"]["submit_token"] = params[0]
|
||||||
|
state["task"]["submitted_result_json"] = params[3]
|
||||||
|
elif sql.startswith("INSERT INTO job_event"):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
self._result = []
|
||||||
|
|
||||||
|
def fetchone(self):
|
||||||
|
return self._result[0] if self._result else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _result(self):
|
||||||
|
return getattr(self, "_result_data", [])
|
||||||
|
|
||||||
|
@_result.setter
|
||||||
|
def _result(self, value):
|
||||||
|
self._result_data = value
|
||||||
|
|
||||||
|
class FakeConn:
|
||||||
|
def cursor(self):
|
||||||
|
return FakeCursor()
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "get_conn", lambda: FakeConn())
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/tasks/TASK-1/submit",
|
||||||
|
headers={"X-Agent-Token": main.settings.AGENT_TOKEN},
|
||||||
|
json={
|
||||||
|
"action": "CONFIRM_AND_CONTINUE",
|
||||||
|
"payload": {},
|
||||||
|
"submitToken": "source-submit-1",
|
||||||
|
"operator": "采购张三",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert state["job_nodes"] == [{
|
||||||
|
"node_code": "SOURCE_ADD",
|
||||||
|
"attempt_no": 1,
|
||||||
|
"status": "SUCCESS",
|
||||||
|
"executor_type": "HUMAN",
|
||||||
|
}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_advance_to_m5_human_node_creates_task():
|
||||||
|
_install_fake_pymysql()
|
||||||
|
main = importlib.import_module("state_center.app.main")
|
||||||
|
|
||||||
|
state = {
|
||||||
|
"job": {
|
||||||
|
"job_id": "JOB-1",
|
||||||
|
"supplier_name": "重庆亿昊药业",
|
||||||
|
"current_node_code": "M5_CREATE_ORDER",
|
||||||
|
"status": "RUNNING",
|
||||||
|
},
|
||||||
|
"job_nodes": [],
|
||||||
|
"tasks": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeCursor:
|
||||||
|
def execute(self, sql, params=None):
|
||||||
|
if sql.startswith("INSERT INTO job_node"):
|
||||||
|
state["job_nodes"].append({
|
||||||
|
"node_code": params[1],
|
||||||
|
"executor_type": params[2],
|
||||||
|
"attempt_no": params[3],
|
||||||
|
"status": params[4],
|
||||||
|
})
|
||||||
|
elif sql.startswith("DELETE FROM user_task"):
|
||||||
|
pass
|
||||||
|
elif sql.startswith("UPDATE job SET current_node_code=%s,status='WAITING_USER'"):
|
||||||
|
state["job"]["current_node_code"] = params[0]
|
||||||
|
state["job"]["status"] = "WAITING_USER"
|
||||||
|
state["job"]["current_role"] = params[1]
|
||||||
|
elif sql.startswith("INSERT INTO user_task"):
|
||||||
|
state["tasks"].append({
|
||||||
|
"job_id": params[1],
|
||||||
|
"node_code": params[2],
|
||||||
|
"role_code": params[3],
|
||||||
|
"title": params[4],
|
||||||
|
})
|
||||||
|
elif sql.startswith("INSERT INTO job_event"):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise AssertionError(sql)
|
||||||
|
|
||||||
|
nxt = main._advance(FakeCursor(), state["job"], "M5_CREATE_ORDER")
|
||||||
|
|
||||||
|
assert nxt == "M5_MANUAL_CONFIRM"
|
||||||
|
assert state["job"]["status"] == "WAITING_USER"
|
||||||
|
assert state["job"]["current_role"] == "PURCHASER"
|
||||||
|
assert state["job_nodes"] == [{
|
||||||
|
"node_code": "M5_MANUAL_CONFIRM",
|
||||||
|
"executor_type": "HUMAN",
|
||||||
|
"attempt_no": 1,
|
||||||
|
"status": "WAITING_USER",
|
||||||
|
}]
|
||||||
|
assert state["tasks"] == [{
|
||||||
|
"job_id": "JOB-1",
|
||||||
|
"node_code": "M5_MANUAL_CONFIRM",
|
||||||
|
"role_code": "PURCHASER",
|
||||||
|
"title": "M5人工确认:重庆亿昊药业",
|
||||||
|
}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_node_complete_exports_artifacts(monkeypatch):
|
||||||
|
_install_fake_pymysql()
|
||||||
|
main = importlib.import_module("state_center.app.main")
|
||||||
|
client = TestClient(main.app)
|
||||||
|
|
||||||
|
state = {
|
||||||
|
"job": {
|
||||||
|
"job_id": "JOB-1",
|
||||||
|
"current_node_code": "CONTRACT_CREATE",
|
||||||
|
"status": "RUNNING",
|
||||||
|
"source_type": "YYC",
|
||||||
|
},
|
||||||
|
"job_node": {
|
||||||
|
"node_code": "CONTRACT_CREATE",
|
||||||
|
"attempt_no": 1,
|
||||||
|
"status": "RUNNING",
|
||||||
|
},
|
||||||
|
"artifacts": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeCursor:
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def execute(self, sql, params=None):
|
||||||
|
if "FROM job WHERE job_id=%s" in sql and "ORDER BY" not in sql:
|
||||||
|
self._result = [state["job"]]
|
||||||
|
elif "FROM job_node WHERE job_id=%s" in sql:
|
||||||
|
self._result = [state["job_node"]]
|
||||||
|
elif "FROM user_task WHERE job_id=%s" in sql:
|
||||||
|
self._result = []
|
||||||
|
elif "FROM job_artifact WHERE job_id=%s" in sql:
|
||||||
|
self._result = list(state["artifacts"])
|
||||||
|
elif sql.startswith("UPDATE job_node SET status='SUCCESS'"):
|
||||||
|
state["job_node"]["status"] = "SUCCESS"
|
||||||
|
state["job_node"]["ended_at"] = params[1]
|
||||||
|
if len(params) > 2:
|
||||||
|
state["job_node"]["callback_idempotency_key"] = params[2]
|
||||||
|
elif sql.startswith("INSERT INTO job_artifact"):
|
||||||
|
state["artifacts"].append({
|
||||||
|
"job_id": params[0],
|
||||||
|
"node_code": params[1],
|
||||||
|
"attempt_no": params[2],
|
||||||
|
"artifact_type": params[3],
|
||||||
|
"storage_path": params[4],
|
||||||
|
"file_hash": params[5],
|
||||||
|
"visible_roles": params[6],
|
||||||
|
"is_masked": params[7],
|
||||||
|
})
|
||||||
|
elif sql.startswith("INSERT INTO job_event"):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
self._result = []
|
||||||
|
|
||||||
|
def fetchone(self):
|
||||||
|
return self._result[0] if self._result else None
|
||||||
|
|
||||||
|
def fetchall(self):
|
||||||
|
if self._result:
|
||||||
|
return list(self._result)
|
||||||
|
return []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _result(self):
|
||||||
|
return getattr(self, "_result_data", [])
|
||||||
|
|
||||||
|
@_result.setter
|
||||||
|
def _result(self, value):
|
||||||
|
self._result_data = value
|
||||||
|
|
||||||
|
class FakeConn:
|
||||||
|
def cursor(self):
|
||||||
|
return FakeCursor()
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "get_conn", lambda: FakeConn())
|
||||||
|
|
||||||
|
headers = {"X-Agent-Token": main.settings.AGENT_TOKEN}
|
||||||
|
done = client.post(
|
||||||
|
"/api/jobs/JOB-1/nodes/CONTRACT_CREATE/complete",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"attemptNo": 1,
|
||||||
|
"executorRunId": "RUN-1",
|
||||||
|
"idempotencyKey": "idem-1",
|
||||||
|
"businessRefs": {"contractSystemNo": "HT-001"},
|
||||||
|
"summary": {"done": True},
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"artifactType": "CONTRACT_PDF",
|
||||||
|
"storagePath": "/data/jobs/JOB-1/contract.pdf",
|
||||||
|
"fileHash": "sha256:abc",
|
||||||
|
"visibleRoles": ["PURCHASER", "PLANNER"],
|
||||||
|
"isMasked": False,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert done.status_code == 200
|
||||||
|
|
||||||
|
package = client.get("/api/jobs/JOB-1/evidence-package")
|
||||||
|
assert package.status_code == 200
|
||||||
|
body = package.json()
|
||||||
|
assert body["artifacts"][0]["artifact_type"] == "CONTRACT_PDF"
|
||||||
|
assert body["artifacts"][0]["storage_path"] == "/data/jobs/JOB-1/contract.pdf"
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_list_api_returns_compact_rows(monkeypatch):
|
||||||
|
_install_fake_pymysql()
|
||||||
|
main = importlib.import_module("state_center.app.main")
|
||||||
|
client = TestClient(main.app)
|
||||||
|
|
||||||
|
class FakeCursor:
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def execute(self, sql, params=None):
|
||||||
|
if "FROM job" in sql:
|
||||||
|
self._result = [{
|
||||||
|
"job_id": "JOB-1",
|
||||||
|
"supplier_name": "重庆亿昊药业",
|
||||||
|
"order_date": "2026-08-04",
|
||||||
|
"source_type": "YYC",
|
||||||
|
"status": "RUNNING",
|
||||||
|
"current_node_code": "PRODUCT_MATCH",
|
||||||
|
"current_role": "PLANNER",
|
||||||
|
"created_at": "2026-08-10 10:00:00",
|
||||||
|
"updated_at": "2026-08-10 10:05:00",
|
||||||
|
"finished_at": None,
|
||||||
|
}]
|
||||||
|
else:
|
||||||
|
self._result = []
|
||||||
|
|
||||||
|
def fetchall(self):
|
||||||
|
return list(self._result)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _result(self):
|
||||||
|
return getattr(self, "_result_data", [])
|
||||||
|
|
||||||
|
@_result.setter
|
||||||
|
def _result(self, value):
|
||||||
|
self._result_data = value
|
||||||
|
|
||||||
|
class FakeConn:
|
||||||
|
def cursor(self):
|
||||||
|
return FakeCursor()
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "get_conn", lambda: FakeConn())
|
||||||
|
|
||||||
|
resp = client.get("/api/jobs", params={"status": "RUNNING"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["items"][0]["job_id"] == "JOB-1"
|
||||||
|
assert body["items"][0]["supplier_name"] == "重庆亿昊药业"
|
||||||
|
assert body["items"][0]["current_node_code"] == "PRODUCT_MATCH"
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_list_api_filters_by_role(monkeypatch):
|
||||||
|
_install_fake_pymysql()
|
||||||
|
main = importlib.import_module("state_center.app.main")
|
||||||
|
client = TestClient(main.app)
|
||||||
|
|
||||||
|
class FakeCursor:
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def execute(self, sql, params=None):
|
||||||
|
if "FROM user_task" in sql:
|
||||||
|
self._result = [{
|
||||||
|
"task_id": "TASK-1",
|
||||||
|
"job_id": "JOB-1",
|
||||||
|
"node_code": "SOURCE_ADD",
|
||||||
|
"role_code": "PURCHASER",
|
||||||
|
"status": "OPEN",
|
||||||
|
"title": "新增货源:重庆亿昊药业",
|
||||||
|
"reason_type": "SOURCE_ADD",
|
||||||
|
"submitted_action": None,
|
||||||
|
"submitted_at": None,
|
||||||
|
"created_at": "2026-08-10 10:00:00",
|
||||||
|
}]
|
||||||
|
else:
|
||||||
|
self._result = []
|
||||||
|
|
||||||
|
def fetchall(self):
|
||||||
|
return list(self._result)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _result(self):
|
||||||
|
return getattr(self, "_result_data", [])
|
||||||
|
|
||||||
|
@_result.setter
|
||||||
|
def _result(self, value):
|
||||||
|
self._result_data = value
|
||||||
|
|
||||||
|
class FakeConn:
|
||||||
|
def cursor(self):
|
||||||
|
return FakeCursor()
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "get_conn", lambda: FakeConn())
|
||||||
|
|
||||||
|
resp = client.get("/api/tasks/my", params={"roleCode": "PURCHASER", "status": "open"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["items"][0]["task_id"] == "TASK-1"
|
||||||
|
assert body["items"][0]["role_code"] == "PURCHASER"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingest_order_items_before_match(monkeypatch):
|
||||||
|
_install_fake_pymysql()
|
||||||
|
main = importlib.import_module("state_center.app.main")
|
||||||
|
client = TestClient(main.app)
|
||||||
|
executed = []
|
||||||
|
|
||||||
|
class FakeCursor:
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def execute(self, sql, params=None):
|
||||||
|
executed.append((sql, params))
|
||||||
|
if "SELECT * FROM job WHERE job_id=%s" in sql:
|
||||||
|
self._result = [{
|
||||||
|
"job_id": "JOB-1",
|
||||||
|
"current_node_code": "ORDER_FETCH",
|
||||||
|
"status": "RUNNING",
|
||||||
|
}]
|
||||||
|
elif "FROM product_match" in sql:
|
||||||
|
self._result = [{"c": 0}]
|
||||||
|
else:
|
||||||
|
self._result = []
|
||||||
|
|
||||||
|
def fetchone(self):
|
||||||
|
return self._result[0] if self._result else None
|
||||||
|
|
||||||
|
class FakeConn:
|
||||||
|
def cursor(self):
|
||||||
|
return FakeCursor()
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "get_conn", lambda: FakeConn())
|
||||||
|
response = client.post(
|
||||||
|
"/api/jobs/JOB-1/items",
|
||||||
|
headers={"X-Agent-Token": main.settings.AGENT_TOKEN},
|
||||||
|
json={
|
||||||
|
"replace": True,
|
||||||
|
"rows": [{
|
||||||
|
"orderNo": "DD-001",
|
||||||
|
"rawProductName": "阿卡波糖片",
|
||||||
|
"platformProductCode": "PT-001",
|
||||||
|
"spec": "50mg*30片",
|
||||||
|
"manufacturer": "示例制药",
|
||||||
|
"approvalNo": "国药准字H00000001",
|
||||||
|
"qty": 12,
|
||||||
|
"unitPrice": 9.8,
|
||||||
|
"sourceRowNo": 2,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"ok": True, "inserted": 1, "order_nos": ["DD-001"]}
|
||||||
|
assert any(sql.startswith("INSERT INTO order_item") for sql, _ in executed)
|
||||||
|
assert any("order_no_list_json" in sql for sql, _ in executed)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingest_order_items_rejects_replace_after_match(monkeypatch):
|
||||||
|
_install_fake_pymysql()
|
||||||
|
main = importlib.import_module("state_center.app.main")
|
||||||
|
client = TestClient(main.app)
|
||||||
|
|
||||||
|
class FakeCursor:
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def execute(self, sql, params=None):
|
||||||
|
if "SELECT * FROM job WHERE job_id=%s" in sql:
|
||||||
|
self._result = [{"job_id": "JOB-1", "current_node_code": "PRODUCT_MATCH"}]
|
||||||
|
elif "FROM product_match" in sql:
|
||||||
|
self._result = [{"c": 1}]
|
||||||
|
else:
|
||||||
|
self._result = []
|
||||||
|
|
||||||
|
def fetchone(self):
|
||||||
|
return self._result[0] if self._result else None
|
||||||
|
|
||||||
|
class FakeConn:
|
||||||
|
def cursor(self):
|
||||||
|
return FakeCursor()
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "get_conn", lambda: FakeConn())
|
||||||
|
response = client.post(
|
||||||
|
"/api/jobs/JOB-1/items",
|
||||||
|
headers={"X-Agent-Token": main.settings.AGENT_TOKEN},
|
||||||
|
json={"replace": True, "rows": []},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 409
|
||||||
|
assert "对码结果" in response.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_fixture_goods_for_local_demo(monkeypatch):
|
||||||
|
_install_fake_pymysql()
|
||||||
|
main = importlib.import_module("state_center.app.main")
|
||||||
|
client = TestClient(main.app)
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.settings, "DEV_MODE", True, raising=False)
|
||||||
|
response = client.post(
|
||||||
|
"/api/dev/match/load-fixture-goods",
|
||||||
|
headers={"X-Agent-Token": main.settings.AGENT_TOKEN},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["loaded"] >= 3
|
||||||
|
assert main._ENGINE_LOADED is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_candidate_requires_candidate_payload(monkeypatch):
|
||||||
|
_install_fake_pymysql()
|
||||||
|
main = importlib.import_module("state_center.app.main")
|
||||||
|
client = TestClient(main.app)
|
||||||
|
|
||||||
|
class FakeCursor:
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def execute(self, sql, params=None):
|
||||||
|
if "FROM user_task WHERE task_id=%s" in sql:
|
||||||
|
self._result = [{
|
||||||
|
"task_id": "TASK-1",
|
||||||
|
"job_id": "JOB-1",
|
||||||
|
"node_code": "PRODUCT_MATCH",
|
||||||
|
"role_code": "PLANNER",
|
||||||
|
"status": "OPEN",
|
||||||
|
"submit_token": None,
|
||||||
|
"product_match_id": 7,
|
||||||
|
}]
|
||||||
|
elif "FROM job WHERE job_id=%s" in sql:
|
||||||
|
self._result = [{
|
||||||
|
"job_id": "JOB-1",
|
||||||
|
"current_node_code": "PRODUCT_MATCH",
|
||||||
|
"status": "WAITING_USER",
|
||||||
|
}]
|
||||||
|
else:
|
||||||
|
self._result = []
|
||||||
|
|
||||||
|
def fetchone(self):
|
||||||
|
return self._result[0] if self._result else None
|
||||||
|
|
||||||
|
class FakeConn:
|
||||||
|
def cursor(self):
|
||||||
|
return FakeCursor()
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "get_conn", lambda: FakeConn())
|
||||||
|
response = client.post(
|
||||||
|
"/api/tasks/TASK-1/submit",
|
||||||
|
headers={"X-Agent-Token": main.settings.AGENT_TOKEN},
|
||||||
|
json={
|
||||||
|
"action": "SELECT_CANDIDATE",
|
||||||
|
"payload": {},
|
||||||
|
"submitToken": "submit-1",
|
||||||
|
"operator": "计划员张三",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json()["detail"] == "选择候选货品时必须提供 productMatchId 和 goodsId"
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
from state_center.app.web_state_machine import build_timeline
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_timeline_marks_skipped_and_waiting_user():
|
||||||
|
job = {
|
||||||
|
"job_id": "JOB-1",
|
||||||
|
"source_type": "YYC",
|
||||||
|
"current_node_code": "SOURCE_ADD",
|
||||||
|
"status": "WAITING_USER",
|
||||||
|
"needs_source_add": 1,
|
||||||
|
}
|
||||||
|
job_nodes = [
|
||||||
|
{"node_code": "ORDER_FETCH", "attempt_no": 1, "status": "SUCCESS"},
|
||||||
|
{"node_code": "PRODUCT_MATCH", "attempt_no": 1, "status": "SUCCESS"},
|
||||||
|
]
|
||||||
|
|
||||||
|
timeline = build_timeline(job, job_nodes)
|
||||||
|
by_code = {item["node_code"]: item for item in timeline}
|
||||||
|
|
||||||
|
assert by_code["ORDER_FETCH"]["display_status"] == "SUCCESS"
|
||||||
|
assert by_code["OCR_EXTRACT"]["display_status"] == "SKIPPED"
|
||||||
|
assert by_code["SOURCE_ADD"]["display_status"] == "WAITING_USER"
|
||||||
|
assert by_code["CONTRACT_CREATE"]["display_status"] == "PENDING"
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
import json
|
||||||
|
|
||||||
|
from rpa_yisaiqi.callback_client import StateCenterClient
|
||||||
|
from rpa_yisaiqi.contract_create_node import (
|
||||||
|
ContractCreateInput,
|
||||||
|
LoginFailed,
|
||||||
|
execute_contract_create,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeHttp:
|
||||||
|
def __init__(self, responses=None):
|
||||||
|
self.requests = []
|
||||||
|
self.responses = list(responses or [{"ok": True}, {"ok": True, "next_node": "CONTRACT_APPROVE_WAIT"}])
|
||||||
|
|
||||||
|
def __call__(self, request, timeout):
|
||||||
|
body = json.loads(request.data.decode("utf-8")) if request.data else None
|
||||||
|
self.requests.append(
|
||||||
|
{
|
||||||
|
"url": request.full_url,
|
||||||
|
"headers": dict(request.header_items()),
|
||||||
|
"body": body,
|
||||||
|
"timeout": timeout,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return FakeResponse(self.responses.pop(0))
|
||||||
|
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
def __init__(self, payload):
|
||||||
|
self.payload = payload
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return json.dumps(self.payload).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_center_client_sends_agent_token_and_json_body():
|
||||||
|
http = FakeHttp([{"ok": True}])
|
||||||
|
client = StateCenterClient("http://state-center:8000/", "dev-agent-token", opener=http)
|
||||||
|
|
||||||
|
result = client.start_node("JOB-1", "CONTRACT_CREATE", attempt_no=2, operator="agent")
|
||||||
|
|
||||||
|
assert result == {"ok": True}
|
||||||
|
assert http.requests[0]["url"] == "http://state-center:8000/api/jobs/JOB-1/nodes/CONTRACT_CREATE/start"
|
||||||
|
assert http.requests[0]["headers"]["X-agent-token"] == "dev-agent-token"
|
||||||
|
assert http.requests[0]["body"] == {
|
||||||
|
"attemptNo": 2,
|
||||||
|
"executorPlatform": "iS-RPA",
|
||||||
|
"operator": "agent",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_center_client_escapes_non_ascii_path_parts():
|
||||||
|
http = FakeHttp([{"ok": True}])
|
||||||
|
client = StateCenterClient("http://state-center:8000", "dev-agent-token", opener=http)
|
||||||
|
|
||||||
|
client.start_node("YYC-合同节点联调-001", "CONTRACT_CREATE")
|
||||||
|
|
||||||
|
assert http.requests[0]["url"] == (
|
||||||
|
"http://state-center:8000/api/jobs/"
|
||||||
|
"YYC-%E5%90%88%E5%90%8C%E8%8A%82%E7%82%B9%E8%81%94%E8%B0%83-001"
|
||||||
|
"/nodes/CONTRACT_CREATE/start"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_contract_create_calls_start_then_complete_with_contract_number():
|
||||||
|
http = FakeHttp()
|
||||||
|
client = StateCenterClient("http://state-center:8000", "dev-agent-token", opener=http)
|
||||||
|
spec = ContractCreateInput(
|
||||||
|
job_id="JOB-1",
|
||||||
|
supplier_name="重庆亿昊药业",
|
||||||
|
order_date="2026-08-11",
|
||||||
|
source_type="YYC",
|
||||||
|
contract_system_no="HT-20260811-001",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = execute_contract_create(spec, client)
|
||||||
|
|
||||||
|
assert result["next_node"] == "CONTRACT_APPROVE_WAIT"
|
||||||
|
assert [request["url"].split("/")[-1] for request in http.requests] == ["start", "complete"]
|
||||||
|
assert http.requests[1]["body"]["businessRefs"] == {"contractSystemNo": "HT-20260811-001"}
|
||||||
|
assert http.requests[1]["body"]["summary"]["supplierName"] == "重庆亿昊药业"
|
||||||
|
assert http.requests[1]["body"]["summary"]["contractDate"] == "2026-08-11"
|
||||||
|
|
||||||
|
|
||||||
|
def test_contract_create_reports_login_failure_to_state_center():
|
||||||
|
http = FakeHttp([{"ok": True}, {"ok": True, "job_status": "FAILED_RETRYABLE"}])
|
||||||
|
client = StateCenterClient("http://state-center:8000", "dev-agent-token", opener=http)
|
||||||
|
spec = ContractCreateInput(
|
||||||
|
job_id="JOB-1",
|
||||||
|
supplier_name="重庆亿昊药业",
|
||||||
|
order_date="2026-08-11",
|
||||||
|
source_type="YYC",
|
||||||
|
contract_system_no="HT-20260811-001",
|
||||||
|
)
|
||||||
|
|
||||||
|
def failing_runner(_spec):
|
||||||
|
raise LoginFailed("合同系统登录失败")
|
||||||
|
|
||||||
|
result = execute_contract_create(spec, client, contract_runner=failing_runner)
|
||||||
|
|
||||||
|
assert result["job_status"] == "FAILED_RETRYABLE"
|
||||||
|
assert http.requests[1]["url"].endswith("/api/jobs/JOB-1/nodes/CONTRACT_CREATE/fail")
|
||||||
|
assert http.requests[1]["body"]["errorCode"] == "SYS_LOGIN_FAIL"
|
||||||
|
assert http.requests[1]["body"]["retryable"] is True
|
||||||
|
assert http.requests[1]["body"]["routeRole"] == "OPS"
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from rpa_yisaiqi.contract_create_node import (
|
||||||
|
ContractCreateError,
|
||||||
|
ContractCreateInput,
|
||||||
|
PdfMergeUnsupported,
|
||||||
|
create_contract_in_contract_system,
|
||||||
|
)
|
||||||
|
from rpa_yisaiqi.contract_draft import load_contract_draft
|
||||||
|
from rpa_yisaiqi.pdf_utils import ensure_merged_pdf
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_contract_draft_maps_full_contract_inputs(tmp_path):
|
||||||
|
draft_path = tmp_path / "contract-draft.json"
|
||||||
|
draft_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"supplierName": "重庆亿昊药业",
|
||||||
|
"orderDate": "2026-08-11",
|
||||||
|
"sourceType": "YYC",
|
||||||
|
"templateType": "NON_FORMAT",
|
||||||
|
"templateName": "非格式--采购协议",
|
||||||
|
"customerCode": "CUST-001",
|
||||||
|
"customerName": "重庆亿昊药业",
|
||||||
|
"contractCategory": "采购协议",
|
||||||
|
"settlementType": "付款",
|
||||||
|
"contractBackground": "按订单自动起草采购协议",
|
||||||
|
"contractNature": "主合同",
|
||||||
|
"amountCurrency": "人民币",
|
||||||
|
"amount": "12000.50",
|
||||||
|
"contractEndDate": "2026-12-31",
|
||||||
|
"isMajorContract": "否",
|
||||||
|
"isPrepayment": "否",
|
||||||
|
"belongYear": "2026",
|
||||||
|
"paymentMethod": "承兑",
|
||||||
|
"paymentDays": "45",
|
||||||
|
"contractStatus": "正式合同",
|
||||||
|
"containsBloodProducts": "否",
|
||||||
|
"platformPdfPaths": ["C:/jobs/JOB-1/a.pdf", "C:/jobs/JOB-1/b.pdf"],
|
||||||
|
"goodsLines": [
|
||||||
|
{
|
||||||
|
"operationCode": "ADD",
|
||||||
|
"goodsCode": "YP-001",
|
||||||
|
"goodsName": "阿卡波糖片",
|
||||||
|
"approvalNo": "国药准字H00000001",
|
||||||
|
"spec": "50mg*30片",
|
||||||
|
"unit": "盒",
|
||||||
|
"quantity": 12,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
draft = load_contract_draft(draft_path)
|
||||||
|
|
||||||
|
assert draft.supplier_name == "重庆亿昊药业"
|
||||||
|
assert draft.template_type == "NON_FORMAT"
|
||||||
|
assert draft.template_name == "非格式--采购协议"
|
||||||
|
assert draft.customer_code == "CUST-001"
|
||||||
|
assert draft.customer_name == "重庆亿昊药业"
|
||||||
|
assert draft.contract_category == "采购协议"
|
||||||
|
assert draft.settlement_type == "付款"
|
||||||
|
assert draft.contract_background == "按订单自动起草采购协议"
|
||||||
|
assert draft.amount == "12000.50"
|
||||||
|
assert draft.contract_end_date == "2026-12-31"
|
||||||
|
assert draft.belong_year == "2026"
|
||||||
|
assert draft.payment_method == "承兑"
|
||||||
|
assert draft.contract_status == "正式合同"
|
||||||
|
assert draft.contains_blood_products == "否"
|
||||||
|
assert draft.platform_pdf_paths == ["C:/jobs/JOB-1/a.pdf", "C:/jobs/JOB-1/b.pdf"]
|
||||||
|
assert draft.goods_lines[0].goods_code == "YP-001"
|
||||||
|
|
||||||
|
|
||||||
|
def test_yyc_requires_pdf_inputs_before_contract_drafting(tmp_path):
|
||||||
|
draft_path = tmp_path / "draft.json"
|
||||||
|
draft_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"supplierName": "重庆亿昊药业",
|
||||||
|
"orderDate": "2026-08-11",
|
||||||
|
"sourceType": "YYC",
|
||||||
|
"goodsLines": [],
|
||||||
|
"platformPdfPaths": [],
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
spec = ContractCreateInput(
|
||||||
|
job_id="JOB-1",
|
||||||
|
supplier_name="重庆亿昊药业",
|
||||||
|
order_date="2026-08-11",
|
||||||
|
source_type="YYC",
|
||||||
|
draft_path=str(draft_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(PdfMergeUnsupported):
|
||||||
|
create_contract_in_contract_system(spec)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_merged_pdf_uses_single_pdf_without_merge(tmp_path):
|
||||||
|
source = tmp_path / "one.pdf"
|
||||||
|
source.write_bytes(b"%PDF-1.4\n%one\n")
|
||||||
|
|
||||||
|
assert ensure_merged_pdf([str(source)], str(tmp_path / "merged.pdf")) == str(source)
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_drafting_requires_yisaiqi_adapter_mapping(tmp_path):
|
||||||
|
draft_path = tmp_path / "draft.json"
|
||||||
|
draft_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"supplierName": "小药药供应商",
|
||||||
|
"orderDate": "2026-08-11",
|
||||||
|
"sourceType": "XYY",
|
||||||
|
"goodsLines": [],
|
||||||
|
"platformPdfPaths": [],
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
spec = ContractCreateInput(
|
||||||
|
job_id="JOB-1",
|
||||||
|
supplier_name="小药药供应商",
|
||||||
|
order_date="2026-08-11",
|
||||||
|
source_type="XYY",
|
||||||
|
draft_path=str(draft_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ContractCreateError, match="YiSaiQiBrowserAdapter"):
|
||||||
|
create_contract_in_contract_system(spec)
|
||||||
|
|
@ -0,0 +1,215 @@
|
||||||
|
from rpa_yisaiqi.contract_flow_steps import (
|
||||||
|
ContractFlowContext,
|
||||||
|
ContractGoodsLine,
|
||||||
|
run_contract_create_flow,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeBrowser:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
self.read_values = {
|
||||||
|
"contract_number": "HT-20260811-REAL-001",
|
||||||
|
}
|
||||||
|
|
||||||
|
def open_url(self, url):
|
||||||
|
self.calls.append(("open_url", url))
|
||||||
|
|
||||||
|
def fill_first(self, selectors, value):
|
||||||
|
self.calls.append(("fill_first", tuple(selectors), value))
|
||||||
|
|
||||||
|
def click_first(self, selectors):
|
||||||
|
self.calls.append(("click_first", tuple(selectors)))
|
||||||
|
|
||||||
|
def wait_for_text(self, text, timeout_seconds=15):
|
||||||
|
self.calls.append(("wait_for_text", text, timeout_seconds))
|
||||||
|
|
||||||
|
def select_first(self, selectors, value):
|
||||||
|
self.calls.append(("select_first", tuple(selectors), value))
|
||||||
|
|
||||||
|
def upload_file_first(self, selectors, file_path):
|
||||||
|
self.calls.append(("upload_file_first", tuple(selectors), file_path))
|
||||||
|
|
||||||
|
def read_first(self, selectors, value_name):
|
||||||
|
self.calls.append(("read_first", tuple(selectors), value_name))
|
||||||
|
return self.read_values[value_name]
|
||||||
|
|
||||||
|
def screenshot(self, name):
|
||||||
|
self.calls.append(("screenshot", name))
|
||||||
|
return f"/tmp/{name}.png"
|
||||||
|
|
||||||
|
|
||||||
|
def test_contract_flow_logs_in_with_real_contract_login_selectors():
|
||||||
|
browser = FakeBrowser()
|
||||||
|
ctx = ContractFlowContext(
|
||||||
|
contract_url="http://contract-test.gzmpc.com:8080/",
|
||||||
|
username="test002",
|
||||||
|
password="1",
|
||||||
|
supplier_name="合同节点联调供应商",
|
||||||
|
order_date="2026-08-11",
|
||||||
|
source_type="XYY",
|
||||||
|
template_type="FORMAT",
|
||||||
|
goods_lines=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
run_contract_create_flow(browser, ctx)
|
||||||
|
|
||||||
|
assert browser.calls[:5] == [
|
||||||
|
("open_url", "http://contract-test.gzmpc.com:8080/"),
|
||||||
|
("fill_first", ('input[name="j_username"]', 'input[placeholder="用户名"]'), "test002"),
|
||||||
|
("fill_first", ('input[name="j_password"]', 'input[placeholder="密码"]'), "1"),
|
||||||
|
("click_first", ('input[name="btn_submit"]', 'a[href*="btn_submit"]', 'text=登录')),
|
||||||
|
("wait_for_text", "合同", 20),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_contract_flow_uses_order_date_and_uploads_yyc_merged_pdf():
|
||||||
|
browser = FakeBrowser()
|
||||||
|
ctx = ContractFlowContext(
|
||||||
|
contract_url="http://contract-test.gzmpc.com:8080/",
|
||||||
|
username="test002",
|
||||||
|
password="1",
|
||||||
|
supplier_name="合同节点联调供应商",
|
||||||
|
order_date="2026-08-11",
|
||||||
|
source_type="YYC",
|
||||||
|
template_type="FORMAT",
|
||||||
|
goods_lines=[
|
||||||
|
ContractGoodsLine(
|
||||||
|
operation_code="ADD",
|
||||||
|
goods_code="YP-001",
|
||||||
|
goods_name="阿卡波糖片",
|
||||||
|
approval_no="国药准字H00000001",
|
||||||
|
spec="50mg*30片",
|
||||||
|
unit="盒",
|
||||||
|
quantity=12,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
merged_pdf_path="C:/rpa/jobs/JOB-1/contract-merged.pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
contract_no = run_contract_create_flow(browser, ctx)
|
||||||
|
|
||||||
|
assert contract_no == "HT-20260811-REAL-001"
|
||||||
|
assert ("fill_first", ('label=合同签订日期', 'input[name="fdSignDate"]', 'input[placeholder*="合同签订"]'), "2026-08-11") in browser.calls
|
||||||
|
assert ("fill_first", ('label=合同开始日期', 'input[name="fdStartDate"]', 'input[placeholder*="合同开始"]'), "2026-08-11") in browser.calls
|
||||||
|
assert ("upload_file_first", ('input[type="file"]', 'label=合同证文上传'), "C:/rpa/jobs/JOB-1/contract-merged.pdf") in browser.calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_contract_flow_skips_pdf_upload_for_xyy():
|
||||||
|
browser = FakeBrowser()
|
||||||
|
ctx = ContractFlowContext(
|
||||||
|
contract_url="http://contract-test.gzmpc.com:8080/",
|
||||||
|
username="test002",
|
||||||
|
password="1",
|
||||||
|
supplier_name="小药药供应商",
|
||||||
|
order_date="2026-08-11",
|
||||||
|
source_type="XYY",
|
||||||
|
template_type="FORMAT",
|
||||||
|
goods_lines=[],
|
||||||
|
merged_pdf_path="C:/should-not-upload.pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
run_contract_create_flow(browser, ctx)
|
||||||
|
|
||||||
|
assert not [call for call in browser.calls if call[0] == "upload_file_first"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_contract_flow_enters_real_draft_menu_and_recent_template():
|
||||||
|
browser = FakeBrowser()
|
||||||
|
ctx = ContractFlowContext(
|
||||||
|
contract_url="http://contract-test.gzmpc.com:8080/",
|
||||||
|
username="test002",
|
||||||
|
password="1",
|
||||||
|
supplier_name="重庆亿昊药业",
|
||||||
|
order_date="2026-08-11",
|
||||||
|
source_type="YYC",
|
||||||
|
template_type="NON_FORMAT",
|
||||||
|
template_name="非格式--采购协议",
|
||||||
|
goods_lines=[],
|
||||||
|
merged_pdf_path="C:/rpa/jobs/JOB-1/contract-merged.pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
run_contract_create_flow(browser, ctx)
|
||||||
|
|
||||||
|
assert ("click_first", ("text=合同管理", "aria=合同管理")) in browser.calls
|
||||||
|
assert ("click_first", ("text=合同起草", "aria=合同起草")) in browser.calls
|
||||||
|
assert ("wait_for_text", "最近使用模板", 20) in browser.calls
|
||||||
|
assert (
|
||||||
|
"click_first",
|
||||||
|
("text=非格式--采购协议", "text=非格式--采购协议...", "title=非格式--采购协议"),
|
||||||
|
) in browser.calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_contract_flow_fills_real_contract_draft_form_fields_from_screenshot():
|
||||||
|
browser = FakeBrowser()
|
||||||
|
ctx = ContractFlowContext(
|
||||||
|
contract_url="http://contract-test.gzmpc.com:8080/",
|
||||||
|
username="test002",
|
||||||
|
password="1",
|
||||||
|
supplier_name="重庆亿昊药业",
|
||||||
|
order_date="2026-08-11",
|
||||||
|
source_type="XYY",
|
||||||
|
template_type="NON_FORMAT",
|
||||||
|
template_name="非格式--采购协议",
|
||||||
|
customer_code="CUST-001",
|
||||||
|
customer_name="重庆亿昊药业",
|
||||||
|
contract_category="采购协议",
|
||||||
|
settlement_type="付款",
|
||||||
|
contract_background="按订单自动起草采购协议",
|
||||||
|
contract_nature="主合同",
|
||||||
|
amount_currency="人民币",
|
||||||
|
amount="12000.50",
|
||||||
|
contract_end_date="2026-12-31",
|
||||||
|
is_major_contract="否",
|
||||||
|
is_prepayment="否",
|
||||||
|
belong_year="2026",
|
||||||
|
payment_method="账期支付",
|
||||||
|
payment_days="30",
|
||||||
|
contract_status="正式合同",
|
||||||
|
contains_blood_products="否",
|
||||||
|
goods_lines=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
run_contract_create_flow(browser, ctx)
|
||||||
|
|
||||||
|
assert ("fill_first", ('label=客户编码', 'input[name="fdCustomerCode"]', 'input[placeholder*="客户编码"]'), "CUST-001") in browser.calls
|
||||||
|
assert ("fill_first", ('label=客户名称', 'input[name="fdCustomerName"]', 'input[placeholder*="客户名称"]'), "重庆亿昊药业") in browser.calls
|
||||||
|
assert ("select_first", ('label=合同类别', 'select[name="fdContractCategory"]'), "采购协议") in browser.calls
|
||||||
|
assert ("select_first", ('label=结算类型', 'select[name="fdSettlementType"]'), "付款") in browser.calls
|
||||||
|
assert ("fill_first", ('label=合同背景概述', 'textarea[name="fdContractBackground"]'), "按订单自动起草采购协议") in browser.calls
|
||||||
|
assert ("click_first", ("label=主合同", "text=主合同")) in browser.calls
|
||||||
|
assert ("select_first", ('label=合同金额', 'select[name="fdCurrency"]'), "人民币") in browser.calls
|
||||||
|
assert ("fill_first", ('label=金额(元)', 'input[name="fdAmount"]'), "12000.50") in browser.calls
|
||||||
|
assert ("fill_first", ('label=合同签订日期', 'input[name="fdSignDate"]', 'input[placeholder*="合同签订"]'), "2026-08-11") in browser.calls
|
||||||
|
assert ("fill_first", ('label=合同开始日期', 'input[name="fdStartDate"]', 'input[placeholder*="合同开始"]'), "2026-08-11") in browser.calls
|
||||||
|
assert ("fill_first", ('label=合同结束日期', 'input[name="fdEndDate"]', 'input[placeholder*="合同结束"]'), "2026-12-31") in browser.calls
|
||||||
|
assert ("select_first", ('label=是否重大合同', 'select[name="fdIsMajorContract"]'), "否") in browser.calls
|
||||||
|
assert ("select_first", ('label=是否预付款', 'select[name="fdIsPrepayment"]'), "否") in browser.calls
|
||||||
|
assert ("fill_first", ('label=合同归属年份', 'input[name="fdBelongYear"]'), "2026") in browser.calls
|
||||||
|
assert ("select_first", ('label=付款方式', 'select[name="fdPaymentMethod"]', 'select[name="paymentMethod"]'), "账期支付") in browser.calls
|
||||||
|
assert ("fill_first", ('label=付款期限', 'input[name="fdPaymentDays"]', 'input[name="paymentDays"]'), "30") in browser.calls
|
||||||
|
assert ("select_first", ('label=合同状态', 'select[name="fdContractStatus"]'), "正式合同") in browser.calls
|
||||||
|
assert ("select_first", ('label=是否包含血液制品', 'select[name="fdContainsBloodProducts"]'), "否") in browser.calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_contract_flow_dry_run_skips_submit_but_still_reads_page_state():
|
||||||
|
browser = FakeBrowser()
|
||||||
|
ctx = ContractFlowContext(
|
||||||
|
contract_url="http://contract-test.gzmpc.com:8080/",
|
||||||
|
username="test002",
|
||||||
|
password="1",
|
||||||
|
supplier_name="重庆亿昊药业",
|
||||||
|
order_date="2026-08-11",
|
||||||
|
source_type="XYY",
|
||||||
|
template_type="NON_FORMAT",
|
||||||
|
template_name="非格式--采购协议",
|
||||||
|
submit_mode="dry_run",
|
||||||
|
goods_lines=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
contract_no = run_contract_create_flow(browser, ctx)
|
||||||
|
|
||||||
|
assert not [call for call in browser.calls if call[0] == "click_first" and "text=提交" in call[1]]
|
||||||
|
assert ("screenshot", "contract-dry-run-ready") in browser.calls
|
||||||
|
assert contract_no == ""
|
||||||
|
assert not [call for call in browser.calls if call[0] == "read_first"]
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
# 给 Trae 的开发交接说明
|
||||||
|
|
||||||
|
> 你(Trae)接手"采购主链RPA一期 · 状态中心"的后续开发。本文是你的入口,先读这份,再读下面点名的文件。写代码前把"硬规则"和"你不做什么"看完,别越界。
|
||||||
|
|
||||||
|
## 一、你要干什么(一句话)
|
||||||
|
|
||||||
|
做**状态中心**:一个 Python(FastAPI)+ MySQL 的后台 + 配套网页。它负责"每一单跑到哪步、出错派给谁、存状态和证据、画步骤树状图给业务看"。它**自己不去点任何外部系统页面**——点页面是艺赛旗 RPA 的活,通过 HTTP 回调跟你交互。
|
||||||
|
|
||||||
|
## 二、先读这几个文件(按顺序)
|
||||||
|
|
||||||
|
1. `资料/采购主链RPA一期需求文档_三方会审版.md` —— 业务规则(节点N01~N10、字段、异常)。
|
||||||
|
2. `资料/采购主链RPA一期设计文档.md` —— 技术设计(表结构、状态机、接口契约、对码引擎)。
|
||||||
|
3. `资料/采购主链RPA一期实施与验收规范.md` —— **每个节点的操作步骤 + 验收标准(代码+业务两栏)**,你做完对着这里勾。
|
||||||
|
4. `state_center/README.md` —— 已有代码怎么起、怎么验。
|
||||||
|
|
||||||
|
## 三、技术栈(已定,别改)
|
||||||
|
|
||||||
|
- 后台:Python 3 + FastAPI
|
||||||
|
- 库:MySQL 8(状态中心自己的库,建库脚本 `sql/schema.sql` 已给)
|
||||||
|
- 对码货品主数据:在 **Oracle**,只读直连查,**不要复制成本地表**
|
||||||
|
- OCR:本地 Python 服务(PaddleOCR),仅线下手工单用
|
||||||
|
- 前端:Vue 或 React(待办页 + 步骤树状图 + 整单查询)
|
||||||
|
- RPA 下层:艺赛旗 iS-RPA(不是你做,你只跟它的 HTTP 回调对接)
|
||||||
|
|
||||||
|
## 四、已经写好的(别重写,在这上面接着做)
|
||||||
|
|
||||||
|
`state_center/` 里已有可跑的第1步骨架:
|
||||||
|
|
||||||
|
| 文件 | 已实现 | 状态 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `sql/schema.sql` | 8张表建库脚本 | 完成 |
|
||||||
|
| `app/config.py` | MySQL/Oracle 连接、Oracle表名列名占位 | 完成 |
|
||||||
|
| `app/db.py` | MySQL 访问(pymysql) | 完成 |
|
||||||
|
| `app/match_engine.py` | 对码引擎:两级命中+规格标准化+Oracle取数函数 | 完成,单测6/6过 |
|
||||||
|
| `app/main.py` | 建单 + start/complete/fail 三回调(含幂等/乱序拒绝)+ 合同审批等待 + 整单查询 | 骨架完成 |
|
||||||
|
| `tests/test_match_engine.py` | 对码单测 | 通过 |
|
||||||
|
|
||||||
|
> 状态机流转已通(**13 节点 = 11 主 + 2 条件** 显式转移表、业务编号回写、回调幂等、乱序拒绝 409、合同审批等待态、M5质管缺失→FAILED_MANUAL、M5不自动保存),用假回调能把一个 job 从头走到尾。
|
||||||
|
>
|
||||||
|
> 节点数口径:主链 11 步每单都走;`OCR_EXTRACT`(仅线下单)、`SOURCE_ADD`(仅缺货源)是条件节点,不满足自动跳过。代码 `NODE_ORDER` 列全 13 个,`TRANSITIONS` 定转移,`NODE_EXECUTOR` 标执行方(STATE_CENTER/RPA/HUMAN)。
|
||||||
|
|
||||||
|
## 五、你的任务清单(按顺序做,每步对着验收规范勾)
|
||||||
|
|
||||||
|
### 任务1:把骨架在真 MySQL 上跑通
|
||||||
|
- 建库、起服务,按 README 的 curl 把一个 job 用假回调走完主链(线上单会自动跳过 OCR_EXTRACT/SOURCE_ADD 条件节点)。
|
||||||
|
- 验收:`GET /api/jobs/{id}` 能看到完整节点轨迹和状态。
|
||||||
|
|
||||||
|
### 任务2:对码接口 + Oracle 真连
|
||||||
|
- 拿到 Oracle 连接串/表名列名后,填 `config.py`,`pip install oracledb`。
|
||||||
|
- **骨架已写好**(`main.py`),你主要是接真数据、补前置:
|
||||||
|
- `POST /api/match/reload-goods`:开工从 Oracle 全量拉货品主数据装进内存引擎(依赖 ORACLE_* 配置)。
|
||||||
|
- `POST /api/jobs/{id}/match/run`:对该 job 的 `order_item` 跑对码 → 落 `product_match`;MULTI/NONE 建 `user_task` 转 WAITING_USER,全命中推进下一节点。**前置:`order_item` 要先落库**(建单/ORDER_FETCH 后把明细写进来),否则 run 会 400。
|
||||||
|
- `POST /api/match/{matchId}/confirm`:人工确认一条,回写 product_match + 沉淀 `mapping_config` 累积别名(下次自动 ALIAS 命中),全部解决后自动推进。
|
||||||
|
- 已补齐的关键边界:
|
||||||
|
- 对码重跑幂等:`product_match(job_id,order_item_id)` 当前态唯一,`match/run` upsert 结果并重建 OPEN 待办。
|
||||||
|
- 货源判定:`goods_id` 明确后,按历史 `supplier_name + goods_id` 判断是否已有货源;缺货源会写 `product_match.source_add_needed/job.needs_source_add` 并创建 `SOURCE_ADD` 聚合待办。
|
||||||
|
- alias 沉淀:同一 `config_key` 先失效旧 active,再插新 active;装载时按固定顺序取当前结果。
|
||||||
|
- 你要补的:① 订单明细落 `order_item` 的入口;② `GET /api/jobs/{id}/match/unmatched`(列未命中给前端);③ H5 待办提交入口(把 `SOURCE_ADD` 完成动作接到 `nodes/SOURCE_ADD/complete`)。
|
||||||
|
- **只写 mapping_config 累积别名,绝不回写 Oracle 主数据**(主数据只读)。
|
||||||
|
- 验收:喂真实订单,命中率有数字,未命中进待办,确认后能自动往下走。
|
||||||
|
|
||||||
|
### 任务3:待办 + 人工提交
|
||||||
|
- `GET /api/tasks/my?roleCode=&status=open`、`POST /api/tasks/{id}/submit`。
|
||||||
|
- 动作:SELECT_CANDIDATE(对码选候选)/CONFIRM_AND_CONTINUE/RETRY_CURRENT_NODE/TRANSFER_MANUAL/TERMINATE_JOB。
|
||||||
|
- 幂等:task_id + submit_token 防重复提交。
|
||||||
|
|
||||||
|
### 任务4:前端网页
|
||||||
|
- 步骤树状图(样式见实施规范第三部分)、待办页、整单查询页。
|
||||||
|
- 验收:业务打开能看到每单跑到哪、卡在哪、去处理按钮跳待办。
|
||||||
|
|
||||||
|
### 任务5:证据 + 事件流 + 企业微信
|
||||||
|
- 证据落盘(job_artifact),事件流已有基础(job_event)。
|
||||||
|
- 企业微信推待办、H5 身份换 JWT。
|
||||||
|
|
||||||
|
### 任务6:调度与重试
|
||||||
|
- 定时触发建单(跑前一天订单)、系统类错误自动重试1-2次、CONTRACT_APPROVE_WAIT 轮询(别死等)、超时升级。
|
||||||
|
|
||||||
|
## 六、硬规则(红线,违反就是错的)
|
||||||
|
|
||||||
|
1. **对码表在 Oracle,只读,绝不复制成本地表、绝不回写 Oracle**。累积别名写自己的 mapping_config。
|
||||||
|
2. **M5 质管资料缺失(errorCode=M5_QC_MISSING)→ 不重试、不等质管,直接 FAILED_MANUAL 转人工**。已在 main.py 实现,别改成重试。
|
||||||
|
3. **M5 一期不自动保存/签订**(auto_save_enabled 默认0),做成开关,阶段二才放开。别默认自动。
|
||||||
|
4. **关键送审在返利中心(REBATE_SUBMIT),不是合同系统**。不送审 M5 带不出协议,是硬阻断。
|
||||||
|
5. **签约日期 = 订单日期**(不是今天),合同建单时按订单日期填。
|
||||||
|
6. **医药城同供应商同日合同合并成一个PDF**再上传(传第二份会覆盖),小药药不上传。
|
||||||
|
7. **返利细单备注必填订单号、特定补偿必填0**。
|
||||||
|
8. **对码未命中/返利重复编码 = 单条挂起,整批继续**;不是停整批。
|
||||||
|
9. **所有写接口必须鉴权**;RPA 回调校验 X-Agent-Token + 幂等键,禁止无认证暴露。
|
||||||
|
10. 节点失败不覆盖历史,attempt_no+1 新建 job_node 行。
|
||||||
|
|
||||||
|
## 七、你不做什么(边界)
|
||||||
|
|
||||||
|
- **不写点外部系统页面的代码**(登录/导Excel/建合同/M5录入)——那是艺赛旗,你只收它的 HTTP 回调。
|
||||||
|
- 不做招财平台(一期不纳入)。
|
||||||
|
- 不做加单/拆单全自动重做(先记录父订单号,转人工)。
|
||||||
|
- 不做返利重复编码全自动重建(规则未定,先转人工)。
|
||||||
|
- 遇到文档里标 `【待确认】` 的(订单状态过滤、OCR部署方式等),别自己拍板,先留 TODO 问项目负责人。
|
||||||
|
|
||||||
|
## 八、回调契约(艺赛旗会这样调你,已实现)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/jobs/{jobId}/nodes/{nodeCode}/start header: X-Agent-Token
|
||||||
|
POST /api/jobs/{jobId}/nodes/{nodeCode}/complete 带 businessRefs/summary
|
||||||
|
POST /api/jobs/{jobId}/nodes/{nodeCode}/fail 带 errorCode/retryable/routeRole
|
||||||
|
```
|
||||||
|
字段细节看 `设计文档` 第六部分和 `main.py` 里的 Pydantic 模型。
|
||||||
|
|
||||||
|
## 九、验收总纲
|
||||||
|
|
||||||
|
每做完一个节点/接口,打开 `实施与验收规范` 第四部分,对着该节点的**代码验收**(你自检)和**业务验收**(交业务勾)两栏逐条过。全绿才算完。
|
||||||
|
|
@ -0,0 +1,197 @@
|
||||||
|
# 给艺赛旗(iS-RPA)的开发交接说明
|
||||||
|
|
||||||
|
> 你(艺赛旗开发)负责"采购主链RPA一期"里**点外部系统页面**的活:登录、导订单、建合同、返利中心操作、M5 录单。每做完一步,用 HTTP 回调告诉"状态中心"结果。状态中心是大脑(记每单跑到哪、派活、存证据),你是手脚(照它给的顺序点页面)。你俩**只通过 HTTP JSON 回调对接,不共享数据库**。
|
||||||
|
|
||||||
|
## 一、你要干什么(一句话)
|
||||||
|
|
||||||
|
按状态中心下发的 job(一个供应商+一个订单日期=一条合同),在各外部系统页面上完成对应节点的操作,每个节点用三个回调(start / complete / fail)把状态回传。
|
||||||
|
|
||||||
|
## 二、你和状态中心怎么对接(核心)
|
||||||
|
|
||||||
|
```
|
||||||
|
状态中心(Python/FastAPI) 艺赛旗 iS-RPA
|
||||||
|
记状态/派活/存证据 <--HTTP--> 点页面/填表/上传
|
||||||
|
| |
|
||||||
|
| 1) 你开始一个节点: POST .../start |
|
||||||
|
| 2) 点完页面成功: POST .../complete |
|
||||||
|
| 3) 出错: POST .../fail |
|
||||||
|
```
|
||||||
|
|
||||||
|
- **状态中心不会调用你**(一期没有反向下发);是**你主动调它**三个回调。
|
||||||
|
- 每次调用**必须带 header `X-Agent-Token`**(默认 `dev-agent-token`,上线换正式值,找状态中心开发要)。
|
||||||
|
- 节点顺序由状态中心维护,你 complete 后它自己算下一个节点是谁并返回 `next_node`,你**照着它返回的 next_node 跑下一步**,不要自己写死顺序。
|
||||||
|
|
||||||
|
## 三、三个回调契约(照抄字段,别改名)
|
||||||
|
|
||||||
|
基础地址:`http://<状态中心地址>:8000`(联调时给你实际 IP)。所有回调 header 带 `X-Agent-Token: <token>`、`Content-Type: application/json`。
|
||||||
|
|
||||||
|
### 3.1 节点开始 start
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/jobs/{jobId}/nodes/{nodeCode}/start
|
||||||
|
{
|
||||||
|
"attemptNo": 1, // 第几次尝试,重试就 +1
|
||||||
|
"executorPlatform": "iS-RPA", // 可选,标记是哪个RPA平台跑的
|
||||||
|
"operator": "agent" // 可选
|
||||||
|
}
|
||||||
|
```
|
||||||
|
返回 `{"ok": true}`。**开始点页面前先调一次**,让状态中心把该节点标 RUNNING。
|
||||||
|
|
||||||
|
### 3.2 节点成功 complete
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/jobs/{jobId}/nodes/{nodeCode}/complete
|
||||||
|
{
|
||||||
|
"attemptNo": 1,
|
||||||
|
"executorRunId": "YD-RUN-001", // 可选,你这边的流程运行号,便于回查
|
||||||
|
"businessRefs": { // 业务编号回写(见下表,产出编号的节点必传)
|
||||||
|
"contractSystemNo": "HT2026..."
|
||||||
|
},
|
||||||
|
"summary": { "orderCount": 12 } // 可选,给业务看的摘要
|
||||||
|
}
|
||||||
|
```
|
||||||
|
返回 `{"ok": true, "next_node": "OCR_EXTRACT"}`。**拿到 next_node 跑下一节点**;`next_node=null` 表示整单跑完。
|
||||||
|
|
||||||
|
### 3.3 节点失败 fail
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/jobs/{jobId}/nodes/{nodeCode}/fail
|
||||||
|
{
|
||||||
|
"attemptNo": 1,
|
||||||
|
"errorCode": "SYS_LOGIN_FAIL", // 错误码,见第五节各节点表,照抄
|
||||||
|
"errorSummary": "验证码识别失败", // 人能看懂的一句话
|
||||||
|
"retryable": true, // 系统类错误填 true(状态中心可安排重试);业务类填 false
|
||||||
|
"routeRole": "OPS" // 转给谁:OPS(运维)/PLANNER(计划员)/PURCHASE(采购)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
返回 `{"ok": true, "job_status": "..."}`。
|
||||||
|
|
||||||
|
**状态中心对 fail 的处理逻辑(你要知道,免得填错)**:
|
||||||
|
- `errorCode == "M5_QC_MISSING"` → 无条件 `FAILED_MANUAL`(转人工手工做,**不重试、不等质管**),`retryable` 填什么都没用。
|
||||||
|
- 否则 `retryable=true` → `FAILED_RETRYABLE`(系统类,等重试)。
|
||||||
|
- 否则 → `WAITING_USER`(业务类,进人工待办,按 `routeRole` 派人)。
|
||||||
|
|
||||||
|
> 判断原则:**系统能自愈的填 retryable=true**(登录超时、页面卡顿);**要人来决定的填 false**(对码要选、合同被禁、返利重复编码)。
|
||||||
|
|
||||||
|
## 四、businessRefs 业务编号回写(三把钥匙 + 收货单)
|
||||||
|
|
||||||
|
有三个节点**必须**在 complete 时回传对应编号,否则状态中心返回 400 拒绝记成功:
|
||||||
|
|
||||||
|
| 节点 nodeCode | 必传字段 | 是什么 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| CONTRACT_CREATE | `contractSystemNo` | 🔑合同编号(后续返利、M5 靠它串) |
|
||||||
|
| REBATE_HEADER | `rebateOrderNo` | 返利中心总单号 |
|
||||||
|
| M5_RECEIPT_NOTICE | `receiptNoticeNo` | 收货通知单号(全链终点) |
|
||||||
|
|
||||||
|
其余节点若产出编号也可回传(如 M5 单号 `m5OrderNo`),状态中心会落库。字段名**照抄**,大小写别改。
|
||||||
|
|
||||||
|
## 五、逐节点你要做什么(页面操作 + 回调)
|
||||||
|
|
||||||
|
> 完整版看 `资料/采购主链RPA一期实施与验收规范.md` 第四部分,每节点都有"页面操作步骤 + 代码验收 + 业务验收 + 失败去向"。这里给精简对照,让你知道每步调哪个回调、错误码填什么。
|
||||||
|
|
||||||
|
节点编排一共 **13 个 = 11 主节点 + 2 条件节点**(状态中心的 NODE_ORDER):
|
||||||
|
`ORDER_FETCH → OCR_EXTRACT → PRODUCT_MATCH → SOURCE_ADD → CONTRACT_CREATE → CONTRACT_APPROVE_WAIT → REBATE_HEADER → REBATE_IMPORT → REBATE_SUBMIT → M5_CREATE_ORDER → M5_MANUAL_CONFIRM → M5_RECEIPT_NOTICE → ARCHIVE_JOB`
|
||||||
|
|
||||||
|
- **2 个条件节点会被自动跳过**(你收不到 next_node 就是被跳了,别当 bug):
|
||||||
|
- `OCR_EXTRACT`:只有线下手工单才走,线上单(医药城/小药药)自动跳过。
|
||||||
|
- `SOURCE_ADD`:只有对码判定缺货源才走,货源齐全自动跳过。
|
||||||
|
- **不是你点页面的节点**:`PRODUCT_MATCH`(对码=状态中心内部算)、`SOURCE_ADD`/`M5_MANUAL_CONFIRM`/`M5_RECEIPT_NOTICE`(一期人工)、`ARCHIVE_JOB`(状态中心归档)。
|
||||||
|
- 你负责的是下面标"★你的活"的节点。**顺序永远听 complete 返回的 next_node**,别自己写死(条件节点跳过就是状态中心帮你算好的)。
|
||||||
|
|
||||||
|
> 状态中心对回调做了**幂等 + 乱序拒绝**:
|
||||||
|
> - 同一节点同 attemptNo 重复 complete,不会二次推进(会返回上次的 next_node,带 `"idempotent": true`),网络重发放心。
|
||||||
|
> - 回调的节点若不是该 job 的当前节点,返回 **409**(乱序被拒)。别跳着节点调,照 next_node 走。
|
||||||
|
> - complete 可带 `idempotencyKey`(你生成的唯一串),便于状态中心去重。
|
||||||
|
|
||||||
|
### ① ORDER_FETCH · 订单获取 ★你的活
|
||||||
|
|
||||||
|
页面(医药城 / 小药药):登录 → 我的订单 → 选日期(默认前一天)→【待确认:按订单状态过滤】→ 导出订单明细 Excel → 存证据目录 + 截图。
|
||||||
|
- complete:`summary` 带 `orderCount / supplierList / orderNoList`,`businessRefs` 无。
|
||||||
|
- 当天无订单:正常结束(不算失败)。
|
||||||
|
- fail 错误码:登录失败 `SYS_LOGIN_FAIL`(retryable=true, OPS)、页面超时 `SYS_PAGE_TIMEOUT`(true, OPS)、导出按钮改版找不到 `UI_ELEMENT_NOT_FOUND`(false, OPS)。
|
||||||
|
|
||||||
|
### ①b OCR_EXTRACT · 线下手工单识别(仅线下单)
|
||||||
|
|
||||||
|
不是你主导。线上订单直接跳过此节点(complete 空过)。线下单由 OCR 服务识别,你只负责取件/触发。
|
||||||
|
- fail:图片模糊 `BIZ_OCR_LOW_CONF`(false, PLANNER)、OCR服务挂 `SYS_OCR_DOWN`(true, OPS)。
|
||||||
|
|
||||||
|
### ③ CONTRACT_CREATE · 合同建单 ★你的活
|
||||||
|
|
||||||
|
页面(合同系统 `contract.gzmpc.com`):登录 → 按供应商对照表判断模板(格式/非格式承兑)→ 可复制历史合同再改 → 填基础信息,**签约日期/合同开始日期 = 订单日期(硬约束,不是今天)** → 填货品明细 → 填付款信息 → 提交送审。
|
||||||
|
- **医药城分支**:下载平台购销合同,**同供应商同日合并成一个PDF**再上传(先合并!传第二份会覆盖第一份)。
|
||||||
|
- **小药药分支**:格式合同,**跳过合同证文上传**。
|
||||||
|
- complete:**必传 `businessRefs.contractSystemNo`(合同编号)**。
|
||||||
|
- fail 错误码:供应商合同被禁/失效 `BIZ_CONTRACT_DISABLED`(false, PURCHASE)、PDF合并不支持 `CAP_PDF_MERGE_UNSUPPORTED`(false, OPS)、页面改版 `UI_ELEMENT_NOT_FOUND`(false, OPS)。
|
||||||
|
|
||||||
|
### ④ CONTRACT_APPROVE_WAIT · 等合同审批 ★你的活(定时查一次,不连续轮询)
|
||||||
|
|
||||||
|
合同送审(CONTRACT_CREATE complete)后,状态中心把该 job 挂成 `WAITING_EXTERNAL`,并按业务方案每约半小时排一次查。**这个节点不用 start/complete/fail 三件套**,用下面两个专用接口:
|
||||||
|
|
||||||
|
1. **到点扫待查**:你(或调度器)定时 `GET /api/scheduler/due-contract-checks`(带 X-Agent-Token),拿到到点该查的 job 列表(含 `contractSystemNo`)。
|
||||||
|
2. **登录查一次审批状态**,把结果回给状态中心:
|
||||||
|
```
|
||||||
|
POST /api/jobs/{jobId}/nodes/CONTRACT_APPROVE_WAIT/check-result
|
||||||
|
{ "approved": true, "operator": "agent", "detail": "已审批通过" }
|
||||||
|
```
|
||||||
|
- `approved=true` → 状态中心放行,返回 `{"ok":true,"next_node":"REBATE_HEADER"}`,你接着跑返利。
|
||||||
|
- `approved=false` → 状态中心把 `wait_check_count+1`,排下一次查(约半小时后又出现在待查列表);查满上限(默认 8 次≈4 小时)还没过 → 自动升级 OPS(`WAITING_USER`),你不用管了。
|
||||||
|
- **别死等、别连续轮询**:只在到点被扫到时查一次。
|
||||||
|
- 注意:未审完也会有合同编号,但**必须审批通过才能进返利导入**。
|
||||||
|
|
||||||
|
### ⑤ REBATE_HEADER · 返利中心总单 ★你的活
|
||||||
|
|
||||||
|
页面(返利中心 `flgzmpc.com`):登录 → 采购协议管理 → **按合同编号查总单** → 填必填项、勾必选项 → 保存。
|
||||||
|
- complete:**必传 `businessRefs.rebateOrderNo`(总单号)**。
|
||||||
|
- fail:查不到总单/必填缺失 → OPS 或 PURCHASE 核查。
|
||||||
|
|
||||||
|
### ⑥ REBATE_IMPORT · 返利细单导入 ★你的活
|
||||||
|
|
||||||
|
页面:下载细单模板 → 填字段(操作码/货品编码、数量、单价、税率、承付方式、细单开始时间=订单时间、失效日期、结算方式、**特定补偿=0**、**细单备注=订单号**、货源信息)→ 上传导入 → 读成功/失败行。
|
||||||
|
- complete:`summary` 带成功/失败计数与明细。
|
||||||
|
- fail:重复编码(订单已做过)`REBATE_DUP_CODE`(false, PURCHASE)→ **该单挂起,整批继续**(重建规则待确认,先转人工)。
|
||||||
|
|
||||||
|
### ⑦ REBATE_SUBMIT · 返利送审 ⭐决定成败⭐ ★你的活
|
||||||
|
|
||||||
|
页面:在返利中心对该总单点"提交送审"(送审即自动审批通过,合同前端已审过)。
|
||||||
|
- **反直觉核心:卡 M5 能不能带出品种的送审在返利中心,不在合同系统。这步不做,后面全废。**
|
||||||
|
- **未送审 = 硬阻断**,整个 job 停在这。不许跳过进 M5。
|
||||||
|
|
||||||
|
### ⑧ M5_CREATE_ORDER · M5 建采购单(1055)★你的活(M5 是桌面客户端)
|
||||||
|
|
||||||
|
M5 是**桌面应用**(不是网页),用桌面自动化处理。步骤:采购订单管理 1055 → 新增 → 保管账=正常保管账(默认唯一)→ 隔天做则日期改前一天签约日期 → **备注填订单号** → 新增细单选品种(返利已送审的才带得出)→ 选对应数量 → 结算/接收/发票用返利带入值(**不填**)→ **★录完就停,绝不点保存★**。
|
||||||
|
- complete:标记"M5待保存"(可回传 `m5OrderNo`)。
|
||||||
|
- fail:质管资料缺失 `M5_QC_MISSING`(**填什么状态中心都转 FAILED_MANUAL,不重试不等质管,转人工手工做**)。
|
||||||
|
|
||||||
|
### ⑨⑩ M5_MANUAL_CONFIRM / M5_RECEIPT_NOTICE · 一期人工
|
||||||
|
|
||||||
|
一期这两步**人工做**(检查→保存→签订→生成收货通知单)。你不点。阶段二 `auto_save_enabled` 放开后才可能给你。
|
||||||
|
|
||||||
|
## 六、硬规则(红线,踩了就是错)
|
||||||
|
|
||||||
|
1. **签约日期 = 订单日期**,不是今天。合同建单、M5 日期都按订单日期。
|
||||||
|
2. **医药城同供应商同日合同合并成一个PDF再上传**(传第二份会覆盖);**小药药不上传合同证文**。
|
||||||
|
3. **关键送审在返利中心(REBATE_SUBMIT)**,不是合同系统。不送审 M5 带不出协议,硬阻断。
|
||||||
|
4. **M5 录完不点保存**(一期人工把关保存/签订)。auto_save_enabled 一期=0。
|
||||||
|
5. **M5 质管缺失(M5_QC_MISSING)不重试、不等质管**,直接转人工。RPA 晚上跑,质管不会半夜维护。
|
||||||
|
6. **返利细单:特定补偿=0、细单备注=订单号**(物流/结算要看)。细单开始时间=订单时间。
|
||||||
|
7. **对码未命中/返利重复编码 = 单条挂起,整批继续**,不是停整批。
|
||||||
|
8. **产出编号的三个节点(合同/返利总单/收货通知)complete 必传对应 businessRefs**,否则被 400 拒。
|
||||||
|
9. **每个回调必须带 X-Agent-Token**;别把状态中心接口无认证暴露。
|
||||||
|
10. **节点顺序听状态中心的**(照 complete 返回的 next_node 走),别自己写死。
|
||||||
|
|
||||||
|
## 七、你不做什么(边界)
|
||||||
|
|
||||||
|
- **不碰状态存储/对码算法/前端页面**——那是状态中心(Trae)的活。你只点外部系统页面 + 回调。
|
||||||
|
- **不做对码判断**——对码是状态中心内部算,MULTI/NONE 会进人工待办,不经过你。
|
||||||
|
- **不做 M5 保存/签订/收货通知**(一期人工)。
|
||||||
|
- **不做招财平台**(一期不纳入)。
|
||||||
|
- 遇到文档标 `【待确认】` 的(订单状态过滤、返利重复编码重建、OCR部署方式),**别自己拍板**,留 TODO 问项目负责人。
|
||||||
|
|
||||||
|
## 八、联调建议顺序
|
||||||
|
|
||||||
|
1. 先跟状态中心开发对齐:实际 IP、正式 `X-Agent-Token`、证据目录路径。
|
||||||
|
2. 拿一个测试供应商 + 一天的测试订单,先只跑 ORDER_FETCH:start → 导出 → complete,在状态中心 `GET /api/jobs/{id}` 看到轨迹即通。
|
||||||
|
3. 再一节点一节点往后接,每接一个对照 `实施与验收规范` 第四部分的"业务验收"栏勾。
|
||||||
|
4. 合同、返利、M5 三段分别单独联调通过后,再跑整链。
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,207 @@
|
||||||
|
# 状态中心页面 - 可调整版式方案
|
||||||
|
|
||||||
|
## 设计原则
|
||||||
|
|
||||||
|
1. **现在(联调阶段)**:全部展开,方便你看数据流转、验证接口。
|
||||||
|
2. **后期(交付阶段)**:一键折叠技术细节,业务只看待办 + 简化流程图。
|
||||||
|
3. **实现方式**:每个区域带展开/收起按钮 + 顶部"调试模式"开关,展开状态存 localStorage。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 版式结构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 顶栏 │
|
||||||
|
│ [状态中心] [调试模式: ●开 ○关] 角色:采购 待办状态:待处理 │
|
||||||
|
│ [令牌: ****] [刷新] │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ▼ 待办队列 (始终可见) [收起/展开]│
|
||||||
|
├──────────────────────────────────────────────────────────────────┤
|
||||||
|
│ ┌────────────┬────────────┬──────────────────────────────────┐ │
|
||||||
|
│ │ □ 新增货源 │ 对码待确认 │ M5人工确认 │ │
|
||||||
|
│ │ 重庆亿昊 │ 阿卡波糖 │ 华润医药 │ │
|
||||||
|
│ │ 10:00 │ 09:30 │ 昨天 │ │
|
||||||
|
│ └────────────┴────────────┴──────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 当前选中待办:新增货源 - 重庆亿昊药业 │
|
||||||
|
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 原因:该供应商未维护这些品种的货源 │ │
|
||||||
|
│ │ [选择候选货品: ▼ 阿卡波糖片 50mg*30片] │ │
|
||||||
|
│ │ [☑ 保存为下次自动匹配规则] │ │
|
||||||
|
│ │ [确认并继续] [转派] [终止] │ │
|
||||||
|
│ └────────────────────────────────────────────────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ▼ 所属单据概览 [收起/展开]│
|
||||||
|
├──────────────────────────────────────────────────────────────────┤
|
||||||
|
│ 单号: YYC-重庆亿昊药业-20260804-demo 状态: 等待人工 │
|
||||||
|
│ 当前节点: 新增货源 责任角色: 采购 │
|
||||||
|
│ 订单日期: 2026-08-04 供应商: 重庆亿昊药业 │
|
||||||
|
│ │
|
||||||
|
│ 业务编号链: │
|
||||||
|
│ 订单 DD-001,DD-002 → 合同 HT-xxx → 返利 - (待填) → M5 - → 收货 - │
|
||||||
|
│ │
|
||||||
|
│ [查看完整详情 ↗] │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ▼ 流程状态图 [收起/展开]│
|
||||||
|
├──────────────────────────────────────────────────────────────────┤
|
||||||
|
│ [订单获取✓] [OCR⊘] [对码✓] [新增货源●] [合同建单○] ... [归档○] │
|
||||||
|
│ │
|
||||||
|
│ 图例: ✓已完成 ●当前 ⊘已跳过 ○待处理 ✗失败 │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ▼ 调试:节点详情 (调试模式开启时显示) [收起/展开]│
|
||||||
|
├──────────────────────────────────────────────────────────────────┤
|
||||||
|
│ 已执行节点: │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ ORDER_FETCH [尝试1] 成功 09:40-09:42 (2分钟) │ │
|
||||||
|
│ │ 摘要: {orderCount: 12} │ │
|
||||||
|
│ │ 证据: orders.xlsx (SOURCE_EXCEL) │ │
|
||||||
|
│ ├──────────────────────────────────────────────────────────┤ │
|
||||||
|
│ │ PRODUCT_MATCH [尝试1] 成功 09:50-09:51 (1分钟) │ │
|
||||||
|
│ │ 摘要: {EXACT:10, ALIAS:1, MULTI:1} │ │
|
||||||
|
│ │ 证据: 无 │ │
|
||||||
|
│ ├──────────────────────────────────────────────────────────┤ │
|
||||||
|
│ │ SOURCE_ADD [尝试1] 等待人工 10:00- │ │
|
||||||
|
│ │ 证据: 无 │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ▼ 调试:证据包 (调试模式开启时显示) [收起/展开]│
|
||||||
|
├──────────────────────────────────────────────────────────────────┤
|
||||||
|
│ ORDER_FETCH尝试1: │
|
||||||
|
│ └─ SOURCE_EXCEL: /data/jobs/.../orders.xlsx (09:42) │
|
||||||
|
│ CONTRACT_CREATE尝试1: │
|
||||||
|
│ └─ CONTRACT_PDF: /data/jobs/.../contract.pdf (10:06) │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ▼ 调试:单据列表 (调试模式开启时显示) [收起/展开]│
|
||||||
|
├──────────────────────────────────────────────────────────────────┤
|
||||||
|
│ [全部] [执行中] [等待人工] [已完成] [失败] │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 重庆亿昊药业 2026-08-04 YYC 等待人工 新增货源 采购 │ │
|
||||||
|
│ │ 华润医药 2026-08-03 线下 执行中 对码 计划员 │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ▼ 开发:节点回调模拟 (仅 DEV 环境显示) [收起/展开]│
|
||||||
|
├──────────────────────────────────────────────────────────────────┤
|
||||||
|
│ 当前节点: 新增货源 │
|
||||||
|
│ [参数 JSON: ] │
|
||||||
|
│ [开始] [成功] [失败] │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 分区说明
|
||||||
|
|
||||||
|
### 1. 待办队列 (始终可见,不可收起)
|
||||||
|
- **用途**:主工作流,无论联调还是生产都需要。
|
||||||
|
- **内容**:待办卡片 + 当前待办处理表单。
|
||||||
|
|
||||||
|
### 2. 所属单据概览 (默认展开,可收起)
|
||||||
|
- **用途**:提供当前待办的上下文(哪个供应商、哪天的单、跑到哪了)。
|
||||||
|
- **后期**:业务只看这个 + 待办队列就够了。
|
||||||
|
|
||||||
|
### 3. 流程状态图 (默认展开,可收起)
|
||||||
|
- **用途**:直观看 13 个节点的完成情况。
|
||||||
|
- **后期**:保留,但可以简化成 mini 版(只显示名称 + 状态点)。
|
||||||
|
|
||||||
|
### 4. 调试:节点详情 (调试模式开启时显示)
|
||||||
|
- **用途**:验证每个节点的 started_at/ended_at/duration/output_json/error。
|
||||||
|
- **后期**:关掉,业务不需要看技术细节。
|
||||||
|
|
||||||
|
### 5. 调试:证据包 (调试模式开启时显示)
|
||||||
|
- **用途**:验证 RPA 回调上传的证据是否落库、路径对不对。
|
||||||
|
- **后期**:关掉或改成"证据总数"摘要。
|
||||||
|
|
||||||
|
### 6. 调试:单据列表 (调试模式开启时显示)
|
||||||
|
- **用途**:你在联调时快速切换不同单据验证状态流转。
|
||||||
|
- **后期**:关掉或改成独立页面(管理员监控用)。
|
||||||
|
|
||||||
|
### 7. 开发:节点回调模拟 (仅 DEV 环境显示)
|
||||||
|
- **用途**:本地联调时模拟艺赛旗回调。
|
||||||
|
- **后期**:生产环境 `import.meta.env.DEV=false` 自动隐藏。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术实现
|
||||||
|
|
||||||
|
### 1. 展开/收起状态存储
|
||||||
|
```typescript
|
||||||
|
// localStorage 存每个区域的展开状态
|
||||||
|
const sectionStates = {
|
||||||
|
jobSummary: true, // 所属单据概览
|
||||||
|
timeline: true, // 流程状态图
|
||||||
|
nodeDetails: false, // 节点详情(调试模式控制)
|
||||||
|
evidence: false, // 证据包(调试模式控制)
|
||||||
|
jobList: false, // 单据列表(调试模式控制)
|
||||||
|
};
|
||||||
|
localStorage.setItem('sectionStates', JSON.stringify(sectionStates));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 调试模式开关
|
||||||
|
```typescript
|
||||||
|
const [debugMode, setDebugMode] = useState(
|
||||||
|
localStorage.getItem('debugMode') === 'true'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 顶栏开关:切换时同步存储,并控制"调试:"前缀的区域显隐
|
||||||
|
function toggleDebugMode() {
|
||||||
|
const next = !debugMode;
|
||||||
|
setDebugMode(next);
|
||||||
|
localStorage.setItem('debugMode', String(next));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 可收起组件封装
|
||||||
|
```typescript
|
||||||
|
<CollapsibleSection
|
||||||
|
title="所属单据概览"
|
||||||
|
storageKey="jobSummary"
|
||||||
|
defaultExpanded={true}
|
||||||
|
>
|
||||||
|
{/* 单据详情内容 */}
|
||||||
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
<CollapsibleSection
|
||||||
|
title="调试:节点详情"
|
||||||
|
storageKey="nodeDetails"
|
||||||
|
defaultExpanded={false}
|
||||||
|
visibleWhen={debugMode} // 调试模式关闭时整个区域隐藏
|
||||||
|
>
|
||||||
|
{/* 节点列表 */}
|
||||||
|
</CollapsibleSection>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 后期交付时的调整
|
||||||
|
|
||||||
|
你只需要:
|
||||||
|
1. **关闭调试模式**:顶栏开关点"关",所有"调试:"区域自动隐藏。
|
||||||
|
2. **可选:收起流程图**:如果业务觉得太占屏幕,点收起按钮即可。
|
||||||
|
3. **最终业务看到的**:待办队列 + 所属单据概览(可能 + 简化流程图),干净清爽。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 你确认一下
|
||||||
|
|
||||||
|
这个方案是否满足你"现在全看、后期可关"的需求?如果可以,我直接动手:
|
||||||
|
1. 先封装 `CollapsibleSection` 组件。
|
||||||
|
2. 把现有页面按上述分区重组。
|
||||||
|
3. 加调试模式开关 + localStorage 持久化。
|
||||||
|
4. 调整样式,让收起状态下不占垂直空间。
|
||||||
|
|
||||||
|
或者你对分区划分 / 默认展开状态有别的想法,直接说,我再调整。
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
# 状态中心 Web 版
|
||||||
|
|
||||||
|
## 一键启动联调环境
|
||||||
|
|
||||||
|
在项目根目录执行:
|
||||||
|
|
||||||
|
macOS/Linux:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./.venv_test/bin/python scripts/dev.py start
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\.venv_test\Scripts\python.exe scripts\dev.py start
|
||||||
|
```
|
||||||
|
|
||||||
|
启动器会复用或创建 Docker MySQL 容器 `rpa-center-mysql`,然后启动 FastAPI 和 Vite。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/dev.py status
|
||||||
|
python scripts/dev.py stop
|
||||||
|
```
|
||||||
|
|
||||||
|
`status` 只有在 MySQL 端口、FastAPI `/health`(包含数据库探测)和 Vite 端口都正常时才返回成功。
|
||||||
|
|
||||||
|
## 手动启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd state_center_web
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
默认地址:`http://localhost:5173`
|
||||||
|
|
||||||
|
后端另开一个终端:
|
||||||
|
|
||||||
|
macOS/Linux:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install -r state_center/requirements.txt
|
||||||
|
DEV_MODE=true python -m uvicorn state_center.app.main:app --reload --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:DEV_MODE = "true"
|
||||||
|
python -m pip install -r state_center\requirements.txt
|
||||||
|
python -m uvicorn state_center.app.main:app --reload --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器访问:`http://localhost:5173/`。同一套页面适用于 Windows 和 macOS;前端不会自行推导流程下一节点,所有节点状态都来自 FastAPI。
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
|
||||||
|
- 页面全中文。
|
||||||
|
- 后端可连时,直接读 `state_center` 的 FastAPI 接口。
|
||||||
|
- 后端没起时,页面自动切到本地演示数据,仍可看到单据列表、流程状态图和待办面板。
|
||||||
|
- 页面顶部会明确显示“本地演示数据”或“后端已连接”。
|
||||||
|
- 对码待办可直接选择候选货品;人工节点按节点显示对应动作。
|
||||||
|
- 节点回调模拟区只在开发环境构建中显示。
|
||||||
|
- 开发令牌只保存在浏览器会话里,不会写进代码。
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>状态中心</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"name": "state-center-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "tsc --noEmit",
|
||||||
|
"test": "node --test --experimental-strip-types src/*.test.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.5",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vite": "^5.4.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,907 @@
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { requestJson, sessionTokenStore } from './api/client';
|
||||||
|
import { JobAcceptanceChecklist } from './components/JobAcceptanceChecklist';
|
||||||
|
import { JobList } from './components/JobList';
|
||||||
|
import { JobEvidenceList } from './components/JobEvidenceList';
|
||||||
|
import { JobNodeDetailList } from './components/JobNodeDetailList';
|
||||||
|
import { JobTimeline } from './components/JobTimeline';
|
||||||
|
import { TaskList } from './components/TaskList';
|
||||||
|
import {
|
||||||
|
type JobArtifactRow,
|
||||||
|
type JobNodeRow,
|
||||||
|
buildNodeCallbackRequestBody,
|
||||||
|
actionLabel,
|
||||||
|
allowedTaskActions,
|
||||||
|
buildBusinessOverview,
|
||||||
|
buildNodeDetails,
|
||||||
|
formatNodeCallbackTemplate,
|
||||||
|
nodeLabel,
|
||||||
|
nodeCallbackActionLabel,
|
||||||
|
reasonLabel,
|
||||||
|
roleLabel,
|
||||||
|
jobStatusBucketLabel,
|
||||||
|
jobStatusBucketMatches,
|
||||||
|
type JobStatusBucket,
|
||||||
|
type NodeCallbackKind,
|
||||||
|
taskCandidates,
|
||||||
|
taskStatusLabel,
|
||||||
|
type JobRow,
|
||||||
|
type StateMachineMeta,
|
||||||
|
type TaskRow,
|
||||||
|
type TimelineNode,
|
||||||
|
} from './stateMachine';
|
||||||
|
|
||||||
|
type JobDetail = {
|
||||||
|
job: JobRow;
|
||||||
|
artifacts: JobArtifactRow[];
|
||||||
|
nodes: JobNodeRow[];
|
||||||
|
tasks: TaskRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type TaskSubmitResponse = {
|
||||||
|
ok: boolean;
|
||||||
|
next_node?: string | null;
|
||||||
|
job_status?: string | null;
|
||||||
|
idempotent?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const devActionsEnabled = import.meta.env.DEV;
|
||||||
|
const demoJobId = 'YYC-重庆亿昊药业-20260804-demo';
|
||||||
|
const demoMeta: StateMachineMeta = {
|
||||||
|
node_order: [
|
||||||
|
'ORDER_FETCH',
|
||||||
|
'OCR_EXTRACT',
|
||||||
|
'PRODUCT_MATCH',
|
||||||
|
'SOURCE_ADD',
|
||||||
|
'CONTRACT_CREATE',
|
||||||
|
'CONTRACT_APPROVE_WAIT',
|
||||||
|
'REBATE_HEADER',
|
||||||
|
'REBATE_IMPORT',
|
||||||
|
'REBATE_SUBMIT',
|
||||||
|
'M5_CREATE_ORDER',
|
||||||
|
'M5_MANUAL_CONFIRM',
|
||||||
|
'M5_RECEIPT_NOTICE',
|
||||||
|
'ARCHIVE_JOB',
|
||||||
|
],
|
||||||
|
transitions: {},
|
||||||
|
conditional_nodes: ['OCR_EXTRACT', 'SOURCE_ADD'],
|
||||||
|
human_node_roles: {
|
||||||
|
SOURCE_ADD: 'PURCHASER',
|
||||||
|
M5_MANUAL_CONFIRM: 'PURCHASER',
|
||||||
|
M5_RECEIPT_NOTICE: 'PURCHASER',
|
||||||
|
},
|
||||||
|
node_executor: {
|
||||||
|
ORDER_FETCH: 'RPA',
|
||||||
|
OCR_EXTRACT: 'RPA',
|
||||||
|
PRODUCT_MATCH: 'STATE_CENTER',
|
||||||
|
SOURCE_ADD: 'HUMAN',
|
||||||
|
CONTRACT_CREATE: 'RPA',
|
||||||
|
CONTRACT_APPROVE_WAIT: 'RPA',
|
||||||
|
REBATE_HEADER: 'RPA',
|
||||||
|
REBATE_IMPORT: 'RPA',
|
||||||
|
REBATE_SUBMIT: 'RPA',
|
||||||
|
M5_CREATE_ORDER: 'RPA',
|
||||||
|
M5_MANUAL_CONFIRM: 'HUMAN',
|
||||||
|
M5_RECEIPT_NOTICE: 'HUMAN',
|
||||||
|
ARCHIVE_JOB: 'STATE_CENTER',
|
||||||
|
},
|
||||||
|
node_labels: {
|
||||||
|
ORDER_FETCH: '订单获取',
|
||||||
|
OCR_EXTRACT: 'OCR识别',
|
||||||
|
PRODUCT_MATCH: '对码',
|
||||||
|
SOURCE_ADD: '新增货源',
|
||||||
|
CONTRACT_CREATE: '合同建单',
|
||||||
|
CONTRACT_APPROVE_WAIT: '合同审批等待',
|
||||||
|
REBATE_HEADER: '返利总单',
|
||||||
|
REBATE_IMPORT: '返利细单导入',
|
||||||
|
REBATE_SUBMIT: '返利送审',
|
||||||
|
M5_CREATE_ORDER: 'M5建单',
|
||||||
|
M5_MANUAL_CONFIRM: 'M5人工确认',
|
||||||
|
M5_RECEIPT_NOTICE: '收货通知单',
|
||||||
|
ARCHIVE_JOB: '归档',
|
||||||
|
},
|
||||||
|
executor_labels: {
|
||||||
|
STATE_CENTER: '状态中心',
|
||||||
|
RPA: '艺赛旗执行',
|
||||||
|
HUMAN: '人工处理',
|
||||||
|
},
|
||||||
|
status_labels: {
|
||||||
|
PENDING: '待处理',
|
||||||
|
RUNNING: '执行中',
|
||||||
|
SUCCESS: '已完成',
|
||||||
|
FAILED: '已失败',
|
||||||
|
WAITING_USER: '等待人工',
|
||||||
|
WAITING_EXTERNAL: '等待外部',
|
||||||
|
SKIPPED: '已跳过',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const demoJobs: JobRow[] = [
|
||||||
|
{
|
||||||
|
job_id: demoJobId,
|
||||||
|
supplier_name: '重庆亿昊药业',
|
||||||
|
order_date: '2026-08-04',
|
||||||
|
source_type: 'YYC',
|
||||||
|
status: 'WAITING_USER',
|
||||||
|
current_node_code: 'SOURCE_ADD',
|
||||||
|
current_role: 'PURCHASER',
|
||||||
|
created_at: '2026-08-10 09:40:00',
|
||||||
|
updated_at: '2026-08-10 10:06:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
job_id: 'OFFLINE-华润医药-20260803-demo',
|
||||||
|
supplier_name: '华润医药',
|
||||||
|
order_date: '2026-08-03',
|
||||||
|
source_type: 'OFFLINE',
|
||||||
|
status: 'RUNNING',
|
||||||
|
current_node_code: 'PRODUCT_MATCH',
|
||||||
|
current_role: 'PLANNER',
|
||||||
|
created_at: '2026-08-10 08:20:00',
|
||||||
|
updated_at: '2026-08-10 08:45:00',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const demoTimeline: TimelineNode[] = [
|
||||||
|
{ node_code: 'ORDER_FETCH', display_status: 'SUCCESS', attempt_no: 1, status: 'SUCCESS' },
|
||||||
|
{ node_code: 'OCR_EXTRACT', display_status: 'SKIPPED' },
|
||||||
|
{ node_code: 'PRODUCT_MATCH', display_status: 'SUCCESS', attempt_no: 1, status: 'SUCCESS' },
|
||||||
|
{ node_code: 'SOURCE_ADD', display_status: 'WAITING_USER', attempt_no: 1, status: 'WAITING_USER' },
|
||||||
|
{ node_code: 'CONTRACT_CREATE', display_status: 'PENDING' },
|
||||||
|
{ node_code: 'CONTRACT_APPROVE_WAIT', display_status: 'PENDING' },
|
||||||
|
{ node_code: 'REBATE_HEADER', display_status: 'PENDING' },
|
||||||
|
{ node_code: 'REBATE_IMPORT', display_status: 'PENDING' },
|
||||||
|
{ node_code: 'REBATE_SUBMIT', display_status: 'PENDING' },
|
||||||
|
{ node_code: 'M5_CREATE_ORDER', display_status: 'PENDING' },
|
||||||
|
{ node_code: 'M5_MANUAL_CONFIRM', display_status: 'PENDING' },
|
||||||
|
{ node_code: 'M5_RECEIPT_NOTICE', display_status: 'PENDING' },
|
||||||
|
{ node_code: 'ARCHIVE_JOB', display_status: 'PENDING' },
|
||||||
|
];
|
||||||
|
const demoTasks: TaskRow[] = [
|
||||||
|
{
|
||||||
|
task_id: 'TASK-001',
|
||||||
|
job_id: demoJobId,
|
||||||
|
node_code: 'SOURCE_ADD',
|
||||||
|
role_code: 'PURCHASER',
|
||||||
|
status: 'OPEN',
|
||||||
|
title: '新增货源:重庆亿昊药业',
|
||||||
|
reason_type: 'SOURCE_ADD',
|
||||||
|
created_at: '2026-08-10 10:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
task_id: 'TASK-002',
|
||||||
|
job_id: 'OFFLINE-华润医药-20260803-demo',
|
||||||
|
node_code: 'PRODUCT_MATCH',
|
||||||
|
role_code: 'PLANNER',
|
||||||
|
status: 'OPEN',
|
||||||
|
title: '对码待确认:阿卡波糖片',
|
||||||
|
reason_type: 'MATCH_MULTI',
|
||||||
|
product_match_id: 7,
|
||||||
|
candidates_json: [
|
||||||
|
{
|
||||||
|
goods_id: 'G-001',
|
||||||
|
goods_code: 'YP-001',
|
||||||
|
common_name: '阿卡波糖片',
|
||||||
|
spec: '50mg*30片',
|
||||||
|
base_unit: '盒',
|
||||||
|
manufacturer: '示例制药',
|
||||||
|
approval_no: '国药准字H00000001',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
goods_id: 'G-004',
|
||||||
|
goods_code: 'YP-004',
|
||||||
|
common_name: '阿卡波糖片',
|
||||||
|
spec: '50mg*60片',
|
||||||
|
base_unit: '盒',
|
||||||
|
manufacturer: '示例制药',
|
||||||
|
approval_no: '国药准字H00000004',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
created_at: '2026-08-10 09:30:00',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const demoJobDetailMap: Record<string, JobDetail> = {
|
||||||
|
[demoJobId]: {
|
||||||
|
job: demoJobs[0],
|
||||||
|
artifacts: [
|
||||||
|
{
|
||||||
|
node_code: 'ORDER_FETCH',
|
||||||
|
attempt_no: 1,
|
||||||
|
artifact_type: 'SOURCE_EXCEL',
|
||||||
|
storage_path: '/data/jobs/YYC-重庆亿昊药业-20260804-demo/orders.xlsx',
|
||||||
|
file_hash: 'sha256:demo-order-excel',
|
||||||
|
visible_roles: ['PURCHASER', 'PLANNER'],
|
||||||
|
is_masked: false,
|
||||||
|
created_at: '2026-08-10 09:42:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node_code: 'CONTRACT_CREATE',
|
||||||
|
attempt_no: 1,
|
||||||
|
artifact_type: 'CONTRACT_PDF',
|
||||||
|
storage_path: '/data/jobs/YYC-重庆亿昊药业-20260804-demo/contract.pdf',
|
||||||
|
file_hash: 'sha256:demo-contract-pdf',
|
||||||
|
visible_roles: ['PURCHASER'],
|
||||||
|
is_masked: false,
|
||||||
|
created_at: '2026-08-10 10:06:00',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nodes: [
|
||||||
|
{ node_code: 'ORDER_FETCH', attempt_no: 1, status: 'SUCCESS' },
|
||||||
|
{ node_code: 'PRODUCT_MATCH', attempt_no: 1, status: 'SUCCESS' },
|
||||||
|
{ node_code: 'SOURCE_ADD', attempt_no: 1, status: 'WAITING_USER' },
|
||||||
|
],
|
||||||
|
tasks: demoTasks,
|
||||||
|
},
|
||||||
|
[demoJobs[1].job_id]: {
|
||||||
|
job: demoJobs[1],
|
||||||
|
artifacts: [],
|
||||||
|
nodes: [
|
||||||
|
{ node_code: 'ORDER_FETCH', attempt_no: 1, status: 'SUCCESS' },
|
||||||
|
{ node_code: 'OCR_EXTRACT', attempt_no: 1, status: 'SKIPPED' },
|
||||||
|
{ node_code: 'PRODUCT_MATCH', attempt_no: 1, status: 'RUNNING' },
|
||||||
|
],
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
task_id: 'TASK-003',
|
||||||
|
job_id: demoJobs[1].job_id,
|
||||||
|
node_code: 'PRODUCT_MATCH',
|
||||||
|
role_code: 'PLANNER',
|
||||||
|
status: 'OPEN',
|
||||||
|
title: '对码待确认:阿卡波糖片',
|
||||||
|
reason_type: 'MATCH_MULTI',
|
||||||
|
product_match_id: 7,
|
||||||
|
candidates_json: demoTasks[1].candidates_json,
|
||||||
|
created_at: '2026-08-10 09:30:00',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [meta, setMeta] = useState<StateMachineMeta | null>(null);
|
||||||
|
const [jobs, setJobs] = useState<JobRow[]>([]);
|
||||||
|
const [selectedJobId, setSelectedJobId] = useState('');
|
||||||
|
const [jobDetail, setJobDetail] = useState<JobDetail | null>(null);
|
||||||
|
const [timeline, setTimeline] = useState<TimelineNode[]>([]);
|
||||||
|
const [tasks, setTasks] = useState<TaskRow[]>([]);
|
||||||
|
const [selectedTaskId, setSelectedTaskId] = useState('');
|
||||||
|
const [roleCode, setRoleCode] = useState('PURCHASER');
|
||||||
|
const [taskStatus, setTaskStatus] = useState('OPEN');
|
||||||
|
const [jobBucket, setJobBucket] = useState<JobStatusBucket>('ALL');
|
||||||
|
const [token, setToken] = useState(sessionTokenStore.get());
|
||||||
|
const [taskAction, setTaskAction] = useState('CONFIRM_AND_CONTINUE');
|
||||||
|
const [selectedCandidateId, setSelectedCandidateId] = useState('');
|
||||||
|
const [saveAsAlias, setSaveAsAlias] = useState(true);
|
||||||
|
const [receiptNoticeNo, setReceiptNoticeNo] = useState('');
|
||||||
|
const [targetRole, setTargetRole] = useState('PURCHASER');
|
||||||
|
const [callbackKind, setCallbackKind] = useState<NodeCallbackKind>('complete');
|
||||||
|
const [simPayloadText, setSimPayloadText] = useState(formatNodeCallbackTemplate('ORDER_FETCH', 'complete'));
|
||||||
|
const [notice, setNotice] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [demoMode, setDemoMode] = useState(false);
|
||||||
|
|
||||||
|
async function loadJobs() {
|
||||||
|
if (demoMode) {
|
||||||
|
setJobs(demoJobs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await requestJson<{ items: JobRow[] }>('/api/jobs');
|
||||||
|
setJobs(data.items);
|
||||||
|
if (!selectedJobId && data.items[0]) {
|
||||||
|
setSelectedJobId(data.items[0].job_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMeta() {
|
||||||
|
if (demoMode) {
|
||||||
|
setMeta(demoMeta);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await requestJson<StateMachineMeta>('/api/state-machine');
|
||||||
|
setMeta(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTasks() {
|
||||||
|
if (demoMode) {
|
||||||
|
const filtered = demoTasks.filter((task) => task.role_code === roleCode && task.status === taskStatus);
|
||||||
|
setTasks(filtered);
|
||||||
|
setSelectedTaskId((current) =>
|
||||||
|
filtered.some((task) => task.task_id === current) ? current : (filtered[0]?.task_id || ''),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await requestJson<{ items: TaskRow[] }>(
|
||||||
|
`/api/tasks/my?roleCode=${encodeURIComponent(roleCode)}&status=${encodeURIComponent(taskStatus)}`,
|
||||||
|
);
|
||||||
|
setTasks(data.items);
|
||||||
|
setSelectedTaskId((current) =>
|
||||||
|
data.items.some((task) => task.task_id === current) ? current : (data.items[0]?.task_id || ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadJobDetail(jobId: string) {
|
||||||
|
if (demoMode) {
|
||||||
|
const detail = demoJobDetailMap[jobId] || demoJobDetailMap[demoJobId];
|
||||||
|
setJobDetail(detail);
|
||||||
|
setTimeline(
|
||||||
|
detail.job.job_id === demoJobId
|
||||||
|
? demoTimeline
|
||||||
|
: [
|
||||||
|
{ node_code: 'ORDER_FETCH', display_status: 'SUCCESS', attempt_no: 1, status: 'SUCCESS' },
|
||||||
|
{ node_code: 'OCR_EXTRACT', display_status: 'SKIPPED' },
|
||||||
|
{ node_code: 'PRODUCT_MATCH', display_status: 'RUNNING', attempt_no: 1, status: 'RUNNING' },
|
||||||
|
{ node_code: 'SOURCE_ADD', display_status: 'PENDING' },
|
||||||
|
{ node_code: 'CONTRACT_CREATE', display_status: 'PENDING' },
|
||||||
|
{ node_code: 'CONTRACT_APPROVE_WAIT', display_status: 'PENDING' },
|
||||||
|
{ node_code: 'REBATE_HEADER', display_status: 'PENDING' },
|
||||||
|
{ node_code: 'REBATE_IMPORT', display_status: 'PENDING' },
|
||||||
|
{ node_code: 'REBATE_SUBMIT', display_status: 'PENDING' },
|
||||||
|
{ node_code: 'M5_CREATE_ORDER', display_status: 'PENDING' },
|
||||||
|
{ node_code: 'M5_MANUAL_CONFIRM', display_status: 'PENDING' },
|
||||||
|
{ node_code: 'M5_RECEIPT_NOTICE', display_status: 'PENDING' },
|
||||||
|
{ node_code: 'ARCHIVE_JOB', display_status: 'PENDING' },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const detail = await requestJson<JobDetail>(`/api/jobs/${jobId}`);
|
||||||
|
const tl = await requestJson<{ job: JobRow; timeline: TimelineNode[] }>(`/api/jobs/${jobId}/timeline`);
|
||||||
|
setJobDetail(detail);
|
||||||
|
setTimeline(tl.timeline);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDemoData(reason: string) {
|
||||||
|
setDemoMode(true);
|
||||||
|
setMeta(demoMeta);
|
||||||
|
setJobs(demoJobs);
|
||||||
|
setSelectedJobId(demoJobId);
|
||||||
|
setJobDetail(demoJobDetailMap[demoJobId]);
|
||||||
|
setTimeline(demoTimeline);
|
||||||
|
setTasks(demoTasks);
|
||||||
|
setSelectedTaskId(demoTasks[0]?.task_id || '');
|
||||||
|
setNotice(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void Promise.all([loadMeta(), loadJobs(), loadTasks()]).catch(() => {
|
||||||
|
applyDemoData('后端暂不可用,已切换到本地演示数据。');
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedJobId) return;
|
||||||
|
void loadJobDetail(selectedJobId).catch((error) => {
|
||||||
|
if (!demoMode) {
|
||||||
|
applyDemoData('后端暂不可用,已切换到本地演示数据。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setNotice(error instanceof Error ? error.message : '加载单据失败');
|
||||||
|
});
|
||||||
|
}, [selectedJobId, demoMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadTasks().catch((error) => {
|
||||||
|
if (!demoMode) {
|
||||||
|
applyDemoData('后端暂不可用,已切换到本地演示数据。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setNotice(error instanceof Error ? error.message : '加载待办失败');
|
||||||
|
});
|
||||||
|
}, [roleCode, taskStatus, demoMode]);
|
||||||
|
|
||||||
|
const selectedTask = useMemo(() => tasks.find((task) => task.task_id === selectedTaskId) || null, [tasks, selectedTaskId]);
|
||||||
|
const visibleJobs = useMemo(
|
||||||
|
() => jobs.filter((job) => jobStatusBucketMatches(jobBucket, job.status)),
|
||||||
|
[jobs, jobBucket],
|
||||||
|
);
|
||||||
|
const selectedTaskActions = useMemo(
|
||||||
|
() => (selectedTask ? allowedTaskActions(selectedTask) : []),
|
||||||
|
[selectedTask],
|
||||||
|
);
|
||||||
|
const candidates = useMemo(() => taskCandidates(selectedTask), [selectedTask]);
|
||||||
|
const selectedCandidate = useMemo(
|
||||||
|
() => candidates.find((candidate) => candidate.goods_id === selectedCandidateId) || null,
|
||||||
|
[candidates, selectedCandidateId],
|
||||||
|
);
|
||||||
|
const currentNodeCode = jobDetail?.job.current_node_code || '';
|
||||||
|
const callbackEndpoint = jobDetail && currentNodeCode
|
||||||
|
? `/api/jobs/${jobDetail.job.job_id}/nodes/${currentNodeCode}/${callbackKind}`
|
||||||
|
: '';
|
||||||
|
const jobArtifacts = jobDetail?.artifacts || [];
|
||||||
|
const jobNodeDetails = useMemo(
|
||||||
|
() => buildNodeDetails(timeline, jobDetail?.nodes || [], jobArtifacts, jobDetail?.tasks || []),
|
||||||
|
[timeline, jobDetail?.nodes, jobArtifacts, jobDetail?.tasks],
|
||||||
|
);
|
||||||
|
const businessOverview = useMemo(
|
||||||
|
() =>
|
||||||
|
jobDetail
|
||||||
|
? buildBusinessOverview(
|
||||||
|
jobDetail.job,
|
||||||
|
timeline,
|
||||||
|
jobDetail.tasks,
|
||||||
|
jobArtifacts,
|
||||||
|
meta,
|
||||||
|
jobDetail.nodes,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
[jobDetail, timeline, jobArtifacts, meta],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visibleJobs.length) return;
|
||||||
|
if (!visibleJobs.some((job) => job.job_id === selectedJobId)) {
|
||||||
|
setSelectedJobId(visibleJobs[0].job_id);
|
||||||
|
}
|
||||||
|
}, [visibleJobs, selectedJobId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTaskAction((current) =>
|
||||||
|
selectedTaskActions.includes(current) ? current : (selectedTaskActions[0] || ''),
|
||||||
|
);
|
||||||
|
}, [selectedTaskActions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedCandidateId((current) =>
|
||||||
|
candidates.some((candidate) => candidate.goods_id === current)
|
||||||
|
? current
|
||||||
|
: (candidates[0]?.goods_id || ''),
|
||||||
|
);
|
||||||
|
}, [candidates]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentNodeCode) return;
|
||||||
|
setSimPayloadText(formatNodeCallbackTemplate(currentNodeCode, callbackKind));
|
||||||
|
}, [currentNodeCode, callbackKind]);
|
||||||
|
|
||||||
|
async function saveToken(nextToken: string) {
|
||||||
|
setToken(nextToken);
|
||||||
|
sessionTokenStore.set(nextToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitTask() {
|
||||||
|
if (!selectedTask) {
|
||||||
|
setNotice('请先选择一条待办。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!token.trim()) {
|
||||||
|
setNotice('请先填写开发令牌。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (demoMode) {
|
||||||
|
setNotice(`演示提交:${actionLabel(taskAction)} 已记录。`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let payload: Record<string, unknown> = {};
|
||||||
|
if (taskAction === 'SELECT_CANDIDATE') {
|
||||||
|
if (!selectedCandidate || !selectedTask.product_match_id) {
|
||||||
|
setNotice('请选择一条候选货品。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
payload = {
|
||||||
|
productMatchId: selectedTask.product_match_id,
|
||||||
|
goodsId: selectedCandidate.goods_id,
|
||||||
|
goodsCode: selectedCandidate.goods_code,
|
||||||
|
baseUnit: selectedCandidate.base_unit,
|
||||||
|
saveAsAlias,
|
||||||
|
};
|
||||||
|
} else if (taskAction === 'CONFIRM_AND_CONTINUE' && selectedTask.node_code === 'M5_RECEIPT_NOTICE') {
|
||||||
|
if (!receiptNoticeNo.trim()) {
|
||||||
|
setNotice('请填写收货通知单号。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
payload = { receiptNoticeNo: receiptNoticeNo.trim() };
|
||||||
|
} else if (taskAction === 'TRANSFER_MANUAL') {
|
||||||
|
payload = { targetRole };
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const result = await requestJson<TaskSubmitResponse>(`/api/tasks/${selectedTask.task_id}/submit`, {
|
||||||
|
method: 'POST',
|
||||||
|
token,
|
||||||
|
body: {
|
||||||
|
action: taskAction,
|
||||||
|
payload,
|
||||||
|
submitToken: crypto.randomUUID(),
|
||||||
|
operator: 'web',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setNotice(`待办已提交:${result.idempotent ? '重复提交' : '首次提交'}。`);
|
||||||
|
await Promise.all([loadJobDetail(selectedTask.job_id), loadTasks(), loadJobs()]);
|
||||||
|
} catch (error) {
|
||||||
|
setNotice(error instanceof Error ? error.message : '提交失败');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetNodeCallbackTemplate() {
|
||||||
|
if (!currentNodeCode) return;
|
||||||
|
setSimPayloadText(formatNodeCallbackTemplate(currentNodeCode, callbackKind));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendNodeCallback() {
|
||||||
|
if (!jobDetail) {
|
||||||
|
setNotice('请先选择一条单据。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!token.trim()) {
|
||||||
|
setNotice('请先填写开发令牌。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (demoMode) {
|
||||||
|
setNotice(`演示回调:${nodeCallbackActionLabel(callbackKind)} 已记录。`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let payload: unknown;
|
||||||
|
try {
|
||||||
|
payload = simPayloadText.trim() ? JSON.parse(simPayloadText) : {};
|
||||||
|
} catch {
|
||||||
|
setNotice('联调回调参数不是合法 JSON。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||||
|
setNotice('联调回调参数必须是 JSON 对象。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = buildNodeCallbackRequestBody(
|
||||||
|
callbackKind,
|
||||||
|
currentNodeCode,
|
||||||
|
payload as Record<string, unknown>,
|
||||||
|
crypto.randomUUID(),
|
||||||
|
);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const result = await requestJson<TaskSubmitResponse>(callbackEndpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
token,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const nextNodeText = result.next_node ? `,下一节点:${result.next_node}` : '';
|
||||||
|
const statusText = result.job_status ? `,状态:${result.job_status}` : '';
|
||||||
|
setNotice(`联调回调成功:${nodeCallbackActionLabel(callbackKind)}${nextNodeText}${statusText}`);
|
||||||
|
await Promise.all([loadJobDetail(jobDetail.job.job_id), loadJobs(), loadTasks()]);
|
||||||
|
} catch (error) {
|
||||||
|
setNotice(error instanceof Error ? error.message : '联调回调失败');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTaskFromNode(taskId: string) {
|
||||||
|
setTaskStatus('OPEN');
|
||||||
|
setSelectedTaskId(taskId);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
document.getElementById('task-section')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleArtifactClick(storagePath: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(storagePath);
|
||||||
|
setNotice(`证据路径已复制:${storagePath}`);
|
||||||
|
} catch {
|
||||||
|
setNotice(`证据路径:${storagePath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-shell">
|
||||||
|
<header className="topbar">
|
||||||
|
<div>
|
||||||
|
<div className="eyebrow">采购主链自动化一期</div>
|
||||||
|
<h1>状态中心</h1>
|
||||||
|
<div className={`mode-badge ${demoMode ? 'mode-demo' : 'mode-live'}`}>
|
||||||
|
{demoMode ? '本地演示数据' : '后端已连接'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="token-panel">
|
||||||
|
<label>
|
||||||
|
本地联调令牌
|
||||||
|
<input
|
||||||
|
value={token}
|
||||||
|
onChange={(event) => void saveToken(event.target.value)}
|
||||||
|
placeholder="仅本地联调时填写"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
待办角色
|
||||||
|
<select value={roleCode} onChange={(event) => setRoleCode(event.target.value)}>
|
||||||
|
<option value="PURCHASER">采购</option>
|
||||||
|
<option value="PLANNER">计划员</option>
|
||||||
|
<option value="QC">质管</option>
|
||||||
|
<option value="OPS">运维</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
待办状态
|
||||||
|
<select value={taskStatus} onChange={(event) => setTaskStatus(event.target.value)}>
|
||||||
|
<option value="OPEN">待处理</option>
|
||||||
|
<option value="SUBMITTED">已提交</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
单据筛选
|
||||||
|
<select value={jobBucket} onChange={(event) => setJobBucket(event.target.value as JobStatusBucket)}>
|
||||||
|
<option value="ALL">{jobStatusBucketLabel('ALL')}</option>
|
||||||
|
<option value="ACTIVE">{jobStatusBucketLabel('ACTIVE')}</option>
|
||||||
|
<option value="WAITING">{jobStatusBucketLabel('WAITING')}</option>
|
||||||
|
<option value="FAILED">{jobStatusBucketLabel('FAILED')}</option>
|
||||||
|
<option value="SUCCESS">{jobStatusBucketLabel('SUCCESS')}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{notice ? <div className="notice">{notice}</div> : null}
|
||||||
|
|
||||||
|
<main className="dashboard-layout">
|
||||||
|
<aside className="dashboard-sidebar">
|
||||||
|
<JobList meta={meta} jobs={visibleJobs} selectedJobId={selectedJobId} onSelect={setSelectedJobId} />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="dashboard-main">
|
||||||
|
<section className="panel hero-panel">
|
||||||
|
<div className="hero-head">
|
||||||
|
<div>
|
||||||
|
<div className="eyebrow">单据概览</div>
|
||||||
|
<h2>{businessOverview?.header.supplierName || '请选择一条单据'}</h2>
|
||||||
|
<div className="muted">
|
||||||
|
{businessOverview
|
||||||
|
? `${businessOverview.header.jobId} · ${businessOverview.header.orderDateText} · ${businessOverview.header.sourceTypeText}`
|
||||||
|
: '先从左侧选择一条单据。'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hero-status">
|
||||||
|
<div className="hero-status-text">{businessOverview?.header.currentStatusText || '-'}</div>
|
||||||
|
<div className="muted">当前节点:{businessOverview?.header.currentNodeText || '-'}</div>
|
||||||
|
<div className="muted">责任角色:{businessOverview?.header.currentRoleText || '-'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hero-metrics">
|
||||||
|
<div className="metric-card">
|
||||||
|
<div className="detail-label">开放待办</div>
|
||||||
|
<div className="detail-value">{businessOverview?.summary.openTaskCount ?? 0}</div>
|
||||||
|
</div>
|
||||||
|
<div className="metric-card">
|
||||||
|
<div className="detail-label">证据</div>
|
||||||
|
<div className="detail-value">{businessOverview?.summary.evidenceCount ?? 0}</div>
|
||||||
|
</div>
|
||||||
|
<div className="metric-card">
|
||||||
|
<div className="detail-label">节点</div>
|
||||||
|
<div className="detail-value">{businessOverview?.summary.nodeDetailCount ?? 0}</div>
|
||||||
|
</div>
|
||||||
|
<div className="metric-card">
|
||||||
|
<div className="detail-label">关键编号</div>
|
||||||
|
<div className="detail-value">{businessOverview?.summary.referenceDoneCount ?? 0}/5</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="reference-strip">
|
||||||
|
{businessOverview?.referenceChain.map((item) => (
|
||||||
|
<div key={item.key} className={`reference-pill reference-${item.status.toLowerCase()}`}>
|
||||||
|
<div className="detail-label">{item.label}</div>
|
||||||
|
<div className="detail-value">{item.value}</div>
|
||||||
|
<div className="muted">{item.status === 'DONE' ? '已回填' : '待回填'}</div>
|
||||||
|
</div>
|
||||||
|
)) || <div className="empty">请选择一条单据。</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="primary-task-strip">
|
||||||
|
<div className="detail-label">当前待办</div>
|
||||||
|
{businessOverview?.primaryTask ? (
|
||||||
|
<div className="primary-task-body">
|
||||||
|
<div className="strong">{businessOverview.primaryTask.title}</div>
|
||||||
|
<div className="muted">
|
||||||
|
{businessOverview.primaryTask.nodeText} · {businessOverview.primaryTask.roleText} · {businessOverview.primaryTask.statusText}
|
||||||
|
</div>
|
||||||
|
<div className="muted">{businessOverview.primaryTask.reasonText}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="empty">当前没有未完成待办。</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<JobTimeline meta={meta} timeline={timeline} />
|
||||||
|
|
||||||
|
<section className="workflow-grid" id="task-section">
|
||||||
|
<div className="workflow-tasks">
|
||||||
|
<TaskList meta={meta} tasks={tasks} selectedTaskId={selectedTaskId} onSelect={setSelectedTaskId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="workflow-form">
|
||||||
|
<div className="panel form-panel">
|
||||||
|
<div className="panel-title">待办提交</div>
|
||||||
|
{selectedTask ? (
|
||||||
|
<div className="task-summary">
|
||||||
|
<div>
|
||||||
|
<div className="detail-label">当前待办</div>
|
||||||
|
<div className="detail-value">{selectedTask.title}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="detail-label">处理原因</div>
|
||||||
|
<div className="detail-value">{selectedTask.reason_user_friendly || reasonLabel(selectedTask.reason_type)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="empty">请先选择一条待办。</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedTask ? (
|
||||||
|
<div className="task-form-grid">
|
||||||
|
<label className="detail-label">
|
||||||
|
处理动作
|
||||||
|
<select value={taskAction} onChange={(event) => setTaskAction(event.target.value)}>
|
||||||
|
{selectedTaskActions.map((action) => (
|
||||||
|
<option key={action} value={action}>
|
||||||
|
{actionLabel(action)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{taskAction === 'SELECT_CANDIDATE' ? (
|
||||||
|
<>
|
||||||
|
<label className="detail-label">
|
||||||
|
候选货品
|
||||||
|
<select value={selectedCandidateId} onChange={(event) => setSelectedCandidateId(event.target.value)}>
|
||||||
|
{candidates.map((candidate) => (
|
||||||
|
<option key={candidate.goods_id} value={candidate.goods_id}>
|
||||||
|
{candidate.common_name || candidate.goods_id} {candidate.spec || ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="checkbox-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={saveAsAlias}
|
||||||
|
onChange={(event) => setSaveAsAlias(event.target.checked)}
|
||||||
|
/>
|
||||||
|
保存为下次自动匹配规则
|
||||||
|
</label>
|
||||||
|
{selectedCandidate ? (
|
||||||
|
<div className="candidate-detail">
|
||||||
|
<span>货品编码:{selectedCandidate.goods_code || '-'}</span>
|
||||||
|
<span>生产厂家:{selectedCandidate.manufacturer || '-'}</span>
|
||||||
|
<span>批准文号:{selectedCandidate.approval_no || '-'}</span>
|
||||||
|
<span>基本单位:{selectedCandidate.base_unit || '-'}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="empty">当前待办没有候选货品。</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{taskAction === 'CONFIRM_AND_CONTINUE' && selectedTask.node_code === 'M5_RECEIPT_NOTICE' ? (
|
||||||
|
<label className="detail-label">
|
||||||
|
收货通知单号
|
||||||
|
<input value={receiptNoticeNo} onChange={(event) => setReceiptNoticeNo(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{taskAction === 'TRANSFER_MANUAL' ? (
|
||||||
|
<label className="detail-label">
|
||||||
|
转派角色
|
||||||
|
<select value={targetRole} onChange={(event) => setTargetRole(event.target.value)}>
|
||||||
|
<option value="PURCHASER">采购</option>
|
||||||
|
<option value="PLANNER">计划员</option>
|
||||||
|
<option value="QC">质管</option>
|
||||||
|
<option value="OPS">运维</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="button-row">
|
||||||
|
<button
|
||||||
|
className={taskAction === 'TERMINATE_JOB' ? 'button-danger' : 'button-primary'}
|
||||||
|
disabled={busy || !selectedTask || (taskAction === 'SELECT_CANDIDATE' && !selectedCandidate)}
|
||||||
|
onClick={() => void submitTask()}
|
||||||
|
>
|
||||||
|
{busy ? '提交中' : actionLabel(taskAction)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="muted filter-summary">
|
||||||
|
当前筛选:{roleLabel(roleCode)},{taskStatusLabel(taskStatus)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="collapse-stack">
|
||||||
|
<details className="collapse-panel">
|
||||||
|
<summary>
|
||||||
|
<span>证据包</span>
|
||||||
|
<span className="collapse-count">{businessOverview?.summary.evidenceCount ?? 0}</span>
|
||||||
|
</summary>
|
||||||
|
<JobEvidenceList meta={meta} artifacts={jobArtifacts} onArtifactClick={(path) => void handleArtifactClick(path)} />
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details className="collapse-panel">
|
||||||
|
<summary>
|
||||||
|
<span>节点详情</span>
|
||||||
|
<span className="collapse-count">{businessOverview?.summary.nodeDetailCount ?? 0}</span>
|
||||||
|
</summary>
|
||||||
|
<JobNodeDetailList
|
||||||
|
meta={meta}
|
||||||
|
nodes={jobNodeDetails}
|
||||||
|
onTaskSelect={selectTaskFromNode}
|
||||||
|
onArtifactClick={(path) => void handleArtifactClick(path)}
|
||||||
|
/>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details className="collapse-panel">
|
||||||
|
<summary>
|
||||||
|
<span>验收对照</span>
|
||||||
|
<span className="collapse-count">
|
||||||
|
{businessOverview
|
||||||
|
? `${businessOverview.summary.acceptanceCounts.DONE}/${
|
||||||
|
businessOverview.summary.acceptanceCounts.DONE +
|
||||||
|
businessOverview.summary.acceptanceCounts.PARTIAL +
|
||||||
|
businessOverview.summary.acceptanceCounts.TODO +
|
||||||
|
businessOverview.summary.acceptanceCounts.NA
|
||||||
|
}`
|
||||||
|
: '0/0'}
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<JobAcceptanceChecklist
|
||||||
|
job={jobDetail?.job || null}
|
||||||
|
timeline={timeline}
|
||||||
|
nodes={jobDetail?.nodes || []}
|
||||||
|
artifacts={jobArtifacts}
|
||||||
|
tasks={jobDetail?.tasks || []}
|
||||||
|
/>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{devActionsEnabled ? (
|
||||||
|
<details className="collapse-panel dev-details">
|
||||||
|
<summary>
|
||||||
|
<span>联调回调</span>
|
||||||
|
<span className="collapse-count">DEV</span>
|
||||||
|
</summary>
|
||||||
|
<div className="panel dev-panel">
|
||||||
|
<div className="dev-grid">
|
||||||
|
<label className="detail-label">
|
||||||
|
回调动作
|
||||||
|
<select value={callbackKind} onChange={(event) => setCallbackKind(event.target.value as NodeCallbackKind)}>
|
||||||
|
<option value="start">{nodeCallbackActionLabel('start')}</option>
|
||||||
|
<option value="complete">{nodeCallbackActionLabel('complete')}</option>
|
||||||
|
<option value="fail">{nodeCallbackActionLabel('fail')}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<div className="detail-label">请求路径</div>
|
||||||
|
<div className="dev-endpoint">{callbackEndpoint || '-'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="inline-row">
|
||||||
|
<textarea
|
||||||
|
aria-label="联调回调参数"
|
||||||
|
value={simPayloadText}
|
||||||
|
onChange={(event) => setSimPayloadText(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="button-row">
|
||||||
|
<button disabled={busy || !currentNodeCode} onClick={resetNodeCallbackTemplate}>
|
||||||
|
重置模板
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={callbackKind === 'fail' ? 'button-danger' : 'button-primary'}
|
||||||
|
disabled={busy || !currentNodeCode}
|
||||||
|
onClick={() => void sendNodeCallback()}
|
||||||
|
>
|
||||||
|
{busy ? '发送中' : `发送${nodeCallbackActionLabel(callbackKind)}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="muted">当前节点:{currentNodeCode ? nodeLabel(meta, currentNodeCode) : '-'}</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
export type RequestOptions = {
|
||||||
|
method?: 'GET' | 'POST';
|
||||||
|
body?: unknown;
|
||||||
|
token?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function parseJson(response: Response) {
|
||||||
|
const text = await response.text();
|
||||||
|
if (!text) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestJson<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
if (options.token) {
|
||||||
|
headers['X-Agent-Token'] = options.token;
|
||||||
|
}
|
||||||
|
const response = await fetch(path, {
|
||||||
|
method: options.method ?? 'GET',
|
||||||
|
headers,
|
||||||
|
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||||
|
});
|
||||||
|
const data = await parseJson(response);
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = data && typeof data === 'object' && 'detail' in data ? (data as { detail?: string }).detail : undefined;
|
||||||
|
throw new Error(detail || `请求失败:${response.status}`);
|
||||||
|
}
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiTokenStore = {
|
||||||
|
get(): string;
|
||||||
|
set(value: string): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sessionTokenStore: ApiTokenStore = {
|
||||||
|
get() {
|
||||||
|
return sessionStorage.getItem('state-center-token') || '';
|
||||||
|
},
|
||||||
|
set(value: string) {
|
||||||
|
sessionStorage.setItem('state-center-token', value);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
import {
|
||||||
|
buildAcceptanceChecklist,
|
||||||
|
type JobArtifactRow,
|
||||||
|
type JobNodeRow,
|
||||||
|
type JobRow,
|
||||||
|
type TaskRow,
|
||||||
|
type TimelineNode,
|
||||||
|
} from '../stateMachine';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
job: JobRow | null;
|
||||||
|
timeline: TimelineNode[];
|
||||||
|
nodes: JobNodeRow[];
|
||||||
|
artifacts: JobArtifactRow[];
|
||||||
|
tasks: TaskRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function JobAcceptanceChecklist({ job, timeline, nodes, artifacts, tasks }: Props) {
|
||||||
|
const checklist = job ? buildAcceptanceChecklist(job, timeline, nodes, artifacts, tasks) : [];
|
||||||
|
const counts = checklist.reduce(
|
||||||
|
(acc, item) => {
|
||||||
|
acc[item.status] += 1;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ DONE: 0, PARTIAL: 0, TODO: 0, NA: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panel">
|
||||||
|
<div className="panel-title">验收对照</div>
|
||||||
|
{job ? (
|
||||||
|
<>
|
||||||
|
<div className="acceptance-summary">
|
||||||
|
<span>已完成 {counts.DONE}</span>
|
||||||
|
<span>部分完成 {counts.PARTIAL}</span>
|
||||||
|
<span>未完成 {counts.TODO}</span>
|
||||||
|
<span>不适用 {counts.NA}</span>
|
||||||
|
</div>
|
||||||
|
<div className="acceptance-grid">
|
||||||
|
{checklist.map((item) => (
|
||||||
|
<div key={item.key} className={`acceptance-item acceptance-${item.status.toLowerCase()}`}>
|
||||||
|
<div className="acceptance-head">
|
||||||
|
<div className="strong">{item.label}</div>
|
||||||
|
<span>{acceptanceStatusText(item.status)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="muted">{item.detail}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="empty">请选择一条单据。</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function acceptanceStatusText(status: 'DONE' | 'PARTIAL' | 'TODO' | 'NA') {
|
||||||
|
return {
|
||||||
|
DONE: '已完成',
|
||||||
|
PARTIAL: '部分完成',
|
||||||
|
TODO: '未完成',
|
||||||
|
NA: '不适用',
|
||||||
|
}[status];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { artifactTypeLabel, formatDateTime, nodeLabel, type JobArtifactRow, type StateMachineMeta } from '../stateMachine';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
meta: StateMachineMeta | null;
|
||||||
|
artifacts: JobArtifactRow[];
|
||||||
|
onArtifactClick(storagePath: string): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function JobEvidenceList({ meta, artifacts, onArtifactClick }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="panel">
|
||||||
|
<div className="panel-title">证据包</div>
|
||||||
|
<table className="grid-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>节点</th>
|
||||||
|
<th>尝试</th>
|
||||||
|
<th>路径</th>
|
||||||
|
<th>可见角色</th>
|
||||||
|
<th>脱敏</th>
|
||||||
|
<th>时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{artifacts.map((artifact, index) => (
|
||||||
|
<tr key={`${artifact.node_code || 'job'}-${artifact.artifact_type}-${index}`}>
|
||||||
|
<td>{artifactTypeLabel(artifact.artifact_type)}</td>
|
||||||
|
<td>{artifact.node_code ? nodeLabel(meta, artifact.node_code) : '-'}</td>
|
||||||
|
<td>{artifact.attempt_no ?? '-'}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="path-button"
|
||||||
|
onClick={() => onArtifactClick(artifact.storage_path)}
|
||||||
|
>
|
||||||
|
{artifact.storage_path}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>{formatVisibleRoles(artifact.visible_roles)}</td>
|
||||||
|
<td>{artifact.is_masked ? '是' : '否'}</td>
|
||||||
|
<td>{formatDateTime(artifact.created_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{artifacts.length === 0 ? <div className="empty">当前单据没有证据。</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatVisibleRoles(value?: string[] | string | null) {
|
||||||
|
if (!value) return '-';
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.length ? value.join('、') : '-';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed.length ? parsed.join('、') : '-';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return value || '-';
|
||||||
|
}
|
||||||
|
return value || '-';
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { jobStatusLabel, roleLabel, sourceTypeLabel, nodeLabel, type JobRow, type StateMachineMeta } from '../stateMachine';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
meta: StateMachineMeta | null;
|
||||||
|
jobs: JobRow[];
|
||||||
|
selectedJobId: string;
|
||||||
|
onSelect(jobId: string): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function statusClass(status: string): string {
|
||||||
|
const s = status.toUpperCase();
|
||||||
|
if (s.includes('SUCCESS') || s.includes('DONE')) return 'status-tag-success';
|
||||||
|
if (s.includes('FAIL')) return 'status-tag-failed';
|
||||||
|
if (s.includes('WAIT')) return 'status-tag-waiting';
|
||||||
|
return 'status-tag-running';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JobList({ meta, jobs, selectedJobId, onSelect }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="panel">
|
||||||
|
<div className="panel-title">单据列表 · {jobs.length}</div>
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>供应商</th>
|
||||||
|
<th>日期</th>
|
||||||
|
<th>来源</th>
|
||||||
|
<th>当前节点</th>
|
||||||
|
<th>角色</th>
|
||||||
|
<th>状态</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{jobs.map((job) => (
|
||||||
|
<tr
|
||||||
|
key={job.job_id}
|
||||||
|
className={job.job_id === selectedJobId ? 'is-selected' : ''}
|
||||||
|
onClick={() => onSelect(job.job_id)}
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<div className="cell-title">{job.supplier_name}</div>
|
||||||
|
<div className="cell-sub">{job.job_id}</div>
|
||||||
|
</td>
|
||||||
|
<td>{job.order_date}</td>
|
||||||
|
<td>{sourceTypeLabel(job.source_type)}</td>
|
||||||
|
<td>{nodeLabel(meta, job.current_node_code)}</td>
|
||||||
|
<td>{roleLabel(job.current_role)}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`status-tag ${statusClass(job.status)}`}>
|
||||||
|
{jobStatusLabel(job.status)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{jobs.length === 0 ? <div className="empty">没有可展示的单据。</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
import {
|
||||||
|
artifactTypeLabel,
|
||||||
|
formatDateTime,
|
||||||
|
nodeLabel,
|
||||||
|
reasonLabel,
|
||||||
|
statusLabel,
|
||||||
|
type NodeDetail,
|
||||||
|
type StateMachineMeta,
|
||||||
|
} from '../stateMachine';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
meta: StateMachineMeta | null;
|
||||||
|
nodes: NodeDetail[];
|
||||||
|
onTaskSelect(taskId: string): void;
|
||||||
|
onArtifactClick(storagePath: string): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function JobNodeDetailList({ meta, nodes, onTaskSelect, onArtifactClick }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="panel">
|
||||||
|
<div className="panel-title">节点详情</div>
|
||||||
|
<div className="node-detail-list">
|
||||||
|
{nodes.map((node) => (
|
||||||
|
<div key={node.node_code} className={`node-detail-card status-${node.display_status.toLowerCase()}`}>
|
||||||
|
<div className="node-detail-head">
|
||||||
|
<div>
|
||||||
|
<div className="strong">{nodeLabel(meta, node.node_code)}</div>
|
||||||
|
<div className="muted">{node.node_code}</div>
|
||||||
|
</div>
|
||||||
|
<div className="node-detail-status">{statusLabel(meta, node.display_status)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="node-detail-grid">
|
||||||
|
<div>
|
||||||
|
<div className="detail-label">开始时间</div>
|
||||||
|
<div className="detail-value">{formatDateTime(node.nodeRow?.started_at || node.started_at)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="detail-label">完成时间</div>
|
||||||
|
<div className="detail-value">{formatDateTime(node.nodeRow?.ended_at || node.ended_at)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="detail-label">耗时</div>
|
||||||
|
<div className="detail-value">{node.durationText}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="detail-label">尝试次数</div>
|
||||||
|
<div className="detail-value">{node.nodeRow?.attempt_no ?? node.attempt_no ?? '-'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{node.summaryText ? (
|
||||||
|
<div className="node-message">
|
||||||
|
<div className="detail-label">关键摘要</div>
|
||||||
|
<div>{node.summaryText}</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{node.errorText ? (
|
||||||
|
<div className="node-message node-error">
|
||||||
|
<div className="detail-label">失败原因</div>
|
||||||
|
<div>{node.errorText}</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{node.openTask ? (
|
||||||
|
<div className="node-task-row">
|
||||||
|
<div>
|
||||||
|
<div className="detail-label">待办原因</div>
|
||||||
|
<div>{node.openTask.reason_user_friendly || reasonLabel(node.openTask.reason_type)}</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => onTaskSelect(node.openTask!.task_id)}>
|
||||||
|
去处理
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{node.artifacts.length ? (
|
||||||
|
<div className="node-artifact-list">
|
||||||
|
{node.artifacts.map((artifact, index) => (
|
||||||
|
<div key={`${artifact.artifact_type}-${index}`} className="node-artifact-row">
|
||||||
|
<span>{artifactTypeLabel(artifact.artifact_type)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="path-button"
|
||||||
|
onClick={() => onArtifactClick(artifact.storage_path)}
|
||||||
|
>
|
||||||
|
{artifact.storage_path}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{nodes.length === 0 ? <div className="empty">当前单据没有节点记录。</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { buildJobReferenceChain, type JobRow } from '../stateMachine';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
job: JobRow | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function JobReferenceChain({ job }: Props) {
|
||||||
|
const chain = job ? buildJobReferenceChain(job) : [];
|
||||||
|
return (
|
||||||
|
<div className="panel">
|
||||||
|
<div className="panel-title">编号链</div>
|
||||||
|
{job ? (
|
||||||
|
<div className="reference-chain">
|
||||||
|
{chain.map((item) => (
|
||||||
|
<div key={item.key} className={`reference-item reference-${item.status.toLowerCase()}`}>
|
||||||
|
<div className="detail-label">{item.label}</div>
|
||||||
|
<div className="detail-value">{item.value}</div>
|
||||||
|
<div className="muted">{item.status === 'DONE' ? '已回填' : '待回填'}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="empty">请选择一条单据。</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { executorLabel, nodeLabel, statusLabel, type StateMachineMeta, type TimelineNode } from '../stateMachine';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
meta: StateMachineMeta | null;
|
||||||
|
timeline: TimelineNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function JobTimeline({ meta, timeline }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="panel">
|
||||||
|
<div className="panel-title">流程状态图</div>
|
||||||
|
<div className="timeline">
|
||||||
|
{timeline.map((node) => (
|
||||||
|
<div key={node.node_code} className={`timeline-node status-${node.display_status.toLowerCase()}`}>
|
||||||
|
<div className="timeline-node-name">{nodeLabel(meta, node.node_code)}</div>
|
||||||
|
<div className="timeline-node-status">{statusLabel(meta, node.display_status)}</div>
|
||||||
|
<div className="timeline-node-executor">{executorLabel(meta, node.executor_type || meta?.node_executor[node.node_code])}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { actionLabel, formatDateTime, nodeLabel, reasonLabel, roleLabel, taskStatusLabel, type TaskRow, type StateMachineMeta } from '../stateMachine';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
meta: StateMachineMeta | null;
|
||||||
|
tasks: TaskRow[];
|
||||||
|
selectedTaskId: string;
|
||||||
|
onSelect(taskId: string): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TaskList({ meta, tasks, selectedTaskId, onSelect }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="panel">
|
||||||
|
<div className="panel-title">待办列表 · {tasks.length}</div>
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>待办</th>
|
||||||
|
<th>节点</th>
|
||||||
|
<th>角色</th>
|
||||||
|
<th>状态</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<tr
|
||||||
|
key={task.task_id}
|
||||||
|
className={task.task_id === selectedTaskId ? 'is-selected' : ''}
|
||||||
|
onClick={() => onSelect(task.task_id)}
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<div className="cell-title">{task.title}</div>
|
||||||
|
<div className="cell-sub">{task.reason_user_friendly || reasonLabel(task.reason_type)}</div>
|
||||||
|
</td>
|
||||||
|
<td>{nodeLabel(meta, task.node_code)}</td>
|
||||||
|
<td>{roleLabel(task.role_code)}</td>
|
||||||
|
<td>
|
||||||
|
<span className="status-tag status-tag-waiting">
|
||||||
|
{task.submitted_action ? actionLabel(task.submitted_action) : taskStatusLabel(task.status)}
|
||||||
|
</span>
|
||||||
|
<div className="cell-sub">{formatDateTime(task.created_at)}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{tasks.length === 0 ? <div className="empty">当前没有待办。</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,267 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
actionLabel,
|
||||||
|
allowedTaskActions,
|
||||||
|
buildBusinessOverview,
|
||||||
|
buildAcceptanceChecklist,
|
||||||
|
buildJobReferenceChain,
|
||||||
|
buildNodeCallbackRequestBody,
|
||||||
|
buildNodeDetails,
|
||||||
|
buildNodeCallbackTemplate,
|
||||||
|
jobStatusBucketLabel,
|
||||||
|
jobStatusBucketMatches,
|
||||||
|
nodeCallbackActionLabel,
|
||||||
|
reasonLabel,
|
||||||
|
roleLabel,
|
||||||
|
taskCandidates,
|
||||||
|
taskStatusLabel,
|
||||||
|
} from './stateMachine.ts';
|
||||||
|
|
||||||
|
test('待办相关编码全部显示为中文', () => {
|
||||||
|
assert.equal(roleLabel('PLANNER'), '计划员');
|
||||||
|
assert.equal(taskStatusLabel('OPEN'), '待处理');
|
||||||
|
assert.equal(actionLabel('SELECT_CANDIDATE'), '选择候选货品');
|
||||||
|
assert.equal(reasonLabel('MATCH_MULTI'), '存在多个候选货品');
|
||||||
|
assert.equal(reasonLabel('SOURCE_ADD'), '需要新增货源');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('候选货品兼容数据库返回的数组和 JSON 文本', () => {
|
||||||
|
const task = {
|
||||||
|
task_id: 'TASK-1',
|
||||||
|
job_id: 'JOB-1',
|
||||||
|
node_code: 'PRODUCT_MATCH',
|
||||||
|
role_code: 'PLANNER',
|
||||||
|
status: 'OPEN',
|
||||||
|
title: '对码待确认',
|
||||||
|
reason_type: 'MATCH_MULTI',
|
||||||
|
};
|
||||||
|
assert.equal(taskCandidates({ ...task, candidates_json: '[{"goods_id":"G-1"}]' })[0]?.goods_id, 'G-1');
|
||||||
|
assert.equal(taskCandidates({ ...task, candidates_json: [{ goods_id: 'G-2' }] })[0]?.goods_id, 'G-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('对码待办只提供后端支持的业务动作', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
allowedTaskActions({
|
||||||
|
task_id: 'TASK-1',
|
||||||
|
job_id: 'JOB-1',
|
||||||
|
node_code: 'PRODUCT_MATCH',
|
||||||
|
role_code: 'PLANNER',
|
||||||
|
status: 'OPEN',
|
||||||
|
title: '对码待确认',
|
||||||
|
reason_type: 'MATCH_MULTI',
|
||||||
|
}),
|
||||||
|
['SELECT_CANDIDATE', 'TRANSFER_MANUAL', 'TERMINATE_JOB'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('合同建单成功回调模板带必填合同编号', () => {
|
||||||
|
const template = buildNodeCallbackTemplate('CONTRACT_CREATE', 'complete');
|
||||||
|
assert.equal(template.attemptNo, 1);
|
||||||
|
assert.equal(template.executorPlatform, 'iS-RPA');
|
||||||
|
assert.equal(template.businessRefs.contractSystemNo, 'HT-联调-001');
|
||||||
|
assert.equal(template.artifacts[0].artifactType, 'CONTRACT_PDF');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('节点失败回调模板带后端必填错误字段', () => {
|
||||||
|
const template = buildNodeCallbackTemplate('REBATE_IMPORT', 'fail');
|
||||||
|
assert.equal(nodeCallbackActionLabel('fail'), '失败');
|
||||||
|
assert.equal(template.errorCode, 'SYS_PAGE_TIMEOUT');
|
||||||
|
assert.equal(template.retryable, true);
|
||||||
|
assert.equal(template.routeRole, 'OPS');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('成功回调请求自动补幂等键并保留手工覆盖字段', () => {
|
||||||
|
const body = buildNodeCallbackRequestBody(
|
||||||
|
'complete',
|
||||||
|
'CONTRACT_CREATE',
|
||||||
|
{
|
||||||
|
summary: { contractAmount: 1200 },
|
||||||
|
businessRefs: { contractSystemNo: 'HT-20260811-001' },
|
||||||
|
},
|
||||||
|
'idem-001',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(body.idempotencyKey, 'idem-001');
|
||||||
|
assert.equal(body.businessRefs.contractSystemNo, 'HT-20260811-001');
|
||||||
|
assert.equal(body.summary.contractAmount, 1200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('单据状态筛选按验收口径归类', () => {
|
||||||
|
assert.equal(jobStatusBucketLabel('ACTIVE'), '进行中');
|
||||||
|
assert.equal(jobStatusBucketMatches('ALL', 'SUCCESS'), true);
|
||||||
|
assert.equal(jobStatusBucketMatches('ACTIVE', 'RUNNING'), true);
|
||||||
|
assert.equal(jobStatusBucketMatches('ACTIVE', 'WAITING_EXTERNAL'), true);
|
||||||
|
assert.equal(jobStatusBucketMatches('WAITING', 'WAITING_USER'), true);
|
||||||
|
assert.equal(jobStatusBucketMatches('WAITING', 'RUNNING'), false);
|
||||||
|
assert.equal(jobStatusBucketMatches('FAILED', 'FAILED_MANUAL'), true);
|
||||||
|
assert.equal(jobStatusBucketMatches('SUCCESS', 'SUCCESS'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('节点详情按节点关联执行记录、证据和待办', () => {
|
||||||
|
const details = buildNodeDetails(
|
||||||
|
[
|
||||||
|
{ node_code: 'ORDER_FETCH', display_status: 'SUCCESS', attempt_no: 1, status: 'SUCCESS' },
|
||||||
|
{ node_code: 'SOURCE_ADD', display_status: 'WAITING_USER', attempt_no: 1, status: 'WAITING_USER' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
node_code: 'ORDER_FETCH',
|
||||||
|
attempt_no: 1,
|
||||||
|
status: 'SUCCESS',
|
||||||
|
started_at: '2026-08-11T09:00:00',
|
||||||
|
ended_at: '2026-08-11T09:00:12',
|
||||||
|
output_json: '{"summary":{"orderCount":2}}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
node_code: 'ORDER_FETCH',
|
||||||
|
attempt_no: 1,
|
||||||
|
artifact_type: 'SOURCE_EXCEL',
|
||||||
|
storage_path: '/data/jobs/JOB-1/orders.xlsx',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
task_id: 'TASK-1',
|
||||||
|
job_id: 'JOB-1',
|
||||||
|
node_code: 'SOURCE_ADD',
|
||||||
|
role_code: 'PURCHASER',
|
||||||
|
status: 'OPEN',
|
||||||
|
title: '新增货源',
|
||||||
|
reason_type: 'SOURCE_ADD',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(details[0].summaryText, 'orderCount:2');
|
||||||
|
assert.equal(details[0].artifacts[0].storage_path, '/data/jobs/JOB-1/orders.xlsx');
|
||||||
|
assert.equal(details[1].openTask?.task_id, 'TASK-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('编号链能标出已回填和待回填项', () => {
|
||||||
|
const chain = buildJobReferenceChain({
|
||||||
|
job_id: 'JOB-1',
|
||||||
|
supplier_name: '重庆亿昊药业',
|
||||||
|
order_date: '2026-08-04',
|
||||||
|
source_type: 'YYC',
|
||||||
|
status: 'RUNNING',
|
||||||
|
current_node_code: 'CONTRACT_CREATE',
|
||||||
|
order_no_list_json: '["ZQD-1","ZQD-2"]',
|
||||||
|
contract_system_no: 'HT-1',
|
||||||
|
rebate_order_no: null,
|
||||||
|
m5_order_no: '',
|
||||||
|
receipt_notice_no: 'SH-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(chain.map((item) => item.status), ['DONE', 'DONE', 'MISSING', 'MISSING', 'DONE']);
|
||||||
|
assert.equal(chain[0].value, 'ZQD-1、ZQD-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('业务概览只保留首屏必须信息', () => {
|
||||||
|
const overview = buildBusinessOverview(
|
||||||
|
{
|
||||||
|
job_id: 'JOB-1',
|
||||||
|
supplier_name: '重庆亿昊药业',
|
||||||
|
order_date: '2026-08-04',
|
||||||
|
source_type: 'YYC',
|
||||||
|
status: 'SUCCESS',
|
||||||
|
current_node_code: 'ARCHIVE_JOB',
|
||||||
|
current_role: 'OPS',
|
||||||
|
contract_system_no: 'HT-1',
|
||||||
|
rebate_order_no: 'FL-1',
|
||||||
|
m5_order_no: 'M5-1',
|
||||||
|
receipt_notice_no: 'SH-1',
|
||||||
|
order_no_list_json: '["ZQD-1","ZQD-2"]',
|
||||||
|
},
|
||||||
|
[
|
||||||
|
{ node_code: 'ORDER_FETCH', display_status: 'SUCCESS', attempt_no: 1, status: 'SUCCESS' },
|
||||||
|
{ node_code: 'PRODUCT_MATCH', display_status: 'SUCCESS', attempt_no: 1, status: 'SUCCESS' },
|
||||||
|
{ node_code: 'SOURCE_ADD', display_status: 'WAITING_USER', attempt_no: 1, status: 'WAITING_USER' },
|
||||||
|
{ node_code: 'CONTRACT_CREATE', display_status: 'PENDING' },
|
||||||
|
{ node_code: 'ARCHIVE_JOB', display_status: 'SUCCESS', attempt_no: 1, status: 'SUCCESS' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ task_id: 'TASK-1', job_id: 'JOB-1', node_code: 'SOURCE_ADD', role_code: 'PURCHASER', status: 'OPEN', title: '新增货源:重庆亿昊药业', reason_type: 'SOURCE_ADD' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ node_code: 'ORDER_FETCH', attempt_no: 1, artifact_type: 'SOURCE_EXCEL', storage_path: '/data/jobs/JOB-1/orders.xlsx' },
|
||||||
|
],
|
||||||
|
{
|
||||||
|
node_order: ['ORDER_FETCH', 'PRODUCT_MATCH', 'SOURCE_ADD', 'CONTRACT_CREATE', 'ARCHIVE_JOB'],
|
||||||
|
transitions: {},
|
||||||
|
conditional_nodes: [],
|
||||||
|
human_node_roles: {},
|
||||||
|
node_executor: {},
|
||||||
|
node_labels: {
|
||||||
|
ORDER_FETCH: '订单获取',
|
||||||
|
PRODUCT_MATCH: '对码',
|
||||||
|
SOURCE_ADD: '新增货源',
|
||||||
|
CONTRACT_CREATE: '合同建单',
|
||||||
|
ARCHIVE_JOB: '归档',
|
||||||
|
},
|
||||||
|
executor_labels: {},
|
||||||
|
status_labels: {
|
||||||
|
SUCCESS: '已完成',
|
||||||
|
WAITING_USER: '等待人工',
|
||||||
|
PENDING: '待处理',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(overview.header.currentStatusText, '已完成');
|
||||||
|
assert.equal(overview.header.currentNodeText, '归档');
|
||||||
|
assert.equal(overview.referenceChain.length, 5);
|
||||||
|
assert.equal(overview.primaryTask?.title, '新增货源:重庆亿昊药业');
|
||||||
|
assert.equal(overview.flow.visibleNodes.length, 5);
|
||||||
|
assert.equal(overview.summary.evidenceCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('验收收口能识别真库一单的完成和缺口', () => {
|
||||||
|
const checklist = buildAcceptanceChecklist(
|
||||||
|
{
|
||||||
|
job_id: 'JOB-1',
|
||||||
|
supplier_name: '重庆亿昊药业',
|
||||||
|
order_date: '2026-08-04',
|
||||||
|
source_type: 'YYC',
|
||||||
|
status: 'SUCCESS',
|
||||||
|
current_node_code: 'ARCHIVE_JOB',
|
||||||
|
contract_system_no: 'HT-1',
|
||||||
|
rebate_order_no: 'FL-1',
|
||||||
|
m5_order_no: 'M5-1',
|
||||||
|
receipt_notice_no: 'SH-1',
|
||||||
|
order_no_list_json: '["ZQD-1"]',
|
||||||
|
},
|
||||||
|
[
|
||||||
|
{ node_code: 'ORDER_FETCH', display_status: 'SUCCESS', attempt_no: 1, status: 'SUCCESS' },
|
||||||
|
{ node_code: 'OCR_EXTRACT', display_status: 'SKIPPED' },
|
||||||
|
{ node_code: 'PRODUCT_MATCH', display_status: 'SUCCESS', attempt_no: 1, status: 'SUCCESS' },
|
||||||
|
{ node_code: 'SOURCE_ADD', display_status: 'SUCCESS', attempt_no: 1, status: 'SUCCESS' },
|
||||||
|
{ node_code: 'CONTRACT_CREATE', display_status: 'SUCCESS', attempt_no: 1, status: 'SUCCESS' },
|
||||||
|
{ node_code: 'CONTRACT_APPROVE_WAIT', display_status: 'SUCCESS', attempt_no: 1, status: 'SUCCESS' },
|
||||||
|
{ node_code: 'REBATE_HEADER', display_status: 'SUCCESS', attempt_no: 1, status: 'SUCCESS' },
|
||||||
|
{ node_code: 'REBATE_IMPORT', display_status: 'SUCCESS', attempt_no: 1, status: 'SUCCESS' },
|
||||||
|
{ node_code: 'REBATE_SUBMIT', display_status: 'SUCCESS', attempt_no: 1, status: 'SUCCESS' },
|
||||||
|
{ node_code: 'M5_CREATE_ORDER', display_status: 'SUCCESS', attempt_no: 1, status: 'SUCCESS' },
|
||||||
|
{ node_code: 'M5_MANUAL_CONFIRM', display_status: 'SUCCESS', attempt_no: 1, status: 'SUCCESS' },
|
||||||
|
{ node_code: 'M5_RECEIPT_NOTICE', display_status: 'SUCCESS', attempt_no: 1, status: 'SUCCESS' },
|
||||||
|
{ node_code: 'ARCHIVE_JOB', display_status: 'SUCCESS', attempt_no: 1, status: 'SUCCESS' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ node_code: 'ORDER_FETCH', attempt_no: 1, status: 'SUCCESS', started_at: '2026-08-11T09:00:00', ended_at: '2026-08-11T09:00:12', output_json: '{"summary":{"orderCount":1}}' },
|
||||||
|
{ node_code: 'CONTRACT_CREATE', attempt_no: 1, status: 'SUCCESS', started_at: '2026-08-11T09:02:00', ended_at: '2026-08-11T09:02:12', output_json: '{"summary":{"saved":true}}' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ node_code: 'ORDER_FETCH', attempt_no: 1, artifact_type: 'SOURCE_EXCEL', storage_path: '/data/jobs/JOB-1/orders.xlsx' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ task_id: 'TASK-1', job_id: 'JOB-1', node_code: 'M5_MANUAL_CONFIRM', role_code: 'PURCHASER', status: 'SUBMITTED', title: 'M5人工确认', reason_type: 'SOURCE_ADD' },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(checklist.find((item) => item.key === 'timeline_complete')?.status, 'DONE');
|
||||||
|
assert.equal(checklist.find((item) => item.key === 'node_detail_coverage')?.status, 'PARTIAL');
|
||||||
|
assert.equal(checklist.find((item) => item.key === 'reference_chain')?.status, 'DONE');
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,746 @@
|
||||||
|
export type StateMachineMeta = {
|
||||||
|
node_order: string[];
|
||||||
|
transitions: Record<string, string | null>;
|
||||||
|
conditional_nodes: string[];
|
||||||
|
human_node_roles: Record<string, string>;
|
||||||
|
node_executor: Record<string, string>;
|
||||||
|
node_labels: Record<string, string>;
|
||||||
|
executor_labels: Record<string, string>;
|
||||||
|
status_labels: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JobRow = {
|
||||||
|
job_id: string;
|
||||||
|
supplier_name: string;
|
||||||
|
order_date: string;
|
||||||
|
source_type: string;
|
||||||
|
status: string;
|
||||||
|
current_node_code: string;
|
||||||
|
current_role?: string | null;
|
||||||
|
order_no_list_json?: string | null;
|
||||||
|
contract_system_no?: string | null;
|
||||||
|
rebate_order_no?: string | null;
|
||||||
|
m5_order_no?: string | null;
|
||||||
|
receipt_notice_no?: string | null;
|
||||||
|
created_at?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
finished_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JobArtifactRow = {
|
||||||
|
node_code?: string | null;
|
||||||
|
attempt_no?: number | null;
|
||||||
|
artifact_type: string;
|
||||||
|
storage_path: string;
|
||||||
|
file_hash?: string | null;
|
||||||
|
visible_roles?: string[] | null;
|
||||||
|
is_masked?: boolean | null;
|
||||||
|
created_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JobStatusBucket = 'ALL' | 'ACTIVE' | 'WAITING' | 'FAILED' | 'SUCCESS';
|
||||||
|
|
||||||
|
export type TimelineNode = {
|
||||||
|
node_code: string;
|
||||||
|
display_status: string;
|
||||||
|
attempt_no?: number | null;
|
||||||
|
executor_type?: string | null;
|
||||||
|
status?: string | null;
|
||||||
|
started_at?: string | null;
|
||||||
|
ended_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JobNodeRow = {
|
||||||
|
node_code: string;
|
||||||
|
attempt_no: number;
|
||||||
|
status: string;
|
||||||
|
started_at?: string | null;
|
||||||
|
ended_at?: string | null;
|
||||||
|
duration_ms?: number | null;
|
||||||
|
error_code?: string | null;
|
||||||
|
error_summary?: string | null;
|
||||||
|
output_json?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TaskRow = {
|
||||||
|
task_id: string;
|
||||||
|
job_id: string;
|
||||||
|
node_code: string;
|
||||||
|
order_item_id?: number | null;
|
||||||
|
product_match_id?: number | null;
|
||||||
|
role_code: string;
|
||||||
|
status: string;
|
||||||
|
title: string;
|
||||||
|
reason_type: string;
|
||||||
|
reason_user_friendly?: string | null;
|
||||||
|
why_me_text?: string | null;
|
||||||
|
candidates_json?: unknown;
|
||||||
|
submitted_action?: string | null;
|
||||||
|
submitted_at?: string | null;
|
||||||
|
created_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GoodsCandidate = {
|
||||||
|
goods_id: string;
|
||||||
|
goods_code?: string | null;
|
||||||
|
common_name?: string | null;
|
||||||
|
spec?: string | null;
|
||||||
|
base_unit?: string | null;
|
||||||
|
manufacturer?: string | null;
|
||||||
|
approval_no?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NodeDetail = TimelineNode & {
|
||||||
|
nodeRow?: JobNodeRow;
|
||||||
|
artifacts: JobArtifactRow[];
|
||||||
|
openTask?: TaskRow;
|
||||||
|
summaryText: string;
|
||||||
|
errorText: string;
|
||||||
|
durationText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReferenceChainItem = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
status: 'DONE' | 'MISSING';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AcceptanceChecklistItem = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
status: 'DONE' | 'PARTIAL' | 'TODO' | 'NA';
|
||||||
|
detail: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BusinessOverviewHeader = {
|
||||||
|
supplierName: string;
|
||||||
|
jobId: string;
|
||||||
|
orderDateText: string;
|
||||||
|
sourceTypeText: string;
|
||||||
|
currentStatusText: string;
|
||||||
|
currentNodeText: string;
|
||||||
|
currentRoleText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BusinessOverviewFlowNode = {
|
||||||
|
node_code: string;
|
||||||
|
label: string;
|
||||||
|
executorText: string;
|
||||||
|
statusText: string;
|
||||||
|
state: 'DONE' | 'CURRENT' | 'WAITING' | 'FAILED' | 'SKIPPED' | 'TODO';
|
||||||
|
isCurrent: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BusinessOverviewTask = {
|
||||||
|
task_id: string;
|
||||||
|
title: string;
|
||||||
|
nodeText: string;
|
||||||
|
roleText: string;
|
||||||
|
statusText: string;
|
||||||
|
reasonText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BusinessOverview = {
|
||||||
|
header: BusinessOverviewHeader;
|
||||||
|
referenceChain: ReferenceChainItem[];
|
||||||
|
primaryTask: BusinessOverviewTask | null;
|
||||||
|
flow: {
|
||||||
|
currentIndex: number;
|
||||||
|
totalCount: number;
|
||||||
|
completedCount: number;
|
||||||
|
waitingCount: number;
|
||||||
|
failedCount: number;
|
||||||
|
skippedCount: number;
|
||||||
|
visibleNodes: BusinessOverviewFlowNode[];
|
||||||
|
};
|
||||||
|
summary: {
|
||||||
|
evidenceCount: number;
|
||||||
|
nodeDetailCount: number;
|
||||||
|
openTaskCount: number;
|
||||||
|
referenceDoneCount: number;
|
||||||
|
acceptanceCounts: Record<AcceptanceChecklistItem['status'], number>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NodeCallbackKind = 'start' | 'complete' | 'fail';
|
||||||
|
|
||||||
|
type JsonObject = Record<string, unknown>;
|
||||||
|
|
||||||
|
export function nodeLabel(meta: StateMachineMeta | null, nodeCode: string) {
|
||||||
|
return meta?.node_labels[nodeCode] || nodeCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function executorLabel(meta: StateMachineMeta | null, executorCode?: string | null) {
|
||||||
|
if (!executorCode) return '';
|
||||||
|
return meta?.executor_labels[executorCode] || executorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusLabel(meta: StateMachineMeta | null, statusCode?: string | null) {
|
||||||
|
if (!statusCode) return '';
|
||||||
|
return meta?.status_labels[statusCode] || statusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function taskStatusLabel(status: string) {
|
||||||
|
return {
|
||||||
|
OPEN: '待处理',
|
||||||
|
SUBMITTED: '已提交',
|
||||||
|
CANCELLED: '已取消',
|
||||||
|
EXPIRED: '已过期',
|
||||||
|
}[status] || status;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jobStatusLabel(status: string) {
|
||||||
|
return {
|
||||||
|
PENDING: '待执行',
|
||||||
|
RUNNING: '执行中',
|
||||||
|
WAITING_USER: '等待人工',
|
||||||
|
WAITING_EXTERNAL: '等待外部',
|
||||||
|
SUCCESS: '已完成',
|
||||||
|
FAILED_RETRYABLE: '可重试失败',
|
||||||
|
FAILED_MANUAL: '人工失败',
|
||||||
|
CANCELLED: '已取消',
|
||||||
|
}[status] || status;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jobStatusBucketLabel(bucket: JobStatusBucket) {
|
||||||
|
return {
|
||||||
|
ALL: '全部',
|
||||||
|
ACTIVE: '进行中',
|
||||||
|
WAITING: '等人处理',
|
||||||
|
FAILED: '失败',
|
||||||
|
SUCCESS: '完成',
|
||||||
|
}[bucket];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jobStatusBucketMatches(bucket: JobStatusBucket, status: string) {
|
||||||
|
if (bucket === 'ALL') return true;
|
||||||
|
if (bucket === 'ACTIVE') {
|
||||||
|
return ['PENDING', 'RUNNING', 'WAITING_USER', 'WAITING_EXTERNAL'].includes(status);
|
||||||
|
}
|
||||||
|
if (bucket === 'WAITING') {
|
||||||
|
return ['WAITING_USER', 'WAITING_EXTERNAL'].includes(status);
|
||||||
|
}
|
||||||
|
if (bucket === 'FAILED') {
|
||||||
|
return ['FAILED', 'FAILED_RETRYABLE', 'FAILED_MANUAL'].includes(status);
|
||||||
|
}
|
||||||
|
if (bucket === 'SUCCESS') {
|
||||||
|
return status === 'SUCCESS';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roleLabel(roleCode?: string | null) {
|
||||||
|
return {
|
||||||
|
PURCHASER: '采购',
|
||||||
|
PLANNER: '计划员',
|
||||||
|
QC: '质管',
|
||||||
|
OPS: '运维',
|
||||||
|
}[roleCode || ''] || (roleCode || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sourceTypeLabel(sourceType?: string | null) {
|
||||||
|
return {
|
||||||
|
YYC: '医药城',
|
||||||
|
XYY: '小药药',
|
||||||
|
OFFLINE: '线下单',
|
||||||
|
}[sourceType || ''] || (sourceType || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function actionLabel(action: string) {
|
||||||
|
return {
|
||||||
|
CONFIRM_AND_CONTINUE: '确认并继续',
|
||||||
|
SELECT_CANDIDATE: '选择候选货品',
|
||||||
|
RETRY_CURRENT_NODE: '重试当前节点',
|
||||||
|
TRANSFER_MANUAL: '转派人工',
|
||||||
|
TERMINATE_JOB: '终止流程',
|
||||||
|
}[action] || action;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nodeCallbackActionLabel(kind: NodeCallbackKind) {
|
||||||
|
return {
|
||||||
|
start: '开始',
|
||||||
|
complete: '成功',
|
||||||
|
fail: '失败',
|
||||||
|
}[kind];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildNodeCallbackTemplate(nodeCode: string, kind: NodeCallbackKind): JsonObject {
|
||||||
|
if (kind === 'start') {
|
||||||
|
return {
|
||||||
|
attemptNo: 1,
|
||||||
|
executorPlatform: 'iS-RPA',
|
||||||
|
operator: 'agent',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (kind === 'fail') {
|
||||||
|
return {
|
||||||
|
attemptNo: 1,
|
||||||
|
errorCode: 'SYS_PAGE_TIMEOUT',
|
||||||
|
errorSummary: '页面超时,联调模拟失败',
|
||||||
|
retryable: true,
|
||||||
|
routeRole: 'OPS',
|
||||||
|
artifacts: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const businessRefs = buildNodeBusinessRefs(nodeCode);
|
||||||
|
const artifacts = buildNodeArtifacts(nodeCode);
|
||||||
|
return {
|
||||||
|
attemptNo: 1,
|
||||||
|
executorPlatform: 'iS-RPA',
|
||||||
|
operator: 'agent',
|
||||||
|
executorRunId: `YSQ-${nodeCode}-RUN-001`,
|
||||||
|
businessRefs,
|
||||||
|
summary: {
|
||||||
|
nodeCode,
|
||||||
|
result: 'ok',
|
||||||
|
},
|
||||||
|
artifacts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatNodeCallbackTemplate(nodeCode: string, kind: NodeCallbackKind) {
|
||||||
|
return JSON.stringify(buildNodeCallbackTemplate(nodeCode, kind), null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildNodeCallbackRequestBody(
|
||||||
|
kind: NodeCallbackKind,
|
||||||
|
nodeCode: string,
|
||||||
|
overrides: JsonObject,
|
||||||
|
idempotencyKey?: string,
|
||||||
|
): JsonObject {
|
||||||
|
const body = mergeJsonObjects(buildNodeCallbackTemplate(nodeCode, kind), overrides);
|
||||||
|
if (kind === 'complete' && !body.idempotencyKey && idempotencyKey) {
|
||||||
|
body.idempotencyKey = idempotencyKey;
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reasonLabel(reasonType?: string | null) {
|
||||||
|
return {
|
||||||
|
MATCH_MULTI: '存在多个候选货品',
|
||||||
|
MATCH_NONE: '未找到匹配货品',
|
||||||
|
SOURCE_ADD: '需要新增货源',
|
||||||
|
OCR_LOW_CONF: '识别结果置信度低',
|
||||||
|
REBATE_DUP: '返利单疑似重复',
|
||||||
|
QC_MISSING: '质管信息缺失',
|
||||||
|
CONTRACT_DISABLED: '合同功能不可用',
|
||||||
|
}[reasonType || ''] || (reasonType || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function artifactTypeLabel(artifactType?: string | null) {
|
||||||
|
return {
|
||||||
|
SOURCE_EXCEL: '订单导出',
|
||||||
|
SOURCE_IMAGE: '订单截图',
|
||||||
|
OCR_JSON: '识别结果',
|
||||||
|
CONTRACT_PDF: '合同文件',
|
||||||
|
MERGED_PDF: '合并文件',
|
||||||
|
SCREENSHOT: '截图',
|
||||||
|
TEMPLATE: '模板文件',
|
||||||
|
LOG: '日志',
|
||||||
|
}[artifactType || ''] || (artifactType || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNodeBusinessRefs(nodeCode: string): JsonObject {
|
||||||
|
if (nodeCode === 'CONTRACT_CREATE') {
|
||||||
|
return { contractSystemNo: 'HT-联调-001' };
|
||||||
|
}
|
||||||
|
if (nodeCode === 'REBATE_HEADER') {
|
||||||
|
return { rebateOrderNo: 'FL-联调-001' };
|
||||||
|
}
|
||||||
|
if (nodeCode === 'M5_CREATE_ORDER') {
|
||||||
|
return { m5OrderNo: 'M5-联调-001' };
|
||||||
|
}
|
||||||
|
if (nodeCode === 'M5_RECEIPT_NOTICE') {
|
||||||
|
return { receiptNoticeNo: 'SH-联调-001' };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNodeArtifacts(nodeCode: string): JsonObject[] {
|
||||||
|
if (nodeCode === 'ORDER_FETCH') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
artifactType: 'SOURCE_EXCEL',
|
||||||
|
storagePath: '/data/jobs/联调/orders.xlsx',
|
||||||
|
fileHash: 'sha256:dev-order-export',
|
||||||
|
visibleRoles: ['PURCHASER', 'PLANNER'],
|
||||||
|
isMasked: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (nodeCode === 'OCR_EXTRACT') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
artifactType: 'OCR_JSON',
|
||||||
|
storagePath: '/data/jobs/联调/ocr.json',
|
||||||
|
fileHash: 'sha256:dev-ocr-json',
|
||||||
|
visibleRoles: ['PLANNER'],
|
||||||
|
isMasked: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (nodeCode === 'CONTRACT_CREATE') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
artifactType: 'CONTRACT_PDF',
|
||||||
|
storagePath: '/data/jobs/联调/contract.pdf',
|
||||||
|
fileHash: 'sha256:dev-contract-pdf',
|
||||||
|
visibleRoles: ['PURCHASER'],
|
||||||
|
isMasked: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeJsonObjects(base: JsonObject, overrides: JsonObject): JsonObject {
|
||||||
|
const merged: JsonObject = { ...base };
|
||||||
|
for (const [key, value] of Object.entries(overrides)) {
|
||||||
|
const existing = merged[key];
|
||||||
|
if (isPlainObject(existing) && isPlainObject(value)) {
|
||||||
|
merged[key] = mergeJsonObjects(existing, value);
|
||||||
|
} else {
|
||||||
|
merged[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is JsonObject {
|
||||||
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildNodeDetails(
|
||||||
|
timeline: TimelineNode[],
|
||||||
|
nodes: JobNodeRow[],
|
||||||
|
artifacts: JobArtifactRow[],
|
||||||
|
tasks: TaskRow[],
|
||||||
|
): NodeDetail[] {
|
||||||
|
return timeline.map((node) => {
|
||||||
|
const nodeRow = nodes.find(
|
||||||
|
(row) => row.node_code === node.node_code && (
|
||||||
|
node.attempt_no == null || row.attempt_no === node.attempt_no
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const attemptNo = nodeRow?.attempt_no ?? node.attempt_no;
|
||||||
|
const nodeArtifacts = artifacts.filter((artifact) =>
|
||||||
|
artifact.node_code === node.node_code && (
|
||||||
|
attemptNo == null || artifact.attempt_no == null || artifact.attempt_no === attemptNo
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const openTask = tasks.find((task) => task.node_code === node.node_code && task.status === 'OPEN');
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
nodeRow,
|
||||||
|
artifacts: nodeArtifacts,
|
||||||
|
openTask,
|
||||||
|
summaryText: formatSummary(nodeRow?.output_json),
|
||||||
|
errorText: [nodeRow?.error_code, nodeRow?.error_summary].filter(Boolean).join(':'),
|
||||||
|
durationText: formatDuration(nodeRow?.duration_ms, nodeRow?.started_at, nodeRow?.ended_at),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildJobReferenceChain(job: JobRow): ReferenceChainItem[] {
|
||||||
|
const items = [
|
||||||
|
{ key: 'order_no_list_json', label: '订单号', value: formatOrderNos(job.order_no_list_json) },
|
||||||
|
{ key: 'contract_system_no', label: '合同编号', value: job.contract_system_no || '' },
|
||||||
|
{ key: 'rebate_order_no', label: '返利总单号', value: job.rebate_order_no || '' },
|
||||||
|
{ key: 'm5_order_no', label: 'M5单号', value: job.m5_order_no || '' },
|
||||||
|
{ key: 'receipt_notice_no', label: '收货通知单号', value: job.receipt_notice_no || '' },
|
||||||
|
];
|
||||||
|
return items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
value: item.value || '-',
|
||||||
|
status: item.value && item.value !== '-' ? 'DONE' : 'MISSING',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAcceptanceChecklist(
|
||||||
|
job: JobRow,
|
||||||
|
timeline: TimelineNode[],
|
||||||
|
nodes: JobNodeRow[],
|
||||||
|
artifacts: JobArtifactRow[],
|
||||||
|
tasks: TaskRow[],
|
||||||
|
): AcceptanceChecklistItem[] {
|
||||||
|
const nodeDetails = buildNodeDetails(timeline, nodes, artifacts, tasks);
|
||||||
|
const completedNodes = nodeDetails.filter((node) => node.display_status === 'SUCCESS');
|
||||||
|
const completedWithEvidence = completedNodes.filter(
|
||||||
|
(node) => node.artifacts.length > 0 && Boolean(node.summaryText),
|
||||||
|
);
|
||||||
|
const referenceChain = buildJobReferenceChain(job);
|
||||||
|
const referenceDoneCount = referenceChain.filter((item) => item.status === 'DONE').length;
|
||||||
|
const failedNodes = nodeDetails.filter((node) => node.display_status === 'FAILED');
|
||||||
|
const openTasks = tasks.filter((task) => task.status === 'OPEN');
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'e2e_job',
|
||||||
|
label: '真库一单已跑通',
|
||||||
|
status: job.status === 'SUCCESS' ? 'DONE' : job.status === 'WAITING_USER' ? 'PARTIAL' : 'TODO',
|
||||||
|
detail: `当前状态:${jobStatusLabel(job.status)};当前节点:${job.current_node_code}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'page_zh',
|
||||||
|
label: '页面全中文',
|
||||||
|
status: 'DONE',
|
||||||
|
detail: '界面文案、按钮、筛选和详情均使用中文。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'browser_common',
|
||||||
|
label: 'Windows/macOS 通用浏览器',
|
||||||
|
status: 'DONE',
|
||||||
|
detail: '当前实现走标准浏览器,不依赖 Electron。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'timeline_complete',
|
||||||
|
label: '节点链完整',
|
||||||
|
status: timeline.length >= 13 ? 'DONE' : timeline.length > 0 ? 'PARTIAL' : 'TODO',
|
||||||
|
detail: `已展示 ${timeline.length}/13 个节点。`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'node_detail_coverage',
|
||||||
|
label: '完成节点有摘要和证据',
|
||||||
|
status:
|
||||||
|
completedNodes.length === 0
|
||||||
|
? 'TODO'
|
||||||
|
: completedWithEvidence.length === completedNodes.length
|
||||||
|
? 'DONE'
|
||||||
|
: completedWithEvidence.length > 0
|
||||||
|
? 'PARTIAL'
|
||||||
|
: 'TODO',
|
||||||
|
detail: `完成节点 ${completedWithEvidence.length}/${completedNodes.length} 个带摘要和证据。`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'reference_chain',
|
||||||
|
label: '编号链完整',
|
||||||
|
status: referenceDoneCount === referenceChain.length
|
||||||
|
? 'DONE'
|
||||||
|
: referenceDoneCount > 0
|
||||||
|
? 'PARTIAL'
|
||||||
|
: 'TODO',
|
||||||
|
detail: `已回填 ${referenceDoneCount}/${referenceChain.length} 个关键编号。`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'evidence_package',
|
||||||
|
label: '证据包可导出',
|
||||||
|
status: artifacts.length > 0 ? 'DONE' : 'TODO',
|
||||||
|
detail: `当前单据已有 ${artifacts.length} 条证据。`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'open_tasks_closed',
|
||||||
|
label: '待办已收口',
|
||||||
|
status: openTasks.length === 0 ? 'DONE' : 'PARTIAL',
|
||||||
|
detail: openTasks.length === 0 ? '当前没有未关闭待办。' : `仍有 ${openTasks.length} 条开放待办。`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'failure_visibility',
|
||||||
|
label: '失败节点可追溯',
|
||||||
|
status: failedNodes.length === 0
|
||||||
|
? 'NA'
|
||||||
|
: failedNodes.every((node) => node.errorText && node.artifacts.length > 0)
|
||||||
|
? 'DONE'
|
||||||
|
: 'PARTIAL',
|
||||||
|
detail:
|
||||||
|
failedNodes.length === 0
|
||||||
|
? '当前单据无失败节点。'
|
||||||
|
: `失败节点 ${failedNodes.length} 个,已覆盖 ${failedNodes.filter((node) => node.errorText).length} 个错误原因。`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBusinessOverview(
|
||||||
|
job: JobRow,
|
||||||
|
timeline: TimelineNode[],
|
||||||
|
tasks: TaskRow[],
|
||||||
|
artifacts: JobArtifactRow[],
|
||||||
|
meta: StateMachineMeta | null = null,
|
||||||
|
nodes: JobNodeRow[] = [],
|
||||||
|
): BusinessOverview {
|
||||||
|
const referenceChain = buildJobReferenceChain(job);
|
||||||
|
const nodeDetails = buildNodeDetails(timeline, nodes, artifacts, tasks);
|
||||||
|
const checklist = buildAcceptanceChecklist(job, timeline, nodes, artifacts, tasks);
|
||||||
|
const openTasks = tasks.filter((task) => task.status === 'OPEN');
|
||||||
|
const primaryTask = pickPrimaryTask(openTasks, job.current_node_code, meta);
|
||||||
|
const currentIndex = pickCurrentNodeIndex(timeline, job.current_node_code);
|
||||||
|
const visibleNodes = timeline.map((node, index) => {
|
||||||
|
const isCurrent = index === currentIndex;
|
||||||
|
const executorCode = node.executor_type || meta?.node_executor[node.node_code];
|
||||||
|
const normalizedState = normalizeOverviewState(node.display_status);
|
||||||
|
return {
|
||||||
|
node_code: node.node_code,
|
||||||
|
label: nodeLabel(meta, node.node_code),
|
||||||
|
executorText: executorLabel(meta, executorCode),
|
||||||
|
statusText: statusLabel(meta, node.display_status),
|
||||||
|
state: isCurrent && normalizedState !== 'FAILED' && normalizedState !== 'SKIPPED' ? 'CURRENT' : normalizedState,
|
||||||
|
isCurrent,
|
||||||
|
} satisfies BusinessOverviewFlowNode;
|
||||||
|
});
|
||||||
|
|
||||||
|
const acceptanceCounts = checklist.reduce<Record<AcceptanceChecklistItem['status'], number>>(
|
||||||
|
(acc, item) => {
|
||||||
|
acc[item.status] += 1;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ DONE: 0, PARTIAL: 0, TODO: 0, NA: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
header: {
|
||||||
|
supplierName: job.supplier_name,
|
||||||
|
jobId: job.job_id,
|
||||||
|
orderDateText: job.order_date,
|
||||||
|
sourceTypeText: sourceTypeLabel(job.source_type),
|
||||||
|
currentStatusText: jobStatusLabel(job.status),
|
||||||
|
currentNodeText: nodeLabel(meta, job.current_node_code),
|
||||||
|
currentRoleText: roleLabel(job.current_role),
|
||||||
|
},
|
||||||
|
referenceChain,
|
||||||
|
primaryTask,
|
||||||
|
flow: {
|
||||||
|
currentIndex,
|
||||||
|
totalCount: timeline.length,
|
||||||
|
completedCount: nodeDetails.filter((node) => node.display_status === 'SUCCESS').length,
|
||||||
|
waitingCount: nodeDetails.filter((node) => ['PENDING', 'RUNNING', 'WAITING_USER', 'WAITING_EXTERNAL'].includes(node.display_status)).length,
|
||||||
|
failedCount: nodeDetails.filter((node) => node.display_status === 'FAILED').length,
|
||||||
|
skippedCount: nodeDetails.filter((node) => node.display_status === 'SKIPPED').length,
|
||||||
|
visibleNodes,
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
evidenceCount: artifacts.length,
|
||||||
|
nodeDetailCount: nodeDetails.length,
|
||||||
|
openTaskCount: openTasks.length,
|
||||||
|
referenceDoneCount: referenceChain.filter((item) => item.status === 'DONE').length,
|
||||||
|
acceptanceCounts,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSummary(value: unknown) {
|
||||||
|
const parsed = parseJsonish(value);
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return '';
|
||||||
|
const summary = 'summary' in parsed ? (parsed as { summary?: unknown }).summary : parsed;
|
||||||
|
if (!summary || typeof summary !== 'object' || Array.isArray(summary)) return '';
|
||||||
|
return Object.entries(summary)
|
||||||
|
.filter(([, entryValue]) => entryValue !== undefined && entryValue !== null && entryValue !== '')
|
||||||
|
.map(([key, entryValue]) => `${key}:${formatSummaryValue(entryValue)}`)
|
||||||
|
.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSummaryValue(value: unknown) {
|
||||||
|
if (Array.isArray(value)) return value.join('、');
|
||||||
|
if (typeof value === 'object' && value !== null) return JSON.stringify(value);
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonish(value: unknown) {
|
||||||
|
if (!value) return null;
|
||||||
|
if (typeof value !== 'string') return value;
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(durationMs?: number | null, startedAt?: string | null, endedAt?: string | null) {
|
||||||
|
const duration = durationMs ?? inferDurationMs(startedAt, endedAt);
|
||||||
|
if (duration == null || !Number.isFinite(duration)) return '-';
|
||||||
|
if (duration < 1000) return `${duration}毫秒`;
|
||||||
|
return `${Math.round(duration / 1000)}秒`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferDurationMs(startedAt?: string | null, endedAt?: string | null) {
|
||||||
|
if (!startedAt || !endedAt) return null;
|
||||||
|
const start = Date.parse(startedAt);
|
||||||
|
const end = Date.parse(endedAt);
|
||||||
|
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return null;
|
||||||
|
return end - start;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOrderNos(value?: string | null) {
|
||||||
|
if (!value) return '-';
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed.length ? parsed.join('、') : '-';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickPrimaryTask(tasks: TaskRow[], currentNodeCode: string, meta: StateMachineMeta | null): BusinessOverviewTask | null {
|
||||||
|
if (!tasks.length) return null;
|
||||||
|
const matchedTask = tasks.find((task) => task.node_code === currentNodeCode) || tasks[0];
|
||||||
|
if (!matchedTask) return null;
|
||||||
|
return {
|
||||||
|
task_id: matchedTask.task_id,
|
||||||
|
title: matchedTask.title,
|
||||||
|
nodeText: nodeLabel(meta, matchedTask.node_code),
|
||||||
|
roleText: roleLabel(matchedTask.role_code),
|
||||||
|
statusText: taskStatusLabel(matchedTask.status),
|
||||||
|
reasonText: matchedTask.reason_user_friendly || reasonLabel(matchedTask.reason_type),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickCurrentNodeIndex(timeline: TimelineNode[], currentNodeCode: string) {
|
||||||
|
const exactIndex = timeline.findIndex((node) => node.node_code === currentNodeCode);
|
||||||
|
if (exactIndex >= 0) return exactIndex;
|
||||||
|
const activeIndex = timeline.findIndex((node) => normalizeOverviewState(node.display_status) === 'CURRENT');
|
||||||
|
if (activeIndex >= 0) return activeIndex;
|
||||||
|
return timeline.findIndex((node) => normalizeOverviewState(node.display_status) === 'WAITING') >= 0
|
||||||
|
? timeline.findIndex((node) => normalizeOverviewState(node.display_status) === 'WAITING')
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOverviewState(displayStatus?: string | null): BusinessOverviewFlowNode['state'] {
|
||||||
|
switch (displayStatus) {
|
||||||
|
case 'SUCCESS':
|
||||||
|
return 'DONE';
|
||||||
|
case 'RUNNING':
|
||||||
|
case 'WAITING_USER':
|
||||||
|
case 'WAITING_EXTERNAL':
|
||||||
|
case 'PENDING':
|
||||||
|
return 'WAITING';
|
||||||
|
case 'FAILED':
|
||||||
|
return 'FAILED';
|
||||||
|
case 'SKIPPED':
|
||||||
|
return 'SKIPPED';
|
||||||
|
default:
|
||||||
|
return 'TODO';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function allowedTaskActions(task: TaskRow) {
|
||||||
|
if (task.node_code === 'PRODUCT_MATCH') {
|
||||||
|
return ['SELECT_CANDIDATE', 'TRANSFER_MANUAL', 'TERMINATE_JOB'];
|
||||||
|
}
|
||||||
|
return ['CONFIRM_AND_CONTINUE', 'TRANSFER_MANUAL', 'RETRY_CURRENT_NODE', 'TERMINATE_JOB'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function taskCandidates(task: TaskRow | null): GoodsCandidate[] {
|
||||||
|
if (!task?.candidates_json) return [];
|
||||||
|
let value = task.candidates_json;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
try {
|
||||||
|
value = JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.filter(
|
||||||
|
(candidate): candidate is GoodsCandidate =>
|
||||||
|
Boolean(candidate && typeof candidate === 'object' && 'goods_id' in candidate),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(value?: string | null) {
|
||||||
|
if (!value) return '-';
|
||||||
|
const text = String(value);
|
||||||
|
return text.replace('T', ' ').replace('.000000', '').slice(0, 19);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": []
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://127.0.0.1:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
# 艺赛旗开发包说明
|
||||||
|
|
||||||
|
这个包包含艺赛旗(iS-RPA)开发采购主链 RPA 一期所需的全部材料。
|
||||||
|
|
||||||
|
## 文档清单
|
||||||
|
|
||||||
|
### 📘 1-技术规范(必读).md
|
||||||
|
**就是** `state_center/给艺赛旗的开发交接.md`
|
||||||
|
|
||||||
|
**用途**:你们的技术圣经,开发过程中反复查。
|
||||||
|
|
||||||
|
**内容**:
|
||||||
|
- HTTP 回调契约(start/complete/fail 三个接口的字段规范)
|
||||||
|
- 三把钥匙(必须回传的业务编号:合同编号、返利总单号、收货通知单号)
|
||||||
|
- 错误码表(什么情况返回什么错误码、retryable 怎么填、routeRole 怎么填)
|
||||||
|
- 逐节点操作说明(哪个节点点什么页面、拿什么编号、失败怎么处理)
|
||||||
|
- 硬规则红线(10条,踩了就是错的)
|
||||||
|
|
||||||
|
### 📋 2-并行开发任务.md
|
||||||
|
**用途**:你们的开发计划和检查清单。
|
||||||
|
|
||||||
|
**内容**:
|
||||||
|
- **阶段1:独立开发**(2-3周,不依赖状态中心)
|
||||||
|
- 逐节点开发页面自动化(ORDER_FETCH、CONTRACT_CREATE、REBATE_*、M5_CREATE_ORDER 等)
|
||||||
|
- 本地模拟回调验证 payload 格式
|
||||||
|
- 错误码识别验证
|
||||||
|
- **阶段2:准备对接**(1天)
|
||||||
|
- 拿到状态中心联调环境信息
|
||||||
|
- 改成调真实接口
|
||||||
|
- 小范围试跑
|
||||||
|
- **联调阶段**(3-5天,和状态中心一起)
|
||||||
|
- 按节点逐个验证
|
||||||
|
- 跑完整链(医药城线上单、线下单、对码 MULTI、缺货源等场景)
|
||||||
|
- 异常场景验证
|
||||||
|
|
||||||
|
### ✅ 3-联调检查清单.md
|
||||||
|
**用途**:联调前自证、联调时逐条勾。
|
||||||
|
|
||||||
|
**内容**:
|
||||||
|
- 功能检查(15项):能登录各系统、能导出订单、能拿到三把钥匙、能处理 M5 桌面自动化…
|
||||||
|
- 回调检查(6项):知道何时调什么接口、必传字段是否正确、错误码是否识别准确…
|
||||||
|
- 异常检查(4项):登录失败、页面改版、M5质管缺失、合同被禁的处理是否正确…
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 开发顺序(重要)
|
||||||
|
|
||||||
|
```
|
||||||
|
第1周 第2周 第3周 第4周
|
||||||
|
│─────阶段1:独立开发─────│阶段2:准备对接│──联调阶段──│
|
||||||
|
(2-3周,不依赖状态中心) (1天) (3-5天)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 现在(第1天)你们要做的:
|
||||||
|
1. **仔细读** `1-技术规范(必读).md`,尤其是:
|
||||||
|
- 第三节:三个回调契约(start/complete/fail 的 JSON 格式)
|
||||||
|
- 第四节:三把钥匙(contractSystemNo/rebateOrderNo/receiptNoticeNo)
|
||||||
|
- 第五节:逐节点操作说明(每个节点点什么页面、拿什么数据)
|
||||||
|
- 第六节:硬规则红线(10条,违反就是错的)
|
||||||
|
|
||||||
|
2. **确认环境**:
|
||||||
|
- iS-RPA 开发环境能访问医药城、小药药、合同系统、返利中心、M5 吗?
|
||||||
|
- 有测试账号吗?
|
||||||
|
- M5 是桌面客户端,iS-RPA 的桌面自动化能力 OK 吗?(这是最大风险点)
|
||||||
|
|
||||||
|
3. **POC 验证**:
|
||||||
|
- 先做一个最简单的节点(比如 ORDER_FETCH:登录医药城 → 导出订单)
|
||||||
|
- 不调状态中心,在自己环境跑通,验证 iS-RPA 能点页面、能拿到数据
|
||||||
|
- 手工构造一个 `complete` 回调的 JSON,确认能拿到订单号列表
|
||||||
|
|
||||||
|
### 第1周你们要完成的:
|
||||||
|
- ORDER_FETCH 页面自动化 + 回调 payload 验证
|
||||||
|
- CONTRACT_CREATE 页面自动化(重点:能拿到合同编号)
|
||||||
|
- REBATE_HEADER 页面自动化(重点:能拿到返利总单号)
|
||||||
|
|
||||||
|
### 第2周你们要完成的:
|
||||||
|
- REBATE_IMPORT 页面自动化(重点:Excel 模板生成、字段映射、上传、读成功/失败行)
|
||||||
|
- REBATE_SUBMIT 页面自动化(简单,就一个按钮,但关键)
|
||||||
|
- M5_CREATE_ORDER 页面自动化(最难,桌面客户端,录完**不保存**)
|
||||||
|
|
||||||
|
### 第3周:
|
||||||
|
- CONTRACT_APPROVE_WAIT 定时查询逻辑(不是连续轮询,是到点查一次)
|
||||||
|
- 错误码识别验证(登录失败、页面改版、质管缺失、合同被禁等场景)
|
||||||
|
- 本地模拟回调,验证所有 payload 格式正确
|
||||||
|
|
||||||
|
### 第4周:
|
||||||
|
- 拿到状态中心联调环境,改成调真实接口
|
||||||
|
- 和状态中心一起联调,跑通完整链
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 关键提醒
|
||||||
|
|
||||||
|
### ⚠️ 三个最大风险点
|
||||||
|
1. **M5 桌面自动化**:M5 是客户端不是网页,iS-RPA 能否稳定操作?建议第1周就 POC 验证。
|
||||||
|
2. **医药城 PDF 合并**:同供应商同日合同要合并成一个 PDF 再上传,iS-RPA 有 PDF 合并能力吗?
|
||||||
|
3. **错误码识别准确性**:M5_QC_MISSING 必须识别准(状态中心会直接转 FAILED_MANUAL,不重试),如果误判成其他错误码,整个流程就乱了。
|
||||||
|
|
||||||
|
### ✅ 成功的标志
|
||||||
|
联调前,你们能自证:
|
||||||
|
- [ ] 能稳定登录 5 个系统(医药城、小药药、合同系统、返利中心、M5),处理验证码/超时
|
||||||
|
- [ ] 能拿到三把钥匙(合同编号、返利总单号、收货通知单号)
|
||||||
|
- [ ] 能识别 10+ 种错误情况并返回正确错误码
|
||||||
|
- [ ] 能按状态中心返回的 `next_node` 继续跑下一节点(不写死顺序)
|
||||||
|
- [ ] 所有回调都带 `X-Agent-Token` header
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 联系方式
|
||||||
|
|
||||||
|
**状态中心负责人**:[你的联系方式]
|
||||||
|
|
||||||
|
**问题反馈**:
|
||||||
|
- 技术问题(接口规范、字段含义):直接问状态中心负责人
|
||||||
|
- 业务问题(节点操作步骤、验收标准):查 `资料/采购主链RPA一期实施与验收规范.md`
|
||||||
|
- 进度协调:每周五同步一次进度
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录:快速上手示例
|
||||||
|
|
||||||
|
### 示例1:本地模拟 ORDER_FETCH complete 回调
|
||||||
|
```bash
|
||||||
|
# 假设你本地起了个 mock 状态中心在 localhost:8000
|
||||||
|
curl -X POST http://localhost:8000/api/jobs/TEST-JOB-001/nodes/ORDER_FETCH/complete \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Agent-Token: dev-agent-token" \
|
||||||
|
-d '{
|
||||||
|
"attemptNo": 1,
|
||||||
|
"executorRunId": "YD-RUN-001",
|
||||||
|
"summary": {
|
||||||
|
"orderCount": 12,
|
||||||
|
"supplierList": ["重庆亿昊药业"],
|
||||||
|
"orderNoList": ["DD-001", "DD-002"]
|
||||||
|
},
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"artifactType": "SOURCE_EXCEL",
|
||||||
|
"storagePath": "/data/jobs/TEST-JOB-001/orders.xlsx",
|
||||||
|
"fileHash": "sha256:abc123"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**期望响应**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"next_node": "OCR_EXTRACT" // 或 "PRODUCT_MATCH"(如果是线上单,跳过 OCR)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例2:CONTRACT_CREATE 必须回传合同编号
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/jobs/TEST-JOB-001/nodes/CONTRACT_CREATE/complete \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Agent-Token: dev-agent-token" \
|
||||||
|
-d '{
|
||||||
|
"attemptNo": 1,
|
||||||
|
"businessRefs": {
|
||||||
|
"contractSystemNo": "HT2026080400001" // 必传!不传返回 400
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"contractTemplate": "FORMAT",
|
||||||
|
"pdfMerged": true
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例3:M5 质管缺失的失败回调
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/jobs/TEST-JOB-001/nodes/M5_CREATE_ORDER/fail \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Agent-Token: dev-agent-token" \
|
||||||
|
-d '{
|
||||||
|
"attemptNo": 1,
|
||||||
|
"errorCode": "M5_QC_MISSING", // 特殊错误码,状态中心会直接转 FAILED_MANUAL
|
||||||
|
"errorSummary": "质管资料缺失,无法在 M5 录单",
|
||||||
|
"retryable": false, // 填什么都没用,状态中心强制转人工
|
||||||
|
"routeRole": "QC"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**期望响应**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"job_status": "FAILED_MANUAL" // 注意:不是 FAILED_RETRYABLE
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
祝开发顺利!有问题随时联系状态中心负责人。
|
||||||
|
|
@ -0,0 +1,587 @@
|
||||||
|
# 采购主链 RPA 编排与手机待办一期方案
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
一期按 `RPA执行平台 + Python/MySQL 轻量状态中心 + 企业微信正式应用 + 本地 OCR` 落地,覆盖主链 `合同扫描件/合同系统 -> 返利中心采购协议 -> M5 采购订单`。
|
||||||
|
目标不是只做一个机器人脚本,而是做成一套**业务可见、异常可处理、手机可协同**的流程编排系统:
|
||||||
|
|
||||||
|
- 主流程默认尽量全自动运行
|
||||||
|
- OCR/映射不确定项转为企业微信手机待办
|
||||||
|
- 每个节点状态、耗时、错误原因、处理人都写入状态中心
|
||||||
|
- 业务可在手机端完成 `查看上下文 / 补录字段 / 继续 / 重试 / 转人工`
|
||||||
|
- RPA 执行平台只负责系统操作与节点执行,不承载核心状态和业务协同
|
||||||
|
|
||||||
|
## Key Changes
|
||||||
|
|
||||||
|
### 0. 入池触发与文件规范
|
||||||
|
|
||||||
|
一期默认以“**共享文件夹入池 + 状态中心建单**”作为统一触发方式,不接受 RPA 平台内部自行起单。
|
||||||
|
|
||||||
|
- 合同扫描件、年度协议、批次协议、返利中心导入模板都必须先进入约定共享目录
|
||||||
|
- 状态中心负责监听入池目录,或由人工在状态中心页面选择文件后建单
|
||||||
|
- RPA 平台只能消费已经建好的 `job`,不能绕过状态中心直接执行
|
||||||
|
|
||||||
|
入池对象至少区分以下几类:
|
||||||
|
|
||||||
|
- 年度协议
|
||||||
|
- 批次合同
|
||||||
|
- 合同附件/补充页
|
||||||
|
- 返利中心细单导入模板
|
||||||
|
|
||||||
|
文件命名规则一期建议固定为:
|
||||||
|
|
||||||
|
- `日期_供应商名称_计划员_合同类型`
|
||||||
|
- 允许补充流水号或附件序号,例如 `20260625_国药控股_张三_批次合同_01.pdf`
|
||||||
|
|
||||||
|
入池校验至少包括:
|
||||||
|
|
||||||
|
- 文件类型是否在允许范围内(PDF/图片/Excel)
|
||||||
|
- 是否命中命名规范
|
||||||
|
- 是否能识别供应商名称与计划员
|
||||||
|
- 同一合同编号/同一业务键是否已存在未完成实例
|
||||||
|
- 是否存在多页缺失、重复上传、同名覆盖
|
||||||
|
|
||||||
|
入池失败时不得直接丢给 RPA 平台,应由状态中心生成待处理记录:
|
||||||
|
|
||||||
|
- 命名不规范 -> 计划员待办
|
||||||
|
- 关键文件缺失 -> 计划员待办
|
||||||
|
- 疑似重复入池 -> 提示并阻止新建,必要时转运维或项目负责人确认
|
||||||
|
|
||||||
|
### 1. 总体架构与职责边界
|
||||||
|
|
||||||
|
- **RPA 执行平台(影刀 / 艺赛旗 / 其他)**
|
||||||
|
- 负责登录系统、页面检索、字段填充、模板导入、截图、日志/录屏采集、执行结果回传
|
||||||
|
- 每完成一个节点,调用状态中心接口上报节点结果
|
||||||
|
- 不直接保存业务主状态,不直接承载人工审批逻辑
|
||||||
|
|
||||||
|
- **轻量状态中心(Python + MySQL)**
|
||||||
|
- 负责流程实例、节点状态、待办、角色路由、重试控制、日志归档、配置映射、执行平台路由
|
||||||
|
- 提供给 RPA 平台的流程启动、节点回调、异常上报、人工处理、继续执行接口
|
||||||
|
- 提供给企业微信的消息卡片、待办链接、处理回调接口
|
||||||
|
|
||||||
|
- **本地 OCR 服务**
|
||||||
|
- 一期默认本地部署,优先按 PaddleOCR 设计
|
||||||
|
- 负责从扫描件/PDF 中提取文本和结构化字段
|
||||||
|
- 输出带置信度的 JSON,不直接写业务系统
|
||||||
|
|
||||||
|
- **企业微信正式应用**
|
||||||
|
- 负责个人待办、异常告警、处理入口、节点进度消息
|
||||||
|
- 不做主流程编排,只作为移动交互层
|
||||||
|
|
||||||
|
#### 1.1 我方自有能力边界
|
||||||
|
|
||||||
|
以下能力必须沉淀在“我们自己的状态中心”中,而不是绑定在某一家 RPA 平台内部:
|
||||||
|
|
||||||
|
- 流程主状态与节点状态机
|
||||||
|
- 合同入池、判重与幂等控制
|
||||||
|
- OCR 结果存档、映射配置、规则版本
|
||||||
|
- 手机待办、角色路由、人工处理留痕
|
||||||
|
- 成功判定口径、错误码体系、证据归档
|
||||||
|
- 多 RPA 平台的统一查询、统一审计和统一对外口径
|
||||||
|
|
||||||
|
换句话说,RPA 平台是可替换的“执行手”,状态中心才是我们的“流程主控”和“业务资产沉淀层”。
|
||||||
|
|
||||||
|
#### 1.2 执行平台可替换边界
|
||||||
|
|
||||||
|
一期支持将底层执行器设计为可切换,但边界要先写死:
|
||||||
|
|
||||||
|
- 可切换的是“执行平台”,不可切换的是状态中心、企业微信协同、业务规则和成功判定口径
|
||||||
|
- 允许“按新单切换平台”,也允许“按节点配置平台”
|
||||||
|
- 不做“单个节点执行到一半跨平台接力”
|
||||||
|
- 老单默认沿用建单时选定的平台,不在执行过程中强行迁移
|
||||||
|
- 新平台接入前必须通过统一 POC 验证清单
|
||||||
|
|
||||||
|
#### 1.3 候选平台能力判断原则
|
||||||
|
|
||||||
|
当前候选执行平台可包括影刀、艺赛旗等,但是否可用于本项目,不能只看厂商宣传页,要按本项目关键动作验证:
|
||||||
|
|
||||||
|
- 目标系统登录与稳定检索
|
||||||
|
- 表单填写、弹窗处理、模板导入
|
||||||
|
- 文件上传/下载与本地目录交互
|
||||||
|
- 截图、日志、录屏留证
|
||||||
|
- 自定义 HTTP 回调状态中心
|
||||||
|
|
||||||
|
满足上述能力后,才能认为该平台可替代现有执行器。
|
||||||
|
|
||||||
|
### 2. 一期业务主链与节点定义
|
||||||
|
|
||||||
|
一期只做以下主链,其他如订单中心分货、招采子系统不纳入一期:
|
||||||
|
|
||||||
|
1. 合同文件入池
|
||||||
|
2. OCR 识别与字段抽取
|
||||||
|
3. 字段映射与业务校验
|
||||||
|
4. 合同系统信息补齐/确认
|
||||||
|
5. 返利中心采购协议检索与总单录入
|
||||||
|
6. 返利中心细单模板生成与导入
|
||||||
|
7. 返利中心结果校验
|
||||||
|
8. M5 采购订单创建
|
||||||
|
9. M5 保存与收货通知单生成
|
||||||
|
10. 流程完成归档
|
||||||
|
|
||||||
|
#### 2.1 合同系统前置条件
|
||||||
|
|
||||||
|
返利中心与 M5 的自动执行,必须建立在“合同系统已达到可执行状态”的前提上。
|
||||||
|
|
||||||
|
一期要明确区分以下几个动作:
|
||||||
|
|
||||||
|
- 合同系统信息补齐
|
||||||
|
- 合同系统送审
|
||||||
|
- 合同系统审核通过
|
||||||
|
- 审核驳回/需补充资料
|
||||||
|
|
||||||
|
建议一期按以下边界落地:
|
||||||
|
|
||||||
|
- 机器人可协助录入合同系统字段,并回写录入结果
|
||||||
|
- “送审”动作是否由机器人执行,需要业务确认后写死
|
||||||
|
- 只有合同处于“已审核/审核通过”状态,才能进入返利中心节点
|
||||||
|
- 审核未通过或缺资料,不得继续推进,直接转计划员或采购待办
|
||||||
|
|
||||||
|
状态中心中必须记录:
|
||||||
|
|
||||||
|
- 合同系统单号
|
||||||
|
- 合同系统当前审核状态
|
||||||
|
- 审核通过时间
|
||||||
|
- 驳回原因摘要
|
||||||
|
|
||||||
|
#### 2.2 一期明确不纳入范围
|
||||||
|
|
||||||
|
以下内容虽然在原始资料中提到,但不纳入一期实施:
|
||||||
|
|
||||||
|
- 订单中心分货与销售订单联动
|
||||||
|
- 招采子系统医院计划抓取与回传
|
||||||
|
- 发货后的一票/二票自动上传
|
||||||
|
- 渠道价管理与维护
|
||||||
|
|
||||||
|
如后续要做,应单独建二期/三期方案,不在一期需求、排期和验收中混入。
|
||||||
|
|
||||||
|
每个节点统一有以下状态:
|
||||||
|
|
||||||
|
- `PENDING`
|
||||||
|
- `RUNNING`
|
||||||
|
- `WAITING_USER`
|
||||||
|
- `SUCCESS`
|
||||||
|
- `FAILED_RETRYABLE`
|
||||||
|
- `FAILED_MANUAL`
|
||||||
|
- `CANCELLED`
|
||||||
|
|
||||||
|
#### 2.3 状态流转规则
|
||||||
|
|
||||||
|
节点状态不仅要有枚举,还要有明确迁移规则,避免同一单被重复推进:
|
||||||
|
|
||||||
|
- `PENDING -> RUNNING`
|
||||||
|
- `RUNNING -> SUCCESS`
|
||||||
|
- `RUNNING -> WAITING_USER`
|
||||||
|
- `RUNNING -> FAILED_RETRYABLE`
|
||||||
|
- `RUNNING -> FAILED_MANUAL`
|
||||||
|
- `WAITING_USER -> RUNNING`
|
||||||
|
- `WAITING_USER -> CANCELLED`
|
||||||
|
- `WAITING_USER -> FAILED_MANUAL`
|
||||||
|
- `FAILED_RETRYABLE -> RUNNING`
|
||||||
|
|
||||||
|
补充约束:
|
||||||
|
|
||||||
|
- 同一节点同一时刻只能有一个有效执行上下文
|
||||||
|
- 手机端“确认并继续”只能成功推进一次,后续重复提交必须返回幂等结果
|
||||||
|
- 节点超时后不能静默停留,必须升级为催办或异常状态
|
||||||
|
- `CANCELLED` 和 `FAILED_MANUAL` 默认不可自动恢复,只能人工明确重开
|
||||||
|
|
||||||
|
每个节点必须记录:
|
||||||
|
|
||||||
|
- 流程单号
|
||||||
|
- 合同编号
|
||||||
|
- 供应商
|
||||||
|
- 当前节点编码
|
||||||
|
- 节点开始/结束时间
|
||||||
|
- 执行结果
|
||||||
|
- 错误码
|
||||||
|
- 错误摘要
|
||||||
|
- 截图/日志路径
|
||||||
|
- 当前处理角色
|
||||||
|
- 当前处理人
|
||||||
|
- 重试次数
|
||||||
|
|
||||||
|
### 3. 手机待办与角色路由
|
||||||
|
|
||||||
|
手机待办默认按**角色路由**,不是固定发给某个人。
|
||||||
|
|
||||||
|
一期角色至少包括:
|
||||||
|
|
||||||
|
- 计划员
|
||||||
|
- 采购
|
||||||
|
- 质管
|
||||||
|
- 运维/机器人管理员
|
||||||
|
|
||||||
|
路由规则:
|
||||||
|
|
||||||
|
- OCR/映射不确定 -> 计划员
|
||||||
|
- 返利中心录入失败且需业务判断 -> 采购
|
||||||
|
- M5 保存失败且提示质管基础数据问题 -> 质管
|
||||||
|
- 机器人登录失败、页面结构变化、系统超时 -> 运维
|
||||||
|
- 多次重试仍失败 -> 运维 + 当前业务角色
|
||||||
|
|
||||||
|
#### 3.1 企业微信一期落地方式
|
||||||
|
|
||||||
|
一期建议将企业微信能力落成:
|
||||||
|
|
||||||
|
- 消息通知
|
||||||
|
- 模版卡片消息更新
|
||||||
|
- H5 处理页/详情页
|
||||||
|
- 用户身份识别与回调处理
|
||||||
|
|
||||||
|
即:企业微信负责“通知 + 打开处理入口 + 提交处理动作”,状态推进仍由状态中心完成。
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 一期文档中的“手机待办”默认指“企业微信消息卡片 + H5 待处理页”的组合能力
|
||||||
|
- 不把企业微信原生待办体系作为一期已确认前提,若后续验证可接,再单独扩展
|
||||||
|
- 所有按钮动作最终都必须调用状态中心接口,并以状态中心结果为准
|
||||||
|
|
||||||
|
企业微信待办支持的动作固定为:
|
||||||
|
|
||||||
|
- `查看详情`
|
||||||
|
- `补录/修改字段`
|
||||||
|
- `确认并继续`
|
||||||
|
- `重新执行当前节点`
|
||||||
|
- `转人工处理`
|
||||||
|
- `终止本单`
|
||||||
|
|
||||||
|
消息内容必须包含:
|
||||||
|
|
||||||
|
- 合同编号
|
||||||
|
- 供应商名称
|
||||||
|
- 当前节点
|
||||||
|
- 错误摘要或待确认原因
|
||||||
|
- 已识别关键字段摘要
|
||||||
|
- 最近一张错误截图
|
||||||
|
- 处理入口链接
|
||||||
|
|
||||||
|
#### 3.2 人工处理字段边界
|
||||||
|
|
||||||
|
手机端“补录/修改字段”不能放成无限编辑,必须收敛为白名单字段。
|
||||||
|
|
||||||
|
一期建议按角色控制:
|
||||||
|
|
||||||
|
- 计划员:合同编号、承付模式、承付参数、计划员归属、合同有效期、OCR 识别结果修正
|
||||||
|
- 采购:供应商映射、货品映射、协议单价、返利中心导入模板相关字段
|
||||||
|
- 质管:仅处理基础资料维护完成确认,不直接改合同业务字段
|
||||||
|
- 运维:只能做重试、转派、失败归档,不修改业务字段值
|
||||||
|
|
||||||
|
以下字段若涉及修改,必须记录修改前后值、修改人、修改时间、修改原因:
|
||||||
|
|
||||||
|
- 合同编号
|
||||||
|
- 供应商名称/编码
|
||||||
|
- 结算方式
|
||||||
|
- 承付模式与承付参数
|
||||||
|
- 货品编码/数量/单价
|
||||||
|
- M5 货源选择
|
||||||
|
|
||||||
|
#### 3.3 人性化待办体验
|
||||||
|
|
||||||
|
为了提升业务接受度,一期建议把以下体验项明确纳入需求,而不是留到后期“有空再优化”:
|
||||||
|
|
||||||
|
- **人话状态**
|
||||||
|
- 不只显示节点编码,要显示“系统正在做什么、通常还要多久、下一步会怎样”
|
||||||
|
- **错误翻译**
|
||||||
|
- 除错误码外,必须给出业务能看懂的错误原因和建议动作
|
||||||
|
- **为什么发给你**
|
||||||
|
- 每条待办都说明“当前归你处理的原因”和“本次需要你确认什么”
|
||||||
|
- **修改前后对比**
|
||||||
|
- 补录字段时同时展示 OCR 原值、系统建议值、用户当前填写值
|
||||||
|
- **提交后即时回执**
|
||||||
|
- 用户点击继续/重试后,立即反馈“已受理、预计多久再次执行、不要重复提交”
|
||||||
|
- **一屏看完最近证据**
|
||||||
|
- 待办详情首页直接展示合同编号、供应商、关键字段、最近错误截图、错误摘要
|
||||||
|
- **预计耗时提示**
|
||||||
|
- 关键节点给出大概执行时长,降低业务等待焦虑
|
||||||
|
- **高风险动作撤回窗口**
|
||||||
|
- 对“终止本单”“转人工处理”等动作,建议提供短时间撤回能力
|
||||||
|
|
||||||
|
### 4. OCR 与规则映射策略
|
||||||
|
|
||||||
|
一期默认**本地 OCR**,并预留适配层,后续可切云 OCR,但一期不依赖云能力。
|
||||||
|
|
||||||
|
OCR 输出必须是结构化 JSON,至少包含:
|
||||||
|
|
||||||
|
- `supplier_name`
|
||||||
|
- `planner_name`
|
||||||
|
- `contract_no`
|
||||||
|
- `settlement_text`
|
||||||
|
- `payment_term_text`
|
||||||
|
- `contract_validity`
|
||||||
|
- `items[]`
|
||||||
|
- `field_confidence{}`
|
||||||
|
|
||||||
|
映射与校验不在 RPA 平台里硬编码,统一放在状态中心配置表中。配置表至少包括:
|
||||||
|
|
||||||
|
- 供应商映射表
|
||||||
|
- 货品映射表
|
||||||
|
- 结算方式映射表
|
||||||
|
- 承付模式映射表
|
||||||
|
- 合同编号规则表
|
||||||
|
- 角色路由规则表
|
||||||
|
- 执行平台路由规则表
|
||||||
|
|
||||||
|
配置治理还需补充:
|
||||||
|
|
||||||
|
- 所有映射配置必须带版本号、生效时间、维护人
|
||||||
|
- 每个 `job` 在开始执行时要绑定一份映射版本快照
|
||||||
|
- 重试默认沿用原映射版本,除非人工明确选择“按最新配置重跑”
|
||||||
|
- 配置表变更要保留审计记录,不能直接覆盖无痕修改
|
||||||
|
|
||||||
|
承付模式映射规则一期采用“**规则优先 + 人工兜底**”:
|
||||||
|
|
||||||
|
- 精确命中字典表达,直接转系统编码
|
||||||
|
- 能识别 `N天` 的标准表达,拆成 `模式代码 + N`
|
||||||
|
- 无法稳定命中或置信度低于阈值,转 `WAITING_USER`
|
||||||
|
- 禁止在低置信度情况下自动猜测继续录入
|
||||||
|
|
||||||
|
### 5. 状态中心接口与数据实体
|
||||||
|
|
||||||
|
重要接口/公共契约如下:
|
||||||
|
|
||||||
|
- `POST /api/jobs`
|
||||||
|
- 创建流程实例,入参为合同文件元数据和发起来源
|
||||||
|
- `POST /api/jobs/{jobId}/nodes/{nodeCode}/start`
|
||||||
|
- RPA 平台或调度器上报节点开始
|
||||||
|
- `POST /api/jobs/{jobId}/nodes/{nodeCode}/complete`
|
||||||
|
- 上报节点成功,附输出数据
|
||||||
|
- `POST /api/jobs/{jobId}/nodes/{nodeCode}/fail`
|
||||||
|
- 上报失败,附错误码、截图、日志、是否可重试
|
||||||
|
- `POST /api/tasks/{taskId}/submit`
|
||||||
|
- 手机端提交人工处理结果
|
||||||
|
- `POST /api/jobs/{jobId}/retry`
|
||||||
|
- 人工或系统触发重试
|
||||||
|
- `POST /api/jobs/{jobId}/handoff`
|
||||||
|
- 转人工或转其他角色
|
||||||
|
- `GET /api/jobs/{jobId}`
|
||||||
|
- 查询整单状态与节点轨迹
|
||||||
|
- `GET /api/tasks/my`
|
||||||
|
- 查询当前用户手机待办
|
||||||
|
|
||||||
|
#### 5.1 幂等与唯一键
|
||||||
|
|
||||||
|
状态中心必须承担幂等控制,不能把“是否重复执行”交给具体 RPA 平台临时判断。
|
||||||
|
|
||||||
|
至少要定义以下唯一键/幂等键:
|
||||||
|
|
||||||
|
- `job` 业务唯一键:建议由 `合同编号 + 合同类型 + 供应商 + 发起来源` 组合生成
|
||||||
|
- 入池幂等键:建议由 `文件哈希 + 文件名 + 上传时间窗` 组合生成
|
||||||
|
- 节点回调幂等键:`jobId + nodeCode + attemptNo + callbackType`
|
||||||
|
- 手机待办提交幂等键:`taskId + action + submitToken`
|
||||||
|
|
||||||
|
系统行为建议固定为:
|
||||||
|
|
||||||
|
- 未完成实例存在时,同业务键默认禁止重复建单
|
||||||
|
- 允许“重跑当前节点”,但不得覆盖历史节点记录
|
||||||
|
- 如需整单重跑,应新建新的 `job`,并关联原 `jobId`
|
||||||
|
- 历史 `job` 的 OCR 结果、截图、日志必须可追溯,不得被新单覆盖
|
||||||
|
|
||||||
|
核心实体至少包括:
|
||||||
|
|
||||||
|
- `job`:一份合同的一次全流程实例
|
||||||
|
- `job_node`:流程实例下的节点执行记录
|
||||||
|
- `user_task`:手机待办/人工处理单
|
||||||
|
- `mapping_config`:业务映射配置
|
||||||
|
- `executor_binding_config`:节点与执行平台绑定配置
|
||||||
|
- `job_artifact`:截图、OCR JSON、导入模板、日志文件
|
||||||
|
- `job_event`:状态变更事件流
|
||||||
|
|
||||||
|
建议增加的关键字段:
|
||||||
|
|
||||||
|
- `job.parent_job_id`:整单重跑或克隆来源
|
||||||
|
- `job.mapping_version`:执行时绑定的配置版本
|
||||||
|
- `job.executor_platform`:本单默认执行平台
|
||||||
|
- `job.source_file_hash`:源文件去重标识
|
||||||
|
- `job_node.attempt_no`:节点第几次尝试
|
||||||
|
- `job_node.executor_platform`:本次节点尝试由哪个平台执行
|
||||||
|
- `job_node.executor_run_id`:平台侧任务号/运行号
|
||||||
|
- `user_task.submit_token`:防重复提交令牌
|
||||||
|
- `job_artifact.artifact_type`:OCR JSON、截图、模板、日志等分类
|
||||||
|
|
||||||
|
### 6. 节点执行与重试策略
|
||||||
|
|
||||||
|
重试分三类:
|
||||||
|
|
||||||
|
- **系统型自动重试**
|
||||||
|
- 页面超时、偶发网络异常、系统短暂不可用
|
||||||
|
- 默认自动重试 1 到 2 次
|
||||||
|
- **业务型人工确认后重试**
|
||||||
|
- 字段识别不确定、映射缺失、返利中心校验不通过
|
||||||
|
- 手机待办确认后重试当前节点
|
||||||
|
- **人工接管**
|
||||||
|
- 连续失败、逻辑分支超出机器人能力、系统规则变化
|
||||||
|
- 标记为 `FAILED_MANUAL`,由人工线下处理,流程保留上下文
|
||||||
|
|
||||||
|
必须明确的失败分流:
|
||||||
|
|
||||||
|
- OCR 失败 -> 待办给计划员
|
||||||
|
- 合同编号无法匹配 -> 待办给计划员
|
||||||
|
- 返利中心模板导入失败 -> 待办给采购
|
||||||
|
- M5 因质管基础数据不全无法保存 -> 待办给质管
|
||||||
|
- 登录失败/控件定位失败/页面结构变化 -> 待办给运维
|
||||||
|
- 平台能力不满足当前节点要求 -> 待办给运维/项目负责人评估是否切换执行平台
|
||||||
|
|
||||||
|
#### 6.1 超时与升级策略
|
||||||
|
|
||||||
|
除失败分流外,还需要定义“长时间没人处理”与“节点卡住”的升级规则。
|
||||||
|
|
||||||
|
建议一期先定简单规则:
|
||||||
|
|
||||||
|
- OCR/映射类待办超过 30 分钟未处理 -> 再次通知当前角色
|
||||||
|
- 超过 2 小时未处理 -> 升级通知项目负责人/备岗人员
|
||||||
|
- 返利中心/M5 运行中节点超过阈值未回调 -> 标记超时异常并通知运维
|
||||||
|
- 夜间非工作时段是否升级,需按业务班次另行配置
|
||||||
|
|
||||||
|
所有升级动作都必须进入 `job_event` 留痕。
|
||||||
|
|
||||||
|
### 7. 业务可见性设计
|
||||||
|
|
||||||
|
一期不单独建设复杂 BI 看板,但状态中心必须支持后续扩展。当前可见性分两层:
|
||||||
|
|
||||||
|
- **企业微信消息流**
|
||||||
|
- 面向业务个人,解决“手机知道发生了什么”
|
||||||
|
- **状态中心查询页**
|
||||||
|
- 面向运维/项目负责人,按合同编号查看节点轨迹、截图、当前责任方、重试记录
|
||||||
|
|
||||||
|
查询页至少支持:
|
||||||
|
|
||||||
|
- 按日期/供应商/合同编号/状态筛选
|
||||||
|
- 查看当前卡点节点
|
||||||
|
- 查看待办归属角色与处理记录
|
||||||
|
- 查看 OCR 结果和映射结果
|
||||||
|
- 查看返利中心/M5 错误截图与日志摘要
|
||||||
|
|
||||||
|
#### 7.1 成功判定与结果回写口径
|
||||||
|
|
||||||
|
一期必须明确“节点成功”不是页面点完就算成功,而是拿到对应业务对象与校验结果。
|
||||||
|
|
||||||
|
至少要求:
|
||||||
|
|
||||||
|
- 合同系统成功:返回合同系统单号,且能查询到对应状态
|
||||||
|
- 返利中心总单成功:返回总单编号,且关键字段与输入一致
|
||||||
|
- 返利中心细单导入成功:返回导入行数、失败行数、失败原因摘要
|
||||||
|
- M5 成功:返回采购订单号
|
||||||
|
- 收货通知单生成成功:返回通知单号
|
||||||
|
|
||||||
|
建议状态中心在节点成功回调中强制要求携带:
|
||||||
|
|
||||||
|
- 业务单号
|
||||||
|
- 关键字段摘要
|
||||||
|
- 成功截图
|
||||||
|
- 校验结果
|
||||||
|
|
||||||
|
如仅有“操作已提交”但未取得业务编号,应落为中间态或待校验态,不能直接记 `SUCCESS`。
|
||||||
|
|
||||||
|
#### 7.3 多 RPA 切换策略
|
||||||
|
|
||||||
|
为了形成长期竞争力,一期方案支持“执行平台可替换”,但切换策略必须保守可控:
|
||||||
|
|
||||||
|
- 切换单位以“新单”或“节点配置”优先,不以“运行中半节点切换”为目标
|
||||||
|
- 每个节点先声明所需能力标签,例如 `desktop_ui`、`file_upload`、`excel_template`、`http_callback`
|
||||||
|
- 每个执行平台声明自己支持的能力标签
|
||||||
|
- 状态中心根据节点所需能力和平台可用能力决定路由
|
||||||
|
- 平台切换前必须通过统一验证清单:登录、检索、模板导入、截图/日志、HTTP 回调
|
||||||
|
|
||||||
|
这样做的目的不是追求脚本 100% 通用,而是确保:
|
||||||
|
|
||||||
|
- 业务流程不被单一厂商绑定
|
||||||
|
- 状态、待办、规则、证据统一留在我方系统
|
||||||
|
- 当成本、授权或稳定性发生变化时,可平滑更换底层执行平台
|
||||||
|
|
||||||
|
#### 7.4 面向业务方的竞争价值
|
||||||
|
|
||||||
|
多 RPA 可切换的价值,不只是技术选型灵活,更要落实到业务能感受到的收益:
|
||||||
|
|
||||||
|
- 不因单一工具变化而中断业务流程
|
||||||
|
- 同一套手机待办、同一套状态查询,不需要业务重新学一套系统
|
||||||
|
- 平台替换时,合同轨迹、截图、历史处理记录不会丢
|
||||||
|
- 某个平台不稳定时,可逐步把新单切到更稳的平台,业务侧感知更平滑
|
||||||
|
- 后续新增流程时,优先复用既有状态中心和手机协同能力,减少重复建设
|
||||||
|
|
||||||
|
#### 7.2 证据留存与权限控制
|
||||||
|
|
||||||
|
一期虽然不做复杂平台,但必须先把资料、日志和截图的留存规则定下来。
|
||||||
|
|
||||||
|
至少要明确:
|
||||||
|
|
||||||
|
- 合同原件、OCR JSON、返利模板、执行截图、运行日志分别存放在哪里
|
||||||
|
- 文件目录命名是否按 `jobId/节点/时间` 归档
|
||||||
|
- 保留时长与清理策略
|
||||||
|
- 哪些角色可查看合同原件,哪些只能看摘要/脱敏截图
|
||||||
|
- 企业微信消息中是否只放缩略图和链接,不直接下发完整合同文件
|
||||||
|
|
||||||
|
涉及合同和供应商信息时,建议默认:
|
||||||
|
|
||||||
|
- 手机端只展示必要摘要
|
||||||
|
- 完整附件放状态中心受控页面查看
|
||||||
|
- 所有下载和查看动作记录审计日志
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
### 核心流程场景
|
||||||
|
|
||||||
|
1. 合同文件命名规范、字段完整、OCR 置信度高
|
||||||
|
- 预期:全自动跑通到 M5 完成,无人工介入
|
||||||
|
2. OCR 成功但承付模式未命中字典
|
||||||
|
- 预期:转计划员手机待办,补录后继续执行
|
||||||
|
3. 返利中心细单导入模板格式错误
|
||||||
|
- 预期:节点失败,通知采购,可修正后重试当前节点
|
||||||
|
4. M5 保存时报质管未维护
|
||||||
|
- 预期:转质管手机待办,修复后重试
|
||||||
|
5. 当前执行平台登录返利中心失败
|
||||||
|
- 预期:自动重试后仍失败,转运维待办
|
||||||
|
6. 业务在手机端点击“转人工处理”
|
||||||
|
- 预期:流程进入 `FAILED_MANUAL`,保留完整上下文
|
||||||
|
7. 同一合同重复发起
|
||||||
|
- 预期:状态中心拦截或提示重复实例,避免重复录单
|
||||||
|
8. 合同系统未审核通过
|
||||||
|
- 预期:流程停在审核前置节点,不进入返利中心
|
||||||
|
9. 企业微信待办重复点击提交
|
||||||
|
- 预期:只推进一次,其余请求返回幂等结果
|
||||||
|
10. 返利中心细单部分导入成功、部分失败
|
||||||
|
- 预期:记录成功/失败行明细,转采购判断是否修正后续跑
|
||||||
|
11. 入池文件命名不规范或缺页
|
||||||
|
- 预期:不启动任何 RPA 平台,直接转计划员待办
|
||||||
|
12. 待办长时间未处理
|
||||||
|
- 预期:按升级规则再次通知并记录事件
|
||||||
|
13. 新单切换到另一执行平台
|
||||||
|
- 预期:状态中心仍可统一建单、回调、留痕与查询
|
||||||
|
14. 用户提交待办后立即收到受理回执
|
||||||
|
- 预期:用户知道系统已接单、无需重复点击
|
||||||
|
15. 用户查看待办详情时可直接看懂错误原因
|
||||||
|
- 预期:无需再去群里追问“这条待办要我干嘛”
|
||||||
|
|
||||||
|
### 接口与状态场景
|
||||||
|
|
||||||
|
1. 节点开始、成功、失败回调顺序正确
|
||||||
|
2. 同一节点重复回调具备幂等处理
|
||||||
|
3. 手机待办提交后只能推进一次,避免重复操作
|
||||||
|
4. 重试后产生新的节点执行记录,但保留历史失败记录
|
||||||
|
5. 企业微信消息与状态中心中的责任人、状态一致
|
||||||
|
6. 整单重跑会创建新的 `job`,并保留原 `job` 关联
|
||||||
|
7. 节点超时后会触发升级通知,不会无限停留在 `RUNNING`
|
||||||
|
8. 不同执行平台回调到状态中心时,状态口径与证据口径保持一致
|
||||||
|
|
||||||
|
### 验收标准
|
||||||
|
|
||||||
|
- 业务能在手机端知道每单当前跑到哪一步
|
||||||
|
- 关键不确定项不会被机器人“猜着填”
|
||||||
|
- 任何失败都能定位到具体节点、原因、截图和责任角色
|
||||||
|
- 一份合同从入池到 M5 完成的轨迹可完整追溯
|
||||||
|
- 能区分“本次只是操作提交”与“业务单号已真正生成”
|
||||||
|
- 同一合同不会因重复上传或重复点击而生成重复业务结果
|
||||||
|
- 不依赖 OpenClaw,也不依赖单一 RPA 厂商内置 OCR 即可完成一期
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- 一期默认只接入一个主执行平台上线,但架构上不与单一平台绑定
|
||||||
|
- 影刀、艺赛旗等候选平台是否正式接入,需以项目 POC 验证结果为准
|
||||||
|
- OpenClaw 一期不使用
|
||||||
|
- 企业微信可申请正式企业应用并支持消息、回调和待办入口
|
||||||
|
- 轻量服务技术栈固定为 Python + MySQL
|
||||||
|
- OCR 一期默认本地部署,优先按 PaddleOCR 设计
|
||||||
|
- 一期范围只覆盖 `合同 -> 返利中心 -> M5`
|
||||||
|
- 映射规则不再依赖 Excel,统一进入状态中心配置表
|
||||||
|
- 企业微信一期至少可提供消息卡片、H5 处理入口、身份识别与回调能力
|
||||||
|
- 合同扫描件默认来自共享目录或状态中心手工入池,不考虑邮件自动收件作为一期入口
|
||||||
|
- 业务目标是“尽量全自动”,但 OCR/映射不确定项必须转手机待办,不做自动猜测
|
||||||
|
|
@ -0,0 +1,910 @@
|
||||||
|
# 采购主链 RPA 一期 Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 建成采购主链一期的轻量状态中心、统一 RPA 执行适配层、企业微信手机协同入口,以及支撑 OCR、返利中心、M5 流程追踪的最小可用中台。
|
||||||
|
|
||||||
|
**Architecture:** 采用“RPA 执行平台 + Python/MySQL 状态中心 + 本地 OCR + 企业微信 H5 协同”的分层方案。状态中心负责建单、节点状态机、幂等、待办、日志、审计和执行平台路由,底层 RPA 平台只负责外部系统操作与结果回传,企业微信只负责通知与手机端操作入口。
|
||||||
|
|
||||||
|
**Tech Stack:** Python、MySQL、HTTP API、RPA 执行平台(影刀 / 艺赛旗等)、企业微信应用、本地 OCR(PaddleOCR 优先)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 使用说明
|
||||||
|
|
||||||
|
本文件既可作为技术实施底稿,也可作为后续拆更细执行计划的母稿。
|
||||||
|
|
||||||
|
## 1. 文档定位
|
||||||
|
|
||||||
|
本文面向开发、测试、实施和项目负责人,目标是把 [PLAN.md](/Users/yuuuu/Documents/AutoRpa/资料/PLAN.md) 进一步落成“可排期、可拆人、可建库、可对接口”的实施稿。
|
||||||
|
|
||||||
|
默认前提:
|
||||||
|
|
||||||
|
- 一期范围只覆盖 `合同扫描件/合同系统 -> 返利中心采购协议 -> M5 采购订单`
|
||||||
|
- RPA 执行平台负责页面操作
|
||||||
|
- Python + MySQL 负责状态中心
|
||||||
|
- 企业微信负责消息通知和 H5 处理入口
|
||||||
|
- OCR 一期采用本地部署方案
|
||||||
|
|
||||||
|
如果后续要正式开工,建议以本文件作为技术排期底稿,再补充具体工期、负责人和环境信息。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 推荐项目结构
|
||||||
|
|
||||||
|
当前仓库还没有正式代码结构,一期建议直接按下面的边界建工程,避免把流程编排、RPA 平台回调、企业微信回调、查询页混在一起。
|
||||||
|
|
||||||
|
```text
|
||||||
|
AutoRpa/
|
||||||
|
├── 资料/
|
||||||
|
│ ├── PLAN.md
|
||||||
|
│ ├── 一期开发任务清单_表结构_API草案.md
|
||||||
|
│ └── 一期需求简版说明_业务版.md
|
||||||
|
├── backend/
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── main.py
|
||||||
|
│ │ ├── config.py
|
||||||
|
│ │ ├── db.py
|
||||||
|
│ │ ├── models/
|
||||||
|
│ │ │ ├── job.py
|
||||||
|
│ │ │ ├── job_node.py
|
||||||
|
│ │ │ ├── user_task.py
|
||||||
|
│ │ │ ├── mapping_config.py
|
||||||
|
│ │ │ ├── job_artifact.py
|
||||||
|
│ │ │ └── job_event.py
|
||||||
|
│ │ ├── schemas/
|
||||||
|
│ │ │ ├── job.py
|
||||||
|
│ │ │ ├── task.py
|
||||||
|
│ │ │ ├── node_callback.py
|
||||||
|
│ │ │ └── wecom.py
|
||||||
|
│ │ ├── api/
|
||||||
|
│ │ │ ├── jobs.py
|
||||||
|
│ │ │ ├── tasks.py
|
||||||
|
│ │ │ ├── admin.py
|
||||||
|
│ │ │ └── wecom.py
|
||||||
|
│ │ ├── services/
|
||||||
|
│ │ │ ├── intake_service.py
|
||||||
|
│ │ │ ├── workflow_service.py
|
||||||
|
│ │ │ ├── retry_service.py
|
||||||
|
│ │ │ ├── task_service.py
|
||||||
|
│ │ │ ├── executor_router.py
|
||||||
|
│ │ │ ├── ocr_service.py
|
||||||
|
│ │ │ ├── mapping_service.py
|
||||||
|
│ │ │ ├── artifact_service.py
|
||||||
|
│ │ │ └── wecom_service.py
|
||||||
|
│ │ ├── integrations/
|
||||||
|
│ │ │ ├── rpa/
|
||||||
|
│ │ │ │ ├── base_adapter.py
|
||||||
|
│ │ │ │ ├── yingdao_adapter.py
|
||||||
|
│ │ │ │ └── isearch_adapter.py
|
||||||
|
│ │ │ ├── paddle_ocr_client.py
|
||||||
|
│ │ │ └── wecom_client.py
|
||||||
|
│ │ └── views/
|
||||||
|
│ │ └── h5_task_page.py
|
||||||
|
│ ├── migrations/
|
||||||
|
│ └── tests/
|
||||||
|
│ ├── api/
|
||||||
|
│ ├── services/
|
||||||
|
│ └── integration/
|
||||||
|
├── agent-contract/
|
||||||
|
│ ├── node_callback_examples.json
|
||||||
|
│ └── error_codes.md
|
||||||
|
└── deployment/
|
||||||
|
├── env.example
|
||||||
|
├── nginx.conf
|
||||||
|
└── systemd/
|
||||||
|
```
|
||||||
|
|
||||||
|
边界原则:
|
||||||
|
|
||||||
|
- `api/` 只收发请求,不放业务流程判断
|
||||||
|
- `services/` 负责状态迁移、幂等、路由、重试、消息发送
|
||||||
|
- `integrations/` 只封装外部系统调用
|
||||||
|
- `executor_router.py` 统一决定节点该交给哪个平台执行
|
||||||
|
- `models/` 和 `schemas/` 分开,避免数据库对象和接口对象互相污染
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 自有能力边界与多平台策略
|
||||||
|
|
||||||
|
我们自己的能力必须沉淀在状态中心中:
|
||||||
|
|
||||||
|
- 流程主状态
|
||||||
|
- 合同入池、判重、幂等
|
||||||
|
- OCR 归档和映射配置
|
||||||
|
- 手机待办、人工协同、角色路由
|
||||||
|
- 成功判定、错误码体系、证据归档
|
||||||
|
- 多平台统一查询和统一审计
|
||||||
|
|
||||||
|
RPA 平台提供的是底层执行能力:
|
||||||
|
|
||||||
|
- 桌面/UI 自动化
|
||||||
|
- 文件上传下载
|
||||||
|
- 表单填写与模板导入
|
||||||
|
- 截图、日志、录屏
|
||||||
|
- 对状态中心发起回调
|
||||||
|
|
||||||
|
多平台切换边界必须明确:
|
||||||
|
|
||||||
|
- 支持“按新单切换平台”
|
||||||
|
- 支持“按节点配置平台”
|
||||||
|
- 不支持“半节点执行过程中跨平台接力”
|
||||||
|
- 平台切换前先跑统一 POC 用例
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 里程碑与开发任务清单
|
||||||
|
|
||||||
|
## Milestone 0:项目初始化与基础设施
|
||||||
|
|
||||||
|
目标:把后端服务、数据库连接、配置管理、日志规范先搭起来。
|
||||||
|
|
||||||
|
任务清单:
|
||||||
|
|
||||||
|
1. 创建 `backend/app/main.py`,跑通基础 HTTP 服务
|
||||||
|
2. 创建 `backend/app/config.py`,统一读取数据库、OCR、企业微信、RPA 平台回调密钥配置
|
||||||
|
3. 创建 `backend/app/db.py`,封装 MySQL 连接和事务入口
|
||||||
|
4. 建立 `backend/tests/` 目录,先接入最基础的接口测试框架
|
||||||
|
5. 创建 `deployment/env.example`,列出所有必需环境变量
|
||||||
|
6. 定义统一日志字段:`trace_id`、`job_id`、`node_code`、`task_id`
|
||||||
|
|
||||||
|
交付物:
|
||||||
|
|
||||||
|
- 服务能启动
|
||||||
|
- 健康检查接口可用
|
||||||
|
- 数据库连接可用
|
||||||
|
- 开发/测试环境变量模板可发给团队
|
||||||
|
|
||||||
|
## Milestone 1:入池建单与判重
|
||||||
|
|
||||||
|
目标:把“共享目录文件 -> 状态中心建单 -> 判重 -> 可追踪 job”跑通。
|
||||||
|
|
||||||
|
任务清单:
|
||||||
|
|
||||||
|
1. 建 `job`、`job_artifact`、`job_event` 三张核心表
|
||||||
|
2. 开发 `POST /api/jobs` 建单接口
|
||||||
|
3. 开发共享目录文件入池服务,读取文件基础元数据
|
||||||
|
4. 实现文件哈希计算与 `source_file_hash` 去重逻辑
|
||||||
|
5. 实现业务唯一键判重逻辑:`合同编号 + 合同类型 + 供应商 + 发起来源`
|
||||||
|
6. 入池失败时生成事件记录和人工待处理状态
|
||||||
|
7. 支持通过状态中心页面手工建单,作为共享目录监听的兜底入口
|
||||||
|
|
||||||
|
交付物:
|
||||||
|
|
||||||
|
- 同一合同重复上传不会生成多条未完成流程
|
||||||
|
- 文件命名异常可被识别并阻断
|
||||||
|
- 每次建单都有 `job_id`
|
||||||
|
|
||||||
|
## Milestone 2:OCR 与映射规则
|
||||||
|
|
||||||
|
目标:把扫描件变成结构化字段,并明确哪些能自动映射、哪些必须转人工。
|
||||||
|
|
||||||
|
任务清单:
|
||||||
|
|
||||||
|
1. 创建 `mapping_config` 表和版本字段
|
||||||
|
2. 建立 OCR 适配接口,统一输出 JSON
|
||||||
|
3. 保存原始 OCR JSON 到 `job_artifact`
|
||||||
|
4. 实现供应商、货品、结算方式、承付模式映射服务
|
||||||
|
5. 实现置信度阈值判断和低置信度转待办逻辑
|
||||||
|
6. 为每个 `job` 绑定 `mapping_version`
|
||||||
|
7. 把字段识别结果、映射结果写入 `job_event`
|
||||||
|
|
||||||
|
交付物:
|
||||||
|
|
||||||
|
- OCR 结果可追溯
|
||||||
|
- 映射规则支持版本化
|
||||||
|
- 低置信度不会自动猜测后继续执行
|
||||||
|
|
||||||
|
## Milestone 3:流程编排与节点状态机
|
||||||
|
|
||||||
|
目标:把节点开始、成功、失败、待人工、重试这些规则稳定下来。
|
||||||
|
|
||||||
|
任务清单:
|
||||||
|
|
||||||
|
1. 创建 `job_node` 表
|
||||||
|
2. 定义节点状态枚举与状态迁移规则
|
||||||
|
3. 定义节点尝试号 `attempt_no`
|
||||||
|
4. 开发节点开始接口 `POST /api/jobs/{jobId}/nodes/{nodeCode}/start`
|
||||||
|
5. 开发节点成功接口 `POST /api/jobs/{jobId}/nodes/{nodeCode}/complete`
|
||||||
|
6. 开发节点失败接口 `POST /api/jobs/{jobId}/nodes/{nodeCode}/fail`
|
||||||
|
7. 实现节点回调幂等控制
|
||||||
|
8. 实现节点超时扫描与升级策略
|
||||||
|
|
||||||
|
交付物:
|
||||||
|
|
||||||
|
- 节点不会重复推进
|
||||||
|
- 同一节点重复回调时可安全去重
|
||||||
|
- 失败与超时都能落事件
|
||||||
|
|
||||||
|
## Milestone 4:RPA 执行适配层与统一契约
|
||||||
|
|
||||||
|
目标:让底层 RPA 平台只做执行,不做流程主状态判断,并具备可切换能力。
|
||||||
|
|
||||||
|
任务清单:
|
||||||
|
|
||||||
|
1. 输出 `agent-contract/node_callback_examples.json`
|
||||||
|
2. 定义统一的 `base_adapter` 接口
|
||||||
|
3. 实现 `yingdao_adapter` 与 `isearch_adapter` 骨架
|
||||||
|
4. 定义平台回调鉴权方式
|
||||||
|
5. 约定统一回传字段:错误码、截图路径、日志路径、录屏路径、输出业务单号
|
||||||
|
6. 约定节点编码字典,例如 `OCR_EXTRACT`、`REBATE_IMPORT_ITEMS`、`M5_CREATE_ORDER`
|
||||||
|
7. 定义平台失败错误码分层:系统类、业务类、配置类、页面变化类、能力不满足类
|
||||||
|
8. 为每个节点定义能力标签,例如 `desktop_ui`、`file_upload`、`excel_template`、`http_callback`
|
||||||
|
9. 制作联调用的假回调脚本或 Postman 集合
|
||||||
|
|
||||||
|
交付物:
|
||||||
|
|
||||||
|
- 不同平台都可以按统一 JSON 回调
|
||||||
|
- 开发和平台实施同学对节点编码、错误码、能力标签理解一致
|
||||||
|
|
||||||
|
## Milestone 5:手机待办、企业微信与 H5 页面
|
||||||
|
|
||||||
|
目标:让业务能在手机端收到通知、看上下文、补字段、继续或转人工。
|
||||||
|
|
||||||
|
任务清单:
|
||||||
|
|
||||||
|
1. 创建 `user_task` 表
|
||||||
|
2. 实现角色路由规则:计划员、采购、质管、运维
|
||||||
|
3. 开发 `GET /api/tasks/my`
|
||||||
|
4. 开发 `POST /api/tasks/{taskId}/submit`
|
||||||
|
5. 开发 `POST /api/jobs/{jobId}/handoff`
|
||||||
|
6. 开发企业微信消息发送与模版卡片更新
|
||||||
|
7. 开发“人话状态”和“错误翻译”展示逻辑
|
||||||
|
8. 开发 H5 处理页,展示关键字段、截图、待办动作
|
||||||
|
9. 开发“为什么发给你”“修改前后对比”“提交后即时回执”
|
||||||
|
10. 接入企业微信身份识别
|
||||||
|
11. 为待办提交增加 `submit_token`,防止重复点击重复推进
|
||||||
|
12. 为高风险动作增加短时间撤回设计
|
||||||
|
|
||||||
|
交付物:
|
||||||
|
|
||||||
|
- 业务能在企业微信打开待办详情
|
||||||
|
- 人工提交只能推进一次
|
||||||
|
- 所有修改留痕
|
||||||
|
- 用户能看懂待办原因、知道该怎么处理、处理后知道系统已受理
|
||||||
|
|
||||||
|
## Milestone 6:查询页、审计与运维能力
|
||||||
|
|
||||||
|
目标:让项目负责人和运维能查问题、追流程、看证据。
|
||||||
|
|
||||||
|
任务清单:
|
||||||
|
|
||||||
|
1. 开发 `GET /api/jobs/{jobId}`
|
||||||
|
2. 开发按合同编号、供应商、日期、状态筛选列表接口
|
||||||
|
3. 展示节点轨迹、责任角色、重试记录
|
||||||
|
4. 展示 OCR 结果、映射结果、截图和日志摘要
|
||||||
|
5. 增加人工重试接口 `POST /api/jobs/{jobId}/retry`
|
||||||
|
6. 增加“整单重跑”能力,生成新的 `job`
|
||||||
|
7. 增加查看/下载审计日志
|
||||||
|
|
||||||
|
交付物:
|
||||||
|
|
||||||
|
- 出问题时能快速看出卡在哪一层
|
||||||
|
- 能区分当前实例和历史重跑实例
|
||||||
|
|
||||||
|
## Milestone 7:测试、联调与上线准备
|
||||||
|
|
||||||
|
目标:把关键链路和关键失败场景都压一遍。
|
||||||
|
|
||||||
|
任务清单:
|
||||||
|
|
||||||
|
1. 覆盖全自动正向场景
|
||||||
|
2. 覆盖 OCR 低置信度转计划员场景
|
||||||
|
3. 覆盖返利中心模板部分失败场景
|
||||||
|
4. 覆盖 M5 质管基础资料不全场景
|
||||||
|
5. 覆盖不同平台回调重复提交场景
|
||||||
|
6. 覆盖企业微信待办重复提交场景
|
||||||
|
7. 覆盖共享目录重复上传场景
|
||||||
|
8. 覆盖节点超时升级场景
|
||||||
|
9. 覆盖新单切换执行平台场景
|
||||||
|
10. 输出上线切换清单、回滚清单、账号权限清单
|
||||||
|
|
||||||
|
交付物:
|
||||||
|
|
||||||
|
- 联调报告
|
||||||
|
- UAT 场景清单
|
||||||
|
- 上线/回滚 checklist
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 表结构草案
|
||||||
|
|
||||||
|
说明:下面是一期最低可用表结构,不追求一次做成大而全平台,先满足幂等、留痕、查询和待办协同。
|
||||||
|
|
||||||
|
### 4.1 `job`
|
||||||
|
|
||||||
|
一份合同的一次流程实例。
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| id | bigint PK | 主键 |
|
||||||
|
| job_id | varchar(64) unique | 对外流程单号 |
|
||||||
|
| business_key | varchar(128) | 业务唯一键 |
|
||||||
|
| parent_job_id | varchar(64) null | 重跑来源 |
|
||||||
|
| source_type | varchar(32) | shared_folder/manual_upload |
|
||||||
|
| source_file_name | varchar(255) | 原始文件名 |
|
||||||
|
| source_file_hash | varchar(64) | 原始文件哈希 |
|
||||||
|
| contract_type | varchar(32) | 年度协议/批次合同 |
|
||||||
|
| contract_no | varchar(64) null | 合同编号 |
|
||||||
|
| supplier_name | varchar(128) null | 供应商名称 |
|
||||||
|
| planner_name | varchar(64) null | 计划员 |
|
||||||
|
| current_node_code | varchar(64) | 当前节点 |
|
||||||
|
| status | varchar(32) | PENDING/RUNNING/WAITING_USER/... |
|
||||||
|
| mapping_version | varchar(64) null | 映射配置版本 |
|
||||||
|
| executor_platform | varchar(32) null | 默认执行平台 |
|
||||||
|
| contract_system_no | varchar(64) null | 合同系统单号 |
|
||||||
|
| rebate_order_no | varchar(64) null | 返利中心总单号 |
|
||||||
|
| m5_order_no | varchar(64) null | M5 采购订单号 |
|
||||||
|
| receipt_notice_no | varchar(64) null | 收货通知单号 |
|
||||||
|
| current_role | varchar(32) null | 当前责任角色 |
|
||||||
|
| current_assignee | varchar(64) null | 当前责任人 |
|
||||||
|
| created_by | varchar(64) | 发起人或系统 |
|
||||||
|
| created_at | datetime | 创建时间 |
|
||||||
|
| updated_at | datetime | 更新时间 |
|
||||||
|
| finished_at | datetime null | 完成时间 |
|
||||||
|
|
||||||
|
索引建议:
|
||||||
|
|
||||||
|
- `uniq_job_business_open`:`business_key + status`
|
||||||
|
- `idx_job_contract_no`
|
||||||
|
- `idx_job_supplier_status`
|
||||||
|
- `idx_job_created_at`
|
||||||
|
|
||||||
|
### 4.2 `job_node`
|
||||||
|
|
||||||
|
记录每个节点每次尝试。
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| id | bigint PK | 主键 |
|
||||||
|
| job_id | varchar(64) | 流程单号 |
|
||||||
|
| node_code | varchar(64) | 节点编码 |
|
||||||
|
| attempt_no | int | 第几次执行 |
|
||||||
|
| status | varchar(32) | 节点状态 |
|
||||||
|
| executor_platform | varchar(32) null | 实际执行平台 |
|
||||||
|
| executor_run_id | varchar(64) null | 平台侧任务号 |
|
||||||
|
| started_at | datetime null | 开始时间 |
|
||||||
|
| ended_at | datetime null | 结束时间 |
|
||||||
|
| duration_ms | bigint null | 耗时 |
|
||||||
|
| error_code | varchar(64) null | 错误码 |
|
||||||
|
| error_summary | varchar(500) null | 错误摘要 |
|
||||||
|
| role_at_failure | varchar(32) null | 失败时责任角色 |
|
||||||
|
| assignee_at_failure | varchar(64) null | 失败时责任人 |
|
||||||
|
| output_json | json null | 节点输出 |
|
||||||
|
| callback_idempotency_key | varchar(128) null | 回调幂等键 |
|
||||||
|
| created_at | datetime | 创建时间 |
|
||||||
|
|
||||||
|
索引建议:
|
||||||
|
|
||||||
|
- `uniq_job_node_attempt`
|
||||||
|
- `idx_job_node_status`
|
||||||
|
|
||||||
|
### 4.3 `user_task`
|
||||||
|
|
||||||
|
企业微信/H5 里的人工处理单。
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| id | bigint PK | 主键 |
|
||||||
|
| task_id | varchar(64) unique | 待办编号 |
|
||||||
|
| job_id | varchar(64) | 所属流程 |
|
||||||
|
| node_code | varchar(64) | 对应节点 |
|
||||||
|
| role_code | varchar(32) | 目标角色 |
|
||||||
|
| assignee_user_id | varchar(64) null | 当前处理人 |
|
||||||
|
| title | varchar(255) | 待办标题 |
|
||||||
|
| reason_type | varchar(32) | OCR_LOW_CONF/REBATE_FAIL/... |
|
||||||
|
| reason_summary | varchar(500) | 待办原因 |
|
||||||
|
| reason_user_friendly | varchar(500) | 面向业务的人话说明 |
|
||||||
|
| why_me_text | varchar(255) | 为什么发给当前角色/当前人 |
|
||||||
|
| editable_fields_json | json | 白名单字段 |
|
||||||
|
| action_schema_json | json | 允许动作 |
|
||||||
|
| comparison_snapshot_json | json null | 原值/建议值/当前值对比 |
|
||||||
|
| status | varchar(32) | OPEN/SUBMITTED/CANCELLED/EXPIRED |
|
||||||
|
| submit_token | varchar(64) | 防重复令牌 |
|
||||||
|
| submitted_action | varchar(32) null | 继续/重试/转人工/终止 |
|
||||||
|
| submitted_payload_json | json null | 人工提交内容 |
|
||||||
|
| submitted_by | varchar(64) null | 提交人 |
|
||||||
|
| submitted_at | datetime null | 提交时间 |
|
||||||
|
| due_at | datetime null | 超时升级时间 |
|
||||||
|
| created_at | datetime | 创建时间 |
|
||||||
|
|
||||||
|
### 4.4 `mapping_config`
|
||||||
|
|
||||||
|
映射规则表,一期可以先做统一表,后续再拆子表。
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| id | bigint PK | 主键 |
|
||||||
|
| config_key | varchar(128) | 业务键 |
|
||||||
|
| config_type | varchar(32) | supplier/item/settlement/payment_term/route |
|
||||||
|
| source_value | varchar(255) | 来源值 |
|
||||||
|
| target_value | varchar(255) | 目标值 |
|
||||||
|
| extra_json | json null | 扩展参数 |
|
||||||
|
| version_no | varchar(64) | 版本号 |
|
||||||
|
| is_active | tinyint | 是否生效 |
|
||||||
|
| effective_from | datetime | 生效时间 |
|
||||||
|
| effective_to | datetime null | 失效时间 |
|
||||||
|
| updated_by | varchar(64) | 更新人 |
|
||||||
|
| updated_at | datetime | 更新时间 |
|
||||||
|
|
||||||
|
### 4.5 `executor_binding_config`
|
||||||
|
|
||||||
|
节点与执行平台路由配置。
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| id | bigint PK | 主键 |
|
||||||
|
| node_code | varchar(64) | 节点编码 |
|
||||||
|
| preferred_platform | varchar(32) | 首选平台 |
|
||||||
|
| fallback_platform | varchar(32) null | 备用平台 |
|
||||||
|
| required_capabilities_json | json | 所需能力标签 |
|
||||||
|
| route_policy | varchar(32) | FIXED / PREFERRED / FALLBACK |
|
||||||
|
| is_active | tinyint | 是否生效 |
|
||||||
|
| updated_by | varchar(64) | 更新人 |
|
||||||
|
| updated_at | datetime | 更新时间 |
|
||||||
|
|
||||||
|
### 4.6 `job_artifact`
|
||||||
|
|
||||||
|
存证表。
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| id | bigint PK | 主键 |
|
||||||
|
| job_id | varchar(64) | 流程单号 |
|
||||||
|
| node_code | varchar(64) null | 所属节点 |
|
||||||
|
| artifact_type | varchar(32) | SOURCE_FILE/OCR_JSON/SCREENSHOT/TEMPLATE/LOG |
|
||||||
|
| storage_path | varchar(500) | 存储路径 |
|
||||||
|
| file_name | varchar(255) | 文件名 |
|
||||||
|
| file_hash | varchar(64) null | 文件哈希 |
|
||||||
|
| is_masked | tinyint | 是否脱敏 |
|
||||||
|
| visible_roles_json | json | 可见角色 |
|
||||||
|
| created_at | datetime | 创建时间 |
|
||||||
|
|
||||||
|
### 4.7 `job_event`
|
||||||
|
|
||||||
|
事件流表,用来查轨迹。
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| id | bigint PK | 主键 |
|
||||||
|
| job_id | varchar(64) | 流程单号 |
|
||||||
|
| node_code | varchar(64) null | 节点 |
|
||||||
|
| event_type | varchar(64) | JOB_CREATED/NODE_FAILED/TASK_CREATED/... |
|
||||||
|
| event_summary | varchar(500) | 事件摘要 |
|
||||||
|
| event_payload_json | json null | 事件详情 |
|
||||||
|
| operator_type | varchar(32) | SYSTEM/ROBOT/USER |
|
||||||
|
| operator_id | varchar(64) | 操作人 |
|
||||||
|
| created_at | datetime | 创建时间 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. MySQL DDL 示例
|
||||||
|
|
||||||
|
以下 DDL 不是最终生产版,但足够作为建库初稿。
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE job (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
job_id VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
business_key VARCHAR(128) NOT NULL,
|
||||||
|
parent_job_id VARCHAR(64) NULL,
|
||||||
|
source_type VARCHAR(32) NOT NULL,
|
||||||
|
source_file_name VARCHAR(255) NOT NULL,
|
||||||
|
source_file_hash VARCHAR(64) NOT NULL,
|
||||||
|
contract_type VARCHAR(32) NOT NULL,
|
||||||
|
contract_no VARCHAR(64) NULL,
|
||||||
|
supplier_name VARCHAR(128) NULL,
|
||||||
|
planner_name VARCHAR(64) NULL,
|
||||||
|
current_node_code VARCHAR(64) NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL,
|
||||||
|
mapping_version VARCHAR(64) NULL,
|
||||||
|
executor_platform VARCHAR(32) NULL,
|
||||||
|
contract_system_no VARCHAR(64) NULL,
|
||||||
|
rebate_order_no VARCHAR(64) NULL,
|
||||||
|
m5_order_no VARCHAR(64) NULL,
|
||||||
|
receipt_notice_no VARCHAR(64) NULL,
|
||||||
|
current_role VARCHAR(32) NULL,
|
||||||
|
current_assignee VARCHAR(64) NULL,
|
||||||
|
created_by VARCHAR(64) NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL,
|
||||||
|
finished_at DATETIME NULL,
|
||||||
|
KEY idx_job_contract_no (contract_no),
|
||||||
|
KEY idx_job_supplier_status (supplier_name, status),
|
||||||
|
KEY idx_job_created_at (created_at)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE job_node (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
job_id VARCHAR(64) NOT NULL,
|
||||||
|
node_code VARCHAR(64) NOT NULL,
|
||||||
|
attempt_no INT NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL,
|
||||||
|
executor_platform VARCHAR(32) NULL,
|
||||||
|
executor_run_id VARCHAR(64) NULL,
|
||||||
|
started_at DATETIME NULL,
|
||||||
|
ended_at DATETIME NULL,
|
||||||
|
duration_ms BIGINT NULL,
|
||||||
|
error_code VARCHAR(64) NULL,
|
||||||
|
error_summary VARCHAR(500) NULL,
|
||||||
|
role_at_failure VARCHAR(32) NULL,
|
||||||
|
assignee_at_failure VARCHAR(64) NULL,
|
||||||
|
output_json JSON NULL,
|
||||||
|
callback_idempotency_key VARCHAR(128) NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
UNIQUE KEY uniq_job_node_attempt (job_id, node_code, attempt_no)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_task (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
task_id VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
job_id VARCHAR(64) NOT NULL,
|
||||||
|
node_code VARCHAR(64) NOT NULL,
|
||||||
|
role_code VARCHAR(32) NOT NULL,
|
||||||
|
assignee_user_id VARCHAR(64) NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
reason_type VARCHAR(32) NOT NULL,
|
||||||
|
reason_summary VARCHAR(500) NOT NULL,
|
||||||
|
editable_fields_json JSON NOT NULL,
|
||||||
|
action_schema_json JSON NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL,
|
||||||
|
submit_token VARCHAR(64) NOT NULL,
|
||||||
|
submitted_action VARCHAR(32) NULL,
|
||||||
|
submitted_payload_json JSON NULL,
|
||||||
|
submitted_by VARCHAR(64) NULL,
|
||||||
|
submitted_at DATETIME NULL,
|
||||||
|
due_at DATETIME NULL,
|
||||||
|
created_at DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. API 草案
|
||||||
|
|
||||||
|
说明:接口统一由状态中心对外提供,RPA 平台、H5 页面、企业微信回调都走状态中心,不允许直接改数据库。
|
||||||
|
|
||||||
|
鉴权建议:
|
||||||
|
|
||||||
|
- 内部服务接口:`Authorization: Bearer <service-token>`
|
||||||
|
- RPA 平台回调接口:`X-Agent-Token`
|
||||||
|
- H5 用户接口:企业微信身份换取后端 session 或 JWT
|
||||||
|
- 幂等写接口:增加 `X-Idempotency-Key`
|
||||||
|
|
||||||
|
### 6.1 建单接口
|
||||||
|
|
||||||
|
`POST /api/jobs`
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
- 共享目录监听器建单
|
||||||
|
- 状态中心手工上传建单
|
||||||
|
|
||||||
|
请求示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sourceType": "shared_folder",
|
||||||
|
"sourceFileName": "20260625_国药控股_张三_批次合同_01.pdf",
|
||||||
|
"sourceFileHash": "6f6f5d8c...",
|
||||||
|
"contractType": "BATCH",
|
||||||
|
"supplierName": "国药控股",
|
||||||
|
"plannerName": "张三",
|
||||||
|
"executorPlatform": "YINGDAO",
|
||||||
|
"createdBy": "system-folder-watcher",
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"artifactType": "SOURCE_FILE",
|
||||||
|
"storagePath": "/data/jobs/inbox/20260625_国药控股_张三_批次合同_01.pdf"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jobId": "JOB202606260001",
|
||||||
|
"status": "PENDING",
|
||||||
|
"currentNodeCode": "FILE_INTAKE"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 节点开始
|
||||||
|
|
||||||
|
`POST /api/jobs/{jobId}/nodes/{nodeCode}/start`
|
||||||
|
|
||||||
|
请求示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"attemptNo": 1,
|
||||||
|
"executorPlatform": "YINGDAO",
|
||||||
|
"operator": "yingdao-agent-01",
|
||||||
|
"startedAt": "2026-06-26T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 节点成功
|
||||||
|
|
||||||
|
`POST /api/jobs/{jobId}/nodes/{nodeCode}/complete`
|
||||||
|
|
||||||
|
请求示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"attemptNo": 1,
|
||||||
|
"executorPlatform": "YINGDAO",
|
||||||
|
"executorRunId": "YD-RUN-20260626-001",
|
||||||
|
"endedAt": "2026-06-26T10:03:25+08:00",
|
||||||
|
"businessRefs": {
|
||||||
|
"contractSystemNo": "HT20260626018",
|
||||||
|
"rebateOrderNo": "FL20260626009",
|
||||||
|
"m5OrderNo": null,
|
||||||
|
"receiptNoticeNo": null
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"importedItemCount": 18,
|
||||||
|
"failedItemCount": 0
|
||||||
|
},
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"artifactType": "SCREENSHOT",
|
||||||
|
"storagePath": "/data/jobs/JOB202606260001/REBATE_IMPORT_ITEMS/success.png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- 如果没有业务编号且节点本身要求有编号,则不能直接记 `SUCCESS`
|
||||||
|
|
||||||
|
### 6.4 节点失败
|
||||||
|
|
||||||
|
`POST /api/jobs/{jobId}/nodes/{nodeCode}/fail`
|
||||||
|
|
||||||
|
请求示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"attemptNo": 1,
|
||||||
|
"executorPlatform": "YINGDAO",
|
||||||
|
"executorRunId": "YD-RUN-20260626-001",
|
||||||
|
"endedAt": "2026-06-26T10:05:01+08:00",
|
||||||
|
"errorCode": "REBATE_TEMPLATE_PARTIAL_FAIL",
|
||||||
|
"errorSummary": "18 行中有 3 行校验失败",
|
||||||
|
"retryable": false,
|
||||||
|
"routeRole": "PURCHASER",
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"artifactType": "SCREENSHOT",
|
||||||
|
"storagePath": "/data/jobs/JOB202606260001/REBATE_IMPORT_ITEMS/error.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"artifactType": "LOG",
|
||||||
|
"storagePath": "/data/jobs/JOB202606260001/REBATE_IMPORT_ITEMS/agent.log"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5 我的待办
|
||||||
|
|
||||||
|
`GET /api/tasks/my`
|
||||||
|
|
||||||
|
查询参数建议:
|
||||||
|
|
||||||
|
- `status=open`
|
||||||
|
- `roleCode=PLANNER`
|
||||||
|
- `pageNo=1`
|
||||||
|
- `pageSize=20`
|
||||||
|
|
||||||
|
响应字段建议:
|
||||||
|
|
||||||
|
- `taskId`
|
||||||
|
- `jobId`
|
||||||
|
- `title`
|
||||||
|
- `reasonSummary`
|
||||||
|
- `reasonUserFriendly`
|
||||||
|
- `whyMeText`
|
||||||
|
- `nodeCode`
|
||||||
|
- `supplierName`
|
||||||
|
- `contractNo`
|
||||||
|
- `dueAt`
|
||||||
|
- `detailUrl`
|
||||||
|
|
||||||
|
### 6.6 待办提交
|
||||||
|
|
||||||
|
`POST /api/tasks/{taskId}/submit`
|
||||||
|
|
||||||
|
请求示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"submitToken": "9f5f2c1d",
|
||||||
|
"action": "CONFIRM_AND_CONTINUE",
|
||||||
|
"payload": {
|
||||||
|
"paymentModeCode": "2",
|
||||||
|
"paymentModeDays": 30,
|
||||||
|
"settlementCode": "3"
|
||||||
|
},
|
||||||
|
"comment": "已根据合同正文修正承付模式"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
动作枚举建议:
|
||||||
|
|
||||||
|
- `CONFIRM_AND_CONTINUE`
|
||||||
|
- `RETRY_CURRENT_NODE`
|
||||||
|
- `TRANSFER_MANUAL`
|
||||||
|
- `TERMINATE_JOB`
|
||||||
|
|
||||||
|
提交成功后的响应,建议额外返回:
|
||||||
|
|
||||||
|
- `accepted`: 是否已受理
|
||||||
|
- `receiptMessage`: 给用户看的即时回执
|
||||||
|
- `nextExpectedAction`: 系统接下来会做什么
|
||||||
|
- `estimatedWaitMinutes`: 预计多久有下一次结果
|
||||||
|
|
||||||
|
### 6.7 重试当前节点
|
||||||
|
|
||||||
|
`POST /api/jobs/{jobId}/retry`
|
||||||
|
|
||||||
|
请求示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scope": "CURRENT_NODE",
|
||||||
|
"operatorId": "zhangsan",
|
||||||
|
"reason": "计划员已修正承付模式,允许重试"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.8 转派/转人工
|
||||||
|
|
||||||
|
`POST /api/jobs/{jobId}/handoff`
|
||||||
|
|
||||||
|
请求示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"targetRole": "OPS",
|
||||||
|
"targetUserId": "wangwu",
|
||||||
|
"reason": "返利中心页面结构变化,需要机器人管理员处理"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.9 查询整单
|
||||||
|
|
||||||
|
`GET /api/jobs/{jobId}`
|
||||||
|
|
||||||
|
返回建议包含:
|
||||||
|
|
||||||
|
- `job` 基础信息
|
||||||
|
- 当前状态
|
||||||
|
- 节点轨迹数组
|
||||||
|
- 当前待办数组
|
||||||
|
- 关键业务编号
|
||||||
|
- OCR 摘要
|
||||||
|
- 映射摘要
|
||||||
|
- 证据文件列表
|
||||||
|
- 执行平台与平台侧运行号
|
||||||
|
|
||||||
|
### 6.10 企业微信回调
|
||||||
|
|
||||||
|
`POST /api/wecom/callback`
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
- 卡片动作回调
|
||||||
|
- 身份事件回调
|
||||||
|
|
||||||
|
注意:
|
||||||
|
|
||||||
|
- 回调只负责接收动作和身份,不直接写业务结果
|
||||||
|
- 必须先换成状态中心标准动作,再调用内部任务提交逻辑
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. H5 页面草案
|
||||||
|
|
||||||
|
页面最低能力建议:
|
||||||
|
|
||||||
|
1. 显示合同基础信息:合同编号、供应商、计划员、当前节点
|
||||||
|
2. 显示“当前系统正在做什么”的人话状态
|
||||||
|
3. 显示 OCR 关键字段和置信度
|
||||||
|
4. 显示最近错误截图和业务可读错误说明
|
||||||
|
5. 显示“为什么发给你”
|
||||||
|
6. 显示允许修改的字段和修改前后对比
|
||||||
|
7. 显示操作按钮:继续、重试、转人工、终止
|
||||||
|
8. 显示处理历史和最近一次失败原因
|
||||||
|
9. 提交动作后显示即时回执,避免重复点击
|
||||||
|
|
||||||
|
页面路由建议:
|
||||||
|
|
||||||
|
- `/h5/tasks/{taskId}`
|
||||||
|
- `/h5/jobs/{jobId}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 平台接入 POC 清单
|
||||||
|
|
||||||
|
任何新 RPA 平台接入前,先做下面的最小验证:
|
||||||
|
|
||||||
|
1. 登录目标系统并打开指定页面
|
||||||
|
2. 读取一份合同字段并调用一次外部 HTTP API
|
||||||
|
3. 在返利中心完成一次模板导入
|
||||||
|
4. 失败时自动截图并回调到状态中心
|
||||||
|
5. 验证平台日志、录屏和运行号能被统一采集
|
||||||
|
|
||||||
|
通过后,才允许接入正式流程路由。
|
||||||
|
|
||||||
|
## 9. 联调顺序建议
|
||||||
|
|
||||||
|
建议不要一上来就四方一起联调,按下面顺序推进更稳:
|
||||||
|
|
||||||
|
1. 状态中心单独完成建单、节点流转、待办流转
|
||||||
|
2. 先用假 OCR 和假 RPA 回调,把流程跑通
|
||||||
|
3. 接入真实 OCR
|
||||||
|
4. 接入真实企业微信消息和 H5 身份
|
||||||
|
5. 最后接入真实 RPA 流程
|
||||||
|
6. 再做返利中心/M5 端到端联调
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 测试重点
|
||||||
|
|
||||||
|
接口测试重点:
|
||||||
|
|
||||||
|
- 重复建单
|
||||||
|
- 重复回调
|
||||||
|
- 重复待办提交
|
||||||
|
- 非法状态迁移
|
||||||
|
- 低置信度转待办
|
||||||
|
- 整单重跑
|
||||||
|
|
||||||
|
集成测试重点:
|
||||||
|
|
||||||
|
- OCR 返回空字段
|
||||||
|
- 返利中心部分导入失败
|
||||||
|
- M5 保存失败
|
||||||
|
- 企业微信回调延迟或重复
|
||||||
|
- 节点长时间未回调
|
||||||
|
|
||||||
|
UAT 重点:
|
||||||
|
|
||||||
|
- 计划员在手机端补录后能继续执行
|
||||||
|
- 采购能看到失败明细和截图
|
||||||
|
- 质管收到问题后只做基础资料确认
|
||||||
|
- 运维能定位页面变化或登录失效
|
||||||
|
- 待办文案是否足够人话,业务无需培训也能理解主要动作
|
||||||
|
- 用户提交后是否明确感知“系统已受理”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 上线前必补清单
|
||||||
|
|
||||||
|
以下内容现在还没落在代码里,但上线前必须补齐:
|
||||||
|
|
||||||
|
1. 企业微信应用信息、回调域名、可信域名
|
||||||
|
2. 共享目录所在服务器、权限和目录清理规则
|
||||||
|
3. 合同附件的访问权限与脱敏策略
|
||||||
|
4. 执行平台运行节点、账号和截图/录屏落盘路径
|
||||||
|
5. 返利中心与 M5 的测试账号、生产账号隔离
|
||||||
|
6. 错误码清单和责任角色映射表
|
||||||
|
7. 环境监控:服务存活、数据库连接、磁盘容量、回调异常告警
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 建议下一步
|
||||||
|
|
||||||
|
如果要继续推进,建议按下面顺序立刻开工:
|
||||||
|
|
||||||
|
1. 先定表结构和节点编码
|
||||||
|
2. 再定统一 RPA 回调 JSON 契约和能力标签
|
||||||
|
3. 再做企业微信“H5 待办页”原型和人性化文案
|
||||||
|
4. 然后并行开发:状态中心后端、RPA 平台适配器、企业微信消息联调
|
||||||
|
|
||||||
|
这份文档适合作为技术 kickoff 和任务拆分底稿。
|
||||||
|
|
@ -0,0 +1,242 @@
|
||||||
|
# 采购主链 RPA 一期需求简版说明
|
||||||
|
|
||||||
|
## 1. 这次要做什么
|
||||||
|
|
||||||
|
一期要做的不是单个机器人脚本,而是一套“**采购主链自动执行 + 异常可协同 + 手机可处理**”的小型流程系统。
|
||||||
|
|
||||||
|
覆盖范围只包含一条主链:
|
||||||
|
|
||||||
|
`合同扫描件/合同系统 -> 返利中心采购协议 -> M5 采购订单 -> 收货通知单`
|
||||||
|
|
||||||
|
核心目标:
|
||||||
|
|
||||||
|
- 能自动跑通的单据尽量自动跑完
|
||||||
|
- 不能确定的地方不让机器人乱猜,直接转人工
|
||||||
|
- 业务人员在手机上就能知道卡在哪、为什么卡、该怎么处理
|
||||||
|
- 每一单都能查到节点轨迹、截图、日志和责任人
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 为什么要做
|
||||||
|
|
||||||
|
当前采购主链里,有几类工作重复度高、人工录入重、出错后又难追:
|
||||||
|
|
||||||
|
- 合同扫描件/合同信息需要人工录入
|
||||||
|
- 合同编号、承付模式、结算方式、货品等需要人工翻译到返利中心
|
||||||
|
- 返利中心录完后还要继续去 M5 做采购订单
|
||||||
|
- 遇到识别不准、模板报错、基础资料缺失时,只能靠人线下沟通和排查
|
||||||
|
|
||||||
|
一期方案要解决的,不只是“替人点页面”,更是把整条链做成:
|
||||||
|
|
||||||
|
- 业务看得见
|
||||||
|
- 异常有人接
|
||||||
|
- 手机能协同
|
||||||
|
- 后续能扩展
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 一期怎么做
|
||||||
|
|
||||||
|
一期按下面的方式落地:
|
||||||
|
|
||||||
|
- RPA 执行平台:负责系统页面操作
|
||||||
|
- 轻量状态中心:负责流程状态、待办、日志、重试、规则和执行平台路由
|
||||||
|
- 本地 OCR:负责识别合同扫描件
|
||||||
|
- 企业微信应用:负责手机通知和处理入口
|
||||||
|
|
||||||
|
简单理解:
|
||||||
|
|
||||||
|
- RPA 执行平台像“执行手”
|
||||||
|
- 状态中心像“总控台”
|
||||||
|
- 企业微信像“手机协同入口”
|
||||||
|
|
||||||
|
这里要特别说明两层边界:
|
||||||
|
|
||||||
|
- 我们自己的能力:流程主控、规则配置、待办协同、日志证据、统一查询
|
||||||
|
- 底层工具能力:页面操作、文件上传下载、截图录屏、执行回调
|
||||||
|
|
||||||
|
也就是说,真正要沉淀在自己手里的不是某个 RPA 工具脚本,而是上层的流程和业务资产。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 业务流程怎么走
|
||||||
|
|
||||||
|
一期主链步骤如下:
|
||||||
|
|
||||||
|
1. 采购或计划员把合同扫描件放进共享目录
|
||||||
|
2. 状态中心建单并做基础判重
|
||||||
|
3. OCR 识别合同字段
|
||||||
|
4. 状态中心按规则做供应商、货品、结算方式、承付模式映射
|
||||||
|
5. 合同系统信息补齐,并以“已审核/审核通过”为继续前提
|
||||||
|
6. 机器人进入返利中心,检索采购协议并录入总单
|
||||||
|
7. 机器人生成并导入返利中心细单模板
|
||||||
|
8. 机器人进入 M5,创建采购订单
|
||||||
|
9. 机器人完成保存并生成收货通知单
|
||||||
|
10. 状态中心归档并保留完整轨迹
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.1 多 RPA 切换边界
|
||||||
|
|
||||||
|
一期方案支持“底层执行平台可替换”,但这个边界要先定清楚:
|
||||||
|
|
||||||
|
- 可切换的是底层执行平台,比如影刀、艺赛旗等
|
||||||
|
- 不可切换的是上层状态中心、企业微信协同、规则配置和验收口径
|
||||||
|
- 支持按“新单”切换平台
|
||||||
|
- 也支持按“节点配置”决定由哪个平台执行
|
||||||
|
- 不做“一个节点执行到一半临时换平台”
|
||||||
|
- 新平台接入前,必须先做 POC 验证
|
||||||
|
|
||||||
|
这么设计的目的,是避免未来被单一厂商绑定。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 手机端能做什么
|
||||||
|
|
||||||
|
业务人员在企业微信里至少能做这些事:
|
||||||
|
|
||||||
|
- 看当前这单跑到哪一步
|
||||||
|
- 看失败原因或待确认原因
|
||||||
|
- 看关键字段摘要和错误截图
|
||||||
|
- 补录或修正允许修改的字段
|
||||||
|
- 确认后继续
|
||||||
|
- 重试当前节点
|
||||||
|
- 转人工处理
|
||||||
|
- 终止本单
|
||||||
|
|
||||||
|
而且不是冷冰冰给一条待办,而是尽量做到:
|
||||||
|
|
||||||
|
- 让业务一眼看懂“这单卡在哪、为什么找你”
|
||||||
|
- 让业务直接看到关键字段和最近错误截图
|
||||||
|
- 让业务知道改之前是什么、改之后会变成什么
|
||||||
|
- 让业务提交后马上知道“系统已经接单了”
|
||||||
|
|
||||||
|
这里的重点不是“人人都能改所有东西”,而是:
|
||||||
|
|
||||||
|
- 计划员处理 OCR/合同编号/承付模式这类问题
|
||||||
|
- 采购处理返利中心录入和模板问题
|
||||||
|
- 质管处理基础资料维护问题
|
||||||
|
- 运维处理机器人登录、页面变化、系统异常问题
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 一期的关键原则
|
||||||
|
|
||||||
|
1. 机器人只负责执行,不负责拍脑袋判断
|
||||||
|
识别不准、映射不稳时,直接转人工,不允许“猜着填”。
|
||||||
|
|
||||||
|
2. 所有状态都进状态中心
|
||||||
|
不把主状态留在某个 RPA 工具脚本里,避免后面没人知道这单停在哪。
|
||||||
|
|
||||||
|
3. 手机待办按角色路由,不按个人写死
|
||||||
|
这样有人请假、换人、轮班时更好处理。
|
||||||
|
|
||||||
|
4. 任何失败都要能追到证据
|
||||||
|
包括节点、错误码、错误摘要、截图、日志和责任角色。
|
||||||
|
|
||||||
|
5. 成功必须以“业务结果生成”为准
|
||||||
|
不是页面点完就算成功,而是要拿到返利中心单号、M5 订单号、收货通知单号等结果。
|
||||||
|
|
||||||
|
6. 上层能力归自己,下层执行器可替换
|
||||||
|
不把状态、规则、待办、留痕长在某个 RPA 平台内部。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 一期带来的直接价值
|
||||||
|
|
||||||
|
对业务:
|
||||||
|
|
||||||
|
- 减少重复录入和跨系统搬运
|
||||||
|
- 出问题时不用到处问人,手机上就能看到卡点和原因
|
||||||
|
- 计划员、采购、质管之间的责任更清楚
|
||||||
|
- 不需要先学很多系统术语,待办本身就能告诉你现在该做什么
|
||||||
|
- 提交后不容易反复点击、反复确认,手机端使用更安心
|
||||||
|
|
||||||
|
对管理:
|
||||||
|
|
||||||
|
- 每单可追踪
|
||||||
|
- 哪类问题最多、卡在哪个节点更容易看清
|
||||||
|
- 后续做统计和优化会更容易
|
||||||
|
- 平台更换时不影响业务使用习惯,推广阻力更小
|
||||||
|
|
||||||
|
对技术:
|
||||||
|
|
||||||
|
- 一期先做成小中台,后面二期三期可以继续接订单中心、招采子系统
|
||||||
|
- 不把流程逻辑绑死在单个机器人脚本里
|
||||||
|
- 当影刀、艺赛旗或其他平台的成本、授权、稳定性变化时,可以平滑替换底层执行器
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 一期明确包含什么
|
||||||
|
|
||||||
|
包含:
|
||||||
|
|
||||||
|
- 共享目录入池
|
||||||
|
- OCR 识别
|
||||||
|
- 映射规则管理
|
||||||
|
- 合同系统前置确认
|
||||||
|
- 返利中心采购协议录入
|
||||||
|
- 返利中心细单模板导入
|
||||||
|
- M5 采购订单创建
|
||||||
|
- 收货通知单生成
|
||||||
|
- 企业微信通知和手机处理入口
|
||||||
|
- 待办“人话状态”“错误翻译”“修改前后对比”“即时回执”等体验设计
|
||||||
|
- 状态查询、日志、截图、待办留痕
|
||||||
|
- 多 RPA 执行平台的可切换设计
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 一期明确不包含什么
|
||||||
|
|
||||||
|
以下内容不纳入一期:
|
||||||
|
|
||||||
|
- 订单中心分货与销售订单联动
|
||||||
|
- 招采子系统医院计划抓取
|
||||||
|
- 发货后一票/二票自动上传
|
||||||
|
- 渠道价管理与维护
|
||||||
|
- 邮件自动收件作为主入口
|
||||||
|
- 复杂 BI 看板
|
||||||
|
|
||||||
|
这些内容如果后续要做,建议单独立项,作为二期或三期推进。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 上线前必须先确认的前置条件
|
||||||
|
|
||||||
|
1. 企业微信正式应用能申请下来
|
||||||
|
2. 企业微信支持消息、H5 入口、身份识别和回调
|
||||||
|
3. 共享目录位置和权限已定
|
||||||
|
4. 合同扫描件的命名规范已定
|
||||||
|
5. 一期主执行平台的运行环境和账号已准备
|
||||||
|
6. 返利中心、M5、合同系统的测试账号已准备
|
||||||
|
7. 承付模式、结算方式、供应商、货品等映射表已准备
|
||||||
|
8. 合同系统“送审/审核通过”的业务规则已确认
|
||||||
|
9. 选定的一期主执行平台已完成 POC 验证
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 一期验收怎么判断
|
||||||
|
|
||||||
|
达到下面几点,就说明一期做成了:
|
||||||
|
|
||||||
|
- 业务能在手机端知道每单当前跑到哪一步
|
||||||
|
- 关键不确定项不会被机器人自动猜测
|
||||||
|
- 任何失败都能定位到具体节点、原因、截图和责任角色
|
||||||
|
- 一份合同从入池到 M5 完成的轨迹可完整追溯
|
||||||
|
- 同一合同不会因为重复上传或重复点击而形成重复业务结果
|
||||||
|
- 能明确拿到返利中心单号、M5 订单号、收货通知单号等结果
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 建议推进方式
|
||||||
|
|
||||||
|
建议按下面顺序推进:
|
||||||
|
|
||||||
|
1. 先确认一期边界和前置条件
|
||||||
|
2. 再确认节点定义、角色路由和人工处理边界
|
||||||
|
3. 再开始状态中心、RPA 执行平台、企业微信并行开发
|
||||||
|
4. 最后做端到端联调和试运行
|
||||||
|
|
||||||
|
如果要给业务和领导一句话总结,这个项目可以这样讲:
|
||||||
|
|
||||||
|
“我们不是单做一个 RPA 机器人,而是在采购主链先搭一个轻量自动化中台,让合同到返利中心再到 M5 的流程尽量自动跑通,异常时能第一时间在企业微信手机端协同处理,并把整单过程完整留痕,同时让底层 RPA 执行工具保持可替换。”
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
# 界面截图证据记录
|
||||||
|
|
||||||
|
> 用途:记录从会议录像截取的系统界面字段结构,作为需求文档的画面级证据。
|
||||||
|
> 说明:视频原始分辨率仅 960×480,抽帧OCR质量有限;以下以直接可辨识的界面截图为准。
|
||||||
|
|
||||||
|
## 1. 返利中心(flgzmpc.com)
|
||||||
|
|
||||||
|
- 平台主体:福建广药洁达医药有限公司,登录人:廖建峰
|
||||||
|
- 功能入口:采购协议管理
|
||||||
|
- 首页有"**待审批事项**"列表,字段:流程名称、流程标识、当前节点、申请人、申请时间、操作
|
||||||
|
- 支持**异步报表导出**:提示"待收返利报表_202683170210 的数据导出文件可以下载了"
|
||||||
|
- 送审后申请人=操作人,进入待审批列表
|
||||||
|
|
||||||
|
## 2. 合同系统(contract.gzmpc.com/km/agreement/)
|
||||||
|
|
||||||
|
- 独立域名的合同管理系统
|
||||||
|
- 左侧完整菜单树
|
||||||
|
- 两套模板:格式采购合同(药品通用)、非格式采购合同(单次,承兑用)
|
||||||
|
|
||||||
|
## 3. M5 采购订单管理 1055(INCA 系统)
|
||||||
|
|
||||||
|
- 角色:福建洁达采购员;部门:采购部;操作员:廖建峰;公司:福建广药洁达医药(907)
|
||||||
|
|
||||||
|
### 顶部功能按钮(两行)
|
||||||
|
第一行:新增、编辑、作废、删除、查询、送达、导入导出、选库存报警信息生成细单、新增细单、编辑细单、删除细单、打印、保存
|
||||||
|
第二行:签订、作废、拆分、勾选采购协议、查询转付款、最新采购情况、批量操作、切换独立单元、仓库转款、生成收货通知单、打印单作废、清空下发标记
|
||||||
|
|
||||||
|
### 采购订单总单列
|
||||||
|
行号、采购订单ID、制单人、订单编号、业务日期、供应商、数据员、联系人、订单金额、书面订单号、采购订单完成、生成状态、开始日期、结果日期、签约日期、结算方式、预付款、其他费用、配送加点率、签约人、送达地点、代理方式、签约类
|
||||||
|
|
||||||
|
### 采购订单细单列
|
||||||
|
行号、采购订单细单ID、采购员、货品ID、通用名、商品名、条形码、规格(规格型号)、基本单位、生产厂家、产地、上市许可持有人、货品数量、单价、金额
|
||||||
|
|
||||||
|
## 4. 医药城订单导出表(订单明细.xls)
|
||||||
|
|
||||||
|
列:下单时间、创建人、订单号、父订单号、支付方式、商家、订单类型、订单状态、品名、产品编码、规格、厂家、国药准字
|
||||||
|
|
||||||
|
样例数据:
|
||||||
|
- 下单时间 `2026-08-04 18:50:48.0`
|
||||||
|
- 订单号 `ZQD202608041850483308`
|
||||||
|
- 支付方式 `账期支付`
|
||||||
|
- 商家 `重庆亿昊药业有限公司`
|
||||||
|
- 订单类型 `普通订单`
|
||||||
|
- 订单状态 `待付款/已取消/待收货`
|
||||||
|
- 品名 `哈药牌钙铁锌口服液`
|
||||||
|
- 产品编码 `2919482534`
|
||||||
|
|
||||||
|
## 5. M5 货品对码基础表(货品运营管理.xls)
|
||||||
|
|
||||||
|
列:通用名、批准文号、规格、基本单位、生产厂家、货品ID、货品编码
|
||||||
|
|
||||||
|
- 匹配键(四要素):通用名 + 批准文号 + 规格 + 生产厂家 → 货品ID + 货品编码
|
||||||
|
- 厂家名含大量英文(进口药):如 Abbott Biologicals B.V.、AESICA PHARMACEUTICALS S.r.l.、ALCON CUSI S.A.
|
||||||
|
|
||||||
|
## 尚未获得清晰画面(需补充)
|
||||||
|
|
||||||
|
- 小药药平台界面
|
||||||
|
- 返利中心总单/细单录入页字段全貌
|
||||||
|
- 合同系统模板内部字段
|
||||||
|
- 线下手工单开单截图样式
|
||||||
Binary file not shown.
|
|
@ -0,0 +1,626 @@
|
||||||
|
# 采购主链 RPA 一期 · 实施与验收规范
|
||||||
|
|
||||||
|
> 用途:把《三方会审版需求文档》和《设计文档》落成**开发能照着做、验收能照着勾**的执行手册。
|
||||||
|
> 读者:①Trae(写上层状态中心代码) ②艺赛旗 iS-RPA 开发(搭下层页面流程) ③业务方(按业务验收标准确认) ④项目负责人(你,通盘把控)。
|
||||||
|
> 语言:全中文。术语见文末《名词表》。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第一部分 · 总览:这套系统到底怎么分工
|
||||||
|
|
||||||
|
### 1.1 上下两层(务必先分清)
|
||||||
|
|
||||||
|
整套系统是**上下两层**,由**两个不同工具**实现,不要混为一谈:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────┐
|
||||||
|
│ 上层:状态中心(大脑) ← Trae 写后台+网页代码 │
|
||||||
|
│ · 决定每一单现在该走哪一步、出错派给谁 │
|
||||||
|
│ · 存 job 状态 / 对码结果 / 证据 / 业务编号 │
|
||||||
|
│ · 【步骤树状图界面】就在这层,是网页,业务打开就能看进度 │
|
||||||
|
│ · 不亲自去点任何外部系统的页面 │
|
||||||
|
└───────────────────────────▲──────────────────────────────┘
|
||||||
|
│ HTTP 接口:下命令(派节点) / 收回执(成功/失败)
|
||||||
|
│ 传的都是 JSON,不共享数据库
|
||||||
|
┌───────────────────────────┴──────────────────────────────┐
|
||||||
|
│ 下层:艺赛旗 iS-RPA(手脚) ← 在艺赛旗设计器里可视化编排 │
|
||||||
|
│ · 真正去点 医药城/合同系统/返利中心/M5 的页面 │
|
||||||
|
│ · 是"搭流程",不是"录视频";能处理 M5 桌面客户端 │
|
||||||
|
│ · 每个动作做完,HTTP 回调状态中心报结果 │
|
||||||
|
└────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
> 澄清你最初的困惑:**不是人去录视频**。是两拨代码活——Trae 写状态中心(网页+后台),艺赛旗里搭 RPA 流程。步骤树状图是 Trae 做出来的**网页界面**,实时显示每单进度,不是 RPA 录出来的东西。
|
||||||
|
|
||||||
|
### 1.2 谁做什么(职责边界)
|
||||||
|
|
||||||
|
| 工作 | 归谁 | 产物 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 数据库建表、状态机、对码引擎、幂等去重 | Trae | 后台服务 |
|
||||||
|
| 派发节点、接收回调、判断下一步 | Trae | API |
|
||||||
|
| 待办网页、步骤树状图、整单查询页 | Trae | 前端网页 |
|
||||||
|
| 企业微信推送、H5 待办确认 | Trae | 集成 |
|
||||||
|
| 登录各系统、导出Excel、建单、上传PDF、录入M5 | 艺赛旗 | RPA流程包 |
|
||||||
|
| 页面动作做完回调状态中心 | 艺赛旗 | HTTP回调 |
|
||||||
|
| OCR 识别线下手工单 | 独立OCR服务(本地) | 字段JSON |
|
||||||
|
| 按业务验收标准确认每步对不对 | 业务方 | 验收签字 |
|
||||||
|
|
||||||
|
### 1.3 一次完整流程 = 一个 job
|
||||||
|
|
||||||
|
**一个 job = 同一供应商 + 同一订单日期 + 同一来源 的一批订单 = 一条合同。**
|
||||||
|
job 从①走到⑥,每一步是一个"节点",节点做完才走下一个。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第二部分 · 一页地图(贴在最显眼处)
|
||||||
|
|
||||||
|
```
|
||||||
|
════════════════════════════════════════════════════════════════════
|
||||||
|
采购主链 RPA 一期 · 一页导读
|
||||||
|
以销定采:先在京东/阿里卖货 → 反向采购 → 把每天手工采购链自动化
|
||||||
|
════════════════════════════════════════════════════════════════════
|
||||||
|
【主链六段】 ▓=RPA干 ░=人干 ⊙=RPA等 谁干 停了怎么办
|
||||||
|
┌──────────────────────────────────────────────┐
|
||||||
|
│ ① 导订单 登录平台→选前一天→导出Excel │ ▓▓▓ 登录失败→自动重试→运维
|
||||||
|
│ 医药城:合同要合并PDF上传 │
|
||||||
|
│ 小药药:年度协议,不传合同 │
|
||||||
|
│ 线下单:图片走OCR (~100份/月) │
|
||||||
|
├──────────────────────────────────────────────┤
|
||||||
|
│ ② 产品对码 ★核心难点★ │ ▓▓▓ 单条命中不了
|
||||||
|
│ 平台药名→[通用名+批准文号+规格+厂家]→M5货品 │ ░ →这条挂起转人工
|
||||||
|
│ ②b 没采过的品种→M5新增货源 │ →整批继续
|
||||||
|
├──────────────────────────────────────────────┤
|
||||||
|
│ ③ 合同建单 选模板→改签约日=订单日 │ ▓▓▓ 合同禁掉失效
|
||||||
|
│ 医药城:同供应商同日合同合并成1个PDF上传 │ →邮件通知采购
|
||||||
|
│ ┌送审→等半小时→出【合同编号】🔑 │ ⊙ (建议轮询别死等)
|
||||||
|
├──────────────────────────────────────────────┤
|
||||||
|
│ ④ 返利中心 用【合同编号】查总单→填→导细单 │ ▓▓▓ 重复编码
|
||||||
|
│ 细单备注必填【订单号】特定补偿必填0 │ ░ →挂起,整批继续
|
||||||
|
│ ┌送审 ⭐决定成败⭐ 不做则M5品种跳不出来 │ ▓▓▓ 不送审→硬阻断
|
||||||
|
├──────────────────────────────────────────────┤
|
||||||
|
│ ⑤ M5建采购单(1055) 选正常保管账,备注填订单号 │ ▓▓▓ 质管资料缺失
|
||||||
|
│ 结算/发票/接收方式=返利中心已下发,不用填 │ →停!转人工,不等质管
|
||||||
|
│ ★★录完就停,不点保存★★ │ ░ (②阶段其实已能预警)
|
||||||
|
├──────────────────────────────────────────────┤
|
||||||
|
│ ⑥ 收尾 检查→保存→签订→生成收货通知单 │ ░→▓ —
|
||||||
|
│ ⚠不可逆:签订后改不了,一期人工把关 │ (准确率OK后交RPA)
|
||||||
|
└──────────────────────────────────────────────┘
|
||||||
|
════════════════════════════════════════════════════════════════════
|
||||||
|
【两把钥匙:五系统无API,全靠RPA搬,靠这两个值接力】
|
||||||
|
🔑货品ID/编码(②产出) ─贯穿─► ③④⑤ 填明细都用它
|
||||||
|
🔑合同编号(③产出) ─► ④按合同号查总单→送审 ─► ⑤M5能带出协议
|
||||||
|
反直觉:卡M5的送审在【返利中心】,不在合同系统
|
||||||
|
🧵订单号(①产出,只追溯): 返利备注→物流软件看 │ M5备注→结算勾对账看
|
||||||
|
════════════════════════════════════════════════════════════════════
|
||||||
|
【停点分类:停住不蠢,分不清"整批停/单条挂起"才蠢】
|
||||||
|
A.该停·业务拍板 M5质管缺失→停转人工不等质管;不可逆保存签订→留给人
|
||||||
|
B.单条挂起别拖累 对码不上/返利重复编码→这条转人工,整批继续
|
||||||
|
C.可以更聪明 等审批→轮询别死等;网页卡顿→自动重试1-2次再报警
|
||||||
|
════════════════════════════════════════════════════════════════════
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第三部分 · 步骤树状图长什么样(Trae 要做的网页界面)
|
||||||
|
|
||||||
|
这是状态中心的核心界面,业务打开就知道每单跑到哪、卡在哪。
|
||||||
|
|
||||||
|
```
|
||||||
|
📋 Job: YYC-亿昊药业-20260804 状态: ⏸ 等待人工处理
|
||||||
|
────────────────────────────────────────────────────────────
|
||||||
|
✅ ① 导订单 ORDER_FETCH 导出12单 22:01 12秒 [看证据]
|
||||||
|
✅ ② 产品对码 PRODUCT_MATCH 对码 10/12 ⚠ 22:01 3秒
|
||||||
|
└─ ⏸ 2条未命中,等计划员选择 [去处理→]
|
||||||
|
⬜ ②b 货源新增 SOURCE_ADD —
|
||||||
|
⬜ ③ 合同建单 CONTRACT_CREATE —
|
||||||
|
⬜ ④ 返利总单 REBATE_HEADER —
|
||||||
|
⬜ ④ 返利细单 REBATE_IMPORT —
|
||||||
|
⬜ ④ 返利送审 REBATE_SUBMIT —
|
||||||
|
⬜ ⑤ M5建单 M5_CREATE_ORDER —
|
||||||
|
⬜ ⑥ 人工保存签订 —
|
||||||
|
⬜ ⑥ 生成收货通知单 —
|
||||||
|
────────────────────────────────────────────────────────────
|
||||||
|
🔑合同编号: — 🔑返利总单号: — 🧵订单号: ZQD...(共12个)
|
||||||
|
图标说明: ✅完成 ⏸等人处理 ❌失败 🔄重试中 ⬜未开始
|
||||||
|
────────────────────────────────────────────────────────────
|
||||||
|
[重跑本单] [转人工] [查看完整事件流] [导出证据包]
|
||||||
|
```
|
||||||
|
|
||||||
|
**界面验收标准(给 Trae)**:
|
||||||
|
- [ ] 能列出该 job 全部节点及状态(用上面10个节点)。
|
||||||
|
- [ ] 每个已完成节点显示:完成时间、耗时、关键结果摘要、证据链接。
|
||||||
|
- [ ] 等人处理的节点有醒目标识 + "去处理"按钮跳到待办页。
|
||||||
|
- [ ] 顶部三把钥匙(合同编号/返利总单号/订单号)实时回填。
|
||||||
|
- [ ] 失败节点显示错误码 + 人话原因 + 截图。
|
||||||
|
- [ ] 列表能按状态筛选(全部/进行中/等人/失败/完成)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第四部分 · 逐节点执行手册(核心)
|
||||||
|
|
||||||
|
> 读法:每个节点四块——
|
||||||
|
> ①**节点信息**(谁干、前置、进出什么);
|
||||||
|
> ②**页面操作步骤**(给艺赛旗,一步一动作,能照着搭);
|
||||||
|
> ③**验收标准**分两栏——**代码验收**(给 Trae/艺赛旗,机器能自检)+**业务验收**(给业务方,人能对着勾);
|
||||||
|
> ④**失败与去向**(停了怎么办,派给谁)。
|
||||||
|
>
|
||||||
|
> 术语:complete=节点成功回调;fail=节点失败回调;挂起=这一条转人工、整批继续;硬阻断=整个 job 停。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 节点① ORDER_FETCH · 订单获取
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 执行方 | 艺赛旗 iS-RPA |
|
||||||
|
| 前置条件 | 平台账号可登录;job 已建(来源+日期已定) |
|
||||||
|
| 输入 | 来源类型(医药城/小药药)、订单日期(默认前一天) |
|
||||||
|
| 输出 | 订单明细 Excel、订单号列表、供应商列表 |
|
||||||
|
|
||||||
|
**页面操作步骤(给艺赛旗)**:
|
||||||
|
1. 打开对应平台登录页,输入账号密码,登录。
|
||||||
|
2. 进入"我的订单 / 订单管理"。
|
||||||
|
3. 选择日期 = 前一天(或状态中心下发的指定日期)。
|
||||||
|
4. 【待确认】按订单状态过滤(只取"待收货"还是含其他,等业务拍板)。
|
||||||
|
5. 点"导出订单明细",等待下载完成。
|
||||||
|
6. 把下载的 Excel 存到证据目录,截图当前页面。
|
||||||
|
7. HTTP 回调 complete,带 Excel 路径 + 解析出的订单号列表 + 供应商列表。
|
||||||
|
|
||||||
|
**输出示例(回调 complete)**:
|
||||||
|
```json
|
||||||
|
{ "attemptNo": 1, "executorRunId": "YD-RUN-001",
|
||||||
|
"artifacts": [{ "artifactType": "SOURCE_EXCEL", "storagePath": "/data/jobs/.../orders.xls" }],
|
||||||
|
"summary": { "orderCount": 12, "supplierList": ["重庆亿昊药业有限公司"], "orderNoList": ["ZQD202608041850483308"] } }
|
||||||
|
```
|
||||||
|
|
||||||
|
**验收标准**
|
||||||
|
|
||||||
|
| 代码验收(机器自检) | 业务验收(人对着勾) |
|
||||||
|
| --- | --- |
|
||||||
|
| [ ] 登录成功,进到订单列表页 | [ ] 导出的单量 = 业务当天实际下单量,不多不少 |
|
||||||
|
| [ ] 日期筛选值 = 目标日期 | [ ] 供应商名、订单号与平台页面一致 |
|
||||||
|
| [ ] Excel 成功下载且非空 | [ ] 选的日期就是"前一天",没选错 |
|
||||||
|
| [ ] 订单号/供应商已解析入 order_item | [ ] 抽查一单:品名/规格/数量与平台一致 |
|
||||||
|
| [ ] 证据目录有 Excel + 截图 | |
|
||||||
|
|
||||||
|
**失败与去向**
|
||||||
|
|
||||||
|
| 情况 | errorCode | 可重试 | 去向 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 登录失败/验证码 | SYS_LOGIN_FAIL | 是(1-2次) | 仍失败→运维OPS |
|
||||||
|
| 页面卡顿/超时 | SYS_PAGE_TIMEOUT | 是(1-2次) | 仍失败→运维OPS |
|
||||||
|
| 导出按钮找不到(改版) | UI_ELEMENT_NOT_FOUND | 否 | 运维OPS |
|
||||||
|
| 当天无订单 | — | — | 正常结束,job 标记"空单" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 节点①b OCR_EXTRACT · 线下手工单识别(仅线下单)
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 执行方 | 独立 OCR 服务(本地优先) + 艺赛旗取件 |
|
||||||
|
| 前置条件 | 线下供应商发来的开单截图/PDF 已入证据目录 |
|
||||||
|
| 输入 | 图片/PDF 文件 |
|
||||||
|
| 输出 | 结构化字段 JSON(品名/规格/厂家/批准文号/数量/单价/供应商)+ 置信度 |
|
||||||
|
|
||||||
|
**页面操作步骤**:
|
||||||
|
1. 读取线下单图片/PDF。
|
||||||
|
2. 调 OCR 服务,拿回字段 + 每字段置信度。
|
||||||
|
3. 低于阈值或缺关键字段 → 标记转人工。
|
||||||
|
4. 识别结果写入 order_item(与线上同结构),进入对码。
|
||||||
|
5. 回调 complete,带识别 JSON 与置信度。
|
||||||
|
|
||||||
|
**验收标准**
|
||||||
|
|
||||||
|
| 代码验收 | 业务验收 |
|
||||||
|
| --- | --- |
|
||||||
|
| [ ] 每字段带置信度 | [ ] 抽查:识别的品名/规格/数量与原图一致 |
|
||||||
|
| [ ] 低置信度/缺字段 → 建人工待办 | [ ] 人工能看到原图,方便核对 |
|
||||||
|
| [ ] 识别结果结构与线上订单一致 | [ ] 识别错的能改,改完能继续 |
|
||||||
|
|
||||||
|
**失败与去向**
|
||||||
|
|
||||||
|
| 情况 | errorCode | 可重试 | 去向 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 图片模糊/识别失败 | BIZ_OCR_LOW_CONF | 否 | 计划员PLANNER 看原图人工录 |
|
||||||
|
| OCR 服务不可用 | SYS_OCR_DOWN | 是 | 仍失败→运维OPS |
|
||||||
|
|
||||||
|
> ⚠️ 一期最大卡点:线上 OCR 涉信息安全,须法务+数据中心批;否则本地部署。此节点部署方式**待确认**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 节点② PRODUCT_MATCH · 产品对码 ★核心难点★
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 执行方 | 状态中心(Trae 内部算,不点页面) |
|
||||||
|
| 前置条件 | order_item 已入库;对码表 mapping_config(PRODUCT) 已加载 |
|
||||||
|
| 输入 | 每行订单的:通用名/批准文号/规格/厂家 |
|
||||||
|
| 输出 | 每行 match_status(EXACT/FUZZY/MULTI/NONE) + 货品ID/编码;未命中清单 |
|
||||||
|
|
||||||
|
**处理步骤(给 Trae)**:
|
||||||
|
1. 每行做规范化(去空格、全半角、规格标准化,如 15×2↔30片、干一瓶↔×1瓶)。
|
||||||
|
2. 拼四要素键:通用名+批准文号+规格+厂家。
|
||||||
|
3. 精确命中对码表 → EXACT,回填货品ID/编码/基本单位。
|
||||||
|
4. 规格/厂家容错命中 → FUZZY,标记待复核。
|
||||||
|
5. 同商品名多规格 → MULTI,生成一对多候选,建人工待办。
|
||||||
|
6. 无命中 → NONE,建人工待办。
|
||||||
|
7. 全 EXACT/已确认 → 放行进③;有 MULTI/NONE → job 转 WAITING_USER。
|
||||||
|
|
||||||
|
**验收标准**
|
||||||
|
|
||||||
|
| 代码验收 | 业务验收 |
|
||||||
|
| --- | --- |
|
||||||
|
| [ ] 每行都有明确 match_status | [ ] 命中的货品ID确实是这个药(抽查) |
|
||||||
|
| [ ] EXACT 行回填了货品ID+编码+单位 | [ ] 一对多候选是"同一个药的不同规格写法",不是乱配 |
|
||||||
|
| [ ] MULTI 行有候选列表供 H5 选 | [ ] 未命中的能在页面上一对多选择/新增 |
|
||||||
|
| [ ] 命中率有统计数字 | [ ] 人工确认后,下次同款能自动命中(累积迭代) |
|
||||||
|
| [ ] 人工确认结果回写对码表(新版本号) | [ ] 进口药英文厂家名能容错匹配 |
|
||||||
|
|
||||||
|
**失败与去向**
|
||||||
|
|
||||||
|
| 情况 | errorCode | 可重试 | 去向 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 一对多 | BIZ_MATCH_MULTI | 否 | 该条挂起→计划员选候选;整批继续 |
|
||||||
|
| 无命中 | BIZ_MATCH_NONE | 否 | 该条挂起→计划员人工匹配/新增;整批继续 |
|
||||||
|
|
||||||
|
> 关键:未命中是**单条挂起**,不停整批。计划员处理完这条,继续跑其余。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 节点②b SOURCE_ADD · 货源新增
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 执行方 | 业务在 M5 手工新增(状态中心只通知) |
|
||||||
|
| 前置条件 | 对码命中了货品,但该供应商从未采过该品种 |
|
||||||
|
| 输入 | 供应商 + 货品 |
|
||||||
|
| 输出 | 货源建好的确认 |
|
||||||
|
|
||||||
|
**步骤**:
|
||||||
|
1. 状态中心判断"该供应商×该货品"无货源记录。
|
||||||
|
2. 推企业微信待办给业务:去 M5 新增货源。
|
||||||
|
3. 业务新增完,在待办点"已完成"。
|
||||||
|
4. job 恢复继续。
|
||||||
|
|
||||||
|
**验收标准**
|
||||||
|
|
||||||
|
| 代码验收 | 业务验收 |
|
||||||
|
| --- | --- |
|
||||||
|
| [ ] 能识别缺货源并建待办 | [ ] 通知内容说清楚是哪个供应商+哪个品种 |
|
||||||
|
| [ ] 业务确认后能继续 | [ ] 新增货源后 M5 里能查到 |
|
||||||
|
|
||||||
|
**失败与去向**:缺货源→通知业务→业务新增→继续。不阻断其余可跑节点。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 节点③ CONTRACT_CREATE · 合同建单
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 执行方 | 艺赛旗 iS-RPA |
|
||||||
|
| 前置条件 | 对码全部通过;供应商对照表已加载 |
|
||||||
|
| 输入 | 供应商、订单日期、货品明细、模板类型 |
|
||||||
|
| 输出 | 🔑合同编号、合同状态、模板类型 |
|
||||||
|
|
||||||
|
**页面操作步骤(给艺赛旗)**:
|
||||||
|
1. 登录合同系统 `contract.gzmpc.com`。
|
||||||
|
2. 按供应商对照表判断模板:格式(默认)/非格式(承兑)。
|
||||||
|
3. 可复制历史合同再改(业务现行做法)。
|
||||||
|
4. 填基础信息;**改签约日期/合同开始日期 = 订单日期**(硬约束)。
|
||||||
|
5. 填货品明细(操作码/货品编码、名称、国药准字、规格、单位、数量)。
|
||||||
|
6. 填付款信息(必填:付款方式、天数)。
|
||||||
|
7. **医药城分支**:下载平台购销合同,同供应商同日**合并成一个PDF**上传(先合并,否则第二份覆盖第一份)。
|
||||||
|
8. **小药药分支**:格式合同,跳过合同证文上传。
|
||||||
|
9. 汇总金额,提交送审。
|
||||||
|
10. 回调 complete,带合同编号。
|
||||||
|
|
||||||
|
**验收标准**
|
||||||
|
|
||||||
|
| 代码验收 | 业务验收 |
|
||||||
|
| --- | --- |
|
||||||
|
| [ ] 模板选择与对照表一致 | [ ] 签约日期 = 订单日期,没填成今天 |
|
||||||
|
| [ ] 签约日期字段 = 订单日期 | [ ] 医药城:PDF 是同供应商同日全部合并的一份 |
|
||||||
|
| [ ] 医药城已上传合并PDF且未被覆盖 | [ ] 小药药:确实没传合同证文 |
|
||||||
|
| [ ] 小药药未走上传分支 | [ ] 合同金额/品种/数量与订单一致 |
|
||||||
|
| [ ] 回传了合同编号 | [ ] 承兑供应商用的是非格式模板 |
|
||||||
|
|
||||||
|
**失败与去向**
|
||||||
|
|
||||||
|
| 情况 | errorCode | 可重试 | 去向 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 供应商合同禁掉/失效 | BIZ_CONTRACT_DISABLED | 否 | 邮件通知采购(可多人)重新禁掉;该单挂起 |
|
||||||
|
| PDF 合并能力不支持 | CAP_PDF_MERGE_UNSUPPORTED | 否 | 换平台/运维OPS |
|
||||||
|
| 页面改版找不到元素 | UI_ELEMENT_NOT_FOUND | 否 | 运维OPS |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 节点④ CONTRACT_APPROVE_WAIT · 等合同审批
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 执行方 | 状态中心(定时器)+ 艺赛旗查状态 |
|
||||||
|
| 前置条件 | 合同已送审 |
|
||||||
|
| 输入 | 合同编号 |
|
||||||
|
| 输出 | 审批通过确认 |
|
||||||
|
|
||||||
|
**步骤**:
|
||||||
|
1. 送审后启动定时器(约半小时)。
|
||||||
|
2. 到点后艺赛旗登录查审批状态(**建议轮询,别死等**)。
|
||||||
|
3. 已通过→放行进④返利;未通过→继续等并重查;超阈值→升级运维。
|
||||||
|
|
||||||
|
**验收标准**
|
||||||
|
|
||||||
|
| 代码验收 | 业务验收 |
|
||||||
|
| --- | --- |
|
||||||
|
| [ ] 有定时/轮询机制,不是硬睡死等 | [ ] 只有审批"真的通过"了才进下一步 |
|
||||||
|
| [ ] 未通过会重查,不误判 | [ ] 卡太久会有人被通知去催 |
|
||||||
|
| [ ] 超阈值升级 OPS | |
|
||||||
|
|
||||||
|
> 注意:未审批完也会生成合同编号,但**必须审批完才导入返利中心**。
|
||||||
|
|
||||||
|
**失败与去向**:超时未审完→保持等待并重查;超阈值→运维/催办。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 节点⑤ REBATE_HEADER · 返利中心总单
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 执行方 | 艺赛旗 iS-RPA |
|
||||||
|
| 前置条件 | 合同审批通过,拿到🔑合同编号 |
|
||||||
|
| 输入 | 合同编号 |
|
||||||
|
| 输出 | 返利中心总单号 |
|
||||||
|
|
||||||
|
**页面操作步骤**:
|
||||||
|
1. 登录返利中心 `flgzmpc.com`。
|
||||||
|
2. 进"采购协议管理"。
|
||||||
|
3. 按**合同编号**查对应总单。
|
||||||
|
4. 总单信息空白,填必填项、勾必选项,保存。
|
||||||
|
5. 回调 complete,带总单号。
|
||||||
|
|
||||||
|
**验收标准**
|
||||||
|
|
||||||
|
| 代码验收 | 业务验收 |
|
||||||
|
| --- | --- |
|
||||||
|
| [ ] 按合同号查到了对应总单 | [ ] 总单必填项都填了,能保存成功 |
|
||||||
|
| [ ] 必填项无遗漏 | [ ] 查出来的总单确实对应这份合同 |
|
||||||
|
| [ ] 回传总单号 | |
|
||||||
|
|
||||||
|
**失败与去向**:查不到总单/必填缺失→运维或采购核查。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 节点⑥ REBATE_IMPORT · 返利细单导入
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 执行方 | 艺赛旗 iS-RPA |
|
||||||
|
| 前置条件 | 总单已建 |
|
||||||
|
| 输入 | 对码结果、数量、单价、订单号 |
|
||||||
|
| 输出 | 导入成功/失败行 |
|
||||||
|
|
||||||
|
**页面操作步骤**:
|
||||||
|
1. 下载细单模板。
|
||||||
|
2. 填字段:操作码/货品编码、数量、单价、税率、承付方式、参数、细单开始时间(=订单时间)、失效日期、结算方式、**特定补偿=0**、**细单备注=订单号**、货源信息。
|
||||||
|
3. 上传导入。
|
||||||
|
4. 读取成功/失败行及原因。
|
||||||
|
5. 回调 complete,带成功/失败计数。
|
||||||
|
|
||||||
|
**验收标准**
|
||||||
|
|
||||||
|
| 代码验收 | 业务验收 |
|
||||||
|
| --- | --- |
|
||||||
|
| [ ] 特定补偿字段 = 0 | [ ] 细单备注确实填了订单号(物流部要看) |
|
||||||
|
| [ ] 细单备注 = 订单号 | [ ] 数量/单价与订单一致 |
|
||||||
|
| [ ] 细单开始时间 = 订单时间 | [ ] 导入的品种数 = 应导数 |
|
||||||
|
| [ ] 成功/失败行有明细回传 | [ ] 失败行原因能看懂 |
|
||||||
|
|
||||||
|
**失败与去向**
|
||||||
|
|
||||||
|
| 情况 | errorCode | 可重试 | 去向 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 存在重复编码(订单已做过) | REBATE_DUP_CODE | 否 | 该单挂起→采购;整批继续(重建总单规则**待确认**) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 节点⑦ REBATE_SUBMIT · 返利送审 ⭐决定成败⭐
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 执行方 | 艺赛旗 iS-RPA |
|
||||||
|
| 前置条件 | 细单导入成功 |
|
||||||
|
| 输入 | 总单 |
|
||||||
|
| 输出 | 送审完成(自动审批通过) |
|
||||||
|
|
||||||
|
**页面操作步骤**:
|
||||||
|
1. 在返利中心对该总单点"提交送审"。
|
||||||
|
2. 送审即自动审批通过(合同前端已审过一遍)。
|
||||||
|
3. 回调 complete。
|
||||||
|
|
||||||
|
**验收标准**
|
||||||
|
|
||||||
|
| 代码验收 | 业务验收 |
|
||||||
|
| --- | --- |
|
||||||
|
| [ ] 确认送审动作已执行且成功 | [ ] 送审后,M5 里能带出这个协议(关键验证) |
|
||||||
|
| [ ] 未送审绝不放行进 M5 | [ ] 没漏送审的单 |
|
||||||
|
|
||||||
|
**失败与去向**:未送审→**硬阻断**(不送审 M5 带不出品种,整个 job 停在此)。
|
||||||
|
|
||||||
|
> ⭐反直觉核心:卡 M5 的送审在**返利中心**,不在合同系统。这一步不做,后面全废。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 节点⑧ M5_CREATE_ORDER · M5 建采购单(1055)
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 执行方 | 艺赛旗 iS-RPA(处理 M5 桌面客户端) |
|
||||||
|
| 前置条件 | 返利已送审 |
|
||||||
|
| 输入 | 货品明细、数量、订单号 |
|
||||||
|
| 输出 | M5 采购单(**录入待保存,不点保存**) |
|
||||||
|
|
||||||
|
**页面操作步骤**:
|
||||||
|
1. M5 采购订单管理 1055 → 新增。
|
||||||
|
2. 选保管账 = 正常保管账(默认,只有一个)。
|
||||||
|
3. 日期:若隔天做,改成前一天签约日期。
|
||||||
|
4. **备注填订单号**(给采购结算勾对账用)。
|
||||||
|
5. 新增细单,选品种(返利已送审的才带得出)。
|
||||||
|
6. 选对应数量(协议累计采购量为0说明没用过)。
|
||||||
|
7. 结算/接收方式/发票 = 返利中心已下发自动带入,**不填**。
|
||||||
|
8. **★录完就停,不点保存★**。
|
||||||
|
9. 回调 complete,标记"M5待保存"。
|
||||||
|
|
||||||
|
**验收标准**
|
||||||
|
|
||||||
|
| 代码验收 | 业务验收 |
|
||||||
|
| --- | --- |
|
||||||
|
| [ ] 保管账 = 正常保管账 | [ ] 品种/数量与订单、返利协议一致 |
|
||||||
|
| [ ] 备注 = 订单号 | [ ] 备注订单号填对了(结算要看) |
|
||||||
|
| [ ] 结算/接收/发票未手工填(用带入值) | [ ] 确认停在"待保存",没被自动保存 |
|
||||||
|
| [ ] **未触发保存动作** | [ ] 隔天做的,签约日期改成了前一天 |
|
||||||
|
|
||||||
|
**失败与去向**
|
||||||
|
|
||||||
|
| 情况 | errorCode | 可重试 | 去向 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 质管资料缺失(供应商年报过期等) | M5_QC_MISSING | **否** | **停止→FAILED_MANUAL,转人工手工做,不等质管** |
|
||||||
|
|
||||||
|
> ⚠️ 硬规则:质管缺失**不重试、不等质管**(RPA 晚上跑,质管不会半夜维护)。直接转人工。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 节点⑨ M5_MANUAL_CONFIRM · 人工检查→保存→签订
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 执行方 | 一期=人工(可配开关,阶段二转 RPA) |
|
||||||
|
| 前置条件 | M5 已录入待保存 |
|
||||||
|
| 输入 | 待保存采购单 |
|
||||||
|
| 输出 | 已保存、已签订 |
|
||||||
|
|
||||||
|
**步骤**:
|
||||||
|
1. 状态中心推待办给计划员:去检查。
|
||||||
|
2. 计划员核对数量等 → 点保存 → 签订。
|
||||||
|
3. 在待办确认已完成。
|
||||||
|
|
||||||
|
**验收标准**
|
||||||
|
|
||||||
|
| 代码验收 | 业务验收 |
|
||||||
|
| --- | --- |
|
||||||
|
| [ ] auto_save_enabled 一期=0(人工) | [ ] 数量核对无误才保存 |
|
||||||
|
| [ ] 开关可切换,便于阶段二放开 | [ ] 保存/签订后不可逆,确认无误 |
|
||||||
|
| [ ] 人工确认动作有留痕 | [ ] 错的能在保存前改掉 |
|
||||||
|
|
||||||
|
> 不可逆:保存/签订后改不了,一期人工把关。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 节点⑩ M5_RECEIPT_NOTICE · 生成收货通知单
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 执行方 | 一期=人工(可配开关) |
|
||||||
|
| 前置条件 | 已签订 |
|
||||||
|
| 输入 | 已签订采购单 |
|
||||||
|
| 输出 | 收货通知单号 |
|
||||||
|
|
||||||
|
**步骤**:签订后点"生成收货通知单",采购合同流转结束。
|
||||||
|
|
||||||
|
**验收标准**
|
||||||
|
|
||||||
|
| 代码验收 | 业务验收 |
|
||||||
|
| --- | --- |
|
||||||
|
| [ ] 收货通知单号已生成并回写 | [ ] 供应商/货品/数量与采购单一致 |
|
||||||
|
| [ ] 与 M5 单号关联落库 | [ ] 通知单号能查到 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 节点⑪ ARCHIVE_JOB · 归档
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 执行方 | 状态中心 |
|
||||||
|
| 前置条件 | 全链完成 |
|
||||||
|
| 输入 | 各业务编号 |
|
||||||
|
| 输出 | 完整编号链 + 证据包 |
|
||||||
|
|
||||||
|
**步骤**:把订单号→合同编号→返利总单号→M5单号→收货通知单号全部落库,证据(Excel/PDF/截图/日志/人工记录)归档。
|
||||||
|
|
||||||
|
**验收标准**
|
||||||
|
|
||||||
|
| 代码验收 | 业务验收 |
|
||||||
|
| --- | --- |
|
||||||
|
| [ ] 五个业务编号全部落库可查 | [ ] 拿任一编号能追到整条链 |
|
||||||
|
| [ ] 证据包完整可导出 | [ ] 抽查证据能还原当时操作 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第五部分 · 你还没想到的事(容易漏,先摆到桌面)
|
||||||
|
|
||||||
|
> 这些不是需求,是"做的时候一定会撞上、现在不定后面返工"的坑。按优先级分档。
|
||||||
|
|
||||||
|
### 5.1 必须先拍板(不定就没法开工)
|
||||||
|
|
||||||
|
| # | 事项 | 为什么重要 | 建议 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | **OCR 信息安全** | 线上OCR公司可能不批,决定 OCR_EXTRACT 部署方式 | 拉法务+数据中心+业务三方会;倾向本地部署 |
|
||||||
|
| 2 | **各系统账号** | RPA 要登录5个系统,得有专用账号 | 要生产+测试账号;确认能否多开、会不会挤下线 |
|
||||||
|
| 3 | **M5 是桌面客户端还是网页** | 决定艺赛旗怎么接(桌面自动化 vs 浏览器) | 让业务确认;艺赛旗能处理桌面,但要提前验证 |
|
||||||
|
| 4 | **订单状态过滤** | 只取"待收货"还是含其他,取错会多做/漏做 | 业务给明确状态清单 |
|
||||||
|
| 5 | **对码表/供应商对照表/计划员表/邮箱** | 缺了对码、判模板、派单、发通知都做不了 | 业务限时提供,格式先对齐 |
|
||||||
|
|
||||||
|
### 5.2 并发与调度(晚上批量跑必踩)
|
||||||
|
|
||||||
|
- [ ] **一台 RPA 机同时只能开一个 M5?** 65~75单/天,串行够不够跑完一晚?要不要多机。
|
||||||
|
- [ ] **登录态互斥**:同账号多处登录会不会把前一个踢下线。
|
||||||
|
- [ ] **一个 job 卡住,别堵住后面的 job**:队列要能跳过挂起的、继续跑能跑的。
|
||||||
|
- [ ] **定时触发时间**:几点跑?跑前一天订单,那"前一天"按几点切界。
|
||||||
|
- [ ] **半小时等审批期间**,RPA 机是空等还是去干别的 job(建议轮询+切换,别占着机器空等)。
|
||||||
|
|
||||||
|
### 5.3 数据与幂等(重复跑会出乱子)
|
||||||
|
|
||||||
|
- [ ] **重复触发/重跑**:同一天同供应商重跑,会不会在合同/返利/M5 建重复单。执行前必须先检索已有单号。
|
||||||
|
- [ ] **加单/拆单**:父订单号怎么关联,加单要重做协议——一期先记录转人工(**待确认**)。
|
||||||
|
- [ ] **返利重复编码**重建总单的完整规则(会议被打断没讲完,**待确认**)。
|
||||||
|
- [ ] **金额/数量精度**:单价小数位、合计对不对得上,别因四舍五入差分。
|
||||||
|
- [ ] **同名不同药**:对码四要素里任一不同就是不同药,别被商品名骗了。
|
||||||
|
|
||||||
|
### 5.4 异常与回滚(错了怎么收场)
|
||||||
|
|
||||||
|
- [ ] **保存/签订不可逆**:一期靠人工把关。切自动前必须有准确率报告(阶段二判据要定量)。
|
||||||
|
- [ ] **中途失败的半成品**:合同建了但返利没做,这单状态怎么标、下次怎么接着做。
|
||||||
|
- [ ] **RPA 跑一半崩了**:节点要能从断点续,attempt_no+1,不覆盖历史。
|
||||||
|
- [ ] **页面改版**:5个系统任一改版,元素定位就失效。要有 UI_ 类告警,快速通知运维。
|
||||||
|
- [ ] **误操作防线**:M5 阶段一严禁自动点保存(auto_save_enabled=0 是硬默认)。
|
||||||
|
|
||||||
|
### 5.5 验收与上线(别一次全上)
|
||||||
|
|
||||||
|
- [ ] **单来源先跑通**:建议先医药城一个来源端到端,再加小药药、线下。
|
||||||
|
- [ ] **准确率统计**:对码命中率、各节点成功率要有数字,支撑"何时转全自动"。
|
||||||
|
- [ ] **证据可回溯**:每单能导出证据包(Excel/PDF/截图/日志),出问题能复盘。
|
||||||
|
- [ ] **权限可见性**:合同/OCR 证据按角色可见,敏感文件脱敏标记。
|
||||||
|
- [ ] **联调顺序**:状态中心单独跑通→假OCR假RPA打通全链→接真对码→接企业微信H5→接真RPA(先医药城)→返利/M5端到端。
|
||||||
|
|
||||||
|
### 5.6 安全底线(别裸奔)
|
||||||
|
|
||||||
|
- [ ] 状态中心所有写接口必须鉴权(内部 Bearer / RPA X-Agent-Token / H5 企业微信换JWT)。
|
||||||
|
- [ ] 回调接口必须校验 token + 幂等键,**禁止无认证暴露**。
|
||||||
|
- [ ] 账号密码不写死在流程里,走密钥管理。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 名词表
|
||||||
|
|
||||||
|
| 词 | 意思 |
|
||||||
|
| --- | --- |
|
||||||
|
| 状态中心 | 自研的编排+审计中台(Trae 写的后台+网页),大脑 |
|
||||||
|
| 艺赛旗 iS-RPA | 下层 RPA 工具,真正去点各系统页面,手脚 |
|
||||||
|
| job | 一次完整流程实例 = 同供应商+同订单日期+同来源 = 一条合同 |
|
||||||
|
| 节点 | 流程里的一步(如 ORDER_FETCH),做完才走下一个 |
|
||||||
|
| 对码 | 把平台药名匹配到 M5 货品ID/编码(四要素:通用名+批准文号+规格+厂家) |
|
||||||
|
| 挂起 | 单条转人工,整批继续跑 |
|
||||||
|
| 硬阻断 | 整个 job 停住 |
|
||||||
|
| 待保存 | M5 录完但不点保存,一期留给人工检查 |
|
||||||
|
| 三把钥匙 | 🔑货品编码 / 🔑合同编号 / 🧵订单号,系统间靠这三个值接力 |
|
||||||
|
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
# 采购主链 RPA 一期开发规划
|
||||||
|
|
||||||
|
> 用途:作为开发启动前的对齐底稿,明确"先做什么、后做什么、为什么这个顺序、卡在哪里"。
|
||||||
|
> 配套文档:《采购主链RPA一期需求文档_三方会审版》(业务规则)、《界面截图证据记录》(字段结构)。
|
||||||
|
> 核心原则:**按风险和依赖排序,不按流程顺序**。让开发启动不被 OCR 决策和资料到位阻塞。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、指导原则
|
||||||
|
|
||||||
|
1. **风险前置**:最可能推翻项目价值的假设(对码命中率),最先验证,且用最小成本验证。
|
||||||
|
2. **依赖解耦**:状态中心与 RPA 执行器分层,业务规则和审计口径掌握在项目方手里,RPA 平台可替换。
|
||||||
|
3. **单路打通**:先用最顺的一条来源(医药城)跑通端到端骨架,再横向扩展其他来源。
|
||||||
|
4. **降难度上线**:一期只做到"录入待保存 + 人工检查",把"自动保存/签订"做成配置开关(关闭),规避不可逆操作风险。
|
||||||
|
5. **阻塞并行**:OCR 信息安全、资料收集这类非技术阻塞,与开发并行推动,不占用开发关键路径。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、三类拦路虎与应对
|
||||||
|
|
||||||
|
| 类型 | 具体事项 | 应对方式 | 是否阻塞开发启动 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 决策阻塞 | OCR 走线上还是本地部署(信息安全) | 拉法务部+数据中心+业务三方会 | 否(只阻塞线下手工单分支) |
|
||||||
|
| 数据依赖 | 对码表、供应商对照表、计划员表、邮箱、测试账号 | 业务方并行收集 | 部分(对码表验证需要) |
|
||||||
|
| 技术未知 | 对码命中率、页面稳定性、返利重复导入处理 | 离线脚本 + POC 试出来 | 是(需最先解决对码) |
|
||||||
|
|
||||||
|
**结论**:只有"技术未知"里的对码命中率必须最先攻,其余两类并行推进,不进关键路径。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、阶段划分与任务拆解
|
||||||
|
|
||||||
|
### 阶段 0:可行性验证(不写 RPA 代码,最先做)
|
||||||
|
|
||||||
|
**目标**:验证对码命中率,决定项目价值和后续工作量。
|
||||||
|
|
||||||
|
| 任务 | 内容 | 产出 | 依赖 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 0.1 收集样本 | 现有货品对码表 + 一批真实历史订单Excel(医药城/小药药各若干天) | 样本数据集 | 业务提供 |
|
||||||
|
| 0.2 离线匹配脚本 | 按"通用名+批准文号+规格+厂家"四要素拼接匹配,统计命中率 | 命中率报告 | 0.1 |
|
||||||
|
| 0.3 未命中归因 | 分析未命中的规格差异模式(15×2 vs 30片、干一瓶 vs ×1瓶等) | 差异模式清单 | 0.2 |
|
||||||
|
| 0.4 对码策略结论 | 精确匹配率、需模糊匹配的规则、需一对多人工的比例 | 对码方案 | 0.3 |
|
||||||
|
|
||||||
|
**为什么最先做**:如果命中率过低(如 <70%),意味着大量人工介入,需重新评估 RPA 价值主张或先集中精力做"对码表建设工具"。这一步投入最小、结论最快、不需要任何系统账号或RPA环境。
|
||||||
|
|
||||||
|
**验收**:给出明确的命中率数字和未命中处理成本估算。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段 1:状态中心骨架 + 医药城端到端最小闭环
|
||||||
|
|
||||||
|
**目标**:打通"医药城导出 → 对码 → 合同 → 返利 → M5录入待保存"一条可跑的链路。
|
||||||
|
|
||||||
|
#### 1.1 状态中心骨架
|
||||||
|
| 任务 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 数据模型 | `job / job_node / user_task / mapping_config / job_artifact / job_event` |
|
||||||
|
| 状态机 | 7状态流转 + attempt_no 重试机制(不覆盖历史) |
|
||||||
|
| 幂等机制 | 文件哈希、业务键(合同号+类型+供应商+来源)、回调幂等键 |
|
||||||
|
| RPA回调接口 | 统一节点契约(jobId/nodeCode/attemptNo/status/businessRefs/artifacts...) |
|
||||||
|
| 证据存储 | 目录结构 `job_id/节点编码/attempt_no/`,截图/日志/JSON |
|
||||||
|
|
||||||
|
#### 1.2 医药城业务节点(RPA执行器)
|
||||||
|
| 节点编码 | 内容 | 关键细节 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ORDER_FETCH` | 登录医药城,选前一天,导出订单明细Excel | 订单状态过滤【待确认】 |
|
||||||
|
| `PRODUCT_MATCH` | 四要素对码 → 货品ID/编码,未命中转人工 | 复用阶段0成果 |
|
||||||
|
| `SOURCE_ADD` | 未采购过的供应商品种,通知M5新增货源 | — |
|
||||||
|
| `CONTRACT_CREATE` | 合同系统建单,选模板,改签约日期=订单日期 | **同供应商同日合并PDF上传** |
|
||||||
|
| `CONTRACT_APPROVE_WAIT` | 送审后等待(半小时后查审核状态) | 等待策略可配置 |
|
||||||
|
| `REBATE_HEADER` | 返利中心按合同号查总单,填必填项保存 | 查出信息为空,需手工填 |
|
||||||
|
| `REBATE_IMPORT` | 细单模板导入(操作码/数量/单价/税率/承付/结算/特定补偿0/细单备注填订单号/货源) | 重复编码处理【待确认】 |
|
||||||
|
| `REBATE_SUBMIT` | 提交送审(决定M5能否带出协议) | 关键卡点 |
|
||||||
|
| `M5_CREATE_ORDER` | M5-1055新增订单+细单,备注填订单号,选默认正常保管账 | **不点保存**(阶段一) |
|
||||||
|
|
||||||
|
#### 1.3 人工协同(企业微信/H5 最小版)
|
||||||
|
| 任务 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 待办推送 | 对码未命中、货源缺失、质管卡住等,推送到企业微信 |
|
||||||
|
| H5确认页 | 查看待确认项、一对多选择、人工修改留痕 |
|
||||||
|
| 人工检查入口 | M5录入待保存后,人工检查→保存→签订→生成收货通知单 |
|
||||||
|
|
||||||
|
**验收**:一条真实医药城订单能从导出跑到 M5 待保存,各节点有证据,人工可在H5介入。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段 2:横向扩展来源 + 完善异常
|
||||||
|
|
||||||
|
**目标**:接入小药药、线下手工单,补齐异常分流。
|
||||||
|
|
||||||
|
| 任务 | 内容 | 前置条件 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 2.1 小药药接入 | 逻辑类似医药城,但格式合同不需上传PDF | **需先补小药药界面画面** |
|
||||||
|
| 2.2 线下手工单+OCR | OCR识别开单截图→对码 | **OCR信息安全决策通过** |
|
||||||
|
| 2.3 异常分流完善 | 对码未命中、货源缺失、合同禁掉失效邮件通知、返利重复、质管卡住转人工 | 邮箱/角色表到位 |
|
||||||
|
| 2.4 收货通知单 | `M5_RECEIPT_NOTICE` + `ARCHIVE_JOB` | — |
|
||||||
|
| 2.5 订单号追溯 | 返利(物流看)/M5(结算看)订单号贯穿校验 | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段 3:稳定性与自动化开关
|
||||||
|
|
||||||
|
**目标**:验证准确率,逐步放开自动化。
|
||||||
|
|
||||||
|
| 任务 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 3.1 准确率统计 | 统计各节点自动完成率、人工纠正率 |
|
||||||
|
| 3.2 自动保存开关 | 准确率达标后,打开 `M5_MANUAL_CONFIRM` 自动化(保存/签订/生成通知单) |
|
||||||
|
| 3.3 重试与超时 | 系统异常自动重试1-2次;节点超时升级;待办30分钟/2小时提醒升级 |
|
||||||
|
| 3.4 平台可替换POC | 若需接第二个RPA平台,验证登录/检索/导入/截图/回调用例 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、技术选型推荐
|
||||||
|
|
||||||
|
> 以下为推荐方案,**均可根据公司现有技术栈和团队能力调整**。医药商业公司IT环境通常以稳定、可维护为先。
|
||||||
|
|
||||||
|
### 状态中心(后端)
|
||||||
|
| 项 | 推荐 | 理由 | 备选 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 语言/框架 | **Python 3 + FastAPI** | 与对码/OCR脚本同栈,开发快,Pydantic 校验回调契约方便;骨架已跑通(`state_center/`) | Java + Spring Boot(团队偏Java时可平移,契约不变) |
|
||||||
|
| 数据库 | **MySQL 8** | 状态中心自有库,只存流程状态+累积别名+证据(对码表在 Oracle,直连查不复制) | PostgreSQL |
|
||||||
|
| 状态机 | 轻量自实现(NODE_ORDER 顺序 + 7 状态枚举) | 7状态流转清晰,自实现更可控 | — |
|
||||||
|
| 对象存储 | MinIO / 公司现有NAS | 合同PDF、截图、OCR JSON等证据 | 本地文件系统 |
|
||||||
|
|
||||||
|
### RPA 执行平台
|
||||||
|
| 项 | 推荐 | 理由 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 平台 | **国产商业RPA(影刀 / 来也UiBot / 艺赛旗)择一POC** | 5个系统均为国内Web/客户端,国产RPA对中文界面、验证码、客户端控件支持好,有本地部署和技术支持;需求已要求"平台可替换",先接一个主平台 |
|
||||||
|
| 关键要求 | 支持HTTP回调状态中心、截图留证、Excel读写、PDF合并、文件上传 | 对应本项目节点契约 |
|
||||||
|
|
||||||
|
> 选型建议:让候选平台跑一个POC用例——登录返利中心+检索+模板导入+截图+HTTP回调,通过再定。
|
||||||
|
|
||||||
|
### OCR(仅线下手工单,取决于信息安全决策)
|
||||||
|
| 场景 | 推荐 |
|
||||||
|
| --- | --- |
|
||||||
|
| 若允许本地部署 | **PaddleOCR(本地)** — 无信息安全顾虑,一次性投入 |
|
||||||
|
| 若允许线上 | 阿里/腾讯云OCR — 按量付费,实际用量约十几元/月 |
|
||||||
|
|
||||||
|
### 企业微信 / H5
|
||||||
|
| 项 | 推荐 |
|
||||||
|
| --- | --- |
|
||||||
|
| 待办推送 | 企业微信应用消息/群机器人 |
|
||||||
|
| H5 | 轻量前端(Vue/React)+ 企业微信OAuth身份 |
|
||||||
|
|
||||||
|
### 部署
|
||||||
|
| 项 | 推荐 |
|
||||||
|
| --- | --- |
|
||||||
|
| 状态中心 | 公司内网服务器/容器,与业务系统同网段便于RPA访问 |
|
||||||
|
| RPA运行机 | 独立运行机/虚拟机,7×24或定时任务,保持登录态 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、关键路径与阻塞项(甘特视角)
|
||||||
|
|
||||||
|
```
|
||||||
|
阶段0对码验证 ──┬──────────────────────────► (决定项目走向)
|
||||||
|
│
|
||||||
|
业务收集资料 ───┼───(对码表/账号/对照表/邮箱)──► 阶段1需要
|
||||||
|
│
|
||||||
|
OCR信息安全决策 ┼────────────────► 仅阻塞阶段2.2线下单
|
||||||
|
│
|
||||||
|
└─► 阶段1 骨架+医药城 ─► 阶段2 扩展 ─► 阶段3 自动化
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键路径**:阶段0对码验证 → 阶段1医药城闭环。
|
||||||
|
**不在关键路径的阻塞**:OCR决策(只影响2.2)、小药药画面(只影响2.1)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、启动前需要业务方立即提供
|
||||||
|
|
||||||
|
| 优先级 | 事项 | 用于 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| P0 | 货品对码表 + 历史订单Excel样本 | 阶段0验证 |
|
||||||
|
| P0 | 测试账号(合同系统/返利中心/M5/医药城) | 阶段1开发 |
|
||||||
|
| P1 | 供应商对照表(合同类别/结算/合同性质) | `CONTRACT_CREATE` |
|
||||||
|
| P1 | 计划员与合同对应关系表 | 待办路由 |
|
||||||
|
| P1 | 通知邮箱(禁掉通知、质管缺料,可多人) | 异常分流 |
|
||||||
|
| P2 | 小药药平台完整演示/截图 | 阶段2.1 |
|
||||||
|
|
||||||
|
## 七、需先拍板的决策项
|
||||||
|
|
||||||
|
1. **OCR信息安全**(最大卡点):线上是否获批 / 本地部署预算。
|
||||||
|
2. **返利中心重复订单**完整处理规则(会议未讲完)。
|
||||||
|
3. **医药城订单状态过滤**:只取"待收货"还是含其他。
|
||||||
|
4. **加单场景**:父订单号关联、重做逻辑。
|
||||||
|
5. **技术栈确认**:上述推荐是否与公司现有栈冲突。
|
||||||
|
|
@ -0,0 +1,607 @@
|
||||||
|
# 采购主链 RPA 一期设计文档(技术设计 / HLD+LLD)
|
||||||
|
|
||||||
|
> 用途:把《采购主链RPA一期需求文档_三方会审版》(业务规则)和《采购主链RPA一期开发规划》(排期与阶段)落成**可建库、可对接口、可分工**的技术设计底稿。
|
||||||
|
> 关系说明:本文取代早期《一期开发任务清单_表结构_API草案.md》的**架构与数据模型部分**——该草案工程框架扎实,但建立在"合同文件入池→OCR→映射"的错误骨架上,缺**订单驱动主线、产品对码、三来源分流**。本文保留其状态机/幂等/证据/待办思想,按订单驱动重排实体与节点。
|
||||||
|
> 术语:状态中心 = 我们自研的编排与审计中台;执行器 = RPA 平台(影刀/来也等);对码 = 电商药品匹配到 M5 货品ID/编码。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、设计目标与非目标
|
||||||
|
|
||||||
|
### 1.1 设计目标
|
||||||
|
1. **订单驱动**:以电商订单(医药城/小药药)导出为入口,线下手工单为 OCR 分支,贯穿到 M5 录入待保存。
|
||||||
|
2. **分层解耦**:业务规则、状态机、审计、对码口径掌握在状态中心;RPA 平台仅做页面执行,可替换。
|
||||||
|
3. **对码可迭代**:对码表版本化、累积迭代,未命中一对多转人工,命中率可统计。
|
||||||
|
4. **不可逆保护**:一期末端"保存/签订/生成收货通知单"由人工执行,自动化做成配置开关。
|
||||||
|
5. **可追溯**:订单号→合同号→返利总单号→M5单号→收货通知单号全链路可查,每节点留证。
|
||||||
|
|
||||||
|
### 1.2 非目标(一期不做)
|
||||||
|
- 招财平台接入。
|
||||||
|
- M5 自动保存/签订(阶段三才放开,本期只留开关)。
|
||||||
|
- 加单/拆单的全自动重做(父订单号先记录,处理转人工,待确认)。
|
||||||
|
- 返利中心重复订单的全自动重建(规则未讲完,先转人工)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、系统架构
|
||||||
|
|
||||||
|
### 2.1 分层架构图
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 人工协同层 │
|
||||||
|
│ 企业微信应用消息/群机器人 + H5待办页(Vue/React) │
|
||||||
|
│ 待办推送 / 一对多对码选择 / 人工检查入口 / 修改留痕 │
|
||||||
|
└───────────────▲─────────────────────────────▲───────────────┘
|
||||||
|
│ 待办/回执 │ OAuth身份
|
||||||
|
┌───────────────┴─────────────────────────────┴───────────────┐
|
||||||
|
│ 状态中心(自研,核心资产) │
|
||||||
|
│ ┌──────────┬──────────┬──────────┬──────────┬────────────┐ │
|
||||||
|
│ │ 建单入池 │ 状态机 │ 对码引擎 │ 待办路由 │ 审计/证据 │ │
|
||||||
|
│ │ 幂等去重 │ 编排调度 │ mapping │ 角色分派 │ 事件流 │ │
|
||||||
|
│ └──────────┴──────────┴──────────┴──────────┴────────────┘ │
|
||||||
|
│ 统一节点契约 / 回调接口 / 执行平台路由 / 查询审计 API │
|
||||||
|
└──────▲───────────────▲──────────────────▲───────────────────┘
|
||||||
|
│ HTTP回调 │ OCR调用 │ 平台路由
|
||||||
|
┌──────┴──────┐ ┌──────┴──────┐ ┌───────┴─────────────────┐
|
||||||
|
│ RPA执行器 │ │ OCR服务 │ │ 对象存储(MinIO/NAS) │
|
||||||
|
│ 影刀/来也 │ │ PaddleOCR │ │ 合同PDF/截图/OCR JSON │
|
||||||
|
│ 登录/导出/ │ │ (本地优先) │ │ 证据目录 │
|
||||||
|
│ 建单/导入/ │ └─────────────┘ └─────────────────────────┘
|
||||||
|
│ 截图/上传 │
|
||||||
|
└──────▲──────┘
|
||||||
|
│ 操作
|
||||||
|
┌──────┴───────────────────────────────────────────────────────┐
|
||||||
|
│ 外部业务系统(RPA 通过 UI 操作,非 API 集成) │
|
||||||
|
│ 医药城 │ 小药药 │ 合同系统 contract.gzmpc.com │ 返利中心 │
|
||||||
|
│ │ │ │ flgzmpc.com │
|
||||||
|
│ M5(INCA) 采购订单管理 1055 │
|
||||||
|
└───────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 组件职责边界
|
||||||
|
|
||||||
|
| 组件 | 职责 | 明确不做 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **状态中心** | job 生命周期、状态机流转、对码、幂等、待办路由、证据审计、平台路由、统一查询 | 不直接操作外部系统页面 |
|
||||||
|
| **RPA 执行器** | 登录/导出Excel/建合同/PDF合并上传/返利导入/M5录入/截图/文件读写/HTTP回调 | 不做流程主状态判断、不做业务规则决策 |
|
||||||
|
| **OCR 服务** | 线下手工单图片→结构化字段 JSON + 置信度 | 仅线下单,线上走 Excel 不经 OCR |
|
||||||
|
| **企业微信/H5** | 待办通知、一对多选择、人工确认、修改留痕、身份识别 | 不直接写业务结果,须经状态中心标准动作 |
|
||||||
|
| **对象存储** | 合同PDF、截图、OCR JSON、日志等证据落盘 | — |
|
||||||
|
|
||||||
|
**关键设计原则**:RPA 平台"半节点执行过程中不跨平台接力";支持"按新单切换平台""按节点配置平台";切换前跑统一 POC 用例。
|
||||||
|
|
||||||
|
### 2.3 技术选型(推荐,可依公司栈调整)
|
||||||
|
|
||||||
|
| 层 | 采用 | 备选 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 状态中心 | Python 3 + FastAPI(开发快、与 OCR/对码脚本同栈、Pydantic 校验回调契约方便) | Java + Spring Boot |
|
||||||
|
| 数据库 | MySQL 8(状态中心自有库,只存流程状态+累积别名+证据) | PostgreSQL |
|
||||||
|
| 状态机 | 轻量自实现(NODE_ORDER 顺序驱动 + 7 状态枚举) | — |
|
||||||
|
| 对象存储 | MinIO / 公司现有 NAS | 本地文件系统 |
|
||||||
|
| RPA | 影刀 / 来也UiBot / 艺赛旗 择一 POC | — |
|
||||||
|
| OCR | PaddleOCR 本地部署(无信息安全顾虑) | 阿里/腾讯云OCR |
|
||||||
|
| 前端 | Vue/React + 企业微信 OAuth | — |
|
||||||
|
|
||||||
|
> 注:状态中心确定采用 **Python 3 + FastAPI + MySQL 8**,骨架代码在 `state_center/`(建库脚本、对码引擎、三回调、整单查询已跑通)。若后续团队要平移到 Java+Spring Boot,数据模型与接口契约保持不变即可。
|
||||||
|
|
||||||
|
### 2.4 部署拓扑
|
||||||
|
|
||||||
|
```
|
||||||
|
公司内网(与业务系统同网段,便于 RPA 访问)
|
||||||
|
├── 状态中心服务(容器/虚机) ── MySQL主 ── 对象存储MinIO
|
||||||
|
├── OCR服务(本地GPU/CPU) [仅阶段2.2线下单启用]
|
||||||
|
├── RPA运行机(独立虚机, 保持登录态, 定时/7×24)
|
||||||
|
└── 企业微信应用(内网可信域名 + H5静态站)
|
||||||
|
```
|
||||||
|
|
||||||
|
安全边界:
|
||||||
|
- RPA 回调接口用 `X-Agent-Token`;内部服务用 `Bearer service-token`;H5 用企业微信身份换 JWT。
|
||||||
|
- 合同/OCR 证据按角色可见(`visible_roles`),敏感文件脱敏标记 `is_masked`。
|
||||||
|
- **网络暴露提醒**:状态中心所有写接口须鉴权,回调接口须校验 token + 幂等键,禁止无认证暴露。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、数据模型设计
|
||||||
|
|
||||||
|
> 按订单驱动重排。核心实体:`job / job_node / order_item / product_match / user_task / mapping_config / executor_binding_config / job_artifact / job_event`。
|
||||||
|
> 相比早期草案新增:`order_item`(订单明细行)、`product_match`(对码结果,核心难点落库);`job` 主键由"合同文件"改为"订单批次"。
|
||||||
|
|
||||||
|
### 3.1 实体关系
|
||||||
|
|
||||||
|
```
|
||||||
|
job (一次流程实例 = 同供应商同日一批订单)
|
||||||
|
├─1:N─ order_item (订单明细行, 对码前的原始行)
|
||||||
|
├─1:N─ product_match (对码结果, 命中/未命中/一对多候选)
|
||||||
|
├─1:N─ job_node (每节点每次尝试)
|
||||||
|
├─1:N─ user_task (人工待办)
|
||||||
|
├─1:N─ job_artifact (证据)
|
||||||
|
└─1:N─ job_event (事件流)
|
||||||
|
|
||||||
|
mapping_config (对码表/供应商对照/承付结算映射, 版本化)
|
||||||
|
executor_binding_config (节点→平台路由)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 `job`(流程实例)
|
||||||
|
|
||||||
|
一次流程实例 = **同一供应商 + 同一订单日期 + 同一来源** 的一批订单(对应一条合同)。
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| id | bigint PK | |
|
||||||
|
| job_id | varchar(64) unique | 对外流程单号 |
|
||||||
|
| business_key | varchar(160) | 业务唯一键:`来源+供应商+订单日期`(见§5.2) |
|
||||||
|
| parent_job_id | varchar(64) null | 重跑/加单来源 |
|
||||||
|
| source_type | varchar(16) | `YYC`(医药城)/`XYY`(小药药)/`OFFLINE`(线下单) |
|
||||||
|
| order_date | date | 订单日期(=合同签约日期) |
|
||||||
|
| supplier_name | varchar(128) | 供应商/商家 |
|
||||||
|
| order_no_list_json | json | 该批订单号数组(贯穿追溯) |
|
||||||
|
| contract_template | varchar(16) null | `FORMAT`(格式)/`NON_FORMAT`(非格式承兑) |
|
||||||
|
| need_pdf_upload | tinyint | 医药城=1,小药药=0 |
|
||||||
|
| planner_name | varchar(64) null | 计划员 |
|
||||||
|
| current_node_code | varchar(64) | 当前节点 |
|
||||||
|
| status | varchar(24) | 见§4.1 |
|
||||||
|
| mapping_version | varchar(64) null | 绑定的对码表版本 |
|
||||||
|
| executor_platform | varchar(16) null | 默认执行平台 |
|
||||||
|
| contract_system_no | varchar(64) null | 合同系统编号 |
|
||||||
|
| rebate_order_no | varchar(64) null | 返利中心总单号 |
|
||||||
|
| m5_order_no | varchar(64) null | M5 采购订单号 |
|
||||||
|
| receipt_notice_no | varchar(64) null | 收货通知单号 |
|
||||||
|
| current_role | varchar(16) null | 当前责任角色 |
|
||||||
|
| current_assignee | varchar(64) null | 当前责任人 |
|
||||||
|
| auto_save_enabled | tinyint | M5自动保存开关(阶段一=0) |
|
||||||
|
| created_by | varchar(64) | |
|
||||||
|
| created_at / updated_at / finished_at | datetime | |
|
||||||
|
|
||||||
|
索引:`uniq_job_business_open(business_key, status)`、`idx_supplier_date(supplier_name, order_date)`、`idx_status`、`idx_created_at`。
|
||||||
|
|
||||||
|
### 3.3 `order_item`(订单明细行,对码前原始数据)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| id | bigint PK | |
|
||||||
|
| job_id | varchar(64) | |
|
||||||
|
| order_no | varchar(64) | 订单号 |
|
||||||
|
| parent_order_no | varchar(64) null | 父订单号(加单/拆单) |
|
||||||
|
| raw_product_name | varchar(255) | 平台品名 |
|
||||||
|
| platform_product_code | varchar(64) null | 平台产品编码 |
|
||||||
|
| spec | varchar(128) null | 规格(对码痛点:15×2 vs 30片) |
|
||||||
|
| manufacturer | varchar(255) null | 厂家(含进口英文) |
|
||||||
|
| approval_no | varchar(64) null | 国药准字/批准文号 |
|
||||||
|
| qty | decimal(14,2) | 数量 |
|
||||||
|
| unit_price | decimal(14,4) null | 单价 |
|
||||||
|
| source_row_no | int | 原始 Excel 行号(回溯) |
|
||||||
|
| created_at | datetime | |
|
||||||
|
|
||||||
|
索引:`idx_job(job_id)`、`idx_order_no`。
|
||||||
|
|
||||||
|
### 3.4 `product_match`(对码结果,核心难点落库)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| id | bigint PK | |
|
||||||
|
| job_id | varchar(64) | |
|
||||||
|
| order_item_id | bigint | 对应订单行 |
|
||||||
|
| match_key | varchar(512) | 四要素拼接键:通用名+批准文号+规格+厂家 |
|
||||||
|
| match_status | varchar(16) | `EXACT`(精确命中)/`FUZZY`(模糊)/`MULTI`(一对多)/`NONE`(未命中)/`MANUAL`(人工确认) |
|
||||||
|
| goods_id | varchar(64) null | 命中的 M5 货品ID |
|
||||||
|
| goods_code | varchar(64) null | 货品编码 |
|
||||||
|
| base_unit | varchar(32) null | 基本单位 |
|
||||||
|
| candidates_json | json null | 一对多候选列表(供H5选择) |
|
||||||
|
| source_add_needed | tinyint | 是否需 M5 新增货源 |
|
||||||
|
| confirmed_by | varchar(64) null | 人工确认人 |
|
||||||
|
| confirmed_at | datetime null | |
|
||||||
|
| mapping_version | varchar(64) | 使用的对码表版本 |
|
||||||
|
| created_at | datetime | |
|
||||||
|
|
||||||
|
索引:`idx_job(job_id)`、`idx_status(match_status)`。
|
||||||
|
|
||||||
|
### 3.5 `job_node`(节点执行)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| id | bigint PK | |
|
||||||
|
| job_id | varchar(64) | |
|
||||||
|
| node_code | varchar(64) | 见§4.2 |
|
||||||
|
| attempt_no | int | 第几次尝试(不覆盖历史) |
|
||||||
|
| status | varchar(24) | 节点状态 |
|
||||||
|
| executor_platform | varchar(16) null | 实际执行平台 |
|
||||||
|
| executor_run_id | varchar(64) null | 平台侧任务号 |
|
||||||
|
| started_at / ended_at | datetime null | |
|
||||||
|
| duration_ms | bigint null | |
|
||||||
|
| error_code | varchar(64) null | |
|
||||||
|
| error_summary | varchar(500) null | |
|
||||||
|
| role_at_failure | varchar(16) null | |
|
||||||
|
| output_json | json null | 节点输出(业务编号等) |
|
||||||
|
| callback_idempotency_key | varchar(128) null | 回调幂等键 |
|
||||||
|
| created_at | datetime | |
|
||||||
|
|
||||||
|
索引:`uniq_job_node_attempt(job_id, node_code, attempt_no)`、`idx_status`。
|
||||||
|
|
||||||
|
### 3.6 `user_task`(人工待办)
|
||||||
|
|
||||||
|
沿用早期草案结构,`reason_type` 扩展订单驱动场景。
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| id | bigint PK | |
|
||||||
|
| task_id | varchar(64) unique | |
|
||||||
|
| job_id | varchar(64) | |
|
||||||
|
| node_code | varchar(64) | |
|
||||||
|
| role_code | varchar(16) | `PLANNER`/`PURCHASER`/`QC`/`OPS` |
|
||||||
|
| assignee_user_id | varchar(64) null | |
|
||||||
|
| title | varchar(255) | |
|
||||||
|
| reason_type | varchar(32) | `MATCH_MULTI`/`MATCH_NONE`/`SOURCE_ADD`/`OCR_LOW_CONF`/`REBATE_DUP`/`QC_MISSING`/`CONTRACT_DISABLED`/... |
|
||||||
|
| reason_user_friendly | varchar(500) | 人话说明 |
|
||||||
|
| why_me_text | varchar(255) | 为什么派给你 |
|
||||||
|
| editable_fields_json | json | 白名单字段 |
|
||||||
|
| action_schema_json | json | 允许动作 |
|
||||||
|
| comparison_snapshot_json | json null | 原值/建议/当前对比 |
|
||||||
|
| candidates_json | json null | 一对多对码候选 |
|
||||||
|
| status | varchar(16) | `OPEN`/`SUBMITTED`/`CANCELLED`/`EXPIRED` |
|
||||||
|
| submit_token | varchar(64) | 防重复 |
|
||||||
|
| submitted_action | varchar(24) null | |
|
||||||
|
| submitted_payload_json | json null | |
|
||||||
|
| submitted_by / submitted_at | | |
|
||||||
|
| due_at | datetime null | 超时升级 |
|
||||||
|
| created_at | datetime | |
|
||||||
|
|
||||||
|
### 3.7 `mapping_config`(映射/对码,版本化)
|
||||||
|
|
||||||
|
统一映射表,`config_type` 区分用途。对码表是其中 `PRODUCT` 类型的累积数据。
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| id | bigint PK | |
|
||||||
|
| config_type | varchar(24) | `PRODUCT`(对码)/`SUPPLIER`(供应商对照)/`SETTLEMENT`/`PAYMENT_TERM`/`PLANNER_ROUTE` |
|
||||||
|
| config_key | varchar(512) | 对码时=四要素拼接键 |
|
||||||
|
| source_value | varchar(512) | 来源值 |
|
||||||
|
| target_value | varchar(512) | 目标值(对码=货品ID+编码+单位JSON) |
|
||||||
|
| extra_json | json null | 扩展 |
|
||||||
|
| version_no | varchar(64) | 版本号 |
|
||||||
|
| is_active | tinyint | |
|
||||||
|
| effective_from / effective_to | datetime | |
|
||||||
|
| updated_by / updated_at | | |
|
||||||
|
|
||||||
|
索引:`idx_type_key(config_type, config_key)`、`idx_active(is_active)`。
|
||||||
|
|
||||||
|
### 3.8 `executor_binding_config` / `job_artifact` / `job_event`
|
||||||
|
|
||||||
|
沿用早期草案结构(见《一期开发任务清单_表结构_API草案.md》§4.5/4.6/4.7)。要点:
|
||||||
|
- `executor_binding_config`:`node_code → preferred_platform/fallback_platform + required_capabilities_json + route_policy`。
|
||||||
|
- `job_artifact`:`artifact_type ∈ {SOURCE_EXCEL, SOURCE_IMAGE, OCR_JSON, CONTRACT_PDF, MERGED_PDF, SCREENSHOT, TEMPLATE, LOG}`;证据目录 `job_id/node_code/attempt_no/`。
|
||||||
|
- `job_event`:`event_type ∈ {JOB_CREATED, NODE_STARTED, NODE_SUCCESS, NODE_FAILED, TASK_CREATED, TASK_SUBMITTED, MANUAL_HANDOFF, JOB_FINISHED}`;`operator_type ∈ {SYSTEM, ROBOT, USER}`。
|
||||||
|
|
||||||
|
### 3.9 关键 DDL 片段(新增表)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE job (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
job_id VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
business_key VARCHAR(160) NOT NULL,
|
||||||
|
parent_job_id VARCHAR(64) NULL,
|
||||||
|
source_type VARCHAR(16) NOT NULL, -- YYC/XYY/OFFLINE
|
||||||
|
order_date DATE NOT NULL,
|
||||||
|
supplier_name VARCHAR(128) NOT NULL,
|
||||||
|
order_no_list_json JSON NULL,
|
||||||
|
contract_template VARCHAR(16) NULL, -- FORMAT/NON_FORMAT
|
||||||
|
need_pdf_upload TINYINT NOT NULL DEFAULT 0,
|
||||||
|
planner_name VARCHAR(64) NULL,
|
||||||
|
current_node_code VARCHAR(64) NOT NULL,
|
||||||
|
status VARCHAR(24) NOT NULL,
|
||||||
|
mapping_version VARCHAR(64) NULL,
|
||||||
|
executor_platform VARCHAR(16) NULL,
|
||||||
|
contract_system_no VARCHAR(64) NULL,
|
||||||
|
rebate_order_no VARCHAR(64) NULL,
|
||||||
|
m5_order_no VARCHAR(64) NULL,
|
||||||
|
receipt_notice_no VARCHAR(64) NULL,
|
||||||
|
current_role VARCHAR(16) NULL,
|
||||||
|
current_assignee VARCHAR(64) NULL,
|
||||||
|
auto_save_enabled TINYINT NOT NULL DEFAULT 0,
|
||||||
|
created_by VARCHAR(64) NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL,
|
||||||
|
finished_at DATETIME NULL,
|
||||||
|
KEY idx_supplier_date (supplier_name, order_date),
|
||||||
|
KEY idx_status (status),
|
||||||
|
KEY idx_created_at (created_at)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE order_item (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
job_id VARCHAR(64) NOT NULL,
|
||||||
|
order_no VARCHAR(64) NOT NULL,
|
||||||
|
parent_order_no VARCHAR(64) NULL,
|
||||||
|
raw_product_name VARCHAR(255) NOT NULL,
|
||||||
|
platform_product_code VARCHAR(64) NULL,
|
||||||
|
spec VARCHAR(128) NULL,
|
||||||
|
manufacturer VARCHAR(255) NULL,
|
||||||
|
approval_no VARCHAR(64) NULL,
|
||||||
|
qty DECIMAL(14,2) NOT NULL,
|
||||||
|
unit_price DECIMAL(14,4) NULL,
|
||||||
|
source_row_no INT NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
KEY idx_job (job_id),
|
||||||
|
KEY idx_order_no (order_no)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE product_match (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
job_id VARCHAR(64) NOT NULL,
|
||||||
|
order_item_id BIGINT NOT NULL,
|
||||||
|
match_key VARCHAR(512) NOT NULL,
|
||||||
|
match_status VARCHAR(16) NOT NULL, -- EXACT/FUZZY/MULTI/NONE/MANUAL
|
||||||
|
goods_id VARCHAR(64) NULL,
|
||||||
|
goods_code VARCHAR(64) NULL,
|
||||||
|
base_unit VARCHAR(32) NULL,
|
||||||
|
candidates_json JSON NULL,
|
||||||
|
source_add_needed TINYINT NOT NULL DEFAULT 0,
|
||||||
|
confirmed_by VARCHAR(64) NULL,
|
||||||
|
confirmed_at DATETIME NULL,
|
||||||
|
mapping_version VARCHAR(64) NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
KEY idx_job (job_id),
|
||||||
|
KEY idx_status (match_status)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、状态机与节点设计
|
||||||
|
|
||||||
|
### 4.1 job 主状态机(7 状态)
|
||||||
|
|
||||||
|
```
|
||||||
|
PENDING ──► RUNNING ──► SUCCESS
|
||||||
|
│ │ ▲
|
||||||
|
│ │ └──── (重试 attempt_no+1)
|
||||||
|
│ ├──► WAITING_USER ──► RUNNING (人工处理后继续)
|
||||||
|
│ ├──► FAILED_RETRYABLE ──► RUNNING
|
||||||
|
│ └──► FAILED_MANUAL (转人工手工做,如M5质管缺失)
|
||||||
|
└──────────────► CANCELLED
|
||||||
|
```
|
||||||
|
|
||||||
|
| 状态 | 含义 | 触发 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| PENDING | 已建单待调度 | 建单成功 |
|
||||||
|
| RUNNING | 节点执行中 | 调度启动节点 |
|
||||||
|
| WAITING_USER | 等人工(对码未命中/一对多/货源新增/OCR确认) | 节点转人工 |
|
||||||
|
| SUCCESS | 全链完成(一期到 M5 待保存+人工完成后) | 末节点成功 |
|
||||||
|
| FAILED_RETRYABLE | 可自动重试(网页卡顿/登录失败) | 系统类错误 |
|
||||||
|
| FAILED_MANUAL | 停止转人工手工做(M5质管资料缺失) | 业务决策终止 |
|
||||||
|
| CANCELLED | 取消 | 人工终止 |
|
||||||
|
|
||||||
|
**重试机制**:节点失败不覆盖历史,`attempt_no+1` 新建 `job_node` 行;系统类自动重试 1-2 次,超时升级。
|
||||||
|
|
||||||
|
### 4.2 节点编码字典(订单驱动重排)
|
||||||
|
|
||||||
|
| 顺序 | 节点编码 | 内容 | 转人工场景 | 能力标签 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 1 | `ORDER_FETCH` | 登录平台,选前一天,导出订单Excel(状态过滤【待确认】) | 登录失败→OPS | desktop_ui, file_download |
|
||||||
|
| 1b | `OCR_EXTRACT` | 仅线下单:图片→字段 | 低置信度→PLANNER | ocr, http_callback |
|
||||||
|
| 2 | `PRODUCT_MATCH` | 四要素对码→货品ID/编码 | 未命中/一对多→PLANNER | (状态中心内算) |
|
||||||
|
| 2b | `SOURCE_ADD` | 未采购过品种→通知M5新增货源 | 缺货源→业务 | (通知) |
|
||||||
|
| 3 | `CONTRACT_CREATE` | 合同建单,选模板,签约日=订单日;医药城合并PDF上传 | 合同禁掉失效→采购邮件 | desktop_ui, file_upload, pdf_merge |
|
||||||
|
| 4 | `CONTRACT_APPROVE_WAIT` | 送审后等半小时查审核 | 未审完→等待 | http_callback, timer |
|
||||||
|
| 5 | `REBATE_HEADER` | 按合同号查总单,填必填项保存 | — | desktop_ui |
|
||||||
|
| 6 | `REBATE_IMPORT` | 细单模板导入(备注填订单号,特定补偿=0) | 重复编码→采购【待确认】 | excel_template, file_upload |
|
||||||
|
| 7 | `REBATE_SUBMIT` | 提交送审(决定M5能否带出协议)⭐ | 未送审→阻断 | desktop_ui |
|
||||||
|
| 8 | `M5_CREATE_ORDER` | M5-1055新增订单+细单,备注填订单号,选正常保管账,**不点保存** | 质管资料缺失→FAILED_MANUAL | desktop_ui |
|
||||||
|
| 9 | `M5_MANUAL_CONFIRM` | 人工检查→保存→签订(阶段一人工,开关控制) | — | (人工/可配自动) |
|
||||||
|
| 10 | `M5_RECEIPT_NOTICE` | 生成收货通知单 | — | (人工/可配自动) |
|
||||||
|
| 11 | `ARCHIVE_JOB` | 归档业务编号链 | — | (系统) |
|
||||||
|
|
||||||
|
**关键卡点**:`REBATE_SUBMIT` 未送审则 M5 带不出协议,是硬阻断点。
|
||||||
|
|
||||||
|
### 4.3 节点转移规则(示例)
|
||||||
|
|
||||||
|
- `PRODUCT_MATCH`:全 EXACT/已确认 → 进 `CONTRACT_CREATE`;有 MULTI/NONE → `WAITING_USER` 建 `user_task`。
|
||||||
|
- `CONTRACT_APPROVE_WAIT`:定时器半小时后查;未审完保持等待并重查,超阈值升级 OPS。
|
||||||
|
- `M5_CREATE_ORDER` 报质管错 → 直接 `FAILED_MANUAL`(不重试、不等质管),通知业务手工做。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、幂等、追溯与证据设计
|
||||||
|
|
||||||
|
### 5.1 幂等三层
|
||||||
|
1. **入池幂等**:源文件哈希 `source_file_hash` + `business_key`,同批订单重复导入不生成多条未完成 job。
|
||||||
|
2. **回调幂等**:`job_id + node_code + attempt_no` 唯一键;重复回调按幂等键去重。
|
||||||
|
3. **人工提交幂等**:`task_id + submit_token`,防止 H5 重复点击重复推进。
|
||||||
|
4. **执行前检索**:RPA 在返利/M5 建单前先检索已有单号,避免重复建单。
|
||||||
|
|
||||||
|
### 5.2 business_key 生成
|
||||||
|
`business_key = source_type + "|" + supplier_name + "|" + order_date`
|
||||||
|
(同供应商同日同来源 = 一条合同 = 一个 job)。加单场景父订单号记录在 order_item,重做逻辑待确认。
|
||||||
|
|
||||||
|
### 5.3 订单号贯穿追溯
|
||||||
|
|
||||||
|
| 位置 | 填什么 | 给谁看 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 电商平台 | 原始订单号 ZQD... | 源头 |
|
||||||
|
| 返利中心细单备注 | 订单号 | 物流部软件 |
|
||||||
|
| M5 订单备注 | 订单号 | 采购结算(打结算勾对账) |
|
||||||
|
|
||||||
|
归档链:订单号→合同编号→返利总单号→M5单号→收货通知单号,写入 `job` 各字段 + `job_event`。
|
||||||
|
|
||||||
|
### 5.4 证据存储
|
||||||
|
目录 `对象存储/{job_id}/{node_code}/{attempt_no}/`,含截图、日志、OCR JSON、合同PDF/合并PDF。`job_artifact` 记录元数据、哈希、可见角色、脱敏标记。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、接口契约设计
|
||||||
|
|
||||||
|
> 所有写操作经状态中心,RPA/H5/企业微信不得直连数据库。鉴权:内部 `Bearer`,RPA `X-Agent-Token`,H5 企业微信身份换 JWT,写接口带 `X-Idempotency-Key`。
|
||||||
|
|
||||||
|
### 6.1 统一节点回调契约
|
||||||
|
|
||||||
|
节点开始 `POST /api/jobs/{jobId}/nodes/{nodeCode}/start`
|
||||||
|
```json
|
||||||
|
{ "attemptNo": 1, "executorPlatform": "YINGDAO", "operator": "agent-01", "startedAt": "2026-08-07T22:00:00+08:00" }
|
||||||
|
```
|
||||||
|
|
||||||
|
节点成功 `POST /api/jobs/{jobId}/nodes/{nodeCode}/complete`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"attemptNo": 1,
|
||||||
|
"executorRunId": "YD-RUN-001",
|
||||||
|
"endedAt": "2026-08-07T22:03:00+08:00",
|
||||||
|
"businessRefs": { "contractSystemNo": "HT...", "rebateOrderNo": null, "m5OrderNo": null, "receiptNoticeNo": null },
|
||||||
|
"summary": { "importedItemCount": 18, "failedItemCount": 0 },
|
||||||
|
"artifacts": [ { "artifactType": "SCREENSHOT", "storagePath": "/data/jobs/.../success.png" } ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
约束:节点要求业务编号却未回传,不得记 SUCCESS。
|
||||||
|
|
||||||
|
节点失败 `POST /api/jobs/{jobId}/nodes/{nodeCode}/fail`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"attemptNo": 1, "errorCode": "REBATE_DUP_CODE", "errorSummary": "存在重复的编码",
|
||||||
|
"retryable": false, "routeRole": "PURCHASER",
|
||||||
|
"artifacts": [ { "artifactType": "SCREENSHOT", "storagePath": "/data/jobs/.../error.png" } ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 错误码分层
|
||||||
|
|
||||||
|
| 层 | 前缀 | 处理 | 例 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 系统类 | `SYS_` | 自动重试1-2次 | SYS_LOGIN_FAIL, SYS_PAGE_TIMEOUT |
|
||||||
|
| 业务类 | `BIZ_` | 转对应角色 | BIZ_MATCH_NONE, REBATE_DUP_CODE, M5_QC_MISSING |
|
||||||
|
| 配置类 | `CFG_` | 转OPS/管理员 | CFG_TEMPLATE_MISSING |
|
||||||
|
| 页面变化类 | `UI_` | 转OPS | UI_ELEMENT_NOT_FOUND |
|
||||||
|
| 能力不满足 | `CAP_` | 换平台/转OPS | CAP_PDF_MERGE_UNSUPPORTED |
|
||||||
|
|
||||||
|
特例:`M5_QC_MISSING` → 不重试,直接 `FAILED_MANUAL`。
|
||||||
|
|
||||||
|
### 6.3 待办与查询接口
|
||||||
|
- `GET /api/tasks/my?roleCode=PLANNER&status=open`
|
||||||
|
- `POST /api/tasks/{taskId}/submit`:动作 `CONFIRM_AND_CONTINUE`/`SELECT_CANDIDATE`(对码一对多)/`RETRY_CURRENT_NODE`/`TRANSFER_MANUAL`/`TERMINATE_JOB`;返回即时回执 `receiptMessage`/`nextExpectedAction`。
|
||||||
|
- `POST /api/jobs/{jobId}/retry`、`POST /api/jobs/{jobId}/handoff`
|
||||||
|
- `GET /api/jobs/{jobId}`:job基础+节点轨迹+待办+对码摘要+业务编号+证据列表。
|
||||||
|
- `POST /api/wecom/callback`:只接动作/身份,转标准动作后调内部逻辑。
|
||||||
|
|
||||||
|
### 6.4 对码专用接口(新增)
|
||||||
|
- `POST /api/jobs/{jobId}/match/run`:触发对码引擎,返回命中率与未命中清单。
|
||||||
|
- `GET /api/jobs/{jobId}/match/unmatched`:未命中/一对多候选(供H5)。
|
||||||
|
- `POST /api/match/{matchId}/confirm`:人工确认选择候选或新增,回写 `mapping_config`(累积迭代)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、对码引擎设计(核心难点)
|
||||||
|
|
||||||
|
### 7.1 匹配流程
|
||||||
|
```
|
||||||
|
order_item ──► 规范化(去空格/全半角/规格标准化) ──► 拼接 match_key(通用名+批准文号+规格+厂家)
|
||||||
|
│
|
||||||
|
├─ 精确命中 mapping_config(PRODUCT) ──► EXACT, 填 goods_id/code
|
||||||
|
├─ 模糊命中(规格/厂家容错规则) ─────► FUZZY(标记待复核)
|
||||||
|
├─ 同商品名多规格 ─────────────────► MULTI, 生成 candidates
|
||||||
|
└─ 无命中 ─────────────────────────► NONE, 建 user_task 转人工
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 规格标准化规则(针对痛点)
|
||||||
|
- `15×2 ↔ 30片`(乘积展开)、`干一瓶 ↔ ×1瓶`、进口厂家英文名容错(大小写/标点/B.V. S.r.l.)。
|
||||||
|
- 规则表化,可迭代补充;未覆盖的进 MULTI/NONE 人工。
|
||||||
|
|
||||||
|
### 7.3 迭代闭环
|
||||||
|
人工确认结果回写 `mapping_config`(新版本 `version_no`),下次自动命中。命中率统计写入 `job_event`,支撑阶段0/阶段3的准确率报告。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、关键时序图
|
||||||
|
|
||||||
|
### 8.1 医药城端到端(正向)
|
||||||
|
```
|
||||||
|
定时器 → 状态中心: 触发建单(前一天医药城订单)
|
||||||
|
状态中心 → RPA: 启动 ORDER_FETCH
|
||||||
|
RPA → 医药城: 登录/导出Excel
|
||||||
|
RPA → 状态中心: complete(ORDER_FETCH, artifacts=Excel)
|
||||||
|
状态中心 → 对码引擎: PRODUCT_MATCH(解析order_item, 匹配)
|
||||||
|
对码引擎 → 状态中心: 全命中
|
||||||
|
状态中心 → RPA: 启动 CONTRACT_CREATE(need_pdf_upload=1)
|
||||||
|
RPA → 合同系统: 建单/选格式模板/改签约日/合并PDF上传/送审
|
||||||
|
RPA → 状态中心: complete(合同编号)
|
||||||
|
状态中心: 定时器等30分钟 → RPA: CONTRACT_APPROVE_WAIT 查审核
|
||||||
|
状态中心 → RPA: REBATE_HEADER(按合同号查总单填必填)
|
||||||
|
状态中心 → RPA: REBATE_IMPORT(细单模板,备注=订单号,特定补偿0)
|
||||||
|
状态中心 → RPA: REBATE_SUBMIT(送审) ⭐
|
||||||
|
状态中心 → RPA: M5_CREATE_ORDER(新增订单+细单,备注订单号,正常保管账,不保存)
|
||||||
|
RPA → 状态中心: complete(M5待保存)
|
||||||
|
状态中心 → 企业微信: 推送人工检查待办
|
||||||
|
计划员 → H5: 检查→保存→签订→生成收货通知单(阶段一人工)
|
||||||
|
状态中心: ARCHIVE_JOB 归档编号链
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 对码未命中(一对多)
|
||||||
|
```
|
||||||
|
对码引擎 → 状态中心: MATCH_MULTI(candidates)
|
||||||
|
状态中心 → user_task: 建待办(reason=MATCH_MULTI, candidates_json)
|
||||||
|
状态中心 → 企业微信: 推送"对码待确认"
|
||||||
|
计划员 → H5: 选择候选/新增
|
||||||
|
H5 → 状态中心: submit(SELECT_CANDIDATE)
|
||||||
|
状态中心 → mapping_config: 回写(累积迭代)
|
||||||
|
状态中心: job WAITING_USER → RUNNING, 继续 CONTRACT_CREATE
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 M5 质管资料缺失(转人工,不等质管)
|
||||||
|
```
|
||||||
|
RPA → M5: 新增订单保存时报错(供应商年报过期)
|
||||||
|
RPA → 状态中心: fail(M5_QC_MISSING, retryable=false)
|
||||||
|
状态中心: job → FAILED_MANUAL (不重试/不等质管)
|
||||||
|
状态中心 → 企业微信: 通知业务"此单转人工手工做"
|
||||||
|
业务: 次日质管维护后自行在M5手工完成
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、模块设计(状态中心内部)
|
||||||
|
|
||||||
|
| 模块 | 职责 | 关键接口/服务 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| intake_service | 建单入池、哈希/业务键去重、Excel解析入 order_item | POST /api/jobs |
|
||||||
|
| workflow_service | 状态机流转、节点调度、转移规则 | node start/complete/fail |
|
||||||
|
| match_service | 对码引擎、规格标准化、候选生成、迭代回写 | match/run, match/confirm |
|
||||||
|
| retry_service | 系统类自动重试、超时扫描升级 | 定时任务 |
|
||||||
|
| task_service | 待办生成、角色路由、提交幂等、回执 | tasks/my, tasks/submit |
|
||||||
|
| executor_router | 节点→平台路由(FIXED/PREFERRED/FALLBACK) | 内部 |
|
||||||
|
| ocr_service | 线下单OCR适配、置信度阈值 | OCR_EXTRACT |
|
||||||
|
| artifact_service | 证据落盘、可见角色、脱敏 | 内部 |
|
||||||
|
| wecom_service | 企业微信消息、卡片更新、身份 | wecom/callback |
|
||||||
|
| audit/query | 事件流、整单查询、审计导出 | GET /api/jobs/{id} |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、与早期草案的设计差异(供评审)
|
||||||
|
|
||||||
|
| # | 早期草案 | 本设计 | 原因 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | job=一份合同文件的流程 | job=同供应商同日一批订单 | 订单驱动,合同是产物非入口 |
|
||||||
|
| 2 | 无 order_item/product_match 表 | 新增两表 | 对码是核心难点须落库 |
|
||||||
|
| 3 | 节点以合同/OCR为主线 | 节点订单驱动重排(ORDER_FETCH起) | 骨架方向纠正 |
|
||||||
|
| 4 | 送审在合同系统 | 关键送审 REBATE_SUBMIT | 卡点位置纠正 |
|
||||||
|
| 5 | 质管缺料转质管重试 | M5_QC_MISSING→FAILED_MANUAL | 业务明确否决重试 |
|
||||||
|
| 6 | 无自动化开关 | job.auto_save_enabled 分阶段 | 分阶段上线诉求 |
|
||||||
|
| 7 | 技术栈未定 | 确定 Python+FastAPI+MySQL,骨架已跑通 | 与对码/OCR同栈,开发快 |
|
||||||
|
| 8 | OCR为全流程核心 | OCR仅线下单分支 | 范围纠正 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、待确认的设计输入(阻塞项)
|
||||||
|
|
||||||
|
1. **OCR信息安全**:线上是否获批 / 本地部署预算 → 决定 `OCR_EXTRACT` 节点部署方式。
|
||||||
|
2. **返利中心重复订单**完整规则 → 决定 `REBATE_DUP_CODE` 是否可自动重建总单。
|
||||||
|
3. **医药城订单状态过滤**:只取"待收货"还是含其他 → `ORDER_FETCH` 过滤条件。
|
||||||
|
4. **加单场景**:父订单号关联与重做逻辑 → `parent_job_id/parent_order_no` 处理策略。
|
||||||
|
5. **测试账号/对码表(Oracle连接串+表名列名)/供应商对照表/邮箱/计划员表**到位时间 → 影响联调。
|
||||||
|
|
||||||
|
> 技术栈已确认:状态中心 = Python 3 + FastAPI + MySQL 8(骨架见 `state_center/`),不再作为阻塞项。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录:联调顺序建议
|
||||||
|
1. 状态中心单独跑通建单/状态流转/待办。
|
||||||
|
2. 假OCR+假RPA回调打通全流程。
|
||||||
|
3. 接真实对码引擎(复用阶段0成果)。
|
||||||
|
4. 接真实企业微信+H5身份。
|
||||||
|
5. 接真实RPA(先医药城单来源)。
|
||||||
|
6. 返利中心/M5端到端联调。
|
||||||
|
|
@ -0,0 +1,488 @@
|
||||||
|
# 采购主链 RPA 一期需求文档(三方会审版)
|
||||||
|
|
||||||
|
> 版本说明:本文基于三段会议录像(共约 87 分钟)的**音频逐句转写** + **系统界面截图**双重证据重写。
|
||||||
|
>
|
||||||
|
> 与《会议整理版》的关系:早期整理版把流程理解为"合同文件入池 → OCR → 映射",方向性错误。本文以**电商订单驱动**为主线完全重写业务骨架,保留早期版本扎实的工程框架(状态机、幂等、证据、异常分流)。
|
||||||
|
>
|
||||||
|
> 证据标注约定:
|
||||||
|
> - `【音】mm:ss`:来自音频转写,可在 `video_frames/transcript_v1~v3.txt` 逐字核对。
|
||||||
|
> - `【画】`:来自系统界面截图,字段名以画面为准。
|
||||||
|
> - `【待确认】`:会议中被时间打断或未展开,需下次会议拍板。
|
||||||
|
>
|
||||||
|
> 视频顺序:因会议软件时长限制切成三段,真实顺序为 **视频3(医药城)→ 视频2(小药药+合同系统+返利中心)→ 视频1(M5演示+18问题复盘)**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 一句话业务本质
|
||||||
|
|
||||||
|
福建广药洁达医药是一家**以销定采**的医药商业公司:先在京东/阿里等平台卖货,再反向采购。采购来源分**线上电商平台**和**线下供应商**。RPA 要做的,是把采购人员**每天**手工做的"从平台导订单 → 产品对码 → 建合同 → 返利中心建协议 → M5 下采购单 → 生成收货通知单"这条重复链路自动化。
|
||||||
|
|
||||||
|
> 【音】v3 04:36 "我是先是以销定采的,全部都是这种"
|
||||||
|
> 【音】v3 04:16 "我的客户是在京东阿里这样的全国性平台,包括大的连锁比如大参林"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 三种订单来源(一期核心分流)
|
||||||
|
|
||||||
|
| 来源 | 性质 | 数据获取方式 | 是否需OCR | 合同产生方式 | 一期是否纳入 |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| **医药城** | 线上平台 | 平台"我的订单→导出订单明细"下载 Excel | 否 | 平台**秒出**购销合同,需下载并**合并上传**到合同系统 | 是 |
|
||||||
|
| **小药药** | 线上平台 | 平台导出 Excel | 否 | 已签**年度协议**,合同系统用**格式合同**,**不需上传合同证文** | 是 |
|
||||||
|
| **线下供应商** | 手工单 | 供应商发来的**开单截图/PDF**(每家样式不同) | **是** | 后补合同(先出货后补章),需人工做电子版合同 | 是 |
|
||||||
|
| 招财平台 | 线上平台 | — | — | 目前纯人工,量少 | **否**(一期不做) |
|
||||||
|
|
||||||
|
> 【音】v3 02:19 "一般选前一天的订单来做,这边有个订单导出的地方"
|
||||||
|
> 【音】v2 08:37 "只要你在他平台上下单,他马上秒出合同…他们平台是秒出的"
|
||||||
|
> 【音】v2 25:04 "小药药它省了一步,不需要传合同,用的是格式的,因为他签了年度协议"
|
||||||
|
> 【音】v2 05:31 "线下都是手工单…如果是有账期的客户,我们叫对方开单,他就只会发一个开单的截图给我们"
|
||||||
|
> 【音】v1 07:06 "招财平台…暂时不做"
|
||||||
|
|
||||||
|
**关键差异点(决定 RPA 分支逻辑)**:
|
||||||
|
- 医药城 vs 小药药:**合同证文是否需要上传**(医药城要合并PDF上传,小药药不用)。
|
||||||
|
- 线上 vs 线下:**是否需要OCR**(线上导出结构化Excel,线下是图片需OCR)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 全流程主链(以真实操作顺序重排)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[每天定时/手动触发] --> B{订单来源}
|
||||||
|
B -->|医药城| C1[平台导出订单明细Excel]
|
||||||
|
B -->|小药药| C2[平台导出订单明细Excel]
|
||||||
|
B -->|线下手工单| C3[OCR识别开单截图/PDF]
|
||||||
|
C1 --> D[产品对码: 通用名+批准文号+规格+厂家 → 货品ID/编码]
|
||||||
|
C2 --> D
|
||||||
|
C3 --> D
|
||||||
|
D --> E{是否全部匹配}
|
||||||
|
E -->|有未匹配| E1[通知业务人工维护对码表/新增货源]
|
||||||
|
E -->|全部匹配| F[合同系统建单]
|
||||||
|
F --> F1[医药城: 合并同供应商同日PDF并上传]
|
||||||
|
F --> F2[小药药: 格式合同,无需上传]
|
||||||
|
F1 --> G[合同送审, 半小时内审批]
|
||||||
|
F2 --> G
|
||||||
|
G --> H[取合同编号]
|
||||||
|
H --> I[返利中心: 按合同号建采购协议总单]
|
||||||
|
I --> J[返利中心: 细单模板导入货品明细]
|
||||||
|
J --> K[返利中心: 提交送审]
|
||||||
|
K --> L{M5能否带出协议}
|
||||||
|
L -->|带不出| L1[返利中心未送审, 阻断]
|
||||||
|
L -->|能带出| M[M5-1055: 新增采购订单+细单]
|
||||||
|
M --> N{M5保存是否报错}
|
||||||
|
N -->|质管资料缺失| N1[停止,转人工手工做, 不等质管]
|
||||||
|
N -->|正常| O[人工检查数量→点保存→签订]
|
||||||
|
O --> P[生成收货通知单]
|
||||||
|
P --> Q[归档: 订单号/合同号/协议号/M5单号/通知单号]
|
||||||
|
```
|
||||||
|
|
||||||
|
**本流程的关键设计要点**:
|
||||||
|
1. 入口是"电商订单导出"而非"合同文件入池"。
|
||||||
|
2. OCR 从贯穿全程的核心节点,降级为"仅线下手工单"的分支。
|
||||||
|
3. 新增"产品对码"作为真正的核心难点节点。
|
||||||
|
4. 送审的关键卡点在**返利中心**(决定 M5 能否带出协议),而非合同系统。
|
||||||
|
5. 末端"保存/签订"一期由**人工**执行(分阶段上线,见 §9)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 节点级需求(字段级 + RPA动作级)
|
||||||
|
|
||||||
|
### N01 订单获取与来源分流
|
||||||
|
|
||||||
|
**触发**:每天定时(跑前一天订单)或手动触发。
|
||||||
|
> 【音】v2 01:12 "每天…每天会到前一天";v3 02:16 "选前一天的或单天的合同订单来做"
|
||||||
|
|
||||||
|
**RPA 动作(线上平台)**:
|
||||||
|
1. 登录医药城 / 小药药平台。
|
||||||
|
2. 进入"我的订单",选择日期(默认前一天)。
|
||||||
|
3. 点击"导出订单明细",下载 Excel。
|
||||||
|
|
||||||
|
**医药城订单明细表字段【画】**(订单明细.xls):
|
||||||
|
|
||||||
|
| 列 | 字段 | 说明 | RPA用途 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| A | 下单时间 | 如 `2026-08-04 18:50:48.0` | → 合同签约日期 |
|
||||||
|
| B | 创建人 | — | — |
|
||||||
|
| C | 订单号 | 如 `ZQD202608041850483308` | **贯穿全流程的关键号**(见§6) |
|
||||||
|
| D | 父订单号 | 加单/拆单关联 | 【待确认】加单合并逻辑 |
|
||||||
|
| E | 支付方式 | 如"账期支付" | — |
|
||||||
|
| F | 商家 | 如"重庆亿昊药业有限公司" | → 供应商,合同合并维度 |
|
||||||
|
| G | 订单类型 | 如"普通订单" | — |
|
||||||
|
| H | 订单状态 | 待付款/已取消/待收货 | **过滤条件**【待确认】只取哪些状态 |
|
||||||
|
| I | 品名 | 如"哈药牌钙铁锌口服液" | 对码用 |
|
||||||
|
| — | 产品编码 | 如 `2919482534` | 平台侧编码,对码用 |
|
||||||
|
| — | 规格/厂家/国药准字 | | 对码四要素 |
|
||||||
|
|
||||||
|
**线下手工单**:来源是供应商发来的开单截图/PDF,**每家样式不同**,走 N02 OCR。
|
||||||
|
> 【音】v2 06:57 "有可能是合同,也有可能是手工单,每个客户样式都长不一样"
|
||||||
|
|
||||||
|
**输出**:`job_id`、订单来源类型、原始订单Excel/图片存证、订单号列表、订单日期、供应商列表。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### N02 OCR 识别(仅线下手工单)
|
||||||
|
|
||||||
|
> ⚠️ 重点:OCR **不是全流程核心**,只服务线下手工单(约100多份/月)。线上两平台直接导出结构化 Excel,不走 OCR。
|
||||||
|
> 【音】v1 08:54 "线下一百多份…手工单一个月一共一百多份"
|
||||||
|
|
||||||
|
**OCR 服务选型(未决,最大卡点)**:
|
||||||
|
- 线上 OCR 服务:约 1000元/月(10亿token),按线下用量估算实际约十几元/月。
|
||||||
|
- **信息安全风险**:合同信息走外部线上OCR,公司可能不批,需拉**法务部 + 数据中心**评估;否则改**本地部署**(费用更高)。
|
||||||
|
> 【音】v1 09:20 "因为涉及信息安全…我担心他们不同意…需要跟法务部还有数据中心去沟通"
|
||||||
|
> 【音】v1 30:15 "现在最麻烦的就是这个OCR的信息安全问题"
|
||||||
|
|
||||||
|
**抽取字段**:品名、规格、厂家、批准文号、数量、单价、供应商。
|
||||||
|
**规则**:识别后同样进入 N03 对码;低置信度/缺字段转人工。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### N03 产品对码(真正的核心难点)
|
||||||
|
|
||||||
|
**目的**:把电商平台/开单截图上的药品,匹配到 M5 系统的货品ID/货品编码。
|
||||||
|
|
||||||
|
**匹配四要素**【音】v3 10:26:**通用名 + 批准文号 + 规格 + 生产厂家**(确定药品唯一性)。
|
||||||
|
|
||||||
|
**M5 货品对码基础表字段【画】**(货品运营管理.xls):
|
||||||
|
|
||||||
|
| 列 | 字段 |
|
||||||
|
| --- | --- |
|
||||||
|
| A | 通用名 |
|
||||||
|
| B | 批准文号 |
|
||||||
|
| C | 规格 |
|
||||||
|
| D | 基本单位 |
|
||||||
|
| E | 生产厂家 |
|
||||||
|
| F | 货品ID |
|
||||||
|
| G | 货品编码 |
|
||||||
|
|
||||||
|
**匹配方式**【音】v3 12:03:"把这几列合并在一起来 V(VLOOKUP)"——即用"通用名+批准文号+规格+厂家"拼接键做匹配,得到货品ID/编码。
|
||||||
|
|
||||||
|
**核心痛点:规格描述不一致导致匹配失败**:
|
||||||
|
> 【音】v3 14:19 "阿卡波糖,我们是15乘2,对方可能直接写30片,那我们就匹不到"
|
||||||
|
> 【音】v3 14:04 "他写干一瓶,我们基础是乘一瓶,就会没匹配到"
|
||||||
|
> 【音】v3 14:28 "摩摩一…也匹配不到,都是规格上面的问题"
|
||||||
|
|
||||||
|
**业务已有资产**:业务方**已在维护"货品对码表"**,是累积迭代的(这次匹配剩下的下次继续拼)。
|
||||||
|
> 【音】v3 14:44 "文华我们一直都在准备做这个叫货品对码表的信息"
|
||||||
|
> 【音】v2 02:16 "我自己做了一个商品编码表…这一次跟下一次叠加,一直去拼"
|
||||||
|
|
||||||
|
**RPA 处理规则**:
|
||||||
|
1. 能精确命中对码表 → 自动填货品ID/编码。
|
||||||
|
2. 匹配不上 → **一对多显示**,把不同规格描述与M5同一商品名对应出来,转人工选择/新增。
|
||||||
|
> 【音】v3 14:55 "匹不到的需要一对多的显示,把不同规格的样式跟我们选中的M5里面同一个商品名对应出来"
|
||||||
|
> 【音】v3 15:47 "如果没匹配上,就通知你们去做人工的匹配…我们就知道要去新增这个东西"
|
||||||
|
|
||||||
|
**厂家名匹配难点**:对码表厂家名有大量英文(进口药,如 `Abbott Biologicals B.V.`、`AESICA PHARMACEUTICALS S.r.l.`),厂家名匹配需容错。【画】
|
||||||
|
|
||||||
|
**输出**:对码结果(货品ID、货品编码、基本单位)、未匹配清单、一对多候选。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### N03b 货源信息新增
|
||||||
|
|
||||||
|
**规则**:如果从未从某供应商采购过某品种,需在 **M5** 新增该"货源"。
|
||||||
|
> 【音】v3 16:38 "没有从这家供应商购进过的话,我从这家供应商购进这个品种,就要增加这个货源"
|
||||||
|
> 【音】v3 16:53 "货源基础数据在 M5"
|
||||||
|
|
||||||
|
**处理**:匹配不到货源 → 通知业务在 M5 新增 → 完成后继续。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### N04 合同系统建单
|
||||||
|
|
||||||
|
**系统**【画】:独立域名 `contract.gzmpc.com/km/agreement/`,走 RPA 访问。
|
||||||
|
|
||||||
|
**两套合同模板**【音】v2 11:51:
|
||||||
|
| 模板 | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| 格式采购合同(药品通用) | 默认,大部分用这个 |
|
||||||
|
| 非格式采购合同(单次) | 承兑(要承兑)的用这个 |
|
||||||
|
> 【音】v2 12:22 "要承兑的用非格式的,其他都是用格式的"
|
||||||
|
|
||||||
|
**合同维度(硬规则)**:**同一供应商 + 同一订单日期 = 一条合同**。
|
||||||
|
> 【音】v2 23:32 "同一个商家、同一天订单,开一条合同"
|
||||||
|
> 【音】v2 23:57 "不会两个商家做成同一个合同"
|
||||||
|
|
||||||
|
**签约日期硬约束**:合同签约日期 = 订单日期(当天做当天合同)。
|
||||||
|
> 【音】v1 04:45 "唯一同意我的就是把签约日期改成前一天…他规定就是你要同一天做合同"
|
||||||
|
> 【音】v2 18:31 "订单日期我下单哪天,合同就选哪天,6号我就选6号"
|
||||||
|
|
||||||
|
**RPA 动作**:
|
||||||
|
1. 登录合同系统。
|
||||||
|
2. 选择模板(格式/非格式,依供应商对照表判断)。
|
||||||
|
3. **可复制历史合同**再改(业务现在就是复制起草)。
|
||||||
|
> 【音】v2 16:16 "我们销售都是用复制的,你只要改后面的就好了"
|
||||||
|
4. 填基础信息 + 修改签约日期/合同开始日期为订单日期。
|
||||||
|
5. 填货品明细(先下模板,填操作码/货品编码、名称、国药准字、规格、单位、数量)。
|
||||||
|
6. 填付款信息(必填):付款方式、天数等。
|
||||||
|
> 【音】v2 20:38 "付款信息这些是必填的"
|
||||||
|
7. **医药城分支**:下载平台购销合同,同供应商同日的**合并成一个PDF**上传到合同证文(传第二份会覆盖第一份,所以必须先合并)。
|
||||||
|
> 【音】v2 24:44 "昆山的所有合并成一个PDF,重庆的合并成一个PDF"
|
||||||
|
> 【音】v2 22:45 "如果你传第二份,他会把前面的覆盖掉"
|
||||||
|
8. **小药药分支**:格式合同,跳过合同证文上传。
|
||||||
|
9. 汇总金额到总单,提交。
|
||||||
|
|
||||||
|
**合同版本/类别/结算/合同性质等字段**:业务方提供**供应商对照表**,RPA 按表判断填写。
|
||||||
|
> 【音】v1 12:34 "我们可以给你这个供应商的合同类型、结算类型、合同性质都列出来,给机器人一个总表"
|
||||||
|
|
||||||
|
**审批**:录入后送审,**半小时内**审批完成(不指定审批人,着急就催领导)。RPA 策略:录入后等半小时再启动下游。
|
||||||
|
> 【音】v2 26:47 "半个小时内就批完了…着急我们就直接跑到领导旁边催他批"
|
||||||
|
> 【音】v1 16:03 "录入完之后RPA就设定半个小时后再启动返利中心的功能"
|
||||||
|
|
||||||
|
**合同编号**:未审批完也会生成合同编号,但**必须审批完才导入返利中心**。
|
||||||
|
> 【音】v2 26:16 "没有审批完他也会生成合同编号"
|
||||||
|
> 【音】v2 26:47 "我们都等批完了然后才开始做的"
|
||||||
|
|
||||||
|
**输出**:合同编号、合同状态、模板类型。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### N05 返利中心采购协议总单
|
||||||
|
|
||||||
|
**系统**【画】:`flgzmpc.com`,主体"福建广药洁达医药",有"待审批事项"和异步报表导出下载。
|
||||||
|
|
||||||
|
**RPA 动作**:
|
||||||
|
1. 登录返利中心。
|
||||||
|
2. 进入"采购协议管理"。
|
||||||
|
3. 按**合同编号**查询对应总单。
|
||||||
|
4. 查到的总单信息是**空白的**,需手工填必填项(勾选必选项)。
|
||||||
|
> 【音】v2 29:19 "这些信息都是空白的,是要手工填的…把必选项勾一下"
|
||||||
|
5. 填总单必填字段并保存。
|
||||||
|
> 【音】v2 29:55 "总单填完,然后下面的信息一样新增导入"
|
||||||
|
|
||||||
|
**输出**:返利中心总单号。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### N06 返利中心细单模板导入
|
||||||
|
|
||||||
|
**细单必填字段【音】v2 30:03-31:57**:
|
||||||
|
|
||||||
|
| 字段 | 值/规则 | 出处 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 操作码/货品编码 | 前面对码结果 | v2 30:17 |
|
||||||
|
| 数量 | 如 234 | v2 30:21 |
|
||||||
|
| 单价 | 如 6.7 | v2 30:24 |
|
||||||
|
| 税率 | 必填 | v2 30:28 |
|
||||||
|
| 承付方式 | 如 11(下月N号结) | v2 30:38 |
|
||||||
|
| 参数 | 如 25(号) | v2 30:41 |
|
||||||
|
| 细单开始时间 | = 订单时间 | v2 30:43 |
|
||||||
|
| 失效日期 | 拉长一点 | v2 30:52 |
|
||||||
|
| 结算方式 | 如 7 | v2 31:03 |
|
||||||
|
| 特定补偿 | **一定填 0** | v2 31:09 |
|
||||||
|
| 约定或B | 不一定填 | v2 31:13 |
|
||||||
|
| **细单备注** | **必填订单号** | v2 31:17 |
|
||||||
|
| 货源信息 | 如"310设定上" | v2 31:51 |
|
||||||
|
|
||||||
|
> 【音】v2 31:22 "细单备注就是填定单号…这个是一定要填的,药城跟小药现在都有填"
|
||||||
|
|
||||||
|
**RPA 动作**:下模板 → 填上述字段 → 上传导入 → 读取成功/失败行及原因。
|
||||||
|
|
||||||
|
**重复订单报错**:如果订单已做过,导入会提示"存在重复的编码",需重新创建总单再导入。
|
||||||
|
> 【音】v2 33:26 "如果重复了他会提示存在重复的编码,那你要重新创建总单,再去把细单输进去"
|
||||||
|
> 【待确认】此处会议被时间打断,重复处理的完整规则未讲完。
|
||||||
|
|
||||||
|
**导入成功后**:提交送审。
|
||||||
|
> 【音】v2 34:15 "导完成功了以后提交送审…然后就会到M5"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### N07 返利中心送审(决定 M5 能否带出协议)⭐关键卡点
|
||||||
|
|
||||||
|
> ⚠️ 注意:送审并非在合同系统完成。真正决定下游的送审在**返利中心**。
|
||||||
|
|
||||||
|
**规则**:返利中心细单**必须送审**,否则 M5 里选品种时协议**带不出来**。
|
||||||
|
> 【音】v1 02:35 "没有点送审他到不了M5…如果返利中心没有送审,M5这里就不会跳出来"
|
||||||
|
> 【音】v1 02:44 "你总单进完之后,输这个品种,他跳不出来,如果返利中心没有的话"
|
||||||
|
|
||||||
|
**送审即自动审批**(合同前端已审过一遍,返利中心送审后自动通过)。
|
||||||
|
> 【音】v1 02:48 "直接送审就一次自动审批了…因为合同已经审过一次,前端已经审了一遍"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### N08 M5 采购订单创建(1055)
|
||||||
|
|
||||||
|
**系统**【画】:INCA 系统,角色"福建洁达采购员",采购订单管理 1055。
|
||||||
|
|
||||||
|
**采购订单总单字段【画】**:订单编号、业务日期、供应商、数据员、联系人、订单金额、书面订单号、开始日期、结果日期、**签约日期**、结算方式、预付款、其他费用、配送加点率、签约人、送达地点、代理方式、签约类。
|
||||||
|
|
||||||
|
**采购订单细单字段【画】**:采购员、货品ID、通用名、商品名、条形码、规格型号、基本单位、生产厂家、产地、上市许可持有人、**货品数量**、单价、金额。
|
||||||
|
|
||||||
|
**自动带入字段(不需RPA填,返利中心已选后下发)**:
|
||||||
|
> 【音】v1 20:27 "接收方式不可以选,因为返利中心已经做了,直接下发下来的"
|
||||||
|
> 【音】v1 21:15 "发票名称也是自动带下来的"
|
||||||
|
|
||||||
|
| 字段 | 处理 |
|
||||||
|
| --- | --- |
|
||||||
|
| 结算方式/承付模式 | 返利中心下发,M5不填 |
|
||||||
|
| 接收方式 | 返利中心下发 |
|
||||||
|
| 发票名称 | 自动带入 |
|
||||||
|
| 保管账 | 只选默认"正常保管账"(只有一个) |
|
||||||
|
|
||||||
|
> 【音】v1 20:02 "保管账我们只选一个正常保管账…默认是正常保管账"
|
||||||
|
|
||||||
|
**关键操作细节**【音】v1 00:17-01:22:
|
||||||
|
1. M5 直接做订单 → 新增。
|
||||||
|
2. 选保管账(B选项,默认正常保管账)。
|
||||||
|
3. 日期:若第二天做,改成前一天签约日期。
|
||||||
|
4. **备注填订单号**(给采购结算勾对账用)。
|
||||||
|
> 【音】v1 00:50 "备注要把订单号粘过来"
|
||||||
|
> 【音】v1 01:11 "返利中心填的订单号给物流部软件看,M5这里填的订单号给我们(结算)看,打结算时看这个订单号去勾"
|
||||||
|
5. 新增细单,选品种(返利中心已送审的才能带出)。
|
||||||
|
6. 选对应数量(协议有累计采购量,为0说明没用过)。
|
||||||
|
> 【音】v1 03:34 "累计采购量是零就说明这个是你自己的订单,没有用过"
|
||||||
|
|
||||||
|
**加单痛点**:一旦加单,要重新做协议(做两条三条),从头开始。
|
||||||
|
> 【音】v1 04:02 "你一加单我们就是要做两条三条…重新从核酷开始做"
|
||||||
|
> 【待确认】加单场景RPA如何处理。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### N08b M5 质管资料缺失处理(业务方明确决策)
|
||||||
|
|
||||||
|
> ⚠️ 曾有"转质管处理后重试"的设想。业务方**明确否决**:因为RPA晚上跑,质管不可能半夜维护。
|
||||||
|
|
||||||
|
**真实决策**:M5 保存报错(供应商基础信息过期等质管问题)→ **停止,转人工手工做这条单,不等质管**。
|
||||||
|
> 【音】v1 24:36 "如果质管没维护好,我们就不做了,由你们直接去做这条单"
|
||||||
|
> 【音】v1 25:00 "我们是晚上做,质管不可能半夜来录入信息"
|
||||||
|
> 【音】v1 26:31 "报错卡住就停止,通知我们,下室(M5)我们手工做"
|
||||||
|
|
||||||
|
**质管卡的是什么**:供应商基础信息(如年报过期)导致 M5 无法保存。细单信息在增加时其实已存在,只是保存不了。
|
||||||
|
> 【音】v1 23:33 "自管没维护好,无法保存时系统会提示…是供应商基础信息"
|
||||||
|
> 【音】v1 26:00 "M5已经生成订单时,供应商资料问题(缺失或过期)就卡住,不让生成"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### N09 人工检查 → 保存 → 签订 → 生成收货通知单
|
||||||
|
|
||||||
|
**分阶段上线(业务方核心要求,见 §9)**:一期 RPA 只做到"**录入待保存**",人工检查后再点保存/签订。
|
||||||
|
|
||||||
|
**不可逆约束**(决定为何末端要人工把关):
|
||||||
|
> 【音】v1 05:03 "签订完之后不能修改,生成收货通知单就回不了头了"
|
||||||
|
> 【音】v1 27:33 "签订完之后不能修改;保存完之后也不能修改"
|
||||||
|
|
||||||
|
**RPA 动作(一期)**:录入完总单+细单后**先不点保存**,转人工。可继续做下一单(一直挂在待保存)。
|
||||||
|
> 【音】v1 28:08 "录完了以后先不点保存,给你们检查,错了还可以改"
|
||||||
|
> 【音】v1 28:11 "不点保存可以做下一单…会一直在待保存"
|
||||||
|
|
||||||
|
**人工确认后**:检查数量 → 点保存 → 签订 → 点"生成收货通知单"。
|
||||||
|
> 【音】v1 05:03 "生成完以后点签订,签订完点生成收货通知单,采购合同就到下方双货币,单据自己结束"
|
||||||
|
|
||||||
|
**成功判定**:收货通知单号生成,供应商/货品/数量与采购订单一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### N10 归档
|
||||||
|
|
||||||
|
**归档业务编号链**:订单号 → 合同编号 → 返利中心总单号 → M5采购订单号 → 收货通知单号,全部可查。
|
||||||
|
> 【音】v1 00:59 "这个订单号我们希望每一个地方都能查到"
|
||||||
|
|
||||||
|
**归档内容**:原始订单Excel/截图、对码结果、合同PDF、各系统业务编号、关键截图、运行日志、人工处理记录。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 量级数据(影响性能设计)
|
||||||
|
|
||||||
|
| 指标 | 数值 | 出处 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 线下手工单 | ~100多份/月 | 【音】v1 08:54 |
|
||||||
|
| 线上订单(总单) | 65~75条/天 | 【音】v1 15:00, 16:24 |
|
||||||
|
| 产品细单 | ~13000条/月 | 【音】v1 19:36 |
|
||||||
|
| 合同审批时长 | 半小时内 | 【音】v2 26:47 |
|
||||||
|
| 线上OCR成本 | ~1000元/月(10亿token),实际线下用量约十几元/月 | 【音】v1 08:43 |
|
||||||
|
| 平均每总单细单数 | ~5个 | 【音】v1 14:26 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 订单号贯穿全流程(关键追溯线)
|
||||||
|
|
||||||
|
| 位置 | 填什么 | 给谁看 | 出处 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 电商平台 | 原始订单号(如 ZQD...) | 源头 | 【画】 |
|
||||||
|
| 返利中心细单备注 | 订单号 | **物流部软件** | 【音】v1 01:07 |
|
||||||
|
| M5 订单备注 | 订单号 | **采购结算**(打结算勾对账) | 【音】v1 01:11 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 分阶段上线策略(业务方核心诉求)
|
||||||
|
|
||||||
|
**阶段一(上线初期)**:
|
||||||
|
- RPA 做到"录入待保存",**不自动点保存/签订**。
|
||||||
|
- 人工检查准确率,错了可改。
|
||||||
|
- M5 的第17/18步(保存、生成收货通知单)由人工做。
|
||||||
|
> 【音】v1 27:57 "先手工一段时间,因为不知道准确率怎么样"
|
||||||
|
|
||||||
|
**阶段二(准确率验证后)**:
|
||||||
|
- 准确率OK后,RPA 接管保存/签订/生成收货通知单。
|
||||||
|
> 【音】v1 28:26 "准确度可以的话,到时候就帮我们保存就好了"
|
||||||
|
|
||||||
|
**设计要求**:保存/签订是否自动,应做成**可配置开关**,便于从阶段一平滑切到阶段二。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 异常分流表(基于真实会议修正)
|
||||||
|
|
||||||
|
| 异常 | 处理角色 | 系统动作 | 备注 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 产品对码未命中 | 业务(计划员/采购) | 一对多候选,人工选择/新增对码表 | 核心难点 |
|
||||||
|
| 货源未建 | 业务 | 通知在M5新增货源 | 必备节点 |
|
||||||
|
| 供应商合同禁掉失效 | 采购(邮件通知) | 通知采购重新禁掉,可多人邮箱 | 邮件多人 |
|
||||||
|
| OCR低置信度(仅线下单) | 计划员 | 展示原文,人工确认 | 范围仅线下 |
|
||||||
|
| 合同未审批完 | 计划员 | 停在前置,等半小时再查 | 定时重查 |
|
||||||
|
| 返利中心导入重复编码 | 采购 | 提示重复,重建总单【待确认】 | 规则待补 |
|
||||||
|
| 返利中心未送审 | 采购 | M5带不出协议,阻断 | 硬卡点 |
|
||||||
|
| **M5质管资料缺失** | **业务手工做,不等质管** | **停止转人工** | **不重试** |
|
||||||
|
| 网页卡顿/登录失败 | 运维 | 截图日志告警 | 系统类 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 待确认事项(下次会议 / 需业务提供)
|
||||||
|
|
||||||
|
**下次会议需拍板**:
|
||||||
|
1. **OCR信息安全**(最大卡点):线上OCR是否获批?否则本地部署费用?需拉法务部+数据中心+业务方三方会议。【音】v1 30:15
|
||||||
|
2. **返利中心重复订单**的完整处理规则(会议被打断未讲完)。【音】v2 33:26
|
||||||
|
3. **小药药平台**完整演示(三段视频均未完整覆盖,原定重开会议演示)。
|
||||||
|
4. **加单场景**:父订单号如何关联、RPA如何处理加单导致的重做。
|
||||||
|
5. 医药城订单**状态过滤**:只取"待收货"还是含其他状态?
|
||||||
|
6. 保存/签订自动化的切换时点和判定标准。
|
||||||
|
|
||||||
|
**业务需提供的资料**:
|
||||||
|
1. 供应商对照表(合同版本/类别/结算类型/合同性质对应关系)。【音】v1 12:34
|
||||||
|
2. 货品对码表(现有的,持续维护)。【音】v3 14:44
|
||||||
|
3. 计划员与合同对应关系表。【音】v1 33:xx
|
||||||
|
4. 各类通知邮箱(禁掉通知、质管缺料通知,均可多人)。【音】v1 11:44, 22:49
|
||||||
|
5. 生产/测试账号(合同系统、返利中心、M5)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 复用的工程框架
|
||||||
|
|
||||||
|
以下工程框架扎实,**建议保留并复用**(本文不再重复展开,详见《会议整理版》对应章节):
|
||||||
|
|
||||||
|
- 状态机:`PENDING/RUNNING/WAITING_USER/SUCCESS/FAILED_RETRYABLE/FAILED_MANUAL/CANCELLED`
|
||||||
|
- 幂等要求:文件哈希入池幂等、`合同号+类型+供应商+来源`业务幂等、`job_id+node_code+attempt_no`回调幂等、`task_id+action+submit_token`手机提交幂等、业务系统执行前先检索已有单号
|
||||||
|
- 证据要求:每节点记录编码/时间/平台/运行号/错误码/业务摘要/截图/日志/责任角色/重试次数
|
||||||
|
- 核心实体:`job/job_node/user_task/mapping_config/executor_binding_config/job_artifact/job_event`
|
||||||
|
- 统一RPA节点契约与回调字段
|
||||||
|
- 企业微信待办与H5详情页需求
|
||||||
|
- RPA执行平台可替换边界
|
||||||
|
|
||||||
|
**但节点编码需按本文重排**,建议:
|
||||||
|
`ORDER_FETCH`(订单获取)、`OCR_EXTRACT`(仅线下)、`PRODUCT_MATCH`(对码)、`SOURCE_ADD`(货源新增)、`CONTRACT_CREATE`(合同建单,含医药城PDF合并)、`CONTRACT_APPROVE_WAIT`(等审批)、`REBATE_HEADER`(返利总单)、`REBATE_IMPORT`(返利细单导入)、`REBATE_SUBMIT`(返利送审)、`M5_CREATE_ORDER`(M5录入待保存)、`M5_MANUAL_CONFIRM`(人工保存签订)、`M5_RECEIPT_NOTICE`(收货通知单)、`ARCHIVE_JOB`(归档)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录A:三方会审的主要纠正(常见误解对照)
|
||||||
|
|
||||||
|
| # | 早期常见误解 | 视频真实业务 | 影响 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | 合同文件入池是入口,OCR是核心 | **电商订单导出是入口**,OCR仅线下手工单 | 流程骨架方向性错误 |
|
||||||
|
| 2 | 未区分订单来源 | **医药城/小药药/线下三来源**,逻辑分支不同 | 缺核心分流 |
|
||||||
|
| 3 | 对码一笔带过 | **产品对码是核心难点**,规格描述不一致,需一对多,业务已有对码表 | 低估核心工作量 |
|
||||||
|
| 4 | 无货源新增 | 未采购过需在M5**新增货源** | 遗漏节点 |
|
||||||
|
| 5 | 无合同证文合并 | 同供应商同日**合并PDF**上传,覆盖问题 | 遗漏硬规则 |
|
||||||
|
| 6 | 送审在合同系统 | 关键送审在**返利中心**,决定M5能否带协议 | 卡点位置错误 |
|
||||||
|
| 7 | M5字段需RPA填 | 结算/接收方式/发票**返利中心下发自动带入** | 多填错填风险 |
|
||||||
|
| 8 | 质管缺料转质管重试 | **停止转人工手工做,不等质管**(晚上跑) | 与真实决策相反 |
|
||||||
|
| 9 | 无分阶段上线 | 一期**录入待保存**,人工检查,准确率OK后再全自动 | 遗漏核心诉求 |
|
||||||
|
| 10 | 无量级数据 | 65-75单/天,细单1.3万/月,审批半小时 | 影响性能设计 |
|
||||||
|
| 11 | 签约日期未强调 | **签约日期=订单日期**硬约束 | 遗漏规则 |
|
||||||
|
| 12 | 未强调订单号追溯 | 订单号贯穿返利(物流看)/M5(结算看) | 遗漏追溯线 |
|
||||||
|
|
@ -0,0 +1,491 @@
|
||||||
|
# 采购主链 RPA 一期需求文档
|
||||||
|
|
||||||
|
> 文档用途:作为产品、开发、RPA 实施、测试和业务确认的共同底稿。本文将当前已留存的会议相关业务底稿,与现有《采购业务 RPA 思路》《一期需求简版说明》《一期开发任务清单》统一整理。
|
||||||
|
>
|
||||||
|
> 证据说明:视频音频未上传至外部转写服务,本文不是视频逐句转写稿,而是基于当前项目资料整理出的可执行需求初稿。文末“上线前待确认事项”用于回看会议原声后完成最终确认。
|
||||||
|
>
|
||||||
|
> 范围说明:当前可落地的主线是“合同/协议 -> 返利中心 -> M5 -> 收货通知单”。订单中心分货、招采子系统和发货后票据上传属于后续范围,本文单独列出,不混入一期验收。
|
||||||
|
|
||||||
|
## 1. 项目概述
|
||||||
|
|
||||||
|
### 1.1 项目目标
|
||||||
|
|
||||||
|
将采购人员收到合同后的重复录入和跨系统搬运工作,建设为一条可追踪的 RPA 自动化流程:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A[合同/协议进入共享目录] --> B[状态中心建单与判重]
|
||||||
|
B --> C[OCR识别合同字段]
|
||||||
|
C --> D[字段映射与业务校验]
|
||||||
|
D --> E{合同系统是否审核通过}
|
||||||
|
E -- 否 --> F[计划员手机待办]
|
||||||
|
E -- 是 --> G[返利中心采购协议总单]
|
||||||
|
G --> H[返利中心细单模板导入]
|
||||||
|
H --> I[返利中心结果校验]
|
||||||
|
I --> J[M5 1055采购订单]
|
||||||
|
J --> K[生成收货通知单]
|
||||||
|
K --> L[归档业务编号、截图、日志]
|
||||||
|
```
|
||||||
|
|
||||||
|
核心原则:机器人只执行已经确认的规则;不能稳定识别或映射的字段必须转人工,禁止猜测填报。
|
||||||
|
|
||||||
|
### 1.2 一期范围
|
||||||
|
|
||||||
|
包含:
|
||||||
|
|
||||||
|
- 合同扫描件/合同文件从共享目录入池
|
||||||
|
- 文件命名、完整性和重复性检查
|
||||||
|
- OCR 识别合同基础字段和货品明细
|
||||||
|
- 供应商、货品、结算方式、承付模式映射
|
||||||
|
- 合同系统信息补齐、审核状态检查
|
||||||
|
- 返利中心采购协议检索、总单录入、细单模板导入
|
||||||
|
- 返利中心导入结果校验
|
||||||
|
- M5 采购订单管理 `1055` 新增采购订单
|
||||||
|
- M5 订单保存及收货通知单生成
|
||||||
|
- 企业微信消息通知和 H5 手机待办
|
||||||
|
- 状态中心、节点状态、日志、截图、重试和审计留痕
|
||||||
|
- RPA 执行平台适配和可替换设计
|
||||||
|
|
||||||
|
### 1.3 一期不包含
|
||||||
|
|
||||||
|
- 订单中心品种分货、货源切换和销售订单联动
|
||||||
|
- 招采子系统医院计划抓取
|
||||||
|
- 发货后一票、二票信息自动上传
|
||||||
|
- 渠道价管理与维护
|
||||||
|
- 邮件自动收件作为主入口
|
||||||
|
- 复杂 BI 看板和经营分析
|
||||||
|
|
||||||
|
以上内容可作为二期/三期独立需求,不应影响一期主链交付。
|
||||||
|
|
||||||
|
## 2. 角色与职责
|
||||||
|
|
||||||
|
| 角色 | 主要职责 | 可处理的异常/字段 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 计划员 | 检查合同、确认 OCR 结果、确认数量和合同系统状态 | 合同编号、合同有效期、承付模式及参数、计划员归属、OCR 结果 |
|
||||||
|
| 采购 | 确认返利中心协议及细单信息 | 供应商/货品映射、协议单价、导入模板错误、返利中心业务校验 |
|
||||||
|
| 质管 | 维护或确认 M5 所需基础资料 | 质管基础资料缺失导致的 M5 保存失败 |
|
||||||
|
| 运维/机器人管理员 | 处理账号、网络、页面变化、执行器和系统异常 | 登录失败、控件定位失败、接口回调失败、页面结构变化、超时 |
|
||||||
|
| 项目负责人 | 处理升级事项、配置审批、平台切换和范围决策 | 多次失败、跨角色协调、执行平台切换 |
|
||||||
|
|
||||||
|
角色路由按角色配置,不将流程责任固定写死到个人账号。人员请假或轮班时,可由管理员替换实际处理人。
|
||||||
|
|
||||||
|
## 3. 总体架构与边界
|
||||||
|
|
||||||
|
### 3.1 系统职责
|
||||||
|
|
||||||
|
| 系统 | 负责内容 | 不负责内容 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 状态中心 | 建单、判重、节点编排、业务规则、待办、重试、审计、证据、平台路由 | 直接操作返利中心或 M5 页面 |
|
||||||
|
| RPA 执行平台 | 登录、检索、点击、录入、上传、下载、截图、结果回调 | 保存主流程状态、人工审批和业务规则主数据 |
|
||||||
|
| OCR 服务 | 文件识别、文本抽取、字段置信度输出 | 直接向业务系统写单 |
|
||||||
|
| 企业微信 | 消息、卡片、H5 入口、用户身份和人工操作提交 | 直接修改数据库或绕过状态中心推进流程 |
|
||||||
|
| 合同系统 | 合同录入、审核和合同编号产生 | 一期不要求其改造接口,优先通过 RPA 访问 |
|
||||||
|
| 返利中心 | 采购协议总单、细单、返利协议结果 | 不承担流程追踪和异常协同 |
|
||||||
|
| M5 | `1055` 采购订单、收货通知单 | 不承担合同和返利中心数据映射 |
|
||||||
|
|
||||||
|
### 3.2 执行平台可替换边界
|
||||||
|
|
||||||
|
- 状态中心、规则、企业微信待办、日志和验收口径必须由项目方掌握。
|
||||||
|
- 一期上线可以只接一个主平台,但节点设计必须保留统一适配接口。
|
||||||
|
- 可按新单或节点配置选择平台;不支持一个节点执行到一半跨平台接力。
|
||||||
|
- 新平台上线前必须通过登录、检索、模板导入、截图、日志、HTTP 回调等 POC 用例。
|
||||||
|
- 老单默认沿用建单时的执行平台,避免中途迁移造成重复业务结果。
|
||||||
|
|
||||||
|
## 4. 主流程节点需求
|
||||||
|
|
||||||
|
### N01 合同文件入池
|
||||||
|
|
||||||
|
**目的**:将合同扫描件、年度协议或批次合同纳入统一流程,建立唯一流程实例。
|
||||||
|
|
||||||
|
**触发方式**:
|
||||||
|
|
||||||
|
- 计划员/采购将双方盖章版本放入约定共享目录;或
|
||||||
|
- 用户在状态中心手工上传并建单。
|
||||||
|
|
||||||
|
**输入**:文件、文件名、上传时间、来源目录、发起人、合同类型(年度协议/批次合同)。
|
||||||
|
|
||||||
|
**处理规则**:
|
||||||
|
|
||||||
|
1. 校验文件格式,至少支持 PDF、常见图片格式;Excel 仅作为模板输入时支持。
|
||||||
|
2. 校验文件是否可打开、页数是否异常、是否存在空文件或缺页。
|
||||||
|
3. 计算文件哈希,拦截相同文件重复上传。
|
||||||
|
4. 按“合同编号 + 合同类型 + 供应商 + 发起来源”判定业务重复。
|
||||||
|
5. 记录原始文件,不覆盖历史文件。
|
||||||
|
6. 创建 `job_id`,初始化节点为 `FILE_INTAKE`,绑定映射版本和默认执行平台。
|
||||||
|
|
||||||
|
**输出**:`job_id`、源文件存证、文件哈希、合同类型、来源信息。
|
||||||
|
|
||||||
|
**异常与路由**:
|
||||||
|
|
||||||
|
- 命名不规范:计划员待办,不启动 RPA。
|
||||||
|
- 文件损坏、缺页或关键附件缺失:计划员待办。
|
||||||
|
- 疑似重复:提示已有流程实例,由计划员/项目负责人确认,不直接新建。
|
||||||
|
- 共享目录不可访问:运维告警。
|
||||||
|
|
||||||
|
**成功判定**:状态中心已生成唯一 `job_id`,且源文件、元数据、判重结果均已留痕。
|
||||||
|
|
||||||
|
### N02 OCR 识别与字段抽取
|
||||||
|
|
||||||
|
**目的**:将合同扫描件转换为可校验的结构化数据。
|
||||||
|
|
||||||
|
**输入**:N01 的源文件和合同类型。
|
||||||
|
|
||||||
|
**建议抽取字段**:
|
||||||
|
|
||||||
|
| 字段 | 说明 | 是否必填 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `supplier_name` | 供应商名称 | 是 |
|
||||||
|
| `planner_name` | 计划员/联系人 | 是 |
|
||||||
|
| `contract_no` | 合同编号 | 是,若合同系统尚未产生可为空 |
|
||||||
|
| `settlement_text` | 原文结算方式 | 是 |
|
||||||
|
| `payment_term_text` | 原文承付模式及参数 | 是 |
|
||||||
|
| `contract_validity` | 合同有效期 | 是 |
|
||||||
|
| `items[]` | 货品、数量、单价等明细 | 批次合同必填 |
|
||||||
|
|
||||||
|
每个字段必须输出原文值、标准化值、页码/位置和置信度;保留完整 OCR JSON,不能只保存最终填入值。
|
||||||
|
|
||||||
|
**处理规则**:
|
||||||
|
|
||||||
|
- OCR 服务采用本地部署,状态中心通过适配接口调用,后续可替换云 OCR。
|
||||||
|
- 关键字段缺失、置信度低于阈值、同一字段识别出多个候选值时,进入 `WAITING_USER`。
|
||||||
|
- OCR 只负责识别,不直接决定系统编码,不直接写入返利中心或 M5。
|
||||||
|
|
||||||
|
**成功判定**:必需字段均有值或已明确进入人工确认,OCR 原始结果和截图可追溯。
|
||||||
|
|
||||||
|
**异常与路由**:OCR 服务不可用、文件无法识别、页码读取异常,转计划员;服务级异常同时通知运维。
|
||||||
|
|
||||||
|
### N03 字段映射与业务校验
|
||||||
|
|
||||||
|
**目的**:把合同语言转换为合同系统、返利中心和 M5 能使用的编码与字段。
|
||||||
|
|
||||||
|
**映射对象**:供应商、货品、结算方式、承付模式、合同编号、角色和执行平台。
|
||||||
|
|
||||||
|
**处理规则**:
|
||||||
|
|
||||||
|
1. 规则优先,精确命中字典才允许自动转换。
|
||||||
|
2. 能识别标准 `N天` 的承付表达时,拆分为“承付模式代码 + 天数参数”。
|
||||||
|
3. 货品需映射到系统货品编码,数量和单价保留合同原值及转换结果。
|
||||||
|
4. 映射规则必须版本化;流程开始后绑定版本快照。
|
||||||
|
5. 重试默认沿用原版本;人工选择“按最新配置重跑”时才更新版本。
|
||||||
|
6. 任一必填项未命中、存在一对多结果或映射置信度不足,不得继续。
|
||||||
|
|
||||||
|
**承付模式字典**:
|
||||||
|
|
||||||
|
`0 手工指定`、`1 订单签订后N天`、`2 入库后N天`、`3 收到发票后N天`、`4 供应商开出发票后N天`、`5 销售发货后N天`、`6 销售出库后N天`、`7 销售回款后N天`、`8 门店铺货后N天`、`9 以销定结`、`10 销完后N天`、`11 下月N号结`、`12 押N批`、`13 预付`、`14 供应商发货日期N天`、`15 开完发票N天`、`16 销售发票入账后N天`、`99 无`。
|
||||||
|
|
||||||
|
**结算方式字典**:
|
||||||
|
|
||||||
|
`3 电汇`、`10 银行承兑汇票`、`11 商业承兑汇票`、`12 托收`、`13 信用证`、`17 电子债权凭证`、`27 电子银行承兑汇票`、`28 电子商业承兑汇票`、`30 应付保理`、`31 归集付款`、`32 电汇-非直联`、`33 不需支付`、`34 其他支付-直联`、`35 其他支付-非直联`、`36 电子债权凭证-非直联`、`37 电子银承汇票-非直联`。
|
||||||
|
|
||||||
|
**输出**:映射后的结构化业务对象、校验结果、未通过字段清单、映射版本。
|
||||||
|
|
||||||
|
**异常与路由**:供应商/货品未找到或多值匹配,计划员或采购待办;字典配置缺失,运维/项目负责人待办。
|
||||||
|
|
||||||
|
### N04 合同系统信息补齐与审核前置
|
||||||
|
|
||||||
|
**目的**:确保后续返利中心和 M5 使用的合同已在合同系统中建立并处于可执行状态。
|
||||||
|
|
||||||
|
**输入**:N02/N03 结构化字段和源文件。
|
||||||
|
|
||||||
|
**RPA 操作**:
|
||||||
|
|
||||||
|
- 登录合同系统。
|
||||||
|
- 按供应商、计划员、合同编号或业务条件检索已有合同。
|
||||||
|
- 补齐允许自动录入的合同字段。
|
||||||
|
- 保存并获取合同系统单号。
|
||||||
|
- 查询合同审核状态。
|
||||||
|
|
||||||
|
**前置规则**:
|
||||||
|
|
||||||
|
- 合同系统“已审核/审核通过”才允许进入返利中心。
|
||||||
|
- 送审是否由机器人执行,需业务最终确认;未确认前默认由计划员操作或确认。
|
||||||
|
- 审核驳回、缺资料或状态不明确,流程停留在前置节点。
|
||||||
|
|
||||||
|
**输出**:合同系统单号、审核状态、审核通过时间、驳回原因摘要。
|
||||||
|
|
||||||
|
**成功判定**:合同单号存在,系统查询状态为审核通过,且关键字段与状态中心数据一致。
|
||||||
|
|
||||||
|
### N05 返利中心采购协议总单
|
||||||
|
|
||||||
|
**目的**:根据合同编号在返利中心定位采购协议并完成总单必填信息。
|
||||||
|
|
||||||
|
**输入**:合同编号、供应商、承付模式、承付参数、结算方式、返利合约标识、有效期。
|
||||||
|
|
||||||
|
**RPA 操作**:
|
||||||
|
|
||||||
|
1. 登录返利中心。
|
||||||
|
2. 进入“采购协议”。
|
||||||
|
3. 以合同编号检索对应总单。
|
||||||
|
4. 校验供应商和合同编号是否与状态中心一致。
|
||||||
|
5. 录入/更新采购协议总单必填字段。
|
||||||
|
6. 保存,获取返利中心总单号并截图。
|
||||||
|
|
||||||
|
**关键业务规则**:
|
||||||
|
|
||||||
|
- 采购协议至少包含品种、承付模式、承付参数和返利合约(一期默认为“否”或按业务配置)。
|
||||||
|
- 承付模式和结算方式必须使用系统内已存在编码。
|
||||||
|
- 批次合同在开户时必须先录入产品。
|
||||||
|
- 批次合同有效期默认为 1 个月;如合同原文不同,以人工确认值为准。
|
||||||
|
|
||||||
|
**成功判定**:总单号已生成或检索到且关键字段核验一致;只有页面保存成功而无业务编号时不得直接成功。
|
||||||
|
|
||||||
|
**异常**:检索不到、供应商不一致、必填项校验失败、重复总单、登录或页面异常,按采购/计划员/运维角色分流。
|
||||||
|
|
||||||
|
### N06 返利中心细单模板生成与导入
|
||||||
|
|
||||||
|
**目的**:批量写入合同约定的货品、数量、单价等明细,减少逐行人工录入。
|
||||||
|
|
||||||
|
**输入**:货品映射结果、合同明细、返利中心模板格式、总单号。
|
||||||
|
|
||||||
|
**状态中心处理**:
|
||||||
|
|
||||||
|
- 按模板版本生成 Excel/导入文件。
|
||||||
|
- 写入来源字段、目标字段和每行数据校验结果。
|
||||||
|
- 模板作为 `TEMPLATE` 存证,记录生成时间和映射版本。
|
||||||
|
|
||||||
|
**RPA 操作**:
|
||||||
|
|
||||||
|
1. 下载或打开返利中心细单模板。
|
||||||
|
2. 上传状态中心生成的模板。
|
||||||
|
3. 提交导入。
|
||||||
|
4. 读取导入总行数、成功行数、失败行数和失败原因。
|
||||||
|
5. 保存错误明细截图和导入结果。
|
||||||
|
|
||||||
|
**成功判定**:成功行数等于应导入行数,失败行数为 0,且返利中心返回可查询的结果。
|
||||||
|
|
||||||
|
**部分成功处理**:必须保留逐行成功/失败明细;不得直接将整节点标记成功,也不得盲目重复导入造成重复货品。转采购待办,由采购决定修正、补导或人工处理。
|
||||||
|
|
||||||
|
### N07 返利中心结果校验
|
||||||
|
|
||||||
|
**目的**:确认总单与细单已经真正落库,并作为 M5 前置条件。
|
||||||
|
|
||||||
|
**校验项**:供应商、合同编号、货品编码、数量、协议单价、结算方式、承付模式、有效期、明细行数。
|
||||||
|
|
||||||
|
**输出**:返利中心总单号、明细校验摘要、差异项、成功截图。
|
||||||
|
|
||||||
|
**规则**:有差异即阻断 M5;差异项应展示“合同值/状态中心值/返利中心值”,转采购或计划员确认。
|
||||||
|
|
||||||
|
### N08 M5 采购订单创建(1055)
|
||||||
|
|
||||||
|
**目的**:依据已审核合同和返利中心协议,在 M5 的采购订单管理 `1055` 中创建采购订单。
|
||||||
|
|
||||||
|
**输入**:合同系统单号、返利中心协议结果、供应商、货品、数量、结算方式、承付模式、单价、货源/联系人信息。
|
||||||
|
|
||||||
|
**RPA 操作**:
|
||||||
|
|
||||||
|
1. 登录 M5,进入采购订单管理 `1055`。
|
||||||
|
2. 新增采购订单和细单。
|
||||||
|
3. 选择供应商、货品、货源。
|
||||||
|
4. 填写结算方式、承付模式、数量、单价等字段。
|
||||||
|
5. 执行页面校验并保存。
|
||||||
|
6. 读取采购订单号。
|
||||||
|
|
||||||
|
**数量规则**:计划员需要确认采购数量是否不超过供应商可供数量;状态中心应保留合同数量、录入数量和差异说明。
|
||||||
|
|
||||||
|
**货源规则**:按合同管理联系人/业务配置选择货源;无法唯一确定时转计划员或采购,机器人不得自行选择。
|
||||||
|
|
||||||
|
**异常**:质管基础资料未维护、货品不存在、货源不唯一、金额或数量校验失败,分别转质管、计划员或采购;控件和登录问题转运维。
|
||||||
|
|
||||||
|
**成功判定**:M5 查询到采购订单号,且订单供应商、货品、数量、单价与输入校验一致。
|
||||||
|
|
||||||
|
### N09 收货通知单生成
|
||||||
|
|
||||||
|
**目的**:采购订单完成后通知仓库即将到货的货品和来源。
|
||||||
|
|
||||||
|
**输入**:M5 采购订单号及细单。
|
||||||
|
|
||||||
|
**RPA 操作**:进入收货通知单功能,基于采购订单生成通知单,保存并读取通知单号。
|
||||||
|
|
||||||
|
**成功判定**:收货通知单号已生成,且其供应商、货品、数量与采购订单一致。
|
||||||
|
|
||||||
|
**异常**:订单未完成、重复生成、仓库信息缺失、页面/登录失败,分别转业务角色或运维;重复操作必须先检索已有通知单。
|
||||||
|
|
||||||
|
### N10 流程归档
|
||||||
|
|
||||||
|
**目的**:以真实业务结果为准完成归档,形成可审计的完整轨迹。
|
||||||
|
|
||||||
|
**归档内容**:合同原件、OCR JSON、映射快照、返利中心总单号、导入结果、M5 订单号、收货通知单号、关键截图、运行日志、人工处理记录。
|
||||||
|
|
||||||
|
**成功判定**:所有必需业务编号已回写状态中心,关键节点成功,审计事件完整;页面点击完成但未拿到业务结果不得归档为成功。
|
||||||
|
|
||||||
|
## 5. 状态机、重试与异常处理
|
||||||
|
|
||||||
|
### 5.1 节点状态
|
||||||
|
|
||||||
|
`PENDING`、`RUNNING`、`WAITING_USER`、`SUCCESS`、`FAILED_RETRYABLE`、`FAILED_MANUAL`、`CANCELLED`。
|
||||||
|
|
||||||
|
允许迁移:
|
||||||
|
|
||||||
|
`PENDING -> RUNNING -> SUCCESS`;
|
||||||
|
`RUNNING -> WAITING_USER -> RUNNING`;
|
||||||
|
`RUNNING -> FAILED_RETRYABLE -> RUNNING`;
|
||||||
|
`RUNNING -> FAILED_MANUAL`;
|
||||||
|
`WAITING_USER -> CANCELLED`。
|
||||||
|
|
||||||
|
同一节点同一时刻只能有一个有效执行上下文;重试产生新的 `attempt_no`,不得覆盖历史失败记录。
|
||||||
|
|
||||||
|
### 5.2 异常分流表
|
||||||
|
|
||||||
|
| 异常 | 默认处理角色 | 系统动作 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 文件命名不规范/缺页 | 计划员 | 不启动 RPA,创建待办 |
|
||||||
|
| OCR 低置信度/关键字段缺失 | 计划员 | 展示原文和建议值,等待确认 |
|
||||||
|
| 供应商或货品无法映射 | 计划员/采购 | 阻断下游,保留映射候选 |
|
||||||
|
| 合同未审核通过 | 计划员 | 停在前置节点,不进入返利中心 |
|
||||||
|
| 返利中心找不到总单 | 采购 | 自动重试后转人工确认 |
|
||||||
|
| 返利中心模板导入失败 | 采购 | 保留逐行错误,允许修正后重试 |
|
||||||
|
| M5 质管资料缺失 | 质管 | 处理基础资料后重试 |
|
||||||
|
| 登录、控件、页面结构变化 | 运维 | 截图、日志、运行号齐全后告警 |
|
||||||
|
| 节点超时无回调 | 运维 | 超时关闭执行上下文并升级 |
|
||||||
|
| 多次失败或超出能力 | 运维/项目负责人 | 转人工,禁止无限重试 |
|
||||||
|
|
||||||
|
### 5.3 重试策略
|
||||||
|
|
||||||
|
- 系统型异常(超时、偶发网络错误):自动重试 1 至 2 次。
|
||||||
|
- 业务型异常(映射不确定、校验失败):人工确认后只重试当前节点。
|
||||||
|
- 人工接管:进入 `FAILED_MANUAL`,保留上下文,不允许后台自动反复拉起。
|
||||||
|
- 整单重跑:新建流程实例并关联原 `parent_job_id`,不覆盖原轨迹。
|
||||||
|
- 待办超过 30 分钟未处理再次提醒,超过 2 小时升级备岗/项目负责人;具体时长可配置。
|
||||||
|
|
||||||
|
## 6. 手机待办与企业微信需求
|
||||||
|
|
||||||
|
### 6.1 待办内容
|
||||||
|
|
||||||
|
每条消息至少包含:合同编号、供应商、当前节点、错误/待确认原因、关键字段摘要、最近错误截图、处理链接、预计处理时间。
|
||||||
|
|
||||||
|
### 6.2 H5 详情页
|
||||||
|
|
||||||
|
页面必须支持:
|
||||||
|
|
||||||
|
- 查看当前人话状态:系统正在做什么、通常耗时、下一步是什么。
|
||||||
|
- 查看 OCR 原值、系统建议值和当前填写值。
|
||||||
|
- 只编辑角色有权限修改的白名单字段。
|
||||||
|
- 查看最近截图、错误翻译和处理历史。
|
||||||
|
- 执行确认并继续、重试当前节点、转人工、终止本单。
|
||||||
|
- 提交后立即返回“已受理、请勿重复提交、预计多久有结果”。
|
||||||
|
|
||||||
|
### 6.3 人工修改留痕
|
||||||
|
|
||||||
|
合同编号、供应商、结算方式、承付模式、货品编码、数量、单价、货源等字段的修改,必须记录修改前值、修改后值、修改人、时间和原因。
|
||||||
|
|
||||||
|
企业微信只展示必要摘要,完整合同和日志通过状态中心受控页面查看;查看和下载动作也要进入审计日志。
|
||||||
|
|
||||||
|
## 7. 状态中心与数据要求
|
||||||
|
|
||||||
|
### 7.1 核心实体
|
||||||
|
|
||||||
|
| 实体 | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| `job` | 一份合同的一次流程实例 |
|
||||||
|
| `job_node` | 每个节点每次尝试 |
|
||||||
|
| `user_task` | 企业微信/H5 人工待办 |
|
||||||
|
| `mapping_config` | 供应商、货品、结算、承付和路由配置 |
|
||||||
|
| `executor_binding_config` | 节点和 RPA 平台绑定 |
|
||||||
|
| `job_artifact` | 合同、OCR、模板、截图、日志存证 |
|
||||||
|
| `job_event` | 全流程事件轨迹 |
|
||||||
|
|
||||||
|
### 7.2 幂等要求
|
||||||
|
|
||||||
|
- 入池幂等:文件哈希 + 文件名 + 时间窗口。
|
||||||
|
- 业务幂等:合同编号 + 合同类型 + 供应商 + 来源。
|
||||||
|
- 节点回调幂等:`job_id + node_code + attempt_no + callback_type`。
|
||||||
|
- 手机提交幂等:`task_id + action + submit_token`。
|
||||||
|
- 业务系统执行前先检索已有单号,避免重复创建采购协议、采购订单和收货通知单。
|
||||||
|
|
||||||
|
### 7.3 证据要求
|
||||||
|
|
||||||
|
每个节点至少记录:节点编码、开始/结束时间、执行平台、运行号、错误码、业务摘要、截图、日志、当前责任角色、重试次数。
|
||||||
|
|
||||||
|
推荐目录:`job_id/节点编码/attempt_no/`。
|
||||||
|
|
||||||
|
## 8. 统一 RPA 节点契约
|
||||||
|
|
||||||
|
RPA 平台只通过状态中心接口交互,禁止直接改状态中心数据库。
|
||||||
|
|
||||||
|
节点回调至少包含:
|
||||||
|
|
||||||
|
- `jobId`
|
||||||
|
- `nodeCode`
|
||||||
|
- `attemptNo`
|
||||||
|
- `executorPlatform`
|
||||||
|
- `executorRunId`
|
||||||
|
- `status`
|
||||||
|
- `errorCode/errorSummary`
|
||||||
|
- `businessRefs`
|
||||||
|
- `summary`
|
||||||
|
- `artifacts`
|
||||||
|
- `callbackIdempotencyKey`
|
||||||
|
|
||||||
|
建议节点编码:
|
||||||
|
|
||||||
|
`FILE_INTAKE`、`OCR_EXTRACT`、`FIELD_MAPPING`、`CONTRACT_SYSTEM_CHECK`、`REBATE_ORDER_HEADER`、`REBATE_IMPORT_ITEMS`、`REBATE_VERIFY`、`M5_CREATE_ORDER`、`M5_RECEIPT_NOTICE`、`ARCHIVE_JOB`。
|
||||||
|
|
||||||
|
## 9. 验收标准
|
||||||
|
|
||||||
|
### 9.1 主流程验收
|
||||||
|
|
||||||
|
- 合同从共享目录入池后可生成唯一流程单。
|
||||||
|
- 完整且规则明确的合同可自动跑到 M5 和收货通知单。
|
||||||
|
- 可查询合同系统单号、返利中心总单号、M5 订单号、收货通知单号。
|
||||||
|
- 每个节点均有开始、结束、结果和证据。
|
||||||
|
|
||||||
|
### 9.2 安全与正确性验收
|
||||||
|
|
||||||
|
- 低置信度和多值映射不会被机器人猜测通过。
|
||||||
|
- 合同未审核通过不会进入返利中心。
|
||||||
|
- 重复上传、重复回调、重复点击不会生成重复业务结果。
|
||||||
|
- 部分导入失败可查看逐行明细,不会被误判为成功。
|
||||||
|
- 整单重跑不覆盖历史实例。
|
||||||
|
|
||||||
|
### 9.3 协同验收
|
||||||
|
|
||||||
|
- 计划员、采购、质管、运维收到的待办角色正确。
|
||||||
|
- 手机端能看懂卡点、原因、处理动作和预计等待时间。
|
||||||
|
- 人工修改有前后值对比和审计记录。
|
||||||
|
- 提交后有即时受理回执,重复点击有幂等反馈。
|
||||||
|
|
||||||
|
## 10. 测试场景
|
||||||
|
|
||||||
|
1. 正常年度协议:字段完整,自动完成主链。
|
||||||
|
2. 正常批次合同:合同有效期一个月,先录产品再进入细单。
|
||||||
|
3. OCR 低置信度:转计划员,修正后继续。
|
||||||
|
4. 承付模式未命中字典:不得自动猜,转计划员确认。
|
||||||
|
5. 供应商/货品一对多映射:转人工选择。
|
||||||
|
6. 合同系统未审核通过:流程停止,不启动返利中心。
|
||||||
|
7. 返利中心模板全部成功:进入结果校验和 M5。
|
||||||
|
8. 返利中心部分导入失败:记录逐行结果,转采购处理。
|
||||||
|
9. M5 质管资料未维护:转质管,修复后重试。
|
||||||
|
10. 执行平台登录失败:自动重试后转运维。
|
||||||
|
11. 页面结构变化:截图、日志、运行号完整,转运维。
|
||||||
|
12. 同一合同重复上传:拦截并提示已有流程。
|
||||||
|
13. 企业微信待办重复提交:只推进一次。
|
||||||
|
14. 节点长时间运行:超时升级,不允许永久停留在运行中。
|
||||||
|
15. 新单切换执行平台:业务状态、待办和审计口径不变。
|
||||||
|
|
||||||
|
## 11. 上线前待确认事项
|
||||||
|
|
||||||
|
以下事项必须由业务、实施和技术共同拍板后写入配置或测试用例:
|
||||||
|
|
||||||
|
- 合同系统“送审”是否由机器人执行。
|
||||||
|
- 合同系统的审核通过状态名称和可查询方式。
|
||||||
|
- 批次合同有效期是否始终默认为一个月。
|
||||||
|
- 返利合约字段是否一期固定为“否”。
|
||||||
|
- 合同文件命名规则、共享目录、文件权限和清理周期。
|
||||||
|
- OCR 置信度阈值及每个字段的必填性。
|
||||||
|
- 计划员、采购、质管和运维的实际企业微信角色映射。
|
||||||
|
- 各节点自动重试次数、超时阈值和工作时间。
|
||||||
|
- 返利中心部分成功后的补导规则,是否允许重导失败行。
|
||||||
|
- 货源选择规则和供应商可供数量校验口径。
|
||||||
|
- 生产和测试账号、RPA 运行机、企业微信可信域名。
|
||||||
|
- 合同、供应商和截图的查看权限、脱敏方式、保留周期。
|
||||||
|
|
||||||
|
## 12. 后续扩展方向
|
||||||
|
|
||||||
|
后续可在本期状态中心和待办体系上扩展:
|
||||||
|
|
||||||
|
- 订单中心货源拆分、分货和销售订单联动。
|
||||||
|
- 招采子系统每日医院计划抓取及订单中心生成。
|
||||||
|
- 发货后的一票、二票信息归集和上传。
|
||||||
|
- 渠道价维护。
|
||||||
|
|
||||||
|
这些流程应复用本期的 `job/job_node/user_task/job_event`、规则版本、幂等和证据体系,不应重新建设一套孤立机器人流程。
|
||||||
Loading…
Reference in New Issue