From ac19e60eb50fe428ba982c14256742b7ac244a7a Mon Sep 17 00:00:00 2001 From: 34BFF76918A9449ABAB323149DDCEA73 Date: Tue, 13 Oct 2026 17:29:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=BF=94=E5=88=A9=E7=8F=AD=E9=80=9A?= =?UTF-8?q?=E7=94=A8=E5=BD=95=E5=85=A5=E6=B5=81=E7=A8=8B=EF=BC=88=E6=80=BB?= =?UTF-8?q?=E5=8D=95=E3=80=81=E7=BB=86=E5=8D=95=E6=96=B0=E5=A2=9E=E3=80=81?= =?UTF-8?q?=E9=80=81=E5=AE=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 查号后同一班补总单、按货品 ID 新增细单、送审写表 4。细单不做 Excel 导入。 承付参数总单和细单都填 25。同一合同一组一个结果。 合同货品弹窗按操作码搜,找不到则失败。 --- AGENTS.md | 23 +- README.md | 2 +- rpa_contract/contract_flow_steps.py | 54 +- rpa_rebate/.env.example | 4 + rpa_rebate/README.md | 36 +- rpa_rebate/apa_browser_adapter.py | 383 ++- rpa_rebate/job.py | 78 +- rpa_rebate/persist.py | 34 +- rpa_rebate/pyproject.toml | 1 + rpa_rebate/rebate_flow_steps.py | 4851 ++++++++++++++++++++++++++- rpa_rebate/repo.py | 44 + rpa_rebate/run_batch.py | 73 + rpa_rebate/run_header.py | 26 + rpa_rebate/run_import.py | 28 + rpa_rebate/run_submit.py | 27 + rpa_rebate/tests/test_finder.py | 15 +- rpa_rebate/tests/test_header.py | 295 ++ rpa_rebate/tests/test_import.py | 325 ++ rpa_rebate/tests/test_job.py | 65 +- rpa_rebate/tests/test_submit.py | 138 + rpa_rebate/uv.lock | 23 + rpa_rebate/现行模式.md | 71 + rpa_rebate/返利流程(1).md | 6 + rpa_rebate/返利流程.md | 168 + 24 files changed, 6620 insertions(+), 150 deletions(-) create mode 100644 rpa_rebate/run_batch.py create mode 100644 rpa_rebate/run_header.py create mode 100644 rpa_rebate/run_import.py create mode 100644 rpa_rebate/run_submit.py create mode 100644 rpa_rebate/tests/test_header.py create mode 100644 rpa_rebate/tests/test_import.py create mode 100644 rpa_rebate/tests/test_submit.py create mode 100644 rpa_rebate/现行模式.md create mode 100644 rpa_rebate/返利流程(1).md create mode 100644 rpa_rebate/返利流程.md diff --git a/AGENTS.md b/AGENTS.md index ffb4ff5..d6e2572 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,9 @@ | 支付回传 | `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_oms/README.md`、根 `README.md` | — | | 节点业务规则(点名一节) | `docs/RPA订单中心接口开发口径_20260930.md` | 禁止通读 `docs/` | @@ -126,29 +129,30 @@ ## 返利班落点 -班次作业在 `rpa_rebate/`,不在接口 3 请求里跑。领表 4 `action_code=1 AND contract_status=1`(合同班起草、尚未回成功),以及表 3 `contract_entry_status=0` 且已有 `contractNo` 的只录协议行。按合同号查返利中心;搜到再回 `contractStatus=2`、录协议写 `actionCode=2`。搜不到不重录合同,留给下一班。查号走页面;录协议仍用替身。接口 4 组包在 oms,path 空,不发 HTTP。 +班次作业在 `rpa_rebate/`,不在接口 3 请求里跑。领表 4 `action_code=1 AND contract_status=1`(合同班起草、尚未回成功),以及表 3 `contract_entry_status=0` 且已有 `contractNo` 的只录协议行。按合同号查返利中心;搜到后同一班走总单 → 细单新增 → 送审,送审成功才插 `actionCode=2`、`agreementStatus=1` 并带上号。搜不到不重录合同,留给下一班。接口 4 组包在 oms,path 空,不发 HTTP。同一合同一组一个结果。 | 步 | 函数 | 要点 | | --- | --- | --- | | 领取 | `claim_pending` | 表 4 盖 `查号中`;表 3 `process_status` 0→3。崩溃停戳,改回 `合同班` / `0` 再跑 | | 查号 | `RebateFinder` / `search_contract` | 打开 `REBATE_URL` → 登录 → 采购协议 → 按合同号搜;表里出现该号才 `True`,输入框回显不算 | -| 落表 | `build_found` + `save_group` | 起草行 `contract_status=2`;每 `oms_id` 插 `action_code=2`、`agreement_status=1`;`push_status=0` | +| 录协议 | `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`;不插协议行 | -验收:`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`。接口 4:`cd rpa_oms` → `uv run pytest tests/test_contract_agreement_outbound.py::test_assembles_unpushed_rows_as_document_envelope -q`。 +验收:`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`,不碰选品 / 加购 / 合同页面。 ## 返利中心及之后 -查号页面已接 `REBATE_FIND`。录协议班次骨架仍是替身。三把钥匙:`contractSystemNo` → `rebateOrderNo` → `receiptNoticeNo`。开页面时按合同同款:一个节点一份 `*_flow_steps.py`,验收只看 ACCEPT / 点名用例。 +查号页面已接 `REBATE_FIND`。总单页面已接 `REBATE_HEADER`。细单新增已接 `REBATE_IMPORT`(节点名仍叫 IMPORT,页面走新增,不做 Excel)。送审已接 `REBATE_SUBMIT`。班次串查号 → 总单 → 细单新增 → 送审,送审成功才写 `actionCode=2`。三把钥匙:`contractSystemNo` → `rebateOrderNo` → `receiptNoticeNo`。开页面时按合同同款:一个节点一份 `*_flow_steps.py`,验收只看 ACCEPT / 点名用例。 | 节点 | 谁做 | 要回传 | 页面要做什么 | | --- | --- | --- | --- | | `REBATE_FIND` | RPA | 搜到 / 搜不到 | 已拆:登录 → 采购协议 → 按合同号搜 | -| `REBATE_HEADER` | RPA | `rebateOrderNo` | 尚未拆:填必填 → 保存拿总单号 | -| `REBATE_IMPORT` | RPA | 成功/失败行 | 下模板 → 按字段填 Excel → 上传 → 读导入结果 | -| `REBATE_SUBMIT` | RPA | `submitted` | 点提交送审 | +| `REBATE_HEADER` | RPA | `rebateOrderNo` | 已拆:搜到后填映射表默认值 → 保存拿总单号 | +| `REBATE_IMPORT` | RPA | 货品表行数 | 已拆:勾总单 → 货品「新增」→ 货品编码三个点按 `goodsId` 搜 → 卡片确认落到表 → 货品工具栏保存。细单备注=订单号,特定补偿=0,承付参数=25。不做 Excel | +| `REBATE_SUBMIT` | RPA | `submitted` + 总单/细单号 | 已拆:点送审;马上能见号则带回 | | `M5_CREATE_ORDER` | RPA 桌面端 | `m5OrderNo` | 录单后**停、不保存**。`M5_QC_MISSING` 必须识别准(直接转人工、不重试) | | `M5_MANUAL_CONFIRM` | 采购 | — | 人在 M5 里保存签订 | | `M5_RECEIPT_NOTICE` | 采购 | `receiptNoticeNo` | 人填收货通知单号,全链终点钥匙 | @@ -165,6 +169,9 @@ - 合同班:点名 `rpa_contract/tests/test_job.py::test_job_merges_same_supplier_and_writes_table4` 或 `test_job_marks_diligence_fail`。 - 返利班:点名 `rpa_rebate/tests/test_job.py::test_job_finds_contract_and_writes_agreement` 或 `test_job_leaves_not_found_for_next_shift`。 - 返利查号页:只读控制台 `ACCEPT SUMMARY REBATE_FIND` 或 `rpa_rebate/screenshots/REBATE_FIND/ACCEPT.txt`。 +- 返利总单页:只读控制台 `ACCEPT SUMMARY REBATE_HEADER` 或 `rpa_rebate/screenshots/REBATE_HEADER/ACCEPT.txt`;点名 `rpa_rebate/tests/test_header.py::test_header_flow_writes_accept_with_order_no`。 +- 返利细单新增:只读控制台 `ACCEPT SUMMARY REBATE_IMPORT` 或 `rpa_rebate/screenshots/REBATE_IMPORT/ACCEPT.txt`;点名 `rpa_rebate/tests/test_import.py::test_import_flow_writes_accept_with_success_fail`。 +- 返利送审:只读控制台 `ACCEPT SUMMARY REBATE_SUBMIT` 或 `rpa_rebate/screenshots/REBATE_SUBMIT/ACCEPT.txt`;点名 `rpa_rebate/tests/test_submit.py::test_submit_flow_writes_accept_with_order_nos`。 - 接口4:点名 `rpa_oms/tests/test_contract_agreement_outbound.py::test_assembles_unpushed_rows_as_document_envelope`。 - 合同页:只读控制台 `ACCEPT` 或 `rpa_contract/screenshots/` 下 `ACCEPT.txt`。 - 单测只跑点名用例。禁止跑整份带页面等待的套件当回归。 diff --git a/README.md b/README.md index cced2a7..6873a91 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ | `rpa_selection/` | 选品班。读表 1 `deal_flag=0`,双平台选品,写表 2 | | `rpa_cart/` | 加购(未做)+ 1 号药城支付落表。表 2 → 口 2 仍未推 | | `rpa_contract/` | 合同班。表 3 合单录入 → 表 4;接口 4 组包在 oms,path 空 | -| `rpa_rebate/` | 返利班。表 4 查号 / 表 3 只录协议 → 表 4;查号走页面,录协议仍用替身 | +| `rpa_rebate/` | 返利班。表 4 查号 / 表 3 只录协议 → 表 4;查号 → 总单 → 细单新增 → 送审同一班 | | 包 | 读 | 写 | 口 | | --- | --- | --- | --- | diff --git a/rpa_contract/contract_flow_steps.py b/rpa_contract/contract_flow_steps.py index 56f91ca..af15026 100644 --- a/rpa_contract/contract_flow_steps.py +++ b/rpa_contract/contract_flow_steps.py @@ -343,7 +343,11 @@ def login_contract_system(browser: BrowserAdapter, ctx: ContractFlowContext) -> def enter_contract_create(browser: BrowserAdapter) -> None: # 登录后直接导航到起草表单页,跳过模板选择(前端委托事件 Selenium 点击无效) - browser.open_url(FORM_PAGE_URL) + reopen = getattr(browser, "reopen_url", None) + if callable(reopen): + reopen(FORM_PAGE_URL) + else: + browser.open_url(FORM_PAGE_URL) browser.wait_for_text("客户编码", timeout_seconds=20) on_form = "客户编码" in (browser.read_body_text() or "") _ACCEPT.check( @@ -612,6 +616,13 @@ def fill_contract_goods_lines(browser: BrowserAdapter, ctx: ContractFlowContext) used.append(row) assigned.append(row) _delete_empty_goods_rows(browser) + used = [] + assigned = [] + for line in ctx.goods_lines: + row = _find_goods_row(browser, line, skip=used) + assigned.append(row) + if row >= 0: + used.append(row) evidence_shot(browser, "contract-goods-lines-filled") for index, (line, row) in enumerate(zip(ctx.goods_lines, assigned, strict=True)): opcode = _try_read(browser, [_goods_selector("goods_code", row)]) if row >= 0 else "" @@ -639,6 +650,27 @@ def _goods_search_keyword(line: ContractGoodsLine) -> str: return (line.mdm_code or line.goods_code or line.goods_name or "").strip() +def _looks_like_goods_opcode(keyword: str) -> bool: + compact = (keyword or "").replace(" ", "").upper() + return compact.startswith(("FQX", "QX")) and any(ch.isdigit() for ch in compact) + + +def _picker_rows_dump(browser: BrowserAdapter) -> str: + dump = browser.execute_script( + """ + return Array.from(document.querySelectorAll('tr')).map((row) => { + const text = (row.innerText || '').replace(/\\s+/g, ' ').trim(); + const box = row.querySelector( + 'input[name="List_Selected"], input[type="checkbox"], input[type="radio"]' + ); + if (!box || !text) return ''; + return text.slice(0, 80); + }).filter(Boolean).slice(0, 8).join('|'); + """ + ) + return str(dump or "") + + def _pick_goods_from_dialog( browser: BrowserAdapter, line: ContractGoodsLine, skip: list[int] | tuple[int, ...] = () ) -> int: @@ -667,14 +699,17 @@ def _select_goods_in_picker(browser: BrowserAdapter, line: ContractGoodsLine, ke browser.switch_to_frame(GOODS_PICKER["frame"]) try: _search_goods_in_dialog(browser, keyword) - if not _dialog_row_has_keyword(browser, keyword) and line.goods_name: - _search_goods_in_dialog(browser, line.goods_name.strip()) + if not _dialog_row_has_keyword(browser, keyword): + _search_goods_in_dialog(browser, keyword) picked = _select_dialog_row_by_keyword(browser, keyword) - if not picked and line.goods_name: - picked = _select_dialog_row_by_keyword(browser, line.goods_name.strip()) + if not picked and line.goods_name and not _looks_like_goods_opcode(keyword): + _search_goods_in_dialog(browser, line.goods_name.strip()) + picked = _select_dialog_row_by_keyword(browser, keyword) + if not picked: + picked = _select_dialog_row_by_keyword(browser, line.goods_name.strip()) if not picked: - browser.wait_for_element(GOODS_PICKER["result_checkbox"], timeout_seconds=10) - browser.click_first(GOODS_PICKER["result_checkbox"]) + dump = _picker_rows_dump(browser) + raise NodeError(f"选择商品失败,未找到「{keyword}」 dump={dump}") time.sleep(0.5) evidence_shot(browser, "contract-goods-picker") except Exception as exc: @@ -848,7 +883,7 @@ def _find_goods_row( }); for (let i = 0; i < 30; i++) { if (skip[i]) continue; - const fields = ['fd_opcode', 'fd_goodsname', 'fd_mdmCode', 'fd_registdocno']; + const fields = ['fd_opcode', 'fd_mdmCode']; const values = fields.map((suffix) => { const el = document.querySelector( 'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.' + suffix + ')"]' @@ -856,9 +891,8 @@ def _find_goods_row( return el ? String(el.value || '').trim() : ''; }).filter(Boolean); if (!values.length) continue; - const joined = values.join(' '); for (const keyword of keys) { - if (values.some((v) => v === keyword || v.indexOf(keyword) >= 0) || joined.indexOf(keyword) >= 0) { + if (values.some((v) => v === keyword)) { return i; } } diff --git a/rpa_rebate/.env.example b/rpa_rebate/.env.example index 7fd8cca..f2ecb9d 100644 --- a/rpa_rebate/.env.example +++ b/rpa_rebate/.env.example @@ -3,3 +3,7 @@ REBATE_USERNAME= REBATE_PASSWORD= # 页面 ACCEPT 要搜的合同号;空则用哨兵号验收「搜不到」 REBATE_ACCEPT_CONTRACT_NO= +# 总单必填,计划员用订单中心的,不要填登录人 +REBATE_PLANNER_NAME= +REBATE_SUPPLIER_CODE= +REBATE_UNIT_CODE= diff --git a/rpa_rebate/README.md b/rpa_rebate/README.md index 91dc291..b840aa4 100644 --- a/rpa_rebate/README.md +++ b/rpa_rebate/README.md @@ -1,11 +1,11 @@ # rpa_rebate -返利班。领表 4 起草行、表 3 只录协议行,按合同号查返利中心。搜到再录协议、写表 4。查号走页面;录协议仍用替身。接口 4 组包在 `rpa_oms`,本班不推、不改 `push_status`。 +返利班。领表 4 起草行、表 3 只录协议行,按合同号查返利中心。搜到后同一班走总单 → 细单新增 → 送审;送审成功才写表 4。接口 4 组包在 `rpa_oms`,本班不推、不改 `push_status`。细单不做 Excel 导入。现行字段见 `现行模式.md`、`返利流程.md`。 | | | | --- | --- | | 读 | 表 4 `action_code=1 AND contract_status=1`(已有合同号、尚未回成功);表 3 `contract_entry_status=0` 且已有 `contractNo` | -| 写 | 表 4:搜到则起草行 `contractStatus=2`;录协议后插 `actionCode=2`、`agreementStatus=1` | +| 写 | 表 4:搜到则起草行 `contractStatus=2`;送审成功插 `actionCode=2`、`agreementStatus=1`,带总单/细单号 | | 口 | 口 4(组包在 oms,path 空) | 与合同同一天两班,建议合同班跑完再跑。查号挂在本班,不单开查状态任务。搜不到不重录合同,戳改回 `合同班`,留给下一班再搜。 @@ -21,6 +21,9 @@ cd rpa_rebate uv sync --python 3.12 uv run pytest tests/test_job.py::test_job_finds_contract_and_writes_agreement -q uv run pytest tests/test_finder.py::test_find_flow_writes_accept_for_missing_number -q +uv run pytest tests/test_header.py::test_header_flow_writes_accept_with_order_no -q +uv run pytest tests/test_import.py::test_import_flow_writes_accept_with_success_fail -q +uv run pytest tests/test_submit.py::test_submit_flow_writes_accept_with_order_nos -q ``` 页面查号(不开录协议、不连 Oracle)。账号放本机 `.env`: @@ -32,6 +35,33 @@ uv run python run_find.py 验收看控制台 `ACCEPT SUMMARY REBATE_FIND` 或 `screenshots/REBATE_FIND/ACCEPT.txt`。默认搜哨兵号,期望 `found=no`。要搜真号:`REBATE_ACCEPT_CONTRACT_NO=合同号`。 +页面总单(复用查号已登录 Chrome,不送审、不连 Oracle): + +```bash +cd rpa_rebate +uv run python run_header.py +``` + +验收看控制台 `ACCEPT SUMMARY REBATE_HEADER` 或 `screenshots/REBATE_HEADER/ACCEPT.txt`。需要 `REBATE_ACCEPT_CONTRACT_NO`。优先表 3:供应商 MDM、货品行。计划员「廖建琦」,联系人「苏文辉」,承付「下月N号结」,承付参数 `25`,结算「电汇」,协议开始/结束当天。卡片仍在「编辑」时点总单工具栏保存。 + +页面细单新增(复用查号已登录 Chrome,不送审、不写 `actionCode=2`、不连 Oracle,不做 Excel): + +```bash +cd rpa_rebate +uv run python run_import.py +``` + +验收看控制台 `ACCEPT SUMMARY REBATE_IMPORT` 或 `screenshots/REBATE_IMPORT/ACCEPT.txt`,须货品表有行。需要 `REBATE_ACCEPT_CONTRACT_NO`。表 3 有货品则按行新增;空缺再用 `REBATE_GOODS_ID` / `REBATE_QTY` / `REBATE_TAX_PRICE` / `REBATE_ORDER_NO`(或 `REBATE_IMPORT_LINES` JSON,字段 `goodsId`)。货品编码三个点按货品ID搜。细单备注=订单号,特定补偿=0,承付参数=25。不要用操作码或 MDM 码去搜。 + +页面送审(复用查号已登录 Chrome,不写 `actionCode=2`、不连 Oracle、不推口 4): + +```bash +cd rpa_rebate +uv run python run_submit.py +``` + +验收看控制台 `ACCEPT SUMMARY REBATE_SUBMIT` 或 `screenshots/REBATE_SUBMIT/ACCEPT.txt`,须带总单号/细单号。需要 `REBATE_ACCEPT_CONTRACT_NO`。点完马上能看见号才回成功。 + 接口 4 组包(`rpa_oms`,不发 HTTP): ```bash @@ -41,4 +71,4 @@ uv run pytest tests/test_contract_agreement_outbound.py::test_assembles_unpushed ## 本期不做 -录协议(总单 / 导入 / 送审)、映射表默认值、向订单中心发 HTTP、改 `push_status`。 +向订单中心发 HTTP、改 `push_status`。 diff --git a/rpa_rebate/apa_browser_adapter.py b/rpa_rebate/apa_browser_adapter.py index 5a42c94..96b300a 100644 --- a/rpa_rebate/apa_browser_adapter.py +++ b/rpa_rebate/apa_browser_adapter.py @@ -5,11 +5,44 @@ Selenium Chrome 浏览器适配。通过 WebDriver 操控 Chrome。 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 = { + "REBATE_FIND": 9335, + "default": 9335, +} +_USER_CHROME_PORT = 9222 + + +def use_user_chrome() -> bool: + raw = (os.getenv("RPA_USE_USER_CHROME") or "").strip().lower() + return raw in {"1", "true", "yes", "on"} + + +def user_chrome_real_profile_dir() -> Path: + return (Path(os.environ.get("LOCALAPPDATA", "")) / "Google" / "Chrome" / "User Data").resolve() + + +def user_chrome_profile_dir() -> Path: + """Chrome 禁止在默认 User Data 上开调试口,用仓库外 junction 指向同一目录。不要 resolve,否则又变回默认路径。""" + link = REPO_ROOT.parent / "rpa-chrome-profile" / "daily-chrome" + real = user_chrome_real_profile_dir() + if not link.exists(): + link.parent.mkdir(parents=True, exist_ok=True) + subprocess.run( + ["cmd.exe", "/c", "mklink", "/J", str(link), str(real)], + check=False, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return link + def _is_inside_repo(path: Path, repo_root: Path) -> bool: try: @@ -19,6 +52,14 @@ def _is_inside_repo(path: Path, repo_root: Path) -> bool: return True +def chrome_session_flow(flow: str) -> str: + """REBATE_HEADER 复用查号已登录的 Chrome。""" + name = (flow or "default").strip() or "default" + if name.startswith("REBATE"): + return "REBATE_FIND" + return name + + 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() @@ -33,6 +74,199 @@ def resolve_chrome_profile_base(requested: str | None = None, *, repo_root: Path return outside +def resolve_chrome_profile_dir( + flow: str = "default", + requested: str | None = None, + *, + repo_root: Path | None = None, +) -> Path: + """Stable profile per Chrome session so later rebate nodes reuse login.""" + if use_user_chrome(): + return user_chrome_profile_dir() + base = resolve_chrome_profile_base(requested, repo_root=repo_root) + session = chrome_session_flow(flow) + safe = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in session) + 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 + if use_user_chrome(): + return _USER_CHROME_PORT + session = chrome_session_flow(flow) + return _FLOW_DEBUG_PORTS.get(session, 9335) + + +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 _chrome_binary() -> str: + raw = (os.getenv("CHROME_PATH") or "").strip() + candidates = [ + raw, + r"C:\Program Files\Google\Chrome\Application\chrome.exe", + r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", + str(Path(os.environ.get("LOCALAPPDATA", "")) / "Google" / "Chrome" / "Application" / "chrome.exe"), + ] + for path in candidates: + if path and os.path.isfile(path): + return path + raise RuntimeError("未找到 chrome.exe") + + +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 _wait_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 + + +def _spawn_chrome(profile_dir: Path, port: int, *, restore_session: bool = False) -> None: + """独立拉起 Chrome,不跟 Python / chromedriver 一起退出。""" + chrome = _chrome_binary() + flags = 0x00000008 | 0x00000200 | 0x01000000 + 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") + subprocess.Popen( + cmd, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + close_fds=False, + creationflags=flags, + ) + + +def _kill_chrome() -> None: + 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, + ) + + +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) + + +def _launch_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) + time.sleep(0.8) + _spawn_chrome(profile_dir, port, restore_session=restore_session) + if not _wait_port(port): + raise RuntimeError(f"Chrome 未在端口 {port} 起来") + return _attach_chrome(service, port) + + +def _focus_agreement_tab(driver) -> bool: + try: + handles = list(driver.window_handles) + except Exception: + return False + for handle in handles: + try: + driver.switch_to.window(handle) + href = str(driver.current_url or "") + title = str(driver.title or "") + except Exception: + continue + if "procurementAgree" in href or "采购协议" in title: + return True + return False + + +_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; +""" + + class ApaBrowserAdapter: """Selenium Chrome 浏览器适配器,实现 BrowserAdapter 协议""" @@ -41,56 +275,70 @@ class ApaBrowserAdapter: 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 def _get_driver(self): - """创建或获取 Chrome WebDriver""" + """复用查号已打开的 Chrome;没有就按稳定 profile 新开。""" if self._driver is not None: return self._driver - from selenium import webdriver - from selenium.webdriver.chrome.service import Service - from selenium.webdriver.chrome.options import Options - - profile_base = resolve_chrome_profile_base() - if _is_inside_repo(profile_base, REPO_ROOT): - raise RuntimeError(f"拒绝在仓库内创建 Chrome 用户目录: {profile_base}") - # 每次进程单独目录:上次 detach 留下的 Chrome 会锁住同一 profile - profile_dir = profile_base / str(os.getpid()) + 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) - profile_dir = str(profile_dir) + port = chrome_debug_port(self._flow) + if not use_user_chrome(): + port = _read_devtools_port(profile_dir) or port - 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") - # 独立目录,避免和日常 Chrome 抢用户数据目录导致卡住 - options.add_argument("--user-data-dir=" + profile_dir) + 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, + restore_session=use_user_chrome(), + ) + 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 - chromedriver = os.getenv("CHROMEDRIVER_PATH") - if chromedriver and os.path.exists(chromedriver): - service = Service(chromedriver) - else: - from webdriver_manager.chrome import ChromeDriverManager - service = Service(ChromeDriverManager().install()) - self._driver = webdriver.Chrome(service=service, options=options) - self._driver.set_page_load_timeout(45) - self._driver.set_script_timeout(30) + 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: + 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 use_user_chrome() and _focus_agreement_tab(driver): + print("Chrome 切到已打开的采购协议页", flush=True) + elif self._url and blank: driver.get(self._url) self._is_open = True @@ -205,6 +453,7 @@ class ApaBrowserAdapter: 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 @@ -226,6 +475,7 @@ class ApaBrowserAdapter: 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 @@ -334,9 +584,25 @@ class ApaBrowserAdapter: self._ensure_open() self._driver.switch_to.default_content() + def _ignore_source_bug(self) -> None: + """货源选择是 bug 弹层,看见就关,不参与任何选择。""" + 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 + def execute_script(self, script: str, *args): self._ensure_open() - return self._driver.execute_script(script, *args) + result = self._driver.execute_script(script, *args) + self._ignore_source_bug() + return result def select_xform(self, name: str, value: str) -> None: """选中 Landray xform fancy-select(.fs-wrap / .fs-option)。""" @@ -472,6 +738,47 @@ class ApaBrowserAdapter: 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 diff --git a/rpa_rebate/job.py b/rpa_rebate/job.py index c991120..1ff850d 100644 --- a/rpa_rebate/job.py +++ b/rpa_rebate/job.py @@ -1,4 +1,4 @@ -"""返利班次:领表 4 起草行 / 表 3 只录协议行 → 查号 → 录协议替身 → 写表 4。""" +"""返利班次:领表 4 起草行 / 表 3 只录协议行 → runner 查号+总单+细单+送审 → 写表 4。""" from __future__ import annotations from collections import OrderedDict @@ -7,6 +7,8 @@ from typing import Callable from persist import ( KIND_FAIL, + KIND_NOT_FOUND, + KIND_OK, AgreementOutcome, RebateGroup, agreement_ok, @@ -15,8 +17,10 @@ from persist import ( ) from repo import Claimed -Finder = Callable[[str], bool] -Recorder = Callable[[str], AgreementOutcome] +Runner = Callable[[RebateGroup], AgreementOutcome] +Header = Callable[[str], str] +Importer = Callable[[str], object] +Submitter = Callable[[str], object] @dataclass @@ -28,11 +32,41 @@ class JobSummary: failed: int = 0 +def recorder( + contract_no: str, + header: Header, + importer: Importer, + submitter: Submitter, +) -> AgreementOutcome: + try: + order_no = (header(contract_no) or "").strip() + except Exception as exc: + return AgreementOutcome(KIND_FAIL, None, None, str(exc) or "总单失败") + 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 "细单导入失败") + 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 "送审失败") + 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, "送审未读到总单号") + agreement_dtl_id = (getattr(submitted, "agreement_dtl_id", None) or "").strip() or None + return AgreementOutcome(KIND_OK, agreement_id, agreement_dtl_id, None) + + def run_batch( repo, *, - finder: Finder, - recorder: Recorder, + runner: Runner, limit: int | None = None, ) -> JobSummary: claimed = repo.claim_pending(limit=limit) @@ -43,21 +77,15 @@ def run_batch( ) for group in groups: try: - found = bool(finder(group.contract_no)) - except Exception: - repo.save_group(build_not_found(group)) - summary.not_found += 1 - continue - if not found: - repo.save_group(build_not_found(group)) - summary.not_found += 1 - continue - try: - outcome = recorder(group.contract_no) + outcome = runner(group) except Exception as exc: outcome = AgreementOutcome(KIND_FAIL, None, None, str(exc)) if outcome is None: outcome = AgreementOutcome(KIND_FAIL, None, None, "协议录入失败") + 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 @@ -80,3 +108,21 @@ def group_claimed(claimed: Claimed) -> list[RebateGroup]: for row in claimed.protocols: bucket(row["contract_no"]).protocols.append(row) return list(groups.values()) + + +def _import_ok(result: object) -> bool: + if result is None: + return False + success = int(getattr(result, "success_count", 0) or 0) + fail = int(getattr(result, "fail_count", 0) or 0) + parsed = getattr(result, "parsed", True) + return bool(parsed) and success > 0 and fail == 0 + + +def _import_reason(result: object) -> str: + if result is None: + return "细单导入失败" + reasons = getattr(result, "fail_reasons", ()) or () + if reasons: + return str(reasons[0]).strip() or "细单导入失败" + return "细单导入失败" diff --git a/rpa_rebate/persist.py b/rpa_rebate/persist.py index 6953f57..7dd455b 100644 --- a/rpa_rebate/persist.py +++ b/rpa_rebate/persist.py @@ -1,15 +1,15 @@ -"""返利班结果:查号 / 录协议 → 表 4 更新与插入、表 3 状态。""" +"""返利班结果:查号 / 送审 → 表 4 更新与插入、表 3 状态。""" from __future__ import annotations from dataclasses import dataclass, field KIND_OK = "ok" KIND_FAIL = "fail" +KIND_NOT_FOUND = "not_found" STAMP_CONTRACT = "合同班" STAMP_CLAIMING = "查号中" STAMP_DONE = "返利班" PROCESS_RESULT_MAX = 1000 -FAIL_REASON_MAX = 500 @dataclass(frozen=True) @@ -75,12 +75,12 @@ def build_found(group: RebateGroup, outcome: AgreementOutcome) -> GroupSave: seen_protocol.setdefault(int(row["oms_id"]), row) for row in seen_protocol.values(): inserts.append(_action1_success(row, number)) - seen_oms: dict[int, dict] = {} - for row in group.drafts + group.protocols: - seen_oms.setdefault(int(row["oms_id"]), row) - for row in seen_oms.values(): - inserts.append(_action2(row, number, outcome, ok)) if ok: + seen_oms: dict[int, dict] = {} + for row in group.drafts + group.protocols: + seen_oms.setdefault(int(row["oms_id"]), row) + for row in seen_oms.values(): + inserts.append(_action2(row, number, outcome)) process_status = 1 process_result = "协议已录入" else: @@ -122,27 +122,17 @@ def _action1_success(row: dict, contract_no: str) -> dict: } -def _action2(row: dict, contract_no: str, outcome: AgreementOutcome, ok: bool) -> dict: - if ok: - status = 1 - fail_reason = None - agreement_id = (outcome.agreement_id or "").strip() or None - agreement_dtl_id = (outcome.agreement_dtl_id or "").strip() or None - else: - status = 2 - fail_reason = clip(outcome.reason or "协议录入失败", FAIL_REASON_MAX) - agreement_id = None - agreement_dtl_id = None +def _action2(row: dict, contract_no: str, outcome: AgreementOutcome) -> dict: return { "oms_id": int(row["oms_id"]), "action_code": 2, "contract_status": None, "contract_fail_reason": None, "contract_no": contract_no, - "agreement_status": status, - "agreement_fail_reason": fail_reason, - "agreement_id": agreement_id, - "agreement_dtl_id": agreement_dtl_id, + "agreement_status": 1, + "agreement_fail_reason": None, + "agreement_id": (outcome.agreement_id or "").strip() or None, + "agreement_dtl_id": (outcome.agreement_dtl_id or "").strip() or None, "push_status": 0, "org_code": row.get("org_code"), "create_user_id": 0, diff --git a/rpa_rebate/pyproject.toml b/rpa_rebate/pyproject.toml index c92f28d..c50c7e4 100644 --- a/rpa_rebate/pyproject.toml +++ b/rpa_rebate/pyproject.toml @@ -5,6 +5,7 @@ description = "洁达返利班。表 4 查号 / 表 3 只录协议 → 表 4。 readme = "README.md" requires-python = ">=3.12" dependencies = [ + "openpyxl>=3.1.5", "oracledb>=2.4.1", "selenium>=4.15.0", "webdriver-manager>=4.0.0", diff --git a/rpa_rebate/rebate_flow_steps.py b/rpa_rebate/rebate_flow_steps.py index ea1dbab..35ed1c5 100644 --- a/rpa_rebate/rebate_flow_steps.py +++ b/rpa_rebate/rebate_flow_steps.py @@ -1,20 +1,45 @@ -"""返利中心查号:打开、登录、进采购协议、按合同号搜。不录协议。""" +"""返利中心查号、总单、细单与送审:搜到后填总单;页面新增细单(按货品ID搜,不做 Excel 导入);点送审后马上能见号则带回总单/细单号。""" 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, evidence_shot -from errors import LoginFailed +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 = ("采购协议", "待审批", "待审批事项", "待办") +_PORTAL_MARKERS = ("采购协议", "待审批", "待审批事项", "待办", "菜单名称", "下载打印插件") EMPTY_MARKERS = ("暂无数据", "没有找到", "查询无结果", "无匹配数据", "未找到相关") LOGIN = { @@ -32,7 +57,8 @@ LOGIN = { 'input[type="password"]', ], "submit": [ - "text=登录", + "button.login-submit", + "button.el-button--primary", "button:contains(登录)", 'button[type="submit"]', 'input[type="submit"]', @@ -69,7 +95,7 @@ SEARCH = { _TABLE_SCRIPT = """ const nodes = Array.from(document.querySelectorAll( - 'table tbody, table, .el-table__body, .el-table, .ant-table-tbody, .ant-table' + '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'); """ @@ -85,6 +111,9 @@ class BrowserAdapter(Protocol): 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) @@ -94,6 +123,54 @@ class RebateFlowContext: 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: @@ -119,6 +196,16 @@ def read_result_table(browser: BrowserAdapter) -> tuple[str, bool]: 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) @@ -135,8 +222,26 @@ def login_rebate_center(browser: BrowserAdapter, ctx: RebateFlowContext) -> None raise LoginFailed("未配置 REBATE_USERNAME / REBATE_PASSWORD") browser.fill_first(LOGIN["user"], ctx.username) browser.fill_first(LOGIN["password"], ctx.password) - browser.click_first(LOGIN["submit"]) - deadline = time.time() + 20 + 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 @@ -148,32 +253,436 @@ def login_rebate_center(browser: BrowserAdapter, ctx: RebateFlowContext) -> None 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 "" - if "合同编号" in body or "合同号" in body: + 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 - browser.click_first(MENU["entry"]) - browser.wait_for_text("查询", timeout_seconds=15) + 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 - browser.fill_first(SEARCH["contract_no"], number) - browser.click_first(SEARCH["button"]) - deadline = time.time() + 15 - last_text = "" - last_from_table = False - while time.time() <= deadline: - 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) - empty = any(marker in (text or "") for marker in EMPTY_MARKERS) - if found or empty: - return found - time.sleep(0.4) - return classify_search_result(last_text, number, from_table=last_from_table) + _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( @@ -226,3 +735,4295 @@ def run_find_flow( 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", +) + +_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})" +) + +_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() + + diff --git a/rpa_rebate/repo.py b/rpa_rebate/repo.py index 4ca9bfc..4f17d72 100644 --- a/rpa_rebate/repo.py +++ b/rpa_rebate/repo.py @@ -95,6 +95,20 @@ class MemoryRebateRepo: 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 @@ -206,6 +220,22 @@ _TABLE4_BIND_KEYS = ( ) +_SELECT_CONTRACT_GOODS = """ +SELECT + fid, oms_id, supplier_id, supplier_name, supplier_mdm_code, + planner_id, planner_name, purchaser_name, org_code, + goods_id, qty, unit_price, goods_source_id, purchase_tax_rate, + contract_start_date, contract_end_date, payment_term +FROM rpa_purchase_contract_data +WHERE TRIM(contract_no) = :contract_no + OR oms_id IN ( + SELECT oms_id FROM rpa_contract_agreement_result + WHERE TRIM(contract_no) = :contract_no + ) +ORDER BY fid +""" + + def _records(cur) -> list[dict]: cols = [item[0].lower() for item in cur.description] return [dict(zip(cols, rec)) for rec in cur.fetchall()] @@ -240,6 +270,20 @@ class OracleRebateRepo: finally: conn.close() + 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) + except Exception as exc: + raise RepoUnavailable(str(exc)) from exc + finally: + conn.close() + def save_group(self, payload: GroupSave) -> None: conn = self._connect() try: diff --git a/rpa_rebate/run_batch.py b/rpa_rebate/run_batch.py new file mode 100644 index 0000000..5d1c1e8 --- /dev/null +++ b/rpa_rebate/run_batch.py @@ -0,0 +1,73 @@ +"""返利班次入口:领表 4 起草行 / 表 3 只录协议行,runner 查号 → 总单 → 细单 → 送审,写表 4。""" +from __future__ import annotations + +import argparse + +from apa_browser_adapter import ApaBrowserAdapter +from db import load_runtime_env +from job import recorder, run_batch +from persist import KIND_NOT_FOUND, AgreementOutcome, RebateGroup +from rebate_finder import RebateFinder, context_from_env +from rebate_flow_steps import ( + import_draft_for_contract, + run_header_flow, + run_import_flow, + run_submit_flow, +) +from repo import OracleRebateRepo + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="返利班次:查号 + 总单 + 细单 + 送审") + parser.add_argument("--limit", type=int, default=None, help="本班最多领取 N 条") + 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) + 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 header(number: str) -> str: + print(f"header contract_no={number}", flush=True) + return run_header_flow(browser, ctx, number) + + def importer(number: str): + print(f"import contract_no={number}", flush=True) + return run_import_flow(browser, ctx, number, import_draft_for_contract(number)) + + 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) + + +def main(argv: list[str] | None = None) -> int: + 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) + summary = run_batch( + repo, + runner=lambda group: production_runner(browser, ctx, finder, group), + limit=args.limit, + ) + print( + f"claimed={summary.claimed} groups={summary.groups} " + f"ok={summary.ok} not_found={summary.not_found} failed={summary.failed}", + flush=True, + ) + return 0 if summary.failed == 0 else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/rpa_rebate/run_header.py b/rpa_rebate/run_header.py new file mode 100644 index 0000000..543f549 --- /dev/null +++ b/rpa_rebate/run_header.py @@ -0,0 +1,26 @@ +"""页面总单入口。不连 Oracle、不导细单、不送审。验收只看 ACCEPT.txt。""" +from __future__ import annotations + +import os + +from apa_browser_adapter import ApaBrowserAdapter +from db import load_runtime_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() + contract_no = (os.getenv("REBATE_ACCEPT_CONTRACT_NO") or "").strip() + if not contract_no: + print("缺少 REBATE_ACCEPT_CONTRACT_NO,总单验收需要已搜到的合同号", flush=True) + return 1 + ctx = context_from_env() + draft = header_draft_for_contract(contract_no) + browser = ApaBrowserAdapter(url=ctx.rebate_url, flow="REBATE_HEADER") + order_no = run_header_flow(browser, ctx, contract_no, draft) + return 0 if order_no else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/rpa_rebate/run_import.py b/rpa_rebate/run_import.py new file mode 100644 index 0000000..b099e6f --- /dev/null +++ b/rpa_rebate/run_import.py @@ -0,0 +1,28 @@ +"""页面细单新增入口(按货品ID搜,不做 Excel)。不连 Oracle、不送审、不写 actionCode=2。验收只看 ACCEPT.txt。""" +from __future__ import annotations + +import os + +from apa_browser_adapter import ApaBrowserAdapter +from db import load_runtime_env +from rebate_finder import context_from_env +from rebate_flow_steps import import_draft_for_contract, run_import_flow + + +def main() -> int: + load_runtime_env() + contract_no = (os.getenv("REBATE_ACCEPT_CONTRACT_NO") or "").strip() + if not contract_no: + print("缺少 REBATE_ACCEPT_CONTRACT_NO,细单新增需要已搜到的合同号", flush=True) + return 1 + ctx = context_from_env() + draft = import_draft_for_contract(contract_no) + browser = ApaBrowserAdapter(url=ctx.rebate_url, flow="REBATE_IMPORT") + result = run_import_flow(browser, ctx, contract_no, draft) + if result.parsed and result.fail_count == 0 and result.success_count > 0: + return 0 + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/rpa_rebate/run_submit.py b/rpa_rebate/run_submit.py new file mode 100644 index 0000000..a0cd05b --- /dev/null +++ b/rpa_rebate/run_submit.py @@ -0,0 +1,27 @@ +"""页面送审入口。不连 Oracle、不写 actionCode=2、不推口 4。验收只看 ACCEPT.txt。""" +from __future__ import annotations + +import os + +from apa_browser_adapter import ApaBrowserAdapter +from db import load_runtime_env +from rebate_finder import context_from_env +from rebate_flow_steps import run_submit_flow + + +def main() -> int: + load_runtime_env() + contract_no = (os.getenv("REBATE_ACCEPT_CONTRACT_NO") or "").strip() + if not contract_no: + print("缺少 REBATE_ACCEPT_CONTRACT_NO,送审需要已搜到的合同号", flush=True) + return 1 + ctx = context_from_env() + browser = ApaBrowserAdapter(url=ctx.rebate_url, flow="REBATE_SUBMIT") + result = run_submit_flow(browser, ctx, contract_no) + if result.submitted and result.agreement_id: + return 0 + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/rpa_rebate/tests/test_finder.py b/rpa_rebate/tests/test_finder.py index 142cf05..c77aa1f 100644 --- a/rpa_rebate/tests/test_finder.py +++ b/rpa_rebate/tests/test_finder.py @@ -1,6 +1,7 @@ from accept import accept_log_path from job import run_batch from persist import KIND_OK, STAMP_CONTRACT, STAMP_DONE, AgreementOutcome +from rebate_finder import RebateFinder from rebate_flow_steps import ( SENTINEL_CONTRACT_NO, RebateFlowContext, @@ -8,7 +9,6 @@ from rebate_flow_steps import ( run_find_flow, search_contract, ) -from rebate_finder import RebateFinder from repo import MemoryRebateRepo @@ -97,6 +97,14 @@ def test_search_table_hit_is_found(): assert search_contract(browser, "HT1") is True +def test_search_one_row_without_visible_contract_no_is_found(): + browser = FakeBrowser( + table_text="协议总单ID 采购协议号\nCG-743799-20261010-00002", + body_text="采购协议管理 共 1 条", + ) + assert search_contract(browser, "035012026101035440") is True + + def test_page_finder_found_writes_agreement_like_stub(): repo = MemoryRebateRepo( table4=[ @@ -130,10 +138,11 @@ def test_page_finder_found_writes_agreement_like_stub(): password="secret", ) - def recorder(contract_no: str) -> AgreementOutcome: + def runner(group): + assert finder(group.contract_no) is True return AgreementOutcome(KIND_OK, "AGR1", "AGRD1", None) - summary = run_batch(repo, finder=finder, recorder=recorder) + 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 diff --git a/rpa_rebate/tests/test_header.py b/rpa_rebate/tests/test_header.py new file mode 100644 index 0000000..d9aaf39 --- /dev/null +++ b/rpa_rebate/tests/test_header.py @@ -0,0 +1,295 @@ +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, + PLANNER_DEFAULT, + RebateFlowContext, + _pick_person, + build_header_draft, + fill_rebate_header, + header_draft_from_table3, + import_lines_from_table3, + run_header_flow, +) + + +class FakeBrowser: + def __init__(self, *, table_text="", body_text="", login_body="", 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.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 + + 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)) + + def click_native(self, selectors: list[str]) -> None: + self.click_first(selectors) + + 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 "" + if "OPEN_HEADER_ROW" in text: + return {"ok": True, "via": "row"} + if "SELECT_HEADER_ROW" in text: + return {"ok": True, "text": str(args[0] if args else "")} + if "HEADER_SELECTION" in text: + no = str(args[0] if args else "") + return {"ok": True, "cells": [no], "contract": no, "checkedIdx": 0, "pager": "共1条"} + 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 "")]) + return {"ok": True} + if "DETAIL_TOAST" in text: + return {"texts": []} + if "HEADER_CARD" in text: + return False + if "OPEN_LABELED_PICKER" in text: + return {"ok": True} + if "FILL_HEADER_LOOKUP" in text or "PICK_HEADER_LOOKUP" in text: + return {"ok": True} + if "CONFIRM_HEADER_CARD" in text: + return {"ok": True, "via": "确认"} + if "SET_HEADER_CELL" in text: + return {"ok": False} + if "SET_LABELED" in text: + label = args[0] if args else "" + value = args[1] if len(args) > 1 else "" + self.filled.append(([str(label)], str(value))) + return True + if "READ_REBATE_ORDER_NO" in text: + return self.order_no + 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 + + 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)) + + 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="李四", + supplier_code="DW0100046361", + rebate_unit_code="901", + today="2026-10-09", + ) + + +def test_header_draft_from_table3_uses_mdm_and_planner(): + draft = header_draft_from_table3( + [ + { + "supplier_id": "SUP001", + "supplier_mdm_code": "DW0100046361", + "planner_name": "李四", + "purchaser_name": "张三", + "org_code": "901", + "goods_id": "4000018689", + "qty": "10", + "unit_price": "10", + "oms_id": "20260928000003", + } + ], + today="2026-10-12", + ) + assert draft.supplier_code == "DW0100046361" + assert draft.planner_name == PLANNER_DEFAULT + assert draft.rebate_unit_code == "901" + assert draft.contact_name == CONTACT_DEFAULT + lines = import_lines_from_table3( + [ + { + "goods_id": "4000018689", + "qty": "10", + "unit_price": "10", + "oms_id": "20260928000003", + "goods_source_id": "SRC1", + } + ] + ) + assert lines[0].goods_id == "4000018689" + assert lines[0].order_no == "20260928000003" + assert lines[0].tax_rate == "0.13" + assert lines[0].net_price == "8.8496" + + +def test_header_defaults_follow_mapping_not_scheme_55(): + draft = _draft() + assert draft.planner_name == "李四" + assert draft.pay_mode == PAY_MODE_NEXT_MONTH_N + assert draft.protocol_start == "2026-10-09" + assert draft.protocol_end == "2026-10-09" + assert draft.rebate_contract == "否" + 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 重庆亿昊", + body_text="返利合约 计划员 承付模式 保存", + ) + fill_rebate_header(browser, _draft()) + filled_values = [value for _, value in browser.filled] + selected_values = [value for _, value in browser.selected] + assert "李四" in filled_values + assert "2026-10-09" in filled_values + assert PAY_MODE_NEXT_MONTH_N in selected_values + assert "否" in selected_values + assert "廖建峰" not in filled_values + assert "账期" not in selected_values + assert "2026-12-31" not in filled_values + + +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) + browser = FakeBrowser( + table_text="HT1 重庆亿昊", + body_text="采购协议管理 合同编号 查询 返利合约 承付模式 计划员 保存 总单号 FL20261009001", + login_body="采购协议管理", + order_no="FL20261009001", + ) + order_no = run_header_flow(browser, _ctx(), "HT1", _draft()) + assert order_no == "FL20261009001" + text = accept_log_path("REBATE_HEADER").read_text(encoding="utf-8") + assert "ACCEPT SUMMARY REBATE_HEADER 8/8 ok" in text + assert "ACCEPT S09 ok 读总单号" in text + assert "rebateOrderNo=FL20261009001" in text + assert "planner=李四" in text + assert f"payMode={PAY_MODE_NEXT_MONTH_N}" in text + assert "protocolEnd=2026-10-09" 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) + browser = FakeBrowser( + table_text="HT1 重庆亿昊", + body_text="采购协议管理 待审批 合同编号 查询 返利合约 承付模式 计划员 保存", + login_body="采购协议管理", + order_no="FL1", + ) + run_header_flow(browser, _ctx(), "HT1", _draft()) + login_fills = [value for selectors, value in browser.filled if selectors == LOGIN["user"]] + assert login_fills == [] + assert "u" not in [value for _, value in browser.filled] + + +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: + """计划员三个点打开自己的选择层,不拿货源选择当层。""" + + 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) + if "READ_LABELED_INPUT" in text: + title = str(args[0] if args else "") + if title == "计划员ID": + return {"value": self.planner_id} + if title == "计划员": + return {"value": self.planner_name} + return {"value": ""} + if "CLOSE_HEADER_QUERY" in text: + return {"ok": True} + if "OPEN_LABELED_PICKER" in text: + self.layer_open = True + return {"ok": True, "via": "more-outline"} + if "PERSON_PICKER_VISIBLE" in text: + return self.layer_open + if "FOCUS_PERSON_FIELD" in text: + return {"ok": True, "ph": "请输入计划员", "title": "计划员选择"} + if "CLICK_PERSON_QUERY" in text: + return {"ok": True, "via": "query"} + if "PERSON_PICKER_ROWS" in text: + return {"ok": True, "match": True, "count": 1, "row": "廖建琦"} + if "PICK_PERSON_ROW" in text: + self.planner_name = "廖建琦" + self.planner_id = "U1001" + self.layer_open = False + return {"ok": True, "text": "廖建琦"} + if "CLOSE_PERSON_PICKER" in text: + self.layer_open = False + return {"ok": True, "via": "close"} + if "SCAN_LOOKUP_OVERLAYS" in text: + return ["计划员选择"] if self.layer_open else [] + if "SOURCE_CHOICE" in text or "CLOSE_SOURCE_CHOICE" in text: + return False + return None + + +def test_pick_person_selects_liao_and_binds_id(): + browser = PlannerPickBrowser() + _pick_person(browser, "计划员", PLANNER_DEFAULT) + assert browser.planner_name == "廖建琦" + assert browser.planner_id == "U1001" + joined = "\n".join(browser.scripts) + assert "FOCUS_PERSON_FIELD" in joined + assert "PICK_PERSON_ROW" in joined + assert "FOCUS_SOURCE_FIELD" not in joined + assert "PICK_SOURCE_ROW" not in joined diff --git a/rpa_rebate/tests/test_import.py b/rpa_rebate/tests/test_import.py new file mode 100644 index 0000000..81cdfac --- /dev/null +++ b/rpa_rebate/tests/test_import.py @@ -0,0 +1,325 @@ +import pytest +from accept import accept_log_path +from apa_browser_adapter import chrome_debug_port, chrome_session_flow, resolve_chrome_profile_dir +from errors import NodeError +from rebate_flow_steps import ( + COMPENSATION_DEFAULT, + LOGIN, + PAY_MODE_CODE, + PAY_PARAM_DEFAULT, + SETTLE_CODE, + TAX_RATE_DEFAULT, + RebateFlowContext, + RebateImportLine, + build_import_draft, + build_import_rows, + fill_import_excel, + run_import_flow, + write_canonical_template, +) + + +class FakeBrowser: + def __init__(self, *, table_text="", body_text="", login_body="", import_result=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.opened: list[str] = [] + self.filled: list[tuple[list[str], str]] = [] + self.selected: list[tuple[list[str], str]] = [] + self.clicked: list[list[str]] = [] + self.uploaded: list[str] = [] + self.download_dir = None + self.script_count = 0 + + 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)) + + def click_native(self, selectors: list[str]) -> None: + self.click_first(selectors) + + 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 "" + if "SELECT_HEADER_ROW" in text: + return {"ok": True, "text": self.table_text} + if "GOODS_ADD_VISIBLE" in text: + return True + if "CLICK_GOODS_ADD" in text: + self.clicked.append(["+新增"]) + return {"ok": True, "text": "+新增"} + if "CLOSE_HEADER_QUERY" in text: + return {"ok": True} + if "OPEN_GOODS_PICKER" in text: + return {"ok": True} + if "WAIT_GOODS_PICKER" in text: + return True + if "GOODS_ID_FIELD" in text: + return True + if "OPEN_GOODS_ID_QUERY" in text: + return {"ok": True, "already": True} + if "SAVE_DETAIL" in text: + self.clicked.append(["保存"]) + return {"ok": True} + if "CONFIRM_SAVE_POPUP" in text: + return {"ok": True, "asked": False} + if "OPEN_DETAIL_SELECT" in text: + return {"ok": True} + if "FILTER_DETAIL_SELECT" in text: + return {"ok": True} + if "CLOSE_DETAIL_CARD" in text: + return {"ok": True} + if "CLOSE_HEADER_CARD" in text: + return {"ok": True} + if "GOODS_TABLE_ROWS" in text: + return ["G001 10 ZQD1"] + 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: + return False + if "PICK_NTH_OVERLAY" in text: + return {"ok": True, "text": str(args[1] if len(args) > 1 else "")} + if "TYPE_DETAIL_FIELD" in text: + return True + if "FILL_DETAIL_CELLS" in text: + return {"qty": True, "price": True, "remark": True, "compensation": True, "tax": True, "payParam": True, "inbound": True} + if "DETAIL_CARD_PAGER" in text: + return "第 1 / 1 条记录" + if "OPEN_HEADER_ROW" in text: + return {"ok": True, "via": "row"} + if "READ_IMPORT_RESULT" in text: + return dict(self.import_result) + if "table-toolbar-btn__text" in text: + title = str(args[0] if args else "") + self.clicked.append([title]) + if title in {"下载模板", "下载导入模板", "导出模板", "导入模板", "模板下载"}: + self._write_template() + 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 "" + + 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)) + + 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) + + 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") + + +def _line() -> RebateImportLine: + return RebateImportLine( + goods_code="G001", + qty="10", + tax_price="6.70", + order_no="ZQD1", + source_line_id="SRC1", + ) + + +def _draft(): + return build_import_draft(lines=[_line()], today="2026-10-10") + + +def test_import_rows_follow_mapping(): + row = build_import_rows([_line()], today="2026-10-10")[0] + assert row["货品编码"] == "G001" + assert row["数量"] == "10" + assert row["含税协议单价"] == "6.70" + assert row["税率"] == TAX_RATE_DEFAULT + assert row["关税税率"] == TAX_RATE_DEFAULT + assert row["承付模式"] == PAY_MODE_CODE + assert row["承付参数"] == PAY_PARAM_DEFAULT + assert row["采购结算方式"] == SETTLE_CODE + assert row["细单开始日期"] == "2026-10-10" + assert row["细单结束日期"] == "2026-12-31" + assert row["特定补偿预估值"] == COMPENSATION_DEFAULT + assert row["进货补偿预估值"] == COMPENSATION_DEFAULT + assert row["细单备注"] == "ZQD1" + assert row["货源细单ID"] == "SRC1" + + +def test_import_rows_search_by_goods_id(): + line = RebateImportLine( + goods_code="FFTMMHTTP", + qty="10", + tax_price="6.70", + order_no="ZQD1", + goods_id="4000018689", + ) + row = build_import_rows([line], today="2026-10-10")[0] + assert row["货品编码"] == "4000018689" + + +def test_import_line_from_mapping_prefers_goods_id(): + from rebate_flow_steps import _line_from_mapping + + line = _line_from_mapping( + { + "goodsId": "4000018689", + "goodsCode": "FFTMMHTTP", + "qty": "10", + "taxPrice": "6.70", + "orderNo": "ZQD1", + } + ) + row = build_import_rows([line], today="2026-10-10")[0] + assert line.goods_id == "4000018689" + assert row["货品编码"] == "4000018689" + + +def test_import_draft_requires_oms_order_no(): + with pytest.raises(NodeError): + build_import_draft( + lines=[RebateImportLine(goods_code="G001", qty="10", tax_price="6.70", order_no="")] + ) + + +def test_fill_excel_writes_goods_id_into_goods_id_column(tmp_path): + from openpyxl import Workbook, load_workbook + + template = tmp_path / "t.xlsx" + wb = Workbook() + ws = wb.active + ws["A1"] = "货品ID" + ws["B1"] = "数量" + wb.save(template) + line = RebateImportLine( + goods_code="FFTMMHTTP", + qty="10", + tax_price="6.70", + order_no="ZQD1", + goods_id="4000018689", + ) + out = fill_import_excel( + template, + build_import_rows([line], today="2026-10-10"), + tmp_path / "out.xlsx", + ) + ws = load_workbook(out).active + assert str(ws["A2"].value) == "4000018689" + + +def test_fill_excel_writes_remark_and_zero_compensation(tmp_path): + from openpyxl import load_workbook + + template = tmp_path / "t.xlsx" + write_canonical_template(template) + out = fill_import_excel( + template, + build_import_rows([_line()], today="2026-10-10"), + tmp_path / "out.xlsx", + ) + ws = load_workbook(out).active + data = {cell.value: value.value for cell, value in zip(ws[1], ws[2])} + assert str(data["细单备注"]) == "ZQD1" + assert str(data["特定补偿预估值"]) == "0" + assert str(data["进货补偿预估值"]) == "0" + assert str(data["承付模式"]) == "11" + + +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) + browser = FakeBrowser( + table_text="HT1 重庆亿昊", + body_text="采购协议管理 合同编号 查询 返利合约 承付模式 保存 导入导出 成功 1 条 失败 0 条", + login_body="采购协议管理", + import_result={"success": 1, "fail": 0, "reasons": []}, + ) + result = run_import_flow(browser, _ctx(), "HT1", _draft(), download_dir=tmp_path / "dl") + assert result.success_count == 1 + assert result.fail_count == 0 + assert result.parsed is True + assert browser.uploaded == [] + text = accept_log_path("REBATE_IMPORT").read_text(encoding="utf-8") + assert "ACCEPT SUMMARY REBATE_IMPORT 8/8 ok" in text + assert "ACCEPT S06 ok 选中总单" in text + assert "ACCEPT S07 ok 新增细单" in text + assert "goodsId=G001" in text + assert "ACCEPT S08 ok 保存细单" in text + assert "1条货品已保存" in text + assert "ACCEPT S10 ok 读导入结果" in text + assert "remark=ZQD1" in text + assert "compensation=0" in text + assert "success=1" in text + 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) + browser = FakeBrowser( + table_text="HT1 重庆亿昊", + body_text="采购协议管理 待审批 合同编号 查询 返利合约 承付模式 保存 导入导出 成功 1 条 失败 0 条", + login_body="采购协议管理", + import_result={"success": 1, "fail": 0, "reasons": []}, + ) + run_import_flow(browser, _ctx(), "HT1", _draft(), download_dir=tmp_path / "dl") + login_fills = [value for selectors, value in browser.filled if selectors == LOGIN["user"]] + assert login_fills == [] + assert "u" not in [value for _, value in browser.filled] + + +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") diff --git a/rpa_rebate/tests/test_job.py b/rpa_rebate/tests/test_job.py index 9563920..0566e9e 100644 --- a/rpa_rebate/tests/test_job.py +++ b/rpa_rebate/tests/test_job.py @@ -1,5 +1,6 @@ -from job import run_batch -from persist import KIND_OK, STAMP_CONTRACT, STAMP_DONE, AgreementOutcome +from job import recorder, run_batch +from persist import KIND_NOT_FOUND, KIND_OK, STAMP_CONTRACT, STAMP_DONE, AgreementOutcome +from rebate_flow_steps import RebateImportResult, RebateSubmitResult from repo import MemoryRebateRepo @@ -46,22 +47,16 @@ def test_job_finds_contract_and_writes_agreement(): _t4(fid=3, contract_status=2, contract_no="HT-DONE", update_user_name=STAMP_DONE), ] ) - looked: list[str] = [] - recorded: list[str] = [] + steps: list[str] = [] - def finder(contract_no: str) -> bool: - looked.append(contract_no) - return True - - def recorder(contract_no: str) -> AgreementOutcome: - recorded.append(contract_no) + def runner(group): + steps.append(group.contract_no) return AgreementOutcome(KIND_OK, "AGR1", "AGRD1", None) - summary = run_batch(repo, finder=finder, recorder=recorder) + summary = run_batch(repo, runner=runner) assert summary.claimed == 2 assert summary.ok == 1 - assert looked == ["HT1"] - assert recorded == ["HT1"] + assert steps == ["HT1"] 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} @@ -80,14 +75,14 @@ def test_job_finds_contract_and_writes_agreement(): def test_job_leaves_not_found_for_next_shift(): repo = MemoryRebateRepo(table4=[_t4()]) - recorded = {"n": 0} + later = {"n": 0} - def recorder(contract_no: str) -> AgreementOutcome: - recorded["n"] += 1 - return AgreementOutcome(KIND_OK, "AGR1", "AGRD1", None) + def runner(group): + later["n"] += 1 + return AgreementOutcome(KIND_NOT_FOUND, None, None, None) - run_batch(repo, finder=lambda _no: False, recorder=recorder) - assert recorded["n"] == 0 + run_batch(repo, runner=runner) + assert later["n"] == 1 row = repo.table4[1] assert row["contract_status"] == 1 assert row["action_code"] == 1 @@ -97,14 +92,14 @@ def test_job_leaves_not_found_for_next_shift(): def test_job_claims_protocol_only_and_writes_table4(): repo = MemoryRebateRepo(table3=[_t3(), _t3(fid=11, process_status=1, contract_no="HT-SKIP")]) - recorded: list[str] = [] + steps: list[str] = [] - def recorder(contract_no: str) -> AgreementOutcome: - recorded.append(contract_no) + def runner(group): + steps.append(group.contract_no) return AgreementOutcome(KIND_OK, "AGR-OLD", "AGRD-OLD", None) - run_batch(repo, finder=lambda _no: True, recorder=recorder) - assert recorded == ["HT-OLD"] + run_batch(repo, runner=runner) + assert steps == ["HT-OLD"] assert repo.table3[10]["process_status"] == 1 assert repo.table3[11]["process_status"] == 1 success = [row for row in repo.table4.values() if row["action_code"] == 1] @@ -118,3 +113,25 @@ def test_job_claims_protocol_only_and_writes_table4(): assert agreements[0]["agreement_status"] == 1 assert agreements[0]["agreement_id"] == "AGR-OLD" assert agreements[0]["agreement_dtl_id"] == "AGRD-OLD" + + +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") + + outcome = recorder("HT1", header, importer, submitter) + assert steps == ["header:HT1", "import:HT1", "submit:HT1"] + assert outcome.kind == KIND_OK + assert outcome.agreement_id == "AGR1" + assert outcome.agreement_dtl_id == "AGRD1" diff --git a/rpa_rebate/tests/test_submit.py b/rpa_rebate/tests/test_submit.py new file mode 100644 index 0000000..ce29f76 --- /dev/null +++ b/rpa_rebate/tests/test_submit.py @@ -0,0 +1,138 @@ +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 LOGIN, RebateFlowContext, _clean_submit_id, run_submit_flow + + +class FakeBrowser: + def __init__(self, *, table_text="", body_text="", login_body="", agreement_id="", agreement_dtl_id=""): + self.table_text = table_text + self.body_text = body_text + self.login_body = login_body or body_text + self.agreement_id = agreement_id + self.agreement_dtl_id = agreement_dtl_id + 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 + + 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)) + + def click_native(self, selectors: list[str]) -> None: + self.click_first(selectors) + + 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 "" + if "SELECT_HEADER_ROW" in text or "OPEN_HEADER_ROW" in text: + return {"ok": True, "via": "row"} + if "READ_AGREEMENT_NOS" in text: + return {"header": self.agreement_id, "detail": self.agreement_dtl_id} + if "READ_REBATE_ORDER_NO" in text: + return self.agreement_id + if "CONFIRM_SUBMIT" in text: + return {"ok": False, "asked": False} + if "table-toolbar-btn__text" in text: + title = str(args[0] if args else "") + self.clicked.append([title]) + 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 self.agreement_id + + 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)) + + 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 test_submit_flow_writes_accept_with_order_nos(tmp_path, monkeypatch): + monkeypatch.delenv("RPA_ACCEPT_LOG", raising=False) + monkeypatch.setattr("accept.SCREENSHOT_ROOT", tmp_path) + browser = FakeBrowser( + table_text="HT1 重庆亿昊", + body_text="采购协议管理 合同编号 查询 送审 协议总单ID 71342020260528 协议细单ID 71342020260529", + login_body="采购协议管理", + agreement_id="71342020260528", + agreement_dtl_id="71342020260529", + ) + result = run_submit_flow(browser, _ctx(), "HT1") + assert result.submitted is True + assert result.agreement_id == "71342020260528" + assert result.agreement_dtl_id == "71342020260529" + assert ["送审"] in browser.clicked + text = accept_log_path("REBATE_SUBMIT").read_text(encoding="utf-8") + assert "ACCEPT SUMMARY REBATE_SUBMIT 7/7 ok" in text + assert "ACCEPT S07 ok 点送审" in text + assert "submitted=yes" in text + assert "ACCEPT S08 ok 读号" in text + assert "rebateOrderNo=71342020260528" in text + assert "agreementDtlId=71342020260529" in text + + +def test_submit_reuses_logged_in_session(tmp_path, monkeypatch): + monkeypatch.delenv("RPA_ACCEPT_LOG", raising=False) + monkeypatch.setattr("accept.SCREENSHOT_ROOT", tmp_path) + browser = FakeBrowser( + table_text="HT1 重庆亿昊", + body_text="采购协议管理 待审批 合同编号 查询 送审", + login_body="采购协议管理", + agreement_id="AGR1", + agreement_dtl_id="AGRD1", + ) + run_submit_flow(browser, _ctx(), "HT1") + login_fills = [value for selectors, value in browser.filled if selectors == LOGIN["user"]] + assert login_fills == [] + assert "u" not in [value for _, value in browser.filled] + + +def test_submit_chrome_session_reuses_find(): + assert chrome_session_flow("REBATE_SUBMIT") == "REBATE_FIND" + assert resolve_chrome_profile_dir("REBATE_SUBMIT") == resolve_chrome_profile_dir("REBATE_FIND") + assert chrome_debug_port("REBATE_SUBMIT") == chrome_debug_port("REBATE_FIND") + + +def test_submit_scan_rejects_loading_placeholder(): + assert _clean_submit_id("查询加载中...设置查询条件") == "" + assert _clean_submit_id("协议细单ID") == "" + assert _clean_submit_id("71342020260528") == "71342020260528" + assert _clean_submit_id("71342020260528", contract_no="71342020260528") == "" diff --git a/rpa_rebate/uv.lock b/rpa_rebate/uv.lock index 4c007b5..46943b1 100644 --- a/rpa_rebate/uv.lock +++ b/rpa_rebate/uv.lock @@ -308,6 +308,15 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, ] +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -335,6 +344,18 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, ] +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + [[package]] name = "oracledb" version = "26.0.1" @@ -481,6 +502,7 @@ name = "rpa-rebate" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "openpyxl" }, { name = "oracledb" }, { name = "selenium" }, { name = "webdriver-manager" }, @@ -493,6 +515,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "openpyxl", specifier = ">=3.1.5" }, { name = "oracledb", specifier = ">=2.4.1" }, { name = "selenium", specifier = ">=4.15.0" }, { name = "webdriver-manager", specifier = ">=4.0.0" }, diff --git a/rpa_rebate/现行模式.md b/rpa_rebate/现行模式.md new file mode 100644 index 0000000..800db8b --- /dev/null +++ b/rpa_rebate/现行模式.md @@ -0,0 +1,71 @@ +# 返利班现行模式(给审计) + +写代码以 `返利流程.md` 为准。班次入口 `run_batch.py`。节点名 `REBATE_IMPORT` 仍叫 IMPORT,页面走细单新增,**不做 Excel**。 + +## 班次 + +1. 领表 4 `action_code=1 AND contract_status=1`(盖「查号中」);领表 3 只录协议行 `process_status` 0→3 +2. 按合同号查返利中心,表里出现该号才算搜到 +3. 勾已有总单(审批通过后已有一条,不要新增总单) +4. 补总单必填 → **卡片仍在编辑时**点总单工具栏保存 → 卡片确认 +5. 细单:货品「新增」→ 货品编码三个点按表 3 `goods_id` 搜 → 货品卡片「确认 Ctrl + Enter」落到表 → 全部确认完再点货品工具栏保存 +6. 总单工具栏送审;成功才写表 4 +7. 同一合同一组一个结果 + +货品行:`load_contract_goods` 先按 `contract_no`,空则用表 4 该合同的 `oms_id` 对表 3。 + +## 字段(现行) + +| 位置 | 字段 | 值 | +| --- | --- | --- | +| 总单 | 供应商编码 | 表 3 `supplier_mdm_code`,三个点搜 | +| 总单 | 计划员 | 廖建琦(选择框,不要选 ID) | +| 总单 | 联系人 | 苏文辉 | +| 总单 | 返利合约 | 否 | +| 总单 | 采购结算方式 | 电汇(不要点电汇—非直联) | +| 总单 | 承付模式 | 下月N号结 | +| 总单 | 承付参数 | **25**(数字框键盘输入) | +| 总单 | 协议开始/结束 | 当天 | +| 细单 | 货品 | 表 3 `goods_id` | +| 细单 | 数量 / 含税单价 | 表 3 `qty` / `unit_price` | +| 细单 | 无税单价 | `含税 / (1+税率)` | +| 细单 | 承付模式 | 下月N号结 | +| 细单 | 承付参数 | **25** | +| 细单 | 采购结算方式 | 电汇 | +| 细单 | 备注 | `oms_id` | +| 细单 | 特定补偿 / 进货补偿 | 0 | +| 细单 | 货源细单ID | 本期不填(下一会话:按总单联系人对货源,搜不到整组报错) | + +落表:起草行 `contract_status=2`;送审成功每 `oms_id` 插 `action_code=2`、`agreement_status=1`,带协议总单ID / 协议细单ID;`push_status=0`。搜不到戳改回合同班。 + +## 修改(相对旧稿 / Excel 方案) + +| 项 | 旧 | 现 | +| --- | --- | --- | +| 细单路径 | Agents / README 写「下模板 → 填 Excel → 上传」 | 页面「新增 + 货品ID搜」。`run_import_flow` 调 `add_detail_lines_by_goods_id` | +| `返利流程(1).md` | 已知/未知草稿,Excel 与页面双轨 | 废稿,指向 `返利流程.md` | +| 承付参数 | 「不要填 25 / 以销售定结」 | 总单、细单都填 **25** | +| 总单保存 | 先卡片确认再保存 | 先总单工具栏保存(卡仍在编辑),再确认 | +| 细单确认 | 容易点到总单「确认」 | 只点货品卡片「确认 Ctrl + Enter」,确认后核对货品表有该 `goods_id` | +| 计划员弹层 | 适配器看见「货源选择」就关 | 计划员三点打开的货源层要按住再选廖建琦 | +| 货品来源 | 只按表 3 `contract_no` | `contract_no` 空时用表 4 `oms_id` 对表 3 | +| 落表 | (曾按行拆成功/失败,已撤回) | 同一合同一组一个结果 | + +## 增加 + +| 项 | 落点 | +| --- | --- | +| 班次入口 | `run_batch.py`:查号 → 总单 → 细单新增 → 送审 → `save_group` | +| 总单数字框键盘填 25 | `_fill_pay_param` | +| 细单键盘填 25 | `_fill_last_detail_qty_price` / `_type_detail_field` | +| 货品表核对 | `_goods_table_rows`、`_wait_goods_row` | +| 原生点击 | 计划员附加按钮、总单/货品工具栏保存、卡片确认 | +| 现行登记 | 本文件 | + +## 代码里还留着、班次不走 + +`rebate_flow_steps.py` 仍有 Excel 下模板 / 填表 / 上传函数(`write_canonical_template`、`download_detail_template` 等)。`run_import_flow` 不调用它们。不要按这些函数写新逻辑。 + +## 下一会话(未做) + +细单「货源细单ID」三个点:用总单联系人对货源。弹层搜不到 → 整组停、写报错返回。不要按行一条成功两条失败。 diff --git a/rpa_rebate/返利流程(1).md b/rpa_rebate/返利流程(1).md new file mode 100644 index 0000000..53605e7 --- /dev/null +++ b/rpa_rebate/返利流程(1).md @@ -0,0 +1,6 @@ +# 废稿 + +这份是早期「已知/未知」草稿,还夹着 Excel 下模板上传,**不要按本文写代码**。 + +现行口径:`返利流程.md` +班次对照:`现行模式.md` diff --git a/rpa_rebate/返利流程.md b/rpa_rebate/返利流程.md new file mode 100644 index 0000000..1874449 --- /dev/null +++ b/rpa_rebate/返利流程.md @@ -0,0 +1,168 @@ +# 返利中心页面流程 + +写代码以本文为准。 + +--- + +## 环境 + +- 地址:`http://192.168.170.144:8001` +- 菜单:采购协议管理 +- 登录账号走本机 `rpa_rebate/.env` +- 合同号用合同系统号,例:`035012026101035440` +- 细单货品:点「货品编码」三个点,弹窗里用表 3 / 接口 3 的 `goodsId` 搜 +- 计划员:先点进选择框,默认选「廖建琦」,不要选计划员 ID。后期再改回订单中心计划员 +- 联系人:选择框,选第二条「苏文辉」 +- 承付模式:方案「下月 N 号结」 +- 承付参数:总单、细单都填 `25`(vxe 数字框,键盘输入) +- 协议开始、协议结束:当天 +- 细单备注 = 订单号(`oms_id`) +- 特定补偿预估值 = 0 +- 进货补偿预估值 = 0 +- 搜不到合同:不重录合同,留给下一班 +- 送审成功才写表 4:`actionCode=2`、`agreementStatus=1`,带上 **协议总单ID** / **协议细单ID** + +--- + +## 整班顺序 + +1. 打开返利中心,登录,进采购协议管理 +2. 按合同号查号,表里出现该号才算搜到 +3. 勾中该合同已有总单(审批通过后返利中心已有一条,不要点新增总单) +4. 补总单必填(卡片仍在「编辑」)→ 总单工具栏保存 → 再点卡片「确认 Ctrl + Enter」→ 读「协议总单ID」 +5. 该总单下多次点细单「新增」,按货品 ID 搜品种;每条卡片点货品卡「确认 Ctrl + Enter」落到货品表;**所有细单都确认完再点货品工具栏保存** +6. 总单工具栏点送审(细单必须已保存) +7. 送审成功才落表 4。同一合同一组一个结果。 + +细单只走页面「新增 + 货品编码三个点 + 按货品ID搜」。Excel 下模板上传不做。班次入口 `run_batch.py`。 + +--- + +## 1 查号 + +1. 打开查询 +2. 合同编号填合同系统号 +3. 点查询 +4. 关查询弹层用 **X / Esc**,不要点「取消」(会清掉合同编号筛选) +5. 总单表 `.table-show` 里「合同编号」列等于该号,点左侧固定列勾选框 +6. 禁止拿第一行,禁止拿 `CG-` 号凑合 +7. 勾中总单后,货品区才出「新增」 +8. 表里出现该合同号才算搜到;输入框回显不算 + +合同审批通过后,返利中心已经有一条总单,直接在那条上操作,不要新增总单。打开后仍要补表头必填;供应商要点三个点进选择框。 + +--- + +## 2 总单 + +1. 先按合同编号勾中总单行 +2. 打开总单:点「编辑」或双击行,哪个方便用哪个。不要点「新增」总单 +3. 带星号都要填 +4. 供应商:点「供应商编码」三个点,打开选择框,用表 3 的 `supplier_mdm_code` 搜,对上编码那一列,确认选中。选中后会带出供应商名称、返利单位等,不要手填这些带出项。名称和编码不要填反 +5. 选对供应商时不应弹出「货源选择」;若弹出,停下问 +6. 计划员:进选择框,先默认选「廖建琦」,不要选计划员 ID。后期再改 +7. 联系人:进选择框,选第二条「苏文辉」 +8. 采购结算方式:可筛选下拉,先输入「电汇」,再精确点「电汇」,不要点「电汇—非直联」 +9. 承付模式:「下月N号结」。承付参数填 `25`(数字框,键盘输入;不要填「以销售定结」) +10. 卡片还在「编辑」时点总单工具栏「保存」,再点卡片「确认 Ctrl + Enter」。先确认会把卡收回表格,保存落不到库 +11. 保存后总单号认 **协议总单ID** + +### 2.1 总单字段 + +| 页面字段 | 怎么填 | 从哪来 | +| --- | --- | --- | +| 供应商编码 | 三个点打开选择框,用 `supplier_mdm_code` 搜,确认选中那条 | 表 3 | +| 供应商名称 | 不填,选供应商后带出 | 带出 | +| 返利单位编码 | 不填,选供应商后带出 | 带出 | +| 计划员 | 选择框先选「廖建琦」,不要选计划员 ID;后期再改 | 本账号,后期改 | +| 联系人 | 选择框选第二条「苏文辉」 | 页面选项 | +| 返利合约 | 「否」 | 写死 | +| 采购结算方式 | 「电汇」,精确点中 | 写死 | +| 承付模式 | 「下月N号结」 | 方案 | +| 承付参数 | `25` | 写死 | +| 协议开始日期 | 当天 | 写死 | +| 协议结束日期 | 当天 | 写死 | + +--- + +## 3 细单 + +1. 必须先勾中总单,货品区才出「新增」 +2. 点货品「新增」(同一合同多行:每行点一次新增) +3. 细单「货品编码」字段点三个点,打开货品选择 +4. 弹窗里用 **货品ID** 搜表 3 的 `goods_id`(不要用 opcode / 货品编码搜) +5. 先点行里的单选 `.vxe-cell--radio`,再点弹窗底「确 定」 +6. 数量、含税协议单价、无税单价按表 3 填 +7. 细单备注 = 订单号;特定补偿 = 0 +8. 承付模式:下月N号结;承付参数 `25` +9. 采购结算方式:先输入「电汇」再精确点「电汇」 +10. 关税税率不填。货源细单ID 本期不填(下一会话:按总单联系人对货源,搜不到整组报错返回)。进货补偿预估值确认时报必填,填 0 +11. 细单开始 / 结束日期先用系统默认;保存报错再看 +12. 每条卡片填完点标题栏「确认 Ctrl + Enter」,行才落到货品表。不要点「返回表格」代替确认 +13. **三条都确认完,再点货品工具栏「保存」**(新增旁边那颗,不要点总单工具栏保存)。保存时若弹出确认,再点确认。不要每条都点工具栏保存 +14. 提示「总单供应商为空」时,先回总单把供应商补上,再继续细单 + +表 3 没有「无税单价」列,只有 `unit_price`(含税)和 `purchase_tax_rate`。无税单价按 `含税 ÷ (1+税率)` 算;税率用该行 `purchase_tax_rate`,空则 `0.13`。 + +### 3.1 细单字段 + +| 页面字段 | 怎么填 | 从哪来 | +| --- | --- | --- | +| 货品编码 | 三个点打开选择框,用货品ID搜 | 表 3 `goods_id` | +| 数量 | 手填 | 表 3 `qty` | +| 含税协议单价 | 手填 | 表 3 `unit_price` | +| 无税单价 | 手填 | 表 3:`unit_price / (1 + purchase_tax_rate)` | +| 税率 | 表 3 `purchase_tax_rate`,空则 `0.13` | 表 3 | +| 关税税率 | 不填 | — | +| 承付模式 | 「下月N号结」 | 方案 | +| 承付参数 | `25` | 写死 | +| 采购结算方式 | 「电汇」,精确点中 | 写死 | +| 细单开始日期 | 系统默认,报错再看 | 页面 | +| 细单结束日期 | 系统默认,报错再看 | 页面 | +| 特定补偿预估值 | `0` | 写死 | +| 进货补偿预估值 | 页面确认时报必填,填 `0` | 写死 | +| 细单备注 | 订单号 | 表 3 `oms_id` | +| 货源细单ID | 本期不填。下一会话:总单联系人同一货源;三个点搜不到则整组报错返回 | — | + +--- + +## 4 送审 + +1. 总单、细单都保存完再送审;未保存细单不能送审 +2. 送审按钮在 **总单工具栏** +3. 点「送审」;有确认框就确定 +4. 送审成功才插表 4 协议行 +5. 总单号认 **协议总单ID**,细单号认 **协议细单ID**。列表上一时看不见,再点一次查询 + +--- + +## 5 落表(班次,不改页面) + +- 领表 4:`action_code=1 AND contract_status=1`(合同班起草、尚未回成功) +- 领表 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,不改 `push_status` + +--- + +## 6 不要做 + +- 不要点查询弹层的「取消」 +- 不要拿第一行或 `CG-` 号当总单 +- 不要新增总单(审批通过后已有一条) +- 不要选计划员 ID;现阶段选「廖建琦」 +- 供应商选择框里不要把名称和编码填反;用 `supplier_mdm_code` 搜编码 +- 不要用 opcode / 货品编码在返利中心搜品种;用货品ID +- 结算不要点到「电汇—非直联」 +- 细单不要点总单工具栏的保存 +- 不要每条细单都点工具栏保存;卡片「确定」落到表后再统一保存 +- 不要把承付参数填成「以销售定结」;总单、细单都填 `25` +- 不要做 Excel 导入 +- 不要按行拆同一合同的成功/失败;一组一个结果 +- 不要点总单卡片的「确认」去当细单确认;细单只点货品卡片「确认 Ctrl + Enter」 +- 搜不到不要重录合同 +- 浏览器卡住时不要关,停下问 +- 计划员三个点打开的「货源选择」是选人弹层,要按住再搜「廖建琦」;适配器默认关货源层时计划员选不了