5030 lines
192 KiB
Python
5030 lines
192 KiB
Python
"""返利中心查号、总单、细单与送审:搜到后填总单;页面新增细单(按货品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()
|
||
|
||
|