chore: 去掉 state_center 旧栈,根目录只留 oms 与四个作业包
删除 state_center、state_center_web、rpa_mock、scripts 及并行开发残留。 oms 运行时只走 Oracle,内存仓库留给单测注入。
This commit is contained in:
parent
d7b3ec48e6
commit
60c2d6d0f0
|
|
@ -1,367 +0,0 @@
|
|||
# 状态中心 ↔ 艺赛旗并行开发方案
|
||||
|
||||
## 核心原则:契约驱动,各自独立,最后对接
|
||||
|
||||
双方只通过 **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 天一起联调。
|
||||
|
||||
---
|
||||
|
||||
要我把这份文档发给艺赛旗吗?或者你先看看有没有要调整的。
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
#!/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/")
|
||||
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
#!/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")
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
"""内存仓库。单测和 OMS_REPO=memory 用,进程退出即丢。
|
||||
"""内存仓库。只给单测用,进程退出即丢。运行时走 Oracle。
|
||||
|
||||
按 oms_id 判重,已存在就 DuplicateOmsId,不覆盖。
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@
|
|||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import Depends, FastAPI, Header
|
||||
|
|
@ -15,21 +14,14 @@ from fastapi.responses import JSONResponse
|
|||
|
||||
from .auth import token_ok
|
||||
from .inbound.envelope import EnvelopeError, parse_envelope
|
||||
from .inbound.memory_repo import MemorySalesGoodsRepo
|
||||
from .inbound.repo import RepoUnavailable
|
||||
from .inbound.sales_goods import receive_sales_goods
|
||||
|
||||
app = FastAPI(title="洁达 RPA × 订单中心", version="0.1")
|
||||
|
||||
# 内存仓库做成进程内单例,同一进程多次 POST 才能测到 DUPLICATE。
|
||||
_memory_repo = MemorySalesGoodsRepo()
|
||||
|
||||
|
||||
def get_repo():
|
||||
# 默认走 Oracle。本机连不上库时 OMS_REPO=memory。
|
||||
if os.getenv("OMS_REPO", "oracle") == "memory":
|
||||
return _memory_repo
|
||||
# thick 客户端初始化有副作用,用到才 import。
|
||||
# 运行时只走 Oracle。单测用 dependency_overrides 注入内存替身。
|
||||
from .inbound.oracle_repo import OracleSalesGoodsRepo
|
||||
|
||||
return OracleSalesGoodsRepo.from_env()
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Thin wrapper around the portable mock agent.
|
||||
|
||||
Prefer:
|
||||
python -m rpa_yisaiqi --create --auto-human
|
||||
"""
|
||||
from rpa_yisaiqi.__main__ import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1 +0,0 @@
|
|||
"""Project development utilities."""
|
||||
292
scripts/dev.py
292
scripts/dev.py
|
|
@ -1,292 +0,0 @@
|
|||
"""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())
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
# 状态中心(采购主链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
|
||||
```
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
"""配置:从环境变量读,别把密码写死在代码里。"""
|
||||
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", "")
|
||||
|
||||
# M5 货品视图(福建货主对码)。账号密码只走环境变量,不进仓库。
|
||||
M5_HOST = os.getenv("M5_HOST", "173.1.1.215")
|
||||
M5_PORT = int(os.getenv("M5_PORT", "1521"))
|
||||
M5_SERVICE = os.getenv("M5_SERVICE", "m5")
|
||||
M5_USER = os.getenv("M5_USER", "")
|
||||
M5_PASSWORD = os.getenv("M5_PASSWORD", "")
|
||||
M5_SCHEMA = os.getenv("M5_SCHEMA", "m5")
|
||||
|
||||
# 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()
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
"""极简 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)
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
"""M5 货品视图只读查询。账号密码来自环境变量或 connection.local.json。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .config import settings
|
||||
|
||||
FIXTURE_DIR = Path(__file__).resolve().parents[1] / "fixtures"
|
||||
M5_DIR = FIXTURE_DIR / "m5"
|
||||
ORDER_PATH = FIXTURE_DIR / "orders" / "yyc-fujian-20260804.json"
|
||||
FUJIAN_PATH = M5_DIR / "fujian.json"
|
||||
LOCAL_CONN_PATH = M5_DIR / "connection.local.json"
|
||||
|
||||
|
||||
def _load_json(path: Path) -> dict[str, Any]:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def load_connection() -> dict[str, Any]:
|
||||
local: dict[str, Any] = {}
|
||||
if LOCAL_CONN_PATH.is_file():
|
||||
local = _load_json(LOCAL_CONN_PATH)
|
||||
host = os.getenv("M5_HOST") or local.get("host") or settings.M5_HOST
|
||||
port = int(os.getenv("M5_PORT") or local.get("port") or settings.M5_PORT)
|
||||
service = os.getenv("M5_SERVICE") or local.get("serviceName") or settings.M5_SERVICE
|
||||
user = os.getenv("M5_USER") or local.get("user") or settings.M5_USER
|
||||
password = os.getenv("M5_PASSWORD") or local.get("password") or settings.M5_PASSWORD
|
||||
schema = os.getenv("M5_SCHEMA") or local.get("schema") or settings.M5_SCHEMA
|
||||
return {
|
||||
"host": host,
|
||||
"port": port,
|
||||
"serviceName": service,
|
||||
"user": user,
|
||||
"password": password,
|
||||
"schema": schema,
|
||||
}
|
||||
|
||||
|
||||
def recorded_source_rows(goods_id: str) -> list[dict[str, Any]] | None:
|
||||
"""离线夹具:哈药牌这条 SQL 的 P1 结果,不连 M5。"""
|
||||
params = lookup_params()
|
||||
if str(goods_id) != params["expectedGoodsId"]:
|
||||
return None
|
||||
return [
|
||||
{
|
||||
"ENTRYID": params["entryId"],
|
||||
"PRIORITY": 1,
|
||||
"GOODSTYPE": params["spec"],
|
||||
"FACTORYNAME": params["manufacturer"],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def evaluate_priority_source(
|
||||
rows: list[dict[str, Any]],
|
||||
*,
|
||||
entry_id: int,
|
||||
spec: str,
|
||||
manufacturer: str,
|
||||
) -> dict[str, Any]:
|
||||
"""PRIORITY=1 且货主/规格/厂家对上,才算货源齐全。"""
|
||||
priority_rows = [row for row in rows if str(row.get("PRIORITY") or "") == "1"]
|
||||
hit = next(
|
||||
(
|
||||
row
|
||||
for row in priority_rows
|
||||
if str(row.get("ENTRYID") or "") == str(entry_id)
|
||||
and str(row.get("GOODSTYPE") or "").strip() == spec.strip()
|
||||
and str(row.get("FACTORYNAME") or "").strip() == manufacturer.strip()
|
||||
),
|
||||
None,
|
||||
)
|
||||
return {
|
||||
"source_add_needed": hit is None,
|
||||
"hit": hit,
|
||||
"priorityRows": priority_rows,
|
||||
}
|
||||
|
||||
|
||||
def lookup_params(order: dict[str, Any] | None = None, fujian: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
order = order or _load_json(ORDER_PATH)
|
||||
fujian = fujian or _load_json(FUJIAN_PATH)
|
||||
row = order["row"]
|
||||
return {
|
||||
"goodsNameLike": f"%{row['goodsNameLike']}%",
|
||||
"approvalNo": row["approvalNo"],
|
||||
"spec": row["spec"],
|
||||
"manufacturer": row["manufacturer"],
|
||||
"entryId": int(order["m5"]["entryId"] or fujian["entryId"]),
|
||||
"expectedGoodsId": str(row["expectedGoodsId"]),
|
||||
"sql": fujian["sql"],
|
||||
"sourceSql": fujian["sourceSql"],
|
||||
"expectedSourceAddNeeded": bool(row.get("expectedSourceAddNeeded", True)),
|
||||
}
|
||||
|
||||
|
||||
def connect(conn_cfg: dict[str, Any] | None = None):
|
||||
cfg = conn_cfg or load_connection()
|
||||
missing = [key for key in ("user", "password") if not cfg.get(key)]
|
||||
if missing:
|
||||
raise RuntimeError(
|
||||
"M5 连接缺少 " + ", ".join(missing) + "。设 M5_USER/M5_PASSWORD,或写 fixtures/m5/connection.local.json"
|
||||
)
|
||||
import oracledb
|
||||
|
||||
dsn = f"{cfg['host']}:{cfg['port']}/{cfg['serviceName']}"
|
||||
return oracledb.connect(user=cfg["user"], password=cfg["password"], dsn=dsn)
|
||||
|
||||
|
||||
def lookup_one(conn_cfg: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
params = lookup_params()
|
||||
conn = connect(conn_cfg)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
params["sql"],
|
||||
goodsNameLike=params["goodsNameLike"],
|
||||
approvalNo=params["approvalNo"],
|
||||
spec=params["spec"],
|
||||
manufacturer=params["manufacturer"],
|
||||
entryId=params["entryId"],
|
||||
)
|
||||
rows = _rows_from_cursor(cur)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
goods_ids = [str(row.get("GOODSID") or "") for row in rows]
|
||||
return {
|
||||
"ok": len(rows) == 1 and goods_ids[0] == params["expectedGoodsId"],
|
||||
"expectedGoodsId": params["expectedGoodsId"],
|
||||
"hits": len(rows),
|
||||
"goodsIds": goods_ids,
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
|
||||
def _rows_from_cursor(cur) -> list[dict[str, Any]]:
|
||||
columns = [col[0] for col in cur.description]
|
||||
return [dict(zip(columns, row)) for row in cur.fetchall()]
|
||||
|
||||
|
||||
def query_priority_source(goods_id: str, conn_cfg: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
||||
fujian = _load_json(FUJIAN_PATH)
|
||||
conn = connect(conn_cfg)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(fujian["sourceSql"], goodsId=int(goods_id))
|
||||
return _rows_from_cursor(cur)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def check_priority_source(conn_cfg: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
params = lookup_params()
|
||||
rows = query_priority_source(params["expectedGoodsId"], conn_cfg)
|
||||
judged = evaluate_priority_source(
|
||||
rows,
|
||||
entry_id=params["entryId"],
|
||||
spec=params["spec"],
|
||||
manufacturer=params["manufacturer"],
|
||||
)
|
||||
judged["ok"] = judged["source_add_needed"] == params["expectedSourceAddNeeded"]
|
||||
judged["expectedSourceAddNeeded"] = params["expectedSourceAddNeeded"]
|
||||
judged["rows"] = rows
|
||||
return judged
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
match = lookup_one()
|
||||
source = check_priority_source() if match.get("ok") else {"skipped": True, "reason": "对码未命中,不查货源"}
|
||||
print(json.dumps({"match": match, "source": source}, ensure_ascii=False, default=str, indent=2))
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,215 +0,0 @@
|
|||
"""
|
||||
对码引擎(核心难点)。
|
||||
|
||||
数据源:
|
||||
- 货品主数据在 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
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
"""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,
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
# 对码入参底座(一条,福建货主)
|
||||
|
||||
订单明细走这里,不走证据包。本批只有一条:重庆亿昊 / 哈药牌钙铁锌口服液。
|
||||
|
||||
## M5
|
||||
|
||||
- 地址默认 `173.1.1.215:1521`,Service Name `m5`
|
||||
- 账号密码可配,不进 git:环境变量 `M5_USER` `M5_PASSWORD`,或复制 `m5/connection.example.json` → `m5/connection.local.json`
|
||||
- 查询:品名 LIKE + 文号 + 规格 + 厂家 + `ENTRYID=907`
|
||||
- 产地空或「无」不参与过滤
|
||||
- 回写 `goods_id` = `GOODSID`(`4000109983`)
|
||||
- 对码命中后再查货源:`bms_su_supplygoods_doc_v` × `bms_su_supplygoods_dtl_v`,`PRIORITY` 必须是 1,且 `ENTRYID/GOODSTYPE/FACTORYNAME` 对上这条规格。对上则跳过新增货源。
|
||||
|
||||
查这一条:
|
||||
|
||||
```bash
|
||||
export M5_USER=...
|
||||
export M5_PASSWORD=...
|
||||
python -m state_center.app.m5_client
|
||||
```
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
{
|
||||
"version": 1,
|
||||
"comment": "Excel 列 → 订单入参。本批只收一条。条码/医保不进 order_item,也不拿去查 M5。账号密码不写在这份文件里。",
|
||||
"sheet": "订单明细",
|
||||
"skipStatuses": ["已取消"],
|
||||
"jobGroupBy": ["supplierName", "orderDate", "sourceType"],
|
||||
"columns": {
|
||||
"父订单号": "parentOrderNo",
|
||||
"商家": "supplierName",
|
||||
"订单状态": "orderStatus",
|
||||
"品名": "rawProductName",
|
||||
"产品编码": "platformProductCode",
|
||||
"产品条码": "barcode",
|
||||
"医保编码": "insuranceCode",
|
||||
"批准文号": "approvalNo"
|
||||
},
|
||||
"orderItem": [
|
||||
"orderNo",
|
||||
"rawProductName",
|
||||
"platformProductCode",
|
||||
"spec",
|
||||
"manufacturer",
|
||||
"approvalNo",
|
||||
"qty",
|
||||
"sourceRowNo"
|
||||
],
|
||||
"m5Lookup": {
|
||||
"view": "PUB_ENTRY_GOODS_V",
|
||||
"goodsIdField": "GOODSID",
|
||||
"filters": {
|
||||
"GOODSNAME": { "from": "rawProductName", "op": "like" },
|
||||
"APPROVEDOCNO": { "from": "approvalNo", "op": "eq" },
|
||||
"GOODSTYPE": { "from": "spec", "op": "eq" },
|
||||
"FACTORYNAME": { "from": "manufacturer", "op": "eq" },
|
||||
"ENTRYID": { "from": "entryId", "op": "eq" }
|
||||
},
|
||||
"optionalFilters": {
|
||||
"PRODAREA": { "from": "prodArea", "skipValues": ["", "无", null] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"host": "173.1.1.215",
|
||||
"port": 1521,
|
||||
"serviceName": "m5",
|
||||
"user": "",
|
||||
"password": "",
|
||||
"schema": "m5",
|
||||
"comment": "复制为 connection.local.json 填账号密码。local 文件不进 git。也可用环境变量 M5_USER / M5_PASSWORD / M5_SERVICE。"
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"entryName": "福建",
|
||||
"entryId": 907,
|
||||
"host": "173.1.1.215",
|
||||
"port": 1521,
|
||||
"serviceName": "m5",
|
||||
"schema": "m5",
|
||||
"view": "PUB_ENTRY_GOODS_V",
|
||||
"goodsIdField": "GOODSID",
|
||||
"requireProdArea": false,
|
||||
"sql": "SELECT t.FACTORYNAME, t.APPROVEDOCNO, t.GOODSTYPE, t.PRODAREA, t.ENTRYID, t.GOODSID, t.GOODSNAME, t.GOODSUNIT, t.ENTRYGOODSID FROM PUB_ENTRY_GOODS_V t WHERE t.GOODSNAME LIKE :goodsNameLike AND t.APPROVEDOCNO = :approvalNo AND t.GOODSTYPE = :spec AND t.FACTORYNAME = :manufacturer AND t.ENTRYID = :entryId",
|
||||
"sourceSql": "SELECT t.ENTRYID, a.PRIORITY, t.GOODSTYPE, t.FACTORYNAME FROM bms_su_supplygoods_doc_v t, bms_su_supplygoods_dtl_v a WHERE t.GOODSID = :goodsId AND a.PRIORITY = 1 AND t.SUPPLYGOODSDOCID = a.SUPPLYGOODSDOCID"
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
{
|
||||
"sourceType": "YYC",
|
||||
"orderDate": "2026-08-04",
|
||||
"supplierName": "重庆亿昊药业有限公司",
|
||||
"m5": {
|
||||
"entryName": "福建",
|
||||
"entryId": 907
|
||||
},
|
||||
"row": {
|
||||
"orderNo": "YYC-ROW-2",
|
||||
"parentOrderNo": "",
|
||||
"rawProductName": "哈药牌钙铁锌口服液",
|
||||
"goodsNameLike": "哈药牌",
|
||||
"platformProductCode": "2919482534",
|
||||
"barcode": "6938780031263",
|
||||
"approvalNo": "食健备G201923000558",
|
||||
"spec": "10ml*90支",
|
||||
"manufacturer": "哈药集团三精制药有限公司",
|
||||
"prodArea": "无",
|
||||
"qty": 83,
|
||||
"orderStatus": "待付款",
|
||||
"sourceRowNo": 2,
|
||||
"expectedGoodsId": "4000109983",
|
||||
"expectedSourceAddNeeded": false
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,205 +0,0 @@
|
|||
-- 采购主链 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='事件流';
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
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)]
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
from state_center.app.m5_client import (
|
||||
evaluate_priority_source,
|
||||
load_connection,
|
||||
lookup_params,
|
||||
recorded_source_rows,
|
||||
)
|
||||
|
||||
|
||||
def test_lookup_params_use_the_single_fujian_row():
|
||||
params = lookup_params()
|
||||
assert params["goodsNameLike"] == "%哈药牌%"
|
||||
assert params["approvalNo"] == "食健备G201923000558"
|
||||
assert params["spec"] == "10ml*90支"
|
||||
assert params["manufacturer"] == "哈药集团三精制药有限公司"
|
||||
assert params["entryId"] == 907
|
||||
assert params["expectedGoodsId"] == "4000109983"
|
||||
assert "PUB_ENTRY_GOODS_V" in params["sql"]
|
||||
assert "PRODAREA" not in params["sql"].split("WHERE", 1)[1]
|
||||
|
||||
|
||||
def test_m5_host_defaults_and_password_stays_out_of_repo(monkeypatch):
|
||||
monkeypatch.delenv("M5_USER", raising=False)
|
||||
monkeypatch.delenv("M5_PASSWORD", raising=False)
|
||||
monkeypatch.delenv("M5_SERVICE", raising=False)
|
||||
cfg = load_connection()
|
||||
assert cfg["host"] == "173.1.1.215"
|
||||
assert cfg["port"] == 1521
|
||||
assert cfg["serviceName"] == "m5"
|
||||
assert cfg["password"] == ""
|
||||
|
||||
|
||||
def test_priority_1_matching_spec_skips_source_add():
|
||||
judged = evaluate_priority_source(
|
||||
[
|
||||
{
|
||||
"ENTRYID": 907,
|
||||
"PRIORITY": 1,
|
||||
"GOODSTYPE": "10ml*90支",
|
||||
"FACTORYNAME": "哈药集团三精制药有限公司",
|
||||
}
|
||||
],
|
||||
entry_id=907,
|
||||
spec="10ml*90支",
|
||||
manufacturer="哈药集团三精制药有限公司",
|
||||
)
|
||||
assert judged["source_add_needed"] is False
|
||||
|
||||
|
||||
def test_priority_1_other_spec_needs_source_add():
|
||||
judged = evaluate_priority_source(
|
||||
[
|
||||
{
|
||||
"ENTRYID": 907,
|
||||
"PRIORITY": 1,
|
||||
"GOODSTYPE": "10ml*10支",
|
||||
"FACTORYNAME": "哈药集团三精制药有限公司",
|
||||
}
|
||||
],
|
||||
entry_id=907,
|
||||
spec="10ml*90支",
|
||||
manufacturer="哈药集团三精制药有限公司",
|
||||
)
|
||||
assert judged["source_add_needed"] is True
|
||||
|
||||
|
||||
def test_lookup_params_include_source_sql():
|
||||
params = lookup_params()
|
||||
assert "bms_su_supplygoods_doc_v" in params["sourceSql"]
|
||||
assert params["expectedSourceAddNeeded"] is False
|
||||
|
||||
|
||||
def test_recorded_source_rows_cover_hayao_without_m5():
|
||||
rows = recorded_source_rows("4000109983")
|
||||
assert rows is not None
|
||||
judged = evaluate_priority_source(
|
||||
rows,
|
||||
entry_id=907,
|
||||
spec="10ml*90支",
|
||||
manufacturer="哈药集团三精制药有限公司",
|
||||
)
|
||||
assert judged["source_add_needed"] is False
|
||||
assert recorded_source_rows("other") is None
|
||||
|
||||
|
||||
def test_hayao_priority1_sql_row_skips_source_add():
|
||||
"""第二条 SQL 的真实结果:907 / 1 / 10ml*90支 / 哈药集团三精制药有限公司。"""
|
||||
judged = evaluate_priority_source(
|
||||
[
|
||||
{
|
||||
"ENTRYID": 907,
|
||||
"PRIORITY": 1,
|
||||
"GOODSTYPE": "10ml*90支",
|
||||
"FACTORYNAME": "哈药集团三精制药有限公司",
|
||||
}
|
||||
],
|
||||
entry_id=907,
|
||||
spec="10ml*90支",
|
||||
manufacturer="哈药集团三精制药有限公司",
|
||||
)
|
||||
assert judged["source_add_needed"] is False
|
||||
assert judged["hit"]["ENTRYID"] == 907
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
"""对码引擎单测:不依赖 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_fujian_hayao_exact_from_m5_row():
|
||||
eng = MatchEngine()
|
||||
eng.load_goods(
|
||||
[
|
||||
GoodsRecord(
|
||||
"哈药牌钙铁锌口服液",
|
||||
"食健备G201923000558",
|
||||
"10ml*90支",
|
||||
"盒",
|
||||
"哈药集团三精制药有限公司",
|
||||
"4000109983",
|
||||
"4000109983",
|
||||
)
|
||||
]
|
||||
)
|
||||
r = eng.match_one(
|
||||
1,
|
||||
"哈药牌钙铁锌口服液",
|
||||
"食健备G201923000558",
|
||||
"10ml*90支",
|
||||
"哈药集团三精制药有限公司",
|
||||
)
|
||||
assert r.match_status == "EXACT"
|
||||
assert r.goods_id == "4000109983"
|
||||
|
||||
|
||||
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")
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,23 +0,0 @@
|
|||
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"
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
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"
|
||||
|
|
@ -1,180 +0,0 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from rpa_yisaiqi.apa_browser_adapter import ApaBrowserAdapter
|
||||
from rpa_yisaiqi.contract_create_node import (
|
||||
ContractCreateError,
|
||||
ContractCreateInput,
|
||||
PdfMergeUnsupported,
|
||||
build_spec_from_data,
|
||||
create_contract_in_contract_system,
|
||||
)
|
||||
from rpa_yisaiqi.contract_draft import is_placeholder_customer_code, 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_placeholder_customer_code_is_rejected_before_opening_browser():
|
||||
assert is_placeholder_customer_code("CUST-YYC-001")
|
||||
assert is_placeholder_customer_code("test-001")
|
||||
assert not is_placeholder_customer_code("DW0100046361")
|
||||
|
||||
with pytest.raises(ContractCreateError, match="占位符"):
|
||||
build_spec_from_data(
|
||||
{
|
||||
"jobId": "JOB-1",
|
||||
"supplierName": "重庆亿昊药业有限公司",
|
||||
"orderDate": "2026-08-11",
|
||||
"sourceType": "XYY",
|
||||
"customerCode": "CUST-YYC-001",
|
||||
"customerName": "重庆亿昊药业有限公司",
|
||||
"amount": "100",
|
||||
"submitMode": "dry_run",
|
||||
"goodsLines": [
|
||||
{
|
||||
"goodsCode": "YP-001",
|
||||
"goodsName": "阿卡波糖片",
|
||||
"quantity": 1,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
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_create_contract_uses_apa_browser_adapter(tmp_path, monkeypatch):
|
||||
draft_path = tmp_path / "draft.json"
|
||||
draft_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"supplierName": "重庆亿昊药业有限公司",
|
||||
"orderDate": "2026-08-11",
|
||||
"sourceType": "XYY",
|
||||
"customerCode": "DW0100046361",
|
||||
"customerName": "重庆亿昊药业有限公司",
|
||||
"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),
|
||||
)
|
||||
captured = {}
|
||||
|
||||
def fake_run(browser, ctx):
|
||||
captured["browser"] = browser
|
||||
captured["ctx"] = ctx
|
||||
return "HT-1"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"rpa_yisaiqi.contract_create_node.run_contract_create_flow",
|
||||
fake_run,
|
||||
)
|
||||
|
||||
assert create_contract_in_contract_system(spec) == "HT-1"
|
||||
assert isinstance(captured["browser"], ApaBrowserAdapter)
|
||||
|
|
@ -1,490 +0,0 @@
|
|||
import pytest
|
||||
|
||||
from rpa_yisaiqi.contract_flow_steps import (
|
||||
ContractFlowContext,
|
||||
ContractGoodsLine,
|
||||
fill_contract_signatory_info,
|
||||
run_contract_create_flow,
|
||||
)
|
||||
from rpa_yisaiqi.runtime.errors import SupplierContractDisabled
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_sleep(monkeypatch):
|
||||
monkeypatch.setattr("rpa_yisaiqi.contract_flow_steps.time.sleep", lambda _s: None)
|
||||
|
||||
|
||||
class FakeBrowser:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.body_text = ""
|
||||
self.xform_labels = {}
|
||||
self.filled = {}
|
||||
self.read_values = {
|
||||
"contract_number": "HT-20260811-REAL-001",
|
||||
}
|
||||
|
||||
def open_url(self, url):
|
||||
self.calls.append(("open_url", url))
|
||||
if "kmAgreementApply" in url or "draft.jsp" in url:
|
||||
self.body_text = "客户编码 客户名称 合同类别"
|
||||
|
||||
def fill_first(self, selectors, value):
|
||||
self.calls.append(("fill_first", tuple(selectors), value))
|
||||
for selector in selectors:
|
||||
self.filled[selector] = value
|
||||
|
||||
def click_native(self, selectors):
|
||||
self.calls.append(("click_native", tuple(selectors)))
|
||||
|
||||
def click_first(self, selectors):
|
||||
self.calls.append(("click_first", tuple(selectors)))
|
||||
joined = " ".join(str(item) for item in selectors)
|
||||
if "login" in joined or "登录" in joined:
|
||||
self.body_text = "切换门户 待办 待阅 合同管理 合同起草"
|
||||
|
||||
def wait_for_text(self, text, timeout_seconds=15):
|
||||
self.calls.append(("wait_for_text", text, timeout_seconds))
|
||||
if text and text not in self.body_text:
|
||||
self.body_text = f"{self.body_text} {text}".strip()
|
||||
|
||||
def wait_for_element(self, selectors, timeout_seconds=15):
|
||||
self.calls.append(("wait_for_element", tuple(selectors), timeout_seconds))
|
||||
|
||||
def element_exists(self, selectors):
|
||||
self.calls.append(("element_exists", tuple(selectors)))
|
||||
return True
|
||||
|
||||
def switch_to_frame(self, selectors):
|
||||
self.calls.append(("switch_to_frame", tuple(selectors)))
|
||||
|
||||
def switch_to_default_content(self):
|
||||
self.calls.append(("switch_to_default_content",))
|
||||
|
||||
def select_first(self, selectors, value):
|
||||
self.calls.append(("select_first", tuple(selectors), value))
|
||||
for selector in selectors:
|
||||
self.filled[selector] = value
|
||||
|
||||
def select_xform(self, name, value):
|
||||
self.calls.append(("select_xform", name, value))
|
||||
self.xform_labels[name] = value
|
||||
|
||||
def execute_script(self, script, *args):
|
||||
self.calls.append(("execute_script", script[:48], args))
|
||||
if "hasTextInFrames" in script:
|
||||
return True
|
||||
if "latestByApplyTime" in script and "headerIdx" in script:
|
||||
return "001132026081734940"
|
||||
if "wantedTitle" in script and "headerIdx" in script:
|
||||
return "001132026081734940"
|
||||
if "fd_opcode" in script and "return i" in script:
|
||||
return 0
|
||||
if "List_Selected" in script and "innerText" in script:
|
||||
token = str(args[0]) if args else ""
|
||||
if token.startswith("DW"):
|
||||
self.filled['input[name="extendDataFormInfo.value(fd_customeropcode)"]'] = token
|
||||
elif token:
|
||||
self.filled['input[name="extendDataFormInfo.value(fd_detail1.0.fd_opcode)"]'] = token
|
||||
if "box.click" in script:
|
||||
return {"ok": True}
|
||||
return True
|
||||
if "marked" in script and "fd_detail1" in script:
|
||||
return []
|
||||
if "return location.href" in script:
|
||||
return "http://contract-test.gzmpc.com:8080/km/agreement/km_agreement_apply/kmAgreementApply.do?method=view"
|
||||
if "Com_Parameter" in script and "fdId" in script:
|
||||
return "FD-TEST-001"
|
||||
if "添加合同" in script and ("pdf" in script or "wantedStem" in script):
|
||||
if any(call[0] == "upload_file_first" for call in self.calls):
|
||||
return {"ok": True, "via": "name"}
|
||||
return {"ok": False, "via": "empty"}
|
||||
if "提交" in script or "合同正文" in script or "确定" in script:
|
||||
return {"ok": True}
|
||||
if "fs-label" in script and args:
|
||||
return self.xform_labels.get(args[0], "")
|
||||
if "data-value" in script:
|
||||
return "模板A"
|
||||
return []
|
||||
|
||||
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))
|
||||
if value_name in self.read_values:
|
||||
return self.read_values[value_name]
|
||||
for selector in selectors:
|
||||
if selector in self.filled:
|
||||
return self.filled[selector]
|
||||
joined = " ".join(str(item) for item in selectors)
|
||||
if "fd_customername" in joined:
|
||||
return self.read_values.get("customer_name", "重庆亿昊药业有限公司")
|
||||
if "fd_customeropcode" in joined:
|
||||
return self.read_values.get("customer_code", self.filled.get(
|
||||
'input[name="extendDataFormInfo.value(fd_customeropcode)"]', ""
|
||||
))
|
||||
if "fdSettype" in joined:
|
||||
return self.read_values.get("settlement_type", "付款")
|
||||
return ""
|
||||
|
||||
def read_body_text(self):
|
||||
self.calls.append(("read_body_text",))
|
||||
return self.body_text
|
||||
|
||||
def screenshot(self, name):
|
||||
self.calls.append(("screenshot", name))
|
||||
return f"/tmp/{name}.png"
|
||||
|
||||
|
||||
def test_signatory_rejects_placeholder_customer_before_opening_picker():
|
||||
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",
|
||||
customer_code="CUST-YYC-001",
|
||||
customer_name="重庆亿昊药业有限公司",
|
||||
)
|
||||
|
||||
with pytest.raises(SupplierContractDisabled, match="占位符"):
|
||||
fill_contract_signatory_info(browser, ctx)
|
||||
|
||||
assert browser.calls == []
|
||||
|
||||
|
||||
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",
|
||||
customer_code="DW0100046361",
|
||||
customer_name="重庆亿昊药业有限公司",
|
||||
goods_lines=[],
|
||||
)
|
||||
|
||||
run_contract_create_flow(browser, ctx)
|
||||
|
||||
assert ("open_url", "http://contract-test.gzmpc.com:8080/") in browser.calls
|
||||
assert ("fill_first", ('input[name="j_username"]', 'input[placeholder="用户名"]'), "test002") in browser.calls
|
||||
assert ("fill_first", ('input[name="j_password"]', 'input[placeholder="密码"]'), "1") in browser.calls
|
||||
assert (
|
||||
"click_first",
|
||||
('a[class*="login_button"]', 'a:contains("登录")', 'input[name="btn_submit"]'),
|
||||
) in browser.calls
|
||||
assert not [call for call in browser.calls if call[0] == "screenshot"]
|
||||
|
||||
|
||||
def _touch_pdf(path):
|
||||
path.write_bytes(b"%PDF-1.4\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF\n")
|
||||
return str(path)
|
||||
|
||||
|
||||
def test_contract_flow_uses_order_date_and_uploads_yyc_merged_pdf(tmp_path):
|
||||
browser = FakeBrowser()
|
||||
browser.read_values["customer_name"] = "重庆亿昊药业有限公司"
|
||||
pdf_path = _touch_pdf(tmp_path / "contract-merged.pdf")
|
||||
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",
|
||||
customer_code="DW0100046361",
|
||||
customer_name="重庆亿昊药业有限公司",
|
||||
goods_lines=[
|
||||
ContractGoodsLine(
|
||||
operation_code="ADD",
|
||||
goods_code="FQX0100118047",
|
||||
goods_name="盐酸托烷司琼注射液",
|
||||
approval_no="苏20160372",
|
||||
spec="5ml:5mg*5支",
|
||||
unit="盒",
|
||||
quantity=12,
|
||||
unit_price="50.00",
|
||||
)
|
||||
],
|
||||
merged_pdf_path=pdf_path,
|
||||
)
|
||||
|
||||
contract_no = run_contract_create_flow(browser, ctx)
|
||||
|
||||
assert contract_no == "001132026081734940"
|
||||
assert ("fill_first", ('input[name="fdSigndate"]',), "2026-08-11") in browser.calls
|
||||
assert ("fill_first", ('input[name="fdEffedate"]',), "2026-08-11") in browser.calls
|
||||
assert (
|
||||
"click_first",
|
||||
('div[title="选择商品"]', "div.lui-component.lui_widget_btn_txt", "text=选择商品"),
|
||||
) in browser.calls
|
||||
assert (
|
||||
"switch_to_frame",
|
||||
(
|
||||
'iframe[src*="EopBasedataMaterial"]',
|
||||
'iframe[src*="dialog_fdMaterial"]',
|
||||
'iframe[src*="common-template"]',
|
||||
),
|
||||
) in browser.calls
|
||||
assert ("fill_first", ("input.search-box-text", "input[placeholder*='关键字']"), "FQX0100118047") in browser.calls
|
||||
assert (
|
||||
"fill_first",
|
||||
('input[name="extendDataFormInfo.value(fd_detail1.0.fd_goodsqty)"]',),
|
||||
"12",
|
||||
) in browser.calls
|
||||
assert (
|
||||
"fill_first",
|
||||
('input[name="extendDataFormInfo.value(fd_detail1.0.fd_unitprice)"]',),
|
||||
"50.00",
|
||||
) in browser.calls
|
||||
assert any(
|
||||
call[0] == "upload_file_first" and call[-1] == pdf_path
|
||||
for call in browser.calls
|
||||
)
|
||||
|
||||
|
||||
def test_contract_flow_skips_pdf_upload_for_xyy():
|
||||
browser = FakeBrowser()
|
||||
browser.read_values["customer_name"] = "重庆亿昊药业有限公司"
|
||||
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",
|
||||
customer_code="DW0100046361",
|
||||
customer_name="重庆亿昊药业有限公司",
|
||||
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_draft_form_by_template_url(tmp_path):
|
||||
from rpa_yisaiqi.contract_flow_steps import FORM_PAGE_URL
|
||||
|
||||
browser = FakeBrowser()
|
||||
browser.read_values["customer_name"] = "重庆亿昊药业有限公司"
|
||||
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="非格式--采购协议",
|
||||
customer_code="DW0100046361",
|
||||
customer_name="重庆亿昊药业有限公司",
|
||||
goods_lines=[],
|
||||
merged_pdf_path=_touch_pdf(tmp_path / "contract-merged.pdf"),
|
||||
)
|
||||
|
||||
run_contract_create_flow(browser, ctx)
|
||||
|
||||
assert ("open_url", FORM_PAGE_URL) in browser.calls
|
||||
assert ("wait_for_text", "客户编码", 20) in browser.calls
|
||||
|
||||
|
||||
def test_contract_flow_fills_real_contract_draft_form_fields_from_screenshot():
|
||||
browser = FakeBrowser()
|
||||
browser.read_values["customer_name"] = "重庆亿昊药业有限公司"
|
||||
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="DW0100046361",
|
||||
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 (
|
||||
"select_xform",
|
||||
"extendDataFormInfo.value(fd_contract_cate_type)",
|
||||
"其他协议(采购)",
|
||||
) in browser.calls
|
||||
assert ("select_first", ('select#fdSettype',), "付款") in browser.calls
|
||||
assert (
|
||||
"fill_first",
|
||||
('textarea[name="extendDataFormInfo.value(fd_desc_info)"]',),
|
||||
"按订单自动起草采购协议",
|
||||
) in browser.calls
|
||||
assert ("select_xform", "extendDataFormInfo.value(fd_all_amount_type)", "合同标的金额") in browser.calls
|
||||
assert ("fill_first", ('input[name="fdAccount"]',), "12000.50") in browser.calls
|
||||
assert ("fill_first", ('input[name="fdSigndate"]',), "2026-08-11") in browser.calls
|
||||
assert ("fill_first", ('input[name="fdEffedate"]',), "2026-08-11") in browser.calls
|
||||
assert ("fill_first", ('input[name="fdEnddate"]',), "2026-12-31") in browser.calls
|
||||
assert ("select_xform", "extendDataFormInfo.value(fd_is_major_contract)", "否") in browser.calls
|
||||
assert ("select_xform", "extendDataFormInfo.value(fd_advance_amout)", "否") in browser.calls
|
||||
assert (
|
||||
"fill_first",
|
||||
('input[name="extendDataFormInfo.value(fd_contract_year_text)"]',),
|
||||
"2026",
|
||||
) in browser.calls
|
||||
assert ("select_xform", "extendDataFormInfo.value(fd_paytype)", "电汇") in browser.calls
|
||||
assert (
|
||||
"fill_first",
|
||||
('input[name="extendDataFormInfo.value(fd_pay_time_limit)"]',),
|
||||
"30",
|
||||
) in browser.calls
|
||||
assert ("select_xform", "extendDataFormInfo.value(fd_suconstatus)", "正式合同") in browser.calls
|
||||
assert ("select_xform", "extendDataFormInfo.value(fd_3d1ce3953f1162)", "否") in browser.calls
|
||||
|
||||
|
||||
def test_contract_flow_dry_run_skips_submit_but_still_reads_page_state():
|
||||
browser = FakeBrowser()
|
||||
browser.read_values["customer_name"] = "重庆亿昊药业有限公司"
|
||||
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="DW0100046361",
|
||||
customer_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 not [call for call in browser.calls if call[0] == "screenshot"]
|
||||
assert contract_no == ""
|
||||
|
||||
|
||||
def test_contract_flow_writes_text_accept_report_instead_of_requiring_screenshots(tmp_path, monkeypatch):
|
||||
from rpa_yisaiqi.contract_flow_steps import _ACCEPT, accept_log_path
|
||||
|
||||
monkeypatch.setenv("RPA_ACCEPT_LOG", str(tmp_path / "ACCEPT.txt"))
|
||||
browser = FakeBrowser()
|
||||
browser.read_values["customer_name"] = "重庆亿昊药业有限公司"
|
||||
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",
|
||||
customer_code="DW0100046361",
|
||||
customer_name="重庆亿昊药业有限公司",
|
||||
submit_mode="dry_run",
|
||||
goods_lines=[],
|
||||
)
|
||||
|
||||
run_contract_create_flow(browser, ctx)
|
||||
|
||||
text = accept_log_path("CONTRACT_CREATE").read_text(encoding="utf-8")
|
||||
assert "ACCEPT SUMMARY" in text
|
||||
assert "ACCEPT S03 ok" in text
|
||||
assert "ACCEPT S06 ok" in text
|
||||
assert "customerName=重庆亿昊药业有限公司" in text
|
||||
assert "ACCEPT S12 ok" in text
|
||||
assert all(line.ok for line in _ACCEPT.lines)
|
||||
|
||||
|
||||
def test_read_contract_number_falls_back_to_my_contracts_list():
|
||||
from rpa_yisaiqi.contract_flow_steps import read_contract_number
|
||||
|
||||
browser = FakeBrowser()
|
||||
browser.read_values.pop("contract_number", None)
|
||||
ctx = ContractFlowContext(
|
||||
contract_url="http://contract-test.gzmpc.com:8080/",
|
||||
username="test002",
|
||||
password="1",
|
||||
supplier_name="重庆亿昊药业有限公司",
|
||||
order_date="2026-08-11",
|
||||
source_type="XYY",
|
||||
customer_code="DW0100046361",
|
||||
customer_name="重庆亿昊药业有限公司",
|
||||
contract_title="test1",
|
||||
goods_lines=[],
|
||||
)
|
||||
|
||||
contract_no = read_contract_number(browser, ctx)
|
||||
|
||||
assert contract_no == "001132026081734940"
|
||||
assert ("open_url", "http://contract-test.gzmpc.com:8080/") in browser.calls
|
||||
assert ("click_first", ("text=我的合同",)) in browser.calls
|
||||
assert ("click_first", ("text=我起草的",)) in browser.calls
|
||||
assert ("click_first", ("text=申请时间",)) in browser.calls
|
||||
|
||||
|
||||
def test_read_contract_number_goes_to_my_list_even_without_ctx_arg():
|
||||
from rpa_yisaiqi import _flow_state as state
|
||||
from rpa_yisaiqi.contract_flow_steps import read_contract_number
|
||||
|
||||
browser = FakeBrowser()
|
||||
browser.read_values.pop("contract_number", None)
|
||||
old_ctx = state.ctx
|
||||
state.ctx = ContractFlowContext(
|
||||
contract_url="http://contract-test.gzmpc.com:8080/",
|
||||
username="test002",
|
||||
password="1",
|
||||
supplier_name="重庆亿昊药业有限公司",
|
||||
order_date="2026-08-11",
|
||||
source_type="XYY",
|
||||
customer_code="DW0100046361",
|
||||
customer_name="重庆亿昊药业有限公司",
|
||||
contract_title="test1",
|
||||
goods_lines=[],
|
||||
)
|
||||
try:
|
||||
contract_no = read_contract_number(browser)
|
||||
finally:
|
||||
state.ctx = old_ctx
|
||||
|
||||
assert contract_no == "001132026081734940"
|
||||
assert ("click_first", ("text=我的合同",)) in browser.calls
|
||||
assert ("click_first", ("text=申请时间",)) in browser.calls
|
||||
|
||||
|
||||
def test_accept_check_fails_when_page_value_mismatches():
|
||||
from rpa_yisaiqi.contract_flow_steps import AcceptanceReport
|
||||
from rpa_yisaiqi.runtime.errors import NodeError
|
||||
|
||||
report = AcceptanceReport()
|
||||
with pytest.raises(NodeError, match="ACCEPT S06 FAIL"):
|
||||
report.check(
|
||||
"S06",
|
||||
"签约方",
|
||||
[("customerName", "其他公司", "重庆亿昊药业有限公司")],
|
||||
)
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
# 给 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 模型。
|
||||
|
||||
## 九、验收总纲
|
||||
|
||||
每做完一个节点/接口,打开 `实施与验收规范` 第四部分,对着该节点的**代码验收**(你自检)和**业务验收**(交业务勾)两栏逐条过。全绿才算完。
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
# 给艺赛旗(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 三段分别单独联调通过后,再跑整链。
|
||||
|
||||
|
||||
|
|
@ -1,207 +0,0 @@
|
|||
# 状态中心页面 - 可调整版式方案
|
||||
|
||||
## 设计原则
|
||||
|
||||
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. 调整样式,让收起状态下不占垂直空间。
|
||||
|
||||
或者你对分区划分 / 默认展开状态有别的想法,直接说,我再调整。
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
# 状态中心 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 接口。
|
||||
- 后端没起时,页面自动切到本地演示数据,仍可看到单据列表、流程状态图和待办面板。
|
||||
- 页面顶部会明确显示“本地演示数据”或“后端已连接”。
|
||||
- 对码待办可直接选择候选货品;人工节点按节点显示对应动作。
|
||||
- 节点回调模拟区只在开发环境构建中显示。
|
||||
- 开发令牌只保存在浏览器会话里,不会写进代码。
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
<!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
|
|
@ -1,24 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,49 +0,0 @@
|
|||
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);
|
||||
},
|
||||
};
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
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];
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
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 || '-';
|
||||
}
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
import { jobStatusLabel, roleLabel, sourceTypeLabel, nodeLabel, type JobRow, type StateMachineMeta } from '../stateMachine';
|
||||
|
||||
type Props = {
|
||||
meta: StateMachineMeta | null;
|
||||
jobs: JobRow[];
|
||||
selectedJobId: string;
|
||||
query?: string;
|
||||
taskJobIds?: Set<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';
|
||||
}
|
||||
|
||||
function Highlight({ text, query }: { text: string; query?: string }) {
|
||||
const q = query?.trim();
|
||||
if (!q) return <>{text}</>;
|
||||
const lower = text.toLowerCase();
|
||||
const needle = q.toLowerCase();
|
||||
const parts: Array<string | { mark: string }> = [];
|
||||
let start = 0;
|
||||
let index = lower.indexOf(needle);
|
||||
if (index < 0) return <>{text}</>;
|
||||
while (index >= 0) {
|
||||
if (index > start) parts.push(text.slice(start, index));
|
||||
parts.push({ mark: text.slice(index, index + q.length) });
|
||||
start = index + q.length;
|
||||
index = lower.indexOf(needle, start);
|
||||
}
|
||||
if (start < text.length) parts.push(text.slice(start));
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) =>
|
||||
typeof part === 'string' ? <span key={i}>{part}</span> : <mark key={i}>{part.mark}</mark>,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function JobList({ meta, jobs, selectedJobId, query, taskJobIds, onSelect }: Props) {
|
||||
function moveSelection(direction: 1 | -1) {
|
||||
if (!jobs.length) return;
|
||||
const current = jobs.findIndex((job) => job.job_id === selectedJobId);
|
||||
const nextIndex = current < 0
|
||||
? 0
|
||||
: Math.min(jobs.length - 1, Math.max(0, current + direction));
|
||||
const next = jobs[nextIndex];
|
||||
if (!next) return;
|
||||
onSelect(next.job_id);
|
||||
window.requestAnimationFrame(() => {
|
||||
document.getElementById(`job-item-${next.job_id}`)?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel job-list-panel">
|
||||
<div className="panel-title">单据 · {jobs.length}</div>
|
||||
<div className="job-list" role="listbox" aria-label="单据列表">
|
||||
{jobs.map((job) => {
|
||||
const selected = job.job_id === selectedJobId;
|
||||
const hasTask = taskJobIds?.has(job.job_id);
|
||||
return (
|
||||
<button
|
||||
key={job.job_id}
|
||||
id={`job-item-${job.job_id}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
tabIndex={selected ? 0 : -1}
|
||||
className={`job-list-item${selected ? ' is-selected' : ''}${hasTask ? ' has-task' : ''}`}
|
||||
onClick={() => onSelect(job.job_id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
moveSelection(1);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
moveSelection(-1);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="job-list-top">
|
||||
<span className="cell-title">
|
||||
<Highlight text={job.supplier_name} query={query} />
|
||||
</span>
|
||||
<span className={`status-tag ${statusClass(job.status)}`}>
|
||||
{jobStatusLabel(job.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="cell-sub">
|
||||
<Highlight text={job.job_id} query={query} />
|
||||
</div>
|
||||
<div className="job-list-meta">
|
||||
<span>{job.order_date}</span>
|
||||
<span>{sourceTypeLabel(job.source_type)}</span>
|
||||
<span>{nodeLabel(meta, job.current_node_code)}</span>
|
||||
<span>{roleLabel(job.current_role)}</span>
|
||||
{hasTask ? <span className="job-task-flag">待办</span> : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{jobs.length === 0 ? (
|
||||
<div className="empty">{query?.trim() ? '没有匹配的单据。' : '没有可展示的单据。'}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
import {
|
||||
artifactTypeLabel,
|
||||
formatDateTime,
|
||||
nodeLabel,
|
||||
reasonLabel,
|
||||
statusLabel,
|
||||
type NodeDetail,
|
||||
type StateMachineMeta,
|
||||
} from '../stateMachine';
|
||||
|
||||
type Props = {
|
||||
meta: StateMachineMeta | null;
|
||||
nodes: NodeDetail[];
|
||||
focusedNodeCode?: string;
|
||||
onTaskSelect(taskId: string): void;
|
||||
onArtifactClick(storagePath: string): void;
|
||||
};
|
||||
|
||||
export function JobNodeDetailList({ meta, nodes, focusedNodeCode, 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}
|
||||
id={`node-detail-${node.node_code}`}
|
||||
className={`node-detail-card status-${node.display_status.toLowerCase()}${
|
||||
focusedNodeCode === node.node_code ? ' is-focused' : ''
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import { executorLabel, nodeLabel, statusLabel, type StateMachineMeta, type TimelineNode } from '../stateMachine';
|
||||
|
||||
type Props = {
|
||||
meta: StateMachineMeta | null;
|
||||
timeline: TimelineNode[];
|
||||
currentNodeCode?: string;
|
||||
focusedNodeCode?: string;
|
||||
onSelect?(nodeCode: string): void;
|
||||
};
|
||||
|
||||
export function JobTimeline({ meta, timeline, currentNodeCode, focusedNodeCode, onSelect }: Props) {
|
||||
return (
|
||||
<div className="panel">
|
||||
<div className="panel-title">流程状态图</div>
|
||||
<div className="timeline">
|
||||
{timeline.map((node) => {
|
||||
const current = node.node_code === currentNodeCode;
|
||||
const focused = node.node_code === focusedNodeCode;
|
||||
const className = [
|
||||
'timeline-node',
|
||||
`status-${node.display_status.toLowerCase()}`,
|
||||
current ? 'is-current' : '',
|
||||
focused ? 'is-focused' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
return (
|
||||
<button
|
||||
key={node.node_code}
|
||||
type="button"
|
||||
className={className}
|
||||
title={onSelect ? '查看节点详情' : undefined}
|
||||
onClick={() => onSelect?.(node.node_code)}
|
||||
>
|
||||
<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>
|
||||
{current ? <div className="timeline-node-flag">当前</div> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
import { actionLabel, formatDateTime, nodeLabel, reasonLabel, roleLabel, taskStatusLabel, type TaskRow, type StateMachineMeta } from '../stateMachine';
|
||||
|
||||
type Props = {
|
||||
meta: StateMachineMeta | null;
|
||||
tasks: TaskRow[];
|
||||
selectedTaskId: string;
|
||||
selectedJobId?: string;
|
||||
onSelect(taskId: string): void;
|
||||
};
|
||||
|
||||
export function TaskList({ meta, tasks, selectedTaskId, selectedJobId, 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) => {
|
||||
const selected = task.task_id === selectedTaskId;
|
||||
const currentJob = Boolean(selectedJobId && task.job_id === selectedJobId);
|
||||
return (
|
||||
<tr
|
||||
key={task.task_id}
|
||||
className={`${selected ? 'is-selected' : ''}${currentJob && !selected ? ' is-current-job' : ''}`}
|
||||
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>
|
||||
<div className="cell-sub">{task.job_id}</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
|
||||
export type ToolbarSelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
options: ToolbarSelectOption[];
|
||||
ariaLabel: string;
|
||||
onChange(value: string): void;
|
||||
};
|
||||
|
||||
export function ToolbarSelect({ value, options, ariaLabel, onChange }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const listId = useId();
|
||||
const selected = options.find((option) => option.value === value) || options[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onPointer(event: MouseEvent) {
|
||||
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
|
||||
}
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
|
||||
event.preventDefault();
|
||||
const idx = options.findIndex((option) => option.value === value);
|
||||
const nextIndex = event.key === 'ArrowDown'
|
||||
? Math.min(options.length - 1, Math.max(0, idx + 1))
|
||||
: Math.max(0, idx < 0 ? 0 : idx - 1);
|
||||
const next = options[nextIndex];
|
||||
if (next) onChange(next.value);
|
||||
}
|
||||
document.addEventListener('mousedown', onPointer);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointer);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open, options, value, onChange]);
|
||||
|
||||
return (
|
||||
<div className={`toolbar-select${open ? ' is-open' : ''}`} ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="toolbar-select-trigger"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-controls={listId}
|
||||
aria-label={ariaLabel}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
<span>{selected?.label}</span>
|
||||
<svg className="toolbar-select-chevron" width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 4.5L6 7.5L9 4.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{open ? (
|
||||
<ul className="toolbar-select-menu" id={listId} role="listbox" aria-label={ariaLabel}>
|
||||
{options.map((option) => {
|
||||
const active = option.value === value;
|
||||
return (
|
||||
<li key={option.value} role="presentation">
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
className={active ? 'toolbar-select-option is-selected' : 'toolbar-select-option'}
|
||||
onClick={() => {
|
||||
onChange(option.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
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>,
|
||||
);
|
||||
|
|
@ -1,288 +0,0 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
actionLabel,
|
||||
allowedTaskActions,
|
||||
buildBusinessOverview,
|
||||
buildAcceptanceChecklist,
|
||||
buildJobReferenceChain,
|
||||
buildNodeCallbackRequestBody,
|
||||
buildNodeDetails,
|
||||
buildNodeCallbackTemplate,
|
||||
jobStatusBucketLabel,
|
||||
jobStatusBucketMatches,
|
||||
jobMatchesQuery,
|
||||
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 job = {
|
||||
job_id: 'YYC-重庆亿昊药业-20260804-demo',
|
||||
supplier_name: '重庆亿昊药业',
|
||||
order_date: '2026-08-04',
|
||||
source_type: 'YYC',
|
||||
status: 'WAITING_USER',
|
||||
current_node_code: 'SOURCE_ADD',
|
||||
current_role: 'PURCHASER',
|
||||
};
|
||||
const meta = { node_labels: { SOURCE_ADD: '新增货源' } };
|
||||
assert.equal(jobMatchesQuery(job, ''), true);
|
||||
assert.equal(jobMatchesQuery(job, ' 亿昊 '), true);
|
||||
assert.equal(jobMatchesQuery(job, 'YYC-重庆'), true);
|
||||
assert.equal(jobMatchesQuery(job, '医药城'), true);
|
||||
assert.equal(jobMatchesQuery(job, '等待人工'), true);
|
||||
assert.equal(jobMatchesQuery(job, '新增货源', meta), true);
|
||||
assert.equal(jobMatchesQuery(job, '华润'), false);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
|
@ -1,772 +0,0 @@
|
|||
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 jobMatchesQuery(job: JobRow, query: string, meta: StateMachineMeta | null = null): boolean {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
const haystack = [
|
||||
job.job_id,
|
||||
job.supplier_name,
|
||||
job.order_date,
|
||||
job.source_type,
|
||||
sourceTypeLabel(job.source_type),
|
||||
job.status,
|
||||
jobStatusLabel(job.status),
|
||||
job.current_node_code,
|
||||
nodeLabel(meta, job.current_node_code),
|
||||
job.current_role || '',
|
||||
roleLabel(job.current_role),
|
||||
job.order_no_list_json || '',
|
||||
job.contract_system_no || '',
|
||||
job.rebate_order_no || '',
|
||||
job.m5_order_no || '',
|
||||
job.receipt_notice_no || '',
|
||||
]
|
||||
.join('\n')
|
||||
.toLowerCase();
|
||||
return haystack.includes(q);
|
||||
}
|
||||
|
||||
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
|
|
@ -1 +0,0 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
{
|
||||
"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": []
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
195
艺赛旗开发包-README.md
195
艺赛旗开发包-README.md
|
|
@ -1,195 +0,0 @@
|
|||
# 艺赛旗开发包说明
|
||||
|
||||
这个包包含艺赛旗(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
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
祝开发顺利!有问题随时联系状态中心负责人。
|
||||
Loading…
Reference in New Issue