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
.idea/
.vscode/
!rpa_selection/.vscode/
!rpa_selection/.vscode/**
*.swp
# Python
@ -17,11 +19,12 @@ __pycache__/
# Node
node_modules/
state_center_web/dist/
# Local runtime / agent
.runtime/
rpa_yisaiqi/screenshots/
rpa_contract/screenshots/
rpa_rebate/screenshots/
rpa_cart/exports/
rpa-chrome-profile/
**/chrome-profile/
.tmp/
@ -29,9 +32,17 @@ rpa-chrome-profile/
.grokignore
.claude/
.superpowers/
.worktrees/
# Duplicate / scratch
state_center_副本/
.env
.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)
*.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
import os
@ -11,16 +9,20 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Protocol
from .errors import NodeError
from errors import NodeError
PACKAGE_ROOT = Path(__file__).resolve().parents[1]
PACKAGE_ROOT = Path(__file__).resolve().parent
SCREENSHOT_ROOT = PACKAGE_ROOT / "screenshots"
# 能按名字截图的浏览器。
class HasScreenshot(Protocol):
def screenshot(self, name: str) -> str: ...
# 按名字截一张图,返回保存路径。
def screenshot(self, name: str) -> str:
...
# 验收的一步:步骤号、标题、是否通过、读到的事实、对不上的字段。
@dataclass
class AcceptLine:
step: str
@ -30,23 +32,26 @@ class AcceptLine:
mismatches: list[str]
# 把流程名收成目录名。空名用 default,其余只留字母、数字、横线和底线。
def safe_flow_name(flow: str) -> str:
text = (flow or "default").strip() or "default"
return "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in text)
# 这个流程的截图和 ACCEPT.txt 放在哪。
def artifact_dir(flow: str) -> Path:
return SCREENSHOT_ROOT / safe_flow_name(flow)
# 默认只在失败时截图。RPA_SCREENSHOT=always 时每步都留。
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()
if not failed and mode != "always":
return ""
return browser.screenshot(name)
# ACCEPT.txt 的路径。RPA_ACCEPT_LOG 可改到别处,单测用这个钉住文件。
def accept_log_path(flow: str = "default") -> Path:
override = os.environ.get("RPA_ACCEPT_LOG")
if override:
@ -54,6 +59,7 @@ def accept_log_path(flow: str = "default") -> Path:
return artifact_dir(flow) / "ACCEPT.txt"
# 页面读数和期望是否同一值。空期望算通过;数字、是否、去空白后包含也算对上。
def field_match(actual: str, expected: str) -> bool:
actual_text = (actual 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
# 把一步收成控制台一行:ACCEPT、步骤、ok 或 FAIL、标题和读到的字段。
def format_accept(line: AcceptLine) -> str:
status = "ok" if line.ok else "FAIL"
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()
# 一次页面流的验收单。每步打印并覆写 ACCEPT.txt,对不上就抛 NodeError。
@dataclass
class AcceptanceReport:
"""DOM / page-state checklist. One instance per flow run."""
flow: str = "default"
lines: list[AcceptLine] = field(default_factory=list)
# 清空已记的步骤,下一次页面流重新计。
def reset(self) -> None:
self.lines = []
# 核对一组字段。对不上就记 FAIL、失败时截图,并抛 NodeError 停下这一步。
def check(
self,
step: str,
@ -122,6 +130,7 @@ class AcceptanceReport:
evidence_shot(browser, screenshot_on_fail, failed=True)
raise NodeError(f"ACCEPT {step} FAIL " + "; ".join(mismatches))
# 记一步事实,不和期望逐字段比。ok 为假且 raise_error 时抛 NodeError。
def note(
self,
step: str,
@ -138,6 +147,7 @@ class AcceptanceReport:
if not ok and raise_error:
raise NodeError(f"ACCEPT {step} FAIL {title} " + " ".join(f"{k}={v}" for k, v in rendered.items()))
# 把当前汇总写进 ACCEPT.txt。写失败只打印,不把页面流打成另一次错误。
def dump(self) -> None:
path = accept_log_path(self.flow)
try:
@ -146,6 +156,7 @@ class AcceptanceReport:
except OSError as exc:
print(f"ACCEPT log write skipped: {exc}", flush=True)
# 汇总文本:第一行是通过数,后面是每步的 ACCEPT 行。
def summary_text(self) -> str:
passed = sum(1 for line in self.lines if line.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
import argparse
import json
import os
import sys
from dataclasses import dataclass, replace
from pathlib import Path
from typing import Any, Callable
from .apa_browser_adapter import ApaBrowserAdapter
from .callback_client import StateCenterClient
from .contract_draft import (
from apa_browser_adapter import ApaBrowserAdapter
from contract_draft import (
ContractDraft,
contract_draft_from_dict,
is_placeholder_customer_code,
load_contract_draft,
)
from .contract_flow_steps import ContractFlowContext, run_contract_create_flow
from .pdf_utils import ensure_merged_pdf
from .runtime.errors import NodeError, PdfMergeUnsupported, UnknownRpaError
from .runtime.shell import execute_node
from contract_flow_steps import ContractFlowContext, run_contract_create_flow
from errors import NodeError, PdfMergeUnsupported, UnknownRpaError
from pdf_utils import ensure_merged_pdf
NODE_CODE = "CONTRACT_CREATE"
CONTRACT_URL = "http://contract-test.gzmpc.com:8080/"
# 入参缺失 / 页面元素找不到 / 页面改版。
class ContractCreateError(NodeError):
"""入参缺失 / 页面元素找不到 / 页面改版,均映射 UI_ELEMENT_NOT_FOUND。"""
def __init__(self, message: str = "") -> None:
# Studio 编译出来的旧 S01 在 input_arg 为空时会直接 raise。
# 这里拦下来改走默认联调 JSON,避免每次改 Main.py 都被覆盖。
if _should_bootstrap_default_input(message):
_bootstrap_default_input_and_exit()
super().__init__(message)
pass
# 返回本包 examples 下默认干跑 JSON 的路径。
def default_contract_input_path() -> str:
return str(
Path(__file__).resolve().parent / "examples" / "contract_create_input_dry_run.json"
)
def _should_bootstrap_default_input(message: str) -> bool:
if "缺少业务入参" not in str(message):
return False
script = os.path.basename(str(sys.argv[0] if sys.argv else "")).lower()
return script == "main.py"
def _bootstrap_default_input_and_exit() -> None:
path = default_contract_input_path()
if not Path(path).is_file():
return
print("未传入 -i/--input,使用默认联调入参:", path, flush=True)
codes_dir = Path(__file__).resolve().parent / "studio" / "codes"
if str(codes_dir) not in sys.path:
sys.path.insert(0, str(codes_dir))
from run_contract import main
raise SystemExit(main())
# 一次合同起草的入参。账密为空时由 resolve_contract_credentials 补环境变量。
@dataclass(frozen=True)
class ContractCreateInput:
job_id: str
@ -72,8 +47,8 @@ class ContractCreateInput:
contract_system_no: str | None = None
attempt_no: int = 1
contract_url: str = CONTRACT_URL
username: str = "test002"
password: str = "1"
username: str = ""
password: str = ""
submit_mode: str = "submit"
order_no_list: tuple[str, ...] = ()
template_name: str = ""
@ -85,46 +60,52 @@ class ContractCreateInput:
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(
spec: ContractCreateInput,
client: StateCenterClient,
*,
contract_runner: ContractRunner | None = None,
) -> dict[str, Any]:
runner = contract_runner or create_contract_in_contract_system
# dry_run:只跑到提交前,不回调状态中心(不 start/complete/fail)
if spec.submit_mode == "dry_run":
runner(spec)
return {"status": "dry_run", "submitted": False}
def _run() -> dict[str, Any]:
try:
contract_no = runner(spec)
except NodeError:
raise
except Exception as exc:
raise UnknownRpaError(str(exc)) from exc
if not contract_no:
raise ContractCreateError("submit 模式未读取到真实合同编号,无法 complete")
return {
"businessRefs": {"contractSystemNo": contract_no},
"summary": _build_summary(spec),
"artifacts": [],
}
return execute_node(
client,
spec.job_id,
NODE_CODE,
_run,
attempt_no=spec.attempt_no,
operator="agent",
)
try:
contract_no = runner(spec)
except NodeError:
raise
except Exception as exc:
raise UnknownRpaError(str(exc)) from exc
if not contract_no:
raise ContractCreateError("submit 模式未读取到真实合同编号")
return {
"status": "done",
"contractSystemNo": contract_no,
"summary": _build_summary(spec),
}
# 在合同系统中起草合同并返回合同编号。
def create_contract_in_contract_system(spec: ContractCreateInput) -> str:
"""在合同系统中起草合同并返回合同编号。"""
if 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)
# 把 JSON 字典收成 ContractCreateInput,并挂上页面上下文。
def build_spec_from_data(data: dict[str, Any]) -> ContractCreateInput:
draft = contract_draft_from_dict(data)
_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")),
attempt_no=int(data.get("attemptNo", 1) or 1),
contract_url=_clean(data.get("contractUrl")) or os.getenv("CONTRACT_URL", CONTRACT_URL),
username=_clean(data.get("username")) or os.getenv("CONTRACT_USERNAME", "test002"),
password=str(data.get("password") or os.getenv("CONTRACT_PASSWORD", "1")),
username=_clean(data.get("username")),
password="" if data.get("password") in (None, "") else str(data.get("password")),
submit_mode=draft.submit_mode,
order_no_list=tuple(draft.order_no_list),
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)
# 把草稿和入参收成页面上下文。账密缺环境变量时在这里失败。
def build_flow_context(spec: ContractCreateInput, draft: ContractDraft) -> ContractFlowContext:
merged_pdf_path = _resolve_merged_pdf(draft, spec)
username, password = resolve_contract_credentials(spec.username, spec.password)
return ContractFlowContext(
contract_url=spec.contract_url,
username=spec.username,
password=spec.password,
username=username,
password=password,
supplier_name=draft.supplier_name or spec.supplier_name,
order_date=draft.order_date or spec.order_date,
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,
contract_title=draft.contract_title,
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_prepayment=draft.is_prepayment,
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:
if draft.source_type != "YYC":
return None
@ -226,6 +213,7 @@ def _resolve_merged_pdf(draft: ContractDraft, spec: ContractCreateInput) -> str
raise PdfMergeUnsupported(f"YYC 平台合同 PDF 合并失败:{exc}") from exc
# 组装提交成功后的摘要字段。
def _build_summary(spec: ContractCreateInput) -> dict[str, Any]:
return {
"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:
missing = []
for key in ("jobId", "supplierName", "orderDate", "sourceType", "customerCode"):
if not _str(data.get(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"):
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))
# 取出必填字符串。缺或空白则失败。
def _require(data: dict[str, Any], key: str) -> str:
value = _str(data.get(key))
if not value:
@ -281,17 +264,20 @@ def _require(data: dict[str, Any], key: str) -> str:
return value
# 把值收成非空字符串,空白则 None。
def _opt(value: Any) -> str | None:
value = _str(value)
return value or None
# 把任意值收成去两端空白的字符串。None 当空。
def _str(value: Any) -> str:
if value is None:
return ""
return str(value).strip()
# 去掉两端空白和成对反引号。
def _clean(value: Any) -> str:
value = _str(value)
if len(value) >= 2 and value[0] == "`" and value[-1] == "`":
@ -299,8 +285,8 @@ def _clean(value: Any) -> str:
return value
# 解析入参:优先按 JSON 字符串,其次按 JSON 文件路径。
def load_input_data(raw: str) -> dict[str, Any]:
"""解析入参:优先按 JSON 字符串,其次按 JSON 文件路径。"""
raw = (raw or "").strip()
if not raw:
fallback = default_contract_input_path()
@ -321,21 +307,15 @@ def load_input_data(raw: str) -> dict[str, Any]:
raise ContractCreateError(f"无法解析入参:既不是合法 JSON,也不是存在的文件路径:{raw}")
def run_contract_create(
data: dict[str, Any],
*,
state_center_url: str,
agent_token: str,
) -> dict[str, Any]:
# 从 JSON 字典起草合同并返回结果。
def run_contract_create(data: dict[str, Any]) -> dict[str, Any]:
spec = build_spec_from_data(data)
client = StateCenterClient(state_center_url, agent_token)
return execute_contract_create(spec, client)
return execute_contract_create(spec)
# 解析命令行。账密默认读环境变量,没有内置账号。
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run YiSaiQi CONTRACT_CREATE node.")
parser.add_argument("--state-center-url", default=os.getenv("STATE_CENTER_URL", "http://127.0.0.1:8000"))
parser.add_argument("--token", default=os.getenv("AGENT_TOKEN", "dev-agent-token"))
parser = argparse.ArgumentParser(description="合同系统页面起草。")
parser.add_argument("--input-json", default=os.getenv("CONTRACT_INPUT_JSON"))
parser.add_argument("--job-id")
parser.add_argument("--supplier-name")
@ -344,22 +324,23 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--contract-system-no", default=os.getenv("CONTRACT_SYSTEM_NO"))
parser.add_argument("--attempt-no", type=int, default=1)
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("--password", default=os.getenv("CONTRACT_PASSWORD", "1"))
parser.add_argument("--username", default=os.getenv("CONTRACT_USERNAME"))
parser.add_argument("--password", default=os.getenv("CONTRACT_PASSWORD"))
parser.add_argument("--draft-path", default=os.getenv("CONTRACT_DRAFT_PATH"))
parser.add_argument("--merged-pdf-output", default=os.getenv("MERGED_PDF_OUTPUT"))
return parser.parse_args()
# 命令行入口:JSON 或参数起草一份合同,打印 JSON 结果。
def main() -> int:
args = parse_args()
client = StateCenterClient(args.state_center_url, args.token)
if args.input_json:
spec = build_spec_from_data(load_input_data(args.input_json))
else:
if not (args.job_id and args.supplier_name and args.order_date):
raise SystemExit("缺少 --job-id/--supplier-name/--order-date 或 --input-json")
username, password = resolve_contract_credentials(args.username, args.password)
spec = ContractCreateInput(
job_id=args.job_id,
supplier_name=args.supplier_name,
@ -368,13 +349,13 @@ def main() -> int:
contract_system_no=args.contract_system_no,
attempt_no=args.attempt_no,
contract_url=args.contract_url,
username=args.username,
password=args.password,
username=username,
password=password,
draft_path=args.draft_path,
merged_pdf_output=args.merged_pdf_output,
)
result = execute_contract_create(spec, client)
result = execute_contract_create(spec)
print(json.dumps(result, ensure_ascii=False))
return 0

View File

@ -1,4 +1,5 @@
"""Contract draft input model for CONTRACT_CREATE."""
# 合同起草入参。页面流按这份草稿填客商、金额、日期和货品行。
from __future__ import annotations
import json
@ -6,17 +7,10 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
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)
from contract_flow_steps import ContractGoodsLine
# 一次起草要填的字段。班次从合单来,命令行从 JSON 来。
@dataclass(frozen=True)
class ContractDraft:
supplier_name: str
@ -35,6 +29,8 @@ class ContractDraft:
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 = ""
@ -48,13 +44,16 @@ class ContractDraft:
merged_pdf_path: str | None = None
contract_document_path: str = ""
order_no_list: list[str] = field(default_factory=list)
lookup_only: bool = False
# 从 JSON 文件读起草草稿。
def load_contract_draft(path: str | Path) -> ContractDraft:
data = json.loads(Path(path).read_text(encoding="utf-8"))
return contract_draft_from_dict(data)
# 把 JSON 字典收成起草草稿。缺的字段用页面上的默认值。
def contract_draft_from_dict(data: dict[str, Any]) -> ContractDraft:
goods_lines = [
ContractGoodsLine(
@ -87,6 +86,8 @@ def contract_draft_from_dict(data: dict[str, Any]) -> ContractDraft:
amount=str(data.get("amount", "")),
contract_title=str(data.get("contractTitle") or data.get("docSubject") or ""),
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_prepayment=str(data.get("isPrepayment", "否")),
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):
error_code = "UI_ELEMENT_NOT_FOUND"
retryable = False
route_role = "OPS"
# 合同系统登录失败。可以重试。
class LoginFailed(NodeError):
error_code = "SYS_LOGIN_FAIL"
retryable = True
route_role = "OPS"
# 供应商在合同系统里不能起草。不重试,交给采购。
class SupplierContractDisabled(NodeError):
error_code = "BIZ_CONTRACT_DISABLED"
retryable = False
route_role = "PURCHASER"
# YYC 多份平台 PDF 合并不了。不重试,交给运维。
class PdfMergeUnsupported(NodeError):
error_code = "CAP_PDF_MERGE_UNSUPPORTED"
retryable = False
route_role = "OPS"
# 页面流里没归类的异常。可以重试。
class UnknownRpaError(NodeError):
error_code = "RPA_UNKNOWN_ERROR"
retryable = True
route_role = "OPS"
# 客商未尽调。不提交合同,不重试,交给采购。
class DiligenceError(NodeError):
error_code = "BIZ_CUSTOMER_NOT_DILIGENCE"
retryable = False
route_role = "PURCHASER"

View File

@ -38,7 +38,5 @@
"mdmCode": "",
"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 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:
"""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:
raise ValueError("YYC contract creation requires at least one platform PDF")
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,
accept_log_path,
artifact_dir,
evidence_shot,
field_match,
)
from rpa_yisaiqi.runtime.errors import NodeError
from errors import NodeError
import pytest
def test_each_flow_writes_its_own_accept_file(tmp_path, monkeypatch):
monkeypatch.delenv("RPA_ACCEPT_LOG", raising=False)
monkeypatch.setattr(
"rpa_yisaiqi.runtime.accept.SCREENSHOT_ROOT",
"accept.SCREENSHOT_ROOT",
tmp_path,
)
report = AcceptanceReport(flow="ORDER_FETCH")

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