"""返利中心查号、总单、细单与送审:搜到后填总单;页面新增细单(按货品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, artifact_dir, evidence_shot from errors import LoginFailed, NodeError FLOW = "REBATE_FIND" HEADER_FLOW = "REBATE_HEADER" IMPORT_FLOW = "REBATE_IMPORT" SUBMIT_FLOW = "REBATE_SUBMIT" DEFAULT_URL = "http://192.168.170.144:8001" SENTINEL_CONTRACT_NO = "__NO_SUCH_CONTRACT__" PAY_MODE_NEXT_MONTH_N = "下月N号结" REBATE_CONTRACT_DEFAULT = "否" SETTLE_MODE_DEFAULT = "否" TAX_RATE_DEFAULT = "0.13" PAY_MODE_CODE = "11" PAY_PARAM_DEFAULT = "25" PAY_PARAM_SALES = "以销售定结" SETTLE_CODE = "3" SETTLE_MODE_WIRE = "电汇" COMPENSATION_DEFAULT = "0" PLANNER_DEFAULT = "廖建琦" CONTACT_DEFAULT = "苏文辉" _ACCEPT = AcceptanceReport(flow=FLOW) _HEADER_ACCEPT = AcceptanceReport(flow=HEADER_FLOW) _IMPORT_ACCEPT = AcceptanceReport(flow=IMPORT_FLOW) _SUBMIT_ACCEPT = AcceptanceReport(flow=SUBMIT_FLOW) _PORTAL_MARKERS = ("采购协议", "待审批", "待审批事项", "待办", "菜单名称", "下载打印插件") EMPTY_MARKERS = ("暂无数据", "没有找到", "查询无结果", "无匹配数据", "未找到相关") LOGIN = { "user": [ 'input[name="username"]', 'input[name="userName"]', 'input[placeholder*="用户名"]', 'input[placeholder*="账号"]', 'input[placeholder*="工号"]', 'input[type="text"]', ], "password": [ 'input[name="password"]', 'input[placeholder*="密码"]', 'input[type="password"]', ], "submit": [ "button.login-submit", "button.el-button--primary", "button:contains(登录)", 'button[type="submit"]', 'input[type="submit"]', 'input[value="登录"]', ], } MENU = { "entry": [ "text=采购协议管理", "text=采购协议", "a:contains(采购协议管理)", "span:contains(采购协议管理)", ], } SEARCH = { "contract_no": [ "label=合同编号", "label=合同号", 'input[placeholder*="合同编号"]', 'input[placeholder*="合同号"]', 'input[name*="contractNo"]', 'input[name*="contract"]', ], "button": [ "text=查询", "button:contains(查询)", "a:contains(查询)", "text=搜索", "button:contains(搜索)", ], } _TABLE_SCRIPT = """ const nodes = Array.from(document.querySelectorAll( 'table tbody, table, .el-table__body, .el-table, .ant-table-tbody, .ant-table, .vxe-table, .vxe-body--row' )); return nodes.map((n) => (n.innerText || '').trim()).filter(Boolean).join('\\n'); """ class BrowserAdapter(Protocol): def open_url(self, url: str) -> None: ... def fill_first(self, selectors: list[str], value: str) -> None: ... def click_first(self, selectors: list[str]) -> None: ... def wait_for_text(self, text: str, timeout_seconds: int = 15) -> None: ... def wait_for_element(self, selectors: list[str], timeout_seconds: int = 15) -> None: ... def element_exists(self, selectors: list[str]) -> bool: ... def read_body_text(self) -> str: ... def execute_script(self, script: str, *args): ... def screenshot(self, name: str) -> str: ... def select_first(self, selectors: list[str], value: str) -> None: ... def read_first(self, selectors: list[str], value_name: str = "") -> str: ... def upload_file_first(self, selectors: list[str], file_path: str) -> None: ... @dataclass(frozen=True) class RebateFlowContext: rebate_url: str username: str password: str @dataclass(frozen=True) class RebateHeaderDraft: planner_name: str supplier_code: str rebate_unit_code: str rebate_contract: str = REBATE_CONTRACT_DEFAULT settle_mode: str = SETTLE_MODE_DEFAULT pay_mode: str = PAY_MODE_NEXT_MONTH_N protocol_start: str = "" protocol_end: str = "" contact_name: str = "" supplier_name: str = "" supplier_id: str = "" @dataclass(frozen=True) class RebateImportLine: goods_code: str qty: str tax_price: str order_no: str source_line_id: str = "" goods_id: str = "" tax_rate: str = "" net_price: str = "" @dataclass(frozen=True) class RebateImportDraft: lines: tuple[RebateImportLine, ...] today: str = "" @dataclass(frozen=True) class RebateImportResult: success_count: int fail_count: int fail_reasons: tuple[str, ...] = () parsed: bool = False @dataclass(frozen=True) class RebateSubmitResult: submitted: bool agreement_id: str = "" agreement_dtl_id: str = "" def classify_search_result(result_text: str, contract_no: str, *, from_table: bool = True) -> bool: number = (contract_no or "").strip() if not number: return False text = result_text or "" if from_table: if any(marker in text for marker in EMPTY_MARKERS) and number not in text: return False return number in text if any(marker in text for marker in EMPTY_MARKERS): return False return text.count(number) >= 2 def read_result_table(browser: BrowserAdapter) -> tuple[str, bool]: try: text = browser.execute_script(_TABLE_SCRIPT) except Exception: text = None if isinstance(text, str) and text.strip(): return text, True return (browser.read_body_text() or ""), False def open_rebate_center(browser: BrowserAdapter, ctx: RebateFlowContext) -> None: url = "" try: url = str(browser.execute_script("return location.href") or "") except Exception: url = "" if "ssoLogin" in url or "login" in url.lower() or "wel/index" in url: browser.open_url(ctx.rebate_url) return if "procurementAgree" in url: return browser.open_url(ctx.rebate_url) def _is_logged_in(browser: BrowserAdapter) -> bool: body = browser.read_body_text() or "" return any(marker in body for marker in _PORTAL_MARKERS) def login_rebate_center(browser: BrowserAdapter, ctx: RebateFlowContext) -> None: try: if _is_logged_in(browser): return if not (ctx.username or "").strip() or ctx.password == "": raise LoginFailed("未配置 REBATE_USERNAME / REBATE_PASSWORD") browser.fill_first(LOGIN["user"], ctx.username) browser.fill_first(LOGIN["password"], ctx.password) clicked = False try: clicked = bool( browser.execute_script( """ const btn = Array.from(document.querySelectorAll('button')).find((el) => { const t = ((el.innerText || el.value || '') + '').replace(/\\s+/g, '').trim(); return t === '登录' && (el.offsetWidth || el.offsetHeight); }); if (!btn) { return false; } btn.click(); return true; """ ) ) except Exception: clicked = False if not clicked: browser.click_first(LOGIN["submit"]) deadline = time.time() + 30 while time.time() < deadline: if _is_logged_in(browser): return time.sleep(0.5) raise TimeoutError("登录后未进入返利中心") except LoginFailed: raise except Exception as exc: raise LoginFailed(f"返利中心登录失败:{exc}") from exc def _switch_agreement_frame(browser: BrowserAdapter) -> bool: driver = getattr(browser, "_driver", None) if driver is None: return False def _here() -> bool: try: body = driver.execute_script("return (document.body && document.body.innerText) || ''") or "" has_search = bool(driver.execute_script("return !!document.querySelector('.search-button')")) return ("协议总单ID" in body or "采购协议号" in body) and has_search except Exception: return False if _here(): return True try: driver.switch_to.default_content() if _here(): return True frames = driver.find_elements("css selector", "iframe") except Exception: return False for frame in frames: try: driver.switch_to.default_content() driver.switch_to.frame(frame) if _here(): return True except Exception: continue try: driver.switch_to.default_content() except Exception: pass return _here() def _dismiss_menu_autocomplete(browser: BrowserAdapter) -> None: """菜单「采购协议」下拉会挡住总单查询和细单新增,进页后必须关掉。""" try: switch = getattr(browser, "switch_to_default_content", None) if callable(switch): switch() except Exception: pass try: browser.execute_script( """ /* DISMISS_MENU_AUTOCOMPLETE */ const input = document.querySelector('input[placeholder*="菜单名称"]'); if (input) { input.blur(); } document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true })); const pops = Array.from(document.querySelectorAll( '.el-autocomplete-suggestion, .my-autocomplete, .el-popper.my-autocomplete' )); for (const el of pops) { el.remove(); } return pops.length; """ ) except Exception: pass def enter_purchase_agreement(browser: BrowserAdapter) -> None: url = "" try: url = str(browser.execute_script("return location.href") or "") except Exception: url = "" body = browser.read_body_text() or "" already = "procurementAgree" in url or "协议总单ID" in body or ( "合同编号" in body and "查询" in body ) if already: _dismiss_menu_autocomplete(browser) _switch_agreement_frame(browser) return try: browser.wait_for_element(['input[placeholder*="菜单名称"]'], timeout_seconds=15) browser.fill_first(['input[placeholder*="菜单名称"]'], "采购协议") time.sleep(1.5) except Exception as exc: print(f"菜单搜索框未填:{exc}", flush=True) clicked = False try: clicked = browser.execute_script( """ const sug = document.querySelector( '.el-autocomplete-suggestion, .my-autocomplete, .el-popper.my-autocomplete' ); const scope = sug || document; const hit = Array.from(scope.querySelectorAll('div.name, li[role="option"]')).find((el) => { const t = ((el.innerText || '') + '').replace(/\\s+/g, '').trim(); return (t === '采购协议管理' || t.indexOf('采购协议管理') === 0) && (el.offsetWidth || el.offsetHeight); }); if (!hit) { return {ok: false}; } hit.click(); return {ok: true, cls: (hit.className || '').toString().slice(0, 80)}; """ ) clicked = bool(isinstance(clicked, dict) and clicked.get("ok")) except Exception as exc: print(f"菜单点击失败:{exc}", flush=True) clicked = False if not clicked: browser.click_first(MENU["entry"]) _dismiss_menu_autocomplete(browser) deadline = time.time() + 25 while time.time() <= deadline: href = "" try: href = str(browser.execute_script("return location.href") or "") except Exception: href = "" if "procurementAgree" in href: _dismiss_menu_autocomplete(browser) if _switch_agreement_frame(browser): return time.sleep(0.5) raise TimeoutError("未进入采购协议管理") _QUERY_LABELS = ("合同编号", "采购协议号", "协议总单ID") def _open_header_search_panel(browser: BrowserAdapter) -> bool: opened = browser.execute_script( """ /* OPEN_HEADER_SEARCH */ function labelText(el) { const n = el.querySelector('.search-label__text'); return ((n && n.innerText) || '').replace(/\\s+/g, '').trim(); } const items = Array.from(document.querySelectorAll('.search-condition-item')); const hasContract = items.some((el) => labelText(el) === '合同编号'); if (hasContract) { return {ok: true, already: true}; } const btn = document.querySelector('.table-show .single-table .search-button-content') || document.querySelector('.table-show .search-button-content') || document.querySelector('.search-button-content'); if (!btn) { return {ok: false}; } btn.click(); return {ok: true}; """ ) if not (isinstance(opened, dict) and opened.get("ok")): return False deadline = time.time() + 8 while time.time() <= deadline: ready = False try: ready = bool( browser.execute_script( """ const items = Array.from(document.querySelectorAll('.search-condition-item')); return items.some((el) => { const n = el.querySelector('.search-label__text'); const t = ((n && n.innerText) || '').replace(/\\s+/g, '').trim(); return t === '合同编号'; }); """ ) ) except Exception: ready = False if ready: return True time.sleep(0.3) return False def _clear_header_search(browser: BrowserAdapter) -> None: browser.execute_script( """ /* CLEAR_HEADER_SEARCH */ const hit = Array.from(document.querySelectorAll('button')).find((el) => { const t = ((el.innerText || '') + '').replace(/\\s+/g, ' ').trim(); return t === '清除 Alt+C' || t === '清除'; }); if (hit) { hit.click(); return true; } return false; """ ) time.sleep(0.4) def _fill_header_contract_and_query(browser: BrowserAdapter, number: str) -> bool: filled = browser.execute_script( """ /* FILL_HEADER_CONTRACT */ const no = String(arguments[0] || ''); function labelText(el) { const n = el.querySelector('.search-label__text'); return ((n && n.innerText) || '').replace(/\\s+/g, '').trim(); } const items = Array.from(document.querySelectorAll('.search-condition-item')); for (const el of items) { const box = el.querySelector('label.elPlus-checkbox'); if (!box) continue; const on = box.classList.contains('is-checked'); const mine = labelText(el) === '合同编号'; if (mine && !on) { box.click(); } if (!mine && on) { box.click(); } } const item = items.find((el) => labelText(el) === '合同编号'); if (!item) { return {ok: false, error: 'no-合同编号'}; } const box = item.querySelector('label.elPlus-checkbox'); if (box && !box.classList.contains('is-checked')) { box.click(); } const input = Array.from(item.querySelectorAll('input.elPlus-input__inner')).find((el) => { return el.type === 'text' && String(el.placeholder || '').indexOf('请输入') >= 0; }) || item.querySelector('.search-component input.elPlus-input__inner'); if (!input) { return {ok: false, error: 'no-input'}; } input.focus(); input.click(); const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; setter.call(input, no); input.dispatchEvent(new Event('input', {bubbles: true})); input.dispatchEvent(new InputEvent('input', {bubbles: true, data: no, inputType: 'insertText'})); input.dispatchEvent(new Event('change', {bubbles: true})); if (box && !box.classList.contains('is-checked')) { box.click(); } input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true })); return {ok: true, value: input.value, checked: !!(box && box.classList.contains('is-checked'))}; """, number, ) if not (isinstance(filled, dict) and filled.get("ok") and filled.get("value") == number): return False driver = getattr(browser, "_driver", None) if driver is not None: try: from selenium.webdriver.common.keys import Keys el = driver.switch_to.active_element el.send_keys(Keys.CONTROL, "a") el.send_keys(number) el.send_keys(Keys.ENTER) except Exception: pass browser.execute_script( """ /* QUERY_HEADER_ENTER */ function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); } const item = Array.from(document.querySelectorAll('.search-condition-item')).find((el) => { const n = el.querySelector('.search-label__text'); return compact(n) === '合同编号'; }); const panel = (item && item.closest( '.search-condition, .vxe-modal--wrapper, .elPlus-dialog, .el-dialog' )) || document; const hit = Array.from(panel.querySelectorAll('button')).find((el) => { const t = compact(el); return t === '查询 Enter' || t === '查询' || t.indexOf('查询') === 0; }); if (hit) { hit.click(); return {ok: true, via: compact(hit)}; } return {ok: false}; """ ) return True def _search_header_by_contract(browser: BrowserAdapter, number: str) -> bool: """总单工具栏按合同编号搜。不要先点「清除」,会把表刷回全量再查不准。""" if not _open_header_search_panel(browser): return False return _fill_header_contract_and_query(browser, number) def _search_via_query_panel(browser: BrowserAdapter, number: str, label: str) -> bool: opened = browser.execute_script( """ /* OPEN_QUERY_PANEL */ if (document.querySelector('.search-condition-item')) { return {ok: true, already: true}; } const btn = document.querySelector('.search-button'); if (!btn) { return {ok: false}; } btn.click(); return {ok: true}; """ ) if not (isinstance(opened, dict) and opened.get("ok")): return False deadline = time.time() + 8 while time.time() <= deadline: ready = False try: ready = bool( browser.execute_script( "return !!document.querySelector('.search-condition-item')" ) ) except Exception: ready = False if ready: break time.sleep(0.3) filled = browser.execute_script( """ /* FILL_QUERY_FIELD */ const label = String(arguments[0] || ''); const no = String(arguments[1] || ''); function labelText(el) { const n = el.querySelector('.search-label__text'); return ((n && n.innerText) || '').replace(/\\s+/g, '').trim(); } const items = Array.from(document.querySelectorAll('.search-condition-item')); if (!items.length) { return {ok: false}; } const item = items.find((el) => labelText(el) === label) || items.find((el) => (el.innerText || '').indexOf(label) >= 0); if (!item) { return {ok: false}; } const box = item.querySelector('label.elPlus-checkbox'); if (box && !box.classList.contains('is-checked')) { box.click(); } const input = Array.from(item.querySelectorAll('input.elPlus-input__inner')).find((el) => { return el.type === 'text' && String(el.placeholder || '').indexOf('请输入') >= 0; }) || item.querySelector('.search-component input.elPlus-input__inner'); if (!input) { return {ok: false}; } input.focus(); input.click(); const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; setter.call(input, no); input.dispatchEvent(new Event('input', {bubbles: true})); input.dispatchEvent(new Event('change', {bubbles: true})); if (box && !box.classList.contains('is-checked')) { box.click(); } return {ok: true, value: input.value, label, checked: !!(box && box.classList.contains('is-checked'))}; """, label, number, ) if not (isinstance(filled, dict) and filled.get("ok")): return False browser.execute_script( """ const hit = Array.from(document.querySelectorAll('button')).find((el) => { const t = ((el.innerText || '') + '').replace(/\\s+/g, ' ').trim(); return t === '查询 Enter' || t === '查询'; }); if (hit) { hit.click(); return true; } return false; """ ) return True def _wait_search_hit(browser: BrowserAdapter, number: str, *, since: float | None = None) -> bool: deadline = time.time() + 15 started = since or time.time() last_text = "" last_from_table = False last_header: dict | None = None while time.time() <= deadline: _switch_agreement_frame(browser) header = read_header_selection(browser, number) elapsed = time.time() - started if header.get("error") != "script" and "cells" in header: last_header = header cells = [str(c or "") for c in (header.get("cells") or [])] if any(number == c or number in c for c in cells) and elapsed >= 0.6: return True pager = str(header.get("pager") or "").replace(" ", "") if "共0条" in pager and elapsed > 6: return False time.sleep(0.4) continue text, from_table = read_result_table(browser) last_text, last_from_table = text, from_table found = classify_search_result(text, number, from_table=from_table) body = "" try: body = browser.read_body_text() or "" except Exception: body = "" pagers = [int(n) for n in re.findall(r"共\s*(\d+)\s*条", f"{text}\n{body}")] all_zero = bool(pagers) and all(n == 0 for n in pagers) empty = any(marker in (text or "") for marker in EMPTY_MARKERS) and number not in (text or "") header_ready = "协议总单ID" in (text or "") if found and elapsed >= 0.6: return True if pagers and max(pagers) == 1 and elapsed >= 0.8: return True if all_zero and elapsed > 2: return False if empty and not header_ready and elapsed > 2: return False time.sleep(0.4) if last_header is not None: cells = [str(c or "") for c in (last_header.get("cells") or [])] return any(number == c or number in c for c in cells) return classify_search_result(last_text, number, from_table=last_from_table) def search_contract(browser: BrowserAdapter, contract_no: str) -> bool: number = (contract_no or "").strip() if not number: return False _dismiss_menu_autocomplete(browser) _switch_agreement_frame(browser) try: if _search_header_by_contract(browser, number): return _wait_search_hit(browser, number, since=time.time()) except Exception: pass used_panel = False for label in _QUERY_LABELS: try: hit = _search_via_query_panel(browser, number, label) if not hit: time.sleep(0.4) _switch_agreement_frame(browser) hit = _search_via_query_panel(browser, number, label) except Exception: hit = False if not hit: continue used_panel = True if _wait_search_hit(browser, number, since=time.time()): return True if not used_panel: try: browser.fill_first(SEARCH["contract_no"], number) browser.click_first(SEARCH["button"]) return _wait_search_hit(browser, number, since=time.time()) except Exception: return False return False def run_find_flow( browser: BrowserAdapter, ctx: RebateFlowContext, contract_no: str, *, expect_found: bool | None = None, ) -> bool: _ACCEPT.reset() try: open_rebate_center(browser, ctx) _ACCEPT.note("S02", "打开返利中心", {"url": ctx.rebate_url}, ok=True) try: login_rebate_center(browser, ctx) _ACCEPT.note("S03", "登录", {"user": ctx.username}, ok=True) except Exception as exc: evidence_shot(browser, "rebate-login-failed", failed=True) _ACCEPT.note( "S03", "登录", {"user": ctx.username, "error": exc}, ok=False, raise_error=False, ) raise enter_purchase_agreement(browser) _ACCEPT.note("S04", "进入采购协议", {"menu": "采购协议管理"}, ok=True) found = search_contract(browser, contract_no) found_text = "yes" if found else "no" if expect_found is None: _ACCEPT.note( "S05", "查号", {"contractNo": contract_no, "found": found_text}, ok=True, ) else: _ACCEPT.check( "S05", "查号", [ ("contractNo", contract_no, contract_no), ("found", found_text, "yes" if expect_found else "no"), ], browser=browser, screenshot_on_fail="rebate-find-mismatch", ) return found finally: print(_ACCEPT.summary_text(), end="", flush=True) _ACCEPT.dump() HEADER = { "rebate_contract": [ "label=返利合约", '//td[contains(.,"返利合约")]/following-sibling::td[1]//select', '//*[contains(text(),"返利合约")]/following::select[1]', ], "supplier_code": [ "label=供应商编码", 'input[placeholder*="供应商编码"]', 'input[name*="supplier"]', '//td[contains(.,"供应商编码")]/following-sibling::td[1]//input', ], "rebate_unit_code": [ "label=返利单位编码", 'input[placeholder*="返利单位"]', 'input[name*="rebateUnit"]', '//td[contains(.,"返利单位编码")]/following-sibling::td[1]//input', ], "planner": [ "label=计划员", 'input[placeholder*="计划员"]', 'input[name*="planner"]', '//td[contains(.,"计划员")]/following-sibling::td[1]//input', '//td[contains(.,"计划员")]/following-sibling::td[1]//select', ], "settle_mode": [ "label=采购结算方式", '//td[contains(.,"采购结算方式")]/following-sibling::td[1]//select', '//*[contains(text(),"采购结算方式")]/following::select[1]', ], "pay_mode": [ "label=承付模式", '//td[contains(.,"承付模式")]/following-sibling::td[1]//select', '//*[contains(text(),"承付模式")]/following::select[1]', ], "protocol_start": [ "label=协议开始日期", 'input[placeholder*="开始日期"]', '//td[contains(.,"协议开始日期")]/following-sibling::td[1]//input', ], "protocol_end": [ "label=协议结束日期", 'input[placeholder*="结束日期"]', '//td[contains(.,"协议结束日期")]/following-sibling::td[1]//input', ], } SAVE = { "button": [ "text=保存", "button:contains(保存)", "a:contains(保存)", 'input[value="保存"]', "text=保 存", ], } ORDER_NO = { "value": [ "label=总单号", "label=采购协议号", "label=协议编号", "label=采购协议总单ID", "label=协议总单号", 'input[name*="agreementId"]', 'input[name*="rebateOrder"]', ], } _FORM_MARKERS = ("保存", "导入导出", "下载模板") _ORDER_NO_LABEL_RE = re.compile( r"(?:总单号|采购协议号|协议编号|采购协议总单ID|协议总单号|采购协议ID)[::\s]*([A-Za-z0-9_-]{3,})" ) _FL_RE = re.compile(r"\bFL[A-Za-z0-9_-]{4,}\b") def _header_dom_js() -> str: """总单卡片:标签在 .label-wrap,三个点是 span.pointer.f20,下拉是 .elPlus-select__wrapper。""" return """ function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); } function vis(el) { if (!el) return false; const st = window.getComputedStyle(el); if (!st || st.display === 'none' || st.visibility === 'hidden') return false; return !!(el.offsetWidth || el.offsetHeight); } function formItem(title, root) { const wanted = String(title || '').replace(/\\s+/g, '').replace(/\\*/g, ''); const scope = root || document; const items = Array.from(scope.querySelectorAll('.elPlus-form-item, .el-form-item')); let exact = null; let fuzzy = null; for (const item of items) { if (!vis(item)) continue; const label = item.querySelector('.form-item__title, .elPlus-form-item__label, label, .label-wrap'); const t = compact(label || item).replace(/\\s+/g, '').replace(/\\*/g, ''); if (t === wanted) { exact = item; break; } if (!fuzzy && t.indexOf(wanted) >= 0 && t.indexOf(wanted) < 8) fuzzy = item; } return exact || fuzzy; } function detailForm() { return Array.from(document.querySelectorAll( '.elPlus-form, form, .elPlus-drawer, .elPlus-dialog, .vxe-modal--wrapper, .elPlus-card' )).find((el) => { const t = compact(el); return vis(el) && t.indexOf('货品编码') >= 0 && t.indexOf('数量') >= 0; }) || null; } function detailItem(title) { const root = detailForm(); return root ? formItem(title, root) : null; } function clickDots(item) { const live = (el) => vis(el) && !el.closest('.vxe-modal--wrapper'); const append = item && item.querySelector( '.elPlus-input-group--append button, .elPlus-input-group--append .elPlus-button, .elPlus-input-group__append button' ); if (append && live(append)) { append.click(); return 'append'; } const pointer = item && Array.from(item.querySelectorAll('.pointer.f20, span.pointer')).find(live); if (pointer) { pointer.click(); return 'pointer'; } const moreIcon = item && Array.from(item.querySelectorAll('.vxe-icon--more')).find(live); if (moreIcon) { const btn = moreIcon.closest('button, .elPlus-button') || moreIcon; btn.click(); return 'more'; } const more = item && Array.from(item.querySelectorAll('button')).find((el) => { const t = compact(el); return live(el) && (t === '...' || t === '…' || t === '···'); }); if (more) { more.click(); return 'dots-button'; } return ''; } function selectWrap(item) { return item && item.querySelector('.elPlus-select__wrapper, .elPlus-select, .el-select'); } function shownValue(item) { if (!item) return ''; const wrap = selectWrap(item); if (wrap) { const t = compact(wrap).replace(/请选择/g, '').trim(); if (t) return t; } const input = item.querySelector('input.elPlus-input__inner, input.vxe-input--inner, input:not([type="checkbox"])'); return input ? String(input.value || '').trim() : ''; } """ _OPEN_HEADER_ROW = """ /* OPEN_HEADER_ROW */ const no = String(arguments[0] || '').trim(); if (!no) { return {ok: false, error: 'empty'}; } const rows = Array.from(document.querySelectorAll( 'tr, .el-table__row, .ant-table-row, .vxe-body--row, [role="row"]' )); let row = rows.find((r) => { const t = (r.innerText || '').replace(/\\s+/g, ' ').trim(); return t.indexOf(no) >= 0 && t.length < 2000; }); if (!row) { const dataRows = rows.filter((r) => r.querySelector('td, .vxe-body--column, [colid]')); if (dataRows.length === 1) { row = dataRows[0]; } } if (!row) { return {ok: false, error: 'row not found'}; } const link = Array.from(row.querySelectorAll('a, button, span, .vxe-body--column, td')).find((el) => { const t = ((el.innerText || '') + '').replace(/\\s+/g, '').trim(); return t.indexOf(no) >= 0 || t.indexOf('CG-') === 0; }); if (link) { link.click(); link.dispatchEvent(new MouseEvent('dblclick', {bubbles: true, cancelable: true, view: window})); return {ok: true, via: 'link'}; } row.click(); row.dispatchEvent(new MouseEvent('dblclick', {bubbles: true, cancelable: true, view: window})); return {ok: true, via: 'row'}; """ _SET_LABELED = """ /* SET_LABELED */ const labelText = String(arguments[0] || '').trim(); const value = String(arguments[1] || ''); function controlFor(label) { const nodes = Array.from(document.querySelectorAll('label, td, th, span, div')); const node = nodes.find((el) => { const t = (el.childNodes && el.childNodes.length === 1) ? (el.textContent || '').replace(/\\s+/g, '').trim() : (el.innerText || '').replace(/\\s+/g, '').trim(); const compact = t.replace(/[*::]/g, ''); const wanted = label.replace(/\\s+/g, ''); return compact === wanted || compact.indexOf(wanted) === 0; }); if (!node) { return null; } if (node.tagName === 'LABEL' && node.htmlFor) { return document.getElementById(node.htmlFor); } const next = node.nextElementSibling; if (next) { if (['INPUT', 'SELECT', 'TEXTAREA'].indexOf(next.tagName) >= 0) { return next; } const inner = next.querySelector('input, select, textarea'); if (inner) { return inner; } } const cell = node.closest('td, th'); if (cell && cell.nextElementSibling) { return cell.nextElementSibling.querySelector('input, select, textarea'); } return null; } const el = controlFor(labelText); if (!el) { return false; } if (el.tagName === 'SELECT') { const opts = Array.from(el.options || []); const hit = opts.find((o) => { const t = (o.textContent || o.text || '').replace(/\\s+/g, ''); const v = String(o.value || '').replace(/\\s+/g, ''); const wanted = value.replace(/\\s+/g, ''); return t === wanted || v === wanted || t.indexOf(wanted) >= 0; }); if (!hit) { return false; } el.value = hit.value; el.dispatchEvent(new Event('change', {bubbles: true})); return true; } const proto = el.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype; const setter = Object.getOwnPropertyDescriptor(proto, 'value').set; setter.call(el, value); el.dispatchEvent(new Event('input', {bubbles: true})); el.dispatchEvent(new Event('change', {bubbles: true})); el.dispatchEvent(new Event('blur', {bubbles: true})); return true; """ _READ_REBATE_ORDER_NO = """ /* READ_REBATE_ORDER_NO */ const labels = ['总单号', '采购协议号', '协议编号', '采购协议总单ID', '协议总单号', '采购协议ID']; function textOf(el) { return ((el && (el.value || el.textContent || el.innerText)) || '').replace(/\\s+/g, ' ').trim(); } for (const label of labels) { const nodes = Array.from(document.querySelectorAll('label, td, th, span, div')); const node = nodes.find((el) => { const t = (el.innerText || '').replace(/\\s+/g, '').replace(/[*::]/g, ''); return t === label || t.indexOf(label) === 0; }); if (!node) { continue; } let value = ''; if (node.tagName === 'LABEL' && node.htmlFor) { value = textOf(document.getElementById(node.htmlFor)); } const next = node.nextElementSibling; if (!value && next) { const inner = next.querySelector('input, select, textarea, span, a') || next; value = textOf(inner); } const cell = node.closest('td, th'); if (!value && cell && cell.nextElementSibling) { const inner = cell.nextElementSibling.querySelector('input, span, a') || cell.nextElementSibling; value = textOf(inner); } if (value && labels.indexOf(value.replace(/[*::]/g, '')) < 0) { return value; } } const body = (document.body && document.body.innerText) || ''; const m = body.match(/(?:总单号|采购协议号|协议编号|采购协议总单ID|协议总单号)[::\\s]*([A-Za-z0-9_-]{3,})/); return m ? m[1] : ''; """ _OPEN_DROPDOWN = """ /* OPEN_DROPDOWN */ const labelText = String(arguments[0] || '').trim(); const nodes = Array.from(document.querySelectorAll('label, td, th, span, div')); const node = nodes.find((el) => { const t = (el.innerText || '').replace(/\\s+/g, '').replace(/[*::]/g, ''); return t === labelText.replace(/\\s+/g, '') || t.indexOf(labelText.replace(/\\s+/g, '')) === 0; }); if (!node) { return false; } const cell = (node.nextElementSibling) || (node.closest('td, th') && node.closest('td, th').nextElementSibling) || node.parentElement; if (!cell) { return false; } const wrap = cell.querySelector('.el-select, .ant-select, .fs-wrap, .combo, select') || cell; wrap.click(); return true; """ def today_iso(today: str | None = None) -> str: text = (today or "").strip() if text: return text return date.today().isoformat() def year_end_iso(today: str | None = None) -> str: day = today_iso(today) return f"{int(day[:4])}-12-31" def _cell(value: object) -> str: if value is None: return "" return str(value).strip() def _tax_rate_of(raw: object) -> str: text = _cell(raw) return text if text else TAX_RATE_DEFAULT def _untaxed_price(tax_price: str, tax_rate: str) -> str: price = _cell(tax_price) rate = _tax_rate_of(tax_rate) if not price: return "" try: net = Decimal(price) / (Decimal("1") + Decimal(rate)) quantized = net.quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP) text = format(quantized, "f") if "." in text: text = text.rstrip("0").rstrip(".") return text except Exception: return "" def build_header_draft( *, planner_name: str, supplier_code: str, rebate_unit_code: str, today: str | None = None, ) -> RebateHeaderDraft: planner = (planner_name or "").strip() or PLANNER_DEFAULT day = today_iso(today) return RebateHeaderDraft( planner_name=planner, supplier_code=(supplier_code or "").strip(), rebate_unit_code=(rebate_unit_code or "").strip(), rebate_contract=REBATE_CONTRACT_DEFAULT, settle_mode=SETTLE_MODE_DEFAULT, pay_mode=PAY_MODE_NEXT_MONTH_N, protocol_start=day, protocol_end=day, contact_name=CONTACT_DEFAULT, ) def header_draft_from_env(*, today: str | None = None) -> RebateHeaderDraft: return build_header_draft( planner_name=os.getenv("REBATE_PLANNER_NAME") or "", supplier_code=os.getenv("REBATE_SUPPLIER_CODE") or "", rebate_unit_code=os.getenv("REBATE_UNIT_CODE") or "", today=today, ) def header_draft_from_table3( rows: list[dict] | tuple[dict, ...], *, today: str | None = None, ) -> RebateHeaderDraft: """总单取表 3 该合同第一行的供应商 MDM。计划员先选廖建琦,联系人苏文辉。""" packed = [row for row in rows if isinstance(row, dict)] if not packed: raise NodeError("表 3 没有该合同行,不能填总单") first = packed[0] supplier = _cell(first.get("supplier_mdm_code") or first.get("supplier_id")) unit = _cell(first.get("org_code")) draft = build_header_draft( planner_name=PLANNER_DEFAULT, supplier_code=supplier, rebate_unit_code=unit, today=today, ) return RebateHeaderDraft( planner_name=PLANNER_DEFAULT, supplier_code=draft.supplier_code, rebate_unit_code=draft.rebate_unit_code, rebate_contract=draft.rebate_contract, settle_mode=draft.settle_mode, pay_mode=draft.pay_mode, protocol_start=draft.protocol_start, protocol_end=draft.protocol_end, contact_name=CONTACT_DEFAULT, supplier_name=_cell(first.get("supplier_name")), supplier_id=_cell(first.get("supplier_id")), ) def import_lines_from_table3(rows: list[dict] | tuple[dict, ...]) -> tuple[RebateImportLine, ...]: """细单按表 3 货品行:货品ID、数量、含税单价、无税=含税/(1+税率)、备注=omsId。""" lines: list[RebateImportLine] = [] for row in rows: if not isinstance(row, dict): continue goods_id = _cell(row.get("goods_id")) if not goods_id: continue tax_price = _cell(row.get("unit_price") or row.get("tax_price")) tax_rate = _tax_rate_of(row.get("purchase_tax_rate") or row.get("tax_rate")) lines.append( RebateImportLine( goods_code=goods_id, qty=_cell(row.get("qty")), tax_price=tax_price, order_no=_cell(row.get("oms_id") or row.get("order_no")), source_line_id=_cell(row.get("goods_source_id") or row.get("source_line_id")), goods_id=goods_id, tax_rate=tax_rate, net_price=_untaxed_price(tax_price, tax_rate), ) ) if not lines: raise NodeError("表 3 没有货品行,不能加细单") return tuple(lines) def header_draft_for_contract(contract_no: str, *, today: str | None = None) -> RebateHeaderDraft: """优先表 3,空缺再退回环境变量。""" number = (contract_no or "").strip() rows: list[dict] = [] if number: try: from repo import OracleRebateRepo rows = OracleRebateRepo.from_env().load_contract_goods(number) except Exception: rows = [] if rows: return header_draft_from_table3(rows, today=today) return header_draft_from_env(today=today) def import_draft_for_contract(contract_no: str, *, today: str | None = None) -> RebateImportDraft: """优先表 3 货品行,空缺再退回环境变量。""" number = (contract_no or "").strip() rows: list[dict] = [] if number: try: from repo import OracleRebateRepo rows = OracleRebateRepo.from_env().load_contract_goods(number) except Exception: rows = [] if rows: return build_import_draft(lines=import_lines_from_table3(rows), today=today) return import_draft_from_env(today=today) def _header_form_visible(browser: BrowserAdapter) -> bool: body = browser.read_body_text() or "" return any(marker in body for marker in _FORM_MARKERS) def _click_toolbar(browser: BrowserAdapter, title: str) -> bool: try: result = browser.execute_script( """ const title = String(arguments[0] || '').trim(); const btn = Array.from(document.querySelectorAll('.table-toolbar-btn__text, button, span')).find((el) => (el.innerText || '').trim() === title); if (!btn) { return {ok: false}; } btn.click(); return {ok: true}; """, title, ) except Exception: result = None return bool(isinstance(result, dict) and result.get("ok")) def _click_header_toolbar(browser: BrowserAdapter, title: str) -> bool: """只点总单工具栏,避开货品工具栏同名的保存/编辑。""" try: result = browser.execute_script( """ /* CLICK_HEADER_TOOLBAR */ const title = String(arguments[0] || '').trim(); function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); } function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); } const root = document.querySelector('.table-show'); const bar = root && root.querySelector('.table-toolbar'); if (!bar) { return {ok: false, error: 'no-header-bar'}; } const hit = Array.from(bar.querySelectorAll( '.table-toolbar-btn, .table-toolbar-btn__text, button, span' )).find((el) => vis(el) && compact(el) === title); if (!hit) { return {ok: false, error: 'no-btn'}; } const btn = hit.closest('button, .table-toolbar-btn') || hit; btn.click(); return {ok: true, via: compact(hit)}; """, title, ) except Exception: result = None return bool(isinstance(result, dict) and result.get("ok")) def _native_click_header_toolbar(browser: BrowserAdapter, title: str) -> bool: driver = getattr(browser, "_driver", None) if driver is None: return False wanted = (title or "").replace(" ", "") try: from selenium.webdriver.common.action_chains import ActionChains root = None try: root = driver.find_element("css selector", ".table-show .table-toolbar") except Exception: root = None scope = root.find_elements("css selector", "button, .table-toolbar-btn, .table-toolbar-btn__text") if root else [] for el in scope: raw = (el.text or "").replace("\n", "").replace(" ", "") if raw != wanted: continue driver.execute_script("arguments[0].scrollIntoView({block:'center'});", el) ActionChains(driver).move_to_element(el).pause(0.15).click().perform() return True except Exception: return False return False def open_header_form(browser: BrowserAdapter, contract_no: str) -> None: _switch_agreement_frame(browser) _close_detail_card(browser) if not select_header_row(browser, contract_no): result = None try: result = browser.execute_script(_OPEN_HEADER_ROW, contract_no) except Exception: result = None opened = result is True or (isinstance(result, dict) and result.get("ok")) if opened: time.sleep(0.5) _click_header_toolbar(browser, "编辑") or _click_toolbar(browser, "编辑") time.sleep(0.8) if _header_form_visible(browser): return try: dbl = getattr(browser, "double_click_first", None) if callable(dbl): dbl([".vxe-body--row", '.vxe-body--row [colid]', "tr.vxe-body--row"]) except Exception: pass try: browser.click_first( [ f"text={contract_no}", f'//a[contains(.,"{contract_no}")]', f'//td[contains(.,"{contract_no}")]', ] ) except Exception: pass deadline = time.time() + 15 while time.time() <= deadline: if _header_form_visible(browser): return for selectors in ( ["text=编辑"], ["text=修改"], ["button:contains(编辑)"], ["a:contains(编辑)"], ): try: browser.click_first(selectors) break except Exception: continue time.sleep(0.4) if not _header_form_visible(browser): raise RuntimeError("未打开总单表单") def _fill_select(browser: BrowserAdapter, label: str, selectors: list[str], value: str) -> None: try: browser.select_first(selectors, value) return except Exception: pass try: browser.execute_script(_OPEN_DROPDOWN, label) browser.click_first( [ f"text={value}", f'//li[contains(.,"{value}")]', f'//div[contains(@class,"option") and contains(.,"{value}")]', f'//span[contains(.,"{value}")]', ] ) return except Exception: pass ok = False try: ok = bool(browser.execute_script(_SET_LABELED, label, value)) except Exception: ok = False if not ok: raise RuntimeError(f"未选到 {label}={value}") def _fill_input(browser: BrowserAdapter, label: str, selectors: list[str], value: str) -> None: try: browser.fill_first(selectors, value) return except Exception: pass try: browser.select_first(selectors, value) return except Exception: pass ok = False try: ok = bool(browser.execute_script(_SET_LABELED, label, value)) except Exception: ok = False if not ok: raise RuntimeError(f"未填到 {label}={value}") def _type_active(browser: BrowserAdapter, value: str) -> None: driver = getattr(browser, "_driver", None) if driver is None: return try: el = driver.switch_to.active_element driver.execute_script( """ const el = arguments[0]; const value = String(arguments[1] || ''); if (!el) { return false; } el.focus(); const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; setter.call(el, value); el.dispatchEvent(new Event('input', {bubbles: true})); el.dispatchEvent(new InputEvent('input', {bubbles: true, composed: true, data: value})); el.dispatchEvent(new Event('change', {bubbles: true})); return true; """, el, value, ) except Exception: pass def _filter_and_pick_dropdown(browser: BrowserAdapter, value: str, *, exact: bool = True) -> bool: """已打开的下拉:输入筛选后点中。电汇必须精确匹配。""" wanted = (value or "").strip() if not wanted: return False _type_active(browser, wanted) deadline = time.time() + 6 while time.time() <= deadline: picked = browser.execute_script( """ /* PICK_DETAIL_OPTION */ const wanted = String(arguments[0] || '').replace(/\\s+/g, ''); const exact = arguments[1] === true; function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim(); } function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); } function scan(root) { const nodes = Array.from(root.querySelectorAll( '.elPlus-select-dropdown__item, .el-select-dropdown__item, .vxe-select-option, li' )); const hitExact = nodes.find((el) => vis(el) && compact(el) === wanted); if (hitExact) return hitExact; if (exact) return null; return nodes.find((el) => vis(el) && compact(el).indexOf(wanted) >= 0); } const drops = Array.from(document.querySelectorAll( '.elPlus-select__popper, .elPlus-select-dropdown, .el-select-dropdown, .vxe-select--panel' )).filter(vis); const roots = drops.length ? drops : [document]; let opt = null; for (const root of roots) { opt = scan(root); if (opt) break; const wrap = root.querySelector( '.elPlus-scrollbar__wrap, .elPlus-select-dropdown__wrap, .elPlus-vl__window' ); if (!wrap) continue; for (let i = 0; i < 25 && !opt; i++) { wrap.scrollTop = (wrap.scrollTop || 0) + 72; opt = scan(root); } if (opt) break; } if (!opt) { return {ok: false, error: 'no-option'}; } opt.click(); return {ok: true, text: ((opt.innerText || '') + '').trim()}; """, wanted, exact, ) text = "" if isinstance(picked, dict) and picked.get("ok"): text = str(picked.get("text") or "").replace(" ", "") folded = wanted.replace(" ", "") if text == folded: return True if not exact and folded and folded in text: return True time.sleep(0.3) return False def _pick_header_lookup(browser: BrowserAdapter, keyword: str) -> bool: """供应商/计划员编码弹窗:按编码搜,点行里单选再确定。""" code = (keyword or "").strip() if not code: return False filled = browser.execute_script( """ /* FILL_HEADER_LOOKUP */ const no = String(arguments[0] || ''); function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); } function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); } const modal = Array.from(document.querySelectorAll( '.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer' )).find((el) => { if (!vis(el)) return false; const t = compact(el); if (t.indexOf('请填写供应商') >= 0 && t.indexOf('采购协议号') >= 0) return false; if (t.indexOf('货品编码') >= 0 && t.indexOf('数量') >= 0) return false; return t.indexOf('供应商选择') >= 0 || t.indexOf('选择供应商') >= 0 || t.indexOf('客商') >= 0 || t.indexOf('计划员') >= 0 || t.indexOf('供应商') >= 0; }); if (!modal) { return {ok: false, error: 'no-modal'}; } const input = modal.querySelector('input.elPlus-input__inner, input[type="text"], input'); if (!input) { return {ok: false, error: 'no-input'}; } input.click(); input.focus(); const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; setter.call(input, no); input.dispatchEvent(new Event('input', {bubbles: true})); input.dispatchEvent(new Event('change', {bubbles: true})); const query = Array.from(modal.querySelectorAll('button')).find((el) => { const t = compact(el); return vis(el) && (t === '查询' || t.indexOf('查询') === 0); }); if (query) { query.click(); } return {ok: true, value: input.value}; """, code, ) if not (isinstance(filled, dict) and filled.get("ok")): return False _type_active(browser, code) deadline = time.time() + 8 while time.time() <= deadline: picked = browser.execute_script( """ /* PICK_HEADER_LOOKUP */ const keyword = String(arguments[0] || '').replace(/\\s+/g, ''); function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ''); } function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); } const modal = Array.from(document.querySelectorAll( '.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer' )).find((el) => { if (!vis(el)) return false; const t = compact(el); if (t.indexOf('请填写供应商') >= 0 && t.indexOf('采购协议号') >= 0) return false; if (t.indexOf('货品编码') >= 0 && t.indexOf('数量') >= 0) return false; return t.indexOf('供应商选择') >= 0 || t.indexOf('选择供应商') >= 0 || t.indexOf('客商') >= 0 || t.indexOf('计划员') >= 0 || t.indexOf('供应商') >= 0; }); if (!modal) { return {ok: false, error: 'no-modal'}; } const root = modal; const rows = Array.from(root.querySelectorAll('.vxe-body--row, tr, [role="row"]')); for (const row of rows) { const text = compact(row); if (text.indexOf(keyword) < 0) continue; const radio = row.querySelector( '.vxe-cell--radio, .vxe-radio--icon, input[type="radio"], .vxe-cell--checkbox' ); if (radio) { radio.click(); } else { row.click(); } const ok = Array.from(root.querySelectorAll('button')).find((el) => { if (!vis(el)) return false; const t = compact(el); return t === '确定' || t === '确 定'; }); if (ok) { ok.click(); } return {ok: true}; } return {ok: false}; """, code, ) if isinstance(picked, dict) and picked.get("ok"): time.sleep(0.4) return True time.sleep(0.4) return False def _set_grid_field(browser: BrowserAdapter, title: str, value: str, *, as_select: bool = False) -> bool: if not value: return False try: clicked = browser.execute_script( """ /* SET_HEADER_CELL */ const name = String(arguments[0] || '').replace(/\\s+/g, ''); function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim(); } const root = document.querySelector('.table-show') || document; const th = Array.from(root.querySelectorAll('.vxe-header--column')).find((el) => compact(el) === name); if (!th) { return {ok: false}; } const colid = th.getAttribute('colid'); const lefts = Array.from(root.querySelectorAll('.vxe-table--fixed-left-wrapper .vxe-body--row')); let idx = 0; for (let i = 0; i < lefts.length; i++) { const icon = lefts[i].querySelector('.vxe-checkbox--icon'); const cls = String((icon && icon.className) || ''); if (cls.indexOf('checkbox-checked') >= 0 && cls.indexOf('unchecked') < 0) { idx = i; break; } } const mains = Array.from(root.querySelectorAll( '.vxe-table--main-wrapper .vxe-body--row, .vxe-table--body-wrapper .vxe-body--row' )); const row = mains[idx] || mains[0]; const cell = row && row.querySelector('[colid="' + colid + '"]'); if (!cell) { return {ok: false}; } cell.scrollIntoView({block: 'center', inline: 'center'}); cell.click(); cell.dispatchEvent(new MouseEvent('dblclick', {bubbles: true, cancelable: true, view: window})); return {ok: true, colid: colid, idx: idx}; """, title, ) except Exception: clicked = None if not (isinstance(clicked, dict) and clicked.get("ok")): return False time.sleep(0.3) if as_select: return _filter_and_pick_dropdown(browser, value) try: typed = browser.execute_script( """ /* TYPE_HEADER_CELL */ const name = String(arguments[0] || '').replace(/\\s+/g, ''); const value = String(arguments[1] || ''); function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim(); } const root = document.querySelector('.table-show') || document; const th = Array.from(root.querySelectorAll('.vxe-header--column')).find((el) => compact(el) === name); if (!th) { return {ok: false}; } const cell = root.querySelector( '.vxe-table--main-wrapper .vxe-body--row [colid="' + th.getAttribute('colid') + '"], .vxe-body--row [colid="' + th.getAttribute('colid') + '"]' ); if (!cell) { return {ok: false}; } const el = cell.querySelector('input, textarea') || document.activeElement; if (!el || !el.tagName) { return {ok: false}; } el.focus(); const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; setter.call(el, value); el.dispatchEvent(new Event('input', {bubbles: true})); el.dispatchEvent(new Event('change', {bubbles: true})); return {ok: true, value: el.value}; """, title, value, ) except Exception: typed = None if isinstance(typed, dict) and typed.get("ok"): _type_active(browser, value) return True return False def _fill_header_code(browser: BrowserAdapter, title: str, code: str) -> None: if not code: return _set_grid_field(browser, title, code) _pick_header_lookup(browser, code) def _activate_header_cell(browser: BrowserAdapter, title: str) -> bool: if not title: return False try: clicked = browser.execute_script( """ /* SET_HEADER_CELL */ const name = String(arguments[0] || '').replace(/\\s+/g, ''); function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim(); } const root = document.querySelector('.table-show') || document; const th = Array.from(root.querySelectorAll('.vxe-header--column')).find((el) => compact(el) === name); if (!th) { return {ok: false}; } const colid = th.getAttribute('colid'); const lefts = Array.from(root.querySelectorAll('.vxe-table--fixed-left-wrapper .vxe-body--row')); let idx = 0; for (let i = 0; i < lefts.length; i++) { const icon = lefts[i].querySelector('.vxe-checkbox--icon'); const cls = String((icon && icon.className) || ''); if (cls.indexOf('checkbox-checked') >= 0 && cls.indexOf('unchecked') < 0) { idx = i; break; } } const mains = Array.from(root.querySelectorAll( '.vxe-table--main-wrapper .vxe-body--row, .vxe-table--body-wrapper .vxe-body--row' )); const row = mains[idx] || mains[0]; const cell = row && row.querySelector('[colid="' + colid + '"]'); if (!cell) { return {ok: false}; } cell.scrollIntoView({block: 'center', inline: 'center'}); cell.click(); cell.dispatchEvent(new MouseEvent('dblclick', {bubbles: true, cancelable: true, view: window})); return {ok: true, colid: colid, idx: idx}; """, title, ) except Exception: clicked = None return bool(isinstance(clicked, dict) and clicked.get("ok")) def _header_card_open(browser: BrowserAdapter) -> bool: try: hit = browser.execute_script( """ /* HEADER_CARD */ """ + _header_dom_js() + """ const root = document.querySelector('.table-show') || document.body; const t = compact(root); if (t.indexOf('确认 Ctrl') >= 0 || t.indexOf('请填写供应商') >= 0 || t.indexOf('状态 编辑') >= 0 || t.indexOf('状态编辑') >= 0) { return true; } return !!formItem('供应商编码'); """ ) except Exception: hit = False return hit is True def _confirm_header_card(browser: BrowserAdapter) -> bool: """总单卡片「确认 Ctrl + Enter」,不要点货品卡片确认。""" clicked = browser.execute_script( """ /* CONFIRM_HEADER_CARD */ function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); } function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); } const card = Array.from(document.querySelectorAll('.table-card')).find((el) => { const t = compact(el); return vis(el) && t.indexOf('供应商编码') >= 0 && t.indexOf('货品编码') < 0; }); const root = card || document.querySelector('.table-show') || document; const hit = Array.from(root.querySelectorAll('.card-header-btn, button, .elPlus-button')).find((el) => { if (!vis(el)) return false; const t = compact(el).replace(/\\s+/g, ''); return t.indexOf('确认Ctrl') === 0 || t === '确认' || t.indexOf('确认') === 0; }); if (!hit) { return {ok: false, error: 'no-confirm'}; } hit.scrollIntoView({block: 'center', inline: 'nearest'}); hit.click(); return {ok: true, via: compact(hit).slice(0, 40)}; """ ) driver = getattr(browser, "_driver", None) if driver is not None: try: from selenium.webdriver.common.action_chains import ActionChains for el in driver.find_elements("css selector", ".table-card .card-header-btn"): info = driver.execute_script( """ const el = arguments[0]; const card = el.closest('.table-card'); const t = ((card && card.innerText) || ''); return { text: ((el.innerText || '') + '').replace(/\\s+/g, ' ').trim(), header: t.indexOf('供应商编码') >= 0 && t.indexOf('货品编码') < 0 }; """, el, ) if not (isinstance(info, dict) and info.get("header")): continue raw = str(info.get("text") or "") if "确认" not in raw.replace(" ", ""): continue driver.execute_script( "arguments[0].scrollIntoView({block:'center', inline:'nearest'});", el, ) time.sleep(0.2) ActionChains(driver).move_to_element(el).pause(0.15).click().perform() clicked = {"ok": True, "via": "native:" + raw[:40]} break except Exception as exc: print("native_header_confirm_err=", exc, flush=True) print("confirm_header=", clicked, flush=True) if isinstance(clicked, dict) and clicked.get("ok"): time.sleep(0.5) err = _read_detail_toast(browser) if err: raise NodeError(err) return True return False def _wait_person_picker(browser: BrowserAdapter, timeout: float = 8) -> bool: deadline = time.time() + timeout while time.time() <= deadline: if _person_picker_visible(browser): return True time.sleep(0.3) return _person_picker_visible(browser) def _native_click_el(browser: BrowserAdapter, el) -> bool: driver = getattr(browser, "_driver", None) if driver is None or el is None: return False try: from selenium.webdriver.common.action_chains import ActionChains driver.execute_script( "arguments[0].scrollIntoView({block:'center', inline:'center'});", el, ) time.sleep(0.15) ActionChains(driver).move_to_element(el).pause(0.15).click().perform() return True except Exception: return False def _open_labeled_picker(browser: BrowserAdapter, title: str, *, dots_only: bool = False) -> bool: opened = browser.execute_script( """ /* OPEN_LABELED_PICKER */ """ + _header_dom_js() + """ const title = String(arguments[0] || ''); const item = formItem(title); if (!item) { return {ok: false, error: 'no-label'}; } item.scrollIntoView({block: 'center', inline: 'nearest'}); const via = clickDots(item); if (via) { return {ok: true, via: via}; } if (arguments[1] === true) { return {ok: false, error: 'no-dots'}; } const wrap = selectWrap(item); if (wrap) { wrap.click(); return {ok: true, via: 'select'}; } const input = item.querySelector('input.elPlus-input__inner, input'); if (input) { input.click(); input.focus(); return {ok: true, via: 'input'}; } return {ok: false, error: 'no-trigger'}; """, title, dots_only, ) print("open_picker=", opened, flush=True) person = title in {"计划员", "联系人"} if person: if _wait_person_picker(browser, timeout=1.2): return True el = browser.execute_script( """ /* PERSON_PICKER_TRIGGER */ """ + _header_dom_js() + """ const item = formItem(arguments[0]); if (!item) { return null; } return item.querySelector( '.elPlus-input-group--append button, .elPlus-input-group--append .elPlus-button' ) || item.querySelector('.pointer.f20, span.pointer'); """, title, ) native = _native_click_el(browser, el) print("open_picker_native=", native, flush=True) if _wait_person_picker(browser, timeout=8): return True print( "person_picker_missing overlays=", _scan_lookup_overlays(browser), "source=", _source_choice_visible(browser), flush=True, ) return False if not getattr(browser, "_hold_source_choice", False): _ignore_source_choice(browser) return bool(isinstance(opened, dict) and opened.get("ok")) def _read_labeled_input(browser: BrowserAdapter, title: str) -> str: try: result = browser.execute_script( """ /* READ_LABELED_INPUT */ """ + _header_dom_js() + """ const title = String(arguments[0] || ''); const item = formItem(title); return {value: shownValue(item)}; """, title, ) except Exception: result = None if isinstance(result, dict): return str(result.get("value") or "").strip() return "" def _read_required_titles(browser: BrowserAdapter) -> list[str]: try: result = browser.execute_script( """ /* REQUIRED_TITLES */ """ + _header_dom_js() + """ const titles = []; const items = Array.from(document.querySelectorAll('.elPlus-form-item, .el-form-item')).filter(vis); for (const item of items) { const star = Array.from(item.querySelectorAll('*')).some((el) => compact(el) === '*'); const required = star || String(item.className || '').indexOf('is-required') >= 0 || !!(item.querySelector('.form-item__title.required')); if (!required) continue; const label = item.querySelector('.form-item__title, .elPlus-form-item__label, label, .label-wrap'); const t = compact(label || item).replace(/\\*/g, '').replace(/\\s+/g, ''); if (t && titles.indexOf(t) < 0) titles.push(t); } return {titles: titles}; """ ) except Exception: result = None if isinstance(result, dict): return [str(t) for t in (result.get("titles") or []) if t] return [] def _picker_bound(browser: BrowserAdapter, title: str, code: str) -> bool: shown = _read_labeled_input(browser, title) if title == "供应商编码": name = _read_labeled_input(browser, "供应商名称") return bool(name) and "请" not in name[:2] and len(name) >= 4 if title == "返利单位编码": name = _read_labeled_input(browser, "返利单位") return bool(name) and "请" not in name[:2] and len(name) >= 2 if title == "计划员": dept = _read_labeled_input(browser, "计划员部门") if code and shown and code in shown and "请" not in shown[:2]: return True return bool(dept) and "请" not in dept[:2] and len(dept) >= 2 if title == "联系人": cid = _read_labeled_input(browser, "联系人ID") return bool(cid) and "请" not in cid[:2] and len(cid) >= 2 return bool(code) and code in shown and "请填写" not in shown and "请选择" not in shown def _close_overlay_named(browser: BrowserAdapter, title: str) -> None: try: browser.execute_script( """ /* CLOSE_NAMED_OVERLAY */ const title = String(arguments[0] || ''); function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); } function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); } const modal = Array.from(document.querySelectorAll( '.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer, .elPlus-popper' )).find((el) => vis(el) && compact(el).indexOf(title) >= 0); if (!modal) { return {ok: false}; } const hit = Array.from(modal.querySelectorAll('button, .vxe-modal--close-btn, .elPlus-dialog__headerbtn')).find((el) => { const t = compact(el); return vis(el) && (t === '取消' || t === '取消 Esc' || t.indexOf('关闭') >= 0); }); if (hit) { hit.click(); return {ok: true}; } const close = modal.querySelector('.vxe-modal--close-btn, .vxe-icon-close, .elPlus-dialog__headerbtn'); if (close) { close.click(); return {ok: true, via: 'x'}; } return {ok: false}; """, title, ) except Exception: pass time.sleep(0.3) def _scan_lookup_overlays(browser: BrowserAdapter) -> list[str]: try: result = browser.execute_script( """ /* SCAN_LOOKUP_OVERLAYS */ function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); } function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); } return Array.from(document.querySelectorAll('.vxe-modal--wrapper')).filter((el) => { const cls = String(el.className || ''); if (!vis(el) || cls.indexOf('is--visible') < 0) return false; const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title')); return title !== '货源选择'; }).map((el) => { const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title')); return (title || compact(el).slice(0, 40)); }).filter(Boolean).slice(0, 10); """ ) except Exception: result = None if isinstance(result, list): return [str(item) for item in result] return [] def _unit_picker_modal_js() -> str: return """ function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); } function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); } function unitPicker() { return Array.from(document.querySelectorAll('.vxe-modal--wrapper')).find((el) => { const cls = String(el.className || ''); if (!vis(el) || cls.indexOf('is--visible') < 0) return false; const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title')); return title === '返利单位选择' || title === '供应商选择'; }) || null; } function sourcePicker() { return Array.from(document.querySelectorAll('.vxe-modal--wrapper')).find((el) => { const cls = String(el.className || ''); if (!vis(el) || cls.indexOf('is--visible') < 0) return false; const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title')); return title === '货源选择'; }) || null; } function personPicker() { const all = Array.from(document.querySelectorAll('.vxe-modal--wrapper')).filter((el) => { const cls = String(el.className || ''); return vis(el) && cls.indexOf('is--visible') >= 0; }); return all.find((el) => { const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title')); if (title === '返利单位选择' || title === '供应商选择') return false; if (title === '货源选择') return true; if (title.indexOf('计划员') >= 0 || title.indexOf('联系人') >= 0 || title.indexOf('人员') >= 0) { return true; } return Array.from(el.querySelectorAll('input')).some((inp) => { const p = String(inp.placeholder || ''); return p.indexOf('计划员') >= 0 || p.indexOf('联系人') >= 0; }); }) || null; } """ def _send_keys_active(browser: BrowserAdapter, value: str, *, enter: bool = False) -> bool: driver = getattr(browser, "_driver", None) if driver is None: return False try: from selenium.webdriver.common.keys import Keys el = driver.switch_to.active_element el.send_keys(Keys.CONTROL, "a") el.send_keys(Keys.BACKSPACE) if value: el.send_keys(value) if enter: el.send_keys(Keys.ENTER) return True except Exception: return False def _click_unit_clear(browser: BrowserAdapter) -> dict: try: result = browser.execute_script( """ /* CLICK_UNIT_CLEAR */ """ + _unit_picker_modal_js() + """ const modal = unitPicker(); if (!modal) { return {ok: false, error: 'no-modal'}; } const clear = Array.from(modal.querySelectorAll('.search-btn button, button')).find((el) => { return vis(el) && compact(el) === '清除'; }); if (!clear) { return {ok: false, error: 'no-clear'}; } clear.click(); return {ok: true, via: 'clear'}; """ ) except Exception: result = None if isinstance(result, dict): return result return {"ok": False} def _click_unit_query(browser: BrowserAdapter) -> dict: try: result = browser.execute_script( """ /* CLICK_UNIT_QUERY */ """ + _unit_picker_modal_js() + """ const modal = unitPicker(); if (!modal) { return {ok: false, error: 'no-modal'}; } const query = modal.querySelector('.search-btn button.elPlus-button--primary') || Array.from(modal.querySelectorAll('.search-btn button, button')).find((el) => { return vis(el) && compact(el) === '查询'; }); if (!query) { return {ok: false, error: 'no-query'}; } query.click(); return {ok: true, via: 'search-btn', text: compact(query)}; """ ) except Exception: result = None if isinstance(result, dict): return result return {"ok": False} def _wait_unit_picker_rows(browser: BrowserAdapter, keyword: str, timeout: float = 8) -> bool: wanted = (keyword or "").replace(" ", "") deadline = time.time() + timeout last = None empty_hits = 0 while time.time() <= deadline: try: last = browser.execute_script( """ /* UNIT_PICKER_ROWS */ """ + _unit_picker_modal_js() + """ const keyword = String(arguments[0] || '').replace(/\\s+/g, ''); const modal = unitPicker(); if (!modal) { return {ok: false, error: 'no-modal'}; } const rows = Array.from(modal.querySelectorAll('.vxe-body--row')).filter(vis); const match = rows.find((el) => compact(el).replace(/ /g, '').indexOf(keyword) >= 0); return { ok: true, match: !!match, count: rows.length, pager: compact(modal.querySelector('.vxe-pager, .modal-body-footer') || {innerText: ''}).slice(0, 80), row: match ? compact(match).slice(0, 80) : '', }; """, wanted, ) except Exception: last = None print("unit_rows=", last, flush=True) if isinstance(last, dict) and last.get("match"): return True pager = str((last or {}).get("pager") or "").replace(" ", "") if isinstance(last, dict) and "共0条" in pager: empty_hits += 1 if empty_hits >= 3: return False else: empty_hits = 0 time.sleep(0.4) return False def _query_overlay_and_pick(browser: BrowserAdapter, field: str, value: str) -> bool: """返利单位选择弹窗:填编码、回车或点查询后再点确定。""" keyword = (value or "").strip() if not keyword: return False _ignore_source_choice(browser) filled = browser.execute_script( """ /* QUERY_OVERLAY_FIELD */ """ + _unit_picker_modal_js() + """ const field = String(arguments[0] || '').replace(/\\s+/g, ''); const value = String(arguments[1] || ''); const modal = unitPicker(); if (!modal) { return {ok: false, error: 'no-modal'}; } const items = Array.from(modal.querySelectorAll('.elPlus-form-item, .el-form-item, .search-condition-item')); const item = items.find((el) => { const t = compact(el.querySelector('.form-item__title, .search-label__text, label, .label-wrap') || el).replace(/ /g, ''); return t === field || t.indexOf(field) >= 0; }); let input = item && item.querySelector('input.elPlus-input__inner, input'); if (!input) { const labels = Array.from(modal.querySelectorAll('.label-wrap, .form-item__title, .search-label__text, label')); const label = labels.find((el) => compact(el).replace(/\\s+/g, '') === field); let node = label; for (let i = 0; i < 5 && node && !input; i++) { input = node.querySelector('input.elPlus-input__inner, input'); node = node.parentElement; } } if (!input) { input = Array.from(modal.querySelectorAll('input')).find((el) => vis(el) && String(el.placeholder || '').indexOf(field) >= 0); } if (!input) { return {ok: false, error: 'no-input', field: field}; } input.click(); input.focus(); const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; setter.call(input, value); input.dispatchEvent(new Event('input', {bubbles: true})); input.dispatchEvent(new Event('change', {bubbles: true})); return {ok: true, value: String(input.value || ''), via: field, title: compact(modal.querySelector('.vxe-modal--header-title'))}; """, field, keyword, ) typed = _send_keys_active(browser, keyword, enter=True) clicked = _click_unit_query(browser) print("query_unit_picker=", filled, "typed=", typed, "query_click=", clicked, flush=True) if not (isinstance(filled, dict) and filled.get("ok")): return False _wait_unit_picker_rows(browser, keyword) return _click_keyword_in_overlays(browser, keyword) def _click_keyword_in_overlays(browser: BrowserAdapter, keyword: str) -> bool: try: result = browser.execute_script( """ /* CLICK_KEYWORD_OVERLAY */ """ + _unit_picker_modal_js() + """ const keyword = String(arguments[0] || '').replace(/\\s+/g, ''); const modal = unitPicker(); const roots = modal ? [modal] : Array.from(document.querySelectorAll( '.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer, .elPlus-popper, .elPlus-select-dropdown, .vxe-modal--box' )).filter(vis); for (const root of roots) { const title = compact(root.querySelector('.vxe-modal--header-title, .vxe-modal--title')); const t = compact(root); if (title === '货源选择' || t.indexOf('货源选择') === 0) continue; if (t.indexOf('请填写供应商') >= 0 && t.indexOf('采购协议号') >= 0) continue; const row = Array.from(root.querySelectorAll( '.vxe-body--row, tr, .elPlus-select-dropdown__item, li' )).find((el) => vis(el) && compact(el).indexOf(keyword) >= 0); if (!row) continue; const radio = row.querySelector('.vxe-cell--radio, .vxe-radio--icon, input[type="radio"]'); if (radio) { radio.click(); } else { row.click(); } const ok = Array.from(root.querySelectorAll('button')).find((el) => { const x = compact(el); return vis(el) && (x === '确定' || x === '确 定'); }); if (ok) { ok.click(); } return {ok: true, text: compact(row).slice(0, 80)}; } return {ok: false}; """, keyword, ) except Exception: result = None return bool(isinstance(result, dict) and result.get("ok")) def _pick_labeled_code(browser: BrowserAdapter, title: str, code: str) -> None: if not code: return if _picker_bound(browser, title, code): return keywords = [code] _open_labeled_picker(browser, title) time.sleep(0.3) _type_active(browser, code) driver = getattr(browser, "_driver", None) if driver is not None: try: from selenium.webdriver.common.keys import Keys driver.switch_to.active_element.send_keys(Keys.ENTER) except Exception: pass time.sleep(0.8) overlays = _scan_lookup_overlays(browser) print("lookup_overlays=", overlays, "title=", title, "code=", code, flush=True) picked = _filter_and_pick_dropdown(browser, code, exact=False) if not picked: picked = _pick_header_lookup(browser, code) if not picked: picked = _click_keyword_in_overlays(browser, code) if not _picker_bound(browser, title, code): raise NodeError(f"未选到{title}={code},当前={_read_labeled_input(browser, title)}") def _source_choice_visible(browser: BrowserAdapter) -> bool: try: hit = browser.execute_script( """ /* SOURCE_CHOICE */ function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); } function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); } const modal = Array.from(document.querySelectorAll( '.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer, .elPlus-overlay' )).find((el) => { if (!vis(el)) return false; const title = compact(el.querySelector( '.vxe-modal--header-title, .vxe-modal--header, .vxe-modal--title, .elPlus-dialog__title, .el-dialog__title' )); const t = compact(el).slice(0, 40); if (title.indexOf('货源选择') < 0 && t.indexOf('货源选择') !== 0) return false; const planner = Array.from(el.querySelectorAll('input')).some((inp) => { const p = String(inp.placeholder || ''); return p.indexOf('计划员') >= 0 || p.indexOf('联系人') >= 0; }); return !planner; }); return !!modal; """ ) except Exception: hit = False return hit is True def _close_source_choice(browser: BrowserAdapter) -> bool: """看得见的货源选择是 bug 弹层:点关闭,不要确定、不要 Escape。""" try: result = browser.execute_script( """ /* CLOSE_SOURCE_CHOICE */ function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); } function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); } const hits = Array.from(document.querySelectorAll( '.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer, .elPlus-overlay, .vxe-modal--box' )).filter((el) => { if (!vis(el)) return false; const title = compact(el.querySelector( '.vxe-modal--header-title, .vxe-modal--header, .vxe-modal--title, .elPlus-dialog__title, .el-dialog__title' )); const t = compact(el).slice(0, 40); return title.indexOf('货源选择') >= 0 || t.indexOf('货源选择') === 0; }); if (!hits.length) { return {ok: false, via: 'none-visible'}; } let via = ''; for (const modal of hits) { const btn = Array.from(modal.querySelectorAll('button')).find((el) => { const t = compact(el).replace(/ /g, ''); return vis(el) && (t === '关闭' || t === '取消'); }); if (btn) { btn.click(); via = via || 'close'; continue; } const x = modal.querySelector( '.vxe-modal--close-btn, .vxe-icon-close, .elPlus-dialog__headerbtn, .el-dialog__headerbtn' ); if (x) { x.click(); via = via || 'x'; } } return {ok: true, via: via, count: hits.length}; """ ) except Exception: result = None if isinstance(result, dict) and result.get("ok"): time.sleep(0.25) return True return False def _ignore_source_choice(browser: BrowserAdapter) -> None: """全局忽略货源选择 bug 弹层。""" for _ in range(3): if not _source_choice_visible(browser): return _close_source_choice(browser) def _stop_if_source_choice(browser: BrowserAdapter) -> None: _ignore_source_choice(browser) def _pick_nth_overlay_row(browser: BrowserAdapter, nth: int, name: str = "") -> bool: try: result = browser.execute_script( """ /* PICK_NTH_OVERLAY */ const nth = Number(arguments[0] || 0); const name = String(arguments[1] || '').replace(/\\s+/g, ''); function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ''); } function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); } const overlays = Array.from(document.querySelectorAll( '.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer, .elPlus-select-dropdown, .elPlus-popper' )).filter((el) => { if (!vis(el)) return false; const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title') || {innerText:''}); return title !== '货源选择'; }); const roots = overlays.length ? overlays : [document]; for (const root of roots) { const rows = Array.from(root.querySelectorAll( '.vxe-body--row, .elPlus-select-dropdown__item, .el-select-dropdown__item, li' )).filter(vis); let row = name ? rows.find((el) => compact(el).indexOf(name) >= 0) : null; if (!row && nth >= 0 && nth < rows.length) { row = rows[nth]; } if (!row) continue; const radio = row.querySelector('.vxe-cell--radio, .vxe-radio--icon, input[type="radio"]'); if (radio) { radio.click(); } else { row.click(); } const ok = Array.from(root.querySelectorAll('button')).find((el) => { const t = compact(el); return vis(el) && (t === '确定' || t === '确 定'); }); if (ok) { ok.click(); } return {ok: true, text: compact(row).slice(0, 80)}; } return {ok: false}; """, nth, name, ) except Exception: result = None return bool(isinstance(result, dict) and result.get("ok")) def _focus_labeled_input(browser: BrowserAdapter, title: str) -> bool: focused = browser.execute_script( """ /* FOCUS_LABELED_INPUT */ const title = String(arguments[0] || ''); function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); } function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); } const wanted = title.replace(/\\s+/g, ''); const labels = Array.from(document.querySelectorAll( '.form-item__title, .elPlus-form-item__label, label' )).filter((el) => vis(el) && !el.closest('.search-condition-item')); const label = labels.find((el) => compact(el).replace(/\\*/g, '') === wanted); if (!label) { return {ok: false, error: 'no-label'}; } const item = label.closest('.elPlus-form-item, .el-form-item') || label.parentElement; const input = item && item.querySelector('input.elPlus-input__inner, input:not([type="checkbox"])'); if (!input) { return {ok: false, error: 'no-input'}; } input.click(); input.focus(); return {ok: true, ph: input.placeholder || '', val: String(input.value || '')}; """, title, ) print("focus_input=", focused, flush=True) _ignore_source_choice(browser) return bool(isinstance(focused, dict) and focused.get("ok")) def _hold_source_choice(browser: BrowserAdapter, hold: bool) -> None: if hasattr(browser, "_hold_source_choice"): browser._hold_source_choice = hold def _person_picker_visible(browser: BrowserAdapter) -> bool: try: hit = browser.execute_script( """ /* PERSON_PICKER_VISIBLE */ """ + _unit_picker_modal_js() + """ return !!personPicker(); """ ) except Exception: hit = False return hit is True def _close_person_picker(browser: BrowserAdapter) -> None: try: browser.execute_script( """ /* CLOSE_PERSON_PICKER */ """ + _unit_picker_modal_js() + """ const modal = personPicker(); if (!modal) { return {ok: false}; } const btn = Array.from(modal.querySelectorAll('button')).find((el) => { const t = compact(el); return vis(el) && (t === '关闭' || t === '取消' || t.indexOf('关闭') >= 0); }); if (btn) { btn.click(); return {ok: true, via: 'close'}; } const x = modal.querySelector('.vxe-modal--close-btn, .vxe-icon-close, .elPlus-dialog__headerbtn'); if (x) { x.click(); return {ok: true, via: 'x'}; } return {ok: false}; """ ) except Exception: pass time.sleep(0.2) def _query_person_and_pick( browser: BrowserAdapter, field: str, value: str, *, supplier_name: str = "" ) -> bool: """计划员三个点打开「货源选择」:按姓名查,行上还要对上供应商名称。""" keyword = (value or "").strip() if not keyword: return False focused = browser.execute_script( """ /* FOCUS_PERSON_FIELD */ """ + _unit_picker_modal_js() + """ const field = String(arguments[0] || ''); const modal = personPicker(); if (!modal) { return {ok: false, error: 'no-modal'}; } const input = Array.from(modal.querySelectorAll('input')).find((el) => { const ph = String(el.placeholder || ''); return vis(el) && ph === ('请输入' + field); }) || Array.from(modal.querySelectorAll('input')).find((el) => { const ph = String(el.placeholder || ''); return vis(el) && ph.indexOf(field) >= 0 && ph.indexOf('ID') < 0; }); if (!input) { return {ok: false, error: 'no-input'}; } input.click(); input.focus(); const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; setter.call(input, String(arguments[1] || '')); input.dispatchEvent(new Event('input', {bubbles: true})); input.dispatchEvent(new Event('change', {bubbles: true})); return {ok: true, ph: input.placeholder || '', title: compact(modal.querySelector('.vxe-modal--header-title'))}; """, field, keyword, ) typed = _send_keys_active(browser, keyword, enter=True) clicked = browser.execute_script( """ /* CLICK_PERSON_QUERY */ """ + _unit_picker_modal_js() + """ const modal = personPicker(); if (!modal) { return {ok: false, error: 'no-modal'}; } const query = Array.from(modal.querySelectorAll('button')).find((el) => vis(el) && compact(el) === '查询'); if (!query) { return {ok: false, error: 'no-query'}; } query.click(); return {ok: true, via: 'query'}; """ ) print("query_person=", focused, "typed=", typed, "query_click=", clicked, flush=True) if not (isinstance(focused, dict) and focused.get("ok")): return False supplier = (supplier_name or "").replace(" ", "") deadline = time.time() + 12 while time.time() <= deadline: last = browser.execute_script( """ /* PERSON_PICKER_ROWS */ """ + _unit_picker_modal_js() + """ const keyword = String(arguments[0] || '').replace(/\\s+/g, ''); const supplier = String(arguments[1] || '').replace(/\\s+/g, ''); const modal = personPicker(); if (!modal) { return {ok: false, error: 'no-modal'}; } const rows = Array.from(modal.querySelectorAll('.vxe-body--row')).filter(vis); const match = rows.find((el) => { const t = compact(el).replace(/ /g, ''); if (t.indexOf(keyword) < 0) return false; if (supplier && t.indexOf(supplier) < 0) return false; return true; }); return { ok: true, match: !!match, count: rows.length, pager: compact(modal.querySelector('.vxe-pager, .modal-body-footer') || {innerText: ''}).slice(0, 80), row: match ? compact(match).slice(0, 80) : '', }; """, keyword, supplier, ) print("person_rows=", last, flush=True) if isinstance(last, dict) and last.get("match"): break time.sleep(0.4) picked = browser.execute_script( """ /* PICK_PERSON_ROW */ """ + _unit_picker_modal_js() + """ const keyword = String(arguments[0] || '').replace(/\\s+/g, ''); const supplier = String(arguments[1] || '').replace(/\\s+/g, ''); const modal = personPicker(); if (!modal) { return {ok: false, error: 'no-modal'}; } const row = Array.from(modal.querySelectorAll('.vxe-body--row')).find((el) => { const t = compact(el).replace(/ /g, ''); if (!vis(el) || t.indexOf(keyword) < 0) return false; if (supplier && t.indexOf(supplier) < 0) return false; return true; }); if (!row) { return {ok: false, error: 'no-row'}; } const radio = row.querySelector('.vxe-cell--radio, .vxe-radio--icon, input[type="radio"]'); if (radio) { radio.click(); } else { row.click(); } const ok = Array.from(modal.querySelectorAll('button')).find((el) => { const t = compact(el); return vis(el) && (t === '确定' || t === '确 定'); }); if (ok) { ok.click(); } return {ok: true, text: compact(row).slice(0, 120)}; """, keyword, supplier, ) print("pick_person=", picked, flush=True) return bool(isinstance(picked, dict) and picked.get("ok")) def _pick_person( browser: BrowserAdapter, title: str, name: str, *, nth: int | None = None, supplier_name: str = "" ) -> None: wanted = (name or "").strip() if not wanted: return if _picker_bound(browser, title, wanted): return _hold_source_choice(browser, True) try: _close_header_query(browser) deadline = time.time() + 5 while time.time() <= deadline and _supplier_picker_open(browser): time.sleep(0.3) clicked = _open_labeled_picker(browser, title, dots_only=True) print("open_person_dots=", clicked, flush=True) deadline = time.time() + 8 while time.time() <= deadline: if _person_picker_visible(browser): break time.sleep(0.3) if not _person_picker_visible(browser): raise NodeError( f"点三个点未打开选择层 title={title} overlays={_scan_lookup_overlays(browser)}" ) picked = _query_person_and_pick(browser, title, wanted, supplier_name=supplier_name) if not picked and nth is not None: picked = _pick_nth_overlay_row(browser, nth, wanted) if not picked: picked = _click_keyword_in_overlays(browser, wanted) if picked: time.sleep(0.3) if _person_picker_visible(browser): _close_person_picker(browser) pid = _read_labeled_input(browser, f"{title}ID") print(f"bound_{title}=", _read_labeled_input(browser, title), "id=", pid, flush=True) if not _picker_bound(browser, title, wanted): raise NodeError( f"未选到{title}={wanted},当前={_read_labeled_input(browser, title)} id={pid}" ) finally: _hold_source_choice(browser, False) def _supplier_picker_open(browser: BrowserAdapter) -> bool: overlays = _scan_lookup_overlays(browser) return any(item in {"返利单位选择", "供应商选择"} or "返利单位选择" in item for item in overlays) def _pick_supplier_by_mdm(browser: BrowserAdapter, code: str, name: str = "") -> None: value = (code or "").strip() wanted_name = (name or "").strip() if not value and not wanted_name: raise NodeError("表 3 没有 supplier_mdm_code,不能选供应商") if _picker_bound(browser, "供应商编码", value): return _stop_if_source_choice(browser) opened = _supplier_picker_open(browser) if opened: print("lookup_overlays=", _scan_lookup_overlays(browser), "already_open=1", flush=True) else: _close_header_query(browser) clicked = _open_labeled_picker(browser, "供应商编码", dots_only=True) print("open_picker=", clicked, flush=True) deadline = time.time() + 8 while time.time() <= deadline: _stop_if_source_choice(browser) overlays = _scan_lookup_overlays(browser) print("lookup_overlays=", overlays, flush=True) if _supplier_picker_open(browser): opened = True break time.sleep(0.4) if not opened: raise NodeError(f"未打开返利单位选择弹窗 overlays={_scan_lookup_overlays(browser)}") ok = False if wanted_name: ok = _query_overlay_and_pick(browser, "供应商名称", wanted_name) if not ok and value: print("unit_query_name_empty, try_code=", value, flush=True) _click_unit_clear(browser) time.sleep(0.3) ok = _query_overlay_and_pick(browser, "供应商编码", value) if not ok and value: ok = _click_keyword_in_overlays(browser, value) if not ok and wanted_name: ok = _click_keyword_in_overlays(browser, wanted_name) if not _picker_bound(browser, "供应商编码", value): raise NodeError( f"未选到供应商编码={value} 名称={wanted_name},当前={_read_labeled_input(browser, '供应商编码')}" ) def _fill_pay_param(browser: BrowserAdapter, title: str, value: str) -> None: wanted = (value or "").strip() if not wanted: return if not re.fullmatch(r"\d+(\.\d+)?", wanted): print("skip_pay_param_not_number=", wanted, flush=True) return print("fill_pay_param=", wanted, flush=True) marked = browser.execute_script( """ /* FOCUS_PAY_PARAM */ """ + _header_dom_js() + """ const item = formItem(arguments[0]); const input = item && item.querySelector('input.vxe-input--inner, input'); if (!input) { return false; } input.setAttribute('data-rpa-pay', '1'); input.click(); input.focus(); return true; """, title, ) driver = getattr(browser, "_driver", None) if marked and driver is not None: try: from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.keys import Keys el = driver.find_element("css selector", '[data-rpa-pay="1"]') driver.execute_script("arguments[0].removeAttribute('data-rpa-pay')", el) ActionChains(driver).click(el).pause(0.1).key_down(Keys.CONTROL).send_keys("a").key_up( Keys.CONTROL ).send_keys(Keys.BACKSPACE).send_keys(wanted).send_keys(Keys.TAB).perform() time.sleep(0.2) except Exception as exc: print("pay_param_type_err=", exc, flush=True) now = _read_labeled_input(browser, title) print("pay_param_now=", now, flush=True) if (now or "").strip() == wanted: return if not _pick_detail_option(browser, title, wanted): try: _fill_input(browser, title, [f"label={title}"], wanted) except Exception: _set_grid_field(browser, title, wanted) print("pay_param_after=", _read_labeled_input(browser, title), flush=True) def _fill_header_card(browser: BrowserAdapter, draft: RebateHeaderDraft) -> None: _ignore_source_choice(browser) titles = _read_required_titles(browser) print("required=", titles, flush=True) settle = draft.settle_mode if draft.settle_mode and draft.settle_mode != SETTLE_MODE_DEFAULT else SETTLE_MODE_WIRE planner = (draft.planner_name or "").strip() or PLANNER_DEFAULT contact = (draft.contact_name or "").strip() or CONTACT_DEFAULT values = { "供应商编码": draft.supplier_code, "计划员": planner, "返利合约": draft.rebate_contract, "采购结算方式": settle, "承付模式": draft.pay_mode, "承付参数": PAY_PARAM_DEFAULT, "协议开始日期": draft.protocol_start, "协议结束日期": draft.protocol_end, "联系人": contact, } selects = {"返利合约", "采购结算方式", "承付模式"} order = [ "供应商编码", "计划员", "联系人", "返利合约", "采购结算方式", "承付模式", "协议开始日期", "协议结束日期", "承付参数", ] skip = {"供应商名称", "返利单位编码", "返利单位"} raw = titles or list(values.keys()) needed = list(order) + [title for title in raw if title not in order and title not in skip] for title in needed: if title in skip: continue value = values.get(title, "") if not value: continue if title == "供应商编码": _pick_supplier_by_mdm(browser, value, name=draft.supplier_name) elif title == "计划员": _pick_person(browser, "计划员", value, supplier_name=draft.supplier_name) elif title == "联系人": _pick_person(browser, "联系人", value, nth=1) elif title == "承付参数": _fill_pay_param(browser, title, value) elif title in selects: if not _pick_detail_option(browser, title, value): if title == "采购结算方式": _pick_detail_option(browser, title, SETTLE_MODE_WIRE) else: try: _fill_input(browser, title, [f"label={title}"], value) except Exception: _set_grid_field(browser, title, value) print( "card_values=", { "supplier": _read_labeled_input(browser, "供应商编码"), "supplierName": _read_labeled_input(browser, "供应商名称"), "unit": _read_labeled_input(browser, "返利单位编码"), "planner": _read_labeled_input(browser, "计划员"), "contact": _read_labeled_input(browser, "联系人"), "payParam": _read_labeled_input(browser, "承付参数"), }, flush=True, ) if draft.protocol_start: try: _fill_input(browser, "协议开始日期", HEADER["protocol_start"], draft.protocol_start) except Exception: _set_grid_field(browser, "协议开始日期", draft.protocol_start) if draft.protocol_end: try: _fill_input(browser, "协议结束日期", HEADER["protocol_end"], draft.protocol_end) except Exception: _set_grid_field(browser, "协议结束日期", draft.protocol_end) _fill_pay_param(browser, "承付参数", PAY_PARAM_DEFAULT) print( "card_values_before_save=", { "planner": _read_labeled_input(browser, "计划员"), "payParam": _read_labeled_input(browser, "承付参数"), "supplier": _read_labeled_input(browser, "供应商名称"), }, flush=True, ) if (_read_labeled_input(browser, "承付参数") or "").strip() != PAY_PARAM_DEFAULT: raise NodeError("总单承付参数未填成25,不能保存") def _wait_header_card(browser: BrowserAdapter, timeout: float = 8, *, contract_no: str = "") -> bool: if _header_card_open(browser): return True if getattr(browser, "_driver", None) is None: return False deadline = time.time() + timeout while time.time() <= deadline: if contract_no: select_header_row(browser, contract_no) _click_header_toolbar(browser, "编辑") or _click_toolbar(browser, "编辑") time.sleep(0.4) if _header_card_open(browser): return True return False def fill_rebate_header(browser: BrowserAdapter, draft: RebateHeaderDraft, *, contract_no: str = "") -> None: _switch_agreement_frame(browser) _ignore_source_choice(browser) if _wait_header_card(browser, contract_no=contract_no): print("header_card=open", flush=True) _fill_header_card(browser, draft) if not _picker_bound(browser, "供应商编码", draft.supplier_code): raise NodeError("总单供应商仍为空") return grid = _activate_header_cell(browser, "供应商编码") or _activate_header_cell(browser, "承付模式") if grid: _set_grid_field(browser, "承付模式", draft.pay_mode, as_select=True) _set_grid_field(browser, "返利合约", draft.rebate_contract, as_select=True) if not _set_grid_field(browser, "采购结算方式", draft.settle_mode, as_select=True): _set_grid_field(browser, "采购结算方式", SETTLE_MODE_WIRE, as_select=True) _set_grid_field(browser, "承付参数", PAY_PARAM_DEFAULT) _fill_header_code(browser, "供应商编码", draft.supplier_code) _fill_header_code(browser, "计划员", draft.planner_name or PLANNER_DEFAULT) _fill_header_code(browser, "联系人", draft.contact_name or CONTACT_DEFAULT) _set_grid_field(browser, "协议开始日期", draft.protocol_start) _set_grid_field(browser, "协议结束日期", draft.protocol_end) return _fill_select(browser, "返利合约", HEADER["rebate_contract"], draft.rebate_contract) if draft.supplier_code: _fill_input(browser, "供应商编码", HEADER["supplier_code"], draft.supplier_code) _pick_header_lookup(browser, draft.supplier_code) _fill_input(browser, "计划员", HEADER["planner"], draft.planner_name or PLANNER_DEFAULT) try: _fill_select(browser, "采购结算方式", HEADER["settle_mode"], draft.settle_mode) except Exception: _fill_select(browser, "采购结算方式", HEADER["settle_mode"], SETTLE_MODE_WIRE) _fill_select(browser, "承付模式", HEADER["pay_mode"], draft.pay_mode) _fill_input(browser, "协议开始日期", HEADER["protocol_start"], draft.protocol_start) _fill_input(browser, "协议结束日期", HEADER["protocol_end"], draft.protocol_end) def _scan_rebate_order_no(browser: BrowserAdapter, *, contract_no: str = "") -> str: try: text = browser.execute_script( """ /* READ_REBATE_ORDER_NO */ const no = String(arguments[0] || ''); const heads = Array.from(document.querySelectorAll('.vxe-header--column')).map((th) => ({ title: (th.innerText || '').replace(/\\s+/g, '').trim(), colid: th.getAttribute('colid'), })); const rows = Array.from(document.querySelectorAll('.vxe-body--row')); const row = rows.find((r) => !no || (r.innerText || '').indexOf(no) >= 0) || rows[0]; function cell(name) { const h = heads.find((x) => x.title === name); if (!h || !row) { return ''; } const c = row.querySelector('[colid="' + h.colid + '"]'); return c ? (c.innerText || '').replace(/\\s+/g, ' ').trim() : ''; } return cell('协议总单ID') || cell('采购协议总单ID') || cell('采购协议号') || ''; """, contract_no, ) except Exception: text = "" return _clean_order_no(text, contract_no=contract_no) def _clean_order_no(raw: object, *, contract_no: str = "") -> str: text = "" if raw is None else str(raw).strip() if not text: return "" if re.match(r"^\d{4}-\d{2}-\d{2}", text): return "" if text in {"总单号", "采购协议号", "协议编号", "采购协议总单ID", "协议总单号", "协议总单ID"}: return "" return text def save_rebate_header(browser: BrowserAdapter, *, contract_no: str = "") -> str: if _header_card_open(browser) and not _picker_bound(browser, "供应商编码", ""): raise NodeError("总单供应商为空,不能保存") if not _native_click_header_toolbar(browser, "保存"): if not _click_header_toolbar(browser, "保存"): if not _click_toolbar(browser, "保存"): browser.click_first(SAVE["button"]) time.sleep(0.5) err = _read_detail_toast(browser) if err: raise NodeError(err) deadline = time.time() + 15 last = "" while time.time() <= deadline: last = _scan_rebate_order_no(browser, contract_no=contract_no) body = browser.read_body_text() or "" if last or "保存成功" in body: return last time.sleep(0.4) return last def read_rebate_order_no(browser: BrowserAdapter, *, contract_no: str = "") -> str: deadline = time.time() + 15 last = "" while time.time() <= deadline: last = _scan_rebate_order_no(browser, contract_no=contract_no) if last: return last time.sleep(0.4) return last def run_header_flow( browser: BrowserAdapter, ctx: RebateFlowContext, contract_no: str, draft: RebateHeaderDraft | None = None, ) -> str: _HEADER_ACCEPT.reset() header = draft or header_draft_for_contract(contract_no) try: open_rebate_center(browser, ctx) _HEADER_ACCEPT.note("S02", "打开返利中心", {"url": ctx.rebate_url}, ok=True) try: login_rebate_center(browser, ctx) _HEADER_ACCEPT.note("S03", "登录", {"user": ctx.username}, ok=True) except Exception as exc: evidence_shot(browser, "rebate-header-login-failed", failed=True) _HEADER_ACCEPT.note( "S03", "登录", {"user": ctx.username, "error": exc}, ok=False, raise_error=False, ) raise enter_purchase_agreement(browser) _HEADER_ACCEPT.note("S04", "进入采购协议", {"menu": "采购协议管理"}, ok=True) found = search_contract(browser, contract_no) if not found: href = "" try: href = str(browser.execute_script("return location.href") or "") except Exception: href = "" if "ssoLogin" in href or "login" in href.lower() or "wel/index" in href: login_rebate_center(browser, ctx) enter_purchase_agreement(browser) found = search_contract(browser, contract_no) if not found: print("header_selection=", read_header_selection(browser, contract_no), flush=True) _HEADER_ACCEPT.note( "S05", "查号", {"contractNo": contract_no, "found": "yes" if found else "no"}, ok=found, ) if not found: raise NodeError("查号未搜到合同,不能填总单") _close_header_query(browser) picked = select_header_row_result(browser, contract_no) if not (isinstance(picked, dict) and picked.get("ok")): raise NodeError("未按合同编号勾中总单行") open_header_form(browser, contract_no) _HEADER_ACCEPT.note("S06", "打开总单", {"contractNo": contract_no}, ok=True) fill_rebate_header(browser, header, contract_no=contract_no) _HEADER_ACCEPT.note( "S07", "填总单", { "planner": header.planner_name, "payMode": header.pay_mode, "protocolEnd": header.protocol_end, "rebateContract": header.rebate_contract, }, ok=True, ) pay_before = _read_labeled_input(browser, "承付参数") planner_before = _read_labeled_input(browser, "计划员") print("before_save pay_param=", pay_before, "planner=", planner_before, flush=True) saved = save_rebate_header(browser, contract_no=contract_no) _HEADER_ACCEPT.note( "S08", "保存", {"saved": "yes", "payParam": pay_before}, ok=(pay_before or "").strip() == PAY_PARAM_DEFAULT, ) _confirm_header_card(browser) order_no = saved or read_rebate_order_no(browser, contract_no=contract_no) _HEADER_ACCEPT.note( "S09", "读总单号", {"rebateOrderNo": order_no, "payParam": pay_before}, ok=True, raise_error=False, ) return order_no or contract_no finally: print(_HEADER_ACCEPT.summary_text(), end="", flush=True) _HEADER_ACCEPT.dump() IMPORT = { "menu": [ "text=导入导出", "button:contains(导入导出)", "span:contains(导入导出)", "a:contains(导入导出)", ], "download": [ "text=下载模板", "text=下载导入模板", "text=导出模板", "text=导入模板", "span:contains(下载模板)", "a:contains(下载模板)", "button:contains(下载模板)", ], "upload_item": [ "text=导入", "span:contains(导入)", "button:contains(导入)", "a:contains(导入)", ], "file": [ 'input[type="file"]', 'input[accept*="excel"]', 'input[accept*=".xls"]', 'input[accept*="sheet"]', ], "confirm": [ "text=确定", "button:contains(确定)", "text=导入", "button:contains(导入)", ], } IMPORT_HEADERS = ( "货品编码", "数量", "含税协议单价", "税率", "关税税率", "承付模式", "承付参数", "细单开始日期", "细单结束日期", "采购结算方式", "特定补偿预估值", "进货补偿预估值", "细单备注", "货源细单ID", ) _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()