chore: 去掉 state_center 旧栈,根目录只留 oms 与四个作业包

删除 state_center、state_center_web、rpa_mock、scripts 及并行开发残留。
oms 运行时只走 Oracle,内存仓库留给单测注入。
This commit is contained in:
Yuuuu 2026-10-03 10:20:54 +08:00
parent d7b3ec48e6
commit 60c2d6d0f0
56 changed files with 2 additions and 12823 deletions

View File

@ -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 天一起联调。
---
要我把这份文档发给艺赛旗吗?或者你先看看有没有要调整的。

View File

@ -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/")

View File

@ -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")

View File

@ -1,4 +1,4 @@
"""内存仓库。单测和 OMS_REPO=memory 用,进程退出即丢。 """内存仓库。只给单测用,进程退出即丢。运行时走 Oracle。
按 oms_id 判重,已存在就 DuplicateOmsId,不覆盖。 按 oms_id 判重,已存在就 DuplicateOmsId,不覆盖。
""" """

View File

@ -7,7 +7,6 @@
""" """
from __future__ import annotations from __future__ import annotations
import os
from typing import Annotated, Any from typing import Annotated, Any
from fastapi import Depends, FastAPI, Header from fastapi import Depends, FastAPI, Header
@ -15,21 +14,14 @@ from fastapi.responses import JSONResponse
from .auth import token_ok from .auth import token_ok
from .inbound.envelope import EnvelopeError, parse_envelope from .inbound.envelope import EnvelopeError, parse_envelope
from .inbound.memory_repo import MemorySalesGoodsRepo
from .inbound.repo import RepoUnavailable from .inbound.repo import RepoUnavailable
from .inbound.sales_goods import receive_sales_goods from .inbound.sales_goods import receive_sales_goods
app = FastAPI(title="洁达 RPA × 订单中心", version="0.1") app = FastAPI(title="洁达 RPA × 订单中心", version="0.1")
# 内存仓库做成进程内单例,同一进程多次 POST 才能测到 DUPLICATE。
_memory_repo = MemorySalesGoodsRepo()
def get_repo(): def get_repo():
# 默认走 Oracle。本机连不上库时 OMS_REPO=memory。 # 运行时只走 Oracle。单测用 dependency_overrides 注入内存替身。
if os.getenv("OMS_REPO", "oracle") == "memory":
return _memory_repo
# thick 客户端初始化有副作用,用到才 import。
from .inbound.oracle_repo import OracleSalesGoodsRepo from .inbound.oracle_repo import OracleSalesGoodsRepo
return OracleSalesGoodsRepo.from_env() return OracleSalesGoodsRepo.from_env()

View File

@ -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()

View File

@ -1 +0,0 @@
"""Project development utilities."""

View File

@ -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())

View File

@ -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
```

View File

@ -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()

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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,
}

View File

@ -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
```

View File

@ -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] }
}
}
}

View File

@ -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。"
}

View File

@ -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"
}

View File

@ -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
}
}

View File

@ -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

View File

@ -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='事件流';

View File

@ -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)]

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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"

View File

@ -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)

View File

@ -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", "其他公司", "重庆亿昊药业有限公司")],
)

View File

@ -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 模型。
## 九、验收总纲
每做完一个节点/接口,打开 `实施与验收规范` 第四部分,对着该节点的**代码验收**(你自检)和**业务验收**(交业务勾)两栏逐条过。全绿才算完。

View File

@ -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 三段分别单独联调通过后,再跑整链。

View File

@ -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. 调整样式,让收起状态下不占垂直空间。
或者你对分区划分 / 默认展开状态有别的想法,直接说,我再调整。

View File

@ -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 接口。
- 后端没起时,页面自动切到本地演示数据,仍可看到单据列表、流程状态图和待办面板。
- 页面顶部会明确显示“本地演示数据”或“后端已连接”。
- 对码待办可直接选择候选货品;人工节点按节点显示对应动作。
- 节点回调模拟区只在开发环境构建中显示。
- 开发令牌只保存在浏览器会话里,不会写进代码。

View File

@ -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

View File

@ -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

View File

@ -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);
},
};

View File

@ -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];
}

View File

@ -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 || '-';
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>,
);

View File

@ -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');
});

View File

@ -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

View File

@ -1 +0,0 @@
/// <reference types="vite/client" />

View File

@ -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": []
}

View File

@ -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,
},
},
},
});

View File

@ -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
}
```
---
祝开发顺利!有问题随时联系状态中心负责人。