Compare commits

...

40 Commits

Author SHA1 Message Date
Yuuuu bcf5e03d07 feat: 选品凑齐后按本次拆定的 sku 加车
凑齐的新行先标成加购中并取回 fid,同一班加车。表 2 已有该 omsId 时不加第二份。加购失败不回滚表 1。补偿班只补仍是系统管理员或过期加购中的行。
2026-10-24 11:37:01 +08:00
Yuuuu 586db4be90 feat: 班次可重跑,避免对方单据重复
领取 UPDATE 按 500 个 fid 分段,同一事务,任一段失败整单回滚。选品一行一落并续租,系统故障改回待领。页面和商城调用期间归还连接。坏连接丢弃后再借一次,未提交语句只重试一次。

加购登录或查询失败放回待领。传入购物车读数且已够量则不再加车。合同按「我的合同」三态:没有则起草,唯一则复用合同号,多条保持处理中。返利送审前按合同号查已有总单,读到则补写不再新建;读号失败保持处理中。

同一次提交带上本分支尚未入库的 Oracle 连接层(rpa_oracle / rpa_browser)及各班次改接。
2026-10-24 02:41:13 +08:00
34BFF76918A9449ABAB323149DDCEA73 ac19e60eb5 feat: 返利班通用录入流程(总单、细单新增、送审)
查号后同一班补总单、按货品 ID 新增细单、送审写表 4。细单不做 Excel 导入。
承付参数总单和细单都填 25。同一合同一组一个结果。
合同货品弹窗按操作码搜,找不到则失败。
2026-10-13 17:29:43 +08:00
34BFF76918A9449ABAB323149DDCEA73 39754bef79 feat: 合同提交按弹窗结果读号,Chrome 按班次复用
提交后按成功 toast / 查看页判定,处理 LUI 确认框。
批跑共用一个浏览器;Chrome 按 flow 固定用户目录和调试端口,可附着已开实例。
2026-10-09 16:11:53 +08:00
34BFF76918A9449ABAB323149DDCEA73 bf0294a12d feat: 合同页按 MDM 搜货并按 fdId 读号
合单缺货品 MDM 直接失败;页面按 MDM 搜货,同品各占一行。
提交前读 fdId,回我的合同按文档/标题+客户编码匹配。
无平台正文时挂占位 PDF;Chrome 用户目录按适配器实例拆开。
默认登录账号改为 10007228。
2026-10-09 11:24:54 +08:00
Yuuuu 3ccda7d6c2 feat: 返利班查号走页面,录协议仍用替身
打开返利中心按合同号搜,接到 finder。班次骨架一并入库。接口4补合同成功+协议行组包用例。
2026-10-08 10:23:23 +08:00
Yuuuu 830d430a46 merge: 合同班合单写表4与接口4组包
把 feature/contract-job-interface4-envelope 并回 main。
2026-10-05 23:11:49 +08:00
Yuuuu 7ea4010bfd docs: 合同班合单与接口4组包已接 2026-10-05 23:07:53 +08:00
Yuuuu 554f8d57b8 feat: 合同班 Oracle 领取与班次入口 2026-10-05 23:05:02 +08:00
Yuuuu 58ef8f280e feat: 合同班合单录入写表4 2026-10-05 22:59:06 +08:00
Yuuuu 71fc1c3b60 feat: 合同班内存领取并写表4 2026-10-05 22:56:46 +08:00
Yuuuu e8ef1a77fc feat: 合同班按供应商合单并映射草稿 2026-10-05 22:55:03 +08:00
Yuuuu 5f5ec4874f feat: 合同页尽调闸和签订日开始日回退 2026-10-05 22:49:18 +08:00
Yuuuu 4c49f9d253 feat: 接口4只读表4未推行 2026-10-05 22:42:22 +08:00
Yuuuu 47ad02a6dc feat: 接口4从表4组装合同协议报文 2026-10-05 22:38:14 +08:00
Yuuuu a80cce5fc5 chore: ignore .worktrees
隔离 worktree 目录不进仓库。
2026-10-05 22:07:12 +08:00
Yuuuu 2e93b2ceac docs: 合同班合单写表4与接口4组包实施计划
按已锁定 spec 拆成接口4组包、尽调/日期、合单、落表、班次入口八个 TDD 任务。
2026-10-05 21:58:14 +08:00
Yuuuu 74a57e450e docs: 修合同班 spec 组包超限断言和采购员范围
组包 limit 测试改为与接口2整组放入一致;采购员/计划员本轮不填页面。
2026-10-05 21:19:39 +08:00
Yuuuu 6f0677f62b docs: 合同班合单写表4与接口4组包 spec
锁定表3领取/按supplierId合单、尽调失败、表4回写,以及接口4只组包不发HTTP。
2026-10-05 21:08:56 +08:00
Yuuuu 0fa29ec9bd feat: 加购班领表2按仓加入购物车,失败改status=2
选品成功行领取后按条形码对仓加车。成功盖章不插行;登录挂、库存、中包装只改该行失败状态。不推接口2。
2026-10-05 08:39:16 +08:00
Yuuuu abf26b4259 feat: 接口2组装加购结算报文,接口3接收采购合同
从表2取 push_status=0 拼文档报文,同 omsId 不拆批。
POST /api/oms/v1/purchase-contracts 按对方 fid 幂等落表3。
2026-10-04 23:57:17 +08:00
Yuuuu 0379f78fe2 chore: 示例与单测货主编码改为 907 2026-10-04 17:30:36 +08:00
Yuuuu 3257081523 fix: 支付落表取消查详情,细单ID自生成,按订单号判重
Excel 没有平台细单号。落库时生成 pay_order_dtl_id 给订单中心防重传,本表按 pay_order_no 跳过已落订单。
2026-10-04 17:28:52 +08:00
34BFF76918A9449ABAB323149DDCEA73 bab6954584 fix: 1号药城支付条码取首个、品名按短名匹配 2026-10-03 20:53:30 +08:00
Yuuuu f58c7a73ab fix: 选品复用仓库外 Chrome 用户目录,减少极验滑块
登录改为 persistent context,默认目录在仓库外 rpa-chrome-profile/selection。
2026-10-03 19:51:13 +08:00
Yuuuu 6fb2f5de95 fix: 1号药城支付落表不再对加购行
购物车和支付单没有强关联。导出行有细单号即写入 operate_type=2,oms_id 填 0。
2026-10-03 19:51:13 +08:00
Yuuuu 6e775566cb feat: 1号药城支付订单落表2
从买家中心导出已付订单,按条码和商家对加购成功行,写入
rpa_cart_settlement_goods_data(operate_type=2)。不推接口2。
2026-10-03 11:55:36 +08:00
34BFF76918A9449ABAB323149DDCEA73 fa4df0f27b merge: 合入远程 oms + 四作业包布局
保留本机 .env 启动、审计字段和 deal_result GBK 截断。
选品目录按远程迁到 rpa_selection。
2026-10-03 10:29:44 +08:00
Yuuuu 60c2d6d0f0 chore: 去掉 state_center 旧栈,根目录只留 oms 与四个作业包
删除 state_center、state_center_web、rpa_mock、scripts 及并行开发残留。
oms 运行时只走 Oracle,内存仓库留给单测注入。
2026-10-03 10:20:54 +08:00
34BFF76918A9449ABAB323149DDCEA73 7e6f7cc8fb feat: 本机.env启动,入站与选品落表补审计字段
- 接口1/选品 INSERT 写入创建人更新人 0/系统管理员
- 选品 deal_result 按 GBK 字节截到 VARCHAR2(200)
- Windows 从本机 .env 启动;选品空缺时复用 rpa_oms/.env
2026-10-02 22:46:22 +08:00
Yuuuu d7b3ec48e6 chore: 根目录改成 oms + 四个英文作业包
选品流程迁到 rpa_selection,资料并入 docs。合同页面脚本迁入 rpa_contract,删除 rpa_yisaiqi。加购和返利先占位。
2026-10-02 21:24:46 +08:00
Yuuuu f40822566e docs: handle leftover untracked files in layout spec 2026-10-02 18:02:21 +08:00
Yuuuu ec3ae342fc docs: distinguish tracked vs untracked files in layout spec 2026-10-02 17:58:32 +08:00
Yuuuu e4875c58e5 docs: lock repo layout for oms + four English job packages 2026-10-02 17:42:43 +08:00
Yuuuu ef9c6f09c6 feat: 选品按四级仓序跨平台拆量落表
Unify 1药城 and 药帮忙 candidates, filter at 4% margin, greedy-split by warehouse rank, and persist allocated qty. Short stock is a business fail; a peer outage with leftover shortfall stays a system fault. No cart, no interface 2.
2026-10-02 17:08:16 +08:00
Yuuuu 94215f7a7f docs: lock 选品跨平台仓序与拆量口径
Record the 2026-10-02 selection rules: 4% margin, four-tier mixed warehouses, greedy split allocation, all-or-nothing persist. No cart, no interface 2.
2026-10-02 16:59:53 +08:00
Yuuuu 5a3951362a feat: 接口1销售商品收件,按omsId幂等落表
订单中心 POST /api/oms/v1/sales-goods,校验后写入
rpa_sales_goods_mapping_data(deal_flag=0)。同 omsId 再推回 DUPLICATE,不更新。
Windows 连 Oracle 测;密码只走环境变量。
2026-10-02 10:57:21 +08:00
Yuuuu 32ad7d327f feat: add job search, bucket counts, and operator console polish
State center web can filter jobs by id/supplier/source/node, keep task
selection tied to the current job, and jump from timeline to node detail.
2026-09-11 22:03:59 +08:00
Yuuuu 29df92ee17 feat: add Fujian M5 match and source-priority fixtures
Offline confirmation for 哈药牌钙铁锌口服液 uses recorded GOODSID 4000109983 and PRIORITY=1 source rows. M5 host/SID are configured; credentials stay local.
2026-08-31 17:04:48 +08:00
Yuuuu 63eec40334 Merge branch 'feat/contract-create-ui'
YiSaiQi CONTRACT_CREATE studio project, Selenium flow, and canvas artifacts.
2026-08-31 11:02:50 +08:00
300 changed files with 39711 additions and 16888 deletions

19
.gitignore vendored
View File

@ -2,6 +2,8 @@
.DS_Store .DS_Store
.idea/ .idea/
.vscode/ .vscode/
!rpa_selection/.vscode/
!rpa_selection/.vscode/**
*.swp *.swp
# Python # Python
@ -17,11 +19,12 @@ __pycache__/
# Node # Node
node_modules/ node_modules/
state_center_web/dist/
# Local runtime / agent # Local runtime / agent
.runtime/ .runtime/
rpa_yisaiqi/screenshots/ rpa_contract/screenshots/
rpa_rebate/screenshots/
rpa_cart/exports/
rpa-chrome-profile/ rpa-chrome-profile/
**/chrome-profile/ **/chrome-profile/
.tmp/ .tmp/
@ -29,9 +32,17 @@ rpa-chrome-profile/
.grokignore .grokignore
.claude/ .claude/
.superpowers/ .superpowers/
.worktrees/
# Duplicate / scratch .env
state_center_副本/ .env.*
!.env.example
# 选品凭据与运行结果
rpa_selection/secrets.json
rpa_selection/data/.auth/
rpa_selection/output/结果/*.xlsx
rpa_selection/output/结果/*.txt
# Media (keep docs, drop recordings and extracted frames) # Media (keep docs, drop recordings and extracted frames)
*.mp4 *.mp4

192
AGENTS.md Normal file
View File

@ -0,0 +1,192 @@
# AutoRpa
洁达 RPA。当前主线是**订单中心入站**(`rpa_oms`)、**选品班次**(`rpa_selection/`)、**加购班次**(`rpa_cart/`)、**合同班次**(`rpa_contract/`)和**返利班次**(`rpa_rebate/`):收件落 Oracle,请求里不跑选品、不录合同;选品另起进程捞 `deal_flag=0`,凑齐后同一进程按本次拆定的 sku 加车;加购进程只补表 2 里仍是「系统管理员」或过期「加购中」的行;合同另起进程领表 3;返利另起进程领表 4 起草行 / 表 3 只录协议行。根上不再有 `选品流程/`、`state_center/`、`rpa_yisaiqi/`。
智力用在**定落点、想清楚对错**;速度和 token 靠下面这张地图定点打开,禁止用扫仓代替思考。改对应了,反工和消耗都会少。
## 怎么干活
1. **先定落点**(不开文件):哪个函数、成功长什么样(哪条 ACCEPT / 哪个点名用例)、这次不碰什么。方向说不清就先问,不要靠翻目录找感觉。
2. **只在落点上想透**:规则、边界、会不会拿错号/写错状态。打开文件是为了验证落点,不是再想要不要改别处。
3. **薄验收就停**:控制台 `ACCEPT SUMMARY` 或一条点名用例。过了收工。
问「法子 / 怎么优化」先答方案;用户说「改 / 重构 / 收口」再动代码。一件事一个会话,换任务 `/new`。
## 项目地图
按问题打开一行,不要 `list_dir` 根目录。
| 问题 | 打开 | 验收 |
| --- | --- | --- |
| 接口1 收件契约 | `rpa_oms/app/inbound/sales_goods.py`、`envelope.py`、`parse.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`、`inbound/audit.py` | 同上;真库见 `rpa_oms/README.md` 运维 |
| 接口2 取数组装 | `rpa_oms/app/outbound/cart_settlement.py` | `rpa_oms/tests/test_cart_settlement_outbound.py::test_assembles_unpushed_rows_as_document_envelope` |
| 接口2 Oracle 取数 | `rpa_oms/app/outbound/oracle_repo.py` | `rpa_oms/tests/test_oracle_cart_repo.py::test_list_unpushed_keeps_whole_oms_group_when_limit_is_3` |
| 接口4 取数组装 | `rpa_oms/app/outbound/contract_agreement.py` | `rpa_oms/tests/test_contract_agreement_outbound.py::test_assembles_unpushed_rows_as_document_envelope` |
| 接口4 Oracle 取数 | `rpa_oms/app/outbound/oracle_repo.py` | `rpa_oms/tests/test_oracle_agreement_repo.py::test_list_unpushed_keeps_whole_contract_no_group_when_limit_is_3` |
| 接口3 收件契约 | `rpa_oms/app/inbound/purchase_contract.py` | `rpa_oms/tests/test_purchase_contract_receive.py::点名` |
| 接口3 Oracle 落表 | `rpa_oms/app/inbound/oracle_contract_repo.py` | `rpa_oms/tests/test_oracle_contract_repo.py::点名` |
| 选品规则 / 拆量 | `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_cart/cart_job.py` `add_known_lines` | `rpa_selection/tests/test_job.py::test_success_adds_each_split_sku_without_rereading_table2` |
| 合同班合单落表 | `rpa_contract/job.py`、`group.py`、`persist.py`、`repo.py` | `rpa_contract/tests/test_job.py::test_job_merges_same_supplier_and_writes_table4` |
| 合同页面步骤 | `rpa_contract/contract_login.py`(S02–S05)/ `contract_form.py`(S06–S08、S10)/ `contract_goods.py`(S09)/ `contract_upload.py`(S11)/ `contract_submit.py`(S12)/ `contract_number.py`(S13);共用 `contract_common.py`;入口 `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_browser/adapter.py`;返利 junction/口在 `rpa_rebate/apa_browser_adapter.py` | `rpa_contract/tests/test_apa_browser_adapter.py` |
| 文字验收 / 截图策略 | `rpa_contract/accept.py` | 只读 `ACCEPT.txt`,禁止读 png |
| 加购班次 | `rpa_cart/cart_job.py`、`cart_repo.py`、`cart_match.py` | `rpa_cart/tests/test_cart_job.py::test_job_adds_split_rows_without_insert` |
| 支付回传 | `rpa_cart/job.py`、`settle.py` | 1 号药城支付按 Excel 落表,不对加购行 |
| 返利班查号落表 | `rpa_rebate/job.py`、`persist.py`、`repo.py` | `rpa_rebate/tests/test_job.py::test_job_finds_contract_and_writes_agreement` |
| 返利共用(登录/查号/勾行/弹层) | `rpa_rebate/rebate_common.py` | 四个节点共用;改这里四个一起变 |
| 返利班查号页面 | `rpa_rebate/rebate_find.py`、`rebate_finder.py` | `ACCEPT SUMMARY REBATE_FIND` 或 `rpa_rebate/tests/test_finder.py::test_find_flow_writes_accept_for_missing_number` |
| 返利总单页面 | `rpa_rebate/rebate_header.py`、`run_header.py` | `ACCEPT SUMMARY REBATE_HEADER` 或 `rpa_rebate/tests/test_header.py::test_header_flow_writes_accept_with_order_no` |
| 返利细单新增 | `rpa_rebate/rebate_import.py`、`run_import.py` | `ACCEPT SUMMARY REBATE_IMPORT` 或 `rpa_rebate/tests/test_import.py::test_import_flow_writes_accept_with_success_fail` |
| 返利送审 | `rpa_rebate/rebate_submit.py`、`run_submit.py` | `ACCEPT SUMMARY REBATE_SUBMIT` 或 `rpa_rebate/tests/test_submit.py::test_submit_flow_writes_accept_with_order_nos` |
| 四个口地图 | `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`(选品空缺时复用);商城账号放 `rpa_selection/.env`;返利中心账号放 `rpa_rebate/.env`。都已 gitignore,不要写进仓库、README、示例 JSON,不要 `git add` / commit / push。
## 接口1 落点(订单中心)
订单中心四接口,本期开放接口 1、3 收件。鉴权 header `X-Oms-Token`(`OMS_INBOUND_TOKEN`)。接口 1:`POST /api/oms/v1/sales-goods`,落表 `rpa_sales_goods_mapping_data`,`deal_flag=0`,**请求里不跑选品**。接口 3:`POST /api/oms/v1/purchase-contracts`,落表 `rpa_purchase_contract_data`,`process_status=0`,按对方 `fid` 幂等,**请求里不录合同**。接口 2 只从表 2 取 `push_status=0` 组装报文,路径仍空,不发 HTTP。
运行时只落 Oracle。入站单测用假连接录 SQL,不连库。服务端口 **8001**。连接从 `rpa_oracle` 池借,一个请求一条。
| 步 | 函数 | 要点 |
| --- | --- | --- |
| 信封 | `parse_envelope` | 缺 `data` / 空数组 / 超过 100 条:整包 422,一条都不落 |
| 整批校验 | `receive_sales_goods` | 内存 REJECTED / 同批 CONFLICT,再一次查重一次 `executemany` |
| 鉴权 | `token_ok` | `X-Oms-Token` 共享密钥,未配一律失败;失败进审计 |
| 落表 | `existing_oms_ids` + `insert_many` | `fid` 由序列触发器生成,INSERT 不绑这一列;只认 `uk_rpa_sgmd_oms_id` |
| 幂等 | 同 `omsId` | 已存在 → `DUPLICATE`,不更新、不重跑 |
| 同批冲突 | 同一 POST 里 `omsId` 再现 | 后一条 `CONFLICT`,只落第一条 |
| 字段不过 | `_parse_row` | `REJECTED`,不入库 |
| 混批 | `post_sales_goods` | 逐条有成功/重复/拒绝时 HTTP 仍 200,看 `results[].resultCode` |
| 对外文案 | `messages.py` | 401「身份认证失败」;503「数据库不可用」;body 不含 ORA |
| 审计 | `write_audit` | 一行请求一条;token 打码;写失败不把业务打成 500 |
`omsId` / `saleQty` / `planPurchaseQty` / 接口 3 的 `fid` 必须是 JSON 整数,不把 `"10"` 当 10。Windows 连真库测;Instant Client 位数与 Python 一致。
接口 2:`load_cart_settlement_envelope` 捞 `push_status=0`,同一 `omsId` 整组进一批,上限默认 500(`OMS_OUTBOUND_BATCH_LIMIT`),SQL 侧截组;金额四位小数字符串,NULL 金额写成 null,不抛整批。连接从 `rpa_oracle` 池借,`get_pool` 懒加载加锁。`mark_pushed` 已备,不 POST 订单中心。
接口 3:`receive_purchase_contracts` 按 `fid` 幂等;同批 `fid` 再现 → `CONFLICT`;`contractEntryStatus=0` 必须带 `contractNo`;结束日不得早于开始日。`INSERT` 绑 `fid`。形状与接口 1 相同:内存整批 → `existing_fids` → `insert_many`。
接口 4:`load_contract_agreement_envelope` 捞 `push_status=0`,同一非空 `contractNo` 整组进一批,上限同样默认 500,SQL 侧截组。空 `contractNo` 按行独立。`mark_pushed` 已备,不 POST。
验收(假连接、不连库):`cd rpa_oms` → `uv run pytest tests/test_sales_goods_receive.py -q`。真库:`GET /health` 后把 `examples/sales_goods_batch.json` POST 两次(第一次两条 `ACCEPTED`,第二次两条 `DUPLICATE`)。接口 3 点名 `tests/test_purchase_contract_receive.py`;真库 POST `examples/purchase_contract_batch.json` 两次。接口 2 点名 `tests/test_cart_settlement_outbound.py::test_assembles_unpushed_rows_as_document_envelope`。
修接口1不要顺手改 `rpa_selection/`、`rpa_cart/`、`rpa_contract/`、`rpa_rebate/`。接口 2、4 只组装不推送。
## 选品落点
班次作业在 `rpa_selection/`,不在接口 1 请求里跑。从表 1 领 `deal_flag=0`,按**条形码**搜 1 号药城 / 药帮忙,两平台合并后按四级仓序贪心拆量,写入表 2。凑齐在内存里定,才落成功行;凑不齐不落部分成功。凑齐的新行同一班按这次拆定的 sku 加车,不再去表 2 重捞来判断齐没齐。表 2 已有该 `omsId` 时不加第二份。
口径:`docs/superpowers/specs/2026-10-02-selection-split-allocation-design.md`。
| 步 | 函数 | 要点 |
| --- | --- | --- |
| 领取 | `claim_pending` | `deal_flag` 0→3;`FOR UPDATE SKIP LOCKED`;中途崩溃停在 3,超过租约(`deal_time` 30 分钟)可再领 |
| 规则 | `allocate` / `run_selection` | 自营仓全称匹配、毛利严格大于 4%、效期 ≥ 12 月;同仓多个 sku 都参与拆量,低价先吃;按仓序拆量 |
| 落表 | `build_result` + `save_result` | 成功每条拆量一行 `status=1`,合计=计划量;`push_status=0`。要马上加车的新行更新人写成「加购中」,并 RETURNING 回 fid |
| 库存不足 | `KIND_BUSINESS_FAIL` | 两边都搜到但合计不够:`deal_flag=2`,该 `omsId` 不重跑 |
| 对端故障 | `KIND_SYSTEM_FAIL` | 一边故障且剩下的凑不齐:可把 `deal_flag` 改回 0;禁止写成库存不足 |
湖南壹号、甘肃壹号云不在自营集合。凑齐后按本次 sku 加车,不推接口 2。加购失败不回滚表 1 的选品成功。登录或查询失败把表 2 戳放回「系统管理员」。同单有成功又有失败时,失败原因带「未加齐」。
验收:`cd rpa_selection` → `uv run pytest tests/test_pipeline.py::test_split_across_malls_is_success -q`(杭州 3 + 广东 7 = 计划 10)。
## 加购落点
补偿班在 `rpa_cart/`。正常凑齐的加车发生在选品班里。本班领表 2 `operate_type=1 AND status=1 AND push_status=0`,戳为 `系统管理员` 或过期的 `加购中`。这些行没有 sku,登录 1 号药城 / 药帮忙后按条形码再搜,对上 `supplier_name`,按行加车。
| 步 | 函数 | 要点 |
| --- | --- | --- |
| 领取 | `claim_pending` | 盖 `加购中`;`FOR UPDATE SKIP LOCKED`;中途崩溃停在这戳,超过租约(`update_time` 30 分钟)可再领 |
| 对仓 | `match_offer` | 条形码结果对商家全称;同仓留低价 |
| 加车 | `run_cart_batch` + 平台 `add_*_cart` | 改数量加入购物车。无 `sku_id`,必须再搜 |
| 成功 | `mark_success` | 不插行,`update_user_name=加购班`,`status` 仍 1 |
| 失败 | `mark_fail` | 该行 `status=2` + 原因。登录挂写 `LOGIN_FAILED`;库存/中包装/对不上仓写业务原因 |
同一 `omsId` 拆两仓要加齐;一行失败只改那一行。同组里有成功又有失败时,失败行原因带「未加齐」,不回退已成功的行。不推接口 2,不清空购物车,不碰合同、表 1。接口 2 仍捞全部 `push_status=0`,加购班跑完之前不要组装。
验收:`cd rpa_cart` → `uv run pytest tests/test_cart_job.py::test_job_adds_split_rows_without_insert -q`(杭州 3 + 广东 7,不插新行)。
## CONTRACT_CREATE 落点
班次作业在 `rpa_contract/`,不在接口 3 请求里跑。领表 3 `process_status=0`(跳过协议重录行),按 `supplierId` + `org_code` + 签订日/开始日/结束日合单,任一不同则各自成组,调页面流,写表 4。`run_contract_create_flow` 串页面;修一步只打开对应文件。`contract_flow_steps.py` 仍是对外入口。
| 步 | 打开 | 要点 |
| --- | --- | --- |
| S02 打开 | `contract_login.py` `open_contract_system` | `http://contract-test.gzmpc.com:8080/` |
| S03 登录 | `contract_login.py` `login_contract_system` | 账密只认 `CONTRACT_USERNAME` / `CONTRACT_PASSWORD` |
| S04–S05 起草 | `contract_login.py` `enter_contract_create`、`select_contract_template` | 直达 `FORM_PAGE_URL`,不要点 SPA 菜单 |
| S06 签约方 | `contract_form.py` `fill_contract_signatory_info` | 客商 `DW0100046361` / `重庆亿昊药业有限公司` |
| 尽调 | `contract_form.py` `check_customer_diligence` | 正文含「未尽调」则失败,不提交 |
| S07 基础 | `contract_form.py` `fill_contract_basic_info` | 标题 `_expected_title` |
| S08 金额日期 | `contract_form.py` `fill_contract_amount_and_dates` | 签订日/开始日用接口 3 三日期,缺则回退 `order_date` |
| S09 货品 | `contract_goods.py` `fill_contract_goods_lines` | 弹窗按操作码搜,找不到则本单失败 |
| S10 付款 | `contract_form.py` `fill_contract_payment_info` | |
| S11 PDF | `contract_upload.py` `upload_contract_document_if_needed` | 仅 YYC 传合并 PDF |
| S12 提交 | `contract_submit.py` `submit_contract_for_approval` | 成功后**不要等详情页** |
| S13 合同号 | `contract_number.py` `read_contract_number` | 回「我的合同」,`fdId` > 标题+客户编码;禁止盲取最新一条 |
成功路径默认不截图(`RPA_SCREENSHOT=always` 才全截)。页面等待用 `wait_for_text` / `wait_for_element`,不要固定长睡。
## 返利班落点
班次作业在 `rpa_rebate/`,不在接口 3 请求里跑。领表 4 `action_code=1 AND contract_status=1`(合同班起草、尚未回成功),以及表 3 `contract_entry_status=0` 且已有 `contractNo` 的只录协议行。按合同号查返利中心;搜到后同一班走总单 → 细单新增 → 送审,送审成功才插 `actionCode=2`、`agreementStatus=1` 并带上号。搜不到不重录合同,留给下一班。接口 4 组包在 oms,path 空,不发 HTTP。同一合同一组一个结果。
| 步 | 函数 | 要点 |
| --- | --- | --- |
| 领取 | `claim_pending` | 表 4 `process_status` 0→3,同一合同号整组;表 3 `process_status` 0→3;`FOR UPDATE SKIP LOCKED`;中途崩溃停在 3,超过租约(表 4 `update_time` / 表 3 `process_time` 30 分钟)可再领 |
| 查号 | `RebateFinder` / `search_contract` | 打开 `REBATE_URL` → 登录 → 采购协议 → 按合同号搜;表里出现该号才 `True`,输入框回显不算 |
| 录协议 | `recorder` | 搜到后总单 → 细单新增(按货品ID,不做 Excel)→ 送审;送审成功才插 `actionCode=2` |
| 落表 | `build_found` + `save_group` | 送审成功:起草行 `contract_status=2`、`process_status=1`,每 `oms_id` 插 `action_code=2`、`agreement_status=1`,带总单/细单号。失败:起草行 `contract_status` 保持 1、`process_status` 改回 0,写 `agreement_fail_reason`,下一班还能领。`push_status=0`。人名固定 `系统管理员` |
| 搜不到 | `build_not_found` | 表 4 `process_status` 改回 `0`;表 3 改回 `0`;不插协议行 |
验收:`cd rpa_rebate` → `uv run pytest tests/test_job.py::test_job_finds_contract_and_writes_agreement -q`。页面:`uv run python run_find.py`,只读 `ACCEPT SUMMARY REBATE_FIND` 或 `rpa_rebate/screenshots/REBATE_FIND/ACCEPT.txt`。总单:`uv run python run_header.py`,只读 `ACCEPT SUMMARY REBATE_HEADER`。细单新增:`uv run python run_import.py`,只读 `ACCEPT SUMMARY REBATE_IMPORT`。送审:`uv run python run_submit.py`,只读 `ACCEPT SUMMARY REBATE_SUBMIT`。接口 4:`cd rpa_oms` → `uv run pytest tests/test_contract_agreement_outbound.py::test_assembles_unpushed_rows_as_document_envelope -q`。现行字段与步骤见 `rpa_rebate/现行模式.md`、`rpa_rebate/返利流程.md`。
不推接口 4,不改 `push_status`,不碰选品 / 加购 / 合同页面。货源细单ID 本期不填(弹层按联系人苏文辉过滤)。计划员现阶段廖建琦。
## 返利中心及之后
查号页面已接 `REBATE_FIND`。总单页面已接 `REBATE_HEADER`。细单新增已接 `REBATE_IMPORT`(节点名仍叫 IMPORT,页面走新增,不做 Excel)。送审已接 `REBATE_SUBMIT`。班次串查号 → 总单 → 细单新增 → 送审,送审成功才写 `actionCode=2`。三把钥匙:`contractSystemNo` → `rebateOrderNo` → `receiptNoticeNo`。开页面时按节点打开 `rebate_find.py` / `rebate_header.py` / `rebate_import.py` / `rebate_submit.py`,共用登录/查号/勾行/弹层打开 `rebate_common.py`。`rebate_flow_steps.py` 仍是对外入口。验收只看 ACCEPT / 点名用例。
| 节点 | 谁做 | 要回传 | 页面要做什么 |
| --- | --- | --- | --- |
| `REBATE_FIND` | RPA | 搜到 / 搜不到 | 已拆:登录 → 采购协议 → 按合同号搜 |
| `REBATE_HEADER` | RPA | `rebateOrderNo` | 已拆:勾已有总单,按 `返利流程.md` 填现行字段 → 保存拿协议总单ID |
| `REBATE_IMPORT` | RPA | 货品表行数 | 已拆:勾总单 → 货品「新增」→ 货品编码三个点按 `goodsId` 搜 → 卡片确认落到表 → 货品工具栏保存。细单备注=订单号,特定补偿=0,承付参数=25。不做 Excel |
| `REBATE_SUBMIT` | RPA | `submitted` + 总单/细单号 | 已拆:点送审;马上能见号则带回 |
| `M5_CREATE_ORDER` | RPA 桌面端 | `m5OrderNo` | 录单后**停、不保存**。`M5_QC_MISSING` 必须识别准(直接转人工、不重试) |
| `M5_MANUAL_CONFIRM` | 采购 | — | 人在 M5 里保存签订 |
| `M5_RECEIPT_NOTICE` | 采购 | `receiptNoticeNo` | 人填收货通知单号,全链终点钥匙 |
不要连续轮询审批;返利段开始前大约 30 分钟查一次。
## 验收
- 接口1:点名 `rpa_oms/tests/test_sales_goods_receive.py` 或 `test_oracle_repo.py`;禁止为「显得全面」跑整包再去连真库。
- 接口2:点名 `rpa_oms/tests/test_cart_settlement_outbound.py::test_assembles_unpushed_rows_as_document_envelope`;SQL 截组 `tests/test_oracle_cart_repo.py::test_list_unpushed_keeps_whole_oms_group_when_limit_is_3`。
- 接口3:点名 `rpa_oms/tests/test_purchase_contract_receive.py` 或 `test_oracle_contract_repo.py`。
- 选品:点名 `rpa_selection/tests/test_pipeline.py::test_split_across_malls_is_success` 或 `test_rules.py::test_margin_exactly_4_percent_passes`。凑齐后加车点名 `rpa_selection/tests/test_job.py::test_success_adds_each_split_sku_without_rereading_table2`。
- 加购:点名 `rpa_cart/tests/test_cart_job.py::test_job_adds_split_rows_without_insert` 或 `test_job_marks_out_of_stock`。选品交来的 sku 点名 `test_known_lines_keep_each_sku_and_flag_incomplete`。
- 合同班:点名 `rpa_contract/tests/test_job.py::test_job_merges_same_supplier_and_writes_table4` 或 `test_job_marks_diligence_fail`。
- 返利班:点名 `rpa_rebate/tests/test_job.py::test_job_finds_contract_and_writes_agreement` 或 `test_job_leaves_not_found_for_next_shift`。
- 返利查号页:只读控制台 `ACCEPT SUMMARY REBATE_FIND` 或 `rpa_rebate/screenshots/REBATE_FIND/ACCEPT.txt`。
- 返利总单页:只读控制台 `ACCEPT SUMMARY REBATE_HEADER` 或 `rpa_rebate/screenshots/REBATE_HEADER/ACCEPT.txt`;点名 `rpa_rebate/tests/test_header.py::test_header_flow_writes_accept_with_order_no`。
- 返利细单新增:只读控制台 `ACCEPT SUMMARY REBATE_IMPORT` 或 `rpa_rebate/screenshots/REBATE_IMPORT/ACCEPT.txt`;点名 `rpa_rebate/tests/test_import.py::test_import_flow_writes_accept_with_success_fail`。
- 返利送审:只读控制台 `ACCEPT SUMMARY REBATE_SUBMIT` 或 `rpa_rebate/screenshots/REBATE_SUBMIT/ACCEPT.txt`;点名 `rpa_rebate/tests/test_submit.py::test_submit_flow_writes_accept_with_order_nos`。
- 接口4:点名 `rpa_oms/tests/test_contract_agreement_outbound.py::test_assembles_unpushed_rows_as_document_envelope`;SQL 截组 `tests/test_oracle_agreement_repo.py::test_list_unpushed_keeps_whole_contract_no_group_when_limit_is_3`。
- 合同页:只读控制台 `ACCEPT` 或 `rpa_contract/screenshots/` 下 `ACCEPT.txt`;步骤门闸点名 `rpa_contract/tests/test_flow_gates.py`。
- 单测只跑点名用例。禁止跑整份带页面等待的套件当回归。
## 不要
- 不要扫根目录,不要通读 `docs/`。需要背景就点名打开一节。
- 不要把 Oracle 密码写进仓库、README、示例 JSON。
- 不要在接口1 请求路径里跑选品。
- 不要在接口3 请求路径里录合同。
- 不要为「显得全面」多开文件、重跑、看图。落点外的改动会制造反工。

View File

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

60
README.md Normal file
View File

@ -0,0 +1,60 @@
# AutoRpa
福建洁达采购 RPA。仓库里叠过两套产品,以本文「现行」为准。
## 现行工作
订单中心四接口 + 四条独立作业。对码在订单中心做完,本方收件、选品、录合同/返利、回写结果。
| 口 | 方向 | 底表 | 状态 |
| --- | --- | --- | --- |
| 1 | 订单中心 → 本方 | `rpa_sales_goods_mapping_data` | `POST /api/oms/v1/sales-goods` |
| 2 | 本方 → 订单中心 | `rpa_cart_settlement_goods_data` | 取 `push_status=0` 组装报文;路径仍空,不发 HTTP |
| 3 | 订单中心 → 本方 | `rpa_purchase_contract_data` | `POST /api/oms/v1/purchase-contracts` |
| 4 | 本方 → 订单中心 | `rpa_contract_agreement_result` | 取 `push_status=0` 组装报文;路径仍空,不发 HTTP |
作业互不串联:选品读表 1,合同/返利读表 3。HTTP 收件不同步跑 RPA。
## 代码入口
| 目录 | 身份 |
| --- | --- |
| `rpa_oms/` | 四个 HTTP 口,一个 FastAPI。接口 1、3 收件;接口 2、4 组装报文 |
| `rpa_selection/` | 选品班。读表 1 `deal_flag=0`,双平台选品,写表 2 |
| `rpa_cart/` | 加购已接;1 号药城支付落表。表 2 → 口 2 仍未推 |
| `rpa_contract/` | 合同班。表 3 合单录入 → 表 4;接口 4 组包在 oms,path 空 |
| `rpa_rebate/` | 返利班。表 4 查号 / 表 3 只录协议 → 表 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` | 表 4 起草行 / 表 3 只录协议行 | 表 4 `contractStatus=2`、`actionCode=2` | 口 4(组包在 oms,path 空) |
合同怎么录、合单和回写口径见 `docs/合同系统录入流程.md`。页面控件以 `rpa_contract/contract_flow_steps.py` 为准。
新接口只进 `rpa_oms/`。起服务见 [`rpa_oms/README.md`](rpa_oms/README.md)。
## 本期未做
| 项 | 口径 |
| --- | --- |
| 返利货源细单ID | 弹层按总单联系人(现苏文辉)过滤;本期不填,见 `rpa_rebate/返利流程.md` |
| 返利计划员 | 现阶段「廖建琦」,后期改回表 3 `planner_name` |
| 口 2 / 口 4 HTTP | path 空,只组包不发 |
| 药帮忙支付 | 只做了 1 号药城 |
| M5 | 录单停、不保存;收货通知单号人填 |
大批量现场还缺调度、崩溃扫戳、清车、合同生产地址,见 [`docs/大批量跑缺口.md`](docs/大批量跑缺口.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 日的四流程稿也是归档。作业语义以口径为准。

BIN
docs/API鉴权.doc Normal file

Binary file not shown.

39
docs/README.md Normal file
View File

@ -0,0 +1,39 @@
# 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` |
| `../rpa_rebate/返利流程.md` | 返利页面现行(总单/细单/送审)。与映射表冲突听这份 |
| `../rpa_rebate/现行模式.md` | 返利班相对旧稿改了什么、下一会话未做 |
| `大批量跑缺口.md` | 四条班次相对大批量现场还缺什么(口 2/4 HTTP、调度、崩溃戳、支付/M5) |
| `写死测试配置整改清单.md` | 上正式前要拆的测试站、测试账号和干跑回退 |
接口字段、示例报文仍以 Excel 为准(当前 `Downloads/RPA与订单中心接口设计_20260922.xlsx`)。Excel 与口径冲突时,作业语义听口径。
`ddl 2/`、`ddl.zip` 与 `ddl/` 重复,不要用。
## 作业资产
| 文件 | 用途 |
| --- | --- |
| `合同返利系统字段映射_20260911.xlsx` | 返利旧默认值;现行字段以 `rpa_rebate/返利流程.md` 为准 |
| `采购平台选品条件优先项_20260902.txt` | 选品规则原文 |
| `【项目方案】福建洁达采购合同RPA自动化录入需求_v20260924.docx` | 页面操作参考 |
## 历史(一期 13 节点主链)
见 `archive/`:`PLAN.md`、一期需求/设计/开发规划/验收规范、`一期开发任务清单_表结构_API草案.md`、会议整理版、业务思路 docx、界面截图记录。
那套模型是「一单合同走状态机」。不要用它解释接口 1 的收件箱。
`superpowers/` 是 8–9 月设计稿,同样视为归档。

View File

@ -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 的鉴权
- 只凑到一部分数量时,多平台报文怎么组
- 返利测试环境确认:点送审后协议号是否立即可见
- 加购业务失败不重推:口头跟对方确认一句
开发以口径文档为准。本文只记为什么这么定。

View File

@ -0,0 +1,174 @@
# 洁达 RPA × 订单中心接口——开发口径
对照开发,避免跑偏。口径锁于 2026-09-29~30。接口路径暂时空着,本阶段用造数。
依据:
- `Downloads/RPA与订单中心接口设计_20260922.xlsx`(以当时最新一份为准)
- `docs/API鉴权.doc`
- `docs/合同返利系统字段映射_20260911.xlsx`(返利旧默认值,对照用)
- `rpa_rebate/返利流程.md`(返利页面现行;与映射表冲突听这份)
- `rpa_selection/` 既有规则
---
## 1. 总原则
- 四条作业独立:选品、加购/支付回传、合同、返利。不要一个任务从加购挂到付款、录完协议。
- 对码在订单中心做完。接口 1 进来时已有 `goodsId`,本方不再做对码。
- 订单中心推接口 1、3;本方推接口 2、4。表是落盘,不是接口本身。
- 给什么填什么。领导批不批、驳回,RPA 不盯。驳回后要再录,等他们重新推数。
- 这期只做账期客户。`customerType` 固定按 `1`。预付不做。
- 出问题、接口没写清的数,打回订单中心。不要猜着填。
---
## 2. 四张表和四个口
| 口 | 方向 | 底表 | 用途 |
| --- | --- | --- | --- |
| 1 | 订单中心 → RPA | 销售商品对码数据 | 选品收件箱 |
| 2 | RPA → 订单中心 | 加购及结算商品数据 | 加购结果、支付单 |
| 3 | 订单中心 → RPA | 采购合同数据 | 合同 + 返利源数据(行级) |
| 4 | RPA → 订单中心 | 合同协议录入结果 | 合同结果、协议结果 |
- 表 1:接口收下才有数,不是手填。选品只读待处理行。
- 幂等:接口 1 按 `omsId`,已存在回 `DUPLICATE`,不更新、不重跑。接口 3 按对方 `fid`。
- 平台:`48` 药帮忙,`66` 1 药城。
- MDM:`goodsMdmCode`、`supplierMdmCode`(及选品侧货品/客户 MDM)按必填收、必填存。
- 货主 `orgCode` 原样落表、回写带上。
---
## 3. 选品
### 3.1 入口
从表 1 捞 `deal_flag=0`,一条条处理。一张 `omsId` 一个品种。
商城查询键:**条形码**。批准文号照收,不拿去搜。
规则沿用 `rpa_selection/`(自营仓、毛利 ≥ 4%、效期 ≥ 12 个月;两平台合并后按仓序拆量,凑齐计划量才算成功)。毛利用 `salePrice`,拆量目标用 `planPurchaseQty`。详见 `docs/superpowers/specs/2026-10-02-selection-split-allocation-design.md`。
### 3.2 加购结果(接口 2,`operateType=1`)
`status` 只给加购用。
| 现场 | 表 1 | 表 2 | 回订单中心 |
| --- | --- | --- | --- |
| 规则过了,已加购 | 处理成功 | `status=1`,带平台/供应商/价/量 | 推送 |
| 登录挂、页面报错 | 处理失败 | `status=2`,原因写系统故障 | 推送;对方可重推再跑 |
| 规则不过,故意不买 | 处理失败 | `status=2`,原因如「无符合规则商品,毛利不足 4%」或「库存不足」 | 推送;**此 `omsId` 不重推、不重跑选品** |
不再要第三种状态码。失败原因必须能区分「业务不买」和「系统故障」。
### 3.3 一单多平台
入站一行一个药。数量要两个平台才凑得齐时:
- 表 2 多行,同一个 `omsId`
- `multi_platform_flag=1`
- **同一次报文**推回,禁止先回一个平台再补另一个
- 这组平台都判定完之前不要推
凑不齐计划量:不落部分成功行,整单 `status=2`,原因「库存不足」。一边平台故障且另一边单独凑不齐:系统故障,可重跑。
### 3.4 支付(接口 2,`operateType=2`)
采购员在商城自己付。选品任务加购回传后结束,不要挂着等付款。
另排班(对准中午 12 点、下午 5 点付完后,例如 12:30、17:30)导出已支付订单:
- 付了:推 `operateType=2`,带 `payOrderNo`、`payOrderDtlId`。有单号就是成功。`status` 填空。
- 没付:**不推** 这一条。不是支付失败。
- 没有「支付失败」回写。
- 数量单价以商城实付为准,不要把加购行原样再推一遍。
### 3.5 造数阶段
失败行的单价/数量/总价接口仍标必填且数量大于 0:先填能过校验的数。`purchaseTotal` 舍入没写:先按单价×数量四位小数。对不上再打回。
---
## 4. 合同
### 4.1 班次
一天两次(中午、下午)。任务启动瞬间冻结本班未处理名单,跑的过程中新进行留给下一班。
### 4.2 合单
按 `supplierId` 合成一份合同。不用 `omsId` 合,不用供应商名称。
- 头字段(账期、签订日、起止日、采购员、计划员)取该班该供应商**第一行**。
- 下午再推来的,哪怕同一供应商,**另开一份**。不追加、不改上午已录合同。
- `contractEntryStatus=0` 且带 `contractNo`:只录协议,不进「按供应商合」,不重开合同。
### 4.3 录入
这期一律按账期客户填(不勾预付款,付款期限用 `paymentTerm`)。尽调仍看合同系统页面;未尽调本单失败,原因「客户未尽调」,不录。
点提交(不要拿暂存号去录返利)。提交成功:
- 接口 4 `actionCode=1`,`contractStatus=1` 起草,带上合同号
- **合同任务到此结束。不要重录合同。**
同一份合同下多个 `fid`:回写同一套合同号和状态。
### 4.4 合同「成功」
接口成功定义是:合同号能在返利中心搜到。不是提交成功。
查号挂在**返利班**上,不单开查状态任务。搜到再回 `contractStatus=2`。搜不到:不重录,留给下一班再搜。
---
## 5. 返利
### 5.1 班次
与合同同一天两班,建议合同班跑完再跑。不做「合同审完订单中心再推返利包」。二次推只处理协议失败重录(带合同号)。
### 5.2 查号 + 录入
拿已有合同号、尚未回合同成功的单,以及对方推来只录协议的单,打开返利中心按合同号搜:
- 搜到:先回合同成功,再录协议
- 搜不到:本班不录协议、不重开合同,下一班再搜
总单/细单现行字段以 `rpa_rebate/返利流程.md` 为准:计划员现阶段「廖建琦」(后期改回订单中心),联系人「苏文辉」,结算「电汇」,承付「下月 N 号结」参数 `25`,协议开始/结束当天。细单走页面新增(按 `goods_id` 搜),不做 Excel。货源细单ID 本期不填。不要用方案 5.5 的「登录人 / 账期 / 当年最后一天」。映射表作对照,冲突听返利流程。
### 5.3 协议「成功」
现场据说无审批流,点送审即成功。点完回 `actionCode=2`、`agreementStatus=1`,带上当时的协议总单/细单号。
测试环境先点一次确认:送审后号是否马上能看见。以后若打开审批流,改成送审后下一班再查,不能再点完就回成功。
---
## 6. 联调与造数
本阶段路径空着,造数跑通落表和作业。
鉴权(调订单中心时):
- Token:`POST https://gzmpc-paas-restcloud-test.gzmpc.com/gw/rest/core/auth/token`
- 请求头:`identitytoken`
- 测试用户名:`rpa`(密码放本机环境变量,禁止写入仓库)
- 接口 2、4 真实 path 仍缺(文档 `gw/xxxx/xxxx`)
- 示例里的 `orgSecret` 是否要带,未写,真联调再问
- 订单中心调本方 1、3 的鉴权由本方定,不在这份鉴权文档里
---
## 7. 开发时不要做的
- 选品按批准文号搜商城
- 加购成功后顺手推支付
- 规则不过报 `status=1`,或报失败却让对方当故障重跑
- 多平台加购拆成两次推送
- 按 `omsId` 或「同供应商同日」另写合单
- 同一班两套账期时整组停掉(已定为取第一行照录)
- 合同搜不到号就再录一遍
- 用方案 5.5 默认值填返利总单
- 把密码、token 写进 Git

View File

@ -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 节提供接口说明书,并确认客户类型、账期天数、计划员对照、货源细单号、对码回写、尽调来源。未齐备前,合同与返利不得按缺省猜测值提交。
上述事项确认后,按《福建洁达四流程技术实现说明》实施。实施顺序为:先完成订单中心取数入库与状态中心可见,再接入选品、合同与返利。

View File

@ -0,0 +1,437 @@
-- 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),
process_status NUMBER(1,0) DEFAULT 0 NOT NULL,
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 '货主编码。';
COMMENT ON COLUMN rpa_contract_agreement_result.process_status IS '返利处理状态。0-待查号,1-返利已回写,2-合同失败不进返利,3-查号中。不进接口4。';
-- ---------------------------------------------------------------------------
-- 入站审计(一行请求一条;写失败不影响业务 HTTP)
-- ---------------------------------------------------------------------------
CREATE TABLE rpa_oms_inbound_audit (
fid NUMBER(19) NOT NULL,
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
api_no NUMBER(1) NOT NULL,
http_code NUMBER(3) NOT NULL,
request_json CLOB,
response_json CLOB,
error_text VARCHAR2(4000),
token_masked VARCHAR2(8),
CONSTRAINT pk_rpa_oms_in_aud PRIMARY KEY (fid)
);
CREATE SEQUENCE seq_rpa_oms_in_aud
START WITH 1
INCREMENT BY 1
NOMAXVALUE
NOCYCLE
NOCACHE;
CREATE OR REPLACE TRIGGER trg_rpa_oms_in_aud_bi
BEFORE INSERT ON rpa_oms_inbound_audit
FOR EACH ROW
WHEN (new.fid IS NULL)
BEGIN
SELECT seq_rpa_oms_in_aud.NEXTVAL INTO :NEW.fid FROM dual;
END;
/
COMMENT ON TABLE rpa_oms_inbound_audit IS '订单中心入站审计。一行请求一条;token 只存打码。';
COMMENT ON COLUMN rpa_oms_inbound_audit.fid IS '本方序列主键。';
COMMENT ON COLUMN rpa_oms_inbound_audit.created_at IS '请求处理时间。';
COMMENT ON COLUMN rpa_oms_inbound_audit.api_no IS '口。1-销售商品,3-采购合同。';
COMMENT ON COLUMN rpa_oms_inbound_audit.http_code IS '对外 HTTP 状态码。';
COMMENT ON COLUMN rpa_oms_inbound_audit.request_json IS '入参 JSON。不含 token 明文。';
COMMENT ON COLUMN rpa_oms_inbound_audit.response_json IS '出参 JSON。';
COMMENT ON COLUMN rpa_oms_inbound_audit.error_text IS '内部错误原文,含 ORA。对外 503 不用这一列。';
COMMENT ON COLUMN rpa_oms_inbound_audit.token_masked IS '有 token 则为 ***。';
-- ---------------------------------------------------------------------------
-- 选品查询失败审计(一次查询失败一条;写失败不影响班次落表)
-- ---------------------------------------------------------------------------
CREATE TABLE rpa_selection_query_audit (
fid NUMBER(19) NOT NULL,
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
oms_id NUMBER(20) NOT NULL,
mapping_fid NUMBER(19) NOT NULL,
mall_name VARCHAR2(64) NOT NULL,
bar_code VARCHAR2(40),
error_text VARCHAR2(4000),
CONSTRAINT pk_rpa_sel_qry_aud PRIMARY KEY (fid)
);
CREATE SEQUENCE seq_rpa_sel_qry_aud
START WITH 1
INCREMENT BY 1
NOMAXVALUE
NOCYCLE
NOCACHE;
CREATE OR REPLACE TRIGGER trg_rpa_sel_qry_aud_bi
BEFORE INSERT ON rpa_selection_query_audit
FOR EACH ROW
WHEN (new.fid IS NULL)
BEGIN
SELECT seq_rpa_sel_qry_aud.NEXTVAL INTO :NEW.fid FROM dual;
END;
/
COMMENT ON TABLE rpa_selection_query_audit IS '选品查询失败审计。一次平台查询失败一条;不含业务短码。';
COMMENT ON COLUMN rpa_selection_query_audit.fid IS '本方序列主键。';
COMMENT ON COLUMN rpa_selection_query_audit.created_at IS '写入时间。';
COMMENT ON COLUMN rpa_selection_query_audit.oms_id IS '订单中心序号。';
COMMENT ON COLUMN rpa_selection_query_audit.mapping_fid IS '表 1 rpa_sales_goods_mapping_data.fid。';
COMMENT ON COLUMN rpa_selection_query_audit.mall_name IS '平台名。';
COMMENT ON COLUMN rpa_selection_query_audit.bar_code IS '查询用的条形码。';
COMMENT ON COLUMN rpa_selection_query_audit.error_text IS '异常原文,含 ORA。超长截断。不进 deal_result。';
-- ---------------------------------------------------------------------------
-- 加购 / 合同 / 返利未捕获异常审计(一条故障一条;写失败不影响班次落表)
-- ---------------------------------------------------------------------------
CREATE TABLE rpa_shift_error_audit (
fid NUMBER(19) NOT NULL,
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
shift_name VARCHAR2(32) NOT NULL,
biz_key VARCHAR2(128),
error_code VARCHAR2(64) NOT NULL,
error_text VARCHAR2(4000),
CONSTRAINT pk_rpa_shift_err_aud PRIMARY KEY (fid)
);
CREATE SEQUENCE seq_rpa_shift_err_aud
START WITH 1
INCREMENT BY 1
NOMAXVALUE
NOCYCLE
NOCACHE;
CREATE OR REPLACE TRIGGER trg_rpa_shift_err_aud_bi
BEFORE INSERT ON rpa_shift_error_audit
FOR EACH ROW
WHEN (new.fid IS NULL)
BEGIN
SELECT seq_rpa_shift_err_aud.NEXTVAL INTO :NEW.fid FROM dual;
END;
/
COMMENT ON TABLE rpa_shift_error_audit IS '加购、合同、返利未捕获异常审计。业务列仍写短码。';
COMMENT ON COLUMN rpa_shift_error_audit.fid IS '本方序列主键。';
COMMENT ON COLUMN rpa_shift_error_audit.created_at IS '写入时间。';
COMMENT ON COLUMN rpa_shift_error_audit.shift_name IS '班次。加购、合同或返利。';
COMMENT ON COLUMN rpa_shift_error_audit.biz_key IS '加购行 fid、合同供应商或返利合同号。';
COMMENT ON COLUMN rpa_shift_error_audit.error_code IS '业务列上的短码,如 QUERY_FAILED、PAGE_FAILED。';
COMMENT ON COLUMN rpa_shift_error_audit.error_text IS '异常原文,含 ORA。超长截断。不进失败原因列。';

170
docs/ddl/列对照.md Normal file
View File

@ -0,0 +1,170 @@
# 四张暂存表 ↔ 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) | 同 | 无 |
| — | 返利处理状态 | process_status | — | NUMBER(1,0) DEFAULT 0 NOT NULL | 本方补。0 待查号,1 返利已回写,2 合同失败不进返利,3 查号中。不进接口 4 |
相对接口 sheet:数据表多 `push_*`、审计字段、`process_status`。

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,105 @@
# 班次大批量收口(打勾用)
改完把 `- [ ]` 改成 `- [x]`,勾上日期。一次只开一条。说「做 BATCH-01」再动代码。
建议顺序:BATCH-01 → 02 → 03 → 04 → 05 → 06 → 07 → 08 → 09 → 10。
目标:一个班次可以安全地重复跑,对方系统里的单据不因重跑而多出一份。
口径不变:接口 1 不跑选品;接口 3 不录合同;选品拆量规则不动;合同 S02–S13 填表步骤不动;接口 2、4 这轮不接推送。
已定,不要再讨论:
- 领取 `UPDATE` 的 `IN` 每段 500 个 `fid`,同一事务,任一段失败整笔回滚。调用方 `limit` 仍管这一班做多少。
- 班次池保持 `min=1, max=1`。还没 `commit` 的失败:`drop` 坏连接,再借一次,只重试这一笔。已提交的不重做。`ping_interval=60`。
- 序列保持 `NOCACHE`。这轮不改 DDL,不加注释。
- `rpa_oms_inbound_audit`、`rpa_selection_query_audit`、`rpa_shift_error_audit` 这轮不清理。
- 合同查重三态:0 条才起草,恰好 1 条才复用合同号,多条停在 `process_status=3`。标题是客商名 + 「采购协议」 + 签订日,不含货主和起止日,标题 + 客商不是合单键。
**注释门禁(每条收工都查):** 本条改过或新建的模块、类、每一个函数/方法(含 `_` 私有、测试函数)都要有 docstring。第一句写做什么,随后只补签名里看不出的规则。不用 Args/Returns/Raises 小标题。缺一条不算完成。
---
## 1. 领取分段,入口封顶
### BATCH-01 领取 UPDATE 分段
- [x] 2026-10-24
- 落点:`rpa_oracle` 放共用分段更新。`rpa_selection/repo.py`、`rpa_cart/cart_repo.py`、`rpa_contract/repo.py`、`rpa_rebate/repo.py` 的领取 `UPDATE` 改走它。
- 改成:每段 500 个 `fid`,同一事务,任一段失败整笔回滚。`rpa_rebate/repo.py` 的 `_take_draft_groups` 第一组仍整组领,只把更新拆段。
- 验收:领取 1001 条时第一段能领走,不报「数据库不可用」。返利一个合同号超过 1000 行仍能整组领走。点名加在各自 `test_oracle_repo.py` / `test_cart_repo.py`。
### BATCH-02 入口默认上限
- [x] 2026-10-24
- 落点:`rpa_selection/main.py`、`rpa_cart/main.py`、`rpa_rebate/run_batch.py`、`rpa_contract/run_batch.py`。
- 改成:不传 `--limit` 用默认值,不再表示全部领走。选品 50,加购 20,合同和返利按行 10。合同入口补上 `--limit`。
- 验收:四个入口不传参时领取条数不超过默认值。
---
## 2. 按单落库,页面期间不占连接
### BATCH-03 选品一行一落,续租,重领不重插
- [x] 2026-10-24
- 落点:`rpa_selection/job.py` 的 `run_batch`,`rpa_selection/repo.py` 的 `save_results`。
- 改成:一行搜完就 `save_results` 这一行。搜索过程中刷新 `deal_time`。落库前再读:仍是本进程领的 `deal_flag=3`,并且表 2 还没有该 `oms_id` 的加购行,才插入。
- 验收:`rpa_selection/tests/test_job.py` 点名。租约过期后第二个进程不再插入第二套表 2。
### BATCH-04 页面期间归还连接
- [x] 2026-10-24
- 落点:`rpa_contract/job.py`、`rpa_rebate/job.py`、`rpa_cart/cart_job.py` 的 `session()`。
- 改成:`session()` 只包住领取和落库,不包住页面或商城调用。一组做完就提交。
- 验收:对应 `test_job.py` / `test_cart_job.py`。页面执行期间连接已归还。
### BATCH-05 坏连接丢弃后再借一次
- [x] 2026-10-24
- 落点:`rpa_oracle/pool.py`。
- 改成:`create_pool` 写 `ping_interval=60`。未提交失败时 `drop` 坏连接,再借一次,只重试这一笔。注释写明班次池只有一条,靠探活和丢弃换连接。班次池保持 `min=1, max=1`。
- 验收:`rpa_oracle/tests/test_pool.py`。探活失败或执行前连接已死时,下一次借到新连接。已提交的语句不重做。
---
## 3. 瞬时故障保持可再领
### BATCH-06 选品系统故障改回待领
- [x] 2026-10-24
- 落点:`rpa_selection/persist.py`。
- 改成:`LOGIN_FAILED` / `QUERY_FAILED` 且凑不齐时,`deal_flag` 改回 0,不写表 2。库存不足、毛利、效期仍写 `deal_flag=2`。
- 验收:`rpa_selection/tests/test_job.py` 里系统故障用例改断言。业务失败用例仍是终态。
### BATCH-07 加购登录和查询失败放回队列
- [x] 2026-10-24
- 落点:`rpa_cart/cart_job.py`。
- 改成:登录失败和查询失败把戳改回「系统管理员」,`status` 保持 1。对不上仓、库存不足仍写 `status=2`。
- 验收:`rpa_cart/tests/test_cart_job.py`。登录失败的行下一班还能领。
---
## 4. 重跑不产生第二份外部单据
### BATCH-08 加购前查车
- [x] 2026-10-24
- 落点:`rpa_cart/cart_job.py` 的 `_add_one`。
- 改成:加车前按条形码和供应商查购物车。数量已经不少于本行,直接 `mark_success`。多出来的数量不当成另一张单。
- 验收:`rpa_cart/tests/test_cart_job.py`。对方购物车已有本行数量、本地仍是「加购中」时,重跑不加第二份。
### BATCH-09 合同查重三态
- [x] 2026-10-24
- 落点:`rpa_contract/run_batch.py` 的 `production_runner`。每个合单组开始前把浏览器拉回起草页。
- 改成:进起草前查「我的合同」。0 条才走 S06–S12;恰好 1 条只落表 4;多条停在 `process_status=3`,不新建,也不取最新一条。提交后读不到号也停在 3,下一班只查号。注释写明三态,以及标题不含货主和起止日。
- 验收:`rpa_contract/tests/test_job.py`。对方已有唯一合同号、本地没写上时,重跑不起草第二份。命中多条时停在处理中。
### BATCH-10 返利按合同号查重
- [x] 2026-10-24
- 落点:`rpa_rebate/job.py` 的 `recorder`,`rpa_rebate/persist.py` 的 `build_found`。
- 改成:送审前按合同号查已有协议。已有总单号就补写 `action_code=2`,不再新建。只在确认对方没有单据时,才把起草行改回 `process_status=0`。读号失败保持处理中。
- 验收:`rpa_rebate/tests/test_job.py`。对方已有协议、本地没写上时,重跑不产生第二份。

View File

@ -0,0 +1,212 @@
> **历史版本,已合并替代。** 当前请使用[福建洁达采购 RPA 概要设计与详细设计 V3.0](/Users/yuuuu/Documents/AutoRpa/docs/superpowers/specs/2026-09-13-jieda-rpa-integrated-design.md)。合同与返利一次接收、共用接口头明细,以新稿为准。下文保留历史内容,不作为当前实施依据。
# 福建洁达 RPA 四流程设计稿
版本:V1.9 · 2026-09-12
配套:[技术实现说明 V2.3](/Users/yuuuu/Documents/AutoRpa/docs/superpowers/specs/2026-09-12-jieda-four-flow-impl.md)。本次为文档修订,应用代码及数据库尚未改动。
## 1. 范围和运行方式
本期做四条流程:选品加购、平台订单导出推送、合同录入、返利录入。业务可以点击各自脚本执行,也可以设置不同时间、不同频率自动执行。一条脚本处理完本批数据就退出。
FastAPI 接收订单中心推送的数据,校验必填字段,缺项当次返回失败及具体字段;通过后保存到 RPA 表并返回成功。RPA 按各自排期读取未处理数据,不在接收请求中同步执行四条流程。
业务范围以本次确认内容为准;字段依据[合同返利字段映射](/Users/yuuuu/Documents/AutoRpa/资料/合同返利系统字段映射_20260911.xlsx),选品依据[采购条件](/Users/yuuuu/Documents/AutoRpa/资料/采购平台选品条件优先项_20260902.txt),合同和返利操作参考 `资料/合同系统录入流程.md`。手工单暂不做。
## 2. 四条流程
### 2.1 选品加购 `PROCURE`
```text
读取订单中心已传入的未处理销售明细
→ 取一条批准文号去平台查询
→ 满足采购条件:加购物车,记录已加购
→ 不满足:记录已跳过及原因
→ 下一条 → 本批处理完结束
```
本次业务已确认,相同批准文号只有一个规格。选品按批准文号查询,不增加规格、厂家、包装和单位换算匹配。
每条记录已加购、已跳过或执行失败。再次执行读取未处理及需要重试的数据。人工付款在平台由业务处理,第一条脚本不等付款。
### 2.2 平台订单导出推送 `ORDER_PUSH`
```text
从对应平台导出订单 → 保存到该平台对应的共享文件夹
→ 按订单中心接口格式组装数据 → 推送订单中心
→ 记录接收结果 → 本批结束
```
1 药城、药帮忙分别配置共享目录及导出字段映射。保留原订单文件、来源订单号和推送结果。对码在订单中心内部完成,本方不增加对码表、候选确认或对码轮询。
### 2.3 合同录入 `CONTRACT`
```text
读取已接收成功、未处理的合同数据
→ 打开合同系统,选择客户,填写合同头和导入明细
→ 汇总金额并保存合同 → 记录合同号和保存结果
→ 调用订单中心链路接口更新对应节点 → 完成
```
必填字段在接收时检查,成功落表后直接供 RPA 录入。合同系统保存成功后才更新订单中心节点;更新失败保留“合同已保存、链路更新失败”,再次处理只补接口调用。
按原业务流程读取尽调回显;未尽调由采购员处理,本条停止自动录入,继续下一条。沿用业务要求的通知方式,不新增审批步骤。
### 2.4 返利录入 `REBATE`
```text
读取已接收的返利数据 → 按合同号查询采购协议
→ 填写返利总单 → 导入细单 → 提交
→ 记录成功或失败 → 下一条
```
字段按第 4 节填写。提交失败按原流程交业务处理。
## 3. 选品条件
前四项全部满足后,按仓库名单顺序选择。
| 条件 | 业务要求 |
| --- | --- |
| 自营仓 | 业务勾选的自营仓 |
| 盈利 | 对比销售价格,有 5% 以上盈利 |
| 有效期 | 大于等于 12 个月 |
| 库存 | 满足需求采购数量 |
| 仓库顺序 | 按以下名单优先选择 |
广东壹号药业有限公司、昆山亿方药业有限公司、湖北亿昊药业有限公司、天津壹号药业有限公司、重庆亿昊药业有限公司、重庆壹号医药有限公司、湖南壹号药业股份有限公司、辽宁亿昊药业有限公司、河南壹号医药有限公司、甘肃壹号云医药有限责任公司企业店、陕西壹号药业有限公司。
## 4. 合同及返利字段
以下字段和行号对应 Excel 的两个业务工作表。订单中心提供的字段在接收时校验;固定默认值由程序填写,系统生成及页面回显字段不要求上游必传。
### 4.1 合同头
| 目标字段 | 取值规则 | 行号 |
| --- | --- | --- |
| 客户名称 | 订单中心 | 12 |
| 客户编码 | 目标系统自动生成 | 11 |
| 尽调情况 | 读取回显;未尽调停止本单自动录入并转采购员处理 | 13 |
| 结算类型 | 收支双向 | 15 |
| 合同版本类型 | 预付:我司定制;账期:非格式合同 | 17 |
| 合同金额(单位元) | 预估本次合作金额 | 24 |
| 金额(元) | 明细汇总;与导入后页面汇总核对并回填 | 25 |
| 合同签订日期、合同开始日期、合同结束日期 | 订单中心分别提供,三个独立字段 | 26–28 |
| 是否重大合同 | 否 | 29 |
| 是否预付款 | 预付:是;账期:否 | 30 |
| 付款方式 | 电汇 | 32 |
| 付款期限 | 预付:1;账期:订单中心账期天数,缺失不默认 30 | 33 |
| 合同具体分类 | 国内药品类、器械类品种 | 37 |
| 运输方式 | 公路运输 | 39 |
### 4.2 合同明细
| 目标字段 | 来源/规则 | 行号 |
| --- | --- | --- |
| 货品编码 | 订单中心 | 43 |
| 货品名称 | 订单中心标准货品名称 | 44 |
| 注册证号 | 订单中心 | 46 |
| 规格、单位 | 按订单中心标准字段填报 | 47–48 |
| 数量、单价 | 按订单中心最终采购数量、单价填报 | 49–50 |
| 付款方式 | 电汇 | 52 |
| 付款条件 | 订单中心;模板无此字段时不填 | 53 |
| 付款期限 | 同合同头规则 | 54 |
### 4.3 返利总单
| 目标字段 | 来源/规则 | 行号 |
| --- | --- | --- |
| 返利合约 | 否 | 5 |
| 供应商编码 | 订单中心 | 6 |
| 返利单位编码 | 订单中心 | 7 |
| 计划员 | 订单中心,按业务给定的计划员/采购员对应关系填写 | 8 |
| 采购结算方式 | 文案“否”;与细单代码 3 分别映射 | 9 |
| 承付模式 | 下月 N 号结 | 10 |
| 协议开始日期、协议结束日期 | 当天 | 11–12 |
### 4.4 返利细单
| 目标字段 | 来源/规则 | 行号 |
| --- | --- | --- |
| 货品编码、数量、含税协议单价 | 订单中心返利源数据 | 15–17 |
| 税率、关税税率 | 均为 0.13 | 18–19 |
| 承付模式、承付参数 | 11、25 | 20–21 |
| 细单开始日期 | 当天 | 22 |
| 细单结束日期 | 本年度最后一天 | 23 |
| 采购结算方式 | 3 | 24 |
| 特定补偿预估值、进货补偿预估值 | 均为 0 | 25–26 |
| 细单备注 | 订单中心订单号 | 33 |
| 货源细单ID | 订单中心货源细单号;与订单号分列 | 34 |
合同明细导入列按 Excel 的 Sheet3。表中标注无需填写的字段不填;系统自动生成字段由系统处理。
## 5. 接口和存储
### 5.1 接口方向
| 方向 | 数据 | 处理 |
| --- | --- | --- |
| 订单中心 → 本方 | 销售总单及明细 | 校验后落表,供选品使用 |
| 订单中心 → 本方 | 合同头及明细 | 校验后落表,供合同录入使用 |
| 订单中心 → 本方 | 返利头及明细、计划员对应信息 | 校验后落表,供返利使用 |
| 本方 → 订单中心 | 平台导出的订单 | 按正式接口格式推送,保存接收结果 |
| 本方 → 订单中心 | 合同保存结果及对应链路节点 | 保存成功后调用更新接口,保存返回结果 |
接收成功表示数据校验通过并已保存。缺必填返回字段错误,订单中心补齐后重传;数据库保存失败返回失败。相同来源单据重复传入时,按双方约定的来源标识识别,避免重复执行。
### 5.2 需要保存的数据
表名为建议,开发时先核对已有 RPA Oracle 表,能复用的直接复用。正式列名和类型按接口报文及 Excel 确定。
| 逻辑表/现有记录 | 保存内容 |
| --- | --- |
| `RPA_JD_SALES_HEAD / LINE` | 来源销售单号、明细标识、批准文号、采购数量、销售价格及上游提供的商品信息 |
| `RPA_JD_CONTRACT_HEAD / LINE` | 第 4 节合同字段、来源标识、订单中心链路关联 |
| `RPA_JD_REBATE_HEAD / LINE` | 第 4 节返利字段、合同/协议关联及计划员对应信息 |
| 现有 `job / job_node` | 流程类型、来源数据关联、处理状态、已加购/已跳过结果、合同号、失败原因及执行次数 |
| 现有 `job_artifact / job_event` | 平台订单文件路径、平台、来源订单号、发送报文和接收结果;合同链路请求和返回结果 |
一个业务头关联它自己的明细,订单号和明细标识分别保存。原平台订单文件保存在共享目录。选品和订单推送不另建一套主数据匹配表。
业务数据放 RPA Oracle,执行状态复用 MySQL 状态中心。脚本通过状态中心 API 取数和回报,由后端统一读写数据库。
## 6. 对当前代码的改动
| 位置 | 本次需要改什么 |
| --- | --- |
| `state_center/app/main.py` | 增加销售/合同/返利接收及取数接口;必填在接收时校验;按流程返回待处理数据 |
| `state_center/app/db.py` 及数据库脚本 | 接入 RPA 业务表;job 增加流程类型和来源关联,原供应商+日期键改为适合各流程的来源标识 |
| `state_center/app/web_state_machine.py` | 按流程定义节点及完成条件 |
| `rpa_yisaiqi/agent.py`、`runtime/shell.py`、`callback_client.py` | 支持四个独立脚本入口,取数、逐条处理、回报及本批退出 |
| 选品与平台导出模块 | 接入平台查询/加购、订单导出、共享目录保存、报文组装及推送 |
| `contract_draft.py`、`contract_create_node.py`、`contract_flow_steps.py` | 按第 4 节字段录入,保存成功后调用链路更新接口;原页面操作可复用部分继续使用 |
| 返利执行模块 | 按第 4 节字段导入和提交;真实页面流程需补齐 |
| 前端列表/详情 | 按四条流程展示状态、文件、结果及失败原因,复用现有页面 |
当前实现仍是一条固定主链;平台真实查询/导出能力需核对现有模块,不能把 mock 当作已完成。整体改动集中在数据接入、流程拆分和接口适配,合同页面操作有可复用基础。
具体执行节点和开发顺序见[技术实现说明](/Users/yuuuu/Documents/AutoRpa/docs/superpowers/specs/2026-09-12-jieda-four-flow-impl.md)。
## 7. 验收
1. 接收数据缺必填当次失败;补齐后可重传,成功数据能被对应脚本读取。
2. 选品合格加购、不合格跳过,处理完本批结束,不等待付款。
3. 平台订单保存到对应共享文件夹,按接口格式推送并记录结果。
4. 合同字段按 Excel 填写;保存成功后更新订单中心节点,更新失败不重复建合同。
5. 返利按字段映射填写并记录提交结果。
6. 四条脚本按各自时间和频率执行,失败可定位到具体记录,已完成数据不重复处理。
## 8. 待补充信息和可能出现的问题
以下是联调需要补齐的信息及实现注意点,不增加业务操作步骤。
| 项目 | 需要处理的内容 |
| --- | --- |
| 正式接口和文件 | 提供订单中心接收/链路更新格式、节点代码,平台导出样例、共享路径和四条排期 |
| 5% 盈利 | 原材料未给出计算公式,需明确按什么基数计算 |
| 合同/返利模板 | 确认实际导入模板、报文中每份合同及返利明细的范围;附件及审批要求以实际系统和业务确认为准,不先设计额外流程 |
| 重复运行 | 同一脚本运行中再次点击不重复启动;重复传单或重新导出同一订单不重复办理 |
| 接口超时 | 记录本次请求及错误,结果不明先核对;合同已保存时仅补链路更新 |
| 平台/共享目录故障 | 查询失败和无合格商品分开记录;文件保存失败时记录错误,不继续推送 |
| 当前合同代码 | 缺单价默认 1.00、同编码行定位和前 30 行扫描等行为需修正;接收缺价应直接返回错误 |
| 旧流程影响 | 新四流程按 flow 区分,保留旧在途单的原节点和处理方式,避免改全局节点影响其他流程 |

View File

@ -0,0 +1,92 @@
> **历史版本,已合并替代。** 当前请使用[福建洁达采购 RPA 概要设计与详细设计 V3.0](/Users/yuuuu/Documents/AutoRpa/docs/superpowers/specs/2026-09-13-jieda-rpa-integrated-design.md)。合同与返利一次接收、共用接口头明细,以新稿为准。下文保留历史内容,不作为当前实施依据。
# 福建洁达四流程技术实现说明
版本:V2.3 · 2026-09-12
配套:[设计稿 V1.9](/Users/yuuuu/Documents/AutoRpa/docs/superpowers/specs/2026-09-12-jieda-four-flow-design-codex.md)。本文为待实施说明,代码和数据库尚未修改。
## 1. 在现有结构上改
FastAPI 接收并校验订单中心数据,RPA Oracle 保存业务头明细,Oracle 继续管理 job、节点、日志和结果。四条脚本分别由业务点击或定时任务启动,不在一个 HTTP 请求里同步执行四条流程。
现有 `job` 增加流程类型和来源数据关联;按各流程配置节点。优先复用 `job_node / job_event / job_artifact`,不另建运行中心、对码中心或多套状态表。
## 2. 接收和取数
建议本方接收入口为 sales、contract-data、rebate-data,正式路径开发时统一命名。每个入口执行:
```text
检查对应必填字段 → 不通过:返回失败和字段位置
→ 通过:同事务保存业务头明细 → 返回成功
```
字段来源和默认值按[设计稿第 4 节](/Users/yuuuu/Documents/AutoRpa/docs/superpowers/specs/2026-09-12-jieda-four-flow-design-codex.md)。系统生成和回显字段不作为上游必填。失败由订单中心修正重传,不建立等待补字段的业务作业。
取数接口按 flow 返回成功接收且未处理的数据。保存来源单号及明细标识,用于识别重复传入。状态中心登记或复用对应 job;领取时将其标记处理中,避免重复执行。脚本逐条回报,处理完本批退出。
## 3. 四条脚本
| flow | job 对象 | 执行节点 | 结束结果 |
| --- | --- | --- | --- |
| PROCURE | 一条销售明细 | `PROCURE_ITEM_RUN` | 已加购或已跳过;执行失败记原因 |
| ORDER_PUSH | 一次平台订单导出批次 | `ORDER_EXPORT_PUSH` | 文件已保存,应推送订单已接收;失败保留具体订单和原因 |
| CONTRACT | 一份上游合同数据及其明细 | `CONTRACT_CREATE → CONTRACT_CHAIN_UPDATE` | 合同已保存,订单中心节点已更新 |
| REBATE | 一份返利数据及其明细 | `REBATE_HEADER → REBATE_IMPORT → REBATE_SUBMIT` | 提交成功或失败 |
**选品脚本:** 按批准文号查询,依次判断自营仓、盈利、效期、库存和仓库顺序;合格加购物车,不合格记录跳过,继续下一条。同批准文号按一个规格处理,不增加属性匹配。复用节点结果记录已加购/已跳过,不等待人工付款。
**订单脚本:** 登录平台导出订单,保存到平台对应共享目录,按映射生成订单中心报文并推送。原文件、来源订单号、发送内容和回执保存在现有文件/日志记录中。再次处理依据已接收订单记录跳过成功项。
**合同脚本:** 读取成功接收的合同头明细,复用页面操作填写并保存。确认保存成功后回报合同号,进入链路更新节点。该节点通过订单中心接口适配器调用更新接口并回报结果。两步在同一条合同流程中执行,失败后恢复当前步骤;不增加独立后台调度任务。
**返利脚本:** 按合同号查询协议,填写总单、导入明细并提交,回报实际结果。原业务要求的未尽调合同及返利失败交业务处理,继续其他记录。
## 4. 数据如何保存
| 内容 | 保存位置 |
| --- | --- |
| 销售头明细 | RPA Oracle 销售表,字段按订单中心数据及选品条件 |
| 合同头明细及链路关联 | RPA Oracle 合同表,字段按 Excel |
| 返利头明细及计划员对应信息 | RPA Oracle 返利表,字段按 Excel |
| 流程、处理状态、合同号、失败原因 | 现有 Oracle job/job_node |
| 文件路径及推送/链路调用记录 | 现有 job_artifact/job_event |
| 平台原始订单文件 | 对应平台共享文件夹 |
表名及列定义见设计稿第 5 节;开发前对照现场已有表和正式报文确定。头明细按来源关联保存,不用供应商+日期代替所有业务唯一标识,不自行合并合同。
处理状态复用现有待处理、处理中、成功、失败等状态。选品正常跳过属于处理完成,业务结果记录为“已跳过”。合同的“保存成功”和“链路更新成功”分别记录,更新失败不回退到重新建合同。
## 5. 代码改动
| 文件/模块 | 改动 |
| --- | --- |
| `state_center/app/main.py` | 接收校验、取数及结果接口;按 flow 创建和查询作业 |
| `state_center/app/db.py`、数据库脚本 | RPA 业务表访问、头明细事务、job 的 flow 和来源字段;成功数据重复传入不重复建单 |
| `state_center/app/web_state_machine.py` | 四条节点配置;旧作业继续用旧配置 |
| `state_center/app/config.py` | 数据库、订单中心接口、平台共享路径和字段映射配置 |
| 订单中心接口适配器 | 两项调用:平台订单推送、合同保存后的链路节点更新;路径及参数按对方协议 |
| `rpa_yisaiqi/agent.py`、`runtime/shell.py`、`callback_client.py` | 四种脚本入口、取数循环、实际执行次数、结果回报、失败步骤恢复 |
| 选品/平台导出模块 | 登录查询、采购筛选、加购、导出、共享文件保存及报文组装 |
| `contract_draft.py`、`contract_create_node.py`、`contract_flow_steps.py` | 按 Excel 字段录入;确认保存结果,衔接链路接口;修正错误单价默认值及明细定位 |
| 返利模块 | 补真实页面填写、导入、提交操作 |
| 前端列表/详情 | 展示四种流程及已有状态,增加文件、接收结果和链路更新结果 |
现有固定主链、供应商+日期建单规则不能直接套用到全部新流程。商城实际登录/查询/导出模块需核对是否已有可用代码;现有 mock 代理和返利 runner 不能算真实执行已完成。
## 6. 开发顺序
1. 按正式报文和 Excel 确定字段,核对已有表,完成接收校验和落表。
2. 增加 flow、取数、结果记录及四个脚本入口,复用现有状态中心。
3. 完成平台导出、共享归档和订单推送。
4. 完成合同字段适配,接上保存后的链路更新接口。
5. 完成逐条选品加购及返利页面操作。
6. 在实际执行机配置四条排期,按设计稿第 7 节联调验收。
## 7. 待确认及可能的问题
- 订单中心正式字段、链路节点代码、平台导出样例、共享路径及排期需提供;5% 盈利公式需明确。
- 重复点击、重复传单、重复导出可能重复办理;使用现有状态和来源标识防重,同一流程不重入。
- 保存或推送成功后回报失败,不能直接重做;先查看已有文件、回执或目标合同结果,再从失败步骤继续。
- 合同链路更新失败只补接口调用;不额外增加后台维护任务。
- 当前合同缺价填 1.00、同编码行定位及前 30 行扫描行为需要处理;具体风险见设计稿末尾。
- 新四流程改动按 flow 区分,保留旧在途单节点,避免影响旧 OCR、对码和 M5 流程。

View File

@ -0,0 +1,222 @@
# 福建洁达:接口数据入 RPA 库(小改)
> 日期:2026-09-12
> 范围更正:流程仍是 RPA,状态机不动。改的是图上那些「从系统/表来」的数据——FastAPI 调接口,落到对方 RPA Oracle,带状态,再给 RPA 用。
> 上一版把四步理解成换主链、砍 M5,范围过大,作废。
---
## 1. 改什么、不改什么
不改:
- 艺赛旗照旧点 1 药城 / 药帮忙 / 合同系统 / 返利中心
- 状态中心 MySQL 状态机、`start/complete/fail`、待办、幂等
- 人工闸门:选品复核付款、未尽调、返利失败
改:
- 原先靠导 Excel / 页面上抄的**主数据、对照表、总单**,改为接口拉取
- FastAPI 按四步拉数,写入 `RPADBTEST`(`192.168.12.244/rpa`,用户 `rpainit`)
- 每张业务表带处理状态,失败可重拉、可跳过、可给 RPA 重跑,不把半截 JSON 丢给机器人
```
对方接口 → FastAPI(四步同步) → Oracle 暂存表(带 STATUS)
↓
RPA 读 READY 行,点页面
↓
回写 SUCCESS/FAILED
↓
状态中心节点 complete/fail
```
两套状态分工:
| 库 | 记什么 | 不记什么 |
| --- | --- | --- |
| Oracle `RPADBTEST` | 这批数据到了没有、RPA 用过没有 | 整单流程走到哪一步 |
| MySQL `rpa_center` | job / 节点 / 待办 | 接口原始报文(必要时只存镜像) |
不要在 Oracle 再做一套 13 节点状态机。
---
## 2. 四步对应图上哪些数据
图上没有写「接口」二字。按「这格是取数/对照表,不是点按钮」切开。点按钮的仍归 RPA。
| 步 | FastAPI | 图上取数 | RPA 随后用它做什么 |
| --- | --- | --- | --- |
| 1 | `POST /api/jieda/sales/sync` | 订单中心「销售总单」 | 按行里的批准文号去 1 药城 / 药帮忙搜品、筛选、加购 |
| 2 | `POST /api/jieda/match/sync` | 手工单 DocAgent 结果;提取字段;对码商家信息;回写订单中心对码表的入参 | 合同里填「客户名称」;对码表有数才能往下 |
| 3 | `POST /api/jieda/contract-refs/sync` | 客户是否已尽调;《客户类型》预付/账期;合同明细导入用的货品行 | 选格式/非格式、填模板、导明细、回填合同金额 |
| 4 | `POST /api/jieda/rebate-refs/sync` | 采购总单;计划员↔采购员对照;返利细单模板行 | 按合同号查总单、填必填项、导细单 |
第 1 步失败:RPA 不要上平台乱搜。
第 3 步尽调=否:RPA 不填合同,走现有人工待办(邮件通知采购员)。
第 4 步对照表缺失:RPA 不猜计划员,节点 fail。
---
## 3. Oracle 表(建议新建,前缀 `RPA_JD_`)
连接(只放环境变量,不进仓库):
```
RPA_ORACLE_HOST=192.168.12.244
RPA_ORACLE_PORT=1521
RPA_ORACLE_SERVICE=rpa
RPA_ORACLE_USER=rpainit
RPA_ORACLE_PASSWORD=...
```
本机目前 TCP `192.168.12.244:1521` 不通,建表要在能进这台库的机器上执行。先 `SELECT table_name FROM user_tables`,有现成表就映射,不要重复造。
### 3.1 每张业务表共用状态列
```
JOB_ID VARCHAR2(64) -- 对齐状态中心 job_id
BATCH_ID VARCHAR2(64) -- 一次 sync
STATUS VARCHAR2(16) -- NEW / READY / RUNNING / SUCCESS / FAILED / SKIPPED
ERR_CODE VARCHAR2(64)
ERR_MSG VARCHAR2(500)
ATTEMPT_NO NUMBER(4)
RAW_JSON CLOB -- 接口原文,排障用
SYNCED_AT DATE
UPDATED_AT DATE
IDEMPOTENCY_KEY VARCHAR2(128)
```
行状态只表示**这条数据**:
- FastAPI 写入成功 → `READY`
- 接口失败、校验失败 → `FAILED`(RPA 不抢)
- RPA 开始用 → `RUNNING`
- 页面做完 → `SUCCESS`
- 业务跳过(如未尽调)→ `SKIPPED`
同一 `JOB_ID + IDEMPOTENCY_KEY` 再 sync:已 `SUCCESS` 的不覆盖;`FAILED` 允许重拉。
### 3.2 四步表
**第 1 步 销售总单**
- `RPA_JD_SALES_HEAD`:销售总单号、订单日期、客户/商家、金额、来源
- `RPA_JD_SALES_LINE`:批准文号、品名、规格、厂家、数量、单价、平台商品编码
**第 2 步 对码 / 手工单**
- `RPA_JD_MATCH_PARTY`:对码商家信息(合同「客户名称 / 客户编码」从这里取)
- `RPA_JD_MATCH_LINE`:对码后货品 ID/编码、是否命中;也承接 DocAgent 抽出的手工单行
- 回写订单中心对码表:另调对方写入接口,本地留 `PUSH_STATUS`
**第 3 步 合同辅助**
- `RPA_JD_CUSTOMER_REF`:客户编码、是否已尽调、客户类型(预付/账期)、应用哪套合同模板
- `RPA_JD_CONTRACT_LINE`:按合同明细导入模板整理好的货品行(RPA 只负责上传,不在页面上拼 Excel)
**第 4 步 返利辅助**
- `RPA_JD_PLANNER_MAP`:计划员 ↔ 采购员(可全量同步,不必每单)
- `RPA_JD_REBATE_HEAD`:采购总单上要填进返利总单的字段
- `RPA_JD_REBATE_LINE`:细单导入行
列级 DDL 等对方接口字段确定后再锁。现在只锁表职责和状态机,避免先猜 40 个列名再推翻。
---
## 4. FastAPI(状态中心里加一层,不新起服务)
四个同步入口,鉴权仍用 `X-Agent-Token`。
```
POST /api/jieda/sales/sync
POST /api/jieda/match/sync
POST /api/jieda/contract-refs/sync
POST /api/jieda/rebate-refs/sync
```
入参最小集:`job_id`、`order_date`、可选 `idempotencyKey`。
出参:`ok`、`batch_id`、`ready_count`、`failed_count`、`status`。
每个 sync:
1. 调对方接口(没有正式文档前走 fixture,契约用 Pydantic 写死)
2. 校验必填(第 1 步无批准文号直接 `FAILED`)
3. upsert 到对应 Oracle 表,`STATUS=READY`
4. 写状态中心事件;该节点仍由 RPA `start/complete` 推进,**sync 成功不等于节点成功**
查询给 RPA:
```
GET /api/jieda/{job_id}/sales
GET /api/jieda/{job_id}/contract-refs
...
```
RPA 也可以直连 Oracle 读 `READY` 行。推荐先走 FastAPI 读,避免脚本里写死账号;直连作为艺赛旗侧备选。
RPA 回写状态:
```
POST /api/jieda/{job_id}/rows/{table}/{id}/status
body: { "status": "RUNNING|SUCCESS|FAILED", "errMsg": "..." }
```
内部更新 Oracle 行状态。节点级成功仍走原来的 `/nodes/{node}/complete`。
---
## 5. 和现有节点怎么接(不大改)
不改 `NODE_ORDER`。四步 sync 挂在现有节点**开始前**:
| 现有节点 | 开始前多做的 sync | RPA 行为变化 |
| --- | --- | --- |
| `ORDER_FETCH` | `sales/sync` | 不再以导 Excel 为唯一数据源;以 Oracle 总单/明细为准去搜品 |
| `OCR_EXTRACT` / 对码前 | `match/sync` | 线下单走 DocAgent 结果表;商家名称从表读 |
| `CONTRACT_CREATE` | `contract-refs/sync` | 尽调、客户类型、明细行从表读;未尽调不点填报 |
| `REBATE_HEADER` / `REBATE_IMPORT` | `rebate-refs/sync` | 总单必填项、计划员、细单行从表读 |
`PRODUCT_MATCH`、`CONTRACT_APPROVE_WAIT`、`M5_*` 先不动。新图没画 M5,不等于本期删 M5。
艺赛旗每个泳道开头加一个「取数」代码块:调对应 `sync`(或确认 scheduler 已 sync)→ 读 `READY` → 再点页面。失败把行打成 `FAILED` 并调节点 `fail`。
---
## 6. 代码改动面(小)
| 位置 | 做什么 |
| --- | --- |
| `state_center/app/config.py` | 增加 `RPA_ORACLE_*`,密码只走环境变量 |
| `state_center/app/rpa_oracle.py` 新建 | 连接、upsert、按 job 查询、改行状态 |
| `state_center/app/jieda_sync.py` 新建 | 四个 sync 的编排 + Pydantic |
| `state_center/app/adapters/` 新建 | 订单中心 / 尽调 / 客户类型 / 计划员 的 HTTP client,先 fixture |
| `state_center/app/main.py` | 挂 4 个 sync + 查询 + 行状态 |
| `state_center/sql/rpa_jd_oracle.sql` 新建 | 上面那些表的 DDL |
| `rpa_yisaiqi` | 合同/选品/返利流程开头读 FastAPI 或 Oracle,不改状态机顺序 |
| 测试 | sync 幂等、失败不覆盖 SUCCESS、无批准文号 → FAILED、未尽调 → SKIPPED |
密码和 TNS 不进 git。`connection.local.json` 已有先例。
---
## 7. 落地顺序
1. 能连 `192.168.12.244` 的机器上看 `rpainit` 已有表,避免重名。
2. 建第 1 步两张表 + `sales/sync` fixture,RPA 能把批准文号读出来。
3. 第 3 步尽调 / 客户类型(合同填报刚需)。
4. 第 4 步计划员对照 + 采购总单。
5. 第 2 步对码回写和 DocAgent,等对方对码接口。
没有接口文档时,每步用一份 JSON fixture 冒充对方响应,表结构和 FastAPI 先定。
---
## 8. 还缺的输入
- 四个接口的 URL、鉴权、字段(尤其销售总单、尽调、客户类型、计划员对照)
- `rpainit` 是否已有同类暂存表
- RPA 读数:走我们 FastAPI,还是艺赛旗直连 Oracle
这三项不齐也能先建表和 fixture;接真接口时只换 adapter。

View File

@ -0,0 +1,683 @@
# 福建洁达采购 RPA 详细设计
| 文档属性 | 内容 |
| --- | --- |
| 版本 | V3.1 |
| 日期 | 2026-09-13 |
| 配套概要设计 | [《福建洁达采购 RPA 概要设计》](./2026-09-13-jieda-rpa-overview-design.md) |
| 状态 | 设计评审稿;未实施代码或建表 |
| 适用范围 | 接口接收、四条流程执行、状态记录、运维与异常恢复 |
本文件承接概要设计,规定可供开发、测试和运维执行的表、字段、状态、接口和处理规则。外部正式字段名、节点代码和现场 Oracle 表结构以第 9 章未决事项为准。
**本期范围收敛:**只实现订单接收、平台订单推送、选品、合同、返利四条流程及基本状态记录。版本冲突、内容哈希、执行令牌、文件证据表、复杂事件分类和自动化核验接口不作为本期新增必做项;现场已有能力可以复用,确需新增时另行确认。
## 4. 详细设计:数据模型
### 4.1 实体关系和存储位置
```mermaid
flowchart LR
IF[接口接收记录<br/>RPA_JD_IF_REQ]
SH[销售接口头<br/>RPA_JD_SALES_H]
SL[销售接口明细<br/>RPA_JD_SALES_L]
OH[合同返利共用头<br/>RPA_JD_ORDER_H]
OL[合同返利共用明细<br/>RPA_JD_ORDER_L]
J[流程作业<br/>JOB]
N[作业节点<br/>JOB_NODE]
E[操作事件<br/>JOB_EVENT]
A[文件证据<br/>JOB_ARTIFACT]
P[订单推送台账<br/>JD_ORDER_PUSH]
IF --> SH
IF --> OH
SH --> SL
OH --> OL
SL --> J
OH --> J
J --> N
J --> E
J --> A
J --> P
```
*图中括号内为数据库表名,前面的中文为业务含义。*
本节所有实体均存放在同一套 **Oracle 数据库**,不再区分接口库、状态库或 MySQL。实体按用途分为三组:
| 分组 | 表 | 作用 |
| --- | --- | --- |
| 接口输入表 | `RPA_JD_IF_REQ`、`RPA_JD_SALES_H/L`、`RPA_JD_ORDER_H/L` | 保存订单中心传入的原始报文、销售数据及合同/返利共用数据 |
| 流程业务表 | `JOB`、`JOB_NODE`、`JOB_EVENT`、`JOB_ARTIFACT` | 保存四条流程的作业、节点执行、操作记录和文件证据 |
| 订单推送表 | `JD_ORDER_PUSH` | 保存平台订单导出后推送订单中心的请求、回执和推送状态 |
接口输入数据只保存一份。合同和返利通过相同的 `INPUT_ID`、`INPUT_VERSION` 关联;两条流程分别在 `JOB` 中记录自己的状态,不复制两套合同/返利输入表。已有表能承载上述用途时优先复用,只有确实缺少的表或字段才在 Oracle 中新增。图中的关联由主键、外键或业务关联字段实现,具体以现场 Oracle 表结构核对结果为准。
接口表内的合同/返利状态不各写一份。运维总览按 INPUT_ID 汇总两份 job 与其节点,展示三个独立标志:合同保存、链路更新、返利办理。读写来源见第 5 章。
### 4.2 接收记录 `RPA_JD_IF_REQ`
一行是一回接收尝试;错误请求也能查到,不能依赖先有 job 才记录。
| 字段 | 建议类型 | 含义与约束 |
| --- | --- | --- |
| REQUEST_LOG_ID | VARCHAR2(64) PK | 本方生成,返回给上游和运维定位 |
| TRACE_ID | VARCHAR2(64) | 本次请求关联应用日志 |
| SOURCE_SYSTEM / MESSAGE_ID | VARCHAR2(32) / VARCHAR2(128) | 调用方及其请求标识;MESSAGE_ID 格式由对方提供 |
| DATA_TYPE | VARCHAR2(24) | SALES 或 ORDER_COMMON;没有独立 REBATE 接收类型 |
| BODY_HASH | VARCHAR2(64) | 规范化业务报文哈希,识别同请求内容是否一致 |
| RECEIVE_STATUS | VARCHAR2(16) | ACCEPTED、REJECTED、DUPLICATE、CONFLICT |
| ORIGINAL_REQ_ID | VARCHAR2(64),可空 | 重复请求关联首次成功记录 |
| RAW_BODY / RESPONSE_BODY | CLOB | 实际业务报文和返回内容,不记录鉴权口令 |
| ERROR_CODE / ERROR_DETAIL | VARCHAR2(64) / CLOB | 字段路径、明细标识、错误说明 |
| RECEIVED_AT / RESPONDED_AT | TIMESTAMP | 收到及生成返回结果的时间 |
| REGISTER_STATUS | VARCHAR2(8),可空 | 成功记录的作业登记标志:PENDING、DONE、FAILED;非成功记录为空 |
| REGISTER_ERROR / REGISTERED_AT | VARCHAR2(500) / TIMESTAMP | 作业登记失败原因及最终登记时间 |
RECEIVE_STATUS 表示接收事实,REGISTER_STATUS 仅表示是否已登记作业,均不代表合同或返利完成。成功接收同事务保存接口头明细并置 REGISTER_STATUS=PENDING;脚本取数前补登记,完成后更新 DONE。登记失败在下一次脚本取数重试,无需上游重传。
约束:对 ACCEPTED 记录建立 `(SOURCE_SYSTEM, MESSAGE_ID)` 条件唯一索引;Oracle 可用 CASE 表达式索引实现。失败/重复尝试保留独立行,不占用成功消息键。索引还包括 `(DATA_TYPE, REGISTER_STATUS, RECEIVED_AT)`、`TRACE_ID`、`(SOURCE_SYSTEM, MESSAGE_ID)`。
### 4.3 接口头公共字段
销售头和共用订单头均使用以下控制列。
| 字段 | 建议类型 | 用途 |
| --- | --- | --- |
| INPUT_ID | VARCHAR2(64) PK | 本方输入身份,跨流程统一关联 |
| REQUEST_LOG_ID | VARCHAR2(64) FK | 首次成功接收记录 |
| SOURCE_SYSTEM / SOURCE_DOC_ID | VARCHAR2(32) / VARCHAR2(128) | 上游稳定业务标识;与展示单号分开 |
| SOURCE_DOC_NO | VARCHAR2(128) | 业务查询展示用单号 |
| INPUT_VERSION | VARCHAR2(64) | 上游版本;未提供时首版用固定 1,变更走第 6.2 节冲突规则 |
| DATA_HASH | VARCHAR2(64) | 本对象完整头明细内容哈希 |
| DATA_STATUS | VARCHAR2(16) | READY:成功接收的完整输入;本期不另设待补字段状态 |
| CREATED_AT | TIMESTAMP | 成功保存时间 |
头表唯一键为 `(SOURCE_SYSTEM, SOURCE_DOC_ID, INPUT_VERSION)`。明细以 `(INPUT_ID, SOURCE_LINE_ID)` 唯一,关联本头,不以货品编码唯一。成功输入不由 RPA 改写,处理结果记录在 Oracle 业务表中。
### 4.4 销售接口头明细 `RPA_JD_SALES_H / L`
| 表 | 业务字段 | 用途 |
| --- | --- | --- |
| SALES_H | 第 4.3 节公共字段、BUSINESS_DATE | 一份销售总单 |
| SALES_L | LINE_ID、INPUT_ID、SOURCE_LINE_ID、APPROVAL_NO、PURCHASE_QTY、SALES_PRICE | 逐条查询所需批准文号、需求采购数量、销售价格 |
| SALES_L 可选描述 | GOODS_NAME、SPEC、MANUFACTURER、UNIT | 按上游原值保留供查看,不作为新增属性匹配门槛 |
LINE_ID 建议 VARCHAR2(64),数量 NUMBER(18,4),价格 NUMBER(18,6),标识/文号 VARCHAR2(128),描述 VARCHAR2(256)。最终长度和精度按真实接口及目标系统核对。
### 4.5 合同/返利共用接口头明细 `RPA_JD_ORDER_H / L`
一份头代表上游明确给定的一组合同及对应返利数据,范围由报文的 SOURCE_DOC_ID 和明细集合确定。首版不按供应商+日期自行合单。若实际存在一组数据对应多份合同/返利,需要上游给出组标识,见第 12 章。
| 位置 | 建议字段 | 含义/使用流程 |
| --- | --- | --- |
| ORDER_H | 第 4.3 节公共字段 | 合同和返利共用输入标识 |
| ORDER_H | CUSTOMER_NAME、CUSTOMER_TYPE、CREDIT_DAYS | 合同客户名称、预付/账期类型、账期天数 |
| ORDER_H | CONTRACT_SIGN_DATE、CONTRACT_START_DATE、CONTRACT_END_DATE | 三个独立合同日期 |
| ORDER_H | SUPPLIER_CODE、REBATE_UNIT_CODE、PLANNER、PURCHASER | 返利供应商、返利单位、计划员及采购员对应信息 |
| ORDER_H | CHAIN_REF | 订单中心链路/源业务关联;精确结构按正式接口 |
| ORDER_L | LINE_ID、INPUT_ID、SOURCE_LINE_ID、ORDER_NO、SOURCE_DETAIL_ID | 本方行 ID、来源行、订单号、货源细单号;订单号与货源细单号分列 |
| ORDER_L | GOODS_CODE、GOODS_NAME、REG_CERT_NO、SPEC、UNIT | 标准货品编码/名称、注册证号、规格、单位 |
| ORDER_L | PURCHASE_QTY、PURCHASE_UNIT_PRICE | 采购数量和单价;用于合同明细 |
| ORDER_L | REBATE_QTY、REBATE_TAX_PRICE | 返利数量和含税协议单价;用于返利明细 |
| ORDER_L | PAYMENT_CONDITION,可空 | 实际合同模板需要时从同一报文提供 |
同一次报文中数量如只有一个来源字段且供两流程共用,映射层同时引用它;不因此要求上游重复传两个数量。采购单价与含税协议单价按 Excel 的不同含义分别映射,未经接口确认不互相代替。
建议数量 NUMBER(18,4)、价格 NUMBER(18,6)、金额 NUMBER(18,2)、日期 DATE;名称 VARCHAR2(256)、编码 VARCHAR2(128)、类型 VARCHAR2(16)、账期 NUMBER(6)、CHAIN_REF CLOB 或按正式关联键拆列。固定默认值不强迫订单中心传入,在节点执行输入中生成并记录所用模板/映射版本。
**此处没有 `RPA_JD_CONTRACT_HEAD / RPA_JD_REBATE_HEAD` 两套输入表。两流程取数的 INPUT_ID 必须相同。**
### 4.6 业务作业与节点记录
| 表/字段 | 现有或新增 | 详细用途 |
| --- | --- | --- |
| job.flow_code / flow_version | 新增 VARCHAR(24) / VARCHAR(16) | 旧作业归原主链,新作业区分四条流程 |
| job.input_id / input_version | 新增 VARCHAR(64) | 指向 Oracle 成功输入;ORDER_PUSH 指向本方导出批次 ID |
| job.source_line_id | 新增 VARCHAR(128),头级作业用空串 | PROCURE 对应一条销售明细;合同/返利对应头 |
| job.business_key | 复用 | 改为上述来源关联的展示/定位键;防重以组合唯一约束为准 |
| job.status / current_node_code | 复用 | 当前执行状态和恢复节点 |
| job.contract_system_no / rebate_order_no | 复用 | 合同 job 保存合同号,返利 job 保存返利总单/协议编号 |
| job.next_check_at | 复用 | 可重试失败或外部等待的下次处理时间 |
| job.execution_token / execution_host | 本期不新增 | 如现有状态中心已有执行占用机制则继续复用,否则按单实例运行约束处理 |
| job_node.attempt_no、status、output_json | 复用 | 每次尝试独立记录;输入引用、业务结果和外部编号 |
| job_node.started_at / ended_at / duration_ms | 复用 | 操作开始结束及耗时 |
| job_node.error_code / error_summary | 复用 | 可筛选错误分类和具体原因 |
| job_node.executor_run_id / callback_idempotency_key | 复用 | 一次脚本运行 ID、稳定回报键,重发回报不重复推进 |
本期按 `(flow_code, input_id, source_line_id)` 识别同一流程作业;已有系统若必须保留版本字段,可作为可选扩展,不因规则升级自动创建第二份作业。
共用数据成功接收后登记两份 job:CONTRACT 初始 PENDING,REBATE 初始 WAITING_EXTERNAL,原因为 WAIT_CONTRACT。同一 Oracle 输入即便登记调用重复,也只建立这一对作业。一次接收多组业务对象则逐头建立,不共享一个总完成标志。
旧列兼容迁移:保留 job_id、business_key、source_type、current_node_code、status、created_by 非空;supplier_name 和 order_date 改为允许空值,新来源数据有值时填入。旧入口继续校验其原必填,不给新作业补造供应商或日期。business_key 由新唯一键各项组合生成可读/哈希值;created_by 记录业务启动人或计划任务账号。
| flow | source_type | supplier_name / order_date | 首节点 |
| --- | --- | --- | --- |
| PROCURE | 本方定义 ORDER_CENTER | 供应商为空;日期取销售业务日(若提供) | PROCURE_ITEM_RUN |
| ORDER_PUSH | 明确的平台配置代码 | 文件批次可含多供应商,供应商为空;日期不强制单日 | ORDER_EXPORT_FILE |
| CONTRACT / REBATE | ORDER_CENTER | 接口明确提供的展示供应商及业务日期;合同三个日期仍取接口独立字段 | CONTRACT_CREATE / REBATE_HEADER |
source_type 上述取值是本方分类建议;药帮忙使用单独配置代码,不映射成旧 XYY。若任一新增标识超出现有列长度,迁移时扩列。旧 job 赋旧 flow/version 及唯一 input_id,新组合唯一键对全部历史行先回填再建立。
### 4.7 逐订单推送记录 `JD_ORDER_PUSH`(按现有表复用,缺少时再新增)
文件/操作日志不能替代按订单查询的处理标志,因此该表只保存推送控制与结果,不复制完整平台业务表。
| 字段 | 建议类型 | 用途 |
| --- | --- | --- |
| id | BIGINT PK | 推送记录 ID |
| platform / account_key / source_order_id | VARCHAR(32) / VARCHAR(64) / VARCHAR(128) | 来源订单唯一键,不按文件名去重 |
| source_order_no / job_id / artifact_id | VARCHAR(128) / VARCHAR(64) / BIGINT | 展示单号、首次导出 job、原始文件关联 |
| content_hash / request_key | VARCHAR(64) / VARCHAR(128) | 订单内容识别和本次固定请求标识 |
| push_status | VARCHAR(16) | PENDING、SENDING、ACCEPTED、REJECTED、UNKNOWN |
| request_body / response_body | JSON | 本次实际结构化发送内容、返回内容或所属批次引用 |
| receipt_no / error_code / error_message | VARCHAR(128) / VARCHAR(64) / VARCHAR(500) | 回执及失败原因 |
| attempt_count / next_retry_at | INT / DATETIME | 发送次数及重试时间 |
| created_at / last_sent_at / accepted_at | DATETIME | 关键时点 |
唯一键 `(platform, account_key, source_order_id)`;相同订单相同内容复用结果,内容变化不能覆盖旧成功记录,记录冲突交运维核对。每次发送详细历史写 job_event,最近状态保存在此表。多个文件含同一订单时,在各导出 job 的日志中记录对应 artifact_id 与台账 id。
接口若支持批量发送,固定请求键绑定整份实际请求体;同键重发内容不变。逐单成功的回执更新对应台账;只发失败子集时生成新请求键并保留与原批次关联。没有逐单回执时按对方整批接收语义记录,不凭本方猜部分成功。
## 5. 详细设计:标志字段与取数条件
### 5.1 状态来源
| 业务标志 | 保存/计算位置 | 何时改变 |
| --- | --- | --- |
| 接收是否成功 | IF_REQ.RECEIVE_STATUS | 当前接收请求完成时 |
| 作业是否登记 | IF_REQ.REGISTER_STATUS | 在 Oracle 中幂等建立本次所需 job 后 |
| 选品处理结果 | PROCURE job_node.output_json.result | 加购或正常跳过回报时:ADDED / SKIPPED |
| 合同保存状态 | CONTRACT_CREATE 节点状态及 result | 开始、保存成功、失败或结果未知回报 |
| 链路更新状态 | CONTRACT_CHAIN_UPDATE 节点状态及 result | 合同保存后调用订单中心,单独记录结果 |
| 返利状态 | REBATE job.status / current_node_code | 等合同、执行、失败、提交成功 |
| 平台订单接收状态 | JD_ORDER_PUSH.push_status | 每次发送和回执记录 |
运维接口返回 `contract_save_status / chain_update_status / rebate_status` 三列,是以上权威记录的汇总字段,**不再写回接口表形成第二套状态**。当前节点按 job 指针及该节点最新 attempt 取值,历史成功节点保留,不用整个合同 job.status 代替保存状态。
### 5.2 状态转换
| job 状态 | 含义 | 后续动作 |
| --- | --- | --- |
| PENDING | 已登记,等待执行 | 对应脚本领取 |
| RUNNING | 已被领取且正在执行 | 接收匹配 execution_token 的回报 |
| SUCCESS | 本流程应做步骤完成 | 常规取数不再领取 |
| FAILED_RETRYABLE | 明确失败,可重试当前步骤 | 到 next_check_at 后或运维重试,新增 attempt |
| WAITING_EXTERNAL | 前置尚未完成或结果待核对 | 按 wait_reason 处理,不能一律自动重做 |
| WAITING_USER / FAILED_MANUAL | 业务或运维需介入 | 在原待办上处理、核对并记录动作 |
| CANCELLED | 明确停止办理 | 保留原数据和历史 |
wait_reason 建议保存在 job 新增 VARCHAR(32) 字段:WAIT_CONTRACT、PROTOCOL_NOT_READY、RESULT_UNKNOWN。前两种下次检查条件;RESULT_UNKNOWN 需要核对外部结果,确认失败才可重试。
节点复用 PENDING/RUNNING/SUCCESS/FAILED,未知结果用节点 `WAITING_EXTERNAL` 并在 output_json 记 `result=UNKNOWN`;需要新增相应前后端状态支持。未尽调转人工保留待办,不能把“停止自动办理”显示为“合同已保存”。
```mermaid
stateDiagram-v2
[*] --> PENDING
PENDING --> RUNNING: 领取
RUNNING --> SUCCESS: 完成
RUNNING --> FAILED_RETRYABLE: 明确可重试失败
RUNNING --> WAITING_EXTERNAL: 前置等待或结果未知
RUNNING --> WAITING_USER: 业务处理
FAILED_RETRYABLE --> PENDING: 重试当前步骤
WAITING_EXTERNAL --> PENDING: 前置满足或核对未成功
WAITING_EXTERNAL --> SUCCESS: 核对本流程已完成
WAITING_USER --> PENDING: 处理后恢复未完成步骤
PENDING --> CANCELLED: 停止办理
```
*中文对照:`PENDING`=待执行,`RUNNING`=执行中,`SUCCESS`=成功,`FAILED_RETRYABLE`=可重试失败,`WAITING_EXTERNAL`=等待外部结果,`WAITING_USER`=等待人工处理,`CANCELLED`=已取消。*
上图为通用 job 状态。合同保存结果核对成功时若链路尚未更新,实际转移到链路节点 PENDING,不能直接标整个合同 SUCCESS。
### 5.3 领取规则
| flow | 读取范围及条件 | 成功/失败记录 |
| --- | --- | --- |
| PROCURE | SALES 数据 READY;分别按 1 号药城、药帮忙的平台配置领取;行 job=PENDING,或到期可重试 | 两个平台使用同一套采购条件;合格 ADDED、不合格 SKIPPED 均结束行 job;错误单独失败 |
| ORDER_PUSH | 对应平台导出任务及历史未完成台账;ACCEPTED 不重发 | 归档文件及每单 push_status |
| CONTRACT | ORDER_COMMON 数据 READY;合同 job 待执行,按 current_node 恢复 | CREATE 成功保存合同号;CHAIN 失败只重试 CHAIN |
| REBATE | 同 INPUT_ID/版本的 CREATE 节点确认 SUCCESS,合同号非空;返利待执行或到期重试 | 从共用接口表读取返利字段;读取合同 job 合同号;提交后 SUCCESS |
新录入与重试均覆盖历史未完成记录,不只筛选当天。创建返利 job 时可处于 WAIT_CONTRACT,合同失败不改它的接口输入;合同成功后,返利下一次扫描将其转 PENDING。
领取使用现有状态中心的领取机制;同一条记录同一时刻只允许一个脚本处理。脚本中断后由运维确认并恢复,不自动启动第二个执行器。
### 5.4 节点定义与订单导出生命周期
| flow | 节点链 | 正常结束条件 |
| --- | --- | --- |
| PROCURE | PROCURE_ITEM_RUN | 本行 ADDED 或 SKIPPED |
| ORDER_PUSH | ORDER_EXPORT_FILE → ORDER_CENTER_PUSH | 文件完整归档后,解析及推送该批应发送订单,均确认接收 |
| CONTRACT | CONTRACT_CREATE → CONTRACT_CHAIN_UPDATE | 保存及链路更新分别成功;未尽调为当前节点人工待办,不另建供数流程 |
| REBATE | REBATE_HEADER → REBATE_IMPORT → REBATE_SUBMIT | 正常提交成功;首次领取前检查合同保存条件 |
ORDER_PUSH 脚本先领取历史未完成批次;再按当前排期需要产生 export_batch_id 并登记新 job(input_id=export_batch_id,input_version=1,source_line_id=空串)。新 job 从 ORDER_EXPORT_FILE 领取执行。导出失败保留该 job;已保存文件和路径写 job_artifact,node_code=ORDER_EXPORT_FILE,恢复时先查已有归档文件。
归档成功后推进 ORDER_CENTER_PUSH:解析文件、按源订单建立/复用 JD_ORDER_PUSH、保存固定请求并推送。本批所有源订单到台账 ID 的关联写入该 job 的节点 output_json(大批量可用 artifact 保存清单),因此同一台账复用于多个文件时仍可判断每个批次是否完成。首次 job_id/artifact_id 不覆盖,后续文件关联由各批次记录持有。
源订单均为 ACCEPTED,或本轮确无可推送订单时,批次 job SUCCESS;零数据结果记 NO_DATA 并显示零条,不创建空订单。部分失败保留当前推送节点,已接收项跳过;同订单不同内容记冲突,阻止该批盲目完成,交运维核对。脚本退出与该批次 job 成功是两件事。
## 6. 详细设计:接口与交互时序
### 6.1.1 平台订单推送中的对码原始数据
平台订单推送给订单中心时,RPA 不传本方判断出的货品编码作为对码结论,而是传订单中心完成判断所需的原始订单信息。每条订单明细至少需要保留并按正式接口字段映射传递:
| 数据组 | 建议字段 | 用途 |
| --- | --- | --- |
| 来源标识 | 平台代码、平台账号、源订单号、父订单号(如有)、源订单明细号、导出批次号 | 订单中心识别来源、拆单/明细及幂等关系 |
| 商品原始信息 | 平台商品编码(如有)、平台品名、批准文号、通用名/商品名称、规格、生产厂家、单位 | 订单中心进行货品对码;平台没有的字段传空并按接口规则处理 |
| 采购信息 | 实际采购数量、采购单价、订单金额、订单日期、订单状态 | 形成标准采购订单并供后续合同数据使用 |
| 商家及追溯 | 平台商家/供应商原始标识、仓库、文件名、文件哈希、原始行号 | 追溯和订单关联;文件信息作为来源追踪,不替代业务订单键 |
其中,批准文号、平台品名、通用名/商品名称、规格、生产厂家、单位属于“给订单中心判断用的原始值”,不代表 RPA 已经完成对码。平台订单接口的正式必填字段、字段名称、枚举和缺项规则由订单中心确认;不能只传一个批准文号、数量和金额就宣称对码数据完整。
订单中心处理后至少应能按源订单号/源明细号返回或在合同共用接口数据中提供:标准货品编码、标准货品名称、注册证号、规格、单位,以及合同/返利所需的采购数量、单价、货源细单号等字段。返回方式可以是订单接收后的后续推送或双方约定的查询接口,但必须保持与源订单明细的关联。本方只使用该标准结果填合同和返利,不在本地补做对码。
### 6.1 接口清单
| 接口/方向 | 状态 | 主要契约 |
| --- | --- | --- |
| `POST /api/jieda/inbound/sales` | 本方拟新增 | 完整销售对象,接收校验后落表 |
| `POST /api/jieda/inbound/order-data` | 本方拟新增 | **一次传入合同及返利共同需要的数据**,返回接收记录 ID 和 INPUT_ID |
| `POST /api/flows/{flow_code}/jobs/claim` | 复用或按现有方式实现 | 按流程领取待处理作业并返回输入关联 |
| `POST /api/flows/ORDER_PUSH/jobs` | 本方拟新增 | 导出前按 export_batch_id 幂等登记平台/账号/范围及 job,导出失败也有作业可查 |
| `GET /api/jobs/{job_id}/input` | 本方拟新增 | 对应接口头明细;返利额外返回关联合同 job 及合同号 |
| `POST /api/jobs/{job_id}/nodes/{node}/{action}` | 复用 start/complete/fail | 保存结果、编号和错误原因 |
| `POST /api/jobs/{job_id}/push-records` | 按现有回报方式实现 | 保存订单推送结果;不强制新增独立接口 |
| `POST /api/jobs/{job_id}/retry` | 复用并修正 | 只恢复当前失败步骤,记录操作者及原因 |
| 平台订单接收 API(本方→订单中心) | 外部契约待提供 | 来源订单、对码原始字段、数量金额及实际接收回执 |
| 链路节点更新 API(本方→订单中心) | 外部契约待提供 | 来源链路关联、对应节点、已保存合同标识及业务结果 |
订单推送由 ORDER_PUSH 脚本、链路更新由 CONTRACT 脚本通过统一客户端调用。调用前通过状态中心记录固定请求,调用后保存回执。状态中心不同时后台发送同一请求。外部正式路径、业务枚举和鉴权由订单中心提供。
### 6.2 请求、校验及重复处理
接收报文在本方适配层统一表示为 `message_id + objects[]`。每个对象有 source_doc_id、input_version(可选)、header、lines。ORDER_COMMON 的 header/lines 包含第 4.5、7 章共同字段,不是分别推 contract 和 rebate。
| 返回情况 | 本方建议 HTTP | 业务返回及持久化 |
| --- | --- | --- |
| 缺字段/格式不合法/空明细 | 422 | REJECTED,errors 含对象标识、行标识及字段路径;无业务头明细 |
| 全部校验通过且保存成功 | 200 | ACCEPTED、request_log_id、input_ids;表示接收成功 |
| 已接收同消息同内容 | 200 | DUPLICATE,返回原 input_ids,另记接收尝试 |
| 已接收同标识不同内容 | 409 | CONFLICT,旧成功输入及作业不变 |
| 保存失败 | 503 | STORE_FAILED+trace_id;不返回接收成功 |
| 鉴权失败 | 401/403 | 拒绝,不建立业务作业 |
首版整次请求校验通过后,同一个 Oracle 事务保存成功接收记录及全部对象头明细。任一必填缺失则整次失败,返回全部可识别字段错误;上游可按完整单据分批发送。缺的是返利字段也当次返回,因为这一份数据承担两个流程。
客户类型、三个合同日期、合同明细字段,以及供应商/返利单位/计划员/订单号/货源细单号/含税协议单价在本次共用数据中按第 7 章校验。合同号是本方执行结果,接收时允许不存在。固定值、系统生成字段、页面尽调回显不要求上游提供。
相同业务对象换 message_id 再传,由头表来源+版本唯一键识别:内容相同引用原 INPUT_ID,内容不同冲突。没有源版本号时,哈希只判断内容同异,不自动判定“哪条更新”。已成功接收的同 SOURCE_DOC_ID 改内容或改版本,本期返回 INPUT_CONFLICT,不自动创建第二组合同/返利 job;这是按业务标识检查,不能仅依赖版本唯一索引。运维和订单中心确认改单处理后再另行安排,原作业和结果保持可查。此前校验失败的修正重传可正常接收。
接收失败日志存 IF_REQ;Oracle 本身不可用时只能写应用错误日志和 trace_id,不能宣称数据库必有失败记录。响应在事务提交后产生;若响应丢失,上游重传可取得原接收结果。
### 6.3 时序图:订单中心接收共用数据
订单中心推送完成后只表示本方已接收并保存数据,合同、返利由各自排期执行。
```mermaid
sequenceDiagram
participant OC as 订单中心
participant API as 接口服务
participant ORA as Oracle
OC->>API: 推送合同返利共用数据
API->>API: 校验必填字段
alt 校验失败
API->>ORA: 保存失败记录
API-->>OC: 失败及字段位置
else 校验通过
API->>ORA: 保存接口数据
ORA-->>API: 返回 INPUT_ID
API-->>OC: 返回接收成功
end
```
### 6.4 时序图:平台订单导出并推送
```mermaid
sequenceDiagram
participant R as 订单脚本
participant P as 采购平台
participant F as 共享文件夹
participant OC as 订单中心
participant ORA as Oracle
R->>P: 导出订单文件
P-->>R: 返回订单文件
R->>F: 保存平台订单文件
R->>ORA: 保存文件和订单记录
R->>OC: 推送订单及对码原始字段
alt 接收成功
OC-->>R: 返回接收结果
R->>ORA: 保存推送成功
else 保存失败或接口失败
R->>ORA: 保存失败原因
end
```
订单中心接收平台订单和对码原始字段,订单中心内部完成对码,并在后续共用数据中提供标准货品结果。
### 6.5 时序图:合同排期处理
```mermaid
sequenceDiagram
participant C as 合同脚本
participant ORA as Oracle
participant CT as 合同系统
participant OC as 订单中心
C->>ORA: 按合同排期领取待处理数据
ORA-->>C: 返回共用数据
C->>CT: 录入并保存合同
CT-->>C: 返回合同号
C->>ORA: 保存合同号和合同结果
C->>OC: 更新订单链路
OC-->>C: 返回链路结果
C->>ORA: 保存链路结果
```
合同保存和链路更新分别记录。链路失败时保留合同号,后续只重试链路更新,不重新创建合同。
### 6.6 时序图:返利排期处理
```mermaid
sequenceDiagram
participant R as 返利脚本
participant ORA as Oracle
participant RB as 返利中心
R->>ORA: 按返利排期领取待处理数据
ORA->>ORA: 检查同 INPUT_ID 的合同结果
alt 合同未保存成功
ORA-->>R: 暂不处理,保留等待状态
else 合同已保存且合同号存在
ORA-->>R: 返回共用数据和合同号
R->>RB: 查询协议并提交返利
RB-->>R: 返回办理结果
R->>ORA: 保存返利结果
end
```
四张图之间的关系是:第一张完成数据接收,第二张完成平台订单推送,第三张和第四张由各自排期独立运行。合同和返利通过同一个 `INPUT_ID` 关联。
## 7. 详细设计:业务字段映射
本章保留 Excel 的实际字段和行号。合同/返利列来源都指向第 4.5 节同一份接口输入;按各自模板生成执行数据。生成的模板、默认日期及映射版本记录到节点 output_json/文件证据,不再复制成独立合同或返利输入表。
### 7.1 合同头
| 目标字段 | 取值规则 | 行号 |
| --- | --- | --- |
| 客户名称 | 订单中心 | 12 |
| 客户编码 | 目标系统自动生成 | 11 |
| 尽调情况 | 读取回显;未尽调停止本单自动录入并转采购员处理 | 13 |
| 结算类型 | 收支双向 | 15 |
| 合同版本类型 | 预付:我司定制;账期:非格式合同 | 17 |
| 合同金额(单位元) | 预估本次合作金额 | 24 |
| 金额(元) | 明细汇总;与导入后页面汇总核对并回填 | 25 |
| 合同签订日期、合同开始日期、合同结束日期 | 订单中心分别提供,三个独立字段 | 26–28 |
| 是否重大合同 | 否 | 29 |
| 是否预付款 | 预付:是;账期:否 | 30 |
| 付款方式 | 电汇 | 32 |
| 付款期限 | 预付:1;账期:订单中心账期天数,缺失不默认 30 | 33 |
| 合同具体分类 | 国内药品类、器械类品种 | 37 |
| 运输方式 | 公路运输 | 39 |
### 7.2 合同明细
| 目标字段 | 来源/规则 | 行号 |
| --- | --- | --- |
| 货品编码 | 订单中心 | 43 |
| 货品名称 | 订单中心标准货品名称 | 44 |
| 注册证号 | 订单中心 | 46 |
| 规格、单位 | 按订单中心标准字段填报 | 47–48 |
| 数量、单价 | 按订单中心最终采购数量、单价填报 | 49–50 |
| 付款方式 | 电汇 | 52 |
| 付款条件 | 订单中心;模板无此字段时不填 | 53 |
| 付款期限 | 同合同头规则 | 54 |
### 7.3 返利总单
| 目标字段 | 来源/规则 | 行号 |
| --- | --- | --- |
| 返利合约 | 否 | 5 |
| 供应商编码 | 订单中心 | 6 |
| 返利单位编码 | 订单中心 | 7 |
| 计划员 | 订单中心,按业务给定的计划员/采购员对应关系填写 | 8 |
| 采购结算方式 | 文案“否”;与细单代码 3 分别映射 | 9 |
| 承付模式 | 下月 N 号结 | 10 |
| 协议开始日期、协议结束日期 | 当天 | 11–12 |
### 7.4 返利细单
| 目标字段 | 来源/规则 | 行号 |
| --- | --- | --- |
| 货品编码、数量、含税协议单价 | 本次共用接口数据的货品编码、数量及含税协议单价 | 15–17 |
| 税率、关税税率 | 均为 0.13 | 18–19 |
| 承付模式、承付参数 | 11、25 | 20–21 |
| 细单开始日期 | 当天 | 22 |
| 细单结束日期 | 本年度最后一天 | 23 |
| 采购结算方式 | 3 | 24 |
| 特定补偿预估值、进货补偿预估值 | 均为 0 | 25–26 |
| 细单备注 | 订单中心订单号 | 33 |
| 货源细单ID | 订单中心货源细单号;与订单号分列 | 34 |
合同明细导入列按 Excel 的 Sheet3。表中标注无需填写的字段不填;系统自动生成字段由系统处理。
### 7.5 映射与运行日期规则
- 合同与返利的数量来自同一次报文;实际只提供一列数量时共用该列。含税协议单价与采购单价分别保留来源映射。
- 首次执行该流程时生成所用默认日期及模板记录。重试已生成的相同步骤使用原输入,避免跨日重试更改已导入单据;新返利作业按返利首次执行当天生成“当天/年底”。这是实现规则,不增加业务确认步骤。
- 合同客户编码为系统生成字段。现有 RPA 入口要求的 customerCode 实际用作客商搜索标识,须与系统自动生成的客户编码分清;按新页面取数方式适配,不要求上游为自动生成字段填占位符。
- 映射表标注无需填写的字段不填。尽调读取页面回显;未尽调按原流程转采购员并通知。接口数据保留,人工完成后核对实际合同号并登记保存成功,进入链路更新步骤;返利按第 5.3 节条件领取。
## 8. 详细设计:操作记录与一致性
### 8.1 最少需要记录的操作信息
| 场景 | 保存位置 | 必须能查到的信息 |
| --- | --- | --- |
| 每次接口接收 | IF_REQ | 请求时间、来源消息、INPUT_ID 关联、原报文、返回结果、字段错误、trace_id |
| 脚本执行一步 | job_node | flow、job、INPUT_ID/版本、节点、attempt、run_id、执行机、开始结束、结果、错误 |
| 平台订单发送一次 | JD_ORDER_PUSH+job_event | 原文件、源订单、固定请求键、实际请求、HTTP/业务返回、耗时、成功/拒绝范围 |
| 合同保存 | job_node+job_artifact | 实际合同标识、保存确认、目标页面证据、所用输入和模板 |
| 链路调用一次 | job_event+链路 job_node | 链路关联、节点代码、合同号、请求键、报文、回执、错误 |
| 运维操作一次 | job_event / user_task | 操作人、时间、动作、原因、前后状态、证据和操作结果 |
| 文件 | job_artifact | 原始订单、导入模板、截图或日志文件路径,文件类型及哈希 |
job_event 建议事件类型包括 INPUT_BOUND、NODE_STARTED、NODE_SUCCESS、NODE_FAILED、API_REQUEST、API_RESPONSE、RETRY_REQUESTED、RESULT_VERIFIED、MANUAL_HANDOFF、JOB_FINISHED。detail_json 保存结构化信息。节点列表用 job_node.error_code/error_summary 筛选及展示;事件详情从 detail_json.errorCode/errorMessage 读取错误,不假定 job_event 存在独立错误列。详细错误与报文在详情展开。
接收失败没有 job 时由 IF_REQ 独立展示;所有能定位到作业的动作都写 job_event。脚本运行 ID 使用现有 executor_run_id;无数据或启动失败的本轮摘要写应用日志,通过 run_id 查询,暂不新增运行历史表。
### 8.2 事务及恢复顺序
| 写入过程 | 事务范围 | 恢复办法 |
| --- | --- | --- |
| 接收成功 | Oracle:成功请求记录+所有接口头明细 | 事务失败整体返回失败;响应丢失可同消息重传 |
| 登记作业 | Oracle:同输入的合同和返利 job 一起幂等建立 | 成功后回写 IF_REQ.REGISTER_STATUS;回写失败下次重复登记返回同 job,再补 DONE |
| 节点成功 | Oracle:该 attempt 成功+业务编号+操作日志/证据索引+推进当前节点 | 回报丢失重发同完成键;同成功结果返回原状态,不再次推进 |
| 外部接口调用 | 先 Oracle 保存请求,事务提交后调用,返回后独立事务记结果 | 超时保留 UNKNOWN,不把网络调用放在数据库事务中 |
| 人工恢复 | Oracle:核查结果、状态变更和操作日志 | 同一操作请求 ID 重复提交返回原结果 |
接口输入和业务执行结果均在 Oracle 中保存,登记、领取、结果回报使用同一数据库事务边界;外部接口调用仍在事务提交后执行。接口表登记失败时可再次幂等登记,组合唯一约束保证不多建合同/返利作业。
### 8.3 文件和配置
1 药城与药帮忙分别配置共享根目录和字段映射;现场路径由配置提供。下载完成后发布文件,文件名按批次区分,保留原件。后台返回文件列表,业务/运维通过现有证据入口打开或下载,不要求共享盘在所有机器使用同一盘符。
订单中心连接、凭据、超时、链路节点映射、四条排期、重试间隔及模板配置集中管理。真实环境缺必要配置返回明确错误,不自动使用 fixture。日期、数量和金额按第 4、7 章数据类型保存,日志不保存 token 或数据库密码。
## 9. 详细设计:正常路径与异常处理
### 9.1 正常路径示例
本例标识仅用于说明字段关系,不代表正式接口报文。
| 时间/动作 | 接口记录 | 合同保存 | 链路更新 | 返利 | 记录位置 |
| --- | --- | --- | --- | --- | --- |
| 接收 M001 | ACCEPTED,INPUT_ID=I001,REGISTER=PENDING | 未登记 | 未登记 | 未登记 | IF_REQ+ORDER_H/L |
| 任一相关脚本启动登记 | REGISTER=DONE | PENDING | 未开始 | WAIT_CONTRACT | 两个 job 都绑定 I001/1 |
| 合同脚本处理 | 原输入不变 | RUNNING | 未开始 | WAIT_CONTRACT | CREATE 第 1 次尝试 |
| 合同保存成功 HT001 | 原输入不变 | SUCCESS | PENDING | 可领取 | 合同号及保存证据 |
| 更新链路成功 | 原输入不变 | SUCCESS | SUCCESS | 可领取 | 链路请求、回执;合同 job 完成 |
| 返利下次运行 | 原输入不变 | SUCCESS | SUCCESS | RUNNING | 读取 I001 同一头明细+HT001 |
| 返利提交完成 FL001 | 原输入不变 | SUCCESS | SUCCESS | SUCCESS | 返利编号、模板及提交结果 |
### 9.2 异常矩阵
| 异常代码(建议) | 识别条件 | 状态和保留内容 | 处理及恢复位置 |
| --- | --- | --- | --- |
| INPUT_REQUIRED | 本次共用数据缺合同或返利上游必填 | IF_REQ=REJECTED,字段路径/行号,无业务输入 | 上游补齐后重传;RPA 无需处理 |
| INPUT_CONFLICT | 成功来源/版本再传不同内容 | CONFLICT,关联旧请求、保存新原文 | 核对来源及版本;不覆盖已使用数据 |
| STORE_FAILED | 接口表事务失败 | 返回 503;应用日志 trace_id | 修复数据库后重传 |
| JOB_REGISTER_FAILED | Oracle 已接收,作业登记失败 | REGISTER=FAILED、原因;输入 READY | 修复数据库或服务后,下一次脚本取数补登记 |
| PLATFORM_QUERY_FAILED | 页面打不开或查询报错 | 行 job 明确失败,截图/错误 | 修复后重试该行;不记 SKIPPED |
| NO_ELIGIBLE_PRODUCT | 查询成功但不满足采购条件 | job SUCCESS,result=SKIPPED、原因 | 正常结束,常规轮询不重复处理 |
| FILE_SAVE_FAILED | 共享目录不可写/文件未完整 | ORDER_PUSH 失败、已知路径 | 修复目录后从导出/归档失败处继续 |
| OC_ORDER_REJECTED | 订单中心明确业务拒收 | push=REJECTED,拒绝原因及范围 | 修正接口映射/源数据后处理失败单,成功单保留 |
| EXTERNAL_RESULT_UNKNOWN | 加购、保存、推送或提交后无确定结果 | WAITING_EXTERNAL/UNKNOWN,请求或页面证据 | 先核对目标结果;确认未成功才重试 |
| CONTRACT_DUE_DILIGENCE | 合同页面显示未尽调 | WAITING_USER、采购员待办和页面记录 | 按原业务人工处理,核验保存结果后继续链路 |
| CHAIN_UPDATE_FAILED | 合同已保存,链路明确失败 | CREATE 成功;CHAIN 失败、回执 | 下次仅重试 CHAIN;合同号不清空 |
| PROTOCOL_NOT_READY | 已有合同号,但返利协议不可查/暂不可操作 | 返利 WAITING_EXTERNAL,原因及检查时间 | 到期再查协议;不重建合同,不等第二次供数 |
| REBATE_SUBMIT_FAILED | 返利系统明确提交失败 | 失败步骤、目标协议/总单、已导入信息 | 按原业务交人工处理;保留前面成功步骤 |
| EXECUTOR_INTERRUPTED | 作业仍 RUNNING,进程已退出 | 执行机/run_id、最近节点、证据 | 运维确认无运行进程并核对结果后,恢复当前步骤 |
所有正常失败恢复保留原 INPUT_ID、job 和历史 attempt。通用“重试”只增加当前失败节点尝试,不把 job 从第一个节点重新执行。已完成外部写入不能仅凭“失败”按钮反向当作没有发生。
## 10. 运维设计:如何查看与处理
### 10.1 查询入口及页面内容
复用现有列表、节点详情、时间线、待办和证据组件,新增接收列表和“同一输入的合同/返利总览”,不用另建运维平台。
| 页面 | 筛选条件 | 列表/详情内容 |
| --- | --- | --- |
| 接口接收列表 | 时间、来源单号、MESSAGE_ID、TRACE_ID、接收状态、登记状态 | 请求/返回原文、缺字段路径、INPUT_ID、重复来源、登记失败原因 |
| 合同返利总览 | INPUT_ID、来源单号、合同号、返利号、三个业务状态、时间 | 同一行显示合同保存、链路更新、返利状态及最近错误;点击进入各 job |
| 作业详情 | flow、job_id、状态、执行机、run_id | 来源输入、当前节点、每次尝试、耗时、错误、回报、外部编号 |
| 订单推送列表 | 平台/账号、订单号、导出批次、push_status | 原文件、发送内容、回执、次数、结果未知/拒收原因 |
| 操作和证据 | job/INPUT_ID、操作者、动作时间 | 谁点击重试/人工处理,前后状态、原因、截图、模板及日志 |
“合同返利总览”由后端以 Oracle INPUT_ID 为基底,关联两份 job/节点结果;不存在 job 时显示未登记。分页/筛选由后端实现,不要求浏览器一次拉全表。
### 10.2 典型查看路径
| 运维问题 | 如何查 | 结论/下一步 |
| --- | --- | --- |
| 订单中心说推过,RPA 没处理 | 先查 MESSAGE_ID/来源单号的接收记录 | REJECTED 看缺字段;ACCEPTED+登记失败看数据库错误;DONE 看对应 job |
| 合同存在,为什么流程显示失败 | 总览看合同保存和链路更新两列 | 保存成功/链路失败只补链路调用,不重建 |
| 返利为什么一直没开始 | 看同 INPUT_ID 的合同保存结果及返利 wait_reason | 合同未保存则正常等待;保存成功但协议不可查则按目标状态处理 |
| 同一批有的成功有的失败 | 订单推送按源订单筛选,选品按行 job 查看 | 重试失败项;已接收/已加购项不重复处理 |
| 数据被改过或重复推过 | 接收尝试记录+DATA_HASH+操作日志 | 明确首次输入、重复/冲突报文及处理人 |
### 10.3 运维动作及权限
| 动作 | 允许条件 | 实际改变 | 必须留痕 |
| --- | --- | --- | --- |
| 重试当前步骤 | FAILED_RETRYABLE,且外部结果已明确 | 当前节点新增 attempt,job 转 PENDING | 操作人、原因、旧状态、新 attempt |
| 重新登记作业 | 已 ACCEPTED 且 REGISTER≠DONE | 幂等补建缺失 job,完成登记标志 | 接收记录、结果、操作者 |
| 核对结果:已成功 | UNKNOWN 或人工完成,提供实际业务编号/回执证据 | 确认对应节点成功并推进;合同保存后仍执行链路 | 目标编号、证据、核对人和时间 |
| 核对结果:未成功 | 确认目标未写入,原执行进程已停止 | 转可重试当前步骤 | 核对依据、操作者、原因 |
| 转业务处理 | 业务错误或系统要求人工 | 创建/复用现有 user_task,保存责任人 | 原因、接收人、操作记录 |
| 停止办理 | 有明确业务指令 | CANCELLED,保留已产生结果 | 原因及外部已发生操作,不自动撤销外部单据 |
只读运维可看列表及证据;恢复动作限定 OPS,业务待办按采购员等现有角色处理。恢复入口必须核对实际操作者身份,不能只相信请求体里填写的 operator。沿用现有鉴权方案补动作权限,不另加业务审批流。
不能用“统一确认并继续”替代所有核对动作,不能直接改接口原始字段或数据库状态。金额、货品等源数据错误由订单中心修正重传;已被使用的输入按冲突规则处理。
### 10.4 查询 API 及日志定位
| API(建议) | 用途 |
| --- | --- |
| `GET /api/jieda/receipts`、`/receipts/{request_log_id}` | 无 job 的失败接收也能查 |
| `GET /api/jieda/orders`、`/orders/{input_id}` | 同一输入的合同/链路/返利总览及头明细 |
| `GET /api/jieda/order-pushes` | 按源订单查推送及文件 |
| 现有 `GET /api/jobs/{job_id}`、`/timeline`、`/evidence-package` | 节点、历史尝试和证据 |
| `GET /api/jobs/{job_id}/events`(新增) | 操作日志和出站请求/返回记录 |
| `POST /api/jieda/receipts/{id}/register`(新增) | 运维手动补登记,与脚本取数复用同一逻辑 |
| `POST /api/jobs/{job_id}/verify-result`(新增) | 按第 10.3 节核对 UNKNOWN/人工结果,记录依据后恢复 |
应用日志统一携带 `trace_id、input_id、job_id、flow_code、node_code、attempt_no、run_id` 中当前已知项。接收阶段没有 job_id 时不编造;执行阶段没有 HTTP trace 时以 job/run 定位。列表展示最近错误,详情保留全部历史,不只覆盖最后一次失败。
索引建议:接口记录按时间/状态/来源,业务头按源单号,job 按 `(flow_code,status,next_check_at)` 和 `(input_id,input_version)`,节点按现有 `(job_id,node_code,attempt_no)`,事件按 `(job_id,created_at)`,推送按来源唯一键及 `(push_status,next_retry_at)`。
## 11. 实施影响与验收
### 11.1 现有代码到新设计的映射
| 现有位置 | 已核对现状 | 实施改动 |
| --- | --- | --- |
| [main.py 建单](/Users/yuuuu/Documents/AutoRpa/state_center/app/main.py:499) | 供应商+日期键,固定首节点,只复用未完成作业 | 新接收/登记/领取按 flow 和 INPUT_ID;已完成作业也防重 |
| [schema.sql](/Users/yuuuu/Documents/AutoRpa/state_center/sql/schema.sql:11) | job 无 flow/input 关联;已有节点、日志及证据表 | 第 4 章 Oracle 接口表及推送台账;job 扩展字段/索引,supplier_name/order_date 允许空;job_event 错误保存在现有 detail_json |
| [db.py](/Users/yuuuu/Documents/AutoRpa/state_center/app/db.py:22) | 当前数据库访问实现 | 按 Oracle 驱动和事务方式调整登记、领取、结果及推进 |
| [main.py 完成回报](/Users/yuuuu/Documents/AutoRpa/state_center/app/main.py:712) | 保存节点、编号和推进分步执行;节点定义全局固定 | 同事务回报,校验 execution_token/attempt;按 flow 定义完成规则 |
| [main.py 重试](/Users/yuuuu/Documents/AutoRpa/state_center/app/main.py:812) | 已保留 attempt 历史、重试当前节点 | 保留机制,支持共用输入关联及 UNKNOWN 核对 |
| [main.py 人工提交](/Users/yuuuu/Documents/AutoRpa/state_center/app/main.py:1346) | 通用确认可直接推进;RETRY_CURRENT_NODE 分支当前只记录事件 | 按第 10.3 节校验结果和动作,实现真实重试及核查恢复 |
| [agent.py](/Users/yuuuu/Documents/AutoRpa/rpa_yisaiqi/agent.py:1) | 通用代理主要调用 mock;存在节点硬编码与默认 attempt=1 | 四条独立入口、真实 runner、领取结果中的 attempt/token |
| [contract_create_node.py](/Users/yuuuu/Documents/AutoRpa/rpa_yisaiqi/contract_create_node.py:241) | 已校验空明细及正数量,仍要求旧 customerCode | 适配共用接口数据和客商定位;保留有效输入检查 |
| [contract_flow_steps.py](/Users/yuuuu/Documents/AutoRpa/rpa_yisaiqi/contract_flow_steps.py:788) | 缺价补 1.00,同码首行及前 30 行定位 | 移除缺价补值;按来源明细/目标行定位并核对实际导入结果 |
| [Web 节点详情](/Users/yuuuu/Documents/AutoRpa/state_center_web/src/components/JobNodeDetailList.tsx:1) | 已展示时间、尝试次数、错误及证据 | 复用组件,补三个独立状态、输入关联、接收列表及恢复动作 |
当前 Oracle 配置主要服务货品主数据读取;新接口表写入连接和权限需现场配置。当前代码不等于已具备四条真实平台执行能力,平台查询/导出及返利页面模块需要核对或实现。
### 11.2 实施顺序
1. 按共同输入契约核对第 4、7 章字段及合同/返利范围;确认现场接口表复用方式。
2. 完成接收校验、Oracle 同事务保存及接收查询,验证失败重传和重复消息。
3. 增加 flow/input 关联、作业登记、独立状态、领取及结果事务,跑通“同一输入两个 job”。
4. 接平台订单导出、共享文件、台账及推送回执。
5. 接合同页面保存和链路更新;返利读取同 INPUT_ID 与合同号完成提交。
6. 接逐条选品;补齐异常及运维动作,配置四条排期后联调。
### 11.3 验收用例
| 编号 | 场景 | 预期结果 |
| --- | --- | --- |
| AT-01 | 一次完整 ORDER_COMMON 推送 | 一份头明细;登记两个相同 INPUT_ID/版本的合同、返利 job |
| AT-02 | 仅返利必填缺失 | 当次返回字段错误,无业务输入;补齐后可重传 |
| AT-03 | 相同请求重发、相同对象换消息 ID 重发 | 返回原输入,不新增执行作业 |
| AT-04 | 同已成功来源/版本传不同内容 | 冲突可查,旧数据和外部结果不被覆盖 |
| AT-05 | 接收已提交,作业登记失败 | 接收记录显示登记失败;后续幂等补登记,不要求上游再推 |
| AT-06 | 返利脚本先于合同脚本启动 | WAIT_CONTRACT;无额外供数请求、无返利提交 |
| AT-07 | 合同保存成功,链路更新失败 | 保存 SUCCESS、链路 FAILED;只恢复链路,返利可按保存条件领取 |
| AT-08 | 合同完成后返利按另一排期启动 | 读取同一接口数据和本方合同号,字段来源可追溯 |
| AT-09 | 平台订单重复导出到不同文件 | 原件保留,已接收同源订单不重复入单 |
| AT-10 | 推送部分成功/超时 | 按实际回执记录逐单结果;UNKNOWN 不直接按失败重发 |
| AT-11 | 选品合格/不合格/查询失败 | 分别 ADDED、SKIPPED、FAILED;本批结束,不等付款 |
| AT-12 | 脚本中断和重复点击 | 不重复领取;已有结果可查,确认停止并核对后恢复当前步骤 |
| AT-13 | 合同已保存但回报丢失 | 重发完成键或核对目标结果,不再次建合同 |
| AT-14 | 未尽调或返利提交失败 | 保留输入及原业务待办,其他记录继续;人工结果核对可留痕 |
| AT-15 | 46 个 Excel RPA 字段 | 共用接口来源、固定值及日期按映射填报,缺价不补 1.00 |
| AT-16 | 运维按 MESSAGE_ID、INPUT_ID、合同号查询 | 能串起接收、两流程、尝试、文件、请求和人工动作 |
| AT-17 | 未授权恢复、旧 token 回报 | 拒绝写入;旧成功结果不被覆盖 |
| AT-18 | 新旧流程并存 | 旧在途单保留原节点;新流程不误入对码/OCR/M5 |
文档检查与业务验收分开:当前只完成设计;以上需要在测试数据库、实际共享目录及外部测试系统执行,mock 成功不代表真实业务通过。
## 12. 未决事项、风险及对其他模块的影响
### 12.1 联调前需要确认
| 项目 | 已明确 | 剩余信息 |
| --- | --- | --- |
| 合同返利供数 | 一次推送共用 | 正式头明细、业务范围标识、消息 ID/版本;一组数据是否有多合同/多返利范围 |
| 业务字段 | Excel 来源及默认值 | 实际 JSON 列名;采购/返利数量是否同字段,价格口径及精度 |
| 订单出站接口及对码字段 | 本方归档后推送原始订单字段 | 确认批准文号、品名、通用名、规格、厂家、单位等字段是否必填,以及标准货品结果的返回/后续供数方式 |
| 链路更新 | 合同保存成功后更新对应节点 | 节点代码、链路关联、合同标识字段及成功返回码 |
| 返利可操作性 | 用同一份输入加已保存合同号 | 目标协议生成时间、是否依赖审批或链路更新;未确认前不新增业务审批门槛 |
| 平台和共享目录 | 两平台分别导出归档 | 真实账号、订单状态/范围、文件样例、共享路径及四条排期 |
| 盈利 | 5% 以上盈利 | 计算基数及边界;不直接套用旧方案 0.95 公式 |
| 现场库和模板 | 接口输入与处理记录分开 | 现有 Oracle 表、权限、正式模板和确有要求的附件 |
### 12.2 可能问题与控制位置
| 问题 | 影响 | 对应设计 |
| --- | --- | --- |
| 共用一个 processed 标志 | 合同处理后返利漏取 | 第 5 章分离合同保存、链路、返利标志 |
| 两份接口输入被分别修改 | 合同/返利货品数量不一致 | 同 INPUT_ID/版本只保留一份原始头明细 |
| 成功接收后没有 job | 数据落表但长期未执行 | REGISTER_STATUS 和取数前补登记;运维可查 |
| 接口/页面结果未知被当失败 | 重复订单、合同或返利 | UNKNOWN 核对后恢复,不无条件重做 |
| 把链路失败当合同未保存 | 再建一份合同 | 保存节点结果与链路节点独立 |
| 当前合同缺价补值或明细定位错误 | 错金额、同码行覆盖或漏行 | 接收校验及第 11.1 节代码修正;单规格规则不消除多订单行的问题 |
| 原人工确认直接推进 | 未核实外部结果就完成作业 | 第 10.3 节按动作和证据校验 |
| 新旧流程改全局状态机 | 旧对码、OCR、M5 在途单无法继续 | flow/version 分开,旧单保持原链;切换前确定同一来源由哪套办理 |
| 两库或共享目录不可用 | 接收失败、原文不可查、文件不可达 | 独立错误可见;执行记录保留,不伪装成空数据或成功 |
本稿的表与字段是详细设计建议,尚未核对现场字典。对外契约尚未提供的部分保留为明确缺口;不以增加业务操作、复制数据或自动猜规则来填补。

View File

@ -0,0 +1,781 @@
# 福建洁达采购 RPA 概要设计与详细设计
| 文档属性 | 内容 |
| --- | --- |
| 版本 | V3.0 |
| 日期 | 2026-09-13 |
| 状态 | 设计评审稿;未实施代码或建表 |
| 适用系统 | 当前 AutoRpa 状态中心、RPA 执行程序及福建洁达采购业务 |
| 本版核心关系 | 订单中心一次推送合同/返利共用数据;合同先处理,返利按自己的排期读取同一份数据 |
| 文档范围 | 概要设计、表与状态详细设计、接口及时序、异常恢复、运维查询、实施与验收 |
| 替代关系 | 整合原 Codex 设计稿 V1.9 和技术实现说明 V2.3;旧方案仅供历史参考 |
**阅读顺序:** 业务评审看第 1–3 章;开发看第 4–9 章;运维看第 10 章;实施验收及未决问题见第 11–12 章。
## 1. 设计依据与范围
### 1.1 已确认业务规则
| 编号 | 规则 |
| --- | --- |
| BR-01 | 四条脚本分别运行,自动时间和频率不同,也支持业务点击;一次 HTTP 接收不等待四条流程全部执行 |
| BR-02 | 选品读取未处理销售明细,按批准文号查询;合格加购物车,不合格跳过,处理完本批结束;不等待人工付款 |
| BR-03 | 本业务相同批准文号只有一个规格,不增加规格、厂家、包装或单位换算匹配 |
| BR-04 | 平台订单导出到各平台对应共享文件夹,按订单中心接口格式组装并推送;对码由订单中心内部处理 |
| BR-05 | 订单中心一次传来的完整业务数据供合同、返利共用,不拆成两次独立供数,不重复保存两份相同头明细 |
| BR-06 | 接收时检查上游应传的必填字段,缺项当次返回失败和字段位置;通过并落表后返回成功 |
| BR-07 | 合同保存成功后调用订单中心链路接口更新对应节点;返利读取同一份接口数据,并使用本方已取得的合同号 |
| BR-08 | 手工单暂不做;合同/返利字段、固定值及原业务人工分支按提供材料执行 |
### 1.2 参考材料
| 材料 | 用途 |
| --- | --- |
| [合同系统录入流程](/Users/yuuuu/Documents/AutoRpa/资料/合同系统录入流程.md) | 合单、尽调、提交、回写;页面步骤指向 rpa_yisaiqi |
| [选品条件 20260902](/Users/yuuuu/Documents/AutoRpa/资料/采购平台选品条件优先项_20260902.txt) | 自营仓、盈利、效期、库存和仓库顺序 |
| [合同返利字段映射 20260911](/Users/yuuuu/Documents/AutoRpa/资料/合同返利系统字段映射_20260911.xlsx) | 两个工作表的 46 个 RPA 字段及 Sheet3 导入列 |
| [仓库一期技术设计](/Users/yuuuu/Documents/AutoRpa/资料/采购主链RPA一期设计文档.md) | 参考概要/详细设计、实体、接口、状态和审计的组织方式;不沿用其旧业务范围 |
| [现有数据库结构](/Users/yuuuu/Documents/AutoRpa/state_center/sql/schema.sql) | 判断 job、节点、待办、日志及证据记录可复用部分 |
本次会话的业务确认优先于旧文档。下文表名、内部代码、API 路径及列类型为本方建议,正式外部字段名、节点代码和现场 Oracle 结构仍需按第 12 章核对。
## 2. 概要设计:架构与分工
### 2.1 总体架构
```mermaid
flowchart TB
OC[订单中心] -->|销售数据 / 合同返利共用数据| API[FastAPI 接收校验]
API -->|校验成功同事务落表| IFDB[(Oracle 接口头明细及接收记录)]
S[四条独立排期 / 点击脚本] --> RPA[RPA 执行程序]
RPA <-->|领取数据 / 回报结果| SC[状态中心]
SC -->|读取同一份输入| IFDB
SC <--> BDB[(Oracle 作业、节点、调用记录)]
RPA -->|查品加购 / 导出订单| PL[采购平台]
PL --> F[对应共享文件夹]
F -->|组装报文并推送| OC
RPA -->|录入保存| CT[合同系统]
RPA -->|保存后调用链路接口| OC
RPA -->|同一接口数据加合同号| RB[返利中心]
UI[运维列表 / 详情 / 日志] --> SC
```
接口数据与业务处理记录分开:**Oracle 接口表回答“订单中心传了什么”,Oracle 业务记录回答“本方做到哪一步、结果是什么、谁操作过”。** RPA 通过状态中心读取接口数据,数据库连接集中在后端。
### 2.2 接口表与业务表的选择
| 方式 | 特点 | 本期选择 |
| --- | --- | --- |
| 仅一张接口表加一个 processed 标志 | 简单,但合同处理后可能被整体标记完成,返利漏取;也无法表达多次失败尝试 | 不采用 |
| 接口表再完整复制成合同表、返利表 | 三份字段容易不一致,需要额外同步 | 不采用 |
| 接口头明细保留一份输入,业务作业/节点保存独立状态与结果 | 合同、返利通过相同 INPUT_ID 关联;历史操作复用现有日志 | **采用** |
现有 `job / job_node` 就是本期业务处理表,无需再复制合同、返利输入。为无 job 的接收失败补接收记录;为跨导出文件识别同一订单,补逐订单推送台账。这两项有明确查询及防重用途。
### 2.3 模块职责及运行方式
| 模块 | 职责 |
| --- | --- |
| FastAPI | 校验、保存接收数据、取数、原子领取、保存回报、查询及运维动作 |
| Oracle 接口库 | 接收尝试、销售头明细、合同/返利共用头明细;成功输入保留原值 |
| Oracle 状态中心 | 各流程 job、节点尝试、业务编号、订单推送状态、操作日志、证据索引 |
| 四条 RPA 脚本 | 本批逐条执行;平台订单和合同链路接口由对应脚本通过适配器调用并回报 |
| 现有 Web | 接收查询、共用数据处理总览、节点尝试、文件和运维动作 |
四条脚本分别配置时间、频率及本轮条数上限,单执行机每条脚本设单实例启动控制。FastAPI 常驻。脚本启动时调用后端登记未建立的 job,再领取本流程数据;订单导出先生成并登记 export_batch_id,恢复失败批次沿用该 ID;本批结束释放本机运行锁。没有数据时记录本轮零条并退出,不新增第五条业务或后台维护流程。
## 3. 概要设计:业务流程与完成边界
| 流程 | 输入 | 正常处理 | 完成含义 |
| --- | --- | --- | --- |
| PROCURE | 销售接口表的一条明细 | 按批准文号查询,按采购条件加购或跳过 | 该行处理完;不代表付款成交 |
| ORDER_PUSH | 对应平台导出的订单文件 | 保存共享目录、解析、组装、推送、记录回执 | 应推送订单确认接收;不等待内部对码 |
| CONTRACT | 共用订单接口头明细 | 录入并保存合同,保存合同号,更新订单中心链路 | 保存及链路更新分别记录;链路失败不重建合同 |
| REBATE | **同一共用订单接口头明细**+本方合同保存结果 | 按合同号查协议、填总单、导细单、提交 | 返利提交成功;不要求订单中心再次推数据 |
### 3.1 合同与返利的关联
```mermaid
flowchart TD
I[一次推送:INPUT_ID = I001] --> C[合同 job:INPUT_ID = I001]
I --> R[返利 job:INPUT_ID = I001]
C --> CS[合同保存成功:合同号 HT001]
CS --> CH[合同链路更新]
CS -->|提供合同号| R
R --> RS[返利提交结果]
```
合同、返利是两个独立作业,绑定同一 INPUT_ID 和 INPUT_VERSION。合同保存号只从对应合同 job 取得,不能按同品种或同一天猜配。
返利默认在“合同已确认保存成功且合同号存在”后可领取;链路回传结果另列显示,**不擅自把链路成功增加为返利前置条件**。若目标返利协议暂不可查,记录等待原因,下次返利运行再查询。是否必须审批通过由目标系统及接口方确认,不新增审批动作。
### 3.2 选品规则
自营仓、5% 以上盈利、有效期大于等于 12 个月、库存满足采购数量四项都通过后,按下列仓库顺序选择。不合格记跳过,平台技术错误记失败。
广东壹号药业有限公司、昆山亿方药业有限公司、湖北亿昊药业有限公司、天津壹号药业有限公司、重庆亿昊药业有限公司、重庆壹号医药有限公司、湖南壹号药业股份有限公司、辽宁亿昊药业有限公司、河南壹号医药有限公司、甘肃壹号云医药有限责任公司企业店、陕西壹号药业有限公司。
## 4. 详细设计:数据模型
### 4.1 实体关系和存储位置
```mermaid
erDiagram
RPA_JD_IF_REQ ||--o{ RPA_JD_SALES_H : accepts
RPA_JD_IF_REQ ||--o{ RPA_JD_ORDER_H : accepts
RPA_JD_SALES_H ||--|{ RPA_JD_SALES_L : contains
RPA_JD_ORDER_H ||--|{ RPA_JD_ORDER_L : contains
RPA_JD_SALES_L ||--o{ JOB : procure
RPA_JD_ORDER_H ||--o{ JOB : contract_and_rebate
JOB ||--o{ JOB_NODE : attempts
JOB ||--o{ JOB_EVENT : operations
JOB ||--o{ JOB_ARTIFACT : files
JOB ||--o{ JD_ORDER_PUSH : exports
```
前五张为 Oracle 逻辑表:一张接收记录、销售头明细、共用订单头明细;已有同用途表优先映射复用。其余为 Oracle,`JD_ORDER_PUSH` 拟新增,其余复用。图中的同库关系是应用关联,不是跨数据库外键。
接口表内的合同/返利状态不各写一份。运维总览按 INPUT_ID 汇总两份 job 与其节点,展示三个独立标志:合同保存、链路更新、返利办理。读写来源见第 5 章。
### 4.2 接收记录 `RPA_JD_IF_REQ`
一行是一回接收尝试;错误请求也能查到,不能依赖先有 job 才记录。
| 字段 | 建议类型 | 含义与约束 |
| --- | --- | --- |
| REQUEST_LOG_ID | VARCHAR2(64) PK | 本方生成,返回给上游和运维定位 |
| TRACE_ID | VARCHAR2(64) | 本次请求关联应用日志 |
| SOURCE_SYSTEM / MESSAGE_ID | VARCHAR2(32) / VARCHAR2(128) | 调用方及其请求标识;MESSAGE_ID 格式由对方提供 |
| DATA_TYPE | VARCHAR2(24) | SALES 或 ORDER_COMMON;没有独立 REBATE 接收类型 |
| BODY_HASH | VARCHAR2(64) | 规范化业务报文哈希,识别同请求内容是否一致 |
| RECEIVE_STATUS | VARCHAR2(16) | ACCEPTED、REJECTED、DUPLICATE、CONFLICT |
| ORIGINAL_REQ_ID | VARCHAR2(64),可空 | 重复请求关联首次成功记录 |
| RAW_BODY / RESPONSE_BODY | CLOB | 实际业务报文和返回内容,不记录鉴权口令 |
| ERROR_CODE / ERROR_DETAIL | VARCHAR2(64) / CLOB | 字段路径、明细标识、错误说明 |
| RECEIVED_AT / RESPONDED_AT | TIMESTAMP | 收到及生成返回结果的时间 |
| REGISTER_STATUS | VARCHAR2(8),可空 | 成功记录的作业登记标志:PENDING、DONE、FAILED;非成功记录为空 |
| REGISTER_ERROR / REGISTERED_AT | VARCHAR2(500) / TIMESTAMP | Oracle 登记失败原因及最终登记时间 |
RECEIVE_STATUS 表示接收事实,REGISTER_STATUS 仅表示是否已登记作业,均不代表合同或返利完成。成功接收同事务保存接口头明细并置 REGISTER_STATUS=PENDING;脚本取数前补登记,完成后更新 DONE。登记失败在下一次脚本取数重试,无需上游重传。
约束:对 ACCEPTED 记录建立 `(SOURCE_SYSTEM, MESSAGE_ID)` 条件唯一索引;Oracle 可用 CASE 表达式索引实现。失败/重复尝试保留独立行,不占用成功消息键。索引还包括 `(DATA_TYPE, REGISTER_STATUS, RECEIVED_AT)`、`TRACE_ID`、`(SOURCE_SYSTEM, MESSAGE_ID)`。
### 4.3 接口头公共字段
销售头和共用订单头均使用以下控制列。
| 字段 | 建议类型 | 用途 |
| --- | --- | --- |
| INPUT_ID | VARCHAR2(64) PK | 本方输入身份,跨流程统一关联 |
| REQUEST_LOG_ID | VARCHAR2(64) FK | 首次成功接收记录 |
| SOURCE_SYSTEM / SOURCE_DOC_ID | VARCHAR2(32) / VARCHAR2(128) | 上游稳定业务标识;与展示单号分开 |
| SOURCE_DOC_NO | VARCHAR2(128) | 业务查询展示用单号 |
| INPUT_VERSION | VARCHAR2(64) | 上游版本;未提供时首版用固定 1,变更走第 6.2 节冲突规则 |
| DATA_HASH | VARCHAR2(64) | 本对象完整头明细内容哈希 |
| DATA_STATUS | VARCHAR2(16) | READY:成功接收的完整输入;本期不另设待补字段状态 |
| CREATED_AT | TIMESTAMP | 成功保存时间 |
头表唯一键为 `(SOURCE_SYSTEM, SOURCE_DOC_ID, INPUT_VERSION)`。明细以 `(INPUT_ID, SOURCE_LINE_ID)` 唯一,关联本头,不以货品编码唯一。成功输入不由 RPA 改写,处理结果另存 Oracle。
### 4.4 销售接口头明细 `RPA_JD_SALES_H / L`
| 表 | 业务字段 | 用途 |
| --- | --- | --- |
| SALES_H | 第 4.3 节公共字段、BUSINESS_DATE | 一份销售总单 |
| SALES_L | LINE_ID、INPUT_ID、SOURCE_LINE_ID、APPROVAL_NO、PURCHASE_QTY、SALES_PRICE | 逐条查询所需批准文号、需求采购数量、销售价格 |
| SALES_L 可选描述 | GOODS_NAME、SPEC、MANUFACTURER、UNIT | 按上游原值保留供查看,不作为新增属性匹配门槛 |
LINE_ID 建议 VARCHAR2(64),数量 NUMBER(18,4),价格 NUMBER(18,6),标识/文号 VARCHAR2(128),描述 VARCHAR2(256)。最终长度和精度按真实接口及目标系统核对。
### 4.5 合同/返利共用接口头明细 `RPA_JD_ORDER_H / L`
一份头代表上游明确给定的一组合同及对应返利数据,范围由报文的 SOURCE_DOC_ID 和明细集合确定。首版不按供应商+日期自行合单。若实际存在一组数据对应多份合同/返利,需要上游给出组标识,见第 12 章。
| 位置 | 建议字段 | 含义/使用流程 |
| --- | --- | --- |
| ORDER_H | 第 4.3 节公共字段 | 合同和返利共用输入标识 |
| ORDER_H | CUSTOMER_NAME、CUSTOMER_TYPE、CREDIT_DAYS | 合同客户名称、预付/账期类型、账期天数 |
| ORDER_H | CONTRACT_SIGN_DATE、CONTRACT_START_DATE、CONTRACT_END_DATE | 三个独立合同日期 |
| ORDER_H | SUPPLIER_CODE、REBATE_UNIT_CODE、PLANNER、PURCHASER | 返利供应商、返利单位、计划员及采购员对应信息 |
| ORDER_H | CHAIN_REF | 订单中心链路/源业务关联;精确结构按正式接口 |
| ORDER_L | LINE_ID、INPUT_ID、SOURCE_LINE_ID、ORDER_NO、SOURCE_DETAIL_ID | 本方行 ID、来源行、订单号、货源细单号;订单号与货源细单号分列 |
| ORDER_L | GOODS_CODE、GOODS_NAME、REG_CERT_NO、SPEC、UNIT | 标准货品编码/名称、注册证号、规格、单位 |
| ORDER_L | PURCHASE_QTY、PURCHASE_UNIT_PRICE | 采购数量和单价;用于合同明细 |
| ORDER_L | REBATE_QTY、REBATE_TAX_PRICE | 返利数量和含税协议单价;用于返利明细 |
| ORDER_L | PAYMENT_CONDITION,可空 | 实际合同模板需要时从同一报文提供 |
同一次报文中数量如只有一个来源字段且供两流程共用,映射层同时引用它;不因此要求上游重复传两个数量。采购单价与含税协议单价按 Excel 的不同含义分别映射,未经接口确认不互相代替。
建议数量 NUMBER(18,4)、价格 NUMBER(18,6)、金额 NUMBER(18,2)、日期 DATE;名称 VARCHAR2(256)、编码 VARCHAR2(128)、类型 VARCHAR2(16)、账期 NUMBER(6)、CHAIN_REF CLOB 或按正式关联键拆列。固定默认值不强迫订单中心传入,在节点执行输入中生成并记录所用模板/映射版本。
**此处没有 `RPA_JD_CONTRACT_HEAD / RPA_JD_REBATE_HEAD` 两套输入表。两流程取数的 INPUT_ID 必须相同。**
### 4.6 业务作业与节点记录
| 表/字段 | 现有或新增 | 详细用途 |
| --- | --- | --- |
| job.flow_code / flow_version | 新增 VARCHAR(24) / VARCHAR(16) | 旧作业归原主链,新作业区分四条流程 |
| job.input_id / input_version | 新增 VARCHAR(64) | 指向 Oracle 成功输入;ORDER_PUSH 指向本方导出批次 ID |
| job.source_line_id | 新增 VARCHAR(128),头级作业用空串 | PROCURE 对应一条销售明细;合同/返利对应头 |
| job.business_key | 复用 | 改为上述来源关联的展示/定位键;防重以组合唯一约束为准 |
| job.status / current_node_code | 复用 | 当前执行状态和恢复节点 |
| job.contract_system_no / rebate_order_no | 复用 | 合同 job 保存合同号,返利 job 保存返利总单/协议编号 |
| job.next_check_at | 复用 | 可重试失败或外部等待的下次处理时间 |
| job.execution_token / execution_host | 新增 VARCHAR(64) / VARCHAR(128) | 当前领取身份,拒绝旧执行回报;记录实际执行机 |
| job_node.attempt_no、status、output_json | 复用 | 每次尝试独立记录;输入引用、业务结果和外部编号 |
| job_node.started_at / ended_at / duration_ms | 复用 | 操作开始结束及耗时 |
| job_node.error_code / error_summary | 复用 | 可筛选错误分类和具体原因 |
| job_node.executor_run_id / callback_idempotency_key | 复用 | 一次脚本运行 ID、稳定回报键,重发回报不重复推进 |
新 flow 的组合唯一约束:`(flow_code, input_id, input_version, source_line_id)`;已成功作业也受约束。flow_version 是实现规则版本,不因升级自动为旧输入创建第二份作业。旧 job 回填其自身唯一 input_id,保留原入口规则。
共用数据成功接收后登记两份 job:CONTRACT 初始 PENDING,REBATE 初始 WAITING_EXTERNAL,原因为 WAIT_CONTRACT。同一 Oracle 输入即便登记调用重复,也只建立这一对作业。一次接收多组业务对象则逐头建立,不共享一个总完成标志。
旧列兼容迁移:保留 job_id、business_key、source_type、current_node_code、status、created_by 非空;supplier_name 和 order_date 改为允许空值,新来源数据有值时填入。旧入口继续校验其原必填,不给新作业补造供应商或日期。business_key 由新唯一键各项组合生成可读/哈希值;created_by 记录业务启动人或计划任务账号。
| flow | source_type | supplier_name / order_date | 首节点 |
| --- | --- | --- | --- |
| PROCURE | 本方定义 ORDER_CENTER | 供应商为空;日期取销售业务日(若提供) | PROCURE_ITEM_RUN |
| ORDER_PUSH | 明确的平台配置代码 | 文件批次可含多供应商,供应商为空;日期不强制单日 | ORDER_EXPORT_FILE |
| CONTRACT / REBATE | ORDER_CENTER | 接口明确提供的展示供应商及业务日期;合同三个日期仍取接口独立字段 | CONTRACT_CREATE / REBATE_HEADER |
source_type 上述取值是本方分类建议;药帮忙使用单独配置代码,不映射成旧 XYY。若任一新增标识超出现有列长度,迁移时扩列。旧 job 赋旧 flow/version 及唯一 input_id,新组合唯一键对全部历史行先回填再建立。
### 4.7 逐订单推送台账 `JD_ORDER_PUSH`(Oracle 拟新增)
文件/操作日志不能替代按订单查询的处理标志,因此该表只保存推送控制与结果,不复制完整平台业务表。
| 字段 | 建议类型 | 用途 |
| --- | --- | --- |
| id | BIGINT PK | 推送记录 ID |
| platform / account_key / source_order_id | VARCHAR(32) / VARCHAR(64) / VARCHAR(128) | 来源订单唯一键,不按文件名去重 |
| source_order_no / job_id / artifact_id | VARCHAR(128) / VARCHAR(64) / BIGINT | 展示单号、首次导出 job、原始文件关联 |
| content_hash / request_key | VARCHAR(64) / VARCHAR(128) | 订单内容识别和本次固定请求标识 |
| push_status | VARCHAR(16) | PENDING、SENDING、ACCEPTED、REJECTED、UNKNOWN |
| request_body / response_body | JSON | 本次实际结构化发送内容、返回内容或所属批次引用 |
| receipt_no / error_code / error_message | VARCHAR(128) / VARCHAR(64) / VARCHAR(500) | 回执及失败原因 |
| attempt_count / next_retry_at | INT / DATETIME | 发送次数及重试时间 |
| created_at / last_sent_at / accepted_at | DATETIME | 关键时点 |
唯一键 `(platform, account_key, source_order_id)`;相同订单相同内容复用结果,内容变化不能覆盖旧成功记录,记录冲突交运维核对。每次发送详细历史写 job_event,最近状态保存在此表。多个文件含同一订单时,在各导出 job 的日志中记录对应 artifact_id 与台账 id。
接口若支持批量发送,固定请求键绑定整份实际请求体;同键重发内容不变。逐单成功的回执更新对应台账;只发失败子集时生成新请求键并保留与原批次关联。没有逐单回执时按对方整批接收语义记录,不凭本方猜部分成功。
## 5. 详细设计:标志字段与取数条件
### 5.1 状态来源
| 业务标志 | 保存/计算位置 | 何时改变 |
| --- | --- | --- |
| 接收是否成功 | IF_REQ.RECEIVE_STATUS | 当前接收请求完成时 |
| 作业是否登记 | IF_REQ.REGISTER_STATUS | 在 Oracle 幂等建立本次所需 job 后 |
| 选品处理结果 | PROCURE job_node.output_json.result | 加购或正常跳过回报时:ADDED / SKIPPED |
| 合同保存状态 | CONTRACT_CREATE 节点状态及 result | 开始、保存成功、失败或结果未知回报 |
| 链路更新状态 | CONTRACT_CHAIN_UPDATE 节点状态及 result | 合同保存后调用订单中心,单独记录结果 |
| 返利状态 | REBATE job.status / current_node_code | 等合同、执行、失败、提交成功 |
| 平台订单接收状态 | JD_ORDER_PUSH.push_status | 每次发送和回执记录 |
运维接口返回 `contract_save_status / chain_update_status / rebate_status` 三列,是以上权威记录的汇总字段,**不再写回接口表形成第二套状态**。当前节点按 job 指针及该节点最新 attempt 取值,历史成功节点保留,不用整个合同 job.status 代替保存状态。
### 5.2 状态转换
| job 状态 | 含义 | 后续动作 |
| --- | --- | --- |
| PENDING | 已登记,等待执行 | 对应脚本领取 |
| RUNNING | 已被领取且正在执行 | 接收匹配 execution_token 的回报 |
| SUCCESS | 本流程应做步骤完成 | 常规取数不再领取 |
| FAILED_RETRYABLE | 明确失败,可重试当前步骤 | 到 next_check_at 后或运维重试,新增 attempt |
| WAITING_EXTERNAL | 前置尚未完成或结果待核对 | 按 wait_reason 处理,不能一律自动重做 |
| WAITING_USER / FAILED_MANUAL | 业务或运维需介入 | 在原待办上处理、核对并记录动作 |
| CANCELLED | 明确停止办理 | 保留原数据和历史 |
wait_reason 建议保存在 job 新增 VARCHAR(32) 字段:WAIT_CONTRACT、PROTOCOL_NOT_READY、RESULT_UNKNOWN。前两种下次检查条件;RESULT_UNKNOWN 需要核对外部结果,确认失败才可重试。
节点复用 PENDING/RUNNING/SUCCESS/FAILED,未知结果用节点 `WAITING_EXTERNAL` 并在 output_json 记 `result=UNKNOWN`;需要新增相应前后端状态支持。未尽调转人工保留待办,不能把“停止自动办理”显示为“合同已保存”。
```mermaid
stateDiagram-v2
[*] --> PENDING
PENDING --> RUNNING: 领取
RUNNING --> SUCCESS: 完成
RUNNING --> FAILED_RETRYABLE: 明确可重试失败
RUNNING --> WAITING_EXTERNAL: 前置等待或结果未知
RUNNING --> WAITING_USER: 业务处理
FAILED_RETRYABLE --> PENDING: 重试当前步骤
WAITING_EXTERNAL --> PENDING: 前置满足或核对未成功
WAITING_EXTERNAL --> SUCCESS: 核对本流程已完成
WAITING_USER --> PENDING: 处理后恢复未完成步骤
PENDING --> CANCELLED: 停止办理
```
上图为通用 job 状态。合同保存结果核对成功时若链路尚未更新,实际转移到链路节点 PENDING,不能直接标整个合同 SUCCESS。
### 5.3 领取规则
| flow | 读取范围及条件 | 成功/失败记录 |
| --- | --- | --- |
| PROCURE | SALES 数据 READY;行 job=PENDING,或到期可重试 | 合格 ADDED、不合格 SKIPPED 均结束行 job;错误单独失败 |
| ORDER_PUSH | 对应平台导出任务及历史未完成台账;ACCEPTED 不重发 | 归档文件及每单 push_status |
| CONTRACT | ORDER_COMMON 数据 READY;合同 job 待执行,按 current_node 恢复 | CREATE 成功保存合同号;CHAIN 失败只重试 CHAIN |
| REBATE | 同 INPUT_ID/版本的 CREATE 节点确认 SUCCESS,合同号非空;返利待执行或到期重试 | 从共用接口表读取返利字段;读取合同 job 合同号;提交后 SUCCESS |
新录入与重试均覆盖历史未完成记录,不只筛选当天。创建返利 job 时可处于 WAIT_CONTRACT,合同失败不改它的接口输入;合同成功后,返利下一次扫描将其转 PENDING。
领取使用 Oracle 短事务条件更新或行锁,同时检查状态并写 execution_token;不能先查询再无条件改状态。脚本中断保留 RUNNING,运维确认原进程已停止后释放并恢复;本期不以超时自动启动第二个执行器。
### 5.4 节点定义与订单导出生命周期
| flow | 节点链 | 正常结束条件 |
| --- | --- | --- |
| PROCURE | PROCURE_ITEM_RUN | 本行 ADDED 或 SKIPPED |
| ORDER_PUSH | ORDER_EXPORT_FILE → ORDER_CENTER_PUSH | 文件完整归档后,解析及推送该批应发送订单,均确认接收 |
| CONTRACT | CONTRACT_CREATE → CONTRACT_CHAIN_UPDATE | 保存及链路更新分别成功;未尽调为当前节点人工待办,不另建供数流程 |
| REBATE | REBATE_HEADER → REBATE_IMPORT → REBATE_SUBMIT | 正常提交成功;首次领取前检查合同保存条件 |
ORDER_PUSH 脚本先领取历史未完成批次;再按当前排期需要产生 export_batch_id 并登记新 job(input_id=export_batch_id,input_version=1,source_line_id=空串)。新 job 从 ORDER_EXPORT_FILE 领取执行。导出失败保留该 job;已保存文件和路径写 job_artifact,node_code=ORDER_EXPORT_FILE,恢复时先查已有归档文件。
归档成功后推进 ORDER_CENTER_PUSH:解析文件、按源订单建立/复用 JD_ORDER_PUSH、保存固定请求并推送。本批所有源订单到台账 ID 的关联写入该 job 的节点 output_json(大批量可用 artifact 保存清单),因此同一台账复用于多个文件时仍可判断每个批次是否完成。首次 job_id/artifact_id 不覆盖,后续文件关联由各批次记录持有。
源订单均为 ACCEPTED,或本轮确无可推送订单时,批次 job SUCCESS;零数据结果记 NO_DATA 并显示零条,不创建空订单。部分失败保留当前推送节点,已接收项跳过;同订单不同内容记冲突,阻止该批盲目完成,交运维核对。脚本退出与该批次 job 成功是两件事。
## 6. 详细设计:接口与交互时序
### 6.1 接口清单
| 接口/方向 | 状态 | 主要契约 |
| --- | --- | --- |
| `POST /api/jieda/inbound/sales` | 本方拟新增 | 完整销售对象,接收校验后落表 |
| `POST /api/jieda/inbound/order-data` | 本方拟新增 | **一次传入合同及返利共同需要的数据**,返回接收记录 ID 和 INPUT_ID |
| `POST /api/flows/{flow_code}/jobs/claim` | 本方拟新增 | 指定本轮 run_id,补登记后领取;返回 job、节点、attempt、执行令牌及输入关联 |
| `POST /api/flows/ORDER_PUSH/jobs` | 本方拟新增 | 导出前按 export_batch_id 幂等登记平台/账号/范围及 job,导出失败也有作业可查 |
| `GET /api/jobs/{job_id}/input` | 本方拟新增 | 对应接口头明细;返利额外返回关联合同 job 及合同号 |
| `POST /api/jobs/{job_id}/nodes/{node}/{action}` | 复用 start/complete/fail 并扩展 | 结果、编号、错误、证据、attempt 和执行令牌;未知结果单独表达 |
| `POST /api/jobs/{job_id}/push-records` | 本方拟新增 | 按源订单登记固定请求、逐单结果;校验所属 ORDER_PUSH 作业 |
| `POST /api/jobs/{job_id}/retry` | 复用并修正 | 只恢复当前失败步骤,记录操作者及原因 |
| 平台订单接收 API(本方→订单中心) | 外部契约待提供 | 来源订单、商品数量金额及实际接收回执 |
| 链路节点更新 API(本方→订单中心) | 外部契约待提供 | 来源链路关联、对应节点、已保存合同标识及业务结果 |
订单推送由 ORDER_PUSH 脚本、链路更新由 CONTRACT 脚本通过统一客户端调用。调用前通过状态中心记录固定请求,调用后保存回执。状态中心不同时后台发送同一请求。外部正式路径、业务枚举和鉴权由订单中心提供。
### 6.2 请求、校验及重复处理
接收报文在本方适配层统一表示为 `message_id + objects[]`。每个对象有 source_doc_id、input_version(可选)、header、lines。ORDER_COMMON 的 header/lines 包含第 4.5、7 章共同字段,不是分别推 contract 和 rebate。
| 返回情况 | 本方建议 HTTP | 业务返回及持久化 |
| --- | --- | --- |
| 缺字段/格式不合法/空明细 | 422 | REJECTED,errors 含对象标识、行标识及字段路径;无业务头明细 |
| 全部校验通过且保存成功 | 200 | ACCEPTED、request_log_id、input_ids;表示接收成功 |
| 已接收同消息同内容 | 200 | DUPLICATE,返回原 input_ids,另记接收尝试 |
| 已接收同标识不同内容 | 409 | CONFLICT,旧成功输入及作业不变 |
| 保存失败 | 503 | STORE_FAILED+trace_id;不返回接收成功 |
| 鉴权失败 | 401/403 | 拒绝,不建立业务作业 |
首版整次请求校验通过后,同一个 Oracle 事务保存成功接收记录及全部对象头明细。任一必填缺失则整次失败,返回全部可识别字段错误;上游可按完整单据分批发送。缺的是返利字段也当次返回,因为这一份数据承担两个流程。
客户类型、三个合同日期、合同明细字段,以及供应商/返利单位/计划员/订单号/货源细单号/含税协议单价在本次共用数据中按第 7 章校验。合同号是本方执行结果,接收时允许不存在。固定值、系统生成字段、页面尽调回显不要求上游提供。
相同业务对象换 message_id 再传,由头表来源+版本唯一键识别:内容相同引用原 INPUT_ID,内容不同冲突。没有源版本号时,哈希只判断内容同异,不自动判定“哪条更新”。已成功接收的同 SOURCE_DOC_ID 改内容或改版本,本期返回 INPUT_CONFLICT,不自动创建第二组合同/返利 job;这是按业务标识检查,不能仅依赖版本唯一索引。运维和订单中心确认改单处理后再另行安排,原作业和结果保持可查。此前校验失败的修正重传可正常接收。
接收失败日志存 IF_REQ;Oracle 本身不可用时只能写应用错误日志和 trace_id,不能宣称数据库必有失败记录。响应在事务提交后产生;若响应丢失,上游重传可取得原接收结果。
### 6.3 时序图:一次接收,两流程共用
**接收及合同处理。** 请求内校验并落表;合同排期触发后读取这份数据。
```mermaid
sequenceDiagram
participant OC as 订单中心
participant API as 状态中心
participant IF as 共用接口表
participant C as 合同脚本
participant CT as 合同系统
OC->>API: 一次推送合同返利共用数据
API->>API: 校验两流程上游必填
alt 校验失败
API->>IF: 记REJECTED,不建业务头明细
API-->>OC: 失败及字段位置
else 校验通过
API->>IF: 同事务保存接收记录和头明细
IF-->>API: 提交成功 INPUT_ID
API-->>OC: 接收成功 INPUT_ID
C->>API: 按合同排期领取
API->>API: 幂等登记同输入的合同/返利job
API->>IF: 按INPUT_ID读取输入并记登记完成
API-->>C: 合同job及共用数据
C->>CT: 录入并保存
CT-->>C: 保存成功及合同号
C->>API: 回报保存结果,推进至链路节点
C->>OC: 更新对应链路节点
OC-->>C: 更新回执
C->>API: 保存链路结果
end
```
**返利在另一排期继续。** 返利不再向订单中心获取另一份数据,按相同 INPUT_ID 读取接口表,并取得本方关联合同结果。
```mermaid
sequenceDiagram
participant R as 返利脚本
participant API as 状态中心
participant DB as 业务记录
participant IF as 同一共用接口表
participant RB as 返利中心
R->>API: 按返利排期领取
API->>DB: 检查同INPUT_ID的合同保存结果
alt 合同未保存
API->>DB: 返利保持WAIT_CONTRACT
API-->>R: 本条暂不领取,继续其他数据
else 合同保存成功且合同号存在
API->>IF: 读取相同INPUT_ID和版本
IF-->>API: 原头明细中的返利字段
API-->>R: 返利数据加本方合同号
R->>RB: 查协议、填总单、导明细并提交
RB-->>R: 提交结果
R->>API: 回报返利结果
API->>DB: 保存节点、编号和操作日志
end
```
两图通过同一 INPUT_ID 衔接,不要求两脚本连续运行。返利先启动时可先完成作业登记,但保持 WAIT_CONTRACT。
### 6.4 时序图:平台订单归档推送
```mermaid
sequenceDiagram
participant R as 订单脚本
participant P as 平台
participant F as 共享文件夹
participant API as 状态中心
participant OC as 订单中心
R->>P: 按约定范围导出订单
P-->>R: 完整文件
R->>F: 保存到对应平台目录
alt 保存失败
R->>API: 回报文件错误,保留失败步骤
else 保存成功
R->>API: 登记文件、源订单及固定请求
API-->>R: 未接收订单和请求键
R->>OC: 按正式格式推送
alt 接收确认
OC-->>R: 业务接收回执
R->>API: 更新逐订单台账,记录成功/拒绝项
else 超时或连接中断
R->>API: 记录UNKNOWN及实际请求
end
end
```
共享目录保存后再组装推送。文件名冲突保留新批次路径,不覆盖原件;导出失败恢复导出,已归档的文件恢复时直接使用原件。
### 6.5 时序图:合同保存成功、链路更新失败
```mermaid
sequenceDiagram
participant C as 合同脚本
participant CT as 合同系统
participant API as 状态中心
participant DB as Oracle
participant OC as 订单中心
C->>CT: 保存合同
CT-->>C: 成功 HT001
C->>API: CREATE complete,合同号和证据
API->>DB: 保存CREATE成功,当前节点改CHAIN_UPDATE
C->>API: 记录链路固定请求
C->>OC: 链路更新
OC-->>C: 明确失败或无响应
C->>API: 记录FAILED或UNKNOWN及原因
API->>DB: 保留合同号和失败位置
Note over C,DB: 脚本继续下一单;运维可见保存成功、链路异常
C->>API: 下次仅领取当前链路节点
Note over C,OC: UNKNOWN先核对;明确可重试才再次调用
C->>OC: 使用约定幂等请求更新
OC-->>C: 更新成功
C->>API: CHAIN complete及回执
API->>DB: 完成合同job;原CREATE记录不变
```
## 7. 详细设计:业务字段映射
本章保留 Excel 的实际字段和行号。合同/返利列来源都指向第 4.5 节同一份接口输入;按各自模板生成执行数据。生成的模板、默认日期及映射版本记录到节点 output_json/文件证据,不再复制成独立合同或返利输入表。
### 7.1 合同头
| 目标字段 | 取值规则 | 行号 |
| --- | --- | --- |
| 客户名称 | 订单中心 | 12 |
| 客户编码 | 目标系统自动生成 | 11 |
| 尽调情况 | 读取回显;未尽调停止本单自动录入并转采购员处理 | 13 |
| 结算类型 | 收支双向 | 15 |
| 合同版本类型 | 预付:我司定制;账期:非格式合同 | 17 |
| 合同金额(单位元) | 预估本次合作金额 | 24 |
| 金额(元) | 明细汇总;与导入后页面汇总核对并回填 | 25 |
| 合同签订日期、合同开始日期、合同结束日期 | 订单中心分别提供,三个独立字段 | 26–28 |
| 是否重大合同 | 否 | 29 |
| 是否预付款 | 预付:是;账期:否 | 30 |
| 付款方式 | 电汇 | 32 |
| 付款期限 | 预付:1;账期:订单中心账期天数,缺失不默认 30 | 33 |
| 合同具体分类 | 国内药品类、器械类品种 | 37 |
| 运输方式 | 公路运输 | 39 |
### 7.2 合同明细
| 目标字段 | 来源/规则 | 行号 |
| --- | --- | --- |
| 货品编码 | 订单中心 | 43 |
| 货品名称 | 订单中心标准货品名称 | 44 |
| 注册证号 | 订单中心 | 46 |
| 规格、单位 | 按订单中心标准字段填报 | 47–48 |
| 数量、单价 | 按订单中心最终采购数量、单价填报 | 49–50 |
| 付款方式 | 电汇 | 52 |
| 付款条件 | 订单中心;模板无此字段时不填 | 53 |
| 付款期限 | 同合同头规则 | 54 |
### 7.3 返利总单
| 目标字段 | 来源/规则 | 行号 |
| --- | --- | --- |
| 返利合约 | 否 | 5 |
| 供应商编码 | 订单中心 | 6 |
| 返利单位编码 | 订单中心 | 7 |
| 计划员 | 订单中心,按业务给定的计划员/采购员对应关系填写 | 8 |
| 采购结算方式 | 文案“否”;与细单代码 3 分别映射 | 9 |
| 承付模式 | 下月 N 号结 | 10 |
| 协议开始日期、协议结束日期 | 当天 | 11–12 |
### 7.4 返利细单
| 目标字段 | 来源/规则 | 行号 |
| --- | --- | --- |
| 货品编码、数量、含税协议单价 | 本次共用接口数据的货品编码、数量及含税协议单价 | 15–17 |
| 税率、关税税率 | 均为 0.13 | 18–19 |
| 承付模式、承付参数 | 11、25 | 20–21 |
| 细单开始日期 | 当天 | 22 |
| 细单结束日期 | 本年度最后一天 | 23 |
| 采购结算方式 | 3 | 24 |
| 特定补偿预估值、进货补偿预估值 | 均为 0 | 25–26 |
| 细单备注 | 订单中心订单号 | 33 |
| 货源细单ID | 订单中心货源细单号;与订单号分列 | 34 |
合同明细导入列按 Excel 的 Sheet3。表中标注无需填写的字段不填;系统自动生成字段由系统处理。
### 7.5 映射与运行日期规则
- 合同与返利的数量来自同一次报文;实际只提供一列数量时共用该列。含税协议单价与采购单价分别保留来源映射。
- 首次执行该流程时生成所用默认日期及模板记录。重试已生成的相同步骤使用原输入,避免跨日重试更改已导入单据;新返利作业按返利首次执行当天生成“当天/年底”。这是实现规则,不增加业务确认步骤。
- 合同客户编码为系统生成字段。现有 RPA 入口要求的 customerCode 实际用作客商搜索标识,须与系统自动生成的客户编码分清;按新页面取数方式适配,不要求上游为自动生成字段填占位符。
- 映射表标注无需填写的字段不填。尽调读取页面回显;未尽调按原流程转采购员并通知。接口数据保留,人工完成后核对实际合同号并登记保存成功,进入链路更新步骤;返利按第 5.3 节条件领取。
## 8. 详细设计:操作记录与一致性
### 8.1 最少需要记录的操作信息
| 场景 | 保存位置 | 必须能查到的信息 |
| --- | --- | --- |
| 每次接口接收 | IF_REQ | 请求时间、来源消息、INPUT_ID 关联、原报文、返回结果、字段错误、trace_id |
| 脚本执行一步 | job_node | flow、job、INPUT_ID/版本、节点、attempt、run_id、执行机、开始结束、结果、错误 |
| 平台订单发送一次 | JD_ORDER_PUSH+job_event | 原文件、源订单、固定请求键、实际请求、HTTP/业务返回、耗时、成功/拒绝范围 |
| 合同保存 | job_node+job_artifact | 实际合同标识、保存确认、目标页面证据、所用输入和模板 |
| 链路调用一次 | job_event+链路 job_node | 链路关联、节点代码、合同号、请求键、报文、回执、错误 |
| 运维操作一次 | job_event / user_task | 操作人、时间、动作、原因、前后状态、证据和操作结果 |
| 文件 | job_artifact | 原始订单、导入模板、截图或日志文件路径,文件类型及哈希 |
job_event 建议事件类型包括 INPUT_BOUND、NODE_STARTED、NODE_SUCCESS、NODE_FAILED、API_REQUEST、API_RESPONSE、RETRY_REQUESTED、RESULT_VERIFIED、MANUAL_HANDOFF、JOB_FINISHED。detail_json 保存结构化信息。节点列表用 job_node.error_code/error_summary 筛选及展示;事件详情从 detail_json.errorCode/errorMessage 读取错误,不假定 job_event 存在独立错误列。详细错误与报文在详情展开。
接收失败没有 job 时由 IF_REQ 独立展示;所有能定位到作业的动作都写 job_event。脚本运行 ID 使用现有 executor_run_id;无数据或启动失败的本轮摘要写应用日志,通过 run_id 查询,暂不新增运行历史表。
### 8.2 事务及恢复顺序
| 写入过程 | 事务范围 | 恢复办法 |
| --- | --- | --- |
| 接收成功 | Oracle:成功请求记录+所有接口头明细 | 事务失败整体返回失败;响应丢失可同消息重传 |
| 登记作业 | Oracle:同输入的合同和返利 job 一起幂等建立 | 成功后回写 IF_REQ.REGISTER_STATUS;回写失败下次重复登记返回同 job,再补 DONE |
| 节点成功 | Oracle:该 attempt 成功+业务编号+操作日志/证据索引+推进当前节点 | 回报丢失重发同完成键;同成功结果返回原状态,不再次推进 |
| 外部接口调用 | 先 Oracle 保存请求,事务提交后调用,返回后独立事务记结果 | 超时保留 UNKNOWN,不把网络调用放在数据库事务中 |
| 人工恢复 | Oracle:核查结果、状态变更和操作日志 | 同一操作请求 ID 重复提交返回原结果 |
Oracle 接口输入和 Oracle 执行结果不要求同库事务,也不把执行状态反复写回 Oracle。接口表登记标志失败最多导致再次尝试登记,组合唯一约束保证不多建合同/返利作业。运维总览查两库时若 Oracle 不可用,仍可展示 Oracle 已保存的执行结果,并标记“接口原文暂不可查”,不能误显示数据不存在。
### 8.3 文件和配置
1 药城与药帮忙分别配置共享根目录和字段映射;现场路径由配置提供。下载完成后发布文件,文件名按批次区分,保留原件。后台返回文件列表,业务/运维通过现有证据入口打开或下载,不要求共享盘在所有机器使用同一盘符。
订单中心连接、凭据、超时、链路节点映射、四条排期、重试间隔及模板配置集中管理。真实环境缺必要配置返回明确错误,不自动使用 fixture。日期、数量和金额按第 4、7 章数据类型保存,日志不保存 token 或数据库密码。
## 9. 详细设计:正常路径与异常处理
### 9.1 正常路径示例
本例标识仅用于说明字段关系,不代表正式接口报文。
| 时间/动作 | 接口记录 | 合同保存 | 链路更新 | 返利 | 记录位置 |
| --- | --- | --- | --- | --- | --- |
| 接收 M001 | ACCEPTED,INPUT_ID=I001,REGISTER=PENDING | 未登记 | 未登记 | 未登记 | IF_REQ+ORDER_H/L |
| 任一相关脚本启动登记 | REGISTER=DONE | PENDING | 未开始 | WAIT_CONTRACT | 两个 job 都绑定 I001/1 |
| 合同脚本处理 | 原输入不变 | RUNNING | 未开始 | WAIT_CONTRACT | CREATE 第 1 次尝试 |
| 合同保存成功 HT001 | 原输入不变 | SUCCESS | PENDING | 可领取 | 合同号及保存证据 |
| 更新链路成功 | 原输入不变 | SUCCESS | SUCCESS | 可领取 | 链路请求、回执;合同 job 完成 |
| 返利下次运行 | 原输入不变 | SUCCESS | SUCCESS | RUNNING | 读取 I001 同一头明细+HT001 |
| 返利提交完成 FL001 | 原输入不变 | SUCCESS | SUCCESS | SUCCESS | 返利编号、模板及提交结果 |
### 9.2 异常矩阵
| 异常代码(建议) | 识别条件 | 状态和保留内容 | 处理及恢复位置 |
| --- | --- | --- | --- |
| INPUT_REQUIRED | 本次共用数据缺合同或返利上游必填 | IF_REQ=REJECTED,字段路径/行号,无业务输入 | 上游补齐后重传;RPA 无需处理 |
| INPUT_CONFLICT | 成功来源/版本再传不同内容 | CONFLICT,关联旧请求、保存新原文 | 核对来源及版本;不覆盖已使用数据 |
| STORE_FAILED | 接口表事务失败 | 返回 503;应用日志 trace_id | 修复数据库后重传 |
| JOB_REGISTER_FAILED | Oracle 已接收,Oracle 登记失败 | REGISTER=FAILED、原因;输入 READY | 恢复 Oracle,下一次脚本取数补登记 |
| PLATFORM_QUERY_FAILED | 页面打不开或查询报错 | 行 job 明确失败,截图/错误 | 修复后重试该行;不记 SKIPPED |
| NO_ELIGIBLE_PRODUCT | 查询成功但不满足采购条件 | job SUCCESS,result=SKIPPED、原因 | 正常结束,常规轮询不重复处理 |
| FILE_SAVE_FAILED | 共享目录不可写/文件未完整 | ORDER_PUSH 失败、已知路径 | 修复目录后从导出/归档失败处继续 |
| OC_ORDER_REJECTED | 订单中心明确业务拒收 | push=REJECTED,拒绝原因及范围 | 修正接口映射/源数据后处理失败单,成功单保留 |
| EXTERNAL_RESULT_UNKNOWN | 加购、保存、推送或提交后无确定结果 | WAITING_EXTERNAL/UNKNOWN,请求或页面证据 | 先核对目标结果;确认未成功才重试 |
| CONTRACT_DUE_DILIGENCE | 合同页面显示未尽调 | WAITING_USER、采购员待办和页面记录 | 按原业务人工处理,核验保存结果后继续链路 |
| CHAIN_UPDATE_FAILED | 合同已保存,链路明确失败 | CREATE 成功;CHAIN 失败、回执 | 下次仅重试 CHAIN;合同号不清空 |
| PROTOCOL_NOT_READY | 已有合同号,但返利协议不可查/暂不可操作 | 返利 WAITING_EXTERNAL,原因及检查时间 | 到期再查协议;不重建合同,不等第二次供数 |
| REBATE_SUBMIT_FAILED | 返利系统明确提交失败 | 失败步骤、目标协议/总单、已导入信息 | 按原业务交人工处理;保留前面成功步骤 |
| EXECUTOR_INTERRUPTED | 作业仍 RUNNING,进程已退出 | 执行机/run_id、最近节点、证据 | 运维确认无运行进程并核对结果后,恢复当前步骤 |
所有正常失败恢复保留原 INPUT_ID、job 和历史 attempt。通用“重试”只增加当前失败节点尝试,不把 job 从第一个节点重新执行。已完成外部写入不能仅凭“失败”按钮反向当作没有发生。
## 10. 运维设计:如何查看与处理
### 10.1 查询入口及页面内容
复用现有列表、节点详情、时间线、待办和证据组件,新增接收列表和“同一输入的合同/返利总览”,不用另建运维平台。
| 页面 | 筛选条件 | 列表/详情内容 |
| --- | --- | --- |
| 接口接收列表 | 时间、来源单号、MESSAGE_ID、TRACE_ID、接收状态、登记状态 | 请求/返回原文、缺字段路径、INPUT_ID、重复来源、登记失败原因 |
| 合同返利总览 | INPUT_ID、来源单号、合同号、返利号、三个业务状态、时间 | 同一行显示合同保存、链路更新、返利状态及最近错误;点击进入各 job |
| 作业详情 | flow、job_id、状态、执行机、run_id | 来源输入、当前节点、每次尝试、耗时、错误、回报、外部编号 |
| 订单推送列表 | 平台/账号、订单号、导出批次、push_status | 原文件、发送内容、回执、次数、结果未知/拒收原因 |
| 操作和证据 | job/INPUT_ID、操作者、动作时间 | 谁点击重试/人工处理,前后状态、原因、截图、模板及日志 |
“合同返利总览”由后端以 Oracle INPUT_ID 为基底,合并两份 Oracle job/节点结果;不存在 job 时显示未登记。分页/筛选由后端实现,不要求浏览器一次拉全表。只有接口原文不可查时保留错误提示,已存在的执行结果仍显示。
### 10.2 典型查看路径
| 运维问题 | 如何查 | 结论/下一步 |
| --- | --- | --- |
| 订单中心说推过,RPA 没处理 | 先查 MESSAGE_ID/来源单号的接收记录 | REJECTED 看缺字段;ACCEPTED+登记失败看数据库错误;DONE 看对应 job |
| 合同存在,为什么流程显示失败 | 总览看合同保存和链路更新两列 | 保存成功/链路失败只补链路调用,不重建 |
| 返利为什么一直没开始 | 看同 INPUT_ID 的合同保存结果及返利 wait_reason | 合同未保存则正常等待;保存成功但协议不可查则按目标状态处理 |
| 同一批有的成功有的失败 | 订单推送按源订单筛选,选品按行 job 查看 | 重试失败项;已接收/已加购项不重复处理 |
| 数据被改过或重复推过 | 接收尝试记录+DATA_HASH+操作日志 | 明确首次输入、重复/冲突报文及处理人 |
### 10.3 运维动作及权限
| 动作 | 允许条件 | 实际改变 | 必须留痕 |
| --- | --- | --- | --- |
| 重试当前步骤 | FAILED_RETRYABLE,且外部结果已明确 | 当前节点新增 attempt,job 转 PENDING | 操作人、原因、旧状态、新 attempt |
| 重新登记作业 | 已 ACCEPTED 且 REGISTER≠DONE | 幂等补建缺失 job,完成登记标志 | 接收记录、结果、操作者 |
| 核对结果:已成功 | UNKNOWN 或人工完成,提供实际业务编号/回执证据 | 确认对应节点成功并推进;合同保存后仍执行链路 | 目标编号、证据、核对人和时间 |
| 核对结果:未成功 | 确认目标未写入,原执行进程已停止 | 转可重试当前步骤 | 核对依据、操作者、原因 |
| 转业务处理 | 业务错误或系统要求人工 | 创建/复用现有 user_task,保存责任人 | 原因、接收人、操作记录 |
| 停止办理 | 有明确业务指令 | CANCELLED,保留已产生结果 | 原因及外部已发生操作,不自动撤销外部单据 |
只读运维可看列表及证据;恢复动作限定 OPS,业务待办按采购员等现有角色处理。恢复入口必须核对实际操作者身份,不能只相信请求体里填写的 operator。沿用现有鉴权方案补动作权限,不另加业务审批流。
不能用“统一确认并继续”替代所有核对动作,不能直接改接口原始字段或数据库状态。金额、货品等源数据错误由订单中心修正重传;已被使用的输入按冲突规则处理。
### 10.4 查询 API 及日志定位
| API(建议) | 用途 |
| --- | --- |
| `GET /api/jieda/receipts`、`/receipts/{request_log_id}` | 无 job 的失败接收也能查 |
| `GET /api/jieda/orders`、`/orders/{input_id}` | 同一输入的合同/链路/返利总览及头明细 |
| `GET /api/jieda/order-pushes` | 按源订单查推送及文件 |
| 现有 `GET /api/jobs/{job_id}`、`/timeline`、`/evidence-package` | 节点、历史尝试和证据 |
| `GET /api/jobs/{job_id}/events`(新增) | 操作日志和出站请求/返回记录 |
| `POST /api/jieda/receipts/{id}/register`(新增) | 运维手动补登记,与脚本取数复用同一逻辑 |
| `POST /api/jobs/{job_id}/verify-result`(新增) | 按第 10.3 节核对 UNKNOWN/人工结果,记录依据后恢复 |
应用日志统一携带 `trace_id、input_id、job_id、flow_code、node_code、attempt_no、run_id` 中当前已知项。接收阶段没有 job_id 时不编造;执行阶段没有 HTTP trace 时以 job/run 定位。列表展示最近错误,详情保留全部历史,不只覆盖最后一次失败。
索引建议:接口记录按时间/状态/来源,业务头按源单号,job 按 `(flow_code,status,next_check_at)` 和 `(input_id,input_version)`,节点按现有 `(job_id,node_code,attempt_no)`,事件按 `(job_id,created_at)`,推送按来源唯一键及 `(push_status,next_retry_at)`。
## 11. 实施影响与验收
### 11.1 现有代码到新设计的映射
| 现有位置 | 已核对现状 | 实施改动 |
| --- | --- | --- |
| [main.py 建单](/Users/yuuuu/Documents/AutoRpa/state_center/app/main.py:499) | 供应商+日期键,固定首节点,只复用未完成作业 | 新接收/登记/领取按 flow 和 INPUT_ID;已完成作业也防重 |
| [schema.sql](/Users/yuuuu/Documents/AutoRpa/state_center/sql/schema.sql:11) | job 无 flow/input 关联;已有节点、日志及证据表 | 第 4 章 Oracle 接口表及推送台账;job 扩展字段/索引,supplier_name/order_date 允许空;job_event 错误保存在现有 detail_json |
| [db.py](/Users/yuuuu/Documents/AutoRpa/state_center/app/db.py:22) | Oracle 默认 autocommit=True | 对登记、领取、结果及推进使用显式短事务 |
| [main.py 完成回报](/Users/yuuuu/Documents/AutoRpa/state_center/app/main.py:712) | 保存节点、编号和推进分步执行;节点定义全局固定 | 同事务回报,校验 execution_token/attempt;按 flow 定义完成规则 |
| [main.py 重试](/Users/yuuuu/Documents/AutoRpa/state_center/app/main.py:812) | 已保留 attempt 历史、重试当前节点 | 保留机制,支持共用输入关联及 UNKNOWN 核对 |
| [main.py 人工提交](/Users/yuuuu/Documents/AutoRpa/state_center/app/main.py:1346) | 通用确认可直接推进;RETRY_CURRENT_NODE 分支当前只记录事件 | 按第 10.3 节校验结果和动作,实现真实重试及核查恢复 |
| [agent.py](/Users/yuuuu/Documents/AutoRpa/rpa_yisaiqi/agent.py:1) | 通用代理主要调用 mock;存在节点硬编码与默认 attempt=1 | 四条独立入口、真实 runner、领取结果中的 attempt/token |
| [contract_create_node.py](/Users/yuuuu/Documents/AutoRpa/rpa_yisaiqi/contract_create_node.py:241) | 已校验空明细及正数量,仍要求旧 customerCode | 适配共用接口数据和客商定位;保留有效输入检查 |
| [contract_flow_steps.py](/Users/yuuuu/Documents/AutoRpa/rpa_yisaiqi/contract_flow_steps.py:788) | 缺价补 1.00,同码首行及前 30 行定位 | 移除缺价补值;按来源明细/目标行定位并核对实际导入结果 |
| [Web 节点详情](/Users/yuuuu/Documents/AutoRpa/state_center_web/src/components/JobNodeDetailList.tsx:1) | 已展示时间、尝试次数、错误及证据 | 复用组件,补三个独立状态、输入关联、接收列表及恢复动作 |
当前 Oracle 配置主要服务货品主数据读取;新接口表写入连接和权限需现场配置。当前代码不等于已具备四条真实平台执行能力,平台查询/导出及返利页面模块需要核对或实现。
### 11.2 实施顺序
1. 按共同输入契约核对第 4、7 章字段及合同/返利范围;确认现场接口表复用方式。
2. 完成接收校验、Oracle 同事务保存及接收查询,验证失败重传和重复消息。
3. 增加 flow/input 关联、作业登记、独立状态、领取及结果事务,跑通“同一输入两个 job”。
4. 接平台订单导出、共享文件、台账及推送回执。
5. 接合同页面保存和链路更新;返利读取同 INPUT_ID 与合同号完成提交。
6. 接逐条选品;补齐异常及运维动作,配置四条排期后联调。
### 11.3 验收用例
| 编号 | 场景 | 预期结果 |
| --- | --- | --- |
| AT-01 | 一次完整 ORDER_COMMON 推送 | 一份头明细;登记两个相同 INPUT_ID/版本的合同、返利 job |
| AT-02 | 仅返利必填缺失 | 当次返回字段错误,无业务输入;补齐后可重传 |
| AT-03 | 相同请求重发、相同对象换消息 ID 重发 | 返回原输入,不新增执行作业 |
| AT-04 | 同已成功来源/版本传不同内容 | 冲突可查,旧数据和外部结果不被覆盖 |
| AT-05 | Oracle 已提交,Oracle 登记失败 | 接收记录显示登记失败;后续幂等补登记,不要求上游再推 |
| AT-06 | 返利脚本先于合同脚本启动 | WAIT_CONTRACT;无额外供数请求、无返利提交 |
| AT-07 | 合同保存成功,链路更新失败 | 保存 SUCCESS、链路 FAILED;只恢复链路,返利可按保存条件领取 |
| AT-08 | 合同完成后返利按另一排期启动 | 读取同一接口数据和本方合同号,字段来源可追溯 |
| AT-09 | 平台订单重复导出到不同文件 | 原件保留,已接收同源订单不重复入单 |
| AT-10 | 推送部分成功/超时 | 按实际回执记录逐单结果;UNKNOWN 不直接按失败重发 |
| AT-11 | 选品合格/不合格/查询失败 | 分别 ADDED、SKIPPED、FAILED;本批结束,不等付款 |
| AT-12 | 脚本中断和重复点击 | 不重复领取;已有结果可查,确认停止并核对后恢复当前步骤 |
| AT-13 | 合同已保存但回报丢失 | 重发完成键或核对目标结果,不再次建合同 |
| AT-14 | 未尽调或返利提交失败 | 保留输入及原业务待办,其他记录继续;人工结果核对可留痕 |
| AT-15 | 46 个 Excel RPA 字段 | 共用接口来源、固定值及日期按映射填报,缺价不补 1.00 |
| AT-16 | 运维按 MESSAGE_ID、INPUT_ID、合同号查询 | 能串起接收、两流程、尝试、文件、请求和人工动作 |
| AT-17 | 未授权恢复、旧 token 回报 | 拒绝写入;旧成功结果不被覆盖 |
| AT-18 | 新旧流程并存 | 旧在途单保留原节点;新流程不误入对码/OCR/M5 |
文档检查与业务验收分开:当前只完成设计;以上需要在测试数据库、实际共享目录及外部测试系统执行,mock 成功不代表真实业务通过。
## 12. 未决事项、风险及对其他模块的影响
### 12.1 联调前需要确认
| 项目 | 已明确 | 剩余信息 |
| --- | --- | --- |
| 合同返利供数 | 一次推送共用 | 正式头明细、业务范围标识、消息 ID/版本;一组数据是否有多合同/多返利范围 |
| 业务字段 | Excel 来源及默认值 | 实际 JSON 列名;采购/返利数量是否同字段,价格口径及精度 |
| 订单出站接口 | 本方归档后推送 | 接收 URL、字段、批量/逐单回执、幂等和查询能力 |
| 链路更新 | 合同保存成功后更新对应节点 | 节点代码、链路关联、合同标识字段及成功返回码 |
| 返利可操作性 | 用同一份输入加已保存合同号 | 目标协议生成时间、是否依赖审批或链路更新;未确认前不新增业务审批门槛 |
| 平台和共享目录 | 两平台分别导出归档 | 真实账号、订单状态/范围、文件样例、共享路径及四条排期 |
| 盈利 | 5% 以上盈利 | 计算基数及边界;不直接套用旧方案 0.95 公式 |
| 现场库和模板 | 接口输入与处理记录分开 | 现有 Oracle 表、权限、正式模板和确有要求的附件 |
### 12.2 可能问题与控制位置
| 问题 | 影响 | 对应设计 |
| --- | --- | --- |
| 共用一个 processed 标志 | 合同处理后返利漏取 | 第 5 章分离合同保存、链路、返利标志 |
| 两份接口输入被分别修改 | 合同/返利货品数量不一致 | 同 INPUT_ID/版本只保留一份原始头明细 |
| 成功接收后没有 job | 数据落表但长期未执行 | REGISTER_STATUS 和取数前补登记;运维可查 |
| 接口/页面结果未知被当失败 | 重复订单、合同或返利 | UNKNOWN 核对后恢复,不无条件重做 |
| 把链路失败当合同未保存 | 再建一份合同 | 保存节点结果与链路节点独立 |
| 当前合同缺价补值或明细定位错误 | 错金额、同码行覆盖或漏行 | 接收校验及第 11.1 节代码修正;单规格规则不消除多订单行的问题 |
| 原人工确认直接推进 | 未核实外部结果就完成作业 | 第 10.3 节按动作和证据校验 |
| 新旧流程改全局状态机 | 旧对码、OCR、M5 在途单无法继续 | flow/version 分开,旧单保持原链;切换前确定同一来源由哪套办理 |
| 两库或共享目录不可用 | 接收失败、原文不可查、文件不可达 | 独立错误可见;执行记录保留,不伪装成空数据或成功 |
本稿的表与字段是详细设计建议,尚未核对现场字典。对外契约尚未提供的部分保留为明确缺口;不以增加业务操作、复制数据或自动猜规则来填补。

View File

@ -0,0 +1,120 @@
# 福建洁达采购 RPA 概要设计
| 文档属性 | 内容 |
| --- | --- |
| 版本 | V3.1 |
| 日期 | 2026-09-13 |
| 状态 | 设计评审稿;未实施代码或建表 |
| 适用系统 | 当前 AutoRpa 状态中心、RPA 执行程序及福建洁达采购业务 |
| 本版核心关系 | 订单中心一次推送合同/返利共用数据;合同先处理,返利按自己的排期读取同一份数据 |
| 文档范围 | 概要设计、表与状态详细设计、接口及时序、异常恢复、运维查询、实施与验收 |
| 替代关系 | 整合原 Codex 设计稿 V1.9 和技术实现说明 V2.3;旧方案仅供历史参考 |
**阅读顺序:** 业务评审看第 1–3 章;开发看第 4–9 章;运维看第 10 章;实施验收及未决问题见第 11–12 章。
> 详细的数据模型、接口字段、状态、时序、异常、运维和验收见[《福建洁达采购 RPA 详细设计》](./2026-09-13-jieda-rpa-detailed-design.md)。
## 1. 设计依据与范围
### 1.1 已确认业务规则
| 编号 | 规则 |
| --- | --- |
| BR-01 | 四条脚本分别运行,自动时间和频率不同,也支持业务点击;一次 HTTP 接收不等待四条流程全部执行 |
| BR-02 | 选品读取未处理销售明细,在 1 号药城和药帮忙均按同一套采购条件执行:按批准文号查询,依次校验自营仓、盈利、有效期、库存,再按仓库距离选择;合格加购物车,不合格跳过,处理完本批结束;不等待人工付款 |
| BR-03 | 本业务相同批准文号只有一个规格,不增加规格、厂家、包装或单位换算匹配 |
| BR-04 | 平台订单导出到各平台对应共享文件夹,按订单中心接口格式组装并推送;对码由订单中心内部处理 |
| BR-05 | 订单中心一次传来的完整业务数据供合同、返利共用,不拆成两次独立供数,不重复保存两份相同头明细 |
| BR-06 | 接收时检查上游应传的必填字段,缺项当次返回失败和字段位置;通过并落表后返回成功 |
| BR-07 | 合同保存成功后调用订单中心链路接口更新对应节点;返利读取同一份接口数据,并使用本方已取得的合同号 |
| BR-08 | 手工单暂不做;合同/返利字段、固定值及原业务人工分支按提供材料执行 |
| BR-09 | RPA 将平台订单的对码原始信息随订单报文传给订单中心;订单中心返回或后续提供标准货品结果,本方不自行判断货品编码 |
### 1.2 参考材料
| 材料 | 用途 |
| --- | --- |
| [合同系统录入流程](/Users/yuuuu/Documents/AutoRpa/资料/合同系统录入流程.md) | 合单、尽调、提交、回写;页面步骤指向 rpa_yisaiqi |
| [选品条件 20260902](/Users/yuuuu/Documents/AutoRpa/资料/采购平台选品条件优先项_20260902.txt) | 自营仓、盈利、效期、库存和仓库顺序 |
| [合同返利字段映射 20260911](/Users/yuuuu/Documents/AutoRpa/资料/合同返利系统字段映射_20260911.xlsx) | 两个工作表的 46 个 RPA 字段及 Sheet3 导入列 |
| [仓库一期技术设计](/Users/yuuuu/Documents/AutoRpa/资料/采购主链RPA一期设计文档.md) | 参考概要/详细设计、实体、接口、状态和审计的组织方式;不沿用其旧业务范围 |
| [现有数据库结构](/Users/yuuuu/Documents/AutoRpa/state_center/sql/schema.sql) | 判断 job、节点、待办、日志及证据记录可复用部分 |
本次会话的业务确认优先于旧文档。下文表名、内部代码、API 路径及列类型为本方建议,正式外部字段名、节点代码和现场 Oracle 结构仍需按第 12 章核对。
## 2. 概要设计:架构与分工
### 2.1 总体架构
```mermaid
flowchart TB
OC[订单中心] -->|销售数据 / 合同返利共用数据| API[FastAPI 接收校验]
API -->|校验成功同事务落表| IFDB[(Oracle 接口头明细及接收记录)]
S[四条独立排期 / 点击脚本] --> RPA[RPA 执行程序]
RPA <-->|领取数据 / 回报结果| SC[状态中心]
SC -->|读取同一份输入| IFDB
SC <--> BDB[(Oracle 作业、节点、调用记录)]
RPA -->|查品加购 / 导出订单| PL[采购平台]
PL --> F[对应共享文件夹]
F -->|组装报文并推送| OC
RPA -->|录入保存| CT[合同系统]
RPA -->|保存后调用链路接口| OC
RPA -->|同一接口数据加合同号| RB[返利中心]
UI[运维列表 / 详情 / 日志] --> SC
```
接口数据与业务处理记录分开:**Oracle 接口表回答“订单中心传了什么”,Oracle 业务记录回答“本方做到哪一步、结果是什么、谁操作过”。** RPA 通过状态中心读取接口数据,数据库连接集中在后端。
### 2.2 接口表与业务表的选择
| 方式 | 特点 | 本期选择 |
| --- | --- | --- |
| 仅一张接口表加一个 processed 标志 | 简单,但合同处理后可能被整体标记完成,返利漏取;也无法表达多次失败尝试 | 不采用 |
| 接口表再完整复制成合同表、返利表 | 三份字段容易不一致,需要额外同步 | 不采用 |
| 接口头明细保留一份输入,业务作业/节点保存独立状态与结果 | 合同、返利通过相同 INPUT_ID 关联;历史操作复用现有日志 | **采用** |
现有 `job / job_node` 就是本期业务处理表,无需再复制合同、返利输入。为无 job 的接收失败补接收记录;为跨导出文件识别同一订单,补逐订单推送台账。这两项有明确查询及防重用途。
### 2.3 模块职责及运行方式
| 模块 | 职责 |
| --- | --- |
| FastAPI | 校验、保存接收数据、取数、原子领取、保存回报、查询及运维动作 |
| Oracle 接口库 | 接收尝试、销售头明细、合同/返利共用头明细;成功输入保留原值 |
| Oracle 状态中心 | 各流程 job、节点尝试、业务编号、订单推送状态、操作日志、证据索引 |
| 四条 RPA 脚本 | 本批逐条执行;平台订单和合同链路接口由对应脚本通过适配器调用并回报 |
| 现有 Web | 接收查询、共用数据处理总览、节点尝试、文件和运维动作 |
四条脚本分别配置时间、频率及本轮条数上限,单执行机每条脚本设单实例启动控制。FastAPI 常驻。脚本启动时调用后端登记未建立的 job,再领取本流程数据;订单导出先生成并登记 export_batch_id,恢复失败批次沿用该 ID;本批结束释放本机运行锁。没有数据时记录本轮零条并退出,不新增第五条业务或后台维护流程。
## 3. 概要设计:业务流程与完成边界
| 流程 | 输入 | 正常处理 | 完成含义 |
| --- | --- | --- | --- |
| PROCURE | 销售接口表的一条明细 | 按批准文号查询,按采购条件加购或跳过 | 该行处理完;不代表付款成交 |
| ORDER_PUSH | 对应平台导出的订单文件 | 保存共享目录、提取对码原始信息、组装、推送、记录回执 | 应推送订单确认接收;不等待内部对码 |
| CONTRACT | 共用订单接口头明细 | 录入并保存合同,保存合同号,更新订单中心链路 | 保存及链路更新分别记录;链路失败不重建合同 |
| REBATE | **同一共用订单接口头明细**+本方合同保存结果 | 按合同号查协议、填总单、导细单、提交 | 返利提交成功;不要求订单中心再次推数据 |
### 3.1 合同与返利的关联
```mermaid
flowchart TD
I[一次推送:INPUT_ID = I001] --> C[合同 job:INPUT_ID = I001]
I --> R[返利 job:INPUT_ID = I001]
C --> CS[合同保存成功:合同号 HT001]
CS --> CH[合同链路更新]
CS -->|提供合同号| R
R --> RS[返利提交结果]
```
合同、返利是两个独立作业,绑定同一 INPUT_ID 和 INPUT_VERSION。合同保存号只从对应合同 job 取得,不能按同品种或同一天猜配。
返利默认在“合同已确认保存成功且合同号存在”后可领取;链路回传结果另列显示,**不擅自把链路成功增加为返利前置条件**。若目标返利协议暂不可查,记录等待原因,下次返利运行再查询。是否必须审批通过由目标系统及接口方确认,不新增审批动作。
### 3.2 选品规则
1 号药城和药帮忙共用以下选品判断逻辑。平台只负责登录、搜索、读取商品信息和执行加购,不能改变判断优先级。自营仓、5% 以上盈利、有效期大于等于 12 个月、库存满足采购数量四项都通过后,按下列仓库顺序选择。不合格记跳过,平台技术错误记失败。
广东壹号药业有限公司、昆山亿方药业有限公司、湖北亿昊药业有限公司、天津壹号药业有限公司、重庆亿昊药业有限公司、重庆壹号医药有限公司、湖南壹号药业股份有限公司、辽宁亿昊药业有限公司、河南壹号医药有限公司、甘肃壹号云医药有限责任公司企业店、陕西壹号药业有限公司。

View File

@ -0,0 +1,498 @@
# 洁达选品:改造商城查价工程(详细方案)
> Windows 按本文改同事原工程。概要口径见 [2026-09-14-jieda-procure-crawler-overview.md](./2026-09-14-jieda-procure-crawler-overview.md)。
> 不要改仓库一期状态机,不要把 ±10% 比价逻辑留在主流程里。
| 文档属性 | 内容 |
| --- | --- |
| 版本 | V1.0 |
| 日期 | 2026-09-14 |
| 执行环境 | Windows + 艺赛旗 Python 应用(`isa-rpa` + `uv`) |
| 基线代码 | 同事工程 `main.py`(约 907 行) |
| 建议工作副本 | 拷一份到本仓库 `rpa_yisaiqi/procure_mall/`,或 Windows 本地独立目录;不要在原 zip 上直接当唯一真源 |
---
## 1. 原工程:留什么、删什么、补什么
### 1.1 原样保留(不要重写)
| 函数 / 能力 | 作用 |
| --- | --- |
| `SecretClient` + `YAOEX_SECRET_NAME` / `YBM_SECRET_NAME` | 密码箱登录 |
| `get_yaoex_auth` / `ensure_yaoex_logged_in` | 1 号药城登录,抽 `token / userId / buyerCode` |
| `get_ybm_cookies` / `ensure_ybm_logged_in` | 药帮忙登录,抽 cookie |
| `build_yaoex_search_request` / `query_yaoex_products` | `homeSearchList`,`keyword=批准文号`,`per=20`,`deadLineStart=12` |
| `decrypt_yaoex_price` / `build_yaoex_price_decrypt_key` | 价格 AES 解密 |
| `build_ybm_search_payload` / `query_ybm_products` | `list-products`,`queryWord=批准文号`,`isThirdCompany=0`,`isEffectPeriodOverOneYear=1` |
| `post_json_with_retry` | 3 次重试 |
| 按批准文号缓存 | 同一文号不打两次 |
搜索接口:
- 1 号药城:`POST https://gateway-b2b.fangkuaiyi.com/home/search/homeSearchList`
- 药帮忙:`POST https://www.ybm100.com/new-front/search/list-products`
字段映射继续用原代码:
| 结果字段 | 1 号药城 | 药帮忙 |
| --- | --- | --- |
| 平台索引ID | `spuCode` | `productInfo.skuId` |
| 平台名称 | `shortName` | `productInfo.showName` |
| 平台规格 | `spec` | `productInfo.spec` |
| 平台价格 | `decrypt(price)` | `price`,空则 `fob`;库存 0 时价当 0 |
| 效期 | `deadLine` | `productInfo.nearEffect` |
| 商家 | `vendorName` | `productInfo.shopName` |
| 库存 | `stockCount` | `productInfo.availableQty` |
### 1.2 必须删除或停用
- `is_in_purchase_price_band`
- `filter_rows_by_purchase_price_band`
- `main()` 里「价格区间过滤后总行数」那段
- 把全部候选行当最终输出
### 1.3 必须新增
- 选品规则引擎(两平台共用)
- 按行决策:每行每平台 0 或 1 个仓
- 加购适配器(接口优先,页面兜底)
- `--dry-run` / `--limit` / `--platform`
- 新结果表(结论 + 原因,不再是比价明细大表)
---
## 2. 建议拆文件
原 `main.py` 已 900 行,不要再往里堆加购和规则。Windows 按下列拆;若艺赛旗工程限制只能单文件,也必须把规则和加购写成独立函数,禁止写进 `query_*`。
```text
procure_mall/
pyproject.toml # 沿用原工程
main.py # 只编排:读入 → 登录 → 循环行 → 写结果
search_yaoex.py # 从 main.py 搬登录+查询+解密
search_ybm.py # 从 main.py 搬登录+查询
http_util.py # post_json_with_retry 等
rules.py # 合格判定 + 仓库排序
cart_yaoex.py # 1 号药城加购
cart_ybm.py # 药帮忙加购
io_excel.py # 读输入、写结果
tests/test_rules.py # 规则单测,不连商城
```
`main.py` 目标骨架:
```python
def main() -> None:
args = parse_args() # --dry-run 默认 True;--limit N;--platform all|yaoex|ybm
rows = load_input_rows(args.input_path)
if args.limit:
rows = rows[: args.limit]
yaoex_auth = asyncio.run(get_yaoex_auth()) if args.platform in ("all", "yaoex") else None
ybm_cookies = asyncio.run(get_ybm_cookies()) if args.platform in ("all", "ybm") else None
decisions = []
for purchase in rows:
if yaoex_auth is not None:
decisions.append(run_one_platform(purchase, "1号药城", yaoex_auth, dry_run=args.dry_run))
if ybm_cookies is not None:
decisions.append(run_one_platform(purchase, "药帮忙", ybm_cookies, dry_run=args.dry_run))
write_decision_file(decisions)
```
单行单平台:
```python
def run_one_platform(purchase, mall_name, auth, dry_run=True) -> dict:
try:
candidates = search_candidates(mall_name, auth, purchase["批准文号"])
except Exception as exc:
return decision(purchase, mall_name, status="FAILED", reason=f"QUERY_FAILED:{exc}")
picked, rejected = select_warehouse(candidates, purchase)
if picked is None:
return decision(purchase, mall_name, status="SKIPPED", reason=rejected_summary(rejected), raw=candidates)
if dry_run:
return decision(purchase, mall_name, status="ADDED", reason="DRY_RUN", picked=picked)
try:
cart_ref = add_to_cart(mall_name, auth, picked, qty=purchase["采购数量"])
except Exception as exc:
return decision(purchase, mall_name, status="FAILED", reason=f"CART_FAILED:{exc}", picked=picked)
return decision(purchase, mall_name, status="ADDED", reason="CART_OK", picked=picked, cart_ref=cart_ref)
```
查询失败不得改成 SKIPPED。
---
## 3. 输入 / 输出
### 3.1 输入 Excel(改模板,不要沿用只有采购价格的比价模板)
路径建议:`桌面/福建商城选品测试/选品输入.xlsx`
第一行表头,从第 2 行起数据。
| 列名 | 必填 | 说明 |
| --- | --- | --- |
| 行号 | 否 | 没有则按 Excel 行号 |
| 批准文号 | 是 | 查询关键字;空行跳过 |
| 销售价格 | 是 | 毛利基准。缺或无法转数字 → 该行两平台都 SKIPPED,原因 `NO_SALE_PRICE` |
| 采购数量 | 是 | 库存门槛和加购数量。缺或 ≤0 → SKIPPED,原因 `NO_QTY` |
| 通用名 | 否 | 回填 |
| 规格 | 否 | 回填,不参与匹配 |
| 生产厂家 | 否 | 回填,不参与匹配 |
本期**不要用采购价格做合格判断**。模板里可以留着采购价格列,规则引擎忽略它。
`--limit 5` 只跑前 5 条有效行。
### 3.2 输出 Excel
路径:`桌面/福建商城选品测试/结果/选品结果_YYYYMMDD_HHMMSS.xlsx`
一行 = 一个输入行 × 一个平台。
| 列名 | 说明 |
| --- | --- |
| 行号 | 输入行 |
| 批准文号 | |
| 通用名 | |
| 销售价格 | |
| 采购数量 | |
| 商城 | `1号药城` / `药帮忙` |
| 结论 | `ADDED` / `SKIPPED` / `FAILED` |
| 原因 | 见 3.3 |
| 选中商家 | ADDED 时必填 |
| 平台索引ID | sku/spu |
| 平台名称 | |
| 平台规格 | |
| 平台价格 | |
| 效期原文 | |
| 库存 | |
| 毛利率 | `(销售价-平台价)/销售价`,保留 4 位小数 |
| dry_run | `Y/N` |
| 加购凭证 | 接口回执或页面摘要;dry-run 为空 |
| 候选商家清单 | 可选,分号拼接,便于核对为什么没选上 |
另写一份 `未匹配商家_时间戳.txt`:所有出现过、但不在 11 家名单里的商家名(去重)。用来决定要不要加别名。
### 3.3 原因码
| 原因码 | 结论 | 何时 |
| --- | --- | --- |
| `NO_SALE_PRICE` | SKIPPED | 销售价缺失 |
| `NO_QTY` | SKIPPED | 采购数量缺失或 ≤0 |
| `NO_HIT` | SKIPPED | 接口无货或空列表 |
| `NO_SELF_WAREHOUSE` | SKIPPED | 有货但不在 11 家 |
| `MARGIN_LT_5PCT` | SKIPPED | 自营仓有货但毛利不够(可与其它原因并列,取「没有任何候选过齐 1~4」) |
| `EXPIRY_LT_12M` | SKIPPED | 效期不够 |
| `STOCK_LT_QTY` | SKIPPED | 库存不够 |
| `NO_CANDIDATE_AFTER_RULES` | SKIPPED | 1~4 筛完为空(推荐作为总原因,明细放候选清单) |
| `DRY_RUN` | ADDED | 规则通过,未真正加购 |
| `CART_OK` | ADDED | 加购成功 |
| `QUERY_FAILED:...` | FAILED | 搜索接口失败(已重试) |
| `CART_FAILED:...` | FAILED | 加购失败 |
| `CART_UNKNOWN:...` | FAILED | 加购后无法确认是否进车 |
SKIPPED 的总原因用 `NO_CANDIDATE_AFTER_RULES`,并在「候选商家清单」里带每条被淘汰原因。不要只写一个模糊的「不合格」。
---
## 4. 规则引擎(`rules.py`)
两平台只把候选翻译成同一结构,再进引擎。平台代码禁止自己改优先级。
```python
@dataclass
class Candidate:
mall_name: str
sku_id: str
name: str
spec: str
price: float
expiry_raw: str
expiry_months: float | None
vendor_raw: str
vendor_norm: str
stock: float
extra: dict
@dataclass
class PurchaseLine:
approval_no: str
sale_price: float
qty: float
```
### 4.1 商家规范化
```text
去首尾空格
去掉前缀 "[自营]"(全角空格也去)
压缩连续空格为单空格
```
规范化后必须 **等于** 下面 11 个字符串之一,顺序即优先级(下标越小越优先):
```python
WAREHOUSE_ORDER = [
"广东壹号药业有限公司",
"昆山亿方药业有限公司",
"湖北亿昊药业有限公司",
"天津壹号药业有限公司",
"重庆亿昊药业有限公司",
"重庆壹号医药有限公司",
"湖南壹号药业股份有限公司",
"辽宁亿昊药业有限公司",
"河南壹号医药有限公司",
"甘肃壹号云医药有限责任公司企业店",
"陕西壹号药业有限公司",
]
```
本期不做别名、不做包含匹配。对不上的 `vendor_raw` 记入未匹配清单。
### 4.2 价格
```text
毛利 = (销售价格 - 平台价) / 销售价格
合格:毛利 >= 0.05
即:平台价 <= 销售价格 * 0.95
```
平台价无法转数字 → 该候选淘汰。销售价 ≤ 0 → 整行 SKIPPED。
**禁止**用采购价、禁止 ±10% 区间。
### 4.3 效期
目标:剩余 ≥ 12 个月。解析顺序:
1. 纯数字(含小数):当作月数。
2. 含「月」:抽出数字当月数。
3. 日期(`YYYY-MM-DD` / `YYYY/MM/DD` / `YYYY-MM`):`(到期日 - 今天) / 30.4375`,到期日按该月最后一天。
4. 都不是:`expiry_months = None`,该候选淘汰(原因 `EXPIRY_UNPARSEABLE`)。
接口虽已带 12 个月过滤,仍以本函数为准。
### 4.4 库存
`stock >= qty`。库存无法转数字或为 0 → 淘汰。药帮忙无货把价格改成 0 的旧逻辑可保留在映射层,引擎侧会因库存/价格再淘汰一次。
### 4.5 择仓
```text
passed = [c for c in candidates if 自营仓 and 毛利 and 效期 and 库存]
if not passed: return None
return min(passed, key=lambda c: WAREHOUSE_ORDER.index(c.vendor_norm))
```
同一仓库多条 SKU:取平台价更低的;再相同取库存更大的。不要两条都加购。
### 4.6 必须先写的单测(不连网)
文件:`tests/test_rules.py`。Windows 在改 `main.py` 前先把这些测过。
| 用例 | 期望 |
| --- | --- |
| 11 家名单内、毛利 6%、效期 13 月、库存够 | 选出该仓 |
| 毛利刚好 5% | 选出(`>= 0.05`) |
| 毛利 4.9% | 淘汰 |
| 商家不在名单 | 淘汰 |
| 商家带 `[自营] 广东壹号药业有限公司` | 视为广东壹号 |
| 广东和昆山都合格 | 选广东 |
| 只有昆山合格 | 选昆山 |
| 库存 9、采购 10 | 淘汰 |
| 效期 11 个月 | 淘汰 |
| 效期 `2027-09-14` 且今天起 ≥12 个月 | 过 |
| 空候选 | SKIPPED 语义(函数返回 None) |
| 销售价用 100、平台价 96 | 淘汰(毛利 4%) |
| 销售价 100、平台价 95 | 过 |
用采购价 ±10% 的旧例子(平台价 96、采购价 90)在新规则下若销售价是 100 应淘汰。加一条单测钉死,防止有人把 ±10% 加回来。
命令:
```bat
cd /d 工程目录
uv run pytest tests/test_rules.py -v
```
期望:全绿。未装 pytest 则 `uv add --dev pytest`。
---
## 5. 加购(原工程没有,这是新工作)
### 5.1 抓接口(每个平台做一次,记录进 `docs/cart-api.md` 或工程内 `CART_API.md`)
在 Windows 上用已经登录的账号:
1. 打开对应商城,F12 → Network,过滤 XHR/Fetch。
2. 搜索一个确定能加的自营仓商品。
3. 点「加入购物车」,看新出现的请求。
4. 记录:URL、方法、请求头(cookie / token 哪些必要)、body、成功响应长什么样。
5. 把 body 里的 sku/spu、数量、商家 ID 标出来。
1 号药城登录态用 `ycgltoken` 等;药帮忙用整份 cookie。加购请求必须带上搜品时同一套认证,不要重新登录。
若加购接口有 CSRF / 签名 / 人机验证:不要硬解,改页面点击(5.3)。
### 5.2 接口加购(优先)
`cart_yaoex.add(auth, candidate, qty) -> cart_ref`
`cart_ybm.add(cookies, candidate, qty) -> cart_ref`
- 数量 = 采购数量。
- 成功:响应明确成功,或随后查购物车能看到该 sku + 数量。
- 响应含糊:结论 `FAILED`,原因 `CART_UNKNOWN`,不要报 ADDED。
- 已在车里:视为成功(幂等),不要再加一次导致数量翻倍。先查车再加。
### 5.3 页面加购(接口抓不到才用)
只对规则已经选出的那 **1 条** 操作:
1. 打开商品页或搜索结果中该 sku。
2. 填数量。
3. 点加入购物车。
4. 等成功提示或打开购物车核对。
禁止:重新用页面搜索批准文号再人工挑仓。仓已经由规则选定。
页面选择器用艺赛旗 `BrowserSession`,登录态复用。不要为加购再登一次(除非会话丢了)。
### 5.4 dry-run
默认 `--dry-run`。`ADDED + DRY_RUN` 表示规则通过。关 dry-run 必须显式 `--execute-cart`。
第一次真正加购只用 `--limit 1 --platform yaoex --execute-cart`,人工打开购物车核对后再加量。
---
## 6. Windows 环境与运行
### 6.1 环境
- 艺赛旗 Python 应用,能 `uv run main.py`
- 密码箱已有:
- `yaoex_GYJDLHT_login`
- `yaobangmang_18146141930_login`
- 账号必须是业务允许加购的测试/正式采购账号。真正加购前跟业务确认,避免测出一车真货。
- 桌面目录:`福建商城选品测试\`
### 6.2 命令
```bat
:: 只判定,不加购(默认)
uv run main.py --dry-run --limit 5
:: 只跑一个平台
uv run main.py --dry-run --limit 5 --platform yaoex
:: 确认规则后,真加购 1 条
uv run main.py --execute-cart --limit 1 --platform yaoex
```
把命令写进 `README.md`,替换原「查价结果」说明。
### 6.3 验收顺序(必须按这次序,不要一上来一千条真加购)
1. `pytest tests/test_rules.py` 全绿。
2. `--dry-run --limit 5 --platform yaoex`:结果表 5 行,结论只有 ADDED/SKIPPED/FAILED,没有 ±10% 残留。
3. 同样 5 条跑药帮忙。
4. 人工抽 2 条 ADDED:对照网页,商家是否 11 家之一、毛利是否 ≥5%、效期、库存。
5. `--execute-cart --limit 1` 一个平台,打开购物车看 sku 和数量。
6. 再跑同一条,确认不会把数量翻倍(幂等)。
7. 再 `--limit 20` dry-run,看耗时。预估一千条:约 `20 条耗时 × 50`,写入结果 README。登录应只发生两次(每平台一次)。
失败则停在当前步,不要跳到一千条。
---
## 7. 异常
| 情况 | 处理 |
| --- | --- |
| 密码箱空 | 立即失败退出,不写半份结果 |
| 登录失败 | 该平台本批中止;另一平台仍可跑(已登录的话) |
| 单文号查询 3 次仍失败 | 该行该平台 FAILED,继续下一行 |
| 加购超时 | FAILED `CART_UNKNOWN`,不要当 SKIPPED |
| 极验登录点一次不够 | 沿用原等待逻辑;仍失败则整平台中止,记登录错误 |
| 关闭 dry-run 但加购模块未实现 | 启动时报错退出,禁止假装 ADDED |
脚本不点支付、不打开结算页。
---
## 8. Windows 任务清单
按序做。每步可单独验证。
### 任务 1:工作副本和输入模板
- [ ] 拷贝同事工程到工作目录(仓库 `rpa_yisaiqi/procure_mall/` 或 Windows 本地)
- [ ] 桌面建 `福建商城选品测试\选品输入.xlsx`,表头按 3.1
- [ ] 放 5 条真实批准文号、销售价格、采购数量(问业务要,不要用假文号)
- [ ] `uv run python -c "import isa_rpa, requests, openpyxl; print('ok')"` 能过
### 任务 2:规则引擎 + 单测
- [ ] 新建 `rules.py`、`tests/test_rules.py`
- [ ] 实现规范化、毛利、效期、库存、择仓
- [ ] `uv run pytest tests/test_rules.py -v` 全绿
- [ ] 确认没有 import 原 ±10% 函数
### 任务 3:搜品接到按行决策(仍不加购)
- [ ] 拆出或保留 `query_yaoex_products` / `query_ybm_products`
- [ ] 候选映射成 `Candidate`
- [ ] `main.py` 改为按行循环,默认 dry-run
- [ ] 删除 `filter_rows_by_purchase_price_band` 调用
- [ ] 写新结果表(3.2)
- [ ] 跑 `uv run main.py --dry-run --limit 5`
- [ ] 人工核对 2 条 ADDED、2 条 SKIPPED
### 任务 4:抓加购接口
- [ ] 1 号药城 F12 抓「加入购物车」,写入 `CART_API.md`
- [ ] 药帮忙同样写一份
- [ ] 判定:接口可用 / 必须页面点击
### 任务 5:实现加购(默认仍 dry-run)
- [ ] `cart_yaoex.py` / `cart_ybm.py`
- [ ] 先查车再加,避免数量翻倍
- [ ] `--execute-cart --limit 1` 单平台
- [ ] 人工打开购物车核对
- [ ] 再跑同一条,数量不翻倍
### 任务 6:收尾
- [ ] README:命令、dry-run 默认、输入输出路径、密码箱名称
- [ ] `--limit 20` dry-run 耗时写进 README
- [ ] 把 `未匹配商家` 列表交给业务看要不要补别名(本期不改代码硬匹配)
- [ ] 交付:工程、5 条结果 xlsx、加购成功截图、CART_API.md
---
## 9. 与后续 Oracle / 状态中心的边界
本期 `decision` 字典就是以后 `PROCURE_ITEM_RUN` 的 `output_json`。字段先对齐:`result=ADDED|SKIPPED|FAILED`、原因、商家、sku、价格、数量。
不要在本期接 FastAPI、不要写 MySQL、不要为选品新建 Oracle 表。下一期只换输入输出通道,不换规则和搜品。
---
## 10. 未决(不影响 Windows 开工)
| 项 | 本期默认 | 谁拍板 |
| --- | --- | --- |
| 两平台都合格是否都加购 | 都加 | 业务;要互斥再改 `main` 循环 |
| 毛利 刚好 5% | 算合格 `>=` | 已按设计说明「不低于 5%」 |
| 商家名不完全等于 11 家 | 淘汰并记未匹配清单 | 业务补别名后再改 `WAREHOUSE_ORDER` 映射 |
| 真正加购用测试账号还是正式采购账号 | 必须业务书面确认后再 `--execute-cart` | 业务 / 运维 |
加购接口在改代码前未知,这是正常的。任务 4 的产出就是答案,不是阻塞任务 1~3 的理由。

View File

@ -0,0 +1,136 @@
# 洁达选品:改造商城查价工程(概要)
| 文档属性 | 内容 |
| --- | --- |
| 版本 | V1.0 |
| 日期 | 2026-09-14 |
| 状态 | 本期实施依据;Windows 机按本文和详细方案改同事原工程 |
| 基线工程 | 同事「1号药城与药帮忙货品价格库存下载」`isa-rpa` Python 应用(`main.py`) |
| 详细方案 | [2026-09-14-jieda-procure-crawler-detail.md](./2026-09-14-jieda-procure-crawler-detail.md) |
| 业务口径 | [洁达四流程概要 V3.1](./2026-09-13-jieda-rpa-overview-design.md) 的 **BR-02 / BR-03 / 第 3.2 节** |
| 选品条件原文 | [采购平台选品条件优先项 20260902](/Users/yuuuu/Documents/AutoRpa/资料/采购平台选品条件优先项_20260902.txt) |
本期只做 **PROCURE 选品加购的第一刀**:在原查价工程上改,不接状态中心、不接 Oracle、不改合同/返利。先跑通「登录搜品 → 按行判定 → 合格加购」。
---
## 1. 要做成什么
一次脚本运行:
1. 读入待选品行(批准文号、销售价格、采购数量)。
2. 浏览器登录 1 号药城、药帮忙各一次,拿 cookie/token。
3. 按批准文号打现有搜索接口,拉候选(商家、价、效期、库存)。
4. 用洁达选品规则,每个平台每行最多选出 **1 家仓**。
5. 合格则加入该平台购物车;不合格记跳过;查询失败记失败。
6. 写出结果表。人工付款仍由业务在平台做,脚本不等、不点支付。
完成标准:Windows 机用 5~20 个品种能稳定跑完;结果表能看出每行 `ADDED / SKIPPED / FAILED`;合格行购物车里能看到对应商品和数量。默认 `--dry-run` 只判定不加购,确认规则后再关。
## 2. 为什么改原工程,不另起
原工程已经有:双平台登录、搜索 API、1 号药城价格解密、按文号缓存、接口重试。缺的是选品决策和加购。合同那套 Studio 逐步点击不适合一千个品种。
原工程里必须删掉的:
- `filter_rows_by_purchase_price_band`(采购价 ±10%)
- 把所有命中行倒进 Excel 当最终答案
- 用「采购价格」做是否合格的依据
保留的:登录、`query_yaoex_products`、`query_ybm_products`、`decrypt_yaoex_price`、密码箱、重试。
## 3. 架构
```text
Excel 输入(本期)
→ 登录一次 / 平台
→ 搜索接口(按批准文号,带缓存)
→ 选品规则引擎(纯 Python,两平台同一套)
→ 加购适配器(先接口,抓不到再点页面;仅合格行)
→ 结果 Excel(每行一个平台一个结论)
```
平台只负责登录、搜索、读商品、执行加购。**不能改规则优先级。**
本期输入仍用桌面 Excel,是为了在 Windows 上先试,不堵在 Oracle。列必须有 **销售价格**,不能再用采购价冒充。下一期再把输入/回报换成状态中心领取。
## 4. 按行怎么跑
一行输入 × 一个平台 = 一条结论。一次脚本把两个平台都跑完。
```text
登录两个商城(各一次)
for 每一行输入:
for 平台 in [1号药城, 药帮忙]:
搜批准文号
规则选出 0 或 1 个仓
合格 → 加购(dry-run 则跳过动作)→ ADDED
无合格仓 → SKIPPED + 原因
接口/登录级错误 → FAILED + 原因(不是 SKIPPED)
写结果表,退出
```
**两边都合格就两边都加购。** 同一销售行可能进两个购物车。若业务要互斥,只改这一条策略,不要改规则引擎。
登录失败整批中止。单个文号查询失败只失败该平台该行,继续后面的行。
## 5. 合格标准(两平台同一套)
按批准文号查询,不配规格、厂家、包装。前 4 项必须同时过,再按仓库名单取第一家。
| 顺序 | 条件 | 判定 |
| --- | --- | --- |
| 1 | 自营仓 | 商家名规范化后,必须落在下列 11 家里 |
| 2 | 价格 | `(销售价格 - 平台价) / 销售价格 >= 0.05`,即平台价 ≤ 销售价 × 0.95。基准是输入里的**销售价格** |
| 3 | 效期 | ≥ 12 个月 |
| 4 | 库存 | 平台库存 ≥ 本行采购数量 |
| 5 | 仓库距离 | 在 1~4 都过的候选里,按下列顺序取第一家 |
仓库顺序(不可改、不可按页面默认排序):
1. 广东壹号药业有限公司
2. 昆山亿方药业有限公司
3. 湖北亿昊药业有限公司
4. 天津壹号药业有限公司
5. 重庆亿昊药业有限公司
6. 重庆壹号医药有限公司
7. 湖南壹号药业股份有限公司
8. 辽宁亿昊药业有限公司
9. 河南壹号医药有限公司
10. 甘肃壹号云医药有限责任公司企业店
11. 陕西壹号药业有限公司
商家名先去掉 `[自营]`、首尾空格再比。对不上名单 = 本候选淘汰,不是整行失败。对不上的原始商家名写入结果,方便以后补别名。本期不做模糊匹配。
效期:接口已带「12 个月」过滤,代码仍要再验。解析不了的效期,该候选淘汰。
查询失败、登录失败、加购后不确定是否进车,记 `FAILED` / `UNKNOWN`,**禁止记成 SKIPPED**。
## 6. 加购
原工程没有购物车代码。合格之后才加购,数量 = 本行采购数量。
1. 用 F12 抓「加入购物车」请求(详细方案有步骤)。
2. 抓到则复用登录态打接口。
3. 抓不到则只对合格行点页面,禁止一千条都去点搜索。
4. `--dry-run` 默认开启:只写「将加购」不真正加。Windows 先用 dry-run 对规则,再用 1~2 行关 dry-run 验车。
脚本不打开结算、不点支付。
## 7. 本期不做
- 状态中心领取 / start / complete / fail
- Oracle 接口表、行 job
- 平台订单导出、合同、返利
- 人工付款、待复核清单替代加购(新方案是合格就加购)
- 把查价 ±10% 留作并行规则
## 8. Windows 交付物
- 改过的 `isa-rpa` 工程(可运行)
- 输入模板(含销售价格、采购数量、批准文号)
- 结果表(每行每平台:结论、原因、选中商家、平台价、效期、库存、是否 dry-run)
- 加购接口记录(URL、方法、关键字段;或「改用页面点击」的说明)
- 用 5 个品种跑通的截图或结果文件
详细到函数、表头、命令、测试步骤,见详细方案。

View File

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

View File

@ -0,0 +1,278 @@
# 选品:跨平台仓序 + 拆量落表
| 文档属性 | 内容 |
| --- | --- |
| 日期 | 2026-10-02 |
| 状态 | 已锁定,本轮实施依据 |
| 范围 | `选品流程/` 规则、班次判定、表 2 落盘。不加购,不推接口 2 |
| 替代 | `选品流程/README.md` 规则节;口径文档 3.1 / 3.3 中「单仓满量、两边都写满量」的作业语义 |
| 不变 | 接口 1 收件、表结构、查询键(条形码)、两平台登录/搜索、接口 2 报文形态 |
一张 `omsId` 一个品种。`status` 仍是该品种整单采购状态。
---
## 1. 要做成什么
从表 1 领一行,两个商城都搜,**合并候选后统一判定**:
1. 硬过滤:自营仓、毛利 ≥ 4%、效期 ≥ 12 个月。库存不再是过滤项。
2. 按四级仓序(同仓低价、再高库存)排序。
3. 同一平台同一仓的多个 sku 都参与拆量,不再只留低价那条。
4. 按序贪心拆量,直到计划采购量凑齐。
5. 凑齐:每条拆量一行写入表 2,`status=1`,数量是该条拆到的件数,合计等于计划量。
6. 凑不齐:不写成功行,表 2 `status=2`,表 1 `deal_flag=2`。
完成标准:`选品流程` 单测全绿;用内存仓库能演示「杭州 3 + 广东 7 = 计划 10」落两行,以及「合计 9 < 10」整单失败且成功行为 0。
---
## 2. 不做什么
- 真加购、清空购物车、页面点击
- 接口 2 推送(`push_status` 仍为 0)
- 批准文号搜索、商家名模糊匹配
- 中包装 / 起订量
- 改表结构、改接口 1
---
## 3. 已锁定决策
| 项 | 决定 |
| --- | --- |
| 毛利 | `(salePrice - 平台价) / salePrice >= 0.04`,恰好 4% 过 |
| 拆量 | 跨仓跨平台贪心拆。凑不齐一件都不落成功行 |
| 仓序 | 见第 4 节全称表。级内顺序即距离顺序 |
| 湖南壹号、甘肃壹号云 | 移出自营集合 |
| 同一平台同一仓 | 只留 1 条(低价,再高库存)。余量给下一仓 |
| `multi_platform_flag` | 实际拆到两个 `platform_type` 时为 1;同一平台拆两仓为 0 |
| 一边故障、另一边单独凑齐 | 按成功落,不写故障平台失败行 |
| 一边故障、剩下的凑不齐 | **系统故障**(可把 `deal_flag` 改回 0 重跑),禁止写成库存不足 |
| 两边都搜到、合计不够 | 业务失败「库存不足」,该 `omsId` 不重跑选品 |
| 查询键 | 仍是条形码 |
---
## 4. 自营仓全称与顺序
商家名去掉 `[自营]`、压缩空白后必须**等于**下表某一行。对不上 = 非自营,淘汰。
| 序 | 级 | 平台 | 全称 |
| ---: | ---: | --- | --- |
| 0 | 1 | 药帮忙 | 杭州小药药自营旗舰店 |
| 1 | 1 | 药帮忙 | 长沙小药药自营旗舰店 |
| 2 | 1 | 药帮忙 | 湖北小药药自营旗舰店 |
| 3 | 2 | 1号药城 | 广东壹号药业有限公司 |
| 4 | 2 | 1号药城 | 昆山亿方药业有限公司 |
| 5 | 2 | 1号药城 | 湖北亿昊药业有限公司 |
| 6 | 3 | 药帮忙 | 济南小药药自营旗舰店 |
| 7 | 3 | 1号药城 | 天津壹号药业有限公司 |
| 8 | 3 | 1号药城 | 重庆亿昊药业有限公司 |
| 9 | 4 | 1号药城 | 辽宁亿昊药业有限公司 |
| 10 | 4 | 1号药城 | 陕西壹号药业有限公司 |
| 11 | 4 | 1号药城 | 河南壹号医药有限公司 |
| 12 | 4 | 1号药城 | 重庆壹号医药有限公司 |
| 13 | 4 | 药帮忙 | 重庆小药药自营旗舰店 |
删除 `WAREHOUSE_ALIASES` 里甘肃那条。平台字段与名单不一致(例如 1 号药城搜出杭州小药药)仍按**商家全称**定序,不按搜索来源改序。
---
## 5. 算法
计划量 `qty = int(plan_purchase_qty)`。`qty <= 0` 仍走现有 `NO_QTY`。库存按件向下取整后再拆。
### 5.1 过滤(单条候选)
`evaluate_candidate` 不再看库存。淘汰原因仅:
- `NOT_SELF_WAREHOUSE`
- `PRICE_UNPARSEABLE`
- `MARGIN_LT_4PCT`(原 `MARGIN_LT_5PCT`)
- `EXPIRY_UNPARSEABLE` / `EXPIRY_LT_12M`
- `NO_SALE_PRICE`(采购行级,先于搜品)
库存为 `None` 或向下取整后 `<= 0`:可以过过滤,但拆量时当作 0,拆不到量。
### 5.2 同仓
不再按 `(mall_name, vendor_norm)` 折成一条。过过滤的 sku 全部进入拆量。同仓低价先吃,不够再吃同仓下一条;仓序仍优先于跨仓低价。不再产生 `SAME_WAREHOUSE_NOT_BEST`。
### 5.3 排序
` (WAREHOUSE_RANK[vendor_norm], price, -stock) `。`price` 缺失视为 `+inf`(过滤阶段已淘汰,排序不应再见到)。
### 5.4 拆量
```
remaining = qty
lines = []
for candidate in sorted_passed:
take = min(int(stock), remaining)
if take <= 0:
continue
lines.append(Allocation(candidate, take))
remaining -= take
if remaining == 0:
break
```
库存 `None` 时 `take` 当 0,不要对 `None` 做 `int()`。
`remaining == 0` → 成功,`lines` 即为表 2 成功行。
`remaining > 0` → 见第 6 节,**丢弃 `lines`**,不落成功行。
禁止:先落部分成功行再标失败。
---
## 6. 班次:先搜齐再判定
`job.run_batch` 对一行先跑完本次启用的全部平台搜索,再调用一次统一判定。禁止再「每平台独立选仓、独立满量写入」。
每个平台的搜索结果是三种之一:
| 结果 | 含义 |
| --- | --- |
| `candidates` | 搜到 0..n 条(空列表 = 无命中,不是故障) |
| `LOGIN_FAILED:...` | 本平台本班不可用 |
| `QUERY_FAILED:...` | 本行本平台查询异常 |
无条形码:两个平台都不搜,业务失败 `NO_BAR_CODE`(与现在相同)。
统一判定:
1. 采购行缺销售价 / 数量:`SKIPPED`,不搜也行(保持现有,在搜之前拦)。
2. 把所有成功搜到的 `candidates` 合并,走第 5 节。
3. 凑齐:成功。故障平台的错误忽略,不写该平台失败行。
4. 凑不齐,且存在任一平台 `LOGIN_FAILED` / `QUERY_FAILED`:整单 **系统故障**。
5. 凑不齐,且无系统故障:业务失败。
只跑 `--platform yaoex` 时,可用候选只有 1 号药城。凑不齐且该平台无故障 → 业务失败;该平台故障 → 系统故障。
---
## 7. 落表
成功行(每条 `Allocation` 一行):
| 列 | 值 |
| --- | --- |
| `operate_type` | 1 |
| `oms_id` | 本行 |
| `platform_type` | 48 药帮忙 / 66 1号药城 |
| `supplier_name` | `vendor_raw` |
| `bar_code` / `org_code` | 原样 |
| `purchase_price` | 该候选平台价,四位小数 |
| `purchase_qty` | 该条 `take`,**不是**计划全量 |
| `purchase_total` | 单价 × 该条数量,四位小数 |
| `status` | 1 |
| `fail_reason` | null |
| `push_status` | 0 |
| `multi_platform_flag` | 本单成功行里出现 ≥2 个不同 `platform_type` 则为 1,否则 0 |
表 1:`deal_flag=1`,`deal_result` 形如 `选品成功:药帮忙、1号药城`(按成功行出现的商城去重,顺序:药帮忙、1号药城)。
业务失败:每个**已启用且已判定**的平台落一行 `status=2`,`purchase_qty` 仍用计划量(造数校验:数量 > 0),`purchase_price` 仍用销售价兜底。`supplier_name` 仍为「无符合规则供应商」。`multi_platform_flag` 按失败行平台数(≥2 个平台则为 1)。
业务失败原因(`fail_reason` / `deal_result`)。先看过滤后是否留下可拆候选(含库存为 0。以过过滤的集合为准):
1. `NO_BAR_CODE` / `NO_SALE_PRICE` / `NO_QTY`:沿用现有中文
2. **过过滤集合非空,但拆不满**:一律 `无符合规则商品,库存不足`。rejected 里即使同时有非自营、效期、毛利淘汰,也不改这条。
3. **过过滤集合为空**时,才按候选清单(含淘汰原因)往下看,取第一条能成立的:
- 含 `MARGIN_LT_4PCT`:`无符合规则商品,毛利不足 4%`
- 含 `EXPIRY_LT_12M` / `EXPIRY_UNPARSEABLE`:`无符合规则商品,效期不足 12 个月`
- 含 `NOT_SELF_WAREHOUSE`:`无符合规则商品,非自营仓`
- 全部无命中:`无符合规则商品,平台无命中`
- 其它:`无符合规则商品`
第 9 节必测补一条:杭州自营库存 4 + 若干非自营/效期短淘汰、计划 10 → 原因必须是库存不足,不能是非自营或效期。
系统故障:每个启用平台一行 `status=2`,原因带 `系统故障:`。有故障的平台写该平台错误;无故障但被连带失败的平台写 `系统故障:对端平台不可用,合计库存不足`。表 1 `deal_result` 以 `系统故障:` 开头。`SelectionResult.mall_errors` 按平台给出原始错误串,persist 据此写各行,禁止所有失败行共用一句。
`JobSummary` 按表 2 行计:`added` = `status=1` 行数,`skipped` = 业务失败行数,`failed` = 系统故障行数。
---
## 8. 模块怎么改
| 文件 | 改什么 |
| --- | --- |
| `rules.py` | 新名单;`MIN_MARGIN=0.04`;`evaluate_candidate` 去掉库存;新增 `allocate()`(过滤+同仓全留+排序+拆量);`select_warehouse` 删除或改为调用 `allocate` 后取第一仓——**禁止**再作为班次入口 |
| `pipeline.py` | 新增 `run_selection(purchase, mall_outcomes)`,一次产出整单结果。`run_one_platform` 只留给单测过渡,班次不再调用 |
| `job.py` | 先收集各平台搜索结果,再 `run_selection` |
| `persist.py` | 吃整单结果:成功按拆量多行;`multi_platform_flag` 按平台种类数;失败原因 5% → 4%,并增加库存不足 |
| `README.md` | 规则节换成本文摘要 |
| `资料/RPA订单中心接口开发口径_20260930.md` | 3.1 改为毛利 4%、库存改为拆量;3.2 示例「毛利不足 5%」改为 4%;3.3 「只凑到一部分」改为整单失败、不落部分成功 |
| `资料/采购平台选品条件优先项_20260902.txt` | 文首加「已被 2026-10-02 spec 替代」;不改历史原文 |
搜索、登录、`repo.py`、表 DDL 不动。
建议的数据结构(名称可改,语义不要改):
```python
@dataclass
class MallOutcome:
mall_name: str
candidates: list[Candidate] | None = None
failed_reason: str | None = None # LOGIN_FAILED: / QUERY_FAILED:
@dataclass
class Allocation:
candidate: Candidate
qty: int
@dataclass
class SelectionResult:
kind: str # SUCCESS / BUSINESS_FAIL / SYSTEM_FAIL
reason: str # SELECTED / STOCK_INSUFFICIENT / NO_HIT / LOGIN_FAILED:... 等
allocations: list[Allocation]
rejected: list[Rejected]
malls: list[str] # 本行启用过的平台,落失败行用
mall_errors: dict[str, str] # 平台 → LOGIN_FAILED:... / QUERY_FAILED:...
```
`reason` 在 `run_selection` / `allocate` 里用过过滤集合算完。persist 只做中文映射,禁止从 `rejected` 反推库存不足(库存为 0 的过过滤候选不在 rejected 里)。
`kind` 决定落表,不要用「有没有 FAILED 决策」去猜:
- `SUCCESS`:`allocations` 合计数量必须等于 `int(qty)`;表 1 `deal_flag=1`,表 2 只写成功行
- `BUSINESS_FAIL`:`allocations` 必须为空;表 1 `deal_flag=2`,原因无「系统故障」前缀,该 `omsId` 不重跑
- `SYSTEM_FAIL`:`allocations` 必须为空;表 1 `deal_flag=2`,`deal_result` 以 `系统故障:` 开头
`pipeline` 的展示字段若仍用 `结论=ADDED/SKIPPED/FAILED`,映射为 SUCCESS→ADDED、BUSINESS_FAIL→SKIPPED、SYSTEM_FAIL→FAILED。`persist` 只认 `kind`。
成功行 `platform_type` 按该候选的 `mall_name`(搜索来源)映射 48/66,仓序只按商家全称。第 4 节「不按搜索来源改序」只约束排序,不改 `platform_type`。
---
## 9. 必须覆盖的行为(单测)
规则:
- 毛利恰好 4% 过;3.9% 淘汰(销售价 100、平台价 96 过,96.1 淘汰)
- 杭州小药药优先于广东壹号
- 湖南壹号、甘肃壹号云视为非自营
- 近仓 3、次仓 20、计划 10 → 近仓 3 + 次仓 7
- 合计 9、计划 10 → 无 allocation
- 同一仓两 SKU(价 90 库存 5、价 92 库存 100)、计划 10 → 价 90 拿 5,价 92 拿 5,不把余量给下一仓
- 库存 3.9 按 3 件拆
班次 / 落表:
- 两平台拆开:药帮忙 3 + 1 号药城 7,两行 `status=1`,数量分别为 3 和 7,`multi_platform_flag=1`,表 1 `deal_flag=1`
- 只在药帮忙拆两仓:两行、同一 `platform_type=48`、`multi_platform_flag=0`
- 药帮忙登录失败、广东壹号库存 20、计划 10 → 只落 1 号药城成功一行
- 药帮忙登录失败、1 号药城库存 4、计划 10 → 系统故障,无成功行
- 两边都搜到、合计库存 4、计划 10 → 业务失败「库存不足」,无成功行
- 杭州自营库存 4 + 路边店/效期短淘汰、计划 10 → 业务失败「库存不足」(不是非自营、不是效期)
- 无条形码仍不搜
---
## 10. 风险
- 搜到落表之间库存会变。这期只落判定,真加购时再处理。
- 中包装可能导致拆出的件数加不进车。真加购时再对。
- 订单中心若假定「一个 `omsId` + 一个 `platform_type` 只有一行」,同一平台拆两仓会撞上。表结构允许多行;联调时若对方拒,再改成同平台合一行(本期不合)。

View File

@ -0,0 +1,266 @@
# 合同班:表 3 合单录入 + 接口 4 组包
| 文档属性 | 内容 |
| --- | --- |
| 日期 | 2026-10-05 |
| 状态 | 已锁定,本轮实施依据 |
| 范围 | `rpa_contract/` 从表 3 领取、按 `supplierId` 合单、调现有页面 runner、尽调闸、写表 4 并回写表 3;`rpa_oms/app/outbound/` 接口 4 组包 |
| 对照 | `docs/RPA订单中心接口开发口径_20260930.md` 第 4 节、第 6 节;`docs/合同系统录入流程.md`;Excel `4合同协议录入结果接口` |
| 不变 | 接口 3 收件、表 3/4 DDL、页面步骤主体(登录/起草/货品/提交/读号)、接口 2 组包形态 |
合同班另起进程。HTTP 收件不同步跑作业。接口 4 真实 path 仍空:只组包,不发 HTTP。
---
## 1. 要做成什么
1. 合同班启动瞬间冻结本班可录名单:表 3 `process_status` 0→3。
2. 按 `supplierId` 合成一份合同;头字段取该组按 `fid` 的第一行;货品行全收。
3. `contractEntryStatus=0` 且已有 `contractNo` 的行不领取,留给返利班。
4. 注入 runner 调现有合同页面流。单测不打开浏览器。
5. 提交成功:表 4 `actionCode=1`、`contractStatus=1`、带合同号;表 3 `process_status=1`。同一合同下多个 `omsId` 回同一套号和状态。
6. 失败(未尽调 / 页面挂):表 4 `contractStatus=3` 带原因;表 3 `process_status=2`。
7. 接口 4 从表 4 取 `push_status=0` 组装 `{"data":[...]}`,一批最多 500。不改 `push_status`,不 POST。
完成标准(内存替身,不连库、不开浏览器):
- `cd rpa_contract` → `uv run pytest tests/test_job.py::test_job_merges_same_supplier_and_writes_table4 -q`
- 同一供应商两行、两个 `omsId`:runner 调一次;表 4 两行同一合同号;表 3 两条 `process_status=1`
- 带 `contractNo` 的协议重录行不领
- `cd rpa_oms` → `uv run pytest tests/test_contract_agreement_outbound.py::test_assembles_unpushed_rows_as_document_envelope -q`
---
## 2. 不做什么
- 不实现返利班、不打开返利中心、不回 `contractStatus=2` / `actionCode=2`
- 不向订单中心发 HTTP,不改 `push_status`
- 不在接口 3 请求路径里录合同
- 不按 `omsId` 或供应商名称合单
- 不把下午新进行追加进上午已录合同(冻结快照自然保证)
- 不默认合并/上传 PDF(接口 3 这条链没有平台扫描件)
- 不改表结构、不改接口 1/2/3
- 不改 `rpa_selection/`、`rpa_cart/`、`rpa_rebate/`
- 合同班不 import `rpa_oms`(作业写表 4;组包在 oms 另调,对齐选品写表 2 / 接口 2 另组)
- 不把 JSON 干跑入口 `main.py --input-json` 拆掉
---
## 3. 已锁定决策
| 项 | 决定 |
| --- | --- |
| 包边界 | 领取/合单/落表在 `rpa_contract`;接口 4 组包在 `rpa_oms/app/outbound/contract_agreement.py` |
| 冻结 | `claim_pending` 一次领完本班可录行,0→3;过程中新进的仍是 0,下一班再处理 |
| 可录 | `process_status=0` 且**不是**(`contract_entry_status=0` 且 `contract_no` 非空) |
| 协议重录行 | 永不领取,保持 `process_status=0` |
| 合单键 | `supplier_id`。头字段 = 该组 `fid` 升序第一行 |
| 采购员 / 计划员 | 只存在表 3 行上,本轮不填页面(现有 flow 无对应控件) |
| 表 4 粒度 | 每个 `oms_id` 一行。同一 `oms_id` 多货品仍一行。`org_code` 跟该 `oms_id` 在表 3 的原值 |
| 表 4 `fid` | 序列触发器生成,INSERT 不绑这一列 |
| 表 4 审计 | `create_user_id`/`update_user_id` = 0;名称 = `合同班`;`push_status=0` |
| 客商 | `customerCode` = 第一行 `supplier_mdm_code`;`customerName` = `supplier_name` |
| 日期 | 页面签订日/开始日/结束日 = 第一行 `contract_sign_date` / `contract_start_date` / `contract_end_date`(`YYYY-MM-DD`) |
| S08 兼容 | `ContractFlowContext` 增加可选 `contract_sign_date`、`contract_start_date`。未给时回退 `order_date`(JSON 干跑不破) |
| PDF | `source_type=""`,不走医药城合并上传 |
| 尽调 | S06 之后扫页面正文含「未尽调」则失败,原因固定「客户未尽调」,不提交。无独立选择器 |
| runner | `Callable[[ContractDraft], ContractOutcome]`。生产包装 `run_contract_create_flow`;单测注入 |
| 崩溃 | 停在表 3 `process_status=3`,人工改回 0 再跑;未写表 4 |
| 组包范围 | 全部 `push_status=0`,不按 `actionCode` 过滤(返利以后写 `actionCode=2` 也走同一函数) |
| 组包拆批 | 同一非空 `contractNo` 不拆批,算法对齐接口 2 的 `omsId`。`contractNo` 为空(失败行)按行独立,不合成一组 |
| `omsId` | JSON 整数 |
| HTTP | 不做。path 空 |
---
## 4. 领取与合单
### 4.1 行模型
`PurchaseContractRow`(表 3 已有列,camelCase 不进作业):
`fid, oms_id, supplier_id, supplier_name, contract_sign_date, contract_start_date, contract_end_date, payment_term, goods_id, goods_name, reg_no, spec, unit, qty, unit_price, purchaser_id, purchaser_name, planner_id, planner_name, contract_entry_status, contract_no, process_status, org_code, goods_mdm_code, supplier_mdm_code`
### 4.2 SQL 语义
```
WHERE process_status = 0
AND NOT (NVL(contract_entry_status, -1) = 0
AND contract_no IS NOT NULL
AND LENGTH(TRIM(contract_no)) > 0)
ORDER BY fid
```
领取:对命中行 `UPDATE ... SET process_status = 3, process_time = SYSTIMESTAMP, update_time = SYSTIMESTAMP WHERE fid = :fid AND process_status = 0`。`rowcount=1` 才算领到。可选 `limit` 仅供单测。
`contract_no` 空白串视为空。
### 4.3 分组
对已领取列表按 `fid` 升序扫描,`OrderedDict` 按 `supplier_id` 归组。组内顺序保持 `fid` 升序。每个 `supplier_id` 一份合同。
### 4.4 草稿映射
第一行 → 头:
| 草稿 | 来源 |
| --- | --- |
| `supplier_name` / `customer_name` | `supplier_name` |
| `customer_code` | `supplier_mdm_code` |
| `order_date` | `contract_sign_date`(标题用) |
| `contract_sign_date` / `contract_start_date` / `contract_end_date` | 对应三列,格式 `YYYY-MM-DD` |
| `payment_days` | `str(payment_term)` |
| `is_prepayment` | `否` |
| `payment_method` | `账期支付` |
| `contract_category` | `采购协议` |
| `source_type` | `""` |
| `amount` | `sum(qty * unit_price)`,四位小数字符串 |
| `belong_year` | 签订日年份 |
每一行 → `ContractGoodsLine`:
| 字段 | 来源 |
| --- | --- |
| `operation_code` | `goods_id`(弹窗实际搜 `goods_code`) |
| `goods_code` | `goods_id` |
| `goods_name` | `goods_name` |
| `approval_no` | `reg_no` |
| `spec` / `unit` | 同名 |
| `quantity` | `qty` |
| `mdm_code` | `goods_mdm_code` |
| `unit_price` | 四位小数字符串 |
第一行 `supplier_mdm_code` 为空:整组业务失败,原因「缺少供应商MDM码」,不调 runner。
---
## 5. 页面改动(只两处)
现有 `run_contract_create_flow` 继续串 S02–S13。本轮只加:
1. **S08 日期**(`fill_contract_amount_and_dates`):签订日 = `contract_sign_date or order_date`;开始日 = `contract_start_date or order_date`;结束日仍用 `contract_end_date`。
2. **尽调闸**(S06 之后、S07 之前):`check_customer_diligence` 读页面正文。含「未尽调」则抛 `DiligenceError`(`errors.py`,`NodeError` 子类,文案「客户未尽调」)。生产 runner 让它冒泡到 job。
不改货品弹窗搜索、不改读号策略、不改 JSON 干跑提交前停住的行为。
---
## 6. 班次结果
```python
@dataclass(frozen=True)
class ContractOutcome:
kind: str # "ok" | "diligence" | "fail"
contract_no: str | None
reason: str | None
```
| `kind` | 表 3 `process_status` / `process_result` | 表 4 |
| --- | --- | --- |
| `ok` 且有合同号 | `1` / 成功说明 | `action_code=1`,`contract_status=1`,`contract_no`,失败原因空 |
| `diligence` | `2` / `客户未尽调` | `action_code=1`,`contract_status=3`,`contract_fail_reason=客户未尽调`,`contract_no` 空 |
| `fail` 或其他异常 | `2` / 原因(截到 1000) | `contract_status=3`,`contract_fail_reason` 截到 500,`contract_no` 空 |
| runner 返回 `ok` 但合同号空 | 同 `fail`,「submit 模式未读取到真实合同编号」 | 同 `fail` |
同一组所有表 3 `fid` 写同一套处理状态。表 4 按组内**去重后的 `oms_id`**(首次出现顺序)各插一行,合同号和状态相同;`agreement_*` 全空;`org_code` 取该 `oms_id` 对应表 3 行。
一次 `save_group`:先插表 4,再改表 3。中途崩溃停在 3、表 4 可能部分写入——Oracle 同一连接一事务提交。内存替身同样先写 4 再改 3,一次调用内完成。
生产 runner:由 `ContractDraft` 填 `ContractFlowContext`(含三个日期),调 `run_contract_create_flow`,读到号 → `kind=ok`;捕获 `DiligenceError` → `kind=diligence`;其他 `NodeError` / 异常 → `kind=fail`。
---
## 7. 接口 4 组包
对标 `app/outbound/cart_settlement.py`。
```python
load_contract_agreement_envelope(repo, limit=500) -> {"data": [...]}
```
`repo.list_unpushed()`:`WHERE push_status = 0 ORDER BY fid`。
每条 camelCase:
```
fid, omsId, actionCode, contractStatus, contractFailReason,
contractNo, agreementStatus, agreementFailReason,
agreementId, agreementDtlId, orgCode
```
- `fid` / `omsId` / `actionCode` / 状态码:JSON 整数(库值 `None` 则字段为 `null`)
- 字符串字段原样,空为 `null`
- 不改 `push_status`,不发 HTTP
拆批:按 `contract_no` 分组(仅非空)。空 `contract_no` 每行单独一组。一组放不进当前批且批已有内容则停;批为空则整组放入(可超过 500,对齐接口 2)。
---
## 8. 文件落点
**`rpa_contract/`**(作业扁平,与选品同款):
| 文件 | 职责 |
| --- | --- |
| `group.py` | 可录判定、按 `supplier_id` 分组、草稿映射、金额四位小数 |
| `job.py` | `claim_pending` → 分组 → runner → `save_group` |
| `repo.py` | `MemoryContractRepo` + `OracleContractRepo`:领取、插表 4、回写表 3 |
| `persist.py` | 由 `ContractOutcome` + 组行生成表 4 dict、表 3 状态 |
| `db.py` | 拷选品连接模式;空缺复用 `rpa_oms/.env`;用户名戳 `合同班` |
| `errors.py` | 新增 `DiligenceError` |
| `contract_flow_steps.py` | S08 日期回退;S06 后尽调扫描 |
| `run_batch.py` | 班次入口:`from_env` + 生产 runner。保留现有 `main.py` JSON 干跑 |
| `pyproject.toml` | 加 `oracledb` |
| `tests/test_job.py` | 点名合单/跳过重录/未尽调 |
| `tests/test_group.py` | 头字段取第一行、金额合计、空白 `contract_no` 仍可领 |
**`rpa_oms/`**:
| 文件 | 职责 |
| --- | --- |
| `app/outbound/contract_agreement.py` | `load_` / `build_contract_agreement_envelope` |
| `app/outbound/memory_repo.py` | 增 `MemoryContractAgreementRepo.list_unpushed` |
| `app/outbound/oracle_repo.py` | 增 `OracleContractAgreementRepo`(或同文件新类)只读未推行 |
| `tests/test_contract_agreement_outbound.py` | 点名组包 |
| `examples/contract_agreement_batch.json` | 与 Excel 请求示例同形(`omsId` 用整数) |
| `README.md` | 接口 4:组包已做,path 空,不发 HTTP |
`rpa_contract/README.md`:合单/尽调/写表 4 本轮已接;接口 4 组包在 oms,本班不推。
---
## 9. 验收用例(点名)
### 9.1 `test_job_merges_same_supplier_and_writes_table4`
两行同一 `supplier_id`,`oms_id` 分别为 `2026100501`、`2026100502`,`process_status=0`。第三行 `contract_entry_status=0` 且 `contract_no="HT-OLD"`。runner 记调用次数并返回 `kind=ok, contract_no="HT20261005001"`。
断言:
- runner 调用 1 次;草稿两行货品;客商编码为第一行 MDM
- 表 4 两行:同一 `contract_no`,`action_code=1`,`contract_status=1`,`push_status=0`,`oms_id` 各一
- 前两行表 3 `process_status=1`;第三行仍为 0(未领)
### 9.2 `test_job_marks_diligence_fail`
一组一行。runner 返回 `kind=diligence, reason="客户未尽调"`。
断言:表 3 `process_status=2`,`process_result` 含「客户未尽调」;表 4 `contract_status=3`,`contract_no` 空,`contract_fail_reason="客户未尽调"`。
### 9.3 `test_assembles_unpushed_rows_as_document_envelope`
内存两行 `push_status=0`:一行 `action_code=1, contract_status=1, contract_no="HT1"`;一行 `action_code=2, agreement_status=1`。另有一行 `push_status=1` 不进包。
断言信封 `data` 两条,camelCase 与第 7 节字段一致,`omsId` 为 `int`,已推行不出现。
另测(对齐接口 2 `_take_complete_groups`):同一 `contract_no` 三行、`limit=2`、当前批为空 → 整组三条都放入(允许超过 limit)。两个不同 `contract_no` 各一行、`limit=1` → 只出第一组。
---
## 10. 风险与边界
- 尽调靠正文扫描,页面若换文案会漏拦。本轮不补选择器。
- 货品弹窗仍按 `goods_id` / 品名搜,不改成 MDM。
- 表 4 没有表 3 `fid` 列,回执关联键是表 4 自己的 `fid` 和 `omsId`。
- 真库领取/落表在 Windows 上做,本轮验收不连库。
- 接口 4 path、Token、是否带 `orgSecret` 仍按口径第 6 节后置,本轮不发明 URL。

View File

@ -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 的列对照(缺了什么、多了什么)。不要展开下一阶段。

View File

@ -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)已从仓库删除。不要再接。

Binary file not shown.

View File

@ -0,0 +1,26 @@
已被 2026-10-02 spec 替代(毛利 4%、两平台混排四级仓、按库存拆量)。本文保留 9 月 2 日原文,不要当作业口径。
选品条件优先级:
1)自营仓:勾选的自营仓
2)价格:跟销售价格对比,需要有5%以上盈利的商品;
3)有效期:大于等于12个月
4)满足库存数量
前4个选品条件必须满足后,再从仓库距离中选择。
5)仓库距离:优先选择距离近的店
1. [自营] 广东壹号药业有限公司
2. [自营] 昆山亿方药业有限公司
3. [自营] 湖北亿昊药业有限公司
4. [自营] 天津壹号药业有限公司
5. [自营] 重庆亿昊药业有限公司
6. [自营] 重庆壹号医药有限公司
7. [自营] 湖南壹号药业股份有限公司
8. [自营] 辽宁亿昊药业有限公司
9. [自营] 河南壹号医药有限公司
10. [自营] 甘肃壹号云医药有限责任公司企业店
11. [自营] 陕西壹号药业有限公司

26
rpa_browser/__init__.py Normal file
View File

@ -0,0 +1,26 @@
# 合同 / 返利共用的 Selenium Chrome 适配器。
#
# Chrome 用户目录必须在仓库外。调试口和 DevTools 就绪用短超时轮询,不 sleep(1)。
# 返利班的用户 Chrome junction / 端口差异留在 rpa_rebate.apa_browser_adapter。
from .adapter import (
ApaBrowserAdapter,
REPO_ROOT,
chrome_debug_port,
launch_independent_chrome,
resolve_chrome_profile_base,
resolve_chrome_profile_dir,
wait_debug_port,
wait_devtools_port,
)
__all__ = [
"ApaBrowserAdapter",
"REPO_ROOT",
"chrome_debug_port",
"launch_independent_chrome",
"resolve_chrome_profile_base",
"resolve_chrome_profile_dir",
"wait_debug_port",
"wait_devtools_port",
]

879
rpa_browser/adapter.py Normal file
View File

@ -0,0 +1,879 @@
# Selenium Chrome 浏览器适配。合同和返利共用这一份。
#
# 通过 WebDriver 操控 Chrome。用户目录必须在仓库外,避免 list_dir 扫到数 GB 的 profile。
# 端口探测和 DevToolsActivePort 用短超时轮询,禁止无条件 sleep(1)。
# 返利用户 Chrome 的 junction / 端口由 rpa_rebate.apa_browser_adapter 覆盖。
from __future__ import annotations
import os
import socket
import subprocess
import time
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
_FLOW_DEBUG_PORTS = {
"CONTRACT_CREATE": 9333,
"REBATE_FIND": 9335,
"default": 9334,
}
_IGNORE_SOURCE_JS = """
function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); }
function compact(el) {
return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim();
}
const hits = Array.from(document.querySelectorAll('.vxe-modal--wrapper')).filter((el) => {
if (!vis(el)) return false;
const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title'));
return title === '货源选择';
});
for (const modal of hits) {
const btn = Array.from(modal.querySelectorAll('button')).find((el) => {
const t = compact(el).replace(/ /g, '');
return vis(el) && (t === '关闭' || t === '取消');
});
if (btn) { btn.click(); continue; }
const x = modal.querySelector('.vxe-modal--close-btn, .vxe-icon-close');
if (x) { x.click(); }
}
return hits.length;
"""
# 判断 path 是否落在仓库目录树内。
def _is_inside_repo(path: Path, repo_root: Path) -> bool:
try:
path.resolve().relative_to(repo_root.resolve())
except ValueError:
return False
return True
# 把 flow 名收成目录名安全的一段。空则 default。
def _sanitize_flow(flow: str) -> str:
safe = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in (flow or "default").strip())
return safe or "default"
# 解析 Chrome user-data 根目录,必须留在仓库外。
#
# 未指定时用仓库父目录下的 rpa-chrome-profile。RPA_CHROME_PROFILE 指到仓库内则改回这个默认。
def resolve_chrome_profile_base(requested: str | None = None, *, repo_root: Path | None = None) -> Path:
root = (repo_root or REPO_ROOT).resolve()
outside = (root.parent / "rpa-chrome-profile").resolve()
raw = os.getenv("RPA_CHROME_PROFILE") if requested is None else requested
if raw:
candidate = Path(raw).expanduser().resolve()
if _is_inside_repo(candidate, root):
print(f"RPA_CHROME_PROFILE 指向仓库内 {candidate},已改用 {outside}", flush=True)
return outside
return candidate
return outside
# 按 flow 给出稳定的 Chrome 用户目录,后续班次复用登录态。
def resolve_chrome_profile_dir(
flow: str = "default",
requested: str | None = None,
*,
repo_root: Path | None = None,
) -> Path:
base = resolve_chrome_profile_base(requested, repo_root=repo_root)
return (base / _sanitize_flow(flow)).resolve()
# 给出本 flow 的调试口。RPA_CHROME_DEBUG_PORT 优先;否则按班次表,未知回 9333。
def chrome_debug_port(flow: str = "default") -> int:
raw = (os.getenv("RPA_CHROME_DEBUG_PORT") or "").strip()
if raw:
try:
return int(raw)
except ValueError:
pass
return _FLOW_DEBUG_PORTS.get(_sanitize_flow(flow), 9333)
# 探测 127.0.0.1:port 此刻能否连上。
def _port_open(port: int) -> bool:
try:
with socket.create_connection(("127.0.0.1", port), timeout=0.4):
return True
except OSError:
return False
# 从 profile 的 DevToolsActivePort 读出当前调试口,没有或坏文件返回 None。
def _read_devtools_port(profile_dir: Path) -> int | None:
marker = profile_dir / "DevToolsActivePort"
if not marker.is_file():
return None
try:
first = marker.read_text(encoding="utf-8").splitlines()[0].strip()
port = int(first)
except (OSError, ValueError, IndexError):
return None
return port if port > 0 else None
# 轮询本机调试口直到可连上或超时。间隔 0.3 秒,不 sleep(1)。
def wait_debug_port(port: int, timeout: float = 20) -> bool:
deadline = time.time() + timeout
while time.time() <= deadline:
if _port_open(port):
return True
time.sleep(0.3)
return False
# 轮询 DevToolsActivePort 直到读到正端口或超时。间隔 0.3 秒。
def wait_devtools_port(profile_dir: Path, timeout: float = 20) -> int | None:
deadline = time.time() + timeout
while time.time() <= deadline:
port = _read_devtools_port(profile_dir)
if port:
return port
time.sleep(0.3)
return None
# 构造 ChromeDriver Service。有 CHROMEDRIVER_PATH 用本地,否则 webdriver_manager 下载。
def _chrome_service():
from selenium.webdriver.chrome.service import Service
chromedriver = os.getenv("CHROMEDRIVER_PATH")
if chromedriver and os.path.exists(chromedriver):
return Service(chromedriver)
from webdriver_manager.chrome import ChromeDriverManager
return Service(ChromeDriverManager().install())
# 找到本机 chrome 可执行文件。CHROME_PATH 优先,找不到则失败。
def _chrome_binary() -> str:
raw = (os.getenv("CHROME_PATH") or "").strip()
local = Path(os.environ.get("LOCALAPPDATA", "")) / "Google" / "Chrome" / "Application" / "chrome.exe"
candidates = [
raw,
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
str(local),
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
]
for path in candidates:
if path and os.path.isfile(path):
return path
raise RuntimeError("未找到 chrome.exe")
# 附着到已在调试口上的 Chrome,并跑一句脚本确认活着。
def _attach_chrome(service, port: int):
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
options = Options()
options.add_experimental_option("debuggerAddress", f"127.0.0.1:{port}")
driver = webdriver.Chrome(service=service, options=options)
driver.execute_script("return 1")
return driver
# 独立进程拉起 Chrome,不跟 Python / chromedriver 一起退出。
def _spawn_chrome(profile_dir: Path, port: int, *, restore_session: bool = False) -> None:
chrome = _chrome_binary()
cmd = [
chrome,
f"--remote-debugging-port={str(port)}",
"--user-data-dir=" + str(profile_dir),
"--remote-allow-origins=*",
"--start-maximized",
"--no-first-run",
"--no-default-browser-check",
"--disable-popup-blocking",
]
if restore_session:
cmd.append("--restore-last-session")
kwargs: dict = {
"stdin": subprocess.DEVNULL,
"stdout": subprocess.DEVNULL,
"stderr": subprocess.DEVNULL,
}
if os.name == "nt":
kwargs["close_fds"] = False
kwargs["creationflags"] = 0x00000008 | 0x00000200 | 0x01000000
subprocess.Popen(cmd, **kwargs)
# 强制结束 chrome.exe,给用户目录重开调试口腾位置。仅 Windows。
def _kill_chrome() -> None:
if os.name != "nt":
return
creation = getattr(subprocess, "CREATE_NO_WINDOW", 0)
subprocess.run(
["taskkill", "/IM", "chrome.exe", "/F"],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
creationflags=creation,
)
# 等到 profile 的 SingletonLock / lockfile 消失,或超时放弃。
def _wait_profile_unlocked(profile_dir: Path, timeout: float = 20) -> None:
deadline = time.time() + timeout
while time.time() <= deadline:
locked = (profile_dir / "SingletonLock").exists() or (profile_dir / "lockfile").exists()
if not locked:
return
time.sleep(0.3)
# 独立拉起 Chrome 再附着调试口。端口和 DevTools 用轮询,不 sleep(1)。
#
# 口已开则直接附着。restore_session 时先结束旧进程、等 profile 解锁再拉起。
def launch_independent_chrome(service, profile_dir: Path, port: int, *, restore_session: bool = False):
if not _port_open(port):
if restore_session:
print(
f"Chrome 日常窗口无调试口,按同一用户目录重开 port={port} profile={profile_dir}",
flush=True,
)
_kill_chrome()
_wait_profile_unlocked(profile_dir)
_spawn_chrome(profile_dir, port, restore_session=restore_session)
if not wait_debug_port(port):
raise RuntimeError(f"Chrome 未在端口 {port} 起来")
wait_devtools_port(profile_dir, timeout=5)
return _attach_chrome(service, port)
# 用 chromedriver 新开带调试口的 Chrome,起来后再轮询调试口。
def _launch_chrome_selenium(service, profile_dir: Path, port: int):
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
options = Options()
options.page_load_strategy = "eager"
options.add_experimental_option("detach", True)
options.add_experimental_option("excludeSwitches", ["enable-automation", "enable-logging"])
options.add_experimental_option("useAutomationExtension", False)
options.add_argument("--disable-blink-features=AutomationControlled")
options.add_argument("--no-first-run")
options.add_argument("--no-default-browser-check")
options.add_argument("--disable-gpu")
options.add_argument("--disable-extensions")
options.add_argument("--disable-popup-blocking")
options.add_argument("--start-maximized")
options.add_argument("--remote-allow-origins=*")
options.add_argument(f"--remote-debugging-port={port}")
options.add_argument("--user-data-dir=" + str(profile_dir))
driver = webdriver.Chrome(service=service, options=options)
wait_debug_port(port, timeout=5)
return driver
# Selenium Chrome 浏览器适配器,实现合同 / 返利页面流用的 BrowserAdapter 协议。
#
# 子类可覆盖 profile / 调试口 / 拉起方式。返利用户 Chrome 走这条扩展点。
class ApaBrowserAdapter:
# 记下起始 URL 和班次名。真正开浏览器推迟到第一次页面操作。
def __init__(self, url: str = "", *, flow: str = "default"):
self._url = url
self._flow = flow
self._driver = None
self._is_open = False
self._download_dir = ""
self._download_before: set[str] = set()
self._ignoring_source = False
self._hold_source_choice = False
# 本班次 Chrome 用户目录。默认按 flow 分子目录。
def _profile_dir(self) -> Path:
return resolve_chrome_profile_dir(self._flow)
# 本班次调试口。默认按 flow 查表。
def _debug_port(self) -> int:
return chrome_debug_port(self._flow)
# 是否按日常 Chrome 用户目录重开并恢复标签。默认否。
def _restore_session(self) -> bool:
return False
# 若已有目标页签则切过去。默认不切。
def _focus_existing_tab(self, driver) -> bool:
return False
# 新开 Chrome。默认 chromedriver;返利子类改成独立进程。
def _launch_driver(self, service, profile_dir: Path, port: int):
return _launch_chrome_selenium(service, profile_dir, port)
# 复用本班次已打开的 Chrome;没有就按稳定 profile 新开。
def _get_driver(self):
if self._driver is not None:
return self._driver
profile_dir = self._profile_dir()
if _is_inside_repo(profile_dir, REPO_ROOT):
raise RuntimeError(f"拒绝在仓库内创建 Chrome 用户目录: {profile_dir}")
os.makedirs(profile_dir, exist_ok=True)
port = self._debug_port()
if not self._restore_session():
port = _read_devtools_port(profile_dir) or port
driver = None
if _port_open(port):
try:
driver = _attach_chrome(_chrome_service(), port)
print(f"Chrome 复用已有实例 flow={self._flow} port={port}", flush=True)
except Exception as exc:
print(f"Chrome 复用失败 port={port}:{exc}", flush=True)
driver = None
if driver is None:
launch_port = self._debug_port()
try:
driver = self._launch_driver(_chrome_service(), profile_dir, launch_port)
print(
f"Chrome 新开实例 flow={self._flow} port={launch_port} profile={profile_dir}",
flush=True,
)
except Exception as exc:
retry_port = wait_devtools_port(profile_dir, timeout=2) or _read_devtools_port(profile_dir)
if retry_port and _port_open(retry_port):
driver = _attach_chrome(_chrome_service(), retry_port)
print(f"Chrome 锁定后改为复用 port={retry_port}", flush=True)
else:
raise RuntimeError(f"打开 Chrome 失败:{exc}") from exc
driver.set_page_load_timeout(45)
driver.set_script_timeout(30)
self._driver = driver
return self._driver
# 保证浏览器已开。空白页才打开起始 URL;子类可先切到已有页签。
def _ensure_open(self):
if self._driver is None:
driver = self._get_driver()
href = ""
try:
href = str(driver.execute_script("return location.href") or "")
except Exception:
href = ""
blank = (not href) or href.startswith("chrome://") or href.startswith("about:")
if self._focus_existing_tab(driver):
print("Chrome 切到已打开的采购协议页", flush=True)
elif self._url and blank:
driver.get(self._url)
self._is_open = True
# 按 CSS / XPath / text= / label= / aria= / title= / :contains() 找第一个元素。
#
# timeout 保留给调用方语义,底层 find_element 本身不等待。
def _find(self, selector: str, timeout: int = 5):
from selenium.webdriver.common.by import By
from selenium.common.exceptions import NoSuchElementException
import re
if selector.startswith("text="):
text_val = selector[5:].strip('"').strip("'")
xpath = f'//*[contains(text(),"{text_val}")]'
return self._driver.find_element(By.XPATH, xpath)
if selector.startswith("label="):
text_val = selector[6:].strip('"').strip("'")
xpath = (
f'//label[contains(text(),"{text_val}")]'
f"/following-sibling::*[1][self::input or self::select or self::textarea]"
)
try:
return self._driver.find_element(By.XPATH, xpath)
except NoSuchElementException:
try:
for_id = self._driver.find_element(
By.XPATH, f'//label[contains(text(),"{text_val}")]'
).get_attribute("for")
if for_id:
return self._driver.find_element(By.ID, for_id)
except NoSuchElementException:
pass
raise
if selector.startswith("aria="):
text_val = selector[5:].strip('"').strip("'")
return self._driver.find_element(By.CSS_SELECTOR, f'[aria-label="{text_val}"]')
if selector.startswith("title="):
text_val = selector[6:].strip('"').strip("'")
return self._driver.find_element(By.CSS_SELECTOR, f'[title="{text_val}"]')
if ":contains(" in selector:
m = re.match(r'(.+?):contains\(["\']?(.+?)["\']?\)', selector)
if m:
tag_part, text_val = m.groups()
xpath = f'//{tag_part}[contains(.,"{text_val}")]'
return self._driver.find_element(By.XPATH, xpath)
if selector.startswith("//") or selector.startswith("/"):
return self._driver.find_element(By.XPATH, selector)
return self._driver.find_element(By.CSS_SELECTOR, selector)
# 打开指定 URL。eager 超时但文档已出来时继续,不把长轮询当失败。
def open_url(self, url: str) -> None:
self._url = url
driver = self._get_driver()
try:
driver.get(url)
except Exception as exc:
current = ""
try:
current = driver.current_url or ""
except Exception:
current = ""
if not current or current in ("data:,", "about:blank"):
raise RuntimeError(f"打开页面失败:{url} ({exc})") from exc
self._is_open = True
# 用原生 value setter 加 input/change/blur 注入,兼容 LUI/React 受控组件。
def _set_value_via_js(self, el, value: str) -> None:
self._driver.execute_script(
"""
var el = arguments[0];
var value = arguments[1];
var proto = el.tagName === 'TEXTAREA'
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype;
var setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
setter.call(el, value);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
el.dispatchEvent(new Event('blur', { bubbles: true }));
""",
el,
str(value),
)
# 按选择器列表填第一个能写上的输入框。原生失败则改 JS 注入。
def fill_first(self, selectors: list, value: str) -> None:
self._ensure_open()
last_error = None
for sel in selectors:
try:
el = self._find(sel)
try:
el.clear()
el.send_keys(str(value))
except Exception:
pass
if el.get_attribute("value") != str(value):
self._set_value_via_js(el, str(value))
time.sleep(0.3)
self._ignore_source_bug()
return
except Exception as e:
last_error = e
continue
raise RuntimeError(f"fill_first failed: {selectors}, last: {last_error}")
# 真实鼠标点击,给只监听 mousedown 的 LUI 页签/工具栏用。
def click_native(self, selectors: list) -> None:
self._ensure_open()
from selenium.webdriver.common.action_chains import ActionChains
last_error = None
for sel in selectors:
try:
el = self._find(sel)
self._driver.execute_script(
"arguments[0].scrollIntoView({block:'center', inline:'center'});",
el,
)
time.sleep(0.2)
ActionChains(self._driver).move_to_element(el).pause(0.15).click().perform()
time.sleep(0.4)
self._ignore_source_bug()
return
except Exception as e:
last_error = e
continue
raise RuntimeError(f"click_native failed: {selectors}, last: {last_error}")
# 按选择器列表 JS 点击第一个能点到的元素。
def click_first(self, selectors: list) -> None:
self._ensure_open()
last_error = None
for sel in selectors:
try:
el = self._find(sel)
self._driver.execute_script("arguments[0].click()", el)
time.sleep(0.5)
return
except Exception as e:
last_error = e
continue
raise RuntimeError(f"click_first failed: {selectors}, last: {last_error}")
# 按选择器列表双击第一个能点到的元素。
def double_click_first(self, selectors: list) -> None:
self._ensure_open()
from selenium.webdriver.common.action_chains import ActionChains
last_error = None
for sel in selectors:
try:
el = self._find(sel)
ActionChains(self._driver).double_click(el).perform()
time.sleep(0.5)
return
except Exception as e:
last_error = e
continue
raise RuntimeError(f"double_click_first failed: {selectors}, last: {last_error}")
# 等到页面 body 出现指定文本,超时抛 TimeoutError。
def wait_for_text(self, text: str, timeout_seconds: int = 15) -> None:
self._ensure_open()
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.by import By
try:
WebDriverWait(self._driver, timeout_seconds).until(
EC.text_to_be_present_in_element((By.TAG_NAME, "body"), text)
)
except Exception as e:
raise TimeoutError(f"wait_for_text '{text}' timed out: {e}")
# 等到任一选择器能找到元素,超时抛 TimeoutError。
def wait_for_element(self, selectors: list, timeout_seconds: int = 15) -> None:
self._ensure_open()
deadline = time.time() + timeout_seconds
last_error = None
while time.time() < deadline:
for sel in selectors:
try:
self._find(sel, timeout=1)
return
except Exception as e:
last_error = e
time.sleep(0.5)
raise TimeoutError(f"wait_for_element timed out: {selectors}, last: {last_error}")
# 任一选择器此刻能找到元素则 True。
def element_exists(self, selectors: list) -> bool:
self._ensure_open()
for sel in selectors:
try:
self._find(sel, timeout=1)
return True
except Exception:
continue
return False
# 切换到匹配到的 iframe。优先可见弹窗,避免切到上次留下的隐藏 iframe。
def switch_to_frame(self, selectors: list) -> None:
self._ensure_open()
from selenium.webdriver.common.by import By
last_error = None
found = []
for sel in selectors:
try:
if sel.startswith("//") or sel.startswith("/"):
found.extend(self._driver.find_elements(By.XPATH, sel))
else:
found.extend(self._driver.find_elements(By.CSS_SELECTOR, sel))
except Exception as e:
last_error = e
visible = []
hidden = []
for el in found:
try:
(visible if el.is_displayed() else hidden).append(el)
except Exception:
hidden.append(el)
for el in visible + hidden:
try:
self._driver.switch_to.default_content()
self._driver.switch_to.frame(el)
return
except Exception as e:
last_error = e
raise RuntimeError(f"switch_to_frame failed: {selectors}, last: {last_error}")
# 切回主文档(顶层 frame)。
def switch_to_default_content(self) -> None:
self._ensure_open()
self._driver.switch_to.default_content()
# 看见「货源选择」bug 弹层就关,不参与任何选择。
def _ignore_source_bug(self) -> None:
if self._hold_source_choice or self._ignoring_source or self._driver is None:
return
self._ignoring_source = True
try:
n = self._driver.execute_script(_IGNORE_SOURCE_JS)
if n:
time.sleep(0.2)
except Exception:
pass
finally:
self._ignoring_source = False
# 在当前页执行一段 JS 并返回结果。
def execute_script(self, script: str, *args):
self._ensure_open()
result = self._driver.execute_script(script, *args)
self._ignore_source_bug()
return result
# 选中 Landray xform fancy-select(.fs-wrap / .fs-option)。
def select_xform(self, name: str, value: str) -> None:
self._ensure_open()
result = self._driver.execute_script(
"""
const name = arguments[0];
const wanted = String(arguments[1] || '').trim();
const flag = document.querySelector('xformflag[property="' + name + '"]');
if (!flag) {
return {ok: false, error: 'flag not found: ' + name};
}
const norm = (s) => String(s || '').replace(/\\s+/g, '').trim();
const wantedN = norm(wanted);
const opts = Array.from(flag.querySelectorAll('.fs-option'));
let target = opts.find((o) => {
const text = norm((o.querySelector('.fs-option-label') || o).textContent);
const val = norm(o.getAttribute('data-value'));
return text === wantedN || val === wantedN;
});
if (!target && wantedN.length >= 2) {
target = opts.find((o) => {
const text = norm((o.querySelector('.fs-option-label') || o).textContent);
return text.indexOf(wantedN) >= 0;
});
}
if (!target) {
return {
ok: false,
error: 'option not found: ' + wanted,
available: opts.map((o) => ((o.querySelector('.fs-option-label') || o).textContent || '').trim()),
};
}
const labelWrap = flag.querySelector('.fs-label-wrap');
if (labelWrap) { labelWrap.click(); }
target.click();
const input = flag.querySelector('input[name="' + name + '"]');
const selected = target.getAttribute('data-value') || '';
if (input) {
const proto = window.HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
setter.call(input, selected);
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
if (typeof __xformDispatch === 'function') {
try { __xformDispatch(selected, input); } catch (e) {}
}
}
return {
ok: true,
value: selected,
label: ((flag.querySelector('.fs-label') || {}).textContent || '').trim(),
};
""",
name,
str(value),
)
if not result or not result.get("ok"):
raise RuntimeError(f"select_xform failed: {name}={value} {result}")
time.sleep(0.3)
# 按选择器列表选中原生 select 的可见文本,失败再用 JS 点 option。
def select_first(self, selectors: list, value: str) -> None:
self._ensure_open()
from selenium.webdriver.support.ui import Select
last_error = None
for sel in selectors:
try:
el = self._find(sel)
try:
Select(el).select_by_visible_text(value)
time.sleep(0.3)
if self._selected_text_matches(el, value):
return
except Exception:
pass
self._select_via_js(el, value)
time.sleep(0.3)
if self._selected_text_matches(el, value):
return
raise RuntimeError(f"select_first 未选中目标项: {value}")
except Exception as e:
last_error = e
continue
raise RuntimeError(f"select_first failed: {selectors}, last: {last_error}")
# 当前选中项的可见文本是否等于 value。
def _selected_text_matches(self, el, value: str) -> bool:
try:
script = (
"var el = arguments[0];"
"if (el.selectedIndex != null) {"
" var o = el.options[el.selectedIndex];"
" return o ? (o.textContent || o.text || '').trim() : '';"
"}"
"return (el.value || '').trim();"
)
selected = self._driver.execute_script(script, el)
return (selected or "").strip() == str(value).strip()
except Exception:
return False
# 用 JS 按可见文本或 value 选中 option 并派发 change。
def _select_via_js(self, el, value: str) -> None:
self._driver.execute_script(
"""
var el = arguments[0];
var value = arguments[1];
var options = el.options || [];
for (var i = 0; i < options.length; i++) {
var t = (options[i].textContent || options[i].text || '').trim();
if (t === value || options[i].value === value) {
el.selectedIndex = i;
el.dispatchEvent(new Event('change', { bubbles: true }));
return;
}
}
""",
el,
str(value),
)
# 按选择器列表给第一个 file 输入框送本地路径。
def upload_file_first(self, selectors: list, file_path: str) -> None:
self._ensure_open()
last_error = None
for sel in selectors:
try:
el = self._find(sel)
el.send_keys(file_path)
time.sleep(1)
return
except Exception as e:
last_error = e
continue
raise RuntimeError(f"upload_file_first failed: {selectors}, last: {last_error}")
# 把浏览器下载目录指到 path,并记下当前已有文件名以便 wait_for_download 认新文件。
def set_download_dir(self, path: str) -> None:
self._ensure_open()
dest = str(Path(path).expanduser().resolve())
Path(dest).mkdir(parents=True, exist_ok=True)
self._download_dir = dest
self._download_before = {p.name for p in Path(dest).iterdir()}
try:
self._driver.execute_cdp_cmd(
"Page.setDownloadBehavior",
{"behavior": "allow", "downloadPath": dest, "eventsEnabled": True},
)
except Exception:
try:
self._driver.execute_cdp_cmd(
"Browser.setDownloadBehavior",
{"behavior": "allow", "downloadPath": dest, "eventsEnabled": True},
)
except Exception as exc:
print(f"set_download_dir skipped: {exc}", flush=True)
# 等到下载目录出现新的 suffix 文件(忽略 .crdownload),返回最新那条路径。
def wait_for_download(self, suffix: str = ".xlsx", timeout_seconds: int = 20) -> str:
dest = Path(self._download_dir or "")
if not dest.is_dir():
raise RuntimeError("未设置下载目录")
before = set(self._download_before)
deadline = time.time() + timeout_seconds
wanted = (suffix or ".xlsx").lower()
while time.time() <= deadline:
hits = [
p
for p in dest.iterdir()
if p.name not in before
and p.suffix.lower() == wanted
and not str(p).endswith(".crdownload")
and not Path(str(p) + ".crdownload").exists()
]
if hits:
return str(max(hits, key=lambda p: p.stat().st_mtime))
time.sleep(0.3)
raise RuntimeError(f"未下到 {wanted} 文件")
# 按选择器列表读第一个非空 text/value。
def read_first(self, selectors: list, value_name: str = "") -> str:
self._ensure_open()
last_error = None
for sel in selectors:
try:
el = self._find(sel)
result = el.text or el.get_attribute("value") or ""
if result:
return result
except Exception as e:
last_error = e
continue
raise RuntimeError(f"read_first failed: {selectors}, last: {last_error}")
# 读当前页 body 可见文本,读不到返回空串。
def read_body_text(self) -> str:
self._ensure_open()
try:
return self._driver.find_element("tag name", "body").text
except Exception:
return ""
# 把当前页截到本包 accept.artifact_dir(flow) 下的 name.png,返回路径。
def screenshot(self, name: str) -> str:
from accept import artifact_dir
save_dir = artifact_dir(self._flow)
save_dir.mkdir(parents=True, exist_ok=True)
path = save_dir / f"{name}.png"
if self._driver:
self._driver.save_screenshot(str(path))
return str(path)
# 接受原生 alert/confirm 并返回其文本。没有弹窗则空串。
def accept_alert(self) -> str:
self._ensure_open()
try:
alert = self._driver.switch_to.alert
text = (alert.text or "").strip()
alert.accept()
return text
except Exception:
return ""
# 关掉当前标签,在同一 Chrome 里新开一标签再打开 url。
def reopen_url(self, url: str) -> None:
driver = self._get_driver()
old = None
try:
old = driver.current_window_handle
driver.switch_to.new_window("tab")
new = driver.current_window_handle
if old and old != new:
try:
driver.switch_to.window(old)
driver.close()
except Exception:
pass
try:
driver.switch_to.window(new)
except Exception:
handles = driver.window_handles
if handles:
driver.switch_to.window(handles[-1])
except Exception:
pass
self.open_url(url)

143
rpa_cart/README.md Normal file
View File

@ -0,0 +1,143 @@
# rpa_cart
加购与支付回传。两个班次,同一个包。
| 班次 | 状态 |
| --- | --- |
| 加购 `operateType=1` | 领表 2 选品成功行,按仓加进购物车。成功盖章,失败改 `status=2` |
| 支付回传 `operateType=2` | **1 号药城已做落表**。不推接口 2 |
## 加购
选品凑齐的行在选品班里按本次拆定的 sku 直接加车,不经过这里。本命令只补还停在 `update_user_name=系统管理员`、或租约过期的「加购中」的行。那些行表 2 里没有 sku,仍按条形码再搜,对上 `supplier_name` 再加。同一 `omsId` 拆两仓、两平台要加齐。
成功不插新行,只把 `update_user_name` 改成 `加购班`。加不进去(登录挂、库存没了、中包装加不进、对不上仓)改该行 `status=2`,写失败原因。领取时先盖 `加购中`;中途崩溃停在这一戳。`update_time` 超过 30 分钟的「加购中」下一班会再领;未过期的需改回 `系统管理员` 再跑。
不推订单中心,不清空购物车,不碰合同。表 2 没有 `sku_id`,加购必须再搜条形码。选完到加购之间库存可能变,已知。
```bash
cd rpa_cart
uv sync --python 3.12
uv run python main.py cart
uv run python main.py cart --limit 20
uv run python main.py cart --platform yaoex
```
账号用 `rpa_selection/.env` 的 `YAOEX_*` / `YBM_*`,库用 `RPA_ORACLE_*`。
验收(内存、不连库):
```bash
uv run pytest tests/test_cart_job.py::test_job_adds_split_rows_without_insert -q
```
杭州 3 + 广东 7 两行都加车,表 2 仍两行。失败点名 `test_job_marks_out_of_stock`、`test_job_marks_login_fail_as_system_error`。
接口 2 仍捞全部 `push_status=0`。加购班跑完之前不要组装推送。
## 运维
### 怎么跑
```bash
cd rpa_cart
uv sync --python 3.12
uv run python main.py cart --limit 20
```
账号用 `rpa_selection/.env` 的 `YAOEX_*` / `YBM_*`,Oracle 空缺时补 `rpa_oms/.env`。连接从 `rpa_oracle` 池取,班次默认池大小 1。缺 `RPA_ORACLE_HOST` / 用户 / 密码时入口失败,文案「数据库不可用」。未捕获异常原文进 `rpa_shift_error_audit`,Windows 上先执行 `docs/ddl/rpa_oms_staging_tables.sql` 里这张表。
### 看哪张表哪几列
- 表 2 `rpa_cart_settlement_goods_data`:`operate_type=1`、`status`(1 成功,2 失败)、`push_status=0`、`update_user_name`(`系统管理员` 待领,`加购中` 处理中,`加购班` 本班已盖)、`fail_reason`、`update_time`、`oms_id`、`supplier_name`、`purchase_qty`
### 崩溃改回 SQL
停在「加购中」且未过 30 分钟租约:
```sql
UPDATE rpa_cart_settlement_goods_data
SET update_user_name = '系统管理员'
WHERE update_user_name = '加购中' AND fid = :fid;
```
过期的「加购中」下一班会再领,不必改。
### 原因列 vs 日志原文
表 2 `fail_reason`:登录挂写 `系统故障:LOGIN_FAILED`,查询抛错写 `系统故障:QUERY_FAILED`,加车抛错写 `系统故障:ADD_FAILED`。异常原文打本机日志,不进这一列。库存不足 / 中包装 / 对不上仓仍写业务中文。
### 点名命令
```bash
cd rpa_cart
uv run pytest tests/test_cart_job.py::test_job_adds_split_rows_without_insert -q
uv run pytest tests/test_cart_job.py::test_job_marks_out_of_stock -q
uv run pytest tests/test_cart_repo.py::test_from_env_without_user_raises -q
uv run pytest tests/test_cart_repo.py::test_claim_reclaims_expired_claiming_stamp tests/test_cart_repo.py::test_claim_skips_fresh_claiming_stamp -q
```
## 1 号药城支付落表
从买家中心导出已付订单,按导出行字段写入表 2 `operate_type=2`。不对加购行。购物车和支付单没有强关联,`oms_id` 表上 NOT NULL,先填 `0`,等人后选。
```bash
cd rpa_cart
uv sync --python 3.12
# 账号用 rpa_selection/.env 的 YAOEX_*,库用 RPA_ORACLE_*
uv run python main.py
```
过滤:只落 待发货 / 待收货 / 已完成。单价用折后单价,总价 `round(折后单价×数量, 4)`。`pay_order_no` 用商城订单号。`pay_order_dtl_id` 落库时自生成(UUID hex),不是平台数据,给订单中心防重传。
幂等:同一 `pay_order_no` 不重复插入。Excel 没有细单 ID,不再打详情接口。
```bash
uv run pytest -v
```
## Windows 真库
Mac 打不到 `192.168.12.244`,**改表 2 在 Windows 上测**。支付导出/解析在 Mac 已经跑过;Win 上要测登录、导出、落表,以及加购登录和对仓加车。
Python 3.12 + Instant Client(位数一致)+ Chrome。密码不要写进仓库。把本包和 `rpa_selection/.env` 一起带过去。
PowerShell:
```powershell
cd rpa_cart
uv sync --python 3.12
uv run playwright install chrome
$env:RPA_ORACLE_HOST = "192.168.12.244"
$env:RPA_ORACLE_PORT = "1521"
$env:RPA_ORACLE_SERVICE = "rpa"
$env:RPA_ORACLE_USER = "rpainit"
$env:RPA_ORACLE_PASSWORD = "这里填密码"
$env:RPA_ORACLE_THICK = "1"
$env:RPA_ORACLE_CLIENT_LIB = "C:\oracle\instantclient"
uv run pytest tests/test_cart_job.py::test_job_adds_split_rows_without_insert -q
uv run python main.py cart
uv run python main.py
```
看控制台 `inserted / skipped_dup / unmatched`。同一订单号再跑应 `skipped_dup` 上升。
查库:
```sql
SELECT operate_type, oms_id, platform_type, supplier_name, bar_code,
purchase_price, purchase_qty, purchase_total,
pay_order_no, pay_order_dtl_id, push_status
FROM rpa_cart_settlement_goods_data
WHERE operate_type = 2 AND platform_type = 66
ORDER BY fid DESC;
```
再跑一遍同一天,`skipped_dup` 应上升,行数不再增加。
不要在页面点「确认收货」。脚本自己导出,过滤待发货/待收货/已完成。
## 本期不做
清空购物车、药帮忙支付、接口 2 推送。

31
rpa_cart/cart_add.py Normal file
View File

@ -0,0 +1,31 @@
# 加车结果。HTTP 客户端把商城回包收成这三种。
from __future__ import annotations
from dataclasses import dataclass
KIND_OK = "ok"
KIND_BUSINESS = "business"
KIND_SYSTEM = "system"
# 一次加车的结果:成功、业务失败(库存/中包装)或系统失败(登录、返回异常)。
@dataclass
class AddOutcome:
kind: str
reason: str = ""
# 按商城回文区分成功、库存/中包装等业务失败,以及看不懂的系统失败。
def classify_mall_message(text: str) -> AddOutcome:
msg = (text or "").strip()
if not msg:
return AddOutcome(kind=KIND_SYSTEM, reason="加购失败")
if any(token in msg for token in ("成功", "已加入")):
return AddOutcome(kind=KIND_OK)
if any(token in msg for token in ("中包装", "倍数", "起订")):
reason = msg if "中包装" in msg else f"中包装加不进:{msg}"
return AddOutcome(kind=KIND_BUSINESS, reason=reason)
if any(token in msg for token in ("库存", "无货", "缺货")):
return AddOutcome(kind=KIND_BUSINESS, reason="库存不足")
return AddOutcome(kind=KIND_BUSINESS, reason=msg)

308
rpa_cart/cart_job.py Normal file
View File

@ -0,0 +1,308 @@
# 加购班次:领表 2 选品成功行 → 再搜条形码对仓 → 加车。
#
# 成功盖章,失败改 status=2。崩在「加购中」:超过租约可再领,未过期需改回「系统管理员」。
# 查询失败原因列只写 QUERY_FAILED / LOGIN_FAILED / ADD_FAILED。原文进审计表,会话结束后另写。
from __future__ import annotations
import logging
from collections import OrderedDict
from contextlib import nullcontext
from dataclasses import dataclass
from typing import Callable
from cart_add import ( # noqa: F401
KIND_BUSINESS,
KIND_OK,
KIND_SYSTEM,
AddOutcome,
)
from cart_match import CartOffer, match_offer
from cart_repo import CartRow, clip_reason
from rpa_oracle import ShiftError
Searcher = Callable[[str], list[CartOffer]]
Adder = Callable[[CartOffer, int], AddOutcome]
CartReader = Callable[[str, str], int]
_RETRY_CODES = ("LOGIN_FAILED", "QUERY_FAILED")
logger = logging.getLogger("rpa_cart")
INCOMPLETE_MARK = "未加齐"
# 一个加购班次的领取和盖章计数。
@dataclass
class CartSummary:
claimed: int = 0
added: int = 0
failed: int = 0
# 选品已经拆定的一行。加这行时不再搜、不再按商家名重对。
@dataclass
class KnownLine:
fid: int
oms_id: int
platform_type: int
supplier_name: str
bar_code: str
qty: int
sku_id: str
vendor_raw: str
vendor_norm: str
stock: float | None
price: float | None
raw: dict
def _session(repo):
"""有 session 就借一条,没有就空上下文。商城调用必须在这个块外面。"""
session = getattr(repo, "session", None)
if session is None:
return nullcontext()
return session()
def run_cart_batch(
repo,
*,
searchers: dict[int, Searcher],
adders: dict[int, Adder],
login_errors: dict[int, str],
limit: int | None = None,
platform_types: list[int] | None = None,
cart_readers: dict[int, CartReader] | None = None,
) -> CartSummary:
"""领取待加购行,按行搜仓、加车、盖章。
session 只包领取和每一组的盖章。搜索和加车在连接归还之后跑。一组盖完再处理下一组。
登录失败和查询失败把戳改回系统管理员。购物车里已有不少于本行的数量则直接成功,不加第二份。
limit 交给领取层。
"""
audits: list[ShiftError] = []
readers = cart_readers or {}
with _session(repo):
rows = repo.claim_pending(limit=limit, platform_types=platform_types)
summary = CartSummary(claimed=len(rows))
for oms_id, group in _by_oms(rows).items():
outcomes = [
(row, *_add_one(row, searchers, adders, login_errors, readers))
for row in group
]
_apply_outcomes(repo, oms_id, outcomes, summary, audits)
repo.save_shift_errors(audits)
return summary
def add_known_lines(
repo,
lines: list[KnownLine],
*,
adders: dict[int, Adder],
login_errors: dict[int, str],
cart_readers: dict[int, CartReader] | None = None,
) -> CartSummary:
"""按调用方已经拆定的 sku 加车并盖章。不再搜条形码,不再按商家名重对。
同一 oms_id 先全部加完再盖章。成功只改戳。业务失败改 status=2。
同组有成功又有失败时,失败原因带「未加齐」。登录失败把戳放回系统管理员。
行必须已经是「加购中」,否则盖章 SQL 不会改到它。
"""
audits: list[ShiftError] = []
readers = cart_readers or {}
summary = CartSummary(claimed=len(lines))
for oms_id, group in _by_known(lines).items():
outcomes = [
(line, *_add_known(line, adders, login_errors, readers)) for line in group
]
_apply_outcomes(repo, oms_id, outcomes, summary, audits)
repo.save_shift_errors(audits)
return summary
# 按盖章结果改表 2。加车必须发生在这个 session 外面。
def _apply_outcomes(repo, oms_id, outcomes, summary: CartSummary, audits: list) -> None:
incomplete = _is_incomplete(outcomes)
if incomplete:
logger.warning("加购未加齐 oms_id=%s", oms_id)
with _session(repo):
for item, reason, code, text in outcomes:
if reason is None:
repo.mark_success(item.fid)
summary.added += 1
continue
if incomplete:
reason = _incomplete_reason(reason)
if _retryable(code, reason):
repo.mark_release(item.fid, reason)
else:
repo.mark_fail(item.fid, reason)
summary.failed += 1
if code and text:
audits.append(ShiftError("加购", str(item.fid), code, text))
# 按 oms_id 保序分组。同组先加完再盖章,才能看出有没有加齐。
def _by_known(lines: list[KnownLine]) -> OrderedDict[int, list[KnownLine]]:
groups: OrderedDict[int, list[KnownLine]] = OrderedDict()
for line in lines:
groups.setdefault(int(line.oms_id), []).append(line)
return groups
def _add_known(
line: KnownLine,
adders: dict[int, Adder],
login_errors: dict[int, str],
cart_readers: dict[int, CartReader],
) -> tuple[str | None, str | None, str | None]:
"""对一条已经拆定的行加车。返回 (失败原因, 审计短码, 原文)。成功三项都是 None。
不看选品当时的库存。库存以商城加车回包为准。购物车里已经不少于本行时直接成功。
没有加车函数时放回待领,让补偿班再处理。
"""
platform = line.platform_type
if platform in login_errors:
text = (login_errors[platform] or "").strip()
logger.warning("加购登录失败 platform=%s: %s", platform, login_errors[platform])
return _system("LOGIN_FAILED"), ("LOGIN_FAILED" if text else None), (text or None)
adder = adders.get(platform)
if adder is None:
return _system("LOGIN_FAILED"), "LOGIN_FAILED", "未配置加车"
bar_code = (line.bar_code or "").strip()
if not bar_code:
return "缺少条形码", None, None
qty = int(line.qty)
if qty <= 0:
return "采购数量无效", None, None
reader = cart_readers.get(platform)
if reader is not None:
try:
already = int(reader(bar_code, line.supplier_name) or 0)
except Exception as exc:
logger.exception("加购查购物车失败 platform=%s bar_code=%s", platform, bar_code)
return _system("QUERY_FAILED"), "QUERY_FAILED", _text(exc)
if already >= qty:
return None, None, None
offer = CartOffer(
sku_id=line.sku_id,
vendor_raw=line.vendor_raw,
vendor_norm=line.vendor_norm,
stock=line.stock,
price=line.price,
extra={"raw": line.raw} if isinstance(line.raw, dict) else {},
)
try:
outcome = adder(offer, qty)
except Exception as exc:
logger.exception("加购加车失败 platform=%s fid=%s", platform, line.fid)
return _system("ADD_FAILED"), "ADD_FAILED", _text(exc)
if outcome.kind == KIND_OK:
return None, None, None
reason = (outcome.reason or "加购失败").strip()
if outcome.kind == KIND_SYSTEM:
return _system(reason), None, None
return clip_reason(reason), None, None
# 按 oms_id 保序分组。同组先加完再盖章,才能看出有没有加齐。
def _by_oms(rows: list[CartRow]) -> OrderedDict[int, list[CartRow]]:
groups: OrderedDict[int, list[CartRow]] = OrderedDict()
for row in rows:
groups.setdefault(int(row.oms_id), []).append(row)
return groups
# 同组里既有成功又有失败,才算这单没加齐。
def _is_incomplete(outcomes: list[tuple]) -> bool:
flags = [reason is None for _row, reason, _code, _text in outcomes]
return any(flags) and not all(flags)
# 失败原因前加上未加齐,已带前缀的不再套一层。
def _incomplete_reason(reason: str) -> str:
text = (reason or "").strip()
if text.startswith(INCOMPLETE_MARK):
return text
return f"{INCOMPLETE_MARK}:{text}" if text else INCOMPLETE_MARK
def _retryable(code: str | None, reason: str | None) -> bool:
"""登录失败和查询失败放回队列。加车失败、对不上仓、库存不足不放。"""
blob = f"{code or ''} {reason or ''}"
return any(token in blob for token in _RETRY_CODES)
def _add_one(
row: CartRow,
searchers: dict[int, Searcher],
adders: dict[int, Adder],
login_errors: dict[int, str],
cart_readers: dict[int, CartReader],
) -> tuple[str | None, str | None, str | None]:
"""对一行查购物车、搜仓并加车。
返回 (失败原因, 审计短码, 原文)。成功三项都是 None。
购物车里该条形码和供应商的数量已经不少于本行时直接成功,多出来的数量不加第二份。
业务原因(对不上仓、库存、商城回的业务失败)不进审计。没有原文也不进。
"""
platform = row.platform_type
if platform in login_errors:
text = (login_errors[platform] or "").strip()
logger.warning("加购登录失败 platform=%s: %s", platform, login_errors[platform])
return _system("LOGIN_FAILED"), ("LOGIN_FAILED" if text else None), (text or None)
searcher = searchers.get(platform)
if searcher is None:
return _system("LOGIN_FAILED"), None, None
adder = adders.get(platform)
if adder is None:
return _system("ADD_FAILED"), None, None
bar_code = (row.bar_code or "").strip()
if not bar_code:
return "缺少条形码", None, None
qty = int(row.purchase_qty)
if qty <= 0:
return "采购数量无效", None, None
reader = cart_readers.get(platform)
if reader is not None:
try:
already = int(reader(bar_code, row.supplier_name) or 0)
except Exception as exc:
logger.exception("加购查购物车失败 platform=%s bar_code=%s", platform, bar_code)
return _system("QUERY_FAILED"), "QUERY_FAILED", _text(exc)
if already >= qty:
return None, None, None
try:
offers = searcher(bar_code)
except Exception as exc:
logger.exception("加购查询失败 platform=%s bar_code=%s", platform, bar_code)
return _system("QUERY_FAILED"), "QUERY_FAILED", _text(exc)
offer = match_offer(offers, row.supplier_name)
if offer is None:
return "未找到对应仓", None, None
if offer.stock is not None and offer.stock < qty:
return "库存不足", None, None
try:
outcome = adder(offer, qty)
except Exception as exc:
logger.exception("加购加车失败 platform=%s fid=%s", platform, row.fid)
return _system("ADD_FAILED"), "ADD_FAILED", _text(exc)
if outcome.kind == KIND_OK:
return None, None, None
reason = (outcome.reason or "加购失败").strip()
if outcome.kind == KIND_SYSTEM:
return _system(reason), None, None
return clip_reason(reason), None, None
# 取出异常原文。空串不当审计正文。
def _text(exc: BaseException) -> str | None:
text = str(exc).strip()
return text or None
# 把系统故障收成「系统故障:短码」并截断。已带前缀的不再套一层。
def _system(detail: str) -> str:
text = (detail or "未知错误").strip()
if text.startswith("系统故障"):
return clip_reason(text)
return clip_reason(f"系统故障:{text}")

54
rpa_cart/cart_match.py Normal file
View File

@ -0,0 +1,54 @@
# 按供应商名对上搜到的仓。无 sku_id 时靠条形码结果 + 商家全称。
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
SELF_OPERATED_PREFIX = "[自营]"
# 再搜到的一条报价:商家全称、库存、价格。加车前靠它对上表 2 的供应商。
@dataclass
class CartOffer:
sku_id: str
vendor_raw: str
vendor_norm: str
stock: float | None
price: float | None = None
extra: dict[str, Any] = field(default_factory=dict)
# 去掉「[自营]」前缀和多余空白,用来对商家全称。
def normalize_supplier(raw: str) -> str:
text = (raw or "").replace("\u3000", " ").strip()
if text.startswith(SELF_OPERATED_PREFIX):
text = text[len(SELF_OPERATED_PREFIX) :]
return " ".join(text.split())
# 条形码结果里对上商家全称。同仓留低价,价格相同留库存多的。对不上返回 None。
def match_offer(offers: list[CartOffer], supplier_name: str) -> CartOffer | None:
target = normalize_supplier(supplier_name)
if not target:
return None
matched = [
offer
for offer in offers
if normalize_supplier(offer.vendor_raw) == target
or normalize_supplier(offer.vendor_norm) == target
]
if not matched:
return None
return min(matched, key=_offer_rank)
# 同仓排序:有价格的优先,低价优先,同价库存多的优先。
def _offer_rank(offer: CartOffer) -> tuple[int, float, float]:
price = offer.price
stock = offer.stock
return (
0 if price is not None else 1,
float(price) if price is not None else 0.0,
-float(stock) if stock is not None else 0.0,
)

370
rpa_cart/cart_repo.py Normal file
View File

@ -0,0 +1,370 @@
# 表 2 加购行:领取、成功盖章、失败改状态。成功不插新行。
#
# 领取时盖「加购中」。进程中途退出时行停在这一戳,超过租约(update_time 30 分钟)才能再领。
# 未过期的「加购中」需改回「系统管理员」再跑。
from __future__ import annotations
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any
from rpa_oracle import RepoUnavailable, job_pool, update_by_fid_chunks
from rpa_oracle.audit import ShiftError, save_shift_errors as write_shift_errors
OPERATE_TYPE_ADD_CART = 1
STATUS_OK = 1
STATUS_FAIL = 2
PUSH_NOT_SENT = 0
STAMP_SELECTION = "系统管理员"
STAMP_CLAIMING = "加购中"
STAMP_DONE = "加购班"
FAIL_REASON_MAX = 500
LEASE_MINUTES = 30
_CLAIM_SQL = """
UPDATE rpa_cart_settlement_goods_data
SET update_user_name = :stamp,
update_time = SYSDATE
WHERE fid IN ({placeholders})
"""
_SUCCESS_SQL = """
UPDATE rpa_cart_settlement_goods_data
SET update_user_name = :stamp,
fail_reason = NULL,
update_time = SYSDATE
WHERE fid = :fid
AND update_user_name = :claim_stamp
"""
_FAIL_SQL = """
UPDATE rpa_cart_settlement_goods_data
SET status = :status,
fail_reason = :fail_reason,
update_user_name = :stamp,
update_time = SYSDATE
WHERE fid = :fid
AND update_user_name = :claim_stamp
"""
_RELEASE_SQL = """
UPDATE rpa_cart_settlement_goods_data
SET update_user_name = :stamp,
fail_reason = :fail_reason,
update_time = SYSDATE
WHERE fid = :fid
AND update_user_name = :claim_stamp
"""
# 表 2 一行待加购或处理中的结算货品。
@dataclass
class CartRow:
fid: int
oms_id: int
platform_type: int
supplier_name: str
bar_code: str
purchase_qty: int
status: int
fail_reason: str | None
push_status: int
update_user_name: str
operate_type: int = OPERATE_TYPE_ADD_CART
# 把失败原因截到 FAIL_REASON_MAX 字,超长末尾加省略号。
def clip_reason(text: str) -> str:
text = (text or "").strip()
if len(text) <= FAIL_REASON_MAX:
return text
return text[: FAIL_REASON_MAX - 1] + "…"
# 判断内存行是否可领:系统管理员,或加购中且 update_time 已过租约。
def _is_pending(row: dict[str, Any], now: datetime) -> bool:
stamp = row.get("update_user_name") or STAMP_SELECTION
if (
int(row.get("operate_type") or 0) != OPERATE_TYPE_ADD_CART
or int(row.get("status") or 0) != STATUS_OK
or int(row.get("push_status") or 0) != PUSH_NOT_SENT
):
return False
if stamp == STAMP_SELECTION:
return True
if stamp == STAMP_CLAIMING:
claimed_at = row.get("update_time")
if claimed_at is None:
return False
return now - claimed_at >= timedelta(minutes=LEASE_MINUTES)
return False
# 把字典收成 CartRow。缺数量当 0。
def _row_from_dict(data: dict[str, Any]) -> CartRow:
qty = data.get("purchase_qty")
return CartRow(
fid=int(data["fid"]),
oms_id=int(data["oms_id"]),
platform_type=int(data["platform_type"]),
supplier_name=str(data.get("supplier_name") or ""),
bar_code=str(data.get("bar_code") or ""),
purchase_qty=int(qty) if qty is not None else 0,
status=int(data.get("status") or 0),
fail_reason=data.get("fail_reason"),
push_status=int(data.get("push_status") or 0),
update_user_name=str(data.get("update_user_name") or ""),
operate_type=int(data.get("operate_type") or OPERATE_TYPE_ADD_CART),
)
# 回滚当前事务。回滚本身失败则忽略。
def _rollback(conn) -> None:
try:
conn.rollback()
except Exception:
pass
# 拼领取 SELECT:系统管理员或过期加购中,末尾 FOR UPDATE SKIP LOCKED。
def _pending_select_sql(
*, limit: int | None, platform_types: list[int] | None
) -> tuple[str, dict[str, Any]]:
params: dict[str, Any] = {
"selection_stamp": STAMP_SELECTION,
"claim_stamp": STAMP_CLAIMING,
"lease_minutes": LEASE_MINUTES,
}
filters = [
"operate_type = 1",
"status = 1",
"push_status = 0",
"""(
NVL(update_user_name, :selection_stamp) = :selection_stamp
OR (
update_user_name = :claim_stamp
AND update_time < SYSDATE - NUMTODSINTERVAL(:lease_minutes, 'MINUTE')
)
)""",
]
if platform_types is not None:
if not platform_types:
filters.append("1 = 0")
else:
keys: list[str] = []
for index, platform in enumerate(platform_types):
key = f"p{index}"
keys.append(f":{key}")
params[key] = int(platform)
filters.append(f"platform_type IN ({', '.join(keys)})")
sql = f"""
SELECT fid, oms_id, platform_type, supplier_name, bar_code,
purchase_qty, status, fail_reason, push_status, update_user_name,
operate_type
FROM rpa_cart_settlement_goods_data
WHERE {' AND '.join(filters)}
ORDER BY oms_id, fid
"""
if limit is not None:
params["limit"] = int(limit)
sql = f"SELECT * FROM (\n{sql}\n) WHERE ROWNUM <= :limit"
return sql + "\nFOR UPDATE SKIP LOCKED", params
# 加购班次的内存仓库,测规则和班次编排,不测 SQL。
class MemoryCartRepo:
# 用给定的表 2 行初始化。同一 fid 后写覆盖先写。
def __init__(self, rows: list[dict[str, Any]]):
self.rows: dict[int, dict[str, Any]] = {int(row["fid"]): dict(row) for row in rows}
self.shift_errors: list[ShiftError] = []
# 领取待加购任务,把戳从系统管理员或过期加购中改成加购中。
#
# 未过租约的加购中不领。limit 为空表示不限制条数。now 只给单测钉住时钟。
def claim_pending(
self,
limit: int | None = None,
platform_types: list[int] | None = None,
*,
now: datetime | None = None,
) -> list[CartRow]:
clock = now or datetime.now()
pending = sorted(
(row for row in self.rows.values() if _is_pending(row, clock)),
key=lambda row: (int(row["oms_id"]), int(row["fid"])),
)
if platform_types is not None:
allowed = set(platform_types)
pending = [row for row in pending if int(row["platform_type"]) in allowed]
if limit is not None:
pending = pending[:limit]
claimed: list[CartRow] = []
for row in pending:
row["update_user_name"] = STAMP_CLAIMING
row["update_time"] = clock
claimed.append(_row_from_dict(row))
return claimed
# 成功盖加购班,清失败原因,不改 status。
def mark_success(self, fid: int) -> None:
row = self.rows[fid]
row["update_user_name"] = STAMP_DONE
row["fail_reason"] = None
# 失败改 status=2,写下截断后的原因,戳改成加购班。
def mark_fail(self, fid: int, reason: str) -> None:
row = self.rows[fid]
row["status"] = STATUS_FAIL
row["fail_reason"] = clip_reason(reason)
row["update_user_name"] = STAMP_DONE
def mark_release(self, fid: int, reason: str) -> None:
"""登录或查询失败放回待领。戳改回系统管理员,status 保持 1。"""
row = self.rows[fid]
row["update_user_name"] = STAMP_SELECTION
row["fail_reason"] = clip_reason(reason)
# 记下未捕获异常原文。不改表 2。
def save_shift_errors(self, items: list[ShiftError]) -> None:
self.shift_errors.extend(items)
# 加购班次连 Oracle 的仓库,管表 2 领取和盖章。
#
# 连接从 rpa_oracle 连接池取。领取戳是加购中。
# 进程退出后行停在加购中,超过租约才能再领。
class OracleCartRepo:
# 挂上连接池。方法里按次 acquire,班次 session 内复用一条。
def __init__(self, pool):
self._pool = pool
self._held = None
# 按环境变量建班次池。缺 HOST / 用户 / 密码抛「数据库不可用」。
@classmethod
def from_env(cls) -> "OracleCartRepo":
return cls(pool=job_pool())
# 未捕获异常原文进共用审计表。独立事务,失败只记日志。
def save_shift_errors(self, items: list[ShiftError]) -> None:
write_shift_errors(self._pool, items)
# 班次内借一条连接,领取和盖章共用。每笔盖章单独提交。
@contextmanager
def session(self):
if self._held is not None:
yield
return
with self._pool.acquire() as conn:
self._held = conn
try:
yield
finally:
self._held = None
# 有 session 时交出已借连接,否则从池里新借一条。
def _acquire(self):
if self._held is not None:
# 交出班次已借的那条连接,退出时不归还。
@contextmanager
def _held():
yield self._held
return _held()
return self._pool.acquire()
def claim_pending(
self, limit: int | None = None, platform_types: list[int] | None = None
) -> list[CartRow]:
"""领取待加购任务,把表 2 的戳从系统管理员改成加购中。
使用 FOR UPDATE SKIP LOCKED,两个班次不会领到同一行。
更新按每段 500 个 fid 执行,同一事务,任一段失败整笔回滚。
进程退出后行停在加购中,超过租约(update_time)才能再领。
limit 仍只截这一班领多少;为空表示不限制。没有可领的行时返回空列表。
"""
sql, params = _pending_select_sql(limit=limit, platform_types=platform_types)
try:
with self._acquire() as conn:
try:
with conn.cursor() as cur:
cur.execute(sql, params)
fetched = [_row_from_cursor(cur, rec) for rec in cur.fetchall()]
if fetched:
update_by_fid_chunks(
cur,
_CLAIM_SQL,
[row.fid for row in fetched],
{"stamp": STAMP_CLAIMING},
)
for row in fetched:
row.update_user_name = STAMP_CLAIMING
conn.commit()
return fetched
except Exception as exc:
_rollback(conn)
if isinstance(exc, RepoUnavailable):
raise
raise RepoUnavailable("数据库不可用") from exc
except RepoUnavailable:
raise
except Exception as exc:
raise RepoUnavailable("数据库不可用") from exc
# 成功盖加购班。只改仍停在加购中的那一行。
def mark_success(self, fid: int) -> None:
self._execute(
_SUCCESS_SQL,
{"fid": int(fid), "stamp": STAMP_DONE, "claim_stamp": STAMP_CLAIMING},
)
# 失败改 status=2 并写原因。只改仍停在加购中的那一行。
def mark_fail(self, fid: int, reason: str) -> None:
self._execute(
_FAIL_SQL,
{
"fid": int(fid),
"status": STATUS_FAIL,
"fail_reason": clip_reason(reason),
"stamp": STAMP_DONE,
"claim_stamp": STAMP_CLAIMING,
},
)
def mark_release(self, fid: int, reason: str) -> None:
"""登录或查询失败放回待领。只改仍停在加购中的那一行,status 不动。"""
self._execute(
_RELEASE_SQL,
{
"fid": int(fid),
"fail_reason": clip_reason(reason),
"stamp": STAMP_SELECTION,
"claim_stamp": STAMP_CLAIMING,
},
)
# 在当前连接上执行一条 UPDATE 并提交。库故障对外「数据库不可用」。
def _execute(self, sql: str, params: dict[str, Any]) -> None:
try:
with self._acquire() as conn:
try:
with conn.cursor() as cur:
cur.execute(sql, params)
conn.commit()
except Exception as exc:
_rollback(conn)
if isinstance(exc, RepoUnavailable):
raise
raise RepoUnavailable("数据库不可用") from exc
except RepoUnavailable:
raise
except Exception as exc:
raise RepoUnavailable("数据库不可用") from exc
# 把查询结果行收成 CartRow。
def _row_from_cursor(cur, rec) -> CartRow:
cols = [item[0].lower() for item in cur.description]
return _row_from_dict(dict(zip(cols, rec)))

92
rpa_cart/cart_search.py Normal file
View File

@ -0,0 +1,92 @@
# 再搜条形码,保留原始商品字段给加车用。
#
# 导入路径由 selection_path 放进 rpa_selection,这里不再改 sys.path。
from __future__ import annotations
from typing import Any
import requests
import selection_path # noqa: F401 先把 rpa_selection 放进导入路径
from cart_match import CartOffer
from search_yaoex import map_yaoex_products, query_yaoex_products
from search_ybm import map_ybm_products, query_ybm_products
# 按条形码搜 1 号药城,结果转成 CartOffer 并带上原始商品字段。
def search_yaoex_offers(
auth,
query_key: str,
session: requests.Session | None = None,
cache: dict[str, list[CartOffer]] | None = None,
) -> list[CartOffer]:
store = cache if cache is not None else {}
if query_key in store:
return store[query_key]
own = session is None
client = session or requests.Session()
try:
products, user_id = query_yaoex_products(client, auth, query_key)
candidates = map_yaoex_products(products, user_id)
offers = [
_from_candidate(candidate, raw)
for candidate, raw in zip(candidates, products)
]
finally:
if own:
client.close()
store[query_key] = offers
return offers
# 按条形码搜药帮忙,结果转成 CartOffer 并带上原始商品字段。
def search_ybm_offers(
cookies: dict[str, str],
query_key: str,
session: requests.Session | None = None,
cache: dict[str, list[CartOffer]] | None = None,
) -> list[CartOffer]:
store = cache if cache is not None else {}
if query_key in store:
return store[query_key]
own = session is None
client = session or requests.Session()
try:
if own:
for name, value in cookies.items():
client.cookies.set(name, value)
rows = query_ybm_products(client, query_key)
candidates = map_ybm_products(rows)
offers = [
_from_candidate(candidate, _ybm_raw(row))
for candidate, row in zip(candidates, rows)
]
finally:
if own:
client.close()
store[query_key] = offers
return offers
# 从药帮忙搜索行抽出商品原始字段,补上 skuId。
def _ybm_raw(row: dict[str, Any]) -> dict[str, Any]:
product = row.get("productInfo")
raw = dict(product) if isinstance(product, dict) else dict(row)
if not raw.get("skuId"):
raw["skuId"] = row.get("skuId")
return raw
# 把选品候选加上原始字段收成加购用的 CartOffer。
def _from_candidate(candidate, raw: dict[str, Any]) -> CartOffer:
extra = dict(candidate.extra)
extra["raw"] = raw
return CartOffer(
sku_id=candidate.sku_id,
vendor_raw=candidate.vendor_raw,
vendor_norm=candidate.vendor_norm,
stock=candidate.stock,
price=candidate.price,
extra=extra,
)

106
rpa_cart/cart_yaoex.py Normal file
View File

@ -0,0 +1,106 @@
# 1 号药城加入购物车。
from __future__ import annotations
import time
from typing import Any
import requests
import urllib3
from cart_add import KIND_OK, KIND_SYSTEM, AddOutcome, classify_mall_message
from cart_match import CartOffer
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
GATEWAY = "https://gateway-b2b.fangkuaiyi.com"
YAOEX_ADD_URL = f"{GATEWAY}/small/shopCart/addShopCart"
# 按再搜到的 sku 把数量加入 1 号药城购物车。没有 sku 当系统失败,不改业务原因。
def add_yaoex_cart(auth, offer: CartOffer, qty: int, *, session: requests.Session | None = None) -> AddOutcome:
ids = _ids(offer)
if not ids["skuId"] and not ids["spuCode"]:
return AddOutcome(kind=KIND_SYSTEM, reason="缺少 sku")
payload = _form(
auth,
{
"skuId": ids["skuId"],
"spuCode": ids["spuCode"],
"sellerCode": ids["sellerCode"],
"amount": int(qty),
},
)
own = session is None
client = session or requests.Session()
try:
response = client.post(
YAOEX_ADD_URL,
data=payload,
headers=_headers(),
timeout=30,
verify=False,
)
response.raise_for_status()
return _outcome_from_json(response.json())
except Exception as exc:
return AddOutcome(kind=KIND_SYSTEM, reason=str(exc))
finally:
if own:
client.close()
# 从报价里取出加车要的 sku、spu 和商家编码。
def _ids(offer: CartOffer) -> dict[str, str]:
raw = offer.extra.get("raw") if isinstance(offer.extra.get("raw"), dict) else offer.extra
if not isinstance(raw, dict):
raw = {}
sku = str(raw.get("skuId") or raw.get("id") or offer.sku_id or "")
spu = str(raw.get("spuCode") or offer.sku_id or "")
seller = str(raw.get("sellerCode") or raw.get("vendorCode") or raw.get("shopCode") or "")
return {"skuId": sku, "spuCode": spu, "sellerCode": seller}
# 拼 1 号药城表单:带上登录 token,再叠上加车字段。
def _form(auth, extra: dict[str, Any]) -> dict[str, Any]:
payload = {
"traderName": "yaoex_pc",
"trader": "pc",
"closesignature": "yes",
"signature_method": "md5",
"signature": "****",
"timestamp": str(int(time.time() * 1000)),
"token": auth.token,
"userToken": auth.token,
"userId": getattr(auth, "user_id", ""),
"buyerCode": getattr(auth, "buyer_code", ""),
}
payload.update(extra)
return payload
# 1 号药城加车请求头。来源页是购物车。
def _headers() -> dict[str, str]:
return {
"Content-Type": "application/x-www-form-urlencoded",
"X-Requested-With": "XMLHttpRequest",
"X-Request-Agent": "Axios",
"traderName": "yaoex_pc",
"trader": "pc",
"Origin": "https://mall.yaoex.com",
"Referer": "https://mall.yaoex.com/v2/cart/",
}
# 把加车回包收成成功、业务失败或系统失败。看不懂的回包当系统失败。
def _outcome_from_json(payload: object) -> AddOutcome:
if not isinstance(payload, dict):
return AddOutcome(kind=KIND_SYSTEM, reason="加购返回异常")
msg = str(payload.get("msg") or payload.get("message") or payload.get("errorMsg") or "")
code = payload.get("code")
success = payload.get("success")
if success is True or code in (0, "0", 200, "200"):
return AddOutcome(kind=KIND_OK)
if msg:
return classify_mall_message(msg)
return AddOutcome(kind=KIND_SYSTEM, reason="加购失败")

83
rpa_cart/cart_ybm.py Normal file
View File

@ -0,0 +1,83 @@
# 药帮忙加入购物车。
from __future__ import annotations
import requests
import urllib3
from cart_add import KIND_OK, KIND_SYSTEM, AddOutcome, classify_mall_message
from cart_match import CartOffer
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
YBM_ADD_URL = "https://www.ybm100.com/new-front/cart/add-product"
# 按再搜到的 sku 把数量加入药帮忙购物车。没有 sku 当系统失败。
def add_ybm_cart(
cookies: dict[str, str],
offer: CartOffer,
qty: int,
*,
session: requests.Session | None = None,
) -> AddOutcome:
sku = _sku_id(offer)
if not sku:
return AddOutcome(kind=KIND_SYSTEM, reason="缺少 sku")
own = session is None
client = session or requests.Session()
try:
if own:
for name, value in cookies.items():
client.cookies.set(name, value)
response = client.post(
YBM_ADD_URL,
json={"skuId": sku, "skuNum": int(qty)},
headers=_headers(),
timeout=30,
verify=False,
)
response.raise_for_status()
return _outcome_from_json(response.json())
except Exception as exc:
return AddOutcome(kind=KIND_SYSTEM, reason=str(exc))
finally:
if own:
client.close()
# 从报价里取药帮忙加车用的 skuId。
def _sku_id(offer: CartOffer) -> str:
raw = offer.extra.get("raw") if isinstance(offer.extra.get("raw"), dict) else offer.extra
if not isinstance(raw, dict):
raw = {}
return str(raw.get("skuId") or offer.sku_id or "")
# 药帮忙加车请求头。
def _headers() -> dict[str, str]:
return {
"Accept": "application/json, text/plain, */*",
"Content-Type": "application/json",
"Origin": "https://www.ybm100.com",
"Referer": "https://www.ybm100.com/new/",
"X-Requested-With": "XMLHttpRequest",
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36"
),
}
# 把加车回包收成成功、业务失败或系统失败。看不懂的回包当系统失败。
def _outcome_from_json(payload: object) -> AddOutcome:
if not isinstance(payload, dict):
return AddOutcome(kind=KIND_SYSTEM, reason="加购返回异常")
msg = str(payload.get("msg") or payload.get("message") or "")
success = payload.get("success")
code = payload.get("code")
if success is True or code in (0, "0", 200, "200"):
return AddOutcome(kind=KIND_OK)
if msg:
return classify_mall_message(msg)
return AddOutcome(kind=KIND_SYSTEM, reason="加购失败")

46
rpa_cart/job.py Normal file
View File

@ -0,0 +1,46 @@
# 1 号药城支付落表班次。
from __future__ import annotations
from dataclasses import dataclass
from settle import MallLine, match_pay_lines
# 一次支付落表的计数:写入、状态跳过、订单号重复、对不上。
@dataclass
class PaySummary:
source_lines: int = 0
inserted: int = 0
skipped_status: int = 0
skipped_dup: int = 0
unmatched: int = 0
unmatched_reasons: dict[str, int] | None = None
# 未对上的原因计数缺省为空字典,避免调用方再判 None。
def __post_init__(self) -> None:
if self.unmatched_reasons is None:
self.unmatched_reasons = {}
# 把 1 号药城已支付行写入表 2 的支付行。不对加购行。dry_run 只统计不落库。
def run_yaoex_pay(
repo,
lines: list[MallLine],
*,
dry_run: bool = False,
) -> PaySummary:
result = match_pay_lines(lines, repo.load_existing_pay_order_nos())
if result.rows and not dry_run:
repo.save_pay_rows(result.rows)
reasons: dict[str, int] = {}
for _line, reason in result.unmatched:
reasons[reason] = reasons.get(reason, 0) + 1
return PaySummary(
source_lines=len(lines),
inserted=len(result.rows),
skipped_status=len(result.skipped_status),
skipped_dup=len(result.skipped_dup),
unmatched=len(result.unmatched),
unmatched_reasons=reasons,
)

264
rpa_cart/main.py Normal file
View File

@ -0,0 +1,264 @@
# 加购班与 1 号药城支付落表。默认支付;`cart` 子命令加购。
from __future__ import annotations
import argparse
from datetime import date, datetime
from pathlib import Path
import requests
from rpa_oracle import RepoUnavailable
from job import run_yaoex_pay
from parse_xls import parse_order_xls
from repo import OraclePayRepo
from yaoex_client import day_range, export_orders
from yaoex_login import (
BrowserRuntime,
load_cart_dotenv,
resolve_yaoex_auth,
resolve_ybm_cookies,
)
PROJECT_ROOT = Path(__file__).resolve().parent
EXPORT_DIR = PROJECT_ROOT / "exports" / "yaoex"
YAOEX_PLATFORM_TYPE = 66
YBM_PLATFORM_TYPE = 48
DEFAULT_CART_LIMIT = 20
# 导出文件按订单日放进固定目录。同一秒再导一次加序号,不盖掉上一份。
def pay_export_path(
begin: str,
end: str,
exported_at: datetime,
root: Path = EXPORT_DIR,
) -> Path:
start_day = begin[:10]
end_day = end[:10]
folder_name = start_day if start_day == end_day else f"{start_day}_{end_day}"
directory = root / folder_name
stamp = exported_at.strftime("%H%M%S")
path = directory / f"{stamp}.xls"
suffix = 2
while path.exists():
path = directory / f"{stamp}_{suffix}.xls"
suffix += 1
return path
# 区分支付落表和加购。没写子命令时走支付。
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
argv = list(argv) if argv is not None else None
if argv is None:
import sys
argv = sys.argv[1:]
command = "pay"
if argv and argv[0] in {"pay", "cart"}:
command = argv.pop(0)
if command == "cart":
args = _parse_cart_args(argv)
args.command = "cart"
return args
args = _parse_pay_args(argv)
args.command = "pay"
return args
# 支付落表没有额外参数:登录、导出当天、写表 2。
def _parse_pay_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="1 号药城支付落表:登录、导出当天、解析、写库")
return parser.parse_args(argv)
def _parse_cart_args(argv: list[str]) -> argparse.Namespace:
"""解析加购参数:领取条数、平台、是否忽略登录缓存。
不传 --limit 时用 20,不再把空值当成全部领走。
"""
parser = argparse.ArgumentParser(description="加购班:领表 2 选品成功行,按仓加进购物车")
parser.add_argument(
"--limit",
type=int,
default=DEFAULT_CART_LIMIT,
help="本班最多领取 N 行,默认 20",
)
parser.add_argument(
"--platform",
choices=["all", "yaoex", "ybm"],
default="all",
help="all=两平台都跑",
)
parser.add_argument("--fresh-login", action="store_true", help="忽略登录缓存")
parser.add_argument("--yaoex-auth", type=Path, default=None)
parser.add_argument("--ybm-cookies", type=Path, default=None)
return parser.parse_args(argv)
# 入口。cart 子命令加购,否则跑 1 号药城支付落表。
def main(argv: list[str] | None = None, repo=None) -> None:
args = parse_args(argv)
if args.command == "cart":
main_cart(args, repo=repo)
return
main_pay(repo=repo)
# 登录 1 号药城,导出当天订单,已支付行写入表 2。不对加购行。
def main_pay(repo=None) -> None:
load_cart_dotenv()
try:
auth = resolve_yaoex_auth()
token = auth.token
print("1号药城认证就绪。")
except SystemExit:
raise
except Exception as exc:
print(f"1号药城登录失败:{exc}")
raise SystemExit(2) from exc
end = date.today()
begin, end_ts = day_range(end, 0)
print(f"导出 {begin} ~ {end_ts}")
content = export_orders(token, begin=begin, end=end_ts)
xls_path = pay_export_path(begin, end_ts, datetime.now())
xls_path.parent.mkdir(parents=True, exist_ok=True)
xls_path.write_bytes(content)
print(f"导出文件 {xls_path} ({len(content)} bytes)")
lines = parse_order_xls(xls_path)
print(f"导出行 {len(lines)}")
if repo is None:
try:
repo = OraclePayRepo.from_env()
except RepoUnavailable as exc:
print(f"数据库不可用:{exc}")
raise SystemExit(2) from exc
summary = run_yaoex_pay(repo, lines)
print(
"落表 "
f"inserted={summary.inserted} "
f"skipped_status={summary.skipped_status} "
f"skipped_dup={summary.skipped_dup} "
f"unmatched={summary.unmatched} "
f"reasons={summary.unmatched_reasons}"
)
# 领表 2 选品成功行,按条形码再搜并对上商家后加车。一行失败只改那一行。
def main_cart(args: argparse.Namespace, repo=None) -> None:
from cart_job import run_cart_batch
from cart_repo import OracleCartRepo
from cart_search import search_yaoex_offers, search_ybm_offers
from cart_yaoex import add_yaoex_cart
from cart_ybm import add_ybm_cart
load_cart_dotenv()
if repo is None:
try:
repo = OracleCartRepo.from_env()
except RepoUnavailable as exc:
print(f"数据库不可用:{exc}")
raise SystemExit(2) from exc
want_yaoex = args.platform in {"all", "yaoex"}
want_ybm = args.platform in {"all", "ybm"}
login_errors: dict[int, str] = {}
searchers = {}
adders = {}
yaoex_auth = None
ybm_cookies = None
runtime = BrowserRuntime(keep=False)
yaoex_session = requests.Session()
ybm_session = requests.Session()
yaoex_cache: dict = {}
ybm_cache: dict = {}
try:
if want_yaoex:
try:
yaoex_auth = resolve_yaoex_auth(
auth_path=args.yaoex_auth,
fresh=args.fresh_login,
runtime=runtime,
)
print("1号药城认证就绪。")
except SystemExit:
raise
except Exception as exc:
login_errors[YAOEX_PLATFORM_TYPE] = str(exc)
print(f"1号药城登录失败,本平台中止:{exc}")
if want_ybm:
try:
ybm_cookies = resolve_ybm_cookies(
cookie_path=args.ybm_cookies,
fresh=args.fresh_login,
runtime=runtime,
)
print("药帮忙认证就绪。")
except SystemExit:
raise
except Exception as exc:
login_errors[YBM_PLATFORM_TYPE] = str(exc)
print(f"药帮忙登录失败,本平台中止:{exc}")
if ybm_cookies:
for name, value in ybm_cookies.items():
ybm_session.cookies.set(name, value)
if yaoex_auth is not None:
# 用本班已登录的 1 号药城会话按条形码再搜。
def search_yaoex(query_key: str):
return search_yaoex_offers(
yaoex_auth, query_key, session=yaoex_session, cache=yaoex_cache
)
# 把对上的仓按数量加入 1 号药城购物车。
def add_yaoex(offer, qty: int):
return add_yaoex_cart(yaoex_auth, offer, qty, session=yaoex_session)
searchers[YAOEX_PLATFORM_TYPE] = search_yaoex
adders[YAOEX_PLATFORM_TYPE] = add_yaoex
if ybm_cookies is not None:
# 用本班已登录的药帮忙会话按条形码再搜。
def search_ybm(query_key: str):
return search_ybm_offers(
ybm_cookies, query_key, session=ybm_session, cache=ybm_cache
)
# 把对上的仓按数量加入药帮忙购物车。
def add_ybm(offer, qty: int):
return add_ybm_cart(ybm_cookies, offer, qty, session=ybm_session)
searchers[YBM_PLATFORM_TYPE] = search_ybm
adders[YBM_PLATFORM_TYPE] = add_ybm
platform_types: list[int] = []
if want_yaoex:
platform_types.append(YAOEX_PLATFORM_TYPE)
if want_ybm:
platform_types.append(YBM_PLATFORM_TYPE)
summary = run_cart_batch(
repo,
searchers=searchers,
adders=adders,
login_errors=login_errors,
limit=args.limit,
platform_types=platform_types,
)
print(
f"领取={summary.claimed} 加购成功={summary.added} 失败={summary.failed}"
)
print("未推订单中心,未清空购物车。")
finally:
yaoex_session.close()
ybm_session.close()
runtime.close()
if __name__ == "__main__":
main()

86
rpa_cart/parse_xls.py Normal file
View File

@ -0,0 +1,86 @@
# 解析 1 号药城「订单明细」xls。
from __future__ import annotations
from decimal import Decimal
from pathlib import Path
import xlrd
from settle import MallLine
_REQUIRED = {
"订单号": "order_no",
"父订单号": "parent_order_no",
"商家": "supplier_name",
"订单状态": "status",
"品名": "goods_name",
"产品编码": "product_code",
"产品条码": "bar_code",
"单价(元)": "unit_price",
"数量": "qty",
"商品金额(元)": "goods_amount",
"折后单价": "pay_price",
"订单实付总金额": "order_paid",
}
# 读 1 号药城订单明细表。缺列就失败。订单号为空的行丢掉。
def parse_order_xls(path: str | Path) -> list[MallLine]:
book = xlrd.open_workbook(str(path))
sheet = book.sheet_by_index(0)
headers = [str(sheet.cell_value(0, col)).strip() for col in range(sheet.ncols)]
missing = [name for name in _REQUIRED if name not in headers]
if missing:
raise ValueError("导出表缺列:" + "、".join(missing))
index = {field: headers.index(name) for name, field in _REQUIRED.items()}
lines: list[MallLine] = []
for row in range(1, sheet.nrows):
values = {field: sheet.cell_value(row, col) for field, col in index.items()}
order_no = _text(values["order_no"])
if not order_no:
continue
lines.append(
MallLine(
order_no=order_no,
parent_order_no=_text(values["parent_order_no"]),
supplier_name=_text(values["supplier_name"]),
status=_text(values["status"]),
goods_name=_text(values["goods_name"]),
product_code=_text(values["product_code"]),
bar_code=_barcode(values["bar_code"]),
unit_price=_decimal(values["unit_price"]),
pay_price=_decimal(values["pay_price"]),
qty=int(_decimal(values["qty"])),
goods_amount=_decimal(values["goods_amount"]),
order_paid=_decimal(values["order_paid"]),
)
)
return lines
# 单元格收成字符串。整数浮点去掉小数点,避免订单号变成 123.0。
def _text(value: object) -> str:
if value is None:
return ""
if isinstance(value, float) and value == int(value):
return str(int(value))
return str(value).strip()
# 条码只留第一段。逗号后的附加码丢掉,末尾 .0 去掉。
def _barcode(value: object) -> str:
text = _text(value)
if "," in text:
parts = [part.strip() for part in text.split(",") if part.strip()]
text = parts[0] if parts else ""
if text.endswith(".0") and text.replace(".", "", 1).isdigit():
return text[:-2]
return text
# 单元格收成金额。空值当 0。
def _decimal(value: object) -> Decimal:
if value is None or value == "":
return Decimal("0")
return Decimal(str(value))

31
rpa_cart/pyproject.toml Normal file
View File

@ -0,0 +1,31 @@
[project]
name = "rpa-cart"
version = "0.1.0"
description = "洁达加购与支付回传。表 2 加购班 + 1 号药城支付落表。不推接口 2。"
requires-python = ">=3.12"
dependencies = [
"oracledb>=2.4.1",
"playwright>=1.49.0",
"pycryptodome>=3.23.0",
"requests>=2.34.2",
"urllib3>=2.0.0",
"xlrd==1.2.0",
]
[dependency-groups]
dev = [
"pytest>=8.0.0",
"xlwt>=1.3.0",
]
[tool.uv]
package = false
[tool.pytest.ini_options]
pythonpath = [".", "..", "../rpa_selection"]
testpaths = ["tests"]
addopts = "-q"
[[tool.uv.index]]
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
default = true

121
rpa_cart/repo.py Normal file
View File

@ -0,0 +1,121 @@
# 表 2:写支付行。
#
# 连接从 rpa_oracle 池取。库故障对外「数据库不可用」,不含 ORA 原文。
from __future__ import annotations
from typing import Any
from rpa_oracle import RepoUnavailable, job_pool
CREATE_USER_NAME = "rpa-cart"
from settle import YAOEX_PLATFORM_TYPE
_SELECT_PAY_ORDER_NOS = """
SELECT DISTINCT pay_order_no
FROM rpa_cart_settlement_goods_data
WHERE operate_type = 2
AND platform_type = :platform_type
AND pay_order_no IS NOT NULL
"""
_INSERT_PAY = """
INSERT INTO rpa_cart_settlement_goods_data (
operate_type, oms_id, platform_type, supplier_name, bar_code,
purchase_price, purchase_qty, purchase_total, status, fail_reason,
pay_order_no, pay_order_dtl_id, push_status, create_time,
org_code, multi_platform_flag, create_user_name
) VALUES (
:operate_type, :oms_id, :platform_type, :supplier_name, :bar_code,
:purchase_price, :purchase_qty, :purchase_total, :status, :fail_reason,
:pay_order_no, :pay_order_dtl_id, :push_status, SYSDATE,
:org_code, :multi_platform_flag, :create_user_name
)
"""
_PAY_BIND_KEYS = (
"operate_type",
"oms_id",
"platform_type",
"supplier_name",
"bar_code",
"purchase_price",
"purchase_qty",
"purchase_total",
"status",
"fail_reason",
"pay_order_no",
"pay_order_dtl_id",
"push_status",
"org_code",
"multi_platform_flag",
)
# 回滚当前事务。回滚本身失败则忽略。
def _rollback(conn) -> None:
try:
conn.rollback()
except Exception:
pass
# 支付班次连 Oracle 的仓库,管表 2 operate_type=2 的读写。
class OraclePayRepo:
# 挂上连接池。方法里按次 acquire。
def __init__(self, pool):
self._pool = pool
# 按环境变量建班次池。缺 HOST / 用户 / 密码抛「数据库不可用」。
@classmethod
def from_env(cls) -> "OraclePayRepo":
return cls(pool=job_pool())
# 读表 2 已有的 1 号药城支付单号,空值丢掉。
def load_existing_pay_order_nos(self) -> set[str]:
try:
with self._pool.acquire() as conn:
try:
with conn.cursor() as cur:
cur.execute(
_SELECT_PAY_ORDER_NOS,
{"platform_type": YAOEX_PLATFORM_TYPE},
)
return {
str(order_no)
for (order_no,) in cur.fetchall()
if order_no
}
except Exception as exc:
if isinstance(exc, RepoUnavailable):
raise
raise RepoUnavailable("数据库不可用") from exc
except RepoUnavailable:
raise
except Exception as exc:
raise RepoUnavailable("数据库不可用") from exc
# 把支付行一次 executemany 插入表 2。空列表直接返回。同一连接同一事务。
def save_pay_rows(self, rows: list[dict[str, Any]]) -> None:
if not rows:
return
binds = []
for row in rows:
payload = {key: row.get(key) for key in _PAY_BIND_KEYS}
payload["create_user_name"] = CREATE_USER_NAME
binds.append(payload)
try:
with self._pool.acquire() as conn:
try:
with conn.cursor() as cur:
cur.executemany(_INSERT_PAY, binds)
conn.commit()
except Exception as exc:
_rollback(conn)
if isinstance(exc, RepoUnavailable):
raise
raise RepoUnavailable("数据库不可用") from exc
except RepoUnavailable:
raise
except Exception as exc:
raise RepoUnavailable("数据库不可用") from exc

View File

@ -0,0 +1,10 @@
# 把 rpa_selection 放进导入路径。登录和搜索模块自己不改 sys.path。
from __future__ import annotations
import sys
from pathlib import Path
SELECTION_ROOT = Path(__file__).resolve().parent.parent / "rpa_selection"
if str(SELECTION_ROOT) not in sys.path:
sys.path.append(str(SELECTION_ROOT))

93
rpa_cart/settle.py Normal file
View File

@ -0,0 +1,93 @@
# 1 号药城导出行 → 表 2 operate_type=2。不对加购行。
from __future__ import annotations
import uuid
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP
from typing import Any
YAOEX_PLATFORM_TYPE = 66
OPERATE_TYPE_PAY = 2
PUSH_NOT_SENT = 0
UNASSIGNED_OMS_ID = 0
PAID_STATUSES = {"待发货", "待收货", "已完成"}
# 金额收到四位小数,四舍五入。接口 2 组包也按这个精度。
def money4(value: object) -> Decimal:
return Decimal(str(value)).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
# 生成支付明细号。表 2 用它区分同一订单下的不同行。
def new_pay_dtl_id() -> str:
return uuid.uuid4().hex
# 1 号药城导出的一行订单明细。只取已支付状态落表。
@dataclass(frozen=True)
class MallLine:
order_no: str
parent_order_no: str
supplier_name: str
status: str
goods_name: str
product_code: str
bar_code: str
unit_price: Decimal
pay_price: Decimal
qty: int
goods_amount: Decimal
order_paid: Decimal
# 支付匹配结果:可落库的行、对不上的行、重复订单、状态未付。
@dataclass
class MatchResult:
rows: list[dict[str, Any]]
unmatched: list[tuple[MallLine, str]]
skipped_dup: list[MallLine]
skipped_status: list[MallLine]
# 筛出待发货、待收货、已完成的行。同一支付订单号已落过的跳过。条码超长不对上。
def match_pay_lines(
lines: list[MallLine],
existing_order_nos: set[str],
) -> MatchResult:
result = MatchResult(rows=[], unmatched=[], skipped_dup=[], skipped_status=[])
for line in lines:
if line.status not in PAID_STATUSES:
result.skipped_status.append(line)
continue
if line.order_no in existing_order_nos:
result.skipped_dup.append(line)
continue
if len(line.bar_code) > 40:
result.unmatched.append((line, "BAR_CODE_TOO_LONG"))
continue
result.rows.append(build_pay_row(line))
return result
# 把一条已支付明细收成表 2 支付行。operate_type=2,oms_id 先填 0,不改加购行。
def build_pay_row(line: MallLine) -> dict[str, Any]:
price = money4(line.pay_price)
qty = int(line.qty)
return {
"operate_type": OPERATE_TYPE_PAY,
"oms_id": UNASSIGNED_OMS_ID,
"platform_type": YAOEX_PLATFORM_TYPE,
"supplier_name": line.supplier_name,
"bar_code": line.bar_code,
"purchase_price": price,
"purchase_qty": qty,
"purchase_total": money4(price * qty),
"status": None,
"fail_reason": None,
"pay_order_no": line.order_no,
"pay_order_dtl_id": new_pay_dtl_id(),
"push_status": PUSH_NOT_SENT,
"org_code": None,
"multi_platform_flag": 0,
}

View File

@ -0,0 +1,120 @@
from cart_add import KIND_BUSINESS, KIND_OK, KIND_SYSTEM, classify_mall_message
from cart_match import CartOffer
from cart_yaoex import YAOEX_ADD_URL, add_yaoex_cart
from cart_ybm import YBM_ADD_URL, add_ybm_cart
class FakeAuth:
token = "tok"
user_id = "u1"
buyer_code = "b1"
class FakeResponse:
def __init__(self, json_data=None, status_code=200, text=""):
self._json = json_data
self.status_code = status_code
self.text = text or str(json_data)
self.headers = {"content-type": "application/json"}
def raise_for_status(self):
if self.status_code >= 400:
raise RuntimeError(f"HTTP {self.status_code}")
def json(self):
if self._json is None:
raise ValueError("not json")
return self._json
class FakeSession:
def __init__(self, response: FakeResponse):
self.response = response
self.calls: list[dict] = []
def post(self, url, data=None, json=None, headers=None, timeout=None, verify=None, params=None):
self.calls.append(
{
"url": url,
"data": data,
"json": json,
"headers": headers,
"params": params,
}
)
return self.response
def close(self):
return None
def _offer(**overrides) -> CartOffer:
extra = overrides.pop(
"extra",
{
"raw": {
"skuId": "SKU66",
"spuCode": "SPU66",
"sellerCode": "GD01",
}
},
)
data = dict(
sku_id="SPU66",
vendor_raw="广东壹号药业有限公司",
vendor_norm="广东壹号药业有限公司",
stock=20.0,
price=90.0,
extra=extra,
)
data.update(overrides)
return CartOffer(**data)
def test_classify_pack_and_stock_and_ok():
pack = classify_mall_message("加入失败,数量须为中包装倍数")
assert pack.kind == KIND_BUSINESS
assert "中包装" in pack.reason
stock = classify_mall_message("库存不足")
assert stock.kind == KIND_BUSINESS
assert stock.reason == "库存不足"
ok = classify_mall_message("加入购物车成功")
assert ok.kind == KIND_OK
def test_yaoex_add_posts_token_and_qty():
session = FakeSession(FakeResponse({"code": 0, "msg": "成功"}))
outcome = add_yaoex_cart(FakeAuth(), _offer(), 7, session=session)
assert outcome.kind == KIND_OK
assert session.calls[0]["url"] == YAOEX_ADD_URL
payload = session.calls[0]["data"]
assert payload["token"] == "tok"
assert payload["userToken"] == "tok"
assert str(payload["amount"]) == "7"
assert payload["skuId"] == "SKU66"
assert payload["spuCode"] == "SPU66"
assert payload["sellerCode"] == "GD01"
def test_yaoex_add_classifies_pack_error():
session = FakeSession(FakeResponse({"code": 1, "msg": "数量必须是中包装的倍数"}))
outcome = add_yaoex_cart(FakeAuth(), _offer(), 7, session=session)
assert outcome.kind == KIND_BUSINESS
assert "中包装" in outcome.reason
def test_ybm_add_posts_sku_and_qty():
session = FakeSession(FakeResponse({"success": True, "msg": "成功"}))
offer = _offer(sku_id="SKU48", extra={"raw": {"skuId": "SKU48"}})
outcome = add_ybm_cart({"SESSION": "c1"}, offer, 3, session=session)
assert outcome.kind == KIND_OK
assert session.calls[0]["url"] == YBM_ADD_URL
body = session.calls[0]["json"]
assert body["skuId"] == "SKU48"
assert body["skuNum"] == 3
def test_ybm_http_error_is_system_fail():
session = FakeSession(FakeResponse(json_data=None, status_code=500, text="bad"))
outcome = add_ybm_cart({}, _offer(extra={"raw": {"skuId": "X"}}), 1, session=session)
assert outcome.kind == KIND_SYSTEM

View File

@ -0,0 +1,549 @@
# 加购班次:拆仓加车不插行、系统故障短码。
from contextlib import contextmanager
from datetime import datetime, timedelta
from cart_job import (
KIND_BUSINESS,
KIND_OK,
KIND_SYSTEM,
AddOutcome,
KnownLine,
add_known_lines,
run_cart_batch,
)
from cart_match import CartOffer
from cart_repo import STAMP_CLAIMING, STAMP_DONE, STAMP_SELECTION, MemoryCartRepo
HANGZHOU = "杭州小药药自营旗舰店"
GUANGDONG = "广东壹号药业有限公司"
YBM = 48
YAOEX = 66
# 构造一条待加购表 2 行。
def _row(**overrides) -> dict:
data = {
"fid": 1,
"oms_id": 2026100201,
"platform_type": YAOEX,
"supplier_name": GUANGDONG,
"bar_code": "06901234567890",
"purchase_qty": 7,
"status": 1,
"fail_reason": None,
"push_status": 0,
"update_user_name": STAMP_SELECTION,
"operate_type": 1,
}
data.update(overrides)
return data
# 构造一条能对上仓的加购候选。
def _offer(vendor: str, **overrides) -> CartOffer:
data = dict(
sku_id="S-" + vendor[:2],
vendor_raw=vendor,
vendor_norm=vendor,
stock=20.0,
price=90.0,
)
data.update(overrides)
return CartOffer(**data)
# 杭州 3 + 广东 7 两行都加车,表 2 仍两行。
def test_job_adds_split_rows_without_insert():
repo = MemoryCartRepo(
[
_row(fid=1, platform_type=YBM, supplier_name=HANGZHOU, purchase_qty=3),
_row(fid=2, platform_type=YAOEX, supplier_name=GUANGDONG, purchase_qty=7),
]
)
added: list[tuple[int, str, int]] = []
# 杭州仓有货。
def search_ybm(bar_code: str):
assert bar_code == "06901234567890"
return [_offer(HANGZHOU, sku_id="HZ", stock=5.0)]
# 广东仓有货。
def search_yaoex(bar_code: str):
assert bar_code == "06901234567890"
return [_offer(GUANGDONG, sku_id="GD", stock=20.0)]
# 记下药帮忙加车。
def add_ybm(offer: CartOffer, qty: int):
added.append((YBM, offer.sku_id, qty))
return AddOutcome(kind=KIND_OK)
# 记下 1 号药城加车。
def add_yaoex(offer: CartOffer, qty: int):
added.append((YAOEX, offer.sku_id, qty))
return AddOutcome(kind=KIND_OK)
summary = run_cart_batch(
repo,
searchers={YBM: search_ybm, YAOEX: search_yaoex},
adders={YBM: add_ybm, YAOEX: add_yaoex},
login_errors={},
)
assert summary.claimed == 2
assert summary.added == 2
assert summary.failed == 0
assert added == [(YBM, "HZ", 3), (YAOEX, "GD", 7)]
assert list(repo.rows) == [1, 2]
assert repo.rows[1]["status"] == 1
assert repo.rows[2]["status"] == 1
assert repo.rows[1]["update_user_name"] == STAMP_DONE
assert repo.rows[2]["update_user_name"] == STAMP_DONE
assert repo.rows[1]["push_status"] == 0
# 同一 omsId 一仓成功一仓失败:失败行原因带未加齐,成功行不回退。
def test_job_flags_incomplete_oms_when_one_row_fails():
repo = MemoryCartRepo(
[
_row(fid=1, platform_type=YBM, supplier_name=HANGZHOU, purchase_qty=3),
_row(fid=2, platform_type=YAOEX, supplier_name=GUANGDONG, purchase_qty=7),
]
)
# 杭州仓有货。
def search_ybm(bar_code: str):
return [_offer(HANGZHOU, stock=5.0)]
# 广东仓库存不够。
def search_yaoex(bar_code: str):
return [_offer(GUANGDONG, stock=1.0)]
summary = run_cart_batch(
repo,
searchers={YBM: search_ybm, YAOEX: search_yaoex},
adders={
YBM: lambda offer, qty: AddOutcome(kind=KIND_OK),
YAOEX: lambda offer, qty: AddOutcome(kind=KIND_OK),
},
login_errors={},
)
assert summary.added == 1
assert summary.failed == 1
assert repo.rows[1]["status"] == 1
assert repo.rows[1]["fail_reason"] is None
assert repo.rows[2]["status"] == 2
assert repo.rows[2]["fail_reason"] == "未加齐:库存不足"
# 库存不够时该行 status=2,原因是库存不足。
def test_job_marks_out_of_stock():
repo = MemoryCartRepo([_row(purchase_qty=10)])
# 库存只有 4。
def search_yaoex(bar_code: str):
return [_offer(GUANGDONG, stock=4.0)]
summary = run_cart_batch(
repo,
searchers={YAOEX: search_yaoex},
adders={YAOEX: lambda offer, qty: AddOutcome(kind=KIND_OK)},
login_errors={},
)
assert summary.added == 0
assert summary.failed == 1
assert repo.rows[1]["status"] == 2
assert repo.rows[1]["fail_reason"] == "库存不足"
assert repo.rows[1]["update_user_name"] == STAMP_DONE
assert repo.shift_errors == []
def test_job_marks_login_fail_as_system_error():
"""登录失败把戳改回系统管理员,status 保持 1,下一班还能领。"""
repo = MemoryCartRepo(
[
_row(fid=1, platform_type=YAOEX),
_row(fid=2, platform_type=YBM, supplier_name=HANGZHOU, purchase_qty=3),
]
)
ybm_added = []
# 杭州仓有货。
def search_ybm(bar_code: str):
return [_offer(HANGZHOU)]
# 记下药帮忙加车数量。
def add_ybm(offer: CartOffer, qty: int):
ybm_added.append(qty)
return AddOutcome(kind=KIND_OK)
summary = run_cart_batch(
repo,
searchers={YBM: search_ybm},
adders={YBM: add_ybm},
login_errors={YAOEX: "账号密码错误"},
)
assert summary.added == 1
assert summary.failed == 1
assert repo.rows[1]["status"] == 1
assert repo.rows[1]["update_user_name"] == STAMP_SELECTION
assert repo.rows[1]["fail_reason"] == "未加齐:系统故障:LOGIN_FAILED"
assert "账号密码错误" not in repo.rows[1]["fail_reason"]
assert len(repo.shift_errors) == 1
assert repo.shift_errors[0].shift_name == "加购"
assert repo.shift_errors[0].biz_key == "1"
assert repo.shift_errors[0].error_code == "LOGIN_FAILED"
assert repo.shift_errors[0].error_text == "账号密码错误"
assert repo.rows[2]["status"] == 1
assert repo.rows[2]["update_user_name"] == STAMP_DONE
assert ybm_added == [3]
again = repo.claim_pending()
assert [row.fid for row in again] == [1]
# 已盖加购班的行本班不领。
def test_job_skips_already_stamped_rows():
repo = MemoryCartRepo([_row(update_user_name=STAMP_DONE)])
called = []
# 不应被调用。
def search_yaoex(bar_code: str):
called.append(bar_code)
return [_offer(GUANGDONG)]
summary = run_cart_batch(
repo,
searchers={YAOEX: search_yaoex},
adders={YAOEX: lambda offer, qty: AddOutcome(kind=KIND_OK)},
login_errors={},
)
assert summary.claimed == 0
assert called == []
assert repo.rows[1]["update_user_name"] == STAMP_DONE
# 对不上 supplier_name 时写未找到对应仓。
def test_job_marks_missing_warehouse():
repo = MemoryCartRepo([_row()])
# 搜到的是杭州仓,对不上广东。
def search_yaoex(bar_code: str):
return [_offer(HANGZHOU)]
summary = run_cart_batch(
repo,
searchers={YAOEX: search_yaoex},
adders={YAOEX: lambda offer, qty: AddOutcome(kind=KIND_OK)},
login_errors={},
)
assert summary.failed == 1
assert repo.rows[1]["status"] == 2
assert repo.rows[1]["fail_reason"] == "未找到对应仓"
# 平台返回业务失败时原样写下原因。
def test_job_records_mall_add_failure():
repo = MemoryCartRepo([_row()])
# 广东仓有货。
def search_yaoex(bar_code: str):
return [_offer(GUANGDONG)]
# 平台返回中包装加不进。
def add_yaoex(offer: CartOffer, qty: int):
return AddOutcome(kind=KIND_BUSINESS, reason="中包装加不进:数量须为10的倍数")
summary = run_cart_batch(
repo,
searchers={YAOEX: search_yaoex},
adders={YAOEX: add_yaoex},
login_errors={},
)
assert summary.failed == 1
assert repo.rows[1]["fail_reason"] == "中包装加不进:数量须为10的倍数"
# 只跑一个平台时另一平台行仍停在系统管理员。
def test_job_platform_filter_leaves_other_mall_pending():
repo = MemoryCartRepo(
[
_row(fid=1, platform_type=YAOEX),
_row(fid=2, platform_type=YBM, supplier_name=HANGZHOU, purchase_qty=3),
]
)
# 广东仓有货。
def search_yaoex(bar_code: str):
return [_offer(GUANGDONG)]
summary = run_cart_batch(
repo,
searchers={YAOEX: search_yaoex},
adders={YAOEX: lambda offer, qty: AddOutcome(kind=KIND_OK)},
login_errors={},
platform_types=[YAOEX],
)
assert summary.claimed == 1
assert summary.added == 1
assert repo.rows[1]["update_user_name"] == STAMP_DONE
assert repo.rows[2]["update_user_name"] == STAMP_SELECTION
assert repo.rows[2]["status"] == 1
def test_job_search_error_is_system_fail():
"""查询失败原因列只写 QUERY_FAILED,戳改回系统管理员,status 保持 1。"""
repo = MemoryCartRepo([_row()])
# 抛带原文的查询错。
def search_yaoex(bar_code: str):
raise RuntimeError("网关 502 secret-traceback")
summary = run_cart_batch(
repo,
searchers={YAOEX: search_yaoex},
adders={YAOEX: lambda offer, qty: AddOutcome(kind=KIND_OK)},
login_errors={},
)
assert summary.failed == 1
reason = repo.rows[1]["fail_reason"]
assert reason == "系统故障:QUERY_FAILED"
assert repo.rows[1]["status"] == 1
assert repo.rows[1]["update_user_name"] == STAMP_SELECTION
assert "网关 502" not in reason
assert "secret-traceback" not in reason
assert len(repo.shift_errors) == 1
assert repo.shift_errors[0].error_code == "QUERY_FAILED"
assert "网关 502" in repo.shift_errors[0].error_text
assert "secret-traceback" in repo.shift_errors[0].error_text
# 加车抛错时原因列只写 ADD_FAILED,不含异常原文。
def test_job_add_error_writes_short_code_not_exception():
repo = MemoryCartRepo([_row()])
# 广东仓有货。
def search_yaoex(bar_code: str):
return [_offer(GUANGDONG)]
# 抛带 ORA 原文的加车错。
def add_yaoex(offer, qty: int):
raise RuntimeError("ORA-03114 secret-traceback")
summary = run_cart_batch(
repo,
searchers={YAOEX: search_yaoex},
adders={YAOEX: add_yaoex},
login_errors={},
)
assert summary.failed == 1
reason = repo.rows[1]["fail_reason"]
assert reason == "系统故障:ADD_FAILED"
assert "ORA-03114" not in reason
assert "secret-traceback" not in reason
assert len(repo.shift_errors) == 1
assert repo.shift_errors[0].error_code == "ADD_FAILED"
assert "ORA-03114" in repo.shift_errors[0].error_text
class HoldingCartRepo(MemoryCartRepo):
"""内存仓库加上可计数的 session,用来断言商城调用期间连接已归还。"""
def __init__(self, rows):
"""在内存行上加 held 计数。"""
super().__init__(rows)
self.held = 0
@contextmanager
def session(self):
"""借连接时 held 加一,退出时减回。"""
self.held += 1
try:
yield
finally:
self.held -= 1
def test_mall_runs_after_connection_is_returned():
"""两组加车时连接已归还,盖章时才再借。"""
repo = HoldingCartRepo(
[
_row(fid=1, oms_id=2026100201),
_row(fid=2, oms_id=2026100202),
]
)
during_add: list[int] = []
during_mark: list[int] = []
original_claim = repo.claim_pending
original_success = repo.mark_success
original_errors = repo.save_shift_errors
def claim_pending(limit=None, platform_types=None, **kwargs):
"""领取时必须占着本班的连接。"""
assert repo.held == 1
return original_claim(limit=limit, platform_types=platform_types, **kwargs)
def mark_success(fid):
"""盖章时必须占着本班的连接。"""
during_mark.append(repo.held)
return original_success(fid)
def save_shift_errors(items):
"""审计走连接池,不能在 session 里写。"""
assert repo.held == 0
return original_errors(items)
def search_yaoex(_bar_code: str):
"""返回能对上仓的候选。"""
return [_offer(GUANGDONG)]
def add_yaoex(_offer, _qty: int):
"""加车时连接必须已经归还。"""
during_add.append(repo.held)
return AddOutcome(kind=KIND_OK)
repo.claim_pending = claim_pending
repo.mark_success = mark_success
repo.save_shift_errors = save_shift_errors
summary = run_cart_batch(
repo,
searchers={YAOEX: search_yaoex},
adders={YAOEX: add_yaoex},
login_errors={},
)
assert during_add == [0, 0]
assert during_mark == [1, 1]
assert repo.held == 0
assert summary.added == 2
def test_rerun_skips_add_when_cart_already_has_the_line():
"""对方购物车已有不少于本行的数量、本地仍是加购中时,重跑不加第二份。"""
repo = MemoryCartRepo(
[
_row(
update_user_name=STAMP_CLAIMING,
update_time=datetime.now() - timedelta(minutes=31),
purchase_qty=7,
)
]
)
added: list[int] = []
def search_yaoex(_bar_code: str):
"""购物车已够时不应再搜。"""
raise AssertionError("购物车已够,不应再搜")
def add_yaoex(_offer, qty: int):
"""购物车已够时不应再加。"""
added.append(qty)
return AddOutcome(kind=KIND_OK)
def cart_qty(bar_code: str, supplier: str) -> int:
"""这一仓比本行多。多出来的数量不当成另一张单。"""
assert bar_code == "06901234567890"
assert supplier == GUANGDONG
return 10
summary = run_cart_batch(
repo,
searchers={YAOEX: search_yaoex},
adders={YAOEX: add_yaoex},
login_errors={},
cart_readers={YAOEX: cart_qty},
)
assert added == []
assert summary.added == 1
assert summary.failed == 0
assert repo.rows[1]["status"] == 1
assert repo.rows[1]["update_user_name"] == STAMP_DONE
assert repo.rows[1]["fail_reason"] is None
def _known(fid: int, sku: str, **overrides) -> KnownLine:
"""一条已经拆定的加购行。raw 里的 skuId 必须跟 sku 一起交到加车。"""
data = dict(
fid=fid,
oms_id=2026100201,
platform_type=YBM,
supplier_name=HANGZHOU,
bar_code="06901234567890",
qty=5,
sku_id=sku,
vendor_raw=HANGZHOU,
vendor_norm=HANGZHOU,
stock=5.0,
price=90.0,
raw={"skuId": sku},
)
data.update(overrides)
return KnownLine(**data)
# 同仓两条 sku 各加各的,不按商家名并到低价那条。一条失败时只给失败行标未加齐。
def test_known_lines_keep_each_sku_and_flag_incomplete():
repo = MemoryCartRepo(
[
_row(
fid=11,
platform_type=YBM,
supplier_name=HANGZHOU,
purchase_qty=6,
update_user_name=STAMP_CLAIMING,
),
_row(
fid=12,
platform_type=YBM,
supplier_name=HANGZHOU,
purchase_qty=4,
update_user_name=STAMP_CLAIMING,
),
]
)
added: list[tuple[str, int]] = []
# 高价那条被商城拒绝。
def add_ybm(offer: CartOffer, qty: int):
added.append((offer.sku_id, qty))
assert offer.extra["raw"]["skuId"] == offer.sku_id
if offer.sku_id == "DEAR":
return AddOutcome(kind=KIND_BUSINESS, reason="库存不足")
return AddOutcome(kind=KIND_OK)
summary = add_known_lines(
repo,
[_known(11, "CHEAP", qty=6), _known(12, "DEAR", qty=4, price=92.0)],
adders={YBM: add_ybm},
login_errors={},
)
assert added == [("CHEAP", 6), ("DEAR", 4)]
assert summary.added == 1
assert summary.failed == 1
assert repo.rows[11]["status"] == 1
assert repo.rows[11]["update_user_name"] == STAMP_DONE
assert repo.rows[12]["status"] == 2
assert repo.rows[12]["fail_reason"] == "未加齐:库存不足"
# 没配加车函数时放回系统管理员,status 仍是 1。
def test_known_line_without_adder_releases_for_retry():
repo = MemoryCartRepo([_row(fid=1, update_user_name=STAMP_CLAIMING)])
summary = add_known_lines(
repo,
[
_known(
1,
"S1",
platform_type=YAOEX,
supplier_name=GUANGDONG,
vendor_raw=GUANGDONG,
vendor_norm=GUANGDONG,
)
],
adders={},
login_errors={},
)
assert summary.added == 0
assert summary.failed == 1
assert repo.rows[1]["status"] == 1
assert repo.rows[1]["update_user_name"] == STAMP_SELECTION
assert "LOGIN_FAILED" in (repo.rows[1]["fail_reason"] or "")

View File

@ -0,0 +1,48 @@
from cart_match import CartOffer, match_offer
def _offer(**overrides) -> CartOffer:
data = dict(
sku_id="S1",
vendor_raw="广东壹号药业有限公司",
vendor_norm="广东壹号药业有限公司",
stock=20.0,
price=90.0,
)
data.update(overrides)
return CartOffer(**data)
def test_matches_supplier_name_exact():
picked = match_offer(
[_offer(vendor_raw="杭州小药药自营旗舰店", vendor_norm="杭州小药药自营旗舰店"), _offer()],
"广东壹号药业有限公司",
)
assert picked is not None
assert picked.sku_id == "S1"
assert picked.vendor_raw == "广东壹号药业有限公司"
def test_matches_supplier_name_stripping_self_operated_prefix():
picked = match_offer(
[_offer(vendor_raw="[自营] 广东壹号药业有限公司", vendor_norm="广东壹号药业有限公司")],
"广东壹号药业有限公司",
)
assert picked is not None
assert picked.vendor_norm == "广东壹号药业有限公司"
def test_same_supplier_keeps_cheaper():
picked = match_offer(
[
_offer(sku_id="DEAR", price=94.0, stock=100.0),
_offer(sku_id="CHEAP", price=90.0, stock=5.0),
],
"广东壹号药业有限公司",
)
assert picked is not None
assert picked.sku_id == "CHEAP"
def test_missing_supplier_returns_none():
assert match_offer([_offer()], "杭州小药药自营旗舰店") is None

View File

@ -0,0 +1,337 @@
# 加购仓库:领取戳、租约、假连接录 SQL。不连真库。
from __future__ import annotations
import ast
from contextlib import contextmanager
from datetime import datetime, timedelta
from pathlib import Path
import os
import pytest
from rpa_oracle import RepoUnavailable, load_job_env
from cart_repo import (
LEASE_MINUTES,
STAMP_CLAIMING,
STAMP_DONE,
STAMP_SELECTION,
MemoryCartRepo,
OracleCartRepo,
_pending_select_sql,
)
# 构造一条待加购表 2 行。
def _row(**overrides) -> dict:
data = {
"fid": 1,
"oms_id": 2026100201,
"platform_type": 66,
"supplier_name": "广东壹号药业有限公司",
"bar_code": "06901234567890",
"purchase_qty": 7,
"status": 1,
"fail_reason": None,
"push_status": 0,
"update_user_name": STAMP_SELECTION,
"operate_type": 1,
}
data.update(overrides)
return data
# 系统管理员成功行领走盖成加购中,已完成和失败行不领。
def test_claim_pending_stamps_processing_and_skips_done():
repo = MemoryCartRepo(
[
_row(),
_row(fid=2, oms_id=2026100202, update_user_name=STAMP_DONE),
_row(fid=3, oms_id=2026100203, status=2, update_user_name=STAMP_SELECTION),
_row(fid=4, oms_id=2026100204, operate_type=2),
]
)
claimed = repo.claim_pending()
assert [row.fid for row in claimed] == [1]
assert claimed[0].update_user_name == STAMP_CLAIMING
assert repo.rows[1]["update_user_name"] == STAMP_CLAIMING
assert repo.rows[2]["update_user_name"] == STAMP_DONE
assert repo.rows[3]["status"] == 2
# 戳加购中且 update_time 超过租约的行可以再领。
def test_claim_reclaims_expired_claiming_stamp():
now = datetime(2026, 10, 22, 12, 0, 0)
repo = MemoryCartRepo(
[_row(update_user_name=STAMP_CLAIMING, update_time=now - timedelta(minutes=31))]
)
claimed = repo.claim_pending(now=now)
assert [row.fid for row in claimed] == [1]
assert repo.rows[1]["update_user_name"] == STAMP_CLAIMING
# 戳加购中且未过租约的行不领。
def test_claim_skips_fresh_claiming_stamp():
now = datetime(2026, 10, 22, 12, 0, 0)
repo = MemoryCartRepo(
[_row(update_user_name=STAMP_CLAIMING, update_time=now - timedelta(minutes=1))]
)
claimed = repo.claim_pending(now=now)
assert claimed == []
assert repo.rows[1]["update_user_name"] == STAMP_CLAIMING
# 成功只盖加购班,不插行、不改 status。
def test_mark_success_does_not_insert_or_change_status():
repo = MemoryCartRepo([_row()])
repo.claim_pending()
repo.mark_success(1)
assert list(repo.rows) == [1]
assert repo.rows[1]["status"] == 1
assert repo.rows[1]["fail_reason"] is None
assert repo.rows[1]["update_user_name"] == STAMP_DONE
assert repo.rows[1]["push_status"] == 0
# 失败改 status=2 并写下原因。
def test_mark_fail_sets_status_and_reason():
repo = MemoryCartRepo([_row()])
repo.claim_pending()
repo.mark_fail(1, "库存不足")
assert list(repo.rows) == [1]
assert repo.rows[1]["status"] == 2
assert repo.rows[1]["fail_reason"] == "库存不足"
assert repo.rows[1]["update_user_name"] == STAMP_DONE
# 加购 .env 空缺时补选品和 oms .env,已有非空环境变量不覆盖。
def test_load_runtime_env_fills_blank_from_other_env(tmp_path, monkeypatch):
monkeypatch.delenv("RPA_ORACLE_HOST", raising=False)
monkeypatch.delenv("RPA_ORACLE_PASSWORD", raising=False)
monkeypatch.setenv("RPA_ORACLE_USER", "keep-me")
cart_env = tmp_path / "cart.env"
select_env = tmp_path / "select.env"
oms_env = tmp_path / "oms.env"
cart_env.write_text("RPA_ORACLE_HOST=192.168.12.244\nRPA_ORACLE_PASSWORD=\n", encoding="utf-8")
select_env.write_text("RPA_ORACLE_PASSWORD=from-select\nRPA_ORACLE_USER=from-select\n", encoding="utf-8")
oms_env.write_text("RPA_ORACLE_PASSWORD=from-oms\n", encoding="utf-8")
load_job_env(tmp_path, [cart_env, select_env, oms_env])
assert os.environ["RPA_ORACLE_HOST"] == "192.168.12.244"
assert os.environ["RPA_ORACLE_PASSWORD"] == "from-select"
assert os.environ["RPA_ORACLE_USER"] == "keep-me"
# 缺 HOST/用户/密码时 from_env 抛数据库不可用。
def test_from_env_without_user_raises(monkeypatch):
monkeypatch.delenv("RPA_ORACLE_USER", raising=False)
monkeypatch.delenv("RPA_ORACLE_PASSWORD", raising=False)
monkeypatch.delenv("RPA_ORACLE_HOST", raising=False)
with pytest.raises(RepoUnavailable):
OracleCartRepo.from_env()
# 录 SQL 的假游标,fetchall 返回预设行。
class _FakeCursor:
# 挂上共享的 executed 列表和预设查询结果。
def __init__(self, executed: list, rows: list | None = None, description=None):
self._executed = executed
self._rows = rows or []
self.description = description
self.rowcount = 1
# 进入 with,返回自身。
def __enter__(self):
return self
# 退出 with,不吞异常。
def __exit__(self, *args):
return False
# 记下 SQL 和 bind。一条 IN 超过 1000 个 fid 时抛错,模拟 ORA-01795。
def execute(self, sql, params=None):
params = params or {}
_reject_oversized_in(params)
self._executed.append((sql, params))
# 返回预设行的副本。
def fetchall(self):
return list(self._rows)
# 假连接:同一实例上的 cursor 共享 executed 列表。
class _FakeConn:
# 可选带上领取查询要返回的行。
def __init__(self, rows: list | None = None, description=None):
self.executed: list[tuple[str, dict]] = []
self.committed = False
self.commits = 0
self._rows = rows or []
self._description = description
# 新开一个录 SQL 的游标。
def cursor(self):
return _FakeCursor(self.executed, self._rows, self._description)
# 记下已提交。
def commit(self):
self.committed = True
self.commits += 1
# 本套用例不应回滚。
def rollback(self):
raise AssertionError("不应 rollback")
# 归还时由池调用,假连接不计数。
def close(self):
return None
# 只交出一条连接,并记下借了几次。
class _FakePool:
# 挂上要借出的那条假连接。
def __init__(self, conn):
self._conn = conn
self.acquires = 0
# 借出连接并给 acquires 加一。
@contextmanager
def acquire(self):
self.acquires += 1
yield self._conn
_CLAIM_DESCRIPTION = (
("FID",),
("OMS_ID",),
("PLATFORM_TYPE",),
("SUPPLIER_NAME",),
("BAR_CODE",),
("PURCHASE_QTY",),
("STATUS",),
("FAIL_REASON",),
("PUSH_STATUS",),
("UPDATE_USER_NAME",),
("OPERATE_TYPE",),
)
# 构造领取查询返回的一行元组。
def _fetched_row(fid: int = 11):
return (
fid,
2026100201,
66,
"广东壹号药业有限公司",
"0690",
7,
1,
None,
0,
STAMP_SELECTION,
1,
)
# 领取 SQL 带 FOR UPDATE SKIP LOCKED,过期加购中可再领。
def test_claim_sql_uses_skip_locked_and_lease():
sql, params = _pending_select_sql(limit=None, platform_types=None)
text = " ".join(sql.split()).lower()
assert "for update skip locked" in text
assert "numtodsinterval(:lease_minutes" in text
assert params["lease_minutes"] == LEASE_MINUTES
assert LEASE_MINUTES == 30
limited, limited_params = _pending_select_sql(limit=5, platform_types=None)
assert "skip locked" in " ".join(limited.split()).lower()
assert limited_params["limit"] == 5
# 领取一次借连接:SKIP LOCKED 选出后盖成加购中。
def test_oracle_claim_updates_stamp():
conn = _FakeConn(rows=[_fetched_row()], description=_CLAIM_DESCRIPTION)
pool = _FakePool(conn)
repo = OracleCartRepo(pool)
claimed = repo.claim_pending()
assert pool.acquires == 1
assert conn.committed is True
assert len(claimed) == 1
assert claimed[0].fid == 11
assert claimed[0].update_user_name == STAMP_CLAIMING
select_sql, select_binds = conn.executed[0]
assert "skip locked" in select_sql.lower()
assert select_binds["lease_minutes"] == 30
update_sql = " ".join(conn.executed[1][0].split()).lower()
assert "update_user_name" in update_sql
assert conn.executed[1][1]["stamp"] == STAMP_CLAIMING
assert 11 in conn.executed[1][1].values()
# 班次 session 内领取加两行盖章只从池里借一次连接。
def test_session_claim_and_marks_acquire_once():
conn = _FakeConn(
rows=[_fetched_row(11), _fetched_row(12)],
description=_CLAIM_DESCRIPTION,
)
pool = _FakePool(conn)
repo = OracleCartRepo(pool)
with repo.session():
claimed = repo.claim_pending()
assert [row.fid for row in claimed] == [11, 12]
repo.mark_success(11)
repo.mark_fail(12, "库存不足")
assert pool.acquires == 1
assert conn.commits >= 3
mark_sqls = [item[0].lower() for item in conn.executed[2:]]
assert any("fail_reason" in sql for sql in mark_sqls)
def _fid_bind_count(params: dict) -> int:
"""数出本段 bind 里的 fid 个数。"""
return sum(
1
for key in params
if isinstance(key, str) and key.startswith("id") and key[2:].isdigit()
)
def _reject_oversized_in(params: dict) -> None:
"""一条 IN 超过 1000 个 fid 时抛错,模拟 ORA-01795。"""
if _fid_bind_count(params) > 1000:
raise RuntimeError("ORA-01795")
def test_claim_1001_rows_updates_first_chunk_without_unavailable():
"""领取 1001 条时第一段 500 能领走,同一事务提交,不报数据库不可用。"""
conn = _FakeConn(
rows=[_fetched_row(fid) for fid in range(1, 1002)],
description=_CLAIM_DESCRIPTION,
)
pool = _FakePool(conn)
repo = OracleCartRepo(pool)
claimed = repo.claim_pending()
assert pool.acquires == 1
assert len(claimed) == 1001
assert claimed[0].update_user_name == STAMP_CLAIMING
updates = [
item for item in conn.executed if item[0].lstrip().lower().startswith("update")
]
assert [_fid_bind_count(params) for _, params in updates] == [500, 500, 1]
assert updates[0][1]["id0"] == 1
assert updates[0][1]["stamp"] == STAMP_CLAIMING
assert conn.committed is True
# 登录和搜索不再往 sys.path 贴选品。
def test_login_and_search_do_not_append_sys_path():
root = Path(__file__).resolve().parent.parent
for name in ("yaoex_login.py", "cart_search.py"):
tree = ast.parse((root / name).read_text(encoding="utf-8"))
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
if isinstance(func, ast.Attribute) and func.attr in {"append", "insert"}:
value = func.value
if isinstance(value, ast.Attribute) and value.attr == "path":
raise AssertionError(f"{name} 仍在改 sys.path")
if isinstance(value, ast.Name) and value.id == "path":
raise AssertionError(f"{name} 仍在改 path")

View File

@ -0,0 +1,61 @@
from decimal import Decimal
from job import run_yaoex_pay
from settle import MallLine
class MemoryPayRepo:
def __init__(self, existing=None):
self.existing = existing or set()
self.saved: list[dict] = []
def load_existing_pay_order_nos(self):
return set(self.existing)
def save_pay_rows(self, rows):
self.saved.extend(rows)
def _line(**overrides) -> MallLine:
data = dict(
order_no="ZQD1",
parent_order_no="",
supplier_name="广东壹号药业有限公司",
status="待收货",
goods_name="阿莫西林",
product_code="2918",
bar_code="6921665004933",
unit_price=Decimal("10.00"),
pay_price=Decimal("9.85"),
qty=10,
goods_amount=Decimal("100.00"),
order_paid=Decimal("98.50"),
)
data.update(overrides)
return MallLine(**data)
def test_job_inserts_generated_dtl_without_details():
repo = MemoryPayRepo()
summary = run_yaoex_pay(repo, [_line()])
assert summary.inserted == 1
assert summary.unmatched == 0
assert repo.saved[0]["oms_id"] == 0
assert repo.saved[0]["pay_order_dtl_id"]
assert len(repo.saved[0]["pay_order_dtl_id"]) <= 40
assert repo.saved[0]["purchase_total"] == Decimal("98.5000")
def test_job_dry_run_does_not_insert():
repo = MemoryPayRepo()
summary = run_yaoex_pay(repo, [_line()], dry_run=True)
assert summary.inserted == 1
assert repo.saved == []
def test_job_skips_existing_order_no():
repo = MemoryPayRepo(existing={"ZQD1"})
summary = run_yaoex_pay(repo, [_line()])
assert summary.inserted == 0
assert summary.skipped_dup == 1
assert repo.saved == []

103
rpa_cart/tests/test_main.py Normal file
View File

@ -0,0 +1,103 @@
from datetime import datetime
from types import SimpleNamespace
import pytest
import cart_job
import main
from cart_repo import STAMP_SELECTION, MemoryCartRepo
from main import DEFAULT_CART_LIMIT, parse_args, pay_export_path
def test_parse_args_defaults():
args = parse_args([])
assert args.command == "pay"
def test_parse_args_pay_rejects_mode_switches():
with pytest.raises(SystemExit):
parse_args(["--xls", "/tmp/orders.xls", "--dry-run"])
def test_pay_export_path_groups_by_order_day(tmp_path):
when = datetime(2026, 10, 24, 15, 30, 45)
path = pay_export_path(
"2026-10-24 00:00:00",
"2026-10-24 23:59:59",
when,
root=tmp_path,
)
assert path == tmp_path / "2026-10-24" / "153045.xls"
def test_pay_export_path_keeps_same_second_export(tmp_path):
when = datetime(2026, 10, 24, 15, 30, 45)
first = pay_export_path(
"2026-10-24 00:00:00",
"2026-10-24 23:59:59",
when,
root=tmp_path,
)
first.parent.mkdir(parents=True)
first.write_bytes(b"a")
second = pay_export_path(
"2026-10-24 00:00:00",
"2026-10-24 23:59:59",
when,
root=tmp_path,
)
assert second == tmp_path / "2026-10-24" / "153045_2.xls"
def test_parse_args_cart_subcommand():
"""显式 --limit 仍覆盖默认 20。"""
args = parse_args(["cart", "--limit", "5", "--platform", "yaoex"])
assert args.command == "cart"
assert args.limit == 5
assert args.platform == "yaoex"
def test_cart_default_limit_caps_claim(monkeypatch):
"""不传 --limit 时加购最多领 20 行。"""
import main
rows = [
{
"fid": i,
"oms_id": 2026100200 + i,
"platform_type": 66,
"supplier_name": "广东壹号药业有限公司",
"bar_code": "06901234567890",
"purchase_qty": 1,
"status": 1,
"fail_reason": None,
"push_status": 0,
"update_user_name": STAMP_SELECTION,
"operate_type": 1,
}
for i in range(1, 31)
]
repo = MemoryCartRepo(rows)
seen: dict[str, int | None] = {}
def fake_run_cart_batch(bound_repo, **kwargs):
"""只按入口传入的上限领取,不加车。"""
seen["limit"] = kwargs.get("limit")
claimed = bound_repo.claim_pending(
limit=kwargs.get("limit"),
platform_types=kwargs.get("platform_types"),
)
seen["count"] = len(claimed)
return SimpleNamespace(claimed=len(claimed), added=0, failed=0)
def no_browser(**_kwargs):
"""挡住商城登录。"""
raise RuntimeError("no browser")
monkeypatch.setattr(cart_job, "run_cart_batch", fake_run_cart_batch)
monkeypatch.setattr(main, "resolve_yaoex_auth", no_browser)
monkeypatch.setattr(main, "resolve_ybm_cookies", no_browser)
main.main(["cart"], repo=repo)
assert seen["limit"] == DEFAULT_CART_LIMIT
assert seen["count"] == 20
assert seen["count"] <= DEFAULT_CART_LIMIT

View File

@ -0,0 +1,104 @@
from decimal import Decimal
from pathlib import Path
import xlwt
from parse_xls import parse_order_xls
def test_parse_order_xls_reads_settlement_columns(tmp_path: Path):
path = tmp_path / "orders.xls"
book = xlwt.Workbook()
sheet = book.add_sheet("订单明细")
headers = [
"下单时间",
"创建人",
"订单号",
"父订单号",
"支付方式",
"商家",
"订单类型",
"订单状态",
"品名",
"产品编码",
"产品条码",
"医保编码",
"批准文号",
"规格",
"厂商",
"有效期",
"生产日期",
"批号",
"单价(元)",
"数量",
"商品金额(元)",
"折后单价",
"订单实付总金额",
"可获返利金(元)",
"收货地址",
"商品类目",
"上市持有人",
"药检链接",
]
for col, name in enumerate(headers):
sheet.write(0, col, name)
sheet.write(1, 2, "ZQD202609301753126875")
sheet.write(1, 5, "重庆亿昊药业有限公司")
sheet.write(1, 7, "待收货")
sheet.write(1, 8, "复方阿嗪米特肠溶片")
sheet.write(1, 9, "2918225766")
sheet.write(1, 10, "6922197100247")
sheet.write(1, 18, 65.49)
sheet.write(1, 19, 20)
sheet.write(1, 20, 1309.8)
sheet.write(1, 21, 65.49)
sheet.write(1, 22, 1309.8)
book.save(str(path))
lines = parse_order_xls(path)
assert len(lines) == 1
line = lines[0]
assert line.order_no == "ZQD202609301753126875"
assert line.supplier_name == "重庆亿昊药业有限公司"
assert line.status == "待收货"
assert line.bar_code == "6922197100247"
assert line.qty == 20
assert line.pay_price == Decimal("65.49")
assert line.unit_price == Decimal("65.49")
def test_parse_order_xls_takes_first_barcode_when_cell_has_many(tmp_path: Path):
path = tmp_path / "orders.xls"
book = xlwt.Workbook()
sheet = book.add_sheet("订单明细")
headers = [
"订单号",
"父订单号",
"商家",
"订单状态",
"品名",
"产品编码",
"产品条码",
"单价(元)",
"数量",
"商品金额(元)",
"折后单价",
"订单实付总金额",
]
for col, name in enumerate(headers):
sheet.write(0, col, name)
sheet.write(1, 0, "ZQD1")
sheet.write(1, 2, "广东壹号药业有限公司")
sheet.write(1, 3, "待收货")
sheet.write(1, 4, "参芪五味子片")
sheet.write(1, 5, "8353YCZ12965")
sheet.write(1, 6, "6930301630566,1601063192,6930301630627,6927245800070")
sheet.write(1, 7, 10)
sheet.write(1, 8, 1)
sheet.write(1, 9, 10)
sheet.write(1, 10, 10)
sheet.write(1, 11, 10)
book.save(str(path))
lines = parse_order_xls(path)
assert lines[0].bar_code == "6930301630566"

130
rpa_cart/tests/test_repo.py Normal file
View File

@ -0,0 +1,130 @@
# 支付落表仓库:假连接录 SQL。不连真库。
from __future__ import annotations
from contextlib import contextmanager
from repo import OraclePayRepo
# 只交出一条连接。
class _FakePool:
# 挂上要借出的那条假连接。
def __init__(self, conn):
self._conn = conn
# 借出连接。
@contextmanager
def acquire(self):
yield self._conn
# 两行支付一次 executemany,带 operate_type=2 和订单号。
def test_save_pay_rows_inserts_operate_type_2():
many: list[tuple[str, list]] = []
# 录 SQL 的假游标。
class FakeCursor:
# 进入 with,返回自身。
def __enter__(self):
return self
# 退出 with,不吞异常。
def __exit__(self, *args):
return False
# 记下一次批量 SQL 和全部 bind。
def executemany(self, sql, params):
many.append((sql, [dict(item) for item in params]))
# 提交后记下 committed。
class FakeConn:
committed = False
# 新开录 SQL 的游标。
def cursor(self):
return FakeCursor()
# 记下已提交。
def commit(self):
self.committed = True
# 本套用例不应回滚。
def rollback(self):
raise AssertionError("不应 rollback")
# 假连接不计数。
def close(self):
return None
conn = FakeConn()
repo = OraclePayRepo(_FakePool(conn))
row = {
"operate_type": 2,
"oms_id": 0,
"platform_type": 66,
"supplier_name": "广东壹号药业有限公司",
"bar_code": "6921",
"purchase_price": 9.85,
"purchase_qty": 10,
"purchase_total": 98.5,
"status": None,
"fail_reason": None,
"pay_order_no": "ZQD1",
"pay_order_dtl_id": "a1b2c3d4e5f6789012345678901234ab",
"push_status": 0,
"org_code": None,
"multi_platform_flag": 0,
}
second = dict(row)
second["pay_order_no"] = "ZQD2"
second["pay_order_dtl_id"] = "b1b2c3d4e5f6789012345678901234ab"
repo.save_pay_rows([row, second])
assert conn.committed is True
assert len(many) == 1
sql, binds = many[0]
assert "rpa_cart_settlement_goods_data" in sql.lower()
assert [item["operate_type"] for item in binds] == [2, 2]
assert [item["oms_id"] for item in binds] == [0, 0]
assert [item["pay_order_no"] for item in binds] == ["ZQD1", "ZQD2"]
assert [item["pay_order_dtl_id"] for item in binds] == [
"a1b2c3d4e5f6789012345678901234ab",
"b1b2c3d4e5f6789012345678901234ab",
]
assert [item["create_user_name"] for item in binds] == ["rpa-cart", "rpa-cart"]
# 已有支付单号去重时丢掉空值。
def test_load_existing_pay_order_nos():
# 返回预设支付单号。
class FakeCursor:
# 进入 with,返回自身。
def __enter__(self):
return self
# 退出 with,不吞异常。
def __exit__(self, *args):
return False
# 记下 SQL 和 bind。
def execute(self, sql, params=None):
self.sql = sql
self.params = params
# 返回含重复和空值的单号。
def fetchall(self):
return [("ZQD1",), ("ZQD1",), (None,)]
# 只提供游标。
class FakeConn:
# 新开返回预设单号的游标。
def cursor(self):
return FakeCursor()
# 假连接不计数。
def close(self):
return None
repo = OraclePayRepo(_FakePool(FakeConn()))
keys = repo.load_existing_pay_order_nos()
assert keys == {"ZQD1"}

View File

@ -0,0 +1,126 @@
from decimal import Decimal
from settle import (
PAID_STATUSES,
MallLine,
build_pay_row,
match_pay_lines,
money4,
)
def _line(**overrides) -> MallLine:
data = dict(
order_no="ZQD1",
parent_order_no="",
supplier_name="广东壹号药业有限公司",
status="待收货",
goods_name="阿莫西林",
product_code="2918",
bar_code="6921665004933",
unit_price=Decimal("10.00"),
pay_price=Decimal("9.85"),
qty=200,
goods_amount=Decimal("2000.00"),
order_paid=Decimal("1970.00"),
)
data.update(overrides)
return MallLine(**data)
def test_money4_rounds_half_up():
assert money4("65.49") == Decimal("65.4900")
assert money4(Decimal("9.85") * 200) == Decimal("1970.0000")
def test_drops_cancelled_and_keeps_paid_statuses():
lines = [
_line(status="已取消", order_no="C1"),
_line(status="待发货", order_no="P1"),
_line(status="待收货", order_no="P2"),
_line(status="已完成", order_no="P3"),
_line(status="退款/退换货", order_no="R1"),
]
result = match_pay_lines(lines, existing_order_nos=set())
kept = {row["pay_order_no"] for row in result.rows}
assert kept == {"P1", "P2", "P3"}
assert {line.order_no for line in result.skipped_status} == {"C1", "R1"}
assert PAID_STATUSES == {"待发货", "待收货", "已完成"}
def test_purchase_total_uses_discounted_price_times_qty():
result = match_pay_lines([_line()], existing_order_nos=set())
assert len(result.rows) == 1
row = result.rows[0]
assert row["purchase_price"] == Decimal("9.8500")
assert row["purchase_qty"] == 200
assert row["purchase_total"] == Decimal("1970.0000")
assert row["operate_type"] == 2
assert row["platform_type"] == 66
assert row["status"] is None
assert row["pay_order_no"] == "ZQD1"
assert row["pay_order_dtl_id"]
assert len(row["pay_order_dtl_id"]) <= 40
assert row["oms_id"] == 0
assert row["org_code"] is None
assert row["multi_platform_flag"] == 0
def test_inserts_excel_line_without_cart_match():
result = match_pay_lines([_line(bar_code="999")], existing_order_nos=set())
assert len(result.rows) == 1
assert result.rows[0]["bar_code"] == "999"
assert result.rows[0]["oms_id"] == 0
assert result.unmatched == []
def test_same_order_two_lines_both_insert_with_distinct_dtl_ids():
lines = [
_line(order_no="A", qty=10, pay_price=Decimal("53.40")),
_line(order_no="A", qty=50, pay_price=Decimal("55.21")),
]
result = match_pay_lines(lines, existing_order_nos=set())
assert len(result.rows) == 2
assert [row["pay_order_no"] for row in result.rows] == ["A", "A"]
ids = [row["pay_order_dtl_id"] for row in result.rows]
assert all(ids)
assert ids[0] != ids[1]
assert all(row["oms_id"] == 0 for row in result.rows)
def test_skips_existing_order_no():
result = match_pay_lines([_line()], existing_order_nos={"ZQD1"})
assert result.rows == []
assert len(result.skipped_dup) == 1
def test_existing_order_no_skips_all_its_lines_other_orders_insert():
lines = [
_line(order_no="OLD", qty=10),
_line(order_no="OLD", qty=20),
_line(order_no="NEW", qty=5),
]
result = match_pay_lines(lines, existing_order_nos={"OLD"})
assert [row["pay_order_no"] for row in result.rows] == ["NEW"]
assert [line.order_no for line in result.skipped_dup] == ["OLD", "OLD"]
def test_unmatched_barcode_over_40_is_not_inserted():
result = match_pay_lines(
[_line(bar_code="1" * 41)],
existing_order_nos=set(),
)
assert result.rows == []
assert result.unmatched[0][1] == "BAR_CODE_TOO_LONG"
def test_build_pay_row_from_excel_fields():
row = build_pay_row(_line())
assert row["oms_id"] == 0
assert row["org_code"] is None
assert row["multi_platform_flag"] == 0
assert row["supplier_name"] == "广东壹号药业有限公司"
assert row["bar_code"] == "6921665004933"
assert row["push_status"] == 0
assert row["pay_order_dtl_id"]
assert len(row["pay_order_dtl_id"]) <= 40

View File

@ -0,0 +1,48 @@
from datetime import date
from yaoex_client import day_range, export_orders
class FakeResponse:
def __init__(self, content=b"", json_data=None, content_type="application/vnd.ms-excel"):
self.content = content
self._json = json_data
self.headers = {"content-type": content_type}
self.text = content.decode("utf-8", errors="replace")
def raise_for_status(self):
return None
def json(self):
return self._json
class FakeSession:
def __init__(self, response: FakeResponse):
self.response = response
self.calls: list[tuple[str, dict]] = []
def post(self, url, data=None, headers=None, timeout=None, verify=None):
self.calls.append((url, data or {}))
return self.response
def close(self):
return None
def test_day_range_today_is_single_day():
begin, end = day_range(date(2026, 10, 3), 0)
assert begin == "2026-10-03 00:00:00"
assert end == "2026-10-03 23:59:59"
def test_export_orders_posts_json_params():
ole = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" + b"\x00" * 20
session = FakeSession(FakeResponse(content=ole))
data = export_orders("tok", begin="2026-10-03 00:00:00", end="2026-10-03 23:59:59", session=session)
assert data.startswith(b"\xd0\xcf")
url, payload = session.calls[0]
assert url.endswith("/api/order/export")
assert payload["token"] == "tok"
assert "ZQD" not in payload["jsonParams"]
assert "2026-10-03 00:00:00" in payload["jsonParams"]

628
rpa_cart/uv.lock Normal file
View File

@ -0,0 +1,628 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[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/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
{ 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/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
{ 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/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
{ 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/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
{ 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/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
{ 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/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
{ 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 = "cryptography"
version = "50.0.2"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731, upload-time = "2026-09-30T14:44:13.485Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676, upload-time = "2026-09-30T14:44:21.744Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085, upload-time = "2026-09-30T14:44:30.704Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868, upload-time = "2026-09-30T14:44:39.71Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" },
]
[[package]]
name = "greenlet"
version = "3.5.6"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3e/6e/0091f175ccd02b02bc8811bbcbcc6ac2e980be116e3b2f7a736ca322bf84/greenlet-3.5.6.tar.gz", hash = "sha256:8e67c43bdfc88d5fee6db0d3e40175b362fc95fb85f0412d233b9b203c53a575", size = 207653, upload-time = "2026-09-14T15:42:51.806Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/72/18/3fc6d951466ae9a2a688edcddde3b2e388da0a8244e0caf7117bbeb0eb95/greenlet-3.5.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:a5876d0a60355af98d535c47f6cd6eb0f8a432396dab26845d380b92f8412422", size = 295668, upload-time = "2026-09-14T14:22:33.241Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/27/89/366d2af5061eeefa5012f510d95a99c8620dcc457609838db4d538820318/greenlet-3.5.6-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e85880b538e59a59f55117b81f208a6660ad5ac328aad9305f812d9b8bc67a0f", size = 611700, upload-time = "2026-09-14T15:12:01.962Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/54/1c/07f133f865fd58ae593dd2bbec3144acaee9b04ffe2eb48c6e121747ceef/greenlet-3.5.6-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f0ba7c2a329d650628f4c8572fd1db29f0a59dd70a3e3e0710dcf18a35cce9d8", size = 624223, upload-time = "2026-09-14T15:20:42.459Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a7/f2/844dc823ff2752ad049caa6b59d57e4572f9c445934b02d3518f4c67197c/greenlet-3.5.6-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee7d9da3bf493909cf811a3f038840cb34fab5ae2956b8a263919f6e289ab188", size = 629529, upload-time = "2026-09-14T15:25:06.354Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/66/6a/1594f3869c57c149abdb380492529e04d4c0229b5e4d79572c5bd0aaa673/greenlet-3.5.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:975736b002ed080d124cf81a79cb7e05cb26d6b3f5c7a7b651c0fcce70353aa1", size = 621404, upload-time = "2026-09-14T14:35:59.027Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c0/42/b1f8dbc89a53b9e77859fc1ad1627d106fc361daa3ea4bdf43a91ebb4338/greenlet-3.5.6-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:71890d5247020c25c21a6b65202782bfc281d4e6e244842419d30e3492bb6dcc", size = 432385, upload-time = "2026-09-14T15:28:37.369Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a2/f5/33e5c9e48178b9259fd000f8f45caa4a65036f65d3d0c06a602f570f025d/greenlet-3.5.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0616b8f878098c5681fd8f0dc92d887551717402342a70f0abcbfea5f5ad8a44", size = 1584998, upload-time = "2026-09-14T15:10:06.653Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ef/31/9b4e140bc24d0ad7927ebd651f5608b0acc2334d061748c3b6ad19085cfa/greenlet-3.5.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3dbb4596a6a4e5d47121a33ff20533a81e60f302d9e67b69909a8bc21a43f0a7", size = 1647568, upload-time = "2026-09-14T14:35:49.787Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c3/71/d79f1791f824f8ff15c2978746640467ae932a2365e0201069f7f272395f/greenlet-3.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:7ac4abb3877c43af320392c664774eef6fa2cc063c79a55fc02d844a3cbe7395", size = 324203, upload-time = "2026-09-14T14:22:54.504Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/63/af/42aca4d56e8cb321912203069d8d34734cb288222f10ad2ae102718cc577/greenlet-3.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:301102a49120b095e72a7838792b41233975fc1c155daec6d98f81c00c9280e0", size = 308310, upload-time = "2026-09-14T14:24:03.008Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f1/a1/e720a38852366c589e1a46cf570b886507ad2cf591050c203365638baab0/greenlet-3.5.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f96f0e30b5a95c7631b12bfe214cbc90ec8fe8cfa36920596c10514a65743519", size = 294627, upload-time = "2026-09-14T14:24:40.102Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/eb/c3/58187858df41354a11e6a55b421e7af9059798abdab3a384cc51b8567c38/greenlet-3.5.6-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c75116c9de79949de23006e2d9b35ee82874c594fcf5c0311b439acaa14b8441", size = 614356, upload-time = "2026-09-14T15:12:03.399Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ce/b9/3a7e67d5f05c9760b1ad411fa52264bd69cc08e22a2ebfb4018b90628ced/greenlet-3.5.6-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cad5782f93f7f738b62c6527b6f32a60694d924029f299a8b524758cfa53d815", size = 626756, upload-time = "2026-09-14T15:20:44.269Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c6/7c/40400455f5b5a65bb83e94fde66d1be9e5ec518638113f8083ace746c309/greenlet-3.5.6-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a93ee7c6e8fd0f8a83525a51bd777be57ee17787e91d805bd8d6faf9dcada18e", size = 632632, upload-time = "2026-09-14T15:25:07.813Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/85/cb/ab0c123c514ed4e94c0dc9ee2e86362633e6b998cfc05de7fc9ac2eb9690/greenlet-3.5.6-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f98e8215e172f567ce80eeaed9107fb4d32b6c44f26983d9b8334658136a205a", size = 623779, upload-time = "2026-09-14T14:36:01.104Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f9/67/1f35cff30a6c51c3f23b63d4afcc7313ab4f97490ba3676fa78178984b27/greenlet-3.5.6-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7f731ebac68ea06d628658295cb2d217b10186329fcf9a3b6a149045059bf92e", size = 434933, upload-time = "2026-09-14T15:28:38.858Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a5/26/fda8a5a06e7073333ccb038133c5893b9e0c4fe29d5992a17e83c241bc6e/greenlet-3.5.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df19e2d0b1620039af5102563fbd96e8938c7f5c3f5828528d641d9fc585525e", size = 1584930, upload-time = "2026-09-14T15:10:08.234Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/2f/37/50f8813163148d6234e08b23dcad6a9e37f01d148c8ec976e4c44ea2d918/greenlet-3.5.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06c0e933290fba8ffe53ead4ae1b8044b0e9754b75cebf381aa2bc3e50d82fac", size = 1647590, upload-time = "2026-09-14T14:35:51.173Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/86/da/b7669b09586365654083a62bd0724cf06cb74bd5085a15cdd161271f992f/greenlet-3.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:5b602b4201b965a8354d74e232364a66ff243dd142e350d035f46169bb36e13d", size = 324086, upload-time = "2026-09-14T14:23:48.428Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e5/5d/c9663cfe84a2a9e0aa96f066f5b0594c227ea4c647511e087e2e11d4ac0a/greenlet-3.5.6-cp313-cp313-win_arm64.whl", hash = "sha256:876077e7ebb8c84ed068e2b23d4c62ebb010d60df84b9591af1be2f39010ffb2", size = 308211, upload-time = "2026-09-14T14:28:01.634Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/66/c0/d254544ae2b8bdd311aef000fafc02828c2771b17d994b3075620ea7cc6e/greenlet-3.5.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cddea1b8339451c2fb3388e138347b6126744f33b611bdb55b7357361cfef46", size = 295221, upload-time = "2026-09-14T14:25:11.583Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/18/eb54be16b9cc3971e09ca5b73334e1b8c804a4630d9addaaf218a4fe300f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c59acfa8eb73a1e0d484392dc002bdf001fd4ce73394e0132df3d1ab6093d7cb", size = 660992, upload-time = "2026-09-14T15:12:04.876Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8f/b4/e193efe65671dcf294bc51fcc59efb52d154adf8612c4ea016da0d2c486c/greenlet-3.5.6-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3b4a01c6da07ef9f80d4fe8933b994bc99747bcea3eab0330a9c34d3c12655b", size = 673428, upload-time = "2026-09-14T15:20:45.756Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/fd/21/631bb45fafde1dca782152377c0676d182ec924820064047f533a3627b28/greenlet-3.5.6-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dd0b83bed3405b586a3133629f1d1a5bc7bfd64822a3b7ab342bdc68e6dbc61b", size = 677688, upload-time = "2026-09-14T15:25:09.279Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/45/ac/28fa7a9e50f2859466214c4ac584d776db52c1604ad4dd158960a5af2a1f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a09d59bef1db94f384b5bcc2d523694d338f3df6b757aeeaf7baca5d0c0be88", size = 670773, upload-time = "2026-09-14T14:36:02.577Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/40/30/2b0a73e68e1e18e30b601d0d183cfdfc2beca4de5a6843c630f0fc9fb90c/greenlet-3.5.6-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:fdacf26402389bdd89857ad3c045a26fe8f3314f9a8b28226f82f88463a65b77", size = 480475, upload-time = "2026-09-14T15:28:40.741Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c3/cd/fb7d6cdd86ff3427c1494854f0e35437eba05142be91f530f6da75e09e19/greenlet-3.5.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8b7c73d1cef3d9ae963e9ff03f6222df43efbb9054ffd2f1969c935b7fc84c02", size = 1631900, upload-time = "2026-09-14T15:10:09.745Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/40/143bdbb20a516628cb15074ae52ed17d850b450292609c7a6fccac6dbece/greenlet-3.5.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8b27df301f56e3b3d2298095c8f7d6b68f2521f6b1693e901fa039bdbae34424", size = 1693740, upload-time = "2026-09-14T14:35:52.959Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c9/9e/019642432e6ae283301df1361227d47610709d2dc69a38f95edef266d713/greenlet-3.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:f8f0bd690e1a41294ac87905e8121c81a3761ec2583c768f13467428606c8c7a", size = 327473, upload-time = "2026-09-14T14:28:12.948Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e9/7f/8aafc7bf70c948786dba7221d0dc0838e5329bebc6d434ef2208b4f0e760/greenlet-3.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:8cda13494d86a4f12429641117cb6ac4bbbc9c30a33f711f7d3a2e5fbe4b0b7e", size = 311095, upload-time = "2026-09-14T14:28:00.7Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/14/7e/7a205688a5b3074933b18a906608d46d106e9a79d776bdab5a4abf4b4feb/greenlet-3.5.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:97c5a53e8c1754df58e73f047a99e287d4da1bdfe64b0072fb25c87000897951", size = 305352, upload-time = "2026-09-14T14:21:31.962Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/78/cb/9c4a57a9d9dd0256e20b8f7f4f06554c2c92badebf0ab73ce344321b78b9/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fea4427d1ffdb3b523d7daa6712038428a4c16c450b9777bdd1221cfee0eab49", size = 672671, upload-time = "2026-09-14T15:12:06.347Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/97/52/c6729681ebbd298f4decd28746815acc8a0b0a0fde21d2df33776fd4d042/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73a29b5ba642e35433166a03a3e02935e7238c4b3467fbd77523b99edea23e5b", size = 679489, upload-time = "2026-09-14T15:20:47.291Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/71/76/3c11c21e0716b1f1dc7c1a4b3d690abb1d3b448c69a9d32049fecb64010a/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:61a61b4a95a4f97922c3a6f5606d3e360851584bd47e500a5161373c53810e3d", size = 681303, upload-time = "2026-09-14T15:25:11.088Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/58/c5/2b6c721ba8b8963da42d5a0f57f25b8aaeb1fe9bdd156875e57f3be648a2/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:460e70b033aba8ed47e2ac9b5d0d2157b05a34fbfa30a241400aef4118902cdc", size = 676608, upload-time = "2026-09-14T14:36:03.959Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3f/26/3ae402202452cd5941bbbd483e5a74297e2397e7aa3182c2a5e3ab7d5666/greenlet-3.5.6-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:fe3170a69fe039b18ad18171e66faa9a75f6fe9d78f968fd9b54e09fbd714d81", size = 510112, upload-time = "2026-09-14T15:28:42.112Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b2/04/0d018e0d05bcdde19a0fcb907834155f1fc853a9bedd3f3f5e6acadcae19/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca80a49b53ed1d22f7282da7255f7bb2fd1935fd0f623d8613fda38745f18961", size = 1641479, upload-time = "2026-09-14T15:10:11.216Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/59/bb/f02ef9073919158f6403fe3701d4ed4403d646720e7201dfc6e9d264bac3/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:916f92f2a8db10508f739d0b5e00b83defe5d1115a997c54532a6d7cf8c95404", size = 1698758, upload-time = "2026-09-14T14:35:54.336Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/08/a5/1f48fe647473a2dcccfd1839b2ff2c78eb57009be776b4da071e901c9bff/greenlet-3.5.6-cp314-cp314t-win_amd64.whl", hash = "sha256:886bcf1870af74c32bc310fd00a6b803445e17e51b7d5a107c7b35c0f362cc16", size = 331574, upload-time = "2026-09-14T14:27:18.451Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/cd/72/3882855a75838faeb54a58aeef4fd77d20b2a86d4bad570c70d41b565dcf/greenlet-3.5.6-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3ac3494c381dab876cad7d0b22f3a722f3e0c8deb3a65b9e7f35ad7f58b8fcb3", size = 295926, upload-time = "2026-09-14T14:27:21.16Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/10/1f/be4d957d8a9b90bcbe8db206548a42134d96222d43e5ed3fc4708fb6e24b/greenlet-3.5.6-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:602024dae6d77e161f4b89491b62ca1d4f19949d79d47b2db057e476d21179d6", size = 666910, upload-time = "2026-09-14T15:12:07.901Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a1/af/60d62571a7d6de961e4ce7625d6c2faf359345659fc782d2cdf517c34577/greenlet-3.5.6-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8e63209c3e1e828ee6a457529b4a6d8b05d050fe0ae03a7ae49e967c5d312e0", size = 677415, upload-time = "2026-09-14T15:20:48.817Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f5/41/b3114c97c10e796010f00a30f51c81470072bca4b53e396ccca87484fcf7/greenlet-3.5.6-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9133d68624b1f2e89ec2f554d56aea8a5b0d7168cd9320200ba58d4d794845a4", size = 681337, upload-time = "2026-09-14T15:25:12.812Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/fb/16/ac9e547b611539aaed1870eb1d6ddc57abdd5924b3a99bb9b5f0b44176b8/greenlet-3.5.6-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccadce0130fd813ec86ebfe969a6c58b42acc1d0fe55a47525375b740e07b605", size = 675927, upload-time = "2026-09-14T14:36:05.34Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/48/1b/d41861c2fa00968e39e467a495ca8db9ce9b6310a5d9b57561b3d0dc48fa/greenlet-3.5.6-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:5adcbbfe78bdc242c71740a02e0991cc1b2f34d33c8bb15ca45eee8fd1140942", size = 487339, upload-time = "2026-09-14T15:28:43.497Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/b1/b7ba08d6431121741f1d30be0d5d292e76873325179a63586cd9217b62f6/greenlet-3.5.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9297fb9c39b9a2c039dbcd306c410bd6906b95244dec3bba4318d36c718c164c", size = 1637236, upload-time = "2026-09-14T15:10:12.442Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/af/c5/3b1cbc68f0c082022fc8717f7fe4b8b13b8d583c52352be37f4e9f55bcd2/greenlet-3.5.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b374e79ffa7511afc11773aef40a4ccea6191fba1c856ea2f9c56738dca69d7a", size = 1698526, upload-time = "2026-09-14T14:35:56.039Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/de/56/12941ed2711400451c89d544e10f831800a2770f19dd55eac8f0f7f2003b/greenlet-3.5.6-cp315-cp315-win_amd64.whl", hash = "sha256:7969bffa322c097bd46ae595ada6a931cefda613f18ba64587e9cff4cb320756", size = 327739, upload-time = "2026-09-14T14:23:55.768Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c5/3b/576b9ed5ac929252e340cf60b4bcb6a8515350dc20797064b1922dc4ea75/greenlet-3.5.6-cp315-cp315-win_arm64.whl", hash = "sha256:8dba0129b93e7091dfefaf4cf7000172741bff7f47bf6326fcf17f32fbb54d6b", size = 311715, upload-time = "2026-09-14T14:28:25.154Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/16/c2/86cfc5555a98e12b86966ddbd24fd39af32f71f2f785c6595b7feb2db156/greenlet-3.5.6-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:de3de000d459402cda015068fd135aa50c0bf6f2477a80d4da1e646f123b4e78", size = 306244, upload-time = "2026-09-14T14:27:57.565Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/14/6d/83ffc9d05a75a80ab3a7595dbb1d9604e5d4fc2996d73a8ae2dbd1284900/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45663c01a4de48b9a64a2ee1509d92d1dfd3afb02b2ccfc9333029d11aef996a", size = 676578, upload-time = "2026-09-14T15:12:09.468Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/5d/d6/c2cf684810e5caded075970aaadea654ecb58b8382b9aecf1d231b936894/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3deccbb57a481e3a408fe61cdfd5c13e0678fc0a30fdd09597917ca87b4be877", size = 684203, upload-time = "2026-09-14T15:20:50.261Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f2/d1/039c353d5593a97a89699e989324c9bc86af499e6c6152fe0180f5742204/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:63aff70fe5aac59c72215f42ec39fcb59ff46774fa966e717f8ecb6ee2273577", size = 686023, upload-time = "2026-09-14T15:25:14.528Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/62/19/00e1bee5d2af890dc8f400b54d0b0f9b489965f92bc12b407ff72cc6f469/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311018b46472fb26ee85870847fb89eb64cc8aaddb617400789d87076f7cfeec", size = 681253, upload-time = "2026-09-14T14:36:06.742Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/62/97ceb8e0b2ea96046cdf8e95b042715020ebb12d83ea0690db80a8f03d23/greenlet-3.5.6-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:520648db8fb92eef7b3e6013f5a6f901cdf0d6685f639c2f7a245879f865bef7", size = 517058, upload-time = "2026-09-14T15:28:44.924Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/89/58/c9275fd0ca195d1d3402931bcce8cfcc74726ff76efb1883d229e6e1a3d7/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7f924a5a9d5890649566f2f6682e0d8ad8ca23028bacffbbac36dbd7fd680176", size = 1645988, upload-time = "2026-09-14T15:10:13.758Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e0/36/b35747582fa4f1a5453f8f3002405dbac788e450cec7674dc2d204b6ccb5/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:de9923832f2d8c1a5ecd8d7260465a6ca5a86888a0d129e3bd5cf0406d2fc5bf", size = 1702383, upload-time = "2026-09-14T14:35:58.143Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ed/69/6ec22ac9351e474d2a134d0ff9400dc80362d1c20f0721088ffffdfc205b/greenlet-3.5.6-cp315-cp315t-win_amd64.whl", hash = "sha256:2ab5f42ac6c238eb71770715e6e909ad9a1a92b6c681ccb64cd5a0f07edb953f", size = 331845, upload-time = "2026-09-14T14:27:41.723Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/30/cf/697c051fd534e223461fb8b523890e21a24eeca229cd50624cff6f02fabd/greenlet-3.5.6-cp315-cp315t-win_arm64.whl", hash = "sha256:f9fe868463ec7e1363733af77e38a5fda3e9b63940337048c945d69e0c80ff24", size = 315070, upload-time = "2026-09-14T14:22:21.476Z" },
]
[[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 = "oracledb"
version = "26.0.1"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "typing-extensions" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8c/f3/22113415f48b6608ada31ebb46047b3b8ac361dba393d1b5ed1f768d8f6a/oracledb-26.0.1.tar.gz", hash = "sha256:786397a6b37e94ebfa6c1c6cd026755eb8fccfd39e15bcb64cb5879491882aef", size = 910463, upload-time = "2026-09-22T21:23:20.378Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/83/fa/349ee179b3e106f210ac070d199dd341f37e2af9f5faf2563b80d8aa6ab1/oracledb-26.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eea4d0824fa82b03c0d90aad81f10dbbacdaf351979c0130190a362a539e63ae", size = 4908265, upload-time = "2026-09-22T21:23:49.524Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/68/2dc6a99d385835290825c0d6689097af29637db8754197040b95814bb5cd/oracledb-26.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c05707b2ba57d8210bdf062ba3419900591f3369d1532fb116af51445c2993d", size = 2448388, upload-time = "2026-09-22T21:23:50.993Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d5/4b/ce48ed072c2328b429a6bc87a35ddd735945151145d9fe49c1e177b0da83/oracledb-26.0.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36cbf2be185412fee95be96aa6f187a73fd5181bb03186b53d5b70ac18fa354b", size = 2663296, upload-time = "2026-09-22T21:23:52.426Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f2/c8/73166ff29389c5ce3272fcc534360ab65248f90fcdf175a99b5fa2bb8466/oracledb-26.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5ad1b6f4db3d8a43182e7734922342fac5901456b34acead92f6fc60cc59882", size = 2493289, upload-time = "2026-09-22T21:23:53.949Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f4/c0/f6b4f82d6dc7f7d7654c0e4889caa1abbe019ec76daa1b4b4ee80859172b/oracledb-26.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aecc4cdd03cb5a5ad51f43680d16d74132ae7df65c991e7bd69bbe2ccc0fd209", size = 2694730, upload-time = "2026-09-22T21:23:55.609Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9b/da/a3ad79e18c5b27b357de005ce9d79dff1ef1646b7c55b6e6557b238f03bd/oracledb-26.0.1-cp312-cp312-win32.whl", hash = "sha256:f55d6fd9ebc3e8bf3bf4828fcd6ae152b363859628d27f92a4764111adfb1001", size = 1578393, upload-time = "2026-09-22T21:23:56.975Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/06/c6/8e2ad66bda2cda6d085f3516ad8f7834b1d7d4c9da21f96834467525b551/oracledb-26.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:1c6f2f0d7290b15f1a166c0d24187e7746a6f08ea6e886e521110f1bf8d2002c", size = 1970972, upload-time = "2026-09-22T21:23:58.352Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ba/ce/98c3a83142a562e40135abb8503b59eba7af1135c11c8077986a289f3a35/oracledb-26.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:c31b6f7440047c867a1b41cbaace7867db0750ecab4dc6454c9f501991152deb", size = 1622016, upload-time = "2026-09-22T21:23:59.996Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f4/d7/2a911476339d9fa6eb83e6a4639b007c13af26d7261907ecc2d8722a6712/oracledb-26.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:339dd6c4f0d50dab4026a36ae89d902240b2a879a17cdf18e3feadc113957b4e", size = 4911060, upload-time = "2026-09-22T21:24:01.757Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e9/e8/97e3b2c283ab996efe1d12b6afa86042c746b0ce4e408942bb4b7c35e39a/oracledb-26.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5907bb5c9df123e417ddf358f540554dfc6766064c2feb84121c099bc4e039a3", size = 2445641, upload-time = "2026-09-22T21:24:03.587Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/03/f7/bbf75b91248ea489e8393ae84e9985b37c895781c658b6d9e5d83d02c1c1/oracledb-26.0.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:16d08328bf9b02e980ad4d5f3e0e99596f9a42aaf6bd1e244ea85be753f2c292", size = 2646590, upload-time = "2026-09-22T21:24:05.22Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3b/32/cbdacb302a2ca047e36e0f804c493391844aad83822d428184fa1d4203d5/oracledb-26.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50e42111d5c620f60478e4571a5847c335286e8650e63a8789f8bd51035f90da", size = 2499129, upload-time = "2026-09-22T21:24:06.801Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9a/1e/ce86b581a4f1c8d5844019f84eacc9f4084c882ba676a90c94d7eaeb749e/oracledb-26.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0453f8f1a6748c982dcdac8af09bcd81971979c7ec9d1d151e15595f58d9164b", size = 2675979, upload-time = "2026-09-22T21:24:08.153Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/82/7c/49e6af631b84db68d801b6d03ff58c4fa7b767ffa8a8402de643584e9d63/oracledb-26.0.1-cp313-cp313-win32.whl", hash = "sha256:93e6a7cda6ad7b14e2ce8689c9694872b3d69d57015aab070028c9a93faf3bf1", size = 1576622, upload-time = "2026-09-22T21:24:09.492Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/59/10/ba7a5ce82fda9bbfcf222930b9a6f9a967c2b3d49f787ba7f4f99b598c6f/oracledb-26.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:6cf0c4d8662072917e2f98598cb97a18e157785bd0f294fbcf770d822ca239a9", size = 1970210, upload-time = "2026-09-22T21:24:10.806Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/24/dc/96ce57dad3b1bb3d3c30be936d0f6daea0a02649d8936b878921004b15e1/oracledb-26.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:22bb1e47a01a5259eead9202be8f631682b69be08ee29d8e532ed9b9364bd7bd", size = 1621513, upload-time = "2026-09-22T21:24:12.246Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3e/e6/7cf147ea1daf0c7c86fcd77acce1a09b0d4ab75539a4677a2034f112c860/oracledb-26.0.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c2201184b46bbec476a7ac29a72f14ee4ed75d692ade3b6c5d672fdbca71e6b2", size = 4961776, upload-time = "2026-09-22T21:24:13.967Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/2b/7e/1ada40fce6ec550718e6eb1b8be9bfcc6e9697f19b6244ed6e0b89aa8da8/oracledb-26.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8bddb6bd7156df750045b419ffc400cfe429276f1a17d83913f10adefd0baa9a", size = 2486660, upload-time = "2026-09-22T21:24:16.397Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/65/2a/a4cf6f8081b1ab3498c82e3917b2174843bf22e2fa4c41e98ad1e0b66d47/oracledb-26.0.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b33b22800a9b02ee0ead00e6f3073b810f7dc51c1e82fc9fc5475c09812420df", size = 2664849, upload-time = "2026-09-22T21:24:18.16Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b2/f2/50a32fa2d8c6cbb0e5fb69c262b01e7412ad08e430192404af9d2b9f4bea/oracledb-26.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:124f0072e6c7df277c561ec3a4c42cb2c02c30e9ce15e75a460b4abcfee41046", size = 2544330, upload-time = "2026-09-22T21:24:19.627Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b0/90/9b6dd9bbc1899ca3a982e9012f25be2a70e4795eacc6f8582c340c64fe1c/oracledb-26.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4c5094d5ba60b0f67ce7c74eb0fbc738d827103c0ba2db5f8483db405ab4e9c4", size = 2693190, upload-time = "2026-09-22T21:24:21.228Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/92/42/0df9265f2f562283c88590a6bcd442084cef80c832ff598bdbab994a3cb8/oracledb-26.0.1-cp314-cp314-win32.whl", hash = "sha256:03424ccfbd25c402133c2a23c5c1a630e57effda7b962085d91c41d765af1b25", size = 1602133, upload-time = "2026-09-22T21:24:22.733Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/db/d4/1b90c5e252a53dbc71833217746f2673e9e2ce0e06115bac1dc42ae347d0/oracledb-26.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:ee8f4736b72f38ade26f9770f7b40e8662bfa825ef48cefcc55cef0c30525d4b", size = 2027835, upload-time = "2026-09-22T21:24:24.105Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d6/4e/9febea61cbf476c2ae5d290fb3f823f1e46668e3ac15b8af5307531e33ad/oracledb-26.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:13b0dfbbc503c6107d6c08b1658df260c2c077bc5574d9545159df0a0301fe7d", size = 1680743, upload-time = "2026-09-22T21:24:25.475Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/77/bb/71f7861bfbf586757873259a858f370030a137b4172865992c3cc9a462b1/oracledb-26.0.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9e77a6b3b01a3a2f4b786939f59d862fbe73fbc4c68cdf87aa45a5398725a16c", size = 4960764, upload-time = "2026-09-22T21:24:27.244Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/14/c0/86aa68f08e5c1d25b864ac5777c559bf0bc8911e5d8262b70bec5e05cc21/oracledb-26.0.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d722f185f9c05ecccb2702a58a1e5ecfd309ae92cad0c38373e0969e83bf2bd", size = 2488466, upload-time = "2026-09-22T21:24:29.177Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/fc/f4/af94230a3c2c6679323f3578a0a5281cfd87f72994f66e6044b5ff6c95e9/oracledb-26.0.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfcf6382bc48e21e448ca2b71644cc75626056a638bca50181acdc8adc69dd3c", size = 2685915, upload-time = "2026-09-22T21:24:30.823Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f5/a7/aa99f8e60961e80795ca926ead66c66d98c3aee78d970320eacdfa843614/oracledb-26.0.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d42a2e0caea9bcf7d260d430b225d72cff6dffa0d6729be7a8823db2678e0b74", size = 2544920, upload-time = "2026-09-22T21:24:32.667Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e2/65/b636a18a578cffd5819df08ae80deeb931f1215d2d2b18112a37e9d2bbea/oracledb-26.0.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2a073cc8bd74a7ce32d12f4bc898dd143971981b52ecd2f72c37b4474bff76cb", size = 2713492, upload-time = "2026-09-22T21:24:34.22Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/0a/4a/e758838b14540a94599487d0d15ce4390edba5a257e53f2cea66f94bd138/oracledb-26.0.1-cp315-cp315-win32.whl", hash = "sha256:f4188bf8eedfc05ef266835e98ce304fda92d6bc87dd776d5f9a8b510d8b54ba", size = 1601872, upload-time = "2026-09-22T21:24:35.591Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/62/b1/51b2a51abd89ba43dc0f9ee14d456a9b002d0b9c31840c7de55387e555eb/oracledb-26.0.1-cp315-cp315-win_amd64.whl", hash = "sha256:9d508d2df7bb9a4802247ec95d06064e2babf8ae9e1f91d89c349bcd30df44ed", size = 2026829, upload-time = "2026-09-22T21:24:37.15Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/62/6b/b099ff2c51447cbf0d2f1c33ea7de1abf96d9bf4c3d93b2a5dff48728965/oracledb-26.0.1-cp315-cp315-win_arm64.whl", hash = "sha256:7c9541d7cf2d324e9516c955c033235bbf25b7d96ce41a856b3bff2eab3494ed", size = 1680535, upload-time = "2026-09-22T21:24:38.551Z" },
]
[[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 = "playwright"
version = "1.63.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "greenlet" },
{ name = "pyee" },
]
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/19/dd/fbb3d34228ad753bc14464d5f2252585b73367539775a88d0a3949cf5a45/playwright-1.63.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:84c540759e8e7f01e690e197e04060f48899d37cea322299f255843273d3385d", size = 44278407, upload-time = "2026-09-15T16:49:06.045Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f5/9a/948b930b1a8c4ee869e5a139a2b7747caa06aab56a3f09a2f0abdcbda221/playwright-1.63.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fd1aa00631d44d55e56e0975bf3f3f285fac4a9fd2813183f0c12a488f1a1b24", size = 42944679, upload-time = "2026-09-15T16:49:10.039Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/94/11/dc5c13fa1602371603acd461be47529c1b3513815d3a0dc98f642c291a10/playwright-1.63.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:c89fc4736502a1f0fac2c8ca5d10c0cbc1c669f1f4774a2d8507a43140e4d53f", size = 44278410, upload-time = "2026-09-15T16:49:13.861Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/27/9c/103a5037789062bdab27c7dca53f3ca6b075b572ab2cd96eec825b3aec4e/playwright-1.63.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:ad21bc07516b187965a7521c5cf0df0bd657b17482eaad74335272d35a2b07de", size = 48217159, upload-time = "2026-09-15T16:49:17.404Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f3/82/3d85505284c5a210f2da6c07b8f757524e79d1fba9cfdafe1eafb766ae59/playwright-1.63.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:354e15b29503565fc598b89f16fbe070459343bef9d7498a93e304864000c6a7", size = 47902708, upload-time = "2026-09-15T16:49:21.055Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/69/8d/f74ff6b52751859f4b69caf66aa7d4a3c8d6c1f0c7dc9920da103612ee39/playwright-1.63.0-py3-none-win32.whl", hash = "sha256:660c00c62639e31b16700ba5456b351ddba55bb766b7ce8261223aa34928e482", size = 38606075, upload-time = "2026-09-15T16:49:29.546Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/76/eb/d6b8d92658038e260dbc7dd69fb3fdbe245aac283953de8b50b02bfe5f61/playwright-1.63.0-py3-none-win_amd64.whl", hash = "sha256:2f9a707a6c6c91157ed77bff2b8caeb04b3c8d46e70d585fc134298cbe4b5cc6", size = 38606082, upload-time = "2026-09-15T16:49:32.838Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/be/75/2432b3e3c7c62103b72d4c4cc8b16a56383ada372bbb0d1278591e988f71/playwright-1.63.0-py3-none-win_arm64.whl", hash = "sha256:1e4a3a838ce22fb68ad17193fcd142a19610d9d70fb9966d2239f5dddc0cc05b", size = 34523554, upload-time = "2026-09-15T16:49:36.455Z" },
]
[[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 = "pycryptodome"
version = "3.23.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
]
[[package]]
name = "pyee"
version = "13.0.1"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" },
]
[[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 = "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 = "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-cart"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "oracledb" },
{ name = "playwright" },
{ name = "pycryptodome" },
{ name = "requests" },
{ name = "urllib3" },
{ name = "xlrd" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "xlwt" },
]
[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" },
{ name = "xlrd", specifier = "==1.2.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=8.0.0" },
{ name = "xlwt", specifier = ">=1.3.0" },
]
[[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]]
name = "xlrd"
version = "1.2.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/aa/05/ec9d4fcbbb74bbf4da9f622b3b61aec541e4eccf31d3c60c5422ec027ce2/xlrd-1.2.0.tar.gz", hash = "sha256:546eb36cee8db40c3eaa46c351e67ffee6eeb5fa2650b71bc4c758a29a1b29b2", size = 554079, upload-time = "2018-12-15T17:47:48.133Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b0/16/63576a1a001752e34bf8ea62e367997530dc553b689356b9879339cf45a4/xlrd-1.2.0-py2.py3-none-any.whl", hash = "sha256:e551fb498759fa3a5384a94ccd4c3c02eb7c00ea424426e212ac0c57be9dfbde", size = 103251, upload-time = "2018-12-15T17:47:45.792Z" },
]
[[package]]
name = "xlwt"
version = "1.3.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/06/97/56a6f56ce44578a69343449aa5a0d98eefe04085d69da539f3034e2cd5c1/xlwt-1.3.0.tar.gz", hash = "sha256:c59912717a9b28f1a3c2a98fd60741014b06b043936dcecbc113eaaada156c88", size = 153929, upload-time = "2017-08-22T06:47:16.498Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/44/48/def306413b25c3d01753603b1a222a011b8621aed27cd7f89cbc27e6b0f4/xlwt-1.3.0-py2.py3-none-any.whl", hash = "sha256:a082260524678ba48a297d922cc385f58278b8aa68741596a87de01a9c628b2e", size = 99981, upload-time = "2017-08-22T06:47:15.281Z" },
]

94
rpa_cart/yaoex_client.py Normal file
View File

@ -0,0 +1,94 @@
# 1 号药城订单导出。
from __future__ import annotations
import json
import time
from datetime import date, datetime, timedelta
from typing import Any
import requests
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
GATEWAY = "https://gateway-b2b.fangkuaiyi.com"
EXPORT_URL = f"{GATEWAY}/api/order/export"
# 导出用的起止时间。默认只含当天,从 0 点到 23:59:59。
def day_range(day: date | None = None, days: int = 0) -> tuple[str, str]:
end = day or date.today()
start = end - timedelta(days=max(days, 0))
return (
datetime.combine(start, datetime.min.time()).strftime("%Y-%m-%d 00:00:00"),
f"{end.isoformat()} 23:59:59",
)
# 按时间段导出 1 号药城订单明细,返回 Excel 字节。回包不是表格就失败。
def export_orders(
token: str,
*,
begin: str,
end: str,
session: requests.Session | None = None,
) -> bytes:
params = {
"queryHistory": "false",
"productName": "",
"supplyName": "",
"flowId": "",
"createBeginTime": begin,
"createEndTime": end,
"orderStatus": 0,
}
own = session is None
client = session or requests.Session()
try:
response = client.post(
EXPORT_URL,
data=_form(token, {"jsonParams": json.dumps(params, ensure_ascii=False)}),
headers=_headers(),
timeout=180,
verify=False,
)
response.raise_for_status()
if "json" in (response.headers.get("content-type") or "") and response.content[:2] != b"PK":
raise RuntimeError(f"导出失败:{response.text[:300]}")
if response.content[:8] != b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" and response.content[:2] != b"PK":
head = response.content[:80]
raise RuntimeError(f"导出不是 Excel:{head!r}")
return response.content
finally:
if own:
client.close()
# 拼 1 号药城导出表单,带上登录 token。
def _form(token: str, extra: dict[str, Any]) -> dict[str, Any]:
payload = {
"traderName": "yaoex_pc",
"trader": "pc",
"closesignature": "yes",
"signature_method": "md5",
"signature": "****",
"timestamp": str(int(time.time() * 1000)),
"token": token,
"userToken": token,
}
payload.update(extra)
return payload
# 1 号药城订单导出请求头。来源页是订单列表。
def _headers() -> dict[str, str]:
return {
"Content-Type": "application/x-www-form-urlencoded",
"X-Requested-With": "XMLHttpRequest",
"X-Request-Agent": "Axios",
"traderName": "yaoex_pc",
"trader": "pc",
"Origin": "https://mall.yaoex.com",
"Referer": "https://mall.yaoex.com/v2/orderList/",
}

84
rpa_cart/yaoex_login.py Normal file
View File

@ -0,0 +1,84 @@
# 复用 rpa_selection 的 1 号药城 / 药帮忙登录。
#
# 导入路径由 selection_path 放进 rpa_selection,这里不再改 sys.path。
from __future__ import annotations
from pathlib import Path
import selection_path # noqa: F401 先把 rpa_selection 放进导入路径
from rpa_oracle import load_job_env
from auth_login import (
DEFAULT_YBM_COOKIE_PATH,
DEFAULT_YAOEX_AUTH_PATH,
BrowserRuntime,
load_cookie_file,
load_secrets,
load_yaoex_auth_file,
login_yaoex,
login_ybm,
save_cookie_file,
save_yaoex_auth,
)
from search_yaoex import YaoexAuth
# 读加购 / 选品 / oms 的 .env,不覆盖已有非空变量。
def load_cart_dotenv() -> None:
load_job_env(Path(__file__).resolve().parent)
# 拿到 1 号药城登录态。有缓存且 token 有效则直接用,否则走浏览器登录。
def resolve_yaoex_auth(
*,
auth_path: Path | None = None,
fresh: bool = False,
runtime: BrowserRuntime | None = None,
) -> YaoexAuth:
cached = auth_path or DEFAULT_YAOEX_AUTH_PATH
if not fresh and cached.exists():
auth = load_yaoex_auth_file(cached)
if auth.token and auth.token != "t":
return auth
secrets = load_secrets()
if not secrets["yaoex"].get("username"):
raise SystemExit(
"缺少 1 号药城账号。配置 rpa_selection/.env 或环境变量 YAOEX_USERNAME / YAOEX_PASSWORD。"
)
own = runtime is None
session = runtime or BrowserRuntime(keep=False)
try:
auth = login_yaoex(runtime=session)
save_yaoex_auth(auth, cached)
return auth
finally:
if own:
session.close()
# 拿到药帮忙登录 cookie。有缓存则直接用,否则走浏览器登录。
def resolve_ybm_cookies(
*,
cookie_path: Path | None = None,
fresh: bool = False,
runtime: BrowserRuntime | None = None,
) -> dict[str, str]:
cached = cookie_path or DEFAULT_YBM_COOKIE_PATH
if not fresh and cached.exists():
cookies = load_cookie_file(cached)
if cookies:
return cookies
secrets = load_secrets()
if not secrets["ybm"].get("username"):
raise SystemExit(
"缺少药帮忙账号。配置 rpa_selection/.env 或环境变量 YBM_USERNAME / YBM_PASSWORD。"
)
own = runtime is None
session = runtime or BrowserRuntime(keep=False)
try:
cookies = login_ybm(runtime=session)
save_cookie_file(cookies, cached)
return cookies
finally:
if own:
session.close()

111
rpa_contract/README.md Normal file
View File

@ -0,0 +1,111 @@
# rpa_contract
合同班。从表 3 领取、按 `supplierId` 合单、调页面录入、写表 4。接口 4 组包在 `rpa_oms`,本班不推、不改 `push_status`。
| | |
| --- | --- |
| 读 | 表 3 `rpa_purchase_contract_data`(`process_status=0`;跳过 `contractEntryStatus=0` 且已有 `contractNo` 的协议重录行) |
| 写 | 表 4 `rpa_contract_agreement_result`(`actionCode=1`,提交成功 `contractStatus=1` 起草) |
| 口 | 口 3 收件在 `rpa_oms`;口 4 组包在 oms,path 空 |
一天两班(中午、下午)。启动瞬间冻结本班名单(`process_status` 0→3)。按 `supplierId` 合单;头字段取该班该供应商第一行。下午同一供应商另开一份。
口径:`docs/RPA订单中心接口开发口径_20260930.md` 第 4 节;合单细节见 `docs/合同系统录入流程.md`。
## 测试环境
地址:`http://contract-test.gzmpc.com:8080/`
账密只认环境变量 `CONTRACT_USERNAME` / `CONTRACT_PASSWORD`,缺则入口失败。不要把密码写进仓库。
客商必须是合同系统里真实存在的操作码,占位符搜不到。例如 `DW0100046361` / `重庆亿昊药业有限公司`。
## 页面步骤
脚本直达起草/模板 URL。修一步只打开对应文件;`contract_flow_steps.py` 是对外入口。
1. 打开合同系统并登录(`contract_login.py` S02–S05)
2. 进起草,套非格式采购协议模板(直达 `FORM_PAGE_URL`)
3. 签约方:客户编码、客户名称(`contract_form.py` S06)
4. 尽调:页面正文含「未尽调」则本单失败,不提交
5. 基础信息:合同版本类型、合同类别、结算类型、合同背景概述、合同性质=主合同(S07)
6. 金额与日期:签订日 / 开始日 / 结束日用接口 3 第一行三日期(JSON 干跑未给则回退 `orderDate`)(S08)
7. 货品明细:弹窗按操作码搜,找不到则本单失败(`contract_goods.py` S09)
8. 付款与标志:这期一律账期,不勾预付款;页面「账期支付」对应选项「电汇」(S10)
9. 挂合同正文(`contract_upload.py` S11);点提交(`contract_submit.py` S12),不要只点暂存
10. 按 fdId 从详情读正式合同号(`contract_number.py` S13)
选择器、工具栏、`fdId` 在 `contract_common.py`。
## 运行
JSON 干跑(不领表):
```bash
cd rpa_contract
uv sync --python 3.12
uv run pytest tests/test_job.py::test_job_merges_same_supplier_and_writes_table4 -q
uv run python main.py --input-json examples/contract_create_input_dry_run.json
```
班次(领表 3):
```bash
uv run python run_batch.py
```
`submitMode=dry_run` 跑到提交前,不点提交。真起草用 `examples/contract_draft_yyc.json` / `examples/contract_draft_xyy.json`。
## 运维
### 怎么跑
```bash
cd rpa_contract
uv sync --python 3.12
uv run python run_batch.py
```
Oracle 空缺时补 `rpa_oms/.env`。连接从 `rpa_oracle` 池取,班次默认池大小 1。缺 `RPA_ORACLE_HOST` / 用户 / 密码时入口失败,文案「数据库不可用」。合同系统账密用 `CONTRACT_USERNAME` / `CONTRACT_PASSWORD`。未捕获异常原文进 `rpa_shift_error_audit`,Windows 上先执行 `docs/ddl/rpa_oms_staging_tables.sql` 里这张表。
JSON 干跑(不领表):
```bash
uv run python main.py --input-json examples/contract_create_input_dry_run.json
```
### 看哪张表哪几列
- 表 3 `rpa_purchase_contract_data`:`process_status`(0 待领,3 处理中,1 成功,2 失败)、`process_time`、`process_result`、`supplier_id`、`contract_entry_status`、`contract_no`
- 表 4 `rpa_contract_agreement_result`:`action_code=1`、`contract_status`(1 起草成功,3 失败)、`contract_no`、`contract_fail_reason`、`push_status=0`
### 崩溃改回 SQL
停在 3 且未过 30 分钟租约:
```sql
UPDATE rpa_purchase_contract_data
SET process_status = 0
WHERE process_status = 3 AND fid = :fid;
```
过期的 3 下一班会再领,不必改。协议重录行(`contract_entry_status=0` 且已有 `contract_no`)本班不领。
### 原因列 vs 日志原文
表 3 `process_result` / 表 4 `contract_fail_reason`:尽调写「客户未尽调」,页面 NodeError 写业务文案,未捕获异常写 `PAGE_FAILED`。异常原文打本机日志,不进这两列。
### 点名命令
```bash
cd rpa_contract
uv run pytest tests/test_job.py::test_job_merges_same_supplier_and_writes_table4 -q
uv run pytest tests/test_job.py::test_job_marks_diligence_fail -q
uv run pytest tests/test_oracle_repo.py::test_from_env_without_user_raises -q
uv run pytest tests/test_job.py::test_claim_reclaims_expired_status_3 tests/test_job.py::test_claim_skips_fresh_status_3 -q
uv run pytest tests/test_flow_gates.py -q
```
页面验收只看控制台 `ACCEPT SUMMARY` 或 `screenshots/` 下 `ACCEPT.txt`,禁止读 png。
## 本期不做
向订单中心发 HTTP、改 `push_status`。返利在 `rpa_rebate/`。点击顺序和选择器本段不改。

View File

@ -1,9 +1,7 @@
"""Shared ACCEPT report for every RPA flow (any platform / page). # 页面验收报告。每步写一行 ACCEPT,汇总进 ACCEPT.txt。
#
# 验收看这份文本,不看截图。截图只在失败时留,给人查页面。
New flows (ORDER_FETCH, REBATE_*, M5, ...) must use this instead of
reading screenshots. Screenshots stay on disk for humans; this text is
the agent oracle.
"""
from __future__ import annotations from __future__ import annotations
import os import os
@ -11,16 +9,20 @@ from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Protocol from typing import Protocol
from .errors import NodeError from errors import NodeError
PACKAGE_ROOT = Path(__file__).resolve().parents[1] PACKAGE_ROOT = Path(__file__).resolve().parent
SCREENSHOT_ROOT = PACKAGE_ROOT / "screenshots" SCREENSHOT_ROOT = PACKAGE_ROOT / "screenshots"
# 能按名字截图的浏览器。
class HasScreenshot(Protocol): class HasScreenshot(Protocol):
def screenshot(self, name: str) -> str: ... # 按名字截一张图,返回保存路径。
def screenshot(self, name: str) -> str:
...
# 验收的一步:步骤号、标题、是否通过、读到的事实、对不上的字段。
@dataclass @dataclass
class AcceptLine: class AcceptLine:
step: str step: str
@ -30,23 +32,26 @@ class AcceptLine:
mismatches: list[str] mismatches: list[str]
# 把流程名收成目录名。空名用 default,其余只留字母、数字、横线和底线。
def safe_flow_name(flow: str) -> str: def safe_flow_name(flow: str) -> str:
text = (flow or "default").strip() or "default" text = (flow or "default").strip() or "default"
return "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in text) return "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in text)
# 这个流程的截图和 ACCEPT.txt 放在哪。
def artifact_dir(flow: str) -> Path: def artifact_dir(flow: str) -> Path:
return SCREENSHOT_ROOT / safe_flow_name(flow) return SCREENSHOT_ROOT / safe_flow_name(flow)
# 默认只在失败时截图。RPA_SCREENSHOT=always 时每步都留。
def evidence_shot(browser: HasScreenshot, name: str, *, failed: bool = False) -> str: def evidence_shot(browser: HasScreenshot, name: str, *, failed: bool = False) -> str:
"""Save a screenshot only on failure. Set RPA_SCREENSHOT=always to keep every frame."""
mode = (os.environ.get("RPA_SCREENSHOT") or "fail").strip().lower() mode = (os.environ.get("RPA_SCREENSHOT") or "fail").strip().lower()
if not failed and mode != "always": if not failed and mode != "always":
return "" return ""
return browser.screenshot(name) return browser.screenshot(name)
# ACCEPT.txt 的路径。RPA_ACCEPT_LOG 可改到别处,单测用这个钉住文件。
def accept_log_path(flow: str = "default") -> Path: def accept_log_path(flow: str = "default") -> Path:
override = os.environ.get("RPA_ACCEPT_LOG") override = os.environ.get("RPA_ACCEPT_LOG")
if override: if override:
@ -54,6 +59,7 @@ def accept_log_path(flow: str = "default") -> Path:
return artifact_dir(flow) / "ACCEPT.txt" return artifact_dir(flow) / "ACCEPT.txt"
# 页面读数和期望是否同一值。空期望算通过;数字、是否、去空白后包含也算对上。
def field_match(actual: str, expected: str) -> bool: def field_match(actual: str, expected: str) -> bool:
actual_text = (actual or "").strip() actual_text = (actual or "").strip()
expected_text = (expected or "").strip() expected_text = (expected or "").strip()
@ -78,6 +84,7 @@ def field_match(actual: str, expected: str) -> bool:
return compact_e in compact_a or compact_a in compact_e return compact_e in compact_a or compact_a in compact_e
# 把一步收成控制台一行:ACCEPT、步骤、ok 或 FAIL、标题和读到的字段。
def format_accept(line: AcceptLine) -> str: def format_accept(line: AcceptLine) -> str:
status = "ok" if line.ok else "FAIL" status = "ok" if line.ok else "FAIL"
facts = " ".join(f"{key}={value}" for key, value in line.facts.items()) facts = " ".join(f"{key}={value}" for key, value in line.facts.items())
@ -85,16 +92,17 @@ def format_accept(line: AcceptLine) -> str:
return f"ACCEPT {line.step} {status} {line.title} {facts}{extra}".rstrip() return f"ACCEPT {line.step} {status} {line.title} {facts}{extra}".rstrip()
# 一次页面流的验收单。每步打印并覆写 ACCEPT.txt,对不上就抛 NodeError。
@dataclass @dataclass
class AcceptanceReport: class AcceptanceReport:
"""DOM / page-state checklist. One instance per flow run."""
flow: str = "default" flow: str = "default"
lines: list[AcceptLine] = field(default_factory=list) lines: list[AcceptLine] = field(default_factory=list)
# 清空已记的步骤,下一次页面流重新计。
def reset(self) -> None: def reset(self) -> None:
self.lines = [] self.lines = []
# 核对一组字段。对不上就记 FAIL、失败时截图,并抛 NodeError 停下这一步。
def check( def check(
self, self,
step: str, step: str,
@ -122,6 +130,7 @@ class AcceptanceReport:
evidence_shot(browser, screenshot_on_fail, failed=True) evidence_shot(browser, screenshot_on_fail, failed=True)
raise NodeError(f"ACCEPT {step} FAIL " + "; ".join(mismatches)) raise NodeError(f"ACCEPT {step} FAIL " + "; ".join(mismatches))
# 记一步事实,不和期望逐字段比。ok 为假且 raise_error 时抛 NodeError。
def note( def note(
self, self,
step: str, step: str,
@ -138,6 +147,7 @@ class AcceptanceReport:
if not ok and raise_error: if not ok and raise_error:
raise NodeError(f"ACCEPT {step} FAIL {title} " + " ".join(f"{k}={v}" for k, v in rendered.items())) raise NodeError(f"ACCEPT {step} FAIL {title} " + " ".join(f"{k}={v}" for k, v in rendered.items()))
# 把当前汇总写进 ACCEPT.txt。写失败只打印,不把页面流打成另一次错误。
def dump(self) -> None: def dump(self) -> None:
path = accept_log_path(self.flow) path = accept_log_path(self.flow)
try: try:
@ -146,6 +156,7 @@ class AcceptanceReport:
except OSError as exc: except OSError as exc:
print(f"ACCEPT log write skipped: {exc}", flush=True) print(f"ACCEPT log write skipped: {exc}", flush=True)
# 汇总文本:第一行是通过数,后面是每步的 ACCEPT 行。
def summary_text(self) -> str: def summary_text(self) -> str:
passed = sum(1 for line in self.lines if line.ok) passed = sum(1 for line in self.lines if line.ok)
rows = [f"ACCEPT SUMMARY {self.flow} {passed}/{len(self.lines)} ok"] rows = [f"ACCEPT SUMMARY {self.flow} {passed}/{len(self.lines)} ok"]

View File

@ -0,0 +1,33 @@
# 合同班浏览器适配。复用 rpa_browser 共享适配器。
#
# 本文件只做导入入口,步骤函数仍 from apa_browser_adapter import ApaBrowserAdapter。
# Chrome 用户目录和调试口用共享默认:CONTRACT_CREATE 口 9333。
from __future__ import annotations
import sys
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parents[1]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
from rpa_browser.adapter import ( # noqa: E402
ApaBrowserAdapter,
REPO_ROOT,
chrome_debug_port,
resolve_chrome_profile_base,
resolve_chrome_profile_dir,
wait_debug_port,
wait_devtools_port,
)
__all__ = [
"ApaBrowserAdapter",
"REPO_ROOT",
"chrome_debug_port",
"resolve_chrome_profile_base",
"resolve_chrome_profile_dir",
"wait_debug_port",
"wait_devtools_port",
]

View File

@ -0,0 +1,606 @@
# 合同页面共用:类型、选择器、工具栏、fdId、验收报告。
#
# 各 S0x 步骤从这里取 BrowserAdapter / ContractFlowContext / _ACCEPT。
# 改字段选择器或工具栏点击,步骤文件一起变。
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Protocol
from accept import AcceptanceReport
# 合同页面操作口,由 ApaBrowserAdapter 实现。
class BrowserAdapter(Protocol):
# 打开指定 URL。
def open_url(self, url: str) -> None:
...
# 按选择器列表填第一个匹配到的输入框。
def fill_first(self, selectors: list[str], value: str) -> None:
...
# 点第一个匹配到的元素。
def click_first(self, selectors: list[str]) -> None:
...
# 用原生事件点第一个匹配到的元素。
def click_native(self, selectors: list[str]) -> None:
...
# 双击第一个匹配到的元素。
def double_click_first(self, selectors: list[str]) -> None:
...
# 等到页面正文出现指定文本。
def wait_for_text(self, text: str, timeout_seconds: int = 15) -> None:
...
# 等到选择器列表里有一个元素出现。
def wait_for_element(self, selectors: list[str], timeout_seconds: int = 15) -> None:
...
# 选择器列表里是否已有匹配元素。
def element_exists(self, selectors: list[str]) -> bool:
...
# 切入第一个匹配到的 iframe。
def switch_to_frame(self, selectors: list[str]) -> None:
...
# 切回主文档。
def switch_to_default_content(self) -> None:
...
# 在第一个匹配到的 select 上选中指定值。
def select_first(self, selectors: list[str], value: str) -> None:
...
# 按 xform property 名选中 fancy-select 选项。
def select_xform(self, name: str, value: str) -> None:
...
# 在当前页跑一段脚本并返回结果。
def execute_script(self, script: str, *args):
...
# 把本地文件塞进第一个匹配到的 file input。
def upload_file_first(self, selectors: list[str], file_path: str) -> None:
...
# 读第一个匹配元素的值或文本。
def read_first(self, selectors: list[str], value_name: str = "") -> str:
...
# 读当前页可见正文。
def read_body_text(self) -> str:
...
# 按名字截一张图,返回保存路径。
def screenshot(self, name: str) -> str:
...
# 有原生 alert 就点确定并返回文案。
def accept_alert(self) -> str:
...
# 关掉当前页再打开指定 URL。
def reopen_url(self, url: str) -> None:
...
# 合同货品明细一行,供选择商品弹窗和明细表回填。
@dataclass(frozen=True)
class ContractGoodsLine:
operation_code: str
goods_code: str
goods_name: str
approval_no: str
spec: str
unit: str
quantity: float
mdm_code: str = ""
unit_price: str = ""
# 一次合同起草页的入参。账密、客商、货品、提交方式都在这里。
@dataclass(frozen=True)
class ContractFlowContext:
contract_url: str
username: str
password: str
supplier_name: str
order_date: str
source_type: str
template_type: str = "FORMAT"
template_name: str = ""
customer_code: str = ""
customer_name: str = ""
contract_category: str = ""
settlement_type: str = "付款"
contract_background: str = ""
contract_nature: str = "主合同"
amount_currency: str = "人民币"
amount_type: str = "合同标的金额"
amount: str = ""
contract_title: str = ""
contract_end_date: str = ""
contract_sign_date: str = ""
contract_start_date: str = ""
is_major_contract: str = "否"
is_prepayment: str = "否"
belong_year: str = ""
payment_method: str = "账期支付"
payment_days: str = "30"
contract_status: str = "正式合同"
contains_blood_products: str = "否"
submit_mode: str = "submit"
goods_lines: list[ContractGoodsLine] = field(default_factory=list)
merged_pdf_path: str | None = None
contract_document_path: str = ""
order_no_list: list[str] = field(default_factory=list)
_ACCEPT = AcceptanceReport(flow="CONTRACT_CREATE")
# 拼合同标题:有 contract_title 用它,否则客商名+类别+订单日。
def _expected_title(ctx: ContractFlowContext) -> str:
title = (ctx.contract_title or "").strip()
if title:
return title
who = (ctx.customer_name or ctx.supplier_name or "").strip()
cate = (ctx.contract_category or "采购协议").strip()
date = (ctx.order_date or "").replace("-", "")
return f"{who}{cate}{date}"
# 合同起草页(模板选择)直达 URL,绕过 SPA 菜单/iframe 复杂性
DRAFT_PAGE_URL = (
"http://contract-test.gzmpc.com:8080/km/agreement/km_agreement_apply/draft/"
"draft.jsp?isSimpleCategory=false"
"&mainModelName=com.landray.kmss.km.agreement.model.KmAgreementApply"
"&modelName=com.landray.kmss.km.agreement.model.KmAgreementTmpl"
)
# 非格式-采购协议(2025集团版)模板 ID(测试环境固定值)
# 直接跳转表单页,绕过「点模板」这一依赖前端委托事件、Selenium 点击无效的步骤
TEMPLATE_ID = "198d10b04325279b8ffeb8b4c6493b5a"
FORM_PAGE_URL = (
"http://contract-test.gzmpc.com:8080/km/agreement/km_agreement_apply/"
"kmAgreementApply.do?method=add"
f"&fdTemplateId={TEMPLATE_ID}"
f"&.fdTemplate={TEMPLATE_ID}"
f"&i.docTemplate={TEMPLATE_ID}"
"&s_css=default"
)
VIEW_PAGE_URL = (
"http://contract-test.gzmpc.com:8080/km/agreement/km_agreement_apply/"
"kmAgreementApply.do?method=view&fdId={fd_id}"
)
EDIT_PAGE_URL = (
"http://contract-test.gzmpc.com:8080/km/agreement/km_agreement_apply/"
"kmAgreementApply.do?method=edit&fdId={fd_id}"
)
# Landray/LUI 表单字段的真实 name
F = {
"customer_code": 'input[name="extendDataFormInfo.value(fd_customeropcode)"]',
"customer_name": 'input[name="extendDataFormInfo.value(fd_customername)"]',
"contract_category": 'input[name="extendDataFormInfo.value(fd_contract_cate_type)"]',
"settlement_type": 'select#fdSettype',
"contract_background": 'textarea[name="extendDataFormInfo.value(fd_desc_info)"]',
"nature_main": 'input[name="fdType"][value="00"]',
"amount": 'input[name="fdAccount"]',
"sign_date": 'input[name="fdSigndate"]',
"begin_date": 'input[name="fdEffedate"]',
"end_date": 'input[name="fdEnddate"]',
"is_major_contract": 'input[name="extendDataFormInfo.value(fd_is_major_contract)"]',
"is_prepayment": 'input[name="extendDataFormInfo.value(fd_advance_amout)"]',
"belong_year": 'input[name="extendDataFormInfo.value(fd_contract_year_text)"]',
"payment_method": 'input[name="extendDataFormInfo.value(fd_paytype)"]',
"payment_days": 'input[name="extendDataFormInfo.value(fd_pay_time_limit)"]',
"contract_status": 'input[name="extendDataFormInfo.value(fd_suconstatus)"]',
"contains_blood": 'input[name="extendDataFormInfo.value(fd_3d1ce3953f1162)"]',
"doc_subject": 'input[name="docSubject"]',
}
# xform fancy-select 的 property 名(select_xform 用)
X = {
"contract_category": "extendDataFormInfo.value(fd_contract_cate_type)",
"cover_template": "extendDataFormInfo.value(fd_contract_cover_type)",
"amount_type": "extendDataFormInfo.value(fd_all_amount_type)",
"is_major_contract": "extendDataFormInfo.value(fd_is_major_contract)",
"is_prepayment": "extendDataFormInfo.value(fd_advance_amout)",
"payment_method": "extendDataFormInfo.value(fd_paytype)",
"contract_status": "extendDataFormInfo.value(fd_suconstatus)",
"contains_blood": "extendDataFormInfo.value(fd_3d1ce3953f1162)",
}
_YES_NO = {
"1": "是",
"2": "否",
"Y": "是",
"N": "否",
"YES": "是",
"NO": "否",
"TRUE": "是",
"FALSE": "否",
}
_CATEGORY_ALIASES = {
"采购协议": "其他协议(采购)",
"采购": "其他协议(采购)",
}
_PAYMENT_ALIASES = {
"账期支付": "电汇",
"账期": "电汇",
"承兑": "银行承兑汇票",
}
# 把 1/2、Y/N 收成页面上的「是」「否」。
def _yes_no(value: str) -> str:
raw = (value or "").strip()
return _YES_NO.get(raw.upper(), raw)
# 把合同类别别名收成页面下拉的实际选项。
def _category_option(value: str) -> str:
raw = (value or "").strip()
return _CATEGORY_ALIASES.get(raw, raw)
# 把付款方式别名收成页面下拉的实际选项。「账期支付」对应「电汇」。
def _payment_option(value: str) -> str:
raw = (value or "").strip()
return _PAYMENT_ALIASES.get(raw, raw)
# 货品明细行的字段后缀;行索引从 0 开始(fd_detail1.0.* 为第 1 行)
GOODS_FIELD_SUFFIXES = {
"goods_code": "fd_opcode",
"goods_name": "fd_goodsname",
"goods_mdm": "fd_mdmCode",
"goods_approval": "fd_registdocno",
"goods_spec": "fd_goodstype",
"goods_unit": "fd_goodsunit",
"goods_qty": "fd_goodsqty",
"goods_price": "fd_unitprice",
}
ADD_ROW_SELECTORS = [
"text=添加行",
"text=新增明细",
"text=新增行",
"button:contains(添加行)",
]
# 货品选择窗口:点「选择商品」弹出 EopBasedataMaterial 列表,按操作码/名称搜索回填。
# lui-id-* 每次加载会变,不要写死;用 title / 按钮文本定位。
GOODS_PICKER = {
"trigger": [
'div[title="选择商品"]',
'div.lui-component.lui_widget_btn_txt',
"text=选择商品",
],
"frame": [
'iframe[src*="EopBasedataMaterial"]',
'iframe[src*="dialog_fdMaterial"]',
'iframe[src*="common-template"]',
],
"search_input": ["input.search-box-text", "input[placeholder*='关键字']"],
"search_button": [
"input.search-box-btn",
"a.search-box-btn",
"div.search-box-btn",
"span.search-box-btn",
"input[value='搜索']",
"input[value='查询']",
"[class*='search-box'] [class*='btn']",
],
"result_checkbox": ['input[name="List_Selected"]'],
}
# 客商选择窗口(点击客户名称旁「选择」按钮弹出)。若真实页面改版,仅需调整此处。
CUSTOMER_PICKER = {
"trigger": [
'div.inputselectsgl[onclick*="fdCustomerEdit"]',
'div[class*="inputselect"][onclick*="fdCustomer"]',
],
"frame": [
'iframe[src*="common-template"]',
'iframe[src*="KmRelativeMain"]',
'iframe[src*="relative"]',
],
"search_input": ["input.search-box-text"],
"search_button": ["input.search-box-btn"],
"result_checkbox": ['input[name="List_Selected"]'],
"confirm_button": ["div.btn.ok", "div.lui_toolbar_btn_def"],
}
_PORTAL_MARKERS = ("切换门户", "待办", "待阅", "合同管理", "合同起草")
# 看页面是否已进门户。看到「切换门户」或待办/待阅即视为已登录。
def _is_logged_in(browser: BrowserAdapter) -> bool:
body = browser.read_body_text() or ""
return any(marker in body for marker in _PORTAL_MARKERS)
# 取签订日。接口 3 给了用它,否则回退 order_date。
def sign_date_for(ctx: ContractFlowContext) -> str:
return (ctx.contract_sign_date or ctx.order_date or "").strip()
# 取开始日。接口 3 给了用它,否则回退 order_date。
def begin_date_for(ctx: ContractFlowContext) -> str:
return (ctx.contract_start_date or ctx.order_date or "").strip()
# 关掉挡在表单前面的「关闭/取消」弹层。
def _close_blocking_dialogs(browser: BrowserAdapter) -> None:
browser.execute_script(
"""
const labels = ['关闭', '取消'];
function clickIn(win) {
const nodes = Array.from(win.document.querySelectorAll('div, a, button, span'));
for (const label of labels) {
const hit = nodes.find((el) => (el.textContent || '').trim() === label && el.children.length === 0);
if (hit) { hit.click(); return label; }
}
for (let i = 0; i < win.frames.length; i++) {
try { const hit = clickIn(win.frames[i]); if (hit) return hit; } catch (e) {}
}
return '';
}
return clickIn(window);
"""
)
time.sleep(0.4)
# 读当前页 URL,跨 iframe 时尽量取 top.location。
def _current_url(browser: BrowserAdapter) -> str:
try:
return str(
browser.execute_script(
"try { return (window.top.location && window.top.location.href) || location.href; }"
" catch (e) { return location.href; }"
)
or ""
)
except Exception:
return ""
# 从 URL、隐藏域或 Com_Parameter 读本单 fdId。
def _read_document_id(browser: BrowserAdapter) -> str:
try:
browser.switch_to_default_content()
except Exception:
pass
value = browser.execute_script(
"""
function fromHref(href) {
const fromUrl = String(href || '').match(/[?&]fdId=([^&]+)/i);
if (fromUrl && fromUrl[1]) return decodeURIComponent(fromUrl[1]);
return '';
}
function fromDoc(win) {
try {
const id = fromHref(win.location && win.location.href);
if (id) return id;
} catch (e) {}
try {
const input = win.document.querySelector('input[name="fdId"], input[name="fdid"]');
if (input && String(input.value || '').trim()) return String(input.value).trim();
} catch (e) {}
try {
if (win.Com_Parameter && win.Com_Parameter.fdId) return String(win.Com_Parameter.fdId);
} catch (e) {}
return '';
}
function walk(win) {
const id = fromDoc(win);
if (id) return id;
for (let i = 0; i < win.frames.length; i++) {
try {
const inner = walk(win.frames[i]);
if (inner) return inner;
} catch (e) {}
}
return '';
}
return walk(window);
"""
)
return str(value or "").strip()
# 用 fdId 打开合同详情页。没有 fdId 则什么都不做。
def _open_submitted_view(browser: BrowserAdapter, fd_id: str) -> None:
doc_id = (fd_id or "").strip() or _read_document_id(browser)
if not doc_id:
print("成功页没有 fdId,无法自动打开详情", flush=True)
return
url = VIEW_PAGE_URL.format(fd_id=doc_id)
print("打开合同详情:", url, flush=True)
browser.open_url(url)
time.sleep(1.5)
# 拼 LUI 工具栏按钮的精确文本 xpath,避免 contains 点到别的按钮。
def _lui_btn_xpath(label: str) -> str:
safe = (label or "").replace("'", "")
return (
"//*[contains(@class,'lui_widget_btn_txt') and normalize-space()='%s']" % safe
)
# 点 LUI 工具栏上指定文案的按钮。脚本点不到再走 native/first。
def _click_toolbar_button(browser: BrowserAdapter, label: str) -> bool:
try:
browser.switch_to_default_content()
except Exception:
pass
clicked = browser.execute_script(_LUI_TOOLBAR_CLICK_JS, label)
print(f"toolbar {label} js={clicked}", flush=True)
if isinstance(clicked, dict) and clicked.get("ok"):
return True
selectors = [_lui_btn_xpath(label)]
try:
browser.click_native(selectors)
print(f"toolbar {label} native=ok", flush=True)
return True
except Exception as exc:
print(f"toolbar {label} native={exc}", flush=True)
try:
browser.click_first(selectors)
print(f"toolbar {label} first=ok", flush=True)
return True
except Exception as exc:
print(f"toolbar {label} first={exc}", flush=True)
return False
_LUI_TOOLBAR_FIND_JS = """
const label = String(arguments[0] || '');
function visible(el) {
const box = el.getBoundingClientRect();
return box.width > 2 && box.height > 2;
}
function textOf(el) {
return (el.textContent || '').replace(/\\s+/g, '').trim();
}
function findIn(win) {
const nodes = Array.from(win.document.querySelectorAll(
'div.lui-component.lui_widget_btn_txt, .lui_widget_btn_txt'
));
const hit = nodes.find((el) => textOf(el) === label && visible(el));
if (hit) {
return {
ok: true,
cid: hit.getAttribute('data-lui-cid') || '',
id: hit.id || '',
count: nodes.length
};
}
for (let i = 0; i < win.frames.length; i++) {
try {
const inner = findIn(win.frames[i]);
if (inner && inner.ok) return inner;
} catch (e) {}
}
return {
ok: false,
texts: nodes.map((el) => textOf(el)).filter(Boolean).slice(0, 12)
};
}
return findIn(window);
"""
_LUI_TOOLBAR_CLICK_JS = """
const label = String(arguments[0] || '');
function visible(el) {
const box = el.getBoundingClientRect();
return box.width > 2 && box.height > 2;
}
function textOf(el) {
return (el.textContent || '').replace(/\\s+/g, '').trim();
}
function fire(el, win) {
const target = el.closest('[data-lui-cid]') || el;
try { target.scrollIntoView({block: 'center', inline: 'nearest'}); } catch (e) {}
['mouseover', 'mousedown', 'mouseup', 'click'].forEach((type) => {
target.dispatchEvent(new MouseEvent(type, {
bubbles: true, cancelable: true, view: win, buttons: 1
}));
});
target.click();
}
function clickIn(win) {
const nodes = Array.from(win.document.querySelectorAll(
'div.lui-component.lui_widget_btn_txt, .lui_widget_btn_txt'
));
const hit = nodes.find((el) => textOf(el) === label && visible(el));
if (hit) {
fire(hit, win);
return {
ok: true,
via: 'btn_txt',
cid: hit.getAttribute('data-lui-cid') || '',
id: hit.id || ''
};
}
for (let i = 0; i < win.frames.length; i++) {
try {
const inner = clickIn(win.frames[i]);
if (inner && inner.ok) return inner;
} catch (e) {}
}
return {
ok: false,
label: label,
texts: nodes.map((el) => textOf(el)).filter(Boolean).slice(0, 12)
};
}
return clickIn(window);
"""
# 工具栏上是否已经出现指定文案的可见按钮。
def _lui_toolbar_button_exists(browser: BrowserAdapter, label: str) -> bool:
found = browser.execute_script(_LUI_TOOLBAR_FIND_JS, label)
ok = isinstance(found, dict) and bool(found.get("ok"))
print(f"toolbar {label} exists={found}", flush=True)
return ok
# 等到工具栏出现指定按钮。超时返回 False。
def _wait_lui_toolbar_button(
browser: BrowserAdapter, label: str, timeout_seconds: float = 10
) -> bool:
deadline = time.time() + timeout_seconds
while time.time() < deadline:
if _lui_toolbar_button_exists(browser, label):
return True
time.sleep(0.4)
return False
# 点工具栏「提交」。
def _click_top_submit(browser: BrowserAdapter) -> bool:
return _click_toolbar_button(browser, "提交")
# 判断字符串是不是正式合同号。占位符、表头、手机号不算。
def _usable_contract_no(value: str) -> bool:
text = (value or "").strip()
if not text or text in ("提交时自动生成", "自动生成", "==请选择==", "[]"):
return False
if text in ("合同标题", "合同编号", "客户编码", "客户名称", "申请时间", "合同(协议)全称"):
return False
if text.startswith("HT"):
return True
if text.isdigit() and len(text) >= 10:
return True
return any(ch.isdigit() for ch in text) and len(text) >= 8 and " " not in text
# 读第一个匹配选择器的值。元素不存在或读失败返回空字符串。
def _try_read(browser: BrowserAdapter, selectors: list[str]) -> str:
if not browser.element_exists(selectors):
return ""
try:
return (browser.read_first(selectors, "") or "").strip()
except Exception:
return ""
# 合同版本类型的验收文案:非格式合同或格式合同。
def contract_version_label(ctx: ContractFlowContext) -> str:
return "非格式合同" if ctx.template_type == "NON_FORMAT" else "格式合同"

View File

@ -1,68 +1,43 @@
"""CONTRACT_CREATE node: input building + state-center callback (runtime.shell) + Selenium drafting.""" # CONTRACT_CREATE: 入参组装 + Selenium 起草。
#
# 结果返回合同号,不回调旧状态中心。
# 合同系统账密只认 CONTRACT_USERNAME / CONTRACT_PASSWORD 或入参,缺则失败。
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import json import json
import os import os
import sys
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
from .apa_browser_adapter import ApaBrowserAdapter from apa_browser_adapter import ApaBrowserAdapter
from .callback_client import StateCenterClient from contract_draft import (
from .contract_draft import (
ContractDraft, ContractDraft,
contract_draft_from_dict, contract_draft_from_dict,
is_placeholder_customer_code,
load_contract_draft, load_contract_draft,
) )
from .contract_flow_steps import ContractFlowContext, run_contract_create_flow from contract_flow_steps import ContractFlowContext, run_contract_create_flow
from .pdf_utils import ensure_merged_pdf from errors import NodeError, PdfMergeUnsupported, UnknownRpaError
from .runtime.errors import NodeError, PdfMergeUnsupported, UnknownRpaError from pdf_utils import ensure_merged_pdf
from .runtime.shell import execute_node
NODE_CODE = "CONTRACT_CREATE"
CONTRACT_URL = "http://contract-test.gzmpc.com:8080/" CONTRACT_URL = "http://contract-test.gzmpc.com:8080/"
# 入参缺失 / 页面元素找不到 / 页面改版。
class ContractCreateError(NodeError): class ContractCreateError(NodeError):
"""入参缺失 / 页面元素找不到 / 页面改版,均映射 UI_ELEMENT_NOT_FOUND。""" pass
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)
# 返回本包 examples 下默认干跑 JSON 的路径。
def default_contract_input_path() -> str: def default_contract_input_path() -> str:
return str( return str(
Path(__file__).resolve().parent / "examples" / "contract_create_input_dry_run.json" Path(__file__).resolve().parent / "examples" / "contract_create_input_dry_run.json"
) )
def _should_bootstrap_default_input(message: str) -> bool: # 一次合同起草的入参。账密为空时由 resolve_contract_credentials 补环境变量。
if "缺少业务入参" not in str(message):
return False
script = os.path.basename(str(sys.argv[0] if sys.argv else "")).lower()
return script == "main.py"
def _bootstrap_default_input_and_exit() -> None:
path = default_contract_input_path()
if not Path(path).is_file():
return
print("未传入 -i/--input,使用默认联调入参:", path, flush=True)
codes_dir = Path(__file__).resolve().parent / "studio" / "codes"
if str(codes_dir) not in sys.path:
sys.path.insert(0, str(codes_dir))
from run_contract import main
raise SystemExit(main())
@dataclass(frozen=True) @dataclass(frozen=True)
class ContractCreateInput: class ContractCreateInput:
job_id: str job_id: str
@ -72,8 +47,8 @@ class ContractCreateInput:
contract_system_no: str | None = None contract_system_no: str | None = None
attempt_no: int = 1 attempt_no: int = 1
contract_url: str = CONTRACT_URL contract_url: str = CONTRACT_URL
username: str = "test002" username: str = ""
password: str = "1" password: str = ""
submit_mode: str = "submit" submit_mode: str = "submit"
order_no_list: tuple[str, ...] = () order_no_list: tuple[str, ...] = ()
template_name: str = "" template_name: str = ""
@ -85,20 +60,35 @@ class ContractCreateInput:
ContractRunner = Callable[[ContractCreateInput], str] ContractRunner = Callable[[ContractCreateInput], str]
# 取出合同系统账密。入参优先,否则读 CONTRACT_USERNAME / CONTRACT_PASSWORD。
#
# 用户名、密码缺一即抛 ContractCreateError。不内置测试账号。
def resolve_contract_credentials(
username: str | None = None,
password: str | None = None,
) -> tuple[str, str]:
user = (username or "").strip() or (os.getenv("CONTRACT_USERNAME") or "").strip()
if password is None or password == "":
pwd = os.getenv("CONTRACT_PASSWORD") or ""
else:
pwd = str(password)
if not user or pwd == "":
raise ContractCreateError("未配置 CONTRACT_USERNAME / CONTRACT_PASSWORD")
return user, pwd
# 按入参起草合同。dry_run 不点提交;submit 必须读到合同号。
def execute_contract_create( def execute_contract_create(
spec: ContractCreateInput, spec: ContractCreateInput,
client: StateCenterClient,
*, *,
contract_runner: ContractRunner | None = None, contract_runner: ContractRunner | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
runner = contract_runner or create_contract_in_contract_system runner = contract_runner or create_contract_in_contract_system
# dry_run:只跑到提交前,不回调状态中心(不 start/complete/fail)
if spec.submit_mode == "dry_run": if spec.submit_mode == "dry_run":
runner(spec) runner(spec)
return {"status": "dry_run", "submitted": False} return {"status": "dry_run", "submitted": False}
def _run() -> dict[str, Any]:
try: try:
contract_no = runner(spec) contract_no = runner(spec)
except NodeError: except NodeError:
@ -106,25 +96,16 @@ def execute_contract_create(
except Exception as exc: except Exception as exc:
raise UnknownRpaError(str(exc)) from exc raise UnknownRpaError(str(exc)) from exc
if not contract_no: if not contract_no:
raise ContractCreateError("submit 模式未读取到真实合同编号,无法 complete") raise ContractCreateError("submit 模式未读取到真实合同编号")
return { return {
"businessRefs": {"contractSystemNo": contract_no}, "status": "done",
"contractSystemNo": contract_no,
"summary": _build_summary(spec), "summary": _build_summary(spec),
"artifacts": [],
} }
return execute_node(
client,
spec.job_id,
NODE_CODE,
_run,
attempt_no=spec.attempt_no,
operator="agent",
)
# 在合同系统中起草合同并返回合同编号。
def create_contract_in_contract_system(spec: ContractCreateInput) -> str: def create_contract_in_contract_system(spec: ContractCreateInput) -> str:
"""在合同系统中起草合同并返回合同编号。"""
if spec.contract_system_no: if spec.contract_system_no:
return spec.contract_system_no return spec.contract_system_no
@ -138,6 +119,7 @@ def create_contract_in_contract_system(spec: ContractCreateInput) -> str:
return run_contract_create_flow(browser, ctx) return run_contract_create_flow(browser, ctx)
# 把 JSON 字典收成 ContractCreateInput,并挂上页面上下文。
def build_spec_from_data(data: dict[str, Any]) -> ContractCreateInput: def build_spec_from_data(data: dict[str, Any]) -> ContractCreateInput:
draft = contract_draft_from_dict(data) draft = contract_draft_from_dict(data)
_validate_data(data, draft) _validate_data(data, draft)
@ -150,8 +132,8 @@ def build_spec_from_data(data: dict[str, Any]) -> ContractCreateInput:
contract_system_no=_opt(data.get("contractSystemNo")), contract_system_no=_opt(data.get("contractSystemNo")),
attempt_no=int(data.get("attemptNo", 1) or 1), attempt_no=int(data.get("attemptNo", 1) or 1),
contract_url=_clean(data.get("contractUrl")) or os.getenv("CONTRACT_URL", CONTRACT_URL), contract_url=_clean(data.get("contractUrl")) or os.getenv("CONTRACT_URL", CONTRACT_URL),
username=_clean(data.get("username")) or os.getenv("CONTRACT_USERNAME", "test002"), username=_clean(data.get("username")),
password=str(data.get("password") or os.getenv("CONTRACT_PASSWORD", "1")), password="" if data.get("password") in (None, "") else str(data.get("password")),
submit_mode=draft.submit_mode, submit_mode=draft.submit_mode,
order_no_list=tuple(draft.order_no_list), order_no_list=tuple(draft.order_no_list),
template_name=draft.template_name, template_name=draft.template_name,
@ -162,12 +144,14 @@ def build_spec_from_data(data: dict[str, Any]) -> ContractCreateInput:
return replace(spec, flow_context=ctx) return replace(spec, flow_context=ctx)
# 把草稿和入参收成页面上下文。账密缺环境变量时在这里失败。
def build_flow_context(spec: ContractCreateInput, draft: ContractDraft) -> ContractFlowContext: def build_flow_context(spec: ContractCreateInput, draft: ContractDraft) -> ContractFlowContext:
merged_pdf_path = _resolve_merged_pdf(draft, spec) merged_pdf_path = _resolve_merged_pdf(draft, spec)
username, password = resolve_contract_credentials(spec.username, spec.password)
return ContractFlowContext( return ContractFlowContext(
contract_url=spec.contract_url, contract_url=spec.contract_url,
username=spec.username, username=username,
password=spec.password, password=password,
supplier_name=draft.supplier_name or spec.supplier_name, supplier_name=draft.supplier_name or spec.supplier_name,
order_date=draft.order_date or spec.order_date, order_date=draft.order_date or spec.order_date,
source_type=draft.source_type or spec.source_type, source_type=draft.source_type or spec.source_type,
@ -184,6 +168,8 @@ def build_flow_context(spec: ContractCreateInput, draft: ContractDraft) -> Contr
amount=draft.amount, amount=draft.amount,
contract_title=draft.contract_title, contract_title=draft.contract_title,
contract_end_date=draft.contract_end_date, contract_end_date=draft.contract_end_date,
contract_sign_date=draft.contract_sign_date,
contract_start_date=draft.contract_start_date,
is_major_contract=draft.is_major_contract, is_major_contract=draft.is_major_contract,
is_prepayment=draft.is_prepayment, is_prepayment=draft.is_prepayment,
belong_year=draft.belong_year, belong_year=draft.belong_year,
@ -199,6 +185,7 @@ def build_flow_context(spec: ContractCreateInput, draft: ContractDraft) -> Contr
) )
# YYC 才合并平台 PDF。dry_run 不校验不上传。
def _resolve_merged_pdf(draft: ContractDraft, spec: ContractCreateInput) -> str | None: def _resolve_merged_pdf(draft: ContractDraft, spec: ContractCreateInput) -> str | None:
if draft.source_type != "YYC": if draft.source_type != "YYC":
return None return None
@ -226,6 +213,7 @@ def _resolve_merged_pdf(draft: ContractDraft, spec: ContractCreateInput) -> str
raise PdfMergeUnsupported(f"YYC 平台合同 PDF 合并失败:{exc}") from exc raise PdfMergeUnsupported(f"YYC 平台合同 PDF 合并失败:{exc}") from exc
# 组装提交成功后的摘要字段。
def _build_summary(spec: ContractCreateInput) -> dict[str, Any]: def _build_summary(spec: ContractCreateInput) -> dict[str, Any]:
return { return {
"supplierName": spec.supplier_name, "supplierName": spec.supplier_name,
@ -238,19 +226,13 @@ def _build_summary(spec: ContractCreateInput) -> dict[str, Any]:
} }
# 检查必填字段、金额和货品行。不通过则抛 ContractCreateError。
def _validate_data(data: dict[str, Any], draft: ContractDraft) -> None: def _validate_data(data: dict[str, Any], draft: ContractDraft) -> None:
missing = [] missing = []
for key in ("jobId", "supplierName", "orderDate", "sourceType", "customerCode"): for key in ("jobId", "supplierName", "orderDate", "sourceType", "customerCode"):
if not _str(data.get(key)): if not _str(data.get(key)):
missing.append(key) missing.append(key)
customer_code = draft.customer_code or _str(data.get("customerCode"))
if customer_code and is_placeholder_customer_code(customer_code):
raise ContractCreateError(
f"customerCode「{customer_code}」是占位符,合同系统客商选择窗搜不到。"
"请换成测试环境真实存在的客商操作码,例如 DW0100046361(重庆亿昊药业有限公司)"
)
if draft.submit_mode not in ("dry_run", "submit"): if draft.submit_mode not in ("dry_run", "submit"):
raise ContractCreateError(f"submitMode 非法:{draft.submit_mode}(仅支持 dry_run / submit)") raise ContractCreateError(f"submitMode 非法:{draft.submit_mode}(仅支持 dry_run / submit)")
@ -274,6 +256,7 @@ def _validate_data(data: dict[str, Any], draft: ContractDraft) -> None:
raise ContractCreateError("缺少必填字段:" + ", ".join(missing)) raise ContractCreateError("缺少必填字段:" + ", ".join(missing))
# 取出必填字符串。缺或空白则失败。
def _require(data: dict[str, Any], key: str) -> str: def _require(data: dict[str, Any], key: str) -> str:
value = _str(data.get(key)) value = _str(data.get(key))
if not value: if not value:
@ -281,17 +264,20 @@ def _require(data: dict[str, Any], key: str) -> str:
return value return value
# 把值收成非空字符串,空白则 None。
def _opt(value: Any) -> str | None: def _opt(value: Any) -> str | None:
value = _str(value) value = _str(value)
return value or None return value or None
# 把任意值收成去两端空白的字符串。None 当空。
def _str(value: Any) -> str: def _str(value: Any) -> str:
if value is None: if value is None:
return "" return ""
return str(value).strip() return str(value).strip()
# 去掉两端空白和成对反引号。
def _clean(value: Any) -> str: def _clean(value: Any) -> str:
value = _str(value) value = _str(value)
if len(value) >= 2 and value[0] == "`" and value[-1] == "`": if len(value) >= 2 and value[0] == "`" and value[-1] == "`":
@ -299,8 +285,8 @@ def _clean(value: Any) -> str:
return value return value
# 解析入参:优先按 JSON 字符串,其次按 JSON 文件路径。
def load_input_data(raw: str) -> dict[str, Any]: def load_input_data(raw: str) -> dict[str, Any]:
"""解析入参:优先按 JSON 字符串,其次按 JSON 文件路径。"""
raw = (raw or "").strip() raw = (raw or "").strip()
if not raw: if not raw:
fallback = default_contract_input_path() fallback = default_contract_input_path()
@ -321,21 +307,15 @@ def load_input_data(raw: str) -> dict[str, Any]:
raise ContractCreateError(f"无法解析入参:既不是合法 JSON,也不是存在的文件路径:{raw}") raise ContractCreateError(f"无法解析入参:既不是合法 JSON,也不是存在的文件路径:{raw}")
def run_contract_create( # 从 JSON 字典起草合同并返回结果。
data: dict[str, Any], def run_contract_create(data: dict[str, Any]) -> dict[str, Any]:
*,
state_center_url: str,
agent_token: str,
) -> dict[str, Any]:
spec = build_spec_from_data(data) spec = build_spec_from_data(data)
client = StateCenterClient(state_center_url, agent_token) return execute_contract_create(spec)
return execute_contract_create(spec, client)
# 解析命令行。账密默认读环境变量,没有内置账号。
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run YiSaiQi CONTRACT_CREATE node.") parser = argparse.ArgumentParser(description="合同系统页面起草。")
parser.add_argument("--state-center-url", default=os.getenv("STATE_CENTER_URL", "http://127.0.0.1:8000"))
parser.add_argument("--token", default=os.getenv("AGENT_TOKEN", "dev-agent-token"))
parser.add_argument("--input-json", default=os.getenv("CONTRACT_INPUT_JSON")) parser.add_argument("--input-json", default=os.getenv("CONTRACT_INPUT_JSON"))
parser.add_argument("--job-id") parser.add_argument("--job-id")
parser.add_argument("--supplier-name") parser.add_argument("--supplier-name")
@ -344,22 +324,23 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--contract-system-no", default=os.getenv("CONTRACT_SYSTEM_NO")) parser.add_argument("--contract-system-no", default=os.getenv("CONTRACT_SYSTEM_NO"))
parser.add_argument("--attempt-no", type=int, default=1) parser.add_argument("--attempt-no", type=int, default=1)
parser.add_argument("--contract-url", default=os.getenv("CONTRACT_URL", CONTRACT_URL)) parser.add_argument("--contract-url", default=os.getenv("CONTRACT_URL", CONTRACT_URL))
parser.add_argument("--username", default=os.getenv("CONTRACT_USERNAME", "test002")) parser.add_argument("--username", default=os.getenv("CONTRACT_USERNAME"))
parser.add_argument("--password", default=os.getenv("CONTRACT_PASSWORD", "1")) parser.add_argument("--password", default=os.getenv("CONTRACT_PASSWORD"))
parser.add_argument("--draft-path", default=os.getenv("CONTRACT_DRAFT_PATH")) parser.add_argument("--draft-path", default=os.getenv("CONTRACT_DRAFT_PATH"))
parser.add_argument("--merged-pdf-output", default=os.getenv("MERGED_PDF_OUTPUT")) parser.add_argument("--merged-pdf-output", default=os.getenv("MERGED_PDF_OUTPUT"))
return parser.parse_args() return parser.parse_args()
# 命令行入口:JSON 或参数起草一份合同,打印 JSON 结果。
def main() -> int: def main() -> int:
args = parse_args() args = parse_args()
client = StateCenterClient(args.state_center_url, args.token)
if args.input_json: if args.input_json:
spec = build_spec_from_data(load_input_data(args.input_json)) spec = build_spec_from_data(load_input_data(args.input_json))
else: else:
if not (args.job_id and args.supplier_name and args.order_date): if not (args.job_id and args.supplier_name and args.order_date):
raise SystemExit("缺少 --job-id/--supplier-name/--order-date 或 --input-json") raise SystemExit("缺少 --job-id/--supplier-name/--order-date 或 --input-json")
username, password = resolve_contract_credentials(args.username, args.password)
spec = ContractCreateInput( spec = ContractCreateInput(
job_id=args.job_id, job_id=args.job_id,
supplier_name=args.supplier_name, supplier_name=args.supplier_name,
@ -368,13 +349,13 @@ def main() -> int:
contract_system_no=args.contract_system_no, contract_system_no=args.contract_system_no,
attempt_no=args.attempt_no, attempt_no=args.attempt_no,
contract_url=args.contract_url, contract_url=args.contract_url,
username=args.username, username=username,
password=args.password, password=password,
draft_path=args.draft_path, draft_path=args.draft_path,
merged_pdf_output=args.merged_pdf_output, 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)) print(json.dumps(result, ensure_ascii=False))
return 0 return 0

View File

@ -1,4 +1,5 @@
"""Contract draft input model for CONTRACT_CREATE.""" # 合同起草入参。页面流按这份草稿填客商、金额、日期和货品行。
from __future__ import annotations from __future__ import annotations
import json import json
@ -6,17 +7,10 @@ from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from .contract_flow_steps import ContractGoodsLine from contract_flow_steps import ContractGoodsLine
_PLACEHOLDER_CUSTOMER_PREFIXES = ("CUST-", "TEST-", "MOCK-")
def is_placeholder_customer_code(code: str) -> bool:
"""CUST-001 / TEST-xxx 这类占位符在合同系统里搜不到,不能拿去点真页面。"""
value = (code or "").strip().upper()
return value.startswith(_PLACEHOLDER_CUSTOMER_PREFIXES)
# 一次起草要填的字段。班次从合单来,命令行从 JSON 来。
@dataclass(frozen=True) @dataclass(frozen=True)
class ContractDraft: class ContractDraft:
supplier_name: str supplier_name: str
@ -35,6 +29,8 @@ class ContractDraft:
amount: str = "" amount: str = ""
contract_title: str = "" contract_title: str = ""
contract_end_date: str = "" contract_end_date: str = ""
contract_sign_date: str = ""
contract_start_date: str = ""
is_major_contract: str = "否" is_major_contract: str = "否"
is_prepayment: str = "否" is_prepayment: str = "否"
belong_year: str = "" belong_year: str = ""
@ -48,13 +44,16 @@ class ContractDraft:
merged_pdf_path: str | None = None merged_pdf_path: str | None = None
contract_document_path: str = "" contract_document_path: str = ""
order_no_list: list[str] = field(default_factory=list) order_no_list: list[str] = field(default_factory=list)
lookup_only: bool = False
# 从 JSON 文件读起草草稿。
def load_contract_draft(path: str | Path) -> ContractDraft: def load_contract_draft(path: str | Path) -> ContractDraft:
data = json.loads(Path(path).read_text(encoding="utf-8")) data = json.loads(Path(path).read_text(encoding="utf-8"))
return contract_draft_from_dict(data) return contract_draft_from_dict(data)
# 把 JSON 字典收成起草草稿。缺的字段用页面上的默认值。
def contract_draft_from_dict(data: dict[str, Any]) -> ContractDraft: def contract_draft_from_dict(data: dict[str, Any]) -> ContractDraft:
goods_lines = [ goods_lines = [
ContractGoodsLine( ContractGoodsLine(
@ -87,6 +86,8 @@ def contract_draft_from_dict(data: dict[str, Any]) -> ContractDraft:
amount=str(data.get("amount", "")), amount=str(data.get("amount", "")),
contract_title=str(data.get("contractTitle") or data.get("docSubject") or ""), contract_title=str(data.get("contractTitle") or data.get("docSubject") or ""),
contract_end_date=str(data.get("contractEndDate", "")), contract_end_date=str(data.get("contractEndDate", "")),
contract_sign_date=str(data.get("contractSignDate", "")),
contract_start_date=str(data.get("contractStartDate", "")),
is_major_contract=str(data.get("isMajorContract", "否")), is_major_contract=str(data.get("isMajorContract", "否")),
is_prepayment=str(data.get("isPrepayment", "否")), is_prepayment=str(data.get("isPrepayment", "否")),
belong_year=str(data.get("belongYear", "")), belong_year=str(data.get("belongYear", "")),

View File

@ -0,0 +1,193 @@
# 合同系统页面步骤:登录、起草、填表、提交、读合同号。
#
# 对外入口:job.py / run_batch.py / 单测继续从本模块 import。
# 实现按 S0x 拆在 contract_common / contract_login / contract_form /
# contract_goods / contract_upload / contract_submit / contract_number。
from __future__ import annotations
from contract_common import (
BrowserAdapter,
ContractGoodsLine,
ContractFlowContext,
_ACCEPT,
_expected_title,
DRAFT_PAGE_URL,
TEMPLATE_ID,
FORM_PAGE_URL,
VIEW_PAGE_URL,
EDIT_PAGE_URL,
F,
X,
_YES_NO,
_CATEGORY_ALIASES,
_PAYMENT_ALIASES,
_yes_no,
_category_option,
_payment_option,
GOODS_FIELD_SUFFIXES,
ADD_ROW_SELECTORS,
GOODS_PICKER,
CUSTOMER_PICKER,
_PORTAL_MARKERS,
_is_logged_in,
sign_date_for,
begin_date_for,
_close_blocking_dialogs,
_current_url,
_read_document_id,
_open_submitted_view,
_lui_btn_xpath,
_click_toolbar_button,
_LUI_TOOLBAR_FIND_JS,
_LUI_TOOLBAR_CLICK_JS,
_lui_toolbar_button_exists,
_wait_lui_toolbar_button,
_click_top_submit,
_usable_contract_no,
_try_read,
contract_version_label,
)
from contract_login import (
open_contract_system,
login_contract_system,
enter_contract_create,
select_contract_template,
)
from contract_form import (
fill_contract_signatory_info,
_wait_customer_name,
_pick_customer_from_dialog,
fill_contract_basic_info,
_fill_contract_title,
_select_cover_template_if_needed,
_read_xform_label,
_select_settlement_type,
check_customer_diligence,
fill_contract_amount_and_dates,
fill_contract_payment_info,
_fill_concession_defaults,
)
from contract_goods import (
fill_contract_goods_lines,
_goods_search_keyword,
_looks_like_goods_opcode,
_picker_rows_dump,
_pick_goods_from_dialog,
_select_goods_in_picker,
_search_goods_in_dialog,
_dialog_row_has_keyword,
_select_dialog_row_by_keyword,
_scroll_to_goods_table,
_click_dialog_ok,
_as_index_list,
_next_unused_goods_row,
_find_goods_row,
_goods_table_dump,
_wait_unused_goods_row,
_empty_goods_row_indexes,
_occupied_goods_row_indexes,
_add_goods_row,
_existing_used_match,
_append_same_goods_row,
_clone_goods_identity,
_fill_goods_required_qty_price,
_delete_empty_goods_rows,
_goods_selector,
)
from contract_upload import (
_resolve_contract_body_path,
upload_contract_document_if_needed,
_upload_contract_body,
_reveal_contract_body_section,
_click_add_contract_body,
_contract_body_attached,
_on_body_editor_tab,
)
from contract_submit import (
_ERROR_MARKS,
_SUCCESS_MARKS,
_CONFIRM_MARKS,
submit_contract_for_approval,
_submit_error_from_body,
_classify_dialog_text,
_wait_submit_outcome,
_read_submit_signal,
_finish_submit_signal,
_accept_native_alert,
_submit_view_page,
_submit_success_text,
_submit_succeeded,
_submit_validation_error,
_handle_submit_dialogs,
_wait_lui_dialog_gone,
)
from contract_number import (
read_contract_number,
_read_contract_number_routed,
_read_contract_number_on_page,
_reload_contract_home,
_reopen_contract_home,
_open_my_contracts,
_click_my_contracts_sidebar,
_wait_text_in_frames,
_page_has_text,
_click_by_exact_text,
_sort_my_contracts_by_apply_time,
_scrape_latest_my_contract_number,
_pick_contract_row,
_search_my_contracts,
_scrape_my_contract_rows,
_row_has_fd_id,
_read_number_by_fd_id,
_read_contract_number_from_my_list,
)
# 按 S02–S13 串合同起草页,成功返回正式合同号。
#
# dry_run 在提交前停,返回空字符串。提交失败也返回空字符串,不抛。
# 合同号必须来自本单 fdId 对应详情。
def run_contract_create_flow(browser: BrowserAdapter, ctx: ContractFlowContext) -> str:
_ACCEPT.reset()
try:
open_contract_system(browser, ctx)
login_contract_system(browser, ctx)
enter_contract_create(browser)
select_contract_template(browser, ctx)
fill_contract_signatory_info(browser, ctx)
check_customer_diligence(browser, ctx)
fill_contract_basic_info(browser, ctx)
fill_contract_amount_and_dates(browser, ctx)
fill_contract_goods_lines(browser, ctx)
fill_contract_payment_info(browser, ctx)
upload_contract_document_if_needed(browser, ctx)
fd_id = _read_document_id(browser)
if not submit_contract_for_approval(browser, ctx, fd_id=fd_id):
if ctx.submit_mode == "dry_run":
_ACCEPT.note("S13", "读合同号", {"contractNo": "(dry_run)", "mode": "dry_run"}, ok=True)
return ""
_ACCEPT.note("S13", "读合同号", {"contractNo": "(none)", "mode": ctx.submit_mode}, ok=False)
return ""
fd_id = fd_id or _read_document_id(browser)
contract_no, via = _read_contract_number_routed(browser, ctx, fd_id=fd_id)
_ACCEPT.note(
"S13",
"读合同号",
{
"contractNo": contract_no or "(none)",
"via": via,
"fdId": fd_id or "(none)",
},
ok=_usable_contract_no(contract_no),
)
return contract_no
finally:
print(_ACCEPT.summary_text(), end="", flush=True)
_ACCEPT.dump()

View File

@ -0,0 +1,294 @@
# 合同 S06–S08、S10:签约方、尽调、基础信息、金额日期、付款。
#
# 客商必须是合同系统真实操作码。正文含「未尽调」则本单失败,不提交。
from __future__ import annotations
import time
from accept import evidence_shot
from errors import DiligenceError, NodeError, SupplierContractDisabled
from contract_common import (
CUSTOMER_PICKER,
F,
X,
BrowserAdapter,
ContractFlowContext,
_ACCEPT,
_category_option,
_expected_title,
_payment_option,
_try_read,
_yes_no,
begin_date_for,
sign_date_for,
)
# 填签约方(S06):按客商操作码在弹窗里选客商并校验名称。
def fill_contract_signatory_info(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
expected_name = (ctx.customer_name or ctx.supplier_name).strip()
if not ctx.customer_code:
raise SupplierContractDisabled("客商操作码(customerCode)为空,无法加载客商")
# 点击客户名称字段旁的「选择」按钮打开客商选择窗口,按编码搜索并带回客商
browser.click_first(CUSTOMER_PICKER["trigger"])
_pick_customer_from_dialog(browser, ctx.customer_code)
loaded_name = _wait_customer_name(browser, timeout=8)
if not loaded_name:
evidence_shot(browser, "contract-customer-load-failed", failed=True)
raise SupplierContractDisabled(
f"客商加载失败,请确认输入的客商操作码是否有误:{ctx.customer_code}"
)
if expected_name and loaded_name != expected_name:
evidence_shot(browser, "contract-customer-name-mismatch", failed=True)
raise SupplierContractDisabled(
f"客商名称校验不通过:期望「{expected_name}」,实际「{loaded_name}」"
)
evidence_shot(browser, "contract-signatory-info-filled")
loaded_code = _try_read(browser, [F["customer_code"]])
_ACCEPT.check(
"S06",
"签约方",
[
("customerName", loaded_name, expected_name),
("customerCode", loaded_code, ctx.customer_code),
],
browser=browser,
screenshot_on_fail="contract-customer-name-mismatch",
)
# 等客户名称回填。加载失败文案出现时返回空字符串。
def _wait_customer_name(browser: BrowserAdapter, timeout: int) -> str:
deadline = time.time() + timeout
while time.time() < deadline:
body = browser.read_body_text()
if "自动加载客商失败" in body or "客商操作码是否有误" in body:
return ""
name = _try_read(browser, [F["customer_name"]])
if name and name not in ("==请选择==", "请选择"):
return name
time.sleep(0.5)
return ""
# 在客商选择 iframe 里按操作码搜并勾中对应行。
def _pick_customer_from_dialog(browser: BrowserAdapter, code: str) -> None:
# 客商选择窗口是一个 iframe 弹窗:切入后搜索编码 → 按编码点对应行 → 切回主文档。
browser.wait_for_element(CUSTOMER_PICKER["frame"], timeout_seconds=10)
browser.switch_to_frame(CUSTOMER_PICKER["frame"])
try:
browser.fill_first(CUSTOMER_PICKER["search_input"], code)
browser.click_first(CUSTOMER_PICKER["search_button"])
time.sleep(1.5)
browser.wait_for_element(CUSTOMER_PICKER["result_checkbox"], timeout_seconds=10)
picked = browser.execute_script(
"""
const code = String(arguments[0] || '');
const rows = Array.from(document.querySelectorAll('tr'));
for (const row of rows) {
const text = (row.innerText || '');
if (text.indexOf(code) < 0) continue;
const box = row.querySelector('input[name="List_Selected"]');
if (box) { box.click(); return {ok: true, text: text.replace(/\\s+/g, ' ').slice(0, 80)}; }
}
return {ok: false};
""",
code,
)
if not (isinstance(picked, dict) and picked.get("ok")):
browser.click_first(CUSTOMER_PICKER["result_checkbox"])
time.sleep(1)
finally:
browser.switch_to_default_content()
# 填基础信息(S07):类别、封面模板、结算、背景、标题。
def fill_contract_basic_info(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
if ctx.contract_category:
browser.select_xform(X["contract_category"], _category_option(ctx.contract_category))
_select_cover_template_if_needed(browser)
if ctx.settlement_type:
_select_settlement_type(browser, ctx.settlement_type)
if ctx.contract_background:
browser.fill_first([F["contract_background"]], ctx.contract_background)
# 合同性质默认主合同(页面已勾选),仅在需要从合同时切换
if ctx.contract_nature and ctx.contract_nature != "主合同":
browser.click_first(['input[name="fdType"][value="01"]'])
_fill_contract_title(browser, ctx)
evidence_shot(browser, "contract-basic-info-filled")
_ACCEPT.check(
"S07",
"基础信息",
[
("category", _read_xform_label(browser, X["contract_category"]), _category_option(ctx.contract_category) if ctx.contract_category else ""),
("settlement", _try_read(browser, [F["settlement_type"]]), ctx.settlement_type),
("background", _try_read(browser, [F["contract_background"]]), ctx.contract_background),
("title", _try_read(browser, [F["doc_subject"]]), _expected_title(ctx)),
],
browser=browser,
screenshot_on_fail="contract-basic-info-filled",
)
# 把合同标题写入 docSubject。空则按客商+类别+订单日拼。
def _fill_contract_title(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
title = (ctx.contract_title or "").strip()
if not title:
who = (ctx.customer_name or ctx.supplier_name or "").strip()
cate = (ctx.contract_category or "采购协议").strip()
date = (ctx.order_date or "").replace("-", "")
title = f"{who}{cate}{date}"
if title:
browser.fill_first([F["doc_subject"]], title)
# 封面模板未选时优先选「否」,避免原合同申请编号变必填。
def _select_cover_template_if_needed(browser: BrowserAdapter) -> None:
# 常用模板/已审核版本:优先选「否」,避免连带「原合同申请编号」变必填
current = _read_xform_label(browser, X["cover_template"])
if current and current not in ("==请选择==", "请选择"):
return
picked = browser.execute_script(
"""
const name = arguments[0];
const flag = document.querySelector('xformflag[property="' + name + '"]');
if (!flag) return '';
const opts = Array.from(flag.querySelectorAll('.fs-option')).map((o) => ({
value: (o.getAttribute('data-value') || '').trim(),
text: ((o.querySelector('.fs-option-label') || o).textContent || '').trim(),
})).filter((o) => o.value || (o.text && o.text.indexOf('请选择') < 0));
const preferNo = opts.find((o) => o.text === '否' || o.value === '2' || o.value === '0');
const hit = preferNo || opts[0];
return hit ? (hit.text || hit.value) : '';
""",
X["cover_template"],
)
if picked:
browser.select_xform(X["cover_template"], picked)
# 读 xform fancy-select 当前可见标签。读不到返回空字符串。
def _read_xform_label(browser: BrowserAdapter, name: str) -> str:
try:
return (
browser.execute_script(
"""
const flag = document.querySelector('xformflag[property="' + arguments[0] + '"]');
return flag ? ((flag.querySelector('.fs-label') || {}).textContent || '').trim() : '';
""",
name,
)
or ""
).strip()
except Exception:
return ""
# 选结算类型。原生 select 未中则按可见文本点 LUI 下拉。
def _select_settlement_type(browser: BrowserAdapter, value: str) -> None:
browser.select_first([F["settlement_type"]], value)
selected = _try_read(browser, [F["settlement_type"]])
if not selected or selected in ("==请选择==", "请选择"):
# LUI 自定义下拉兜底:点击展开后按可见文本选择
browser.click_first([F["settlement_type"]])
browser.click_first([f"//option[contains(text(),'{value}')]", f"text={value}"])
selected = _try_read(browser, [F["settlement_type"]])
if not selected or selected in ("==请选择==", "请选择"):
raise NodeError(f"结算方式下拉未选中,仍停留在「==请选择==」,期望「{value}」")
# 尽调闸门。页面正文含「未尽调」则失败,不提交。
def check_customer_diligence(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
text = browser.read_body_text() or ""
if "未尽调" in text:
raise DiligenceError("客户未尽调")
# 填金额与签订日、开始日、结束日(S08)。
def fill_contract_amount_and_dates(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
amount_type = (ctx.amount_type or "合同标的金额").strip()
if amount_type:
browser.select_xform(X["amount_type"], amount_type)
if ctx.amount:
browser.fill_first([F["amount"]], ctx.amount)
sign_date = sign_date_for(ctx)
begin_date = begin_date_for(ctx)
browser.fill_first([F["sign_date"]], sign_date)
browser.fill_first([F["begin_date"]], begin_date)
if ctx.contract_end_date:
browser.fill_first([F["end_date"]], ctx.contract_end_date)
evidence_shot(browser, "contract-amount-and-dates-filled")
_ACCEPT.check(
"S08",
"金额日期",
[
("amount", _try_read(browser, [F["amount"]]), ctx.amount),
("signDate", _try_read(browser, [F["sign_date"]]), sign_date),
("beginDate", _try_read(browser, [F["begin_date"]]), begin_date),
("endDate", _try_read(browser, [F["end_date"]]), ctx.contract_end_date),
],
browser=browser,
screenshot_on_fail="contract-amount-and-dates-filled",
)
# 填付款与标志(S10)。这期一律账期,不勾预付款。
def fill_contract_payment_info(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
browser.select_xform(X["is_major_contract"], _yes_no(ctx.is_major_contract))
browser.select_xform(X["is_prepayment"], _yes_no(ctx.is_prepayment))
if ctx.belong_year:
browser.fill_first([F["belong_year"]], ctx.belong_year)
if ctx.payment_method:
browser.select_xform(X["payment_method"], _payment_option(ctx.payment_method))
if ctx.payment_days:
browser.fill_first([F["payment_days"]], ctx.payment_days)
if ctx.contract_status:
browser.select_xform(X["contract_status"], ctx.contract_status)
if ctx.contains_blood_products:
browser.select_xform(X["contains_blood"], _yes_no(ctx.contains_blood_products))
_fill_concession_defaults(browser)
evidence_shot(browser, "contract-payment-filled")
_ACCEPT.check(
"S10",
"付款信息",
[
("major", _read_xform_label(browser, X["is_major_contract"]), _yes_no(ctx.is_major_contract)),
("prepay", _read_xform_label(browser, X["is_prepayment"]), _yes_no(ctx.is_prepayment)),
("year", _try_read(browser, [F["belong_year"]]), ctx.belong_year),
("payMethod", _read_xform_label(browser, X["payment_method"]), _payment_option(ctx.payment_method) if ctx.payment_method else ""),
("payDays", _try_read(browser, [F["payment_days"]]), ctx.payment_days),
("status", _read_xform_label(browser, X["contract_status"]), ctx.contract_status),
],
browser=browser,
screenshot_on_fail="contract-payment-filled",
)
# 让步情形等是/否单选未填时默认点「否」,避免挡住提交。
def _fill_concession_defaults(browser: BrowserAdapter) -> None:
# 让步情形等是/否单选未填会挡住提交;默认点「否」
browser.execute_script(
"""
const clicked = [];
document.querySelectorAll('tr').forEach((tr) => {
const radios = Array.from(tr.querySelectorAll('input[type="radio"]'));
if (radios.length < 2) return;
const labeled = radios.map((r) => ({
el: r,
text: ((r.closest('label') || r.parentElement || {}).textContent || '')
.replace(/\\s+/g, '').trim(),
}));
if (!labeled.some((x) => x.text === '是') || !labeled.some((x) => x.text === '否')) return;
if (radios.some((r) => r.checked)) return;
const no = labeled.find((x) => x.text === '否');
if (no) { no.el.click(); clicked.push(radios[0].name); }
});
return clicked;
"""
)

View File

@ -0,0 +1,605 @@
# 合同 S09:货品明细。弹窗按操作码搜,找不到则本单失败。
#
# 同品种多行时先克隆身份再填数量单价,不把两行压进同一格。
from __future__ import annotations
import time
from accept import evidence_shot
from errors import NodeError
from contract_common import (
ADD_ROW_SELECTORS,
GOODS_FIELD_SUFFIXES,
GOODS_PICKER,
BrowserAdapter,
ContractFlowContext,
ContractGoodsLine,
_ACCEPT,
_try_read,
)
# 填货品明细(S09)。弹窗按操作码搜,找不到则本单失败。
def fill_contract_goods_lines(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
if not ctx.goods_lines:
evidence_shot(browser, "contract-goods-lines-filled")
_ACCEPT.note("S09", "货品明细", {"lines": 0}, ok=True)
return
used: list[int] = []
assigned: list[int] = []
for line in ctx.goods_lines:
row = _find_goods_row(browser, line, skip=used)
if row < 0:
same = _existing_used_match(browser, line, used)
if same >= 0:
row = _append_same_goods_row(browser, same)
else:
if used and not _empty_goods_row_indexes(browser):
_add_goods_row(browser)
row = _pick_goods_from_dialog(browser, line, skip=used)
_fill_goods_required_qty_price(browser, line, row)
used.append(row)
assigned.append(row)
_delete_empty_goods_rows(browser)
used = []
assigned = []
for line in ctx.goods_lines:
row = _find_goods_row(browser, line, skip=used)
assigned.append(row)
if row >= 0:
used.append(row)
evidence_shot(browser, "contract-goods-lines-filled")
for index, (line, row) in enumerate(zip(ctx.goods_lines, assigned, strict=True)):
opcode = _try_read(browser, [_goods_selector("goods_code", row)]) if row >= 0 else ""
mdm = _try_read(browser, [_goods_selector("goods_mdm", row)]) if row >= 0 else ""
qty = _try_read(browser, [_goods_selector("goods_qty", row)]) if row >= 0 else ""
price = _try_read(browser, [_goods_selector("goods_price", row)]) if row >= 0 else ""
if line.mdm_code:
code_pair = ("goodsMdm", mdm or opcode, line.mdm_code)
else:
code_pair = ("goodsCode", opcode, line.goods_code)
_ACCEPT.check(
"S09",
f"货品明细#{index + 1}",
[
code_pair,
("qty", qty, line.quantity),
("price", price, line.unit_price or "1.00"),
],
browser=browser,
screenshot_on_fail="contract-goods-pick-failed",
)
# 货品弹窗搜索关键字:MDM、操作码、名称按这个顺序取第一个非空。
def _goods_search_keyword(line: ContractGoodsLine) -> str:
return (line.mdm_code or line.goods_code or line.goods_name or "").strip()
# 判断关键字是不是操作码形态(FQX/QX 开头且含数字)。
def _looks_like_goods_opcode(keyword: str) -> bool:
compact = (keyword or "").replace(" ", "").upper()
return compact.startswith(("FQX", "QX")) and any(ch.isdigit() for ch in compact)
# 把商品选择弹窗当前可见行收成短文本,失败时写进报错。
def _picker_rows_dump(browser: BrowserAdapter) -> str:
dump = browser.execute_script(
"""
return Array.from(document.querySelectorAll('tr')).map((row) => {
const text = (row.innerText || '').replace(/\\s+/g, ' ').trim();
const box = row.querySelector(
'input[name="List_Selected"], input[type="checkbox"], input[type="radio"]'
);
if (!box || !text) return '';
return text.slice(0, 80);
}).filter(Boolean).slice(0, 8).join('|');
"""
)
return str(dump or "")
# 打开选择商品弹窗搜一行货,返回明细表里新占用的行号。
def _pick_goods_from_dialog(
browser: BrowserAdapter, line: ContractGoodsLine, skip: list[int] | tuple[int, ...] = ()
) -> int:
keyword = _goods_search_keyword(line)
if not keyword:
raise NodeError("货品缺少 goodsMdmCode/goodsCode,无法在「选择商品」中搜索")
_select_goods_in_picker(browser, line, keyword)
row = _wait_unused_goods_row(browser, line, skip, timeout=12)
if row >= 0:
return row
_add_goods_row(browser)
_select_goods_in_picker(browser, line, keyword)
row = _wait_unused_goods_row(browser, line, skip, timeout=12)
if row >= 0:
return row
_scroll_to_goods_table(browser)
evidence_shot(browser, "contract-goods-pick-failed", failed=True)
dump = _goods_table_dump(browser)
raise NodeError(f"选择商品后未在明细表找到新的一行「{keyword}」 dump={dump}")
# 在选择商品 iframe 里搜关键字并勾中对应行,然后点确定。
def _select_goods_in_picker(browser: BrowserAdapter, line: ContractGoodsLine, keyword: str) -> None:
browser.click_first(GOODS_PICKER["trigger"])
browser.wait_for_element(GOODS_PICKER["frame"], timeout_seconds=15)
browser.switch_to_frame(GOODS_PICKER["frame"])
try:
_search_goods_in_dialog(browser, keyword)
if not _dialog_row_has_keyword(browser, keyword):
_search_goods_in_dialog(browser, keyword)
picked = _select_dialog_row_by_keyword(browser, keyword)
if not picked and line.goods_name and not _looks_like_goods_opcode(keyword):
_search_goods_in_dialog(browser, line.goods_name.strip())
picked = _select_dialog_row_by_keyword(browser, keyword)
if not picked:
picked = _select_dialog_row_by_keyword(browser, line.goods_name.strip())
if not picked:
dump = _picker_rows_dump(browser)
raise NodeError(f"选择商品失败,未找到「{keyword}」 dump={dump}")
time.sleep(0.5)
evidence_shot(browser, "contract-goods-picker")
except Exception as exc:
browser.switch_to_default_content()
_scroll_to_goods_table(browser)
evidence_shot(browser, "contract-goods-pick-failed", failed=True)
raise NodeError(f"选择商品失败,未找到「{keyword}」:{exc}") from exc
browser.switch_to_default_content()
_click_dialog_ok(browser)
# 在商品弹窗搜索框填关键字并点搜索,等到出现含关键字的行。
def _search_goods_in_dialog(browser: BrowserAdapter, keyword: str) -> None:
browser.fill_first(GOODS_PICKER["search_input"], keyword)
browser.execute_script(
"""
const el = document.querySelector('input.search-box-text, input[placeholder*="关键字"]');
if (!el) return false;
el.dispatchEvent(new KeyboardEvent('keydown', {key:'Enter', code:'Enter', keyCode:13, which:13, bubbles:true}));
el.dispatchEvent(new KeyboardEvent('keyup', {key:'Enter', code:'Enter', keyCode:13, which:13, bubbles:true}));
if (el.form) { el.form.dispatchEvent(new Event('submit', {bubbles:true, cancelable:true})); }
return true;
"""
)
try:
browser.click_native(GOODS_PICKER["search_button"])
except Exception:
try:
browser.click_first(GOODS_PICKER["search_button"])
except Exception:
pass
deadline = time.time() + 6
while time.time() < deadline:
if _dialog_row_has_keyword(browser, keyword):
return
time.sleep(0.4)
# 商品弹窗表格里是否已有含该关键字且带勾选框的行。
def _dialog_row_has_keyword(browser: BrowserAdapter, keyword: str) -> bool:
result = browser.execute_script(
"""
const keyword = String(arguments[0] || '').replace(/\\s+/g, '');
if (!keyword) return false;
return Array.from(document.querySelectorAll('tr')).some((row) => {
const text = (row.innerText || '').replace(/\\s+/g, '');
return text.indexOf(keyword) >= 0 && !!row.querySelector(
'input[name="List_Selected"], input[type="checkbox"], input[type="radio"]'
);
});
""",
keyword,
)
return result is True
# 按关键字勾中商品弹窗里对应行。勾到返回 True。
def _select_dialog_row_by_keyword(browser: BrowserAdapter, keyword: str) -> bool:
picked = browser.execute_script(
"""
const keyword = String(arguments[0] || '').replace(/\\s+/g, '');
if (!keyword) return {ok: false};
const rows = Array.from(document.querySelectorAll('tr'));
for (const row of rows) {
const text = (row.innerText || '').replace(/\\s+/g, '');
if (text.indexOf(keyword) < 0) continue;
const box = row.querySelector(
'input[name="List_Selected"], input[type="checkbox"], input[type="radio"]'
);
if (box) {
if (!box.checked) { box.click(); }
return {ok: true, text: (row.innerText || '').replace(/\\s+/g, ' ').slice(0, 80)};
}
row.click();
return {ok: true, text: 'row-click'};
}
return {ok: false, rows: rows.length};
""",
keyword,
)
return isinstance(picked, dict) and bool(picked.get("ok"))
# 把「选择商品」滚进视口,方便后续点添加行。
def _scroll_to_goods_table(browser: BrowserAdapter) -> None:
try:
browser.execute_script(
"""
const hit = document.querySelector('div[title="选择商品"]')
|| Array.from(document.querySelectorAll('div, a, span, button'))
.find((el) => (el.textContent || '').trim() === '选择商品');
if (hit) hit.scrollIntoView({block: 'center'});
"""
)
time.sleep(0.3)
except Exception:
pass
# 点商品弹窗的「确定」。原生点不到就走脚本。
def _click_dialog_ok(browser: BrowserAdapter) -> None:
try:
browser.click_native(
[
"//*[contains(@class,'lui_dialog')]//*[normalize-space()='确定']",
".lui_dialog_main div.lui_widget_btn_txt",
"text=确定",
]
)
return
except Exception:
pass
result = browser.execute_script(
"""
const root = document.querySelector('.lui_dialog_main') || document;
const btns = Array.from(root.querySelectorAll('div, a, span, button')).filter((el) => {
const text = (el.textContent || '').trim();
return text === '确定' && el.children.length === 0;
});
if (!btns.length) return {ok: false};
btns[btns.length - 1].click();
return {ok: true};
"""
)
if isinstance(result, dict) and result.get("ok"):
return
browser.click_first([".lui_dialog_main div.lui_widget_btn_txt", "text=确定"])
# 把脚本返回的行号收成 int 列表。字符串按逗号拆。
def _as_index_list(value) -> list[int]:
if value is None or value is False:
return []
if isinstance(value, bool):
return []
if isinstance(value, (int, float)):
return [int(value)]
if isinstance(value, str):
parts = [part.strip() for part in value.replace(";", ",").split(",") if part.strip()]
out: list[int] = []
for part in parts:
try:
out.append(int(float(part)))
except ValueError:
continue
return out
try:
seq = list(value)
except TypeError:
return []
out: list[int] = []
for item in seq:
try:
out.append(int(item))
except (TypeError, ValueError):
continue
return out
# 从匹配行号里取第一个不在 skip 里的。没有则返回 -1。
def _next_unused_goods_row(matches: list[int], skip: list[int] | tuple[int, ...] = ()) -> int:
used = {int(i) for i in skip}
for row in matches:
if row not in used:
return row
return -1
# 在明细表里按 MDM/操作码找尚未占用的行。找不到返回 -1。
def _find_goods_row(
browser: BrowserAdapter, line: ContractGoodsLine, skip: list[int] | tuple[int, ...] = ()
) -> int:
row = browser.execute_script(
"""
const keys = [String(arguments[0] || '').trim(), String(arguments[1] || '').trim()].filter(Boolean);
const skip = {};
String(arguments[2] || '').split(',').forEach((part) => {
if (part !== '') skip[Number(part)] = true;
});
for (let i = 0; i < 30; i++) {
if (skip[i]) continue;
const fields = ['fd_opcode', 'fd_mdmCode'];
const values = fields.map((suffix) => {
const el = document.querySelector(
'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.' + suffix + ')"]'
);
return el ? String(el.value || '').trim() : '';
}).filter(Boolean);
if (!values.length) continue;
for (const keyword of keys) {
if (values.some((v) => v === keyword)) {
return i;
}
}
}
return -1;
""",
line.mdm_code or line.goods_code,
line.goods_name,
",".join(str(int(i)) for i in skip),
)
try:
return int(row)
except (TypeError, ValueError):
return -1
# 把前 10 行明细的 MDM/数量/单价收成短文本,失败时写进报错。
def _goods_table_dump(browser: BrowserAdapter) -> str:
dump = browser.execute_script(
"""
const out = [];
for (let i = 0; i < 10; i++) {
const mdm = document.querySelector(
'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_mdmCode)"]'
);
const qty = document.querySelector(
'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_goodsqty)"]'
);
const price = document.querySelector(
'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_unitprice)"]'
);
if (!mdm && !qty && !price) continue;
out.push(
String(i) + ':' + (mdm ? String(mdm.value || '') : '') +
':' + (qty ? String(qty.value || '') : '') +
':' + (price ? String(price.value || '') : '')
);
}
return out.join('|');
"""
)
return str(dump or "")
# 等到明细表出现该货的新行。超时返回 -1。
def _wait_unused_goods_row(
browser: BrowserAdapter,
line: ContractGoodsLine,
skip: list[int] | tuple[int, ...],
timeout: float,
) -> int:
deadline = time.time() + timeout
while True:
row = _find_goods_row(browser, line, skip=skip)
if row >= 0:
return row
if time.time() >= deadline:
return -1
_click_dialog_ok(browser)
time.sleep(0.6)
# 返回明细表里身份字段都空的行号。
def _empty_goods_row_indexes(browser: BrowserAdapter) -> list[int]:
rows = browser.execute_script(
"""
const found = [];
for (let i = 0; i < 30; i++) {
const code = document.querySelector(
'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_opcode)"]'
);
const name = document.querySelector(
'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_goodsname)"]'
);
const mdm = document.querySelector(
'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_mdmCode)"]'
);
if (!code && !name && !mdm) continue;
const empty = !(code && String(code.value || '').trim())
&& !(name && String(name.value || '').trim())
&& !(mdm && String(mdm.value || '').trim());
if (empty) found.push(i);
}
return found.join(',');
"""
)
return _as_index_list(rows)
# 返回明细表里已填 MDM 或操作码的行号。
def _occupied_goods_row_indexes(browser: BrowserAdapter) -> list[int]:
rows = browser.execute_script(
"""
const found = [];
for (let i = 0; i < 30; i++) {
const mdm = document.querySelector(
'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_mdmCode)"]'
);
const code = document.querySelector(
'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_opcode)"]'
);
const filled = (mdm && String(mdm.value || '').trim())
|| (code && String(code.value || '').trim());
if (filled) found.push(i);
}
return found.join(',');
"""
)
return _as_index_list(rows)
# 点「添加行」或调 DocList_AddRow,给同品种再占一行。
def _add_goods_row(browser: BrowserAdapter) -> None:
before = _occupied_goods_row_indexes(browser)
_scroll_to_goods_table(browser)
result = browser.execute_script(
"""
const wanted = ['添加行', '新增明细', '新增行'];
const nodes = Array.from(document.querySelectorAll('a, div, span, button, input'));
for (const el of nodes) {
const text = (el.value || el.textContent || '').replace(/\\s+/g, '').trim();
if (!wanted.includes(text)) continue;
if (el.children && el.children.length > 2) continue;
const rect = el.getBoundingClientRect();
if (rect.width < 2 || rect.height < 2) continue;
el.click();
return {ok: true, via: text};
}
const tables = Array.from(document.querySelectorAll('table[id]')).map((t) => t.id);
if (typeof DocList_AddRow === 'function') {
const hit = tables.find((id) =>
id.toLowerCase().indexOf('detail') >= 0 || id.indexOf('DocList') >= 0
);
if (hit) {
DocList_AddRow(hit);
return {ok: true, via: 'DocList:' + hit};
}
DocList_AddRow();
return {ok: true, via: 'DocList()'};
}
return {ok: false, tables: tables.slice(0, 8).join(',')};
"""
)
deadline = time.time() + 5
while time.time() < deadline:
if _empty_goods_row_indexes(browser):
return
if len(_occupied_goods_row_indexes(browser)) > len(before):
return
time.sleep(0.3)
if isinstance(result, dict) and not result.get("ok"):
raise NodeError(f"同品种需要新的明细行,未找到「添加行」 tables={result.get('tables')}")
try:
browser.click_native(ADD_ROW_SELECTORS)
except Exception:
browser.click_first(ADD_ROW_SELECTORS)
time.sleep(0.5)
if _empty_goods_row_indexes(browser):
return
dump = _goods_table_dump(browser)
raise NodeError(f"同品种需要新的明细行,点击「添加行」后没有空行 dump={dump}")
# 在已经填过的行里找同一货品,供同品种追加新行时克隆身份。
def _existing_used_match(
browser: BrowserAdapter, line: ContractGoodsLine, used: list[int]
) -> int:
key = (line.mdm_code or line.goods_code or "").strip()
name = (line.goods_name or "").strip()
for row in used:
blob = " ".join(
value
for value in (
_try_read(browser, [_goods_selector("goods_mdm", row)]),
_try_read(browser, [_goods_selector("goods_code", row)]),
_try_read(browser, [_goods_selector("goods_name", row)]),
)
if value
)
if key and key in blob:
return row
if name and name in blob:
return row
return -1
# 给已有货品克隆一行空明细,返回新行号。
def _append_same_goods_row(browser: BrowserAdapter, src_row: int) -> int:
empties = _empty_goods_row_indexes(browser)
if not empties:
_add_goods_row(browser)
empties = _empty_goods_row_indexes(browser)
if not empties:
dump = _goods_table_dump(browser)
raise NodeError(f"同品种需要新的明细行,添加行后仍没有空行 dump={dump}")
dest = empties[0]
_clone_goods_identity(browser, src_row, dest)
return dest
# 把源行的操作码、名称、MDM、批准文号、规格、单位拷到目标行。
def _clone_goods_identity(browser: BrowserAdapter, src_row: int, dest_row: int) -> None:
browser.execute_script(
"""
const src = Number(arguments[0]);
const dest = Number(arguments[1]);
const fields = [
'fd_opcode', 'fd_goodsname', 'fd_mdmCode', 'fd_registdocno',
'fd_goodstype', 'fd_goodsunit'
];
for (const field of fields) {
const from = document.querySelector(
'input[name="extendDataFormInfo.value(fd_detail1.' + src + '.' + field + ')"]'
);
const to = document.querySelector(
'input[name="extendDataFormInfo.value(fd_detail1.' + dest + '.' + field + ')"]'
);
if (!from || !to) continue;
to.value = from.value;
to.dispatchEvent(new Event('input', {bubbles: true}));
to.dispatchEvent(new Event('change', {bubbles: true}));
}
""",
src_row,
dest_row,
)
# 往指定明细行填数量和单价。单价空则写 1.00。
def _fill_goods_required_qty_price(browser: BrowserAdapter, line: ContractGoodsLine, row: int) -> None:
if row < 0:
raise NodeError(f"选择商品后未在明细表找到「{line.goods_code or line.goods_name}」")
qty = str(line.quantity if line.quantity else "")
if qty:
browser.fill_first([_goods_selector("goods_qty", row)], qty)
price = (line.unit_price or "").strip() or "1.00"
browser.fill_first([_goods_selector("goods_price", row)], price)
# 勾掉身份为空的明细行并点删除行。
def _delete_empty_goods_rows(browser: BrowserAdapter) -> None:
marked = browser.execute_script(
"""
const marked = [];
for (let i = 0; i < 30; i++) {
const code = document.querySelector('input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_opcode)"]');
const name = document.querySelector('input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_goodsname)"]');
if (!code && !name) continue;
const empty = !(code && String(code.value || '').trim()) && !(name && String(name.value || '').trim());
if (!empty) continue;
const row = (code || name).closest('tr');
const box = row ? row.querySelector('input[type="checkbox"]') : null;
if (box && !box.checked) { box.click(); }
marked.push(i);
}
return marked;
"""
)
if marked:
try:
browser.click_first(["text=删除行", "a:contains(删除行)"])
time.sleep(0.5)
except Exception:
pass
# 拼第 row_index 行某个货品字段的 input name 选择器。
def _goods_selector(field_key: str, row_index: int) -> str:
suffix = GOODS_FIELD_SUFFIXES[field_key]
return f'input[name="extendDataFormInfo.value(fd_detail1.{row_index}.{suffix})"]'

View File

@ -0,0 +1,91 @@
# 合同 S02–S05:打开系统、登录、直达起草表单。
#
# 不点 SPA 菜单。登录后走 FORM_PAGE_URL。
from __future__ import annotations
import time
from accept import evidence_shot
from errors import LoginFailed
from contract_common import (
FORM_PAGE_URL,
BrowserAdapter,
ContractFlowContext,
_ACCEPT,
_is_logged_in,
contract_version_label,
)
# 打开合同系统首页(S02)。
def open_contract_system(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
browser.open_url(ctx.contract_url)
_ACCEPT.note("S02", "打开合同系统", {"url": ctx.contract_url}, ok=True)
# 登录合同系统(S03)。已登录则跳过填表。
def login_contract_system(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
try:
if _is_logged_in(browser):
evidence_shot(browser, "contract-already-logged-in")
_ACCEPT.note("S03", "登录", {"user": ctx.username, "via": "already"}, ok=True)
return
browser.wait_for_element(
['input[name="j_username"]', 'input[placeholder="用户名"]'],
timeout_seconds=15,
)
browser.fill_first(['input[name="j_username"]', 'input[placeholder="用户名"]'], ctx.username)
browser.fill_first(['input[name="j_password"]', 'input[placeholder="密码"]'], ctx.password)
browser.click_first(['a[class*="login_button"]', 'a:contains("登录")', 'input[name="btn_submit"]'])
deadline = time.time() + 20
while time.time() < deadline:
if _is_logged_in(browser):
evidence_shot(browser, "contract-login-ok")
_ACCEPT.note("S03", "登录", {"user": ctx.username, "portal": "yes"}, ok=True)
return
time.sleep(0.5)
raise TimeoutError("登录后未进入门户(未看到切换门户/待办/待阅)")
except Exception as exc:
evidence_shot(browser, "contract-login-failed", failed=True)
_ACCEPT.note("S03", "登录", {"user": ctx.username, "error": exc}, ok=False, raise_error=False)
raise LoginFailed(f"合同系统登录失败:{exc}") from exc
# 直达起草表单页(S04),不点 SPA 菜单。
def enter_contract_create(browser: BrowserAdapter) -> None:
# 登录后直接导航到起草表单页,跳过模板选择(前端委托事件 Selenium 点击无效)
reopen = getattr(browser, "reopen_url", None)
if callable(reopen):
reopen(FORM_PAGE_URL)
else:
browser.open_url(FORM_PAGE_URL)
browser.wait_for_text("客户编码", timeout_seconds=20)
on_form = "客户编码" in (browser.read_body_text() or "")
_ACCEPT.check(
"S04",
"进入起草",
[("formMarker", "客户编码" if on_form else (browser.read_body_text() or "")[:40], "客户编码")],
browser=browser,
screenshot_on_fail="contract-login-ok",
)
# 确认已在指定模板的表单页(S05)。未进表单则再导航一次。
def select_contract_template(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
# 已通过 URL 直接指定模板,本步骤仅兜底:若尚未进入表单则再次导航
if "客户编码" not in (browser.read_body_text() or ""):
browser.open_url(FORM_PAGE_URL)
browser.wait_for_text("客户编码", timeout_seconds=20)
on_form = "客户编码" in (browser.read_body_text() or "")
_ACCEPT.check(
"S05",
"选择模板",
[
("formMarker", "客户编码" if on_form else "", "客户编码"),
("templateType", contract_version_label(ctx), contract_version_label(ctx)),
],
browser=browser,
screenshot_on_fail="contract-login-ok",
)

View File

@ -0,0 +1,606 @@
# 合同 S13:按 fdId 打开详情读正式合同号。
#
# fdId 优先于标题+客户编码。禁止盲取「我的合同」最新一条。
from __future__ import annotations
import time
from contract_common import (
BrowserAdapter,
ContractFlowContext,
_expected_title,
_is_logged_in,
_open_submitted_view,
_read_document_id,
_usable_contract_no,
)
from contract_login import login_contract_system
# 读正式合同号(S13)。没有 fdId 则返回空字符串。
def read_contract_number(
browser: BrowserAdapter, ctx: ContractFlowContext | None = None, *, fd_id: str = ""
) -> str:
no, _via = _read_contract_number_routed(browser, ctx, fd_id=fd_id)
return no
# 按 fdId 打开详情读号。读不到返回空,不回列表盲取最新一条。
def _read_contract_number_routed(
browser: BrowserAdapter, ctx: ContractFlowContext | None = None, *, fd_id: str = ""
) -> tuple[str, str]:
del ctx
doc_id = (fd_id or "").strip() or _read_document_id(browser)
if not doc_id:
print("S13 没有 fdId,无法打开详情", flush=True)
return "", "none"
print(f"S13:开详情读号 fdId={doc_id}", flush=True)
no = _read_number_by_fd_id(browser, doc_id)
if no:
return no, "docNumber"
print("S13 详情未读到合同编号", flush=True)
return "", "none"
# 从详情页 #_xform_docNumber 读合同编号。占位符和手机号不算。
def _read_contract_number_on_page(browser: BrowserAdapter) -> str:
try:
browser.switch_to_default_content()
except Exception:
pass
scanned = browser.execute_script(
"""
const skip = ['提交时自动生成', '自动生成', '==请选择==', '合同编号', '合同标题'];
function usable(raw) {
const v = String(raw || '').replace(/\\s+/g, ' ').trim();
if (!v || skip.indexOf(v) >= 0) return '';
return v;
}
function fromDoc(doc) {
const node = doc.querySelector('#_xform_docNumber');
if (node) {
const typed = node.querySelector('input, textarea');
const v = usable(typed ? typed.value : (node.innerText || node.textContent || ''));
if (v) return v;
}
const input = doc.querySelector('input[name="docNumber"]');
if (input) {
const v = usable(input.value || '');
if (v) return v;
}
return '';
}
function walk(win) {
try {
const no = fromDoc(win.document);
if (no) return no;
} catch (e) {}
for (let i = 0; i < win.frames.length; i++) {
try {
const no = walk(win.frames[i]);
if (no) return no;
} catch (e) {}
}
return '';
}
return walk(window);
"""
)
if isinstance(scanned, str) and _usable_contract_no(scanned.strip()):
return scanned.strip()
if scanned:
print(f"S13 详情页扫到不可用值={scanned!r}", flush=True)
return ""
# 把当前页导航回合同系统主界面,等到出现「我的合同」。
def _reload_contract_home(browser: BrowserAdapter, home: str) -> bool:
try:
browser.switch_to_default_content()
except Exception:
pass
print("刷新回合同主界面:", home, flush=True)
try:
browser.execute_script(
"try { window.top.location.href = arguments[0]; } catch (e) { location.href = arguments[0]; } return true;",
home,
)
except Exception:
pass
try:
browser.open_url(home)
except Exception as exc:
print(f"open_url 主界面失败:{exc}", flush=True)
return _wait_text_in_frames(browser, "我的合同", timeout_seconds=12)
# 刷新主界面失败时关页重开;掉登录则再登一次。
def _reopen_contract_home(browser: BrowserAdapter, ctx: ContractFlowContext, home: str) -> bool:
print("主界面刷新失败,关闭当前页后重开", flush=True)
reopen = getattr(browser, "reopen_url", None)
if callable(reopen):
reopen(home)
else:
browser.open_url(home)
if _wait_text_in_frames(browser, "我的合同", timeout_seconds=12):
return True
if not _is_logged_in(browser):
login_contract_system(browser, ctx)
browser.open_url(home)
return _wait_text_in_frames(browser, "我的合同", timeout_seconds=12)
# 回到主界面并点开「我的合同 / 我起草的」。
def _open_my_contracts(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
home = (ctx.contract_url or "").strip() or "http://contract-test.gzmpc.com:8080/"
if not _reload_contract_home(browser, home):
_reopen_contract_home(browser, ctx, home)
if not _click_my_contracts_sidebar(browser):
print("未点到侧栏「我的合同」,关闭页面后重开", flush=True)
_reopen_contract_home(browser, ctx, home)
_click_my_contracts_sidebar(browser)
_wait_text_in_frames(browser, "我起草的", timeout_seconds=10)
_click_by_exact_text(browser, "我起草的")
_wait_text_in_frames(browser, "申请时间", timeout_seconds=10)
_wait_text_in_frames(browser, "合同编号", timeout_seconds=10)
# 点侧栏「我的合同」。点到后等到「我起草的」。
def _click_my_contracts_sidebar(browser: BrowserAdapter) -> bool:
clicked = browser.execute_script(
"""
const label = '我的合同';
function clickIn(win) {
const nodes = Array.from(win.document.querySelectorAll('a, span, div, li, p, em, td'));
const hits = nodes.filter((el) => (el.textContent || '').trim() === label);
hits.sort((a, b) => {
const ax = a.getBoundingClientRect().left;
const bx = b.getBoundingClientRect().left;
if (ax !== bx) return ax - bx;
return a.children.length - b.children.length;
});
if (hits.length) { hits[0].click(); return true; }
for (let i = 0; i < win.frames.length; i++) {
try { if (clickIn(win.frames[i])) return true; } catch (e) {}
}
return false;
}
return clickIn(window);
"""
)
if clicked is True:
return _wait_text_in_frames(browser, "我起草的", timeout_seconds=8)
_click_by_exact_text(browser, "我的合同")
return _wait_text_in_frames(browser, "我起草的", timeout_seconds=8)
# 在当前页和 iframe 里等到出现指定文本。超时返回 False。
def _wait_text_in_frames(browser: BrowserAdapter, text: str, timeout_seconds: int = 15) -> bool:
deadline = time.time() + timeout_seconds
while time.time() < deadline:
if _page_has_text(browser, text):
return True
time.sleep(0.3)
return False
# 当前页或任一 iframe 正文是否含有指定文本。
def _page_has_text(browser: BrowserAdapter, text: str) -> bool:
found = browser.execute_script(
"""
const wanted = String(arguments[0] || '');
function hasTextInFrames(win) {
try {
const body = (win.document && win.document.body && win.document.body.innerText) || '';
if (body.indexOf(wanted) >= 0) return true;
} catch (e) {}
for (let i = 0; i < win.frames.length; i++) {
try { if (hasTextInFrames(win.frames[i])) return true; } catch (e) {}
}
return false;
}
return hasTextInFrames(window);
""",
text,
)
return found is True
# 按可见文本精确点击。叶子节点优先。
def _click_by_exact_text(browser: BrowserAdapter, label: str) -> None:
clicked = browser.execute_script(
"""
const label = String(arguments[0] || '');
function clickIn(doc) {
const nodes = Array.from(doc.querySelectorAll('div, a, span, li, p, em, td, th, button'));
const leaf = nodes.find((el) => (el.textContent || '').trim() === label && el.children.length === 0);
if (leaf) { leaf.click(); return true; }
const parent = nodes.find((el) => (el.textContent || '').trim() === label);
if (parent) { parent.click(); return true; }
return false;
}
function clickInFrames(win) {
try { if (clickIn(win.document)) return true; } catch (e) {}
for (let i = 0; i < win.frames.length; i++) {
try { if (clickInFrames(win.frames[i])) return true; } catch (e) {}
}
return false;
}
return clickInFrames(window) ? label : '';
""",
label,
)
if clicked != label:
try:
browser.click_first([f"text={label}"])
except Exception:
pass
# 按申请时间排序「我的合同」列表。
def _sort_my_contracts_by_apply_time(browser: BrowserAdapter) -> None:
_click_by_exact_text(browser, "申请时间")
browser.execute_script(
"""
const latestByApplyTime = true;
function textOf(el) { return (el.textContent || '').replace(/\\s+/g, ' ').trim(); }
function sortIn(doc) {
const desc = Array.from(doc.querySelectorAll('[title], a, span, i, em')).find((el) => {
const t = (el.getAttribute('title') || el.textContent || '').trim();
return t === '降序' || t === '从晚到早' || t === '倒序';
});
if (desc) { desc.click(); return 'desc'; }
const bar = Array.from(doc.querySelectorAll('div, span')).find((el) => {
const t = textOf(el);
return t.indexOf('更多排序') >= 0 && t.length < 80;
});
if (bar) {
const downs = Array.from(bar.querySelectorAll('i, em, span, a')).filter((el) => {
const t = (el.textContent || '').trim();
const cls = String(el.className || '');
return t === '∨' || t === 'v' || t === '↓' || t === '▼'
|| cls.indexOf('down') >= 0 || cls.indexOf('desc') >= 0;
});
if (downs.length) { downs[downs.length - 1].click(); return 'arrow'; }
}
return '';
}
function walk(win) {
try {
const hit = sortIn(win.document);
if (hit) return hit;
} catch (e) {}
for (let i = 0; i < win.frames.length; i++) {
try {
const hit = walk(win.frames[i]);
if (hit) return hit;
} catch (e) {}
}
return '';
}
return walk(window) || (latestByApplyTime ? 'apply-time' : '');
"""
)
_wait_text_in_frames(browser, "合同编号", timeout_seconds=10)
# 刮「我的合同」第一行可用合同号。给排查用,正式读号不走这里。
def _scrape_latest_my_contract_number(browser: BrowserAdapter) -> str:
scanned = browser.execute_script(
"""
const latestByApplyTime = true;
const skip = ['提交时自动生成', '自动生成', '==请选择=='];
function scrape(doc) {
const rows = Array.from(doc.querySelectorAll('tr'));
let headerIdx = null;
for (const tr of rows) {
const cells = Array.from(tr.querySelectorAll('th, td')).map((c) =>
(c.innerText || '').replace(/\\s+/g, ' ').trim()
);
if (!headerIdx && cells.indexOf('合同编号') >= 0) {
headerIdx = {};
cells.forEach((h, i) => { if (h && headerIdx[h] === undefined) headerIdx[h] = i; });
continue;
}
if (!headerIdx) continue;
const no = (cells[headerIdx['合同编号']] || '').trim();
if (!no || skip.indexOf(no) >= 0) continue;
return latestByApplyTime ? no : no;
}
return '';
}
function walk(win) {
try {
const no = scrape(win.document);
if (no) return no;
} catch (e) {}
for (let i = 0; i < win.frames.length; i++) {
try {
const no = walk(win.frames[i]);
if (no) return no;
} catch (e) {}
}
return '';
}
return walk(window);
"""
)
if isinstance(scanned, str) and _usable_contract_no(scanned.strip()):
return scanned.strip()
return ""
# 从列表行里按 fdId、标题+客户编码挑合同号。fdId 优先。
def _pick_contract_row(
rows: list[dict],
*,
fd_id: str = "",
title: str = "",
customer_code: str = "",
) -> str:
fd_id = (fd_id or "").strip()
title = (title or "").strip()
customer_code = (customer_code or "").strip()
if fd_id:
for row in rows:
href = str(row.get("href") or "")
no = str(row.get("no") or "").strip()
if fd_id in href and _usable_contract_no(no):
return no
if title and customer_code:
for row in rows:
row_title = str(row.get("title") or "")
row_cust = str(row.get("customer") or "")
no = str(row.get("no") or "").strip()
if title in row_title and customer_code in row_cust and _usable_contract_no(no):
return no
if title:
for row in rows:
no = str(row.get("no") or "").strip()
if title in str(row.get("title") or "") and _usable_contract_no(no):
return no
return ""
# 在「我的合同」查询里填标题和客户编码并点搜索。
def _search_my_contracts(browser: BrowserAdapter, title: str, customer_code: str) -> None:
title = (title or "").strip()
customer_code = (customer_code or "").strip()
if not title and not customer_code:
return
browser.execute_script(
"""
const title = String(arguments[0] || '');
const customer = String(arguments[1] || '');
function setValue(el, value) {
if (!el) return false;
el.focus();
el.value = value;
el.dispatchEvent(new Event('input', {bubbles: true}));
el.dispatchEvent(new Event('change', {bubbles: true}));
return true;
}
function fillNearLabel(doc, label, value) {
if (!value) return false;
const nodes = Array.from(doc.querySelectorAll('td, th, span, label, div, em'));
for (const node of nodes) {
const text = (node.innerText || '').replace(/\\s+/g, '').trim();
if (text !== label && text !== label + ':' && text !== label + ':') continue;
const box = node.parentElement || node;
const input = box.querySelector('input[type="text"], input:not([type]), input[type="search"]');
if (setValue(input, value)) return true;
const next = node.nextElementSibling;
if (next && setValue(next.querySelector('input'), value)) return true;
}
return false;
}
function fillPlaceholder(doc, hint, value) {
if (!value) return false;
const inputs = Array.from(doc.querySelectorAll('input'));
const el = inputs.find((i) => (i.getAttribute('placeholder') || '').indexOf(hint) >= 0);
if (!el) return false;
setValue(el, value);
const btn = el.parentElement && el.parentElement.querySelector('a, button, span, i, em');
if (btn) btn.click();
return true;
}
function clickSearch(doc) {
const labels = ['搜索', '查询'];
const nodes = Array.from(doc.querySelectorAll('a, div, span, button, input'));
for (const label of labels) {
const hit = nodes.find((el) => {
const text = (el.textContent || el.value || '').trim();
return text === label && (!el.children || el.children.length === 0);
});
if (hit) { hit.click(); return true; }
}
return false;
}
function walk(win) {
try {
fillPlaceholder(win.document, '合同(协议)全称', title)
|| fillPlaceholder(win.document, '合同标题', title)
|| fillNearLabel(win.document, '合同标题', title)
|| fillNearLabel(win.document, '合同(协议)全称', title);
fillPlaceholder(win.document, '客户编码', customer)
|| fillNearLabel(win.document, '客户编码', customer);
clickSearch(win.document);
} catch (e) {}
for (let i = 0; i < win.frames.length; i++) {
try { walk(win.frames[i]); } catch (e) {}
}
}
walk(window);
return true;
""",
title,
customer_code,
)
_wait_text_in_frames(browser, "合同编号", timeout_seconds=10)
# 刮「我的合同」表格:编号、标题、客户、带 fdId 的链接。
def _scrape_my_contract_rows(browser: BrowserAdapter) -> list[dict]:
raw = browser.execute_script(
"""
function scrapeTable(doc) {
const rows = Array.from(doc.querySelectorAll('tr'));
let header = null;
const found = [];
for (const tr of rows) {
const cells = Array.from(tr.querySelectorAll('th, td')).map((c) =>
(c.innerText || '').replace(/\\s+/g, ' ').trim()
);
if (!header && cells.indexOf('合同编号') >= 0) {
header = {};
cells.forEach((h, i) => { if (h && header[h] === undefined) header[h] = i; });
continue;
}
if (!header) continue;
const no = (cells[header['合同编号']] || '').trim();
const title = (
cells[header['合同标题']]
|| cells[header['合同(协议)全称']]
|| cells[header['合同名称']]
|| cells[header['主题']]
|| cells[header['标题']]
|| ''
).trim();
const customer = (
cells[header['客户编码']] || cells[header['客户名称']] || cells[header['客商编码']] || ''
).trim();
const link = tr.querySelector('a[href*="fdId"], a[href*="fdid"]');
const box = tr.querySelector('input[name="List_Selected"]');
const href = link
? String(link.getAttribute('href') || '')
: (box ? String(box.value || '') : (tr.innerHTML || ''));
if (!no && !href) continue;
found.push({no: no, title: title, customer: customer, href: href});
}
return found;
}
function scrapeLinks(doc) {
const found = [];
const links = Array.from(doc.querySelectorAll('a[href*="fdId"], a[href*="fdid"]'));
for (const a of links) {
const href = String(a.getAttribute('href') || '');
const tr = a.closest('tr') || a.parentElement;
const blob = ((tr && tr.innerText) || a.innerText || '').replace(/\\s+/g, ' ').trim();
const hit = blob.match(/HT[0-9A-Za-z-]+/) || blob.match(/\\b\\d{10,}\\b/);
found.push({
no: hit ? hit[0] : '',
title: blob,
customer: blob,
href: href
});
}
return found;
}
function walk(win) {
let all = [];
try { all = all.concat(scrapeTable(win.document)); } catch (e) {}
try { all = all.concat(scrapeLinks(win.document)); } catch (e) {}
for (let i = 0; i < win.frames.length; i++) {
try { all = all.concat(walk(win.frames[i])); } catch (e) {}
}
return all;
}
return walk(window);
"""
)
if not isinstance(raw, list):
return []
rows: list[dict] = []
for item in raw:
if not isinstance(item, dict):
continue
rows.append(
{
"no": str(item.get("no") or "").strip(),
"title": str(item.get("title") or "").strip(),
"customer": str(item.get("customer") or "").strip(),
"href": str(item.get("href") or "").strip(),
}
)
return rows
# 列表行的 href 里是否含有该 fdId。
def _row_has_fd_id(rows: list[dict], fd_id: str) -> bool:
token = (fd_id or "").strip()
if not token:
return False
return any(token in str(row.get("href") or "") for row in rows)
# 打开该 fdId 的详情页并读合同编号。文档不存在则返回空。
def _read_number_by_fd_id(browser: BrowserAdapter, fd_id: str) -> str:
doc_id = (fd_id or "").strip()
if not doc_id:
return ""
_open_submitted_view(browser, doc_id)
deadline = time.time() + 10
while time.time() < deadline:
body = browser.read_body_text() or ""
if "该文档不存在" in body or "操作失败" in body:
print(f"S13 详情不存在 fdId={doc_id}", flush=True)
return ""
no = _read_contract_number_on_page(browser)
if no:
print("从合同详情读到合同编号:", no, flush=True)
return no
time.sleep(0.8)
return ""
# 回「我的合同」按 fdId/标题+客户编码查号。匹配不到不取最新一条。
def _read_contract_number_from_my_list(
browser: BrowserAdapter,
ctx: ContractFlowContext,
timeout_seconds: int = 25,
fd_id: str = "",
) -> str:
title = _expected_title(ctx)
customer = (ctx.customer_code or "").strip()
print(
f"S13:回我的合同查询 fdId={fd_id or '-'} title={title} customer={customer}",
flush=True,
)
_open_my_contracts(browser, ctx)
deadline = time.time() + timeout_seconds
searched = False
saw_row = False
while time.time() < deadline:
rows = _scrape_my_contract_rows(browser)
print(f"S13 列表行数={len(rows)}", flush=True)
for row in rows[:6]:
print(
"S13 row"
f" no={row.get('no')!r}"
f" title={str(row.get('title') or '')[:60]!r}"
f" customer={str(row.get('customer') or '')[:30]!r}"
f" href={str(row.get('href') or '')[:90]!r}",
flush=True,
)
no = _pick_contract_row(
rows,
fd_id=fd_id,
title=title,
customer_code=customer,
)
if no:
print("从我的合同读到合同编号:", no, flush=True)
return no
if fd_id and _row_has_fd_id(rows, fd_id):
saw_row = True
if not searched:
_search_my_contracts(browser, title, customer)
searched = True
time.sleep(1)
if saw_row and fd_id:
no = _read_number_by_fd_id(browser, fd_id)
if no:
return no
print("我的合同列表未匹配到合同编号", flush=True)
return ""

View File

@ -0,0 +1,363 @@
# 合同 S12:暂存后提交。成功后不要等详情页。
#
# 测试环境直接提交会落到「该文档不存在」,所以先点暂存再点提交。
# dry_run 不点提交,返回 False。
from __future__ import annotations
import time
from accept import evidence_shot
from contract_common import (
EDIT_PAGE_URL,
BrowserAdapter,
ContractFlowContext,
_ACCEPT,
_click_toolbar_button,
_close_blocking_dialogs,
_current_url,
_lui_toolbar_button_exists,
_read_document_id,
_wait_lui_toolbar_button,
)
_ERROR_MARKS = (
"不能为空",
"请填写",
"校验失败",
"提交失败",
"必填项未填",
"该文档不存在",
"操作失败",
)
_SUCCESS_MARKS = ("您的操作已成功", "操作已成功", "提交成功", "流程已提交", "成功提交")
_CONFIRM_MARKS = ("确定提交", "是否提交", "确认提交", "是否确定", "确定要提交")
# 暂存后提交(S12)。dry_run 不点提交。成功后不要等详情页。
def submit_contract_for_approval(
browser: BrowserAdapter, ctx: ContractFlowContext, *, fd_id: str = ""
) -> bool:
if ctx.submit_mode == "dry_run":
evidence_shot(browser, "contract-dry-run-ready")
_ACCEPT.note("S12", "提交", {"mode": "dry_run", "submitted": "no"}, ok=True)
return False
_close_blocking_dialogs(browser)
fd_id = (fd_id or "").strip() or _read_document_id(browser)
print(f"S12 fdId={fd_id or '-'}", flush=True)
# 测试环境直接提交会落到「该文档不存在」:先暂存落库,再提交。合同号从详情 #_xform_docNumber 读。
try:
saved_click = _click_toolbar_button(browser, "暂存")
except Exception as exc:
print(f"暂存未点到:{exc}", flush=True)
saved_click = False
save_kind, save_text = _wait_submit_outcome(browser, timeout_seconds=15)
if save_kind == "error":
evidence_shot(browser, "contract-submitted", failed=True)
_ACCEPT.note(
"S12",
"提交",
{"submitted": "no", "error": save_text, "at": "暂存", "clickedSave": "yes" if saved_click else "no"},
ok=False,
raise_error=False,
)
return False
if save_kind != "success":
print("暂存后仍在表单页,继续点提交。", flush=True)
fd_id = fd_id or _read_document_id(browser)
if not _lui_toolbar_button_exists(browser, "提交"):
if not fd_id:
evidence_shot(browser, "contract-submitted", failed=True)
_ACCEPT.note(
"S12",
"提交",
{"submitted": "no", "error": "暂存后离开表单且无 fdId,无法再点提交", "clicked": "no"},
ok=False,
raise_error=False,
)
return False
print(f"暂存后打开编辑页 fdId={fd_id}", flush=True)
browser.open_url(EDIT_PAGE_URL.format(fd_id=fd_id))
if not _wait_lui_toolbar_button(browser, "提交", timeout_seconds=12):
evidence_shot(browser, "contract-submitted", failed=True)
_ACCEPT.note(
"S12",
"提交",
{"submitted": "no", "error": "编辑页未见提交按钮 lui_widget_btn_txt", "clicked": "no", "fdId": fd_id},
ok=False,
raise_error=False,
)
return False
try:
clicked = _click_toolbar_button(browser, "提交")
except Exception as exc:
evidence_shot(browser, "contract-submitted", failed=True)
_ACCEPT.note("S12", "提交", {"submitted": "no", "error": str(exc)[:120], "clicked": "no"}, ok=False, raise_error=False)
return False
if not clicked:
evidence_shot(browser, "contract-submitted", failed=True)
_ACCEPT.note("S12", "提交", {"submitted": "no", "error": "未点到提交按钮 lui_widget_btn_txt", "clicked": "no"}, ok=False, raise_error=False)
return False
evidence_shot(browser, "contract-after-submit-click")
kind, dialog = _wait_submit_outcome(browser, timeout_seconds=20, trust_body_success=False)
if kind == "error":
evidence_shot(browser, "contract-submitted", failed=True)
_ACCEPT.note(
"S12",
"提交",
{"submitted": "no", "clicked": "yes", "error": dialog},
ok=False,
raise_error=False,
)
return False
print(f"提交已点 kind={kind} dialog={dialog[:80]!r},S13 从详情读合同号。", flush=True)
_ACCEPT.note(
"S12",
"提交",
{
"submitted": "yes",
"clicked": "yes",
"via": "暂存后提交",
"signal": kind,
"dialog": dialog[:80] or "(none)",
},
ok=True,
)
return True
# 从页面正文抽出提交失败原因。没有则返回空字符串。
def _submit_error_from_body(body: str) -> str:
text = body or ""
if "该文档不存在" in text:
return "该文档不存在"
if "操作失败" in text and "您的请求已提交" in text:
return "操作失败"
return ""
# 把弹窗文案收成 error / success / confirm / none。
def _classify_dialog_text(text: str) -> str:
blob = text or ""
for mark in _ERROR_MARKS:
if mark in blob:
return "error"
for mark in _SUCCESS_MARKS:
if mark in blob:
return "success"
for mark in _CONFIRM_MARKS:
if mark in blob:
return "confirm"
return "none"
# 等到提交弹窗给出成功或失败信号。超时返回 none。
def _wait_submit_outcome(
browser: BrowserAdapter,
timeout_seconds: float,
*,
trust_body_success: bool = True,
) -> tuple[str, str]:
deadline = time.time() + timeout_seconds
last_dialog = ""
saw_confirm = False
while time.time() < deadline:
kind, text, confirm = _read_submit_signal(
browser,
trust_body_success=trust_body_success or saw_confirm,
)
if confirm:
saw_confirm = True
if text:
last_dialog = text
if kind == "error":
return "error", text
if kind == "success":
return "success", text
time.sleep(0.4)
kind, text, _confirm = _read_submit_signal(
browser,
trust_body_success=trust_body_success or saw_confirm,
)
if kind in ("error", "success"):
return kind, text
return "none", text or last_dialog
# 读一次原生 alert 和 LUI 弹窗,返回 kind、文案、是否点过确认。
def _read_submit_signal(
browser: BrowserAdapter,
*,
trust_body_success: bool = True,
) -> tuple[str, str, bool]:
alert_text = _accept_native_alert(browser)
if alert_text:
kind = _classify_dialog_text(alert_text)
if kind == "none":
kind = "confirm"
print(f"S12 native alert kind={kind} text={alert_text[:80]!r}", flush=True)
if kind == "error":
return "error", alert_text, False
if kind == "success":
return "success", alert_text, False
if kind == "confirm":
return _finish_submit_signal(browser, alert_text, True, trust_body_success)
snapshot = _handle_submit_dialogs(browser)
dialog_text = str(snapshot.get("text") or "").strip()
kind = _classify_dialog_text(dialog_text) if dialog_text else "none"
if kind == "none":
kind = str(snapshot.get("kind") or "none")
if kind == "error":
return "error", dialog_text or "弹窗报错", False
if kind == "success":
return "success", dialog_text or "ok", False
return _finish_submit_signal(
browser,
dialog_text,
kind == "confirm" or bool(snapshot.get("clicked")),
trust_body_success,
)
# 弹窗之后再看正文和 URL,把提交结果收成 error / success / none。
def _finish_submit_signal(
browser: BrowserAdapter,
dialog_text: str,
saw_confirm: bool,
trust_body_success: bool,
) -> tuple[str, str, bool]:
body_error = _submit_error_from_body(browser.read_body_text() or "")
if body_error:
return "error", body_error, saw_confirm
if _submit_view_page(browser):
return "success", dialog_text or "view", saw_confirm
if (trust_body_success or saw_confirm) and _submit_success_text(browser):
return "success", dialog_text or "ok", saw_confirm
return "none", dialog_text, saw_confirm
# 有原生 alert 就点确定并返回文案。没有则返回空字符串。
def _accept_native_alert(browser: BrowserAdapter) -> str:
accept = getattr(browser, "accept_alert", None)
if not callable(accept):
return ""
try:
return str(accept() or "").strip()
except Exception:
return ""
# 当前 URL 是否已经落到合同详情 view 页。
def _submit_view_page(browser: BrowserAdapter) -> bool:
url = _current_url(browser)
return "method=view" in url and "kmAgreementApply" in url
# 页面正文是否出现提交成功标记,且没有失败文案。
def _submit_success_text(browser: BrowserAdapter) -> bool:
body = browser.read_body_text() or ""
if _submit_error_from_body(body):
return False
return any(mark in body for mark in _SUCCESS_MARKS)
# 提交是否已经成功:成功文案或已到详情页,且正文没有失败。
def _submit_succeeded(browser: BrowserAdapter) -> bool:
if _submit_error_from_body(browser.read_body_text() or ""):
return False
return _submit_success_text(browser) or _submit_view_page(browser)
# 读当前弹窗/正文里的校验失败文案。没有则返回空字符串。
def _submit_validation_error(browser: BrowserAdapter) -> str:
kind, text, _confirm = _read_submit_signal(browser, trust_body_success=True)
if kind == "error":
return text
return ""
# 扫描 LUI 弹窗:报错原样返回,确认/成功则点确定。
def _handle_submit_dialogs(browser: BrowserAdapter) -> dict:
raw = browser.execute_script(
"""
const errorMarks = ['不能为空','请填写','校验失败','提交失败','必填项未填','该文档不存在','操作失败'];
const successMarks = ['您的操作已成功','操作已成功','提交成功','流程已提交','成功提交'];
const confirmMarks = ['确定提交','是否提交','确认提交','是否确定','确定要提交'];
function classify(text) {
if (errorMarks.some((m) => text.indexOf(m) >= 0)) return 'error';
if (successMarks.some((m) => text.indexOf(m) >= 0)) return 'success';
if (confirmMarks.some((m) => text.indexOf(m) >= 0)) return 'confirm';
return 'none';
}
function dialogText(doc) {
const roots = Array.from(doc.querySelectorAll(
'.lui_dialog, .lui_message, .lui_alert, .lui_dialog_content, .prompt_frame, .lui_dialog_common'
));
return roots.map((el) => (el.innerText || '').trim()).filter(Boolean).join('\\n');
}
function clickLabel(doc, labels) {
const nodes = Array.from(doc.querySelectorAll('div, a, button, span, input'));
for (const label of labels) {
const hit = nodes.find((el) => {
const t = (el.textContent || el.value || '').trim();
return t === label && (!el.children || el.children.length === 0);
});
if (hit) { hit.click(); return label; }
}
return '';
}
function walk(win) {
let found = {kind: 'none', text: '', clicked: ''};
try {
const text = dialogText(win.document);
const kind = classify(text);
if (kind === 'error') return {kind: kind, text: text.slice(0, 200), clicked: ''};
if (kind === 'confirm') {
const clicked = clickLabel(win.document, ['确定', '确认', '是', 'OK']);
return {kind: kind, text: text.slice(0, 200), clicked: clicked};
}
if (kind === 'success') {
const clicked = clickLabel(win.document, ['确定', '关闭', '确认']);
return {kind: kind, text: text.slice(0, 200), clicked: clicked};
}
if (text) {
const clicked = clickLabel(win.document, ['确定', '确认']);
if (clicked) return {kind: 'confirm', text: text.slice(0, 200), clicked: clicked};
}
} catch (e) {}
for (let i = 0; i < win.frames.length; i++) {
try {
const inner = walk(win.frames[i]);
if (inner.kind !== 'none' || inner.clicked) return inner;
} catch (e) {}
}
return found;
}
return walk(window);
"""
)
if not isinstance(raw, dict):
return {"kind": "none", "text": "", "clicked": ""}
return {
"kind": str(raw.get("kind") or "none"),
"text": str(raw.get("text") or "").strip(),
"clicked": str(raw.get("clicked") or ""),
}
# 等到提交确认弹窗消失或被点掉。
def _wait_lui_dialog_gone(browser: BrowserAdapter, timeout_seconds: float = 8) -> None:
deadline = time.time() + timeout_seconds
while time.time() < deadline:
snapshot = _handle_submit_dialogs(browser)
kind = str(snapshot.get("kind") or "none")
text = str(snapshot.get("text") or "").strip()
if kind == "confirm":
return
if kind in ("none", "") and not text:
return
_close_blocking_dialogs(browser)
time.sleep(0.4)
_close_blocking_dialogs(browser)

View File

@ -0,0 +1,197 @@
# 合同 S11:挂合同正文。仅 YYC 传合并 PDF;提交前无文件则用占位 PDF。
#
# dry_run 且没有文件时跳过。
from __future__ import annotations
import time
from pathlib import Path
from accept import evidence_shot
from errors import NodeError
from contract_common import (
BrowserAdapter,
ContractFlowContext,
_ACCEPT,
_close_blocking_dialogs,
)
# 决定要挂的合同正文路径。提交且无文件时用占位 PDF。
def _resolve_contract_body_path(ctx: ContractFlowContext) -> str:
path = (ctx.contract_document_path or "").strip()
if not path and ctx.source_type == "YYC":
path = (ctx.merged_pdf_path or "").strip()
if path:
return path
if ctx.submit_mode == "dry_run":
return ""
from pdf_utils import ensure_placeholder_contract_pdf
return ensure_placeholder_contract_pdf()
# 挂合同正文(S11)。dry_run 且没有文件则跳过。
def upload_contract_document_if_needed(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
path = _resolve_contract_body_path(ctx)
if not path:
evidence_shot(browser, "contract-pdf-upload-skipped")
_ACCEPT.note("S11", "上传正文", {"skipped": "dry_run_no_file"}, ok=True)
return
try:
_upload_contract_body(browser, path)
except Exception as exc:
_ACCEPT.note("S11", "上传正文", {"attached": "no", "error": exc}, ok=False, raise_error=False)
raise
evidence_shot(browser, "contract-body-uploaded")
attached = _contract_body_attached(browser, path)
_ACCEPT.check(
"S11",
"上传正文",
[("attached", "yes" if attached else "no", "yes"), ("file", Path(path).name, Path(path).name)],
browser=browser,
screenshot_on_fail="contract-body-uploaded",
)
# 打开合同正文区,点添加合同,把本地文件塞进 file input。
def _upload_contract_body(browser: BrowserAdapter, file_path: str) -> None:
from pathlib import Path
if not Path(file_path).is_file():
raise NodeError(f"合同正文文件不存在:{file_path}")
_close_blocking_dialogs(browser)
_reveal_contract_body_section(browser)
evidence_shot(browser, "contract-body-tab")
if _contract_body_attached(browser, file_path):
evidence_shot(browser, "contract-body-uploaded")
return
_click_add_contract_body(browser)
time.sleep(0.8)
try:
browser.click_native(["text=打开本地初稿", "text=本地初稿", "text=上传本地文件"])
time.sleep(0.8)
except Exception:
pass
browser.upload_file_first(
[
'input[name="_apply_filemainOnline"]',
'input[name="__landray_filemainOnline"]',
'input[name*="filemainOnline"]',
'input[name*="fdMainOnline"]',
'.lui_dialog input[type="file"]',
'input[type="file"]',
],
file_path,
)
deadline = time.time() + 12
while time.time() < deadline:
if _contract_body_attached(browser, file_path) or _on_body_editor_tab(browser):
evidence_shot(browser, "contract-body-uploaded")
return
time.sleep(0.6)
evidence_shot(browser, "contract-body-uploaded", failed=True)
raise NodeError("合同正文未挂上:页面仍是「+添加合同」,提交会被系统拒绝")
# 滚到或点开「合同正文」区域,露出「+添加合同」。
def _reveal_contract_body_section(browser: BrowserAdapter) -> None:
browser.execute_script(
"""
const labelOf = (el) => (el.textContent || '').replace(/\\s+/g, '').trim();
const addBtn = Array.from(document.querySelectorAll('a, span, div, td')).find((el) => {
const text = labelOf(el);
return text === '+添加合同' || text === '添加合同';
});
if (addBtn) {
addBtn.scrollIntoView({block: 'center'});
return {ok: true, via: 'add'};
}
const hits = Array.from(document.querySelectorAll('div, li, span, a')).filter((el) => labelOf(el) === '合同正文');
for (const el of hits) {
const group = (el.parentElement && el.parentElement.parentElement) || el;
if (labelOf(group).indexOf('签约方信息') >= 0) {
(el.closest('li') || el).click();
return {ok: true, via: 'tabbar'};
}
}
return {ok: false};
"""
)
time.sleep(0.6)
# 点「+添加合同」打开本地初稿入口。
def _click_add_contract_body(browser: BrowserAdapter) -> None:
clicked = browser.execute_script(
"""
const labelOf = (el) => (el.textContent || '').replace(/\\s+/g, '').trim();
const hit = Array.from(document.querySelectorAll('a, span, div, td, button')).find((el) => {
const text = labelOf(el);
return (text === '+添加合同' || text === '添加合同') && el.children.length <= 1;
});
if (!hit) return {ok: false};
hit.scrollIntoView({block: 'center'});
hit.click();
return {ok: true};
"""
)
if isinstance(clicked, dict) and clicked.get("ok"):
return
browser.click_native(["text=+添加合同", "text=添加合同"])
# 看合同正文是否已经挂上:文件名、office iframe 或 file input 有文件。
def _contract_body_attached(browser: BrowserAdapter, file_path: str) -> bool:
from pathlib import Path
stem = Path(file_path).name
result = browser.execute_script(
"""
const wanted = String(arguments[0] || '');
const wantedStem = wanted.replace(/\\.[^.]+$/, '');
const fileInputs = Array.from(document.querySelectorAll('input[type="file"]'));
if (fileInputs.some((el) => el.files && el.files.length > 0)) {
return {ok: true, via: 'input'};
}
const frames = Array.from(document.querySelectorAll('iframe, object, embed'));
const office = frames.find((el) => {
const hint = ((el.src || '') + ' ' + (el.id || '') + ' ' + (el.className || '')).toLowerCase();
const box = el.getBoundingClientRect();
return box.width > 80 && box.height > 80 && /office|ntko|wps|kingsoft|weboffice|filemain|online|pdf/.test(hint);
});
if (office) return {ok: true, via: 'iframe'};
const rows = Array.from(document.querySelectorAll('tr'));
for (const row of rows) {
const cells = Array.from(row.querySelectorAll('td, th'));
if (!cells.length) continue;
const head = (cells[0].innerText || '').replace(/\\s+/g, '').trim();
if (head !== '合同正文') continue;
const body = (row.innerText || '').replace(/\\s+/g, ' ').trim();
if (wanted && body.indexOf(wanted) >= 0) return {ok: true, via: 'name'};
if (wantedStem && body.indexOf(wantedStem) >= 0) return {ok: true, via: 'stem'};
if (/\\.(pdf|doc|docx|wps)/i.test(body)) return {ok: true, via: 'ext'};
if (body.indexOf('添加合同') >= 0 && body.indexOf('打开本地初稿') < 0) return {ok: false, via: 'empty'};
}
const page = (document.body.innerText || '');
if (wanted && page.indexOf(wanted) >= 0) return {ok: true, via: 'page'};
return {ok: false, via: 'missing'};
""",
stem,
)
return isinstance(result, dict) and bool(result.get("ok"))
# 底部「合同正文」在线编辑页:文件进编辑器后不一定回显文件名。
def _on_body_editor_tab(browser: BrowserAdapter) -> bool:
result = browser.execute_script(
"""
const page = (document.body.innerText || '').replace(/\\s+/g, '');
return page.indexOf('打开本地初稿') >= 0 && page.indexOf('添加合同') < 0;
"""
)
return result is True

View File

@ -1,31 +1,42 @@
"""Node errors that map onto state-center fail callbacks.""" # 页面流错误。错误码留在日志里,不再回调旧状态中心。
# 页面元素找不到或页面改版。不重试,交给运维。
class NodeError(RuntimeError): class NodeError(RuntimeError):
error_code = "UI_ELEMENT_NOT_FOUND" error_code = "UI_ELEMENT_NOT_FOUND"
retryable = False retryable = False
route_role = "OPS" route_role = "OPS"
# 合同系统登录失败。可以重试。
class LoginFailed(NodeError): class LoginFailed(NodeError):
error_code = "SYS_LOGIN_FAIL" error_code = "SYS_LOGIN_FAIL"
retryable = True retryable = True
route_role = "OPS" route_role = "OPS"
# 供应商在合同系统里不能起草。不重试,交给采购。
class SupplierContractDisabled(NodeError): class SupplierContractDisabled(NodeError):
error_code = "BIZ_CONTRACT_DISABLED" error_code = "BIZ_CONTRACT_DISABLED"
retryable = False retryable = False
route_role = "PURCHASER" route_role = "PURCHASER"
# YYC 多份平台 PDF 合并不了。不重试,交给运维。
class PdfMergeUnsupported(NodeError): class PdfMergeUnsupported(NodeError):
error_code = "CAP_PDF_MERGE_UNSUPPORTED" error_code = "CAP_PDF_MERGE_UNSUPPORTED"
retryable = False retryable = False
route_role = "OPS" route_role = "OPS"
# 页面流里没归类的异常。可以重试。
class UnknownRpaError(NodeError): class UnknownRpaError(NodeError):
error_code = "RPA_UNKNOWN_ERROR" error_code = "RPA_UNKNOWN_ERROR"
retryable = True retryable = True
route_role = "OPS" route_role = "OPS"
# 客商未尽调。不提交合同,不重试,交给采购。
class DiligenceError(NodeError):
error_code = "BIZ_CUSTOMER_NOT_DILIGENCE"
retryable = False
route_role = "PURCHASER"

View File

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

View File

@ -0,0 +1,45 @@
%PDF-1.3
%âãÏÓ
1 0 obj
<<
/Producer (pypdf)
>>
endobj
2 0 obj
<<
/Type /Pages
/Count 1
/Kids [ 4 0 R ]
>>
endobj
3 0 obj
<<
/Type /Catalog
/Pages 2 0 R
>>
endobj
4 0 obj
<<
/Type /Page
/Resources <<
>>
/MediaBox [ 0.0 0.0 612 792 ]
/Parent 2 0 R
>>
endobj
xref
0 5
0000000000 65535 f
0000000015 00000 n
0000000054 00000 n
0000000113 00000 n
0000000162 00000 n
trailer
<<
/Size 5
/Root 3 0 R
/Info 1 0 R
>>
startxref
256
%%EOF

123
rpa_contract/group.py Normal file
View File

@ -0,0 +1,123 @@
# 合同班合单:可录判定、按供应商+货主+三个日期分组、草稿映射。
from __future__ import annotations
from collections import OrderedDict
from datetime import date, datetime
from decimal import Decimal, ROUND_HALF_UP
from contract_draft import ContractDraft
from contract_flow_steps import ContractGoodsLine
from persist import LOOKUP_ONLY_RESULT
from repo import PurchaseContractRow
# 判断是否只录协议行:contract_entry_status=0 且已有合同号。
#
# 合同班跳过这些行,留给返利班。
def is_protocol_rerun(row: PurchaseContractRow) -> bool:
if row.contract_entry_status is not None and int(row.contract_entry_status) == 0:
return bool((row.contract_no or "").strip())
return False
# 判断是否可领:process_status=0 且不是协议重录行。
#
# 过期的 process_status=3 由仓库租约逻辑另判,本函数只认新鲜待领。
def is_claimable(row: PurchaseContractRow) -> bool:
if int(row.process_status or 0) != 0:
return False
return not is_protocol_rerun(row)
# 合单键:供应商、货主、签订日、开始日、结束日。任一不同则各自成组。
def _group_key(row: PurchaseContractRow) -> tuple:
return (
row.supplier_id,
row.org_code,
row.contract_sign_date,
row.contract_start_date,
row.contract_end_date,
)
# 按合单键分组,组内按 fid 升序,头字段取该组第一行。
def group_by_supplier(rows: list[PurchaseContractRow]) -> list[list[PurchaseContractRow]]:
groups: OrderedDict[tuple, list[PurchaseContractRow]] = OrderedDict()
for row in sorted(rows, key=lambda item: item.fid):
groups.setdefault(_group_key(row), []).append(row)
return list(groups.values())
# 检查合单是否缺供应商或货品 MDM 码。缺则返回原因,齐则返回 None。
def missing_mdm_reason(rows: list[PurchaseContractRow]) -> str | None:
if not rows:
return "缺少供应商MDM码"
if not (rows[0].supplier_mdm_code or "").strip():
return "缺少供应商MDM码"
for row in rows:
if not (row.goods_mdm_code or "").strip():
return "缺少货品MDM码"
return None
# 把金额收成四位小数字符串,四舍五入。
def money4(value) -> str:
amount = Decimal(str(value)).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
return f"{amount:.4f}"
# 把日期收成 YYYY-MM-DD。空值返回空字符串。
def iso_date(value) -> str:
if value is None:
return ""
if isinstance(value, datetime):
return value.date().isoformat()
if isinstance(value, date):
return value.isoformat()
return str(value)[:10]
def draft_from_group(rows: list[PurchaseContractRow]) -> ContractDraft:
"""把同一供应商一组表 3 行收成页面草稿。头字段取第一行。
结果里已有「只查号」时,下一班只查已有合同,不再起草。
"""
first = rows[0]
sign = iso_date(first.contract_sign_date)
total = sum(
(Decimal(str(row.qty)) * Decimal(str(row.unit_price)) for row in rows),
Decimal("0"),
)
goods = [
ContractGoodsLine(
operation_code=(row.goods_mdm_code or "").strip(),
goods_code=(row.goods_mdm_code or "").strip(),
goods_name=row.goods_name,
approval_no=row.reg_no,
spec=row.spec,
unit=row.unit,
quantity=float(row.qty),
mdm_code=(row.goods_mdm_code or "").strip(),
unit_price=money4(row.unit_price),
)
for row in rows
]
return ContractDraft(
supplier_name=first.supplier_name,
order_date=sign,
source_type="",
customer_code=(first.supplier_mdm_code or "").strip(),
customer_name=first.supplier_name,
contract_category="采购协议",
amount=money4(total),
contract_sign_date=sign,
contract_start_date=iso_date(first.contract_start_date),
contract_end_date=iso_date(first.contract_end_date),
is_prepayment="否",
belong_year=sign[:4],
payment_method="账期支付",
payment_days=str(first.payment_term),
goods_lines=goods,
lookup_only=any(LOOKUP_ONLY_RESULT in (row.process_result or "") for row in rows),
)

107
rpa_contract/job.py Normal file
View File

@ -0,0 +1,107 @@
# 合同班次:领取表 3 → 按供应商、货主和三个日期合单 → runner 录合同 → 写表 4。
#
# 崩在 process_status=3:超过租约可再领,未过期需改回 0。
# 未捕获异常原因列只写 PAGE_FAILED。原文进审计表,会话结束后另写。
from __future__ import annotations
import logging
from contextlib import nullcontext
from dataclasses import dataclass
from typing import Callable
from contract_draft import ContractDraft
from errors import DiligenceError, NodeError
from group import draft_from_group, group_by_supplier, missing_mdm_reason
from persist import KIND_DILIGENCE, KIND_FAIL, KIND_OK, ContractOutcome, build_save
from rpa_oracle import ShiftError
PAGE_FAILED = "PAGE_FAILED"
Runner = Callable[[ContractDraft], ContractOutcome]
logger = logging.getLogger("rpa_contract")
# 一个合同班次的领取、合单和成败计数。
@dataclass
class JobSummary:
claimed: int = 0
groups: int = 0
ok: int = 0
failed: int = 0
def _session(repo):
"""有 session 就借一条,没有就空上下文。页面调用必须在这个块外面。"""
session = getattr(repo, "session", None)
if session is None:
return nullcontext()
return session()
def run_batch(repo, *, runner: Runner, limit: int | None = None) -> JobSummary:
"""领取待录合同行,按供应商合单、调 runner、落表。
session 只包领取和每一组的落表。页面在连接归还之后跑。一组落完再处理下一组。
limit 交给领取层。
"""
audits: list[ShiftError] = []
with _session(repo):
claimed = repo.claim_pending(limit=limit)
summary = JobSummary(claimed=len(claimed), groups=0)
for rows in group_by_supplier(claimed):
summary.groups += 1
outcome = _run_group(rows, runner)
with _session(repo):
repo.save_group(build_save(rows, outcome))
if outcome.error_text:
supplier = rows[0].supplier_id if rows else ""
audits.append(ShiftError("合同", str(supplier), PAGE_FAILED, outcome.error_text))
if outcome.kind == KIND_OK and (outcome.contract_no or "").strip():
summary.ok += 1
else:
summary.failed += 1
repo.save_shift_errors(audits)
return summary
# 录一组同一供应商的行。缺 MDM 不调页面;未捕获异常写 PAGE_FAILED。
def _run_group(rows, runner: Runner) -> ContractOutcome:
reason = missing_mdm_reason(rows)
if reason:
return ContractOutcome(KIND_FAIL, None, reason)
try:
outcome = runner(draft_from_group(rows))
except DiligenceError:
return ContractOutcome(KIND_DILIGENCE, None, "客户未尽调")
except NodeError as exc:
return ContractOutcome(KIND_FAIL, None, str(exc))
except Exception as exc:
logger.exception("合同录入未捕获异常 supplier=%s", rows[0].supplier_id if rows else "")
return ContractOutcome(KIND_FAIL, None, PAGE_FAILED, _text(exc))
if outcome.kind == KIND_OK and not (outcome.contract_no or "").strip():
return ContractOutcome(KIND_FAIL, None, "submit 模式未读取到真实合同编号")
return outcome
# 把页面 run() 的合同号或异常收成 ContractOutcome。
#
# 尽调失败写客户未尽调;NodeError 写业务文案;其它异常写 PAGE_FAILED,原文放 error_text。
def outcome_from_run(run: Callable[[], str]) -> ContractOutcome:
try:
number = (run() or "").strip()
except DiligenceError:
return ContractOutcome(KIND_DILIGENCE, None, "客户未尽调")
except NodeError as exc:
return ContractOutcome(KIND_FAIL, None, str(exc))
except Exception as exc:
logger.exception("合同页面未捕获异常")
return ContractOutcome(KIND_FAIL, None, PAGE_FAILED, _text(exc))
if not number:
return ContractOutcome(KIND_FAIL, None, "submit 模式未读取到真实合同编号")
return ContractOutcome(KIND_OK, number, None)
# 取出异常原文。空串不当审计正文。
def _text(exc: BaseException) -> str | None:
text = str(exc).strip()
return text or None

6
rpa_contract/main.py Normal file
View File

@ -0,0 +1,6 @@
# 合同起草命令行入口。真正的入参组装和页面流在 contract_create_node。
from contract_create_node import main
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -1,15 +1,29 @@
"""PDF utilities for YiSaiQi contract drafting.""" # 合同 PDF 合并、提交占位正文。
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
_PLACEHOLDER_PDF = Path(__file__).resolve().parent / "examples" / "placeholder_contract.pdf"
# 提交必须挂合同正文;没有平台文件时用这一份占位 PDF。
def ensure_placeholder_contract_pdf() -> str:
path = _PLACEHOLDER_PDF
if path.is_file() and path.stat().st_size > 64:
return str(path)
path.parent.mkdir(parents=True, exist_ok=True)
from pypdf import PdfWriter
writer = PdfWriter()
writer.add_blank_page(width=612, height=792)
with path.open("wb") as file:
writer.write(file)
return str(path)
# 合成一份要上传的合同正文。同供应商同日只传这一份。单文件直接返回路径。
def ensure_merged_pdf(source_paths: list[str], output_path: str) -> str: def ensure_merged_pdf(source_paths: list[str], output_path: str) -> str:
"""Return one PDF path, merging multiple source PDFs when needed.
YiSaiQi must upload only one YYC contract document for the same supplier
and same order date. A single source PDF can be uploaded directly.
"""
if not source_paths: if not source_paths:
raise ValueError("YYC contract creation requires at least one platform PDF") raise ValueError("YYC contract creation requires at least one platform PDF")
if len(source_paths) == 1: if len(source_paths) == 1:

118
rpa_contract/persist.py Normal file
View File

@ -0,0 +1,118 @@
# 合同班结果:ContractOutcome → 表 4 行 + 表 3 状态。
from __future__ import annotations
from dataclasses import dataclass
from repo import PurchaseContractRow
KIND_OK = "ok"
KIND_DILIGENCE = "diligence"
KIND_FAIL = "fail"
KIND_HOLD = "hold"
# 提交后没读到号。下一班看到这个结果只查号,不再起草。
LOOKUP_ONLY_RESULT = "只查号"
USER_NAME = "系统管理员"
TABLE4_PENDING = 0
TABLE4_SKIP = 2
PROCESS_RESULT_MAX = 1000
FAIL_REASON_MAX = 500
# 录合同结果。error_text 只给审计表,不进原因列。
@dataclass(frozen=True)
class ContractOutcome:
kind: str
contract_no: str | None
reason: str | None
error_text: str | None = None
# 一组合同要写回的内容:表 3 的 fid 和状态,以及表 4 起草行。
@dataclass(frozen=True)
class GroupSave:
fids: list[int]
process_status: int
process_result: str
table4_rows: list[dict]
# 原因超长时截断并加省略号,避免写爆表 3 / 表 4 的原因列。
def clip(text: str, max_len: int) -> str:
text = (text or "").strip()
if len(text) <= max_len:
return text
return text[: max_len - 1] + "…"
def build_save(rows: list[PurchaseContractRow], outcome: ContractOutcome) -> GroupSave:
"""按录合同结果生成落库内容。
读到合同号:表 3 成功,表 4 起草行待返利领取。
未尽调或失败:表 3 失败,表 4 记失败并不再领。
查重多条或提交后没读到号:表 3 停在 3,不写表 4。同一 oms_id 只留一行。
"""
kind = outcome.kind
number = (outcome.contract_no or "").strip() or None
if kind == KIND_HOLD:
return GroupSave(
fids=[row.fid for row in rows],
process_status=3,
process_result=clip(outcome.reason or LOOKUP_ONLY_RESULT, PROCESS_RESULT_MAX),
table4_rows=[],
)
if kind == KIND_OK and number:
process_status = 1
process_result = "合同已提交"
contract_status = 1
fail_reason = None
contract_no = number
table4_process_status = TABLE4_PENDING
elif kind == KIND_DILIGENCE:
process_status = 2
process_result = clip("客户未尽调", PROCESS_RESULT_MAX)
contract_status = 3
fail_reason = "客户未尽调"
contract_no = None
table4_process_status = TABLE4_SKIP
else:
reason = outcome.reason or "合同录入失败"
if kind == KIND_OK and not number:
reason = "submit 模式未读取到真实合同编号"
process_status = 2
process_result = clip(reason, PROCESS_RESULT_MAX)
contract_status = 3
fail_reason = clip(reason, FAIL_REASON_MAX)
contract_no = None
table4_process_status = TABLE4_SKIP
seen: dict[int, PurchaseContractRow] = {}
for row in rows:
seen.setdefault(row.oms_id, row)
table4 = [
{
"oms_id": row.oms_id,
"action_code": 1,
"contract_status": contract_status,
"contract_fail_reason": fail_reason,
"contract_no": contract_no,
"agreement_status": None,
"agreement_fail_reason": None,
"agreement_id": None,
"agreement_dtl_id": None,
"push_status": 0,
"process_status": table4_process_status,
"org_code": row.org_code,
"create_user_id": 0,
"create_user_name": USER_NAME,
"update_user_id": 0,
"update_user_name": USER_NAME,
}
for row in seen.values()
]
return GroupSave(
fids=[row.fid for row in rows],
process_status=process_status,
process_result=process_result,
table4_rows=table4,
)

View File

@ -0,0 +1,30 @@
[project]
name = "rpa-contract"
version = "0.1.0"
description = "洁达合同班。表 3 合单录入 → 表 4。接口 4 组包在 rpa_oms。"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"oracledb>=2.4.1",
"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

350
rpa_contract/repo.py Normal file
View File

@ -0,0 +1,350 @@
# 表 3 领取、表 4 写入。
#
# 从表 3 领取 process_status=0 的行改成 3,合单成功后写表 4、表 3 改成 1。
# 进程中途退出时行停在 3,超过租约(process_time 30 分钟)才能再领。
# 跳过协议重录行。不推接口 4。
from __future__ import annotations
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from decimal import Decimal
from typing import Any
from rpa_oracle import RepoUnavailable, job_pool, update_by_fid_chunks
from rpa_oracle.audit import ShiftError, save_shift_errors as write_shift_errors
LEASE_MINUTES = 30
_SELECT_BODY = """
SELECT
fid, oms_id, supplier_id, supplier_name,
contract_sign_date, contract_start_date, contract_end_date,
payment_term, goods_id, goods_name, reg_no, spec, unit,
qty, unit_price, purchaser_id, purchaser_name, planner_id, planner_name,
contract_entry_status, contract_no, process_status, org_code,
goods_mdm_code, supplier_mdm_code,
NVL(process_result, '') AS process_result
FROM rpa_purchase_contract_data
WHERE (
process_status = 0
OR (process_status = 3 AND process_time < SYSDATE - NUMTODSINTERVAL(:lease_minutes, 'MINUTE'))
)
AND NOT (NVL(contract_entry_status, -1) = 0
AND contract_no IS NOT NULL
AND LENGTH(TRIM(contract_no)) > 0)
ORDER BY fid
"""
_SELECT_PENDING = _SELECT_BODY + "\nFOR UPDATE SKIP LOCKED"
_SELECT_PENDING_LIMIT = f"""
SELECT * FROM (
{_SELECT_BODY}
) WHERE ROWNUM <= :limit
FOR UPDATE SKIP LOCKED
"""
_CLAIM_SQL = """
UPDATE rpa_purchase_contract_data
SET process_status = 3,
process_time = SYSDATE,
update_time = SYSDATE
WHERE fid IN ({placeholders})
"""
_INSERT_TABLE4_SQL = """
INSERT INTO rpa_contract_agreement_result (
oms_id, action_code, contract_status, contract_fail_reason, contract_no,
agreement_status, agreement_fail_reason, agreement_id, agreement_dtl_id,
push_status, process_status, create_time, update_time,
create_user_id, create_user_name, update_user_id, update_user_name, org_code
) VALUES (
:oms_id, :action_code, :contract_status, :contract_fail_reason, :contract_no,
:agreement_status, :agreement_fail_reason, :agreement_id, :agreement_dtl_id,
0, :process_status, SYSTIMESTAMP, SYSTIMESTAMP,
0, '系统管理员', 0, '系统管理员', :org_code
)
"""
_COMPLETE_SQL = """
UPDATE rpa_purchase_contract_data
SET process_status = :process_status,
process_result = :process_result,
process_time = SYSTIMESTAMP,
update_time = SYSTIMESTAMP
WHERE fid = :fid AND process_status = 3
"""
_TABLE4_BIND_KEYS = (
"oms_id",
"action_code",
"contract_status",
"contract_fail_reason",
"contract_no",
"agreement_status",
"agreement_fail_reason",
"agreement_id",
"agreement_dtl_id",
"process_status",
"org_code",
)
# 表 3 一行待录或处理中的采购合同。
@dataclass
class PurchaseContractRow:
fid: int
oms_id: int
supplier_id: str
supplier_name: str
contract_sign_date: date | None
contract_start_date: date | None
contract_end_date: date | None
payment_term: int
goods_id: str
goods_name: str
reg_no: str
spec: str
unit: str
qty: Decimal | float | int
unit_price: Decimal | float | int
purchaser_id: str
purchaser_name: str
planner_id: str
planner_name: str
contract_entry_status: int | None
contract_no: str | None
process_status: int
org_code: str | None
goods_mdm_code: str | None
supplier_mdm_code: str | None
process_result: str = ""
process_time: datetime | None = None
# 判断是否只录协议行:contract_entry_status=0 且已有合同号。
def _is_protocol_rerun(row: PurchaseContractRow) -> bool:
if row.contract_entry_status is not None and int(row.contract_entry_status) == 0:
return bool((row.contract_no or "").strip())
return False
# 判断内存行是否可领:0,或 3 且 process_time 已过租约。协议重录行永不领。
def _is_claimable(row: PurchaseContractRow, now: datetime) -> bool:
if _is_protocol_rerun(row):
return False
if int(row.process_status or 0) == 0:
return True
if int(row.process_status or 0) == 3 and row.process_time is not None:
return now - row.process_time >= timedelta(minutes=LEASE_MINUTES)
return False
# 回滚当前事务。回滚本身失败则忽略。
def _rollback(conn) -> None:
try:
conn.rollback()
except Exception:
pass
# 把查询结果行收成 PurchaseContractRow。
def _row_from_record(cols: list[str], rec) -> PurchaseContractRow:
data = dict(zip(cols, rec))
return PurchaseContractRow(
fid=int(data["fid"]),
oms_id=int(data["oms_id"]),
supplier_id=data["supplier_id"],
supplier_name=data["supplier_name"],
contract_sign_date=data["contract_sign_date"],
contract_start_date=data["contract_start_date"],
contract_end_date=data["contract_end_date"],
payment_term=int(data["payment_term"]),
goods_id=data["goods_id"],
goods_name=data["goods_name"],
reg_no=data["reg_no"],
spec=data["spec"],
unit=data["unit"],
qty=data["qty"],
unit_price=data["unit_price"],
purchaser_id=data["purchaser_id"],
purchaser_name=data["purchaser_name"],
planner_id=data["planner_id"],
planner_name=data["planner_name"],
contract_entry_status=data["contract_entry_status"],
contract_no=data["contract_no"],
process_status=int(data["process_status"]),
org_code=data["org_code"],
goods_mdm_code=data["goods_mdm_code"],
supplier_mdm_code=data["supplier_mdm_code"],
process_result=data.get("process_result") or "",
)
# 合同班次的内存仓库,测合单和班次编排,不测 SQL。
class MemoryContractRepo:
# 用给定的表 3 行初始化。同一 fid 后写覆盖先写。
def __init__(self, rows: list[PurchaseContractRow]):
self.table3 = {row.fid: row for row in rows}
self.table4: list[dict] = []
self.shift_errors: list[ShiftError] = []
self._next_fid = 1
# 领取待录合同任务,把 process_status 从 0 或过期 3 改成 3。
#
# 未过租约的 3 不领。协议重录行不领。limit 为空表示不限制条数。now 只给单测钉住时钟。
def claim_pending(
self, limit: int | None = None, *, now: datetime | None = None
) -> list[PurchaseContractRow]:
clock = now or datetime.now()
pending = sorted(
(row for row in self.table3.values() if _is_claimable(row, clock)),
key=lambda row: row.fid,
)
if limit is not None:
pending = pending[:limit]
for row in pending:
row.process_status = 3
row.process_time = clock
return pending
# 把合单结果写入内存表 4,并改表 3 的 process_status。
def save_group(self, payload: Any) -> None:
for row in payload.table4_rows:
item = dict(row)
item["fid"] = self._next_fid
self._next_fid += 1
self.table4.append(item)
for fid in payload.fids:
target = self.table3[fid]
target.process_status = payload.process_status
target.process_result = payload.process_result
# 记下未捕获异常原文。不改表 3 / 表 4。
def save_shift_errors(self, items: list[ShiftError]) -> None:
self.shift_errors.extend(items)
# 合同班次连 Oracle 的仓库,管表 3 领取和表 4 写入。
#
# 连接从 rpa_oracle 连接池取。领取戳是 process_status=3。
# 进程退出后行停在 3,超过租约才能再领。
class OracleContractRepo:
# 挂上连接池。方法里按次 acquire,班次 session 内复用一条。
def __init__(self, pool):
self._pool = pool
self._held = None
# 按环境变量建班次池。缺 HOST / 用户 / 密码抛「数据库不可用」。
@classmethod
def from_env(cls) -> "OracleContractRepo":
return cls(pool=job_pool())
# 未捕获异常原文进共用审计表。独立事务,失败只记日志。
def save_shift_errors(self, items: list[ShiftError]) -> None:
write_shift_errors(self._pool, items)
# 班次内借一条连接,领取和落表共用。每笔 save_group 单独提交。
@contextmanager
def session(self):
if self._held is not None:
yield
return
with self._pool.acquire() as conn:
self._held = conn
try:
yield
finally:
self._held = None
# 有 session 时交出已借连接,否则从池里新借一条。
def _acquire(self):
if self._held is not None:
# 交出班次已借的那条连接,退出时不归还。
@contextmanager
def _held():
yield self._held
return _held()
return self._pool.acquire()
def claim_pending(self, limit: int | None = None) -> list[PurchaseContractRow]:
"""领取待录合同任务,把表 3 的 process_status 从 0 改成 3。
使用 FOR UPDATE SKIP LOCKED,两个班次不会领到同一行。
更新按每段 500 个 fid 执行,同一事务,任一段失败整笔回滚。
进程退出后行停在 3,超过租约(process_time)才能再领。
limit 仍只截这一班领多少;为空表示不限制。没有可领的行时返回空列表。
"""
binds: dict = {"lease_minutes": LEASE_MINUTES}
try:
with self._acquire() as conn:
try:
with conn.cursor() as cur:
if limit is None:
cur.execute(_SELECT_PENDING, binds)
else:
cur.execute(
_SELECT_PENDING_LIMIT,
{**binds, "limit": int(limit)},
)
cols = [item[0].lower() for item in cur.description]
fetched = [_row_from_record(cols, rec) for rec in cur.fetchall()]
if fetched:
update_by_fid_chunks(
cur,
_CLAIM_SQL,
[row.fid for row in fetched],
)
for row in fetched:
row.process_status = 3
conn.commit()
return fetched
except Exception as exc:
_rollback(conn)
if isinstance(exc, RepoUnavailable):
raise
raise RepoUnavailable("数据库不可用") from exc
except RepoUnavailable:
raise
except Exception as exc:
raise RepoUnavailable("数据库不可用") from exc
# 把合单结果写入表 4,并把表 3 的 process_status 改成 1 或 2。
#
# 表 4 插入和表 3 更新各一次 executemany,同一连接同一事务。
# 某一侧没有行就跳过那一次调用。库故障对外「数据库不可用」,不含 ORA 原文。
def save_group(self, payload: Any) -> None:
table4_binds = [
{key: row.get(key) for key in _TABLE4_BIND_KEYS}
for row in payload.table4_rows
]
complete_binds = [
{
"process_status": payload.process_status,
"process_result": payload.process_result,
"fid": fid,
}
for fid in payload.fids
]
try:
with self._acquire() as conn:
try:
with conn.cursor() as cur:
if table4_binds:
cur.executemany(_INSERT_TABLE4_SQL, table4_binds)
if complete_binds:
cur.executemany(_COMPLETE_SQL, complete_binds)
conn.commit()
except Exception as exc:
_rollback(conn)
if isinstance(exc, RepoUnavailable):
raise
raise RepoUnavailable("数据库不可用") from exc
except RepoUnavailable:
raise
except Exception as exc:
raise RepoUnavailable("数据库不可用") from exc

175
rpa_contract/run_batch.py Normal file
View File

@ -0,0 +1,175 @@
# 合同班次入口:领表 3、调页面流、写表 4。不推接口 4。
#
# 崩在 process_status=3。账密读 CONTRACT_USERNAME / CONTRACT_PASSWORD,缺则失败。
from __future__ import annotations
import argparse
from apa_browser_adapter import ApaBrowserAdapter
from contract_create_node import CONTRACT_URL, ContractCreateInput, build_flow_context
from contract_draft import ContractDraft
from contract_flow_steps import run_contract_create_flow
from pathlib import Path
from rpa_oracle import load_job_env
from job import outcome_from_run, run_batch
from persist import KIND_DILIGENCE, KIND_HOLD, KIND_OK, LOOKUP_ONLY_RESULT, ContractOutcome
from repo import OracleContractRepo
DEFAULT_CLAIM_LIMIT = 10
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"""解析合同班次参数。不传 --limit 时按行领 10 条,不再全部领走。"""
parser = argparse.ArgumentParser(description="合同班次:领表 3、按供应商合单、写表 4")
parser.add_argument(
"--limit",
type=int,
default=DEFAULT_CLAIM_LIMIT,
help="本班最多领取 N 行,默认 10",
)
return parser.parse_args(argv)
def contract_lookup_title(draft: ContractDraft) -> str:
"""拼查重标题:客商名 + 采购协议 + 签订日。不含货主和起止日,也不是合单键。"""
who = (draft.customer_name or draft.supplier_name or "").strip()
sign = (draft.contract_sign_date or draft.order_date or "").replace("-", "")
return f"{who}采购协议{sign}"
def decide_existing_contracts(
numbers: list[str], *, lookup_only: bool
) -> ContractOutcome | None:
"""按「我的合同」命中行决定这一组。
0 条且还可以起草时返回 None,调用方才走 S06–S12。
恰好 1 条复用该合同号,只落表 4。多条停在处理中,不取最新一条。
只查号时 0 条也停在处理中。
"""
hits = list(numbers)
if len(hits) > 1:
return ContractOutcome(KIND_HOLD, None, "我的合同命中多条")
if len(hits) == 1:
number = (hits[0] or "").strip()
if not number:
return ContractOutcome(KIND_HOLD, None, LOOKUP_ONLY_RESULT)
return ContractOutcome(KIND_OK, number, None)
if lookup_only:
return ContractOutcome(KIND_HOLD, None, LOOKUP_ONLY_RESULT)
return None
def _pull_back_to_draft(browser) -> None:
"""一组开始前离开上一张合同的页面,回到起草页。"""
from contract_common import FORM_PAGE_URL
browser.open_url(FORM_PAGE_URL)
def _flow_context(draft: ContractDraft):
"""按这组草稿建页面上下文。账密仍只认环境变量。"""
spec = ContractCreateInput(
job_id="contract-batch",
supplier_name=draft.supplier_name,
order_date=draft.order_date,
source_type=draft.source_type or "",
submit_mode="submit",
)
return build_flow_context(spec, draft)
def _lookup_my_contracts(browser, draft: ContractDraft) -> list[str]:
"""进起草前查「我的合同」。命中行的合同号原样返回,不取最新一条。"""
from contract_number import (
_open_my_contracts,
_scrape_my_contract_rows,
_search_my_contracts,
)
ctx = _flow_context(draft)
_open_my_contracts(browser, ctx)
_search_my_contracts(
browser,
contract_lookup_title(draft),
(draft.customer_code or "").strip(),
)
rows = _scrape_my_contract_rows(browser)
return [str(row.get("no") or "") for row in rows]
def _create_and_read_number(browser, draft: ContractDraft) -> ContractOutcome:
"""0 条时走 S06–S12。提交后读不到号则停在处理中,下一班只查号。"""
_pull_back_to_draft(browser)
ctx = _flow_context(draft)
def run() -> str:
"""在已打开的浏览器里跑起草并返回合同号。"""
return run_contract_create_flow(browser, ctx)
outcome = outcome_from_run(run)
if outcome.kind == KIND_OK and (outcome.contract_no or "").strip():
return outcome
if outcome.kind == KIND_DILIGENCE:
return outcome
if outcome.kind == KIND_FAIL and "未读取到真实合同编号" in (outcome.reason or ""):
return ContractOutcome(KIND_HOLD, None, LOOKUP_ONLY_RESULT, outcome.error_text)
return outcome
def production_runner(
browser: ApaBrowserAdapter,
draft: ContractDraft,
*,
find_contracts=None,
create=None,
) -> ContractOutcome:
"""每个合单组先拉回起草页,再查「我的合同」。
查重三态:0 条才走 S06–S12;恰好 1 条只落表 4,不起草第二份;
多条停在 process_status=3,不新建,也不取最新一条。
标题是客商名 + 「采购协议」 + 签订日,不含货主和起止日。标题和客商不是合单键。
提交后读不到号也停在 3,下一班只查号。
"""
if find_contracts is None:
_pull_back_to_draft(browser)
numbers = _lookup_my_contracts(browser, draft)
else:
numbers = list(find_contracts(browser, draft))
decided = decide_existing_contracts(
numbers,
lookup_only=bool(getattr(draft, "lookup_only", False)),
)
if decided is not None:
return decided
if create is None:
return _create_and_read_number(browser, draft)
number = (create(browser, draft) or "").strip()
if number:
return ContractOutcome(KIND_OK, number, None)
return ContractOutcome(KIND_HOLD, None, LOOKUP_ONLY_RESULT)
def main(argv: list[str] | None = None, repo=None, runner=None) -> int:
"""领表 3、按供应商合单录合同、写表 4。有失败则进程退出码 1。
不传 --limit 时最多领 10 行。传入 repo 和 runner 时不打开浏览器。
"""
args = parse_args(argv)
load_job_env(Path(__file__).resolve().parent)
if repo is None:
repo = OracleContractRepo.from_env()
if runner is None:
browser = ApaBrowserAdapter(url=CONTRACT_URL, flow="CONTRACT_CREATE")
runner = lambda draft: production_runner(browser, draft)
summary = run_batch(repo, runner=runner, limit=args.limit)
print(
f"claimed={summary.claimed} groups={summary.groups} "
f"ok={summary.ok} failed={summary.failed}"
)
return 0 if summary.failed == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

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

View File

@ -0,0 +1,121 @@
# 合同浏览器适配:profile 在仓库外、调试口稳定、端口/DevTools 用轮询。
from __future__ import annotations
import socket
import threading
import time
from pathlib import Path
from apa_browser_adapter import (
ApaBrowserAdapter,
REPO_ROOT,
chrome_debug_port,
resolve_chrome_profile_base,
resolve_chrome_profile_dir,
wait_debug_port,
wait_devtools_port,
)
# 合同 ApaBrowserAdapter 就是 rpa_browser 里的那一份。
def test_adapter_class_is_shared_rpa_browser():
from rpa_browser.adapter import ApaBrowserAdapter as Shared
assert ApaBrowserAdapter is Shared
# 未配环境变量时 Chrome 用户目录落在仓库外 rpa-chrome-profile。
def test_default_chrome_profile_is_outside_repo():
profile = resolve_chrome_profile_base(requested="")
assert profile == (REPO_ROOT.parent / "rpa-chrome-profile").resolve()
assert REPO_ROOT.resolve() not in profile.parents
assert profile != REPO_ROOT.resolve()
# RPA_CHROME_PROFILE 指到仓库内时改用仓库外默认目录。
def test_env_path_inside_repo_is_rejected(tmp_path, monkeypatch):
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()
assert not _is_relative_to(profile, REPO_ROOT)
# RPA_CHROME_PROFILE 在仓库外时按原路径使用。
def test_env_path_outside_repo_is_kept(tmp_path):
outside = tmp_path / "custom-chrome"
outside.mkdir()
profile = resolve_chrome_profile_base(requested=str(outside))
assert profile == outside.resolve()
# 同一 flow 的 profile 目录稳定,且不落在仓库内。
def test_chrome_profile_dir_is_stable_per_flow():
first = resolve_chrome_profile_dir("CONTRACT_CREATE")
second = resolve_chrome_profile_dir("CONTRACT_CREATE")
assert first == second
assert first == (REPO_ROOT.parent / "rpa-chrome-profile" / "CONTRACT_CREATE").resolve()
assert not _is_relative_to(first, REPO_ROOT)
# CONTRACT_CREATE 默认调试口 9333,同一 flow 两次相同。
def test_chrome_debug_port_is_stable_per_flow(monkeypatch):
monkeypatch.delenv("RPA_CHROME_DEBUG_PORT", raising=False)
assert chrome_debug_port("CONTRACT_CREATE") == 9333
assert chrome_debug_port("CONTRACT_CREATE") == chrome_debug_port("CONTRACT_CREATE")
# 端口已开时 wait_debug_port 立刻返回 True。
def test_wait_debug_port_true_when_open():
srv = socket.socket()
srv.bind(("127.0.0.1", 0))
srv.listen(1)
port = srv.getsockname()[1]
try:
assert wait_debug_port(port, timeout=1.0) is True
finally:
srv.close()
# 端口一直关着时短超时轮询后返回 False,整段远小于无条件 sleep(1)。
def test_wait_debug_port_false_on_timeout():
port = _closed_port()
started = time.monotonic()
assert wait_debug_port(port, timeout=0.25) is False
assert time.monotonic() - started < 0.8
# profile 里已有 DevToolsActivePort 时立刻读出端口。
def test_wait_devtools_port_reads_marker(tmp_path):
(tmp_path / "DevToolsActivePort").write_text("9333\n/devtools/browser/x\n", encoding="utf-8")
assert wait_devtools_port(tmp_path, timeout=0.5) == 9333
# DevToolsActivePort 稍后出现时轮询仍能读到端口。
def test_wait_devtools_port_picks_up_late_marker(tmp_path):
# 稍后写入 DevToolsActivePort,用来证明 wait 是轮询不是一次读。
def _write() -> None:
time.sleep(0.15)
(tmp_path / "DevToolsActivePort").write_text("9335\n", encoding="utf-8")
threading.Thread(target=_write, daemon=True).start()
assert wait_devtools_port(tmp_path, timeout=1.0) == 9335
# 借一个刚释放的本机端口,调用时应当是关着的。
def _closed_port() -> int:
srv = socket.socket()
srv.bind(("127.0.0.1", 0))
port = srv.getsockname()[1]
srv.close()
return port
# path 是否落在 root 目录树内。
def _is_relative_to(path: Path, root: Path) -> bool:
try:
path.resolve().relative_to(root.resolve())
except ValueError:
return False
return True

View File

@ -0,0 +1,63 @@
# 合同系统账密:只认环境变量或入参,仓库里没有默认密码。
from __future__ import annotations
from pathlib import Path
import pytest
from contract_create_node import (
ContractCreateError,
ContractCreateInput,
resolve_contract_credentials,
)
# 不设 CONTRACT_USERNAME / CONTRACT_PASSWORD 且无入参时入口失败。
def test_resolve_credentials_requires_env(monkeypatch):
monkeypatch.delenv("CONTRACT_USERNAME", raising=False)
monkeypatch.delenv("CONTRACT_PASSWORD", raising=False)
with pytest.raises(ContractCreateError, match="CONTRACT_USERNAME"):
resolve_contract_credentials()
# 环境变量齐时返回账密。
def test_resolve_credentials_uses_env(monkeypatch):
monkeypatch.setenv("CONTRACT_USERNAME", "u")
monkeypatch.setenv("CONTRACT_PASSWORD", "p")
assert resolve_contract_credentials() == ("u", "p")
# 入参优先于环境变量。
def test_resolve_credentials_prefers_explicit(monkeypatch):
monkeypatch.setenv("CONTRACT_USERNAME", "env-u")
monkeypatch.setenv("CONTRACT_PASSWORD", "env-p")
assert resolve_contract_credentials("u", "p") == ("u", "p")
# ContractCreateInput 不再内置测试账密。
def test_create_input_has_no_default_password():
spec = ContractCreateInput(
job_id="j",
supplier_name="s",
order_date="2026-10-01",
source_type="",
)
assert spec.username == ""
assert spec.password == ""
# 合同包源码里搜不到默认账号 10007228。
def test_source_has_no_hardcoded_contract_account():
root = Path(__file__).resolve().parent.parent
skipped = {"tests", ".venv", "__pycache__"}
hits: list[str] = []
for path in root.rglob("*"):
if any(part in skipped for part in path.parts):
continue
if path.suffix not in {".py", ".md", ".json"}:
continue
text = path.read_text(encoding="utf-8")
if "10007228" in text:
hits.append(str(path.relative_to(root)))
assert hits == []

View File

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

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