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