Compare commits

..

No commits in common. "60c2d6d0f019ffbbdc3fbe7cd1f7f0feea7cd187" and "ef9c6f09c6627b55576dad6e5176113cd64e70b0" have entirely different histories.

168 changed files with 16404 additions and 2092 deletions

16
.gitignore vendored
View File

@ -2,8 +2,6 @@
.DS_Store .DS_Store
.idea/ .idea/
.vscode/ .vscode/
!rpa_selection/.vscode/
!rpa_selection/.vscode/**
*.swp *.swp
# Python # Python
@ -19,10 +17,11 @@ __pycache__/
# Node # Node
node_modules/ node_modules/
state_center_web/dist/
# Local runtime / agent # Local runtime / agent
.runtime/ .runtime/
rpa_contract/screenshots/ rpa_yisaiqi/screenshots/
rpa-chrome-profile/ rpa-chrome-profile/
**/chrome-profile/ **/chrome-profile/
.tmp/ .tmp/
@ -31,16 +30,15 @@ rpa-chrome-profile/
.claude/ .claude/
.superpowers/ .superpowers/
# Duplicate / scratch
state_center_副本/
# Local M5 credentials
state_center/fixtures/m5/connection.local.json
.env .env
.env.* .env.*
!.env.example !.env.example
# 选品凭据与运行结果
rpa_selection/secrets.json
rpa_selection/data/.auth/
rpa_selection/output/结果/*.xlsx
rpa_selection/output/结果/*.txt
# Media (keep docs, drop recordings and extracted frames) # Media (keep docs, drop recordings and extracted frames)
*.mp4 *.mp4
video_frames_v2/ video_frames_v2/

367
PARALLEL_DEV_PLAN.md Normal file
View File

@ -0,0 +1,367 @@
# 状态中心 ↔ 艺赛旗并行开发方案
## 核心原则:契约驱动,各自独立,最后对接
双方只通过 **HTTP 回调契约** 交互,这个契约在交接文档里已经明确定义。在对接前,双方各自用假数据/假回调验证自己的逻辑,互不阻塞。
---
## 对接契约(双方共同遵守)
### 基础信息
- **状态中心地址**:联调时给艺赛旗实际 IP,如 `http://192.168.x.x:8000`
- **鉴权**:所有回调必须带 header `X-Agent-Token: <token>`(默认 `dev-agent-token`,上线换正式值)
- **幂等键**:`complete` 回调可带 `idempotencyKey`,重发同键不会二次推进
### 三个回调接口(艺赛旗调状态中心)
#### 1. 节点开始
```bash
POST /api/jobs/{jobId}/nodes/{nodeCode}/start
Content-Type: application/json
X-Agent-Token: dev-agent-token
{
"attemptNo": 1,
"executorPlatform": "iS-RPA",
"operator": "agent"
}
```
#### 2. 节点成功
```bash
POST /api/jobs/{jobId}/nodes/{nodeCode}/complete
Content-Type: application/json
X-Agent-Token: dev-agent-token
{
"attemptNo": 1,
"executorRunId": "YD-RUN-001",
"idempotencyKey": "uuid-xxx",
"businessRefs": {
"contractSystemNo": "HT2026...", // 三把钥匙,对应节点必传
"rebateOrderNo": "FL2026...",
"m5OrderNo": "M5-xxx",
"receiptNoticeNo": "SH-xxx"
},
"summary": { "orderCount": 12 },
"artifacts": [
{
"artifactType": "SOURCE_EXCEL",
"storagePath": "/data/jobs/xxx/orders.xlsx",
"fileHash": "sha256:abc",
"visibleRoles": ["PURCHASER", "PLANNER"],
"isMasked": false
}
]
}
```
**返回**:`{"ok": true, "next_node": "OCR_EXTRACT"}` ← 艺赛旗拿到 `next_node` 继续跑下一节点
#### 3. 节点失败
```bash
POST /api/jobs/{jobId}/nodes/{nodeCode}/fail
Content-Type: application/json
X-Agent-Token: dev-agent-token
{
"attemptNo": 1,
"errorCode": "SYS_LOGIN_FAIL", // 见交接文档错误码表
"errorSummary": "验证码识别失败",
"retryable": true, // 系统类填 true,业务类填 false
"routeRole": "OPS", // OPS/PLANNER/PURCHASER
"artifacts": []
}
```
**返回**:`{"ok": true, "job_status": "FAILED_RETRYABLE"}`
### 必须回传的业务编号(三把钥匙)
| 节点 | 必传字段 | 说明 |
| --- | --- | --- |
| CONTRACT_CREATE | `contractSystemNo` | 合同编号(后续返利、M5靠它串) |
| REBATE_HEADER | `rebateOrderNo` | 返利中心总单号 |
| M5_RECEIPT_NOTICE | `receiptNoticeNo` | 收货通知单号(全链终点) |
不传或传空,状态中心返回 **400 拒绝**。
### 错误码规范(部分,全表见交接文档)
| 错误码 | 含义 | retryable | routeRole |
| --- | --- | --- | --- |
| SYS_LOGIN_FAIL | 登录失败 | true | OPS |
| SYS_PAGE_TIMEOUT | 页面超时 | true | OPS |
| UI_ELEMENT_NOT_FOUND | 页面改版找不到元素 | false | OPS |
| BIZ_CONTRACT_DISABLED | 供应商合同被禁 | false | PURCHASER |
| M5_QC_MISSING | M5质管资料缺失 | - | - |
| REBATE_DUP_CODE | 返利重复编码 | false | PURCHASER |
**特殊规则**:`M5_QC_MISSING` 无条件转 `FAILED_MANUAL`,`retryable` 填什么都没用。
---
## 状态中心这边(你的任务)
### 阶段1:独立验证(不依赖艺赛旗)
**时间**:1-2天
**目标**:后端在真 MySQL 上用假回调跑通全链
#### 任务
1. **环境准备**
- Docker 起 MySQL 8,执行 `schema.sql` 建库。
- 配 `MYSQL_DSN`,起后端服务。
- 前端 `npm run dev` 起来。
2. **用假回调跑通一单**
- 方式1:用 curl 按顺序调回调接口。
- 方式2:用前端"开发:节点回调模拟"区域的按钮。
- **路径**:建单 → ORDER_FETCH start/complete → PRODUCT_MATCH run(有 MULTI) → 人工确认 → SOURCE_ADD 人工提交 → CONTRACT_CREATE complete(带合同编号) → ... → ARCHIVE_JOB。
- **验证点**:
- job 状态正确推进(PENDING → RUNNING → WAITING_USER → ...)。
- 业务编号正确落库(合同编号、返利总单号、M5单号、收货通知单号)。
- job_node 每个节点有历史记录,attempt_no 正确。
- user_task 在该创建时创建,提交后关闭。
- job_artifact 证据落库。
- 条件节点(OCR_EXTRACT/SOURCE_ADD)正确跳过或执行。
3. **发现并修复真实问题**
- SQL 语法问题(如 MySQL 8 的保留字冲突)。
- JSON 列序列化问题(Python dict → MySQL JSON → 前端拿到的是对象还是字符串)。
- 事务回滚是否正确。
- datetime 字段格式(MySQL 存的 `YYYY-MM-DD HH:MM:SS`,前端拿到的格式)。
- 幂等逻辑是否真的防重(重复 complete 同一节点,状态不会乱)。
4. **记录真实 API 响应**
- 把 `GET /api/jobs/{id}` 的完整响应存下来,给 UI 优化做参考。
#### 产出
- ✅ 一个能在真 MySQL 上跑通全链的后端(用假回调验证)。
- ✅ 一份真实响应示例 + 发现的问题清单。
- ✅ 前端能正常展示真实数据(不再依赖 `demoJobDetailMap`)。
### 阶段2:优化与补全(不依赖艺赛旗)
**时间**:2-3天
**目标**:UI 优化 + 补缺失接口 + 加调度重试
#### 任务
1. **UI 优化**
- 按刚才的方案,分区 + 可折叠 + 调试开关。
- 基于阶段1 的真实响应,确保所有字段正确展示。
2. **补缺失接口**
- `GET /api/jobs/{id}/match/unmatched`:返回该 job 未命中的订单行列表。
- 其他小接口(如果阶段1 发现有遗漏)。
3. **调度与重试(任务6)**
- 定时建单:每天早上扫前一天订单。
- 系统类错误自动重试(`FAILED_RETRYABLE` 状态的 job,重试 1-2 次)。
- 超时升级:合同审批超时、人工待办超时转 OPS。
#### 产出
- ✅ 优化后的前端(信息分层清晰,调试模式可开关)。
- ✅ 完整的接口(验收点名的都实现)。
- ✅ 调度器能定时触发建单和重试。
### 阶段3:准备对接(等艺赛旗阶段2 完成)
**时间**:半天
**目标**:给艺赛旗提供联调环境
#### 任务
1. **部署联调环境**
- 把后端部署到艺赛旗能访问的机器(或你本机开端口映射)。
- 给艺赛旗提供:基础地址(如 `http://192.168.x.x:8000`) + 正式 `X-Agent-Token`。
2. **准备测试数据**
- 在库里建几个不同类型的 job:
- 医药城线上单(会跳过 OCR_EXTRACT)。
- 线下单(会走 OCR_EXTRACT)。
- 有对码 MULTI 的单。
- 缺货源的单(会走 SOURCE_ADD)。
- 每个 job 停在 ORDER_FETCH 节点,等艺赛旗来调 start/complete 推进。
3. **写联调检查表**
- 逐节点列验收点,双方对着检查表勾。
#### 产出
- ✅ 一个可供艺赛旗调用的联调环境。
- ✅ 测试数据 + 联调检查表。
---
## 艺赛旗那边(你要转述给他们)
### 阶段1:独立开发(不依赖状态中心)
**时间**:2-3周(他们的活重)
**目标**:每个节点的页面自动化都能独立跑通
#### 任务
1. **理解回调契约**
- 仔细读 `给艺赛旗的开发交接.md`。
- 确认三个回调接口的字段、错误码表。
2. **逐节点开发页面自动化**
- **节点顺序**(他们的优先级):
1. ORDER_FETCH(订单获取):登录医药城/小药药 → 导出 Excel → 存证据。
2. CONTRACT_CREATE(合同建单):登录合同系统 → 填表 → 上传 PDF(医药城) → 提交送审 → 拿到合同编号。
3. REBATE_HEADER(返利总单):登录返利中心 → 按合同编号查总单 → 填必填项 → 保存 → 拿到返利总单号。
4. REBATE_IMPORT(返利细单):下载模板 → 填字段 → 上传 → 读成功/失败行。
5. REBATE_SUBMIT(返利送审):点"提交送审"按钮。
6. M5_CREATE_ORDER(M5建单):桌面自动化 → 录单 → **停,不保存**。
7. CONTRACT_APPROVE_WAIT(合同审批):定时查审批状态(到点被状态中心扫到时才查一次)。
- **每个节点独立验证**:
- 不调状态中心,在自己环境里跑(比如用测试账号登录真实系统,手工给初始数据)。
- 验证能拿到业务编号(合同编号、返利总单号等)。
- 验证错误情况能识别出正确的错误码(如登录失败是 `SYS_LOGIN_FAIL`,合同被禁是 `BIZ_CONTRACT_DISABLED`)。
3. **本地模拟回调**
- 自己起个假的状态中心(或者直接用 curl 打印 JSON),验证回调 payload 格式对不对。
- 示例(他们自己写个小脚本验证):
```python
# 假的状态中心 mock.py
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route('/api/jobs/<job_id>/nodes/<node_code>/complete', methods=['POST'])
def complete(job_id, node_code):
data = request.json
print(f"收到 {node_code} complete:", data)
# 检查必传字段
if node_code == 'CONTRACT_CREATE' and not data.get('businessRefs', {}).get('contractSystemNo'):
return jsonify({"error": "缺少合同编号"}), 400
return jsonify({"ok": True, "next_node": "下一节点"})
app.run(port=8000)
```
#### 产出
- ✅ 每个节点的页面自动化脚本(iS-RPA 流程)。
- ✅ 能正确识别各种错误并返回对应错误码。
- ✅ 能拿到三把钥匙(合同编号、返利总单号、收货通知单号)。
### 阶段2:准备对接(等状态中心阶段3 准备好)
**时间**:1天
**目标**:改成调真实状态中心接口
#### 任务
1. **获取联调环境信息**
- 从状态中心拿到:基础地址 + `X-Agent-Token`。
2. **改造回调逻辑**
- 把阶段1 的假回调改成调真实状态中心接口。
- **关键**:拿到 `complete` 返回的 `next_node`,继续跑下一节点(不要自己写死顺序)。
3. **小范围试跑**
- 先跑一个最简单的节点(如 ORDER_FETCH),验证回调能调通、状态中心能正确推进。
#### 产出
- ✅ 能调通状态中心回调接口的 RPA 脚本。
---
## 联调阶段(双方一起)
**时间**:3-5天
**目标**:端到端跑通一单真实流程
### 任务
1. **按节点逐个验证**
- 顺序:ORDER_FETCH → (OCR_EXTRACT) → PRODUCT_MATCH(状态中心内部) → (SOURCE_ADD 人工) → CONTRACT_CREATE → ...
- 每个节点验证:
- RPA 能正确调 start/complete/fail。
- 状态中心正确推进到下一节点或转人工。
- 业务编号正确落库。
- 证据正确存储。
2. **跑完整链**
- 医药城线上单(跳过 OCR)。
- 线下单(走 OCR)。
- 有对码 MULTI 的单(走人工确认)。
- 缺货源的单(走 SOURCE_ADD 人工)。
- 合同审批等待(定时查几次,验证不会死等)。
3. **异常场景**
- 登录失败 → 重试。
- 页面改版找不到元素 → 转 OPS。
- M5 质管缺失 → 直接 FAILED_MANUAL,不重试。
- 返利重复编码 → 该单挂起,整批继续。
4. **对照验收规范逐条勾**
- 打开 `实施与验收规范.md`,每个节点的代码验收 + 业务验收两栏逐条过。
### 产出
- ✅ 一个能端到端跑通的完整流程(从订单获取到归档)。
- ✅ 验收报告(哪些通过,哪些有问题)。
---
## 时间线(并行开发)
```
第1周 第2周 第3周
状态中心 │────阶段1────│───阶段2───│──阶段3──│────联调────│
真MySQL跑通 UI优化+补全 准备环境 与艺赛旗对接
(1-2天) (2-3天) (半天) (3-5天)
艺赛旗 │────────────阶段1────────────│─阶段2─│────联调────│
独立开发各节点页面自动化 改真回调 与状态中心对接
(2-3周) (1天) (3-5天)
```
**关键点**:
- 前两周双方完全独立,互不阻塞。
- 状态中心先做完(1周内),等艺赛旗追上。
- 艺赛旗开发周期长(页面自动化细节多),但不影响状态中心进度。
- 第3周开始联调,双方都已在各自环境验证过,对接就是走形式。
---
## 给艺赛旗的检查清单(转述)
在开始联调前,艺赛旗必须自证能做到:
### 功能检查
- [ ] 能登录医药城/小药药/合同系统/返利中心/M5,并正确处理验证码/超时。
- [ ] 能导出订单 Excel 并存到指定路径。
- [ ] 能建合同、上传 PDF(医药城)、拿到合同编号。
- [ ] 能在返利中心按合同编号查总单、填表、拿到返利总单号。
- [ ] 能下载返利细单模板、填字段、上传、读成功/失败行。
- [ ] 能点返利送审按钮。
- [ ] 能用桌面自动化在 M5 录单(但不保存)。
- [ ] 能定时查合同审批状态(不是连续轮询,是到点查一次)。
### 回调检查
- [ ] 知道何时调 start、何时调 complete、何时调 fail。
- [ ] complete 时能正确带 `businessRefs`(三把钥匙对应节点必传)。
- [ ] fail 时能正确识别错误码(`SYS_*` vs `BIZ_*`,`retryable` 填对)。
- [ ] 拿到 `next_node` 后能继续跑下一节点(不写死顺序)。
- [ ] 所有回调都带 `X-Agent-Token` header。
### 异常检查
- [ ] 登录失败时返回 `SYS_LOGIN_FAIL`,`retryable=true`。
- [ ] 页面改版找不到元素时返回 `UI_ELEMENT_NOT_FOUND`,`retryable=false`,`routeRole=OPS`。
- [ ] M5 质管缺失时返回 `M5_QC_MISSING`(状态中心会直接转 FAILED_MANUAL,不重试)。
- [ ] 供应商合同被禁时返回 `BIZ_CONTRACT_DISABLED`,`retryable=false`,`routeRole=PURCHASER`。
---
## 总结
**你现在要做的**:
1. 马上开始阶段1(真 MySQL 跑通),1-2天搞定。
2. 接着做阶段2(UI 优化 + 补全),2-3天搞定。
3. 然后做阶段3(准备联调环境),半天搞定。
4. **这时你这边已经全部做完**,等艺赛旗追上(他们要 2-3 周)。
**艺赛旗要做的**(你转述给他们):
1. 阶段1:独立开发各节点页面自动化,2-3周。期间不需要状态中心配合,自己用假数据验证。
2. 阶段2:拿到状态中心联调环境信息后,改成调真实接口,1天。
3. 联调阶段:双方一起按检查表逐条验收,3-5天。
**不会互相阻塞**:你 1 周内做完你的,艺赛旗 2-3 周做完他们的,最后 3-5 天一起联调。
---
要我把这份文档发给艺赛旗吗?或者你先看看有没有要调整的。

View File

@ -1,48 +0,0 @@
# AutoRpa
福建洁达采购 RPA。仓库里叠过两套产品,以本文「现行」为准。
## 现行工作
订单中心四接口 + 四条独立作业。对码在订单中心做完,本方收件、选品、录合同/返利、回写结果。
| 口 | 方向 | 底表 | 状态 |
| --- | --- | --- | --- |
| 1 | 订单中心 → 本方 | `rpa_sales_goods_mapping_data` | 进行中(`rpa_oms/`) |
| 2 | 本方 → 订单中心 | `rpa_cart_settlement_goods_data` | 选品已落表,push_status=0,路径仍空 |
| 3 | 订单中心 → 本方 | `rpa_purchase_contract_data` | 复用接口 1 接收壳 |
| 4 | 本方 → 订单中心 | `rpa_contract_agreement_result` | 路径空,先造数 |
作业互不串联:选品读表 1,合同/返利读表 3。HTTP 收件不同步跑 RPA。
## 代码入口
| 目录 | 身份 |
| --- | --- |
| `rpa_oms/` | 四个 HTTP 口,一个 FastAPI。先做接口 1 |
| `rpa_selection/` | 选品班。读表 1 `deal_flag=0`,双平台选品,写表 2 |
| `rpa_cart/` | 加购 + 支付回传(占位)。表 2 → 口 2 |
| `rpa_contract/` | 合同班。页面录入在本包;表 3 合单、接口 4 尚未接 |
| `rpa_rebate/` | 返利班(占位)。查号、录协议 → 表 4 |
| 包 | 读 | 写 | 口 |
| --- | --- | --- | --- |
| `rpa_selection` | 表 1 `deal_flag=0` | 表 2 `operate_type=1`,表 1 处理标志 | 不直接调口;口 2 由 `rpa_cart` 推 |
| `rpa_cart` | 表 2 | 表 2 `push_status`;支付行 | 口 2 |
| `rpa_contract` | 表 3 `process_status=0` | 表 4 `actionCode=1` | 口 3 收件在 oms;口 4 回写 |
| `rpa_rebate` | 表 3 / 表 4 待查号与待录协议 | 表 4 `contractStatus=2`、`actionCode=2` | 口 4 |
合同怎么录、合单和回写口径见 `docs/合同系统录入流程.md`。页面控件以 `rpa_contract/contract_flow_steps.py` 为准。
新接口只进 `rpa_oms/`。起服务见 [`rpa_oms/README.md`](rpa_oms/README.md)。
## 文档入口
现行口径和表:[`docs/README.md`](docs/README.md)。
开发对照只看:
- `docs/RPA订单中心接口开发口径_20260930.md`
- `docs/ddl/rpa_oms_staging_tables.sql`
一期主链需求/设计在 `docs/archive/`;`docs/superpowers/specs/` 里 9 月 12–13 日的四流程稿也是归档。作业语义以口径为准。

Binary file not shown.

View File

@ -1,35 +0,0 @@
# docs
按现行 / 作业资产 / 历史查阅。
## 现行(订单中心四接口)
| 文件 | 用途 |
| --- | --- |
| `RPA订单中心接口开发口径_20260930.md` | 开发对照,作业语义以这份为准 |
| `RPA订单中心接口口径讨论纪要_20260929.md` | 为什么这么定 |
| `ddl/rpa_oms_staging_tables.sql` | Oracle 11.2 四张暂存表 |
| `ddl/列对照.md` | 与接口 Excel 列对照 |
| `API鉴权.doc` | 调订单中心时的 Token;接口 1、3 入站鉴权由本方定 |
| `下一会话任务_建表.md` | 建表任务说明(已完成) |
| `合同系统录入流程.md` | 合同班合单、尽调、提交、回写;页面步骤指向 `rpa_contract` |
接口字段、示例报文仍以 Excel 为准(当前 `Downloads/RPA与订单中心接口设计_20260922.xlsx`)。Excel 与口径冲突时,作业语义听口径。
`ddl 2/`、`ddl.zip` 与 `ddl/` 重复,不要用。
## 作业资产
| 文件 | 用途 |
| --- | --- |
| `合同返利系统字段映射_20260911.xlsx` | 返利总单默认值 |
| `采购平台选品条件优先项_20260902.txt` | 选品规则原文 |
| `【项目方案】福建洁达采购合同RPA自动化录入需求_v20260924.docx` | 页面操作参考 |
## 历史(一期 13 节点主链)
见 `archive/`:`PLAN.md`、一期需求/设计/开发规划/验收规范、`一期开发任务清单_表结构_API草案.md`、会议整理版、业务思路 docx、界面截图记录。
那套模型是「一单合同走状态机」。不要用它解释接口 1 的收件箱。
`superpowers/` 是 8–9 月设计稿,同样视为归档。

View File

@ -1,86 +0,0 @@
# 纪要:RPA 与订单中心接口口径
- 时间:2026-09-29~30
- 材料:`RPA与订单中心接口设计_20260922.xlsx`、`API鉴权.doc`、`docs/合同返利系统字段映射_20260911.xlsx`、方案 v20260924
- 开发对照:`docs/RPA订单中心接口开发口径_20260930.md`
---
## 一、接口怎么理解
四个口不是四条流程一对一。1 是选品收件,2 是加购/支付回传,3 是合同和返利共用的行级下发,4 是合同/协议结果回写。
对码不在本方。接口 1 的 `goodsId` 进来就已经有了。
表 1 是收件箱:订单中心推 → 本方接口写入表 1 → 选品捞待处理。不是手填。
---
## 二、已锁定论
### 合单
福建确认一天两班(中午、下午)。Albert:未处理行按供应商汇总,一个供应商一份合同、一份返利。
锁定:
- 按 `supplierId` 合,不用 `omsId`,不用名称
- 任务启动冻结快照,新进数据下一班
- 头字段取该班该供应商第一行(同一班账期不一致也照录,算对方发数)
- 下午再来的另开一份,不改上午已录合同
- 带合同号的协议重录不进按供应商合
### 选品 / 加购 / 支付
- 搜商城用条形码
- 规则不过:表 1 失败 + 表 2 `status=2` + 原因;默认对方**不重推**该 `omsId`(口头再跟对方确认,开发按已锁)
- `status` 只用于加购。支付:有单号就推 `operateType=2`,没付不推,没有支付失败
- 入站一单一个品种;多平台凑数量时同一 `omsId` 多行、`multi_platform_flag=1`、必须同包回(已进表 2)
- 付款仍是采购员两班;支付回传另排,选品不等
### 合同 / 返利回写(业务确认)
合同选 A:提交后回起草;成功=返利中心能搜到合同号。搜不到下一班再搜,不重录合同。
返利选 A 的字面、按现场做:据说无审批流,点送审即成功,点完就回成功并带协议号。以后若开审批流再改。
查号挂在返利班,不单开查状态任务。合同班只录+提交+回起草。
这期不做「合同审完订单中心再推返利包」。二次推只用接口已写的「协议失败带合同号重录」。
暂存号不用于返利。返利用正式合同号去搜。
### 这期范围
- 只做账期。`customerType` 预留,对方固定传 1。预付项目组不做。
- 平台字典:66 = 1 药城,48 = 药帮忙
- 返利总单默认值跟映射表,不跟方案 5.5
- MDM 码对方说默认必填,按必填做;示例没带先不管
- 失败行数量/单价必填、总价舍入:先不管,真数据出问题打回订单中心
### 联调
- Token 测试地址已有;测试用户名 `rpa`(密码不入库)
- 接口 2、4 路径暂时空着,先造数
---
## 三、讨论过程中澄清过的误解
1. 接口 3 一次可以很多行,那是批量收件,不是「已经合成一份合同」。合单是本方按供应商做的。
2. 供应商编码不是小药药商家名。这期用对方给的 MDM/供应商字段填页面。
3. 接口 4 示例是 RPA 把结果推出去,不是「合同审批完通知订单中心再推返利」。
4. `contractStatus=2` 不是让本方重复录合同,是提交后按班次去返利**查号**。
5. 审批/驳回不是本方作业;但合同成功码按接口仍要查号,不能理解成「填完报成功」。
6. 加购「跳过」最后不单开状态码,用失败+原因;和多平台同包回不是一件事。
---
## 四、后置(不挡造数)
- 接口 2、4 真实 URL;`orgSecret` 是否要带;订单中心调本方 1、3 的鉴权
- 只凑到一部分数量时,多平台报文怎么组
- 返利测试环境确认:点送审后协议号是否立即可见
- 加购业务失败不重推:口头跟对方确认一句
开发以口径文档为准。本文只记为什么这么定。

View File

@ -1,305 +0,0 @@
# 福建洁达采购合同全链条自动化设计说明
| 项目 | 内容 |
| --- | --- |
| 文档名称 | 福建洁达采购合同全链条自动化设计说明 |
| 版本 | V1.1 |
| 日期 | 2026-09-12 |
| 适用范围 | 福建广药洁达医药有限公司采购合同、返利中心录入及相关选品作业 |
| 依据 | 《福建洁达采购合同全链条 RPA 自动化录入流程》《采购平台选品条件优先项》(2026-09-02)《合同返利系统字段映射》(2026-09-11) |
| 配套文档 | 《福建洁达四流程技术实现说明》 |
---
## 1. 概述
本方案在现有状态中心与 RPA 执行体系上,将采购作业拆为四条可独立启动的流程:选品采购、订单对码、合同录入、返利录入。
页面操作仍由 RPA 完成。**四条流程启动后的第一步,均由状态中心调用订单中心接口取数(或回写),写入 RPA 数据库并标记处理状态;接口未成功返回前,RPA 不得打开下游系统页面。** 操作人员可在状态中心启动作业、查看进度与业务数据,亦可按约定时间自动触发。
四条流程独立启动、独立失败、独立重跑,互不占用同一执行会话。同一笔业务单据的数据仍按「选品 → 对码 → 合同 → 返利」顺序衔接;前置数据未就绪时,后续流程不予执行,并记录原因。
---
## 2. 建设目标
1. 提供统一操作入口:状态中心可分别启动四条流程,或按计划定时运行。
2. 保证过程可见:每条作业的执行状态、当前环节、责任角色、业务数据及失败原因可在状态中心查询。
3. 保证数据可追溯:订单中心接口数据先落入 RPA 库,记录待处理、成功、失败等状态,支持失败重拉与按单重跑。
4. 保证规则可落地:选品按已确认的优先项执行;合同、返利按字段映射表填写,不自行推断未提供的字段。
5. 控制操作风险:商城付款、未尽调客户的合同填报,均不由机器人自动完成。
---
## 3. 总体方案
```
状态中心(启动 / 监控 / 待办)
│
│ 每条流程启动后,先调订单中心接口
▼
订单中心 ──读取──► 销售总单 / 客户主数据 / 货品 / 计划员 / 账期
◄──回写── 对码结果
│
▼
RPA 数据库(带处理状态:待处理 / 成功 / 失败)
│
│ 仅 STATUS=就绪 时,RPA 才点页面
▼
1号药城 / 药帮忙 / 合同系统 / 返利中心
```
| 流程 | 业务内容 | 建议运行时点 |
| --- | --- | --- |
| 选品采购 | 读取订单中心销售总单,在 1 号药城、药帮忙按规则选品,生成待复核清单 | 销售订单产生之后 |
| 订单对码 | 将选品结果或手工单字段写入订单中心对码表 | 选品完成,或手工单到达之后 |
| 合同录入 | 按字段映射填写合同系统 | 对码完成,且客户已尽调 |
| 返利录入 | 按合同号填写返利中心总单与细单 | 合同编号已生成 |
启动某一流程仅处理该流程范围内的任务,不强制四条连续一次跑完。某流程缺少必要数据或处于人工待办时,系统停留在该流程并写明原因,不向下游系统提交空数据。
---
## 4. 订单中心接口
订单中心是本方案的**唯一业务数据源**。合同系统、返利中心页面上由 RPA 填写、且映射表标注「从订单中心获取」的字段,一律经本接口取得,禁止从商城页面抄写,禁止在 RPA 脚本中写死。
调用原则:
1. **只由状态中心调用。** RPA 不直连订单中心。状态中心将结果写入 RPA 库后,机器人只读本地就绪数据。
2. **先接口、后页面。** 接口失败、超时、缺必填字段,本流程记失败并停止,不打开 1 号药城、合同系统或返利中心。
3. **原文留存。** 每次响应写入 RPA 库(含原始报文),状态中心可查,便于对账与重拉。
4. **按单幂等。** 同一业务键重复启动不重复插入成功数据;失败允许按同一键重拉。
正式 URL、鉴权、报文格式由订单中心提供后锁定。本节先按业务能力列出必须具备的接口;能力未齐备前,对应流程不得对生产系统写入。
### 4.1 接口清单与调用时点
| 序号 | 接口能力 | 方向 | 调用流程 | 调用时点 | 失败后果 |
| --- | --- | --- | --- | --- | --- |
| A | 销售总单查询 | 读 | 选品 | 选品启动后、上商城之前 | 选品停止,不上 1 号药城 / 药帮忙 |
| B | 销售明细查询 | 读 | 选品 | 紧接 A,按总单取行 | 同 A;无批准文号的行单独失败,不阻断其他行 |
| C | 对码结果回写 | 写 | 对码 | 选品完成或手工单识别之后 | 对码失败;合同不得启动 |
| D | 对码商家与货品查询 | 读 | 对码 / 合同 | 回写成功后,取客户名称、货品编码等 | 合同不得启动 |
| E | 客户主数据查询 | 读 | 合同 | 合同启动后、打开合同系统之前 | 合同停止;不填报 |
| F | 合同货品明细查询 | 读 | 合同 | 与 E 一并发起 | 同 E;缺货品行则本单失败 |
| G | 计划员与采购员对照查询 | 读 | 返利 | 返利启动后、打开返利中心之前 | 返利停止;不猜测计划员 |
| H | 返利源数据查询 | 读 | 返利 | 与 G 一并发起 | 缺供应商编码、订单号或货源细单号则本单失败 |
### 4.2 各接口必须返回的字段
字段名称以订单中心正式文档为准。下表为业务含义,来源为流程图及《合同返利系统字段映射》。
**A 销售总单查询**
入参:订单日期(默认前一日),可选供应商名称。
出参(每张总单):销售总单号、订单日期、客户(商家)名称、客户编码、来源、总金额。
**B 销售明细查询**
入参:销售总单号。
出参(每行):订单号、批准文号、通用名、规格、生产厂家、采购数量、**销售价格**(选品计算 5% 毛利的基准,不得用采购价替代)、平台商品编码(如有)。
无批准文号、无销售价格的行标记失败,不进入商城查询。
**C 对码结果回写**
入参:订单号或销售明细行标识,以及待写入对码表的字段(品名、规格、厂家、批准文号、数量、选品后的货品信息等)。
出参:写入是否成功。
说明:对应流程图「提取需要字段数据,传到订单中心对码功能数据表」。此为写接口,与查询分开。
**D 对码商家与货品查询**
入参:客户(商家)标识、货品匹配条件。
出参:客户名称、客户编码;货品编码、货品名称、注册证号、规格、单位。
说明:合同系统「客户名称」对照此处填写,不以商城店铺名为准。
**E 客户主数据查询**
入参:客户编码或客户名称。
出参:
| 字段 | 用途 |
| --- | --- |
| 客户名称、客户编码 | 合同「客户名称」 |
| 客户类型(预付 / 账期) | 预付:合同版本「我司定制」、预付款「是」、付款期限 1 天;账期:合同版本「非格式合同」、预付款「否」、付款期限取账期天数 |
| 账期天数 | 仅账期客户必填;映射表要求订单中心具备该维护功能 |
| 合同签订日期、开始日期、结束日期 | 合同对应日期字段 |
| 尽调状态(如订单中心有维护) | 未尽调则本份合同跳过并邮件通知采购员。若尽调仅在合同系统回显,则本字段改为页面读取,须在接口说明书中明确 |
**F 合同货品明细查询**
入参:销售总单号或订单号。
出参(每行):货品编码、货品名称、注册证号、规格、单位、数量、单价。
说明:用于合同明细导入模板。数量、单价取订单中心,不取商城实时价。
**G 计划员与采购员对照查询**
入参:计划员或采购员。可按全量或按供应商查询。
出参:计划员、对应采购员。
说明:映射表注明「订单中心需要有计划员和采购员的对应关系」。无对照则返利总单「计划员」无法填写。
**H 返利源数据查询**
入参:合同号或销售总单号。
出参:供应商编码、返利单位编码、计划员、订单号、货源细单号;以及细单行的货品编码、数量、含税协议单价。
说明:返利细单「细单备注」填订单号,「货源细单 ID」填货源细单号,二者均须订单中心给出。
### 4.3 调用顺序(按一笔业务)
```
选品启动
① 调 A、B → 写入销售总单/明细(就绪) → 再上商城查价
A/B 失败 → 选品失败,不上商城
对码启动
② 调 C 回写对码表 → 调 D 取客户与货品编码 → 就绪后方可合同
C/D 失败 → 对码失败,合同入口拒绝启动
合同启动
③ 调 E、F → 客户类型/账期/日期/货品行就绪 → 再打开合同系统
必填缺失 → 合同失败,不填报
未尽调 → 本单跳过,邮件通知,处理下一单
返利启动
④ 调 G、H → 计划员对照与细单源数据就绪 → 再打开返利中心
必填缺失 → 返利失败,不猜测字段
```
四条流程仍可分时、分别启动;上述顺序约束的是**同一业务键上的数据依赖**,不是强制一次连续跑完四条。
### 4.4 失败与重试
| 情形 | 处理 |
| --- | --- |
| 网络超时、服务不可用 | 本流程失败,可重拉接口;不启动 RPA |
| 返回成功但缺必填字段 | 本单失败,记录缺失字段名称;不启动 RPA |
| 部分明细行缺批准文号或销售价格 | 仅该行失败,其余行继续 |
| 对码回写失败 | 对码失败;合同、返利不得启动 |
| 客户未尽调 | 本合同跳过(不算接口失败),发邮件,继续下一客户 |
| 计划员对照不存在 | 返利失败,不使用默认计划员 |
重拉只覆盖失败数据,已成功入库的记录不得覆盖。
### 4.5 需订单中心确认的事项
1. 上表 A–H 的正式地址、鉴权方式、测试与生产环境。
2. 客户类型、账期天数、计划员与采购员对照、货源细单号是否已有维护功能与查询接口(映射表已提出,当前文档未提供接口说明)。
3. 尽调状态以订单中心为准,或以合同系统选择客户后的回显为准。
4. 对码功能数据表的写入接口是否已经开放;未开放则对码流程无法闭环。
未确认前,状态中心可用测试数据验证「调用 → 入库 → 状态中心可见」链路,不对生产订单中心与生产合同系统写入。
---
## 5. 流程说明
### 5.1 选品采购
调用接口 A、B,取得销售总单及明细后,按批准文号检索 1 号药城、药帮忙商品,依据第 6 节规则筛选。筛选结果形成待复核清单,由采购人员确认后再付款。机器人不执行支付操作。
已完成的商城登录与按文号查询能力纳入本流程复用,查询结果改为写入 RPA 库及状态中心,不再以桌面表格作为唯一产出。
### 5.2 订单对码
调用接口 C,将选品结果或 DocAgent 识别的手工单字段写入订单中心对码功能数据表;再调用接口 D,取得客户名称与货品编码。合同系统中的「客户名称」以接口 D 为准。
### 5.3 合同录入
调用接口 E、F 取得客户主数据与货品明细后,依据《合同返利系统字段映射》填写合同系统。录入方式分为系统自动生成、RPA 填写、无需填写三类;RPA 仅处理标注为「RPA」的字段。
客户未完成主体尽调时:向采购员发送邮件,本份合同跳过,继续处理后续合同,不中断整批作业。
客户类型为预付时:合同版本类型取「我司定制」,是否预付款为「是」,付款期限为 1 天。
客户类型为账期时:合同版本类型取「非格式合同」,是否预付款为「否」,付款期限取接口 E 返回的账期天数。
### 5.4 返利录入
调用接口 G、H 取得计划员对照与返利源数据后,按合同号查询采购协议,填写总单必填项并导入细单。提交失败转为人工处理,不循环强行提交。
---
## 6. 选品规则
规则依据《采购平台选品条件优先项》(2026-09-02)。下列第 1 至 4 项须同时满足,再比较仓库距离。销售价格以订单中心接口 B 为准。
1. **自营仓**:仅在已勾选的自营仓范围内选择。
2. **价格**:与销售价格比较,毛利不低于 5%。
3. **有效期**:大于等于 12 个月。
4. **库存**:库存数量不低于本单采购数量。
5. **仓库距离**:在满足以上条件后,按下列自营仓顺序优先选择距离较近的仓库:
1. 广东壹号药业有限公司
2. 昆山亿方药业有限公司
3. 湖北亿昊药业有限公司
4. 天津壹号药业有限公司
5. 重庆亿昊药业有限公司
6. 重庆壹号医药有限公司
7. 湖南壹号药业股份有限公司
8. 辽宁亿昊药业有限公司
9. 河南壹号医药有限公司
10. 甘肃壹号云医药有限责任公司企业店
11. 陕西壹号药业有限公司
现有商城查价程序实现了双平台登录与按批准文号取价,可作为选品流程的查询能力复用。该程序现行过滤逻辑与上述规则存在差异,上线口径以本节为准:
| 现有查价程序 | 本方案采用规则 |
| --- | --- |
| 按采购价格上下浮动 10% 过滤 | 相对销售价格,毛利不低于 5% |
| 未限定自营仓范围 | 仅在上述 11 家自营仓内选择 |
| 未校验库存是否覆盖采购数量 | 库存须大于等于采购数量 |
| 结果输出至本地表格 | 结果写入 RPA 库,并在状态中心展示 |
---
## 7. 合同与返利录入口径
字段来源、录入方式以《合同返利系统字段映射》(2026-09-11)为准。凡标注「从订单中心获取」的字段,取值路径见第 4 节,缺项不得提交。
合同、返利中标注为默认值的字段(如结算类型「收支双向」、付款方式「电汇」、税率 0.13、承付模式及参数等)按映射表执行,不在运行时另行解释。
---
## 8. 运行监控
状态中心提供以下可见信息:
- 当日四条流程的执行数量,以及成功、失败、等待人工处理的分布
- 单笔作业的当前环节与责任角色
- 订单中心各接口最近一次调用时间、成功或失败、缺失字段
- 选品候选、毛利是否达标、选用自营仓
- 合同关键字段(客户名称、金额、日期、尽调结果)
- 返利总单编号、细单导入结果
- 失败原因及是否允许按本流程重跑
接口数据写入 RPA 测试库,并标记处理状态(待处理 / 处理中 / 成功 / 失败 / 跳过)。状态中心同步展示,作为业务查询与问题定位的依据。
---
## 9. 本期范围
**本期包含**
- 四条流程的独立启动、定时启动与状态查询
- 订单中心接口 A–H 的调用、入库与失败停机
- 选品查询能力与优先项规则落地
- 合同、返利按映射表自动填写
**本期不包含**
- 机器人自动完成商城支付
- 对未尽调客户自动填写并提交合同
- 将四条流程拆成互不共享状态的独立工具
- 在订单中心正式接口就绪前对生产系统写入
流程图未包含 M5 采购订单。若现场仍需 M5 作业,另行立项,不纳入本四条流程。
---
## 10. 待确认事项
1. 四条流程采用统一状态中心入口、分别启动;允许只运行其中一条。
2. 选品正式规则以第 6 节为准;现有查价程序仅复用登录与查询能力。
3. 订单中心按第 4.5 节提供接口说明书,并确认客户类型、账期天数、计划员对照、货源细单号、对码回写、尽调来源。未齐备前,合同与返利不得按缺省猜测值提交。
上述事项确认后,按《福建洁达四流程技术实现说明》实施。实施顺序为:先完成订单中心取数入库与状态中心可见,再接入选品、合同与返利。

View File

@ -1,318 +0,0 @@
-- RPA 库暂存表 DDL(Oracle 11.2)
-- 依据:/Users/yuuuu/Downloads/RPA与订单中心接口设计_20260922.xlsx(2026-09-30 16:24)
-- 四张「数据表」sheet。表1 已去掉 oms_orderno;表1 fid 为 number(19);
-- 表2 push_time/create_time/update_time 为 date(create_time 写作 dateNOT NULL,按 date NOT NULL)。
-- 库版本:11.2.0.4,无 IDENTITY;表1/2/4 自增用 SEQUENCE + BEFORE INSERT 触发器(fid 为空才取号,等同 BY DEFAULT)。
-- 标识符不超过 30 字节。
-- 范围:仅本方 RPA 库四张暂存表。不是订单中心对码功能表。本方不建对码引擎。
-- oms_id 一律 number(20)。
--
-- 待确认(Excel 现稿保留,未删列):
-- 表1 oms_id:需确认字段属性是否与订单中心一致。
-- 表2 bar_code:确认同一条形码是否可出现在多个订单;普通索引已建,不作为主键。
-- 表3 fid:确认重录协议时是否生成新的 fid。
-- 表3 purchase_tax_rate:确认税率是百分数还是小数比例;Excel 备注为 0.13 表示 13%。
-- 表3 contract_entry_status:确认是否保留 need_contract 旧字段名;现稿用本列名。
--
-- 主键 / 唯一(Excel 未写主键时按任务口径):
-- 表1 fid 自增主键;oms_id 幂等唯一(因此补 NOT NULL)。
-- 表2、表4 fid 本方序列主键。
-- 表3 fid 订单中心来源唯一序号,作主键。
-- ---------------------------------------------------------------------------
-- 1. 销售商品对码数据(Excel 名;实为选品收件箱)
-- ---------------------------------------------------------------------------
CREATE TABLE rpa_sales_goods_mapping_data (
oms_id NUMBER(20) NOT NULL,
bar_code VARCHAR2(40),
order_date DATE,
sale_price NUMBER(16,4),
sale_qty NUMBER(10),
plan_purchase_qty NUMBER(10),
goods_id VARCHAR2(20),
goods_name VARCHAR2(200),
goods_spec VARCHAR2(100),
manufacturer VARCHAR2(100),
approval_no VARCHAR2(100),
unit VARCHAR2(20),
customer_id VARCHAR2(20),
customer_name VARCHAR2(200),
create_time DATE,
create_user_id NUMBER(10),
create_user_name VARCHAR2(20),
update_time DATE,
update_user_id NUMBER(10),
update_user_name VARCHAR2(20),
deal_flag NUMBER(2) DEFAULT 0 NOT NULL,
deal_result VARCHAR2(200),
deal_time DATE,
deal_user_id NUMBER(10),
deal_user_name VARCHAR2(20),
fid NUMBER(19) NOT NULL,
org_code VARCHAR2(10),
goods_mdm_code VARCHAR2(100),
customer_mdm_code VARCHAR2(100),
CONSTRAINT pk_rpa_sgmd PRIMARY KEY (fid),
CONSTRAINT uk_rpa_sgmd_oms_id UNIQUE (oms_id)
);
CREATE SEQUENCE seq_rpa_sgmd_fid
START WITH 1
INCREMENT BY 1
NOMAXVALUE
NOCYCLE
NOCACHE;
CREATE OR REPLACE TRIGGER trg_rpa_sgmd_bi
BEFORE INSERT ON rpa_sales_goods_mapping_data
FOR EACH ROW
WHEN (new.fid IS NULL)
BEGIN
SELECT seq_rpa_sgmd_fid.NEXTVAL INTO :NEW.fid FROM dual;
END;
/
COMMENT ON TABLE rpa_sales_goods_mapping_data IS '销售商品对码数据(选品收件箱)。订单中心推入,按 oms_id 幂等。';
COMMENT ON COLUMN rpa_sales_goods_mapping_data.oms_id IS '订单中心序号。已存在则不入库、不重跑。';
COMMENT ON COLUMN rpa_sales_goods_mapping_data.bar_code IS '条形码。';
COMMENT ON COLUMN rpa_sales_goods_mapping_data.order_date IS '订单日期。';
COMMENT ON COLUMN rpa_sales_goods_mapping_data.sale_price IS '销售单价。';
COMMENT ON COLUMN rpa_sales_goods_mapping_data.sale_qty IS '销售数量。';
COMMENT ON COLUMN rpa_sales_goods_mapping_data.plan_purchase_qty IS '计划采购量。';
COMMENT ON COLUMN rpa_sales_goods_mapping_data.goods_id IS '货品ID。入站时已有,本方不做对码。';
COMMENT ON COLUMN rpa_sales_goods_mapping_data.goods_name IS '品名。';
COMMENT ON COLUMN rpa_sales_goods_mapping_data.goods_spec IS '货品规格。';
COMMENT ON COLUMN rpa_sales_goods_mapping_data.manufacturer IS '厂家。';
COMMENT ON COLUMN rpa_sales_goods_mapping_data.approval_no IS '国药准字/注册证号。';
COMMENT ON COLUMN rpa_sales_goods_mapping_data.unit IS '单位。';
COMMENT ON COLUMN rpa_sales_goods_mapping_data.customer_id IS '客户ID。';
COMMENT ON COLUMN rpa_sales_goods_mapping_data.customer_name IS '客户名称。';
COMMENT ON COLUMN rpa_sales_goods_mapping_data.create_time IS '创建时间。';
COMMENT ON COLUMN rpa_sales_goods_mapping_data.create_user_id IS '创建人ID。';
COMMENT ON COLUMN rpa_sales_goods_mapping_data.create_user_name IS '创建人名称。';
COMMENT ON COLUMN rpa_sales_goods_mapping_data.update_time IS '更新时间。';
COMMENT ON COLUMN rpa_sales_goods_mapping_data.update_user_id IS '更新人ID。';
COMMENT ON COLUMN rpa_sales_goods_mapping_data.update_user_name IS '更新人名称。';
COMMENT ON COLUMN rpa_sales_goods_mapping_data.deal_flag IS '处理标志。0-待处理、1-处理成功、2-处理失败、3-处理中。';
COMMENT ON COLUMN rpa_sales_goods_mapping_data.deal_result IS '处理结果。';
COMMENT ON COLUMN rpa_sales_goods_mapping_data.deal_time IS '处理时间。';
COMMENT ON COLUMN rpa_sales_goods_mapping_data.deal_user_id IS '处理人ID。';
COMMENT ON COLUMN rpa_sales_goods_mapping_data.deal_user_name IS '处理人名称。';
COMMENT ON COLUMN rpa_sales_goods_mapping_data.fid IS '序号,自增序列。';
COMMENT ON COLUMN rpa_sales_goods_mapping_data.org_code IS '货主编码。';
COMMENT ON COLUMN rpa_sales_goods_mapping_data.goods_mdm_code IS '货品MDM码。';
COMMENT ON COLUMN rpa_sales_goods_mapping_data.customer_mdm_code IS '客户MDM码。';
-- ---------------------------------------------------------------------------
-- 2. 加购及结算商品数据(加购/支付回传缓冲)
-- ---------------------------------------------------------------------------
CREATE TABLE rpa_cart_settlement_goods_data (
fid NUMBER(19,0) NOT NULL,
operate_type NUMBER(2,0) NOT NULL,
oms_id NUMBER(20) NOT NULL,
platform_type NUMBER(4,0) NOT NULL,
supplier_name VARCHAR2(200 CHAR) NOT NULL,
bar_code VARCHAR2(40 CHAR) NOT NULL,
purchase_price NUMBER(16,4) NOT NULL,
purchase_qty NUMBER(10,0) NOT NULL,
purchase_total NUMBER(16,4) NOT NULL,
status NUMBER(2,0),
fail_reason VARCHAR2(500 CHAR),
pay_order_no VARCHAR2(40 CHAR),
pay_order_dtl_id VARCHAR2(40 CHAR),
push_status NUMBER(1,0) DEFAULT 0 NOT NULL,
push_result VARCHAR2(500 CHAR),
push_time DATE,
create_time DATE NOT NULL,
update_time DATE,
create_user_id NUMBER(19,0),
create_user_name VARCHAR2(100 CHAR),
update_user_id NUMBER(19,0),
update_user_name VARCHAR2(100 CHAR),
org_code VARCHAR2(10),
multi_platform_flag NUMBER(1,0) DEFAULT 0 NOT NULL,
CONSTRAINT pk_rpa_csgd PRIMARY KEY (fid)
);
CREATE SEQUENCE seq_rpa_csgd_fid
START WITH 1
INCREMENT BY 1
NOMAXVALUE
NOCYCLE
NOCACHE;
CREATE OR REPLACE TRIGGER trg_rpa_csgd_bi
BEFORE INSERT ON rpa_cart_settlement_goods_data
FOR EACH ROW
WHEN (new.fid IS NULL)
BEGIN
SELECT seq_rpa_csgd_fid.NEXTVAL INTO :NEW.fid FROM dual;
END;
/
CREATE INDEX idx_rpa_csgd_bar_code
ON rpa_cart_settlement_goods_data (bar_code);
COMMENT ON TABLE rpa_cart_settlement_goods_data IS '加购及结算商品数据。RPA 回传缓冲;同一 oms_id 多平台多行。';
COMMENT ON COLUMN rpa_cart_settlement_goods_data.fid IS 'RPA记录技术主键;接口推送时作为业务回执关联键。';
COMMENT ON COLUMN rpa_cart_settlement_goods_data.operate_type IS '操作类型。1-加购,2-支付。';
COMMENT ON COLUMN rpa_cart_settlement_goods_data.oms_id IS '订单中心单据编号。';
COMMENT ON COLUMN rpa_cart_settlement_goods_data.platform_type IS '平台类型。48 药帮忙,66 1药城。';
COMMENT ON COLUMN rpa_cart_settlement_goods_data.supplier_name IS '供应商名称。';
COMMENT ON COLUMN rpa_cart_settlement_goods_data.bar_code IS '条形码。保留前导零,不作为单独主键。';
COMMENT ON COLUMN rpa_cart_settlement_goods_data.purchase_price IS '采购单价。金额精度4位小数。';
COMMENT ON COLUMN rpa_cart_settlement_goods_data.purchase_qty IS '采购数量。大于0。';
COMMENT ON COLUMN rpa_cart_settlement_goods_data.purchase_total IS '采购总价。金额字段统一 NUMBER(16,4)。';
COMMENT ON COLUMN rpa_cart_settlement_goods_data.status IS '整单采购状态。仅加购:1-成功,2-失败。支付填空。';
COMMENT ON COLUMN rpa_cart_settlement_goods_data.fail_reason IS '整单采购失败原因。status=2时填写。';
COMMENT ON COLUMN rpa_cart_settlement_goods_data.pay_order_no IS '支付订单号。支付场景填写。';
COMMENT ON COLUMN rpa_cart_settlement_goods_data.pay_order_dtl_id IS '支付订单明细ID。支付场景填写。';
COMMENT ON COLUMN rpa_cart_settlement_goods_data.push_status IS '推送状态。0-未推送,1-推送成功,2-推送失败,3-推送中。';
COMMENT ON COLUMN rpa_cart_settlement_goods_data.push_result IS '推送结果。保存订单中心回执或失败原因。';
COMMENT ON COLUMN rpa_cart_settlement_goods_data.push_time IS '推送时间。';
COMMENT ON COLUMN rpa_cart_settlement_goods_data.create_time IS '创建时间。';
COMMENT ON COLUMN rpa_cart_settlement_goods_data.update_time IS '更新时间。';
COMMENT ON COLUMN rpa_cart_settlement_goods_data.create_user_id IS '创建人ID。';
COMMENT ON COLUMN rpa_cart_settlement_goods_data.create_user_name IS '创建人名称。';
COMMENT ON COLUMN rpa_cart_settlement_goods_data.update_user_id IS '更新人ID。';
COMMENT ON COLUMN rpa_cart_settlement_goods_data.update_user_name IS '更新人名称。';
COMMENT ON COLUMN rpa_cart_settlement_goods_data.org_code IS '货主编码。';
COMMENT ON COLUMN rpa_cart_settlement_goods_data.multi_platform_flag IS '多平台满足标志。同一 OMS ID 多行时为1,须同报文返回。';
-- ---------------------------------------------------------------------------
-- 3. 采购合同数据
-- ---------------------------------------------------------------------------
CREATE TABLE rpa_purchase_contract_data (
fid NUMBER(19,0) NOT NULL,
oms_id NUMBER(20) NOT NULL,
supplier_id VARCHAR2(20 CHAR) NOT NULL,
supplier_name VARCHAR2(200 CHAR) NOT NULL,
contract_sign_date DATE NOT NULL,
contract_start_date DATE NOT NULL,
contract_end_date DATE NOT NULL,
payment_term NUMBER(5,0) NOT NULL,
goods_id VARCHAR2(20 CHAR) NOT NULL,
goods_name VARCHAR2(200 CHAR) NOT NULL,
reg_no VARCHAR2(100 CHAR) NOT NULL,
spec VARCHAR2(100 CHAR) NOT NULL,
unit VARCHAR2(20 CHAR) NOT NULL,
qty NUMBER(16,4) NOT NULL,
purchase_tax_rate NUMBER(8,4) NOT NULL,
unit_price NUMBER(16,4) NOT NULL,
goods_source_id VARCHAR2(20 CHAR) NOT NULL,
purchaser_id VARCHAR2(20 CHAR) NOT NULL,
purchaser_name VARCHAR2(100 CHAR) NOT NULL,
planner_id VARCHAR2(20 CHAR) NOT NULL,
planner_name VARCHAR2(100 CHAR) NOT NULL,
contract_entry_status NUMBER(1,0),
contract_no VARCHAR2(40 CHAR),
process_status NUMBER(1,0) DEFAULT 0 NOT NULL,
process_result VARCHAR2(1000 CHAR),
process_time TIMESTAMP(6),
create_time TIMESTAMP(6) NOT NULL,
update_time TIMESTAMP(6),
org_code VARCHAR2(10),
customer_type NUMBER(2,0) NOT NULL,
goods_mdm_code VARCHAR2(100),
supplier_mdm_code VARCHAR2(100),
CONSTRAINT pk_rpa_pcd PRIMARY KEY (fid)
);
COMMENT ON TABLE rpa_purchase_contract_data IS '采购合同数据。订单中心行级下发;按来源 fid 幂等。';
COMMENT ON COLUMN rpa_purchase_contract_data.fid IS '订单中心来源稳定唯一序号。';
COMMENT ON COLUMN rpa_purchase_contract_data.oms_id IS '订单中心单据编号。';
COMMENT ON COLUMN rpa_purchase_contract_data.supplier_id IS '供应商ID。';
COMMENT ON COLUMN rpa_purchase_contract_data.supplier_name IS '供应商名称。';
COMMENT ON COLUMN rpa_purchase_contract_data.contract_sign_date IS '合同签订日期。';
COMMENT ON COLUMN rpa_purchase_contract_data.contract_start_date IS '合同开始日期。';
COMMENT ON COLUMN rpa_purchase_contract_data.contract_end_date IS '合同结束日期。';
COMMENT ON COLUMN rpa_purchase_contract_data.payment_term IS '付款期限(账期),单位:天。';
COMMENT ON COLUMN rpa_purchase_contract_data.goods_id IS '货品ID。';
COMMENT ON COLUMN rpa_purchase_contract_data.goods_name IS '货品名称。';
COMMENT ON COLUMN rpa_purchase_contract_data.reg_no IS '注册证号。';
COMMENT ON COLUMN rpa_purchase_contract_data.spec IS '规格。';
COMMENT ON COLUMN rpa_purchase_contract_data.unit IS '单位。';
COMMENT ON COLUMN rpa_purchase_contract_data.qty IS '数量。支持非整数采购数量。';
COMMENT ON COLUMN rpa_purchase_contract_data.purchase_tax_rate IS '进货税率。0.13 表示 13%。';
COMMENT ON COLUMN rpa_purchase_contract_data.unit_price IS '采购单价。';
COMMENT ON COLUMN rpa_purchase_contract_data.goods_source_id IS '货源细单ID。';
COMMENT ON COLUMN rpa_purchase_contract_data.purchaser_id IS '采购员ID。';
COMMENT ON COLUMN rpa_purchase_contract_data.purchaser_name IS '采购员。';
COMMENT ON COLUMN rpa_purchase_contract_data.planner_id IS '计划员ID。';
COMMENT ON COLUMN rpa_purchase_contract_data.planner_name IS '计划员。';
COMMENT ON COLUMN rpa_purchase_contract_data.contract_entry_status IS '录入合同状态。null-首次下发,0-失败,1-成功。';
COMMENT ON COLUMN rpa_purchase_contract_data.contract_no IS '采购合同号。失败重录协议时填写。';
COMMENT ON COLUMN rpa_purchase_contract_data.process_status IS '处理状态。0-待处理,1-成功,2-失败,3-处理中。';
COMMENT ON COLUMN rpa_purchase_contract_data.process_result IS '处理结果。保存RPA处理结果或失败原因。';
COMMENT ON COLUMN rpa_purchase_contract_data.process_time IS '处理时间。';
COMMENT ON COLUMN rpa_purchase_contract_data.create_time IS '创建时间。';
COMMENT ON COLUMN rpa_purchase_contract_data.update_time IS '更新时间。';
COMMENT ON COLUMN rpa_purchase_contract_data.org_code IS '货主编码。';
COMMENT ON COLUMN rpa_purchase_contract_data.customer_type IS '客户类型。口径本期固定 1 账期。';
COMMENT ON COLUMN rpa_purchase_contract_data.goods_mdm_code IS '货品MDM码。';
COMMENT ON COLUMN rpa_purchase_contract_data.supplier_mdm_code IS '供应商MDM码。';
-- ---------------------------------------------------------------------------
-- 4. 合同协议录入结果
-- ---------------------------------------------------------------------------
CREATE TABLE rpa_contract_agreement_result (
fid NUMBER(19,0) NOT NULL,
oms_id NUMBER(20) NOT NULL,
action_code NUMBER(2,0) NOT NULL,
contract_status NUMBER(2,0),
contract_fail_reason VARCHAR2(500 CHAR),
contract_no VARCHAR2(40 CHAR),
agreement_status NUMBER(2,0),
agreement_fail_reason VARCHAR2(500 CHAR),
agreement_id VARCHAR2(40 CHAR),
agreement_dtl_id VARCHAR2(40 CHAR),
push_status NUMBER(1,0) DEFAULT 0 NOT NULL,
push_result VARCHAR2(500 CHAR),
push_time TIMESTAMP(6),
create_time TIMESTAMP(6) NOT NULL,
update_time TIMESTAMP(6),
create_user_id NUMBER(19,0),
create_user_name VARCHAR2(100 CHAR),
update_user_id NUMBER(19,0),
update_user_name VARCHAR2(100 CHAR),
org_code VARCHAR2(10),
CONSTRAINT pk_rpa_car PRIMARY KEY (fid)
);
CREATE SEQUENCE seq_rpa_car_fid
START WITH 1
INCREMENT BY 1
NOMAXVALUE
NOCYCLE
NOCACHE;
CREATE OR REPLACE TRIGGER trg_rpa_car_bi
BEFORE INSERT ON rpa_contract_agreement_result
FOR EACH ROW
WHEN (new.fid IS NULL)
BEGIN
SELECT seq_rpa_car_fid.NEXTVAL INTO :NEW.fid FROM dual;
END;
/
COMMENT ON TABLE rpa_contract_agreement_result IS '合同协议录入结果。本方回写缓冲。';
COMMENT ON COLUMN rpa_contract_agreement_result.fid IS 'RPA记录技术主键;接口推送时作为回执关联键。';
COMMENT ON COLUMN rpa_contract_agreement_result.oms_id IS '订单中心单据编号。';
COMMENT ON COLUMN rpa_contract_agreement_result.action_code IS '回传动作。1-合同,2-返利协议。';
COMMENT ON COLUMN rpa_contract_agreement_result.contract_status IS '合同状态。1-起草,2-成功,3-失败。';
COMMENT ON COLUMN rpa_contract_agreement_result.contract_fail_reason IS '合同录入失败原因。';
COMMENT ON COLUMN rpa_contract_agreement_result.contract_no IS '合同编号。';
COMMENT ON COLUMN rpa_contract_agreement_result.agreement_status IS '返利协议状态。1-成功,2-失败。';
COMMENT ON COLUMN rpa_contract_agreement_result.agreement_fail_reason IS '协议录入失败原因。';
COMMENT ON COLUMN rpa_contract_agreement_result.agreement_id IS '采购协议总单ID。';
COMMENT ON COLUMN rpa_contract_agreement_result.agreement_dtl_id IS '采购协议细单ID。';
COMMENT ON COLUMN rpa_contract_agreement_result.push_status IS '推送状态。0-未推送,1-推送成功,2-推送失败,3-推送中。';
COMMENT ON COLUMN rpa_contract_agreement_result.push_result IS '推送结果。';
COMMENT ON COLUMN rpa_contract_agreement_result.push_time IS '推送时间。';
COMMENT ON COLUMN rpa_contract_agreement_result.create_time IS '创建时间。';
COMMENT ON COLUMN rpa_contract_agreement_result.update_time IS '更新时间。';
COMMENT ON COLUMN rpa_contract_agreement_result.create_user_id IS '创建人ID。';
COMMENT ON COLUMN rpa_contract_agreement_result.create_user_name IS '创建人名称。';
COMMENT ON COLUMN rpa_contract_agreement_result.update_user_id IS '更新人ID。';
COMMENT ON COLUMN rpa_contract_agreement_result.update_user_name IS '更新人名称。';
COMMENT ON COLUMN rpa_contract_agreement_result.org_code IS '货主编码。';

View File

@ -1,169 +0,0 @@
# 四张暂存表 ↔ Excel 列对照
依据:`/Users/yuuuu/Downloads/RPA与订单中心接口设计_20260922.xlsx`(2026-09-30 16:24)四张「数据表」sheet。
DDL:`docs/ddl/rpa_oms_staging_tables.sql`(Oracle 11.2)。
Excel 无表英文名;表名按中文意译,未用拼音。
库是 11.2.0.4:表1/2/4 自增不用 IDENTITY,改 SEQUENCE + 触发器;约束/索引名压到 30 字节内。列集合未改。
约束(非列):表1 `oms_id` 唯一、`fid` 主键;表2/4 `fid` 主键;表3 `fid` 主键(来源唯一);表2 `bar_code` 普通索引。
## 总览
| Excel 数据表 | DDL 表名 | 列数 | 缺列 | 多列 |
| --- | --- | ---: | --- | --- |
| 销售商品对码数据 | `rpa_sales_goods_mapping_data` | 29 | 无 | 无 |
| 加购及结算商品数据 | `rpa_cart_settlement_goods_data` | 24 | 无 | 无 |
| 采购合同数据 | `rpa_purchase_contract_data` | 32 | 无 | 无 |
| 合同协议录入结果 | `rpa_contract_agreement_result` | 20 | 无 | 无 |
口径点名且 Excel 已有的列,按所在表落地:`org_code`(四张都有);MDM 在表1(货品/客户)、表3(货品/供应商);`customer_type` 仅表3;`multi_platform_flag` 仅表2;`push_status` 在表2、表4。
---
## 1. 销售商品对码数据
Excel 待确认:`oms_id` 属性是否与订单中心一致。
| Excel 序号 | 中文名 | 英文名 | Excel 属性 | DDL | 差异 |
| ---: | --- | --- | --- | --- | --- |
| 1 | 订单中心序号 | oms_id | number(20) | NUMBER(20) NOT NULL | 幂等唯一,补 NOT NULL + UNIQUE |
| 2 | 条形码 | bar_code | varchar2(40) | VARCHAR2(40) | 同 |
| 3 | 订单日期 | order_date | date | DATE | 同 |
| 4 | 销售单价 | sale_price | number(16,4) | NUMBER(16,4) | 同 |
| 5 | 销售数量 | sale_qty | number(10) | NUMBER(10) | 同 |
| 6 | 计划采购量 | plan_purchase_qty | number(10) | NUMBER(10) | 同 |
| 7 | 货品ID | goods_id | varchar2(20) | VARCHAR2(20) | 同 |
| 8 | 品名 | goods_name | varchar2(200) | VARCHAR2(200) | 同 |
| 9 | 货品规格 | goods_spec | varchar2(100) | VARCHAR2(100) | 同 |
| 10 | 厂家 | manufacturer | varchar2(100) | VARCHAR2(100) | 同 |
| 11 | 国药准字/注册证号 | approval_no | varchar2(100) | VARCHAR2(100) | 同 |
| 12 | 单位 | unit | varchar2(20) | VARCHAR2(20) | 同 |
| 13 | 客户ID | customer_id | varchar2(20) | VARCHAR2(20) | 同 |
| 14 | 客户名称 | customer_name | varchar2(200) | VARCHAR2(200) | 同 |
| 15 | 创建时间 | create_time | date | DATE | 同 |
| 16 | 创建人ID | create_user_id | number(10) | NUMBER(10) | 同 |
| 17 | 创建人名称 | create_user_name | varchar2(20) | VARCHAR2(20) | 同 |
| 18 | 更新时间 | update_time | date | DATE | 同 |
| 19 | 更新人ID | update_user_id | number(10) | NUMBER(10) | 同 |
| 20 | 更新人名称 | update_user_name | varchar2(20) | VARCHAR2(20) | 同 |
| 21 | 处理标志 | deal_flag | number(2) DEFAULT 0 NOT NULL | NUMBER(2) DEFAULT 0 NOT NULL | 同 |
| 22 | 处理结果 | deal_result | varchar2(200) | VARCHAR2(200) | 同 |
| 23 | 处理时间 | deal_time | date | DATE | 同 |
| 24 | 处理人ID | deal_user_id | number(10) | NUMBER(10) | 同 |
| 25 | 处理人名称 | deal_user_name | varchar2(20) | VARCHAR2(20) | 同 |
| 26 | 序号 | fid | number(19) 自增序列 | NUMBER(19) 主键 + SEQUENCE/触发器 | 11g 无 IDENTITY |
| 27 | 货主编码 | org_code | varchar2(10) | VARCHAR2(10) | 同 |
| 28 | 货品MDM码 | goods_mdm_code | varchar2(100) | VARCHAR2(100) | 同 |
| 29 | 客户MDM码 | customer_mdm_code | varchar2(100) | VARCHAR2(100) | 同 |
相对接口 sheet:数据表多审计字段、`deal_*`、`fid`。接口业务字段均在表中。`oms_orderno` 已删。
---
## 2. 加购及结算商品数据
Excel 待确认:同一条形码是否可出现在多个订单。平台字典已写入备注:48 药帮忙,66 1药城。
| Excel 序号 | 中文名 | 英文名 | Excel 属性 | DDL | 差异 |
| ---: | --- | --- | --- | --- | --- |
| 1 | 序号 | fid | number(19,0) IDENTITY | NUMBER(19,0) 主键 + SEQUENCE/触发器 | 11g 无 IDENTITY,主键补 NOT NULL |
| 2 | 操作类型 | operate_type | number(2,0) NOT NULL | 同 | 无 |
| 3 | 订单中心单据编号 | oms_id | number(20) NOT NULL | 同 | 无 |
| 4 | 平台类型 | platform_type | number(4,0) NOT NULL | 同 | 无 |
| 5 | 供应商名称 | supplier_name | varchar2(200 char) NOT NULL | 同 | 无 |
| 6 | 条形码 | bar_code | varchar2(40 char) NOT NULL | 同;普通索引 | 无 |
| 7 | 采购单价 | purchase_price | number(16,4) NOT NULL | 同 | 无 |
| 8 | 采购数量 | purchase_qty | number(10,0) NOT NULL | 同 | 无 |
| 9 | 采购总价 | purchase_total | number(16,4) NOT NULL | 同 | 无 |
| 10 | 整单采购状态 | status | number(2,0) | 同 | 无 |
| 11 | 整单采购失败原因 | fail_reason | varchar2(500 char) | 同 | 无 |
| 12 | 支付订单号 | pay_order_no | varchar2(40 char) | 同 | 无 |
| 13 | 支付订单明细ID | pay_order_dtl_id | varchar2(40 char) | 同 | 无 |
| 14 | 推送状态 | push_status | number(1,0) DEFAULT 0 NOT NULL | 同 | 无 |
| 15 | 推送结果 | push_result | varchar2(500 char) | 同 | 无 |
| 16 | 推送时间 | push_time | date | DATE | 无 |
| 17 | 创建时间 | create_time | date NOT NULL | DATE NOT NULL | Excel 写作 dateNOT NULL |
| 18 | 更新时间 | update_time | date | DATE | 无 |
| 19 | 创建人ID | create_user_id | number(19,0) | 同 | 无 |
| 20 | 创建人名称 | create_user_name | varchar2(100 char) | 同 | 无 |
| 21 | 更新人ID | update_user_id | number(19,0) | 同 | 无 |
| 22 | 更新人名称 | update_user_name | varchar2(100 char) | 同 | 无 |
| 23 | 货主编码 | org_code | varchar2(10) | 同 | 无 |
| 24 | 多平台满足标志 | multi_platform_flag | number(1,0) DEFAULT 0 NOT NULL | 同 | 无 |
相对接口 sheet:数据表多 `push_*`、审计、`multi_platform_flag`。
---
## 3. 采购合同数据
Excel 待确认:重录协议是否新 `fid`;税率百分数还是小数;是否保留 `need_contract` 旧名。
| Excel 序号 | 中文名 | 英文名 | Excel 属性 | DDL | 差异 |
| ---: | --- | --- | --- | --- | --- |
| 1 | 序号 | fid | number(19,0) NOT NULL | 同,主键(来源唯一) | 无 |
| 2 | 订单中心单据编号 | oms_id | number(20) NOT NULL | 同 | 无 |
| 3 | 供应商ID | supplier_id | varchar2(20 char) NOT NULL | 同 | 无 |
| 4 | 供应商名称 | supplier_name | varchar2(200 char) NOT NULL | 同 | 无 |
| 5 | 合同签订日期 | contract_sign_date | date NOT NULL | 同 | 无 |
| 6 | 合同开始日期 | contract_start_date | date NOT NULL | 同 | 无 |
| 7 | 合同结束日期 | contract_end_date | date NOT NULL | 同 | 无 |
| 8 | 付款期限(账期) | payment_term | number(5,0) NOT NULL | 同 | 无 |
| 9 | 货品ID | goods_id | varchar2(20 char) NOT NULL | 同 | 无 |
| 10 | 货品名称 | goods_name | varchar2(200 char) NOT NULL | 同 | 无 |
| 11 | 注册证号 | reg_no | varchar2(100 char) NOT NULL | 同 | 无 |
| 12 | 规格 | spec | varchar2(100 char) NOT NULL | 同 | 无 |
| 13 | 单位 | unit | varchar2(20 char) NOT NULL | 同 | 无 |
| 14 | 数量 | qty | number(16,4) NOT NULL | 同 | 无 |
| 15 | 进货税率 | purchase_tax_rate | number(8,4) NOT NULL | 同 | 无 |
| 16 | 采购单价 | unit_price | number(16,4) NOT NULL | 同 | 无 |
| 17 | 货源细单ID | goods_source_id | varchar2(20 char) NOT NULL | 同 | 无 |
| 18 | 采购员ID | purchaser_id | varchar2(20 char) NOT NULL | 同 | 无 |
| 19 | 采购员 | purchaser_name | varchar2(100 char) NOT NULL | 同 | 无 |
| 20 | 计划员ID | planner_id | varchar2(20 char) NOT NULL | 同 | 无 |
| 21 | 计划员 | planner_name | varchar2(100 char) NOT NULL | 同 | 无 |
| 22 | 录入合同状态 | contract_entry_status | number(1,0) | 同 | 无 |
| 23 | 采购合同号 | contract_no | varchar2(40 char) | 同 | 无 |
| 24 | 处理状态 | process_status | number(1,0) DEFAULT 0 NOT NULL | 同 | 无 |
| 25 | 处理结果 | process_result | varchar2(1000 char) | 同 | 无 |
| 26 | 处理时间 | process_time | timestamp(6) | 同 | 无 |
| 27 | 创建时间 | create_time | timestamp(6) NOT NULL | 同 | 无 |
| 28 | 更新时间 | update_time | timestamp(6) | 同 | 无 |
| 29 | 货主编码 | org_code | varchar2(10) | 同 | 无 |
| 30 | 客户类型 | customer_type | number(2,0) NOT NULL | 同 | 无 |
| 31 | 货品MDM码 | goods_mdm_code | varchar2(100) | 同 | 无 |
| 32 | 供应商MDM码 | supplier_mdm_code | varchar2(100) | 同 | 无 |
相对接口 sheet:数据表多 `process_*`、`create_time`、`update_time`。未建 `need_contract`。
---
## 4. 合同协议录入结果
无 Excel「待确认」列。
| Excel 序号 | 中文名 | 英文名 | Excel 属性 | DDL | 差异 |
| ---: | --- | --- | --- | --- | --- |
| 1 | 序号 | fid | number(19,0) IDENTITY | NUMBER(19,0) 主键 + SEQUENCE/触发器 | 11g 无 IDENTITY,主键补 NOT NULL |
| 2 | 订单中心单据编号 | oms_id | number(20) NOT NULL | 同 | 无 |
| 3 | 回传动作 | action_code | number(2,0) NOT NULL | 同 | 无 |
| 4 | 合同状态 | contract_status | number(2,0) | 同 | 无 |
| 5 | 合同录入失败原因 | contract_fail_reason | varchar2(500 char) | 同 | 无 |
| 6 | 合同编号 | contract_no | varchar2(40 char) | 同 | 无 |
| 7 | 返利协议状态 | agreement_status | number(2,0) | 同 | 无 |
| 8 | 协议录入失败原因 | agreement_fail_reason | varchar2(500 char) | 同 | 无 |
| 9 | 采购协议总单ID | agreement_id | varchar2(40 char) | 同 | 无 |
| 10 | 采购协议细单ID | agreement_dtl_id | varchar2(40 char) | 同 | 无 |
| 11 | 推送状态 | push_status | number(1,0) DEFAULT 0 NOT NULL | 同 | 无 |
| 12 | 推送结果 | push_result | varchar2(500 char) | 同 | 无 |
| 13 | 推送时间 | push_time | timestamp(6) | 同 | 无 |
| 14 | 创建时间 | create_time | timestamp(6) NOT NULL | 同 | 无 |
| 15 | 更新时间 | update_time | timestamp(6) | 同 | 无 |
| 16 | 创建人ID | create_user_id | number(19,0) | 同 | 无 |
| 17 | 创建人名称 | create_user_name | varchar2(100 char) | 同 | 无 |
| 18 | 更新人ID | update_user_id | number(19,0) | 同 | 无 |
| 19 | 更新人名称 | update_user_name | varchar2(100 char) | 同 | 无 |
| 20 | 货主编码 | org_code | varchar2(10) | 同 | 无 |
相对接口 sheet:数据表多 `push_*`、审计字段。

View File

@ -1,268 +0,0 @@
# 仓库布局:四接口一服务 + 四个英文作业包
| 文档属性 | 内容 |
| --- | --- |
| 日期 | 2026-10-02 |
| 状态 | 已锁定。随后 `rpa_yisaiqi/` 已删,页面脚本迁入 `rpa_contract/` |
| 范围 | 根目录命名与分家:`选品流程/` 整包迁到 `rpa_selection/`;占位 `rpa_cart/`、`rpa_contract/`、`rpa_rebate/`;`资料/` 并入 `docs/`。路径与文档引用。 |
| 替代 | 根 `README.md` 代码入口表;`资料/README.md`(并入后成为 `docs/README.md`) |
| 不变 | 四张表、四个口的作业语义、接口 1 行为、选品规则与拆量、`rpa_yisaiqi` 页面步骤 |
四个 HTTP 口不是四个服务。四条作业不是一个包。代码目录名全英文。
---
## 1. 要做成什么
根目录变成:
```
AutoRpa/
README.md
rpa_oms/ # 四个 HTTP 口,一个 FastAPI
rpa_selection/ # 选品班(现 选品流程/ 整包搬来)
rpa_cart/ # 加购 + 支付回传(占位)
rpa_contract/ # 合同班(占位)
rpa_rebate/ # 返利班(占位)
docs/ # 现行口径 + 归档(现 资料/ 并入)
rpa_yisaiqi/ # 过渡:合同班能跑之后整包删除
```
完成标准:
1. 仓库根上不再有 `选品流程/`、`资料/`。
2. `cd rpa_selection && uv run pytest -v` 与搬前同等通过。
3. `cd rpa_oms && uv run pytest -v` 通过。
4. 根 README、`docs/README.md`、`.gitignore`、现行口径里的路径指向新目录。
5. `rpa_cart/`、`rpa_contract/`、`rpa_rebate/` 各有 README 和空 `pyproject.toml`,能看出职责,没有可运行作业代码。
---
## 2. 不做什么
- 不加购、不支付回传、不录合同、不录返利
- 不实现接口 2 / 3 / 4,不在 `rpa_oms` 里建空路由模块
- 不改选品规则、班次判定、表结构、接口 1 报文
- 不整理、不改名、不删除 `rpa_yisaiqi/`(本轮只改指向它的现行文档路径,若文档从 `资料/` 搬走)
- 不把作业改成 `app/` 包;作业保持扁平脚本(`main.py` + `job.py`)
- 不把四个作业收进 `rpa_jobs/`
- 不改 `docs/superpowers/` 里历史 spec / plan 的死链(视为归档)
- 不提交 `state_center/` 等本轮无关的已删文件(那些删除不在本 spec 范围)
---
## 3. 已锁定决策
| 项 | 决定 |
| --- | --- |
| 四个口 | 继续住在 `rpa_oms/`。入站 1、3 在 `app/inbound/`,出站 2、4 做到时再加 `app/outbound/`。本轮只把这张地图写进 `rpa_oms/README.md` |
| 四个作业 | 根上并列:`rpa_selection`、`rpa_cart`、`rpa_contract`、`rpa_rebate` |
| `rpa_cart` | 加购(接口 2 `operateType=1`)和支付回传(`operateType=2`)同一个包。两个班次,共享表 2 |
| 选品 | `git mv 选品流程 rpa_selection`。业务代码、扁平 import、`pythonpath = ["."]` 不动 |
| 占位包 | 只有 `README.md` + `pyproject.toml`。无 `main.py`、无 `tests/` |
| 中文 | **代码目录**全英文。文档文件名可以中文。`docs/` 是目录名,里面的 `.md` 文件名不改 |
| `rpa_yisaiqi` | 过渡资产。合同页面步骤仍以它为准。等 `rpa_contract` 能跑再整包删 |
| `docs/superpowers/` | 原地不动,归档 |
---
## 4. `rpa_oms`:四个口的地图
本轮不改 Python 文件(README 除外)。运行时仍只开放接口 1。
| 口 | 方向 | 底表 | 代码落点(做到再写) |
| --- | --- | --- | --- |
| 1 | 订单中心 → 本方 | `rpa_sales_goods_mapping_data` | 已有 `app/inbound/sales_goods.py`,`POST /api/oms/v1/sales-goods` |
| 2 | 本方 → 订单中心 | `rpa_cart_settlement_goods_data` | 未来 `app/outbound/`,由 `rpa_cart` 班次调用,本服务也可提供 push 客户端 |
| 3 | 订单中心 → 本方 | `rpa_purchase_contract_data` | 未来 `app/inbound/`,复用接口 1 信封/鉴权 |
| 4 | 本方 → 订单中心 | `rpa_contract_agreement_result` | 未来 `app/outbound/`,由合同/返利班次调用 |
`rpa_oms/README.md` 用上表替换「本期只开放接口 1」的单一叙述,并写明:HTTP 收件不同步跑作业。
---
## 5. 四个作业包
作业语义仍以 `docs/RPA订单中心接口开发口径_20260930.md`(搬后路径)为准。包只是落点。
### 5.1 `rpa_selection/`(整包搬)
来源:`选品流程/`。
| 动作 | 细节 |
| --- | --- |
| 移动 | `git mv 选品流程 rpa_selection`。未跟踪残留(`.explore/`、`.magic/`、`output/结果` 下 html/png、本地 `.venv` / `.env`)不会跟着走,目录会留下。把需要的本地文件普通 `mv` 进 `rpa_selection/`,探测垃圾直接删,然后去掉空的 `选品流程/`。不要 `git add` `.explore` / `.magic` / 截图 |
| `pyproject.toml` | `name = "rpa-selection"`;description 改成带新目录名。依赖、`requires-python`、pytest 配置不动 |
| `README.md` | 所有 `cd 选品流程`、`` `选品流程/` `` 改成 `rpa_selection`;口径路径改 `docs/...` |
| 代码 | 不改 import、不改 `PROJECT_ROOT = Path(__file__).resolve().parent` |
| `output/结果` | 不改名(gitignored 产物)。`.gitignore` 路径跟着父目录改 |
搬完后包内仍是扁平脚本:`main.py`、`job.py`、`persist.py`、`repo.py`、`rules.py`、`pipeline.py`、`search_*.py`、`auth_login.py`、`tests/`。
### 5.2 占位包共同形态
`rpa_cart/`、`rpa_contract/`、`rpa_rebate/` 各两个文件,模板一致。
`pyproject.toml`:
```toml
[project]
name = "rpa-<slot>"
version = "0.1.0"
description = "<一句话职责>"
requires-python = ">=3.12"
dependencies = []
[tool.uv]
package = false
[[tool.uv.index]]
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
default = true
```
三个 `name` / `description`:
| 目录 | name | description |
| --- | --- | --- |
| `rpa_cart` | `rpa-cart` | 洁达加购与支付回传。表 2 → 接口 2。本包尚未实现。 |
| `rpa_contract` | `rpa-contract` | 洁达合同班。表 3 合单录入 → 表 4。本包尚未实现。 |
| `rpa_rebate` | `rpa-rebate` | 洁达返利班。按合同号查号、录协议 → 表 4。本包尚未实现。 |
每个 README 必须写清:
1. 读哪张表、写哪张表、对应哪个口
2. 班次(选品随接口 1;加购在选品之后另班;支付另班对准 12:30 / 17:30;合同/返利一天两班,返利建议合同班之后)
3. 本期不做(占位)
4. 合同包额外一句:页面步骤目前仍在 `rpa_yisaiqi/`,本包能跑之后再删艺赛旗
`rpa_cart` README 须写明两个入口将来都在本包:`operateType=1` 加购、`operateType=2` 支付回传。不要暗示会拆成第五个包。
### 5.3 作业与表的对照(写入根 README)
| 包 | 读 | 写 | 口 |
| --- | --- | --- | --- |
| `rpa_selection` | 表 1 `deal_flag=0` | 表 2 `operate_type=1`,表 1 处理标志 | 不直接调口;口 2 由 `rpa_cart` 推 |
| `rpa_cart` | 表 2 | 表 2 `push_status`;支付行 | 口 2 |
| `rpa_contract` | 表 3 `process_status=0` | 表 4 `actionCode=1` | 口 3 收件在 oms;口 4 回写 |
| `rpa_rebate` | 表 3 / 表 4 待查号与待录协议 | 表 4 `contractStatus=2`、`actionCode=2` | 口 4 |
---
## 6. `资料/` → `docs/`
`docs/` 已有 `superpowers/`。不要新建第二套文档根。`资料/` 拆开并入后删除空目录。
已跟踪的用 `git mv`;未跟踪的用普通 `mv`,改完再 `git add`。不要对未跟踪文件跑 `git mv`。
仓库根 `README.md` 当前未跟踪。本轮按第 5.3 节写好后 `git add README.md`。
`选品流程/output/结果/.gitkeep` 已跟踪,随父目录 `git mv` 进入 `rpa_selection/output/结果/.gitkeep`。目录名保持 `结果`。
### 6.1 现行(`docs/` 根下)
已跟踪(`git mv`):
| 来源 | 去向 |
| --- | --- |
| `资料/RPA订单中心接口开发口径_20260930.md` | `docs/` 同名 |
| `资料/采购平台选品条件优先项_20260902.txt` | `docs/` 同名 |
未跟踪(`mv` + `git add`):
| 来源 | 去向 |
| --- | --- |
| `资料/README.md` | `docs/README.md`(重写路径,去掉「路径先不动」) |
| `资料/RPA订单中心接口口径讨论纪要_20260929.md` | `docs/` 同名 |
| `资料/ddl/` | `docs/ddl/` |
| `资料/API鉴权.doc` | `docs/` |
| `资料/下一会话任务_建表.md` | `docs/` |
| `资料/合同系统录入流程.md` | `docs/` |
| `资料/合同返利系统字段映射_20260911.xlsx` | `docs/` |
| `资料/【项目方案】福建洁达采购合同RPA自动化录入需求_v20260924.docx` | `docs/` |
口径正文里的 `` `资料/...` `` 改成 `` `docs/...` ``;`` `选品流程/` `` 改成 `` `rpa_selection/` ``。`合同系统录入流程.md` 里指向 `rpa_yisaiqi` 的句子保留。
### 6.2 归档(`docs/archive/`)
一期 13 节点主链,以及未列入现行表的旧设计说明。
已跟踪(`git mv` 进 `docs/archive/`):
- `PLAN.md`
- `一期开发任务清单_表结构_API草案.md`
- `一期需求简版说明_业务版.md`
- `界面截图证据记录.md`
- `采购业务RPA思路20260625.docx`
- `采购主链RPA一期实施与验收规范.md`
- `采购主链RPA一期开发规划.md`
- `采购主链RPA一期设计文档.md`
- `采购主链RPA一期需求文档_三方会审版.md`
- `采购主链RPA一期需求文档_会议整理版.md`
未跟踪(`mv` + `git add` 进 `docs/archive/`):
- `福建洁达采购合同自动化设计说明.md`
这些文件内部的 `资料/` 链接不改。`docs/README.md` 的历史节指向 `docs/archive/`,并保留原警告:那套模型是「一单合同走状态机」,不要用来解释接口 1 收件箱。
### 6.3 必须改路径的现行文件
只改还在用的入口,不扫历史 spec。
| 文件 | 改什么 |
| --- | --- |
| `README.md`(仓库根) | 代码入口表换成五包;文档入口改 `docs/` |
| `docs/README.md` | 见 6.1 |
| `rpa_oms/README.md` | 四个口地图;文档链接 `docs/` |
| `rpa_selection/README.md` | 目录名与口径路径 |
| `rpa_yisaiqi/README.md` | `` `资料/合同系统录入流程.md` `` → `` `docs/合同系统录入流程.md` `` |
| `.gitignore` | `选品流程/` → `rpa_selection/`(`.vscode` 例外规则、secrets、`data/.auth`、`output/结果`) |
| `docs/RPA订单中心接口开发口径_20260930.md` | `选品流程/`、`资料/` 路径 |
| `docs/RPA订单中心接口口径讨论纪要_20260929.md` | `资料/` 路径 |
| `docs/ddl/列对照.md` | `资料/ddl/` → `docs/ddl/` |
| `docs/下一会话任务_建表.md` | `资料/` 路径 |
| `docs/合同系统录入流程.md` | 若正文有 `资料/` 自引用则改 |
`docs/superpowers/specs/2026-10-02-selection-split-allocation-design.md` 里的 `选品流程/`、`资料/` 是当时实施记录,不改。
---
## 7. `.gitignore`
`选品流程` 出现在两处,按前缀替换,不要当成一整段覆盖:
1. 顶部 `.vscode` 例外:`!选品流程/.vscode/` → `!rpa_selection/.vscode/`(两行)
2. 底部凭据与产物:`选品流程/secrets.json` 等四行,前缀改 `rpa_selection/`
其余规则不动。根 README 的代码入口表仍保留 `rpa_yisaiqi` 一行,标明过渡;「五包」指 oms + 四个作业,不是把艺赛旗从入口拿掉。
---
## 8. 错误与回归
这是搬家,没有新的运行时错误分支。失败模式只有路径漏改:
- 测试或文档还写 `选品流程`、`资料/`(现行入口)
- `cd 选品流程` 还出现在现行 README
- 占位包被误写成可运行作业(出现 `main.py` 却无实现)
回归:
```bash
cd rpa_oms && uv run pytest -v
cd rpa_selection && uv run pytest -v
```
选品单测不得改断言、不得为搬家放宽。若有测试读仓库根路径或写死 `选品流程`,改为相对包根(`Path(__file__).resolve().parents[1]` 已是包根的保持不动)。
---
## 9. 实施顺序
1. `git mv 选品流程 rpa_selection`;按 5.1 处理未跟踪残留,改该包 `pyproject.toml` 与 README
2. 建三个占位包并 `git add`
3. 按第 6 节:已跟踪文件 `git mv`,未跟踪文件 `mv` 后 `git add`;重写 `docs/README.md`
4. 写根 `README.md` 并 `git add`(该文件当前未跟踪);改 `rpa_oms/README.md`、`rpa_yisaiqi/README.md`、`.gitignore`、搬过来的现行口径
5. 跑第 8 节两条 pytest
6. 确认根上没有 `选品流程/`、`资料/`

View File

@ -1,45 +0,0 @@
# 下一会话任务:四张接口底表 DDL
先读再写,不要发挥。
必读:
1. `docs/RPA订单中心接口开发口径_20260930.md`(作业口径,列不能和口径打架)
2. `docs/RPA订单中心接口口径讨论纪要_20260929.md`
3. 接口 Excel 四张「数据表」sheet(列名、类型、备注以 Excel 为准)
当前文件:`/Users/yuuuu/Downloads/RPA与订单中心接口设计_20260922.xlsx`
若 Downloads 有更新,用最新一份。
4. 不要凭方案 5.5 加列。返利默认值是填页面用的,不是表结构。
## 交付
给出 Oracle 建表语句,四张都是 **RPA 库暂存**,不是订单中心的对码功能表。对码在订单中心做完,本方不建对码引擎。
选品这一步本方只有两张:收件 + 回传。另外两张是合同收件、合同/协议回写。
- 销售商品对码数据(接口 1):选品收件箱。Excel 沿用对方名字,实为待选品行,不是对码表。
- 加购及结算商品数据(接口 2):选品结果回传缓冲。
- 采购合同数据(接口 3)
- 合同协议录入结果(接口 4)
建议落盘:`docs/ddl/` 或仓库内现有 SQL 目录(若已有 Oracle 脚本目录,跟现有走)。
每张表包含:列、默认值、主键/唯一约束、口径里点名的索引(如条形码普通索引、接口 1 的 `oms_id` 幂等唯一、接口 3 的来源 `fid` 唯一)。表名、列名跟 Excel 英文名,不要自创拼音。
必须带上 Excel 已有、口径也锁了的列:`org_code`、MDM 码、`customer_type`、`multi_platform_flag`、`push_status` 等。`oms_id` 用 `number(20)`。
## 不要做
- 不要写选品/合同/返利页面流程
- 不要写接收 API、推送客户端
- 不要改口径文档
- 不要把测试密码写入仓库
- 接口 2、4 路径空着,DDL 不管网关
## 不确定时
Excel 与口径冲突:口径赢在作业语义,列集合以 Excel 表 sheet 为准。
Excel「待确认」列:按现稿建,不要删列。在 DDL 文件头用注释列出待确认项。
缺主键:接口 1 业务幂等是 `oms_id`;接口 2、4 的 `fid` 是本方自增主键;接口 3 的 `fid` 是订单中心来源唯一序号。
做完只交 DDL 和与 Excel 的列对照(缺了什么、多了什么)。不要展开下一阶段。

View File

@ -1,75 +0,0 @@
# 合同系统录入流程(现行)
对照:`RPA订单中心接口开发口径_20260930.md` 第 4 节。
页面动作对照:`rpa_contract/contract_flow_steps.py`、`rpa_contract/selectors/contract_create_selectors.json`、`rpa_contract/examples/`。
状态中心 job / 13 节点 / start-complete-fail **不再使用**。合单、班次、回写以口径为准;点哪些控件以 `rpa_contract` 为准。
---
## 1. 数据从哪来
订单中心推接口 3,落入 `rpa_purchase_contract_data`。合同班读 `process_status=0`。
一天两班(中午、下午)。启动瞬间冻结本班名单,跑的过程中新进的下一班再处理。
按 `supplierId` 合成一份合同:
- 不用 `omsId`,不用供应商名称
- 头字段(账期、签订日、起止日、采购员、计划员)取该班该供应商**第一行**
- 下午同一供应商另开一份,不改上午已录合同
- `contractEntryStatus=0` 且带 `contractNo`:只录返利协议,不进合单、不重开合同
旧脚本按「供应商 + 订单日期」建单,并且把签订日/开始日写成订单日期。那是上一套模型,不要抄。
---
## 2. 页面步骤(合同测试系统)
地址:`http://contract-test.gzmpc.com:8080/`
测试账号见 `rpa_contract/README.md`。客商必须是系统里真实存在的操作码,占位符搜不到。
脚本现用直达起草/模板 URL,绕过左侧菜单和「点模板」的前端委托(Selenium 点不中):
- 起草:`DRAFT_PAGE_URL`
- 非格式采购协议(2025 集团版)模板 ID:`198d10b04325279b8ffeb8b4c6493b5a` → `FORM_PAGE_URL`
顺序:
1. 打开合同系统并登录
2. 进起草,套非格式采购协议模板
3. 签约方:客户编码、客户名称(用接口 3 的供应商 MDM / 名称,以页面能搜到的为准)
4. 基础信息:合同版本类型、合同类别、结算类型、合同背景概述、合同性质=主合同
5. 金额与日期:签订日 / 开始日 / 结束日用接口 3 第一行的 `contractSignDate`、`contractStartDate`、`contractEndDate`
6. 货品明细:该班该供应商合进来的行(货品、规格、数量、单价、MDM)
7. 付款与标志:这期一律账期,**不勾预付款**;付款期限用 `paymentTerm`。页面「账期支付」对应选项「电汇」(见脚本 `_PAYMENT_ALIASES`)
8. **尽调**:看合同系统页面回显。未尽调 → 本单失败,原因「客户未尽调」,不往下填。旧脚本没有这一步,口径有
9. 点**提交**,不要点暂存。暂存号不拿去录返利
10. 从「我的合同」读正式合同号
医药城同供应商同日 PDF 合并上传,是旧主链(平台导出扫描件)的步骤。接口 3 这条链有没有附件,录合同时再核对,不要默认还去拼 PDF。
---
## 3. 提交之后
提交成功:
- 写入表 4,接口 4 `actionCode=1`,`contractStatus=1`(起草),带合同号
- 同一份合同下多个 `fid` 回同一套号和状态
- **合同任务结束。不要重录合同。**
「合同成功」不是提交成功,而是返利中心能搜到这个合同号。查号挂在返利班,搜到再回 `contractStatus=2`;搜不到不重录,下一班再搜。
---
## 4. 代码要从哪抄
| 要 | 文件 |
| --- | --- |
| 打开/登录/填表/提交/读号 | `rpa_contract/contract_flow_steps.py` |
| 字段 name / 选择器 | 同文件里的 `F`、`X`;`selectors/contract_create_selectors.json` |
| 浏览器适配 | `rpa_contract/apa_browser_adapter.py` |
| 干跑样例 | `rpa_contract/examples/contract_create_input_dry_run.json` |
旧状态中心(`callback_client.py` / `agent.py` / start-complete-fail)已从仓库删除。不要再接。

View File

@ -1,16 +0,0 @@
# rpa_cart
加购与支付回传。两个班次,同一个包,不拆第五个目录。本包尚未实现。
| 班次 | 读 | 写 | 口 |
| --- | --- | --- | --- |
| 加购 `operateType=1` | 表 2(选品已落、`push_status=0`) | 表 2 加购结果,再推口 2 | 口 2 |
| 支付回传 `operateType=2` | 商城已支付订单 | 表 2 支付行(`payOrderNo` 等) | 口 2 |
加购在选品班之后另排。支付对准采购员两班付完后(例如 12:30、17:30),有单号才推,没付不推。
选品落表在 `rpa_selection/`。HTTP 推送路径做到时进 `rpa_oms` 的 `app/outbound/`。
## 本期不做
真加购、清空购物车、支付回传、接口 2 推送。

View File

@ -1,13 +0,0 @@
[project]
name = "rpa-cart"
version = "0.1.0"
description = "洁达加购与支付回传。表 2 → 接口 2。本包尚未实现。"
requires-python = ">=3.12"
dependencies = []
[tool.uv]
package = false
[[tool.uv.index]]
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
default = true

View File

@ -1,49 +0,0 @@
# rpa_contract
合同班。页面录入脚本在本包;从表 3 合单、写表 4、推接口 4 尚未接。
| | |
| --- | --- |
| 读 | 表 3 `rpa_purchase_contract_data`(`process_status=0`) |
| 写 | 表 4 `rpa_contract_agreement_result`(接口 4 `actionCode=1`,`contractStatus=1` 起草) |
| 口 | 口 3 收件在 `rpa_oms`;口 4 回写做到时由本班调用 |
一天两班(中午、下午)。启动瞬间冻结本班名单。按 `supplierId` 合单;头字段取该班该供应商第一行。下午同一供应商另开一份。
口径:`docs/RPA订单中心接口开发口径_20260930.md` 第 4 节;合单细节见 `docs/合同系统录入流程.md`。
## 测试账号
地址:`http://contract-test.gzmpc.com:8080/`
账号:`test002 / 1`
客商必须是合同系统里真实存在的操作码,占位符搜不到。例如 `DW0100046361` / `重庆亿昊药业有限公司`。
## 页面步骤
脚本直达起草/模板 URL:
1. 打开合同系统并登录
2. 进起草,套非格式采购协议模板
3. 签约方:客户编码、客户名称
4. 基础信息:合同版本类型、合同类别、结算类型、合同背景概述、合同性质=主合同
5. 金额与日期:签订日 / 开始日 / 结束日
6. 货品明细
7. 付款与标志:这期一律账期,不勾预付款;页面「账期支付」对应选项「电汇」
8. 尽调:口径有,脚本尚未加这一闸
9. 点提交,不要点暂存
10. 从「我的合同」读正式合同号
## 运行
```bash
cd rpa_contract
uv sync --python 3.12
uv run pytest -v
uv run python main.py --input-json examples/contract_create_input_dry_run.json
```
`submitMode=dry_run` 跑到提交前,不点提交。真起草用 `examples/contract_draft_yyc.json` / `examples/contract_draft_xyy.json`。
## 本期不做
从表 3 按班次合单、尽调闸、接口 4 回写。

View File

@ -1,4 +0,0 @@
from contract_create_node import main
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -1,29 +0,0 @@
[project]
name = "rpa-contract"
version = "0.1.0"
description = "洁达合同班。表 3 合单录入 → 表 4。页面脚本已在本包;合单与接口 4 尚未接。"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"pypdf>=5.1.0",
"selenium>=4.15.0",
"webdriver-manager>=4.0.0",
"requests>=2.31.0",
]
[dependency-groups]
dev = [
"pytest>=8.0.0",
]
[tool.uv]
package = false
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
addopts = "-q"
[[tool.uv.index]]
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
default = true

View File

@ -1,15 +0,0 @@
from errors import LoginFailed, NodeError
def test_node_error_carries_default_ui_code():
exc = NodeError("元素找不到")
assert exc.error_code == "UI_ELEMENT_NOT_FOUND"
assert exc.retryable is False
assert exc.route_role == "OPS"
def test_login_failed_is_retryable():
exc = LoginFailed("登录失败")
assert exc.error_code == "SYS_LOGIN_FAIL"
assert exc.retryable is True
assert exc.route_role == "OPS"

View File

@ -1,480 +0,0 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "attrs"
version = "26.1.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
]
[[package]]
name = "certifi"
version = "2026.7.22"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
name = "cffi"
version = "2.1.1"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.5.2"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e7/c8/693809898870237d82785a03f3b2b58fe4c9f14669f84a7d4e623c92a59e/charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491", size = 367780, upload-time = "2026-09-30T04:35:30.888Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c9/87/2fea8c13dc24b3ca9c6f803a5b2dfdeae73eb4f9e12c7885ed908ff0433c/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c", size = 246730, upload-time = "2026-09-30T04:35:32.286Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a8/9e/09efac30b937722f46d3110ba30b875b24b2e3a266ed746cc4e376a94d80/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0", size = 237707, upload-time = "2026-09-30T04:35:33.709Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9e/18/70d76670b13686237863a379928d60bd10e021f17d243ab3d7014c4a5f4e/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51", size = 273050, upload-time = "2026-09-30T04:35:35.138Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/54/e2/77a8b09d5adc013ed07b95b01b8b8fa5441c4e810e83ee7e4aae2fa4d91a/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5", size = 270345, upload-time = "2026-09-30T04:35:36.502Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7f/c5/38806a25ab5e65fc178f39affeda20858efafede2fce1ffc2556cfc9fe73/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649", size = 257601, upload-time = "2026-09-30T04:35:37.919Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ae/8d/213565184708fdb263ae55e2c04ee1ff748129dd65d48ed0e3502da9c85a/charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e", size = 252222, upload-time = "2026-09-30T04:35:39.544Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7e/24/76d2cefc25472531e4c5c7dfff68865eb1c39b78482f0fdc15b46f047830/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346", size = 248482, upload-time = "2026-09-30T04:35:41.088Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7d/dc/65a801b66ab4c197e22c433ab25e7ac24324ac6f45a2269aca42cce309bf/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1", size = 241206, upload-time = "2026-09-30T04:35:42.59Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a7/95/ca9b5eabde673002c6f1e7ada1b223916fe18f6d661da7aabd4d643718f1/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875", size = 273190, upload-time = "2026-09-30T04:35:44.347Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/2d/8b/803b4d2a3f6e1740f63f1e87b04d14b42f3d4fdfe6ed7d4db2d34102b14f/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1", size = 253527, upload-time = "2026-09-30T04:35:45.915Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a9/55/93c0e5dbd085ae0471346026abbe7e0db9ea2d6fea74e51f0b5a46f233a7/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413", size = 271285, upload-time = "2026-09-30T04:35:47.49Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/95/69/0dbd0e0b9b16cfa816cdfcb3e2e3854a1f680dc07fb1245ea125e7448060/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869", size = 260010, upload-time = "2026-09-30T04:35:48.996Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/58/9d/e7b88e7b1bf403590c3b573277b5e1e488c68c7a6fbacca310a2c324e90c/charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e", size = 184125, upload-time = "2026-09-30T04:35:50.777Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/eb/e6/e6e083884cbcfd49c64865af05027fe7011be7b2d9179524f099a1b611f3/charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc", size = 207486, upload-time = "2026-09-30T04:35:52.194Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/e3/017aea0911ada7405a825c7d937eb3a13009664e2f5b38e8c4bbf2abf894/charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3", size = 196734, upload-time = "2026-09-30T04:35:53.636Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946, upload-time = "2026-09-30T04:35:55.313Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619, upload-time = "2026-09-30T04:35:56.863Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701, upload-time = "2026-09-30T04:35:58.639Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d", size = 210506, upload-time = "2026-09-30T04:36:00.147Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055", size = 366626, upload-time = "2026-09-30T04:36:01.77Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858", size = 244624, upload-time = "2026-09-30T04:36:03.389Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234", size = 235731, upload-time = "2026-09-30T04:36:04.987Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21", size = 270352, upload-time = "2026-09-30T04:36:06.412Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718", size = 267393, upload-time = "2026-09-30T04:36:07.865Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4", size = 254660, upload-time = "2026-09-30T04:36:09.248Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3", size = 249996, upload-time = "2026-09-30T04:36:10.73Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c", size = 247028, upload-time = "2026-09-30T04:36:12.294Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429", size = 240562, upload-time = "2026-09-30T04:36:13.887Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f", size = 271699, upload-time = "2026-09-30T04:36:15.41Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a", size = 252030, upload-time = "2026-09-30T04:36:16.839Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00", size = 268565, upload-time = "2026-09-30T04:36:18.468Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d", size = 257627, upload-time = "2026-09-30T04:36:19.938Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3", size = 143407, upload-time = "2026-09-30T04:36:21.376Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd", size = 183207, upload-time = "2026-09-30T04:36:22.776Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639", size = 206489, upload-time = "2026-09-30T04:36:24.306Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3", size = 195501, upload-time = "2026-09-30T04:36:25.846Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010, upload-time = "2026-09-30T04:36:27.669Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266, upload-time = "2026-09-30T04:36:29.434Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783, upload-time = "2026-09-30T04:36:30.872Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995", size = 210449, upload-time = "2026-09-30T04:36:32.625Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424", size = 367815, upload-time = "2026-09-30T04:36:34.197Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13", size = 247095, upload-time = "2026-09-30T04:36:35.803Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d", size = 233942, upload-time = "2026-09-30T04:36:37.407Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4", size = 273762, upload-time = "2026-09-30T04:36:38.904Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438", size = 269514, upload-time = "2026-09-30T04:36:40.469Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a", size = 255056, upload-time = "2026-09-30T04:36:42.02Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56", size = 251311, upload-time = "2026-09-30T04:36:43.577Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd", size = 250190, upload-time = "2026-09-30T04:36:45.185Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204", size = 237098, upload-time = "2026-09-30T04:36:46.689Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7", size = 274228, upload-time = "2026-09-30T04:36:48.31Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd", size = 252401, upload-time = "2026-09-30T04:36:50.091Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc", size = 271846, upload-time = "2026-09-30T04:36:51.719Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874", size = 258151, upload-time = "2026-09-30T04:36:53.234Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655", size = 143570, upload-time = "2026-09-30T04:36:54.816Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0", size = 186412, upload-time = "2026-09-30T04:36:56.643Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c", size = 210965, upload-time = "2026-09-30T04:36:58.205Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253", size = 200406, upload-time = "2026-09-30T04:36:59.764Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709", size = 396871, upload-time = "2026-09-30T04:37:01.543Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084", size = 249711, upload-time = "2026-09-30T04:37:03.111Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb", size = 231146, upload-time = "2026-09-30T04:37:04.721Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f", size = 271552, upload-time = "2026-09-30T04:37:06.295Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09", size = 269810, upload-time = "2026-09-30T04:37:07.905Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80", size = 259845, upload-time = "2026-09-30T04:37:09.554Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c", size = 252825, upload-time = "2026-09-30T04:37:11.274Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f", size = 252659, upload-time = "2026-09-30T04:37:12.941Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03", size = 238819, upload-time = "2026-09-30T04:37:14.659Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604", size = 272973, upload-time = "2026-09-30T04:37:16.346Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8", size = 253692, upload-time = "2026-09-30T04:37:18.212Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93", size = 268757, upload-time = "2026-09-30T04:37:19.877Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915", size = 262220, upload-time = "2026-09-30T04:37:21.583Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797, upload-time = "2026-09-30T04:37:23.235Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765, upload-time = "2026-09-30T04:37:24.891Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985, upload-time = "2026-09-30T04:37:26.634Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26", size = 367676, upload-time = "2026-09-30T04:37:28.329Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364", size = 248414, upload-time = "2026-09-30T04:37:30.167Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/26/79/e697f77464748a3ee3cf490c83d592459400d4898380d66c38366b03080c/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253", size = 244775, upload-time = "2026-09-30T04:37:31.964Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ca/87/3d42a42e18ea066e2513936fd678a00696e77878b5ae04528976abdbcb83/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0", size = 273877, upload-time = "2026-09-30T04:37:33.661Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c3/76/8a28136f3938ba9836f84280ce0c4d61ed1cf15a036b2034900c62634162/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc", size = 269455, upload-time = "2026-09-30T04:37:35.573Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229", size = 257336, upload-time = "2026-09-30T04:37:37.397Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ba/a2/8b50aa320adb880ad579518e6f718f24944804b42a88b83d267d5d444125/charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5", size = 257584, upload-time = "2026-09-30T04:37:39.522Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98", size = 251854, upload-time = "2026-09-30T04:37:41.23Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3", size = 248767, upload-time = "2026-09-30T04:37:42.883Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2", size = 274479, upload-time = "2026-09-30T04:37:44.658Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf", size = 258310, upload-time = "2026-09-30T04:37:46.529Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95", size = 271100, upload-time = "2026-09-30T04:37:48.399Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d", size = 259936, upload-time = "2026-09-30T04:37:50.023Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847", size = 186301, upload-time = "2026-09-30T04:37:51.722Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8", size = 210858, upload-time = "2026-09-30T04:37:53.427Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a", size = 200309, upload-time = "2026-09-30T04:37:55.015Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1", size = 395343, upload-time = "2026-09-30T04:37:56.743Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/04/3a/c00b50e94c964cf934c7899cd47c97952fc11dad71cc5884b3c61795b09b/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b", size = 250114, upload-time = "2026-09-30T04:37:58.607Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/50/27/d102dc880bbcffd0479ab64dfc1fb96777a854355a55e2bda72a71efadcb/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f", size = 239997, upload-time = "2026-09-30T04:38:00.511Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a5/4a/bf7ef45794dd293fab5f98a9309817977fbb845b9998f171b8cc5d8437a3/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3", size = 272313, upload-time = "2026-09-30T04:38:02.509Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e8/ee/008a2837737991474c5754bb3191010007663860979701990982a502cbaf/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e", size = 269986, upload-time = "2026-09-30T04:38:04.435Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/93/ad/bd74a283940dc910c5b14f8e4f80a248082bc9c0fcbe1f54530cb6d9cc5e/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9", size = 260202, upload-time = "2026-09-30T04:38:06.549Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/7b/ed341c66f69f688723501fac752be3d63c7159ca0d0d4174fc611e5710bb/charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a", size = 259083, upload-time = "2026-09-30T04:38:08.311Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115", size = 253081, upload-time = "2026-09-30T04:38:10.402Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c", size = 248712, upload-time = "2026-09-30T04:38:12.317Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d", size = 274176, upload-time = "2026-09-30T04:38:14.138Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d", size = 259413, upload-time = "2026-09-30T04:38:15.841Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4", size = 269434, upload-time = "2026-09-30T04:38:17.626Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b", size = 262716, upload-time = "2026-09-30T04:38:19.214Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800", size = 197159, upload-time = "2026-09-30T04:38:21.037Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21", size = 221322, upload-time = "2026-09-30T04:38:22.886Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58", size = 209192, upload-time = "2026-09-30T04:38:24.648Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "idna"
version = "3.20"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "outcome"
version = "1.3.0.post0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "attrs" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b" },
]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pypdf"
version = "6.19.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1f/ac/63d71aaedb59acbcdef491e6ca6469165e3771c9c74358204818fd9bc5a6/pypdf-6.19.0.tar.gz", hash = "sha256:bbc43aca292369ccc6cbc8a921991ecf2538a3587ab5a116eff06c321d647155", size = 7033266, upload-time = "2026-09-16T09:32:05.946Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3c/2c/c43c03eaf630435f023f1dc61ec4a4a78951ad5530a62c71cc89bde307b7/pypdf-6.19.0-py3-none-any.whl", hash = "sha256:7e5d6e730e7dae87d560a2cee218b852f6498c8be61966f3cd02ead971e48d14", size = 395480, upload-time = "2026-09-16T09:32:04.087Z" },
]
[[package]]
name = "pysocks"
version = "1.7.1"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.4"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" },
]
[[package]]
name = "requests"
version = "2.34.2"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
]
[[package]]
name = "rpa-contract"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "pypdf" },
{ name = "requests" },
{ name = "selenium" },
{ name = "webdriver-manager" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
{ name = "pypdf", specifier = ">=5.1.0" },
{ name = "requests", specifier = ">=2.31.0" },
{ name = "selenium", specifier = ">=4.15.0" },
{ name = "webdriver-manager", specifier = ">=4.0.0" },
]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=8.0.0" }]
[[package]]
name = "selenium"
version = "4.50.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "certifi" },
{ name = "trio" },
{ name = "trio-websocket" },
{ name = "typing-extensions" },
{ name = "urllib3", extra = ["socks"] },
{ name = "websocket-client" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1f/6c/09a5bdf96a5f850a2bf69ee670a246ae30a132dca3975c7fb081aae09338/selenium-4.50.0.tar.gz", hash = "sha256:80402fb6f48e0ee2183ec1d58b257a593dcd5c8130c1e6419ee7509f2128be5d", size = 1051762, upload-time = "2026-09-30T19:49:33.506Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f7/d3/fedaee179dcd47a7033bb5a74b7d5742da18e46d4ef3b279277c887de64a/selenium-4.50.0-py3-none-any.whl", hash = "sha256:f724a38a7fd8b0561c6767c620bd9b2c7bd8012d4cb664b2ad23064e5d67d0df", size = 12080288, upload-time = "2026-09-30T19:49:30.734Z" },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "sortedcontainers"
version = "2.4.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
]
[[package]]
name = "trio"
version = "0.34.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "attrs" },
{ name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" },
{ name = "idna" },
{ name = "outcome" },
{ name = "sniffio" },
{ name = "sortedcontainers" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/92/dc/a2d25ed73ad49cfd79bf18d262577c3731c98e382284e28d522f49a0df35/trio-0.34.0.tar.gz", hash = "sha256:63b9485408bdfdde544fced107045a8c0086cdc4bd0ef2f797b9e0dd111b964b", size = 607457, upload-time = "2026-08-11T00:33:42.198Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/77/1f/555f1364bed52a92a864181962b77f1b15adadeacf23b86105324363e461/trio-0.34.0-py3-none-any.whl", hash = "sha256:6c7c9f49917694dcdcd5f67abd168df5599eca480d61f29854d17a61a75c2f05", size = 511840, upload-time = "2026-08-11T00:33:40.552Z" },
]
[[package]]
name = "trio-websocket"
version = "0.12.2"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "outcome" },
{ name = "trio" },
{ name = "wsproto" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/3c/8b4358e81f2f2cfe71b66a267f023a91db20a817b9425dd964873796980a/trio_websocket-0.12.2.tar.gz", hash = "sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae", size = 33549, upload-time = "2025-02-25T05:16:58.947Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c7/19/eb640a397bba49ba49ef9dbe2e7e5c04202ba045b6ce2ec36e9cadc51e04/trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6", size = 21221, upload-time = "2025-02-25T05:16:57.545Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "urllib3"
version = "2.8.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
]
[package.optional-dependencies]
socks = [
{ name = "pysocks" },
]
[[package]]
name = "webdriver-manager"
version = "4.1.2"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "packaging" },
{ name = "python-dotenv" },
{ name = "requests" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/65/e5/78d362c3507473d27708068fa66c2439b55127571351a096c7ec8eb85e9c/webdriver_manager-4.1.2.tar.gz", hash = "sha256:230515ff4dc4c2feff1add25306ca262421b31016d77ded305e8dd921266445f", size = 38948, upload-time = "2026-06-04T06:00:00.895Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/df/2c/7414ab135e99df7d9cc4a4afbd21b46906a9397aa2519beac50f6517138e/webdriver_manager-4.1.2-py3-none-any.whl", hash = "sha256:552bbb4e6f95e6e8b772565d2023e7c0ec5ff1736db0b186922d0466fc78c3de", size = 32512, upload-time = "2026-06-04T05:59:59.565Z" },
]
[[package]]
name = "websocket-client"
version = "1.9.2"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/cb/a5abcc2891249f393827c650c6296660ce40374ac22d99ab9aea41f9d2a2/websocket_client-1.9.2.tar.gz", hash = "sha256:0fcb57545848be86992e128218fd96dd87a6769ffdb1a968dff79632b85604d0", size = 84110, upload-time = "2026-08-31T14:08:40.964Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d5/d2/cc4dc1271e464942db7ee278baae2daa99ee77cb2af744025c04da585a3e/websocket_client-1.9.2-py3-none-any.whl", hash = "sha256:e1a673830a9c7bfa47b1cd3d5e4178f4c9651d80a4eab02c9c23a1c3ec6250ce", size = 95786, upload-time = "2026-08-31T14:08:39.899Z" },
]
[[package]]
name = "wsproto"
version = "1.3.2"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "h11" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" },
]

89
rpa_mock/mock_orders.py Normal file
View File

@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""
Mock 医药城/小药药订单数据生成器
用于在没有真实医药平台访问权限时,模拟 ORDER_FETCH 节点的输出
"""
import pandas as pd
from datetime import datetime, timedelta
import random
import os
# 假订单数据模板
MOCK_SUPPLIERS = [
"重庆亿昊药业",
"华润医药",
"国药控股",
"上海医药"
]
MOCK_PRODUCTS = [
{"name": "阿卡波糖片", "spec": "50mg*30片", "manufacturer": "示例制药", "approval_no": "国药准字H00000001"},
{"name": "二甲双胍片", "spec": "0.5g*60片", "manufacturer": "示例制药", "approval_no": "国药准字H00000002"},
{"name": "格列美脲片", "spec": "2mg*10片", "manufacturer": "示例制药", "approval_no": "国药准字H00000003"},
{"name": "阿托伐他汀钙片", "spec": "10mg*7片", "manufacturer": "示例制药", "approval_no": "国药准字H00000004"},
]
def generate_mock_orders(supplier_name: str, order_date: str, source_type: str = "YYC", item_count: int = 5):
"""
生成模拟订单Excel
Args:
supplier_name: 供应商名称
order_date: 订单日期 YYYY-MM-DD
source_type: 来源类型 YYC/XYY/OFFLINE
item_count: 订单明细行数
Returns:
生成的Excel文件路径
"""
rows = []
for i in range(item_count):
product = random.choice(MOCK_PRODUCTS)
rows.append({
"订单号": f"DD-{order_date.replace('-', '')}-{i+1:03d}",
"订单日期": order_date,
"供应商名称": supplier_name,
"药品通用名": product["name"],
"规格": product["spec"],
"单位": "盒",
"数量": random.randint(10, 500),
"单价": round(random.uniform(5.0, 200.0), 2),
"生产厂家": product["manufacturer"],
"批准文号": product["approval_no"],
})
df = pd.DataFrame(rows)
# 生成文件路径
output_dir = "/tmp/mock_rpa_data"
os.makedirs(output_dir, exist_ok=True)
filename = f"orders_{supplier_name}_{order_date}_{source_type}.xlsx"
filepath = os.path.join(output_dir, filename)
df.to_excel(filepath, index=False, sheet_name="订单明细")
print(f"✓ 生成模拟订单: {filepath}")
print(f" - 供应商: {supplier_name}")
print(f" - 订单日期: {order_date}")
print(f" - 明细行数: {len(rows)}")
return filepath
if __name__ == "__main__":
# 生成几个测试订单
test_date = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
print("生成模拟订单数据...\n")
# 医药城订单(线上)
generate_mock_orders("重庆亿昊药业", test_date, "YYC", item_count=8)
# 小药药订单(线上)
generate_mock_orders("华润医药", test_date, "XYY", item_count=5)
# 线下手工单
generate_mock_orders("国药控股", test_date, "OFFLINE", item_count=3)
print("\n✓ 全部完成,文件位于: /tmp/mock_rpa_data/")

View File

@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""
ORDER_FETCH 节点 Mock 实现
模拟从医药城/小药药导出订单,实际返回本地生成的假数据
"""
import requests
import hashlib
from pathlib import Path
from mock_orders import generate_mock_orders
from datetime import datetime, timedelta
STATE_CENTER_URL = "http://localhost:8000" # 状态中心地址
AGENT_TOKEN = "dev-agent-token-12345" # 开发令牌(和state_center的配置一致)
def calculate_file_hash(filepath: str) -> str:
"""计算文件SHA256哈希"""
sha256 = hashlib.sha256()
with open(filepath, 'rb') as f:
for chunk in iter(lambda: f.read(4096), b''):
sha256.update(chunk)
return f"sha256:{sha256.hexdigest()}"
def mock_order_fetch(job_id: str, order_date: str, source_type: str = "YYC"):
"""
模拟 ORDER_FETCH 节点执行
Args:
job_id: 单据ID
order_date: 订单日期
source_type: 来源类型 YYC/XYY/OFFLINE
"""
print(f"\n{'='*60}")
print(f"🤖 [Mock RPA] ORDER_FETCH 开始执行")
print(f" Job ID: {job_id}")
print(f" 订单日期: {order_date}")
print(f" 来源: {source_type}")
print(f"{'='*60}\n")
# 1. 调用 start 回调
print("📞 调用状态中心 /start 回调...")
start_resp = requests.post(
f"{STATE_CENTER_URL}/api/jobs/{job_id}/nodes/ORDER_FETCH/start",
headers={"X-Agent-Token": AGENT_TOKEN},
json={
"attemptNo": 1,
"executorRunId": f"MOCK-RUN-{datetime.now().strftime('%Y%m%d%H%M%S')}",
"executorPlatform": "mock-rpa",
"operator": "mock-script"
}
)
print(f" 响应: {start_resp.status_code} - {start_resp.json()}\n")
# 2. 生成假订单数据
print("📦 生成模拟订单数据...")
supplier_name = "重庆亿昊药业" if source_type == "YYC" else "华润医药"
excel_path = generate_mock_orders(supplier_name, order_date, source_type, item_count=8)
print()
# 3. 计算文件哈希
file_hash = calculate_file_hash(excel_path)
# 4. 调用 complete 回调
print("📞 调用状态中心 /complete 回调...")
order_nos = [f"DD-{order_date.replace('-', '')}-{i+1:03d}" for i in range(8)]
complete_resp = requests.post(
f"{STATE_CENTER_URL}/api/jobs/{job_id}/nodes/ORDER_FETCH/complete",
headers={"X-Agent-Token": AGENT_TOKEN},
json={
"attemptNo": 1,
"executorRunId": f"MOCK-RUN-{datetime.now().strftime('%Y%m%d%H%M%S')}",
"executorPlatform": "mock-rpa",
"operator": "mock-script",
"summary": {
"orderCount": 8,
"supplierList": [supplier_name],
"orderNoList": order_nos
},
"artifacts": [
{
"artifactType": "SOURCE_EXCEL",
"storagePath": excel_path,
"fileHash": file_hash
}
]
}
)
print(f" 响应: {complete_resp.status_code}")
result = complete_resp.json()
print(f" next_node: {result.get('next_node')}")
print(f" job_status: {result.get('job_status')}\n")
print(f"✅ ORDER_FETCH 完成!下一节点: {result.get('next_node')}\n")
return result
if __name__ == "__main__":
# 示例:模拟昨天的医药城订单
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
job_id = f"YYC-重庆亿昊药业-{yesterday.replace('-', '')}-001"
mock_order_fetch(job_id, yesterday, "YYC")

View File

@ -1,33 +1,38 @@
# rpa_oms # rpa_oms
订单中心四个 HTTP 口,一个 FastAPI。HTTP 收件不同步跑作业。作业在 `rpa_selection` / `rpa_cart` / `rpa_contract` / `rpa_rebate`。 订单中心四接口。本期只开放接口 1:销售商品对码数据接收。
口径:`docs/RPA订单中心接口开发口径_20260930.md`。DDL:`docs/ddl/rpa_oms_staging_tables.sql`。
| 口 | 方向 | 底表 | 代码 |
| --- | --- | --- | --- |
| 1 | 订单中心 → 本方 | `rpa_sales_goods_mapping_data` | 已有 `app/inbound/sales_goods.py`,`POST /api/oms/v1/sales-goods` |
| 2 | 本方 → 订单中心 | `rpa_cart_settlement_goods_data` | 未做。将来 `app/outbound/`,由 `rpa_cart` 调用 |
| 3 | 订单中心 → 本方 | `rpa_purchase_contract_data` | 未做。将来 `app/inbound/`,复用接口 1 信封/鉴权 |
| 4 | 本方 → 订单中心 | `rpa_contract_agreement_result` | 未做。将来 `app/outbound/`,由合同/返利班调用 |
本期只开放接口 1:
- 路径:`POST /api/oms/v1/sales-goods` - 路径:`POST /api/oms/v1/sales-goods`
- 鉴权:header `X-Oms-Token`,环境变量 `OMS_INBOUND_TOKEN` - 鉴权:header `X-Oms-Token`,环境变量 `OMS_INBOUND_TOKEN`
- 落表:`rpa_sales_goods_mapping_data`(`deal_flag=0`) - 落表:`rpa_sales_goods_mapping_data`(`deal_flag=0`)
- 请求里不跑选品 - 请求里不跑选品
运行时只落 Oracle。单测用内存替身,不连库:`uv run pytest -v`。 ## 本地(内存仓库)
```bash
cd rpa_oms
uv sync --python 3.12
export OMS_INBOUND_TOKEN=dev-oms-token
export OMS_REPO=memory
uv run uvicorn app.main:app --reload --port 8001
```
```bash
curl -sS http://127.0.0.1:8001/api/oms/v1/sales-goods \
-H 'Content-Type: application/json' \
-H 'X-Oms-Token: dev-oms-token' \
-d @examples/sales_goods_batch.json
```
单测:`uv run pytest -v`
## 真库 ## 真库
Oracle 11.2 需要 Instant Client(thick)。密码只放环境变量。 Oracle 11.2 需要 Instant Client(thick)。密码只放环境变量。
```bash ```bash
cd rpa_oms export OMS_REPO=oracle
uv sync --python 3.12 export OMS_INBOUND_TOKEN=...
export OMS_INBOUND_TOKEN=dev-oms-token
export RPA_ORACLE_HOST=192.168.12.244 export RPA_ORACLE_HOST=192.168.12.244
export RPA_ORACLE_PORT=1521 export RPA_ORACLE_PORT=1521
export RPA_ORACLE_SERVICE=rpa export RPA_ORACLE_SERVICE=rpa
@ -36,7 +41,6 @@ export RPA_ORACLE_PASSWORD=...
export RPA_ORACLE_THICK=1 export RPA_ORACLE_THICK=1
# 如需指定 Instant Client 目录: # 如需指定 Instant Client 目录:
# export RPA_ORACLE_CLIENT_LIB=/path/to/instantclient # export RPA_ORACLE_CLIENT_LIB=/path/to/instantclient
uv run uvicorn app.main:app --reload --port 8001
``` ```
缺 `RPA_ORACLE_HOST` / 用户 / 密码时,收件接口回 503。 缺 `RPA_ORACLE_HOST` / 用户 / 密码时,收件接口回 503。
@ -50,6 +54,7 @@ PowerShell:
```powershell ```powershell
cd rpa_oms cd rpa_oms
uv sync --python 3.12 uv sync --python 3.12
$env:OMS_REPO = "oracle"
$env:OMS_INBOUND_TOKEN = "dev-oms-token" $env:OMS_INBOUND_TOKEN = "dev-oms-token"
$env:RPA_ORACLE_HOST = "192.168.12.244" $env:RPA_ORACLE_HOST = "192.168.12.244"
$env:RPA_ORACLE_PORT = "1521" $env:RPA_ORACLE_PORT = "1521"

View File

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

View File

@ -7,6 +7,7 @@
""" """
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
@ -14,14 +15,21 @@ 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。单测用 dependency_overrides 注入内存替身。 # 默认走 Oracle。本机连不上库时 OMS_REPO=memory。
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,15 +0,0 @@
# rpa_rebate
返利班。本包尚未实现。
| | |
| --- | --- |
| 读 | 表 3 / 表 4:已有合同号尚未回合同成功的单,以及只录协议的单 |
| 写 | 表 4:搜到合同号则 `contractStatus=2`;录协议后 `actionCode=2`、`agreementStatus=1` |
| 口 | 口 4 |
与合同同一天两班,建议合同班跑完再跑。查号挂在本班,不单开查状态任务。搜不到不重录合同,留给下一班再搜。
## 本期不做
返利中心查号、录协议、送审、接口 4 回写。

View File

@ -1,13 +0,0 @@
[project]
name = "rpa-rebate"
version = "0.1.0"
description = "洁达返利班。按合同号查号、录协议 → 表 4。本包尚未实现。"
requires-python = ">=3.12"
dependencies = []
[tool.uv]
package = false
[[tool.uv.index]]
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
default = true

191
rpa_yisaiqi/README.md Normal file
View File

@ -0,0 +1,191 @@
# 艺赛旗联调包
这个目录是 Windows 和 macOS 共用的 RPA 包。状态机在 `state_center`,这里只负责回调外壳和节点 runner。
Mac 上先用 mock runner 把契约跑通:
```bash
# 状态中心已启动、DEV_MODE=true
python -m rpa_yisaiqi --create --auto-human
```
Agent 只听状态中心的当前节点和 `next_node`,不写死顺序。人工节点默认停下;加 `--auto-human` 才会替你提交待办。
## CONTRACT_CREATE 联调骨架
第一版目标是先验证:
1. Windows 虚拟机能访问状态中心。
2. `CONTRACT_CREATE` 能按约定调用 `start`。
3. 合同系统动作完成后能调用 `complete`,并带 `businessRefs.contractSystemNo`。
4. 登录失败、页面改版、合同禁用等异常能调用 `fail`。
## 合同节点步骤点
来自 `state_center/给艺赛旗的开发交接.md`:
1. 打开合同测试系统:`http://contract-test.gzmpc.com:8080/`
2. 登录:`test002 / 1`
3. 左侧菜单进入 `合同管理 > 合同起草`
4. 在最近使用模板中选择 `templateName`,非格式默认 `非格式--采购协议`
5. 填签约方信息:客户编码、客户名称
6. 填基础信息:合同版本类型、合同类别、结算类型、合同背景概述、合同性质
7. 填金额和日期,合同签订日期和合同开始日期必须等于订单日期
8. 填货品明细
9. 填付款信息与特殊标志:是否重大合同、是否预付款、归属年份、付款方式、付款期限、合同状态、是否包含血液制品
10. 医药城 `YYC`:同供应商同日 PDF 先合并成一个再上传
11. 小药药 `XYY`:格式合同,跳过合同证文上传
12. 提交并读取合同编号
13. 调状态中心 `complete`,必传 `businessRefs.contractSystemNo`
## 先做联通验证
在项目根目录执行:
```powershell
python -m rpa_yisaiqi.contract_create_node `
--state-center-url http://127.0.0.1:8000 `
--token dev-agent-token `
--job-id YYC-联调测试供应商-20260811-a6e5df `
--supplier-name 联调测试供应商 `
--order-date 2026-08-11 `
--source-type YYC `
--contract-system-no HT-20260811-001
```
这条只验证回调链路,合同号由命令行传入。
## 真实合同起草运行
客商必须用合同测试系统里**真实存在**的操作码。`CUST-YYC-001` 这类占位符会在选客商时搜不到,入参校验会直接拒绝。
测试环境当前可用:
| 字段 | 值 |
| --- | --- |
| customerCode | `DW0100046361` |
| customerName | `重庆亿昊药业有限公司` |
联调入参用:
- `examples/contract_create_input_dry_run.json`:真客商 + `dry_run`,先校准页面
- `examples/contract_draft_yyc.json`:医药城,同上客商
- `examples/contract_draft_xyy.json`:小药药分支(不上传 PDF),客商仍用合同系统里能搜到的同一条
YYC 有多份平台 PDF 时需要合并,先安装:
```powershell
pip install -r rpa_yisaiqi/requirements.txt
```
然后运行:
```powershell
python -m rpa_yisaiqi.contract_create_node `
--state-center-url http://192.168.x.x:8000 `
--token dev-agent-token `
--job-id <状态中心job_id> `
--supplier-name 重庆亿昊药业有限公司 `
--order-date 2026-08-11 `
--source-type YYC `
--draft-path rpa_yisaiqi/examples/contract_draft_yyc.json `
--merged-pdf-output C:/rpa/jobs/<状态中心job_id>/contract-merged.pdf
```
这条会走真实起草流程:
1. 状态中心 `/start`
2. 打开合同系统并登录
3. 进入 `合同管理 > 合同起草`
4. 选择最近使用模板
5. 填签约方、基础信息、金额日期、货品明细、付款和特殊标志
6. YYC 合并并上传 PDF,XYY 跳过上传
7. 提交
8. 读取合同编号
9. 状态中心 `/complete`
### 先做 dry-run 预览
如果你只想先验证页面,不想真的提交,把输入里的 `submitMode` 设成 `dry_run`。这时流程会跑到提交前并输出 `contract-dry-run-ready` 截图,不会点提交,也不会读取真实合同号。
适合先校准 selector 的情况:
1. 进入 `合同管理 > 合同起草`
2. 选择最近使用模板
3. 填签约方、基础信息、金额日期、明细、付款和特殊标志
4. 停在提交前
如果状态中心部署在 macOS 主机,`--state-center-url` 换成 Windows 虚拟机能访问到的地址,例如:
```powershell
--state-center-url http://192.168.x.x:8000
```
## 艺赛旗画布怎么看
Studio 里「插入代码块」标题改不了,会一直显示「插入代码块#1」。步骤名写在节点**备注**里,画布上出现在代码块下方,例如 `S06 填写签约方`。
流程按四段换行,不要再排成一条长龙:
```
准备 开始 → S01 初始化 → S02 打开系统 → S03 登录
进入起草 S04 进入起草 → S05 选择模板
填写表单 S06 签约方 → S07 基础信息 → S08 金额日期 → S09 货品 → S10 付款
提交回写 S11 上传PDF → S12 提交 → S13 读合同号 → S14 回调
```
改步骤后先关掉 Studio,再生成画布:
```powershell
python rpa_yisaiqi/build_multinode_seq.py
```
然后重新打开 `rpa_yisaiqi/studio` 项目并编译。
## 接入真实页面自动化
真实艺赛旗页面操作按流程图拆在:
- `flows/contract_create_flow.json`:流程图节点清单
- `contract_flow_steps.py`:每个流程图节点对应的动作函数
- `yisaiqi_adapter_template.py`:艺赛旗浏览器活动适配模板
艺赛旗设计器里建议按这个顺序拖流程图:
1. `S01_CALLBACK_START`:HTTP 请求状态中心 `/start`
2. `S02_OPEN_CONTRACT_SYSTEM`:打开合同系统
3. `S03_LOGIN`:输入 `j_username`、`j_password`,点击 `btn_submit`
4. `S04_ENTER_CONTRACT_DRAFT`:进入 `合同管理 > 合同起草`
5. `S05_SELECT_RECENT_TEMPLATE`:选择最近使用模板
6. `S06_FILL_SIGNATORY_INFO`:填客户编码、客户名称
7. `S07_FILL_CONTRACT_BASIC_FORM`:填合同类别、结算类型、背景概述、合同性质
8. `S08_FILL_AMOUNT_AND_DATES`:填金额、签订日期、开始日期、结束日期
9. `S09_FILL_GOODS_LINES`:循环填货品明细
10. `S10_FILL_PAYMENT_AND_FLAGS`:填付款方式、付款期限、合同状态、是否包含血液制品等
11. `S11_MERGE_PDF_IF_YYC`:`sourceType=YYC` 时先合并同供应商同日 PDF;`XYY` 跳过
12. `S12_UPLOAD_PDF_IF_YYC`:`sourceType=YYC` 时上传合并 PDF;`XYY` 跳过
13. `S13_SUBMIT`:提交
14. `S14_READ_CONTRACT_NO`:读取合同编号到变量 `contractSystemNo`
15. `S15_CALLBACK_COMPLETE`:HTTP 请求状态中心 `/complete`
如果只做预览,把这条流程里的 `submitMode` 设成 `dry_run`,或者直接导入 `flows/contract_create_preview_flow.json`。
如果艺赛旗支持 Python 脚本块,直接把每个 `contract_flow_steps.py` 函数挂到对应流程图节点。
如果艺赛旗只支持原生活动,就按 `flows/contract_create_flow.json` 的节点顺序拖活动,用 `yisaiqi_adapter_template.py` 里的方法名做映射。
外层回调骨架仍然在 `contract_create_node.py`:
```python
create_contract_in_contract_system(spec)
```
这个函数最后返回合同编号字符串即可。状态中心回调、幂等键、错误码映射已经封在外层。后续接真页面时,可以在这里调用艺赛旗流程图,或让流程图最后把合同编号回传给这个函数。
## 异常映射
| 场景 | 异常类 | 回调错误码 | retryable | routeRole |
| --- | --- | --- | --- | --- |
| 登录失败 | `LoginFailed` | `SYS_LOGIN_FAIL` | true | OPS |
| 供应商合同被禁/失效 | `SupplierContractDisabled` | `BIZ_CONTRACT_DISABLED` | false | PURCHASER |
| PDF 合并能力不支持 | `PdfMergeUnsupported` | `CAP_PDF_MERGE_UNSUPPORTED` | false | OPS |
| 页面改版/元素找不到 | `ContractCreateError` | `UI_ELEMENT_NOT_FOUND` | false | OPS |

2
rpa_yisaiqi/__init__.py Normal file
View File

@ -0,0 +1,2 @@
"""YiSaiQi RPA integration helpers."""

59
rpa_yisaiqi/__main__.py Normal file
View File

@ -0,0 +1,59 @@
"""Run the portable agent against a state center.
Examples:
python -m rpa_yisaiqi --create --auto-human
python -m rpa_yisaiqi --job-id YYC-xxx --auto-human
"""
from __future__ import annotations
import argparse
import os
from datetime import datetime, timedelta
from uuid import uuid4
from .agent import JobAgent
from .callback_client import StateCenterClient
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Drive one job through the state center.")
parser.add_argument("--state-center-url", default=os.getenv("STATE_CENTER_URL", "http://127.0.0.1:8000"))
parser.add_argument("--token", default=os.getenv("AGENT_TOKEN", "dev-agent-token"))
parser.add_argument("--job-id")
parser.add_argument("--create", action="store_true", help="create a new job before running")
parser.add_argument("--source-type", choices=("YYC", "XYY", "OFFLINE"), default="YYC")
parser.add_argument("--supplier-name", default=None)
parser.add_argument("--order-date", default=None)
parser.add_argument("--auto-human", action="store_true")
parser.add_argument("--auto-retry", action="store_true")
parser.add_argument("--load-fixture-goods", action="store_true", default=True)
parser.add_argument("--no-load-fixture-goods", action="store_false", dest="load_fixture_goods")
return parser.parse_args()
def main() -> None:
args = parse_args()
client = StateCenterClient(args.state_center_url, args.token)
job_id = args.job_id
if args.create or not job_id:
order_date = args.order_date or (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
supplier = args.supplier_name or f"重庆亿昊药业-{uuid4().hex[:4]}"
created = client.create_job(args.source_type, order_date, supplier, created_by="rpa-agent")
job_id = created["job_id"]
print(f"created job {job_id}")
if args.load_fixture_goods:
loaded = client.load_fixture_goods()
print(f"fixture goods loaded: {loaded.get('loaded')}")
history = JobAgent(
client,
job_id,
runner_mode="mock",
auto_human=args.auto_human,
auto_retry=args.auto_retry,
).run()
last = history[-1]
print(f"job {job_id} stopped={last.get('stopped')} next_node={last.get('next_node')} steps={len(history)}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,17 @@
# coding=utf-8
"""多节点流程图(Main.seq)的跨节点共享状态容器。
艺赛旗编译 Main.seq 后,各代码节点的 PBCode 会在同一 Python 进程中执行。
本模块以「模块级全局变量」承载 browser / ctx / client / spec 等对象,
避免依赖艺赛旗对局部变量的拼接方式,让每个节点都能可靠地取到上一步的产物。
"""
spec = None
client = None
ctx = None
browser = None
submitted = False
contract_no = ""
fd_id = ""
is_dry_run = False
completed = False

125
rpa_yisaiqi/agent.py Normal file
View File

@ -0,0 +1,125 @@
"""Drive one job by asking the state center where it is, then acting."""
from __future__ import annotations
import uuid
from typing import Any
from .runners import mock as mock_runners
from .runtime.shell import execute_node
HUMAN_NODES = {"SOURCE_ADD", "M5_MANUAL_CONFIRM", "M5_RECEIPT_NOTICE"}
class JobAgent:
def __init__(
self,
client: Any,
job_id: str,
*,
runner_mode: str = "mock",
auto_human: bool = False,
auto_retry: bool = False,
operator: str = "mock-agent",
):
self.client = client
self.job_id = job_id
self.runner_mode = runner_mode
self.auto_human = auto_human
self.auto_retry = auto_retry
self.operator = operator
def step(self) -> dict[str, Any]:
detail = self.client.get_job(self.job_id)
job = detail["job"]
tasks = detail.get("tasks") or []
status = job.get("status")
node = job.get("current_node_code")
if status == "SUCCESS":
return {"stopped": "SUCCESS", "next_node": None}
if status == "FAILED_RETRYABLE":
if not self.auto_retry:
return {"stopped": "FAILED_RETRYABLE"}
return self.client.retry_job(self.job_id)
if status == "WAITING_USER" or node in HUMAN_NODES:
if not self.auto_human:
return {"stopped": "WAITING_USER"}
return self._submit_human(job, tasks)
if node == "PRODUCT_MATCH":
return self.client.match_run(self.job_id)
if node == "CONTRACT_APPROVE_WAIT":
return self.client.check_contract_result(self.job_id, True, operator=self.operator)
if node == "ARCHIVE_JOB":
return {"stopped": "SUCCESS", "next_node": None}
return execute_node(
self.client,
self.job_id,
node,
lambda: self._run(node, job),
attempt_no=int(job.get("attempt_no") or 1),
operator=self.operator,
)
def _run(self, node_code: str, job: dict[str, Any]) -> dict[str, Any]:
if self.runner_mode != "mock":
raise RuntimeError(f"unsupported runner_mode {self.runner_mode}")
return mock_runners.run(node_code, job, self.client)
def _submit_human(self, job: dict[str, Any], tasks: list[dict[str, Any]]) -> dict[str, Any]:
node = job["current_node_code"]
task = _open_task(tasks, node)
if task is None and hasattr(self.client, "list_tasks"):
listed = self.client.list_tasks(status="OPEN")
task = _open_task(listed.get("items") or [], node, self.job_id)
if task is None:
return {"stopped": "WAITING_USER", "reason": "no_open_task"}
action = "CONFIRM_AND_CONTINUE"
payload: dict[str, Any] = {}
if node == "PRODUCT_MATCH":
action = "SELECT_CANDIDATE"
payload = {
"productMatchId": task.get("product_match_id"),
"goodsId": "G-001",
"goodsCode": "YP-001",
"baseUnit": "盒",
"saveAsAlias": True,
}
elif node == "M5_RECEIPT_NOTICE":
payload = {"receiptNoticeNo": f"SH{uuid.uuid4().hex[:8].upper()}"}
return self.client.submit_task(
task["task_id"],
action,
payload,
submit_token=uuid.uuid4().hex,
operator=self.operator,
)
def run(self, *, max_steps: int = 40) -> list[dict[str, Any]]:
history: list[dict[str, Any]] = []
for _ in range(max_steps):
result = self.step()
history.append(result)
if result.get("stopped") in {"SUCCESS", "WAITING_USER", "FAILED_RETRYABLE", "FAILED_MANUAL"}:
return history
if result.get("waiting_user"):
continue
if result.get("next_node") is None and result.get("ok"):
detail = self.client.get_job(self.job_id)
if detail["job"].get("status") == "SUCCESS":
history.append({"stopped": "SUCCESS", "next_node": None})
return history
raise RuntimeError(f"job {self.job_id} exceeded {max_steps} agent steps")
def _open_task(tasks: list[dict[str, Any]], node_code: str, job_id: str | None = None):
for task in tasks:
if task.get("node_code") != node_code:
continue
if task.get("status") not in (None, "OPEN"):
continue
if job_id and task.get("job_id") not in (None, job_id):
continue
return task
return None

View File

@ -1,6 +1,7 @@
# coding=utf-8 # coding=utf-8
""" """
Selenium Chrome 浏览器适配。通过 WebDriver 操控 Chrome。 APA (艺赛旗) Selenium Chrome BrowserAdapter 实现
通过 Selenium WebDriver 直接操控 Chrome,不依赖 APA 浏览器模块
""" """
from __future__ import annotations from __future__ import annotations
@ -494,7 +495,7 @@ class ApaBrowserAdapter:
return "" return ""
def screenshot(self, name: str) -> str: def screenshot(self, name: str) -> str:
from accept import artifact_dir from .runtime.accept import artifact_dir
save_dir = artifact_dir(self._flow) save_dir = artifact_dir(self._flow)
save_dir.mkdir(parents=True, exist_ok=True) save_dir.mkdir(parents=True, exist_ok=True)

View File

@ -0,0 +1,358 @@
# coding=utf-8
"""
构建合同建单 Main.seq —— 按业务段换行,画布上能直接读出步骤。
艺赛旗「插入代码块」的标题固定为「插入代码块#1」,「业务流程描述」节点
打开后再保存标题会被清空。画布上真正能看见的是代码块的备注(NOTE)。
因此本脚本:
1. 不再插入空的业务流程描述节点
2. 把「S06 填写签约方」写进 NOTE,作为画布副标题
3. 按 准备 / 进入起草 / 填写表单 / 提交回写 四段换行
关闭 Studio 后运行本脚本,再重新打开项目编译。
"""
from __future__ import annotations
import base64
import json
from pathlib import Path
HERE = Path(__file__).resolve().parent
OUT = HERE / "studio" / "Main.seq"
COL_W = 280
ROW_H = 200
ORIGIN_X = 360
ORIGIN_Y = 220
BEGIN_TAG = "202608141000000001"
def b64_apa(val):
# 艺赛旗 EP_CONTROL / EP_Gen 字段值末尾带换行,缺失会导致字段解析失败
return json.dumps({"val": base64.b64encode(str(val).encode()).decode()}, indent=4) + "\n"
def b64_raw(val):
return base64.b64encode(str(val).encode()).decode()
STEP_INIT = '''import sys, os, json
from pathlib import Path
_REPO_ROOT = next(
p for p in Path(__file__).resolve().parents
if (p / "rpa_yisaiqi" / "__init__.py").exists()
)
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_create_node import ContractCreateError, load_input_data, build_spec_from_data
from rpa_yisaiqi.callback_client import StateCenterClient
from rpa_yisaiqi.apa_browser_adapter import ApaBrowserAdapter
def _first(*values):
for value in values:
if value is not None and str(value).strip():
return str(value).strip()
return ""
raw = (self.input_arg or "").strip()
if not raw:
raw = os.getenv("CONTRACT_INPUT_JSON", "").strip()
if not raw:
raw = str(_REPO_ROOT / "rpa_yisaiqi" / "examples" / "contract_create_input_dry_run.json")
print("未传入 -i/--input,使用默认联调入参:", raw)
if not raw:
raise ContractCreateError("缺少业务入参:请通过 -i/--input 传入 JSON 字符串或文件路径")
data = load_input_data(raw)
state_center_url = _first(
data.get("stateCenterUrl"), data.get("stateCenterURL"),
os.getenv("STATE_CENTER_URL"), "http://192.168.105.69:8000",
)
agent_token = _first(data.get("agentToken"), os.getenv("AGENT_TOKEN"), "dev-agent-token")
_st.spec = build_spec_from_data(data)
_st.client = StateCenterClient(state_center_url, agent_token)
_st.ctx = _st.spec.flow_context
_st.browser = ApaBrowserAdapter(url=_st.ctx.contract_url, flow="CONTRACT_CREATE")
_st.is_dry_run = (_st.spec.submit_mode == "dry_run")
if not _st.is_dry_run:
print("回调状态中心 start", _st.spec.job_id)
_st.client.start_node(_st.spec.job_id, "CONTRACT_CREATE")'''
STEP_OPEN = '''import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import open_contract_system
open_contract_system(_st.browser, _st.ctx)'''
STEP_LOGIN = '''import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import login_contract_system
login_contract_system(_st.browser, _st.ctx)'''
STEP_ENTER = '''import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import enter_contract_create
enter_contract_create(_st.browser)'''
STEP_TEMPLATE = '''import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import select_contract_template
select_contract_template(_st.browser, _st.ctx)'''
STEP_SIGNATORY = '''import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import fill_contract_signatory_info
fill_contract_signatory_info(_st.browser, _st.ctx)'''
STEP_BASIC = '''import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import fill_contract_basic_info
fill_contract_basic_info(_st.browser, _st.ctx)'''
STEP_AMOUNT = '''import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import fill_contract_amount_and_dates
fill_contract_amount_and_dates(_st.browser, _st.ctx)'''
STEP_GOODS = '''import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import fill_contract_goods_lines
fill_contract_goods_lines(_st.browser, _st.ctx)'''
STEP_PAYMENT = '''import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import fill_contract_payment_info
fill_contract_payment_info(_st.browser, _st.ctx)'''
STEP_UPLOAD = '''import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import upload_contract_document_if_needed
upload_contract_document_if_needed(_st.browser, _st.ctx)'''
STEP_SUBMIT = '''import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import submit_contract_for_approval
_st.submitted = submit_contract_for_approval(_st.browser, _st.ctx)'''
STEP_READ_NO = '''import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import read_contract_number
if _st.submitted:
_st.contract_no = read_contract_number(_st.browser, _st.ctx)'''
STEP_COMPLETE = '''import json
import rpa_yisaiqi._flow_state as _st
if not _st.is_dry_run and _st.contract_no:
print("回调状态中心 complete", _st.spec.job_id, _st.contract_no)
_st.client.complete_node(_st.spec.job_id, "CONTRACT_CREATE", {
"businessRefs": {"contractSystemNo": _st.contract_no},
})
print(json.dumps({"status": "done", "contractSystemNo": _st.contract_no}, ensure_ascii=False))'''
# 每段一行。NOTE 会显示在画布「插入代码块#1」下方,务必写短步骤名。
PHASES = [
(
"准备",
[
("S01 初始化并回调 start", STEP_INIT),
("S02 打开合同系统", STEP_OPEN),
("S03 登录合同系统", STEP_LOGIN),
],
),
(
"进入起草",
[
("S04 进入合同起草", STEP_ENTER),
("S05 选择模板", STEP_TEMPLATE),
],
),
(
"填写表单",
[
("S06 填写签约方", STEP_SIGNATORY),
("S07 填写基础信息", STEP_BASIC),
("S08 填写金额日期", STEP_AMOUNT),
("S09 填写货品明细", STEP_GOODS),
("S10 填写付款信息", STEP_PAYMENT),
],
),
(
"提交回写",
[
("S11 上传合同PDF", STEP_UPLOAD),
("S12 提交送审", STEP_SUBMIT),
("S13 读取合同编号", STEP_READ_NO),
("S14 回调 complete", STEP_COMPLETE),
],
),
]
def code_tag(i: int) -> str:
return f"202608143{i:02d}0000000"
def line_tag(i: int) -> str:
return f"202608149{i:02d}0000000"
def _iter_steps():
index = 1
for phase_name, items in PHASES:
for title, code in items:
yield index, phase_name, title, code
index += 1
def build_begin_node(px: int, py: int) -> dict:
return {
"NodePanelData": {
"ExpandedGroups": [],
"InputProperty": {"mouse_x": 0, "mouse_y": 0},
"PubProperty": "",
"RetPins": [],
},
"NodeProperty": "",
"NodeUI": {
"NodeElementID": "",
"NodeModel": "2",
"NodePropErr": False,
"NodePx": str(px),
"NodePy": str(py),
"NodeSubType": "EnumWidget_Begin",
"NodeTag": BEGIN_TAG,
"NodeTitle": "开始",
},
}
def build_code_node(tag: str, title: str, code: str, px: int, py: int) -> dict:
body = f'print("[{title}]")\n' + code
return {
"NodePanelData": {
"ExpandedGroups": [],
"InputProperty": {"mouse_x": 0, "mouse_y": 0},
"PubProperty": "",
"RetPins": [],
},
"NodeProperty": {
"EP_CONTROL": {
"EP_CONTROL_IMPORT": json.dumps(
{"PImport": "", "PBCode": b64_raw(body)}, indent=4
) + "\n"
},
"EP_Gen": {
"EP_Gen_AFTERDELAY": b64_apa("0"),
"EP_Gen_BEFOREDELAY": b64_apa("0"),
"EP_Gen_NOTE": b64_apa(title),
},
},
"NodeUI": {
"NodeElementID": "",
"NodeModel": "2",
"NodePropErr": False,
"NodePx": str(px),
"NodePy": str(py),
"NodeSubType": "EnumWidget_Code_Custom",
"NodeTag": tag,
"NodeTitle": title,
},
}
def build_nodes():
nodes = []
placed = [] # (tag, x, y)
begin_x, begin_y = ORIGIN_X, ORIGIN_Y
nodes.append(build_begin_node(begin_x, begin_y))
placed.append((BEGIN_TAG, begin_x, begin_y))
# 第一段从「开始」右侧接着排;后续每段单独一行
col = 1
row = 0
current_phase = None
for index, phase_name, title, code in _iter_steps():
if current_phase is None:
current_phase = phase_name
elif phase_name != current_phase:
current_phase = phase_name
row += 1
col = 0
x = ORIGIN_X + col * COL_W
y = ORIGIN_Y + row * ROW_H
tag = code_tag(index)
nodes.append(build_code_node(tag, title, code, x, y))
placed.append((tag, x, y))
col += 1
return nodes, placed
def build_lines(placed: list) -> list:
lines = []
for k in range(len(placed) - 1):
prev_tag, prev_x, prev_y = placed[k]
next_tag, next_x, next_y = placed[k + 1]
same_row = prev_y == next_y
if same_row:
start_x, start_y = prev_x + 150, prev_y + 40
end_x, end_y = next_x - 4, next_y + 40
else:
start_x, start_y = prev_x + 70, prev_y + 90
end_x, end_y = next_x + 70, next_y - 8
lines.append({
"LineColor": "255, 255, 255",
"LineCommented": 0,
"LineEnd": {
"Pidx": f"{next_tag}/EPin_Flow_In",
"Px": str(end_x),
"Py": str(end_y),
},
"LineNote": "",
"LineSplit": {"Px": "", "Py": ""},
"LineStart": {
"Pidx": f"{prev_tag}/EPin_Flow_Out",
"Px": str(start_x),
"Py": str(start_y),
},
"LineTag": line_tag(k + 1),
"LineType": 0,
})
return lines
def main() -> None:
nodes, placed = build_nodes()
xs = [x for _, x, _ in placed]
ys = [y for _, _, y in placed]
main_seq = {
"LineList": build_lines(placed),
"NodeList": nodes,
"SeqTag": "Main",
"ViewCenterPx": str((min(xs) + max(xs)) // 2),
"ViewCenterPy": str((min(ys) + max(ys)) // 2),
"ViewScaling": "0.7",
}
OUT.write_text(json.dumps(main_seq, ensure_ascii=False, indent=4), encoding="utf-8")
print(f"多节点 Main.seq 已生成:{OUT}")
print(f"节点数:{len(nodes)}(1 开始 + {len(placed) - 1} 代码块,已去掉空业务标识)")
print(f"连线数:{len(main_seq['LineList'])}")
current_phase = None
for index, phase_name, title, _ in _iter_steps():
if phase_name != current_phase:
current_phase = phase_name
print(f" [{phase_name}]")
print(f" {title}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,166 @@
"""HTTP client for YiSaiQi callbacks into the state center."""
from __future__ import annotations
import json
from typing import Any, Callable, Dict
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
JsonDict = Dict[str, Any]
Opener = Callable[[Request, float], Any]
class StateCenterError(RuntimeError):
def __init__(self, status: int, detail: Any):
super().__init__(f"state center returned {status}: {detail}")
self.status = status
self.detail = detail
class StateCenterClient:
def __init__(
self,
base_url: str,
token: str,
*,
timeout: float = 15,
opener: Opener | None = None,
):
self.base_url = base_url.rstrip("/")
self.token = token
self.timeout = timeout
self._opener = opener or _urlopen_with_timeout
def start_node(
self,
job_id: str,
node_code: str,
*,
attempt_no: int = 1,
operator: str = "agent",
) -> JsonDict:
return self._post(
f"/api/jobs/{_path_part(job_id)}/nodes/{_path_part(node_code)}/start",
{
"attemptNo": attempt_no,
"executorPlatform": "iS-RPA",
"operator": operator,
},
)
def complete_node(
self,
job_id: str,
node_code: str,
payload: JsonDict,
) -> JsonDict:
return self._post(f"/api/jobs/{_path_part(job_id)}/nodes/{_path_part(node_code)}/complete", payload)
def fail_node(
self,
job_id: str,
node_code: str,
payload: JsonDict,
) -> JsonDict:
return self._post(f"/api/jobs/{_path_part(job_id)}/nodes/{_path_part(node_code)}/fail", payload)
def get_job(self, job_id: str) -> JsonDict:
return self._request("GET", f"/api/jobs/{_path_part(job_id)}")
def ingest_items(self, job_id: str, rows: list[JsonDict], replace: bool = True) -> JsonDict:
return self._post(
f"/api/jobs/{_path_part(job_id)}/items",
{"replace": replace, "rows": rows},
)
def match_run(self, job_id: str) -> JsonDict:
return self._post(f"/api/jobs/{_path_part(job_id)}/match/run", {})
def load_fixture_goods(self) -> JsonDict:
return self._post("/api/dev/match/load-fixture-goods", {})
def retry_job(self, job_id: str) -> JsonDict:
return self._post(f"/api/jobs/{_path_part(job_id)}/retry", {})
def list_tasks(self, *, role_code: str | None = None, status: str | None = None) -> JsonDict:
query = []
if role_code:
query.append(f"roleCode={quote(role_code, safe='')}")
if status:
query.append(f"status={quote(status, safe='')}")
suffix = f"?{'&'.join(query)}" if query else ""
return self._request("GET", f"/api/tasks/my{suffix}")
def submit_task(
self,
task_id: str,
action: str,
payload: JsonDict,
submit_token: str,
operator: str = "agent",
) -> JsonDict:
return self._post(
f"/api/tasks/{_path_part(task_id)}/submit",
{
"action": action,
"payload": payload,
"submitToken": submit_token,
"operator": operator,
},
)
def check_contract_result(
self,
job_id: str,
approved: bool,
*,
operator: str = "agent",
detail: str | None = None,
) -> JsonDict:
return self._post(
f"/api/jobs/{_path_part(job_id)}/nodes/CONTRACT_APPROVE_WAIT/check-result",
{"approved": approved, "operator": operator, "detail": detail},
)
def create_job(self, source_type: str, order_date: str, supplier_name: str, created_by: str = "agent") -> JsonDict:
return self._post(
"/api/jobs",
{
"source_type": source_type,
"order_date": order_date,
"supplier_name": supplier_name,
"created_by": created_by,
},
)
def _post(self, path: str, payload: JsonDict) -> JsonDict:
return self._request("POST", path, payload)
def _request(self, method: str, path: str, payload: JsonDict | None = None) -> JsonDict:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else None
headers = {"X-Agent-Token": self.token}
if data is not None:
headers["Content-Type"] = "application/json"
request = Request(
f"{self.base_url}{path}",
data=data,
headers=headers,
method=method,
)
try:
with self._opener(request, self.timeout) as response:
raw = response.read().decode("utf-8")
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise StateCenterError(exc.code, detail) from exc
return json.loads(raw) if raw else {}
def _path_part(value: str) -> str:
return quote(value, safe="")
def _urlopen_with_timeout(request: Request, timeout: float) -> Any:
return urlopen(request, timeout=timeout)

View File

@ -1,29 +1,40 @@
"""CONTRACT_CREATE: 入参组装 + Selenium 起草。结果返回合同号,不回调旧状态中心。""" """CONTRACT_CREATE node: input building + state-center callback (runtime.shell) + Selenium drafting."""
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import json import json
import os import os
import sys
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
from apa_browser_adapter import ApaBrowserAdapter from .apa_browser_adapter import ApaBrowserAdapter
from contract_draft import ( from .callback_client import StateCenterClient
from .contract_draft import (
ContractDraft, ContractDraft,
contract_draft_from_dict, contract_draft_from_dict,
is_placeholder_customer_code, is_placeholder_customer_code,
load_contract_draft, load_contract_draft,
) )
from contract_flow_steps import ContractFlowContext, run_contract_create_flow from .contract_flow_steps import ContractFlowContext, run_contract_create_flow
from errors import NodeError, PdfMergeUnsupported, UnknownRpaError from .pdf_utils import ensure_merged_pdf
from pdf_utils import ensure_merged_pdf from .runtime.errors import NodeError, PdfMergeUnsupported, UnknownRpaError
from .runtime.shell import execute_node
NODE_CODE = "CONTRACT_CREATE"
CONTRACT_URL = "http://contract-test.gzmpc.com:8080/" CONTRACT_URL = "http://contract-test.gzmpc.com:8080/"
class ContractCreateError(NodeError): class ContractCreateError(NodeError):
"""入参缺失 / 页面元素找不到 / 页面改版。""" """入参缺失 / 页面元素找不到 / 页面改版,均映射 UI_ELEMENT_NOT_FOUND。"""
def __init__(self, message: str = "") -> None:
# Studio 编译出来的旧 S01 在 input_arg 为空时会直接 raise。
# 这里拦下来改走默认联调 JSON,避免每次改 Main.py 都被覆盖。
if _should_bootstrap_default_input(message):
_bootstrap_default_input_and_exit()
super().__init__(message)
def default_contract_input_path() -> str: def default_contract_input_path() -> str:
@ -32,6 +43,26 @@ def default_contract_input_path() -> str:
) )
def _should_bootstrap_default_input(message: str) -> bool:
if "缺少业务入参" not in str(message):
return False
script = os.path.basename(str(sys.argv[0] if sys.argv else "")).lower()
return script == "main.py"
def _bootstrap_default_input_and_exit() -> None:
path = default_contract_input_path()
if not Path(path).is_file():
return
print("未传入 -i/--input,使用默认联调入参:", path, flush=True)
codes_dir = Path(__file__).resolve().parent / "studio" / "codes"
if str(codes_dir) not in sys.path:
sys.path.insert(0, str(codes_dir))
from run_contract import main
raise SystemExit(main())
@dataclass(frozen=True) @dataclass(frozen=True)
class ContractCreateInput: class ContractCreateInput:
job_id: str job_id: str
@ -56,15 +87,18 @@ ContractRunner = Callable[[ContractCreateInput], str]
def execute_contract_create( def execute_contract_create(
spec: ContractCreateInput, spec: ContractCreateInput,
client: StateCenterClient,
*, *,
contract_runner: ContractRunner | None = None, contract_runner: ContractRunner | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
runner = contract_runner or create_contract_in_contract_system runner = contract_runner or create_contract_in_contract_system
# dry_run:只跑到提交前,不回调状态中心(不 start/complete/fail)
if spec.submit_mode == "dry_run": if spec.submit_mode == "dry_run":
runner(spec) runner(spec)
return {"status": "dry_run", "submitted": False} return {"status": "dry_run", "submitted": False}
def _run() -> dict[str, Any]:
try: try:
contract_no = runner(spec) contract_no = runner(spec)
except NodeError: except NodeError:
@ -72,13 +106,22 @@ def execute_contract_create(
except Exception as exc: except Exception as exc:
raise UnknownRpaError(str(exc)) from exc raise UnknownRpaError(str(exc)) from exc
if not contract_no: if not contract_no:
raise ContractCreateError("submit 模式未读取到真实合同编号") raise ContractCreateError("submit 模式未读取到真实合同编号,无法 complete")
return { return {
"status": "done", "businessRefs": {"contractSystemNo": contract_no},
"contractSystemNo": contract_no,
"summary": _build_summary(spec), "summary": _build_summary(spec),
"artifacts": [],
} }
return execute_node(
client,
spec.job_id,
NODE_CODE,
_run,
attempt_no=spec.attempt_no,
operator="agent",
)
def create_contract_in_contract_system(spec: ContractCreateInput) -> str: def create_contract_in_contract_system(spec: ContractCreateInput) -> str:
"""在合同系统中起草合同并返回合同编号。""" """在合同系统中起草合同并返回合同编号。"""
@ -278,13 +321,21 @@ def load_input_data(raw: str) -> dict[str, Any]:
raise ContractCreateError(f"无法解析入参:既不是合法 JSON,也不是存在的文件路径:{raw}") raise ContractCreateError(f"无法解析入参:既不是合法 JSON,也不是存在的文件路径:{raw}")
def run_contract_create(data: dict[str, Any]) -> dict[str, Any]: def run_contract_create(
data: dict[str, Any],
*,
state_center_url: str,
agent_token: str,
) -> dict[str, Any]:
spec = build_spec_from_data(data) spec = build_spec_from_data(data)
return execute_contract_create(spec) client = StateCenterClient(state_center_url, agent_token)
return execute_contract_create(spec, client)
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="合同系统页面起草。") parser = argparse.ArgumentParser(description="Run YiSaiQi CONTRACT_CREATE node.")
parser.add_argument("--state-center-url", default=os.getenv("STATE_CENTER_URL", "http://127.0.0.1:8000"))
parser.add_argument("--token", default=os.getenv("AGENT_TOKEN", "dev-agent-token"))
parser.add_argument("--input-json", default=os.getenv("CONTRACT_INPUT_JSON")) parser.add_argument("--input-json", default=os.getenv("CONTRACT_INPUT_JSON"))
parser.add_argument("--job-id") parser.add_argument("--job-id")
parser.add_argument("--supplier-name") parser.add_argument("--supplier-name")
@ -302,6 +353,7 @@ def parse_args() -> argparse.Namespace:
def main() -> int: def main() -> int:
args = parse_args() args = parse_args()
client = StateCenterClient(args.state_center_url, args.token)
if args.input_json: if args.input_json:
spec = build_spec_from_data(load_input_data(args.input_json)) spec = build_spec_from_data(load_input_data(args.input_json))
@ -322,7 +374,7 @@ def main() -> int:
merged_pdf_output=args.merged_pdf_output, merged_pdf_output=args.merged_pdf_output,
) )
result = execute_contract_create(spec) result = execute_contract_create(spec, client)
print(json.dumps(result, ensure_ascii=False)) print(json.dumps(result, ensure_ascii=False))
return 0 return 0

View File

@ -6,7 +6,7 @@ from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from contract_flow_steps import ContractGoodsLine from .contract_flow_steps import ContractGoodsLine
_PLACEHOLDER_CUSTOMER_PREFIXES = ("CUST-", "TEST-", "MOCK-") _PLACEHOLDER_CUSTOMER_PREFIXES = ("CUST-", "TEST-", "MOCK-")

View File

@ -1,4 +1,8 @@
"""合同系统页面步骤:登录、起草、填表、提交、读合同号。""" """Flowchart-sized CONTRACT_CREATE browser steps for YiSaiQi.
Each function is intentionally small enough to map to one YiSaiQi flowchart
node or one script block behind a flowchart node.
"""
from __future__ import annotations from __future__ import annotations
import time import time
@ -6,8 +10,8 @@ from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Protocol from typing import Protocol
from accept import AcceptanceReport, accept_log_path, evidence_shot from .runtime.accept import AcceptanceReport, accept_log_path, evidence_shot
from errors import ( from .runtime.errors import (
LoginFailed, LoginFailed,
NodeError, NodeError,
PdfMergeUnsupported, PdfMergeUnsupported,
@ -116,6 +120,10 @@ def run_contract_create_flow(browser: BrowserAdapter, ctx: ContractFlowContext)
_ACCEPT.note("S13", "读合同号", {"contractNo": "(none)", "mode": ctx.submit_mode}, ok=False) _ACCEPT.note("S13", "读合同号", {"contractNo": "(none)", "mode": ctx.submit_mode}, ok=False)
return "" return ""
contract_no = read_contract_number(browser, ctx) contract_no = read_contract_number(browser, ctx)
state = _flow_runtime()
if state is not None:
state.contract_no = contract_no
_callback_complete(contract_no)
_ACCEPT.note( _ACCEPT.note(
"S13", "S13",
"读合同号", "读合同号",
@ -286,7 +294,51 @@ CUSTOMER_PICKER = {
} }
def _flow_runtime():
try:
from . import _flow_state as state
except Exception:
return None
return state
def _callback_start() -> None:
state = _flow_runtime()
if state is None or getattr(state, "is_dry_run", False):
return
client = getattr(state, "client", None)
spec = getattr(state, "spec", None)
if not client or not spec:
return
print("回调状态中心 start", spec.job_id, flush=True)
client.start_node(spec.job_id, "CONTRACT_CREATE")
def _callback_complete(contract_no: str) -> None:
state = _flow_runtime()
if state is None or getattr(state, "is_dry_run", False):
return
if getattr(state, "completed", False):
print("状态中心 complete 已发送,跳过重复回调", flush=True)
return
client = getattr(state, "client", None)
spec = getattr(state, "spec", None)
number = (contract_no or "").strip()
if not client or not spec or not number:
print("跳过状态中心 complete:合同编号为空", flush=True)
return
print("回调状态中心 complete", spec.job_id, number, flush=True)
client.complete_node(
spec.job_id,
"CONTRACT_CREATE",
{"businessRefs": {"contractSystemNo": number}},
)
state.completed = True
state.contract_no = number
def open_contract_system(browser: BrowserAdapter, ctx: ContractFlowContext) -> None: def open_contract_system(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
_callback_start()
browser.open_url(ctx.contract_url) browser.open_url(ctx.contract_url)
_ACCEPT.note("S02", "打开合同系统", {"url": ctx.contract_url}, ok=True) _ACCEPT.note("S02", "打开合同系统", {"url": ctx.contract_url}, ok=True)
@ -359,7 +411,7 @@ def fill_contract_signatory_info(browser: BrowserAdapter, ctx: ContractFlowConte
if not ctx.customer_code: if not ctx.customer_code:
raise SupplierContractDisabled("客商操作码(customerCode)为空,无法加载客商") raise SupplierContractDisabled("客商操作码(customerCode)为空,无法加载客商")
from contract_draft import is_placeholder_customer_code from .contract_draft import is_placeholder_customer_code
if is_placeholder_customer_code(ctx.customer_code): if is_placeholder_customer_code(ctx.customer_code):
raise SupplierContractDisabled( raise SupplierContractDisabled(
@ -1068,6 +1120,10 @@ def submit_contract_for_approval(browser: BrowserAdapter, ctx: ContractFlowConte
evidence_shot(browser, "contract-submitted") evidence_shot(browser, "contract-submitted")
print("提交成功,回我的合同取编号。", flush=True) print("提交成功,回我的合同取编号。", flush=True)
fd_id = fd_id or _read_document_id(browser) fd_id = fd_id or _read_document_id(browser)
state = _flow_runtime()
if state is not None:
state.fd_id = fd_id
state.submitted = True
_ACCEPT.note("S12", "提交", {"submitted": "yes"}, ok=True) _ACCEPT.note("S12", "提交", {"submitted": "yes"}, ok=True)
return True return True
_confirm_submit_dialogs(browser) _confirm_submit_dialogs(browser)
@ -1163,6 +1219,10 @@ def _confirm_submit_dialogs(browser: BrowserAdapter) -> None:
def read_contract_number(browser: BrowserAdapter, ctx: ContractFlowContext | None = None) -> str: def read_contract_number(browser: BrowserAdapter, ctx: ContractFlowContext | None = None) -> str:
if ctx is None:
state = _flow_runtime()
if state is not None:
ctx = getattr(state, "ctx", None)
if ctx is None: if ctx is None:
ctx = ContractFlowContext( ctx = ContractFlowContext(
contract_url="http://contract-test.gzmpc.com:8080/", contract_url="http://contract-test.gzmpc.com:8080/",
@ -1172,7 +1232,13 @@ def read_contract_number(browser: BrowserAdapter, ctx: ContractFlowContext | Non
order_date="", order_date="",
source_type="", source_type="",
) )
return _read_contract_number_from_my_list(browser, ctx) number = _read_contract_number_from_my_list(browser, ctx)
if _usable_contract_no(number):
state = _flow_runtime()
if state is not None:
state.contract_no = number
_callback_complete(number)
return number
def _read_contract_number_on_page(browser: BrowserAdapter) -> str: def _read_contract_number_on_page(browser: BrowserAdapter) -> str:

View File

@ -38,5 +38,7 @@
"mdmCode": "", "mdmCode": "",
"unitPrice": "50.00" "unitPrice": "50.00"
} }
] ],
"stateCenterUrl": "http://127.0.0.1:8000",
"agentToken": "dev-agent-token"
} }

View File

@ -0,0 +1,151 @@
{
"flowName": "CONTRACT_CREATE_合同建单",
"platform": "YiSaiQi iS-RPA",
"nodeCode": "CONTRACT_CREATE",
"inputs": [
"stateCenterUrl",
"agentToken",
"jobId",
"supplierName",
"orderDate",
"sourceType",
"templateType",
"templateName",
"customerCode",
"customerName",
"contractCategory",
"settlementType",
"contractBackground",
"amount",
"contractEndDate",
"paymentMethod",
"paymentDays",
"submitMode",
"goodsLines",
"mergedPdfPath"
],
"outputs": [
"contractSystemNo",
"nextNode"
],
"steps": [
{
"id": "S01_CALLBACK_START",
"name": "回调状态中心-start",
"script": "StateCenterClient.start_node(jobId, 'CONTRACT_CREATE')"
},
{
"id": "S02_OPEN_CONTRACT_SYSTEM",
"name": "打开合同系统",
"script": "open_contract_system(browser, ctx)"
},
{
"id": "S03_LOGIN",
"name": "登录合同系统",
"script": "login_contract_system(browser, ctx)",
"selectors": {
"username": "input[name='j_username']",
"password": "input[name='j_password']",
"submit": "input[name='btn_submit']"
}
},
{
"id": "S04_ENTER_CONTRACT_DRAFT",
"name": "进入合同起草",
"script": "enter_contract_create(browser)"
},
{
"id": "S05_SELECT_RECENT_TEMPLATE",
"name": "选择最近使用模板",
"script": "select_contract_template(browser, ctx)",
"rule": "优先点击最近使用模板中的 templateName;非格式默认非格式--采购协议"
},
{
"id": "S06_FILL_SIGNATORY_INFO",
"name": "填写签约方信息",
"script": "fill_contract_signatory_info(browser, ctx)",
"fields": [
"客户编码",
"客户名称"
]
},
{
"id": "S07_FILL_CONTRACT_BASIC_FORM",
"name": "填写合同基础表单",
"script": "fill_contract_basic_info(browser, ctx)",
"fields": [
"合同版本类型",
"合同类别",
"结算类型",
"合同背景概述",
"合同性质"
]
},
{
"id": "S08_FILL_AMOUNT_AND_DATES",
"name": "填写金额与日期",
"script": "fill_contract_amount_and_dates(browser, ctx)",
"hardRules": [
"合同签订日期=orderDate",
"合同开始日期=orderDate"
]
},
{
"id": "S09_FILL_GOODS_LINES",
"name": "填写货品明细",
"script": "fill_contract_goods_lines(browser, ctx)"
},
{
"id": "S10_FILL_PAYMENT_AND_FLAGS",
"name": "填写付款信息与特殊标志",
"script": "fill_contract_payment_info(browser, ctx)"
},
{
"id": "S11_MERGE_PDF_IF_YYC",
"name": "医药城合并同供应商同日PDF",
"script": "ensure_merged_pdf(platformPdfPaths, mergedPdfOutput)",
"branch": "sourceType=YYC 且 platformPdfPaths 多于一份时执行;单份直接沿用;sourceType=XYY 跳过"
},
{
"id": "S12_UPLOAD_PDF_IF_YYC",
"name": "医药城上传合并合同PDF",
"script": "upload_contract_document_if_needed(browser, ctx)",
"branch": "sourceType=YYC 才执行;sourceType=XYY 跳过"
},
{
"id": "S13_SUBMIT",
"name": "提交",
"script": "submit_contract_for_approval(browser)"
},
{
"id": "S14_READ_CONTRACT_NO",
"name": "读取合同编号",
"script": "read_contract_number(browser)"
},
{
"id": "S15_CALLBACK_COMPLETE",
"name": "回调状态中心-complete",
"script": "StateCenterClient.complete_node(jobId, 'CONTRACT_CREATE', businessRefs.contractSystemNo)"
},
{
"id": "E_LOGIN_FAILED",
"name": "异常-登录失败",
"script": "StateCenterClient.fail_node(..., errorCode='SYS_LOGIN_FAIL', retryable=true, routeRole='OPS')"
},
{
"id": "E_CONTRACT_DISABLED",
"name": "异常-供应商合同禁用",
"script": "StateCenterClient.fail_node(..., errorCode='BIZ_CONTRACT_DISABLED', retryable=false, routeRole='PURCHASER')"
},
{
"id": "E_PDF_MERGE_UNSUPPORTED",
"name": "异常-PDF合并能力不支持",
"script": "StateCenterClient.fail_node(..., errorCode='CAP_PDF_MERGE_UNSUPPORTED', retryable=false, routeRole='OPS')"
},
{
"id": "E_PAGE_CHANGED",
"name": "异常-页面改版",
"script": "StateCenterClient.fail_node(..., errorCode='UI_ELEMENT_NOT_FOUND', retryable=false, routeRole='OPS')"
}
]
}

View File

@ -0,0 +1,42 @@
{
"flowName": "CONTRACT_CREATE_预览",
"platform": "YiSaiQi iS-RPA",
"nodeCode": "CONTRACT_CREATE",
"inputs": [
"stateCenterUrl",
"agentToken",
"jobId",
"supplierName",
"orderDate",
"sourceType",
"templateType",
"templateName",
"customerCode",
"customerName",
"contractCategory",
"settlementType",
"contractBackground",
"amount",
"contractEndDate",
"paymentMethod",
"paymentDays",
"submitMode",
"goodsLines",
"mergedPdfPath"
],
"outputs": [
"previewDone"
],
"steps": [
{ "id": "P01", "name": "打开合同系统", "script": "open_contract_system(browser, ctx)" },
{ "id": "P02", "name": "登录合同系统", "script": "login_contract_system(browser, ctx)" },
{ "id": "P03", "name": "进入合同起草", "script": "enter_contract_create(browser)" },
{ "id": "P04", "name": "选择模板", "script": "select_contract_template(browser, ctx)" },
{ "id": "P05", "name": "填写签约方", "script": "fill_contract_signatory_info(browser, ctx)" },
{ "id": "P06", "name": "填写基础表单", "script": "fill_contract_basic_info(browser, ctx)" },
{ "id": "P07", "name": "填写金额日期", "script": "fill_contract_amount_and_dates(browser, ctx)" },
{ "id": "P08", "name": "填写明细", "script": "fill_contract_goods_lines(browser, ctx)" },
{ "id": "P09", "name": "填写付款与标志", "script": "fill_contract_payment_info(browser, ctx)" },
{ "id": "P10", "name": "预览结束", "script": "submit_contract_for_approval(browser, ctx) with submitMode=dry_run" }
]
}

View File

@ -1,4 +1,4 @@
"""合同 PDF 合并。""" """PDF utilities for YiSaiQi contract drafting."""
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
@ -7,7 +7,8 @@ from pathlib import Path
def ensure_merged_pdf(source_paths: list[str], output_path: str) -> str: def ensure_merged_pdf(source_paths: list[str], output_path: str) -> str:
"""Return one PDF path, merging multiple source PDFs when needed. """Return one PDF path, merging multiple source PDFs when needed.
同供应商同日只上传一份 PDF。单文件直接返回路径。 YiSaiQi must upload only one YYC contract document for the same supplier
and same order date. A single source PDF can be uploaded directly.
""" """
if not source_paths: if not source_paths:
raise ValueError("YYC contract creation requires at least one platform PDF") raise ValueError("YYC contract creation requires at least one platform PDF")

View File

@ -0,0 +1,4 @@
pypdf==5.1.0
selenium>=4.15.0
webdriver-manager>=4.0.0
requests>=2.31.0

View File

@ -0,0 +1 @@
"""Node runners. Mock now, Windows page runners later."""

View File

@ -0,0 +1,87 @@
"""Mock runners for Mac-side state-machine verification."""
from __future__ import annotations
import uuid
from typing import Any
from ..runtime.errors import NodeError
def run(node_code: str, job: dict[str, Any], client: Any) -> dict[str, Any]:
if node_code == "ORDER_FETCH":
return run_order_fetch(job, client)
if node_code == "OCR_EXTRACT":
return {"summary": {"ocr": "mock"}}
if node_code == "CONTRACT_CREATE":
contract_no = f"HT{str(job.get('order_date') or '').replace('-', '')}{uuid.uuid4().hex[:5].upper()}"
return {"businessRefs": {"contractSystemNo": contract_no}, "summary": {"sourceType": job.get("source_type")}}
if node_code == "REBATE_HEADER":
return {"businessRefs": {"rebateOrderNo": f"FL-{uuid.uuid4().hex[:8].upper()}"}}
if node_code == "REBATE_IMPORT":
return {"summary": {"imported": 1, "failed": 0}}
if node_code == "REBATE_SUBMIT":
return {"summary": {"submitted": True}}
if node_code == "M5_CREATE_ORDER":
return {"businessRefs": {"m5OrderNo": f"M5-{uuid.uuid4().hex[:8].upper()}"}, "summary": {"autoSave": False}}
if node_code == "ARCHIVE_JOB":
return {"summary": {"archived": True}}
raise NodeError(f"no mock runner for {node_code}")
def run_order_fetch(job: dict[str, Any], client: Any) -> dict[str, Any]:
from pathlib import Path
import json
fixture = (
Path(__file__).resolve().parents[2]
/ "state_center"
/ "fixtures"
/ "orders"
/ "yyc-fujian-20260804.json"
)
order_date = str(job.get("order_date") or "2026-08-12")
if fixture.is_file():
data = json.loads(fixture.read_text(encoding="utf-8"))
row = data["row"]
order_no = row.get("orderNo") or f"DD-{order_date.replace('-', '')}-001"
rows = [
{
"orderNo": order_no,
"rawProductName": row["rawProductName"],
"platformProductCode": row.get("platformProductCode"),
"spec": row.get("spec"),
"manufacturer": row.get("manufacturer"),
"approvalNo": row.get("approvalNo"),
"qty": row.get("qty") or 1,
"sourceRowNo": row.get("sourceRowNo"),
}
]
else:
order_no = f"DD-{order_date.replace('-', '')}-001"
rows = [
{
"orderNo": order_no,
"rawProductName": "阿卡波糖片",
"platformProductCode": "YP-001",
"spec": "50mg*30片",
"manufacturer": "示例制药",
"approvalNo": "国药准字H00000001",
"qty": 100,
"unitPrice": 25.5,
"sourceRowNo": 1,
}
]
if hasattr(client, "ingest_items"):
client.ingest_items(job["job_id"], rows, replace=True)
return {
"summary": {
"orderCount": 1,
"supplierList": [job.get("supplier_name")],
"orderNoList": [order_no],
},
"artifacts": [{
"artifactType": "SOURCE_EXCEL",
"storagePath": f"/tmp/mock_rpa_data/orders_{job.get('supplier_name')}_{order_date}.xlsx",
"fileHash": "sha256:mock-order-excel",
}],
}

View File

@ -0,0 +1 @@
"""Portable node runtime shared by mock and Windows runners."""

View File

@ -11,9 +11,9 @@ from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Protocol from typing import Protocol
from errors import NodeError from .errors import NodeError
PACKAGE_ROOT = Path(__file__).resolve().parent PACKAGE_ROOT = Path(__file__).resolve().parents[1]
SCREENSHOT_ROOT = PACKAGE_ROOT / "screenshots" SCREENSHOT_ROOT = PACKAGE_ROOT / "screenshots"

View File

@ -1,4 +1,4 @@
"""Page-flow errors. Codes stay for logs; they no longer map to a state-center fail callback.""" """Node errors that map onto state-center fail callbacks."""
class NodeError(RuntimeError): class NodeError(RuntimeError):

View File

@ -0,0 +1,37 @@
"""Complete/fail payload builders for RPA nodes."""
from __future__ import annotations
import uuid
from typing import Any
from .errors import NodeError
def build_complete_payload(
job_id: str,
node_code: str,
attempt_no: int,
output: dict[str, Any] | None = None,
) -> dict[str, Any]:
output = output or {}
refs = output.get("businessRefs") or {}
ref_key = next(iter(refs.values()), "ok")
return {
"attemptNo": attempt_no,
"executorRunId": f"YSQ-{node_code}-{uuid.uuid4().hex[:12]}",
"idempotencyKey": f"{job_id}:{node_code}:{attempt_no}:{ref_key}",
"businessRefs": refs,
"summary": output.get("summary") or {},
"artifacts": output.get("artifacts") or [],
}
def build_fail_payload(attempt_no: int, exc: NodeError) -> dict[str, Any]:
return {
"attemptNo": attempt_no,
"errorCode": exc.error_code,
"errorSummary": str(exc),
"retryable": exc.retryable,
"routeRole": exc.route_role,
"artifacts": [],
}

View File

@ -0,0 +1,28 @@
"""Generic start → runner → complete/fail shell."""
from __future__ import annotations
from typing import Any, Callable, Dict, Optional
from ..callback_client import StateCenterClient
from .errors import NodeError
from .payloads import build_complete_payload, build_fail_payload
Runner = Callable[[], Optional[Dict[str, Any]]]
def execute_node(
client: StateCenterClient,
job_id: str,
node_code: str,
runner: Runner,
*,
attempt_no: int = 1,
operator: str = "agent",
) -> dict[str, Any]:
client.start_node(job_id, node_code, attempt_no=attempt_no, operator=operator)
try:
output = runner() or {}
payload = build_complete_payload(job_id, node_code, attempt_no, output)
return client.complete_node(job_id, node_code, payload)
except NodeError as exc:
return client.fail_node(job_id, node_code, build_fail_payload(attempt_no, exc))

790
rpa_yisaiqi/studio/Main.seq Normal file
View File

@ -0,0 +1,790 @@
{
"LineList": [
{
"LineColor": "255, 255, 255",
"LineCommented": 0,
"LineEnd": {
"Pidx": "202608143010000000/EPin_Flow_In",
"Px": "697",
"Py": "314.931"
},
"LineNote": "",
"LineSplit": {
"Px": "",
"Py": ""
},
"LineStart": {
"Pidx": "202608141000000001/EPin_Flow_Out",
"Px": "547",
"Py": "296"
},
"LineTag": "202608149010000000",
"LineType": 0
},
{
"LineColor": "255, 255, 255",
"LineCommented": 0,
"LineEnd": {
"Pidx": "202608143020000000/EPin_Flow_In",
"Px": "977",
"Py": "314.931"
},
"LineNote": "",
"LineSplit": {
"Px": "",
"Py": ""
},
"LineStart": {
"Pidx": "202608143010000000/EPin_Flow_Out",
"Px": "827",
"Py": "314.931"
},
"LineTag": "202608149020000000",
"LineType": 0
},
{
"LineColor": "255, 255, 255",
"LineCommented": 0,
"LineEnd": {
"Pidx": "202608143030000000/EPin_Flow_In",
"Px": "1257",
"Py": "320"
},
"LineNote": "",
"LineSplit": {
"Px": "",
"Py": ""
},
"LineStart": {
"Pidx": "202608143020000000/EPin_Flow_Out",
"Px": "1107",
"Py": "314.931"
},
"LineTag": "202608149030000000",
"LineType": 0
},
{
"LineColor": "255, 255, 255",
"LineCommented": 0,
"LineEnd": {
"Pidx": "202608143040000000/EPin_Flow_In",
"Px": "417",
"Py": "520"
},
"LineNote": "",
"LineSplit": {
"Px": "",
"Py": ""
},
"LineStart": {
"Pidx": "202608143030000000/EPin_Flow_Out",
"Px": "1387",
"Py": "320"
},
"LineTag": "202608149040000000",
"LineType": 0
},
{
"LineColor": "255, 255, 255",
"LineCommented": 0,
"LineEnd": {
"Pidx": "202608143050000000/EPin_Flow_In",
"Px": "697",
"Py": "520"
},
"LineNote": "",
"LineSplit": {
"Px": "",
"Py": ""
},
"LineStart": {
"Pidx": "202608143040000000/EPin_Flow_Out",
"Px": "547",
"Py": "520"
},
"LineTag": "202608149050000000",
"LineType": 0
},
{
"LineColor": "255, 255, 255",
"LineCommented": 0,
"LineEnd": {
"Pidx": "202608143060000000/EPin_Flow_In",
"Px": "417",
"Py": "720"
},
"LineNote": "",
"LineSplit": {
"Px": "",
"Py": ""
},
"LineStart": {
"Pidx": "202608143050000000/EPin_Flow_Out",
"Px": "827",
"Py": "520"
},
"LineTag": "202608149060000000",
"LineType": 0
},
{
"LineColor": "255, 255, 255",
"LineCommented": 0,
"LineEnd": {
"Pidx": "202608143070000000/EPin_Flow_In",
"Px": "697",
"Py": "720"
},
"LineNote": "",
"LineSplit": {
"Px": "",
"Py": ""
},
"LineStart": {
"Pidx": "202608143060000000/EPin_Flow_Out",
"Px": "547",
"Py": "720"
},
"LineTag": "202608149070000000",
"LineType": 0
},
{
"LineColor": "255, 255, 255",
"LineCommented": 0,
"LineEnd": {
"Pidx": "202608143080000000/EPin_Flow_In",
"Px": "977",
"Py": "720"
},
"LineNote": "",
"LineSplit": {
"Px": "",
"Py": ""
},
"LineStart": {
"Pidx": "202608143070000000/EPin_Flow_Out",
"Px": "827",
"Py": "720"
},
"LineTag": "202608149080000000",
"LineType": 0
},
{
"LineColor": "255, 255, 255",
"LineCommented": 0,
"LineEnd": {
"Pidx": "202608143090000000/EPin_Flow_In",
"Px": "1257",
"Py": "720"
},
"LineNote": "",
"LineSplit": {
"Px": "",
"Py": ""
},
"LineStart": {
"Pidx": "202608143080000000/EPin_Flow_Out",
"Px": "1107",
"Py": "720"
},
"LineTag": "202608149090000000",
"LineType": 0
},
{
"LineColor": "255, 255, 255",
"LineCommented": 0,
"LineEnd": {
"Pidx": "202608143100000000/EPin_Flow_In",
"Px": "1537",
"Py": "720"
},
"LineNote": "",
"LineSplit": {
"Px": "",
"Py": ""
},
"LineStart": {
"Pidx": "202608143090000000/EPin_Flow_Out",
"Px": "1387",
"Py": "720"
},
"LineTag": "202608149100000000",
"LineType": 0
},
{
"LineColor": "255, 255, 255",
"LineCommented": 0,
"LineEnd": {
"Pidx": "202608143110000000/EPin_Flow_In",
"Px": "417",
"Py": "920"
},
"LineNote": "",
"LineSplit": {
"Px": "",
"Py": ""
},
"LineStart": {
"Pidx": "202608143100000000/EPin_Flow_Out",
"Px": "1667",
"Py": "720"
},
"LineTag": "202608149110000000",
"LineType": 0
},
{
"LineColor": "255, 255, 255",
"LineCommented": 0,
"LineEnd": {
"Pidx": "202608143120000000/EPin_Flow_In",
"Px": "697",
"Py": "920"
},
"LineNote": "",
"LineSplit": {
"Px": "",
"Py": ""
},
"LineStart": {
"Pidx": "202608143110000000/EPin_Flow_Out",
"Px": "547",
"Py": "920"
},
"LineTag": "202608149120000000",
"LineType": 0
},
{
"LineColor": "255, 255, 255",
"LineCommented": 0,
"LineEnd": {
"Pidx": "202608143130000000/EPin_Flow_In",
"Px": "977",
"Py": "920"
},
"LineNote": "",
"LineSplit": {
"Px": "",
"Py": ""
},
"LineStart": {
"Pidx": "202608143120000000/EPin_Flow_Out",
"Px": "827",
"Py": "920"
},
"LineTag": "202608149130000000",
"LineType": 0
},
{
"LineColor": "255, 255, 255",
"LineCommented": 0,
"LineEnd": {
"Pidx": "202608143140000000/EPin_Flow_In",
"Px": "1257",
"Py": "920"
},
"LineNote": "",
"LineSplit": {
"Px": "",
"Py": ""
},
"LineStart": {
"Pidx": "202608143130000000/EPin_Flow_Out",
"Px": "1107",
"Py": "920"
},
"LineTag": "202608149140000000",
"LineType": 0
}
],
"NodeList": [
{
"NodePanelData": {
"ExpandedGroups": [
],
"InputProperty": {
"mouse_x": 0,
"mouse_y": 0
},
"PubProperty": "",
"RetPins": [
]
},
"NodeProperty": "",
"NodeUI": {
"NodeElementID": "",
"NodeModel": "2",
"NodePropErr": false,
"NodePx": "420",
"NodePy": "280",
"NodeSubType": "EnumWidget_Begin",
"NodeTag": "202608141000000001",
"NodeTitle": "开始"
}
},
{
"NodePanelData": {
"ExpandedGroups": [
],
"InputProperty": {
"mouse_x": 0,
"mouse_y": 0
},
"PubProperty": "",
"RetPins": [
]
},
"NodeProperty": {
"EP_CONTROL": {
"EP_CONTROL_IMPORT": "{\n \"PBCode\": \"cHJpbnQoIltTMDEg5Yid5aeL5YyW5bm25Zue6LCDIHN0YXJ0XSIpCmltcG9ydCBzeXMsIG9zLCBqc29uCmZyb20gcGF0aGxpYiBpbXBvcnQgUGF0aAoKX1JFUE9fUk9PVCA9IG5leHQoCiAgICBwIGZvciBwIGluIFBhdGgoX19maWxlX18pLnJlc29sdmUoKS5wYXJlbnRzCiAgICBpZiAocCAvICJycGFfeWlzYWlxaSIgLyAiX19pbml0X18ucHkiKS5leGlzdHMoKQopCmlmIHN0cihfUkVQT19ST09UKSBub3QgaW4gc3lzLnBhdGg6CiAgICBzeXMucGF0aC5pbnNlcnQoMCwgc3RyKF9SRVBPX1JPT1QpKQoKaW1wb3J0IHJwYV95aXNhaXFpLl9mbG93X3N0YXRlIGFzIF9zdApmcm9tIHJwYV95aXNhaXFpLmNvbnRyYWN0X2NyZWF0ZV9ub2RlIGltcG9ydCBDb250cmFjdENyZWF0ZUVycm9yLCBsb2FkX2lucHV0X2RhdGEsIGJ1aWxkX3NwZWNfZnJvbV9kYXRhCmZyb20gcnBhX3lpc2FpcWkuY2FsbGJhY2tfY2xpZW50IGltcG9ydCBTdGF0ZUNlbnRlckNsaWVudApmcm9tIHJwYV95aXNhaXFpLmFwYV9icm93c2VyX2FkYXB0ZXIgaW1wb3J0IEFwYUJyb3dzZXJBZGFwdGVyCgoKZGVmIF9maXJzdCgqdmFsdWVzKToKICAgIGZvciB2YWx1ZSBpbiB2YWx1ZXM6CiAgICAgICAgaWYgdmFsdWUgaXMgbm90IE5vbmUgYW5kIHN0cih2YWx1ZSkuc3RyaXAoKToKICAgICAgICAgICAgcmV0dXJuIHN0cih2YWx1ZSkuc3RyaXAoKQogICAgcmV0dXJuICIiCgoKcmF3ID0gKHNlbGYuaW5wdXRfYXJnIG9yICIiKS5zdHJpcCgpCmlmIG5vdCByYXc6CiAgICByYXcgPSBvcy5nZXRlbnYoIkNPTlRSQUNUX0lOUFVUX0pTT04iLCAiIikuc3RyaXAoKQppZiBub3QgcmF3OgogICAgcmF3ID0gc3RyKF9SRVBPX1JPT1QgLyAicnBhX3lpc2FpcWkiIC8gImV4YW1wbGVzIiAvICJjb250cmFjdF9jcmVhdGVfaW5wdXRfZHJ5X3J1bi5qc29uIikKICAgIHByaW50KCLmnKrkvKDlhaUgLWkvLS1pbnB1dO+8jOS9v+eUqOm7mOiupOiBlOiwg+WFpeWPgu+8miIsIHJhdykKaWYgbm90IHJhdzoKICAgIHJhaXNlIENvbnRyYWN0Q3JlYXRlRXJyb3IoIue8uuWwkeS4muWKoeWFpeWPgu+8muivt+mAmui/hyAtaS8tLWlucHV0IOS8oOWFpSBKU09OIOWtl+espuS4suaIluaWh+S7tui3r+W+hCIpCgpkYXRhID0gbG9hZF9pbnB1dF9kYXRhKHJhdykKc3RhdGVfY2VudGVyX3VybCA9IF9maXJzdCgKICAgIGRhdGEuZ2V0KCJzdGF0ZUNlbnRlclVybCIpLCBkYXRhLmdldCgic3RhdGVDZW50ZXJVUkwiKSwKICAgIG9zLmdldGVudigiU1RBVEVfQ0VOVEVSX1VSTCIpLCAiaHR0cDovLzEyNy4wLjAuMTo4MDAwIiwKKQphZ2VudF90b2tlbiA9IF9maXJzdChkYXRhLmdldCgiYWdlbnRUb2tlbiIpLCBvcy5nZXRlbnYoIkFHRU5UX1RPS0VOIiksICJkZXYtYWdlbnQtdG9rZW4iKQoKX3N0LnNwZWMgPSBidWlsZF9zcGVjX2Zyb21fZGF0YShkYXRhKQpfc3QuY2xpZW50ID0gU3RhdGVDZW50ZXJDbGllbnQoc3RhdGVfY2VudGVyX3VybCwgYWdlbnRfdG9rZW4pCl9zdC5jdHggPSBfc3Quc3BlYy5mbG93X2NvbnRleHQKX3N0LmJyb3dzZXIgPSBBcGFCcm93c2VyQWRhcHRlcih1cmw9X3N0LmN0eC5jb250cmFjdF91cmwpCl9zdC5pc19kcnlfcnVuID0gKF9zdC5zcGVjLnN1Ym1pdF9tb2RlID09ICJkcnlfcnVuIikKCmlmIG5vdCBfc3QuaXNfZHJ5X3J1biBhbmQgb3MuZ2V0ZW52KCJSUEFfQ0FMTEJBQ0tfU1RBVEVfQ0VOVEVSIikgPT0gIjEiOgogICAgX3N0LmNsaWVudC5zdGFydF9ub2RlKF9zdC5zcGVjLmpvYl9pZCwgIkNPTlRSQUNUX0NSRUFURSIp\",\n \"PImport\": \"\"\n}\n"
},
"EP_Gen": {
"EP_Gen_AFTERDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_BEFOREDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_NOTE": "{\n \"val\": \"UzAxIOWIneWni+WMluW5tuWbnuiwgyBzdGFydA==\"\n}\n"
}
},
"NodeUI": {
"NodeElementID": "",
"NodeModel": "2",
"NodePropErr": false,
"NodePx": "700",
"NodePy": "280",
"NodeSubType": "EnumWidget_Code_Custom",
"NodeTag": "202608143010000000",
"NodeTitle": "插入代码块#1-13"
}
},
{
"NodePanelData": {
"ExpandedGroups": [
],
"InputProperty": {
"mouse_x": 0,
"mouse_y": 0
},
"PubProperty": "",
"RetPins": [
]
},
"NodeProperty": {
"EP_CONTROL": {
"EP_CONTROL_IMPORT": "{\n \"PBCode\": \"cHJpbnQoIltTMDIg5omT5byA5ZCI5ZCM57O757ufXSIpCmltcG9ydCBycGFfeWlzYWlxaS5fZmxvd19zdGF0ZSBhcyBfc3QKZnJvbSBycGFfeWlzYWlxaS5jb250cmFjdF9mbG93X3N0ZXBzIGltcG9ydCBvcGVuX2NvbnRyYWN0X3N5c3RlbQoKb3Blbl9jb250cmFjdF9zeXN0ZW0oX3N0LmJyb3dzZXIsIF9zdC5jdHgp\",\n \"PImport\": \"\"\n}\n"
},
"EP_Gen": {
"EP_Gen_AFTERDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_BEFOREDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_NOTE": "{\n \"val\": \"UzAyIOaJk+W8gOWQiOWQjOezu+e7nw==\"\n}\n"
}
},
"NodeUI": {
"NodeElementID": "",
"NodeModel": "2",
"NodePropErr": false,
"NodePx": "980",
"NodePy": "280",
"NodeSubType": "EnumWidget_Code_Custom",
"NodeTag": "202608143020000000",
"NodeTitle": "插入代码块#1-12"
}
},
{
"NodePanelData": {
"ExpandedGroups": [
],
"InputProperty": {
"mouse_x": 0,
"mouse_y": 0
},
"PubProperty": "",
"RetPins": [
]
},
"NodeProperty": {
"EP_CONTROL": {
"EP_CONTROL_IMPORT": "{\n \"PImport\": \"\",\n \"PBCode\": \"cHJpbnQoIltTMDMg55m75b2V5ZCI5ZCM57O757ufXSIpCmltcG9ydCBycGFfeWlzYWlxaS5fZmxvd19zdGF0ZSBhcyBfc3QKZnJvbSBycGFfeWlzYWlxaS5jb250cmFjdF9mbG93X3N0ZXBzIGltcG9ydCBsb2dpbl9jb250cmFjdF9zeXN0ZW0KCmxvZ2luX2NvbnRyYWN0X3N5c3RlbShfc3QuYnJvd3NlciwgX3N0LmN0eCk=\"\n}\n"
},
"EP_Gen": {
"EP_Gen_AFTERDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_BEFOREDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_NOTE": "{\n \"val\": \"UzAzIOeZu+W9leWQiOWQjOezu+e7nw==\"\n}\n"
}
},
"NodeUI": {
"NodeElementID": "",
"NodeModel": "2",
"NodePropErr": false,
"NodePx": "1260",
"NodePy": "280",
"NodeSubType": "EnumWidget_Code_Custom",
"NodeTag": "202608143030000000",
"NodeTitle": "插入代码块#1-11"
}
},
{
"NodePanelData": {
"ExpandedGroups": [
],
"InputProperty": {
"mouse_x": 0,
"mouse_y": 0
},
"PubProperty": "",
"RetPins": [
]
},
"NodeProperty": {
"EP_CONTROL": {
"EP_CONTROL_IMPORT": "{\n \"PImport\": \"\",\n \"PBCode\": \"cHJpbnQoIltTMDQg6L+b5YWl5ZCI5ZCM6LW36I2JXSIpCmltcG9ydCBycGFfeWlzYWlxaS5fZmxvd19zdGF0ZSBhcyBfc3QKZnJvbSBycGFfeWlzYWlxaS5jb250cmFjdF9mbG93X3N0ZXBzIGltcG9ydCBlbnRlcl9jb250cmFjdF9jcmVhdGUKCmVudGVyX2NvbnRyYWN0X2NyZWF0ZShfc3QuYnJvd3Nlcik=\"\n}\n"
},
"EP_Gen": {
"EP_Gen_AFTERDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_BEFOREDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_NOTE": "{\n \"val\": \"UzA0IOi/m+WFpeWQiOWQjOi1t+iNiQ==\"\n}\n"
}
},
"NodeUI": {
"NodeElementID": "",
"NodeModel": "2",
"NodePropErr": false,
"NodePx": "420",
"NodePy": "480",
"NodeSubType": "EnumWidget_Code_Custom",
"NodeTag": "202608143040000000",
"NodeTitle": "插入代码块#1-10"
}
},
{
"NodePanelData": {
"ExpandedGroups": [
],
"InputProperty": {
"mouse_x": 0,
"mouse_y": 0
},
"PubProperty": "",
"RetPins": [
]
},
"NodeProperty": {
"EP_CONTROL": {
"EP_CONTROL_IMPORT": "{\n \"PImport\": \"\",\n \"PBCode\": \"cHJpbnQoIltTMDUg6YCJ5oup5qih5p2/XSIpCmltcG9ydCBycGFfeWlzYWlxaS5fZmxvd19zdGF0ZSBhcyBfc3QKZnJvbSBycGFfeWlzYWlxaS5jb250cmFjdF9mbG93X3N0ZXBzIGltcG9ydCBzZWxlY3RfY29udHJhY3RfdGVtcGxhdGUKCnNlbGVjdF9jb250cmFjdF90ZW1wbGF0ZShfc3QuYnJvd3NlciwgX3N0LmN0eCk=\"\n}\n"
},
"EP_Gen": {
"EP_Gen_AFTERDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_BEFOREDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_NOTE": "{\n \"val\": \"UzA1IOmAieaLqeaooeadvw==\"\n}\n"
}
},
"NodeUI": {
"NodeElementID": "",
"NodeModel": "2",
"NodePropErr": false,
"NodePx": "700",
"NodePy": "480",
"NodeSubType": "EnumWidget_Code_Custom",
"NodeTag": "202608143050000000",
"NodeTitle": "插入代码块#1-9"
}
},
{
"NodePanelData": {
"ExpandedGroups": [
],
"InputProperty": {
"mouse_x": 0,
"mouse_y": 0
},
"PubProperty": "",
"RetPins": [
]
},
"NodeProperty": {
"EP_CONTROL": {
"EP_CONTROL_IMPORT": "{\n \"PImport\": \"\",\n \"PBCode\": \"cHJpbnQoIltTMDYg5aGr5YaZ562+57qm5pa5XSIpCmltcG9ydCBycGFfeWlzYWlxaS5fZmxvd19zdGF0ZSBhcyBfc3QKZnJvbSBycGFfeWlzYWlxaS5jb250cmFjdF9mbG93X3N0ZXBzIGltcG9ydCBmaWxsX2NvbnRyYWN0X3NpZ25hdG9yeV9pbmZvCgpmaWxsX2NvbnRyYWN0X3NpZ25hdG9yeV9pbmZvKF9zdC5icm93c2VyLCBfc3QuY3R4KQ==\"\n}\n"
},
"EP_Gen": {
"EP_Gen_AFTERDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_BEFOREDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_NOTE": "{\n \"val\": \"UzA2IOWhq+WGmeetvue6puaWuQ==\"\n}\n"
}
},
"NodeUI": {
"NodeElementID": "",
"NodeModel": "2",
"NodePropErr": false,
"NodePx": "420",
"NodePy": "680",
"NodeSubType": "EnumWidget_Code_Custom",
"NodeTag": "202608143060000000",
"NodeTitle": "插入代码块#1-8"
}
},
{
"NodePanelData": {
"ExpandedGroups": [
],
"InputProperty": {
"mouse_x": 0,
"mouse_y": 0
},
"PubProperty": "",
"RetPins": [
]
},
"NodeProperty": {
"EP_CONTROL": {
"EP_CONTROL_IMPORT": "{\n \"PImport\": \"\",\n \"PBCode\": \"cHJpbnQoIltTMDcg5aGr5YaZ5Z+656GA5L+h5oGvXSIpCmltcG9ydCBycGFfeWlzYWlxaS5fZmxvd19zdGF0ZSBhcyBfc3QKZnJvbSBycGFfeWlzYWlxaS5jb250cmFjdF9mbG93X3N0ZXBzIGltcG9ydCBmaWxsX2NvbnRyYWN0X2Jhc2ljX2luZm8KCmZpbGxfY29udHJhY3RfYmFzaWNfaW5mbyhfc3QuYnJvd3NlciwgX3N0LmN0eCk=\"\n}\n"
},
"EP_Gen": {
"EP_Gen_AFTERDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_BEFOREDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_NOTE": "{\n \"val\": \"UzA3IOWhq+WGmeWfuuehgOS/oeaBrw==\"\n}\n"
}
},
"NodeUI": {
"NodeElementID": "",
"NodeModel": "2",
"NodePropErr": false,
"NodePx": "700",
"NodePy": "680",
"NodeSubType": "EnumWidget_Code_Custom",
"NodeTag": "202608143070000000",
"NodeTitle": "插入代码块#1-7"
}
},
{
"NodePanelData": {
"ExpandedGroups": [
],
"InputProperty": {
"mouse_x": 0,
"mouse_y": 0
},
"PubProperty": "",
"RetPins": [
]
},
"NodeProperty": {
"EP_CONTROL": {
"EP_CONTROL_IMPORT": "{\n \"PImport\": \"\",\n \"PBCode\": \"cHJpbnQoIltTMDgg5aGr5YaZ6YeR6aKd5pel5pyfXSIpCmltcG9ydCBycGFfeWlzYWlxaS5fZmxvd19zdGF0ZSBhcyBfc3QKZnJvbSBycGFfeWlzYWlxaS5jb250cmFjdF9mbG93X3N0ZXBzIGltcG9ydCBmaWxsX2NvbnRyYWN0X2Ftb3VudF9hbmRfZGF0ZXMKCmZpbGxfY29udHJhY3RfYW1vdW50X2FuZF9kYXRlcyhfc3QuYnJvd3NlciwgX3N0LmN0eCk=\"\n}\n"
},
"EP_Gen": {
"EP_Gen_AFTERDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_BEFOREDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_NOTE": "{\n \"val\": \"UzA4IOWhq+WGmemHkemineaXpeacnw==\"\n}\n"
}
},
"NodeUI": {
"NodeElementID": "",
"NodeModel": "2",
"NodePropErr": false,
"NodePx": "980",
"NodePy": "680",
"NodeSubType": "EnumWidget_Code_Custom",
"NodeTag": "202608143080000000",
"NodeTitle": "插入代码块#1-6"
}
},
{
"NodePanelData": {
"ExpandedGroups": [
],
"InputProperty": {
"mouse_x": 0,
"mouse_y": 0
},
"PubProperty": "",
"RetPins": [
]
},
"NodeProperty": {
"EP_CONTROL": {
"EP_CONTROL_IMPORT": "{\n \"PImport\": \"\",\n \"PBCode\": \"cHJpbnQoIltTMDkg5aGr5YaZ6LSn5ZOB5piO57uGXSIpCmltcG9ydCBycGFfeWlzYWlxaS5fZmxvd19zdGF0ZSBhcyBfc3QKZnJvbSBycGFfeWlzYWlxaS5jb250cmFjdF9mbG93X3N0ZXBzIGltcG9ydCBmaWxsX2NvbnRyYWN0X2dvb2RzX2xpbmVzCgpmaWxsX2NvbnRyYWN0X2dvb2RzX2xpbmVzKF9zdC5icm93c2VyLCBfc3QuY3R4KQ==\"\n}\n"
},
"EP_Gen": {
"EP_Gen_AFTERDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_BEFOREDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_NOTE": "{\n \"val\": \"UzA5IOWhq+WGmei0p+WTgeaYjue7hg==\"\n}\n"
}
},
"NodeUI": {
"NodeElementID": "",
"NodeModel": "2",
"NodePropErr": false,
"NodePx": "1260",
"NodePy": "680",
"NodeSubType": "EnumWidget_Code_Custom",
"NodeTag": "202608143090000000",
"NodeTitle": "插入代码块#1-5"
}
},
{
"NodePanelData": {
"ExpandedGroups": [
],
"InputProperty": {
"mouse_x": 0,
"mouse_y": 0
},
"PubProperty": "",
"RetPins": [
]
},
"NodeProperty": {
"EP_CONTROL": {
"EP_CONTROL_IMPORT": "{\n \"PImport\": \"\",\n \"PBCode\": \"cHJpbnQoIltTMTAg5aGr5YaZ5LuY5qy+5L+h5oGvXSIpCmltcG9ydCBycGFfeWlzYWlxaS5fZmxvd19zdGF0ZSBhcyBfc3QKZnJvbSBycGFfeWlzYWlxaS5jb250cmFjdF9mbG93X3N0ZXBzIGltcG9ydCBmaWxsX2NvbnRyYWN0X3BheW1lbnRfaW5mbwoKZmlsbF9jb250cmFjdF9wYXltZW50X2luZm8oX3N0LmJyb3dzZXIsIF9zdC5jdHgp\"\n}\n"
},
"EP_Gen": {
"EP_Gen_AFTERDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_BEFOREDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_NOTE": "{\n \"val\": \"UzEwIOWhq+WGmeS7mOasvuS/oeaBrw==\"\n}\n"
}
},
"NodeUI": {
"NodeElementID": "",
"NodeModel": "2",
"NodePropErr": false,
"NodePx": "1540",
"NodePy": "680",
"NodeSubType": "EnumWidget_Code_Custom",
"NodeTag": "202608143100000000",
"NodeTitle": "插入代码块#1-4"
}
},
{
"NodePanelData": {
"ExpandedGroups": [
],
"InputProperty": {
"mouse_x": 0,
"mouse_y": 0
},
"PubProperty": "",
"RetPins": [
]
},
"NodeProperty": {
"EP_CONTROL": {
"EP_CONTROL_IMPORT": "{\n \"PImport\": \"\",\n \"PBCode\": \"cHJpbnQoIltTMTEg5LiK5Lyg5ZCI5ZCMUERGXSIpCmltcG9ydCBycGFfeWlzYWlxaS5fZmxvd19zdGF0ZSBhcyBfc3QKZnJvbSBycGFfeWlzYWlxaS5jb250cmFjdF9mbG93X3N0ZXBzIGltcG9ydCB1cGxvYWRfY29udHJhY3RfZG9jdW1lbnRfaWZfbmVlZGVkCgp1cGxvYWRfY29udHJhY3RfZG9jdW1lbnRfaWZfbmVlZGVkKF9zdC5icm93c2VyLCBfc3QuY3R4KQ==\"\n}\n"
},
"EP_Gen": {
"EP_Gen_AFTERDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_BEFOREDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_NOTE": "{\n \"val\": \"UzExIOS4iuS8oOWQiOWQjFBERg==\"\n}\n"
}
},
"NodeUI": {
"NodeElementID": "",
"NodeModel": "2",
"NodePropErr": false,
"NodePx": "420",
"NodePy": "880",
"NodeSubType": "EnumWidget_Code_Custom",
"NodeTag": "202608143110000000",
"NodeTitle": "插入代码块#1-3"
}
},
{
"NodePanelData": {
"ExpandedGroups": [
],
"InputProperty": {
"mouse_x": 0,
"mouse_y": 0
},
"PubProperty": "",
"RetPins": [
]
},
"NodeProperty": {
"EP_CONTROL": {
"EP_CONTROL_IMPORT": "{\n \"PImport\": \"\",\n \"PBCode\": \"cHJpbnQoIltTMTIg5o+Q5Lqk6YCB5a6hXSIpCmltcG9ydCBycGFfeWlzYWlxaS5fZmxvd19zdGF0ZSBhcyBfc3QKZnJvbSBycGFfeWlzYWlxaS5jb250cmFjdF9mbG93X3N0ZXBzIGltcG9ydCBzdWJtaXRfY29udHJhY3RfZm9yX2FwcHJvdmFsCgpfc3Quc3VibWl0dGVkID0gc3VibWl0X2NvbnRyYWN0X2Zvcl9hcHByb3ZhbChfc3QuYnJvd3NlciwgX3N0LmN0eCk=\"\n}\n"
},
"EP_Gen": {
"EP_Gen_AFTERDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_BEFOREDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_NOTE": "{\n \"val\": \"UzEyIOaPkOS6pOmAgeWuoQ==\"\n}\n"
}
},
"NodeUI": {
"NodeElementID": "",
"NodeModel": "2",
"NodePropErr": false,
"NodePx": "700",
"NodePy": "880",
"NodeSubType": "EnumWidget_Code_Custom",
"NodeTag": "202608143120000000",
"NodeTitle": "插入代码块#1-2"
}
},
{
"NodePanelData": {
"ExpandedGroups": [
],
"InputProperty": {
"mouse_x": 0,
"mouse_y": 0
},
"PubProperty": "",
"RetPins": [
]
},
"NodeProperty": {
"EP_CONTROL": {
"EP_CONTROL_IMPORT": "{\n \"PImport\": \"\",\n \"PBCode\": \"cHJpbnQoIltTMTMg6K+75Y+W5ZCI5ZCM57yW5Y+3XSIpCmltcG9ydCBycGFfeWlzYWlxaS5fZmxvd19zdGF0ZSBhcyBfc3QKZnJvbSBycGFfeWlzYWlxaS5jb250cmFjdF9mbG93X3N0ZXBzIGltcG9ydCByZWFkX2NvbnRyYWN0X251bWJlcgoKaWYgX3N0LnN1Ym1pdHRlZDoKICAgIF9zdC5jb250cmFjdF9ubyA9IHJlYWRfY29udHJhY3RfbnVtYmVyKF9zdC5icm93c2VyKQ==\"\n}\n"
},
"EP_Gen": {
"EP_Gen_AFTERDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_BEFOREDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_NOTE": "{\n \"val\": \"UzEzIOivu+WPluWQiOWQjOe8luWPtw==\"\n}\n"
}
},
"NodeUI": {
"NodeElementID": "",
"NodeModel": "2",
"NodePropErr": false,
"NodePx": "980",
"NodePy": "880",
"NodeSubType": "EnumWidget_Code_Custom",
"NodeTag": "202608143130000000",
"NodeTitle": "插入代码块#1-1"
}
},
{
"NodePanelData": {
"ExpandedGroups": [
],
"InputProperty": {
"mouse_x": 0,
"mouse_y": 0
},
"PubProperty": "",
"RetPins": [
]
},
"NodeProperty": {
"EP_CONTROL": {
"EP_CONTROL_IMPORT": "{\n \"PImport\": \"\",\n \"PBCode\": \"cHJpbnQoIltTMTQg5Zue6LCDIGNvbXBsZXRlXSIpCmltcG9ydCBqc29uCmltcG9ydCBycGFfeWlzYWlxaS5fZmxvd19zdGF0ZSBhcyBfc3QKCmlmIG5vdCBfc3QuaXNfZHJ5X3J1biBhbmQgX3N0LmNvbnRyYWN0X25vIGFuZCBvcy5nZXRlbnYoIlJQQV9DQUxMQkFDS19TVEFURV9DRU5URVIiKSA9PSAiMSI6CiAgICBfc3QuY2xpZW50LmNvbXBsZXRlX25vZGUoX3N0LnNwZWMuam9iX2lkLCAiQ09OVFJBQ1RfQ1JFQVRFIiwgewogICAgICAgICJidXNpbmVzc1JlZnMiOiB7ImNvbnRyYWN0U3lzdGVtTm8iOiBfc3QuY29udHJhY3Rfbm99LAogICAgfSkKcHJpbnQoanNvbi5kdW1wcyh7InN0YXR1cyI6ICJkb25lIiwgImNvbnRyYWN0U3lzdGVtTm8iOiBfc3QuY29udHJhY3Rfbm99LCBlbnN1cmVfYXNjaWk9RmFsc2UpKQ==\"\n}\n"
},
"EP_Gen": {
"EP_Gen_AFTERDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_BEFOREDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_NOTE": "{\n \"val\": \"UzE0IOWbnuiwgyBjb21wbGV0ZQ==\"\n}\n"
}
},
"NodeUI": {
"NodeElementID": "",
"NodeModel": "2",
"NodePropErr": false,
"NodePx": "1260",
"NodePy": "880",
"NodeSubType": "EnumWidget_Code_Custom",
"NodeTag": "202608143140000000",
"NodeTitle": "插入代码块#1"
}
}
],
"SeqTag": "Main",
"ViewCenterPx": "1357.02",
"ViewCenterPy": "547.412",
"ViewScaling": "0.564474"
}

View File

@ -0,0 +1,46 @@
{
"GlobalVar": [
],
"OpenFlow": "[\n {\n \"current-tab\": \"true\",\n \"seq\": \"Main\",\n \"tag\": \"2026081210133309518\",\n \"title\": \"Main\",\n \"type\": \"bp\"\n }\n]\n",
"ProInfo": {
"CodeCustomCount": "0",
"ProAbsolutePath": "D:/work/AutoRpa/rpa_yisaiqi/studio",
"ProChange": "2026/08/18 13:12:33",
"ProColorRef": "4",
"ProCreate": "2026/08/12 10:13:31",
"ProDesc": "",
"ProDocVersion": "1.0.0.4",
"ProImports": "",
"ProIsAdminRun": "0",
"ProIsNewReturn": "1",
"ProName": "AutoRpa",
"ProStudio": "2026.1.0.36",
"ProTag": "2026081210133159316",
"ProTheme": "light",
"ProUIADeepMatch": "1",
"ProUserID": "34BFF76918A9449ABAB323149DDCEA73",
"ProUserName": "Administrator",
"ProVersion": "1.0.0.0",
"operatingSystem": 0,
"releaseUserName": "qm01"
},
"SeqGroupList": [
],
"SeqPythonModuleList": [
],
"SeqTextFileList": [
],
"SequenceList": [
{
"sdc": "",
"seq": "Main",
"seqptag": "",
"seqret": "1",
"seqtag": "2026081210133309518",
"spr": [
],
"spv": [
]
}
]
}

View File

@ -0,0 +1,22 @@
# coding=utf-8
# 编译日期:2026-08-12 10:13:31
# 版权所有:www.i-search.com.cn
# 测试运行或调试以上全局函数入口
if __name__ == '__main__':
pass

View File

@ -0,0 +1,200 @@
# coding=utf-8
# 编译日期:2026-08-17 16:28:48
# 版权所有:www.i-search.com.cn
import ubpa.init_input as iinput
from ubpa.base_util import StdOutHook, ExceptionHandler
import time
import pdb
from ubpa.ilog import ILog
import getopt
from sys import argv
import sys
import os
import datetime as is_datetime
import pandas
from ubpa.base_img import *
import ubpa.iplatform as iplatform
class AutoRpa:
def __init__(self,**kwargs):
self.__logger = ILog(__file__)
self.path = set_img_res_path(__file__)
self.robot_no = ''
self.proc_no = ''
self.job_no = ''
self.input_arg = ''
if('robot_no' in kwargs.keys()):
self.robot_no = kwargs['robot_no']
if('proc_no' in kwargs.keys()):
self.proc_no = kwargs['proc_no']
if('job_no' in kwargs.keys()):
self.job_no = kwargs['job_no']
ILog.JOB_NO, ILog.OLD_STDOUT = self.job_no, sys.stdout
sys.stdout = StdOutHook(self.job_no, sys.stdout)
ExceptionHandler.JOB_NO, ExceptionHandler.OLD_STDERR = self.job_no, sys.stderr
sys.excepthook = ExceptionHandler.handle_exception
if('input_arg' in kwargs.keys()):
self.input_arg = kwargs['input_arg']
if(len(self.input_arg) <= 0):
self.input_arg = iinput.load_init(__file__)
if self.input_arg is None:
sys.exit(0)
def Main(self):
#插入代码块#1-13
self.__logger.dlogs(job_no=self.job_no,logmsg='Flow:Main,StepNodeTag:202608143010000000,Title:插入代码块#1-13,Note:S01 初始化并回调 start')
print("[S01 初始化并回调 start]")
import sys, os, json
from pathlib import Path
_REPO_ROOT = next(
p for p in Path(__file__).resolve().parents
if (p / "rpa_yisaiqi" / "__init__.py").exists()
)
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_create_node import ContractCreateError, load_input_data, build_spec_from_data
from rpa_yisaiqi.callback_client import StateCenterClient
from rpa_yisaiqi.apa_browser_adapter import ApaBrowserAdapter
def _first(*values):
for value in values:
if value is not None and str(value).strip():
return str(value).strip()
return ""
raw = (self.input_arg or "").strip()
if not raw:
raw = os.getenv("CONTRACT_INPUT_JSON", "").strip()
if not raw:
raw = str(_REPO_ROOT / "rpa_yisaiqi" / "examples" / "contract_create_input_dry_run.json")
print("未传入 -i/--input,使用默认联调入参:", raw)
if not raw:
raise ContractCreateError("缺少业务入参:请通过 -i/--input 传入 JSON 字符串或文件路径")
data = load_input_data(raw)
state_center_url = _first(
data.get("stateCenterUrl"), data.get("stateCenterURL"),
os.getenv("STATE_CENTER_URL"), "http://127.0.0.1:8000",
)
agent_token = _first(data.get("agentToken"), os.getenv("AGENT_TOKEN"), "dev-agent-token")
_st.spec = build_spec_from_data(data)
_st.client = StateCenterClient(state_center_url, agent_token)
_st.ctx = _st.spec.flow_context
_st.browser = ApaBrowserAdapter(url=_st.ctx.contract_url)
_st.is_dry_run = (_st.spec.submit_mode == "dry_run")
if not _st.is_dry_run and os.getenv("RPA_CALLBACK_STATE_CENTER") == "1":
_st.client.start_node(_st.spec.job_id, "CONTRACT_CREATE")
#插入代码块#1-12
self.__logger.dlogs(job_no=self.job_no,logmsg='Flow:Main,StepNodeTag:202608143020000000,Title:插入代码块#1-12,Note:S02 打开合同系统')
print("[S02 打开合同系统]")
import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import open_contract_system
open_contract_system(_st.browser, _st.ctx)
#插入代码块#1-11
self.__logger.dlogs(job_no=self.job_no,logmsg='Flow:Main,StepNodeTag:202608143030000000,Title:插入代码块#1-11,Note:S03 登录合同系统')
print("[S03 登录合同系统]")
import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import login_contract_system
login_contract_system(_st.browser, _st.ctx)
#插入代码块#1-10
self.__logger.dlogs(job_no=self.job_no,logmsg='Flow:Main,StepNodeTag:202608143040000000,Title:插入代码块#1-10,Note:S04 进入合同起草')
print("[S04 进入合同起草]")
import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import enter_contract_create
enter_contract_create(_st.browser)
#插入代码块#1-9
self.__logger.dlogs(job_no=self.job_no,logmsg='Flow:Main,StepNodeTag:202608143050000000,Title:插入代码块#1-9,Note:S05 选择模板')
print("[S05 选择模板]")
import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import select_contract_template
select_contract_template(_st.browser, _st.ctx)
#插入代码块#1-8
self.__logger.dlogs(job_no=self.job_no,logmsg='Flow:Main,StepNodeTag:202608143060000000,Title:插入代码块#1-8,Note:S06 填写签约方')
print("[S06 填写签约方]")
import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import fill_contract_signatory_info
fill_contract_signatory_info(_st.browser, _st.ctx)
#插入代码块#1-7
self.__logger.dlogs(job_no=self.job_no,logmsg='Flow:Main,StepNodeTag:202608143070000000,Title:插入代码块#1-7,Note:S07 填写基础信息')
print("[S07 填写基础信息]")
import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import fill_contract_basic_info
fill_contract_basic_info(_st.browser, _st.ctx)
#插入代码块#1-6
self.__logger.dlogs(job_no=self.job_no,logmsg='Flow:Main,StepNodeTag:202608143080000000,Title:插入代码块#1-6,Note:S08 填写金额日期')
print("[S08 填写金额日期]")
import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import fill_contract_amount_and_dates
fill_contract_amount_and_dates(_st.browser, _st.ctx)
#插入代码块#1-5
self.__logger.dlogs(job_no=self.job_no,logmsg='Flow:Main,StepNodeTag:202608143090000000,Title:插入代码块#1-5,Note:S09 填写货品明细')
print("[S09 填写货品明细]")
import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import fill_contract_goods_lines
fill_contract_goods_lines(_st.browser, _st.ctx)
#插入代码块#1-4
self.__logger.dlogs(job_no=self.job_no,logmsg='Flow:Main,StepNodeTag:202608143100000000,Title:插入代码块#1-4,Note:S10 填写付款信息')
print("[S10 填写付款信息]")
import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import fill_contract_payment_info
fill_contract_payment_info(_st.browser, _st.ctx)
#插入代码块#1-3
self.__logger.dlogs(job_no=self.job_no,logmsg='Flow:Main,StepNodeTag:202608143110000000,Title:插入代码块#1-3,Note:S11 上传合同PDF')
print("[S11 上传合同PDF]")
import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import upload_contract_document_if_needed
upload_contract_document_if_needed(_st.browser, _st.ctx)
#插入代码块#1-2
self.__logger.dlogs(job_no=self.job_no,logmsg='Flow:Main,StepNodeTag:202608143120000000,Title:插入代码块#1-2,Note:S12 提交送审')
print("[S12 提交送审]")
import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import submit_contract_for_approval
_st.submitted = submit_contract_for_approval(_st.browser, _st.ctx)
#插入代码块#1-1
self.__logger.dlogs(job_no=self.job_no,logmsg='Flow:Main,StepNodeTag:202608143130000000,Title:插入代码块#1-1,Note:S13 读取合同编号')
print("[S13 读取合同编号]")
import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import read_contract_number
if _st.submitted:
_st.contract_no = read_contract_number(_st.browser)
#插入代码块#1
self.__logger.dlogs(job_no=self.job_no,logmsg='Flow:Main,StepNodeTag:202608143140000000,Title:插入代码块#1,Note:S14 回调 complete')
print("[S14 回调 complete]")
import json
import rpa_yisaiqi._flow_state as _st
if not _st.is_dry_run and _st.contract_no and os.getenv("RPA_CALLBACK_STATE_CENTER") == "1":
_st.client.complete_node(_st.spec.job_id, "CONTRACT_CREATE", {
"businessRefs": {"contractSystemNo": _st.contract_no},
})
print(json.dumps({"status": "done", "contractSystemNo": _st.contract_no}, ensure_ascii=False))
if __name__ == '__main__':
ILog.begin_init()
robot_no = ''
proc_no = ''
job_no = ''
input_arg = ''
params = {}
fn = "Main"
flow_params={'Main':[]}
try:
argv = sys.argv[1:]
opts, args = getopt.getopt(argv,"hr:p:j:i:c:",["robot = ","proc = ","job = ","input = ","params = "])
except getopt.GetoptError:
print ('robot.py -r <robot> -p <proc> -j <job>')
for opt, arg in opts:
if opt == '-h':
print ('robot.py -r <robot> -p <proc> -j <job>')
elif opt in ("-r", "--robot"):
robot_no = arg
elif opt in ("-p", "--proc"):
proc_no = arg
elif opt in ("-j", "--job"):
job_no = arg
elif opt in ("-i", "--input"):
input_arg = arg
elif opt in ("-c", "--params"):
cParam: dict = iinput.parseConnectorParam(arg)
params = cParam.pop("Params", params)
fn = cParam.pop("FlowName", fn)
pro = AutoRpa(robot_no=robot_no,proc_no=proc_no,job_no=job_no,input_arg=input_arg)
iplatform.Return(MainResult=getattr(pro, fn)(**params), job_no=job_no, fn=fn, flow_return_params=flow_params.get(fn,[]))

View File

@ -0,0 +1,12 @@
# coding: utf8
"""
Python模块脚本
"""
import os
# 项目路径、资源库元素文件(建议其他元素库文件路径在此处声明)
ProjectPath = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
CodePath = os.path.join(ProjectPath, "codes")
ModulePath = os.path.join(CodePath, "modules")
ElementFile = os.path.join(ModulePath, "resources", "elements.json")

View File

@ -0,0 +1,86 @@
# 更多示例代码 http://support.i-search.com.cn:8088/showdoc/web/#/page/11923
## 导入资源库
'''
from iresource import iSResource
is_resource: iSResource = iSResource(__file__)
或者手动指定其他工程元素库文件(当前项目或其他项目)
ElementFile = r"~/NewProject1/codes/modules/resources/elements.json"
is_resource: iSResource = iSResource(json_file=ElementFile)
'''
## 全部资源
'''
all_resource = is_resource.all()
'''
## 获取全局变量、流程变量(参数)
'''
gv1 = is_resource.get_global_var("gv_1")
lv1 = is_resource.get_flow_var("lv_1")
'''
## 设置全局变量、流程变量(参数)
'''
gv1 = is_resource.set_global_var("gv_1", "gv_1新值")
lv1 = is_resource.set_flow_var("lv_1", "lv_1新值")
'''
## 获取元素对象(这段代码可从【元素库】中右击元素,点击【复制代码】直接获得)
'''
element1 = is_resource.get_element("元素ID")
element1.show() # 打印显示元素信息
all_element = is_resource.elements() # 全部元素
'''
## 支持 By.ID,By.CODE,By.NAME 获取元素对象
'''
is_resource.get_element(By.ID, "元素ID")
is_resource.get_element(By.CODE, "元素Code")
is_resource.get_element(By.NAME, "元素Name")
## 也支持具体方法获取元素对象
'''
is_resource.get_element_by_id("元素id")
is_resource.get_element_by_id("元素Code")
is_resource.get_element_by_id("元素Name")
'''
## 元素对象的 方法 和 属性
'''
element1.actions() # 所有方法
element1.properties() # 所有属性
element1.path # 元素路径
element1.snapshot # 元素快照
element1.help("click") # 查看方法使用文档
element1.click(wait_seconds=5)
is_resource.set_action_wait_seconds(5) # 全局调整动作方法执行的等待时间秒数
element1.set_text(value="测试一下")
'''
## 对象化使用
'''
# 使用对象库,通过iSObject对象可以调用子对象(如:键盘、窗口、Excel等)
iSObject.Keyboard(element1.window_title).keypress_input("abc") # 模拟按键
iSObject.Server().set_var("abc", "123") # 设置服务器变量
'''

View File

@ -0,0 +1,3 @@
{
"version": "2.0"
}

View File

@ -0,0 +1,148 @@
# coding=utf-8
"""合同起草真实入口。Studio 编译不会改这个文件。"""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
def _repo_root() -> Path:
for parent in Path(__file__).resolve().parents:
if (parent / "rpa_yisaiqi" / "__init__.py").exists():
return parent
raise RuntimeError("找不到仓库根目录(缺少 rpa_yisaiqi/__init__.py)")
_REPO_ROOT = _repo_root()
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
from rpa_yisaiqi.apa_browser_adapter import ApaBrowserAdapter # noqa: E402
from rpa_yisaiqi.callback_client import StateCenterClient # noqa: E402
from rpa_yisaiqi.contract_create_node import ( # noqa: E402
ContractCreateError,
build_spec_from_data,
load_input_data,
)
from rpa_yisaiqi.contract_flow_steps import ( # noqa: E402
enter_contract_create,
fill_contract_amount_and_dates,
fill_contract_basic_info,
fill_contract_goods_lines,
fill_contract_payment_info,
fill_contract_signatory_info,
login_contract_system,
open_contract_system,
read_contract_number,
select_contract_template,
submit_contract_for_approval,
upload_contract_document_if_needed,
)
def _first(*values) -> str:
for value in values:
if value is not None and str(value).strip():
return str(value).strip()
return ""
def _parse_input_arg(argv: list[str]) -> str:
i = 0
while i < len(argv):
arg = argv[i]
if arg in ("-i", "--input", "--input-json") and i + 1 < len(argv):
return argv[i + 1]
if arg.startswith("--input=") or arg.startswith("--input-json="):
return arg.split("=", 1)[1]
i += 1
return ""
def main() -> int:
print("合同起草流程启动", flush=True)
raw = _parse_input_arg(sys.argv[1:])
if not raw:
raw = os.getenv("CONTRACT_INPUT_JSON", "").strip()
if not raw:
raw = str(_REPO_ROOT / "rpa_yisaiqi" / "examples" / "contract_create_input_dry_run.json")
print("未传入 -i/--input,使用默认联调入参:", raw, flush=True)
print("[S01 初始化]", flush=True)
data = load_input_data(raw)
state_center_url = _first(
data.get("stateCenterUrl"),
data.get("stateCenterURL"),
os.getenv("STATE_CENTER_URL"),
"http://192.168.105.69:8000",
)
agent_token = _first(data.get("agentToken"), os.getenv("AGENT_TOKEN"), "dev-agent-token")
spec = build_spec_from_data(data)
ctx = spec.flow_context
if ctx is None:
raise ContractCreateError("初始化失败:flow_context 为空")
client = StateCenterClient(state_center_url, agent_token)
browser = ApaBrowserAdapter(url=ctx.contract_url, flow="CONTRACT_CREATE")
is_dry_run = spec.submit_mode == "dry_run"
print(" jobId=", spec.job_id, " submitMode=", spec.submit_mode, flush=True)
print(" contractDocumentPath=", ctx.contract_document_path or ctx.merged_pdf_path, flush=True)
if not is_dry_run:
print("回调状态中心 start", spec.job_id, flush=True)
client.start_node(spec.job_id, "CONTRACT_CREATE")
print("[S02 打开合同系统]", flush=True)
open_contract_system(browser, ctx)
print("[S03 登录合同系统]", flush=True)
login_contract_system(browser, ctx)
print("[S04 进入合同起草]", flush=True)
enter_contract_create(browser)
print("[S05 选择模板]", flush=True)
select_contract_template(browser, ctx)
print("[S06 填写签约方]", flush=True)
fill_contract_signatory_info(browser, ctx)
print("[S07 填写基础信息]", flush=True)
fill_contract_basic_info(browser, ctx)
print("[S08 填写金额日期]", flush=True)
fill_contract_amount_and_dates(browser, ctx)
print("[S09 填写货品明细]", flush=True)
fill_contract_goods_lines(browser, ctx)
print("[S10 填写付款信息]", flush=True)
fill_contract_payment_info(browser, ctx)
print("[S11 上传合同PDF]", flush=True)
upload_contract_document_if_needed(browser, ctx)
print("[S12 提交送审]", flush=True)
submitted = submit_contract_for_approval(browser, ctx)
contract_no = ""
if submitted:
print("[S13 读取合同编号]", flush=True)
contract_no = read_contract_number(browser, ctx)
print("[S14 结束]", flush=True)
if not is_dry_run and contract_no:
print("回调状态中心 complete", spec.job_id, contract_no, flush=True)
client.complete_node(
spec.job_id,
"CONTRACT_CREATE",
{"businessRefs": {"contractSystemNo": contract_no}},
)
print(json.dumps({"status": "done", "contractSystemNo": contract_no}, ensure_ascii=False), flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,64 @@
# coding=utf-8
"""APA 启动 Main.py 前会加载本文件。Studio 重新编译不会改这里。
Studio 点运行只带 -p,不带 -i。这里自动补上默认联调 JSON,
避免 S01 因 input_arg 为空直接报「缺少业务入参」。
账号过期把 Main.py 覆盖成空注释时,改走 run_contract.py。
"""
from __future__ import annotations
import os
import sys
def _codes_dir() -> str:
return os.path.dirname(os.path.abspath(__file__))
def _default_input_path() -> str:
repo = os.path.abspath(os.path.join(_codes_dir(), "..", "..", ".."))
return os.path.join(repo, "rpa_yisaiqi", "examples", "contract_create_input_dry_run.json")
def _is_apa_main() -> bool:
if not sys.argv:
return False
return os.path.basename(str(sys.argv[0])).lower() == "main.py"
def _argv_has_input(argv: list) -> bool:
for i, arg in enumerate(argv):
if arg in ("-i", "--input") and i + 1 < len(argv) and str(argv[i + 1]).strip():
return True
if str(arg).startswith("--input=") and str(arg).split("=", 1)[1].strip():
return True
return False
def _main_is_expired_stub(path: str) -> bool:
try:
text = open(path, encoding="utf-8").read()
except Exception:
return False
return "账号过期" in text
# Windows 虚拟机只回调 Mac 状态中心,不在本机起库。
os.environ.setdefault("STATE_CENTER_URL", "http://192.168.105.69:8000")
os.environ.setdefault("AGENT_TOKEN", "dev-agent-token")
os.environ.setdefault("RPA_CALLBACK_STATE_CENTER", "1")
if _is_apa_main():
if _main_is_expired_stub(sys.argv[0]):
print("检测到 Main.py 被艺赛旗覆盖(账号过期),改走独立入口", flush=True)
if _codes_dir() not in sys.path:
sys.path.insert(0, _codes_dir())
from run_contract import main
raise SystemExit(main())
if not _argv_has_input(sys.argv):
default_input = _default_input_path()
if os.path.isfile(default_input):
sys.argv.extend(["-i", default_input])
print("未传入 -i,自动使用默认联调入参:", default_input, flush=True)

View File

@ -0,0 +1 @@
[]

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,240 @@
from rpa_yisaiqi.agent import JobAgent
class FakeClient:
def __init__(self, job, *, tasks=None):
self.job = dict(job)
self.tasks = list(tasks or [])
self.calls = []
def get_job(self, job_id):
self.calls.append(("get_job", job_id))
return {"job": dict(self.job), "tasks": list(self.tasks)}
def start_node(self, job_id, node_code, **kwargs):
self.calls.append(("start", node_code, kwargs))
return {"ok": True}
def complete_node(self, job_id, node_code, payload):
self.calls.append(("complete", node_code, payload))
return {"ok": True, "next_node": "PRODUCT_MATCH"}
def fail_node(self, job_id, node_code, payload):
self.calls.append(("fail", node_code, payload))
return {"ok": True, "job_status": "FAILED_RETRYABLE"}
def ingest_items(self, job_id, rows, replace=True):
self.calls.append(("ingest_items", job_id, rows))
return {"ok": True, "inserted": len(rows)}
def match_run(self, job_id):
self.calls.append(("match_run", job_id))
return {"ok": True, "next_node": "CONTRACT_CREATE"}
def list_tasks(self, **kwargs):
self.calls.append(("list_tasks", kwargs))
return {"items": list(self.tasks)}
def submit_task(self, task_id, action, payload, submit_token, operator="agent"):
self.calls.append(("submit_task", task_id, action, payload, submit_token))
return {"ok": True, "next_node": "CONTRACT_CREATE"}
def check_contract_result(self, job_id, approved, **kwargs):
self.calls.append(("check_result", job_id, approved))
return {"ok": True, "next_node": "REBATE_HEADER"}
def retry_job(self, job_id):
self.calls.append(("retry", job_id))
return {"ok": True, "node_code": self.job["current_node_code"], "attempt_no": 2}
def test_agent_does_not_hardcode_next_node_after_order_fetch():
client = FakeClient({
"job_id": "JOB-1",
"current_node_code": "ORDER_FETCH",
"status": "PENDING",
"source_type": "YYC",
"supplier_name": "重庆亿昊药业",
"order_date": "2026-08-12",
})
agent = JobAgent(client, "JOB-1", runner_mode="mock")
result = agent.step()
assert result["next_node"] == "PRODUCT_MATCH"
assert ("complete", "ORDER_FETCH") == (client.calls[-1][0], client.calls[-1][1])
assert not any(call[0] == "complete" and call[1] == "OCR_EXTRACT" for call in client.calls)
def test_agent_runs_product_match_via_state_center_not_rpa_callback():
client = FakeClient({
"job_id": "JOB-1",
"current_node_code": "PRODUCT_MATCH",
"status": "PENDING",
"source_type": "YYC",
})
agent = JobAgent(client, "JOB-1", runner_mode="mock")
result = agent.step()
assert result["next_node"] == "CONTRACT_CREATE"
assert client.calls[1][0] == "match_run"
assert not any(call[0] in {"start", "complete"} for call in client.calls)
def test_agent_auto_human_submits_source_add_task_not_node_complete():
job_id = "YYC-重庆亿昊药业-2fa0-20260812-16abff"
client = FakeClient(
{
"job_id": job_id,
"current_node_code": "SOURCE_ADD",
"status": "WAITING_USER",
"source_type": "YYC",
},
tasks=[{"task_id": "T-1", "node_code": "SOURCE_ADD", "status": "OPEN"}],
)
agent = JobAgent(client, job_id, runner_mode="mock", auto_human=True)
result = agent.step()
assert result["next_node"] == "CONTRACT_CREATE"
assert client.calls[-1][:3] == ("submit_task", "T-1", "CONFIRM_AND_CONTINUE")
assert len(client.calls[-1][4]) <= 64
assert not any(call[0] == "complete" for call in client.calls)
def test_agent_stops_on_human_node_when_auto_human_disabled():
client = FakeClient({
"job_id": "JOB-1",
"current_node_code": "SOURCE_ADD",
"status": "WAITING_USER",
"source_type": "YYC",
})
agent = JobAgent(client, "JOB-1", runner_mode="mock", auto_human=False)
result = agent.step()
assert result["stopped"] == "WAITING_USER"
assert not any(call[0] == "submit_task" for call in client.calls)
def test_agent_contract_wait_uses_check_result_not_start_complete():
client = FakeClient({
"job_id": "JOB-1",
"current_node_code": "CONTRACT_APPROVE_WAIT",
"status": "WAITING_EXTERNAL",
"source_type": "YYC",
})
agent = JobAgent(client, "JOB-1", runner_mode="mock")
result = agent.step()
assert result["next_node"] == "REBATE_HEADER"
assert client.calls[-1][:2] == ("check_result", "JOB-1")
assert not any(call[0] in {"start", "complete"} for call in client.calls)
def test_agent_retries_failed_retryable_then_stops_if_still_failed():
client = FakeClient({
"job_id": "JOB-1",
"current_node_code": "ORDER_FETCH",
"status": "FAILED_RETRYABLE",
"source_type": "YYC",
})
agent = JobAgent(client, "JOB-1", runner_mode="mock", auto_retry=True)
result = agent.step()
assert ("retry", "JOB-1") in client.calls
assert result["attempt_no"] == 2
class ScriptedClient(FakeClient):
"""Advance through canned job states after each action. Sequence is not in the agent."""
def __init__(self, frames):
self.frames = list(frames)
super().__init__(self.frames[0]["job"], tasks=self.frames[0].get("tasks") or [])
def _advance(self):
if len(self.frames) > 1:
self.frames.pop(0)
self.job = dict(self.frames[0]["job"])
self.tasks = list(self.frames[0].get("tasks") or [])
def complete_node(self, job_id, node_code, payload):
nxt = self.frames[0].get("next_node")
self._advance()
self.calls.append(("complete", node_code, payload))
return {"ok": True, "next_node": nxt}
def match_run(self, job_id):
nxt = self.frames[0].get("next_node")
self._advance()
self.calls.append(("match_run", job_id))
return {"ok": True, "next_node": nxt}
def submit_task(self, task_id, action, payload, submit_token, operator="agent"):
nxt = self.frames[0].get("next_node")
self._advance()
self.calls.append(("submit_task", task_id, action, payload))
return {"ok": True, "next_node": nxt}
def check_contract_result(self, job_id, approved, **kwargs):
nxt = self.frames[0].get("next_node")
self._advance()
self.calls.append(("check_result", job_id, approved))
return {"ok": True, "next_node": nxt}
def test_agent_run_follows_state_center_until_success():
client = ScriptedClient([
{"job": {"job_id": "JOB-1", "current_node_code": "ORDER_FETCH", "status": "PENDING",
"source_type": "YYC", "supplier_name": "重庆亿昊药业", "order_date": "2026-08-12"},
"next_node": "PRODUCT_MATCH"},
{"job": {"job_id": "JOB-1", "current_node_code": "PRODUCT_MATCH", "status": "PENDING",
"source_type": "YYC"},
"next_node": "CONTRACT_CREATE"},
{"job": {"job_id": "JOB-1", "current_node_code": "CONTRACT_CREATE", "status": "PENDING",
"source_type": "YYC", "order_date": "2026-08-12"},
"next_node": "CONTRACT_APPROVE_WAIT"},
{"job": {"job_id": "JOB-1", "current_node_code": "CONTRACT_APPROVE_WAIT",
"status": "WAITING_EXTERNAL", "source_type": "YYC"},
"next_node": "REBATE_HEADER"},
{"job": {"job_id": "JOB-1", "current_node_code": "REBATE_HEADER", "status": "PENDING",
"source_type": "YYC"},
"next_node": "REBATE_IMPORT"},
{"job": {"job_id": "JOB-1", "current_node_code": "REBATE_IMPORT", "status": "PENDING",
"source_type": "YYC"},
"next_node": "REBATE_SUBMIT"},
{"job": {"job_id": "JOB-1", "current_node_code": "REBATE_SUBMIT", "status": "PENDING",
"source_type": "YYC"},
"next_node": "M5_CREATE_ORDER"},
{"job": {"job_id": "JOB-1", "current_node_code": "M5_CREATE_ORDER", "status": "PENDING",
"source_type": "YYC"},
"next_node": "M5_MANUAL_CONFIRM"},
{"job": {"job_id": "JOB-1", "current_node_code": "M5_MANUAL_CONFIRM", "status": "WAITING_USER",
"source_type": "YYC"},
"tasks": [{"task_id": "T-M5", "node_code": "M5_MANUAL_CONFIRM", "status": "OPEN"}],
"next_node": "M5_RECEIPT_NOTICE"},
{"job": {"job_id": "JOB-1", "current_node_code": "M5_RECEIPT_NOTICE", "status": "WAITING_USER",
"source_type": "YYC"},
"tasks": [{"task_id": "T-SH", "node_code": "M5_RECEIPT_NOTICE", "status": "OPEN"}],
"next_node": None},
{"job": {"job_id": "JOB-1", "current_node_code": "ARCHIVE_JOB", "status": "SUCCESS",
"source_type": "YYC"}},
])
agent = JobAgent(client, "JOB-1", runner_mode="mock", auto_human=True)
history = agent.run()
acted = [call[0] for call in client.calls if call[0] in {
"complete", "match_run", "check_result", "submit_task",
}]
assert acted == [
"complete", "match_run", "complete", "check_result",
"complete", "complete", "complete", "complete",
"submit_task", "submit_task",
]
assert history[-1]["stopped"] == "SUCCESS"
assert not any(call[1] == "OCR_EXTRACT" for call in client.calls if call[0] == "complete")

View File

@ -1,6 +1,6 @@
from pathlib import Path from pathlib import Path
from apa_browser_adapter import REPO_ROOT, resolve_chrome_profile_base from rpa_yisaiqi.apa_browser_adapter import REPO_ROOT, resolve_chrome_profile_base
def test_default_chrome_profile_is_outside_repo(): def test_default_chrome_profile_is_outside_repo():
@ -11,7 +11,7 @@ def test_default_chrome_profile_is_outside_repo():
def test_env_path_inside_repo_is_rejected(tmp_path, monkeypatch): def test_env_path_inside_repo_is_rejected(tmp_path, monkeypatch):
inside = REPO_ROOT / "rpa_contract" / "screenshots" / "chrome-profile" inside = REPO_ROOT / "rpa_yisaiqi" / "runtime" / "chrome-profile"
monkeypatch.delenv("RPA_CHROME_PROFILE", raising=False) monkeypatch.delenv("RPA_CHROME_PROFILE", raising=False)
profile = resolve_chrome_profile_base(requested=str(inside)) profile = resolve_chrome_profile_base(requested=str(inside))
assert profile == (REPO_ROOT.parent / "rpa-chrome-profile").resolve() assert profile == (REPO_ROOT.parent / "rpa-chrome-profile").resolve()

View File

@ -0,0 +1,78 @@
import json
from rpa_yisaiqi.callback_client import StateCenterClient
class FakeHttp:
def __init__(self, responses=None):
self.requests = []
self.responses = list(responses or [{"ok": True}])
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,
"method": request.get_method(),
"body": body,
"headers": dict(request.header_items()),
})
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_get_job_uses_get_and_token():
http = FakeHttp([{"job": {"job_id": "JOB-1"}, "tasks": []}])
client = StateCenterClient("http://state-center:8000", "dev-agent-token", opener=http)
result = client.get_job("JOB-1")
assert result["job"]["job_id"] == "JOB-1"
assert http.requests[0]["method"] == "GET"
assert http.requests[0]["url"] == "http://state-center:8000/api/jobs/JOB-1"
assert http.requests[0]["headers"]["X-agent-token"] == "dev-agent-token"
def test_ingest_items_posts_rows():
http = FakeHttp([{"ok": True, "inserted": 1}])
client = StateCenterClient("http://state-center:8000", "dev-agent-token", opener=http)
client.ingest_items("JOB-1", [{"orderNo": "DD-1", "rawProductName": "阿卡波糖片", "qty": 1}])
assert http.requests[0]["url"].endswith("/api/jobs/JOB-1/items")
assert http.requests[0]["body"]["replace"] is True
assert http.requests[0]["body"]["rows"][0]["orderNo"] == "DD-1"
def test_match_run_and_retry_and_task_submit():
http = FakeHttp([
{"ok": True, "next_node": "CONTRACT_CREATE"},
{"ok": True, "attempt_no": 2},
{"ok": True, "next_node": "CONTRACT_CREATE"},
{"ok": True, "next_node": "REBATE_HEADER"},
])
client = StateCenterClient("http://state-center:8000", "dev-agent-token", opener=http)
assert client.match_run("JOB-1")["next_node"] == "CONTRACT_CREATE"
assert client.retry_job("JOB-1")["attempt_no"] == 2
assert client.submit_task("T-1", "CONFIRM_AND_CONTINUE", {}, "tok")["next_node"] == "CONTRACT_CREATE"
assert client.check_contract_result("JOB-1", True)["next_node"] == "REBATE_HEADER"
assert http.requests[0]["url"].endswith("/api/jobs/JOB-1/match/run")
assert http.requests[1]["url"].endswith("/api/jobs/JOB-1/retry")
assert http.requests[2]["url"].endswith("/api/tasks/T-1/submit")
assert http.requests[2]["body"]["action"] == "CONFIRM_AND_CONTINUE"
assert http.requests[3]["url"].endswith("/nodes/CONTRACT_APPROVE_WAIT/check-result")
assert http.requests[3]["body"]["approved"] is True

View File

@ -1,18 +1,18 @@
from accept import ( from rpa_yisaiqi.runtime.accept import (
AcceptanceReport, AcceptanceReport,
accept_log_path, accept_log_path,
artifact_dir, artifact_dir,
evidence_shot, evidence_shot,
field_match, field_match,
) )
from errors import NodeError from rpa_yisaiqi.runtime.errors import NodeError
import pytest import pytest
def test_each_flow_writes_its_own_accept_file(tmp_path, monkeypatch): def test_each_flow_writes_its_own_accept_file(tmp_path, monkeypatch):
monkeypatch.delenv("RPA_ACCEPT_LOG", raising=False) monkeypatch.delenv("RPA_ACCEPT_LOG", raising=False)
monkeypatch.setattr( monkeypatch.setattr(
"accept.SCREENSHOT_ROOT", "rpa_yisaiqi.runtime.accept.SCREENSHOT_ROOT",
tmp_path, tmp_path,
) )
report = AcceptanceReport(flow="ORDER_FETCH") report = AcceptanceReport(flow="ORDER_FETCH")

View File

@ -0,0 +1,75 @@
import json
from rpa_yisaiqi.callback_client import StateCenterClient
from rpa_yisaiqi.runtime.errors import LoginFailed, NodeError
from rpa_yisaiqi.runtime.shell import execute_node
class FakeHttp:
def __init__(self, responses=None):
self.requests = []
self.responses = list(responses or [{"ok": True}, {"ok": True, "next_node": "PRODUCT_MATCH"}])
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,
"method": request.get_method(),
"body": body,
})
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_execute_node_starts_then_completes_with_runner_payload():
http = FakeHttp()
client = StateCenterClient("http://state-center:8000", "dev-agent-token", opener=http)
result = execute_node(
client,
"JOB-1",
"ORDER_FETCH",
lambda: {"summary": {"orderCount": 1}, "businessRefs": {}},
attempt_no=1,
)
assert result["next_node"] == "PRODUCT_MATCH"
assert [req["url"].rsplit("/", 1)[-1] for req in http.requests] == ["start", "complete"]
assert http.requests[1]["body"]["summary"] == {"orderCount": 1}
assert http.requests[1]["body"]["attemptNo"] == 1
def test_execute_node_maps_login_failure_to_fail_callback():
http = FakeHttp([{"ok": True}, {"ok": True, "job_status": "FAILED_RETRYABLE"}])
client = StateCenterClient("http://state-center:8000", "dev-agent-token", opener=http)
def failing():
raise LoginFailed("登录失败")
result = execute_node(client, "JOB-1", "ORDER_FETCH", failing)
assert result["job_status"] == "FAILED_RETRYABLE"
assert http.requests[1]["url"].endswith("/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"
def test_node_error_carries_default_ui_code():
exc = NodeError("元素找不到")
assert exc.error_code == "UI_ELEMENT_NOT_FOUND"
assert exc.retryable is False
assert exc.route_role == "OPS"

View File

@ -0,0 +1,42 @@
"""YiSaiQi adapter template for CONTRACT_CREATE flowchart nodes.
This file is a mapping template. In the YiSaiQi designer, replace each method
body with the platform's native browser activities: open webpage, input text,
click element, select dropdown, upload file, read text, and screenshot.
"""
from __future__ import annotations
class YiSaiQiBrowserAdapter:
def open_url(self, url: str) -> None:
"""Flowchart node: 浏览器打开网页."""
raise NotImplementedError("Map to YiSaiQi browser open-url activity")
def fill_first(self, selectors: list[str], value: str) -> None:
"""Flowchart node: 按候选定位器依次查找,找到后输入文本."""
raise NotImplementedError(f"Map to YiSaiQi input-text activity: {selectors=} {value=}")
def click_first(self, selectors: list[str]) -> None:
"""Flowchart node: 按候选定位器依次查找,找到后点击."""
raise NotImplementedError(f"Map to YiSaiQi click activity: {selectors=}")
def wait_for_text(self, text: str, timeout_seconds: int = 15) -> None:
"""Flowchart node: 等待页面出现指定文本."""
raise NotImplementedError(f"Map to YiSaiQi wait-text activity: {text=} {timeout_seconds=}")
def select_first(self, selectors: list[str], value: str) -> None:
"""Flowchart node: 下拉框选择."""
raise NotImplementedError(f"Map to YiSaiQi select activity: {selectors=} {value=}")
def upload_file_first(self, selectors: list[str], file_path: str) -> None:
"""Flowchart node: 文件上传."""
raise NotImplementedError(f"Map to YiSaiQi upload-file activity: {selectors=} {file_path=}")
def read_first(self, selectors: list[str], value_name: str) -> str:
"""Flowchart node: 读取页面文本/输入框值并写入流程变量."""
raise NotImplementedError(f"Map to YiSaiQi read-text activity: {selectors=} {value_name=}")
def screenshot(self, name: str) -> str:
"""Human evidence only. Acceptance is ACCEPT text from runtime.accept, not this image."""
raise NotImplementedError(f"Map to YiSaiQi screenshot activity: {name=}")

11
run_mock_chain.py Normal file
View File

@ -0,0 +1,11 @@
#!/usr/bin/env python3
"""Thin wrapper around the portable mock agent.
Prefer:
python -m rpa_yisaiqi --create --auto-human
"""
from rpa_yisaiqi.__main__ import main
if __name__ == "__main__":
main()

1
scripts/__init__.py Normal file
View File

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

292
scripts/dev.py Normal file
View File

@ -0,0 +1,292 @@
"""Start and inspect the local state-center integration stack.
Usage:
python scripts/dev.py start
python scripts/dev.py status
python scripts/dev.py stop
"""
from __future__ import annotations
import argparse
import json
import os
import signal
import socket
import subprocess
import sys
import time
from pathlib import Path
from urllib.error import URLError
from urllib.request import urlopen
ROOT = Path(__file__).resolve().parents[1]
RUNTIME_DIR = ROOT / ".runtime"
PID_FILE = RUNTIME_DIR / "dev-pids.json"
MYSQL_CONTAINER = "rpa-center-mysql"
MYSQL_VOLUME = "rpa-center-mysql-data"
MYSQL_IMAGE = "mysql:8"
def build_backend_env(source: dict[str, str] | None = None) -> dict[str, str]:
"""Return backend environment with safe local defaults."""
env = dict(os.environ if source is None else source)
env.setdefault("DEV_MODE", "true")
env.setdefault(
"MYSQL_DSN",
"mysql+pymysql://root:root@127.0.0.1:3306/rpa_center?charset=utf8mb4",
)
env.setdefault("AGENT_TOKEN", "dev-agent-token")
return env
def _run(command: list[str], *, check: bool = True, capture: bool = False) -> subprocess.CompletedProcess[str]:
return subprocess.run(
command,
cwd=ROOT,
check=check,
text=True,
capture_output=capture,
)
def _command_exists(command: str) -> bool:
return subprocess.run(
[command, "--version"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
).returncode == 0
def _backend_python() -> str:
configured = os.getenv("STATE_CENTER_PYTHON")
if configured:
return configured
local_python = ROOT / ".venv_test" / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
if local_python.exists():
return str(local_python)
return sys.executable
def _ensure_backend_runtime() -> str:
python = _backend_python()
probe = subprocess.run(
[python, "-c", "import fastapi, pymysql, uvicorn, cryptography"],
cwd=ROOT,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
check=False,
)
if probe.returncode != 0:
raise RuntimeError(
f"Backend dependencies are missing for {python}. "
f"Install them with: {python} -m pip install -r state_center/requirements.txt"
)
return python
def _docker_container_state() -> str | None:
if not _command_exists("docker"):
return None
result = _run(
["docker", "inspect", "-f", "{{.State.Status}}", MYSQL_CONTAINER],
check=False,
capture=True,
)
if result.returncode != 0:
return None
return result.stdout.strip()
def ensure_mysql() -> None:
if not _command_exists("docker"):
raise RuntimeError("Docker is required to start the local MySQL container.")
state = _docker_container_state()
if state == "running":
return
if state:
_run(["docker", "start", MYSQL_CONTAINER])
return
_run(
[
"docker",
"run",
"--detach",
"--name",
MYSQL_CONTAINER,
"--env",
"MYSQL_ROOT_PASSWORD=root",
"--env",
"MYSQL_DATABASE=rpa_center",
"--publish",
"3306:3306",
"--volume",
f"{MYSQL_VOLUME}:/var/lib/mysql",
"--volume",
f"{ROOT / 'state_center' / 'sql' / 'schema.sql'}:/docker-entrypoint-initdb.d/001-schema.sql:ro",
MYSQL_IMAGE,
]
)
def _port_open(host: str, port: int) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(0.5)
return sock.connect_ex((host, port)) == 0
def wait_for_port(host: str, port: int, timeout: float = 30) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if _port_open(host, port):
return
time.sleep(0.5)
raise RuntimeError(f"Timed out waiting for {host}:{port}.")
def _spawn(name: str, command: list[str], cwd: Path, env: dict[str, str]) -> int:
RUNTIME_DIR.mkdir(exist_ok=True)
log_path = RUNTIME_DIR / f"{name}.log"
log_file = log_path.open("a", encoding="utf-8")
kwargs: dict[str, object] = {
"cwd": cwd,
"env": env,
"stdout": log_file,
"stderr": subprocess.STDOUT,
"stdin": subprocess.DEVNULL,
}
if os.name == "nt":
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
else:
kwargs["start_new_session"] = True
process = subprocess.Popen(command, **kwargs)
log_file.close()
return process.pid
def _read_pids() -> dict[str, int]:
if not PID_FILE.exists():
return {}
try:
return {key: int(value) for key, value in json.loads(PID_FILE.read_text()).items()}
except (OSError, ValueError, TypeError):
return {}
def _write_pids(pids: dict[str, int]) -> None:
RUNTIME_DIR.mkdir(exist_ok=True)
PID_FILE.write_text(json.dumps(pids, indent=2), encoding="utf-8")
def _pid_alive(pid: int) -> bool:
if pid <= 0:
return False
try:
os.kill(pid, 0)
except OSError:
return False
return True
def _service_alive(pid: int, host: str, port: int) -> bool:
return _pid_alive(pid) or _port_open(host, port)
def start() -> None:
ensure_mysql()
wait_for_port("127.0.0.1", 3306)
backend_python = _ensure_backend_runtime()
pids = _read_pids()
backend_env = build_backend_env()
if not _service_alive(pids.get("backend", -1), "127.0.0.1", 8000):
pids["backend"] = _spawn(
"backend",
[
backend_python,
"-m",
"uvicorn",
"state_center.app.main:app",
"--host",
"0.0.0.0",
"--port",
"8000",
],
ROOT,
backend_env,
)
if not _service_alive(pids.get("frontend", -1), "127.0.0.1", 5173):
pids["frontend"] = _spawn(
"frontend",
["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"],
ROOT / "state_center_web",
os.environ.copy(),
)
_write_pids(pids)
wait_for_port("127.0.0.1", 8000)
wait_for_port("127.0.0.1", 5173)
print("MySQL: 127.0.0.1:3306")
print("Backend: http://127.0.0.1:8000")
print("Frontend: http://127.0.0.1:5173")
def status() -> int:
db = _port_open("127.0.0.1", 3306)
backend = False
backend_detail = ""
try:
with urlopen("http://127.0.0.1:8000/health", timeout=2) as response:
payload = json.loads(response.read().decode("utf-8"))
backend = response.status == 200 and payload.get("database") == "ok"
backend_detail = json.dumps(payload, ensure_ascii=False)
except (OSError, URLError, json.JSONDecodeError) as exc:
backend_detail = str(exc)
frontend = _port_open("127.0.0.1", 5173)
print(f"MySQL 3306: {'UP' if db else 'DOWN'}")
print(f"Backend 8000: {'UP' if backend else 'DOWN'} {backend_detail}".rstrip())
print(f"Frontend 5173: {'UP' if frontend else 'DOWN'}")
return 0 if db and backend and frontend else 1
def _stop_pid(pid: int) -> None:
try:
if os.name == "nt":
os.kill(pid, signal.SIGTERM)
else:
os.killpg(os.getpgid(pid), signal.SIGTERM)
except OSError:
return
def stop() -> None:
for pid in _read_pids().values():
_stop_pid(pid)
if _docker_container_state() == "running":
_run(["docker", "stop", MYSQL_CONTAINER], check=False)
if PID_FILE.exists():
PID_FILE.unlink()
print("Local integration stack stopped.")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("command", choices=("start", "status", "stop"))
args = parser.parse_args()
try:
if args.command == "start":
start()
return 0
if args.command == "status":
return status()
stop()
return 0
except (OSError, RuntimeError, subprocess.CalledProcessError) as exc:
print(f"dev launcher error: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

124
state_center/README.md Normal file
View File

@ -0,0 +1,124 @@
# 状态中心(采购主链RPA一期 · 第1步骨架)
Python + FastAPI + MySQL。库里只存**流程状态 + 对码累积别名 + 证据**;对码货品主数据在 **Oracle**,直连查(不复制)。
## 目录
```
state_center/
├── sql/schema.sql 建库脚本(job 那套 + mapping_config + 证据)
├── app/
│ ├── config.py 配置(MySQL/Oracle 连接、表名列名,从环境变量读)
│ ├── db.py MySQL 访问(pymysql)
│ ├── match_engine.py 对码引擎(两级命中 + 规格标准化 + Oracle 取数)
│ └── main.py FastAPI:建单 + 回调(含幂等/乱序拒绝)+ 合同审批等待 + 整单查询
├── tests/test_match_engine.py 对码单测(不依赖 DB/Oracle)
└── requirements.txt
```
## 起步
以下命令在 macOS/Linux 的终端执行;Windows PowerShell 只需要把激活命令换成下一段对应写法,接口和浏览器端完全相同。
```bash
python -m pip install -r state_center/requirements.txt
# 1) 建库(先起个 MySQL 8)
mysql -uroot -p < state_center/sql/schema.sql
# 2) 配连接(默认 root/root@127.0.0.1)
export MYSQL_DSN="mysql+pymysql://root:root@127.0.0.1:3306/rpa_center?charset=utf8mb4"
# 3) 起服务
uvicorn state_center.app.main:app --reload --port 8000
# 打开 http://127.0.0.1:8000/docs 有全部接口可点
```
Windows PowerShell:
```powershell
py -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install -r state_center\requirements.txt
$env:DEV_MODE = "true"
$env:MYSQL_DSN = "mysql+pymysql://root:root@127.0.0.1:3306/rpa_center?charset=utf8mb4"
python -m uvicorn state_center.app.main:app --reload --port 8000
```
浏览器端另开一个终端:
```bash
cd state_center_web
npm install
npm run dev
```
然后打开 `http://localhost:5173/`。Windows 和 macOS 均使用标准浏览器访问,不需要 Electron 或系统专用脚本。`DEV_MODE=true` 只用于本地样例货品和节点回调模拟,生产环境不要开启。
本地样例联调顺序:建单 → 写入订单明细(`POST /api/jobs/{job_id}/items`) → 开发接口装载样例货品(`POST /api/dev/match/load-fixture-goods`) → 运行对码。真实环境把样例接口替换为 Oracle 货品装载即可。
## 验证第1步(不接真实系统,假回调走全链)
```bash
# 建单
curl -X POST http://127.0.0.1:8000/api/jobs -H 'Content-Type: application/json' \
-d '{"source_type":"YYC","order_date":"2026-08-04","supplier_name":"重庆亿昊药业"}'
# 拿到 job_id 后,模拟艺赛旗回调(带 token):
curl -X POST http://127.0.0.1:8000/api/jobs/<JOB_ID>/nodes/ORDER_FETCH/start \
-H 'X-Agent-Token: dev-agent-token' -H 'Content-Type: application/json' -d '{"attemptNo":1}'
curl -X POST http://127.0.0.1:8000/api/jobs/<JOB_ID>/nodes/ORDER_FETCH/complete \
-H 'X-Agent-Token: dev-agent-token' -H 'Content-Type: application/json' \
-d '{"attemptNo":1,"summary":{"orderCount":12}}'
# 反复 start/complete 走完各节点,最后 GET 看整单轨迹:
curl http://127.0.0.1:8000/api/jobs/<JOB_ID>
```
## 节点编排(13 = 11 主 + 2 条件)
主链 11 步(每单都走):
`ORDER_FETCH → PRODUCT_MATCH → CONTRACT_CREATE → CONTRACT_APPROVE_WAIT → REBATE_HEADER → REBATE_IMPORT → REBATE_SUBMIT → M5_CREATE_ORDER → M5_MANUAL_CONFIRM → M5_RECEIPT_NOTICE → ARCHIVE_JOB`
2 个条件节点(不满足条件自动跳过,不算漏节点):
| 条件节点 | 何时执行 | 何时跳过 |
| --- | --- | --- |
| `OCR_EXTRACT` | 线下手工单(source_type=OFFLINE) | 线上单(医药城/小药药) |
| `SOURCE_ADD` | 对码判定缺货源(needs_source_add=1) | 货源齐全 |
转移不是"数组下一个",是 `main.py` 里的显式转移表 `TRANSITIONS` + 条件跳过。
每个节点标了执行方 `NODE_EXECUTOR`:STATE_CENTER(对码,内部算)/ RPA(艺赛旗点页面)/ HUMAN(一期人工)。
所以文档里"11 个节点"和代码里"13 个"是同一套:**11 主 + 2 条件 = 13**。
## 关键设计点(会审确认)
- **回调幂等**:同节点同 attempt 重复 complete 不会二次推进(靠 job_node 状态 + `callback_idempotency_key`)。
- **乱序拒绝**:回调节点必须是 job 当前节点,否则 409(避免网络重发把状态推乱)。
- **合同审批等待**(CONTRACT_APPROVE_WAIT)**不连续轮询**:进 `WAITING_EXTERNAL` 态,每
`CONTRACT_WAIT_INTERVAL_MIN`(默认 30 分)由调度器扫 `GET /api/scheduler/due-contract-checks`
触发 RPA 查一次,结果回 `POST .../CONTRACT_APPROVE_WAIT/check-result`;查满
`CONTRACT_WAIT_MAX_CHECKS`(默认 8 次)还没过就升级 OPS。
- **对码状态只有 4 种**:EXACT / ALIAS / MULTI / NONE(无 FUZZY;ALIAS=累积别名命中,非当次人工)。
- **PRODUCT_MATCH 是状态中心自己算的节点**(executor=STATE_CENTER),不走 RPA 三回调,靠
`POST /api/jobs/{id}/match/run` 推进:跑 `order_item` → 落 `product_match` → MULTI/NONE 建
待办转 WAITING_USER,全命中自动往下走;`POST /api/match/{id}/confirm` 人工确认后沉淀
`mapping_config` 别名并推进。**前置**:`order_item` 已落库 + `POST /api/match/reload-goods`
从 Oracle 装载过货品主数据(这两样是 Trae 任务2/3 的接真数据部分)。
- **SOURCE_ADD 触发源明确**:对码拿到 `goods_id` 后,状态中心按历史 `supplier_name + goods_id`
判断是否已有货源;没历史则写 `product_match.source_add_needed=1`、同步 `job.needs_source_add=1`
并创建一条聚合的 `SOURCE_ADD` 待办。
- **对码重跑幂等**:`product_match` 以 `(job_id, order_item_id)` 为当前态唯一键,
`match/run` 重跑时 upsert 结果并重建 OPEN 待办,不重复造结果/待办。
- **alias 当前态唯一**:`match/confirm` 会先失效同一 `config_key` 的旧 active 记录,再插入新 active;
装载 alias 时按 `updated_at/id` 固定顺序读取,历史脏数据也有确定结果。
## 对码接 Oracle(拿到连接串后)
1. `pip install oracledb`
2. 在 `config.py` / 环境变量填 `ORACLE_DSN/USER/PASSWORD`,把 `ORACLE_GOODS_TABLE` 和各列名改成真实的。
3. 开工时 `load_goods_from_oracle(settings)` 拉全量 → `MatchEngine.load_goods(...)`,之后内存匹配。
## 对码单测
```bash
python3 state_center/tests/test_match_engine.py # 6/6 passed
```

View File

@ -0,0 +1,51 @@
"""配置:从环境变量读,别把密码写死在代码里。"""
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()

21
state_center/app/db.py Normal file
View File

@ -0,0 +1,21 @@
"""极简 MySQL 访问层(pymysql)。够第1步用,后面要换 SQLAlchemy 也行。"""
import pymysql
from pymysql.cursors import DictCursor
from .config import settings
def _parse_dsn(dsn: str) -> dict:
# mysql+pymysql://user:pass@host:port/db?charset=utf8mb4
body = dsn.split("://", 1)[1]
creds, rest = body.split("@", 1)
user, password = creds.split(":", 1)
hostport, dbq = rest.split("/", 1)
host, port = (hostport.split(":", 1) + ["3306"])[:2]
db = dbq.split("?", 1)[0]
return dict(host=host, port=int(port), user=user, password=password, database=db)
def get_conn():
cfg = _parse_dsn(settings.MYSQL_DSN)
return pymysql.connect(charset="utf8mb4", cursorclass=DictCursor, autocommit=True, **cfg)

View File

@ -0,0 +1,174 @@
"""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))

1511
state_center/app/main.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,215 @@
"""
对码引擎(核心难点)。
数据源:
- 货品主数据在 Oracle(只读)。开工时可全量 load 进内存,后续内存匹配。
- mapping_config(PRODUCT):平台奇葩写法→货品ID 的人工确认累积,存在自己 MySQL。
查找顺序(命中率越用越高):
1. 先查 mapping_config 累积别名 → 命中直接出货品ID(ALIAS,累积别名命中,非人工介入)
2. 再查货品主数据(内存/Oracle)四要素精确键 → EXACT
3. 规格/厂家容错后同商品名多条 → MULTI(出候选)
4. 都没有 → NONE(转人工)
对码结果状态只有 4 种:EXACT / ALIAS / MULTI / NONE。
(无 FUZZY:本引擎不产出模糊命中档,拿不准就走 MULTI 出候选让人选。)
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Optional
# ---------- 规范化:对付"15×2 vs 30片""干一瓶 vs ×1瓶""进口英文厂家" ----------
_FULL2HALF = {ord(" "): " ", ord("("): "(", ord(")"): ")", ord("*"): "*", ord("×"): "*"}
def _norm_text(s: Optional[str]) -> str:
"""通用规范化:去空格、全半角、大小写、常见标点。"""
if not s:
return ""
s = s.translate(_FULL2HALF)
s = s.lower().strip()
s = re.sub(r"[\s,,。.、;;::'\"“”‘’]+", "", s)
return s
def _norm_manufacturer(s: Optional[str]) -> str:
"""厂家名容错:进口药英文名统一大小写、去标点(B.V. / S.r.l. 之类)。"""
if not s:
return ""
s = s.lower().strip()
s = re.sub(r"[.\s,]+", "", s) # 去点、空格、逗号
return s
def _norm_spec(s: Optional[str]) -> str:
"""
规格标准化。把 '15×2' '15*2' 展开成乘积;'30片' '×1瓶' 抽出数字。
返回一个可比较的规范串,如 '30' 或 '30片'。
这是痛点,规则先覆盖会议里点名的几种,后续可加。
"""
if not s:
return ""
raw = s.translate(_FULL2HALF).lower().strip()
raw = re.sub(r"\s+", "", raw)
# 形如 15*2 / 15x2 → 乘积 30
m = re.search(r"(\d+(?:\.\d+)?)\*(\d+(?:\.\d+)?)", raw)
if m:
product = float(m.group(1)) * float(m.group(2))
product = int(product) if product == int(product) else product
# 保留单位后缀(如 片/瓶),便于跟 '30片' 对齐
unit = re.sub(r"[\d*.]+", "", raw)
return f"{product}{unit}"
return raw
def build_match_key(common_name: str, approval_no: str, spec: str, manufacturer: str) -> str:
"""四要素拼接键:通用名 + 批准文号 + 规格 + 厂家(全部规范化)。"""
return "|".join([
_norm_text(common_name),
_norm_text(approval_no),
_norm_spec(spec),
_norm_manufacturer(manufacturer),
])
# ---------- 货品主数据(Oracle 镜像到内存) ----------
@dataclass
class GoodsRecord:
common_name: str
approval_no: str
spec: str
base_unit: str
manufacturer: str
goods_id: str
goods_code: str
@dataclass
class MatchResult:
order_item_id: int
match_key: str
match_status: str # EXACT / ALIAS / MULTI / NONE
goods_id: Optional[str] = None
goods_code: Optional[str] = None
base_unit: Optional[str] = None
candidates: list = field(default_factory=list)
class MatchEngine:
"""
内存索引 + 两级查找。
goods_index: 四要素键 -> [GoodsRecord] (可能一键多货品,即一对多)
name_index: 规范通用名 -> [GoodsRecord] (用于同商品名多规格出候选)
alias_map: mapping_config 累积别名 config_key -> (goods_id, goods_code, base_unit)
"""
def __init__(self):
self.goods_index: dict[str, list[GoodsRecord]] = {}
self.name_index: dict[str, list[GoodsRecord]] = {}
self.alias_map: dict[str, tuple[str, str, str]] = {}
# -- 装载 --
def load_goods(self, records: list[GoodsRecord]) -> None:
self.goods_index.clear()
self.name_index.clear()
for r in records:
key = build_match_key(r.common_name, r.approval_no, r.spec, r.manufacturer)
self.goods_index.setdefault(key, []).append(r)
self.name_index.setdefault(_norm_text(r.common_name), []).append(r)
def load_aliases(self, aliases: dict[str, tuple[str, str, str]]) -> None:
"""aliases: config_key -> (goods_id, goods_code, base_unit)"""
self.alias_map = dict(aliases)
# -- 单行对码 --
def match_one(self, order_item_id: int, common_name: str, approval_no: str,
spec: str, manufacturer: str) -> MatchResult:
key = build_match_key(common_name, approval_no, spec, manufacturer)
# 1) 累积别名优先(ALIAS = 历史人工确认沉淀的别名命中,不是当次人工介入)
if key in self.alias_map:
gid, gcode, unit = self.alias_map[key]
return MatchResult(order_item_id, key, "ALIAS", gid, gcode, unit)
# 2) 四要素精确
hits = self.goods_index.get(key, [])
if len(hits) == 1:
r = hits[0]
return MatchResult(order_item_id, key, "EXACT", r.goods_id, r.goods_code, r.base_unit)
if len(hits) > 1:
return MatchResult(order_item_id, key, "MULTI",
candidates=[self._cand(r) for r in hits])
# 3) 同商品名多规格 → 出候选给人工选
name_hits = self.name_index.get(_norm_text(common_name), [])
if name_hits:
return MatchResult(order_item_id, key, "MULTI",
candidates=[self._cand(r) for r in name_hits])
# 4) 无命中
return MatchResult(order_item_id, key, "NONE")
@staticmethod
def _cand(r: GoodsRecord) -> dict:
return {
"goods_id": r.goods_id,
"goods_code": r.goods_code,
"common_name": r.common_name,
"spec": r.spec,
"base_unit": r.base_unit,
"manufacturer": r.manufacturer,
"approval_no": r.approval_no,
}
# ---------- Oracle 取数(拿到真实连接串后启用) ----------
def load_goods_from_oracle(settings) -> list[GoodsRecord]:
"""
从 Oracle 只读拉全量货品主数据。
需要: pip install oracledb
真实表名/列名在 config.py 里改。
"""
import oracledb # 延迟导入,没配 Oracle 时不影响其他功能
sql = f"""
SELECT {settings.ORACLE_COL_COMMON_NAME},
{settings.ORACLE_COL_APPROVAL_NO},
{settings.ORACLE_COL_SPEC},
{settings.ORACLE_COL_UNIT},
{settings.ORACLE_COL_MANUFACTURER},
{settings.ORACLE_COL_GOODS_ID},
{settings.ORACLE_COL_GOODS_CODE}
FROM {settings.ORACLE_GOODS_TABLE}
"""
conn = oracledb.connect(
user=settings.ORACLE_USER,
password=settings.ORACLE_PASSWORD,
dsn=settings.ORACLE_DSN,
)
try:
cur = conn.cursor()
cur.execute(sql)
rows = cur.fetchall()
finally:
conn.close()
out = []
for row in rows:
out.append(GoodsRecord(
common_name=str(row[0] or ""),
approval_no=str(row[1] or ""),
spec=str(row[2] or ""),
base_unit=str(row[3] or ""),
manufacturer=str(row[4] or ""),
goods_id=str(row[5] or ""),
goods_code=str(row[6] or ""),
))
return out

View File

@ -0,0 +1,163 @@
"""Web 版状态机纯逻辑。
只放和 UI/接口共享的计算,不碰数据库。
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
NODE_ORDER = [
"ORDER_FETCH", "OCR_EXTRACT", "PRODUCT_MATCH", "SOURCE_ADD",
"CONTRACT_CREATE", "CONTRACT_APPROVE_WAIT", "REBATE_HEADER",
"REBATE_IMPORT", "REBATE_SUBMIT", "M5_CREATE_ORDER",
"M5_MANUAL_CONFIRM", "M5_RECEIPT_NOTICE", "ARCHIVE_JOB",
]
TRANSITIONS = {
"ORDER_FETCH": "OCR_EXTRACT",
"OCR_EXTRACT": "PRODUCT_MATCH",
"PRODUCT_MATCH": "SOURCE_ADD",
"SOURCE_ADD": "CONTRACT_CREATE",
"CONTRACT_CREATE": "CONTRACT_APPROVE_WAIT",
"CONTRACT_APPROVE_WAIT": "REBATE_HEADER",
"REBATE_HEADER": "REBATE_IMPORT",
"REBATE_IMPORT": "REBATE_SUBMIT",
"REBATE_SUBMIT": "M5_CREATE_ORDER",
"M5_CREATE_ORDER": "M5_MANUAL_CONFIRM",
"M5_MANUAL_CONFIRM": "M5_RECEIPT_NOTICE",
"M5_RECEIPT_NOTICE": "ARCHIVE_JOB",
"ARCHIVE_JOB": None,
}
CONDITIONAL_NODES = {"OCR_EXTRACT", "SOURCE_ADD"}
HUMAN_NODE_ROLES = {
"SOURCE_ADD": "PURCHASER",
"M5_MANUAL_CONFIRM": "PURCHASER",
"M5_RECEIPT_NOTICE": "PURCHASER",
}
NODE_EXECUTOR = {
"ORDER_FETCH": "RPA",
"OCR_EXTRACT": "RPA",
"PRODUCT_MATCH": "STATE_CENTER",
"SOURCE_ADD": "HUMAN",
"CONTRACT_CREATE": "RPA",
"CONTRACT_APPROVE_WAIT": "RPA",
"REBATE_HEADER": "RPA",
"REBATE_IMPORT": "RPA",
"REBATE_SUBMIT": "RPA",
"M5_CREATE_ORDER": "RPA",
"M5_MANUAL_CONFIRM": "HUMAN",
"M5_RECEIPT_NOTICE": "HUMAN",
"ARCHIVE_JOB": "STATE_CENTER",
}
NODE_LABELS_ZH = {
"ORDER_FETCH": "订单获取",
"OCR_EXTRACT": "OCR识别",
"PRODUCT_MATCH": "对码",
"SOURCE_ADD": "新增货源",
"CONTRACT_CREATE": "合同建单",
"CONTRACT_APPROVE_WAIT": "合同审批等待",
"REBATE_HEADER": "返利总单",
"REBATE_IMPORT": "返利细单导入",
"REBATE_SUBMIT": "返利送审",
"M5_CREATE_ORDER": "M5建单",
"M5_MANUAL_CONFIRM": "M5人工确认",
"M5_RECEIPT_NOTICE": "收货通知单",
"ARCHIVE_JOB": "归档",
}
EXECUTOR_LABELS_ZH = {
"STATE_CENTER": "状态中心",
"RPA": "艺赛旗执行",
"HUMAN": "人工处理",
}
STATUS_LABELS_ZH = {
"PENDING": "待处理",
"RUNNING": "执行中",
"SUCCESS": "已完成",
"FAILED": "已失败",
"WAITING_USER": "等待人工",
"WAITING_EXTERNAL": "等待外部",
"SKIPPED": "已跳过",
}
def _should_skip(node_code: str, job: Mapping[str, object]) -> bool:
if node_code == "OCR_EXTRACT":
return job.get("source_type") != "OFFLINE"
if node_code == "SOURCE_ADD":
return not job.get("needs_source_add")
return False
def _node_row_map(job_nodes: Sequence[Mapping[str, object]]) -> dict[str, Mapping[str, object]]:
out: dict[str, Mapping[str, object]] = {}
for row in job_nodes:
node_code = str(row.get("node_code") or "")
if node_code and node_code not in out:
out[node_code] = row
return out
def _display_status_for_node(job: Mapping[str, object], node_code: str,
job_nodes_by_code: Mapping[str, Mapping[str, object]]) -> str:
row = job_nodes_by_code.get(node_code)
if row:
row_status = str(row.get("status") or "PENDING")
if row_status in {"SUCCESS", "FAILED", "RUNNING", "WAITING_USER", "WAITING_EXTERNAL"}:
return row_status
return "PENDING"
if node_code == job.get("current_node_code"):
job_status = str(job.get("status") or "PENDING")
if job_status in {"RUNNING", "WAITING_USER", "WAITING_EXTERNAL", "FAILED_RETRYABLE", "FAILED_MANUAL"}:
return "WAITING_USER" if job_status == "WAITING_USER" else (
"WAITING_EXTERNAL" if job_status == "WAITING_EXTERNAL" else (
"RUNNING" if job_status == "RUNNING" else "FAILED"
)
)
if job_status == "SUCCESS":
return "SUCCESS"
return "PENDING"
if node_code in CONDITIONAL_NODES and _should_skip(node_code, job):
return "SKIPPED"
return "PENDING"
def build_timeline(job: Mapping[str, object], job_nodes: Sequence[Mapping[str, object]]) -> list[dict]:
"""Return all nodes with backend-computed display status.
The frontend should render this directly and never infer node skipping itself.
"""
by_code = _node_row_map(job_nodes)
timeline = []
for node_code in NODE_ORDER:
row = by_code.get(node_code)
timeline.append({
"node_code": node_code,
"display_status": _display_status_for_node(job, node_code, by_code),
"attempt_no": row.get("attempt_no") if row else None,
"executor_type": row.get("executor_type") if row else None,
"status": row.get("status") if row else None,
"started_at": row.get("started_at") if row else None,
"ended_at": row.get("ended_at") if row else None,
})
return timeline
def build_state_machine_meta() -> dict:
return {
"node_order": NODE_ORDER,
"transitions": TRANSITIONS,
"conditional_nodes": sorted(CONDITIONAL_NODES),
"human_node_roles": HUMAN_NODE_ROLES,
"node_executor": NODE_EXECUTOR,
"node_labels": NODE_LABELS_ZH,
"executor_labels": EXECUTOR_LABELS_ZH,
"status_labels": STATUS_LABELS_ZH,
}

View File

@ -0,0 +1,20 @@
# 对码入参底座(一条,福建货主)
订单明细走这里,不走证据包。本批只有一条:重庆亿昊 / 哈药牌钙铁锌口服液。
## 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

@ -0,0 +1,41 @@
{
"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

@ -0,0 +1,9 @@
{
"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

@ -0,0 +1,13 @@
{
"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

@ -0,0 +1,26 @@
{
"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

@ -0,0 +1,7 @@
fastapi==0.115.0
uvicorn==0.30.6
pydantic==2.9.2
pymysql==1.1.1
cryptography==50.0.0
# 对码接 Oracle 时再装(拿到连接串后):
# oracledb==2.4.1

205
state_center/sql/schema.sql Normal file
View File

@ -0,0 +1,205 @@
-- 采购主链 RPA 一期 · 状态中心建库脚本
-- 库里只存"流程状态 + 对码累积别名 + 证据",不存对码表本身(对码表在 Oracle,直连查)。
-- 目标库:MySQL 8
CREATE DATABASE IF NOT EXISTS rpa_center DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
USE rpa_center;
-- ============================================================
-- job:一次流程实例 = 同供应商 + 同订单日期 + 同来源 = 一条合同
-- ============================================================
CREATE TABLE IF NOT EXISTS job (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
job_id VARCHAR(64) NOT NULL UNIQUE COMMENT '对外流程单号',
business_key VARCHAR(160) NOT NULL COMMENT '来源+供应商+订单日期,幂等用',
parent_job_id VARCHAR(64) NULL COMMENT '重跑/加单来源',
source_type VARCHAR(16) NOT NULL COMMENT 'YYC医药城/XYY小药药/OFFLINE线下单',
order_date DATE NOT NULL COMMENT '订单日期=合同签约日期',
supplier_name VARCHAR(128) NOT NULL COMMENT '供应商/商家',
order_no_list_json JSON NULL COMMENT '该批订单号数组',
contract_template VARCHAR(16) NULL COMMENT 'FORMAT格式/NON_FORMAT非格式承兑',
need_pdf_upload TINYINT NOT NULL DEFAULT 0 COMMENT '医药城=1,小药药=0',
planner_name VARCHAR(64) NULL COMMENT '计划员',
current_node_code VARCHAR(64) NOT NULL COMMENT '当前节点',
status VARCHAR(24) NOT NULL COMMENT 'PENDING/RUNNING/WAITING_USER/WAITING_EXTERNAL/SUCCESS/FAILED_RETRYABLE/FAILED_MANUAL/CANCELLED',
mapping_version VARCHAR(64) NULL COMMENT '绑定的对码累积版本',
needs_source_add TINYINT NOT NULL DEFAULT 0 COMMENT '对码后判定是否缺货源,决定是否走 SOURCE_ADD 条件节点',
executor_platform VARCHAR(16) NULL COMMENT '默认执行平台,如 ISRPA',
contract_system_no VARCHAR(64) NULL COMMENT '合同系统编号(钥匙1)',
rebate_order_no VARCHAR(64) NULL COMMENT '返利中心总单号',
m5_order_no VARCHAR(64) NULL COMMENT 'M5采购订单号',
receipt_notice_no VARCHAR(64) NULL COMMENT '收货通知单号',
current_role VARCHAR(16) NULL COMMENT '当前责任角色',
current_assignee VARCHAR(64) NULL COMMENT '当前责任人',
auto_save_enabled TINYINT NOT NULL DEFAULT 0 COMMENT 'M5自动保存开关,一期=0',
next_check_at DATETIME NULL COMMENT '等待态(如合同审批)下次检查时间,调度器扫这个',
wait_check_count INT NOT NULL DEFAULT 0 COMMENT '当前等待态已检查次数,超上限升级OPS',
created_by VARCHAR(64) NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
finished_at DATETIME NULL,
KEY idx_business_status (business_key, status),
KEY idx_supplier_date (supplier_name, order_date),
KEY idx_status (status),
KEY idx_next_check (status, next_check_at),
KEY idx_created_at (created_at)
) COMMENT='流程实例主表';
-- ============================================================
-- order_item:订单明细行(对码前的原始数据)
-- ============================================================
CREATE TABLE IF NOT EXISTS order_item (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
job_id VARCHAR(64) NOT NULL,
order_no VARCHAR(64) NOT NULL COMMENT '订单号(贯穿追溯)',
parent_order_no VARCHAR(64) NULL COMMENT '父订单号(加单/拆单)',
raw_product_name VARCHAR(255) NOT NULL COMMENT '平台品名',
platform_product_code VARCHAR(64) NULL COMMENT '平台产品编码',
spec VARCHAR(128) NULL COMMENT '规格(对码痛点:15×2 vs 30片)',
manufacturer VARCHAR(255) NULL COMMENT '厂家(含进口英文)',
approval_no VARCHAR(64) NULL COMMENT '国药准字/批准文号',
qty DECIMAL(14,2) NOT NULL COMMENT '数量',
unit_price DECIMAL(14,4) NULL COMMENT '单价',
source_row_no INT NULL COMMENT '原始Excel行号',
created_at DATETIME NOT NULL,
KEY idx_job (job_id),
KEY idx_order_no (order_no)
) COMMENT='订单明细行';
-- ============================================================
-- product_match:对码结果(核心难点落库)
-- ============================================================
CREATE TABLE IF NOT EXISTS product_match (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
job_id VARCHAR(64) NOT NULL,
order_item_id BIGINT NOT NULL COMMENT '对应订单行',
match_key VARCHAR(512) NOT NULL COMMENT '四要素拼接键:通用名+批准文号+规格+厂家',
match_status VARCHAR(16) NOT NULL COMMENT 'EXACT精确/ALIAS别名命中/MULTI一对多/NONE未命中(无FUZZY)',
goods_id VARCHAR(64) NULL COMMENT '命中的M5货品ID(来自Oracle)',
goods_code VARCHAR(64) NULL COMMENT '货品编码',
base_unit VARCHAR(32) NULL COMMENT '基本单位',
candidates_json JSON NULL COMMENT '一对多候选列表(供H5选择)',
source_add_needed TINYINT NOT NULL DEFAULT 0 COMMENT '是否需M5新增货源',
confirmed_by VARCHAR(64) NULL COMMENT '人工确认人',
confirmed_at DATETIME NULL,
mapping_version VARCHAR(64) NULL COMMENT '使用的对码累积版本',
created_at DATETIME NOT NULL,
UNIQUE KEY uniq_job_order_item (job_id, order_item_id),
KEY idx_job (job_id),
KEY idx_status (match_status)
) COMMENT='对码结果';
-- ============================================================
-- job_node:节点执行(每节点每次尝试一行,不覆盖历史)
-- ============================================================
CREATE TABLE IF NOT EXISTS job_node (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
job_id VARCHAR(64) NOT NULL,
node_code VARCHAR(64) NOT NULL COMMENT 'ORDER_FETCH/PRODUCT_MATCH/...',
executor_type VARCHAR(16) NOT NULL DEFAULT 'RPA' COMMENT '执行方:STATE_CENTER编排/RPA执行/HUMAN人工挂起',
attempt_no INT NOT NULL DEFAULT 1 COMMENT '第几次尝试',
status VARCHAR(24) NOT NULL COMMENT '节点状态',
executor_platform VARCHAR(16) NULL COMMENT '实际执行平台',
executor_run_id VARCHAR(64) NULL COMMENT '平台侧任务号',
started_at DATETIME NULL,
ended_at DATETIME NULL,
duration_ms BIGINT NULL,
error_code VARCHAR(64) NULL,
error_summary VARCHAR(500) NULL,
role_at_failure VARCHAR(16) NULL,
output_json JSON NULL COMMENT '节点输出(业务编号等)',
callback_idempotency_key VARCHAR(128) NULL COMMENT '回调幂等键',
created_at DATETIME NOT NULL,
UNIQUE KEY uniq_job_node_attempt (job_id, node_code, attempt_no),
KEY idx_status (status)
) COMMENT='节点执行记录';
-- ============================================================
-- user_task:人工待办
-- ============================================================
CREATE TABLE IF NOT EXISTS user_task (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
task_id VARCHAR(64) NOT NULL UNIQUE,
job_id VARCHAR(64) NOT NULL,
node_code VARCHAR(64) NOT NULL,
order_item_id BIGINT NULL COMMENT '对应订单行(对码类待办用)',
product_match_id BIGINT NULL COMMENT '对应对码结果行(对码类待办精确关联,confirm 时按它关闭)',
role_code VARCHAR(16) NOT NULL COMMENT 'PLANNER/PURCHASER/QC/OPS',
assignee_user_id VARCHAR(64) NULL,
title VARCHAR(255) NOT NULL,
reason_type VARCHAR(32) NOT NULL COMMENT 'MATCH_MULTI/MATCH_NONE/SOURCE_ADD/OCR_LOW_CONF/REBATE_DUP/QC_MISSING/CONTRACT_DISABLED',
reason_user_friendly VARCHAR(500) NULL COMMENT '人话说明',
why_me_text VARCHAR(255) NULL COMMENT '为什么派给你',
editable_fields_json JSON NULL COMMENT '白名单字段',
action_schema_json JSON NULL COMMENT '允许动作',
comparison_snapshot_json JSON NULL COMMENT '原值/建议/当前对比',
candidates_json JSON NULL COMMENT '一对多对码候选',
status VARCHAR(16) NOT NULL DEFAULT 'OPEN' COMMENT 'OPEN/SUBMITTED/CANCELLED/EXPIRED',
submit_token VARCHAR(64) NULL COMMENT '防重复提交',
submitted_action VARCHAR(24) NULL,
submitted_payload_json JSON NULL,
submitted_result_json JSON NULL COMMENT '提交结果,同 submit_token 重放时返回',
submitted_by VARCHAR(64) NULL,
submitted_at DATETIME NULL,
due_at DATETIME NULL COMMENT '超时升级',
created_at DATETIME NOT NULL,
KEY idx_job (job_id),
KEY idx_role_status (role_code, status),
KEY idx_product_match (product_match_id)
) COMMENT='人工待办';
-- ============================================================
-- mapping_config:对码累积别名 + 供应商对照等映射(版本化)
-- 注意:这不是对码表的拷贝!对码表在 Oracle。
-- 这里存的是"平台奇葩写法 → 货品ID"的人工确认记录,以及供应商对照。
-- ============================================================
CREATE TABLE IF NOT EXISTS mapping_config (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
config_type VARCHAR(24) NOT NULL COMMENT 'PRODUCT对码别名/SUPPLIER供应商对照/SETTLEMENT/PAYMENT_TERM/PLANNER_ROUTE',
config_key VARCHAR(512) NOT NULL COMMENT '对码时=四要素拼接键或平台写法',
source_value VARCHAR(512) NULL COMMENT '来源值',
target_value VARCHAR(512) NULL COMMENT '目标值(对码=货品ID+编码+单位JSON)',
extra_json JSON NULL,
version_no VARCHAR(64) NULL COMMENT '版本号',
is_active TINYINT NOT NULL DEFAULT 1,
effective_from DATETIME NULL,
effective_to DATETIME NULL,
updated_by VARCHAR(64) NULL,
updated_at DATETIME NOT NULL,
KEY idx_type_key (config_type, config_key),
KEY idx_type_key_active (config_type, config_key, is_active, updated_at, id),
KEY idx_active (is_active)
) COMMENT='映射/对码累积别名(非对码表拷贝)';
-- ============================================================
-- job_artifact:证据元数据(文件落对象存储,这里存元数据)
-- ============================================================
CREATE TABLE IF NOT EXISTS job_artifact (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
job_id VARCHAR(64) NOT NULL,
node_code VARCHAR(64) NULL,
attempt_no INT NULL,
artifact_type VARCHAR(32) NOT NULL COMMENT 'SOURCE_EXCEL/SOURCE_IMAGE/OCR_JSON/CONTRACT_PDF/MERGED_PDF/SCREENSHOT/TEMPLATE/LOG',
storage_path VARCHAR(500) NOT NULL,
file_hash VARCHAR(128) NULL,
visible_roles VARCHAR(128) NULL COMMENT '可见角色',
is_masked TINYINT NOT NULL DEFAULT 0 COMMENT '脱敏标记',
created_at DATETIME NOT NULL,
KEY idx_job (job_id)
) COMMENT='证据元数据';
-- ============================================================
-- job_event:事件流(审计留痕)
-- ============================================================
CREATE TABLE IF NOT EXISTS job_event (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
job_id VARCHAR(64) NOT NULL,
event_type VARCHAR(32) NOT NULL COMMENT 'JOB_CREATED/NODE_STARTED/NODE_SUCCESS/NODE_FAILED/TASK_CREATED/TASK_SUBMITTED/MANUAL_HANDOFF/JOB_FINISHED',
node_code VARCHAR(64) NULL,
operator_type VARCHAR(16) NOT NULL COMMENT 'SYSTEM/ROBOT/USER',
operator VARCHAR(64) NULL,
detail_json JSON NULL,
created_at DATETIME NOT NULL,
KEY idx_job (job_id),
KEY idx_type (event_type)
) COMMENT='事件流';

View File

@ -0,0 +1,111 @@
from scripts import dev
def test_backend_runtime_probe_checks_mysql8_auth_dependency(monkeypatch):
commands = []
monkeypatch.setattr(dev, "_backend_python", lambda: "/tmp/backend-python")
def fake_run(command, **kwargs):
commands.append(command)
class Result:
returncode = 0
stderr = ""
return Result()
monkeypatch.setattr(dev.subprocess, "run", fake_run)
assert dev._ensure_backend_runtime() == "/tmp/backend-python"
assert commands[0] == [
"/tmp/backend-python",
"-c",
"import fastapi, pymysql, uvicorn, cryptography",
]
def test_backend_environment_has_local_defaults(monkeypatch):
for name in ("DEV_MODE", "MYSQL_DSN", "AGENT_TOKEN"):
monkeypatch.delenv(name, raising=False)
env = dev.build_backend_env({})
assert env["DEV_MODE"] == "true"
assert env["MYSQL_DSN"] == "mysql+pymysql://root:root@127.0.0.1:3306/rpa_center?charset=utf8mb4"
assert env["AGENT_TOKEN"] == "dev-agent-token"
def test_start_uses_checked_backend_python(monkeypatch):
spawned = []
monkeypatch.setattr(dev, "ensure_mysql", lambda: None)
monkeypatch.setattr(dev, "wait_for_port", lambda host, port: None)
monkeypatch.setattr(dev, "_ensure_backend_runtime", lambda: "/tmp/backend-python")
monkeypatch.setattr(dev, "_read_pids", lambda: {})
monkeypatch.setattr(dev, "_pid_alive", lambda pid: False)
monkeypatch.setattr(dev, "_port_open", lambda host, port: False)
monkeypatch.setattr(dev, "_write_pids", lambda pids: None)
monkeypatch.setattr(dev, "build_backend_env", lambda: {})
def fake_spawn(name, command, cwd, env):
spawned.append((name, command))
return len(spawned)
monkeypatch.setattr(dev, "_spawn", fake_spawn)
dev.start()
assert spawned[0] == (
"backend",
[
"/tmp/backend-python",
"-m",
"uvicorn",
"state_center.app.main:app",
"--host",
"0.0.0.0",
"--port",
"8000",
],
)
def test_start_reuses_frontend_when_port_is_already_open(monkeypatch):
spawned = []
monkeypatch.setattr(dev, "ensure_mysql", lambda: None)
monkeypatch.setattr(dev, "wait_for_port", lambda host, port: None)
monkeypatch.setattr(dev, "_ensure_backend_runtime", lambda: "/tmp/backend-python")
monkeypatch.setattr(dev, "_read_pids", lambda: {})
monkeypatch.setattr(dev, "_write_pids", lambda pids: None)
monkeypatch.setattr(dev, "build_backend_env", lambda: {})
monkeypatch.setattr(dev, "_port_open", lambda host, port: port == 5173)
def fake_spawn(name, command, cwd, env):
spawned.append(name)
return len(spawned)
monkeypatch.setattr(dev, "_spawn", fake_spawn)
dev.start()
assert spawned == ["backend"]
def test_pid_alive_rejects_invalid_pid():
assert dev._pid_alive(-1) is False
assert dev._pid_alive(0) is False
def test_stop_pid_terminates_process_group_on_posix(monkeypatch):
calls = []
monkeypatch.setattr(dev.os, "name", "posix")
monkeypatch.setattr(dev.os, "getpgid", lambda pid: 4321)
monkeypatch.setattr(dev.os, "killpg", lambda pgid, sig: calls.append(("killpg", pgid, sig)))
monkeypatch.setattr(dev.os, "kill", lambda pid, sig: calls.append(("kill", pid, sig)))
dev._stop_pid(1234)
assert calls == [("killpg", 4321, dev.signal.SIGTERM)]

View File

@ -0,0 +1,101 @@
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

@ -0,0 +1,107 @@
"""对码引擎单测:不依赖 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

@ -0,0 +1,23 @@
from state_center.app.web_state_machine import build_timeline
def test_build_timeline_marks_skipped_and_waiting_user():
job = {
"job_id": "JOB-1",
"source_type": "YYC",
"current_node_code": "SOURCE_ADD",
"status": "WAITING_USER",
"needs_source_add": 1,
}
job_nodes = [
{"node_code": "ORDER_FETCH", "attempt_no": 1, "status": "SUCCESS"},
{"node_code": "PRODUCT_MATCH", "attempt_no": 1, "status": "SUCCESS"},
]
timeline = build_timeline(job, job_nodes)
by_code = {item["node_code"]: item for item in timeline}
assert by_code["ORDER_FETCH"]["display_status"] == "SUCCESS"
assert by_code["OCR_EXTRACT"]["display_status"] == "SKIPPED"
assert by_code["SOURCE_ADD"]["display_status"] == "WAITING_USER"
assert by_code["CONTRACT_CREATE"]["display_status"] == "PENDING"

View File

@ -0,0 +1,112 @@
import json
from rpa_yisaiqi.callback_client import StateCenterClient
from rpa_yisaiqi.contract_create_node import (
ContractCreateInput,
LoginFailed,
execute_contract_create,
)
class FakeHttp:
def __init__(self, responses=None):
self.requests = []
self.responses = list(responses or [{"ok": True}, {"ok": True, "next_node": "CONTRACT_APPROVE_WAIT"}])
def __call__(self, request, timeout):
body = json.loads(request.data.decode("utf-8")) if request.data else None
self.requests.append(
{
"url": request.full_url,
"headers": dict(request.header_items()),
"body": body,
"timeout": timeout,
}
)
return FakeResponse(self.responses.pop(0))
class FakeResponse:
def __init__(self, payload):
self.payload = payload
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self):
return json.dumps(self.payload).encode("utf-8")
def test_state_center_client_sends_agent_token_and_json_body():
http = FakeHttp([{"ok": True}])
client = StateCenterClient("http://state-center:8000/", "dev-agent-token", opener=http)
result = client.start_node("JOB-1", "CONTRACT_CREATE", attempt_no=2, operator="agent")
assert result == {"ok": True}
assert http.requests[0]["url"] == "http://state-center:8000/api/jobs/JOB-1/nodes/CONTRACT_CREATE/start"
assert http.requests[0]["headers"]["X-agent-token"] == "dev-agent-token"
assert http.requests[0]["body"] == {
"attemptNo": 2,
"executorPlatform": "iS-RPA",
"operator": "agent",
}
def test_state_center_client_escapes_non_ascii_path_parts():
http = FakeHttp([{"ok": True}])
client = StateCenterClient("http://state-center:8000", "dev-agent-token", opener=http)
client.start_node("YYC-合同节点联调-001", "CONTRACT_CREATE")
assert http.requests[0]["url"] == (
"http://state-center:8000/api/jobs/"
"YYC-%E5%90%88%E5%90%8C%E8%8A%82%E7%82%B9%E8%81%94%E8%B0%83-001"
"/nodes/CONTRACT_CREATE/start"
)
def test_contract_create_calls_start_then_complete_with_contract_number():
http = FakeHttp()
client = StateCenterClient("http://state-center:8000", "dev-agent-token", opener=http)
spec = ContractCreateInput(
job_id="JOB-1",
supplier_name="重庆亿昊药业",
order_date="2026-08-11",
source_type="YYC",
contract_system_no="HT-20260811-001",
)
result = execute_contract_create(spec, client)
assert result["next_node"] == "CONTRACT_APPROVE_WAIT"
assert [request["url"].split("/")[-1] for request in http.requests] == ["start", "complete"]
assert http.requests[1]["body"]["businessRefs"] == {"contractSystemNo": "HT-20260811-001"}
assert http.requests[1]["body"]["summary"]["supplierName"] == "重庆亿昊药业"
assert http.requests[1]["body"]["summary"]["contractDate"] == "2026-08-11"
def test_contract_create_reports_login_failure_to_state_center():
http = FakeHttp([{"ok": True}, {"ok": True, "job_status": "FAILED_RETRYABLE"}])
client = StateCenterClient("http://state-center:8000", "dev-agent-token", opener=http)
spec = ContractCreateInput(
job_id="JOB-1",
supplier_name="重庆亿昊药业",
order_date="2026-08-11",
source_type="YYC",
contract_system_no="HT-20260811-001",
)
def failing_runner(_spec):
raise LoginFailed("合同系统登录失败")
result = execute_contract_create(spec, client, contract_runner=failing_runner)
assert result["job_status"] == "FAILED_RETRYABLE"
assert http.requests[1]["url"].endswith("/api/jobs/JOB-1/nodes/CONTRACT_CREATE/fail")
assert http.requests[1]["body"]["errorCode"] == "SYS_LOGIN_FAIL"
assert http.requests[1]["body"]["retryable"] is True
assert http.requests[1]["body"]["routeRole"] == "OPS"

View File

@ -0,0 +1,180 @@
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

@ -0,0 +1,490 @@
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

@ -0,0 +1,110 @@
# 给 Trae 的开发交接说明
> 你(Trae)接手"采购主链RPA一期 · 状态中心"的后续开发。本文是你的入口,先读这份,再读下面点名的文件。写代码前把"硬规则"和"你不做什么"看完,别越界。
## 一、你要干什么(一句话)
做**状态中心**:一个 Python(FastAPI)+ MySQL 的后台 + 配套网页。它负责"每一单跑到哪步、出错派给谁、存状态和证据、画步骤树状图给业务看"。它**自己不去点任何外部系统页面**——点页面是艺赛旗 RPA 的活,通过 HTTP 回调跟你交互。
## 二、先读这几个文件(按顺序)
1. `资料/采购主链RPA一期需求文档_三方会审版.md` —— 业务规则(节点N01~N10、字段、异常)。
2. `资料/采购主链RPA一期设计文档.md` —— 技术设计(表结构、状态机、接口契约、对码引擎)。
3. `资料/采购主链RPA一期实施与验收规范.md` —— **每个节点的操作步骤 + 验收标准(代码+业务两栏)**,你做完对着这里勾。
4. `state_center/README.md` —— 已有代码怎么起、怎么验。
## 三、技术栈(已定,别改)
- 后台:Python 3 + FastAPI
- 库:MySQL 8(状态中心自己的库,建库脚本 `sql/schema.sql` 已给)
- 对码货品主数据:在 **Oracle**,只读直连查,**不要复制成本地表**
- OCR:本地 Python 服务(PaddleOCR),仅线下手工单用
- 前端:Vue 或 React(待办页 + 步骤树状图 + 整单查询)
- RPA 下层:艺赛旗 iS-RPA(不是你做,你只跟它的 HTTP 回调对接)
## 四、已经写好的(别重写,在这上面接着做)
`state_center/` 里已有可跑的第1步骨架:
| 文件 | 已实现 | 状态 |
| --- | --- | --- |
| `sql/schema.sql` | 8张表建库脚本 | 完成 |
| `app/config.py` | MySQL/Oracle 连接、Oracle表名列名占位 | 完成 |
| `app/db.py` | MySQL 访问(pymysql) | 完成 |
| `app/match_engine.py` | 对码引擎:两级命中+规格标准化+Oracle取数函数 | 完成,单测6/6过 |
| `app/main.py` | 建单 + start/complete/fail 三回调(含幂等/乱序拒绝)+ 合同审批等待 + 整单查询 | 骨架完成 |
| `tests/test_match_engine.py` | 对码单测 | 通过 |
> 状态机流转已通(**13 节点 = 11 主 + 2 条件** 显式转移表、业务编号回写、回调幂等、乱序拒绝 409、合同审批等待态、M5质管缺失→FAILED_MANUAL、M5不自动保存),用假回调能把一个 job 从头走到尾。
>
> 节点数口径:主链 11 步每单都走;`OCR_EXTRACT`(仅线下单)、`SOURCE_ADD`(仅缺货源)是条件节点,不满足自动跳过。代码 `NODE_ORDER` 列全 13 个,`TRANSITIONS` 定转移,`NODE_EXECUTOR` 标执行方(STATE_CENTER/RPA/HUMAN)。
## 五、你的任务清单(按顺序做,每步对着验收规范勾)
### 任务1:把骨架在真 MySQL 上跑通
- 建库、起服务,按 README 的 curl 把一个 job 用假回调走完主链(线上单会自动跳过 OCR_EXTRACT/SOURCE_ADD 条件节点)。
- 验收:`GET /api/jobs/{id}` 能看到完整节点轨迹和状态。
### 任务2:对码接口 + Oracle 真连
- 拿到 Oracle 连接串/表名列名后,填 `config.py`,`pip install oracledb`。
- **骨架已写好**(`main.py`),你主要是接真数据、补前置:
- `POST /api/match/reload-goods`:开工从 Oracle 全量拉货品主数据装进内存引擎(依赖 ORACLE_* 配置)。
- `POST /api/jobs/{id}/match/run`:对该 job 的 `order_item` 跑对码 → 落 `product_match`;MULTI/NONE 建 `user_task` 转 WAITING_USER,全命中推进下一节点。**前置:`order_item` 要先落库**(建单/ORDER_FETCH 后把明细写进来),否则 run 会 400。
- `POST /api/match/{matchId}/confirm`:人工确认一条,回写 product_match + 沉淀 `mapping_config` 累积别名(下次自动 ALIAS 命中),全部解决后自动推进。
- 已补齐的关键边界:
- 对码重跑幂等:`product_match(job_id,order_item_id)` 当前态唯一,`match/run` upsert 结果并重建 OPEN 待办。
- 货源判定:`goods_id` 明确后,按历史 `supplier_name + goods_id` 判断是否已有货源;缺货源会写 `product_match.source_add_needed/job.needs_source_add` 并创建 `SOURCE_ADD` 聚合待办。
- alias 沉淀:同一 `config_key` 先失效旧 active,再插新 active;装载时按固定顺序取当前结果。
- 你要补的:① 订单明细落 `order_item` 的入口;② `GET /api/jobs/{id}/match/unmatched`(列未命中给前端);③ H5 待办提交入口(把 `SOURCE_ADD` 完成动作接到 `nodes/SOURCE_ADD/complete`)。
- **只写 mapping_config 累积别名,绝不回写 Oracle 主数据**(主数据只读)。
- 验收:喂真实订单,命中率有数字,未命中进待办,确认后能自动往下走。
### 任务3:待办 + 人工提交
- `GET /api/tasks/my?roleCode=&status=open`、`POST /api/tasks/{id}/submit`。
- 动作:SELECT_CANDIDATE(对码选候选)/CONFIRM_AND_CONTINUE/RETRY_CURRENT_NODE/TRANSFER_MANUAL/TERMINATE_JOB。
- 幂等:task_id + submit_token 防重复提交。
### 任务4:前端网页
- 步骤树状图(样式见实施规范第三部分)、待办页、整单查询页。
- 验收:业务打开能看到每单跑到哪、卡在哪、去处理按钮跳待办。
### 任务5:证据 + 事件流 + 企业微信
- 证据落盘(job_artifact),事件流已有基础(job_event)。
- 企业微信推待办、H5 身份换 JWT。
### 任务6:调度与重试
- 定时触发建单(跑前一天订单)、系统类错误自动重试1-2次、CONTRACT_APPROVE_WAIT 轮询(别死等)、超时升级。
## 六、硬规则(红线,违反就是错的)
1. **对码表在 Oracle,只读,绝不复制成本地表、绝不回写 Oracle**。累积别名写自己的 mapping_config。
2. **M5 质管资料缺失(errorCode=M5_QC_MISSING)→ 不重试、不等质管,直接 FAILED_MANUAL 转人工**。已在 main.py 实现,别改成重试。
3. **M5 一期不自动保存/签订**(auto_save_enabled 默认0),做成开关,阶段二才放开。别默认自动。
4. **关键送审在返利中心(REBATE_SUBMIT),不是合同系统**。不送审 M5 带不出协议,是硬阻断。
5. **签约日期 = 订单日期**(不是今天),合同建单时按订单日期填。
6. **医药城同供应商同日合同合并成一个PDF**再上传(传第二份会覆盖),小药药不上传。
7. **返利细单备注必填订单号、特定补偿必填0**。
8. **对码未命中/返利重复编码 = 单条挂起,整批继续**;不是停整批。
9. **所有写接口必须鉴权**;RPA 回调校验 X-Agent-Token + 幂等键,禁止无认证暴露。
10. 节点失败不覆盖历史,attempt_no+1 新建 job_node 行。
## 七、你不做什么(边界)
- **不写点外部系统页面的代码**(登录/导Excel/建合同/M5录入)——那是艺赛旗,你只收它的 HTTP 回调。
- 不做招财平台(一期不纳入)。
- 不做加单/拆单全自动重做(先记录父订单号,转人工)。
- 不做返利重复编码全自动重建(规则未定,先转人工)。
- 遇到文档里标 `【待确认】` 的(订单状态过滤、OCR部署方式等),别自己拍板,先留 TODO 问项目负责人。
## 八、回调契约(艺赛旗会这样调你,已实现)
```
POST /api/jobs/{jobId}/nodes/{nodeCode}/start header: X-Agent-Token
POST /api/jobs/{jobId}/nodes/{nodeCode}/complete 带 businessRefs/summary
POST /api/jobs/{jobId}/nodes/{nodeCode}/fail 带 errorCode/retryable/routeRole
```
字段细节看 `设计文档` 第六部分和 `main.py` 里的 Pydantic 模型。
## 九、验收总纲
每做完一个节点/接口,打开 `实施与验收规范` 第四部分,对着该节点的**代码验收**(你自检)和**业务验收**(交业务勾)两栏逐条过。全绿才算完。

View File

@ -0,0 +1,197 @@
# 给艺赛旗(iS-RPA)的开发交接说明
> 你(艺赛旗开发)负责"采购主链RPA一期"里**点外部系统页面**的活:登录、导订单、建合同、返利中心操作、M5 录单。每做完一步,用 HTTP 回调告诉"状态中心"结果。状态中心是大脑(记每单跑到哪、派活、存证据),你是手脚(照它给的顺序点页面)。你俩**只通过 HTTP JSON 回调对接,不共享数据库**。
## 一、你要干什么(一句话)
按状态中心下发的 job(一个供应商+一个订单日期=一条合同),在各外部系统页面上完成对应节点的操作,每个节点用三个回调(start / complete / fail)把状态回传。
## 二、你和状态中心怎么对接(核心)
```
状态中心(Python/FastAPI) 艺赛旗 iS-RPA
记状态/派活/存证据 <--HTTP--> 点页面/填表/上传
| |
| 1) 你开始一个节点: POST .../start |
| 2) 点完页面成功: POST .../complete |
| 3) 出错: POST .../fail |
```
- **状态中心不会调用你**(一期没有反向下发);是**你主动调它**三个回调。
- 每次调用**必须带 header `X-Agent-Token`**(默认 `dev-agent-token`,上线换正式值,找状态中心开发要)。
- 节点顺序由状态中心维护,你 complete 后它自己算下一个节点是谁并返回 `next_node`,你**照着它返回的 next_node 跑下一步**,不要自己写死顺序。
## 三、三个回调契约(照抄字段,别改名)
基础地址:`http://<状态中心地址>:8000`(联调时给你实际 IP)。所有回调 header 带 `X-Agent-Token: <token>`、`Content-Type: application/json`。
### 3.1 节点开始 start
```
POST /api/jobs/{jobId}/nodes/{nodeCode}/start
{
"attemptNo": 1, // 第几次尝试,重试就 +1
"executorPlatform": "iS-RPA", // 可选,标记是哪个RPA平台跑的
"operator": "agent" // 可选
}
```
返回 `{"ok": true}`。**开始点页面前先调一次**,让状态中心把该节点标 RUNNING。
### 3.2 节点成功 complete
```
POST /api/jobs/{jobId}/nodes/{nodeCode}/complete
{
"attemptNo": 1,
"executorRunId": "YD-RUN-001", // 可选,你这边的流程运行号,便于回查
"businessRefs": { // 业务编号回写(见下表,产出编号的节点必传)
"contractSystemNo": "HT2026..."
},
"summary": { "orderCount": 12 } // 可选,给业务看的摘要
}
```
返回 `{"ok": true, "next_node": "OCR_EXTRACT"}`。**拿到 next_node 跑下一节点**;`next_node=null` 表示整单跑完。
### 3.3 节点失败 fail
```
POST /api/jobs/{jobId}/nodes/{nodeCode}/fail
{
"attemptNo": 1,
"errorCode": "SYS_LOGIN_FAIL", // 错误码,见第五节各节点表,照抄
"errorSummary": "验证码识别失败", // 人能看懂的一句话
"retryable": true, // 系统类错误填 true(状态中心可安排重试);业务类填 false
"routeRole": "OPS" // 转给谁:OPS(运维)/PLANNER(计划员)/PURCHASE(采购)
}
```
返回 `{"ok": true, "job_status": "..."}`。
**状态中心对 fail 的处理逻辑(你要知道,免得填错)**:
- `errorCode == "M5_QC_MISSING"` → 无条件 `FAILED_MANUAL`(转人工手工做,**不重试、不等质管**),`retryable` 填什么都没用。
- 否则 `retryable=true` → `FAILED_RETRYABLE`(系统类,等重试)。
- 否则 → `WAITING_USER`(业务类,进人工待办,按 `routeRole` 派人)。
> 判断原则:**系统能自愈的填 retryable=true**(登录超时、页面卡顿);**要人来决定的填 false**(对码要选、合同被禁、返利重复编码)。
## 四、businessRefs 业务编号回写(三把钥匙 + 收货单)
有三个节点**必须**在 complete 时回传对应编号,否则状态中心返回 400 拒绝记成功:
| 节点 nodeCode | 必传字段 | 是什么 |
| --- | --- | --- |
| CONTRACT_CREATE | `contractSystemNo` | 🔑合同编号(后续返利、M5 靠它串) |
| REBATE_HEADER | `rebateOrderNo` | 返利中心总单号 |
| M5_RECEIPT_NOTICE | `receiptNoticeNo` | 收货通知单号(全链终点) |
其余节点若产出编号也可回传(如 M5 单号 `m5OrderNo`),状态中心会落库。字段名**照抄**,大小写别改。
## 五、逐节点你要做什么(页面操作 + 回调)
> 完整版看 `资料/采购主链RPA一期实施与验收规范.md` 第四部分,每节点都有"页面操作步骤 + 代码验收 + 业务验收 + 失败去向"。这里给精简对照,让你知道每步调哪个回调、错误码填什么。
节点编排一共 **13 个 = 11 主节点 + 2 条件节点**(状态中心的 NODE_ORDER):
`ORDER_FETCH → OCR_EXTRACT → PRODUCT_MATCH → SOURCE_ADD → CONTRACT_CREATE → CONTRACT_APPROVE_WAIT → REBATE_HEADER → REBATE_IMPORT → REBATE_SUBMIT → M5_CREATE_ORDER → M5_MANUAL_CONFIRM → M5_RECEIPT_NOTICE → ARCHIVE_JOB`
- **2 个条件节点会被自动跳过**(你收不到 next_node 就是被跳了,别当 bug):
- `OCR_EXTRACT`:只有线下手工单才走,线上单(医药城/小药药)自动跳过。
- `SOURCE_ADD`:只有对码判定缺货源才走,货源齐全自动跳过。
- **不是你点页面的节点**:`PRODUCT_MATCH`(对码=状态中心内部算)、`SOURCE_ADD`/`M5_MANUAL_CONFIRM`/`M5_RECEIPT_NOTICE`(一期人工)、`ARCHIVE_JOB`(状态中心归档)。
- 你负责的是下面标"★你的活"的节点。**顺序永远听 complete 返回的 next_node**,别自己写死(条件节点跳过就是状态中心帮你算好的)。
> 状态中心对回调做了**幂等 + 乱序拒绝**:
> - 同一节点同 attemptNo 重复 complete,不会二次推进(会返回上次的 next_node,带 `"idempotent": true`),网络重发放心。
> - 回调的节点若不是该 job 的当前节点,返回 **409**(乱序被拒)。别跳着节点调,照 next_node 走。
> - complete 可带 `idempotencyKey`(你生成的唯一串),便于状态中心去重。
### ① ORDER_FETCH · 订单获取 ★你的活
页面(医药城 / 小药药):登录 → 我的订单 → 选日期(默认前一天)→【待确认:按订单状态过滤】→ 导出订单明细 Excel → 存证据目录 + 截图。
- complete:`summary` 带 `orderCount / supplierList / orderNoList`,`businessRefs` 无。
- 当天无订单:正常结束(不算失败)。
- fail 错误码:登录失败 `SYS_LOGIN_FAIL`(retryable=true, OPS)、页面超时 `SYS_PAGE_TIMEOUT`(true, OPS)、导出按钮改版找不到 `UI_ELEMENT_NOT_FOUND`(false, OPS)。
### ①b OCR_EXTRACT · 线下手工单识别(仅线下单)
不是你主导。线上订单直接跳过此节点(complete 空过)。线下单由 OCR 服务识别,你只负责取件/触发。
- fail:图片模糊 `BIZ_OCR_LOW_CONF`(false, PLANNER)、OCR服务挂 `SYS_OCR_DOWN`(true, OPS)。
### ③ CONTRACT_CREATE · 合同建单 ★你的活
页面(合同系统 `contract.gzmpc.com`):登录 → 按供应商对照表判断模板(格式/非格式承兑)→ 可复制历史合同再改 → 填基础信息,**签约日期/合同开始日期 = 订单日期(硬约束,不是今天)** → 填货品明细 → 填付款信息 → 提交送审。
- **医药城分支**:下载平台购销合同,**同供应商同日合并成一个PDF**再上传(先合并!传第二份会覆盖第一份)。
- **小药药分支**:格式合同,**跳过合同证文上传**。
- complete:**必传 `businessRefs.contractSystemNo`(合同编号)**。
- fail 错误码:供应商合同被禁/失效 `BIZ_CONTRACT_DISABLED`(false, PURCHASE)、PDF合并不支持 `CAP_PDF_MERGE_UNSUPPORTED`(false, OPS)、页面改版 `UI_ELEMENT_NOT_FOUND`(false, OPS)。
### ④ CONTRACT_APPROVE_WAIT · 等合同审批 ★你的活(定时查一次,不连续轮询)
合同送审(CONTRACT_CREATE complete)后,状态中心把该 job 挂成 `WAITING_EXTERNAL`,并按业务方案每约半小时排一次查。**这个节点不用 start/complete/fail 三件套**,用下面两个专用接口:
1. **到点扫待查**:你(或调度器)定时 `GET /api/scheduler/due-contract-checks`(带 X-Agent-Token),拿到到点该查的 job 列表(含 `contractSystemNo`)。
2. **登录查一次审批状态**,把结果回给状态中心:
```
POST /api/jobs/{jobId}/nodes/CONTRACT_APPROVE_WAIT/check-result
{ "approved": true, "operator": "agent", "detail": "已审批通过" }
```
- `approved=true` → 状态中心放行,返回 `{"ok":true,"next_node":"REBATE_HEADER"}`,你接着跑返利。
- `approved=false` → 状态中心把 `wait_check_count+1`,排下一次查(约半小时后又出现在待查列表);查满上限(默认 8 次≈4 小时)还没过 → 自动升级 OPS(`WAITING_USER`),你不用管了。
- **别死等、别连续轮询**:只在到点被扫到时查一次。
- 注意:未审完也会有合同编号,但**必须审批通过才能进返利导入**。
### ⑤ REBATE_HEADER · 返利中心总单 ★你的活
页面(返利中心 `flgzmpc.com`):登录 → 采购协议管理 → **按合同编号查总单** → 填必填项、勾必选项 → 保存。
- complete:**必传 `businessRefs.rebateOrderNo`(总单号)**。
- fail:查不到总单/必填缺失 → OPS 或 PURCHASE 核查。
### ⑥ REBATE_IMPORT · 返利细单导入 ★你的活
页面:下载细单模板 → 填字段(操作码/货品编码、数量、单价、税率、承付方式、细单开始时间=订单时间、失效日期、结算方式、**特定补偿=0**、**细单备注=订单号**、货源信息)→ 上传导入 → 读成功/失败行。
- complete:`summary` 带成功/失败计数与明细。
- fail:重复编码(订单已做过)`REBATE_DUP_CODE`(false, PURCHASE)→ **该单挂起,整批继续**(重建规则待确认,先转人工)。
### ⑦ REBATE_SUBMIT · 返利送审 ⭐决定成败⭐ ★你的活
页面:在返利中心对该总单点"提交送审"(送审即自动审批通过,合同前端已审过)。
- **反直觉核心:卡 M5 能不能带出品种的送审在返利中心,不在合同系统。这步不做,后面全废。**
- **未送审 = 硬阻断**,整个 job 停在这。不许跳过进 M5。
### ⑧ M5_CREATE_ORDER · M5 建采购单(1055)★你的活(M5 是桌面客户端)
M5 是**桌面应用**(不是网页),用桌面自动化处理。步骤:采购订单管理 1055 → 新增 → 保管账=正常保管账(默认唯一)→ 隔天做则日期改前一天签约日期 → **备注填订单号** → 新增细单选品种(返利已送审的才带得出)→ 选对应数量 → 结算/接收/发票用返利带入值(**不填**)→ **★录完就停,绝不点保存★**。
- complete:标记"M5待保存"(可回传 `m5OrderNo`)。
- fail:质管资料缺失 `M5_QC_MISSING`(**填什么状态中心都转 FAILED_MANUAL,不重试不等质管,转人工手工做**)。
### ⑨⑩ M5_MANUAL_CONFIRM / M5_RECEIPT_NOTICE · 一期人工
一期这两步**人工做**(检查→保存→签订→生成收货通知单)。你不点。阶段二 `auto_save_enabled` 放开后才可能给你。
## 六、硬规则(红线,踩了就是错)
1. **签约日期 = 订单日期**,不是今天。合同建单、M5 日期都按订单日期。
2. **医药城同供应商同日合同合并成一个PDF再上传**(传第二份会覆盖);**小药药不上传合同证文**。
3. **关键送审在返利中心(REBATE_SUBMIT)**,不是合同系统。不送审 M5 带不出协议,硬阻断。
4. **M5 录完不点保存**(一期人工把关保存/签订)。auto_save_enabled 一期=0。
5. **M5 质管缺失(M5_QC_MISSING)不重试、不等质管**,直接转人工。RPA 晚上跑,质管不会半夜维护。
6. **返利细单:特定补偿=0、细单备注=订单号**(物流/结算要看)。细单开始时间=订单时间。
7. **对码未命中/返利重复编码 = 单条挂起,整批继续**,不是停整批。
8. **产出编号的三个节点(合同/返利总单/收货通知)complete 必传对应 businessRefs**,否则被 400 拒。
9. **每个回调必须带 X-Agent-Token**;别把状态中心接口无认证暴露。
10. **节点顺序听状态中心的**(照 complete 返回的 next_node 走),别自己写死。
## 七、你不做什么(边界)
- **不碰状态存储/对码算法/前端页面**——那是状态中心(Trae)的活。你只点外部系统页面 + 回调。
- **不做对码判断**——对码是状态中心内部算,MULTI/NONE 会进人工待办,不经过你。
- **不做 M5 保存/签订/收货通知**(一期人工)。
- **不做招财平台**(一期不纳入)。
- 遇到文档标 `【待确认】` 的(订单状态过滤、返利重复编码重建、OCR部署方式),**别自己拍板**,留 TODO 问项目负责人。
## 八、联调建议顺序
1. 先跟状态中心开发对齐:实际 IP、正式 `X-Agent-Token`、证据目录路径。
2. 拿一个测试供应商 + 一天的测试订单,先只跑 ORDER_FETCH:start → 导出 → complete,在状态中心 `GET /api/jobs/{id}` 看到轨迹即通。
3. 再一节点一节点往后接,每接一个对照 `实施与验收规范` 第四部分的"业务验收"栏勾。
4. 合同、返利、M5 三段分别单独联调通过后,再跑整链。

View File

@ -0,0 +1,207 @@
# 状态中心页面 - 可调整版式方案
## 设计原则
1. **现在(联调阶段)**:全部展开,方便你看数据流转、验证接口。
2. **后期(交付阶段)**:一键折叠技术细节,业务只看待办 + 简化流程图。
3. **实现方式**:每个区域带展开/收起按钮 + 顶部"调试模式"开关,展开状态存 localStorage。
---
## 版式结构
```
┌──────────────────────────────────────────────────────────────────┐
│ 顶栏 │
│ [状态中心] [调试模式: ●开 ○关] 角色:采购 待办状态:待处理 │
│ [令牌: ****] [刷新] │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ ▼ 待办队列 (始终可见) [收起/展开]│
├──────────────────────────────────────────────────────────────────┤
│ ┌────────────┬────────────┬──────────────────────────────────┐ │
│ │ □ 新增货源 │ 对码待确认 │ M5人工确认 │ │
│ │ 重庆亿昊 │ 阿卡波糖 │ 华润医药 │ │
│ │ 10:00 │ 09:30 │ 昨天 │ │
│ └────────────┴────────────┴──────────────────────────────────┘ │
│ │
│ 当前选中待办:新增货源 - 重庆亿昊药业 │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ 原因:该供应商未维护这些品种的货源 │ │
│ │ [选择候选货品: ▼ 阿卡波糖片 50mg*30片] │ │
│ │ [☑ 保存为下次自动匹配规则] │ │
│ │ [确认并继续] [转派] [终止] │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ ▼ 所属单据概览 [收起/展开]│
├──────────────────────────────────────────────────────────────────┤
│ 单号: YYC-重庆亿昊药业-20260804-demo 状态: 等待人工 │
│ 当前节点: 新增货源 责任角色: 采购 │
│ 订单日期: 2026-08-04 供应商: 重庆亿昊药业 │
│ │
│ 业务编号链: │
│ 订单 DD-001,DD-002 → 合同 HT-xxx → 返利 - (待填) → M5 - → 收货 - │
│ │
│ [查看完整详情 ↗] │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ ▼ 流程状态图 [收起/展开]│
├──────────────────────────────────────────────────────────────────┤
│ [订单获取✓] [OCR⊘] [对码✓] [新增货源●] [合同建单○] ... [归档○] │
│ │
│ 图例: ✓已完成 ●当前 ⊘已跳过 ○待处理 ✗失败 │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ ▼ 调试:节点详情 (调试模式开启时显示) [收起/展开]│
├──────────────────────────────────────────────────────────────────┤
│ 已执行节点: │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ ORDER_FETCH [尝试1] 成功 09:40-09:42 (2分钟) │ │
│ │ 摘要: {orderCount: 12} │ │
│ │ 证据: orders.xlsx (SOURCE_EXCEL) │ │
│ ├──────────────────────────────────────────────────────────┤ │
│ │ PRODUCT_MATCH [尝试1] 成功 09:50-09:51 (1分钟) │ │
│ │ 摘要: {EXACT:10, ALIAS:1, MULTI:1} │ │
│ │ 证据: 无 │ │
│ ├──────────────────────────────────────────────────────────┤ │
│ │ SOURCE_ADD [尝试1] 等待人工 10:00- │ │
│ │ 证据: 无 │ │
│ └──────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ ▼ 调试:证据包 (调试模式开启时显示) [收起/展开]│
├──────────────────────────────────────────────────────────────────┤
│ ORDER_FETCH尝试1: │
│ └─ SOURCE_EXCEL: /data/jobs/.../orders.xlsx (09:42) │
│ CONTRACT_CREATE尝试1: │
│ └─ CONTRACT_PDF: /data/jobs/.../contract.pdf (10:06) │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ ▼ 调试:单据列表 (调试模式开启时显示) [收起/展开]│
├──────────────────────────────────────────────────────────────────┤
│ [全部] [执行中] [等待人工] [已完成] [失败] │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 重庆亿昊药业 2026-08-04 YYC 等待人工 新增货源 采购 │ │
│ │ 华润医药 2026-08-03 线下 执行中 对码 计划员 │ │
│ └──────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ ▼ 开发:节点回调模拟 (仅 DEV 环境显示) [收起/展开]│
├──────────────────────────────────────────────────────────────────┤
│ 当前节点: 新增货源 │
│ [参数 JSON: ] │
│ [开始] [成功] [失败] │
└──────────────────────────────────────────────────────────────────┘
```
---
## 分区说明
### 1. 待办队列 (始终可见,不可收起)
- **用途**:主工作流,无论联调还是生产都需要。
- **内容**:待办卡片 + 当前待办处理表单。
### 2. 所属单据概览 (默认展开,可收起)
- **用途**:提供当前待办的上下文(哪个供应商、哪天的单、跑到哪了)。
- **后期**:业务只看这个 + 待办队列就够了。
### 3. 流程状态图 (默认展开,可收起)
- **用途**:直观看 13 个节点的完成情况。
- **后期**:保留,但可以简化成 mini 版(只显示名称 + 状态点)。
### 4. 调试:节点详情 (调试模式开启时显示)
- **用途**:验证每个节点的 started_at/ended_at/duration/output_json/error。
- **后期**:关掉,业务不需要看技术细节。
### 5. 调试:证据包 (调试模式开启时显示)
- **用途**:验证 RPA 回调上传的证据是否落库、路径对不对。
- **后期**:关掉或改成"证据总数"摘要。
### 6. 调试:单据列表 (调试模式开启时显示)
- **用途**:你在联调时快速切换不同单据验证状态流转。
- **后期**:关掉或改成独立页面(管理员监控用)。
### 7. 开发:节点回调模拟 (仅 DEV 环境显示)
- **用途**:本地联调时模拟艺赛旗回调。
- **后期**:生产环境 `import.meta.env.DEV=false` 自动隐藏。
---
## 技术实现
### 1. 展开/收起状态存储
```typescript
// localStorage 存每个区域的展开状态
const sectionStates = {
jobSummary: true, // 所属单据概览
timeline: true, // 流程状态图
nodeDetails: false, // 节点详情(调试模式控制)
evidence: false, // 证据包(调试模式控制)
jobList: false, // 单据列表(调试模式控制)
};
localStorage.setItem('sectionStates', JSON.stringify(sectionStates));
```
### 2. 调试模式开关
```typescript
const [debugMode, setDebugMode] = useState(
localStorage.getItem('debugMode') === 'true'
);
// 顶栏开关:切换时同步存储,并控制"调试:"前缀的区域显隐
function toggleDebugMode() {
const next = !debugMode;
setDebugMode(next);
localStorage.setItem('debugMode', String(next));
}
```
### 3. 可收起组件封装
```typescript
<CollapsibleSection
title="所属单据概览"
storageKey="jobSummary"
defaultExpanded={true}
>
{/* 单据详情内容 */}
</CollapsibleSection>
<CollapsibleSection
title="调试:节点详情"
storageKey="nodeDetails"
defaultExpanded={false}
visibleWhen={debugMode} // 调试模式关闭时整个区域隐藏
>
{/* 节点列表 */}
</CollapsibleSection>
```
---
## 后期交付时的调整
你只需要:
1. **关闭调试模式**:顶栏开关点"关",所有"调试:"区域自动隐藏。
2. **可选:收起流程图**:如果业务觉得太占屏幕,点收起按钮即可。
3. **最终业务看到的**:待办队列 + 所属单据概览(可能 + 简化流程图),干净清爽。
---
## 你确认一下
这个方案是否满足你"现在全看、后期可关"的需求?如果可以,我直接动手:
1. 先封装 `CollapsibleSection` 组件。
2. 把现有页面按上述分区重组。
3. 加调试模式开关 + localStorage 持久化。
4. 调整样式,让收起状态下不占垂直空间。
或者你对分区划分 / 默认展开状态有别的想法,直接说,我再调整。

Some files were not shown because too many files have changed in this diff Show More