diff --git a/.gitignore b/.gitignore index 868fc3a..4101acd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ .DS_Store .idea/ .vscode/ +!rpa_selection/.vscode/ +!rpa_selection/.vscode/** *.swp # Python @@ -17,11 +19,10 @@ __pycache__/ # Node node_modules/ -state_center_web/dist/ # Local runtime / agent .runtime/ -rpa_yisaiqi/screenshots/ +rpa_contract/screenshots/ rpa-chrome-profile/ **/chrome-profile/ .tmp/ @@ -30,17 +31,15 @@ rpa-chrome-profile/ .claude/ .superpowers/ -# Duplicate / scratch -state_center_副本/ - -# Local M5 credentials -state_center/fixtures/m5/connection.local.json .env .env.* !.env.example -# Mall login cache (tokens / cookies) -**/data/.auth/ +# 选品凭据与运行结果 +rpa_selection/secrets.json +rpa_selection/data/.auth/ +rpa_selection/output/结果/*.xlsx +rpa_selection/output/结果/*.txt # Media (keep docs, drop recordings and extracted frames) *.mp4 diff --git a/AGENTS.md b/AGENTS.md index 5732f9c..3f22b67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AutoRpa -洁达 RPA。当前主线是**订单中心入站**(`rpa_oms`)和**选品班次**(`选品流程/`):收件落 Oracle,请求里不跑选品;选品作业另起进程捞 `deal_flag=0`。采购主链(状态中心编排、艺赛旗/Selenium 点合同系统)仍在仓库里,按问题打开对应行。 +洁达 RPA。当前主线是**订单中心入站**(`rpa_oms`)和**选品班次**(`rpa_selection/`):收件落 Oracle,请求里不跑选品;选品作业另起进程捞 `deal_flag=0`。合同页面在 `rpa_contract/`;加购 `rpa_cart/`、返利 `rpa_rebate/` 仍是占位。根上不再有 `选品流程/`、`state_center/`、`rpa_yisaiqi/`。 智力用在**定落点、想清楚对错**;速度和 token 靠下面这张地图定点打开,禁止用扫仓代替思考。改对应了,反工和消耗都会少。 @@ -21,27 +21,25 @@ | 接口1 收件契约 | `rpa_oms/app/inbound/sales_goods.py`、`envelope.py` | `rpa_oms/tests/test_sales_goods_receive.py::点名` | | 接口1 Oracle 落表 | `rpa_oms/app/inbound/oracle_repo.py` | `rpa_oms/tests/test_oracle_repo.py::点名` | | 接口1 入口 / 鉴权 | `rpa_oms/app/main.py`、`auth.py` | 同上;真库见 `rpa_oms/README.md` Windows 段 | -| 选品规则 / 拆量 | `选品流程/rules.py`、`pipeline.py` | `选品流程/tests/test_pipeline.py::test_split_across_malls_is_success` | -| 选品班次落表 | `选品流程/job.py`、`persist.py`、`repo.py` | `选品流程/tests/test_job.py::test_job_searches_barcode_not_approval_no` | -| 合同页面步骤 S02–S13 | `rpa_yisaiqi/contract_flow_steps.py` 里对应函数 | `ACCEPT SUMMARY` 或 `rpa_yisaiqi/screenshots/CONTRACT_CREATE/ACCEPT.txt` | -| 合同入参 / 客商草稿 | `rpa_yisaiqi/contract_draft.py` | `state_center/tests/test_yisaiqi_contract_draft.py::点名` | -| 节点入口、回调 complete | `rpa_yisaiqi/contract_create_node.py`、`callback_client.py` | 回调 JSON 含 `contractSystemNo` | -| 浏览器适配 | `rpa_yisaiqi/apa_browser_adapter.py` | `rpa_yisaiqi/tests/test_apa_browser_adapter.py::点名` | -| 文字验收 / 截图策略 | `rpa_yisaiqi/runtime/accept.py` | 只读 `ACCEPT.txt`,禁止读 png | -| 艺赛旗画布代码块 | `rpa_yisaiqi/build_multinode_seq.py`(源)→ `studio/`(编译产物) | 未说画布不要改这两处 | -| 跨节点共享状态 | `rpa_yisaiqi/_flow_state.py` | — | -| 状态机 / API | `state_center/app/main.py`、`web_state_machine.py`、`match_engine.py` | `state_center/tests/test_web_state_machine.py::点名` 等 | -| 前端看板 | `state_center_web/src/stateMachine.ts`、`App.tsx` | 浏览器点一遍相关页 | -| 一键启动(状态中心) | `scripts/dev.py start` | 状态中心 :8000,前端 :5173 | -| 节点业务规则(点名一节) | `资料/采购主链RPA一期实施与验收规范.md` | 禁止通读 `资料/`、`docs/` | +| 选品规则 / 拆量 | `rpa_selection/rules.py`、`pipeline.py` | `rpa_selection/tests/test_pipeline.py::test_split_across_malls_is_success` | +| 选品班次落表 | `rpa_selection/job.py`、`persist.py`、`repo.py` | `rpa_selection/tests/test_job.py::test_job_searches_barcode_not_approval_no` | +| 合同页面步骤 | `rpa_contract/contract_flow_steps.py` 里对应函数 | `ACCEPT SUMMARY` 或 `rpa_contract/screenshots/` 下 `ACCEPT.txt` | +| 合同入参 / 客商草稿 | `rpa_contract/contract_draft.py` | `rpa_contract` 点名用例 | +| 节点入口 | `rpa_contract/contract_create_node.py`、`main.py` | 读到正式合同号 | +| 浏览器适配 | `rpa_contract/apa_browser_adapter.py` | `rpa_contract/tests/test_apa_browser_adapter.py::点名` | +| 文字验收 / 截图策略 | `rpa_contract/accept.py` | 只读 `ACCEPT.txt`,禁止读 png | +| 加购 / 支付回传 | `rpa_cart/README.md` | 占位,未实现 | +| 返利班 | `rpa_rebate/README.md` | 占位,未实现 | +| 四个口地图 | `rpa_oms/README.md`、根 `README.md` | — | +| 节点业务规则(点名一节) | `docs/RPA订单中心接口开发口径_20260930.md` | 禁止通读 `docs/` | -禁止打开:`node_modules/`、`chrome-profile/`、任何 png。Chrome 用户目录只在仓库外 `D:\work\rpa-chrome-profile`。Oracle 密码放本机 `rpa_oms/.env`(选品空缺时复用);商城账号放 `选品流程/.env`。都已 gitignore,不要写进仓库、README、示例 JSON,不要 `git add` / commit / push。 +禁止打开:`node_modules/`、`chrome-profile/`、任何 png。Chrome 用户目录只在仓库外 `D:\work\rpa-chrome-profile`。Oracle 密码放本机 `rpa_oms/.env`(选品空缺时复用);商城账号放 `rpa_selection/.env`。都已 gitignore,不要写进仓库、README、示例 JSON,不要 `git add` / commit / push。 ## 接口1 落点(订单中心) 订单中心四接口,本期只开放接口 1:销售商品对码数据接收。`POST /api/oms/v1/sales-goods`,鉴权 header `X-Oms-Token`(`OMS_INBOUND_TOKEN`)。落表 `rpa_sales_goods_mapping_data`,`deal_flag=0`,等选品作业捞;**请求里不跑选品**。 -默认 `OMS_REPO=oracle`,本机连不上库用 `OMS_REPO=memory`。服务端口 **8001**,不走 `scripts/dev.py`。 +运行时只落 Oracle。单测用内存替身,不连库。服务端口 **8001**。 | 步 | 函数 | 要点 | | --- | --- | --- | @@ -58,11 +56,11 @@ 验收(内存、不连库):`cd rpa_oms` → `uv run pytest tests/test_sales_goods_receive.py::点名 -q`。真库:`GET /health` 后把 `examples/sales_goods_batch.json` POST 两次(第一次两条 `ACCEPTED`,第二次两条 `DUPLICATE`)。 -修接口1不要顺手改 `state_center/`、`rpa_yisaiqi/`、`选品流程/`。接口 2–4 未开放,不要先写。 +修接口1不要顺手改 `rpa_selection/`、`rpa_cart/`、`rpa_contract/`、`rpa_rebate/`。接口 2–4 未开放,不要先写。 ## 选品落点 -班次作业在 `选品流程/`,不在接口 1 请求里跑。从表 1 领 `deal_flag=0`,按**条形码**搜 1 号药城 / 药帮忙,两平台合并后按四级仓序贪心拆量,写入表 2。凑齐才落成功行;凑不齐不落部分成功。 +班次作业在 `rpa_selection/`,不在接口 1 请求里跑。从表 1 领 `deal_flag=0`,按**条形码**搜 1 号药城 / 药帮忙,两平台合并后按四级仓序贪心拆量,写入表 2。凑齐才落成功行;凑不齐不落部分成功。 口径:`docs/superpowers/specs/2026-10-02-selection-split-allocation-design.md`。 @@ -76,11 +74,11 @@ 湖南壹号、甘肃壹号云不在自营集合。不加购,不推接口 2。 -验收:`cd 选品流程` → `uv run pytest tests/test_pipeline.py::test_split_across_malls_is_success -q`(杭州 3 + 广东 7 = 计划 10)。 +验收:`cd rpa_selection` → `uv run pytest tests/test_pipeline.py::test_split_across_malls_is_success -q`(杭州 3 + 广东 7 = 计划 10)。 ## CONTRACT_CREATE 落点 -`run_contract_create_flow` 串起来;修一步只打开对应函数。 +页面脚本在 `rpa_contract/`。`run_contract_create_flow` 串起来;修一步只打开对应函数。从表 3 合单、写表 4 尚未接。 | 步 | 函数 | 要点 | | --- | --- | --- | @@ -100,7 +98,7 @@ ## 返利中心及之后 -合同审批通过后才进这一段。三把钥匙:`contractSystemNo` → `rebateOrderNo` → `receiptNoticeNo`。页面步骤未建,联调 `rpa_yisaiqi/runners/mock.py`。开做时按合同同款:一个节点一份 `*_flow_steps.py`,验收只看 ACCEPT / 点名用例。 +`rpa_rebate/` 仍是占位。合同审批通过后才进这一段。三把钥匙:`contractSystemNo` → `rebateOrderNo` → `receiptNoticeNo`。开做时按合同同款:一个节点一份 `*_flow_steps.py`,验收只看 ACCEPT / 点名用例。 | 节点 | 谁做 | 要回传 | 页面要做什么(尚未拆函数) | | --- | --- | --- | --- | @@ -108,24 +106,21 @@ | `REBATE_IMPORT` | RPA | 成功/失败行 | 下模板 → 按字段填 Excel → 上传 → 读导入结果 | | `REBATE_SUBMIT` | RPA | `submitted` | 点提交送审 | | `M5_CREATE_ORDER` | RPA 桌面端 | `m5OrderNo` | 录单后**停、不保存**。`M5_QC_MISSING` 必须识别准(直接转人工、不重试) | -| `M5_MANUAL_CONFIRM` | 采购 | — | 人在 M5 里保存签订;前端任务推进 | +| `M5_MANUAL_CONFIRM` | 采购 | — | 人在 M5 里保存签订 | | `M5_RECEIPT_NOTICE` | 采购 | `receiptNoticeNo` | 人填收货通知单号,全链终点钥匙 | -| `ARCHIVE_JOB` | 状态中心 | — | 无页面 | -调度:`rpa_yisaiqi/agent.py`。人节点在 `HUMAN_NODES`。不要连续轮询审批;返利段开始前的 `CONTRACT_APPROVE_WAIT` 约 30 分钟查一次。 +不要连续轮询审批;返利段开始前大约 30 分钟查一次。 ## 验收 - 接口1:点名 `rpa_oms/tests/test_sales_goods_receive.py` 或 `test_oracle_repo.py`;禁止为「显得全面」跑整包再去连真库。 -- 选品:点名 `选品流程/tests/test_pipeline.py::test_split_across_malls_is_success` 或 `test_rules.py::test_margin_exactly_4_percent_passes`。 -- 合同页:只读控制台 `ACCEPT` 或 `screenshots/<节点>/ACCEPT.txt`。 -- 单测只跑点名用例,例如 `state_center/tests/test_yisaiqi_contract_flow_steps.py::test_read_contract_number_falls_back_to_my_contracts_list`。禁止跑整份该文件。 -- 该文件已打掉 `time.sleep`;不要再拿全量带睡的套件当回归。 +- 选品:点名 `rpa_selection/tests/test_pipeline.py::test_split_across_malls_is_success` 或 `test_rules.py::test_margin_exactly_4_percent_passes`。 +- 合同页:只读控制台 `ACCEPT` 或 `rpa_contract/screenshots/` 下 `ACCEPT.txt`。 +- 单测只跑点名用例。禁止跑整份带页面等待的套件当回归。 ## 不要 -- 不要扫根目录,不要通读 `资料/`、`docs/`。需要背景就点名打开一节。 -- 不要顺手改 `studio/`、`build_multinode_seq.py`(画布任务除外)。 +- 不要扫根目录,不要通读 `docs/`。需要背景就点名打开一节。 - 不要把 Oracle 密码写进仓库、README、示例 JSON。 - 不要在接口1 请求路径里跑选品。 - 不要为「显得全面」多开文件、重跑、看图。落点外的改动会制造反工。 diff --git a/PARALLEL_DEV_PLAN.md b/PARALLEL_DEV_PLAN.md deleted file mode 100644 index c690390..0000000 --- a/PARALLEL_DEV_PLAN.md +++ /dev/null @@ -1,367 +0,0 @@ -# 状态中心 ↔ 艺赛旗并行开发方案 - -## 核心原则:契约驱动,各自独立,最后对接 - -双方只通过 **HTTP 回调契约** 交互,这个契约在交接文档里已经明确定义。在对接前,双方各自用假数据/假回调验证自己的逻辑,互不阻塞。 - ---- - -## 对接契约(双方共同遵守) - -### 基础信息 -- **状态中心地址**:联调时给艺赛旗实际 IP,如 `http://192.168.x.x:8000` -- **鉴权**:所有回调必须带 header `X-Agent-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//nodes//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 天一起联调。 - ---- - -要我把这份文档发给艺赛旗吗?或者你先看看有没有要调整的。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..dcd74ed --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# 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 日的四流程稿也是归档。作业语义以口径为准。 diff --git a/docs/API鉴权.doc b/docs/API鉴权.doc new file mode 100644 index 0000000..07ba3f6 Binary files /dev/null and b/docs/API鉴权.doc differ diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..3f1a7cf --- /dev/null +++ b/docs/README.md @@ -0,0 +1,35 @@ +# 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 月设计稿,同样视为归档。 diff --git a/docs/RPA订单中心接口口径讨论纪要_20260929.md b/docs/RPA订单中心接口口径讨论纪要_20260929.md new file mode 100644 index 0000000..025a699 --- /dev/null +++ b/docs/RPA订单中心接口口径讨论纪要_20260929.md @@ -0,0 +1,86 @@ +# 纪要: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 的鉴权 +- 只凑到一部分数量时,多平台报文怎么组 +- 返利测试环境确认:点送审后协议号是否立即可见 +- 加购业务失败不重推:口头跟对方确认一句 + +开发以口径文档为准。本文只记为什么这么定。 diff --git a/资料/RPA订单中心接口开发口径_20260930.md b/docs/RPA订单中心接口开发口径_20260930.md similarity index 94% rename from 资料/RPA订单中心接口开发口径_20260930.md rename to docs/RPA订单中心接口开发口径_20260930.md index 3aad6ae..9f87f79 100644 --- a/资料/RPA订单中心接口开发口径_20260930.md +++ b/docs/RPA订单中心接口开发口径_20260930.md @@ -5,9 +5,9 @@ 依据: - `Downloads/RPA与订单中心接口设计_20260922.xlsx`(以当时最新一份为准) -- `资料/API鉴权.doc` -- `资料/合同返利系统字段映射_20260911.xlsx`(返利总单默认值) -- `选品流程/` 既有规则 +- `docs/API鉴权.doc` +- `docs/合同返利系统字段映射_20260911.xlsx`(返利总单默认值) +- `rpa_selection/` 既有规则 --- @@ -47,7 +47,7 @@ 商城查询键:**条形码**。批准文号照收,不拿去搜。 -规则沿用 `选品流程/`(自营仓、毛利 ≥ 4%、效期 ≥ 12 个月;两平台合并后按仓序拆量,凑齐计划量才算成功)。毛利用 `salePrice`,拆量目标用 `planPurchaseQty`。详见 `docs/superpowers/specs/2026-10-02-selection-split-allocation-design.md`。 +规则沿用 `rpa_selection/`(自营仓、毛利 ≥ 4%、效期 ≥ 12 个月;两平台合并后按仓序拆量,凑齐计划量才算成功)。毛利用 `salePrice`,拆量目标用 `planPurchaseQty`。详见 `docs/superpowers/specs/2026-10-02-selection-split-allocation-design.md`。 ### 3.2 加购结果(接口 2,`operateType=1`) diff --git a/资料/PLAN.md b/docs/archive/PLAN.md similarity index 100% rename from 资料/PLAN.md rename to docs/archive/PLAN.md diff --git a/资料/一期开发任务清单_表结构_API草案.md b/docs/archive/一期开发任务清单_表结构_API草案.md similarity index 100% rename from 资料/一期开发任务清单_表结构_API草案.md rename to docs/archive/一期开发任务清单_表结构_API草案.md diff --git a/资料/一期需求简版说明_业务版.md b/docs/archive/一期需求简版说明_业务版.md similarity index 100% rename from 资料/一期需求简版说明_业务版.md rename to docs/archive/一期需求简版说明_业务版.md diff --git a/资料/界面截图证据记录.md b/docs/archive/界面截图证据记录.md similarity index 100% rename from 资料/界面截图证据记录.md rename to docs/archive/界面截图证据记录.md diff --git a/docs/archive/福建洁达采购合同自动化设计说明.md b/docs/archive/福建洁达采购合同自动化设计说明.md new file mode 100644 index 0000000..45d190f --- /dev/null +++ b/docs/archive/福建洁达采购合同自动化设计说明.md @@ -0,0 +1,305 @@ +# 福建洁达采购合同全链条自动化设计说明 + +| 项目 | 内容 | +| --- | --- | +| 文档名称 | 福建洁达采购合同全链条自动化设计说明 | +| 版本 | 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 节提供接口说明书,并确认客户类型、账期天数、计划员对照、货源细单号、对码回写、尽调来源。未齐备前,合同与返利不得按缺省猜测值提交。 + +上述事项确认后,按《福建洁达四流程技术实现说明》实施。实施顺序为:先完成订单中心取数入库与状态中心可见,再接入选品、合同与返利。 diff --git a/资料/采购业务RPA思路20260625.docx b/docs/archive/采购业务RPA思路20260625.docx similarity index 100% rename from 资料/采购业务RPA思路20260625.docx rename to docs/archive/采购业务RPA思路20260625.docx diff --git a/资料/采购主链RPA一期实施与验收规范.md b/docs/archive/采购主链RPA一期实施与验收规范.md similarity index 100% rename from 资料/采购主链RPA一期实施与验收规范.md rename to docs/archive/采购主链RPA一期实施与验收规范.md diff --git a/资料/采购主链RPA一期开发规划.md b/docs/archive/采购主链RPA一期开发规划.md similarity index 100% rename from 资料/采购主链RPA一期开发规划.md rename to docs/archive/采购主链RPA一期开发规划.md diff --git a/资料/采购主链RPA一期设计文档.md b/docs/archive/采购主链RPA一期设计文档.md similarity index 100% rename from 资料/采购主链RPA一期设计文档.md rename to docs/archive/采购主链RPA一期设计文档.md diff --git a/资料/采购主链RPA一期需求文档_三方会审版.md b/docs/archive/采购主链RPA一期需求文档_三方会审版.md similarity index 100% rename from 资料/采购主链RPA一期需求文档_三方会审版.md rename to docs/archive/采购主链RPA一期需求文档_三方会审版.md diff --git a/资料/采购主链RPA一期需求文档_会议整理版.md b/docs/archive/采购主链RPA一期需求文档_会议整理版.md similarity index 100% rename from 资料/采购主链RPA一期需求文档_会议整理版.md rename to docs/archive/采购主链RPA一期需求文档_会议整理版.md diff --git a/docs/ddl/rpa_oms_staging_tables.sql b/docs/ddl/rpa_oms_staging_tables.sql new file mode 100644 index 0000000..8d83e91 --- /dev/null +++ b/docs/ddl/rpa_oms_staging_tables.sql @@ -0,0 +1,318 @@ +-- 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 '货主编码。'; diff --git a/docs/ddl/列对照.md b/docs/ddl/列对照.md new file mode 100644 index 0000000..6962f36 --- /dev/null +++ b/docs/ddl/列对照.md @@ -0,0 +1,169 @@ +# 四张暂存表 ↔ 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_*`、审计字段。 diff --git a/docs/superpowers/specs/2026-10-02-repo-layout-design.md b/docs/superpowers/specs/2026-10-02-repo-layout-design.md new file mode 100644 index 0000000..0e96e58 --- /dev/null +++ b/docs/superpowers/specs/2026-10-02-repo-layout-design.md @@ -0,0 +1,268 @@ +# 仓库布局:四接口一服务 + 四个英文作业包 + +| 文档属性 | 内容 | +| --- | --- | +| 日期 | 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-" +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. 确认根上没有 `选品流程/`、`资料/` diff --git a/docs/【项目方案】福建洁达采购合同RPA自动化录入需求_v20260924.docx b/docs/【项目方案】福建洁达采购合同RPA自动化录入需求_v20260924.docx new file mode 100644 index 0000000..a3a2384 Binary files /dev/null and b/docs/【项目方案】福建洁达采购合同RPA自动化录入需求_v20260924.docx differ diff --git a/docs/下一会话任务_建表.md b/docs/下一会话任务_建表.md new file mode 100644 index 0000000..07d1483 --- /dev/null +++ b/docs/下一会话任务_建表.md @@ -0,0 +1,45 @@ +# 下一会话任务:四张接口底表 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 的列对照(缺了什么、多了什么)。不要展开下一阶段。 diff --git a/docs/合同系统录入流程.md b/docs/合同系统录入流程.md new file mode 100644 index 0000000..2f3cb7e --- /dev/null +++ b/docs/合同系统录入流程.md @@ -0,0 +1,75 @@ +# 合同系统录入流程(现行) + +对照:`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)已从仓库删除。不要再接。 diff --git a/docs/合同返利系统字段映射_20260911.xlsx b/docs/合同返利系统字段映射_20260911.xlsx new file mode 100644 index 0000000..390a51b Binary files /dev/null and b/docs/合同返利系统字段映射_20260911.xlsx differ diff --git a/资料/采购平台选品条件优先项_20260902.txt b/docs/采购平台选品条件优先项_20260902.txt similarity index 100% rename from 资料/采购平台选品条件优先项_20260902.txt rename to docs/采购平台选品条件优先项_20260902.txt diff --git a/rpa_cart/README.md b/rpa_cart/README.md new file mode 100644 index 0000000..3dba6c9 --- /dev/null +++ b/rpa_cart/README.md @@ -0,0 +1,16 @@ +# 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 推送。 diff --git a/rpa_cart/pyproject.toml b/rpa_cart/pyproject.toml new file mode 100644 index 0000000..0ae0130 --- /dev/null +++ b/rpa_cart/pyproject.toml @@ -0,0 +1,13 @@ +[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 diff --git a/rpa_contract/README.md b/rpa_contract/README.md new file mode 100644 index 0000000..24fcb44 --- /dev/null +++ b/rpa_contract/README.md @@ -0,0 +1,49 @@ +# 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 回写。 diff --git a/rpa_yisaiqi/runtime/accept.py b/rpa_contract/accept.py similarity index 98% rename from rpa_yisaiqi/runtime/accept.py rename to rpa_contract/accept.py index 90a5b25..3ffc538 100644 --- a/rpa_yisaiqi/runtime/accept.py +++ b/rpa_contract/accept.py @@ -11,9 +11,9 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Protocol -from .errors import NodeError +from errors import NodeError -PACKAGE_ROOT = Path(__file__).resolve().parents[1] +PACKAGE_ROOT = Path(__file__).resolve().parent SCREENSHOT_ROOT = PACKAGE_ROOT / "screenshots" diff --git a/rpa_yisaiqi/apa_browser_adapter.py b/rpa_contract/apa_browser_adapter.py similarity index 99% rename from rpa_yisaiqi/apa_browser_adapter.py rename to rpa_contract/apa_browser_adapter.py index e2df2ba..5a42c94 100644 --- a/rpa_yisaiqi/apa_browser_adapter.py +++ b/rpa_contract/apa_browser_adapter.py @@ -1,7 +1,6 @@ # coding=utf-8 """ -APA (艺赛旗) Selenium Chrome BrowserAdapter 实现 -通过 Selenium WebDriver 直接操控 Chrome,不依赖 APA 浏览器模块 +Selenium Chrome 浏览器适配。通过 WebDriver 操控 Chrome。 """ from __future__ import annotations @@ -495,7 +494,7 @@ class ApaBrowserAdapter: return "" def screenshot(self, name: str) -> str: - from .runtime.accept import artifact_dir + from accept import artifact_dir save_dir = artifact_dir(self._flow) save_dir.mkdir(parents=True, exist_ok=True) diff --git a/rpa_yisaiqi/contract_create_node.py b/rpa_contract/contract_create_node.py similarity index 78% rename from rpa_yisaiqi/contract_create_node.py rename to rpa_contract/contract_create_node.py index 7e5da2a..899f547 100644 --- a/rpa_yisaiqi/contract_create_node.py +++ b/rpa_contract/contract_create_node.py @@ -1,40 +1,29 @@ -"""CONTRACT_CREATE node: input building + state-center callback (runtime.shell) + Selenium drafting.""" +"""CONTRACT_CREATE: 入参组装 + Selenium 起草。结果返回合同号,不回调旧状态中心。""" from __future__ import annotations import argparse import json import os -import sys from dataclasses import dataclass, replace from pathlib import Path from typing import Any, Callable -from .apa_browser_adapter import ApaBrowserAdapter -from .callback_client import StateCenterClient -from .contract_draft import ( +from apa_browser_adapter import ApaBrowserAdapter +from contract_draft import ( ContractDraft, contract_draft_from_dict, is_placeholder_customer_code, load_contract_draft, ) -from .contract_flow_steps import ContractFlowContext, run_contract_create_flow -from .pdf_utils import ensure_merged_pdf -from .runtime.errors import NodeError, PdfMergeUnsupported, UnknownRpaError -from .runtime.shell import execute_node +from contract_flow_steps import ContractFlowContext, run_contract_create_flow +from errors import NodeError, PdfMergeUnsupported, UnknownRpaError +from pdf_utils import ensure_merged_pdf -NODE_CODE = "CONTRACT_CREATE" CONTRACT_URL = "http://contract-test.gzmpc.com:8080/" 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: @@ -43,26 +32,6 @@ 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) class ContractCreateInput: job_id: str @@ -87,40 +56,28 @@ ContractRunner = Callable[[ContractCreateInput], str] def execute_contract_create( spec: ContractCreateInput, - client: StateCenterClient, *, contract_runner: ContractRunner | None = None, ) -> dict[str, Any]: runner = contract_runner or create_contract_in_contract_system - # dry_run:只跑到提交前,不回调状态中心(不 start/complete/fail) if spec.submit_mode == "dry_run": runner(spec) return {"status": "dry_run", "submitted": False} - def _run() -> dict[str, Any]: - try: - contract_no = runner(spec) - except NodeError: - raise - except Exception as exc: - raise UnknownRpaError(str(exc)) from exc - if not contract_no: - raise ContractCreateError("submit 模式未读取到真实合同编号,无法 complete") - return { - "businessRefs": {"contractSystemNo": contract_no}, - "summary": _build_summary(spec), - "artifacts": [], - } - - return execute_node( - client, - spec.job_id, - NODE_CODE, - _run, - attempt_no=spec.attempt_no, - operator="agent", - ) + try: + contract_no = runner(spec) + except NodeError: + raise + except Exception as exc: + raise UnknownRpaError(str(exc)) from exc + if not contract_no: + raise ContractCreateError("submit 模式未读取到真实合同编号") + return { + "status": "done", + "contractSystemNo": contract_no, + "summary": _build_summary(spec), + } def create_contract_in_contract_system(spec: ContractCreateInput) -> str: @@ -321,21 +278,13 @@ def load_input_data(raw: str) -> dict[str, Any]: raise ContractCreateError(f"无法解析入参:既不是合法 JSON,也不是存在的文件路径:{raw}") -def run_contract_create( - data: dict[str, Any], - *, - state_center_url: str, - agent_token: str, -) -> dict[str, Any]: +def run_contract_create(data: dict[str, Any]) -> dict[str, Any]: spec = build_spec_from_data(data) - client = StateCenterClient(state_center_url, agent_token) - return execute_contract_create(spec, client) + return execute_contract_create(spec) def parse_args() -> argparse.Namespace: - 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 = argparse.ArgumentParser(description="合同系统页面起草。") parser.add_argument("--input-json", default=os.getenv("CONTRACT_INPUT_JSON")) parser.add_argument("--job-id") parser.add_argument("--supplier-name") @@ -353,7 +302,6 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() - client = StateCenterClient(args.state_center_url, args.token) if args.input_json: spec = build_spec_from_data(load_input_data(args.input_json)) @@ -374,7 +322,7 @@ def main() -> int: merged_pdf_output=args.merged_pdf_output, ) - result = execute_contract_create(spec, client) + result = execute_contract_create(spec) print(json.dumps(result, ensure_ascii=False)) return 0 diff --git a/rpa_yisaiqi/contract_draft.py b/rpa_contract/contract_draft.py similarity index 98% rename from rpa_yisaiqi/contract_draft.py rename to rpa_contract/contract_draft.py index 91b0aff..5b8902c 100644 --- a/rpa_yisaiqi/contract_draft.py +++ b/rpa_contract/contract_draft.py @@ -6,7 +6,7 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any -from .contract_flow_steps import ContractGoodsLine +from contract_flow_steps import ContractGoodsLine _PLACEHOLDER_CUSTOMER_PREFIXES = ("CUST-", "TEST-", "MOCK-") diff --git a/rpa_yisaiqi/contract_flow_steps.py b/rpa_contract/contract_flow_steps.py similarity index 96% rename from rpa_yisaiqi/contract_flow_steps.py rename to rpa_contract/contract_flow_steps.py index 3577c5c..5f1f988 100644 --- a/rpa_yisaiqi/contract_flow_steps.py +++ b/rpa_contract/contract_flow_steps.py @@ -1,8 +1,4 @@ -"""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 import time @@ -10,8 +6,8 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Protocol -from .runtime.accept import AcceptanceReport, accept_log_path, evidence_shot -from .runtime.errors import ( +from accept import AcceptanceReport, accept_log_path, evidence_shot +from errors import ( LoginFailed, NodeError, PdfMergeUnsupported, @@ -120,10 +116,6 @@ def run_contract_create_flow(browser: BrowserAdapter, ctx: ContractFlowContext) _ACCEPT.note("S13", "读合同号", {"contractNo": "(none)", "mode": ctx.submit_mode}, ok=False) return "" 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( "S13", "读合同号", @@ -294,51 +286,7 @@ 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: - _callback_start() browser.open_url(ctx.contract_url) _ACCEPT.note("S02", "打开合同系统", {"url": ctx.contract_url}, ok=True) @@ -411,7 +359,7 @@ def fill_contract_signatory_info(browser: BrowserAdapter, ctx: ContractFlowConte if not ctx.customer_code: 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): raise SupplierContractDisabled( @@ -1120,10 +1068,6 @@ def submit_contract_for_approval(browser: BrowserAdapter, ctx: ContractFlowConte evidence_shot(browser, "contract-submitted") print("提交成功,回我的合同取编号。", flush=True) 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) return True _confirm_submit_dialogs(browser) @@ -1219,10 +1163,6 @@ def _confirm_submit_dialogs(browser: BrowserAdapter) -> None: 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: ctx = ContractFlowContext( contract_url="http://contract-test.gzmpc.com:8080/", @@ -1232,13 +1172,7 @@ def read_contract_number(browser: BrowserAdapter, ctx: ContractFlowContext | Non order_date="", source_type="", ) - 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 + return _read_contract_number_from_my_list(browser, ctx) def _read_contract_number_on_page(browser: BrowserAdapter) -> str: diff --git a/rpa_yisaiqi/runtime/errors.py b/rpa_contract/errors.py similarity index 86% rename from rpa_yisaiqi/runtime/errors.py rename to rpa_contract/errors.py index 7793368..01ac7a8 100644 --- a/rpa_yisaiqi/runtime/errors.py +++ b/rpa_contract/errors.py @@ -1,4 +1,4 @@ -"""Node errors that map onto state-center fail callbacks.""" +"""Page-flow errors. Codes stay for logs; they no longer map to a state-center fail callback.""" class NodeError(RuntimeError): diff --git a/rpa_yisaiqi/examples/contract_create_input_dry_run.json b/rpa_contract/examples/contract_create_input_dry_run.json similarity index 93% rename from rpa_yisaiqi/examples/contract_create_input_dry_run.json rename to rpa_contract/examples/contract_create_input_dry_run.json index 430fce0..99cc486 100644 --- a/rpa_yisaiqi/examples/contract_create_input_dry_run.json +++ b/rpa_contract/examples/contract_create_input_dry_run.json @@ -38,7 +38,5 @@ "mdmCode": "", "unitPrice": "50.00" } - ], - "stateCenterUrl": "http://127.0.0.1:8000", - "agentToken": "dev-agent-token" + ] } diff --git a/rpa_yisaiqi/examples/contract_draft_xyy.json b/rpa_contract/examples/contract_draft_xyy.json similarity index 100% rename from rpa_yisaiqi/examples/contract_draft_xyy.json rename to rpa_contract/examples/contract_draft_xyy.json diff --git a/rpa_yisaiqi/examples/contract_draft_yyc.json b/rpa_contract/examples/contract_draft_yyc.json similarity index 100% rename from rpa_yisaiqi/examples/contract_draft_yyc.json rename to rpa_contract/examples/contract_draft_yyc.json diff --git a/rpa_contract/main.py b/rpa_contract/main.py new file mode 100644 index 0000000..c222494 --- /dev/null +++ b/rpa_contract/main.py @@ -0,0 +1,4 @@ +from contract_create_node import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/rpa_yisaiqi/pdf_utils.py b/rpa_contract/pdf_utils.py similarity index 80% rename from rpa_yisaiqi/pdf_utils.py rename to rpa_contract/pdf_utils.py index 272a28e..78b40e8 100644 --- a/rpa_yisaiqi/pdf_utils.py +++ b/rpa_contract/pdf_utils.py @@ -1,4 +1,4 @@ -"""PDF utilities for YiSaiQi contract drafting.""" +"""合同 PDF 合并。""" from __future__ import annotations from pathlib import Path @@ -7,8 +7,7 @@ from pathlib import Path def ensure_merged_pdf(source_paths: list[str], output_path: str) -> str: """Return one PDF path, merging multiple source PDFs when needed. - YiSaiQi must upload only one YYC contract document for the same supplier - and same order date. A single source PDF can be uploaded directly. + 同供应商同日只上传一份 PDF。单文件直接返回路径。 """ if not source_paths: raise ValueError("YYC contract creation requires at least one platform PDF") diff --git a/rpa_contract/pyproject.toml b/rpa_contract/pyproject.toml new file mode 100644 index 0000000..eb49001 --- /dev/null +++ b/rpa_contract/pyproject.toml @@ -0,0 +1,29 @@ +[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 diff --git a/rpa_yisaiqi/selectors/contract_create_selectors.json b/rpa_contract/selectors/contract_create_selectors.json similarity index 100% rename from rpa_yisaiqi/selectors/contract_create_selectors.json rename to rpa_contract/selectors/contract_create_selectors.json diff --git a/rpa_yisaiqi/tests/test_runtime_accept.py b/rpa_contract/tests/test_accept.py similarity index 92% rename from rpa_yisaiqi/tests/test_runtime_accept.py rename to rpa_contract/tests/test_accept.py index d59a391..54d874a 100644 --- a/rpa_yisaiqi/tests/test_runtime_accept.py +++ b/rpa_contract/tests/test_accept.py @@ -1,18 +1,18 @@ -from rpa_yisaiqi.runtime.accept import ( +from accept import ( AcceptanceReport, accept_log_path, artifact_dir, evidence_shot, field_match, ) -from rpa_yisaiqi.runtime.errors import NodeError +from errors import NodeError import pytest def test_each_flow_writes_its_own_accept_file(tmp_path, monkeypatch): monkeypatch.delenv("RPA_ACCEPT_LOG", raising=False) monkeypatch.setattr( - "rpa_yisaiqi.runtime.accept.SCREENSHOT_ROOT", + "accept.SCREENSHOT_ROOT", tmp_path, ) report = AcceptanceReport(flow="ORDER_FETCH") diff --git a/rpa_yisaiqi/tests/test_apa_browser_adapter.py b/rpa_contract/tests/test_apa_browser_adapter.py similarity index 86% rename from rpa_yisaiqi/tests/test_apa_browser_adapter.py rename to rpa_contract/tests/test_apa_browser_adapter.py index 527837a..a817ae4 100644 --- a/rpa_yisaiqi/tests/test_apa_browser_adapter.py +++ b/rpa_contract/tests/test_apa_browser_adapter.py @@ -1,6 +1,6 @@ from pathlib import Path -from rpa_yisaiqi.apa_browser_adapter import REPO_ROOT, resolve_chrome_profile_base +from apa_browser_adapter import REPO_ROOT, resolve_chrome_profile_base 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): - inside = REPO_ROOT / "rpa_yisaiqi" / "runtime" / "chrome-profile" + inside = REPO_ROOT / "rpa_contract" / "screenshots" / "chrome-profile" monkeypatch.delenv("RPA_CHROME_PROFILE", raising=False) profile = resolve_chrome_profile_base(requested=str(inside)) assert profile == (REPO_ROOT.parent / "rpa-chrome-profile").resolve() diff --git a/rpa_contract/tests/test_errors.py b/rpa_contract/tests/test_errors.py new file mode 100644 index 0000000..75260a8 --- /dev/null +++ b/rpa_contract/tests/test_errors.py @@ -0,0 +1,15 @@ +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" diff --git a/rpa_contract/uv.lock b/rpa_contract/uv.lock new file mode 100644 index 0000000..91263d6 --- /dev/null +++ b/rpa_contract/uv.lock @@ -0,0 +1,480 @@ +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" }, +] diff --git a/rpa_mock/mock_orders.py b/rpa_mock/mock_orders.py deleted file mode 100644 index 79da8d2..0000000 --- a/rpa_mock/mock_orders.py +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env python3 -""" -Mock 医药城/小药药订单数据生成器 -用于在没有真实医药平台访问权限时,模拟 ORDER_FETCH 节点的输出 -""" -import pandas as pd -from datetime import datetime, timedelta -import random -import os - -# 假订单数据模板 -MOCK_SUPPLIERS = [ - "重庆亿昊药业", - "华润医药", - "国药控股", - "上海医药" -] - -MOCK_PRODUCTS = [ - {"name": "阿卡波糖片", "spec": "50mg*30片", "manufacturer": "示例制药", "approval_no": "国药准字H00000001"}, - {"name": "二甲双胍片", "spec": "0.5g*60片", "manufacturer": "示例制药", "approval_no": "国药准字H00000002"}, - {"name": "格列美脲片", "spec": "2mg*10片", "manufacturer": "示例制药", "approval_no": "国药准字H00000003"}, - {"name": "阿托伐他汀钙片", "spec": "10mg*7片", "manufacturer": "示例制药", "approval_no": "国药准字H00000004"}, -] - -def generate_mock_orders(supplier_name: str, order_date: str, source_type: str = "YYC", item_count: int = 5): - """ - 生成模拟订单Excel - - Args: - supplier_name: 供应商名称 - order_date: 订单日期 YYYY-MM-DD - source_type: 来源类型 YYC/XYY/OFFLINE - item_count: 订单明细行数 - - Returns: - 生成的Excel文件路径 - """ - rows = [] - for i in range(item_count): - product = random.choice(MOCK_PRODUCTS) - rows.append({ - "订单号": f"DD-{order_date.replace('-', '')}-{i+1:03d}", - "订单日期": order_date, - "供应商名称": supplier_name, - "药品通用名": product["name"], - "规格": product["spec"], - "单位": "盒", - "数量": random.randint(10, 500), - "单价": round(random.uniform(5.0, 200.0), 2), - "生产厂家": product["manufacturer"], - "批准文号": product["approval_no"], - }) - - df = pd.DataFrame(rows) - - # 生成文件路径 - output_dir = "/tmp/mock_rpa_data" - os.makedirs(output_dir, exist_ok=True) - - filename = f"orders_{supplier_name}_{order_date}_{source_type}.xlsx" - filepath = os.path.join(output_dir, filename) - - df.to_excel(filepath, index=False, sheet_name="订单明细") - print(f"✓ 生成模拟订单: {filepath}") - print(f" - 供应商: {supplier_name}") - print(f" - 订单日期: {order_date}") - print(f" - 明细行数: {len(rows)}") - - return filepath - - -if __name__ == "__main__": - # 生成几个测试订单 - test_date = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d") - - print("生成模拟订单数据...\n") - - # 医药城订单(线上) - generate_mock_orders("重庆亿昊药业", test_date, "YYC", item_count=8) - - # 小药药订单(线上) - generate_mock_orders("华润医药", test_date, "XYY", item_count=5) - - # 线下手工单 - generate_mock_orders("国药控股", test_date, "OFFLINE", item_count=3) - - print("\n✓ 全部完成,文件位于: /tmp/mock_rpa_data/") - diff --git a/rpa_mock/order_fetch_mock.py b/rpa_mock/order_fetch_mock.py deleted file mode 100644 index 724e754..0000000 --- a/rpa_mock/order_fetch_mock.py +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env python3 -""" -ORDER_FETCH 节点 Mock 实现 -模拟从医药城/小药药导出订单,实际返回本地生成的假数据 -""" -import requests -import hashlib -from pathlib import Path -from mock_orders import generate_mock_orders -from datetime import datetime, timedelta - - -STATE_CENTER_URL = "http://localhost:8000" # 状态中心地址 -AGENT_TOKEN = "dev-agent-token-12345" # 开发令牌(和state_center的配置一致) - - -def calculate_file_hash(filepath: str) -> str: - """计算文件SHA256哈希""" - sha256 = hashlib.sha256() - with open(filepath, 'rb') as f: - for chunk in iter(lambda: f.read(4096), b''): - sha256.update(chunk) - return f"sha256:{sha256.hexdigest()}" - - -def mock_order_fetch(job_id: str, order_date: str, source_type: str = "YYC"): - """ - 模拟 ORDER_FETCH 节点执行 - - Args: - job_id: 单据ID - order_date: 订单日期 - source_type: 来源类型 YYC/XYY/OFFLINE - """ - print(f"\n{'='*60}") - print(f"🤖 [Mock RPA] ORDER_FETCH 开始执行") - print(f" Job ID: {job_id}") - print(f" 订单日期: {order_date}") - print(f" 来源: {source_type}") - print(f"{'='*60}\n") - - # 1. 调用 start 回调 - print("📞 调用状态中心 /start 回调...") - start_resp = requests.post( - f"{STATE_CENTER_URL}/api/jobs/{job_id}/nodes/ORDER_FETCH/start", - headers={"X-Agent-Token": AGENT_TOKEN}, - json={ - "attemptNo": 1, - "executorRunId": f"MOCK-RUN-{datetime.now().strftime('%Y%m%d%H%M%S')}", - "executorPlatform": "mock-rpa", - "operator": "mock-script" - } - ) - print(f" 响应: {start_resp.status_code} - {start_resp.json()}\n") - - # 2. 生成假订单数据 - print("📦 生成模拟订单数据...") - supplier_name = "重庆亿昊药业" if source_type == "YYC" else "华润医药" - excel_path = generate_mock_orders(supplier_name, order_date, source_type, item_count=8) - print() - - # 3. 计算文件哈希 - file_hash = calculate_file_hash(excel_path) - - # 4. 调用 complete 回调 - print("📞 调用状态中心 /complete 回调...") - order_nos = [f"DD-{order_date.replace('-', '')}-{i+1:03d}" for i in range(8)] - - complete_resp = requests.post( - f"{STATE_CENTER_URL}/api/jobs/{job_id}/nodes/ORDER_FETCH/complete", - headers={"X-Agent-Token": AGENT_TOKEN}, - json={ - "attemptNo": 1, - "executorRunId": f"MOCK-RUN-{datetime.now().strftime('%Y%m%d%H%M%S')}", - "executorPlatform": "mock-rpa", - "operator": "mock-script", - "summary": { - "orderCount": 8, - "supplierList": [supplier_name], - "orderNoList": order_nos - }, - "artifacts": [ - { - "artifactType": "SOURCE_EXCEL", - "storagePath": excel_path, - "fileHash": file_hash - } - ] - } - ) - print(f" 响应: {complete_resp.status_code}") - result = complete_resp.json() - print(f" next_node: {result.get('next_node')}") - print(f" job_status: {result.get('job_status')}\n") - - print(f"✅ ORDER_FETCH 完成!下一节点: {result.get('next_node')}\n") - - return result - - -if __name__ == "__main__": - # 示例:模拟昨天的医药城订单 - yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d") - job_id = f"YYC-重庆亿昊药业-{yesterday.replace('-', '')}-001" - - mock_order_fetch(job_id, yesterday, "YYC") diff --git a/rpa_oms/README.md b/rpa_oms/README.md index ebf4606..630a46b 100644 --- a/rpa_oms/README.md +++ b/rpa_oms/README.md @@ -1,38 +1,33 @@ # rpa_oms -订单中心四接口。本期只开放接口 1:销售商品对码数据接收。 +订单中心四个 HTTP 口,一个 FastAPI。HTTP 收件不同步跑作业。作业在 `rpa_selection` / `rpa_cart` / `rpa_contract` / `rpa_rebate`。 + +口径:`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` - 鉴权:header `X-Oms-Token`,环境变量 `OMS_INBOUND_TOKEN` - 落表:`rpa_sales_goods_mapping_data`(`deal_flag=0`) -- 请求里不跑选品。选品班次在仓库根目录 `选品流程/`,按条形码搜两平台、四级仓序拆量后写 `rpa_cart_settlement_goods_data` +- 请求里不跑选品。选品班次在 `rpa_selection/`,按条形码搜两平台、四级仓序拆量后写 `rpa_cart_settlement_goods_data` -## 本地(内存仓库) - -```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。单测用内存替身,不连库:`uv run pytest -v`。 ## 真库 Oracle 11.2 需要 Instant Client(thick)。密码只放本机 `rpa_oms/.env`(已 gitignore),不要写进仓库、README、命令行历史。 ```bash -export OMS_REPO=oracle -export OMS_INBOUND_TOKEN=... +cd rpa_oms +uv sync --python 3.12 +export OMS_INBOUND_TOKEN=dev-oms-token export RPA_ORACLE_HOST=192.168.12.244 export RPA_ORACLE_PORT=1521 export RPA_ORACLE_SERVICE=rpa @@ -41,6 +36,7 @@ export RPA_ORACLE_PASSWORD=... export RPA_ORACLE_THICK=1 # 如需指定 Instant Client 目录: # export RPA_ORACLE_CLIENT_LIB=/path/to/instantclient +uv run uvicorn app.main:app --reload --port 8001 ``` 缺 `RPA_ORACLE_HOST` / 用户 / 密码时,收件接口回 503。 diff --git a/rpa_oms/app/inbound/memory_repo.py b/rpa_oms/app/inbound/memory_repo.py index 1dbbf7e..f731a82 100644 --- a/rpa_oms/app/inbound/memory_repo.py +++ b/rpa_oms/app/inbound/memory_repo.py @@ -1,4 +1,4 @@ -"""内存仓库。单测和 OMS_REPO=memory 用,进程退出即丢。 +"""内存仓库。只给单测用,进程退出即丢。运行时走 Oracle。 按 oms_id 判重,已存在就 DuplicateOmsId,不覆盖。 """ diff --git a/rpa_oms/app/main.py b/rpa_oms/app/main.py index fefd5d7..f967490 100644 --- a/rpa_oms/app/main.py +++ b/rpa_oms/app/main.py @@ -7,7 +7,6 @@ """ from __future__ import annotations -import os from typing import Annotated, Any from fastapi import Depends, FastAPI, Header @@ -18,21 +17,14 @@ from .local_env import load_local_env load_local_env() from .inbound.envelope import EnvelopeError, parse_envelope -from .inbound.memory_repo import MemorySalesGoodsRepo from .inbound.repo import RepoUnavailable from .inbound.sales_goods import receive_sales_goods app = FastAPI(title="洁达 RPA × 订单中心", version="0.1") -# 内存仓库做成进程内单例,同一进程多次 POST 才能测到 DUPLICATE。 -_memory_repo = MemorySalesGoodsRepo() - def get_repo(): - # 默认走 Oracle。本机连不上库时 OMS_REPO=memory。 - if os.getenv("OMS_REPO", "oracle") == "memory": - return _memory_repo - # thick 客户端初始化有副作用,用到才 import。 + # 运行时只走 Oracle。单测用 dependency_overrides 注入内存替身。 from .inbound.oracle_repo import OracleSalesGoodsRepo return OracleSalesGoodsRepo.from_env() diff --git a/rpa_rebate/README.md b/rpa_rebate/README.md new file mode 100644 index 0000000..e479add --- /dev/null +++ b/rpa_rebate/README.md @@ -0,0 +1,15 @@ +# rpa_rebate + +返利班。本包尚未实现。 + +| | | +| --- | --- | +| 读 | 表 3 / 表 4:已有合同号尚未回合同成功的单,以及只录协议的单 | +| 写 | 表 4:搜到合同号则 `contractStatus=2`;录协议后 `actionCode=2`、`agreementStatus=1` | +| 口 | 口 4 | + +与合同同一天两班,建议合同班跑完再跑。查号挂在本班,不单开查状态任务。搜不到不重录合同,留给下一班再搜。 + +## 本期不做 + +返利中心查号、录协议、送审、接口 4 回写。 diff --git a/rpa_rebate/pyproject.toml b/rpa_rebate/pyproject.toml new file mode 100644 index 0000000..9a5892b --- /dev/null +++ b/rpa_rebate/pyproject.toml @@ -0,0 +1,13 @@ +[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 diff --git a/选品流程/.env.example b/rpa_selection/.env.example similarity index 100% rename from 选品流程/.env.example rename to rpa_selection/.env.example diff --git a/选品流程/.python-version b/rpa_selection/.python-version similarity index 100% rename from 选品流程/.python-version rename to rpa_selection/.python-version diff --git a/选品流程/.vscode/extensions.json b/rpa_selection/.vscode/extensions.json similarity index 100% rename from 选品流程/.vscode/extensions.json rename to rpa_selection/.vscode/extensions.json diff --git a/选品流程/.vscode/launch.json b/rpa_selection/.vscode/launch.json similarity index 100% rename from 选品流程/.vscode/launch.json rename to rpa_selection/.vscode/launch.json diff --git a/选品流程/.vscode/settings.json b/rpa_selection/.vscode/settings.json similarity index 100% rename from 选品流程/.vscode/settings.json rename to rpa_selection/.vscode/settings.json diff --git a/选品流程/README.md b/rpa_selection/README.md similarity index 96% rename from 选品流程/README.md rename to rpa_selection/README.md index 06a1edc..efe2fb3 100644 --- a/选品流程/README.md +++ b/rpa_selection/README.md @@ -2,7 +2,7 @@ 班次作业:从表 1 捞 `deal_flag=0`,按**条形码**搜 1 号药城 / 药帮忙,规则选出仓,结果写入表 2。不加购,不推订单中心。 -口径:`资料/RPA订单中心接口开发口径_20260930.md` 第 3 节。 +口径:`docs/RPA订单中心接口开发口径_20260930.md` 第 3 节。 ## 数据流 @@ -33,7 +33,7 @@ rpa_sales_goods_mapping_data (deal_flag=0) ## 环境 ```bash -cd 选品流程 +cd rpa_selection uv sync --python 3.12 uv run playwright install chromium ``` @@ -50,7 +50,7 @@ Oracle 与 `rpa_oms` 同一套变量:`RPA_ORACLE_HOST` / `PORT` / `SERVICE` / ## 运行 ```bash -cd 选品流程 +cd rpa_selection uv run pytest -v uv run python main.py --limit 5 uv run python main.py --platform yaoex @@ -59,7 +59,7 @@ uv run python main.py --platform yaoex Windows: ```powershell -cd 选品流程 +cd rpa_selection uv sync --python 3.12 Copy-Item -LiteralPath .env.example -Destination .env # 商城账号写本机 .env;Oracle 也可只留在 rpa_oms/.env diff --git a/选品流程/auth_login.py b/rpa_selection/auth_login.py similarity index 100% rename from 选品流程/auth_login.py rename to rpa_selection/auth_login.py diff --git a/选品流程/db.py b/rpa_selection/db.py similarity index 100% rename from 选品流程/db.py rename to rpa_selection/db.py diff --git a/选品流程/http_util.py b/rpa_selection/http_util.py similarity index 100% rename from 选品流程/http_util.py rename to rpa_selection/http_util.py diff --git a/选品流程/job.py b/rpa_selection/job.py similarity index 100% rename from 选品流程/job.py rename to rpa_selection/job.py diff --git a/选品流程/main.py b/rpa_selection/main.py similarity index 100% rename from 选品流程/main.py rename to rpa_selection/main.py diff --git a/选品流程/mall_config.py b/rpa_selection/mall_config.py similarity index 100% rename from 选品流程/mall_config.py rename to rpa_selection/mall_config.py diff --git a/选品流程/output/技术可行性报告_双商城查价_20260904.md b/rpa_selection/output/技术可行性报告_双商城查价_20260904.md similarity index 100% rename from 选品流程/output/技术可行性报告_双商城查价_20260904.md rename to rpa_selection/output/技术可行性报告_双商城查价_20260904.md diff --git a/选品流程/output/结果/.gitkeep b/rpa_selection/output/结果/.gitkeep similarity index 100% rename from 选品流程/output/结果/.gitkeep rename to rpa_selection/output/结果/.gitkeep diff --git a/选品流程/persist.py b/rpa_selection/persist.py similarity index 100% rename from 选品流程/persist.py rename to rpa_selection/persist.py diff --git a/选品流程/pipeline.py b/rpa_selection/pipeline.py similarity index 100% rename from 选品流程/pipeline.py rename to rpa_selection/pipeline.py diff --git a/选品流程/pyproject.toml b/rpa_selection/pyproject.toml similarity index 78% rename from 选品流程/pyproject.toml rename to rpa_selection/pyproject.toml index d483e02..dd9ea5e 100644 --- a/选品流程/pyproject.toml +++ b/rpa_selection/pyproject.toml @@ -1,7 +1,7 @@ [project] -name = "jieda-procure" +name = "rpa-selection" version = "0.1.0" -description = "洁达选品班次:表1 deal_flag=0 → 双平台搜品 → 写表2" +description = "洁达选品班次(rpa_selection):表1 deal_flag=0 → 双平台搜品 → 写表2" readme = "README.md" requires-python = ">=3.12" dependencies = [ diff --git a/选品流程/repo.py b/rpa_selection/repo.py similarity index 100% rename from 选品流程/repo.py rename to rpa_selection/repo.py diff --git a/选品流程/rules.py b/rpa_selection/rules.py similarity index 100% rename from 选品流程/rules.py rename to rpa_selection/rules.py diff --git a/选品流程/search_yaoex.py b/rpa_selection/search_yaoex.py similarity index 100% rename from 选品流程/search_yaoex.py rename to rpa_selection/search_yaoex.py diff --git a/选品流程/search_ybm.py b/rpa_selection/search_ybm.py similarity index 100% rename from 选品流程/search_ybm.py rename to rpa_selection/search_ybm.py diff --git a/选品流程/secrets.example.json b/rpa_selection/secrets.example.json similarity index 100% rename from 选品流程/secrets.example.json rename to rpa_selection/secrets.example.json diff --git a/选品流程/start.ps1 b/rpa_selection/start.ps1 similarity index 95% rename from 选品流程/start.ps1 rename to rpa_selection/start.ps1 index cda89a0..05fea94 100644 --- a/选品流程/start.ps1 +++ b/rpa_selection/start.ps1 @@ -53,7 +53,7 @@ if (Test-Path -LiteralPath $venvPy) { $uv = Get-Command uv -ErrorAction SilentlyContinue if ($null -eq $uv) { - throw "找不到 uv,也没有 选品流程\.venv\Scripts\python.exe" + throw "找不到 uv,也没有 rpa_selection\.venv\Scripts\python.exe" } $uvArgs = @('run', '--python', '3.12', 'python') + $pyArgs diff --git a/选品流程/tests/test_cli.py b/rpa_selection/tests/test_cli.py similarity index 100% rename from 选品流程/tests/test_cli.py rename to rpa_selection/tests/test_cli.py diff --git a/选品流程/tests/test_decrypt.py b/rpa_selection/tests/test_decrypt.py similarity index 100% rename from 选品流程/tests/test_decrypt.py rename to rpa_selection/tests/test_decrypt.py diff --git a/选品流程/tests/test_job.py b/rpa_selection/tests/test_job.py similarity index 100% rename from 选品流程/tests/test_job.py rename to rpa_selection/tests/test_job.py diff --git a/选品流程/tests/test_legacy.py b/rpa_selection/tests/test_legacy.py similarity index 100% rename from 选品流程/tests/test_legacy.py rename to rpa_selection/tests/test_legacy.py diff --git a/选品流程/tests/test_mapping.py b/rpa_selection/tests/test_mapping.py similarity index 100% rename from 选品流程/tests/test_mapping.py rename to rpa_selection/tests/test_mapping.py diff --git a/选品流程/tests/test_oracle_repo.py b/rpa_selection/tests/test_oracle_repo.py similarity index 100% rename from 选品流程/tests/test_oracle_repo.py rename to rpa_selection/tests/test_oracle_repo.py diff --git a/选品流程/tests/test_persist.py b/rpa_selection/tests/test_persist.py similarity index 100% rename from 选品流程/tests/test_persist.py rename to rpa_selection/tests/test_persist.py diff --git a/选品流程/tests/test_pipeline.py b/rpa_selection/tests/test_pipeline.py similarity index 100% rename from 选品流程/tests/test_pipeline.py rename to rpa_selection/tests/test_pipeline.py diff --git a/选品流程/tests/test_rules.py b/rpa_selection/tests/test_rules.py similarity index 100% rename from 选品流程/tests/test_rules.py rename to rpa_selection/tests/test_rules.py diff --git a/选品流程/tests/test_search_key.py b/rpa_selection/tests/test_search_key.py similarity index 100% rename from 选品流程/tests/test_search_key.py rename to rpa_selection/tests/test_search_key.py diff --git a/选品流程/util.py b/rpa_selection/util.py similarity index 100% rename from 选品流程/util.py rename to rpa_selection/util.py diff --git a/选品流程/uv.lock b/rpa_selection/uv.lock similarity index 99% rename from 选品流程/uv.lock rename to rpa_selection/uv.lock index 3121897..185b4f0 100644 --- a/选品流程/uv.lock +++ b/rpa_selection/uv.lock @@ -384,35 +384,6 @@ 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 = "jieda-procure" -version = "0.1.0" -source = { virtual = "." } -dependencies = [ - { name = "oracledb" }, - { name = "playwright" }, - { name = "pycryptodome" }, - { name = "requests" }, - { name = "urllib3" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pytest" }, -] - -[package.metadata] -requires-dist = [ - { name = "oracledb", specifier = ">=2.4.1" }, - { name = "playwright", specifier = ">=1.49.0" }, - { name = "pycryptodome", specifier = ">=3.23.0" }, - { name = "requests", specifier = ">=2.34.2" }, - { name = "urllib3", specifier = ">=2.0.0" }, -] - -[package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=8.0.0" }] - [[package]] name = "oracledb" version = "26.0.1" @@ -585,6 +556,35 @@ 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-selection" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "oracledb" }, + { name = "playwright" }, + { name = "pycryptodome" }, + { name = "requests" }, + { name = "urllib3" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "oracledb", specifier = ">=2.4.1" }, + { name = "playwright", specifier = ">=1.49.0" }, + { name = "pycryptodome", specifier = ">=3.23.0" }, + { name = "requests", specifier = ">=2.34.2" }, + { name = "urllib3", specifier = ">=2.0.0" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.0.0" }] + [[package]] name = "typing-extensions" version = "4.16.0" diff --git a/rpa_yisaiqi/README.md b/rpa_yisaiqi/README.md deleted file mode 100644 index b13d101..0000000 --- a/rpa_yisaiqi/README.md +++ /dev/null @@ -1,191 +0,0 @@ -# 艺赛旗联调包 - -这个目录是 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 | diff --git a/rpa_yisaiqi/__init__.py b/rpa_yisaiqi/__init__.py deleted file mode 100644 index a13d19b..0000000 --- a/rpa_yisaiqi/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""YiSaiQi RPA integration helpers.""" - diff --git a/rpa_yisaiqi/__main__.py b/rpa_yisaiqi/__main__.py deleted file mode 100644 index 53d8433..0000000 --- a/rpa_yisaiqi/__main__.py +++ /dev/null @@ -1,59 +0,0 @@ -"""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() diff --git a/rpa_yisaiqi/_flow_state.py b/rpa_yisaiqi/_flow_state.py deleted file mode 100644 index 41daedf..0000000 --- a/rpa_yisaiqi/_flow_state.py +++ /dev/null @@ -1,17 +0,0 @@ -# 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 diff --git a/rpa_yisaiqi/agent.py b/rpa_yisaiqi/agent.py deleted file mode 100644 index 10f9d83..0000000 --- a/rpa_yisaiqi/agent.py +++ /dev/null @@ -1,125 +0,0 @@ -"""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 diff --git a/rpa_yisaiqi/build_multinode_seq.py b/rpa_yisaiqi/build_multinode_seq.py deleted file mode 100644 index 64faae3..0000000 --- a/rpa_yisaiqi/build_multinode_seq.py +++ /dev/null @@ -1,358 +0,0 @@ -# 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() diff --git a/rpa_yisaiqi/callback_client.py b/rpa_yisaiqi/callback_client.py deleted file mode 100644 index a8c80a7..0000000 --- a/rpa_yisaiqi/callback_client.py +++ /dev/null @@ -1,166 +0,0 @@ -"""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) diff --git a/rpa_yisaiqi/flows/contract_create_flow.json b/rpa_yisaiqi/flows/contract_create_flow.json deleted file mode 100644 index d3fa6be..0000000 --- a/rpa_yisaiqi/flows/contract_create_flow.json +++ /dev/null @@ -1,151 +0,0 @@ -{ - "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')" - } - ] -} diff --git a/rpa_yisaiqi/flows/contract_create_preview_flow.json b/rpa_yisaiqi/flows/contract_create_preview_flow.json deleted file mode 100644 index 72906b3..0000000 --- a/rpa_yisaiqi/flows/contract_create_preview_flow.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "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" } - ] -} diff --git a/rpa_yisaiqi/requirements.txt b/rpa_yisaiqi/requirements.txt deleted file mode 100644 index 2197c68..0000000 --- a/rpa_yisaiqi/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -pypdf==5.1.0 -selenium>=4.15.0 -webdriver-manager>=4.0.0 -requests>=2.31.0 diff --git a/rpa_yisaiqi/runners/__init__.py b/rpa_yisaiqi/runners/__init__.py deleted file mode 100644 index 1213286..0000000 --- a/rpa_yisaiqi/runners/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Node runners. Mock now, Windows page runners later.""" diff --git a/rpa_yisaiqi/runners/mock.py b/rpa_yisaiqi/runners/mock.py deleted file mode 100644 index 37a5aa8..0000000 --- a/rpa_yisaiqi/runners/mock.py +++ /dev/null @@ -1,87 +0,0 @@ -"""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", - }], - } diff --git a/rpa_yisaiqi/runtime/__init__.py b/rpa_yisaiqi/runtime/__init__.py deleted file mode 100644 index 8de8ef4..0000000 --- a/rpa_yisaiqi/runtime/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Portable node runtime shared by mock and Windows runners.""" diff --git a/rpa_yisaiqi/runtime/payloads.py b/rpa_yisaiqi/runtime/payloads.py deleted file mode 100644 index d75ca22..0000000 --- a/rpa_yisaiqi/runtime/payloads.py +++ /dev/null @@ -1,37 +0,0 @@ -"""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": [], - } diff --git a/rpa_yisaiqi/runtime/shell.py b/rpa_yisaiqi/runtime/shell.py deleted file mode 100644 index 31d2091..0000000 --- a/rpa_yisaiqi/runtime/shell.py +++ /dev/null @@ -1,28 +0,0 @@ -"""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)) diff --git a/rpa_yisaiqi/studio/Main.seq b/rpa_yisaiqi/studio/Main.seq deleted file mode 100644 index 7ad7f22..0000000 --- a/rpa_yisaiqi/studio/Main.seq +++ /dev/null @@ -1,790 +0,0 @@ -{ - "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" -} diff --git a/rpa_yisaiqi/studio/Project.pro b/rpa_yisaiqi/studio/Project.pro deleted file mode 100644 index 982d5bb..0000000 --- a/rpa_yisaiqi/studio/Project.pro +++ /dev/null @@ -1,46 +0,0 @@ -{ - "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": [ - ] - } - ] -} diff --git a/rpa_yisaiqi/studio/codes/GlobalFun.py b/rpa_yisaiqi/studio/codes/GlobalFun.py deleted file mode 100644 index d4ff247..0000000 --- a/rpa_yisaiqi/studio/codes/GlobalFun.py +++ /dev/null @@ -1,22 +0,0 @@ -# coding=utf-8 -# 编译日期:2026-08-12 10:13:31 -# 版权所有:www.i-search.com.cn - - - - - - - - - - - - - - - - -# 测试运行或调试以上全局函数入口 -if __name__ == '__main__': - pass \ No newline at end of file diff --git a/rpa_yisaiqi/studio/codes/Main.py b/rpa_yisaiqi/studio/codes/Main.py deleted file mode 100644 index ca56770..0000000 --- a/rpa_yisaiqi/studio/codes/Main.py +++ /dev/null @@ -1,200 +0,0 @@ -# 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 -p -j ') - for opt, arg in opts: - if opt == '-h': - print ('robot.py -r -p -j ') - 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,[])) diff --git a/rpa_yisaiqi/studio/codes/modules/__init__.py b/rpa_yisaiqi/studio/codes/modules/__init__.py deleted file mode 100644 index 75eda77..0000000 --- a/rpa_yisaiqi/studio/codes/modules/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -# 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") diff --git a/rpa_yisaiqi/studio/codes/modules/readme.txt b/rpa_yisaiqi/studio/codes/modules/readme.txt deleted file mode 100644 index 0e7ca99..0000000 --- a/rpa_yisaiqi/studio/codes/modules/readme.txt +++ /dev/null @@ -1,86 +0,0 @@ - -# 更多示例代码 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") # 设置服务器变量 -''' \ No newline at end of file diff --git a/rpa_yisaiqi/studio/codes/modules/resources/elements.json b/rpa_yisaiqi/studio/codes/modules/resources/elements.json deleted file mode 100644 index 903482d..0000000 --- a/rpa_yisaiqi/studio/codes/modules/resources/elements.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "version": "2.0" -} diff --git a/rpa_yisaiqi/studio/codes/run_contract.py b/rpa_yisaiqi/studio/codes/run_contract.py deleted file mode 100644 index f5d7531..0000000 --- a/rpa_yisaiqi/studio/codes/run_contract.py +++ /dev/null @@ -1,148 +0,0 @@ -# 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()) diff --git a/rpa_yisaiqi/studio/codes/sitecustomize.py b/rpa_yisaiqi/studio/codes/sitecustomize.py deleted file mode 100644 index f720ef3..0000000 --- a/rpa_yisaiqi/studio/codes/sitecustomize.py +++ /dev/null @@ -1,64 +0,0 @@ -# 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) diff --git a/rpa_yisaiqi/studio/conf/marketConf.json b/rpa_yisaiqi/studio/conf/marketConf.json deleted file mode 100644 index 0637a08..0000000 --- a/rpa_yisaiqi/studio/conf/marketConf.json +++ /dev/null @@ -1 +0,0 @@ -[] \ No newline at end of file diff --git a/rpa_yisaiqi/studio/project.html b/rpa_yisaiqi/studio/project.html deleted file mode 100644 index 5fffece..0000000 --- a/rpa_yisaiqi/studio/project.html +++ /dev/null @@ -1,273 +0,0 @@ - - - - -流程组件展示 - - - -
-
I-Search 流程组件展示图
-
- - -
- -
- - -
-
-
- -
-
-
- - - - - \ No newline at end of file diff --git a/rpa_yisaiqi/tests/test_agent.py b/rpa_yisaiqi/tests/test_agent.py deleted file mode 100644 index 5506aa2..0000000 --- a/rpa_yisaiqi/tests/test_agent.py +++ /dev/null @@ -1,240 +0,0 @@ -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") diff --git a/rpa_yisaiqi/tests/test_callback_client.py b/rpa_yisaiqi/tests/test_callback_client.py deleted file mode 100644 index 8d6d07a..0000000 --- a/rpa_yisaiqi/tests/test_callback_client.py +++ /dev/null @@ -1,78 +0,0 @@ -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 diff --git a/rpa_yisaiqi/tests/test_runtime_shell.py b/rpa_yisaiqi/tests/test_runtime_shell.py deleted file mode 100644 index 2411128..0000000 --- a/rpa_yisaiqi/tests/test_runtime_shell.py +++ /dev/null @@ -1,75 +0,0 @@ -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" diff --git a/rpa_yisaiqi/yisaiqi_adapter_template.py b/rpa_yisaiqi/yisaiqi_adapter_template.py deleted file mode 100644 index fdc4c7b..0000000 --- a/rpa_yisaiqi/yisaiqi_adapter_template.py +++ /dev/null @@ -1,42 +0,0 @@ -"""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=}") - diff --git a/run_mock_chain.py b/run_mock_chain.py deleted file mode 100644 index 0d84b1c..0000000 --- a/run_mock_chain.py +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env python3 -"""Thin wrapper around the portable mock agent. - -Prefer: - python -m rpa_yisaiqi --create --auto-human -""" -from rpa_yisaiqi.__main__ import main - - -if __name__ == "__main__": - main() diff --git a/scripts/__init__.py b/scripts/__init__.py deleted file mode 100644 index aab9e1c..0000000 --- a/scripts/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Project development utilities.""" diff --git a/scripts/dev.py b/scripts/dev.py deleted file mode 100644 index fbe1bb3..0000000 --- a/scripts/dev.py +++ /dev/null @@ -1,292 +0,0 @@ -"""Start and inspect the local state-center integration stack. - -Usage: - python scripts/dev.py start - python scripts/dev.py status - python scripts/dev.py stop -""" -from __future__ import annotations - -import argparse -import json -import os -import signal -import socket -import subprocess -import sys -import time -from pathlib import Path -from urllib.error import URLError -from urllib.request import urlopen - -ROOT = Path(__file__).resolve().parents[1] -RUNTIME_DIR = ROOT / ".runtime" -PID_FILE = RUNTIME_DIR / "dev-pids.json" -MYSQL_CONTAINER = "rpa-center-mysql" -MYSQL_VOLUME = "rpa-center-mysql-data" -MYSQL_IMAGE = "mysql:8" - - -def build_backend_env(source: dict[str, str] | None = None) -> dict[str, str]: - """Return backend environment with safe local defaults.""" - env = dict(os.environ if source is None else source) - env.setdefault("DEV_MODE", "true") - env.setdefault( - "MYSQL_DSN", - "mysql+pymysql://root:root@127.0.0.1:3306/rpa_center?charset=utf8mb4", - ) - env.setdefault("AGENT_TOKEN", "dev-agent-token") - return env - - -def _run(command: list[str], *, check: bool = True, capture: bool = False) -> subprocess.CompletedProcess[str]: - return subprocess.run( - command, - cwd=ROOT, - check=check, - text=True, - capture_output=capture, - ) - - -def _command_exists(command: str) -> bool: - return subprocess.run( - [command, "--version"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ).returncode == 0 - - -def _backend_python() -> str: - configured = os.getenv("STATE_CENTER_PYTHON") - if configured: - return configured - local_python = ROOT / ".venv_test" / ("Scripts/python.exe" if os.name == "nt" else "bin/python") - if local_python.exists(): - return str(local_python) - return sys.executable - - -def _ensure_backend_runtime() -> str: - python = _backend_python() - probe = subprocess.run( - [python, "-c", "import fastapi, pymysql, uvicorn, cryptography"], - cwd=ROOT, - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - text=True, - check=False, - ) - if probe.returncode != 0: - raise RuntimeError( - f"Backend dependencies are missing for {python}. " - f"Install them with: {python} -m pip install -r state_center/requirements.txt" - ) - return python - - -def _docker_container_state() -> str | None: - if not _command_exists("docker"): - return None - result = _run( - ["docker", "inspect", "-f", "{{.State.Status}}", MYSQL_CONTAINER], - check=False, - capture=True, - ) - if result.returncode != 0: - return None - return result.stdout.strip() - - -def ensure_mysql() -> None: - if not _command_exists("docker"): - raise RuntimeError("Docker is required to start the local MySQL container.") - - state = _docker_container_state() - if state == "running": - return - if state: - _run(["docker", "start", MYSQL_CONTAINER]) - return - - _run( - [ - "docker", - "run", - "--detach", - "--name", - MYSQL_CONTAINER, - "--env", - "MYSQL_ROOT_PASSWORD=root", - "--env", - "MYSQL_DATABASE=rpa_center", - "--publish", - "3306:3306", - "--volume", - f"{MYSQL_VOLUME}:/var/lib/mysql", - "--volume", - f"{ROOT / 'state_center' / 'sql' / 'schema.sql'}:/docker-entrypoint-initdb.d/001-schema.sql:ro", - MYSQL_IMAGE, - ] - ) - - -def _port_open(host: str, port: int) -> bool: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.settimeout(0.5) - return sock.connect_ex((host, port)) == 0 - - -def wait_for_port(host: str, port: int, timeout: float = 30) -> None: - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - if _port_open(host, port): - return - time.sleep(0.5) - raise RuntimeError(f"Timed out waiting for {host}:{port}.") - - -def _spawn(name: str, command: list[str], cwd: Path, env: dict[str, str]) -> int: - RUNTIME_DIR.mkdir(exist_ok=True) - log_path = RUNTIME_DIR / f"{name}.log" - log_file = log_path.open("a", encoding="utf-8") - kwargs: dict[str, object] = { - "cwd": cwd, - "env": env, - "stdout": log_file, - "stderr": subprocess.STDOUT, - "stdin": subprocess.DEVNULL, - } - if os.name == "nt": - kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP - else: - kwargs["start_new_session"] = True - process = subprocess.Popen(command, **kwargs) - log_file.close() - return process.pid - - -def _read_pids() -> dict[str, int]: - if not PID_FILE.exists(): - return {} - try: - return {key: int(value) for key, value in json.loads(PID_FILE.read_text()).items()} - except (OSError, ValueError, TypeError): - return {} - - -def _write_pids(pids: dict[str, int]) -> None: - RUNTIME_DIR.mkdir(exist_ok=True) - PID_FILE.write_text(json.dumps(pids, indent=2), encoding="utf-8") - - -def _pid_alive(pid: int) -> bool: - if pid <= 0: - return False - try: - os.kill(pid, 0) - except OSError: - return False - return True - - -def _service_alive(pid: int, host: str, port: int) -> bool: - return _pid_alive(pid) or _port_open(host, port) - - -def start() -> None: - ensure_mysql() - wait_for_port("127.0.0.1", 3306) - backend_python = _ensure_backend_runtime() - - pids = _read_pids() - backend_env = build_backend_env() - if not _service_alive(pids.get("backend", -1), "127.0.0.1", 8000): - pids["backend"] = _spawn( - "backend", - [ - backend_python, - "-m", - "uvicorn", - "state_center.app.main:app", - "--host", - "0.0.0.0", - "--port", - "8000", - ], - ROOT, - backend_env, - ) - if not _service_alive(pids.get("frontend", -1), "127.0.0.1", 5173): - pids["frontend"] = _spawn( - "frontend", - ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"], - ROOT / "state_center_web", - os.environ.copy(), - ) - _write_pids(pids) - wait_for_port("127.0.0.1", 8000) - wait_for_port("127.0.0.1", 5173) - print("MySQL: 127.0.0.1:3306") - print("Backend: http://127.0.0.1:8000") - print("Frontend: http://127.0.0.1:5173") - - -def status() -> int: - db = _port_open("127.0.0.1", 3306) - backend = False - backend_detail = "" - try: - with urlopen("http://127.0.0.1:8000/health", timeout=2) as response: - payload = json.loads(response.read().decode("utf-8")) - backend = response.status == 200 and payload.get("database") == "ok" - backend_detail = json.dumps(payload, ensure_ascii=False) - except (OSError, URLError, json.JSONDecodeError) as exc: - backend_detail = str(exc) - frontend = _port_open("127.0.0.1", 5173) - print(f"MySQL 3306: {'UP' if db else 'DOWN'}") - print(f"Backend 8000: {'UP' if backend else 'DOWN'} {backend_detail}".rstrip()) - print(f"Frontend 5173: {'UP' if frontend else 'DOWN'}") - return 0 if db and backend and frontend else 1 - - -def _stop_pid(pid: int) -> None: - try: - if os.name == "nt": - os.kill(pid, signal.SIGTERM) - else: - os.killpg(os.getpgid(pid), signal.SIGTERM) - except OSError: - return - - -def stop() -> None: - for pid in _read_pids().values(): - _stop_pid(pid) - if _docker_container_state() == "running": - _run(["docker", "stop", MYSQL_CONTAINER], check=False) - if PID_FILE.exists(): - PID_FILE.unlink() - print("Local integration stack stopped.") - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("command", choices=("start", "status", "stop")) - args = parser.parse_args() - try: - if args.command == "start": - start() - return 0 - if args.command == "status": - return status() - stop() - return 0 - except (OSError, RuntimeError, subprocess.CalledProcessError) as exc: - print(f"dev launcher error: {exc}", file=sys.stderr) - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/state_center/README.md b/state_center/README.md deleted file mode 100644 index dc1efb2..0000000 --- a/state_center/README.md +++ /dev/null @@ -1,124 +0,0 @@ -# 状态中心(采购主链RPA一期 · 第1步骨架) - -Python + FastAPI + MySQL。库里只存**流程状态 + 对码累积别名 + 证据**;对码货品主数据在 **Oracle**,直连查(不复制)。 - -## 目录 - -``` -state_center/ -├── sql/schema.sql 建库脚本(job 那套 + mapping_config + 证据) -├── app/ -│ ├── config.py 配置(MySQL/Oracle 连接、表名列名,从环境变量读) -│ ├── db.py MySQL 访问(pymysql) -│ ├── match_engine.py 对码引擎(两级命中 + 规格标准化 + Oracle 取数) -│ └── main.py FastAPI:建单 + 回调(含幂等/乱序拒绝)+ 合同审批等待 + 整单查询 -├── tests/test_match_engine.py 对码单测(不依赖 DB/Oracle) -└── requirements.txt -``` - -## 起步 - -以下命令在 macOS/Linux 的终端执行;Windows PowerShell 只需要把激活命令换成下一段对应写法,接口和浏览器端完全相同。 - -```bash -python -m pip install -r state_center/requirements.txt - -# 1) 建库(先起个 MySQL 8) -mysql -uroot -p < state_center/sql/schema.sql - -# 2) 配连接(默认 root/root@127.0.0.1) -export MYSQL_DSN="mysql+pymysql://root:root@127.0.0.1:3306/rpa_center?charset=utf8mb4" - -# 3) 起服务 -uvicorn state_center.app.main:app --reload --port 8000 -# 打开 http://127.0.0.1:8000/docs 有全部接口可点 -``` - -Windows PowerShell: - -```powershell -py -m venv .venv -.\.venv\Scripts\Activate.ps1 -python -m pip install -r state_center\requirements.txt -$env:DEV_MODE = "true" -$env:MYSQL_DSN = "mysql+pymysql://root:root@127.0.0.1:3306/rpa_center?charset=utf8mb4" -python -m uvicorn state_center.app.main:app --reload --port 8000 -``` - -浏览器端另开一个终端: - -```bash -cd state_center_web -npm install -npm run dev -``` - -然后打开 `http://localhost:5173/`。Windows 和 macOS 均使用标准浏览器访问,不需要 Electron 或系统专用脚本。`DEV_MODE=true` 只用于本地样例货品和节点回调模拟,生产环境不要开启。 - -本地样例联调顺序:建单 → 写入订单明细(`POST /api/jobs/{job_id}/items`) → 开发接口装载样例货品(`POST /api/dev/match/load-fixture-goods`) → 运行对码。真实环境把样例接口替换为 Oracle 货品装载即可。 - -## 验证第1步(不接真实系统,假回调走全链) - -```bash -# 建单 -curl -X POST http://127.0.0.1:8000/api/jobs -H 'Content-Type: application/json' \ - -d '{"source_type":"YYC","order_date":"2026-08-04","supplier_name":"重庆亿昊药业"}' -# 拿到 job_id 后,模拟艺赛旗回调(带 token): -curl -X POST http://127.0.0.1:8000/api/jobs//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//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/ -``` - -## 节点编排(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 -``` diff --git a/state_center/app/__init__.py b/state_center/app/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/state_center/app/config.py b/state_center/app/config.py deleted file mode 100644 index e33a5ff..0000000 --- a/state_center/app/config.py +++ /dev/null @@ -1,51 +0,0 @@ -"""配置:从环境变量读,别把密码写死在代码里。""" -import os - - -class Settings: - # 本地联调功能开关。生产环境保持关闭。 - DEV_MODE = os.getenv("DEV_MODE", "false").lower() == "true" - - # MySQL(状态中心自己的库) - MYSQL_DSN = os.getenv( - "MYSQL_DSN", - "mysql+pymysql://root:root@127.0.0.1:3306/rpa_center?charset=utf8mb4", - ) - - # Oracle(他们的货品主数据,只读)。拿到真实连接串后填这里。 - # 形如: username/password@host:1521/service_name - ORACLE_DSN = os.getenv("ORACLE_DSN", "") - ORACLE_USER = os.getenv("ORACLE_USER", "") - ORACLE_PASSWORD = os.getenv("ORACLE_PASSWORD", "") - - # M5 货品视图(福建货主对码)。账号密码只走环境变量,不进仓库。 - M5_HOST = os.getenv("M5_HOST", "173.1.1.215") - M5_PORT = int(os.getenv("M5_PORT", "1521")) - M5_SERVICE = os.getenv("M5_SERVICE", "m5") - M5_USER = os.getenv("M5_USER", "") - M5_PASSWORD = os.getenv("M5_PASSWORD", "") - M5_SCHEMA = os.getenv("M5_SCHEMA", "m5") - - # Oracle 货品主数据表(真实表名/列名拿到后改这里,先按设计文档 A~G 列占位) - ORACLE_GOODS_TABLE = os.getenv("ORACLE_GOODS_TABLE", "GOODS_MASTER") - ORACLE_COL_COMMON_NAME = os.getenv("ORACLE_COL_COMMON_NAME", "COMMON_NAME") # 通用名 - ORACLE_COL_APPROVAL_NO = os.getenv("ORACLE_COL_APPROVAL_NO", "APPROVAL_NO") # 批准文号 - ORACLE_COL_SPEC = os.getenv("ORACLE_COL_SPEC", "SPEC") # 规格 - ORACLE_COL_UNIT = os.getenv("ORACLE_COL_UNIT", "BASE_UNIT") # 基本单位 - ORACLE_COL_MANUFACTURER = os.getenv("ORACLE_COL_MANUFACTURER", "MANUFACTURER")# 生产厂家 - ORACLE_COL_GOODS_ID = os.getenv("ORACLE_COL_GOODS_ID", "GOODS_ID") # 货品ID - ORACLE_COL_GOODS_CODE = os.getenv("ORACLE_COL_GOODS_CODE", "GOODS_CODE") # 货品编码 - - # 对码货品主数据是否开工时全量 load 进内存(几万行推荐 True,快) - MATCH_LOAD_INTO_MEMORY = os.getenv("MATCH_LOAD_INTO_MEMORY", "true").lower() == "true" - - # RPA 回调鉴权 token - AGENT_TOKEN = os.getenv("AGENT_TOKEN", "dev-agent-token") - - # 合同审批等待(CONTRACT_APPROVE_WAIT):按业务方案定时查,不连续轮询。 - # 到点由调度器触发 RPA 去查一次审批状态,没过就再排下一次。 - CONTRACT_WAIT_INTERVAL_MIN = int(os.getenv("CONTRACT_WAIT_INTERVAL_MIN", "30")) # 每次间隔(分钟),业务方案≈半小时 - CONTRACT_WAIT_MAX_CHECKS = int(os.getenv("CONTRACT_WAIT_MAX_CHECKS", "8")) # 最多查几次,超了升级OPS(8次≈4小时) - - -settings = Settings() diff --git a/state_center/app/db.py b/state_center/app/db.py deleted file mode 100644 index df5d2cf..0000000 --- a/state_center/app/db.py +++ /dev/null @@ -1,21 +0,0 @@ -"""极简 MySQL 访问层(pymysql)。够第1步用,后面要换 SQLAlchemy 也行。""" -import pymysql -from pymysql.cursors import DictCursor - -from .config import settings - - -def _parse_dsn(dsn: str) -> dict: - # mysql+pymysql://user:pass@host:port/db?charset=utf8mb4 - body = dsn.split("://", 1)[1] - creds, rest = body.split("@", 1) - user, password = creds.split(":", 1) - hostport, dbq = rest.split("/", 1) - host, port = (hostport.split(":", 1) + ["3306"])[:2] - db = dbq.split("?", 1)[0] - return dict(host=host, port=int(port), user=user, password=password, database=db) - - -def get_conn(): - cfg = _parse_dsn(settings.MYSQL_DSN) - return pymysql.connect(charset="utf8mb4", cursorclass=DictCursor, autocommit=True, **cfg) diff --git a/state_center/app/m5_client.py b/state_center/app/m5_client.py deleted file mode 100644 index b8120a3..0000000 --- a/state_center/app/m5_client.py +++ /dev/null @@ -1,174 +0,0 @@ -"""M5 货品视图只读查询。账号密码来自环境变量或 connection.local.json。""" -from __future__ import annotations - -import json -import os -from pathlib import Path -from typing import Any - -from .config import settings - -FIXTURE_DIR = Path(__file__).resolve().parents[1] / "fixtures" -M5_DIR = FIXTURE_DIR / "m5" -ORDER_PATH = FIXTURE_DIR / "orders" / "yyc-fujian-20260804.json" -FUJIAN_PATH = M5_DIR / "fujian.json" -LOCAL_CONN_PATH = M5_DIR / "connection.local.json" - - -def _load_json(path: Path) -> dict[str, Any]: - return json.loads(path.read_text(encoding="utf-8")) - - -def load_connection() -> dict[str, Any]: - local: dict[str, Any] = {} - if LOCAL_CONN_PATH.is_file(): - local = _load_json(LOCAL_CONN_PATH) - host = os.getenv("M5_HOST") or local.get("host") or settings.M5_HOST - port = int(os.getenv("M5_PORT") or local.get("port") or settings.M5_PORT) - service = os.getenv("M5_SERVICE") or local.get("serviceName") or settings.M5_SERVICE - user = os.getenv("M5_USER") or local.get("user") or settings.M5_USER - password = os.getenv("M5_PASSWORD") or local.get("password") or settings.M5_PASSWORD - schema = os.getenv("M5_SCHEMA") or local.get("schema") or settings.M5_SCHEMA - return { - "host": host, - "port": port, - "serviceName": service, - "user": user, - "password": password, - "schema": schema, - } - - -def recorded_source_rows(goods_id: str) -> list[dict[str, Any]] | None: - """离线夹具:哈药牌这条 SQL 的 P1 结果,不连 M5。""" - params = lookup_params() - if str(goods_id) != params["expectedGoodsId"]: - return None - return [ - { - "ENTRYID": params["entryId"], - "PRIORITY": 1, - "GOODSTYPE": params["spec"], - "FACTORYNAME": params["manufacturer"], - } - ] - - -def evaluate_priority_source( - rows: list[dict[str, Any]], - *, - entry_id: int, - spec: str, - manufacturer: str, -) -> dict[str, Any]: - """PRIORITY=1 且货主/规格/厂家对上,才算货源齐全。""" - priority_rows = [row for row in rows if str(row.get("PRIORITY") or "") == "1"] - hit = next( - ( - row - for row in priority_rows - if str(row.get("ENTRYID") or "") == str(entry_id) - and str(row.get("GOODSTYPE") or "").strip() == spec.strip() - and str(row.get("FACTORYNAME") or "").strip() == manufacturer.strip() - ), - None, - ) - return { - "source_add_needed": hit is None, - "hit": hit, - "priorityRows": priority_rows, - } - - -def lookup_params(order: dict[str, Any] | None = None, fujian: dict[str, Any] | None = None) -> dict[str, Any]: - order = order or _load_json(ORDER_PATH) - fujian = fujian or _load_json(FUJIAN_PATH) - row = order["row"] - return { - "goodsNameLike": f"%{row['goodsNameLike']}%", - "approvalNo": row["approvalNo"], - "spec": row["spec"], - "manufacturer": row["manufacturer"], - "entryId": int(order["m5"]["entryId"] or fujian["entryId"]), - "expectedGoodsId": str(row["expectedGoodsId"]), - "sql": fujian["sql"], - "sourceSql": fujian["sourceSql"], - "expectedSourceAddNeeded": bool(row.get("expectedSourceAddNeeded", True)), - } - - -def connect(conn_cfg: dict[str, Any] | None = None): - cfg = conn_cfg or load_connection() - missing = [key for key in ("user", "password") if not cfg.get(key)] - if missing: - raise RuntimeError( - "M5 连接缺少 " + ", ".join(missing) + "。设 M5_USER/M5_PASSWORD,或写 fixtures/m5/connection.local.json" - ) - import oracledb - - dsn = f"{cfg['host']}:{cfg['port']}/{cfg['serviceName']}" - return oracledb.connect(user=cfg["user"], password=cfg["password"], dsn=dsn) - - -def lookup_one(conn_cfg: dict[str, Any] | None = None) -> dict[str, Any]: - params = lookup_params() - conn = connect(conn_cfg) - try: - cur = conn.cursor() - cur.execute( - params["sql"], - goodsNameLike=params["goodsNameLike"], - approvalNo=params["approvalNo"], - spec=params["spec"], - manufacturer=params["manufacturer"], - entryId=params["entryId"], - ) - rows = _rows_from_cursor(cur) - finally: - conn.close() - - goods_ids = [str(row.get("GOODSID") or "") for row in rows] - return { - "ok": len(rows) == 1 and goods_ids[0] == params["expectedGoodsId"], - "expectedGoodsId": params["expectedGoodsId"], - "hits": len(rows), - "goodsIds": goods_ids, - "rows": rows, - } - - -def _rows_from_cursor(cur) -> list[dict[str, Any]]: - columns = [col[0] for col in cur.description] - return [dict(zip(columns, row)) for row in cur.fetchall()] - - -def query_priority_source(goods_id: str, conn_cfg: dict[str, Any] | None = None) -> list[dict[str, Any]]: - fujian = _load_json(FUJIAN_PATH) - conn = connect(conn_cfg) - try: - cur = conn.cursor() - cur.execute(fujian["sourceSql"], goodsId=int(goods_id)) - return _rows_from_cursor(cur) - finally: - conn.close() - - -def check_priority_source(conn_cfg: dict[str, Any] | None = None) -> dict[str, Any]: - params = lookup_params() - rows = query_priority_source(params["expectedGoodsId"], conn_cfg) - judged = evaluate_priority_source( - rows, - entry_id=params["entryId"], - spec=params["spec"], - manufacturer=params["manufacturer"], - ) - judged["ok"] = judged["source_add_needed"] == params["expectedSourceAddNeeded"] - judged["expectedSourceAddNeeded"] = params["expectedSourceAddNeeded"] - judged["rows"] = rows - return judged - - -if __name__ == "__main__": - match = lookup_one() - source = check_priority_source() if match.get("ok") else {"skipped": True, "reason": "对码未命中,不查货源"} - print(json.dumps({"match": match, "source": source}, ensure_ascii=False, default=str, indent=2)) diff --git a/state_center/app/main.py b/state_center/app/main.py deleted file mode 100644 index 29d0c3f..0000000 --- a/state_center/app/main.py +++ /dev/null @@ -1,1511 +0,0 @@ -""" -状态中心 · FastAPI 入口(第1步骨架)。 - -提供: - - 建单 POST /api/jobs - - 节点开始 POST /api/jobs/{job_id}/nodes/{node_code}/start - - 节点成功 POST /api/jobs/{job_id}/nodes/{node_code}/complete - - 节点失败 POST /api/jobs/{job_id}/nodes/{node_code}/fail - - 合同审批查一次 POST /api/jobs/{job_id}/nodes/CONTRACT_APPROVE_WAIT/check-result - - 到点待查列表(调度器扫) GET /api/scheduler/due-contract-checks - - 对码运行(内部算) POST /api/jobs/{job_id}/match/run - - 对码人工确认 POST /api/match/{match_id}/confirm - - 装载货品主数据 POST /api/match/reload-goods - - 整单查询(画树状图用) GET /api/jobs/{job_id} - -鉴权:RPA 回调带 header X-Agent-Token。 - -节点编排(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_ADD(仅对码判定缺货源)。 - 条件不满足则自动跳过,不占流程。 - -转移不是"数组下一个",是显式转移表 + 条件节点判定(见 _next_node)。 -每个节点标了执行方(STATE_CENTER 编排 / RPA 执行 / HUMAN 人工挂起),见 NODE_EXECUTOR。 -回调做了幂等(重复 complete 不二次推进)和乱序拒绝(非当前节点回调返回 409)。 -""" -from __future__ import annotations - -import json -import uuid -from datetime import datetime, timedelta -from typing import Optional - -from fastapi import FastAPI, Header, HTTPException -from pydantic import BaseModel - -from .config import settings -from .db import get_conn -from .match_engine import GoodsRecord, MatchEngine, build_match_key, load_goods_from_oracle -from .web_state_machine import build_state_machine_meta, build_timeline - -app = FastAPI(title="采购主链RPA状态中心", version="0.1") - -# 全部 13 个节点(11 主 + 2 条件),仅用于展示口径 / 前端画图 / 校验节点名合法。 -# 顺序不代表实际转移,实际转移看 TRANSITIONS + _next_node。 -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", -] - -# 显式转移表:node -> 下一个"直连"节点(不含条件跳过逻辑,跳过在 _should_skip 处理)。 -# None = 整单终点。条件节点(OCR_EXTRACT/SOURCE_ADD)也在链里,但可能被跳过。 -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, -} - -# 执行方标注:谁真正干这个节点。 -# STATE_CENTER = 状态中心内部算(对码),不派 RPA、不挂人工。 -# RPA = 艺赛旗点页面。 -# HUMAN = 一期人工做(状态中心只挂待办等回填)。 -NODE_EXECUTOR = { - "ORDER_FETCH": "RPA", - "OCR_EXTRACT": "RPA", # 线下单触发 OCR;线上单跳过 - "PRODUCT_MATCH": "STATE_CENTER", # 对码是状态中心内部算 - "SOURCE_ADD": "HUMAN", # 缺货源时人工在 M5 新增货源 - "CONTRACT_CREATE": "RPA", - "CONTRACT_APPROVE_WAIT": "RPA", # 到点由调度器触发 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", -} - -HUMAN_NODE_ROLES = { - "SOURCE_ADD": "PURCHASER", - "M5_MANUAL_CONFIRM": "PURCHASER", - "M5_RECEIPT_NOTICE": "PURCHASER", -} - -HUMAN_TASK_TITLES = { - "M5_MANUAL_CONFIRM": "M5人工确认", - "M5_RECEIPT_NOTICE": "生成收货通知单", - "CONTRACT_APPROVE_WAIT": "合同审批超时", -} - -HUMAN_TASK_REASONS = { - "M5_MANUAL_CONFIRM": "M5采购单已录入待保存,需要人工核对、保存并签订。", - "M5_RECEIPT_NOTICE": "M5采购单已签订,需要生成收货通知单并回填单号。", - "CONTRACT_APPROVE_WAIT": "合同审批已超过等待上限,需要运维确认已通过或终止本单。", -} - -# 条件节点:满足条件才执行,否则自动跳过下一节点。 -CONDITIONAL_NODES = {"OCR_EXTRACT", "SOURCE_ADD"} - -# 业务编号回写:哪个节点产出哪个 job 字段 -BUSINESS_REF_FIELD = { - "contractSystemNo": "contract_system_no", - "rebateOrderNo": "rebate_order_no", - "m5OrderNo": "m5_order_no", - "receiptNoticeNo": "receipt_notice_no", -} - - -def _now(): - return datetime.now() - - -def _check_agent(token: Optional[str]): - if token != settings.AGENT_TOKEN: - raise HTTPException(status_code=401, detail="bad agent token") - - -def _should_skip(node_code: str, job: dict) -> bool: - """条件节点是否跳过。 - - OCR_EXTRACT:只有线下单(OFFLINE)才做 OCR,线上单跳过。 - - SOURCE_ADD:只有对码判定缺货源(needs_source_add=1)才做,否则跳过。 - """ - 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 _next_node(node_code: str, job: dict) -> Optional[str]: - """按显式转移表找下一节点;遇到不满足条件的条件节点自动继续往后跳。""" - nxt = TRANSITIONS.get(node_code) - # 一路跳过不满足条件的条件节点 - while nxt is not None and nxt in CONDITIONAL_NODES and _should_skip(nxt, job): - nxt = TRANSITIONS.get(nxt) - return nxt - - -def _advance(cur, job: dict, node_code: str) -> Optional[str]: - """把 job 从 node_code 推进到下一节点,处理终点/合同审批等待/普通三种落法。 - 返回 next_node(None=整单结束)。调用方需保证 node_code 已记 SUCCESS。""" - job_id = job["job_id"] - nxt = _next_node(node_code, job) - if nxt is None: - cur.execute("UPDATE job SET status='SUCCESS',finished_at=%s,updated_at=%s WHERE job_id=%s", - (_now(), _now(), job_id)) - _log_event(cur, job_id, "JOB_FINISHED", None, "SYSTEM", None) - elif nxt == "ARCHIVE_JOB": - # 归档是状态中心自己的收口,进入即完成,不派 RPA、不挂人工。 - now = _now() - cur.execute( - "INSERT INTO job_node(job_id,node_code,executor_type,attempt_no,status,started_at,ended_at,created_at)" - " VALUES(%s,%s,%s,%s,%s,%s,%s,%s)" - " ON DUPLICATE KEY UPDATE status='SUCCESS',ended_at=VALUES(ended_at)", - (job_id, "ARCHIVE_JOB", NODE_EXECUTOR["ARCHIVE_JOB"], 1, "SUCCESS", now, now, now), - ) - cur.execute( - "UPDATE job SET status='SUCCESS',finished_at=%s,updated_at=%s,current_node_code=%s WHERE job_id=%s", - (now, now, "ARCHIVE_JOB", job_id), - ) - _log_event(cur, job_id, "JOB_FINISHED", None, "SYSTEM", None) - return None - elif nxt == "CONTRACT_APPROVE_WAIT": - # 进等待外部态:不立刻派 RPA,挂 WAITING_EXTERNAL,到点由调度器触发查一次。 - next_check = _now() + timedelta(minutes=settings.CONTRACT_WAIT_INTERVAL_MIN) - cur.execute( - "UPDATE job SET current_node_code=%s,status='WAITING_EXTERNAL'," - "next_check_at=%s,wait_check_count=0,updated_at=%s WHERE job_id=%s", - (nxt, next_check, _now(), job_id), - ) - _log_event(cur, job_id, "NODE_STARTED", nxt, "SYSTEM", None, - {"wait": "CONTRACT_APPROVE", "next_check_at": next_check.isoformat()}) - elif nxt in HUMAN_NODE_ROLES: - role_code = HUMAN_NODE_ROLES[nxt] - _mark_node_waiting_user(cur, job_id, nxt) - cur.execute( - "UPDATE job SET current_node_code=%s,status='WAITING_USER'," - "current_role=%s,updated_at=%s WHERE job_id=%s", - (nxt, role_code, _now(), job_id), - ) - if nxt != "SOURCE_ADD": - _create_human_task(cur, job, nxt, role_code) - _log_event(cur, job_id, "MANUAL_HANDOFF", nxt, "SYSTEM", None, - {"role_code": role_code}) - else: - cur.execute("UPDATE job SET current_node_code=%s,status='PENDING',updated_at=%s WHERE job_id=%s", - (nxt, _now(), job_id)) - return nxt - - -def _log_event(cur, job_id, event_type, node_code, operator_type, operator, detail=None): - cur.execute( - "INSERT INTO job_event(job_id,event_type,node_code,operator_type,operator,detail_json,created_at)" - " VALUES(%s,%s,%s,%s,%s,%s,%s)", - (job_id, event_type, node_code, operator_type, operator, - json.dumps(detail, ensure_ascii=False) if detail else None, _now()), - ) - - -def _serialize_visible_roles(visible_roles: Optional[list[str]]) -> Optional[str]: - if visible_roles is None: - return None - return json.dumps(visible_roles, ensure_ascii=False) - - -def _parse_visible_roles(value): - if value in (None, ""): - return [] - if isinstance(value, list): - return [str(item) for item in value] - try: - parsed = json.loads(value) - except (TypeError, ValueError): - parsed = None - if isinstance(parsed, list): - return [str(item) for item in parsed] - return [part.strip() for part in str(value).split(",") if part.strip()] - - -def _record_job_artifacts(cur, job_id: str, node_code: str, attempt_no: int, - artifacts: Optional[list["NodeArtifactReq"]]): - if not artifacts: - return - for artifact in artifacts: - cur.execute( - "INSERT INTO job_artifact(job_id,node_code,attempt_no,artifact_type,storage_path," - "file_hash,visible_roles,is_masked,created_at)" - " VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s)", - ( - job_id, - node_code, - attempt_no, - artifact.artifactType, - artifact.storagePath, - artifact.fileHash, - _serialize_visible_roles(artifact.visibleRoles), - 1 if artifact.isMasked else 0, - _now(), - ), - ) - - -def _fetch_job_artifacts(cur, job_id: str) -> list[dict]: - cur.execute( - "SELECT node_code,attempt_no,artifact_type,storage_path,file_hash,visible_roles," - "is_masked,created_at FROM job_artifact WHERE job_id=%s ORDER BY id", - (job_id,), - ) - rows = cur.fetchall() - out = [] - for row in rows: - out.append({ - "node_code": row.get("node_code"), - "attempt_no": row.get("attempt_no"), - "artifact_type": row.get("artifact_type"), - "storage_path": row.get("storage_path"), - "file_hash": row.get("file_hash"), - "visible_roles": _parse_visible_roles(row.get("visible_roles")), - "is_masked": bool(row.get("is_masked")), - "created_at": row.get("created_at"), - }) - return out - - -def _mark_node_waiting_user(cur, job_id: str, node_code: str): - cur.execute( - "INSERT INTO job_node(job_id,node_code,executor_type,attempt_no,status,started_at,created_at)" - " VALUES(%s,%s,%s,%s,%s,%s,%s)" - " ON DUPLICATE KEY UPDATE status=VALUES(status),started_at=COALESCE(started_at,VALUES(started_at))", - (job_id, node_code, NODE_EXECUTOR.get(node_code, "HUMAN"), 1, "WAITING_USER", _now(), _now()), - ) - - -def _mark_node_success(cur, job_id: str, node_code: str): - """把当前人工/内部节点记 SUCCESS。 - - 已有 WAITING_USER 行则原地收口;合同审批超时这类已有 FAILED 检查历史的, - 另起 attempt,不覆盖查审批记录。 - """ - cur.execute( - "SELECT attempt_no,status FROM job_node WHERE job_id=%s AND node_code=%s" - " ORDER BY attempt_no DESC LIMIT 1", - (job_id, node_code), - ) - latest = cur.fetchone() - if latest and latest.get("status") == "WAITING_USER": - cur.execute( - "UPDATE job_node SET status='SUCCESS',ended_at=%s" - " WHERE job_id=%s AND node_code=%s AND attempt_no=%s", - (_now(), job_id, node_code, latest["attempt_no"]), - ) - return - - next_attempt = int((latest or {}).get("attempt_no") or 0) + 1 - now = _now() - cur.execute( - "INSERT INTO job_node(job_id,node_code,executor_type,attempt_no,status,started_at,ended_at,created_at)" - " VALUES(%s,%s,%s,%s,'SUCCESS',%s,%s,%s)", - (job_id, node_code, NODE_EXECUTOR.get(node_code, "HUMAN"), next_attempt, now, now, now), - ) - - -def _create_human_task(cur, job: dict, node_code: str, role_code: str): - job_id = job["job_id"] - cur.execute( - "DELETE FROM user_task WHERE job_id=%s AND node_code=%s AND status='OPEN'", - (job_id, node_code), - ) - title_prefix = HUMAN_TASK_TITLES.get(node_code, node_code) - cur.execute( - "INSERT INTO user_task(task_id,job_id,node_code,role_code,title,reason_type," - "reason_user_friendly,why_me_text,status,created_at)" - " VALUES(%s,%s,%s,%s,%s,%s,%s,%s,'OPEN',%s)", - ( - uuid.uuid4().hex, - job_id, - node_code, - role_code, - f"{title_prefix}:{job['supplier_name']}", - node_code, - HUMAN_TASK_REASONS.get(node_code), - "该节点一期由人工处理,确认完成后流程继续。", - _now(), - ), - ) - - -def _supplier_has_source(cur, supplier_name: str, goods_id: str, current_job_id: str) -> bool: - """用状态中心历史结果判断该供应商是否已有该货品货源。 - - 一期没有 M5 货源 API,所以用历史已确认/已命中的 supplier+goods_id 作为明确触发源。 - """ - cur.execute( - "SELECT 1 FROM job j JOIN product_match pm ON pm.job_id=j.job_id" - " WHERE j.supplier_name=%s AND pm.goods_id=%s AND j.job_id<>%s" - " AND pm.match_status IN ('EXACT','ALIAS') LIMIT 1", - (supplier_name, goods_id, current_job_id), - ) - return cur.fetchone() is not None - - -def _source_add_needed( - cur, - job: dict, - goods_id: Optional[str], - spec: str = "", - manufacturer: str = "", -) -> int: - if not goods_id: - return 0 - from .config import settings - from .m5_client import ( - FUJIAN_PATH, - _load_json, - evaluate_priority_source, - load_connection, - query_priority_source, - recorded_source_rows, - ) - - if spec and manufacturer: - recorded = recorded_source_rows(str(goods_id)) - if recorded is not None and settings.DEV_MODE: - entry_id = int(_load_json(FUJIAN_PATH)["entryId"]) - judged = evaluate_priority_source( - recorded, - entry_id=entry_id, - spec=spec, - manufacturer=manufacturer, - ) - return 1 if judged["source_add_needed"] else 0 - - cfg = load_connection() - if cfg.get("user") and cfg.get("password") and spec and manufacturer: - try: - entry_id = int(_load_json(FUJIAN_PATH)["entryId"]) - rows = query_priority_source(str(goods_id), cfg) - judged = evaluate_priority_source( - rows, - entry_id=entry_id, - spec=spec, - manufacturer=manufacturer, - ) - return 1 if judged["source_add_needed"] else 0 - except Exception: - pass - return 0 if _supplier_has_source(cur, job["supplier_name"], goods_id, job["job_id"]) else 1 - - -def _build_source_add_candidates(cur, job_id: str) -> list[dict]: - """从 product_match 当前态生成 SOURCE_ADD 待办明细,按 goods_id 去重。""" - cur.execute( - "SELECT pm.id AS product_match_id,pm.order_item_id,pm.goods_id,pm.goods_code," - "pm.base_unit,pm.match_key,oi.raw_product_name,oi.spec,oi.manufacturer" - " FROM product_match pm JOIN order_item oi ON oi.id=pm.order_item_id" - " WHERE pm.job_id=%s AND pm.source_add_needed=1 AND pm.goods_id IS NOT NULL" - " ORDER BY pm.id", - (job_id,), - ) - out = [] - seen_goods = set() - for row in cur.fetchall(): - goods_id = row["goods_id"] - if goods_id in seen_goods: - continue - seen_goods.add(goods_id) - out.append({ - "goods_id": goods_id, - "goods_code": row["goods_code"], - "base_unit": row["base_unit"], - "raw_product_name": row["raw_product_name"], - "spec": row["spec"], - "manufacturer": row["manufacturer"], - "order_item_id": row["order_item_id"], - "product_match_id": row["product_match_id"], - "match_key": row["match_key"], - }) - return out - - -def _sync_source_add_task(cur, job: dict) -> list[dict]: - """把 product_match.source_add_needed 同步到 job.needs_source_add 和一条聚合待办。""" - job_id = job["job_id"] - candidates = _build_source_add_candidates(cur, job_id) - cur.execute( - "DELETE FROM user_task WHERE job_id=%s AND node_code='SOURCE_ADD' AND status='OPEN'", - (job_id,), - ) - cur.execute( - "UPDATE job SET needs_source_add=%s,updated_at=%s WHERE job_id=%s", - (1 if candidates else 0, _now(), job_id), - ) - if not candidates: - return [] - - detail = { - "supplier_name": job["supplier_name"], - "items": candidates, - } - cur.execute( - "INSERT INTO user_task(task_id,job_id,node_code,role_code,title,reason_type," - "reason_user_friendly,why_me_text,comparison_snapshot_json,candidates_json,status,created_at)" - " VALUES(%s,%s,'SOURCE_ADD','PURCHASER',%s,'SOURCE_ADD',%s,%s,%s,%s,'OPEN',%s)", - ( - uuid.uuid4().hex, - job_id, - f"新增货源:{job['supplier_name']}", - "该供应商未维护这些品种的货源,需要在 M5 新增后继续。", - "供应商货源由采购/业务在 M5 维护。", - json.dumps(detail, ensure_ascii=False), - json.dumps(candidates, ensure_ascii=False), - _now(), - ), - ) - _log_event(cur, job_id, "TASK_CREATED", "SOURCE_ADD", "SYSTEM", None, detail) - return candidates - - -# ---------------- 建单 ---------------- - -class CreateJobReq(BaseModel): - source_type: str # YYC/XYY/OFFLINE - order_date: str # YYYY-MM-DD - supplier_name: str - created_by: str = "system" - - -class OrderItemRowReq(BaseModel): - orderNo: str - rawProductName: str - platformProductCode: Optional[str] = None - spec: Optional[str] = None - manufacturer: Optional[str] = None - approvalNo: Optional[str] = None - qty: float - unitPrice: Optional[float] = None - sourceRowNo: Optional[int] = None - - -class OrderItemsIngestReq(BaseModel): - replace: bool = True - rows: list[OrderItemRowReq] - - -@app.post("/api/jobs") -def create_job(req: CreateJobReq): - job_id = f"{req.source_type}-{req.supplier_name}-{req.order_date.replace('-', '')}-{uuid.uuid4().hex[:6]}" - business_key = f"{req.source_type}|{req.supplier_name}|{req.order_date}" - need_pdf = 1 if req.source_type == "YYC" else 0 - first_node = NODE_ORDER[0] - - conn = get_conn() - with conn.cursor() as cur: - # 幂等:同 business_key 已有未结束 job 就不重复建 - cur.execute( - "SELECT job_id FROM job WHERE business_key=%s AND status NOT IN" - " ('SUCCESS','CANCELLED','FAILED_MANUAL') LIMIT 1", - (business_key,), - ) - exist = cur.fetchone() - if exist: - return {"job_id": exist["job_id"], "reused": True} - - cur.execute( - "INSERT INTO job(job_id,business_key,source_type,order_date,supplier_name," - "need_pdf_upload,current_node_code,status,auto_save_enabled,created_by,created_at,updated_at)" - " VALUES(%s,%s,%s,%s,%s,%s,%s,'PENDING',0,%s,%s,%s)", - (job_id, business_key, req.source_type, req.order_date, req.supplier_name, - need_pdf, first_node, req.created_by, _now(), _now()), - ) - _log_event(cur, job_id, "JOB_CREATED", None, "SYSTEM", req.created_by, - {"business_key": business_key}) - return {"job_id": job_id, "first_node": first_node, "reused": False} - - -@app.post("/api/jobs/{job_id}/items") -def ingest_order_items(job_id: str, req: OrderItemsIngestReq, - x_agent_token: Optional[str] = Header(default=None)): - """开发/接入侧写入订单明细。已有对码结果后禁止改写,避免破坏下游状态。""" - _check_agent(x_agent_token) - conn = get_conn() - with conn.cursor() as cur: - _load_job(cur, job_id) - cur.execute("SELECT COUNT(*) AS c FROM product_match WHERE job_id=%s", (job_id,)) - if cur.fetchone()["c"]: - raise HTTPException(status_code=409, detail="已有对码结果,不能覆盖订单明细") - - if req.replace: - cur.execute("DELETE FROM order_item WHERE job_id=%s", (job_id,)) - - order_nos = [] - for row in req.rows: - if row.orderNo not in order_nos: - order_nos.append(row.orderNo) - cur.execute( - "INSERT INTO order_item(job_id,order_no,raw_product_name,platform_product_code," - "spec,manufacturer,approval_no,qty,unit_price,source_row_no,created_at)" - " VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", - ( - job_id, - row.orderNo, - row.rawProductName, - row.platformProductCode, - row.spec, - row.manufacturer, - row.approvalNo, - row.qty, - row.unitPrice, - row.sourceRowNo, - _now(), - ), - ) - - cur.execute( - "UPDATE job SET order_no_list_json=%s,updated_at=%s WHERE job_id=%s", - (json.dumps(order_nos, ensure_ascii=False), _now(), job_id), - ) - _log_event(cur, job_id, "ORDER_ITEMS_INGESTED", "ORDER_FETCH", "ROBOT", None, - {"replace": req.replace, "inserted": len(req.rows), "order_nos": order_nos}) - return {"ok": True, "inserted": len(req.rows), "order_nos": order_nos} - - -@app.get("/api/jobs") -def list_jobs(status: Optional[str] = None, source_type: Optional[str] = None, - supplier_name: Optional[str] = None, date_from: Optional[str] = None, - date_to: Optional[str] = None): - conn = get_conn() - sql = ( - "SELECT job_id,supplier_name,order_date,source_type,status,current_node_code," - "current_role,created_at,updated_at,finished_at FROM job WHERE 1=1" - ) - params = [] - if status: - sql += " AND status=%s" - params.append(status) - if source_type: - sql += " AND source_type=%s" - params.append(source_type) - if supplier_name: - sql += " AND supplier_name LIKE %s" - params.append(f"%{supplier_name}%") - if date_from: - sql += " AND order_date>=%s" - params.append(date_from) - if date_to: - sql += " AND order_date<=%s" - params.append(date_to) - sql += " ORDER BY created_at DESC" - with conn.cursor() as cur: - cur.execute(sql, tuple(params)) - rows = cur.fetchall() - return {"items": rows} - - -@app.get("/api/tasks/my") -def list_my_tasks(roleCode: Optional[str] = None, status: Optional[str] = None): - conn = get_conn() - sql = ( - "SELECT task_id,job_id,node_code,order_item_id,product_match_id,role_code,status,title," - "reason_type,reason_user_friendly,why_me_text,candidates_json,submitted_action," - "submitted_at,created_at FROM user_task WHERE 1=1" - ) - params = [] - if roleCode: - sql += " AND role_code=%s" - params.append(roleCode) - if status: - sql += " AND status=%s" - params.append(status.upper()) - sql += " ORDER BY created_at DESC" - with conn.cursor() as cur: - cur.execute(sql, tuple(params)) - rows = cur.fetchall() - return {"items": rows} - - -# ---------------- 节点开始 ---------------- - -class NodeStartReq(BaseModel): - attemptNo: int = 1 - executorPlatform: Optional[str] = None - operator: Optional[str] = "agent" - - -def _load_job(cur, job_id: str) -> dict: - cur.execute("SELECT * FROM job WHERE job_id=%s", (job_id,)) - job = cur.fetchone() - if not job: - raise HTTPException(status_code=404, detail="job not found") - return job - - -def _guard_current_node(job: dict, node_code: str): - """乱序拒绝:回调的节点必须是该 job 当前节点,否则 409。 - 避免 RPA 端网络重发/顺序错乱把状态推乱。""" - if node_code not in NODE_ORDER: - raise HTTPException(status_code=400, detail=f"unknown node {node_code}") - if job.get("current_node_code") != node_code: - raise HTTPException( - status_code=409, - detail=f"out-of-order: job 当前在 {job.get('current_node_code')},拒绝 {node_code} 回调", - ) - - -@app.post("/api/jobs/{job_id}/nodes/{node_code}/start") -def node_start(job_id: str, node_code: str, req: NodeStartReq, - x_agent_token: Optional[str] = Header(default=None)): - _check_agent(x_agent_token) - conn = get_conn() - with conn.cursor() as cur: - job = _load_job(cur, job_id) - _guard_current_node(job, node_code) - - # 幂等:该节点该次尝试已 RUNNING/SUCCESS,直接回 ok,不重复起点 - cur.execute( - "SELECT status FROM job_node WHERE job_id=%s AND node_code=%s AND attempt_no=%s", - (job_id, node_code, req.attemptNo), - ) - row = cur.fetchone() - if row and row["status"] in ("RUNNING", "SUCCESS"): - return {"ok": True, "idempotent": True} - - cur.execute( - "INSERT INTO job_node(job_id,node_code,executor_type,attempt_no,status,executor_platform," - "executor_run_id,started_at,created_at) VALUES(%s,%s,%s,%s,'RUNNING',%s,NULL,%s,%s)" - " ON DUPLICATE KEY UPDATE status='RUNNING',started_at=VALUES(started_at)", - (job_id, node_code, NODE_EXECUTOR.get(node_code, "RPA"), req.attemptNo, - req.executorPlatform, _now(), _now()), - ) - cur.execute( - "UPDATE job SET status='RUNNING',updated_at=%s WHERE job_id=%s", - (_now(), job_id), - ) - _log_event(cur, job_id, "NODE_STARTED", node_code, "ROBOT", req.operator) - return {"ok": True} - - -# ---------------- 节点成功 ---------------- - -class NodeArtifactReq(BaseModel): - artifactType: str - storagePath: str - fileHash: Optional[str] = None - visibleRoles: Optional[list[str]] = None - isMasked: bool = False - - -class NodeCompleteReq(BaseModel): - attemptNo: int = 1 - executorRunId: Optional[str] = None - idempotencyKey: Optional[str] = None # RPA 端生成的回调幂等键,重发同键不二次推进 - businessRefs: Optional[dict] = None # {contractSystemNo:..., rebateOrderNo:...} - summary: Optional[dict] = None - artifacts: Optional[list[NodeArtifactReq]] = None - endedAt: Optional[str] = None - - -@app.post("/api/jobs/{job_id}/nodes/{node_code}/complete") -def node_complete(job_id: str, node_code: str, req: NodeCompleteReq, - x_agent_token: Optional[str] = Header(default=None)): - _check_agent(x_agent_token) - - if NODE_EXECUTOR.get(node_code) == "HUMAN": - raise HTTPException(status_code=409, detail="人工节点只能通过待办提交,拒绝 RPA complete") - - # 约束:要求业务编号却没回传的节点,不许记 SUCCESS - required = {"CONTRACT_CREATE": "contractSystemNo", "REBATE_HEADER": "rebateOrderNo", - "M5_RECEIPT_NOTICE": "receiptNoticeNo"} - if node_code in required: - key = required[node_code] - if not (req.businessRefs or {}).get(key): - raise HTTPException(status_code=400, detail=f"{node_code} 必须回传 {key}") - - conn = get_conn() - with conn.cursor() as cur: - job = _load_job(cur, job_id) - - # 幂等:该节点该次尝试已 SUCCESS,直接回上次算出的 next,不二次推进 - cur.execute( - "SELECT status,callback_idempotency_key FROM job_node" - " WHERE job_id=%s AND node_code=%s AND attempt_no=%s", - (job_id, node_code, req.attemptNo), - ) - prev = cur.fetchone() - if prev and prev["status"] == "SUCCESS": - return {"ok": True, "next_node": _next_node(node_code, job), "idempotent": True} - - # 乱序拒绝:非当前节点的 complete 一律 409(幂等重发已在上面放行) - _guard_current_node(job, node_code) - - cur.execute( - "UPDATE job_node SET status='SUCCESS',executor_run_id=%s,ended_at=%s," - "callback_idempotency_key=%s,output_json=%s" - " WHERE job_id=%s AND node_code=%s AND attempt_no=%s", - (req.executorRunId, _now(), req.idempotencyKey, - json.dumps({"businessRefs": req.businessRefs, "summary": req.summary}, ensure_ascii=False), - job_id, node_code, req.attemptNo), - ) - # 回写业务编号(三把钥匙等) - if req.businessRefs: - for k, v in req.businessRefs.items(): - col = BUSINESS_REF_FIELD.get(k) - if col and v: - cur.execute(f"UPDATE job SET {col}=%s,updated_at=%s WHERE job_id=%s", - (v, _now(), job_id)) - _record_job_artifacts(cur, job_id, node_code, req.attemptNo, req.artifacts) - _log_event(cur, job_id, "NODE_SUCCESS", node_code, "ROBOT", None, req.summary) - - # 决定下一步(显式转移表 + 条件节点跳过) - nxt = _advance(cur, job, node_code) - return {"ok": True, "next_node": nxt} - - -# ---------------- 节点失败 ---------------- - -class NodeFailReq(BaseModel): - attemptNo: int = 1 - errorCode: str - errorSummary: Optional[str] = None - retryable: bool = False - routeRole: Optional[str] = None - artifacts: Optional[list[NodeArtifactReq]] = None - - -@app.post("/api/jobs/{job_id}/nodes/{node_code}/fail") -def node_fail(job_id: str, node_code: str, req: NodeFailReq, - x_agent_token: Optional[str] = Header(default=None)): - _check_agent(x_agent_token) - - # M5 质管缺失:不重试、不等质管,直接转人工手工做 - force_manual = req.errorCode == "M5_QC_MISSING" - - conn = get_conn() - with conn.cursor() as cur: - job = _load_job(cur, job_id) - _guard_current_node(job, node_code) # 只有当前节点能报失败,乱序 409 - - cur.execute( - "UPDATE job_node SET status='FAILED',error_code=%s,error_summary=%s," - "role_at_failure=%s,ended_at=%s WHERE job_id=%s AND node_code=%s AND attempt_no=%s", - (req.errorCode, req.errorSummary, req.routeRole, _now(), - job_id, node_code, req.attemptNo), - ) - _record_job_artifacts(cur, job_id, node_code, req.attemptNo, req.artifacts) - if force_manual: - job_status = "FAILED_MANUAL" - elif req.retryable: - job_status = "FAILED_RETRYABLE" - else: - job_status = "WAITING_USER" # 业务类转人工待办 - cur.execute("UPDATE job SET status=%s,current_role=%s,updated_at=%s WHERE job_id=%s", - (job_status, req.routeRole, _now(), job_id)) - _log_event(cur, job_id, "NODE_FAILED", node_code, "ROBOT", None, - {"errorCode": req.errorCode, "retryable": req.retryable}) - return {"ok": True, "job_status": job_status} - - -@app.post("/api/jobs/{job_id}/retry") -def retry_job(job_id: str, x_agent_token: Optional[str] = Header(default=None)): - """把 FAILED_RETRYABLE 的当前节点放回可 start 状态。 - - 不覆盖失败历史:下一轮 start 用 attempt_no+1 另起 job_node 行。 - """ - _check_agent(x_agent_token) - conn = get_conn() - with conn.cursor() as cur: - job = _load_job(cur, job_id) - if job.get("status") != "FAILED_RETRYABLE": - raise HTTPException( - status_code=409, - detail=f"job 不在可重试状态(当前 {job.get('status')})", - ) - node_code = job["current_node_code"] - cur.execute( - "SELECT COALESCE(MAX(attempt_no), 0) AS max_attempt FROM job_node" - " WHERE job_id=%s AND node_code=%s", - (job_id, node_code), - ) - row = cur.fetchone() or {} - next_attempt = int(row.get("max_attempt") or 0) + 1 - cur.execute( - "UPDATE job SET status='PENDING',updated_at=%s WHERE job_id=%s", - (_now(), job_id), - ) - _log_event( - cur, job_id, "NODE_STARTED", node_code, "SYSTEM", None, - {"retry": True, "attempt_no": next_attempt}, - ) - return {"ok": True, "node_code": node_code, "attempt_no": next_attempt} - - -# ---------------- 合同审批等待(定时查一次,不连续轮询) ---------------- - -@app.get("/api/scheduler/due-contract-checks") -def due_contract_checks(x_agent_token: Optional[str] = Header(default=None)): - """调度器/RPA 定时扫这个:到点该查审批的 job 列表。 - 业务方案:送审后每 CONTRACT_WAIT_INTERVAL_MIN 分钟查一次,不死等。""" - _check_agent(x_agent_token) - conn = get_conn() - with conn.cursor() as cur: - cur.execute( - "SELECT job_id,contract_system_no,wait_check_count,next_check_at FROM job" - " WHERE status='WAITING_EXTERNAL' AND current_node_code='CONTRACT_APPROVE_WAIT'" - " AND next_check_at IS NOT NULL AND next_check_at<=%s ORDER BY next_check_at", - (_now(),), - ) - rows = cur.fetchall() - return {"due": rows, "interval_min": settings.CONTRACT_WAIT_INTERVAL_MIN, - "max_checks": settings.CONTRACT_WAIT_MAX_CHECKS} - - -class ContractCheckReq(BaseModel): - approved: bool # RPA 查完:审批是否已通过 - operator: Optional[str] = "agent" - detail: Optional[str] = None - - -@app.post("/api/jobs/{job_id}/nodes/CONTRACT_APPROVE_WAIT/check-result") -def contract_check_result(job_id: str, req: ContractCheckReq, - x_agent_token: Optional[str] = Header(default=None)): - """RPA 到点登录查了一次审批状态,把结果回给状态中心。 - - 通过 → 该节点 SUCCESS,推进到 REBATE_HEADER。 - - 未通过 → 次数+1;没超上限则排下一次查(next_check_at);超了升级 OPS(WAITING_USER)。 - """ - _check_agent(x_agent_token) - conn = get_conn() - with conn.cursor() as cur: - job = _load_job(cur, job_id) - if job.get("current_node_code") != "CONTRACT_APPROVE_WAIT" or job.get("status") != "WAITING_EXTERNAL": - raise HTTPException(status_code=409, - detail=f"job 不在合同审批等待态(当前 {job.get('current_node_code')}/{job.get('status')})") - - # 本次是第几次查(1-based)。每次查都单独落一行 job_node(不覆盖历史), - # attempt_no 递增 → 命中 uniq_job_node_attempt 也不冲突,审计轨迹完整。 - attempt = int(job.get("wait_check_count") or 0) + 1 - - if req.approved: - cur.execute( - "INSERT INTO job_node(job_id,node_code,executor_type,attempt_no,status," - "started_at,ended_at,created_at) VALUES(%s,'CONTRACT_APPROVE_WAIT',%s,%s,'SUCCESS',%s,%s,%s)", - (job_id, NODE_EXECUTOR["CONTRACT_APPROVE_WAIT"], attempt, _now(), _now(), _now()), - ) - nxt = _next_node("CONTRACT_APPROVE_WAIT", job) # REBATE_HEADER - cur.execute( - "UPDATE job SET current_node_code=%s,status='PENDING',wait_check_count=%s," - "next_check_at=NULL,updated_at=%s WHERE job_id=%s", - (nxt, attempt, _now(), job_id), - ) - _log_event(cur, job_id, "NODE_SUCCESS", "CONTRACT_APPROVE_WAIT", "ROBOT", req.operator, - {"approved": True, "checks": attempt}) - return {"ok": True, "next_node": nxt} - - # 未通过:本次查也单独落一行(记为一次尝试),再决定重排还是升级 - over_limit = attempt >= settings.CONTRACT_WAIT_MAX_CHECKS - cur.execute( - "INSERT INTO job_node(job_id,node_code,executor_type,attempt_no,status,error_code," - "error_summary,role_at_failure,started_at,ended_at,created_at)" - " VALUES(%s,'CONTRACT_APPROVE_WAIT',%s,%s,%s,%s,%s,%s,%s,%s,%s)", - (job_id, NODE_EXECUTOR["CONTRACT_APPROVE_WAIT"], attempt, - "FAILED" if over_limit else "WAITING_EXTERNAL", - "CONTRACT_APPROVE_TIMEOUT" if over_limit else None, - req.detail, "OPS" if over_limit else None, _now(), _now(), _now()), - ) - if over_limit: - # 超上限:升级 OPS 人工介入,并建一条可提交的待办。 - cur.execute( - "UPDATE job SET status='WAITING_USER',current_role='OPS'," - "wait_check_count=%s,next_check_at=NULL,updated_at=%s WHERE job_id=%s", - (attempt, _now(), job_id), - ) - _create_human_task(cur, job, "CONTRACT_APPROVE_WAIT", "OPS") - _log_event(cur, job_id, "MANUAL_HANDOFF", "CONTRACT_APPROVE_WAIT", "SYSTEM", None, - {"reason": "approve_timeout", "checks": attempt}) - return {"ok": True, "escalated": True, "job_status": "WAITING_USER"} - - # 没超:排下一次查 - next_check = _now() + timedelta(minutes=settings.CONTRACT_WAIT_INTERVAL_MIN) - cur.execute( - "UPDATE job SET wait_check_count=%s,next_check_at=%s,updated_at=%s WHERE job_id=%s", - (attempt, next_check, _now(), job_id), - ) - _log_event(cur, job_id, "NODE_STARTED", "CONTRACT_APPROVE_WAIT", "ROBOT", req.operator, - {"approved": False, "checks": attempt, "next_check_at": next_check.isoformat()}) - return {"ok": True, "approved": False, "checks": attempt, "next_check_at": next_check.isoformat()} - - -# ---------------- 对码(PRODUCT_MATCH,状态中心内部算) ---------------- -# -# 骨架说明:对码是状态中心自己算的节点(executor=STATE_CENTER),不派 RPA。 -# 依赖两样东西,拿到后本节点才真正跑得动: -# 1) order_item 已落库(建单/ORDER_FETCH 后把订单明细写进来)——Trae 任务; -# 2) 货品主数据装载进 MatchEngine(开工时从 Oracle 全量拉)——需 Oracle 真连。 -# 当前给出可推进的骨架:有明细就跑 match_engine 落 product_match、判 needs_source_add、 -# MULTI/NONE 建 user_task 转人工,全命中则推进下一节点;没明细/引擎没装载则明确报错,不静默卡。 - -# 进程内对码引擎(开工 reload 一次,后续内存匹配)。多进程部署时各进程各自 reload。 -_ENGINE = MatchEngine() -_ENGINE_LOADED = False - - -@app.post("/api/match/reload-goods") -def reload_goods(x_agent_token: Optional[str] = Header(default=None)): - """从 Oracle 全量拉货品主数据装载进内存引擎。需先配好 ORACLE_* 且 pip install oracledb。""" - _check_agent(x_agent_token) - if not settings.ORACLE_DSN: - raise HTTPException(status_code=400, detail="未配置 ORACLE_DSN,无法装载货品主数据") - global _ENGINE_LOADED - records = load_goods_from_oracle(settings) - _ENGINE.load_goods(records) - _ENGINE_LOADED = True - return {"ok": True, "loaded": len(records)} - - -@app.post("/api/dev/match/load-fixture-goods") -def load_fixture_goods(x_agent_token: Optional[str] = Header(default=None)): - """仅本地联调用,不依赖 Oracle 装载一组可重复的货品样例。""" - _check_agent(x_agent_token) - if not settings.DEV_MODE: - raise HTTPException(status_code=404, detail="开发接口未启用") - - records = [ - GoodsRecord("阿卡波糖片", "国药准字H00000001", "50mg*30片", "盒", "示例制药", "G-001", "YP-001"), - GoodsRecord("阿莫西林胶囊", "国药准字H00000002", "0.25g*24粒", "盒", "示例医药", "G-002", "YP-002"), - GoodsRecord("氯化钠注射液", "国药准字H00000003", "100ml", "瓶", "示例药业", "G-003", "YP-003"), - GoodsRecord( - "哈药牌钙铁锌口服液", - "食健备G201923000558", - "10ml*90支", - "盒", - "哈药集团三精制药有限公司", - "4000109983", - "4000109983", - ), - ] - global _ENGINE_LOADED - _ENGINE.load_goods(records) - _ENGINE_LOADED = True - return {"ok": True, "loaded": len(records)} - - -def _load_aliases_into_engine(cur) -> int: - """把 mapping_config(PRODUCT,active)里的累积别名装进引擎 alias_map。 - target_value 存的是 {"goods_id":..,"goods_code":..,"base_unit":..} 的 JSON。""" - cur.execute( - "SELECT config_key,target_value FROM mapping_config" - " WHERE config_type='PRODUCT' AND is_active=1" - " ORDER BY config_key,updated_at,id", - ) - alias = {} - for row in cur.fetchall(): - try: - tv = json.loads(row["target_value"]) if row["target_value"] else {} - except (TypeError, ValueError): - continue - gid = tv.get("goods_id") - if gid: - alias[row["config_key"]] = (gid, tv.get("goods_code", ""), tv.get("base_unit", "")) - _ENGINE.load_aliases(alias) - return len(alias) - - -@app.post("/api/jobs/{job_id}/match/run") -def match_run(job_id: str, x_agent_token: Optional[str] = Header(default=None)): - """对该 job 的 order_item 全量跑对码,落 product_match。 - - 全部 EXACT/ALIAS(且不缺货源)→ 推进下一节点。 - - 有 MULTI/NONE → 逐条建 user_task,job 转 WAITING_USER(单条挂起,整批继续落库)。 - """ - _check_agent(x_agent_token) - if not _ENGINE_LOADED: - raise HTTPException(status_code=409, detail="对码引擎未装载货品主数据,先调 /api/match/reload-goods") - - conn = get_conn() - try: - conn.begin() - with conn.cursor() as cur: - job = _load_job(cur, job_id) - _guard_current_node(job, "PRODUCT_MATCH") - - cur.execute( - "SELECT id,raw_product_name,approval_no,spec,manufacturer FROM order_item" - " WHERE job_id=%s ORDER BY id", (job_id,), - ) - items = cur.fetchall() - if not items: - # 明确报错,不静默推进(否则等于跳过对码) - raise HTTPException(status_code=400, detail=f"job {job_id} 没有 order_item,无法对码") - - _load_aliases_into_engine(cur) - - # 对码结果是当前态,重跑时用 job_id+order_item_id upsert,避免重复 product_match/user_task。 - cur.execute( - "DELETE FROM user_task WHERE job_id=%s AND node_code IN ('PRODUCT_MATCH','SOURCE_ADD')" - " AND status='OPEN'", - (job_id,), - ) - - stat = {"EXACT": 0, "ALIAS": 0, "MULTI": 0, "NONE": 0, "SOURCE_ADD": 0} - need_match_human = False - for it in items: - r = _ENGINE.match_one(it["id"], it["raw_product_name"], it["approval_no"] or "", - it["spec"] or "", it["manufacturer"] or "") - stat[r.match_status] = stat.get(r.match_status, 0) + 1 - source_add_needed = _source_add_needed( - cur, job, r.goods_id, it.get("spec") or "", it.get("manufacturer") or "" - ) - cur.execute( - "INSERT INTO product_match(job_id,order_item_id,match_key,match_status," - "goods_id,goods_code,base_unit,candidates_json,source_add_needed,created_at)" - " VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)" - " ON DUPLICATE KEY UPDATE match_key=VALUES(match_key),match_status=VALUES(match_status)," - "goods_id=VALUES(goods_id),goods_code=VALUES(goods_code),base_unit=VALUES(base_unit)," - "candidates_json=VALUES(candidates_json),source_add_needed=VALUES(source_add_needed)," - "confirmed_by=NULL,confirmed_at=NULL,id=LAST_INSERT_ID(id)", - (job_id, it["id"], r.match_key, r.match_status, r.goods_id, r.goods_code, - r.base_unit, - json.dumps(r.candidates, ensure_ascii=False) if r.candidates else None, - source_add_needed, _now()), - ) - match_id = cur.lastrowid - if r.match_status in ("MULTI", "NONE"): - need_match_human = True - reason = "MATCH_MULTI" if r.match_status == "MULTI" else "MATCH_NONE" - cur.execute( - "INSERT INTO user_task(task_id,job_id,node_code,order_item_id,product_match_id," - "role_code,title,reason_type,candidates_json,status,created_at)" - " VALUES(%s,%s,'PRODUCT_MATCH',%s,%s,'PLANNER',%s,%s,%s,'OPEN',%s)", - (uuid.uuid4().hex, job_id, it["id"], match_id, - f"对码待确认:{it['raw_product_name']}", reason, - json.dumps(r.candidates, ensure_ascii=False) if r.candidates else None, _now()), - ) - - source_add_candidates = _sync_source_add_task(cur, job) - stat["SOURCE_ADD"] = len(source_add_candidates) - - cur.execute( - "INSERT INTO job_node(job_id,node_code,executor_type,attempt_no,status,started_at,ended_at,created_at)" - " VALUES(%s,'PRODUCT_MATCH',%s,1,%s,%s,%s,%s)" - " ON DUPLICATE KEY UPDATE status=VALUES(status),ended_at=VALUES(ended_at)", - (job_id, NODE_EXECUTOR["PRODUCT_MATCH"], "WAITING_USER" if need_match_human else "SUCCESS", - _now(), _now(), _now()), - ) - _log_event(cur, job_id, "NODE_SUCCESS" if not need_match_human else "TASK_CREATED", - "PRODUCT_MATCH", "SYSTEM", None, stat) - - if need_match_human: - # 有待确认:job 转人工待办,不推进(人工 confirm 后再推进)。 - cur.execute( - "UPDATE job SET status='WAITING_USER',current_role='PLANNER',updated_at=%s WHERE job_id=%s", - (_now(), job_id), - ) - conn.commit() - return {"ok": True, "stat": stat, "waiting_user": True, - "source_add_needed": bool(source_add_candidates)} - - # 全部已明确匹配:按 needs_source_add 决定是否进入 SOURCE_ADD,否则进合同建单。 - job = _load_job(cur, job_id) - nxt = _advance(cur, job, "PRODUCT_MATCH") - conn.commit() - return {"ok": True, "stat": stat, "next_node": nxt, - "source_add_needed": bool(source_add_candidates)} - except Exception: - conn.rollback() - raise - finally: - conn.close() - - -class MatchConfirmReq(BaseModel): - goods_id: str # 人工选定/新增的货品ID - goods_code: Optional[str] = None - base_unit: Optional[str] = None - confirmed_by: str = "planner" - save_as_alias: bool = True # 是否把这次选择沉淀成累积别名(下次自动命中) - - -@app.post("/api/match/{match_id}/confirm") -def match_confirm(match_id: int, req: MatchConfirmReq, - x_agent_token: Optional[str] = Header(default=None)): - """人工确认一条 MULTI/NONE 对码结果: - - 回写 product_match(goods_id/编码/单位、确认人)。 - - save_as_alias=1 时把 match_key→货品ID 沉淀进 mapping_config(PRODUCT),下次自动 ALIAS 命中。 - - 关掉对应 user_task;若该 job 已无待确认,推进 PRODUCT_MATCH 到下一节点。 - 注意:只写 mapping_config 累积别名,绝不回写 Oracle 货品主数据(主数据只读)。 - """ - _check_agent(x_agent_token) - conn = get_conn() - try: - conn.begin() - with conn.cursor() as cur: - cur.execute("SELECT * FROM product_match WHERE id=%s", (match_id,)) - pm = cur.fetchone() - if not pm: - raise HTTPException(status_code=404, detail="product_match not found") - job_id = pm["job_id"] - job = _load_job(cur, job_id) - oi = None - if pm.get("order_item_id"): - cur.execute( - "SELECT spec,manufacturer FROM order_item WHERE id=%s", - (pm["order_item_id"],), - ) - oi = cur.fetchone() - source_add_needed = _source_add_needed( - cur, - job, - req.goods_id, - (oi or {}).get("spec") or "", - (oi or {}).get("manufacturer") or "", - ) - - cur.execute( - "UPDATE product_match SET match_status='ALIAS',goods_id=%s,goods_code=%s,base_unit=%s," - "source_add_needed=%s,confirmed_by=%s,confirmed_at=%s WHERE id=%s", - (req.goods_id, req.goods_code, req.base_unit, source_add_needed, - req.confirmed_by, _now(), match_id), - ) - - if req.save_as_alias: - target = json.dumps({"goods_id": req.goods_id, "goods_code": req.goods_code, - "base_unit": req.base_unit}, ensure_ascii=False) - cur.execute( - "UPDATE mapping_config SET is_active=0,effective_to=%s,updated_by=%s,updated_at=%s" - " WHERE config_type='PRODUCT' AND config_key=%s AND is_active=1", - (_now(), req.confirmed_by, _now(), pm["match_key"]), - ) - cur.execute( - "INSERT INTO mapping_config(config_type,config_key,target_value,is_active," - "effective_from,updated_by,updated_at)" - " VALUES('PRODUCT',%s,%s,1,%s,%s,%s)", - (pm["match_key"], target, _now(), req.confirmed_by, _now()), - ) - - # 精确关掉这条对码结果对应的待办(按 product_match_id 关联,不再靠"最早 OPEN"猜) - cur.execute( - "UPDATE user_task SET status='SUBMITTED',submitted_by=%s,submitted_at=%s" - " WHERE product_match_id=%s AND status='OPEN'", - (req.confirmed_by, _now(), match_id), - ) - _log_event(cur, job_id, "TASK_SUBMITTED", "PRODUCT_MATCH", "USER", req.confirmed_by, - {"match_id": match_id, "goods_id": req.goods_id, - "source_add_needed": bool(source_add_needed)}) - - source_add_candidates = _sync_source_add_task(cur, job) - - # 还有没有未解决的对码?(product_match 里仍是 MULTI/NONE 的) - cur.execute( - "SELECT COUNT(*) AS c FROM product_match WHERE job_id=%s AND match_status IN ('MULTI','NONE')", - (job_id,), - ) - remaining = cur.fetchone()["c"] - if remaining == 0: - job = _load_job(cur, job_id) - if job.get("current_node_code") == "PRODUCT_MATCH": - cur.execute( - "UPDATE job_node SET status='SUCCESS',ended_at=%s" - " WHERE job_id=%s AND node_code='PRODUCT_MATCH' AND attempt_no=1", - (_now(), job_id), - ) - nxt = _advance(cur, job, "PRODUCT_MATCH") - conn.commit() - return {"ok": True, "all_resolved": True, "next_node": nxt, - "source_add_needed": bool(source_add_candidates)} - conn.commit() - return {"ok": True, "all_resolved": remaining == 0, "remaining": remaining, - "source_add_needed": bool(source_add_candidates)} - except Exception: - conn.rollback() - raise - finally: - conn.close() - - -# ---------------- 人工待办提交(Web/H5 用) ---------------- - -class TaskSubmitReq(BaseModel): - action: str - payload: Optional[dict] = None - submitToken: str - operator: Optional[str] = "user" - - -def _submit_candidate_selection(cur, task: dict, job: dict, payload: dict, operator: str) -> dict: - """在当前事务/连接中完成一条对码候选确认。""" - match_id = payload.get("productMatchId") - goods_id = payload.get("goodsId") - if not match_id or not goods_id: - raise HTTPException( - status_code=400, - detail="选择候选货品时必须提供 productMatchId 和 goodsId", - ) - if task.get("node_code") != "PRODUCT_MATCH": - raise HTTPException(status_code=400, detail="该待办不是对码待办") - if task.get("product_match_id") and int(match_id) != int(task["product_match_id"]): - raise HTTPException(status_code=409, detail="候选货品与当前待办不匹配") - if job.get("current_node_code") != "PRODUCT_MATCH": - raise HTTPException(status_code=409, detail="当前单据已不在对码节点") - - cur.execute("SELECT * FROM product_match WHERE id=%s", (match_id,)) - pm = cur.fetchone() - if not pm or pm.get("job_id") != task["job_id"]: - raise HTTPException(status_code=404, detail="对码结果不存在") - - oi = None - if pm.get("order_item_id"): - cur.execute( - "SELECT spec,manufacturer FROM order_item WHERE id=%s", - (pm["order_item_id"],), - ) - oi = cur.fetchone() - source_add_needed = _source_add_needed( - cur, - job, - goods_id, - (oi or {}).get("spec") or "", - (oi or {}).get("manufacturer") or "", - ) - goods_code = payload.get("goodsCode") - base_unit = payload.get("baseUnit") - cur.execute( - "UPDATE product_match SET match_status='ALIAS',goods_id=%s,goods_code=%s,base_unit=%s," - "source_add_needed=%s,confirmed_by=%s,confirmed_at=%s WHERE id=%s", - (goods_id, goods_code, base_unit, source_add_needed, operator, _now(), match_id), - ) - - if payload.get("saveAsAlias", True): - target = json.dumps( - {"goods_id": goods_id, "goods_code": goods_code, "base_unit": base_unit}, - ensure_ascii=False, - ) - cur.execute( - "UPDATE mapping_config SET is_active=0,effective_to=%s,updated_by=%s,updated_at=%s" - " WHERE config_type='PRODUCT' AND config_key=%s AND is_active=1", - (_now(), operator, _now(), pm["match_key"]), - ) - cur.execute( - "INSERT INTO mapping_config(config_type,config_key,target_value,is_active," - "effective_from,updated_by,updated_at) VALUES('PRODUCT',%s,%s,1,%s,%s,%s)", - (pm["match_key"], target, _now(), operator, _now()), - ) - - source_add_candidates = _sync_source_add_task(cur, job) - cur.execute( - "SELECT COUNT(*) AS c FROM product_match WHERE job_id=%s AND match_status IN ('MULTI','NONE')", - (task["job_id"],), - ) - remaining = cur.fetchone()["c"] - result = { - "ok": True, - "all_resolved": remaining == 0, - "remaining": remaining, - "source_add_needed": bool(source_add_candidates), - } - if remaining == 0: - cur.execute( - "UPDATE job_node SET status='SUCCESS',ended_at=%s" - " WHERE job_id=%s AND node_code='PRODUCT_MATCH' AND attempt_no=1", - (_now(), task["job_id"]), - ) - result["next_node"] = _advance(cur, job, "PRODUCT_MATCH") - return result - - -@app.post("/api/tasks/{task_id}/submit") -def task_submit(task_id: str, req: TaskSubmitReq, - x_agent_token: Optional[str] = Header(default=None)): - _check_agent(x_agent_token) - if not req.submitToken or len(req.submitToken) > 64: - raise HTTPException(status_code=400, detail="submitToken 最长 64") - - conn = get_conn() - began = False - try: - begin = getattr(conn, "begin", None) - if callable(begin): - begin() - began = True - result = _task_submit_body(conn, task_id, req) - if began: - conn.commit() - return result - except Exception: - rollback = getattr(conn, "rollback", None) - if callable(rollback): - rollback() - raise - finally: - close = getattr(conn, "close", None) - if callable(close): - close() - - -def _task_submit_body(conn, task_id: str, req: TaskSubmitReq): - with conn.cursor() as cur: - cur.execute("SELECT * FROM user_task WHERE task_id=%s", (task_id,)) - task = cur.fetchone() - if not task: - raise HTTPException(status_code=404, detail="task not found") - - existing_token = task.get("submit_token") - if existing_token: - if existing_token != req.submitToken: - raise HTTPException(status_code=409, detail="task already submitted with another token") - try: - result = json.loads(task.get("submitted_result_json") or "{}") - except (TypeError, ValueError): - result = {"ok": True} - result["idempotent"] = True - return result - - if task.get("status") != "OPEN": - raise HTTPException(status_code=409, detail=f"task status is {task.get('status')}") - - job = _load_job(cur, task["job_id"]) - node_code = task["node_code"] - payload = req.payload or {} - - if req.action == "SELECT_CANDIDATE": - result = _submit_candidate_selection(cur, task, job, payload, req.operator or "user") - elif req.action == "CONFIRM_AND_CONTINUE": - if job.get("current_node_code") != node_code: - raise HTTPException( - status_code=409, - detail=f"job 当前在 {job.get('current_node_code')},不能提交 {node_code}", - ) - - if node_code == "M5_RECEIPT_NOTICE": - receipt_no = payload.get("receiptNoticeNo") - if not receipt_no: - raise HTTPException(status_code=400, detail="M5_RECEIPT_NOTICE 需要 receiptNoticeNo") - cur.execute( - "UPDATE job SET receipt_notice_no=%s,updated_at=%s WHERE job_id=%s", - (receipt_no, _now(), task["job_id"]), - ) - - if node_code == "SOURCE_ADD": - cur.execute( - "UPDATE job SET needs_source_add=0,updated_at=%s WHERE job_id=%s", - (_now(), task["job_id"]), - ) - - _mark_node_success(cur, task["job_id"], node_code) - job = _load_job(cur, task["job_id"]) - nxt = _advance(cur, job, node_code) - result = {"ok": True, "next_node": nxt} - elif req.action == "TERMINATE_JOB": - cur.execute( - "UPDATE job SET status='CANCELLED',finished_at=%s,updated_at=%s WHERE job_id=%s", - (_now(), _now(), task["job_id"]), - ) - _log_event(cur, task["job_id"], "TASK_SUBMITTED", node_code, "USER", req.operator, - {"action": req.action, "payload": payload}) - result = {"ok": True, "job_status": "CANCELLED"} - elif req.action in ("TRANSFER_MANUAL", "RETRY_CURRENT_NODE"): - if req.action == "TRANSFER_MANUAL": - target_role = payload.get("targetRole") - if target_role: - cur.execute( - "UPDATE job SET current_role=%s,updated_at=%s WHERE job_id=%s", - (target_role, _now(), task["job_id"]), - ) - _log_event(cur, task["job_id"], "TASK_SUBMITTED", node_code, "USER", req.operator, - {"action": req.action, "payload": payload}) - result = {"ok": True, "job_status": job.get("status"), "node_code": node_code} - else: - raise HTTPException(status_code=400, detail=f"unsupported task action {req.action}") - - result_json = json.dumps(result, ensure_ascii=False) - cur.execute( - "UPDATE user_task SET status='SUBMITTED',submit_token=%s,submitted_action=%s," - "submitted_payload_json=%s,submitted_result_json=%s,submitted_by=%s,submitted_at=%s" - " WHERE task_id=%s", - ( - req.submitToken, - req.action, - json.dumps(payload, ensure_ascii=False), - result_json, - req.operator, - _now(), - task_id, - ), - ) - _log_event(cur, task["job_id"], "TASK_SUBMITTED", node_code, "USER", req.operator, - {"action": req.action, "payload": payload, "result": result}) - return result - - -# ---------------- 整单查询(前端画树状图用) ---------------- - -@app.get("/api/jobs/{job_id}") -def get_job(job_id: str): - conn = get_conn() - with conn.cursor() as cur: - cur.execute("SELECT * FROM job WHERE job_id=%s", (job_id,)) - job = cur.fetchone() - if not job: - raise HTTPException(status_code=404, detail="job not found") - cur.execute("SELECT node_code,attempt_no,status,started_at,ended_at,duration_ms," - "error_code,error_summary,output_json FROM job_node WHERE job_id=%s" - " ORDER BY id", (job_id,)) - nodes = cur.fetchall() - cur.execute("SELECT task_id,node_code,role_code,reason_type,status FROM user_task" - " WHERE job_id=%s ORDER BY id", (job_id,)) - tasks = cur.fetchall() - artifacts = _fetch_job_artifacts(cur, job_id) - return {"job": job, "nodes": nodes, "tasks": tasks, "artifacts": artifacts, - "node_order": NODE_ORDER, "node_executor": NODE_EXECUTOR, - "conditional_nodes": sorted(CONDITIONAL_NODES)} - - -@app.get("/api/jobs/{job_id}/timeline") -def get_job_timeline(job_id: str): - conn = get_conn() - with conn.cursor() as cur: - cur.execute("SELECT * FROM job WHERE job_id=%s", (job_id,)) - job = cur.fetchone() - if not job: - raise HTTPException(status_code=404, detail="job not found") - cur.execute( - "SELECT node_code,attempt_no,status,executor_type,started_at,ended_at " - "FROM job_node WHERE job_id=%s ORDER BY id", - (job_id,), - ) - nodes = cur.fetchall() - return {"job": job, "timeline": build_timeline(job, nodes)} - - -@app.get("/api/jobs/{job_id}/evidence-package") -def get_evidence_package(job_id: str): - conn = get_conn() - with conn.cursor() as cur: - cur.execute("SELECT job_id FROM job WHERE job_id=%s", (job_id,)) - job = cur.fetchone() - if not job: - raise HTTPException(status_code=404, detail="job not found") - artifacts = _fetch_job_artifacts(cur, job_id) - return {"job_id": job_id, "artifacts": artifacts} - - -@app.get("/health") -def health(): - conn = None - try: - conn = get_conn() - with conn.cursor() as cur: - cur.execute("SELECT 1") - cur.fetchone() - return {"ok": True, "database": "ok"} - except Exception as exc: - raise HTTPException(status_code=503, detail=f"database unavailable: {exc}") from exc - finally: - if conn is not None: - conn.close() - - -@app.get("/api/state-machine") -def get_state_machine(): - return build_state_machine_meta() diff --git a/state_center/app/match_engine.py b/state_center/app/match_engine.py deleted file mode 100644 index 171e169..0000000 --- a/state_center/app/match_engine.py +++ /dev/null @@ -1,215 +0,0 @@ -""" -对码引擎(核心难点)。 - -数据源: - - 货品主数据在 Oracle(只读)。开工时可全量 load 进内存,后续内存匹配。 - - mapping_config(PRODUCT):平台奇葩写法→货品ID 的人工确认累积,存在自己 MySQL。 - -查找顺序(命中率越用越高): - 1. 先查 mapping_config 累积别名 → 命中直接出货品ID(ALIAS,累积别名命中,非人工介入) - 2. 再查货品主数据(内存/Oracle)四要素精确键 → EXACT - 3. 规格/厂家容错后同商品名多条 → MULTI(出候选) - 4. 都没有 → NONE(转人工) - -对码结果状态只有 4 种:EXACT / ALIAS / MULTI / NONE。 -(无 FUZZY:本引擎不产出模糊命中档,拿不准就走 MULTI 出候选让人选。) -""" -from __future__ import annotations - -import re -from dataclasses import dataclass, field -from typing import Optional - - -# ---------- 规范化:对付"15×2 vs 30片""干一瓶 vs ×1瓶""进口英文厂家" ---------- - -_FULL2HALF = {ord(" "): " ", ord("("): "(", ord(")"): ")", ord("*"): "*", ord("×"): "*"} - - -def _norm_text(s: Optional[str]) -> str: - """通用规范化:去空格、全半角、大小写、常见标点。""" - if not s: - return "" - s = s.translate(_FULL2HALF) - s = s.lower().strip() - s = re.sub(r"[\s,,。.、;;::'\"“”‘’]+", "", s) - return s - - -def _norm_manufacturer(s: Optional[str]) -> str: - """厂家名容错:进口药英文名统一大小写、去标点(B.V. / S.r.l. 之类)。""" - if not s: - return "" - s = s.lower().strip() - s = re.sub(r"[.\s,]+", "", s) # 去点、空格、逗号 - return s - - -def _norm_spec(s: Optional[str]) -> str: - """ - 规格标准化。把 '15×2' '15*2' 展开成乘积;'30片' '×1瓶' 抽出数字。 - 返回一个可比较的规范串,如 '30' 或 '30片'。 - 这是痛点,规则先覆盖会议里点名的几种,后续可加。 - """ - if not s: - return "" - raw = s.translate(_FULL2HALF).lower().strip() - raw = re.sub(r"\s+", "", raw) - - # 形如 15*2 / 15x2 → 乘积 30 - m = re.search(r"(\d+(?:\.\d+)?)\*(\d+(?:\.\d+)?)", raw) - if m: - product = float(m.group(1)) * float(m.group(2)) - product = int(product) if product == int(product) else product - # 保留单位后缀(如 片/瓶),便于跟 '30片' 对齐 - unit = re.sub(r"[\d*.]+", "", raw) - return f"{product}{unit}" - - return raw - - -def build_match_key(common_name: str, approval_no: str, spec: str, manufacturer: str) -> str: - """四要素拼接键:通用名 + 批准文号 + 规格 + 厂家(全部规范化)。""" - return "|".join([ - _norm_text(common_name), - _norm_text(approval_no), - _norm_spec(spec), - _norm_manufacturer(manufacturer), - ]) - - -# ---------- 货品主数据(Oracle 镜像到内存) ---------- - -@dataclass -class GoodsRecord: - common_name: str - approval_no: str - spec: str - base_unit: str - manufacturer: str - goods_id: str - goods_code: str - - -@dataclass -class MatchResult: - order_item_id: int - match_key: str - match_status: str # EXACT / ALIAS / MULTI / NONE - goods_id: Optional[str] = None - goods_code: Optional[str] = None - base_unit: Optional[str] = None - candidates: list = field(default_factory=list) - - -class MatchEngine: - """ - 内存索引 + 两级查找。 - goods_index: 四要素键 -> [GoodsRecord] (可能一键多货品,即一对多) - name_index: 规范通用名 -> [GoodsRecord] (用于同商品名多规格出候选) - alias_map: mapping_config 累积别名 config_key -> (goods_id, goods_code, base_unit) - """ - - def __init__(self): - self.goods_index: dict[str, list[GoodsRecord]] = {} - self.name_index: dict[str, list[GoodsRecord]] = {} - self.alias_map: dict[str, tuple[str, str, str]] = {} - - # -- 装载 -- - def load_goods(self, records: list[GoodsRecord]) -> None: - self.goods_index.clear() - self.name_index.clear() - for r in records: - key = build_match_key(r.common_name, r.approval_no, r.spec, r.manufacturer) - self.goods_index.setdefault(key, []).append(r) - self.name_index.setdefault(_norm_text(r.common_name), []).append(r) - - def load_aliases(self, aliases: dict[str, tuple[str, str, str]]) -> None: - """aliases: config_key -> (goods_id, goods_code, base_unit)""" - self.alias_map = dict(aliases) - - # -- 单行对码 -- - def match_one(self, order_item_id: int, common_name: str, approval_no: str, - spec: str, manufacturer: str) -> MatchResult: - key = build_match_key(common_name, approval_no, spec, manufacturer) - - # 1) 累积别名优先(ALIAS = 历史人工确认沉淀的别名命中,不是当次人工介入) - if key in self.alias_map: - gid, gcode, unit = self.alias_map[key] - return MatchResult(order_item_id, key, "ALIAS", gid, gcode, unit) - - # 2) 四要素精确 - hits = self.goods_index.get(key, []) - if len(hits) == 1: - r = hits[0] - return MatchResult(order_item_id, key, "EXACT", r.goods_id, r.goods_code, r.base_unit) - if len(hits) > 1: - return MatchResult(order_item_id, key, "MULTI", - candidates=[self._cand(r) for r in hits]) - - # 3) 同商品名多规格 → 出候选给人工选 - name_hits = self.name_index.get(_norm_text(common_name), []) - if name_hits: - return MatchResult(order_item_id, key, "MULTI", - candidates=[self._cand(r) for r in name_hits]) - - # 4) 无命中 - return MatchResult(order_item_id, key, "NONE") - - @staticmethod - def _cand(r: GoodsRecord) -> dict: - return { - "goods_id": r.goods_id, - "goods_code": r.goods_code, - "common_name": r.common_name, - "spec": r.spec, - "base_unit": r.base_unit, - "manufacturer": r.manufacturer, - "approval_no": r.approval_no, - } - - -# ---------- Oracle 取数(拿到真实连接串后启用) ---------- - -def load_goods_from_oracle(settings) -> list[GoodsRecord]: - """ - 从 Oracle 只读拉全量货品主数据。 - 需要: pip install oracledb - 真实表名/列名在 config.py 里改。 - """ - import oracledb # 延迟导入,没配 Oracle 时不影响其他功能 - - sql = f""" - SELECT {settings.ORACLE_COL_COMMON_NAME}, - {settings.ORACLE_COL_APPROVAL_NO}, - {settings.ORACLE_COL_SPEC}, - {settings.ORACLE_COL_UNIT}, - {settings.ORACLE_COL_MANUFACTURER}, - {settings.ORACLE_COL_GOODS_ID}, - {settings.ORACLE_COL_GOODS_CODE} - FROM {settings.ORACLE_GOODS_TABLE} - """ - conn = oracledb.connect( - user=settings.ORACLE_USER, - password=settings.ORACLE_PASSWORD, - dsn=settings.ORACLE_DSN, - ) - try: - cur = conn.cursor() - cur.execute(sql) - rows = cur.fetchall() - finally: - conn.close() - - out = [] - for row in rows: - out.append(GoodsRecord( - common_name=str(row[0] or ""), - approval_no=str(row[1] or ""), - spec=str(row[2] or ""), - base_unit=str(row[3] or ""), - manufacturer=str(row[4] or ""), - goods_id=str(row[5] or ""), - goods_code=str(row[6] or ""), - )) - return out diff --git a/state_center/app/web_state_machine.py b/state_center/app/web_state_machine.py deleted file mode 100644 index a475a73..0000000 --- a/state_center/app/web_state_machine.py +++ /dev/null @@ -1,163 +0,0 @@ -"""Web 版状态机纯逻辑。 - -只放和 UI/接口共享的计算,不碰数据库。 -""" -from __future__ import annotations - -from collections.abc import Mapping, Sequence - -NODE_ORDER = [ - "ORDER_FETCH", "OCR_EXTRACT", "PRODUCT_MATCH", "SOURCE_ADD", - "CONTRACT_CREATE", "CONTRACT_APPROVE_WAIT", "REBATE_HEADER", - "REBATE_IMPORT", "REBATE_SUBMIT", "M5_CREATE_ORDER", - "M5_MANUAL_CONFIRM", "M5_RECEIPT_NOTICE", "ARCHIVE_JOB", -] - -TRANSITIONS = { - "ORDER_FETCH": "OCR_EXTRACT", - "OCR_EXTRACT": "PRODUCT_MATCH", - "PRODUCT_MATCH": "SOURCE_ADD", - "SOURCE_ADD": "CONTRACT_CREATE", - "CONTRACT_CREATE": "CONTRACT_APPROVE_WAIT", - "CONTRACT_APPROVE_WAIT": "REBATE_HEADER", - "REBATE_HEADER": "REBATE_IMPORT", - "REBATE_IMPORT": "REBATE_SUBMIT", - "REBATE_SUBMIT": "M5_CREATE_ORDER", - "M5_CREATE_ORDER": "M5_MANUAL_CONFIRM", - "M5_MANUAL_CONFIRM": "M5_RECEIPT_NOTICE", - "M5_RECEIPT_NOTICE": "ARCHIVE_JOB", - "ARCHIVE_JOB": None, -} - -CONDITIONAL_NODES = {"OCR_EXTRACT", "SOURCE_ADD"} -HUMAN_NODE_ROLES = { - "SOURCE_ADD": "PURCHASER", - "M5_MANUAL_CONFIRM": "PURCHASER", - "M5_RECEIPT_NOTICE": "PURCHASER", -} - -NODE_EXECUTOR = { - "ORDER_FETCH": "RPA", - "OCR_EXTRACT": "RPA", - "PRODUCT_MATCH": "STATE_CENTER", - "SOURCE_ADD": "HUMAN", - "CONTRACT_CREATE": "RPA", - "CONTRACT_APPROVE_WAIT": "RPA", - "REBATE_HEADER": "RPA", - "REBATE_IMPORT": "RPA", - "REBATE_SUBMIT": "RPA", - "M5_CREATE_ORDER": "RPA", - "M5_MANUAL_CONFIRM": "HUMAN", - "M5_RECEIPT_NOTICE": "HUMAN", - "ARCHIVE_JOB": "STATE_CENTER", -} - -NODE_LABELS_ZH = { - "ORDER_FETCH": "订单获取", - "OCR_EXTRACT": "OCR识别", - "PRODUCT_MATCH": "对码", - "SOURCE_ADD": "新增货源", - "CONTRACT_CREATE": "合同建单", - "CONTRACT_APPROVE_WAIT": "合同审批等待", - "REBATE_HEADER": "返利总单", - "REBATE_IMPORT": "返利细单导入", - "REBATE_SUBMIT": "返利送审", - "M5_CREATE_ORDER": "M5建单", - "M5_MANUAL_CONFIRM": "M5人工确认", - "M5_RECEIPT_NOTICE": "收货通知单", - "ARCHIVE_JOB": "归档", -} - -EXECUTOR_LABELS_ZH = { - "STATE_CENTER": "状态中心", - "RPA": "艺赛旗执行", - "HUMAN": "人工处理", -} - -STATUS_LABELS_ZH = { - "PENDING": "待处理", - "RUNNING": "执行中", - "SUCCESS": "已完成", - "FAILED": "已失败", - "WAITING_USER": "等待人工", - "WAITING_EXTERNAL": "等待外部", - "SKIPPED": "已跳过", -} - - -def _should_skip(node_code: str, job: Mapping[str, object]) -> bool: - if node_code == "OCR_EXTRACT": - return job.get("source_type") != "OFFLINE" - if node_code == "SOURCE_ADD": - return not job.get("needs_source_add") - return False - - -def _node_row_map(job_nodes: Sequence[Mapping[str, object]]) -> dict[str, Mapping[str, object]]: - out: dict[str, Mapping[str, object]] = {} - for row in job_nodes: - node_code = str(row.get("node_code") or "") - if node_code and node_code not in out: - out[node_code] = row - return out - - -def _display_status_for_node(job: Mapping[str, object], node_code: str, - job_nodes_by_code: Mapping[str, Mapping[str, object]]) -> str: - row = job_nodes_by_code.get(node_code) - if row: - row_status = str(row.get("status") or "PENDING") - if row_status in {"SUCCESS", "FAILED", "RUNNING", "WAITING_USER", "WAITING_EXTERNAL"}: - return row_status - return "PENDING" - - if node_code == job.get("current_node_code"): - job_status = str(job.get("status") or "PENDING") - if job_status in {"RUNNING", "WAITING_USER", "WAITING_EXTERNAL", "FAILED_RETRYABLE", "FAILED_MANUAL"}: - return "WAITING_USER" if job_status == "WAITING_USER" else ( - "WAITING_EXTERNAL" if job_status == "WAITING_EXTERNAL" else ( - "RUNNING" if job_status == "RUNNING" else "FAILED" - ) - ) - if job_status == "SUCCESS": - return "SUCCESS" - return "PENDING" - - if node_code in CONDITIONAL_NODES and _should_skip(node_code, job): - return "SKIPPED" - - return "PENDING" - - -def build_timeline(job: Mapping[str, object], job_nodes: Sequence[Mapping[str, object]]) -> list[dict]: - """Return all nodes with backend-computed display status. - - The frontend should render this directly and never infer node skipping itself. - """ - by_code = _node_row_map(job_nodes) - timeline = [] - for node_code in NODE_ORDER: - row = by_code.get(node_code) - timeline.append({ - "node_code": node_code, - "display_status": _display_status_for_node(job, node_code, by_code), - "attempt_no": row.get("attempt_no") if row else None, - "executor_type": row.get("executor_type") if row else None, - "status": row.get("status") if row else None, - "started_at": row.get("started_at") if row else None, - "ended_at": row.get("ended_at") if row else None, - }) - return timeline - - -def build_state_machine_meta() -> dict: - return { - "node_order": NODE_ORDER, - "transitions": TRANSITIONS, - "conditional_nodes": sorted(CONDITIONAL_NODES), - "human_node_roles": HUMAN_NODE_ROLES, - "node_executor": NODE_EXECUTOR, - "node_labels": NODE_LABELS_ZH, - "executor_labels": EXECUTOR_LABELS_ZH, - "status_labels": STATUS_LABELS_ZH, - } diff --git a/state_center/fixtures/README.md b/state_center/fixtures/README.md deleted file mode 100644 index 611c45c..0000000 --- a/state_center/fixtures/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# 对码入参底座(一条,福建货主) - -订单明细走这里,不走证据包。本批只有一条:重庆亿昊 / 哈药牌钙铁锌口服液。 - -## M5 - -- 地址默认 `173.1.1.215:1521`,Service Name `m5` -- 账号密码可配,不进 git:环境变量 `M5_USER` `M5_PASSWORD`,或复制 `m5/connection.example.json` → `m5/connection.local.json` -- 查询:品名 LIKE + 文号 + 规格 + 厂家 + `ENTRYID=907` -- 产地空或「无」不参与过滤 -- 回写 `goods_id` = `GOODSID`(`4000109983`) -- 对码命中后再查货源:`bms_su_supplygoods_doc_v` × `bms_su_supplygoods_dtl_v`,`PRIORITY` 必须是 1,且 `ENTRYID/GOODSTYPE/FACTORYNAME` 对上这条规格。对上则跳过新增货源。 - -查这一条: - -```bash -export M5_USER=... -export M5_PASSWORD=... -python -m state_center.app.m5_client -``` diff --git a/state_center/fixtures/ingest_mapping.json b/state_center/fixtures/ingest_mapping.json deleted file mode 100644 index 325b907..0000000 --- a/state_center/fixtures/ingest_mapping.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "version": 1, - "comment": "Excel 列 → 订单入参。本批只收一条。条码/医保不进 order_item,也不拿去查 M5。账号密码不写在这份文件里。", - "sheet": "订单明细", - "skipStatuses": ["已取消"], - "jobGroupBy": ["supplierName", "orderDate", "sourceType"], - "columns": { - "父订单号": "parentOrderNo", - "商家": "supplierName", - "订单状态": "orderStatus", - "品名": "rawProductName", - "产品编码": "platformProductCode", - "产品条码": "barcode", - "医保编码": "insuranceCode", - "批准文号": "approvalNo" - }, - "orderItem": [ - "orderNo", - "rawProductName", - "platformProductCode", - "spec", - "manufacturer", - "approvalNo", - "qty", - "sourceRowNo" - ], - "m5Lookup": { - "view": "PUB_ENTRY_GOODS_V", - "goodsIdField": "GOODSID", - "filters": { - "GOODSNAME": { "from": "rawProductName", "op": "like" }, - "APPROVEDOCNO": { "from": "approvalNo", "op": "eq" }, - "GOODSTYPE": { "from": "spec", "op": "eq" }, - "FACTORYNAME": { "from": "manufacturer", "op": "eq" }, - "ENTRYID": { "from": "entryId", "op": "eq" } - }, - "optionalFilters": { - "PRODAREA": { "from": "prodArea", "skipValues": ["", "无", null] } - } - } -} diff --git a/state_center/fixtures/m5/connection.example.json b/state_center/fixtures/m5/connection.example.json deleted file mode 100644 index fcadf3a..0000000 --- a/state_center/fixtures/m5/connection.example.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "host": "173.1.1.215", - "port": 1521, - "serviceName": "m5", - "user": "", - "password": "", - "schema": "m5", - "comment": "复制为 connection.local.json 填账号密码。local 文件不进 git。也可用环境变量 M5_USER / M5_PASSWORD / M5_SERVICE。" -} diff --git a/state_center/fixtures/m5/fujian.json b/state_center/fixtures/m5/fujian.json deleted file mode 100644 index 777a7ee..0000000 --- a/state_center/fixtures/m5/fujian.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "entryName": "福建", - "entryId": 907, - "host": "173.1.1.215", - "port": 1521, - "serviceName": "m5", - "schema": "m5", - "view": "PUB_ENTRY_GOODS_V", - "goodsIdField": "GOODSID", - "requireProdArea": false, - "sql": "SELECT t.FACTORYNAME, t.APPROVEDOCNO, t.GOODSTYPE, t.PRODAREA, t.ENTRYID, t.GOODSID, t.GOODSNAME, t.GOODSUNIT, t.ENTRYGOODSID FROM PUB_ENTRY_GOODS_V t WHERE t.GOODSNAME LIKE :goodsNameLike AND t.APPROVEDOCNO = :approvalNo AND t.GOODSTYPE = :spec AND t.FACTORYNAME = :manufacturer AND t.ENTRYID = :entryId", - "sourceSql": "SELECT t.ENTRYID, a.PRIORITY, t.GOODSTYPE, t.FACTORYNAME FROM bms_su_supplygoods_doc_v t, bms_su_supplygoods_dtl_v a WHERE t.GOODSID = :goodsId AND a.PRIORITY = 1 AND t.SUPPLYGOODSDOCID = a.SUPPLYGOODSDOCID" -} diff --git a/state_center/fixtures/orders/yyc-fujian-20260804.json b/state_center/fixtures/orders/yyc-fujian-20260804.json deleted file mode 100644 index ec3eff8..0000000 --- a/state_center/fixtures/orders/yyc-fujian-20260804.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "sourceType": "YYC", - "orderDate": "2026-08-04", - "supplierName": "重庆亿昊药业有限公司", - "m5": { - "entryName": "福建", - "entryId": 907 - }, - "row": { - "orderNo": "YYC-ROW-2", - "parentOrderNo": "", - "rawProductName": "哈药牌钙铁锌口服液", - "goodsNameLike": "哈药牌", - "platformProductCode": "2919482534", - "barcode": "6938780031263", - "approvalNo": "食健备G201923000558", - "spec": "10ml*90支", - "manufacturer": "哈药集团三精制药有限公司", - "prodArea": "无", - "qty": 83, - "orderStatus": "待付款", - "sourceRowNo": 2, - "expectedGoodsId": "4000109983", - "expectedSourceAddNeeded": false - } -} diff --git a/state_center/requirements.txt b/state_center/requirements.txt deleted file mode 100644 index 2e5826c..0000000 --- a/state_center/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -fastapi==0.115.0 -uvicorn==0.30.6 -pydantic==2.9.2 -pymysql==1.1.1 -cryptography==50.0.0 -# 对码接 Oracle 时再装(拿到连接串后): -# oracledb==2.4.1 diff --git a/state_center/sql/schema.sql b/state_center/sql/schema.sql deleted file mode 100644 index 5596445..0000000 --- a/state_center/sql/schema.sql +++ /dev/null @@ -1,205 +0,0 @@ --- 采购主链 RPA 一期 · 状态中心建库脚本 --- 库里只存"流程状态 + 对码累积别名 + 证据",不存对码表本身(对码表在 Oracle,直连查)。 --- 目标库:MySQL 8 - -CREATE DATABASE IF NOT EXISTS rpa_center DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; -USE rpa_center; - --- ============================================================ --- job:一次流程实例 = 同供应商 + 同订单日期 + 同来源 = 一条合同 --- ============================================================ -CREATE TABLE IF NOT EXISTS job ( - id BIGINT PRIMARY KEY AUTO_INCREMENT, - job_id VARCHAR(64) NOT NULL UNIQUE COMMENT '对外流程单号', - business_key VARCHAR(160) NOT NULL COMMENT '来源+供应商+订单日期,幂等用', - parent_job_id VARCHAR(64) NULL COMMENT '重跑/加单来源', - source_type VARCHAR(16) NOT NULL COMMENT 'YYC医药城/XYY小药药/OFFLINE线下单', - order_date DATE NOT NULL COMMENT '订单日期=合同签约日期', - supplier_name VARCHAR(128) NOT NULL COMMENT '供应商/商家', - order_no_list_json JSON NULL COMMENT '该批订单号数组', - contract_template VARCHAR(16) NULL COMMENT 'FORMAT格式/NON_FORMAT非格式承兑', - need_pdf_upload TINYINT NOT NULL DEFAULT 0 COMMENT '医药城=1,小药药=0', - planner_name VARCHAR(64) NULL COMMENT '计划员', - current_node_code VARCHAR(64) NOT NULL COMMENT '当前节点', - status VARCHAR(24) NOT NULL COMMENT 'PENDING/RUNNING/WAITING_USER/WAITING_EXTERNAL/SUCCESS/FAILED_RETRYABLE/FAILED_MANUAL/CANCELLED', - mapping_version VARCHAR(64) NULL COMMENT '绑定的对码累积版本', - needs_source_add TINYINT NOT NULL DEFAULT 0 COMMENT '对码后判定是否缺货源,决定是否走 SOURCE_ADD 条件节点', - executor_platform VARCHAR(16) NULL COMMENT '默认执行平台,如 ISRPA', - contract_system_no VARCHAR(64) NULL COMMENT '合同系统编号(钥匙1)', - rebate_order_no VARCHAR(64) NULL COMMENT '返利中心总单号', - m5_order_no VARCHAR(64) NULL COMMENT 'M5采购订单号', - receipt_notice_no VARCHAR(64) NULL COMMENT '收货通知单号', - current_role VARCHAR(16) NULL COMMENT '当前责任角色', - current_assignee VARCHAR(64) NULL COMMENT '当前责任人', - auto_save_enabled TINYINT NOT NULL DEFAULT 0 COMMENT 'M5自动保存开关,一期=0', - next_check_at DATETIME NULL COMMENT '等待态(如合同审批)下次检查时间,调度器扫这个', - wait_check_count INT NOT NULL DEFAULT 0 COMMENT '当前等待态已检查次数,超上限升级OPS', - created_by VARCHAR(64) NOT NULL, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - finished_at DATETIME NULL, - KEY idx_business_status (business_key, status), - KEY idx_supplier_date (supplier_name, order_date), - KEY idx_status (status), - KEY idx_next_check (status, next_check_at), - KEY idx_created_at (created_at) -) COMMENT='流程实例主表'; - --- ============================================================ --- order_item:订单明细行(对码前的原始数据) --- ============================================================ -CREATE TABLE IF NOT EXISTS order_item ( - id BIGINT PRIMARY KEY AUTO_INCREMENT, - job_id VARCHAR(64) NOT NULL, - order_no VARCHAR(64) NOT NULL COMMENT '订单号(贯穿追溯)', - parent_order_no VARCHAR(64) NULL COMMENT '父订单号(加单/拆单)', - raw_product_name VARCHAR(255) NOT NULL COMMENT '平台品名', - platform_product_code VARCHAR(64) NULL COMMENT '平台产品编码', - spec VARCHAR(128) NULL COMMENT '规格(对码痛点:15×2 vs 30片)', - manufacturer VARCHAR(255) NULL COMMENT '厂家(含进口英文)', - approval_no VARCHAR(64) NULL COMMENT '国药准字/批准文号', - qty DECIMAL(14,2) NOT NULL COMMENT '数量', - unit_price DECIMAL(14,4) NULL COMMENT '单价', - source_row_no INT NULL COMMENT '原始Excel行号', - created_at DATETIME NOT NULL, - KEY idx_job (job_id), - KEY idx_order_no (order_no) -) COMMENT='订单明细行'; - --- ============================================================ --- product_match:对码结果(核心难点落库) --- ============================================================ -CREATE TABLE IF NOT EXISTS product_match ( - id BIGINT PRIMARY KEY AUTO_INCREMENT, - job_id VARCHAR(64) NOT NULL, - order_item_id BIGINT NOT NULL COMMENT '对应订单行', - match_key VARCHAR(512) NOT NULL COMMENT '四要素拼接键:通用名+批准文号+规格+厂家', - match_status VARCHAR(16) NOT NULL COMMENT 'EXACT精确/ALIAS别名命中/MULTI一对多/NONE未命中(无FUZZY)', - goods_id VARCHAR(64) NULL COMMENT '命中的M5货品ID(来自Oracle)', - goods_code VARCHAR(64) NULL COMMENT '货品编码', - base_unit VARCHAR(32) NULL COMMENT '基本单位', - candidates_json JSON NULL COMMENT '一对多候选列表(供H5选择)', - source_add_needed TINYINT NOT NULL DEFAULT 0 COMMENT '是否需M5新增货源', - confirmed_by VARCHAR(64) NULL COMMENT '人工确认人', - confirmed_at DATETIME NULL, - mapping_version VARCHAR(64) NULL COMMENT '使用的对码累积版本', - created_at DATETIME NOT NULL, - UNIQUE KEY uniq_job_order_item (job_id, order_item_id), - KEY idx_job (job_id), - KEY idx_status (match_status) -) COMMENT='对码结果'; - --- ============================================================ --- job_node:节点执行(每节点每次尝试一行,不覆盖历史) --- ============================================================ -CREATE TABLE IF NOT EXISTS job_node ( - id BIGINT PRIMARY KEY AUTO_INCREMENT, - job_id VARCHAR(64) NOT NULL, - node_code VARCHAR(64) NOT NULL COMMENT 'ORDER_FETCH/PRODUCT_MATCH/...', - executor_type VARCHAR(16) NOT NULL DEFAULT 'RPA' COMMENT '执行方:STATE_CENTER编排/RPA执行/HUMAN人工挂起', - attempt_no INT NOT NULL DEFAULT 1 COMMENT '第几次尝试', - status VARCHAR(24) NOT NULL COMMENT '节点状态', - executor_platform VARCHAR(16) NULL COMMENT '实际执行平台', - executor_run_id VARCHAR(64) NULL COMMENT '平台侧任务号', - started_at DATETIME NULL, - ended_at DATETIME NULL, - duration_ms BIGINT NULL, - error_code VARCHAR(64) NULL, - error_summary VARCHAR(500) NULL, - role_at_failure VARCHAR(16) NULL, - output_json JSON NULL COMMENT '节点输出(业务编号等)', - callback_idempotency_key VARCHAR(128) NULL COMMENT '回调幂等键', - created_at DATETIME NOT NULL, - UNIQUE KEY uniq_job_node_attempt (job_id, node_code, attempt_no), - KEY idx_status (status) -) COMMENT='节点执行记录'; - --- ============================================================ --- user_task:人工待办 --- ============================================================ -CREATE TABLE IF NOT EXISTS user_task ( - id BIGINT PRIMARY KEY AUTO_INCREMENT, - task_id VARCHAR(64) NOT NULL UNIQUE, - job_id VARCHAR(64) NOT NULL, - node_code VARCHAR(64) NOT NULL, - order_item_id BIGINT NULL COMMENT '对应订单行(对码类待办用)', - product_match_id BIGINT NULL COMMENT '对应对码结果行(对码类待办精确关联,confirm 时按它关闭)', - role_code VARCHAR(16) NOT NULL COMMENT 'PLANNER/PURCHASER/QC/OPS', - assignee_user_id VARCHAR(64) NULL, - title VARCHAR(255) NOT NULL, - reason_type VARCHAR(32) NOT NULL COMMENT 'MATCH_MULTI/MATCH_NONE/SOURCE_ADD/OCR_LOW_CONF/REBATE_DUP/QC_MISSING/CONTRACT_DISABLED', - reason_user_friendly VARCHAR(500) NULL COMMENT '人话说明', - why_me_text VARCHAR(255) NULL COMMENT '为什么派给你', - editable_fields_json JSON NULL COMMENT '白名单字段', - action_schema_json JSON NULL COMMENT '允许动作', - comparison_snapshot_json JSON NULL COMMENT '原值/建议/当前对比', - candidates_json JSON NULL COMMENT '一对多对码候选', - status VARCHAR(16) NOT NULL DEFAULT 'OPEN' COMMENT 'OPEN/SUBMITTED/CANCELLED/EXPIRED', - submit_token VARCHAR(64) NULL COMMENT '防重复提交', - submitted_action VARCHAR(24) NULL, - submitted_payload_json JSON NULL, - submitted_result_json JSON NULL COMMENT '提交结果,同 submit_token 重放时返回', - submitted_by VARCHAR(64) NULL, - submitted_at DATETIME NULL, - due_at DATETIME NULL COMMENT '超时升级', - created_at DATETIME NOT NULL, - KEY idx_job (job_id), - KEY idx_role_status (role_code, status), - KEY idx_product_match (product_match_id) -) COMMENT='人工待办'; - --- ============================================================ --- mapping_config:对码累积别名 + 供应商对照等映射(版本化) --- 注意:这不是对码表的拷贝!对码表在 Oracle。 --- 这里存的是"平台奇葩写法 → 货品ID"的人工确认记录,以及供应商对照。 --- ============================================================ -CREATE TABLE IF NOT EXISTS mapping_config ( - id BIGINT PRIMARY KEY AUTO_INCREMENT, - config_type VARCHAR(24) NOT NULL COMMENT 'PRODUCT对码别名/SUPPLIER供应商对照/SETTLEMENT/PAYMENT_TERM/PLANNER_ROUTE', - config_key VARCHAR(512) NOT NULL COMMENT '对码时=四要素拼接键或平台写法', - source_value VARCHAR(512) NULL COMMENT '来源值', - target_value VARCHAR(512) NULL COMMENT '目标值(对码=货品ID+编码+单位JSON)', - extra_json JSON NULL, - version_no VARCHAR(64) NULL COMMENT '版本号', - is_active TINYINT NOT NULL DEFAULT 1, - effective_from DATETIME NULL, - effective_to DATETIME NULL, - updated_by VARCHAR(64) NULL, - updated_at DATETIME NOT NULL, - KEY idx_type_key (config_type, config_key), - KEY idx_type_key_active (config_type, config_key, is_active, updated_at, id), - KEY idx_active (is_active) -) COMMENT='映射/对码累积别名(非对码表拷贝)'; - --- ============================================================ --- job_artifact:证据元数据(文件落对象存储,这里存元数据) --- ============================================================ -CREATE TABLE IF NOT EXISTS job_artifact ( - id BIGINT PRIMARY KEY AUTO_INCREMENT, - job_id VARCHAR(64) NOT NULL, - node_code VARCHAR(64) NULL, - attempt_no INT NULL, - artifact_type VARCHAR(32) NOT NULL COMMENT 'SOURCE_EXCEL/SOURCE_IMAGE/OCR_JSON/CONTRACT_PDF/MERGED_PDF/SCREENSHOT/TEMPLATE/LOG', - storage_path VARCHAR(500) NOT NULL, - file_hash VARCHAR(128) NULL, - visible_roles VARCHAR(128) NULL COMMENT '可见角色', - is_masked TINYINT NOT NULL DEFAULT 0 COMMENT '脱敏标记', - created_at DATETIME NOT NULL, - KEY idx_job (job_id) -) COMMENT='证据元数据'; - --- ============================================================ --- job_event:事件流(审计留痕) --- ============================================================ -CREATE TABLE IF NOT EXISTS job_event ( - id BIGINT PRIMARY KEY AUTO_INCREMENT, - job_id VARCHAR(64) NOT NULL, - event_type VARCHAR(32) NOT NULL COMMENT 'JOB_CREATED/NODE_STARTED/NODE_SUCCESS/NODE_FAILED/TASK_CREATED/TASK_SUBMITTED/MANUAL_HANDOFF/JOB_FINISHED', - node_code VARCHAR(64) NULL, - operator_type VARCHAR(16) NOT NULL COMMENT 'SYSTEM/ROBOT/USER', - operator VARCHAR(64) NULL, - detail_json JSON NULL, - created_at DATETIME NOT NULL, - KEY idx_job (job_id), - KEY idx_type (event_type) -) COMMENT='事件流'; diff --git a/state_center/tests/test_dev_launcher.py b/state_center/tests/test_dev_launcher.py deleted file mode 100644 index a7b6d80..0000000 --- a/state_center/tests/test_dev_launcher.py +++ /dev/null @@ -1,111 +0,0 @@ -from scripts import dev - - -def test_backend_runtime_probe_checks_mysql8_auth_dependency(monkeypatch): - commands = [] - - monkeypatch.setattr(dev, "_backend_python", lambda: "/tmp/backend-python") - - def fake_run(command, **kwargs): - commands.append(command) - - class Result: - returncode = 0 - stderr = "" - - return Result() - - monkeypatch.setattr(dev.subprocess, "run", fake_run) - - assert dev._ensure_backend_runtime() == "/tmp/backend-python" - assert commands[0] == [ - "/tmp/backend-python", - "-c", - "import fastapi, pymysql, uvicorn, cryptography", - ] - - -def test_backend_environment_has_local_defaults(monkeypatch): - for name in ("DEV_MODE", "MYSQL_DSN", "AGENT_TOKEN"): - monkeypatch.delenv(name, raising=False) - - env = dev.build_backend_env({}) - - assert env["DEV_MODE"] == "true" - assert env["MYSQL_DSN"] == "mysql+pymysql://root:root@127.0.0.1:3306/rpa_center?charset=utf8mb4" - assert env["AGENT_TOKEN"] == "dev-agent-token" - - -def test_start_uses_checked_backend_python(monkeypatch): - spawned = [] - - monkeypatch.setattr(dev, "ensure_mysql", lambda: None) - monkeypatch.setattr(dev, "wait_for_port", lambda host, port: None) - monkeypatch.setattr(dev, "_ensure_backend_runtime", lambda: "/tmp/backend-python") - monkeypatch.setattr(dev, "_read_pids", lambda: {}) - monkeypatch.setattr(dev, "_pid_alive", lambda pid: False) - monkeypatch.setattr(dev, "_port_open", lambda host, port: False) - monkeypatch.setattr(dev, "_write_pids", lambda pids: None) - monkeypatch.setattr(dev, "build_backend_env", lambda: {}) - - def fake_spawn(name, command, cwd, env): - spawned.append((name, command)) - return len(spawned) - - monkeypatch.setattr(dev, "_spawn", fake_spawn) - - dev.start() - - assert spawned[0] == ( - "backend", - [ - "/tmp/backend-python", - "-m", - "uvicorn", - "state_center.app.main:app", - "--host", - "0.0.0.0", - "--port", - "8000", - ], - ) - - -def test_start_reuses_frontend_when_port_is_already_open(monkeypatch): - spawned = [] - - monkeypatch.setattr(dev, "ensure_mysql", lambda: None) - monkeypatch.setattr(dev, "wait_for_port", lambda host, port: None) - monkeypatch.setattr(dev, "_ensure_backend_runtime", lambda: "/tmp/backend-python") - monkeypatch.setattr(dev, "_read_pids", lambda: {}) - monkeypatch.setattr(dev, "_write_pids", lambda pids: None) - monkeypatch.setattr(dev, "build_backend_env", lambda: {}) - monkeypatch.setattr(dev, "_port_open", lambda host, port: port == 5173) - - def fake_spawn(name, command, cwd, env): - spawned.append(name) - return len(spawned) - - monkeypatch.setattr(dev, "_spawn", fake_spawn) - - dev.start() - - assert spawned == ["backend"] - - -def test_pid_alive_rejects_invalid_pid(): - assert dev._pid_alive(-1) is False - assert dev._pid_alive(0) is False - - -def test_stop_pid_terminates_process_group_on_posix(monkeypatch): - calls = [] - - monkeypatch.setattr(dev.os, "name", "posix") - monkeypatch.setattr(dev.os, "getpgid", lambda pid: 4321) - monkeypatch.setattr(dev.os, "killpg", lambda pgid, sig: calls.append(("killpg", pgid, sig))) - monkeypatch.setattr(dev.os, "kill", lambda pid, sig: calls.append(("kill", pid, sig))) - - dev._stop_pid(1234) - - assert calls == [("killpg", 4321, dev.signal.SIGTERM)] diff --git a/state_center/tests/test_m5_client.py b/state_center/tests/test_m5_client.py deleted file mode 100644 index 5bcbb87..0000000 --- a/state_center/tests/test_m5_client.py +++ /dev/null @@ -1,101 +0,0 @@ -from state_center.app.m5_client import ( - evaluate_priority_source, - load_connection, - lookup_params, - recorded_source_rows, -) - - -def test_lookup_params_use_the_single_fujian_row(): - params = lookup_params() - assert params["goodsNameLike"] == "%哈药牌%" - assert params["approvalNo"] == "食健备G201923000558" - assert params["spec"] == "10ml*90支" - assert params["manufacturer"] == "哈药集团三精制药有限公司" - assert params["entryId"] == 907 - assert params["expectedGoodsId"] == "4000109983" - assert "PUB_ENTRY_GOODS_V" in params["sql"] - assert "PRODAREA" not in params["sql"].split("WHERE", 1)[1] - - -def test_m5_host_defaults_and_password_stays_out_of_repo(monkeypatch): - monkeypatch.delenv("M5_USER", raising=False) - monkeypatch.delenv("M5_PASSWORD", raising=False) - monkeypatch.delenv("M5_SERVICE", raising=False) - cfg = load_connection() - assert cfg["host"] == "173.1.1.215" - assert cfg["port"] == 1521 - assert cfg["serviceName"] == "m5" - assert cfg["password"] == "" - - -def test_priority_1_matching_spec_skips_source_add(): - judged = evaluate_priority_source( - [ - { - "ENTRYID": 907, - "PRIORITY": 1, - "GOODSTYPE": "10ml*90支", - "FACTORYNAME": "哈药集团三精制药有限公司", - } - ], - entry_id=907, - spec="10ml*90支", - manufacturer="哈药集团三精制药有限公司", - ) - assert judged["source_add_needed"] is False - - -def test_priority_1_other_spec_needs_source_add(): - judged = evaluate_priority_source( - [ - { - "ENTRYID": 907, - "PRIORITY": 1, - "GOODSTYPE": "10ml*10支", - "FACTORYNAME": "哈药集团三精制药有限公司", - } - ], - entry_id=907, - spec="10ml*90支", - manufacturer="哈药集团三精制药有限公司", - ) - assert judged["source_add_needed"] is True - - -def test_lookup_params_include_source_sql(): - params = lookup_params() - assert "bms_su_supplygoods_doc_v" in params["sourceSql"] - assert params["expectedSourceAddNeeded"] is False - - -def test_recorded_source_rows_cover_hayao_without_m5(): - rows = recorded_source_rows("4000109983") - assert rows is not None - judged = evaluate_priority_source( - rows, - entry_id=907, - spec="10ml*90支", - manufacturer="哈药集团三精制药有限公司", - ) - assert judged["source_add_needed"] is False - assert recorded_source_rows("other") is None - - -def test_hayao_priority1_sql_row_skips_source_add(): - """第二条 SQL 的真实结果:907 / 1 / 10ml*90支 / 哈药集团三精制药有限公司。""" - judged = evaluate_priority_source( - [ - { - "ENTRYID": 907, - "PRIORITY": 1, - "GOODSTYPE": "10ml*90支", - "FACTORYNAME": "哈药集团三精制药有限公司", - } - ], - entry_id=907, - spec="10ml*90支", - manufacturer="哈药集团三精制药有限公司", - ) - assert judged["source_add_needed"] is False - assert judged["hit"]["ENTRYID"] == 907 diff --git a/state_center/tests/test_match_engine.py b/state_center/tests/test_match_engine.py deleted file mode 100644 index 22bf315..0000000 --- a/state_center/tests/test_match_engine.py +++ /dev/null @@ -1,107 +0,0 @@ -"""对码引擎单测:不依赖 DB / Oracle,纯内存验证四要素匹配 + 规格标准化。 -运行: python -m pytest state_center/tests/test_match_engine.py -v -或直接: python state_center/tests/test_match_engine.py -""" -import os -import sys - -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) - -from state_center.app.match_engine import ( # noqa: E402 - MatchEngine, GoodsRecord, build_match_key, _norm_spec, -) - - -def _engine(): - eng = MatchEngine() - eng.load_goods([ - GoodsRecord("阿卡波糖片", "国药准字H19990001", "50mg*30", "盒", "拜耳医药", "G001", "C001"), - GoodsRecord("哈药牌钙铁锌口服液", "国药准字Z20000002", "10ml*10", "盒", "哈药集团", "G002", "C002"), - # 同名多规格(用于一对多) - GoodsRecord("阿卡波糖片", "国药准字H19990001", "50mg*60", "盒", "拜耳医药", "G003", "C003"), - ]) - return eng - - -def test_spec_normalize_product(): - # 15×2 展开成 30 - assert _norm_spec("15×2") == _norm_spec("15*2") - assert _norm_spec("50mg*30").startswith("1500") or "mg" in _norm_spec("50mg*30") - - -def test_exact_hit(): - eng = _engine() - r = eng.match_one(1, "哈药牌钙铁锌口服液", "国药准字Z20000002", "10ml*10", "哈药集团") - assert r.match_status == "EXACT" - assert r.goods_id == "G002" - - -def test_multi_same_name(): - eng = _engine() - # 规格写法对不上任何精确键,但商品名命中多条 → MULTI - r = eng.match_one(2, "阿卡波糖片", "国药准字H19990001", "写个对不上的规格", "拜耳医药") - assert r.match_status == "MULTI" - assert len(r.candidates) >= 2 - - -def test_none(): - eng = _engine() - r = eng.match_one(3, "不存在的药", "X", "1", "无") - assert r.match_status == "NONE" - - -def test_fujian_hayao_exact_from_m5_row(): - eng = MatchEngine() - eng.load_goods( - [ - GoodsRecord( - "哈药牌钙铁锌口服液", - "食健备G201923000558", - "10ml*90支", - "盒", - "哈药集团三精制药有限公司", - "4000109983", - "4000109983", - ) - ] - ) - r = eng.match_one( - 1, - "哈药牌钙铁锌口服液", - "食健备G201923000558", - "10ml*90支", - "哈药集团三精制药有限公司", - ) - assert r.match_status == "EXACT" - assert r.goods_id == "4000109983" - - -def test_alias_priority(): - eng = _engine() - key = build_match_key("阿卡波糖", "", "30片", "") # 平台奇葩写法 - eng.load_aliases({key: ("G001", "C001", "盒")}) - r = eng.match_one(4, "阿卡波糖", "", "30片", "") - assert r.match_status == "ALIAS" - assert r.goods_id == "G001" - - -def test_manufacturer_tolerance(): - # 进口英文厂家:大小写/标点容错 - k1 = build_match_key("药A", "H1", "1", "Abbott Biologicals B.V.") - k2 = build_match_key("药A", "H1", "1", "abbott biologicals bv") - assert k1 == k2 - - -if __name__ == "__main__": - import traceback - fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")] - passed = 0 - for fn in fns: - try: - fn() - print(f"PASS {fn.__name__}") - passed += 1 - except Exception: - print(f"FAIL {fn.__name__}") - traceback.print_exc() - print(f"\n{passed}/{len(fns)} passed") diff --git a/state_center/tests/test_web_api.py b/state_center/tests/test_web_api.py deleted file mode 100644 index 478b890..0000000 --- a/state_center/tests/test_web_api.py +++ /dev/null @@ -1,1226 +0,0 @@ -import importlib -import sys -import types - -from fastapi.testclient import TestClient - - -def _install_fake_pymysql(): - pymysql = types.ModuleType("pymysql") - cursors = types.ModuleType("pymysql.cursors") - - class DictCursor: # noqa: D401 - test double - pass - - def connect(*args, **kwargs): # noqa: D401 - test double - raise AssertionError("connect should be monkeypatched in tests") - - cursors.DictCursor = DictCursor - pymysql.cursors = cursors - pymysql.connect = connect - sys.modules["pymysql"] = pymysql - sys.modules["pymysql.cursors"] = cursors - - -def test_timeline_api_returns_backend_computed_nodes(monkeypatch): - _install_fake_pymysql() - main = importlib.import_module("state_center.app.main") - client = TestClient(main.app) - - class FakeCursor: - def __init__(self): - self._result = [] - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def execute(self, sql, params=None): - if "FROM job WHERE job_id" in sql: - self._result = [{ - "job_id": "JOB-1", - "source_type": "YYC", - "current_node_code": "SOURCE_ADD", - "status": "WAITING_USER", - "needs_source_add": 1, - }] - elif "FROM job_node" in sql: - self._result = [ - {"node_code": "ORDER_FETCH", "attempt_no": 1, "status": "SUCCESS"}, - {"node_code": "PRODUCT_MATCH", "attempt_no": 1, "status": "SUCCESS"}, - ] - else: - self._result = [] - - def fetchone(self): - return self._result[0] if self._result else None - - def fetchall(self): - return list(self._result) - - class FakeConn: - def cursor(self): - return FakeCursor() - - monkeypatch.setattr(main, "get_conn", lambda: FakeConn()) - - resp = client.get("/api/jobs/JOB-1/timeline") - assert resp.status_code == 200 - body = resp.json() - by_code = {item["node_code"]: item for item in body["timeline"]} - assert by_code["ORDER_FETCH"]["display_status"] == "SUCCESS" - assert by_code["OCR_EXTRACT"]["display_status"] == "SKIPPED" - assert by_code["SOURCE_ADD"]["display_status"] == "WAITING_USER" - - -def test_state_machine_api_returns_chinese_labels(monkeypatch): - _install_fake_pymysql() - main = importlib.import_module("state_center.app.main") - client = TestClient(main.app) - - resp = client.get("/api/state-machine") - assert resp.status_code == 200 - body = resp.json() - assert body["node_labels"]["ORDER_FETCH"] == "订单获取" - assert body["executor_labels"]["RPA"] == "艺赛旗执行" - assert body["node_executor"]["PRODUCT_MATCH"] == "STATE_CENTER" - - -def test_health_reports_database_status(monkeypatch): - _install_fake_pymysql() - main = importlib.import_module("state_center.app.main") - client = TestClient(main.app) - - class FakeCursor: - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def execute(self, sql, params=None): - assert sql == "SELECT 1" - - def fetchone(self): - return {"ok": 1} - - class FakeConn: - def cursor(self): - return FakeCursor() - - def close(self): - pass - - monkeypatch.setattr(main, "get_conn", lambda: FakeConn()) - - response = client.get("/health") - - assert response.status_code == 200 - assert response.json() == {"ok": True, "database": "ok"} - - -def test_task_submit_confirms_source_add_and_is_idempotent(monkeypatch): - _install_fake_pymysql() - main = importlib.import_module("state_center.app.main") - client = TestClient(main.app) - - state = { - "task": { - "task_id": "TASK-1", - "job_id": "JOB-1", - "node_code": "SOURCE_ADD", - "role_code": "PURCHASER", - "status": "OPEN", - "submit_token": None, - "submitted_action": None, - "submitted_payload_json": None, - "submitted_result_json": None, - }, - "job": { - "job_id": "JOB-1", - "current_node_code": "SOURCE_ADD", - "status": "WAITING_USER", - "needs_source_add": 1, - "current_role": "PURCHASER", - "source_type": "YYC", - }, - "job_nodes": [ - {"node_code": "SOURCE_ADD", "attempt_no": 1, "status": "WAITING_USER"}, - ], - } - - class FakeCursor: - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def execute(self, sql, params=None): - self.last_sql = sql - self.last_params = params - if "FROM user_task WHERE task_id=%s" in sql: - self._result = [state["task"]] - elif "FROM job WHERE job_id=%s" in sql and "UPDATE" not in sql: - self._result = [state["job"]] - elif "submitted_result_json" in sql and "FROM user_task" in sql: - self._result = [state["task"]] - elif "SELECT node_code,attempt_no,status" in sql: - self._result = list(state["job_nodes"]) - elif sql.startswith("UPDATE user_task SET status='SUBMITTED'"): - state["task"]["status"] = "SUBMITTED" - state["task"]["submit_token"] = params[0] - state["task"]["submitted_action"] = params[1] - state["task"]["submitted_payload_json"] = params[2] - state["task"]["submitted_result_json"] = params[3] - state["task"]["submitted_by"] = params[4] - state["task"]["submitted_at"] = params[5] - elif sql.startswith("UPDATE job SET needs_source_add=0"): - state["job"]["needs_source_add"] = 0 - elif sql.startswith("UPDATE job SET current_node_code=%s,status='PENDING'"): - state["job"]["current_node_code"] = params[0] - state["job"]["status"] = "PENDING" - elif sql.startswith("UPDATE job_node SET status='SUCCESS'"): - state["job_nodes"][0]["status"] = "SUCCESS" - elif sql.startswith("INSERT INTO job_event"): - pass - else: - self._result = [] - - def fetchone(self): - return self._result[0] if self._result else None - - def fetchall(self): - return list(self._result) - - @property - def _result(self): - return getattr(self, "_result_data", []) - - @_result.setter - def _result(self, value): - self._result_data = value - - class FakeConn: - def cursor(self): - return FakeCursor() - - monkeypatch.setattr(main, "get_conn", lambda: FakeConn()) - - headers = {"X-Agent-Token": main.settings.AGENT_TOKEN} - first = client.post( - "/api/tasks/TASK-1/submit", - headers=headers, - json={ - "action": "CONFIRM_AND_CONTINUE", - "payload": {"sourceAdd": True}, - "submitToken": "token-1", - "operator": "alice", - }, - ) - assert first.status_code == 200 - first_body = first.json() - assert first_body["next_node"] == "CONTRACT_CREATE" - assert state["task"]["status"] == "SUBMITTED" - assert state["task"]["submitted_result_json"] is not None - assert state["job"]["current_node_code"] == "CONTRACT_CREATE" - assert state["job"]["needs_source_add"] == 0 - - second = client.post( - "/api/tasks/TASK-1/submit", - headers=headers, - json={ - "action": "CONFIRM_AND_CONTINUE", - "payload": {"sourceAdd": True}, - "submitToken": "token-1", - "operator": "alice", - }, - ) - assert second.status_code == 200 - assert second.json()["idempotent"] is True - - third = client.post( - "/api/tasks/TASK-1/submit", - headers=headers, - json={ - "action": "CONFIRM_AND_CONTINUE", - "payload": {"sourceAdd": True}, - "submitToken": "token-2", - "operator": "alice", - }, - ) - assert third.status_code == 409 - - -def test_task_submit_source_add_creates_missing_node_history(monkeypatch): - _install_fake_pymysql() - main = importlib.import_module("state_center.app.main") - client = TestClient(main.app) - - state = { - "task": { - "task_id": "TASK-1", - "job_id": "JOB-1", - "node_code": "SOURCE_ADD", - "role_code": "PURCHASER", - "status": "OPEN", - "submit_token": None, - "submitted_result_json": None, - }, - "job": { - "job_id": "JOB-1", - "current_node_code": "SOURCE_ADD", - "status": "WAITING_USER", - "needs_source_add": 1, - "current_role": "PURCHASER", - "source_type": "YYC", - }, - "job_nodes": [], - } - - class FakeCursor: - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def execute(self, sql, params=None): - if "FROM user_task WHERE task_id=%s" in sql: - self._result = [state["task"]] - elif "FROM job WHERE job_id=%s" in sql and "UPDATE" not in sql: - self._result = [state["job"]] - elif sql.startswith("UPDATE job SET needs_source_add=0"): - state["job"]["needs_source_add"] = 0 - elif sql.startswith("INSERT INTO job_node"): - state["job_nodes"].append({ - "node_code": params[1], - "attempt_no": params[3], - "status": "SUCCESS", - "executor_type": params[2], - }) - elif sql.startswith("UPDATE job SET current_node_code=%s,status='PENDING'"): - state["job"]["current_node_code"] = params[0] - state["job"]["status"] = "PENDING" - elif sql.startswith("UPDATE user_task SET status='SUBMITTED'"): - state["task"]["status"] = "SUBMITTED" - state["task"]["submit_token"] = params[0] - state["task"]["submitted_result_json"] = params[3] - elif sql.startswith("INSERT INTO job_event"): - pass - else: - self._result = [] - - def fetchone(self): - return self._result[0] if self._result else None - - @property - def _result(self): - return getattr(self, "_result_data", []) - - @_result.setter - def _result(self, value): - self._result_data = value - - class FakeConn: - def cursor(self): - return FakeCursor() - - monkeypatch.setattr(main, "get_conn", lambda: FakeConn()) - - response = client.post( - "/api/tasks/TASK-1/submit", - headers={"X-Agent-Token": main.settings.AGENT_TOKEN}, - json={ - "action": "CONFIRM_AND_CONTINUE", - "payload": {}, - "submitToken": "source-submit-1", - "operator": "采购张三", - }, - ) - - assert response.status_code == 200 - assert state["job_nodes"] == [{ - "node_code": "SOURCE_ADD", - "attempt_no": 1, - "status": "SUCCESS", - "executor_type": "HUMAN", - }] - - -def test_advance_to_m5_human_node_creates_task(): - _install_fake_pymysql() - main = importlib.import_module("state_center.app.main") - - state = { - "job": { - "job_id": "JOB-1", - "supplier_name": "重庆亿昊药业", - "current_node_code": "M5_CREATE_ORDER", - "status": "RUNNING", - }, - "job_nodes": [], - "tasks": [], - } - - class FakeCursor: - def execute(self, sql, params=None): - if sql.startswith("INSERT INTO job_node"): - state["job_nodes"].append({ - "node_code": params[1], - "executor_type": params[2], - "attempt_no": params[3], - "status": params[4], - }) - elif sql.startswith("DELETE FROM user_task"): - pass - elif sql.startswith("UPDATE job SET current_node_code=%s,status='WAITING_USER'"): - state["job"]["current_node_code"] = params[0] - state["job"]["status"] = "WAITING_USER" - state["job"]["current_role"] = params[1] - elif sql.startswith("INSERT INTO user_task"): - state["tasks"].append({ - "job_id": params[1], - "node_code": params[2], - "role_code": params[3], - "title": params[4], - }) - elif sql.startswith("INSERT INTO job_event"): - pass - else: - raise AssertionError(sql) - - nxt = main._advance(FakeCursor(), state["job"], "M5_CREATE_ORDER") - - assert nxt == "M5_MANUAL_CONFIRM" - assert state["job"]["status"] == "WAITING_USER" - assert state["job"]["current_role"] == "PURCHASER" - assert state["job_nodes"] == [{ - "node_code": "M5_MANUAL_CONFIRM", - "executor_type": "HUMAN", - "attempt_no": 1, - "status": "WAITING_USER", - }] - assert state["tasks"] == [{ - "job_id": "JOB-1", - "node_code": "M5_MANUAL_CONFIRM", - "role_code": "PURCHASER", - "title": "M5人工确认:重庆亿昊药业", - }] - - -def test_node_complete_exports_artifacts(monkeypatch): - _install_fake_pymysql() - main = importlib.import_module("state_center.app.main") - client = TestClient(main.app) - - state = { - "job": { - "job_id": "JOB-1", - "current_node_code": "CONTRACT_CREATE", - "status": "RUNNING", - "source_type": "YYC", - }, - "job_node": { - "node_code": "CONTRACT_CREATE", - "attempt_no": 1, - "status": "RUNNING", - }, - "artifacts": [], - } - - class FakeCursor: - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def execute(self, sql, params=None): - if "FROM job WHERE job_id=%s" in sql and "ORDER BY" not in sql: - self._result = [state["job"]] - elif "FROM job_node WHERE job_id=%s" in sql: - self._result = [state["job_node"]] - elif "FROM user_task WHERE job_id=%s" in sql: - self._result = [] - elif "FROM job_artifact WHERE job_id=%s" in sql: - self._result = list(state["artifacts"]) - elif sql.startswith("UPDATE job_node SET status='SUCCESS'"): - state["job_node"]["status"] = "SUCCESS" - state["job_node"]["ended_at"] = params[1] - if len(params) > 2: - state["job_node"]["callback_idempotency_key"] = params[2] - elif sql.startswith("INSERT INTO job_artifact"): - state["artifacts"].append({ - "job_id": params[0], - "node_code": params[1], - "attempt_no": params[2], - "artifact_type": params[3], - "storage_path": params[4], - "file_hash": params[5], - "visible_roles": params[6], - "is_masked": params[7], - }) - elif sql.startswith("INSERT INTO job_event"): - pass - else: - self._result = [] - - def fetchone(self): - return self._result[0] if self._result else None - - def fetchall(self): - if self._result: - return list(self._result) - return [] - - @property - def _result(self): - return getattr(self, "_result_data", []) - - @_result.setter - def _result(self, value): - self._result_data = value - - class FakeConn: - def cursor(self): - return FakeCursor() - - monkeypatch.setattr(main, "get_conn", lambda: FakeConn()) - - headers = {"X-Agent-Token": main.settings.AGENT_TOKEN} - done = client.post( - "/api/jobs/JOB-1/nodes/CONTRACT_CREATE/complete", - headers=headers, - json={ - "attemptNo": 1, - "executorRunId": "RUN-1", - "idempotencyKey": "idem-1", - "businessRefs": {"contractSystemNo": "HT-001"}, - "summary": {"done": True}, - "artifacts": [ - { - "artifactType": "CONTRACT_PDF", - "storagePath": "/data/jobs/JOB-1/contract.pdf", - "fileHash": "sha256:abc", - "visibleRoles": ["PURCHASER", "PLANNER"], - "isMasked": False, - } - ], - }, - ) - - assert done.status_code == 200 - - package = client.get("/api/jobs/JOB-1/evidence-package") - assert package.status_code == 200 - body = package.json() - assert body["artifacts"][0]["artifact_type"] == "CONTRACT_PDF" - assert body["artifacts"][0]["storage_path"] == "/data/jobs/JOB-1/contract.pdf" - - -def test_job_list_api_returns_compact_rows(monkeypatch): - _install_fake_pymysql() - main = importlib.import_module("state_center.app.main") - client = TestClient(main.app) - - class FakeCursor: - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def execute(self, sql, params=None): - if "FROM job" in sql: - self._result = [{ - "job_id": "JOB-1", - "supplier_name": "重庆亿昊药业", - "order_date": "2026-08-04", - "source_type": "YYC", - "status": "RUNNING", - "current_node_code": "PRODUCT_MATCH", - "current_role": "PLANNER", - "created_at": "2026-08-10 10:00:00", - "updated_at": "2026-08-10 10:05:00", - "finished_at": None, - }] - else: - self._result = [] - - def fetchall(self): - return list(self._result) - - @property - def _result(self): - return getattr(self, "_result_data", []) - - @_result.setter - def _result(self, value): - self._result_data = value - - class FakeConn: - def cursor(self): - return FakeCursor() - - monkeypatch.setattr(main, "get_conn", lambda: FakeConn()) - - resp = client.get("/api/jobs", params={"status": "RUNNING"}) - assert resp.status_code == 200 - body = resp.json() - assert body["items"][0]["job_id"] == "JOB-1" - assert body["items"][0]["supplier_name"] == "重庆亿昊药业" - assert body["items"][0]["current_node_code"] == "PRODUCT_MATCH" - - -def test_task_list_api_filters_by_role(monkeypatch): - _install_fake_pymysql() - main = importlib.import_module("state_center.app.main") - client = TestClient(main.app) - - class FakeCursor: - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def execute(self, sql, params=None): - if "FROM user_task" in sql: - self._result = [{ - "task_id": "TASK-1", - "job_id": "JOB-1", - "node_code": "SOURCE_ADD", - "role_code": "PURCHASER", - "status": "OPEN", - "title": "新增货源:重庆亿昊药业", - "reason_type": "SOURCE_ADD", - "submitted_action": None, - "submitted_at": None, - "created_at": "2026-08-10 10:00:00", - }] - else: - self._result = [] - - def fetchall(self): - return list(self._result) - - @property - def _result(self): - return getattr(self, "_result_data", []) - - @_result.setter - def _result(self, value): - self._result_data = value - - class FakeConn: - def cursor(self): - return FakeCursor() - - monkeypatch.setattr(main, "get_conn", lambda: FakeConn()) - - resp = client.get("/api/tasks/my", params={"roleCode": "PURCHASER", "status": "open"}) - assert resp.status_code == 200 - body = resp.json() - assert body["items"][0]["task_id"] == "TASK-1" - assert body["items"][0]["role_code"] == "PURCHASER" - - -def test_ingest_order_items_before_match(monkeypatch): - _install_fake_pymysql() - main = importlib.import_module("state_center.app.main") - client = TestClient(main.app) - executed = [] - - class FakeCursor: - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def execute(self, sql, params=None): - executed.append((sql, params)) - if "SELECT * FROM job WHERE job_id=%s" in sql: - self._result = [{ - "job_id": "JOB-1", - "current_node_code": "ORDER_FETCH", - "status": "RUNNING", - }] - elif "FROM product_match" in sql: - self._result = [{"c": 0}] - else: - self._result = [] - - def fetchone(self): - return self._result[0] if self._result else None - - class FakeConn: - def cursor(self): - return FakeCursor() - - monkeypatch.setattr(main, "get_conn", lambda: FakeConn()) - response = client.post( - "/api/jobs/JOB-1/items", - headers={"X-Agent-Token": main.settings.AGENT_TOKEN}, - json={ - "replace": True, - "rows": [{ - "orderNo": "DD-001", - "rawProductName": "阿卡波糖片", - "platformProductCode": "PT-001", - "spec": "50mg*30片", - "manufacturer": "示例制药", - "approvalNo": "国药准字H00000001", - "qty": 12, - "unitPrice": 9.8, - "sourceRowNo": 2, - }], - }, - ) - - assert response.status_code == 200 - assert response.json() == {"ok": True, "inserted": 1, "order_nos": ["DD-001"]} - assert any(sql.startswith("INSERT INTO order_item") for sql, _ in executed) - assert any("order_no_list_json" in sql for sql, _ in executed) - - -def test_ingest_order_items_rejects_replace_after_match(monkeypatch): - _install_fake_pymysql() - main = importlib.import_module("state_center.app.main") - client = TestClient(main.app) - - class FakeCursor: - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def execute(self, sql, params=None): - if "SELECT * FROM job WHERE job_id=%s" in sql: - self._result = [{"job_id": "JOB-1", "current_node_code": "PRODUCT_MATCH"}] - elif "FROM product_match" in sql: - self._result = [{"c": 1}] - else: - self._result = [] - - def fetchone(self): - return self._result[0] if self._result else None - - class FakeConn: - def cursor(self): - return FakeCursor() - - monkeypatch.setattr(main, "get_conn", lambda: FakeConn()) - response = client.post( - "/api/jobs/JOB-1/items", - headers={"X-Agent-Token": main.settings.AGENT_TOKEN}, - json={"replace": True, "rows": []}, - ) - - assert response.status_code == 409 - assert "对码结果" in response.json()["detail"] - - -def test_load_fixture_goods_for_local_demo(monkeypatch): - _install_fake_pymysql() - main = importlib.import_module("state_center.app.main") - client = TestClient(main.app) - - monkeypatch.setattr(main.settings, "DEV_MODE", True, raising=False) - response = client.post( - "/api/dev/match/load-fixture-goods", - headers={"X-Agent-Token": main.settings.AGENT_TOKEN}, - ) - - assert response.status_code == 200 - assert response.json()["loaded"] >= 3 - assert main._ENGINE_LOADED is True - - -def test_select_candidate_requires_candidate_payload(monkeypatch): - _install_fake_pymysql() - main = importlib.import_module("state_center.app.main") - client = TestClient(main.app) - - class FakeCursor: - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def execute(self, sql, params=None): - if "FROM user_task WHERE task_id=%s" in sql: - self._result = [{ - "task_id": "TASK-1", - "job_id": "JOB-1", - "node_code": "PRODUCT_MATCH", - "role_code": "PLANNER", - "status": "OPEN", - "submit_token": None, - "product_match_id": 7, - }] - elif "FROM job WHERE job_id=%s" in sql: - self._result = [{ - "job_id": "JOB-1", - "current_node_code": "PRODUCT_MATCH", - "status": "WAITING_USER", - }] - else: - self._result = [] - - def fetchone(self): - return self._result[0] if self._result else None - - class FakeConn: - def cursor(self): - return FakeCursor() - - monkeypatch.setattr(main, "get_conn", lambda: FakeConn()) - response = client.post( - "/api/tasks/TASK-1/submit", - headers={"X-Agent-Token": main.settings.AGENT_TOKEN}, - json={ - "action": "SELECT_CANDIDATE", - "payload": {}, - "submitToken": "submit-1", - "operator": "计划员张三", - }, - ) - - assert response.status_code == 400 - assert response.json()["detail"] == "选择候选货品时必须提供 productMatchId 和 goodsId" - - -def test_advance_from_receipt_notice_auto_archives_job(): - _install_fake_pymysql() - main = importlib.import_module("state_center.app.main") - - state = { - "job": { - "job_id": "JOB-1", - "supplier_name": "重庆亿昊药业", - "current_node_code": "M5_RECEIPT_NOTICE", - "status": "WAITING_USER", - "finished_at": None, - }, - "job_nodes": [], - "events": [], - } - - class FakeCursor: - def execute(self, sql, params=None): - if sql.startswith("INSERT INTO job_node"): - state["job_nodes"].append({ - "node_code": params[1], - "executor_type": params[2], - "attempt_no": params[3], - "status": params[4], - }) - elif sql.startswith("UPDATE job SET status='SUCCESS'"): - state["job"]["status"] = "SUCCESS" - state["job"]["finished_at"] = params[0] - state["job"]["current_node_code"] = "ARCHIVE_JOB" - elif sql.startswith("INSERT INTO job_event"): - state["events"].append({"event_type": params[1], "node_code": params[2]}) - else: - raise AssertionError(sql) - - nxt = main._advance(FakeCursor(), state["job"], "M5_RECEIPT_NOTICE") - - assert nxt is None - assert state["job"]["status"] == "SUCCESS" - assert state["job"]["current_node_code"] == "ARCHIVE_JOB" - assert state["job"]["finished_at"] is not None - assert state["job_nodes"] == [{ - "node_code": "ARCHIVE_JOB", - "executor_type": "STATE_CENTER", - "attempt_no": 1, - "status": "SUCCESS", - }] - assert {"event_type": "JOB_FINISHED", "node_code": None} in state["events"] - - -def test_retry_failed_retryable_node_opens_next_attempt(monkeypatch): - _install_fake_pymysql() - main = importlib.import_module("state_center.app.main") - client = TestClient(main.app) - - state = { - "job": { - "job_id": "JOB-1", - "current_node_code": "ORDER_FETCH", - "status": "FAILED_RETRYABLE", - "source_type": "YYC", - }, - "attempts": [ - {"attempt_no": 1, "status": "FAILED"}, - ], - "events": [], - } - - class FakeCursor: - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def execute(self, sql, params=None): - if "SELECT * FROM job WHERE job_id=%s" in sql: - self._result = [state["job"]] - elif "SELECT MAX(attempt_no)" in sql or "SELECT COALESCE(MAX(attempt_no)" in sql: - self._result = [{"max_attempt": state["attempts"][-1]["attempt_no"]}] - elif sql.startswith("UPDATE job SET status='PENDING'"): - state["job"]["status"] = "PENDING" - elif sql.startswith("INSERT INTO job_event"): - state["events"].append({"event_type": params[1], "node_code": params[2]}) - else: - self._result = [] - - def fetchone(self): - return self._result[0] if self._result else None - - @property - def _result(self): - return getattr(self, "_result_data", []) - - @_result.setter - def _result(self, value): - self._result_data = value - - class FakeConn: - def cursor(self): - return FakeCursor() - - monkeypatch.setattr(main, "get_conn", lambda: FakeConn()) - response = client.post( - "/api/jobs/JOB-1/retry", - headers={"X-Agent-Token": main.settings.AGENT_TOKEN}, - ) - - assert response.status_code == 200 - body = response.json() - assert body["ok"] is True - assert body["node_code"] == "ORDER_FETCH" - assert body["attempt_no"] == 2 - assert state["job"]["status"] == "PENDING" - assert state["job"]["current_node_code"] == "ORDER_FETCH" - assert state["attempts"] == [{"attempt_no": 1, "status": "FAILED"}] - - -def test_contract_approve_timeout_creates_ops_task(monkeypatch): - _install_fake_pymysql() - main = importlib.import_module("state_center.app.main") - client = TestClient(main.app) - - state = { - "job": { - "job_id": "JOB-1", - "supplier_name": "重庆亿昊药业", - "current_node_code": "CONTRACT_APPROVE_WAIT", - "status": "WAITING_EXTERNAL", - "wait_check_count": main.settings.CONTRACT_WAIT_MAX_CHECKS - 1, - "source_type": "YYC", - }, - "job_nodes": [], - "tasks": [], - } - - class FakeCursor: - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def execute(self, sql, params=None): - if "SELECT * FROM job WHERE job_id=%s" in sql: - self._result = [state["job"]] - elif sql.startswith("INSERT INTO job_node"): - state["job_nodes"].append({ - "node_code": params[1], - "attempt_no": params[2], - "status": params[3], - }) - elif sql.startswith("UPDATE job SET status='WAITING_USER'"): - state["job"]["status"] = "WAITING_USER" - state["job"]["current_role"] = "OPS" - state["job"]["wait_check_count"] = params[0] - elif sql.startswith("DELETE FROM user_task"): - pass - elif sql.startswith("INSERT INTO user_task"): - state["tasks"].append({ - "job_id": params[1], - "node_code": params[2], - "role_code": params[3], - "title": params[4], - }) - elif sql.startswith("INSERT INTO job_event"): - pass - else: - self._result = [] - - def fetchone(self): - return self._result[0] if self._result else None - - @property - def _result(self): - return getattr(self, "_result_data", []) - - @_result.setter - def _result(self, value): - self._result_data = value - - class FakeConn: - def cursor(self): - return FakeCursor() - - monkeypatch.setattr(main, "get_conn", lambda: FakeConn()) - response = client.post( - "/api/jobs/JOB-1/nodes/CONTRACT_APPROVE_WAIT/check-result", - headers={"X-Agent-Token": main.settings.AGENT_TOKEN}, - json={"approved": False, "operator": "agent", "detail": "仍未审批"}, - ) - - assert response.status_code == 200 - body = response.json() - assert body["escalated"] is True - assert body["job_status"] == "WAITING_USER" - assert state["job"]["current_role"] == "OPS" - assert state["tasks"] == [{ - "job_id": "JOB-1", - "node_code": "CONTRACT_APPROVE_WAIT", - "role_code": "OPS", - "title": "合同审批超时:重庆亿昊药业", - }] - - -def test_contract_approve_timeout_task_confirm_advances_to_rebate(monkeypatch): - _install_fake_pymysql() - main = importlib.import_module("state_center.app.main") - client = TestClient(main.app) - - state = { - "task": { - "task_id": "TASK-OPS-1", - "job_id": "JOB-1", - "node_code": "CONTRACT_APPROVE_WAIT", - "role_code": "OPS", - "status": "OPEN", - "submit_token": None, - "submitted_result_json": None, - }, - "job": { - "job_id": "JOB-1", - "supplier_name": "重庆亿昊药业", - "current_node_code": "CONTRACT_APPROVE_WAIT", - "status": "WAITING_USER", - "current_role": "OPS", - "source_type": "YYC", - }, - "job_nodes": [ - {"node_code": "CONTRACT_APPROVE_WAIT", "attempt_no": 8, "status": "FAILED"}, - ], - } - - class FakeCursor: - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def execute(self, sql, params=None): - if "FROM user_task WHERE task_id=%s" in sql: - self._result = [state["task"]] - elif "SELECT * FROM job WHERE job_id=%s" in sql: - self._result = [state["job"]] - elif "SELECT attempt_no,status FROM job_node" in sql: - self._result = [state["job_nodes"][-1]] - elif sql.startswith("INSERT INTO job_node"): - state["job_nodes"].append({ - "node_code": params[1], - "executor_type": params[2], - "attempt_no": params[3], - "status": "SUCCESS", - }) - elif sql.startswith("UPDATE job_node SET status='SUCCESS'"): - raise AssertionError("timeout confirm must not overwrite check history") - elif sql.startswith("UPDATE job SET current_node_code=%s,status='PENDING'"): - state["job"]["current_node_code"] = params[0] - state["job"]["status"] = "PENDING" - elif sql.startswith("UPDATE user_task SET status='SUBMITTED'"): - state["task"]["status"] = "SUBMITTED" - state["task"]["submit_token"] = params[0] - state["task"]["submitted_result_json"] = params[3] - elif sql.startswith("INSERT INTO job_event"): - pass - else: - self._result = [] - - def fetchone(self): - return self._result[0] if self._result else None - - @property - def _result(self): - return getattr(self, "_result_data", []) - - @_result.setter - def _result(self, value): - self._result_data = value - - class FakeConn: - def cursor(self): - return FakeCursor() - - monkeypatch.setattr(main, "get_conn", lambda: FakeConn()) - response = client.post( - "/api/tasks/TASK-OPS-1/submit", - headers={"X-Agent-Token": main.settings.AGENT_TOKEN}, - json={ - "action": "CONFIRM_AND_CONTINUE", - "payload": {}, - "submitToken": "ops-confirm-1", - "operator": "ops", - }, - ) - - assert response.status_code == 200 - assert response.json()["next_node"] == "REBATE_HEADER" - assert state["job"]["current_node_code"] == "REBATE_HEADER" - assert state["job_nodes"][-1]["node_code"] == "CONTRACT_APPROVE_WAIT" - assert state["job_nodes"][-1]["status"] == "SUCCESS" - assert state["job_nodes"][-1]["attempt_no"] == 9 - assert state["job_nodes"][0]["status"] == "FAILED" - - -def test_human_node_rejects_rpa_complete(monkeypatch): - _install_fake_pymysql() - main = importlib.import_module("state_center.app.main") - client = TestClient(main.app) - - class FakeCursor: - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def execute(self, sql, params=None): - if "SELECT * FROM job WHERE job_id=%s" in sql: - self._result = [{ - "job_id": "JOB-1", - "current_node_code": "SOURCE_ADD", - "status": "WAITING_USER", - "source_type": "YYC", - }] - else: - self._result = [] - - def fetchone(self): - return self._result[0] if self._result else None - - @property - def _result(self): - return getattr(self, "_result_data", []) - - @_result.setter - def _result(self, value): - self._result_data = value - - class FakeConn: - def cursor(self): - return FakeCursor() - - monkeypatch.setattr(main, "get_conn", lambda: FakeConn()) - response = client.post( - "/api/jobs/JOB-1/nodes/SOURCE_ADD/complete", - headers={"X-Agent-Token": main.settings.AGENT_TOKEN}, - json={"attemptNo": 1, "summary": {"sourceAdded": True}}, - ) - - assert response.status_code == 409 - assert "人工节点" in response.json()["detail"] - - -def test_task_submit_rejects_oversized_submit_token(monkeypatch): - _install_fake_pymysql() - main = importlib.import_module("state_center.app.main") - client = TestClient(main.app) - advanced = {"moved": False} - - class FakeCursor: - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def execute(self, sql, params=None): - if "FROM user_task WHERE task_id=%s" in sql: - self._result = [{ - "task_id": "TASK-1", - "job_id": "JOB-1", - "node_code": "SOURCE_ADD", - "status": "OPEN", - "submit_token": None, - }] - elif "SELECT * FROM job WHERE job_id=%s" in sql: - self._result = [{ - "job_id": "JOB-1", - "current_node_code": "SOURCE_ADD", - "status": "WAITING_USER", - "source_type": "YYC", - }] - elif sql.startswith("UPDATE job SET"): - advanced["moved"] = True - else: - self._result = [] - - def fetchone(self): - return self._result[0] if self._result else None - - @property - def _result(self): - return getattr(self, "_result_data", []) - - @_result.setter - def _result(self, value): - self._result_data = value - - class FakeConn: - def cursor(self): - return FakeCursor() - - def begin(self): - return None - - def commit(self): - return None - - def rollback(self): - return None - - def close(self): - return None - - monkeypatch.setattr(main, "get_conn", lambda: FakeConn()) - response = client.post( - "/api/tasks/TASK-1/submit", - headers={"X-Agent-Token": main.settings.AGENT_TOKEN}, - json={ - "action": "CONFIRM_AND_CONTINUE", - "payload": {}, - "submitToken": "x" * 65, - "operator": "alice", - }, - ) - - assert response.status_code == 400 - assert "submitToken" in response.json()["detail"] - assert advanced["moved"] is False diff --git a/state_center/tests/test_web_state_machine.py b/state_center/tests/test_web_state_machine.py deleted file mode 100644 index bb5acf2..0000000 --- a/state_center/tests/test_web_state_machine.py +++ /dev/null @@ -1,23 +0,0 @@ -from state_center.app.web_state_machine import build_timeline - - -def test_build_timeline_marks_skipped_and_waiting_user(): - job = { - "job_id": "JOB-1", - "source_type": "YYC", - "current_node_code": "SOURCE_ADD", - "status": "WAITING_USER", - "needs_source_add": 1, - } - job_nodes = [ - {"node_code": "ORDER_FETCH", "attempt_no": 1, "status": "SUCCESS"}, - {"node_code": "PRODUCT_MATCH", "attempt_no": 1, "status": "SUCCESS"}, - ] - - timeline = build_timeline(job, job_nodes) - by_code = {item["node_code"]: item for item in timeline} - - assert by_code["ORDER_FETCH"]["display_status"] == "SUCCESS" - assert by_code["OCR_EXTRACT"]["display_status"] == "SKIPPED" - assert by_code["SOURCE_ADD"]["display_status"] == "WAITING_USER" - assert by_code["CONTRACT_CREATE"]["display_status"] == "PENDING" diff --git a/state_center/tests/test_yisaiqi_contract_create.py b/state_center/tests/test_yisaiqi_contract_create.py deleted file mode 100644 index 56a66c8..0000000 --- a/state_center/tests/test_yisaiqi_contract_create.py +++ /dev/null @@ -1,112 +0,0 @@ -import json - -from rpa_yisaiqi.callback_client import StateCenterClient -from rpa_yisaiqi.contract_create_node import ( - ContractCreateInput, - LoginFailed, - execute_contract_create, -) - - -class FakeHttp: - def __init__(self, responses=None): - self.requests = [] - self.responses = list(responses or [{"ok": True}, {"ok": True, "next_node": "CONTRACT_APPROVE_WAIT"}]) - - def __call__(self, request, timeout): - body = json.loads(request.data.decode("utf-8")) if request.data else None - self.requests.append( - { - "url": request.full_url, - "headers": dict(request.header_items()), - "body": body, - "timeout": timeout, - } - ) - return FakeResponse(self.responses.pop(0)) - - -class FakeResponse: - def __init__(self, payload): - self.payload = payload - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def read(self): - return json.dumps(self.payload).encode("utf-8") - - -def test_state_center_client_sends_agent_token_and_json_body(): - http = FakeHttp([{"ok": True}]) - client = StateCenterClient("http://state-center:8000/", "dev-agent-token", opener=http) - - result = client.start_node("JOB-1", "CONTRACT_CREATE", attempt_no=2, operator="agent") - - assert result == {"ok": True} - assert http.requests[0]["url"] == "http://state-center:8000/api/jobs/JOB-1/nodes/CONTRACT_CREATE/start" - assert http.requests[0]["headers"]["X-agent-token"] == "dev-agent-token" - assert http.requests[0]["body"] == { - "attemptNo": 2, - "executorPlatform": "iS-RPA", - "operator": "agent", - } - - -def test_state_center_client_escapes_non_ascii_path_parts(): - http = FakeHttp([{"ok": True}]) - client = StateCenterClient("http://state-center:8000", "dev-agent-token", opener=http) - - client.start_node("YYC-合同节点联调-001", "CONTRACT_CREATE") - - assert http.requests[0]["url"] == ( - "http://state-center:8000/api/jobs/" - "YYC-%E5%90%88%E5%90%8C%E8%8A%82%E7%82%B9%E8%81%94%E8%B0%83-001" - "/nodes/CONTRACT_CREATE/start" - ) - - -def test_contract_create_calls_start_then_complete_with_contract_number(): - http = FakeHttp() - client = StateCenterClient("http://state-center:8000", "dev-agent-token", opener=http) - spec = ContractCreateInput( - job_id="JOB-1", - supplier_name="重庆亿昊药业", - order_date="2026-08-11", - source_type="YYC", - contract_system_no="HT-20260811-001", - ) - - result = execute_contract_create(spec, client) - - assert result["next_node"] == "CONTRACT_APPROVE_WAIT" - assert [request["url"].split("/")[-1] for request in http.requests] == ["start", "complete"] - assert http.requests[1]["body"]["businessRefs"] == {"contractSystemNo": "HT-20260811-001"} - assert http.requests[1]["body"]["summary"]["supplierName"] == "重庆亿昊药业" - assert http.requests[1]["body"]["summary"]["contractDate"] == "2026-08-11" - - -def test_contract_create_reports_login_failure_to_state_center(): - http = FakeHttp([{"ok": True}, {"ok": True, "job_status": "FAILED_RETRYABLE"}]) - client = StateCenterClient("http://state-center:8000", "dev-agent-token", opener=http) - spec = ContractCreateInput( - job_id="JOB-1", - supplier_name="重庆亿昊药业", - order_date="2026-08-11", - source_type="YYC", - contract_system_no="HT-20260811-001", - ) - - def failing_runner(_spec): - raise LoginFailed("合同系统登录失败") - - result = execute_contract_create(spec, client, contract_runner=failing_runner) - - assert result["job_status"] == "FAILED_RETRYABLE" - assert http.requests[1]["url"].endswith("/api/jobs/JOB-1/nodes/CONTRACT_CREATE/fail") - assert http.requests[1]["body"]["errorCode"] == "SYS_LOGIN_FAIL" - assert http.requests[1]["body"]["retryable"] is True - assert http.requests[1]["body"]["routeRole"] == "OPS" diff --git a/state_center/tests/test_yisaiqi_contract_draft.py b/state_center/tests/test_yisaiqi_contract_draft.py deleted file mode 100644 index adb3d6d..0000000 --- a/state_center/tests/test_yisaiqi_contract_draft.py +++ /dev/null @@ -1,180 +0,0 @@ -import json -from pathlib import Path - -import pytest - -from rpa_yisaiqi.apa_browser_adapter import ApaBrowserAdapter -from rpa_yisaiqi.contract_create_node import ( - ContractCreateError, - ContractCreateInput, - PdfMergeUnsupported, - build_spec_from_data, - create_contract_in_contract_system, -) -from rpa_yisaiqi.contract_draft import is_placeholder_customer_code, load_contract_draft -from rpa_yisaiqi.pdf_utils import ensure_merged_pdf - - -def test_load_contract_draft_maps_full_contract_inputs(tmp_path): - draft_path = tmp_path / "contract-draft.json" - draft_path.write_text( - json.dumps( - { - "supplierName": "重庆亿昊药业", - "orderDate": "2026-08-11", - "sourceType": "YYC", - "templateType": "NON_FORMAT", - "templateName": "非格式--采购协议", - "customerCode": "CUST-001", - "customerName": "重庆亿昊药业", - "contractCategory": "采购协议", - "settlementType": "付款", - "contractBackground": "按订单自动起草采购协议", - "contractNature": "主合同", - "amountCurrency": "人民币", - "amount": "12000.50", - "contractEndDate": "2026-12-31", - "isMajorContract": "否", - "isPrepayment": "否", - "belongYear": "2026", - "paymentMethod": "承兑", - "paymentDays": "45", - "contractStatus": "正式合同", - "containsBloodProducts": "否", - "platformPdfPaths": ["C:/jobs/JOB-1/a.pdf", "C:/jobs/JOB-1/b.pdf"], - "goodsLines": [ - { - "operationCode": "ADD", - "goodsCode": "YP-001", - "goodsName": "阿卡波糖片", - "approvalNo": "国药准字H00000001", - "spec": "50mg*30片", - "unit": "盒", - "quantity": 12, - } - ], - }, - ensure_ascii=False, - ), - encoding="utf-8", - ) - - draft = load_contract_draft(draft_path) - - assert draft.supplier_name == "重庆亿昊药业" - assert draft.template_type == "NON_FORMAT" - assert draft.template_name == "非格式--采购协议" - assert draft.customer_code == "CUST-001" - assert draft.customer_name == "重庆亿昊药业" - assert draft.contract_category == "采购协议" - assert draft.settlement_type == "付款" - assert draft.contract_background == "按订单自动起草采购协议" - assert draft.amount == "12000.50" - assert draft.contract_end_date == "2026-12-31" - assert draft.belong_year == "2026" - assert draft.payment_method == "承兑" - assert draft.contract_status == "正式合同" - assert draft.contains_blood_products == "否" - assert draft.platform_pdf_paths == ["C:/jobs/JOB-1/a.pdf", "C:/jobs/JOB-1/b.pdf"] - assert draft.goods_lines[0].goods_code == "YP-001" - - -def test_placeholder_customer_code_is_rejected_before_opening_browser(): - assert is_placeholder_customer_code("CUST-YYC-001") - assert is_placeholder_customer_code("test-001") - assert not is_placeholder_customer_code("DW0100046361") - - with pytest.raises(ContractCreateError, match="占位符"): - build_spec_from_data( - { - "jobId": "JOB-1", - "supplierName": "重庆亿昊药业有限公司", - "orderDate": "2026-08-11", - "sourceType": "XYY", - "customerCode": "CUST-YYC-001", - "customerName": "重庆亿昊药业有限公司", - "amount": "100", - "submitMode": "dry_run", - "goodsLines": [ - { - "goodsCode": "YP-001", - "goodsName": "阿卡波糖片", - "quantity": 1, - } - ], - } - ) - - -def test_yyc_requires_pdf_inputs_before_contract_drafting(tmp_path): - draft_path = tmp_path / "draft.json" - draft_path.write_text( - json.dumps( - { - "supplierName": "重庆亿昊药业", - "orderDate": "2026-08-11", - "sourceType": "YYC", - "goodsLines": [], - "platformPdfPaths": [], - }, - ensure_ascii=False, - ), - encoding="utf-8", - ) - spec = ContractCreateInput( - job_id="JOB-1", - supplier_name="重庆亿昊药业", - order_date="2026-08-11", - source_type="YYC", - draft_path=str(draft_path), - ) - - with pytest.raises(PdfMergeUnsupported): - create_contract_in_contract_system(spec) - - -def test_ensure_merged_pdf_uses_single_pdf_without_merge(tmp_path): - source = tmp_path / "one.pdf" - source.write_bytes(b"%PDF-1.4\n%one\n") - - assert ensure_merged_pdf([str(source)], str(tmp_path / "merged.pdf")) == str(source) - - -def test_create_contract_uses_apa_browser_adapter(tmp_path, monkeypatch): - draft_path = tmp_path / "draft.json" - draft_path.write_text( - json.dumps( - { - "supplierName": "重庆亿昊药业有限公司", - "orderDate": "2026-08-11", - "sourceType": "XYY", - "customerCode": "DW0100046361", - "customerName": "重庆亿昊药业有限公司", - "goodsLines": [], - "platformPdfPaths": [], - }, - ensure_ascii=False, - ), - encoding="utf-8", - ) - spec = ContractCreateInput( - job_id="JOB-1", - supplier_name="重庆亿昊药业有限公司", - order_date="2026-08-11", - source_type="XYY", - draft_path=str(draft_path), - ) - captured = {} - - def fake_run(browser, ctx): - captured["browser"] = browser - captured["ctx"] = ctx - return "HT-1" - - monkeypatch.setattr( - "rpa_yisaiqi.contract_create_node.run_contract_create_flow", - fake_run, - ) - - assert create_contract_in_contract_system(spec) == "HT-1" - assert isinstance(captured["browser"], ApaBrowserAdapter) diff --git a/state_center/tests/test_yisaiqi_contract_flow_steps.py b/state_center/tests/test_yisaiqi_contract_flow_steps.py deleted file mode 100644 index aa64901..0000000 --- a/state_center/tests/test_yisaiqi_contract_flow_steps.py +++ /dev/null @@ -1,490 +0,0 @@ -import pytest - -from rpa_yisaiqi.contract_flow_steps import ( - ContractFlowContext, - ContractGoodsLine, - fill_contract_signatory_info, - run_contract_create_flow, -) -from rpa_yisaiqi.runtime.errors import SupplierContractDisabled - - -@pytest.fixture(autouse=True) -def no_sleep(monkeypatch): - monkeypatch.setattr("rpa_yisaiqi.contract_flow_steps.time.sleep", lambda _s: None) - - -class FakeBrowser: - def __init__(self): - self.calls = [] - self.body_text = "" - self.xform_labels = {} - self.filled = {} - self.read_values = { - "contract_number": "HT-20260811-REAL-001", - } - - def open_url(self, url): - self.calls.append(("open_url", url)) - if "kmAgreementApply" in url or "draft.jsp" in url: - self.body_text = "客户编码 客户名称 合同类别" - - def fill_first(self, selectors, value): - self.calls.append(("fill_first", tuple(selectors), value)) - for selector in selectors: - self.filled[selector] = value - - def click_native(self, selectors): - self.calls.append(("click_native", tuple(selectors))) - - def click_first(self, selectors): - self.calls.append(("click_first", tuple(selectors))) - joined = " ".join(str(item) for item in selectors) - if "login" in joined or "登录" in joined: - self.body_text = "切换门户 待办 待阅 合同管理 合同起草" - - def wait_for_text(self, text, timeout_seconds=15): - self.calls.append(("wait_for_text", text, timeout_seconds)) - if text and text not in self.body_text: - self.body_text = f"{self.body_text} {text}".strip() - - def wait_for_element(self, selectors, timeout_seconds=15): - self.calls.append(("wait_for_element", tuple(selectors), timeout_seconds)) - - def element_exists(self, selectors): - self.calls.append(("element_exists", tuple(selectors))) - return True - - def switch_to_frame(self, selectors): - self.calls.append(("switch_to_frame", tuple(selectors))) - - def switch_to_default_content(self): - self.calls.append(("switch_to_default_content",)) - - def select_first(self, selectors, value): - self.calls.append(("select_first", tuple(selectors), value)) - for selector in selectors: - self.filled[selector] = value - - def select_xform(self, name, value): - self.calls.append(("select_xform", name, value)) - self.xform_labels[name] = value - - def execute_script(self, script, *args): - self.calls.append(("execute_script", script[:48], args)) - if "hasTextInFrames" in script: - return True - if "latestByApplyTime" in script and "headerIdx" in script: - return "001132026081734940" - if "wantedTitle" in script and "headerIdx" in script: - return "001132026081734940" - if "fd_opcode" in script and "return i" in script: - return 0 - if "List_Selected" in script and "innerText" in script: - token = str(args[0]) if args else "" - if token.startswith("DW"): - self.filled['input[name="extendDataFormInfo.value(fd_customeropcode)"]'] = token - elif token: - self.filled['input[name="extendDataFormInfo.value(fd_detail1.0.fd_opcode)"]'] = token - if "box.click" in script: - return {"ok": True} - return True - if "marked" in script and "fd_detail1" in script: - return [] - if "return location.href" in script: - return "http://contract-test.gzmpc.com:8080/km/agreement/km_agreement_apply/kmAgreementApply.do?method=view" - if "Com_Parameter" in script and "fdId" in script: - return "FD-TEST-001" - if "添加合同" in script and ("pdf" in script or "wantedStem" in script): - if any(call[0] == "upload_file_first" for call in self.calls): - return {"ok": True, "via": "name"} - return {"ok": False, "via": "empty"} - if "提交" in script or "合同正文" in script or "确定" in script: - return {"ok": True} - if "fs-label" in script and args: - return self.xform_labels.get(args[0], "") - if "data-value" in script: - return "模板A" - return [] - - def upload_file_first(self, selectors, file_path): - self.calls.append(("upload_file_first", tuple(selectors), file_path)) - - def read_first(self, selectors, value_name): - self.calls.append(("read_first", tuple(selectors), value_name)) - if value_name in self.read_values: - return self.read_values[value_name] - for selector in selectors: - if selector in self.filled: - return self.filled[selector] - joined = " ".join(str(item) for item in selectors) - if "fd_customername" in joined: - return self.read_values.get("customer_name", "重庆亿昊药业有限公司") - if "fd_customeropcode" in joined: - return self.read_values.get("customer_code", self.filled.get( - 'input[name="extendDataFormInfo.value(fd_customeropcode)"]', "" - )) - if "fdSettype" in joined: - return self.read_values.get("settlement_type", "付款") - return "" - - def read_body_text(self): - self.calls.append(("read_body_text",)) - return self.body_text - - def screenshot(self, name): - self.calls.append(("screenshot", name)) - return f"/tmp/{name}.png" - - -def test_signatory_rejects_placeholder_customer_before_opening_picker(): - browser = FakeBrowser() - ctx = ContractFlowContext( - contract_url="http://contract-test.gzmpc.com:8080/", - username="test002", - password="1", - supplier_name="重庆亿昊药业有限公司", - order_date="2026-08-11", - source_type="YYC", - customer_code="CUST-YYC-001", - customer_name="重庆亿昊药业有限公司", - ) - - with pytest.raises(SupplierContractDisabled, match="占位符"): - fill_contract_signatory_info(browser, ctx) - - assert browser.calls == [] - - -def test_contract_flow_logs_in_with_real_contract_login_selectors(): - browser = FakeBrowser() - ctx = ContractFlowContext( - contract_url="http://contract-test.gzmpc.com:8080/", - username="test002", - password="1", - supplier_name="重庆亿昊药业有限公司", - order_date="2026-08-11", - source_type="XYY", - template_type="FORMAT", - customer_code="DW0100046361", - customer_name="重庆亿昊药业有限公司", - goods_lines=[], - ) - - run_contract_create_flow(browser, ctx) - - assert ("open_url", "http://contract-test.gzmpc.com:8080/") in browser.calls - assert ("fill_first", ('input[name="j_username"]', 'input[placeholder="用户名"]'), "test002") in browser.calls - assert ("fill_first", ('input[name="j_password"]', 'input[placeholder="密码"]'), "1") in browser.calls - assert ( - "click_first", - ('a[class*="login_button"]', 'a:contains("登录")', 'input[name="btn_submit"]'), - ) in browser.calls - assert not [call for call in browser.calls if call[0] == "screenshot"] - - -def _touch_pdf(path): - path.write_bytes(b"%PDF-1.4\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF\n") - return str(path) - - -def test_contract_flow_uses_order_date_and_uploads_yyc_merged_pdf(tmp_path): - browser = FakeBrowser() - browser.read_values["customer_name"] = "重庆亿昊药业有限公司" - pdf_path = _touch_pdf(tmp_path / "contract-merged.pdf") - ctx = ContractFlowContext( - contract_url="http://contract-test.gzmpc.com:8080/", - username="test002", - password="1", - supplier_name="重庆亿昊药业有限公司", - order_date="2026-08-11", - source_type="YYC", - template_type="FORMAT", - customer_code="DW0100046361", - customer_name="重庆亿昊药业有限公司", - goods_lines=[ - ContractGoodsLine( - operation_code="ADD", - goods_code="FQX0100118047", - goods_name="盐酸托烷司琼注射液", - approval_no="苏20160372", - spec="5ml:5mg*5支", - unit="盒", - quantity=12, - unit_price="50.00", - ) - ], - merged_pdf_path=pdf_path, - ) - - contract_no = run_contract_create_flow(browser, ctx) - - assert contract_no == "001132026081734940" - assert ("fill_first", ('input[name="fdSigndate"]',), "2026-08-11") in browser.calls - assert ("fill_first", ('input[name="fdEffedate"]',), "2026-08-11") in browser.calls - assert ( - "click_first", - ('div[title="选择商品"]', "div.lui-component.lui_widget_btn_txt", "text=选择商品"), - ) in browser.calls - assert ( - "switch_to_frame", - ( - 'iframe[src*="EopBasedataMaterial"]', - 'iframe[src*="dialog_fdMaterial"]', - 'iframe[src*="common-template"]', - ), - ) in browser.calls - assert ("fill_first", ("input.search-box-text", "input[placeholder*='关键字']"), "FQX0100118047") in browser.calls - assert ( - "fill_first", - ('input[name="extendDataFormInfo.value(fd_detail1.0.fd_goodsqty)"]',), - "12", - ) in browser.calls - assert ( - "fill_first", - ('input[name="extendDataFormInfo.value(fd_detail1.0.fd_unitprice)"]',), - "50.00", - ) in browser.calls - assert any( - call[0] == "upload_file_first" and call[-1] == pdf_path - for call in browser.calls - ) - - -def test_contract_flow_skips_pdf_upload_for_xyy(): - browser = FakeBrowser() - browser.read_values["customer_name"] = "重庆亿昊药业有限公司" - ctx = ContractFlowContext( - contract_url="http://contract-test.gzmpc.com:8080/", - username="test002", - password="1", - supplier_name="重庆亿昊药业有限公司", - order_date="2026-08-11", - source_type="XYY", - template_type="FORMAT", - customer_code="DW0100046361", - customer_name="重庆亿昊药业有限公司", - goods_lines=[], - merged_pdf_path="C:/should-not-upload.pdf", - ) - - run_contract_create_flow(browser, ctx) - - assert not [call for call in browser.calls if call[0] == "upload_file_first"] - - -def test_contract_flow_enters_draft_form_by_template_url(tmp_path): - from rpa_yisaiqi.contract_flow_steps import FORM_PAGE_URL - - browser = FakeBrowser() - browser.read_values["customer_name"] = "重庆亿昊药业有限公司" - ctx = ContractFlowContext( - contract_url="http://contract-test.gzmpc.com:8080/", - username="test002", - password="1", - supplier_name="重庆亿昊药业有限公司", - order_date="2026-08-11", - source_type="YYC", - template_type="NON_FORMAT", - template_name="非格式--采购协议", - customer_code="DW0100046361", - customer_name="重庆亿昊药业有限公司", - goods_lines=[], - merged_pdf_path=_touch_pdf(tmp_path / "contract-merged.pdf"), - ) - - run_contract_create_flow(browser, ctx) - - assert ("open_url", FORM_PAGE_URL) in browser.calls - assert ("wait_for_text", "客户编码", 20) in browser.calls - - -def test_contract_flow_fills_real_contract_draft_form_fields_from_screenshot(): - browser = FakeBrowser() - browser.read_values["customer_name"] = "重庆亿昊药业有限公司" - ctx = ContractFlowContext( - contract_url="http://contract-test.gzmpc.com:8080/", - username="test002", - password="1", - supplier_name="重庆亿昊药业有限公司", - order_date="2026-08-11", - source_type="XYY", - template_type="NON_FORMAT", - template_name="非格式--采购协议", - customer_code="DW0100046361", - customer_name="重庆亿昊药业有限公司", - contract_category="采购协议", - settlement_type="付款", - contract_background="按订单自动起草采购协议", - contract_nature="主合同", - amount_currency="人民币", - amount="12000.50", - contract_end_date="2026-12-31", - is_major_contract="否", - is_prepayment="否", - belong_year="2026", - payment_method="账期支付", - payment_days="30", - contract_status="正式合同", - contains_blood_products="否", - goods_lines=[], - ) - - run_contract_create_flow(browser, ctx) - - assert ( - "select_xform", - "extendDataFormInfo.value(fd_contract_cate_type)", - "其他协议(采购)", - ) in browser.calls - assert ("select_first", ('select#fdSettype',), "付款") in browser.calls - assert ( - "fill_first", - ('textarea[name="extendDataFormInfo.value(fd_desc_info)"]',), - "按订单自动起草采购协议", - ) in browser.calls - assert ("select_xform", "extendDataFormInfo.value(fd_all_amount_type)", "合同标的金额") in browser.calls - assert ("fill_first", ('input[name="fdAccount"]',), "12000.50") in browser.calls - assert ("fill_first", ('input[name="fdSigndate"]',), "2026-08-11") in browser.calls - assert ("fill_first", ('input[name="fdEffedate"]',), "2026-08-11") in browser.calls - assert ("fill_first", ('input[name="fdEnddate"]',), "2026-12-31") in browser.calls - assert ("select_xform", "extendDataFormInfo.value(fd_is_major_contract)", "否") in browser.calls - assert ("select_xform", "extendDataFormInfo.value(fd_advance_amout)", "否") in browser.calls - assert ( - "fill_first", - ('input[name="extendDataFormInfo.value(fd_contract_year_text)"]',), - "2026", - ) in browser.calls - assert ("select_xform", "extendDataFormInfo.value(fd_paytype)", "电汇") in browser.calls - assert ( - "fill_first", - ('input[name="extendDataFormInfo.value(fd_pay_time_limit)"]',), - "30", - ) in browser.calls - assert ("select_xform", "extendDataFormInfo.value(fd_suconstatus)", "正式合同") in browser.calls - assert ("select_xform", "extendDataFormInfo.value(fd_3d1ce3953f1162)", "否") in browser.calls - - -def test_contract_flow_dry_run_skips_submit_but_still_reads_page_state(): - browser = FakeBrowser() - browser.read_values["customer_name"] = "重庆亿昊药业有限公司" - ctx = ContractFlowContext( - contract_url="http://contract-test.gzmpc.com:8080/", - username="test002", - password="1", - supplier_name="重庆亿昊药业有限公司", - order_date="2026-08-11", - source_type="XYY", - template_type="NON_FORMAT", - template_name="非格式--采购协议", - customer_code="DW0100046361", - customer_name="重庆亿昊药业有限公司", - submit_mode="dry_run", - goods_lines=[], - ) - - contract_no = run_contract_create_flow(browser, ctx) - - assert not [call for call in browser.calls if call[0] == "click_first" and "text=提交" in call[1]] - assert not [call for call in browser.calls if call[0] == "screenshot"] - assert contract_no == "" - - -def test_contract_flow_writes_text_accept_report_instead_of_requiring_screenshots(tmp_path, monkeypatch): - from rpa_yisaiqi.contract_flow_steps import _ACCEPT, accept_log_path - - monkeypatch.setenv("RPA_ACCEPT_LOG", str(tmp_path / "ACCEPT.txt")) - browser = FakeBrowser() - browser.read_values["customer_name"] = "重庆亿昊药业有限公司" - ctx = ContractFlowContext( - contract_url="http://contract-test.gzmpc.com:8080/", - username="test002", - password="1", - supplier_name="重庆亿昊药业有限公司", - order_date="2026-08-11", - source_type="XYY", - template_type="NON_FORMAT", - customer_code="DW0100046361", - customer_name="重庆亿昊药业有限公司", - submit_mode="dry_run", - goods_lines=[], - ) - - run_contract_create_flow(browser, ctx) - - text = accept_log_path("CONTRACT_CREATE").read_text(encoding="utf-8") - assert "ACCEPT SUMMARY" in text - assert "ACCEPT S03 ok" in text - assert "ACCEPT S06 ok" in text - assert "customerName=重庆亿昊药业有限公司" in text - assert "ACCEPT S12 ok" in text - assert all(line.ok for line in _ACCEPT.lines) - - -def test_read_contract_number_falls_back_to_my_contracts_list(): - from rpa_yisaiqi.contract_flow_steps import read_contract_number - - browser = FakeBrowser() - browser.read_values.pop("contract_number", None) - ctx = ContractFlowContext( - contract_url="http://contract-test.gzmpc.com:8080/", - username="test002", - password="1", - supplier_name="重庆亿昊药业有限公司", - order_date="2026-08-11", - source_type="XYY", - customer_code="DW0100046361", - customer_name="重庆亿昊药业有限公司", - contract_title="test1", - goods_lines=[], - ) - - contract_no = read_contract_number(browser, ctx) - - assert contract_no == "001132026081734940" - assert ("open_url", "http://contract-test.gzmpc.com:8080/") in browser.calls - assert ("click_first", ("text=我的合同",)) in browser.calls - assert ("click_first", ("text=我起草的",)) in browser.calls - assert ("click_first", ("text=申请时间",)) in browser.calls - - -def test_read_contract_number_goes_to_my_list_even_without_ctx_arg(): - from rpa_yisaiqi import _flow_state as state - from rpa_yisaiqi.contract_flow_steps import read_contract_number - - browser = FakeBrowser() - browser.read_values.pop("contract_number", None) - old_ctx = state.ctx - state.ctx = ContractFlowContext( - contract_url="http://contract-test.gzmpc.com:8080/", - username="test002", - password="1", - supplier_name="重庆亿昊药业有限公司", - order_date="2026-08-11", - source_type="XYY", - customer_code="DW0100046361", - customer_name="重庆亿昊药业有限公司", - contract_title="test1", - goods_lines=[], - ) - try: - contract_no = read_contract_number(browser) - finally: - state.ctx = old_ctx - - assert contract_no == "001132026081734940" - assert ("click_first", ("text=我的合同",)) in browser.calls - assert ("click_first", ("text=申请时间",)) in browser.calls - - -def test_accept_check_fails_when_page_value_mismatches(): - from rpa_yisaiqi.contract_flow_steps import AcceptanceReport - from rpa_yisaiqi.runtime.errors import NodeError - - report = AcceptanceReport() - with pytest.raises(NodeError, match="ACCEPT S06 FAIL"): - report.check( - "S06", - "签约方", - [("customerName", "其他公司", "重庆亿昊药业有限公司")], - ) diff --git a/state_center/给Trae的开发交接.md b/state_center/给Trae的开发交接.md deleted file mode 100644 index 3b0f950..0000000 --- a/state_center/给Trae的开发交接.md +++ /dev/null @@ -1,110 +0,0 @@ -# 给 Trae 的开发交接说明 - -> 你(Trae)接手"采购主链RPA一期 · 状态中心"的后续开发。本文是你的入口,先读这份,再读下面点名的文件。写代码前把"硬规则"和"你不做什么"看完,别越界。 - -## 一、你要干什么(一句话) - -做**状态中心**:一个 Python(FastAPI)+ MySQL 的后台 + 配套网页。它负责"每一单跑到哪步、出错派给谁、存状态和证据、画步骤树状图给业务看"。它**自己不去点任何外部系统页面**——点页面是艺赛旗 RPA 的活,通过 HTTP 回调跟你交互。 - -## 二、先读这几个文件(按顺序) - -1. `资料/采购主链RPA一期需求文档_三方会审版.md` —— 业务规则(节点N01~N10、字段、异常)。 -2. `资料/采购主链RPA一期设计文档.md` —— 技术设计(表结构、状态机、接口契约、对码引擎)。 -3. `资料/采购主链RPA一期实施与验收规范.md` —— **每个节点的操作步骤 + 验收标准(代码+业务两栏)**,你做完对着这里勾。 -4. `state_center/README.md` —— 已有代码怎么起、怎么验。 - -## 三、技术栈(已定,别改) - -- 后台:Python 3 + FastAPI -- 库:MySQL 8(状态中心自己的库,建库脚本 `sql/schema.sql` 已给) -- 对码货品主数据:在 **Oracle**,只读直连查,**不要复制成本地表** -- OCR:本地 Python 服务(PaddleOCR),仅线下手工单用 -- 前端:Vue 或 React(待办页 + 步骤树状图 + 整单查询) -- RPA 下层:艺赛旗 iS-RPA(不是你做,你只跟它的 HTTP 回调对接) - -## 四、已经写好的(别重写,在这上面接着做) - -`state_center/` 里已有可跑的第1步骨架: - -| 文件 | 已实现 | 状态 | -| --- | --- | --- | -| `sql/schema.sql` | 8张表建库脚本 | 完成 | -| `app/config.py` | MySQL/Oracle 连接、Oracle表名列名占位 | 完成 | -| `app/db.py` | MySQL 访问(pymysql) | 完成 | -| `app/match_engine.py` | 对码引擎:两级命中+规格标准化+Oracle取数函数 | 完成,单测6/6过 | -| `app/main.py` | 建单 + start/complete/fail 三回调(含幂等/乱序拒绝)+ 合同审批等待 + 整单查询 | 骨架完成 | -| `tests/test_match_engine.py` | 对码单测 | 通过 | - -> 状态机流转已通(**13 节点 = 11 主 + 2 条件** 显式转移表、业务编号回写、回调幂等、乱序拒绝 409、合同审批等待态、M5质管缺失→FAILED_MANUAL、M5不自动保存),用假回调能把一个 job 从头走到尾。 -> -> 节点数口径:主链 11 步每单都走;`OCR_EXTRACT`(仅线下单)、`SOURCE_ADD`(仅缺货源)是条件节点,不满足自动跳过。代码 `NODE_ORDER` 列全 13 个,`TRANSITIONS` 定转移,`NODE_EXECUTOR` 标执行方(STATE_CENTER/RPA/HUMAN)。 - -## 五、你的任务清单(按顺序做,每步对着验收规范勾) - -### 任务1:把骨架在真 MySQL 上跑通 -- 建库、起服务,按 README 的 curl 把一个 job 用假回调走完主链(线上单会自动跳过 OCR_EXTRACT/SOURCE_ADD 条件节点)。 -- 验收:`GET /api/jobs/{id}` 能看到完整节点轨迹和状态。 - -### 任务2:对码接口 + Oracle 真连 -- 拿到 Oracle 连接串/表名列名后,填 `config.py`,`pip install oracledb`。 -- **骨架已写好**(`main.py`),你主要是接真数据、补前置: - - `POST /api/match/reload-goods`:开工从 Oracle 全量拉货品主数据装进内存引擎(依赖 ORACLE_* 配置)。 - - `POST /api/jobs/{id}/match/run`:对该 job 的 `order_item` 跑对码 → 落 `product_match`;MULTI/NONE 建 `user_task` 转 WAITING_USER,全命中推进下一节点。**前置:`order_item` 要先落库**(建单/ORDER_FETCH 后把明细写进来),否则 run 会 400。 - - `POST /api/match/{matchId}/confirm`:人工确认一条,回写 product_match + 沉淀 `mapping_config` 累积别名(下次自动 ALIAS 命中),全部解决后自动推进。 -- 已补齐的关键边界: - - 对码重跑幂等:`product_match(job_id,order_item_id)` 当前态唯一,`match/run` upsert 结果并重建 OPEN 待办。 - - 货源判定:`goods_id` 明确后,按历史 `supplier_name + goods_id` 判断是否已有货源;缺货源会写 `product_match.source_add_needed/job.needs_source_add` 并创建 `SOURCE_ADD` 聚合待办。 - - alias 沉淀:同一 `config_key` 先失效旧 active,再插新 active;装载时按固定顺序取当前结果。 -- 你要补的:① 订单明细落 `order_item` 的入口;② `GET /api/jobs/{id}/match/unmatched`(列未命中给前端);③ H5 待办提交入口(把 `SOURCE_ADD` 完成动作接到 `nodes/SOURCE_ADD/complete`)。 -- **只写 mapping_config 累积别名,绝不回写 Oracle 主数据**(主数据只读)。 -- 验收:喂真实订单,命中率有数字,未命中进待办,确认后能自动往下走。 - -### 任务3:待办 + 人工提交 -- `GET /api/tasks/my?roleCode=&status=open`、`POST /api/tasks/{id}/submit`。 -- 动作:SELECT_CANDIDATE(对码选候选)/CONFIRM_AND_CONTINUE/RETRY_CURRENT_NODE/TRANSFER_MANUAL/TERMINATE_JOB。 -- 幂等:task_id + submit_token 防重复提交。 - -### 任务4:前端网页 -- 步骤树状图(样式见实施规范第三部分)、待办页、整单查询页。 -- 验收:业务打开能看到每单跑到哪、卡在哪、去处理按钮跳待办。 - -### 任务5:证据 + 事件流 + 企业微信 -- 证据落盘(job_artifact),事件流已有基础(job_event)。 -- 企业微信推待办、H5 身份换 JWT。 - -### 任务6:调度与重试 -- 定时触发建单(跑前一天订单)、系统类错误自动重试1-2次、CONTRACT_APPROVE_WAIT 轮询(别死等)、超时升级。 - -## 六、硬规则(红线,违反就是错的) - -1. **对码表在 Oracle,只读,绝不复制成本地表、绝不回写 Oracle**。累积别名写自己的 mapping_config。 -2. **M5 质管资料缺失(errorCode=M5_QC_MISSING)→ 不重试、不等质管,直接 FAILED_MANUAL 转人工**。已在 main.py 实现,别改成重试。 -3. **M5 一期不自动保存/签订**(auto_save_enabled 默认0),做成开关,阶段二才放开。别默认自动。 -4. **关键送审在返利中心(REBATE_SUBMIT),不是合同系统**。不送审 M5 带不出协议,是硬阻断。 -5. **签约日期 = 订单日期**(不是今天),合同建单时按订单日期填。 -6. **医药城同供应商同日合同合并成一个PDF**再上传(传第二份会覆盖),小药药不上传。 -7. **返利细单备注必填订单号、特定补偿必填0**。 -8. **对码未命中/返利重复编码 = 单条挂起,整批继续**;不是停整批。 -9. **所有写接口必须鉴权**;RPA 回调校验 X-Agent-Token + 幂等键,禁止无认证暴露。 -10. 节点失败不覆盖历史,attempt_no+1 新建 job_node 行。 - -## 七、你不做什么(边界) - -- **不写点外部系统页面的代码**(登录/导Excel/建合同/M5录入)——那是艺赛旗,你只收它的 HTTP 回调。 -- 不做招财平台(一期不纳入)。 -- 不做加单/拆单全自动重做(先记录父订单号,转人工)。 -- 不做返利重复编码全自动重建(规则未定,先转人工)。 -- 遇到文档里标 `【待确认】` 的(订单状态过滤、OCR部署方式等),别自己拍板,先留 TODO 问项目负责人。 - -## 八、回调契约(艺赛旗会这样调你,已实现) - -``` -POST /api/jobs/{jobId}/nodes/{nodeCode}/start header: X-Agent-Token -POST /api/jobs/{jobId}/nodes/{nodeCode}/complete 带 businessRefs/summary -POST /api/jobs/{jobId}/nodes/{nodeCode}/fail 带 errorCode/retryable/routeRole -``` -字段细节看 `设计文档` 第六部分和 `main.py` 里的 Pydantic 模型。 - -## 九、验收总纲 - -每做完一个节点/接口,打开 `实施与验收规范` 第四部分,对着该节点的**代码验收**(你自检)和**业务验收**(交业务勾)两栏逐条过。全绿才算完。 diff --git a/state_center/给艺赛旗的开发交接.md b/state_center/给艺赛旗的开发交接.md deleted file mode 100644 index af3948a..0000000 --- a/state_center/给艺赛旗的开发交接.md +++ /dev/null @@ -1,197 +0,0 @@ -# 给艺赛旗(iS-RPA)的开发交接说明 - -> 你(艺赛旗开发)负责"采购主链RPA一期"里**点外部系统页面**的活:登录、导订单、建合同、返利中心操作、M5 录单。每做完一步,用 HTTP 回调告诉"状态中心"结果。状态中心是大脑(记每单跑到哪、派活、存证据),你是手脚(照它给的顺序点页面)。你俩**只通过 HTTP JSON 回调对接,不共享数据库**。 - -## 一、你要干什么(一句话) - -按状态中心下发的 job(一个供应商+一个订单日期=一条合同),在各外部系统页面上完成对应节点的操作,每个节点用三个回调(start / complete / fail)把状态回传。 - -## 二、你和状态中心怎么对接(核心) - -``` -状态中心(Python/FastAPI) 艺赛旗 iS-RPA - 记状态/派活/存证据 <--HTTP--> 点页面/填表/上传 - | | - | 1) 你开始一个节点: POST .../start | - | 2) 点完页面成功: POST .../complete | - | 3) 出错: POST .../fail | -``` - -- **状态中心不会调用你**(一期没有反向下发);是**你主动调它**三个回调。 -- 每次调用**必须带 header `X-Agent-Token`**(默认 `dev-agent-token`,上线换正式值,找状态中心开发要)。 -- 节点顺序由状态中心维护,你 complete 后它自己算下一个节点是谁并返回 `next_node`,你**照着它返回的 next_node 跑下一步**,不要自己写死顺序。 - -## 三、三个回调契约(照抄字段,别改名) - -基础地址:`http://<状态中心地址>:8000`(联调时给你实际 IP)。所有回调 header 带 `X-Agent-Token: `、`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 三段分别单独联调通过后,再跑整链。 - - diff --git a/state_center_web/LAYOUT_PROPOSAL.md b/state_center_web/LAYOUT_PROPOSAL.md deleted file mode 100644 index 545b953..0000000 --- a/state_center_web/LAYOUT_PROPOSAL.md +++ /dev/null @@ -1,207 +0,0 @@ -# 状态中心页面 - 可调整版式方案 - -## 设计原则 - -1. **现在(联调阶段)**:全部展开,方便你看数据流转、验证接口。 -2. **后期(交付阶段)**:一键折叠技术细节,业务只看待办 + 简化流程图。 -3. **实现方式**:每个区域带展开/收起按钮 + 顶部"调试模式"开关,展开状态存 localStorage。 - ---- - -## 版式结构 - -``` -┌──────────────────────────────────────────────────────────────────┐ -│ 顶栏 │ -│ [状态中心] [调试模式: ●开 ○关] 角色:采购 待办状态:待处理 │ -│ [令牌: ****] [刷新] │ -└──────────────────────────────────────────────────────────────────┘ - -┌──────────────────────────────────────────────────────────────────┐ -│ ▼ 待办队列 (始终可见) [收起/展开]│ -├──────────────────────────────────────────────────────────────────┤ -│ ┌────────────┬────────────┬──────────────────────────────────┐ │ -│ │ □ 新增货源 │ 对码待确认 │ M5人工确认 │ │ -│ │ 重庆亿昊 │ 阿卡波糖 │ 华润医药 │ │ -│ │ 10:00 │ 09:30 │ 昨天 │ │ -│ └────────────┴────────────┴──────────────────────────────────┘ │ -│ │ -│ 当前选中待办:新增货源 - 重庆亿昊药业 │ -│ ┌────────────────────────────────────────────────────────────┐ │ -│ │ 原因:该供应商未维护这些品种的货源 │ │ -│ │ [选择候选货品: ▼ 阿卡波糖片 50mg*30片] │ │ -│ │ [☑ 保存为下次自动匹配规则] │ │ -│ │ [确认并继续] [转派] [终止] │ │ -│ └────────────────────────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────────────────────┘ - -┌──────────────────────────────────────────────────────────────────┐ -│ ▼ 所属单据概览 [收起/展开]│ -├──────────────────────────────────────────────────────────────────┤ -│ 单号: YYC-重庆亿昊药业-20260804-demo 状态: 等待人工 │ -│ 当前节点: 新增货源 责任角色: 采购 │ -│ 订单日期: 2026-08-04 供应商: 重庆亿昊药业 │ -│ │ -│ 业务编号链: │ -│ 订单 DD-001,DD-002 → 合同 HT-xxx → 返利 - (待填) → M5 - → 收货 - │ -│ │ -│ [查看完整详情 ↗] │ -└──────────────────────────────────────────────────────────────────┘ - -┌──────────────────────────────────────────────────────────────────┐ -│ ▼ 流程状态图 [收起/展开]│ -├──────────────────────────────────────────────────────────────────┤ -│ [订单获取✓] [OCR⊘] [对码✓] [新增货源●] [合同建单○] ... [归档○] │ -│ │ -│ 图例: ✓已完成 ●当前 ⊘已跳过 ○待处理 ✗失败 │ -└──────────────────────────────────────────────────────────────────┘ - -┌──────────────────────────────────────────────────────────────────┐ -│ ▼ 调试:节点详情 (调试模式开启时显示) [收起/展开]│ -├──────────────────────────────────────────────────────────────────┤ -│ 已执行节点: │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ ORDER_FETCH [尝试1] 成功 09:40-09:42 (2分钟) │ │ -│ │ 摘要: {orderCount: 12} │ │ -│ │ 证据: orders.xlsx (SOURCE_EXCEL) │ │ -│ ├──────────────────────────────────────────────────────────┤ │ -│ │ PRODUCT_MATCH [尝试1] 成功 09:50-09:51 (1分钟) │ │ -│ │ 摘要: {EXACT:10, ALIAS:1, MULTI:1} │ │ -│ │ 证据: 无 │ │ -│ ├──────────────────────────────────────────────────────────┤ │ -│ │ SOURCE_ADD [尝试1] 等待人工 10:00- │ │ -│ │ 证据: 无 │ │ -│ └──────────────────────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────────────────────┘ - -┌──────────────────────────────────────────────────────────────────┐ -│ ▼ 调试:证据包 (调试模式开启时显示) [收起/展开]│ -├──────────────────────────────────────────────────────────────────┤ -│ ORDER_FETCH尝试1: │ -│ └─ SOURCE_EXCEL: /data/jobs/.../orders.xlsx (09:42) │ -│ CONTRACT_CREATE尝试1: │ -│ └─ CONTRACT_PDF: /data/jobs/.../contract.pdf (10:06) │ -└──────────────────────────────────────────────────────────────────┘ - -┌──────────────────────────────────────────────────────────────────┐ -│ ▼ 调试:单据列表 (调试模式开启时显示) [收起/展开]│ -├──────────────────────────────────────────────────────────────────┤ -│ [全部] [执行中] [等待人工] [已完成] [失败] │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ 重庆亿昊药业 2026-08-04 YYC 等待人工 新增货源 采购 │ │ -│ │ 华润医药 2026-08-03 线下 执行中 对码 计划员 │ │ -│ └──────────────────────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────────────────────┘ - -┌──────────────────────────────────────────────────────────────────┐ -│ ▼ 开发:节点回调模拟 (仅 DEV 环境显示) [收起/展开]│ -├──────────────────────────────────────────────────────────────────┤ -│ 当前节点: 新增货源 │ -│ [参数 JSON: ] │ -│ [开始] [成功] [失败] │ -└──────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 分区说明 - -### 1. 待办队列 (始终可见,不可收起) -- **用途**:主工作流,无论联调还是生产都需要。 -- **内容**:待办卡片 + 当前待办处理表单。 - -### 2. 所属单据概览 (默认展开,可收起) -- **用途**:提供当前待办的上下文(哪个供应商、哪天的单、跑到哪了)。 -- **后期**:业务只看这个 + 待办队列就够了。 - -### 3. 流程状态图 (默认展开,可收起) -- **用途**:直观看 13 个节点的完成情况。 -- **后期**:保留,但可以简化成 mini 版(只显示名称 + 状态点)。 - -### 4. 调试:节点详情 (调试模式开启时显示) -- **用途**:验证每个节点的 started_at/ended_at/duration/output_json/error。 -- **后期**:关掉,业务不需要看技术细节。 - -### 5. 调试:证据包 (调试模式开启时显示) -- **用途**:验证 RPA 回调上传的证据是否落库、路径对不对。 -- **后期**:关掉或改成"证据总数"摘要。 - -### 6. 调试:单据列表 (调试模式开启时显示) -- **用途**:你在联调时快速切换不同单据验证状态流转。 -- **后期**:关掉或改成独立页面(管理员监控用)。 - -### 7. 开发:节点回调模拟 (仅 DEV 环境显示) -- **用途**:本地联调时模拟艺赛旗回调。 -- **后期**:生产环境 `import.meta.env.DEV=false` 自动隐藏。 - ---- - -## 技术实现 - -### 1. 展开/收起状态存储 -```typescript -// localStorage 存每个区域的展开状态 -const sectionStates = { - jobSummary: true, // 所属单据概览 - timeline: true, // 流程状态图 - nodeDetails: false, // 节点详情(调试模式控制) - evidence: false, // 证据包(调试模式控制) - jobList: false, // 单据列表(调试模式控制) -}; -localStorage.setItem('sectionStates', JSON.stringify(sectionStates)); -``` - -### 2. 调试模式开关 -```typescript -const [debugMode, setDebugMode] = useState( - localStorage.getItem('debugMode') === 'true' -); - -// 顶栏开关:切换时同步存储,并控制"调试:"前缀的区域显隐 -function toggleDebugMode() { - const next = !debugMode; - setDebugMode(next); - localStorage.setItem('debugMode', String(next)); -} -``` - -### 3. 可收起组件封装 -```typescript - - {/* 单据详情内容 */} - - - - {/* 节点列表 */} - -``` - ---- - -## 后期交付时的调整 - -你只需要: -1. **关闭调试模式**:顶栏开关点"关",所有"调试:"区域自动隐藏。 -2. **可选:收起流程图**:如果业务觉得太占屏幕,点收起按钮即可。 -3. **最终业务看到的**:待办队列 + 所属单据概览(可能 + 简化流程图),干净清爽。 - ---- - -## 你确认一下 - -这个方案是否满足你"现在全看、后期可关"的需求?如果可以,我直接动手: -1. 先封装 `CollapsibleSection` 组件。 -2. 把现有页面按上述分区重组。 -3. 加调试模式开关 + localStorage 持久化。 -4. 调整样式,让收起状态下不占垂直空间。 - -或者你对分区划分 / 默认展开状态有别的想法,直接说,我再调整。 diff --git a/state_center_web/README.md b/state_center_web/README.md deleted file mode 100644 index 3fdbc90..0000000 --- a/state_center_web/README.md +++ /dev/null @@ -1,65 +0,0 @@ -# 状态中心 Web 版 - -## 一键启动联调环境 - -在项目根目录执行: - -macOS/Linux: - -```bash -./.venv_test/bin/python scripts/dev.py start -``` - -Windows PowerShell: - -```powershell -.\.venv_test\Scripts\python.exe scripts\dev.py start -``` - -启动器会复用或创建 Docker MySQL 容器 `rpa-center-mysql`,然后启动 FastAPI 和 Vite。 - -```bash -python scripts/dev.py status -python scripts/dev.py stop -``` - -`status` 只有在 MySQL 端口、FastAPI `/health`(包含数据库探测)和 Vite 端口都正常时才返回成功。 - -## 手动启动 - -```bash -cd state_center_web -npm install -npm run dev -``` - -默认地址:`http://localhost:5173` - -后端另开一个终端: - -macOS/Linux: - -```bash -python -m pip install -r state_center/requirements.txt -DEV_MODE=true python -m uvicorn state_center.app.main:app --reload --port 8000 -``` - -Windows PowerShell: - -```powershell -$env:DEV_MODE = "true" -python -m pip install -r state_center\requirements.txt -python -m uvicorn state_center.app.main:app --reload --port 8000 -``` - -浏览器访问:`http://localhost:5173/`。同一套页面适用于 Windows 和 macOS;前端不会自行推导流程下一节点,所有节点状态都来自 FastAPI。 - -## 说明 - -- 页面全中文。 -- 后端可连时,直接读 `state_center` 的 FastAPI 接口。 -- 后端没起时,页面自动切到本地演示数据,仍可看到单据列表、流程状态图和待办面板。 -- 页面顶部会明确显示“本地演示数据”或“后端已连接”。 -- 对码待办可直接选择候选货品;人工节点按节点显示对应动作。 -- 节点回调模拟区只在开发环境构建中显示。 -- 开发令牌只保存在浏览器会话里,不会写进代码。 diff --git a/state_center_web/index.html b/state_center_web/index.html deleted file mode 100644 index 0308ff3..0000000 --- a/state_center_web/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - 状态中心 - - -
- - - diff --git a/state_center_web/package-lock.json b/state_center_web/package-lock.json deleted file mode 100644 index 5dabd8a..0000000 --- a/state_center_web/package-lock.json +++ /dev/null @@ -1,1750 +0,0 @@ -{ - "name": "state-center-web", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "state-center-web", - "version": "0.1.0", - "dependencies": { - "react": "^18.3.1", - "react-dom": "^18.3.1" - }, - "devDependencies": { - "@types/react": "^18.3.5", - "@types/react-dom": "^18.3.0", - "@vitejs/plugin-react": "^4.3.1", - "typescript": "^5.6.3", - "vite": "^5.4.8" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", - "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", - "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.8", - "@babel/types": "^7.29.8", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", - "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", - "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", - "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.8" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", - "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", - "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", - "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", - "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.8", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.8", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.8", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", - "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@napi-rs/lzma-linux-x64-gnu": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", - "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^22.20 || ^24.12 || >=25" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.27", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", - "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", - "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", - "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", - "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", - "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", - "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", - "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", - "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", - "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", - "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", - "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", - "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", - "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", - "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", - "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", - "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", - "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", - "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", - "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", - "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", - "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", - "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", - "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", - "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", - "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", - "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "18.3.31", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", - "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", - "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.28.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.27", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.11.13", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz", - "integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/browserslist": { - "version": "4.28.8", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", - "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.11.12", - "caniuse-lite": "^1.0.30001809", - "electron-to-chromium": "^1.5.402", - "node-releases": "^2.0.53", - "update-browserslist-db": "^1.3.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001809", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", - "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.403", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.403.tgz", - "integrity": "sha512-MQsYmdaLzvaCX5j+ZZBr5Fm6uCCnPQcRtlvmvRlWqrXy+BH2O4ffXIAScF+JQznQWB9brWp4lSD9Z4yNmaf2BA==", - "dev": true, - "license": "ISC" - }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/node-releases": { - "version": "2.0.53", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", - "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/postcss": { - "version": "8.5.26", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", - "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.17", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/rollup": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", - "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.9" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@napi-rs/lzma-linux-x64-gnu": "1.5.1", - "@rollup/rollup-android-arm-eabi": "4.62.4", - "@rollup/rollup-android-arm64": "4.62.4", - "@rollup/rollup-darwin-arm64": "4.62.4", - "@rollup/rollup-darwin-x64": "4.62.4", - "@rollup/rollup-freebsd-arm64": "4.62.4", - "@rollup/rollup-freebsd-x64": "4.62.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", - "@rollup/rollup-linux-arm-musleabihf": "4.62.4", - "@rollup/rollup-linux-arm64-gnu": "4.62.4", - "@rollup/rollup-linux-arm64-musl": "4.62.4", - "@rollup/rollup-linux-loong64-gnu": "4.62.4", - "@rollup/rollup-linux-loong64-musl": "4.62.4", - "@rollup/rollup-linux-ppc64-gnu": "4.62.4", - "@rollup/rollup-linux-ppc64-musl": "4.62.4", - "@rollup/rollup-linux-riscv64-gnu": "4.62.4", - "@rollup/rollup-linux-riscv64-musl": "4.62.4", - "@rollup/rollup-linux-s390x-gnu": "4.62.4", - "@rollup/rollup-linux-x64-gnu": "4.62.4", - "@rollup/rollup-linux-x64-musl": "4.62.4", - "@rollup/rollup-openbsd-x64": "4.62.4", - "@rollup/rollup-openharmony-arm64": "4.62.4", - "@rollup/rollup-win32-arm64-msvc": "4.62.4", - "@rollup/rollup-win32-ia32-msvc": "4.62.4", - "@rollup/rollup-win32-x64-gnu": "4.62.4", - "@rollup/rollup-win32-x64-msvc": "4.62.4", - "fsevents": "~2.3.2" - } - }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.0.tgz", - "integrity": "sha512-x/M6q3w4Ybp91CNaS4S69UnliqR3BzRpOT6LWbksjth0S/+jhfaPJsWjt/TewpT8j9eLIojUf5jr29WextHroA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - } - } -} diff --git a/state_center_web/package.json b/state_center_web/package.json deleted file mode 100644 index c79b1c6..0000000 --- a/state_center_web/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "state-center-web", - "private": true, - "version": "0.1.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview", - "check": "tsc --noEmit", - "test": "node --test --experimental-strip-types src/*.test.mjs" - }, - "dependencies": { - "react": "^18.3.1", - "react-dom": "^18.3.1" - }, - "devDependencies": { - "@types/react": "^18.3.5", - "@types/react-dom": "^18.3.0", - "@vitejs/plugin-react": "^4.3.1", - "typescript": "^5.6.3", - "vite": "^5.4.8" - } -} diff --git a/state_center_web/src/App.tsx b/state_center_web/src/App.tsx deleted file mode 100644 index 6aae693..0000000 --- a/state_center_web/src/App.tsx +++ /dev/null @@ -1,1136 +0,0 @@ -import { useEffect, useMemo, useState } from 'react'; -import { requestJson, sessionTokenStore } from './api/client'; -import { JobAcceptanceChecklist } from './components/JobAcceptanceChecklist'; -import { JobList } from './components/JobList'; -import { JobEvidenceList } from './components/JobEvidenceList'; -import { JobNodeDetailList } from './components/JobNodeDetailList'; -import { JobTimeline } from './components/JobTimeline'; -import { TaskList } from './components/TaskList'; -import { ToolbarSelect } from './components/ToolbarSelect'; -import { - type JobArtifactRow, - type JobNodeRow, - buildNodeCallbackRequestBody, - actionLabel, - allowedTaskActions, - buildBusinessOverview, - buildNodeDetails, - formatNodeCallbackTemplate, - nodeLabel, - nodeCallbackActionLabel, - reasonLabel, - roleLabel, - jobStatusBucketLabel, - jobStatusBucketMatches, - jobMatchesQuery, - type JobStatusBucket, - type NodeCallbackKind, - taskCandidates, - taskStatusLabel, - type JobRow, - type StateMachineMeta, - type TaskRow, - type TimelineNode, -} from './stateMachine'; - -type JobDetail = { - job: JobRow; - artifacts: JobArtifactRow[]; - nodes: JobNodeRow[]; - tasks: TaskRow[]; -}; - -type TaskSubmitResponse = { - ok: boolean; - next_node?: string | null; - job_status?: string | null; - idempotent?: boolean; -}; - -const devActionsEnabled = import.meta.env.DEV; -const jobBucketOptions: JobStatusBucket[] = ['ALL', 'ACTIVE', 'WAITING', 'FAILED', 'SUCCESS']; -const taskStatusOptions = ['OPEN', 'SUBMITTED'] as const; -const roleOptions = [ - { value: 'PURCHASER', label: '采购' }, - { value: 'PLANNER', label: '计划员' }, - { value: 'QC', label: '质管' }, - { value: 'OPS', label: '运维' }, -]; -const demoJobId = 'YYC-重庆亿昊药业-20260804-demo'; -const demoMeta: StateMachineMeta = { - node_order: [ - 'ORDER_FETCH', - 'OCR_EXTRACT', - 'PRODUCT_MATCH', - 'SOURCE_ADD', - 'CONTRACT_CREATE', - 'CONTRACT_APPROVE_WAIT', - 'REBATE_HEADER', - 'REBATE_IMPORT', - 'REBATE_SUBMIT', - 'M5_CREATE_ORDER', - 'M5_MANUAL_CONFIRM', - 'M5_RECEIPT_NOTICE', - 'ARCHIVE_JOB', - ], - transitions: {}, - conditional_nodes: ['OCR_EXTRACT', 'SOURCE_ADD'], - human_node_roles: { - SOURCE_ADD: 'PURCHASER', - M5_MANUAL_CONFIRM: 'PURCHASER', - M5_RECEIPT_NOTICE: 'PURCHASER', - }, - node_executor: { - ORDER_FETCH: 'RPA', - OCR_EXTRACT: 'RPA', - PRODUCT_MATCH: 'STATE_CENTER', - SOURCE_ADD: 'HUMAN', - CONTRACT_CREATE: 'RPA', - CONTRACT_APPROVE_WAIT: 'RPA', - REBATE_HEADER: 'RPA', - REBATE_IMPORT: 'RPA', - REBATE_SUBMIT: 'RPA', - M5_CREATE_ORDER: 'RPA', - M5_MANUAL_CONFIRM: 'HUMAN', - M5_RECEIPT_NOTICE: 'HUMAN', - ARCHIVE_JOB: 'STATE_CENTER', - }, - node_labels: { - ORDER_FETCH: '订单获取', - OCR_EXTRACT: 'OCR识别', - PRODUCT_MATCH: '对码', - SOURCE_ADD: '新增货源', - CONTRACT_CREATE: '合同建单', - CONTRACT_APPROVE_WAIT: '合同审批等待', - REBATE_HEADER: '返利总单', - REBATE_IMPORT: '返利细单导入', - REBATE_SUBMIT: '返利送审', - M5_CREATE_ORDER: 'M5建单', - M5_MANUAL_CONFIRM: 'M5人工确认', - M5_RECEIPT_NOTICE: '收货通知单', - ARCHIVE_JOB: '归档', - }, - executor_labels: { - STATE_CENTER: '状态中心', - RPA: '艺赛旗执行', - HUMAN: '人工处理', - }, - status_labels: { - PENDING: '待处理', - RUNNING: '执行中', - SUCCESS: '已完成', - FAILED: '已失败', - WAITING_USER: '等待人工', - WAITING_EXTERNAL: '等待外部', - SKIPPED: '已跳过', - }, -}; -const demoJobs: JobRow[] = [ - { - job_id: demoJobId, - supplier_name: '重庆亿昊药业', - order_date: '2026-08-04', - source_type: 'YYC', - status: 'WAITING_USER', - current_node_code: 'SOURCE_ADD', - current_role: 'PURCHASER', - created_at: '2026-08-10 09:40:00', - updated_at: '2026-08-10 10:06:00', - }, - { - job_id: 'OFFLINE-华润医药-20260803-demo', - supplier_name: '华润医药', - order_date: '2026-08-03', - source_type: 'OFFLINE', - status: 'RUNNING', - current_node_code: 'PRODUCT_MATCH', - current_role: 'PLANNER', - created_at: '2026-08-10 08:20:00', - updated_at: '2026-08-10 08:45:00', - }, -]; -const demoTimeline: TimelineNode[] = [ - { node_code: 'ORDER_FETCH', display_status: 'SUCCESS', attempt_no: 1, status: 'SUCCESS' }, - { node_code: 'OCR_EXTRACT', display_status: 'SKIPPED' }, - { node_code: 'PRODUCT_MATCH', display_status: 'SUCCESS', attempt_no: 1, status: 'SUCCESS' }, - { node_code: 'SOURCE_ADD', display_status: 'WAITING_USER', attempt_no: 1, status: 'WAITING_USER' }, - { node_code: 'CONTRACT_CREATE', display_status: 'PENDING' }, - { node_code: 'CONTRACT_APPROVE_WAIT', display_status: 'PENDING' }, - { node_code: 'REBATE_HEADER', display_status: 'PENDING' }, - { node_code: 'REBATE_IMPORT', display_status: 'PENDING' }, - { node_code: 'REBATE_SUBMIT', display_status: 'PENDING' }, - { node_code: 'M5_CREATE_ORDER', display_status: 'PENDING' }, - { node_code: 'M5_MANUAL_CONFIRM', display_status: 'PENDING' }, - { node_code: 'M5_RECEIPT_NOTICE', display_status: 'PENDING' }, - { node_code: 'ARCHIVE_JOB', display_status: 'PENDING' }, -]; -const demoTasks: TaskRow[] = [ - { - task_id: 'TASK-001', - job_id: demoJobId, - node_code: 'SOURCE_ADD', - role_code: 'PURCHASER', - status: 'OPEN', - title: '新增货源:重庆亿昊药业', - reason_type: 'SOURCE_ADD', - created_at: '2026-08-10 10:00:00', - }, - { - task_id: 'TASK-002', - job_id: 'OFFLINE-华润医药-20260803-demo', - node_code: 'PRODUCT_MATCH', - role_code: 'PLANNER', - status: 'OPEN', - title: '对码待确认:阿卡波糖片', - reason_type: 'MATCH_MULTI', - product_match_id: 7, - candidates_json: [ - { - goods_id: 'G-001', - goods_code: 'YP-001', - common_name: '阿卡波糖片', - spec: '50mg*30片', - base_unit: '盒', - manufacturer: '示例制药', - approval_no: '国药准字H00000001', - }, - { - goods_id: 'G-004', - goods_code: 'YP-004', - common_name: '阿卡波糖片', - spec: '50mg*60片', - base_unit: '盒', - manufacturer: '示例制药', - approval_no: '国药准字H00000004', - }, - ], - created_at: '2026-08-10 09:30:00', - }, -]; -const demoJobDetailMap: Record = { - [demoJobId]: { - job: demoJobs[0], - artifacts: [ - { - node_code: 'ORDER_FETCH', - attempt_no: 1, - artifact_type: 'SOURCE_EXCEL', - storage_path: '/data/jobs/YYC-重庆亿昊药业-20260804-demo/orders.xlsx', - file_hash: 'sha256:demo-order-excel', - visible_roles: ['PURCHASER', 'PLANNER'], - is_masked: false, - created_at: '2026-08-10 09:42:00', - }, - { - node_code: 'CONTRACT_CREATE', - attempt_no: 1, - artifact_type: 'CONTRACT_PDF', - storage_path: '/data/jobs/YYC-重庆亿昊药业-20260804-demo/contract.pdf', - file_hash: 'sha256:demo-contract-pdf', - visible_roles: ['PURCHASER'], - is_masked: false, - created_at: '2026-08-10 10:06:00', - }, - ], - nodes: [ - { node_code: 'ORDER_FETCH', attempt_no: 1, status: 'SUCCESS' }, - { node_code: 'PRODUCT_MATCH', attempt_no: 1, status: 'SUCCESS' }, - { node_code: 'SOURCE_ADD', attempt_no: 1, status: 'WAITING_USER' }, - ], - tasks: demoTasks, - }, - [demoJobs[1].job_id]: { - job: demoJobs[1], - artifacts: [], - nodes: [ - { node_code: 'ORDER_FETCH', attempt_no: 1, status: 'SUCCESS' }, - { node_code: 'OCR_EXTRACT', attempt_no: 1, status: 'SKIPPED' }, - { node_code: 'PRODUCT_MATCH', attempt_no: 1, status: 'RUNNING' }, - ], - tasks: [ - { - task_id: 'TASK-003', - job_id: demoJobs[1].job_id, - node_code: 'PRODUCT_MATCH', - role_code: 'PLANNER', - status: 'OPEN', - title: '对码待确认:阿卡波糖片', - reason_type: 'MATCH_MULTI', - product_match_id: 7, - candidates_json: demoTasks[1].candidates_json, - created_at: '2026-08-10 09:30:00', - }, - ], - }, -}; - -export default function App() { - const [meta, setMeta] = useState(null); - const [jobs, setJobs] = useState([]); - const [selectedJobId, setSelectedJobId] = useState(''); - const [jobDetail, setJobDetail] = useState(null); - const [timeline, setTimeline] = useState([]); - const [tasks, setTasks] = useState([]); - const [selectedTaskId, setSelectedTaskId] = useState(''); - const [roleCode, setRoleCode] = useState('PURCHASER'); - const [taskStatus, setTaskStatus] = useState('OPEN'); - const [jobBucket, setJobBucket] = useState('ALL'); - const [jobQuery, setJobQuery] = useState(''); - const [token, setToken] = useState(sessionTokenStore.get()); - const [taskAction, setTaskAction] = useState('CONFIRM_AND_CONTINUE'); - const [selectedCandidateId, setSelectedCandidateId] = useState(''); - const [saveAsAlias, setSaveAsAlias] = useState(true); - const [receiptNoticeNo, setReceiptNoticeNo] = useState(''); - const [targetRole, setTargetRole] = useState('PURCHASER'); - const [callbackKind, setCallbackKind] = useState('complete'); - const [simPayloadText, setSimPayloadText] = useState(formatNodeCallbackTemplate('ORDER_FETCH', 'complete')); - const [notice, setNotice] = useState(''); - const [busy, setBusy] = useState(false); - const [demoMode, setDemoMode] = useState(false); - const [focusedNodeCode, setFocusedNodeCode] = useState(''); - - async function loadJobs() { - if (demoMode) { - setJobs(demoJobs); - return; - } - const data = await requestJson<{ items: JobRow[] }>('/api/jobs'); - setJobs(data.items); - if (!selectedJobId && data.items[0]) { - setSelectedJobId(data.items[0].job_id); - } - } - - async function loadMeta() { - if (demoMode) { - setMeta(demoMeta); - return; - } - const data = await requestJson('/api/state-machine'); - setMeta(data); - } - - async function loadTasks() { - if (demoMode) { - const filtered = demoTasks.filter((task) => task.role_code === roleCode && task.status === taskStatus); - setTasks(filtered); - setSelectedTaskId((current) => - filtered.some((task) => task.task_id === current) ? current : '', - ); - return; - } - const data = await requestJson<{ items: TaskRow[] }>( - `/api/tasks/my?roleCode=${encodeURIComponent(roleCode)}&status=${encodeURIComponent(taskStatus)}`, - ); - setTasks(data.items); - setSelectedTaskId((current) => - data.items.some((task) => task.task_id === current) ? current : '', - ); - } - - async function loadJobDetail(jobId: string) { - if (demoMode) { - const detail = demoJobDetailMap[jobId] || demoJobDetailMap[demoJobId]; - setJobDetail(detail); - setTimeline( - detail.job.job_id === demoJobId - ? demoTimeline - : [ - { node_code: 'ORDER_FETCH', display_status: 'SUCCESS', attempt_no: 1, status: 'SUCCESS' }, - { node_code: 'OCR_EXTRACT', display_status: 'SKIPPED' }, - { node_code: 'PRODUCT_MATCH', display_status: 'RUNNING', attempt_no: 1, status: 'RUNNING' }, - { node_code: 'SOURCE_ADD', display_status: 'PENDING' }, - { node_code: 'CONTRACT_CREATE', display_status: 'PENDING' }, - { node_code: 'CONTRACT_APPROVE_WAIT', display_status: 'PENDING' }, - { node_code: 'REBATE_HEADER', display_status: 'PENDING' }, - { node_code: 'REBATE_IMPORT', display_status: 'PENDING' }, - { node_code: 'REBATE_SUBMIT', display_status: 'PENDING' }, - { node_code: 'M5_CREATE_ORDER', display_status: 'PENDING' }, - { node_code: 'M5_MANUAL_CONFIRM', display_status: 'PENDING' }, - { node_code: 'M5_RECEIPT_NOTICE', display_status: 'PENDING' }, - { node_code: 'ARCHIVE_JOB', display_status: 'PENDING' }, - ], - ); - return; - } - const detail = await requestJson(`/api/jobs/${jobId}`); - const tl = await requestJson<{ job: JobRow; timeline: TimelineNode[] }>(`/api/jobs/${jobId}/timeline`); - setJobDetail(detail); - setTimeline(tl.timeline); - } - - function applyDemoData(reason: string) { - setDemoMode(true); - setMeta(demoMeta); - setJobs(demoJobs); - setSelectedJobId(demoJobId); - setJobDetail(demoJobDetailMap[demoJobId]); - setTimeline(demoTimeline); - setTasks(demoTasks); - setSelectedTaskId(demoTasks[0]?.task_id || ''); - setNotice(reason); - } - - useEffect(() => { - void Promise.all([loadMeta(), loadJobs(), loadTasks()]).catch(() => { - applyDemoData('后端暂不可用,已切换到本地演示数据。'); - }); - }, []); - - useEffect(() => { - if (!selectedJobId) return; - void loadJobDetail(selectedJobId).catch((error) => { - if (!demoMode) { - applyDemoData('后端暂不可用,已切换到本地演示数据。'); - return; - } - setNotice(error instanceof Error ? error.message : '加载单据失败'); - }); - }, [selectedJobId, demoMode]); - - useEffect(() => { - void loadTasks().catch((error) => { - if (!demoMode) { - applyDemoData('后端暂不可用,已切换到本地演示数据。'); - return; - } - setNotice(error instanceof Error ? error.message : '加载待办失败'); - }); - }, [roleCode, taskStatus, demoMode]); - - const selectedTask = useMemo(() => tasks.find((task) => task.task_id === selectedTaskId) || null, [tasks, selectedTaskId]); - const visibleJobs = useMemo( - () => - jobs.filter( - (job) => jobStatusBucketMatches(jobBucket, job.status) && jobMatchesQuery(job, jobQuery, meta), - ), - [jobs, jobBucket, jobQuery, meta], - ); - const jobBucketCounts = useMemo(() => { - const matched = jobs.filter((job) => jobMatchesQuery(job, jobQuery, meta)); - const counts = { ALL: matched.length } as Record; - for (const bucket of jobBucketOptions) { - counts[bucket] = bucket === 'ALL' - ? matched.length - : matched.filter((job) => jobStatusBucketMatches(bucket, job.status)).length; - } - return counts; - }, [jobs, jobQuery, meta]); - const taskJobIds = useMemo(() => new Set(tasks.map((task) => task.job_id)), [tasks]); - const selectedTaskActions = useMemo( - () => (selectedTask ? allowedTaskActions(selectedTask) : []), - [selectedTask], - ); - const candidates = useMemo(() => taskCandidates(selectedTask), [selectedTask]); - const selectedCandidate = useMemo( - () => candidates.find((candidate) => candidate.goods_id === selectedCandidateId) || null, - [candidates, selectedCandidateId], - ); - const currentNodeCode = jobDetail?.job.current_node_code || ''; - const callbackEndpoint = jobDetail && currentNodeCode - ? `/api/jobs/${jobDetail.job.job_id}/nodes/${currentNodeCode}/${callbackKind}` - : ''; - const jobArtifacts = jobDetail?.artifacts || []; - const jobNodeDetails = useMemo( - () => buildNodeDetails(timeline, jobDetail?.nodes || [], jobArtifacts, jobDetail?.tasks || []), - [timeline, jobDetail?.nodes, jobArtifacts, jobDetail?.tasks], - ); - const businessOverview = useMemo( - () => - jobDetail - ? buildBusinessOverview( - jobDetail.job, - timeline, - jobDetail.tasks, - jobArtifacts, - meta, - jobDetail.nodes, - ) - : null, - [jobDetail, timeline, jobArtifacts, meta], - ); - - useEffect(() => { - if (!visibleJobs.length) return; - if (!visibleJobs.some((job) => job.job_id === selectedJobId)) { - setSelectedJobId(visibleJobs[0].job_id); - } - }, [visibleJobs, selectedJobId]); - - useEffect(() => { - const related = tasks.filter((task) => task.job_id === selectedJobId); - setSelectedTaskId((current) => { - if (related.some((task) => task.task_id === current)) return current; - return related[0]?.task_id || ''; - }); - }, [selectedJobId, tasks]); - - useEffect(() => { - setFocusedNodeCode(jobDetail?.job.current_node_code || ''); - }, [jobDetail?.job.job_id, jobDetail?.job.current_node_code]); - - useEffect(() => { - function onKey(event: KeyboardEvent) { - const target = event.target as HTMLElement | null; - const inField = Boolean( - target && - (target.tagName === 'INPUT' || - target.tagName === 'TEXTAREA' || - target.tagName === 'SELECT' || - target.isContentEditable), - ); - if (event.key === '/' && !inField) { - event.preventDefault(); - document.getElementById('job-search')?.focus(); - } - } - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, []); - - useEffect(() => { - setTaskAction((current) => - selectedTaskActions.includes(current) ? current : (selectedTaskActions[0] || ''), - ); - }, [selectedTaskActions]); - - useEffect(() => { - setSelectedCandidateId((current) => - candidates.some((candidate) => candidate.goods_id === current) - ? current - : (candidates[0]?.goods_id || ''), - ); - }, [candidates]); - - useEffect(() => { - if (!currentNodeCode) return; - setSimPayloadText(formatNodeCallbackTemplate(currentNodeCode, callbackKind)); - }, [currentNodeCode, callbackKind]); - - async function saveToken(nextToken: string) { - setToken(nextToken); - sessionTokenStore.set(nextToken); - } - - async function submitTask() { - if (!selectedTask) { - setNotice('请先选择一条待办。'); - return; - } - if (!token.trim()) { - setNotice('请先填写开发令牌。'); - return; - } - if (demoMode) { - setNotice(`演示提交:${actionLabel(taskAction)} 已记录。`); - return; - } - let payload: Record = {}; - if (taskAction === 'SELECT_CANDIDATE') { - if (!selectedCandidate || !selectedTask.product_match_id) { - setNotice('请选择一条候选货品。'); - return; - } - payload = { - productMatchId: selectedTask.product_match_id, - goodsId: selectedCandidate.goods_id, - goodsCode: selectedCandidate.goods_code, - baseUnit: selectedCandidate.base_unit, - saveAsAlias, - }; - } else if (taskAction === 'CONFIRM_AND_CONTINUE' && selectedTask.node_code === 'M5_RECEIPT_NOTICE') { - if (!receiptNoticeNo.trim()) { - setNotice('请填写收货通知单号。'); - return; - } - payload = { receiptNoticeNo: receiptNoticeNo.trim() }; - } else if (taskAction === 'TRANSFER_MANUAL') { - payload = { targetRole }; - } - setBusy(true); - try { - const result = await requestJson(`/api/tasks/${selectedTask.task_id}/submit`, { - method: 'POST', - token, - body: { - action: taskAction, - payload, - submitToken: crypto.randomUUID(), - operator: 'web', - }, - }); - setNotice(`待办已提交:${result.idempotent ? '重复提交' : '首次提交'}。`); - await Promise.all([loadJobDetail(selectedTask.job_id), loadTasks(), loadJobs()]); - } catch (error) { - setNotice(error instanceof Error ? error.message : '提交失败'); - } finally { - setBusy(false); - } - } - - function resetNodeCallbackTemplate() { - if (!currentNodeCode) return; - setSimPayloadText(formatNodeCallbackTemplate(currentNodeCode, callbackKind)); - } - - async function sendNodeCallback() { - if (!jobDetail) { - setNotice('请先选择一条单据。'); - return; - } - if (!token.trim()) { - setNotice('请先填写开发令牌。'); - return; - } - if (demoMode) { - setNotice(`演示回调:${nodeCallbackActionLabel(callbackKind)} 已记录。`); - return; - } - let payload: unknown; - try { - payload = simPayloadText.trim() ? JSON.parse(simPayloadText) : {}; - } catch { - setNotice('联调回调参数不是合法 JSON。'); - return; - } - if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { - setNotice('联调回调参数必须是 JSON 对象。'); - return; - } - const body = buildNodeCallbackRequestBody( - callbackKind, - currentNodeCode, - payload as Record, - crypto.randomUUID(), - ); - setBusy(true); - try { - const result = await requestJson(callbackEndpoint, { - method: 'POST', - token, - body, - }); - const nextNodeText = result.next_node ? `,下一节点:${result.next_node}` : ''; - const statusText = result.job_status ? `,状态:${result.job_status}` : ''; - setNotice(`联调回调成功:${nodeCallbackActionLabel(callbackKind)}${nextNodeText}${statusText}`); - await Promise.all([loadJobDetail(jobDetail.job.job_id), loadJobs(), loadTasks()]); - } catch (error) { - setNotice(error instanceof Error ? error.message : '联调回调失败'); - } finally { - setBusy(false); - } - } - - function selectJob(jobId: string) { - setSelectedJobId(jobId); - } - - function activateTask(taskId: string) { - const fromQueue = tasks.find((task) => task.task_id === taskId); - const fromJob = jobDetail?.tasks.find((task) => task.task_id === taskId); - const task = fromQueue || fromJob; - if (task?.role_code && task.role_code !== roleCode) setRoleCode(task.role_code); - if (task?.status && task.status !== taskStatus) setTaskStatus(task.status); - if (task?.job_id) setSelectedJobId(task.job_id); - setSelectedTaskId(taskId); - window.setTimeout(() => { - document.getElementById('task-section')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - }, 0); - } - - function openPanel(id: string) { - const el = document.getElementById(id); - if (el instanceof HTMLDetailsElement) el.open = true; - el?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - - function focusNode(nodeCode: string) { - setFocusedNodeCode(nodeCode); - openPanel('node-details-panel'); - window.setTimeout(() => { - document.getElementById(`node-detail-${nodeCode}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' }); - }, 50); - } - - async function copyValue(label: string, value: string) { - if (!value || value === '-') { - setNotice(`${label}尚未回填`); - return; - } - try { - await navigator.clipboard.writeText(value); - setNotice(`已复制${label}:${value}`); - } catch { - setNotice(`${label}:${value}`); - } - } - - function bucketForStatus(status: string): JobStatusBucket { - if (jobStatusBucketMatches('WAITING', status)) return 'WAITING'; - if (jobStatusBucketMatches('FAILED', status)) return 'FAILED'; - if (jobStatusBucketMatches('SUCCESS', status)) return 'SUCCESS'; - if (jobStatusBucketMatches('ACTIVE', status)) return 'ACTIVE'; - return 'ALL'; - } - - return ( -
-
-
-

状态中心

- 采购主链自动化一期 - - {demoMode ? '本地演示数据' : '后端已连接'} - -
-
-
- 查询 -
- - setJobQuery(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Escape') setJobQuery(''); - }} - placeholder="供应商、单号、来源、节点…" - aria-label="查询单据" - /> - {jobQuery ? ( - - ) : null} -
-
-
- 待办角色 - -
-
- 待办状态 -
- {taskStatusOptions.map((status) => ( - - ))} -
-
-
- 单据 -
- {jobBucketOptions.map((bucket) => ( - - ))} -
-
- -
-
- - {notice ? ( -
- {notice} - -
- ) : null} - -
- - -
-
-
-
-
单据概览
-

{businessOverview?.header.supplierName || '请选择一条单据'}

- {businessOverview ? ( -
- - - {businessOverview.header.orderDateText} · {businessOverview.header.sourceTypeText} - -
- ) : ( -
先从左侧选择一条单据。
- )} -
-
- {jobDetail ? ( - - ) : ( -
-
- )} -
当前节点:{businessOverview?.header.currentNodeText || '-'}
-
责任角色:{businessOverview?.header.currentRoleText || '-'}
-
-
- -
- - - - -
- -
- {businessOverview?.referenceChain.map((item) => ( - - )) ||
请选择一条单据。
} -
- -
- {businessOverview?.primaryTask ? ( - - ) : ( - <> -
当前待办
-
当前没有未完成待办。
- - )} -
-
- - - -
-
- -
- -
-
-
待办提交
- {selectedTask ? ( -
-
-
当前待办
-
{selectedTask.title}
-
-
-
处理原因
-
{selectedTask.reason_user_friendly || reasonLabel(selectedTask.reason_type)}
-
-
- ) : businessOverview?.primaryTask ? ( -
- 当前单据待办属于{businessOverview.primaryTask.roleText}。 - -
- ) : ( -
请先选择一条待办。
- )} - - {selectedTask ? ( -
- - - {taskAction === 'SELECT_CANDIDATE' ? ( - <> - - - {selectedCandidate ? ( -
- 货品编码:{selectedCandidate.goods_code || '-'} - 生产厂家:{selectedCandidate.manufacturer || '-'} - 批准文号:{selectedCandidate.approval_no || '-'} - 基本单位:{selectedCandidate.base_unit || '-'} -
- ) : ( -
当前待办没有候选货品。
- )} - - ) : null} - - {taskAction === 'CONFIRM_AND_CONTINUE' && selectedTask.node_code === 'M5_RECEIPT_NOTICE' ? ( - - ) : null} - - {taskAction === 'TRANSFER_MANUAL' ? ( - - ) : null} - -
- -
-
- ) : null} - -
- 当前筛选:{roleLabel(roleCode)},{taskStatusLabel(taskStatus)} -
-
-
-
- -
-
- - 证据包 - {businessOverview?.summary.evidenceCount ?? 0} - - void copyValue('证据路径', path)} /> -
- -
- - 节点详情 - {businessOverview?.summary.nodeDetailCount ?? 0} - - void copyValue('证据路径', path)} - /> -
- -
- - 验收对照 - - {businessOverview - ? `${businessOverview.summary.acceptanceCounts.DONE}/${ - businessOverview.summary.acceptanceCounts.DONE + - businessOverview.summary.acceptanceCounts.PARTIAL + - businessOverview.summary.acceptanceCounts.TODO + - businessOverview.summary.acceptanceCounts.NA - }` - : '0/0'} - - - -
- - {devActionsEnabled ? ( -
- - 联调回调 - DEV - -
-
- -
-
请求路径
-
{callbackEndpoint || '-'}
-
-
-
-