# 返利页面共用:登录、进采购协议、search_contract、勾总单行、弹层/选人/货源选择。 from __future__ import annotations import re import time from dataclasses import dataclass from datetime import date from decimal import Decimal, ROUND_HALF_UP from typing import Protocol from errors import LoginFailed, NodeError FLOW = "REBATE_FIND" HEADER_FLOW = "REBATE_HEADER" IMPORT_FLOW = "REBATE_IMPORT" SUBMIT_FLOW = "REBATE_SUBMIT" DEFAULT_URL = "http://192.168.170.144:8001" SENTINEL_CONTRACT_NO = "__NO_SUCH_CONTRACT__" PAY_MODE_NEXT_MONTH_N = "下月N号结" REBATE_CONTRACT_DEFAULT = "否" SETTLE_MODE_DEFAULT = "否" TAX_RATE_DEFAULT = "0.13" PAY_MODE_CODE = "11" PAY_PARAM_DEFAULT = "25" PAY_PARAM_SALES = "以销售定结" SETTLE_CODE = "3" SETTLE_MODE_WIRE = "电汇" COMPENSATION_DEFAULT = "0" PLANNER_DEFAULT = "廖建琦" CONTACT_DEFAULT = "苏文辉" _PORTAL_MARKERS = ("采购协议", "待审批", "待审批事项", "待办", "菜单名称", "下载打印插件") EMPTY_MARKERS = ("暂无数据", "没有找到", "查询无结果", "无匹配数据", "未找到相关") LOGIN = { "user": [ 'input[name="username"]', 'input[name="userName"]', 'input[placeholder*="用户名"]', 'input[placeholder*="账号"]', 'input[placeholder*="工号"]', 'input[type="text"]', ], "password": [ 'input[name="password"]', 'input[placeholder*="密码"]', 'input[type="password"]', ], "submit": [ "button.login-submit", "button.el-button--primary", "button:contains(登录)", 'button[type="submit"]', 'input[type="submit"]', 'input[value="登录"]', ], } MENU = { "entry": [ "text=采购协议管理", "text=采购协议", "a:contains(采购协议管理)", "span:contains(采购协议管理)", ], } SEARCH = { "contract_no": [ "label=合同编号", "label=合同号", 'input[placeholder*="合同编号"]', 'input[placeholder*="合同号"]', 'input[name*="contractNo"]', 'input[name*="contract"]', ], "button": [ "text=查询", "button:contains(查询)", "a:contains(查询)", "text=搜索", "button:contains(搜索)", ], } _TABLE_SCRIPT = """ const nodes = Array.from(document.querySelectorAll( 'table tbody, table, .el-table__body, .el-table, .ant-table-tbody, .ant-table, .vxe-table, .vxe-body--row' )); return nodes.map((n) => (n.innerText || '').trim()).filter(Boolean).join('\\n'); """ # 返利页面操作口,由 ApaBrowserAdapter 实现。 class BrowserAdapter(Protocol): # 打开指定 URL。 def open_url(self, url: str) -> None: ... # 按选择器列表填第一个匹配到的输入框。 def fill_first(self, selectors: list[str], value: str) -> None: ... # 点第一个匹配到的元素。 def click_first(self, selectors: list[str]) -> None: ... # 等到页面正文出现指定文本。 def wait_for_text(self, text: str, timeout_seconds: int = 15) -> None: ... # 等到选择器列表里有一个元素出现。 def wait_for_element(self, selectors: list[str], timeout_seconds: int = 15) -> None: ... # 选择器列表里是否已有匹配元素。 def element_exists(self, selectors: list[str]) -> bool: ... # 读当前页可见正文。 def read_body_text(self) -> str: ... # 在当前页跑一段脚本并返回结果。 def execute_script(self, script: str, *args): ... # 按名字截一张图,返回保存路径。 def screenshot(self, name: str) -> str: ... # 在第一个匹配到的 select 上选中指定值。 def select_first(self, selectors: list[str], value: str) -> None: ... # 读第一个匹配元素的值或文本。 def read_first(self, selectors: list[str], value_name: str = "") -> str: ... # 把本地文件塞进第一个匹配到的 file input。 def upload_file_first(self, selectors: list[str], file_path: str) -> None: ... # 一次返利页面会话的入参:地址和账密。 @dataclass(frozen=True) class RebateFlowContext: rebate_url: str username: str password: str # 总单卡片要填的字段。计划员现阶段写死廖建琦,联系人苏文辉。 @dataclass(frozen=True) class RebateHeaderDraft: planner_name: str supplier_code: str rebate_unit_code: str rebate_contract: str = REBATE_CONTRACT_DEFAULT settle_mode: str = SETTLE_MODE_DEFAULT pay_mode: str = PAY_MODE_NEXT_MONTH_N protocol_start: str = "" protocol_end: str = "" contact_name: str = "" supplier_name: str = "" supplier_id: str = "" # 一条细单:货品ID、数量、含税单价、订单号。货源细单ID本期不填。 @dataclass(frozen=True) class RebateImportLine: goods_code: str qty: str tax_price: str order_no: str source_line_id: str = "" goods_id: str = "" tax_rate: str = "" net_price: str = "" # 一组细单加上当天日期,供新增节点一次加完。 @dataclass(frozen=True) class RebateImportDraft: lines: tuple[RebateImportLine, ...] today: str = "" # 细单保存后的成功/失败条数。页面新增路径用货品表行数当成功数。 @dataclass(frozen=True) class RebateImportResult: success_count: int fail_count: int fail_reasons: tuple[str, ...] = () parsed: bool = False # 送审结果:是否点到送审,以及马上能见的总单/细单号。 @dataclass(frozen=True) class RebateSubmitResult: submitted: bool agreement_id: str = "" agreement_dtl_id: str = "" # 判断查号结果里是否出现该合同号。 # # from_table 为真时认表正文;为假时要求号至少出现两次,避免把输入框回显当成搜到。空表标记出现且号不在文里则否。 def classify_search_result(result_text: str, contract_no: str, *, from_table: bool = True) -> bool: number = (contract_no or "").strip() if not number: return False text = result_text or "" if from_table: if any(marker in text for marker in EMPTY_MARKERS) and number not in text: return False return number in text if any(marker in text for marker in EMPTY_MARKERS): return False return text.count(number) >= 2 # 读总单表可见行文本。脚本拿不到表时退回整页正文。 def read_result_table(browser: BrowserAdapter) -> tuple[str, bool]: try: text = browser.execute_script(_TABLE_SCRIPT) except Exception: text = None if isinstance(text, str) and text.strip(): return text, True return (browser.read_body_text() or ""), False # 打开返利中心。已在登录页或采购协议页则不再整页刷新。 def open_rebate_center(browser: BrowserAdapter, ctx: RebateFlowContext) -> None: url = "" try: url = str(browser.execute_script("return location.href") or "") except Exception: url = "" if "ssoLogin" in url or "login" in url.lower() or "wel/index" in url: browser.open_url(ctx.rebate_url) return if "procurementAgree" in url: return browser.open_url(ctx.rebate_url) # 页面正文是否已有门户标记,用来跳过重复登录。 def _is_logged_in(browser: BrowserAdapter) -> bool: body = browser.read_body_text() or "" return any(marker in body for marker in _PORTAL_MARKERS) # 填账密点登录,等到进入返利中心。 # # 缺 REBATE_USERNAME / REBATE_PASSWORD 或超时都抛 LoginFailed。已登录则直接返回。 def login_rebate_center(browser: BrowserAdapter, ctx: RebateFlowContext) -> None: try: if _is_logged_in(browser): return if not (ctx.username or "").strip() or ctx.password == "": raise LoginFailed("未配置 REBATE_USERNAME / REBATE_PASSWORD") browser.fill_first(LOGIN["user"], ctx.username) browser.fill_first(LOGIN["password"], ctx.password) clicked = False try: clicked = bool( browser.execute_script( """ const btn = Array.from(document.querySelectorAll('button')).find((el) => { const t = ((el.innerText || el.value || '') + '').replace(/\\s+/g, '').trim(); return t === '登录' && (el.offsetWidth || el.offsetHeight); }); if (!btn) { return false; } btn.click(); return true; """ ) ) except Exception: clicked = False if not clicked: browser.click_first(LOGIN["submit"]) deadline = time.time() + 30 while time.time() < deadline: if _is_logged_in(browser): return time.sleep(0.5) raise TimeoutError("登录后未进入返利中心") except LoginFailed: raise except Exception as exc: raise LoginFailed(f"返利中心登录失败:{exc}") from exc # 切入带「协议总单ID」和查询按钮的 iframe。已在该文档则不动。 def _switch_agreement_frame(browser: BrowserAdapter) -> bool: driver = getattr(browser, "_driver", None) if driver is None: return False # 当前文档是不是采购协议列表:有协议总单或协议号,并且有查询按钮。 def _here() -> bool: try: body = driver.execute_script("return (document.body && document.body.innerText) || ''") or "" has_search = bool(driver.execute_script("return !!document.querySelector('.search-button')")) return ("协议总单ID" in body or "采购协议号" in body) and has_search except Exception: return False if _here(): return True try: driver.switch_to.default_content() if _here(): return True frames = driver.find_elements("css selector", "iframe") except Exception: return False for frame in frames: try: driver.switch_to.default_content() driver.switch_to.frame(frame) if _here(): return True except Exception: continue try: driver.switch_to.default_content() except Exception: pass return _here() # 菜单「采购协议」下拉会挡住总单查询和细单新增,进页后必须关掉。 def _dismiss_menu_autocomplete(browser: BrowserAdapter) -> None: try: switch = getattr(browser, "switch_to_default_content", None) if callable(switch): switch() except Exception: pass try: browser.execute_script( """ /* DISMISS_MENU_AUTOCOMPLETE */ const input = document.querySelector('input[placeholder*="菜单名称"]'); if (input) { input.blur(); } document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true })); const pops = Array.from(document.querySelectorAll( '.el-autocomplete-suggestion, .my-autocomplete, .el-popper.my-autocomplete' )); for (const el of pops) { el.remove(); } return pops.length; """ ) except Exception: pass # 从菜单进采购协议管理。 # # 填「采购协议」后等自动补全再点「采购协议管理」。进页后关掉菜单下拉,避免挡住查询和新增。 def enter_purchase_agreement(browser: BrowserAdapter) -> None: url = "" try: url = str(browser.execute_script("return location.href") or "") except Exception: url = "" body = browser.read_body_text() or "" already = "procurementAgree" in url or "协议总单ID" in body or ( "合同编号" in body and "查询" in body ) if already: _dismiss_menu_autocomplete(browser) _switch_agreement_frame(browser) return try: browser.wait_for_element(['input[placeholder*="菜单名称"]'], timeout_seconds=15) browser.fill_first(['input[placeholder*="菜单名称"]'], "采购协议") try: browser.wait_for_element( [ ".el-autocomplete-suggestion", ".my-autocomplete", ".el-popper.my-autocomplete", ], timeout_seconds=5, ) except Exception: pass except Exception as exc: print(f"菜单搜索框未填:{exc}", flush=True) clicked = False try: clicked = browser.execute_script( """ const sug = document.querySelector( '.el-autocomplete-suggestion, .my-autocomplete, .el-popper.my-autocomplete' ); const scope = sug || document; const hit = Array.from(scope.querySelectorAll('div.name, li[role="option"]')).find((el) => { const t = ((el.innerText || '') + '').replace(/\\s+/g, '').trim(); return (t === '采购协议管理' || t.indexOf('采购协议管理') === 0) && (el.offsetWidth || el.offsetHeight); }); if (!hit) { return {ok: false}; } hit.click(); return {ok: true, cls: (hit.className || '').toString().slice(0, 80)}; """ ) clicked = bool(isinstance(clicked, dict) and clicked.get("ok")) except Exception as exc: print(f"菜单点击失败:{exc}", flush=True) clicked = False if not clicked: browser.click_first(MENU["entry"]) _dismiss_menu_autocomplete(browser) deadline = time.time() + 25 while time.time() <= deadline: href = "" try: href = str(browser.execute_script("return location.href") or "") except Exception: href = "" if "procurementAgree" in href: _dismiss_menu_autocomplete(browser) if _switch_agreement_frame(browser): return time.sleep(0.5) raise TimeoutError("未进入采购协议管理") _QUERY_LABELS = ("合同编号", "采购协议号", "协议总单ID") # 打开总单查询条件。已经露出「合同编号」则不再点。 def _open_header_search_panel(browser: BrowserAdapter) -> bool: opened = browser.execute_script( """ /* OPEN_HEADER_SEARCH */ function labelText(el) { const n = el.querySelector('.search-label__text'); return ((n && n.innerText) || '').replace(/\\s+/g, '').trim(); } const items = Array.from(document.querySelectorAll('.search-condition-item')); const hasContract = items.some((el) => labelText(el) === '合同编号'); if (hasContract) { return {ok: true, already: true}; } const btn = document.querySelector('.table-show .single-table .search-button-content') || document.querySelector('.table-show .search-button-content') || document.querySelector('.search-button-content'); if (!btn) { return {ok: false}; } btn.click(); return {ok: true}; """ ) if not (isinstance(opened, dict) and opened.get("ok")): return False deadline = time.time() + 8 while time.time() <= deadline: ready = False try: ready = bool( browser.execute_script( """ const items = Array.from(document.querySelectorAll('.search-condition-item')); return items.some((el) => { const n = el.querySelector('.search-label__text'); const t = ((n && n.innerText) || '').replace(/\\s+/g, '').trim(); return t === '合同编号'; }); """ ) ) except Exception: ready = False if ready: return True time.sleep(0.3) return False # 点查询面板「清除」。查号路径不要先调这个,会把表刷回全量。 def _clear_header_search(browser: BrowserAdapter) -> None: browser.execute_script( """ /* CLEAR_HEADER_SEARCH */ const hit = Array.from(document.querySelectorAll('button')).find((el) => { const t = ((el.innerText || '') + '').replace(/\\s+/g, ' ').trim(); return t === '清除 Alt+C' || t === '清除'; }); if (hit) { hit.click(); return true; } return false; """ ) time.sleep(0.4) # 只勾「合同编号」、填号、点查询 Enter。其它条件勾掉。 def _fill_header_contract_and_query(browser: BrowserAdapter, number: str) -> bool: filled = browser.execute_script( """ /* FILL_HEADER_CONTRACT */ const no = String(arguments[0] || ''); function labelText(el) { const n = el.querySelector('.search-label__text'); return ((n && n.innerText) || '').replace(/\\s+/g, '').trim(); } const items = Array.from(document.querySelectorAll('.search-condition-item')); for (const el of items) { const box = el.querySelector('label.elPlus-checkbox'); if (!box) continue; const on = box.classList.contains('is-checked'); const mine = labelText(el) === '合同编号'; if (mine && !on) { box.click(); } if (!mine && on) { box.click(); } } const item = items.find((el) => labelText(el) === '合同编号'); if (!item) { return {ok: false, error: 'no-合同编号'}; } const box = item.querySelector('label.elPlus-checkbox'); if (box && !box.classList.contains('is-checked')) { box.click(); } const input = Array.from(item.querySelectorAll('input.elPlus-input__inner')).find((el) => { return el.type === 'text' && String(el.placeholder || '').indexOf('请输入') >= 0; }) || item.querySelector('.search-component input.elPlus-input__inner'); if (!input) { return {ok: false, error: 'no-input'}; } input.focus(); input.click(); const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; setter.call(input, no); input.dispatchEvent(new Event('input', {bubbles: true})); input.dispatchEvent(new InputEvent('input', {bubbles: true, data: no, inputType: 'insertText'})); input.dispatchEvent(new Event('change', {bubbles: true})); if (box && !box.classList.contains('is-checked')) { box.click(); } input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true })); return {ok: true, value: input.value, checked: !!(box && box.classList.contains('is-checked'))}; """, number, ) if not (isinstance(filled, dict) and filled.get("ok") and filled.get("value") == number): return False driver = getattr(browser, "_driver", None) if driver is not None: try: from selenium.webdriver.common.keys import Keys el = driver.switch_to.active_element el.send_keys(Keys.CONTROL, "a") el.send_keys(number) el.send_keys(Keys.ENTER) except Exception: pass browser.execute_script( """ /* QUERY_HEADER_ENTER */ function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); } const item = Array.from(document.querySelectorAll('.search-condition-item')).find((el) => { const n = el.querySelector('.search-label__text'); return compact(n) === '合同编号'; }); const panel = (item && item.closest( '.search-condition, .vxe-modal--wrapper, .elPlus-dialog, .el-dialog' )) || document; const hit = Array.from(panel.querySelectorAll('button')).find((el) => { const t = compact(el); return t === '查询 Enter' || t === '查询' || t.indexOf('查询') === 0; }); if (hit) { hit.click(); return {ok: true, via: compact(hit)}; } return {ok: false}; """ ) return True # 总单工具栏按合同编号搜。不要先点「清除」,会把表刷回全量再查不准。 def _search_header_by_contract(browser: BrowserAdapter, number: str) -> bool: if not _open_header_search_panel(browser): return False return _fill_header_contract_and_query(browser, number) # 打开查询面板,按给定标签填合同号再点查询。 def _search_via_query_panel(browser: BrowserAdapter, number: str, label: str) -> bool: opened = browser.execute_script( """ /* OPEN_QUERY_PANEL */ if (document.querySelector('.search-condition-item')) { return {ok: true, already: true}; } const btn = document.querySelector('.search-button'); if (!btn) { return {ok: false}; } btn.click(); return {ok: true}; """ ) if not (isinstance(opened, dict) and opened.get("ok")): return False deadline = time.time() + 8 while time.time() <= deadline: ready = False try: ready = bool( browser.execute_script( "return !!document.querySelector('.search-condition-item')" ) ) except Exception: ready = False if ready: break time.sleep(0.3) filled = browser.execute_script( """ /* FILL_QUERY_FIELD */ const label = String(arguments[0] || ''); const no = String(arguments[1] || ''); function labelText(el) { const n = el.querySelector('.search-label__text'); return ((n && n.innerText) || '').replace(/\\s+/g, '').trim(); } const items = Array.from(document.querySelectorAll('.search-condition-item')); if (!items.length) { return {ok: false}; } const item = items.find((el) => labelText(el) === label) || items.find((el) => (el.innerText || '').indexOf(label) >= 0); if (!item) { return {ok: false}; } const box = item.querySelector('label.elPlus-checkbox'); if (box && !box.classList.contains('is-checked')) { box.click(); } const input = Array.from(item.querySelectorAll('input.elPlus-input__inner')).find((el) => { return el.type === 'text' && String(el.placeholder || '').indexOf('请输入') >= 0; }) || item.querySelector('.search-component input.elPlus-input__inner'); if (!input) { return {ok: false}; } input.focus(); input.click(); const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; setter.call(input, no); input.dispatchEvent(new Event('input', {bubbles: true})); input.dispatchEvent(new Event('change', {bubbles: true})); if (box && !box.classList.contains('is-checked')) { box.click(); } return {ok: true, value: input.value, label, checked: !!(box && box.classList.contains('is-checked'))}; """, label, number, ) if not (isinstance(filled, dict) and filled.get("ok")): return False browser.execute_script( """ const hit = Array.from(document.querySelectorAll('button')).find((el) => { const t = ((el.innerText || '') + '').replace(/\\s+/g, ' ').trim(); return t === '查询 Enter' || t === '查询'; }); if (hit) { hit.click(); return true; } return false; """ ) return True # 等到总单表出现该合同号,或分页明确共 0 条。输入框回显不算。 def _wait_search_hit(browser: BrowserAdapter, number: str, *, since: float | None = None) -> bool: deadline = time.time() + 15 started = since or time.time() last_text = "" last_from_table = False last_header: dict | None = None while time.time() <= deadline: _switch_agreement_frame(browser) header = read_header_selection(browser, number) elapsed = time.time() - started if header.get("error") != "script" and "cells" in header: last_header = header cells = [str(c or "") for c in (header.get("cells") or [])] if any(number == c or number in c for c in cells) and elapsed >= 0.6: return True pager = str(header.get("pager") or "").replace(" ", "") if "共0条" in pager and elapsed > 6: return False time.sleep(0.4) continue text, from_table = read_result_table(browser) last_text, last_from_table = text, from_table found = classify_search_result(text, number, from_table=from_table) body = "" try: body = browser.read_body_text() or "" except Exception: body = "" pagers = [int(n) for n in re.findall(r"共\s*(\d+)\s*条", f"{text}\n{body}")] all_zero = bool(pagers) and all(n == 0 for n in pagers) empty = any(marker in (text or "") for marker in EMPTY_MARKERS) and number not in (text or "") header_ready = "协议总单ID" in (text or "") if found and elapsed >= 0.6: return True if pagers and max(pagers) == 1 and elapsed >= 0.8: return True if all_zero and elapsed > 2: return False if empty and not header_ready and elapsed > 2: return False time.sleep(0.4) if last_header is not None: cells = [str(c or "") for c in (last_header.get("cells") or [])] return any(number == c or number in c for c in cells) return classify_search_result(last_text, number, from_table=last_from_table) # 按合同号搜总单表。表里出现该号才算搜到。 # # 先走总单工具栏合同编号;不行再试查询面板。不要点查询弹层的「取消」。 def search_contract(browser: BrowserAdapter, contract_no: str) -> bool: number = (contract_no or "").strip() if not number: return False _dismiss_menu_autocomplete(browser) _switch_agreement_frame(browser) try: if _search_header_by_contract(browser, number): return _wait_search_hit(browser, number, since=time.time()) except Exception: pass used_panel = False for label in _QUERY_LABELS: try: hit = _search_via_query_panel(browser, number, label) if not hit: time.sleep(0.4) _switch_agreement_frame(browser) hit = _search_via_query_panel(browser, number, label) except Exception: hit = False if not hit: continue used_panel = True if _wait_search_hit(browser, number, since=time.time()): return True if not used_panel: try: browser.fill_first(SEARCH["contract_no"], number) browser.click_first(SEARCH["button"]) return _wait_search_hit(browser, number, since=time.time()) except Exception: return False return False # 总单卡片:标签在 .label-wrap,三个点是 span.pointer.f20,下拉是 .elPlus-select__wrapper。 def _header_dom_js() -> str: return """ function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); } function vis(el) { if (!el) return false; const st = window.getComputedStyle(el); if (!st || st.display === 'none' || st.visibility === 'hidden') return false; return !!(el.offsetWidth || el.offsetHeight); } function formItem(title, root) { const wanted = String(title || '').replace(/\\s+/g, '').replace(/\\*/g, ''); const scope = root || document; const items = Array.from(scope.querySelectorAll('.elPlus-form-item, .el-form-item')); let exact = null; let fuzzy = null; for (const item of items) { if (!vis(item)) continue; const label = item.querySelector('.form-item__title, .elPlus-form-item__label, label, .label-wrap'); const t = compact(label || item).replace(/\\s+/g, '').replace(/\\*/g, ''); if (t === wanted) { exact = item; break; } if (!fuzzy && t.indexOf(wanted) >= 0 && t.indexOf(wanted) < 8) fuzzy = item; } return exact || fuzzy; } function detailForm() { return Array.from(document.querySelectorAll( '.elPlus-form, form, .elPlus-drawer, .elPlus-dialog, .vxe-modal--wrapper, .elPlus-card' )).find((el) => { const t = compact(el); return vis(el) && t.indexOf('货品编码') >= 0 && t.indexOf('数量') >= 0; }) || null; } function detailItem(title) { const root = detailForm(); return root ? formItem(title, root) : null; } function clickDots(item) { const live = (el) => vis(el) && !el.closest('.vxe-modal--wrapper'); const append = item && item.querySelector( '.elPlus-input-group--append button, .elPlus-input-group--append .elPlus-button, .elPlus-input-group__append button' ); if (append && live(append)) { append.click(); return 'append'; } const pointer = item && Array.from(item.querySelectorAll('.pointer.f20, span.pointer')).find(live); if (pointer) { pointer.click(); return 'pointer'; } const moreIcon = item && Array.from(item.querySelectorAll('.vxe-icon--more')).find(live); if (moreIcon) { const btn = moreIcon.closest('button, .elPlus-button') || moreIcon; btn.click(); return 'more'; } const more = item && Array.from(item.querySelectorAll('button')).find((el) => { const t = compact(el); return live(el) && (t === '...' || t === '…' || t === '···'); }); if (more) { more.click(); return 'dots-button'; } return ''; } function selectWrap(item) { return item && item.querySelector('.elPlus-select__wrapper, .elPlus-select, .el-select'); } function shownValue(item) { if (!item) return ''; const wrap = selectWrap(item); if (wrap) { const t = compact(wrap).replace(/请选择/g, '').trim(); if (t) return t; } const input = item.querySelector('input.elPlus-input__inner, input.vxe-input--inner, input:not([type="checkbox"])'); return input ? String(input.value || '').trim() : ''; } """ _OPEN_HEADER_ROW = """ /* OPEN_HEADER_ROW */ const no = String(arguments[0] || '').trim(); if (!no) { return {ok: false, error: 'empty'}; } const rows = Array.from(document.querySelectorAll( 'tr, .el-table__row, .ant-table-row, .vxe-body--row, [role="row"]' )); let row = rows.find((r) => { const t = (r.innerText || '').replace(/\\s+/g, ' ').trim(); return t.indexOf(no) >= 0 && t.length < 2000; }); if (!row) { const dataRows = rows.filter((r) => r.querySelector('td, .vxe-body--column, [colid]')); if (dataRows.length === 1) { row = dataRows[0]; } } if (!row) { return {ok: false, error: 'row not found'}; } const link = Array.from(row.querySelectorAll('a, button, span, .vxe-body--column, td')).find((el) => { const t = ((el.innerText || '') + '').replace(/\\s+/g, '').trim(); return t.indexOf(no) >= 0 || t.indexOf('CG-') === 0; }); if (link) { link.click(); link.dispatchEvent(new MouseEvent('dblclick', {bubbles: true, cancelable: true, view: window})); return {ok: true, via: 'link'}; } row.click(); row.dispatchEvent(new MouseEvent('dblclick', {bubbles: true, cancelable: true, view: window})); return {ok: true, via: 'row'}; """ # 把日期收成 YYYY-MM-DD。空则用当天。 def today_iso(today: str | None = None) -> str: text = (today or "").strip() if text: return text return date.today().isoformat() # 取该日所在年的 12-31。空则按当天算。 def year_end_iso(today: str | None = None) -> str: day = today_iso(today) return f"{int(day[:4])}-12-31" # 把单元格收成去空白字符串。None 收成空。 def _cell(value: object) -> str: if value is None: return "" return str(value).strip() # 税率文本;空则用默认 0.13。 def _tax_rate_of(raw: object) -> str: text = _cell(raw) return text if text else TAX_RATE_DEFAULT # 无税单价 = 含税 / (1+税率),四舍五入到四位。算不出来返回空。 def _untaxed_price(tax_price: str, tax_rate: str) -> str: price = _cell(tax_price) rate = _tax_rate_of(tax_rate) if not price: return "" try: net = Decimal(price) / (Decimal("1") + Decimal(rate)) quantized = net.quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP) text = format(quantized, "f") if "." in text: text = text.rstrip("0").rstrip(".") return text except Exception: return "" # 按按钮文案点页面工具栏。总单和货品同名时可能点错,优先用带范围的那几个。 def _click_toolbar(browser: BrowserAdapter, title: str) -> bool: try: result = browser.execute_script( """ const title = String(arguments[0] || '').trim(); const btn = Array.from(document.querySelectorAll('.table-toolbar-btn__text, button, span')).find((el) => (el.innerText || '').trim() === title); if (!btn) { return {ok: false}; } btn.click(); return {ok: true}; """, title, ) except Exception: result = None return bool(isinstance(result, dict) and result.get("ok")) # 只点总单工具栏,避开货品工具栏同名的保存/编辑。 def _click_header_toolbar(browser: BrowserAdapter, title: str) -> bool: try: result = browser.execute_script( """ /* CLICK_HEADER_TOOLBAR */ const title = String(arguments[0] || '').trim(); function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); } function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); } const root = document.querySelector('.table-show'); const bar = root && root.querySelector('.table-toolbar'); if (!bar) { return {ok: false, error: 'no-header-bar'}; } const hit = Array.from(bar.querySelectorAll( '.table-toolbar-btn, .table-toolbar-btn__text, button, span' )).find((el) => vis(el) && compact(el) === title); if (!hit) { return {ok: false, error: 'no-btn'}; } const btn = hit.closest('button, .table-toolbar-btn') || hit; btn.click(); return {ok: true, via: compact(hit)}; """, title, ) except Exception: result = None return bool(isinstance(result, dict) and result.get("ok")) # 用 ActionChains 点总单工具栏按钮,避开货品工具栏同名保存。 def _native_click_header_toolbar(browser: BrowserAdapter, title: str) -> bool: driver = getattr(browser, "_driver", None) if driver is None: return False wanted = (title or "").replace(" ", "") try: from selenium.webdriver.common.action_chains import ActionChains root = None try: root = driver.find_element("css selector", ".table-show .table-toolbar") except Exception: root = None scope = root.find_elements("css selector", "button, .table-toolbar-btn, .table-toolbar-btn__text") if root else [] for el in scope: raw = (el.text or "").replace("\n", "").replace(" ", "") if raw != wanted: continue driver.execute_script("arguments[0].scrollIntoView({block:'center'});", el) ActionChains(driver).move_to_element(el).pause(0.15).click().perform() return True except Exception: return False return False # 往当前焦点输入框设值并派发 input/change。没有 driver 则什么都不做。 def _type_active(browser: BrowserAdapter, value: str) -> None: driver = getattr(browser, "_driver", None) if driver is None: return try: el = driver.switch_to.active_element driver.execute_script( """ const el = arguments[0]; const value = String(arguments[1] || ''); if (!el) { return false; } el.focus(); const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; setter.call(el, value); el.dispatchEvent(new Event('input', {bubbles: true})); el.dispatchEvent(new InputEvent('input', {bubbles: true, composed: true, data: value})); el.dispatchEvent(new Event('change', {bubbles: true})); return true; """, el, value, ) except Exception: pass # 已打开的下拉:输入筛选后点中。电汇必须精确匹配。 def _filter_and_pick_dropdown(browser: BrowserAdapter, value: str, *, exact: bool = True) -> bool: wanted = (value or "").strip() if not wanted: return False _type_active(browser, wanted) deadline = time.time() + 6 while time.time() <= deadline: picked = browser.execute_script( """ /* PICK_DETAIL_OPTION */ const wanted = String(arguments[0] || '').replace(/\\s+/g, ''); const exact = arguments[1] === true; function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim(); } function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); } function scan(root) { const nodes = Array.from(root.querySelectorAll( '.elPlus-select-dropdown__item, .el-select-dropdown__item, .vxe-select-option, li' )); const hitExact = nodes.find((el) => vis(el) && compact(el) === wanted); if (hitExact) return hitExact; if (exact) return null; return nodes.find((el) => vis(el) && compact(el).indexOf(wanted) >= 0); } const drops = Array.from(document.querySelectorAll( '.elPlus-select__popper, .elPlus-select-dropdown, .el-select-dropdown, .vxe-select--panel' )).filter(vis); const roots = drops.length ? drops : [document]; let opt = null; for (const root of roots) { opt = scan(root); if (opt) break; const wrap = root.querySelector( '.elPlus-scrollbar__wrap, .elPlus-select-dropdown__wrap, .elPlus-vl__window' ); if (!wrap) continue; for (let i = 0; i < 25 && !opt; i++) { wrap.scrollTop = (wrap.scrollTop || 0) + 72; opt = scan(root); } if (opt) break; } if (!opt) { return {ok: false, error: 'no-option'}; } opt.click(); return {ok: true, text: ((opt.innerText || '') + '').trim()}; """, wanted, exact, ) text = "" if isinstance(picked, dict) and picked.get("ok"): text = str(picked.get("text") or "").replace(" ", "") folded = wanted.replace(" ", "") if text == folded: return True if not exact and folded and folded in text: return True time.sleep(0.3) return False # 供应商/计划员编码弹窗:按编码搜,点行里单选再确定。 def _pick_header_lookup(browser: BrowserAdapter, keyword: str) -> bool: code = (keyword or "").strip() if not code: return False filled = browser.execute_script( """ /* FILL_HEADER_LOOKUP */ const no = String(arguments[0] || ''); function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); } function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); } const modal = Array.from(document.querySelectorAll( '.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer' )).find((el) => { if (!vis(el)) return false; const t = compact(el); if (t.indexOf('请填写供应商') >= 0 && t.indexOf('采购协议号') >= 0) return false; if (t.indexOf('货品编码') >= 0 && t.indexOf('数量') >= 0) return false; return t.indexOf('供应商选择') >= 0 || t.indexOf('选择供应商') >= 0 || t.indexOf('客商') >= 0 || t.indexOf('计划员') >= 0 || t.indexOf('供应商') >= 0; }); if (!modal) { return {ok: false, error: 'no-modal'}; } const input = modal.querySelector('input.elPlus-input__inner, input[type="text"], input'); if (!input) { return {ok: false, error: 'no-input'}; } input.click(); input.focus(); const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; setter.call(input, no); input.dispatchEvent(new Event('input', {bubbles: true})); input.dispatchEvent(new Event('change', {bubbles: true})); const query = Array.from(modal.querySelectorAll('button')).find((el) => { const t = compact(el); return vis(el) && (t === '查询' || t.indexOf('查询') === 0); }); if (query) { query.click(); } return {ok: true, value: input.value}; """, code, ) if not (isinstance(filled, dict) and filled.get("ok")): return False _type_active(browser, code) deadline = time.time() + 8 while time.time() <= deadline: picked = browser.execute_script( """ /* PICK_HEADER_LOOKUP */ const keyword = String(arguments[0] || '').replace(/\\s+/g, ''); function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ''); } function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); } const modal = Array.from(document.querySelectorAll( '.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer' )).find((el) => { if (!vis(el)) return false; const t = compact(el); if (t.indexOf('请填写供应商') >= 0 && t.indexOf('采购协议号') >= 0) return false; if (t.indexOf('货品编码') >= 0 && t.indexOf('数量') >= 0) return false; return t.indexOf('供应商选择') >= 0 || t.indexOf('选择供应商') >= 0 || t.indexOf('客商') >= 0 || t.indexOf('计划员') >= 0 || t.indexOf('供应商') >= 0; }); if (!modal) { return {ok: false, error: 'no-modal'}; } const root = modal; const rows = Array.from(root.querySelectorAll('.vxe-body--row, tr, [role="row"]')); for (const row of rows) { const text = compact(row); if (text.indexOf(keyword) < 0) continue; const radio = row.querySelector( '.vxe-cell--radio, .vxe-radio--icon, input[type="radio"], .vxe-cell--checkbox' ); if (radio) { radio.click(); } else { row.click(); } const ok = Array.from(root.querySelectorAll('button')).find((el) => { if (!vis(el)) return false; const t = compact(el); return t === '确定' || t === '确 定'; }); if (ok) { ok.click(); } return {ok: true}; } return {ok: false}; """, code, ) if isinstance(picked, dict) and picked.get("ok"): time.sleep(0.4) return True time.sleep(0.4) return False # 等到计划员/联系人选择层出现。 def _wait_person_picker(browser: BrowserAdapter, timeout: float = 8) -> bool: deadline = time.time() + timeout while time.time() <= deadline: if _person_picker_visible(browser): return True time.sleep(0.3) return _person_picker_visible(browser) # 把元素滚到视口中央再用 ActionChains 点。 def _native_click_el(browser: BrowserAdapter, el) -> bool: driver = getattr(browser, "_driver", None) if driver is None or el is None: return False try: from selenium.webdriver.common.action_chains import ActionChains driver.execute_script( "arguments[0].scrollIntoView({block:'center', inline:'center'});", el, ) time.sleep(0.15) ActionChains(driver).move_to_element(el).pause(0.15).click().perform() return True except Exception: return False # 点字段旁三个点打开选择层。 # # 计划员、联系人打开的是选人层(标题常写成货源选择),要按住再搜姓名。dots_only 为真时不点下拉,只点三个点。 def _open_labeled_picker(browser: BrowserAdapter, title: str, *, dots_only: bool = False) -> bool: opened = browser.execute_script( """ /* OPEN_LABELED_PICKER */ """ + _header_dom_js() + """ const title = String(arguments[0] || ''); const item = formItem(title); if (!item) { return {ok: false, error: 'no-label'}; } item.scrollIntoView({block: 'center', inline: 'nearest'}); const via = clickDots(item); if (via) { return {ok: true, via: via}; } if (arguments[1] === true) { return {ok: false, error: 'no-dots'}; } const wrap = selectWrap(item); if (wrap) { wrap.click(); return {ok: true, via: 'select'}; } const input = item.querySelector('input.elPlus-input__inner, input'); if (input) { input.click(); input.focus(); return {ok: true, via: 'input'}; } return {ok: false, error: 'no-trigger'}; """, title, dots_only, ) print("open_picker=", opened, flush=True) person = title in {"计划员", "联系人"} if person: if _wait_person_picker(browser, timeout=1.2): return True el = browser.execute_script( """ /* PERSON_PICKER_TRIGGER */ """ + _header_dom_js() + """ const item = formItem(arguments[0]); if (!item) { return null; } return item.querySelector( '.elPlus-input-group--append button, .elPlus-input-group--append .elPlus-button' ) || item.querySelector('.pointer.f20, span.pointer'); """, title, ) native = _native_click_el(browser, el) print("open_picker_native=", native, flush=True) if _wait_person_picker(browser, timeout=8): return True print( "person_picker_missing overlays=", _scan_lookup_overlays(browser), "source=", _source_choice_visible(browser), flush=True, ) return False if not getattr(browser, "_hold_source_choice", False): _ignore_source_choice(browser) return bool(isinstance(opened, dict) and opened.get("ok")) # 读总单卡片上该标签当前显示值。 def _read_labeled_input(browser: BrowserAdapter, title: str) -> str: try: result = browser.execute_script( """ /* READ_LABELED_INPUT */ """ + _header_dom_js() + """ const title = String(arguments[0] || ''); const item = formItem(title); return {value: shownValue(item)}; """, title, ) except Exception: result = None if isinstance(result, dict): return str(result.get("value") or "").strip() return "" # 判断该字段是否已经绑上码。 # # 供应商看名称是否带出;计划员看部门或姓名;联系人看联系人ID。 def _picker_bound(browser: BrowserAdapter, title: str, code: str) -> bool: shown = _read_labeled_input(browser, title) if title == "供应商编码": name = _read_labeled_input(browser, "供应商名称") return bool(name) and "请" not in name[:2] and len(name) >= 4 if title == "返利单位编码": name = _read_labeled_input(browser, "返利单位") return bool(name) and "请" not in name[:2] and len(name) >= 2 if title == "计划员": dept = _read_labeled_input(browser, "计划员部门") if code and shown and code in shown and "请" not in shown[:2]: return True return bool(dept) and "请" not in dept[:2] and len(dept) >= 2 if title == "联系人": cid = _read_labeled_input(browser, "联系人ID") return bool(cid) and "请" not in cid[:2] and len(cid) >= 2 return bool(code) and code in shown and "请填写" not in shown and "请选择" not in shown # 关掉标题含给定字的弹层,点取消或右上角 X。 def _close_overlay_named(browser: BrowserAdapter, title: str) -> None: try: browser.execute_script( """ /* CLOSE_NAMED_OVERLAY */ const title = String(arguments[0] || ''); function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); } function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); } const modal = Array.from(document.querySelectorAll( '.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer, .elPlus-popper' )).find((el) => vis(el) && compact(el).indexOf(title) >= 0); if (!modal) { return {ok: false}; } const hit = Array.from(modal.querySelectorAll('button, .vxe-modal--close-btn, .elPlus-dialog__headerbtn')).find((el) => { const t = compact(el); return vis(el) && (t === '取消' || t === '取消 Esc' || t.indexOf('关闭') >= 0); }); if (hit) { hit.click(); return {ok: true}; } const close = modal.querySelector('.vxe-modal--close-btn, .vxe-icon-close, .elPlus-dialog__headerbtn'); if (close) { close.click(); return {ok: true, via: 'x'}; } return {ok: false}; """, title, ) except Exception: pass time.sleep(0.3) # 列出当前可见的选择弹层标题,排除货源选择 bug 层。 def _scan_lookup_overlays(browser: BrowserAdapter) -> list[str]: try: result = browser.execute_script( """ /* SCAN_LOOKUP_OVERLAYS */ function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); } function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); } return Array.from(document.querySelectorAll('.vxe-modal--wrapper')).filter((el) => { const cls = String(el.className || ''); if (!vis(el) || cls.indexOf('is--visible') < 0) return false; const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title')); return title !== '货源选择'; }).map((el) => { const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title')); return (title || compact(el).slice(0, 40)); }).filter(Boolean).slice(0, 10); """ ) except Exception: result = None if isinstance(result, list): return [str(item) for item in result] return [] # 返回识别返利单位/供应商/选人/货源弹层的一段页面脚本。 def _unit_picker_modal_js() -> str: return """ function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); } function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); } function unitPicker() { return Array.from(document.querySelectorAll('.vxe-modal--wrapper')).find((el) => { const cls = String(el.className || ''); if (!vis(el) || cls.indexOf('is--visible') < 0) return false; const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title')); return title === '返利单位选择' || title === '供应商选择'; }) || null; } function sourcePicker() { return Array.from(document.querySelectorAll('.vxe-modal--wrapper')).find((el) => { const cls = String(el.className || ''); if (!vis(el) || cls.indexOf('is--visible') < 0) return false; const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title')); return title === '货源选择'; }) || null; } function personPicker() { const all = Array.from(document.querySelectorAll('.vxe-modal--wrapper')).filter((el) => { const cls = String(el.className || ''); return vis(el) && cls.indexOf('is--visible') >= 0; }); return all.find((el) => { const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title')); if (title === '返利单位选择' || title === '供应商选择') return false; if (title === '货源选择') return true; if (title.indexOf('计划员') >= 0 || title.indexOf('联系人') >= 0 || title.indexOf('人员') >= 0) { return true; } return Array.from(el.querySelectorAll('input')).some((inp) => { const p = String(inp.placeholder || ''); return p.indexOf('计划员') >= 0 || p.indexOf('联系人') >= 0; }); }) || null; } """ # 对焦点元素 Ctrl+A 后键入。enter 为真再回车。 def _send_keys_active(browser: BrowserAdapter, value: str, *, enter: bool = False) -> bool: driver = getattr(browser, "_driver", None) if driver is None: return False try: from selenium.webdriver.common.keys import Keys el = driver.switch_to.active_element el.send_keys(Keys.CONTROL, "a") el.send_keys(Keys.BACKSPACE) if value: el.send_keys(value) if enter: el.send_keys(Keys.ENTER) return True except Exception: return False # 点返利单位/供应商选择层里的「清除」。 def _click_unit_clear(browser: BrowserAdapter) -> dict: try: result = browser.execute_script( """ /* CLICK_UNIT_CLEAR */ """ + _unit_picker_modal_js() + """ const modal = unitPicker(); if (!modal) { return {ok: false, error: 'no-modal'}; } const clear = Array.from(modal.querySelectorAll('.search-btn button, button')).find((el) => { return vis(el) && compact(el) === '清除'; }); if (!clear) { return {ok: false, error: 'no-clear'}; } clear.click(); return {ok: true, via: 'clear'}; """ ) except Exception: result = None if isinstance(result, dict): return result return {"ok": False} # 点返利单位/供应商选择层里的「查询」。 def _click_unit_query(browser: BrowserAdapter) -> dict: try: result = browser.execute_script( """ /* CLICK_UNIT_QUERY */ """ + _unit_picker_modal_js() + """ const modal = unitPicker(); if (!modal) { return {ok: false, error: 'no-modal'}; } const query = modal.querySelector('.search-btn button.elPlus-button--primary') || Array.from(modal.querySelectorAll('.search-btn button, button')).find((el) => { return vis(el) && compact(el) === '查询'; }); if (!query) { return {ok: false, error: 'no-query'}; } query.click(); return {ok: true, via: 'search-btn', text: compact(query)}; """ ) except Exception: result = None if isinstance(result, dict): return result return {"ok": False} # 等到单位选择层出现含关键字的行,或连续看到共 0 条。 def _wait_unit_picker_rows(browser: BrowserAdapter, keyword: str, timeout: float = 8) -> bool: wanted = (keyword or "").replace(" ", "") deadline = time.time() + timeout last = None empty_hits = 0 while time.time() <= deadline: try: last = browser.execute_script( """ /* UNIT_PICKER_ROWS */ """ + _unit_picker_modal_js() + """ const keyword = String(arguments[0] || '').replace(/\\s+/g, ''); const modal = unitPicker(); if (!modal) { return {ok: false, error: 'no-modal'}; } const rows = Array.from(modal.querySelectorAll('.vxe-body--row')).filter(vis); const match = rows.find((el) => compact(el).replace(/ /g, '').indexOf(keyword) >= 0); return { ok: true, match: !!match, count: rows.length, pager: compact(modal.querySelector('.vxe-pager, .modal-body-footer') || {innerText: ''}).slice(0, 80), row: match ? compact(match).slice(0, 80) : '', }; """, wanted, ) except Exception: last = None print("unit_rows=", last, flush=True) if isinstance(last, dict) and last.get("match"): return True pager = str((last or {}).get("pager") or "").replace(" ", "") if isinstance(last, dict) and "共0条" in pager: empty_hits += 1 if empty_hits >= 3: return False else: empty_hits = 0 time.sleep(0.4) return False # 返利单位选择弹窗:填编码、回车或点查询后再点确定。 def _query_overlay_and_pick(browser: BrowserAdapter, field: str, value: str) -> bool: keyword = (value or "").strip() if not keyword: return False _ignore_source_choice(browser) filled = browser.execute_script( """ /* QUERY_OVERLAY_FIELD */ """ + _unit_picker_modal_js() + """ const field = String(arguments[0] || '').replace(/\\s+/g, ''); const value = String(arguments[1] || ''); const modal = unitPicker(); if (!modal) { return {ok: false, error: 'no-modal'}; } const items = Array.from(modal.querySelectorAll('.elPlus-form-item, .el-form-item, .search-condition-item')); const item = items.find((el) => { const t = compact(el.querySelector('.form-item__title, .search-label__text, label, .label-wrap') || el).replace(/ /g, ''); return t === field || t.indexOf(field) >= 0; }); let input = item && item.querySelector('input.elPlus-input__inner, input'); if (!input) { const labels = Array.from(modal.querySelectorAll('.label-wrap, .form-item__title, .search-label__text, label')); const label = labels.find((el) => compact(el).replace(/\\s+/g, '') === field); let node = label; for (let i = 0; i < 5 && node && !input; i++) { input = node.querySelector('input.elPlus-input__inner, input'); node = node.parentElement; } } if (!input) { input = Array.from(modal.querySelectorAll('input')).find((el) => vis(el) && String(el.placeholder || '').indexOf(field) >= 0); } if (!input) { return {ok: false, error: 'no-input', field: field}; } input.click(); input.focus(); const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; setter.call(input, value); input.dispatchEvent(new Event('input', {bubbles: true})); input.dispatchEvent(new Event('change', {bubbles: true})); return {ok: true, value: String(input.value || ''), via: field, title: compact(modal.querySelector('.vxe-modal--header-title'))}; """, field, keyword, ) typed = _send_keys_active(browser, keyword, enter=True) clicked = _click_unit_query(browser) print("query_unit_picker=", filled, "typed=", typed, "query_click=", clicked, flush=True) if not (isinstance(filled, dict) and filled.get("ok")): return False _wait_unit_picker_rows(browser, keyword) return _click_keyword_in_overlays(browser, keyword) # 在单位/供应商选择层点中含关键字的行,再点确定。跳过货源选择。 def _click_keyword_in_overlays(browser: BrowserAdapter, keyword: str) -> bool: try: result = browser.execute_script( """ /* CLICK_KEYWORD_OVERLAY */ """ + _unit_picker_modal_js() + """ const keyword = String(arguments[0] || '').replace(/\\s+/g, ''); const modal = unitPicker(); const roots = modal ? [modal] : Array.from(document.querySelectorAll( '.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer, .elPlus-popper, .elPlus-select-dropdown, .vxe-modal--box' )).filter(vis); for (const root of roots) { const title = compact(root.querySelector('.vxe-modal--header-title, .vxe-modal--title')); const t = compact(root); if (title === '货源选择' || t.indexOf('货源选择') === 0) continue; if (t.indexOf('请填写供应商') >= 0 && t.indexOf('采购协议号') >= 0) continue; const row = Array.from(root.querySelectorAll( '.vxe-body--row, tr, .elPlus-select-dropdown__item, li' )).find((el) => vis(el) && compact(el).indexOf(keyword) >= 0); if (!row) continue; const radio = row.querySelector('.vxe-cell--radio, .vxe-radio--icon, input[type="radio"]'); if (radio) { radio.click(); } else { row.click(); } const ok = Array.from(root.querySelectorAll('button')).find((el) => { const x = compact(el); return vis(el) && (x === '确定' || x === '确 定'); }); if (ok) { ok.click(); } return {ok: true, text: compact(row).slice(0, 80)}; } return {ok: false}; """, keyword, ) except Exception: result = None return bool(isinstance(result, dict) and result.get("ok")) # 打开标签三个点,按编码搜并绑上。绑不上抛 NodeError。 def _pick_labeled_code(browser: BrowserAdapter, title: str, code: str) -> None: if not code: return if _picker_bound(browser, title, code): return keywords = [code] _open_labeled_picker(browser, title) time.sleep(0.3) _type_active(browser, code) driver = getattr(browser, "_driver", None) if driver is not None: try: from selenium.webdriver.common.keys import Keys driver.switch_to.active_element.send_keys(Keys.ENTER) except Exception: pass time.sleep(0.8) overlays = _scan_lookup_overlays(browser) print("lookup_overlays=", overlays, "title=", title, "code=", code, flush=True) picked = _filter_and_pick_dropdown(browser, code, exact=False) if not picked: picked = _pick_header_lookup(browser, code) if not picked: picked = _click_keyword_in_overlays(browser, code) if not _picker_bound(browser, title, code): raise NodeError(f"未选到{title}={code},当前={_read_labeled_input(browser, title)}") # 是否出现误弹的货源选择层(不是计划员选人层)。 def _source_choice_visible(browser: BrowserAdapter) -> bool: try: hit = browser.execute_script( """ /* SOURCE_CHOICE */ function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); } function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); } const modal = Array.from(document.querySelectorAll( '.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer, .elPlus-overlay' )).find((el) => { if (!vis(el)) return false; const title = compact(el.querySelector( '.vxe-modal--header-title, .vxe-modal--header, .vxe-modal--title, .elPlus-dialog__title, .el-dialog__title' )); const t = compact(el).slice(0, 40); if (title.indexOf('货源选择') < 0 && t.indexOf('货源选择') !== 0) return false; const planner = Array.from(el.querySelectorAll('input')).some((inp) => { const p = String(inp.placeholder || ''); return p.indexOf('计划员') >= 0 || p.indexOf('联系人') >= 0; }); return !planner; }); return !!modal; """ ) except Exception: hit = False return hit is True # 看得见的货源选择是 bug 弹层:点关闭,不要确定、不要 Escape。 def _close_source_choice(browser: BrowserAdapter) -> bool: try: result = browser.execute_script( """ /* CLOSE_SOURCE_CHOICE */ function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); } function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); } const hits = Array.from(document.querySelectorAll( '.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer, .elPlus-overlay, .vxe-modal--box' )).filter((el) => { if (!vis(el)) return false; const title = compact(el.querySelector( '.vxe-modal--header-title, .vxe-modal--header, .vxe-modal--title, .elPlus-dialog__title, .el-dialog__title' )); const t = compact(el).slice(0, 40); return title.indexOf('货源选择') >= 0 || t.indexOf('货源选择') === 0; }); if (!hits.length) { return {ok: false, via: 'none-visible'}; } let via = ''; for (const modal of hits) { const btn = Array.from(modal.querySelectorAll('button')).find((el) => { const t = compact(el).replace(/ /g, ''); return vis(el) && (t === '关闭' || t === '取消'); }); if (btn) { btn.click(); via = via || 'close'; continue; } const x = modal.querySelector( '.vxe-modal--close-btn, .vxe-icon-close, .elPlus-dialog__headerbtn, .el-dialog__headerbtn' ); if (x) { x.click(); via = via || 'x'; } } return {ok: true, via: via, count: hits.length}; """ ) except Exception: result = None if isinstance(result, dict) and result.get("ok"): time.sleep(0.25) return True return False # 全局忽略货源选择 bug 弹层。 def _ignore_source_choice(browser: BrowserAdapter) -> None: for _ in range(3): if not _source_choice_visible(browser): return _close_source_choice(browser) # 看见货源选择 bug 弹层就关掉,不把流程停掉。 def _stop_if_source_choice(browser: BrowserAdapter) -> None: _ignore_source_choice(browser) # 在非货源选择层里点第 n 行,或按姓名命中的那行,再点确定。 def _pick_nth_overlay_row(browser: BrowserAdapter, nth: int, name: str = "") -> bool: try: result = browser.execute_script( """ /* PICK_NTH_OVERLAY */ const nth = Number(arguments[0] || 0); const name = String(arguments[1] || '').replace(/\\s+/g, ''); function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ''); } function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); } const overlays = Array.from(document.querySelectorAll( '.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer, .elPlus-select-dropdown, .elPlus-popper' )).filter((el) => { if (!vis(el)) return false; const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title') || {innerText:''}); return title !== '货源选择'; }); const roots = overlays.length ? overlays : [document]; for (const root of roots) { const rows = Array.from(root.querySelectorAll( '.vxe-body--row, .elPlus-select-dropdown__item, .el-select-dropdown__item, li' )).filter(vis); let row = name ? rows.find((el) => compact(el).indexOf(name) >= 0) : null; if (!row && nth >= 0 && nth < rows.length) { row = rows[nth]; } if (!row) continue; const radio = row.querySelector('.vxe-cell--radio, .vxe-radio--icon, input[type="radio"]'); if (radio) { radio.click(); } else { row.click(); } const ok = Array.from(root.querySelectorAll('button')).find((el) => { const t = compact(el); return vis(el) && (t === '确定' || t === '确 定'); }); if (ok) { ok.click(); } return {ok: true, text: compact(row).slice(0, 80)}; } return {ok: false}; """, nth, name, ) except Exception: result = None return bool(isinstance(result, dict) and result.get("ok")) # 点击并聚焦总单卡片上该标签的输入框。 def _focus_labeled_input(browser: BrowserAdapter, title: str) -> bool: focused = browser.execute_script( """ /* FOCUS_LABELED_INPUT */ const title = String(arguments[0] || ''); function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); } function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); } const wanted = title.replace(/\\s+/g, ''); const labels = Array.from(document.querySelectorAll( '.form-item__title, .elPlus-form-item__label, label' )).filter((el) => vis(el) && !el.closest('.search-condition-item')); const label = labels.find((el) => compact(el).replace(/\\*/g, '') === wanted); if (!label) { return {ok: false, error: 'no-label'}; } const item = label.closest('.elPlus-form-item, .el-form-item') || label.parentElement; const input = item && item.querySelector('input.elPlus-input__inner, input:not([type="checkbox"])'); if (!input) { return {ok: false, error: 'no-input'}; } input.click(); input.focus(); return {ok: true, ph: input.placeholder || '', val: String(input.value || '')}; """, title, ) print("focus_input=", focused, flush=True) _ignore_source_choice(browser) return bool(isinstance(focused, dict) and focused.get("ok")) # 暂时禁止自动关货源层,好让计划员三个点打开的选人层按住。 def _hold_source_choice(browser: BrowserAdapter, hold: bool) -> None: if hasattr(browser, "_hold_source_choice"): browser._hold_source_choice = hold # 计划员/联系人选择层是否可见。 def _person_picker_visible(browser: BrowserAdapter) -> bool: try: hit = browser.execute_script( """ /* PERSON_PICKER_VISIBLE */ """ + _unit_picker_modal_js() + """ return !!personPicker(); """ ) except Exception: hit = False return hit is True # 关掉计划员/联系人选择层。 def _close_person_picker(browser: BrowserAdapter) -> None: try: browser.execute_script( """ /* CLOSE_PERSON_PICKER */ """ + _unit_picker_modal_js() + """ const modal = personPicker(); if (!modal) { return {ok: false}; } const btn = Array.from(modal.querySelectorAll('button')).find((el) => { const t = compact(el); return vis(el) && (t === '关闭' || t === '取消' || t.indexOf('关闭') >= 0); }); if (btn) { btn.click(); return {ok: true, via: 'close'}; } const x = modal.querySelector('.vxe-modal--close-btn, .vxe-icon-close, .elPlus-dialog__headerbtn'); if (x) { x.click(); return {ok: true, via: 'x'}; } return {ok: false}; """ ) except Exception: pass time.sleep(0.2) # 计划员三个点打开「货源选择」:按姓名查,行上还要对上供应商名称。 def _query_person_and_pick( browser: BrowserAdapter, field: str, value: str, *, supplier_name: str = "" ) -> bool: keyword = (value or "").strip() if not keyword: return False focused = browser.execute_script( """ /* FOCUS_PERSON_FIELD */ """ + _unit_picker_modal_js() + """ const field = String(arguments[0] || ''); const modal = personPicker(); if (!modal) { return {ok: false, error: 'no-modal'}; } const input = Array.from(modal.querySelectorAll('input')).find((el) => { const ph = String(el.placeholder || ''); return vis(el) && ph === ('请输入' + field); }) || Array.from(modal.querySelectorAll('input')).find((el) => { const ph = String(el.placeholder || ''); return vis(el) && ph.indexOf(field) >= 0 && ph.indexOf('ID') < 0; }); if (!input) { return {ok: false, error: 'no-input'}; } input.click(); input.focus(); const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; setter.call(input, String(arguments[1] || '')); input.dispatchEvent(new Event('input', {bubbles: true})); input.dispatchEvent(new Event('change', {bubbles: true})); return {ok: true, ph: input.placeholder || '', title: compact(modal.querySelector('.vxe-modal--header-title'))}; """, field, keyword, ) typed = _send_keys_active(browser, keyword, enter=True) clicked = browser.execute_script( """ /* CLICK_PERSON_QUERY */ """ + _unit_picker_modal_js() + """ const modal = personPicker(); if (!modal) { return {ok: false, error: 'no-modal'}; } const query = Array.from(modal.querySelectorAll('button')).find((el) => vis(el) && compact(el) === '查询'); if (!query) { return {ok: false, error: 'no-query'}; } query.click(); return {ok: true, via: 'query'}; """ ) print("query_person=", focused, "typed=", typed, "query_click=", clicked, flush=True) if not (isinstance(focused, dict) and focused.get("ok")): return False supplier = (supplier_name or "").replace(" ", "") deadline = time.time() + 12 while time.time() <= deadline: last = browser.execute_script( """ /* PERSON_PICKER_ROWS */ """ + _unit_picker_modal_js() + """ const keyword = String(arguments[0] || '').replace(/\\s+/g, ''); const supplier = String(arguments[1] || '').replace(/\\s+/g, ''); const modal = personPicker(); if (!modal) { return {ok: false, error: 'no-modal'}; } const rows = Array.from(modal.querySelectorAll('.vxe-body--row')).filter(vis); const match = rows.find((el) => { const t = compact(el).replace(/ /g, ''); if (t.indexOf(keyword) < 0) return false; if (supplier && t.indexOf(supplier) < 0) return false; return true; }); return { ok: true, match: !!match, count: rows.length, pager: compact(modal.querySelector('.vxe-pager, .modal-body-footer') || {innerText: ''}).slice(0, 80), row: match ? compact(match).slice(0, 80) : '', }; """, keyword, supplier, ) print("person_rows=", last, flush=True) if isinstance(last, dict) and last.get("match"): break time.sleep(0.4) picked = browser.execute_script( """ /* PICK_PERSON_ROW */ """ + _unit_picker_modal_js() + """ const keyword = String(arguments[0] || '').replace(/\\s+/g, ''); const supplier = String(arguments[1] || '').replace(/\\s+/g, ''); const modal = personPicker(); if (!modal) { return {ok: false, error: 'no-modal'}; } const row = Array.from(modal.querySelectorAll('.vxe-body--row')).find((el) => { const t = compact(el).replace(/ /g, ''); if (!vis(el) || t.indexOf(keyword) < 0) return false; if (supplier && t.indexOf(supplier) < 0) return false; return true; }); if (!row) { return {ok: false, error: 'no-row'}; } const radio = row.querySelector('.vxe-cell--radio, .vxe-radio--icon, input[type="radio"]'); if (radio) { radio.click(); } else { row.click(); } const ok = Array.from(modal.querySelectorAll('button')).find((el) => { const t = compact(el); return vis(el) && (t === '确定' || t === '确 定'); }); if (ok) { ok.click(); } return {ok: true, text: compact(row).slice(0, 120)}; """, keyword, supplier, ) print("pick_person=", picked, flush=True) return bool(isinstance(picked, dict) and picked.get("ok")) # 点三个点打开选人层,按姓名搜并对上供应商名称。 # # 计划员选廖建琦,联系人选苏文辉。适配器默认关货源层时这里必须先 hold。选不上抛 NodeError。 def _pick_person( browser: BrowserAdapter, title: str, name: str, *, nth: int | None = None, supplier_name: str = "" ) -> None: wanted = (name or "").strip() if not wanted: return if _picker_bound(browser, title, wanted): return _hold_source_choice(browser, True) try: _close_header_query(browser) deadline = time.time() + 5 while time.time() <= deadline and _supplier_picker_open(browser): time.sleep(0.3) clicked = _open_labeled_picker(browser, title, dots_only=True) print("open_person_dots=", clicked, flush=True) deadline = time.time() + 8 while time.time() <= deadline: if _person_picker_visible(browser): break time.sleep(0.3) if not _person_picker_visible(browser): raise NodeError( f"点三个点未打开选择层 title={title} overlays={_scan_lookup_overlays(browser)}" ) picked = _query_person_and_pick(browser, title, wanted, supplier_name=supplier_name) if not picked and nth is not None: picked = _pick_nth_overlay_row(browser, nth, wanted) if not picked: picked = _click_keyword_in_overlays(browser, wanted) if picked: time.sleep(0.3) if _person_picker_visible(browser): _close_person_picker(browser) pid = _read_labeled_input(browser, f"{title}ID") print(f"bound_{title}=", _read_labeled_input(browser, title), "id=", pid, flush=True) if not _picker_bound(browser, title, wanted): raise NodeError( f"未选到{title}={wanted},当前={_read_labeled_input(browser, title)} id={pid}" ) finally: _hold_source_choice(browser, False) # 返利单位选择或供应商选择层是否开着。 def _supplier_picker_open(browser: BrowserAdapter) -> bool: overlays = _scan_lookup_overlays(browser) return any(item in {"返利单位选择", "供应商选择"} or "返利单位选择" in item for item in overlays) # 从总单表当前行读协议总单ID。没有则空字符串。 def _scan_rebate_order_no(browser: BrowserAdapter, *, contract_no: str = "") -> str: try: text = browser.execute_script( """ /* READ_REBATE_ORDER_NO */ const no = String(arguments[0] || ''); const heads = Array.from(document.querySelectorAll('.vxe-header--column')).map((th) => ({ title: (th.innerText || '').replace(/\\s+/g, '').trim(), colid: th.getAttribute('colid'), })); const rows = Array.from(document.querySelectorAll('.vxe-body--row')); const row = rows.find((r) => !no || (r.innerText || '').indexOf(no) >= 0) || rows[0]; function cell(name) { const h = heads.find((x) => x.title === name); if (!h || !row) { return ''; } const c = row.querySelector('[colid="' + h.colid + '"]'); return c ? (c.innerText || '').replace(/\\s+/g, ' ').trim() : ''; } return cell('协议总单ID') || cell('采购协议总单ID') || cell('采购协议号') || ''; """, contract_no, ) except Exception: text = "" return _clean_order_no(text, contract_no=contract_no) # 丢掉日期、列名占位和空值,留下像协议号的文本。 def _clean_order_no(raw: object, *, contract_no: str = "") -> str: text = "" if raw is None else str(raw).strip() if not text: return "" if re.match(r"^\d{4}-\d{2}-\d{2}", text): return "" if text in {"总单号", "采购协议号", "协议编号", "采购协议总单ID", "协议总单号", "协议总单ID"}: return "" return text # 先按工具栏文案点,点不到再按 text=/contains 选择器点。 def _click_text(browser: BrowserAdapter, title: str) -> bool: if _click_toolbar(browser, title): return True try: browser.click_first( [ f"text={title}", f"button:contains({title})", f"span:contains({title})", f"a:contains({title})", f'//li[contains(.,"{title}")]', ] ) return True except Exception: return False _SELECT_HEADER_ROW_JS = """ /* SELECT_HEADER_ROW */ const no = String(arguments[0] || '').trim().replace(/\\s+/g, ''); function compact(el) { return ((el && (el.innerText || el.textContent)) || '').replace(/\\s+/g, ''); } function rowChecked(row) { if (!row) return false; const icon = row.querySelector('.vxe-checkbox--icon'); const cls = String((icon && icon.className) || ''); return cls.indexOf('checkbox-checked') >= 0 && cls.indexOf('unchecked') < 0; } function checkLeft(row) { if (!row) return false; if (rowChecked(row)) return true; const box = row.querySelector('.vxe-cell--checkbox:not(.is--disabled), .vxe-checkbox--icon'); if (!box) return false; if (String(box.className || '').indexOf('is--disabled') >= 0) return false; box.click(); return true; } const root = document.querySelector('.table-show'); if (!root) { return {ok: false, error: 'no-header-table'}; } if (!no) { return {ok: false, error: 'empty'}; } const col = Array.from(root.querySelectorAll('.vxe-header--column')).find((th) => { return compact(th) === '合同编号'; }); if (!col) { return {ok: false, error: 'no-合同编号-col'}; } const colid = col.getAttribute('colid'); const mains = Array.from(root.querySelectorAll( '.vxe-table--main-wrapper .vxe-body--row, .vxe-table--body-wrapper .vxe-body--row' )); let idx = -1; let hitText = ''; for (let i = 0; i < mains.length; i++) { const cell = mains[i].querySelector('[colid="' + colid + '"]'); const t = compact(cell); if (t === no || (t && t.indexOf(no) >= 0)) { idx = i; hitText = t; break; } } if (idx < 0) { const cells = Array.from(root.querySelectorAll('.vxe-body--row [colid="' + colid + '"]')); for (const cell of cells) { const t = compact(cell); if (t !== no && t.indexOf(no) < 0) continue; const row = cell.closest('.vxe-body--row'); idx = mains.indexOf(row); if (idx < 0) { const all = Array.from(root.querySelectorAll('.vxe-body--row')); idx = all.indexOf(row); } hitText = t; break; } } if (idx < 0) { return {ok: false, error: 'row', colid: colid, rows: mains.length}; } const lefts = Array.from(root.querySelectorAll('.vxe-table--fixed-left-wrapper .vxe-body--row')); const left = lefts[idx] || null; const main = mains[idx] || null; if (main) { main.scrollIntoView({block: 'center'}); } const checked = checkLeft(left); return { ok: !!checked, text: hitText || compact(main).slice(0, 80), idx: idx, colid: colid, checked: rowChecked(left), }; """ # 按总单表「合同编号」列勾中那一行,禁止拿第一行或 CG- 号凑合。 def select_header_row(browser: BrowserAdapter, contract_no: str) -> bool: result = select_header_row_result(browser, contract_no) return bool(isinstance(result, dict) and result.get("ok")) # 按「合同编号」列勾中那一行,返回脚本结果字典。禁止拿第一行或 CG- 号。 def select_header_row_result(browser: BrowserAdapter, contract_no: str) -> dict: _switch_agreement_frame(browser) try: result = browser.execute_script(_SELECT_HEADER_ROW_JS, contract_no) except Exception: result = None if isinstance(result, dict): return result return {"ok": False, "error": "script"} # 读总单表当前勾中行的合同编号,给续跑脚本确认没拿错行。 def read_header_selection(browser: BrowserAdapter, contract_no: str) -> dict: _switch_agreement_frame(browser) try: result = browser.execute_script( """ /* HEADER_SELECTION */ const no = String(arguments[0] || '').trim().replace(/\\s+/g, ''); function compact(el) { return ((el && (el.innerText || el.textContent)) || '').replace(/\\s+/g, ''); } const root = document.querySelector('.table-show'); if (!root) { return {ok: false, error: 'no-header-table'}; } const col = Array.from(root.querySelectorAll('.vxe-header--column')).find((th) => { return compact(th) === '合同编号'; }); const colid = col ? col.getAttribute('colid') : ''; const mains = Array.from(root.querySelectorAll( '.vxe-table--main-wrapper .vxe-body--row, .vxe-table--body-wrapper .vxe-body--row' )); const lefts = Array.from(root.querySelectorAll( '.vxe-table--fixed-left-wrapper .vxe-body--row' )); const cells = colid ? Array.from(root.querySelectorAll('.vxe-body--row [colid="' + colid + '"]')).map((c) => compact(c)) : []; let checkedIdx = -1; for (let i = 0; i < lefts.length; i++) { const icon = lefts[i].querySelector('.vxe-checkbox--icon'); const cls = String((icon && icon.className) || ''); if (cls.indexOf('checkbox-checked') >= 0 && cls.indexOf('unchecked') < 0) { checkedIdx = i; break; } } const contract = (checkedIdx >= 0 && cells[checkedIdx]) ? cells[checkedIdx] : (cells[0] || ''); return { ok: !!(no && contract && contract.indexOf(no) >= 0 && checkedIdx >= 0), colid: colid, rows: mains.length, cells: cells, checkedIdx: checkedIdx, contract: contract, pager: compact(root.querySelector('.vxe-pager, .elPlus-pagination')), }; """, contract_no, ) except Exception: result = None if isinstance(result, dict): return result return {"ok": False, "error": "script"} # 关掉总单查询弹层。点「取消」会清掉合同编号筛选,只关 X / Esc。 def _close_header_query(browser: BrowserAdapter) -> None: try: browser.execute_script( """ /* CLOSE_HEADER_QUERY */ function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); } function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); } const modal = Array.from(document.querySelectorAll( '.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer, .elPlus-overlay, .elPlus-popper' )).find((el) => { if (!vis(el)) return false; const t = compact(el); return t.indexOf('已保存查询条件') >= 0 || t.indexOf('设置查询条件') >= 0 || (t.indexOf('合同编号') >= 0 && !!el.querySelector('.search-condition-item')); }); if (modal) { const close = modal.querySelector( '.vxe-modal--close-btn, .vxe-icon-close, .elPlus-dialog__headerbtn, .el-dialog__headerbtn' ); if (close) { close.click(); return {ok: true, via: 'x'}; } modal.remove(); return {ok: true, via: 'remove'}; } return {ok: false, via: 'none'}; """ ) except Exception: pass time.sleep(0.4) # 下拉可筛选且很长。先输入再精确点中文案,避免「电汇」点到「电汇—非直联」。 def _pick_detail_option( browser: BrowserAdapter, title: str, value: str, *, in_detail: bool = False ) -> bool: wanted = (value or "").strip() if not wanted: return False item_fn = "detailItem" if in_detail else "formItem" opened = browser.execute_script( """ /* OPEN_DETAIL_SELECT */ """ + _header_dom_js() + f""" const title = String(arguments[0] || ''); const item = {item_fn}(title); if (!item) {{ return {{ok: false, error: 'no-label'}}; }} item.scrollIntoView({{block: 'center', inline: 'nearest'}}); const trigger = selectWrap(item) || item.querySelector('.elPlus-input__wrapper, input, .vxe-input'); if (!trigger) {{ return {{ok: false, error: 'no-trigger'}}; }} trigger.click(); return {{ok: true}}; """, title, ) if not (isinstance(opened, dict) and opened.get("ok")): return False time.sleep(0.2) browser.execute_script( """ /* FILTER_DETAIL_SELECT */ """ + _header_dom_js() + f""" const title = String(arguments[0] || ''); const wanted = String(arguments[1] || ''); const item = {item_fn}(title); const input = (item && item.querySelector('input.elPlus-select__input')) || Array.from(document.querySelectorAll( '.elPlus-select__popper input, .elPlus-select-dropdown input.elPlus-select__input' )).find(vis); if (!input) {{ return {{ok: false, error: 'no-filter'}}; }} input.focus(); input.click(); const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; setter.call(input, wanted); input.dispatchEvent(new Event('input', {{bubbles: true}})); input.dispatchEvent(new InputEvent('input', {{bubbles: true, composed: true, data: wanted}})); input.dispatchEvent(new KeyboardEvent('keyup', {{key: wanted.slice(-1) || '', bubbles: true}})); return {{ok: true, value: input.value}}; """, title, wanted, ) driver = getattr(browser, "_driver", None) if driver is not None: try: from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys els = [ el for el in driver.find_elements("css selector", "input.elPlus-select__input") if el.is_displayed() ] if els: el = els[-1] ActionChains(driver).move_to_element(el).click().send_keys(Keys.CONTROL + "a").send_keys(wanted).perform() except Exception: pass deadline = time.time() + 6 picked = None while time.time() <= deadline: picked = browser.execute_script( """ /* PICK_DETAIL_OPTION */ const wanted = String(arguments[0] || '').replace(/\\s+/g, ''); function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim(); } function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); } function scan(root) { return Array.from(root.querySelectorAll( '.elPlus-select-dropdown__item, .el-select-dropdown__item, .vxe-select-option, li' )).find((el) => vis(el) && compact(el) === wanted); } const drops = Array.from(document.querySelectorAll( '.elPlus-select__popper, .elPlus-select-dropdown, .el-select-dropdown, .vxe-select--panel' )).filter(vis); const roots = drops.length ? drops : [document]; let opt = null; for (const root of roots) { opt = scan(root); if (opt) break; const wrap = root.querySelector( '.elPlus-scrollbar__wrap, .elPlus-select-dropdown__wrap, .elPlus-vl__window' ); if (!wrap) continue; for (let i = 0; i < 25 && !opt; i++) { wrap.scrollTop = (wrap.scrollTop || 0) + 72; opt = scan(root); } if (opt) break; } if (!opt) { return {ok: false, error: 'no-option'}; } opt.click(); return {ok: true, text: ((opt.innerText || '') + '').trim()}; """, wanted, ) if isinstance(picked, dict) and picked.get("ok") and picked.get("text", "").replace(" ", "") == wanted.replace(" ", ""): return True time.sleep(0.3) return False # 读页面 toast。供应商为空或失败文案原样返回,没有则空。 def _read_detail_toast(browser: BrowserAdapter) -> str: try: result = browser.execute_script( """ /* DETAIL_TOAST */ function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); } const nodes = Array.from(document.querySelectorAll( '.elPlus-message, .el-message, .elPlus-notification, .el-notification' )); const texts = nodes.map((el) => compact(el)).filter(Boolean); return {texts: texts.slice(0, 8)}; """ ) except Exception: result = None texts = [] if isinstance(result, dict): texts = [str(t) for t in (result.get("texts") or []) if t] for text in texts: if "供应商为空" in text: return text if "失败" in text or "不能" in text or "不能为空" in text: return text return "" # 总单卡片开着时细单确认会点到总单确认。先回表格。 def _close_header_card(browser: BrowserAdapter) -> None: try: browser.execute_script( """ /* CLOSE_HEADER_CARD */ function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); } function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); } const card = Array.from(document.querySelectorAll('.table-card')).find((el) => { const t = compact(el); return vis(el) && t.indexOf('供应商编码') >= 0 && t.indexOf('货品编码') < 0; }); if (!card) { return {ok: false}; } const hit = Array.from(card.querySelectorAll('.card-header-btn, button')).find((el) => { if (!vis(el)) return false; const t = compact(el).replace(/\\s+/g, ''); return t.indexOf('返回表格') === 0; }); if (!hit) { return {ok: false, error: 'no-back'}; } hit.click(); return {ok: true, via: compact(hit).slice(0, 40)}; """ ) except Exception: pass time.sleep(0.3) # 回到货品表。未确认的新增卡片用「返回表格 Ctrl + B」。 def _close_detail_card(browser: BrowserAdapter) -> None: try: browser.execute_script( """ /* CLOSE_DETAIL_CARD */ """ + _header_dom_js() + """ const card = Array.from(document.querySelectorAll('.table-card')).find((el) => { return vis(el) && compact(el).indexOf('货品编码') >= 0; }); if (!card) { return {ok: false}; } const hit = Array.from(card.querySelectorAll('.card-header-btn, button')).find((el) => { if (!(el.offsetWidth || el.offsetHeight)) return false; const t = compact(el).replace(/\\s+/g, ''); return t.indexOf('返回表格') === 0; }); if (hit) { hit.click(); return {ok: true, via: compact(hit).slice(0, 40)}; } return {ok: false}; """ ) except Exception: pass time.sleep(0.3) _SELECT_HEADER_ROW = _SELECT_HEADER_ROW_JS