feat: 班次可重跑,避免对方单据重复

领取 UPDATE 按 500 个 fid 分段,同一事务,任一段失败整单回滚。选品一行一落并续租,系统故障改回待领。页面和商城调用期间归还连接。坏连接丢弃后再借一次,未提交语句只重试一次。

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

同一次提交带上本分支尚未入库的 Oracle 连接层(rpa_oracle / rpa_browser)及各班次改接。
This commit is contained in:
Yuuuu 2026-10-24 02:41:13 +08:00
parent ac19e60eb5
commit 586db4be90
159 changed files with 19078 additions and 11291 deletions

1
.gitignore vendored
View File

@ -24,6 +24,7 @@ node_modules/
.runtime/ .runtime/
rpa_contract/screenshots/ rpa_contract/screenshots/
rpa_rebate/screenshots/ rpa_rebate/screenshots/
rpa_cart/exports/
rpa-chrome-profile/ rpa-chrome-profile/
**/chrome-profile/ **/chrome-profile/
.tmp/ .tmp/

View File

@ -18,28 +18,31 @@
| 问题 | 打开 | 验收 | | 问题 | 打开 | 验收 |
| --- | --- | --- | | --- | --- | --- |
| 接口1 收件契约 | `rpa_oms/app/inbound/sales_goods.py`、`envelope.py` | `rpa_oms/tests/test_sales_goods_receive.py::点名` | | 接口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 Oracle 落表 | `rpa_oms/app/inbound/oracle_repo.py` | `rpa_oms/tests/test_oracle_repo.py::点名` |
| 接口1 入口 / 鉴权 | `rpa_oms/app/main.py`、`auth.py` | 同上;真库见 `rpa_oms/README.md` Windows 段 | | 接口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 取数组装 | `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 取数组装 | `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 收件契约 | `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::点名` | | 接口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/rules.py`、`pipeline.py` | `rpa_selection/tests/test_pipeline.py::test_split_across_malls_is_success` |
| 选品班次落表 | `rpa_selection/job.py`、`persist.py`、`repo.py` | `rpa_selection/tests/test_job.py::test_job_searches_barcode_not_approval_no` | | 选品班次落表 | `rpa_selection/job.py`、`persist.py`、`repo.py` | `rpa_selection/tests/test_job.py::test_job_searches_barcode_not_approval_no` |
| 合同班合单落表 | `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/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_flow_steps.py` 里对应函数 | `ACCEPT SUMMARY` 或 `rpa_contract/screenshots/` 下 `ACCEPT.txt` | | 合同页面步骤 | `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_draft.py` | `rpa_contract` 点名用例 |
| 节点入口 | `rpa_contract/contract_create_node.py`、`main.py` | 读到正式合同号 | | 节点入口 | `rpa_contract/contract_create_node.py`、`main.py` | 读到正式合同号 |
| 浏览器适配 | `rpa_contract/apa_browser_adapter.py` | `rpa_contract/tests/test_apa_browser_adapter.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_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/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_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/job.py`、`persist.py`、`repo.py` | `rpa_rebate/tests/test_job.py::test_job_finds_contract_and_writes_agreement` |
| 返利班查号页面 | `rpa_rebate/rebate_flow_steps.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_common.py` | 四个节点共用;改这里四个一起变 |
| 返利总单页面 | `rpa_rebate/rebate_flow_steps.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_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_flow_steps.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_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_flow_steps.py`、`run_submit.py` | `ACCEPT SUMMARY REBATE_SUBMIT` 或 `rpa_rebate/tests/test_submit.py::test_submit_flow_writes_accept_with_order_nos` | | 返利细单新增 | `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` | — | | 四个口地图 | `rpa_oms/README.md`、根 `README.md` | — |
| 节点业务规则(点名一节) | `docs/RPA订单中心接口开发口径_20260930.md` | 禁止通读 `docs/` | | 节点业务规则(点名一节) | `docs/RPA订单中心接口开发口径_20260930.md` | 禁止通读 `docs/` |
@ -49,26 +52,30 @@
订单中心四接口,本期开放接口 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。 订单中心四接口,本期开放接口 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。单测用内存替身,不连库。服务端口 **8001**。 运行时只落 Oracle。入站单测用假连接录 SQL,不连库。服务端口 **8001**。连接从 `rpa_oracle` 池借,一个请求一条。
| 步 | 函数 | 要点 | | 步 | 函数 | 要点 |
| --- | --- | --- | | --- | --- | --- |
| 信封 | `parse_envelope` | 缺 `data` / 空数组 / 超过 100 条:整包 422,一条都不落 | | 信封 | `parse_envelope` | 缺 `data` / 空数组 / 超过 100 条:整包 422,一条都不落 |
| 逐条校验 | `receive_sales_goods` | 入参 camelCase,落表 snake_case;长度跟 VARCHAR2 | | 整批校验 | `receive_sales_goods` | 内存 REJECTED / 同批 CONFLICT,再一次查重一次 `executemany` |
| 鉴权 | `token_ok` | 未配 token 一律失败 | | 鉴权 | `token_ok` | `X-Oms-Token` 共享密钥,未配一律失败;失败进审计 |
| 落表 | `OracleSalesGoodsRepo.insert` | `fid` 由序列触发器生成,INSERT 不绑这一列 | | 落表 | `existing_oms_ids` + `insert_many` | `fid` 由序列触发器生成,INSERT 不绑这一列;只认 `uk_rpa_sgmd_oms_id` |
| 幂等 | 同 `omsId` | 已存在 → `DUPLICATE`,不更新、不重跑 | | 幂等 | 同 `omsId` | 已存在 → `DUPLICATE`,不更新、不重跑 |
| 同批冲突 | 同一 POST 里 `omsId` 再现 | 后一条 `CONFLICT`,只落第一条 | | 同批冲突 | 同一 POST 里 `omsId` 再现 | 后一条 `CONFLICT`,只落第一条 |
| 字段不过 | `_parse_row` | `REJECTED`,不入库 | | 字段不过 | `_parse_row` | `REJECTED`,不入库 |
| 混批 | `post_sales_goods` | 逐条有成功/重复/拒绝时 HTTP 仍 200,看 `results[].resultCode` | | 混批 | `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 一致。 `omsId` / `saleQty` / `planPurchaseQty` / 接口 3 的 `fid` 必须是 JSON 整数,不把 `"10"` 当 10。Windows 连真库测;Instant Client 位数与 Python 一致。
接口 2:`load_cart_settlement_envelope` 捞 `push_status=0`,同一 `omsId` 整组进一批,上限 500;金额四位小数字符串。不改 `push_status`,不 POST 订单中心。 接口 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`。 接口 3:`receive_purchase_contracts` 按 `fid` 幂等;同批 `fid` 再现 → `CONFLICT`;`contractEntryStatus=0` 必须带 `contractNo`;结束日不得早于开始日。`INSERT` 绑 `fid`。形状与接口 1 相同:内存整批 → `existing_fids` → `insert_many`。
验收(内存、不连库):`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`。 接口 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 只组装不推送。 修接口1不要顺手改 `rpa_selection/`、`rpa_cart/`、`rpa_contract/`、`rpa_rebate/`。接口 2、4 只组装不推送。
@ -80,9 +87,9 @@
| 步 | 函数 | 要点 | | 步 | 函数 | 要点 |
| --- | --- | --- | | --- | --- | --- |
| 领取 | `claim_pending` | `deal_flag` 0→3;中途崩溃停在 3,改回 0 再跑 | | 领取 | `claim_pending` | `deal_flag` 0→3;`FOR UPDATE SKIP LOCKED`;中途崩溃停在 3,超过租约(`deal_time` 30 分钟)可再领 |
| 规则 | `allocate` / `run_selection` | 自营仓全称匹配、毛利 ≥ 4%、效期 ≥ 12 月;同仓只留低价;按仓序拆量 | | 规则 | `allocate` / `run_selection` | 自营仓全称匹配、毛利严格大于 4%、效期 ≥ 12 月;同仓多个 sku 都参与拆量,低价先吃;按仓序拆量 |
| 落表 | `build_result` + `save_result` | 成功每仓一行 `status=1`,合计=计划量;`push_status=0` | | 落表 | `build_result` + `save_result` | 成功每条拆量一行 `status=1`,合计=计划量;`push_status=0` |
| 库存不足 | `KIND_BUSINESS_FAIL` | 两边都搜到但合计不够:`deal_flag=2`,该 `omsId` 不重跑 | | 库存不足 | `KIND_BUSINESS_FAIL` | 两边都搜到但合计不够:`deal_flag=2`,该 `omsId` 不重跑 |
| 对端故障 | `KIND_SYSTEM_FAIL` | 一边故障且剩下的凑不齐:可把 `deal_flag` 改回 0;禁止写成库存不足 | | 对端故障 | `KIND_SYSTEM_FAIL` | 一边故障且剩下的凑不齐:可把 `deal_flag` 改回 0;禁止写成库存不足 |
@ -92,38 +99,38 @@
## 加购落点 ## 加购落点
班次作业在 `rpa_cart/`,不在选品请求里跑。领表 2 `operate_type=1 AND status=1 AND push_status=0 AND update_user_name=系统管理员`。登录 1 号药城 / 药帮忙,按条形码再搜,对上 `supplier_name`,按行加车。 班次作业在 `rpa_cart/`,不在选品请求里跑。领表 2 `operate_type=1 AND status=1 AND push_status=0`,戳为 `系统管理员` 或过期的 `加购中`。登录 1 号药城 / 药帮忙,按条形码再搜,对上 `supplier_name`,按行加车。
| 步 | 函数 | 要点 | | 步 | 函数 | 要点 |
| --- | --- | --- | | --- | --- | --- |
| 领取 | `claim_pending` | 盖 `加购中`;崩溃停在这戳,改回 `系统管理员` 再跑 | | 领取 | `claim_pending` | 盖 `加购中`;`FOR UPDATE SKIP LOCKED`;中途崩溃停在这戳,超过租约(`update_time` 30 分钟)可再领 |
| 对仓 | `match_offer` | 条形码结果对商家全称;同仓留低价 | | 对仓 | `match_offer` | 条形码结果对商家全称;同仓留低价 |
| 加车 | `run_cart_batch` + 平台 `add_*_cart` | 改数量加入购物车。无 `sku_id`,必须再搜 | | 加车 | `run_cart_batch` + 平台 `add_*_cart` | 改数量加入购物车。无 `sku_id`,必须再搜 |
| 成功 | `mark_success` | 不插行,`update_user_name=加购班`,`status` 仍 1 | | 成功 | `mark_success` | 不插行,`update_user_name=加购班`,`status` 仍 1 |
| 失败 | `mark_fail` | 该行 `status=2` + 原因。登录挂写系统故障;库存/中包装/对不上仓写业务原因 | | 失败 | `mark_fail` | 该行 `status=2` + 原因。登录挂写 `LOGIN_FAILED`;库存/中包装/对不上仓写业务原因 |
同一 `omsId` 拆两仓要加齐;一行失败只改那一行。不推接口 2,不清空购物车,不碰合同、表 1。接口 2 仍捞全部 `push_status=0`,加购班跑完之前不要组装。 同一 `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,不插新行)。 验收:`cd rpa_cart` → `uv run pytest tests/test_cart_job.py::test_job_adds_split_rows_without_insert -q`(杭州 3 + 广东 7,不插新行)。
## CONTRACT_CREATE 落点 ## CONTRACT_CREATE 落点
班次作业在 `rpa_contract/`,不在接口 3 请求里跑。领表 3 `process_status=0`(跳过协议重录行),按 `supplierId` 合单,调页面流,写表 4。`run_contract_create_flow` 串页面;修一步只打开对应函数。 班次作业在 `rpa_contract/`,不在接口 3 请求里跑。领表 3 `process_status=0`(跳过协议重录行),按 `supplierId` + `org_code` + 签订日/开始日/结束日合单,任一不同则各自成组,调页面流,写表 4。`run_contract_create_flow` 串页面;修一步只打开对应文件。`contract_flow_steps.py` 仍是对外入口。
| 步 | 函数 | 要点 | | 步 | 打开 | 要点 |
| --- | --- | --- | | --- | --- | --- |
| S02 打开 | `open_contract_system` | `http://contract-test.gzmpc.com:8080/` | | S02 打开 | `contract_login.py` `open_contract_system` | `http://contract-test.gzmpc.com:8080/` |
| S03 登录 | `login_contract_system` | `10007228` / `1` | | S03 登录 | `contract_login.py` `login_contract_system` | 账密只认 `CONTRACT_USERNAME` / `CONTRACT_PASSWORD` |
| S04–S05 起草 | `enter_contract_create`、`select_contract_template` | 直达 `FORM_PAGE_URL`,不要点 SPA 菜单 | | S04–S05 起草 | `contract_login.py` `enter_contract_create`、`select_contract_template` | 直达 `FORM_PAGE_URL`,不要点 SPA 菜单 |
| S06 签约方 | `fill_contract_signatory_info` | 客商 `DW0100046361` / `重庆亿昊药业有限公司` | | S06 签约方 | `contract_form.py` `fill_contract_signatory_info` | 客商 `DW0100046361` / `重庆亿昊药业有限公司` |
| 尽调 | `check_customer_diligence` | 正文含「未尽调」则失败,不提交 | | 尽调 | `contract_form.py` `check_customer_diligence` | 正文含「未尽调」则失败,不提交 |
| S07 基础 | `fill_contract_basic_info` | 标题 `_expected_title` | | S07 基础 | `contract_form.py` `fill_contract_basic_info` | 标题 `_expected_title` |
| S08 金额日期 | `fill_contract_amount_and_dates` | 签订日/开始日用接口 3 三日期,缺则回退 `order_date` | | S08 金额日期 | `contract_form.py` `fill_contract_amount_and_dates` | 签订日/开始日用接口 3 三日期,缺则回退 `order_date` |
| S09 货品 | `fill_contract_goods_lines` | 弹窗按操作码搜 | | S09 货品 | `contract_goods.py` `fill_contract_goods_lines` | 弹窗按操作码搜,找不到则本单失败 |
| S10 付款 | `fill_contract_payment_info` | | | S10 付款 | `contract_form.py` `fill_contract_payment_info` | |
| S11 PDF | `upload_contract_document_if_needed` | 仅 YYC 传合并 PDF | | S11 PDF | `contract_upload.py` `upload_contract_document_if_needed` | 仅 YYC 传合并 PDF |
| S12 提交 | `submit_contract_for_approval` | 成功后**不要等详情页** | | S12 提交 | `contract_submit.py` `submit_contract_for_approval` | 成功后**不要等详情页** |
| S13 合同号 | `read_contract_number` | 回「我的合同」,`fdId` > 标题+客户编码;禁止盲取最新一条 | | S13 合同号 | `contract_number.py` `read_contract_number` | 回「我的合同」,`fdId` > 标题+客户编码;禁止盲取最新一条 |
成功路径默认不截图(`RPA_SCREENSHOT=always` 才全截)。页面等待用 `wait_for_text` / `wait_for_element`,不要固定长睡。 成功路径默认不截图(`RPA_SCREENSHOT=always` 才全截)。页面等待用 `wait_for_text` / `wait_for_element`,不要固定长睡。
@ -133,24 +140,24 @@
| 步 | 函数 | 要点 | | 步 | 函数 | 要点 |
| --- | --- | --- | | --- | --- | --- |
| 领取 | `claim_pending` | 表 4 盖 `查号中`;表 3 `process_status` 0→3。崩溃停戳,改回 `合同班` / `0` 再跑 | | 领取 | `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`,输入框回显不算 | | 查号 | `RebateFinder` / `search_contract` | 打开 `REBATE_URL` → 登录 → 采购协议 → 按合同号搜;表里出现该号才 `True`,输入框回显不算 |
| 录协议 | `recorder` | 搜到后总单 → 细单新增(按货品ID,不做 Excel)→ 送审;送审成功才插 `actionCode=2` | | 录协议 | `recorder` | 搜到后总单 → 细单新增(按货品ID,不做 Excel)→ 送审;送审成功才插 `actionCode=2` |
| 落表 | `build_found` + `save_group` | 起草行 `contract_status=2`;送审成功每 `oms_id` 插 `action_code=2`、`agreement_status=1`,带总单/细单号;`push_status=0` | | 落表 | `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` | 戳改回 `合同班`;表 3 改回 `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`。 验收:`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`,不碰选品 / 加购 / 合同页面。 不推接口 4,不改 `push_status`,不碰选品 / 加购 / 合同页面。货源细单ID 本期不填(弹层按联系人苏文辉过滤)。计划员现阶段廖建琦。
## 返利中心及之后 ## 返利中心及之后
查号页面已接 `REBATE_FIND`。总单页面已接 `REBATE_HEADER`。细单新增已接 `REBATE_IMPORT`(节点名仍叫 IMPORT,页面走新增,不做 Excel)。送审已接 `REBATE_SUBMIT`。班次串查号 → 总单 → 细单新增 → 送审,送审成功才写 `actionCode=2`。三把钥匙:`contractSystemNo` → `rebateOrderNo` → `receiptNoticeNo`。开页面时按合同同款:一个节点一份 `*_flow_steps.py`,验收只看 ACCEPT / 点名用例。 查号页面已接 `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_FIND` | RPA | 搜到 / 搜不到 | 已拆:登录 → 采购协议 → 按合同号搜 |
| `REBATE_HEADER` | RPA | `rebateOrderNo` | 已拆:搜到后填映射表默认值 → 保存拿总单号 | | `REBATE_HEADER` | RPA | `rebateOrderNo` | 已拆:勾已有总单,按 `返利流程.md` 填现行字段 → 保存拿协议总单ID |
| `REBATE_IMPORT` | RPA | 货品表行数 | 已拆:勾总单 → 货品「新增」→ 货品编码三个点按 `goodsId` 搜 → 卡片确认落到表 → 货品工具栏保存。细单备注=订单号,特定补偿=0,承付参数=25。不做 Excel | | `REBATE_IMPORT` | RPA | 货品表行数 | 已拆:勾总单 → 货品「新增」→ 货品编码三个点按 `goodsId` 搜 → 卡片确认落到表 → 货品工具栏保存。细单备注=订单号,特定补偿=0,承付参数=25。不做 Excel |
| `REBATE_SUBMIT` | RPA | `submitted` + 总单/细单号 | 已拆:点送审;马上能见号则带回 | | `REBATE_SUBMIT` | RPA | `submitted` + 总单/细单号 | 已拆:点送审;马上能见号则带回 |
| `M5_CREATE_ORDER` | RPA 桌面端 | `m5OrderNo` | 录单后**停、不保存**。`M5_QC_MISSING` 必须识别准(直接转人工、不重试) | | `M5_CREATE_ORDER` | RPA 桌面端 | `m5OrderNo` | 录单后**停、不保存**。`M5_QC_MISSING` 必须识别准(直接转人工、不重试) |
@ -162,7 +169,7 @@
## 验收 ## 验收
- 接口1:点名 `rpa_oms/tests/test_sales_goods_receive.py` 或 `test_oracle_repo.py`;禁止为「显得全面」跑整包再去连真库。 - 接口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`。 - 接口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`。 - 接口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_pipeline.py::test_split_across_malls_is_success` 或 `test_rules.py::test_margin_exactly_4_percent_passes`。
- 加购:点名 `rpa_cart/tests/test_cart_job.py::test_job_adds_split_rows_without_insert` 或 `test_job_marks_out_of_stock`。 - 加购:点名 `rpa_cart/tests/test_cart_job.py::test_job_adds_split_rows_without_insert` 或 `test_job_marks_out_of_stock`。
@ -172,8 +179,8 @@
- 返利总单页:只读控制台 `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_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_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`。 - 返利送审:只读控制台 `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`。 - 接口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`。 - 合同页:只读控制台 `ACCEPT` 或 `rpa_contract/screenshots/` 下 `ACCEPT.txt`;步骤门闸点名 `rpa_contract/tests/test_flow_gates.py`。
- 单测只跑点名用例。禁止跑整份带页面等待的套件当回归。 - 单测只跑点名用例。禁止跑整份带页面等待的套件当回归。
## 不要 ## 不要

View File

@ -21,7 +21,7 @@
| --- | --- | | --- | --- |
| `rpa_oms/` | 四个 HTTP 口,一个 FastAPI。接口 1、3 收件;接口 2、4 组装报文 | | `rpa_oms/` | 四个 HTTP 口,一个 FastAPI。接口 1、3 收件;接口 2、4 组装报文 |
| `rpa_selection/` | 选品班。读表 1 `deal_flag=0`,双平台选品,写表 2 | | `rpa_selection/` | 选品班。读表 1 `deal_flag=0`,双平台选品,写表 2 |
| `rpa_cart/` | 加购(未做)+ 1 号药城支付落表。表 2 → 口 2 仍未推 | | `rpa_cart/` | 加购已接;1 号药城支付落表。表 2 → 口 2 仍未推 |
| `rpa_contract/` | 合同班。表 3 合单录入 → 表 4;接口 4 组包在 oms,path 空 | | `rpa_contract/` | 合同班。表 3 合单录入 → 表 4;接口 4 组包在 oms,path 空 |
| `rpa_rebate/` | 返利班。表 4 查号 / 表 3 只录协议 → 表 4;查号 → 总单 → 细单新增 → 送审同一班 | | `rpa_rebate/` | 返利班。表 4 查号 / 表 3 只录协议 → 表 4;查号 → 总单 → 细单新增 → 送审同一班 |
@ -36,6 +36,18 @@
新接口只进 `rpa_oms/`。起服务见 [`rpa_oms/README.md`](rpa_oms/README.md)。 新接口只进 `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/README.md`](docs/README.md)。

View File

@ -13,6 +13,10 @@
| `API鉴权.doc` | 调订单中心时的 Token;接口 1、3 入站鉴权由本方定 | | `API鉴权.doc` | 调订单中心时的 Token;接口 1、3 入站鉴权由本方定 |
| `下一会话任务_建表.md` | 建表任务说明(已完成) | | `下一会话任务_建表.md` | 建表任务说明(已完成) |
| `合同系统录入流程.md` | 合同班合单、尽调、提交、回写;页面步骤指向 `rpa_contract` | | `合同系统录入流程.md` | 合同班合单、尽调、提交、回写;页面步骤指向 `rpa_contract` |
| `../rpa_rebate/返利流程.md` | 返利页面现行(总单/细单/送审)。与映射表冲突听这份 |
| `../rpa_rebate/现行模式.md` | 返利班相对旧稿改了什么、下一会话未做 |
| `大批量跑缺口.md` | 四条班次相对大批量现场还缺什么(口 2/4 HTTP、调度、崩溃戳、支付/M5) |
| `写死测试配置整改清单.md` | 上正式前要拆的测试站、测试账号和干跑回退 |
接口字段、示例报文仍以 Excel 为准(当前 `Downloads/RPA与订单中心接口设计_20260922.xlsx`)。Excel 与口径冲突时,作业语义听口径。 接口字段、示例报文仍以 Excel 为准(当前 `Downloads/RPA与订单中心接口设计_20260922.xlsx`)。Excel 与口径冲突时,作业语义听口径。
@ -22,7 +26,7 @@
| 文件 | 用途 | | 文件 | 用途 |
| --- | --- | | --- | --- |
| `合同返利系统字段映射_20260911.xlsx` | 返利总单默认值 | | `合同返利系统字段映射_20260911.xlsx` | 返利旧默认值;现行字段以 `rpa_rebate/返利流程.md` 为准 |
| `采购平台选品条件优先项_20260902.txt` | 选品规则原文 | | `采购平台选品条件优先项_20260902.txt` | 选品规则原文 |
| `【项目方案】福建洁达采购合同RPA自动化录入需求_v20260924.docx` | 页面操作参考 | | `【项目方案】福建洁达采购合同RPA自动化录入需求_v20260924.docx` | 页面操作参考 |

View File

@ -6,7 +6,8 @@
- `Downloads/RPA与订单中心接口设计_20260922.xlsx`(以当时最新一份为准) - `Downloads/RPA与订单中心接口设计_20260922.xlsx`(以当时最新一份为准)
- `docs/API鉴权.doc` - `docs/API鉴权.doc`
- `docs/合同返利系统字段映射_20260911.xlsx`(返利总单默认值) - `docs/合同返利系统字段映射_20260911.xlsx`(返利旧默认值,对照用)
- `rpa_rebate/返利流程.md`(返利页面现行;与映射表冲突听这份)
- `rpa_selection/` 既有规则 - `rpa_selection/` 既有规则
--- ---
@ -135,7 +136,7 @@
- 搜到:先回合同成功,再录协议 - 搜到:先回合同成功,再录协议
- 搜不到:本班不录协议、不重开合同,下一班再搜 - 搜不到:本班不录协议、不重开合同,下一班再搜
总单默认值跟映射表(计划员用订单中心给的,承付「下月 N 号结」,协议结束当天等)。不要用方案 5.5 的「登录人 / 账期 / 当年最后一天」。 总单/细单现行字段以 `rpa_rebate/返利流程.md` 为准:计划员现阶段「廖建琦」(后期改回订单中心),联系人「苏文辉」,结算「电汇」,承付「下月 N 号结」参数 `25`,协议开始/结束当天。细单走页面新增(按 `goods_id` 搜),不做 Excel。货源细单ID 本期不填。不要用方案 5.5 的「登录人 / 账期 / 当年最后一天」。映射表作对照,冲突听返利流程。
### 5.3 协议「成功」 ### 5.3 协议「成功」

View File

@ -276,6 +276,7 @@ CREATE TABLE rpa_contract_agreement_result (
update_user_id NUMBER(19,0), update_user_id NUMBER(19,0),
update_user_name VARCHAR2(100 CHAR), update_user_name VARCHAR2(100 CHAR),
org_code VARCHAR2(10), org_code VARCHAR2(10),
process_status NUMBER(1,0) DEFAULT 0 NOT NULL,
CONSTRAINT pk_rpa_car PRIMARY KEY (fid) CONSTRAINT pk_rpa_car PRIMARY KEY (fid)
); );
@ -316,3 +317,121 @@ 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_id IS '更新人ID。';
COMMENT ON COLUMN rpa_contract_agreement_result.update_user_name IS '更新人名称。'; 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.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。超长截断。不进失败原因列。';

View File

@ -165,5 +165,6 @@ Excel 待确认:重录协议是否新 `fid`;税率百分数还是小数;
| 18 | 更新人ID | update_user_id | number(19,0) | 同 | 无 | | 18 | 更新人ID | update_user_id | number(19,0) | 同 | 无 |
| 19 | 更新人名称 | update_user_name | varchar2(100 char) | 同 | 无 | | 19 | 更新人名称 | update_user_name | varchar2(100 char) | 同 | 无 |
| 20 | 货主编码 | org_code | varchar2(10) | 同 | 无 | | 20 | 货主编码 | org_code | varchar2(10) | 同 | 无 |
| — | 返利处理状态 | process_status | — | NUMBER(1,0) DEFAULT 0 NOT NULL | 本方补。0 待查号,1 返利已回写,2 合同失败不进返利,3 查号中。不进接口 4 |
相对接口 sheet:数据表多 `push_*`、审计字段。 相对接口 sheet:数据表多 `push_*`、审计字段、`process_status`。

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

@ -18,9 +18,9 @@
1. 硬过滤:自营仓、毛利 ≥ 4%、效期 ≥ 12 个月。库存不再是过滤项。 1. 硬过滤:自营仓、毛利 ≥ 4%、效期 ≥ 12 个月。库存不再是过滤项。
2. 按四级仓序(同仓低价、再高库存)排序。 2. 按四级仓序(同仓低价、再高库存)排序。
3. 同一平台同一仓只留 1 条候选。 3. 同一平台同一仓的多个 sku 都参与拆量,不再只留低价那条。
4. 按序贪心拆量,直到计划采购量凑齐。 4. 按序贪心拆量,直到计划采购量凑齐。
5. 凑齐:每个仓一行写入表 2,`status=1`,数量是该仓拆到的件数,合计等于计划量。 5. 凑齐:每条拆量一行写入表 2,`status=1`,数量是该条拆到的件数,合计等于计划量。
6. 凑不齐:不写成功行,表 2 `status=2`,表 1 `deal_flag=2`。 6. 凑不齐:不写成功行,表 2 `status=2`,表 1 `deal_flag=2`。
完成标准:`选品流程` 单测全绿;用内存仓库能演示「杭州 3 + 广东 7 = 计划 10」落两行,以及「合计 9 < 10」整单失败且成功行为 0。 完成标准:`选品流程` 单测全绿;用内存仓库能演示「杭州 3 + 广东 7 = 计划 10」落两行,以及「合计 9 < 10」整单失败且成功行为 0。
@ -33,7 +33,6 @@
- 接口 2 推送(`push_status` 仍为 0) - 接口 2 推送(`push_status` 仍为 0)
- 批准文号搜索、商家名模糊匹配 - 批准文号搜索、商家名模糊匹配
- 中包装 / 起订量 - 中包装 / 起订量
- 同一仓多 SKU 拆成两行
- 改表结构、改接口 1 - 改表结构、改接口 1
--- ---
@ -94,11 +93,11 @@
- `EXPIRY_UNPARSEABLE` / `EXPIRY_LT_12M` - `EXPIRY_UNPARSEABLE` / `EXPIRY_LT_12M`
- `NO_SALE_PRICE`(采购行级,先于搜品) - `NO_SALE_PRICE`(采购行级,先于搜品)
库存为 `None` 或向下取整后 `<= 0`:可以过过滤,但折叠和拆量时当作 0,拆不到量。 库存为 `None` 或向下取整后 `<= 0`:可以过过滤,但拆量时当作 0,拆不到量。
### 5.2 折叠 ### 5.2 同仓
`key = (mall_name, vendor_norm)`。同一 key 多条时保留 `(price, -stock)` 最优的一条。其余记入 rejected,原因 `SAME_WAREHOUSE_NOT_BEST`。 不再按 `(mall_name, vendor_norm)` 折成一条。过过滤的 sku 全部进入拆量。同仓低价先吃,不够再吃同仓下一条;仓序仍优先于跨仓低价。不再产生 `SAME_WAREHOUSE_NOT_BEST`。
### 5.3 排序 ### 5.3 排序
@ -166,8 +165,8 @@ for candidate in sorted_passed:
| `supplier_name` | `vendor_raw` | | `supplier_name` | `vendor_raw` |
| `bar_code` / `org_code` | 原样 | | `bar_code` / `org_code` | 原样 |
| `purchase_price` | 该候选平台价,四位小数 | | `purchase_price` | 该候选平台价,四位小数 |
| `purchase_qty` | 该仓 `take`,**不是**计划全量 | | `purchase_qty` | 该条 `take`,**不是**计划全量 |
| `purchase_total` | 单价 × 该仓数量,四位小数 | | `purchase_total` | 单价 × 该条数量,四位小数 |
| `status` | 1 | | `status` | 1 |
| `fail_reason` | null | | `fail_reason` | null |
| `push_status` | 0 | | `push_status` | 0 |
@ -177,7 +176,7 @@ for candidate in sorted_passed:
业务失败:每个**已启用且已判定**的平台落一行 `status=2`,`purchase_qty` 仍用计划量(造数校验:数量 > 0),`purchase_price` 仍用销售价兜底。`supplier_name` 仍为「无符合规则供应商」。`multi_platform_flag` 按失败行平台数(≥2 个平台则为 1)。 业务失败:每个**已启用且已判定**的平台落一行 `status=2`,`purchase_qty` 仍用计划量(造数校验:数量 > 0),`purchase_price` 仍用销售价兜底。`supplier_name` 仍为「无符合规则供应商」。`multi_platform_flag` 按失败行平台数(≥2 个平台则为 1)。
业务失败原因(`fail_reason` / `deal_result`)。先看过滤后是否留下可拆候选(含库存为 0、以及被 `SAME_WAREHOUSE_NOT_BEST` 折叠掉但仍算「该仓有过过滤候选」——以折叠**之前**过过滤的集合为准): 业务失败原因(`fail_reason` / `deal_result`)。先看过滤后是否留下可拆候选(含库存为 0。以过过滤的集合为准):
1. `NO_BAR_CODE` / `NO_SALE_PRICE` / `NO_QTY`:沿用现有中文 1. `NO_BAR_CODE` / `NO_SALE_PRICE` / `NO_QTY`:沿用现有中文
2. **过过滤集合非空,但拆不满**:一律 `无符合规则商品,库存不足`。rejected 里即使同时有非自营、效期、毛利淘汰,也不改这条。 2. **过过滤集合非空,但拆不满**:一律 `无符合规则商品,库存不足`。rejected 里即使同时有非自营、效期、毛利淘汰,也不改这条。
@ -200,7 +199,7 @@ for candidate in sorted_passed:
| 文件 | 改什么 | | 文件 | 改什么 |
| --- | --- | | --- | --- |
| `rules.py` | 新名单;`MIN_MARGIN=0.04`;`evaluate_candidate` 去掉库存;新增 `allocate()`(过滤+折叠+排序+拆量);`select_warehouse` 删除或改为调用 `allocate` 后取第一仓——**禁止**再作为班次入口 | | `rules.py` | 新名单;`MIN_MARGIN=0.04`;`evaluate_candidate` 去掉库存;新增 `allocate()`(过滤+同仓全留+排序+拆量);`select_warehouse` 删除或改为调用 `allocate` 后取第一仓——**禁止**再作为班次入口 |
| `pipeline.py` | 新增 `run_selection(purchase, mall_outcomes)`,一次产出整单结果。`run_one_platform` 只留给单测过渡,班次不再调用 | | `pipeline.py` | 新增 `run_selection(purchase, mall_outcomes)`,一次产出整单结果。`run_one_platform` 只留给单测过渡,班次不再调用 |
| `job.py` | 先收集各平台搜索结果,再 `run_selection` | | `job.py` | 先收集各平台搜索结果,再 `run_selection` |
| `persist.py` | 吃整单结果:成功按拆量多行;`multi_platform_flag` 按平台种类数;失败原因 5% → 4%,并增加库存不足 | | `persist.py` | 吃整单结果:成功按拆量多行;`multi_platform_flag` 按平台种类数;失败原因 5% → 4%,并增加库存不足 |
@ -234,7 +233,7 @@ class SelectionResult:
mall_errors: dict[str, str] # 平台 → LOGIN_FAILED:... / QUERY_FAILED:... mall_errors: dict[str, str] # 平台 → LOGIN_FAILED:... / QUERY_FAILED:...
``` ```
`reason` 在 `run_selection` / `allocate` 里用折叠**前**过过滤集合算完。persist 只做中文映射,禁止从 `rejected` 反推库存不足(库存为 0 的过过滤候选不在 rejected 里)。 `reason` 在 `run_selection` / `allocate` 里用过过滤集合算完。persist 只做中文映射,禁止从 `rejected` 反推库存不足(库存为 0 的过过滤候选不在 rejected 里)。
`kind` 决定落表,不要用「有没有 FAILED 决策」去猜: `kind` 决定落表,不要用「有没有 FAILED 决策」去猜:
@ -257,7 +256,7 @@ class SelectionResult:
- 湖南壹号、甘肃壹号云视为非自营 - 湖南壹号、甘肃壹号云视为非自营
- 近仓 3、次仓 20、计划 10 → 近仓 3 + 次仓 7 - 近仓 3、次仓 20、计划 10 → 近仓 3 + 次仓 7
- 合计 9、计划 10 → 无 allocation - 合计 9、计划 10 → 无 allocation
- 同一仓两 SKU(价 90 库存 5、价 92 库存 100)、计划 10 → 只用价 90 那条的 5,余量 5 给下一仓,不吃同仓 92 的库存 - 同一仓两 SKU(价 90 库存 5、价 92 库存 100)、计划 10 → 价 90 拿 5,价 92 拿 5,不把余量给下一仓
- 库存 3.9 按 3 件拆 - 库存 3.9 按 3 件拆
班次 / 落表: 班次 / 落表:

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)

View File

@ -11,7 +11,7 @@
选品已经把「哪个仓、加几件」写成表 2:`operate_type=1`、`status=1`、`push_status=0`、`update_user_name=系统管理员`。本班读这些行,登录 1 号药城 / 药帮忙,按条形码再搜一次,对上 `supplier_name`,改数量,加入购物车。同一 `omsId` 拆两仓、两平台要加齐。 选品已经把「哪个仓、加几件」写成表 2:`operate_type=1`、`status=1`、`push_status=0`、`update_user_name=系统管理员`。本班读这些行,登录 1 号药城 / 药帮忙,按条形码再搜一次,对上 `supplier_name`,改数量,加入购物车。同一 `omsId` 拆两仓、两平台要加齐。
成功不插新行,只把 `update_user_name` 改成 `加购班`。加不进去(登录挂、库存没了、中包装加不进、对不上仓)改该行 `status=2`,写失败原因。领取时先盖 `加购中`;中途崩溃停在这一戳,改回 `系统管理员` 再跑。 成功不插新行,只把 `update_user_name` 改成 `加购班`。加不进去(登录挂、库存没了、中包装加不进、对不上仓)改该行 `status=2`,写失败原因。领取时先盖 `加购中`;中途崩溃停在这一戳。`update_time` 超过 30 分钟的「加购中」下一班会再领;未过期的需改回 `系统管理员` 再跑。
不推订单中心,不清空购物车,不碰合同。表 2 没有 `sku_id`,加购必须再搜条形码。选完到加购之间库存可能变,已知。 不推订单中心,不清空购物车,不碰合同。表 2 没有 `sku_id`,加购必须再搜条形码。选完到加购之间库存可能变,已知。
@ -35,6 +35,48 @@ uv run pytest tests/test_cart_job.py::test_job_adds_split_rows_without_insert -q
接口 2 仍捞全部 `push_status=0`。加购班跑完之前不要组装推送。 接口 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 号药城支付落表 ## 1 号药城支付落表
从买家中心导出已付订单,按导出行字段写入表 2 `operate_type=2`。不对加购行。购物车和支付单没有强关联,`oms_id` 表上 NOT NULL,先填 `0`,等人后选。 从买家中心导出已付订单,按导出行字段写入表 2 `operate_type=2`。不对加购行。购物车和支付单没有强关联,`oms_id` 表上 NOT NULL,先填 `0`,等人后选。
@ -44,9 +86,6 @@ cd rpa_cart
uv sync --python 3.12 uv sync --python 3.12
# 账号用 rpa_selection/.env 的 YAOEX_*,库用 RPA_ORACLE_* # 账号用 rpa_selection/.env 的 YAOEX_*,库用 RPA_ORACLE_*
uv run python main.py uv run python main.py
uv run python main.py --dry-run --xls /tmp/yaoex_order_export/orders.xls
uv run python main.py --days 0 # 仅当天(默认)
uv run python main.py --day 2026-09-30 --days 0
``` ```
过滤:只落 待发货 / 待收货 / 已完成。单价用折后单价,总价 `round(折后单价×数量, 4)`。`pay_order_no` 用商城订单号。`pay_order_dtl_id` 落库时自生成(UUID hex),不是平台数据,给订单中心防重传。 过滤:只落 待发货 / 待收货 / 已完成。单价用折后单价,总价 `round(折后单价×数量, 4)`。`pay_order_no` 用商城订单号。`pay_order_dtl_id` 落库时自生成(UUID hex),不是平台数据,给订单中心防重传。
@ -79,9 +118,7 @@ $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 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 cart
# 国庆当天可能没单,用 9-30 有数 uv run python main.py
uv run python main.py --dry-run --day 2026-09-30 --days 0
uv run python main.py --day 2026-09-30 --days 0
``` ```
看控制台 `inserted / skipped_dup / unmatched`。同一订单号再跑应 `skipped_dup` 上升。 看控制台 `inserted / skipped_dup / unmatched`。同一订单号再跑应 `skipped_dup` 上升。

View File

@ -1,4 +1,5 @@
"""加车结果。HTTP 客户端把商城回包收成这三种。""" # 加车结果。HTTP 客户端把商城回包收成这三种。
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
@ -8,12 +9,14 @@ KIND_BUSINESS = "business"
KIND_SYSTEM = "system" KIND_SYSTEM = "system"
# 一次加车的结果:成功、业务失败(库存/中包装)或系统失败(登录、返回异常)。
@dataclass @dataclass
class AddOutcome: class AddOutcome:
kind: str kind: str
reason: str = "" reason: str = ""
# 按商城回文区分成功、库存/中包装等业务失败,以及看不懂的系统失败。
def classify_mall_message(text: str) -> AddOutcome: def classify_mall_message(text: str) -> AddOutcome:
msg = (text or "").strip() msg = (text or "").strip()
if not msg: if not msg:

View File

@ -1,6 +1,13 @@
"""加购班次:领表 2 选品成功行 → 再搜条形码对仓 → 加车。成功盖章,失败改 status=2。""" # 加购班次:领表 2 选品成功行 → 再搜条形码对仓 → 加车。
#
# 成功盖章,失败改 status=2。崩在「加购中」:超过租约可再领,未过期需改回「系统管理员」。
# 查询失败原因列只写 QUERY_FAILED / LOGIN_FAILED / ADD_FAILED。原文进审计表,会话结束后另写。
from __future__ import annotations from __future__ import annotations
import logging
from collections import OrderedDict
from contextlib import nullcontext
from dataclasses import dataclass from dataclasses import dataclass
from typing import Callable from typing import Callable
@ -10,13 +17,19 @@ from cart_add import ( # noqa: F401
KIND_SYSTEM, KIND_SYSTEM,
AddOutcome, AddOutcome,
) )
from cart_match import CartOffer, match_offer, middle_pack, qty_fits_pack from cart_match import CartOffer, match_offer
from cart_repo import CartRow, clip_reason from cart_repo import CartRow, clip_reason
from rpa_oracle import ShiftError
Searcher = Callable[[str], list[CartOffer]] Searcher = Callable[[str], list[CartOffer]]
Adder = Callable[[CartOffer, int], AddOutcome] Adder = Callable[[CartOffer, int], AddOutcome]
CartReader = Callable[[str, str], int]
_RETRY_CODES = ("LOGIN_FAILED", "QUERY_FAILED")
logger = logging.getLogger("rpa_cart")
INCOMPLETE_MARK = "未加齐"
# 一个加购班次的领取和盖章计数。
@dataclass @dataclass
class CartSummary: class CartSummary:
claimed: int = 0 claimed: int = 0
@ -24,6 +37,14 @@ class CartSummary:
failed: int = 0 failed: int = 0
def _session(repo):
"""有 session 就借一条,没有就空上下文。商城调用必须在这个块外面。"""
session = getattr(repo, "session", None)
if session is None:
return nullcontext()
return session()
def run_cart_batch( def run_cart_batch(
repo, repo,
*, *,
@ -32,65 +53,143 @@ def run_cart_batch(
login_errors: dict[int, str], login_errors: dict[int, str],
limit: int | None = None, limit: int | None = None,
platform_types: list[int] | None = None, platform_types: list[int] | None = None,
cart_readers: dict[int, CartReader] | None = None,
) -> CartSummary: ) -> CartSummary:
"""领取待加购行,按行搜仓、加车、盖章。
session 只包领取和每一组的盖章。搜索和加车在连接归还之后跑。一组盖完再处理下一组。
登录失败和查询失败把戳改回系统管理员。购物车里已有不少于本行的数量则直接成功,不加第二份。
limit 交给领取层。
"""
audits: list[ShiftError] = []
readers = cart_readers or {}
with _session(repo):
rows = repo.claim_pending(limit=limit, platform_types=platform_types) rows = repo.claim_pending(limit=limit, platform_types=platform_types)
summary = CartSummary(claimed=len(rows)) summary = CartSummary(claimed=len(rows))
for row in rows: for oms_id, group in _by_oms(rows).items():
reason = _add_one(row, searchers, adders, login_errors) outcomes = [
(row, *_add_one(row, searchers, adders, login_errors, readers))
for row in group
]
incomplete = _is_incomplete(outcomes)
if incomplete:
logger.warning("加购未加齐 oms_id=%s", oms_id)
with _session(repo):
for row, reason, code, text in outcomes:
if reason is None: if reason is None:
repo.mark_success(row.fid) repo.mark_success(row.fid)
summary.added += 1 summary.added += 1
continue
if incomplete:
reason = _incomplete_reason(reason)
if _retryable(code, reason):
repo.mark_release(row.fid, reason)
else: else:
repo.mark_fail(row.fid, reason) repo.mark_fail(row.fid, reason)
summary.failed += 1 summary.failed += 1
if code and text:
audits.append(ShiftError("加购", str(row.fid), code, text))
repo.save_shift_errors(audits)
return summary return summary
# 按 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( def _add_one(
row: CartRow, row: CartRow,
searchers: dict[int, Searcher], searchers: dict[int, Searcher],
adders: dict[int, Adder], adders: dict[int, Adder],
login_errors: dict[int, str], login_errors: dict[int, str],
) -> str | None: cart_readers: dict[int, CartReader],
) -> tuple[str | None, str | None, str | None]:
"""对一行查购物车、搜仓并加车。
返回 (失败原因, 审计短码, 原文)。成功三项都是 None。
购物车里该条形码和供应商的数量已经不少于本行时直接成功,多出来的数量不加第二份。
业务原因(对不上仓、库存、商城回的业务失败)不进审计。没有原文也不进。
"""
platform = row.platform_type platform = row.platform_type
if platform in login_errors: if platform in login_errors:
return _system(login_errors[platform]) 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) searcher = searchers.get(platform)
if searcher is None: if searcher is None:
return _system("未登录该平台") return _system("LOGIN_FAILED"), None, None
adder = adders.get(platform) adder = adders.get(platform)
if adder is None: if adder is None:
return _system("未配置加车") return _system("ADD_FAILED"), None, None
bar_code = (row.bar_code or "").strip() bar_code = (row.bar_code or "").strip()
if not bar_code: if not bar_code:
return "缺少条形码" return "缺少条形码", None, None
qty = int(row.purchase_qty) qty = int(row.purchase_qty)
if qty <= 0: if qty <= 0:
return "采购数量无效" 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: try:
offers = searcher(bar_code) offers = searcher(bar_code)
except Exception as exc: except Exception as exc:
return _system(f"查询失败:{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) offer = match_offer(offers, row.supplier_name)
if offer is None: if offer is None:
return "未找到对应仓" return "未找到对应仓", None, None
if offer.stock is not None and offer.stock < qty: if offer.stock is not None and offer.stock < qty:
return "库存不足" return "库存不足", None, None
pack = middle_pack(offer)
if not qty_fits_pack(qty, pack):
return f"中包装加不进:数量须为{pack}的倍数"
try: try:
outcome = adder(offer, qty) outcome = adder(offer, qty)
except Exception as exc: except Exception as exc:
return _system(f"加购失败:{exc}") logger.exception("加购加车失败 platform=%s fid=%s", platform, row.fid)
return _system("ADD_FAILED"), "ADD_FAILED", _text(exc)
if outcome.kind == KIND_OK: if outcome.kind == KIND_OK:
return None return None, None, None
reason = (outcome.reason or "加购失败").strip() reason = (outcome.reason or "加购失败").strip()
if outcome.kind == KIND_SYSTEM: if outcome.kind == KIND_SYSTEM:
return _system(reason) return _system(reason), None, None
return clip_reason(reason) 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: def _system(detail: str) -> str:
text = (detail or "未知错误").strip() text = (detail or "未知错误").strip()
if text.startswith("系统故障"): if text.startswith("系统故障"):

View File

@ -1,13 +1,14 @@
"""按供应商名对上搜到的仓。无 sku_id 时靠条形码结果 + 商家全称。""" # 按供应商名对上搜到的仓。无 sku_id 时靠条形码结果 + 商家全称。
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
SELF_OPERATED_PREFIX = "[自营]" SELF_OPERATED_PREFIX = "[自营]"
PACK_KEYS = ("middlePackNum", "midPackQty", "packNum", "packQty")
# 再搜到的一条报价:商家全称、库存、价格。加车前靠它对上表 2 的供应商。
@dataclass @dataclass
class CartOffer: class CartOffer:
sku_id: str sku_id: str
@ -18,6 +19,7 @@ class CartOffer:
extra: dict[str, Any] = field(default_factory=dict) extra: dict[str, Any] = field(default_factory=dict)
# 去掉「[自营]」前缀和多余空白,用来对商家全称。
def normalize_supplier(raw: str) -> str: def normalize_supplier(raw: str) -> str:
text = (raw or "").replace("\u3000", " ").strip() text = (raw or "").replace("\u3000", " ").strip()
if text.startswith(SELF_OPERATED_PREFIX): if text.startswith(SELF_OPERATED_PREFIX):
@ -25,6 +27,7 @@ def normalize_supplier(raw: str) -> str:
return " ".join(text.split()) return " ".join(text.split())
# 条形码结果里对上商家全称。同仓留低价,价格相同留库存多的。对不上返回 None。
def match_offer(offers: list[CartOffer], supplier_name: str) -> CartOffer | None: def match_offer(offers: list[CartOffer], supplier_name: str) -> CartOffer | None:
target = normalize_supplier(supplier_name) target = normalize_supplier(supplier_name)
if not target: if not target:
@ -40,23 +43,7 @@ def match_offer(offers: list[CartOffer], supplier_name: str) -> CartOffer | None
return min(matched, key=_offer_rank) return min(matched, key=_offer_rank)
def middle_pack(offer: CartOffer) -> int | None: # 同仓排序:有价格的优先,低价优先,同价库存多的优先。
raw = offer.extra.get("raw") if isinstance(offer.extra.get("raw"), dict) else offer.extra
if not isinstance(raw, dict):
return None
for key in PACK_KEYS:
value = _positive_int(raw.get(key))
if value is not None:
return value
return None
def qty_fits_pack(qty: int, pack: int | None) -> bool:
if pack is None or pack <= 1:
return True
return qty % pack == 0
def _offer_rank(offer: CartOffer) -> tuple[int, float, float]: def _offer_rank(offer: CartOffer) -> tuple[int, float, float]:
price = offer.price price = offer.price
stock = offer.stock stock = offer.stock
@ -65,15 +52,3 @@ def _offer_rank(offer: CartOffer) -> tuple[int, float, float]:
float(price) if price is not None else 0.0, float(price) if price is not None else 0.0,
-float(stock) if stock is not None else 0.0, -float(stock) if stock is not None else 0.0,
) )
def _positive_int(value: object) -> int | None:
if value is None or value == "":
return None
try:
number = int(float(str(value)))
except (TypeError, ValueError):
return None
if number <= 0:
return None
return number

View File

@ -1,11 +1,17 @@
"""表 2 加购行:领取、成功盖章、失败改状态。成功不插新行。""" # 表 2 加购行:领取、成功盖章、失败改状态。成功不插新行。
#
# 领取时盖「加购中」。进程中途退出时行停在这一戳,超过租约(update_time 30 分钟)才能再领。
# 未过期的「加购中」需改回「系统管理员」再跑。
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from contextlib import contextmanager
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any from typing import Any
from db import RepoUnavailable, connect_from_env, oracle_dsn 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 OPERATE_TYPE_ADD_CART = 1
STATUS_OK = 1 STATUS_OK = 1
@ -15,16 +21,13 @@ STAMP_SELECTION = "系统管理员"
STAMP_CLAIMING = "加购中" STAMP_CLAIMING = "加购中"
STAMP_DONE = "加购班" STAMP_DONE = "加购班"
FAIL_REASON_MAX = 500 FAIL_REASON_MAX = 500
LEASE_MINUTES = 30
_CLAIM_SQL = """ _CLAIM_SQL = """
UPDATE rpa_cart_settlement_goods_data UPDATE rpa_cart_settlement_goods_data
SET update_user_name = :stamp, SET update_user_name = :stamp,
update_time = SYSDATE update_time = SYSDATE
WHERE fid = :fid WHERE fid IN ({placeholders})
AND operate_type = 1
AND status = 1
AND push_status = 0
AND NVL(update_user_name, :selection_stamp) = :selection_stamp
""" """
_SUCCESS_SQL = """ _SUCCESS_SQL = """
@ -46,7 +49,17 @@ WHERE fid = :fid
AND update_user_name = :claim_stamp 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 @dataclass
class CartRow: class CartRow:
fid: int fid: int
@ -62,6 +75,7 @@ class CartRow:
operate_type: int = OPERATE_TYPE_ADD_CART operate_type: int = OPERATE_TYPE_ADD_CART
# 把失败原因截到 FAIL_REASON_MAX 字,超长末尾加省略号。
def clip_reason(text: str) -> str: def clip_reason(text: str) -> str:
text = (text or "").strip() text = (text or "").strip()
if len(text) <= FAIL_REASON_MAX: if len(text) <= FAIL_REASON_MAX:
@ -69,16 +83,26 @@ def clip_reason(text: str) -> str:
return text[: FAIL_REASON_MAX - 1] + "…" return text[: FAIL_REASON_MAX - 1] + "…"
def _is_pending(row: dict[str, Any]) -> bool: # 判断内存行是否可领:系统管理员,或加购中且 update_time 已过租约。
def _is_pending(row: dict[str, Any], now: datetime) -> bool:
stamp = row.get("update_user_name") or STAMP_SELECTION stamp = row.get("update_user_name") or STAMP_SELECTION
return ( if (
int(row.get("operate_type") or 0) == OPERATE_TYPE_ADD_CART int(row.get("operate_type") or 0) != OPERATE_TYPE_ADD_CART
and int(row.get("status") or 0) == STATUS_OK or int(row.get("status") or 0) != STATUS_OK
and int(row.get("push_status") or 0) == PUSH_NOT_SENT or int(row.get("push_status") or 0) != PUSH_NOT_SENT
and stamp == STAMP_SELECTION ):
) 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: def _row_from_dict(data: dict[str, Any]) -> CartRow:
qty = data.get("purchase_qty") qty = data.get("purchase_qty")
return CartRow( return CartRow(
@ -96,130 +120,34 @@ def _row_from_dict(data: dict[str, Any]) -> CartRow:
) )
class MemoryCartRepo: # 回滚当前事务。回滚本身失败则忽略。
def __init__(self, rows: list[dict[str, Any]]): def _rollback(conn) -> None:
self.rows: dict[int, dict[str, Any]] = {int(row["fid"]): dict(row) for row in rows}
def claim_pending(
self, limit: int | None = None, platform_types: list[int] | None = None
) -> list[CartRow]:
pending = sorted(
(row for row in self.rows.values() if _is_pending(row)),
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
claimed.append(_row_from_dict(row))
return claimed
def mark_success(self, fid: int) -> None:
row = self.rows[fid]
row["update_user_name"] = STAMP_DONE
row["fail_reason"] = None
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
class OracleCartRepo:
def __init__(self, connect: Callable):
self._connect = connect
@classmethod
def from_env(cls) -> "OracleCartRepo":
oracle_dsn()
return cls(connect=connect_from_env)
def claim_pending(
self, limit: int | None = None, platform_types: list[int] | None = None
) -> list[CartRow]:
conn = self._connect()
try:
with conn.cursor() as cur:
sql, params = _pending_sql(limit=limit, platform_types=platform_types)
cur.execute(sql, params)
fetched = [_row_from_cursor(cur, rec) for rec in cur.fetchall()]
claimed: list[CartRow] = []
for row in fetched:
cur.execute(
_CLAIM_SQL,
{
"fid": row.fid,
"stamp": STAMP_CLAIMING,
"selection_stamp": STAMP_SELECTION,
},
)
if cur.rowcount == 1:
row.update_user_name = STAMP_CLAIMING
claimed.append(row)
conn.commit()
return claimed
except Exception as exc:
try: try:
conn.rollback() conn.rollback()
except Exception: except Exception:
pass pass
raise RepoUnavailable(str(exc)) from exc
finally:
conn.close()
def mark_success(self, fid: int) -> None:
self._execute(
_SUCCESS_SQL,
{"fid": int(fid), "stamp": STAMP_DONE, "claim_stamp": STAMP_CLAIMING},
)
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 _execute(self, sql: str, params: dict[str, Any]) -> None:
conn = self._connect()
try:
with conn.cursor() as cur:
cur.execute(sql, params)
conn.commit()
except Exception as exc:
try:
conn.rollback()
except Exception:
pass
raise RepoUnavailable(str(exc)) from exc
finally:
conn.close()
def _row_from_cursor(cur, rec) -> CartRow: # 拼领取 SELECT:系统管理员或过期加购中,末尾 FOR UPDATE SKIP LOCKED。
cols = [item[0].lower() for item in cur.description] def _pending_select_sql(
return _row_from_dict(dict(zip(cols, rec)))
def _pending_sql(
*, limit: int | None, platform_types: list[int] | None *, limit: int | None, platform_types: list[int] | None
) -> tuple[str, dict[str, Any]]: ) -> tuple[str, dict[str, Any]]:
params: dict[str, Any] = {"selection_stamp": STAMP_SELECTION} params: dict[str, Any] = {
"selection_stamp": STAMP_SELECTION,
"claim_stamp": STAMP_CLAIMING,
"lease_minutes": LEASE_MINUTES,
}
filters = [ filters = [
"operate_type = 1", "operate_type = 1",
"status = 1", "status = 1",
"push_status = 0", "push_status = 0",
"NVL(update_user_name, :selection_stamp) = :selection_stamp", """(
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 platform_types is not None:
if not platform_types: if not platform_types:
@ -242,4 +170,201 @@ ORDER BY oms_id, fid
if limit is not None: if limit is not None:
params["limit"] = int(limit) params["limit"] = int(limit)
sql = f"SELECT * FROM (\n{sql}\n) WHERE ROWNUM <= :limit" sql = f"SELECT * FROM (\n{sql}\n) WHERE ROWNUM <= :limit"
return sql, params 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)))

View File

@ -1,22 +1,20 @@
"""再搜条形码,保留原始商品字段给加车用。""" # 再搜条形码,保留原始商品字段给加车用。
#
# 导入路径由 selection_path 放进 rpa_selection,这里不再改 sys.path。
from __future__ import annotations from __future__ import annotations
import sys
from pathlib import Path
from typing import Any from typing import Any
import requests import requests
import selection_path # noqa: F401 先把 rpa_selection 放进导入路径
from cart_match import CartOffer from cart_match import CartOffer
from yaoex_login import SELECTION_ROOT from search_yaoex import map_yaoex_products, query_yaoex_products
from search_ybm import map_ybm_products, query_ybm_products
if str(SELECTION_ROOT) not in sys.path:
sys.path.append(str(SELECTION_ROOT))
from search_yaoex import map_yaoex_products, query_yaoex_products # noqa: E402
from search_ybm import map_ybm_products, query_ybm_products # noqa: E402
# 按条形码搜 1 号药城,结果转成 CartOffer 并带上原始商品字段。
def search_yaoex_offers( def search_yaoex_offers(
auth, auth,
query_key: str, query_key: str,
@ -42,6 +40,7 @@ def search_yaoex_offers(
return offers return offers
# 按条形码搜药帮忙,结果转成 CartOffer 并带上原始商品字段。
def search_ybm_offers( def search_ybm_offers(
cookies: dict[str, str], cookies: dict[str, str],
query_key: str, query_key: str,
@ -70,6 +69,7 @@ def search_ybm_offers(
return offers return offers
# 从药帮忙搜索行抽出商品原始字段,补上 skuId。
def _ybm_raw(row: dict[str, Any]) -> dict[str, Any]: def _ybm_raw(row: dict[str, Any]) -> dict[str, Any]:
product = row.get("productInfo") product = row.get("productInfo")
raw = dict(product) if isinstance(product, dict) else dict(row) raw = dict(product) if isinstance(product, dict) else dict(row)
@ -78,6 +78,7 @@ def _ybm_raw(row: dict[str, Any]) -> dict[str, Any]:
return raw return raw
# 把选品候选加上原始字段收成加购用的 CartOffer。
def _from_candidate(candidate, raw: dict[str, Any]) -> CartOffer: def _from_candidate(candidate, raw: dict[str, Any]) -> CartOffer:
extra = dict(candidate.extra) extra = dict(candidate.extra)
extra["raw"] = raw extra["raw"] = raw

View File

@ -1,4 +1,5 @@
"""1 号药城加入购物车。""" # 1 号药城加入购物车。
from __future__ import annotations from __future__ import annotations
import time import time
@ -16,6 +17,7 @@ GATEWAY = "https://gateway-b2b.fangkuaiyi.com"
YAOEX_ADD_URL = f"{GATEWAY}/small/shopCart/addShopCart" 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: def add_yaoex_cart(auth, offer: CartOffer, qty: int, *, session: requests.Session | None = None) -> AddOutcome:
ids = _ids(offer) ids = _ids(offer)
if not ids["skuId"] and not ids["spuCode"]: if not ids["skuId"] and not ids["spuCode"]:
@ -48,6 +50,7 @@ def add_yaoex_cart(auth, offer: CartOffer, qty: int, *, session: requests.Sessio
client.close() client.close()
# 从报价里取出加车要的 sku、spu 和商家编码。
def _ids(offer: CartOffer) -> dict[str, str]: def _ids(offer: CartOffer) -> dict[str, str]:
raw = offer.extra.get("raw") if isinstance(offer.extra.get("raw"), dict) else offer.extra raw = offer.extra.get("raw") if isinstance(offer.extra.get("raw"), dict) else offer.extra
if not isinstance(raw, dict): if not isinstance(raw, dict):
@ -58,6 +61,7 @@ def _ids(offer: CartOffer) -> dict[str, str]:
return {"skuId": sku, "spuCode": spu, "sellerCode": seller} return {"skuId": sku, "spuCode": spu, "sellerCode": seller}
# 拼 1 号药城表单:带上登录 token,再叠上加车字段。
def _form(auth, extra: dict[str, Any]) -> dict[str, Any]: def _form(auth, extra: dict[str, Any]) -> dict[str, Any]:
payload = { payload = {
"traderName": "yaoex_pc", "traderName": "yaoex_pc",
@ -75,6 +79,7 @@ def _form(auth, extra: dict[str, Any]) -> dict[str, Any]:
return payload return payload
# 1 号药城加车请求头。来源页是购物车。
def _headers() -> dict[str, str]: def _headers() -> dict[str, str]:
return { return {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
@ -87,6 +92,7 @@ def _headers() -> dict[str, str]:
} }
# 把加车回包收成成功、业务失败或系统失败。看不懂的回包当系统失败。
def _outcome_from_json(payload: object) -> AddOutcome: def _outcome_from_json(payload: object) -> AddOutcome:
if not isinstance(payload, dict): if not isinstance(payload, dict):
return AddOutcome(kind=KIND_SYSTEM, reason="加购返回异常") return AddOutcome(kind=KIND_SYSTEM, reason="加购返回异常")

View File

@ -1,4 +1,5 @@
"""药帮忙加入购物车。""" # 药帮忙加入购物车。
from __future__ import annotations from __future__ import annotations
import requests import requests
@ -12,6 +13,7 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
YBM_ADD_URL = "https://www.ybm100.com/new-front/cart/add-product" YBM_ADD_URL = "https://www.ybm100.com/new-front/cart/add-product"
# 按再搜到的 sku 把数量加入药帮忙购物车。没有 sku 当系统失败。
def add_ybm_cart( def add_ybm_cart(
cookies: dict[str, str], cookies: dict[str, str],
offer: CartOffer, offer: CartOffer,
@ -44,6 +46,7 @@ def add_ybm_cart(
client.close() client.close()
# 从报价里取药帮忙加车用的 skuId。
def _sku_id(offer: CartOffer) -> str: def _sku_id(offer: CartOffer) -> str:
raw = offer.extra.get("raw") if isinstance(offer.extra.get("raw"), dict) else offer.extra raw = offer.extra.get("raw") if isinstance(offer.extra.get("raw"), dict) else offer.extra
if not isinstance(raw, dict): if not isinstance(raw, dict):
@ -51,6 +54,7 @@ def _sku_id(offer: CartOffer) -> str:
return str(raw.get("skuId") or offer.sku_id or "") return str(raw.get("skuId") or offer.sku_id or "")
# 药帮忙加车请求头。
def _headers() -> dict[str, str]: def _headers() -> dict[str, str]:
return { return {
"Accept": "application/json, text/plain, */*", "Accept": "application/json, text/plain, */*",
@ -65,6 +69,7 @@ def _headers() -> dict[str, str]:
} }
# 把加车回包收成成功、业务失败或系统失败。看不懂的回包当系统失败。
def _outcome_from_json(payload: object) -> AddOutcome: def _outcome_from_json(payload: object) -> AddOutcome:
if not isinstance(payload, dict): if not isinstance(payload, dict):
return AddOutcome(kind=KIND_SYSTEM, reason="加购返回异常") return AddOutcome(kind=KIND_SYSTEM, reason="加购返回异常")

View File

@ -1,46 +0,0 @@
"""Oracle 11.2 thick 连接。环境变量与 rpa_oms / rpa_selection 相同。"""
from __future__ import annotations
import os
CREATE_USER_NAME = "rpa-cart"
_client_ready = False
class RepoUnavailable(Exception):
def __init__(self, message: str = "数据库不可用"):
self.message = message
super().__init__(message)
def oracle_dsn() -> tuple[str, str, str]:
user = os.getenv("RPA_ORACLE_USER", "")
password = os.getenv("RPA_ORACLE_PASSWORD", "")
host = os.getenv("RPA_ORACLE_HOST", "")
if not user or not password or not host:
raise RepoUnavailable("未配置 RPA_ORACLE_HOST / RPA_ORACLE_USER / RPA_ORACLE_PASSWORD")
port = os.getenv("RPA_ORACLE_PORT", "1521")
service = os.getenv("RPA_ORACLE_SERVICE", "rpa")
return user, password, f"{host}:{port}/{service}"
def connect_from_env():
user, password, dsn = oracle_dsn()
_ensure_client()
import oracledb
return oracledb.connect(user=user, password=password, dsn=dsn)
def _ensure_client() -> None:
global _client_ready
if _client_ready:
return
import oracledb
if os.getenv("RPA_ORACLE_THICK", "1") == "1":
lib_dir = os.getenv("RPA_ORACLE_CLIENT_LIB", "")
kwargs = {"lib_dir": lib_dir} if lib_dir else {}
oracledb.init_oracle_client(**kwargs)
_client_ready = True

View File

@ -1,4 +1,5 @@
"""1 号药城支付落表班次。""" # 1 号药城支付落表班次。
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
@ -6,6 +7,7 @@ from dataclasses import dataclass
from settle import MallLine, match_pay_lines from settle import MallLine, match_pay_lines
# 一次支付落表的计数:写入、状态跳过、订单号重复、对不上。
@dataclass @dataclass
class PaySummary: class PaySummary:
source_lines: int = 0 source_lines: int = 0
@ -15,11 +17,13 @@ class PaySummary:
unmatched: int = 0 unmatched: int = 0
unmatched_reasons: dict[str, int] | None = None unmatched_reasons: dict[str, int] | None = None
# 未对上的原因计数缺省为空字典,避免调用方再判 None。
def __post_init__(self) -> None: def __post_init__(self) -> None:
if self.unmatched_reasons is None: if self.unmatched_reasons is None:
self.unmatched_reasons = {} self.unmatched_reasons = {}
# 把 1 号药城已支付行写入表 2 的支付行。不对加购行。dry_run 只统计不落库。
def run_yaoex_pay( def run_yaoex_pay(
repo, repo,
lines: list[MallLine], lines: list[MallLine],

View File

@ -1,17 +1,17 @@
"""加购班与 1 号药城支付落表。默认支付;`cart` 子命令加购。""" # 加购班与 1 号药城支付落表。默认支付;`cart` 子命令加购。
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import tempfile from datetime import date, datetime
from datetime import date
from pathlib import Path from pathlib import Path
import requests import requests
from db import RepoUnavailable from rpa_oracle import RepoUnavailable
from job import run_yaoex_pay from job import run_yaoex_pay
from parse_xls import parse_order_xls from parse_xls import parse_order_xls
from repo import EmptyPayRepo, OraclePayRepo from repo import OraclePayRepo
from yaoex_client import day_range, export_orders from yaoex_client import day_range, export_orders
from yaoex_login import ( from yaoex_login import (
BrowserRuntime, BrowserRuntime,
@ -21,10 +21,33 @@ from yaoex_login import (
) )
PROJECT_ROOT = Path(__file__).resolve().parent PROJECT_ROOT = Path(__file__).resolve().parent
EXPORT_DIR = PROJECT_ROOT / "exports" / "yaoex"
YAOEX_PLATFORM_TYPE = 66 YAOEX_PLATFORM_TYPE = 66
YBM_PLATFORM_TYPE = 48 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: def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
argv = list(argv) if argv is not None else None argv = list(argv) if argv is not None else None
if argv is None: if argv is None:
@ -43,26 +66,24 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
return args return args
# 支付落表没有额外参数:登录、导出当天、写表 2。
def _parse_pay_args(argv: list[str]) -> argparse.Namespace: def _parse_pay_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="1 号药城支付落表") parser = argparse.ArgumentParser(description="1 号药城支付落表:登录、导出当天、解析、写库")
parser.add_argument("--xls", type=Path, default=None, help="已导出的订单明细 xls,跳过商城导出")
parser.add_argument(
"--days",
type=int,
default=0,
help="导出日期跨度,0=仅当天。与 --xls 互斥时仍用于无 xls 的导出",
)
parser.add_argument("--day", type=str, default=None, help="结束日期 YYYY-MM-DD,默认今天")
parser.add_argument("--dry-run", action="store_true", help="解析但不写库")
parser.add_argument("--fresh-login", action="store_true", help="忽略登录缓存")
parser.add_argument("--secrets", type=Path, default=None)
parser.add_argument("--yaoex-auth", type=Path, default=None)
return parser.parse_args(argv) return parser.parse_args(argv)
def _parse_cart_args(argv: list[str]) -> argparse.Namespace: def _parse_cart_args(argv: list[str]) -> argparse.Namespace:
"""解析加购参数:领取条数、平台、是否忽略登录缓存。
不传 --limit 时用 20,不再把空值当成全部领走。
"""
parser = argparse.ArgumentParser(description="加购班:领表 2 选品成功行,按仓加进购物车") parser = argparse.ArgumentParser(description="加购班:领表 2 选品成功行,按仓加进购物车")
parser.add_argument("--limit", type=int, default=None, help="本班最多领取 N 行") parser.add_argument(
"--limit",
type=int,
default=DEFAULT_CART_LIMIT,
help="本班最多领取 N 行,默认 20",
)
parser.add_argument( parser.add_argument(
"--platform", "--platform",
choices=["all", "yaoex", "ybm"], choices=["all", "yaoex", "ybm"],
@ -70,32 +91,25 @@ def _parse_cart_args(argv: list[str]) -> argparse.Namespace:
help="all=两平台都跑", help="all=两平台都跑",
) )
parser.add_argument("--fresh-login", action="store_true", help="忽略登录缓存") parser.add_argument("--fresh-login", action="store_true", help="忽略登录缓存")
parser.add_argument("--secrets", type=Path, default=None)
parser.add_argument("--yaoex-auth", type=Path, default=None) parser.add_argument("--yaoex-auth", type=Path, default=None)
parser.add_argument("--ybm-cookies", type=Path, default=None) parser.add_argument("--ybm-cookies", type=Path, default=None)
return parser.parse_args(argv) return parser.parse_args(argv)
# 入口。cart 子命令加购,否则跑 1 号药城支付落表。
def main(argv: list[str] | None = None, repo=None) -> None: def main(argv: list[str] | None = None, repo=None) -> None:
args = parse_args(argv) args = parse_args(argv)
if args.command == "cart": if args.command == "cart":
main_cart(args, repo=repo) main_cart(args, repo=repo)
return return
main_pay(args, repo=repo) main_pay(repo=repo)
def main_pay(args: argparse.Namespace, repo=None) -> None: # 登录 1 号药城,导出当天订单,已支付行写入表 2。不对加购行。
def main_pay(repo=None) -> None:
load_cart_dotenv() load_cart_dotenv()
xls_path = args.xls
tmp_xls: Path | None = None
if xls_path is None:
try: try:
auth = resolve_yaoex_auth( auth = resolve_yaoex_auth()
secrets_path=args.secrets,
auth_path=args.yaoex_auth,
fresh=args.fresh_login,
)
token = auth.token token = auth.token
print("1号药城认证就绪。") print("1号药城认证就绪。")
except SystemExit: except SystemExit:
@ -103,14 +117,13 @@ def main_pay(args: argparse.Namespace, repo=None) -> None:
except Exception as exc: except Exception as exc:
print(f"1号药城登录失败:{exc}") print(f"1号药城登录失败:{exc}")
raise SystemExit(2) from exc raise SystemExit(2) from exc
end = date.fromisoformat(args.day) if args.day else date.today() end = date.today()
begin, end_ts = day_range(end, args.days) begin, end_ts = day_range(end, 0)
print(f"导出 {begin} ~ {end_ts}") print(f"导出 {begin} ~ {end_ts}")
content = export_orders(token, begin=begin, end=end_ts) content = export_orders(token, begin=begin, end=end_ts)
tmp_dir = Path(tempfile.mkdtemp(prefix="yaoex_pay_")) xls_path = pay_export_path(begin, end_ts, datetime.now())
tmp_xls = tmp_dir / "orders.xls" xls_path.parent.mkdir(parents=True, exist_ok=True)
tmp_xls.write_bytes(content) xls_path.write_bytes(content)
xls_path = tmp_xls
print(f"导出文件 {xls_path} ({len(content)} bytes)") print(f"导出文件 {xls_path} ({len(content)} bytes)")
lines = parse_order_xls(xls_path) lines = parse_order_xls(xls_path)
@ -120,13 +133,10 @@ def main_pay(args: argparse.Namespace, repo=None) -> None:
try: try:
repo = OraclePayRepo.from_env() repo = OraclePayRepo.from_env()
except RepoUnavailable as exc: except RepoUnavailable as exc:
if not args.dry_run:
print(f"数据库不可用:{exc}") print(f"数据库不可用:{exc}")
raise SystemExit(2) from exc raise SystemExit(2) from exc
print(f"无库,dry-run 只解析:{exc}")
repo = EmptyPayRepo()
summary = run_yaoex_pay(repo, lines, dry_run=args.dry_run) summary = run_yaoex_pay(repo, lines)
print( print(
"落表 " "落表 "
f"inserted={summary.inserted} " f"inserted={summary.inserted} "
@ -137,6 +147,7 @@ def main_pay(args: argparse.Namespace, repo=None) -> None:
) )
# 领表 2 选品成功行,按条形码再搜并对上商家后加车。一行失败只改那一行。
def main_cart(args: argparse.Namespace, repo=None) -> None: def main_cart(args: argparse.Namespace, repo=None) -> None:
from cart_job import run_cart_batch from cart_job import run_cart_batch
from cart_repo import OracleCartRepo from cart_repo import OracleCartRepo
@ -168,7 +179,6 @@ def main_cart(args: argparse.Namespace, repo=None) -> None:
if want_yaoex: if want_yaoex:
try: try:
yaoex_auth = resolve_yaoex_auth( yaoex_auth = resolve_yaoex_auth(
secrets_path=args.secrets,
auth_path=args.yaoex_auth, auth_path=args.yaoex_auth,
fresh=args.fresh_login, fresh=args.fresh_login,
runtime=runtime, runtime=runtime,
@ -182,7 +192,6 @@ def main_cart(args: argparse.Namespace, repo=None) -> None:
if want_ybm: if want_ybm:
try: try:
ybm_cookies = resolve_ybm_cookies( ybm_cookies = resolve_ybm_cookies(
secrets_path=args.secrets,
cookie_path=args.ybm_cookies, cookie_path=args.ybm_cookies,
fresh=args.fresh_login, fresh=args.fresh_login,
runtime=runtime, runtime=runtime,
@ -200,11 +209,13 @@ def main_cart(args: argparse.Namespace, repo=None) -> None:
if yaoex_auth is not None: if yaoex_auth is not None:
# 用本班已登录的 1 号药城会话按条形码再搜。
def search_yaoex(query_key: str): def search_yaoex(query_key: str):
return search_yaoex_offers( return search_yaoex_offers(
yaoex_auth, query_key, session=yaoex_session, cache=yaoex_cache yaoex_auth, query_key, session=yaoex_session, cache=yaoex_cache
) )
# 把对上的仓按数量加入 1 号药城购物车。
def add_yaoex(offer, qty: int): def add_yaoex(offer, qty: int):
return add_yaoex_cart(yaoex_auth, offer, qty, session=yaoex_session) return add_yaoex_cart(yaoex_auth, offer, qty, session=yaoex_session)
@ -213,11 +224,13 @@ def main_cart(args: argparse.Namespace, repo=None) -> None:
if ybm_cookies is not None: if ybm_cookies is not None:
# 用本班已登录的药帮忙会话按条形码再搜。
def search_ybm(query_key: str): def search_ybm(query_key: str):
return search_ybm_offers( return search_ybm_offers(
ybm_cookies, query_key, session=ybm_session, cache=ybm_cache ybm_cookies, query_key, session=ybm_session, cache=ybm_cache
) )
# 把对上的仓按数量加入药帮忙购物车。
def add_ybm(offer, qty: int): def add_ybm(offer, qty: int):
return add_ybm_cart(ybm_cookies, offer, qty, session=ybm_session) return add_ybm_cart(ybm_cookies, offer, qty, session=ybm_session)

View File

@ -1,4 +1,5 @@
"""解析 1 号药城「订单明细」xls。""" # 解析 1 号药城「订单明细」xls。
from __future__ import annotations from __future__ import annotations
from decimal import Decimal from decimal import Decimal
@ -24,6 +25,7 @@ _REQUIRED = {
} }
# 读 1 号药城订单明细表。缺列就失败。订单号为空的行丢掉。
def parse_order_xls(path: str | Path) -> list[MallLine]: def parse_order_xls(path: str | Path) -> list[MallLine]:
book = xlrd.open_workbook(str(path)) book = xlrd.open_workbook(str(path))
sheet = book.sheet_by_index(0) sheet = book.sheet_by_index(0)
@ -57,6 +59,7 @@ def parse_order_xls(path: str | Path) -> list[MallLine]:
return lines return lines
# 单元格收成字符串。整数浮点去掉小数点,避免订单号变成 123.0。
def _text(value: object) -> str: def _text(value: object) -> str:
if value is None: if value is None:
return "" return ""
@ -65,6 +68,7 @@ def _text(value: object) -> str:
return str(value).strip() return str(value).strip()
# 条码只留第一段。逗号后的附加码丢掉,末尾 .0 去掉。
def _barcode(value: object) -> str: def _barcode(value: object) -> str:
text = _text(value) text = _text(value)
if "," in text: if "," in text:
@ -75,6 +79,7 @@ def _barcode(value: object) -> str:
return text return text
# 单元格收成金额。空值当 0。
def _decimal(value: object) -> Decimal: def _decimal(value: object) -> Decimal:
if value is None or value == "": if value is None or value == "":
return Decimal("0") return Decimal("0")

View File

@ -22,7 +22,7 @@ dev = [
package = false package = false
[tool.pytest.ini_options] [tool.pytest.ini_options]
pythonpath = ["."] pythonpath = [".", "..", "../rpa_selection"]
testpaths = ["tests"] testpaths = ["tests"]
addopts = "-q" addopts = "-q"

View File

@ -1,10 +1,14 @@
"""表 2:写支付行。""" # 表 2:写支付行。
#
# 连接从 rpa_oracle 池取。库故障对外「数据库不可用」,不含 ORA 原文。
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
from typing import Any from typing import Any
from db import CREATE_USER_NAME, RepoUnavailable, connect_from_env, oracle_dsn from rpa_oracle import RepoUnavailable, job_pool
CREATE_USER_NAME = "rpa-cart"
from settle import YAOEX_PLATFORM_TYPE from settle import YAOEX_PLATFORM_TYPE
_SELECT_PAY_ORDER_NOS = """ _SELECT_PAY_ORDER_NOS = """
@ -48,50 +52,70 @@ _PAY_BIND_KEYS = (
) )
class EmptyPayRepo: # 回滚当前事务。回滚本身失败则忽略。
def load_existing_pay_order_nos(self) -> set[str]: def _rollback(conn) -> None:
return set()
def save_pay_rows(self, rows: list[dict[str, Any]]) -> None:
return
class OraclePayRepo:
def __init__(self, connect: Callable):
self._connect = connect
@classmethod
def from_env(cls) -> "OraclePayRepo":
oracle_dsn()
return cls(connect=connect_from_env)
def load_existing_pay_order_nos(self) -> set[str]:
conn = self._connect()
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:
raise RepoUnavailable(str(exc)) from exc
finally:
conn.close()
def save_pay_rows(self, rows: list[dict[str, Any]]) -> None:
if not rows:
return
conn = self._connect()
try:
with conn.cursor() as cur:
for row in rows:
payload = {key: row.get(key) for key in _PAY_BIND_KEYS}
payload["create_user_name"] = CREATE_USER_NAME
cur.execute(_INSERT_PAY, payload)
conn.commit()
except Exception as exc:
try: try:
conn.rollback() conn.rollback()
except Exception: except Exception:
pass pass
raise RepoUnavailable(str(exc)) from exc
finally:
conn.close() # 支付班次连 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))

View File

@ -1,4 +1,5 @@
"""1 号药城导出行 → 表 2 operate_type=2。不对加购行。""" # 1 号药城导出行 → 表 2 operate_type=2。不对加购行。
from __future__ import annotations from __future__ import annotations
import uuid import uuid
@ -13,14 +14,17 @@ UNASSIGNED_OMS_ID = 0
PAID_STATUSES = {"待发货", "待收货", "已完成"} PAID_STATUSES = {"待发货", "待收货", "已完成"}
# 金额收到四位小数,四舍五入。接口 2 组包也按这个精度。
def money4(value: object) -> Decimal: def money4(value: object) -> Decimal:
return Decimal(str(value)).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP) return Decimal(str(value)).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
# 生成支付明细号。表 2 用它区分同一订单下的不同行。
def new_pay_dtl_id() -> str: def new_pay_dtl_id() -> str:
return uuid.uuid4().hex return uuid.uuid4().hex
# 1 号药城导出的一行订单明细。只取已支付状态落表。
@dataclass(frozen=True) @dataclass(frozen=True)
class MallLine: class MallLine:
order_no: str order_no: str
@ -37,6 +41,7 @@ class MallLine:
order_paid: Decimal order_paid: Decimal
# 支付匹配结果:可落库的行、对不上的行、重复订单、状态未付。
@dataclass @dataclass
class MatchResult: class MatchResult:
rows: list[dict[str, Any]] rows: list[dict[str, Any]]
@ -45,6 +50,7 @@ class MatchResult:
skipped_status: list[MallLine] skipped_status: list[MallLine]
# 筛出待发货、待收货、已完成的行。同一支付订单号已落过的跳过。条码超长不对上。
def match_pay_lines( def match_pay_lines(
lines: list[MallLine], lines: list[MallLine],
existing_order_nos: set[str], existing_order_nos: set[str],
@ -64,6 +70,7 @@ def match_pay_lines(
return result return result
# 把一条已支付明细收成表 2 支付行。operate_type=2,oms_id 先填 0,不改加购行。
def build_pay_row(line: MallLine) -> dict[str, Any]: def build_pay_row(line: MallLine) -> dict[str, Any]:
price = money4(line.pay_price) price = money4(line.pay_price)
qty = int(line.qty) qty = int(line.qty)

View File

@ -1,6 +1,11 @@
# 加购班次:拆仓加车不插行、系统故障短码。
from contextlib import contextmanager
from datetime import datetime, timedelta
from cart_job import KIND_BUSINESS, KIND_OK, KIND_SYSTEM, AddOutcome, run_cart_batch from cart_job import KIND_BUSINESS, KIND_OK, KIND_SYSTEM, AddOutcome, run_cart_batch
from cart_match import CartOffer from cart_match import CartOffer
from cart_repo import STAMP_DONE, STAMP_SELECTION, MemoryCartRepo from cart_repo import STAMP_CLAIMING, STAMP_DONE, STAMP_SELECTION, MemoryCartRepo
HANGZHOU = "杭州小药药自营旗舰店" HANGZHOU = "杭州小药药自营旗舰店"
@ -9,6 +14,7 @@ YBM = 48
YAOEX = 66 YAOEX = 66
# 构造一条待加购表 2 行。
def _row(**overrides) -> dict: def _row(**overrides) -> dict:
data = { data = {
"fid": 1, "fid": 1,
@ -27,6 +33,7 @@ def _row(**overrides) -> dict:
return data return data
# 构造一条能对上仓的加购候选。
def _offer(vendor: str, **overrides) -> CartOffer: def _offer(vendor: str, **overrides) -> CartOffer:
data = dict( data = dict(
sku_id="S-" + vendor[:2], sku_id="S-" + vendor[:2],
@ -39,6 +46,7 @@ def _offer(vendor: str, **overrides) -> CartOffer:
return CartOffer(**data) return CartOffer(**data)
# 杭州 3 + 广东 7 两行都加车,表 2 仍两行。
def test_job_adds_split_rows_without_insert(): def test_job_adds_split_rows_without_insert():
repo = MemoryCartRepo( repo = MemoryCartRepo(
[ [
@ -48,18 +56,22 @@ def test_job_adds_split_rows_without_insert():
) )
added: list[tuple[int, str, int]] = [] added: list[tuple[int, str, int]] = []
# 杭州仓有货。
def search_ybm(bar_code: str): def search_ybm(bar_code: str):
assert bar_code == "06901234567890" assert bar_code == "06901234567890"
return [_offer(HANGZHOU, sku_id="HZ", stock=5.0)] return [_offer(HANGZHOU, sku_id="HZ", stock=5.0)]
# 广东仓有货。
def search_yaoex(bar_code: str): def search_yaoex(bar_code: str):
assert bar_code == "06901234567890" assert bar_code == "06901234567890"
return [_offer(GUANGDONG, sku_id="GD", stock=20.0)] return [_offer(GUANGDONG, sku_id="GD", stock=20.0)]
# 记下药帮忙加车。
def add_ybm(offer: CartOffer, qty: int): def add_ybm(offer: CartOffer, qty: int):
added.append((YBM, offer.sku_id, qty)) added.append((YBM, offer.sku_id, qty))
return AddOutcome(kind=KIND_OK) return AddOutcome(kind=KIND_OK)
# 记下 1 号药城加车。
def add_yaoex(offer: CartOffer, qty: int): def add_yaoex(offer: CartOffer, qty: int):
added.append((YAOEX, offer.sku_id, qty)) added.append((YAOEX, offer.sku_id, qty))
return AddOutcome(kind=KIND_OK) return AddOutcome(kind=KIND_OK)
@ -82,9 +94,45 @@ def test_job_adds_split_rows_without_insert():
assert repo.rows[1]["push_status"] == 0 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(): def test_job_marks_out_of_stock():
repo = MemoryCartRepo([_row(purchase_qty=10)]) repo = MemoryCartRepo([_row(purchase_qty=10)])
# 库存只有 4。
def search_yaoex(bar_code: str): def search_yaoex(bar_code: str):
return [_offer(GUANGDONG, stock=4.0)] return [_offer(GUANGDONG, stock=4.0)]
@ -99,9 +147,11 @@ def test_job_marks_out_of_stock():
assert repo.rows[1]["status"] == 2 assert repo.rows[1]["status"] == 2
assert repo.rows[1]["fail_reason"] == "库存不足" assert repo.rows[1]["fail_reason"] == "库存不足"
assert repo.rows[1]["update_user_name"] == STAMP_DONE assert repo.rows[1]["update_user_name"] == STAMP_DONE
assert repo.shift_errors == []
def test_job_marks_login_fail_as_system_error(): def test_job_marks_login_fail_as_system_error():
"""登录失败把戳改回系统管理员,status 保持 1,下一班还能领。"""
repo = MemoryCartRepo( repo = MemoryCartRepo(
[ [
_row(fid=1, platform_type=YAOEX), _row(fid=1, platform_type=YAOEX),
@ -110,9 +160,11 @@ def test_job_marks_login_fail_as_system_error():
) )
ybm_added = [] ybm_added = []
# 杭州仓有货。
def search_ybm(bar_code: str): def search_ybm(bar_code: str):
return [_offer(HANGZHOU)] return [_offer(HANGZHOU)]
# 记下药帮忙加车数量。
def add_ybm(offer: CartOffer, qty: int): def add_ybm(offer: CartOffer, qty: int):
ybm_added.append(qty) ybm_added.append(qty)
return AddOutcome(kind=KIND_OK) return AddOutcome(kind=KIND_OK)
@ -125,18 +177,28 @@ def test_job_marks_login_fail_as_system_error():
) )
assert summary.added == 1 assert summary.added == 1
assert summary.failed == 1 assert summary.failed == 1
assert repo.rows[1]["status"] == 2 assert repo.rows[1]["status"] == 1
assert repo.rows[1]["fail_reason"].startswith("系统故障:") assert repo.rows[1]["update_user_name"] == STAMP_SELECTION
assert "账号密码错误" in repo.rows[1]["fail_reason"] 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]["status"] == 1
assert repo.rows[2]["update_user_name"] == STAMP_DONE assert repo.rows[2]["update_user_name"] == STAMP_DONE
assert ybm_added == [3] assert ybm_added == [3]
again = repo.claim_pending()
assert [row.fid for row in again] == [1]
# 已盖加购班的行本班不领。
def test_job_skips_already_stamped_rows(): def test_job_skips_already_stamped_rows():
repo = MemoryCartRepo([_row(update_user_name=STAMP_DONE)]) repo = MemoryCartRepo([_row(update_user_name=STAMP_DONE)])
called = [] called = []
# 不应被调用。
def search_yaoex(bar_code: str): def search_yaoex(bar_code: str):
called.append(bar_code) called.append(bar_code)
return [_offer(GUANGDONG)] return [_offer(GUANGDONG)]
@ -152,9 +214,11 @@ def test_job_skips_already_stamped_rows():
assert repo.rows[1]["update_user_name"] == STAMP_DONE assert repo.rows[1]["update_user_name"] == STAMP_DONE
# 对不上 supplier_name 时写未找到对应仓。
def test_job_marks_missing_warehouse(): def test_job_marks_missing_warehouse():
repo = MemoryCartRepo([_row()]) repo = MemoryCartRepo([_row()])
# 搜到的是杭州仓,对不上广东。
def search_yaoex(bar_code: str): def search_yaoex(bar_code: str):
return [_offer(HANGZHOU)] return [_offer(HANGZHOU)]
@ -169,29 +233,15 @@ def test_job_marks_missing_warehouse():
assert repo.rows[1]["fail_reason"] == "未找到对应仓" assert repo.rows[1]["fail_reason"] == "未找到对应仓"
def test_job_marks_pack_reject(): # 平台返回业务失败时原样写下原因。
repo = MemoryCartRepo([_row(purchase_qty=7)])
def search_yaoex(bar_code: str):
return [_offer(GUANGDONG, extra={"raw": {"middlePackNum": 10}})]
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"].startswith("中包装加不进")
def test_job_records_mall_add_failure(): def test_job_records_mall_add_failure():
repo = MemoryCartRepo([_row()]) repo = MemoryCartRepo([_row()])
# 广东仓有货。
def search_yaoex(bar_code: str): def search_yaoex(bar_code: str):
return [_offer(GUANGDONG)] return [_offer(GUANGDONG)]
# 平台返回中包装加不进。
def add_yaoex(offer: CartOffer, qty: int): def add_yaoex(offer: CartOffer, qty: int):
return AddOutcome(kind=KIND_BUSINESS, reason="中包装加不进:数量须为10的倍数") return AddOutcome(kind=KIND_BUSINESS, reason="中包装加不进:数量须为10的倍数")
@ -205,6 +255,7 @@ def test_job_records_mall_add_failure():
assert repo.rows[1]["fail_reason"] == "中包装加不进:数量须为10的倍数" assert repo.rows[1]["fail_reason"] == "中包装加不进:数量须为10的倍数"
# 只跑一个平台时另一平台行仍停在系统管理员。
def test_job_platform_filter_leaves_other_mall_pending(): def test_job_platform_filter_leaves_other_mall_pending():
repo = MemoryCartRepo( repo = MemoryCartRepo(
[ [
@ -213,6 +264,7 @@ def test_job_platform_filter_leaves_other_mall_pending():
] ]
) )
# 广东仓有货。
def search_yaoex(bar_code: str): def search_yaoex(bar_code: str):
return [_offer(GUANGDONG)] return [_offer(GUANGDONG)]
@ -231,10 +283,12 @@ def test_job_platform_filter_leaves_other_mall_pending():
def test_job_search_error_is_system_fail(): def test_job_search_error_is_system_fail():
"""查询失败原因列只写 QUERY_FAILED,戳改回系统管理员,status 保持 1。"""
repo = MemoryCartRepo([_row()]) repo = MemoryCartRepo([_row()])
# 抛带原文的查询错。
def search_yaoex(bar_code: str): def search_yaoex(bar_code: str):
raise RuntimeError("网关 502") raise RuntimeError("网关 502 secret-traceback")
summary = run_cart_batch( summary = run_cart_batch(
repo, repo,
@ -243,5 +297,155 @@ def test_job_search_error_is_system_fail():
login_errors={}, login_errors={},
) )
assert summary.failed == 1 assert summary.failed == 1
assert repo.rows[1]["fail_reason"].startswith("系统故障:") reason = repo.rows[1]["fail_reason"]
assert "网关 502" in 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

View File

@ -1,4 +1,4 @@
from cart_match import CartOffer, match_offer, middle_pack, qty_fits_pack from cart_match import CartOffer, match_offer
def _offer(**overrides) -> CartOffer: def _offer(**overrides) -> CartOffer:
@ -46,11 +46,3 @@ def test_same_supplier_keeps_cheaper():
def test_missing_supplier_returns_none(): def test_missing_supplier_returns_none():
assert match_offer([_offer()], "杭州小药药自营旗舰店") is None assert match_offer([_offer()], "杭州小药药自营旗舰店") is None
def test_qty_not_multiple_of_middle_pack():
offer = _offer(extra={"raw": {"middlePackNum": 10}})
assert middle_pack(offer) == 10
assert qty_fits_pack(7, 10) is False
assert qty_fits_pack(10, 10) is True
assert qty_fits_pack(7, None) is True

View File

@ -1,12 +1,28 @@
# 加购仓库:领取戳、租约、假连接录 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 ( from cart_repo import (
LEASE_MINUTES,
STAMP_CLAIMING, STAMP_CLAIMING,
STAMP_DONE, STAMP_DONE,
STAMP_SELECTION, STAMP_SELECTION,
MemoryCartRepo, MemoryCartRepo,
OracleCartRepo, OracleCartRepo,
_pending_select_sql,
) )
# 构造一条待加购表 2 行。
def _row(**overrides) -> dict: def _row(**overrides) -> dict:
data = { data = {
"fid": 1, "fid": 1,
@ -25,6 +41,7 @@ def _row(**overrides) -> dict:
return data return data
# 系统管理员成功行领走盖成加购中,已完成和失败行不领。
def test_claim_pending_stamps_processing_and_skips_done(): def test_claim_pending_stamps_processing_and_skips_done():
repo = MemoryCartRepo( repo = MemoryCartRepo(
[ [
@ -42,6 +59,29 @@ def test_claim_pending_stamps_processing_and_skips_done():
assert repo.rows[3]["status"] == 2 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(): def test_mark_success_does_not_insert_or_change_status():
repo = MemoryCartRepo([_row()]) repo = MemoryCartRepo([_row()])
repo.claim_pending() repo.claim_pending()
@ -53,6 +93,7 @@ def test_mark_success_does_not_insert_or_change_status():
assert repo.rows[1]["push_status"] == 0 assert repo.rows[1]["push_status"] == 0
# 失败改 status=2 并写下原因。
def test_mark_fail_sets_status_and_reason(): def test_mark_fail_sets_status_and_reason():
repo = MemoryCartRepo([_row()]) repo = MemoryCartRepo([_row()])
repo.claim_pending() repo.claim_pending()
@ -63,26 +104,103 @@ def test_mark_fail_sets_status_and_reason():
assert repo.rows[1]["update_user_name"] == STAMP_DONE assert repo.rows[1]["update_user_name"] == STAMP_DONE
def test_oracle_claim_updates_stamp(): # 加购 .env 空缺时补选品和 oms .env,已有非空环境变量不覆盖。
fetched = [ def test_load_runtime_env_fills_blank_from_other_env(tmp_path, monkeypatch):
( monkeypatch.delenv("RPA_ORACLE_HOST", raising=False)
11, monkeypatch.delenv("RPA_ORACLE_PASSWORD", raising=False)
2026100201, monkeypatch.setenv("RPA_ORACLE_USER", "keep-me")
66, cart_env = tmp_path / "cart.env"
"广东壹号药业有限公司", select_env = tmp_path / "select.env"
"0690", oms_env = tmp_path / "oms.env"
7, cart_env.write_text("RPA_ORACLE_HOST=192.168.12.244\nRPA_ORACLE_PASSWORD=\n", encoding="utf-8")
1, select_env.write_text("RPA_ORACLE_PASSWORD=from-select\nRPA_ORACLE_USER=from-select\n", encoding="utf-8")
None, oms_env.write_text("RPA_ORACLE_PASSWORD=from-oms\n", encoding="utf-8")
0, load_job_env(tmp_path, [cart_env, select_env, oms_env])
STAMP_SELECTION, assert os.environ["RPA_ORACLE_HOST"] == "192.168.12.244"
1, assert os.environ["RPA_ORACLE_PASSWORD"] == "from-select"
) assert os.environ["RPA_ORACLE_USER"] == "keep-me"
]
executed: list[tuple[str, dict]] = []
class FakeCursor:
description = [ # 缺 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",), ("FID",),
("OMS_ID",), ("OMS_ID",),
("PLATFORM_TYPE",), ("PLATFORM_TYPE",),
@ -94,43 +212,126 @@ def test_oracle_claim_updates_stamp():
("PUSH_STATUS",), ("PUSH_STATUS",),
("UPDATE_USER_NAME",), ("UPDATE_USER_NAME",),
("OPERATE_TYPE",), ("OPERATE_TYPE",),
] )
rowcount = 1
def __enter__(self):
return self
def __exit__(self, *args): # 构造领取查询返回的一行元组。
return False def _fetched_row(fid: int = 11):
return (
fid,
2026100201,
66,
"广东壹号药业有限公司",
"0690",
7,
1,
None,
0,
STAMP_SELECTION,
1,
)
def execute(self, sql, params=None):
executed.append((sql, params or {}))
def fetchall(self): # 领取 SQL 带 FOR UPDATE SKIP LOCKED,过期加购中可再领。
return fetched 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
class FakeConn:
committed = False
def cursor(self): # 领取一次借连接:SKIP LOCKED 选出后盖成加购中。
return FakeCursor() def test_oracle_claim_updates_stamp():
conn = _FakeConn(rows=[_fetched_row()], description=_CLAIM_DESCRIPTION)
def commit(self): pool = _FakePool(conn)
self.committed = True repo = OracleCartRepo(pool)
def rollback(self):
raise AssertionError("不应 rollback")
def close(self):
pass
conn = FakeConn()
repo = OracleCartRepo(connect=lambda: conn)
claimed = repo.claim_pending() claimed = repo.claim_pending()
assert pool.acquires == 1
assert conn.committed is True assert conn.committed is True
assert len(claimed) == 1 assert len(claimed) == 1
assert claimed[0].fid == 11 assert claimed[0].fid == 11
assert claimed[0].update_user_name == STAMP_CLAIMING assert claimed[0].update_user_name == STAMP_CLAIMING
assert "update_user_name" in executed[1][0].lower() select_sql, select_binds = conn.executed[0]
assert executed[1][1]["fid"] == 11 assert "skip locked" in select_sql.lower()
assert executed[1][1]["stamp"] == STAMP_CLAIMING 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

@ -1,23 +1,103 @@
from main import parse_args 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(): def test_parse_args_defaults():
args = parse_args([]) args = parse_args([])
assert args.days == 0
assert args.dry_run is False
assert args.xls is None
assert args.command == "pay" assert args.command == "pay"
def test_parse_args_xls_dry_run(): def test_parse_args_pay_rejects_mode_switches():
args = parse_args(["--xls", "/tmp/orders.xls", "--dry-run"]) with pytest.raises(SystemExit):
assert args.dry_run is True parse_args(["--xls", "/tmp/orders.xls", "--dry-run"])
assert str(args.xls).endswith("orders.xls")
assert args.command == "pay"
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(): def test_parse_args_cart_subcommand():
"""显式 --limit 仍覆盖默认 20。"""
args = parse_args(["cart", "--limit", "5", "--platform", "yaoex"]) args = parse_args(["cart", "--limit", "5", "--platform", "yaoex"])
assert args.command == "cart" assert args.command == "cart"
assert args.limit == 5 assert args.limit == 5
assert args.platform == "yaoex" 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

@ -1,43 +1,65 @@
# 支付落表仓库:假连接录 SQL。不连真库。
from __future__ import annotations
from contextlib import contextmanager
from repo import OraclePayRepo from repo import OraclePayRepo
def test_save_pay_rows_inserts_operate_type_2(): # 只交出一条连接。
executed: list[tuple[str, dict]] = [] 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: class FakeCursor:
# 进入 with,返回自身。
def __enter__(self): def __enter__(self):
return self return self
# 退出 with,不吞异常。
def __exit__(self, *args): def __exit__(self, *args):
return False return False
def execute(self, sql, params=None): # 记下一次批量 SQL 和全部 bind。
executed.append((sql, params or {}))
def executemany(self, sql, params): def executemany(self, sql, params):
for item in params: many.append((sql, [dict(item) for item in params]))
executed.append((sql, item))
# 提交后记下 committed。
class FakeConn: class FakeConn:
committed = False committed = False
# 新开录 SQL 的游标。
def cursor(self): def cursor(self):
return FakeCursor() return FakeCursor()
# 记下已提交。
def commit(self): def commit(self):
self.committed = True self.committed = True
# 本套用例不应回滚。
def rollback(self): def rollback(self):
raise AssertionError("不应 rollback") raise AssertionError("不应 rollback")
# 假连接不计数。
def close(self): def close(self):
pass return None
conn = FakeConn() conn = FakeConn()
repo = OraclePayRepo(connect=lambda: conn) repo = OraclePayRepo(_FakePool(conn))
repo.save_pay_rows( row = {
[
{
"operate_type": 2, "operate_type": 2,
"oms_id": 0, "oms_id": 0,
"platform_type": 66, "platform_type": 66,
@ -54,38 +76,55 @@ def test_save_pay_rows_inserts_operate_type_2():
"org_code": None, "org_code": None,
"multi_platform_flag": 0, "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 conn.committed is True
assert "rpa_cart_settlement_goods_data" in executed[0][0].lower() assert len(many) == 1
assert executed[0][1]["operate_type"] == 2 sql, binds = many[0]
assert executed[0][1]["oms_id"] == 0 assert "rpa_cart_settlement_goods_data" in sql.lower()
assert executed[0][1]["pay_order_no"] == "ZQD1" assert [item["operate_type"] for item in binds] == [2, 2]
assert executed[0][1]["pay_order_dtl_id"] == "a1b2c3d4e5f6789012345678901234ab" 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(): def test_load_existing_pay_order_nos():
# 返回预设支付单号。
class FakeCursor: class FakeCursor:
# 进入 with,返回自身。
def __enter__(self): def __enter__(self):
return self return self
# 退出 with,不吞异常。
def __exit__(self, *args): def __exit__(self, *args):
return False return False
# 记下 SQL 和 bind。
def execute(self, sql, params=None): def execute(self, sql, params=None):
self.sql = sql self.sql = sql
self.params = params self.params = params
# 返回含重复和空值的单号。
def fetchall(self): def fetchall(self):
return [("ZQD1",), ("ZQD1",), (None,)] return [("ZQD1",), ("ZQD1",), (None,)]
# 只提供游标。
class FakeConn: class FakeConn:
# 新开返回预设单号的游标。
def cursor(self): def cursor(self):
return FakeCursor() return FakeCursor()
# 假连接不计数。
def close(self): def close(self):
pass return None
repo = OraclePayRepo(connect=lambda: FakeConn()) repo = OraclePayRepo(_FakePool(FakeConn()))
keys = repo.load_existing_pay_order_nos() keys = repo.load_existing_pay_order_nos()
assert keys == {"ZQD1"} assert keys == {"ZQD1"}

View File

@ -1,4 +1,5 @@
"""1 号药城订单导出。""" # 1 号药城订单导出。
from __future__ import annotations from __future__ import annotations
import json import json
@ -15,6 +16,7 @@ GATEWAY = "https://gateway-b2b.fangkuaiyi.com"
EXPORT_URL = f"{GATEWAY}/api/order/export" EXPORT_URL = f"{GATEWAY}/api/order/export"
# 导出用的起止时间。默认只含当天,从 0 点到 23:59:59。
def day_range(day: date | None = None, days: int = 0) -> tuple[str, str]: def day_range(day: date | None = None, days: int = 0) -> tuple[str, str]:
end = day or date.today() end = day or date.today()
start = end - timedelta(days=max(days, 0)) start = end - timedelta(days=max(days, 0))
@ -24,6 +26,7 @@ def day_range(day: date | None = None, days: int = 0) -> tuple[str, str]:
) )
# 按时间段导出 1 号药城订单明细,返回 Excel 字节。回包不是表格就失败。
def export_orders( def export_orders(
token: str, token: str,
*, *,
@ -62,6 +65,7 @@ def export_orders(
client.close() client.close()
# 拼 1 号药城导出表单,带上登录 token。
def _form(token: str, extra: dict[str, Any]) -> dict[str, Any]: def _form(token: str, extra: dict[str, Any]) -> dict[str, Any]:
payload = { payload = {
"traderName": "yaoex_pc", "traderName": "yaoex_pc",
@ -77,6 +81,7 @@ def _form(token: str, extra: dict[str, Any]) -> dict[str, Any]:
return payload return payload
# 1 号药城订单导出请求头。来源页是订单列表。
def _headers() -> dict[str, str]: def _headers() -> dict[str, str]:
return { return {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",

View File

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

View File

@ -12,26 +12,28 @@
口径:`docs/RPA订单中心接口开发口径_20260930.md` 第 4 节;合单细节见 `docs/合同系统录入流程.md`。 口径:`docs/RPA订单中心接口开发口径_20260930.md` 第 4 节;合单细节见 `docs/合同系统录入流程.md`。
## 测试账号 ## 测试环境
地址:`http://contract-test.gzmpc.com:8080/` 地址:`http://contract-test.gzmpc.com:8080/`
账号:`10007228 / 1` 账密只认环境变量 `CONTRACT_USERNAME` / `CONTRACT_PASSWORD`,缺则入口失败。不要把密码写进仓库。
客商必须是合同系统里真实存在的操作码,占位符搜不到。例如 `DW0100046361` / `重庆亿昊药业有限公司`。 客商必须是合同系统里真实存在的操作码,占位符搜不到。例如 `DW0100046361` / `重庆亿昊药业有限公司`。
## 页面步骤 ## 页面步骤
脚本直达起草/模板 URL: 脚本直达起草/模板 URL。修一步只打开对应文件;`contract_flow_steps.py` 是对外入口。
1. 打开合同系统并登录 1. 打开合同系统并登录(`contract_login.py` S02–S05)
2. 进起草,套非格式采购协议模板 2. 进起草,套非格式采购协议模板(直达 `FORM_PAGE_URL`)
3. 签约方:客户编码、客户名称 3. 签约方:客户编码、客户名称(`contract_form.py` S06)
4. 尽调:页面正文含「未尽调」则本单失败,不提交 4. 尽调:页面正文含「未尽调」则本单失败,不提交
5. 基础信息:合同版本类型、合同类别、结算类型、合同背景概述、合同性质=主合同 5. 基础信息:合同版本类型、合同类别、结算类型、合同背景概述、合同性质=主合同(S07)
6. 金额与日期:签订日 / 开始日 / 结束日用接口 3 第一行三日期(JSON 干跑未给则回退 `orderDate`) 6. 金额与日期:签订日 / 开始日 / 结束日用接口 3 第一行三日期(JSON 干跑未给则回退 `orderDate`)(S08)
7. 货品明细 7. 货品明细:弹窗按操作码搜,找不到则本单失败(`contract_goods.py` S09)
8. 付款与标志:这期一律账期,不勾预付款;页面「账期支付」对应选项「电汇」 8. 付款与标志:这期一律账期,不勾预付款;页面「账期支付」对应选项「电汇」(S10)
9. 点提交,不要点暂存 9. 挂合同正文(`contract_upload.py` S11);点提交(`contract_submit.py` S12),不要只点暂存
10. 从「我的合同」读正式合同号 10. 按 fdId 从详情读正式合同号(`contract_number.py` S13)
选择器、工具栏、`fdId` 在 `contract_common.py`。
## 运行 ## 运行
@ -52,6 +54,58 @@ uv run python run_batch.py
`submitMode=dry_run` 跑到提交前,不点提交。真起草用 `examples/contract_draft_yyc.json` / `examples/contract_draft_xyy.json`。 `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`。 向订单中心发 HTTP、改 `push_status`。返利在 `rpa_rebate/`。点击顺序和选择器本段不改。

View File

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

View File

@ -1,627 +1,33 @@
# coding=utf-8 # 合同班浏览器适配。复用 rpa_browser 共享适配器。
""" #
Selenium Chrome 浏览器适配。通过 WebDriver 操控 Chrome。 # 本文件只做导入入口,步骤函数仍 from apa_browser_adapter import ApaBrowserAdapter。
""" # Chrome 用户目录和调试口用共享默认:CONTRACT_CREATE 口 9333。
from __future__ import annotations from __future__ import annotations
import os import sys
import socket
import time
from pathlib import Path from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1] _REPO_ROOT = Path(__file__).resolve().parents[1]
if str(_REPO_ROOT) not in sys.path:
_FLOW_DEBUG_PORTS = { sys.path.insert(0, str(_REPO_ROOT))
"CONTRACT_CREATE": 9333,
"REBATE_FIND": 9335, from rpa_browser.adapter import ( # noqa: E402
"default": 9334, ApaBrowserAdapter,
} REPO_ROOT,
chrome_debug_port,
resolve_chrome_profile_base,
def _is_inside_repo(path: Path, repo_root: Path) -> bool: resolve_chrome_profile_dir,
try: wait_debug_port,
path.resolve().relative_to(repo_root.resolve()) wait_devtools_port,
except ValueError: )
return False
return True __all__ = [
"ApaBrowserAdapter",
"REPO_ROOT",
def resolve_chrome_profile_base(requested: str | None = None, *, repo_root: Path | None = None) -> Path: "chrome_debug_port",
"""Chrome user-data dir must stay outside the repo so list_dir never walks 3GB.""" "resolve_chrome_profile_base",
root = (repo_root or REPO_ROOT).resolve() "resolve_chrome_profile_dir",
outside = (root.parent / "rpa-chrome-profile").resolve() "wait_debug_port",
raw = os.getenv("RPA_CHROME_PROFILE") if requested is None else requested "wait_devtools_port",
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
def resolve_chrome_profile_dir(
flow: str = "default",
requested: str | None = None,
*,
repo_root: Path | None = None,
) -> Path:
"""Stable profile per flow so later tasks reuse login cookies."""
base = resolve_chrome_profile_base(requested, repo_root=repo_root)
safe = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in (flow or "default").strip())
return (base / (safe or "default")).resolve()
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((flow or "default").strip(), 9333)
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
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
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())
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
def _launch_chrome(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))
return webdriver.Chrome(service=service, options=options)
class ApaBrowserAdapter:
"""Selenium Chrome 浏览器适配器,实现 BrowserAdapter 协议"""
def __init__(self, url: str = "", *, flow: str = "default"):
self._url = url
self._flow = flow
self._driver = None
self._is_open = False
def _get_driver(self):
"""复用本班次已打开的 Chrome;没有就按稳定 profile 新开。"""
if self._driver is not None:
return self._driver
profile_dir = resolve_chrome_profile_dir(self._flow)
if _is_inside_repo(profile_dir, REPO_ROOT):
raise RuntimeError(f"拒绝在仓库内创建 Chrome 用户目录: {profile_dir}")
os.makedirs(profile_dir, exist_ok=True)
port = _read_devtools_port(profile_dir) or chrome_debug_port(self._flow)
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 = chrome_debug_port(self._flow)
try:
driver = _launch_chrome(_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 = _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
def _ensure_open(self):
if self._driver is None:
driver = self._get_driver()
if self._url:
driver.get(self._url)
self._is_open = True
# ---- 内部查找方法 ----
def _find(self, selector: str, timeout: int = 5):
"""
智能查找元素:支持 CSS、XPath、text=、label=、aria=、title=、:contains()
"""
from selenium.webdriver.common.by import By
from selenium.common.exceptions import NoSuchElementException
import re
# 1. text=xxx → 查找包含对应文本的元素
if selector.startswith("text="):
text_val = selector[5:].strip('"').strip("'")
xpath = f'//*[contains(text(),"{text_val}")]'
return self._driver.find_element(By.XPATH, xpath)
# 2. label=xxx → 找到对应标签的输入框
if selector.startswith("label="):
text_val = selector[6:].strip('"').strip("'")
xpath = f'//label[contains(text(),"{text_val}")]/following-sibling::*[1][self::input or self::select or self::textarea]'
try:
return self._driver.find_element(By.XPATH, xpath)
except NoSuchElementException:
# Fallback: find the label's "for" attribute
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
# 3. aria=xxx → aria-label 属性
if selector.startswith("aria="):
text_val = selector[5:].strip('"').strip("'")
return self._driver.find_element(By.CSS_SELECTOR, f'[aria-label="{text_val}"]')
# 4. title=xxx → title 属性
if selector.startswith("title="):
text_val = selector[6:].strip('"').strip("'")
return self._driver.find_element(By.CSS_SELECTOR, f'[title="{text_val}"]')
# 5. :contains() → 转 XPath(支持 button:contains(文本) / a:contains("文本"))
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)
# 6. 显式 XPath(以 / 或 // 开头)优先按 XPath 查找
if selector.startswith("//") or selector.startswith("/"):
return self._driver.find_element(By.XPATH, selector)
# 7. 标准 CSS selector
return self._driver.find_element(By.CSS_SELECTOR, selector)
# ---- BrowserAdapter 协议实现 ----
def open_url(self, url: str) -> None:
self._url = url
driver = self._get_driver()
try:
driver.get(url)
except Exception as exc:
# eager 仍可能因长轮询资源超时;页面文档已出来就继续
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
def _set_value_via_js(self, el, value: str) -> None:
"""通过原生 value setter + 事件注入设置值,兼容 LUI/React 受控组件"""
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),
)
def fill_first(self, selectors: list, value: str) -> None:
self._ensure_open()
last_error = None
for sel in selectors:
try:
el = self._find(sel)
# 先尝试原生输入;LUI 受控组件用 JS 注入兜底
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)
return
except Exception as e:
last_error = e
continue
raise RuntimeError(f"fill_first failed: {selectors}, last: {last_error}")
def click_native(self, selectors: list) -> None:
"""真实鼠标点击,给只监听 mousedown 的 LUI 页签/工具栏用。"""
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)
return
except Exception as e:
last_error = e
continue
raise RuntimeError(f"click_native failed: {selectors}, last: {last_error}")
def click_first(self, selectors: list) -> None:
self._ensure_open()
last_error = None
for sel in selectors:
try:
el = self._find(sel)
# LUI/委托事件用 JS 点击最可靠
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}")
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}")
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}")
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
def switch_to_frame(self, selectors: list) -> None:
"""切换到匹配到的 iframe。优先可见弹窗,避免切到上次留下的隐藏 iframe。"""
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}")
def switch_to_default_content(self) -> None:
"""切回主文档(顶层 frame)。"""
self._ensure_open()
self._driver.switch_to.default_content()
def execute_script(self, script: str, *args):
self._ensure_open()
return self._driver.execute_script(script, *args)
def select_xform(self, name: str, value: str) -> None:
"""选中 Landray xform fancy-select(.fs-wrap / .fs-option)。"""
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)
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)
# 原生 <select>
try:
Select(el).select_by_visible_text(value)
time.sleep(0.3)
if self._selected_text_matches(el, value):
return
except Exception:
pass
# 兜底:JS 定位 option 并设置选中
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}")
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
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),
)
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}")
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}")
def read_body_text(self) -> str:
self._ensure_open()
try:
return self._driver.find_element("tag name", "body").text
except Exception:
return ""
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)
def accept_alert(self) -> str:
"""Accept a native JS alert/confirm and return its text. Empty if none."""
self._ensure_open()
try:
alert = self._driver.switch_to.alert
text = (alert.text or "").strip()
alert.accept()
return text
except Exception:
return ""
def reopen_url(self, url: str) -> None:
"""Close the current tab and open url in a fresh tab of the same Chrome."""
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)

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,4 +1,8 @@
"""CONTRACT_CREATE: 入参组装 + Selenium 起草。结果返回合同号,不回调旧状态中心。""" # CONTRACT_CREATE: 入参组装 + Selenium 起草。
#
# 结果返回合同号,不回调旧状态中心。
# 合同系统账密只认 CONTRACT_USERNAME / CONTRACT_PASSWORD 或入参,缺则失败。
from __future__ import annotations from __future__ import annotations
import argparse import argparse
@ -12,7 +16,6 @@ from apa_browser_adapter import ApaBrowserAdapter
from contract_draft import ( from contract_draft import (
ContractDraft, ContractDraft,
contract_draft_from_dict, contract_draft_from_dict,
is_placeholder_customer_code,
load_contract_draft, load_contract_draft,
) )
from contract_flow_steps import ContractFlowContext, run_contract_create_flow from contract_flow_steps import ContractFlowContext, run_contract_create_flow
@ -22,16 +25,19 @@ from pdf_utils import ensure_merged_pdf
CONTRACT_URL = "http://contract-test.gzmpc.com:8080/" CONTRACT_URL = "http://contract-test.gzmpc.com:8080/"
# 入参缺失 / 页面元素找不到 / 页面改版。
class ContractCreateError(NodeError): class ContractCreateError(NodeError):
"""入参缺失 / 页面元素找不到 / 页面改版。""" pass
# 返回本包 examples 下默认干跑 JSON 的路径。
def default_contract_input_path() -> str: def default_contract_input_path() -> str:
return str( return str(
Path(__file__).resolve().parent / "examples" / "contract_create_input_dry_run.json" Path(__file__).resolve().parent / "examples" / "contract_create_input_dry_run.json"
) )
# 一次合同起草的入参。账密为空时由 resolve_contract_credentials 补环境变量。
@dataclass(frozen=True) @dataclass(frozen=True)
class ContractCreateInput: class ContractCreateInput:
job_id: str job_id: str
@ -41,8 +47,8 @@ class ContractCreateInput:
contract_system_no: str | None = None contract_system_no: str | None = None
attempt_no: int = 1 attempt_no: int = 1
contract_url: str = CONTRACT_URL contract_url: str = CONTRACT_URL
username: str = "10007228" username: str = ""
password: str = "1" password: str = ""
submit_mode: str = "submit" submit_mode: str = "submit"
order_no_list: tuple[str, ...] = () order_no_list: tuple[str, ...] = ()
template_name: str = "" template_name: str = ""
@ -54,6 +60,24 @@ class ContractCreateInput:
ContractRunner = Callable[[ContractCreateInput], str] ContractRunner = Callable[[ContractCreateInput], str]
# 取出合同系统账密。入参优先,否则读 CONTRACT_USERNAME / CONTRACT_PASSWORD。
#
# 用户名、密码缺一即抛 ContractCreateError。不内置测试账号。
def resolve_contract_credentials(
username: str | None = None,
password: str | None = None,
) -> tuple[str, str]:
user = (username or "").strip() or (os.getenv("CONTRACT_USERNAME") or "").strip()
if password is None or password == "":
pwd = os.getenv("CONTRACT_PASSWORD") or ""
else:
pwd = str(password)
if not user or pwd == "":
raise ContractCreateError("未配置 CONTRACT_USERNAME / CONTRACT_PASSWORD")
return user, pwd
# 按入参起草合同。dry_run 不点提交;submit 必须读到合同号。
def execute_contract_create( def execute_contract_create(
spec: ContractCreateInput, spec: ContractCreateInput,
*, *,
@ -80,8 +104,8 @@ def execute_contract_create(
} }
# 在合同系统中起草合同并返回合同编号。
def create_contract_in_contract_system(spec: ContractCreateInput) -> str: def create_contract_in_contract_system(spec: ContractCreateInput) -> str:
"""在合同系统中起草合同并返回合同编号。"""
if spec.contract_system_no: if spec.contract_system_no:
return spec.contract_system_no return spec.contract_system_no
@ -95,6 +119,7 @@ def create_contract_in_contract_system(spec: ContractCreateInput) -> str:
return run_contract_create_flow(browser, ctx) return run_contract_create_flow(browser, ctx)
# 把 JSON 字典收成 ContractCreateInput,并挂上页面上下文。
def build_spec_from_data(data: dict[str, Any]) -> ContractCreateInput: def build_spec_from_data(data: dict[str, Any]) -> ContractCreateInput:
draft = contract_draft_from_dict(data) draft = contract_draft_from_dict(data)
_validate_data(data, draft) _validate_data(data, draft)
@ -107,8 +132,8 @@ def build_spec_from_data(data: dict[str, Any]) -> ContractCreateInput:
contract_system_no=_opt(data.get("contractSystemNo")), contract_system_no=_opt(data.get("contractSystemNo")),
attempt_no=int(data.get("attemptNo", 1) or 1), attempt_no=int(data.get("attemptNo", 1) or 1),
contract_url=_clean(data.get("contractUrl")) or os.getenv("CONTRACT_URL", CONTRACT_URL), contract_url=_clean(data.get("contractUrl")) or os.getenv("CONTRACT_URL", CONTRACT_URL),
username=_clean(data.get("username")) or os.getenv("CONTRACT_USERNAME", "10007228"), username=_clean(data.get("username")),
password=str(data.get("password") or os.getenv("CONTRACT_PASSWORD", "1")), password="" if data.get("password") in (None, "") else str(data.get("password")),
submit_mode=draft.submit_mode, submit_mode=draft.submit_mode,
order_no_list=tuple(draft.order_no_list), order_no_list=tuple(draft.order_no_list),
template_name=draft.template_name, template_name=draft.template_name,
@ -119,12 +144,14 @@ def build_spec_from_data(data: dict[str, Any]) -> ContractCreateInput:
return replace(spec, flow_context=ctx) return replace(spec, flow_context=ctx)
# 把草稿和入参收成页面上下文。账密缺环境变量时在这里失败。
def build_flow_context(spec: ContractCreateInput, draft: ContractDraft) -> ContractFlowContext: def build_flow_context(spec: ContractCreateInput, draft: ContractDraft) -> ContractFlowContext:
merged_pdf_path = _resolve_merged_pdf(draft, spec) merged_pdf_path = _resolve_merged_pdf(draft, spec)
username, password = resolve_contract_credentials(spec.username, spec.password)
return ContractFlowContext( return ContractFlowContext(
contract_url=spec.contract_url, contract_url=spec.contract_url,
username=spec.username, username=username,
password=spec.password, password=password,
supplier_name=draft.supplier_name or spec.supplier_name, supplier_name=draft.supplier_name or spec.supplier_name,
order_date=draft.order_date or spec.order_date, order_date=draft.order_date or spec.order_date,
source_type=draft.source_type or spec.source_type, source_type=draft.source_type or spec.source_type,
@ -158,6 +185,7 @@ def build_flow_context(spec: ContractCreateInput, draft: ContractDraft) -> Contr
) )
# YYC 才合并平台 PDF。dry_run 不校验不上传。
def _resolve_merged_pdf(draft: ContractDraft, spec: ContractCreateInput) -> str | None: def _resolve_merged_pdf(draft: ContractDraft, spec: ContractCreateInput) -> str | None:
if draft.source_type != "YYC": if draft.source_type != "YYC":
return None return None
@ -185,6 +213,7 @@ def _resolve_merged_pdf(draft: ContractDraft, spec: ContractCreateInput) -> str
raise PdfMergeUnsupported(f"YYC 平台合同 PDF 合并失败:{exc}") from exc raise PdfMergeUnsupported(f"YYC 平台合同 PDF 合并失败:{exc}") from exc
# 组装提交成功后的摘要字段。
def _build_summary(spec: ContractCreateInput) -> dict[str, Any]: def _build_summary(spec: ContractCreateInput) -> dict[str, Any]:
return { return {
"supplierName": spec.supplier_name, "supplierName": spec.supplier_name,
@ -197,19 +226,13 @@ def _build_summary(spec: ContractCreateInput) -> dict[str, Any]:
} }
# 检查必填字段、金额和货品行。不通过则抛 ContractCreateError。
def _validate_data(data: dict[str, Any], draft: ContractDraft) -> None: def _validate_data(data: dict[str, Any], draft: ContractDraft) -> None:
missing = [] missing = []
for key in ("jobId", "supplierName", "orderDate", "sourceType", "customerCode"): for key in ("jobId", "supplierName", "orderDate", "sourceType", "customerCode"):
if not _str(data.get(key)): if not _str(data.get(key)):
missing.append(key) missing.append(key)
customer_code = draft.customer_code or _str(data.get("customerCode"))
if customer_code and is_placeholder_customer_code(customer_code):
raise ContractCreateError(
f"customerCode「{customer_code}」是占位符,合同系统客商选择窗搜不到。"
"请换成测试环境真实存在的客商操作码,例如 DW0100046361(重庆亿昊药业有限公司)"
)
if draft.submit_mode not in ("dry_run", "submit"): if draft.submit_mode not in ("dry_run", "submit"):
raise ContractCreateError(f"submitMode 非法:{draft.submit_mode}(仅支持 dry_run / submit)") raise ContractCreateError(f"submitMode 非法:{draft.submit_mode}(仅支持 dry_run / submit)")
@ -233,6 +256,7 @@ def _validate_data(data: dict[str, Any], draft: ContractDraft) -> None:
raise ContractCreateError("缺少必填字段:" + ", ".join(missing)) raise ContractCreateError("缺少必填字段:" + ", ".join(missing))
# 取出必填字符串。缺或空白则失败。
def _require(data: dict[str, Any], key: str) -> str: def _require(data: dict[str, Any], key: str) -> str:
value = _str(data.get(key)) value = _str(data.get(key))
if not value: if not value:
@ -240,17 +264,20 @@ def _require(data: dict[str, Any], key: str) -> str:
return value return value
# 把值收成非空字符串,空白则 None。
def _opt(value: Any) -> str | None: def _opt(value: Any) -> str | None:
value = _str(value) value = _str(value)
return value or None return value or None
# 把任意值收成去两端空白的字符串。None 当空。
def _str(value: Any) -> str: def _str(value: Any) -> str:
if value is None: if value is None:
return "" return ""
return str(value).strip() return str(value).strip()
# 去掉两端空白和成对反引号。
def _clean(value: Any) -> str: def _clean(value: Any) -> str:
value = _str(value) value = _str(value)
if len(value) >= 2 and value[0] == "`" and value[-1] == "`": if len(value) >= 2 and value[0] == "`" and value[-1] == "`":
@ -258,8 +285,8 @@ def _clean(value: Any) -> str:
return value return value
# 解析入参:优先按 JSON 字符串,其次按 JSON 文件路径。
def load_input_data(raw: str) -> dict[str, Any]: def load_input_data(raw: str) -> dict[str, Any]:
"""解析入参:优先按 JSON 字符串,其次按 JSON 文件路径。"""
raw = (raw or "").strip() raw = (raw or "").strip()
if not raw: if not raw:
fallback = default_contract_input_path() fallback = default_contract_input_path()
@ -280,11 +307,13 @@ def load_input_data(raw: str) -> dict[str, Any]:
raise ContractCreateError(f"无法解析入参:既不是合法 JSON,也不是存在的文件路径:{raw}") raise ContractCreateError(f"无法解析入参:既不是合法 JSON,也不是存在的文件路径:{raw}")
# 从 JSON 字典起草合同并返回结果。
def run_contract_create(data: dict[str, Any]) -> dict[str, Any]: def run_contract_create(data: dict[str, Any]) -> dict[str, Any]:
spec = build_spec_from_data(data) spec = build_spec_from_data(data)
return execute_contract_create(spec) return execute_contract_create(spec)
# 解析命令行。账密默认读环境变量,没有内置账号。
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="合同系统页面起草。") parser = argparse.ArgumentParser(description="合同系统页面起草。")
parser.add_argument("--input-json", default=os.getenv("CONTRACT_INPUT_JSON")) parser.add_argument("--input-json", default=os.getenv("CONTRACT_INPUT_JSON"))
@ -295,13 +324,14 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--contract-system-no", default=os.getenv("CONTRACT_SYSTEM_NO")) parser.add_argument("--contract-system-no", default=os.getenv("CONTRACT_SYSTEM_NO"))
parser.add_argument("--attempt-no", type=int, default=1) parser.add_argument("--attempt-no", type=int, default=1)
parser.add_argument("--contract-url", default=os.getenv("CONTRACT_URL", CONTRACT_URL)) parser.add_argument("--contract-url", default=os.getenv("CONTRACT_URL", CONTRACT_URL))
parser.add_argument("--username", default=os.getenv("CONTRACT_USERNAME", "10007228")) parser.add_argument("--username", default=os.getenv("CONTRACT_USERNAME"))
parser.add_argument("--password", default=os.getenv("CONTRACT_PASSWORD", "1")) parser.add_argument("--password", default=os.getenv("CONTRACT_PASSWORD"))
parser.add_argument("--draft-path", default=os.getenv("CONTRACT_DRAFT_PATH")) parser.add_argument("--draft-path", default=os.getenv("CONTRACT_DRAFT_PATH"))
parser.add_argument("--merged-pdf-output", default=os.getenv("MERGED_PDF_OUTPUT")) parser.add_argument("--merged-pdf-output", default=os.getenv("MERGED_PDF_OUTPUT"))
return parser.parse_args() return parser.parse_args()
# 命令行入口:JSON 或参数起草一份合同,打印 JSON 结果。
def main() -> int: def main() -> int:
args = parse_args() args = parse_args()
@ -310,6 +340,7 @@ def main() -> int:
else: else:
if not (args.job_id and args.supplier_name and args.order_date): if not (args.job_id and args.supplier_name and args.order_date):
raise SystemExit("缺少 --job-id/--supplier-name/--order-date 或 --input-json") raise SystemExit("缺少 --job-id/--supplier-name/--order-date 或 --input-json")
username, password = resolve_contract_credentials(args.username, args.password)
spec = ContractCreateInput( spec = ContractCreateInput(
job_id=args.job_id, job_id=args.job_id,
supplier_name=args.supplier_name, supplier_name=args.supplier_name,
@ -318,8 +349,8 @@ def main() -> int:
contract_system_no=args.contract_system_no, contract_system_no=args.contract_system_no,
attempt_no=args.attempt_no, attempt_no=args.attempt_no,
contract_url=args.contract_url, contract_url=args.contract_url,
username=args.username, username=username,
password=args.password, password=password,
draft_path=args.draft_path, draft_path=args.draft_path,
merged_pdf_output=args.merged_pdf_output, merged_pdf_output=args.merged_pdf_output,
) )

View File

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

File diff suppressed because it is too large Load Diff

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,73 +0,0 @@
"""Oracle 11.2 thick 连接。环境变量与 rpa_oms 相同。"""
from __future__ import annotations
import os
from pathlib import Path
_client_ready = False
_PROJECT_ROOT = Path(__file__).resolve().parent
_OMS_ENV = _PROJECT_ROOT.parent / "rpa_oms" / ".env"
class RepoUnavailable(Exception):
def __init__(self, message: str = "数据库不可用"):
self.message = message
super().__init__(message)
def load_runtime_env(paths: list[Path] | None = None) -> None:
"""先读合同 .env,空缺再补 rpa_oms/.env。不覆盖已有非空环境变量。"""
env_paths = paths if paths is not None else [_PROJECT_ROOT / ".env", _OMS_ENV]
for path in env_paths:
_fill_env(path)
def _fill_env(path: Path) -> None:
if not path.is_file():
return
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
value = value[1:-1]
if not key or not value:
continue
current = os.environ.get(key, "")
if not current:
os.environ[key] = value
def oracle_dsn() -> tuple[str, str, str]:
user = os.getenv("RPA_ORACLE_USER", "")
password = os.getenv("RPA_ORACLE_PASSWORD", "")
host = os.getenv("RPA_ORACLE_HOST", "")
if not user or not password or not host:
raise RepoUnavailable("未配置 RPA_ORACLE_HOST / RPA_ORACLE_USER / RPA_ORACLE_PASSWORD")
port = os.getenv("RPA_ORACLE_PORT", "1521")
service = os.getenv("RPA_ORACLE_SERVICE", "rpa")
return user, password, f"{host}:{port}/{service}"
def connect_from_env():
user, password, dsn = oracle_dsn()
_ensure_client()
import oracledb
return oracledb.connect(user=user, password=password, dsn=dsn)
def _ensure_client() -> None:
global _client_ready
if _client_ready:
return
import oracledb
if os.getenv("RPA_ORACLE_THICK", "1") == "1":
lib_dir = os.getenv("RPA_ORACLE_CLIENT_LIB", "")
kwargs = {"lib_dir": lib_dir} if lib_dir else {}
oracledb.init_oracle_client(**kwargs)
_client_ready = True

View File

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

View File

@ -1,4 +1,5 @@
"""合同班合单:可录判定、按供应商分组、草稿映射。""" # 合同班合单:可录判定、按供应商+货主+三个日期分组、草稿映射。
from __future__ import annotations from __future__ import annotations
from collections import OrderedDict from collections import OrderedDict
@ -7,25 +8,48 @@ from decimal import Decimal, ROUND_HALF_UP
from contract_draft import ContractDraft from contract_draft import ContractDraft
from contract_flow_steps import ContractGoodsLine from contract_flow_steps import ContractGoodsLine
from persist import LOOKUP_ONLY_RESULT
from repo import PurchaseContractRow 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: def is_claimable(row: PurchaseContractRow) -> bool:
if int(row.process_status or 0) != 0: if int(row.process_status or 0) != 0:
return False return False
if row.contract_entry_status is not None and int(row.contract_entry_status) == 0: return not is_protocol_rerun(row)
if (row.contract_no or "").strip():
return False
return True
# 合单键:供应商、货主、签订日、开始日、结束日。任一不同则各自成组。
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]]: def group_by_supplier(rows: list[PurchaseContractRow]) -> list[list[PurchaseContractRow]]:
groups: OrderedDict[str, list[PurchaseContractRow]] = OrderedDict() groups: OrderedDict[tuple, list[PurchaseContractRow]] = OrderedDict()
for row in sorted(rows, key=lambda item: item.fid): for row in sorted(rows, key=lambda item: item.fid):
groups.setdefault(row.supplier_id, []).append(row) groups.setdefault(_group_key(row), []).append(row)
return list(groups.values()) return list(groups.values())
# 检查合单是否缺供应商或货品 MDM 码。缺则返回原因,齐则返回 None。
def missing_mdm_reason(rows: list[PurchaseContractRow]) -> str | None: def missing_mdm_reason(rows: list[PurchaseContractRow]) -> str | None:
if not rows: if not rows:
return "缺少供应商MDM码" return "缺少供应商MDM码"
@ -37,11 +61,13 @@ def missing_mdm_reason(rows: list[PurchaseContractRow]) -> str | None:
return None return None
# 把金额收成四位小数字符串,四舍五入。
def money4(value) -> str: def money4(value) -> str:
amount = Decimal(str(value)).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP) amount = Decimal(str(value)).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
return f"{amount:.4f}" return f"{amount:.4f}"
# 把日期收成 YYYY-MM-DD。空值返回空字符串。
def iso_date(value) -> str: def iso_date(value) -> str:
if value is None: if value is None:
return "" return ""
@ -53,6 +79,10 @@ def iso_date(value) -> str:
def draft_from_group(rows: list[PurchaseContractRow]) -> ContractDraft: def draft_from_group(rows: list[PurchaseContractRow]) -> ContractDraft:
"""把同一供应商一组表 3 行收成页面草稿。头字段取第一行。
结果里已有「只查号」时,下一班只查已有合同,不再起草。
"""
first = rows[0] first = rows[0]
sign = iso_date(first.contract_sign_date) sign = iso_date(first.contract_sign_date)
total = sum( total = sum(
@ -89,4 +119,5 @@ def draft_from_group(rows: list[PurchaseContractRow]) -> ContractDraft:
payment_method="账期支付", payment_method="账期支付",
payment_days=str(first.payment_term), payment_days=str(first.payment_term),
goods_lines=goods, goods_lines=goods,
lookup_only=any(LOOKUP_ONLY_RESULT in (row.process_result or "") for row in rows),
) )

View File

@ -1,6 +1,12 @@
"""合同班次:领取表 3 → 按供应商合单 → runner 录合同 → 写表 4。""" # 合同班次:领取表 3 → 按供应商、货主和三个日期合单 → runner 录合同 → 写表 4。
#
# 崩在 process_status=3:超过租约可再领,未过期需改回 0。
# 未捕获异常原因列只写 PAGE_FAILED。原文进审计表,会话结束后另写。
from __future__ import annotations from __future__ import annotations
import logging
from contextlib import nullcontext
from dataclasses import dataclass from dataclasses import dataclass
from typing import Callable from typing import Callable
@ -8,10 +14,14 @@ from contract_draft import ContractDraft
from errors import DiligenceError, NodeError from errors import DiligenceError, NodeError
from group import draft_from_group, group_by_supplier, missing_mdm_reason 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 persist import KIND_DILIGENCE, KIND_FAIL, KIND_OK, ContractOutcome, build_save
from rpa_oracle import ShiftError
PAGE_FAILED = "PAGE_FAILED"
Runner = Callable[[ContractDraft], ContractOutcome] Runner = Callable[[ContractDraft], ContractOutcome]
logger = logging.getLogger("rpa_contract")
# 一个合同班次的领取、合单和成败计数。
@dataclass @dataclass
class JobSummary: class JobSummary:
claimed: int = 0 claimed: int = 0
@ -20,33 +30,62 @@ class JobSummary:
failed: 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: 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) claimed = repo.claim_pending(limit=limit)
summary = JobSummary(claimed=len(claimed), groups=0) summary = JobSummary(claimed=len(claimed), groups=0)
for rows in group_by_supplier(claimed): for rows in group_by_supplier(claimed):
summary.groups += 1 summary.groups += 1
outcome = _run_group(rows, runner) outcome = _run_group(rows, runner)
with _session(repo):
repo.save_group(build_save(rows, outcome)) 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(): if outcome.kind == KIND_OK and (outcome.contract_no or "").strip():
summary.ok += 1 summary.ok += 1
else: else:
summary.failed += 1 summary.failed += 1
repo.save_shift_errors(audits)
return summary return summary
# 录一组同一供应商的行。缺 MDM 不调页面;未捕获异常写 PAGE_FAILED。
def _run_group(rows, runner: Runner) -> ContractOutcome: def _run_group(rows, runner: Runner) -> ContractOutcome:
reason = missing_mdm_reason(rows) reason = missing_mdm_reason(rows)
if reason: if reason:
return ContractOutcome(KIND_FAIL, None, reason) return ContractOutcome(KIND_FAIL, None, reason)
try: try:
outcome = runner(draft_from_group(rows)) outcome = runner(draft_from_group(rows))
except Exception as exc: except DiligenceError:
return ContractOutcome(KIND_DILIGENCE, None, "客户未尽调")
except NodeError as exc:
return ContractOutcome(KIND_FAIL, None, str(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(): if outcome.kind == KIND_OK and not (outcome.contract_no or "").strip():
return ContractOutcome(KIND_FAIL, None, "submit 模式未读取到真实合同编号") return ContractOutcome(KIND_FAIL, None, "submit 模式未读取到真实合同编号")
return outcome return outcome
# 把页面 run() 的合同号或异常收成 ContractOutcome。
#
# 尽调失败写客户未尽调;NodeError 写业务文案;其它异常写 PAGE_FAILED,原文放 error_text。
def outcome_from_run(run: Callable[[], str]) -> ContractOutcome: def outcome_from_run(run: Callable[[], str]) -> ContractOutcome:
try: try:
number = (run() or "").strip() number = (run() or "").strip()
@ -55,7 +94,14 @@ def outcome_from_run(run: Callable[[], str]) -> ContractOutcome:
except NodeError as exc: except NodeError as exc:
return ContractOutcome(KIND_FAIL, None, str(exc)) return ContractOutcome(KIND_FAIL, None, str(exc))
except Exception as exc: except Exception as exc:
return ContractOutcome(KIND_FAIL, None, str(exc)) logger.exception("合同页面未捕获异常")
return ContractOutcome(KIND_FAIL, None, PAGE_FAILED, _text(exc))
if not number: if not number:
return ContractOutcome(KIND_FAIL, None, "submit 模式未读取到真实合同编号") return ContractOutcome(KIND_FAIL, None, "submit 模式未读取到真实合同编号")
return ContractOutcome(KIND_OK, number, None) return ContractOutcome(KIND_OK, number, None)
# 取出异常原文。空串不当审计正文。
def _text(exc: BaseException) -> str | None:
text = str(exc).strip()
return text or None

View File

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

View File

@ -1,4 +1,5 @@
"""合同 PDF 合并、提交占位正文。""" # 合同 PDF 合并、提交占位正文。
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
@ -6,8 +7,8 @@ from pathlib import Path
_PLACEHOLDER_PDF = Path(__file__).resolve().parent / "examples" / "placeholder_contract.pdf" _PLACEHOLDER_PDF = Path(__file__).resolve().parent / "examples" / "placeholder_contract.pdf"
# 提交必须挂合同正文;没有平台文件时用这一份占位 PDF。
def ensure_placeholder_contract_pdf() -> str: def ensure_placeholder_contract_pdf() -> str:
"""提交必须挂合同正文;没有平台文件时用这一份占位 PDF。"""
path = _PLACEHOLDER_PDF path = _PLACEHOLDER_PDF
if path.is_file() and path.stat().st_size > 64: if path.is_file() and path.stat().st_size > 64:
return str(path) return str(path)
@ -21,11 +22,8 @@ def ensure_placeholder_contract_pdf() -> str:
return str(path) return str(path)
# 合成一份要上传的合同正文。同供应商同日只传这一份。单文件直接返回路径。
def ensure_merged_pdf(source_paths: list[str], output_path: str) -> str: def ensure_merged_pdf(source_paths: list[str], output_path: str) -> str:
"""Return one PDF path, merging multiple source PDFs when needed.
同供应商同日只上传一份 PDF。单文件直接返回路径。
"""
if not source_paths: if not source_paths:
raise ValueError("YYC contract creation requires at least one platform PDF") raise ValueError("YYC contract creation requires at least one platform PDF")
if len(source_paths) == 1: if len(source_paths) == 1:

View File

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

View File

@ -21,7 +21,7 @@ dev = [
package = false package = false
[tool.pytest.ini_options] [tool.pytest.ini_options]
pythonpath = ["."] pythonpath = [".", ".."]
testpaths = ["tests"] testpaths = ["tests"]
addopts = "-q" addopts = "-q"

View File

@ -1,15 +1,98 @@
"""表 3 领取、表 4 写入。""" # 表 3 领取、表 4 写入。
#
# 从表 3 领取 process_status=0 的行改成 3,合单成功后写表 4、表 3 改成 1。
# 进程中途退出时行停在 3,超过租约(process_time 30 分钟)才能再领。
# 跳过协议重录行。不推接口 4。
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from contextlib import contextmanager
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date from datetime import date, datetime, timedelta
from decimal import Decimal from decimal import Decimal
from typing import Any from typing import Any
from db import RepoUnavailable, connect_from_env, oracle_dsn 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 @dataclass
class PurchaseContractRow: class PurchaseContractRow:
fid: int fid: int
@ -38,107 +121,36 @@ class PurchaseContractRow:
goods_mdm_code: str | None goods_mdm_code: str | None
supplier_mdm_code: str | None supplier_mdm_code: str | None
process_result: str = "" process_result: str = ""
process_time: datetime | None = None
class MemoryContractRepo: # 判断是否只录协议行:contract_entry_status=0 且已有合同号。
def __init__(self, rows: list[PurchaseContractRow]): def _is_protocol_rerun(row: PurchaseContractRow) -> bool:
self.table3 = {row.fid: row for row in rows} if row.contract_entry_status is not None and int(row.contract_entry_status) == 0:
self.table4: list[dict] = [] return bool((row.contract_no or "").strip())
self._next_fid = 1 return False
def claim_pending(self, limit: int | None = None) -> list[PurchaseContractRow]:
from group import is_claimable
pending = sorted(
(row for row in self.table3.values() if is_claimable(row)),
key=lambda row: row.fid,
)
if limit is not None:
pending = pending[:limit]
for row in pending:
row.process_status = 3
return pending
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
_SELECT_PENDING = """ # 判断内存行是否可领:0,或 3 且 process_time 已过租约。协议重录行永不领。
SELECT def _is_claimable(row: PurchaseContractRow, now: datetime) -> bool:
fid, oms_id, supplier_id, supplier_name, if _is_protocol_rerun(row):
contract_sign_date, contract_start_date, contract_end_date, return False
payment_term, goods_id, goods_name, reg_no, spec, unit, if int(row.process_status or 0) == 0:
qty, unit_price, purchaser_id, purchaser_name, planner_id, planner_name, return True
contract_entry_status, contract_no, process_status, org_code, if int(row.process_status or 0) == 3 and row.process_time is not None:
goods_mdm_code, supplier_mdm_code, return now - row.process_time >= timedelta(minutes=LEASE_MINUTES)
NVL(process_result, '') AS process_result return False
FROM rpa_purchase_contract_data
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
"""
_SELECT_PENDING_LIMIT = f"""
SELECT * FROM (
{_SELECT_PENDING}
) WHERE ROWNUM <= :limit
"""
_CLAIM_SQL = """
UPDATE rpa_purchase_contract_data
SET process_status = 3,
process_time = SYSTIMESTAMP,
update_time = SYSTIMESTAMP
WHERE fid = :fid AND process_status = 0
"""
_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, 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, 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",
"org_code",
)
# 回滚当前事务。回滚本身失败则忽略。
def _rollback(conn) -> None:
try:
conn.rollback()
except Exception:
pass
# 把查询结果行收成 PurchaseContractRow。
def _row_from_record(cols: list[str], rec) -> PurchaseContractRow: def _row_from_record(cols: list[str], rec) -> PurchaseContractRow:
data = dict(zip(cols, rec)) data = dict(zip(cols, rec))
return PurchaseContractRow( return PurchaseContractRow(
@ -171,68 +183,168 @@ def _row_from_record(cols: list[str], rec) -> PurchaseContractRow:
) )
class OracleContractRepo: # 合同班次的内存仓库,测合单和班次编排,不测 SQL。
def __init__(self, connect: Callable): class MemoryContractRepo:
self._connect = connect # 用给定的表 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 @classmethod
def from_env(cls) -> "OracleContractRepo": def from_env(cls) -> "OracleContractRepo":
oracle_dsn() return cls(pool=job_pool())
return cls(connect=connect_from_env)
# 未捕获异常原文进共用审计表。独立事务,失败只记日志。
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]: def claim_pending(self, limit: int | None = None) -> list[PurchaseContractRow]:
conn = self._connect() """领取待录合同任务,把表 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: try:
with conn.cursor() as cur: with conn.cursor() as cur:
if limit is None: if limit is None:
cur.execute(_SELECT_PENDING) cur.execute(_SELECT_PENDING, binds)
else: else:
cur.execute(_SELECT_PENDING_LIMIT, {"limit": int(limit)}) cur.execute(
_SELECT_PENDING_LIMIT,
{**binds, "limit": int(limit)},
)
cols = [item[0].lower() for item in cur.description] cols = [item[0].lower() for item in cur.description]
fetched = [_row_from_record(cols, rec) for rec in cur.fetchall()] fetched = [_row_from_record(cols, rec) for rec in cur.fetchall()]
claimed: list[PurchaseContractRow] = [] if fetched:
for row in fetched: update_by_fid_chunks(
cur.execute(_CLAIM_SQL, {"fid": row.fid}) cur,
if cur.rowcount == 1: _CLAIM_SQL,
row.process_status = 3 [row.fid for row in fetched],
claimed.append(row)
conn.commit()
return claimed
except Exception as exc:
try:
conn.rollback()
except Exception:
pass
raise RepoUnavailable(str(exc)) from exc
finally:
conn.close()
def save_group(self, payload: Any) -> None:
conn = self._connect()
try:
with conn.cursor() as cur:
for row in payload.table4_rows:
cur.execute(
_INSERT_TABLE4_SQL,
{key: row.get(key) for key in _TABLE4_BIND_KEYS},
) )
for fid in payload.fids: for row in fetched:
cur.execute( row.process_status = 3
_COMPLETE_SQL, 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_status": payload.process_status,
"process_result": payload.process_result, "process_result": payload.process_result,
"fid": fid, "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() conn.commit()
except Exception as exc: except Exception as exc:
try: _rollback(conn)
conn.rollback() if isinstance(exc, RepoUnavailable):
except Exception: raise
pass raise RepoUnavailable("数据库不可用") from exc
raise RepoUnavailable(str(exc)) from exc except RepoUnavailable:
finally: raise
conn.close() except Exception as exc:
raise RepoUnavailable("数据库不可用") from exc

View File

@ -1,17 +1,75 @@
"""合同班次入口:领表 3、调页面流、写表 4。不推接口 4。""" # 合同班次入口:领表 3、调页面流、写表 4。不推接口 4。
#
# 崩在 process_status=3。账密读 CONTRACT_USERNAME / CONTRACT_PASSWORD,缺则失败。
from __future__ import annotations from __future__ import annotations
import argparse
from apa_browser_adapter import ApaBrowserAdapter from apa_browser_adapter import ApaBrowserAdapter
from contract_create_node import CONTRACT_URL, ContractCreateInput, build_flow_context from contract_create_node import CONTRACT_URL, ContractCreateInput, build_flow_context
from contract_draft import ContractDraft from contract_draft import ContractDraft
from contract_flow_steps import run_contract_create_flow from contract_flow_steps import run_contract_create_flow
from db import load_runtime_env from pathlib import Path
from rpa_oracle import load_job_env
from job import outcome_from_run, run_batch from job import outcome_from_run, run_batch
from persist import ContractOutcome from persist import KIND_DILIGENCE, KIND_HOLD, KIND_OK, LOOKUP_ONLY_RESULT, ContractOutcome
from repo import OracleContractRepo from repo import OracleContractRepo
DEFAULT_CLAIM_LIMIT = 10
def production_runner(browser: ApaBrowserAdapter, draft: ContractDraft) -> ContractOutcome:
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( spec = ContractCreateInput(
job_id="contract-batch", job_id="contract-batch",
supplier_name=draft.supplier_name, supplier_name=draft.supplier_name,
@ -19,19 +77,93 @@ def production_runner(browser: ApaBrowserAdapter, draft: ContractDraft) -> Contr
source_type=draft.source_type or "", source_type=draft.source_type or "",
submit_mode="submit", submit_mode="submit",
) )
ctx = build_flow_context(spec, draft) 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: def run() -> str:
"""在已打开的浏览器里跑起草并返回合同号。"""
return run_contract_create_flow(browser, ctx) return run_contract_create_flow(browser, ctx)
return outcome_from_run(run) 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 main() -> int: def production_runner(
load_runtime_env() 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() repo = OracleContractRepo.from_env()
if runner is None:
browser = ApaBrowserAdapter(url=CONTRACT_URL, flow="CONTRACT_CREATE") browser = ApaBrowserAdapter(url=CONTRACT_URL, flow="CONTRACT_CREATE")
summary = run_batch(repo, runner=lambda draft: production_runner(browser, draft)) runner = lambda draft: production_runner(browser, draft)
summary = run_batch(repo, runner=runner, limit=args.limit)
print( print(
f"claimed={summary.claimed} groups={summary.groups} " f"claimed={summary.claimed} groups={summary.groups} "
f"ok={summary.ok} failed={summary.failed}" f"ok={summary.ok} failed={summary.failed}"

View File

@ -1,13 +1,31 @@
# 合同浏览器适配:profile 在仓库外、调试口稳定、端口/DevTools 用轮询。
from __future__ import annotations
import socket
import threading
import time
from pathlib import Path from pathlib import Path
from apa_browser_adapter import ( from apa_browser_adapter import (
ApaBrowserAdapter,
REPO_ROOT, REPO_ROOT,
chrome_debug_port, chrome_debug_port,
resolve_chrome_profile_base, resolve_chrome_profile_base,
resolve_chrome_profile_dir, 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(): def test_default_chrome_profile_is_outside_repo():
profile = resolve_chrome_profile_base(requested="") profile = resolve_chrome_profile_base(requested="")
assert profile == (REPO_ROOT.parent / "rpa-chrome-profile").resolve() assert profile == (REPO_ROOT.parent / "rpa-chrome-profile").resolve()
@ -15,6 +33,7 @@ def test_default_chrome_profile_is_outside_repo():
assert profile != REPO_ROOT.resolve() assert profile != REPO_ROOT.resolve()
# RPA_CHROME_PROFILE 指到仓库内时改用仓库外默认目录。
def test_env_path_inside_repo_is_rejected(tmp_path, monkeypatch): def test_env_path_inside_repo_is_rejected(tmp_path, monkeypatch):
inside = REPO_ROOT / "rpa_contract" / "screenshots" / "chrome-profile" inside = REPO_ROOT / "rpa_contract" / "screenshots" / "chrome-profile"
monkeypatch.delenv("RPA_CHROME_PROFILE", raising=False) monkeypatch.delenv("RPA_CHROME_PROFILE", raising=False)
@ -23,6 +42,7 @@ def test_env_path_inside_repo_is_rejected(tmp_path, monkeypatch):
assert not _is_relative_to(profile, REPO_ROOT) assert not _is_relative_to(profile, REPO_ROOT)
# RPA_CHROME_PROFILE 在仓库外时按原路径使用。
def test_env_path_outside_repo_is_kept(tmp_path): def test_env_path_outside_repo_is_kept(tmp_path):
outside = tmp_path / "custom-chrome" outside = tmp_path / "custom-chrome"
outside.mkdir() outside.mkdir()
@ -30,6 +50,7 @@ def test_env_path_outside_repo_is_kept(tmp_path):
assert profile == outside.resolve() assert profile == outside.resolve()
# 同一 flow 的 profile 目录稳定,且不落在仓库内。
def test_chrome_profile_dir_is_stable_per_flow(): def test_chrome_profile_dir_is_stable_per_flow():
first = resolve_chrome_profile_dir("CONTRACT_CREATE") first = resolve_chrome_profile_dir("CONTRACT_CREATE")
second = resolve_chrome_profile_dir("CONTRACT_CREATE") second = resolve_chrome_profile_dir("CONTRACT_CREATE")
@ -38,12 +59,60 @@ def test_chrome_profile_dir_is_stable_per_flow():
assert not _is_relative_to(first, REPO_ROOT) assert not _is_relative_to(first, REPO_ROOT)
# CONTRACT_CREATE 默认调试口 9333,同一 flow 两次相同。
def test_chrome_debug_port_is_stable_per_flow(monkeypatch): def test_chrome_debug_port_is_stable_per_flow(monkeypatch):
monkeypatch.delenv("RPA_CHROME_DEBUG_PORT", raising=False) monkeypatch.delenv("RPA_CHROME_DEBUG_PORT", raising=False)
assert chrome_debug_port("CONTRACT_CREATE") == 9333 assert chrome_debug_port("CONTRACT_CREATE") == 9333
assert chrome_debug_port("CONTRACT_CREATE") == chrome_debug_port("CONTRACT_CREATE") 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: def _is_relative_to(path: Path, root: Path) -> bool:
try: try:
path.resolve().relative_to(root.resolve()) path.resolve().relative_to(root.resolve())

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

@ -86,6 +86,29 @@ def test_group_by_supplier_keeps_fid_order_and_first_header():
assert draft.belong_year == "2026" assert draft.belong_year == "2026"
# 同供应商但货主或三个日期任一不同,不得收成同一张草稿。
def test_same_supplier_splits_when_org_or_dates_differ():
rows = [
_row(fid=1, supplier_id="A"),
_row(fid=2, supplier_id="A", org_code="902", oms_id=2),
_row(fid=3, supplier_id="A", contract_sign_date=date(2026, 11, 1), oms_id=3),
_row(fid=4, supplier_id="A", contract_start_date=date(2026, 11, 2), oms_id=4),
_row(fid=5, supplier_id="A", contract_end_date=date(2028, 1, 1), oms_id=5),
_row(fid=6, supplier_id="A", oms_id=6, goods_id="G0009"),
]
groups = group_by_supplier(rows)
drafts = {
tuple(row.fid for row in group): draft_from_group(group) for group in groups
}
assert len(groups) == 5
assert (1, 6) in drafts
assert drafts[(1, 6)].amount == "2000.0000"
assert len(drafts[(1, 6)].goods_lines) == 2
for fid in (2, 3, 4, 5):
assert (fid,) in drafts
assert len(drafts[(fid,)].goods_lines) == 1
def test_missing_mdm_reason(): def test_missing_mdm_reason():
rows = [_row(supplier_mdm_code="")] rows = [_row(supplier_mdm_code="")]
assert missing_mdm_reason(rows) == "缺少供应商MDM码" assert missing_mdm_reason(rows) == "缺少供应商MDM码"

View File

@ -1,12 +1,29 @@
from datetime import date # 合同班次:领取戳、合单、尽调失败、系统故障短码。
from contextlib import contextmanager
from datetime import date, datetime, timedelta
from decimal import Decimal from decimal import Decimal
from errors import DiligenceError, NodeError from errors import DiligenceError, NodeError
from job import outcome_from_run, run_batch from job import PAGE_FAILED, outcome_from_run, run_batch
from persist import KIND_DILIGENCE, KIND_FAIL, KIND_OK, ContractOutcome, build_save from persist import (
KIND_DILIGENCE,
KIND_FAIL,
KIND_OK,
LOOKUP_ONLY_RESULT,
ContractOutcome,
build_save,
)
from repo import MemoryContractRepo, PurchaseContractRow from repo import MemoryContractRepo, PurchaseContractRow
from run_batch import (
DEFAULT_CLAIM_LIMIT,
contract_lookup_title,
main as batch_main,
production_runner,
)
# 构造一条待录合同的表 3 行。
def _row(**overrides) -> PurchaseContractRow: def _row(**overrides) -> PurchaseContractRow:
data = dict( data = dict(
fid=1, fid=1,
@ -39,6 +56,7 @@ def _row(**overrides) -> PurchaseContractRow:
return PurchaseContractRow(**data) return PurchaseContractRow(**data)
# process_status=0 领走后变成 3,协议重录行和已完成行不领。
def test_claim_pending_sets_processing_and_skips_protocol_rerun(): def test_claim_pending_sets_processing_and_skips_protocol_rerun():
repo = MemoryContractRepo( repo = MemoryContractRepo(
[ [
@ -53,6 +71,46 @@ def test_claim_pending_sets_processing_and_skips_protocol_rerun():
assert repo.table3[3].process_status == 0 assert repo.table3[3].process_status == 0
# process_status=3 且 process_time 超过租约的行可以再领。
def test_claim_reclaims_expired_status_3():
now = datetime(2026, 10, 22, 12, 0, 0)
repo = MemoryContractRepo(
[_row(process_status=3, process_time=now - timedelta(minutes=31))]
)
claimed = repo.claim_pending(now=now)
assert [row.fid for row in claimed] == [1]
assert repo.table3[1].process_status == 3
# process_status=3 且未过租约的行不领。
def test_claim_skips_fresh_status_3():
now = datetime(2026, 10, 22, 12, 0, 0)
repo = MemoryContractRepo(
[_row(process_status=3, process_time=now - timedelta(minutes=1))]
)
claimed = repo.claim_pending(now=now)
assert claimed == []
assert repo.table3[1].process_status == 3
# 协议重录行即使停在过期 3 也不领。
def test_claim_skips_protocol_rerun_even_if_expired():
now = datetime(2026, 10, 22, 12, 0, 0)
repo = MemoryContractRepo(
[
_row(
process_status=3,
process_time=now - timedelta(minutes=31),
contract_entry_status=0,
contract_no="HT-OLD",
)
]
)
assert repo.claim_pending(now=now) == []
assert repo.table3[1].process_status == 3
# 同一 oms_id 多行只插一条表 4,表 3 全部改成成功。
def test_save_group_inserts_one_table4_row_per_oms_id(): def test_save_group_inserts_one_table4_row_per_oms_id():
repo = MemoryContractRepo( repo = MemoryContractRepo(
[ [
@ -69,11 +127,15 @@ def test_save_group_inserts_one_table4_row_per_oms_id():
assert {row["contract_no"] for row in repo.table4} == {"HT20261005001"} assert {row["contract_no"] for row in repo.table4} == {"HT20261005001"}
assert {row["action_code"] for row in repo.table4} == {1} assert {row["action_code"] for row in repo.table4} == {1}
assert {row["contract_status"] for row in repo.table4} == {1} assert {row["contract_status"] for row in repo.table4} == {1}
assert {row["process_status"] for row in repo.table4} == {0}
assert {row["create_user_name"] for row in repo.table4} == {"系统管理员"}
assert {row["update_user_name"] for row in repo.table4} == {"系统管理员"}
assert {row["push_status"] for row in repo.table4} == {0} assert {row["push_status"] for row in repo.table4} == {0}
assert repo.table3[1].process_status == 1 assert repo.table3[1].process_status == 1
assert repo.table3[3].process_status == 1 assert repo.table3[3].process_status == 1
# 同一供应商合单一次录入,协议重录行不进本班。
def test_job_merges_same_supplier_and_writes_table4(): def test_job_merges_same_supplier_and_writes_table4():
repo = MemoryContractRepo( repo = MemoryContractRepo(
[ [
@ -84,6 +146,7 @@ def test_job_merges_same_supplier_and_writes_table4():
) )
calls: list = [] calls: list = []
# 记下草稿并返回成功合同号。
def runner(draft): def runner(draft):
calls.append(draft) calls.append(draft)
return ContractOutcome(KIND_OK, "HT20261005001", None) return ContractOutcome(KIND_OK, "HT20261005001", None)
@ -103,15 +166,19 @@ def test_job_merges_same_supplier_and_writes_table4():
assert {row["contract_no"] for row in repo.table4} == {"HT20261005001"} assert {row["contract_no"] for row in repo.table4} == {"HT20261005001"}
assert {row["action_code"] for row in repo.table4} == {1} assert {row["action_code"] for row in repo.table4} == {1}
assert {row["contract_status"] for row in repo.table4} == {1} assert {row["contract_status"] for row in repo.table4} == {1}
assert {row["process_status"] for row in repo.table4} == {0}
assert {row["create_user_name"] for row in repo.table4} == {"系统管理员"}
assert {row["push_status"] for row in repo.table4} == {0} assert {row["push_status"] for row in repo.table4} == {0}
assert repo.table3[1].process_status == 1 assert repo.table3[1].process_status == 1
assert repo.table3[2].process_status == 1 assert repo.table3[2].process_status == 1
assert repo.table3[3].process_status == 0 assert repo.table3[3].process_status == 0
# 尽调失败写客户未尽调,表 3 改成 2,表 4 起草失败。
def test_job_marks_diligence_fail(): def test_job_marks_diligence_fail():
repo = MemoryContractRepo([_row()]) repo = MemoryContractRepo([_row()])
# 返回未尽调结果。
def runner(draft): def runner(draft):
return ContractOutcome(KIND_DILIGENCE, None, "客户未尽调") return ContractOutcome(KIND_DILIGENCE, None, "客户未尽调")
@ -119,14 +186,18 @@ def test_job_marks_diligence_fail():
assert repo.table3[1].process_status == 2 assert repo.table3[1].process_status == 2
assert "客户未尽调" in repo.table3[1].process_result assert "客户未尽调" in repo.table3[1].process_result
assert repo.table4[0]["contract_status"] == 3 assert repo.table4[0]["contract_status"] == 3
assert repo.table4[0]["process_status"] == 2
assert repo.table4[0]["update_user_name"] == "系统管理员"
assert repo.table4[0]["contract_no"] is None assert repo.table4[0]["contract_no"] is None
assert repo.table4[0]["contract_fail_reason"] == "客户未尽调" assert repo.table4[0]["contract_fail_reason"] == "客户未尽调"
# 缺供应商 MDM 码时不调页面,直接业务失败。
def test_job_skips_runner_when_mdm_missing(): def test_job_skips_runner_when_mdm_missing():
repo = MemoryContractRepo([_row(supplier_mdm_code="")]) repo = MemoryContractRepo([_row(supplier_mdm_code="")])
called = {"n": 0} called = {"n": 0}
# 本用例不应被调用。
def runner(draft): def runner(draft):
called["n"] += 1 called["n"] += 1
return ContractOutcome(KIND_OK, "HT-X", None) return ContractOutcome(KIND_OK, "HT-X", None)
@ -137,7 +208,9 @@ def test_job_skips_runner_when_mdm_missing():
assert "缺少供应商MDM码" in repo.table3[1].process_result assert "缺少供应商MDM码" in repo.table3[1].process_result
# 页面尽调失败映射成 KIND_DILIGENCE。
def test_outcome_from_run_maps_diligence(): def test_outcome_from_run_maps_diligence():
# 抛出未尽调。
def run(): def run():
raise DiligenceError("客户未尽调") raise DiligenceError("客户未尽调")
@ -147,18 +220,22 @@ def test_outcome_from_run_maps_diligence():
assert out.contract_no is None assert out.contract_no is None
# 提交成功但没读到合同号时记失败。
def test_outcome_from_run_maps_empty_number_to_fail(): def test_outcome_from_run_maps_empty_number_to_fail():
out = outcome_from_run(lambda: "") out = outcome_from_run(lambda: "")
assert out.kind == KIND_FAIL assert out.kind == KIND_FAIL
assert "未读取到真实合同编号" in (out.reason or "") assert "未读取到真实合同编号" in (out.reason or "")
# 读到合同号时记成功。
def test_outcome_from_run_ok(): def test_outcome_from_run_ok():
out = outcome_from_run(lambda: "HT9") out = outcome_from_run(lambda: "HT9")
assert out == ContractOutcome(KIND_OK, "HT9", None) assert out == ContractOutcome(KIND_OK, "HT9", None)
# 页面 NodeError 仍写业务文案。
def test_outcome_from_run_maps_node_error(): def test_outcome_from_run_maps_node_error():
# 抛出客商加载失败。
def run(): def run():
raise NodeError("客商加载失败") raise NodeError("客商加载失败")
@ -166,3 +243,218 @@ def test_outcome_from_run_maps_node_error():
assert out.kind == KIND_FAIL assert out.kind == KIND_FAIL
assert "客商加载失败" in (out.reason or "") assert "客商加载失败" in (out.reason or "")
# 未捕获异常只写 PAGE_FAILED,不含原文。
def test_outcome_from_run_maps_unexpected_to_short_code():
# 抛出带 ORA 原文的异常。
def run():
raise RuntimeError("ORA-00001 unique constraint (RPA.UK)")
out = outcome_from_run(run)
assert out.kind == KIND_FAIL
assert out.reason == PAGE_FAILED
assert "ORA-" not in (out.reason or "")
assert out.error_text == "ORA-00001 unique constraint (RPA.UK)"
# runner 抛未捕获异常时表 3/表 4 原因列只写 PAGE_FAILED。
def test_job_maps_unexpected_error_to_short_code():
repo = MemoryContractRepo([_row()])
# 模拟页面未捕获异常。
def runner(draft):
raise RuntimeError("traceback here")
run_batch(repo, runner=runner)
assert repo.table3[1].process_status == 2
assert repo.table3[1].process_result == PAGE_FAILED
assert repo.table4[0]["contract_fail_reason"] == PAGE_FAILED
assert len(repo.shift_errors) == 1
assert repo.shift_errors[0].shift_name == "合同"
assert repo.shift_errors[0].biz_key == "SUP001"
assert repo.shift_errors[0].error_code == PAGE_FAILED
assert repo.shift_errors[0].error_text == "traceback here"
def test_default_limit_caps_claim_at_10():
"""不传 --limit 时合同班最多领 10 行。"""
repo = MemoryContractRepo(
[
_row(fid=i, oms_id=2026100500 + i, supplier_id=f"SUP{i}")
for i in range(1, 16)
]
)
seen: dict[str, int | None] = {}
original = repo.claim_pending
def claim_pending(limit=None, **kwargs):
"""记下入口传给领取的上限和实际领走的行数。"""
seen["limit"] = limit
claimed = original(limit=limit, **kwargs)
seen["count"] = len(claimed)
return claimed
repo.claim_pending = claim_pending
def runner(_draft):
"""不打开页面,直接记失败。"""
return ContractOutcome(KIND_FAIL, None, "skip")
batch_main([], repo=repo, runner=runner)
assert seen["limit"] == DEFAULT_CLAIM_LIMIT
assert seen["count"] == 10
assert seen["count"] <= DEFAULT_CLAIM_LIMIT
def test_unique_existing_contract_does_not_draft_again():
"""对方已有唯一合同号、本地没写上时,重跑不起草第二份。"""
repo = MemoryContractRepo([_row()])
drafted: list[str] = []
def finder(_browser, draft):
"""标题不含货主和起止日。对方只有一份合同时返回该号。"""
title = contract_lookup_title(draft)
assert title == "示例供应商采购协议20261001"
assert "901" not in title
assert "20261002" not in title
assert "20270930" not in title
return ["HT-EXISTING"]
def creator(_browser, _draft):
"""本用例不应起草。"""
drafted.append("created")
return "HT-NEW"
def runner(draft):
"""用注入的查重,不打开浏览器。"""
return production_runner(None, draft, find_contracts=finder, create=creator)
summary = run_batch(repo, runner=runner)
assert drafted == []
assert summary.ok == 1
assert repo.table4[0]["contract_no"] == "HT-EXISTING"
assert repo.table3[1].process_status == 1
def test_many_contracts_stay_in_process():
"""命中多条时停在处理中,不新建,不取最新一条。"""
repo = MemoryContractRepo([_row()])
drafted: list[str] = []
def finder(_browser, _draft):
"""两行都在,调用方不能挑最新的那条。"""
return ["HT-OLD", "HT-NEW"]
def creator(_browser, _draft):
"""命中多条时不应起草。"""
drafted.append("created")
return "HT-PICKED"
def runner(draft):
"""用注入的查重,不打开浏览器。"""
return production_runner(None, draft, find_contracts=finder, create=creator)
run_batch(repo, runner=runner)
assert drafted == []
assert repo.table4 == []
assert repo.table3[1].process_status == 3
assert repo.table3[1].process_result == "我的合同命中多条"
assert repo.claim_pending() == []
def test_missing_number_stays_in_process_and_next_shift_only_looks_up():
"""提交后读不到号停在处理中。下一班只查号,不再起草。"""
repo = MemoryContractRepo([_row()])
drafted: list[int] = []
found = {1: []}
def finder(_browser, draft):
"""第二班必须带着只查号标记。"""
if drafted:
assert draft.lookup_only is True
return found[1]
def creator(_browser, _draft):
"""只有第一班会进来,而且读不到号。"""
drafted.append(1)
return ""
def runner(draft):
"""用注入的查重和起草,不打开浏览器。"""
return production_runner(None, draft, find_contracts=finder, create=creator)
run_batch(repo, runner=runner)
assert drafted == [1]
assert repo.table4 == []
assert repo.table3[1].process_status == 3
assert repo.table3[1].process_result == LOOKUP_ONLY_RESULT
repo.table3[1].process_time = datetime.now() - timedelta(minutes=31)
found[1] = ["HT-LATER"]
run_batch(repo, runner=runner)
assert drafted == [1]
assert repo.table4[0]["contract_no"] == "HT-LATER"
assert repo.table3[1].process_status == 1
class HoldingContractRepo(MemoryContractRepo):
"""内存仓库加上可计数的 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_page_runs_after_connection_is_returned():
"""两组页面执行时连接已归还,落表时才再借。"""
repo = HoldingContractRepo(
[
_row(fid=1, supplier_id="SUP-A"),
_row(fid=2, oms_id=2026100502, supplier_id="SUP-B"),
]
)
during_page: list[int] = []
during_save: list[int] = []
original_claim = repo.claim_pending
original_save = repo.save_group
original_errors = repo.save_shift_errors
def claim_pending(limit=None, **kwargs):
"""领取时必须占着本班的连接。"""
assert repo.held == 1
return original_claim(limit=limit, **kwargs)
def save_group(payload):
"""落表时必须占着本班的连接。"""
during_save.append(repo.held)
return original_save(payload)
def save_shift_errors(items):
"""审计走连接池,不能在 session 里写。"""
assert repo.held == 0
return original_errors(items)
def runner(_draft):
"""页面执行时连接必须已经归还。"""
during_page.append(repo.held)
return ContractOutcome(KIND_OK, "HT-REUSE", None)
repo.claim_pending = claim_pending
repo.save_group = save_group
repo.save_shift_errors = save_shift_errors
summary = run_batch(repo, runner=runner)
assert during_page == [0, 0]
assert during_save == [1, 1]
assert repo.held == 0
assert summary.groups == 2
assert summary.ok == 2

View File

@ -0,0 +1,332 @@
# 合同 Oracle 仓库:.env 填充、领取 SQL、落表一条连接。不连真库。
from __future__ import annotations
from contextlib import contextmanager
from datetime import date
from decimal import Decimal
import os
import pytest
from rpa_oracle import RepoUnavailable, load_job_env
from persist import KIND_OK, ContractOutcome, build_save
from repo import (
LEASE_MINUTES,
OracleContractRepo,
PurchaseContractRow,
_SELECT_PENDING,
_SELECT_PENDING_LIMIT,
)
# 构造一条待录合同的表 3 行。
def _row(**overrides) -> PurchaseContractRow:
data = dict(
fid=1,
oms_id=2026100501,
supplier_id="SUP001",
supplier_name="示例供应商",
contract_sign_date=date(2026, 10, 1),
contract_start_date=date(2026, 10, 2),
contract_end_date=date(2027, 9, 30),
payment_term=90,
goods_id="G0001",
goods_name="阿莫西林胶囊",
reg_no="国药准字H20201234",
spec="0.25g*24粒",
unit="盒",
qty=Decimal("10.0000"),
unit_price=Decimal("100.0000"),
purchaser_id="P001",
purchaser_name="张三",
planner_id="PL001",
planner_name="李四",
contract_entry_status=None,
contract_no=None,
process_status=0,
org_code="901",
goods_mdm_code="QX0100167583",
supplier_mdm_code="DW0100046361",
)
data.update(overrides)
return PurchaseContractRow(**data)
# 合同 .env 空缺时补 oms .env,已有非空环境变量不覆盖。
def test_load_runtime_env_fills_blank_from_oms_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")
contract_env = tmp_path / "contract.env"
oms_env = tmp_path / "oms.env"
contract_env.write_text(
"RPA_ORACLE_HOST=192.168.12.244\nRPA_ORACLE_PASSWORD=\nRPA_ORACLE_USER=from-contract\n",
encoding="utf-8",
)
oms_env.write_text(
"RPA_ORACLE_PASSWORD=not-for-git\nRPA_ORACLE_USER=from-oms\n",
encoding="utf-8",
)
load_job_env(tmp_path, [contract_env, oms_env])
assert os.environ["RPA_ORACLE_HOST"] == "192.168.12.244"
assert os.environ["RPA_ORACLE_PASSWORD"] == "not-for-git"
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):
OracleContractRepo.from_env()
# 录 SQL 的假游标,fetchall 返回预设行。
class _FakeCursor:
# 挂上共享的 executed 列表和预设查询结果。
def __init__(
self,
executed: list,
rows: list | None = None,
description=None,
many: list | None = None,
):
self._executed = executed
self._rows = rows or []
self._many = many if many is not None else []
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))
# 记下一次批量 SQL 和全部 bind。
def executemany(self, sql, seq):
self._many.append((sql, [dict(item) for item in seq]))
# 返回预设行的副本。
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.many: list[tuple[str, list]] = []
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, many=self.many)
# 记下已提交。
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",),
("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",),
("PROCESS_RESULT",),
)
# 构造领取查询返回的一行元组。
def _fetched_row(fid: int = 11, oms_id: int = 2026100501):
return (
fid,
oms_id,
"SUP001",
"示例供应商",
date(2026, 10, 1),
date(2026, 10, 2),
date(2027, 9, 30),
90,
"G0001",
"阿莫西林胶囊",
"国药准字H20201234",
"0.25g*24粒",
"盒",
Decimal("10.0000"),
Decimal("100.0000"),
"P001",
"张三",
"PL001",
"李四",
None,
None,
0,
"901",
"QX0100167583",
"DW0100046361",
"",
)
# 领取 SQL 带 FOR UPDATE SKIP LOCKED,过期 process_status=3 可再领。
def test_claim_sql_uses_skip_locked_and_lease():
sql = " ".join(_SELECT_PENDING.split()).lower()
assert "for update skip locked" in sql
assert "process_status = 0" in sql
assert "process_status = 3" in sql
assert "process_time" in sql
assert "numtodsinterval(:lease_minutes" in sql
assert "skip locked" in " ".join(_SELECT_PENDING_LIMIT.split()).lower()
assert LEASE_MINUTES == 30
# 领取一次借连接:SKIP LOCKED 选出后盖成 3。
def test_claim_pending_locks_and_stamps_in_one_acquire():
conn = _FakeConn(rows=[_fetched_row()], description=_CLAIM_DESCRIPTION)
pool = _FakePool(conn)
repo = OracleContractRepo(pool)
claimed = repo.claim_pending(limit=5)
assert pool.acquires == 1
assert [row.fid for row in claimed] == [11]
assert claimed[0].process_status == 3
assert conn.committed is True
select_sql, select_binds = conn.executed[0]
assert "skip locked" in select_sql.lower()
assert select_binds["lease_minutes"] == 30
assert select_binds["limit"] == 5
update_sql = " ".join(conn.executed[1][0].split()).lower()
assert "process_status = 3" in update_sql
assert "fid" in update_sql
# 表 4 插入和表 3 更新各一次 executemany,同一连接同一事务。
def test_save_group_inserts_then_updates_in_one_acquire():
conn = _FakeConn()
pool = _FakePool(conn)
repo = OracleContractRepo(pool)
payload = build_save(
[_row(), _row(fid=2, oms_id=2026100502)],
ContractOutcome(KIND_OK, "HT1", None),
)
repo.save_group(payload)
assert pool.acquires == 1
assert conn.committed is True
assert conn.executed == []
assert len(conn.many) == 2
insert_sql, insert_binds = conn.many[0]
update_sql, update_binds = conn.many[1]
assert "rpa_contract_agreement_result" in insert_sql.lower()
assert [row["oms_id"] for row in insert_binds] == [2026100501, 2026100502]
assert "rpa_purchase_contract_data" in update_sql.lower()
assert [row["fid"] for row in update_binds] == [1, 2]
assert [row["process_status"] for row in update_binds] == [1, 1]
# 班次 session 内领取加落表只从池里借一次连接。
def test_session_claim_and_save_acquire_once():
conn = _FakeConn(
rows=[_fetched_row(11), _fetched_row(12, oms_id=2026100502)],
description=_CLAIM_DESCRIPTION,
)
pool = _FakePool(conn)
repo = OracleContractRepo(pool)
with repo.session():
claimed = repo.claim_pending()
assert [row.fid for row in claimed] == [11, 12]
repo.save_group(build_save(claimed, ContractOutcome(KIND_OK, "HT1", None)))
assert pool.acquires == 1
assert conn.commits >= 2
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, oms_id=2026100500 + fid) for fid in range(1, 1002)],
description=_CLAIM_DESCRIPTION,
)
pool = _FakePool(conn)
repo = OracleContractRepo(pool)
claimed = repo.claim_pending()
assert pool.acquires == 1
assert len(claimed) == 1001
assert claimed[0].process_status == 3
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 conn.committed is True

View File

@ -18,25 +18,31 @@
- 接口 1 落表 `deal_flag=0`,请求里不跑选品 - 接口 1 落表 `deal_flag=0`,请求里不跑选品
- 接口 3 落表 `process_status=0`,请求里不录合同 - 接口 3 落表 `process_status=0`,请求里不录合同
接口 2 出站组装:`load_cart_settlement_envelope(repo)`,同一 `omsId` 不拆批,一批最多 500 条。不改 `push_status`。 接口 2 出站组装:`load_cart_settlement_envelope(repo)`,同一 `omsId` 不拆批。上限默认 500,环境变量 `OMS_OUTBOUND_BATCH_LIMIT` 可改。SQL 侧按组截断。不改 `push_status`,不发 HTTP。连接从 `rpa_oracle` 池借。
```python ```python
from rpa_oracle.pool import create_pool
from app.outbound.cart_settlement import load_cart_settlement_envelope from app.outbound.cart_settlement import load_cart_settlement_envelope
from app.outbound.oracle_repo import OracleCartSettlementRepo from app.outbound.oracle_repo import OracleCartSettlementRepo
envelope = load_cart_settlement_envelope(OracleCartSettlementRepo.from_env()) pool = create_pool(role="oms")
with pool.acquire() as conn:
envelope = load_cart_settlement_envelope(OracleCartSettlementRepo(conn))
``` ```
接口 4 出站组装:`load_contract_agreement_envelope(repo)`,同一非空 `contractNo` 不拆批,一批最多 500 条。不改 `push_status`。 接口 4 出站组装:`load_contract_agreement_envelope(repo)`,同一非空 `contractNo` 不拆批。上限同样默认 500。不改 `push_status`,不发 HTTP。
```python ```python
from rpa_oracle.pool import create_pool
from app.outbound.contract_agreement import load_contract_agreement_envelope from app.outbound.contract_agreement import load_contract_agreement_envelope
from app.outbound.oracle_repo import OracleContractAgreementRepo from app.outbound.oracle_repo import OracleContractAgreementRepo
envelope = load_contract_agreement_envelope(OracleContractAgreementRepo.from_env()) pool = create_pool(role="oms")
with pool.acquire() as conn:
envelope = load_contract_agreement_envelope(OracleContractAgreementRepo(conn))
``` ```
运行时只落 / 读 Oracle。单测用内存替身,不连库:`uv run pytest -v`。 运行时只落 / 读 Oracle。入站单测用假连接录 SQL,不连库。
## 真库 ## 真库
@ -93,3 +99,48 @@ curl.exe -sS http://127.0.0.1:8001/api/oms/v1/purchase-contracts `
-H "X-Oms-Token: dev-oms-token" ` -H "X-Oms-Token: dev-oms-token" `
--data-binary "@examples/purchase_contract_batch.json" --data-binary "@examples/purchase_contract_batch.json"
``` ```
## 运维
### 怎么跑
Windows 真库见上一节:本机 `.env` 配 `OMS_INBOUND_TOKEN` 和 `RPA_ORACLE_*`,`.\start.ps1` 起 8001。缺 HOST / 用户 / 密码时收件回 503。审计表 DDL 在 `docs/ddl/rpa_oms_staging_tables.sql` 末尾 `rpa_oms_inbound_audit`,真库要先建这张表。
### 看表 1/3 哪列
- 接口 1:`rpa_sales_goods_mapping_data`。看 `oms_id`、`deal_flag`(0 待选品)、`bar_code`、`goods_mdm_code`。同一 `oms_id` 不应有第二行。
- 接口 3:`rpa_purchase_contract_data`。看 `fid`、`process_status`(0 待合同班)、`contract_entry_status`、`contract_no`。按对方 `fid` 幂等。
- 审计:`rpa_oms_inbound_audit`。看 `api_no`(1 或 3)、`http_code`、`error_text`、`token_masked`。
- 接口 2:`rpa_cart_settlement_goods_data`。看 `oms_id`、`push_status`(0 未推,1 成功,2 失败,3 推送中)、`push_result`。
- 接口 4:`rpa_contract_agreement_result`。看 `contract_no`、`push_status`、`push_result`。
### 出站批次和重推
- 一批上限:环境变量 `OMS_OUTBOUND_BATCH_LIMIT`,默认 500。口 2 同一 `omsId` 不拆批;口 4 同一非空 `contractNo` 不拆批。
- 未推:`SELECT * FROM rpa_cart_settlement_goods_data WHERE push_status = 0`(口 4 换表名)。
- 已推成功:`push_status = 1`。
- 失败重推:把 `push_status` 改回 0(例如 `UPDATE ... SET push_status = 0 WHERE push_status = 2`)。`mark_pushed` 已备,路径仍空,不要把未 POST 的行标成已推。
- 缺 HOST / 用户 / 密码时 `create_pool` 抛「数据库不可用」,文案不含环境变量名。
### 崩溃与审计失败
入站请求里不跑选品、不录合同,没有班次那种「停在 3」的戳。库故障整包 503,一条业务行都不落。审计 INSERT 失败只记本机日志,不把已经 200 的收件打成 500。
### 503 对外 vs 审计原文
对外 `msg` 固定「数据库不可用」,HTTP body 不含 DSN、Instant Client 路径、`ORA-`、表名。ORA 原文在审计表 `error_text`。401 对外「身份认证失败」,token 只存 `***`。
### 点名命令
```bash
cd rpa_oms
uv run pytest tests/test_sales_goods_receive.py -q
uv run pytest tests/test_oracle_repo.py -q
uv run pytest tests/test_purchase_contract_receive.py -q
uv run pytest tests/test_oracle_contract_repo.py -q
uv run pytest tests/test_cart_settlement_outbound.py::test_assembles_unpushed_rows_as_document_envelope -q
uv run pytest tests/test_oracle_cart_repo.py::test_list_unpushed_keeps_whole_oms_group_when_limit_is_3 -q
uv run pytest tests/test_contract_agreement_outbound.py::test_assembles_unpushed_rows_as_document_envelope -q
```
真库:同一 JSON POST 两次 → 先 ACCEPTED 后 DUPLICATE。再人为断库,接口回固定「数据库不可用」,审计表有原文。

View File

@ -1 +1,2 @@
"""洁达 RPA × 订单中心入站服务。""" # 洁达 RPA × 订单中心入站服务。

View File

@ -1,13 +1,16 @@
"""订单中心调本方:请求头 X-Oms-Token,和环境变量 OMS_INBOUND_TOKEN 比对。 # 订单中心调本方的共享密钥鉴权。
#
# 内网一对一:请求头 X-Oms-Token,和环境变量 OMS_INBOUND_TOKEN 做 compare_digest。
# 不上 OAuth、不加用户会话。未配置 token 或请求没带 token 一律失败,避免空串把鉴权打成敞开。
# 失败由 main._post_inbound 回 401「身份认证失败」并写审计(token 打码)。
未配置 token 时一律失败,避免空串把鉴权打成敞开。
"""
from __future__ import annotations from __future__ import annotations
import os import os
import secrets import secrets
# 比较请求头与环境变量里的共享密钥。两边有空就失败。
def token_ok(provided: str | None) -> bool: def token_ok(provided: str | None) -> bool:
expected = os.getenv("OMS_INBOUND_TOKEN", "") expected = os.getenv("OMS_INBOUND_TOKEN", "")
if not expected or not provided: if not expected or not provided:

View File

@ -1 +1,2 @@
"""入站收件:拆信封、逐条校验。接口 1 按 omsId 幂等,接口 3 按 fid 幂等。""" # 入站收件:拆信封、整批校验再落库。接口 1 按 omsId 幂等,接口 3 按 fid 幂等。

View File

@ -0,0 +1,73 @@
# 入站审计:一行请求一条,写失败只记日志,不把业务请求打成 500。
from __future__ import annotations
import json
import logging
from typing import Any
logger = logging.getLogger("rpa_oms.inbound.audit")
_INSERT_SQL = """
INSERT INTO rpa_oms_inbound_audit (
api_no, http_code, request_json, response_json, error_text, token_masked
) VALUES (
:api_no, :http_code, :request_json, :response_json, :error_text, :token_masked
)
"""
_ERROR_MAX = 4000
# 有 token 就打成 ***,没有则空。不把明文写进审计。
def mask_token(token: str | None) -> str | None:
if not token:
return None
return "***"
# 把入参或出参收成 JSON 字符串,datetime / Decimal 用 str。
def dumps(payload: Any) -> str:
return json.dumps(payload, ensure_ascii=False, default=str)
# 内部错误原文截到 VARCHAR2(4000)。空串当没有。
def clip_error(text: str | None) -> str | None:
if not text:
return None
if len(text) > _ERROR_MAX:
return text[:_ERROR_MAX]
return text
# 往审计表插一行。任何异常都吞掉并记日志。
def write_audit(
conn,
*,
api_no: int,
http_code: int,
payload: Any,
response: Any,
error_text: str | None,
token: str | None,
) -> None:
binds = {
"api_no": api_no,
"http_code": http_code,
"request_json": dumps(payload if payload is not None else {}),
"response_json": dumps(response if response is not None else {}),
"error_text": clip_error(error_text),
"token_masked": mask_token(token),
}
try:
with conn.cursor() as cur:
cur.execute(_INSERT_SQL, binds)
conn.commit()
except Exception:
logger.exception("inbound audit write failed")
# 取出内部错误原文:优先 __cause__,供审计表用,不给出站。
def cause_text(exc: BaseException) -> str:
cause = exc.__cause__ or exc
return str(cause)

View File

@ -1,16 +1,18 @@
"""批量信封。缺 data、空数组、超过 100 条:整包 422,一条都不落。 # 批量信封。缺 data、空数组、超过 100 条:整包 422,一条都不落。
#
单条字段错误不走这里,由各收件函数逐条 REJECTED,HTTP 仍 200。 # 单条字段错误不走这里,由各收件函数逐条 REJECTED,HTTP 仍 200。
"""
MAX_BATCH = 100 MAX_BATCH = 100
# 信封不合法,对应 HTTP 422。
class EnvelopeError(Exception): class EnvelopeError(Exception):
# 记下对外文案。
def __init__(self, message: str): def __init__(self, message: str):
self.message = message self.message = message
# 拆出 data 数组。不合法时抛 EnvelopeError,调用方整包拒绝。
def parse_envelope(payload: object) -> list: def parse_envelope(payload: object) -> list:
if not isinstance(payload, dict) or "data" not in payload: if not isinstance(payload, dict) or "data" not in payload:
raise EnvelopeError("缺少批量数据节点 data") raise EnvelopeError("缺少批量数据节点 data")

View File

@ -1,43 +0,0 @@
"""内存仓库。只给单测用,进程退出即丢。运行时走 Oracle。
按 oms_id 判重,已存在就 DuplicateOmsId,不覆盖。
"""
from __future__ import annotations
from .repo import DuplicateFid, DuplicateOmsId
class MemorySalesGoodsRepo:
def __init__(self):
self._rows: dict[int, dict] = {}
def insert(self, record: dict) -> None:
oms_id = record["oms_id"]
if oms_id in self._rows:
raise DuplicateOmsId(oms_id)
self._rows[oms_id] = dict(record)
def get(self, oms_id: int) -> dict | None:
row = self._rows.get(oms_id)
return dict(row) if row is not None else None
def count(self) -> int:
return len(self._rows)
class MemoryPurchaseContractRepo:
def __init__(self):
self._rows: dict[int, dict] = {}
def insert(self, record: dict) -> None:
fid = record["fid"]
if fid in self._rows:
raise DuplicateFid(fid)
self._rows[fid] = dict(record)
def get(self, fid: int) -> dict | None:
row = self._rows.get(fid)
return dict(row) if row is not None else None
def count(self) -> int:
return len(self._rows)

View File

@ -0,0 +1,14 @@
# 入站对外固定文案。HTTP body 只用这里的句子,不把 ORA 原文带出去。
AUTH_FAILED = "身份认证失败"
DB_UNAVAILABLE = "数据库不可用"
ROW_NOT_OBJECT = "明细必须是对象"
SALES_ACCEPTED = "接收成功,等待RPA处理"
SALES_DUPLICATE = "订单中心序号已存在,未重复入库"
SALES_CONFLICT = "同一请求中 omsId 重复"
CONTRACT_ACCEPTED = "采购合同数据已接收,等待处理"
CONTRACT_DUPLICATE = "序号已存在,未重复入库"
CONTRACT_CONFLICT = "同一请求中 fid 重复"

View File

@ -1,11 +1,10 @@
"""接口 3 Oracle 落表。fid 是订单中心来源序号,INSERT 必须绑这一列。""" # 接口 3 Oracle 落表。fid 是订单中心来源序号,INSERT 必须绑这一列。
from __future__ import annotations from __future__ import annotations
import os from rpa_oracle.errors import RepoUnavailable, is_contract_fid_duplicate
from collections.abc import Callable
from .oracle_repo import _ensure_client, _ora_code from .oracle_repo import _rollback, in_binds, unique_collisions
from .repo import DuplicateFid, RepoUnavailable
_INSERT_SQL = """ _INSERT_SQL = """
INSERT INTO rpa_purchase_contract_data ( INSERT INTO rpa_purchase_contract_data (
@ -60,44 +59,51 @@ _BIND_KEYS = (
) )
# 接口 3 连 Oracle 的仓库,管表 3 按 fid 查重和批量插入。
#
# 连接由调用方从池里借来,一个请求一条。
class OraclePurchaseContractRepo: class OraclePurchaseContractRepo:
def __init__(self, connect: Callable): # 挂上本请求的连接。不要在仓库里再 connect。
self._connect = connect def __init__(self, conn):
self._conn = conn
@classmethod # 查出表 3 里已经有的 fid。空列表不访问库。
def from_env(cls) -> "OraclePurchaseContractRepo": def existing_fids(self, fids: list[int]) -> set[int]:
user = os.getenv("RPA_ORACLE_USER", "") if not fids:
password = os.getenv("RPA_ORACLE_PASSWORD", "") return set()
host = os.getenv("RPA_ORACLE_HOST", "") placeholders, binds = in_binds(fids)
if not user or not password or not host: sql = (
raise RepoUnavailable("未配置 RPA_ORACLE_HOST / RPA_ORACLE_USER / RPA_ORACLE_PASSWORD") "SELECT fid FROM rpa_purchase_contract_data "
port = os.getenv("RPA_ORACLE_PORT", "1521") f"WHERE fid IN ({placeholders})"
service = os.getenv("RPA_ORACLE_SERVICE", "rpa") )
dsn = f"{host}:{port}/{service}"
def connect():
_ensure_client()
import oracledb
return oracledb.connect(user=user, password=password, dsn=dsn)
return cls(connect=connect)
def insert(self, record: dict) -> None:
conn = self._connect()
try: try:
with conn.cursor() as cur: with self._conn.cursor() as cur:
cur.execute(_INSERT_SQL, {key: record[key] for key in _BIND_KEYS}) cur.execute(sql, binds)
conn.commit() return {row[0] for row in cur.fetchall()}
except DuplicateFid: except Exception as exc:
raise RepoUnavailable("数据库不可用") from exc
# 一次 executemany 插入待写入行。
#
# 返回撞上 pk_rpa_pcd 的 fid。其它唯一约束或库故障抛 RepoUnavailable。
# 成功行在本方法内 commit。
def insert_many(self, records: list[dict]) -> set[int]:
if not records:
return set()
payload = [{key: record[key] for key in _BIND_KEYS} for record in records]
try:
with self._conn.cursor() as cur:
cur.executemany(_INSERT_SQL, payload, batcherrors=True)
collided = unique_collisions(
cur, records, "fid", is_contract_fid_duplicate
)
self._conn.commit()
return collided
except RepoUnavailable:
_rollback(self._conn)
raise raise
except Exception as exc: except Exception as exc:
try: _rollback(self._conn)
conn.rollback() if is_contract_fid_duplicate(exc) and len(records) == 1:
except Exception: return {records[0]["fid"]}
pass raise RepoUnavailable("数据库不可用") from exc
if _ora_code(exc) == 1:
raise DuplicateFid(record["fid"]) from exc
raise RepoUnavailable(str(exc)) from exc
finally:
conn.close()

View File

@ -1,17 +1,13 @@
"""接口 1 Oracle 落表。 # 接口 1 Oracle 落表。
#
# 11.2 只能 thick(Instant Client)。连接从 rpa_oracle 池借。
# fid 由序列触发器生成,INSERT 不绑这一列。
# 唯一约束只认 uk_rpa_sgmd_oms_id 为 oms_id 重复;其它唯一约束当系统故障。
11.2 只能 thick(Instant Client)。Windows 把 Instant Client 目录设到
RPA_ORACLE_CLIENT_LIB。fid 由序列触发器生成,INSERT 不绑这一列。
唯一约束冲突(ORA-00001)当成 oms_id 重复。
"""
from __future__ import annotations from __future__ import annotations
import os from rpa_oracle.errors import RepoUnavailable, is_sales_oms_id_duplicate
from collections.abc import Callable
from .repo import DuplicateOmsId, RepoUnavailable
# 接口入站无登录用户:创建人/更新人固定 0 / 系统管理员,时间用库时钟。
_INSERT_SQL = """ _INSERT_SQL = """
INSERT INTO rpa_sales_goods_mapping_data ( INSERT INTO rpa_sales_goods_mapping_data (
oms_id, bar_code, order_date, sale_price, sale_qty, plan_purchase_qty, oms_id, bar_code, order_date, sale_price, sale_qty, plan_purchase_qty,
@ -50,67 +46,90 @@ _BIND_KEYS = (
"customer_mdm_code", "customer_mdm_code",
) )
_client_ready = False
# 接口 1 连 Oracle 的仓库,管表 1 查重和批量插入。
#
# 连接由调用方从池里借来,一个请求一条。
class OracleSalesGoodsRepo: class OracleSalesGoodsRepo:
def __init__(self, connect: Callable): # 挂上本请求的连接。不要在仓库里再 connect。
self._connect = connect def __init__(self, conn):
self._conn = conn
@classmethod # 查出表 1 里已经有的 oms_id。空列表不访问库。
def from_env(cls) -> "OracleSalesGoodsRepo": def existing_oms_ids(self, oms_ids: list[int]) -> set[int]:
user = os.getenv("RPA_ORACLE_USER", "") if not oms_ids:
password = os.getenv("RPA_ORACLE_PASSWORD", "") return set()
host = os.getenv("RPA_ORACLE_HOST", "") placeholders, binds = in_binds(oms_ids)
if not user or not password or not host: sql = (
raise RepoUnavailable("未配置 RPA_ORACLE_HOST / RPA_ORACLE_USER / RPA_ORACLE_PASSWORD") "SELECT oms_id FROM rpa_sales_goods_mapping_data "
port = os.getenv("RPA_ORACLE_PORT", "1521") f"WHERE oms_id IN ({placeholders})"
service = os.getenv("RPA_ORACLE_SERVICE", "rpa") )
dsn = f"{host}:{port}/{service}"
def connect():
_ensure_client()
import oracledb
return oracledb.connect(user=user, password=password, dsn=dsn)
return cls(connect=connect)
def insert(self, record: dict) -> None:
conn = self._connect()
try: try:
with conn.cursor() as cur: with self._conn.cursor() as cur:
cur.execute(_INSERT_SQL, {key: record[key] for key in _BIND_KEYS}) cur.execute(sql, binds)
conn.commit() return {row[0] for row in cur.fetchall()}
except DuplicateOmsId: except Exception as exc:
raise RepoUnavailable("数据库不可用") from exc
# 一次 executemany 插入待写入行。
#
# 返回撞上 uk_rpa_sgmd_oms_id 的 oms_id。其它唯一约束或库故障抛 RepoUnavailable。
# 成功行在本方法内 commit。
def insert_many(self, records: list[dict]) -> set[int]:
if not records:
return set()
payload = [{key: record[key] for key in _BIND_KEYS} for record in records]
try:
with self._conn.cursor() as cur:
cur.executemany(_INSERT_SQL, payload, batcherrors=True)
collided = unique_collisions(
cur, records, "oms_id", is_sales_oms_id_duplicate
)
self._conn.commit()
return collided
except RepoUnavailable:
_rollback(self._conn)
raise raise
except Exception as exc: except Exception as exc:
_rollback(self._conn)
if is_sales_oms_id_duplicate(exc) and len(records) == 1:
return {records[0]["oms_id"]}
raise RepoUnavailable("数据库不可用") from exc
# 把一组整数编成 :id0, :id1 占位和 bind 字典。
def in_binds(ids: list[int]) -> tuple[str, dict[str, int]]:
binds = {f"id{i}": value for i, value in enumerate(ids)}
placeholders = ", ".join(f":id{i}" for i in range(len(ids)))
return placeholders, binds
# 把 getbatcherrors() 单项收成 ora_code / ora_constraint 能认的异常。
def as_ora_exc(err) -> Exception:
message = getattr(err, "message", str(err))
code = getattr(err, "code", None)
info = type("Info", (), {"code": code, "message": message})()
exc = Exception(message)
exc.args = (info,)
return exc
# 从批错误里挑出幂等键冲突的 id;碰到未知约束则整批失败。
def unique_collisions(cur, records: list[dict], key: str, is_dup) -> set[int]:
collided: set[int] = set()
errors = cur.getbatcherrors() if hasattr(cur, "getbatcherrors") else []
for err in errors:
wrapped = as_ora_exc(err)
if is_dup(wrapped):
collided.add(records[err.offset][key])
continue
raise RepoUnavailable("数据库不可用") from wrapped
return collided
# 回滚本请求已写入的行;回滚失败忽略。
def _rollback(conn) -> None:
try: try:
conn.rollback() conn.rollback()
except Exception: except Exception:
pass pass
if _ora_code(exc) == 1:
raise DuplicateOmsId(record["oms_id"]) from exc
raise RepoUnavailable(str(exc)) from exc
finally:
conn.close()
def _ora_code(exc: BaseException) -> int | None:
if not exc.args:
return None
return getattr(exc.args[0], "code", None)
def _ensure_client() -> None:
# init_oracle_client 每个进程只能调一次。
global _client_ready
if _client_ready:
return
import oracledb
if os.getenv("RPA_ORACLE_THICK", "1") == "1":
lib_dir = os.getenv("RPA_ORACLE_CLIENT_LIB", "")
kwargs = {"lib_dir": lib_dir} if lib_dir else {}
oracledb.init_oracle_client(**kwargs)
_client_ready = True

View File

@ -0,0 +1,23 @@
# 入站字段解析。实现在 app.parse。
from __future__ import annotations
from ..parse import (
parse_date,
parse_decimal,
parse_fid,
parse_int,
parse_oms_id,
parse_positive_int,
parse_str,
)
__all__ = [
"parse_date",
"parse_decimal",
"parse_fid",
"parse_int",
"parse_oms_id",
"parse_positive_int",
"parse_str",
]

View File

@ -1,14 +1,12 @@
"""接口 3 逐条校验与落表。不跑合同作业。 # 接口 3 整批校验与落表。不跑合同作业。
#
# 入参 camelCase,落表 snake_case。幂等键是对方 fid。
# 请求里不录合同。
入参 camelCase,落表 snake_case。幂等键是对方 fid。
请求里不录合同。
"""
from __future__ import annotations from __future__ import annotations
from datetime import date, datetime from . import messages
from .parse import parse_date, parse_decimal, parse_fid, parse_int, parse_oms_id, parse_str
from .repo import DuplicateFid
from .sales_goods import _parse_decimal, _parse_int, _parse_oms_id, _parse_str
_STRING_FIELDS = ( _STRING_FIELDS = (
("supplierId", "supplier_id", 20), ("supplierId", "supplier_id", 20),
@ -41,21 +39,30 @@ _DECIMAL_FIELDS = (
) )
# 整批接收采购合同:先校验再落库,返回与入参下标对齐的结果。
def receive_purchase_contracts(rows: list, repo) -> list[dict]: def receive_purchase_contracts(rows: list, repo) -> list[dict]:
results: list[dict | None] = [None] * len(rows)
pending: list[tuple[int, dict]] = []
seen: set[int] = set() seen: set[int] = set()
results: list[dict] = [] for index, row in enumerate(rows):
for row in rows: decided = _classify(row, seen)
results.append(_receive_one(row, repo, seen)) if "resultCode" in decided:
return results results[index] = decided
else:
pending.append((index, decided))
if pending:
_persist(results, pending, repo)
return [item for item in results if item is not None]
def _receive_one(row: object, repo, seen: set[int]) -> dict: # 内存判定:对象/字段/同批 fid 冲突当场出结果,通过则返回待插入记录。
def _classify(row: object, seen: set[int]) -> dict:
if not isinstance(row, dict): if not isinstance(row, dict):
return _result(None, "REJECTED", "明细必须是对象") return _result(None, "REJECTED", messages.ROW_NOT_OBJECT)
fid, fid_error = _parse_fid(row.get("fid")) fid, fid_error = parse_fid(row.get("fid"))
if fid is not None and fid in seen: if fid is not None and fid in seen:
return _result(fid, "CONFLICT", "同一请求中 fid 重复") return _result(fid, "CONFLICT", messages.CONTRACT_CONFLICT)
if fid is not None: if fid is not None:
seen.add(fid) seen.add(fid)
@ -63,32 +70,52 @@ def _receive_one(row: object, repo, seen: set[int]) -> dict:
if error: if error:
_camel, message = error _camel, message = error
return _result(row.get("fid"), "REJECTED", message) return _result(row.get("fid"), "REJECTED", message)
return parsed
try:
repo.insert(parsed)
except DuplicateFid:
return _result(parsed["fid"], "DUPLICATE", "序号已存在,未重复入库")
return _result(parsed["fid"], "ACCEPTED", "采购合同数据已接收,等待处理")
# 对通过校验的行一次查重、一次插入,把 DUPLICATE / ACCEPTED 填回 results。
def _persist(results: list[dict | None], pending: list[tuple[int, dict]], repo) -> None:
existing = repo.existing_fids([record["fid"] for _index, record in pending])
to_insert: list[dict] = []
insert_at: list[int] = []
for index, record in pending:
if record["fid"] in existing:
results[index] = _result(
record["fid"], "DUPLICATE", messages.CONTRACT_DUPLICATE
)
else:
to_insert.append(record)
insert_at.append(index)
collided = repo.insert_many(to_insert) if to_insert else set()
for index, record in zip(insert_at, to_insert):
if record["fid"] in collided:
results[index] = _result(
record["fid"], "DUPLICATE", messages.CONTRACT_DUPLICATE
)
else:
results[index] = _result(
record["fid"], "ACCEPTED", messages.CONTRACT_ACCEPTED
)
# 把一条明细收成表 3 行。失败返回 (None, (字段, 说明))。
def _parse_row(row: dict, fid: int | None, fid_error: str | None): def _parse_row(row: dict, fid: int | None, fid_error: str | None):
if fid_error: if fid_error:
return None, ("fid", fid_error) return None, ("fid", fid_error)
oms_id, oms_error = _parse_oms_id(row.get("omsId")) oms_id, oms_error = parse_oms_id(row.get("omsId"))
if oms_error: if oms_error:
return None, ("omsId", oms_error) return None, ("omsId", oms_error)
record: dict = {"fid": fid, "oms_id": oms_id, "process_status": 0} record: dict = {"fid": fid, "oms_id": oms_id, "process_status": 0}
for camel, snake, max_len in _STRING_FIELDS: for camel, snake, max_len in _STRING_FIELDS:
value, error = _parse_str(row.get(camel), camel, max_len) value, error = parse_str(row.get(camel), camel, max_len)
if error: if error:
return None, (camel, error) return None, (camel, error)
record[snake] = value record[snake] = value
for camel, snake in _DATE_FIELDS: for camel, snake in _DATE_FIELDS:
value, error = _parse_date(row.get(camel), camel) value, error = parse_date(row.get(camel), camel)
if error: if error:
return None, (camel, error) return None, (camel, error)
record[snake] = value record[snake] = value
@ -96,18 +123,18 @@ def _parse_row(row: dict, fid: int | None, fid_error: str | None):
if record["contract_end_date"] < record["contract_start_date"]: if record["contract_end_date"] < record["contract_start_date"]:
return None, ("contractEndDate", "contractEndDate 不得早于 contractStartDate") return None, ("contractEndDate", "contractEndDate 不得早于 contractStartDate")
payment_term, error = _parse_int(row.get("paymentTerm"), "paymentTerm") payment_term, error = parse_int(row.get("paymentTerm"), "paymentTerm")
if error: if error:
return None, ("paymentTerm", error) return None, ("paymentTerm", error)
record["payment_term"] = payment_term record["payment_term"] = payment_term
for camel, snake in _DECIMAL_FIELDS: for camel, snake in _DECIMAL_FIELDS:
value, error = _parse_decimal(row.get(camel), camel) value, error = parse_decimal(row.get(camel), camel)
if error: if error:
return None, (camel, error) return None, (camel, error)
record[snake] = value record[snake] = value
customer_type, error = _parse_int(row.get("customerType"), "customerType") customer_type, error = parse_int(row.get("customerType"), "customerType")
if error: if error:
return None, ("customerType", error) return None, ("customerType", error)
record["customer_type"] = customer_type record["customer_type"] = customer_type
@ -125,29 +152,7 @@ def _parse_row(row: dict, fid: int | None, fid_error: str | None):
return record, None return record, None
def _parse_fid(value) -> tuple[int | None, str | None]: # 录入合同状态:缺省当 None;有值必须是 0 或 1。
if value is None or value == "":
return None, "缺少必填字段 fid"
if type(value) is int and 0 < value < 10**19:
return value, None
return None, "fid 必须是整数"
def _parse_date(value, camel: str) -> tuple[date | None, str | None]:
if value is None or value == "":
return None, f"缺少必填字段 {camel}"
if isinstance(value, datetime):
return value.date(), None
if isinstance(value, date):
return value, None
if not isinstance(value, str):
return None, f"{camel} 必须是 YYYY-MM-DD"
try:
return date.fromisoformat(value.strip()), None
except ValueError:
return None, f"{camel} 必须是 YYYY-MM-DD"
def _parse_entry_status(value) -> tuple[int | None, str | None]: def _parse_entry_status(value) -> tuple[int | None, str | None]:
if value is None or value == "": if value is None or value == "":
return None, None return None, None
@ -156,13 +161,15 @@ def _parse_entry_status(value) -> tuple[int | None, str | None]:
return None, "contractEntryStatus 必须是 0 或 1" return None, "contractEntryStatus 必须是 0 或 1"
# contractEntryStatus=0 时合同号必填;其它情况可空。
def _parse_contract_no(value, entry_status: int | None) -> tuple[str | None, str | None]: def _parse_contract_no(value, entry_status: int | None) -> tuple[str | None, str | None]:
if entry_status == 0: if entry_status == 0:
return _parse_str(value, "contractNo", 40) return parse_str(value, "contractNo", 40)
if value is None or value == "": if value is None or value == "":
return None, None return None, None
return _parse_str(value, "contractNo", 40) return parse_str(value, "contractNo", 40)
# 拼一条接口 3 的 results[] 项。
def _result(fid, result_code: str, message: str) -> dict: def _result(fid, result_code: str, message: str) -> dict:
return {"fid": fid, "resultCode": result_code, "message": message} return {"fid": fid, "resultCode": result_code, "message": message}

View File

@ -1,21 +1,23 @@
"""收件仓库契约。 # 收件仓库契约。
#
# DuplicateOmsId / DuplicateFid:幂等键已存在时的旧异常类型,班次和出站仍可能引用。
# RepoUnavailable 与 rpa_oracle.errors 是同一个类:连不上库或未配置,整包 HTTP 503。
DuplicateOmsId:oms_id 已存在,调用方回 DUPLICATE,不更新。 from rpa_oracle.errors import RepoUnavailable
DuplicateFid:接口 3 来源 fid 已存在,调用方回 DUPLICATE,不更新。
RepoUnavailable:连不上库或未配置,整包 HTTP 503,一条都不落。
"""
# 表 1 已有同一 oms_id。新入站路径用查重集合,不再抛这个。
class DuplicateOmsId(Exception): class DuplicateOmsId(Exception):
# 记下冲突的 oms_id。
def __init__(self, oms_id: int): def __init__(self, oms_id: int):
self.oms_id = oms_id self.oms_id = oms_id
# 表 3 已有同一 fid。新入站路径用查重集合,不再抛这个。
class DuplicateFid(Exception): class DuplicateFid(Exception):
# 记下冲突的 fid。
def __init__(self, fid: int): def __init__(self, fid: int):
self.fid = fid self.fid = fid
class RepoUnavailable(Exception): __all__ = ["DuplicateFid", "DuplicateOmsId", "RepoUnavailable"]
def __init__(self, message: str = "数据库不可用"):
self.message = message

View File

@ -1,23 +1,21 @@
"""接口 1 逐条校验与落表。不跑选品。 # 接口 1 整批校验与落表。不跑选品。
#
# 入参 camelCase,落表 snake_case。长度跟 VARCHAR2。
#
# 先在内存里标 REJECTED / 同批 CONFLICT,再一次查重、一次插入。
# 单条结果:
# - ACCEPTED:入库,deal_flag=0,等选品作业捞
# - DUPLICATE:库里已有同一 omsId,或并发撞上 uk_rpa_sgmd_oms_id
# - CONFLICT:同一请求里 omsId 出现过(前一条哪怕被拒,后一条也算冲突)
# - REJECTED:字段不过,不入库
#
# 混批 HTTP 仍 200。omsId / 数量必须是 JSON 整数,不把 "10" 当 10。
入参 camelCase,落表 snake_case。长度跟 VARCHAR2。
单条结果:
- ACCEPTED:入库,deal_flag=0,等选品作业捞
- DUPLICATE:库里已有同一 omsId,不更新、不重跑
- CONFLICT:同一请求里 omsId 出现过(前一条哪怕被拒,后一条也算冲突)
- REJECTED:字段不过,不入库
混批 HTTP 仍 200。omsId / 数量必须是 JSON 整数,不把 "10" 当 10。
"""
from __future__ import annotations from __future__ import annotations
from datetime import date, datetime from . import messages
from decimal import Decimal, InvalidOperation from .parse import parse_date, parse_decimal, parse_int, parse_oms_id, parse_str
from .repo import DuplicateOmsId
# (接口字段, 表列, 最大字符数)
_STRING_FIELDS = ( _STRING_FIELDS = (
("barCode", "bar_code", 40), ("barCode", "bar_code", 40),
("goodsId", "goods_id", 20), ("goodsId", "goods_id", 20),
@ -39,21 +37,30 @@ _INT_FIELDS = (
) )
# 整批接收销售商品:先校验再落库,返回与入参下标对齐的结果。
def receive_sales_goods(rows: list, repo) -> list[dict]: def receive_sales_goods(rows: list, repo) -> list[dict]:
results: list[dict | None] = [None] * len(rows)
pending: list[tuple[int, dict]] = []
seen: set[int] = set() seen: set[int] = set()
results: list[dict] = [] for index, row in enumerate(rows):
for row in rows: decided = _classify(row, seen)
results.append(_receive_one(row, repo, seen)) if "resultCode" in decided:
return results results[index] = decided
else:
pending.append((index, decided))
if pending:
_persist(results, pending, repo)
return [item for item in results if item is not None]
def _receive_one(row: object, repo, seen: set[int]) -> dict: # 内存判定:对象/字段/同批冲突当场出结果,通过则返回待插入记录。
def _classify(row: object, seen: set[int]) -> dict:
if not isinstance(row, dict): if not isinstance(row, dict):
return _result(None, "REJECTED", "明细必须是对象") return _result(None, "REJECTED", messages.ROW_NOT_OBJECT)
oms_id, oms_error = _parse_oms_id(row.get("omsId")) oms_id, oms_error = parse_oms_id(row.get("omsId"))
if oms_id is not None and oms_id in seen: if oms_id is not None and oms_id in seen:
return _result(oms_id, "CONFLICT", "同一请求中 omsId 重复") return _result(oms_id, "CONFLICT", messages.SALES_CONFLICT)
if oms_id is not None: if oms_id is not None:
seen.add(oms_id) seen.add(oms_id)
@ -61,38 +68,58 @@ def _receive_one(row: object, repo, seen: set[int]) -> dict:
if error: if error:
_camel, message = error _camel, message = error
return _result(row.get("omsId"), "REJECTED", message) return _result(row.get("omsId"), "REJECTED", message)
return parsed
try:
repo.insert(parsed)
except DuplicateOmsId:
return _result(parsed["oms_id"], "DUPLICATE", "订单中心序号已存在,未重复入库")
return _result(parsed["oms_id"], "ACCEPTED", "接收成功,等待RPA处理")
# 对通过校验的行一次查重、一次插入,把 DUPLICATE / ACCEPTED 填回 results。
def _persist(results: list[dict | None], pending: list[tuple[int, dict]], repo) -> None:
existing = repo.existing_oms_ids([record["oms_id"] for _index, record in pending])
to_insert: list[dict] = []
insert_at: list[int] = []
for index, record in pending:
if record["oms_id"] in existing:
results[index] = _result(
record["oms_id"], "DUPLICATE", messages.SALES_DUPLICATE
)
else:
to_insert.append(record)
insert_at.append(index)
collided = repo.insert_many(to_insert) if to_insert else set()
for index, record in zip(insert_at, to_insert):
if record["oms_id"] in collided:
results[index] = _result(
record["oms_id"], "DUPLICATE", messages.SALES_DUPLICATE
)
else:
results[index] = _result(
record["oms_id"], "ACCEPTED", messages.SALES_ACCEPTED
)
# 把一条明细收成表 1 行。失败返回 (None, (字段, 说明))。
def _parse_row(row: dict, oms_id: int | None, oms_error: str | None): def _parse_row(row: dict, oms_id: int | None, oms_error: str | None):
if oms_error: if oms_error:
return None, ("omsId", oms_error) return None, ("omsId", oms_error)
record: dict = {"oms_id": oms_id, "deal_flag": 0} record: dict = {"oms_id": oms_id, "deal_flag": 0}
for camel, snake, max_len in _STRING_FIELDS: for camel, snake, max_len in _STRING_FIELDS:
value, error = _parse_str(row.get(camel), camel, max_len) value, error = parse_str(row.get(camel), camel, max_len)
if error: if error:
return None, (camel, error) return None, (camel, error)
record[snake] = value record[snake] = value
order_date, error = _parse_date(row.get("orderDate")) order_date, error = parse_date(row.get("orderDate"), "orderDate")
if error: if error:
return None, ("orderDate", error) return None, ("orderDate", error)
record["order_date"] = order_date record["order_date"] = order_date
sale_price, error = _parse_decimal(row.get("salePrice"), "salePrice") sale_price, error = parse_decimal(row.get("salePrice"), "salePrice")
if error: if error:
return None, ("salePrice", error) return None, ("salePrice", error)
record["sale_price"] = sale_price record["sale_price"] = sale_price
for camel, snake in _INT_FIELDS: for camel, snake in _INT_FIELDS:
value, error = _parse_int(row.get(camel), camel) value, error = parse_int(row.get(camel), camel)
if error: if error:
return None, (camel, error) return None, (camel, error)
record[snake] = value record[snake] = value
@ -100,71 +127,6 @@ def _parse_row(row: dict, oms_id: int | None, oms_error: str | None):
return record, None return record, None
def _parse_oms_id(value) -> tuple[int | None, str | None]: # 拼一条接口 1 的 results[] 项。
if value is None or value == "":
return None, "缺少必填字段 omsId"
# bool 是 int 子类,必须用 type(value) is int。oms_id 列 number(20)。
if type(value) is int and 0 < value < 10**20:
return value, None
return None, "omsId 必须是整数"
def _parse_str(value, camel: str, max_len: int) -> tuple[str | None, str | None]:
if value is None:
return None, f"缺少必填字段 {camel}"
if not isinstance(value, str):
return None, f"{camel} 必须是字符串"
text = value.strip()
if not text:
return None, f"缺少必填字段 {camel}"
if len(text) > max_len:
return None, f"{camel} 超出长度"
return text, None
def _parse_int(value, camel: str) -> tuple[int | None, str | None]:
if value is None or value == "":
return None, f"缺少必填字段 {camel}"
if type(value) is int:
return value, None
return None, f"{camel} 必须是整数"
def _parse_decimal(value, camel: str) -> tuple[Decimal | None, str | None]:
if value is None or value == "":
return None, f"缺少必填字段 {camel}"
if type(value) is bool:
return None, f"{camel} 必须是数字"
try:
if isinstance(value, Decimal):
amount = value
elif isinstance(value, str):
amount = Decimal(value.strip())
elif type(value) is int:
amount = Decimal(value)
elif isinstance(value, float):
amount = Decimal(str(value))
else:
return None, f"{camel} 必须是数字"
except (InvalidOperation, ValueError):
return None, f"{camel} 必须是数字"
return amount, None
def _parse_date(value) -> tuple[date | None, str | None]:
if value is None or value == "":
return None, "缺少必填字段 orderDate"
if isinstance(value, datetime):
return value.date(), None
if isinstance(value, date):
return value, None
if not isinstance(value, str):
return None, "orderDate 必须是 YYYY-MM-DD"
try:
return date.fromisoformat(value.strip()), None
except ValueError:
return None, "orderDate 必须是 YYYY-MM-DD"
def _result(oms_id, result_code: str, message: str) -> dict: def _result(oms_id, result_code: str, message: str) -> dict:
return {"omsId": oms_id, "resultCode": result_code, "message": message} return {"omsId": oms_id, "resultCode": result_code, "message": message}

View File

@ -1,24 +0,0 @@
"""从 rpa_oms/.env 读本机配置。该文件已 gitignore,不覆盖已有环境变量。"""
from __future__ import annotations
import os
from pathlib import Path
_ENV_PATH = Path(__file__).resolve().parents[1] / ".env"
def load_local_env(path: Path | None = None) -> None:
env_path = path if path is not None else _ENV_PATH
if not env_path.is_file():
return
for raw in env_path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
value = value[1:-1]
if key and key not in os.environ:
os.environ[key] = value

View File

@ -1,102 +1,165 @@
"""订单中心接口服务。本期开放接口 1、3 收件;接口 2 只在 outbound 组装报文。 # 订单中心接口服务。本期开放接口 1、3 收件;接口 2 只在 outbound 组装报文。
#
# 路径:
# - POST /api/oms/v1/sales-goods
# - POST /api/oms/v1/purchase-contracts
# 鉴权:X-Oms-Token,内网共享密钥,不上 OAuth。
# 请求里不跑选品、不录合同。
# 对外 401/503 用固定文案;ORA 原文只进审计表。
路径:
- POST /api/oms/v1/sales-goods
- POST /api/oms/v1/purchase-contracts
鉴权:X-Oms-Token
请求里不跑选品、不录合同。
"""
from __future__ import annotations from __future__ import annotations
import logging
import sys
import threading
from pathlib import Path
from typing import Annotated, Any from typing import Annotated, Any
from fastapi import Depends, FastAPI, Header from fastapi import Depends, FastAPI, Header, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from .auth import token_ok # 把仓库根目录放进 sys.path,才能 import rpa_oracle。
from .local_env import load_local_env def _put_repo_on_path() -> None:
root = Path(__file__).resolve().parents[2]
if str(root) not in sys.path:
sys.path.insert(0, str(root))
load_local_env()
_put_repo_on_path()
from rpa_oracle.env import load_env
from rpa_oracle.errors import RepoUnavailable
from .auth import token_ok
from .inbound.audit import cause_text, write_audit
from .inbound.envelope import EnvelopeError, parse_envelope from .inbound.envelope import EnvelopeError, parse_envelope
from .inbound.messages import AUTH_FAILED, DB_UNAVAILABLE
from .inbound.oracle_contract_repo import OraclePurchaseContractRepo
from .inbound.oracle_repo import OracleSalesGoodsRepo
from .inbound.purchase_contract import receive_purchase_contracts from .inbound.purchase_contract import receive_purchase_contracts
from .inbound.repo import RepoUnavailable
from .inbound.sales_goods import receive_sales_goods from .inbound.sales_goods import receive_sales_goods
load_env(Path(__file__).resolve().parents[1] / ".env")
logger = logging.getLogger("rpa_oms")
app = FastAPI(title="洁达 RPA × 订单中心", version="0.1") app = FastAPI(title="洁达 RPA × 订单中心", version="0.1")
_pool = None
def get_repo(): _pool_lock = threading.Lock()
# 运行时只走 Oracle。单测用 dependency_overrides 注入内存替身。
from .inbound.oracle_repo import OracleSalesGoodsRepo
return OracleSalesGoodsRepo.from_env()
def get_contract_repo(): # 进程内共用 OMS 连接池。缺配置抛「数据库不可用」。
from .inbound.oracle_contract_repo import OraclePurchaseContractRepo #
# 懒加载加锁:并发的首个请求只建一个池,输家的池不会被丢掉漏连接。
def get_pool():
global _pool
if _pool is not None:
return _pool
with _pool_lock:
if _pool is None:
from rpa_oracle.pool import create_pool
return OraclePurchaseContractRepo.from_env() _pool = create_pool(role="oms")
return _pool
@app.get("/health") # 依赖注入阶段库不可用时,仍回固定 503,不把异常原文带出去。
@app.exception_handler(RepoUnavailable)
def handle_repo_unavailable(_request: Request, _exc: RepoUnavailable):
return JSONResponse(
status_code=503,
content={"code": 503, "msg": DB_UNAVAILABLE, "results": []},
)
@app.get("/health", description="进程存活探活,不连库。")
def health(): def health():
return {"ok": True} return {"ok": True}
@app.post("/api/oms/v1/sales-goods") @app.post("/api/oms/v1/sales-goods", description="接收接口 1 销售商品,落表 1。请求里不跑选品。")
def post_sales_goods( def post_sales_goods(
payload: dict[str, Any] | None = None, payload: dict[str, Any] | None = None,
x_oms_token: Annotated[str | None, Header()] = None, x_oms_token: Annotated[str | None, Header()] = None,
repo=Depends(get_repo), pool=Depends(get_pool),
): ):
return _post_inbound( return _post_inbound(
payload, payload,
x_oms_token, x_oms_token,
receive=lambda rows: receive_sales_goods(rows, repo), pool,
api_no=1,
receive=receive_sales_goods,
repo_cls=OracleSalesGoodsRepo,
) )
@app.post("/api/oms/v1/purchase-contracts") @app.post("/api/oms/v1/purchase-contracts", description="接收接口 3 采购合同,落表 3。请求里不录合同。")
def post_purchase_contracts( def post_purchase_contracts(
payload: dict[str, Any] | None = None, payload: dict[str, Any] | None = None,
x_oms_token: Annotated[str | None, Header()] = None, x_oms_token: Annotated[str | None, Header()] = None,
repo=Depends(get_contract_repo), pool=Depends(get_pool),
): ):
return _post_inbound( return _post_inbound(
payload, payload,
x_oms_token, x_oms_token,
receive=lambda rows: receive_purchase_contracts(rows, repo), pool,
api_no=3,
receive=receive_purchase_contracts,
repo_cls=OraclePurchaseContractRepo,
) )
def _post_inbound(payload, x_oms_token, receive): # 接口 1/3 共用:鉴权、拆信封、整批收件、写审计。混批 HTTP 仍 200。
def _post_inbound(payload, x_oms_token, pool, api_no: int, receive, repo_cls):
if not token_ok(x_oms_token): if not token_ok(x_oms_token):
return JSONResponse( body = {"code": 401, "msg": AUTH_FAILED, "results": []}
status_code=401, _audit_best_effort(pool, api_no, 401, payload, body, None, x_oms_token)
content={"code": 401, "msg": "身份认证失败", "results": []}, return JSONResponse(status_code=401, content=body)
)
try: try:
rows = parse_envelope(payload if payload is not None else {}) rows = parse_envelope(payload if payload is not None else {})
except EnvelopeError as exc: except EnvelopeError as exc:
return JSONResponse( body = {"code": 422, "msg": exc.message, "results": []}
status_code=422, _audit_best_effort(pool, api_no, 422, payload, body, None, x_oms_token)
content={"code": 422, "msg": exc.message, "results": []}, return JSONResponse(status_code=422, content=body)
)
try: try:
results = receive(rows) with pool.acquire() as conn:
except RepoUnavailable as exc: results = receive(rows, repo_cls(conn))
return JSONResponse( body = {"code": 200, "msg": _summary(results), "results": results}
status_code=503, write_audit(
content={"code": 503, "msg": exc.message, "results": []}, conn,
api_no=api_no,
http_code=200,
payload=payload,
response=body,
error_text=None,
token=x_oms_token,
) )
# 逐条有成功/重复/拒绝时 HTTP 仍 200,看 results[].resultCode。 return body
return { except RepoUnavailable as exc:
"code": 200, body = {"code": 503, "msg": DB_UNAVAILABLE, "results": []}
"msg": _summary(results), _audit_best_effort(
"results": results, pool, api_no, 503, payload, body, cause_text(exc), x_oms_token
} )
return JSONResponse(status_code=503, content=body)
# 401/422/503 路径再借一条连接写审计。失败只记日志。
def _audit_best_effort(pool, api_no, http_code, payload, body, error_text, token) -> None:
try:
with pool.acquire() as conn:
write_audit(
conn,
api_no=api_no,
http_code=http_code,
payload=payload,
response=body,
error_text=error_text,
token=token,
)
except Exception:
logger.exception("inbound audit write failed")
# 按本批 resultCode 拼整包 msg:全成功 / 含重复 / 其它混批。
def _summary(results: list[dict]) -> str: def _summary(results: list[dict]) -> str:
codes = {item["resultCode"] for item in results} codes = {item["resultCode"] for item in results}
if codes <= {"ACCEPTED"}: if codes <= {"ACCEPTED"}:

View File

@ -1 +1,2 @@
"""出站:从表组装订单中心报文。本期接口 2、4 只取数组装,不发 HTTP。""" # 出站:从表组装订单中心报文。本期接口 2、4 只取数组装,不发 HTTP。

View File

@ -1,25 +1,36 @@
"""接口 2:从表 2 取未推行,组装文档报文。 # 接口 2:从表 2 取未推行,组装文档报文。
#
# 同一 omsId 的加购行必须在同一批 data 里。一批上限默认 500,可用
# OMS_OUTBOUND_BATCH_LIMIT 覆盖。不改 push_status,不向订单中心发 HTTP。
同一 omsId 的加购行必须在同一批 data 里。一批最多 500 条。
不改 push_status,不向订单中心发 HTTP(路径仍空)。
"""
from __future__ import annotations from __future__ import annotations
from collections import OrderedDict from collections import OrderedDict
from decimal import Decimal, ROUND_HALF_UP
MAX_BATCH = 500 from ..parse import as_int, money4
from .limits import MAX_BATCH, batch_limit
__all__ = [
"MAX_BATCH",
"batch_limit",
"build_cart_settlement_envelope",
"load_cart_settlement_envelope",
]
def load_cart_settlement_envelope(repo, limit: int = MAX_BATCH) -> dict: # 从仓库取未推行并组装信封。未传 limit 时读环境变量。
return build_cart_settlement_envelope(repo.list_unpushed(), limit=limit) def load_cart_settlement_envelope(repo, limit: int | None = None) -> dict:
cap = batch_limit() if limit is None else limit
return build_cart_settlement_envelope(repo.list_unpushed(limit=cap), limit=cap)
# 把已取出的行按 omsId 整组截到 limit,再转文档字段。
def build_cart_settlement_envelope(rows: list[dict], limit: int = MAX_BATCH) -> dict: def build_cart_settlement_envelope(rows: list[dict], limit: int = MAX_BATCH) -> dict:
chosen = _take_complete_groups(rows, limit) chosen = _take_complete_groups(rows, limit)
return {"data": [_to_item(row) for row in chosen]} return {"data": [_to_item(row) for row in chosen]}
# 按 oms_id 保序成组;装不下的整组留下。空批时超限组仍进入。
def _take_complete_groups(rows: list[dict], limit: int) -> list[dict]: def _take_complete_groups(rows: list[dict], limit: int) -> list[dict]:
groups: OrderedDict = OrderedDict() groups: OrderedDict = OrderedDict()
for row in rows: for row in rows:
@ -32,31 +43,28 @@ def _take_complete_groups(rows: list[dict], limit: int) -> list[dict]:
return batch return batch
# 金额四位小数字符串。库里的 NULL 保持 null,不让一行空金额掀掉整批。
def _money4(value) -> str | None:
if value is None:
return None
return money4(value)
# 把表 2 行转成接口 2 文档字段。金额四位小数字符串。
def _to_item(row: dict) -> dict: def _to_item(row: dict) -> dict:
return { return {
"fid": _as_int(row["fid"]), "fid": as_int(row["fid"]),
"operateType": _as_int(row["operate_type"]), "operateType": as_int(row["operate_type"]),
"omsId": _as_int(row["oms_id"]), "omsId": as_int(row["oms_id"]),
"platformType": _as_int(row["platform_type"]), "platformType": as_int(row["platform_type"]),
"supplierName": row["supplier_name"], "supplierName": row["supplier_name"],
"barCode": row["bar_code"], "barCode": row["bar_code"],
"purchasePrice": _money4(row["purchase_price"]), "purchasePrice": _money4(row.get("purchase_price")),
"purchaseQty": _as_int(row["purchase_qty"]), "purchaseQty": as_int(row["purchase_qty"]),
"purchaseTotal": _money4(row["purchase_total"]), "purchaseTotal": _money4(row.get("purchase_total")),
"status": _as_int(row["status"]) if row.get("status") is not None else None, "status": as_int(row["status"]) if row.get("status") is not None else None,
"failReason": row.get("fail_reason"), "failReason": row.get("fail_reason"),
"payOrderNo": row.get("pay_order_no"), "payOrderNo": row.get("pay_order_no"),
"payOrderDtlId": row.get("pay_order_dtl_id"), "payOrderDtlId": row.get("pay_order_dtl_id"),
"orgCode": row.get("org_code"), "orgCode": row.get("org_code"),
} }
def _as_int(value):
if value is None:
return None
return int(value)
def _money4(value) -> str:
amount = Decimal(str(value)).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
return f"{amount:.4f}"

View File

@ -1,24 +1,36 @@
"""接口 4:从表 4 取未推行,组装文档报文。 # 接口 4:从表 4 取未推行,组装文档报文。
#
# 同一非空 contractNo 必须在同一批 data 里。空 contractNo(失败行)按行独立。
# 一批上限默认 500,可用 OMS_OUTBOUND_BATCH_LIMIT 覆盖。不改 push_status,不发 HTTP。
同一非空 contractNo 必须在同一批 data 里。一批最多 500 条。
空 contractNo(失败行)按行独立。不改 push_status,不发 HTTP。
"""
from __future__ import annotations from __future__ import annotations
from collections import OrderedDict from collections import OrderedDict
MAX_BATCH = 500 from ..parse import as_int, as_str
from .limits import MAX_BATCH, batch_limit
__all__ = [
"MAX_BATCH",
"batch_limit",
"build_contract_agreement_envelope",
"load_contract_agreement_envelope",
]
def load_contract_agreement_envelope(repo, limit: int = MAX_BATCH) -> dict: # 从仓库取未推行并组装信封。未传 limit 时读环境变量。
return build_contract_agreement_envelope(repo.list_unpushed(), limit=limit) def load_contract_agreement_envelope(repo, limit: int | None = None) -> dict:
cap = batch_limit() if limit is None else limit
return build_contract_agreement_envelope(repo.list_unpushed(limit=cap), limit=cap)
# 把已取出的行按合同号整组截到 limit,再转文档字段。
def build_contract_agreement_envelope(rows: list[dict], limit: int = MAX_BATCH) -> dict: def build_contract_agreement_envelope(rows: list[dict], limit: int = MAX_BATCH) -> dict:
chosen = _take_complete_groups(rows, limit) chosen = _take_complete_groups(rows, limit)
return {"data": [_to_item(row) for row in chosen]} return {"data": [_to_item(row) for row in chosen]}
# 非空合同号成一组;空合同号用 fid 自成一组。
def _group_key(row: dict): def _group_key(row: dict):
number = row.get("contract_no") number = row.get("contract_no")
if number is None or str(number).strip() == "": if number is None or str(number).strip() == "":
@ -26,6 +38,7 @@ def _group_key(row: dict):
return ("no", str(number).strip()) return ("no", str(number).strip())
# 按合同号保序成组;装不下的整组留下。空批时超限组仍进入。
def _take_complete_groups(rows: list[dict], limit: int) -> list[dict]: def _take_complete_groups(rows: list[dict], limit: int) -> list[dict]:
groups: OrderedDict = OrderedDict() groups: OrderedDict = OrderedDict()
for row in rows: for row in rows:
@ -38,32 +51,18 @@ def _take_complete_groups(rows: list[dict], limit: int) -> list[dict]:
return batch return batch
# 把表 4 行转成接口 4 文档字段。
def _to_item(row: dict) -> dict: def _to_item(row: dict) -> dict:
return { return {
"fid": _as_int(row["fid"]), "fid": as_int(row["fid"]),
"omsId": _as_int(row["oms_id"]), "omsId": as_int(row["oms_id"]),
"actionCode": _as_int(row["action_code"]), "actionCode": as_int(row["action_code"]),
"contractStatus": _as_int(row.get("contract_status")), "contractStatus": as_int(row.get("contract_status")),
"contractFailReason": _as_str(row.get("contract_fail_reason")), "contractFailReason": as_str(row.get("contract_fail_reason")),
"contractNo": _as_str(row.get("contract_no")), "contractNo": as_str(row.get("contract_no")),
"agreementStatus": _as_int(row.get("agreement_status")), "agreementStatus": as_int(row.get("agreement_status")),
"agreementFailReason": _as_str(row.get("agreement_fail_reason")), "agreementFailReason": as_str(row.get("agreement_fail_reason")),
"agreementId": _as_str(row.get("agreement_id")), "agreementId": as_str(row.get("agreement_id")),
"agreementDtlId": _as_str(row.get("agreement_dtl_id")), "agreementDtlId": as_str(row.get("agreement_dtl_id")),
"orgCode": _as_str(row.get("org_code")), "orgCode": as_str(row.get("org_code")),
} }
def _as_int(value):
if value is None:
return None
return int(value)
def _as_str(value):
if value is None:
return None
text = str(value)
if text.strip() == "":
return None
return text

View File

@ -0,0 +1,22 @@
# 出站一批上限。
from __future__ import annotations
import os
MAX_BATCH = 500
LIMIT_ENV = "OMS_OUTBOUND_BATCH_LIMIT"
# 读 OMS_OUTBOUND_BATCH_LIMIT。缺省、非整数或非正数时用 500。
def batch_limit() -> int:
raw = os.getenv(LIMIT_ENV, "").strip()
if not raw:
return MAX_BATCH
try:
value = int(raw)
except ValueError:
return MAX_BATCH
if value <= 0:
return MAX_BATCH
return value

View File

@ -1,18 +0,0 @@
"""内存表 2 / 表 4。只给单测用。"""
from __future__ import annotations
class MemoryCartSettlementRepo:
def __init__(self, rows: list[dict] | None = None):
self._rows = [dict(row) for row in (rows or [])]
def list_unpushed(self) -> list[dict]:
return [dict(row) for row in self._rows if row.get("push_status", 0) == 0]
class MemoryContractAgreementRepo:
def __init__(self, rows: list[dict] | None = None):
self._rows = [dict(row) for row in (rows or [])]
def list_unpushed(self) -> list[dict]:
return [dict(row) for row in self._rows if row.get("push_status", 0) == 0]

View File

@ -1,101 +1,171 @@
"""接口 2 / 4:读 push_status=0。不更新推送状态。""" # 接口 2 / 4:读 push_status=0,按组在 SQL 侧截断。
#
# 连接由调用方从 rpa_oracle 池借入,仓库不 connect、不 close。
# 不向订单中心发 HTTP。mark_pushed 备好回写,本段不接推送。
from __future__ import annotations from __future__ import annotations
import os from rpa_oracle.errors import RepoUnavailable
from collections.abc import Callable
from ..inbound.oracle_repo import _ensure_client from ..inbound.oracle_repo import in_binds
from ..inbound.repo import RepoUnavailable
_LIST_UNPUSHED_SQL = """ _LIST_UNPUSHED_SQL = """
SELECT WITH src AS (
SELECT
fid, operate_type, oms_id, platform_type, supplier_name, bar_code, fid, operate_type, oms_id, platform_type, supplier_name, bar_code,
purchase_price, purchase_qty, purchase_total, status, fail_reason, purchase_price, purchase_qty, purchase_total, status, fail_reason,
pay_order_no, pay_order_dtl_id, org_code, push_status pay_order_no, pay_order_dtl_id, org_code, push_status
FROM rpa_cart_settlement_goods_data FROM rpa_cart_settlement_goods_data
WHERE push_status = 0 WHERE push_status = 0
ORDER BY oms_id, fid ),
ranked AS (
SELECT
src.*,
COUNT(*) OVER (PARTITION BY oms_id) AS grp_size
FROM src
),
grp AS (
SELECT oms_id, MIN(grp_size) AS grp_size
FROM ranked
GROUP BY oms_id
),
run AS (
SELECT
oms_id,
grp_size,
SUM(grp_size) OVER (ORDER BY oms_id) AS running_end,
SUM(grp_size) OVER (ORDER BY oms_id) - grp_size AS running_start
FROM grp
)
SELECT
r.fid, r.operate_type, r.oms_id, r.platform_type, r.supplier_name, r.bar_code,
r.purchase_price, r.purchase_qty, r.purchase_total, r.status, r.fail_reason,
r.pay_order_no, r.pay_order_dtl_id, r.org_code, r.push_status
FROM ranked r
JOIN run ON run.oms_id = r.oms_id
WHERE run.running_start = 0 OR run.running_end <= :limit
ORDER BY r.oms_id, r.fid
""" """
class OracleCartSettlementRepo:
def __init__(self, connect: Callable):
self._connect = connect
@classmethod
def from_env(cls) -> "OracleCartSettlementRepo":
user = os.getenv("RPA_ORACLE_USER", "")
password = os.getenv("RPA_ORACLE_PASSWORD", "")
host = os.getenv("RPA_ORACLE_HOST", "")
if not user or not password or not host:
raise RepoUnavailable("未配置 RPA_ORACLE_HOST / RPA_ORACLE_USER / RPA_ORACLE_PASSWORD")
port = os.getenv("RPA_ORACLE_PORT", "1521")
service = os.getenv("RPA_ORACLE_SERVICE", "rpa")
dsn = f"{host}:{port}/{service}"
def connect():
_ensure_client()
import oracledb
return oracledb.connect(user=user, password=password, dsn=dsn)
return cls(connect=connect)
def list_unpushed(self) -> list[dict]:
conn = self._connect()
try:
with conn.cursor() as cur:
cur.execute(_LIST_UNPUSHED_SQL)
cols = [item[0].lower() for item in cur.description]
return [dict(zip(cols, row)) for row in cur.fetchall()]
except Exception as exc:
raise RepoUnavailable(str(exc)) from exc
finally:
conn.close()
_LIST_AGREEMENT_SQL = """ _LIST_AGREEMENT_SQL = """
SELECT WITH src AS (
SELECT
fid, oms_id, action_code, contract_status, contract_fail_reason, fid, oms_id, action_code, contract_status, contract_fail_reason,
contract_no, agreement_status, agreement_fail_reason, contract_no, agreement_status, agreement_fail_reason,
agreement_id, agreement_dtl_id, org_code, push_status agreement_id, agreement_dtl_id, org_code, push_status,
FROM rpa_contract_agreement_result CASE
WHERE push_status = 0 WHEN TRIM(contract_no) IS NULL THEN 'F:' || TO_CHAR(fid)
ORDER BY fid ELSE 'N:' || TRIM(contract_no)
END AS grp_key
FROM rpa_contract_agreement_result
WHERE push_status = 0
),
ranked AS (
SELECT
src.*,
COUNT(*) OVER (PARTITION BY grp_key) AS grp_size
FROM src
),
grp AS (
SELECT grp_key, MIN(grp_size) AS grp_size, MIN(fid) AS grp_first_fid
FROM ranked
GROUP BY grp_key
),
run AS (
SELECT
grp_key,
grp_size,
SUM(grp_size) OVER (ORDER BY grp_first_fid, grp_key) AS running_end,
SUM(grp_size) OVER (ORDER BY grp_first_fid, grp_key) - grp_size AS running_start
FROM grp
)
SELECT
r.fid, r.oms_id, r.action_code, r.contract_status, r.contract_fail_reason,
r.contract_no, r.agreement_status, r.agreement_fail_reason,
r.agreement_id, r.agreement_dtl_id, r.org_code, r.push_status
FROM ranked r
JOIN run ON run.grp_key = r.grp_key
WHERE run.running_start = 0 OR run.running_end <= :limit
ORDER BY r.fid
""" """
_CART_TABLE = "rpa_cart_settlement_goods_data"
_AGREEMENT_TABLE = "rpa_contract_agreement_result"
# 接口 2 连 Oracle 的仓库,管表 2 未推取数和回写。
class OracleCartSettlementRepo:
# 挂上调用方借来的连接。不要在仓库里再 connect 或 close。
def __init__(self, conn):
self._conn = conn
# 捞 push_status=0,同一 oms_id 不拆组,在 SQL 侧按 limit 截断。
def list_unpushed(self, limit: int = 500) -> list[dict]:
return _fetch(self._conn, _LIST_UNPUSHED_SQL, {"limit": limit})
# 一次 UPDATE 回写表 2 的 push_status。空列表不访问库。
def mark_pushed(self, fids: list[int], status: int, reason: str | None) -> None:
_mark(self._conn, _CART_TABLE, fids, status, reason, "SYSDATE")
# 接口 4 连 Oracle 的仓库,管表 4 未推取数和回写。
class OracleContractAgreementRepo: class OracleContractAgreementRepo:
def __init__(self, connect: Callable): # 挂上调用方借来的连接。不要在仓库里再 connect 或 close。
self._connect = connect def __init__(self, conn):
self._conn = conn
@classmethod # 捞 push_status=0,同一非空 contract_no 不拆组,在 SQL 侧按 limit 截断。
def from_env(cls) -> "OracleContractAgreementRepo": def list_unpushed(self, limit: int = 500) -> list[dict]:
user = os.getenv("RPA_ORACLE_USER", "") return _fetch(self._conn, _LIST_AGREEMENT_SQL, {"limit": limit})
password = os.getenv("RPA_ORACLE_PASSWORD", "")
host = os.getenv("RPA_ORACLE_HOST", "")
if not user or not password or not host:
raise RepoUnavailable("未配置 RPA_ORACLE_HOST / RPA_ORACLE_USER / RPA_ORACLE_PASSWORD")
port = os.getenv("RPA_ORACLE_PORT", "1521")
service = os.getenv("RPA_ORACLE_SERVICE", "rpa")
dsn = f"{host}:{port}/{service}"
def connect(): # 一次 UPDATE 回写表 4 的 push_status。空列表不访问库。
_ensure_client() def mark_pushed(self, fids: list[int], status: int, reason: str | None) -> None:
import oracledb _mark(self._conn, _AGREEMENT_TABLE, fids, status, reason, "SYSTIMESTAMP")
return oracledb.connect(user=user, password=password, dsn=dsn)
return cls(connect=connect) # 执行查询并把列名转小写。失败抛「数据库不可用」,不含 ORA。
def _fetch(conn, sql: str, binds: dict) -> list[dict]:
def list_unpushed(self) -> list[dict]:
conn = self._connect()
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(_LIST_AGREEMENT_SQL) cur.execute(sql, binds)
cols = [item[0].lower() for item in cur.description] cols = [item[0].lower() for item in cur.description]
return [dict(zip(cols, row)) for row in cur.fetchall()] return [dict(zip(cols, row)) for row in cur.fetchall()]
except Exception as exc: except Exception as exc:
raise RepoUnavailable(str(exc)) from exc raise RepoUnavailable("数据库不可用") from exc
finally:
conn.close()
# 一次 UPDATE ... WHERE fid IN (...)。失败回滚并抛「数据库不可用」。
def _mark(
conn,
table: str,
fids: list[int],
status: int,
reason: str | None,
time_expr: str,
) -> None:
if not fids:
return
placeholders, binds = in_binds(fids)
binds["status"] = status
binds["reason"] = reason
sql = (
f"UPDATE {table} "
f"SET push_status = :status, push_result = :reason, "
f"push_time = {time_expr}, update_time = {time_expr} "
f"WHERE fid IN ({placeholders})"
)
try:
with conn.cursor() as cur:
cur.execute(sql, binds)
conn.commit()
except Exception as exc:
_rollback(conn)
raise RepoUnavailable("数据库不可用") from exc
# 回滚本连接已写入的行;回滚失败忽略。
def _rollback(conn) -> None:
try:
conn.rollback()
except Exception:
pass

129
rpa_oms/app/parse.py Normal file
View File

@ -0,0 +1,129 @@
# 入站和出站共用的字段解析。
#
# 入站把 camelCase JSON 收成落表值。缺字段、类型不对或超长时返回
# (None, 错误说明),调用方据此标 REJECTED。omsId / fid / 数量必须是 JSON
# 整数,不把 "10" 当 10。
#
# 出站读库行,用 as_int / as_str / money4 收成文档字段。金额是四位小数字符串。
from __future__ import annotations
from datetime import date, datetime
from decimal import Decimal, ROUND_HALF_UP, InvalidOperation
# 把接口字段收成去掉首尾空白的字符串,并检查是否超过 max_len。
#
# required=False 时,缺省或空白返回 (None, None)。
def parse_str(
value, field: str, max_len: int, *, required: bool = True
) -> tuple[str | None, str | None]:
if value is None or (isinstance(value, str) and not value.strip()):
if required:
return None, f"缺少必填字段 {field}"
return None, None
if not isinstance(value, str):
return None, f"{field} 必须是字符串"
text = value.strip()
if len(text) > max_len:
return None, f"{field} 超出长度"
return text, None
# 把接口字段收成 JSON 整数。布尔值不算整数。
def parse_int(value, field: str, *, required: bool = True) -> tuple[int | None, str | None]:
if value is None or value == "":
if required:
return None, f"缺少必填字段 {field}"
return None, None
if type(value) is int:
return value, None
return None, f"{field} 必须是整数"
# 把接口字段收成开区间 (0, max_exclusive) 内的 JSON 整数。
def parse_positive_int(
value, field: str, max_exclusive: int
) -> tuple[int | None, str | None]:
if value is None or value == "":
return None, f"缺少必填字段 {field}"
if type(value) is int and 0 < value < max_exclusive:
return value, None
return None, f"{field} 必须是整数"
# 把 omsId 收成正整数,上限跟表 1 NUMBER(20)。
def parse_oms_id(value) -> tuple[int | None, str | None]:
return parse_positive_int(value, "omsId", 10**20)
# 把接口 3 的 fid 收成正整数,上限跟表 3 NUMBER(19)。
def parse_fid(value) -> tuple[int | None, str | None]:
return parse_positive_int(value, "fid", 10**19)
# 把接口字段收成 Decimal。布尔值拒绝;字符串、int、float 可转。
def parse_decimal(value, field: str) -> tuple[Decimal | None, str | None]:
if value is None or value == "":
return None, f"缺少必填字段 {field}"
if type(value) is bool:
return None, f"{field} 必须是数字"
try:
if isinstance(value, Decimal):
amount = value
elif isinstance(value, str):
amount = Decimal(value.strip())
elif type(value) is int:
amount = Decimal(value)
elif isinstance(value, float):
amount = Decimal(str(value))
else:
return None, f"{field} 必须是数字"
except (InvalidOperation, ValueError):
return None, f"{field} 必须是数字"
return amount, None
# 把接口字段收成 date。接受 YYYY-MM-DD 字符串、date、datetime。
def parse_date(value, field: str) -> tuple[date | None, str | None]:
if value is None or value == "":
return None, f"缺少必填字段 {field}"
if isinstance(value, datetime):
return value.date(), None
if isinstance(value, date):
return value, None
if not isinstance(value, str):
return None, f"{field} 必须是 YYYY-MM-DD"
try:
return date.fromisoformat(value.strip()), None
except ValueError:
return None, f"{field} 必须是 YYYY-MM-DD"
# 出站整数。None 保持 None。JSON 整数走 parse_int,库里的 Decimal 交给 int。
def as_int(value) -> int | None:
if value is None:
return None
parsed, error = parse_int(value, "value", required=False)
if error is None and parsed is not None:
return parsed
return int(value)
# 出站字符串。None 和空白当成 None,其余转 str,不改非空原文。
def as_str(value) -> str | None:
if value is None:
return None
text = str(value)
if text.strip() == "":
return None
return text
# 金额四舍五入到四位小数,再格式成字符串。
def money4(value) -> str:
amount, error = parse_decimal(value, "amount")
if error is not None or amount is None:
raise ValueError(error or "缺少金额")
quantized = amount.quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
return f"{quantized:.4f}"

View File

@ -20,7 +20,7 @@ dev = [
package = false package = false
[tool.pytest.ini_options] [tool.pytest.ini_options]
pythonpath = ["."] pythonpath = [".", ".."]
testpaths = ["tests"] testpaths = ["tests"]
addopts = "-q" addopts = "-q"
filterwarnings = [ filterwarnings = [

View File

@ -0,0 +1,282 @@
# 入站单测用的假连接:录 SQL、按 oms_id / fid 存行,不连真库。
from __future__ import annotations
from contextlib import contextmanager
from types import SimpleNamespace
# 构造带 oracledb 风格 args[0].code / .message 的假异常。
def ora_error(code: int, message: str) -> Exception:
info = SimpleNamespace(code=code, message=message)
exc = Exception(message)
exc.args = (info,)
return exc
# 构造指定约束名的 ORA-00001。
def unique_error(constraint: str) -> Exception:
return ora_error(
1,
f"ORA-00001: unique constraint (RPAINIT.{constraint}) violated",
)
# oracledb cursor.getbatcherrors() 单项的替身。
class BatchError:
# 记下失败行在本批里的下标、报文和 ORA 码。
def __init__(self, offset: int, message: str, code: int = 1):
self.offset = offset
self.message = message
self.code = code
_CART_COLS = (
"fid",
"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",
"org_code",
"push_status",
)
_AGREEMENT_COLS = (
"fid",
"oms_id",
"action_code",
"contract_status",
"contract_fail_reason",
"contract_no",
"agreement_status",
"agreement_fail_reason",
"agreement_id",
"agreement_dtl_id",
"org_code",
"push_status",
)
# 一块假库:销售商品、采购合同、入站审计、表 2 / 表 4,外加 SQL 记录。
class FakeDb:
# 空库,默认不注入故障。
def __init__(self):
self.sales: dict[int, dict] = {}
self.contracts: dict[int, dict] = {}
self.carts: dict[int, dict] = {}
self.agreements: dict[int, dict] = {}
self.audits: list[dict] = []
self.calls: list[tuple] = []
self.commits = 0
self.rollbacks = 0
self.closes = 0
self.collide_sales: set[int] = set()
self.collide_contracts: set[int] = set()
self.collide_constraint: str | None = None
self.raise_execute: BaseException | None = None
# 新开一条假连接,读写同一块数据。
def connection(self) -> "FakeConn":
return FakeConn(self)
# 假 Oracle 连接:cursor / commit / rollback / close。
class FakeConn:
# 挂到同一块 FakeDb 上。
def __init__(self, db: FakeDb):
self.db = db
# 打开一条假游标。
def cursor(self) -> "FakeCursor":
return FakeCursor(self.db)
# 记下提交次数。
def commit(self) -> None:
self.db.commits += 1
# 记下回滚次数。
def rollback(self) -> None:
self.db.rollbacks += 1
# 记下归还次数。
def close(self) -> None:
self.db.closes += 1
# 按 SQL 关键字把 SELECT / INSERT / executemany 落到 FakeDb。
class FakeCursor:
# 空结果、无批错误。
def __init__(self, db: FakeDb):
self.db = db
self._rows: list[tuple] = []
self._batch_errors: list[BatchError] = []
self.description: tuple = ()
# with 语句返回自身。
def __enter__(self) -> "FakeCursor":
return self
# 不吞异常。
def __exit__(self, *args) -> bool:
return False
# 执行单条 SQL:查重、插入、审计,或表 2 / 表 4 取数回写。
def execute(self, sql: str, params=None) -> None:
self.db.calls.append(("execute", sql, params))
if self.db.raise_execute is not None:
raise self.db.raise_execute
self._dispatch(sql, params, offset=None, batcherrors=False)
# 按行插入;batcherrors=True 时把唯一冲突记进批错误。
def executemany(self, sql: str, seq_of_params, batcherrors: bool = False) -> None:
self.db.calls.append(("executemany", sql, seq_of_params, batcherrors))
if self.db.raise_execute is not None:
raise self.db.raise_execute
self._batch_errors = []
for offset, params in enumerate(seq_of_params):
try:
self._dispatch(sql, params, offset=offset, batcherrors=batcherrors)
except Exception as exc:
if batcherrors and _is_unique(exc):
self._batch_errors.append(
BatchError(offset, str(exc), code=1)
)
continue
raise
# 返回最近一次 SELECT 的行。
def fetchall(self) -> list[tuple]:
return list(self._rows)
# 返回最近一次 executemany 的批错误。
def getbatcherrors(self) -> list[BatchError]:
return list(self._batch_errors)
# 按表名把 SQL 分到销售、合同、审计、表 2 或表 4。
def _dispatch(self, sql: str, params, offset, batcherrors: bool) -> None:
norm = " ".join(sql.split()).upper()
binds = params or {}
if norm.startswith("SELECT") and "RPA_SALES_GOODS_MAPPING_DATA" in norm:
ids = list(binds.values()) if isinstance(binds, dict) else list(binds)
self._rows = [(oms_id,) for oms_id in ids if oms_id in self.db.sales]
return
if norm.startswith("SELECT") and "RPA_PURCHASE_CONTRACT_DATA" in norm:
ids = list(binds.values()) if isinstance(binds, dict) else list(binds)
self._rows = [(fid,) for fid in ids if fid in self.db.contracts]
return
if "RPA_OMS_INBOUND_AUDIT" in norm:
self.db.audits.append(dict(binds))
return
if "RPA_CART_SETTLEMENT_GOODS_DATA" in norm:
if "UPDATE" in norm:
self._update_pushed(self.db.carts, binds)
return
self._select_cart(sql, binds)
return
if "RPA_CONTRACT_AGREEMENT_RESULT" in norm:
if "UPDATE" in norm:
self._update_pushed(self.db.agreements, binds)
return
self._select_agreement(sql, binds)
return
if "RPA_SALES_GOODS_MAPPING_DATA" in norm:
self._insert_sales(binds)
return
if "RPA_PURCHASE_CONTRACT_DATA" in norm:
self._insert_contract(binds)
return
raise AssertionError(f"unexpected sql: {sql}")
# 捞表 2 push_status=0;SQL 带 PARTITION BY 且绑了 limit 时按 oms_id 整组截断。
def _select_cart(self, sql: str, binds: dict) -> None:
rows = [dict(row) for row in self.db.carts.values() if row.get("push_status", 0) == 0]
rows.sort(key=lambda row: (row["oms_id"], row["fid"]))
if "PARTITION BY" in sql.upper() and "limit" in binds:
from app.outbound.cart_settlement import _take_complete_groups
rows = _take_complete_groups(rows, int(binds["limit"]))
self.description = tuple((name.upper(),) for name in _CART_COLS)
self._rows = [tuple(row.get(name) for name in _CART_COLS) for row in rows]
# 捞表 4 push_status=0;SQL 带 PARTITION BY 且绑了 limit 时按合同号整组截断。
def _select_agreement(self, sql: str, binds: dict) -> None:
rows = [
dict(row) for row in self.db.agreements.values() if row.get("push_status", 0) == 0
]
rows.sort(key=lambda row: row["fid"])
if "PARTITION BY" in sql.upper() and "limit" in binds:
from app.outbound.contract_agreement import _take_complete_groups
rows = _take_complete_groups(rows, int(binds["limit"]))
self.description = tuple((name.upper(),) for name in _AGREEMENT_COLS)
self._rows = [tuple(row.get(name) for name in _AGREEMENT_COLS) for row in rows]
# 按 :id* 回写 push_status / push_result。
def _update_pushed(self, table: dict[int, dict], binds: dict) -> None:
status = binds.get("status")
reason = binds.get("reason")
fids = [value for key, value in binds.items() if str(key).startswith("id")]
for fid in fids:
row = table.get(fid)
if row is None:
continue
row["push_status"] = status
row["push_result"] = reason
# 按 oms_id 写入表 1 替身,冲突时抛 ORA-00001。
def _insert_sales(self, params: dict) -> None:
oms_id = params["oms_id"]
constraint = self.db.collide_constraint
if constraint and (oms_id in self.db.collide_sales or oms_id in self.db.sales):
raise unique_error(constraint)
if oms_id in self.db.sales or oms_id in self.db.collide_sales:
raise unique_error("UK_RPA_SGMD_OMS_ID")
row = dict(params)
row.setdefault("deal_flag", 0)
self.db.sales[oms_id] = row
# 按 fid 写入表 3 替身,冲突时抛 ORA-00001。
def _insert_contract(self, params: dict) -> None:
fid = params["fid"]
constraint = self.db.collide_constraint
if constraint and (fid in self.db.collide_contracts or fid in self.db.contracts):
raise unique_error(constraint)
if fid in self.db.contracts or fid in self.db.collide_contracts:
raise unique_error("PK_RPA_PCD")
row = dict(params)
row.setdefault("process_status", 0)
self.db.contracts[fid] = row
# 假异常是否为 ORA-00001。
def _is_unique(exc: BaseException) -> bool:
if not exc.args:
return False
info = exc.args[0]
return getattr(info, "code", None) == 1
# get_pool 替身:每次 acquire 都借同一块 FakeDb 上的新连接。
class FakePool:
# 默认自带一块空库。
def __init__(self, db: FakeDb | None = None):
self.db = db or FakeDb()
self.acquires = 0
# 借一条假连接,退出 with 时 close。
@contextmanager
def acquire(self):
self.acquires += 1
conn = FakeConn(self.db)
try:
yield conn
finally:
conn.close()

View File

@ -1,16 +1,20 @@
"""接口 2:从表 2 取 push_status=0,组装文档报文。不连 Oracle,不发 HTTP。""" # 接口 2:从表 2 取 push_status=0,组装文档报文。假连接录 SQL,不连真库,不发 HTTP。
from __future__ import annotations from __future__ import annotations
from decimal import Decimal from decimal import Decimal
from app.outbound.cart_settlement import ( from app.outbound.cart_settlement import (
MAX_BATCH, MAX_BATCH,
batch_limit,
build_cart_settlement_envelope, build_cart_settlement_envelope,
load_cart_settlement_envelope, load_cart_settlement_envelope,
) )
from app.outbound.memory_repo import MemoryCartSettlementRepo from app.outbound.oracle_repo import OracleCartSettlementRepo
from tests.fake_oracle import FakeDb
# 构造一条表 2 行,供组装层单测用。
def _row(**overrides): def _row(**overrides):
row = { row = {
"fid": 100001, "fid": 100001,
@ -34,8 +38,25 @@ def _row(**overrides):
return row return row
# 把表 2 行放进假库。list_unpushed 走真 SQL,调用记在 db.calls。
def _open(rows: list[dict]) -> tuple[OracleCartSettlementRepo, FakeDb]:
db = FakeDb()
for row in rows:
db.carts[row["fid"]] = row
return OracleCartSettlementRepo(db.connection()), db
# 取出最近一次 execute 的 SQL 和绑定。
def _last_execute(db: FakeDb) -> tuple[str, dict]:
execute_calls = [call for call in db.calls if call[0] == "execute"]
assert execute_calls
_, sql, params = execute_calls[-1]
return sql, params or {}
# 未推行按文档信封字段组装,金额四位小数字符串。
def test_assembles_unpushed_rows_as_document_envelope(): def test_assembles_unpushed_rows_as_document_envelope():
repo = MemoryCartSettlementRepo( repo, db = _open(
[ [
_row(), _row(),
_row( _row(
@ -85,10 +106,39 @@ def test_assembles_unpushed_rows_as_document_envelope():
}, },
] ]
} }
sql, params = _last_execute(db)
assert "rpa_cart_settlement_goods_data" in sql.lower()
assert "push_status = 0" in sql.lower()
assert params["limit"] == 500
# 失败行金额为 NULL 时该行置空,同批其它行照常组装。
def test_null_amounts_do_not_fail_the_batch():
repo, _db = _open(
[
_row(
fid=1,
status=2,
purchase_price=None,
purchase_total=None,
fail_reason="库存不足",
),
_row(fid=2, oms_id=20260928000002),
]
)
envelope = load_cart_settlement_envelope(repo)
failed, ok = envelope["data"]
assert failed["status"] == 2
assert failed["purchasePrice"] is None
assert failed["purchaseTotal"] is None
assert failed["failReason"] == "库存不足"
assert ok["purchasePrice"] == "128.5000"
assert ok["purchaseTotal"] == "1285.0000"
# push_status=1 的行不进报文。
def test_skips_already_pushed_rows(): def test_skips_already_pushed_rows():
repo = MemoryCartSettlementRepo( repo, db = _open(
[ [
_row(fid=1, push_status=1), _row(fid=1, push_status=1),
_row(fid=2, oms_id=20260928000002, push_status=0), _row(fid=2, oms_id=20260928000002, push_status=0),
@ -96,8 +146,11 @@ def test_skips_already_pushed_rows():
) )
envelope = load_cart_settlement_envelope(repo) envelope = load_cart_settlement_envelope(repo)
assert [item["fid"] for item in envelope["data"]] == [2] assert [item["fid"] for item in envelope["data"]] == [2]
sql, _params = _last_execute(db)
assert "push_status = 0" in sql.lower()
# 同一 omsId 的行必须落在同一批 data。
def test_keeps_same_oms_id_in_one_envelope(): def test_keeps_same_oms_id_in_one_envelope():
rows = [ rows = [
_row(fid=1, oms_id=1, platform_type=48, multi_platform_flag=1), _row(fid=1, oms_id=1, platform_type=48, multi_platform_flag=1),
@ -109,6 +162,7 @@ def test_keeps_same_oms_id_in_one_envelope():
assert fids == [1, 2] assert fids == [1, 2]
# 批次已有行时,装不下的 omsId 整组留下。
def test_does_not_split_oms_id_group_across_batch_limit(): def test_does_not_split_oms_id_group_across_batch_limit():
rows = [ rows = [
_row(fid=1, oms_id=1, platform_type=48), _row(fid=1, oms_id=1, platform_type=48),
@ -119,6 +173,7 @@ def test_does_not_split_oms_id_group_across_batch_limit():
assert [item["fid"] for item in envelope["data"]] == [1] assert [item["fid"] for item in envelope["data"]] == [1]
# 默认上限 500;超过则按组截到 500。
def test_batch_cap_is_500(): def test_batch_cap_is_500():
assert MAX_BATCH == 500 assert MAX_BATCH == 500
rows = [_row(fid=i, oms_id=i) for i in range(1, 502)] rows = [_row(fid=i, oms_id=i) for i in range(1, 502)]
@ -128,6 +183,7 @@ def test_batch_cap_is_500():
assert envelope["data"][-1]["fid"] == 500 assert envelope["data"][-1]["fid"] == 500
# 报文不含 push_status 等表内列。
def test_payload_omits_table_only_columns(): def test_payload_omits_table_only_columns():
envelope = build_cart_settlement_envelope([_row()]) envelope = build_cart_settlement_envelope([_row()])
item = envelope["data"][0] item = envelope["data"][0]
@ -135,3 +191,47 @@ def test_payload_omits_table_only_columns():
assert "push_status" not in item assert "push_status" not in item
assert "multiPlatformFlag" not in item assert "multiPlatformFlag" not in item
assert "createTime" not in item assert "createTime" not in item
# 组装入口把 limit 传给仓库,让 SQL 侧截组。
def test_load_passes_limit_to_list_unpushed():
seen: dict = {}
# 只记 list_unpushed 收到的 limit。
class SpyRepo:
# 记下上限并返回空列表。
def list_unpushed(self, limit: int = 500) -> list[dict]:
seen["limit"] = limit
return []
load_cart_settlement_envelope(SpyRepo(), limit=3)
assert seen["limit"] == 3
# OMS_OUTBOUND_BATCH_LIMIT 覆盖默认 500。
def test_batch_limit_reads_env(monkeypatch):
monkeypatch.setenv("OMS_OUTBOUND_BATCH_LIMIT", "3")
assert batch_limit() == 3
# 非法或非正数上限回退 500。
def test_batch_limit_invalid_falls_back_to_500(monkeypatch):
monkeypatch.setenv("OMS_OUTBOUND_BATCH_LIMIT", "abc")
assert batch_limit() == 500
monkeypatch.setenv("OMS_OUTBOUND_BATCH_LIMIT", "0")
assert batch_limit() == 500
# 未显式传 limit 时按环境变量截组。
def test_load_uses_env_batch_limit(monkeypatch):
monkeypatch.setenv("OMS_OUTBOUND_BATCH_LIMIT", "1")
repo, db = _open(
[
_row(fid=1, oms_id=1),
_row(fid=2, oms_id=2),
]
)
envelope = load_cart_settlement_envelope(repo)
assert [item["fid"] for item in envelope["data"]] == [1]
_sql, params = _last_execute(db)
assert params["limit"] == 1

View File

@ -1,14 +1,18 @@
"""接口 4:从表 4 取 push_status=0,组装文档报文。不连 Oracle,不发 HTTP。""" # 接口 4:从表 4 取 push_status=0,组装文档报文。假连接录 SQL,不连真库,不发 HTTP。
from __future__ import annotations from __future__ import annotations
from app.outbound.contract_agreement import ( from app.outbound.contract_agreement import (
MAX_BATCH, MAX_BATCH,
batch_limit,
build_contract_agreement_envelope, build_contract_agreement_envelope,
load_contract_agreement_envelope, load_contract_agreement_envelope,
) )
from app.outbound.memory_repo import MemoryContractAgreementRepo from app.outbound.oracle_repo import OracleContractAgreementRepo
from tests.fake_oracle import FakeDb
# 构造一条表 4 行,供组装层单测用。
def _row(**overrides): def _row(**overrides):
row = { row = {
"fid": 300001, "fid": 300001,
@ -28,8 +32,25 @@ def _row(**overrides):
return row return row
# 把表 4 行放进假库。list_unpushed 走真 SQL,调用记在 db.calls。
def _open(rows: list[dict]) -> tuple[OracleContractAgreementRepo, FakeDb]:
db = FakeDb()
for row in rows:
db.agreements[row["fid"]] = row
return OracleContractAgreementRepo(db.connection()), db
# 取出最近一次 execute 的 SQL 和绑定。
def _last_execute(db: FakeDb) -> tuple[str, dict]:
execute_calls = [call for call in db.calls if call[0] == "execute"]
assert execute_calls
_, sql, params = execute_calls[-1]
return sql, params or {}
# 未推行按文档信封字段组装;已推行跳过。
def test_assembles_unpushed_rows_as_document_envelope(): def test_assembles_unpushed_rows_as_document_envelope():
repo = MemoryContractAgreementRepo( repo, db = _open(
[ [
_row(), _row(),
_row( _row(
@ -76,10 +97,15 @@ def test_assembles_unpushed_rows_as_document_envelope():
] ]
} }
assert isinstance(envelope["data"][0]["omsId"], int) assert isinstance(envelope["data"][0]["omsId"], int)
sql, params = _last_execute(db)
assert "rpa_contract_agreement_result" in sql.lower()
assert "push_status = 0" in sql.lower()
assert params["limit"] == 500
# 合同成功行和协议行可以同批。
def test_assembles_contract_success_and_agreement_rows(): def test_assembles_contract_success_and_agreement_rows():
repo = MemoryContractAgreementRepo( repo, db = _open(
[ [
_row(contract_status=2), _row(contract_status=2),
_row( _row(
@ -100,8 +126,11 @@ def test_assembles_contract_success_and_agreement_rows():
assert envelope["data"][1]["agreementStatus"] == 1 assert envelope["data"][1]["agreementStatus"] == 1
assert envelope["data"][1]["agreementId"] == "AGR1" assert envelope["data"][1]["agreementId"] == "AGR1"
assert envelope["data"][1]["agreementDtlId"] == "AGRD1" assert envelope["data"][1]["agreementDtlId"] == "AGRD1"
sql, _params = _last_execute(db)
assert "rpa_contract_agreement_result" in sql.lower()
# 空批时超限的同一 contractNo 仍整组进入。
def test_oversized_contract_no_group_enters_when_batch_empty(): def test_oversized_contract_no_group_enters_when_batch_empty():
rows = [ rows = [
_row(fid=1, oms_id=1, contract_no="HT-BIG"), _row(fid=1, oms_id=1, contract_no="HT-BIG"),
@ -112,6 +141,7 @@ def test_oversized_contract_no_group_enters_when_batch_empty():
assert [item["fid"] for item in envelope["data"]] == [1, 2, 3] assert [item["fid"] for item in envelope["data"]] == [1, 2, 3]
# 批次已有行时,装不下的 contractNo 整组留下。
def test_does_not_split_contract_no_when_batch_already_has_rows(): def test_does_not_split_contract_no_when_batch_already_has_rows():
rows = [ rows = [
_row(fid=1, oms_id=1, contract_no="HT-A"), _row(fid=1, oms_id=1, contract_no="HT-A"),
@ -122,6 +152,7 @@ def test_does_not_split_contract_no_when_batch_already_has_rows():
assert [item["fid"] for item in envelope["data"]] == [1] assert [item["fid"] for item in envelope["data"]] == [1]
# 空 contractNo 按行独立,互不捆绑。
def test_empty_contract_no_rows_are_independent(): def test_empty_contract_no_rows_are_independent():
rows = [ rows = [
_row(fid=1, oms_id=1, contract_no=None, contract_status=3), _row(fid=1, oms_id=1, contract_no=None, contract_status=3),
@ -132,11 +163,34 @@ def test_empty_contract_no_rows_are_independent():
assert [item["fid"] for item in envelope["data"]] == [1] assert [item["fid"] for item in envelope["data"]] == [1]
# 默认上限 500。
def test_batch_cap_is_500(): def test_batch_cap_is_500():
assert MAX_BATCH == 500 assert MAX_BATCH == 500
# 报文不含 push_status。
def test_payload_omits_push_status(): def test_payload_omits_push_status():
item = build_contract_agreement_envelope([_row()])["data"][0] item = build_contract_agreement_envelope([_row()])["data"][0]
assert "pushStatus" not in item assert "pushStatus" not in item
assert "push_status" not in item assert "push_status" not in item
# 组装入口把 limit 传给仓库,让 SQL 侧截组。
def test_load_passes_limit_to_list_unpushed():
seen: dict = {}
# 只记 list_unpushed 收到的 limit。
class SpyRepo:
# 记下上限并返回空列表。
def list_unpushed(self, limit: int = 500) -> list[dict]:
seen["limit"] = limit
return []
load_contract_agreement_envelope(SpyRepo(), limit=3)
assert seen["limit"] == 3
# OMS_OUTBOUND_BATCH_LIMIT 覆盖默认 500。
def test_batch_limit_reads_env(monkeypatch):
monkeypatch.setenv("OMS_OUTBOUND_BATCH_LIMIT", "3")
assert batch_limit() == 3

View File

@ -1,11 +1,13 @@
"""本机 .env 只补缺,不覆盖已有环境变量。""" # 本机 .env 只补缺,不覆盖已有非空环境变量。
from __future__ import annotations from __future__ import annotations
import os import os
from app.local_env import load_local_env from rpa_oracle.env import load_env as load_local_env
# 缺的键从文件补上,已有非空环境变量保持不变。
def test_load_local_env_fills_missing_and_keeps_existing(tmp_path, monkeypatch): def test_load_local_env_fills_missing_and_keeps_existing(tmp_path, monkeypatch):
monkeypatch.setenv("OMS_REPO", "memory") monkeypatch.setenv("OMS_REPO", "memory")
monkeypatch.delenv("RPA_ORACLE_PASSWORD", raising=False) monkeypatch.delenv("RPA_ORACLE_PASSWORD", raising=False)

View File

@ -0,0 +1,86 @@
# 接口 4 Oracle 取数:SQL 按非空 contractNo 整组截断;一次 UPDATE 回写。不连真库。
from __future__ import annotations
import pytest
from app.inbound.repo import RepoUnavailable
from app.outbound.oracle_repo import _LIST_AGREEMENT_SQL, OracleContractAgreementRepo
from tests.fake_oracle import FakeDb, ora_error
# 构造一条表 4 未推行。
def _agreement(**overrides) -> dict:
row = {
"fid": 1,
"oms_id": 1,
"action_code": 1,
"contract_status": 1,
"contract_fail_reason": None,
"contract_no": "HT1",
"agreement_status": None,
"agreement_fail_reason": None,
"agreement_id": None,
"agreement_dtl_id": None,
"org_code": "901",
"push_status": 0,
}
row.update(overrides)
return row
# 取数 SQL 只捞 push_status=0,按非空合同号分组,并用 :limit 在库侧截断。
def test_list_sql_filters_unpushed_and_partitions_by_contract_no():
sql = " ".join(_LIST_AGREEMENT_SQL.split()).lower()
assert "rpa_contract_agreement_result" in sql
assert "push_status = 0" in sql
assert "partition by" in sql
assert "contract_no" in sql
assert ":limit" in sql
assert "running_start" in sql
# limit=3 时同一非空 contractNo 三行整组进批,多出的另一组留下。
def test_list_unpushed_keeps_whole_contract_no_group_when_limit_is_3():
db = FakeDb()
db.agreements[1] = _agreement(fid=1, oms_id=1, contract_no="HT-A")
db.agreements[2] = _agreement(fid=2, oms_id=2, contract_no="HT-A")
db.agreements[3] = _agreement(fid=3, oms_id=3, contract_no="HT-A")
db.agreements[4] = _agreement(fid=4, oms_id=4, contract_no="HT-B")
repo = OracleContractAgreementRepo(db.connection())
rows = repo.list_unpushed(limit=3)
assert [row["fid"] for row in rows] == [1, 2, 3]
execute_calls = [call for call in db.calls if call[0] == "execute"]
sql, params = execute_calls[-1][1], execute_calls[-1][2]
assert "partition by" in sql.lower()
assert params["limit"] == 3
# 库故障对外固定「数据库不可用」,不含 ORA 原文。
def test_list_unpushed_hides_ora_text():
db = FakeDb()
db.raise_execute = ora_error(12541, "ORA-12541: TNS:no listener")
repo = OracleContractAgreementRepo(db.connection())
with pytest.raises(RepoUnavailable) as caught:
repo.list_unpushed(limit=500)
assert caught.value.message == "数据库不可用"
assert "ORA-" not in str(caught.value)
# 回写 push_status 只发一条 UPDATE ... WHERE fid IN (...)。
def test_mark_pushed_updates_in_one_statement():
db = FakeDb()
db.agreements[1] = _agreement(fid=1)
db.agreements[2] = _agreement(fid=2)
repo = OracleContractAgreementRepo(db.connection())
repo.mark_pushed([1, 2], status=1, reason="ok")
updates = [
call for call in db.calls if call[0] == "execute" and "UPDATE" in call[1].upper()
]
assert len(updates) == 1
sql = updates[0][1].upper()
assert "RPA_CONTRACT_AGREEMENT_RESULT" in sql
assert "FID IN" in sql
assert db.agreements[1]["push_status"] == 1
assert db.agreements[2]["push_result"] == "ok"
assert db.commits == 1

View File

@ -1,77 +1,136 @@
"""接口 2 Oracle 取数:只捞 push_status=0。不连真库。""" # 接口 2 Oracle 取数:SQL 按 oms_id 整组截断;一次 UPDATE 回写。不连真库。
from __future__ import annotations from __future__ import annotations
import pytest
from app.inbound.repo import RepoUnavailable
from app.outbound.oracle_repo import _LIST_UNPUSHED_SQL, OracleCartSettlementRepo from app.outbound.oracle_repo import _LIST_UNPUSHED_SQL, OracleCartSettlementRepo
from tests.fake_oracle import FakeDb, ora_error
def test_list_sql_filters_unpushed_and_orders_by_oms_id(): # 构造一条表 2 未推行。
sql = " ".join(_LIST_UNPUSHED_SQL.split()) def _cart(**overrides) -> dict:
assert "rpa_cart_settlement_goods_data" in sql.lower() row = {
"fid": 1,
"operate_type": 1,
"oms_id": 1,
"platform_type": 48,
"supplier_name": "示例供应商",
"bar_code": "06901234567890",
"purchase_price": 128.5,
"purchase_qty": 10,
"purchase_total": 1285,
"status": 1,
"fail_reason": None,
"pay_order_no": None,
"pay_order_dtl_id": None,
"org_code": "901",
"push_status": 0,
}
row.update(overrides)
return row
# 取数 SQL 只捞 push_status=0,按 oms_id 分组,并用 :limit 在库侧截断。
def test_list_sql_filters_unpushed_and_partitions_by_oms_id():
sql = " ".join(_LIST_UNPUSHED_SQL.split()).lower()
assert "rpa_cart_settlement_goods_data" in sql
assert "push_status = 0" in sql assert "push_status = 0" in sql
assert "order by oms_id" in sql.lower() assert "partition by" in sql
assert "fid" in sql.lower() assert "oms_id" in sql
assert ":limit" in sql
assert "running_start" in sql
assert "order by" in sql and "oms_id" in sql
# 假连接返回的列名转小写后能对上组装层字段。
def test_list_unpushed_maps_columns(): def test_list_unpushed_maps_columns():
class FakeCursor: db = FakeDb()
description = ( db.carts[100001] = _cart(
("FID",), fid=100001,
("OPERATE_TYPE",), oms_id=20260928000001,
("OMS_ID",), bar_code="06901234567890",
("PLATFORM_TYPE",),
("SUPPLIER_NAME",),
("BAR_CODE",),
("PURCHASE_PRICE",),
("PURCHASE_QTY",),
("PURCHASE_TOTAL",),
("STATUS",),
("FAIL_REASON",),
("PAY_ORDER_NO",),
("PAY_ORDER_DTL_ID",),
("ORG_CODE",),
("PUSH_STATUS",),
) )
repo = OracleCartSettlementRepo(db.connection())
def __enter__(self): rows = repo.list_unpushed(limit=500)
return self
def __exit__(self, *args):
return False
def execute(self, sql, params=None):
self.sql = sql
def fetchall(self):
return [
(
100001,
1,
20260928000001,
48,
"示例供应商",
"06901234567890",
128.5,
10,
1285,
1,
None,
None,
None,
"901",
0,
)
]
class FakeConn:
def cursor(self):
return FakeCursor()
def close(self):
pass
repo = OracleCartSettlementRepo(connect=lambda: FakeConn())
rows = repo.list_unpushed()
assert rows[0]["fid"] == 100001 assert rows[0]["fid"] == 100001
assert rows[0]["operate_type"] == 1 assert rows[0]["operate_type"] == 1
assert rows[0]["bar_code"] == "06901234567890" assert rows[0]["bar_code"] == "06901234567890"
assert rows[0]["push_status"] == 0 assert rows[0]["push_status"] == 0
assert db.closes == 0
# limit=3 时同一 omsId 三行整组进批,多出的另一组留下。
def test_list_unpushed_keeps_whole_oms_group_when_limit_is_3():
db = FakeDb()
db.carts[1] = _cart(fid=1, oms_id=1)
db.carts[2] = _cart(fid=2, oms_id=1)
db.carts[3] = _cart(fid=3, oms_id=1)
db.carts[4] = _cart(fid=4, oms_id=2)
repo = OracleCartSettlementRepo(db.connection())
rows = repo.list_unpushed(limit=3)
assert [row["fid"] for row in rows] == [1, 2, 3]
sql, params = _last_execute(db)
assert "partition by" in sql.lower()
assert params["limit"] == 3
# 库故障对外固定「数据库不可用」,不含 ORA 原文。
def test_list_unpushed_hides_ora_text():
db = FakeDb()
db.raise_execute = ora_error(12541, "ORA-12541: TNS:no listener")
repo = OracleCartSettlementRepo(db.connection())
with pytest.raises(RepoUnavailable) as caught:
repo.list_unpushed(limit=500)
assert caught.value.message == "数据库不可用"
assert "ORA-" not in str(caught.value)
# 回写 push_status 只发一条 UPDATE ... WHERE fid IN (...)。
def test_mark_pushed_updates_in_one_statement():
db = FakeDb()
db.carts[1] = _cart(fid=1)
db.carts[2] = _cart(fid=2)
repo = OracleCartSettlementRepo(db.connection())
repo.mark_pushed([1, 2], status=1, reason="ok")
updates = [
call for call in db.calls if call[0] == "execute" and "UPDATE" in call[1].upper()
]
assert len(updates) == 1
sql = updates[0][1].upper()
assert "RPA_CART_SETTLEMENT_GOODS_DATA" in sql
assert "FID IN" in sql
assert db.carts[1]["push_status"] == 1
assert db.carts[2]["push_status"] == 1
assert db.carts[1]["push_result"] == "ok"
assert db.commits == 1
# 空 fid 列表不发 SQL、不 commit。
def test_mark_pushed_empty_fids_does_not_touch_db():
db = FakeDb()
repo = OracleCartSettlementRepo(db.connection())
repo.mark_pushed([], status=1, reason=None)
assert db.calls == []
assert db.commits == 0
# 回写失败对外固定「数据库不可用」。
def test_mark_pushed_hides_ora_text():
db = FakeDb()
db.carts[1] = _cart(fid=1)
db.raise_execute = ora_error(3113, "ORA-03113: end-of-file on communication channel")
repo = OracleCartSettlementRepo(db.connection())
with pytest.raises(RepoUnavailable) as caught:
repo.mark_pushed([1], status=2, reason="fail")
assert caught.value.message == "数据库不可用"
assert "ORA-" not in str(caught.value)
# 取出最近一次 execute 的 SQL 和绑定。
def _last_execute(db: FakeDb) -> tuple[str, dict]:
execute_calls = [call for call in db.calls if call[0] == "execute"]
assert execute_calls
_, sql, params = execute_calls[-1]
return sql, params or {}

View File

@ -1,4 +1,5 @@
"""接口 3 Oracle 仓库:INSERT 绑 fid;ORA-00001 映射成 DuplicateFid。不连真库。""" # 接口 3 Oracle 仓库:INSERT 绑 fid;按主键认重复;一批 executemany。不连真库。
from __future__ import annotations from __future__ import annotations
from datetime import date from datetime import date
@ -6,11 +7,13 @@ from decimal import Decimal
import pytest import pytest
from app.inbound.repo import DuplicateFid, RepoUnavailable from app.inbound.repo import RepoUnavailable
from tests.fake_oracle import FakeDb
def _record(): # 构造一条表 3 落库记录。
return { def _record(**overrides):
row = {
"fid": 200001, "fid": 200001,
"oms_id": 20260928000001, "oms_id": 20260928000001,
"supplier_id": "SUP001", "supplier_id": "SUP001",
@ -40,51 +43,61 @@ def _record():
"supplier_mdm_code": "DW0100046361", "supplier_mdm_code": "DW0100046361",
"process_status": 0, "process_status": 0,
} }
row.update(overrides)
return row
# 缺 host 时 get_pool 抛「数据库不可用」。
def test_from_env_without_host_raises(monkeypatch): def test_from_env_without_host_raises(monkeypatch):
monkeypatch.delenv("RPA_ORACLE_HOST", raising=False) monkeypatch.delenv("RPA_ORACLE_HOST", raising=False)
from app.inbound.oracle_contract_repo import OraclePurchaseContractRepo import app.main as main
with pytest.raises(RepoUnavailable): main._pool = None
OraclePurchaseContractRepo.from_env() with pytest.raises(RepoUnavailable) as caught:
main.get_pool()
assert caught.value.message == "数据库不可用"
# pk_rpa_pcd 冲突时 insert_many 把该 fid 放进重复集合。
def test_insert_unique_violation_is_duplicate_fid(): def test_insert_unique_violation_is_duplicate_fid():
from app.inbound.oracle_contract_repo import OraclePurchaseContractRepo from app.inbound.oracle_contract_repo import OraclePurchaseContractRepo
class FakeError(Exception): db = FakeDb()
def __init__(self): db.collide_contracts.add(200001)
self.args = (type("Info", (), {"code": 1})(),) repo = OraclePurchaseContractRepo(db.connection())
collided = repo.insert_many([_record()])
class FakeCursor: assert collided == {200001}
def __enter__(self):
return self
def __exit__(self, *args):
return False
def execute(self, sql, params=None):
raise FakeError()
class FakeConn:
def cursor(self):
return FakeCursor()
def commit(self):
raise AssertionError("unique 冲突不应 commit")
def rollback(self):
self.rolled_back = True
def close(self):
pass
repo = OraclePurchaseContractRepo(connect=lambda: FakeConn())
with pytest.raises(DuplicateFid):
repo.insert(_record())
# 未知唯一约束不当成 fid 重复,抛「数据库不可用」。
def test_unknown_unique_constraint_raises_unavailable():
from app.inbound.oracle_contract_repo import OraclePurchaseContractRepo
db = FakeDb()
db.collide_contracts.add(200001)
db.collide_constraint = "UK_SOMETHING_ELSE"
repo = OraclePurchaseContractRepo(db.connection())
with pytest.raises(RepoUnavailable) as caught:
repo.insert_many([_record()])
assert caught.value.message == "数据库不可用"
# 待插入多行时一次 executemany,且 batcherrors=True。
def test_insert_many_uses_executemany():
from app.inbound.oracle_contract_repo import OraclePurchaseContractRepo
db = FakeDb()
repo = OraclePurchaseContractRepo(db.connection())
assert repo.existing_fids([1, 2]) == set()
collided = repo.insert_many([_record(fid=1, oms_id=1), _record(fid=2, oms_id=2)])
assert collided == set()
assert set(db.contracts) == {1, 2}
many = [c for c in db.calls if c[0] == "executemany"]
assert len(many) == 1
assert many[0][3] is True
# INSERT 绑来源 fid,process_status 用字面量 0。
def test_insert_sql_binds_source_fid_and_pending_status(): def test_insert_sql_binds_source_fid_and_pending_status():
from app.inbound.oracle_contract_repo import _BIND_KEYS, _INSERT_SQL from app.inbound.oracle_contract_repo import _BIND_KEYS, _INSERT_SQL

View File

@ -1,20 +1,19 @@
"""Oracle 仓库:缺配置 503;ORA-00001 映射成 DuplicateOmsId。不连真库。""" # 接口 1 Oracle 仓库:缺配置 503;按约束名认重复;一批 executemany。不连真库。
from __future__ import annotations from __future__ import annotations
import os from datetime import date
from decimal import Decimal
import pytest import pytest
os.environ.setdefault("OMS_INBOUND_TOKEN", "test-oms-token") from app.inbound.repo import RepoUnavailable
from tests.fake_oracle import FakeDb, ora_error
from app.inbound.repo import DuplicateOmsId, RepoUnavailable
def _record(): # 构造一条表 1 落库记录。
from datetime import date def _record(**overrides):
from decimal import Decimal row = {
return {
"oms_id": 2026092801, "oms_id": 2026092801,
"bar_code": "06901234567890", "bar_code": "06901234567890",
"order_date": date(2026, 9, 28), "order_date": date(2026, 9, 28),
@ -34,52 +33,109 @@ def _record():
"customer_mdm_code": "DW0100079727", "customer_mdm_code": "DW0100079727",
"deal_flag": 0, "deal_flag": 0,
} }
row.update(overrides)
return row
# 并发首个请求只建一个池。
def test_get_pool_creates_once_under_concurrency(monkeypatch):
import threading
import time
import app.main as main
created: list[object] = []
# 拖住建池,让多个线程同时穿过「还没有池」的窗口。
def fake_create_pool(*, role: str = "oms"):
time.sleep(0.05)
pool = object()
created.append(pool)
return pool
monkeypatch.setattr("rpa_oracle.pool.create_pool", fake_create_pool)
main._pool = None
barrier = threading.Barrier(8)
errors: list[BaseException] = []
# 同时打 get_pool。
def worker() -> None:
try:
barrier.wait()
main.get_pool()
except BaseException as exc:
errors.append(exc)
threads = [threading.Thread(target=worker) for _ in range(8)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
try:
assert errors == []
assert len(created) == 1
assert main._pool is created[0]
finally:
main._pool = None
# 缺用户时 get_pool 抛「数据库不可用」,文案不含缺项名单。
def test_from_env_without_user_raises(monkeypatch): def test_from_env_without_user_raises(monkeypatch):
monkeypatch.delenv("RPA_ORACLE_USER", raising=False) monkeypatch.delenv("RPA_ORACLE_USER", raising=False)
monkeypatch.delenv("RPA_ORACLE_PASSWORD", raising=False) monkeypatch.delenv("RPA_ORACLE_PASSWORD", raising=False)
from app.inbound.oracle_repo import OracleSalesGoodsRepo import app.main as main
with pytest.raises(RepoUnavailable): main._pool = None
OracleSalesGoodsRepo.from_env() with pytest.raises(RepoUnavailable) as caught:
main.get_pool()
assert caught.value.message == "数据库不可用"
assert "RPA_ORACLE" not in str(caught.value)
# uk_rpa_sgmd_oms_id 冲突时 insert_many 把该 oms_id 放进重复集合。
def test_insert_unique_violation_is_duplicate(): def test_insert_unique_violation_is_duplicate():
from app.inbound.oracle_repo import OracleSalesGoodsRepo from app.inbound.oracle_repo import OracleSalesGoodsRepo
class FakeError(Exception): db = FakeDb()
def __init__(self): db.collide_sales.add(2026092801)
self.args = (type("Info", (), {"code": 1})(),) repo = OracleSalesGoodsRepo(db.connection())
collided = repo.insert_many([_record()])
class FakeCursor: assert collided == {2026092801}
def __enter__(self):
return self
def __exit__(self, *args):
return False
def execute(self, sql, params=None):
raise FakeError()
class FakeConn:
def cursor(self):
return FakeCursor()
def commit(self):
raise AssertionError("unique 冲突不应 commit")
def rollback(self):
self.rolled_back = True
def close(self):
pass
repo = OracleSalesGoodsRepo(connect=lambda: FakeConn())
with pytest.raises(DuplicateOmsId):
repo.insert(_record())
# 未知唯一约束不当成 oms_id 重复,抛「数据库不可用」。
def test_unknown_unique_constraint_raises_unavailable():
from app.inbound.oracle_repo import OracleSalesGoodsRepo
db = FakeDb()
db.collide_sales.add(2026092801)
db.collide_constraint = "UK_SOMETHING_ELSE"
repo = OracleSalesGoodsRepo(db.connection())
with pytest.raises(RepoUnavailable) as caught:
repo.insert_many([_record()])
assert caught.value.message == "数据库不可用"
# 待插入多行时先 SELECT IN 再一次 executemany。
def test_insert_many_uses_executemany_and_select_in():
from app.inbound.oracle_repo import OracleSalesGoodsRepo
db = FakeDb()
conn = db.connection()
repo = OracleSalesGoodsRepo(conn)
existing = repo.existing_oms_ids([1, 2])
assert existing == set()
collided = repo.insert_many([_record(oms_id=1), _record(oms_id=2)])
assert collided == set()
assert set(db.sales) == {1, 2}
selects = [c for c in db.calls if c[0] == "execute" and "SELECT" in c[1].upper()]
assert any("IN" in c[1].upper() for c in selects)
many = [c for c in db.calls if c[0] == "executemany"]
assert len(many) == 1
assert many[0][3] is True
# INSERT 仍包含选品班要读的列。
def test_insert_sql_includes_select_job_columns(): def test_insert_sql_includes_select_job_columns():
from app.inbound.oracle_repo import _BIND_KEYS, _INSERT_SQL from app.inbound.oracle_repo import _BIND_KEYS, _INSERT_SQL
@ -108,6 +164,7 @@ def test_insert_sql_includes_select_job_columns():
assert "deal_flag" in sql assert "deal_flag" in sql
# 创建人/更新人固定 0 / 系统管理员,时间用 SYSDATE。
def test_insert_sql_sets_system_auditor(): def test_insert_sql_sets_system_auditor():
from app.inbound.oracle_repo import _INSERT_SQL from app.inbound.oracle_repo import _INSERT_SQL
@ -119,3 +176,16 @@ def test_insert_sql_sets_system_auditor():
assert "update_user_name" in sql assert "update_user_name" in sql
assert "SYSDATE, 0, '系统管理员'" in sql assert "SYSDATE, 0, '系统管理员'" in sql
assert sql.count("SYSDATE") == 2 assert sql.count("SYSDATE") == 2
# 非唯一约束的 ORA 对外仍是「数据库不可用」,不带原文。
def test_other_ora_error_message_is_fixed():
from app.inbound.oracle_repo import OracleSalesGoodsRepo
db = FakeDb()
db.raise_execute = ora_error(12170, "ORA-12170: TNS:Connect timeout occurred")
repo = OracleSalesGoodsRepo(db.connection())
with pytest.raises(RepoUnavailable) as caught:
repo.existing_oms_ids([1])
assert caught.value.message == "数据库不可用"
assert "ORA-" not in str(caught.value)

View File

@ -1,4 +1,5 @@
"""接口 3 收件契约。内存仓库,不连 Oracle。按对方 fid 幂等。""" # 接口 3 收件契约。假连接录 SQL,不连 Oracle。按对方 fid 幂等。
from __future__ import annotations from __future__ import annotations
import os import os
@ -8,14 +9,14 @@ from fastapi.testclient import TestClient
os.environ["OMS_INBOUND_TOKEN"] = "test-oms-token" os.environ["OMS_INBOUND_TOKEN"] = "test-oms-token"
from app.inbound.memory_repo import MemoryPurchaseContractRepo from app.main import app, get_pool
from app.inbound.repo import RepoUnavailable from tests.fake_oracle import FakePool, ora_error
from app.main import app, get_contract_repo
TOKEN = "test-oms-token" TOKEN = "test-oms-token"
PATH = "/api/oms/v1/purchase-contracts" PATH = "/api/oms/v1/purchase-contracts"
# 构造一条合法的接口 3 明细,overrides 覆盖字段。
def _item(**overrides): def _item(**overrides):
row = { row = {
"fid": 200001, "fid": 200001,
@ -50,44 +51,51 @@ def _item(**overrides):
return row return row
# 注入 FakePool,整次请求走同一块假库。
@pytest.fixture @pytest.fixture
def client(): def client():
repo = MemoryPurchaseContractRepo() pool = FakePool()
app.dependency_overrides[get_contract_repo] = lambda: repo app.dependency_overrides[get_pool] = lambda: pool
with TestClient(app) as test_client: with TestClient(app) as test_client:
yield test_client, repo yield test_client, pool
app.dependency_overrides.clear() app.dependency_overrides.clear()
# 带或不带 X-Oms-Token 发 POST。
def _post(client, body, token=TOKEN): def _post(client, body, token=TOKEN):
headers = {"X-Oms-Token": token} if token is not None else {} headers = {"X-Oms-Token": token} if token is not None else {}
return client.post(PATH, json=body, headers=headers) return client.post(PATH, json=body, headers=headers)
# 缺 X-Oms-Token 时接口返回 401。
def test_missing_token_returns_401(client): def test_missing_token_returns_401(client):
test_client, _ = client test_client, pool = client
resp = _post(test_client, {"data": [_item()]}, token=None) resp = _post(test_client, {"data": [_item()]}, token=None)
assert resp.status_code == 401 assert resp.status_code == 401
assert resp.json()["results"] == [] assert resp.json()["results"] == []
assert pool.db.audits
# 缺 data 节点时整包 422,一条都不落。
def test_missing_data_returns_422(client): def test_missing_data_returns_422(client):
test_client, repo = client test_client, pool = client
resp = _post(test_client, {}) resp = _post(test_client, {})
assert resp.status_code == 422 assert resp.status_code == 422
assert repo.count() == 0 assert pool.db.contracts == {}
# 超过 100 条时整包 422,假库仍空。
def test_over_100_rows_returns_422(client): def test_over_100_rows_returns_422(client):
test_client, repo = client test_client, pool = client
rows = [_item(fid=i, omsId=i) for i in range(1, 102)] rows = [_item(fid=i, omsId=i) for i in range(1, 102)]
resp = _post(test_client, {"data": rows}) resp = _post(test_client, {"data": rows})
assert resp.status_code == 422 assert resp.status_code == 422
assert repo.count() == 0 assert pool.db.contracts == {}
# 合法一行 ACCEPTED,process_status=0,fid 原样落库。
def test_accepts_valid_row(client): def test_accepts_valid_row(client):
test_client, repo = client test_client, pool = client
resp = _post(test_client, {"data": [_item()]}) resp = _post(test_client, {"data": [_item()]})
assert resp.status_code == 200 assert resp.status_code == 200
body = resp.json() body = resp.json()
@ -99,7 +107,7 @@ def test_accepts_valid_row(client):
"message": "采购合同数据已接收,等待处理", "message": "采购合同数据已接收,等待处理",
} }
] ]
stored = repo.get(200001) stored = pool.db.contracts[200001]
assert stored["process_status"] == 0 assert stored["process_status"] == 0
assert stored["oms_id"] == 20260928000001 assert stored["oms_id"] == 20260928000001
assert stored["supplier_id"] == "SUP001" assert stored["supplier_id"] == "SUP001"
@ -108,19 +116,21 @@ def test_accepts_valid_row(client):
assert stored["contract_no"] is None assert stored["contract_no"] is None
# 跨请求同 fid:DUPLICATE,库里品名不改。
def test_existing_fid_is_duplicate_and_not_updated(client): def test_existing_fid_is_duplicate_and_not_updated(client):
test_client, repo = client test_client, pool = client
_post(test_client, {"data": [_item()]}) _post(test_client, {"data": [_item()]})
resp = _post(test_client, {"data": [_item(goodsName="不应覆盖")]}) resp = _post(test_client, {"data": [_item(goodsName="不应覆盖")]})
assert resp.status_code == 200 assert resp.status_code == 200
result = resp.json()["results"][0] result = resp.json()["results"][0]
assert result["resultCode"] == "DUPLICATE" assert result["resultCode"] == "DUPLICATE"
assert result["message"] == "序号已存在,未重复入库" assert result["message"] == "序号已存在,未重复入库"
assert repo.get(200001)["goods_name"] == "阿莫西林胶囊" assert pool.db.contracts[200001]["goods_name"] == "阿莫西林胶囊"
# 同一 POST 里两条相同 fid:后一条 CONFLICT,只落第一条。
def test_duplicate_fid_in_same_batch_is_conflict(client): def test_duplicate_fid_in_same_batch_is_conflict(client):
test_client, repo = client test_client, pool = client
resp = _post( resp = _post(
test_client, test_client,
{"data": [_item(), _item(omsId=20260928000002)]}, {"data": [_item(), _item(omsId=20260928000002)]},
@ -130,63 +140,69 @@ def test_duplicate_fid_in_same_batch_is_conflict(client):
assert results[0]["resultCode"] == "ACCEPTED" assert results[0]["resultCode"] == "ACCEPTED"
assert results[1]["resultCode"] == "CONFLICT" assert results[1]["resultCode"] == "CONFLICT"
assert results[1]["fid"] == 200001 assert results[1]["fid"] == 200001
assert repo.count() == 1 assert len(pool.db.contracts) == 1
# contractEntryStatus=0 却没有合同号时 REJECTED。
def test_retry_protocol_without_contract_no_is_rejected(client): def test_retry_protocol_without_contract_no_is_rejected(client):
test_client, repo = client test_client, pool = client
resp = _post(test_client, {"data": [_item(contractEntryStatus=0, contractNo=None)]}) resp = _post(test_client, {"data": [_item(contractEntryStatus=0, contractNo=None)]})
assert resp.status_code == 200 assert resp.status_code == 200
result = resp.json()["results"][0] result = resp.json()["results"][0]
assert result["resultCode"] == "REJECTED" assert result["resultCode"] == "REJECTED"
assert "contractNo" in result["message"] assert "contractNo" in result["message"]
assert repo.count() == 0 assert pool.db.contracts == {}
# contractEntryStatus=0 且带合同号时 ACCEPTED。
def test_retry_protocol_with_contract_no_is_accepted(client): def test_retry_protocol_with_contract_no_is_accepted(client):
test_client, repo = client test_client, pool = client
resp = _post( resp = _post(
test_client, test_client,
{"data": [_item(fid=200002, contractEntryStatus=0, contractNo="HT20260928002")]}, {"data": [_item(fid=200002, contractEntryStatus=0, contractNo="HT20260928002")]},
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["results"][0]["resultCode"] == "ACCEPTED" assert resp.json()["results"][0]["resultCode"] == "ACCEPTED"
stored = repo.get(200002) stored = pool.db.contracts[200002]
assert stored["contract_entry_status"] == 0 assert stored["contract_entry_status"] == 0
assert stored["contract_no"] == "HT20260928002" assert stored["contract_no"] == "HT20260928002"
# 结束日早于开始日时 REJECTED。
def test_end_date_before_start_is_rejected(client): def test_end_date_before_start_is_rejected(client):
test_client, repo = client test_client, pool = client
resp = _post( resp = _post(
test_client, test_client,
{"data": [_item(contractStartDate="2027-09-30", contractEndDate="2026-10-01")]}, {"data": [_item(contractStartDate="2027-09-30", contractEndDate="2026-10-01")]},
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["results"][0]["resultCode"] == "REJECTED" assert resp.json()["results"][0]["resultCode"] == "REJECTED"
assert repo.count() == 0 assert pool.db.contracts == {}
# JSON 字符串 omsId 不转整数,标 REJECTED。
def test_string_oms_id_is_rejected(client): def test_string_oms_id_is_rejected(client):
test_client, repo = client test_client, pool = client
resp = _post(test_client, {"data": [_item(omsId="20260928000001")]}) resp = _post(test_client, {"data": [_item(omsId="20260928000001")]})
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["results"][0]["resultCode"] == "REJECTED" assert resp.json()["results"][0]["resultCode"] == "REJECTED"
assert repo.count() == 0 assert pool.db.contracts == {}
# 缺货品 MDM 码 REJECTED,不入库。
def test_missing_mdm_is_rejected_and_not_stored(client): def test_missing_mdm_is_rejected_and_not_stored(client):
test_client, repo = client test_client, pool = client
resp = _post(test_client, {"data": [_item(goodsMdmCode="")]}) resp = _post(test_client, {"data": [_item(goodsMdmCode="")]})
assert resp.status_code == 200 assert resp.status_code == 200
result = resp.json()["results"][0] result = resp.json()["results"][0]
assert result["resultCode"] == "REJECTED" assert result["resultCode"] == "REJECTED"
assert "goodsMdmCode" in result["message"] assert "goodsMdmCode" in result["message"]
assert repo.count() == 0 assert pool.db.contracts == {}
# 混批 DUPLICATE + ACCEPTED + REJECTED 时 HTTP 仍 200。
def test_mixed_batch_still_http_200(client): def test_mixed_batch_still_http_200(client):
test_client, repo = client test_client, pool = client
_post(test_client, {"data": [_item(fid=1, omsId=1)]}) _post(test_client, {"data": [_item(fid=1, omsId=1)]})
resp = _post( resp = _post(
test_client, test_client,
@ -201,18 +217,34 @@ def test_mixed_batch_still_http_200(client):
assert resp.status_code == 200 assert resp.status_code == 200
codes = [r["resultCode"] for r in resp.json()["results"]] codes = [r["resultCode"] for r in resp.json()["results"]]
assert codes == ["DUPLICATE", "ACCEPTED", "REJECTED"] assert codes == ["DUPLICATE", "ACCEPTED", "REJECTED"]
assert repo.count() == 2 assert set(pool.db.contracts) == {1, 2}
# 库故障时 HTTP 503,body 不含 ORA。
def test_repo_unavailable_returns_503(client): def test_repo_unavailable_returns_503(client):
test_client, _ = client test_client, pool = client
pool.db.raise_execute = ora_error(
class Boom: 12170,
def insert(self, record): "ORA-12170: TNS:Connect timeout occurred at rpa_purchase_contract_data",
raise RepoUnavailable("数据库不可用") )
app.dependency_overrides[get_contract_repo] = lambda: Boom()
resp = _post(test_client, {"data": [_item()]}) resp = _post(test_client, {"data": [_item()]})
assert resp.status_code == 503 assert resp.status_code == 503
assert resp.json()["code"] == 503 assert resp.json()["code"] == 503
assert resp.json()["msg"] == "数据库不可用"
assert resp.json()["results"] == [] assert resp.json()["results"] == []
assert "ORA-" not in resp.text
assert "rpa_purchase_contract_data" not in resp.text
# 一批两条合法明细只从池里借一次连接。
def test_batch_two_rows_acquires_once(client):
test_client, pool = client
resp = _post(
test_client,
{"data": [_item(fid=1, omsId=1), _item(fid=2, omsId=2)]},
)
assert resp.status_code == 200
assert [r["resultCode"] for r in resp.json()["results"]] == ["ACCEPTED", "ACCEPTED"]
assert pool.acquires == 1
many = [c for c in pool.db.calls if c[0] == "executemany"]
assert len(many) == 1

View File

@ -1,4 +1,5 @@
"""接口 1 收件契约。内存仓库,不连 Oracle。""" # 接口 1 收件契约。假连接录 SQL,不连 Oracle。
from __future__ import annotations from __future__ import annotations
import os import os
@ -8,14 +9,14 @@ from fastapi.testclient import TestClient
os.environ["OMS_INBOUND_TOKEN"] = "test-oms-token" os.environ["OMS_INBOUND_TOKEN"] = "test-oms-token"
from app.inbound.memory_repo import MemorySalesGoodsRepo from app.main import app, get_pool
from app.inbound.repo import RepoUnavailable from tests.fake_oracle import FakePool, ora_error
from app.main import app, get_repo
TOKEN = "test-oms-token" TOKEN = "test-oms-token"
PATH = "/api/oms/v1/sales-goods" PATH = "/api/oms/v1/sales-goods"
# 构造一条合法的接口 1 明细,overrides 覆盖字段。
def _item(**overrides): def _item(**overrides):
row = { row = {
"omsId": 2026092801, "omsId": 2026092801,
@ -40,61 +41,72 @@ def _item(**overrides):
return row return row
# 注入 FakePool,整次请求走同一块假库。
@pytest.fixture @pytest.fixture
def client(): def client():
repo = MemorySalesGoodsRepo() pool = FakePool()
app.dependency_overrides[get_repo] = lambda: repo app.dependency_overrides[get_pool] = lambda: pool
with TestClient(app) as test_client: with TestClient(app) as test_client:
yield test_client, repo yield test_client, pool
app.dependency_overrides.clear() app.dependency_overrides.clear()
# 带或不带 X-Oms-Token 发 POST。
def _post(client, body, token=TOKEN): def _post(client, body, token=TOKEN):
headers = {"X-Oms-Token": token} if token is not None else {} headers = {"X-Oms-Token": token} if token is not None else {}
return client.post(PATH, json=body, headers=headers) return client.post(PATH, json=body, headers=headers)
# 缺 X-Oms-Token 时接口返回 401。
def test_missing_token_returns_401(client): def test_missing_token_returns_401(client):
test_client, _ = client test_client, pool = client
resp = _post(test_client, {"data": [_item()]}, token=None) resp = _post(test_client, {"data": [_item()]}, token=None)
assert resp.status_code == 401 assert resp.status_code == 401
body = resp.json() body = resp.json()
assert body["code"] == 401 assert body["code"] == 401
assert body["msg"] == "身份认证失败"
assert body["results"] == [] assert body["results"] == []
assert pool.db.audits
# 错误 token 时接口返回 401。
def test_wrong_token_returns_401(client): def test_wrong_token_returns_401(client):
test_client, _ = client test_client, _pool = client
resp = _post(test_client, {"data": [_item()]}, token="bad") resp = _post(test_client, {"data": [_item()]}, token="bad")
assert resp.status_code == 401 assert resp.status_code == 401
assert resp.json()["code"] == 401 assert resp.json()["code"] == 401
# 缺 data 节点时整包 422,一条都不落。
def test_missing_data_returns_422(client): def test_missing_data_returns_422(client):
test_client, _ = client test_client, pool = client
resp = _post(test_client, {}) resp = _post(test_client, {})
assert resp.status_code == 422 assert resp.status_code == 422
assert resp.json()["code"] == 422 assert resp.json()["code"] == 422
assert resp.json()["results"] == [] assert resp.json()["results"] == []
assert pool.db.sales == {}
# data 空数组时整包 422。
def test_empty_data_returns_422(client): def test_empty_data_returns_422(client):
test_client, _ = client test_client, _pool = client
resp = _post(test_client, {"data": []}) resp = _post(test_client, {"data": []})
assert resp.status_code == 422 assert resp.status_code == 422
assert resp.json()["code"] == 422 assert resp.json()["code"] == 422
# 超过 100 条时整包 422,假库仍空。
def test_over_100_rows_returns_422(client): def test_over_100_rows_returns_422(client):
test_client, repo = client test_client, pool = client
rows = [_item(omsId=i) for i in range(1, 102)] rows = [_item(omsId=i) for i in range(1, 102)]
resp = _post(test_client, {"data": rows}) resp = _post(test_client, {"data": rows})
assert resp.status_code == 422 assert resp.status_code == 422
assert repo.count() == 0 assert pool.db.sales == {}
# 合法一行 ACCEPTED,deal_flag=0,条码和 MDM 按 snake_case 落库。
def test_accepts_valid_row(client): def test_accepts_valid_row(client):
test_client, repo = client test_client, pool = client
resp = _post(test_client, {"data": [_item()]}) resp = _post(test_client, {"data": [_item()]})
assert resp.status_code == 200 assert resp.status_code == 200
body = resp.json() body = resp.json()
@ -106,26 +118,26 @@ def test_accepts_valid_row(client):
"message": "接收成功,等待RPA处理", "message": "接收成功,等待RPA处理",
} }
] ]
stored = repo.get(2026092801) stored = pool.db.sales[2026092801]
assert stored["deal_flag"] == 0 assert stored["deal_flag"] == 0
assert stored["bar_code"] == "06901234567890" assert stored["bar_code"] == "06901234567890"
assert stored["goods_mdm_code"] == "QX0100167583" assert stored["goods_mdm_code"] == "QX0100167583"
# 跨请求同 omsId:DUPLICATE,库里品名不改。
def test_existing_oms_id_is_duplicate_and_not_updated(client): def test_existing_oms_id_is_duplicate_and_not_updated(client):
# 跨请求同 omsId:DUPLICATE,库里品名不改。 test_client, pool = client
test_client, repo = client
_post(test_client, {"data": [_item()]}) _post(test_client, {"data": [_item()]})
resp = _post(test_client, {"data": [_item(goodsName="不应覆盖")]}) resp = _post(test_client, {"data": [_item(goodsName="不应覆盖")]})
assert resp.status_code == 200 assert resp.status_code == 200
result = resp.json()["results"][0] result = resp.json()["results"][0]
assert result["resultCode"] == "DUPLICATE" assert result["resultCode"] == "DUPLICATE"
assert repo.get(2026092801)["goods_name"] == "阿莫西林胶囊" assert pool.db.sales[2026092801]["goods_name"] == "阿莫西林胶囊"
# 同一 POST 里两条相同 omsId:后一条 CONFLICT,只落第一条。
def test_duplicate_oms_id_in_same_batch_is_conflict(client): def test_duplicate_oms_id_in_same_batch_is_conflict(client):
# 同一 POST 里两条相同 omsId:后一条 CONFLICT,只落第一条。 test_client, pool = client
test_client, repo = client
resp = _post( resp = _post(
test_client, test_client,
{"data": [_item(), _item(barCode="06901234567891")]}, {"data": [_item(), _item(barCode="06901234567891")]},
@ -135,21 +147,23 @@ def test_duplicate_oms_id_in_same_batch_is_conflict(client):
assert results[0]["resultCode"] == "ACCEPTED" assert results[0]["resultCode"] == "ACCEPTED"
assert results[1]["resultCode"] == "CONFLICT" assert results[1]["resultCode"] == "CONFLICT"
assert results[1]["omsId"] == 2026092801 assert results[1]["omsId"] == 2026092801
assert repo.count() == 1 assert len(pool.db.sales) == 1
# 缺 MDM 码 REJECTED,不入库。
def test_missing_mdm_is_rejected_and_not_stored(client): def test_missing_mdm_is_rejected_and_not_stored(client):
test_client, repo = client test_client, pool = client
resp = _post(test_client, {"data": [_item(goodsMdmCode="")]}) resp = _post(test_client, {"data": [_item(goodsMdmCode="")]})
assert resp.status_code == 200 assert resp.status_code == 200
result = resp.json()["results"][0] result = resp.json()["results"][0]
assert result["resultCode"] == "REJECTED" assert result["resultCode"] == "REJECTED"
assert "goodsMdmCode" in result["message"] assert "goodsMdmCode" in result["message"]
assert repo.count() == 0 assert pool.db.sales == {}
# 混批 DUPLICATE + ACCEPTED + REJECTED 时 HTTP 仍 200。
def test_mixed_batch_still_http_200(client): def test_mixed_batch_still_http_200(client):
test_client, repo = client test_client, pool = client
_post(test_client, {"data": [_item(omsId=1)]}) _post(test_client, {"data": [_item(omsId=1)]})
resp = _post( resp = _post(
test_client, test_client,
@ -164,27 +178,72 @@ def test_mixed_batch_still_http_200(client):
assert resp.status_code == 200 assert resp.status_code == 200
codes = [r["resultCode"] for r in resp.json()["results"]] codes = [r["resultCode"] for r in resp.json()["results"]]
assert codes == ["DUPLICATE", "ACCEPTED", "REJECTED"] assert codes == ["DUPLICATE", "ACCEPTED", "REJECTED"]
assert repo.count() == 2 assert set(pool.db.sales) == {1, 2}
# JSON 字符串 omsId 不转整数,标 REJECTED。
def test_string_oms_id_is_rejected(client): def test_string_oms_id_is_rejected(client):
# JSON 字符串 "2026092801" 不转整数。 test_client, pool = client
test_client, repo = client
resp = _post(test_client, {"data": [_item(omsId="2026092801")]}) resp = _post(test_client, {"data": [_item(omsId="2026092801")]})
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["results"][0]["resultCode"] == "REJECTED" assert resp.json()["results"][0]["resultCode"] == "REJECTED"
assert repo.count() == 0 assert pool.db.sales == {}
def test_repo_unavailable_returns_503(client): # 库故障时 HTTP 503,body 固定「数据库不可用」,不含 ORA 和表名。
test_client, _ = client def test_repo_unavailable_returns_503_without_ora(client):
test_client, pool = client
class Boom: pool.db.raise_execute = ora_error(
def insert(self, record): 12170,
raise RepoUnavailable("数据库不可用") "ORA-12170: TNS:Connect timeout occurred at rpa_sales_goods_mapping_data",
)
app.dependency_overrides[get_repo] = lambda: Boom()
resp = _post(test_client, {"data": [_item()]}) resp = _post(test_client, {"data": [_item()]})
assert resp.status_code == 503 assert resp.status_code == 503
assert resp.json()["code"] == 503 body = resp.json()
assert resp.json()["results"] == [] assert body["code"] == 503
assert body["msg"] == "数据库不可用"
assert body["results"] == []
text = resp.text
assert "ORA-" not in text
assert "rpa_sales_goods_mapping_data" not in text
# 一批两条合法明细只从池里借一次连接。
def test_batch_two_rows_acquires_once(client):
test_client, pool = client
resp = _post(
test_client,
{"data": [_item(omsId=1), _item(omsId=2)]},
)
assert resp.status_code == 200
assert [r["resultCode"] for r in resp.json()["results"]] == ["ACCEPTED", "ACCEPTED"]
assert pool.acquires == 1
many = [c for c in pool.db.calls if c[0] == "executemany"]
assert len(many) == 1
# 并发撞上未知唯一约束时不回 DUPLICATE,整包 503。
def test_unknown_unique_constraint_is_not_duplicate(client):
test_client, pool = client
pool.db.collide_sales.add(2026092801)
pool.db.collide_constraint = "UK_SOMETHING_ELSE"
resp = _post(test_client, {"data": [_item()]})
assert resp.status_code == 503
assert resp.json()["msg"] == "数据库不可用"
assert "DUPLICATE" not in resp.text
# 成功收件写审计一行,入参出参 JSON 里没有 token 明文。
def test_success_audit_masks_token(client):
test_client, pool = client
resp = _post(test_client, {"data": [_item()]})
assert resp.status_code == 200
assert len(pool.db.audits) == 1
audit = pool.db.audits[0]
blob = " ".join(str(v) for v in audit.values())
assert TOKEN not in blob
assert audit["token_masked"] == "***"
assert audit["api_no"] == 1
assert audit["http_code"] == 200
audit_sql = [c for c in pool.db.calls if c[0] == "execute" and "RPA_OMS_INBOUND_AUDIT" in " ".join(c[1].split()).upper()]
assert audit_sql

35
rpa_oracle/__init__.py Normal file
View File

@ -0,0 +1,35 @@
# RPA 共用 Oracle 连接层:读 .env、连接池、ORA 码、班次故障审计。
#
# 各班次和订单中心仓库从这里取连接、认约束名。
# 业务表 SQL 仍留在各仓库。班次故障原文只进 rpa_shift_error_audit。
from .audit import ShiftError, save_shift_errors
from .env import job_env_files, load_env, load_job_env
from .errors import (
RepoUnavailable,
is_contract_fid_duplicate,
is_sales_oms_id_duplicate,
ora_code,
ora_constraint,
)
from .chunked_update import FID_CHUNK_SIZE, update_by_fid_chunks
from .pool import OraclePool, create_pool, job_pool, pool_limits
__all__ = [
"FID_CHUNK_SIZE",
"OraclePool",
"RepoUnavailable",
"ShiftError",
"create_pool",
"is_contract_fid_duplicate",
"is_sales_oms_id_duplicate",
"job_env_files",
"job_pool",
"load_env",
"load_job_env",
"ora_code",
"ora_constraint",
"pool_limits",
"save_shift_errors",
"update_by_fid_chunks",
]

67
rpa_oracle/audit.py Normal file
View File

@ -0,0 +1,67 @@
# 加购、合同、返利的未捕获异常原文。
#
# 短码仍写在业务原因列。本表单独提交,写失败只记日志。
from __future__ import annotations
import logging
from dataclasses import dataclass
logger = logging.getLogger("rpa_oracle.audit")
_ERROR_MAX = 4000
_INSERT_SQL = """
INSERT INTO rpa_shift_error_audit (
shift_name, biz_key, error_code, error_text
) VALUES (
:shift_name, :biz_key, :error_code, :error_text
)
"""
# 一条班次故障审计。shift_name 是加购、合同或返利。
@dataclass(frozen=True)
class ShiftError:
shift_name: str
biz_key: str
error_code: str
error_text: str
# 独立事务写入 rpa_shift_error_audit。空列表不借连接。失败只记日志。
def save_shift_errors(pool, items: list[ShiftError]) -> None:
if not items:
return
binds = [_bind(item) for item in items]
try:
with pool.acquire() as conn:
try:
with conn.cursor() as cur:
cur.executemany(_INSERT_SQL, binds)
conn.commit()
except Exception:
_rollback(conn)
logger.exception("班次故障审计写入失败")
except Exception:
logger.exception("班次故障审计写入失败")
# 收成插入 bind。原文截到 VARCHAR2(4000)。不绑 fid,留给触发器。
def _bind(item: ShiftError) -> dict[str, str]:
text = item.error_text or ""
if len(text) > _ERROR_MAX:
text = text[:_ERROR_MAX]
return {
"shift_name": item.shift_name,
"biz_key": item.biz_key,
"error_code": item.error_code,
"error_text": text,
}
# 回滚当前事务。回滚本身失败则忽略。
def _rollback(conn) -> None:
try:
conn.rollback()
except Exception:
pass

View File

@ -0,0 +1,39 @@
"""领取用的分段 UPDATE。
每段 500 个 fid,避开 Oracle 一条 IN 最多 1000 个表达式。
调用方持有事务:本模块不提交。任一段失败由调用方整笔回滚。
"""
from __future__ import annotations
FID_CHUNK_SIZE = 500
def update_by_fid_chunks(
cur,
sql_template: str,
fids: list[int],
binds: dict | None = None,
) -> None:
"""按每段 500 个 fid 执行同一条 UPDATE。
sql_template 用 {placeholders} 放本段占位符。各段在当前游标上顺序执行,不提交。
空列表不执行。任一段抛错时前面各段已执行但未提交,调用方应整笔回滚。
"""
if not fids:
return
extra = dict(binds or {})
for start in range(0, len(fids), FID_CHUNK_SIZE):
chunk = list(fids[start : start + FID_CHUNK_SIZE])
placeholders, fid_binds = _fid_placeholders(chunk)
cur.execute(
sql_template.format(placeholders=placeholders),
{**extra, **fid_binds},
)
def _fid_placeholders(fids: list[int]) -> tuple[str, dict[str, int]]:
"""把本段 fid 编成 :id0, :id1 占位和 bind。段内从 0 编号。"""
fid_binds = {f"id{index}": value for index, value in enumerate(fids)}
placeholders = ", ".join(f":id{index}" for index in range(len(fids)))
return placeholders, fid_binds

53
rpa_oracle/env.py Normal file
View File

@ -0,0 +1,53 @@
# 把本机 .env 补进进程环境,不覆盖已经有值的变量。
from __future__ import annotations
import os
from pathlib import Path
# 按顺序读 .env,只填当前为空的变量。
#
# 文件不存在则跳过。已有非空环境变量不覆盖。值两边成对的单双引号会去掉。
# 多个路径时,先读到的非空值留下。
def load_env(*paths: Path) -> None:
for path in paths:
_fill_env(path)
# 该班次要读的 .env:本包,加购再加选品,最后 rpa_oms。
def job_env_files(package_dir: Path) -> tuple[Path, ...]:
repo = package_dir.resolve().parent
files = [package_dir / ".env"]
if package_dir.name == "rpa_cart":
files.append(repo / "rpa_selection" / ".env")
files.append(repo / "rpa_oms" / ".env")
return tuple(files)
# 先读班次 .env,空缺再补后面的文件。
#
# 默认顺序见 job_env_files。paths 给定时只读这一列,供测试注入。
# 已有非空环境变量不覆盖。
def load_job_env(package_dir: Path, paths: list[Path] | None = None) -> None:
load_env(*(paths if paths is not None else job_env_files(package_dir)))
# 把单个 .env 里尚未占用的键写进 os.environ。
def _fill_env(path: Path) -> None:
if not path.is_file():
return
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
value = value[1:-1]
if not key or not value:
continue
current = os.environ.get(key, "")
if not current:
os.environ[key] = value

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