AutoRpa/rpa_rebate/rebate_flow_steps.py

229 lines
7.2 KiB
Python

"""返利中心查号:打开、登录、进采购协议、按合同号搜。不录协议。"""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Protocol
from accept import AcceptanceReport, evidence_shot
from errors import LoginFailed
FLOW = "REBATE_FIND"
DEFAULT_URL = "http://192.168.170.144:8001"
SENTINEL_CONTRACT_NO = "__NO_SUCH_CONTRACT__"
_ACCEPT = AcceptanceReport(flow=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": [
"text=登录",
"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'
));
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: ...
@dataclass(frozen=True)
class RebateFlowContext:
rebate_url: str
username: str
password: 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:
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)
browser.click_first(LOGIN["submit"])
deadline = time.time() + 20
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 enter_purchase_agreement(browser: BrowserAdapter) -> None:
body = browser.read_body_text() or ""
if "合同编号" in body or "合同号" in body:
return
browser.click_first(MENU["entry"])
browser.wait_for_text("查询", timeout_seconds=15)
def search_contract(browser: BrowserAdapter, contract_no: str) -> bool:
number = (contract_no or "").strip()
if not number:
return False
browser.fill_first(SEARCH["contract_no"], number)
browser.click_first(SEARCH["button"])
deadline = time.time() + 15
last_text = ""
last_from_table = False
while time.time() <= deadline:
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)
empty = any(marker in (text or "") for marker in EMPTY_MARKERS)
if found or empty:
return found
time.sleep(0.4)
return classify_search_result(last_text, number, from_table=last_from_table)
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()