AutoRpa/rpa_rebate/rebate_common.py

2444 lines
97 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 返利页面共用:登录、进采购协议、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