AutoRpa/rpa_rebate/rebate_flow_steps.py

5030 lines
192 KiB
Python
Raw 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.

"""返利中心查号、总单、细单与送审:搜到后填总单;页面新增细单(按货品ID搜,不做 Excel 导入);点送审后马上能见号则带回总单/细单号。"""
from __future__ import annotations
import json
import os
import re
import shutil
import time
from dataclasses import dataclass
from datetime import date
from decimal import Decimal, ROUND_HALF_UP
from pathlib import Path
from typing import Protocol
from accept import AcceptanceReport, artifact_dir, evidence_shot
from errors import LoginFailed, NodeError
FLOW = "REBATE_FIND"
HEADER_FLOW = "REBATE_HEADER"
IMPORT_FLOW = "REBATE_IMPORT"
SUBMIT_FLOW = "REBATE_SUBMIT"
DEFAULT_URL = "http://192.168.170.144:8001"
SENTINEL_CONTRACT_NO = "__NO_SUCH_CONTRACT__"
PAY_MODE_NEXT_MONTH_N = "下月N号结"
REBATE_CONTRACT_DEFAULT = "否"
SETTLE_MODE_DEFAULT = "否"
TAX_RATE_DEFAULT = "0.13"
PAY_MODE_CODE = "11"
PAY_PARAM_DEFAULT = "25"
PAY_PARAM_SALES = "以销售定结"
SETTLE_CODE = "3"
SETTLE_MODE_WIRE = "电汇"
COMPENSATION_DEFAULT = "0"
PLANNER_DEFAULT = "廖建琦"
CONTACT_DEFAULT = "苏文辉"
_ACCEPT = AcceptanceReport(flow=FLOW)
_HEADER_ACCEPT = AcceptanceReport(flow=HEADER_FLOW)
_IMPORT_ACCEPT = AcceptanceReport(flow=IMPORT_FLOW)
_SUBMIT_ACCEPT = AcceptanceReport(flow=SUBMIT_FLOW)
_PORTAL_MARKERS = ("采购协议", "待审批", "待审批事项", "待办", "菜单名称", "下载打印插件")
EMPTY_MARKERS = ("暂无数据", "没有找到", "查询无结果", "无匹配数据", "未找到相关")
LOGIN = {
"user": [
'input[name="username"]',
'input[name="userName"]',
'input[placeholder*="用户名"]',
'input[placeholder*="账号"]',
'input[placeholder*="工号"]',
'input[type="text"]',
],
"password": [
'input[name="password"]',
'input[placeholder*="密码"]',
'input[type="password"]',
],
"submit": [
"button.login-submit",
"button.el-button--primary",
"button:contains(登录)",
'button[type="submit"]',
'input[type="submit"]',
'input[value="登录"]',
],
}
MENU = {
"entry": [
"text=采购协议管理",
"text=采购协议",
"a:contains(采购协议管理)",
"span:contains(采购协议管理)",
],
}
SEARCH = {
"contract_no": [
"label=合同编号",
"label=合同号",
'input[placeholder*="合同编号"]',
'input[placeholder*="合同号"]',
'input[name*="contractNo"]',
'input[name*="contract"]',
],
"button": [
"text=查询",
"button:contains(查询)",
"a:contains(查询)",
"text=搜索",
"button:contains(搜索)",
],
}
_TABLE_SCRIPT = """
const nodes = Array.from(document.querySelectorAll(
'table tbody, table, .el-table__body, .el-table, .ant-table-tbody, .ant-table, .vxe-table, .vxe-body--row'
));
return nodes.map((n) => (n.innerText || '').trim()).filter(Boolean).join('\\n');
"""
class BrowserAdapter(Protocol):
def open_url(self, url: str) -> None: ...
def fill_first(self, selectors: list[str], value: str) -> None: ...
def click_first(self, selectors: list[str]) -> None: ...
def wait_for_text(self, text: str, timeout_seconds: int = 15) -> None: ...
def wait_for_element(self, selectors: list[str], timeout_seconds: int = 15) -> None: ...
def element_exists(self, selectors: list[str]) -> bool: ...
def read_body_text(self) -> str: ...
def execute_script(self, script: str, *args): ...
def screenshot(self, name: str) -> str: ...
def select_first(self, selectors: list[str], value: str) -> None: ...
def read_first(self, selectors: list[str], value_name: str = "") -> str: ...
def upload_file_first(self, selectors: list[str], file_path: str) -> None: ...
@dataclass(frozen=True)
class RebateFlowContext:
rebate_url: str
username: str
password: str
@dataclass(frozen=True)
class RebateHeaderDraft:
planner_name: str
supplier_code: str
rebate_unit_code: str
rebate_contract: str = REBATE_CONTRACT_DEFAULT
settle_mode: str = SETTLE_MODE_DEFAULT
pay_mode: str = PAY_MODE_NEXT_MONTH_N
protocol_start: str = ""
protocol_end: str = ""
contact_name: str = ""
supplier_name: str = ""
supplier_id: str = ""
@dataclass(frozen=True)
class RebateImportLine:
goods_code: str
qty: str
tax_price: str
order_no: str
source_line_id: str = ""
goods_id: str = ""
tax_rate: str = ""
net_price: str = ""
@dataclass(frozen=True)
class RebateImportDraft:
lines: tuple[RebateImportLine, ...]
today: str = ""
@dataclass(frozen=True)
class RebateImportResult:
success_count: int
fail_count: int
fail_reasons: tuple[str, ...] = ()
parsed: bool = False
@dataclass(frozen=True)
class RebateSubmitResult:
submitted: bool
agreement_id: str = ""
agreement_dtl_id: str = ""
def classify_search_result(result_text: str, contract_no: str, *, from_table: bool = True) -> bool:
number = (contract_no or "").strip()
if not number:
return False
text = result_text or ""
if from_table:
if any(marker in text for marker in EMPTY_MARKERS) and number not in text:
return False
return number in text
if any(marker in text for marker in EMPTY_MARKERS):
return False
return text.count(number) >= 2
def read_result_table(browser: BrowserAdapter) -> tuple[str, bool]:
try:
text = browser.execute_script(_TABLE_SCRIPT)
except Exception:
text = None
if isinstance(text, str) and text.strip():
return text, True
return (browser.read_body_text() or ""), False
def open_rebate_center(browser: BrowserAdapter, ctx: RebateFlowContext) -> None:
url = ""
try:
url = str(browser.execute_script("return location.href") or "")
except Exception:
url = ""
if "ssoLogin" in url or "login" in url.lower() or "wel/index" in url:
browser.open_url(ctx.rebate_url)
return
if "procurementAgree" in url:
return
browser.open_url(ctx.rebate_url)
def _is_logged_in(browser: BrowserAdapter) -> bool:
body = browser.read_body_text() or ""
return any(marker in body for marker in _PORTAL_MARKERS)
def login_rebate_center(browser: BrowserAdapter, ctx: RebateFlowContext) -> None:
try:
if _is_logged_in(browser):
return
if not (ctx.username or "").strip() or ctx.password == "":
raise LoginFailed("未配置 REBATE_USERNAME / REBATE_PASSWORD")
browser.fill_first(LOGIN["user"], ctx.username)
browser.fill_first(LOGIN["password"], ctx.password)
clicked = False
try:
clicked = bool(
browser.execute_script(
"""
const btn = Array.from(document.querySelectorAll('button')).find((el) => {
const t = ((el.innerText || el.value || '') + '').replace(/\\s+/g, '').trim();
return t === '登录' && (el.offsetWidth || el.offsetHeight);
});
if (!btn) { return false; }
btn.click();
return true;
"""
)
)
except Exception:
clicked = False
if not clicked:
browser.click_first(LOGIN["submit"])
deadline = time.time() + 30
while time.time() < deadline:
if _is_logged_in(browser):
return
time.sleep(0.5)
raise TimeoutError("登录后未进入返利中心")
except LoginFailed:
raise
except Exception as exc:
raise LoginFailed(f"返利中心登录失败:{exc}") from exc
def _switch_agreement_frame(browser: BrowserAdapter) -> bool:
driver = getattr(browser, "_driver", None)
if driver is None:
return False
def _here() -> bool:
try:
body = driver.execute_script("return (document.body && document.body.innerText) || ''") or ""
has_search = bool(driver.execute_script("return !!document.querySelector('.search-button')"))
return ("协议总单ID" in body or "采购协议号" in body) and has_search
except Exception:
return False
if _here():
return True
try:
driver.switch_to.default_content()
if _here():
return True
frames = driver.find_elements("css selector", "iframe")
except Exception:
return False
for frame in frames:
try:
driver.switch_to.default_content()
driver.switch_to.frame(frame)
if _here():
return True
except Exception:
continue
try:
driver.switch_to.default_content()
except Exception:
pass
return _here()
def _dismiss_menu_autocomplete(browser: BrowserAdapter) -> None:
"""菜单「采购协议」下拉会挡住总单查询和细单新增,进页后必须关掉。"""
try:
switch = getattr(browser, "switch_to_default_content", None)
if callable(switch):
switch()
except Exception:
pass
try:
browser.execute_script(
"""
/* DISMISS_MENU_AUTOCOMPLETE */
const input = document.querySelector('input[placeholder*="菜单名称"]');
if (input) { input.blur(); }
document.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true
}));
const pops = Array.from(document.querySelectorAll(
'.el-autocomplete-suggestion, .my-autocomplete, .el-popper.my-autocomplete'
));
for (const el of pops) { el.remove(); }
return pops.length;
"""
)
except Exception:
pass
def enter_purchase_agreement(browser: BrowserAdapter) -> None:
url = ""
try:
url = str(browser.execute_script("return location.href") or "")
except Exception:
url = ""
body = browser.read_body_text() or ""
already = "procurementAgree" in url or "协议总单ID" in body or (
"合同编号" in body and "查询" in body
)
if already:
_dismiss_menu_autocomplete(browser)
_switch_agreement_frame(browser)
return
try:
browser.wait_for_element(['input[placeholder*="菜单名称"]'], timeout_seconds=15)
browser.fill_first(['input[placeholder*="菜单名称"]'], "采购协议")
time.sleep(1.5)
except Exception as exc:
print(f"菜单搜索框未填:{exc}", flush=True)
clicked = False
try:
clicked = browser.execute_script(
"""
const sug = document.querySelector(
'.el-autocomplete-suggestion, .my-autocomplete, .el-popper.my-autocomplete'
);
const scope = sug || document;
const hit = Array.from(scope.querySelectorAll('div.name, li[role="option"]')).find((el) => {
const t = ((el.innerText || '') + '').replace(/\\s+/g, '').trim();
return (t === '采购协议管理' || t.indexOf('采购协议管理') === 0)
&& (el.offsetWidth || el.offsetHeight);
});
if (!hit) { return {ok: false}; }
hit.click();
return {ok: true, cls: (hit.className || '').toString().slice(0, 80)};
"""
)
clicked = bool(isinstance(clicked, dict) and clicked.get("ok"))
except Exception as exc:
print(f"菜单点击失败:{exc}", flush=True)
clicked = False
if not clicked:
browser.click_first(MENU["entry"])
_dismiss_menu_autocomplete(browser)
deadline = time.time() + 25
while time.time() <= deadline:
href = ""
try:
href = str(browser.execute_script("return location.href") or "")
except Exception:
href = ""
if "procurementAgree" in href:
_dismiss_menu_autocomplete(browser)
if _switch_agreement_frame(browser):
return
time.sleep(0.5)
raise TimeoutError("未进入采购协议管理")
_QUERY_LABELS = ("合同编号", "采购协议号", "协议总单ID")
def _open_header_search_panel(browser: BrowserAdapter) -> bool:
opened = browser.execute_script(
"""
/* OPEN_HEADER_SEARCH */
function labelText(el) {
const n = el.querySelector('.search-label__text');
return ((n && n.innerText) || '').replace(/\\s+/g, '').trim();
}
const items = Array.from(document.querySelectorAll('.search-condition-item'));
const hasContract = items.some((el) => labelText(el) === '合同编号');
if (hasContract) { return {ok: true, already: true}; }
const btn = document.querySelector('.table-show .single-table .search-button-content')
|| document.querySelector('.table-show .search-button-content')
|| document.querySelector('.search-button-content');
if (!btn) { return {ok: false}; }
btn.click();
return {ok: true};
"""
)
if not (isinstance(opened, dict) and opened.get("ok")):
return False
deadline = time.time() + 8
while time.time() <= deadline:
ready = False
try:
ready = bool(
browser.execute_script(
"""
const items = Array.from(document.querySelectorAll('.search-condition-item'));
return items.some((el) => {
const n = el.querySelector('.search-label__text');
const t = ((n && n.innerText) || '').replace(/\\s+/g, '').trim();
return t === '合同编号';
});
"""
)
)
except Exception:
ready = False
if ready:
return True
time.sleep(0.3)
return False
def _clear_header_search(browser: BrowserAdapter) -> None:
browser.execute_script(
"""
/* CLEAR_HEADER_SEARCH */
const hit = Array.from(document.querySelectorAll('button')).find((el) => {
const t = ((el.innerText || '') + '').replace(/\\s+/g, ' ').trim();
return t === '清除 Alt+C' || t === '清除';
});
if (hit) { hit.click(); return true; }
return false;
"""
)
time.sleep(0.4)
def _fill_header_contract_and_query(browser: BrowserAdapter, number: str) -> bool:
filled = browser.execute_script(
"""
/* FILL_HEADER_CONTRACT */
const no = String(arguments[0] || '');
function labelText(el) {
const n = el.querySelector('.search-label__text');
return ((n && n.innerText) || '').replace(/\\s+/g, '').trim();
}
const items = Array.from(document.querySelectorAll('.search-condition-item'));
for (const el of items) {
const box = el.querySelector('label.elPlus-checkbox');
if (!box) continue;
const on = box.classList.contains('is-checked');
const mine = labelText(el) === '合同编号';
if (mine && !on) { box.click(); }
if (!mine && on) { box.click(); }
}
const item = items.find((el) => labelText(el) === '合同编号');
if (!item) { return {ok: false, error: 'no-合同编号'}; }
const box = item.querySelector('label.elPlus-checkbox');
if (box && !box.classList.contains('is-checked')) { box.click(); }
const input = Array.from(item.querySelectorAll('input.elPlus-input__inner')).find((el) => {
return el.type === 'text' && String(el.placeholder || '').indexOf('请输入') >= 0;
}) || item.querySelector('.search-component input.elPlus-input__inner');
if (!input) { return {ok: false, error: 'no-input'}; }
input.focus();
input.click();
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
setter.call(input, no);
input.dispatchEvent(new Event('input', {bubbles: true}));
input.dispatchEvent(new InputEvent('input', {bubbles: true, data: no, inputType: 'insertText'}));
input.dispatchEvent(new Event('change', {bubbles: true}));
if (box && !box.classList.contains('is-checked')) { box.click(); }
input.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true
}));
return {ok: true, value: input.value, checked: !!(box && box.classList.contains('is-checked'))};
""",
number,
)
if not (isinstance(filled, dict) and filled.get("ok") and filled.get("value") == number):
return False
driver = getattr(browser, "_driver", None)
if driver is not None:
try:
from selenium.webdriver.common.keys import Keys
el = driver.switch_to.active_element
el.send_keys(Keys.CONTROL, "a")
el.send_keys(number)
el.send_keys(Keys.ENTER)
except Exception:
pass
browser.execute_script(
"""
/* QUERY_HEADER_ENTER */
function compact(el) {
return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim();
}
const item = Array.from(document.querySelectorAll('.search-condition-item')).find((el) => {
const n = el.querySelector('.search-label__text');
return compact(n) === '合同编号';
});
const panel = (item && item.closest(
'.search-condition, .vxe-modal--wrapper, .elPlus-dialog, .el-dialog'
)) || document;
const hit = Array.from(panel.querySelectorAll('button')).find((el) => {
const t = compact(el);
return t === '查询 Enter' || t === '查询' || t.indexOf('查询') === 0;
});
if (hit) { hit.click(); return {ok: true, via: compact(hit)}; }
return {ok: false};
"""
)
return True
def _search_header_by_contract(browser: BrowserAdapter, number: str) -> bool:
"""总单工具栏按合同编号搜。不要先点「清除」,会把表刷回全量再查不准。"""
if not _open_header_search_panel(browser):
return False
return _fill_header_contract_and_query(browser, number)
def _search_via_query_panel(browser: BrowserAdapter, number: str, label: str) -> bool:
opened = browser.execute_script(
"""
/* OPEN_QUERY_PANEL */
if (document.querySelector('.search-condition-item')) {
return {ok: true, already: true};
}
const btn = document.querySelector('.search-button');
if (!btn) { return {ok: false}; }
btn.click();
return {ok: true};
"""
)
if not (isinstance(opened, dict) and opened.get("ok")):
return False
deadline = time.time() + 8
while time.time() <= deadline:
ready = False
try:
ready = bool(
browser.execute_script(
"return !!document.querySelector('.search-condition-item')"
)
)
except Exception:
ready = False
if ready:
break
time.sleep(0.3)
filled = browser.execute_script(
"""
/* FILL_QUERY_FIELD */
const label = String(arguments[0] || '');
const no = String(arguments[1] || '');
function labelText(el) {
const n = el.querySelector('.search-label__text');
return ((n && n.innerText) || '').replace(/\\s+/g, '').trim();
}
const items = Array.from(document.querySelectorAll('.search-condition-item'));
if (!items.length) { return {ok: false}; }
const item = items.find((el) => labelText(el) === label)
|| items.find((el) => (el.innerText || '').indexOf(label) >= 0);
if (!item) { return {ok: false}; }
const box = item.querySelector('label.elPlus-checkbox');
if (box && !box.classList.contains('is-checked')) { box.click(); }
const input = Array.from(item.querySelectorAll('input.elPlus-input__inner')).find((el) => {
return el.type === 'text' && String(el.placeholder || '').indexOf('请输入') >= 0;
}) || item.querySelector('.search-component input.elPlus-input__inner');
if (!input) { return {ok: false}; }
input.focus();
input.click();
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
setter.call(input, no);
input.dispatchEvent(new Event('input', {bubbles: true}));
input.dispatchEvent(new Event('change', {bubbles: true}));
if (box && !box.classList.contains('is-checked')) { box.click(); }
return {ok: true, value: input.value, label, checked: !!(box && box.classList.contains('is-checked'))};
""",
label,
number,
)
if not (isinstance(filled, dict) and filled.get("ok")):
return False
browser.execute_script(
"""
const hit = Array.from(document.querySelectorAll('button')).find((el) => {
const t = ((el.innerText || '') + '').replace(/\\s+/g, ' ').trim();
return t === '查询 Enter' || t === '查询';
});
if (hit) { hit.click(); return true; }
return false;
"""
)
return True
def _wait_search_hit(browser: BrowserAdapter, number: str, *, since: float | None = None) -> bool:
deadline = time.time() + 15
started = since or time.time()
last_text = ""
last_from_table = False
last_header: dict | None = None
while time.time() <= deadline:
_switch_agreement_frame(browser)
header = read_header_selection(browser, number)
elapsed = time.time() - started
if header.get("error") != "script" and "cells" in header:
last_header = header
cells = [str(c or "") for c in (header.get("cells") or [])]
if any(number == c or number in c for c in cells) and elapsed >= 0.6:
return True
pager = str(header.get("pager") or "").replace(" ", "")
if "共0条" in pager and elapsed > 6:
return False
time.sleep(0.4)
continue
text, from_table = read_result_table(browser)
last_text, last_from_table = text, from_table
found = classify_search_result(text, number, from_table=from_table)
body = ""
try:
body = browser.read_body_text() or ""
except Exception:
body = ""
pagers = [int(n) for n in re.findall(r"共\s*(\d+)\s*条", f"{text}\n{body}")]
all_zero = bool(pagers) and all(n == 0 for n in pagers)
empty = any(marker in (text or "") for marker in EMPTY_MARKERS) and number not in (text or "")
header_ready = "协议总单ID" in (text or "")
if found and elapsed >= 0.6:
return True
if pagers and max(pagers) == 1 and elapsed >= 0.8:
return True
if all_zero and elapsed > 2:
return False
if empty and not header_ready and elapsed > 2:
return False
time.sleep(0.4)
if last_header is not None:
cells = [str(c or "") for c in (last_header.get("cells") or [])]
return any(number == c or number in c for c in cells)
return classify_search_result(last_text, number, from_table=last_from_table)
def search_contract(browser: BrowserAdapter, contract_no: str) -> bool:
number = (contract_no or "").strip()
if not number:
return False
_dismiss_menu_autocomplete(browser)
_switch_agreement_frame(browser)
try:
if _search_header_by_contract(browser, number):
return _wait_search_hit(browser, number, since=time.time())
except Exception:
pass
used_panel = False
for label in _QUERY_LABELS:
try:
hit = _search_via_query_panel(browser, number, label)
if not hit:
time.sleep(0.4)
_switch_agreement_frame(browser)
hit = _search_via_query_panel(browser, number, label)
except Exception:
hit = False
if not hit:
continue
used_panel = True
if _wait_search_hit(browser, number, since=time.time()):
return True
if not used_panel:
try:
browser.fill_first(SEARCH["contract_no"], number)
browser.click_first(SEARCH["button"])
return _wait_search_hit(browser, number, since=time.time())
except Exception:
return False
return False
def run_find_flow(
browser: BrowserAdapter,
ctx: RebateFlowContext,
contract_no: str,
*,
expect_found: bool | None = None,
) -> bool:
_ACCEPT.reset()
try:
open_rebate_center(browser, ctx)
_ACCEPT.note("S02", "打开返利中心", {"url": ctx.rebate_url}, ok=True)
try:
login_rebate_center(browser, ctx)
_ACCEPT.note("S03", "登录", {"user": ctx.username}, ok=True)
except Exception as exc:
evidence_shot(browser, "rebate-login-failed", failed=True)
_ACCEPT.note(
"S03",
"登录",
{"user": ctx.username, "error": exc},
ok=False,
raise_error=False,
)
raise
enter_purchase_agreement(browser)
_ACCEPT.note("S04", "进入采购协议", {"menu": "采购协议管理"}, ok=True)
found = search_contract(browser, contract_no)
found_text = "yes" if found else "no"
if expect_found is None:
_ACCEPT.note(
"S05",
"查号",
{"contractNo": contract_no, "found": found_text},
ok=True,
)
else:
_ACCEPT.check(
"S05",
"查号",
[
("contractNo", contract_no, contract_no),
("found", found_text, "yes" if expect_found else "no"),
],
browser=browser,
screenshot_on_fail="rebate-find-mismatch",
)
return found
finally:
print(_ACCEPT.summary_text(), end="", flush=True)
_ACCEPT.dump()
HEADER = {
"rebate_contract": [
"label=返利合约",
'//td[contains(.,"返利合约")]/following-sibling::td[1]//select',
'//*[contains(text(),"返利合约")]/following::select[1]',
],
"supplier_code": [
"label=供应商编码",
'input[placeholder*="供应商编码"]',
'input[name*="supplier"]',
'//td[contains(.,"供应商编码")]/following-sibling::td[1]//input',
],
"rebate_unit_code": [
"label=返利单位编码",
'input[placeholder*="返利单位"]',
'input[name*="rebateUnit"]',
'//td[contains(.,"返利单位编码")]/following-sibling::td[1]//input',
],
"planner": [
"label=计划员",
'input[placeholder*="计划员"]',
'input[name*="planner"]',
'//td[contains(.,"计划员")]/following-sibling::td[1]//input',
'//td[contains(.,"计划员")]/following-sibling::td[1]//select',
],
"settle_mode": [
"label=采购结算方式",
'//td[contains(.,"采购结算方式")]/following-sibling::td[1]//select',
'//*[contains(text(),"采购结算方式")]/following::select[1]',
],
"pay_mode": [
"label=承付模式",
'//td[contains(.,"承付模式")]/following-sibling::td[1]//select',
'//*[contains(text(),"承付模式")]/following::select[1]',
],
"protocol_start": [
"label=协议开始日期",
'input[placeholder*="开始日期"]',
'//td[contains(.,"协议开始日期")]/following-sibling::td[1]//input',
],
"protocol_end": [
"label=协议结束日期",
'input[placeholder*="结束日期"]',
'//td[contains(.,"协议结束日期")]/following-sibling::td[1]//input',
],
}
SAVE = {
"button": [
"text=保存",
"button:contains(保存)",
"a:contains(保存)",
'input[value="保存"]',
"text=保 存",
],
}
ORDER_NO = {
"value": [
"label=总单号",
"label=采购协议号",
"label=协议编号",
"label=采购协议总单ID",
"label=协议总单号",
'input[name*="agreementId"]',
'input[name*="rebateOrder"]',
],
}
_FORM_MARKERS = ("保存", "导入导出", "下载模板")
_ORDER_NO_LABEL_RE = re.compile(
r"(?:总单号|采购协议号|协议编号|采购协议总单ID|协议总单号|采购协议ID)[::\s]*([A-Za-z0-9_-]{3,})"
)
_FL_RE = re.compile(r"\bFL[A-Za-z0-9_-]{4,}\b")
def _header_dom_js() -> str:
"""总单卡片:标签在 .label-wrap,三个点是 span.pointer.f20,下拉是 .elPlus-select__wrapper。"""
return """
function compact(el) {
return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim();
}
function vis(el) {
if (!el) return false;
const st = window.getComputedStyle(el);
if (!st || st.display === 'none' || st.visibility === 'hidden') return false;
return !!(el.offsetWidth || el.offsetHeight);
}
function formItem(title, root) {
const wanted = String(title || '').replace(/\\s+/g, '').replace(/\\*/g, '');
const scope = root || document;
const items = Array.from(scope.querySelectorAll('.elPlus-form-item, .el-form-item'));
let exact = null;
let fuzzy = null;
for (const item of items) {
if (!vis(item)) continue;
const label = item.querySelector('.form-item__title, .elPlus-form-item__label, label, .label-wrap');
const t = compact(label || item).replace(/\\s+/g, '').replace(/\\*/g, '');
if (t === wanted) { exact = item; break; }
if (!fuzzy && t.indexOf(wanted) >= 0 && t.indexOf(wanted) < 8) fuzzy = item;
}
return exact || fuzzy;
}
function detailForm() {
return Array.from(document.querySelectorAll(
'.elPlus-form, form, .elPlus-drawer, .elPlus-dialog, .vxe-modal--wrapper, .elPlus-card'
)).find((el) => {
const t = compact(el);
return vis(el) && t.indexOf('货品编码') >= 0 && t.indexOf('数量') >= 0;
}) || null;
}
function detailItem(title) {
const root = detailForm();
return root ? formItem(title, root) : null;
}
function clickDots(item) {
const live = (el) => vis(el) && !el.closest('.vxe-modal--wrapper');
const append = item && item.querySelector(
'.elPlus-input-group--append button, .elPlus-input-group--append .elPlus-button, .elPlus-input-group__append button'
);
if (append && live(append)) { append.click(); return 'append'; }
const pointer = item && Array.from(item.querySelectorAll('.pointer.f20, span.pointer')).find(live);
if (pointer) { pointer.click(); return 'pointer'; }
const moreIcon = item && Array.from(item.querySelectorAll('.vxe-icon--more')).find(live);
if (moreIcon) {
const btn = moreIcon.closest('button, .elPlus-button') || moreIcon;
btn.click();
return 'more';
}
const more = item && Array.from(item.querySelectorAll('button')).find((el) => {
const t = compact(el);
return live(el) && (t === '...' || t === '…' || t === '···');
});
if (more) { more.click(); return 'dots-button'; }
return '';
}
function selectWrap(item) {
return item && item.querySelector('.elPlus-select__wrapper, .elPlus-select, .el-select');
}
function shownValue(item) {
if (!item) return '';
const wrap = selectWrap(item);
if (wrap) {
const t = compact(wrap).replace(/请选择/g, '').trim();
if (t) return t;
}
const input = item.querySelector('input.elPlus-input__inner, input.vxe-input--inner, input:not([type="checkbox"])');
return input ? String(input.value || '').trim() : '';
}
"""
_OPEN_HEADER_ROW = """
/* OPEN_HEADER_ROW */
const no = String(arguments[0] || '').trim();
if (!no) { return {ok: false, error: 'empty'}; }
const rows = Array.from(document.querySelectorAll(
'tr, .el-table__row, .ant-table-row, .vxe-body--row, [role="row"]'
));
let row = rows.find((r) => {
const t = (r.innerText || '').replace(/\\s+/g, ' ').trim();
return t.indexOf(no) >= 0 && t.length < 2000;
});
if (!row) {
const dataRows = rows.filter((r) => r.querySelector('td, .vxe-body--column, [colid]'));
if (dataRows.length === 1) { row = dataRows[0]; }
}
if (!row) { return {ok: false, error: 'row not found'}; }
const link = Array.from(row.querySelectorAll('a, button, span, .vxe-body--column, td')).find((el) => {
const t = ((el.innerText || '') + '').replace(/\\s+/g, '').trim();
return t.indexOf(no) >= 0 || t.indexOf('CG-') === 0;
});
if (link) {
link.click();
link.dispatchEvent(new MouseEvent('dblclick', {bubbles: true, cancelable: true, view: window}));
return {ok: true, via: 'link'};
}
row.click();
row.dispatchEvent(new MouseEvent('dblclick', {bubbles: true, cancelable: true, view: window}));
return {ok: true, via: 'row'};
"""
_SET_LABELED = """
/* SET_LABELED */
const labelText = String(arguments[0] || '').trim();
const value = String(arguments[1] || '');
function controlFor(label) {
const nodes = Array.from(document.querySelectorAll('label, td, th, span, div'));
const node = nodes.find((el) => {
const t = (el.childNodes && el.childNodes.length === 1)
? (el.textContent || '').replace(/\\s+/g, '').trim()
: (el.innerText || '').replace(/\\s+/g, '').trim();
const compact = t.replace(/[*::]/g, '');
const wanted = label.replace(/\\s+/g, '');
return compact === wanted || compact.indexOf(wanted) === 0;
});
if (!node) { return null; }
if (node.tagName === 'LABEL' && node.htmlFor) {
return document.getElementById(node.htmlFor);
}
const next = node.nextElementSibling;
if (next) {
if (['INPUT', 'SELECT', 'TEXTAREA'].indexOf(next.tagName) >= 0) { return next; }
const inner = next.querySelector('input, select, textarea');
if (inner) { return inner; }
}
const cell = node.closest('td, th');
if (cell && cell.nextElementSibling) {
return cell.nextElementSibling.querySelector('input, select, textarea');
}
return null;
}
const el = controlFor(labelText);
if (!el) { return false; }
if (el.tagName === 'SELECT') {
const opts = Array.from(el.options || []);
const hit = opts.find((o) => {
const t = (o.textContent || o.text || '').replace(/\\s+/g, '');
const v = String(o.value || '').replace(/\\s+/g, '');
const wanted = value.replace(/\\s+/g, '');
return t === wanted || v === wanted || t.indexOf(wanted) >= 0;
});
if (!hit) { return false; }
el.value = hit.value;
el.dispatchEvent(new Event('change', {bubbles: true}));
return true;
}
const proto = el.tagName === 'TEXTAREA'
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
setter.call(el, value);
el.dispatchEvent(new Event('input', {bubbles: true}));
el.dispatchEvent(new Event('change', {bubbles: true}));
el.dispatchEvent(new Event('blur', {bubbles: true}));
return true;
"""
_READ_REBATE_ORDER_NO = """
/* READ_REBATE_ORDER_NO */
const labels = ['总单号', '采购协议号', '协议编号', '采购协议总单ID', '协议总单号', '采购协议ID'];
function textOf(el) {
return ((el && (el.value || el.textContent || el.innerText)) || '').replace(/\\s+/g, ' ').trim();
}
for (const label of labels) {
const nodes = Array.from(document.querySelectorAll('label, td, th, span, div'));
const node = nodes.find((el) => {
const t = (el.innerText || '').replace(/\\s+/g, '').replace(/[*::]/g, '');
return t === label || t.indexOf(label) === 0;
});
if (!node) { continue; }
let value = '';
if (node.tagName === 'LABEL' && node.htmlFor) {
value = textOf(document.getElementById(node.htmlFor));
}
const next = node.nextElementSibling;
if (!value && next) {
const inner = next.querySelector('input, select, textarea, span, a') || next;
value = textOf(inner);
}
const cell = node.closest('td, th');
if (!value && cell && cell.nextElementSibling) {
const inner = cell.nextElementSibling.querySelector('input, span, a') || cell.nextElementSibling;
value = textOf(inner);
}
if (value && labels.indexOf(value.replace(/[*::]/g, '')) < 0) { return value; }
}
const body = (document.body && document.body.innerText) || '';
const m = body.match(/(?:总单号|采购协议号|协议编号|采购协议总单ID|协议总单号)[::\\s]*([A-Za-z0-9_-]{3,})/);
return m ? m[1] : '';
"""
_OPEN_DROPDOWN = """
/* OPEN_DROPDOWN */
const labelText = String(arguments[0] || '').trim();
const nodes = Array.from(document.querySelectorAll('label, td, th, span, div'));
const node = nodes.find((el) => {
const t = (el.innerText || '').replace(/\\s+/g, '').replace(/[*::]/g, '');
return t === labelText.replace(/\\s+/g, '') || t.indexOf(labelText.replace(/\\s+/g, '')) === 0;
});
if (!node) { return false; }
const cell = (node.nextElementSibling)
|| (node.closest('td, th') && node.closest('td, th').nextElementSibling)
|| node.parentElement;
if (!cell) { return false; }
const wrap = cell.querySelector('.el-select, .ant-select, .fs-wrap, .combo, select') || cell;
wrap.click();
return true;
"""
def today_iso(today: str | None = None) -> str:
text = (today or "").strip()
if text:
return text
return date.today().isoformat()
def year_end_iso(today: str | None = None) -> str:
day = today_iso(today)
return f"{int(day[:4])}-12-31"
def _cell(value: object) -> str:
if value is None:
return ""
return str(value).strip()
def _tax_rate_of(raw: object) -> str:
text = _cell(raw)
return text if text else TAX_RATE_DEFAULT
def _untaxed_price(tax_price: str, tax_rate: str) -> str:
price = _cell(tax_price)
rate = _tax_rate_of(tax_rate)
if not price:
return ""
try:
net = Decimal(price) / (Decimal("1") + Decimal(rate))
quantized = net.quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
text = format(quantized, "f")
if "." in text:
text = text.rstrip("0").rstrip(".")
return text
except Exception:
return ""
def build_header_draft(
*,
planner_name: str,
supplier_code: str,
rebate_unit_code: str,
today: str | None = None,
) -> RebateHeaderDraft:
planner = (planner_name or "").strip() or PLANNER_DEFAULT
day = today_iso(today)
return RebateHeaderDraft(
planner_name=planner,
supplier_code=(supplier_code or "").strip(),
rebate_unit_code=(rebate_unit_code or "").strip(),
rebate_contract=REBATE_CONTRACT_DEFAULT,
settle_mode=SETTLE_MODE_DEFAULT,
pay_mode=PAY_MODE_NEXT_MONTH_N,
protocol_start=day,
protocol_end=day,
contact_name=CONTACT_DEFAULT,
)
def header_draft_from_env(*, today: str | None = None) -> RebateHeaderDraft:
return build_header_draft(
planner_name=os.getenv("REBATE_PLANNER_NAME") or "",
supplier_code=os.getenv("REBATE_SUPPLIER_CODE") or "",
rebate_unit_code=os.getenv("REBATE_UNIT_CODE") or "",
today=today,
)
def header_draft_from_table3(
rows: list[dict] | tuple[dict, ...],
*,
today: str | None = None,
) -> RebateHeaderDraft:
"""总单取表 3 该合同第一行的供应商 MDM。计划员先选廖建琦,联系人苏文辉。"""
packed = [row for row in rows if isinstance(row, dict)]
if not packed:
raise NodeError("表 3 没有该合同行,不能填总单")
first = packed[0]
supplier = _cell(first.get("supplier_mdm_code") or first.get("supplier_id"))
unit = _cell(first.get("org_code"))
draft = build_header_draft(
planner_name=PLANNER_DEFAULT,
supplier_code=supplier,
rebate_unit_code=unit,
today=today,
)
return RebateHeaderDraft(
planner_name=PLANNER_DEFAULT,
supplier_code=draft.supplier_code,
rebate_unit_code=draft.rebate_unit_code,
rebate_contract=draft.rebate_contract,
settle_mode=draft.settle_mode,
pay_mode=draft.pay_mode,
protocol_start=draft.protocol_start,
protocol_end=draft.protocol_end,
contact_name=CONTACT_DEFAULT,
supplier_name=_cell(first.get("supplier_name")),
supplier_id=_cell(first.get("supplier_id")),
)
def import_lines_from_table3(rows: list[dict] | tuple[dict, ...]) -> tuple[RebateImportLine, ...]:
"""细单按表 3 货品行:货品ID、数量、含税单价、无税=含税/(1+税率)、备注=omsId。"""
lines: list[RebateImportLine] = []
for row in rows:
if not isinstance(row, dict):
continue
goods_id = _cell(row.get("goods_id"))
if not goods_id:
continue
tax_price = _cell(row.get("unit_price") or row.get("tax_price"))
tax_rate = _tax_rate_of(row.get("purchase_tax_rate") or row.get("tax_rate"))
lines.append(
RebateImportLine(
goods_code=goods_id,
qty=_cell(row.get("qty")),
tax_price=tax_price,
order_no=_cell(row.get("oms_id") or row.get("order_no")),
source_line_id=_cell(row.get("goods_source_id") or row.get("source_line_id")),
goods_id=goods_id,
tax_rate=tax_rate,
net_price=_untaxed_price(tax_price, tax_rate),
)
)
if not lines:
raise NodeError("表 3 没有货品行,不能加细单")
return tuple(lines)
def header_draft_for_contract(contract_no: str, *, today: str | None = None) -> RebateHeaderDraft:
"""优先表 3,空缺再退回环境变量。"""
number = (contract_no or "").strip()
rows: list[dict] = []
if number:
try:
from repo import OracleRebateRepo
rows = OracleRebateRepo.from_env().load_contract_goods(number)
except Exception:
rows = []
if rows:
return header_draft_from_table3(rows, today=today)
return header_draft_from_env(today=today)
def import_draft_for_contract(contract_no: str, *, today: str | None = None) -> RebateImportDraft:
"""优先表 3 货品行,空缺再退回环境变量。"""
number = (contract_no or "").strip()
rows: list[dict] = []
if number:
try:
from repo import OracleRebateRepo
rows = OracleRebateRepo.from_env().load_contract_goods(number)
except Exception:
rows = []
if rows:
return build_import_draft(lines=import_lines_from_table3(rows), today=today)
return import_draft_from_env(today=today)
def _header_form_visible(browser: BrowserAdapter) -> bool:
body = browser.read_body_text() or ""
return any(marker in body for marker in _FORM_MARKERS)
def _click_toolbar(browser: BrowserAdapter, title: str) -> bool:
try:
result = browser.execute_script(
"""
const title = String(arguments[0] || '').trim();
const btn = Array.from(document.querySelectorAll('.table-toolbar-btn__text, button, span')).find((el) => (el.innerText || '').trim() === title);
if (!btn) { return {ok: false}; }
btn.click();
return {ok: true};
""",
title,
)
except Exception:
result = None
return bool(isinstance(result, dict) and result.get("ok"))
def _click_header_toolbar(browser: BrowserAdapter, title: str) -> bool:
"""只点总单工具栏,避开货品工具栏同名的保存/编辑。"""
try:
result = browser.execute_script(
"""
/* CLICK_HEADER_TOOLBAR */
const title = String(arguments[0] || '').trim();
function compact(el) {
return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim();
}
function vis(el) {
return !!(el && (el.offsetWidth || el.offsetHeight));
}
const root = document.querySelector('.table-show');
const bar = root && root.querySelector('.table-toolbar');
if (!bar) { return {ok: false, error: 'no-header-bar'}; }
const hit = Array.from(bar.querySelectorAll(
'.table-toolbar-btn, .table-toolbar-btn__text, button, span'
)).find((el) => vis(el) && compact(el) === title);
if (!hit) { return {ok: false, error: 'no-btn'}; }
const btn = hit.closest('button, .table-toolbar-btn') || hit;
btn.click();
return {ok: true, via: compact(hit)};
""",
title,
)
except Exception:
result = None
return bool(isinstance(result, dict) and result.get("ok"))
def _native_click_header_toolbar(browser: BrowserAdapter, title: str) -> bool:
driver = getattr(browser, "_driver", None)
if driver is None:
return False
wanted = (title or "").replace(" ", "")
try:
from selenium.webdriver.common.action_chains import ActionChains
root = None
try:
root = driver.find_element("css selector", ".table-show .table-toolbar")
except Exception:
root = None
scope = root.find_elements("css selector", "button, .table-toolbar-btn, .table-toolbar-btn__text") if root else []
for el in scope:
raw = (el.text or "").replace("\n", "").replace(" ", "")
if raw != wanted:
continue
driver.execute_script("arguments[0].scrollIntoView({block:'center'});", el)
ActionChains(driver).move_to_element(el).pause(0.15).click().perform()
return True
except Exception:
return False
return False
def open_header_form(browser: BrowserAdapter, contract_no: str) -> None:
_switch_agreement_frame(browser)
_close_detail_card(browser)
if not select_header_row(browser, contract_no):
result = None
try:
result = browser.execute_script(_OPEN_HEADER_ROW, contract_no)
except Exception:
result = None
opened = result is True or (isinstance(result, dict) and result.get("ok"))
if opened:
time.sleep(0.5)
_click_header_toolbar(browser, "编辑") or _click_toolbar(browser, "编辑")
time.sleep(0.8)
if _header_form_visible(browser):
return
try:
dbl = getattr(browser, "double_click_first", None)
if callable(dbl):
dbl([".vxe-body--row", '.vxe-body--row [colid]', "tr.vxe-body--row"])
except Exception:
pass
try:
browser.click_first(
[
f"text={contract_no}",
f'//a[contains(.,"{contract_no}")]',
f'//td[contains(.,"{contract_no}")]',
]
)
except Exception:
pass
deadline = time.time() + 15
while time.time() <= deadline:
if _header_form_visible(browser):
return
for selectors in (
["text=编辑"],
["text=修改"],
["button:contains(编辑)"],
["a:contains(编辑)"],
):
try:
browser.click_first(selectors)
break
except Exception:
continue
time.sleep(0.4)
if not _header_form_visible(browser):
raise RuntimeError("未打开总单表单")
def _fill_select(browser: BrowserAdapter, label: str, selectors: list[str], value: str) -> None:
try:
browser.select_first(selectors, value)
return
except Exception:
pass
try:
browser.execute_script(_OPEN_DROPDOWN, label)
browser.click_first(
[
f"text={value}",
f'//li[contains(.,"{value}")]',
f'//div[contains(@class,"option") and contains(.,"{value}")]',
f'//span[contains(.,"{value}")]',
]
)
return
except Exception:
pass
ok = False
try:
ok = bool(browser.execute_script(_SET_LABELED, label, value))
except Exception:
ok = False
if not ok:
raise RuntimeError(f"未选到 {label}={value}")
def _fill_input(browser: BrowserAdapter, label: str, selectors: list[str], value: str) -> None:
try:
browser.fill_first(selectors, value)
return
except Exception:
pass
try:
browser.select_first(selectors, value)
return
except Exception:
pass
ok = False
try:
ok = bool(browser.execute_script(_SET_LABELED, label, value))
except Exception:
ok = False
if not ok:
raise RuntimeError(f"未填到 {label}={value}")
def _type_active(browser: BrowserAdapter, value: str) -> None:
driver = getattr(browser, "_driver", None)
if driver is None:
return
try:
el = driver.switch_to.active_element
driver.execute_script(
"""
const el = arguments[0];
const value = String(arguments[1] || '');
if (!el) { return false; }
el.focus();
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
setter.call(el, value);
el.dispatchEvent(new Event('input', {bubbles: true}));
el.dispatchEvent(new InputEvent('input', {bubbles: true, composed: true, data: value}));
el.dispatchEvent(new Event('change', {bubbles: true}));
return true;
""",
el,
value,
)
except Exception:
pass
def _filter_and_pick_dropdown(browser: BrowserAdapter, value: str, *, exact: bool = True) -> bool:
"""已打开的下拉:输入筛选后点中。电汇必须精确匹配。"""
wanted = (value or "").strip()
if not wanted:
return False
_type_active(browser, wanted)
deadline = time.time() + 6
while time.time() <= deadline:
picked = browser.execute_script(
"""
/* PICK_DETAIL_OPTION */
const wanted = String(arguments[0] || '').replace(/\\s+/g, '');
const exact = arguments[1] === true;
function compact(el) {
return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim();
}
function vis(el) {
return !!(el && (el.offsetWidth || el.offsetHeight));
}
function scan(root) {
const nodes = Array.from(root.querySelectorAll(
'.elPlus-select-dropdown__item, .el-select-dropdown__item, .vxe-select-option, li'
));
const hitExact = nodes.find((el) => vis(el) && compact(el) === wanted);
if (hitExact) return hitExact;
if (exact) return null;
return nodes.find((el) => vis(el) && compact(el).indexOf(wanted) >= 0);
}
const drops = Array.from(document.querySelectorAll(
'.elPlus-select__popper, .elPlus-select-dropdown, .el-select-dropdown, .vxe-select--panel'
)).filter(vis);
const roots = drops.length ? drops : [document];
let opt = null;
for (const root of roots) {
opt = scan(root);
if (opt) break;
const wrap = root.querySelector(
'.elPlus-scrollbar__wrap, .elPlus-select-dropdown__wrap, .elPlus-vl__window'
);
if (!wrap) continue;
for (let i = 0; i < 25 && !opt; i++) {
wrap.scrollTop = (wrap.scrollTop || 0) + 72;
opt = scan(root);
}
if (opt) break;
}
if (!opt) { return {ok: false, error: 'no-option'}; }
opt.click();
return {ok: true, text: ((opt.innerText || '') + '').trim()};
""",
wanted,
exact,
)
text = ""
if isinstance(picked, dict) and picked.get("ok"):
text = str(picked.get("text") or "").replace(" ", "")
folded = wanted.replace(" ", "")
if text == folded:
return True
if not exact and folded and folded in text:
return True
time.sleep(0.3)
return False
def _pick_header_lookup(browser: BrowserAdapter, keyword: str) -> bool:
"""供应商/计划员编码弹窗:按编码搜,点行里单选再确定。"""
code = (keyword or "").strip()
if not code:
return False
filled = browser.execute_script(
"""
/* FILL_HEADER_LOOKUP */
const no = String(arguments[0] || '');
function compact(el) {
return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim();
}
function vis(el) {
return !!(el && (el.offsetWidth || el.offsetHeight));
}
const modal = Array.from(document.querySelectorAll(
'.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer'
)).find((el) => {
if (!vis(el)) return false;
const t = compact(el);
if (t.indexOf('请填写供应商') >= 0 && t.indexOf('采购协议号') >= 0) return false;
if (t.indexOf('货品编码') >= 0 && t.indexOf('数量') >= 0) return false;
return t.indexOf('供应商选择') >= 0 || t.indexOf('选择供应商') >= 0
|| t.indexOf('客商') >= 0 || t.indexOf('计划员') >= 0 || t.indexOf('供应商') >= 0;
});
if (!modal) { return {ok: false, error: 'no-modal'}; }
const input = modal.querySelector('input.elPlus-input__inner, input[type="text"], input');
if (!input) { return {ok: false, error: 'no-input'}; }
input.click();
input.focus();
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
setter.call(input, no);
input.dispatchEvent(new Event('input', {bubbles: true}));
input.dispatchEvent(new Event('change', {bubbles: true}));
const query = Array.from(modal.querySelectorAll('button')).find((el) => {
const t = compact(el);
return vis(el) && (t === '查询' || t.indexOf('查询') === 0);
});
if (query) { query.click(); }
return {ok: true, value: input.value};
""",
code,
)
if not (isinstance(filled, dict) and filled.get("ok")):
return False
_type_active(browser, code)
deadline = time.time() + 8
while time.time() <= deadline:
picked = browser.execute_script(
"""
/* PICK_HEADER_LOOKUP */
const keyword = String(arguments[0] || '').replace(/\\s+/g, '');
function compact(el) {
return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '');
}
function vis(el) {
return !!(el && (el.offsetWidth || el.offsetHeight));
}
const modal = Array.from(document.querySelectorAll(
'.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer'
)).find((el) => {
if (!vis(el)) return false;
const t = compact(el);
if (t.indexOf('请填写供应商') >= 0 && t.indexOf('采购协议号') >= 0) return false;
if (t.indexOf('货品编码') >= 0 && t.indexOf('数量') >= 0) return false;
return t.indexOf('供应商选择') >= 0 || t.indexOf('选择供应商') >= 0
|| t.indexOf('客商') >= 0 || t.indexOf('计划员') >= 0 || t.indexOf('供应商') >= 0;
});
if (!modal) { return {ok: false, error: 'no-modal'}; }
const root = modal;
const rows = Array.from(root.querySelectorAll('.vxe-body--row, tr, [role="row"]'));
for (const row of rows) {
const text = compact(row);
if (text.indexOf(keyword) < 0) continue;
const radio = row.querySelector(
'.vxe-cell--radio, .vxe-radio--icon, input[type="radio"], .vxe-cell--checkbox'
);
if (radio) { radio.click(); } else { row.click(); }
const ok = Array.from(root.querySelectorAll('button')).find((el) => {
if (!vis(el)) return false;
const t = compact(el);
return t === '确定' || t === '确 定';
});
if (ok) { ok.click(); }
return {ok: true};
}
return {ok: false};
""",
code,
)
if isinstance(picked, dict) and picked.get("ok"):
time.sleep(0.4)
return True
time.sleep(0.4)
return False
def _set_grid_field(browser: BrowserAdapter, title: str, value: str, *, as_select: bool = False) -> bool:
if not value:
return False
try:
clicked = browser.execute_script(
"""
/* SET_HEADER_CELL */
const name = String(arguments[0] || '').replace(/\\s+/g, '');
function compact(el) {
return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim();
}
const root = document.querySelector('.table-show') || document;
const th = Array.from(root.querySelectorAll('.vxe-header--column')).find((el) => compact(el) === name);
if (!th) { return {ok: false}; }
const colid = th.getAttribute('colid');
const lefts = Array.from(root.querySelectorAll('.vxe-table--fixed-left-wrapper .vxe-body--row'));
let idx = 0;
for (let i = 0; i < lefts.length; i++) {
const icon = lefts[i].querySelector('.vxe-checkbox--icon');
const cls = String((icon && icon.className) || '');
if (cls.indexOf('checkbox-checked') >= 0 && cls.indexOf('unchecked') < 0) {
idx = i;
break;
}
}
const mains = Array.from(root.querySelectorAll(
'.vxe-table--main-wrapper .vxe-body--row, .vxe-table--body-wrapper .vxe-body--row'
));
const row = mains[idx] || mains[0];
const cell = row && row.querySelector('[colid="' + colid + '"]');
if (!cell) { return {ok: false}; }
cell.scrollIntoView({block: 'center', inline: 'center'});
cell.click();
cell.dispatchEvent(new MouseEvent('dblclick', {bubbles: true, cancelable: true, view: window}));
return {ok: true, colid: colid, idx: idx};
""",
title,
)
except Exception:
clicked = None
if not (isinstance(clicked, dict) and clicked.get("ok")):
return False
time.sleep(0.3)
if as_select:
return _filter_and_pick_dropdown(browser, value)
try:
typed = browser.execute_script(
"""
/* TYPE_HEADER_CELL */
const name = String(arguments[0] || '').replace(/\\s+/g, '');
const value = String(arguments[1] || '');
function compact(el) {
return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim();
}
const root = document.querySelector('.table-show') || document;
const th = Array.from(root.querySelectorAll('.vxe-header--column')).find((el) => compact(el) === name);
if (!th) { return {ok: false}; }
const cell = root.querySelector(
'.vxe-table--main-wrapper .vxe-body--row [colid="' + th.getAttribute('colid') + '"], .vxe-body--row [colid="' + th.getAttribute('colid') + '"]'
);
if (!cell) { return {ok: false}; }
const el = cell.querySelector('input, textarea') || document.activeElement;
if (!el || !el.tagName) { return {ok: false}; }
el.focus();
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
setter.call(el, value);
el.dispatchEvent(new Event('input', {bubbles: true}));
el.dispatchEvent(new Event('change', {bubbles: true}));
return {ok: true, value: el.value};
""",
title,
value,
)
except Exception:
typed = None
if isinstance(typed, dict) and typed.get("ok"):
_type_active(browser, value)
return True
return False
def _fill_header_code(browser: BrowserAdapter, title: str, code: str) -> None:
if not code:
return
_set_grid_field(browser, title, code)
_pick_header_lookup(browser, code)
def _activate_header_cell(browser: BrowserAdapter, title: str) -> bool:
if not title:
return False
try:
clicked = browser.execute_script(
"""
/* SET_HEADER_CELL */
const name = String(arguments[0] || '').replace(/\\s+/g, '');
function compact(el) {
return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim();
}
const root = document.querySelector('.table-show') || document;
const th = Array.from(root.querySelectorAll('.vxe-header--column')).find((el) => compact(el) === name);
if (!th) { return {ok: false}; }
const colid = th.getAttribute('colid');
const lefts = Array.from(root.querySelectorAll('.vxe-table--fixed-left-wrapper .vxe-body--row'));
let idx = 0;
for (let i = 0; i < lefts.length; i++) {
const icon = lefts[i].querySelector('.vxe-checkbox--icon');
const cls = String((icon && icon.className) || '');
if (cls.indexOf('checkbox-checked') >= 0 && cls.indexOf('unchecked') < 0) {
idx = i;
break;
}
}
const mains = Array.from(root.querySelectorAll(
'.vxe-table--main-wrapper .vxe-body--row, .vxe-table--body-wrapper .vxe-body--row'
));
const row = mains[idx] || mains[0];
const cell = row && row.querySelector('[colid="' + colid + '"]');
if (!cell) { return {ok: false}; }
cell.scrollIntoView({block: 'center', inline: 'center'});
cell.click();
cell.dispatchEvent(new MouseEvent('dblclick', {bubbles: true, cancelable: true, view: window}));
return {ok: true, colid: colid, idx: idx};
""",
title,
)
except Exception:
clicked = None
return bool(isinstance(clicked, dict) and clicked.get("ok"))
def _header_card_open(browser: BrowserAdapter) -> bool:
try:
hit = browser.execute_script(
"""
/* HEADER_CARD */
"""
+ _header_dom_js()
+ """
const root = document.querySelector('.table-show') || document.body;
const t = compact(root);
if (t.indexOf('确认 Ctrl') >= 0 || t.indexOf('请填写供应商') >= 0
|| t.indexOf('状态 编辑') >= 0 || t.indexOf('状态编辑') >= 0) {
return true;
}
return !!formItem('供应商编码');
"""
)
except Exception:
hit = False
return hit is True
def _confirm_header_card(browser: BrowserAdapter) -> bool:
"""总单卡片「确认 Ctrl + Enter」,不要点货品卡片确认。"""
clicked = browser.execute_script(
"""
/* CONFIRM_HEADER_CARD */
function compact(el) {
return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim();
}
function vis(el) {
return !!(el && (el.offsetWidth || el.offsetHeight));
}
const card = Array.from(document.querySelectorAll('.table-card')).find((el) => {
const t = compact(el);
return vis(el) && t.indexOf('供应商编码') >= 0 && t.indexOf('货品编码') < 0;
});
const root = card || document.querySelector('.table-show') || document;
const hit = Array.from(root.querySelectorAll('.card-header-btn, button, .elPlus-button')).find((el) => {
if (!vis(el)) return false;
const t = compact(el).replace(/\\s+/g, '');
return t.indexOf('确认Ctrl') === 0 || t === '确认' || t.indexOf('确认') === 0;
});
if (!hit) { return {ok: false, error: 'no-confirm'}; }
hit.scrollIntoView({block: 'center', inline: 'nearest'});
hit.click();
return {ok: true, via: compact(hit).slice(0, 40)};
"""
)
driver = getattr(browser, "_driver", None)
if driver is not None:
try:
from selenium.webdriver.common.action_chains import ActionChains
for el in driver.find_elements("css selector", ".table-card .card-header-btn"):
info = driver.execute_script(
"""
const el = arguments[0];
const card = el.closest('.table-card');
const t = ((card && card.innerText) || '');
return {
text: ((el.innerText || '') + '').replace(/\\s+/g, ' ').trim(),
header: t.indexOf('供应商编码') >= 0 && t.indexOf('货品编码') < 0
};
""",
el,
)
if not (isinstance(info, dict) and info.get("header")):
continue
raw = str(info.get("text") or "")
if "确认" not in raw.replace(" ", ""):
continue
driver.execute_script(
"arguments[0].scrollIntoView({block:'center', inline:'nearest'});",
el,
)
time.sleep(0.2)
ActionChains(driver).move_to_element(el).pause(0.15).click().perform()
clicked = {"ok": True, "via": "native:" + raw[:40]}
break
except Exception as exc:
print("native_header_confirm_err=", exc, flush=True)
print("confirm_header=", clicked, flush=True)
if isinstance(clicked, dict) and clicked.get("ok"):
time.sleep(0.5)
err = _read_detail_toast(browser)
if err:
raise NodeError(err)
return True
return False
def _wait_person_picker(browser: BrowserAdapter, timeout: float = 8) -> bool:
deadline = time.time() + timeout
while time.time() <= deadline:
if _person_picker_visible(browser):
return True
time.sleep(0.3)
return _person_picker_visible(browser)
def _native_click_el(browser: BrowserAdapter, el) -> bool:
driver = getattr(browser, "_driver", None)
if driver is None or el is None:
return False
try:
from selenium.webdriver.common.action_chains import ActionChains
driver.execute_script(
"arguments[0].scrollIntoView({block:'center', inline:'center'});",
el,
)
time.sleep(0.15)
ActionChains(driver).move_to_element(el).pause(0.15).click().perform()
return True
except Exception:
return False
def _open_labeled_picker(browser: BrowserAdapter, title: str, *, dots_only: bool = False) -> bool:
opened = browser.execute_script(
"""
/* OPEN_LABELED_PICKER */
"""
+ _header_dom_js()
+ """
const title = String(arguments[0] || '');
const item = formItem(title);
if (!item) { return {ok: false, error: 'no-label'}; }
item.scrollIntoView({block: 'center', inline: 'nearest'});
const via = clickDots(item);
if (via) { return {ok: true, via: via}; }
if (arguments[1] === true) { return {ok: false, error: 'no-dots'}; }
const wrap = selectWrap(item);
if (wrap) { wrap.click(); return {ok: true, via: 'select'}; }
const input = item.querySelector('input.elPlus-input__inner, input');
if (input) { input.click(); input.focus(); return {ok: true, via: 'input'}; }
return {ok: false, error: 'no-trigger'};
""",
title,
dots_only,
)
print("open_picker=", opened, flush=True)
person = title in {"计划员", "联系人"}
if person:
if _wait_person_picker(browser, timeout=1.2):
return True
el = browser.execute_script(
"""
/* PERSON_PICKER_TRIGGER */
"""
+ _header_dom_js()
+ """
const item = formItem(arguments[0]);
if (!item) { return null; }
return item.querySelector(
'.elPlus-input-group--append button, .elPlus-input-group--append .elPlus-button'
) || item.querySelector('.pointer.f20, span.pointer');
""",
title,
)
native = _native_click_el(browser, el)
print("open_picker_native=", native, flush=True)
if _wait_person_picker(browser, timeout=8):
return True
print(
"person_picker_missing overlays=",
_scan_lookup_overlays(browser),
"source=",
_source_choice_visible(browser),
flush=True,
)
return False
if not getattr(browser, "_hold_source_choice", False):
_ignore_source_choice(browser)
return bool(isinstance(opened, dict) and opened.get("ok"))
def _read_labeled_input(browser: BrowserAdapter, title: str) -> str:
try:
result = browser.execute_script(
"""
/* READ_LABELED_INPUT */
"""
+ _header_dom_js()
+ """
const title = String(arguments[0] || '');
const item = formItem(title);
return {value: shownValue(item)};
""",
title,
)
except Exception:
result = None
if isinstance(result, dict):
return str(result.get("value") or "").strip()
return ""
def _read_required_titles(browser: BrowserAdapter) -> list[str]:
try:
result = browser.execute_script(
"""
/* REQUIRED_TITLES */
"""
+ _header_dom_js()
+ """
const titles = [];
const items = Array.from(document.querySelectorAll('.elPlus-form-item, .el-form-item')).filter(vis);
for (const item of items) {
const star = Array.from(item.querySelectorAll('*')).some((el) => compact(el) === '*');
const required = star || String(item.className || '').indexOf('is-required') >= 0
|| !!(item.querySelector('.form-item__title.required'));
if (!required) continue;
const label = item.querySelector('.form-item__title, .elPlus-form-item__label, label, .label-wrap');
const t = compact(label || item).replace(/\\*/g, '').replace(/\\s+/g, '');
if (t && titles.indexOf(t) < 0) titles.push(t);
}
return {titles: titles};
"""
)
except Exception:
result = None
if isinstance(result, dict):
return [str(t) for t in (result.get("titles") or []) if t]
return []
def _picker_bound(browser: BrowserAdapter, title: str, code: str) -> bool:
shown = _read_labeled_input(browser, title)
if title == "供应商编码":
name = _read_labeled_input(browser, "供应商名称")
return bool(name) and "请" not in name[:2] and len(name) >= 4
if title == "返利单位编码":
name = _read_labeled_input(browser, "返利单位")
return bool(name) and "请" not in name[:2] and len(name) >= 2
if title == "计划员":
dept = _read_labeled_input(browser, "计划员部门")
if code and shown and code in shown and "请" not in shown[:2]:
return True
return bool(dept) and "请" not in dept[:2] and len(dept) >= 2
if title == "联系人":
cid = _read_labeled_input(browser, "联系人ID")
return bool(cid) and "请" not in cid[:2] and len(cid) >= 2
return bool(code) and code in shown and "请填写" not in shown and "请选择" not in shown
def _close_overlay_named(browser: BrowserAdapter, title: str) -> None:
try:
browser.execute_script(
"""
/* CLOSE_NAMED_OVERLAY */
const title = String(arguments[0] || '');
function compact(el) {
return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim();
}
function vis(el) {
return !!(el && (el.offsetWidth || el.offsetHeight));
}
const modal = Array.from(document.querySelectorAll(
'.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer, .elPlus-popper'
)).find((el) => vis(el) && compact(el).indexOf(title) >= 0);
if (!modal) { return {ok: false}; }
const hit = Array.from(modal.querySelectorAll('button, .vxe-modal--close-btn, .elPlus-dialog__headerbtn')).find((el) => {
const t = compact(el);
return vis(el) && (t === '取消' || t === '取消 Esc' || t.indexOf('关闭') >= 0);
});
if (hit) { hit.click(); return {ok: true}; }
const close = modal.querySelector('.vxe-modal--close-btn, .vxe-icon-close, .elPlus-dialog__headerbtn');
if (close) { close.click(); return {ok: true, via: 'x'}; }
return {ok: false};
""",
title,
)
except Exception:
pass
time.sleep(0.3)
def _scan_lookup_overlays(browser: BrowserAdapter) -> list[str]:
try:
result = browser.execute_script(
"""
/* SCAN_LOOKUP_OVERLAYS */
function compact(el) {
return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim();
}
function vis(el) {
return !!(el && (el.offsetWidth || el.offsetHeight));
}
return Array.from(document.querySelectorAll('.vxe-modal--wrapper')).filter((el) => {
const cls = String(el.className || '');
if (!vis(el) || cls.indexOf('is--visible') < 0) return false;
const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title'));
return title !== '货源选择';
}).map((el) => {
const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title'));
return (title || compact(el).slice(0, 40));
}).filter(Boolean).slice(0, 10);
"""
)
except Exception:
result = None
if isinstance(result, list):
return [str(item) for item in result]
return []
def _unit_picker_modal_js() -> str:
return """
function compact(el) {
return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim();
}
function vis(el) {
return !!(el && (el.offsetWidth || el.offsetHeight));
}
function unitPicker() {
return Array.from(document.querySelectorAll('.vxe-modal--wrapper')).find((el) => {
const cls = String(el.className || '');
if (!vis(el) || cls.indexOf('is--visible') < 0) return false;
const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title'));
return title === '返利单位选择' || title === '供应商选择';
}) || null;
}
function sourcePicker() {
return Array.from(document.querySelectorAll('.vxe-modal--wrapper')).find((el) => {
const cls = String(el.className || '');
if (!vis(el) || cls.indexOf('is--visible') < 0) return false;
const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title'));
return title === '货源选择';
}) || null;
}
function personPicker() {
const all = Array.from(document.querySelectorAll('.vxe-modal--wrapper')).filter((el) => {
const cls = String(el.className || '');
return vis(el) && cls.indexOf('is--visible') >= 0;
});
return all.find((el) => {
const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title'));
if (title === '返利单位选择' || title === '供应商选择') return false;
if (title === '货源选择') return true;
if (title.indexOf('计划员') >= 0 || title.indexOf('联系人') >= 0 || title.indexOf('人员') >= 0) {
return true;
}
return Array.from(el.querySelectorAll('input')).some((inp) => {
const p = String(inp.placeholder || '');
return p.indexOf('计划员') >= 0 || p.indexOf('联系人') >= 0;
});
}) || null;
}
"""
def _send_keys_active(browser: BrowserAdapter, value: str, *, enter: bool = False) -> bool:
driver = getattr(browser, "_driver", None)
if driver is None:
return False
try:
from selenium.webdriver.common.keys import Keys
el = driver.switch_to.active_element
el.send_keys(Keys.CONTROL, "a")
el.send_keys(Keys.BACKSPACE)
if value:
el.send_keys(value)
if enter:
el.send_keys(Keys.ENTER)
return True
except Exception:
return False
def _click_unit_clear(browser: BrowserAdapter) -> dict:
try:
result = browser.execute_script(
"""
/* CLICK_UNIT_CLEAR */
"""
+ _unit_picker_modal_js()
+ """
const modal = unitPicker();
if (!modal) { return {ok: false, error: 'no-modal'}; }
const clear = Array.from(modal.querySelectorAll('.search-btn button, button')).find((el) => {
return vis(el) && compact(el) === '清除';
});
if (!clear) { return {ok: false, error: 'no-clear'}; }
clear.click();
return {ok: true, via: 'clear'};
"""
)
except Exception:
result = None
if isinstance(result, dict):
return result
return {"ok": False}
def _click_unit_query(browser: BrowserAdapter) -> dict:
try:
result = browser.execute_script(
"""
/* CLICK_UNIT_QUERY */
"""
+ _unit_picker_modal_js()
+ """
const modal = unitPicker();
if (!modal) { return {ok: false, error: 'no-modal'}; }
const query = modal.querySelector('.search-btn button.elPlus-button--primary')
|| Array.from(modal.querySelectorAll('.search-btn button, button')).find((el) => {
return vis(el) && compact(el) === '查询';
});
if (!query) { return {ok: false, error: 'no-query'}; }
query.click();
return {ok: true, via: 'search-btn', text: compact(query)};
"""
)
except Exception:
result = None
if isinstance(result, dict):
return result
return {"ok": False}
def _wait_unit_picker_rows(browser: BrowserAdapter, keyword: str, timeout: float = 8) -> bool:
wanted = (keyword or "").replace(" ", "")
deadline = time.time() + timeout
last = None
empty_hits = 0
while time.time() <= deadline:
try:
last = browser.execute_script(
"""
/* UNIT_PICKER_ROWS */
"""
+ _unit_picker_modal_js()
+ """
const keyword = String(arguments[0] || '').replace(/\\s+/g, '');
const modal = unitPicker();
if (!modal) { return {ok: false, error: 'no-modal'}; }
const rows = Array.from(modal.querySelectorAll('.vxe-body--row')).filter(vis);
const match = rows.find((el) => compact(el).replace(/ /g, '').indexOf(keyword) >= 0);
return {
ok: true,
match: !!match,
count: rows.length,
pager: compact(modal.querySelector('.vxe-pager, .modal-body-footer') || {innerText: ''}).slice(0, 80),
row: match ? compact(match).slice(0, 80) : '',
};
""",
wanted,
)
except Exception:
last = None
print("unit_rows=", last, flush=True)
if isinstance(last, dict) and last.get("match"):
return True
pager = str((last or {}).get("pager") or "").replace(" ", "")
if isinstance(last, dict) and "共0条" in pager:
empty_hits += 1
if empty_hits >= 3:
return False
else:
empty_hits = 0
time.sleep(0.4)
return False
def _query_overlay_and_pick(browser: BrowserAdapter, field: str, value: str) -> bool:
"""返利单位选择弹窗:填编码、回车或点查询后再点确定。"""
keyword = (value or "").strip()
if not keyword:
return False
_ignore_source_choice(browser)
filled = browser.execute_script(
"""
/* QUERY_OVERLAY_FIELD */
"""
+ _unit_picker_modal_js()
+ """
const field = String(arguments[0] || '').replace(/\\s+/g, '');
const value = String(arguments[1] || '');
const modal = unitPicker();
if (!modal) { return {ok: false, error: 'no-modal'}; }
const items = Array.from(modal.querySelectorAll('.elPlus-form-item, .el-form-item, .search-condition-item'));
const item = items.find((el) => {
const t = compact(el.querySelector('.form-item__title, .search-label__text, label, .label-wrap') || el).replace(/ /g, '');
return t === field || t.indexOf(field) >= 0;
});
let input = item && item.querySelector('input.elPlus-input__inner, input');
if (!input) {
const labels = Array.from(modal.querySelectorAll('.label-wrap, .form-item__title, .search-label__text, label'));
const label = labels.find((el) => compact(el).replace(/\\s+/g, '') === field);
let node = label;
for (let i = 0; i < 5 && node && !input; i++) {
input = node.querySelector('input.elPlus-input__inner, input');
node = node.parentElement;
}
}
if (!input) {
input = Array.from(modal.querySelectorAll('input')).find((el) => vis(el) && String(el.placeholder || '').indexOf(field) >= 0);
}
if (!input) { return {ok: false, error: 'no-input', field: field}; }
input.click();
input.focus();
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
setter.call(input, value);
input.dispatchEvent(new Event('input', {bubbles: true}));
input.dispatchEvent(new Event('change', {bubbles: true}));
return {ok: true, value: String(input.value || ''), via: field, title: compact(modal.querySelector('.vxe-modal--header-title'))};
""",
field,
keyword,
)
typed = _send_keys_active(browser, keyword, enter=True)
clicked = _click_unit_query(browser)
print("query_unit_picker=", filled, "typed=", typed, "query_click=", clicked, flush=True)
if not (isinstance(filled, dict) and filled.get("ok")):
return False
_wait_unit_picker_rows(browser, keyword)
return _click_keyword_in_overlays(browser, keyword)
def _click_keyword_in_overlays(browser: BrowserAdapter, keyword: str) -> bool:
try:
result = browser.execute_script(
"""
/* CLICK_KEYWORD_OVERLAY */
"""
+ _unit_picker_modal_js()
+ """
const keyword = String(arguments[0] || '').replace(/\\s+/g, '');
const modal = unitPicker();
const roots = modal ? [modal] : Array.from(document.querySelectorAll(
'.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer, .elPlus-popper, .elPlus-select-dropdown, .vxe-modal--box'
)).filter(vis);
for (const root of roots) {
const title = compact(root.querySelector('.vxe-modal--header-title, .vxe-modal--title'));
const t = compact(root);
if (title === '货源选择' || t.indexOf('货源选择') === 0) continue;
if (t.indexOf('请填写供应商') >= 0 && t.indexOf('采购协议号') >= 0) continue;
const row = Array.from(root.querySelectorAll(
'.vxe-body--row, tr, .elPlus-select-dropdown__item, li'
)).find((el) => vis(el) && compact(el).indexOf(keyword) >= 0);
if (!row) continue;
const radio = row.querySelector('.vxe-cell--radio, .vxe-radio--icon, input[type="radio"]');
if (radio) { radio.click(); } else { row.click(); }
const ok = Array.from(root.querySelectorAll('button')).find((el) => {
const x = compact(el);
return vis(el) && (x === '确定' || x === '确 定');
});
if (ok) { ok.click(); }
return {ok: true, text: compact(row).slice(0, 80)};
}
return {ok: false};
""",
keyword,
)
except Exception:
result = None
return bool(isinstance(result, dict) and result.get("ok"))
def _pick_labeled_code(browser: BrowserAdapter, title: str, code: str) -> None:
if not code:
return
if _picker_bound(browser, title, code):
return
keywords = [code]
_open_labeled_picker(browser, title)
time.sleep(0.3)
_type_active(browser, code)
driver = getattr(browser, "_driver", None)
if driver is not None:
try:
from selenium.webdriver.common.keys import Keys
driver.switch_to.active_element.send_keys(Keys.ENTER)
except Exception:
pass
time.sleep(0.8)
overlays = _scan_lookup_overlays(browser)
print("lookup_overlays=", overlays, "title=", title, "code=", code, flush=True)
picked = _filter_and_pick_dropdown(browser, code, exact=False)
if not picked:
picked = _pick_header_lookup(browser, code)
if not picked:
picked = _click_keyword_in_overlays(browser, code)
if not _picker_bound(browser, title, code):
raise NodeError(f"未选到{title}={code},当前={_read_labeled_input(browser, title)}")
def _source_choice_visible(browser: BrowserAdapter) -> bool:
try:
hit = browser.execute_script(
"""
/* SOURCE_CHOICE */
function compact(el) {
return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim();
}
function vis(el) {
return !!(el && (el.offsetWidth || el.offsetHeight));
}
const modal = Array.from(document.querySelectorAll(
'.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer, .elPlus-overlay'
)).find((el) => {
if (!vis(el)) return false;
const title = compact(el.querySelector(
'.vxe-modal--header-title, .vxe-modal--header, .vxe-modal--title, .elPlus-dialog__title, .el-dialog__title'
));
const t = compact(el).slice(0, 40);
if (title.indexOf('货源选择') < 0 && t.indexOf('货源选择') !== 0) return false;
const planner = Array.from(el.querySelectorAll('input')).some((inp) => {
const p = String(inp.placeholder || '');
return p.indexOf('计划员') >= 0 || p.indexOf('联系人') >= 0;
});
return !planner;
});
return !!modal;
"""
)
except Exception:
hit = False
return hit is True
def _close_source_choice(browser: BrowserAdapter) -> bool:
"""看得见的货源选择是 bug 弹层:点关闭,不要确定、不要 Escape。"""
try:
result = browser.execute_script(
"""
/* CLOSE_SOURCE_CHOICE */
function compact(el) {
return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim();
}
function vis(el) {
return !!(el && (el.offsetWidth || el.offsetHeight));
}
const hits = Array.from(document.querySelectorAll(
'.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer, .elPlus-overlay, .vxe-modal--box'
)).filter((el) => {
if (!vis(el)) return false;
const title = compact(el.querySelector(
'.vxe-modal--header-title, .vxe-modal--header, .vxe-modal--title, .elPlus-dialog__title, .el-dialog__title'
));
const t = compact(el).slice(0, 40);
return title.indexOf('货源选择') >= 0 || t.indexOf('货源选择') === 0;
});
if (!hits.length) { return {ok: false, via: 'none-visible'}; }
let via = '';
for (const modal of hits) {
const btn = Array.from(modal.querySelectorAll('button')).find((el) => {
const t = compact(el).replace(/ /g, '');
return vis(el) && (t === '关闭' || t === '取消');
});
if (btn) { btn.click(); via = via || 'close'; continue; }
const x = modal.querySelector(
'.vxe-modal--close-btn, .vxe-icon-close, .elPlus-dialog__headerbtn, .el-dialog__headerbtn'
);
if (x) { x.click(); via = via || 'x'; }
}
return {ok: true, via: via, count: hits.length};
"""
)
except Exception:
result = None
if isinstance(result, dict) and result.get("ok"):
time.sleep(0.25)
return True
return False
def _ignore_source_choice(browser: BrowserAdapter) -> None:
"""全局忽略货源选择 bug 弹层。"""
for _ in range(3):
if not _source_choice_visible(browser):
return
_close_source_choice(browser)
def _stop_if_source_choice(browser: BrowserAdapter) -> None:
_ignore_source_choice(browser)
def _pick_nth_overlay_row(browser: BrowserAdapter, nth: int, name: str = "") -> bool:
try:
result = browser.execute_script(
"""
/* PICK_NTH_OVERLAY */
const nth = Number(arguments[0] || 0);
const name = String(arguments[1] || '').replace(/\\s+/g, '');
function compact(el) {
return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '');
}
function vis(el) {
return !!(el && (el.offsetWidth || el.offsetHeight));
}
const overlays = Array.from(document.querySelectorAll(
'.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer, .elPlus-select-dropdown, .elPlus-popper'
)).filter((el) => {
if (!vis(el)) return false;
const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title') || {innerText:''});
return title !== '货源选择';
});
const roots = overlays.length ? overlays : [document];
for (const root of roots) {
const rows = Array.from(root.querySelectorAll(
'.vxe-body--row, .elPlus-select-dropdown__item, .el-select-dropdown__item, li'
)).filter(vis);
let row = name ? rows.find((el) => compact(el).indexOf(name) >= 0) : null;
if (!row && nth >= 0 && nth < rows.length) { row = rows[nth]; }
if (!row) continue;
const radio = row.querySelector('.vxe-cell--radio, .vxe-radio--icon, input[type="radio"]');
if (radio) { radio.click(); } else { row.click(); }
const ok = Array.from(root.querySelectorAll('button')).find((el) => {
const t = compact(el);
return vis(el) && (t === '确定' || t === '确 定');
});
if (ok) { ok.click(); }
return {ok: true, text: compact(row).slice(0, 80)};
}
return {ok: false};
""",
nth,
name,
)
except Exception:
result = None
return bool(isinstance(result, dict) and result.get("ok"))
def _focus_labeled_input(browser: BrowserAdapter, title: str) -> bool:
focused = browser.execute_script(
"""
/* FOCUS_LABELED_INPUT */
const title = String(arguments[0] || '');
function compact(el) {
return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim();
}
function vis(el) {
return !!(el && (el.offsetWidth || el.offsetHeight));
}
const wanted = title.replace(/\\s+/g, '');
const labels = Array.from(document.querySelectorAll(
'.form-item__title, .elPlus-form-item__label, label'
)).filter((el) => vis(el) && !el.closest('.search-condition-item'));
const label = labels.find((el) => compact(el).replace(/\\*/g, '') === wanted);
if (!label) { return {ok: false, error: 'no-label'}; }
const item = label.closest('.elPlus-form-item, .el-form-item') || label.parentElement;
const input = item && item.querySelector('input.elPlus-input__inner, input:not([type="checkbox"])');
if (!input) { return {ok: false, error: 'no-input'}; }
input.click();
input.focus();
return {ok: true, ph: input.placeholder || '', val: String(input.value || '')};
""",
title,
)
print("focus_input=", focused, flush=True)
_ignore_source_choice(browser)
return bool(isinstance(focused, dict) and focused.get("ok"))
def _hold_source_choice(browser: BrowserAdapter, hold: bool) -> None:
if hasattr(browser, "_hold_source_choice"):
browser._hold_source_choice = hold
def _person_picker_visible(browser: BrowserAdapter) -> bool:
try:
hit = browser.execute_script(
"""
/* PERSON_PICKER_VISIBLE */
"""
+ _unit_picker_modal_js()
+ """
return !!personPicker();
"""
)
except Exception:
hit = False
return hit is True
def _close_person_picker(browser: BrowserAdapter) -> None:
try:
browser.execute_script(
"""
/* CLOSE_PERSON_PICKER */
"""
+ _unit_picker_modal_js()
+ """
const modal = personPicker();
if (!modal) { return {ok: false}; }
const btn = Array.from(modal.querySelectorAll('button')).find((el) => {
const t = compact(el);
return vis(el) && (t === '关闭' || t === '取消' || t.indexOf('关闭') >= 0);
});
if (btn) { btn.click(); return {ok: true, via: 'close'}; }
const x = modal.querySelector('.vxe-modal--close-btn, .vxe-icon-close, .elPlus-dialog__headerbtn');
if (x) { x.click(); return {ok: true, via: 'x'}; }
return {ok: false};
"""
)
except Exception:
pass
time.sleep(0.2)
def _query_person_and_pick(
browser: BrowserAdapter, field: str, value: str, *, supplier_name: str = ""
) -> bool:
"""计划员三个点打开「货源选择」:按姓名查,行上还要对上供应商名称。"""
keyword = (value or "").strip()
if not keyword:
return False
focused = browser.execute_script(
"""
/* FOCUS_PERSON_FIELD */
"""
+ _unit_picker_modal_js()
+ """
const field = String(arguments[0] || '');
const modal = personPicker();
if (!modal) { return {ok: false, error: 'no-modal'}; }
const input = Array.from(modal.querySelectorAll('input')).find((el) => {
const ph = String(el.placeholder || '');
return vis(el) && ph === ('请输入' + field);
}) || Array.from(modal.querySelectorAll('input')).find((el) => {
const ph = String(el.placeholder || '');
return vis(el) && ph.indexOf(field) >= 0 && ph.indexOf('ID') < 0;
});
if (!input) { return {ok: false, error: 'no-input'}; }
input.click();
input.focus();
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
setter.call(input, String(arguments[1] || ''));
input.dispatchEvent(new Event('input', {bubbles: true}));
input.dispatchEvent(new Event('change', {bubbles: true}));
return {ok: true, ph: input.placeholder || '', title: compact(modal.querySelector('.vxe-modal--header-title'))};
""",
field,
keyword,
)
typed = _send_keys_active(browser, keyword, enter=True)
clicked = browser.execute_script(
"""
/* CLICK_PERSON_QUERY */
"""
+ _unit_picker_modal_js()
+ """
const modal = personPicker();
if (!modal) { return {ok: false, error: 'no-modal'}; }
const query = Array.from(modal.querySelectorAll('button')).find((el) => vis(el) && compact(el) === '查询');
if (!query) { return {ok: false, error: 'no-query'}; }
query.click();
return {ok: true, via: 'query'};
"""
)
print("query_person=", focused, "typed=", typed, "query_click=", clicked, flush=True)
if not (isinstance(focused, dict) and focused.get("ok")):
return False
supplier = (supplier_name or "").replace(" ", "")
deadline = time.time() + 12
while time.time() <= deadline:
last = browser.execute_script(
"""
/* PERSON_PICKER_ROWS */
"""
+ _unit_picker_modal_js()
+ """
const keyword = String(arguments[0] || '').replace(/\\s+/g, '');
const supplier = String(arguments[1] || '').replace(/\\s+/g, '');
const modal = personPicker();
if (!modal) { return {ok: false, error: 'no-modal'}; }
const rows = Array.from(modal.querySelectorAll('.vxe-body--row')).filter(vis);
const match = rows.find((el) => {
const t = compact(el).replace(/ /g, '');
if (t.indexOf(keyword) < 0) return false;
if (supplier && t.indexOf(supplier) < 0) return false;
return true;
});
return {
ok: true,
match: !!match,
count: rows.length,
pager: compact(modal.querySelector('.vxe-pager, .modal-body-footer') || {innerText: ''}).slice(0, 80),
row: match ? compact(match).slice(0, 80) : '',
};
""",
keyword,
supplier,
)
print("person_rows=", last, flush=True)
if isinstance(last, dict) and last.get("match"):
break
time.sleep(0.4)
picked = browser.execute_script(
"""
/* PICK_PERSON_ROW */
"""
+ _unit_picker_modal_js()
+ """
const keyword = String(arguments[0] || '').replace(/\\s+/g, '');
const supplier = String(arguments[1] || '').replace(/\\s+/g, '');
const modal = personPicker();
if (!modal) { return {ok: false, error: 'no-modal'}; }
const row = Array.from(modal.querySelectorAll('.vxe-body--row')).find((el) => {
const t = compact(el).replace(/ /g, '');
if (!vis(el) || t.indexOf(keyword) < 0) return false;
if (supplier && t.indexOf(supplier) < 0) return false;
return true;
});
if (!row) { return {ok: false, error: 'no-row'}; }
const radio = row.querySelector('.vxe-cell--radio, .vxe-radio--icon, input[type="radio"]');
if (radio) { radio.click(); } else { row.click(); }
const ok = Array.from(modal.querySelectorAll('button')).find((el) => {
const t = compact(el);
return vis(el) && (t === '确定' || t === '确 定');
});
if (ok) { ok.click(); }
return {ok: true, text: compact(row).slice(0, 120)};
""",
keyword,
supplier,
)
print("pick_person=", picked, flush=True)
return bool(isinstance(picked, dict) and picked.get("ok"))
def _pick_person(
browser: BrowserAdapter, title: str, name: str, *, nth: int | None = None, supplier_name: str = ""
) -> None:
wanted = (name or "").strip()
if not wanted:
return
if _picker_bound(browser, title, wanted):
return
_hold_source_choice(browser, True)
try:
_close_header_query(browser)
deadline = time.time() + 5
while time.time() <= deadline and _supplier_picker_open(browser):
time.sleep(0.3)
clicked = _open_labeled_picker(browser, title, dots_only=True)
print("open_person_dots=", clicked, flush=True)
deadline = time.time() + 8
while time.time() <= deadline:
if _person_picker_visible(browser):
break
time.sleep(0.3)
if not _person_picker_visible(browser):
raise NodeError(
f"点三个点未打开选择层 title={title} overlays={_scan_lookup_overlays(browser)}"
)
picked = _query_person_and_pick(browser, title, wanted, supplier_name=supplier_name)
if not picked and nth is not None:
picked = _pick_nth_overlay_row(browser, nth, wanted)
if not picked:
picked = _click_keyword_in_overlays(browser, wanted)
if picked:
time.sleep(0.3)
if _person_picker_visible(browser):
_close_person_picker(browser)
pid = _read_labeled_input(browser, f"{title}ID")
print(f"bound_{title}=", _read_labeled_input(browser, title), "id=", pid, flush=True)
if not _picker_bound(browser, title, wanted):
raise NodeError(
f"未选到{title}={wanted},当前={_read_labeled_input(browser, title)} id={pid}"
)
finally:
_hold_source_choice(browser, False)
def _supplier_picker_open(browser: BrowserAdapter) -> bool:
overlays = _scan_lookup_overlays(browser)
return any(item in {"返利单位选择", "供应商选择"} or "返利单位选择" in item for item in overlays)
def _pick_supplier_by_mdm(browser: BrowserAdapter, code: str, name: str = "") -> None:
value = (code or "").strip()
wanted_name = (name or "").strip()
if not value and not wanted_name:
raise NodeError("表 3 没有 supplier_mdm_code,不能选供应商")
if _picker_bound(browser, "供应商编码", value):
return
_stop_if_source_choice(browser)
opened = _supplier_picker_open(browser)
if opened:
print("lookup_overlays=", _scan_lookup_overlays(browser), "already_open=1", flush=True)
else:
_close_header_query(browser)
clicked = _open_labeled_picker(browser, "供应商编码", dots_only=True)
print("open_picker=", clicked, flush=True)
deadline = time.time() + 8
while time.time() <= deadline:
_stop_if_source_choice(browser)
overlays = _scan_lookup_overlays(browser)
print("lookup_overlays=", overlays, flush=True)
if _supplier_picker_open(browser):
opened = True
break
time.sleep(0.4)
if not opened:
raise NodeError(f"未打开返利单位选择弹窗 overlays={_scan_lookup_overlays(browser)}")
ok = False
if wanted_name:
ok = _query_overlay_and_pick(browser, "供应商名称", wanted_name)
if not ok and value:
print("unit_query_name_empty, try_code=", value, flush=True)
_click_unit_clear(browser)
time.sleep(0.3)
ok = _query_overlay_and_pick(browser, "供应商编码", value)
if not ok and value:
ok = _click_keyword_in_overlays(browser, value)
if not ok and wanted_name:
ok = _click_keyword_in_overlays(browser, wanted_name)
if not _picker_bound(browser, "供应商编码", value):
raise NodeError(
f"未选到供应商编码={value} 名称={wanted_name},当前={_read_labeled_input(browser, '供应商编码')}"
)
def _fill_pay_param(browser: BrowserAdapter, title: str, value: str) -> None:
wanted = (value or "").strip()
if not wanted:
return
if not re.fullmatch(r"\d+(\.\d+)?", wanted):
print("skip_pay_param_not_number=", wanted, flush=True)
return
print("fill_pay_param=", wanted, flush=True)
marked = browser.execute_script(
"""
/* FOCUS_PAY_PARAM */
"""
+ _header_dom_js()
+ """
const item = formItem(arguments[0]);
const input = item && item.querySelector('input.vxe-input--inner, input');
if (!input) { return false; }
input.setAttribute('data-rpa-pay', '1');
input.click();
input.focus();
return true;
""",
title,
)
driver = getattr(browser, "_driver", None)
if marked and driver is not None:
try:
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.keys import Keys
el = driver.find_element("css selector", '[data-rpa-pay="1"]')
driver.execute_script("arguments[0].removeAttribute('data-rpa-pay')", el)
ActionChains(driver).click(el).pause(0.1).key_down(Keys.CONTROL).send_keys("a").key_up(
Keys.CONTROL
).send_keys(Keys.BACKSPACE).send_keys(wanted).send_keys(Keys.TAB).perform()
time.sleep(0.2)
except Exception as exc:
print("pay_param_type_err=", exc, flush=True)
now = _read_labeled_input(browser, title)
print("pay_param_now=", now, flush=True)
if (now or "").strip() == wanted:
return
if not _pick_detail_option(browser, title, wanted):
try:
_fill_input(browser, title, [f"label={title}"], wanted)
except Exception:
_set_grid_field(browser, title, wanted)
print("pay_param_after=", _read_labeled_input(browser, title), flush=True)
def _fill_header_card(browser: BrowserAdapter, draft: RebateHeaderDraft) -> None:
_ignore_source_choice(browser)
titles = _read_required_titles(browser)
print("required=", titles, flush=True)
settle = draft.settle_mode if draft.settle_mode and draft.settle_mode != SETTLE_MODE_DEFAULT else SETTLE_MODE_WIRE
planner = (draft.planner_name or "").strip() or PLANNER_DEFAULT
contact = (draft.contact_name or "").strip() or CONTACT_DEFAULT
values = {
"供应商编码": draft.supplier_code,
"计划员": planner,
"返利合约": draft.rebate_contract,
"采购结算方式": settle,
"承付模式": draft.pay_mode,
"承付参数": PAY_PARAM_DEFAULT,
"协议开始日期": draft.protocol_start,
"协议结束日期": draft.protocol_end,
"联系人": contact,
}
selects = {"返利合约", "采购结算方式", "承付模式"}
order = [
"供应商编码",
"计划员",
"联系人",
"返利合约",
"采购结算方式",
"承付模式",
"协议开始日期",
"协议结束日期",
"承付参数",
]
skip = {"供应商名称", "返利单位编码", "返利单位"}
raw = titles or list(values.keys())
needed = list(order) + [title for title in raw if title not in order and title not in skip]
for title in needed:
if title in skip:
continue
value = values.get(title, "")
if not value:
continue
if title == "供应商编码":
_pick_supplier_by_mdm(browser, value, name=draft.supplier_name)
elif title == "计划员":
_pick_person(browser, "计划员", value, supplier_name=draft.supplier_name)
elif title == "联系人":
_pick_person(browser, "联系人", value, nth=1)
elif title == "承付参数":
_fill_pay_param(browser, title, value)
elif title in selects:
if not _pick_detail_option(browser, title, value):
if title == "采购结算方式":
_pick_detail_option(browser, title, SETTLE_MODE_WIRE)
else:
try:
_fill_input(browser, title, [f"label={title}"], value)
except Exception:
_set_grid_field(browser, title, value)
print(
"card_values=",
{
"supplier": _read_labeled_input(browser, "供应商编码"),
"supplierName": _read_labeled_input(browser, "供应商名称"),
"unit": _read_labeled_input(browser, "返利单位编码"),
"planner": _read_labeled_input(browser, "计划员"),
"contact": _read_labeled_input(browser, "联系人"),
"payParam": _read_labeled_input(browser, "承付参数"),
},
flush=True,
)
if draft.protocol_start:
try:
_fill_input(browser, "协议开始日期", HEADER["protocol_start"], draft.protocol_start)
except Exception:
_set_grid_field(browser, "协议开始日期", draft.protocol_start)
if draft.protocol_end:
try:
_fill_input(browser, "协议结束日期", HEADER["protocol_end"], draft.protocol_end)
except Exception:
_set_grid_field(browser, "协议结束日期", draft.protocol_end)
_fill_pay_param(browser, "承付参数", PAY_PARAM_DEFAULT)
print(
"card_values_before_save=",
{
"planner": _read_labeled_input(browser, "计划员"),
"payParam": _read_labeled_input(browser, "承付参数"),
"supplier": _read_labeled_input(browser, "供应商名称"),
},
flush=True,
)
if (_read_labeled_input(browser, "承付参数") or "").strip() != PAY_PARAM_DEFAULT:
raise NodeError("总单承付参数未填成25,不能保存")
def _wait_header_card(browser: BrowserAdapter, timeout: float = 8, *, contract_no: str = "") -> bool:
if _header_card_open(browser):
return True
if getattr(browser, "_driver", None) is None:
return False
deadline = time.time() + timeout
while time.time() <= deadline:
if contract_no:
select_header_row(browser, contract_no)
_click_header_toolbar(browser, "编辑") or _click_toolbar(browser, "编辑")
time.sleep(0.4)
if _header_card_open(browser):
return True
return False
def fill_rebate_header(browser: BrowserAdapter, draft: RebateHeaderDraft, *, contract_no: str = "") -> None:
_switch_agreement_frame(browser)
_ignore_source_choice(browser)
if _wait_header_card(browser, contract_no=contract_no):
print("header_card=open", flush=True)
_fill_header_card(browser, draft)
if not _picker_bound(browser, "供应商编码", draft.supplier_code):
raise NodeError("总单供应商仍为空")
return
grid = _activate_header_cell(browser, "供应商编码") or _activate_header_cell(browser, "承付模式")
if grid:
_set_grid_field(browser, "承付模式", draft.pay_mode, as_select=True)
_set_grid_field(browser, "返利合约", draft.rebate_contract, as_select=True)
if not _set_grid_field(browser, "采购结算方式", draft.settle_mode, as_select=True):
_set_grid_field(browser, "采购结算方式", SETTLE_MODE_WIRE, as_select=True)
_set_grid_field(browser, "承付参数", PAY_PARAM_DEFAULT)
_fill_header_code(browser, "供应商编码", draft.supplier_code)
_fill_header_code(browser, "计划员", draft.planner_name or PLANNER_DEFAULT)
_fill_header_code(browser, "联系人", draft.contact_name or CONTACT_DEFAULT)
_set_grid_field(browser, "协议开始日期", draft.protocol_start)
_set_grid_field(browser, "协议结束日期", draft.protocol_end)
return
_fill_select(browser, "返利合约", HEADER["rebate_contract"], draft.rebate_contract)
if draft.supplier_code:
_fill_input(browser, "供应商编码", HEADER["supplier_code"], draft.supplier_code)
_pick_header_lookup(browser, draft.supplier_code)
_fill_input(browser, "计划员", HEADER["planner"], draft.planner_name or PLANNER_DEFAULT)
try:
_fill_select(browser, "采购结算方式", HEADER["settle_mode"], draft.settle_mode)
except Exception:
_fill_select(browser, "采购结算方式", HEADER["settle_mode"], SETTLE_MODE_WIRE)
_fill_select(browser, "承付模式", HEADER["pay_mode"], draft.pay_mode)
_fill_input(browser, "协议开始日期", HEADER["protocol_start"], draft.protocol_start)
_fill_input(browser, "协议结束日期", HEADER["protocol_end"], draft.protocol_end)
def _scan_rebate_order_no(browser: BrowserAdapter, *, contract_no: str = "") -> str:
try:
text = browser.execute_script(
"""
/* READ_REBATE_ORDER_NO */
const no = String(arguments[0] || '');
const heads = Array.from(document.querySelectorAll('.vxe-header--column')).map((th) => ({
title: (th.innerText || '').replace(/\\s+/g, '').trim(),
colid: th.getAttribute('colid'),
}));
const rows = Array.from(document.querySelectorAll('.vxe-body--row'));
const row = rows.find((r) => !no || (r.innerText || '').indexOf(no) >= 0) || rows[0];
function cell(name) {
const h = heads.find((x) => x.title === name);
if (!h || !row) { return ''; }
const c = row.querySelector('[colid="' + h.colid + '"]');
return c ? (c.innerText || '').replace(/\\s+/g, ' ').trim() : '';
}
return cell('协议总单ID') || cell('采购协议总单ID') || cell('采购协议号') || '';
""",
contract_no,
)
except Exception:
text = ""
return _clean_order_no(text, contract_no=contract_no)
def _clean_order_no(raw: object, *, contract_no: str = "") -> str:
text = "" if raw is None else str(raw).strip()
if not text:
return ""
if re.match(r"^\d{4}-\d{2}-\d{2}", text):
return ""
if text in {"总单号", "采购协议号", "协议编号", "采购协议总单ID", "协议总单号", "协议总单ID"}:
return ""
return text
def save_rebate_header(browser: BrowserAdapter, *, contract_no: str = "") -> str:
if _header_card_open(browser) and not _picker_bound(browser, "供应商编码", ""):
raise NodeError("总单供应商为空,不能保存")
if not _native_click_header_toolbar(browser, "保存"):
if not _click_header_toolbar(browser, "保存"):
if not _click_toolbar(browser, "保存"):
browser.click_first(SAVE["button"])
time.sleep(0.5)
err = _read_detail_toast(browser)
if err:
raise NodeError(err)
deadline = time.time() + 15
last = ""
while time.time() <= deadline:
last = _scan_rebate_order_no(browser, contract_no=contract_no)
body = browser.read_body_text() or ""
if last or "保存成功" in body:
return last
time.sleep(0.4)
return last
def read_rebate_order_no(browser: BrowserAdapter, *, contract_no: str = "") -> str:
deadline = time.time() + 15
last = ""
while time.time() <= deadline:
last = _scan_rebate_order_no(browser, contract_no=contract_no)
if last:
return last
time.sleep(0.4)
return last
def run_header_flow(
browser: BrowserAdapter,
ctx: RebateFlowContext,
contract_no: str,
draft: RebateHeaderDraft | None = None,
) -> str:
_HEADER_ACCEPT.reset()
header = draft or header_draft_for_contract(contract_no)
try:
open_rebate_center(browser, ctx)
_HEADER_ACCEPT.note("S02", "打开返利中心", {"url": ctx.rebate_url}, ok=True)
try:
login_rebate_center(browser, ctx)
_HEADER_ACCEPT.note("S03", "登录", {"user": ctx.username}, ok=True)
except Exception as exc:
evidence_shot(browser, "rebate-header-login-failed", failed=True)
_HEADER_ACCEPT.note(
"S03",
"登录",
{"user": ctx.username, "error": exc},
ok=False,
raise_error=False,
)
raise
enter_purchase_agreement(browser)
_HEADER_ACCEPT.note("S04", "进入采购协议", {"menu": "采购协议管理"}, ok=True)
found = search_contract(browser, contract_no)
if not found:
href = ""
try:
href = str(browser.execute_script("return location.href") or "")
except Exception:
href = ""
if "ssoLogin" in href or "login" in href.lower() or "wel/index" in href:
login_rebate_center(browser, ctx)
enter_purchase_agreement(browser)
found = search_contract(browser, contract_no)
if not found:
print("header_selection=", read_header_selection(browser, contract_no), flush=True)
_HEADER_ACCEPT.note(
"S05",
"查号",
{"contractNo": contract_no, "found": "yes" if found else "no"},
ok=found,
)
if not found:
raise NodeError("查号未搜到合同,不能填总单")
_close_header_query(browser)
picked = select_header_row_result(browser, contract_no)
if not (isinstance(picked, dict) and picked.get("ok")):
raise NodeError("未按合同编号勾中总单行")
open_header_form(browser, contract_no)
_HEADER_ACCEPT.note("S06", "打开总单", {"contractNo": contract_no}, ok=True)
fill_rebate_header(browser, header, contract_no=contract_no)
_HEADER_ACCEPT.note(
"S07",
"填总单",
{
"planner": header.planner_name,
"payMode": header.pay_mode,
"protocolEnd": header.protocol_end,
"rebateContract": header.rebate_contract,
},
ok=True,
)
pay_before = _read_labeled_input(browser, "承付参数")
planner_before = _read_labeled_input(browser, "计划员")
print("before_save pay_param=", pay_before, "planner=", planner_before, flush=True)
saved = save_rebate_header(browser, contract_no=contract_no)
_HEADER_ACCEPT.note(
"S08",
"保存",
{"saved": "yes", "payParam": pay_before},
ok=(pay_before or "").strip() == PAY_PARAM_DEFAULT,
)
_confirm_header_card(browser)
order_no = saved or read_rebate_order_no(browser, contract_no=contract_no)
_HEADER_ACCEPT.note(
"S09",
"读总单号",
{"rebateOrderNo": order_no, "payParam": pay_before},
ok=True,
raise_error=False,
)
return order_no or contract_no
finally:
print(_HEADER_ACCEPT.summary_text(), end="", flush=True)
_HEADER_ACCEPT.dump()
IMPORT = {
"menu": [
"text=导入导出",
"button:contains(导入导出)",
"span:contains(导入导出)",
"a:contains(导入导出)",
],
"download": [
"text=下载模板",
"text=下载导入模板",
"text=导出模板",
"text=导入模板",
"span:contains(下载模板)",
"a:contains(下载模板)",
"button:contains(下载模板)",
],
"upload_item": [
"text=导入",
"span:contains(导入)",
"button:contains(导入)",
"a:contains(导入)",
],
"file": [
'input[type="file"]',
'input[accept*="excel"]',
'input[accept*=".xls"]',
'input[accept*="sheet"]',
],
"confirm": [
"text=确定",
"button:contains(确定)",
"text=导入",
"button:contains(导入)",
],
}
IMPORT_HEADERS = (
"货品编码",
"数量",
"含税协议单价",
"税率",
"关税税率",
"承付模式",
"承付参数",
"细单开始日期",
"细单结束日期",
"采购结算方式",
"特定补偿预估值",
"进货补偿预估值",
"细单备注",
"货源细单ID",
)
_HEADER_ALIASES = {
"货品编码": ("货品ID", "货品编码", "操作码", "商品编码"),
"数量": ("数量",),
"含税协议单价": ("含税协议单价", "单价", "协议单价"),
"税率": ("税率",),
"关税税率": ("关税税率",),
"承付模式": ("承付模式", "承付方式"),
"承付参数": ("承付参数", "参数"),
"细单开始日期": ("细单开始日期", "细单开始时间", "开始日期"),
"细单结束日期": ("细单结束日期", "失效日期", "结束日期"),
"采购结算方式": ("采购结算方式", "结算方式"),
"特定补偿预估值": ("特定补偿预估值", "特定补偿"),
"进货补偿预估值": ("进货补偿预估值", "进货补偿"),
"细单备注": ("细单备注", "备注"),
"货源细单ID": ("货源细单ID", "货源细单号", "货源信息"),
}
_DOWNLOAD_TITLES = ("下载模板", "下载导入模板", "导出模板", "导入模板", "模板下载")
_SUCCESS_RE = re.compile(r"成功[^0-9]{0,10}(\d+)")
_FAIL_RE = re.compile(r"失败[^0-9]{0,10}(\d+)")
_READ_IMPORT_RESULT = """
/* READ_IMPORT_RESULT */
const body = (document.body && document.body.innerText) || '';
function num(re) {
const m = body.match(re);
return m ? Number(m[1]) : 0;
}
const reasons = Array.from(document.querySelectorAll(
'.el-message, .el-notification, .el-dialog, .vxe-body--row, tr, .ant-message'
)).map((el) => (el.innerText || '').replace(/\\s+/g, ' ').trim()).filter((t) => {
return t && (t.indexOf('失败') >= 0 || t.indexOf('重复') >= 0 || t.indexOf('错误') >= 0);
}).slice(0, 10);
return {
success: num(/成功[^0-9]{0,10}(\\d+)/),
fail: num(/失败[^0-9]{0,10}(\\d+)/),
reasons,
body: body.slice(0, 800),
};
"""
def goods_search_id(line: RebateImportLine) -> str:
"""返利中心增加细单按货品ID搜品种,用接口 3 的 goodsId。"""
return _cell(line.goods_id) or _cell(line.goods_code)
def build_import_rows(
lines: list[RebateImportLine] | tuple[RebateImportLine, ...],
*,
today: str | None = None,
) -> list[dict[str, str]]:
day = today_iso(today)
end = year_end_iso(day)
rows: list[dict[str, str]] = []
for line in lines:
rows.append(
{
"货品编码": goods_search_id(line),
"数量": _cell(line.qty),
"含税协议单价": _cell(line.tax_price),
"税率": TAX_RATE_DEFAULT,
"关税税率": TAX_RATE_DEFAULT,
"承付模式": PAY_MODE_CODE,
"承付参数": PAY_PARAM_DEFAULT,
"细单开始日期": day,
"细单结束日期": end,
"采购结算方式": SETTLE_CODE,
"特定补偿预估值": COMPENSATION_DEFAULT,
"进货补偿预估值": COMPENSATION_DEFAULT,
"细单备注": _cell(line.order_no),
"货源细单ID": _cell(line.source_line_id),
}
)
return rows
def build_import_draft(
*,
lines: list[RebateImportLine] | tuple[RebateImportLine, ...],
today: str | None = None,
) -> RebateImportDraft:
packed = tuple(lines)
if not packed:
raise NodeError("细单至少一行")
for line in packed:
if not goods_search_id(line) or not _cell(line.order_no):
raise NodeError("细单货品ID和订单号必须用订单中心的")
if not _cell(line.qty) or not _cell(line.tax_price):
raise NodeError("细单数量和含税协议单价必须用订单中心的")
return RebateImportDraft(lines=packed, today=today_iso(today))
def _line_from_mapping(item: dict) -> RebateImportLine:
goods_id = _cell(item.get("goodsId") or item.get("goods_id"))
goods_code = _cell(item.get("goodsCode") or item.get("goods_code"))
tax_price = _cell(item.get("taxPrice") or item.get("tax_price") or item.get("price") or item.get("unit_price"))
tax_rate = _tax_rate_of(
item.get("taxRate") or item.get("tax_rate") or item.get("purchase_tax_rate")
)
return RebateImportLine(
goods_code=goods_code,
qty=_cell(item.get("qty") or item.get("quantity") or item.get("saleQty")),
tax_price=tax_price,
order_no=_cell(item.get("orderNo") or item.get("order_no")),
source_line_id=_cell(
item.get("sourceLineId")
or item.get("source_line_id")
or item.get("goodsSourceId")
),
goods_id=goods_id,
tax_rate=tax_rate,
net_price=_untaxed_price(tax_price, tax_rate),
)
def import_draft_from_env(*, today: str | None = None) -> RebateImportDraft:
raw = (os.getenv("REBATE_IMPORT_LINES") or "").strip()
if raw:
payload = json.loads(raw)
if not isinstance(payload, list):
payload = [payload]
lines = [_line_from_mapping(item) for item in payload]
else:
lines = [
RebateImportLine(
goods_code=os.getenv("REBATE_GOODS_CODE") or "",
qty=os.getenv("REBATE_QTY") or "",
tax_price=os.getenv("REBATE_TAX_PRICE") or "",
order_no=os.getenv("REBATE_ORDER_NO") or "",
source_line_id=os.getenv("REBATE_SOURCE_LINE_ID") or "",
goods_id=os.getenv("REBATE_GOODS_ID") or "",
)
]
return build_import_draft(lines=lines, today=today)
def write_canonical_template(path: Path) -> Path:
from openpyxl import Workbook
dest = Path(path)
dest.parent.mkdir(parents=True, exist_ok=True)
wb = Workbook()
ws = wb.active
ws.title = "细单"
for col, name in enumerate(IMPORT_HEADERS, 1):
ws.cell(1, col, name)
wb.save(dest)
return dest
def _header_titles(row) -> list[str]:
titles = []
for cell in row:
text = _cell(cell.value).lstrip("*")
if text:
titles.append(text)
return titles
def _header_row_index(ws) -> int:
aliases = {name for names in _HEADER_ALIASES.values() for name in names}
for index, row in enumerate(ws.iter_rows(min_row=1, max_row=15), 1):
titles = _header_titles(row)
if any(title in aliases for title in titles):
return index
return 1
def _col_map(ws, header_row: int) -> dict[str, int]:
mapping: dict[str, int] = {}
for cell in ws[header_row]:
title = _cell(cell.value).lstrip("*")
if not title or not cell.column:
continue
for field, names in _HEADER_ALIASES.items():
if title in names and field not in mapping:
mapping[field] = int(cell.column)
return mapping
def fill_import_excel(
template_path: Path,
rows: list[dict[str, str]],
output_path: Path | None = None,
) -> Path:
from openpyxl import load_workbook
src = Path(template_path)
dest = Path(output_path) if output_path else src.with_name(src.stem + "_filled.xlsx")
dest.parent.mkdir(parents=True, exist_ok=True)
if src.suffix.lower() != ".xlsx":
write_canonical_template(dest)
elif src.resolve() != dest.resolve():
shutil.copy2(src, dest)
wb = load_workbook(dest)
ws = wb.active
header_row = _header_row_index(ws)
colmap = _col_map(ws, header_row)
used = [int(cell.column) for cell in ws[header_row] if cell.column]
next_col = (max(used) if used else 0) + 1
for field in IMPORT_HEADERS:
if field in colmap:
continue
ws.cell(header_row, next_col, field)
colmap[field] = next_col
next_col += 1
start = header_row + 1
for offset, row in enumerate(rows):
for field, value in row.items():
col = colmap.get(field)
if col:
ws.cell(start + offset, col, value)
wb.save(dest)
return dest
def _click_text(browser: BrowserAdapter, title: str) -> bool:
if _click_toolbar(browser, title):
return True
try:
browser.click_first(
[
f"text={title}",
f"button:contains({title})",
f"span:contains({title})",
f"a:contains({title})",
f'//li[contains(.,"{title}")]',
]
)
return True
except Exception:
return False
def _wait_local_download(dest_dir: Path, before: set[str], timeout_seconds: int = 20) -> Path:
deadline = time.time() + timeout_seconds
while time.time() <= deadline:
hits = [
p
for p in dest_dir.iterdir()
if p.name not in before
and p.suffix.lower() == ".xlsx"
and not str(p).endswith(".crdownload")
and not Path(str(p) + ".crdownload").exists()
]
if hits:
return max(hits, key=lambda p: p.stat().st_mtime)
time.sleep(0.3)
raise RuntimeError("未下到细单模板")
def download_detail_template(browser: BrowserAdapter, dest_dir: Path) -> Path:
dest = Path(dest_dir)
dest.mkdir(parents=True, exist_ok=True)
setter = getattr(browser, "set_download_dir", None)
if callable(setter):
setter(str(dest.resolve()))
before = {p.name for p in dest.iterdir()}
_switch_agreement_frame(browser)
if not _click_text(browser, "导入导出"):
try:
browser.click_first(IMPORT["menu"])
except Exception:
pass
downloaded = False
for title in _DOWNLOAD_TITLES:
if _click_text(browser, title):
downloaded = True
break
if not downloaded:
browser.click_first(IMPORT["download"])
waiter = getattr(browser, "wait_for_download", None)
if callable(waiter):
try:
return Path(waiter(".xlsx"))
except Exception:
pass
return _wait_local_download(dest, before)
def upload_detail_file(browser: BrowserAdapter, file_path: Path) -> None:
_switch_agreement_frame(browser)
if not _click_text(browser, "导入"):
_click_text(browser, "导入导出")
if not _click_text(browser, "导入"):
try:
browser.click_first(IMPORT["upload_item"])
except Exception:
pass
path = str(Path(file_path).resolve())
browser.upload_file_first(IMPORT["file"], path)
try:
browser.click_first(IMPORT["confirm"])
except Exception:
pass
def _scan_import_result(browser: BrowserAdapter, *, expected_lines: int = 0) -> RebateImportResult:
raw = None
try:
raw = browser.execute_script(_READ_IMPORT_RESULT)
except Exception:
raw = None
body = ""
success = 0
fail = 0
reasons: list[str] = []
if isinstance(raw, dict):
try:
success = int(raw.get("success") or 0)
except (TypeError, ValueError):
success = 0
try:
fail = int(raw.get("fail") or 0)
except (TypeError, ValueError):
fail = 0
reasons = [_cell(x) for x in (raw.get("reasons") or []) if _cell(x)]
body = str(raw.get("body") or "")
if not body:
body = browser.read_body_text() or ""
if success == 0:
matched = _SUCCESS_RE.search(body)
if matched:
success = int(matched.group(1))
if fail == 0:
matched = _FAIL_RE.search(body)
if matched:
fail = int(matched.group(1))
if "存在重复的编码" in body:
if "存在重复的编码" not in reasons:
reasons.append("存在重复的编码")
if fail == 0:
fail = expected_lines or 1
if success == 0 and fail == 0 and "导入成功" in body:
success = expected_lines or 1
parsed = success + fail > 0 or "导入成功" in body or bool(reasons)
return RebateImportResult(success, fail, tuple(reasons), parsed)
_SELECT_HEADER_ROW_JS = """
/* SELECT_HEADER_ROW */
const no = String(arguments[0] || '').trim().replace(/\\s+/g, '');
function compact(el) {
return ((el && (el.innerText || el.textContent)) || '').replace(/\\s+/g, '');
}
function rowChecked(row) {
if (!row) return false;
const icon = row.querySelector('.vxe-checkbox--icon');
const cls = String((icon && icon.className) || '');
return cls.indexOf('checkbox-checked') >= 0 && cls.indexOf('unchecked') < 0;
}
function checkLeft(row) {
if (!row) return false;
if (rowChecked(row)) return true;
const box = row.querySelector('.vxe-cell--checkbox:not(.is--disabled), .vxe-checkbox--icon');
if (!box) return false;
if (String(box.className || '').indexOf('is--disabled') >= 0) return false;
box.click();
return true;
}
const root = document.querySelector('.table-show');
if (!root) { return {ok: false, error: 'no-header-table'}; }
if (!no) { return {ok: false, error: 'empty'}; }
const col = Array.from(root.querySelectorAll('.vxe-header--column')).find((th) => {
return compact(th) === '合同编号';
});
if (!col) { return {ok: false, error: 'no-合同编号-col'}; }
const colid = col.getAttribute('colid');
const mains = Array.from(root.querySelectorAll(
'.vxe-table--main-wrapper .vxe-body--row, .vxe-table--body-wrapper .vxe-body--row'
));
let idx = -1;
let hitText = '';
for (let i = 0; i < mains.length; i++) {
const cell = mains[i].querySelector('[colid="' + colid + '"]');
const t = compact(cell);
if (t === no || (t && t.indexOf(no) >= 0)) {
idx = i;
hitText = t;
break;
}
}
if (idx < 0) {
const cells = Array.from(root.querySelectorAll('.vxe-body--row [colid="' + colid + '"]'));
for (const cell of cells) {
const t = compact(cell);
if (t !== no && t.indexOf(no) < 0) continue;
const row = cell.closest('.vxe-body--row');
idx = mains.indexOf(row);
if (idx < 0) {
const all = Array.from(root.querySelectorAll('.vxe-body--row'));
idx = all.indexOf(row);
}
hitText = t;
break;
}
}
if (idx < 0) {
return {ok: false, error: 'row', colid: colid, rows: mains.length};
}
const lefts = Array.from(root.querySelectorAll('.vxe-table--fixed-left-wrapper .vxe-body--row'));
const left = lefts[idx] || null;
const main = mains[idx] || null;
if (main) { main.scrollIntoView({block: 'center'}); }
const checked = checkLeft(left);
return {
ok: !!checked,
text: hitText || compact(main).slice(0, 80),
idx: idx,
colid: colid,
checked: rowChecked(left),
};
"""
def select_header_row(browser: BrowserAdapter, contract_no: str) -> bool:
"""按总单表「合同编号」列勾中那一行,禁止拿第一行或 CG- 号凑合。"""
result = select_header_row_result(browser, contract_no)
return bool(isinstance(result, dict) and result.get("ok"))
def select_header_row_result(browser: BrowserAdapter, contract_no: str) -> dict:
_switch_agreement_frame(browser)
try:
result = browser.execute_script(_SELECT_HEADER_ROW_JS, contract_no)
except Exception:
result = None
if isinstance(result, dict):
return result
return {"ok": False, "error": "script"}
def read_header_selection(browser: BrowserAdapter, contract_no: str) -> dict:
"""读总单表当前勾中行的合同编号,给续跑脚本确认没拿错行。"""
_switch_agreement_frame(browser)
try:
result = browser.execute_script(
"""
/* HEADER_SELECTION */
const no = String(arguments[0] || '').trim().replace(/\\s+/g, '');
function compact(el) {
return ((el && (el.innerText || el.textContent)) || '').replace(/\\s+/g, '');
}
const root = document.querySelector('.table-show');
if (!root) { return {ok: false, error: 'no-header-table'}; }
const col = Array.from(root.querySelectorAll('.vxe-header--column')).find((th) => {
return compact(th) === '合同编号';
});
const colid = col ? col.getAttribute('colid') : '';
const mains = Array.from(root.querySelectorAll(
'.vxe-table--main-wrapper .vxe-body--row, .vxe-table--body-wrapper .vxe-body--row'
));
const lefts = Array.from(root.querySelectorAll(
'.vxe-table--fixed-left-wrapper .vxe-body--row'
));
const cells = colid
? Array.from(root.querySelectorAll('.vxe-body--row [colid="' + colid + '"]')).map((c) => compact(c))
: [];
let checkedIdx = -1;
for (let i = 0; i < lefts.length; i++) {
const icon = lefts[i].querySelector('.vxe-checkbox--icon');
const cls = String((icon && icon.className) || '');
if (cls.indexOf('checkbox-checked') >= 0 && cls.indexOf('unchecked') < 0) {
checkedIdx = i;
break;
}
}
const contract = (checkedIdx >= 0 && cells[checkedIdx]) ? cells[checkedIdx] : (cells[0] || '');
return {
ok: !!(no && contract && contract.indexOf(no) >= 0 && checkedIdx >= 0),
colid: colid,
rows: mains.length,
cells: cells,
checkedIdx: checkedIdx,
contract: contract,
pager: compact(root.querySelector('.vxe-pager, .elPlus-pagination')),
};
""",
contract_no,
)
except Exception:
result = None
if isinstance(result, dict):
return result
return {"ok": False, "error": "script"}
def _goods_add_visible(browser: BrowserAdapter) -> bool:
try:
hit = browser.execute_script(
"""
/* GOODS_ADD_VISIBLE */
function compact(el) {
return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim();
}
function goodsTable() {
const header = document.querySelector('.table-show .single-table');
const all = Array.from(document.querySelectorAll('.single-table')).filter((el) => {
return el !== header && (el.offsetWidth || el.offsetHeight);
});
return all.find((el) => compact(el).indexOf('货品') >= 0)
|| all.find((el) => el.closest('.elPlus-tab-pane'))
|| all[0]
|| null;
}
const table = goodsTable();
if (!table) return false;
const nodes = Array.from(table.querySelectorAll(
'.table-toolbar button, .table-toolbar .vxe-button, .table-toolbar span, .table-toolbar a, .table-toolbar-btn__text'
));
return nodes.some((el) => {
if (!(el.offsetWidth || el.offsetHeight)) return false;
const t = compact(el);
return t === '新增' || t === '+新增' || t === '新增细单' || t === '添加细单';
});
"""
)
except Exception:
hit = False
return hit is True
def _wait_goods_add_button(browser: BrowserAdapter, timeout: float = 8) -> bool:
deadline = time.time() + timeout
while time.time() <= deadline:
_switch_agreement_frame(browser)
if _goods_add_visible(browser):
return True
_click_text(browser, "货品")
time.sleep(0.4)
return False
def _click_add_detail(browser: BrowserAdapter) -> bool:
_switch_agreement_frame(browser)
clicked = browser.execute_script(
"""
/* CLICK_GOODS_ADD */
function compact(el) {
return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim();
}
function goodsTable() {
const header = document.querySelector('.table-show .single-table');
const all = Array.from(document.querySelectorAll('.single-table')).filter((el) => {
return el !== header && (el.offsetWidth || el.offsetHeight);
});
return all.find((el) => compact(el).indexOf('货品') >= 0)
|| all.find((el) => el.closest('.elPlus-tab-pane'))
|| all[0]
|| null;
}
const table = goodsTable();
if (!table) { return {ok: false, error: 'goods-table'}; }
const nodes = Array.from(table.querySelectorAll(
'.table-toolbar button, .table-toolbar .vxe-button, .table-toolbar span, .table-toolbar a, .table-toolbar-btn__text'
));
const hit = nodes.find((el) => {
if (!(el.offsetWidth || el.offsetHeight)) return false;
const t = compact(el);
return t === '新增' || t === '+新增' || t === '新增细单' || t === '添加细单';
});
if (!hit) { return {ok: false}; }
hit.click();
return {ok: true, text: compact(hit)};
"""
)
if isinstance(clicked, dict) and clicked.get("ok"):
time.sleep(0.8)
return True
return False
def _click_goods_toolbar(browser: BrowserAdapter, title: str) -> bool:
"""只点货品工具栏(新增旁边那颗),不要点总单工具栏。"""
clicked = browser.execute_script(
"""
/* SAVE_DETAIL */
const title = String(arguments[0] || '').replace(/\\s+/g, '').trim();
function compact(el) {
return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim();
}
function vis(el) {
return !!(el && (el.offsetWidth || el.offsetHeight));
}
const bars = Array.from(document.querySelectorAll('.table-toolbar')).filter(vis);
const bar = bars.find((el) => {
const t = compact(el);
return t.indexOf('新增') >= 0 && t.indexOf('保存') >= 0 && t.indexOf('送审') < 0;
}) || bars.find((el) => compact(el).indexOf('新增') >= 0);
if (!bar) { return {ok: false, error: 'no-detail-bar'}; }
const hit = Array.from(bar.querySelectorAll(
'button, .table-toolbar-btn, .table-toolbar-btn__text, span'
)).find((el) => vis(el) && compact(el) === title);
if (!hit) { return {ok: false, error: 'no-btn'}; }
const btn = hit.closest('button, .table-toolbar-btn') || hit;
btn.click();
return {ok: true, via: compact(hit)};
""",
title,
)
return bool(isinstance(clicked, dict) and clicked.get("ok"))
def _native_click_goods_toolbar(browser: BrowserAdapter, title: str) -> bool:
driver = getattr(browser, "_driver", None)
if driver is None:
return False
wanted = (title or "").replace(" ", "")
try:
from selenium.webdriver.common.action_chains import ActionChains
for bar in driver.find_elements("css selector", ".table-toolbar"):
text = (bar.text or "").replace("\n", " ")
if "新增" not in text or "保存" not in text or "送审" in text:
continue
for el in bar.find_elements("css selector", "button, .table-toolbar-btn, .table-toolbar-btn__text"):
raw = (el.text or "").replace("\n", "").replace(" ", "")
if raw != wanted:
continue
driver.execute_script("arguments[0].scrollIntoView({block:'center'});", el)
ActionChains(driver).move_to_element(el).pause(0.15).click().perform()
return True
except Exception:
return False
return False
def _confirm_save_popup(browser: BrowserAdapter) -> bool:
"""货品保存时若弹出确认,再点确认。"""
try:
clicked = browser.execute_script(
"""
/* CONFIRM_SAVE_POPUP */
function compact(el) {
return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim();
}
function vis(el) {
return !!(el && (el.offsetWidth || el.offsetHeight));
}
const box = Array.from(document.querySelectorAll(
'.elPlus-message-box, .el-message-box, .elPlus-dialog, .el-dialog, .vxe-modal--wrapper, .ant-modal'
)).find((el) => {
if (!vis(el)) return false;
const t = compact(el);
return t.indexOf('确认') >= 0 || t.indexOf('确定') >= 0 || t.indexOf('是否') >= 0;
});
if (!box) { return {ok: false, asked: false}; }
const hit = Array.from(box.querySelectorAll('button, .elPlus-button, span')).find((el) => {
if (!vis(el)) return false;
const t = compact(el);
return t === '确定' || t === '确 定' || t === '确认' || t === '确 认' || t === '是';
});
if (!hit) { return {ok: false, asked: true}; }
hit.click();
return {ok: true, asked: true, via: compact(hit)};
"""
)
except Exception:
clicked = None
return bool(isinstance(clicked, dict) and clicked.get("ok"))
def _goods_id_field_visible(browser: BrowserAdapter) -> bool:
try:
hit = browser.execute_script(
"""
/* GOODS_ID_FIELD */
function labelText(el) {
const n = el.querySelector('.search-label__text');
return ((n && n.innerText) || '').replace(/\\s+/g, '').trim();
}
return Array.from(document.querySelectorAll('.search-condition-item')).some((el) => {
return (el.offsetWidth || el.offsetHeight) && labelText(el) === '货品ID';
});
"""
)
except Exception:
hit = False
return hit is True
def _open_goods_id_query(browser: BrowserAdapter) -> None:
browser.execute_script(
"""
/* OPEN_GOODS_ID_QUERY */
function labelText(el) {
const n = el.querySelector('.search-label__text');
return ((n && n.innerText) || '').replace(/\\s+/g, '').trim();
}
const ready = Array.from(document.querySelectorAll('.search-condition-item')).some((el) => {
return (el.offsetWidth || el.offsetHeight) && labelText(el) === '货品ID';
});
if (ready) { return {ok: true, already: true}; }
const modal = Array.from(document.querySelectorAll(
'.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer'
)).find((el) => el.offsetWidth || el.offsetHeight);
const root = modal || document;
const btn = root.querySelector('.search-button-content, .search-button');
if (btn && (btn.offsetWidth || btn.offsetHeight) && !root.querySelector('.table-show')) {
btn.click();
return {ok: true, via: 'query'};
}
if (modal) {
const inner = modal.querySelector('.search-button-content, .search-button');
if (inner) { inner.click(); return {ok: true, via: 'modal'}; }
}
return {ok: false};
"""
)
def _wait_goods_id_field(browser: BrowserAdapter, timeout: float = 8) -> bool:
deadline = time.time() + timeout
retried_add = False
while time.time() <= deadline:
_switch_agreement_frame(browser)
if _goods_id_field_visible(browser):
return True
_open_goods_id_query(browser)
if _goods_id_field_visible(browser):
return True
if not retried_add:
_click_add_detail(browser)
retried_add = True
time.sleep(0.4)
return _goods_id_field_visible(browser)
def _close_header_query(browser: BrowserAdapter) -> None:
"""关掉总单查询弹层。点「取消」会清掉合同编号筛选,只关 X / Esc。"""
try:
browser.execute_script(
"""
/* CLOSE_HEADER_QUERY */
function compact(el) {
return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim();
}
function vis(el) {
return !!(el && (el.offsetWidth || el.offsetHeight));
}
const modal = Array.from(document.querySelectorAll(
'.vxe-modal--wrapper, .elPlus-dialog, .el-dialog, .elPlus-drawer, .elPlus-overlay, .elPlus-popper'
)).find((el) => {
if (!vis(el)) return false;
const t = compact(el);
return t.indexOf('已保存查询条件') >= 0 || t.indexOf('设置查询条件') >= 0
|| (t.indexOf('合同编号') >= 0 && !!el.querySelector('.search-condition-item'));
});
if (modal) {
const close = modal.querySelector(
'.vxe-modal--close-btn, .vxe-icon-close, .elPlus-dialog__headerbtn, .el-dialog__headerbtn'
);
if (close) { close.click(); return {ok: true, via: 'x'}; }
modal.remove();
return {ok: true, via: 'remove'};
}
return {ok: false, via: 'none'};
"""
)
except Exception:
pass
time.sleep(0.4)
def _open_goods_picker(browser: BrowserAdapter) -> bool:
opened = browser.execute_script(
"""
/* OPEN_GOODS_PICKER */
"""
+ _header_dom_js()
+ """
const item = detailItem('货品编码') || formItem('货品编码');
if (!item) { return {ok: false, error: 'no-goods-code'}; }
item.scrollIntoView({block: 'center', inline: 'nearest'});
const via = clickDots(item);
if (via) { return {ok: true, via: via}; }
const btn = item.querySelector('button.elPlus-button, button');
if (!btn) { return {ok: false}; }
btn.click();
return {ok: true, via: 'button'};
"""
)
return bool(isinstance(opened, dict) and opened.get("ok"))
def _wait_goods_picker(browser: BrowserAdapter, timeout: float = 12) -> bool:
deadline = time.time() + timeout
while time.time() <= deadline:
hit = browser.execute_script(
"""
/* WAIT_GOODS_PICKER */
const el = Array.from(document.querySelectorAll('.vxe-modal--wrapper')).find((n) => {
const t = ((n.innerText || '') + '');
const vis = n.offsetWidth || n.offsetHeight
|| String(n.className || '').indexOf('is--visible') >= 0;
return vis && t.indexOf('货品选择') >= 0;
});
return !!(el && (el.offsetWidth || el.offsetHeight));
"""
)
if hit is True:
return True
time.sleep(0.4)
return False
def _pick_goods_id_in_dialog(browser: BrowserAdapter, goods_id: str) -> bool:
keyword = (goods_id or "").strip()
if not keyword:
return False
if not _open_goods_picker(browser):
return False
if not _wait_goods_picker(browser):
return False
filled = browser.execute_script(
"""
/* FILL_GOODS_ID */
const no = String(arguments[0] || '');
function compact(el) {
return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim();
}
function vis(el) {
return !!(el && (el.offsetWidth || el.offsetHeight));
}
const modal = Array.from(document.querySelectorAll('.vxe-modal--wrapper')).find((el) => {
return vis(el) && compact(el).indexOf('货品选择') >= 0;
});
if (!modal) { return {ok: false, error: 'no-modal'}; }
const tab = Array.from(modal.querySelectorAll('span, label, li, button, div')).find((el) => {
return compact(el) === '货品ID';
});
if (tab) { tab.click(); }
const input = modal.querySelector('input.elPlus-input__inner, input');
if (!input) { return {ok: false, error: 'no-input'}; }
input.click();
input.focus();
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
setter.call(input, no);
input.dispatchEvent(new Event('input', {bubbles: true}));
input.dispatchEvent(new Event('change', {bubbles: true}));
const query = Array.from(modal.querySelectorAll('button')).find((el) => {
const t = compact(el);
return t === '查询' || t.indexOf('查询') === 0;
});
if (query) { query.click(); }
return {ok: true, label: '货品ID', value: input.value};
""",
keyword,
)
if not (isinstance(filled, dict) and filled.get("ok") and filled.get("value") == keyword):
return False
deadline = time.time() + 8
while time.time() <= deadline:
picked = browser.execute_script(
"""
/* PICK_GOODS_ROW */
const keyword = String(arguments[0] || '').replace(/\\s+/g, '');
function compact(el) {
return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '');
}
function vis(el) {
return !!(el && (el.offsetWidth || el.offsetHeight));
}
const modal = Array.from(document.querySelectorAll('.vxe-modal--wrapper')).find((el) => {
return vis(el) && compact(el).indexOf('货品选择') >= 0;
});
const root = modal || document;
const rows = Array.from(root.querySelectorAll('.vxe-body--row, tr, [role="row"]'));
for (const row of rows) {
const text = compact(row);
if (text.indexOf(keyword) < 0) continue;
if (text.indexOf('CG-') >= 0) continue;
const radio = row.querySelector(
'.vxe-cell--radio, .vxe-radio--icon, .vxe-radio--label, input[type="radio"]'
);
if (radio) { radio.click(); } else { row.click(); }
const ok = Array.from(root.querySelectorAll('button')).find((el) => {
if (!vis(el)) return false;
const t = compact(el);
return t === '确定' || t === '确 定';
});
if (ok) { ok.click(); }
return {ok: true, via: radio ? 'radio' : 'row'};
}
return {ok: false};
""",
keyword,
)
if isinstance(picked, dict) and picked.get("ok"):
time.sleep(0.5)
return True
time.sleep(0.4)
return False
def _pick_detail_option(
browser: BrowserAdapter, title: str, value: str, *, in_detail: bool = False
) -> bool:
"""下拉可筛选且很长。先输入再精确点中文案,避免「电汇」点到「电汇—非直联」。"""
wanted = (value or "").strip()
if not wanted:
return False
item_fn = "detailItem" if in_detail else "formItem"
opened = browser.execute_script(
"""
/* OPEN_DETAIL_SELECT */
"""
+ _header_dom_js()
+ f"""
const title = String(arguments[0] || '');
const item = {item_fn}(title);
if (!item) {{ return {{ok: false, error: 'no-label'}}; }}
item.scrollIntoView({{block: 'center', inline: 'nearest'}});
const trigger = selectWrap(item) || item.querySelector('.elPlus-input__wrapper, input, .vxe-input');
if (!trigger) {{ return {{ok: false, error: 'no-trigger'}}; }}
trigger.click();
return {{ok: true}};
""",
title,
)
if not (isinstance(opened, dict) and opened.get("ok")):
return False
time.sleep(0.2)
browser.execute_script(
"""
/* FILTER_DETAIL_SELECT */
"""
+ _header_dom_js()
+ f"""
const title = String(arguments[0] || '');
const wanted = String(arguments[1] || '');
const item = {item_fn}(title);
const input = (item && item.querySelector('input.elPlus-select__input'))
|| Array.from(document.querySelectorAll(
'.elPlus-select__popper input, .elPlus-select-dropdown input.elPlus-select__input'
)).find(vis);
if (!input) {{ return {{ok: false, error: 'no-filter'}}; }}
input.focus();
input.click();
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
setter.call(input, wanted);
input.dispatchEvent(new Event('input', {{bubbles: true}}));
input.dispatchEvent(new InputEvent('input', {{bubbles: true, composed: true, data: wanted}}));
input.dispatchEvent(new KeyboardEvent('keyup', {{key: wanted.slice(-1) || '', bubbles: true}}));
return {{ok: true, value: input.value}};
""",
title,
wanted,
)
driver = getattr(browser, "_driver", None)
if driver is not None:
try:
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
els = [
el
for el in driver.find_elements("css selector", "input.elPlus-select__input")
if el.is_displayed()
]
if els:
el = els[-1]
ActionChains(driver).move_to_element(el).click().send_keys(Keys.CONTROL + "a").send_keys(wanted).perform()
except Exception:
pass
deadline = time.time() + 6
picked = None
while time.time() <= deadline:
picked = browser.execute_script(
"""
/* PICK_DETAIL_OPTION */
const wanted = String(arguments[0] || '').replace(/\\s+/g, '');
function compact(el) {
return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim();
}
function vis(el) {
return !!(el && (el.offsetWidth || el.offsetHeight));
}
function scan(root) {
return Array.from(root.querySelectorAll(
'.elPlus-select-dropdown__item, .el-select-dropdown__item, .vxe-select-option, li'
)).find((el) => vis(el) && compact(el) === wanted);
}
const drops = Array.from(document.querySelectorAll(
'.elPlus-select__popper, .elPlus-select-dropdown, .el-select-dropdown, .vxe-select--panel'
)).filter(vis);
const roots = drops.length ? drops : [document];
let opt = null;
for (const root of roots) {
opt = scan(root);
if (opt) break;
const wrap = root.querySelector(
'.elPlus-scrollbar__wrap, .elPlus-select-dropdown__wrap, .elPlus-vl__window'
);
if (!wrap) continue;
for (let i = 0; i < 25 && !opt; i++) {
wrap.scrollTop = (wrap.scrollTop || 0) + 72;
opt = scan(root);
}
if (opt) break;
}
if (!opt) { return {ok: false, error: 'no-option'}; }
opt.click();
return {ok: true, text: ((opt.innerText || '') + '').trim()};
""",
wanted,
)
if isinstance(picked, dict) and picked.get("ok") and picked.get("text", "").replace(" ", "") == wanted.replace(" ", ""):
return True
time.sleep(0.3)
return False
def _read_detail_toast(browser: BrowserAdapter) -> str:
try:
result = browser.execute_script(
"""
/* DETAIL_TOAST */
function compact(el) {
return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim();
}
const nodes = Array.from(document.querySelectorAll(
'.elPlus-message, .el-message, .elPlus-notification, .el-notification'
));
const texts = nodes.map((el) => compact(el)).filter(Boolean);
return {texts: texts.slice(0, 8)};
"""
)
except Exception:
result = None
texts = []
if isinstance(result, dict):
texts = [str(t) for t in (result.get("texts") or []) if t]
for text in texts:
if "供应商为空" in text:
return text
if "失败" in text or "不能" in text or "不能为空" in text:
return text
return ""
def _detail_card_pager(browser: BrowserAdapter) -> str:
try:
text = browser.execute_script(
"""
/* DETAIL_CARD_PAGER */
const el = document.querySelector('.right-btns');
return ((el && el.innerText) || '').replace(/\\s+/g, ' ').trim();
"""
)
except Exception:
text = ""
return str(text or "")
def _goods_table_rows(browser: BrowserAdapter) -> list[str]:
try:
rows = browser.execute_script(
"""
/* GOODS_TABLE_ROWS */
function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); }
function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); }
const table = Array.from(document.querySelectorAll('.single-table')).find((el) => {
return vis(el) && compact(el).indexOf('协议细单ID') >= 0;
});
if (!table) { return []; }
return Array.from(table.querySelectorAll('.vxe-body--row')).filter(vis).map((el) => compact(el).slice(0, 240));
"""
)
except Exception:
rows = None
if isinstance(rows, list):
return [str(item) for item in rows]
return []
def _wait_goods_row(browser: BrowserAdapter, gid: str, timeout: float = 8) -> bool:
number = (gid or "").strip()
if not number:
return False
deadline = time.time() + timeout
while time.time() <= deadline:
rows = _goods_table_rows(browser)
if any(number in row for row in rows):
return True
time.sleep(0.3)
print("goods_table_rows=", _goods_table_rows(browser), "need=", number, flush=True)
return False
def _close_header_card(browser: BrowserAdapter) -> None:
"""总单卡片开着时细单确认会点到总单确认。先回表格。"""
try:
browser.execute_script(
"""
/* CLOSE_HEADER_CARD */
function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); }
function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); }
const card = Array.from(document.querySelectorAll('.table-card')).find((el) => {
const t = compact(el);
return vis(el) && t.indexOf('供应商编码') >= 0 && t.indexOf('货品编码') < 0;
});
if (!card) { return {ok: false}; }
const hit = Array.from(card.querySelectorAll('.card-header-btn, button')).find((el) => {
if (!vis(el)) return false;
const t = compact(el).replace(/\\s+/g, '');
return t.indexOf('返回表格') === 0;
});
if (!hit) { return {ok: false, error: 'no-back'}; }
hit.click();
return {ok: true, via: compact(hit).slice(0, 40)};
"""
)
except Exception:
pass
time.sleep(0.3)
def _confirm_detail_card(browser: BrowserAdapter) -> bool:
"""点货品卡片标题栏「确认 Ctrl + Enter」,不要点总单卡片确认。"""
before = _detail_card_pager(browser)
clicked = browser.execute_script(
"""
/* CONFIRM_DETAIL_CARD */
function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); }
function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); }
const card = Array.from(document.querySelectorAll('.table-card')).find((el) => {
return vis(el) && compact(el).indexOf('货品编码') >= 0;
});
if (!card) { return {ok: false, error: 'no-goods-card'}; }
const nodes = Array.from(card.querySelectorAll('.card-header-btn, button.elPlus-button'));
const texts = nodes.map((el) => compact(el)).filter(Boolean);
const hit = nodes.find((el) => {
if (!vis(el)) return false;
const t = compact(el).replace(/\\s+/g, '');
return t.indexOf('确认') === 0 || t.indexOf('确认Ctrl') === 0;
});
if (!hit) { return {ok: false, error: 'no-confirm', n: nodes.length, texts: texts.slice(0, 8)}; }
hit.scrollIntoView({block: 'center', inline: 'nearest'});
hit.click();
return {ok: true, via: compact(hit).slice(0, 40), w: hit.offsetWidth, h: hit.offsetHeight};
"""
)
driver = getattr(browser, "_driver", None)
if driver is not None:
try:
from selenium.webdriver.common.action_chains import ActionChains
for el in driver.find_elements("css selector", ".table-card .card-header-btn"):
info = driver.execute_script(
"""
const el = arguments[0];
const card = el.closest('.table-card');
return {
text: ((el.innerText || '') + '').replace(/\\s+/g, ' ').trim(),
goods: !!card && ((card.innerText || '').indexOf('货品编码') >= 0)
};
""",
el,
)
if not (isinstance(info, dict) and info.get("goods")):
continue
raw = str(info.get("text") or "")
if "确认" not in raw.replace(" ", "").replace("\n", ""):
continue
driver.execute_script(
"arguments[0].scrollIntoView({block:'center', inline:'nearest'});",
el,
)
time.sleep(0.2)
ActionChains(driver).move_to_element(el).pause(0.15).click().perform()
clicked = {"ok": True, "via": "native:" + raw[:40]}
break
except Exception as exc:
print("native_confirm_err=", exc, flush=True)
print("confirm_click=", clicked, "pager_before=", before, flush=True)
if not (isinstance(clicked, dict) and clicked.get("ok")):
return False
time.sleep(0.6)
err = _read_detail_toast(browser)
if err:
raise NodeError(err)
errors = []
try:
errors = browser.execute_script(
"""
/* DETAIL_FORM_ERRORS */
function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); }
return Array.from(document.querySelectorAll(
'.table-card .elPlus-form-item.is-error, .table-card .elPlus-form-item__error'
)).map((el) => compact(el)).filter(Boolean).slice(0, 8);
"""
) or []
except Exception:
errors = []
print("pager_after=", _detail_card_pager(browser), "errors=", errors, flush=True)
if errors:
raise NodeError("细单确认未过:" + ";".join(str(x) for x in errors[:3]))
return True
if isinstance(clicked, dict) and clicked.get("ok"):
time.sleep(0.5)
err = _read_detail_toast(browser)
if err:
raise NodeError(err)
return True
driver = getattr(browser, "_driver", None)
if driver is not None:
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.keys import Keys
ActionChains(driver).key_down(Keys.CONTROL).send_keys(Keys.ENTER).key_up(Keys.CONTROL).perform()
time.sleep(0.5)
return True
return False
def _type_detail_field(browser: BrowserAdapter, title: str, value: str) -> bool:
"""卡片输入要用键盘,JS 赋值 Vue 收不到,确认时会报必填。"""
wanted = _cell(value)
if not wanted:
return False
marked = browser.execute_script(
"""
/* TYPE_DETAIL_FIELD */
"""
+ _header_dom_js()
+ """
const item = detailItem(arguments[0]);
const input = item && item.querySelector('input, textarea');
if (!input) { return false; }
input.setAttribute('data-rpa-fill', '1');
input.click();
return true;
""",
title,
)
if not marked:
return False
driver = getattr(browser, "_driver", None)
if driver is None:
return True
try:
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.keys import Keys
el = driver.find_element("css selector", '.table-card [data-rpa-fill="1"]')
driver.execute_script("arguments[0].removeAttribute('data-rpa-fill')", el)
ActionChains(driver).move_to_element(el).click().pause(0.05).key_down(Keys.CONTROL).send_keys("a").key_up(
Keys.CONTROL
).send_keys(Keys.BACKSPACE).send_keys(wanted).send_keys(Keys.TAB).perform()
return True
except Exception:
return False
def _fill_last_detail_qty_price(browser: BrowserAdapter, line: RebateImportLine) -> None:
qty = _cell(line.qty)
price = _cell(line.tax_price)
remark = _cell(line.order_no)
tax = _tax_rate_of(line.tax_rate)
net = _cell(line.net_price) or _untaxed_price(price, tax)
filled = browser.execute_script(
"""
/* FILL_DETAIL_CELLS */
"""
+ _header_dom_js()
+ """
const qty = String(arguments[0] || '');
const price = String(arguments[1] || '');
const remark = String(arguments[2] || '');
const compensation = String(arguments[3] || '');
const tax = String(arguments[4] || '');
const payParam = String(arguments[5] || '');
const net = String(arguments[6] || '');
function setByLabel(title, value) {
if (!value) return false;
const item = detailItem(title);
if (!item) return false;
const input = item.querySelector('input, textarea, .vxe-input--inner');
if (!input) return false;
input.click();
const desc = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')
|| Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value');
if (desc && desc.set) { desc.set.call(input, value); }
else { input.value = value; }
input.dispatchEvent(new Event('input', {bubbles: true}));
input.dispatchEvent(new Event('change', {bubbles: true}));
return true;
}
return {
qty: setByLabel('数量', qty),
price: setByLabel('含税协议单价', price),
net: setByLabel('无税协议价', net) || setByLabel('无税单价', net),
tax: setByLabel('税率', tax),
payParam: setByLabel('承付参数', payParam),
remark: setByLabel('细单备注', remark),
compensation: setByLabel('特定补偿预估值', compensation) || setByLabel('特定补偿', compensation),
inbound: setByLabel('进货补偿预估值', compensation),
payMode: shownValue(detailItem('承付模式')),
settle: shownValue(detailItem('采购结算方式')),
};
""",
qty,
price,
remark,
COMPENSATION_DEFAULT,
tax,
PAY_PARAM_DEFAULT,
net,
)
print("fill_detail=", filled, flush=True)
try:
browser.execute_script(
"""
document.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true
}));
"""
)
except Exception:
pass
_type_detail_field(browser, "数量", qty)
_type_detail_field(browser, "含税协议单价", price)
_type_detail_field(browser, "无税协议价", net)
_type_detail_field(browser, "承付参数", PAY_PARAM_DEFAULT)
_type_detail_field(browser, "细单备注", remark)
_type_detail_field(browser, "特定补偿预估值", COMPENSATION_DEFAULT)
_type_detail_field(browser, "进货补偿预估值", COMPENSATION_DEFAULT)
if not _pick_detail_option(browser, "承付模式", PAY_MODE_NEXT_MONTH_N, in_detail=True):
raise NodeError("细单承付未点到下月N号结")
_pick_detail_option(browser, "承付参数", PAY_PARAM_DEFAULT, in_detail=True)
if not _pick_detail_option(browser, "采购结算方式", SETTLE_MODE_WIRE, in_detail=True):
raise NodeError("细单结算未精确点到电汇")
def _close_detail_card(browser: BrowserAdapter) -> None:
"""回到货品表。未确认的新增卡片用「返回表格 Ctrl + B」。"""
try:
browser.execute_script(
"""
/* CLOSE_DETAIL_CARD */
"""
+ _header_dom_js()
+ """
const card = Array.from(document.querySelectorAll('.table-card')).find((el) => {
return vis(el) && compact(el).indexOf('货品编码') >= 0;
});
if (!card) { return {ok: false}; }
const hit = Array.from(card.querySelectorAll('.card-header-btn, button')).find((el) => {
if (!(el.offsetWidth || el.offsetHeight)) return false;
const t = compact(el).replace(/\\s+/g, '');
return t.indexOf('返回表格') === 0;
});
if (hit) { hit.click(); return {ok: true, via: compact(hit).slice(0, 40)}; }
return {ok: false};
"""
)
except Exception:
pass
time.sleep(0.3)
def add_detail_lines_by_goods_id(
browser: BrowserAdapter,
lines: list[RebateImportLine] | tuple[RebateImportLine, ...],
*,
save: bool = True,
) -> int:
added = 0
_close_header_card(browser)
_close_detail_card(browser)
for line in lines:
gid = goods_search_id(line)
if not gid:
raise NodeError("细单货品ID必须用订单中心的 goodsId")
if not _click_add_detail(browser):
raise NodeError("选中总单后未出现货品新增")
if not _pick_goods_id_in_dialog(browser, gid):
raise NodeError(f"新增后未按货品ID搜到 {gid},停下确认搜框是不是货品ID")
_fill_last_detail_qty_price(browser, line)
try:
print(
"card_btns=",
browser.execute_script(
"""
return Array.from(document.querySelectorAll('.table-card .card-header-btn, .card-header-btn')).map((el) => ((el.innerText || '') + '').replace(/\\s+/g, ' ').trim()).filter(Boolean).slice(0, 8);
"""
),
flush=True,
)
except Exception:
pass
confirmed = _confirm_detail_card(browser)
print("confirm_detail=", confirmed, "gid=", gid, flush=True)
if not confirmed:
raise NodeError(f"细单 {gid} 未点到货品卡片「确认 Ctrl + Enter」,行还没落到货品表")
if not _wait_goods_row(browser, gid):
raise NodeError(f"细单确认后货品表没有 {gid}")
added += 1
if added:
_close_detail_card(browser)
if added and save:
if not _native_click_goods_toolbar(browser, "保存"):
if not _click_goods_toolbar(browser, "保存"):
raise NodeError("未点到货品工具栏保存(新增旁边那颗),不要点总单保存")
time.sleep(0.5)
_confirm_save_popup(browser)
err = _read_detail_toast(browser)
if err:
raise NodeError(err)
return added
def read_import_result(browser: BrowserAdapter, *, expected_lines: int = 0) -> RebateImportResult:
deadline = time.time() + 20
last = RebateImportResult(0, 0, (), False)
while time.time() <= deadline:
last = _scan_import_result(browser, expected_lines=expected_lines)
if last.parsed:
return last
time.sleep(0.4)
return last
def run_import_flow(
browser: BrowserAdapter,
ctx: RebateFlowContext,
contract_no: str,
draft: RebateImportDraft | None = None,
*,
download_dir: Path | None = None,
) -> RebateImportResult:
_IMPORT_ACCEPT.reset()
payload = draft or import_draft_for_contract(contract_no)
dest = Path(download_dir) if download_dir else artifact_dir(IMPORT_FLOW) / "downloads"
result = RebateImportResult(0, 0, (), False)
try:
open_rebate_center(browser, ctx)
_IMPORT_ACCEPT.note("S02", "打开返利中心", {"url": ctx.rebate_url}, ok=True)
try:
login_rebate_center(browser, ctx)
_IMPORT_ACCEPT.note("S03", "登录", {"user": ctx.username}, ok=True)
except Exception as exc:
evidence_shot(browser, "rebate-import-login-failed", failed=True)
_IMPORT_ACCEPT.note(
"S03",
"登录",
{"user": ctx.username, "error": exc},
ok=False,
raise_error=False,
)
raise
enter_purchase_agreement(browser)
_IMPORT_ACCEPT.note("S04", "进入采购协议", {"menu": "采购协议管理"}, ok=True)
found = search_contract(browser, contract_no)
_IMPORT_ACCEPT.note(
"S05",
"查号",
{"contractNo": contract_no, "found": "yes" if found else "no"},
ok=found,
)
if not found:
raise NodeError("查号未搜到合同,不能加细单")
_close_header_query(browser)
picked = select_header_row_result(browser, contract_no)
if not (isinstance(picked, dict) and picked.get("ok")):
raise NodeError("未按合同编号勾中总单行")
if not _wait_goods_add_button(browser):
raise NodeError("点中总单后未出现货品新增,停下确认货品区是否已出")
_close_header_card(browser)
_IMPORT_ACCEPT.note("S06", "选中总单", {"selected": "yes"}, ok=True)
added = add_detail_lines_by_goods_id(browser, payload.lines)
first = payload.lines[0]
_IMPORT_ACCEPT.note(
"S07",
"新增细单",
{
"lines": added,
"goodsId": goods_search_id(first),
"remark": _cell(first.order_no),
"compensation": COMPENSATION_DEFAULT,
},
ok=added == len(payload.lines),
)
if added != len(payload.lines):
raise NodeError(f"细单未加齐 {added}/{len(payload.lines)}")
table_rows = _goods_table_rows(browser)
missing = [
goods_search_id(line)
for line in payload.lines
if not any(goods_search_id(line) in row for row in table_rows)
]
if missing:
raise NodeError("货品表缺少 " + ",".join(missing))
_IMPORT_ACCEPT.note(
"S08",
"保存细单",
{"saved": len(table_rows), "msg": f"{len(table_rows)}条货品已保存"},
ok=True,
)
result = RebateImportResult(len(table_rows), 0, (), True)
_IMPORT_ACCEPT.note(
"S10",
"读导入结果",
{"success": result.success_count, "fail": result.fail_count},
ok=True,
)
return result
finally:
print(_IMPORT_ACCEPT.summary_text(), end="", flush=True)
_IMPORT_ACCEPT.dump()
_SUBMIT_TITLES = ("送审", "提交送审", "提交审批")
_SUBMIT_FAIL_MARKS = ("送审失败", "提交失败", "请先导入")
_ALREADY_SUBMIT_MARKS = ("已送审", "已经送审", "不允许送审", "不能送审", "不可重复")
_DETAIL_TAB_TITLES = ("协议细单", "细单", "明细")
_SUBMIT_ID_LABELS = {
"总单号",
"采购协议号",
"协议编号",
"采购协议总单ID",
"协议总单号",
"采购协议ID",
"协议总单ID",
"协议细单ID",
"采购协议细单ID",
"细单号",
"细单ID",
"货源细单ID",
}
_BUSY_MARKS = ("加载中", "查询加载", "设置查询条件", "自定义查询")
_ID_RE = re.compile(r"^[A-Za-z0-9_-]{3,40}$")
_DTL_LABEL_RE = re.compile(
r"(?:采购协议细单ID|协议细单ID)[::\s]*([A-Za-z0-9_-]{3,40})"
)
_SELECT_HEADER_ROW = _SELECT_HEADER_ROW_JS
_CONFIRM_SUBMIT = """
/* CONFIRM_SUBMIT */
const marks = ['确定送审', '确认送审', '是否送审', '确定提交', '确认提交', '是否提交'];
const body = (document.body && document.body.innerText) || '';
const asked = marks.some((m) => body.indexOf(m) >= 0)
|| !!document.querySelector('.el-message-box, .el-dialog__wrapper, .vxe-modal--wrapper, .ant-modal');
if (!asked) { return {ok: false, asked: false}; }
const btn = Array.from(document.querySelectorAll('button, .el-button, span')).find((el) => {
const t = ((el.innerText || '') + '').replace(/\\s+/g, '').trim();
return (t === '确定' || t === '确认' || t === '是') && (el.offsetWidth || el.offsetHeight);
});
if (!btn) { return {ok: false, asked: true}; }
btn.click();
return {ok: true, asked: true};
"""
_READ_AGREEMENT_NOS = """
/* READ_AGREEMENT_NOS */
const no = String(arguments[0] || '').trim();
const headerNames = ['协议总单ID', '采购协议总单ID', '采购协议号', '总单号', '协议编号', '协议总单号', '采购协议ID'];
const detailNames = ['协议细单ID', '采购协议细单ID', '协议明细ID', '细单号', '细单ID', '明细ID'];
function compact(el) {
return ((el && (el.innerText || el.value || el.textContent)) || '').replace(/\\s+/g, ' ').trim();
}
function isId(t) {
return /^[A-Za-z0-9_-]{3,40}$/.test(t || '');
}
function fromVxe(names, requireNo) {
const heads = Array.from(document.querySelectorAll('.vxe-header--column')).map((th) => ({
title: (th.innerText || '').replace(/\\s+/g, '').trim(),
colid: th.getAttribute('colid'),
}));
const rows = Array.from(document.querySelectorAll('.vxe-body--row'));
for (const name of names) {
const h = heads.find((x) => x.title === name);
if (!h || !h.colid) { continue; }
for (const row of rows) {
const text = (row.innerText || '');
if (requireNo && no && text.indexOf(no) < 0) { continue; }
const c = row.querySelector('[colid="' + h.colid + '"]');
const t = compact(c);
if (isId(t)) { return t; }
}
if (!requireNo) {
const cells = document.querySelectorAll('.vxe-body--row [colid="' + h.colid + '"]');
for (const c of cells) {
const t = compact(c);
if (isId(t)) { return t; }
}
}
}
return '';
}
function fromElTable(names, requireNo) {
const tables = Array.from(document.querySelectorAll('table, .el-table'));
for (const table of tables) {
const ths = Array.from(table.querySelectorAll('th'));
const titles = ths.map((th) => (th.innerText || '').replace(/\\s+/g, '').trim());
const rows = Array.from(table.querySelectorAll('tbody tr, .el-table__body tr'));
for (const name of names) {
const idx = titles.indexOf(name);
if (idx < 0) { continue; }
for (const row of rows) {
const text = (row.innerText || '');
if (requireNo && no && text.indexOf(no) < 0) { continue; }
const cells = row.querySelectorAll('td');
const t = compact(cells[idx]);
if (isId(t)) { return t; }
}
}
}
return '';
}
function fromTitleContains(needles, skip) {
const heads = Array.from(document.querySelectorAll('.vxe-header--column')).map((th) => ({
title: (th.innerText || '').replace(/\\s+/g, '').trim(),
colid: th.getAttribute('colid'),
}));
for (const h of heads) {
if (!h.colid) { continue; }
if (skip.some((s) => h.title.indexOf(s) >= 0)) { continue; }
if (!needles.some((n) => h.title.indexOf(n) >= 0)) { continue; }
const cells = document.querySelectorAll('.vxe-body--row [colid="' + h.colid + '"]');
for (const c of cells) {
const t = compact(c);
if (isId(t)) { return t; }
}
}
return '';
}
function fromLabel(names) {
for (const label of names) {
const nodes = Array.from(document.querySelectorAll('label, td, th, span'));
const node = nodes.find((el) => {
const raw = (el.childNodes && el.childNodes.length === 1) ? (el.textContent || '') : (el.innerText || '');
const t = raw.replace(/\\s+/g, '').replace(/[*::]/g, '');
return t === label && t.length <= 20;
});
if (!node) { continue; }
let value = '';
if (node.tagName === 'LABEL' && node.htmlFor) {
value = compact(document.getElementById(node.htmlFor));
}
const next = node.nextElementSibling;
if (!value && next) {
value = compact(next.querySelector('input, span, a') || next);
}
const cell = node.closest('td, th');
if (!value && cell && cell.nextElementSibling) {
value = compact(cell.nextElementSibling.querySelector('input, span, a') || cell.nextElementSibling);
}
if (isId(value)) { return value; }
}
return '';
}
const titles = Array.from(document.querySelectorAll('.vxe-header--column, th')).map((th) => {
return (th.innerText || '').replace(/\\s+/g, '').trim();
}).filter(Boolean);
const header = fromVxe(headerNames, true) || fromElTable(headerNames, true) || fromLabel(headerNames)
|| fromVxe(headerNames, false) || fromElTable(headerNames, false);
const detail = fromVxe(detailNames, false) || fromElTable(detailNames, false) || fromLabel(detailNames)
|| fromTitleContains(['细单', '明细'], ['货源', '备注', '日期']);
return {header: header, detail: detail, titles: titles};
"""
def _clean_submit_id(raw: object, *, contract_no: str = "") -> str:
text = _clean_order_no(raw, contract_no=contract_no)
if not text:
return ""
if text in _SUBMIT_ID_LABELS:
return ""
if contract_no and text == contract_no.strip():
return ""
if any(mark in text for mark in _BUSY_MARKS):
return ""
if not _ID_RE.fullmatch(text):
return ""
return text
def _select_or_open_row(browser: BrowserAdapter, contract_no: str) -> None:
_switch_agreement_frame(browser)
if select_header_row(browser, contract_no):
return
selected = None
try:
selected = browser.execute_script(_SELECT_HEADER_ROW, contract_no)
except Exception:
selected = None
if isinstance(selected, dict) and selected.get("ok"):
return
try:
browser.execute_script(_OPEN_HEADER_ROW, contract_no)
except Exception:
pass
def _confirm_if_asked(browser: BrowserAdapter) -> None:
result = None
try:
result = browser.execute_script(_CONFIRM_SUBMIT)
except Exception:
result = None
if isinstance(result, dict) and result.get("ok"):
return
if isinstance(result, dict) and result.get("asked"):
for title in ("确定", "确认", "是"):
if _click_text(browser, title):
return
def click_rebate_submit(browser: BrowserAdapter) -> str:
_switch_agreement_frame(browser)
for title in _SUBMIT_TITLES:
if _click_header_toolbar(browser, title):
_native_click_header_toolbar(browser, title)
_confirm_if_asked(browser)
return title
for title in _SUBMIT_TITLES:
if _click_toolbar(browser, title):
_confirm_if_asked(browser)
return title
for title in _SUBMIT_TITLES:
if _click_text(browser, title):
_confirm_if_asked(browser)
return title
return ""
def _body_has(browser: BrowserAdapter, marks: tuple[str, ...]) -> bool:
body = browser.read_body_text() or ""
return any(mark in body for mark in marks)
def _submit_error(browser: BrowserAdapter) -> str:
body = browser.read_body_text() or ""
for mark in _SUBMIT_FAIL_MARKS:
if mark in body:
return mark
return ""
def _open_header_view(browser: BrowserAdapter, contract_no: str) -> None:
_switch_agreement_frame(browser)
try:
browser.execute_script(_OPEN_HEADER_ROW, contract_no)
except Exception:
pass
def _reveal_detail_tab(browser: BrowserAdapter) -> None:
_switch_agreement_frame(browser)
for title in _DETAIL_TAB_TITLES:
if _click_toolbar(browser, title):
return
try:
clicked = browser.execute_script(
"""
const title = String(arguments[0] || '').replace(/\\s+/g, '');
const hit = Array.from(document.querySelectorAll(
'[role="tab"], .el-tabs__item, .el-radio-button, li, span, a, div'
)).find((el) => {
const t = ((el.innerText || '') + '').replace(/\\s+/g, '').trim();
return t === title && (el.offsetWidth || el.offsetHeight);
});
if (!hit) { return {ok: false}; }
hit.click();
return {ok: true};
""",
title,
)
except Exception:
clicked = None
if isinstance(clicked, dict) and clicked.get("ok"):
return
def _scan_agreement_nos(browser: BrowserAdapter, *, contract_no: str = "") -> tuple[str, str, str]:
header = ""
detail = ""
titles = ""
try:
raw = browser.execute_script(_READ_AGREEMENT_NOS, contract_no)
except Exception:
raw = None
if isinstance(raw, dict):
header = _clean_submit_id(raw.get("header"), contract_no=contract_no)
detail = _clean_submit_id(raw.get("detail"), contract_no=contract_no)
titles = ",".join(_cell(x) for x in (raw.get("titles") or []) if _cell(x))
if not header:
header = _clean_submit_id(
_scan_rebate_order_no(browser, contract_no=contract_no),
contract_no=contract_no,
)
if not detail:
body = browser.read_body_text() or ""
match = _DTL_LABEL_RE.search(body)
if match:
detail = _clean_submit_id(match.group(1), contract_no=contract_no)
return header, detail, titles
def _scroll_grid_end(browser: BrowserAdapter) -> None:
try:
browser.execute_script(
"""
const wraps = Array.from(document.querySelectorAll(
'.vxe-table--body-wrapper, .vxe-table--header-wrapper, .vxe-table--body-inner-wrapper'
));
wraps.forEach((w) => { w.scrollLeft = w.scrollWidth; });
return wraps.length;
"""
)
except Exception:
return
def read_agreement_nos(browser: BrowserAdapter, *, contract_no: str = "") -> tuple[str, str, str]:
deadline = time.time() + 15
last = ("", "", "")
opened = False
revealed = False
scrolled = False
while time.time() <= deadline:
last = _scan_agreement_nos(browser, contract_no=contract_no)
if last[0] and last[1]:
return last
if not scrolled:
_scroll_grid_end(browser)
scrolled = True
elif not opened:
_open_header_view(browser, contract_no)
opened = True
elif last[0] and not last[1] and not revealed:
_reveal_detail_tab(browser)
revealed = True
time.sleep(0.4)
return last
def run_submit_flow(
browser: BrowserAdapter,
ctx: RebateFlowContext,
contract_no: str,
) -> RebateSubmitResult:
_SUBMIT_ACCEPT.reset()
result = RebateSubmitResult(False)
try:
open_rebate_center(browser, ctx)
_SUBMIT_ACCEPT.note("S02", "打开返利中心", {"url": ctx.rebate_url}, ok=True)
try:
login_rebate_center(browser, ctx)
_SUBMIT_ACCEPT.note("S03", "登录", {"user": ctx.username}, ok=True)
except Exception as exc:
evidence_shot(browser, "rebate-submit-login-failed", failed=True)
_SUBMIT_ACCEPT.note(
"S03",
"登录",
{"user": ctx.username, "error": exc},
ok=False,
raise_error=False,
)
raise
enter_purchase_agreement(browser)
_SUBMIT_ACCEPT.note("S04", "进入采购协议", {"menu": "采购协议管理"}, ok=True)
found = search_contract(browser, contract_no)
_SUBMIT_ACCEPT.note(
"S05",
"查号",
{"contractNo": contract_no, "found": "yes" if found else "no"},
ok=found,
)
_close_header_query(browser)
_select_or_open_row(browser, contract_no)
_SUBMIT_ACCEPT.note("S06", "选总单", {"contractNo": contract_no}, ok=True)
before_header, before_detail, _ = _scan_agreement_nos(browser, contract_no=contract_no)
clicked = click_rebate_submit(browser)
error = _submit_error(browser) if clicked else "未点到送审"
already = _body_has(browser, _ALREADY_SUBMIT_MARKS)
submitted = bool(clicked and not error) or already
facts: dict[str, object] = {
"submitted": "yes" if submitted else "no",
"via": clicked or ("已送审" if already else ""),
}
if error and not already:
facts["error"] = error
_SUBMIT_ACCEPT.note("S07", "点送审", facts, ok=submitted)
header, detail, titles = read_agreement_nos(browser, contract_no=contract_no)
header = header or before_header
detail = detail or before_detail
read_facts: dict[str, object] = {"rebateOrderNo": header, "agreementDtlId": detail}
if not detail:
cols = [t for t in titles.split(",") if "细单" in t or "明细" in t]
if cols:
read_facts["detailCols"] = ",".join(cols[:8])
_SUBMIT_ACCEPT.note(
"S08",
"读号",
read_facts,
ok=bool(header),
)
result = RebateSubmitResult(submitted, header, detail)
return result
finally:
print(_SUBMIT_ACCEPT.summary_text(), end="", flush=True)
_SUBMIT_ACCEPT.dump()