feat: 合同提交按弹窗结果读号,Chrome 按班次复用

提交后按成功 toast / 查看页判定,处理 LUI 确认框。
批跑共用一个浏览器;Chrome 按 flow 固定用户目录和调试端口,可附着已开实例。
This commit is contained in:
34BFF76918A9449ABAB323149DDCEA73 2026-10-09 16:11:53 +08:00
parent bf0294a12d
commit 39754bef79
5 changed files with 907 additions and 198 deletions

View File

@ -5,11 +5,18 @@ Selenium Chrome 浏览器适配。通过 WebDriver 操控 Chrome。
from __future__ import annotations from __future__ import annotations
import os import os
import socket
import time import time
from pathlib import Path from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = Path(__file__).resolve().parents[1]
_FLOW_DEBUG_PORTS = {
"CONTRACT_CREATE": 9333,
"REBATE_FIND": 9335,
"default": 9334,
}
def _is_inside_repo(path: Path, repo_root: Path) -> bool: def _is_inside_repo(path: Path, repo_root: Path) -> bool:
try: try:
@ -33,6 +40,92 @@ def resolve_chrome_profile_base(requested: str | None = None, *, repo_root: Path
return outside return outside
def resolve_chrome_profile_dir(
flow: str = "default",
requested: str | None = None,
*,
repo_root: Path | None = None,
) -> Path:
"""Stable profile per flow so later tasks reuse login cookies."""
base = resolve_chrome_profile_base(requested, repo_root=repo_root)
safe = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in (flow or "default").strip())
return (base / (safe or "default")).resolve()
def chrome_debug_port(flow: str = "default") -> int:
raw = (os.getenv("RPA_CHROME_DEBUG_PORT") or "").strip()
if raw:
try:
return int(raw)
except ValueError:
pass
return _FLOW_DEBUG_PORTS.get((flow or "default").strip(), 9333)
def _port_open(port: int) -> bool:
try:
with socket.create_connection(("127.0.0.1", port), timeout=0.4):
return True
except OSError:
return False
def _read_devtools_port(profile_dir: Path) -> int | None:
marker = profile_dir / "DevToolsActivePort"
if not marker.is_file():
return None
try:
first = marker.read_text(encoding="utf-8").splitlines()[0].strip()
port = int(first)
except (OSError, ValueError, IndexError):
return None
return port if port > 0 else None
def _chrome_service():
from selenium.webdriver.chrome.service import Service
chromedriver = os.getenv("CHROMEDRIVER_PATH")
if chromedriver and os.path.exists(chromedriver):
return Service(chromedriver)
from webdriver_manager.chrome import ChromeDriverManager
return Service(ChromeDriverManager().install())
def _attach_chrome(service, port: int):
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
options = Options()
options.add_experimental_option("debuggerAddress", f"127.0.0.1:{port}")
driver = webdriver.Chrome(service=service, options=options)
driver.execute_script("return 1")
return driver
def _launch_chrome(service, profile_dir: Path, port: int):
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
options = Options()
options.page_load_strategy = "eager"
options.add_experimental_option("detach", True)
options.add_experimental_option("excludeSwitches", ["enable-automation", "enable-logging"])
options.add_experimental_option("useAutomationExtension", False)
options.add_argument("--disable-blink-features=AutomationControlled")
options.add_argument("--no-first-run")
options.add_argument("--no-default-browser-check")
options.add_argument("--disable-gpu")
options.add_argument("--disable-extensions")
options.add_argument("--disable-popup-blocking")
options.add_argument("--start-maximized")
options.add_argument("--remote-allow-origins=*")
options.add_argument(f"--remote-debugging-port={port}")
options.add_argument("--user-data-dir=" + str(profile_dir))
return webdriver.Chrome(service=service, options=options)
class ApaBrowserAdapter: class ApaBrowserAdapter:
"""Selenium Chrome 浏览器适配器,实现 BrowserAdapter 协议""" """Selenium Chrome 浏览器适配器,实现 BrowserAdapter 协议"""
@ -43,48 +136,43 @@ class ApaBrowserAdapter:
self._is_open = False self._is_open = False
def _get_driver(self): def _get_driver(self):
"""创建或获取 Chrome WebDriver""" """复用本班次已打开的 Chrome;没有就按稳定 profile 新开。"""
if self._driver is not None: if self._driver is not None:
return self._driver return self._driver
from selenium import webdriver profile_dir = resolve_chrome_profile_dir(self._flow)
from selenium.webdriver.chrome.service import Service if _is_inside_repo(profile_dir, REPO_ROOT):
from selenium.webdriver.chrome.options import Options raise RuntimeError(f"拒绝在仓库内创建 Chrome 用户目录: {profile_dir}")
profile_base = resolve_chrome_profile_base()
if _is_inside_repo(profile_base, REPO_ROOT):
raise RuntimeError(f"拒绝在仓库内创建 Chrome 用户目录: {profile_base}")
# 每次适配器单独目录:同进程第二组、上次 detach 留下的 Chrome 都会锁住同一 profile
profile_dir = profile_base / f"{os.getpid()}-{id(self)}"
os.makedirs(profile_dir, exist_ok=True) os.makedirs(profile_dir, exist_ok=True)
profile_dir = str(profile_dir) port = _read_devtools_port(profile_dir) or chrome_debug_port(self._flow)
options = Options() driver = None
options.page_load_strategy = "eager" if _port_open(port):
# 不自动关闭浏览器,方便调试 try:
options.add_experimental_option("detach", True) driver = _attach_chrome(_chrome_service(), port)
# 避免被检测为自动化 print(f"Chrome 复用已有实例 flow={self._flow} port={port}", flush=True)
options.add_experimental_option("excludeSwitches", ["enable-automation", "enable-logging"]) except Exception as exc:
options.add_experimental_option("useAutomationExtension", False) print(f"Chrome 复用失败 port={port}:{exc}", flush=True)
options.add_argument("--disable-blink-features=AutomationControlled") driver = None
options.add_argument("--no-first-run") if driver is None:
options.add_argument("--no-default-browser-check") launch_port = chrome_debug_port(self._flow)
options.add_argument("--disable-gpu") try:
options.add_argument("--disable-extensions") driver = _launch_chrome(_chrome_service(), profile_dir, launch_port)
options.add_argument("--disable-popup-blocking") print(
options.add_argument("--start-maximized") f"Chrome 新开实例 flow={self._flow} port={launch_port} profile={profile_dir}",
# 独立目录,避免和日常 Chrome 抢用户数据目录导致卡住 flush=True,
options.add_argument("--user-data-dir=" + profile_dir) )
except Exception as exc:
retry_port = _read_devtools_port(profile_dir)
if retry_port and _port_open(retry_port):
driver = _attach_chrome(_chrome_service(), retry_port)
print(f"Chrome 锁定后改为复用 port={retry_port}", flush=True)
else:
raise RuntimeError(f"打开 Chrome 失败:{exc}") from exc
chromedriver = os.getenv("CHROMEDRIVER_PATH") driver.set_page_load_timeout(45)
if chromedriver and os.path.exists(chromedriver): driver.set_script_timeout(30)
service = Service(chromedriver) self._driver = driver
else:
from webdriver_manager.chrome import ChromeDriverManager
service = Service(ChromeDriverManager().install())
self._driver = webdriver.Chrome(service=service, options=options)
self._driver.set_page_load_timeout(45)
self._driver.set_script_timeout(30)
return self._driver return self._driver
def _ensure_open(self): def _ensure_open(self):
@ -502,3 +590,38 @@ class ApaBrowserAdapter:
if self._driver: if self._driver:
self._driver.save_screenshot(str(path)) self._driver.save_screenshot(str(path))
return str(path) return str(path)
def accept_alert(self) -> str:
"""Accept a native JS alert/confirm and return its text. Empty if none."""
self._ensure_open()
try:
alert = self._driver.switch_to.alert
text = (alert.text or "").strip()
alert.accept()
return text
except Exception:
return ""
def reopen_url(self, url: str) -> None:
"""Close the current tab and open url in a fresh tab of the same Chrome."""
driver = self._get_driver()
old = None
try:
old = driver.current_window_handle
driver.switch_to.new_window("tab")
new = driver.current_window_handle
if old and old != new:
try:
driver.switch_to.window(old)
driver.close()
except Exception:
pass
try:
driver.switch_to.window(new)
except Exception:
handles = driver.window_handles
if handles:
driver.switch_to.window(handles[-1])
except Exception:
pass
self.open_url(url)

View File

@ -34,6 +34,8 @@ class BrowserAdapter(Protocol):
def read_first(self, selectors: list[str], value_name: str = "") -> str: ... def read_first(self, selectors: list[str], value_name: str = "") -> str: ...
def read_body_text(self) -> str: ... def read_body_text(self) -> str: ...
def screenshot(self, name: str) -> str: ... def screenshot(self, name: str) -> str: ...
def accept_alert(self) -> str: ...
def reopen_url(self, url: str) -> None: ...
@dataclass(frozen=True) @dataclass(frozen=True)
@ -114,20 +116,20 @@ def run_contract_create_flow(browser: BrowserAdapter, ctx: ContractFlowContext)
fill_contract_payment_info(browser, ctx) fill_contract_payment_info(browser, ctx)
upload_contract_document_if_needed(browser, ctx) upload_contract_document_if_needed(browser, ctx)
fd_id = _read_document_id(browser) fd_id = _read_document_id(browser)
if not submit_contract_for_approval(browser, ctx): if not submit_contract_for_approval(browser, ctx, fd_id=fd_id):
if ctx.submit_mode == "dry_run": if ctx.submit_mode == "dry_run":
_ACCEPT.note("S13", "读合同号", {"contractNo": "(dry_run)", "mode": "dry_run"}, ok=True) _ACCEPT.note("S13", "读合同号", {"contractNo": "(dry_run)", "mode": "dry_run"}, ok=True)
return "" return ""
_ACCEPT.note("S13", "读合同号", {"contractNo": "(none)", "mode": ctx.submit_mode}, ok=False) _ACCEPT.note("S13", "读合同号", {"contractNo": "(none)", "mode": ctx.submit_mode}, ok=False)
return "" return ""
fd_id = fd_id or _read_document_id(browser) fd_id = fd_id or _read_document_id(browser)
contract_no = read_contract_number(browser, ctx, fd_id=fd_id) contract_no, via = _read_contract_number_routed(browser, ctx, fd_id=fd_id)
_ACCEPT.note( _ACCEPT.note(
"S13", "S13",
"读合同号", "读合同号",
{ {
"contractNo": contract_no or "(none)", "contractNo": contract_no or "(none)",
"via": "我的合同查询", "via": via,
"fdId": fd_id or "(none)", "fdId": fd_id or "(none)",
}, },
ok=_usable_contract_no(contract_no), ok=_usable_contract_no(contract_no),
@ -162,6 +164,10 @@ VIEW_PAGE_URL = (
"http://contract-test.gzmpc.com:8080/km/agreement/km_agreement_apply/" "http://contract-test.gzmpc.com:8080/km/agreement/km_agreement_apply/"
"kmAgreementApply.do?method=view&fdId={fd_id}" "kmAgreementApply.do?method=view&fdId={fd_id}"
) )
EDIT_PAGE_URL = (
"http://contract-test.gzmpc.com:8080/km/agreement/km_agreement_apply/"
"kmAgreementApply.do?method=edit&fdId={fd_id}"
)
# Landray/LUI 表单字段的真实 name # Landray/LUI 表单字段的真实 name
F = { F = {
@ -1341,55 +1347,128 @@ def _on_body_editor_tab(browser: BrowserAdapter) -> bool:
return result is True return result is True
_ERROR_MARKS = (
"不能为空",
"请填写",
"校验失败",
"提交失败",
"必填项未填",
"该文档不存在",
"操作失败",
)
_SUCCESS_MARKS = ("您的操作已成功", "操作已成功", "提交成功", "流程已提交", "成功提交")
_CONFIRM_MARKS = ("确定提交", "是否提交", "确认提交", "是否确定", "确定要提交")
def _close_blocking_dialogs(browser: BrowserAdapter) -> None: def _close_blocking_dialogs(browser: BrowserAdapter) -> None:
browser.execute_script( browser.execute_script(
""" """
const labels = ['关闭', '取消']; const labels = ['关闭', '取消'];
const nodes = Array.from(document.querySelectorAll('div, a, button, span')); function clickIn(win) {
for (const label of labels) { const nodes = Array.from(win.document.querySelectorAll('div, a, button, span'));
const hit = nodes.find((el) => (el.textContent || '').trim() === label && el.children.length === 0); for (const label of labels) {
if (hit) { hit.click(); return label; } const hit = nodes.find((el) => (el.textContent || '').trim() === label && el.children.length === 0);
if (hit) { hit.click(); return label; }
}
for (let i = 0; i < win.frames.length; i++) {
try { const hit = clickIn(win.frames[i]); if (hit) return hit; } catch (e) {}
}
return '';
} }
return ''; return clickIn(window);
""" """
) )
time.sleep(0.4) time.sleep(0.4)
def submit_contract_for_approval(browser: BrowserAdapter, ctx: ContractFlowContext) -> bool: def submit_contract_for_approval(
browser: BrowserAdapter, ctx: ContractFlowContext, *, fd_id: str = ""
) -> bool:
if ctx.submit_mode == "dry_run": if ctx.submit_mode == "dry_run":
evidence_shot(browser, "contract-dry-run-ready") evidence_shot(browser, "contract-dry-run-ready")
_ACCEPT.note("S12", "提交", {"mode": "dry_run", "submitted": "no"}, ok=True) _ACCEPT.note("S12", "提交", {"mode": "dry_run", "submitted": "no"}, ok=True)
return False return False
_close_blocking_dialogs(browser) _close_blocking_dialogs(browser)
# 测试环境直接提交会落到「该文档不存在」:先暂存落库,再提交。合同号仍从我的合同查。 fd_id = (fd_id or "").strip() or _read_document_id(browser)
print(f"S12 fdId={fd_id or '-'}", flush=True)
# 测试环境直接提交会落到「该文档不存在」:先暂存落库,再提交。合同号从详情 #_xform_docNumber 读。
try: try:
_click_toolbar_button(browser, "暂存") saved_click = _click_toolbar_button(browser, "暂存")
except Exception as exc: except Exception as exc:
print(f"暂存未点到:{exc}", flush=True) print(f"暂存未点到:{exc}", flush=True)
saved = _wait_after_save_or_submit(browser, timeout_seconds=15) saved_click = False
blocked = _submit_validation_error(browser) save_kind, save_text = _wait_submit_outcome(browser, timeout_seconds=15)
if blocked: if save_kind == "error":
evidence_shot(browser, "contract-submitted", failed=True) evidence_shot(browser, "contract-submitted", failed=True)
_ACCEPT.note("S12", "提交", {"submitted": "no", "error": blocked, "at": "暂存"}, ok=False, raise_error=False) _ACCEPT.note(
"S12",
"提交",
{"submitted": "no", "error": save_text, "at": "暂存", "clickedSave": "yes" if saved_click else "no"},
ok=False,
raise_error=False,
)
return False return False
if not saved: if save_kind != "success":
print("暂存后仍在表单页,继续点提交。", flush=True) print("暂存后仍在表单页,继续点提交。", flush=True)
fd_id = fd_id or _read_document_id(browser)
if not _lui_toolbar_button_exists(browser, "提交"):
if not fd_id:
evidence_shot(browser, "contract-submitted", failed=True)
_ACCEPT.note(
"S12",
"提交",
{"submitted": "no", "error": "暂存后离开表单且无 fdId,无法再点提交", "clicked": "no"},
ok=False,
raise_error=False,
)
return False
print(f"暂存后打开编辑页 fdId={fd_id}", flush=True)
browser.open_url(EDIT_PAGE_URL.format(fd_id=fd_id))
if not _wait_lui_toolbar_button(browser, "提交", timeout_seconds=12):
evidence_shot(browser, "contract-submitted", failed=True)
_ACCEPT.note(
"S12",
"提交",
{"submitted": "no", "error": "编辑页未见提交按钮 lui_widget_btn_txt", "clicked": "no", "fdId": fd_id},
ok=False,
raise_error=False,
)
return False
try: try:
_click_toolbar_button(browser, "提交") clicked = _click_toolbar_button(browser, "提交")
except Exception as exc: except Exception as exc:
evidence_shot(browser, "contract-submitted", failed=True) evidence_shot(browser, "contract-submitted", failed=True)
_ACCEPT.note("S12", "提交", {"submitted": "no", "error": str(exc)[:120]}, ok=False, raise_error=False) _ACCEPT.note("S12", "提交", {"submitted": "no", "error": str(exc)[:120], "clicked": "no"}, ok=False, raise_error=False)
return False
if not clicked:
evidence_shot(browser, "contract-submitted", failed=True)
_ACCEPT.note("S12", "提交", {"submitted": "no", "error": "未点到提交按钮 lui_widget_btn_txt", "clicked": "no"}, ok=False, raise_error=False)
return False return False
evidence_shot(browser, "contract-after-submit-click") evidence_shot(browser, "contract-after-submit-click")
submitted = _wait_after_save_or_submit(browser, timeout_seconds=20) kind, dialog = _wait_submit_outcome(browser, timeout_seconds=20, trust_body_success=False)
blocked = _submit_validation_error(browser) if kind == "error":
if blocked:
evidence_shot(browser, "contract-submitted", failed=True) evidence_shot(browser, "contract-submitted", failed=True)
_ACCEPT.note("S12", "提交", {"submitted": "no", "error": blocked}, ok=False, raise_error=False) _ACCEPT.note(
"S12",
"提交",
{"submitted": "no", "clicked": "yes", "error": dialog},
ok=False,
raise_error=False,
)
return False return False
print("提交已点,回我的合同查询合同号。", flush=True) print(f"提交已点 kind={kind} dialog={dialog[:80]!r},S13 从详情读合同号。", flush=True)
_ACCEPT.note("S12", "提交", {"submitted": "yes", "via": "暂存后提交", "leftForm": "yes" if submitted else "no"}, ok=True) _ACCEPT.note(
"S12",
"提交",
{
"submitted": "yes",
"clicked": "yes",
"via": "暂存后提交",
"signal": kind,
"dialog": dialog[:80] or "(none)",
},
ok=True,
)
return True return True
@ -1402,66 +1481,256 @@ def _submit_error_from_body(body: str) -> str:
return "" return ""
def _wait_after_save_or_submit(browser: BrowserAdapter, timeout_seconds: float) -> bool: def _classify_dialog_text(text: str) -> str:
blob = text or ""
for mark in _ERROR_MARKS:
if mark in blob:
return "error"
for mark in _SUCCESS_MARKS:
if mark in blob:
return "success"
for mark in _CONFIRM_MARKS:
if mark in blob:
return "confirm"
return "none"
def _current_url(browser: BrowserAdapter) -> str:
try:
return str(
browser.execute_script(
"try { return (window.top.location && window.top.location.href) || location.href; }"
" catch (e) { return location.href; }"
)
or ""
)
except Exception:
return ""
def _wait_submit_outcome(
browser: BrowserAdapter,
timeout_seconds: float,
*,
trust_body_success: bool = True,
) -> tuple[str, str]:
deadline = time.time() + timeout_seconds deadline = time.time() + timeout_seconds
last_dialog = ""
saw_confirm = False
while time.time() < deadline: while time.time() < deadline:
if _submit_validation_error(browser): kind, text, confirm = _read_submit_signal(
return False browser,
_confirm_submit_dialogs(browser) trust_body_success=trust_body_success or saw_confirm,
if _submit_succeeded(browser): )
return True if confirm:
time.sleep(0.5) saw_confirm = True
return not bool(_submit_validation_error(browser)) and _submit_succeeded(browser) if text:
last_dialog = text
if kind == "error":
return "error", text
if kind == "success":
return "success", text
time.sleep(0.4)
kind, text, _confirm = _read_submit_signal(
browser,
trust_body_success=trust_body_success or saw_confirm,
)
if kind in ("error", "success"):
return kind, text
return "none", text or last_dialog
def _submit_succeeded(browser: BrowserAdapter) -> bool: def _read_submit_signal(
browser: BrowserAdapter,
*,
trust_body_success: bool = True,
) -> tuple[str, str, bool]:
alert_text = _accept_native_alert(browser)
if alert_text:
kind = _classify_dialog_text(alert_text)
if kind == "none":
kind = "confirm"
print(f"S12 native alert kind={kind} text={alert_text[:80]!r}", flush=True)
if kind == "error":
return "error", alert_text, False
if kind == "success":
return "success", alert_text, False
if kind == "confirm":
return _finish_submit_signal(browser, alert_text, True, trust_body_success)
snapshot = _handle_submit_dialogs(browser)
dialog_text = str(snapshot.get("text") or "").strip()
kind = _classify_dialog_text(dialog_text) if dialog_text else "none"
if kind == "none":
kind = str(snapshot.get("kind") or "none")
if kind == "error":
return "error", dialog_text or "弹窗报错", False
if kind == "success":
return "success", dialog_text or "ok", False
return _finish_submit_signal(
browser,
dialog_text,
kind == "confirm" or bool(snapshot.get("clicked")),
trust_body_success,
)
def _finish_submit_signal(
browser: BrowserAdapter,
dialog_text: str,
saw_confirm: bool,
trust_body_success: bool,
) -> tuple[str, str, bool]:
body_error = _submit_error_from_body(browser.read_body_text() or "")
if body_error:
return "error", body_error, saw_confirm
if _submit_view_page(browser):
return "success", dialog_text or "view", saw_confirm
if (trust_body_success or saw_confirm) and _submit_success_text(browser):
return "success", dialog_text or "ok", saw_confirm
return "none", dialog_text, saw_confirm
def _accept_native_alert(browser: BrowserAdapter) -> str:
accept = getattr(browser, "accept_alert", None)
if not callable(accept):
return ""
try:
return str(accept() or "").strip()
except Exception:
return ""
def _submit_view_page(browser: BrowserAdapter) -> bool:
url = _current_url(browser)
return "method=view" in url and "kmAgreementApply" in url
def _submit_success_text(browser: BrowserAdapter) -> bool:
body = browser.read_body_text() or "" body = browser.read_body_text() or ""
if _submit_error_from_body(body): if _submit_error_from_body(body):
return False return False
if any(mark in body for mark in ("您的操作已成功", "操作已成功", "提交成功", "流程已提交", "成功提交")): return any(mark in body for mark in _SUCCESS_MARKS)
return True
url = ""
try: def _submit_succeeded(browser: BrowserAdapter) -> bool:
url = str(browser.execute_script("return location.href") or "") if _submit_error_from_body(browser.read_body_text() or ""):
except Exception: return False
url = "" return _submit_success_text(browser) or _submit_view_page(browser)
return "method=add" not in url and "kmAgreementApply" in url
def _submit_validation_error(browser: BrowserAdapter) -> str: def _submit_validation_error(browser: BrowserAdapter) -> str:
body = browser.read_body_text() or "" kind, text, _confirm = _read_submit_signal(browser, trust_body_success=True)
hit = _submit_error_from_body(body) if kind == "error":
if hit: return text
return hit
text = browser.execute_script(
"""
const roots = Array.from(document.querySelectorAll(
'.lui_dialog, .lui_message, .lui_alert, .lui_dialog_content, .prompt_frame'
));
return roots.map((el) => (el.innerText || '').trim()).filter(Boolean).join('\\n').slice(0, 400);
"""
)
blob = str(text or "").strip()
if not blob:
return ""
for mark in ("不能为空", "请填写", "校验失败", "提交失败", "必填项未填", "该文档不存在", "操作失败"):
if mark in blob:
return blob[:120]
return "" return ""
def _handle_submit_dialogs(browser: BrowserAdapter) -> dict:
raw = browser.execute_script(
"""
const errorMarks = ['不能为空','请填写','校验失败','提交失败','必填项未填','该文档不存在','操作失败'];
const successMarks = ['您的操作已成功','操作已成功','提交成功','流程已提交','成功提交'];
const confirmMarks = ['确定提交','是否提交','确认提交','是否确定','确定要提交'];
function classify(text) {
if (errorMarks.some((m) => text.indexOf(m) >= 0)) return 'error';
if (successMarks.some((m) => text.indexOf(m) >= 0)) return 'success';
if (confirmMarks.some((m) => text.indexOf(m) >= 0)) return 'confirm';
return 'none';
}
function dialogText(doc) {
const roots = Array.from(doc.querySelectorAll(
'.lui_dialog, .lui_message, .lui_alert, .lui_dialog_content, .prompt_frame, .lui_dialog_common'
));
return roots.map((el) => (el.innerText || '').trim()).filter(Boolean).join('\\n');
}
function clickLabel(doc, labels) {
const nodes = Array.from(doc.querySelectorAll('div, a, button, span, input'));
for (const label of labels) {
const hit = nodes.find((el) => {
const t = (el.textContent || el.value || '').trim();
return t === label && (!el.children || el.children.length === 0);
});
if (hit) { hit.click(); return label; }
}
return '';
}
function walk(win) {
let found = {kind: 'none', text: '', clicked: ''};
try {
const text = dialogText(win.document);
const kind = classify(text);
if (kind === 'error') return {kind: kind, text: text.slice(0, 200), clicked: ''};
if (kind === 'confirm') {
const clicked = clickLabel(win.document, ['确定', '确认', '是', 'OK']);
return {kind: kind, text: text.slice(0, 200), clicked: clicked};
}
if (kind === 'success') {
const clicked = clickLabel(win.document, ['确定', '关闭', '确认']);
return {kind: kind, text: text.slice(0, 200), clicked: clicked};
}
if (text) {
const clicked = clickLabel(win.document, ['确定', '确认']);
if (clicked) return {kind: 'confirm', text: text.slice(0, 200), clicked: clicked};
}
} catch (e) {}
for (let i = 0; i < win.frames.length; i++) {
try {
const inner = walk(win.frames[i]);
if (inner.kind !== 'none' || inner.clicked) return inner;
} catch (e) {}
}
return found;
}
return walk(window);
"""
)
if not isinstance(raw, dict):
return {"kind": "none", "text": "", "clicked": ""}
return {
"kind": str(raw.get("kind") or "none"),
"text": str(raw.get("text") or "").strip(),
"clicked": str(raw.get("clicked") or ""),
}
def _read_document_id(browser: BrowserAdapter) -> str: def _read_document_id(browser: BrowserAdapter) -> str:
try:
browser.switch_to_default_content()
except Exception:
pass
value = browser.execute_script( value = browser.execute_script(
""" """
const href = String(location.href || ''); function fromHref(href) {
const fromUrl = href.match(/[?&]fdId=([^&]+)/i); const fromUrl = String(href || '').match(/[?&]fdId=([^&]+)/i);
if (fromUrl && fromUrl[1]) return decodeURIComponent(fromUrl[1]); if (fromUrl && fromUrl[1]) return decodeURIComponent(fromUrl[1]);
const input = document.querySelector('input[name="fdId"], input[name="fdid"]'); return '';
if (input && String(input.value || '').trim()) return String(input.value).trim(); }
try { function fromDoc(win) {
if (window.Com_Parameter && Com_Parameter.fdId) return String(Com_Parameter.fdId); try {
} catch (e) {} const id = fromHref(win.location && win.location.href);
return ''; if (id) return id;
} catch (e) {}
try {
const input = win.document.querySelector('input[name="fdId"], input[name="fdid"]');
if (input && String(input.value || '').trim()) return String(input.value).trim();
} catch (e) {}
try {
if (win.Com_Parameter && win.Com_Parameter.fdId) return String(win.Com_Parameter.fdId);
} catch (e) {}
return '';
}
function walk(win) {
const id = fromDoc(win);
if (id) return id;
for (let i = 0; i < win.frames.length; i++) {
try {
const inner = walk(win.frames[i]);
if (inner) return inner;
} catch (e) {}
}
return '';
}
return walk(window);
""" """
) )
return str(value or "").strip() return str(value or "").strip()
@ -1478,79 +1747,182 @@ def _open_submitted_view(browser: BrowserAdapter, fd_id: str) -> None:
time.sleep(1.5) time.sleep(1.5)
def _click_toolbar_button(browser: BrowserAdapter, label: str) -> None: def _lui_btn_xpath(label: str) -> str:
clicked = browser.execute_script( safe = (label or "").replace("'", "")
""" return (
"//*[contains(@class,'lui_widget_btn_txt') and normalize-space()='%s']" % safe
)
def _click_toolbar_button(browser: BrowserAdapter, label: str) -> bool:
try:
browser.switch_to_default_content()
except Exception:
pass
clicked = browser.execute_script(_LUI_TOOLBAR_CLICK_JS, label)
print(f"toolbar {label} js={clicked}", flush=True)
if isinstance(clicked, dict) and clicked.get("ok"):
return True
selectors = [_lui_btn_xpath(label)]
try:
browser.click_native(selectors)
print(f"toolbar {label} native=ok", flush=True)
return True
except Exception as exc:
print(f"toolbar {label} native={exc}", flush=True)
try:
browser.click_first(selectors)
print(f"toolbar {label} first=ok", flush=True)
return True
except Exception as exc:
print(f"toolbar {label} first={exc}", flush=True)
return False
_LUI_TOOLBAR_FIND_JS = """
const label = String(arguments[0] || ''); const label = String(arguments[0] || '');
function visible(el) { function visible(el) {
const box = el.getBoundingClientRect(); const box = el.getBoundingClientRect();
return box.width > 2 && box.height > 2; return box.width > 2 && box.height > 2;
} }
function clickIn(win) { function textOf(el) {
const toolbar = win.document.querySelector( return (el.textContent || '').replace(/\\s+/g, '').trim();
'.lui_toolbar_frame, .lui_toolbar, [class*="lui_toolbar"]' }
); function findIn(win) {
const roots = [toolbar, win.document].filter(Boolean); const nodes = Array.from(win.document.querySelectorAll(
for (const root of roots) { 'div.lui-component.lui_widget_btn_txt, .lui_widget_btn_txt'
const hits = Array.from(root.querySelectorAll('div, a, button, span, li')).filter((el) => { ));
return (el.textContent || '').replace(/\\s+/g, '').trim() === label && visible(el); const hit = nodes.find((el) => textOf(el) === label && visible(el));
}); if (hit) {
hits.sort((a, b) => a.children.length - b.children.length); return {
if (hits.length) { ok: true,
hits[0].click(); cid: hit.getAttribute('data-lui-cid') || '',
return true; id: hit.id || '',
} count: nodes.length
};
} }
for (let i = 0; i < win.frames.length; i++) { for (let i = 0; i < win.frames.length; i++) {
try { if (clickIn(win.frames[i])) return true; } catch (e) {} try {
const inner = findIn(win.frames[i]);
if (inner && inner.ok) return inner;
} catch (e) {}
} }
return false; return {
ok: false,
texts: nodes.map((el) => textOf(el)).filter(Boolean).slice(0, 12)
};
} }
return {ok: clickIn(window), label: label}; return findIn(window);
""",
label,
)
print(f"toolbar {label} js={clicked}", flush=True)
if isinstance(clicked, dict) and clicked.get("ok"):
return
browser.click_first([f"text={label}", f"button:contains({label})"])
def _click_top_submit(browser: BrowserAdapter) -> None:
_click_toolbar_button(browser, "提交")
def _confirm_submit_dialogs(browser: BrowserAdapter) -> None:
browser.execute_script(
""" """
const labels = ['确定', '确认', '是', 'OK'];
const nodes = Array.from(document.querySelectorAll('div, a, button, span, input'));
for (const label of labels) { _LUI_TOOLBAR_CLICK_JS = """
const hit = nodes.find((el) => { const label = String(arguments[0] || '');
const text = (el.textContent || el.value || '').trim(); function visible(el) {
return text === label && el.children.length === 0; const box = el.getBoundingClientRect();
return box.width > 2 && box.height > 2;
}
function textOf(el) {
return (el.textContent || '').replace(/\\s+/g, '').trim();
}
function fire(el, win) {
const target = el.closest('[data-lui-cid]') || el;
try { target.scrollIntoView({block: 'center', inline: 'nearest'}); } catch (e) {}
['mouseover', 'mousedown', 'mouseup', 'click'].forEach((type) => {
target.dispatchEvent(new MouseEvent(type, {
bubbles: true, cancelable: true, view: win, buttons: 1
}));
}); });
if (hit) { hit.click(); return label; } target.click();
} }
return ''; function clickIn(win) {
const nodes = Array.from(win.document.querySelectorAll(
'div.lui-component.lui_widget_btn_txt, .lui_widget_btn_txt'
));
const hit = nodes.find((el) => textOf(el) === label && visible(el));
if (hit) {
fire(hit, win);
return {
ok: true,
via: 'btn_txt',
cid: hit.getAttribute('data-lui-cid') || '',
id: hit.id || ''
};
}
for (let i = 0; i < win.frames.length; i++) {
try {
const inner = clickIn(win.frames[i]);
if (inner && inner.ok) return inner;
} catch (e) {}
}
return {
ok: false,
label: label,
texts: nodes.map((el) => textOf(el)).filter(Boolean).slice(0, 12)
};
}
return clickIn(window);
""" """
)
time.sleep(0.8)
def _lui_toolbar_button_exists(browser: BrowserAdapter, label: str) -> bool:
found = browser.execute_script(_LUI_TOOLBAR_FIND_JS, label)
ok = isinstance(found, dict) and bool(found.get("ok"))
print(f"toolbar {label} exists={found}", flush=True)
return ok
def _wait_lui_toolbar_button(
browser: BrowserAdapter, label: str, timeout_seconds: float = 10
) -> bool:
deadline = time.time() + timeout_seconds
while time.time() < deadline:
if _lui_toolbar_button_exists(browser, label):
return True
time.sleep(0.4)
return False
def _wait_lui_dialog_gone(browser: BrowserAdapter, timeout_seconds: float = 8) -> None:
deadline = time.time() + timeout_seconds
while time.time() < deadline:
snapshot = _handle_submit_dialogs(browser)
kind = str(snapshot.get("kind") or "none")
text = str(snapshot.get("text") or "").strip()
if kind == "confirm":
return
if kind in ("none", "") and not text:
return
_close_blocking_dialogs(browser)
time.sleep(0.4)
_close_blocking_dialogs(browser)
def _click_top_submit(browser: BrowserAdapter) -> bool:
return _click_toolbar_button(browser, "提交")
def read_contract_number( def read_contract_number(
browser: BrowserAdapter, ctx: ContractFlowContext | None = None, *, fd_id: str = "" browser: BrowserAdapter, ctx: ContractFlowContext | None = None, *, fd_id: str = ""
) -> str: ) -> str:
if ctx is None: no, _via = _read_contract_number_routed(browser, ctx, fd_id=fd_id)
ctx = ContractFlowContext( return no
contract_url="http://contract-test.gzmpc.com:8080/",
username="",
password="", def _read_contract_number_routed(
supplier_name="", browser: BrowserAdapter, ctx: ContractFlowContext | None = None, *, fd_id: str = ""
order_date="", ) -> tuple[str, str]:
source_type="", del ctx
) doc_id = (fd_id or "").strip() or _read_document_id(browser)
return _read_contract_number_from_my_list(browser, ctx, fd_id=fd_id) if not doc_id:
print("S13 没有 fdId,无法打开详情", flush=True)
return "", "none"
print(f"S13:开详情读号 fdId={doc_id}", flush=True)
no = _read_number_by_fd_id(browser, doc_id)
if no:
return no, "docNumber"
print("S13 详情未读到合同编号", flush=True)
return "", "none"
def _read_contract_number_on_page(browser: BrowserAdapter) -> str: def _read_contract_number_on_page(browser: BrowserAdapter) -> str:
@ -1561,25 +1933,22 @@ def _read_contract_number_on_page(browser: BrowserAdapter) -> str:
scanned = browser.execute_script( scanned = browser.execute_script(
""" """
const skip = ['提交时自动生成', '自动生成', '==请选择==', '合同编号', '合同标题']; const skip = ['提交时自动生成', '自动生成', '==请选择==', '合同编号', '合同标题'];
function usable(raw) {
const v = String(raw || '').replace(/\\s+/g, ' ').trim();
if (!v || skip.indexOf(v) >= 0) return '';
return v;
}
function fromDoc(doc) { function fromDoc(doc) {
const inputs = doc.querySelectorAll( const node = doc.querySelector('#_xform_docNumber');
'input[name="fdNumber"], input[name="contractNo"], input[name="contractSystemNo"], input[name*="fdNumber"]' if (node) {
); const typed = node.querySelector('input, textarea');
for (const el of inputs) { const v = usable(typed ? typed.value : (node.innerText || node.textContent || ''));
const v = (el.value || '').trim(); if (v) return v;
if (v && skip.indexOf(v) < 0) return v;
} }
const nodes = Array.from(doc.querySelectorAll('td, th, span, div, label, em, b')); const input = doc.querySelector('input[name="docNumber"]');
for (let i = 0; i < nodes.length; i++) { if (input) {
const label = (nodes[i].innerText || '').replace(/\\s+/g, '').trim(); const v = usable(input.value || '');
if (label !== '合同编号' && label !== '合同编号:' && label !== '合同编号:') continue; if (v) return v;
const box = nodes[i].parentElement || nodes[i];
const typed = box.querySelector('input, textarea');
const n = typed
? String(typed.value || '').trim()
: ((nodes[i + 1] && (nodes[i + 1].innerText || nodes[i + 1].value || '')) || '')
.replace(/\\s+/g, ' ').trim();
if (n && skip.indexOf(n) < 0) return n;
} }
return ''; return '';
} }
@ -1603,15 +1972,10 @@ def _read_contract_number_on_page(browser: BrowserAdapter) -> str:
return scanned.strip() return scanned.strip()
if scanned: if scanned:
print(f"S13 详情页扫到不可用值={scanned!r}", flush=True) print(f"S13 详情页扫到不可用值={scanned!r}", flush=True)
body = browser.read_body_text() or ""
for line in body.splitlines():
text = line.strip()
if _usable_contract_no(text) and (text.startswith("HT") or (text.isdigit() and len(text) >= 10)):
return text
return "" return ""
def _reload_contract_home(browser: BrowserAdapter, home: str) -> None: def _reload_contract_home(browser: BrowserAdapter, home: str) -> bool:
try: try:
browser.switch_to_default_content() browser.switch_to_default_content()
except Exception: except Exception:
@ -1624,18 +1988,68 @@ def _reload_contract_home(browser: BrowserAdapter, home: str) -> None:
) )
except Exception: except Exception:
pass pass
browser.open_url(home) try:
if not _wait_text_in_frames(browser, "我的合同", timeout_seconds=15): browser.open_url(home)
print("主界面未出现「我的合同」,仍继续进入查询", flush=True) except Exception as exc:
print(f"open_url 主界面失败:{exc}", flush=True)
return _wait_text_in_frames(browser, "我的合同", timeout_seconds=12)
def _reopen_contract_home(browser: BrowserAdapter, ctx: ContractFlowContext, home: str) -> bool:
print("主界面刷新失败,关闭当前页后重开", flush=True)
reopen = getattr(browser, "reopen_url", None)
if callable(reopen):
reopen(home)
else:
browser.open_url(home)
if _wait_text_in_frames(browser, "我的合同", timeout_seconds=12):
return True
if not _is_logged_in(browser):
login_contract_system(browser, ctx)
browser.open_url(home)
return _wait_text_in_frames(browser, "我的合同", timeout_seconds=12)
def _open_my_contracts(browser: BrowserAdapter, ctx: ContractFlowContext) -> None: def _open_my_contracts(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
home = (ctx.contract_url or "").strip() or "http://contract-test.gzmpc.com:8080/" home = (ctx.contract_url or "").strip() or "http://contract-test.gzmpc.com:8080/"
_reload_contract_home(browser, home) if not _reload_contract_home(browser, home):
_click_by_exact_text(browser, "我的合同") _reopen_contract_home(browser, ctx, home)
if not _click_my_contracts_sidebar(browser):
print("未点到侧栏「我的合同」,关闭页面后重开", flush=True)
_reopen_contract_home(browser, ctx, home)
_click_my_contracts_sidebar(browser)
_wait_text_in_frames(browser, "我起草的", timeout_seconds=10) _wait_text_in_frames(browser, "我起草的", timeout_seconds=10)
_click_by_exact_text(browser, "我起草的") _click_by_exact_text(browser, "我起草的")
_wait_text_in_frames(browser, "申请时间", timeout_seconds=10) _wait_text_in_frames(browser, "申请时间", timeout_seconds=10)
_wait_text_in_frames(browser, "合同编号", timeout_seconds=10)
def _click_my_contracts_sidebar(browser: BrowserAdapter) -> bool:
clicked = browser.execute_script(
"""
const label = '我的合同';
function clickIn(win) {
const nodes = Array.from(win.document.querySelectorAll('a, span, div, li, p, em, td'));
const hits = nodes.filter((el) => (el.textContent || '').trim() === label);
hits.sort((a, b) => {
const ax = a.getBoundingClientRect().left;
const bx = b.getBoundingClientRect().left;
if (ax !== bx) return ax - bx;
return a.children.length - b.children.length;
});
if (hits.length) { hits[0].click(); return true; }
for (let i = 0; i < win.frames.length; i++) {
try { if (clickIn(win.frames[i])) return true; } catch (e) {}
}
return false;
}
return clickIn(window);
"""
)
if clicked is True:
return _wait_text_in_frames(browser, "我起草的", timeout_seconds=8)
_click_by_exact_text(browser, "我的合同")
return _wait_text_in_frames(browser, "我起草的", timeout_seconds=8)
def _wait_text_in_frames(browser: BrowserAdapter, text: str, timeout_seconds: int = 15) -> bool: def _wait_text_in_frames(browser: BrowserAdapter, text: str, timeout_seconds: int = 15) -> bool:
@ -1851,6 +2265,16 @@ def _search_my_contracts(browser: BrowserAdapter, title: str, customer_code: str
} }
return false; return false;
} }
function fillPlaceholder(doc, hint, value) {
if (!value) return false;
const inputs = Array.from(doc.querySelectorAll('input'));
const el = inputs.find((i) => (i.getAttribute('placeholder') || '').indexOf(hint) >= 0);
if (!el) return false;
setValue(el, value);
const btn = el.parentElement && el.parentElement.querySelector('a, button, span, i, em');
if (btn) btn.click();
return true;
}
function clickSearch(doc) { function clickSearch(doc) {
const labels = ['搜索', '查询']; const labels = ['搜索', '查询'];
const nodes = Array.from(doc.querySelectorAll('a, div, span, button, input')); const nodes = Array.from(doc.querySelectorAll('a, div, span, button, input'));
@ -1865,9 +2289,12 @@ def _search_my_contracts(browser: BrowserAdapter, title: str, customer_code: str
} }
function walk(win) { function walk(win) {
try { try {
fillNearLabel(win.document, '合同标题', title) fillPlaceholder(win.document, '合同(协议)全称', title)
|| fillPlaceholder(win.document, '合同标题', title)
|| fillNearLabel(win.document, '合同标题', title)
|| fillNearLabel(win.document, '合同(协议)全称', title); || fillNearLabel(win.document, '合同(协议)全称', title);
fillNearLabel(win.document, '客户编码', customer); fillPlaceholder(win.document, '客户编码', customer)
|| fillNearLabel(win.document, '客户编码', customer);
clickSearch(win.document); clickSearch(win.document);
} catch (e) {} } catch (e) {}
for (let i = 0; i < win.frames.length; i++) { for (let i = 0; i < win.frames.length; i++) {
@ -2009,6 +2436,7 @@ def _read_contract_number_from_my_list(
_open_my_contracts(browser, ctx) _open_my_contracts(browser, ctx)
deadline = time.time() + timeout_seconds deadline = time.time() + timeout_seconds
searched = False searched = False
saw_row = False
while time.time() < deadline: while time.time() < deadline:
rows = _scrape_my_contract_rows(browser) rows = _scrape_my_contract_rows(browser)
print(f"S13 列表行数={len(rows)}", flush=True) print(f"S13 列表行数={len(rows)}", flush=True)
@ -2031,14 +2459,15 @@ def _read_contract_number_from_my_list(
print("从我的合同读到合同编号:", no, flush=True) print("从我的合同读到合同编号:", no, flush=True)
return no return no
if fd_id and _row_has_fd_id(rows, fd_id): if fd_id and _row_has_fd_id(rows, fd_id):
no = _read_number_by_fd_id(browser, fd_id) saw_row = True
if no:
return no
_open_my_contracts(browser, ctx)
if not searched: if not searched:
_search_my_contracts(browser, title, customer) _search_my_contracts(browser, title, customer)
searched = True searched = True
time.sleep(1) time.sleep(1)
if saw_row and fd_id:
no = _read_number_by_fd_id(browser, fd_id)
if no:
return no
print("我的合同列表未匹配到合同编号", flush=True) print("我的合同列表未匹配到合同编号", flush=True)
return "" return ""

View File

@ -11,7 +11,7 @@ from persist import ContractOutcome
from repo import OracleContractRepo from repo import OracleContractRepo
def production_runner(draft: ContractDraft) -> ContractOutcome: def production_runner(browser: ApaBrowserAdapter, draft: ContractDraft) -> ContractOutcome:
spec = ContractCreateInput( spec = ContractCreateInput(
job_id="contract-batch", job_id="contract-batch",
supplier_name=draft.supplier_name, supplier_name=draft.supplier_name,
@ -20,7 +20,6 @@ def production_runner(draft: ContractDraft) -> ContractOutcome:
submit_mode="submit", submit_mode="submit",
) )
ctx = build_flow_context(spec, draft) ctx = build_flow_context(spec, draft)
browser = ApaBrowserAdapter(url=ctx.contract_url or CONTRACT_URL, flow="CONTRACT_CREATE")
def run() -> str: def run() -> str:
return run_contract_create_flow(browser, ctx) return run_contract_create_flow(browser, ctx)
@ -31,7 +30,8 @@ def production_runner(draft: ContractDraft) -> ContractOutcome:
def main() -> int: def main() -> int:
load_runtime_env() load_runtime_env()
repo = OracleContractRepo.from_env() repo = OracleContractRepo.from_env()
summary = run_batch(repo, runner=production_runner) browser = ApaBrowserAdapter(url=CONTRACT_URL, flow="CONTRACT_CREATE")
summary = run_batch(repo, runner=lambda draft: production_runner(browser, draft))
print( print(
f"claimed={summary.claimed} groups={summary.groups} " f"claimed={summary.claimed} groups={summary.groups} "
f"ok={summary.ok} failed={summary.failed}" f"ok={summary.ok} failed={summary.failed}"

View File

@ -1,6 +1,11 @@
from pathlib import Path from pathlib import Path
from apa_browser_adapter import REPO_ROOT, resolve_chrome_profile_base from apa_browser_adapter import (
REPO_ROOT,
chrome_debug_port,
resolve_chrome_profile_base,
resolve_chrome_profile_dir,
)
def test_default_chrome_profile_is_outside_repo(): def test_default_chrome_profile_is_outside_repo():
@ -25,6 +30,20 @@ def test_env_path_outside_repo_is_kept(tmp_path):
assert profile == outside.resolve() assert profile == outside.resolve()
def test_chrome_profile_dir_is_stable_per_flow():
first = resolve_chrome_profile_dir("CONTRACT_CREATE")
second = resolve_chrome_profile_dir("CONTRACT_CREATE")
assert first == second
assert first == (REPO_ROOT.parent / "rpa-chrome-profile" / "CONTRACT_CREATE").resolve()
assert not _is_relative_to(first, REPO_ROOT)
def test_chrome_debug_port_is_stable_per_flow(monkeypatch):
monkeypatch.delenv("RPA_CHROME_DEBUG_PORT", raising=False)
assert chrome_debug_port("CONTRACT_CREATE") == 9333
assert chrome_debug_port("CONTRACT_CREATE") == chrome_debug_port("CONTRACT_CREATE")
def _is_relative_to(path: Path, root: Path) -> bool: def _is_relative_to(path: Path, root: Path) -> bool:
try: try:
path.resolve().relative_to(root.resolve()) path.resolve().relative_to(root.resolve())

View File

@ -4,12 +4,18 @@ from contract_flow_steps import (
ContractFlowContext, ContractFlowContext,
begin_date_for, begin_date_for,
check_customer_diligence, check_customer_diligence,
read_contract_number,
sign_date_for, sign_date_for,
_as_index_list, _as_index_list,
_classify_dialog_text,
_click_toolbar_button,
_lui_btn_xpath,
_next_unused_goods_row, _next_unused_goods_row,
_pick_contract_row, _pick_contract_row,
_read_contract_number_on_page,
_resolve_contract_body_path, _resolve_contract_body_path,
_submit_error_from_body, _submit_error_from_body,
_submit_succeeded,
) )
from errors import DiligenceError from errors import DiligenceError
import pytest import pytest
@ -29,12 +35,29 @@ def _ctx(**overrides) -> ContractFlowContext:
class _FakeBrowser: class _FakeBrowser:
def __init__(self, text: str): def __init__(self, text: str, url: str = "", script_result: str = ""):
self._text = text self._text = text
self._url = url
self._script_result = script_result
self.opened: list[str] = []
def read_body_text(self) -> str: def read_body_text(self) -> str:
return self._text return self._text
def switch_to_default_content(self) -> None:
return None
def open_url(self, url: str) -> None:
self.opened.append(url)
self._url = url
def execute_script(self, script: str, *args):
if "_xform_docNumber" in script:
return self._script_result
if "location.href" in script:
return self._url
return ""
def test_sign_and_begin_fall_back_to_order_date(): def test_sign_and_begin_fall_back_to_order_date():
ctx = _ctx() ctx = _ctx()
@ -124,6 +147,121 @@ def test_submit_error_from_doc_missing_page():
assert _submit_error_from_body("客户编码 提交") == "" assert _submit_error_from_body("客户编码 提交") == ""
def test_submit_succeeded_on_success_toast():
browser = _FakeBrowser("您的操作已成功", url="kmAgreementApply.do?method=edit&fdId=1")
assert _submit_succeeded(browser) is True
def test_submit_succeeded_on_view_page():
browser = _FakeBrowser("合同详情", url="kmAgreementApply.do?method=view&fdId=1")
assert _submit_succeeded(browser) is True
def test_submit_not_succeeded_on_edit_or_add_page():
edit = _FakeBrowser("客户编码 暂存 提交", url="kmAgreementApply.do?method=edit&fdId=1")
add = _FakeBrowser("客户编码 暂存 提交", url="kmAgreementApply.do?method=add")
assert _submit_succeeded(edit) is False
assert _submit_succeeded(add) is False
def test_classify_dialog_text_kinds():
assert _classify_dialog_text("操作失败!该文档不存在") == "error"
assert _classify_dialog_text("您确定要提交此文档吗?") == "confirm"
assert _classify_dialog_text("您的操作已成功") == "success"
assert _classify_dialog_text("客户编码") == "none"
def test_lui_btn_xpath_is_exact_txt():
xpath = _lui_btn_xpath("提交")
assert "lui_widget_btn_txt" in xpath
assert "normalize-space()='提交'" in xpath
class _ClickBrowser(_FakeBrowser):
def __init__(self, script_result):
super().__init__("")
self.script_result = script_result
self.scripts: list[tuple] = []
self.native: list[list[str]] = []
self.first: list[list[str]] = []
def execute_script(self, script, *args):
self.scripts.append((script, args))
return self.script_result
def click_native(self, selectors):
self.native.append(list(selectors))
def click_first(self, selectors):
self.first.append(list(selectors))
def test_click_toolbar_button_targets_lui_btn_txt():
browser = _ClickBrowser({"ok": True, "via": "btn_txt", "cid": "lui-id-36"})
assert _click_toolbar_button(browser, "提交") is True
script, args = browser.scripts[0]
assert "lui_widget_btn_txt" in script
assert args[0] == "提交"
assert browser.native == []
assert browser.first == []
def test_click_toolbar_button_fallback_uses_exact_txt():
browser = _ClickBrowser({"ok": False, "texts": ["暂存"]})
assert _click_toolbar_button(browser, "提交") is True
joined = " ".join(browser.native[0])
assert "lui_widget_btn_txt" in joined
assert "normalize-space()='提交'" in joined
assert "text=提交" not in joined
assert "contains(text()" not in joined
def test_read_contract_number_from_xform_doc_number():
browser = _FakeBrowser(
"手机号 13559935238 合同编号",
script_result="035012026100835435",
)
assert _read_contract_number_on_page(browser) == "035012026100835435"
no = read_contract_number(browser, _ctx(), fd_id="abc")
assert no == "035012026100835435"
assert any("method=view" in url and "fdId=abc" in url for url in browser.opened)
assert all("我的合同" not in url for url in browser.opened)
def test_read_contract_number_requires_fd_id():
browser = _FakeBrowser("合同编号")
assert read_contract_number(browser, _ctx(), fd_id="") == ""
assert browser.opened == []
def test_read_contract_number_skips_placeholder_and_phone():
browser = _FakeBrowser(
"手机号\n13559935238\n提交时自动生成",
script_result="提交时自动生成",
)
assert _read_contract_number_on_page(browser) == ""
def test_pick_contract_row_skips_empty_number():
rows = [
{
"no": "",
"title": "重庆亿昊药业有限公司采购协议20261009",
"customer": "DW0100046361",
"href": "kmAgreementApply.do?method=view&fdId=ours",
}
]
assert (
_pick_contract_row(
rows,
fd_id="ours",
title="重庆亿昊药业有限公司采购协议20261009",
customer_code="DW0100046361",
)
== ""
)
def test_submit_without_file_uses_placeholder_body(): def test_submit_without_file_uses_placeholder_body():
ctx = _ctx(submit_mode="submit", source_type="") ctx = _ctx(submit_mode="submit", source_type="")
path = _resolve_contract_body_path(ctx) path = _resolve_contract_body_path(ctx)