From 586db4be901be7b1330bdfaef1cd6b5c16ae9ef7 Mon Sep 17 00:00:00 2001 From: Yuuuu <1569752928@qq.com> Date: Sat, 24 Oct 2026 02:41:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=8F=AD=E6=AC=A1=E5=8F=AF=E9=87=8D?= =?UTF-8?q?=E8=B7=91=EF=BC=8C=E9=81=BF=E5=85=8D=E5=AF=B9=E6=96=B9=E5=8D=95?= =?UTF-8?q?=E6=8D=AE=E9=87=8D=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 领取 UPDATE 按 500 个 fid 分段,同一事务,任一段失败整单回滚。选品一行一落并续租,系统故障改回待领。页面和商城调用期间归还连接。坏连接丢弃后再借一次,未提交语句只重试一次。 加购登录或查询失败放回待领。传入购物车读数且已够量则不再加车。合同按「我的合同」三态:没有则起草,唯一则复用合同号,多条保持处理中。返利送审前按合同号查已有总单,读到则补写不再新建;读号失败保持处理中。 同一次提交带上本分支尚未入库的 Oracle 连接层(rpa_oracle / rpa_browser)及各班次改接。 --- .gitignore | 1 + AGENTS.md | 97 +- README.md | 14 +- docs/README.md | 6 +- docs/RPA订单中心接口开发口径_20260930.md | 5 +- docs/ddl/rpa_oms_staging_tables.sql | 119 + docs/ddl/列对照.md | 3 +- .../2026-10-24-batch-runtime-checklist.md | 105 + ...10-02-selection-split-allocation-design.md | 23 +- rpa_browser/__init__.py | 26 + rpa_browser/adapter.py | 879 +++ rpa_cart/README.md | 51 +- rpa_cart/cart_add.py | 5 +- rpa_cart/cart_job.py | 153 +- rpa_cart/cart_match.py | 37 +- rpa_cart/cart_repo.py | 389 +- rpa_cart/cart_search.py | 21 +- rpa_cart/cart_yaoex.py | 8 +- rpa_cart/cart_ybm.py | 7 +- rpa_cart/db.py | 46 - rpa_cart/job.py | 6 +- rpa_cart/main.py | 125 +- rpa_cart/parse_xls.py | 7 +- rpa_cart/pyproject.toml | 2 +- rpa_cart/repo.py | 92 +- rpa_cart/selection_path.py | 10 + rpa_cart/settle.py | 9 +- rpa_cart/tests/test_cart_job.py | 252 +- rpa_cart/tests/test_cart_match.py | 10 +- rpa_cart/tests/test_cart_repo.py | 331 +- rpa_cart/tests/test_main.py | 98 +- rpa_cart/tests/test_repo.py | 113 +- rpa_cart/yaoex_client.py | 7 +- rpa_cart/yaoex_login.py | 41 +- rpa_contract/README.md | 80 +- rpa_contract/accept.py | 29 +- rpa_contract/apa_browser_adapter.py | 652 +- rpa_contract/contract_common.py | 606 ++ rpa_contract/contract_create_node.py | 75 +- rpa_contract/contract_draft.py | 15 +- rpa_contract/contract_flow_steps.py | 2639 +-------- rpa_contract/contract_form.py | 294 + rpa_contract/contract_goods.py | 605 ++ rpa_contract/contract_login.py | 91 + rpa_contract/contract_number.py | 606 ++ rpa_contract/contract_submit.py | 363 ++ rpa_contract/contract_upload.py | 197 + rpa_contract/db.py | 73 - rpa_contract/errors.py | 9 +- rpa_contract/group.py | 45 +- rpa_contract/job.py | 56 +- rpa_contract/main.py | 2 + rpa_contract/pdf_utils.py | 10 +- rpa_contract/persist.py | 35 +- rpa_contract/pyproject.toml | 2 +- rpa_contract/repo.py | 422 +- rpa_contract/run_batch.py | 154 +- .../tests/test_apa_browser_adapter.py | 69 + rpa_contract/tests/test_credentials.py | 63 + rpa_contract/tests/test_group.py | 23 + rpa_contract/tests/test_job.py | 298 +- rpa_contract/tests/test_oracle_repo.py | 332 ++ rpa_oms/README.md | 61 +- rpa_oms/app/__init__.py | 3 +- rpa_oms/app/auth.py | 9 +- rpa_oms/app/inbound/__init__.py | 3 +- rpa_oms/app/inbound/audit.py | 73 + rpa_oms/app/inbound/envelope.py | 10 +- rpa_oms/app/inbound/memory_repo.py | 43 - rpa_oms/app/inbound/messages.py | 14 + rpa_oms/app/inbound/oracle_contract_repo.py | 88 +- rpa_oms/app/inbound/oracle_repo.py | 145 +- rpa_oms/app/inbound/parse.py | 23 + rpa_oms/app/inbound/purchase_contract.py | 115 +- rpa_oms/app/inbound/repo.py | 18 +- rpa_oms/app/inbound/sales_goods.py | 164 +- rpa_oms/app/local_env.py | 24 - rpa_oms/app/main.py | 157 +- rpa_oms/app/outbound/__init__.py | 3 +- rpa_oms/app/outbound/cart_settlement.py | 62 +- rpa_oms/app/outbound/contract_agreement.py | 65 +- rpa_oms/app/outbound/limits.py | 22 + rpa_oms/app/outbound/memory_repo.py | 18 - rpa_oms/app/outbound/oracle_repo.py | 234 +- rpa_oms/app/parse.py | 129 + rpa_oms/pyproject.toml | 2 +- rpa_oms/tests/fake_oracle.py | 282 + .../tests/test_cart_settlement_outbound.py | 108 +- .../tests/test_contract_agreement_outbound.py | 62 +- rpa_oms/tests/test_local_env.py | 6 +- rpa_oms/tests/test_oracle_agreement_repo.py | 86 + rpa_oms/tests/test_oracle_cart_repo.py | 187 +- rpa_oms/tests/test_oracle_contract_repo.py | 87 +- rpa_oms/tests/test_oracle_repo.py | 156 +- .../tests/test_purchase_contract_receive.py | 106 +- rpa_oms/tests/test_sales_goods_receive.py | 135 +- rpa_oracle/__init__.py | 35 + rpa_oracle/audit.py | 67 + rpa_oracle/chunked_update.py | 39 + rpa_oracle/env.py | 53 + rpa_oracle/errors.py | 75 + rpa_oracle/pool.py | 217 + rpa_oracle/pyproject.toml | 25 + rpa_oracle/tests/test_audit.py | 96 + rpa_oracle/tests/test_env.py | 78 + rpa_oracle/tests/test_errors.py | 75 + rpa_oracle/tests/test_pool.py | 381 ++ rpa_oracle/uv.lock | 293 + rpa_rebate/README.md | 58 +- rpa_rebate/accept.py | 29 +- rpa_rebate/apa_browser_adapter.py | 805 +-- rpa_rebate/db.py | 73 - rpa_rebate/errors.py | 5 +- rpa_rebate/job.py | 89 +- rpa_rebate/persist.py | 70 +- rpa_rebate/pyproject.toml | 2 +- rpa_rebate/rebate_common.py | 2443 ++++++++ rpa_rebate/rebate_find.py | 71 + rpa_rebate/rebate_finder.py | 9 +- rpa_rebate/rebate_flow_steps.py | 5235 +---------------- rpa_rebate/rebate_header.py | 1059 ++++ rpa_rebate/rebate_import.py | 1269 ++++ rpa_rebate/rebate_submit.py | 434 ++ rpa_rebate/repo.py | 686 ++- rpa_rebate/run_batch.py | 63 +- rpa_rebate/run_find.py | 9 +- rpa_rebate/run_header.py | 9 +- rpa_rebate/run_import.py | 9 +- rpa_rebate/run_submit.py | 9 +- rpa_rebate/tests/test_apa_browser_adapter.py | 49 + rpa_rebate/tests/test_finder.py | 10 +- rpa_rebate/tests/test_header.py | 82 +- rpa_rebate/tests/test_import.py | 94 +- rpa_rebate/tests/test_job.py | 430 +- rpa_rebate/tests/test_oracle_repo.py | 368 ++ rpa_rebate/现行模式.md | 19 +- rpa_rebate/返利流程.md | 25 +- rpa_selection/README.md | 60 +- rpa_selection/auth_login.py | 100 +- rpa_selection/db.py | 75 - rpa_selection/http_util.py | 5 + rpa_selection/job.py | 123 +- rpa_selection/main.py | 28 +- rpa_selection/mall_config.py | 2 + rpa_selection/persist.py | 61 +- rpa_selection/pipeline.py | 22 +- rpa_selection/pyproject.toml | 2 +- rpa_selection/repo.py | 413 +- rpa_selection/rules.py | 72 +- rpa_selection/search_yaoex.py | 9 + rpa_selection/search_ybm.py | 7 + rpa_selection/tests/test_auth_login.py | 64 + rpa_selection/tests/test_cli.py | 43 + rpa_selection/tests/test_job.py | 168 +- rpa_selection/tests/test_oracle_repo.py | 451 +- rpa_selection/tests/test_persist.py | 12 +- rpa_selection/tests/test_pipeline.py | 34 +- rpa_selection/tests/test_rules.py | 23 +- rpa_selection/util.py | 5 + 159 files changed, 19078 insertions(+), 11291 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-24-batch-runtime-checklist.md create mode 100644 rpa_browser/__init__.py create mode 100644 rpa_browser/adapter.py delete mode 100644 rpa_cart/db.py create mode 100644 rpa_cart/selection_path.py create mode 100644 rpa_contract/contract_common.py create mode 100644 rpa_contract/contract_form.py create mode 100644 rpa_contract/contract_goods.py create mode 100644 rpa_contract/contract_login.py create mode 100644 rpa_contract/contract_number.py create mode 100644 rpa_contract/contract_submit.py create mode 100644 rpa_contract/contract_upload.py delete mode 100644 rpa_contract/db.py create mode 100644 rpa_contract/tests/test_credentials.py create mode 100644 rpa_contract/tests/test_oracle_repo.py create mode 100644 rpa_oms/app/inbound/audit.py delete mode 100644 rpa_oms/app/inbound/memory_repo.py create mode 100644 rpa_oms/app/inbound/messages.py create mode 100644 rpa_oms/app/inbound/parse.py delete mode 100644 rpa_oms/app/local_env.py create mode 100644 rpa_oms/app/outbound/limits.py delete mode 100644 rpa_oms/app/outbound/memory_repo.py create mode 100644 rpa_oms/app/parse.py create mode 100644 rpa_oms/tests/fake_oracle.py create mode 100644 rpa_oms/tests/test_oracle_agreement_repo.py create mode 100644 rpa_oracle/__init__.py create mode 100644 rpa_oracle/audit.py create mode 100644 rpa_oracle/chunked_update.py create mode 100644 rpa_oracle/env.py create mode 100644 rpa_oracle/errors.py create mode 100644 rpa_oracle/pool.py create mode 100644 rpa_oracle/pyproject.toml create mode 100644 rpa_oracle/tests/test_audit.py create mode 100644 rpa_oracle/tests/test_env.py create mode 100644 rpa_oracle/tests/test_errors.py create mode 100644 rpa_oracle/tests/test_pool.py create mode 100644 rpa_oracle/uv.lock delete mode 100644 rpa_rebate/db.py create mode 100644 rpa_rebate/rebate_common.py create mode 100644 rpa_rebate/rebate_find.py create mode 100644 rpa_rebate/rebate_header.py create mode 100644 rpa_rebate/rebate_import.py create mode 100644 rpa_rebate/rebate_submit.py create mode 100644 rpa_rebate/tests/test_apa_browser_adapter.py create mode 100644 rpa_rebate/tests/test_oracle_repo.py delete mode 100644 rpa_selection/db.py create mode 100644 rpa_selection/tests/test_auth_login.py diff --git a/.gitignore b/.gitignore index eaaf15c..b5c6b31 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ node_modules/ .runtime/ rpa_contract/screenshots/ rpa_rebate/screenshots/ +rpa_cart/exports/ rpa-chrome-profile/ **/chrome-profile/ .tmp/ diff --git a/AGENTS.md b/AGENTS.md index d6e2572..6a43ae5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 入口 / 鉴权 | `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 Oracle 取数 | `rpa_oms/app/outbound/oracle_repo.py` | `rpa_oms/tests/test_oracle_cart_repo.py::test_list_unpushed_keeps_whole_oms_group_when_limit_is_3` | | 接口4 取数组装 | `rpa_oms/app/outbound/contract_agreement.py` | `rpa_oms/tests/test_contract_agreement_outbound.py::test_assembles_unpushed_rows_as_document_envelope` | +| 接口4 Oracle 取数 | `rpa_oms/app/outbound/oracle_repo.py` | `rpa_oms/tests/test_oracle_agreement_repo.py::test_list_unpushed_keeps_whole_contract_no_group_when_limit_is_3` | | 接口3 收件契约 | `rpa_oms/app/inbound/purchase_contract.py` | `rpa_oms/tests/test_purchase_contract_receive.py::点名` | | 接口3 Oracle 落表 | `rpa_oms/app/inbound/oracle_contract_repo.py` | `rpa_oms/tests/test_oracle_contract_repo.py::点名` | | 选品规则 / 拆量 | `rpa_selection/rules.py`、`pipeline.py` | `rpa_selection/tests/test_pipeline.py::test_split_across_malls_is_success` | | 选品班次落表 | `rpa_selection/job.py`、`persist.py`、`repo.py` | `rpa_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/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_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_cart/cart_job.py`、`cart_repo.py`、`cart_match.py` | `rpa_cart/tests/test_cart_job.py::test_job_adds_split_rows_without_insert` | | 支付回传 | `rpa_cart/job.py`、`settle.py` | 1 号药城支付按 Excel 落表,不对加购行 | | 返利班查号落表 | `rpa_rebate/job.py`、`persist.py`、`repo.py` | `rpa_rebate/tests/test_job.py::test_job_finds_contract_and_writes_agreement` | -| 返利班查号页面 | `rpa_rebate/rebate_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_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_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_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_common.py` | 四个节点共用;改这里四个一起变 | +| 返利班查号页面 | `rpa_rebate/rebate_find.py`、`rebate_finder.py` | `ACCEPT SUMMARY REBATE_FIND` 或 `rpa_rebate/tests/test_finder.py::test_find_flow_writes_accept_for_missing_number` | +| 返利总单页面 | `rpa_rebate/rebate_header.py`、`run_header.py` | `ACCEPT SUMMARY REBATE_HEADER` 或 `rpa_rebate/tests/test_header.py::test_header_flow_writes_accept_with_order_no` | +| 返利细单新增 | `rpa_rebate/rebate_import.py`、`run_import.py` | `ACCEPT SUMMARY REBATE_IMPORT` 或 `rpa_rebate/tests/test_import.py::test_import_flow_writes_accept_with_success_fail` | +| 返利送审 | `rpa_rebate/rebate_submit.py`、`run_submit.py` | `ACCEPT SUMMARY REBATE_SUBMIT` 或 `rpa_rebate/tests/test_submit.py::test_submit_flow_writes_accept_with_order_nos` | | 四个口地图 | `rpa_oms/README.md`、根 `README.md` | — | | 节点业务规则(点名一节) | `docs/RPA订单中心接口开发口径_20260930.md` | 禁止通读 `docs/` | @@ -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。 -运行时只落 Oracle。单测用内存替身,不连库。服务端口 **8001**。 +运行时只落 Oracle。入站单测用假连接录 SQL,不连库。服务端口 **8001**。连接从 `rpa_oracle` 池借,一个请求一条。 | 步 | 函数 | 要点 | | --- | --- | --- | | 信封 | `parse_envelope` | 缺 `data` / 空数组 / 超过 100 条:整包 422,一条都不落 | -| 逐条校验 | `receive_sales_goods` | 入参 camelCase,落表 snake_case;长度跟 VARCHAR2 | -| 鉴权 | `token_ok` | 未配 token 一律失败 | -| 落表 | `OracleSalesGoodsRepo.insert` | `fid` 由序列触发器生成,INSERT 不绑这一列 | +| 整批校验 | `receive_sales_goods` | 内存 REJECTED / 同批 CONFLICT,再一次查重一次 `executemany` | +| 鉴权 | `token_ok` | `X-Oms-Token` 共享密钥,未配一律失败;失败进审计 | +| 落表 | `existing_oms_ids` + `insert_many` | `fid` 由序列触发器生成,INSERT 不绑这一列;只认 `uk_rpa_sgmd_oms_id` | | 幂等 | 同 `omsId` | 已存在 → `DUPLICATE`,不更新、不重跑 | | 同批冲突 | 同一 POST 里 `omsId` 再现 | 后一条 `CONFLICT`,只落第一条 | | 字段不过 | `_parse_row` | `REJECTED`,不入库 | | 混批 | `post_sales_goods` | 逐条有成功/重复/拒绝时 HTTP 仍 200,看 `results[].resultCode` | +| 对外文案 | `messages.py` | 401「身份认证失败」;503「数据库不可用」;body 不含 ORA | +| 审计 | `write_audit` | 一行请求一条;token 打码;写失败不把业务打成 500 | `omsId` / `saleQty` / `planPurchaseQty` / 接口 3 的 `fid` 必须是 JSON 整数,不把 `"10"` 当 10。Windows 连真库测;Instant Client 位数与 Python 一致。 -接口 2:`load_cart_settlement_envelope` 捞 `push_status=0`,同一 `omsId` 整组进一批,上限 500;金额四位小数字符串。不改 `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 只组装不推送。 @@ -80,9 +87,9 @@ | 步 | 函数 | 要点 | | --- | --- | --- | -| 领取 | `claim_pending` | `deal_flag` 0→3;中途崩溃停在 3,改回 0 再跑 | -| 规则 | `allocate` / `run_selection` | 自营仓全称匹配、毛利 ≥ 4%、效期 ≥ 12 月;同仓只留低价;按仓序拆量 | -| 落表 | `build_result` + `save_result` | 成功每仓一行 `status=1`,合计=计划量;`push_status=0` | +| 领取 | `claim_pending` | `deal_flag` 0→3;`FOR UPDATE SKIP LOCKED`;中途崩溃停在 3,超过租约(`deal_time` 30 分钟)可再领 | +| 规则 | `allocate` / `run_selection` | 自营仓全称匹配、毛利严格大于 4%、效期 ≥ 12 月;同仓多个 sku 都参与拆量,低价先吃;按仓序拆量 | +| 落表 | `build_result` + `save_result` | 成功每条拆量一行 `status=1`,合计=计划量;`push_status=0` | | 库存不足 | `KIND_BUSINESS_FAIL` | 两边都搜到但合计不够:`deal_flag=2`,该 `omsId` 不重跑 | | 对端故障 | `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` | 条形码结果对商家全称;同仓留低价 | | 加车 | `run_cart_batch` + 平台 `add_*_cart` | 改数量加入购物车。无 `sku_id`,必须再搜 | | 成功 | `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,不插新行)。 ## 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/` | -| S03 登录 | `login_contract_system` | `10007228` / `1` | -| S04–S05 起草 | `enter_contract_create`、`select_contract_template` | 直达 `FORM_PAGE_URL`,不要点 SPA 菜单 | -| S06 签约方 | `fill_contract_signatory_info` | 客商 `DW0100046361` / `重庆亿昊药业有限公司` | -| 尽调 | `check_customer_diligence` | 正文含「未尽调」则失败,不提交 | -| S07 基础 | `fill_contract_basic_info` | 标题 `_expected_title` | -| S08 金额日期 | `fill_contract_amount_and_dates` | 签订日/开始日用接口 3 三日期,缺则回退 `order_date` | -| S09 货品 | `fill_contract_goods_lines` | 弹窗按操作码搜 | -| S10 付款 | `fill_contract_payment_info` | | -| S11 PDF | `upload_contract_document_if_needed` | 仅 YYC 传合并 PDF | -| S12 提交 | `submit_contract_for_approval` | 成功后**不要等详情页** | -| S13 合同号 | `read_contract_number` | 回「我的合同」,`fdId` > 标题+客户编码;禁止盲取最新一条 | +| S02 打开 | `contract_login.py` `open_contract_system` | `http://contract-test.gzmpc.com:8080/` | +| S03 登录 | `contract_login.py` `login_contract_system` | 账密只认 `CONTRACT_USERNAME` / `CONTRACT_PASSWORD` | +| S04–S05 起草 | `contract_login.py` `enter_contract_create`、`select_contract_template` | 直达 `FORM_PAGE_URL`,不要点 SPA 菜单 | +| S06 签约方 | `contract_form.py` `fill_contract_signatory_info` | 客商 `DW0100046361` / `重庆亿昊药业有限公司` | +| 尽调 | `contract_form.py` `check_customer_diligence` | 正文含「未尽调」则失败,不提交 | +| S07 基础 | `contract_form.py` `fill_contract_basic_info` | 标题 `_expected_title` | +| S08 金额日期 | `contract_form.py` `fill_contract_amount_and_dates` | 签订日/开始日用接口 3 三日期,缺则回退 `order_date` | +| S09 货品 | `contract_goods.py` `fill_contract_goods_lines` | 弹窗按操作码搜,找不到则本单失败 | +| S10 付款 | `contract_form.py` `fill_contract_payment_info` | | +| S11 PDF | `contract_upload.py` `upload_contract_document_if_needed` | 仅 YYC 传合并 PDF | +| S12 提交 | `contract_submit.py` `submit_contract_for_approval` | 成功后**不要等详情页** | +| S13 合同号 | `contract_number.py` `read_contract_number` | 回「我的合同」,`fdId` > 标题+客户编码;禁止盲取最新一条 | 成功路径默认不截图(`RPA_SCREENSHOT=always` 才全截)。页面等待用 `wait_for_text` / `wait_for_element`,不要固定长睡。 @@ -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`,输入框回显不算 | | 录协议 | `recorder` | 搜到后总单 → 细单新增(按货品ID,不做 Excel)→ 送审;送审成功才插 `actionCode=2` | -| 落表 | `build_found` + `save_group` | 起草行 `contract_status=2`;送审成功每 `oms_id` 插 `action_code=2`、`agreement_status=1`,带总单/细单号;`push_status=0` | -| 搜不到 | `build_not_found` | 戳改回 `合同班`;表 3 改回 `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` | 表 4 `process_status` 改回 `0`;表 3 改回 `0`;不插协议行 | 验收:`cd rpa_rebate` → `uv run pytest tests/test_job.py::test_job_finds_contract_and_writes_agreement -q`。页面:`uv run python run_find.py`,只读 `ACCEPT SUMMARY REBATE_FIND` 或 `rpa_rebate/screenshots/REBATE_FIND/ACCEPT.txt`。总单:`uv run python run_header.py`,只读 `ACCEPT SUMMARY REBATE_HEADER`。细单新增:`uv run python run_import.py`,只读 `ACCEPT SUMMARY REBATE_IMPORT`。送审:`uv run python run_submit.py`,只读 `ACCEPT SUMMARY REBATE_SUBMIT`。接口 4:`cd rpa_oms` → `uv run pytest tests/test_contract_agreement_outbound.py::test_assembles_unpushed_rows_as_document_envelope -q`。现行字段与步骤见 `rpa_rebate/现行模式.md`、`rpa_rebate/返利流程.md`。 -不推接口 4,不改 `push_status`,不碰选品 / 加购 / 合同页面。 +不推接口 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_HEADER` | RPA | `rebateOrderNo` | 已拆:搜到后填映射表默认值 → 保存拿总单号 | +| `REBATE_HEADER` | RPA | `rebateOrderNo` | 已拆:勾已有总单,按 `返利流程.md` 填现行字段 → 保存拿协议总单ID | | `REBATE_IMPORT` | RPA | 货品表行数 | 已拆:勾总单 → 货品「新增」→ 货品编码三个点按 `goodsId` 搜 → 卡片确认落到表 → 货品工具栏保存。细单备注=订单号,特定补偿=0,承付参数=25。不做 Excel | | `REBATE_SUBMIT` | RPA | `submitted` + 总单/细单号 | 已拆:点送审;马上能见号则带回 | | `M5_CREATE_ORDER` | RPA 桌面端 | `m5OrderNo` | 录单后**停、不保存**。`M5_QC_MISSING` 必须识别准(直接转人工、不重试) | @@ -162,7 +169,7 @@ ## 验收 - 接口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`。 - 选品:点名 `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`。 @@ -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_IMPORT` 或 `rpa_rebate/screenshots/REBATE_IMPORT/ACCEPT.txt`;点名 `rpa_rebate/tests/test_import.py::test_import_flow_writes_accept_with_success_fail`。 - 返利送审:只读控制台 `ACCEPT SUMMARY REBATE_SUBMIT` 或 `rpa_rebate/screenshots/REBATE_SUBMIT/ACCEPT.txt`;点名 `rpa_rebate/tests/test_submit.py::test_submit_flow_writes_accept_with_order_nos`。 -- 接口4:点名 `rpa_oms/tests/test_contract_agreement_outbound.py::test_assembles_unpushed_rows_as_document_envelope`。 -- 合同页:只读控制台 `ACCEPT` 或 `rpa_contract/screenshots/` 下 `ACCEPT.txt`。 +- 接口4:点名 `rpa_oms/tests/test_contract_agreement_outbound.py::test_assembles_unpushed_rows_as_document_envelope`;SQL 截组 `tests/test_oracle_agreement_repo.py::test_list_unpushed_keeps_whole_contract_no_group_when_limit_is_3`。 +- 合同页:只读控制台 `ACCEPT` 或 `rpa_contract/screenshots/` 下 `ACCEPT.txt`;步骤门闸点名 `rpa_contract/tests/test_flow_gates.py`。 - 单测只跑点名用例。禁止跑整份带页面等待的套件当回归。 ## 不要 diff --git a/README.md b/README.md index 6873a91..aa31dbe 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ | --- | --- | | `rpa_oms/` | 四个 HTTP 口,一个 FastAPI。接口 1、3 收件;接口 2、4 组装报文 | | `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_rebate/` | 返利班。表 4 查号 / 表 3 只录协议 → 表 4;查号 → 总单 → 细单新增 → 送审同一班 | @@ -36,6 +36,18 @@ 新接口只进 `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)。 diff --git a/docs/README.md b/docs/README.md index 3f1a7cf..53f49b6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,10 @@ | `API鉴权.doc` | 调订单中心时的 Token;接口 1、3 入站鉴权由本方定 | | `下一会话任务_建表.md` | 建表任务说明(已完成) | | `合同系统录入流程.md` | 合同班合单、尽调、提交、回写;页面步骤指向 `rpa_contract` | +| `../rpa_rebate/返利流程.md` | 返利页面现行(总单/细单/送审)。与映射表冲突听这份 | +| `../rpa_rebate/现行模式.md` | 返利班相对旧稿改了什么、下一会话未做 | +| `大批量跑缺口.md` | 四条班次相对大批量现场还缺什么(口 2/4 HTTP、调度、崩溃戳、支付/M5) | +| `写死测试配置整改清单.md` | 上正式前要拆的测试站、测试账号和干跑回退 | 接口字段、示例报文仍以 Excel 为准(当前 `Downloads/RPA与订单中心接口设计_20260922.xlsx`)。Excel 与口径冲突时,作业语义听口径。 @@ -22,7 +26,7 @@ | 文件 | 用途 | | --- | --- | -| `合同返利系统字段映射_20260911.xlsx` | 返利总单默认值 | +| `合同返利系统字段映射_20260911.xlsx` | 返利旧默认值;现行字段以 `rpa_rebate/返利流程.md` 为准 | | `采购平台选品条件优先项_20260902.txt` | 选品规则原文 | | `【项目方案】福建洁达采购合同RPA自动化录入需求_v20260924.docx` | 页面操作参考 | diff --git a/docs/RPA订单中心接口开发口径_20260930.md b/docs/RPA订单中心接口开发口径_20260930.md index 9f87f79..1897cd5 100644 --- a/docs/RPA订单中心接口开发口径_20260930.md +++ b/docs/RPA订单中心接口开发口径_20260930.md @@ -6,7 +6,8 @@ - `Downloads/RPA与订单中心接口设计_20260922.xlsx`(以当时最新一份为准) - `docs/API鉴权.doc` -- `docs/合同返利系统字段映射_20260911.xlsx`(返利总单默认值) +- `docs/合同返利系统字段映射_20260911.xlsx`(返利旧默认值,对照用) +- `rpa_rebate/返利流程.md`(返利页面现行;与映射表冲突听这份) - `rpa_selection/` 既有规则 --- @@ -135,7 +136,7 @@ - 搜到:先回合同成功,再录协议 - 搜不到:本班不录协议、不重开合同,下一班再搜 -总单默认值跟映射表(计划员用订单中心给的,承付「下月 N 号结」,协议结束当天等)。不要用方案 5.5 的「登录人 / 账期 / 当年最后一天」。 +总单/细单现行字段以 `rpa_rebate/返利流程.md` 为准:计划员现阶段「廖建琦」(后期改回订单中心),联系人「苏文辉」,结算「电汇」,承付「下月 N 号结」参数 `25`,协议开始/结束当天。细单走页面新增(按 `goods_id` 搜),不做 Excel。货源细单ID 本期不填。不要用方案 5.5 的「登录人 / 账期 / 当年最后一天」。映射表作对照,冲突听返利流程。 ### 5.3 协议「成功」 diff --git a/docs/ddl/rpa_oms_staging_tables.sql b/docs/ddl/rpa_oms_staging_tables.sql index 8d83e91..893d0bd 100644 --- a/docs/ddl/rpa_oms_staging_tables.sql +++ b/docs/ddl/rpa_oms_staging_tables.sql @@ -276,6 +276,7 @@ CREATE TABLE rpa_contract_agreement_result ( update_user_id NUMBER(19,0), update_user_name VARCHAR2(100 CHAR), org_code VARCHAR2(10), + process_status NUMBER(1,0) DEFAULT 0 NOT NULL, CONSTRAINT pk_rpa_car PRIMARY KEY (fid) ); @@ -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_name IS '更新人名称。'; COMMENT ON COLUMN rpa_contract_agreement_result.org_code IS '货主编码。'; +COMMENT ON COLUMN rpa_contract_agreement_result.process_status IS '返利处理状态。0-待查号,1-返利已回写,2-合同失败不进返利,3-查号中。不进接口4。'; + +-- --------------------------------------------------------------------------- +-- 入站审计(一行请求一条;写失败不影响业务 HTTP) +-- --------------------------------------------------------------------------- +CREATE TABLE rpa_oms_inbound_audit ( + fid NUMBER(19) NOT NULL, + created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL, + api_no NUMBER(1) NOT NULL, + http_code NUMBER(3) NOT NULL, + request_json CLOB, + response_json CLOB, + error_text VARCHAR2(4000), + token_masked VARCHAR2(8), + CONSTRAINT pk_rpa_oms_in_aud PRIMARY KEY (fid) +); + +CREATE SEQUENCE seq_rpa_oms_in_aud + START WITH 1 + INCREMENT BY 1 + NOMAXVALUE + NOCYCLE + NOCACHE; + +CREATE OR REPLACE TRIGGER trg_rpa_oms_in_aud_bi +BEFORE INSERT ON rpa_oms_inbound_audit +FOR EACH ROW +WHEN (new.fid IS NULL) +BEGIN + SELECT seq_rpa_oms_in_aud.NEXTVAL INTO :NEW.fid FROM dual; +END; +/ + +COMMENT ON TABLE rpa_oms_inbound_audit IS '订单中心入站审计。一行请求一条;token 只存打码。'; +COMMENT ON COLUMN rpa_oms_inbound_audit.fid IS '本方序列主键。'; +COMMENT ON COLUMN rpa_oms_inbound_audit.created_at IS '请求处理时间。'; +COMMENT ON COLUMN rpa_oms_inbound_audit.api_no IS '口。1-销售商品,3-采购合同。'; +COMMENT ON COLUMN rpa_oms_inbound_audit.http_code IS '对外 HTTP 状态码。'; +COMMENT ON COLUMN rpa_oms_inbound_audit.request_json IS '入参 JSON。不含 token 明文。'; +COMMENT ON COLUMN rpa_oms_inbound_audit.response_json IS '出参 JSON。'; +COMMENT ON COLUMN rpa_oms_inbound_audit.error_text IS '内部错误原文,含 ORA。对外 503 不用这一列。'; +COMMENT ON COLUMN rpa_oms_inbound_audit.token_masked IS '有 token 则为 ***。'; + +-- --------------------------------------------------------------------------- +-- 选品查询失败审计(一次查询失败一条;写失败不影响班次落表) +-- --------------------------------------------------------------------------- +CREATE TABLE rpa_selection_query_audit ( + fid NUMBER(19) NOT NULL, + created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL, + oms_id NUMBER(20) NOT NULL, + mapping_fid NUMBER(19) NOT NULL, + mall_name VARCHAR2(64) NOT NULL, + bar_code VARCHAR2(40), + error_text VARCHAR2(4000), + CONSTRAINT pk_rpa_sel_qry_aud PRIMARY KEY (fid) +); + +CREATE SEQUENCE seq_rpa_sel_qry_aud + START WITH 1 + INCREMENT BY 1 + NOMAXVALUE + NOCYCLE + NOCACHE; + +CREATE OR REPLACE TRIGGER trg_rpa_sel_qry_aud_bi +BEFORE INSERT ON rpa_selection_query_audit +FOR EACH ROW +WHEN (new.fid IS NULL) +BEGIN + SELECT seq_rpa_sel_qry_aud.NEXTVAL INTO :NEW.fid FROM dual; +END; +/ + +COMMENT ON TABLE rpa_selection_query_audit IS '选品查询失败审计。一次平台查询失败一条;不含业务短码。'; +COMMENT ON COLUMN rpa_selection_query_audit.fid IS '本方序列主键。'; +COMMENT ON COLUMN rpa_selection_query_audit.created_at IS '写入时间。'; +COMMENT ON COLUMN rpa_selection_query_audit.oms_id IS '订单中心序号。'; +COMMENT ON COLUMN rpa_selection_query_audit.mapping_fid IS '表 1 rpa_sales_goods_mapping_data.fid。'; +COMMENT ON COLUMN rpa_selection_query_audit.mall_name IS '平台名。'; +COMMENT ON COLUMN rpa_selection_query_audit.bar_code IS '查询用的条形码。'; +COMMENT ON COLUMN rpa_selection_query_audit.error_text IS '异常原文,含 ORA。超长截断。不进 deal_result。'; + +-- --------------------------------------------------------------------------- +-- 加购 / 合同 / 返利未捕获异常审计(一条故障一条;写失败不影响班次落表) +-- --------------------------------------------------------------------------- +CREATE TABLE rpa_shift_error_audit ( + fid NUMBER(19) NOT NULL, + created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL, + shift_name VARCHAR2(32) NOT NULL, + biz_key VARCHAR2(128), + error_code VARCHAR2(64) NOT NULL, + error_text VARCHAR2(4000), + CONSTRAINT pk_rpa_shift_err_aud PRIMARY KEY (fid) +); + +CREATE SEQUENCE seq_rpa_shift_err_aud + START WITH 1 + INCREMENT BY 1 + NOMAXVALUE + NOCYCLE + NOCACHE; + +CREATE OR REPLACE TRIGGER trg_rpa_shift_err_aud_bi +BEFORE INSERT ON rpa_shift_error_audit +FOR EACH ROW +WHEN (new.fid IS NULL) +BEGIN + SELECT seq_rpa_shift_err_aud.NEXTVAL INTO :NEW.fid FROM dual; +END; +/ + +COMMENT ON TABLE rpa_shift_error_audit IS '加购、合同、返利未捕获异常审计。业务列仍写短码。'; +COMMENT ON COLUMN rpa_shift_error_audit.fid IS '本方序列主键。'; +COMMENT ON COLUMN rpa_shift_error_audit.created_at IS '写入时间。'; +COMMENT ON COLUMN rpa_shift_error_audit.shift_name IS '班次。加购、合同或返利。'; +COMMENT ON COLUMN rpa_shift_error_audit.biz_key IS '加购行 fid、合同供应商或返利合同号。'; +COMMENT ON COLUMN rpa_shift_error_audit.error_code IS '业务列上的短码,如 QUERY_FAILED、PAGE_FAILED。'; +COMMENT ON COLUMN rpa_shift_error_audit.error_text IS '异常原文,含 ORA。超长截断。不进失败原因列。'; diff --git a/docs/ddl/列对照.md b/docs/ddl/列对照.md index 6962f36..d2c650f 100644 --- a/docs/ddl/列对照.md +++ b/docs/ddl/列对照.md @@ -165,5 +165,6 @@ Excel 待确认:重录协议是否新 `fid`;税率百分数还是小数; | 18 | 更新人ID | update_user_id | number(19,0) | 同 | 无 | | 19 | 更新人名称 | update_user_name | varchar2(100 char) | 同 | 无 | | 20 | 货主编码 | org_code | varchar2(10) | 同 | 无 | +| — | 返利处理状态 | process_status | — | NUMBER(1,0) DEFAULT 0 NOT NULL | 本方补。0 待查号,1 返利已回写,2 合同失败不进返利,3 查号中。不进接口 4 | -相对接口 sheet:数据表多 `push_*`、审计字段。 +相对接口 sheet:数据表多 `push_*`、审计字段、`process_status`。 diff --git a/docs/superpowers/plans/2026-10-24-batch-runtime-checklist.md b/docs/superpowers/plans/2026-10-24-batch-runtime-checklist.md new file mode 100644 index 0000000..cf50fb0 --- /dev/null +++ b/docs/superpowers/plans/2026-10-24-batch-runtime-checklist.md @@ -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`。对方已有协议、本地没写上时,重跑不产生第二份。 diff --git a/docs/superpowers/specs/2026-10-02-selection-split-allocation-design.md b/docs/superpowers/specs/2026-10-02-selection-split-allocation-design.md index f1750cb..52ed0fd 100644 --- a/docs/superpowers/specs/2026-10-02-selection-split-allocation-design.md +++ b/docs/superpowers/specs/2026-10-02-selection-split-allocation-design.md @@ -18,9 +18,9 @@ 1. 硬过滤:自营仓、毛利 ≥ 4%、效期 ≥ 12 个月。库存不再是过滤项。 2. 按四级仓序(同仓低价、再高库存)排序。 -3. 同一平台同一仓只留 1 条候选。 +3. 同一平台同一仓的多个 sku 都参与拆量,不再只留低价那条。 4. 按序贪心拆量,直到计划采购量凑齐。 -5. 凑齐:每个仓一行写入表 2,`status=1`,数量是该仓拆到的件数,合计等于计划量。 +5. 凑齐:每条拆量一行写入表 2,`status=1`,数量是该条拆到的件数,合计等于计划量。 6. 凑不齐:不写成功行,表 2 `status=2`,表 1 `deal_flag=2`。 完成标准:`选品流程` 单测全绿;用内存仓库能演示「杭州 3 + 广东 7 = 计划 10」落两行,以及「合计 9 < 10」整单失败且成功行为 0。 @@ -33,7 +33,6 @@ - 接口 2 推送(`push_status` 仍为 0) - 批准文号搜索、商家名模糊匹配 - 中包装 / 起订量 -- 同一仓多 SKU 拆成两行 - 改表结构、改接口 1 --- @@ -94,11 +93,11 @@ - `EXPIRY_UNPARSEABLE` / `EXPIRY_LT_12M` - `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 排序 @@ -166,8 +165,8 @@ for candidate in sorted_passed: | `supplier_name` | `vendor_raw` | | `bar_code` / `org_code` | 原样 | | `purchase_price` | 该候选平台价,四位小数 | -| `purchase_qty` | 该仓 `take`,**不是**计划全量 | -| `purchase_total` | 单价 × 该仓数量,四位小数 | +| `purchase_qty` | 该条 `take`,**不是**计划全量 | +| `purchase_total` | 单价 × 该条数量,四位小数 | | `status` | 1 | | `fail_reason` | null | | `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)。 -业务失败原因(`fail_reason` / `deal_result`)。先看过滤后是否留下可拆候选(含库存为 0、以及被 `SAME_WAREHOUSE_NOT_BEST` 折叠掉但仍算「该仓有过过滤候选」——以折叠**之前**过过滤的集合为准): +业务失败原因(`fail_reason` / `deal_result`)。先看过滤后是否留下可拆候选(含库存为 0。以过过滤的集合为准): 1. `NO_BAR_CODE` / `NO_SALE_PRICE` / `NO_QTY`:沿用现有中文 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` 只留给单测过渡,班次不再调用 | | `job.py` | 先收集各平台搜索结果,再 `run_selection` | | `persist.py` | 吃整单结果:成功按拆量多行;`multi_platform_flag` 按平台种类数;失败原因 5% → 4%,并增加库存不足 | @@ -234,7 +233,7 @@ class SelectionResult: 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 决策」去猜: @@ -257,7 +256,7 @@ class SelectionResult: - 湖南壹号、甘肃壹号云视为非自营 - 近仓 3、次仓 20、计划 10 → 近仓 3 + 次仓 7 - 合计 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 件拆 班次 / 落表: diff --git a/rpa_browser/__init__.py b/rpa_browser/__init__.py new file mode 100644 index 0000000..280a1ac --- /dev/null +++ b/rpa_browser/__init__.py @@ -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", +] diff --git a/rpa_browser/adapter.py b/rpa_browser/adapter.py new file mode 100644 index 0000000..33c5380 --- /dev/null +++ b/rpa_browser/adapter.py @@ -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) diff --git a/rpa_cart/README.md b/rpa_cart/README.md index c4b0365..6382ac6 100644 --- a/rpa_cart/README.md +++ b/rpa_cart/README.md @@ -11,7 +11,7 @@ 选品已经把「哪个仓、加几件」写成表 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`,加购必须再搜条形码。选完到加购之间库存可能变,已知。 @@ -35,6 +35,48 @@ uv run pytest tests/test_cart_job.py::test_job_adds_split_rows_without_insert -q 接口 2 仍捞全部 `push_status=0`。加购班跑完之前不要组装推送。 +## 运维 + +### 怎么跑 + +```bash +cd rpa_cart +uv sync --python 3.12 +uv run python main.py cart --limit 20 +``` + +账号用 `rpa_selection/.env` 的 `YAOEX_*` / `YBM_*`,Oracle 空缺时补 `rpa_oms/.env`。连接从 `rpa_oracle` 池取,班次默认池大小 1。缺 `RPA_ORACLE_HOST` / 用户 / 密码时入口失败,文案「数据库不可用」。未捕获异常原文进 `rpa_shift_error_audit`,Windows 上先执行 `docs/ddl/rpa_oms_staging_tables.sql` 里这张表。 + +### 看哪张表哪几列 + +- 表 2 `rpa_cart_settlement_goods_data`:`operate_type=1`、`status`(1 成功,2 失败)、`push_status=0`、`update_user_name`(`系统管理员` 待领,`加购中` 处理中,`加购班` 本班已盖)、`fail_reason`、`update_time`、`oms_id`、`supplier_name`、`purchase_qty` + +### 崩溃改回 SQL + +停在「加购中」且未过 30 分钟租约: + +```sql +UPDATE rpa_cart_settlement_goods_data +SET update_user_name = '系统管理员' +WHERE update_user_name = '加购中' AND fid = :fid; +``` + +过期的「加购中」下一班会再领,不必改。 + +### 原因列 vs 日志原文 + +表 2 `fail_reason`:登录挂写 `系统故障:LOGIN_FAILED`,查询抛错写 `系统故障:QUERY_FAILED`,加车抛错写 `系统故障:ADD_FAILED`。异常原文打本机日志,不进这一列。库存不足 / 中包装 / 对不上仓仍写业务中文。 + +### 点名命令 + +```bash +cd rpa_cart +uv run pytest tests/test_cart_job.py::test_job_adds_split_rows_without_insert -q +uv run pytest tests/test_cart_job.py::test_job_marks_out_of_stock -q +uv run pytest tests/test_cart_repo.py::test_from_env_without_user_raises -q +uv run pytest tests/test_cart_repo.py::test_claim_reclaims_expired_claiming_stamp tests/test_cart_repo.py::test_claim_skips_fresh_claiming_stamp -q +``` + ## 1 号药城支付落表 从买家中心导出已付订单,按导出行字段写入表 2 `operate_type=2`。不对加购行。购物车和支付单没有强关联,`oms_id` 表上 NOT NULL,先填 `0`,等人后选。 @@ -44,9 +86,6 @@ cd rpa_cart uv sync --python 3.12 # 账号用 rpa_selection/.env 的 YAOEX_*,库用 RPA_ORACLE_* 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),不是平台数据,给订单中心防重传。 @@ -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 python main.py cart -# 国庆当天可能没单,用 9-30 有数 -uv run python main.py --dry-run --day 2026-09-30 --days 0 -uv run python main.py --day 2026-09-30 --days 0 +uv run python main.py ``` 看控制台 `inserted / skipped_dup / unmatched`。同一订单号再跑应 `skipped_dup` 上升。 diff --git a/rpa_cart/cart_add.py b/rpa_cart/cart_add.py index 52abf17..973d521 100644 --- a/rpa_cart/cart_add.py +++ b/rpa_cart/cart_add.py @@ -1,4 +1,5 @@ -"""加车结果。HTTP 客户端把商城回包收成这三种。""" +# 加车结果。HTTP 客户端把商城回包收成这三种。 + from __future__ import annotations from dataclasses import dataclass @@ -8,12 +9,14 @@ KIND_BUSINESS = "business" KIND_SYSTEM = "system" +# 一次加车的结果:成功、业务失败(库存/中包装)或系统失败(登录、返回异常)。 @dataclass class AddOutcome: kind: str reason: str = "" +# 按商城回文区分成功、库存/中包装等业务失败,以及看不懂的系统失败。 def classify_mall_message(text: str) -> AddOutcome: msg = (text or "").strip() if not msg: diff --git a/rpa_cart/cart_job.py b/rpa_cart/cart_job.py index 15193c9..8e00e41 100644 --- a/rpa_cart/cart_job.py +++ b/rpa_cart/cart_job.py @@ -1,6 +1,13 @@ -"""加购班次:领表 2 选品成功行 → 再搜条形码对仓 → 加车。成功盖章,失败改 status=2。""" +# 加购班次:领表 2 选品成功行 → 再搜条形码对仓 → 加车。 +# +# 成功盖章,失败改 status=2。崩在「加购中」:超过租约可再领,未过期需改回「系统管理员」。 +# 查询失败原因列只写 QUERY_FAILED / LOGIN_FAILED / ADD_FAILED。原文进审计表,会话结束后另写。 + from __future__ import annotations +import logging +from collections import OrderedDict +from contextlib import nullcontext from dataclasses import dataclass from typing import Callable @@ -10,13 +17,19 @@ from cart_add import ( # noqa: F401 KIND_SYSTEM, 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 rpa_oracle import ShiftError Searcher = Callable[[str], list[CartOffer]] Adder = Callable[[CartOffer, int], AddOutcome] +CartReader = Callable[[str, str], int] +_RETRY_CODES = ("LOGIN_FAILED", "QUERY_FAILED") +logger = logging.getLogger("rpa_cart") +INCOMPLETE_MARK = "未加齐" +# 一个加购班次的领取和盖章计数。 @dataclass class CartSummary: claimed: int = 0 @@ -24,6 +37,14 @@ class CartSummary: failed: int = 0 +def _session(repo): + """有 session 就借一条,没有就空上下文。商城调用必须在这个块外面。""" + session = getattr(repo, "session", None) + if session is None: + return nullcontext() + return session() + + def run_cart_batch( repo, *, @@ -32,65 +53,143 @@ def run_cart_batch( login_errors: dict[int, str], limit: int | None = None, platform_types: list[int] | None = None, + cart_readers: dict[int, CartReader] | None = None, ) -> CartSummary: - rows = repo.claim_pending(limit=limit, platform_types=platform_types) + """领取待加购行,按行搜仓、加车、盖章。 + + session 只包领取和每一组的盖章。搜索和加车在连接归还之后跑。一组盖完再处理下一组。 + 登录失败和查询失败把戳改回系统管理员。购物车里已有不少于本行的数量则直接成功,不加第二份。 + limit 交给领取层。 + """ + audits: list[ShiftError] = [] + readers = cart_readers or {} + with _session(repo): + rows = repo.claim_pending(limit=limit, platform_types=platform_types) summary = CartSummary(claimed=len(rows)) - for row in rows: - reason = _add_one(row, searchers, adders, login_errors) - if reason is None: - repo.mark_success(row.fid) - summary.added += 1 - else: - repo.mark_fail(row.fid, reason) - summary.failed += 1 + for oms_id, group in _by_oms(rows).items(): + 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: + repo.mark_success(row.fid) + summary.added += 1 + continue + if incomplete: + reason = _incomplete_reason(reason) + if _retryable(code, reason): + repo.mark_release(row.fid, reason) + else: + repo.mark_fail(row.fid, reason) + summary.failed += 1 + if code and text: + audits.append(ShiftError("加购", str(row.fid), code, text)) + repo.save_shift_errors(audits) 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( row: CartRow, searchers: dict[int, Searcher], adders: dict[int, Adder], login_errors: dict[int, str], -) -> str | None: + cart_readers: dict[int, CartReader], +) -> tuple[str | None, str | None, str | None]: + """对一行查购物车、搜仓并加车。 + + 返回 (失败原因, 审计短码, 原文)。成功三项都是 None。 + 购物车里该条形码和供应商的数量已经不少于本行时直接成功,多出来的数量不加第二份。 + 业务原因(对不上仓、库存、商城回的业务失败)不进审计。没有原文也不进。 + """ platform = row.platform_type 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) if searcher is None: - return _system("未登录该平台") + return _system("LOGIN_FAILED"), None, None adder = adders.get(platform) if adder is None: - return _system("未配置加车") + return _system("ADD_FAILED"), None, None bar_code = (row.bar_code or "").strip() if not bar_code: - return "缺少条形码" + return "缺少条形码", None, None qty = int(row.purchase_qty) 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: offers = searcher(bar_code) 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) if offer is None: - return "未找到对应仓" + return "未找到对应仓", None, None if offer.stock is not None and offer.stock < qty: - return "库存不足" - pack = middle_pack(offer) - if not qty_fits_pack(qty, pack): - return f"中包装加不进:数量须为{pack}的倍数" + return "库存不足", None, None try: outcome = adder(offer, qty) 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: - return None + return None, None, None reason = (outcome.reason or "加购失败").strip() if outcome.kind == KIND_SYSTEM: - return _system(reason) - return clip_reason(reason) + return _system(reason), None, None + return clip_reason(reason), None, None +# 取出异常原文。空串不当审计正文。 +def _text(exc: BaseException) -> str | None: + text = str(exc).strip() + return text or None + + +# 把系统故障收成「系统故障:短码」并截断。已带前缀的不再套一层。 def _system(detail: str) -> str: text = (detail or "未知错误").strip() if text.startswith("系统故障"): diff --git a/rpa_cart/cart_match.py b/rpa_cart/cart_match.py index 55b50b6..1f7b2fa 100644 --- a/rpa_cart/cart_match.py +++ b/rpa_cart/cart_match.py @@ -1,13 +1,14 @@ -"""按供应商名对上搜到的仓。无 sku_id 时靠条形码结果 + 商家全称。""" +# 按供应商名对上搜到的仓。无 sku_id 时靠条形码结果 + 商家全称。 + from __future__ import annotations from dataclasses import dataclass, field from typing import Any SELF_OPERATED_PREFIX = "[自营]" -PACK_KEYS = ("middlePackNum", "midPackQty", "packNum", "packQty") +# 再搜到的一条报价:商家全称、库存、价格。加车前靠它对上表 2 的供应商。 @dataclass class CartOffer: sku_id: str @@ -18,6 +19,7 @@ class CartOffer: extra: dict[str, Any] = field(default_factory=dict) +# 去掉「[自营]」前缀和多余空白,用来对商家全称。 def normalize_supplier(raw: str) -> str: text = (raw or "").replace("\u3000", " ").strip() if text.startswith(SELF_OPERATED_PREFIX): @@ -25,6 +27,7 @@ def normalize_supplier(raw: str) -> str: return " ".join(text.split()) +# 条形码结果里对上商家全称。同仓留低价,价格相同留库存多的。对不上返回 None。 def match_offer(offers: list[CartOffer], supplier_name: str) -> CartOffer | None: target = normalize_supplier(supplier_name) if not target: @@ -40,23 +43,7 @@ def match_offer(offers: list[CartOffer], supplier_name: str) -> CartOffer | None 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]: price = offer.price 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(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 diff --git a/rpa_cart/cart_repo.py b/rpa_cart/cart_repo.py index 60a1b35..7dd2e32 100644 --- a/rpa_cart/cart_repo.py +++ b/rpa_cart/cart_repo.py @@ -1,11 +1,17 @@ -"""表 2 加购行:领取、成功盖章、失败改状态。成功不插新行。""" +# 表 2 加购行:领取、成功盖章、失败改状态。成功不插新行。 +# +# 领取时盖「加购中」。进程中途退出时行停在这一戳,超过租约(update_time 30 分钟)才能再领。 +# 未过期的「加购中」需改回「系统管理员」再跑。 + from __future__ import annotations -from collections.abc import Callable +from contextlib import contextmanager from dataclasses import dataclass +from datetime import datetime, timedelta 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 STATUS_OK = 1 @@ -15,16 +21,13 @@ STAMP_SELECTION = "系统管理员" STAMP_CLAIMING = "加购中" STAMP_DONE = "加购班" FAIL_REASON_MAX = 500 +LEASE_MINUTES = 30 _CLAIM_SQL = """ UPDATE rpa_cart_settlement_goods_data SET update_user_name = :stamp, update_time = SYSDATE -WHERE fid = :fid - AND operate_type = 1 - AND status = 1 - AND push_status = 0 - AND NVL(update_user_name, :selection_stamp) = :selection_stamp +WHERE fid IN ({placeholders}) """ _SUCCESS_SQL = """ @@ -46,7 +49,17 @@ WHERE fid = :fid AND update_user_name = :claim_stamp """ +_RELEASE_SQL = """ +UPDATE rpa_cart_settlement_goods_data +SET update_user_name = :stamp, + fail_reason = :fail_reason, + update_time = SYSDATE +WHERE fid = :fid + AND update_user_name = :claim_stamp +""" + +# 表 2 一行待加购或处理中的结算货品。 @dataclass class CartRow: fid: int @@ -62,6 +75,7 @@ class CartRow: operate_type: int = OPERATE_TYPE_ADD_CART +# 把失败原因截到 FAIL_REASON_MAX 字,超长末尾加省略号。 def clip_reason(text: str) -> str: text = (text or "").strip() if len(text) <= FAIL_REASON_MAX: @@ -69,16 +83,26 @@ def clip_reason(text: str) -> str: 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 - return ( - int(row.get("operate_type") or 0) == OPERATE_TYPE_ADD_CART - and int(row.get("status") or 0) == STATUS_OK - and int(row.get("push_status") or 0) == PUSH_NOT_SENT - and stamp == STAMP_SELECTION - ) + if ( + int(row.get("operate_type") or 0) != OPERATE_TYPE_ADD_CART + or int(row.get("status") or 0) != STATUS_OK + or int(row.get("push_status") or 0) != PUSH_NOT_SENT + ): + return False + if stamp == STAMP_SELECTION: + return True + if stamp == STAMP_CLAIMING: + claimed_at = row.get("update_time") + if claimed_at is None: + return False + return now - claimed_at >= timedelta(minutes=LEASE_MINUTES) + return False +# 把字典收成 CartRow。缺数量当 0。 def _row_from_dict(data: dict[str, Any]) -> CartRow: qty = data.get("purchase_qty") return CartRow( @@ -96,130 +120,34 @@ def _row_from_dict(data: dict[str, Any]) -> CartRow: ) -class MemoryCartRepo: - def __init__(self, rows: list[dict[str, Any]]): - 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 +# 回滚当前事务。回滚本身失败则忽略。 +def _rollback(conn) -> None: + try: + conn.rollback() + except Exception: + pass -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: - conn.rollback() - except Exception: - 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: - cols = [item[0].lower() for item in cur.description] - return _row_from_dict(dict(zip(cols, rec))) - - -def _pending_sql( +# 拼领取 SELECT:系统管理员或过期加购中,末尾 FOR UPDATE SKIP LOCKED。 +def _pending_select_sql( *, limit: int | None, platform_types: list[int] | None ) -> tuple[str, dict[str, Any]]: - params: dict[str, Any] = {"selection_stamp": STAMP_SELECTION} + params: dict[str, Any] = { + "selection_stamp": STAMP_SELECTION, + "claim_stamp": STAMP_CLAIMING, + "lease_minutes": LEASE_MINUTES, + } filters = [ "operate_type = 1", "status = 1", "push_status = 0", - "NVL(update_user_name, :selection_stamp) = :selection_stamp", + """( + NVL(update_user_name, :selection_stamp) = :selection_stamp + OR ( + update_user_name = :claim_stamp + AND update_time < SYSDATE - NUMTODSINTERVAL(:lease_minutes, 'MINUTE') + ) + )""", ] if platform_types is not None: if not platform_types: @@ -242,4 +170,201 @@ ORDER BY oms_id, fid if limit is not None: params["limit"] = int(limit) sql = f"SELECT * FROM (\n{sql}\n) WHERE ROWNUM <= :limit" - return sql, 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))) diff --git a/rpa_cart/cart_search.py b/rpa_cart/cart_search.py index 542b7c9..7510344 100644 --- a/rpa_cart/cart_search.py +++ b/rpa_cart/cart_search.py @@ -1,22 +1,20 @@ -"""再搜条形码,保留原始商品字段给加车用。""" +# 再搜条形码,保留原始商品字段给加车用。 +# +# 导入路径由 selection_path 放进 rpa_selection,这里不再改 sys.path。 + from __future__ import annotations -import sys -from pathlib import Path from typing import Any import requests +import selection_path # noqa: F401 先把 rpa_selection 放进导入路径 from cart_match import CartOffer -from yaoex_login import SELECTION_ROOT - -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 +from search_yaoex import map_yaoex_products, query_yaoex_products +from search_ybm import map_ybm_products, query_ybm_products +# 按条形码搜 1 号药城,结果转成 CartOffer 并带上原始商品字段。 def search_yaoex_offers( auth, query_key: str, @@ -42,6 +40,7 @@ def search_yaoex_offers( return offers +# 按条形码搜药帮忙,结果转成 CartOffer 并带上原始商品字段。 def search_ybm_offers( cookies: dict[str, str], query_key: str, @@ -70,6 +69,7 @@ def search_ybm_offers( return offers +# 从药帮忙搜索行抽出商品原始字段,补上 skuId。 def _ybm_raw(row: dict[str, Any]) -> dict[str, Any]: product = row.get("productInfo") raw = dict(product) if isinstance(product, dict) else dict(row) @@ -78,6 +78,7 @@ def _ybm_raw(row: dict[str, Any]) -> dict[str, Any]: return raw +# 把选品候选加上原始字段收成加购用的 CartOffer。 def _from_candidate(candidate, raw: dict[str, Any]) -> CartOffer: extra = dict(candidate.extra) extra["raw"] = raw diff --git a/rpa_cart/cart_yaoex.py b/rpa_cart/cart_yaoex.py index 558c00a..2cba6b7 100644 --- a/rpa_cart/cart_yaoex.py +++ b/rpa_cart/cart_yaoex.py @@ -1,4 +1,5 @@ -"""1 号药城加入购物车。""" +# 1 号药城加入购物车。 + from __future__ import annotations import time @@ -16,6 +17,7 @@ GATEWAY = "https://gateway-b2b.fangkuaiyi.com" YAOEX_ADD_URL = f"{GATEWAY}/small/shopCart/addShopCart" +# 按再搜到的 sku 把数量加入 1 号药城购物车。没有 sku 当系统失败,不改业务原因。 def add_yaoex_cart(auth, offer: CartOffer, qty: int, *, session: requests.Session | None = None) -> AddOutcome: ids = _ids(offer) if not ids["skuId"] and not ids["spuCode"]: @@ -48,6 +50,7 @@ def add_yaoex_cart(auth, offer: CartOffer, qty: int, *, session: requests.Sessio client.close() +# 从报价里取出加车要的 sku、spu 和商家编码。 def _ids(offer: CartOffer) -> dict[str, str]: raw = offer.extra.get("raw") if isinstance(offer.extra.get("raw"), dict) else offer.extra if not isinstance(raw, dict): @@ -58,6 +61,7 @@ def _ids(offer: CartOffer) -> dict[str, str]: return {"skuId": sku, "spuCode": spu, "sellerCode": seller} +# 拼 1 号药城表单:带上登录 token,再叠上加车字段。 def _form(auth, extra: dict[str, Any]) -> dict[str, Any]: payload = { "traderName": "yaoex_pc", @@ -75,6 +79,7 @@ def _form(auth, extra: dict[str, Any]) -> dict[str, Any]: return payload +# 1 号药城加车请求头。来源页是购物车。 def _headers() -> dict[str, str]: return { "Content-Type": "application/x-www-form-urlencoded", @@ -87,6 +92,7 @@ def _headers() -> dict[str, str]: } +# 把加车回包收成成功、业务失败或系统失败。看不懂的回包当系统失败。 def _outcome_from_json(payload: object) -> AddOutcome: if not isinstance(payload, dict): return AddOutcome(kind=KIND_SYSTEM, reason="加购返回异常") diff --git a/rpa_cart/cart_ybm.py b/rpa_cart/cart_ybm.py index 391d6e6..2ca5a6e 100644 --- a/rpa_cart/cart_ybm.py +++ b/rpa_cart/cart_ybm.py @@ -1,4 +1,5 @@ -"""药帮忙加入购物车。""" +# 药帮忙加入购物车。 + from __future__ import annotations import requests @@ -12,6 +13,7 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) YBM_ADD_URL = "https://www.ybm100.com/new-front/cart/add-product" +# 按再搜到的 sku 把数量加入药帮忙购物车。没有 sku 当系统失败。 def add_ybm_cart( cookies: dict[str, str], offer: CartOffer, @@ -44,6 +46,7 @@ def add_ybm_cart( client.close() +# 从报价里取药帮忙加车用的 skuId。 def _sku_id(offer: CartOffer) -> str: raw = offer.extra.get("raw") if isinstance(offer.extra.get("raw"), dict) else offer.extra if not isinstance(raw, dict): @@ -51,6 +54,7 @@ def _sku_id(offer: CartOffer) -> str: return str(raw.get("skuId") or offer.sku_id or "") +# 药帮忙加车请求头。 def _headers() -> dict[str, str]: return { "Accept": "application/json, text/plain, */*", @@ -65,6 +69,7 @@ def _headers() -> dict[str, str]: } +# 把加车回包收成成功、业务失败或系统失败。看不懂的回包当系统失败。 def _outcome_from_json(payload: object) -> AddOutcome: if not isinstance(payload, dict): return AddOutcome(kind=KIND_SYSTEM, reason="加购返回异常") diff --git a/rpa_cart/db.py b/rpa_cart/db.py deleted file mode 100644 index 4bc196d..0000000 --- a/rpa_cart/db.py +++ /dev/null @@ -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 diff --git a/rpa_cart/job.py b/rpa_cart/job.py index 22bca15..0620f8c 100644 --- a/rpa_cart/job.py +++ b/rpa_cart/job.py @@ -1,4 +1,5 @@ -"""1 号药城支付落表班次。""" +# 1 号药城支付落表班次。 + from __future__ import annotations from dataclasses import dataclass @@ -6,6 +7,7 @@ from dataclasses import dataclass from settle import MallLine, match_pay_lines +# 一次支付落表的计数:写入、状态跳过、订单号重复、对不上。 @dataclass class PaySummary: source_lines: int = 0 @@ -15,11 +17,13 @@ class PaySummary: unmatched: int = 0 unmatched_reasons: dict[str, int] | None = None + # 未对上的原因计数缺省为空字典,避免调用方再判 None。 def __post_init__(self) -> None: if self.unmatched_reasons is None: self.unmatched_reasons = {} +# 把 1 号药城已支付行写入表 2 的支付行。不对加购行。dry_run 只统计不落库。 def run_yaoex_pay( repo, lines: list[MallLine], diff --git a/rpa_cart/main.py b/rpa_cart/main.py index 9b3860c..b4c1711 100644 --- a/rpa_cart/main.py +++ b/rpa_cart/main.py @@ -1,17 +1,17 @@ -"""加购班与 1 号药城支付落表。默认支付;`cart` 子命令加购。""" +# 加购班与 1 号药城支付落表。默认支付;`cart` 子命令加购。 + from __future__ import annotations import argparse -import tempfile -from datetime import date +from datetime import date, datetime from pathlib import Path import requests -from db import RepoUnavailable +from rpa_oracle import RepoUnavailable from job import run_yaoex_pay 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_login import ( BrowserRuntime, @@ -21,10 +21,33 @@ from yaoex_login import ( ) PROJECT_ROOT = Path(__file__).resolve().parent +EXPORT_DIR = PROJECT_ROOT / "exports" / "yaoex" YAOEX_PLATFORM_TYPE = 66 YBM_PLATFORM_TYPE = 48 +DEFAULT_CART_LIMIT = 20 +# 导出文件按订单日放进固定目录。同一秒再导一次加序号,不盖掉上一份。 +def pay_export_path( + begin: str, + end: str, + exported_at: datetime, + root: Path = EXPORT_DIR, +) -> Path: + start_day = begin[:10] + end_day = end[:10] + folder_name = start_day if start_day == end_day else f"{start_day}_{end_day}" + directory = root / folder_name + stamp = exported_at.strftime("%H%M%S") + path = directory / f"{stamp}.xls" + suffix = 2 + while path.exists(): + path = directory / f"{stamp}_{suffix}.xls" + suffix += 1 + return path + + +# 区分支付落表和加购。没写子命令时走支付。 def parse_args(argv: list[str] | None = None) -> argparse.Namespace: argv = list(argv) if argv is not None else None if argv is None: @@ -43,26 +66,24 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: return args +# 支付落表没有额外参数:登录、导出当天、写表 2。 def _parse_pay_args(argv: list[str]) -> argparse.Namespace: - 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) + parser = argparse.ArgumentParser(description="1 号药城支付落表:登录、导出当天、解析、写库") return parser.parse_args(argv) def _parse_cart_args(argv: list[str]) -> argparse.Namespace: + """解析加购参数:领取条数、平台、是否忽略登录缓存。 + + 不传 --limit 时用 20,不再把空值当成全部领走。 + """ parser = argparse.ArgumentParser(description="加购班:领表 2 选品成功行,按仓加进购物车") - parser.add_argument("--limit", type=int, default=None, help="本班最多领取 N 行") + parser.add_argument( + "--limit", + type=int, + default=DEFAULT_CART_LIMIT, + help="本班最多领取 N 行,默认 20", + ) parser.add_argument( "--platform", choices=["all", "yaoex", "ybm"], @@ -70,48 +91,40 @@ def _parse_cart_args(argv: list[str]) -> argparse.Namespace: help="all=两平台都跑", ) 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("--ybm-cookies", type=Path, default=None) return parser.parse_args(argv) +# 入口。cart 子命令加购,否则跑 1 号药城支付落表。 def main(argv: list[str] | None = None, repo=None) -> None: args = parse_args(argv) if args.command == "cart": main_cart(args, repo=repo) return - main_pay(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() - xls_path = args.xls - tmp_xls: Path | None = None - - if xls_path is None: - try: - auth = resolve_yaoex_auth( - secrets_path=args.secrets, - auth_path=args.yaoex_auth, - fresh=args.fresh_login, - ) - token = auth.token - print("1号药城认证就绪。") - except SystemExit: - raise - except Exception as exc: - print(f"1号药城登录失败:{exc}") - raise SystemExit(2) from exc - end = date.fromisoformat(args.day) if args.day else date.today() - begin, end_ts = day_range(end, args.days) - print(f"导出 {begin} ~ {end_ts}") - content = export_orders(token, begin=begin, end=end_ts) - tmp_dir = Path(tempfile.mkdtemp(prefix="yaoex_pay_")) - tmp_xls = tmp_dir / "orders.xls" - tmp_xls.write_bytes(content) - xls_path = tmp_xls - print(f"导出文件 {xls_path} ({len(content)} bytes)") + try: + auth = resolve_yaoex_auth() + token = auth.token + print("1号药城认证就绪。") + except SystemExit: + raise + except Exception as exc: + print(f"1号药城登录失败:{exc}") + raise SystemExit(2) from exc + end = date.today() + begin, end_ts = day_range(end, 0) + print(f"导出 {begin} ~ {end_ts}") + content = export_orders(token, begin=begin, end=end_ts) + xls_path = pay_export_path(begin, end_ts, datetime.now()) + xls_path.parent.mkdir(parents=True, exist_ok=True) + xls_path.write_bytes(content) + print(f"导出文件 {xls_path} ({len(content)} bytes)") lines = parse_order_xls(xls_path) print(f"导出行 {len(lines)}") @@ -120,13 +133,10 @@ def main_pay(args: argparse.Namespace, repo=None) -> None: try: repo = OraclePayRepo.from_env() except RepoUnavailable as exc: - if not args.dry_run: - print(f"数据库不可用:{exc}") - raise SystemExit(2) from exc - print(f"无库,dry-run 只解析:{exc}") - repo = EmptyPayRepo() + print(f"数据库不可用:{exc}") + raise SystemExit(2) from exc - summary = run_yaoex_pay(repo, lines, dry_run=args.dry_run) + summary = run_yaoex_pay(repo, lines) print( "落表 " 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: from cart_job import run_cart_batch from cart_repo import OracleCartRepo @@ -168,7 +179,6 @@ def main_cart(args: argparse.Namespace, repo=None) -> None: if want_yaoex: try: yaoex_auth = resolve_yaoex_auth( - secrets_path=args.secrets, auth_path=args.yaoex_auth, fresh=args.fresh_login, runtime=runtime, @@ -182,7 +192,6 @@ def main_cart(args: argparse.Namespace, repo=None) -> None: if want_ybm: try: ybm_cookies = resolve_ybm_cookies( - secrets_path=args.secrets, cookie_path=args.ybm_cookies, fresh=args.fresh_login, runtime=runtime, @@ -200,11 +209,13 @@ def main_cart(args: argparse.Namespace, repo=None) -> None: if yaoex_auth is not None: + # 用本班已登录的 1 号药城会话按条形码再搜。 def search_yaoex(query_key: str): return search_yaoex_offers( yaoex_auth, query_key, session=yaoex_session, cache=yaoex_cache ) + # 把对上的仓按数量加入 1 号药城购物车。 def add_yaoex(offer, qty: int): return add_yaoex_cart(yaoex_auth, offer, qty, session=yaoex_session) @@ -213,11 +224,13 @@ def main_cart(args: argparse.Namespace, repo=None) -> None: if ybm_cookies is not None: + # 用本班已登录的药帮忙会话按条形码再搜。 def search_ybm(query_key: str): return search_ybm_offers( ybm_cookies, query_key, session=ybm_session, cache=ybm_cache ) + # 把对上的仓按数量加入药帮忙购物车。 def add_ybm(offer, qty: int): return add_ybm_cart(ybm_cookies, offer, qty, session=ybm_session) diff --git a/rpa_cart/parse_xls.py b/rpa_cart/parse_xls.py index 887a220..bf1734d 100644 --- a/rpa_cart/parse_xls.py +++ b/rpa_cart/parse_xls.py @@ -1,4 +1,5 @@ -"""解析 1 号药城「订单明细」xls。""" +# 解析 1 号药城「订单明细」xls。 + from __future__ import annotations from decimal import Decimal @@ -24,6 +25,7 @@ _REQUIRED = { } +# 读 1 号药城订单明细表。缺列就失败。订单号为空的行丢掉。 def parse_order_xls(path: str | Path) -> list[MallLine]: book = xlrd.open_workbook(str(path)) sheet = book.sheet_by_index(0) @@ -57,6 +59,7 @@ def parse_order_xls(path: str | Path) -> list[MallLine]: return lines +# 单元格收成字符串。整数浮点去掉小数点,避免订单号变成 123.0。 def _text(value: object) -> str: if value is None: return "" @@ -65,6 +68,7 @@ def _text(value: object) -> str: return str(value).strip() +# 条码只留第一段。逗号后的附加码丢掉,末尾 .0 去掉。 def _barcode(value: object) -> str: text = _text(value) if "," in text: @@ -75,6 +79,7 @@ def _barcode(value: object) -> str: return text +# 单元格收成金额。空值当 0。 def _decimal(value: object) -> Decimal: if value is None or value == "": return Decimal("0") diff --git a/rpa_cart/pyproject.toml b/rpa_cart/pyproject.toml index ae16e0c..6a78de3 100644 --- a/rpa_cart/pyproject.toml +++ b/rpa_cart/pyproject.toml @@ -22,7 +22,7 @@ dev = [ package = false [tool.pytest.ini_options] -pythonpath = ["."] +pythonpath = [".", "..", "../rpa_selection"] testpaths = ["tests"] addopts = "-q" diff --git a/rpa_cart/repo.py b/rpa_cart/repo.py index 037de4f..226bd9b 100644 --- a/rpa_cart/repo.py +++ b/rpa_cart/repo.py @@ -1,10 +1,14 @@ -"""表 2:写支付行。""" +# 表 2:写支付行。 +# +# 连接从 rpa_oracle 池取。库故障对外「数据库不可用」,不含 ORA 原文。 + from __future__ import annotations -from collections.abc import Callable 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 _SELECT_PAY_ORDER_NOS = """ @@ -48,50 +52,70 @@ _PAY_BIND_KEYS = ( ) -class EmptyPayRepo: - def load_existing_pay_order_nos(self) -> set[str]: - return set() - - def save_pay_rows(self, rows: list[dict[str, Any]]) -> None: - return +# 回滚当前事务。回滚本身失败则忽略。 +def _rollback(conn) -> None: + try: + conn.rollback() + except Exception: + pass +# 支付班次连 Oracle 的仓库,管表 2 operate_type=2 的读写。 class OraclePayRepo: - def __init__(self, connect: Callable): - self._connect = connect + # 挂上连接池。方法里按次 acquire。 + def __init__(self, pool): + self._pool = pool + # 按环境变量建班次池。缺 HOST / 用户 / 密码抛「数据库不可用」。 @classmethod def from_env(cls) -> "OraclePayRepo": - oracle_dsn() - return cls(connect=connect_from_env) + return cls(pool=job_pool()) + # 读表 2 已有的 1 号药城支付单号,空值丢掉。 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} + 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(str(exc)) from exc - finally: - conn.close() + raise RepoUnavailable("数据库不可用") from exc + # 把支付行一次 executemany 插入表 2。空列表直接返回。同一连接同一事务。 def save_pay_rows(self, rows: list[dict[str, Any]]) -> None: if not rows: return - conn = self._connect() + 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 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() + 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: - try: - conn.rollback() - except Exception: - pass - raise RepoUnavailable(str(exc)) from exc - finally: - conn.close() + raise RepoUnavailable("数据库不可用") from exc diff --git a/rpa_cart/selection_path.py b/rpa_cart/selection_path.py new file mode 100644 index 0000000..436c982 --- /dev/null +++ b/rpa_cart/selection_path.py @@ -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)) diff --git a/rpa_cart/settle.py b/rpa_cart/settle.py index ac909cb..7f02eae 100644 --- a/rpa_cart/settle.py +++ b/rpa_cart/settle.py @@ -1,4 +1,5 @@ -"""1 号药城导出行 → 表 2 operate_type=2。不对加购行。""" +# 1 号药城导出行 → 表 2 operate_type=2。不对加购行。 + from __future__ import annotations import uuid @@ -13,14 +14,17 @@ UNASSIGNED_OMS_ID = 0 PAID_STATUSES = {"待发货", "待收货", "已完成"} +# 金额收到四位小数,四舍五入。接口 2 组包也按这个精度。 def money4(value: object) -> Decimal: return Decimal(str(value)).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP) +# 生成支付明细号。表 2 用它区分同一订单下的不同行。 def new_pay_dtl_id() -> str: return uuid.uuid4().hex +# 1 号药城导出的一行订单明细。只取已支付状态落表。 @dataclass(frozen=True) class MallLine: order_no: str @@ -37,6 +41,7 @@ class MallLine: order_paid: Decimal +# 支付匹配结果:可落库的行、对不上的行、重复订单、状态未付。 @dataclass class MatchResult: rows: list[dict[str, Any]] @@ -45,6 +50,7 @@ class MatchResult: skipped_status: list[MallLine] +# 筛出待发货、待收货、已完成的行。同一支付订单号已落过的跳过。条码超长不对上。 def match_pay_lines( lines: list[MallLine], existing_order_nos: set[str], @@ -64,6 +70,7 @@ def match_pay_lines( return result +# 把一条已支付明细收成表 2 支付行。operate_type=2,oms_id 先填 0,不改加购行。 def build_pay_row(line: MallLine) -> dict[str, Any]: price = money4(line.pay_price) qty = int(line.qty) diff --git a/rpa_cart/tests/test_cart_job.py b/rpa_cart/tests/test_cart_job.py index 6eba926..7231ffc 100644 --- a/rpa_cart/tests/test_cart_job.py +++ b/rpa_cart/tests/test_cart_job.py @@ -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_match import CartOffer -from cart_repo import STAMP_DONE, STAMP_SELECTION, MemoryCartRepo +from cart_repo import STAMP_CLAIMING, STAMP_DONE, STAMP_SELECTION, MemoryCartRepo HANGZHOU = "杭州小药药自营旗舰店" @@ -9,6 +14,7 @@ YBM = 48 YAOEX = 66 +# 构造一条待加购表 2 行。 def _row(**overrides) -> dict: data = { "fid": 1, @@ -27,6 +33,7 @@ def _row(**overrides) -> dict: return data +# 构造一条能对上仓的加购候选。 def _offer(vendor: str, **overrides) -> CartOffer: data = dict( sku_id="S-" + vendor[:2], @@ -39,6 +46,7 @@ def _offer(vendor: str, **overrides) -> CartOffer: return CartOffer(**data) +# 杭州 3 + 广东 7 两行都加车,表 2 仍两行。 def test_job_adds_split_rows_without_insert(): repo = MemoryCartRepo( [ @@ -48,18 +56,22 @@ def test_job_adds_split_rows_without_insert(): ) added: list[tuple[int, str, int]] = [] + # 杭州仓有货。 def search_ybm(bar_code: str): assert bar_code == "06901234567890" return [_offer(HANGZHOU, sku_id="HZ", stock=5.0)] + # 广东仓有货。 def search_yaoex(bar_code: str): assert bar_code == "06901234567890" return [_offer(GUANGDONG, sku_id="GD", stock=20.0)] + # 记下药帮忙加车。 def add_ybm(offer: CartOffer, qty: int): added.append((YBM, offer.sku_id, qty)) return AddOutcome(kind=KIND_OK) + # 记下 1 号药城加车。 def add_yaoex(offer: CartOffer, qty: int): added.append((YAOEX, offer.sku_id, qty)) return AddOutcome(kind=KIND_OK) @@ -82,9 +94,45 @@ def test_job_adds_split_rows_without_insert(): assert repo.rows[1]["push_status"] == 0 +# 同一 omsId 一仓成功一仓失败:失败行原因带未加齐,成功行不回退。 +def test_job_flags_incomplete_oms_when_one_row_fails(): + repo = MemoryCartRepo( + [ + _row(fid=1, platform_type=YBM, supplier_name=HANGZHOU, purchase_qty=3), + _row(fid=2, platform_type=YAOEX, supplier_name=GUANGDONG, purchase_qty=7), + ] + ) + + # 杭州仓有货。 + def search_ybm(bar_code: str): + return [_offer(HANGZHOU, stock=5.0)] + + # 广东仓库存不够。 + def search_yaoex(bar_code: str): + return [_offer(GUANGDONG, stock=1.0)] + + summary = run_cart_batch( + repo, + searchers={YBM: search_ybm, YAOEX: search_yaoex}, + adders={ + YBM: lambda offer, qty: AddOutcome(kind=KIND_OK), + YAOEX: lambda offer, qty: AddOutcome(kind=KIND_OK), + }, + login_errors={}, + ) + assert summary.added == 1 + assert summary.failed == 1 + assert repo.rows[1]["status"] == 1 + assert repo.rows[1]["fail_reason"] is None + assert repo.rows[2]["status"] == 2 + assert repo.rows[2]["fail_reason"] == "未加齐:库存不足" + + +# 库存不够时该行 status=2,原因是库存不足。 def test_job_marks_out_of_stock(): repo = MemoryCartRepo([_row(purchase_qty=10)]) + # 库存只有 4。 def search_yaoex(bar_code: str): return [_offer(GUANGDONG, stock=4.0)] @@ -99,9 +147,11 @@ def test_job_marks_out_of_stock(): assert repo.rows[1]["status"] == 2 assert repo.rows[1]["fail_reason"] == "库存不足" assert repo.rows[1]["update_user_name"] == STAMP_DONE + assert repo.shift_errors == [] def test_job_marks_login_fail_as_system_error(): + """登录失败把戳改回系统管理员,status 保持 1,下一班还能领。""" repo = MemoryCartRepo( [ _row(fid=1, platform_type=YAOEX), @@ -110,9 +160,11 @@ def test_job_marks_login_fail_as_system_error(): ) ybm_added = [] + # 杭州仓有货。 def search_ybm(bar_code: str): return [_offer(HANGZHOU)] + # 记下药帮忙加车数量。 def add_ybm(offer: CartOffer, qty: int): ybm_added.append(qty) return AddOutcome(kind=KIND_OK) @@ -125,18 +177,28 @@ def test_job_marks_login_fail_as_system_error(): ) assert summary.added == 1 assert summary.failed == 1 - assert repo.rows[1]["status"] == 2 - assert repo.rows[1]["fail_reason"].startswith("系统故障:") - assert "账号密码错误" in repo.rows[1]["fail_reason"] + assert repo.rows[1]["status"] == 1 + assert repo.rows[1]["update_user_name"] == STAMP_SELECTION + assert repo.rows[1]["fail_reason"] == "未加齐:系统故障:LOGIN_FAILED" + assert "账号密码错误" not in repo.rows[1]["fail_reason"] + assert len(repo.shift_errors) == 1 + assert repo.shift_errors[0].shift_name == "加购" + assert repo.shift_errors[0].biz_key == "1" + assert repo.shift_errors[0].error_code == "LOGIN_FAILED" + assert repo.shift_errors[0].error_text == "账号密码错误" assert repo.rows[2]["status"] == 1 assert repo.rows[2]["update_user_name"] == STAMP_DONE assert ybm_added == [3] + again = repo.claim_pending() + assert [row.fid for row in again] == [1] +# 已盖加购班的行本班不领。 def test_job_skips_already_stamped_rows(): repo = MemoryCartRepo([_row(update_user_name=STAMP_DONE)]) called = [] + # 不应被调用。 def search_yaoex(bar_code: str): called.append(bar_code) return [_offer(GUANGDONG)] @@ -152,9 +214,11 @@ def test_job_skips_already_stamped_rows(): assert repo.rows[1]["update_user_name"] == STAMP_DONE +# 对不上 supplier_name 时写未找到对应仓。 def test_job_marks_missing_warehouse(): repo = MemoryCartRepo([_row()]) + # 搜到的是杭州仓,对不上广东。 def search_yaoex(bar_code: str): return [_offer(HANGZHOU)] @@ -169,29 +233,15 @@ def test_job_marks_missing_warehouse(): 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(): repo = MemoryCartRepo([_row()]) + # 广东仓有货。 def search_yaoex(bar_code: str): return [_offer(GUANGDONG)] + # 平台返回中包装加不进。 def add_yaoex(offer: CartOffer, qty: int): return AddOutcome(kind=KIND_BUSINESS, reason="中包装加不进:数量须为10的倍数") @@ -205,6 +255,7 @@ def test_job_records_mall_add_failure(): assert repo.rows[1]["fail_reason"] == "中包装加不进:数量须为10的倍数" +# 只跑一个平台时另一平台行仍停在系统管理员。 def test_job_platform_filter_leaves_other_mall_pending(): repo = MemoryCartRepo( [ @@ -213,6 +264,7 @@ def test_job_platform_filter_leaves_other_mall_pending(): ] ) + # 广东仓有货。 def search_yaoex(bar_code: str): return [_offer(GUANGDONG)] @@ -231,10 +283,12 @@ def test_job_platform_filter_leaves_other_mall_pending(): def test_job_search_error_is_system_fail(): + """查询失败原因列只写 QUERY_FAILED,戳改回系统管理员,status 保持 1。""" repo = MemoryCartRepo([_row()]) + # 抛带原文的查询错。 def search_yaoex(bar_code: str): - raise RuntimeError("网关 502") + raise RuntimeError("网关 502 secret-traceback") summary = run_cart_batch( repo, @@ -243,5 +297,155 @@ def test_job_search_error_is_system_fail(): login_errors={}, ) assert summary.failed == 1 - assert repo.rows[1]["fail_reason"].startswith("系统故障:") - assert "网关 502" in repo.rows[1]["fail_reason"] + reason = repo.rows[1]["fail_reason"] + assert reason == "系统故障:QUERY_FAILED" + assert repo.rows[1]["status"] == 1 + assert repo.rows[1]["update_user_name"] == STAMP_SELECTION + assert "网关 502" not in reason + assert "secret-traceback" not in reason + assert len(repo.shift_errors) == 1 + assert repo.shift_errors[0].error_code == "QUERY_FAILED" + assert "网关 502" in repo.shift_errors[0].error_text + assert "secret-traceback" in repo.shift_errors[0].error_text + + +# 加车抛错时原因列只写 ADD_FAILED,不含异常原文。 +def test_job_add_error_writes_short_code_not_exception(): + repo = MemoryCartRepo([_row()]) + + # 广东仓有货。 + def search_yaoex(bar_code: str): + return [_offer(GUANGDONG)] + + # 抛带 ORA 原文的加车错。 + def add_yaoex(offer, qty: int): + raise RuntimeError("ORA-03114 secret-traceback") + + summary = run_cart_batch( + repo, + searchers={YAOEX: search_yaoex}, + adders={YAOEX: add_yaoex}, + login_errors={}, + ) + assert summary.failed == 1 + reason = repo.rows[1]["fail_reason"] + assert reason == "系统故障:ADD_FAILED" + assert "ORA-03114" not in reason + assert "secret-traceback" not in reason + assert len(repo.shift_errors) == 1 + assert repo.shift_errors[0].error_code == "ADD_FAILED" + assert "ORA-03114" in repo.shift_errors[0].error_text + + +class HoldingCartRepo(MemoryCartRepo): + """内存仓库加上可计数的 session,用来断言商城调用期间连接已归还。""" + + def __init__(self, rows): + """在内存行上加 held 计数。""" + super().__init__(rows) + self.held = 0 + + @contextmanager + def session(self): + """借连接时 held 加一,退出时减回。""" + self.held += 1 + try: + yield + finally: + self.held -= 1 + + +def test_mall_runs_after_connection_is_returned(): + """两组加车时连接已归还,盖章时才再借。""" + repo = HoldingCartRepo( + [ + _row(fid=1, oms_id=2026100201), + _row(fid=2, oms_id=2026100202), + ] + ) + during_add: list[int] = [] + during_mark: list[int] = [] + original_claim = repo.claim_pending + original_success = repo.mark_success + original_errors = repo.save_shift_errors + + def claim_pending(limit=None, platform_types=None, **kwargs): + """领取时必须占着本班的连接。""" + assert repo.held == 1 + return original_claim(limit=limit, platform_types=platform_types, **kwargs) + + def mark_success(fid): + """盖章时必须占着本班的连接。""" + during_mark.append(repo.held) + return original_success(fid) + + def save_shift_errors(items): + """审计走连接池,不能在 session 里写。""" + assert repo.held == 0 + return original_errors(items) + + def search_yaoex(_bar_code: str): + """返回能对上仓的候选。""" + return [_offer(GUANGDONG)] + + def add_yaoex(_offer, _qty: int): + """加车时连接必须已经归还。""" + during_add.append(repo.held) + return AddOutcome(kind=KIND_OK) + + repo.claim_pending = claim_pending + repo.mark_success = mark_success + repo.save_shift_errors = save_shift_errors + summary = run_cart_batch( + repo, + searchers={YAOEX: search_yaoex}, + adders={YAOEX: add_yaoex}, + login_errors={}, + ) + assert during_add == [0, 0] + assert during_mark == [1, 1] + assert repo.held == 0 + assert summary.added == 2 + + +def test_rerun_skips_add_when_cart_already_has_the_line(): + """对方购物车已有不少于本行的数量、本地仍是加购中时,重跑不加第二份。""" + repo = MemoryCartRepo( + [ + _row( + update_user_name=STAMP_CLAIMING, + update_time=datetime.now() - timedelta(minutes=31), + purchase_qty=7, + ) + ] + ) + added: list[int] = [] + + def search_yaoex(_bar_code: str): + """购物车已够时不应再搜。""" + raise AssertionError("购物车已够,不应再搜") + + def add_yaoex(_offer, qty: int): + """购物车已够时不应再加。""" + added.append(qty) + return AddOutcome(kind=KIND_OK) + + def cart_qty(bar_code: str, supplier: str) -> int: + """这一仓比本行多。多出来的数量不当成另一张单。""" + assert bar_code == "06901234567890" + assert supplier == GUANGDONG + return 10 + + summary = run_cart_batch( + repo, + searchers={YAOEX: search_yaoex}, + adders={YAOEX: add_yaoex}, + login_errors={}, + cart_readers={YAOEX: cart_qty}, + ) + assert added == [] + assert summary.added == 1 + assert summary.failed == 0 + assert repo.rows[1]["status"] == 1 + assert repo.rows[1]["update_user_name"] == STAMP_DONE + assert repo.rows[1]["fail_reason"] is None diff --git a/rpa_cart/tests/test_cart_match.py b/rpa_cart/tests/test_cart_match.py index 5eb24a1..82b7b82 100644 --- a/rpa_cart/tests/test_cart_match.py +++ b/rpa_cart/tests/test_cart_match.py @@ -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: @@ -46,11 +46,3 @@ def test_same_supplier_keeps_cheaper(): def test_missing_supplier_returns_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 diff --git a/rpa_cart/tests/test_cart_repo.py b/rpa_cart/tests/test_cart_repo.py index 4623327..0cc9ea0 100644 --- a/rpa_cart/tests/test_cart_repo.py +++ b/rpa_cart/tests/test_cart_repo.py @@ -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 ( + LEASE_MINUTES, STAMP_CLAIMING, STAMP_DONE, STAMP_SELECTION, MemoryCartRepo, OracleCartRepo, + _pending_select_sql, ) +# 构造一条待加购表 2 行。 def _row(**overrides) -> dict: data = { "fid": 1, @@ -25,6 +41,7 @@ def _row(**overrides) -> dict: return data +# 系统管理员成功行领走盖成加购中,已完成和失败行不领。 def test_claim_pending_stamps_processing_and_skips_done(): repo = MemoryCartRepo( [ @@ -42,6 +59,29 @@ def test_claim_pending_stamps_processing_and_skips_done(): assert repo.rows[3]["status"] == 2 +# 戳加购中且 update_time 超过租约的行可以再领。 +def test_claim_reclaims_expired_claiming_stamp(): + now = datetime(2026, 10, 22, 12, 0, 0) + repo = MemoryCartRepo( + [_row(update_user_name=STAMP_CLAIMING, update_time=now - timedelta(minutes=31))] + ) + claimed = repo.claim_pending(now=now) + assert [row.fid for row in claimed] == [1] + assert repo.rows[1]["update_user_name"] == STAMP_CLAIMING + + +# 戳加购中且未过租约的行不领。 +def test_claim_skips_fresh_claiming_stamp(): + now = datetime(2026, 10, 22, 12, 0, 0) + repo = MemoryCartRepo( + [_row(update_user_name=STAMP_CLAIMING, update_time=now - timedelta(minutes=1))] + ) + claimed = repo.claim_pending(now=now) + assert claimed == [] + assert repo.rows[1]["update_user_name"] == STAMP_CLAIMING + + +# 成功只盖加购班,不插行、不改 status。 def test_mark_success_does_not_insert_or_change_status(): repo = MemoryCartRepo([_row()]) repo.claim_pending() @@ -53,6 +93,7 @@ def test_mark_success_does_not_insert_or_change_status(): assert repo.rows[1]["push_status"] == 0 +# 失败改 status=2 并写下原因。 def test_mark_fail_sets_status_and_reason(): repo = MemoryCartRepo([_row()]) repo.claim_pending() @@ -63,74 +104,234 @@ def test_mark_fail_sets_status_and_reason(): assert repo.rows[1]["update_user_name"] == STAMP_DONE +# 加购 .env 空缺时补选品和 oms .env,已有非空环境变量不覆盖。 +def test_load_runtime_env_fills_blank_from_other_env(tmp_path, monkeypatch): + monkeypatch.delenv("RPA_ORACLE_HOST", raising=False) + monkeypatch.delenv("RPA_ORACLE_PASSWORD", raising=False) + monkeypatch.setenv("RPA_ORACLE_USER", "keep-me") + cart_env = tmp_path / "cart.env" + select_env = tmp_path / "select.env" + oms_env = tmp_path / "oms.env" + cart_env.write_text("RPA_ORACLE_HOST=192.168.12.244\nRPA_ORACLE_PASSWORD=\n", encoding="utf-8") + select_env.write_text("RPA_ORACLE_PASSWORD=from-select\nRPA_ORACLE_USER=from-select\n", encoding="utf-8") + oms_env.write_text("RPA_ORACLE_PASSWORD=from-oms\n", encoding="utf-8") + load_job_env(tmp_path, [cart_env, select_env, oms_env]) + assert os.environ["RPA_ORACLE_HOST"] == "192.168.12.244" + assert os.environ["RPA_ORACLE_PASSWORD"] == "from-select" + assert os.environ["RPA_ORACLE_USER"] == "keep-me" + + +# 缺 HOST/用户/密码时 from_env 抛数据库不可用。 +def test_from_env_without_user_raises(monkeypatch): + monkeypatch.delenv("RPA_ORACLE_USER", raising=False) + monkeypatch.delenv("RPA_ORACLE_PASSWORD", raising=False) + monkeypatch.delenv("RPA_ORACLE_HOST", raising=False) + with pytest.raises(RepoUnavailable): + OracleCartRepo.from_env() + + +# 录 SQL 的假游标,fetchall 返回预设行。 +class _FakeCursor: + # 挂上共享的 executed 列表和预设查询结果。 + def __init__(self, executed: list, rows: list | None = None, description=None): + self._executed = executed + self._rows = rows or [] + self.description = description + self.rowcount = 1 + + # 进入 with,返回自身。 + def __enter__(self): + return self + + # 退出 with,不吞异常。 + def __exit__(self, *args): + return False + + # 记下 SQL 和 bind。一条 IN 超过 1000 个 fid 时抛错,模拟 ORA-01795。 + def execute(self, sql, params=None): + params = params or {} + _reject_oversized_in(params) + self._executed.append((sql, params)) + + # 返回预设行的副本。 + def fetchall(self): + return list(self._rows) + + +# 假连接:同一实例上的 cursor 共享 executed 列表。 +class _FakeConn: + # 可选带上领取查询要返回的行。 + def __init__(self, rows: list | None = None, description=None): + self.executed: list[tuple[str, dict]] = [] + self.committed = False + self.commits = 0 + self._rows = rows or [] + self._description = description + + # 新开一个录 SQL 的游标。 + def cursor(self): + return _FakeCursor(self.executed, self._rows, self._description) + + # 记下已提交。 + def commit(self): + self.committed = True + self.commits += 1 + + # 本套用例不应回滚。 + def rollback(self): + raise AssertionError("不应 rollback") + + # 归还时由池调用,假连接不计数。 + def close(self): + return None + + +# 只交出一条连接,并记下借了几次。 +class _FakePool: + # 挂上要借出的那条假连接。 + def __init__(self, conn): + self._conn = conn + self.acquires = 0 + + # 借出连接并给 acquires 加一。 + @contextmanager + def acquire(self): + self.acquires += 1 + yield self._conn + + +_CLAIM_DESCRIPTION = ( + ("FID",), + ("OMS_ID",), + ("PLATFORM_TYPE",), + ("SUPPLIER_NAME",), + ("BAR_CODE",), + ("PURCHASE_QTY",), + ("STATUS",), + ("FAIL_REASON",), + ("PUSH_STATUS",), + ("UPDATE_USER_NAME",), + ("OPERATE_TYPE",), +) + + +# 构造领取查询返回的一行元组。 +def _fetched_row(fid: int = 11): + return ( + fid, + 2026100201, + 66, + "广东壹号药业有限公司", + "0690", + 7, + 1, + None, + 0, + STAMP_SELECTION, + 1, + ) + + +# 领取 SQL 带 FOR UPDATE SKIP LOCKED,过期加购中可再领。 +def test_claim_sql_uses_skip_locked_and_lease(): + sql, params = _pending_select_sql(limit=None, platform_types=None) + text = " ".join(sql.split()).lower() + assert "for update skip locked" in text + assert "numtodsinterval(:lease_minutes" in text + assert params["lease_minutes"] == LEASE_MINUTES + assert LEASE_MINUTES == 30 + limited, limited_params = _pending_select_sql(limit=5, platform_types=None) + assert "skip locked" in " ".join(limited.split()).lower() + assert limited_params["limit"] == 5 + + +# 领取一次借连接:SKIP LOCKED 选出后盖成加购中。 def test_oracle_claim_updates_stamp(): - fetched = [ - ( - 11, - 2026100201, - 66, - "广东壹号药业有限公司", - "0690", - 7, - 1, - None, - 0, - STAMP_SELECTION, - 1, - ) - ] - executed: list[tuple[str, dict]] = [] - - class FakeCursor: - description = [ - ("FID",), - ("OMS_ID",), - ("PLATFORM_TYPE",), - ("SUPPLIER_NAME",), - ("BAR_CODE",), - ("PURCHASE_QTY",), - ("STATUS",), - ("FAIL_REASON",), - ("PUSH_STATUS",), - ("UPDATE_USER_NAME",), - ("OPERATE_TYPE",), - ] - rowcount = 1 - - def __enter__(self): - return self - - def __exit__(self, *args): - return False - - def execute(self, sql, params=None): - executed.append((sql, params or {})) - - def fetchall(self): - return fetched - - class FakeConn: - committed = False - - def cursor(self): - return FakeCursor() - - def commit(self): - self.committed = True - - def rollback(self): - raise AssertionError("不应 rollback") - - def close(self): - pass - - conn = FakeConn() - repo = OracleCartRepo(connect=lambda: conn) + conn = _FakeConn(rows=[_fetched_row()], description=_CLAIM_DESCRIPTION) + pool = _FakePool(conn) + repo = OracleCartRepo(pool) claimed = repo.claim_pending() + assert pool.acquires == 1 assert conn.committed is True assert len(claimed) == 1 assert claimed[0].fid == 11 assert claimed[0].update_user_name == STAMP_CLAIMING - assert "update_user_name" in executed[1][0].lower() - assert executed[1][1]["fid"] == 11 - assert executed[1][1]["stamp"] == STAMP_CLAIMING + select_sql, select_binds = conn.executed[0] + assert "skip locked" in select_sql.lower() + assert select_binds["lease_minutes"] == 30 + update_sql = " ".join(conn.executed[1][0].split()).lower() + assert "update_user_name" in update_sql + assert conn.executed[1][1]["stamp"] == STAMP_CLAIMING + assert 11 in conn.executed[1][1].values() + + +# 班次 session 内领取加两行盖章只从池里借一次连接。 +def test_session_claim_and_marks_acquire_once(): + conn = _FakeConn( + rows=[_fetched_row(11), _fetched_row(12)], + description=_CLAIM_DESCRIPTION, + ) + pool = _FakePool(conn) + repo = OracleCartRepo(pool) + with repo.session(): + claimed = repo.claim_pending() + assert [row.fid for row in claimed] == [11, 12] + repo.mark_success(11) + repo.mark_fail(12, "库存不足") + assert pool.acquires == 1 + assert conn.commits >= 3 + mark_sqls = [item[0].lower() for item in conn.executed[2:]] + assert any("fail_reason" in sql for sql in mark_sqls) + + +def _fid_bind_count(params: dict) -> int: + """数出本段 bind 里的 fid 个数。""" + return sum( + 1 + for key in params + if isinstance(key, str) and key.startswith("id") and key[2:].isdigit() + ) + + +def _reject_oversized_in(params: dict) -> None: + """一条 IN 超过 1000 个 fid 时抛错,模拟 ORA-01795。""" + if _fid_bind_count(params) > 1000: + raise RuntimeError("ORA-01795") + + +def test_claim_1001_rows_updates_first_chunk_without_unavailable(): + """领取 1001 条时第一段 500 能领走,同一事务提交,不报数据库不可用。""" + conn = _FakeConn( + rows=[_fetched_row(fid) for fid in range(1, 1002)], + description=_CLAIM_DESCRIPTION, + ) + pool = _FakePool(conn) + repo = OracleCartRepo(pool) + claimed = repo.claim_pending() + assert pool.acquires == 1 + assert len(claimed) == 1001 + assert claimed[0].update_user_name == STAMP_CLAIMING + updates = [ + item for item in conn.executed if item[0].lstrip().lower().startswith("update") + ] + assert [_fid_bind_count(params) for _, params in updates] == [500, 500, 1] + assert updates[0][1]["id0"] == 1 + assert updates[0][1]["stamp"] == STAMP_CLAIMING + assert conn.committed is True + + +# 登录和搜索不再往 sys.path 贴选品。 +def test_login_and_search_do_not_append_sys_path(): + root = Path(__file__).resolve().parent.parent + for name in ("yaoex_login.py", "cart_search.py"): + tree = ast.parse((root / name).read_text(encoding="utf-8")) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + if isinstance(func, ast.Attribute) and func.attr in {"append", "insert"}: + value = func.value + if isinstance(value, ast.Attribute) and value.attr == "path": + raise AssertionError(f"{name} 仍在改 sys.path") + if isinstance(value, ast.Name) and value.id == "path": + raise AssertionError(f"{name} 仍在改 path") diff --git a/rpa_cart/tests/test_main.py b/rpa_cart/tests/test_main.py index 3b6388e..e4ab9cd 100644 --- a/rpa_cart/tests/test_main.py +++ b/rpa_cart/tests/test_main.py @@ -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(): args = parse_args([]) - assert args.days == 0 - assert args.dry_run is False - assert args.xls is None assert args.command == "pay" -def test_parse_args_xls_dry_run(): - args = parse_args(["--xls", "/tmp/orders.xls", "--dry-run"]) - assert args.dry_run is True - assert str(args.xls).endswith("orders.xls") - assert args.command == "pay" +def test_parse_args_pay_rejects_mode_switches(): + with pytest.raises(SystemExit): + parse_args(["--xls", "/tmp/orders.xls", "--dry-run"]) + + +def test_pay_export_path_groups_by_order_day(tmp_path): + when = datetime(2026, 10, 24, 15, 30, 45) + path = pay_export_path( + "2026-10-24 00:00:00", + "2026-10-24 23:59:59", + when, + root=tmp_path, + ) + assert path == tmp_path / "2026-10-24" / "153045.xls" + + +def test_pay_export_path_keeps_same_second_export(tmp_path): + when = datetime(2026, 10, 24, 15, 30, 45) + first = pay_export_path( + "2026-10-24 00:00:00", + "2026-10-24 23:59:59", + when, + root=tmp_path, + ) + first.parent.mkdir(parents=True) + first.write_bytes(b"a") + second = pay_export_path( + "2026-10-24 00:00:00", + "2026-10-24 23:59:59", + when, + root=tmp_path, + ) + assert second == tmp_path / "2026-10-24" / "153045_2.xls" def test_parse_args_cart_subcommand(): + """显式 --limit 仍覆盖默认 20。""" args = parse_args(["cart", "--limit", "5", "--platform", "yaoex"]) assert args.command == "cart" assert args.limit == 5 assert args.platform == "yaoex" + + +def test_cart_default_limit_caps_claim(monkeypatch): + """不传 --limit 时加购最多领 20 行。""" + import main + + rows = [ + { + "fid": i, + "oms_id": 2026100200 + i, + "platform_type": 66, + "supplier_name": "广东壹号药业有限公司", + "bar_code": "06901234567890", + "purchase_qty": 1, + "status": 1, + "fail_reason": None, + "push_status": 0, + "update_user_name": STAMP_SELECTION, + "operate_type": 1, + } + for i in range(1, 31) + ] + repo = MemoryCartRepo(rows) + seen: dict[str, int | None] = {} + + def fake_run_cart_batch(bound_repo, **kwargs): + """只按入口传入的上限领取,不加车。""" + seen["limit"] = kwargs.get("limit") + claimed = bound_repo.claim_pending( + limit=kwargs.get("limit"), + platform_types=kwargs.get("platform_types"), + ) + seen["count"] = len(claimed) + return SimpleNamespace(claimed=len(claimed), added=0, failed=0) + + def no_browser(**_kwargs): + """挡住商城登录。""" + raise RuntimeError("no browser") + + monkeypatch.setattr(cart_job, "run_cart_batch", fake_run_cart_batch) + monkeypatch.setattr(main, "resolve_yaoex_auth", no_browser) + monkeypatch.setattr(main, "resolve_ybm_cookies", no_browser) + main.main(["cart"], repo=repo) + assert seen["limit"] == DEFAULT_CART_LIMIT + assert seen["count"] == 20 + assert seen["count"] <= DEFAULT_CART_LIMIT diff --git a/rpa_cart/tests/test_repo.py b/rpa_cart/tests/test_repo.py index 4f7d711..b3be94f 100644 --- a/rpa_cart/tests/test_repo.py +++ b/rpa_cart/tests/test_repo.py @@ -1,91 +1,130 @@ +# 支付落表仓库:假连接录 SQL。不连真库。 + +from __future__ import annotations + +from contextlib import contextmanager + 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: + # 进入 with,返回自身。 def __enter__(self): return self + # 退出 with,不吞异常。 def __exit__(self, *args): return False - def execute(self, sql, params=None): - executed.append((sql, params or {})) - + # 记下一次批量 SQL 和全部 bind。 def executemany(self, sql, params): - for item in params: - executed.append((sql, item)) + many.append((sql, [dict(item) for item in params])) + # 提交后记下 committed。 class FakeConn: committed = False + # 新开录 SQL 的游标。 def cursor(self): return FakeCursor() + # 记下已提交。 def commit(self): self.committed = True + # 本套用例不应回滚。 def rollback(self): raise AssertionError("不应 rollback") + # 假连接不计数。 def close(self): - pass + return None conn = FakeConn() - repo = OraclePayRepo(connect=lambda: conn) - repo.save_pay_rows( - [ - { - "operate_type": 2, - "oms_id": 0, - "platform_type": 66, - "supplier_name": "广东壹号药业有限公司", - "bar_code": "6921", - "purchase_price": 9.85, - "purchase_qty": 10, - "purchase_total": 98.5, - "status": None, - "fail_reason": None, - "pay_order_no": "ZQD1", - "pay_order_dtl_id": "a1b2c3d4e5f6789012345678901234ab", - "push_status": 0, - "org_code": None, - "multi_platform_flag": 0, - } - ] - ) + repo = OraclePayRepo(_FakePool(conn)) + row = { + "operate_type": 2, + "oms_id": 0, + "platform_type": 66, + "supplier_name": "广东壹号药业有限公司", + "bar_code": "6921", + "purchase_price": 9.85, + "purchase_qty": 10, + "purchase_total": 98.5, + "status": None, + "fail_reason": None, + "pay_order_no": "ZQD1", + "pay_order_dtl_id": "a1b2c3d4e5f6789012345678901234ab", + "push_status": 0, + "org_code": None, + "multi_platform_flag": 0, + } + second = dict(row) + second["pay_order_no"] = "ZQD2" + second["pay_order_dtl_id"] = "b1b2c3d4e5f6789012345678901234ab" + repo.save_pay_rows([row, second]) assert conn.committed is True - assert "rpa_cart_settlement_goods_data" in executed[0][0].lower() - assert executed[0][1]["operate_type"] == 2 - assert executed[0][1]["oms_id"] == 0 - assert executed[0][1]["pay_order_no"] == "ZQD1" - assert executed[0][1]["pay_order_dtl_id"] == "a1b2c3d4e5f6789012345678901234ab" + assert len(many) == 1 + sql, binds = many[0] + assert "rpa_cart_settlement_goods_data" in sql.lower() + assert [item["operate_type"] for item in binds] == [2, 2] + assert [item["oms_id"] for item in binds] == [0, 0] + assert [item["pay_order_no"] for item in binds] == ["ZQD1", "ZQD2"] + assert [item["pay_order_dtl_id"] for item in binds] == [ + "a1b2c3d4e5f6789012345678901234ab", + "b1b2c3d4e5f6789012345678901234ab", + ] + assert [item["create_user_name"] for item in binds] == ["rpa-cart", "rpa-cart"] +# 已有支付单号去重时丢掉空值。 def test_load_existing_pay_order_nos(): + # 返回预设支付单号。 class FakeCursor: + # 进入 with,返回自身。 def __enter__(self): return self + # 退出 with,不吞异常。 def __exit__(self, *args): return False + # 记下 SQL 和 bind。 def execute(self, sql, params=None): self.sql = sql self.params = params + # 返回含重复和空值的单号。 def fetchall(self): return [("ZQD1",), ("ZQD1",), (None,)] + # 只提供游标。 class FakeConn: + # 新开返回预设单号的游标。 def cursor(self): return FakeCursor() + # 假连接不计数。 def close(self): - pass + return None - repo = OraclePayRepo(connect=lambda: FakeConn()) + repo = OraclePayRepo(_FakePool(FakeConn())) keys = repo.load_existing_pay_order_nos() assert keys == {"ZQD1"} diff --git a/rpa_cart/yaoex_client.py b/rpa_cart/yaoex_client.py index a6686a9..9c3573b 100644 --- a/rpa_cart/yaoex_client.py +++ b/rpa_cart/yaoex_client.py @@ -1,4 +1,5 @@ -"""1 号药城订单导出。""" +# 1 号药城订单导出。 + from __future__ import annotations import json @@ -15,6 +16,7 @@ GATEWAY = "https://gateway-b2b.fangkuaiyi.com" EXPORT_URL = f"{GATEWAY}/api/order/export" +# 导出用的起止时间。默认只含当天,从 0 点到 23:59:59。 def day_range(day: date | None = None, days: int = 0) -> tuple[str, str]: end = day or date.today() start = end - timedelta(days=max(days, 0)) @@ -24,6 +26,7 @@ def day_range(day: date | None = None, days: int = 0) -> tuple[str, str]: ) +# 按时间段导出 1 号药城订单明细,返回 Excel 字节。回包不是表格就失败。 def export_orders( token: str, *, @@ -62,6 +65,7 @@ def export_orders( client.close() +# 拼 1 号药城导出表单,带上登录 token。 def _form(token: str, extra: dict[str, Any]) -> dict[str, Any]: payload = { "traderName": "yaoex_pc", @@ -77,6 +81,7 @@ def _form(token: str, extra: dict[str, Any]) -> dict[str, Any]: return payload +# 1 号药城订单导出请求头。来源页是订单列表。 def _headers() -> dict[str, str]: return { "Content-Type": "application/x-www-form-urlencoded", diff --git a/rpa_cart/yaoex_login.py b/rpa_cart/yaoex_login.py index 5edea0b..12da1e1 100644 --- a/rpa_cart/yaoex_login.py +++ b/rpa_cart/yaoex_login.py @@ -1,19 +1,18 @@ -"""复用 rpa_selection 的 1 号药城 / 药帮忙登录。""" +# 复用 rpa_selection 的 1 号药城 / 药帮忙登录。 +# +# 导入路径由 selection_path 放进 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)) - -from auth_login import ( # noqa: E402 +import selection_path # noqa: F401 先把 rpa_selection 放进导入路径 +from rpa_oracle import load_job_env +from auth_login import ( DEFAULT_YBM_COOKIE_PATH, DEFAULT_YAOEX_AUTH_PATH, BrowserRuntime, load_cookie_file, - load_dotenv, load_secrets, load_yaoex_auth_file, login_yaoex, @@ -21,17 +20,17 @@ from auth_login import ( # noqa: E402 save_cookie_file, save_yaoex_auth, ) -from search_yaoex import YaoexAuth # noqa: E402 +from search_yaoex import YaoexAuth +# 读加购 / 选品 / oms 的 .env,不覆盖已有非空变量。 def load_cart_dotenv() -> None: - load_dotenv(Path(__file__).resolve().parent / ".env") - load_dotenv(SELECTION_ROOT / ".env") + load_job_env(Path(__file__).resolve().parent) +# 拿到 1 号药城登录态。有缓存且 token 有效则直接用,否则走浏览器登录。 def resolve_yaoex_auth( *, - secrets_path: Path | None = None, auth_path: Path | None = None, fresh: bool = False, runtime: BrowserRuntime | None = None, @@ -41,13 +40,15 @@ def resolve_yaoex_auth( auth = load_yaoex_auth_file(cached) if auth.token and auth.token != "t": return auth - secrets = load_secrets(secrets_path) + secrets = load_secrets() 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 session = runtime or BrowserRuntime(keep=False) try: - auth = login_yaoex(secrets_path, runtime=session) + auth = login_yaoex(runtime=session) save_yaoex_auth(auth, cached) return auth finally: @@ -55,9 +56,9 @@ def resolve_yaoex_auth( session.close() +# 拿到药帮忙登录 cookie。有缓存则直接用,否则走浏览器登录。 def resolve_ybm_cookies( *, - secrets_path: Path | None = None, cookie_path: Path | None = None, fresh: bool = False, runtime: BrowserRuntime | None = None, @@ -67,13 +68,15 @@ def resolve_ybm_cookies( cookies = load_cookie_file(cached) if cookies: return cookies - secrets = load_secrets(secrets_path) + secrets = load_secrets() 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 session = runtime or BrowserRuntime(keep=False) try: - cookies = login_ybm(secrets_path, runtime=session) + cookies = login_ybm(runtime=session) save_cookie_file(cookies, cached) return cookies finally: diff --git a/rpa_contract/README.md b/rpa_contract/README.md index f9fffba..0195c61 100644 --- a/rpa_contract/README.md +++ b/rpa_contract/README.md @@ -12,26 +12,28 @@ 口径:`docs/RPA订单中心接口开发口径_20260930.md` 第 4 节;合单细节见 `docs/合同系统录入流程.md`。 -## 测试账号 +## 测试环境 地址:`http://contract-test.gzmpc.com:8080/` -账号:`10007228 / 1` +账密只认环境变量 `CONTRACT_USERNAME` / `CONTRACT_PASSWORD`,缺则入口失败。不要把密码写进仓库。 客商必须是合同系统里真实存在的操作码,占位符搜不到。例如 `DW0100046361` / `重庆亿昊药业有限公司`。 ## 页面步骤 -脚本直达起草/模板 URL: +脚本直达起草/模板 URL。修一步只打开对应文件;`contract_flow_steps.py` 是对外入口。 -1. 打开合同系统并登录 -2. 进起草,套非格式采购协议模板 -3. 签约方:客户编码、客户名称 +1. 打开合同系统并登录(`contract_login.py` S02–S05) +2. 进起草,套非格式采购协议模板(直达 `FORM_PAGE_URL`) +3. 签约方:客户编码、客户名称(`contract_form.py` S06) 4. 尽调:页面正文含「未尽调」则本单失败,不提交 -5. 基础信息:合同版本类型、合同类别、结算类型、合同背景概述、合同性质=主合同 -6. 金额与日期:签订日 / 开始日 / 结束日用接口 3 第一行三日期(JSON 干跑未给则回退 `orderDate`) -7. 货品明细 -8. 付款与标志:这期一律账期,不勾预付款;页面「账期支付」对应选项「电汇」 -9. 点提交,不要点暂存 -10. 从「我的合同」读正式合同号 +5. 基础信息:合同版本类型、合同类别、结算类型、合同背景概述、合同性质=主合同(S07) +6. 金额与日期:签订日 / 开始日 / 结束日用接口 3 第一行三日期(JSON 干跑未给则回退 `orderDate`)(S08) +7. 货品明细:弹窗按操作码搜,找不到则本单失败(`contract_goods.py` S09) +8. 付款与标志:这期一律账期,不勾预付款;页面「账期支付」对应选项「电汇」(S10) +9. 挂合同正文(`contract_upload.py` S11);点提交(`contract_submit.py` S12),不要只点暂存 +10. 按 fdId 从详情读正式合同号(`contract_number.py` S13) + +选择器、工具栏、`fdId` 在 `contract_common.py`。 ## 运行 @@ -52,6 +54,58 @@ uv run python run_batch.py `submitMode=dry_run` 跑到提交前,不点提交。真起草用 `examples/contract_draft_yyc.json` / `examples/contract_draft_xyy.json`。 +## 运维 + +### 怎么跑 + +```bash +cd rpa_contract +uv sync --python 3.12 +uv run python run_batch.py +``` + +Oracle 空缺时补 `rpa_oms/.env`。连接从 `rpa_oracle` 池取,班次默认池大小 1。缺 `RPA_ORACLE_HOST` / 用户 / 密码时入口失败,文案「数据库不可用」。合同系统账密用 `CONTRACT_USERNAME` / `CONTRACT_PASSWORD`。未捕获异常原文进 `rpa_shift_error_audit`,Windows 上先执行 `docs/ddl/rpa_oms_staging_tables.sql` 里这张表。 + +JSON 干跑(不领表): + +```bash +uv run python main.py --input-json examples/contract_create_input_dry_run.json +``` + +### 看哪张表哪几列 + +- 表 3 `rpa_purchase_contract_data`:`process_status`(0 待领,3 处理中,1 成功,2 失败)、`process_time`、`process_result`、`supplier_id`、`contract_entry_status`、`contract_no` +- 表 4 `rpa_contract_agreement_result`:`action_code=1`、`contract_status`(1 起草成功,3 失败)、`contract_no`、`contract_fail_reason`、`push_status=0` + +### 崩溃改回 SQL + +停在 3 且未过 30 分钟租约: + +```sql +UPDATE rpa_purchase_contract_data +SET process_status = 0 +WHERE process_status = 3 AND fid = :fid; +``` + +过期的 3 下一班会再领,不必改。协议重录行(`contract_entry_status=0` 且已有 `contract_no`)本班不领。 + +### 原因列 vs 日志原文 + +表 3 `process_result` / 表 4 `contract_fail_reason`:尽调写「客户未尽调」,页面 NodeError 写业务文案,未捕获异常写 `PAGE_FAILED`。异常原文打本机日志,不进这两列。 + +### 点名命令 + +```bash +cd rpa_contract +uv run pytest tests/test_job.py::test_job_merges_same_supplier_and_writes_table4 -q +uv run pytest tests/test_job.py::test_job_marks_diligence_fail -q +uv run pytest tests/test_oracle_repo.py::test_from_env_without_user_raises -q +uv run pytest tests/test_job.py::test_claim_reclaims_expired_status_3 tests/test_job.py::test_claim_skips_fresh_status_3 -q +uv run pytest tests/test_flow_gates.py -q +``` + +页面验收只看控制台 `ACCEPT SUMMARY` 或 `screenshots/` 下 `ACCEPT.txt`,禁止读 png。 + ## 本期不做 -返利班、向订单中心发 HTTP、改 `push_status`。 +向订单中心发 HTTP、改 `push_status`。返利在 `rpa_rebate/`。点击顺序和选择器本段不改。 diff --git a/rpa_contract/accept.py b/rpa_contract/accept.py index 3ffc538..af958e4 100644 --- a/rpa_contract/accept.py +++ b/rpa_contract/accept.py @@ -1,9 +1,7 @@ -"""Shared ACCEPT report for every RPA flow (any platform / page). +# 页面验收报告。每步写一行 ACCEPT,汇总进 ACCEPT.txt。 +# +# 验收看这份文本,不看截图。截图只在失败时留,给人查页面。 -New flows (ORDER_FETCH, REBATE_*, M5, ...) must use this instead of -reading screenshots. Screenshots stay on disk for humans; this text is -the agent oracle. -""" from __future__ import annotations import os @@ -17,10 +15,14 @@ PACKAGE_ROOT = Path(__file__).resolve().parent SCREENSHOT_ROOT = PACKAGE_ROOT / "screenshots" +# 能按名字截图的浏览器。 class HasScreenshot(Protocol): - def screenshot(self, name: str) -> str: ... + # 按名字截一张图,返回保存路径。 + def screenshot(self, name: str) -> str: + ... +# 验收的一步:步骤号、标题、是否通过、读到的事实、对不上的字段。 @dataclass class AcceptLine: step: str @@ -30,23 +32,26 @@ class AcceptLine: mismatches: list[str] +# 把流程名收成目录名。空名用 default,其余只留字母、数字、横线和底线。 def safe_flow_name(flow: str) -> str: text = (flow or "default").strip() or "default" return "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in text) +# 这个流程的截图和 ACCEPT.txt 放在哪。 def artifact_dir(flow: str) -> Path: return SCREENSHOT_ROOT / safe_flow_name(flow) +# 默认只在失败时截图。RPA_SCREENSHOT=always 时每步都留。 def evidence_shot(browser: HasScreenshot, name: str, *, failed: bool = False) -> str: - """Save a screenshot only on failure. Set RPA_SCREENSHOT=always to keep every frame.""" mode = (os.environ.get("RPA_SCREENSHOT") or "fail").strip().lower() if not failed and mode != "always": return "" return browser.screenshot(name) +# ACCEPT.txt 的路径。RPA_ACCEPT_LOG 可改到别处,单测用这个钉住文件。 def accept_log_path(flow: str = "default") -> Path: override = os.environ.get("RPA_ACCEPT_LOG") if override: @@ -54,6 +59,7 @@ def accept_log_path(flow: str = "default") -> Path: return artifact_dir(flow) / "ACCEPT.txt" +# 页面读数和期望是否同一值。空期望算通过;数字、是否、去空白后包含也算对上。 def field_match(actual: str, expected: str) -> bool: actual_text = (actual or "").strip() expected_text = (expected or "").strip() @@ -78,6 +84,7 @@ def field_match(actual: str, expected: str) -> bool: return compact_e in compact_a or compact_a in compact_e +# 把一步收成控制台一行:ACCEPT、步骤、ok 或 FAIL、标题和读到的字段。 def format_accept(line: AcceptLine) -> str: status = "ok" if line.ok else "FAIL" facts = " ".join(f"{key}={value}" for key, value in line.facts.items()) @@ -85,16 +92,17 @@ def format_accept(line: AcceptLine) -> str: return f"ACCEPT {line.step} {status} {line.title} {facts}{extra}".rstrip() +# 一次页面流的验收单。每步打印并覆写 ACCEPT.txt,对不上就抛 NodeError。 @dataclass class AcceptanceReport: - """DOM / page-state checklist. One instance per flow run.""" - flow: str = "default" lines: list[AcceptLine] = field(default_factory=list) + # 清空已记的步骤,下一次页面流重新计。 def reset(self) -> None: self.lines = [] + # 核对一组字段。对不上就记 FAIL、失败时截图,并抛 NodeError 停下这一步。 def check( self, step: str, @@ -122,6 +130,7 @@ class AcceptanceReport: evidence_shot(browser, screenshot_on_fail, failed=True) raise NodeError(f"ACCEPT {step} FAIL " + "; ".join(mismatches)) + # 记一步事实,不和期望逐字段比。ok 为假且 raise_error 时抛 NodeError。 def note( self, step: str, @@ -138,6 +147,7 @@ class AcceptanceReport: if not ok and raise_error: raise NodeError(f"ACCEPT {step} FAIL {title} " + " ".join(f"{k}={v}" for k, v in rendered.items())) + # 把当前汇总写进 ACCEPT.txt。写失败只打印,不把页面流打成另一次错误。 def dump(self) -> None: path = accept_log_path(self.flow) try: @@ -146,6 +156,7 @@ class AcceptanceReport: except OSError as exc: print(f"ACCEPT log write skipped: {exc}", flush=True) + # 汇总文本:第一行是通过数,后面是每步的 ACCEPT 行。 def summary_text(self) -> str: passed = sum(1 for line in self.lines if line.ok) rows = [f"ACCEPT SUMMARY {self.flow} {passed}/{len(self.lines)} ok"] diff --git a/rpa_contract/apa_browser_adapter.py b/rpa_contract/apa_browser_adapter.py index b9026cf..19eaa2c 100644 --- a/rpa_contract/apa_browser_adapter.py +++ b/rpa_contract/apa_browser_adapter.py @@ -1,627 +1,33 @@ -# coding=utf-8 -""" -Selenium Chrome 浏览器适配。通过 WebDriver 操控 Chrome。 -""" +# 合同班浏览器适配。复用 rpa_browser 共享适配器。 +# +# 本文件只做导入入口,步骤函数仍 from apa_browser_adapter import ApaBrowserAdapter。 +# Chrome 用户目录和调试口用共享默认:CONTRACT_CREATE 口 9333。 + from __future__ import annotations -import os -import socket -import time +import sys from pathlib import Path -REPO_ROOT = Path(__file__).resolve().parents[1] - -_FLOW_DEBUG_PORTS = { - "CONTRACT_CREATE": 9333, - "REBATE_FIND": 9335, - "default": 9334, -} - - -def _is_inside_repo(path: Path, repo_root: Path) -> bool: - try: - path.resolve().relative_to(repo_root.resolve()) - except ValueError: - return False - return True - - -def resolve_chrome_profile_base(requested: str | None = None, *, repo_root: Path | None = None) -> Path: - """Chrome user-data dir must stay outside the repo so list_dir never walks 3GB.""" - 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 - - -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) - # 原生 - 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 + return _focus_agreement_tab(driver) - 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), + # 独立进程拉起 Chrome 再附着,Python 退出后窗口还在。 + def _launch_driver(self, service, profile_dir: Path, port: int): + return launch_independent_chrome( + service, + profile_dir, + port, + restore_session=use_user_chrome(), ) - - 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 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) - - 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} 文件") - - 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) diff --git a/rpa_rebate/db.py b/rpa_rebate/db.py deleted file mode 100644 index b6646be..0000000 --- a/rpa_rebate/db.py +++ /dev/null @@ -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 diff --git a/rpa_rebate/errors.py b/rpa_rebate/errors.py index 6649208..2c23fc8 100644 --- a/rpa_rebate/errors.py +++ b/rpa_rebate/errors.py @@ -1,12 +1,13 @@ -"""Page-flow errors. Codes stay for logs.""" - +# 返利页面流错误。错误码留在日志里。 +# 页面元素找不到或页面改版。不重试,交给运维。 class NodeError(RuntimeError): error_code = "UI_ELEMENT_NOT_FOUND" retryable = False route_role = "OPS" +# 返利中心登录失败。可以重试。 class LoginFailed(NodeError): error_code = "SYS_LOGIN_FAIL" retryable = True diff --git a/rpa_rebate/job.py b/rpa_rebate/job.py index 1ff850d..f598f08 100644 --- a/rpa_rebate/job.py +++ b/rpa_rebate/job.py @@ -1,12 +1,20 @@ -"""返利班次:领表 4 起草行 / 表 3 只录协议行 → runner 查号+总单+细单+送审 → 写表 4。""" +# 返利班次:领表 4 起草行 / 表 3 只录协议行 → runner 查号+总单+细单+送审 → 写表 4。 +# +# 崩在表 4 或表 3 的 process_status=3:超过租约可再领,未过期需改回 0。 +# 未捕获异常原因列只写短码。原文进审计表,会话结束后另写。 + from __future__ import annotations +import logging from collections import OrderedDict +from contextlib import nullcontext from dataclasses import dataclass from typing import Callable +from rpa_oracle import ShiftError from persist import ( KIND_FAIL, + KIND_HOLD, KIND_NOT_FOUND, KIND_OK, AgreementOutcome, @@ -17,12 +25,18 @@ from persist import ( ) from repo import Claimed +HEADER_FAILED = "HEADER_FAILED" +IMPORT_FAILED = "IMPORT_FAILED" +SUBMIT_FAILED = "SUBMIT_FAILED" +PAGE_FAILED = "PAGE_FAILED" Runner = Callable[[RebateGroup], AgreementOutcome] Header = Callable[[str], str] Importer = Callable[[str], object] Submitter = Callable[[str], object] +logger = logging.getLogger("rpa_rebate") +# 一个返利班次的领取、合组和成败计数。 @dataclass class JobSummary: claimed: int = 0 @@ -37,39 +51,72 @@ def recorder( header: Header, importer: Importer, submitter: Submitter, + existing: Callable[[str], str] | None = None, ) -> AgreementOutcome: + """按总单 → 细单新增 → 送审录入一份协议。 + + 送审前按合同号查已有协议。已有总单号则直接带回,不再新建。 + existing 返回空串表示确认没有协议。抛错表示读号失败,保持处理中。 + 任一步抛错只写短码,原文打日志。业务失败仍写对应中文原因。 + """ + if existing is not None: + try: + found_id = (existing(contract_no) or "").strip() + except Exception as exc: + logger.exception("返利查已有协议失败 contract_no=%s", contract_no) + return AgreementOutcome(KIND_HOLD, None, None, "读号失败", _text(exc)) + if found_id: + return AgreementOutcome(KIND_OK, found_id, None, None) try: order_no = (header(contract_no) or "").strip() except Exception as exc: - return AgreementOutcome(KIND_FAIL, None, None, str(exc) or "总单失败") + logger.exception("返利总单未捕获异常 contract_no=%s", contract_no) + return AgreementOutcome(KIND_FAIL, None, None, HEADER_FAILED, _text(exc)) if not order_no: return AgreementOutcome(KIND_FAIL, None, None, "总单未拿到号") try: imported = importer(contract_no) except Exception as exc: - return AgreementOutcome(KIND_FAIL, None, None, str(exc) or "细单导入失败") + logger.exception("返利细单未捕获异常 contract_no=%s", contract_no) + return AgreementOutcome(KIND_FAIL, None, None, IMPORT_FAILED, _text(exc)) if not _import_ok(imported): return AgreementOutcome(KIND_FAIL, None, None, _import_reason(imported)) try: submitted = submitter(contract_no) except Exception as exc: - return AgreementOutcome(KIND_FAIL, None, None, str(exc) or "送审失败") + logger.exception("返利送审未捕获异常 contract_no=%s", contract_no) + return AgreementOutcome(KIND_FAIL, None, None, SUBMIT_FAILED, _text(exc)) if submitted is None or not bool(getattr(submitted, "submitted", False)): return AgreementOutcome(KIND_FAIL, None, None, "送审失败") agreement_id = (getattr(submitted, "agreement_id", None) or "").strip() if not agreement_id: - return AgreementOutcome(KIND_FAIL, None, None, "送审未读到总单号") + return AgreementOutcome(KIND_HOLD, None, None, "送审未读到总单号") agreement_dtl_id = (getattr(submitted, "agreement_dtl_id", None) or "").strip() or None return AgreementOutcome(KIND_OK, agreement_id, agreement_dtl_id, None) +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: - claimed = repo.claim_pending(limit=limit) + """领取待查号行,按合同号合组、调 runner、落表。 + + session 只包领取和每一组的落表。查号和录协议在连接归还之后跑。一组落完再处理下一组。 + runner 抛未捕获异常时原因列只写 PAGE_FAILED。limit 交给领取层。 + """ + audits: list[ShiftError] = [] + with _session(repo): + claimed = repo.claim_pending(limit=limit) groups = group_claimed(claimed) summary = JobSummary( claimed=len(claimed.drafts) + len(claimed.protocols), @@ -79,24 +126,40 @@ def run_batch( try: outcome = runner(group) except Exception as exc: - outcome = AgreementOutcome(KIND_FAIL, None, None, str(exc)) + logger.exception("返利班未捕获异常 contract_no=%s", group.contract_no) + outcome = AgreementOutcome(KIND_FAIL, None, None, PAGE_FAILED, _text(exc)) if outcome is None: outcome = AgreementOutcome(KIND_FAIL, None, None, "协议录入失败") + if outcome.error_text: + audits.append( + ShiftError( + "返利", + group.contract_no, + outcome.reason or PAGE_FAILED, + outcome.error_text, + ) + ) + with _session(repo): + if outcome.kind == KIND_NOT_FOUND: + repo.save_group(build_not_found(group)) + else: + repo.save_group(build_found(group, outcome)) if outcome.kind == KIND_NOT_FOUND: - repo.save_group(build_not_found(group)) summary.not_found += 1 continue - repo.save_group(build_found(group, outcome)) if agreement_ok(outcome): summary.ok += 1 else: summary.failed += 1 + repo.save_shift_errors(audits) return summary +# 把已领取的起草行和只录协议行按合同号收成组,组内顺序跟领取顺序一致。 def group_claimed(claimed: Claimed) -> list[RebateGroup]: groups: OrderedDict[str, RebateGroup] = OrderedDict() + # 取出或新建该合同号的组。 def bucket(contract_no: str) -> RebateGroup: number = (contract_no or "").strip() if number not in groups: @@ -110,6 +173,13 @@ def group_claimed(claimed: Claimed) -> list[RebateGroup]: return list(groups.values()) +# 取出异常原文。空串不当审计正文。 +def _text(exc: BaseException) -> str | None: + text = str(exc).strip() + return text or None + + +# 细单新增至少成功一行、失败零行、解析成功才算过。 def _import_ok(result: object) -> bool: if result is None: return False @@ -119,6 +189,7 @@ def _import_ok(result: object) -> bool: return bool(parsed) and success > 0 and fail == 0 +# 取出细单第一条失败原因;没有则写「细单导入失败」。 def _import_reason(result: object) -> str: if result is None: return "细单导入失败" diff --git a/rpa_rebate/persist.py b/rpa_rebate/persist.py index 7dd455b..5772004 100644 --- a/rpa_rebate/persist.py +++ b/rpa_rebate/persist.py @@ -1,4 +1,8 @@ -"""返利班结果:查号 / 送审 → 表 4 更新与插入、表 3 状态。""" +# 返利班结果:查号 / 送审 → 表 4 更新与插入、表 3 状态。 +# +# 搜不到把表 4 process_status 改回 0、表 3 改回 0。送审成功才插 action_code=2。 +# 系统故障原因列写短码,不写异常原文。人名固定系统管理员。 + from __future__ import annotations from dataclasses import dataclass, field @@ -6,20 +10,27 @@ from dataclasses import dataclass, field KIND_OK = "ok" KIND_FAIL = "fail" KIND_NOT_FOUND = "not_found" -STAMP_CONTRACT = "合同班" -STAMP_CLAIMING = "查号中" -STAMP_DONE = "返利班" +KIND_HOLD = "hold" +USER_NAME = "系统管理员" +PROCESS_PENDING = 0 +PROCESS_DONE = 1 PROCESS_RESULT_MAX = 1000 +AGREEMENT_REASON_MAX = 500 +# 一组合同的查号/录协议结果:成功带总单/细单号,失败带原因。 +# +# error_text 只给审计表,不进原因列。 @dataclass(frozen=True) class AgreementOutcome: kind: str agreement_id: str | None agreement_dtl_id: str | None reason: str | None + error_text: str | None = None +# 同一合同号下本班领到的起草行和只录协议行。 @dataclass class RebateGroup: contract_no: str @@ -27,6 +38,7 @@ class RebateGroup: protocols: list[dict] = field(default_factory=list) +# 一组合同要写回 Oracle 的表 4 更新/插入/释放和表 3 回写。 @dataclass(frozen=True) class GroupSave: table4_updates: list[dict] @@ -35,6 +47,7 @@ class GroupSave: table3_updates: list[dict] +# 把原因截到 max_len 字,超长末尾加省略号。 def clip(text: str, max_len: int) -> str: text = (text or "").strip() if len(text) <= max_len: @@ -42,10 +55,12 @@ def clip(text: str, max_len: int) -> str: return text[: max_len - 1] + "…" +# 送审成功且总单号非空才算这一组过。 def agreement_ok(outcome: AgreementOutcome) -> bool: return outcome.kind == KIND_OK and bool((outcome.agreement_id or "").strip()) +# 搜不到:起草行 process_status 改回 0,表 3 改回 0,不插协议行。 def build_not_found(group: RebateGroup) -> GroupSave: return GroupSave( table4_updates=[], @@ -59,13 +74,44 @@ def build_not_found(group: RebateGroup) -> GroupSave: def build_found(group: RebateGroup, outcome: AgreementOutcome) -> GroupSave: + """搜到后按成败写表 4 / 表 3。 + + 送审成功:起草行 contract_status=2、process_status=1,并按 oms_id 插 action_code=2。 + 确认失败:起草行 process_status 改回 0。读号失败保持 process_status=3,不插协议行。 + 只录协议行先补一条 action_code=1。只有确认没有单据才放回待领。 + """ + if outcome.kind == KIND_HOLD: + reason = clip(outcome.reason or "读号失败", AGREEMENT_REASON_MAX) + return GroupSave( + table4_updates=[ + { + "fid": int(row["fid"]), + "contract_status": 1, + "process_status": 3, + "agreement_fail_reason": reason, + } + for row in group.drafts + ], + table4_inserts=[], + table4_release_fids=[], + table3_updates=[ + { + "fid": int(row["fid"]), + "process_status": 3, + "process_result": reason, + } + for row in group.protocols + ], + ) ok = agreement_ok(outcome) number = (group.contract_no or "").strip() + fail_reason = clip(outcome.reason or "协议录入失败", AGREEMENT_REASON_MAX) updates = [ { "fid": int(row["fid"]), - "contract_status": 2, - "update_user_name": STAMP_DONE, + "contract_status": 2 if ok else 1, + "process_status": PROCESS_DONE if ok else PROCESS_PENDING, + "agreement_fail_reason": None if ok else fail_reason, } for row in group.drafts ] @@ -102,6 +148,7 @@ def build_found(group: RebateGroup, outcome: AgreementOutcome) -> GroupSave: ) +# 只录协议行补一条表 4 起草成功记录,action_code=1、contract_status=2。 def _action1_success(row: dict, contract_no: str) -> dict: return { "oms_id": int(row["oms_id"]), @@ -114,14 +161,16 @@ def _action1_success(row: dict, contract_no: str) -> dict: "agreement_id": None, "agreement_dtl_id": None, "push_status": 0, + "process_status": PROCESS_DONE, "org_code": row.get("org_code"), "create_user_id": 0, - "create_user_name": STAMP_DONE, + "create_user_name": USER_NAME, "update_user_id": 0, - "update_user_name": STAMP_DONE, + "update_user_name": USER_NAME, } +# 送审成功插一条协议行,action_code=2、agreement_status=1,带总单/细单号。 def _action2(row: dict, contract_no: str, outcome: AgreementOutcome) -> dict: return { "oms_id": int(row["oms_id"]), @@ -134,9 +183,10 @@ def _action2(row: dict, contract_no: str, outcome: AgreementOutcome) -> dict: "agreement_id": (outcome.agreement_id or "").strip() or None, "agreement_dtl_id": (outcome.agreement_dtl_id or "").strip() or None, "push_status": 0, + "process_status": PROCESS_DONE, "org_code": row.get("org_code"), "create_user_id": 0, - "create_user_name": STAMP_DONE, + "create_user_name": USER_NAME, "update_user_id": 0, - "update_user_name": STAMP_DONE, + "update_user_name": USER_NAME, } diff --git a/rpa_rebate/pyproject.toml b/rpa_rebate/pyproject.toml index c50c7e4..d99167e 100644 --- a/rpa_rebate/pyproject.toml +++ b/rpa_rebate/pyproject.toml @@ -20,7 +20,7 @@ dev = [ package = false [tool.pytest.ini_options] -pythonpath = ["."] +pythonpath = [".", ".."] testpaths = ["tests"] addopts = "-q" diff --git a/rpa_rebate/rebate_common.py b/rpa_rebate/rebate_common.py new file mode 100644 index 0000000..71138d6 --- /dev/null +++ b/rpa_rebate/rebate_common.py @@ -0,0 +1,2443 @@ +# 返利页面共用:登录、进采购协议、search_contract、勾总单行、弹层/选人/货源选择。 + +from __future__ import annotations + +import re +import time +from dataclasses import dataclass +from datetime import date +from decimal import Decimal, ROUND_HALF_UP +from typing import Protocol + +from errors import LoginFailed, NodeError + +FLOW = "REBATE_FIND" + +HEADER_FLOW = "REBATE_HEADER" + +IMPORT_FLOW = "REBATE_IMPORT" + +SUBMIT_FLOW = "REBATE_SUBMIT" + +DEFAULT_URL = "http://192.168.170.144:8001" + +SENTINEL_CONTRACT_NO = "__NO_SUCH_CONTRACT__" + +PAY_MODE_NEXT_MONTH_N = "下月N号结" + +REBATE_CONTRACT_DEFAULT = "否" + +SETTLE_MODE_DEFAULT = "否" + +TAX_RATE_DEFAULT = "0.13" + +PAY_MODE_CODE = "11" + +PAY_PARAM_DEFAULT = "25" + +PAY_PARAM_SALES = "以销售定结" + +SETTLE_CODE = "3" + +SETTLE_MODE_WIRE = "电汇" + +COMPENSATION_DEFAULT = "0" + +PLANNER_DEFAULT = "廖建琦" + +CONTACT_DEFAULT = "苏文辉" + +_PORTAL_MARKERS = ("采购协议", "待审批", "待审批事项", "待办", "菜单名称", "下载打印插件") + +EMPTY_MARKERS = ("暂无数据", "没有找到", "查询无结果", "无匹配数据", "未找到相关") + +LOGIN = { + "user": [ + 'input[name="username"]', + 'input[name="userName"]', + 'input[placeholder*="用户名"]', + 'input[placeholder*="账号"]', + 'input[placeholder*="工号"]', + 'input[type="text"]', + ], + "password": [ + 'input[name="password"]', + 'input[placeholder*="密码"]', + 'input[type="password"]', + ], + "submit": [ + "button.login-submit", + "button.el-button--primary", + "button:contains(登录)", + 'button[type="submit"]', + 'input[type="submit"]', + 'input[value="登录"]', + ], +} + +MENU = { + "entry": [ + "text=采购协议管理", + "text=采购协议", + "a:contains(采购协议管理)", + "span:contains(采购协议管理)", + ], +} + +SEARCH = { + "contract_no": [ + "label=合同编号", + "label=合同号", + 'input[placeholder*="合同编号"]', + 'input[placeholder*="合同号"]', + 'input[name*="contractNo"]', + 'input[name*="contract"]', + ], + "button": [ + "text=查询", + "button:contains(查询)", + "a:contains(查询)", + "text=搜索", + "button:contains(搜索)", + ], +} + +_TABLE_SCRIPT = """ +const nodes = Array.from(document.querySelectorAll( + 'table tbody, table, .el-table__body, .el-table, .ant-table-tbody, .ant-table, .vxe-table, .vxe-body--row' +)); +return nodes.map((n) => (n.innerText || '').trim()).filter(Boolean).join('\\n'); +""" + +# 返利页面操作口,由 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 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: + ... + + # 读当前页可见正文。 + def read_body_text(self) -> str: + ... + + # 在当前页跑一段脚本并返回结果。 + def execute_script(self, script: str, *args): + ... + + # 按名字截一张图,返回保存路径。 + def screenshot(self, name: str) -> str: + ... + + # 在第一个匹配到的 select 上选中指定值。 + def select_first(self, selectors: list[str], value: str) -> None: + ... + + # 读第一个匹配元素的值或文本。 + def read_first(self, selectors: list[str], value_name: str = "") -> str: + ... + + # 把本地文件塞进第一个匹配到的 file input。 + def upload_file_first(self, selectors: list[str], file_path: str) -> None: + ... + +# 一次返利页面会话的入参:地址和账密。 +@dataclass(frozen=True) +class RebateFlowContext: + rebate_url: str + username: str + password: str + +# 总单卡片要填的字段。计划员现阶段写死廖建琦,联系人苏文辉。 +@dataclass(frozen=True) +class RebateHeaderDraft: + planner_name: str + supplier_code: str + rebate_unit_code: str + rebate_contract: str = REBATE_CONTRACT_DEFAULT + settle_mode: str = SETTLE_MODE_DEFAULT + pay_mode: str = PAY_MODE_NEXT_MONTH_N + protocol_start: str = "" + protocol_end: str = "" + contact_name: str = "" + supplier_name: str = "" + supplier_id: str = "" + +# 一条细单:货品ID、数量、含税单价、订单号。货源细单ID本期不填。 +@dataclass(frozen=True) +class RebateImportLine: + goods_code: str + qty: str + tax_price: str + order_no: str + source_line_id: str = "" + goods_id: str = "" + tax_rate: str = "" + net_price: str = "" + +# 一组细单加上当天日期,供新增节点一次加完。 +@dataclass(frozen=True) +class RebateImportDraft: + lines: tuple[RebateImportLine, ...] + today: str = "" + +# 细单保存后的成功/失败条数。页面新增路径用货品表行数当成功数。 +@dataclass(frozen=True) +class RebateImportResult: + success_count: int + fail_count: int + fail_reasons: tuple[str, ...] = () + parsed: bool = False + +# 送审结果:是否点到送审,以及马上能见的总单/细单号。 +@dataclass(frozen=True) +class RebateSubmitResult: + submitted: bool + agreement_id: str = "" + agreement_dtl_id: str = "" + +# 判断查号结果里是否出现该合同号。 +# +# from_table 为真时认表正文;为假时要求号至少出现两次,避免把输入框回显当成搜到。空表标记出现且号不在文里则否。 +def classify_search_result(result_text: str, contract_no: str, *, from_table: bool = True) -> bool: + number = (contract_no or "").strip() + if not number: + return False + text = result_text or "" + if from_table: + if any(marker in text for marker in EMPTY_MARKERS) and number not in text: + return False + return number in text + if any(marker in text for marker in EMPTY_MARKERS): + return False + return text.count(number) >= 2 + +# 读总单表可见行文本。脚本拿不到表时退回整页正文。 +def read_result_table(browser: BrowserAdapter) -> tuple[str, bool]: + try: + text = browser.execute_script(_TABLE_SCRIPT) + except Exception: + text = None + if isinstance(text, str) and text.strip(): + return text, True + return (browser.read_body_text() or ""), False + +# 打开返利中心。已在登录页或采购协议页则不再整页刷新。 +def open_rebate_center(browser: BrowserAdapter, ctx: RebateFlowContext) -> None: + url = "" + try: + url = str(browser.execute_script("return location.href") or "") + except Exception: + url = "" + if "ssoLogin" in url or "login" in url.lower() or "wel/index" in url: + browser.open_url(ctx.rebate_url) + return + if "procurementAgree" in url: + return + browser.open_url(ctx.rebate_url) + +# 页面正文是否已有门户标记,用来跳过重复登录。 +def _is_logged_in(browser: BrowserAdapter) -> bool: + body = browser.read_body_text() or "" + return any(marker in body for marker in _PORTAL_MARKERS) + +# 填账密点登录,等到进入返利中心。 +# +# 缺 REBATE_USERNAME / REBATE_PASSWORD 或超时都抛 LoginFailed。已登录则直接返回。 +def login_rebate_center(browser: BrowserAdapter, ctx: RebateFlowContext) -> None: + try: + if _is_logged_in(browser): + return + if not (ctx.username or "").strip() or ctx.password == "": + raise LoginFailed("未配置 REBATE_USERNAME / REBATE_PASSWORD") + browser.fill_first(LOGIN["user"], ctx.username) + browser.fill_first(LOGIN["password"], ctx.password) + clicked = False + try: + clicked = bool( + browser.execute_script( + """ + const btn = Array.from(document.querySelectorAll('button')).find((el) => { + const t = ((el.innerText || el.value || '') + '').replace(/\\s+/g, '').trim(); + return t === '登录' && (el.offsetWidth || el.offsetHeight); + }); + if (!btn) { return false; } + btn.click(); + return true; + """ + ) + ) + except Exception: + clicked = False + if not clicked: + browser.click_first(LOGIN["submit"]) + deadline = time.time() + 30 + while time.time() < deadline: + if _is_logged_in(browser): + return + time.sleep(0.5) + raise TimeoutError("登录后未进入返利中心") + except LoginFailed: + raise + except Exception as exc: + raise LoginFailed(f"返利中心登录失败:{exc}") from exc + +# 切入带「协议总单ID」和查询按钮的 iframe。已在该文档则不动。 +def _switch_agreement_frame(browser: BrowserAdapter) -> bool: + driver = getattr(browser, "_driver", None) + if driver is None: + return False + + # 当前文档是不是采购协议列表:有协议总单或协议号,并且有查询按钮。 + def _here() -> bool: + try: + body = driver.execute_script("return (document.body && document.body.innerText) || ''") or "" + has_search = bool(driver.execute_script("return !!document.querySelector('.search-button')")) + return ("协议总单ID" in body or "采购协议号" in body) and has_search + except Exception: + return False + + if _here(): + return True + try: + driver.switch_to.default_content() + if _here(): + return True + frames = driver.find_elements("css selector", "iframe") + except Exception: + return False + for frame in frames: + try: + driver.switch_to.default_content() + driver.switch_to.frame(frame) + if _here(): + return True + except Exception: + continue + try: + driver.switch_to.default_content() + except Exception: + pass + return _here() + +# 菜单「采购协议」下拉会挡住总单查询和细单新增,进页后必须关掉。 +def _dismiss_menu_autocomplete(browser: BrowserAdapter) -> None: + try: + switch = getattr(browser, "switch_to_default_content", None) + if callable(switch): + switch() + except Exception: + pass + try: + browser.execute_script( + """ + /* DISMISS_MENU_AUTOCOMPLETE */ + const input = document.querySelector('input[placeholder*="菜单名称"]'); + if (input) { input.blur(); } + document.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true + })); + const pops = Array.from(document.querySelectorAll( + '.el-autocomplete-suggestion, .my-autocomplete, .el-popper.my-autocomplete' + )); + for (const el of pops) { el.remove(); } + return pops.length; + """ + ) + except Exception: + pass + +# 从菜单进采购协议管理。 +# +# 填「采购协议」后等自动补全再点「采购协议管理」。进页后关掉菜单下拉,避免挡住查询和新增。 +def enter_purchase_agreement(browser: BrowserAdapter) -> None: + url = "" + try: + url = str(browser.execute_script("return location.href") or "") + except Exception: + url = "" + body = browser.read_body_text() or "" + already = "procurementAgree" in url or "协议总单ID" in body or ( + "合同编号" in body and "查询" in body + ) + if already: + _dismiss_menu_autocomplete(browser) + _switch_agreement_frame(browser) + return + try: + browser.wait_for_element(['input[placeholder*="菜单名称"]'], timeout_seconds=15) + browser.fill_first(['input[placeholder*="菜单名称"]'], "采购协议") + try: + browser.wait_for_element( + [ + ".el-autocomplete-suggestion", + ".my-autocomplete", + ".el-popper.my-autocomplete", + ], + timeout_seconds=5, + ) + except Exception: + pass + except Exception as exc: + print(f"菜单搜索框未填:{exc}", flush=True) + clicked = False + try: + clicked = browser.execute_script( + """ + const sug = document.querySelector( + '.el-autocomplete-suggestion, .my-autocomplete, .el-popper.my-autocomplete' + ); + const scope = sug || document; + const hit = Array.from(scope.querySelectorAll('div.name, li[role="option"]')).find((el) => { + const t = ((el.innerText || '') + '').replace(/\\s+/g, '').trim(); + return (t === '采购协议管理' || t.indexOf('采购协议管理') === 0) + && (el.offsetWidth || el.offsetHeight); + }); + if (!hit) { return {ok: false}; } + hit.click(); + return {ok: true, cls: (hit.className || '').toString().slice(0, 80)}; + """ + ) + clicked = bool(isinstance(clicked, dict) and clicked.get("ok")) + except Exception as exc: + print(f"菜单点击失败:{exc}", flush=True) + clicked = False + if not clicked: + browser.click_first(MENU["entry"]) + _dismiss_menu_autocomplete(browser) + deadline = time.time() + 25 + while time.time() <= deadline: + href = "" + try: + href = str(browser.execute_script("return location.href") or "") + except Exception: + href = "" + if "procurementAgree" in href: + _dismiss_menu_autocomplete(browser) + if _switch_agreement_frame(browser): + return + time.sleep(0.5) + raise TimeoutError("未进入采购协议管理") + +_QUERY_LABELS = ("合同编号", "采购协议号", "协议总单ID") + +# 打开总单查询条件。已经露出「合同编号」则不再点。 +def _open_header_search_panel(browser: BrowserAdapter) -> bool: + opened = browser.execute_script( + """ + /* OPEN_HEADER_SEARCH */ + function labelText(el) { + const n = el.querySelector('.search-label__text'); + return ((n && n.innerText) || '').replace(/\\s+/g, '').trim(); + } + const items = Array.from(document.querySelectorAll('.search-condition-item')); + const hasContract = items.some((el) => labelText(el) === '合同编号'); + if (hasContract) { return {ok: true, already: true}; } + const btn = document.querySelector('.table-show .single-table .search-button-content') + || document.querySelector('.table-show .search-button-content') + || document.querySelector('.search-button-content'); + if (!btn) { return {ok: false}; } + btn.click(); + return {ok: true}; + """ + ) + if not (isinstance(opened, dict) and opened.get("ok")): + return False + deadline = time.time() + 8 + while time.time() <= deadline: + ready = False + try: + ready = bool( + browser.execute_script( + """ + const items = Array.from(document.querySelectorAll('.search-condition-item')); + return items.some((el) => { + const n = el.querySelector('.search-label__text'); + const t = ((n && n.innerText) || '').replace(/\\s+/g, '').trim(); + return t === '合同编号'; + }); + """ + ) + ) + except Exception: + ready = False + if ready: + return True + time.sleep(0.3) + return False + +# 点查询面板「清除」。查号路径不要先调这个,会把表刷回全量。 +def _clear_header_search(browser: BrowserAdapter) -> None: + browser.execute_script( + """ + /* CLEAR_HEADER_SEARCH */ + const hit = Array.from(document.querySelectorAll('button')).find((el) => { + const t = ((el.innerText || '') + '').replace(/\\s+/g, ' ').trim(); + return t === '清除 Alt+C' || t === '清除'; + }); + if (hit) { hit.click(); return true; } + return false; + """ + ) + time.sleep(0.4) + +# 只勾「合同编号」、填号、点查询 Enter。其它条件勾掉。 +def _fill_header_contract_and_query(browser: BrowserAdapter, number: str) -> bool: + filled = browser.execute_script( + """ + /* FILL_HEADER_CONTRACT */ + const no = String(arguments[0] || ''); + function labelText(el) { + const n = el.querySelector('.search-label__text'); + return ((n && n.innerText) || '').replace(/\\s+/g, '').trim(); + } + const items = Array.from(document.querySelectorAll('.search-condition-item')); + for (const el of items) { + const box = el.querySelector('label.elPlus-checkbox'); + if (!box) continue; + const on = box.classList.contains('is-checked'); + const mine = labelText(el) === '合同编号'; + if (mine && !on) { box.click(); } + if (!mine && on) { box.click(); } + } + const item = items.find((el) => labelText(el) === '合同编号'); + if (!item) { return {ok: false, error: 'no-合同编号'}; } + const box = item.querySelector('label.elPlus-checkbox'); + if (box && !box.classList.contains('is-checked')) { box.click(); } + const input = Array.from(item.querySelectorAll('input.elPlus-input__inner')).find((el) => { + return el.type === 'text' && String(el.placeholder || '').indexOf('请输入') >= 0; + }) || item.querySelector('.search-component input.elPlus-input__inner'); + if (!input) { return {ok: false, error: 'no-input'}; } + input.focus(); + input.click(); + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; + setter.call(input, no); + input.dispatchEvent(new Event('input', {bubbles: true})); + input.dispatchEvent(new InputEvent('input', {bubbles: true, data: no, inputType: 'insertText'})); + input.dispatchEvent(new Event('change', {bubbles: true})); + if (box && !box.classList.contains('is-checked')) { box.click(); } + input.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true + })); + return {ok: true, value: input.value, checked: !!(box && box.classList.contains('is-checked'))}; + """, + number, + ) + if not (isinstance(filled, dict) and filled.get("ok") and filled.get("value") == number): + return False + driver = getattr(browser, "_driver", None) + if driver is not None: + try: + from selenium.webdriver.common.keys import Keys + + el = driver.switch_to.active_element + el.send_keys(Keys.CONTROL, "a") + el.send_keys(number) + el.send_keys(Keys.ENTER) + except Exception: + pass + browser.execute_script( + """ + /* QUERY_HEADER_ENTER */ + function compact(el) { + return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); + } + const item = Array.from(document.querySelectorAll('.search-condition-item')).find((el) => { + const n = el.querySelector('.search-label__text'); + return compact(n) === '合同编号'; + }); + const panel = (item && item.closest( + '.search-condition, .vxe-modal--wrapper, .elPlus-dialog, .el-dialog' + )) || document; + const hit = Array.from(panel.querySelectorAll('button')).find((el) => { + const t = compact(el); + return t === '查询 Enter' || t === '查询' || t.indexOf('查询') === 0; + }); + if (hit) { hit.click(); return {ok: true, via: compact(hit)}; } + return {ok: false}; + """ + ) + return True + +# 总单工具栏按合同编号搜。不要先点「清除」,会把表刷回全量再查不准。 +def _search_header_by_contract(browser: BrowserAdapter, number: str) -> bool: + if not _open_header_search_panel(browser): + return False + return _fill_header_contract_and_query(browser, number) + +# 打开查询面板,按给定标签填合同号再点查询。 +def _search_via_query_panel(browser: BrowserAdapter, number: str, label: str) -> bool: + opened = browser.execute_script( + """ + /* OPEN_QUERY_PANEL */ + if (document.querySelector('.search-condition-item')) { + return {ok: true, already: true}; + } + const btn = document.querySelector('.search-button'); + if (!btn) { return {ok: false}; } + btn.click(); + return {ok: true}; + """ + ) + if not (isinstance(opened, dict) and opened.get("ok")): + return False + deadline = time.time() + 8 + while time.time() <= deadline: + ready = False + try: + ready = bool( + browser.execute_script( + "return !!document.querySelector('.search-condition-item')" + ) + ) + except Exception: + ready = False + if ready: + break + time.sleep(0.3) + filled = browser.execute_script( + """ + /* FILL_QUERY_FIELD */ + const label = String(arguments[0] || ''); + const no = String(arguments[1] || ''); + function labelText(el) { + const n = el.querySelector('.search-label__text'); + return ((n && n.innerText) || '').replace(/\\s+/g, '').trim(); + } + const items = Array.from(document.querySelectorAll('.search-condition-item')); + if (!items.length) { return {ok: false}; } + const item = items.find((el) => labelText(el) === label) + || items.find((el) => (el.innerText || '').indexOf(label) >= 0); + if (!item) { return {ok: false}; } + const box = item.querySelector('label.elPlus-checkbox'); + if (box && !box.classList.contains('is-checked')) { box.click(); } + const input = Array.from(item.querySelectorAll('input.elPlus-input__inner')).find((el) => { + return el.type === 'text' && String(el.placeholder || '').indexOf('请输入') >= 0; + }) || item.querySelector('.search-component input.elPlus-input__inner'); + if (!input) { return {ok: false}; } + input.focus(); + input.click(); + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; + setter.call(input, no); + input.dispatchEvent(new Event('input', {bubbles: true})); + input.dispatchEvent(new Event('change', {bubbles: true})); + if (box && !box.classList.contains('is-checked')) { box.click(); } + return {ok: true, value: input.value, label, checked: !!(box && box.classList.contains('is-checked'))}; + """, + label, + number, + ) + if not (isinstance(filled, dict) and filled.get("ok")): + return False + browser.execute_script( + """ + const hit = Array.from(document.querySelectorAll('button')).find((el) => { + const t = ((el.innerText || '') + '').replace(/\\s+/g, ' ').trim(); + return t === '查询 Enter' || t === '查询'; + }); + if (hit) { hit.click(); return true; } + return false; + """ + ) + return True + +# 等到总单表出现该合同号,或分页明确共 0 条。输入框回显不算。 +def _wait_search_hit(browser: BrowserAdapter, number: str, *, since: float | None = None) -> bool: + deadline = time.time() + 15 + started = since or time.time() + last_text = "" + last_from_table = False + last_header: dict | None = None + while time.time() <= deadline: + _switch_agreement_frame(browser) + header = read_header_selection(browser, number) + elapsed = time.time() - started + if header.get("error") != "script" and "cells" in header: + last_header = header + cells = [str(c or "") for c in (header.get("cells") or [])] + if any(number == c or number in c for c in cells) and elapsed >= 0.6: + return True + pager = str(header.get("pager") or "").replace(" ", "") + if "共0条" in pager and elapsed > 6: + return False + time.sleep(0.4) + continue + text, from_table = read_result_table(browser) + last_text, last_from_table = text, from_table + found = classify_search_result(text, number, from_table=from_table) + body = "" + try: + body = browser.read_body_text() or "" + except Exception: + body = "" + pagers = [int(n) for n in re.findall(r"共\s*(\d+)\s*条", f"{text}\n{body}")] + all_zero = bool(pagers) and all(n == 0 for n in pagers) + empty = any(marker in (text or "") for marker in EMPTY_MARKERS) and number not in (text or "") + header_ready = "协议总单ID" in (text or "") + if found and elapsed >= 0.6: + return True + if pagers and max(pagers) == 1 and elapsed >= 0.8: + return True + if all_zero and elapsed > 2: + return False + if empty and not header_ready and elapsed > 2: + return False + time.sleep(0.4) + if last_header is not None: + cells = [str(c or "") for c in (last_header.get("cells") or [])] + return any(number == c or number in c for c in cells) + return classify_search_result(last_text, number, from_table=last_from_table) + +# 按合同号搜总单表。表里出现该号才算搜到。 +# +# 先走总单工具栏合同编号;不行再试查询面板。不要点查询弹层的「取消」。 +def search_contract(browser: BrowserAdapter, contract_no: str) -> bool: + number = (contract_no or "").strip() + if not number: + return False + _dismiss_menu_autocomplete(browser) + _switch_agreement_frame(browser) + try: + if _search_header_by_contract(browser, number): + return _wait_search_hit(browser, number, since=time.time()) + except Exception: + pass + used_panel = False + for label in _QUERY_LABELS: + try: + hit = _search_via_query_panel(browser, number, label) + if not hit: + time.sleep(0.4) + _switch_agreement_frame(browser) + hit = _search_via_query_panel(browser, number, label) + except Exception: + hit = False + if not hit: + continue + used_panel = True + if _wait_search_hit(browser, number, since=time.time()): + return True + if not used_panel: + try: + browser.fill_first(SEARCH["contract_no"], number) + browser.click_first(SEARCH["button"]) + return _wait_search_hit(browser, number, since=time.time()) + except Exception: + return False + return False + +# 总单卡片:标签在 .label-wrap,三个点是 span.pointer.f20,下拉是 .elPlus-select__wrapper。 +def _header_dom_js() -> str: + return """ + function compact(el) { + return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); + } + function vis(el) { + if (!el) return false; + const st = window.getComputedStyle(el); + if (!st || st.display === 'none' || st.visibility === 'hidden') return false; + return !!(el.offsetWidth || el.offsetHeight); + } + function formItem(title, root) { + const wanted = String(title || '').replace(/\\s+/g, '').replace(/\\*/g, ''); + const scope = root || document; + const items = Array.from(scope.querySelectorAll('.elPlus-form-item, .el-form-item')); + let exact = null; + let fuzzy = null; + for (const item of items) { + if (!vis(item)) continue; + const label = item.querySelector('.form-item__title, .elPlus-form-item__label, label, .label-wrap'); + const t = compact(label || item).replace(/\\s+/g, '').replace(/\\*/g, ''); + if (t === wanted) { exact = item; break; } + if (!fuzzy && t.indexOf(wanted) >= 0 && t.indexOf(wanted) < 8) fuzzy = item; + } + return exact || fuzzy; + } + function detailForm() { + return Array.from(document.querySelectorAll( + '.elPlus-form, form, .elPlus-drawer, .elPlus-dialog, .vxe-modal--wrapper, .elPlus-card' + )).find((el) => { + const t = compact(el); + return vis(el) && t.indexOf('货品编码') >= 0 && t.indexOf('数量') >= 0; + }) || null; + } + function detailItem(title) { + const root = detailForm(); + return root ? formItem(title, root) : null; + } + function clickDots(item) { + const live = (el) => vis(el) && !el.closest('.vxe-modal--wrapper'); + const append = item && item.querySelector( + '.elPlus-input-group--append button, .elPlus-input-group--append .elPlus-button, .elPlus-input-group__append button' + ); + if (append && live(append)) { append.click(); return 'append'; } + const pointer = item && Array.from(item.querySelectorAll('.pointer.f20, span.pointer')).find(live); + if (pointer) { pointer.click(); return 'pointer'; } + const moreIcon = item && Array.from(item.querySelectorAll('.vxe-icon--more')).find(live); + if (moreIcon) { + const btn = moreIcon.closest('button, .elPlus-button') || moreIcon; + btn.click(); + return 'more'; + } + const more = item && Array.from(item.querySelectorAll('button')).find((el) => { + const t = compact(el); + return live(el) && (t === '...' || t === '…' || t === '···'); + }); + if (more) { more.click(); return 'dots-button'; } + return ''; + } + function selectWrap(item) { + return item && item.querySelector('.elPlus-select__wrapper, .elPlus-select, .el-select'); + } + function shownValue(item) { + if (!item) return ''; + const wrap = selectWrap(item); + if (wrap) { + const t = compact(wrap).replace(/请选择/g, '').trim(); + if (t) return t; + } + const input = item.querySelector('input.elPlus-input__inner, input.vxe-input--inner, input:not([type="checkbox"])'); + return input ? String(input.value || '').trim() : ''; + } + """ + +_OPEN_HEADER_ROW = """ +/* OPEN_HEADER_ROW */ +const no = String(arguments[0] || '').trim(); +if (!no) { return {ok: false, error: 'empty'}; } +const rows = Array.from(document.querySelectorAll( + 'tr, .el-table__row, .ant-table-row, .vxe-body--row, [role="row"]' +)); +let row = rows.find((r) => { + const t = (r.innerText || '').replace(/\\s+/g, ' ').trim(); + return t.indexOf(no) >= 0 && t.length < 2000; +}); +if (!row) { + const dataRows = rows.filter((r) => r.querySelector('td, .vxe-body--column, [colid]')); + if (dataRows.length === 1) { row = dataRows[0]; } +} +if (!row) { return {ok: false, error: 'row not found'}; } +const link = Array.from(row.querySelectorAll('a, button, span, .vxe-body--column, td')).find((el) => { + const t = ((el.innerText || '') + '').replace(/\\s+/g, '').trim(); + return t.indexOf(no) >= 0 || t.indexOf('CG-') === 0; +}); +if (link) { + link.click(); + link.dispatchEvent(new MouseEvent('dblclick', {bubbles: true, cancelable: true, view: window})); + return {ok: true, via: 'link'}; +} +row.click(); +row.dispatchEvent(new MouseEvent('dblclick', {bubbles: true, cancelable: true, view: window})); +return {ok: true, via: 'row'}; +""" + +# 把日期收成 YYYY-MM-DD。空则用当天。 +def today_iso(today: str | None = None) -> str: + text = (today or "").strip() + if text: + return text + return date.today().isoformat() + +# 取该日所在年的 12-31。空则按当天算。 +def year_end_iso(today: str | None = None) -> str: + day = today_iso(today) + return f"{int(day[:4])}-12-31" + +# 把单元格收成去空白字符串。None 收成空。 +def _cell(value: object) -> str: + if value is None: + return "" + return str(value).strip() + +# 税率文本;空则用默认 0.13。 +def _tax_rate_of(raw: object) -> str: + text = _cell(raw) + return text if text else TAX_RATE_DEFAULT + +# 无税单价 = 含税 / (1+税率),四舍五入到四位。算不出来返回空。 +def _untaxed_price(tax_price: str, tax_rate: str) -> str: + price = _cell(tax_price) + rate = _tax_rate_of(tax_rate) + if not price: + return "" + try: + net = Decimal(price) / (Decimal("1") + Decimal(rate)) + quantized = net.quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP) + text = format(quantized, "f") + if "." in text: + text = text.rstrip("0").rstrip(".") + return text + except Exception: + return "" + +# 按按钮文案点页面工具栏。总单和货品同名时可能点错,优先用带范围的那几个。 +def _click_toolbar(browser: BrowserAdapter, title: str) -> bool: + try: + result = browser.execute_script( + """ + const title = String(arguments[0] || '').trim(); + const btn = Array.from(document.querySelectorAll('.table-toolbar-btn__text, button, span')).find((el) => (el.innerText || '').trim() === title); + if (!btn) { return {ok: false}; } + btn.click(); + return {ok: true}; + """, + title, + ) + except Exception: + result = None + return bool(isinstance(result, dict) and result.get("ok")) + +# 只点总单工具栏,避开货品工具栏同名的保存/编辑。 +def _click_header_toolbar(browser: BrowserAdapter, title: str) -> bool: + try: + result = browser.execute_script( + """ + /* CLICK_HEADER_TOOLBAR */ + const title = String(arguments[0] || '').trim(); + function compact(el) { + return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); + } + function vis(el) { + return !!(el && (el.offsetWidth || el.offsetHeight)); + } + const root = document.querySelector('.table-show'); + const bar = root && root.querySelector('.table-toolbar'); + if (!bar) { return {ok: false, error: 'no-header-bar'}; } + const hit = Array.from(bar.querySelectorAll( + '.table-toolbar-btn, .table-toolbar-btn__text, button, span' + )).find((el) => vis(el) && compact(el) === title); + if (!hit) { return {ok: false, error: 'no-btn'}; } + const btn = hit.closest('button, .table-toolbar-btn') || hit; + btn.click(); + return {ok: true, via: compact(hit)}; + """, + title, + ) + except Exception: + result = None + return bool(isinstance(result, dict) and result.get("ok")) + +# 用 ActionChains 点总单工具栏按钮,避开货品工具栏同名保存。 +def _native_click_header_toolbar(browser: BrowserAdapter, title: str) -> bool: + driver = getattr(browser, "_driver", None) + if driver is None: + return False + wanted = (title or "").replace(" ", "") + try: + from selenium.webdriver.common.action_chains import ActionChains + + root = None + try: + root = driver.find_element("css selector", ".table-show .table-toolbar") + except Exception: + root = None + scope = root.find_elements("css selector", "button, .table-toolbar-btn, .table-toolbar-btn__text") if root else [] + for el in scope: + raw = (el.text or "").replace("\n", "").replace(" ", "") + if raw != wanted: + continue + driver.execute_script("arguments[0].scrollIntoView({block:'center'});", el) + ActionChains(driver).move_to_element(el).pause(0.15).click().perform() + return True + except Exception: + return False + return False + +# 往当前焦点输入框设值并派发 input/change。没有 driver 则什么都不做。 +def _type_active(browser: BrowserAdapter, value: str) -> None: + driver = getattr(browser, "_driver", None) + if driver is None: + return + try: + el = driver.switch_to.active_element + driver.execute_script( + """ + const el = arguments[0]; + const value = String(arguments[1] || ''); + if (!el) { return false; } + el.focus(); + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; + setter.call(el, value); + el.dispatchEvent(new Event('input', {bubbles: true})); + el.dispatchEvent(new InputEvent('input', {bubbles: true, composed: true, data: value})); + el.dispatchEvent(new Event('change', {bubbles: true})); + return true; + """, + el, + value, + ) + except Exception: + pass + +# 已打开的下拉:输入筛选后点中。电汇必须精确匹配。 +def _filter_and_pick_dropdown(browser: BrowserAdapter, value: str, *, exact: bool = True) -> bool: + wanted = (value or "").strip() + if not wanted: + return False + _type_active(browser, wanted) + deadline = time.time() + 6 + while time.time() <= deadline: + picked = browser.execute_script( + """ + /* PICK_DETAIL_OPTION */ + const wanted = String(arguments[0] || '').replace(/\\s+/g, ''); + const exact = arguments[1] === true; + function compact(el) { + return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim(); + } + function vis(el) { + return !!(el && (el.offsetWidth || el.offsetHeight)); + } + function scan(root) { + const nodes = Array.from(root.querySelectorAll( + '.elPlus-select-dropdown__item, .el-select-dropdown__item, .vxe-select-option, li' + )); + const hitExact = nodes.find((el) => vis(el) && compact(el) === wanted); + if (hitExact) return hitExact; + if (exact) return null; + return nodes.find((el) => vis(el) && compact(el).indexOf(wanted) >= 0); + } + const drops = Array.from(document.querySelectorAll( + '.elPlus-select__popper, .elPlus-select-dropdown, .el-select-dropdown, .vxe-select--panel' + )).filter(vis); + const roots = drops.length ? drops : [document]; + let opt = null; + for (const root of roots) { + opt = scan(root); + if (opt) break; + const wrap = root.querySelector( + '.elPlus-scrollbar__wrap, .elPlus-select-dropdown__wrap, .elPlus-vl__window' + ); + if (!wrap) continue; + for (let i = 0; i < 25 && !opt; i++) { + wrap.scrollTop = (wrap.scrollTop || 0) + 72; + opt = scan(root); + } + if (opt) break; + } + if (!opt) { return {ok: false, error: 'no-option'}; } + opt.click(); + return {ok: true, text: ((opt.innerText || '') + '').trim()}; + """, + wanted, + exact, + ) + text = "" + if isinstance(picked, dict) and picked.get("ok"): + text = str(picked.get("text") or "").replace(" ", "") + folded = wanted.replace(" ", "") + if text == folded: + return True + if not exact and folded and folded in text: + return True + time.sleep(0.3) + return False + +# 供应商/计划员编码弹窗:按编码搜,点行里单选再确定。 +def _pick_header_lookup(browser: BrowserAdapter, keyword: str) -> bool: + code = (keyword or "").strip() + if not code: + return False + filled = browser.execute_script( + """ + /* FILL_HEADER_LOOKUP */ + const no = String(arguments[0] || ''); + function compact(el) { + return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); + } + function vis(el) { + return !!(el && (el.offsetWidth || el.offsetHeight)); + } + const modal = Array.from(document.querySelectorAll( + '.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer' + )).find((el) => { + if (!vis(el)) return false; + const t = compact(el); + if (t.indexOf('请填写供应商') >= 0 && t.indexOf('采购协议号') >= 0) return false; + if (t.indexOf('货品编码') >= 0 && t.indexOf('数量') >= 0) return false; + return t.indexOf('供应商选择') >= 0 || t.indexOf('选择供应商') >= 0 + || t.indexOf('客商') >= 0 || t.indexOf('计划员') >= 0 || t.indexOf('供应商') >= 0; + }); + if (!modal) { return {ok: false, error: 'no-modal'}; } + const input = modal.querySelector('input.elPlus-input__inner, input[type="text"], input'); + if (!input) { return {ok: false, error: 'no-input'}; } + input.click(); + input.focus(); + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; + setter.call(input, no); + input.dispatchEvent(new Event('input', {bubbles: true})); + input.dispatchEvent(new Event('change', {bubbles: true})); + const query = Array.from(modal.querySelectorAll('button')).find((el) => { + const t = compact(el); + return vis(el) && (t === '查询' || t.indexOf('查询') === 0); + }); + if (query) { query.click(); } + return {ok: true, value: input.value}; + """, + code, + ) + if not (isinstance(filled, dict) and filled.get("ok")): + return False + _type_active(browser, code) + deadline = time.time() + 8 + while time.time() <= deadline: + picked = browser.execute_script( + """ + /* PICK_HEADER_LOOKUP */ + const keyword = String(arguments[0] || '').replace(/\\s+/g, ''); + function compact(el) { + return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ''); + } + function vis(el) { + return !!(el && (el.offsetWidth || el.offsetHeight)); + } + const modal = Array.from(document.querySelectorAll( + '.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer' + )).find((el) => { + if (!vis(el)) return false; + const t = compact(el); + if (t.indexOf('请填写供应商') >= 0 && t.indexOf('采购协议号') >= 0) return false; + if (t.indexOf('货品编码') >= 0 && t.indexOf('数量') >= 0) return false; + return t.indexOf('供应商选择') >= 0 || t.indexOf('选择供应商') >= 0 + || t.indexOf('客商') >= 0 || t.indexOf('计划员') >= 0 || t.indexOf('供应商') >= 0; + }); + if (!modal) { return {ok: false, error: 'no-modal'}; } + const root = modal; + const rows = Array.from(root.querySelectorAll('.vxe-body--row, tr, [role="row"]')); + for (const row of rows) { + const text = compact(row); + if (text.indexOf(keyword) < 0) continue; + const radio = row.querySelector( + '.vxe-cell--radio, .vxe-radio--icon, input[type="radio"], .vxe-cell--checkbox' + ); + if (radio) { radio.click(); } else { row.click(); } + const ok = Array.from(root.querySelectorAll('button')).find((el) => { + if (!vis(el)) return false; + const t = compact(el); + return t === '确定' || t === '确 定'; + }); + if (ok) { ok.click(); } + return {ok: true}; + } + return {ok: false}; + """, + code, + ) + if isinstance(picked, dict) and picked.get("ok"): + time.sleep(0.4) + return True + time.sleep(0.4) + return False + +# 等到计划员/联系人选择层出现。 +def _wait_person_picker(browser: BrowserAdapter, timeout: float = 8) -> bool: + deadline = time.time() + timeout + while time.time() <= deadline: + if _person_picker_visible(browser): + return True + time.sleep(0.3) + return _person_picker_visible(browser) + +# 把元素滚到视口中央再用 ActionChains 点。 +def _native_click_el(browser: BrowserAdapter, el) -> bool: + driver = getattr(browser, "_driver", None) + if driver is None or el is None: + return False + try: + from selenium.webdriver.common.action_chains import ActionChains + + driver.execute_script( + "arguments[0].scrollIntoView({block:'center', inline:'center'});", + el, + ) + time.sleep(0.15) + ActionChains(driver).move_to_element(el).pause(0.15).click().perform() + return True + except Exception: + return False + +# 点字段旁三个点打开选择层。 +# +# 计划员、联系人打开的是选人层(标题常写成货源选择),要按住再搜姓名。dots_only 为真时不点下拉,只点三个点。 +def _open_labeled_picker(browser: BrowserAdapter, title: str, *, dots_only: bool = False) -> bool: + opened = browser.execute_script( + """ + /* OPEN_LABELED_PICKER */ + """ + + _header_dom_js() + + """ + const title = String(arguments[0] || ''); + const item = formItem(title); + if (!item) { return {ok: false, error: 'no-label'}; } + item.scrollIntoView({block: 'center', inline: 'nearest'}); + const via = clickDots(item); + if (via) { return {ok: true, via: via}; } + if (arguments[1] === true) { return {ok: false, error: 'no-dots'}; } + const wrap = selectWrap(item); + if (wrap) { wrap.click(); return {ok: true, via: 'select'}; } + const input = item.querySelector('input.elPlus-input__inner, input'); + if (input) { input.click(); input.focus(); return {ok: true, via: 'input'}; } + return {ok: false, error: 'no-trigger'}; + """, + title, + dots_only, + ) + print("open_picker=", opened, flush=True) + person = title in {"计划员", "联系人"} + if person: + if _wait_person_picker(browser, timeout=1.2): + return True + el = browser.execute_script( + """ + /* PERSON_PICKER_TRIGGER */ + """ + + _header_dom_js() + + """ + const item = formItem(arguments[0]); + if (!item) { return null; } + return item.querySelector( + '.elPlus-input-group--append button, .elPlus-input-group--append .elPlus-button' + ) || item.querySelector('.pointer.f20, span.pointer'); + """, + title, + ) + native = _native_click_el(browser, el) + print("open_picker_native=", native, flush=True) + if _wait_person_picker(browser, timeout=8): + return True + print( + "person_picker_missing overlays=", + _scan_lookup_overlays(browser), + "source=", + _source_choice_visible(browser), + flush=True, + ) + return False + if not getattr(browser, "_hold_source_choice", False): + _ignore_source_choice(browser) + return bool(isinstance(opened, dict) and opened.get("ok")) + +# 读总单卡片上该标签当前显示值。 +def _read_labeled_input(browser: BrowserAdapter, title: str) -> str: + try: + result = browser.execute_script( + """ + /* READ_LABELED_INPUT */ + """ + + _header_dom_js() + + """ + const title = String(arguments[0] || ''); + const item = formItem(title); + return {value: shownValue(item)}; + """, + title, + ) + except Exception: + result = None + if isinstance(result, dict): + return str(result.get("value") or "").strip() + return "" + +# 判断该字段是否已经绑上码。 +# +# 供应商看名称是否带出;计划员看部门或姓名;联系人看联系人ID。 +def _picker_bound(browser: BrowserAdapter, title: str, code: str) -> bool: + shown = _read_labeled_input(browser, title) + if title == "供应商编码": + name = _read_labeled_input(browser, "供应商名称") + return bool(name) and "请" not in name[:2] and len(name) >= 4 + if title == "返利单位编码": + name = _read_labeled_input(browser, "返利单位") + return bool(name) and "请" not in name[:2] and len(name) >= 2 + if title == "计划员": + dept = _read_labeled_input(browser, "计划员部门") + if code and shown and code in shown and "请" not in shown[:2]: + return True + return bool(dept) and "请" not in dept[:2] and len(dept) >= 2 + if title == "联系人": + cid = _read_labeled_input(browser, "联系人ID") + return bool(cid) and "请" not in cid[:2] and len(cid) >= 2 + return bool(code) and code in shown and "请填写" not in shown and "请选择" not in shown + +# 关掉标题含给定字的弹层,点取消或右上角 X。 +def _close_overlay_named(browser: BrowserAdapter, title: str) -> None: + try: + browser.execute_script( + """ + /* CLOSE_NAMED_OVERLAY */ + const title = String(arguments[0] || ''); + function compact(el) { + return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); + } + function vis(el) { + return !!(el && (el.offsetWidth || el.offsetHeight)); + } + const modal = Array.from(document.querySelectorAll( + '.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer, .elPlus-popper' + )).find((el) => vis(el) && compact(el).indexOf(title) >= 0); + if (!modal) { return {ok: false}; } + const hit = Array.from(modal.querySelectorAll('button, .vxe-modal--close-btn, .elPlus-dialog__headerbtn')).find((el) => { + const t = compact(el); + return vis(el) && (t === '取消' || t === '取消 Esc' || t.indexOf('关闭') >= 0); + }); + if (hit) { hit.click(); return {ok: true}; } + const close = modal.querySelector('.vxe-modal--close-btn, .vxe-icon-close, .elPlus-dialog__headerbtn'); + if (close) { close.click(); return {ok: true, via: 'x'}; } + return {ok: false}; + """, + title, + ) + except Exception: + pass + time.sleep(0.3) + +# 列出当前可见的选择弹层标题,排除货源选择 bug 层。 +def _scan_lookup_overlays(browser: BrowserAdapter) -> list[str]: + try: + result = browser.execute_script( + """ + /* SCAN_LOOKUP_OVERLAYS */ + function compact(el) { + return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); + } + function vis(el) { + return !!(el && (el.offsetWidth || el.offsetHeight)); + } + return Array.from(document.querySelectorAll('.vxe-modal--wrapper')).filter((el) => { + const cls = String(el.className || ''); + if (!vis(el) || cls.indexOf('is--visible') < 0) return false; + const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title')); + return title !== '货源选择'; + }).map((el) => { + const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title')); + return (title || compact(el).slice(0, 40)); + }).filter(Boolean).slice(0, 10); + """ + ) + except Exception: + result = None + if isinstance(result, list): + return [str(item) for item in result] + return [] + +# 返回识别返利单位/供应商/选人/货源弹层的一段页面脚本。 +def _unit_picker_modal_js() -> str: + return """ + function compact(el) { + return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); + } + function vis(el) { + return !!(el && (el.offsetWidth || el.offsetHeight)); + } + function unitPicker() { + return Array.from(document.querySelectorAll('.vxe-modal--wrapper')).find((el) => { + const cls = String(el.className || ''); + if (!vis(el) || cls.indexOf('is--visible') < 0) return false; + const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title')); + return title === '返利单位选择' || title === '供应商选择'; + }) || null; + } + function sourcePicker() { + return Array.from(document.querySelectorAll('.vxe-modal--wrapper')).find((el) => { + const cls = String(el.className || ''); + if (!vis(el) || cls.indexOf('is--visible') < 0) return false; + const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title')); + return title === '货源选择'; + }) || null; + } + function personPicker() { + const all = Array.from(document.querySelectorAll('.vxe-modal--wrapper')).filter((el) => { + const cls = String(el.className || ''); + return vis(el) && cls.indexOf('is--visible') >= 0; + }); + return all.find((el) => { + const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title')); + if (title === '返利单位选择' || title === '供应商选择') return false; + if (title === '货源选择') return true; + if (title.indexOf('计划员') >= 0 || title.indexOf('联系人') >= 0 || title.indexOf('人员') >= 0) { + return true; + } + return Array.from(el.querySelectorAll('input')).some((inp) => { + const p = String(inp.placeholder || ''); + return p.indexOf('计划员') >= 0 || p.indexOf('联系人') >= 0; + }); + }) || null; + } + """ + +# 对焦点元素 Ctrl+A 后键入。enter 为真再回车。 +def _send_keys_active(browser: BrowserAdapter, value: str, *, enter: bool = False) -> bool: + driver = getattr(browser, "_driver", None) + if driver is None: + return False + try: + from selenium.webdriver.common.keys import Keys + + el = driver.switch_to.active_element + el.send_keys(Keys.CONTROL, "a") + el.send_keys(Keys.BACKSPACE) + if value: + el.send_keys(value) + if enter: + el.send_keys(Keys.ENTER) + return True + except Exception: + return False + +# 点返利单位/供应商选择层里的「清除」。 +def _click_unit_clear(browser: BrowserAdapter) -> dict: + try: + result = browser.execute_script( + """ + /* CLICK_UNIT_CLEAR */ + """ + + _unit_picker_modal_js() + + """ + const modal = unitPicker(); + if (!modal) { return {ok: false, error: 'no-modal'}; } + const clear = Array.from(modal.querySelectorAll('.search-btn button, button')).find((el) => { + return vis(el) && compact(el) === '清除'; + }); + if (!clear) { return {ok: false, error: 'no-clear'}; } + clear.click(); + return {ok: true, via: 'clear'}; + """ + ) + except Exception: + result = None + if isinstance(result, dict): + return result + return {"ok": False} + +# 点返利单位/供应商选择层里的「查询」。 +def _click_unit_query(browser: BrowserAdapter) -> dict: + try: + result = browser.execute_script( + """ + /* CLICK_UNIT_QUERY */ + """ + + _unit_picker_modal_js() + + """ + const modal = unitPicker(); + if (!modal) { return {ok: false, error: 'no-modal'}; } + const query = modal.querySelector('.search-btn button.elPlus-button--primary') + || Array.from(modal.querySelectorAll('.search-btn button, button')).find((el) => { + return vis(el) && compact(el) === '查询'; + }); + if (!query) { return {ok: false, error: 'no-query'}; } + query.click(); + return {ok: true, via: 'search-btn', text: compact(query)}; + """ + ) + except Exception: + result = None + if isinstance(result, dict): + return result + return {"ok": False} + +# 等到单位选择层出现含关键字的行,或连续看到共 0 条。 +def _wait_unit_picker_rows(browser: BrowserAdapter, keyword: str, timeout: float = 8) -> bool: + wanted = (keyword or "").replace(" ", "") + deadline = time.time() + timeout + last = None + empty_hits = 0 + while time.time() <= deadline: + try: + last = browser.execute_script( + """ + /* UNIT_PICKER_ROWS */ + """ + + _unit_picker_modal_js() + + """ + const keyword = String(arguments[0] || '').replace(/\\s+/g, ''); + const modal = unitPicker(); + if (!modal) { return {ok: false, error: 'no-modal'}; } + const rows = Array.from(modal.querySelectorAll('.vxe-body--row')).filter(vis); + const match = rows.find((el) => compact(el).replace(/ /g, '').indexOf(keyword) >= 0); + return { + ok: true, + match: !!match, + count: rows.length, + pager: compact(modal.querySelector('.vxe-pager, .modal-body-footer') || {innerText: ''}).slice(0, 80), + row: match ? compact(match).slice(0, 80) : '', + }; + """, + wanted, + ) + except Exception: + last = None + print("unit_rows=", last, flush=True) + if isinstance(last, dict) and last.get("match"): + return True + pager = str((last or {}).get("pager") or "").replace(" ", "") + if isinstance(last, dict) and "共0条" in pager: + empty_hits += 1 + if empty_hits >= 3: + return False + else: + empty_hits = 0 + time.sleep(0.4) + return False + +# 返利单位选择弹窗:填编码、回车或点查询后再点确定。 +def _query_overlay_and_pick(browser: BrowserAdapter, field: str, value: str) -> bool: + keyword = (value or "").strip() + if not keyword: + return False + _ignore_source_choice(browser) + filled = browser.execute_script( + """ + /* QUERY_OVERLAY_FIELD */ + """ + + _unit_picker_modal_js() + + """ + const field = String(arguments[0] || '').replace(/\\s+/g, ''); + const value = String(arguments[1] || ''); + const modal = unitPicker(); + if (!modal) { return {ok: false, error: 'no-modal'}; } + const items = Array.from(modal.querySelectorAll('.elPlus-form-item, .el-form-item, .search-condition-item')); + const item = items.find((el) => { + const t = compact(el.querySelector('.form-item__title, .search-label__text, label, .label-wrap') || el).replace(/ /g, ''); + return t === field || t.indexOf(field) >= 0; + }); + let input = item && item.querySelector('input.elPlus-input__inner, input'); + if (!input) { + const labels = Array.from(modal.querySelectorAll('.label-wrap, .form-item__title, .search-label__text, label')); + const label = labels.find((el) => compact(el).replace(/\\s+/g, '') === field); + let node = label; + for (let i = 0; i < 5 && node && !input; i++) { + input = node.querySelector('input.elPlus-input__inner, input'); + node = node.parentElement; + } + } + if (!input) { + input = Array.from(modal.querySelectorAll('input')).find((el) => vis(el) && String(el.placeholder || '').indexOf(field) >= 0); + } + if (!input) { return {ok: false, error: 'no-input', field: field}; } + input.click(); + input.focus(); + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; + setter.call(input, value); + input.dispatchEvent(new Event('input', {bubbles: true})); + input.dispatchEvent(new Event('change', {bubbles: true})); + return {ok: true, value: String(input.value || ''), via: field, title: compact(modal.querySelector('.vxe-modal--header-title'))}; + """, + field, + keyword, + ) + typed = _send_keys_active(browser, keyword, enter=True) + clicked = _click_unit_query(browser) + print("query_unit_picker=", filled, "typed=", typed, "query_click=", clicked, flush=True) + if not (isinstance(filled, dict) and filled.get("ok")): + return False + _wait_unit_picker_rows(browser, keyword) + return _click_keyword_in_overlays(browser, keyword) + +# 在单位/供应商选择层点中含关键字的行,再点确定。跳过货源选择。 +def _click_keyword_in_overlays(browser: BrowserAdapter, keyword: str) -> bool: + try: + result = browser.execute_script( + """ + /* CLICK_KEYWORD_OVERLAY */ + """ + + _unit_picker_modal_js() + + """ + const keyword = String(arguments[0] || '').replace(/\\s+/g, ''); + const modal = unitPicker(); + const roots = modal ? [modal] : Array.from(document.querySelectorAll( + '.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer, .elPlus-popper, .elPlus-select-dropdown, .vxe-modal--box' + )).filter(vis); + for (const root of roots) { + const title = compact(root.querySelector('.vxe-modal--header-title, .vxe-modal--title')); + const t = compact(root); + if (title === '货源选择' || t.indexOf('货源选择') === 0) continue; + if (t.indexOf('请填写供应商') >= 0 && t.indexOf('采购协议号') >= 0) continue; + const row = Array.from(root.querySelectorAll( + '.vxe-body--row, tr, .elPlus-select-dropdown__item, li' + )).find((el) => vis(el) && compact(el).indexOf(keyword) >= 0); + if (!row) continue; + const radio = row.querySelector('.vxe-cell--radio, .vxe-radio--icon, input[type="radio"]'); + if (radio) { radio.click(); } else { row.click(); } + const ok = Array.from(root.querySelectorAll('button')).find((el) => { + const x = compact(el); + return vis(el) && (x === '确定' || x === '确 定'); + }); + if (ok) { ok.click(); } + return {ok: true, text: compact(row).slice(0, 80)}; + } + return {ok: false}; + """, + keyword, + ) + except Exception: + result = None + return bool(isinstance(result, dict) and result.get("ok")) + +# 打开标签三个点,按编码搜并绑上。绑不上抛 NodeError。 +def _pick_labeled_code(browser: BrowserAdapter, title: str, code: str) -> None: + if not code: + return + if _picker_bound(browser, title, code): + return + keywords = [code] + _open_labeled_picker(browser, title) + time.sleep(0.3) + _type_active(browser, code) + driver = getattr(browser, "_driver", None) + if driver is not None: + try: + from selenium.webdriver.common.keys import Keys + + driver.switch_to.active_element.send_keys(Keys.ENTER) + except Exception: + pass + time.sleep(0.8) + overlays = _scan_lookup_overlays(browser) + print("lookup_overlays=", overlays, "title=", title, "code=", code, flush=True) + picked = _filter_and_pick_dropdown(browser, code, exact=False) + if not picked: + picked = _pick_header_lookup(browser, code) + if not picked: + picked = _click_keyword_in_overlays(browser, code) + if not _picker_bound(browser, title, code): + raise NodeError(f"未选到{title}={code},当前={_read_labeled_input(browser, title)}") + +# 是否出现误弹的货源选择层(不是计划员选人层)。 +def _source_choice_visible(browser: BrowserAdapter) -> bool: + try: + hit = browser.execute_script( + """ + /* SOURCE_CHOICE */ + function compact(el) { + return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); + } + function vis(el) { + return !!(el && (el.offsetWidth || el.offsetHeight)); + } + const modal = Array.from(document.querySelectorAll( + '.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer, .elPlus-overlay' + )).find((el) => { + if (!vis(el)) return false; + const title = compact(el.querySelector( + '.vxe-modal--header-title, .vxe-modal--header, .vxe-modal--title, .elPlus-dialog__title, .el-dialog__title' + )); + const t = compact(el).slice(0, 40); + if (title.indexOf('货源选择') < 0 && t.indexOf('货源选择') !== 0) return false; + const planner = Array.from(el.querySelectorAll('input')).some((inp) => { + const p = String(inp.placeholder || ''); + return p.indexOf('计划员') >= 0 || p.indexOf('联系人') >= 0; + }); + return !planner; + }); + return !!modal; + """ + ) + except Exception: + hit = False + return hit is True + +# 看得见的货源选择是 bug 弹层:点关闭,不要确定、不要 Escape。 +def _close_source_choice(browser: BrowserAdapter) -> bool: + try: + result = browser.execute_script( + """ + /* CLOSE_SOURCE_CHOICE */ + function compact(el) { + return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); + } + function vis(el) { + return !!(el && (el.offsetWidth || el.offsetHeight)); + } + const hits = Array.from(document.querySelectorAll( + '.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer, .elPlus-overlay, .vxe-modal--box' + )).filter((el) => { + if (!vis(el)) return false; + const title = compact(el.querySelector( + '.vxe-modal--header-title, .vxe-modal--header, .vxe-modal--title, .elPlus-dialog__title, .el-dialog__title' + )); + const t = compact(el).slice(0, 40); + return title.indexOf('货源选择') >= 0 || t.indexOf('货源选择') === 0; + }); + if (!hits.length) { return {ok: false, via: 'none-visible'}; } + let via = ''; + 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(); via = via || 'close'; continue; } + const x = modal.querySelector( + '.vxe-modal--close-btn, .vxe-icon-close, .elPlus-dialog__headerbtn, .el-dialog__headerbtn' + ); + if (x) { x.click(); via = via || 'x'; } + } + return {ok: true, via: via, count: hits.length}; + """ + ) + except Exception: + result = None + if isinstance(result, dict) and result.get("ok"): + time.sleep(0.25) + return True + return False + +# 全局忽略货源选择 bug 弹层。 +def _ignore_source_choice(browser: BrowserAdapter) -> None: + for _ in range(3): + if not _source_choice_visible(browser): + return + _close_source_choice(browser) + +# 看见货源选择 bug 弹层就关掉,不把流程停掉。 +def _stop_if_source_choice(browser: BrowserAdapter) -> None: + _ignore_source_choice(browser) + +# 在非货源选择层里点第 n 行,或按姓名命中的那行,再点确定。 +def _pick_nth_overlay_row(browser: BrowserAdapter, nth: int, name: str = "") -> bool: + try: + result = browser.execute_script( + """ + /* PICK_NTH_OVERLAY */ + const nth = Number(arguments[0] || 0); + const name = String(arguments[1] || '').replace(/\\s+/g, ''); + function compact(el) { + return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ''); + } + function vis(el) { + return !!(el && (el.offsetWidth || el.offsetHeight)); + } + const overlays = Array.from(document.querySelectorAll( + '.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer, .elPlus-select-dropdown, .elPlus-popper' + )).filter((el) => { + if (!vis(el)) return false; + const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title') || {innerText:''}); + return title !== '货源选择'; + }); + const roots = overlays.length ? overlays : [document]; + for (const root of roots) { + const rows = Array.from(root.querySelectorAll( + '.vxe-body--row, .elPlus-select-dropdown__item, .el-select-dropdown__item, li' + )).filter(vis); + let row = name ? rows.find((el) => compact(el).indexOf(name) >= 0) : null; + if (!row && nth >= 0 && nth < rows.length) { row = rows[nth]; } + if (!row) continue; + const radio = row.querySelector('.vxe-cell--radio, .vxe-radio--icon, input[type="radio"]'); + if (radio) { radio.click(); } else { row.click(); } + const ok = Array.from(root.querySelectorAll('button')).find((el) => { + const t = compact(el); + return vis(el) && (t === '确定' || t === '确 定'); + }); + if (ok) { ok.click(); } + return {ok: true, text: compact(row).slice(0, 80)}; + } + return {ok: false}; + """, + nth, + name, + ) + except Exception: + result = None + return bool(isinstance(result, dict) and result.get("ok")) + +# 点击并聚焦总单卡片上该标签的输入框。 +def _focus_labeled_input(browser: BrowserAdapter, title: str) -> bool: + focused = browser.execute_script( + """ + /* FOCUS_LABELED_INPUT */ + const title = String(arguments[0] || ''); + function compact(el) { + return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); + } + function vis(el) { + return !!(el && (el.offsetWidth || el.offsetHeight)); + } + const wanted = title.replace(/\\s+/g, ''); + const labels = Array.from(document.querySelectorAll( + '.form-item__title, .elPlus-form-item__label, label' + )).filter((el) => vis(el) && !el.closest('.search-condition-item')); + const label = labels.find((el) => compact(el).replace(/\\*/g, '') === wanted); + if (!label) { return {ok: false, error: 'no-label'}; } + const item = label.closest('.elPlus-form-item, .el-form-item') || label.parentElement; + const input = item && item.querySelector('input.elPlus-input__inner, input:not([type="checkbox"])'); + if (!input) { return {ok: false, error: 'no-input'}; } + input.click(); + input.focus(); + return {ok: true, ph: input.placeholder || '', val: String(input.value || '')}; + """, + title, + ) + print("focus_input=", focused, flush=True) + _ignore_source_choice(browser) + return bool(isinstance(focused, dict) and focused.get("ok")) + +# 暂时禁止自动关货源层,好让计划员三个点打开的选人层按住。 +def _hold_source_choice(browser: BrowserAdapter, hold: bool) -> None: + if hasattr(browser, "_hold_source_choice"): + browser._hold_source_choice = hold + +# 计划员/联系人选择层是否可见。 +def _person_picker_visible(browser: BrowserAdapter) -> bool: + try: + hit = browser.execute_script( + """ + /* PERSON_PICKER_VISIBLE */ + """ + + _unit_picker_modal_js() + + """ + return !!personPicker(); + """ + ) + except Exception: + hit = False + return hit is True + +# 关掉计划员/联系人选择层。 +def _close_person_picker(browser: BrowserAdapter) -> None: + try: + browser.execute_script( + """ + /* CLOSE_PERSON_PICKER */ + """ + + _unit_picker_modal_js() + + """ + const modal = personPicker(); + if (!modal) { return {ok: false}; } + const btn = Array.from(modal.querySelectorAll('button')).find((el) => { + const t = compact(el); + return vis(el) && (t === '关闭' || t === '取消' || t.indexOf('关闭') >= 0); + }); + if (btn) { btn.click(); return {ok: true, via: 'close'}; } + const x = modal.querySelector('.vxe-modal--close-btn, .vxe-icon-close, .elPlus-dialog__headerbtn'); + if (x) { x.click(); return {ok: true, via: 'x'}; } + return {ok: false}; + """ + ) + except Exception: + pass + time.sleep(0.2) + +# 计划员三个点打开「货源选择」:按姓名查,行上还要对上供应商名称。 +def _query_person_and_pick( + browser: BrowserAdapter, field: str, value: str, *, supplier_name: str = "" +) -> bool: + keyword = (value or "").strip() + if not keyword: + return False + focused = browser.execute_script( + """ + /* FOCUS_PERSON_FIELD */ + """ + + _unit_picker_modal_js() + + """ + const field = String(arguments[0] || ''); + const modal = personPicker(); + if (!modal) { return {ok: false, error: 'no-modal'}; } + const input = Array.from(modal.querySelectorAll('input')).find((el) => { + const ph = String(el.placeholder || ''); + return vis(el) && ph === ('请输入' + field); + }) || Array.from(modal.querySelectorAll('input')).find((el) => { + const ph = String(el.placeholder || ''); + return vis(el) && ph.indexOf(field) >= 0 && ph.indexOf('ID') < 0; + }); + if (!input) { return {ok: false, error: 'no-input'}; } + input.click(); + input.focus(); + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; + setter.call(input, String(arguments[1] || '')); + input.dispatchEvent(new Event('input', {bubbles: true})); + input.dispatchEvent(new Event('change', {bubbles: true})); + return {ok: true, ph: input.placeholder || '', title: compact(modal.querySelector('.vxe-modal--header-title'))}; + """, + field, + keyword, + ) + typed = _send_keys_active(browser, keyword, enter=True) + clicked = browser.execute_script( + """ + /* CLICK_PERSON_QUERY */ + """ + + _unit_picker_modal_js() + + """ + const modal = personPicker(); + if (!modal) { return {ok: false, error: 'no-modal'}; } + const query = Array.from(modal.querySelectorAll('button')).find((el) => vis(el) && compact(el) === '查询'); + if (!query) { return {ok: false, error: 'no-query'}; } + query.click(); + return {ok: true, via: 'query'}; + """ + ) + print("query_person=", focused, "typed=", typed, "query_click=", clicked, flush=True) + if not (isinstance(focused, dict) and focused.get("ok")): + return False + supplier = (supplier_name or "").replace(" ", "") + deadline = time.time() + 12 + while time.time() <= deadline: + last = browser.execute_script( + """ + /* PERSON_PICKER_ROWS */ + """ + + _unit_picker_modal_js() + + """ + const keyword = String(arguments[0] || '').replace(/\\s+/g, ''); + const supplier = String(arguments[1] || '').replace(/\\s+/g, ''); + const modal = personPicker(); + if (!modal) { return {ok: false, error: 'no-modal'}; } + const rows = Array.from(modal.querySelectorAll('.vxe-body--row')).filter(vis); + const match = rows.find((el) => { + const t = compact(el).replace(/ /g, ''); + if (t.indexOf(keyword) < 0) return false; + if (supplier && t.indexOf(supplier) < 0) return false; + return true; + }); + return { + ok: true, + match: !!match, + count: rows.length, + pager: compact(modal.querySelector('.vxe-pager, .modal-body-footer') || {innerText: ''}).slice(0, 80), + row: match ? compact(match).slice(0, 80) : '', + }; + """, + keyword, + supplier, + ) + print("person_rows=", last, flush=True) + if isinstance(last, dict) and last.get("match"): + break + time.sleep(0.4) + picked = browser.execute_script( + """ + /* PICK_PERSON_ROW */ + """ + + _unit_picker_modal_js() + + """ + const keyword = String(arguments[0] || '').replace(/\\s+/g, ''); + const supplier = String(arguments[1] || '').replace(/\\s+/g, ''); + const modal = personPicker(); + if (!modal) { return {ok: false, error: 'no-modal'}; } + const row = Array.from(modal.querySelectorAll('.vxe-body--row')).find((el) => { + const t = compact(el).replace(/ /g, ''); + if (!vis(el) || t.indexOf(keyword) < 0) return false; + if (supplier && t.indexOf(supplier) < 0) return false; + return true; + }); + if (!row) { return {ok: false, error: 'no-row'}; } + const radio = row.querySelector('.vxe-cell--radio, .vxe-radio--icon, input[type="radio"]'); + if (radio) { radio.click(); } else { row.click(); } + const ok = Array.from(modal.querySelectorAll('button')).find((el) => { + const t = compact(el); + return vis(el) && (t === '确定' || t === '确 定'); + }); + if (ok) { ok.click(); } + return {ok: true, text: compact(row).slice(0, 120)}; + """, + keyword, + supplier, + ) + print("pick_person=", picked, flush=True) + return bool(isinstance(picked, dict) and picked.get("ok")) + +# 点三个点打开选人层,按姓名搜并对上供应商名称。 +# +# 计划员选廖建琦,联系人选苏文辉。适配器默认关货源层时这里必须先 hold。选不上抛 NodeError。 +def _pick_person( + browser: BrowserAdapter, title: str, name: str, *, nth: int | None = None, supplier_name: str = "" +) -> None: + wanted = (name or "").strip() + if not wanted: + return + if _picker_bound(browser, title, wanted): + return + _hold_source_choice(browser, True) + try: + _close_header_query(browser) + deadline = time.time() + 5 + while time.time() <= deadline and _supplier_picker_open(browser): + time.sleep(0.3) + clicked = _open_labeled_picker(browser, title, dots_only=True) + print("open_person_dots=", clicked, flush=True) + deadline = time.time() + 8 + while time.time() <= deadline: + if _person_picker_visible(browser): + break + time.sleep(0.3) + if not _person_picker_visible(browser): + raise NodeError( + f"点三个点未打开选择层 title={title} overlays={_scan_lookup_overlays(browser)}" + ) + picked = _query_person_and_pick(browser, title, wanted, supplier_name=supplier_name) + if not picked and nth is not None: + picked = _pick_nth_overlay_row(browser, nth, wanted) + if not picked: + picked = _click_keyword_in_overlays(browser, wanted) + if picked: + time.sleep(0.3) + if _person_picker_visible(browser): + _close_person_picker(browser) + pid = _read_labeled_input(browser, f"{title}ID") + print(f"bound_{title}=", _read_labeled_input(browser, title), "id=", pid, flush=True) + if not _picker_bound(browser, title, wanted): + raise NodeError( + f"未选到{title}={wanted},当前={_read_labeled_input(browser, title)} id={pid}" + ) + finally: + _hold_source_choice(browser, False) + +# 返利单位选择或供应商选择层是否开着。 +def _supplier_picker_open(browser: BrowserAdapter) -> bool: + overlays = _scan_lookup_overlays(browser) + return any(item in {"返利单位选择", "供应商选择"} or "返利单位选择" in item for item in overlays) + +# 从总单表当前行读协议总单ID。没有则空字符串。 +def _scan_rebate_order_no(browser: BrowserAdapter, *, contract_no: str = "") -> str: + try: + text = browser.execute_script( + """ + /* READ_REBATE_ORDER_NO */ + const no = String(arguments[0] || ''); + const heads = Array.from(document.querySelectorAll('.vxe-header--column')).map((th) => ({ + title: (th.innerText || '').replace(/\\s+/g, '').trim(), + colid: th.getAttribute('colid'), + })); + const rows = Array.from(document.querySelectorAll('.vxe-body--row')); + const row = rows.find((r) => !no || (r.innerText || '').indexOf(no) >= 0) || rows[0]; + function cell(name) { + const h = heads.find((x) => x.title === name); + if (!h || !row) { return ''; } + const c = row.querySelector('[colid="' + h.colid + '"]'); + return c ? (c.innerText || '').replace(/\\s+/g, ' ').trim() : ''; + } + return cell('协议总单ID') || cell('采购协议总单ID') || cell('采购协议号') || ''; + """, + contract_no, + ) + except Exception: + text = "" + return _clean_order_no(text, contract_no=contract_no) + +# 丢掉日期、列名占位和空值,留下像协议号的文本。 +def _clean_order_no(raw: object, *, contract_no: str = "") -> str: + text = "" if raw is None else str(raw).strip() + if not text: + return "" + if re.match(r"^\d{4}-\d{2}-\d{2}", text): + return "" + if text in {"总单号", "采购协议号", "协议编号", "采购协议总单ID", "协议总单号", "协议总单ID"}: + return "" + return text + +# 先按工具栏文案点,点不到再按 text=/contains 选择器点。 +def _click_text(browser: BrowserAdapter, title: str) -> bool: + if _click_toolbar(browser, title): + return True + try: + browser.click_first( + [ + f"text={title}", + f"button:contains({title})", + f"span:contains({title})", + f"a:contains({title})", + f'//li[contains(.,"{title}")]', + ] + ) + return True + except Exception: + return False + +_SELECT_HEADER_ROW_JS = """ +/* SELECT_HEADER_ROW */ +const no = String(arguments[0] || '').trim().replace(/\\s+/g, ''); +function compact(el) { + return ((el && (el.innerText || el.textContent)) || '').replace(/\\s+/g, ''); +} +function rowChecked(row) { + if (!row) return false; + const icon = row.querySelector('.vxe-checkbox--icon'); + const cls = String((icon && icon.className) || ''); + return cls.indexOf('checkbox-checked') >= 0 && cls.indexOf('unchecked') < 0; +} +function checkLeft(row) { + if (!row) return false; + if (rowChecked(row)) return true; + const box = row.querySelector('.vxe-cell--checkbox:not(.is--disabled), .vxe-checkbox--icon'); + if (!box) return false; + if (String(box.className || '').indexOf('is--disabled') >= 0) return false; + box.click(); + return true; +} +const root = document.querySelector('.table-show'); +if (!root) { return {ok: false, error: 'no-header-table'}; } +if (!no) { return {ok: false, error: 'empty'}; } +const col = Array.from(root.querySelectorAll('.vxe-header--column')).find((th) => { + return compact(th) === '合同编号'; +}); +if (!col) { return {ok: false, error: 'no-合同编号-col'}; } +const colid = col.getAttribute('colid'); +const mains = Array.from(root.querySelectorAll( + '.vxe-table--main-wrapper .vxe-body--row, .vxe-table--body-wrapper .vxe-body--row' +)); +let idx = -1; +let hitText = ''; +for (let i = 0; i < mains.length; i++) { + const cell = mains[i].querySelector('[colid="' + colid + '"]'); + const t = compact(cell); + if (t === no || (t && t.indexOf(no) >= 0)) { + idx = i; + hitText = t; + break; + } +} +if (idx < 0) { + const cells = Array.from(root.querySelectorAll('.vxe-body--row [colid="' + colid + '"]')); + for (const cell of cells) { + const t = compact(cell); + if (t !== no && t.indexOf(no) < 0) continue; + const row = cell.closest('.vxe-body--row'); + idx = mains.indexOf(row); + if (idx < 0) { + const all = Array.from(root.querySelectorAll('.vxe-body--row')); + idx = all.indexOf(row); + } + hitText = t; + break; + } +} +if (idx < 0) { + return {ok: false, error: 'row', colid: colid, rows: mains.length}; +} +const lefts = Array.from(root.querySelectorAll('.vxe-table--fixed-left-wrapper .vxe-body--row')); +const left = lefts[idx] || null; +const main = mains[idx] || null; +if (main) { main.scrollIntoView({block: 'center'}); } +const checked = checkLeft(left); +return { + ok: !!checked, + text: hitText || compact(main).slice(0, 80), + idx: idx, + colid: colid, + checked: rowChecked(left), +}; +""" + +# 按总单表「合同编号」列勾中那一行,禁止拿第一行或 CG- 号凑合。 +def select_header_row(browser: BrowserAdapter, contract_no: str) -> bool: + result = select_header_row_result(browser, contract_no) + return bool(isinstance(result, dict) and result.get("ok")) + +# 按「合同编号」列勾中那一行,返回脚本结果字典。禁止拿第一行或 CG- 号。 +def select_header_row_result(browser: BrowserAdapter, contract_no: str) -> dict: + _switch_agreement_frame(browser) + try: + result = browser.execute_script(_SELECT_HEADER_ROW_JS, contract_no) + except Exception: + result = None + if isinstance(result, dict): + return result + return {"ok": False, "error": "script"} + +# 读总单表当前勾中行的合同编号,给续跑脚本确认没拿错行。 +def read_header_selection(browser: BrowserAdapter, contract_no: str) -> dict: + _switch_agreement_frame(browser) + try: + result = browser.execute_script( + """ + /* HEADER_SELECTION */ + const no = String(arguments[0] || '').trim().replace(/\\s+/g, ''); + function compact(el) { + return ((el && (el.innerText || el.textContent)) || '').replace(/\\s+/g, ''); + } + const root = document.querySelector('.table-show'); + if (!root) { return {ok: false, error: 'no-header-table'}; } + const col = Array.from(root.querySelectorAll('.vxe-header--column')).find((th) => { + return compact(th) === '合同编号'; + }); + const colid = col ? col.getAttribute('colid') : ''; + const mains = Array.from(root.querySelectorAll( + '.vxe-table--main-wrapper .vxe-body--row, .vxe-table--body-wrapper .vxe-body--row' + )); + const lefts = Array.from(root.querySelectorAll( + '.vxe-table--fixed-left-wrapper .vxe-body--row' + )); + const cells = colid + ? Array.from(root.querySelectorAll('.vxe-body--row [colid="' + colid + '"]')).map((c) => compact(c)) + : []; + let checkedIdx = -1; + for (let i = 0; i < lefts.length; i++) { + const icon = lefts[i].querySelector('.vxe-checkbox--icon'); + const cls = String((icon && icon.className) || ''); + if (cls.indexOf('checkbox-checked') >= 0 && cls.indexOf('unchecked') < 0) { + checkedIdx = i; + break; + } + } + const contract = (checkedIdx >= 0 && cells[checkedIdx]) ? cells[checkedIdx] : (cells[0] || ''); + return { + ok: !!(no && contract && contract.indexOf(no) >= 0 && checkedIdx >= 0), + colid: colid, + rows: mains.length, + cells: cells, + checkedIdx: checkedIdx, + contract: contract, + pager: compact(root.querySelector('.vxe-pager, .elPlus-pagination')), + }; + """, + contract_no, + ) + except Exception: + result = None + if isinstance(result, dict): + return result + return {"ok": False, "error": "script"} + +# 关掉总单查询弹层。点「取消」会清掉合同编号筛选,只关 X / Esc。 +def _close_header_query(browser: BrowserAdapter) -> None: + try: + browser.execute_script( + """ + /* CLOSE_HEADER_QUERY */ + function compact(el) { + return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); + } + function vis(el) { + return !!(el && (el.offsetWidth || el.offsetHeight)); + } + const modal = Array.from(document.querySelectorAll( + '.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer, .elPlus-overlay, .elPlus-popper' + )).find((el) => { + if (!vis(el)) return false; + const t = compact(el); + return t.indexOf('已保存查询条件') >= 0 || t.indexOf('设置查询条件') >= 0 + || (t.indexOf('合同编号') >= 0 && !!el.querySelector('.search-condition-item')); + }); + if (modal) { + const close = modal.querySelector( + '.vxe-modal--close-btn, .vxe-icon-close, .elPlus-dialog__headerbtn, .el-dialog__headerbtn' + ); + if (close) { close.click(); return {ok: true, via: 'x'}; } + modal.remove(); + return {ok: true, via: 'remove'}; + } + return {ok: false, via: 'none'}; + """ + ) + except Exception: + pass + time.sleep(0.4) + +# 下拉可筛选且很长。先输入再精确点中文案,避免「电汇」点到「电汇—非直联」。 +def _pick_detail_option( + browser: BrowserAdapter, title: str, value: str, *, in_detail: bool = False +) -> bool: + wanted = (value or "").strip() + if not wanted: + return False + item_fn = "detailItem" if in_detail else "formItem" + opened = browser.execute_script( + """ + /* OPEN_DETAIL_SELECT */ + """ + + _header_dom_js() + + f""" + const title = String(arguments[0] || ''); + const item = {item_fn}(title); + if (!item) {{ return {{ok: false, error: 'no-label'}}; }} + item.scrollIntoView({{block: 'center', inline: 'nearest'}}); + const trigger = selectWrap(item) || item.querySelector('.elPlus-input__wrapper, input, .vxe-input'); + if (!trigger) {{ return {{ok: false, error: 'no-trigger'}}; }} + trigger.click(); + return {{ok: true}}; + """, + title, + ) + if not (isinstance(opened, dict) and opened.get("ok")): + return False + time.sleep(0.2) + browser.execute_script( + """ + /* FILTER_DETAIL_SELECT */ + """ + + _header_dom_js() + + f""" + const title = String(arguments[0] || ''); + const wanted = String(arguments[1] || ''); + const item = {item_fn}(title); + const input = (item && item.querySelector('input.elPlus-select__input')) + || Array.from(document.querySelectorAll( + '.elPlus-select__popper input, .elPlus-select-dropdown input.elPlus-select__input' + )).find(vis); + if (!input) {{ return {{ok: false, error: 'no-filter'}}; }} + input.focus(); + input.click(); + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; + setter.call(input, wanted); + input.dispatchEvent(new Event('input', {{bubbles: true}})); + input.dispatchEvent(new InputEvent('input', {{bubbles: true, composed: true, data: wanted}})); + input.dispatchEvent(new KeyboardEvent('keyup', {{key: wanted.slice(-1) || '', bubbles: true}})); + return {{ok: true, value: input.value}}; + """, + title, + wanted, + ) + driver = getattr(browser, "_driver", None) + if driver is not None: + try: + from selenium.webdriver.common.action_chains import ActionChains + from selenium.webdriver.common.by import By + from selenium.webdriver.common.keys import Keys + + els = [ + el + for el in driver.find_elements("css selector", "input.elPlus-select__input") + if el.is_displayed() + ] + if els: + el = els[-1] + ActionChains(driver).move_to_element(el).click().send_keys(Keys.CONTROL + "a").send_keys(wanted).perform() + except Exception: + pass + deadline = time.time() + 6 + picked = None + while time.time() <= deadline: + picked = browser.execute_script( + """ + /* PICK_DETAIL_OPTION */ + const wanted = String(arguments[0] || '').replace(/\\s+/g, ''); + function compact(el) { + return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim(); + } + function vis(el) { + return !!(el && (el.offsetWidth || el.offsetHeight)); + } + function scan(root) { + return Array.from(root.querySelectorAll( + '.elPlus-select-dropdown__item, .el-select-dropdown__item, .vxe-select-option, li' + )).find((el) => vis(el) && compact(el) === wanted); + } + const drops = Array.from(document.querySelectorAll( + '.elPlus-select__popper, .elPlus-select-dropdown, .el-select-dropdown, .vxe-select--panel' + )).filter(vis); + const roots = drops.length ? drops : [document]; + let opt = null; + for (const root of roots) { + opt = scan(root); + if (opt) break; + const wrap = root.querySelector( + '.elPlus-scrollbar__wrap, .elPlus-select-dropdown__wrap, .elPlus-vl__window' + ); + if (!wrap) continue; + for (let i = 0; i < 25 && !opt; i++) { + wrap.scrollTop = (wrap.scrollTop || 0) + 72; + opt = scan(root); + } + if (opt) break; + } + if (!opt) { return {ok: false, error: 'no-option'}; } + opt.click(); + return {ok: true, text: ((opt.innerText || '') + '').trim()}; + """, + wanted, + ) + if isinstance(picked, dict) and picked.get("ok") and picked.get("text", "").replace(" ", "") == wanted.replace(" ", ""): + return True + time.sleep(0.3) + return False + +# 读页面 toast。供应商为空或失败文案原样返回,没有则空。 +def _read_detail_toast(browser: BrowserAdapter) -> str: + try: + result = browser.execute_script( + """ + /* DETAIL_TOAST */ + function compact(el) { + return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); + } + const nodes = Array.from(document.querySelectorAll( + '.elPlus-message, .el-message, .elPlus-notification, .el-notification' + )); + const texts = nodes.map((el) => compact(el)).filter(Boolean); + return {texts: texts.slice(0, 8)}; + """ + ) + except Exception: + result = None + texts = [] + if isinstance(result, dict): + texts = [str(t) for t in (result.get("texts") or []) if t] + for text in texts: + if "供应商为空" in text: + return text + if "失败" in text or "不能" in text or "不能为空" in text: + return text + return "" + +# 总单卡片开着时细单确认会点到总单确认。先回表格。 +def _close_header_card(browser: BrowserAdapter) -> None: + try: + browser.execute_script( + """ + /* CLOSE_HEADER_CARD */ + function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); } + function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); } + const card = Array.from(document.querySelectorAll('.table-card')).find((el) => { + const t = compact(el); + return vis(el) && t.indexOf('供应商编码') >= 0 && t.indexOf('货品编码') < 0; + }); + if (!card) { return {ok: false}; } + const hit = Array.from(card.querySelectorAll('.card-header-btn, button')).find((el) => { + if (!vis(el)) return false; + const t = compact(el).replace(/\\s+/g, ''); + return t.indexOf('返回表格') === 0; + }); + if (!hit) { return {ok: false, error: 'no-back'}; } + hit.click(); + return {ok: true, via: compact(hit).slice(0, 40)}; + """ + ) + except Exception: + pass + time.sleep(0.3) + +# 回到货品表。未确认的新增卡片用「返回表格 Ctrl + B」。 +def _close_detail_card(browser: BrowserAdapter) -> None: + try: + browser.execute_script( + """ + /* CLOSE_DETAIL_CARD */ + """ + + _header_dom_js() + + """ + const card = Array.from(document.querySelectorAll('.table-card')).find((el) => { + return vis(el) && compact(el).indexOf('货品编码') >= 0; + }); + if (!card) { return {ok: false}; } + const hit = Array.from(card.querySelectorAll('.card-header-btn, button')).find((el) => { + if (!(el.offsetWidth || el.offsetHeight)) return false; + const t = compact(el).replace(/\\s+/g, ''); + return t.indexOf('返回表格') === 0; + }); + if (hit) { hit.click(); return {ok: true, via: compact(hit).slice(0, 40)}; } + return {ok: false}; + """ + ) + except Exception: + pass + time.sleep(0.3) + +_SELECT_HEADER_ROW = _SELECT_HEADER_ROW_JS diff --git a/rpa_rebate/rebate_find.py b/rpa_rebate/rebate_find.py new file mode 100644 index 0000000..1e630e7 --- /dev/null +++ b/rpa_rebate/rebate_find.py @@ -0,0 +1,71 @@ +# 返利查号节点:run_find_flow。登录/查号在 rebate_common。 + +from __future__ import annotations + +from accept import AcceptanceReport, evidence_shot + +from rebate_common import ( + FLOW, + BrowserAdapter, + RebateFlowContext, + open_rebate_center, + login_rebate_center, + enter_purchase_agreement, + search_contract, +) + +_ACCEPT = AcceptanceReport(flow=FLOW) + +# 打开返利中心、登录、进采购协议、按合同号查号。 +# +# 表里出现该号才返回 True。输入框回显不算。写 ACCEPT SUMMARY REBATE_FIND。 +def run_find_flow( + browser: BrowserAdapter, + ctx: RebateFlowContext, + contract_no: str, + *, + expect_found: bool | None = None, +) -> bool: + _ACCEPT.reset() + try: + open_rebate_center(browser, ctx) + _ACCEPT.note("S02", "打开返利中心", {"url": ctx.rebate_url}, ok=True) + try: + login_rebate_center(browser, ctx) + _ACCEPT.note("S03", "登录", {"user": ctx.username}, ok=True) + except Exception as exc: + evidence_shot(browser, "rebate-login-failed", failed=True) + _ACCEPT.note( + "S03", + "登录", + {"user": ctx.username, "error": exc}, + ok=False, + raise_error=False, + ) + raise + enter_purchase_agreement(browser) + _ACCEPT.note("S04", "进入采购协议", {"menu": "采购协议管理"}, ok=True) + found = search_contract(browser, contract_no) + found_text = "yes" if found else "no" + if expect_found is None: + _ACCEPT.note( + "S05", + "查号", + {"contractNo": contract_no, "found": found_text}, + ok=True, + ) + else: + _ACCEPT.check( + "S05", + "查号", + [ + ("contractNo", contract_no, contract_no), + ("found", found_text, "yes" if expect_found else "no"), + ], + browser=browser, + screenshot_on_fail="rebate-find-mismatch", + ) + return found + finally: + print(_ACCEPT.summary_text(), end="", flush=True) + _ACCEPT.dump() diff --git a/rpa_rebate/rebate_finder.py b/rpa_rebate/rebate_finder.py index 25f1fc7..bed6413 100644 --- a/rpa_rebate/rebate_finder.py +++ b/rpa_rebate/rebate_finder.py @@ -1,4 +1,5 @@ -"""生产 finder:登录一次,按合同号搜返利中心。Callable[[str], bool]。""" +# 生产 finder:登录一次,按合同号搜返利中心。Callable[[str], bool]。 + from __future__ import annotations import os @@ -13,7 +14,9 @@ from rebate_flow_steps import ( ) +# 按合同号查返利中心。一次班次只登录一次,后面的合同号复用这个会话。 class RebateFinder: + # 记下浏览器和地址账密。还没登录。 def __init__(self, browser, ctx: RebateFlowContext | None = None, **kwargs): self._browser = browser self._ctx = ctx or RebateFlowContext( @@ -23,10 +26,12 @@ class RebateFinder: ) self._ready = False + # 搜这个合同号。表里出现该号才是 True,输入框回显不算。 def __call__(self, contract_no: str) -> bool: self._ensure_session() return search_contract(self._browser, contract_no) + # 第一次搜之前打开返利中心、登录、进入采购协议。 def _ensure_session(self) -> None: if self._ready: return @@ -36,6 +41,7 @@ class RebateFinder: self._ready = True +# 从 REBATE_URL / 账号环境变量拼页面会话。没配地址时用默认内网地址。 def context_from_env() -> RebateFlowContext: return RebateFlowContext( rebate_url=(os.getenv("REBATE_URL") or DEFAULT_URL).strip(), @@ -44,6 +50,7 @@ def context_from_env() -> RebateFlowContext: ) +# 按环境变量打开浏览器,做成班次用的查号器。 def production_finder() -> RebateFinder: from apa_browser_adapter import ApaBrowserAdapter diff --git a/rpa_rebate/rebate_flow_steps.py b/rpa_rebate/rebate_flow_steps.py index 35ed1c5..8d2415e 100644 --- a/rpa_rebate/rebate_flow_steps.py +++ b/rpa_rebate/rebate_flow_steps.py @@ -1,5029 +1,222 @@ -"""返利中心查号、总单、细单与送审:搜到后填总单;页面新增细单(按货品ID搜,不做 Excel 导入);点送审后马上能见号则带回总单/细单号。""" +# 返利中心查号、总单、细单与送审:搜到后填总单;页面新增细单(按货品ID搜,不做 Excel 导入);点送审后马上能见号则带回总单/细单号。 +# +# 对外入口:job.py / run_*.py / 单测继续从本模块 import。实现按节点拆在 rebate_common / rebate_find / rebate_header / rebate_import / rebate_submit。 + from __future__ import annotations -import json -import os -import re -import shutil -import time -from dataclasses import dataclass -from datetime import date -from decimal import Decimal, ROUND_HALF_UP -from pathlib import Path -from typing import Protocol - -from accept import AcceptanceReport, artifact_dir, evidence_shot -from errors import LoginFailed, NodeError - -FLOW = "REBATE_FIND" -HEADER_FLOW = "REBATE_HEADER" -IMPORT_FLOW = "REBATE_IMPORT" -SUBMIT_FLOW = "REBATE_SUBMIT" -DEFAULT_URL = "http://192.168.170.144:8001" -SENTINEL_CONTRACT_NO = "__NO_SUCH_CONTRACT__" -PAY_MODE_NEXT_MONTH_N = "下月N号结" -REBATE_CONTRACT_DEFAULT = "否" -SETTLE_MODE_DEFAULT = "否" -TAX_RATE_DEFAULT = "0.13" -PAY_MODE_CODE = "11" -PAY_PARAM_DEFAULT = "25" -PAY_PARAM_SALES = "以销售定结" -SETTLE_CODE = "3" -SETTLE_MODE_WIRE = "电汇" -COMPENSATION_DEFAULT = "0" -PLANNER_DEFAULT = "廖建琦" -CONTACT_DEFAULT = "苏文辉" - -_ACCEPT = AcceptanceReport(flow=FLOW) -_HEADER_ACCEPT = AcceptanceReport(flow=HEADER_FLOW) -_IMPORT_ACCEPT = AcceptanceReport(flow=IMPORT_FLOW) -_SUBMIT_ACCEPT = AcceptanceReport(flow=SUBMIT_FLOW) - -_PORTAL_MARKERS = ("采购协议", "待审批", "待审批事项", "待办", "菜单名称", "下载打印插件") -EMPTY_MARKERS = ("暂无数据", "没有找到", "查询无结果", "无匹配数据", "未找到相关") - -LOGIN = { - "user": [ - 'input[name="username"]', - 'input[name="userName"]', - 'input[placeholder*="用户名"]', - 'input[placeholder*="账号"]', - 'input[placeholder*="工号"]', - 'input[type="text"]', - ], - "password": [ - 'input[name="password"]', - 'input[placeholder*="密码"]', - 'input[type="password"]', - ], - "submit": [ - "button.login-submit", - "button.el-button--primary", - "button:contains(登录)", - 'button[type="submit"]', - 'input[type="submit"]', - 'input[value="登录"]', - ], -} - -MENU = { - "entry": [ - "text=采购协议管理", - "text=采购协议", - "a:contains(采购协议管理)", - "span:contains(采购协议管理)", - ], -} - -SEARCH = { - "contract_no": [ - "label=合同编号", - "label=合同号", - 'input[placeholder*="合同编号"]', - 'input[placeholder*="合同号"]', - 'input[name*="contractNo"]', - 'input[name*="contract"]', - ], - "button": [ - "text=查询", - "button:contains(查询)", - "a:contains(查询)", - "text=搜索", - "button:contains(搜索)", - ], -} - -_TABLE_SCRIPT = """ -const nodes = Array.from(document.querySelectorAll( - 'table tbody, table, .el-table__body, .el-table, .ant-table-tbody, .ant-table, .vxe-table, .vxe-body--row' -)); -return nodes.map((n) => (n.innerText || '').trim()).filter(Boolean).join('\\n'); -""" - - -class BrowserAdapter(Protocol): - 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 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: ... - def read_body_text(self) -> str: ... - def execute_script(self, script: str, *args): ... - def screenshot(self, name: str) -> str: ... - def select_first(self, selectors: list[str], value: str) -> None: ... - def read_first(self, selectors: list[str], value_name: str = "") -> str: ... - def upload_file_first(self, selectors: list[str], file_path: str) -> None: ... - - -@dataclass(frozen=True) -class RebateFlowContext: - rebate_url: str - username: str - password: str - - -@dataclass(frozen=True) -class RebateHeaderDraft: - planner_name: str - supplier_code: str - rebate_unit_code: str - rebate_contract: str = REBATE_CONTRACT_DEFAULT - settle_mode: str = SETTLE_MODE_DEFAULT - pay_mode: str = PAY_MODE_NEXT_MONTH_N - protocol_start: str = "" - protocol_end: str = "" - contact_name: str = "" - supplier_name: str = "" - supplier_id: str = "" - - -@dataclass(frozen=True) -class RebateImportLine: - goods_code: str - qty: str - tax_price: str - order_no: str - source_line_id: str = "" - goods_id: str = "" - tax_rate: str = "" - net_price: str = "" - - -@dataclass(frozen=True) -class RebateImportDraft: - lines: tuple[RebateImportLine, ...] - today: str = "" - - -@dataclass(frozen=True) -class RebateImportResult: - success_count: int - fail_count: int - fail_reasons: tuple[str, ...] = () - parsed: bool = False - - -@dataclass(frozen=True) -class RebateSubmitResult: - submitted: bool - agreement_id: str = "" - agreement_dtl_id: str = "" - - -def classify_search_result(result_text: str, contract_no: str, *, from_table: bool = True) -> bool: - number = (contract_no or "").strip() - if not number: - return False - text = result_text or "" - if from_table: - if any(marker in text for marker in EMPTY_MARKERS) and number not in text: - return False - return number in text - if any(marker in text for marker in EMPTY_MARKERS): - return False - return text.count(number) >= 2 - - -def read_result_table(browser: BrowserAdapter) -> tuple[str, bool]: - try: - text = browser.execute_script(_TABLE_SCRIPT) - except Exception: - text = None - if isinstance(text, str) and text.strip(): - return text, True - return (browser.read_body_text() or ""), False - - -def open_rebate_center(browser: BrowserAdapter, ctx: RebateFlowContext) -> None: - url = "" - try: - url = str(browser.execute_script("return location.href") or "") - except Exception: - url = "" - if "ssoLogin" in url or "login" in url.lower() or "wel/index" in url: - browser.open_url(ctx.rebate_url) - return - if "procurementAgree" in url: - return - browser.open_url(ctx.rebate_url) - - -def _is_logged_in(browser: BrowserAdapter) -> bool: - body = browser.read_body_text() or "" - return any(marker in body for marker in _PORTAL_MARKERS) - - -def login_rebate_center(browser: BrowserAdapter, ctx: RebateFlowContext) -> None: - try: - if _is_logged_in(browser): - return - if not (ctx.username or "").strip() or ctx.password == "": - raise LoginFailed("未配置 REBATE_USERNAME / REBATE_PASSWORD") - browser.fill_first(LOGIN["user"], ctx.username) - browser.fill_first(LOGIN["password"], ctx.password) - clicked = False - try: - clicked = bool( - browser.execute_script( - """ - const btn = Array.from(document.querySelectorAll('button')).find((el) => { - const t = ((el.innerText || el.value || '') + '').replace(/\\s+/g, '').trim(); - return t === '登录' && (el.offsetWidth || el.offsetHeight); - }); - if (!btn) { return false; } - btn.click(); - return true; - """ - ) - ) - except Exception: - clicked = False - if not clicked: - browser.click_first(LOGIN["submit"]) - deadline = time.time() + 30 - while time.time() < deadline: - if _is_logged_in(browser): - return - time.sleep(0.5) - raise TimeoutError("登录后未进入返利中心") - except LoginFailed: - raise - except Exception as exc: - raise LoginFailed(f"返利中心登录失败:{exc}") from exc - - -def _switch_agreement_frame(browser: BrowserAdapter) -> bool: - driver = getattr(browser, "_driver", None) - if driver is None: - return False - - def _here() -> bool: - try: - body = driver.execute_script("return (document.body && document.body.innerText) || ''") or "" - has_search = bool(driver.execute_script("return !!document.querySelector('.search-button')")) - return ("协议总单ID" in body or "采购协议号" in body) and has_search - except Exception: - return False - - if _here(): - return True - try: - driver.switch_to.default_content() - if _here(): - return True - frames = driver.find_elements("css selector", "iframe") - except Exception: - return False - for frame in frames: - try: - driver.switch_to.default_content() - driver.switch_to.frame(frame) - if _here(): - return True - except Exception: - continue - try: - driver.switch_to.default_content() - except Exception: - pass - return _here() - - -def _dismiss_menu_autocomplete(browser: BrowserAdapter) -> None: - """菜单「采购协议」下拉会挡住总单查询和细单新增,进页后必须关掉。""" - try: - switch = getattr(browser, "switch_to_default_content", None) - if callable(switch): - switch() - except Exception: - pass - try: - browser.execute_script( - """ - /* DISMISS_MENU_AUTOCOMPLETE */ - const input = document.querySelector('input[placeholder*="菜单名称"]'); - if (input) { input.blur(); } - document.dispatchEvent(new KeyboardEvent('keydown', { - key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true - })); - const pops = Array.from(document.querySelectorAll( - '.el-autocomplete-suggestion, .my-autocomplete, .el-popper.my-autocomplete' - )); - for (const el of pops) { el.remove(); } - return pops.length; - """ - ) - except Exception: - pass - - -def enter_purchase_agreement(browser: BrowserAdapter) -> None: - url = "" - try: - url = str(browser.execute_script("return location.href") or "") - except Exception: - url = "" - body = browser.read_body_text() or "" - already = "procurementAgree" in url or "协议总单ID" in body or ( - "合同编号" in body and "查询" in body - ) - if already: - _dismiss_menu_autocomplete(browser) - _switch_agreement_frame(browser) - return - try: - browser.wait_for_element(['input[placeholder*="菜单名称"]'], timeout_seconds=15) - browser.fill_first(['input[placeholder*="菜单名称"]'], "采购协议") - time.sleep(1.5) - except Exception as exc: - print(f"菜单搜索框未填:{exc}", flush=True) - clicked = False - try: - clicked = browser.execute_script( - """ - const sug = document.querySelector( - '.el-autocomplete-suggestion, .my-autocomplete, .el-popper.my-autocomplete' - ); - const scope = sug || document; - const hit = Array.from(scope.querySelectorAll('div.name, li[role="option"]')).find((el) => { - const t = ((el.innerText || '') + '').replace(/\\s+/g, '').trim(); - return (t === '采购协议管理' || t.indexOf('采购协议管理') === 0) - && (el.offsetWidth || el.offsetHeight); - }); - if (!hit) { return {ok: false}; } - hit.click(); - return {ok: true, cls: (hit.className || '').toString().slice(0, 80)}; - """ - ) - clicked = bool(isinstance(clicked, dict) and clicked.get("ok")) - except Exception as exc: - print(f"菜单点击失败:{exc}", flush=True) - clicked = False - if not clicked: - browser.click_first(MENU["entry"]) - _dismiss_menu_autocomplete(browser) - deadline = time.time() + 25 - while time.time() <= deadline: - href = "" - try: - href = str(browser.execute_script("return location.href") or "") - except Exception: - href = "" - if "procurementAgree" in href: - _dismiss_menu_autocomplete(browser) - if _switch_agreement_frame(browser): - return - time.sleep(0.5) - raise TimeoutError("未进入采购协议管理") - - -_QUERY_LABELS = ("合同编号", "采购协议号", "协议总单ID") - - -def _open_header_search_panel(browser: BrowserAdapter) -> bool: - opened = browser.execute_script( - """ - /* OPEN_HEADER_SEARCH */ - function labelText(el) { - const n = el.querySelector('.search-label__text'); - return ((n && n.innerText) || '').replace(/\\s+/g, '').trim(); - } - const items = Array.from(document.querySelectorAll('.search-condition-item')); - const hasContract = items.some((el) => labelText(el) === '合同编号'); - if (hasContract) { return {ok: true, already: true}; } - const btn = document.querySelector('.table-show .single-table .search-button-content') - || document.querySelector('.table-show .search-button-content') - || document.querySelector('.search-button-content'); - if (!btn) { return {ok: false}; } - btn.click(); - return {ok: true}; - """ - ) - if not (isinstance(opened, dict) and opened.get("ok")): - return False - deadline = time.time() + 8 - while time.time() <= deadline: - ready = False - try: - ready = bool( - browser.execute_script( - """ - const items = Array.from(document.querySelectorAll('.search-condition-item')); - return items.some((el) => { - const n = el.querySelector('.search-label__text'); - const t = ((n && n.innerText) || '').replace(/\\s+/g, '').trim(); - return t === '合同编号'; - }); - """ - ) - ) - except Exception: - ready = False - if ready: - return True - time.sleep(0.3) - return False - - -def _clear_header_search(browser: BrowserAdapter) -> None: - browser.execute_script( - """ - /* CLEAR_HEADER_SEARCH */ - const hit = Array.from(document.querySelectorAll('button')).find((el) => { - const t = ((el.innerText || '') + '').replace(/\\s+/g, ' ').trim(); - return t === '清除 Alt+C' || t === '清除'; - }); - if (hit) { hit.click(); return true; } - return false; - """ - ) - time.sleep(0.4) - - -def _fill_header_contract_and_query(browser: BrowserAdapter, number: str) -> bool: - filled = browser.execute_script( - """ - /* FILL_HEADER_CONTRACT */ - const no = String(arguments[0] || ''); - function labelText(el) { - const n = el.querySelector('.search-label__text'); - return ((n && n.innerText) || '').replace(/\\s+/g, '').trim(); - } - const items = Array.from(document.querySelectorAll('.search-condition-item')); - for (const el of items) { - const box = el.querySelector('label.elPlus-checkbox'); - if (!box) continue; - const on = box.classList.contains('is-checked'); - const mine = labelText(el) === '合同编号'; - if (mine && !on) { box.click(); } - if (!mine && on) { box.click(); } - } - const item = items.find((el) => labelText(el) === '合同编号'); - if (!item) { return {ok: false, error: 'no-合同编号'}; } - const box = item.querySelector('label.elPlus-checkbox'); - if (box && !box.classList.contains('is-checked')) { box.click(); } - const input = Array.from(item.querySelectorAll('input.elPlus-input__inner')).find((el) => { - return el.type === 'text' && String(el.placeholder || '').indexOf('请输入') >= 0; - }) || item.querySelector('.search-component input.elPlus-input__inner'); - if (!input) { return {ok: false, error: 'no-input'}; } - input.focus(); - input.click(); - const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; - setter.call(input, no); - input.dispatchEvent(new Event('input', {bubbles: true})); - input.dispatchEvent(new InputEvent('input', {bubbles: true, data: no, inputType: 'insertText'})); - input.dispatchEvent(new Event('change', {bubbles: true})); - if (box && !box.classList.contains('is-checked')) { box.click(); } - input.dispatchEvent(new KeyboardEvent('keydown', { - key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true - })); - return {ok: true, value: input.value, checked: !!(box && box.classList.contains('is-checked'))}; - """, - number, - ) - if not (isinstance(filled, dict) and filled.get("ok") and filled.get("value") == number): - return False - driver = getattr(browser, "_driver", None) - if driver is not None: - try: - from selenium.webdriver.common.keys import Keys - - el = driver.switch_to.active_element - el.send_keys(Keys.CONTROL, "a") - el.send_keys(number) - el.send_keys(Keys.ENTER) - except Exception: - pass - browser.execute_script( - """ - /* QUERY_HEADER_ENTER */ - function compact(el) { - return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); - } - const item = Array.from(document.querySelectorAll('.search-condition-item')).find((el) => { - const n = el.querySelector('.search-label__text'); - return compact(n) === '合同编号'; - }); - const panel = (item && item.closest( - '.search-condition, .vxe-modal--wrapper, .elPlus-dialog, .el-dialog' - )) || document; - const hit = Array.from(panel.querySelectorAll('button')).find((el) => { - const t = compact(el); - return t === '查询 Enter' || t === '查询' || t.indexOf('查询') === 0; - }); - if (hit) { hit.click(); return {ok: true, via: compact(hit)}; } - return {ok: false}; - """ - ) - return True - - -def _search_header_by_contract(browser: BrowserAdapter, number: str) -> bool: - """总单工具栏按合同编号搜。不要先点「清除」,会把表刷回全量再查不准。""" - if not _open_header_search_panel(browser): - return False - return _fill_header_contract_and_query(browser, number) - - -def _search_via_query_panel(browser: BrowserAdapter, number: str, label: str) -> bool: - opened = browser.execute_script( - """ - /* OPEN_QUERY_PANEL */ - if (document.querySelector('.search-condition-item')) { - return {ok: true, already: true}; - } - const btn = document.querySelector('.search-button'); - if (!btn) { return {ok: false}; } - btn.click(); - return {ok: true}; - """ - ) - if not (isinstance(opened, dict) and opened.get("ok")): - return False - deadline = time.time() + 8 - while time.time() <= deadline: - ready = False - try: - ready = bool( - browser.execute_script( - "return !!document.querySelector('.search-condition-item')" - ) - ) - except Exception: - ready = False - if ready: - break - time.sleep(0.3) - filled = browser.execute_script( - """ - /* FILL_QUERY_FIELD */ - const label = String(arguments[0] || ''); - const no = String(arguments[1] || ''); - function labelText(el) { - const n = el.querySelector('.search-label__text'); - return ((n && n.innerText) || '').replace(/\\s+/g, '').trim(); - } - const items = Array.from(document.querySelectorAll('.search-condition-item')); - if (!items.length) { return {ok: false}; } - const item = items.find((el) => labelText(el) === label) - || items.find((el) => (el.innerText || '').indexOf(label) >= 0); - if (!item) { return {ok: false}; } - const box = item.querySelector('label.elPlus-checkbox'); - if (box && !box.classList.contains('is-checked')) { box.click(); } - const input = Array.from(item.querySelectorAll('input.elPlus-input__inner')).find((el) => { - return el.type === 'text' && String(el.placeholder || '').indexOf('请输入') >= 0; - }) || item.querySelector('.search-component input.elPlus-input__inner'); - if (!input) { return {ok: false}; } - input.focus(); - input.click(); - const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; - setter.call(input, no); - input.dispatchEvent(new Event('input', {bubbles: true})); - input.dispatchEvent(new Event('change', {bubbles: true})); - if (box && !box.classList.contains('is-checked')) { box.click(); } - return {ok: true, value: input.value, label, checked: !!(box && box.classList.contains('is-checked'))}; - """, - label, - number, - ) - if not (isinstance(filled, dict) and filled.get("ok")): - return False - browser.execute_script( - """ - const hit = Array.from(document.querySelectorAll('button')).find((el) => { - const t = ((el.innerText || '') + '').replace(/\\s+/g, ' ').trim(); - return t === '查询 Enter' || t === '查询'; - }); - if (hit) { hit.click(); return true; } - return false; - """ - ) - return True - - -def _wait_search_hit(browser: BrowserAdapter, number: str, *, since: float | None = None) -> bool: - deadline = time.time() + 15 - started = since or time.time() - last_text = "" - last_from_table = False - last_header: dict | None = None - while time.time() <= deadline: - _switch_agreement_frame(browser) - header = read_header_selection(browser, number) - elapsed = time.time() - started - if header.get("error") != "script" and "cells" in header: - last_header = header - cells = [str(c or "") for c in (header.get("cells") or [])] - if any(number == c or number in c for c in cells) and elapsed >= 0.6: - return True - pager = str(header.get("pager") or "").replace(" ", "") - if "共0条" in pager and elapsed > 6: - return False - time.sleep(0.4) - continue - text, from_table = read_result_table(browser) - last_text, last_from_table = text, from_table - found = classify_search_result(text, number, from_table=from_table) - body = "" - try: - body = browser.read_body_text() or "" - except Exception: - body = "" - pagers = [int(n) for n in re.findall(r"共\s*(\d+)\s*条", f"{text}\n{body}")] - all_zero = bool(pagers) and all(n == 0 for n in pagers) - empty = any(marker in (text or "") for marker in EMPTY_MARKERS) and number not in (text or "") - header_ready = "协议总单ID" in (text or "") - if found and elapsed >= 0.6: - return True - if pagers and max(pagers) == 1 and elapsed >= 0.8: - return True - if all_zero and elapsed > 2: - return False - if empty and not header_ready and elapsed > 2: - return False - time.sleep(0.4) - if last_header is not None: - cells = [str(c or "") for c in (last_header.get("cells") or [])] - return any(number == c or number in c for c in cells) - return classify_search_result(last_text, number, from_table=last_from_table) - - -def search_contract(browser: BrowserAdapter, contract_no: str) -> bool: - number = (contract_no or "").strip() - if not number: - return False - _dismiss_menu_autocomplete(browser) - _switch_agreement_frame(browser) - try: - if _search_header_by_contract(browser, number): - return _wait_search_hit(browser, number, since=time.time()) - except Exception: - pass - used_panel = False - for label in _QUERY_LABELS: - try: - hit = _search_via_query_panel(browser, number, label) - if not hit: - time.sleep(0.4) - _switch_agreement_frame(browser) - hit = _search_via_query_panel(browser, number, label) - except Exception: - hit = False - if not hit: - continue - used_panel = True - if _wait_search_hit(browser, number, since=time.time()): - return True - if not used_panel: - try: - browser.fill_first(SEARCH["contract_no"], number) - browser.click_first(SEARCH["button"]) - return _wait_search_hit(browser, number, since=time.time()) - except Exception: - return False - return False - - -def run_find_flow( - browser: BrowserAdapter, - ctx: RebateFlowContext, - contract_no: str, - *, - expect_found: bool | None = None, -) -> bool: - _ACCEPT.reset() - try: - open_rebate_center(browser, ctx) - _ACCEPT.note("S02", "打开返利中心", {"url": ctx.rebate_url}, ok=True) - try: - login_rebate_center(browser, ctx) - _ACCEPT.note("S03", "登录", {"user": ctx.username}, ok=True) - except Exception as exc: - evidence_shot(browser, "rebate-login-failed", failed=True) - _ACCEPT.note( - "S03", - "登录", - {"user": ctx.username, "error": exc}, - ok=False, - raise_error=False, - ) - raise - enter_purchase_agreement(browser) - _ACCEPT.note("S04", "进入采购协议", {"menu": "采购协议管理"}, ok=True) - found = search_contract(browser, contract_no) - found_text = "yes" if found else "no" - if expect_found is None: - _ACCEPT.note( - "S05", - "查号", - {"contractNo": contract_no, "found": found_text}, - ok=True, - ) - else: - _ACCEPT.check( - "S05", - "查号", - [ - ("contractNo", contract_no, contract_no), - ("found", found_text, "yes" if expect_found else "no"), - ], - browser=browser, - screenshot_on_fail="rebate-find-mismatch", - ) - return found - finally: - print(_ACCEPT.summary_text(), end="", flush=True) - _ACCEPT.dump() - - -HEADER = { - "rebate_contract": [ - "label=返利合约", - '//td[contains(.,"返利合约")]/following-sibling::td[1]//select', - '//*[contains(text(),"返利合约")]/following::select[1]', - ], - "supplier_code": [ - "label=供应商编码", - 'input[placeholder*="供应商编码"]', - 'input[name*="supplier"]', - '//td[contains(.,"供应商编码")]/following-sibling::td[1]//input', - ], - "rebate_unit_code": [ - "label=返利单位编码", - 'input[placeholder*="返利单位"]', - 'input[name*="rebateUnit"]', - '//td[contains(.,"返利单位编码")]/following-sibling::td[1]//input', - ], - "planner": [ - "label=计划员", - 'input[placeholder*="计划员"]', - 'input[name*="planner"]', - '//td[contains(.,"计划员")]/following-sibling::td[1]//input', - '//td[contains(.,"计划员")]/following-sibling::td[1]//select', - ], - "settle_mode": [ - "label=采购结算方式", - '//td[contains(.,"采购结算方式")]/following-sibling::td[1]//select', - '//*[contains(text(),"采购结算方式")]/following::select[1]', - ], - "pay_mode": [ - "label=承付模式", - '//td[contains(.,"承付模式")]/following-sibling::td[1]//select', - '//*[contains(text(),"承付模式")]/following::select[1]', - ], - "protocol_start": [ - "label=协议开始日期", - 'input[placeholder*="开始日期"]', - '//td[contains(.,"协议开始日期")]/following-sibling::td[1]//input', - ], - "protocol_end": [ - "label=协议结束日期", - 'input[placeholder*="结束日期"]', - '//td[contains(.,"协议结束日期")]/following-sibling::td[1]//input', - ], -} - -SAVE = { - "button": [ - "text=保存", - "button:contains(保存)", - "a:contains(保存)", - 'input[value="保存"]', - "text=保 存", - ], -} - -ORDER_NO = { - "value": [ - "label=总单号", - "label=采购协议号", - "label=协议编号", - "label=采购协议总单ID", - "label=协议总单号", - 'input[name*="agreementId"]', - 'input[name*="rebateOrder"]', - ], -} - -_FORM_MARKERS = ("保存", "导入导出", "下载模板") -_ORDER_NO_LABEL_RE = re.compile( - r"(?:总单号|采购协议号|协议编号|采购协议总单ID|协议总单号|采购协议ID)[::\s]*([A-Za-z0-9_-]{3,})" -) -_FL_RE = re.compile(r"\bFL[A-Za-z0-9_-]{4,}\b") - - -def _header_dom_js() -> str: - """总单卡片:标签在 .label-wrap,三个点是 span.pointer.f20,下拉是 .elPlus-select__wrapper。""" - return """ - function compact(el) { - return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); - } - function vis(el) { - if (!el) return false; - const st = window.getComputedStyle(el); - if (!st || st.display === 'none' || st.visibility === 'hidden') return false; - return !!(el.offsetWidth || el.offsetHeight); - } - function formItem(title, root) { - const wanted = String(title || '').replace(/\\s+/g, '').replace(/\\*/g, ''); - const scope = root || document; - const items = Array.from(scope.querySelectorAll('.elPlus-form-item, .el-form-item')); - let exact = null; - let fuzzy = null; - for (const item of items) { - if (!vis(item)) continue; - const label = item.querySelector('.form-item__title, .elPlus-form-item__label, label, .label-wrap'); - const t = compact(label || item).replace(/\\s+/g, '').replace(/\\*/g, ''); - if (t === wanted) { exact = item; break; } - if (!fuzzy && t.indexOf(wanted) >= 0 && t.indexOf(wanted) < 8) fuzzy = item; - } - return exact || fuzzy; - } - function detailForm() { - return Array.from(document.querySelectorAll( - '.elPlus-form, form, .elPlus-drawer, .elPlus-dialog, .vxe-modal--wrapper, .elPlus-card' - )).find((el) => { - const t = compact(el); - return vis(el) && t.indexOf('货品编码') >= 0 && t.indexOf('数量') >= 0; - }) || null; - } - function detailItem(title) { - const root = detailForm(); - return root ? formItem(title, root) : null; - } - function clickDots(item) { - const live = (el) => vis(el) && !el.closest('.vxe-modal--wrapper'); - const append = item && item.querySelector( - '.elPlus-input-group--append button, .elPlus-input-group--append .elPlus-button, .elPlus-input-group__append button' - ); - if (append && live(append)) { append.click(); return 'append'; } - const pointer = item && Array.from(item.querySelectorAll('.pointer.f20, span.pointer')).find(live); - if (pointer) { pointer.click(); return 'pointer'; } - const moreIcon = item && Array.from(item.querySelectorAll('.vxe-icon--more')).find(live); - if (moreIcon) { - const btn = moreIcon.closest('button, .elPlus-button') || moreIcon; - btn.click(); - return 'more'; - } - const more = item && Array.from(item.querySelectorAll('button')).find((el) => { - const t = compact(el); - return live(el) && (t === '...' || t === '…' || t === '···'); - }); - if (more) { more.click(); return 'dots-button'; } - return ''; - } - function selectWrap(item) { - return item && item.querySelector('.elPlus-select__wrapper, .elPlus-select, .el-select'); - } - function shownValue(item) { - if (!item) return ''; - const wrap = selectWrap(item); - if (wrap) { - const t = compact(wrap).replace(/请选择/g, '').trim(); - if (t) return t; - } - const input = item.querySelector('input.elPlus-input__inner, input.vxe-input--inner, input:not([type="checkbox"])'); - return input ? String(input.value || '').trim() : ''; - } - """ - -_OPEN_HEADER_ROW = """ -/* OPEN_HEADER_ROW */ -const no = String(arguments[0] || '').trim(); -if (!no) { return {ok: false, error: 'empty'}; } -const rows = Array.from(document.querySelectorAll( - 'tr, .el-table__row, .ant-table-row, .vxe-body--row, [role="row"]' -)); -let row = rows.find((r) => { - const t = (r.innerText || '').replace(/\\s+/g, ' ').trim(); - return t.indexOf(no) >= 0 && t.length < 2000; -}); -if (!row) { - const dataRows = rows.filter((r) => r.querySelector('td, .vxe-body--column, [colid]')); - if (dataRows.length === 1) { row = dataRows[0]; } -} -if (!row) { return {ok: false, error: 'row not found'}; } -const link = Array.from(row.querySelectorAll('a, button, span, .vxe-body--column, td')).find((el) => { - const t = ((el.innerText || '') + '').replace(/\\s+/g, '').trim(); - return t.indexOf(no) >= 0 || t.indexOf('CG-') === 0; -}); -if (link) { - link.click(); - link.dispatchEvent(new MouseEvent('dblclick', {bubbles: true, cancelable: true, view: window})); - return {ok: true, via: 'link'}; -} -row.click(); -row.dispatchEvent(new MouseEvent('dblclick', {bubbles: true, cancelable: true, view: window})); -return {ok: true, via: 'row'}; -""" - -_SET_LABELED = """ -/* SET_LABELED */ -const labelText = String(arguments[0] || '').trim(); -const value = String(arguments[1] || ''); -function controlFor(label) { - const nodes = Array.from(document.querySelectorAll('label, td, th, span, div')); - const node = nodes.find((el) => { - const t = (el.childNodes && el.childNodes.length === 1) - ? (el.textContent || '').replace(/\\s+/g, '').trim() - : (el.innerText || '').replace(/\\s+/g, '').trim(); - const compact = t.replace(/[*::]/g, ''); - const wanted = label.replace(/\\s+/g, ''); - return compact === wanted || compact.indexOf(wanted) === 0; - }); - if (!node) { return null; } - if (node.tagName === 'LABEL' && node.htmlFor) { - return document.getElementById(node.htmlFor); - } - const next = node.nextElementSibling; - if (next) { - if (['INPUT', 'SELECT', 'TEXTAREA'].indexOf(next.tagName) >= 0) { return next; } - const inner = next.querySelector('input, select, textarea'); - if (inner) { return inner; } - } - const cell = node.closest('td, th'); - if (cell && cell.nextElementSibling) { - return cell.nextElementSibling.querySelector('input, select, textarea'); - } - return null; -} -const el = controlFor(labelText); -if (!el) { return false; } -if (el.tagName === 'SELECT') { - const opts = Array.from(el.options || []); - const hit = opts.find((o) => { - const t = (o.textContent || o.text || '').replace(/\\s+/g, ''); - const v = String(o.value || '').replace(/\\s+/g, ''); - const wanted = value.replace(/\\s+/g, ''); - return t === wanted || v === wanted || t.indexOf(wanted) >= 0; - }); - if (!hit) { return false; } - el.value = hit.value; - el.dispatchEvent(new Event('change', {bubbles: true})); - return true; -} -const proto = el.tagName === 'TEXTAREA' - ? window.HTMLTextAreaElement.prototype - : window.HTMLInputElement.prototype; -const 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})); -return true; -""" - -_READ_REBATE_ORDER_NO = """ -/* READ_REBATE_ORDER_NO */ -const labels = ['总单号', '采购协议号', '协议编号', '采购协议总单ID', '协议总单号', '采购协议ID']; -function textOf(el) { - return ((el && (el.value || el.textContent || el.innerText)) || '').replace(/\\s+/g, ' ').trim(); -} -for (const label of labels) { - const nodes = Array.from(document.querySelectorAll('label, td, th, span, div')); - const node = nodes.find((el) => { - const t = (el.innerText || '').replace(/\\s+/g, '').replace(/[*::]/g, ''); - return t === label || t.indexOf(label) === 0; - }); - if (!node) { continue; } - let value = ''; - if (node.tagName === 'LABEL' && node.htmlFor) { - value = textOf(document.getElementById(node.htmlFor)); - } - const next = node.nextElementSibling; - if (!value && next) { - const inner = next.querySelector('input, select, textarea, span, a') || next; - value = textOf(inner); - } - const cell = node.closest('td, th'); - if (!value && cell && cell.nextElementSibling) { - const inner = cell.nextElementSibling.querySelector('input, span, a') || cell.nextElementSibling; - value = textOf(inner); - } - if (value && labels.indexOf(value.replace(/[*::]/g, '')) < 0) { return value; } -} -const body = (document.body && document.body.innerText) || ''; -const m = body.match(/(?:总单号|采购协议号|协议编号|采购协议总单ID|协议总单号)[::\\s]*([A-Za-z0-9_-]{3,})/); -return m ? m[1] : ''; -""" - -_OPEN_DROPDOWN = """ -/* OPEN_DROPDOWN */ -const labelText = String(arguments[0] || '').trim(); -const nodes = Array.from(document.querySelectorAll('label, td, th, span, div')); -const node = nodes.find((el) => { - const t = (el.innerText || '').replace(/\\s+/g, '').replace(/[*::]/g, ''); - return t === labelText.replace(/\\s+/g, '') || t.indexOf(labelText.replace(/\\s+/g, '')) === 0; -}); -if (!node) { return false; } -const cell = (node.nextElementSibling) - || (node.closest('td, th') && node.closest('td, th').nextElementSibling) - || node.parentElement; -if (!cell) { return false; } -const wrap = cell.querySelector('.el-select, .ant-select, .fs-wrap, .combo, select') || cell; -wrap.click(); -return true; -""" - - -def today_iso(today: str | None = None) -> str: - text = (today or "").strip() - if text: - return text - return date.today().isoformat() - - -def year_end_iso(today: str | None = None) -> str: - day = today_iso(today) - return f"{int(day[:4])}-12-31" - - -def _cell(value: object) -> str: - if value is None: - return "" - return str(value).strip() - - -def _tax_rate_of(raw: object) -> str: - text = _cell(raw) - return text if text else TAX_RATE_DEFAULT - - -def _untaxed_price(tax_price: str, tax_rate: str) -> str: - price = _cell(tax_price) - rate = _tax_rate_of(tax_rate) - if not price: - return "" - try: - net = Decimal(price) / (Decimal("1") + Decimal(rate)) - quantized = net.quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP) - text = format(quantized, "f") - if "." in text: - text = text.rstrip("0").rstrip(".") - return text - except Exception: - return "" - - -def build_header_draft( - *, - planner_name: str, - supplier_code: str, - rebate_unit_code: str, - today: str | None = None, -) -> RebateHeaderDraft: - planner = (planner_name or "").strip() or PLANNER_DEFAULT - day = today_iso(today) - return RebateHeaderDraft( - planner_name=planner, - supplier_code=(supplier_code or "").strip(), - rebate_unit_code=(rebate_unit_code or "").strip(), - rebate_contract=REBATE_CONTRACT_DEFAULT, - settle_mode=SETTLE_MODE_DEFAULT, - pay_mode=PAY_MODE_NEXT_MONTH_N, - protocol_start=day, - protocol_end=day, - contact_name=CONTACT_DEFAULT, - ) - - -def header_draft_from_env(*, today: str | None = None) -> RebateHeaderDraft: - return build_header_draft( - planner_name=os.getenv("REBATE_PLANNER_NAME") or "", - supplier_code=os.getenv("REBATE_SUPPLIER_CODE") or "", - rebate_unit_code=os.getenv("REBATE_UNIT_CODE") or "", - today=today, - ) - - -def header_draft_from_table3( - rows: list[dict] | tuple[dict, ...], - *, - today: str | None = None, -) -> RebateHeaderDraft: - """总单取表 3 该合同第一行的供应商 MDM。计划员先选廖建琦,联系人苏文辉。""" - packed = [row for row in rows if isinstance(row, dict)] - if not packed: - raise NodeError("表 3 没有该合同行,不能填总单") - first = packed[0] - supplier = _cell(first.get("supplier_mdm_code") or first.get("supplier_id")) - unit = _cell(first.get("org_code")) - draft = build_header_draft( - planner_name=PLANNER_DEFAULT, - supplier_code=supplier, - rebate_unit_code=unit, - today=today, - ) - return RebateHeaderDraft( - planner_name=PLANNER_DEFAULT, - supplier_code=draft.supplier_code, - rebate_unit_code=draft.rebate_unit_code, - rebate_contract=draft.rebate_contract, - settle_mode=draft.settle_mode, - pay_mode=draft.pay_mode, - protocol_start=draft.protocol_start, - protocol_end=draft.protocol_end, - contact_name=CONTACT_DEFAULT, - supplier_name=_cell(first.get("supplier_name")), - supplier_id=_cell(first.get("supplier_id")), - ) - - -def import_lines_from_table3(rows: list[dict] | tuple[dict, ...]) -> tuple[RebateImportLine, ...]: - """细单按表 3 货品行:货品ID、数量、含税单价、无税=含税/(1+税率)、备注=omsId。""" - lines: list[RebateImportLine] = [] - for row in rows: - if not isinstance(row, dict): - continue - goods_id = _cell(row.get("goods_id")) - if not goods_id: - continue - tax_price = _cell(row.get("unit_price") or row.get("tax_price")) - tax_rate = _tax_rate_of(row.get("purchase_tax_rate") or row.get("tax_rate")) - lines.append( - RebateImportLine( - goods_code=goods_id, - qty=_cell(row.get("qty")), - tax_price=tax_price, - order_no=_cell(row.get("oms_id") or row.get("order_no")), - source_line_id=_cell(row.get("goods_source_id") or row.get("source_line_id")), - goods_id=goods_id, - tax_rate=tax_rate, - net_price=_untaxed_price(tax_price, tax_rate), - ) - ) - if not lines: - raise NodeError("表 3 没有货品行,不能加细单") - return tuple(lines) - - -def header_draft_for_contract(contract_no: str, *, today: str | None = None) -> RebateHeaderDraft: - """优先表 3,空缺再退回环境变量。""" - number = (contract_no or "").strip() - rows: list[dict] = [] - if number: - try: - from repo import OracleRebateRepo - - rows = OracleRebateRepo.from_env().load_contract_goods(number) - except Exception: - rows = [] - if rows: - return header_draft_from_table3(rows, today=today) - return header_draft_from_env(today=today) - - -def import_draft_for_contract(contract_no: str, *, today: str | None = None) -> RebateImportDraft: - """优先表 3 货品行,空缺再退回环境变量。""" - number = (contract_no or "").strip() - rows: list[dict] = [] - if number: - try: - from repo import OracleRebateRepo - - rows = OracleRebateRepo.from_env().load_contract_goods(number) - except Exception: - rows = [] - if rows: - return build_import_draft(lines=import_lines_from_table3(rows), today=today) - return import_draft_from_env(today=today) - - -def _header_form_visible(browser: BrowserAdapter) -> bool: - body = browser.read_body_text() or "" - return any(marker in body for marker in _FORM_MARKERS) - - -def _click_toolbar(browser: BrowserAdapter, title: str) -> bool: - try: - result = browser.execute_script( - """ - const title = String(arguments[0] || '').trim(); - const btn = Array.from(document.querySelectorAll('.table-toolbar-btn__text, button, span')).find((el) => (el.innerText || '').trim() === title); - if (!btn) { return {ok: false}; } - btn.click(); - return {ok: true}; - """, - title, - ) - except Exception: - result = None - return bool(isinstance(result, dict) and result.get("ok")) - - -def _click_header_toolbar(browser: BrowserAdapter, title: str) -> bool: - """只点总单工具栏,避开货品工具栏同名的保存/编辑。""" - try: - result = browser.execute_script( - """ - /* CLICK_HEADER_TOOLBAR */ - const title = String(arguments[0] || '').trim(); - function compact(el) { - return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); - } - function vis(el) { - return !!(el && (el.offsetWidth || el.offsetHeight)); - } - const root = document.querySelector('.table-show'); - const bar = root && root.querySelector('.table-toolbar'); - if (!bar) { return {ok: false, error: 'no-header-bar'}; } - const hit = Array.from(bar.querySelectorAll( - '.table-toolbar-btn, .table-toolbar-btn__text, button, span' - )).find((el) => vis(el) && compact(el) === title); - if (!hit) { return {ok: false, error: 'no-btn'}; } - const btn = hit.closest('button, .table-toolbar-btn') || hit; - btn.click(); - return {ok: true, via: compact(hit)}; - """, - title, - ) - except Exception: - result = None - return bool(isinstance(result, dict) and result.get("ok")) - - -def _native_click_header_toolbar(browser: BrowserAdapter, title: str) -> bool: - driver = getattr(browser, "_driver", None) - if driver is None: - return False - wanted = (title or "").replace(" ", "") - try: - from selenium.webdriver.common.action_chains import ActionChains - - root = None - try: - root = driver.find_element("css selector", ".table-show .table-toolbar") - except Exception: - root = None - scope = root.find_elements("css selector", "button, .table-toolbar-btn, .table-toolbar-btn__text") if root else [] - for el in scope: - raw = (el.text or "").replace("\n", "").replace(" ", "") - if raw != wanted: - continue - driver.execute_script("arguments[0].scrollIntoView({block:'center'});", el) - ActionChains(driver).move_to_element(el).pause(0.15).click().perform() - return True - except Exception: - return False - return False - - -def open_header_form(browser: BrowserAdapter, contract_no: str) -> None: - _switch_agreement_frame(browser) - _close_detail_card(browser) - if not select_header_row(browser, contract_no): - result = None - try: - result = browser.execute_script(_OPEN_HEADER_ROW, contract_no) - except Exception: - result = None - opened = result is True or (isinstance(result, dict) and result.get("ok")) - if opened: - time.sleep(0.5) - _click_header_toolbar(browser, "编辑") or _click_toolbar(browser, "编辑") - time.sleep(0.8) - if _header_form_visible(browser): - return - try: - dbl = getattr(browser, "double_click_first", None) - if callable(dbl): - dbl([".vxe-body--row", '.vxe-body--row [colid]', "tr.vxe-body--row"]) - except Exception: - pass - try: - browser.click_first( - [ - f"text={contract_no}", - f'//a[contains(.,"{contract_no}")]', - f'//td[contains(.,"{contract_no}")]', - ] - ) - except Exception: - pass - deadline = time.time() + 15 - while time.time() <= deadline: - if _header_form_visible(browser): - return - for selectors in ( - ["text=编辑"], - ["text=修改"], - ["button:contains(编辑)"], - ["a:contains(编辑)"], - ): - try: - browser.click_first(selectors) - break - except Exception: - continue - time.sleep(0.4) - if not _header_form_visible(browser): - raise RuntimeError("未打开总单表单") - - -def _fill_select(browser: BrowserAdapter, label: str, selectors: list[str], value: str) -> None: - try: - browser.select_first(selectors, value) - return - except Exception: - pass - try: - browser.execute_script(_OPEN_DROPDOWN, label) - browser.click_first( - [ - f"text={value}", - f'//li[contains(.,"{value}")]', - f'//div[contains(@class,"option") and contains(.,"{value}")]', - f'//span[contains(.,"{value}")]', - ] - ) - return - except Exception: - pass - ok = False - try: - ok = bool(browser.execute_script(_SET_LABELED, label, value)) - except Exception: - ok = False - if not ok: - raise RuntimeError(f"未选到 {label}={value}") - - -def _fill_input(browser: BrowserAdapter, label: str, selectors: list[str], value: str) -> None: - try: - browser.fill_first(selectors, value) - return - except Exception: - pass - try: - browser.select_first(selectors, value) - return - except Exception: - pass - ok = False - try: - ok = bool(browser.execute_script(_SET_LABELED, label, value)) - except Exception: - ok = False - if not ok: - raise RuntimeError(f"未填到 {label}={value}") - - -def _type_active(browser: BrowserAdapter, value: str) -> None: - driver = getattr(browser, "_driver", None) - if driver is None: - return - try: - el = driver.switch_to.active_element - driver.execute_script( - """ - const el = arguments[0]; - const value = String(arguments[1] || ''); - if (!el) { return false; } - el.focus(); - const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; - setter.call(el, value); - el.dispatchEvent(new Event('input', {bubbles: true})); - el.dispatchEvent(new InputEvent('input', {bubbles: true, composed: true, data: value})); - el.dispatchEvent(new Event('change', {bubbles: true})); - return true; - """, - el, - value, - ) - except Exception: - pass - - -def _filter_and_pick_dropdown(browser: BrowserAdapter, value: str, *, exact: bool = True) -> bool: - """已打开的下拉:输入筛选后点中。电汇必须精确匹配。""" - wanted = (value or "").strip() - if not wanted: - return False - _type_active(browser, wanted) - deadline = time.time() + 6 - while time.time() <= deadline: - picked = browser.execute_script( - """ - /* PICK_DETAIL_OPTION */ - const wanted = String(arguments[0] || '').replace(/\\s+/g, ''); - const exact = arguments[1] === true; - function compact(el) { - return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim(); - } - function vis(el) { - return !!(el && (el.offsetWidth || el.offsetHeight)); - } - function scan(root) { - const nodes = Array.from(root.querySelectorAll( - '.elPlus-select-dropdown__item, .el-select-dropdown__item, .vxe-select-option, li' - )); - const hitExact = nodes.find((el) => vis(el) && compact(el) === wanted); - if (hitExact) return hitExact; - if (exact) return null; - return nodes.find((el) => vis(el) && compact(el).indexOf(wanted) >= 0); - } - const drops = Array.from(document.querySelectorAll( - '.elPlus-select__popper, .elPlus-select-dropdown, .el-select-dropdown, .vxe-select--panel' - )).filter(vis); - const roots = drops.length ? drops : [document]; - let opt = null; - for (const root of roots) { - opt = scan(root); - if (opt) break; - const wrap = root.querySelector( - '.elPlus-scrollbar__wrap, .elPlus-select-dropdown__wrap, .elPlus-vl__window' - ); - if (!wrap) continue; - for (let i = 0; i < 25 && !opt; i++) { - wrap.scrollTop = (wrap.scrollTop || 0) + 72; - opt = scan(root); - } - if (opt) break; - } - if (!opt) { return {ok: false, error: 'no-option'}; } - opt.click(); - return {ok: true, text: ((opt.innerText || '') + '').trim()}; - """, - wanted, - exact, - ) - text = "" - if isinstance(picked, dict) and picked.get("ok"): - text = str(picked.get("text") or "").replace(" ", "") - folded = wanted.replace(" ", "") - if text == folded: - return True - if not exact and folded and folded in text: - return True - time.sleep(0.3) - return False - - -def _pick_header_lookup(browser: BrowserAdapter, keyword: str) -> bool: - """供应商/计划员编码弹窗:按编码搜,点行里单选再确定。""" - code = (keyword or "").strip() - if not code: - return False - filled = browser.execute_script( - """ - /* FILL_HEADER_LOOKUP */ - const no = String(arguments[0] || ''); - function compact(el) { - return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); - } - function vis(el) { - return !!(el && (el.offsetWidth || el.offsetHeight)); - } - const modal = Array.from(document.querySelectorAll( - '.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer' - )).find((el) => { - if (!vis(el)) return false; - const t = compact(el); - if (t.indexOf('请填写供应商') >= 0 && t.indexOf('采购协议号') >= 0) return false; - if (t.indexOf('货品编码') >= 0 && t.indexOf('数量') >= 0) return false; - return t.indexOf('供应商选择') >= 0 || t.indexOf('选择供应商') >= 0 - || t.indexOf('客商') >= 0 || t.indexOf('计划员') >= 0 || t.indexOf('供应商') >= 0; - }); - if (!modal) { return {ok: false, error: 'no-modal'}; } - const input = modal.querySelector('input.elPlus-input__inner, input[type="text"], input'); - if (!input) { return {ok: false, error: 'no-input'}; } - input.click(); - input.focus(); - const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; - setter.call(input, no); - input.dispatchEvent(new Event('input', {bubbles: true})); - input.dispatchEvent(new Event('change', {bubbles: true})); - const query = Array.from(modal.querySelectorAll('button')).find((el) => { - const t = compact(el); - return vis(el) && (t === '查询' || t.indexOf('查询') === 0); - }); - if (query) { query.click(); } - return {ok: true, value: input.value}; - """, - code, - ) - if not (isinstance(filled, dict) and filled.get("ok")): - return False - _type_active(browser, code) - deadline = time.time() + 8 - while time.time() <= deadline: - picked = browser.execute_script( - """ - /* PICK_HEADER_LOOKUP */ - const keyword = String(arguments[0] || '').replace(/\\s+/g, ''); - function compact(el) { - return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ''); - } - function vis(el) { - return !!(el && (el.offsetWidth || el.offsetHeight)); - } - const modal = Array.from(document.querySelectorAll( - '.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer' - )).find((el) => { - if (!vis(el)) return false; - const t = compact(el); - if (t.indexOf('请填写供应商') >= 0 && t.indexOf('采购协议号') >= 0) return false; - if (t.indexOf('货品编码') >= 0 && t.indexOf('数量') >= 0) return false; - return t.indexOf('供应商选择') >= 0 || t.indexOf('选择供应商') >= 0 - || t.indexOf('客商') >= 0 || t.indexOf('计划员') >= 0 || t.indexOf('供应商') >= 0; - }); - if (!modal) { return {ok: false, error: 'no-modal'}; } - const root = modal; - const rows = Array.from(root.querySelectorAll('.vxe-body--row, tr, [role="row"]')); - for (const row of rows) { - const text = compact(row); - if (text.indexOf(keyword) < 0) continue; - const radio = row.querySelector( - '.vxe-cell--radio, .vxe-radio--icon, input[type="radio"], .vxe-cell--checkbox' - ); - if (radio) { radio.click(); } else { row.click(); } - const ok = Array.from(root.querySelectorAll('button')).find((el) => { - if (!vis(el)) return false; - const t = compact(el); - return t === '确定' || t === '确 定'; - }); - if (ok) { ok.click(); } - return {ok: true}; - } - return {ok: false}; - """, - code, - ) - if isinstance(picked, dict) and picked.get("ok"): - time.sleep(0.4) - return True - time.sleep(0.4) - return False - - -def _set_grid_field(browser: BrowserAdapter, title: str, value: str, *, as_select: bool = False) -> bool: - if not value: - return False - try: - clicked = browser.execute_script( - """ - /* SET_HEADER_CELL */ - const name = String(arguments[0] || '').replace(/\\s+/g, ''); - function compact(el) { - return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim(); - } - const root = document.querySelector('.table-show') || document; - const th = Array.from(root.querySelectorAll('.vxe-header--column')).find((el) => compact(el) === name); - if (!th) { return {ok: false}; } - const colid = th.getAttribute('colid'); - const lefts = Array.from(root.querySelectorAll('.vxe-table--fixed-left-wrapper .vxe-body--row')); - let idx = 0; - for (let i = 0; i < lefts.length; i++) { - const icon = lefts[i].querySelector('.vxe-checkbox--icon'); - const cls = String((icon && icon.className) || ''); - if (cls.indexOf('checkbox-checked') >= 0 && cls.indexOf('unchecked') < 0) { - idx = i; - break; - } - } - const mains = Array.from(root.querySelectorAll( - '.vxe-table--main-wrapper .vxe-body--row, .vxe-table--body-wrapper .vxe-body--row' - )); - const row = mains[idx] || mains[0]; - const cell = row && row.querySelector('[colid="' + colid + '"]'); - if (!cell) { return {ok: false}; } - cell.scrollIntoView({block: 'center', inline: 'center'}); - cell.click(); - cell.dispatchEvent(new MouseEvent('dblclick', {bubbles: true, cancelable: true, view: window})); - return {ok: true, colid: colid, idx: idx}; - """, - title, - ) - except Exception: - clicked = None - if not (isinstance(clicked, dict) and clicked.get("ok")): - return False - time.sleep(0.3) - if as_select: - return _filter_and_pick_dropdown(browser, value) - try: - typed = browser.execute_script( - """ - /* TYPE_HEADER_CELL */ - const name = String(arguments[0] || '').replace(/\\s+/g, ''); - const value = String(arguments[1] || ''); - function compact(el) { - return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim(); - } - const root = document.querySelector('.table-show') || document; - const th = Array.from(root.querySelectorAll('.vxe-header--column')).find((el) => compact(el) === name); - if (!th) { return {ok: false}; } - const cell = root.querySelector( - '.vxe-table--main-wrapper .vxe-body--row [colid="' + th.getAttribute('colid') + '"], .vxe-body--row [colid="' + th.getAttribute('colid') + '"]' - ); - if (!cell) { return {ok: false}; } - const el = cell.querySelector('input, textarea') || document.activeElement; - if (!el || !el.tagName) { return {ok: false}; } - el.focus(); - const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; - setter.call(el, value); - el.dispatchEvent(new Event('input', {bubbles: true})); - el.dispatchEvent(new Event('change', {bubbles: true})); - return {ok: true, value: el.value}; - """, - title, - value, - ) - except Exception: - typed = None - if isinstance(typed, dict) and typed.get("ok"): - _type_active(browser, value) - return True - return False - - -def _fill_header_code(browser: BrowserAdapter, title: str, code: str) -> None: - if not code: - return - _set_grid_field(browser, title, code) - _pick_header_lookup(browser, code) - - -def _activate_header_cell(browser: BrowserAdapter, title: str) -> bool: - if not title: - return False - try: - clicked = browser.execute_script( - """ - /* SET_HEADER_CELL */ - const name = String(arguments[0] || '').replace(/\\s+/g, ''); - function compact(el) { - return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim(); - } - const root = document.querySelector('.table-show') || document; - const th = Array.from(root.querySelectorAll('.vxe-header--column')).find((el) => compact(el) === name); - if (!th) { return {ok: false}; } - const colid = th.getAttribute('colid'); - const lefts = Array.from(root.querySelectorAll('.vxe-table--fixed-left-wrapper .vxe-body--row')); - let idx = 0; - for (let i = 0; i < lefts.length; i++) { - const icon = lefts[i].querySelector('.vxe-checkbox--icon'); - const cls = String((icon && icon.className) || ''); - if (cls.indexOf('checkbox-checked') >= 0 && cls.indexOf('unchecked') < 0) { - idx = i; - break; - } - } - const mains = Array.from(root.querySelectorAll( - '.vxe-table--main-wrapper .vxe-body--row, .vxe-table--body-wrapper .vxe-body--row' - )); - const row = mains[idx] || mains[0]; - const cell = row && row.querySelector('[colid="' + colid + '"]'); - if (!cell) { return {ok: false}; } - cell.scrollIntoView({block: 'center', inline: 'center'}); - cell.click(); - cell.dispatchEvent(new MouseEvent('dblclick', {bubbles: true, cancelable: true, view: window})); - return {ok: true, colid: colid, idx: idx}; - """, - title, - ) - except Exception: - clicked = None - return bool(isinstance(clicked, dict) and clicked.get("ok")) - - -def _header_card_open(browser: BrowserAdapter) -> bool: - try: - hit = browser.execute_script( - """ - /* HEADER_CARD */ - """ - + _header_dom_js() - + """ - const root = document.querySelector('.table-show') || document.body; - const t = compact(root); - if (t.indexOf('确认 Ctrl') >= 0 || t.indexOf('请填写供应商') >= 0 - || t.indexOf('状态 编辑') >= 0 || t.indexOf('状态编辑') >= 0) { - return true; - } - return !!formItem('供应商编码'); - """ - ) - except Exception: - hit = False - return hit is True - - -def _confirm_header_card(browser: BrowserAdapter) -> bool: - """总单卡片「确认 Ctrl + Enter」,不要点货品卡片确认。""" - clicked = browser.execute_script( - """ - /* CONFIRM_HEADER_CARD */ - function compact(el) { - return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); - } - function vis(el) { - return !!(el && (el.offsetWidth || el.offsetHeight)); - } - const card = Array.from(document.querySelectorAll('.table-card')).find((el) => { - const t = compact(el); - return vis(el) && t.indexOf('供应商编码') >= 0 && t.indexOf('货品编码') < 0; - }); - const root = card || document.querySelector('.table-show') || document; - const hit = Array.from(root.querySelectorAll('.card-header-btn, button, .elPlus-button')).find((el) => { - if (!vis(el)) return false; - const t = compact(el).replace(/\\s+/g, ''); - return t.indexOf('确认Ctrl') === 0 || t === '确认' || t.indexOf('确认') === 0; - }); - if (!hit) { return {ok: false, error: 'no-confirm'}; } - hit.scrollIntoView({block: 'center', inline: 'nearest'}); - hit.click(); - return {ok: true, via: compact(hit).slice(0, 40)}; - """ - ) - driver = getattr(browser, "_driver", None) - if driver is not None: - try: - from selenium.webdriver.common.action_chains import ActionChains - - for el in driver.find_elements("css selector", ".table-card .card-header-btn"): - info = driver.execute_script( - """ - const el = arguments[0]; - const card = el.closest('.table-card'); - const t = ((card && card.innerText) || ''); - return { - text: ((el.innerText || '') + '').replace(/\\s+/g, ' ').trim(), - header: t.indexOf('供应商编码') >= 0 && t.indexOf('货品编码') < 0 - }; - """, - el, - ) - if not (isinstance(info, dict) and info.get("header")): - continue - raw = str(info.get("text") or "") - if "确认" not in raw.replace(" ", ""): - continue - driver.execute_script( - "arguments[0].scrollIntoView({block:'center', inline:'nearest'});", - el, - ) - time.sleep(0.2) - ActionChains(driver).move_to_element(el).pause(0.15).click().perform() - clicked = {"ok": True, "via": "native:" + raw[:40]} - break - except Exception as exc: - print("native_header_confirm_err=", exc, flush=True) - print("confirm_header=", clicked, flush=True) - if isinstance(clicked, dict) and clicked.get("ok"): - time.sleep(0.5) - err = _read_detail_toast(browser) - if err: - raise NodeError(err) - return True - return False - - -def _wait_person_picker(browser: BrowserAdapter, timeout: float = 8) -> bool: - deadline = time.time() + timeout - while time.time() <= deadline: - if _person_picker_visible(browser): - return True - time.sleep(0.3) - return _person_picker_visible(browser) - - -def _native_click_el(browser: BrowserAdapter, el) -> bool: - driver = getattr(browser, "_driver", None) - if driver is None or el is None: - return False - try: - from selenium.webdriver.common.action_chains import ActionChains - - driver.execute_script( - "arguments[0].scrollIntoView({block:'center', inline:'center'});", - el, - ) - time.sleep(0.15) - ActionChains(driver).move_to_element(el).pause(0.15).click().perform() - return True - except Exception: - return False - - -def _open_labeled_picker(browser: BrowserAdapter, title: str, *, dots_only: bool = False) -> bool: - opened = browser.execute_script( - """ - /* OPEN_LABELED_PICKER */ - """ - + _header_dom_js() - + """ - const title = String(arguments[0] || ''); - const item = formItem(title); - if (!item) { return {ok: false, error: 'no-label'}; } - item.scrollIntoView({block: 'center', inline: 'nearest'}); - const via = clickDots(item); - if (via) { return {ok: true, via: via}; } - if (arguments[1] === true) { return {ok: false, error: 'no-dots'}; } - const wrap = selectWrap(item); - if (wrap) { wrap.click(); return {ok: true, via: 'select'}; } - const input = item.querySelector('input.elPlus-input__inner, input'); - if (input) { input.click(); input.focus(); return {ok: true, via: 'input'}; } - return {ok: false, error: 'no-trigger'}; - """, - title, - dots_only, - ) - print("open_picker=", opened, flush=True) - person = title in {"计划员", "联系人"} - if person: - if _wait_person_picker(browser, timeout=1.2): - return True - el = browser.execute_script( - """ - /* PERSON_PICKER_TRIGGER */ - """ - + _header_dom_js() - + """ - const item = formItem(arguments[0]); - if (!item) { return null; } - return item.querySelector( - '.elPlus-input-group--append button, .elPlus-input-group--append .elPlus-button' - ) || item.querySelector('.pointer.f20, span.pointer'); - """, - title, - ) - native = _native_click_el(browser, el) - print("open_picker_native=", native, flush=True) - if _wait_person_picker(browser, timeout=8): - return True - print( - "person_picker_missing overlays=", - _scan_lookup_overlays(browser), - "source=", - _source_choice_visible(browser), - flush=True, - ) - return False - if not getattr(browser, "_hold_source_choice", False): - _ignore_source_choice(browser) - return bool(isinstance(opened, dict) and opened.get("ok")) - - -def _read_labeled_input(browser: BrowserAdapter, title: str) -> str: - try: - result = browser.execute_script( - """ - /* READ_LABELED_INPUT */ - """ - + _header_dom_js() - + """ - const title = String(arguments[0] || ''); - const item = formItem(title); - return {value: shownValue(item)}; - """, - title, - ) - except Exception: - result = None - if isinstance(result, dict): - return str(result.get("value") or "").strip() - return "" - - -def _read_required_titles(browser: BrowserAdapter) -> list[str]: - try: - result = browser.execute_script( - """ - /* REQUIRED_TITLES */ - """ - + _header_dom_js() - + """ - const titles = []; - const items = Array.from(document.querySelectorAll('.elPlus-form-item, .el-form-item')).filter(vis); - for (const item of items) { - const star = Array.from(item.querySelectorAll('*')).some((el) => compact(el) === '*'); - const required = star || String(item.className || '').indexOf('is-required') >= 0 - || !!(item.querySelector('.form-item__title.required')); - if (!required) continue; - const label = item.querySelector('.form-item__title, .elPlus-form-item__label, label, .label-wrap'); - const t = compact(label || item).replace(/\\*/g, '').replace(/\\s+/g, ''); - if (t && titles.indexOf(t) < 0) titles.push(t); - } - return {titles: titles}; - """ - ) - except Exception: - result = None - if isinstance(result, dict): - return [str(t) for t in (result.get("titles") or []) if t] - return [] - - -def _picker_bound(browser: BrowserAdapter, title: str, code: str) -> bool: - shown = _read_labeled_input(browser, title) - if title == "供应商编码": - name = _read_labeled_input(browser, "供应商名称") - return bool(name) and "请" not in name[:2] and len(name) >= 4 - if title == "返利单位编码": - name = _read_labeled_input(browser, "返利单位") - return bool(name) and "请" not in name[:2] and len(name) >= 2 - if title == "计划员": - dept = _read_labeled_input(browser, "计划员部门") - if code and shown and code in shown and "请" not in shown[:2]: - return True - return bool(dept) and "请" not in dept[:2] and len(dept) >= 2 - if title == "联系人": - cid = _read_labeled_input(browser, "联系人ID") - return bool(cid) and "请" not in cid[:2] and len(cid) >= 2 - return bool(code) and code in shown and "请填写" not in shown and "请选择" not in shown - - -def _close_overlay_named(browser: BrowserAdapter, title: str) -> None: - try: - browser.execute_script( - """ - /* CLOSE_NAMED_OVERLAY */ - const title = String(arguments[0] || ''); - function compact(el) { - return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); - } - function vis(el) { - return !!(el && (el.offsetWidth || el.offsetHeight)); - } - const modal = Array.from(document.querySelectorAll( - '.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer, .elPlus-popper' - )).find((el) => vis(el) && compact(el).indexOf(title) >= 0); - if (!modal) { return {ok: false}; } - const hit = Array.from(modal.querySelectorAll('button, .vxe-modal--close-btn, .elPlus-dialog__headerbtn')).find((el) => { - const t = compact(el); - return vis(el) && (t === '取消' || t === '取消 Esc' || t.indexOf('关闭') >= 0); - }); - if (hit) { hit.click(); return {ok: true}; } - const close = modal.querySelector('.vxe-modal--close-btn, .vxe-icon-close, .elPlus-dialog__headerbtn'); - if (close) { close.click(); return {ok: true, via: 'x'}; } - return {ok: false}; - """, - title, - ) - except Exception: - pass - time.sleep(0.3) - - -def _scan_lookup_overlays(browser: BrowserAdapter) -> list[str]: - try: - result = browser.execute_script( - """ - /* SCAN_LOOKUP_OVERLAYS */ - function compact(el) { - return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); - } - function vis(el) { - return !!(el && (el.offsetWidth || el.offsetHeight)); - } - return Array.from(document.querySelectorAll('.vxe-modal--wrapper')).filter((el) => { - const cls = String(el.className || ''); - if (!vis(el) || cls.indexOf('is--visible') < 0) return false; - const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title')); - return title !== '货源选择'; - }).map((el) => { - const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title')); - return (title || compact(el).slice(0, 40)); - }).filter(Boolean).slice(0, 10); - """ - ) - except Exception: - result = None - if isinstance(result, list): - return [str(item) for item in result] - return [] - - -def _unit_picker_modal_js() -> str: - return """ - function compact(el) { - return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); - } - function vis(el) { - return !!(el && (el.offsetWidth || el.offsetHeight)); - } - function unitPicker() { - return Array.from(document.querySelectorAll('.vxe-modal--wrapper')).find((el) => { - const cls = String(el.className || ''); - if (!vis(el) || cls.indexOf('is--visible') < 0) return false; - const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title')); - return title === '返利单位选择' || title === '供应商选择'; - }) || null; - } - function sourcePicker() { - return Array.from(document.querySelectorAll('.vxe-modal--wrapper')).find((el) => { - const cls = String(el.className || ''); - if (!vis(el) || cls.indexOf('is--visible') < 0) return false; - const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title')); - return title === '货源选择'; - }) || null; - } - function personPicker() { - const all = Array.from(document.querySelectorAll('.vxe-modal--wrapper')).filter((el) => { - const cls = String(el.className || ''); - return vis(el) && cls.indexOf('is--visible') >= 0; - }); - return all.find((el) => { - const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title')); - if (title === '返利单位选择' || title === '供应商选择') return false; - if (title === '货源选择') return true; - if (title.indexOf('计划员') >= 0 || title.indexOf('联系人') >= 0 || title.indexOf('人员') >= 0) { - return true; - } - return Array.from(el.querySelectorAll('input')).some((inp) => { - const p = String(inp.placeholder || ''); - return p.indexOf('计划员') >= 0 || p.indexOf('联系人') >= 0; - }); - }) || null; - } - """ - - -def _send_keys_active(browser: BrowserAdapter, value: str, *, enter: bool = False) -> bool: - driver = getattr(browser, "_driver", None) - if driver is None: - return False - try: - from selenium.webdriver.common.keys import Keys - - el = driver.switch_to.active_element - el.send_keys(Keys.CONTROL, "a") - el.send_keys(Keys.BACKSPACE) - if value: - el.send_keys(value) - if enter: - el.send_keys(Keys.ENTER) - return True - except Exception: - return False - - -def _click_unit_clear(browser: BrowserAdapter) -> dict: - try: - result = browser.execute_script( - """ - /* CLICK_UNIT_CLEAR */ - """ - + _unit_picker_modal_js() - + """ - const modal = unitPicker(); - if (!modal) { return {ok: false, error: 'no-modal'}; } - const clear = Array.from(modal.querySelectorAll('.search-btn button, button')).find((el) => { - return vis(el) && compact(el) === '清除'; - }); - if (!clear) { return {ok: false, error: 'no-clear'}; } - clear.click(); - return {ok: true, via: 'clear'}; - """ - ) - except Exception: - result = None - if isinstance(result, dict): - return result - return {"ok": False} - - -def _click_unit_query(browser: BrowserAdapter) -> dict: - try: - result = browser.execute_script( - """ - /* CLICK_UNIT_QUERY */ - """ - + _unit_picker_modal_js() - + """ - const modal = unitPicker(); - if (!modal) { return {ok: false, error: 'no-modal'}; } - const query = modal.querySelector('.search-btn button.elPlus-button--primary') - || Array.from(modal.querySelectorAll('.search-btn button, button')).find((el) => { - return vis(el) && compact(el) === '查询'; - }); - if (!query) { return {ok: false, error: 'no-query'}; } - query.click(); - return {ok: true, via: 'search-btn', text: compact(query)}; - """ - ) - except Exception: - result = None - if isinstance(result, dict): - return result - return {"ok": False} - - -def _wait_unit_picker_rows(browser: BrowserAdapter, keyword: str, timeout: float = 8) -> bool: - wanted = (keyword or "").replace(" ", "") - deadline = time.time() + timeout - last = None - empty_hits = 0 - while time.time() <= deadline: - try: - last = browser.execute_script( - """ - /* UNIT_PICKER_ROWS */ - """ - + _unit_picker_modal_js() - + """ - const keyword = String(arguments[0] || '').replace(/\\s+/g, ''); - const modal = unitPicker(); - if (!modal) { return {ok: false, error: 'no-modal'}; } - const rows = Array.from(modal.querySelectorAll('.vxe-body--row')).filter(vis); - const match = rows.find((el) => compact(el).replace(/ /g, '').indexOf(keyword) >= 0); - return { - ok: true, - match: !!match, - count: rows.length, - pager: compact(modal.querySelector('.vxe-pager, .modal-body-footer') || {innerText: ''}).slice(0, 80), - row: match ? compact(match).slice(0, 80) : '', - }; - """, - wanted, - ) - except Exception: - last = None - print("unit_rows=", last, flush=True) - if isinstance(last, dict) and last.get("match"): - return True - pager = str((last or {}).get("pager") or "").replace(" ", "") - if isinstance(last, dict) and "共0条" in pager: - empty_hits += 1 - if empty_hits >= 3: - return False - else: - empty_hits = 0 - time.sleep(0.4) - return False - - -def _query_overlay_and_pick(browser: BrowserAdapter, field: str, value: str) -> bool: - """返利单位选择弹窗:填编码、回车或点查询后再点确定。""" - keyword = (value or "").strip() - if not keyword: - return False - _ignore_source_choice(browser) - filled = browser.execute_script( - """ - /* QUERY_OVERLAY_FIELD */ - """ - + _unit_picker_modal_js() - + """ - const field = String(arguments[0] || '').replace(/\\s+/g, ''); - const value = String(arguments[1] || ''); - const modal = unitPicker(); - if (!modal) { return {ok: false, error: 'no-modal'}; } - const items = Array.from(modal.querySelectorAll('.elPlus-form-item, .el-form-item, .search-condition-item')); - const item = items.find((el) => { - const t = compact(el.querySelector('.form-item__title, .search-label__text, label, .label-wrap') || el).replace(/ /g, ''); - return t === field || t.indexOf(field) >= 0; - }); - let input = item && item.querySelector('input.elPlus-input__inner, input'); - if (!input) { - const labels = Array.from(modal.querySelectorAll('.label-wrap, .form-item__title, .search-label__text, label')); - const label = labels.find((el) => compact(el).replace(/\\s+/g, '') === field); - let node = label; - for (let i = 0; i < 5 && node && !input; i++) { - input = node.querySelector('input.elPlus-input__inner, input'); - node = node.parentElement; - } - } - if (!input) { - input = Array.from(modal.querySelectorAll('input')).find((el) => vis(el) && String(el.placeholder || '').indexOf(field) >= 0); - } - if (!input) { return {ok: false, error: 'no-input', field: field}; } - input.click(); - input.focus(); - const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; - setter.call(input, value); - input.dispatchEvent(new Event('input', {bubbles: true})); - input.dispatchEvent(new Event('change', {bubbles: true})); - return {ok: true, value: String(input.value || ''), via: field, title: compact(modal.querySelector('.vxe-modal--header-title'))}; - """, - field, - keyword, - ) - typed = _send_keys_active(browser, keyword, enter=True) - clicked = _click_unit_query(browser) - print("query_unit_picker=", filled, "typed=", typed, "query_click=", clicked, flush=True) - if not (isinstance(filled, dict) and filled.get("ok")): - return False - _wait_unit_picker_rows(browser, keyword) - return _click_keyword_in_overlays(browser, keyword) - - -def _click_keyword_in_overlays(browser: BrowserAdapter, keyword: str) -> bool: - try: - result = browser.execute_script( - """ - /* CLICK_KEYWORD_OVERLAY */ - """ - + _unit_picker_modal_js() - + """ - const keyword = String(arguments[0] || '').replace(/\\s+/g, ''); - const modal = unitPicker(); - const roots = modal ? [modal] : Array.from(document.querySelectorAll( - '.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer, .elPlus-popper, .elPlus-select-dropdown, .vxe-modal--box' - )).filter(vis); - for (const root of roots) { - const title = compact(root.querySelector('.vxe-modal--header-title, .vxe-modal--title')); - const t = compact(root); - if (title === '货源选择' || t.indexOf('货源选择') === 0) continue; - if (t.indexOf('请填写供应商') >= 0 && t.indexOf('采购协议号') >= 0) continue; - const row = Array.from(root.querySelectorAll( - '.vxe-body--row, tr, .elPlus-select-dropdown__item, li' - )).find((el) => vis(el) && compact(el).indexOf(keyword) >= 0); - if (!row) continue; - const radio = row.querySelector('.vxe-cell--radio, .vxe-radio--icon, input[type="radio"]'); - if (radio) { radio.click(); } else { row.click(); } - const ok = Array.from(root.querySelectorAll('button')).find((el) => { - const x = compact(el); - return vis(el) && (x === '确定' || x === '确 定'); - }); - if (ok) { ok.click(); } - return {ok: true, text: compact(row).slice(0, 80)}; - } - return {ok: false}; - """, - keyword, - ) - except Exception: - result = None - return bool(isinstance(result, dict) and result.get("ok")) - - -def _pick_labeled_code(browser: BrowserAdapter, title: str, code: str) -> None: - if not code: - return - if _picker_bound(browser, title, code): - return - keywords = [code] - _open_labeled_picker(browser, title) - time.sleep(0.3) - _type_active(browser, code) - driver = getattr(browser, "_driver", None) - if driver is not None: - try: - from selenium.webdriver.common.keys import Keys - - driver.switch_to.active_element.send_keys(Keys.ENTER) - except Exception: - pass - time.sleep(0.8) - overlays = _scan_lookup_overlays(browser) - print("lookup_overlays=", overlays, "title=", title, "code=", code, flush=True) - picked = _filter_and_pick_dropdown(browser, code, exact=False) - if not picked: - picked = _pick_header_lookup(browser, code) - if not picked: - picked = _click_keyword_in_overlays(browser, code) - if not _picker_bound(browser, title, code): - raise NodeError(f"未选到{title}={code},当前={_read_labeled_input(browser, title)}") - - -def _source_choice_visible(browser: BrowserAdapter) -> bool: - try: - hit = browser.execute_script( - """ - /* SOURCE_CHOICE */ - function compact(el) { - return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); - } - function vis(el) { - return !!(el && (el.offsetWidth || el.offsetHeight)); - } - const modal = Array.from(document.querySelectorAll( - '.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer, .elPlus-overlay' - )).find((el) => { - if (!vis(el)) return false; - const title = compact(el.querySelector( - '.vxe-modal--header-title, .vxe-modal--header, .vxe-modal--title, .elPlus-dialog__title, .el-dialog__title' - )); - const t = compact(el).slice(0, 40); - if (title.indexOf('货源选择') < 0 && t.indexOf('货源选择') !== 0) return false; - const planner = Array.from(el.querySelectorAll('input')).some((inp) => { - const p = String(inp.placeholder || ''); - return p.indexOf('计划员') >= 0 || p.indexOf('联系人') >= 0; - }); - return !planner; - }); - return !!modal; - """ - ) - except Exception: - hit = False - return hit is True - - -def _close_source_choice(browser: BrowserAdapter) -> bool: - """看得见的货源选择是 bug 弹层:点关闭,不要确定、不要 Escape。""" - try: - result = browser.execute_script( - """ - /* CLOSE_SOURCE_CHOICE */ - function compact(el) { - return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); - } - function vis(el) { - return !!(el && (el.offsetWidth || el.offsetHeight)); - } - const hits = Array.from(document.querySelectorAll( - '.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer, .elPlus-overlay, .vxe-modal--box' - )).filter((el) => { - if (!vis(el)) return false; - const title = compact(el.querySelector( - '.vxe-modal--header-title, .vxe-modal--header, .vxe-modal--title, .elPlus-dialog__title, .el-dialog__title' - )); - const t = compact(el).slice(0, 40); - return title.indexOf('货源选择') >= 0 || t.indexOf('货源选择') === 0; - }); - if (!hits.length) { return {ok: false, via: 'none-visible'}; } - let via = ''; - 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(); via = via || 'close'; continue; } - const x = modal.querySelector( - '.vxe-modal--close-btn, .vxe-icon-close, .elPlus-dialog__headerbtn, .el-dialog__headerbtn' - ); - if (x) { x.click(); via = via || 'x'; } - } - return {ok: true, via: via, count: hits.length}; - """ - ) - except Exception: - result = None - if isinstance(result, dict) and result.get("ok"): - time.sleep(0.25) - return True - return False - - -def _ignore_source_choice(browser: BrowserAdapter) -> None: - """全局忽略货源选择 bug 弹层。""" - for _ in range(3): - if not _source_choice_visible(browser): - return - _close_source_choice(browser) - - -def _stop_if_source_choice(browser: BrowserAdapter) -> None: - _ignore_source_choice(browser) - - -def _pick_nth_overlay_row(browser: BrowserAdapter, nth: int, name: str = "") -> bool: - try: - result = browser.execute_script( - """ - /* PICK_NTH_OVERLAY */ - const nth = Number(arguments[0] || 0); - const name = String(arguments[1] || '').replace(/\\s+/g, ''); - function compact(el) { - return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ''); - } - function vis(el) { - return !!(el && (el.offsetWidth || el.offsetHeight)); - } - const overlays = Array.from(document.querySelectorAll( - '.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer, .elPlus-select-dropdown, .elPlus-popper' - )).filter((el) => { - if (!vis(el)) return false; - const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title') || {innerText:''}); - return title !== '货源选择'; - }); - const roots = overlays.length ? overlays : [document]; - for (const root of roots) { - const rows = Array.from(root.querySelectorAll( - '.vxe-body--row, .elPlus-select-dropdown__item, .el-select-dropdown__item, li' - )).filter(vis); - let row = name ? rows.find((el) => compact(el).indexOf(name) >= 0) : null; - if (!row && nth >= 0 && nth < rows.length) { row = rows[nth]; } - if (!row) continue; - const radio = row.querySelector('.vxe-cell--radio, .vxe-radio--icon, input[type="radio"]'); - if (radio) { radio.click(); } else { row.click(); } - const ok = Array.from(root.querySelectorAll('button')).find((el) => { - const t = compact(el); - return vis(el) && (t === '确定' || t === '确 定'); - }); - if (ok) { ok.click(); } - return {ok: true, text: compact(row).slice(0, 80)}; - } - return {ok: false}; - """, - nth, - name, - ) - except Exception: - result = None - return bool(isinstance(result, dict) and result.get("ok")) - - -def _focus_labeled_input(browser: BrowserAdapter, title: str) -> bool: - focused = browser.execute_script( - """ - /* FOCUS_LABELED_INPUT */ - const title = String(arguments[0] || ''); - function compact(el) { - return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); - } - function vis(el) { - return !!(el && (el.offsetWidth || el.offsetHeight)); - } - const wanted = title.replace(/\\s+/g, ''); - const labels = Array.from(document.querySelectorAll( - '.form-item__title, .elPlus-form-item__label, label' - )).filter((el) => vis(el) && !el.closest('.search-condition-item')); - const label = labels.find((el) => compact(el).replace(/\\*/g, '') === wanted); - if (!label) { return {ok: false, error: 'no-label'}; } - const item = label.closest('.elPlus-form-item, .el-form-item') || label.parentElement; - const input = item && item.querySelector('input.elPlus-input__inner, input:not([type="checkbox"])'); - if (!input) { return {ok: false, error: 'no-input'}; } - input.click(); - input.focus(); - return {ok: true, ph: input.placeholder || '', val: String(input.value || '')}; - """, - title, - ) - print("focus_input=", focused, flush=True) - _ignore_source_choice(browser) - return bool(isinstance(focused, dict) and focused.get("ok")) - - -def _hold_source_choice(browser: BrowserAdapter, hold: bool) -> None: - if hasattr(browser, "_hold_source_choice"): - browser._hold_source_choice = hold - - -def _person_picker_visible(browser: BrowserAdapter) -> bool: - try: - hit = browser.execute_script( - """ - /* PERSON_PICKER_VISIBLE */ - """ - + _unit_picker_modal_js() - + """ - return !!personPicker(); - """ - ) - except Exception: - hit = False - return hit is True - - -def _close_person_picker(browser: BrowserAdapter) -> None: - try: - browser.execute_script( - """ - /* CLOSE_PERSON_PICKER */ - """ - + _unit_picker_modal_js() - + """ - const modal = personPicker(); - if (!modal) { return {ok: false}; } - const btn = Array.from(modal.querySelectorAll('button')).find((el) => { - const t = compact(el); - return vis(el) && (t === '关闭' || t === '取消' || t.indexOf('关闭') >= 0); - }); - if (btn) { btn.click(); return {ok: true, via: 'close'}; } - const x = modal.querySelector('.vxe-modal--close-btn, .vxe-icon-close, .elPlus-dialog__headerbtn'); - if (x) { x.click(); return {ok: true, via: 'x'}; } - return {ok: false}; - """ - ) - except Exception: - pass - time.sleep(0.2) - - -def _query_person_and_pick( - browser: BrowserAdapter, field: str, value: str, *, supplier_name: str = "" -) -> bool: - """计划员三个点打开「货源选择」:按姓名查,行上还要对上供应商名称。""" - keyword = (value or "").strip() - if not keyword: - return False - focused = browser.execute_script( - """ - /* FOCUS_PERSON_FIELD */ - """ - + _unit_picker_modal_js() - + """ - const field = String(arguments[0] || ''); - const modal = personPicker(); - if (!modal) { return {ok: false, error: 'no-modal'}; } - const input = Array.from(modal.querySelectorAll('input')).find((el) => { - const ph = String(el.placeholder || ''); - return vis(el) && ph === ('请输入' + field); - }) || Array.from(modal.querySelectorAll('input')).find((el) => { - const ph = String(el.placeholder || ''); - return vis(el) && ph.indexOf(field) >= 0 && ph.indexOf('ID') < 0; - }); - if (!input) { return {ok: false, error: 'no-input'}; } - input.click(); - input.focus(); - const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; - setter.call(input, String(arguments[1] || '')); - input.dispatchEvent(new Event('input', {bubbles: true})); - input.dispatchEvent(new Event('change', {bubbles: true})); - return {ok: true, ph: input.placeholder || '', title: compact(modal.querySelector('.vxe-modal--header-title'))}; - """, - field, - keyword, - ) - typed = _send_keys_active(browser, keyword, enter=True) - clicked = browser.execute_script( - """ - /* CLICK_PERSON_QUERY */ - """ - + _unit_picker_modal_js() - + """ - const modal = personPicker(); - if (!modal) { return {ok: false, error: 'no-modal'}; } - const query = Array.from(modal.querySelectorAll('button')).find((el) => vis(el) && compact(el) === '查询'); - if (!query) { return {ok: false, error: 'no-query'}; } - query.click(); - return {ok: true, via: 'query'}; - """ - ) - print("query_person=", focused, "typed=", typed, "query_click=", clicked, flush=True) - if not (isinstance(focused, dict) and focused.get("ok")): - return False - supplier = (supplier_name or "").replace(" ", "") - deadline = time.time() + 12 - while time.time() <= deadline: - last = browser.execute_script( - """ - /* PERSON_PICKER_ROWS */ - """ - + _unit_picker_modal_js() - + """ - const keyword = String(arguments[0] || '').replace(/\\s+/g, ''); - const supplier = String(arguments[1] || '').replace(/\\s+/g, ''); - const modal = personPicker(); - if (!modal) { return {ok: false, error: 'no-modal'}; } - const rows = Array.from(modal.querySelectorAll('.vxe-body--row')).filter(vis); - const match = rows.find((el) => { - const t = compact(el).replace(/ /g, ''); - if (t.indexOf(keyword) < 0) return false; - if (supplier && t.indexOf(supplier) < 0) return false; - return true; - }); - return { - ok: true, - match: !!match, - count: rows.length, - pager: compact(modal.querySelector('.vxe-pager, .modal-body-footer') || {innerText: ''}).slice(0, 80), - row: match ? compact(match).slice(0, 80) : '', - }; - """, - keyword, - supplier, - ) - print("person_rows=", last, flush=True) - if isinstance(last, dict) and last.get("match"): - break - time.sleep(0.4) - picked = browser.execute_script( - """ - /* PICK_PERSON_ROW */ - """ - + _unit_picker_modal_js() - + """ - const keyword = String(arguments[0] || '').replace(/\\s+/g, ''); - const supplier = String(arguments[1] || '').replace(/\\s+/g, ''); - const modal = personPicker(); - if (!modal) { return {ok: false, error: 'no-modal'}; } - const row = Array.from(modal.querySelectorAll('.vxe-body--row')).find((el) => { - const t = compact(el).replace(/ /g, ''); - if (!vis(el) || t.indexOf(keyword) < 0) return false; - if (supplier && t.indexOf(supplier) < 0) return false; - return true; - }); - if (!row) { return {ok: false, error: 'no-row'}; } - const radio = row.querySelector('.vxe-cell--radio, .vxe-radio--icon, input[type="radio"]'); - if (radio) { radio.click(); } else { row.click(); } - const ok = Array.from(modal.querySelectorAll('button')).find((el) => { - const t = compact(el); - return vis(el) && (t === '确定' || t === '确 定'); - }); - if (ok) { ok.click(); } - return {ok: true, text: compact(row).slice(0, 120)}; - """, - keyword, - supplier, - ) - print("pick_person=", picked, flush=True) - return bool(isinstance(picked, dict) and picked.get("ok")) - - -def _pick_person( - browser: BrowserAdapter, title: str, name: str, *, nth: int | None = None, supplier_name: str = "" -) -> None: - wanted = (name or "").strip() - if not wanted: - return - if _picker_bound(browser, title, wanted): - return - _hold_source_choice(browser, True) - try: - _close_header_query(browser) - deadline = time.time() + 5 - while time.time() <= deadline and _supplier_picker_open(browser): - time.sleep(0.3) - clicked = _open_labeled_picker(browser, title, dots_only=True) - print("open_person_dots=", clicked, flush=True) - deadline = time.time() + 8 - while time.time() <= deadline: - if _person_picker_visible(browser): - break - time.sleep(0.3) - if not _person_picker_visible(browser): - raise NodeError( - f"点三个点未打开选择层 title={title} overlays={_scan_lookup_overlays(browser)}" - ) - picked = _query_person_and_pick(browser, title, wanted, supplier_name=supplier_name) - if not picked and nth is not None: - picked = _pick_nth_overlay_row(browser, nth, wanted) - if not picked: - picked = _click_keyword_in_overlays(browser, wanted) - if picked: - time.sleep(0.3) - if _person_picker_visible(browser): - _close_person_picker(browser) - pid = _read_labeled_input(browser, f"{title}ID") - print(f"bound_{title}=", _read_labeled_input(browser, title), "id=", pid, flush=True) - if not _picker_bound(browser, title, wanted): - raise NodeError( - f"未选到{title}={wanted},当前={_read_labeled_input(browser, title)} id={pid}" - ) - finally: - _hold_source_choice(browser, False) - - -def _supplier_picker_open(browser: BrowserAdapter) -> bool: - overlays = _scan_lookup_overlays(browser) - return any(item in {"返利单位选择", "供应商选择"} or "返利单位选择" in item for item in overlays) - - -def _pick_supplier_by_mdm(browser: BrowserAdapter, code: str, name: str = "") -> None: - value = (code or "").strip() - wanted_name = (name or "").strip() - if not value and not wanted_name: - raise NodeError("表 3 没有 supplier_mdm_code,不能选供应商") - if _picker_bound(browser, "供应商编码", value): - return - _stop_if_source_choice(browser) - opened = _supplier_picker_open(browser) - if opened: - print("lookup_overlays=", _scan_lookup_overlays(browser), "already_open=1", flush=True) - else: - _close_header_query(browser) - clicked = _open_labeled_picker(browser, "供应商编码", dots_only=True) - print("open_picker=", clicked, flush=True) - deadline = time.time() + 8 - while time.time() <= deadline: - _stop_if_source_choice(browser) - overlays = _scan_lookup_overlays(browser) - print("lookup_overlays=", overlays, flush=True) - if _supplier_picker_open(browser): - opened = True - break - time.sleep(0.4) - if not opened: - raise NodeError(f"未打开返利单位选择弹窗 overlays={_scan_lookup_overlays(browser)}") - ok = False - if wanted_name: - ok = _query_overlay_and_pick(browser, "供应商名称", wanted_name) - if not ok and value: - print("unit_query_name_empty, try_code=", value, flush=True) - _click_unit_clear(browser) - time.sleep(0.3) - ok = _query_overlay_and_pick(browser, "供应商编码", value) - if not ok and value: - ok = _click_keyword_in_overlays(browser, value) - if not ok and wanted_name: - ok = _click_keyword_in_overlays(browser, wanted_name) - if not _picker_bound(browser, "供应商编码", value): - raise NodeError( - f"未选到供应商编码={value} 名称={wanted_name},当前={_read_labeled_input(browser, '供应商编码')}" - ) - - -def _fill_pay_param(browser: BrowserAdapter, title: str, value: str) -> None: - wanted = (value or "").strip() - if not wanted: - return - if not re.fullmatch(r"\d+(\.\d+)?", wanted): - print("skip_pay_param_not_number=", wanted, flush=True) - return - print("fill_pay_param=", wanted, flush=True) - marked = browser.execute_script( - """ - /* FOCUS_PAY_PARAM */ - """ - + _header_dom_js() - + """ - const item = formItem(arguments[0]); - const input = item && item.querySelector('input.vxe-input--inner, input'); - if (!input) { return false; } - input.setAttribute('data-rpa-pay', '1'); - input.click(); - input.focus(); - return true; - """, - title, - ) - driver = getattr(browser, "_driver", None) - if marked and driver is not None: - try: - from selenium.webdriver.common.action_chains import ActionChains - from selenium.webdriver.common.keys import Keys - - el = driver.find_element("css selector", '[data-rpa-pay="1"]') - driver.execute_script("arguments[0].removeAttribute('data-rpa-pay')", el) - ActionChains(driver).click(el).pause(0.1).key_down(Keys.CONTROL).send_keys("a").key_up( - Keys.CONTROL - ).send_keys(Keys.BACKSPACE).send_keys(wanted).send_keys(Keys.TAB).perform() - time.sleep(0.2) - except Exception as exc: - print("pay_param_type_err=", exc, flush=True) - now = _read_labeled_input(browser, title) - print("pay_param_now=", now, flush=True) - if (now or "").strip() == wanted: - return - if not _pick_detail_option(browser, title, wanted): - try: - _fill_input(browser, title, [f"label={title}"], wanted) - except Exception: - _set_grid_field(browser, title, wanted) - print("pay_param_after=", _read_labeled_input(browser, title), flush=True) - - -def _fill_header_card(browser: BrowserAdapter, draft: RebateHeaderDraft) -> None: - _ignore_source_choice(browser) - titles = _read_required_titles(browser) - print("required=", titles, flush=True) - settle = draft.settle_mode if draft.settle_mode and draft.settle_mode != SETTLE_MODE_DEFAULT else SETTLE_MODE_WIRE - planner = (draft.planner_name or "").strip() or PLANNER_DEFAULT - contact = (draft.contact_name or "").strip() or CONTACT_DEFAULT - values = { - "供应商编码": draft.supplier_code, - "计划员": planner, - "返利合约": draft.rebate_contract, - "采购结算方式": settle, - "承付模式": draft.pay_mode, - "承付参数": PAY_PARAM_DEFAULT, - "协议开始日期": draft.protocol_start, - "协议结束日期": draft.protocol_end, - "联系人": contact, - } - selects = {"返利合约", "采购结算方式", "承付模式"} - order = [ - "供应商编码", - "计划员", - "联系人", - "返利合约", - "采购结算方式", - "承付模式", - "协议开始日期", - "协议结束日期", - "承付参数", - ] - skip = {"供应商名称", "返利单位编码", "返利单位"} - raw = titles or list(values.keys()) - needed = list(order) + [title for title in raw if title not in order and title not in skip] - for title in needed: - if title in skip: - continue - value = values.get(title, "") - if not value: - continue - if title == "供应商编码": - _pick_supplier_by_mdm(browser, value, name=draft.supplier_name) - elif title == "计划员": - _pick_person(browser, "计划员", value, supplier_name=draft.supplier_name) - elif title == "联系人": - _pick_person(browser, "联系人", value, nth=1) - elif title == "承付参数": - _fill_pay_param(browser, title, value) - elif title in selects: - if not _pick_detail_option(browser, title, value): - if title == "采购结算方式": - _pick_detail_option(browser, title, SETTLE_MODE_WIRE) - else: - try: - _fill_input(browser, title, [f"label={title}"], value) - except Exception: - _set_grid_field(browser, title, value) - print( - "card_values=", - { - "supplier": _read_labeled_input(browser, "供应商编码"), - "supplierName": _read_labeled_input(browser, "供应商名称"), - "unit": _read_labeled_input(browser, "返利单位编码"), - "planner": _read_labeled_input(browser, "计划员"), - "contact": _read_labeled_input(browser, "联系人"), - "payParam": _read_labeled_input(browser, "承付参数"), - }, - flush=True, - ) - if draft.protocol_start: - try: - _fill_input(browser, "协议开始日期", HEADER["protocol_start"], draft.protocol_start) - except Exception: - _set_grid_field(browser, "协议开始日期", draft.protocol_start) - if draft.protocol_end: - try: - _fill_input(browser, "协议结束日期", HEADER["protocol_end"], draft.protocol_end) - except Exception: - _set_grid_field(browser, "协议结束日期", draft.protocol_end) - _fill_pay_param(browser, "承付参数", PAY_PARAM_DEFAULT) - print( - "card_values_before_save=", - { - "planner": _read_labeled_input(browser, "计划员"), - "payParam": _read_labeled_input(browser, "承付参数"), - "supplier": _read_labeled_input(browser, "供应商名称"), - }, - flush=True, - ) - if (_read_labeled_input(browser, "承付参数") or "").strip() != PAY_PARAM_DEFAULT: - raise NodeError("总单承付参数未填成25,不能保存") - - -def _wait_header_card(browser: BrowserAdapter, timeout: float = 8, *, contract_no: str = "") -> bool: - if _header_card_open(browser): - return True - if getattr(browser, "_driver", None) is None: - return False - deadline = time.time() + timeout - while time.time() <= deadline: - if contract_no: - select_header_row(browser, contract_no) - _click_header_toolbar(browser, "编辑") or _click_toolbar(browser, "编辑") - time.sleep(0.4) - if _header_card_open(browser): - return True - return False - - -def fill_rebate_header(browser: BrowserAdapter, draft: RebateHeaderDraft, *, contract_no: str = "") -> None: - _switch_agreement_frame(browser) - _ignore_source_choice(browser) - if _wait_header_card(browser, contract_no=contract_no): - print("header_card=open", flush=True) - _fill_header_card(browser, draft) - if not _picker_bound(browser, "供应商编码", draft.supplier_code): - raise NodeError("总单供应商仍为空") - return - grid = _activate_header_cell(browser, "供应商编码") or _activate_header_cell(browser, "承付模式") - if grid: - _set_grid_field(browser, "承付模式", draft.pay_mode, as_select=True) - _set_grid_field(browser, "返利合约", draft.rebate_contract, as_select=True) - if not _set_grid_field(browser, "采购结算方式", draft.settle_mode, as_select=True): - _set_grid_field(browser, "采购结算方式", SETTLE_MODE_WIRE, as_select=True) - _set_grid_field(browser, "承付参数", PAY_PARAM_DEFAULT) - _fill_header_code(browser, "供应商编码", draft.supplier_code) - _fill_header_code(browser, "计划员", draft.planner_name or PLANNER_DEFAULT) - _fill_header_code(browser, "联系人", draft.contact_name or CONTACT_DEFAULT) - _set_grid_field(browser, "协议开始日期", draft.protocol_start) - _set_grid_field(browser, "协议结束日期", draft.protocol_end) - return - _fill_select(browser, "返利合约", HEADER["rebate_contract"], draft.rebate_contract) - if draft.supplier_code: - _fill_input(browser, "供应商编码", HEADER["supplier_code"], draft.supplier_code) - _pick_header_lookup(browser, draft.supplier_code) - _fill_input(browser, "计划员", HEADER["planner"], draft.planner_name or PLANNER_DEFAULT) - try: - _fill_select(browser, "采购结算方式", HEADER["settle_mode"], draft.settle_mode) - except Exception: - _fill_select(browser, "采购结算方式", HEADER["settle_mode"], SETTLE_MODE_WIRE) - _fill_select(browser, "承付模式", HEADER["pay_mode"], draft.pay_mode) - _fill_input(browser, "协议开始日期", HEADER["protocol_start"], draft.protocol_start) - _fill_input(browser, "协议结束日期", HEADER["protocol_end"], draft.protocol_end) - - -def _scan_rebate_order_no(browser: BrowserAdapter, *, contract_no: str = "") -> str: - try: - text = browser.execute_script( - """ - /* READ_REBATE_ORDER_NO */ - const no = String(arguments[0] || ''); - const heads = Array.from(document.querySelectorAll('.vxe-header--column')).map((th) => ({ - title: (th.innerText || '').replace(/\\s+/g, '').trim(), - colid: th.getAttribute('colid'), - })); - const rows = Array.from(document.querySelectorAll('.vxe-body--row')); - const row = rows.find((r) => !no || (r.innerText || '').indexOf(no) >= 0) || rows[0]; - function cell(name) { - const h = heads.find((x) => x.title === name); - if (!h || !row) { return ''; } - const c = row.querySelector('[colid="' + h.colid + '"]'); - return c ? (c.innerText || '').replace(/\\s+/g, ' ').trim() : ''; - } - return cell('协议总单ID') || cell('采购协议总单ID') || cell('采购协议号') || ''; - """, - contract_no, - ) - except Exception: - text = "" - return _clean_order_no(text, contract_no=contract_no) - - -def _clean_order_no(raw: object, *, contract_no: str = "") -> str: - text = "" if raw is None else str(raw).strip() - if not text: - return "" - if re.match(r"^\d{4}-\d{2}-\d{2}", text): - return "" - if text in {"总单号", "采购协议号", "协议编号", "采购协议总单ID", "协议总单号", "协议总单ID"}: - return "" - return text - - -def save_rebate_header(browser: BrowserAdapter, *, contract_no: str = "") -> str: - if _header_card_open(browser) and not _picker_bound(browser, "供应商编码", ""): - raise NodeError("总单供应商为空,不能保存") - if not _native_click_header_toolbar(browser, "保存"): - if not _click_header_toolbar(browser, "保存"): - if not _click_toolbar(browser, "保存"): - browser.click_first(SAVE["button"]) - time.sleep(0.5) - err = _read_detail_toast(browser) - if err: - raise NodeError(err) - deadline = time.time() + 15 - last = "" - while time.time() <= deadline: - last = _scan_rebate_order_no(browser, contract_no=contract_no) - body = browser.read_body_text() or "" - if last or "保存成功" in body: - return last - time.sleep(0.4) - return last - - -def read_rebate_order_no(browser: BrowserAdapter, *, contract_no: str = "") -> str: - deadline = time.time() + 15 - last = "" - while time.time() <= deadline: - last = _scan_rebate_order_no(browser, contract_no=contract_no) - if last: - return last - time.sleep(0.4) - return last - - -def run_header_flow( - browser: BrowserAdapter, - ctx: RebateFlowContext, - contract_no: str, - draft: RebateHeaderDraft | None = None, -) -> str: - _HEADER_ACCEPT.reset() - header = draft or header_draft_for_contract(contract_no) - try: - open_rebate_center(browser, ctx) - _HEADER_ACCEPT.note("S02", "打开返利中心", {"url": ctx.rebate_url}, ok=True) - try: - login_rebate_center(browser, ctx) - _HEADER_ACCEPT.note("S03", "登录", {"user": ctx.username}, ok=True) - except Exception as exc: - evidence_shot(browser, "rebate-header-login-failed", failed=True) - _HEADER_ACCEPT.note( - "S03", - "登录", - {"user": ctx.username, "error": exc}, - ok=False, - raise_error=False, - ) - raise - enter_purchase_agreement(browser) - _HEADER_ACCEPT.note("S04", "进入采购协议", {"menu": "采购协议管理"}, ok=True) - found = search_contract(browser, contract_no) - if not found: - href = "" - try: - href = str(browser.execute_script("return location.href") or "") - except Exception: - href = "" - if "ssoLogin" in href or "login" in href.lower() or "wel/index" in href: - login_rebate_center(browser, ctx) - enter_purchase_agreement(browser) - found = search_contract(browser, contract_no) - if not found: - print("header_selection=", read_header_selection(browser, contract_no), flush=True) - _HEADER_ACCEPT.note( - "S05", - "查号", - {"contractNo": contract_no, "found": "yes" if found else "no"}, - ok=found, - ) - if not found: - raise NodeError("查号未搜到合同,不能填总单") - _close_header_query(browser) - picked = select_header_row_result(browser, contract_no) - if not (isinstance(picked, dict) and picked.get("ok")): - raise NodeError("未按合同编号勾中总单行") - open_header_form(browser, contract_no) - _HEADER_ACCEPT.note("S06", "打开总单", {"contractNo": contract_no}, ok=True) - fill_rebate_header(browser, header, contract_no=contract_no) - _HEADER_ACCEPT.note( - "S07", - "填总单", - { - "planner": header.planner_name, - "payMode": header.pay_mode, - "protocolEnd": header.protocol_end, - "rebateContract": header.rebate_contract, - }, - ok=True, - ) - pay_before = _read_labeled_input(browser, "承付参数") - planner_before = _read_labeled_input(browser, "计划员") - print("before_save pay_param=", pay_before, "planner=", planner_before, flush=True) - saved = save_rebate_header(browser, contract_no=contract_no) - _HEADER_ACCEPT.note( - "S08", - "保存", - {"saved": "yes", "payParam": pay_before}, - ok=(pay_before or "").strip() == PAY_PARAM_DEFAULT, - ) - _confirm_header_card(browser) - order_no = saved or read_rebate_order_no(browser, contract_no=contract_no) - _HEADER_ACCEPT.note( - "S09", - "读总单号", - {"rebateOrderNo": order_no, "payParam": pay_before}, - ok=True, - raise_error=False, - ) - return order_no or contract_no - finally: - print(_HEADER_ACCEPT.summary_text(), end="", flush=True) - _HEADER_ACCEPT.dump() - - -IMPORT = { - "menu": [ - "text=导入导出", - "button:contains(导入导出)", - "span:contains(导入导出)", - "a:contains(导入导出)", - ], - "download": [ - "text=下载模板", - "text=下载导入模板", - "text=导出模板", - "text=导入模板", - "span:contains(下载模板)", - "a:contains(下载模板)", - "button:contains(下载模板)", - ], - "upload_item": [ - "text=导入", - "span:contains(导入)", - "button:contains(导入)", - "a:contains(导入)", - ], - "file": [ - 'input[type="file"]', - 'input[accept*="excel"]', - 'input[accept*=".xls"]', - 'input[accept*="sheet"]', - ], - "confirm": [ - "text=确定", - "button:contains(确定)", - "text=导入", - "button:contains(导入)", - ], -} - -IMPORT_HEADERS = ( - "货品编码", - "数量", - "含税协议单价", - "税率", - "关税税率", - "承付模式", - "承付参数", - "细单开始日期", - "细单结束日期", - "采购结算方式", - "特定补偿预估值", - "进货补偿预估值", - "细单备注", - "货源细单ID", +from rebate_common import ( + FLOW, + HEADER_FLOW, + IMPORT_FLOW, + SUBMIT_FLOW, + DEFAULT_URL, + SENTINEL_CONTRACT_NO, + PAY_MODE_NEXT_MONTH_N, + REBATE_CONTRACT_DEFAULT, + SETTLE_MODE_DEFAULT, + TAX_RATE_DEFAULT, + PAY_MODE_CODE, + PAY_PARAM_DEFAULT, + PAY_PARAM_SALES, + SETTLE_CODE, + SETTLE_MODE_WIRE, + COMPENSATION_DEFAULT, + PLANNER_DEFAULT, + CONTACT_DEFAULT, + _PORTAL_MARKERS, + EMPTY_MARKERS, + LOGIN, + MENU, + SEARCH, + _TABLE_SCRIPT, + BrowserAdapter, + RebateFlowContext, + RebateHeaderDraft, + RebateImportLine, + RebateImportDraft, + RebateImportResult, + RebateSubmitResult, + classify_search_result, + read_result_table, + open_rebate_center, + _is_logged_in, + login_rebate_center, + _switch_agreement_frame, + _dismiss_menu_autocomplete, + enter_purchase_agreement, + _QUERY_LABELS, + _open_header_search_panel, + _clear_header_search, + _fill_header_contract_and_query, + _search_header_by_contract, + _search_via_query_panel, + _wait_search_hit, + search_contract, + _header_dom_js, + _OPEN_HEADER_ROW, + today_iso, + year_end_iso, + _cell, + _tax_rate_of, + _untaxed_price, + _click_toolbar, + _click_header_toolbar, + _native_click_header_toolbar, + _type_active, + _filter_and_pick_dropdown, + _pick_header_lookup, + _wait_person_picker, + _native_click_el, + _open_labeled_picker, + _read_labeled_input, + _picker_bound, + _close_overlay_named, + _scan_lookup_overlays, + _unit_picker_modal_js, + _send_keys_active, + _click_unit_clear, + _click_unit_query, + _wait_unit_picker_rows, + _query_overlay_and_pick, + _click_keyword_in_overlays, + _pick_labeled_code, + _source_choice_visible, + _close_source_choice, + _ignore_source_choice, + _stop_if_source_choice, + _pick_nth_overlay_row, + _focus_labeled_input, + _hold_source_choice, + _person_picker_visible, + _close_person_picker, + _query_person_and_pick, + _pick_person, + _supplier_picker_open, + _scan_rebate_order_no, + _clean_order_no, + _click_text, + _SELECT_HEADER_ROW_JS, + select_header_row, + select_header_row_result, + read_header_selection, + _close_header_query, + _pick_detail_option, + _read_detail_toast, + _close_header_card, + _close_detail_card, + _SELECT_HEADER_ROW, ) -_HEADER_ALIASES = { - "货品编码": ("货品ID", "货品编码", "操作码", "商品编码"), - "数量": ("数量",), - "含税协议单价": ("含税协议单价", "单价", "协议单价"), - "税率": ("税率",), - "关税税率": ("关税税率",), - "承付模式": ("承付模式", "承付方式"), - "承付参数": ("承付参数", "参数"), - "细单开始日期": ("细单开始日期", "细单开始时间", "开始日期"), - "细单结束日期": ("细单结束日期", "失效日期", "结束日期"), - "采购结算方式": ("采购结算方式", "结算方式"), - "特定补偿预估值": ("特定补偿预估值", "特定补偿"), - "进货补偿预估值": ("进货补偿预估值", "进货补偿"), - "细单备注": ("细单备注", "备注"), - "货源细单ID": ("货源细单ID", "货源细单号", "货源信息"), -} - -_DOWNLOAD_TITLES = ("下载模板", "下载导入模板", "导出模板", "导入模板", "模板下载") -_SUCCESS_RE = re.compile(r"成功[^0-9]{0,10}(\d+)") -_FAIL_RE = re.compile(r"失败[^0-9]{0,10}(\d+)") - -_READ_IMPORT_RESULT = """ -/* READ_IMPORT_RESULT */ -const body = (document.body && document.body.innerText) || ''; -function num(re) { - const m = body.match(re); - return m ? Number(m[1]) : 0; -} -const reasons = Array.from(document.querySelectorAll( - '.el-message, .el-notification, .el-dialog, .vxe-body--row, tr, .ant-message' -)).map((el) => (el.innerText || '').replace(/\\s+/g, ' ').trim()).filter((t) => { - return t && (t.indexOf('失败') >= 0 || t.indexOf('重复') >= 0 || t.indexOf('错误') >= 0); -}).slice(0, 10); -return { - success: num(/成功[^0-9]{0,10}(\\d+)/), - fail: num(/失败[^0-9]{0,10}(\\d+)/), - reasons, - body: body.slice(0, 800), -}; -""" - - -def goods_search_id(line: RebateImportLine) -> str: - """返利中心增加细单按货品ID搜品种,用接口 3 的 goodsId。""" - return _cell(line.goods_id) or _cell(line.goods_code) - - -def build_import_rows( - lines: list[RebateImportLine] | tuple[RebateImportLine, ...], - *, - today: str | None = None, -) -> list[dict[str, str]]: - day = today_iso(today) - end = year_end_iso(day) - rows: list[dict[str, str]] = [] - for line in lines: - rows.append( - { - "货品编码": goods_search_id(line), - "数量": _cell(line.qty), - "含税协议单价": _cell(line.tax_price), - "税率": TAX_RATE_DEFAULT, - "关税税率": TAX_RATE_DEFAULT, - "承付模式": PAY_MODE_CODE, - "承付参数": PAY_PARAM_DEFAULT, - "细单开始日期": day, - "细单结束日期": end, - "采购结算方式": SETTLE_CODE, - "特定补偿预估值": COMPENSATION_DEFAULT, - "进货补偿预估值": COMPENSATION_DEFAULT, - "细单备注": _cell(line.order_no), - "货源细单ID": _cell(line.source_line_id), - } - ) - return rows - - -def build_import_draft( - *, - lines: list[RebateImportLine] | tuple[RebateImportLine, ...], - today: str | None = None, -) -> RebateImportDraft: - packed = tuple(lines) - if not packed: - raise NodeError("细单至少一行") - for line in packed: - if not goods_search_id(line) or not _cell(line.order_no): - raise NodeError("细单货品ID和订单号必须用订单中心的") - if not _cell(line.qty) or not _cell(line.tax_price): - raise NodeError("细单数量和含税协议单价必须用订单中心的") - return RebateImportDraft(lines=packed, today=today_iso(today)) - - -def _line_from_mapping(item: dict) -> RebateImportLine: - goods_id = _cell(item.get("goodsId") or item.get("goods_id")) - goods_code = _cell(item.get("goodsCode") or item.get("goods_code")) - tax_price = _cell(item.get("taxPrice") or item.get("tax_price") or item.get("price") or item.get("unit_price")) - tax_rate = _tax_rate_of( - item.get("taxRate") or item.get("tax_rate") or item.get("purchase_tax_rate") - ) - return RebateImportLine( - goods_code=goods_code, - qty=_cell(item.get("qty") or item.get("quantity") or item.get("saleQty")), - tax_price=tax_price, - order_no=_cell(item.get("orderNo") or item.get("order_no")), - source_line_id=_cell( - item.get("sourceLineId") - or item.get("source_line_id") - or item.get("goodsSourceId") - ), - goods_id=goods_id, - tax_rate=tax_rate, - net_price=_untaxed_price(tax_price, tax_rate), - ) - - -def import_draft_from_env(*, today: str | None = None) -> RebateImportDraft: - raw = (os.getenv("REBATE_IMPORT_LINES") or "").strip() - if raw: - payload = json.loads(raw) - if not isinstance(payload, list): - payload = [payload] - lines = [_line_from_mapping(item) for item in payload] - else: - lines = [ - RebateImportLine( - goods_code=os.getenv("REBATE_GOODS_CODE") or "", - qty=os.getenv("REBATE_QTY") or "", - tax_price=os.getenv("REBATE_TAX_PRICE") or "", - order_no=os.getenv("REBATE_ORDER_NO") or "", - source_line_id=os.getenv("REBATE_SOURCE_LINE_ID") or "", - goods_id=os.getenv("REBATE_GOODS_ID") or "", - ) - ] - return build_import_draft(lines=lines, today=today) - - -def write_canonical_template(path: Path) -> Path: - from openpyxl import Workbook - - dest = Path(path) - dest.parent.mkdir(parents=True, exist_ok=True) - wb = Workbook() - ws = wb.active - ws.title = "细单" - for col, name in enumerate(IMPORT_HEADERS, 1): - ws.cell(1, col, name) - wb.save(dest) - return dest - - -def _header_titles(row) -> list[str]: - titles = [] - for cell in row: - text = _cell(cell.value).lstrip("*") - if text: - titles.append(text) - return titles - - -def _header_row_index(ws) -> int: - aliases = {name for names in _HEADER_ALIASES.values() for name in names} - for index, row in enumerate(ws.iter_rows(min_row=1, max_row=15), 1): - titles = _header_titles(row) - if any(title in aliases for title in titles): - return index - return 1 - - -def _col_map(ws, header_row: int) -> dict[str, int]: - mapping: dict[str, int] = {} - for cell in ws[header_row]: - title = _cell(cell.value).lstrip("*") - if not title or not cell.column: - continue - for field, names in _HEADER_ALIASES.items(): - if title in names and field not in mapping: - mapping[field] = int(cell.column) - return mapping - - -def fill_import_excel( - template_path: Path, - rows: list[dict[str, str]], - output_path: Path | None = None, -) -> Path: - from openpyxl import load_workbook - - src = Path(template_path) - dest = Path(output_path) if output_path else src.with_name(src.stem + "_filled.xlsx") - dest.parent.mkdir(parents=True, exist_ok=True) - if src.suffix.lower() != ".xlsx": - write_canonical_template(dest) - elif src.resolve() != dest.resolve(): - shutil.copy2(src, dest) - wb = load_workbook(dest) - ws = wb.active - header_row = _header_row_index(ws) - colmap = _col_map(ws, header_row) - used = [int(cell.column) for cell in ws[header_row] if cell.column] - next_col = (max(used) if used else 0) + 1 - for field in IMPORT_HEADERS: - if field in colmap: - continue - ws.cell(header_row, next_col, field) - colmap[field] = next_col - next_col += 1 - start = header_row + 1 - for offset, row in enumerate(rows): - for field, value in row.items(): - col = colmap.get(field) - if col: - ws.cell(start + offset, col, value) - wb.save(dest) - return dest - - -def _click_text(browser: BrowserAdapter, title: str) -> bool: - if _click_toolbar(browser, title): - return True - try: - browser.click_first( - [ - f"text={title}", - f"button:contains({title})", - f"span:contains({title})", - f"a:contains({title})", - f'//li[contains(.,"{title}")]', - ] - ) - return True - except Exception: - return False - - -def _wait_local_download(dest_dir: Path, before: set[str], timeout_seconds: int = 20) -> Path: - deadline = time.time() + timeout_seconds - while time.time() <= deadline: - hits = [ - p - for p in dest_dir.iterdir() - if p.name not in before - and p.suffix.lower() == ".xlsx" - and not str(p).endswith(".crdownload") - and not Path(str(p) + ".crdownload").exists() - ] - if hits: - return max(hits, key=lambda p: p.stat().st_mtime) - time.sleep(0.3) - raise RuntimeError("未下到细单模板") - - -def download_detail_template(browser: BrowserAdapter, dest_dir: Path) -> Path: - dest = Path(dest_dir) - dest.mkdir(parents=True, exist_ok=True) - setter = getattr(browser, "set_download_dir", None) - if callable(setter): - setter(str(dest.resolve())) - before = {p.name for p in dest.iterdir()} - _switch_agreement_frame(browser) - if not _click_text(browser, "导入导出"): - try: - browser.click_first(IMPORT["menu"]) - except Exception: - pass - downloaded = False - for title in _DOWNLOAD_TITLES: - if _click_text(browser, title): - downloaded = True - break - if not downloaded: - browser.click_first(IMPORT["download"]) - waiter = getattr(browser, "wait_for_download", None) - if callable(waiter): - try: - return Path(waiter(".xlsx")) - except Exception: - pass - return _wait_local_download(dest, before) - - -def upload_detail_file(browser: BrowserAdapter, file_path: Path) -> None: - _switch_agreement_frame(browser) - if not _click_text(browser, "导入"): - _click_text(browser, "导入导出") - if not _click_text(browser, "导入"): - try: - browser.click_first(IMPORT["upload_item"]) - except Exception: - pass - path = str(Path(file_path).resolve()) - browser.upload_file_first(IMPORT["file"], path) - try: - browser.click_first(IMPORT["confirm"]) - except Exception: - pass - - -def _scan_import_result(browser: BrowserAdapter, *, expected_lines: int = 0) -> RebateImportResult: - raw = None - try: - raw = browser.execute_script(_READ_IMPORT_RESULT) - except Exception: - raw = None - body = "" - success = 0 - fail = 0 - reasons: list[str] = [] - if isinstance(raw, dict): - try: - success = int(raw.get("success") or 0) - except (TypeError, ValueError): - success = 0 - try: - fail = int(raw.get("fail") or 0) - except (TypeError, ValueError): - fail = 0 - reasons = [_cell(x) for x in (raw.get("reasons") or []) if _cell(x)] - body = str(raw.get("body") or "") - if not body: - body = browser.read_body_text() or "" - if success == 0: - matched = _SUCCESS_RE.search(body) - if matched: - success = int(matched.group(1)) - if fail == 0: - matched = _FAIL_RE.search(body) - if matched: - fail = int(matched.group(1)) - if "存在重复的编码" in body: - if "存在重复的编码" not in reasons: - reasons.append("存在重复的编码") - if fail == 0: - fail = expected_lines or 1 - if success == 0 and fail == 0 and "导入成功" in body: - success = expected_lines or 1 - parsed = success + fail > 0 or "导入成功" in body or bool(reasons) - return RebateImportResult(success, fail, tuple(reasons), parsed) - - -_SELECT_HEADER_ROW_JS = """ -/* SELECT_HEADER_ROW */ -const no = String(arguments[0] || '').trim().replace(/\\s+/g, ''); -function compact(el) { - return ((el && (el.innerText || el.textContent)) || '').replace(/\\s+/g, ''); -} -function rowChecked(row) { - if (!row) return false; - const icon = row.querySelector('.vxe-checkbox--icon'); - const cls = String((icon && icon.className) || ''); - return cls.indexOf('checkbox-checked') >= 0 && cls.indexOf('unchecked') < 0; -} -function checkLeft(row) { - if (!row) return false; - if (rowChecked(row)) return true; - const box = row.querySelector('.vxe-cell--checkbox:not(.is--disabled), .vxe-checkbox--icon'); - if (!box) return false; - if (String(box.className || '').indexOf('is--disabled') >= 0) return false; - box.click(); - return true; -} -const root = document.querySelector('.table-show'); -if (!root) { return {ok: false, error: 'no-header-table'}; } -if (!no) { return {ok: false, error: 'empty'}; } -const col = Array.from(root.querySelectorAll('.vxe-header--column')).find((th) => { - return compact(th) === '合同编号'; -}); -if (!col) { return {ok: false, error: 'no-合同编号-col'}; } -const colid = col.getAttribute('colid'); -const mains = Array.from(root.querySelectorAll( - '.vxe-table--main-wrapper .vxe-body--row, .vxe-table--body-wrapper .vxe-body--row' -)); -let idx = -1; -let hitText = ''; -for (let i = 0; i < mains.length; i++) { - const cell = mains[i].querySelector('[colid="' + colid + '"]'); - const t = compact(cell); - if (t === no || (t && t.indexOf(no) >= 0)) { - idx = i; - hitText = t; - break; - } -} -if (idx < 0) { - const cells = Array.from(root.querySelectorAll('.vxe-body--row [colid="' + colid + '"]')); - for (const cell of cells) { - const t = compact(cell); - if (t !== no && t.indexOf(no) < 0) continue; - const row = cell.closest('.vxe-body--row'); - idx = mains.indexOf(row); - if (idx < 0) { - const all = Array.from(root.querySelectorAll('.vxe-body--row')); - idx = all.indexOf(row); - } - hitText = t; - break; - } -} -if (idx < 0) { - return {ok: false, error: 'row', colid: colid, rows: mains.length}; -} -const lefts = Array.from(root.querySelectorAll('.vxe-table--fixed-left-wrapper .vxe-body--row')); -const left = lefts[idx] || null; -const main = mains[idx] || null; -if (main) { main.scrollIntoView({block: 'center'}); } -const checked = checkLeft(left); -return { - ok: !!checked, - text: hitText || compact(main).slice(0, 80), - idx: idx, - colid: colid, - checked: rowChecked(left), -}; -""" - - -def select_header_row(browser: BrowserAdapter, contract_no: str) -> bool: - """按总单表「合同编号」列勾中那一行,禁止拿第一行或 CG- 号凑合。""" - result = select_header_row_result(browser, contract_no) - return bool(isinstance(result, dict) and result.get("ok")) - - -def select_header_row_result(browser: BrowserAdapter, contract_no: str) -> dict: - _switch_agreement_frame(browser) - try: - result = browser.execute_script(_SELECT_HEADER_ROW_JS, contract_no) - except Exception: - result = None - if isinstance(result, dict): - return result - return {"ok": False, "error": "script"} - - -def read_header_selection(browser: BrowserAdapter, contract_no: str) -> dict: - """读总单表当前勾中行的合同编号,给续跑脚本确认没拿错行。""" - _switch_agreement_frame(browser) - try: - result = browser.execute_script( - """ - /* HEADER_SELECTION */ - const no = String(arguments[0] || '').trim().replace(/\\s+/g, ''); - function compact(el) { - return ((el && (el.innerText || el.textContent)) || '').replace(/\\s+/g, ''); - } - const root = document.querySelector('.table-show'); - if (!root) { return {ok: false, error: 'no-header-table'}; } - const col = Array.from(root.querySelectorAll('.vxe-header--column')).find((th) => { - return compact(th) === '合同编号'; - }); - const colid = col ? col.getAttribute('colid') : ''; - const mains = Array.from(root.querySelectorAll( - '.vxe-table--main-wrapper .vxe-body--row, .vxe-table--body-wrapper .vxe-body--row' - )); - const lefts = Array.from(root.querySelectorAll( - '.vxe-table--fixed-left-wrapper .vxe-body--row' - )); - const cells = colid - ? Array.from(root.querySelectorAll('.vxe-body--row [colid="' + colid + '"]')).map((c) => compact(c)) - : []; - let checkedIdx = -1; - for (let i = 0; i < lefts.length; i++) { - const icon = lefts[i].querySelector('.vxe-checkbox--icon'); - const cls = String((icon && icon.className) || ''); - if (cls.indexOf('checkbox-checked') >= 0 && cls.indexOf('unchecked') < 0) { - checkedIdx = i; - break; - } - } - const contract = (checkedIdx >= 0 && cells[checkedIdx]) ? cells[checkedIdx] : (cells[0] || ''); - return { - ok: !!(no && contract && contract.indexOf(no) >= 0 && checkedIdx >= 0), - colid: colid, - rows: mains.length, - cells: cells, - checkedIdx: checkedIdx, - contract: contract, - pager: compact(root.querySelector('.vxe-pager, .elPlus-pagination')), - }; - """, - contract_no, - ) - except Exception: - result = None - if isinstance(result, dict): - return result - return {"ok": False, "error": "script"} - - -def _goods_add_visible(browser: BrowserAdapter) -> bool: - try: - hit = browser.execute_script( - """ - /* GOODS_ADD_VISIBLE */ - function compact(el) { - return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim(); - } - function goodsTable() { - const header = document.querySelector('.table-show .single-table'); - const all = Array.from(document.querySelectorAll('.single-table')).filter((el) => { - return el !== header && (el.offsetWidth || el.offsetHeight); - }); - return all.find((el) => compact(el).indexOf('货品') >= 0) - || all.find((el) => el.closest('.elPlus-tab-pane')) - || all[0] - || null; - } - const table = goodsTable(); - if (!table) return false; - const nodes = Array.from(table.querySelectorAll( - '.table-toolbar button, .table-toolbar .vxe-button, .table-toolbar span, .table-toolbar a, .table-toolbar-btn__text' - )); - return nodes.some((el) => { - if (!(el.offsetWidth || el.offsetHeight)) return false; - const t = compact(el); - return t === '新增' || t === '+新增' || t === '新增细单' || t === '添加细单'; - }); - """ - ) - except Exception: - hit = False - return hit is True - - -def _wait_goods_add_button(browser: BrowserAdapter, timeout: float = 8) -> bool: - deadline = time.time() + timeout - while time.time() <= deadline: - _switch_agreement_frame(browser) - if _goods_add_visible(browser): - return True - _click_text(browser, "货品") - time.sleep(0.4) - return False - - -def _click_add_detail(browser: BrowserAdapter) -> bool: - _switch_agreement_frame(browser) - clicked = browser.execute_script( - """ - /* CLICK_GOODS_ADD */ - function compact(el) { - return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim(); - } - function goodsTable() { - const header = document.querySelector('.table-show .single-table'); - const all = Array.from(document.querySelectorAll('.single-table')).filter((el) => { - return el !== header && (el.offsetWidth || el.offsetHeight); - }); - return all.find((el) => compact(el).indexOf('货品') >= 0) - || all.find((el) => el.closest('.elPlus-tab-pane')) - || all[0] - || null; - } - const table = goodsTable(); - if (!table) { return {ok: false, error: 'goods-table'}; } - const nodes = Array.from(table.querySelectorAll( - '.table-toolbar button, .table-toolbar .vxe-button, .table-toolbar span, .table-toolbar a, .table-toolbar-btn__text' - )); - const hit = nodes.find((el) => { - if (!(el.offsetWidth || el.offsetHeight)) return false; - const t = compact(el); - return t === '新增' || t === '+新增' || t === '新增细单' || t === '添加细单'; - }); - if (!hit) { return {ok: false}; } - hit.click(); - return {ok: true, text: compact(hit)}; - """ - ) - if isinstance(clicked, dict) and clicked.get("ok"): - time.sleep(0.8) - return True - return False - - -def _click_goods_toolbar(browser: BrowserAdapter, title: str) -> bool: - """只点货品工具栏(新增旁边那颗),不要点总单工具栏。""" - clicked = browser.execute_script( - """ - /* SAVE_DETAIL */ - const title = String(arguments[0] || '').replace(/\\s+/g, '').trim(); - function compact(el) { - return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim(); - } - function vis(el) { - return !!(el && (el.offsetWidth || el.offsetHeight)); - } - const bars = Array.from(document.querySelectorAll('.table-toolbar')).filter(vis); - const bar = bars.find((el) => { - const t = compact(el); - return t.indexOf('新增') >= 0 && t.indexOf('保存') >= 0 && t.indexOf('送审') < 0; - }) || bars.find((el) => compact(el).indexOf('新增') >= 0); - if (!bar) { return {ok: false, error: 'no-detail-bar'}; } - const hit = Array.from(bar.querySelectorAll( - 'button, .table-toolbar-btn, .table-toolbar-btn__text, span' - )).find((el) => vis(el) && compact(el) === title); - if (!hit) { return {ok: false, error: 'no-btn'}; } - const btn = hit.closest('button, .table-toolbar-btn') || hit; - btn.click(); - return {ok: true, via: compact(hit)}; - """, - title, - ) - return bool(isinstance(clicked, dict) and clicked.get("ok")) - - -def _native_click_goods_toolbar(browser: BrowserAdapter, title: str) -> bool: - driver = getattr(browser, "_driver", None) - if driver is None: - return False - wanted = (title or "").replace(" ", "") - try: - from selenium.webdriver.common.action_chains import ActionChains - - for bar in driver.find_elements("css selector", ".table-toolbar"): - text = (bar.text or "").replace("\n", " ") - if "新增" not in text or "保存" not in text or "送审" in text: - continue - for el in bar.find_elements("css selector", "button, .table-toolbar-btn, .table-toolbar-btn__text"): - raw = (el.text or "").replace("\n", "").replace(" ", "") - if raw != wanted: - continue - driver.execute_script("arguments[0].scrollIntoView({block:'center'});", el) - ActionChains(driver).move_to_element(el).pause(0.15).click().perform() - return True - except Exception: - return False - return False - - -def _confirm_save_popup(browser: BrowserAdapter) -> bool: - """货品保存时若弹出确认,再点确认。""" - try: - clicked = browser.execute_script( - """ - /* CONFIRM_SAVE_POPUP */ - function compact(el) { - return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim(); - } - function vis(el) { - return !!(el && (el.offsetWidth || el.offsetHeight)); - } - const box = Array.from(document.querySelectorAll( - '.elPlus-message-box, .el-message-box, .elPlus-dialog, .el-dialog, .vxe-modal--wrapper, .ant-modal' - )).find((el) => { - if (!vis(el)) return false; - const t = compact(el); - return t.indexOf('确认') >= 0 || t.indexOf('确定') >= 0 || t.indexOf('是否') >= 0; - }); - if (!box) { return {ok: false, asked: false}; } - const hit = Array.from(box.querySelectorAll('button, .elPlus-button, span')).find((el) => { - if (!vis(el)) return false; - const t = compact(el); - return t === '确定' || t === '确 定' || t === '确认' || t === '确 认' || t === '是'; - }); - if (!hit) { return {ok: false, asked: true}; } - hit.click(); - return {ok: true, asked: true, via: compact(hit)}; - """ - ) - except Exception: - clicked = None - return bool(isinstance(clicked, dict) and clicked.get("ok")) - - -def _goods_id_field_visible(browser: BrowserAdapter) -> bool: - try: - hit = browser.execute_script( - """ - /* GOODS_ID_FIELD */ - function labelText(el) { - const n = el.querySelector('.search-label__text'); - return ((n && n.innerText) || '').replace(/\\s+/g, '').trim(); - } - return Array.from(document.querySelectorAll('.search-condition-item')).some((el) => { - return (el.offsetWidth || el.offsetHeight) && labelText(el) === '货品ID'; - }); - """ - ) - except Exception: - hit = False - return hit is True - - -def _open_goods_id_query(browser: BrowserAdapter) -> None: - browser.execute_script( - """ - /* OPEN_GOODS_ID_QUERY */ - function labelText(el) { - const n = el.querySelector('.search-label__text'); - return ((n && n.innerText) || '').replace(/\\s+/g, '').trim(); - } - const ready = Array.from(document.querySelectorAll('.search-condition-item')).some((el) => { - return (el.offsetWidth || el.offsetHeight) && labelText(el) === '货品ID'; - }); - if (ready) { return {ok: true, already: true}; } - const modal = Array.from(document.querySelectorAll( - '.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer' - )).find((el) => el.offsetWidth || el.offsetHeight); - const root = modal || document; - const btn = root.querySelector('.search-button-content, .search-button'); - if (btn && (btn.offsetWidth || btn.offsetHeight) && !root.querySelector('.table-show')) { - btn.click(); - return {ok: true, via: 'query'}; - } - if (modal) { - const inner = modal.querySelector('.search-button-content, .search-button'); - if (inner) { inner.click(); return {ok: true, via: 'modal'}; } - } - return {ok: false}; - """ - ) - - -def _wait_goods_id_field(browser: BrowserAdapter, timeout: float = 8) -> bool: - deadline = time.time() + timeout - retried_add = False - while time.time() <= deadline: - _switch_agreement_frame(browser) - if _goods_id_field_visible(browser): - return True - _open_goods_id_query(browser) - if _goods_id_field_visible(browser): - return True - if not retried_add: - _click_add_detail(browser) - retried_add = True - time.sleep(0.4) - return _goods_id_field_visible(browser) - - -def _close_header_query(browser: BrowserAdapter) -> None: - """关掉总单查询弹层。点「取消」会清掉合同编号筛选,只关 X / Esc。""" - try: - browser.execute_script( - """ - /* CLOSE_HEADER_QUERY */ - function compact(el) { - return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); - } - function vis(el) { - return !!(el && (el.offsetWidth || el.offsetHeight)); - } - const modal = Array.from(document.querySelectorAll( - '.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer, .elPlus-overlay, .elPlus-popper' - )).find((el) => { - if (!vis(el)) return false; - const t = compact(el); - return t.indexOf('已保存查询条件') >= 0 || t.indexOf('设置查询条件') >= 0 - || (t.indexOf('合同编号') >= 0 && !!el.querySelector('.search-condition-item')); - }); - if (modal) { - const close = modal.querySelector( - '.vxe-modal--close-btn, .vxe-icon-close, .elPlus-dialog__headerbtn, .el-dialog__headerbtn' - ); - if (close) { close.click(); return {ok: true, via: 'x'}; } - modal.remove(); - return {ok: true, via: 'remove'}; - } - return {ok: false, via: 'none'}; - """ - ) - except Exception: - pass - time.sleep(0.4) - - -def _open_goods_picker(browser: BrowserAdapter) -> bool: - opened = browser.execute_script( - """ - /* OPEN_GOODS_PICKER */ - """ - + _header_dom_js() - + """ - const item = detailItem('货品编码') || formItem('货品编码'); - if (!item) { return {ok: false, error: 'no-goods-code'}; } - item.scrollIntoView({block: 'center', inline: 'nearest'}); - const via = clickDots(item); - if (via) { return {ok: true, via: via}; } - const btn = item.querySelector('button.elPlus-button, button'); - if (!btn) { return {ok: false}; } - btn.click(); - return {ok: true, via: 'button'}; - """ - ) - return bool(isinstance(opened, dict) and opened.get("ok")) - - -def _wait_goods_picker(browser: BrowserAdapter, timeout: float = 12) -> bool: - deadline = time.time() + timeout - while time.time() <= deadline: - hit = browser.execute_script( - """ - /* WAIT_GOODS_PICKER */ - const el = Array.from(document.querySelectorAll('.vxe-modal--wrapper')).find((n) => { - const t = ((n.innerText || '') + ''); - const vis = n.offsetWidth || n.offsetHeight - || String(n.className || '').indexOf('is--visible') >= 0; - return vis && t.indexOf('货品选择') >= 0; - }); - return !!(el && (el.offsetWidth || el.offsetHeight)); - """ - ) - if hit is True: - return True - time.sleep(0.4) - return False - - -def _pick_goods_id_in_dialog(browser: BrowserAdapter, goods_id: str) -> bool: - keyword = (goods_id or "").strip() - if not keyword: - return False - if not _open_goods_picker(browser): - return False - if not _wait_goods_picker(browser): - return False - filled = browser.execute_script( - """ - /* FILL_GOODS_ID */ - const no = String(arguments[0] || ''); - function compact(el) { - return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); - } - function vis(el) { - return !!(el && (el.offsetWidth || el.offsetHeight)); - } - const modal = Array.from(document.querySelectorAll('.vxe-modal--wrapper')).find((el) => { - return vis(el) && compact(el).indexOf('货品选择') >= 0; - }); - if (!modal) { return {ok: false, error: 'no-modal'}; } - const tab = Array.from(modal.querySelectorAll('span, label, li, button, div')).find((el) => { - return compact(el) === '货品ID'; - }); - if (tab) { tab.click(); } - const input = modal.querySelector('input.elPlus-input__inner, input'); - if (!input) { return {ok: false, error: 'no-input'}; } - input.click(); - input.focus(); - const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; - setter.call(input, no); - input.dispatchEvent(new Event('input', {bubbles: true})); - input.dispatchEvent(new Event('change', {bubbles: true})); - const query = Array.from(modal.querySelectorAll('button')).find((el) => { - const t = compact(el); - return t === '查询' || t.indexOf('查询') === 0; - }); - if (query) { query.click(); } - return {ok: true, label: '货品ID', value: input.value}; - """, - keyword, - ) - if not (isinstance(filled, dict) and filled.get("ok") and filled.get("value") == keyword): - return False - deadline = time.time() + 8 - while time.time() <= deadline: - picked = browser.execute_script( - """ - /* PICK_GOODS_ROW */ - const keyword = String(arguments[0] || '').replace(/\\s+/g, ''); - function compact(el) { - return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ''); - } - function vis(el) { - return !!(el && (el.offsetWidth || el.offsetHeight)); - } - const modal = Array.from(document.querySelectorAll('.vxe-modal--wrapper')).find((el) => { - return vis(el) && compact(el).indexOf('货品选择') >= 0; - }); - const root = modal || document; - const rows = Array.from(root.querySelectorAll('.vxe-body--row, tr, [role="row"]')); - for (const row of rows) { - const text = compact(row); - if (text.indexOf(keyword) < 0) continue; - if (text.indexOf('CG-') >= 0) continue; - const radio = row.querySelector( - '.vxe-cell--radio, .vxe-radio--icon, .vxe-radio--label, input[type="radio"]' - ); - if (radio) { radio.click(); } else { row.click(); } - const ok = Array.from(root.querySelectorAll('button')).find((el) => { - if (!vis(el)) return false; - const t = compact(el); - return t === '确定' || t === '确 定'; - }); - if (ok) { ok.click(); } - return {ok: true, via: radio ? 'radio' : 'row'}; - } - return {ok: false}; - """, - keyword, - ) - if isinstance(picked, dict) and picked.get("ok"): - time.sleep(0.5) - return True - time.sleep(0.4) - return False - - -def _pick_detail_option( - browser: BrowserAdapter, title: str, value: str, *, in_detail: bool = False -) -> bool: - """下拉可筛选且很长。先输入再精确点中文案,避免「电汇」点到「电汇—非直联」。""" - wanted = (value or "").strip() - if not wanted: - return False - item_fn = "detailItem" if in_detail else "formItem" - opened = browser.execute_script( - """ - /* OPEN_DETAIL_SELECT */ - """ - + _header_dom_js() - + f""" - const title = String(arguments[0] || ''); - const item = {item_fn}(title); - if (!item) {{ return {{ok: false, error: 'no-label'}}; }} - item.scrollIntoView({{block: 'center', inline: 'nearest'}}); - const trigger = selectWrap(item) || item.querySelector('.elPlus-input__wrapper, input, .vxe-input'); - if (!trigger) {{ return {{ok: false, error: 'no-trigger'}}; }} - trigger.click(); - return {{ok: true}}; - """, - title, - ) - if not (isinstance(opened, dict) and opened.get("ok")): - return False - time.sleep(0.2) - browser.execute_script( - """ - /* FILTER_DETAIL_SELECT */ - """ - + _header_dom_js() - + f""" - const title = String(arguments[0] || ''); - const wanted = String(arguments[1] || ''); - const item = {item_fn}(title); - const input = (item && item.querySelector('input.elPlus-select__input')) - || Array.from(document.querySelectorAll( - '.elPlus-select__popper input, .elPlus-select-dropdown input.elPlus-select__input' - )).find(vis); - if (!input) {{ return {{ok: false, error: 'no-filter'}}; }} - input.focus(); - input.click(); - const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; - setter.call(input, wanted); - input.dispatchEvent(new Event('input', {{bubbles: true}})); - input.dispatchEvent(new InputEvent('input', {{bubbles: true, composed: true, data: wanted}})); - input.dispatchEvent(new KeyboardEvent('keyup', {{key: wanted.slice(-1) || '', bubbles: true}})); - return {{ok: true, value: input.value}}; - """, - title, - wanted, - ) - driver = getattr(browser, "_driver", None) - if driver is not None: - try: - from selenium.webdriver.common.action_chains import ActionChains - from selenium.webdriver.common.by import By - from selenium.webdriver.common.keys import Keys - - els = [ - el - for el in driver.find_elements("css selector", "input.elPlus-select__input") - if el.is_displayed() - ] - if els: - el = els[-1] - ActionChains(driver).move_to_element(el).click().send_keys(Keys.CONTROL + "a").send_keys(wanted).perform() - except Exception: - pass - deadline = time.time() + 6 - picked = None - while time.time() <= deadline: - picked = browser.execute_script( - """ - /* PICK_DETAIL_OPTION */ - const wanted = String(arguments[0] || '').replace(/\\s+/g, ''); - function compact(el) { - return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim(); - } - function vis(el) { - return !!(el && (el.offsetWidth || el.offsetHeight)); - } - function scan(root) { - return Array.from(root.querySelectorAll( - '.elPlus-select-dropdown__item, .el-select-dropdown__item, .vxe-select-option, li' - )).find((el) => vis(el) && compact(el) === wanted); - } - const drops = Array.from(document.querySelectorAll( - '.elPlus-select__popper, .elPlus-select-dropdown, .el-select-dropdown, .vxe-select--panel' - )).filter(vis); - const roots = drops.length ? drops : [document]; - let opt = null; - for (const root of roots) { - opt = scan(root); - if (opt) break; - const wrap = root.querySelector( - '.elPlus-scrollbar__wrap, .elPlus-select-dropdown__wrap, .elPlus-vl__window' - ); - if (!wrap) continue; - for (let i = 0; i < 25 && !opt; i++) { - wrap.scrollTop = (wrap.scrollTop || 0) + 72; - opt = scan(root); - } - if (opt) break; - } - if (!opt) { return {ok: false, error: 'no-option'}; } - opt.click(); - return {ok: true, text: ((opt.innerText || '') + '').trim()}; - """, - wanted, - ) - if isinstance(picked, dict) and picked.get("ok") and picked.get("text", "").replace(" ", "") == wanted.replace(" ", ""): - return True - time.sleep(0.3) - return False - - -def _read_detail_toast(browser: BrowserAdapter) -> str: - try: - result = browser.execute_script( - """ - /* DETAIL_TOAST */ - function compact(el) { - return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); - } - const nodes = Array.from(document.querySelectorAll( - '.elPlus-message, .el-message, .elPlus-notification, .el-notification' - )); - const texts = nodes.map((el) => compact(el)).filter(Boolean); - return {texts: texts.slice(0, 8)}; - """ - ) - except Exception: - result = None - texts = [] - if isinstance(result, dict): - texts = [str(t) for t in (result.get("texts") or []) if t] - for text in texts: - if "供应商为空" in text: - return text - if "失败" in text or "不能" in text or "不能为空" in text: - return text - return "" - - -def _detail_card_pager(browser: BrowserAdapter) -> str: - try: - text = browser.execute_script( - """ - /* DETAIL_CARD_PAGER */ - const el = document.querySelector('.right-btns'); - return ((el && el.innerText) || '').replace(/\\s+/g, ' ').trim(); - """ - ) - except Exception: - text = "" - return str(text or "") - - -def _goods_table_rows(browser: BrowserAdapter) -> list[str]: - try: - rows = browser.execute_script( - """ - /* GOODS_TABLE_ROWS */ - function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); } - function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); } - const table = Array.from(document.querySelectorAll('.single-table')).find((el) => { - return vis(el) && compact(el).indexOf('协议细单ID') >= 0; - }); - if (!table) { return []; } - return Array.from(table.querySelectorAll('.vxe-body--row')).filter(vis).map((el) => compact(el).slice(0, 240)); - """ - ) - except Exception: - rows = None - if isinstance(rows, list): - return [str(item) for item in rows] - return [] - - -def _wait_goods_row(browser: BrowserAdapter, gid: str, timeout: float = 8) -> bool: - number = (gid or "").strip() - if not number: - return False - deadline = time.time() + timeout - while time.time() <= deadline: - rows = _goods_table_rows(browser) - if any(number in row for row in rows): - return True - time.sleep(0.3) - print("goods_table_rows=", _goods_table_rows(browser), "need=", number, flush=True) - return False - - -def _close_header_card(browser: BrowserAdapter) -> None: - """总单卡片开着时细单确认会点到总单确认。先回表格。""" - try: - browser.execute_script( - """ - /* CLOSE_HEADER_CARD */ - function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); } - function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); } - const card = Array.from(document.querySelectorAll('.table-card')).find((el) => { - const t = compact(el); - return vis(el) && t.indexOf('供应商编码') >= 0 && t.indexOf('货品编码') < 0; - }); - if (!card) { return {ok: false}; } - const hit = Array.from(card.querySelectorAll('.card-header-btn, button')).find((el) => { - if (!vis(el)) return false; - const t = compact(el).replace(/\\s+/g, ''); - return t.indexOf('返回表格') === 0; - }); - if (!hit) { return {ok: false, error: 'no-back'}; } - hit.click(); - return {ok: true, via: compact(hit).slice(0, 40)}; - """ - ) - except Exception: - pass - time.sleep(0.3) - - -def _confirm_detail_card(browser: BrowserAdapter) -> bool: - """点货品卡片标题栏「确认 Ctrl + Enter」,不要点总单卡片确认。""" - before = _detail_card_pager(browser) - clicked = browser.execute_script( - """ - /* CONFIRM_DETAIL_CARD */ - function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); } - function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); } - const card = Array.from(document.querySelectorAll('.table-card')).find((el) => { - return vis(el) && compact(el).indexOf('货品编码') >= 0; - }); - if (!card) { return {ok: false, error: 'no-goods-card'}; } - const nodes = Array.from(card.querySelectorAll('.card-header-btn, button.elPlus-button')); - const texts = nodes.map((el) => compact(el)).filter(Boolean); - const hit = nodes.find((el) => { - if (!vis(el)) return false; - const t = compact(el).replace(/\\s+/g, ''); - return t.indexOf('确认') === 0 || t.indexOf('确认Ctrl') === 0; - }); - if (!hit) { return {ok: false, error: 'no-confirm', n: nodes.length, texts: texts.slice(0, 8)}; } - hit.scrollIntoView({block: 'center', inline: 'nearest'}); - hit.click(); - return {ok: true, via: compact(hit).slice(0, 40), w: hit.offsetWidth, h: hit.offsetHeight}; - """ - ) - driver = getattr(browser, "_driver", None) - if driver is not None: - try: - from selenium.webdriver.common.action_chains import ActionChains - - for el in driver.find_elements("css selector", ".table-card .card-header-btn"): - info = driver.execute_script( - """ - const el = arguments[0]; - const card = el.closest('.table-card'); - return { - text: ((el.innerText || '') + '').replace(/\\s+/g, ' ').trim(), - goods: !!card && ((card.innerText || '').indexOf('货品编码') >= 0) - }; - """, - el, - ) - if not (isinstance(info, dict) and info.get("goods")): - continue - raw = str(info.get("text") or "") - if "确认" not in raw.replace(" ", "").replace("\n", ""): - continue - driver.execute_script( - "arguments[0].scrollIntoView({block:'center', inline:'nearest'});", - el, - ) - time.sleep(0.2) - ActionChains(driver).move_to_element(el).pause(0.15).click().perform() - clicked = {"ok": True, "via": "native:" + raw[:40]} - break - except Exception as exc: - print("native_confirm_err=", exc, flush=True) - print("confirm_click=", clicked, "pager_before=", before, flush=True) - if not (isinstance(clicked, dict) and clicked.get("ok")): - return False - time.sleep(0.6) - err = _read_detail_toast(browser) - if err: - raise NodeError(err) - errors = [] - try: - errors = browser.execute_script( - """ - /* DETAIL_FORM_ERRORS */ - function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); } - return Array.from(document.querySelectorAll( - '.table-card .elPlus-form-item.is-error, .table-card .elPlus-form-item__error' - )).map((el) => compact(el)).filter(Boolean).slice(0, 8); - """ - ) or [] - except Exception: - errors = [] - print("pager_after=", _detail_card_pager(browser), "errors=", errors, flush=True) - if errors: - raise NodeError("细单确认未过:" + ";".join(str(x) for x in errors[:3])) - return True - if isinstance(clicked, dict) and clicked.get("ok"): - time.sleep(0.5) - err = _read_detail_toast(browser) - if err: - raise NodeError(err) - return True - driver = getattr(browser, "_driver", None) - if driver is not None: - from selenium.webdriver.common.action_chains import ActionChains - from selenium.webdriver.common.keys import Keys - - ActionChains(driver).key_down(Keys.CONTROL).send_keys(Keys.ENTER).key_up(Keys.CONTROL).perform() - time.sleep(0.5) - return True - return False - - -def _type_detail_field(browser: BrowserAdapter, title: str, value: str) -> bool: - """卡片输入要用键盘,JS 赋值 Vue 收不到,确认时会报必填。""" - wanted = _cell(value) - if not wanted: - return False - marked = browser.execute_script( - """ - /* TYPE_DETAIL_FIELD */ - """ - + _header_dom_js() - + """ - const item = detailItem(arguments[0]); - const input = item && item.querySelector('input, textarea'); - if (!input) { return false; } - input.setAttribute('data-rpa-fill', '1'); - input.click(); - return true; - """, - title, - ) - if not marked: - return False - driver = getattr(browser, "_driver", None) - if driver is None: - return True - try: - from selenium.webdriver.common.action_chains import ActionChains - from selenium.webdriver.common.keys import Keys - - el = driver.find_element("css selector", '.table-card [data-rpa-fill="1"]') - driver.execute_script("arguments[0].removeAttribute('data-rpa-fill')", el) - ActionChains(driver).move_to_element(el).click().pause(0.05).key_down(Keys.CONTROL).send_keys("a").key_up( - Keys.CONTROL - ).send_keys(Keys.BACKSPACE).send_keys(wanted).send_keys(Keys.TAB).perform() - return True - except Exception: - return False - - -def _fill_last_detail_qty_price(browser: BrowserAdapter, line: RebateImportLine) -> None: - qty = _cell(line.qty) - price = _cell(line.tax_price) - remark = _cell(line.order_no) - tax = _tax_rate_of(line.tax_rate) - net = _cell(line.net_price) or _untaxed_price(price, tax) - filled = browser.execute_script( - """ - /* FILL_DETAIL_CELLS */ - """ - + _header_dom_js() - + """ - const qty = String(arguments[0] || ''); - const price = String(arguments[1] || ''); - const remark = String(arguments[2] || ''); - const compensation = String(arguments[3] || ''); - const tax = String(arguments[4] || ''); - const payParam = String(arguments[5] || ''); - const net = String(arguments[6] || ''); - function setByLabel(title, value) { - if (!value) return false; - const item = detailItem(title); - if (!item) return false; - const input = item.querySelector('input, textarea, .vxe-input--inner'); - if (!input) return false; - input.click(); - const desc = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value') - || Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value'); - if (desc && desc.set) { desc.set.call(input, value); } - else { input.value = value; } - input.dispatchEvent(new Event('input', {bubbles: true})); - input.dispatchEvent(new Event('change', {bubbles: true})); - return true; - } - return { - qty: setByLabel('数量', qty), - price: setByLabel('含税协议单价', price), - net: setByLabel('无税协议价', net) || setByLabel('无税单价', net), - tax: setByLabel('税率', tax), - payParam: setByLabel('承付参数', payParam), - remark: setByLabel('细单备注', remark), - compensation: setByLabel('特定补偿预估值', compensation) || setByLabel('特定补偿', compensation), - inbound: setByLabel('进货补偿预估值', compensation), - payMode: shownValue(detailItem('承付模式')), - settle: shownValue(detailItem('采购结算方式')), - }; - """, - qty, - price, - remark, - COMPENSATION_DEFAULT, - tax, - PAY_PARAM_DEFAULT, - net, - ) - print("fill_detail=", filled, flush=True) - try: - browser.execute_script( - """ - document.dispatchEvent(new KeyboardEvent('keydown', { - key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true - })); - """ - ) - except Exception: - pass - _type_detail_field(browser, "数量", qty) - _type_detail_field(browser, "含税协议单价", price) - _type_detail_field(browser, "无税协议价", net) - _type_detail_field(browser, "承付参数", PAY_PARAM_DEFAULT) - _type_detail_field(browser, "细单备注", remark) - _type_detail_field(browser, "特定补偿预估值", COMPENSATION_DEFAULT) - _type_detail_field(browser, "进货补偿预估值", COMPENSATION_DEFAULT) - if not _pick_detail_option(browser, "承付模式", PAY_MODE_NEXT_MONTH_N, in_detail=True): - raise NodeError("细单承付未点到下月N号结") - _pick_detail_option(browser, "承付参数", PAY_PARAM_DEFAULT, in_detail=True) - if not _pick_detail_option(browser, "采购结算方式", SETTLE_MODE_WIRE, in_detail=True): - raise NodeError("细单结算未精确点到电汇") - - -def _close_detail_card(browser: BrowserAdapter) -> None: - """回到货品表。未确认的新增卡片用「返回表格 Ctrl + B」。""" - try: - browser.execute_script( - """ - /* CLOSE_DETAIL_CARD */ - """ - + _header_dom_js() - + """ - const card = Array.from(document.querySelectorAll('.table-card')).find((el) => { - return vis(el) && compact(el).indexOf('货品编码') >= 0; - }); - if (!card) { return {ok: false}; } - const hit = Array.from(card.querySelectorAll('.card-header-btn, button')).find((el) => { - if (!(el.offsetWidth || el.offsetHeight)) return false; - const t = compact(el).replace(/\\s+/g, ''); - return t.indexOf('返回表格') === 0; - }); - if (hit) { hit.click(); return {ok: true, via: compact(hit).slice(0, 40)}; } - return {ok: false}; - """ - ) - except Exception: - pass - time.sleep(0.3) - - -def add_detail_lines_by_goods_id( - browser: BrowserAdapter, - lines: list[RebateImportLine] | tuple[RebateImportLine, ...], - *, - save: bool = True, -) -> int: - added = 0 - _close_header_card(browser) - _close_detail_card(browser) - for line in lines: - gid = goods_search_id(line) - if not gid: - raise NodeError("细单货品ID必须用订单中心的 goodsId") - if not _click_add_detail(browser): - raise NodeError("选中总单后未出现货品新增") - if not _pick_goods_id_in_dialog(browser, gid): - raise NodeError(f"新增后未按货品ID搜到 {gid},停下确认搜框是不是货品ID") - _fill_last_detail_qty_price(browser, line) - try: - print( - "card_btns=", - browser.execute_script( - """ - return Array.from(document.querySelectorAll('.table-card .card-header-btn, .card-header-btn')).map((el) => ((el.innerText || '') + '').replace(/\\s+/g, ' ').trim()).filter(Boolean).slice(0, 8); - """ - ), - flush=True, - ) - except Exception: - pass - confirmed = _confirm_detail_card(browser) - print("confirm_detail=", confirmed, "gid=", gid, flush=True) - if not confirmed: - raise NodeError(f"细单 {gid} 未点到货品卡片「确认 Ctrl + Enter」,行还没落到货品表") - if not _wait_goods_row(browser, gid): - raise NodeError(f"细单确认后货品表没有 {gid}") - added += 1 - if added: - _close_detail_card(browser) - if added and save: - if not _native_click_goods_toolbar(browser, "保存"): - if not _click_goods_toolbar(browser, "保存"): - raise NodeError("未点到货品工具栏保存(新增旁边那颗),不要点总单保存") - time.sleep(0.5) - _confirm_save_popup(browser) - err = _read_detail_toast(browser) - if err: - raise NodeError(err) - return added - - -def read_import_result(browser: BrowserAdapter, *, expected_lines: int = 0) -> RebateImportResult: - deadline = time.time() + 20 - last = RebateImportResult(0, 0, (), False) - while time.time() <= deadline: - last = _scan_import_result(browser, expected_lines=expected_lines) - if last.parsed: - return last - time.sleep(0.4) - return last - - -def run_import_flow( - browser: BrowserAdapter, - ctx: RebateFlowContext, - contract_no: str, - draft: RebateImportDraft | None = None, - *, - download_dir: Path | None = None, -) -> RebateImportResult: - _IMPORT_ACCEPT.reset() - payload = draft or import_draft_for_contract(contract_no) - dest = Path(download_dir) if download_dir else artifact_dir(IMPORT_FLOW) / "downloads" - result = RebateImportResult(0, 0, (), False) - try: - open_rebate_center(browser, ctx) - _IMPORT_ACCEPT.note("S02", "打开返利中心", {"url": ctx.rebate_url}, ok=True) - try: - login_rebate_center(browser, ctx) - _IMPORT_ACCEPT.note("S03", "登录", {"user": ctx.username}, ok=True) - except Exception as exc: - evidence_shot(browser, "rebate-import-login-failed", failed=True) - _IMPORT_ACCEPT.note( - "S03", - "登录", - {"user": ctx.username, "error": exc}, - ok=False, - raise_error=False, - ) - raise - enter_purchase_agreement(browser) - _IMPORT_ACCEPT.note("S04", "进入采购协议", {"menu": "采购协议管理"}, ok=True) - found = search_contract(browser, contract_no) - _IMPORT_ACCEPT.note( - "S05", - "查号", - {"contractNo": contract_no, "found": "yes" if found else "no"}, - ok=found, - ) - if not found: - raise NodeError("查号未搜到合同,不能加细单") - _close_header_query(browser) - picked = select_header_row_result(browser, contract_no) - if not (isinstance(picked, dict) and picked.get("ok")): - raise NodeError("未按合同编号勾中总单行") - if not _wait_goods_add_button(browser): - raise NodeError("点中总单后未出现货品新增,停下确认货品区是否已出") - _close_header_card(browser) - _IMPORT_ACCEPT.note("S06", "选中总单", {"selected": "yes"}, ok=True) - added = add_detail_lines_by_goods_id(browser, payload.lines) - first = payload.lines[0] - _IMPORT_ACCEPT.note( - "S07", - "新增细单", - { - "lines": added, - "goodsId": goods_search_id(first), - "remark": _cell(first.order_no), - "compensation": COMPENSATION_DEFAULT, - }, - ok=added == len(payload.lines), - ) - if added != len(payload.lines): - raise NodeError(f"细单未加齐 {added}/{len(payload.lines)}") - table_rows = _goods_table_rows(browser) - missing = [ - goods_search_id(line) - for line in payload.lines - if not any(goods_search_id(line) in row for row in table_rows) - ] - if missing: - raise NodeError("货品表缺少 " + ",".join(missing)) - _IMPORT_ACCEPT.note( - "S08", - "保存细单", - {"saved": len(table_rows), "msg": f"{len(table_rows)}条货品已保存"}, - ok=True, - ) - result = RebateImportResult(len(table_rows), 0, (), True) - _IMPORT_ACCEPT.note( - "S10", - "读导入结果", - {"success": result.success_count, "fail": result.fail_count}, - ok=True, - ) - return result - finally: - print(_IMPORT_ACCEPT.summary_text(), end="", flush=True) - _IMPORT_ACCEPT.dump() - - -_SUBMIT_TITLES = ("送审", "提交送审", "提交审批") -_SUBMIT_FAIL_MARKS = ("送审失败", "提交失败", "请先导入") -_ALREADY_SUBMIT_MARKS = ("已送审", "已经送审", "不允许送审", "不能送审", "不可重复") -_DETAIL_TAB_TITLES = ("协议细单", "细单", "明细") -_SUBMIT_ID_LABELS = { - "总单号", - "采购协议号", - "协议编号", - "采购协议总单ID", - "协议总单号", - "采购协议ID", - "协议总单ID", - "协议细单ID", - "采购协议细单ID", - "细单号", - "细单ID", - "货源细单ID", -} -_BUSY_MARKS = ("加载中", "查询加载", "设置查询条件", "自定义查询") -_ID_RE = re.compile(r"^[A-Za-z0-9_-]{3,40}$") -_DTL_LABEL_RE = re.compile( - r"(?:采购协议细单ID|协议细单ID)[::\s]*([A-Za-z0-9_-]{3,40})" +from rebate_find import ( + _ACCEPT, + run_find_flow, ) -_SELECT_HEADER_ROW = _SELECT_HEADER_ROW_JS - -_CONFIRM_SUBMIT = """ -/* CONFIRM_SUBMIT */ -const marks = ['确定送审', '确认送审', '是否送审', '确定提交', '确认提交', '是否提交']; -const body = (document.body && document.body.innerText) || ''; -const asked = marks.some((m) => body.indexOf(m) >= 0) - || !!document.querySelector('.el-message-box, .el-dialog__wrapper, .vxe-modal--wrapper, .ant-modal'); -if (!asked) { return {ok: false, asked: false}; } -const btn = Array.from(document.querySelectorAll('button, .el-button, span')).find((el) => { - const t = ((el.innerText || '') + '').replace(/\\s+/g, '').trim(); - return (t === '确定' || t === '确认' || t === '是') && (el.offsetWidth || el.offsetHeight); -}); -if (!btn) { return {ok: false, asked: true}; } -btn.click(); -return {ok: true, asked: true}; -""" - -_READ_AGREEMENT_NOS = """ -/* READ_AGREEMENT_NOS */ -const no = String(arguments[0] || '').trim(); -const headerNames = ['协议总单ID', '采购协议总单ID', '采购协议号', '总单号', '协议编号', '协议总单号', '采购协议ID']; -const detailNames = ['协议细单ID', '采购协议细单ID', '协议明细ID', '细单号', '细单ID', '明细ID']; -function compact(el) { - return ((el && (el.innerText || el.value || el.textContent)) || '').replace(/\\s+/g, ' ').trim(); -} -function isId(t) { - return /^[A-Za-z0-9_-]{3,40}$/.test(t || ''); -} -function fromVxe(names, requireNo) { - const heads = Array.from(document.querySelectorAll('.vxe-header--column')).map((th) => ({ - title: (th.innerText || '').replace(/\\s+/g, '').trim(), - colid: th.getAttribute('colid'), - })); - const rows = Array.from(document.querySelectorAll('.vxe-body--row')); - for (const name of names) { - const h = heads.find((x) => x.title === name); - if (!h || !h.colid) { continue; } - for (const row of rows) { - const text = (row.innerText || ''); - if (requireNo && no && text.indexOf(no) < 0) { continue; } - const c = row.querySelector('[colid="' + h.colid + '"]'); - const t = compact(c); - if (isId(t)) { return t; } - } - if (!requireNo) { - const cells = document.querySelectorAll('.vxe-body--row [colid="' + h.colid + '"]'); - for (const c of cells) { - const t = compact(c); - if (isId(t)) { return t; } - } - } - } - return ''; -} -function fromElTable(names, requireNo) { - const tables = Array.from(document.querySelectorAll('table, .el-table')); - for (const table of tables) { - const ths = Array.from(table.querySelectorAll('th')); - const titles = ths.map((th) => (th.innerText || '').replace(/\\s+/g, '').trim()); - const rows = Array.from(table.querySelectorAll('tbody tr, .el-table__body tr')); - for (const name of names) { - const idx = titles.indexOf(name); - if (idx < 0) { continue; } - for (const row of rows) { - const text = (row.innerText || ''); - if (requireNo && no && text.indexOf(no) < 0) { continue; } - const cells = row.querySelectorAll('td'); - const t = compact(cells[idx]); - if (isId(t)) { return t; } - } - } - } - return ''; -} -function fromTitleContains(needles, skip) { - const heads = Array.from(document.querySelectorAll('.vxe-header--column')).map((th) => ({ - title: (th.innerText || '').replace(/\\s+/g, '').trim(), - colid: th.getAttribute('colid'), - })); - for (const h of heads) { - if (!h.colid) { continue; } - if (skip.some((s) => h.title.indexOf(s) >= 0)) { continue; } - if (!needles.some((n) => h.title.indexOf(n) >= 0)) { continue; } - const cells = document.querySelectorAll('.vxe-body--row [colid="' + h.colid + '"]'); - for (const c of cells) { - const t = compact(c); - if (isId(t)) { return t; } - } - } - return ''; -} -function fromLabel(names) { - for (const label of names) { - const nodes = Array.from(document.querySelectorAll('label, td, th, span')); - const node = nodes.find((el) => { - const raw = (el.childNodes && el.childNodes.length === 1) ? (el.textContent || '') : (el.innerText || ''); - const t = raw.replace(/\\s+/g, '').replace(/[*::]/g, ''); - return t === label && t.length <= 20; - }); - if (!node) { continue; } - let value = ''; - if (node.tagName === 'LABEL' && node.htmlFor) { - value = compact(document.getElementById(node.htmlFor)); - } - const next = node.nextElementSibling; - if (!value && next) { - value = compact(next.querySelector('input, span, a') || next); - } - const cell = node.closest('td, th'); - if (!value && cell && cell.nextElementSibling) { - value = compact(cell.nextElementSibling.querySelector('input, span, a') || cell.nextElementSibling); - } - if (isId(value)) { return value; } - } - return ''; -} -const titles = Array.from(document.querySelectorAll('.vxe-header--column, th')).map((th) => { - return (th.innerText || '').replace(/\\s+/g, '').trim(); -}).filter(Boolean); -const header = fromVxe(headerNames, true) || fromElTable(headerNames, true) || fromLabel(headerNames) - || fromVxe(headerNames, false) || fromElTable(headerNames, false); -const detail = fromVxe(detailNames, false) || fromElTable(detailNames, false) || fromLabel(detailNames) - || fromTitleContains(['细单', '明细'], ['货源', '备注', '日期']); -return {header: header, detail: detail, titles: titles}; -""" - - -def _clean_submit_id(raw: object, *, contract_no: str = "") -> str: - text = _clean_order_no(raw, contract_no=contract_no) - if not text: - return "" - if text in _SUBMIT_ID_LABELS: - return "" - if contract_no and text == contract_no.strip(): - return "" - if any(mark in text for mark in _BUSY_MARKS): - return "" - if not _ID_RE.fullmatch(text): - return "" - return text - - -def _select_or_open_row(browser: BrowserAdapter, contract_no: str) -> None: - _switch_agreement_frame(browser) - if select_header_row(browser, contract_no): - return - selected = None - try: - selected = browser.execute_script(_SELECT_HEADER_ROW, contract_no) - except Exception: - selected = None - if isinstance(selected, dict) and selected.get("ok"): - return - try: - browser.execute_script(_OPEN_HEADER_ROW, contract_no) - except Exception: - pass - - -def _confirm_if_asked(browser: BrowserAdapter) -> None: - result = None - try: - result = browser.execute_script(_CONFIRM_SUBMIT) - except Exception: - result = None - if isinstance(result, dict) and result.get("ok"): - return - if isinstance(result, dict) and result.get("asked"): - for title in ("确定", "确认", "是"): - if _click_text(browser, title): - return - - -def click_rebate_submit(browser: BrowserAdapter) -> str: - _switch_agreement_frame(browser) - for title in _SUBMIT_TITLES: - if _click_header_toolbar(browser, title): - _native_click_header_toolbar(browser, title) - _confirm_if_asked(browser) - return title - for title in _SUBMIT_TITLES: - if _click_toolbar(browser, title): - _confirm_if_asked(browser) - return title - for title in _SUBMIT_TITLES: - if _click_text(browser, title): - _confirm_if_asked(browser) - return title - return "" - - -def _body_has(browser: BrowserAdapter, marks: tuple[str, ...]) -> bool: - body = browser.read_body_text() or "" - return any(mark in body for mark in marks) - - -def _submit_error(browser: BrowserAdapter) -> str: - body = browser.read_body_text() or "" - for mark in _SUBMIT_FAIL_MARKS: - if mark in body: - return mark - return "" - - -def _open_header_view(browser: BrowserAdapter, contract_no: str) -> None: - _switch_agreement_frame(browser) - try: - browser.execute_script(_OPEN_HEADER_ROW, contract_no) - except Exception: - pass - - -def _reveal_detail_tab(browser: BrowserAdapter) -> None: - _switch_agreement_frame(browser) - for title in _DETAIL_TAB_TITLES: - if _click_toolbar(browser, title): - return - try: - clicked = browser.execute_script( - """ - const title = String(arguments[0] || '').replace(/\\s+/g, ''); - const hit = Array.from(document.querySelectorAll( - '[role="tab"], .el-tabs__item, .el-radio-button, li, span, a, div' - )).find((el) => { - const t = ((el.innerText || '') + '').replace(/\\s+/g, '').trim(); - return t === title && (el.offsetWidth || el.offsetHeight); - }); - if (!hit) { return {ok: false}; } - hit.click(); - return {ok: true}; - """, - title, - ) - except Exception: - clicked = None - if isinstance(clicked, dict) and clicked.get("ok"): - return - - -def _scan_agreement_nos(browser: BrowserAdapter, *, contract_no: str = "") -> tuple[str, str, str]: - header = "" - detail = "" - titles = "" - try: - raw = browser.execute_script(_READ_AGREEMENT_NOS, contract_no) - except Exception: - raw = None - if isinstance(raw, dict): - header = _clean_submit_id(raw.get("header"), contract_no=contract_no) - detail = _clean_submit_id(raw.get("detail"), contract_no=contract_no) - titles = ",".join(_cell(x) for x in (raw.get("titles") or []) if _cell(x)) - if not header: - header = _clean_submit_id( - _scan_rebate_order_no(browser, contract_no=contract_no), - contract_no=contract_no, - ) - if not detail: - body = browser.read_body_text() or "" - match = _DTL_LABEL_RE.search(body) - if match: - detail = _clean_submit_id(match.group(1), contract_no=contract_no) - return header, detail, titles - - -def _scroll_grid_end(browser: BrowserAdapter) -> None: - try: - browser.execute_script( - """ - const wraps = Array.from(document.querySelectorAll( - '.vxe-table--body-wrapper, .vxe-table--header-wrapper, .vxe-table--body-inner-wrapper' - )); - wraps.forEach((w) => { w.scrollLeft = w.scrollWidth; }); - return wraps.length; - """ - ) - except Exception: - return - - -def read_agreement_nos(browser: BrowserAdapter, *, contract_no: str = "") -> tuple[str, str, str]: - deadline = time.time() + 15 - last = ("", "", "") - opened = False - revealed = False - scrolled = False - while time.time() <= deadline: - last = _scan_agreement_nos(browser, contract_no=contract_no) - if last[0] and last[1]: - return last - if not scrolled: - _scroll_grid_end(browser) - scrolled = True - elif not opened: - _open_header_view(browser, contract_no) - opened = True - elif last[0] and not last[1] and not revealed: - _reveal_detail_tab(browser) - revealed = True - time.sleep(0.4) - return last - - -def run_submit_flow( - browser: BrowserAdapter, - ctx: RebateFlowContext, - contract_no: str, -) -> RebateSubmitResult: - _SUBMIT_ACCEPT.reset() - result = RebateSubmitResult(False) - try: - open_rebate_center(browser, ctx) - _SUBMIT_ACCEPT.note("S02", "打开返利中心", {"url": ctx.rebate_url}, ok=True) - try: - login_rebate_center(browser, ctx) - _SUBMIT_ACCEPT.note("S03", "登录", {"user": ctx.username}, ok=True) - except Exception as exc: - evidence_shot(browser, "rebate-submit-login-failed", failed=True) - _SUBMIT_ACCEPT.note( - "S03", - "登录", - {"user": ctx.username, "error": exc}, - ok=False, - raise_error=False, - ) - raise - enter_purchase_agreement(browser) - _SUBMIT_ACCEPT.note("S04", "进入采购协议", {"menu": "采购协议管理"}, ok=True) - found = search_contract(browser, contract_no) - _SUBMIT_ACCEPT.note( - "S05", - "查号", - {"contractNo": contract_no, "found": "yes" if found else "no"}, - ok=found, - ) - _close_header_query(browser) - _select_or_open_row(browser, contract_no) - _SUBMIT_ACCEPT.note("S06", "选总单", {"contractNo": contract_no}, ok=True) - before_header, before_detail, _ = _scan_agreement_nos(browser, contract_no=contract_no) - clicked = click_rebate_submit(browser) - error = _submit_error(browser) if clicked else "未点到送审" - already = _body_has(browser, _ALREADY_SUBMIT_MARKS) - submitted = bool(clicked and not error) or already - facts: dict[str, object] = { - "submitted": "yes" if submitted else "no", - "via": clicked or ("已送审" if already else ""), - } - if error and not already: - facts["error"] = error - _SUBMIT_ACCEPT.note("S07", "点送审", facts, ok=submitted) - header, detail, titles = read_agreement_nos(browser, contract_no=contract_no) - header = header or before_header - detail = detail or before_detail - read_facts: dict[str, object] = {"rebateOrderNo": header, "agreementDtlId": detail} - if not detail: - cols = [t for t in titles.split(",") if "细单" in t or "明细" in t] - if cols: - read_facts["detailCols"] = ",".join(cols[:8]) - _SUBMIT_ACCEPT.note( - "S08", - "读号", - read_facts, - ok=bool(header), - ) - result = RebateSubmitResult(submitted, header, detail) - return result - finally: - print(_SUBMIT_ACCEPT.summary_text(), end="", flush=True) - _SUBMIT_ACCEPT.dump() +from rebate_header import ( + _HEADER_ACCEPT, + HEADER, + SAVE, + ORDER_NO, + _FORM_MARKERS, + _ORDER_NO_LABEL_RE, + _FL_RE, + _SET_LABELED, + _READ_REBATE_ORDER_NO, + _OPEN_DROPDOWN, + build_header_draft, + header_draft_from_env, + header_draft_from_table3, + header_draft_for_contract, + _header_form_visible, + open_header_form, + _fill_select, + _fill_input, + _set_grid_field, + _fill_header_code, + _activate_header_cell, + _header_card_open, + _confirm_header_card, + _read_required_titles, + _pick_supplier_by_mdm, + _fill_pay_param, + _fill_header_card, + _wait_header_card, + fill_rebate_header, + save_rebate_header, + read_rebate_order_no, + run_header_flow, +) +from rebate_import import ( + _IMPORT_ACCEPT, + import_lines_from_table3, + import_draft_for_contract, + IMPORT, + IMPORT_HEADERS, + _HEADER_ALIASES, + _DOWNLOAD_TITLES, + _SUCCESS_RE, + _FAIL_RE, + _READ_IMPORT_RESULT, + goods_search_id, + build_import_rows, + build_import_draft, + _line_from_mapping, + import_draft_from_env, + write_canonical_template, + _header_titles, + _header_row_index, + _col_map, + fill_import_excel, + _wait_local_download, + download_detail_template, + upload_detail_file, + _scan_import_result, + _goods_add_visible, + _wait_goods_add_button, + _click_add_detail, + _click_goods_toolbar, + _native_click_goods_toolbar, + _confirm_save_popup, + _goods_id_field_visible, + _open_goods_id_query, + _wait_goods_id_field, + _open_goods_picker, + _wait_goods_picker, + _pick_goods_id_in_dialog, + _detail_card_pager, + _goods_table_rows, + _wait_goods_row, + _confirm_detail_card, + _type_detail_field, + _fill_last_detail_qty_price, + add_detail_lines_by_goods_id, + read_import_result, + run_import_flow, +) +from rebate_submit import ( + _SUBMIT_ACCEPT, + _SUBMIT_TITLES, + _SUBMIT_FAIL_MARKS, + _ALREADY_SUBMIT_MARKS, + _DETAIL_TAB_TITLES, + _SUBMIT_ID_LABELS, + _BUSY_MARKS, + _ID_RE, + _DTL_LABEL_RE, + _CONFIRM_SUBMIT, + _READ_AGREEMENT_NOS, + _clean_submit_id, + _select_or_open_row, + _confirm_if_asked, + click_rebate_submit, + _body_has, + _submit_error, + _open_header_view, + _reveal_detail_tab, + _scan_agreement_nos, + _scroll_grid_end, + read_agreement_nos, + run_submit_flow, +) diff --git a/rpa_rebate/rebate_header.py b/rpa_rebate/rebate_header.py new file mode 100644 index 0000000..1f5f354 --- /dev/null +++ b/rpa_rebate/rebate_header.py @@ -0,0 +1,1059 @@ +# 返利总单节点:本段字段与 run_header_flow。登录/查号/勾行/弹层在 rebate_common。 + +from __future__ import annotations + +import os +import re +import time + +from accept import AcceptanceReport, evidence_shot +from errors import NodeError + +from rebate_common import ( + HEADER_FLOW, + PAY_MODE_NEXT_MONTH_N, + REBATE_CONTRACT_DEFAULT, + SETTLE_MODE_DEFAULT, + PAY_PARAM_DEFAULT, + SETTLE_MODE_WIRE, + PLANNER_DEFAULT, + CONTACT_DEFAULT, + BrowserAdapter, + RebateFlowContext, + RebateHeaderDraft, + open_rebate_center, + login_rebate_center, + _switch_agreement_frame, + enter_purchase_agreement, + search_contract, + _header_dom_js, + _OPEN_HEADER_ROW, + today_iso, + _cell, + _click_toolbar, + _click_header_toolbar, + _native_click_header_toolbar, + _type_active, + _filter_and_pick_dropdown, + _pick_header_lookup, + _open_labeled_picker, + _read_labeled_input, + _picker_bound, + _scan_lookup_overlays, + _click_unit_clear, + _query_overlay_and_pick, + _click_keyword_in_overlays, + _ignore_source_choice, + _stop_if_source_choice, + _pick_person, + _supplier_picker_open, + _scan_rebate_order_no, + select_header_row, + select_header_row_result, + read_header_selection, + _close_header_query, + _pick_detail_option, + _read_detail_toast, + _close_detail_card, +) + +_HEADER_ACCEPT = AcceptanceReport(flow=HEADER_FLOW) + +HEADER = { + "rebate_contract": [ + "label=返利合约", + '//td[contains(.,"返利合约")]/following-sibling::td[1]//select', + '//*[contains(text(),"返利合约")]/following::select[1]', + ], + "supplier_code": [ + "label=供应商编码", + 'input[placeholder*="供应商编码"]', + 'input[name*="supplier"]', + '//td[contains(.,"供应商编码")]/following-sibling::td[1]//input', + ], + "rebate_unit_code": [ + "label=返利单位编码", + 'input[placeholder*="返利单位"]', + 'input[name*="rebateUnit"]', + '//td[contains(.,"返利单位编码")]/following-sibling::td[1]//input', + ], + "planner": [ + "label=计划员", + 'input[placeholder*="计划员"]', + 'input[name*="planner"]', + '//td[contains(.,"计划员")]/following-sibling::td[1]//input', + '//td[contains(.,"计划员")]/following-sibling::td[1]//select', + ], + "settle_mode": [ + "label=采购结算方式", + '//td[contains(.,"采购结算方式")]/following-sibling::td[1]//select', + '//*[contains(text(),"采购结算方式")]/following::select[1]', + ], + "pay_mode": [ + "label=承付模式", + '//td[contains(.,"承付模式")]/following-sibling::td[1]//select', + '//*[contains(text(),"承付模式")]/following::select[1]', + ], + "protocol_start": [ + "label=协议开始日期", + 'input[placeholder*="开始日期"]', + '//td[contains(.,"协议开始日期")]/following-sibling::td[1]//input', + ], + "protocol_end": [ + "label=协议结束日期", + 'input[placeholder*="结束日期"]', + '//td[contains(.,"协议结束日期")]/following-sibling::td[1]//input', + ], +} + +SAVE = { + "button": [ + "text=保存", + "button:contains(保存)", + "a:contains(保存)", + 'input[value="保存"]', + "text=保 存", + ], +} + +ORDER_NO = { + "value": [ + "label=总单号", + "label=采购协议号", + "label=协议编号", + "label=采购协议总单ID", + "label=协议总单号", + 'input[name*="agreementId"]', + 'input[name*="rebateOrder"]', + ], +} + +_FORM_MARKERS = ("保存", "导入导出", "下载模板") + +_ORDER_NO_LABEL_RE = re.compile( + r"(?:总单号|采购协议号|协议编号|采购协议总单ID|协议总单号|采购协议ID)[::\s]*([A-Za-z0-9_-]{3,})" +) + +_FL_RE = re.compile(r"\bFL[A-Za-z0-9_-]{4,}\b") + +_SET_LABELED = """ +/* SET_LABELED */ +const labelText = String(arguments[0] || '').trim(); +const value = String(arguments[1] || ''); +function controlFor(label) { + const nodes = Array.from(document.querySelectorAll('label, td, th, span, div')); + const node = nodes.find((el) => { + const t = (el.childNodes && el.childNodes.length === 1) + ? (el.textContent || '').replace(/\\s+/g, '').trim() + : (el.innerText || '').replace(/\\s+/g, '').trim(); + const compact = t.replace(/[*::]/g, ''); + const wanted = label.replace(/\\s+/g, ''); + return compact === wanted || compact.indexOf(wanted) === 0; + }); + if (!node) { return null; } + if (node.tagName === 'LABEL' && node.htmlFor) { + return document.getElementById(node.htmlFor); + } + const next = node.nextElementSibling; + if (next) { + if (['INPUT', 'SELECT', 'TEXTAREA'].indexOf(next.tagName) >= 0) { return next; } + const inner = next.querySelector('input, select, textarea'); + if (inner) { return inner; } + } + const cell = node.closest('td, th'); + if (cell && cell.nextElementSibling) { + return cell.nextElementSibling.querySelector('input, select, textarea'); + } + return null; +} +const el = controlFor(labelText); +if (!el) { return false; } +if (el.tagName === 'SELECT') { + const opts = Array.from(el.options || []); + const hit = opts.find((o) => { + const t = (o.textContent || o.text || '').replace(/\\s+/g, ''); + const v = String(o.value || '').replace(/\\s+/g, ''); + const wanted = value.replace(/\\s+/g, ''); + return t === wanted || v === wanted || t.indexOf(wanted) >= 0; + }); + if (!hit) { return false; } + el.value = hit.value; + el.dispatchEvent(new Event('change', {bubbles: true})); + return true; +} +const proto = el.tagName === 'TEXTAREA' + ? window.HTMLTextAreaElement.prototype + : window.HTMLInputElement.prototype; +const 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})); +return true; +""" + +_READ_REBATE_ORDER_NO = """ +/* READ_REBATE_ORDER_NO */ +const labels = ['总单号', '采购协议号', '协议编号', '采购协议总单ID', '协议总单号', '采购协议ID']; +function textOf(el) { + return ((el && (el.value || el.textContent || el.innerText)) || '').replace(/\\s+/g, ' ').trim(); +} +for (const label of labels) { + const nodes = Array.from(document.querySelectorAll('label, td, th, span, div')); + const node = nodes.find((el) => { + const t = (el.innerText || '').replace(/\\s+/g, '').replace(/[*::]/g, ''); + return t === label || t.indexOf(label) === 0; + }); + if (!node) { continue; } + let value = ''; + if (node.tagName === 'LABEL' && node.htmlFor) { + value = textOf(document.getElementById(node.htmlFor)); + } + const next = node.nextElementSibling; + if (!value && next) { + const inner = next.querySelector('input, select, textarea, span, a') || next; + value = textOf(inner); + } + const cell = node.closest('td, th'); + if (!value && cell && cell.nextElementSibling) { + const inner = cell.nextElementSibling.querySelector('input, span, a') || cell.nextElementSibling; + value = textOf(inner); + } + if (value && labels.indexOf(value.replace(/[*::]/g, '')) < 0) { return value; } +} +const body = (document.body && document.body.innerText) || ''; +const m = body.match(/(?:总单号|采购协议号|协议编号|采购协议总单ID|协议总单号)[::\\s]*([A-Za-z0-9_-]{3,})/); +return m ? m[1] : ''; +""" + +_OPEN_DROPDOWN = """ +/* OPEN_DROPDOWN */ +const labelText = String(arguments[0] || '').trim(); +const nodes = Array.from(document.querySelectorAll('label, td, th, span, div')); +const node = nodes.find((el) => { + const t = (el.innerText || '').replace(/\\s+/g, '').replace(/[*::]/g, ''); + return t === labelText.replace(/\\s+/g, '') || t.indexOf(labelText.replace(/\\s+/g, '')) === 0; +}); +if (!node) { return false; } +const cell = (node.nextElementSibling) + || (node.closest('td, th') && node.closest('td, th').nextElementSibling) + || node.parentElement; +if (!cell) { return false; } +const wrap = cell.querySelector('.el-select, .ant-select, .fs-wrap, .combo, select') || cell; +wrap.click(); +return true; +""" + +# 拼一份总单草稿。计划员空则廖建琦,协议起止都是当天,返利合约=否。 +def build_header_draft( + *, + planner_name: str, + supplier_code: str, + rebate_unit_code: str, + today: str | None = None, +) -> RebateHeaderDraft: + planner = (planner_name or "").strip() or PLANNER_DEFAULT + day = today_iso(today) + return RebateHeaderDraft( + planner_name=planner, + supplier_code=(supplier_code or "").strip(), + rebate_unit_code=(rebate_unit_code or "").strip(), + rebate_contract=REBATE_CONTRACT_DEFAULT, + settle_mode=SETTLE_MODE_DEFAULT, + pay_mode=PAY_MODE_NEXT_MONTH_N, + protocol_start=day, + protocol_end=day, + contact_name=CONTACT_DEFAULT, + ) + +# 从环境变量读供应商编码和计划员,拼总单草稿。干跑缺表 3 时用。 +def header_draft_from_env(*, today: str | None = None) -> RebateHeaderDraft: + return build_header_draft( + planner_name=os.getenv("REBATE_PLANNER_NAME") or "", + supplier_code=os.getenv("REBATE_SUPPLIER_CODE") or "", + rebate_unit_code=os.getenv("REBATE_UNIT_CODE") or "", + today=today, + ) + +# 总单取表 3 该合同第一行的供应商 MDM。计划员先选廖建琦,联系人苏文辉。 +def header_draft_from_table3( + rows: list[dict] | tuple[dict, ...], + *, + today: str | None = None, +) -> RebateHeaderDraft: + packed = [row for row in rows if isinstance(row, dict)] + if not packed: + raise NodeError("表 3 没有该合同行,不能填总单") + first = packed[0] + supplier = _cell(first.get("supplier_mdm_code") or first.get("supplier_id")) + unit = _cell(first.get("org_code")) + draft = build_header_draft( + planner_name=PLANNER_DEFAULT, + supplier_code=supplier, + rebate_unit_code=unit, + today=today, + ) + return RebateHeaderDraft( + planner_name=PLANNER_DEFAULT, + supplier_code=draft.supplier_code, + rebate_unit_code=draft.rebate_unit_code, + rebate_contract=draft.rebate_contract, + settle_mode=draft.settle_mode, + pay_mode=draft.pay_mode, + protocol_start=draft.protocol_start, + protocol_end=draft.protocol_end, + contact_name=CONTACT_DEFAULT, + supplier_name=_cell(first.get("supplier_name")), + supplier_id=_cell(first.get("supplier_id")), + ) + +# 优先表 3,空缺再退回环境变量。 +def header_draft_for_contract(contract_no: str, *, today: str | None = None) -> RebateHeaderDraft: + number = (contract_no or "").strip() + rows: list[dict] = [] + if number: + try: + from repo import OracleRebateRepo + + rows = OracleRebateRepo.from_env().load_contract_goods(number) + except Exception: + rows = [] + if rows: + return header_draft_from_table3(rows, today=today) + return header_draft_from_env(today=today) + +# 正文是否已出现总单表单标记。 +def _header_form_visible(browser: BrowserAdapter) -> bool: + body = browser.read_body_text() or "" + return any(marker in body for marker in _FORM_MARKERS) + +# 勾中该合同总单后点编辑打开卡片。不要点新增总单。 +def open_header_form(browser: BrowserAdapter, contract_no: str) -> None: + _switch_agreement_frame(browser) + _close_detail_card(browser) + if not select_header_row(browser, contract_no): + result = None + try: + result = browser.execute_script(_OPEN_HEADER_ROW, contract_no) + except Exception: + result = None + opened = result is True or (isinstance(result, dict) and result.get("ok")) + if opened: + time.sleep(0.5) + _click_header_toolbar(browser, "编辑") or _click_toolbar(browser, "编辑") + time.sleep(0.8) + if _header_form_visible(browser): + return + try: + dbl = getattr(browser, "double_click_first", None) + if callable(dbl): + dbl([".vxe-body--row", '.vxe-body--row [colid]', "tr.vxe-body--row"]) + except Exception: + pass + try: + browser.click_first( + [ + f"text={contract_no}", + f'//a[contains(.,"{contract_no}")]', + f'//td[contains(.,"{contract_no}")]', + ] + ) + except Exception: + pass + deadline = time.time() + 15 + while time.time() <= deadline: + if _header_form_visible(browser): + return + for selectors in ( + ["text=编辑"], + ["text=修改"], + ["button:contains(编辑)"], + ["a:contains(编辑)"], + ): + try: + browser.click_first(selectors) + break + except Exception: + continue + time.sleep(0.4) + if not _header_form_visible(browser): + raise RuntimeError("未打开总单表单") + +# 按下拉选择器选中给定文案,点不到再按标签脚本设值。 +def _fill_select(browser: BrowserAdapter, label: str, selectors: list[str], value: str) -> None: + try: + browser.select_first(selectors, value) + return + except Exception: + pass + try: + browser.execute_script(_OPEN_DROPDOWN, label) + browser.click_first( + [ + f"text={value}", + f'//li[contains(.,"{value}")]', + f'//div[contains(@class,"option") and contains(.,"{value}")]', + f'//span[contains(.,"{value}")]', + ] + ) + return + except Exception: + pass + ok = False + try: + ok = bool(browser.execute_script(_SET_LABELED, label, value)) + except Exception: + ok = False + if not ok: + raise RuntimeError(f"未选到 {label}={value}") + +# 按选择器填输入框,填不到再按标签脚本设值。 +def _fill_input(browser: BrowserAdapter, label: str, selectors: list[str], value: str) -> None: + try: + browser.fill_first(selectors, value) + return + except Exception: + pass + try: + browser.select_first(selectors, value) + return + except Exception: + pass + ok = False + try: + ok = bool(browser.execute_script(_SET_LABELED, label, value)) + except Exception: + ok = False + if not ok: + raise RuntimeError(f"未填到 {label}={value}") + +# 双击总单表已勾中行的该列,输入或打开下拉。卡片编辑时一般不用这条。 +def _set_grid_field(browser: BrowserAdapter, title: str, value: str, *, as_select: bool = False) -> bool: + if not value: + return False + try: + clicked = browser.execute_script( + """ + /* SET_HEADER_CELL */ + const name = String(arguments[0] || '').replace(/\\s+/g, ''); + function compact(el) { + return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim(); + } + const root = document.querySelector('.table-show') || document; + const th = Array.from(root.querySelectorAll('.vxe-header--column')).find((el) => compact(el) === name); + if (!th) { return {ok: false}; } + const colid = th.getAttribute('colid'); + const lefts = Array.from(root.querySelectorAll('.vxe-table--fixed-left-wrapper .vxe-body--row')); + let idx = 0; + for (let i = 0; i < lefts.length; i++) { + const icon = lefts[i].querySelector('.vxe-checkbox--icon'); + const cls = String((icon && icon.className) || ''); + if (cls.indexOf('checkbox-checked') >= 0 && cls.indexOf('unchecked') < 0) { + idx = i; + break; + } + } + const mains = Array.from(root.querySelectorAll( + '.vxe-table--main-wrapper .vxe-body--row, .vxe-table--body-wrapper .vxe-body--row' + )); + const row = mains[idx] || mains[0]; + const cell = row && row.querySelector('[colid="' + colid + '"]'); + if (!cell) { return {ok: false}; } + cell.scrollIntoView({block: 'center', inline: 'center'}); + cell.click(); + cell.dispatchEvent(new MouseEvent('dblclick', {bubbles: true, cancelable: true, view: window})); + return {ok: true, colid: colid, idx: idx}; + """, + title, + ) + except Exception: + clicked = None + if not (isinstance(clicked, dict) and clicked.get("ok")): + return False + time.sleep(0.3) + if as_select: + return _filter_and_pick_dropdown(browser, value) + try: + typed = browser.execute_script( + """ + /* TYPE_HEADER_CELL */ + const name = String(arguments[0] || '').replace(/\\s+/g, ''); + const value = String(arguments[1] || ''); + function compact(el) { + return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim(); + } + const root = document.querySelector('.table-show') || document; + const th = Array.from(root.querySelectorAll('.vxe-header--column')).find((el) => compact(el) === name); + if (!th) { return {ok: false}; } + const cell = root.querySelector( + '.vxe-table--main-wrapper .vxe-body--row [colid="' + th.getAttribute('colid') + '"], .vxe-body--row [colid="' + th.getAttribute('colid') + '"]' + ); + if (!cell) { return {ok: false}; } + const el = cell.querySelector('input, textarea') || document.activeElement; + if (!el || !el.tagName) { return {ok: false}; } + el.focus(); + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; + setter.call(el, value); + el.dispatchEvent(new Event('input', {bubbles: true})); + el.dispatchEvent(new Event('change', {bubbles: true})); + return {ok: true, value: el.value}; + """, + title, + value, + ) + except Exception: + typed = None + if isinstance(typed, dict) and typed.get("ok"): + _type_active(browser, value) + return True + return False + +# 往总单表格该列填编码并打开编码弹窗勾中。 +def _fill_header_code(browser: BrowserAdapter, title: str, code: str) -> None: + if not code: + return + _set_grid_field(browser, title, code) + _pick_header_lookup(browser, code) + +# 点总单表已勾中行的该列,让单元格进入编辑。 +def _activate_header_cell(browser: BrowserAdapter, title: str) -> bool: + if not title: + return False + try: + clicked = browser.execute_script( + """ + /* SET_HEADER_CELL */ + const name = String(arguments[0] || '').replace(/\\s+/g, ''); + function compact(el) { + return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim(); + } + const root = document.querySelector('.table-show') || document; + const th = Array.from(root.querySelectorAll('.vxe-header--column')).find((el) => compact(el) === name); + if (!th) { return {ok: false}; } + const colid = th.getAttribute('colid'); + const lefts = Array.from(root.querySelectorAll('.vxe-table--fixed-left-wrapper .vxe-body--row')); + let idx = 0; + for (let i = 0; i < lefts.length; i++) { + const icon = lefts[i].querySelector('.vxe-checkbox--icon'); + const cls = String((icon && icon.className) || ''); + if (cls.indexOf('checkbox-checked') >= 0 && cls.indexOf('unchecked') < 0) { + idx = i; + break; + } + } + const mains = Array.from(root.querySelectorAll( + '.vxe-table--main-wrapper .vxe-body--row, .vxe-table--body-wrapper .vxe-body--row' + )); + const row = mains[idx] || mains[0]; + const cell = row && row.querySelector('[colid="' + colid + '"]'); + if (!cell) { return {ok: false}; } + cell.scrollIntoView({block: 'center', inline: 'center'}); + cell.click(); + cell.dispatchEvent(new MouseEvent('dblclick', {bubbles: true, cancelable: true, view: window})); + return {ok: true, colid: colid, idx: idx}; + """, + title, + ) + except Exception: + clicked = None + return bool(isinstance(clicked, dict) and clicked.get("ok")) + +# 总单卡片是否处于编辑(能看见供应商编码、确认 Ctrl)。 +def _header_card_open(browser: BrowserAdapter) -> bool: + try: + hit = browser.execute_script( + """ + /* HEADER_CARD */ + """ + + _header_dom_js() + + """ + const root = document.querySelector('.table-show') || document.body; + const t = compact(root); + if (t.indexOf('确认 Ctrl') >= 0 || t.indexOf('请填写供应商') >= 0 + || t.indexOf('状态 编辑') >= 0 || t.indexOf('状态编辑') >= 0) { + return true; + } + return !!formItem('供应商编码'); + """ + ) + except Exception: + hit = False + return hit is True + +# 总单卡片「确认 Ctrl + Enter」,不要点货品卡片确认。 +def _confirm_header_card(browser: BrowserAdapter) -> bool: + clicked = browser.execute_script( + """ + /* CONFIRM_HEADER_CARD */ + function compact(el) { + return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); + } + function vis(el) { + return !!(el && (el.offsetWidth || el.offsetHeight)); + } + const card = Array.from(document.querySelectorAll('.table-card')).find((el) => { + const t = compact(el); + return vis(el) && t.indexOf('供应商编码') >= 0 && t.indexOf('货品编码') < 0; + }); + const root = card || document.querySelector('.table-show') || document; + const hit = Array.from(root.querySelectorAll('.card-header-btn, button, .elPlus-button')).find((el) => { + if (!vis(el)) return false; + const t = compact(el).replace(/\\s+/g, ''); + return t.indexOf('确认Ctrl') === 0 || t === '确认' || t.indexOf('确认') === 0; + }); + if (!hit) { return {ok: false, error: 'no-confirm'}; } + hit.scrollIntoView({block: 'center', inline: 'nearest'}); + hit.click(); + return {ok: true, via: compact(hit).slice(0, 40)}; + """ + ) + driver = getattr(browser, "_driver", None) + if driver is not None: + try: + from selenium.webdriver.common.action_chains import ActionChains + + for el in driver.find_elements("css selector", ".table-card .card-header-btn"): + info = driver.execute_script( + """ + const el = arguments[0]; + const card = el.closest('.table-card'); + const t = ((card && card.innerText) || ''); + return { + text: ((el.innerText || '') + '').replace(/\\s+/g, ' ').trim(), + header: t.indexOf('供应商编码') >= 0 && t.indexOf('货品编码') < 0 + }; + """, + el, + ) + if not (isinstance(info, dict) and info.get("header")): + continue + raw = str(info.get("text") or "") + if "确认" not in raw.replace(" ", ""): + continue + driver.execute_script( + "arguments[0].scrollIntoView({block:'center', inline:'nearest'});", + el, + ) + time.sleep(0.2) + ActionChains(driver).move_to_element(el).pause(0.15).click().perform() + clicked = {"ok": True, "via": "native:" + raw[:40]} + break + except Exception as exc: + print("native_header_confirm_err=", exc, flush=True) + print("confirm_header=", clicked, flush=True) + if isinstance(clicked, dict) and clicked.get("ok"): + time.sleep(0.5) + err = _read_detail_toast(browser) + if err: + raise NodeError(err) + return True + return False + +# 列出总单卡片当前带星号的字段标题。 +def _read_required_titles(browser: BrowserAdapter) -> list[str]: + try: + result = browser.execute_script( + """ + /* REQUIRED_TITLES */ + """ + + _header_dom_js() + + """ + const titles = []; + const items = Array.from(document.querySelectorAll('.elPlus-form-item, .el-form-item')).filter(vis); + for (const item of items) { + const star = Array.from(item.querySelectorAll('*')).some((el) => compact(el) === '*'); + const required = star || String(item.className || '').indexOf('is-required') >= 0 + || !!(item.querySelector('.form-item__title.required')); + if (!required) continue; + const label = item.querySelector('.form-item__title, .elPlus-form-item__label, label, .label-wrap'); + const t = compact(label || item).replace(/\\*/g, '').replace(/\\s+/g, ''); + if (t && titles.indexOf(t) < 0) titles.push(t); + } + return {titles: titles}; + """ + ) + except Exception: + result = None + if isinstance(result, dict): + return [str(t) for t in (result.get("titles") or []) if t] + return [] + +# 用表 3 supplier_mdm_code 在供应商选择层搜编码并确认。 +# +# 选中后名称、返利单位会带出,不要手填。选对时不应弹出货源选择。 +def _pick_supplier_by_mdm(browser: BrowserAdapter, code: str, name: str = "") -> None: + value = (code or "").strip() + wanted_name = (name or "").strip() + if not value and not wanted_name: + raise NodeError("表 3 没有 supplier_mdm_code,不能选供应商") + if _picker_bound(browser, "供应商编码", value): + return + _stop_if_source_choice(browser) + opened = _supplier_picker_open(browser) + if opened: + print("lookup_overlays=", _scan_lookup_overlays(browser), "already_open=1", flush=True) + else: + _close_header_query(browser) + clicked = _open_labeled_picker(browser, "供应商编码", dots_only=True) + print("open_picker=", clicked, flush=True) + deadline = time.time() + 8 + while time.time() <= deadline: + _stop_if_source_choice(browser) + overlays = _scan_lookup_overlays(browser) + print("lookup_overlays=", overlays, flush=True) + if _supplier_picker_open(browser): + opened = True + break + time.sleep(0.4) + if not opened: + raise NodeError(f"未打开返利单位选择弹窗 overlays={_scan_lookup_overlays(browser)}") + ok = False + if wanted_name: + ok = _query_overlay_and_pick(browser, "供应商名称", wanted_name) + if not ok and value: + print("unit_query_name_empty, try_code=", value, flush=True) + _click_unit_clear(browser) + time.sleep(0.3) + ok = _query_overlay_and_pick(browser, "供应商编码", value) + if not ok and value: + ok = _click_keyword_in_overlays(browser, value) + if not ok and wanted_name: + ok = _click_keyword_in_overlays(browser, wanted_name) + if not _picker_bound(browser, "供应商编码", value): + raise NodeError( + f"未选到供应商编码={value} 名称={wanted_name},当前={_read_labeled_input(browser, '供应商编码')}" + ) + +# 往承付参数数字框键盘填 25。不是数字的值直接跳过,不要填「以销售定结」。 +def _fill_pay_param(browser: BrowserAdapter, title: str, value: str) -> None: + wanted = (value or "").strip() + if not wanted: + return + if not re.fullmatch(r"\d+(\.\d+)?", wanted): + print("skip_pay_param_not_number=", wanted, flush=True) + return + print("fill_pay_param=", wanted, flush=True) + marked = browser.execute_script( + """ + /* FOCUS_PAY_PARAM */ + """ + + _header_dom_js() + + """ + const item = formItem(arguments[0]); + const input = item && item.querySelector('input.vxe-input--inner, input'); + if (!input) { return false; } + input.setAttribute('data-rpa-pay', '1'); + input.click(); + input.focus(); + return true; + """, + title, + ) + driver = getattr(browser, "_driver", None) + if marked and driver is not None: + try: + from selenium.webdriver.common.action_chains import ActionChains + from selenium.webdriver.common.keys import Keys + + el = driver.find_element("css selector", '[data-rpa-pay="1"]') + driver.execute_script("arguments[0].removeAttribute('data-rpa-pay')", el) + ActionChains(driver).click(el).pause(0.1).key_down(Keys.CONTROL).send_keys("a").key_up( + Keys.CONTROL + ).send_keys(Keys.BACKSPACE).send_keys(wanted).send_keys(Keys.TAB).perform() + time.sleep(0.2) + except Exception as exc: + print("pay_param_type_err=", exc, flush=True) + now = _read_labeled_input(browser, title) + print("pay_param_now=", now, flush=True) + if (now or "").strip() == wanted: + return + if not _pick_detail_option(browser, title, wanted): + try: + _fill_input(browser, title, [f"label={title}"], wanted) + except Exception: + _set_grid_field(browser, title, wanted) + print("pay_param_after=", _read_labeled_input(browser, title), flush=True) + +# 按现行字段填总单卡片:供应商三个点、计划员廖建琦、联系人苏文辉、电汇、下月N号结、承付参数 25、协议起止当天。 +def _fill_header_card(browser: BrowserAdapter, draft: RebateHeaderDraft) -> None: + _ignore_source_choice(browser) + titles = _read_required_titles(browser) + print("required=", titles, flush=True) + settle = draft.settle_mode if draft.settle_mode and draft.settle_mode != SETTLE_MODE_DEFAULT else SETTLE_MODE_WIRE + planner = (draft.planner_name or "").strip() or PLANNER_DEFAULT + contact = (draft.contact_name or "").strip() or CONTACT_DEFAULT + values = { + "供应商编码": draft.supplier_code, + "计划员": planner, + "返利合约": draft.rebate_contract, + "采购结算方式": settle, + "承付模式": draft.pay_mode, + "承付参数": PAY_PARAM_DEFAULT, + "协议开始日期": draft.protocol_start, + "协议结束日期": draft.protocol_end, + "联系人": contact, + } + selects = {"返利合约", "采购结算方式", "承付模式"} + order = [ + "供应商编码", + "计划员", + "联系人", + "返利合约", + "采购结算方式", + "承付模式", + "协议开始日期", + "协议结束日期", + "承付参数", + ] + skip = {"供应商名称", "返利单位编码", "返利单位"} + raw = titles or list(values.keys()) + needed = list(order) + [title for title in raw if title not in order and title not in skip] + for title in needed: + if title in skip: + continue + value = values.get(title, "") + if not value: + continue + if title == "供应商编码": + _pick_supplier_by_mdm(browser, value, name=draft.supplier_name) + elif title == "计划员": + _pick_person(browser, "计划员", value, supplier_name=draft.supplier_name) + elif title == "联系人": + _pick_person(browser, "联系人", value, nth=1) + elif title == "承付参数": + _fill_pay_param(browser, title, value) + elif title in selects: + if not _pick_detail_option(browser, title, value): + if title == "采购结算方式": + _pick_detail_option(browser, title, SETTLE_MODE_WIRE) + else: + try: + _fill_input(browser, title, [f"label={title}"], value) + except Exception: + _set_grid_field(browser, title, value) + print( + "card_values=", + { + "supplier": _read_labeled_input(browser, "供应商编码"), + "supplierName": _read_labeled_input(browser, "供应商名称"), + "unit": _read_labeled_input(browser, "返利单位编码"), + "planner": _read_labeled_input(browser, "计划员"), + "contact": _read_labeled_input(browser, "联系人"), + "payParam": _read_labeled_input(browser, "承付参数"), + }, + flush=True, + ) + if draft.protocol_start: + try: + _fill_input(browser, "协议开始日期", HEADER["protocol_start"], draft.protocol_start) + except Exception: + _set_grid_field(browser, "协议开始日期", draft.protocol_start) + if draft.protocol_end: + try: + _fill_input(browser, "协议结束日期", HEADER["protocol_end"], draft.protocol_end) + except Exception: + _set_grid_field(browser, "协议结束日期", draft.protocol_end) + _fill_pay_param(browser, "承付参数", PAY_PARAM_DEFAULT) + print( + "card_values_before_save=", + { + "planner": _read_labeled_input(browser, "计划员"), + "payParam": _read_labeled_input(browser, "承付参数"), + "supplier": _read_labeled_input(browser, "供应商名称"), + }, + flush=True, + ) + if (_read_labeled_input(browser, "承付参数") or "").strip() != PAY_PARAM_DEFAULT: + raise NodeError("总单承付参数未填成25,不能保存") + +# 等到总单卡片打开。超时返回 False。 +def _wait_header_card(browser: BrowserAdapter, timeout: float = 8, *, contract_no: str = "") -> bool: + if _header_card_open(browser): + return True + if getattr(browser, "_driver", None) is None: + return False + deadline = time.time() + timeout + while time.time() <= deadline: + if contract_no: + select_header_row(browser, contract_no) + _click_header_toolbar(browser, "编辑") or _click_toolbar(browser, "编辑") + time.sleep(0.4) + if _header_card_open(browser): + return True + return False + +# 打开已有总单卡片并填必填。审批通过后已有一条,不要新增。 +def fill_rebate_header(browser: BrowserAdapter, draft: RebateHeaderDraft, *, contract_no: str = "") -> None: + _switch_agreement_frame(browser) + _ignore_source_choice(browser) + if _wait_header_card(browser, contract_no=contract_no): + print("header_card=open", flush=True) + _fill_header_card(browser, draft) + if not _picker_bound(browser, "供应商编码", draft.supplier_code): + raise NodeError("总单供应商仍为空") + return + grid = _activate_header_cell(browser, "供应商编码") or _activate_header_cell(browser, "承付模式") + if grid: + _set_grid_field(browser, "承付模式", draft.pay_mode, as_select=True) + _set_grid_field(browser, "返利合约", draft.rebate_contract, as_select=True) + if not _set_grid_field(browser, "采购结算方式", draft.settle_mode, as_select=True): + _set_grid_field(browser, "采购结算方式", SETTLE_MODE_WIRE, as_select=True) + _set_grid_field(browser, "承付参数", PAY_PARAM_DEFAULT) + _fill_header_code(browser, "供应商编码", draft.supplier_code) + _fill_header_code(browser, "计划员", draft.planner_name or PLANNER_DEFAULT) + _fill_header_code(browser, "联系人", draft.contact_name or CONTACT_DEFAULT) + _set_grid_field(browser, "协议开始日期", draft.protocol_start) + _set_grid_field(browser, "协议结束日期", draft.protocol_end) + return + _fill_select(browser, "返利合约", HEADER["rebate_contract"], draft.rebate_contract) + if draft.supplier_code: + _fill_input(browser, "供应商编码", HEADER["supplier_code"], draft.supplier_code) + _pick_header_lookup(browser, draft.supplier_code) + _fill_input(browser, "计划员", HEADER["planner"], draft.planner_name or PLANNER_DEFAULT) + try: + _fill_select(browser, "采购结算方式", HEADER["settle_mode"], draft.settle_mode) + except Exception: + _fill_select(browser, "采购结算方式", HEADER["settle_mode"], SETTLE_MODE_WIRE) + _fill_select(browser, "承付模式", HEADER["pay_mode"], draft.pay_mode) + _fill_input(browser, "协议开始日期", HEADER["protocol_start"], draft.protocol_start) + _fill_input(browser, "协议结束日期", HEADER["protocol_end"], draft.protocol_end) + +# 卡片仍在编辑时点总单工具栏保存,再点卡片确认。 +# +# 先确认会把卡收回表格,保存落不到库。成功返回协议总单ID。 +def save_rebate_header(browser: BrowserAdapter, *, contract_no: str = "") -> str: + if _header_card_open(browser) and not _picker_bound(browser, "供应商编码", ""): + raise NodeError("总单供应商为空,不能保存") + if not _native_click_header_toolbar(browser, "保存"): + if not _click_header_toolbar(browser, "保存"): + if not _click_toolbar(browser, "保存"): + browser.click_first(SAVE["button"]) + time.sleep(0.5) + err = _read_detail_toast(browser) + if err: + raise NodeError(err) + deadline = time.time() + 15 + last = "" + while time.time() <= deadline: + last = _scan_rebate_order_no(browser, contract_no=contract_no) + body = browser.read_body_text() or "" + if last or "保存成功" in body: + return last + time.sleep(0.4) + return last + +# 读当前总单的协议总单ID。 +def read_rebate_order_no(browser: BrowserAdapter, *, contract_no: str = "") -> str: + deadline = time.time() + 15 + last = "" + while time.time() <= deadline: + last = _scan_rebate_order_no(browser, contract_no=contract_no) + if last: + return last + time.sleep(0.4) + return last + +# 查号 → 勾已有总单 → 补必填 → 保存拿协议总单ID。 +# +# 表上已有协议总单ID则复用,不再走新建。写 ACCEPT SUMMARY REBATE_HEADER。 +def run_header_flow( + browser: BrowserAdapter, + ctx: RebateFlowContext, + contract_no: str, + draft: RebateHeaderDraft | None = None, +) -> str: + _HEADER_ACCEPT.reset() + header = draft or header_draft_for_contract(contract_no) + try: + open_rebate_center(browser, ctx) + _HEADER_ACCEPT.note("S02", "打开返利中心", {"url": ctx.rebate_url}, ok=True) + try: + login_rebate_center(browser, ctx) + _HEADER_ACCEPT.note("S03", "登录", {"user": ctx.username}, ok=True) + except Exception as exc: + evidence_shot(browser, "rebate-header-login-failed", failed=True) + _HEADER_ACCEPT.note( + "S03", + "登录", + {"user": ctx.username, "error": exc}, + ok=False, + raise_error=False, + ) + raise + enter_purchase_agreement(browser) + _HEADER_ACCEPT.note("S04", "进入采购协议", {"menu": "采购协议管理"}, ok=True) + found = search_contract(browser, contract_no) + if not found: + href = "" + try: + href = str(browser.execute_script("return location.href") or "") + except Exception: + href = "" + if "ssoLogin" in href or "login" in href.lower() or "wel/index" in href: + login_rebate_center(browser, ctx) + enter_purchase_agreement(browser) + found = search_contract(browser, contract_no) + if not found: + print("header_selection=", read_header_selection(browser, contract_no), flush=True) + _HEADER_ACCEPT.note( + "S05", + "查号", + {"contractNo": contract_no, "found": "yes" if found else "no"}, + ok=found, + ) + if not found: + raise NodeError("查号未搜到合同,不能填总单") + _close_header_query(browser) + picked = select_header_row_result(browser, contract_no) + if not (isinstance(picked, dict) and picked.get("ok")): + raise NodeError("未按合同编号勾中总单行") + # 勾已有总单后走编辑补必填,禁止点新增。表上已有协议总单ID则回传该号。 + existing = _scan_rebate_order_no(browser, contract_no=contract_no) + if existing == contract_no: + existing = "" + open_header_form(browser, contract_no) + _HEADER_ACCEPT.note( + "S06", + "打开总单", + {"contractNo": contract_no, "reused": "yes" if existing else "no"}, + ok=True, + ) + fill_rebate_header(browser, header, contract_no=contract_no) + _HEADER_ACCEPT.note( + "S07", + "填总单", + { + "planner": header.planner_name, + "payMode": header.pay_mode, + "protocolEnd": header.protocol_end, + "rebateContract": header.rebate_contract, + }, + ok=True, + ) + pay_before = _read_labeled_input(browser, "承付参数") + planner_before = _read_labeled_input(browser, "计划员") + print("before_save pay_param=", pay_before, "planner=", planner_before, flush=True) + saved = save_rebate_header(browser, contract_no=contract_no) + _HEADER_ACCEPT.note( + "S08", + "保存", + {"saved": "yes", "payParam": pay_before}, + ok=(pay_before or "").strip() == PAY_PARAM_DEFAULT, + ) + _confirm_header_card(browser) + order_no = saved or existing or read_rebate_order_no(browser, contract_no=contract_no) + _HEADER_ACCEPT.note( + "S09", + "读总单号", + { + "rebateOrderNo": order_no, + "payParam": pay_before, + "reused": "yes" if existing else "no", + }, + ok=True, + raise_error=False, + ) + return order_no or contract_no + finally: + print(_HEADER_ACCEPT.summary_text(), end="", flush=True) + _HEADER_ACCEPT.dump() diff --git a/rpa_rebate/rebate_import.py b/rpa_rebate/rebate_import.py new file mode 100644 index 0000000..a269235 --- /dev/null +++ b/rpa_rebate/rebate_import.py @@ -0,0 +1,1269 @@ +# 返利细单节点:本段字段与 run_import_flow。登录/查号/勾行/弹层在 rebate_common。 + +from __future__ import annotations + +import json +import os +import re +import shutil +import time +from pathlib import Path + +from accept import AcceptanceReport, artifact_dir, evidence_shot +from errors import NodeError + +from rebate_common import ( + IMPORT_FLOW, + PAY_MODE_NEXT_MONTH_N, + TAX_RATE_DEFAULT, + PAY_MODE_CODE, + PAY_PARAM_DEFAULT, + SETTLE_CODE, + SETTLE_MODE_WIRE, + COMPENSATION_DEFAULT, + BrowserAdapter, + RebateFlowContext, + RebateImportLine, + RebateImportDraft, + RebateImportResult, + open_rebate_center, + login_rebate_center, + _switch_agreement_frame, + enter_purchase_agreement, + search_contract, + _header_dom_js, + today_iso, + year_end_iso, + _cell, + _tax_rate_of, + _untaxed_price, + _click_text, + select_header_row_result, + _close_header_query, + _pick_detail_option, + _read_detail_toast, + _close_header_card, + _close_detail_card, +) + +_IMPORT_ACCEPT = AcceptanceReport(flow=IMPORT_FLOW) + +# 细单按表 3 货品行:货品ID、数量、含税单价、无税=含税/(1+税率)、备注=omsId。 +def import_lines_from_table3(rows: list[dict] | tuple[dict, ...]) -> tuple[RebateImportLine, ...]: + lines: list[RebateImportLine] = [] + for row in rows: + if not isinstance(row, dict): + continue + goods_id = _cell(row.get("goods_id")) + if not goods_id: + continue + tax_price = _cell(row.get("unit_price") or row.get("tax_price")) + tax_rate = _tax_rate_of(row.get("purchase_tax_rate") or row.get("tax_rate")) + lines.append( + RebateImportLine( + goods_code=goods_id, + qty=_cell(row.get("qty")), + tax_price=tax_price, + order_no=_cell(row.get("oms_id") or row.get("order_no")), + source_line_id=_cell(row.get("goods_source_id") or row.get("source_line_id")), + goods_id=goods_id, + tax_rate=tax_rate, + net_price=_untaxed_price(tax_price, tax_rate), + ) + ) + if not lines: + raise NodeError("表 3 没有货品行,不能加细单") + return tuple(lines) + +# 优先表 3 货品行,空缺再退回环境变量。 +def import_draft_for_contract(contract_no: str, *, today: str | None = None) -> RebateImportDraft: + number = (contract_no or "").strip() + rows: list[dict] = [] + if number: + try: + from repo import OracleRebateRepo + + rows = OracleRebateRepo.from_env().load_contract_goods(number) + except Exception: + rows = [] + if rows: + return build_import_draft(lines=import_lines_from_table3(rows), today=today) + return import_draft_from_env(today=today) + +IMPORT = { + "menu": [ + "text=导入导出", + "button:contains(导入导出)", + "span:contains(导入导出)", + "a:contains(导入导出)", + ], + "download": [ + "text=下载模板", + "text=下载导入模板", + "text=导出模板", + "text=导入模板", + "span:contains(下载模板)", + "a:contains(下载模板)", + "button:contains(下载模板)", + ], + "upload_item": [ + "text=导入", + "span:contains(导入)", + "button:contains(导入)", + "a:contains(导入)", + ], + "file": [ + 'input[type="file"]', + 'input[accept*="excel"]', + 'input[accept*=".xls"]', + 'input[accept*="sheet"]', + ], + "confirm": [ + "text=确定", + "button:contains(确定)", + "text=导入", + "button:contains(导入)", + ], +} + +IMPORT_HEADERS = ( + "货品编码", + "数量", + "含税协议单价", + "税率", + "关税税率", + "承付模式", + "承付参数", + "细单开始日期", + "细单结束日期", + "采购结算方式", + "特定补偿预估值", + "进货补偿预估值", + "细单备注", + "货源细单ID", +) + +_HEADER_ALIASES = { + "货品编码": ("货品ID", "货品编码", "操作码", "商品编码"), + "数量": ("数量",), + "含税协议单价": ("含税协议单价", "单价", "协议单价"), + "税率": ("税率",), + "关税税率": ("关税税率",), + "承付模式": ("承付模式", "承付方式"), + "承付参数": ("承付参数", "参数"), + "细单开始日期": ("细单开始日期", "细单开始时间", "开始日期"), + "细单结束日期": ("细单结束日期", "失效日期", "结束日期"), + "采购结算方式": ("采购结算方式", "结算方式"), + "特定补偿预估值": ("特定补偿预估值", "特定补偿"), + "进货补偿预估值": ("进货补偿预估值", "进货补偿"), + "细单备注": ("细单备注", "备注"), + "货源细单ID": ("货源细单ID", "货源细单号", "货源信息"), +} + +_DOWNLOAD_TITLES = ("下载模板", "下载导入模板", "导出模板", "导入模板", "模板下载") + +_SUCCESS_RE = re.compile(r"成功[^0-9]{0,10}(\d+)") + +_FAIL_RE = re.compile(r"失败[^0-9]{0,10}(\d+)") + +_READ_IMPORT_RESULT = """ +/* READ_IMPORT_RESULT */ +const body = (document.body && document.body.innerText) || ''; +function num(re) { + const m = body.match(re); + return m ? Number(m[1]) : 0; +} +const reasons = Array.from(document.querySelectorAll( + '.el-message, .el-notification, .el-dialog, .vxe-body--row, tr, .ant-message' +)).map((el) => (el.innerText || '').replace(/\\s+/g, ' ').trim()).filter((t) => { + return t && (t.indexOf('失败') >= 0 || t.indexOf('重复') >= 0 || t.indexOf('错误') >= 0); +}).slice(0, 10); +return { + success: num(/成功[^0-9]{0,10}(\\d+)/), + fail: num(/失败[^0-9]{0,10}(\\d+)/), + reasons, + body: body.slice(0, 800), +}; +""" + +# 返利中心增加细单按货品ID搜品种,用接口 3 的 goodsId。 +def goods_search_id(line: RebateImportLine) -> str: + return _cell(line.goods_id) or _cell(line.goods_code) + +# 把细单行收成旧 Excel 列字典。班次不走这条,页面走新增。 +def build_import_rows( + lines: list[RebateImportLine] | tuple[RebateImportLine, ...], + *, + today: str | None = None, +) -> list[dict[str, str]]: + day = today_iso(today) + end = year_end_iso(day) + rows: list[dict[str, str]] = [] + for line in lines: + rows.append( + { + "货品编码": goods_search_id(line), + "数量": _cell(line.qty), + "含税协议单价": _cell(line.tax_price), + "税率": TAX_RATE_DEFAULT, + "关税税率": TAX_RATE_DEFAULT, + "承付模式": PAY_MODE_CODE, + "承付参数": PAY_PARAM_DEFAULT, + "细单开始日期": day, + "细单结束日期": end, + "采购结算方式": SETTLE_CODE, + "特定补偿预估值": COMPENSATION_DEFAULT, + "进货补偿预估值": COMPENSATION_DEFAULT, + "细单备注": _cell(line.order_no), + "货源细单ID": _cell(line.source_line_id), + } + ) + return rows + +# 校验细单至少一行,货品ID、订单号、数量、含税单价都要有。 +def build_import_draft( + *, + lines: list[RebateImportLine] | tuple[RebateImportLine, ...], + today: str | None = None, +) -> RebateImportDraft: + packed = tuple(lines) + if not packed: + raise NodeError("细单至少一行") + for line in packed: + if not goods_search_id(line) or not _cell(line.order_no): + raise NodeError("细单货品ID和订单号必须用订单中心的") + if not _cell(line.qty) or not _cell(line.tax_price): + raise NodeError("细单数量和含税协议单价必须用订单中心的") + return RebateImportDraft(lines=packed, today=today_iso(today)) + +# 把 camelCase / snake_case 字典收成 RebateImportLine。无税单价按含税和税率算。 +def _line_from_mapping(item: dict) -> RebateImportLine: + goods_id = _cell(item.get("goodsId") or item.get("goods_id")) + goods_code = _cell(item.get("goodsCode") or item.get("goods_code")) + tax_price = _cell(item.get("taxPrice") or item.get("tax_price") or item.get("price") or item.get("unit_price")) + tax_rate = _tax_rate_of( + item.get("taxRate") or item.get("tax_rate") or item.get("purchase_tax_rate") + ) + return RebateImportLine( + goods_code=goods_code, + qty=_cell(item.get("qty") or item.get("quantity") or item.get("saleQty")), + tax_price=tax_price, + order_no=_cell(item.get("orderNo") or item.get("order_no")), + source_line_id=_cell( + item.get("sourceLineId") + or item.get("source_line_id") + or item.get("goodsSourceId") + ), + goods_id=goods_id, + tax_rate=tax_rate, + net_price=_untaxed_price(tax_price, tax_rate), + ) + +# 从 REBATE_IMPORT_LINES 或单条环境变量拼细单草稿。表 3 空缺时干跑用。 +def import_draft_from_env(*, today: str | None = None) -> RebateImportDraft: + raw = (os.getenv("REBATE_IMPORT_LINES") or "").strip() + if raw: + payload = json.loads(raw) + if not isinstance(payload, list): + payload = [payload] + lines = [_line_from_mapping(item) for item in payload] + else: + lines = [ + RebateImportLine( + goods_code=os.getenv("REBATE_GOODS_CODE") or "", + qty=os.getenv("REBATE_QTY") or "", + tax_price=os.getenv("REBATE_TAX_PRICE") or "", + order_no=os.getenv("REBATE_ORDER_NO") or "", + source_line_id=os.getenv("REBATE_SOURCE_LINE_ID") or "", + goods_id=os.getenv("REBATE_GOODS_ID") or "", + ) + ] + return build_import_draft(lines=lines, today=today) + +# 写一份带表头的空 xlsx,给旧 Excel 路径的单测用。 +def write_canonical_template(path: Path) -> Path: + from openpyxl import Workbook + + dest = Path(path) + dest.parent.mkdir(parents=True, exist_ok=True) + wb = Workbook() + ws = wb.active + ws.title = "细单" + for col, name in enumerate(IMPORT_HEADERS, 1): + ws.cell(1, col, name) + wb.save(dest) + return dest + +# 读工作表一行的标题文本。 +def _header_titles(row) -> list[str]: + titles = [] + for cell in row: + text = _cell(cell.value).lstrip("*") + if text: + titles.append(text) + return titles + +# 找到含「货品编码」的表头行号。 +def _header_row_index(ws) -> int: + aliases = {name for names in _HEADER_ALIASES.values() for name in names} + for index, row in enumerate(ws.iter_rows(min_row=1, max_row=15), 1): + titles = _header_titles(row) + if any(title in aliases for title in titles): + return index + return 1 + +# 表头标题到列号的映射。 +def _col_map(ws, header_row: int) -> dict[str, int]: + mapping: dict[str, int] = {} + for cell in ws[header_row]: + title = _cell(cell.value).lstrip("*") + if not title or not cell.column: + continue + for field, names in _HEADER_ALIASES.items(): + if title in names and field not in mapping: + mapping[field] = int(cell.column) + return mapping + +# 按列名把细单写进模板。班次不调用。 +def fill_import_excel( + template_path: Path, + rows: list[dict[str, str]], + output_path: Path | None = None, +) -> Path: + from openpyxl import load_workbook + + src = Path(template_path) + dest = Path(output_path) if output_path else src.with_name(src.stem + "_filled.xlsx") + dest.parent.mkdir(parents=True, exist_ok=True) + if src.suffix.lower() != ".xlsx": + write_canonical_template(dest) + elif src.resolve() != dest.resolve(): + shutil.copy2(src, dest) + wb = load_workbook(dest) + ws = wb.active + header_row = _header_row_index(ws) + colmap = _col_map(ws, header_row) + used = [int(cell.column) for cell in ws[header_row] if cell.column] + next_col = (max(used) if used else 0) + 1 + for field in IMPORT_HEADERS: + if field in colmap: + continue + ws.cell(header_row, next_col, field) + colmap[field] = next_col + next_col += 1 + start = header_row + 1 + for offset, row in enumerate(rows): + for field, value in row.items(): + col = colmap.get(field) + if col: + ws.cell(start + offset, col, value) + wb.save(dest) + return dest + +# 等到下载目录出现新的 xlsx,忽略 .crdownload。 +def _wait_local_download(dest_dir: Path, before: set[str], timeout_seconds: int = 20) -> Path: + deadline = time.time() + timeout_seconds + while time.time() <= deadline: + hits = [ + p + for p in dest_dir.iterdir() + if p.name not in before + and p.suffix.lower() == ".xlsx" + and not str(p).endswith(".crdownload") + and not Path(str(p) + ".crdownload").exists() + ] + if hits: + return max(hits, key=lambda p: p.stat().st_mtime) + time.sleep(0.3) + raise RuntimeError("未下到细单模板") + +# 点导入导出下模板。班次不走 Excel,单测还留着。 +def download_detail_template(browser: BrowserAdapter, dest_dir: Path) -> Path: + dest = Path(dest_dir) + dest.mkdir(parents=True, exist_ok=True) + setter = getattr(browser, "set_download_dir", None) + if callable(setter): + setter(str(dest.resolve())) + before = {p.name for p in dest.iterdir()} + _switch_agreement_frame(browser) + if not _click_text(browser, "导入导出"): + try: + browser.click_first(IMPORT["menu"]) + except Exception: + pass + downloaded = False + for title in _DOWNLOAD_TITLES: + if _click_text(browser, title): + downloaded = True + break + if not downloaded: + browser.click_first(IMPORT["download"]) + waiter = getattr(browser, "wait_for_download", None) + if callable(waiter): + try: + return Path(waiter(".xlsx")) + except Exception: + pass + return _wait_local_download(dest, before) + +# 点导入把 xlsx 塞进文件框。班次不走这条。 +def upload_detail_file(browser: BrowserAdapter, file_path: Path) -> None: + _switch_agreement_frame(browser) + if not _click_text(browser, "导入"): + _click_text(browser, "导入导出") + if not _click_text(browser, "导入"): + try: + browser.click_first(IMPORT["upload_item"]) + except Exception: + pass + path = str(Path(file_path).resolve()) + browser.upload_file_first(IMPORT["file"], path) + try: + browser.click_first(IMPORT["confirm"]) + except Exception: + pass + +# 从页面读导入成功/失败条数。新增路径用货品表行数代替。 +def _scan_import_result(browser: BrowserAdapter, *, expected_lines: int = 0) -> RebateImportResult: + raw = None + try: + raw = browser.execute_script(_READ_IMPORT_RESULT) + except Exception: + raw = None + body = "" + success = 0 + fail = 0 + reasons: list[str] = [] + if isinstance(raw, dict): + try: + success = int(raw.get("success") or 0) + except (TypeError, ValueError): + success = 0 + try: + fail = int(raw.get("fail") or 0) + except (TypeError, ValueError): + fail = 0 + reasons = [_cell(x) for x in (raw.get("reasons") or []) if _cell(x)] + body = str(raw.get("body") or "") + if not body: + body = browser.read_body_text() or "" + if success == 0: + matched = _SUCCESS_RE.search(body) + if matched: + success = int(matched.group(1)) + if fail == 0: + matched = _FAIL_RE.search(body) + if matched: + fail = int(matched.group(1)) + if "存在重复的编码" in body: + if "存在重复的编码" not in reasons: + reasons.append("存在重复的编码") + if fail == 0: + fail = expected_lines or 1 + if success == 0 and fail == 0 and "导入成功" in body: + success = expected_lines or 1 + parsed = success + fail > 0 or "导入成功" in body or bool(reasons) + return RebateImportResult(success, fail, tuple(reasons), parsed) + +# 货品工具栏是否已出现「新增」。必须先勾中总单。 +def _goods_add_visible(browser: BrowserAdapter) -> bool: + try: + hit = browser.execute_script( + """ + /* GOODS_ADD_VISIBLE */ + function compact(el) { + return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim(); + } + function goodsTable() { + const header = document.querySelector('.table-show .single-table'); + const all = Array.from(document.querySelectorAll('.single-table')).filter((el) => { + return el !== header && (el.offsetWidth || el.offsetHeight); + }); + return all.find((el) => compact(el).indexOf('货品') >= 0) + || all.find((el) => el.closest('.elPlus-tab-pane')) + || all[0] + || null; + } + const table = goodsTable(); + if (!table) return false; + const nodes = Array.from(table.querySelectorAll( + '.table-toolbar button, .table-toolbar .vxe-button, .table-toolbar span, .table-toolbar a, .table-toolbar-btn__text' + )); + return nodes.some((el) => { + if (!(el.offsetWidth || el.offsetHeight)) return false; + const t = compact(el); + return t === '新增' || t === '+新增' || t === '新增细单' || t === '添加细单'; + }); + """ + ) + except Exception: + hit = False + return hit is True + +# 等到货品新增按钮出现。超时返回 False。 +def _wait_goods_add_button(browser: BrowserAdapter, timeout: float = 8) -> bool: + deadline = time.time() + timeout + while time.time() <= deadline: + _switch_agreement_frame(browser) + if _goods_add_visible(browser): + return True + _click_text(browser, "货品") + time.sleep(0.4) + return False + +# 点货品工具栏「新增」,打开一条细单卡片。 +def _click_add_detail(browser: BrowserAdapter) -> bool: + _switch_agreement_frame(browser) + clicked = browser.execute_script( + """ + /* CLICK_GOODS_ADD */ + function compact(el) { + return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim(); + } + function goodsTable() { + const header = document.querySelector('.table-show .single-table'); + const all = Array.from(document.querySelectorAll('.single-table')).filter((el) => { + return el !== header && (el.offsetWidth || el.offsetHeight); + }); + return all.find((el) => compact(el).indexOf('货品') >= 0) + || all.find((el) => el.closest('.elPlus-tab-pane')) + || all[0] + || null; + } + const table = goodsTable(); + if (!table) { return {ok: false, error: 'goods-table'}; } + const nodes = Array.from(table.querySelectorAll( + '.table-toolbar button, .table-toolbar .vxe-button, .table-toolbar span, .table-toolbar a, .table-toolbar-btn__text' + )); + const hit = nodes.find((el) => { + if (!(el.offsetWidth || el.offsetHeight)) return false; + const t = compact(el); + return t === '新增' || t === '+新增' || t === '新增细单' || t === '添加细单'; + }); + if (!hit) { return {ok: false}; } + hit.click(); + return {ok: true, text: compact(hit)}; + """ + ) + if isinstance(clicked, dict) and clicked.get("ok"): + time.sleep(0.8) + return True + return False + +# 只点货品工具栏(新增旁边那颗),不要点总单工具栏。 +def _click_goods_toolbar(browser: BrowserAdapter, title: str) -> bool: + clicked = browser.execute_script( + """ + /* SAVE_DETAIL */ + const title = String(arguments[0] || '').replace(/\\s+/g, '').trim(); + function compact(el) { + return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim(); + } + function vis(el) { + return !!(el && (el.offsetWidth || el.offsetHeight)); + } + const bars = Array.from(document.querySelectorAll('.table-toolbar')).filter(vis); + const bar = bars.find((el) => { + const t = compact(el); + return t.indexOf('新增') >= 0 && t.indexOf('保存') >= 0 && t.indexOf('送审') < 0; + }) || bars.find((el) => compact(el).indexOf('新增') >= 0); + if (!bar) { return {ok: false, error: 'no-detail-bar'}; } + const hit = Array.from(bar.querySelectorAll( + 'button, .table-toolbar-btn, .table-toolbar-btn__text, span' + )).find((el) => vis(el) && compact(el) === title); + if (!hit) { return {ok: false, error: 'no-btn'}; } + const btn = hit.closest('button, .table-toolbar-btn') || hit; + btn.click(); + return {ok: true, via: compact(hit)}; + """, + title, + ) + return bool(isinstance(clicked, dict) and clicked.get("ok")) + +# 用 ActionChains 点货品工具栏按钮,避开总单工具栏同名保存。 +def _native_click_goods_toolbar(browser: BrowserAdapter, title: str) -> bool: + driver = getattr(browser, "_driver", None) + if driver is None: + return False + wanted = (title or "").replace(" ", "") + try: + from selenium.webdriver.common.action_chains import ActionChains + + for bar in driver.find_elements("css selector", ".table-toolbar"): + text = (bar.text or "").replace("\n", " ") + if "新增" not in text or "保存" not in text or "送审" in text: + continue + for el in bar.find_elements("css selector", "button, .table-toolbar-btn, .table-toolbar-btn__text"): + raw = (el.text or "").replace("\n", "").replace(" ", "") + if raw != wanted: + continue + driver.execute_script("arguments[0].scrollIntoView({block:'center'});", el) + ActionChains(driver).move_to_element(el).pause(0.15).click().perform() + return True + except Exception: + return False + return False + +# 货品保存时若弹出确认,再点确认。 +def _confirm_save_popup(browser: BrowserAdapter) -> bool: + try: + clicked = browser.execute_script( + """ + /* CONFIRM_SAVE_POPUP */ + function compact(el) { + return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim(); + } + function vis(el) { + return !!(el && (el.offsetWidth || el.offsetHeight)); + } + const box = Array.from(document.querySelectorAll( + '.elPlus-message-box, .el-message-box, .elPlus-dialog, .el-dialog, .vxe-modal--wrapper, .ant-modal' + )).find((el) => { + if (!vis(el)) return false; + const t = compact(el); + return t.indexOf('确认') >= 0 || t.indexOf('确定') >= 0 || t.indexOf('是否') >= 0; + }); + if (!box) { return {ok: false, asked: false}; } + const hit = Array.from(box.querySelectorAll('button, .elPlus-button, span')).find((el) => { + if (!vis(el)) return false; + const t = compact(el); + return t === '确定' || t === '确 定' || t === '确认' || t === '确 认' || t === '是'; + }); + if (!hit) { return {ok: false, asked: true}; } + hit.click(); + return {ok: true, asked: true, via: compact(hit)}; + """ + ) + except Exception: + clicked = None + return bool(isinstance(clicked, dict) and clicked.get("ok")) + +# 细单卡片上货品编码输入是否已出来。 +def _goods_id_field_visible(browser: BrowserAdapter) -> bool: + try: + hit = browser.execute_script( + """ + /* GOODS_ID_FIELD */ + function labelText(el) { + const n = el.querySelector('.search-label__text'); + return ((n && n.innerText) || '').replace(/\\s+/g, '').trim(); + } + return Array.from(document.querySelectorAll('.search-condition-item')).some((el) => { + return (el.offsetWidth || el.offsetHeight) && labelText(el) === '货品ID'; + }); + """ + ) + except Exception: + hit = False + return hit is True + +# 点货品编码三个点,准备按货品ID搜。 +def _open_goods_id_query(browser: BrowserAdapter) -> None: + browser.execute_script( + """ + /* OPEN_GOODS_ID_QUERY */ + function labelText(el) { + const n = el.querySelector('.search-label__text'); + return ((n && n.innerText) || '').replace(/\\s+/g, '').trim(); + } + const ready = Array.from(document.querySelectorAll('.search-condition-item')).some((el) => { + return (el.offsetWidth || el.offsetHeight) && labelText(el) === '货品ID'; + }); + if (ready) { return {ok: true, already: true}; } + const modal = Array.from(document.querySelectorAll( + '.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer' + )).find((el) => el.offsetWidth || el.offsetHeight); + const root = modal || document; + const btn = root.querySelector('.search-button-content, .search-button'); + if (btn && (btn.offsetWidth || btn.offsetHeight) && !root.querySelector('.table-show')) { + btn.click(); + return {ok: true, via: 'query'}; + } + if (modal) { + const inner = modal.querySelector('.search-button-content, .search-button'); + if (inner) { inner.click(); return {ok: true, via: 'modal'}; } + } + return {ok: false}; + """ + ) + +# 等到细单卡片货品编码字段出现。 +def _wait_goods_id_field(browser: BrowserAdapter, timeout: float = 8) -> bool: + deadline = time.time() + timeout + retried_add = False + while time.time() <= deadline: + _switch_agreement_frame(browser) + if _goods_id_field_visible(browser): + return True + _open_goods_id_query(browser) + if _goods_id_field_visible(browser): + return True + if not retried_add: + _click_add_detail(browser) + retried_add = True + time.sleep(0.4) + return _goods_id_field_visible(browser) + +# 打开货品选择弹窗。 +def _open_goods_picker(browser: BrowserAdapter) -> bool: + opened = browser.execute_script( + """ + /* OPEN_GOODS_PICKER */ + """ + + _header_dom_js() + + """ + const item = detailItem('货品编码') || formItem('货品编码'); + if (!item) { return {ok: false, error: 'no-goods-code'}; } + item.scrollIntoView({block: 'center', inline: 'nearest'}); + const via = clickDots(item); + if (via) { return {ok: true, via: via}; } + const btn = item.querySelector('button.elPlus-button, button'); + if (!btn) { return {ok: false}; } + btn.click(); + return {ok: true, via: 'button'}; + """ + ) + return bool(isinstance(opened, dict) and opened.get("ok")) + +# 等到货品选择弹窗出现。 +def _wait_goods_picker(browser: BrowserAdapter, timeout: float = 12) -> bool: + deadline = time.time() + timeout + while time.time() <= deadline: + hit = browser.execute_script( + """ + /* WAIT_GOODS_PICKER */ + const el = Array.from(document.querySelectorAll('.vxe-modal--wrapper')).find((n) => { + const t = ((n.innerText || '') + ''); + const vis = n.offsetWidth || n.offsetHeight + || String(n.className || '').indexOf('is--visible') >= 0; + return vis && t.indexOf('货品选择') >= 0; + }); + return !!(el && (el.offsetWidth || el.offsetHeight)); + """ + ) + if hit is True: + return True + time.sleep(0.4) + return False + +# 弹窗里用货品ID搜,先点行里单选再点确定。不要用操作码或货品编码搜。 +def _pick_goods_id_in_dialog(browser: BrowserAdapter, goods_id: str) -> bool: + keyword = (goods_id or "").strip() + if not keyword: + return False + if not _open_goods_picker(browser): + return False + if not _wait_goods_picker(browser): + return False + filled = browser.execute_script( + """ + /* FILL_GOODS_ID */ + const no = String(arguments[0] || ''); + function compact(el) { + return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); + } + function vis(el) { + return !!(el && (el.offsetWidth || el.offsetHeight)); + } + const modal = Array.from(document.querySelectorAll('.vxe-modal--wrapper')).find((el) => { + return vis(el) && compact(el).indexOf('货品选择') >= 0; + }); + if (!modal) { return {ok: false, error: 'no-modal'}; } + const tab = Array.from(modal.querySelectorAll('span, label, li, button, div')).find((el) => { + return compact(el) === '货品ID'; + }); + if (tab) { tab.click(); } + const input = modal.querySelector('input.elPlus-input__inner, input'); + if (!input) { return {ok: false, error: 'no-input'}; } + input.click(); + input.focus(); + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; + setter.call(input, no); + input.dispatchEvent(new Event('input', {bubbles: true})); + input.dispatchEvent(new Event('change', {bubbles: true})); + const query = Array.from(modal.querySelectorAll('button')).find((el) => { + const t = compact(el); + return t === '查询' || t.indexOf('查询') === 0; + }); + if (query) { query.click(); } + return {ok: true, label: '货品ID', value: input.value}; + """, + keyword, + ) + if not (isinstance(filled, dict) and filled.get("ok") and filled.get("value") == keyword): + return False + deadline = time.time() + 8 + while time.time() <= deadline: + picked = browser.execute_script( + """ + /* PICK_GOODS_ROW */ + const keyword = String(arguments[0] || '').replace(/\\s+/g, ''); + function compact(el) { + return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ''); + } + function vis(el) { + return !!(el && (el.offsetWidth || el.offsetHeight)); + } + const modal = Array.from(document.querySelectorAll('.vxe-modal--wrapper')).find((el) => { + return vis(el) && compact(el).indexOf('货品选择') >= 0; + }); + const root = modal || document; + const rows = Array.from(root.querySelectorAll('.vxe-body--row, tr, [role="row"]')); + for (const row of rows) { + const text = compact(row); + if (text.indexOf(keyword) < 0) continue; + if (text.indexOf('CG-') >= 0) continue; + const radio = row.querySelector( + '.vxe-cell--radio, .vxe-radio--icon, .vxe-radio--label, input[type="radio"]' + ); + if (radio) { radio.click(); } else { row.click(); } + const ok = Array.from(root.querySelectorAll('button')).find((el) => { + if (!vis(el)) return false; + const t = compact(el); + return t === '确定' || t === '确 定'; + }); + if (ok) { ok.click(); } + return {ok: true, via: radio ? 'radio' : 'row'}; + } + return {ok: false}; + """, + keyword, + ) + if isinstance(picked, dict) and picked.get("ok"): + time.sleep(0.5) + return True + time.sleep(0.4) + return False + +# 读细单卡片分页文案,用来判断确认有没有把行落到表。 +def _detail_card_pager(browser: BrowserAdapter) -> str: + try: + text = browser.execute_script( + """ + /* DETAIL_CARD_PAGER */ + const el = document.querySelector('.right-btns'); + return ((el && el.innerText) || '').replace(/\\s+/g, ' ').trim(); + """ + ) + except Exception: + text = "" + return str(text or "") + +# 读货品表各行文本,供核对 goodsId 是否已在表里。 +def _goods_table_rows(browser: BrowserAdapter) -> list[str]: + try: + rows = browser.execute_script( + """ + /* GOODS_TABLE_ROWS */ + function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); } + function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); } + const table = Array.from(document.querySelectorAll('.single-table')).find((el) => { + return vis(el) && compact(el).indexOf('协议细单ID') >= 0; + }); + if (!table) { return []; } + return Array.from(table.querySelectorAll('.vxe-body--row')).filter(vis).map((el) => compact(el).slice(0, 240)); + """ + ) + except Exception: + rows = None + if isinstance(rows, list): + return [str(item) for item in rows] + return [] + +# 等到货品表出现该货品ID。 +def _wait_goods_row(browser: BrowserAdapter, gid: str, timeout: float = 8) -> bool: + number = (gid or "").strip() + if not number: + return False + deadline = time.time() + timeout + while time.time() <= deadline: + rows = _goods_table_rows(browser) + if any(number in row for row in rows): + return True + time.sleep(0.3) + print("goods_table_rows=", _goods_table_rows(browser), "need=", number, flush=True) + return False + +# 点货品卡片标题栏「确认 Ctrl + Enter」,不要点总单卡片确认。 +def _confirm_detail_card(browser: BrowserAdapter) -> bool: + before = _detail_card_pager(browser) + clicked = browser.execute_script( + """ + /* CONFIRM_DETAIL_CARD */ + function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); } + function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); } + const card = Array.from(document.querySelectorAll('.table-card')).find((el) => { + return vis(el) && compact(el).indexOf('货品编码') >= 0; + }); + if (!card) { return {ok: false, error: 'no-goods-card'}; } + const nodes = Array.from(card.querySelectorAll('.card-header-btn, button.elPlus-button')); + const texts = nodes.map((el) => compact(el)).filter(Boolean); + const hit = nodes.find((el) => { + if (!vis(el)) return false; + const t = compact(el).replace(/\\s+/g, ''); + return t.indexOf('确认') === 0 || t.indexOf('确认Ctrl') === 0; + }); + if (!hit) { return {ok: false, error: 'no-confirm', n: nodes.length, texts: texts.slice(0, 8)}; } + hit.scrollIntoView({block: 'center', inline: 'nearest'}); + hit.click(); + return {ok: true, via: compact(hit).slice(0, 40), w: hit.offsetWidth, h: hit.offsetHeight}; + """ + ) + driver = getattr(browser, "_driver", None) + if driver is not None: + try: + from selenium.webdriver.common.action_chains import ActionChains + + for el in driver.find_elements("css selector", ".table-card .card-header-btn"): + info = driver.execute_script( + """ + const el = arguments[0]; + const card = el.closest('.table-card'); + return { + text: ((el.innerText || '') + '').replace(/\\s+/g, ' ').trim(), + goods: !!card && ((card.innerText || '').indexOf('货品编码') >= 0) + }; + """, + el, + ) + if not (isinstance(info, dict) and info.get("goods")): + continue + raw = str(info.get("text") or "") + if "确认" not in raw.replace(" ", "").replace("\n", ""): + continue + driver.execute_script( + "arguments[0].scrollIntoView({block:'center', inline:'nearest'});", + el, + ) + time.sleep(0.2) + ActionChains(driver).move_to_element(el).pause(0.15).click().perform() + clicked = {"ok": True, "via": "native:" + raw[:40]} + break + except Exception as exc: + print("native_confirm_err=", exc, flush=True) + print("confirm_click=", clicked, "pager_before=", before, flush=True) + if not (isinstance(clicked, dict) and clicked.get("ok")): + return False + time.sleep(0.6) + err = _read_detail_toast(browser) + if err: + raise NodeError(err) + errors = [] + try: + errors = browser.execute_script( + """ + /* DETAIL_FORM_ERRORS */ + function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); } + return Array.from(document.querySelectorAll( + '.table-card .elPlus-form-item.is-error, .table-card .elPlus-form-item__error' + )).map((el) => compact(el)).filter(Boolean).slice(0, 8); + """ + ) or [] + except Exception: + errors = [] + print("pager_after=", _detail_card_pager(browser), "errors=", errors, flush=True) + if errors: + raise NodeError("细单确认未过:" + ";".join(str(x) for x in errors[:3])) + return True + if isinstance(clicked, dict) and clicked.get("ok"): + time.sleep(0.5) + err = _read_detail_toast(browser) + if err: + raise NodeError(err) + return True + driver = getattr(browser, "_driver", None) + if driver is not None: + from selenium.webdriver.common.action_chains import ActionChains + from selenium.webdriver.common.keys import Keys + + ActionChains(driver).key_down(Keys.CONTROL).send_keys(Keys.ENTER).key_up(Keys.CONTROL).perform() + time.sleep(0.5) + return True + return False + +# 卡片输入要用键盘,JS 赋值 Vue 收不到,确认时会报必填。 +def _type_detail_field(browser: BrowserAdapter, title: str, value: str) -> bool: + wanted = _cell(value) + if not wanted: + return False + marked = browser.execute_script( + """ + /* TYPE_DETAIL_FIELD */ + """ + + _header_dom_js() + + """ + const item = detailItem(arguments[0]); + const input = item && item.querySelector('input, textarea'); + if (!input) { return false; } + input.setAttribute('data-rpa-fill', '1'); + input.click(); + return true; + """, + title, + ) + if not marked: + return False + driver = getattr(browser, "_driver", None) + if driver is None: + return True + try: + from selenium.webdriver.common.action_chains import ActionChains + from selenium.webdriver.common.keys import Keys + + el = driver.find_element("css selector", '.table-card [data-rpa-fill="1"]') + driver.execute_script("arguments[0].removeAttribute('data-rpa-fill')", el) + ActionChains(driver).move_to_element(el).click().pause(0.05).key_down(Keys.CONTROL).send_keys("a").key_up( + Keys.CONTROL + ).send_keys(Keys.BACKSPACE).send_keys(wanted).send_keys(Keys.TAB).perform() + return True + except Exception: + return False + +# 填数量、含税单价、无税单价、备注=订单号、特定/进货补偿=0、承付模式和参数 25、结算电汇。 +def _fill_last_detail_qty_price(browser: BrowserAdapter, line: RebateImportLine) -> None: + qty = _cell(line.qty) + price = _cell(line.tax_price) + remark = _cell(line.order_no) + tax = _tax_rate_of(line.tax_rate) + net = _cell(line.net_price) or _untaxed_price(price, tax) + filled = browser.execute_script( + """ + /* FILL_DETAIL_CELLS */ + """ + + _header_dom_js() + + """ + const qty = String(arguments[0] || ''); + const price = String(arguments[1] || ''); + const remark = String(arguments[2] || ''); + const compensation = String(arguments[3] || ''); + const tax = String(arguments[4] || ''); + const payParam = String(arguments[5] || ''); + const net = String(arguments[6] || ''); + function setByLabel(title, value) { + if (!value) return false; + const item = detailItem(title); + if (!item) return false; + const input = item.querySelector('input, textarea, .vxe-input--inner'); + if (!input) return false; + input.click(); + const desc = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value') + || Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value'); + if (desc && desc.set) { desc.set.call(input, value); } + else { input.value = value; } + input.dispatchEvent(new Event('input', {bubbles: true})); + input.dispatchEvent(new Event('change', {bubbles: true})); + return true; + } + return { + qty: setByLabel('数量', qty), + price: setByLabel('含税协议单价', price), + net: setByLabel('无税协议价', net) || setByLabel('无税单价', net), + tax: setByLabel('税率', tax), + payParam: setByLabel('承付参数', payParam), + remark: setByLabel('细单备注', remark), + compensation: setByLabel('特定补偿预估值', compensation) || setByLabel('特定补偿', compensation), + inbound: setByLabel('进货补偿预估值', compensation), + payMode: shownValue(detailItem('承付模式')), + settle: shownValue(detailItem('采购结算方式')), + }; + """, + qty, + price, + remark, + COMPENSATION_DEFAULT, + tax, + PAY_PARAM_DEFAULT, + net, + ) + print("fill_detail=", filled, flush=True) + try: + browser.execute_script( + """ + document.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true + })); + """ + ) + except Exception: + pass + _type_detail_field(browser, "数量", qty) + _type_detail_field(browser, "含税协议单价", price) + _type_detail_field(browser, "无税协议价", net) + _type_detail_field(browser, "承付参数", PAY_PARAM_DEFAULT) + _type_detail_field(browser, "细单备注", remark) + _type_detail_field(browser, "特定补偿预估值", COMPENSATION_DEFAULT) + _type_detail_field(browser, "进货补偿预估值", COMPENSATION_DEFAULT) + if not _pick_detail_option(browser, "承付模式", PAY_MODE_NEXT_MONTH_N, in_detail=True): + raise NodeError("细单承付未点到下月N号结") + _pick_detail_option(browser, "承付参数", PAY_PARAM_DEFAULT, in_detail=True) + if not _pick_detail_option(browser, "采购结算方式", SETTLE_MODE_WIRE, in_detail=True): + raise NodeError("细单结算未精确点到电汇") + +# 货品表是否已有该货品ID。重跑时跳过已存在的行。 +def _goods_id_already_in_table(browser: BrowserAdapter, gid: str) -> bool: + number = (gid or "").strip() + if not number: + return False + return any(number in row for row in _goods_table_rows(browser)) + + +# 按货品ID逐条新增细单,全部确认完再点货品工具栏保存。 +# +# 已在表里的货品ID跳过。不要每条都点工具栏保存,不要点总单保存。返回加进表的条数(含跳过)。 +def add_detail_lines_by_goods_id( + browser: BrowserAdapter, + lines: list[RebateImportLine] | tuple[RebateImportLine, ...], + *, + save: bool = True, +) -> int: + added = 0 + created = 0 + _close_header_card(browser) + _close_detail_card(browser) + for line in lines: + gid = goods_search_id(line) + if not gid: + raise NodeError("细单货品ID必须用订单中心的 goodsId") + if _goods_id_already_in_table(browser, gid): + print("skip_detail_existing gid=", gid, flush=True) + added += 1 + continue + if not _click_add_detail(browser): + raise NodeError("选中总单后未出现货品新增") + if not _pick_goods_id_in_dialog(browser, gid): + raise NodeError(f"新增后未按货品ID搜到 {gid},停下确认搜框是不是货品ID") + _fill_last_detail_qty_price(browser, line) + try: + print( + "card_btns=", + browser.execute_script( + """ + return Array.from(document.querySelectorAll('.table-card .card-header-btn, .card-header-btn')).map((el) => ((el.innerText || '') + '').replace(/\\s+/g, ' ').trim()).filter(Boolean).slice(0, 8); + """ + ), + flush=True, + ) + except Exception: + pass + confirmed = _confirm_detail_card(browser) + print("confirm_detail=", confirmed, "gid=", gid, flush=True) + if not confirmed: + raise NodeError(f"细单 {gid} 未点到货品卡片「确认 Ctrl + Enter」,行还没落到货品表") + if not _wait_goods_row(browser, gid): + raise NodeError(f"细单确认后货品表没有 {gid}") + added += 1 + created += 1 + if created: + _close_detail_card(browser) + if created and save: + if not _native_click_goods_toolbar(browser, "保存"): + if not _click_goods_toolbar(browser, "保存"): + raise NodeError("未点到货品工具栏保存(新增旁边那颗),不要点总单保存") + time.sleep(0.5) + _confirm_save_popup(browser) + err = _read_detail_toast(browser) + if err: + raise NodeError(err) + return added + +# 等到能解析出导入成功/失败条数。新增路径用货品表行数。 +def read_import_result(browser: BrowserAdapter, *, expected_lines: int = 0) -> RebateImportResult: + deadline = time.time() + 20 + last = RebateImportResult(0, 0, (), False) + while time.time() <= deadline: + last = _scan_import_result(browser, expected_lines=expected_lines) + if last.parsed: + return last + time.sleep(0.4) + return last + +# 查号 → 勾总单 → 按货品ID新增细单 → 保存。 +# +# 不做 Excel。写 ACCEPT SUMMARY REBATE_IMPORT。 +def run_import_flow( + browser: BrowserAdapter, + ctx: RebateFlowContext, + contract_no: str, + draft: RebateImportDraft | None = None, + *, + download_dir: Path | None = None, +) -> RebateImportResult: + _IMPORT_ACCEPT.reset() + payload = draft or import_draft_for_contract(contract_no) + dest = Path(download_dir) if download_dir else artifact_dir(IMPORT_FLOW) / "downloads" + result = RebateImportResult(0, 0, (), False) + try: + open_rebate_center(browser, ctx) + _IMPORT_ACCEPT.note("S02", "打开返利中心", {"url": ctx.rebate_url}, ok=True) + try: + login_rebate_center(browser, ctx) + _IMPORT_ACCEPT.note("S03", "登录", {"user": ctx.username}, ok=True) + except Exception as exc: + evidence_shot(browser, "rebate-import-login-failed", failed=True) + _IMPORT_ACCEPT.note( + "S03", + "登录", + {"user": ctx.username, "error": exc}, + ok=False, + raise_error=False, + ) + raise + enter_purchase_agreement(browser) + _IMPORT_ACCEPT.note("S04", "进入采购协议", {"menu": "采购协议管理"}, ok=True) + found = search_contract(browser, contract_no) + _IMPORT_ACCEPT.note( + "S05", + "查号", + {"contractNo": contract_no, "found": "yes" if found else "no"}, + ok=found, + ) + if not found: + raise NodeError("查号未搜到合同,不能加细单") + _close_header_query(browser) + picked = select_header_row_result(browser, contract_no) + if not (isinstance(picked, dict) and picked.get("ok")): + raise NodeError("未按合同编号勾中总单行") + if not _wait_goods_add_button(browser): + raise NodeError("点中总单后未出现货品新增,停下确认货品区是否已出") + _close_header_card(browser) + _IMPORT_ACCEPT.note("S06", "选中总单", {"selected": "yes"}, ok=True) + added = add_detail_lines_by_goods_id(browser, payload.lines) + first = payload.lines[0] + _IMPORT_ACCEPT.note( + "S07", + "新增细单", + { + "lines": added, + "goodsId": goods_search_id(first), + "remark": _cell(first.order_no), + "compensation": COMPENSATION_DEFAULT, + }, + ok=added == len(payload.lines), + ) + if added != len(payload.lines): + raise NodeError(f"细单未加齐 {added}/{len(payload.lines)}") + table_rows = _goods_table_rows(browser) + missing = [ + goods_search_id(line) + for line in payload.lines + if not any(goods_search_id(line) in row for row in table_rows) + ] + if missing: + raise NodeError("货品表缺少 " + ",".join(missing)) + _IMPORT_ACCEPT.note( + "S08", + "保存细单", + {"saved": len(table_rows), "msg": f"{len(table_rows)}条货品已保存"}, + ok=True, + ) + result = RebateImportResult(len(table_rows), 0, (), True) + _IMPORT_ACCEPT.note( + "S10", + "读导入结果", + {"success": result.success_count, "fail": result.fail_count}, + ok=True, + ) + return result + finally: + print(_IMPORT_ACCEPT.summary_text(), end="", flush=True) + _IMPORT_ACCEPT.dump() diff --git a/rpa_rebate/rebate_submit.py b/rpa_rebate/rebate_submit.py new file mode 100644 index 0000000..7362b1f --- /dev/null +++ b/rpa_rebate/rebate_submit.py @@ -0,0 +1,434 @@ +# 返利送审节点:run_submit_flow。登录/查号/勾行在 rebate_common。 + +from __future__ import annotations + +import re +import time + +from accept import AcceptanceReport, evidence_shot + +from rebate_common import ( + SUBMIT_FLOW, + BrowserAdapter, + RebateFlowContext, + RebateSubmitResult, + open_rebate_center, + login_rebate_center, + _switch_agreement_frame, + enter_purchase_agreement, + search_contract, + _OPEN_HEADER_ROW, + _cell, + _click_toolbar, + _click_header_toolbar, + _native_click_header_toolbar, + _scan_rebate_order_no, + _clean_order_no, + _click_text, + select_header_row, + _close_header_query, + _SELECT_HEADER_ROW, +) + +_SUBMIT_ACCEPT = AcceptanceReport(flow=SUBMIT_FLOW) + +_SUBMIT_TITLES = ("送审", "提交送审", "提交审批") + +_SUBMIT_FAIL_MARKS = ("送审失败", "提交失败", "请先导入") + +_ALREADY_SUBMIT_MARKS = ("已送审", "已经送审", "不允许送审", "不能送审", "不可重复") + +_DETAIL_TAB_TITLES = ("协议细单", "细单", "明细") + +_SUBMIT_ID_LABELS = { + "总单号", + "采购协议号", + "协议编号", + "采购协议总单ID", + "协议总单号", + "采购协议ID", + "协议总单ID", + "协议细单ID", + "采购协议细单ID", + "细单号", + "细单ID", + "货源细单ID", +} + +_BUSY_MARKS = ("加载中", "查询加载", "设置查询条件", "自定义查询") + +_ID_RE = re.compile(r"^[A-Za-z0-9_-]{3,40}$") + +_DTL_LABEL_RE = re.compile( + r"(?:采购协议细单ID|协议细单ID)[::\s]*([A-Za-z0-9_-]{3,40})" +) + +_CONFIRM_SUBMIT = """ +/* CONFIRM_SUBMIT */ +const marks = ['确定送审', '确认送审', '是否送审', '确定提交', '确认提交', '是否提交']; +const body = (document.body && document.body.innerText) || ''; +const asked = marks.some((m) => body.indexOf(m) >= 0) + || !!document.querySelector('.el-message-box, .el-dialog__wrapper, .vxe-modal--wrapper, .ant-modal'); +if (!asked) { return {ok: false, asked: false}; } +const btn = Array.from(document.querySelectorAll('button, .el-button, span')).find((el) => { + const t = ((el.innerText || '') + '').replace(/\\s+/g, '').trim(); + return (t === '确定' || t === '确认' || t === '是') && (el.offsetWidth || el.offsetHeight); +}); +if (!btn) { return {ok: false, asked: true}; } +btn.click(); +return {ok: true, asked: true}; +""" + +_READ_AGREEMENT_NOS = """ +/* READ_AGREEMENT_NOS */ +const no = String(arguments[0] || '').trim(); +const headerNames = ['协议总单ID', '采购协议总单ID', '采购协议号', '总单号', '协议编号', '协议总单号', '采购协议ID']; +const detailNames = ['协议细单ID', '采购协议细单ID', '协议明细ID', '细单号', '细单ID', '明细ID']; +function compact(el) { + return ((el && (el.innerText || el.value || el.textContent)) || '').replace(/\\s+/g, ' ').trim(); +} +function isId(t) { + return /^[A-Za-z0-9_-]{3,40}$/.test(t || ''); +} +function fromVxe(names, requireNo) { + const heads = Array.from(document.querySelectorAll('.vxe-header--column')).map((th) => ({ + title: (th.innerText || '').replace(/\\s+/g, '').trim(), + colid: th.getAttribute('colid'), + })); + const rows = Array.from(document.querySelectorAll('.vxe-body--row')); + for (const name of names) { + const h = heads.find((x) => x.title === name); + if (!h || !h.colid) { continue; } + for (const row of rows) { + const text = (row.innerText || ''); + if (requireNo && no && text.indexOf(no) < 0) { continue; } + const c = row.querySelector('[colid="' + h.colid + '"]'); + const t = compact(c); + if (isId(t)) { return t; } + } + if (!requireNo) { + const cells = document.querySelectorAll('.vxe-body--row [colid="' + h.colid + '"]'); + for (const c of cells) { + const t = compact(c); + if (isId(t)) { return t; } + } + } + } + return ''; +} +function fromElTable(names, requireNo) { + const tables = Array.from(document.querySelectorAll('table, .el-table')); + for (const table of tables) { + const ths = Array.from(table.querySelectorAll('th')); + const titles = ths.map((th) => (th.innerText || '').replace(/\\s+/g, '').trim()); + const rows = Array.from(table.querySelectorAll('tbody tr, .el-table__body tr')); + for (const name of names) { + const idx = titles.indexOf(name); + if (idx < 0) { continue; } + for (const row of rows) { + const text = (row.innerText || ''); + if (requireNo && no && text.indexOf(no) < 0) { continue; } + const cells = row.querySelectorAll('td'); + const t = compact(cells[idx]); + if (isId(t)) { return t; } + } + } + } + return ''; +} +function fromTitleContains(needles, skip) { + const heads = Array.from(document.querySelectorAll('.vxe-header--column')).map((th) => ({ + title: (th.innerText || '').replace(/\\s+/g, '').trim(), + colid: th.getAttribute('colid'), + })); + for (const h of heads) { + if (!h.colid) { continue; } + if (skip.some((s) => h.title.indexOf(s) >= 0)) { continue; } + if (!needles.some((n) => h.title.indexOf(n) >= 0)) { continue; } + const cells = document.querySelectorAll('.vxe-body--row [colid="' + h.colid + '"]'); + for (const c of cells) { + const t = compact(c); + if (isId(t)) { return t; } + } + } + return ''; +} +function fromLabel(names) { + for (const label of names) { + const nodes = Array.from(document.querySelectorAll('label, td, th, span')); + const node = nodes.find((el) => { + const raw = (el.childNodes && el.childNodes.length === 1) ? (el.textContent || '') : (el.innerText || ''); + const t = raw.replace(/\\s+/g, '').replace(/[*::]/g, ''); + return t === label && t.length <= 20; + }); + if (!node) { continue; } + let value = ''; + if (node.tagName === 'LABEL' && node.htmlFor) { + value = compact(document.getElementById(node.htmlFor)); + } + const next = node.nextElementSibling; + if (!value && next) { + value = compact(next.querySelector('input, span, a') || next); + } + const cell = node.closest('td, th'); + if (!value && cell && cell.nextElementSibling) { + value = compact(cell.nextElementSibling.querySelector('input, span, a') || cell.nextElementSibling); + } + if (isId(value)) { return value; } + } + return ''; +} +const titles = Array.from(document.querySelectorAll('.vxe-header--column, th')).map((th) => { + return (th.innerText || '').replace(/\\s+/g, '').trim(); +}).filter(Boolean); +const header = fromVxe(headerNames, true) || fromElTable(headerNames, true) || fromLabel(headerNames) + || fromVxe(headerNames, false) || fromElTable(headerNames, false); +const detail = fromVxe(detailNames, false) || fromElTable(detailNames, false) || fromLabel(detailNames) + || fromTitleContains(['细单', '明细'], ['货源', '备注', '日期']); +return {header: header, detail: detail, titles: titles}; +""" + +# 丢掉列名、合同号本身、忙碌文案,只留像协议总单ID/细单ID的串。 +def _clean_submit_id(raw: object, *, contract_no: str = "") -> str: + text = _clean_order_no(raw, contract_no=contract_no) + if not text: + return "" + if text in _SUBMIT_ID_LABELS: + return "" + if contract_no and text == contract_no.strip(): + return "" + if any(mark in text for mark in _BUSY_MARKS): + return "" + if not _ID_RE.fullmatch(text): + return "" + return text + +# 先按合同编号勾中总单行;勾不到再双击打开。 +def _select_or_open_row(browser: BrowserAdapter, contract_no: str) -> None: + _switch_agreement_frame(browser) + if select_header_row(browser, contract_no): + return + selected = None + try: + selected = browser.execute_script(_SELECT_HEADER_ROW, contract_no) + except Exception: + selected = None + if isinstance(selected, dict) and selected.get("ok"): + return + try: + browser.execute_script(_OPEN_HEADER_ROW, contract_no) + except Exception: + pass + +# 送审弹出确认框时点确定。 +def _confirm_if_asked(browser: BrowserAdapter) -> None: + result = None + try: + result = browser.execute_script(_CONFIRM_SUBMIT) + except Exception: + result = None + if isinstance(result, dict) and result.get("ok"): + return + if isinstance(result, dict) and result.get("asked"): + for title in ("确定", "确认", "是"): + if _click_text(browser, title): + return + +# 点总单工具栏「送审」。点到则返回按钮文案,点不到返回空。 +def click_rebate_submit(browser: BrowserAdapter) -> str: + _switch_agreement_frame(browser) + for title in _SUBMIT_TITLES: + if _click_header_toolbar(browser, title): + _native_click_header_toolbar(browser, title) + _confirm_if_asked(browser) + return title + for title in _SUBMIT_TITLES: + if _click_toolbar(browser, title): + _confirm_if_asked(browser) + return title + for title in _SUBMIT_TITLES: + if _click_text(browser, title): + _confirm_if_asked(browser) + return title + return "" + +# 正文是否含给定标记之一。 +def _body_has(browser: BrowserAdapter, marks: tuple[str, ...]) -> bool: + body = browser.read_body_text() or "" + return any(mark in body for mark in marks) + +# 正文里是否出现送审失败标记,有则返回那句。 +def _submit_error(browser: BrowserAdapter) -> str: + body = browser.read_body_text() or "" + for mark in _SUBMIT_FAIL_MARKS: + if mark in body: + return mark + return "" + +# 双击总单行打开查看,方便读协议号。 +def _open_header_view(browser: BrowserAdapter, contract_no: str) -> None: + _switch_agreement_frame(browser) + try: + browser.execute_script(_OPEN_HEADER_ROW, contract_no) + except Exception: + pass + +# 切到细单/货品页签,好读协议细单ID。 +def _reveal_detail_tab(browser: BrowserAdapter) -> None: + _switch_agreement_frame(browser) + for title in _DETAIL_TAB_TITLES: + if _click_toolbar(browser, title): + return + try: + clicked = browser.execute_script( + """ + const title = String(arguments[0] || '').replace(/\\s+/g, ''); + const hit = Array.from(document.querySelectorAll( + '[role="tab"], .el-tabs__item, .el-radio-button, li, span, a, div' + )).find((el) => { + const t = ((el.innerText || '') + '').replace(/\\s+/g, '').trim(); + return t === title && (el.offsetWidth || el.offsetHeight); + }); + if (!hit) { return {ok: false}; } + hit.click(); + return {ok: true}; + """, + title, + ) + except Exception: + clicked = None + if isinstance(clicked, dict) and clicked.get("ok"): + return + +# 从表格或标签扫协议总单ID和协议细单ID。 +def _scan_agreement_nos(browser: BrowserAdapter, *, contract_no: str = "") -> tuple[str, str, str]: + header = "" + detail = "" + titles = "" + try: + raw = browser.execute_script(_READ_AGREEMENT_NOS, contract_no) + except Exception: + raw = None + if isinstance(raw, dict): + header = _clean_submit_id(raw.get("header"), contract_no=contract_no) + detail = _clean_submit_id(raw.get("detail"), contract_no=contract_no) + titles = ",".join(_cell(x) for x in (raw.get("titles") or []) if _cell(x)) + if not header: + header = _clean_submit_id( + _scan_rebate_order_no(browser, contract_no=contract_no), + contract_no=contract_no, + ) + if not detail: + body = browser.read_body_text() or "" + match = _DTL_LABEL_RE.search(body) + if match: + detail = _clean_submit_id(match.group(1), contract_no=contract_no) + return header, detail, titles + +# 把表格滚到最右,避免协议号列被挡住。 +def _scroll_grid_end(browser: BrowserAdapter) -> None: + try: + browser.execute_script( + """ + const wraps = Array.from(document.querySelectorAll( + '.vxe-table--body-wrapper, .vxe-table--header-wrapper, .vxe-table--body-inner-wrapper' + )); + wraps.forEach((w) => { w.scrollLeft = w.scrollWidth; }); + return wraps.length; + """ + ) + except Exception: + return + +# 等到能读到协议总单ID和细单ID。列表上一时看不见就再打开行或切页签。 +def read_agreement_nos(browser: BrowserAdapter, *, contract_no: str = "") -> tuple[str, str, str]: + deadline = time.time() + 15 + last = ("", "", "") + opened = False + revealed = False + scrolled = False + while time.time() <= deadline: + last = _scan_agreement_nos(browser, contract_no=contract_no) + if last[0] and last[1]: + return last + if not scrolled: + _scroll_grid_end(browser) + scrolled = True + elif not opened: + _open_header_view(browser, contract_no) + opened = True + elif last[0] and not last[1] and not revealed: + _reveal_detail_tab(browser) + revealed = True + time.sleep(0.4) + return last + +# 查号 → 勾总单 → 点送审。马上能见号则带回。 +# +# 细单必须已保存。写 ACCEPT SUMMARY REBATE_SUBMIT。 +def run_submit_flow( + browser: BrowserAdapter, + ctx: RebateFlowContext, + contract_no: str, +) -> RebateSubmitResult: + _SUBMIT_ACCEPT.reset() + result = RebateSubmitResult(False) + try: + open_rebate_center(browser, ctx) + _SUBMIT_ACCEPT.note("S02", "打开返利中心", {"url": ctx.rebate_url}, ok=True) + try: + login_rebate_center(browser, ctx) + _SUBMIT_ACCEPT.note("S03", "登录", {"user": ctx.username}, ok=True) + except Exception as exc: + evidence_shot(browser, "rebate-submit-login-failed", failed=True) + _SUBMIT_ACCEPT.note( + "S03", + "登录", + {"user": ctx.username, "error": exc}, + ok=False, + raise_error=False, + ) + raise + enter_purchase_agreement(browser) + _SUBMIT_ACCEPT.note("S04", "进入采购协议", {"menu": "采购协议管理"}, ok=True) + found = search_contract(browser, contract_no) + _SUBMIT_ACCEPT.note( + "S05", + "查号", + {"contractNo": contract_no, "found": "yes" if found else "no"}, + ok=found, + ) + _close_header_query(browser) + _select_or_open_row(browser, contract_no) + _SUBMIT_ACCEPT.note("S06", "选总单", {"contractNo": contract_no}, ok=True) + before_header, before_detail, _ = _scan_agreement_nos(browser, contract_no=contract_no) + clicked = click_rebate_submit(browser) + error = _submit_error(browser) if clicked else "未点到送审" + already = _body_has(browser, _ALREADY_SUBMIT_MARKS) + submitted = bool(clicked and not error) or already + facts: dict[str, object] = { + "submitted": "yes" if submitted else "no", + "via": clicked or ("已送审" if already else ""), + } + if error and not already: + facts["error"] = error + _SUBMIT_ACCEPT.note("S07", "点送审", facts, ok=submitted) + header, detail, titles = read_agreement_nos(browser, contract_no=contract_no) + header = header or before_header + detail = detail or before_detail + read_facts: dict[str, object] = {"rebateOrderNo": header, "agreementDtlId": detail} + if not detail: + cols = [t for t in titles.split(",") if "细单" in t or "明细" in t] + if cols: + read_facts["detailCols"] = ",".join(cols[:8]) + _SUBMIT_ACCEPT.note( + "S08", + "读号", + read_facts, + ok=bool(header), + ) + result = RebateSubmitResult(submitted, header, detail) + return result + finally: + print(_SUBMIT_ACCEPT.summary_text(), end="", flush=True) + _SUBMIT_ACCEPT.dump() diff --git a/rpa_rebate/repo.py b/rpa_rebate/repo.py index 4f17d72..f379f92 100644 --- a/rpa_rebate/repo.py +++ b/rpa_rebate/repo.py @@ -1,162 +1,95 @@ -"""表 4 起草行领取、表 3 只录协议行领取、表 4 写入。""" +# 表 4 起草行领取、表 3 只录协议行领取、表 4 写入。 +# +# 表 4 领取时 process_status 从 0 改成 3;表 3 只录协议行同样 0 改成 3。 +# 进程中途退出时行停在 3,超过租约(表 4 update_time / 表 3 process_time 30 分钟)才能再领。 +# 未过期把表 4 改回 0、表 3 改回 0 再跑。人名固定系统管理员。不推接口 4。 +# 同一合同号整组领取,limit 不把一组切开。 + from __future__ import annotations -from collections.abc import Callable +from contextlib import contextmanager from dataclasses import dataclass, field -from db import RepoUnavailable, connect_from_env, oracle_dsn -from persist import STAMP_CLAIMING, STAMP_CONTRACT, STAMP_DONE, GroupSave +from datetime import datetime, timedelta + +from rpa_oracle import RepoUnavailable, job_pool, update_by_fid_chunks +from rpa_oracle.audit import ShiftError, save_shift_errors as write_shift_errors +from persist import USER_NAME, GroupSave ACTION_CONTRACT = 1 CONTRACT_DRAFT = 1 PROCESS_PENDING = 0 PROCESS_CLAIMING = 3 ENTRY_PROTOCOL_ONLY = 0 +LEASE_MINUTES = 30 - -@dataclass -class Claimed: - drafts: list[dict] = field(default_factory=list) - protocols: list[dict] = field(default_factory=list) - - -def _has_contract_no(row: dict) -> bool: - return bool((row.get("contract_no") or "").strip()) - - -def is_draft(row: dict) -> bool: - if int(row.get("action_code") or 0) != ACTION_CONTRACT: - return False - if int(row.get("contract_status") or 0) != CONTRACT_DRAFT: - return False - if not _has_contract_no(row): - return False - stamp = row.get("update_user_name") or STAMP_CONTRACT - return stamp == STAMP_CONTRACT - - -def is_protocol_only(row: dict) -> bool: - if int(row.get("process_status") or 0) != PROCESS_PENDING: - return False - entry = row.get("contract_entry_status") - if entry is None or int(entry) != ENTRY_PROTOCOL_ONLY: - return False - return _has_contract_no(row) - - -class MemoryRebateRepo: - def __init__( - self, - table4: list[dict] | None = None, - table3: list[dict] | None = None, - ): - self.table4: dict[int, dict] = { - int(row["fid"]): dict(row) for row in (table4 or []) - } - self.table3: dict[int, dict] = { - int(row["fid"]): dict(row) for row in (table3 or []) - } - self._next_fid = (max(self.table4) + 1) if self.table4 else 1 - - def claim_pending(self, limit: int | None = None) -> Claimed: - drafts = sorted( - (row for row in self.table4.values() if is_draft(row)), - key=lambda row: int(row["fid"]), - ) - protocols = sorted( - (row for row in self.table3.values() if is_protocol_only(row)), - key=lambda row: int(row["fid"]), - ) - if limit is not None: - if len(drafts) >= limit: - drafts = drafts[:limit] - protocols = [] - else: - protocols = protocols[: max(0, limit - len(drafts))] - for row in drafts: - row["update_user_name"] = STAMP_CLAIMING - for row in protocols: - row["process_status"] = PROCESS_CLAIMING - return Claimed(drafts=list(drafts), protocols=list(protocols)) - - def save_group(self, payload: GroupSave) -> None: - for item in payload.table4_updates: - target = self.table4[int(item["fid"])] - target["contract_status"] = item["contract_status"] - target["update_user_name"] = item["update_user_name"] - for fid in payload.table4_release_fids: - self.table4[int(fid)]["update_user_name"] = STAMP_CONTRACT - for row in payload.table4_inserts: - item = dict(row) - item["fid"] = self._next_fid - self._next_fid += 1 - self.table4[item["fid"]] = item - for item in payload.table3_updates: - target = self.table3[int(item["fid"])] - target["process_status"] = item["process_status"] - target["process_result"] = item["process_result"] - - def load_contract_goods(self, contract_no: str) -> list[dict]: - number = (contract_no or "").strip() - oms_ids = { - int(row["oms_id"]) - for row in self.table4.values() - if (row.get("contract_no") or "").strip() == number and row.get("oms_id") is not None - } - return [ - dict(row) - for row in self.table3.values() - if (row.get("contract_no") or "").strip() == number - or int(row.get("oms_id") or 0) in oms_ids - ] - - -_SELECT_DRAFTS = """ +_SELECT_DRAFTS_BODY = """ SELECT 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, - NVL(update_user_name, '合同班') AS update_user_name -FROM rpa_contract_agreement_result + process_status +FROM rpa_contract_agreement_result src WHERE action_code = 1 AND contract_status = 1 AND contract_no IS NOT NULL AND LENGTH(TRIM(contract_no)) > 0 - AND NVL(update_user_name, '合同班') = '合同班' + AND ( + process_status = 0 + OR ( + process_status = 3 + AND update_time < SYSDATE - NUMTODSINTERVAL(:lease_minutes, 'MINUTE') + ) + ) + AND NOT EXISTS ( + SELECT 1 + FROM rpa_contract_agreement_result held + WHERE held.action_code = 1 + AND TRIM(held.contract_no) = TRIM(src.contract_no) + AND held.process_status = 3 + AND ( + held.update_time IS NULL + OR held.update_time >= SYSDATE - NUMTODSINTERVAL(:lease_minutes, 'MINUTE') + ) + ) ORDER BY fid """ -_SELECT_DRAFTS_LIMIT = f""" -SELECT * FROM ( -{_SELECT_DRAFTS} -) WHERE ROWNUM <= :limit -""" +_SELECT_DRAFTS = _SELECT_DRAFTS_BODY + "\nFOR UPDATE SKIP LOCKED" + +# 起草行按合同号在领取后截组,SQL 不按行 ROWNUM 切开。 +_SELECT_DRAFTS_LIMIT = _SELECT_DRAFTS _CLAIM_DRAFT_SQL = """ UPDATE rpa_contract_agreement_result -SET update_user_name = :stamp, +SET process_status = 3, + update_user_id = 0, + update_user_name = '系统管理员', update_time = SYSTIMESTAMP -WHERE fid = :fid - AND action_code = 1 - AND contract_status = 1 - AND NVL(update_user_name, :contract_stamp) = :contract_stamp +WHERE fid IN ({placeholders}) """ -_SELECT_PROTOCOLS = """ +_SELECT_PROTOCOLS_BODY = """ SELECT fid, oms_id, contract_no, contract_entry_status, process_status, org_code, NVL(process_result, '') AS process_result FROM rpa_purchase_contract_data -WHERE process_status = 0 +WHERE ( + process_status = 0 + OR (process_status = 3 AND process_time < SYSDATE - NUMTODSINTERVAL(:lease_minutes, 'MINUTE')) + ) AND NVL(contract_entry_status, -1) = 0 AND contract_no IS NOT NULL AND LENGTH(TRIM(contract_no)) > 0 ORDER BY fid """ +_SELECT_PROTOCOLS = _SELECT_PROTOCOLS_BODY + "\nFOR UPDATE SKIP LOCKED" + _SELECT_PROTOCOLS_LIMIT = f""" SELECT * FROM ( -{_SELECT_PROTOCOLS} +{_SELECT_PROTOCOLS_BODY} ) WHERE ROWNUM <= :limit +FOR UPDATE SKIP LOCKED """ _CLAIM_PROTOCOL_SQL = """ @@ -164,36 +97,40 @@ UPDATE rpa_purchase_contract_data SET process_status = 3, process_time = SYSTIMESTAMP, update_time = SYSTIMESTAMP -WHERE fid = :fid AND process_status = 0 +WHERE fid IN ({placeholders}) """ _UPDATE_DRAFT_SQL = """ UPDATE rpa_contract_agreement_result SET contract_status = :contract_status, + process_status = :process_status, + agreement_fail_reason = :agreement_fail_reason, update_user_id = 0, - update_user_name = :stamp, + update_user_name = '系统管理员', update_time = SYSTIMESTAMP -WHERE fid = :fid AND update_user_name = :claim_stamp +WHERE fid = :fid AND process_status = 3 """ _RELEASE_DRAFT_SQL = """ UPDATE rpa_contract_agreement_result -SET update_user_name = :stamp, +SET process_status = 0, + update_user_id = 0, + update_user_name = '系统管理员', update_time = SYSTIMESTAMP -WHERE fid = :fid AND update_user_name = :claim_stamp +WHERE fid = :fid AND process_status = 3 """ _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, + 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, SYSTIMESTAMP, SYSTIMESTAMP, - 0, '返利班', 0, '返利班', :org_code + 0, :process_status, SYSTIMESTAMP, SYSTIMESTAMP, + 0, '系统管理员', 0, '系统管理员', :org_code ) """ @@ -216,10 +153,10 @@ _TABLE4_BIND_KEYS = ( "agreement_fail_reason", "agreement_id", "agreement_dtl_id", + "process_status", "org_code", ) - _SELECT_CONTRACT_GOODS = """ SELECT fid, oms_id, supplier_id, supplier_name, supplier_mdm_code, @@ -236,132 +173,415 @@ ORDER BY fid """ +# 一次领取拿到的表 4 起草行和表 3 只录协议行。 +@dataclass +class Claimed: + drafts: list[dict] = field(default_factory=list) + protocols: list[dict] = field(default_factory=list) + + +# 合同号去掉空白后非空才算有号。 +def _has_contract_no(row: dict) -> bool: + return bool((row.get("contract_no") or "").strip()) + + +# 判断是否待查号起草行:action_code=1、contract_status=1、process_status=0、已有合同号。 +def is_draft(row: dict) -> bool: + if int(row.get("action_code") or 0) != ACTION_CONTRACT: + return False + if int(row.get("contract_status") or 0) != CONTRACT_DRAFT: + return False + if int(row.get("process_status") or 0) != PROCESS_PENDING: + return False + return _has_contract_no(row) + + +# 判断是否待领的只录协议行:process_status=0、contract_entry_status=0、已有合同号。 +def is_protocol_only(row: dict) -> bool: + if int(row.get("process_status") or 0) != PROCESS_PENDING: + return False + entry = row.get("contract_entry_status") + if entry is None or int(entry) != ENTRY_PROTOCOL_ONLY: + return False + return _has_contract_no(row) + + +# 合同号去掉空白,作为整组领取的键。 +def _contract_key(row: dict) -> str: + return (row.get("contract_no") or "").strip() + + +# 领取时间已满租约才算过期。没有时间不算过期。 +def _lease_expired(claimed_at: datetime | None, now: datetime) -> bool: + if claimed_at is None: + return False + return now - claimed_at >= timedelta(minutes=LEASE_MINUTES) + + +# 判断同合同号是否仍被未过租约的 process_status=3 占着。 +def _is_fresh_hold(row: dict, now: datetime) -> bool: + if int(row.get("action_code") or 0) != ACTION_CONTRACT: + return False + if int(row.get("contract_status") or 0) != CONTRACT_DRAFT: + return False + if not _has_contract_no(row): + return False + if int(row.get("process_status") or 0) != PROCESS_CLAIMING: + return False + return not _lease_expired(row.get("update_time"), now) + + +# 判断内存表 4 行是否可领:process_status=0,或 3 且 update_time 已过租约。 +def _is_claimable_draft(row: dict, now: datetime) -> bool: + if int(row.get("action_code") or 0) != ACTION_CONTRACT: + return False + if int(row.get("contract_status") or 0) != CONTRACT_DRAFT: + return False + if not _has_contract_no(row): + return False + status = int(row.get("process_status") or 0) + if status == PROCESS_PENDING: + return True + if status == PROCESS_CLAIMING: + return _lease_expired(row.get("update_time"), now) + return False + + +# 按合同号成组截到 limit。第一组超限也整组拿走,之后装不下的整组留下。 +def _take_draft_groups(rows: list[dict], limit: int | None) -> list[dict]: + groups: dict[str, list[dict]] = {} + order: list[str] = [] + for row in sorted(rows, key=lambda item: int(item["fid"])): + key = _contract_key(row) + if key not in groups: + order.append(key) + groups[key] = [] + groups[key].append(row) + chosen: list[dict] = [] + for key in order: + group = groups[key] + if limit is not None and chosen and len(chosen) + len(group) > limit: + break + chosen.extend(group) + return chosen + + +# 判断内存表 3 行是否可领:只录协议且状态 0,或 3 且 process_time 已过租约。 +def _is_claimable_protocol(row: dict, now: datetime) -> bool: + entry = row.get("contract_entry_status") + if entry is None or int(entry) != ENTRY_PROTOCOL_ONLY: + return False + if not _has_contract_no(row): + return False + status = int(row.get("process_status") or 0) + if status == PROCESS_PENDING: + return True + if status == PROCESS_CLAIMING: + claimed_at = row.get("process_time") + if claimed_at is None: + return False + return now - claimed_at >= timedelta(minutes=LEASE_MINUTES) + return False + + +# 回滚当前事务。回滚本身失败则忽略。 +def _rollback(conn) -> None: + try: + conn.rollback() + except Exception: + pass + + +# 把当前游标结果收成小写列名的字典列表。 def _records(cur) -> list[dict]: cols = [item[0].lower() for item in cur.description] return [dict(zip(cols, rec)) for rec in cur.fetchall()] -class OracleRebateRepo: - def __init__(self, connect: Callable): - self._connect = connect +# 库故障对外「数据库不可用」,已是该异常则原样抛出。 +def _wrap_unavailable(exc: BaseException) -> RepoUnavailable: + if isinstance(exc, RepoUnavailable): + return exc + return RepoUnavailable("数据库不可用") + +# 返利班次的内存仓库,测领取、合组和落表,不测 SQL。 +class MemoryRebateRepo: + # 用给定的表 4 / 表 3 行初始化。同一 fid 后写覆盖先写。 + def __init__( + self, + table4: list[dict] | None = None, + table3: list[dict] | None = None, + ): + self.table4: dict[int, dict] = { + int(row["fid"]): dict(row) for row in (table4 or []) + } + self.table3: dict[int, dict] = { + int(row["fid"]): dict(row) for row in (table3 or []) + } + self._next_fid = (max(self.table4) + 1) if self.table4 else 1 + self.shift_errors: list[ShiftError] = [] + + # 领取待查号任务,表 4 process_status 改成 3,表 3 只录协议行改成 3。 + # + # 先领起草行,同一合同号整组进出,limit 有余再领协议行。 + # 组内还有未过租约的 3 则整组不领。now 只给单测钉住时钟。 + def claim_pending( + self, limit: int | None = None, *, now: datetime | None = None + ) -> Claimed: + clock = now or datetime.now() + held = { + _contract_key(row) + for row in self.table4.values() + if _is_fresh_hold(row, clock) + } + claimable = [ + row + for row in self.table4.values() + if _is_claimable_draft(row, clock) and _contract_key(row) not in held + ] + drafts = _take_draft_groups(claimable, limit) + protocols = sorted( + (row for row in self.table3.values() if _is_claimable_protocol(row, clock)), + key=lambda row: int(row["fid"]), + ) + if limit is not None: + remaining = limit - len(drafts) + if remaining <= 0: + protocols = [] + else: + protocols = protocols[:remaining] + for row in drafts: + row["process_status"] = PROCESS_CLAIMING + row["update_user_id"] = 0 + row["update_user_name"] = USER_NAME + row["update_time"] = clock + for row in protocols: + row["process_status"] = PROCESS_CLAIMING + row["process_time"] = clock + return Claimed(drafts=list(drafts), protocols=list(protocols)) + + # 把一组查号/录协议结果写入内存表 4,并改表 3 的 process_status。 + def save_group(self, payload: GroupSave) -> None: + for item in payload.table4_updates: + target = self.table4[int(item["fid"])] + target["contract_status"] = item["contract_status"] + target["process_status"] = item["process_status"] + if "agreement_fail_reason" in item: + target["agreement_fail_reason"] = item["agreement_fail_reason"] + target["update_user_id"] = 0 + target["update_user_name"] = USER_NAME + for fid in payload.table4_release_fids: + target = self.table4[int(fid)] + target["process_status"] = PROCESS_PENDING + target["update_user_id"] = 0 + target["update_user_name"] = USER_NAME + for row in payload.table4_inserts: + item = dict(row) + item["fid"] = self._next_fid + self._next_fid += 1 + self.table4[item["fid"]] = item + for item in payload.table3_updates: + target = self.table3[int(item["fid"])] + target["process_status"] = item["process_status"] + target["process_result"] = item["process_result"] + + # 记下未捕获异常原文。不改表 3 / 表 4。 + def save_shift_errors(self, items: list[ShiftError]) -> None: + self.shift_errors.extend(items) + + # 按合同号从表 3 取货品行;表 4 同号的 oms_id 也会对上。 + def load_contract_goods(self, contract_no: str) -> list[dict]: + number = (contract_no or "").strip() + oms_ids = { + int(row["oms_id"]) + for row in self.table4.values() + if (row.get("contract_no") or "").strip() == number and row.get("oms_id") is not None + } + return [ + dict(row) + for row in self.table3.values() + if (row.get("contract_no") or "").strip() == number + or int(row.get("oms_id") or 0) in oms_ids + ] + + +# 返利班次连 Oracle 的仓库,管表 4 / 表 3 领取和表 4 写入。 +# +# 连接从 rpa_oracle 连接池取。领取戳是表 4 / 表 3 的 process_status=3。 +# 进程退出后行停在 3,超过租约才能再领。 +class OracleRebateRepo: + # 挂上连接池。方法里按次 acquire,班次 session 内复用一条。 + def __init__(self, pool): + self._pool = pool + self._held = None + + # 按环境变量建班次池。缺 HOST / 用户 / 密码抛「数据库不可用」。 @classmethod def from_env(cls) -> "OracleRebateRepo": - oracle_dsn() - return cls(connect=connect_from_env) + return cls(pool=job_pool()) - def claim_pending(self, limit: int | None = None) -> Claimed: - conn = self._connect() - try: - with conn.cursor() as cur: - drafts = self._claim_drafts(cur, limit) - remaining = None if limit is None else max(0, int(limit) - len(drafts)) - protocols: list[dict] = [] - if remaining is None or remaining > 0: - protocols = self._claim_protocols(cur, remaining) - conn.commit() - return Claimed(drafts=drafts, protocols=protocols) - except Exception as exc: + # 未捕获异常原文进共用审计表。独立事务,失败只记日志。 + 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: - conn.rollback() - except Exception: - pass - raise RepoUnavailable(str(exc)) from exc - finally: - conn.close() + 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() + + # 领取待查号任务,表 4 process_status 改成 3,表 3 只录协议行改成 3。 + # + # 使用 FOR UPDATE SKIP LOCKED,两个班次不会领到同一行。 + # 进程退出后行停在 3,超过租约才能再领。 + # 先领起草行,同一合同号整组进出,limit 有余再领协议行。没有可领的行时返回空 Claimed。 + def claim_pending(self, limit: int | None = None) -> Claimed: + draft_binds: dict = {"lease_minutes": LEASE_MINUTES} + protocol_binds: dict = {"lease_minutes": LEASE_MINUTES} + try: + with self._acquire() as conn: + try: + with conn.cursor() as cur: + drafts = self._claim_drafts(cur, limit, draft_binds) + remaining = None if limit is None else max(0, int(limit) - len(drafts)) + protocols: list[dict] = [] + if remaining is None or remaining > 0: + protocols = self._claim_protocols(cur, remaining, protocol_binds) + conn.commit() + return Claimed(drafts=drafts, protocols=protocols) + except Exception as exc: + _rollback(conn) + raise _wrap_unavailable(exc) from exc + except RepoUnavailable: + raise + except Exception as exc: + raise RepoUnavailable("数据库不可用") from exc + + # 按合同号从表 3 取货品行,供总单/细单页面填字段。 + # + # 表 4 同号的 oms_id 也会对上。空合同号返回空列表。库故障对外「数据库不可用」。 def load_contract_goods(self, contract_no: str) -> list[dict]: number = (contract_no or "").strip() if not number: return [] - conn = self._connect() try: - with conn.cursor() as cur: - cur.execute(_SELECT_CONTRACT_GOODS, {"contract_no": number}) - return _records(cur) + with self._acquire() as conn: + try: + with conn.cursor() as cur: + cur.execute(_SELECT_CONTRACT_GOODS, {"contract_no": number}) + return _records(cur) + except Exception as exc: + raise _wrap_unavailable(exc) from exc + except RepoUnavailable: + raise except Exception as exc: - raise RepoUnavailable(str(exc)) from exc - finally: - conn.close() + raise RepoUnavailable("数据库不可用") from exc + # 把一组查号/录协议结果写入表 4,并改表 3 的 process_status。 + # + # 起草更新、释放、插入、表 3 回写各一次 executemany,同一连接同一事务。 + # 某一侧没有行就跳过那一次调用。库故障对外「数据库不可用」,不含 ORA 原文。 def save_group(self, payload: GroupSave) -> None: - conn = self._connect() + draft_binds = [ + { + "fid": int(item["fid"]), + "contract_status": item["contract_status"], + "process_status": item["process_status"], + "agreement_fail_reason": item.get("agreement_fail_reason"), + } + for item in payload.table4_updates + ] + release_binds = [{"fid": int(fid)} for fid in payload.table4_release_fids] + insert_binds = [ + {key: row.get(key) for key in _TABLE4_BIND_KEYS} + for row in payload.table4_inserts + ] + protocol_binds = [ + { + "fid": int(item["fid"]), + "process_status": item["process_status"], + "process_result": item["process_result"], + } + for item in payload.table3_updates + ] try: - with conn.cursor() as cur: - for item in payload.table4_updates: - cur.execute( - _UPDATE_DRAFT_SQL, - { - "fid": int(item["fid"]), - "contract_status": item["contract_status"], - "stamp": STAMP_DONE, - "claim_stamp": STAMP_CLAIMING, - }, - ) - for fid in payload.table4_release_fids: - cur.execute( - _RELEASE_DRAFT_SQL, - { - "fid": int(fid), - "stamp": STAMP_CONTRACT, - "claim_stamp": STAMP_CLAIMING, - }, - ) - for row in payload.table4_inserts: - cur.execute( - _INSERT_TABLE4_SQL, - {key: row.get(key) for key in _TABLE4_BIND_KEYS}, - ) - for item in payload.table3_updates: - cur.execute( - _COMPLETE_PROTOCOL_SQL, - { - "fid": int(item["fid"]), - "process_status": item["process_status"], - "process_result": item["process_result"], - }, - ) - conn.commit() + with self._acquire() as conn: + try: + with conn.cursor() as cur: + if draft_binds: + cur.executemany(_UPDATE_DRAFT_SQL, draft_binds) + if release_binds: + cur.executemany(_RELEASE_DRAFT_SQL, release_binds) + if insert_binds: + cur.executemany(_INSERT_TABLE4_SQL, insert_binds) + if protocol_binds: + cur.executemany(_COMPLETE_PROTOCOL_SQL, protocol_binds) + conn.commit() + except Exception as exc: + _rollback(conn) + raise _wrap_unavailable(exc) from exc + except RepoUnavailable: + raise except Exception as exc: - try: - conn.rollback() - except Exception: - pass - raise RepoUnavailable(str(exc)) from exc - finally: - conn.close() + raise RepoUnavailable("数据库不可用") from exc - def _claim_drafts(self, cur, limit: int | None) -> list[dict]: - if limit is None: - cur.execute(_SELECT_DRAFTS) - else: - cur.execute(_SELECT_DRAFTS_LIMIT, {"limit": int(limit)}) - fetched = _records(cur) - claimed: list[dict] = [] - for row in fetched: - cur.execute( - _CLAIM_DRAFT_SQL, - { - "fid": row["fid"], - "stamp": STAMP_CLAIMING, - "contract_stamp": STAMP_CONTRACT, - }, - ) - if cur.rowcount == 1: - row["update_user_name"] = STAMP_CLAIMING - claimed.append(row) - return claimed + def _claim_drafts(self, cur, limit: int | None, binds: dict) -> list[dict]: + """SKIP LOCKED 选出表 4 起草行,按合同号整组改成 process_status=3。 - def _claim_protocols(self, cur, limit: int | None) -> list[dict]: - if limit is None: - cur.execute(_SELECT_PROTOCOLS) - else: - cur.execute(_SELECT_PROTOCOLS_LIMIT, {"limit": int(limit)}) - fetched = _records(cur) - claimed: list[dict] = [] + 第一组超 limit 仍整组领走。更新按每段 500 个 fid 执行,不在这里提交。 + """ + cur.execute(_SELECT_DRAFTS, binds) + fetched = _take_draft_groups(_records(cur), limit) + if not fetched: + return [] + update_by_fid_chunks( + cur, + _CLAIM_DRAFT_SQL, + [int(row["fid"]) for row in fetched], + ) for row in fetched: - cur.execute(_CLAIM_PROTOCOL_SQL, {"fid": row["fid"]}) - if cur.rowcount == 1: - row["process_status"] = PROCESS_CLAIMING - claimed.append(row) - return claimed + row["process_status"] = PROCESS_CLAIMING + row["update_user_id"] = 0 + row["update_user_name"] = USER_NAME + return fetched + + def _claim_protocols(self, cur, limit: int | None, binds: dict) -> list[dict]: + """SKIP LOCKED 选出表 3 只录协议行并改成 process_status=3。 + + 更新按每段 500 个 fid 执行,不在这里提交。 + """ + if limit is None: + cur.execute(_SELECT_PROTOCOLS, binds) + else: + cur.execute(_SELECT_PROTOCOLS_LIMIT, {**binds, "limit": int(limit)}) + fetched = _records(cur) + if not fetched: + return [] + update_by_fid_chunks( + cur, + _CLAIM_PROTOCOL_SQL, + [int(row["fid"]) for row in fetched], + ) + for row in fetched: + row["process_status"] = PROCESS_CLAIMING + return fetched diff --git a/rpa_rebate/run_batch.py b/rpa_rebate/run_batch.py index 5d1c1e8..dfb49c0 100644 --- a/rpa_rebate/run_batch.py +++ b/rpa_rebate/run_batch.py @@ -1,12 +1,14 @@ -"""返利班次入口:领表 4 起草行 / 表 3 只录协议行,runner 查号 → 总单 → 细单 → 送审,写表 4。""" +# 返利班次入口:领表 4 起草行 / 表 3 只录协议行,runner 查号 → 总单 → 细单 → 送审,写表 4。 + from __future__ import annotations import argparse +from pathlib import Path from apa_browser_adapter import ApaBrowserAdapter -from db import load_runtime_env +from rpa_oracle import load_job_env from job import recorder, run_batch -from persist import KIND_NOT_FOUND, AgreementOutcome, RebateGroup +from persist import KIND_HOLD, KIND_NOT_FOUND, AgreementOutcome, RebateGroup from rebate_finder import RebateFinder, context_from_env from rebate_flow_steps import ( import_draft_for_contract, @@ -16,49 +18,82 @@ from rebate_flow_steps import ( ) from repo import OracleRebateRepo +DEFAULT_CLAIM_LIMIT = 10 + def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """解析返利班次参数。不传 --limit 时按行领 10 条,不再全部领走。""" parser = argparse.ArgumentParser(description="返利班次:查号 + 总单 + 细单 + 送审") - parser.add_argument("--limit", type=int, default=None, help="本班最多领取 N 条") + parser.add_argument( + "--limit", + type=int, + default=DEFAULT_CLAIM_LIMIT, + help="本班最多领取 N 条,默认 10", + ) return parser.parse_args(argv) def production_runner(browser, ctx, finder: RebateFinder, group: RebateGroup) -> AgreementOutcome: + """一组合同:先查号。确认没有单据才留给下一班。 + + 送审前按合同号查已有协议。已有总单号则补写,不再新建。 + 读号失败保持处理中,不把起草行改回待领。 + """ contract_no = group.contract_no print(f"find contract_no={contract_no}", flush=True) try: found = bool(finder(contract_no)) - except Exception: - return AgreementOutcome(KIND_NOT_FOUND, None, None, None) + except Exception as exc: + text = str(exc).strip() + return AgreementOutcome(KIND_HOLD, None, None, "读号失败", text or None) print(f"found={'yes' if found else 'no'} contract_no={contract_no}", flush=True) if not found: return AgreementOutcome(KIND_NOT_FOUND, None, None, None) + def existing(number: str) -> str: + """读当前页上的协议总单号。合同号本身不算已有协议。""" + from rebate_common import _scan_rebate_order_no + + found_id = (_scan_rebate_order_no(browser, contract_no=number) or "").strip() + if found_id == (number or "").strip(): + return "" + return found_id + + # 填总单并返回协议总单号。没拿到号后面就不送审。 def header(number: str) -> str: print(f"header contract_no={number}", flush=True) return run_header_flow(browser, ctx, number) + # 按表 3 货品新增细单。有失败行时这一组不算录入成功。 def importer(number: str): print(f"import contract_no={number}", flush=True) return run_import_flow(browser, ctx, number, import_draft_for_contract(number)) + # 送审。成功才由落表逻辑插 actionCode=2。 def submitter(number: str): print(f"submit contract_no={number}", flush=True) return run_submit_flow(browser, ctx, number) - return recorder(contract_no, header, importer, submitter) + return recorder(contract_no, header, importer, submitter, existing=existing) -def main(argv: list[str] | None = None) -> int: +def main(argv: list[str] | None = None, repo=None, runner=None) -> int: + """领表 4 起草行和表 3 只录协议行,跑完查号到送审。有失败则进程退出码 1。 + + 不传 --limit 时最多领 10 行。传入 repo 和 runner 时不打开浏览器。 + """ args = parse_args(argv) - load_runtime_env() - repo = OracleRebateRepo.from_env() - ctx = context_from_env() - browser = ApaBrowserAdapter(url=ctx.rebate_url, flow="REBATE_FIND") - finder = RebateFinder(browser, ctx) + load_job_env(Path(__file__).resolve().parent) + if repo is None: + repo = OracleRebateRepo.from_env() + if runner is None: + ctx = context_from_env() + browser = ApaBrowserAdapter(url=ctx.rebate_url, flow="REBATE_FIND") + finder = RebateFinder(browser, ctx) + runner = lambda group: production_runner(browser, ctx, finder, group) summary = run_batch( repo, - runner=lambda group: production_runner(browser, ctx, finder, group), + runner=runner, limit=args.limit, ) print( diff --git a/rpa_rebate/run_find.py b/rpa_rebate/run_find.py index f0bcec8..266e5e1 100644 --- a/rpa_rebate/run_find.py +++ b/rpa_rebate/run_find.py @@ -1,16 +1,19 @@ -"""页面查号入口。不连 Oracle、不录协议。验收只看 ACCEPT.txt。""" +# 页面查号入口。不连 Oracle、不录协议。验收只看 ACCEPT.txt。 + from __future__ import annotations import os +from pathlib import Path from apa_browser_adapter import ApaBrowserAdapter -from db import load_runtime_env +from rpa_oracle import load_job_env from rebate_finder import context_from_env from rebate_flow_steps import SENTINEL_CONTRACT_NO, run_find_flow +# 只跑查号页。没配合同号时用哨兵号验收「搜不到」。不写表。 def main() -> int: - load_runtime_env() + load_job_env(Path(__file__).resolve().parent) contract_no = (os.getenv("REBATE_ACCEPT_CONTRACT_NO") or SENTINEL_CONTRACT_NO).strip() expect_found = None if contract_no != SENTINEL_CONTRACT_NO else False ctx = context_from_env() diff --git a/rpa_rebate/run_header.py b/rpa_rebate/run_header.py index 543f549..4208579 100644 --- a/rpa_rebate/run_header.py +++ b/rpa_rebate/run_header.py @@ -1,16 +1,19 @@ -"""页面总单入口。不连 Oracle、不导细单、不送审。验收只看 ACCEPT.txt。""" +# 页面总单入口。不连 Oracle、不导细单、不送审。验收只看 ACCEPT.txt。 + from __future__ import annotations import os +from pathlib import Path from apa_browser_adapter import ApaBrowserAdapter -from db import load_runtime_env +from rpa_oracle import load_job_env from rebate_finder import context_from_env from rebate_flow_steps import header_draft_for_contract, run_header_flow +# 只跑总单页。必须已有合同号。拿到协议总单号才算通过。不写表。 def main() -> int: - load_runtime_env() + load_job_env(Path(__file__).resolve().parent) contract_no = (os.getenv("REBATE_ACCEPT_CONTRACT_NO") or "").strip() if not contract_no: print("缺少 REBATE_ACCEPT_CONTRACT_NO,总单验收需要已搜到的合同号", flush=True) diff --git a/rpa_rebate/run_import.py b/rpa_rebate/run_import.py index b099e6f..5c30c14 100644 --- a/rpa_rebate/run_import.py +++ b/rpa_rebate/run_import.py @@ -1,16 +1,19 @@ -"""页面细单新增入口(按货品ID搜,不做 Excel)。不连 Oracle、不送审、不写 actionCode=2。验收只看 ACCEPT.txt。""" +# 页面细单新增入口(按货品ID搜,不做 Excel)。不连 Oracle、不送审、不写 actionCode=2。验收只看 ACCEPT.txt。 + from __future__ import annotations import os +from pathlib import Path from apa_browser_adapter import ApaBrowserAdapter -from db import load_runtime_env +from rpa_oracle import load_job_env from rebate_finder import context_from_env from rebate_flow_steps import import_draft_for_contract, run_import_flow +# 只跑细单新增。按货品 ID 搜,不做 Excel。货品表有成功行且没有失败行才算通过。不写表。 def main() -> int: - load_runtime_env() + load_job_env(Path(__file__).resolve().parent) contract_no = (os.getenv("REBATE_ACCEPT_CONTRACT_NO") or "").strip() if not contract_no: print("缺少 REBATE_ACCEPT_CONTRACT_NO,细单新增需要已搜到的合同号", flush=True) diff --git a/rpa_rebate/run_submit.py b/rpa_rebate/run_submit.py index a0cd05b..52ec2d3 100644 --- a/rpa_rebate/run_submit.py +++ b/rpa_rebate/run_submit.py @@ -1,16 +1,19 @@ -"""页面送审入口。不连 Oracle、不写 actionCode=2、不推口 4。验收只看 ACCEPT.txt。""" +# 页面送审入口。不连 Oracle、不写 actionCode=2、不推口 4。验收只看 ACCEPT.txt。 + from __future__ import annotations import os +from pathlib import Path from apa_browser_adapter import ApaBrowserAdapter -from db import load_runtime_env +from rpa_oracle import load_job_env from rebate_finder import context_from_env from rebate_flow_steps import run_submit_flow +# 只跑送审页。点到送审并读到协议号才算通过。不写 actionCode=2。 def main() -> int: - load_runtime_env() + load_job_env(Path(__file__).resolve().parent) contract_no = (os.getenv("REBATE_ACCEPT_CONTRACT_NO") or "").strip() if not contract_no: print("缺少 REBATE_ACCEPT_CONTRACT_NO,送审需要已搜到的合同号", flush=True) diff --git a/rpa_rebate/tests/test_apa_browser_adapter.py b/rpa_rebate/tests/test_apa_browser_adapter.py new file mode 100644 index 0000000..79d8f33 --- /dev/null +++ b/rpa_rebate/tests/test_apa_browser_adapter.py @@ -0,0 +1,49 @@ +# 返利浏览器适配:共享类、Chrome junction、端口差异。 + +from __future__ import annotations + +from apa_browser_adapter import ( + ApaBrowserAdapter, + REPO_ROOT, + chrome_debug_port, + chrome_session_flow, + resolve_chrome_profile_dir, + use_user_chrome, +) + + +# 返利 ApaBrowserAdapter 是 rpa_browser 共享类或其子类。 +def test_adapter_is_shared_or_subclass(): + from rpa_browser.adapter import ApaBrowserAdapter as Shared + + assert issubclass(ApaBrowserAdapter, Shared) + + +# REBATE_HEADER / IMPORT / SUBMIT 复用查号同一 profile 和调试口。 +def test_rebate_nodes_share_find_chrome_session(monkeypatch): + monkeypatch.delenv("RPA_USE_USER_CHROME", raising=False) + monkeypatch.delenv("RPA_CHROME_DEBUG_PORT", raising=False) + assert chrome_session_flow("REBATE_HEADER") == "REBATE_FIND" + assert chrome_session_flow("REBATE_IMPORT") == "REBATE_FIND" + assert chrome_session_flow("REBATE_SUBMIT") == "REBATE_FIND" + assert resolve_chrome_profile_dir("REBATE_HEADER") == resolve_chrome_profile_dir("REBATE_FIND") + assert chrome_debug_port("REBATE_HEADER") == chrome_debug_port("REBATE_FIND") + assert chrome_debug_port("REBATE_FIND") == 9335 + + +# RPA_USE_USER_CHROME 打开时调试口固定 9222,不跟班次口走。 +def test_user_chrome_debug_port_is_9222(monkeypatch): + monkeypatch.setenv("RPA_USE_USER_CHROME", "1") + monkeypatch.delenv("RPA_CHROME_DEBUG_PORT", raising=False) + assert use_user_chrome() is True + assert chrome_debug_port("REBATE_FIND") == 9222 + assert chrome_debug_port("REBATE_HEADER") == 9222 + + +# 用户 Chrome 的 profile 是仓库外 daily-chrome junction,不要 resolve 回默认 User Data。 +def test_user_chrome_profile_is_daily_chrome_junction(monkeypatch): + monkeypatch.setenv("RPA_USE_USER_CHROME", "1") + monkeypatch.setattr("apa_browser_adapter._ensure_user_chrome_junction", lambda link, real: None) + path = resolve_chrome_profile_dir("REBATE_FIND") + assert path == REPO_ROOT.parent / "rpa-chrome-profile" / "daily-chrome" + assert path.name == "daily-chrome" diff --git a/rpa_rebate/tests/test_finder.py b/rpa_rebate/tests/test_finder.py index c77aa1f..14be1f7 100644 --- a/rpa_rebate/tests/test_finder.py +++ b/rpa_rebate/tests/test_finder.py @@ -1,6 +1,6 @@ from accept import accept_log_path from job import run_batch -from persist import KIND_OK, STAMP_CONTRACT, STAMP_DONE, AgreementOutcome +from persist import KIND_OK, USER_NAME, AgreementOutcome from rebate_finder import RebateFinder from rebate_flow_steps import ( SENTINEL_CONTRACT_NO, @@ -121,8 +121,9 @@ def test_page_finder_found_writes_agreement_like_stub(): "agreement_dtl_id": None, "org_code": "901", "push_status": 0, - "create_user_name": STAMP_CONTRACT, - "update_user_name": STAMP_CONTRACT, + "process_status": 0, + "create_user_name": USER_NAME, + "update_user_name": USER_NAME, } ] ) @@ -145,7 +146,8 @@ def test_page_finder_found_writes_agreement_like_stub(): summary = run_batch(repo, runner=runner) assert summary.ok == 1 assert repo.table4[1]["contract_status"] == 2 - assert repo.table4[1]["update_user_name"] == STAMP_DONE + assert repo.table4[1]["process_status"] == 1 + assert repo.table4[1]["update_user_name"] == USER_NAME agreements = [row for row in repo.table4.values() if row["action_code"] == 2] assert len(agreements) == 1 assert len(browser.opened) == 1 diff --git a/rpa_rebate/tests/test_header.py b/rpa_rebate/tests/test_header.py index d9aaf39..fc9aa8d 100644 --- a/rpa_rebate/tests/test_header.py +++ b/rpa_rebate/tests/test_header.py @@ -1,9 +1,12 @@ +# 返利总单:表 3 MDM、计划员廖建琦、复用已有总单号。 + from accept import accept_log_path from apa_browser_adapter import chrome_debug_port, chrome_session_flow, resolve_chrome_profile_dir from rebate_flow_steps import ( CONTACT_DEFAULT, LOGIN, PAY_MODE_NEXT_MONTH_N, + PAY_PARAM_DEFAULT, PLANNER_DEFAULT, RebateFlowContext, _pick_person, @@ -15,44 +18,65 @@ from rebate_flow_steps import ( ) +# 总单流程假浏览器,按脚本关键字回勾选和协议总单ID。 class FakeBrowser: - def __init__(self, *, table_text="", body_text="", login_body="", order_no=""): + # 记下页面脚本回传的勾选、保存和协议号。 + def __init__( + self, + *, + table_text="", + body_text="", + login_body="", + order_no="", + existing_order_no="", + ): self.table_text = table_text self.body_text = body_text self.login_body = login_body or body_text self.order_no = order_no + self.existing_order_no = existing_order_no + self.saved = False self.opened: list[str] = [] self.filled: list[tuple[list[str], str]] = [] self.selected: list[tuple[list[str], str]] = [] self.clicked: list[list[str]] = [] self.script_count = 0 + # 记下打开过的 URL。 def open_url(self, url: str) -> None: self.opened.append(url) + # 记下填过的选择器和值。 def fill_first(self, selectors: list[str], value: str) -> None: self.filled.append((list(selectors), value)) + # 记下点过的选择器。 def click_first(self, selectors: list[str]) -> None: self.clicked.append(list(selectors)) + # 记下原生点击,行为同 click_first。 def click_native(self, selectors: list[str]) -> None: self.click_first(selectors) + # 假正文里没有这段字就抛 TimeoutError。 def wait_for_text(self, text: str, timeout_seconds: int = 15) -> None: haystack = f"{self.body_text}\n{self.table_text}\n{self.login_body}" if text not in haystack: raise TimeoutError(text) + # 假页面一律当作元素已在。 def wait_for_element(self, selectors: list[str], timeout_seconds: int = 15) -> None: return None + # 假页面一律当作元素已在。 def element_exists(self, selectors: list[str]) -> bool: return True + # 返回构造时塞进的正文。 def read_body_text(self) -> str: return self.body_text + # 按脚本标记回勾选、保存、协议号。 def execute_script(self, script: str, *args): self.script_count += 1 text = script or "" @@ -66,7 +90,10 @@ class FakeBrowser: if "CLOSE_HEADER_QUERY" in text or "CLOSE_DETAIL_CARD" in text: return {"ok": True} if "CLICK_HEADER_TOOLBAR" in text: - self.clicked.append([str(args[0] if args else "")]) + title = str(args[0] if args else "") + self.clicked.append([title]) + if title == "保存": + self.saved = True return {"ok": True} if "DETAIL_TOAST" in text: return {"texts": []} @@ -85,36 +112,52 @@ class FakeBrowser: value = args[1] if len(args) > 1 else "" self.filled.append(([str(label)], str(value))) return True + if "READ_LABELED_INPUT" in text: + title = str(args[0] if args else "") + if title == "承付参数": + return {"value": PAY_PARAM_DEFAULT} + if title == "计划员": + return {"value": "李四"} + return {"value": ""} if "READ_REBATE_ORDER_NO" in text: - return self.order_no + return self.existing_order_no or (self.order_no if self.saved else "") return self.table_text + # 假截图,返回空路径。 def screenshot(self, name: str) -> str: return name + # 假读取,返回空字符串。 def read_first(self, selectors: list[str], value_name: str = "") -> str: return self.order_no + # 假切入 iframe,什么都不做。 def switch_to_frame(self, selectors: list[str]) -> None: return None + # 假切回主文档,什么都不做。 def switch_to_default_content(self) -> None: return None + # 记下选过的选择器和值。 def select_first(self, selectors: list[str], value: str) -> None: self.selected.append((list(selectors), value)) + # 记下 xform 选择。 def select_xform(self, name: str, value: str) -> None: return None + # 记下上传路径。 def upload_file_first(self, selectors: list[str], file_path: str) -> None: return None +# 构造一段假返利会话入参。 def _ctx() -> RebateFlowContext: return RebateFlowContext(rebate_url="http://example", username="u", password="p") +# 构造一份带测试客商编码的总单草稿。 def _draft(): return build_header_draft( planner_name="李四", @@ -124,6 +167,7 @@ def _draft(): ) +# 总单供应商用表 3 MDM,计划员仍是廖建琦,细单货品ID和备注来自表 3。 def test_header_draft_from_table3_uses_mdm_and_planner(): draft = header_draft_from_table3( [ @@ -162,6 +206,7 @@ def test_header_draft_from_table3_uses_mdm_and_planner(): assert lines[0].net_price == "8.8496" +# 总单默认走现行字段:下月N号结、协议起止当天、返利合约否。 def test_header_defaults_follow_mapping_not_scheme_55(): draft = _draft() assert draft.planner_name == "李四" @@ -172,12 +217,14 @@ def test_header_defaults_follow_mapping_not_scheme_55(): assert draft.settle_mode == "否" +# 计划员名为空时回退廖建琦,联系人苏文辉。 def test_header_draft_defaults_planner(): draft = build_header_draft(planner_name="", supplier_code="S1", rebate_unit_code="901") assert draft.planner_name == PLANNER_DEFAULT assert draft.contact_name == CONTACT_DEFAULT +# 填总单时用草稿里的计划员、结算和承付模式。 def test_fill_header_uses_mapping_values(): browser = FakeBrowser( table_text="HT1 重庆亿昊", @@ -195,6 +242,7 @@ def test_fill_header_uses_mapping_values(): assert "2026-12-31" not in filled_values +# 总单流程写 ACCEPT,带上协议总单ID。 def test_header_flow_writes_accept_with_order_no(tmp_path, monkeypatch): monkeypatch.delenv("RPA_ACCEPT_LOG", raising=False) monkeypatch.setattr("accept.SCREENSHOT_ROOT", tmp_path) @@ -215,6 +263,27 @@ def test_header_flow_writes_accept_with_order_no(tmp_path, monkeypatch): assert "protocolEnd=2026-10-09" in text +# 表上已有协议总单ID则复用,不再新建。 +def test_header_reuses_existing_order_no_without_create(tmp_path, monkeypatch): + monkeypatch.delenv("RPA_ACCEPT_LOG", raising=False) + monkeypatch.setattr("accept.SCREENSHOT_ROOT", tmp_path) + browser = FakeBrowser( + table_text="HT1 重庆亿昊 FL_EXISTING", + body_text="采购协议管理 合同编号 查询 返利合约 承付模式 计划员 保存 总单号 FL_EXISTING", + login_body="采购协议管理", + existing_order_no="FL_EXISTING", + ) + order_no = run_header_flow(browser, _ctx(), "HT1", _draft()) + assert order_no == "FL_EXISTING" + assert ["新增"] not in browser.clicked + assert ["保存"] in browser.clicked + text = accept_log_path("REBATE_HEADER").read_text(encoding="utf-8") + assert "rebateOrderNo=FL_EXISTING" in text + assert "reused=yes" in text + assert "ACCEPT S07 ok 填总单" in text + + +# 已登录会话不再填账密。 def test_header_reuses_logged_in_session(tmp_path, monkeypatch): monkeypatch.delenv("RPA_ACCEPT_LOG", raising=False) monkeypatch.setattr("accept.SCREENSHOT_ROOT", tmp_path) @@ -230,21 +299,23 @@ def test_header_reuses_logged_in_session(tmp_path, monkeypatch): assert "u" not in [value for _, value in browser.filled] +# 总单和查号共用同一 Chrome 会话和端口。 def test_header_chrome_session_reuses_find(): assert chrome_session_flow("REBATE_HEADER") == "REBATE_FIND" assert resolve_chrome_profile_dir("REBATE_HEADER") == resolve_chrome_profile_dir("REBATE_FIND") assert chrome_debug_port("REBATE_HEADER") == chrome_debug_port("REBATE_FIND") +# 计划员三个点打开自己的选择层,不拿货源选择当层。 class PlannerPickBrowser: - """计划员三个点打开自己的选择层,不拿货源选择当层。""" - + # 记下计划员选择层是否打开、选中的姓名和 ID。 def __init__(self): self.planner_name = "" self.planner_id = "" self.layer_open = False self.scripts: list[str] = [] + # 按脚本标记模拟计划员三个点弹层。 def execute_script(self, script: str, *args): text = script or "" self.scripts.append(text) @@ -283,6 +354,7 @@ class PlannerPickBrowser: return None +# 计划员三个点按姓名选廖建琦并绑上 ID,不走货源细单。 def test_pick_person_selects_liao_and_binds_id(): browser = PlannerPickBrowser() _pick_person(browser, "计划员", PLANNER_DEFAULT) diff --git a/rpa_rebate/tests/test_import.py b/rpa_rebate/tests/test_import.py index 81cdfac..b379338 100644 --- a/rpa_rebate/tests/test_import.py +++ b/rpa_rebate/tests/test_import.py @@ -1,3 +1,5 @@ +# 返利细单新增:按货品ID搜、已在表则跳过、ACCEPT 成功失败条数。 + import pytest from accept import accept_log_path from apa_browser_adapter import chrome_debug_port, chrome_session_flow, resolve_chrome_profile_dir @@ -11,6 +13,7 @@ from rebate_flow_steps import ( TAX_RATE_DEFAULT, RebateFlowContext, RebateImportLine, + add_detail_lines_by_goods_id, build_import_draft, build_import_rows, fill_import_excel, @@ -19,12 +22,24 @@ from rebate_flow_steps import ( ) +# 细单流程假浏览器,按脚本关键字回货品表行和导入结果。 class FakeBrowser: - def __init__(self, *, table_text="", body_text="", login_body="", import_result=None): + # 记下页面脚本回传的勾选、保存和协议号。 + def __init__( + self, + *, + table_text="", + body_text="", + login_body="", + import_result=None, + goods_rows=None, + ): self.table_text = table_text self.body_text = body_text self.login_body = login_body or body_text self.import_result = import_result or {"success": 1, "fail": 0, "reasons": []} + self.goods_rows = list(goods_rows) if goods_rows is not None else [] + self.last_goods_id = "" self.opened: list[str] = [] self.filled: list[tuple[list[str], str]] = [] self.selected: list[tuple[list[str], str]] = [] @@ -33,32 +48,41 @@ class FakeBrowser: self.download_dir = None self.script_count = 0 + # 记下打开过的 URL。 def open_url(self, url: str) -> None: self.opened.append(url) + # 记下填过的选择器和值。 def fill_first(self, selectors: list[str], value: str) -> None: self.filled.append((list(selectors), value)) + # 记下点过的选择器。 def click_first(self, selectors: list[str]) -> None: self.clicked.append(list(selectors)) + # 记下原生点击,行为同 click_first。 def click_native(self, selectors: list[str]) -> None: self.click_first(selectors) + # 假正文里没有这段字就抛 TimeoutError。 def wait_for_text(self, text: str, timeout_seconds: int = 15) -> None: haystack = f"{self.body_text}\n{self.table_text}\n{self.login_body}" if text not in haystack: raise TimeoutError(text) + # 假页面一律当作元素已在。 def wait_for_element(self, selectors: list[str], timeout_seconds: int = 15) -> None: return None + # 假页面一律当作元素已在。 def element_exists(self, selectors: list[str]) -> bool: return True + # 返回构造时塞进的正文。 def read_body_text(self) -> str: return self.body_text + # 按脚本标记回勾选、保存、协议号。 def execute_script(self, script: str, *args): self.script_count += 1 text = script or "" @@ -93,17 +117,21 @@ class FakeBrowser: if "CLOSE_HEADER_CARD" in text: return {"ok": True} if "GOODS_TABLE_ROWS" in text: - return ["G001 10 ZQD1"] + return list(self.goods_rows) + if "FILL_GOODS_ID" in text: + self.last_goods_id = str(args[0] if args else "") + return {"ok": True, "label": "货品ID", "value": self.last_goods_id} + if "CONFIRM_DETAIL_CARD" in text: + gid = self.last_goods_id + if gid and not any(gid in row for row in self.goods_rows): + self.goods_rows.append(gid) + return {"ok": True, "via": "确认"} if "PICK_DETAIL_OPTION" in text: return {"ok": True, "text": str(args[0] if args else "")} - if "CONFIRM_DETAIL_CARD" in text: - return {"ok": True, "via": "确认"} if "DETAIL_TOAST" in text: return {"texts": []} if "DETAIL_FORM_ERRORS" in text: return [] - if "FILL_GOODS_ID" in text: - return {"ok": True, "label": "货品ID", "value": str(args[0] if args else "")} if "PICK_GOODS_ROW" in text: return {"ok": True} if "SOURCE_CHOICE" in text: @@ -128,43 +156,54 @@ class FakeBrowser: return {"ok": True} return self.table_text + # 假截图,返回空路径。 def screenshot(self, name: str) -> str: return name + # 假读取,返回空字符串。 def read_first(self, selectors: list[str], value_name: str = "") -> str: return "" + # 假切入 iframe,什么都不做。 def switch_to_frame(self, selectors: list[str]) -> None: return None + # 假切回主文档,什么都不做。 def switch_to_default_content(self) -> None: return None + # 记下选过的选择器和值。 def select_first(self, selectors: list[str], value: str) -> None: self.selected.append((list(selectors), value)) + # 记下 xform 选择。 def select_xform(self, name: str, value: str) -> None: return None + # 记下上传路径。 def upload_file_first(self, selectors: list[str], file_path: str) -> None: self.uploaded.append(file_path) + # 记下下载目录。 def set_download_dir(self, path: str) -> None: from pathlib import Path self.download_dir = Path(path) self.download_dir.mkdir(parents=True, exist_ok=True) + # 在下载目录写一份空 xlsx 当模板。 def _write_template(self) -> None: if self.download_dir is None: return write_canonical_template(self.download_dir / "detail_template.xlsx") +# 构造一段假返利会话入参。 def _ctx() -> RebateFlowContext: return RebateFlowContext(rebate_url="http://example", username="u", password="p") +# 构造一条带货品ID和订单号的细单。 def _line() -> RebateImportLine: return RebateImportLine( goods_code="G001", @@ -175,10 +214,12 @@ def _line() -> RebateImportLine: ) +# 构造一份含一行细单的导入草稿。 def _draft(): return build_import_draft(lines=[_line()], today="2026-10-10") +# Excel 行字典走现行列:备注=订单号、补偿 0、承付参数 25。 def test_import_rows_follow_mapping(): row = build_import_rows([_line()], today="2026-10-10")[0] assert row["货品编码"] == "G001" @@ -197,6 +238,7 @@ def test_import_rows_follow_mapping(): assert row["货源细单ID"] == "SRC1" +# 货品编码列用 goodsId,不用操作码。 def test_import_rows_search_by_goods_id(): line = RebateImportLine( goods_code="FFTMMHTTP", @@ -209,6 +251,7 @@ def test_import_rows_search_by_goods_id(): assert row["货品编码"] == "4000018689" +# 同时有货品编码和 goodsId 时搜 goodsId。 def test_import_line_from_mapping_prefers_goods_id(): from rebate_flow_steps import _line_from_mapping @@ -226,6 +269,7 @@ def test_import_line_from_mapping_prefers_goods_id(): assert row["货品编码"] == "4000018689" +# 细单缺订单号时拒绝组草稿。 def test_import_draft_requires_oms_order_no(): with pytest.raises(NodeError): build_import_draft( @@ -233,6 +277,7 @@ def test_import_draft_requires_oms_order_no(): ) +# 填模板时货品编码列写入 goodsId。 def test_fill_excel_writes_goods_id_into_goods_id_column(tmp_path): from openpyxl import Workbook, load_workbook @@ -258,6 +303,7 @@ def test_fill_excel_writes_goods_id_into_goods_id_column(tmp_path): assert str(ws["A2"].value) == "4000018689" +# 填模板时备注是订单号,补偿是 0。 def test_fill_excel_writes_remark_and_zero_compensation(tmp_path): from openpyxl import load_workbook @@ -276,6 +322,7 @@ def test_fill_excel_writes_remark_and_zero_compensation(tmp_path): assert str(data["承付模式"]) == "11" +# 细单流程写 ACCEPT,带成功失败条数和货品ID。 def test_import_flow_writes_accept_with_success_fail(tmp_path, monkeypatch): monkeypatch.delenv("RPA_ACCEPT_LOG", raising=False) monkeypatch.setattr("accept.SCREENSHOT_ROOT", tmp_path) @@ -304,6 +351,7 @@ def test_import_flow_writes_accept_with_success_fail(tmp_path, monkeypatch): assert "fail=0" in text +# 已登录会话不再填账密。 def test_import_reuses_logged_in_session(tmp_path, monkeypatch): monkeypatch.delenv("RPA_ACCEPT_LOG", raising=False) monkeypatch.setattr("accept.SCREENSHOT_ROOT", tmp_path) @@ -319,7 +367,41 @@ def test_import_reuses_logged_in_session(tmp_path, monkeypatch): assert "u" not in [value for _, value in browser.filled] +# 细单和查号共用同一 Chrome 会话和端口。 def test_import_chrome_session_reuses_find(): assert chrome_session_flow("REBATE_IMPORT") == "REBATE_FIND" assert resolve_chrome_profile_dir("REBATE_IMPORT") == resolve_chrome_profile_dir("REBATE_FIND") assert chrome_debug_port("REBATE_IMPORT") == chrome_debug_port("REBATE_FIND") + + +# 按货品ID构造一条细单,给查重用例用。 +def _goods_line(goods_id: str, *, order_no: str = "ZQD1") -> RebateImportLine: + return RebateImportLine( + goods_code=goods_id, + qty="10", + tax_price="6.70", + order_no=order_no, + goods_id=goods_id, + ) + + +# 货品表已有该 ID 则跳过新增。 +def test_add_detail_skips_goods_already_in_table(): + browser = FakeBrowser(goods_rows=["G001 10 ZQD1"]) + added = add_detail_lines_by_goods_id(browser, [_goods_line("G001")]) + assert added == 1 + assert ["+新增"] not in browser.clicked + assert ["保存"] not in browser.clicked + + +# 同批已有的跳过、没有的仍新增。 +def test_add_detail_skips_existing_and_adds_missing(): + browser = FakeBrowser(goods_rows=["G001 10 ZQD1"]) + added = add_detail_lines_by_goods_id( + browser, + [_goods_line("G001"), _goods_line("G002", order_no="ZQD2")], + ) + assert added == 2 + assert browser.clicked.count(["+新增"]) == 1 + assert any("G002" in row for row in browser.goods_rows) + assert ["保存"] in browser.clicked diff --git a/rpa_rebate/tests/test_job.py b/rpa_rebate/tests/test_job.py index 0566e9e..2e160fa 100644 --- a/rpa_rebate/tests/test_job.py +++ b/rpa_rebate/tests/test_job.py @@ -1,9 +1,28 @@ -from job import recorder, run_batch -from persist import KIND_NOT_FOUND, KIND_OK, STAMP_CONTRACT, STAMP_DONE, AgreementOutcome +# 返利班次:领取戳、租约、查号落表、系统故障短码。 + +from contextlib import contextmanager +from datetime import datetime, timedelta + +from job import ( + HEADER_FAILED, + IMPORT_FAILED, + PAGE_FAILED, + SUBMIT_FAILED, + recorder, + run_batch, +) +from persist import ( + KIND_FAIL, + KIND_NOT_FOUND, + KIND_OK, + USER_NAME, + AgreementOutcome, +) from rebate_flow_steps import RebateImportResult, RebateSubmitResult from repo import MemoryRebateRepo +# 构造一条表 4 起草行。 def _t4(**overrides) -> dict: row = { "fid": 1, @@ -18,13 +37,15 @@ def _t4(**overrides) -> dict: "agreement_dtl_id": None, "org_code": "901", "push_status": 0, - "create_user_name": STAMP_CONTRACT, - "update_user_name": STAMP_CONTRACT, + "process_status": 0, + "create_user_name": USER_NAME, + "update_user_name": USER_NAME, } row.update(overrides) return row +# 构造一条表 3 只录协议行。 def _t3(**overrides) -> dict: row = { "fid": 10, @@ -39,16 +60,33 @@ def _t3(**overrides) -> dict: return row +# 总单替身,返回固定协议总单号。 +def _ok_header(contract_no: str) -> str: + return "RB1" + + +# 细单替身,全部成功。 +def _ok_importer(contract_no: str) -> RebateImportResult: + return RebateImportResult(1, 0, (), True) + + +# 送审替身,带总单/细单号。 +def _ok_submitter(contract_no: str) -> RebateSubmitResult: + return RebateSubmitResult(True, "AGR1", "AGRD1") + + +# 搜到并送审成功:起草行 process_status=1,每 oms_id 插一条 action_code=2。 def test_job_finds_contract_and_writes_agreement(): repo = MemoryRebateRepo( table4=[ _t4(), _t4(fid=2, oms_id=2026100502), - _t4(fid=3, contract_status=2, contract_no="HT-DONE", update_user_name=STAMP_DONE), + _t4(fid=3, contract_status=2, contract_no="HT-DONE", process_status=1), ] ) steps: list[str] = [] + # 记下合同号并回成功。 def runner(group): steps.append(group.contract_no) return AgreementOutcome(KIND_OK, "AGR1", "AGRD1", None) @@ -60,7 +98,8 @@ def test_job_finds_contract_and_writes_agreement(): drafts = [row for row in repo.table4.values() if row["fid"] in (1, 2)] assert {row["contract_status"] for row in drafts} == {2} assert {row["action_code"] for row in drafts} == {1} - assert {row["update_user_name"] for row in drafts} == {STAMP_DONE} + assert {row["process_status"] for row in drafts} == {1} + assert {row["update_user_name"] for row in drafts} == {USER_NAME} assert {row["push_status"] for row in drafts} == {0} agreements = [row for row in repo.table4.values() if row["action_code"] == 2] assert sorted(row["oms_id"] for row in agreements) == [2026100501, 2026100502] @@ -69,14 +108,43 @@ def test_job_finds_contract_and_writes_agreement(): assert {row["agreement_id"] for row in agreements} == {"AGR1"} assert {row["agreement_dtl_id"] for row in agreements} == {"AGRD1"} assert {row["push_status"] for row in agreements} == {0} + assert {row["process_status"] for row in agreements} == {1} + assert {row["create_user_name"] for row in agreements} == {USER_NAME} + assert {row["update_user_name"] for row in agreements} == {USER_NAME} assert repo.table4[3]["contract_status"] == 2 assert repo.table4[3]["action_code"] == 1 +# 送审失败:起草行回到可领,agreement_fail_reason 可查,不插协议行。 +def test_job_failed_draft_stays_claimable_with_reason(): + repo = MemoryRebateRepo( + table4=[_t4(), _t4(fid=2, oms_id=2026100502)] + ) + + # 模拟送审失败。 + def runner(group): + return AgreementOutcome(KIND_FAIL, None, None, "送审失败") + + summary = run_batch(repo, runner=runner) + assert summary.failed == 1 + assert summary.ok == 0 + for fid in (1, 2): + row = repo.table4[fid] + assert row["contract_status"] == 1 + assert row["process_status"] == 0 + assert row["agreement_fail_reason"] == "送审失败" + assert row["action_code"] == 1 + assert [row["action_code"] for row in repo.table4.values()] == [1, 1] + again = repo.claim_pending() + assert [row["fid"] for row in again.drafts] == [1, 2] + + +# 搜不到:起草行 process_status 改回 0,不插协议行,留给下一班。 def test_job_leaves_not_found_for_next_shift(): repo = MemoryRebateRepo(table4=[_t4()]) later = {"n": 0} + # 模拟查号未命中。 def runner(group): later["n"] += 1 return AgreementOutcome(KIND_NOT_FOUND, None, None, None) @@ -86,14 +154,17 @@ def test_job_leaves_not_found_for_next_shift(): row = repo.table4[1] assert row["contract_status"] == 1 assert row["action_code"] == 1 - assert row["update_user_name"] == STAMP_CONTRACT + assert row["process_status"] == 0 + assert row["update_user_name"] == USER_NAME assert [item["action_code"] for item in repo.table4.values()] == [1] +# 表 3 只录协议行领走后写表 4,已完成行不领。 def test_job_claims_protocol_only_and_writes_table4(): repo = MemoryRebateRepo(table3=[_t3(), _t3(fid=11, process_status=1, contract_no="HT-SKIP")]) steps: list[str] = [] + # 记下合同号并回成功。 def runner(group): steps.append(group.contract_no) return AgreementOutcome(KIND_OK, "AGR-OLD", "AGRD-OLD", None) @@ -115,17 +186,106 @@ def test_job_claims_protocol_only_and_writes_table4(): assert agreements[0]["agreement_dtl_id"] == "AGRD-OLD" +# 待查号行领走后 process_status=3,已回写行不领。 +def test_claim_pending_stamps_drafts_and_skips_done(): + repo = MemoryRebateRepo( + table4=[ + _t4(), + _t4(fid=2, contract_status=2, process_status=1), + ] + ) + claimed = repo.claim_pending() + assert [row["fid"] for row in claimed.drafts] == [1] + assert claimed.drafts[0]["process_status"] == 3 + assert claimed.drafts[0]["update_user_name"] == USER_NAME + assert repo.table4[1]["process_status"] == 3 + assert repo.table4[2]["process_status"] == 1 + + +# 表 4 process_status=3 且 update_time 超过租约的行可以再领。 +def test_claim_reclaims_expired_claiming_stamp(): + now = datetime(2026, 10, 22, 12, 0, 0) + repo = MemoryRebateRepo( + table4=[_t4(process_status=3, update_time=now - timedelta(minutes=31))] + ) + claimed = repo.claim_pending(now=now) + assert [row["fid"] for row in claimed.drafts] == [1] + assert repo.table4[1]["process_status"] == 3 + + +# 表 4 process_status=3 且未过租约的行不领。 +def test_claim_skips_fresh_claiming_stamp(): + now = datetime(2026, 10, 22, 12, 0, 0) + repo = MemoryRebateRepo( + table4=[_t4(process_status=3, update_time=now - timedelta(minutes=1))] + ) + claimed = repo.claim_pending(now=now) + assert claimed.drafts == [] + assert repo.table4[1]["process_status"] == 3 + + +# limit 不切开同一合同号;组内有未过租约的 3 则整组不领。 +def test_claim_keeps_whole_contract_and_skips_fresh_sibling(): + now = datetime(2026, 10, 22, 12, 0, 0) + repo = MemoryRebateRepo( + table4=[ + _t4(fid=1, contract_no="HT1"), + _t4(fid=2, oms_id=2026100502, contract_no="HT1"), + _t4(fid=3, oms_id=2026100503, contract_no="HT2"), + _t4(fid=4, oms_id=2026100504, contract_no="HT3"), + _t4( + fid=5, + oms_id=2026100505, + contract_no="HT3", + process_status=3, + update_time=now - timedelta(minutes=1), + ), + ] + ) + claimed = repo.claim_pending(limit=1, now=now) + assert [row["fid"] for row in claimed.drafts] == [1, 2] + assert repo.table4[3]["process_status"] == 0 + assert repo.table4[4]["process_status"] == 0 + assert repo.table4[5]["process_status"] == 3 + + +# 表 3 只录协议行停在 3 且 process_time 超过租约可以再领。 +def test_claim_reclaims_expired_protocol_status_3(): + now = datetime(2026, 10, 22, 12, 0, 0) + repo = MemoryRebateRepo( + table3=[_t3(process_status=3, process_time=now - timedelta(minutes=31))] + ) + claimed = repo.claim_pending(now=now) + assert [row["fid"] for row in claimed.protocols] == [10] + assert repo.table3[10]["process_status"] == 3 + + +# 表 3 只录协议行停在 3 且未过租约的不领。 +def test_claim_skips_fresh_protocol_status_3(): + now = datetime(2026, 10, 22, 12, 0, 0) + repo = MemoryRebateRepo( + table3=[_t3(process_status=3, process_time=now - timedelta(minutes=1))] + ) + claimed = repo.claim_pending(now=now) + assert claimed.protocols == [] + assert repo.table3[10]["process_status"] == 3 + + +# recorder 按总单 → 细单 → 送审顺序把合同号传下去。 def test_recorder_passes_contract_no_to_header_import_submit(): steps: list[str] = [] + # 记下总单调用。 def header(contract_no: str) -> str: steps.append(f"header:{contract_no}") return "RB1" + # 记下细单调用。 def importer(contract_no: str) -> RebateImportResult: steps.append(f"import:{contract_no}") return RebateImportResult(1, 0, (), True) + # 记下送审调用。 def submitter(contract_no: str) -> RebateSubmitResult: steps.append(f"submit:{contract_no}") return RebateSubmitResult(True, "AGR1", "AGRD1") @@ -135,3 +295,259 @@ def test_recorder_passes_contract_no_to_header_import_submit(): assert outcome.kind == KIND_OK assert outcome.agreement_id == "AGR1" assert outcome.agreement_dtl_id == "AGRD1" + + +# 总单抛错只写 HEADER_FAILED,不含异常原文。 +def test_recorder_maps_header_error_to_short_code(): + # 模拟总单页面未捕获异常。 + def header(contract_no: str) -> str: + raise RuntimeError("ORA-00001 unique constraint (RPA.UK)") + + outcome = recorder("HT1", header, _ok_importer, _ok_submitter) + assert outcome.kind == KIND_FAIL + assert outcome.reason == HEADER_FAILED + assert "ORA-" not in (outcome.reason or "") + assert outcome.error_text == "ORA-00001 unique constraint (RPA.UK)" + + +# 细单抛错只写 IMPORT_FAILED,不含异常原文。 +def test_recorder_maps_import_error_to_short_code(): + # 模拟细单页面未捕获异常。 + def importer(contract_no: str) -> RebateImportResult: + raise RuntimeError("traceback here") + + outcome = recorder("HT1", _ok_header, importer, _ok_submitter) + assert outcome.kind == KIND_FAIL + assert outcome.reason == IMPORT_FAILED + assert "traceback" not in (outcome.reason or "") + + +# 送审抛错只写 SUBMIT_FAILED,不含异常原文。 +def test_recorder_maps_submit_error_to_short_code(): + # 模拟送审页面未捕获异常。 + def submitter(contract_no: str) -> RebateSubmitResult: + raise RuntimeError("ORA-12541 boom") + + outcome = recorder("HT1", _ok_header, _ok_importer, submitter) + assert outcome.kind == KIND_FAIL + assert outcome.reason == SUBMIT_FAILED + assert "ORA-" not in (outcome.reason or "") + + +# runner 抛未捕获异常时表 3 原因列只写 PAGE_FAILED。 +def test_job_maps_unexpected_error_to_short_code(): + repo = MemoryRebateRepo(table3=[_t3()]) + + # 模拟查号后页面未捕获异常。 + def runner(group): + raise RuntimeError("traceback here") + + run_batch(repo, runner=runner) + assert repo.table3[10]["process_status"] == 2 + assert repo.table3[10]["process_result"] == PAGE_FAILED + assert "traceback" not in repo.table3[10]["process_result"] + assert len(repo.shift_errors) == 1 + assert repo.shift_errors[0].shift_name == "返利" + assert repo.shift_errors[0].biz_key == "HT-OLD" + 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 行。""" + from run_batch import DEFAULT_CLAIM_LIMIT, main as batch_main + + repo = MemoryRebateRepo( + table4=[ + _t4(fid=i, oms_id=2026100500 + i, contract_no=f"HT{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.drafts) + len(claimed.protocols) + return claimed + + repo.claim_pending = claim_pending + + def runner(_group): + """不打开页面,当作没搜到。""" + return AgreementOutcome(KIND_NOT_FOUND, None, None, None) + + batch_main([], repo=repo, runner=runner) + assert seen["limit"] == DEFAULT_CLAIM_LIMIT + assert seen["count"] == 10 + assert seen["count"] <= DEFAULT_CLAIM_LIMIT + + +class HoldingRebateRepo(MemoryRebateRepo): + """内存仓库加上可计数的 session,用来断言页面期间连接已归还。""" + + def __init__(self, table4=None, table3=None): + """在内存行上加 held 计数。""" + super().__init__(table4=table4, table3=table3) + 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 = HoldingRebateRepo( + table4=[ + _t4(fid=1, contract_no="HT1"), + _t4(fid=2, oms_id=2026100502, contract_no="HT2"), + ] + ) + 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(_group): + """查号和录协议时连接必须已经归还。""" + during_page.append(repo.held) + return AgreementOutcome(KIND_OK, "AGR1", "AGRD1", 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 + + +def test_existing_agreement_is_not_created_again(): + """对方已有协议、本地没写上时,重跑不产生第二份。""" + repo = MemoryRebateRepo( + table4=[_t4(), _t4(fid=2, oms_id=2026100502)] + ) + created: list[str] = [] + + def runner(group): + """已有总单号则补写,不再走总单、细单和送审。""" + + def header(_number: str) -> str: + """本用例不应新建总单。""" + created.append("header") + return "NEW" + + def importer(_number: str) -> RebateImportResult: + """本用例不应新建细单。""" + created.append("import") + return RebateImportResult(1, 0, (), True) + + def submitter(_number: str) -> RebateSubmitResult: + """本用例不应再送审。""" + created.append("submit") + return RebateSubmitResult(True, "AGR-NEW", "DTL-NEW") + + def existing(number: str) -> str: + """对方这一合同已经有协议总单。""" + assert number == "HT1" + return "AGR-ALREADY" + + return recorder( + group.contract_no, + header, + importer, + submitter, + existing=existing, + ) + + summary = run_batch(repo, runner=runner) + assert created == [] + assert summary.ok == 1 + agreements = [row for row in repo.table4.values() if row["action_code"] == 2] + assert sorted(row["oms_id"] for row in agreements) == [2026100501, 2026100502] + assert {row["agreement_id"] for row in agreements} == {"AGR-ALREADY"} + assert {row["agreement_status"] for row in agreements} == {1} + + +def test_unreadable_agreement_stays_in_process(): + """读号失败保持处理中。确认没有单据才放回待领。""" + repo = MemoryRebateRepo(table4=[_t4()]) + + def runner(group): + """查已有协议时读不到号。""" + + def existing(_number: str) -> str: + """页面没有把总单号读出来。""" + raise RuntimeError("页面没有总单号") + + return recorder( + group.contract_no, + _ok_header, + _ok_importer, + _ok_submitter, + existing=existing, + ) + + run_batch(repo, runner=runner) + row = repo.table4[1] + assert row["process_status"] == 3 + assert row["contract_status"] == 1 + assert row["action_code"] == 1 + assert row["agreement_fail_reason"] == "读号失败" + assert [item for item in repo.table4.values() if item["action_code"] == 2] == [] + assert repo.claim_pending().drafts == [] + + +def test_confirmed_absent_agreement_still_creates_once(): + """确认没有协议时才新建一份,空串不算已有总单。""" + steps: list[str] = [] + + def header(contract_no: str) -> str: + """确认没有协议后才填总单。""" + steps.append(f"header:{contract_no}") + return "RB1" + + def importer(contract_no: str) -> RebateImportResult: + """确认没有协议后才新增细单。""" + steps.append(f"import:{contract_no}") + return RebateImportResult(1, 0, (), True) + + def submitter(contract_no: str) -> RebateSubmitResult: + """确认没有协议后才送审。""" + steps.append(f"submit:{contract_no}") + return RebateSubmitResult(True, "AGR1", "AGRD1") + + def existing(_number: str) -> str: + """对方确认没有这份协议。""" + return "" + + outcome = recorder("HT1", header, importer, submitter, existing=existing) + assert steps == ["header:HT1", "import:HT1", "submit:HT1"] + assert outcome.kind == KIND_OK + assert outcome.agreement_id == "AGR1" diff --git a/rpa_rebate/tests/test_oracle_repo.py b/rpa_rebate/tests/test_oracle_repo.py new file mode 100644 index 0000000..1dabd72 --- /dev/null +++ b/rpa_rebate/tests/test_oracle_repo.py @@ -0,0 +1,368 @@ +# 返利 Oracle 仓库:.env 填充、领取 SQL、落表一条连接。不连真库。 + +from __future__ import annotations + +from contextlib import contextmanager +import os + +import pytest + +from rpa_oracle import RepoUnavailable, load_job_env +from persist import USER_NAME, GroupSave +from repo import ( + LEASE_MINUTES, + OracleRebateRepo, + _SELECT_DRAFTS, + _SELECT_DRAFTS_LIMIT, + _SELECT_PROTOCOLS, + _SELECT_PROTOCOLS_LIMIT, +) + + +# 返利 .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") + rebate_env = tmp_path / "rebate.env" + oms_env = tmp_path / "oms.env" + rebate_env.write_text( + "RPA_ORACLE_HOST=192.168.12.244\nRPA_ORACLE_PASSWORD=\nRPA_ORACLE_USER=from-rebate\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, [rebate_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): + OracleRebateRepo.from_env() + + +# 录 SQL 的假游标,按表名返回预设领取行。 +class _FakeCursor: + # 挂上共享的假连接。 + def __init__(self, conn): + self._conn = conn + self._rows: list = [] + self.description = None + self.rowcount = 1 + + # 进入 with,返回自身。 + def __enter__(self): + return self + + # 退出 with,不吞异常。 + def __exit__(self, *args): + return False + + # 记下一次批量 SQL 和全部 bind。 + def executemany(self, sql, seq): + self._conn.many.append((sql, [dict(item) for item in seq])) + + # 记下 SQL 和 bind;SELECT 按表切领取结果。一条 IN 超过 1000 个 fid 时抛错。 + def execute(self, sql, params=None): + params = params or {} + _reject_oversized_in(params) + self._conn.executed.append((sql, params)) + text = sql.lstrip().lower() + if text.startswith("select") and "rpa_contract_agreement_result" in text: + self._rows = self._conn.draft_rows + self.description = self._conn.draft_description + elif text.startswith("select") and "rpa_purchase_contract_data" in text: + self._rows = self._conn.protocol_rows + self.description = self._conn.protocol_description + else: + self._rows = [] + + # 返回本次 SELECT 预设行的副本。 + def fetchall(self): + return list(self._rows) + + +# 假连接:同一实例上的 cursor 共享 executed 列表。 +class _FakeConn: + # 可选带上表 4 起草行和表 3 只录协议行。 + def __init__(self, *, draft_rows=None, protocol_rows=None): + self.executed: list[tuple[str, dict]] = [] + self.many: list[tuple[str, list]] = [] + self.committed = False + self.commits = 0 + self.draft_rows = draft_rows or [] + self.protocol_rows = protocol_rows or [] + self.draft_description = _DRAFT_DESCRIPTION + self.protocol_description = _PROTOCOL_DESCRIPTION + + # 新开一个录 SQL 的游标。 + def cursor(self): + return _FakeCursor(self) + + # 记下已提交。 + 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 + + +_DRAFT_DESCRIPTION = ( + ("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",), + ("PROCESS_STATUS",), +) + +_PROTOCOL_DESCRIPTION = ( + ("FID",), + ("OMS_ID",), + ("CONTRACT_NO",), + ("CONTRACT_ENTRY_STATUS",), + ("PROCESS_STATUS",), + ("ORG_CODE",), + ("PROCESS_RESULT",), +) + + +# 构造表 4 领取查询返回的一行元组。 +def _draft_fetched(fid: int = 11, oms_id: int = 2026100501, contract_no: str = "HT1"): + """构造表 4 领取查询返回的一行元组。""" + return ( + fid, + oms_id, + 1, + 1, + None, + contract_no, + None, + None, + None, + None, + "901", + 0, + 0, + ) + + +# 构造表 3 只录协议领取查询返回的一行元组。 +def _protocol_fetched(fid: int = 21, oms_id: int = 2026100503): + return (fid, oms_id, "HT-OLD", 0, 0, "901", "") + + +# 领取 SQL 带 FOR UPDATE SKIP LOCKED,过期 process_status=3 可再领,同号未过租约整组排除。 +def test_claim_sql_uses_skip_locked_and_lease(): + draft_sql = " ".join(_SELECT_DRAFTS.split()).lower() + assert "for update skip locked" in draft_sql + assert "process_status = 0" in draft_sql + assert "process_status = 3" in draft_sql + assert "not exists" in draft_sql + assert "numtodsinterval(:lease_minutes" in draft_sql + assert "rownum" not in draft_sql + assert "skip locked" in " ".join(_SELECT_DRAFTS_LIMIT.split()).lower() + protocol_sql = " ".join(_SELECT_PROTOCOLS.split()).lower() + assert "for update skip locked" in protocol_sql + assert "process_status = 0" in protocol_sql + assert "process_status = 3" in protocol_sql + assert "process_time" in protocol_sql + assert "numtodsinterval(:lease_minutes" in protocol_sql + assert "skip locked" in " ".join(_SELECT_PROTOCOLS_LIMIT.split()).lower() + assert LEASE_MINUTES == 30 + + +# 领取一次借连接:SKIP LOCKED 选出后表 4、表 3 都改成 process_status=3。 +def test_claim_pending_locks_and_stamps_in_one_acquire(): + conn = _FakeConn( + draft_rows=[_draft_fetched()], + protocol_rows=[_protocol_fetched()], + ) + pool = _FakePool(conn) + repo = OracleRebateRepo(pool) + claimed = repo.claim_pending(limit=5) + assert pool.acquires == 1 + assert [row["fid"] for row in claimed.drafts] == [11] + assert claimed.drafts[0]["process_status"] == 3 + assert claimed.drafts[0]["update_user_name"] == USER_NAME + assert [row["fid"] for row in claimed.protocols] == [21] + assert claimed.protocols[0]["process_status"] == 3 + assert conn.committed is True + selects = [item for item in conn.executed if item[0].lstrip().lower().startswith("select")] + updates = [item for item in conn.executed if item[0].lstrip().lower().startswith("update")] + assert len(selects) == 2 + assert len(updates) == 2 + assert "skip locked" in selects[0][0].lower() + assert selects[0][1]["lease_minutes"] == 30 + assert "limit" not in selects[0][1] + assert selects[1][1]["limit"] == 4 + assert "skip locked" in selects[1][0].lower() + draft_update = " ".join(updates[0][0].split()).lower() + assert "process_status = 3" in draft_update + assert "系统管理员" in updates[0][0] + protocol_update = " ".join(updates[1][0].split()).lower() + assert "process_status = 3" in protocol_update + + +# 表 4 更新/释放/插入和表 3 更新各一次 executemany,同一连接同一事务。 +def test_save_group_writes_in_one_acquire(): + conn = _FakeConn() + pool = _FakePool(conn) + repo = OracleRebateRepo(pool) + payload = GroupSave( + table4_updates=[ + {"fid": 1, "contract_status": 2, "process_status": 1}, + { + "fid": 3, + "contract_status": 1, + "process_status": 0, + "agreement_fail_reason": "送审失败", + }, + ], + table4_inserts=[ + { + "oms_id": 2026100501, + "action_code": 2, + "contract_status": None, + "contract_fail_reason": None, + "contract_no": "HT1", + "agreement_status": 1, + "agreement_fail_reason": None, + "agreement_id": "AGR1", + "agreement_dtl_id": "AGRD1", + "process_status": 1, + "org_code": "901", + }, + { + "oms_id": 2026100502, + "action_code": 2, + "contract_status": None, + "contract_fail_reason": None, + "contract_no": "HT1", + "agreement_status": 1, + "agreement_fail_reason": None, + "agreement_id": "AGR1", + "agreement_dtl_id": "AGRD2", + "process_status": 1, + "org_code": "901", + }, + ], + table4_release_fids=[2, 4], + table3_updates=[ + {"fid": 10, "process_status": 1, "process_result": "协议已录入"}, + {"fid": 11, "process_status": 0, "process_result": "下一班再领"}, + ], + ) + repo.save_group(payload) + assert pool.acquires == 1 + assert conn.committed is True + assert conn.executed == [] + assert len(conn.many) == 4 + draft_sql, draft_binds = conn.many[0] + release_sql, release_binds = conn.many[1] + insert_sql, insert_binds = conn.many[2] + protocol_sql, protocol_binds = conn.many[3] + assert "agreement_fail_reason" in draft_sql.lower() + assert [row["fid"] for row in draft_binds] == [1, 3] + assert draft_binds[0]["agreement_fail_reason"] is None + assert draft_binds[1]["agreement_fail_reason"] == "送审失败" + assert [row["fid"] for row in release_binds] == [2, 4] + assert "process_status = 0" in " ".join(release_sql.split()).lower() + assert "insert into 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 protocol_sql.lower() + assert [row["fid"] for row in protocol_binds] == [10, 11] + assert [row["process_status"] for row in protocol_binds] == [1, 0] + + +# 班次 session 内领取加落表只从池里借一次连接。 +def test_session_claim_and_save_acquire_once(): + conn = _FakeConn(draft_rows=[_draft_fetched()]) + pool = _FakePool(conn) + repo = OracleRebateRepo(pool) + payload = GroupSave( + table4_updates=[ + {"fid": 11, "contract_status": 2, "process_status": 1}, + ], + table4_inserts=[], + table4_release_fids=[], + table3_updates=[], + ) + with repo.session(): + claimed = repo.claim_pending() + assert [row["fid"] for row in claimed.drafts] == [11] + repo.save_group(payload) + 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_one_contract_over_1000_rows_keeps_the_whole_group(): + """一个合同号超过 1000 行仍整组领走,更新按 500 分段,不报数据库不可用。""" + rows = [ + _draft_fetched(fid, oms_id=2026100500 + fid, contract_no="HT-BIG") + for fid in range(1, 1002) + ] + conn = _FakeConn(draft_rows=rows) + pool = _FakePool(conn) + repo = OracleRebateRepo(pool) + claimed = repo.claim_pending(limit=10) + assert pool.acquires == 1 + assert len(claimed.drafts) == 1001 + assert {row["contract_no"] for row in claimed.drafts} == {"HT-BIG"} + assert all(row["process_status"] == 3 for row in claimed.drafts) + assert claimed.protocols == [] + 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 conn.committed is True diff --git a/rpa_rebate/现行模式.md b/rpa_rebate/现行模式.md index 800db8b..98c2ec7 100644 --- a/rpa_rebate/现行模式.md +++ b/rpa_rebate/现行模式.md @@ -4,7 +4,7 @@ ## 班次 -1. 领表 4 `action_code=1 AND contract_status=1`(盖「查号中」);领表 3 只录协议行 `process_status` 0→3 +1. 领表 4 `action_code=1 AND contract_status=1 AND process_status=0`(改成 3,同一合同号整组);领表 3 只录协议行 `process_status` 0→3 2. 按合同号查返利中心,表里出现该号才算搜到 3. 勾已有总单(审批通过后已有一条,不要新增总单) 4. 补总单必填 → **卡片仍在编辑时**点总单工具栏保存 → 卡片确认 @@ -34,9 +34,9 @@ | 细单 | 采购结算方式 | 电汇 | | 细单 | 备注 | `oms_id` | | 细单 | 特定补偿 / 进货补偿 | 0 | -| 细单 | 货源细单ID | 本期不填(下一会话:按总单联系人对货源,搜不到整组报错) | +| 细单 | 货源细单ID | 本期不填。弹层按联系人(苏文辉)过滤 | -落表:起草行 `contract_status=2`;送审成功每 `oms_id` 插 `action_code=2`、`agreement_status=1`,带协议总单ID / 协议细单ID;`push_status=0`。搜不到戳改回合同班。 +落表:起草行 `contract_status=2`、`process_status=1`;送审成功每 `oms_id` 插 `action_code=2`、`agreement_status=1`,带协议总单ID / 协议细单ID;`push_status=0`。人名固定「系统管理员」。搜不到表 4 `process_status` 改回 0。 ## 修改(相对旧稿 / Excel 方案) @@ -44,7 +44,13 @@ | --- | --- | --- | | 细单路径 | Agents / README 写「下模板 → 填 Excel → 上传」 | 页面「新增 + 货品ID搜」。`run_import_flow` 调 `add_detail_lines_by_goods_id` | | `返利流程(1).md` | 已知/未知草稿,Excel 与页面双轨 | 废稿,指向 `返利流程.md` | +| 计划员 | 映射表 / 口径:订单中心 `planner_name` | 选择框「廖建琦」,后期改回 | +| 联系人 | 映射表无 | 「苏文辉」。货源弹层按此人过滤 | +| 采购结算方式 | 映射表总单默认「否」、细单代码 `3` | 总单、细单都精确点「电汇」 | | 承付参数 | 「不要填 25 / 以销售定结」 | 总单、细单都填 **25** | +| 关税税率 | 映射表 `0.13` | 不填 | +| 税率 | 映射表固定 `0.13` | 表 3 `purchase_tax_rate`,空则 `0.13` | +| 细单日期 | 映射表开始当天、结束年底 | 先用页面默认,报错再看 | | 总单保存 | 先卡片确认再保存 | 先总单工具栏保存(卡仍在编辑),再确认 | | 细单确认 | 容易点到总单「确认」 | 只点货品卡片「确认 Ctrl + Enter」,确认后核对货品表有该 `goods_id` | | 计划员弹层 | 适配器看见「货源选择」就关 | 计划员三点打开的货源层要按住再选廖建琦 | @@ -64,8 +70,11 @@ ## 代码里还留着、班次不走 -`rebate_flow_steps.py` 仍有 Excel 下模板 / 填表 / 上传函数(`write_canonical_template`、`download_detail_template` 等)。`run_import_flow` 不调用它们。不要按这些函数写新逻辑。 +`rebate_import.py` 仍有 Excel 下模板 / 填表 / 上传函数(`write_canonical_template`、`download_detail_template` 等)。`run_import_flow` 不调用它们。不要按这些函数写新逻辑。对外仍从 `rebate_flow_steps` import。 ## 下一会话(未做) -细单「货源细单ID」三个点:用总单联系人对货源。弹层搜不到 → 整组停、写报错返回。不要按行一条成功两条失败。 +1. **货源细单ID**:三个点搜表 3 `goods_source_id`。弹层只出总单联系人(现苏文辉)名下货源,不是全局搜号。空 → 整组停、`KIND_FAIL`、不送审。不要按行一条成功两条失败。 +2. **计划员**:现阶段「廖建琦」;改回表 3 `planner_name`。 + +口 2 / 口 4 HTTP、M5、药帮忙支付不在本包,见根 `README.md`「本期未做」。 diff --git a/rpa_rebate/返利流程.md b/rpa_rebate/返利流程.md index 1874449..4aabc5f 100644 --- a/rpa_rebate/返利流程.md +++ b/rpa_rebate/返利流程.md @@ -96,7 +96,7 @@ 7. 细单备注 = 订单号;特定补偿 = 0 8. 承付模式:下月N号结;承付参数 `25` 9. 采购结算方式:先输入「电汇」再精确点「电汇」 -10. 关税税率不填。货源细单ID 本期不填(下一会话:按总单联系人对货源,搜不到整组报错返回)。进货补偿预估值确认时报必填,填 0 +10. 关税税率不填。货源细单ID 本期不填。弹层按**总单联系人**过滤(现「苏文辉」),不是全局按号搜;表 3 `goods_source_id` 若不是该联系人名下货源,三个点是空的。下一会话再填;搜不到整组报错返回,不按行拆。进货补偿预估值确认时报必填,填 0 11. 细单开始 / 结束日期先用系统默认;保存报错再看 12. 每条卡片填完点标题栏「确认 Ctrl + Enter」,行才落到货品表。不要点「返回表格」代替确认 13. **三条都确认完,再点货品工具栏「保存」**(新增旁边那颗,不要点总单工具栏保存)。保存时若弹出确认,再点确认。不要每条都点工具栏保存 @@ -122,7 +122,7 @@ | 特定补偿预估值 | `0` | 写死 | | 进货补偿预估值 | 页面确认时报必填,填 `0` | 写死 | | 细单备注 | 订单号 | 表 3 `oms_id` | -| 货源细单ID | 本期不填。下一会话:总单联系人同一货源;三个点搜不到则整组报错返回 | — | +| 货源细单ID | 本期不填。弹层按总单联系人(现苏文辉)过滤;下一会话三个点搜,空则整组报错返回 | — | --- @@ -138,12 +138,12 @@ ## 5 落表(班次,不改页面) -- 领表 4:`action_code=1 AND contract_status=1`(合同班起草、尚未回成功) +- 领表 4:`action_code=1 AND contract_status=1 AND process_status=0`(合同已起草、尚未回成功)。同一合同号整组领 - 领表 3:`contract_entry_status=0` 且已有 `contractNo` 的只录协议行 -- 领取时表 4 盖「查号中」,表 3 `process_status` 0→3 -- 崩溃停戳:改回「合同班」/ `0` 再跑 -- 搜到并送审成功:起草行 `contract_status=2`;同一合同每个 `oms_id` 插同一套 `action_code=2`、`agreement_status=1`,带协议总单ID / 协议细单ID;`push_status=0`。一组一个结果,不按行拆成功/失败 -- 搜不到:戳改回「合同班」,表 3 改回 `0`,不插协议行 +- 领取时表 4、表 3 的 `process_status` 都是 0→3。`create_user_name` / `update_user_name` 固定「系统管理员」 +- 崩溃停在 3:改回 `0` 再跑 +- 搜到并送审成功:起草行 `contract_status=2`、`process_status=1`;同一合同每个 `oms_id` 插同一套 `action_code=2`、`agreement_status=1`,带协议总单ID / 协议细单ID;`push_status=0`。一组一个结果,不按行拆成功/失败 +- 搜不到:表 4 `process_status` 改回 `0`,表 3 改回 `0`,不插协议行 - 不推接口 4,不改 `push_status` --- @@ -166,3 +166,14 @@ - 搜不到不要重录合同 - 浏览器卡住时不要关,停下问 - 计划员三个点打开的「货源选择」是选人弹层,要按住再搜「廖建琦」;适配器默认关货源层时计划员选不了 +- 货源跟联系人,不跟计划员。细单货源弹层只出苏文辉名下货源 + +--- + +## 7 本期不填 / 后期再改 + +| 项 | 现在 | 以后 | +| --- | --- | --- | +| 货源细单ID | 不填 | 三个点按联系人过滤搜表 3 号;空则整组失败 | +| 计划员 | 廖建琦 | 改回表 3 `planner_name` | +| 口 2 / 口 4 HTTP、M5、药帮忙支付 | 不做 | 不在本包 | diff --git a/rpa_selection/README.md b/rpa_selection/README.md index efe2fb3..d140368 100644 --- a/rpa_selection/README.md +++ b/rpa_selection/README.md @@ -20,13 +20,13 @@ rpa_sales_goods_mapping_data (deal_flag=0) ## 规则 -硬过滤三项,再按仓序拆量(同一平台同一仓只留低价那条): +硬过滤三项,再按仓序拆量(同一平台同一仓的多个 sku 都参与,低价先吃): 1. 自营仓(商家名去 `[自营]` 后必须等于四级名单全称) 2. 毛利 `(销售价格 - 平台价) / 销售价格 >= 4%` 3. 效期 ≥ 12 个月 4. 仓序(级内由近到远):药帮忙杭州/长沙/湖北小药药 → 1 药城广东/昆山/湖北亿昊 → 济南小药药、天津壹号、重庆亿昊 → 辽宁亿昊、陕西壹号、河南壹号、重庆壹号、重庆小药药 -5. 按序贪心拆量。凑齐则每个仓一行,`purchase_qty` 为该仓件数;凑不齐整单失败 +5. 按序贪心拆量。凑齐则每条拆量一行,`purchase_qty` 为该条件数;凑不齐整单失败 `multi_platform_flag=1` 仅当成功行出现两个 `platform_type`。湖南壹号、甘肃壹号云不在自营集合。口径见 `docs/superpowers/specs/2026-10-02-selection-split-allocation-design.md`。 @@ -38,12 +38,9 @@ uv sync --python 3.12 uv run playwright install chromium ``` -Oracle 与 `rpa_oms` 同一套变量:`RPA_ORACLE_HOST` / `PORT` / `SERVICE` / `USER` / `PASSWORD`,11.2 用 thick。Windows 还要 `RPA_ORACLE_CLIENT_LIB`。选品 `.env` 里 Oracle 密码留空时,会补 `rpa_oms/.env` 里已有的值。 +Oracle 与 `rpa_oms` 同一套变量:`RPA_ORACLE_HOST` / `PORT` / `SERVICE` / `USER` / `PASSWORD`,11.2 用 thick。Windows 还要 `RPA_ORACLE_CLIENT_LIB`。选品 `.env` 里 Oracle 密码留空时,会补 `rpa_oms/.env` 里已有的值。连接从 `rpa_oracle` 池取,班次默认池大小 1。 -商城账号(任选其一,不要提交真实密码): - -- 复制 `.env.example` 为 `.env`,填 `YAOEX_USERNAME` / `YAOEX_PASSWORD` / `YBM_USERNAME` / `YBM_PASSWORD` -- 或复制 `secrets.example.json` 为 `secrets.json` +商城账号只认环境变量(不要提交真实密码):复制 `.env.example` 为 `.env`,填 `YAOEX_USERNAME` / `YAOEX_PASSWORD` / `YBM_USERNAME` / `YBM_PASSWORD`。 已有登录态可跳过浏览器:`--yaoex-auth auth.json`、`--ybm-cookies cookies.json`。 @@ -51,7 +48,7 @@ Oracle 与 `rpa_oms` 同一套变量:`RPA_ORACLE_HOST` / `PORT` / `SERVICE` / ```bash cd rpa_selection -uv run pytest -v +uv run pytest tests/test_pipeline.py::test_split_across_malls_is_success tests/test_job.py::test_job_searches_barcode_not_approval_no -q uv run python main.py --limit 5 uv run python main.py --platform yaoex ``` @@ -66,9 +63,9 @@ Copy-Item -LiteralPath .env.example -Destination .env .\start.ps1 --limit 5 ``` -`--limit` 只领取本班前 N 条 `deal_flag=0`。跑的过程中新进入的留给下一班。 +`--limit` 只领取本班前 N 条。跑的过程中新进入的留给下一班。 -中途崩溃会停在 `deal_flag=3`。改回 `0` 再跑。系统故障不能靠订单中心重推同一 `oms_id`(接口 1 幂等),同样把 `deal_flag` 改回 `0`。 +中途崩溃会停在 `deal_flag=3`。`deal_time` 超过 30 分钟的 3 下一班会再领;未过期的 3 不领。系统故障不能靠订单中心重推同一 `oms_id`(接口 1 幂等),把 `deal_flag` 改回 `0` 或等租约到期。 ## 模块 @@ -83,6 +80,49 @@ Copy-Item -LiteralPath .env.example -Destination .env | `search_yaoex.py` / `search_ybm.py` | 搜索接口;查询键是条形码 | | `auth_login.py` | Playwright 登录 | +## 运维 + +### 怎么跑 + +```bash +cd rpa_selection +uv sync --python 3.12 +uv run python main.py --limit 5 +``` + +Windows 把商城账号写本机 `.env`,Oracle 也可只留在 `rpa_oms/.env`,然后 `.\start.ps1 --limit 5`。缺 `RPA_ORACLE_HOST` / 用户 / 密码时入口失败,文案「数据库不可用」。 + +### 看哪张表哪几列 + +- 表 1 `rpa_sales_goods_mapping_data`:`oms_id`、`deal_flag`(0 待领,3 处理中,1 成功,2 失败)、`deal_time`、`deal_result`、`bar_code` +- 表 2 `rpa_cart_settlement_goods_data`:`oms_id`、`status`(1 成功,2 失败)、`fail_reason`、`push_status=0`、`purchase_qty` + +### 崩溃改回 SQL + +停在 3 且未过 30 分钟租约: + +```sql +UPDATE rpa_sales_goods_mapping_data +SET deal_flag = 0 +WHERE deal_flag = 3 AND oms_id = :oms_id; +``` + +过期的 3 下一班会再领,不必改。系统故障也是 `deal_flag=2`,要重跑同样改回 0。 + +### 原因列 vs 日志原文 + +表 1 `deal_result` / 表 2 `fail_reason` 写短码(`QUERY_FAILED`、`LOGIN_FAILED`)。查询失败原文写入 `rpa_selection_query_audit`,不进这两列。 + +### 点名命令 + +```bash +cd rpa_selection +uv run pytest tests/test_pipeline.py::test_split_across_malls_is_success -q +uv run pytest tests/test_job.py::test_job_searches_barcode_not_approval_no -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_flag_3 tests/test_job.py::test_claim_skips_fresh_flag_3 -q +``` + ## 本期不做 加购、接口 2 推送、支付回传、批准文号搜索、商家名模糊匹配。 diff --git a/rpa_selection/auth_login.py b/rpa_selection/auth_login.py index 4020894..f33d245 100644 --- a/rpa_selection/auth_login.py +++ b/rpa_selection/auth_login.py @@ -1,7 +1,13 @@ +# 商城登录:读环境变量账号,写出 token/cookie 缓存。 +# +# 账号只认 YAOEX_ / YBM_ 环境变量,不读 secrets.json。 +# 缓存文件权限收成 0o600。 + from __future__ import annotations import json import os +import sys import time from pathlib import Path from typing import Any @@ -18,48 +24,40 @@ from util import normalize_text PROJECT_ROOT = Path(__file__).resolve().parent REPO_ROOT = PROJECT_ROOT.parent +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from rpa_oracle.env import load_env + AUTH_DIR = PROJECT_ROOT / "data" / ".auth" DEFAULT_YAOEX_AUTH_PATH = AUTH_DIR / "yaoex.json" DEFAULT_YBM_COOKIE_PATH = AUTH_DIR / "ybm.json" +# 把单个 .env 补进环境。已有非空变量不覆盖。加购班过渡仍走这一层。 def load_dotenv(path: Path) -> None: - if not path.exists(): - return - for line in path.read_text(encoding="utf-8").splitlines(): - stripped = line.strip() - if not stripped or stripped.startswith("#") or "=" not in stripped: - continue - key, _, value = stripped.partition("=") - key = key.strip() - value = value.strip().strip('"').strip("'") - os.environ.setdefault(key, value) + load_env(path) -def load_secrets(secrets_path: Path | None = None) -> dict[str, dict[str, str]]: - load_dotenv(PROJECT_ROOT / ".env") - result: dict[str, dict[str, str]] = {"yaoex": {}, "ybm": {}} - path = secrets_path or (PROJECT_ROOT / "secrets.json") - if path.exists(): - data = json.loads(path.read_text(encoding="utf-8")) - if isinstance(data, dict): - for mall in ("yaoex", "ybm"): - block = data.get(mall) or {} - if isinstance(block, dict): - result[mall] = { - "username": normalize_text(block.get("username")), - "password": normalize_text(block.get("password")), - } +# 读商城账号。只认 YAOEX_ / YBM_ 环境变量,不读 secrets.json。 +# +# 先补选品 .env 里尚未占用的键。 +def load_secrets() -> dict[str, dict[str, str]]: + load_env(PROJECT_ROOT / ".env") env_map = { "yaoex": ("YAOEX_USERNAME", "YAOEX_PASSWORD"), "ybm": ("YBM_USERNAME", "YBM_PASSWORD"), } - for mall, (user_key, pass_key) in env_map.items(): - result[mall]["username"] = os.environ.get(user_key) or result[mall].get("username", "") - result[mall]["password"] = os.environ.get(pass_key) or result[mall].get("password", "") - return result + return { + mall: { + "username": os.environ.get(user_key) or "", + "password": os.environ.get(pass_key) or "", + } + for mall, (user_key, pass_key) in env_map.items() + } +# 从缓存 JSON 读出 1 号药城 token / user_id / buyer_code。 def load_yaoex_auth_file(path: Path) -> YaoexAuth: data = json.loads(path.read_text(encoding="utf-8")) return YaoexAuth( @@ -69,6 +67,7 @@ def load_yaoex_auth_file(path: Path) -> YaoexAuth: ) +# 从缓存 JSON 读出药帮忙 cookie。文件必须是对象。 def load_cookie_file(path: Path) -> dict[str, str]: data = json.loads(path.read_text(encoding="utf-8")) if not isinstance(data, dict): @@ -76,6 +75,15 @@ def load_cookie_file(path: Path) -> dict[str, str]: return {str(key): parse_cookie_value(str(value)) for key, value in data.items()} +# 把缓存文件权限收成 0o600。Windows 上 chmod 可能无效,忽略 OSError。 +def _restrict_file_mode(path: Path) -> None: + try: + os.chmod(path, 0o600) + except OSError: + pass + + +# 写出 1 号药城登录缓存,权限 0o600。 def save_yaoex_auth(auth: YaoexAuth, path: Path = DEFAULT_YAOEX_AUTH_PATH) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text( @@ -86,20 +94,25 @@ def save_yaoex_auth(auth: YaoexAuth, path: Path = DEFAULT_YAOEX_AUTH_PATH) -> No ), encoding="utf-8", ) + _restrict_file_mode(path) +# 写出药帮忙 cookie 缓存,权限 0o600。 def save_cookie_file(cookies: dict[str, str], path: Path = DEFAULT_YBM_COOKIE_PATH) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(cookies, ensure_ascii=False, indent=2), encoding="utf-8") + _restrict_file_mode(path) +# 账号或密码为空时退出,提示去环境变量配置。 def _require_account(mall_label: str, username: str, password: str) -> None: if not username or not password: raise SystemExit( - f"缺少{mall_label}账号。请在 .env / 环境变量或 secrets.json 中配置用户名和密码。" + f"缺少{mall_label}账号。请在 .env 或环境变量中配置用户名和密码。" ) +# 路径是否落在仓库根之内。 def _is_inside_repo(path: Path, repo_root: Path) -> bool: try: path.resolve().relative_to(repo_root.resolve()) @@ -108,12 +121,12 @@ def _is_inside_repo(path: Path, repo_root: Path) -> bool: return True +# 选品 Chrome 用户目录固定在仓库外,避免每次新开空浏览器触发极验滑块。 def resolve_selection_chrome_profile( requested: str | None = None, *, repo_root: Path | None = None, ) -> Path: - """选品 Chrome 用户目录固定在仓库外,避免每次新开空浏览器触发极验滑块。""" root = (repo_root or REPO_ROOT).resolve() outside = (root.parent / "rpa-chrome-profile" / "selection").resolve() raw = os.getenv("RPA_SELECTION_CHROME_PROFILE") if requested is None else requested @@ -129,9 +142,9 @@ def resolve_selection_chrome_profile( return candidate +# 同一轮脚本共用一套已登录的 Chrome 用户目录。keep=True 时搜完仍留着窗口。 class BrowserRuntime: - """同一轮脚本共用一套已登录的 Chrome 用户目录。keep=True 时搜完仍留着窗口。""" - + # 记下是否保持窗口,以及保持多少秒。 def __init__(self, keep: bool = False, hold_seconds: int = 180) -> None: self.keep = keep self.hold_seconds = hold_seconds @@ -139,6 +152,7 @@ class BrowserRuntime: self._context: Any = None self._virgin_page: Any = None + # 打开仓库外的持久 Chrome 用户目录。已启动则直接返回。 def start(self, *, viewport: dict[str, int] | None = None) -> None: if self._playwright is not None: return @@ -169,6 +183,7 @@ class BrowserRuntime: pages = list(self._context.pages) self._virgin_page = pages[0] if pages else None + # 取出一个页面。优先复用启动时自带的空白页。 def new_page(self, *, viewport: dict[str, int] | None = None) -> tuple[Any, Any]: self.start(viewport=viewport) assert self._context is not None @@ -180,6 +195,7 @@ class BrowserRuntime: page.bring_to_front() return self._context, page + # keep=True 时先留窗口,再关 Playwright。 def close(self) -> None: if self.keep and self._context is not None: if self.hold_seconds <= 0: @@ -204,6 +220,7 @@ class BrowserRuntime: self._playwright = None +# 若停在 1 号药城登录页则填账号;仍停在登录页则失败。 def _ensure_yaoex_logged_in(page: Any, username: str, password: str) -> None: page.goto(YAOEX_LOGIN_URL, wait_until="domcontentloaded") page.wait_for_timeout(2000) @@ -256,6 +273,7 @@ def _ensure_yaoex_logged_in(page: Any, username: str, password: str) -> None: ) +# 若停在药帮忙登录页则填账号;仍停在登录页则失败。 def _ensure_ybm_logged_in(page: Any, username: str, password: str) -> None: page.goto(YBM_LOGIN_URL, wait_until="domcontentloaded") page.wait_for_timeout(2000) @@ -284,6 +302,7 @@ def _ensure_ybm_logged_in(page: Any, username: str, password: str) -> None: raise RuntimeError(f"药帮忙登录后仍停留在登录页,登录可能失败{detail}") +# 打开 1 号药城,登录后从 cookie 抽出 token / user_id / buyer_code。 def _playwright_login_yaoex(username: str, password: str, runtime: BrowserRuntime) -> YaoexAuth: context, page = runtime.new_page() _ensure_yaoex_logged_in(page, username, password) @@ -302,6 +321,7 @@ def _playwright_login_yaoex(username: str, password: str, runtime: BrowserRuntim return YaoexAuth(token=token, user_id=user_id, buyer_code=buyer_code) +# 打开药帮忙,登录后带回全部 cookie。 def _playwright_login_ybm(username: str, password: str, runtime: BrowserRuntime) -> dict[str, str]: context, page = runtime.new_page() _ensure_ybm_logged_in(page, username, password) @@ -312,11 +332,9 @@ def _playwright_login_ybm(username: str, password: str, runtime: BrowserRuntime) return {item["name"]: parse_cookie_value(item["value"]) for item in cookies} -def login_yaoex( - secrets_path: Path | None = None, - runtime: BrowserRuntime | None = None, -) -> YaoexAuth: - secrets = load_secrets(secrets_path) +# 登录 1 号药城。缺账号则退出;传入 runtime 时不关浏览器。 +def login_yaoex(runtime: BrowserRuntime | None = None) -> YaoexAuth: + secrets = load_secrets() username = secrets["yaoex"].get("username", "") password = secrets["yaoex"].get("password", "") _require_account("1号药城", username, password) @@ -329,11 +347,9 @@ def login_yaoex( session.close() -def login_ybm( - secrets_path: Path | None = None, - runtime: BrowserRuntime | None = None, -) -> dict[str, str]: - secrets = load_secrets(secrets_path) +# 登录药帮忙。缺账号则退出;传入 runtime 时不关浏览器。 +def login_ybm(runtime: BrowserRuntime | None = None) -> dict[str, str]: + secrets = load_secrets() username = secrets["ybm"].get("username", "") password = secrets["ybm"].get("password", "") _require_account("药帮忙", username, password) diff --git a/rpa_selection/db.py b/rpa_selection/db.py deleted file mode 100644 index 7037514..0000000 --- a/rpa_selection/db.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Oracle 11.2 thick 连接。环境变量与 rpa_oms 相同。""" -from __future__ import annotations - -import os -from pathlib import Path - -DEAL_USER_NAME = "rpa-select" - -_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 diff --git a/rpa_selection/http_util.py b/rpa_selection/http_util.py index 7531238..64530ca 100644 --- a/rpa_selection/http_util.py +++ b/rpa_selection/http_util.py @@ -1,3 +1,5 @@ +# 商城搜索的 HTTP:解 cookie、认 JSON、失败按次数重试。 + from __future__ import annotations import json @@ -13,12 +15,14 @@ from mall_config import API_MAX_RETRIES, API_REQUEST_TIMEOUT_SECONDS, API_RETRY_ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) +# 去掉 cookie 值两端引号并做 URL 解码。空值当空串。 def parse_cookie_value(raw_value: str | None) -> str: if not raw_value: return "" return unquote(raw_value.strip().strip('"')) +# 把商城响应收成 JSON 对象。剥掉 BOM 和防劫持前缀。不是对象就报平台和查询键。 def parse_response_json_or_raise( response: requests.Response, platform_name: str, @@ -48,6 +52,7 @@ def parse_response_json_or_raise( return payload_json +# POST 商城搜索。HTTP 或 JSON 失败时按次数重试,仍失败就报平台和查询键。 def post_json_with_retry( session: requests.Session, url: str, diff --git a/rpa_selection/job.py b/rpa_selection/job.py index 312dda7..8ac54a8 100644 --- a/rpa_selection/job.py +++ b/rpa_selection/job.py @@ -1,17 +1,24 @@ -"""选品班次:领取表 1 → 按条形码搜平台 → 统一拆量写表 2。""" +# 选品班次:领取表 1 → 按条形码搜平台 → 统一拆量写表 2。 +# +# 崩在 deal_flag=3:超过租约可再领,未过期需把 3 改回 0。 +# 查询失败原因列只写 QUERY_FAILED,原文进 rpa_selection_query_audit。 + from __future__ import annotations +import logging from dataclasses import dataclass from typing import Callable from persist import STATUS_OK, build_result from pipeline import KIND_BUSINESS_FAIL, MallOutcome, SelectionResult, run_selection -from repo import MappingRow +from repo import FinishWrite, MappingRow, QueryAudit from rules import Candidate, PurchaseLine, validate_purchase Searcher = Callable[[str], list[Candidate]] +logger = logging.getLogger("rpa_selection") +# 一个班次的领取和落表计数。 @dataclass class JobSummary: claimed: int = 0 @@ -20,6 +27,7 @@ class JobSummary: failed: int = 0 +# 把表 1 行收成规则层的采购行。缺价格或数量保持 None。 def mapping_to_purchase(row: MappingRow) -> PurchaseLine: sale_price = float(row.sale_price) if row.sale_price is not None else None qty = float(row.plan_purchase_qty) if row.plan_purchase_qty is not None else None @@ -45,16 +53,44 @@ def run_batch( login_errors: dict[str, str], limit: int | None = None, ) -> JobSummary: + """领取待选品行,按行搜平台、拆量,一行搜完就落这一行。 + + 每个平台开搜前刷新 deal_time。落库前由仓库再确认仍是 deal_flag=3, + 且表 2 还没有该 oms_id。limit 交给领取层。查询失败原文在该行落表后另写。 + """ rows = repo.claim_pending(limit=limit) summary = JobSummary(claimed=len(rows)) for row in rows: purchase = mapping_to_purchase(row) - selection = _select_row(purchase, platforms, searchers, login_errors) + + def renew(_fid: int = row.fid) -> None: + """这一行还在搜时,把租约续到现在。""" + touch = getattr(repo, "touch_claim", None) + if touch is not None: + touch(_fid) + + selection, row_audits = _select_row( + purchase, + platforms, + searchers, + login_errors, + before_search=renew, + ) result = build_result(purchase, selection) _count(summary, result.cart_rows) - repo.save_result( - result.oms_id, result.deal_flag, result.deal_result, result.cart_rows + repo.save_results( + [ + FinishWrite( + oms_id=result.oms_id, + fid=row.fid, + deal_flag=result.deal_flag, + deal_result=result.deal_result, + cart_rows=result.cart_rows, + ) + ] ) + if row_audits: + repo.save_query_audits(row_audits) return summary @@ -63,49 +99,78 @@ def _select_row( platforms: list[str], searchers: dict[str, Searcher], login_errors: dict[str, str], -) -> SelectionResult: + before_search: Callable[[], None] | None = None, +) -> tuple[SelectionResult, list[QueryAudit]]: + """校验采购行并搜各平台,交给统一拆量。校验不过不搜。 + + before_search 在每个平台开搜前调用,用来续租。 + """ row_reason = validate_purchase(purchase) if row_reason: - return SelectionResult( - kind=KIND_BUSINESS_FAIL, - reason=row_reason, - allocations=[], - rejected=[], - malls=list(platforms), + return ( + SelectionResult( + kind=KIND_BUSINESS_FAIL, + reason=row_reason, + allocations=[], + rejected=[], + malls=list(platforms), + ), + [], ) - if not purchase.bar_code: - return SelectionResult( - kind=KIND_BUSINESS_FAIL, - reason="NO_BAR_CODE", - allocations=[], - rejected=[], - malls=list(platforms), - ) - outcomes = [ - _search_one(purchase, mall, searchers, login_errors) for mall in platforms - ] - return run_selection(purchase, outcomes) + outcomes: list[MallOutcome] = [] + audits: list[QueryAudit] = [] + for mall in platforms: + if before_search is not None: + before_search() + outcome, audit = _search_one(purchase, mall, searchers, login_errors) + outcomes.append(outcome) + if audit is not None: + audits.append(audit) + return run_selection(purchase, outcomes), audits +# 搜一个平台。登录失败写 LOGIN_FAILED,查询抛错写 QUERY_FAILED。 +# +# 异常原文进审计行,不写进失败原因列。 def _search_one( purchase: PurchaseLine, mall: str, searchers: dict[str, Searcher], login_errors: dict[str, str], -) -> MallOutcome: +) -> tuple[MallOutcome, QueryAudit | None]: login_error = login_errors.get(mall) if login_error: - return MallOutcome(mall, failed_reason=f"LOGIN_FAILED:{login_error}") + logger.warning("选品登录失败 mall=%s: %s", mall, login_error) + return MallOutcome(mall, failed_reason="LOGIN_FAILED"), None searcher = searchers.get(mall) if searcher is None: - return MallOutcome(mall, failed_reason="LOGIN_FAILED:未登录") + return MallOutcome(mall, failed_reason="LOGIN_FAILED"), None try: candidates = searcher(purchase.bar_code) except Exception as exc: - return MallOutcome(mall, failed_reason=f"QUERY_FAILED:{exc}") - return MallOutcome(mall, candidates=candidates) + logger.exception("选品查询失败 mall=%s bar_code=%s", mall, purchase.bar_code) + return MallOutcome(mall, failed_reason="QUERY_FAILED"), _query_audit( + purchase, mall, exc + ) + return MallOutcome(mall, candidates=candidates), None +# 有 oms_id 和表 1 fid 时收成一条审计。缺主键则只留日志。 +def _query_audit( + purchase: PurchaseLine, mall: str, exc: Exception +) -> QueryAudit | None: + if purchase.oms_id is None or purchase.row_no is None: + return None + return QueryAudit( + oms_id=purchase.oms_id, + mapping_fid=purchase.row_no, + mall_name=mall, + bar_code=purchase.bar_code, + error_text=str(exc), + ) + + +# 按表 2 行状态累加成功、系统故障、业务跳过。 def _count(summary: JobSummary, cart_rows: list[dict]) -> None: for row in cart_rows: if row.get("status") == STATUS_OK: diff --git a/rpa_selection/main.py b/rpa_selection/main.py index 225d07e..5187473 100644 --- a/rpa_selection/main.py +++ b/rpa_selection/main.py @@ -1,3 +1,5 @@ +# 选品班次入口:登录 1 号药城 / 药帮忙,按条形码搜,结果写入表 2。不加购,不推接口 2。 + from __future__ import annotations import argparse @@ -17,7 +19,7 @@ from auth_login import ( save_cookie_file, save_yaoex_auth, ) -from db import RepoUnavailable, load_runtime_env +from rpa_oracle import RepoUnavailable, load_job_env from job import run_batch from mall_config import YAOEX_MALL_NAME, YBM_MALL_NAME from repo import OracleSelectRepo @@ -26,19 +28,29 @@ from search_yaoex import YaoexAuth from search_yaoex import search_candidates as search_yaoex_candidates from search_ybm import search_candidates as search_ybm_candidates +DEFAULT_CLAIM_LIMIT = 50 + def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """解析本班领取条数、跑哪个平台、是否用登录缓存。 + + 不传 --limit 时用 50,不再把空值当成全部领走。 + """ parser = argparse.ArgumentParser( description="选品班次:捞表1 deal_flag=0,按条形码搜平台,结果写入表2。" ) - parser.add_argument("--limit", type=int, default=None, help="本班最多领取 N 条") + parser.add_argument( + "--limit", + type=int, + default=DEFAULT_CLAIM_LIMIT, + help="本班最多领取 N 条,默认 50", + ) parser.add_argument( "--platform", choices=["all", "yaoex", "ybm"], default="all", help="all=两平台都跑", ) - parser.add_argument("--secrets", type=Path, default=None, help="secrets.json 路径") parser.add_argument( "--yaoex-auth", type=Path, @@ -70,6 +82,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: return parser.parse_args(argv) +# 把命令行平台收成要登录的商城名。all 是两个都跑。 def _platforms(choice: str) -> list[str]: if choice == "yaoex": return [YAOEX_MALL_NAME] @@ -78,9 +91,10 @@ def _platforms(choice: str) -> list[str]: return [YAOEX_MALL_NAME, YBM_MALL_NAME] +# 登录两个商城,领表 1 deal_flag=0 选品并写表 2。一边登录失败只停那一边。 def main(argv: list[str] | None = None, repo=None) -> None: args = parse_args(argv) - load_runtime_env() + load_job_env(Path(__file__).resolve().parent) flow_start = time.perf_counter() if repo is None: try: @@ -105,7 +119,7 @@ def main(argv: list[str] | None = None, repo=None) -> None: yaoex_auth = load_yaoex_auth_file(cached) print(f"1号药城使用缓存登录:{cached}") else: - yaoex_auth = login_yaoex(args.secrets, runtime=runtime) + yaoex_auth = login_yaoex(runtime=runtime) save_yaoex_auth(yaoex_auth) print(f"1号药城登录已缓存:{DEFAULT_YAOEX_AUTH_PATH}") print("1号药城认证就绪。") @@ -124,7 +138,7 @@ def main(argv: list[str] | None = None, repo=None) -> None: ybm_cookies = load_cookie_file(cached) print(f"药帮忙使用缓存登录:{cached}") else: - ybm_cookies = login_ybm(args.secrets, runtime=runtime) + ybm_cookies = login_ybm(runtime=runtime) save_cookie_file(ybm_cookies) print(f"药帮忙登录已缓存:{DEFAULT_YBM_COOKIE_PATH}") print("药帮忙认证就绪。") @@ -145,6 +159,7 @@ def main(argv: list[str] | None = None, repo=None) -> None: searchers = {} if yaoex_auth is not None: + # 用本班已登录的 1 号药城会话按条形码搜。同一条码走缓存。 def search_yaoex(query_key: str) -> list[Candidate]: assert yaoex_auth is not None return search_yaoex_candidates( @@ -155,6 +170,7 @@ def main(argv: list[str] | None = None, repo=None) -> None: if ybm_cookies is not None: + # 用本班已登录的药帮忙会话按条形码搜。同一条码走缓存。 def search_ybm(query_key: str) -> list[Candidate]: return search_ybm_candidates( ybm_cookies, query_key, session=ybm_session, cache=ybm_cache diff --git a/rpa_selection/mall_config.py b/rpa_selection/mall_config.py index 8e40053..b32425b 100644 --- a/rpa_selection/mall_config.py +++ b/rpa_selection/mall_config.py @@ -1,3 +1,5 @@ +# 1 号药城和药帮忙的登录地址、搜索接口和平台类型。选品、加购共用这些名字。 + YAOEX_LOGIN_URL = "https://mall.yaoex.com/login" YAOEX_HOME_URL = "https://mall.yaoex.com/v2/home/" YAOEX_SEARCH_API_URL = "https://gateway-b2b.fangkuaiyi.com/home/search/homeSearchList" diff --git a/rpa_selection/persist.py b/rpa_selection/persist.py index 0d07a03..c0849d3 100644 --- a/rpa_selection/persist.py +++ b/rpa_selection/persist.py @@ -1,7 +1,8 @@ -"""选品判定 → 表 2 行 + 表 1 deal_flag。 +# 选品判定 → 表 2 行 + 表 1 deal_flag。 +# +# 成功按拆出行落 status=1。凑不齐不落成功行。 +# 系统故障原因列写短码,不写异常原文。 -成功按拆出行落 status=1。凑不齐不落成功行。 -""" from __future__ import annotations from dataclasses import dataclass @@ -25,6 +26,7 @@ MALL_REPORT_ORDER = (YBM_MALL_NAME, YAOEX_MALL_NAME) PEER_UNAVAILABLE = "系统故障:对端平台不可用,合计库存不足" +# 一行选品要写入表 1 / 表 2 的结果。 @dataclass class SelectResult: oms_id: int @@ -33,11 +35,17 @@ class SelectResult: cart_rows: list[dict[str, Any]] +# 金额四舍五入到四位小数。 def money4(value: object) -> Decimal: return Decimal(str(value)).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP) def build_result(purchase: PurchaseLine, selection: SelectionResult) -> SelectResult: + """把判定收成表 2 行和表 1 的 deal_flag / deal_result。缺 oms_id 则失败。 + + LOGIN_FAILED / QUERY_FAILED 且凑不齐时 deal_flag 改回 0,不写表 2。 + 库存不足、毛利、效期仍写 deal_flag=2。 + """ if purchase.oms_id is None: raise ValueError("选品落表需要 oms_id") @@ -54,12 +62,13 @@ def build_result(purchase: PurchaseLine, selection: SelectionResult) -> SelectRe malls = selection.malls or list(MALL_PLATFORM_TYPE) if selection.kind == KIND_SYSTEM_FAIL: - rows = [ - _fail_row(purchase, mall, _system_fail_reason(mall, selection.mall_errors)) - for mall in malls - ] detail = "; ".join(selection.mall_errors.values()) or "未知错误" - deal_result = _clip_deal_result(f"系统故障:{detail}") + return SelectResult( + oms_id=purchase.oms_id, + deal_flag=0, + deal_result=_clip_deal_result(f"系统故障:{detail}"), + cart_rows=[], + ) else: reason = _business_fail_reason(selection.reason) rows = [_fail_row(purchase, mall, reason) for mall in malls] @@ -74,6 +83,7 @@ def build_result(purchase: PurchaseLine, selection: SelectionResult) -> SelectRe ) +# 成功行出现两个 platform_type 时 multi_platform_flag=1。 def _set_multi_flag(rows: list[dict[str, Any]]) -> None: types = {row["platform_type"] for row in rows} flag = 1 if len(types) > 1 else 0 @@ -81,6 +91,7 @@ def _set_multi_flag(rows: list[dict[str, Any]]) -> None: row["multi_platform_flag"] = flag +# 从表 2 行还原平台名,药帮忙在前。 def _malls_in_rows(rows: list[dict[str, Any]]) -> list[str]: types = {row["platform_type"] for row in rows} names: list[str] = [] @@ -93,6 +104,7 @@ def _malls_in_rows(rows: list[dict[str, Any]]) -> list[str]: return names +# 按拆出仓构造一条 status=1 的表 2 行。 def _success_row(purchase: PurchaseLine, item: Allocation) -> dict[str, Any]: price = money4(item.candidate.price) qty = item.qty @@ -110,6 +122,7 @@ def _success_row(purchase: PurchaseLine, item: Allocation) -> dict[str, Any]: return row +# 构造一条 status=2 的表 2 行,供应商用占位名。 def _fail_row(purchase: PurchaseLine, mall_name: str, reason: str) -> dict[str, Any]: price = _fail_price(purchase) qty = _qty(purchase) @@ -127,6 +140,7 @@ def _fail_row(purchase: PurchaseLine, mall_name: str, reason: str) -> dict[str, return row +# 表 2 公共字段。未知商城名直接失败。 def _base_row(purchase: PurchaseLine, mall_name: str) -> dict[str, Any]: if mall_name not in MALL_PLATFORM_TYPE: raise ValueError(f"未知商城:{mall_name}") @@ -140,6 +154,7 @@ def _base_row(purchase: PurchaseLine, mall_name: str) -> dict[str, Any]: } +# 失败行采购量:计划量,缺或非正则 1。 def _qty(purchase: PurchaseLine) -> int: qty = purchase.qty if qty is None or qty <= 0: @@ -147,20 +162,34 @@ def _qty(purchase: PurchaseLine) -> int: return int(qty) +# 失败行单价用销售价;没有则 0.0100。 def _fail_price(purchase: PurchaseLine) -> Decimal: if purchase.sale_price is not None and purchase.sale_price > 0: return money4(purchase.sale_price) return money4("0.0100") +_BUSINESS_PREFIX = "无符合规则商品," + + +# 把规则短码收成表 1/表 2 可读的业务失败原因。多个短码都写上。 def _business_fail_reason(code: str) -> str: + parts = [part for part in code.split(";") if part] + if len(parts) <= 1: + return _one_business_fail_reason(code) + tails = [_reason_tail(_one_business_fail_reason(part)) for part in parts] + return _BUSINESS_PREFIX + ";".join(tails) + + +# 单个短码对应的文案。 +def _one_business_fail_reason(code: str) -> str: mapping = { "NO_HIT": "无符合规则商品,平台无命中", "NO_BAR_CODE": "缺少条形码,无法检索平台", "NO_SALE_PRICE": "缺少销售价格", "NO_QTY": "缺少计划采购量", "STOCK_INSUFFICIENT": "无符合规则商品,库存不足", - "MARGIN_LT_4PCT": "无符合规则商品,毛利不足 4%", + "MARGIN_LT_4PCT": "无符合规则商品,毛利不大于 4%", "EXPIRY_LT_12M": "无符合规则商品,效期不足 12 个月", "EXPIRY_UNPARSEABLE": "无符合规则商品,效期不足 12 个月", "NOT_SELF_WAREHOUSE": "无符合规则商品,非自营仓", @@ -169,16 +198,25 @@ def _business_fail_reason(code: str) -> str: if code in mapping: return mapping[code] if code: - return f"无符合规则商品,{code}" + return f"{_BUSINESS_PREFIX}{code}" return "无符合规则商品" +# 拼多条原因时去掉重复的前缀。 +def _reason_tail(text: str) -> str: + if text.startswith(_BUSINESS_PREFIX): + return text[len(_BUSINESS_PREFIX) :] + return text + + +# 本平台有错误短码则带上;没有则写对端不可用。 def _system_fail_reason(mall: str, mall_errors: dict[str, str]) -> str: if mall in mall_errors: return _clip(f"系统故障:{mall_errors[mall]}", FAIL_REASON_MAX) return PEER_UNAVAILABLE +# 按字符截断,超出时末尾换成省略号。 def _clip(text: str, max_len: int) -> str: text = text.strip() if len(text) <= max_len: @@ -186,11 +224,12 @@ def _clip(text: str, max_len: int) -> str: return text[: max_len - 1] + "…" +# 表 1 deal_result 是 VARCHAR2(200 BYTE) + ZHS16GBK,按字节截。 def _clip_deal_result(text: str) -> str: - """表 1 deal_result 是 VARCHAR2(200 BYTE) + ZHS16GBK,按字节截。""" return _clip_encoded(text, DEAL_RESULT_MAX, "gbk") +# 按指定编码的字节数截断,避免切断多字节字符。 def _clip_encoded(text: str, max_bytes: int, encoding: str) -> str: text = text.strip() raw = text.encode(encoding, errors="replace") diff --git a/rpa_selection/pipeline.py b/rpa_selection/pipeline.py index 81ba9af..54f94ca 100644 --- a/rpa_selection/pipeline.py +++ b/rpa_selection/pipeline.py @@ -1,3 +1,8 @@ +# 单行两平台统一判定:合并候选后按仓序拆量。 +# +# 凑齐计划量才成功。一边故障且剩下的凑不齐是系统故障,两边都搜到但不够是库存不足。 +# 不在这里搜平台、不写表。 + from __future__ import annotations from dataclasses import dataclass, field @@ -22,6 +27,7 @@ KIND_BUSINESS_FAIL = "BUSINESS_FAIL" KIND_SYSTEM_FAIL = "SYSTEM_FAIL" +# 一个平台的搜索结果:候选列表,或失败短码。 @dataclass class MallOutcome: mall_name: str @@ -29,6 +35,7 @@ class MallOutcome: failed_reason: str | None = None +# 一行选品的判定:成功拆量、业务失败或系统故障。 @dataclass class SelectionResult: kind: str @@ -39,6 +46,7 @@ class SelectionResult: mall_errors: dict[str, str] = field(default_factory=dict) +# 把各平台结果合并后拆量。mall_errors 非空且凑不齐时标系统故障。 def run_selection( purchase: PurchaseLine, mall_outcomes: list[MallOutcome], @@ -102,20 +110,24 @@ def run_selection( ) +# 没有拆出量时,把同时命中的库存、毛利、效期、非自营都收进原因。 def _business_reason( passed_before_fold: list[Candidate], rejected: list[Rejected], had_hit: bool, ) -> str: + reasons = {item.reason for item in rejected} + codes: list[str] = [] if passed_before_fold: - return "STOCK_INSUFFICIENT" - reasons = [item.reason for item in rejected] + codes.append("STOCK_INSUFFICIENT") if REASON_MARGIN in reasons: - return REASON_MARGIN + codes.append(REASON_MARGIN) if REASON_EXPIRY in reasons or REASON_EXPIRY_UNPARSEABLE in reasons: - return REASON_EXPIRY + codes.append(REASON_EXPIRY) if REASON_NOT_SELF in reasons: - return REASON_NOT_SELF + codes.append(REASON_NOT_SELF) + if codes: + return ";".join(codes) if not had_hit: return "NO_HIT" return "NO_CANDIDATE_AFTER_RULES" diff --git a/rpa_selection/pyproject.toml b/rpa_selection/pyproject.toml index dd9ea5e..0bda28c 100644 --- a/rpa_selection/pyproject.toml +++ b/rpa_selection/pyproject.toml @@ -21,7 +21,7 @@ dev = [ package = false [tool.pytest.ini_options] -pythonpath = ["."] +pythonpath = [".", ".."] testpaths = ["tests"] addopts = "-q" diff --git a/rpa_selection/repo.py b/rpa_selection/repo.py index 9d63321..0e36765 100644 --- a/rpa_selection/repo.py +++ b/rpa_selection/repo.py @@ -1,17 +1,25 @@ -"""选品读写:表 1 领取、表 2 落结果。 +# 选品班次的领取和落表。 +# +# 从表 1 领取 deal_flag=0 的行改成 3,选品成功后写表 2、表 1 改成 1。 +# 进程中途退出时行停在 3,超过租约(deal_time)才能再领。 +# 不处理加购,不推接口 2。 -claim 时 deal_flag 0 → 3;save 时写成 1 或 2。 -中途崩溃会停在 3,需人工改回 0 再跑。 -""" from __future__ import annotations -from collections.abc import Callable +import logging from dataclasses import dataclass +from datetime import datetime, timedelta from typing import Any -from db import DEAL_USER_NAME, RepoUnavailable, connect_from_env, oracle_dsn +from rpa_oracle import RepoUnavailable, job_pool, update_by_fid_chunks -_SELECT_PENDING = """ +DEAL_USER_NAME = "rpa-select" + +logger = logging.getLogger("rpa_selection") +LEASE_MINUTES = 30 +_QUERY_ERROR_MAX = 4000 + +_SELECT_BODY = """ SELECT fid, oms_id, bar_code, sale_price, plan_purchase_qty, NVL(approval_no, '') AS approval_no, NVL(goods_name, '') AS goods_name, @@ -21,13 +29,17 @@ SELECT fid, oms_id, bar_code, sale_price, plan_purchase_qty, deal_flag FROM rpa_sales_goods_mapping_data WHERE deal_flag = 0 + OR (deal_flag = 3 AND deal_time < SYSDATE - NUMTODSINTERVAL(:lease_minutes, 'MINUTE')) ORDER BY fid """ +_SELECT_PENDING = _SELECT_BODY + "\nFOR UPDATE SKIP LOCKED" + _SELECT_PENDING_LIMIT = f""" SELECT * FROM ( -{_SELECT_PENDING} +{_SELECT_BODY} ) WHERE ROWNUM <= :limit +FOR UPDATE SKIP LOCKED """ _CLAIM_SQL = """ @@ -36,7 +48,7 @@ SET deal_flag = 3, deal_time = SYSDATE, update_time = SYSDATE, deal_user_name = :deal_user_name -WHERE fid = :fid AND deal_flag = 0 +WHERE fid IN ({placeholders}) """ # 选品无登录用户:创建人/更新人固定 0 / 系统管理员,时间用库时钟。 @@ -63,9 +75,38 @@ SET deal_flag = :deal_flag, deal_time = SYSDATE, update_time = SYSDATE, deal_user_name = :deal_user_name +WHERE fid = :fid + AND deal_flag = 3 +""" + +_OWN_SQL = """ +SELECT deal_flag +FROM rpa_sales_goods_mapping_data +WHERE fid = :fid +""" + +_CART_EXISTS_SQL = """ +SELECT COUNT(*) +FROM rpa_cart_settlement_goods_data WHERE oms_id = :oms_id """ +_TOUCH_SQL = """ +UPDATE rpa_sales_goods_mapping_data +SET deal_time = SYSDATE, + update_time = SYSDATE +WHERE fid = :fid + AND deal_flag = 3 +""" + +_INSERT_QUERY_AUDIT_SQL = """ +INSERT INTO rpa_selection_query_audit ( + oms_id, mapping_fid, mall_name, bar_code, error_text +) VALUES ( + :oms_id, :mapping_fid, :mall_name, :bar_code, :error_text +) +""" + _CART_BIND_KEYS = ( "operate_type", "oms_id", @@ -83,6 +124,27 @@ _CART_BIND_KEYS = ( ) +# 一次平台查询失败要写入审计表的原文。 +@dataclass +class QueryAudit: + oms_id: int + mapping_fid: int + mall_name: str + bar_code: str + error_text: str + + +# 一单要落的表 2 行,以及表 1 按 fid 的完成更新。 +@dataclass +class FinishWrite: + oms_id: int + fid: int + deal_flag: int + deal_result: str + cart_rows: list[dict[str, Any]] + + +# 表 1 一行待选品或处理中的映射。 @dataclass class MappingRow: fid: int @@ -97,108 +159,322 @@ class MappingRow: org_code: str deal_flag: int deal_result: str = "" + deal_time: datetime | None = None +# 判断内存行是否可领:0,或 3 且 deal_time 已过租约。 +def _is_claimable(row: MappingRow, now: datetime) -> bool: + if row.deal_flag == 0: + return True + if row.deal_flag == 3 and row.deal_time is not None: + return now - row.deal_time >= timedelta(minutes=LEASE_MINUTES) + return False + + +# 异常原文截到审计列 VARCHAR2(4000)。 +def _clip_error_text(text: str) -> str: + if len(text) <= _QUERY_ERROR_MAX: + return text + return text[:_QUERY_ERROR_MAX] + + +# 回滚当前事务。回滚本身失败则忽略。 +def _rollback(conn) -> None: + try: + conn.rollback() + except Exception: + pass + + +# 选品班次的内存仓库,测规则和班次编排,不测 SQL。 class MemorySelectRepo: + # 用给定的表 1 行初始化。同一 oms_id 后写覆盖先写。 def __init__(self, rows: list[MappingRow]): self.mapping: dict[int, MappingRow] = {row.oms_id: row for row in rows} self.cart: list[dict[str, Any]] = [] + self.query_audits: list[QueryAudit] = [] - def claim_pending(self, limit: int | None = None) -> list[MappingRow]: + # 领取待选品任务,把 deal_flag 从 0 或过期 3 改成 3。 + # + # 未过租约的 3 不领。limit 为空表示不限制条数。now 只给单测钉住时钟。 + def claim_pending( + self, limit: int | None = None, *, now: datetime | None = None + ) -> list[MappingRow]: + clock = now or datetime.now() pending = sorted( - (row for row in self.mapping.values() if row.deal_flag == 0), + (row for row in self.mapping.values() if _is_claimable(row, clock)), key=lambda row: row.fid, ) if limit is not None: pending = pending[:limit] for row in pending: row.deal_flag = 3 + row.deal_time = clock return pending + # 把一单选品结果写入内存表。班次请用 save_results。 def save_result( self, oms_id: int, deal_flag: int, deal_result: str, cart_rows: list[dict[str, Any]], + *, + fid: int, ) -> None: - row = self.mapping[oms_id] - row.deal_flag = deal_flag - row.deal_result = deal_result - self.cart.extend(dict(item) for item in cart_rows) + self.save_results( + [ + FinishWrite( + oms_id=oms_id, + fid=fid, + deal_flag=deal_flag, + deal_result=deal_result, + cart_rows=cart_rows, + ) + ] + ) + + def touch_claim(self, fid: int, *, now: datetime | None = None) -> None: + """搜索过程中刷新这一行的 deal_time。只动仍是 deal_flag=3 的行。""" + row = self._row_by_fid(fid) + if row.deal_flag != 3: + return + row.deal_time = now or datetime.now() + + def save_results(self, items: list[FinishWrite]) -> None: + """把选品结果写入内存表 2,并按 fid 改表 1。 + + 落库前再读:仍是 deal_flag=3,并且表 2 还没有该 oms_id,才插入。 + 已经不是本班领的,整单跳过。已有加购行时只改表 1,不插第二套。 + """ + for item in items: + row = self._row_by_fid(item.fid) + if row.oms_id != item.oms_id: + raise KeyError(item.oms_id) + if row.deal_flag != 3: + continue + exists = any(int(cart.get("oms_id") or 0) == int(item.oms_id) for cart in self.cart) + if item.cart_rows and not exists: + self.cart.extend(dict(cart_row) for cart_row in item.cart_rows) + row.deal_flag = item.deal_flag + row.deal_result = item.deal_result + + # 记下查询失败原文。不改表 1 / 表 2。 + def save_query_audits(self, items: list[QueryAudit]) -> None: + self.query_audits.extend(_clipped_audit(item) for item in items) + + # 按主键找表 1 行。找不到则 KeyError。 + def _row_by_fid(self, fid: int) -> MappingRow: + for row in self.mapping.values(): + if row.fid == fid: + return row + raise KeyError(fid) +# 选品班次连 Oracle 的仓库,管表 1 领取和表 2 写入。 +# +# 连接从 rpa_oracle 连接池取。领取戳是 rpa-select。 +# 进程退出后行停在 3,超过租约才能再领。 class OracleSelectRepo: - def __init__(self, connect: Callable): - self._connect = connect + # 挂上连接池。方法里按次 acquire,不要在仓库外再 connect。 + def __init__(self, pool): + self._pool = pool + # 按环境变量建班次池。缺 HOST / 用户 / 密码抛「数据库不可用」。 @classmethod def from_env(cls) -> "OracleSelectRepo": - oracle_dsn() - return cls(connect=connect_from_env) + return cls(pool=job_pool()) def claim_pending(self, limit: int | None = None) -> list[MappingRow]: - conn = self._connect() - try: - with conn.cursor() as cur: - if limit is None: - cur.execute(_SELECT_PENDING) - else: - cur.execute(_SELECT_PENDING_LIMIT, {"limit": int(limit)}) - fetched = [_mapping_from_cursor(cur, rec) for rec in cur.fetchall()] - claimed: list[MappingRow] = [] - for row in fetched: - cur.execute( - _CLAIM_SQL, - {"fid": row.fid, "deal_user_name": DEAL_USER_NAME}, - ) - if cur.rowcount == 1: - row.deal_flag = 3 - 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() + """领取待选品任务,把表 1 的 deal_flag 从 0 改成 3。 + 使用 FOR UPDATE SKIP LOCKED,两个班次不会领到同一行。 + 更新按每段 500 个 fid 执行,同一事务,任一段失败整笔回滚。 + 进程退出后行停在 3,超过租约(deal_time)才能再领。 + limit 仍只截这一班领多少;为空表示不限制。没有可领的行时返回空列表。 + """ + binds: dict = {"lease_minutes": LEASE_MINUTES} + try: + with self._pool.acquire() as conn: + try: + with conn.cursor() as cur: + if limit is None: + cur.execute(_SELECT_PENDING, binds) + else: + cur.execute( + _SELECT_PENDING_LIMIT, + {**binds, "limit": int(limit)}, + ) + fetched = [ + _mapping_from_cursor(cur, rec) for rec in cur.fetchall() + ] + if fetched: + update_by_fid_chunks( + cur, + _CLAIM_SQL, + [row.fid for row in fetched], + {"deal_user_name": DEAL_USER_NAME}, + ) + for row in fetched: + row.deal_flag = 3 + conn.commit() + return fetched + except Exception as exc: + _rollback(conn) + if isinstance(exc, RepoUnavailable): + raise + raise RepoUnavailable("数据库不可用") from exc + except RepoUnavailable: + raise + except Exception as exc: + raise RepoUnavailable("数据库不可用") from exc + + # 把一单选品结果落库。班次请用 save_results。 def save_result( self, oms_id: int, deal_flag: int, deal_result: str, cart_rows: list[dict[str, Any]], + *, + fid: int, ) -> None: - conn = self._connect() - try: - with conn.cursor() as cur: - for row in cart_rows: - payload = {key: row.get(key) for key in _CART_BIND_KEYS} - cur.execute(_INSERT_CART_SQL, payload) - cur.execute( - _COMPLETE_SQL, - { - "deal_flag": deal_flag, - "deal_result": deal_result, - "deal_user_name": DEAL_USER_NAME, - "oms_id": oms_id, - }, + self.save_results( + [ + FinishWrite( + oms_id=oms_id, + fid=fid, + deal_flag=deal_flag, + deal_result=deal_result, + cart_rows=cart_rows, ) - conn.commit() + ] + ) + + def touch_claim(self, fid: int) -> None: + """搜索过程中刷新 deal_time。只改仍是 deal_flag=3 的行,并马上提交。""" + try: + with self._pool.acquire() as conn: + try: + with conn.cursor() as cur: + cur.execute(_TOUCH_SQL, {"fid": int(fid)}) + conn.commit() + except Exception as exc: + _rollback(conn) + if isinstance(exc, RepoUnavailable): + raise + raise RepoUnavailable("数据库不可用") from exc + except RepoUnavailable: + raise except Exception as exc: - try: - conn.rollback() - except Exception: - pass - raise RepoUnavailable(str(exc)) from exc - finally: - conn.close() + raise RepoUnavailable("数据库不可用") from exc + + def save_results(self, items: list[FinishWrite]) -> None: + """把这一调用里的表 2 行和表 1 完成更新落在同一个事务。 + + 每一单先再读:仍是 deal_flag=3,并且表 2 还没有该 oms_id,才插入。 + 已有加购行时只改表 1。空列表不借连接。任一行失败整笔回滚。 + 库故障对外「数据库不可用」,不含 ORA 原文。 + """ + if not items: + return + try: + with self._pool.acquire() as conn: + try: + with conn.cursor() as cur: + cart_binds: list[dict] = [] + complete_binds: list[dict] = [] + for item in items: + if not _claim_still_open(cur, item.fid): + continue + if item.cart_rows and not _cart_already_written(cur, item.oms_id): + cart_binds.extend( + {key: cart_row.get(key) for key in _CART_BIND_KEYS} + for cart_row in item.cart_rows + ) + complete_binds.append( + { + "deal_flag": item.deal_flag, + "deal_result": item.deal_result, + "deal_user_name": DEAL_USER_NAME, + "fid": item.fid, + } + ) + if cart_binds: + cur.executemany(_INSERT_CART_SQL, cart_binds) + if complete_binds: + cur.executemany(_COMPLETE_SQL, complete_binds) + conn.commit() + except Exception as exc: + _rollback(conn) + if isinstance(exc, RepoUnavailable): + raise + raise RepoUnavailable("数据库不可用") from exc + except RepoUnavailable: + raise + except Exception as exc: + raise RepoUnavailable("数据库不可用") from exc + + # 把查询失败原文写入审计表。独立事务,失败只记日志。 + def save_query_audits(self, items: list[QueryAudit]) -> None: + if not items: + return + binds = [_audit_bind(item) for item in items] + try: + with self._pool.acquire() as conn: + try: + with conn.cursor() as cur: + cur.executemany(_INSERT_QUERY_AUDIT_SQL, binds) + conn.commit() + except Exception: + _rollback(conn) + logger.exception("选品查询审计写入失败") + except Exception: + logger.exception("选品查询审计写入失败") +def _claim_still_open(cur, fid: int) -> bool: + """再读表 1。deal_flag 仍是 3 才算还是本班领的。""" + cur.execute(_OWN_SQL, {"fid": int(fid)}) + rows = cur.fetchall() + if not rows: + return False + return int(rows[0][0]) == 3 + + +def _cart_already_written(cur, oms_id: int) -> bool: + """表 2 已有该 oms_id 时,重跑不能再插一套加购行。""" + cur.execute(_CART_EXISTS_SQL, {"oms_id": int(oms_id)}) + rows = cur.fetchall() + if not rows: + return False + return int(rows[0][0]) > 0 + + +# 返回截断原文后的审计行。 +def _clipped_audit(item: QueryAudit) -> QueryAudit: + return QueryAudit( + oms_id=item.oms_id, + mapping_fid=item.mapping_fid, + mall_name=item.mall_name, + bar_code=item.bar_code, + error_text=_clip_error_text(item.error_text), + ) + + +# 收成审计插入 bind。不绑 fid,留给触发器。 +def _audit_bind(item: QueryAudit) -> dict[str, Any]: + clipped = _clipped_audit(item) + return { + "oms_id": clipped.oms_id, + "mapping_fid": clipped.mapping_fid, + "mall_name": clipped.mall_name, + "bar_code": clipped.bar_code, + "error_text": clipped.error_text, + } + + +# 把查询结果行收成 MappingRow。缺省字符串当空,数字缺省当 None。 def _mapping_from_cursor(cur, rec) -> MappingRow: cols = [item[0].lower() for item in cur.description] data = dict(zip(cols, rec)) @@ -217,6 +493,7 @@ def _mapping_from_cursor(cur, rec) -> MappingRow: ) +# 把库里的数字收成 float;空值保持 None。 def _float_or_none(value: object) -> float | None: if value is None or value == "": return None diff --git a/rpa_selection/rules.py b/rpa_selection/rules.py index cc50e6f..4608cb8 100644 --- a/rpa_selection/rules.py +++ b/rpa_selection/rules.py @@ -1,11 +1,18 @@ +# 选品规则:自营仓全称匹配、毛利严格大于 4%、效期不少于 12 个月,按仓序贪心拆量。 +# +# 凑齐计划量才给出拆量行。凑不齐时 allocations 为空,调用方不落部分成功。 +# 湖南壹号、甘肃壹号云不在自营集合。 + from __future__ import annotations import re from calendar import monthrange from dataclasses import dataclass, field from datetime import date, datetime +from decimal import Decimal from typing import Any +# 仓序:级内由近到远。全称不在这张表里的商家不是自营仓。 WAREHOUSE_ORDER = [ "杭州小药药自营旗舰店", "长沙小药药自营旗舰店", @@ -26,7 +33,7 @@ WAREHOUSE_ORDER = [ WAREHOUSE_RANK = {name: index for index, name in enumerate(WAREHOUSE_ORDER)} WAREHOUSE_ALIASES: dict[str, str] = {} SELF_OPERATED_PREFIX = "[自营]" -MIN_MARGIN = 0.04 +MIN_MARGIN = Decimal("0.04") MIN_EXPIRY_MONTHS = 12.0 DAYS_PER_MONTH = 30.4375 @@ -35,9 +42,9 @@ REASON_MARGIN = "MARGIN_LT_4PCT" REASON_EXPIRY = "EXPIRY_LT_12M" REASON_EXPIRY_UNPARSEABLE = "EXPIRY_UNPARSEABLE" REASON_PRICE_UNPARSEABLE = "PRICE_UNPARSEABLE" -REASON_SAME_WAREHOUSE = "SAME_WAREHOUSE_NOT_BEST" +# 一条商城搜索结果:商家、价格、效期、库存。后面按仓序拆量。 @dataclass class Candidate: mall_name: str @@ -53,6 +60,7 @@ class Candidate: extra: dict[str, Any] = field(default_factory=dict) +# 表 1 的一条待选品需求。按条形码搜,计划量是 qty。 @dataclass class PurchaseLine: approval_no: str @@ -68,18 +76,21 @@ class PurchaseLine: org_code: str = "" +# 没通过自营、毛利或效期的候选,以及原因码。 @dataclass class Rejected: candidate: Candidate reason: str +# 一个仓分到的数量。同仓多条 sku 都留,合计才是该仓吃掉的量。 @dataclass class Allocation: candidate: Candidate qty: int +# 拆量结果。凑齐时 allocations 合计等于计划量;凑不齐时 allocations 为空。 @dataclass class AllocationResult: allocations: list[Allocation] @@ -88,6 +99,7 @@ class AllocationResult: passed_before_fold: list[Candidate] +# 去掉「[自营]」前缀和多余空白,收成仓序表里的商家全称。 def normalize_vendor(raw: Any) -> str: text = "" if raw is None else str(raw) text = text.replace("\u3000", " ").strip() @@ -97,18 +109,23 @@ def normalize_vendor(raw: Any) -> str: return WAREHOUSE_ALIASES.get(text, text) +# 商家全称是否在自营仓序里。 def is_self_warehouse(vendor_norm: str) -> bool: return vendor_norm in WAREHOUSE_RANK -def margin(sale_price: float, platform_price: float) -> float: - return (sale_price - platform_price) / sale_price +# (销售价 - 平台价) / 销售价。用十进制,避免 104 与 99.84 漂成不等于 4%。 +def margin(sale_price: float, platform_price: float) -> Decimal: + sale = Decimal(str(sale_price)) + return (sale - Decimal(str(platform_price))) / sale +# 毛利必须严格大于 4%。刚好 4% 不通过。 def margin_ok(sale_price: float, platform_price: float) -> bool: - return margin(sale_price, platform_price) >= MIN_MARGIN + return margin(sale_price, platform_price) > MIN_MARGIN +# 把效期原文收成剩余月数。日期按今天算;「长期」按 120 个月;认不出返回 None。 def parse_expiry_months(raw: Any, today: date | None = None) -> float | None: text = "" if raw is None else str(raw).strip() if not text or text in {"-", "—", "/", "无", "null", "None"}: @@ -159,6 +176,7 @@ def parse_expiry_months(raw: Any, today: date | None = None) -> float | None: return remaining_months(today, expiry_date) +# 只有年月、或日超出当月时,落到该月最后一天。 def _safe_date(year: int, month: int, day: int) -> date: last_day = monthrange(year, month)[1] if day <= 0 or day > last_day: @@ -166,12 +184,14 @@ def _safe_date(year: int, month: int, day: int) -> date: return date(year, month, day) +# 从今天到失效日还剩几个月。日差按 30.4375 天折成月。 def remaining_months(today: date, expiry: date) -> float: months = (expiry.year - today.year) * 12 + (expiry.month - today.month) months += (expiry.day - today.day) / DAYS_PER_MONTH return months +# 解析 YYYY-MM-DD 或 YYYY-MM。只有年月时用该月最后一天。 def _parse_expiry_date(text: str) -> date | None: for fmt in ("%Y-%m-%d", "%Y/%m/%d"): try: @@ -188,14 +208,18 @@ def _parse_expiry_date(text: str) -> date | None: return None +# 需求本身能不能选:要有销售价、计划量和条形码。过了返回 None。 def validate_purchase(purchase: PurchaseLine) -> str | None: if purchase.sale_price is None or purchase.sale_price <= 0: return "NO_SALE_PRICE" if purchase.qty is None or purchase.qty <= 0: return "NO_QTY" + if not purchase.bar_code: + return "NO_BAR_CODE" return None +# 单条候选过不过:自营仓、价格能解析、毛利严格大于 4%、效期不少于 12 个月。过了返回 None。 def evaluate_candidate( candidate: Candidate, purchase: PurchaseLine, @@ -221,12 +245,14 @@ def evaluate_candidate( return None +# 库存向下取整。没有或非正数当 0,不参与拆量。 def stock_units(stock: float | None) -> int: if stock is None or stock <= 0: return 0 return int(stock) +# 计划采购量向下取整。没有或非正数当 0。 def plan_qty(purchase: PurchaseLine) -> int: qty = purchase.qty if qty is None or qty <= 0: @@ -234,6 +260,9 @@ def plan_qty(purchase: PurchaseLine) -> int: return int(qty) +# 按仓序贪心拆计划量。同仓多个 sku 都参与,低价先吃。 +# +# 凑齐才返回拆量行,合计等于计划量。凑不齐清空 allocations,不落部分成功。 def allocate( candidates: list[Candidate], purchase: PurchaseLine, @@ -256,7 +285,7 @@ def allocate( else: rejected.append(Rejected(candidate, reason)) - folded = _fold_same_warehouse(passed, rejected) + folded = _fold_same_warehouse(passed) folded.sort(key=_sort_key) remaining = plan_qty(purchase) @@ -282,35 +311,12 @@ def allocate( ) -def _fold_same_warehouse( - passed: list[Candidate], - rejected: list[Rejected], -) -> list[Candidate]: - best: dict[tuple[str, str], Candidate] = {} - order: list[tuple[str, str]] = [] - for candidate in passed: - key = (candidate.mall_name, candidate.vendor_norm) - current = best.get(key) - if current is None: - best[key] = candidate - order.append(key) - continue - if _sku_better(candidate, current): - rejected.append(Rejected(current, REASON_SAME_WAREHOUSE)) - best[key] = candidate - else: - rejected.append(Rejected(candidate, REASON_SAME_WAREHOUSE)) - return [best[key] for key in order] - - -def _sku_better(left: Candidate, right: Candidate) -> bool: - left_price = left.price if left.price is not None else float("inf") - right_price = right.price if right.price is not None else float("inf") - if left_price != right_price: - return left_price < right_price - return stock_units(left.stock) > stock_units(right.stock) +# 同仓多个 sku 都留下。低价先拆由后面的排序决定,这里不再丢掉贵的那条。 +def _fold_same_warehouse(passed: list[Candidate]) -> list[Candidate]: + return list(passed) +# 仓序在前,同仓低价在前,同价库存多的在前。 def _sort_key(candidate: Candidate) -> tuple[int, float, int]: rank = WAREHOUSE_RANK[candidate.vendor_norm] price = candidate.price if candidate.price is not None else float("inf") diff --git a/rpa_selection/search_yaoex.py b/rpa_selection/search_yaoex.py index 5002939..82ac76a 100644 --- a/rpa_selection/search_yaoex.py +++ b/rpa_selection/search_yaoex.py @@ -1,3 +1,5 @@ +# 1 号药城按条形码搜索,解密价格后收成选品候选。 + from __future__ import annotations import base64 @@ -20,6 +22,7 @@ from rules import Candidate, normalize_vendor, parse_expiry_months from util import normalize_text, parse_float_or_none +# 1 号药城登录态:token、用户和采购商编码。价格解密要用 user_id。 @dataclass class YaoexAuth: token: str @@ -27,6 +30,7 @@ class YaoexAuth: buyer_code: str +# 拼搜索请求。关键字是条形码,效期下限 12 个月,只要有库存的。 def build_yaoex_search_request( auth: YaoexAuth, query_key: str ) -> tuple[dict[str, Any], dict[str, Any]]: @@ -71,6 +75,7 @@ def build_yaoex_search_request( return common_part, form_payload +# 用用户号拼价格解密密钥。没有用户号时只用基础密钥。 def build_yaoex_price_decrypt_key(user_id: Any) -> bytes: user_text = normalize_text(user_id) if not user_text: @@ -79,6 +84,7 @@ def build_yaoex_price_decrypt_key(user_id: Any) -> bytes: return dynamic_key.encode("utf-8") +# 把 1 号药城返回的价格收成数字。已是明文就直接用,否则按 AES 解密。 def decrypt_yaoex_price(raw_price: Any, user_id: Any) -> Any: if raw_price is None: return "" @@ -110,6 +116,7 @@ def decrypt_yaoex_price(raw_price: Any, user_id: Any) -> Any: return plain_text +# 按条形码查 1 号药城,返回商品列表和解密价格要用的用户号。 def query_yaoex_products( session: requests.Session, auth: YaoexAuth, @@ -145,6 +152,7 @@ def query_yaoex_products( return products, response_user_id +# 把 1 号药城商品收成候选:商家全称、解密后的价格、效期和库存。 def map_yaoex_products(products: list[dict[str, Any]], user_id: Any) -> list[Candidate]: candidates: list[Candidate] = [] if products: @@ -181,6 +189,7 @@ def map_yaoex_products(products: list[dict[str, Any]], user_id: Any) -> list[Can return candidates +# 按条形码搜 1 号药城。同一查询键本班只打一次接口。 def search_candidates( auth: YaoexAuth, query_key: str, diff --git a/rpa_selection/search_ybm.py b/rpa_selection/search_ybm.py index 9c4efaa..5452fe3 100644 --- a/rpa_selection/search_ybm.py +++ b/rpa_selection/search_ybm.py @@ -1,3 +1,5 @@ +# 药帮忙按条形码搜索,收成选品候选。无货的价格记 0,不参与拆量。 + from __future__ import annotations from typing import Any @@ -10,6 +12,7 @@ from rules import Candidate, normalize_vendor, parse_expiry_months from util import normalize_text, parse_float_or_none, parse_int_or_none +# 拼药帮忙搜索体。关键字是条形码,只要效期超过一年的。 def build_ybm_search_payload(query_key: str) -> dict[str, Any]: return { "queryWord": query_key, @@ -24,6 +27,7 @@ def build_ybm_search_payload(query_key: str) -> dict[str, Any]: } +# 取平台价。price 空了再用 fob。 def pick_ybm_platform_price(product: dict[str, Any]) -> Any: price = product.get("price") if normalize_text(price) != "": @@ -34,6 +38,7 @@ def pick_ybm_platform_price(product: dict[str, Any]) -> Any: return "" +# 按条形码查药帮忙,返回商品行。接口声明失败就报错,不当成没搜到。 def query_ybm_products( session: requests.Session, query_key: str, @@ -69,6 +74,7 @@ def query_ybm_products( return rows +# 把药帮忙商品收成候选。库存为 0 时价格记 0,后面拆量不会吃它。 def map_ybm_products(rows: list[dict[str, Any]]) -> list[Candidate]: candidates: list[Candidate] = [] for row in rows: @@ -97,6 +103,7 @@ def map_ybm_products(rows: list[dict[str, Any]]) -> list[Candidate]: return candidates +# 按条形码搜药帮忙。同一查询键本班只打一次接口。 def search_candidates( cookies: dict[str, str], query_key: str, diff --git a/rpa_selection/tests/test_auth_login.py b/rpa_selection/tests/test_auth_login.py new file mode 100644 index 0000000..026e593 --- /dev/null +++ b/rpa_selection/tests/test_auth_login.py @@ -0,0 +1,64 @@ +# 登录缓存权限和商城账号来源。 + +from __future__ import annotations + +import json +import os +import stat + +from auth_login import load_secrets, save_cookie_file, save_yaoex_auth +from search_yaoex import YaoexAuth + + +# 写出的 1 号药城缓存权限为 0o600(Windows 只断言文件存在)。 +def test_save_yaoex_auth_sets_mode_600(tmp_path): + path = tmp_path / "yaoex.json" + save_yaoex_auth(YaoexAuth(token="t", user_id="u", buyer_code="b"), path) + assert path.is_file() + if os.name == "nt": + return + assert path.stat().st_mode & 0o777 == 0o600 + + +# 写出的药帮忙 cookie 权限为 0o600(Windows 只断言文件存在)。 +def test_save_cookie_file_sets_mode_600(tmp_path): + path = tmp_path / "ybm.json" + save_cookie_file({"sid": "1"}, path) + assert path.is_file() + if os.name == "nt": + return + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + + +# 旁路 secrets.json 不读。没有环境变量时账号为空。 +def test_load_secrets_ignores_secrets_json(tmp_path, monkeypatch): + monkeypatch.setattr("auth_login.PROJECT_ROOT", tmp_path) + (tmp_path / "secrets.json").write_text( + json.dumps({"yaoex": {"username": "from-file", "password": "p"}}), + encoding="utf-8", + ) + monkeypatch.delenv("YAOEX_USERNAME", raising=False) + monkeypatch.delenv("YAOEX_PASSWORD", raising=False) + monkeypatch.delenv("YBM_USERNAME", raising=False) + monkeypatch.delenv("YBM_PASSWORD", raising=False) + secrets = load_secrets() + assert secrets["yaoex"]["username"] == "" + assert secrets["yaoex"]["password"] == "" + + +# 账号只认环境变量。旁边的 json 即使写了别的用户名也不采用。 +def test_load_secrets_reads_env_not_json(tmp_path, monkeypatch): + monkeypatch.setattr("auth_login.PROJECT_ROOT", tmp_path) + (tmp_path / "secrets.json").write_text( + json.dumps({"yaoex": {"username": "from-file", "password": "p"}}), + encoding="utf-8", + ) + monkeypatch.setenv("YAOEX_USERNAME", "from-env") + monkeypatch.setenv("YAOEX_PASSWORD", "env-pass") + monkeypatch.delenv("YBM_USERNAME", raising=False) + monkeypatch.delenv("YBM_PASSWORD", raising=False) + secrets = load_secrets() + assert secrets["yaoex"]["username"] == "from-env" + assert secrets["yaoex"]["password"] == "env-pass" + assert secrets["ybm"]["username"] == "" + assert secrets["ybm"]["password"] == "" diff --git a/rpa_selection/tests/test_cli.py b/rpa_selection/tests/test_cli.py index 7d22d91..9bbd556 100644 --- a/rpa_selection/tests/test_cli.py +++ b/rpa_selection/tests/test_cli.py @@ -1,5 +1,7 @@ from __future__ import annotations +from types import SimpleNamespace + import pytest import main @@ -92,6 +94,47 @@ def test_main_writes_cart_rows_from_mapping(monkeypatch): assert repo.cart[0]["bar_code"] == "06901234567890" +def test_default_limit_caps_claim_at_50(monkeypatch): + """不传 --limit 时最多领 50 条,多出来的行保持待领。""" + rows = [ + MappingRow( + fid=i, + oms_id=2026100200 + i, + bar_code="06901234567890", + sale_price=100.0, + plan_purchase_qty=10, + approval_no="国药准字H20100001", + goods_name="药A", + goods_spec="0.25g", + manufacturer="示例", + org_code="907", + deal_flag=0, + ) + for i in range(1, 61) + ] + repo = MemorySelectRepo(rows) + seen: dict[str, int | None] = {} + + def fake_run_batch(bound_repo, **kwargs): + """只按入口传入的上限领取,不搜平台。""" + seen["limit"] = kwargs.get("limit") + claimed = bound_repo.claim_pending(limit=kwargs.get("limit")) + seen["count"] = len(claimed) + return SimpleNamespace(claimed=len(claimed), added=0, skipped=0, failed=0) + + def no_browser(*_args, **_kwargs): + """挡住浏览器登录。""" + raise RuntimeError("no browser") + + monkeypatch.setattr(main, "run_batch", fake_run_batch) + monkeypatch.setattr(main, "login_yaoex", no_browser) + monkeypatch.setattr(main, "login_ybm", no_browser) + main.main([], repo=repo) + assert seen["limit"] == main.DEFAULT_CLAIM_LIMIT + assert seen["count"] == 50 + assert seen["count"] <= main.DEFAULT_CLAIM_LIMIT + + def test_run_batch_empty_claim_is_noop(): repo = MemorySelectRepo([]) summary = run_batch( diff --git a/rpa_selection/tests/test_job.py b/rpa_selection/tests/test_job.py index 98ee369..e0d0a74 100644 --- a/rpa_selection/tests/test_job.py +++ b/rpa_selection/tests/test_job.py @@ -1,5 +1,9 @@ +# 选品班次:领取戳、按条形码搜、系统故障短码。 + from __future__ import annotations +from datetime import datetime, timedelta + from repo import MappingRow, MemorySelectRepo from rules import Candidate, normalize_vendor from job import run_batch @@ -9,6 +13,7 @@ HANGZHOU = "杭州小药药自营旗舰店" GUANGDONG = "广东壹号药业有限公司" +# 构造一条待选品表 1 行。 def _row(**overrides) -> MappingRow: data = { "fid": 1, @@ -27,6 +32,7 @@ def _row(**overrides) -> MappingRow: return MappingRow(**data) +# 构造一条能通过规则的候选。 def _ok_candidate(mall: str = "1号药城", **overrides) -> Candidate: vendor = overrides.pop("vendor", GUANGDONG if mall == "1号药城" else HANGZHOU) data = { @@ -47,6 +53,7 @@ def _ok_candidate(mall: str = "1号药城", **overrides) -> Candidate: return Candidate(**data) +# deal_flag=0 领走后变成 3,已完成的 1 不领。 def test_claim_pending_sets_processing_flag(): repo = MemorySelectRepo([_row(), _row(fid=2, oms_id=2026100202, deal_flag=1)]) claimed = repo.claim_pending() @@ -55,10 +62,34 @@ def test_claim_pending_sets_processing_flag(): assert repo.mapping[2026100202].deal_flag == 1 +# deal_flag=3 且 deal_time 超过租约的行可以再领。 +def test_claim_reclaims_expired_flag_3(): + now = datetime(2026, 10, 22, 12, 0, 0) + expired = now - timedelta(minutes=31) + repo = MemorySelectRepo( + [_row(deal_flag=3, deal_time=expired)] + ) + claimed = repo.claim_pending(now=now) + assert [row.oms_id for row in claimed] == [2026100201] + assert repo.mapping[2026100201].deal_flag == 3 + + +# deal_flag=3 且未过租约的行不领。 +def test_claim_skips_fresh_flag_3(): + now = datetime(2026, 10, 22, 12, 0, 0) + fresh = now - timedelta(minutes=1) + repo = MemorySelectRepo([_row(deal_flag=3, deal_time=fresh)]) + claimed = repo.claim_pending(now=now) + assert claimed == [] + assert repo.mapping[2026100201].deal_flag == 3 + + +# 班次按条形码搜,不拿批准文号去搜。 def test_job_searches_barcode_not_approval_no(): repo = MemorySelectRepo([_row()]) seen: list[str] = [] + # 记下查询键并返回一条合格候选。 def search_yaoex(query_key: str): seen.append(query_key) return [_ok_candidate()] @@ -78,10 +109,91 @@ def test_job_searches_barcode_not_approval_no(): assert repo.cart[0]["purchase_qty"] == 10 +def test_job_saves_each_claimed_row_before_the_next(): + """一行搜完就落这一行,不攒到班次结束。""" + + class _Repo(MemorySelectRepo): + """记下每次 save_results 的行。""" + + def __init__(self, rows): + super().__init__(rows) + self.batches: list[list] = [] + + def save_results(self, items): + """记下这一次落库,再交给内存仓库。""" + self.batches.append(list(items)) + super().save_results(items) + + repo = _Repo( + [ + _row(fid=1, oms_id=1, bar_code="111"), + _row(fid=2, oms_id=2, bar_code="222"), + ] + ) + + # 两行都给一条合格候选。 + def search(_query_key: str): + return [_ok_candidate()] + + summary = run_batch( + repo, + platforms=["1号药城"], + searchers={"1号药城": search}, + login_errors={}, + ) + assert summary.claimed == 2 + assert summary.added == 2 + assert len(repo.batches) == 2 + assert [item.fid for batch in repo.batches for item in batch] == [1, 2] + assert repo.mapping[1].deal_flag == 1 + assert repo.mapping[2].deal_flag == 1 + + +def test_expired_lease_second_process_does_not_insert_second_cart_set(): + """租约过期后第二个进程不再插入第二套表 2。""" + from repo import FinishWrite + + now = datetime(2026, 10, 22, 12, 0, 0) + repo = MemorySelectRepo([_row(deal_flag=3, deal_time=now - timedelta(minutes=31))]) + claimed = repo.claim_pending(now=now) + assert [row.fid for row in claimed] == [1] + repo.cart.append( + { + "oms_id": 2026100201, + "platform_type": 66, + "purchase_qty": 10, + "status": 1, + } + ) + repo.save_results( + [ + FinishWrite( + oms_id=2026100201, + fid=1, + deal_flag=1, + deal_result="选品成功:1号药城", + cart_rows=[ + { + "oms_id": 2026100201, + "platform_type": 66, + "purchase_qty": 10, + "status": 1, + } + ], + ) + ] + ) + assert len(repo.cart) == 1 + assert repo.cart[0]["purchase_qty"] == 10 + assert repo.mapping[2026100201].deal_flag == 1 + + +# 没有条形码时不搜平台,业务失败。 def test_job_skips_search_when_barcode_missing(): repo = MemorySelectRepo([_row(bar_code="")]) called = {"n": 0} + # 本用例不应被调用。 def search_yaoex(_query_key: str): called["n"] += 1 return [_ok_candidate()] @@ -99,6 +211,7 @@ def test_job_skips_search_when_barcode_missing(): assert "条形码" in (repo.cart[0]["fail_reason"] or "") +# limit 只领 fid 最小的前 N 条。 def test_job_limit_claims_first_n(): repo = MemorySelectRepo( [ @@ -113,6 +226,7 @@ def test_job_limit_claims_first_n(): def test_both_malls_login_failed_marks_system_fault(): + """两边登录失败且凑不齐时,deal_flag 改回 0,不写表 2。""" repo = MemorySelectRepo([_row()]) summary = run_batch( repo, @@ -120,19 +234,21 @@ def test_both_malls_login_failed_marks_system_fault(): searchers={}, login_errors={"1号药城": "chrome down", "药帮忙": "timeout"}, ) - assert summary.failed == 2 - assert repo.mapping[2026100201].deal_flag == 2 + assert summary.added == 0 + assert repo.mapping[2026100201].deal_flag == 0 assert repo.mapping[2026100201].deal_result.startswith("系统故障") - assert len(repo.cart) == 2 - assert all(row["status"] == 2 for row in repo.cart) + assert repo.cart == [] +# 杭州 3 + 广东 7 落两行成功,不插多余行。 def test_split_across_malls_writes_two_success_rows(): repo = MemorySelectRepo([_row()]) + # 广东仓库存 20。 def search_yaoex(_key: str): return [_ok_candidate("1号药城", stock=20.0)] + # 杭州仓库存 3。 def search_ybm(_key: str): return [_ok_candidate("药帮忙", vendor=HANGZHOU, sku_id="HZ", stock=3.0)] @@ -151,9 +267,11 @@ def test_split_across_malls_writes_two_success_rows(): assert ybm["multi_platform_flag"] == 1 +# 一边登录失败、另一边能凑齐计划量时按成功落。 def test_peer_login_failed_but_other_fills_is_success(): repo = MemorySelectRepo([_row()]) + # 广东仓库存 20,能单独凑齐。 def search_yaoex(_key: str): return [_ok_candidate(stock=20.0)] @@ -170,9 +288,11 @@ def test_peer_login_failed_but_other_fills_is_success(): def test_peer_login_failed_and_short_stock_is_system_fault(): + """一边故障且剩下的凑不齐时改回待领,不写成库存不足,不写表 2。""" repo = MemorySelectRepo([_row()]) def search_yaoex(_key: str): + """广东仓库存 4,凑不齐计划 10。""" return [_ok_candidate(stock=4.0)] summary = run_batch( @@ -181,19 +301,22 @@ def test_peer_login_failed_and_short_stock_is_system_fault(): searchers={"1号药城": search_yaoex}, login_errors={"药帮忙": "timeout"}, ) - assert summary.failed == 2 assert summary.added == 0 - assert repo.mapping[2026100201].deal_flag == 2 + assert repo.mapping[2026100201].deal_flag == 0 assert repo.mapping[2026100201].deal_result.startswith("系统故障") - assert all(row["status"] == 2 for row in repo.cart) + assert "库存不足" not in repo.mapping[2026100201].deal_result + assert repo.cart == [] +# 两边都搜到但合计不够是业务失败库存不足。 def test_combined_short_stock_is_business_fail(): repo = MemorySelectRepo([_row()]) + # 广东仓库存 2。 def search_yaoex(_key: str): return [_ok_candidate(stock=2.0)] + # 杭州仓库存 2。 def search_ybm(_key: str): return [_ok_candidate("药帮忙", vendor=HANGZHOU, sku_id="HZ", stock=2.0)] @@ -208,3 +331,34 @@ def test_combined_short_stock_is_business_fail(): assert repo.mapping[2026100201].deal_flag == 2 assert "库存不足" in repo.mapping[2026100201].deal_result assert all(row["status"] == 2 for row in repo.cart) + + +# 查询抛错时原因列只写 QUERY_FAILED,不含异常原文。 +def test_job_query_failed_writes_short_code_not_exception(): + repo = MemorySelectRepo([_row()]) + + # 抛带 ORA 原文的错,确认原因列不收录。 + def boom(_key: str): + raise RuntimeError("ORA-03114 secret-traceback") + + summary = run_batch( + repo, + platforms=["1号药城"], + searchers={"1号药城": boom}, + login_errors={}, + ) + assert summary.added == 0 + assert repo.mapping[2026100201].deal_flag == 0 + assert repo.cart == [] + deal_result = repo.mapping[2026100201].deal_result + assert deal_result.startswith("系统故障") + assert "QUERY_FAILED" in deal_result + assert "ORA-03114" not in deal_result + assert "secret-traceback" not in deal_result + assert len(repo.query_audits) == 1 + audit = repo.query_audits[0] + assert audit.oms_id == 2026100201 + assert audit.mapping_fid == 1 + assert audit.mall_name == "1号药城" + assert audit.bar_code == "06901234567890" + assert audit.error_text == "ORA-03114 secret-traceback" diff --git a/rpa_selection/tests/test_oracle_repo.py b/rpa_selection/tests/test_oracle_repo.py index 28179a8..c534cdf 100644 --- a/rpa_selection/tests/test_oracle_repo.py +++ b/rpa_selection/tests/test_oracle_repo.py @@ -1,13 +1,23 @@ +# 选品 Oracle 仓库:.env 填充、领取 SQL、落表一条连接。不连真库。 + from __future__ import annotations +from contextlib import contextmanager import os import pytest -from db import RepoUnavailable, load_runtime_env -from repo import OracleSelectRepo +from rpa_oracle import RepoUnavailable, load_job_env +from repo import ( + FinishWrite, + OracleSelectRepo, + QueryAudit, + _SELECT_PENDING, + _SELECT_PENDING_LIMIT, +) +# 选品 .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) @@ -22,12 +32,13 @@ def test_load_runtime_env_fills_blank_from_oms_env(tmp_path, monkeypatch): "RPA_ORACLE_PASSWORD=not-for-git\nRPA_ORACLE_USER=from-oms\n", encoding="utf-8", ) - load_runtime_env([select_env, oms_env]) + load_job_env(tmp_path, [select_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) @@ -36,61 +47,208 @@ def test_from_env_without_user_raises(monkeypatch): OracleSelectRepo.from_env() -def test_save_result_inserts_cart_then_updates_mapping(): - executed: list[tuple[str, dict]] = [] +# 录 SQL 的假游标,fetchall 返回预设行。 +class _FakeCursor: + # 挂上共享的 executed 列表和预设查询结果。 + def __init__( + self, + executed: list, + rows: list | None = None, + description=None, + *, + many: list | None = None, + fail_on_many: bool = False, + fail_update_at: int | None = None, + ): + self._executed = executed + self._rows = rows or [] + self._many = many if many is not None else [] + self._fail_on_many = fail_on_many + self._fail_update_at = fail_update_at + self._updates = 0 + self.description = description + self.rowcount = 1 - class FakeCursor: - def __enter__(self): - return self + # 进入 with,返回自身。 + def __enter__(self): + return self - def __exit__(self, *args): - return False + # 退出 with,不吞异常。 + def __exit__(self, *args): + return False - def execute(self, sql, params=None): - executed.append((sql, params or {})) + # 记下 SQL 和 bind。一条 IN 超过 1000 个 fid 时抛错,模拟 ORA-01795。 + def execute(self, sql, params=None): + params = params or {} + _reject_oversized_in(params) + if str(sql).lstrip().lower().startswith("update"): + self._updates = getattr(self, "_updates", 0) + 1 + if self._fail_update_at is not None and self._updates == self._fail_update_at: + raise RuntimeError("chunk failed") + self._last_sql = sql + self._executed.append((sql, params)) - class FakeConn: - def __init__(self): - self.committed = False + # 记下批量 SQL。fail_on_many 时抛错,用来点名整批回滚。 + def executemany(self, sql, seq): + if self._fail_on_many: + raise RuntimeError("batch failed") + self._many.append((sql, [dict(item) for item in seq])) - def cursor(self): - return FakeCursor() + def fetchall(self): + """领取查询返回预设行。落库再读默认仍是 deal_flag=3、表 2 还没有这单。""" + text = " ".join(str(getattr(self, "_last_sql", "")).split()).lower() + if text.startswith("select") and "for update" not in text: + if "count" in text and "rpa_cart_settlement_goods_data" in text: + return [(0,)] + if "deal_flag" in text and "where fid" in text: + return [(3,)] + return list(self._rows) - def commit(self): - self.committed = True - def rollback(self): - raise AssertionError("不应 rollback") +# 假连接:同一实例上的 cursor 共享 executed 列表。 +class _FakeConn: + # 可选带上领取查询要返回的行。 + def __init__( + self, + rows: list | None = None, + description=None, + *, + fail_on_many: bool = False, + fail_update_at: int | None = None, + ): + self.executed: list[tuple[str, dict]] = [] + self.many: list[tuple[str, list]] = [] + self.committed = False + self.rolled_back = False + self.closed = 0 + self._rows = rows or [] + self._description = description + self._fail_on_many = fail_on_many + self._fail_update_at = fail_update_at - def close(self): - pass + # 新开一个录 SQL 的游标。 + def cursor(self): + return _FakeCursor( + self.executed, + self._rows, + self._description, + many=self.many, + fail_on_many=self._fail_on_many, + fail_update_at=self._fail_update_at, + ) - conn = FakeConn() - repo = OracleSelectRepo(connect=lambda: conn) - repo.save_result( - 2026100201, - 1, - "选品成功:1号药城", - [ - { - "operate_type": 1, - "oms_id": 2026100201, - "platform_type": 66, - "supplier_name": "广东壹号药业有限公司", - "bar_code": "06901234567890", - "purchase_price": 90, - "purchase_qty": 10, - "purchase_total": 900, - "status": 1, - "fail_reason": None, - "push_status": 0, - "org_code": "907", - "multi_platform_flag": 0, - } + # 记下已提交。 + def commit(self): + self.committed = True + + # 记下已回滚。 + def rollback(self): + self.rolled_back = True + + # 记下关闭次数。 + def close(self): + self.closed += 1 + + +# 只交出一条连接,并记下借了几次。 +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",), + ("BAR_CODE",), + ("SALE_PRICE",), + ("PLAN_PURCHASE_QTY",), + ("APPROVAL_NO",), + ("GOODS_NAME",), + ("GOODS_SPEC",), + ("MANUFACTURER",), + ("ORG_CODE",), + ("DEAL_FLAG",), +) + + +# 构造一条表 2 成功行 bind。 +def _cart_row(**overrides) -> dict: + row = { + "operate_type": 1, + "oms_id": 2026100201, + "platform_type": 66, + "supplier_name": "广东壹号药业有限公司", + "bar_code": "06901234567890", + "purchase_price": 90, + "purchase_qty": 10, + "purchase_total": 900, + "status": 1, + "fail_reason": None, + "push_status": 0, + "org_code": "907", + "multi_platform_flag": 0, + } + row.update(overrides) + return row + + +# 领取 SQL 带 FOR UPDATE SKIP LOCKED,过期 deal_flag=3 可再领。 +def test_claim_sql_uses_skip_locked_and_lease(): + from repo import LEASE_MINUTES + + sql = " ".join(_SELECT_PENDING.split()).lower() + assert "for update skip locked" in sql + assert "deal_flag = 0" in sql + assert "deal_flag = 3" in sql + assert "deal_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=[ + (1, 2026100201, "06901234567890", 100, 10, "", "药", "0.25g", "厂", "907", 0), ], + description=_CLAIM_DESCRIPTION, ) + pool = _FakePool(conn) + repo = OracleSelectRepo(pool) + claimed = repo.claim_pending(limit=5) + assert pool.acquires == 1 + assert [row.oms_id for row in claimed] == [2026100201] + assert claimed[0].deal_flag == 3 assert conn.committed is True - sql = " ".join(executed[0][0].split()) + 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 "deal_flag = 3" in update_sql + assert "fid" in update_sql + + +# 先插表 2 再改表 1,同一连接同一事务。 +def test_save_result_inserts_cart_then_updates_mapping(): + conn = _FakeConn() + pool = _FakePool(conn) + repo = OracleSelectRepo(pool) + repo.save_result(2026100201, 1, "选品成功:1号药城", [_cart_row()], fid=42) + assert conn.committed is True + assert conn.rolled_back is False + assert pool.acquires == 1 + assert len(conn.many) == 2 + sql = " ".join(conn.many[0][0].split()) assert "rpa_cart_settlement_goods_data" in sql.lower() assert "create_user_id" in sql assert "create_user_name" in sql @@ -99,7 +257,200 @@ def test_save_result_inserts_cart_then_updates_mapping(): assert "update_user_name" in sql assert "SYSDATE, 0, '系统管理员'" in sql assert sql.count("SYSDATE") == 2 - assert executed[0][1]["oms_id"] == 2026100201 - assert "create_user_name" not in executed[0][1] - assert "rpa_sales_goods_mapping_data" in executed[1][0].lower() - assert executed[1][1]["deal_flag"] == 1 + assert conn.many[0][1][0]["oms_id"] == 2026100201 + assert "create_user_name" not in conn.many[0][1][0] + update_sql = " ".join(conn.many[1][0].split()).lower() + assert "rpa_sales_goods_mapping_data" in update_sql + assert "where fid = :fid" in update_sql + assert conn.many[1][1][0]["fid"] == 42 + assert conn.many[1][1][0]["deal_flag"] == 1 + + +# 一批两行表 2 只从池里借一次连接。 +def test_save_result_acquires_once_for_two_cart_rows(): + conn = _FakeConn() + pool = _FakePool(conn) + repo = OracleSelectRepo(pool) + repo.save_result( + 2026100201, + 1, + "选品成功:1号药城、药帮忙", + [_cart_row(purchase_qty=7), _cart_row(platform_type=48, purchase_qty=3)], + fid=42, + ) + assert pool.acquires == 1 + inserts = [item for item in conn.many if "rpa_cart_settlement_goods_data" in item[0].lower()] + updates = [item for item in conn.many if "rpa_sales_goods_mapping_data" in item[0].lower()] + assert len(inserts) == 1 + assert len(inserts[0][1]) == 2 + assert len(updates) == 1 + assert len(updates[0][1]) == 1 + assert conn.committed is True + + +# 两单的表 2 和表 1 完成更新各一次 executemany,只借一条连接。 +def test_save_results_batches_two_orders_in_one_acquire(): + conn = _FakeConn() + pool = _FakePool(conn) + repo = OracleSelectRepo(pool) + repo.save_results( + [ + FinishWrite( + oms_id=2026100201, + fid=42, + deal_flag=1, + deal_result="选品成功:1号药城、药帮忙", + cart_rows=[ + _cart_row(purchase_qty=7), + _cart_row(platform_type=48, purchase_qty=3), + ], + ), + FinishWrite( + oms_id=2026100202, + fid=43, + deal_flag=2, + deal_result="缺少条形码,无法检索平台", + cart_rows=[_cart_row(oms_id=2026100202, status=2, purchase_qty=10)], + ), + ] + ) + assert pool.acquires == 1 + assert conn.committed is True + assert conn.rolled_back is False + assert len(conn.many) == 2 + insert_sql, insert_binds = conn.many[0] + update_sql, update_binds = conn.many[1] + assert "rpa_cart_settlement_goods_data" in insert_sql.lower() + assert [row["oms_id"] for row in insert_binds] == [ + 2026100201, + 2026100201, + 2026100202, + ] + assert "where fid = :fid" in " ".join(update_sql.split()).lower() + assert [row["fid"] for row in update_binds] == [42, 43] + assert [row["deal_flag"] for row in update_binds] == [1, 2] + + +# 批量写入失败则整批回滚,不提交。 +def test_save_results_rolls_back_the_batch_when_write_fails(): + conn = _FakeConn(fail_on_many=True) + pool = _FakePool(conn) + repo = OracleSelectRepo(pool) + with pytest.raises(RepoUnavailable): + repo.save_results( + [ + FinishWrite( + oms_id=2026100201, + fid=42, + deal_flag=1, + deal_result="选品成功:1号药城", + cart_rows=[_cart_row()], + ) + ] + ) + assert conn.rolled_back is True + assert conn.committed is False + assert pool.acquires == 1 + + +# 查询失败原文进审计表,一次 executemany,不绑 fid。 +def test_save_query_audits_inserts_error_text_apart_from_deal_result(): + conn = _FakeConn() + pool = _FakePool(conn) + repo = OracleSelectRepo(pool) + repo.save_query_audits( + [ + QueryAudit( + oms_id=2026100201, + mapping_fid=42, + mall_name="1号药城", + bar_code="06901234567890", + error_text="ORA-03114 secret-traceback", + ) + ] + ) + assert pool.acquires == 1 + assert conn.committed is True + assert conn.rolled_back is False + assert len(conn.many) == 1 + sql, binds = conn.many[0] + folded = " ".join(sql.split()).lower() + assert "insert into rpa_selection_query_audit" in folded + assert "( oms_id, mapping_fid, mall_name, bar_code, error_text )" in folded + assert binds[0]["oms_id"] == 2026100201 + assert binds[0]["mapping_fid"] == 42 + assert binds[0]["error_text"] == "ORA-03114 secret-traceback" + + +# 审计写入失败只回滚审计自己,不向外抛。 +def test_save_query_audits_swallows_write_failure(): + conn = _FakeConn(fail_on_many=True) + pool = _FakePool(conn) + repo = OracleSelectRepo(pool) + repo.save_query_audits( + [ + QueryAudit( + oms_id=2026100201, + mapping_fid=42, + mall_name="1号药城", + bar_code="06901234567890", + error_text="ORA-03114 secret-traceback", + ) + ] + ) + assert conn.rolled_back is True + assert conn.committed is False + + +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 能领走,同一事务提交,不报数据库不可用。""" + rows = [ + (fid, 2026100200 + fid, "0690", 100, 10, "", "药", "0.25g", "厂", "907", 0) + for fid in range(1, 1002) + ] + conn = _FakeConn(rows=rows, description=_CLAIM_DESCRIPTION) + pool = _FakePool(conn) + repo = OracleSelectRepo(pool) + claimed = repo.claim_pending() + assert pool.acquires == 1 + assert len(claimed) == 1001 + assert claimed[0].deal_flag == 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 updates[0][1]["deal_user_name"] == "rpa-select" + assert conn.committed is True + assert conn.rolled_back is False + + +def test_claim_rolls_back_when_a_later_chunk_fails(): + """后一段更新失败时整笔回滚,不提交,对外数据库不可用。""" + rows = [ + (fid, 2026100200 + fid, "0690", 100, 10, "", "药", "0.25g", "厂", "907", 0) + for fid in range(1, 502) + ] + conn = _FakeConn(rows=rows, description=_CLAIM_DESCRIPTION, fail_update_at=2) + pool = _FakePool(conn) + repo = OracleSelectRepo(pool) + with pytest.raises(RepoUnavailable, match="数据库不可用"): + repo.claim_pending() + assert conn.rolled_back is True + assert conn.committed is False + assert pool.acquires == 1 diff --git a/rpa_selection/tests/test_persist.py b/rpa_selection/tests/test_persist.py index a9752c8..8b8feb6 100644 --- a/rpa_selection/tests/test_persist.py +++ b/rpa_selection/tests/test_persist.py @@ -142,8 +142,8 @@ def test_margin_fail_text_is_4_percent(): malls=["1号药城"], ), ) - assert "毛利不足 4%" in result.deal_result - assert "毛利不足 4%" in (result.cart_rows[0]["fail_reason"] or "") + assert "毛利不大于 4%" in result.deal_result + assert "毛利不大于 4%" in (result.cart_rows[0]["fail_reason"] or "") def test_system_fault_deal_result_fits_varchar2_200_byte(): @@ -175,12 +175,10 @@ def test_system_fault_uses_per_mall_errors(): mall_errors={"药帮忙": "LOGIN_FAILED:timeout"}, ), ) - assert result.deal_flag == 2 + assert result.deal_flag == 0 + assert result.cart_rows == [] assert result.deal_result.startswith("系统故障") - ybm = next(row for row in result.cart_rows if row["platform_type"] == 48) - yaoex = next(row for row in result.cart_rows if row["platform_type"] == 66) - assert "LOGIN_FAILED:timeout" in (ybm["fail_reason"] or "") - assert "对端平台不可用" in (yaoex["fail_reason"] or "") + assert "LOGIN_FAILED:timeout" in result.deal_result def test_failure_total_is_price_times_plan_qty(): diff --git a/rpa_selection/tests/test_pipeline.py b/rpa_selection/tests/test_pipeline.py index c1ce219..6d1b02f 100644 --- a/rpa_selection/tests/test_pipeline.py +++ b/rpa_selection/tests/test_pipeline.py @@ -1,5 +1,6 @@ from __future__ import annotations +from persist import build_result from pipeline import MallOutcome, run_selection from rules import Candidate, PurchaseLine, normalize_vendor @@ -181,5 +182,36 @@ def test_short_stock_with_rejects_is_stock_insufficient(): ], ) assert result.kind == "BUSINESS_FAIL" - assert result.reason == "STOCK_INSUFFICIENT" + assert result.reason == "STOCK_INSUFFICIENT;EXPIRY_LT_12M;NOT_SELF_WAREHOUSE" assert result.allocations == [] + + +# 毛利和效期同时命中时,原因里两句都在,不再只留优先级更高的一条。 +def test_margin_and_expiry_both_in_reason(): + result = run_selection( + _purchase(), + [ + MallOutcome( + "1号药城", + candidates=[ + _candidate( + sku_id="MARGIN", + vendor_raw=HANGZHOU, + price=99.0, + stock=100.0, + ), + _candidate( + sku_id="EXP", + vendor_raw=GUANGDONG, + price=90.0, + expiry_raw="6", + expiry_months=6.0, + stock=100.0, + ), + ], + ) + ], + ) + built = build_result(_purchase(), result) + assert "毛利不大于 4%" in built.deal_result + assert "效期不足 12 个月" in built.deal_result diff --git a/rpa_selection/tests/test_rules.py b/rpa_selection/tests/test_rules.py index b46a90d..bcb1626 100644 --- a/rpa_selection/tests/test_rules.py +++ b/rpa_selection/tests/test_rules.py @@ -81,8 +81,19 @@ def test_warehouse_order_is_four_tier_mixed_malls(): ] +# 正好 4% 拒绝。96/100 与 99.84/104 在 Decimal 下都是 0.04。 def test_margin_exactly_4_percent_passes(): result = _alloc([_candidate(price=96.0)]) + assert result.allocations == [] + assert result.rejected[0].reason == "MARGIN_LT_4PCT" + + result = _alloc([_candidate(price=99.84)], _purchase(sale_price=104.0)) + assert result.allocations == [] + assert result.rejected[0].reason == "MARGIN_LT_4PCT" + + +def test_margin_just_over_4_percent_passes(): + result = _alloc([_candidate(price=95.99)]) assert result.allocations assert result.allocations[0].qty == 10 @@ -160,7 +171,8 @@ def test_total_stock_short_returns_no_allocation(): assert result.passed_before_fold -def test_same_warehouse_keeps_cheaper_sku_only(): +# 同仓两个 sku 都参与拆量。低价先吃,不够再吃同仓另一条,不把余量让给下一仓。 +def test_same_warehouse_keeps_both_skus(): result = _alloc( [ _candidate(sku_id="CHEAP", vendor_raw=HANGZHOU, mall_name="药帮忙", price=90.0, stock=5.0), @@ -168,10 +180,8 @@ def test_same_warehouse_keeps_cheaper_sku_only(): _candidate(sku_id="GD", vendor_raw=GUANGDONG, price=94.0, stock=20.0), ] ) - assert _qtys(result) == [("CHEAP", 5), ("GD", 5)] - dominated = [item for item in result.rejected if item.reason == "SAME_WAREHOUSE_NOT_BEST"] - assert len(dominated) == 1 - assert dominated[0].candidate.sku_id == "DEAR" + assert _qtys(result) == [("CHEAP", 5), ("DEAR", 5)] + assert not any(item.reason == "SAME_WAREHOUSE_NOT_BEST" for item in result.rejected) def test_stock_fraction_floors_to_int(): @@ -250,4 +260,5 @@ def test_validate_purchase_reasons(): assert validate_purchase(_purchase(sale_price=0)) == "NO_SALE_PRICE" assert validate_purchase(_purchase(qty=None)) == "NO_QTY" assert validate_purchase(_purchase(qty=0)) == "NO_QTY" - assert validate_purchase(_purchase()) is None + assert validate_purchase(_purchase(bar_code="")) == "NO_BAR_CODE" + assert validate_purchase(_purchase(bar_code="06901234567890")) is None diff --git a/rpa_selection/util.py b/rpa_selection/util.py index 01f446d..505fa31 100644 --- a/rpa_selection/util.py +++ b/rpa_selection/util.py @@ -1,14 +1,18 @@ +# 选品用的文本和数字清洗。空值和认不出的数字收成空或 None,不抛。 + from __future__ import annotations from typing import Any +# 收成去掉两端空白的字符串。None 当空。 def normalize_text(value: Any) -> str: if value is None: return "" return str(value).strip() +# 把文本收成整数。空或认不出返回 None。先按浮点再取整,兼容 "10.0"。 def parse_int_or_none(value: Any) -> int | None: text = normalize_text(value) if text == "": @@ -19,6 +23,7 @@ def parse_int_or_none(value: Any) -> int | None: return None +# 把文本收成浮点。空或认不出返回 None。逗号当千分位去掉后再试一次。 def parse_float_or_none(value: Any) -> float | None: text = normalize_text(value) if text == "":