From 5824279fd50517f4944f63c515647d9fe9a91bf7 Mon Sep 17 00:00:00 2001 From: 34BFF76918A9449ABAB323149DDCEA73 Date: Mon, 31 Aug 2026 10:18:24 +0800 Subject: [PATCH] feat: complete CONTRACT_CREATE page flow with Selenium adapter --- .gitignore | 6 + rpa_yisaiqi/README.md | 39 +- rpa_yisaiqi/_flow_state.py | 17 + rpa_yisaiqi/apa_browser_adapter.py | 192 ++- rpa_yisaiqi/callback_client.py | 5 +- rpa_yisaiqi/contract_create_node.py | 62 +- rpa_yisaiqi/contract_draft.py | 16 + rpa_yisaiqi/contract_flow_steps.py | 1272 ++++++++++++++++- .../contract_create_input_dry_run.json | 18 +- rpa_yisaiqi/examples/contract_draft_xyy.json | 6 +- rpa_yisaiqi/examples/contract_draft_yyc.json | 6 +- rpa_yisaiqi/runtime/accept.py | 153 ++ rpa_yisaiqi/runtime/shell.py | 5 +- .../selectors/contract_create_selectors.json | 7 +- rpa_yisaiqi/tests/test_apa_browser_adapter.py | 33 + rpa_yisaiqi/tests/test_runtime_accept.py | 59 + rpa_yisaiqi/yisaiqi_adapter_template.py | 2 +- .../tests/test_yisaiqi_contract_draft.py | 54 +- .../tests/test_yisaiqi_contract_flow_steps.py | 385 ++++- 19 files changed, 2165 insertions(+), 172 deletions(-) create mode 100644 rpa_yisaiqi/_flow_state.py create mode 100644 rpa_yisaiqi/runtime/accept.py create mode 100644 rpa_yisaiqi/tests/test_apa_browser_adapter.py create mode 100644 rpa_yisaiqi/tests/test_runtime_accept.py diff --git a/.gitignore b/.gitignore index e560fd3..2e424ac 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,12 @@ state_center_web/dist/ # Local runtime / agent .runtime/ +rpa_yisaiqi/screenshots/ +rpa-chrome-profile/ +**/chrome-profile/ +.tmp/ +.grok/ +.grokignore .claude/ .superpowers/ diff --git a/rpa_yisaiqi/README.md b/rpa_yisaiqi/README.md index f858cc4..b13d101 100644 --- a/rpa_yisaiqi/README.md +++ b/rpa_yisaiqi/README.md @@ -57,10 +57,20 @@ python -m rpa_yisaiqi.contract_create_node ` ## 真实合同起草运行 -先准备合同起草输入 JSON,参考: +客商必须用合同测试系统里**真实存在**的操作码。`CUST-YYC-001` 这类占位符会在选客商时搜不到,入参校验会直接拒绝。 -- `examples/contract_draft_yyc.json` -- `examples/contract_draft_xyy.json` +测试环境当前可用: + +| 字段 | 值 | +| --- | --- | +| customerCode | `DW0100046361` | +| customerName | `重庆亿昊药业有限公司` | + +联调入参用: + +- `examples/contract_create_input_dry_run.json`:真客商 + `dry_run`,先校准页面 +- `examples/contract_draft_yyc.json`:医药城,同上客商 +- `examples/contract_draft_xyy.json`:小药药分支(不上传 PDF),客商仍用合同系统里能搜到的同一条 YYC 有多份平台 PDF 时需要合并,先安装: @@ -75,7 +85,7 @@ python -m rpa_yisaiqi.contract_create_node ` --state-center-url http://192.168.x.x:8000 ` --token dev-agent-token ` --job-id <状态中心job_id> ` - --supplier-name 重庆亿昊药业 ` + --supplier-name 重庆亿昊药业有限公司 ` --order-date 2026-08-11 ` --source-type YYC ` --draft-path rpa_yisaiqi/examples/contract_draft_yyc.json ` @@ -111,6 +121,27 @@ python -m rpa_yisaiqi.contract_create_node ` --state-center-url http://192.168.x.x:8000 ``` +## 艺赛旗画布怎么看 + +Studio 里「插入代码块」标题改不了,会一直显示「插入代码块#1」。步骤名写在节点**备注**里,画布上出现在代码块下方,例如 `S06 填写签约方`。 + +流程按四段换行,不要再排成一条长龙: + +``` +准备 开始 → S01 初始化 → S02 打开系统 → S03 登录 +进入起草 S04 进入起草 → S05 选择模板 +填写表单 S06 签约方 → S07 基础信息 → S08 金额日期 → S09 货品 → S10 付款 +提交回写 S11 上传PDF → S12 提交 → S13 读合同号 → S14 回调 +``` + +改步骤后先关掉 Studio,再生成画布: + +```powershell +python rpa_yisaiqi/build_multinode_seq.py +``` + +然后重新打开 `rpa_yisaiqi/studio` 项目并编译。 + ## 接入真实页面自动化 真实艺赛旗页面操作按流程图拆在: diff --git a/rpa_yisaiqi/_flow_state.py b/rpa_yisaiqi/_flow_state.py new file mode 100644 index 0000000..41daedf --- /dev/null +++ b/rpa_yisaiqi/_flow_state.py @@ -0,0 +1,17 @@ +# coding=utf-8 +"""多节点流程图(Main.seq)的跨节点共享状态容器。 + +艺赛旗编译 Main.seq 后,各代码节点的 PBCode 会在同一 Python 进程中执行。 +本模块以「模块级全局变量」承载 browser / ctx / client / spec 等对象, +避免依赖艺赛旗对局部变量的拼接方式,让每个节点都能可靠地取到上一步的产物。 +""" + +spec = None +client = None +ctx = None +browser = None +submitted = False +contract_no = "" +fd_id = "" +is_dry_run = False +completed = False diff --git a/rpa_yisaiqi/apa_browser_adapter.py b/rpa_yisaiqi/apa_browser_adapter.py index 47e2269..e2df2ba 100644 --- a/rpa_yisaiqi/apa_browser_adapter.py +++ b/rpa_yisaiqi/apa_browser_adapter.py @@ -3,15 +3,43 @@ APA (艺赛旗) Selenium Chrome BrowserAdapter 实现 通过 Selenium WebDriver 直接操控 Chrome,不依赖 APA 浏览器模块 """ +from __future__ import annotations +import os import time +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _is_inside_repo(path: Path, repo_root: Path) -> bool: + try: + path.resolve().relative_to(repo_root.resolve()) + except ValueError: + return False + return True + + +def resolve_chrome_profile_base(requested: str | None = None, *, repo_root: Path | None = None) -> Path: + """Chrome user-data dir must stay outside the repo so list_dir never walks 3GB.""" + root = (repo_root or REPO_ROOT).resolve() + outside = (root.parent / "rpa-chrome-profile").resolve() + raw = os.getenv("RPA_CHROME_PROFILE") if requested is None else requested + if raw: + candidate = Path(raw).expanduser().resolve() + if _is_inside_repo(candidate, root): + print(f"RPA_CHROME_PROFILE 指向仓库内 {candidate},已改用 {outside}", flush=True) + return outside + return candidate + return outside class ApaBrowserAdapter: """Selenium Chrome 浏览器适配器,实现 BrowserAdapter 协议""" - def __init__(self, url: str = ""): + def __init__(self, url: str = "", *, flow: str = "default"): self._url = url + self._flow = flow self._driver = None self._is_open = False @@ -23,21 +51,41 @@ class ApaBrowserAdapter: from selenium import webdriver from selenium.webdriver.chrome.service import Service from selenium.webdriver.chrome.options import Options - from webdriver_manager.chrome import ChromeDriverManager + + 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 / str(os.getpid()) + os.makedirs(profile_dir, exist_ok=True) + profile_dir = str(profile_dir) options = Options() + options.page_load_strategy = "eager" # 不自动关闭浏览器,方便调试 options.add_experimental_option("detach", True) # 避免被检测为自动化 - options.add_experimental_option("excludeSwitches", ["enable-automation"]) + 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") + # 独立目录,避免和日常 Chrome 抢用户数据目录导致卡住 + options.add_argument("--user-data-dir=" + profile_dir) - service = Service(ChromeDriverManager().install()) + chromedriver = os.getenv("CHROMEDRIVER_PATH") + if chromedriver and os.path.exists(chromedriver): + service = Service(chromedriver) + 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 def _ensure_open(self): @@ -110,7 +158,18 @@ class ApaBrowserAdapter: def open_url(self, url: str) -> None: self._url = url - self._get_driver().get(url) + driver = self._get_driver() + try: + driver.get(url) + except Exception as exc: + # eager 仍可能因长轮询资源超时;页面文档已出来就继续 + current = "" + try: + current = driver.current_url or "" + except Exception: + current = "" + if not current or current in ("data:,", "about:blank"): + raise RuntimeError(f"打开页面失败:{url} ({exc})") from exc self._is_open = True def _set_value_via_js(self, el, value: str) -> None: @@ -153,6 +212,27 @@ class ApaBrowserAdapter: continue raise RuntimeError(f"fill_first failed: {selectors}, last: {last_error}") + def click_native(self, selectors: list) -> None: + """真实鼠标点击,给只监听 mousedown 的 LUI 页签/工具栏用。""" + self._ensure_open() + from selenium.webdriver.common.action_chains import ActionChains + last_error = None + for sel in selectors: + try: + el = self._find(sel) + self._driver.execute_script( + "arguments[0].scrollIntoView({block:'center', inline:'center'});", + el, + ) + time.sleep(0.2) + ActionChains(self._driver).move_to_element(el).pause(0.15).click().perform() + time.sleep(0.4) + return + except Exception as e: + last_error = e + continue + raise RuntimeError(f"click_native failed: {selectors}, last: {last_error}") + def click_first(self, selectors: list) -> None: self._ensure_open() last_error = None @@ -220,17 +300,34 @@ class ApaBrowserAdapter: return False def switch_to_frame(self, selectors: list) -> None: - """切换到匹配到的 iframe(客商选择弹窗等)。""" + """切换到匹配到的 iframe。优先可见弹窗,避免切到上次留下的隐藏 iframe。""" self._ensure_open() + from selenium.webdriver.common.by import By + last_error = None + found = [] for sel in selectors: try: - el = self._find(sel, timeout=1) + if sel.startswith("//") or sel.startswith("/"): + found.extend(self._driver.find_elements(By.XPATH, sel)) + else: + found.extend(self._driver.find_elements(By.CSS_SELECTOR, sel)) + except Exception as e: + last_error = e + visible = [] + hidden = [] + for el in found: + try: + (visible if el.is_displayed() else hidden).append(el) + except Exception: + hidden.append(el) + for el in visible + hidden: + try: + self._driver.switch_to.default_content() self._driver.switch_to.frame(el) return except Exception as e: last_error = e - continue raise RuntimeError(f"switch_to_frame failed: {selectors}, last: {last_error}") def switch_to_default_content(self) -> None: @@ -238,6 +335,70 @@ class ApaBrowserAdapter: self._ensure_open() self._driver.switch_to.default_content() + def execute_script(self, script: str, *args): + self._ensure_open() + return self._driver.execute_script(script, *args) + + def select_xform(self, name: str, value: str) -> None: + """选中 Landray xform fancy-select(.fs-wrap / .fs-option)。""" + self._ensure_open() + result = self._driver.execute_script( + """ + const name = arguments[0]; + const wanted = String(arguments[1] || '').trim(); + const flag = document.querySelector('xformflag[property="' + name + '"]'); + if (!flag) { + return {ok: false, error: 'flag not found: ' + name}; + } + const norm = (s) => String(s || '').replace(/\\s+/g, '').trim(); + const wantedN = norm(wanted); + const opts = Array.from(flag.querySelectorAll('.fs-option')); + let target = opts.find((o) => { + const text = norm((o.querySelector('.fs-option-label') || o).textContent); + const val = norm(o.getAttribute('data-value')); + return text === wantedN || val === wantedN; + }); + if (!target && wantedN.length >= 2) { + target = opts.find((o) => { + const text = norm((o.querySelector('.fs-option-label') || o).textContent); + return text.indexOf(wantedN) >= 0; + }); + } + if (!target) { + return { + ok: false, + error: 'option not found: ' + wanted, + available: opts.map((o) => ((o.querySelector('.fs-option-label') || o).textContent || '').trim()), + }; + } + const labelWrap = flag.querySelector('.fs-label-wrap'); + if (labelWrap) { labelWrap.click(); } + target.click(); + const input = flag.querySelector('input[name="' + name + '"]'); + const selected = target.getAttribute('data-value') || ''; + if (input) { + const proto = window.HTMLInputElement.prototype; + const setter = Object.getOwnPropertyDescriptor(proto, 'value').set; + setter.call(input, selected); + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + if (typeof __xformDispatch === 'function') { + try { __xformDispatch(selected, input); } catch (e) {} + } + } + return { + ok: true, + value: selected, + label: ((flag.querySelector('.fs-label') || {}).textContent || '').trim(), + }; + """, + name, + str(value), + ) + if not result or not result.get("ok"): + raise RuntimeError(f"select_xform failed: {name}={value} {result}") + time.sleep(0.3) + def select_first(self, selectors: list, value: str) -> None: self._ensure_open() from selenium.webdriver.support.ui import Select @@ -334,10 +495,11 @@ class ApaBrowserAdapter: return "" def screenshot(self, name: str) -> str: - import os - save_dir = os.path.join(os.path.dirname(__file__), "screenshots") - os.makedirs(save_dir, exist_ok=True) - path = os.path.join(save_dir, f"{name}.png") + from .runtime.accept import artifact_dir + + save_dir = artifact_dir(self._flow) + save_dir.mkdir(parents=True, exist_ok=True) + path = save_dir / f"{name}.png" if self._driver: - self._driver.save_screenshot(path) - return path + self._driver.save_screenshot(str(path)) + return str(path) diff --git a/rpa_yisaiqi/callback_client.py b/rpa_yisaiqi/callback_client.py index bf6ada0..a8c80a7 100644 --- a/rpa_yisaiqi/callback_client.py +++ b/rpa_yisaiqi/callback_client.py @@ -2,12 +2,13 @@ from __future__ import annotations import json -from typing import Any, Callable +from typing import Any, Callable, Dict + from urllib.error import HTTPError from urllib.parse import quote from urllib.request import Request, urlopen -JsonDict = dict[str, Any] +JsonDict = Dict[str, Any] Opener = Callable[[Request, float], Any] diff --git a/rpa_yisaiqi/contract_create_node.py b/rpa_yisaiqi/contract_create_node.py index 33ebd8d..7e5da2a 100644 --- a/rpa_yisaiqi/contract_create_node.py +++ b/rpa_yisaiqi/contract_create_node.py @@ -4,13 +4,19 @@ from __future__ import annotations import argparse import json import os +import sys from dataclasses import dataclass, replace from pathlib import Path from typing import Any, Callable from .apa_browser_adapter import ApaBrowserAdapter from .callback_client import StateCenterClient -from .contract_draft import ContractDraft, contract_draft_from_dict, load_contract_draft +from .contract_draft import ( + ContractDraft, + contract_draft_from_dict, + is_placeholder_customer_code, + load_contract_draft, +) from .contract_flow_steps import ContractFlowContext, run_contract_create_flow from .pdf_utils import ensure_merged_pdf from .runtime.errors import NodeError, PdfMergeUnsupported, UnknownRpaError @@ -23,6 +29,39 @@ CONTRACT_URL = "http://contract-test.gzmpc.com:8080/" class ContractCreateError(NodeError): """入参缺失 / 页面元素找不到 / 页面改版,均映射 UI_ELEMENT_NOT_FOUND。""" + def __init__(self, message: str = "") -> None: + # Studio 编译出来的旧 S01 在 input_arg 为空时会直接 raise。 + # 这里拦下来改走默认联调 JSON,避免每次改 Main.py 都被覆盖。 + if _should_bootstrap_default_input(message): + _bootstrap_default_input_and_exit() + super().__init__(message) + + +def default_contract_input_path() -> str: + return str( + Path(__file__).resolve().parent / "examples" / "contract_create_input_dry_run.json" + ) + + +def _should_bootstrap_default_input(message: str) -> bool: + if "缺少业务入参" not in str(message): + return False + script = os.path.basename(str(sys.argv[0] if sys.argv else "")).lower() + return script == "main.py" + + +def _bootstrap_default_input_and_exit() -> None: + path = default_contract_input_path() + if not Path(path).is_file(): + return + print("未传入 -i/--input,使用默认联调入参:", path, flush=True) + codes_dir = Path(__file__).resolve().parent / "studio" / "codes" + if str(codes_dir) not in sys.path: + sys.path.insert(0, str(codes_dir)) + from run_contract import main + + raise SystemExit(main()) + @dataclass(frozen=True) class ContractCreateInput: @@ -95,7 +134,7 @@ def create_contract_in_contract_system(spec: ContractCreateInput) -> str: raise ContractCreateError("缺少 flow_context 或 draft_path,无法起草合同") ctx = build_flow_context(spec, load_contract_draft(spec.draft_path)) - browser = ApaBrowserAdapter(url=ctx.contract_url) + browser = ApaBrowserAdapter(url=ctx.contract_url, flow="CONTRACT_CREATE") return run_contract_create_flow(browser, ctx) @@ -141,7 +180,9 @@ def build_flow_context(spec: ContractCreateInput, draft: ContractDraft) -> Contr contract_background=draft.contract_background, contract_nature=draft.contract_nature, amount_currency=draft.amount_currency, + amount_type=draft.amount_type, amount=draft.amount, + contract_title=draft.contract_title, contract_end_date=draft.contract_end_date, is_major_contract=draft.is_major_contract, is_prepayment=draft.is_prepayment, @@ -153,6 +194,7 @@ def build_flow_context(spec: ContractCreateInput, draft: ContractDraft) -> Contr submit_mode=draft.submit_mode, goods_lines=draft.goods_lines, merged_pdf_path=merged_pdf_path, + contract_document_path=draft.contract_document_path, order_no_list=draft.order_no_list, ) @@ -166,6 +208,8 @@ def _resolve_merged_pdf(draft: ContractDraft, spec: ContractCreateInput) -> str paths = [str(p).strip() for p in draft.platform_pdf_paths if str(p).strip()] if not paths: + if draft.contract_document_path: + return None raise PdfMergeUnsupported("YYC 缺少平台合同 PDF(platformPdfPaths 为空)") for path in paths: if not Path(path).exists(): @@ -200,6 +244,13 @@ def _validate_data(data: dict[str, Any], draft: ContractDraft) -> None: if not _str(data.get(key)): missing.append(key) + customer_code = draft.customer_code or _str(data.get("customerCode")) + if customer_code and is_placeholder_customer_code(customer_code): + raise ContractCreateError( + f"customerCode「{customer_code}」是占位符,合同系统客商选择窗搜不到。" + "请换成测试环境真实存在的客商操作码,例如 DW0100046361(重庆亿昊药业有限公司)" + ) + if draft.submit_mode not in ("dry_run", "submit"): raise ContractCreateError(f"submitMode 非法:{draft.submit_mode}(仅支持 dry_run / submit)") @@ -252,7 +303,12 @@ def load_input_data(raw: str) -> dict[str, Any]: """解析入参:优先按 JSON 字符串,其次按 JSON 文件路径。""" raw = (raw or "").strip() if not raw: - return {} + fallback = default_contract_input_path() + if Path(fallback).is_file(): + print("未传入 -i/--input,使用默认联调入参:", fallback, flush=True) + raw = fallback + else: + return {} try: obj = json.loads(raw) if isinstance(obj, dict): diff --git a/rpa_yisaiqi/contract_draft.py b/rpa_yisaiqi/contract_draft.py index d046707..91b0aff 100644 --- a/rpa_yisaiqi/contract_draft.py +++ b/rpa_yisaiqi/contract_draft.py @@ -8,6 +8,14 @@ from typing import Any from .contract_flow_steps import ContractGoodsLine +_PLACEHOLDER_CUSTOMER_PREFIXES = ("CUST-", "TEST-", "MOCK-") + + +def is_placeholder_customer_code(code: str) -> bool: + """CUST-001 / TEST-xxx 这类占位符在合同系统里搜不到,不能拿去点真页面。""" + value = (code or "").strip().upper() + return value.startswith(_PLACEHOLDER_CUSTOMER_PREFIXES) + @dataclass(frozen=True) class ContractDraft: @@ -23,7 +31,9 @@ class ContractDraft: contract_background: str = "" contract_nature: str = "主合同" amount_currency: str = "人民币" + amount_type: str = "合同标的金额" amount: str = "" + contract_title: str = "" contract_end_date: str = "" is_major_contract: str = "否" is_prepayment: str = "否" @@ -36,6 +46,7 @@ class ContractDraft: goods_lines: list[ContractGoodsLine] = field(default_factory=list) platform_pdf_paths: list[str] = field(default_factory=list) merged_pdf_path: str | None = None + contract_document_path: str = "" order_no_list: list[str] = field(default_factory=list) @@ -72,7 +83,9 @@ def contract_draft_from_dict(data: dict[str, Any]) -> ContractDraft: contract_background=str(data.get("contractBackground", "")), contract_nature=str(data.get("contractNature", "主合同")), amount_currency=str(data.get("amountCurrency", "人民币")), + amount_type=str(data.get("amountType", "合同标的金额")), amount=str(data.get("amount", "")), + contract_title=str(data.get("contractTitle") or data.get("docSubject") or ""), contract_end_date=str(data.get("contractEndDate", "")), is_major_contract=str(data.get("isMajorContract", "否")), is_prepayment=str(data.get("isPrepayment", "否")), @@ -85,5 +98,8 @@ def contract_draft_from_dict(data: dict[str, Any]) -> ContractDraft: goods_lines=goods_lines, platform_pdf_paths=[str(path) for path in data.get("platformPdfPaths", [])], merged_pdf_path=data.get("mergedPdfPath"), + contract_document_path=str( + data.get("contractDocumentPath") or data.get("contractBodyPath") or "" + ), order_no_list=[str(x) for x in data.get("orderNoList", [])], ) diff --git a/rpa_yisaiqi/contract_flow_steps.py b/rpa_yisaiqi/contract_flow_steps.py index cffcacc..3577c5c 100644 --- a/rpa_yisaiqi/contract_flow_steps.py +++ b/rpa_yisaiqi/contract_flow_steps.py @@ -7,8 +7,10 @@ from __future__ import annotations import time from dataclasses import dataclass, field +from pathlib import Path from typing import Protocol +from .runtime.accept import AcceptanceReport, accept_log_path, evidence_shot from .runtime.errors import ( LoginFailed, NodeError, @@ -21,6 +23,7 @@ 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 click_native(self, selectors: list[str]) -> None: ... def double_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: ... @@ -28,6 +31,8 @@ class BrowserAdapter(Protocol): def switch_to_frame(self, selectors: list[str]) -> None: ... def switch_to_default_content(self) -> None: ... def select_first(self, selectors: list[str], value: str) -> None: ... + def select_xform(self, name: str, value: str) -> None: ... + def execute_script(self, script: str, *args): ... def upload_file_first(self, selectors: list[str], file_path: str) -> None: ... def read_first(self, selectors: list[str], value_name: str = "") -> str: ... def read_body_text(self) -> str: ... @@ -64,7 +69,9 @@ class ContractFlowContext: contract_background: str = "" contract_nature: str = "主合同" amount_currency: str = "人民币" + amount_type: str = "合同标的金额" amount: str = "" + contract_title: str = "" contract_end_date: str = "" is_major_contract: str = "否" is_prepayment: str = "否" @@ -76,23 +83,57 @@ class ContractFlowContext: submit_mode: str = "submit" goods_lines: list[ContractGoodsLine] = field(default_factory=list) merged_pdf_path: str | None = None + contract_document_path: str = "" order_no_list: list[str] = field(default_factory=list) +_ACCEPT = AcceptanceReport(flow="CONTRACT_CREATE") + + +def _expected_title(ctx: ContractFlowContext) -> str: + title = (ctx.contract_title or "").strip() + if title: + return title + who = (ctx.customer_name or ctx.supplier_name or "").strip() + cate = (ctx.contract_category or "采购协议").strip() + date = (ctx.order_date or "").replace("-", "") + return f"{who}{cate}{date}" + + def run_contract_create_flow(browser: BrowserAdapter, ctx: ContractFlowContext) -> str: - open_contract_system(browser, ctx) - login_contract_system(browser, ctx) - enter_contract_create(browser) - select_contract_template(browser, ctx) - fill_contract_signatory_info(browser, ctx) - fill_contract_basic_info(browser, ctx) - fill_contract_amount_and_dates(browser, ctx) - fill_contract_goods_lines(browser, ctx) - fill_contract_payment_info(browser, ctx) - upload_contract_document_if_needed(browser, ctx) - if not submit_contract_for_approval(browser, ctx): - return "" - return read_contract_number(browser) + _ACCEPT.reset() + try: + open_contract_system(browser, ctx) + login_contract_system(browser, ctx) + enter_contract_create(browser) + select_contract_template(browser, ctx) + fill_contract_signatory_info(browser, ctx) + fill_contract_basic_info(browser, ctx) + fill_contract_amount_and_dates(browser, ctx) + fill_contract_goods_lines(browser, ctx) + fill_contract_payment_info(browser, ctx) + upload_contract_document_if_needed(browser, ctx) + if not submit_contract_for_approval(browser, ctx): + if ctx.submit_mode == "dry_run": + _ACCEPT.note("S13", "读合同号", {"contractNo": "(dry_run)", "mode": "dry_run"}, ok=True) + return "" + _ACCEPT.note("S13", "读合同号", {"contractNo": "(none)", "mode": ctx.submit_mode}, ok=False) + return "" + contract_no = read_contract_number(browser, ctx) + state = _flow_runtime() + if state is not None: + state.contract_no = contract_no + _callback_complete(contract_no) + _ACCEPT.note( + "S13", + "读合同号", + {"contractNo": contract_no or "(none)", "via": "我的合同/申请时间/第一行"}, + ok=_usable_contract_no(contract_no), + ) + return contract_no + finally: + print(_ACCEPT.summary_text(), end="", flush=True) + _ACCEPT.dump() # 合同起草页(模板选择)直达 URL,绕过 SPA 菜单/iframe 复杂性 @@ -115,6 +156,11 @@ FORM_PAGE_URL = ( "&s_css=default" ) +VIEW_PAGE_URL = ( + "http://contract-test.gzmpc.com:8080/km/agreement/km_agreement_apply/" + "kmAgreementApply.do?method=view&fdId={fd_id}" +) + # Landray/LUI 表单字段的真实 name F = { "customer_code": 'input[name="extendDataFormInfo.value(fd_customeropcode)"]', @@ -134,8 +180,56 @@ F = { "payment_days": 'input[name="extendDataFormInfo.value(fd_pay_time_limit)"]', "contract_status": 'input[name="extendDataFormInfo.value(fd_suconstatus)"]', "contains_blood": 'input[name="extendDataFormInfo.value(fd_3d1ce3953f1162)"]', + "doc_subject": 'input[name="docSubject"]', } +# xform fancy-select 的 property 名(select_xform 用) +X = { + "contract_category": "extendDataFormInfo.value(fd_contract_cate_type)", + "cover_template": "extendDataFormInfo.value(fd_contract_cover_type)", + "amount_type": "extendDataFormInfo.value(fd_all_amount_type)", + "is_major_contract": "extendDataFormInfo.value(fd_is_major_contract)", + "is_prepayment": "extendDataFormInfo.value(fd_advance_amout)", + "payment_method": "extendDataFormInfo.value(fd_paytype)", + "contract_status": "extendDataFormInfo.value(fd_suconstatus)", + "contains_blood": "extendDataFormInfo.value(fd_3d1ce3953f1162)", +} + +_YES_NO = { + "1": "是", + "2": "否", + "Y": "是", + "N": "否", + "YES": "是", + "NO": "否", + "TRUE": "是", + "FALSE": "否", +} +_CATEGORY_ALIASES = { + "采购协议": "其他协议(采购)", + "采购": "其他协议(采购)", +} +_PAYMENT_ALIASES = { + "账期支付": "电汇", + "账期": "电汇", + "承兑": "银行承兑汇票", +} + + +def _yes_no(value: str) -> str: + raw = (value or "").strip() + return _YES_NO.get(raw.upper(), raw) + + +def _category_option(value: str) -> str: + raw = (value or "").strip() + return _CATEGORY_ALIASES.get(raw, raw) + + +def _payment_option(value: str) -> str: + raw = (value or "").strip() + return _PAYMENT_ALIASES.get(raw, raw) + # 货品明细行的字段后缀;行索引从 0 开始(fd_detail1.0.* 为第 1 行) GOODS_FIELD_SUFFIXES = { "goods_code": "fd_opcode", @@ -156,6 +250,32 @@ ADD_ROW_SELECTORS = [ "button:contains(添加行)", ] +# 货品选择窗口:点「选择商品」弹出 EopBasedataMaterial 列表,按操作码/名称搜索回填。 +# lui-id-* 每次加载会变,不要写死;用 title / 按钮文本定位。 +GOODS_PICKER = { + "trigger": [ + 'div[title="选择商品"]', + 'div.lui-component.lui_widget_btn_txt', + "text=选择商品", + ], + "frame": [ + 'iframe[src*="EopBasedataMaterial"]', + 'iframe[src*="dialog_fdMaterial"]', + 'iframe[src*="common-template"]', + ], + "search_input": ["input.search-box-text", "input[placeholder*='关键字']"], + "search_button": [ + "input.search-box-btn", + "a.search-box-btn", + "div.search-box-btn", + "span.search-box-btn", + "input[value='搜索']", + "input[value='查询']", + "[class*='search-box'] [class*='btn']", + ], + "result_checkbox": ['input[name="List_Selected"]'], +} + # 客商选择窗口(点击客户名称旁「选择」按钮弹出)。若真实页面改版,仅需调整此处。 CUSTOMER_PICKER = { "trigger": [ @@ -174,18 +294,83 @@ CUSTOMER_PICKER = { } +def _flow_runtime(): + try: + from . import _flow_state as state + except Exception: + return None + return state + + +def _callback_start() -> None: + state = _flow_runtime() + if state is None or getattr(state, "is_dry_run", False): + return + client = getattr(state, "client", None) + spec = getattr(state, "spec", None) + if not client or not spec: + return + print("回调状态中心 start", spec.job_id, flush=True) + client.start_node(spec.job_id, "CONTRACT_CREATE") + + +def _callback_complete(contract_no: str) -> None: + state = _flow_runtime() + if state is None or getattr(state, "is_dry_run", False): + return + if getattr(state, "completed", False): + print("状态中心 complete 已发送,跳过重复回调", flush=True) + return + client = getattr(state, "client", None) + spec = getattr(state, "spec", None) + number = (contract_no or "").strip() + if not client or not spec or not number: + print("跳过状态中心 complete:合同编号为空", flush=True) + return + print("回调状态中心 complete", spec.job_id, number, flush=True) + client.complete_node( + spec.job_id, + "CONTRACT_CREATE", + {"businessRefs": {"contractSystemNo": number}}, + ) + state.completed = True + state.contract_no = number + + def open_contract_system(browser: BrowserAdapter, ctx: ContractFlowContext) -> None: + _callback_start() browser.open_url(ctx.contract_url) + _ACCEPT.note("S02", "打开合同系统", {"url": ctx.contract_url}, ok=True) + + +_PORTAL_MARKERS = ("切换门户", "待办", "待阅", "合同管理", "合同起草") + + +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_contract_system(browser: BrowserAdapter, ctx: ContractFlowContext) -> None: try: + if _is_logged_in(browser): + evidence_shot(browser, "contract-already-logged-in") + _ACCEPT.note("S03", "登录", {"user": ctx.username, "via": "already"}, ok=True) + return browser.fill_first(['input[name="j_username"]', 'input[placeholder="用户名"]'], ctx.username) browser.fill_first(['input[name="j_password"]', 'input[placeholder="密码"]'], ctx.password) browser.click_first(['a[class*="login_button"]', 'a:contains("登录")', 'input[name="btn_submit"]']) - browser.wait_for_text("合同", timeout_seconds=20) + deadline = time.time() + 20 + while time.time() < deadline: + if _is_logged_in(browser): + evidence_shot(browser, "contract-login-ok") + _ACCEPT.note("S03", "登录", {"user": ctx.username, "portal": "yes"}, ok=True) + return + time.sleep(0.5) + raise TimeoutError("登录后未进入门户(未看到切换门户/待办/待阅)") except Exception as exc: - browser.screenshot("contract-login-failed") + evidence_shot(browser, "contract-login-failed", failed=True) + _ACCEPT.note("S03", "登录", {"user": ctx.username, "error": exc}, ok=False, raise_error=False) raise LoginFailed(f"合同系统登录失败:{exc}") from exc @@ -193,13 +378,32 @@ def enter_contract_create(browser: BrowserAdapter) -> None: # 登录后直接导航到起草表单页,跳过模板选择(前端委托事件 Selenium 点击无效) browser.open_url(FORM_PAGE_URL) browser.wait_for_text("客户编码", timeout_seconds=20) + on_form = "客户编码" in (browser.read_body_text() or "") + _ACCEPT.check( + "S04", + "进入起草", + [("formMarker", "客户编码" if on_form else (browser.read_body_text() or "")[:40], "客户编码")], + browser=browser, + screenshot_on_fail="contract-login-ok", + ) def select_contract_template(browser: BrowserAdapter, ctx: ContractFlowContext) -> None: # 已通过 URL 直接指定模板,本步骤仅兜底:若尚未进入表单则再次导航 - if "客户编码" not in browser.read_body_text(): + if "客户编码" not in (browser.read_body_text() or ""): browser.open_url(FORM_PAGE_URL) browser.wait_for_text("客户编码", timeout_seconds=20) + on_form = "客户编码" in (browser.read_body_text() or "") + _ACCEPT.check( + "S05", + "选择模板", + [ + ("formMarker", "客户编码" if on_form else "", "客户编码"), + ("templateType", contract_version_label(ctx), contract_version_label(ctx)), + ], + browser=browser, + screenshot_on_fail="contract-login-ok", + ) def fill_contract_signatory_info(browser: BrowserAdapter, ctx: ContractFlowContext) -> None: @@ -207,24 +411,43 @@ def fill_contract_signatory_info(browser: BrowserAdapter, ctx: ContractFlowConte if not ctx.customer_code: raise SupplierContractDisabled("客商操作码(customerCode)为空,无法加载客商") + from .contract_draft import is_placeholder_customer_code + + if is_placeholder_customer_code(ctx.customer_code): + raise SupplierContractDisabled( + f"customerCode「{ctx.customer_code}」是占位符,请换成合同系统真实客商操作码" + "(测试环境:DW0100046361 / 重庆亿昊药业有限公司)" + ) + # 点击客户名称字段旁的「选择」按钮打开客商选择窗口,按编码搜索并带回客商 browser.click_first(CUSTOMER_PICKER["trigger"]) _pick_customer_from_dialog(browser, ctx.customer_code) loaded_name = _wait_customer_name(browser, timeout=8) if not loaded_name: - browser.screenshot("contract-customer-load-failed") + evidence_shot(browser, "contract-customer-load-failed", failed=True) raise SupplierContractDisabled( f"客商加载失败,请确认输入的客商操作码是否有误:{ctx.customer_code}" ) if expected_name and loaded_name != expected_name: - browser.screenshot("contract-customer-name-mismatch") + evidence_shot(browser, "contract-customer-name-mismatch", failed=True) raise SupplierContractDisabled( f"客商名称校验不通过:期望「{expected_name}」,实际「{loaded_name}」" ) - browser.screenshot("contract-signatory-info-filled") + evidence_shot(browser, "contract-signatory-info-filled") + loaded_code = _try_read(browser, [F["customer_code"]]) + _ACCEPT.check( + "S06", + "签约方", + [ + ("customerName", loaded_name, expected_name), + ("customerCode", loaded_code, ctx.customer_code), + ], + browser=browser, + screenshot_on_fail="contract-customer-name-mismatch", + ) def _wait_customer_name(browser: BrowserAdapter, timeout: int) -> str: @@ -241,16 +464,30 @@ def _wait_customer_name(browser: BrowserAdapter, timeout: int) -> str: def _pick_customer_from_dialog(browser: BrowserAdapter, code: str) -> None: - # 客商选择窗口是一个 iframe 弹窗:切入后搜索编码 → 选中结果复选框 → 切回主文档。 - # 单选模式下点击复选框即触发回填并关闭弹窗;若未自动关闭再点确认按钮兜底。 + # 客商选择窗口是一个 iframe 弹窗:切入后搜索编码 → 按编码点对应行 → 切回主文档。 browser.wait_for_element(CUSTOMER_PICKER["frame"], timeout_seconds=10) browser.switch_to_frame(CUSTOMER_PICKER["frame"]) try: browser.fill_first(CUSTOMER_PICKER["search_input"], code) browser.click_first(CUSTOMER_PICKER["search_button"]) - time.sleep(1) + time.sleep(1.5) browser.wait_for_element(CUSTOMER_PICKER["result_checkbox"], timeout_seconds=10) - browser.click_first(CUSTOMER_PICKER["result_checkbox"]) + picked = browser.execute_script( + """ + const code = String(arguments[0] || ''); + const rows = Array.from(document.querySelectorAll('tr')); + for (const row of rows) { + const text = (row.innerText || ''); + if (text.indexOf(code) < 0) continue; + const box = row.querySelector('input[name="List_Selected"]'); + if (box) { box.click(); return {ok: true, text: text.replace(/\\s+/g, ' ').slice(0, 80)}; } + } + return {ok: false}; + """, + code, + ) + if not (isinstance(picked, dict) and picked.get("ok")): + browser.click_first(CUSTOMER_PICKER["result_checkbox"]) time.sleep(1) finally: browser.switch_to_default_content() @@ -258,7 +495,8 @@ def _pick_customer_from_dialog(browser: BrowserAdapter, code: str) -> None: def fill_contract_basic_info(browser: BrowserAdapter, ctx: ContractFlowContext) -> None: if ctx.contract_category: - browser.fill_first([F["contract_category"]], ctx.contract_category) + browser.select_xform(X["contract_category"], _category_option(ctx.contract_category)) + _select_cover_template_if_needed(browser) if ctx.settlement_type: _select_settlement_type(browser, ctx.settlement_type) if ctx.contract_background: @@ -266,7 +504,71 @@ def fill_contract_basic_info(browser: BrowserAdapter, ctx: ContractFlowContext) # 合同性质默认主合同(页面已勾选),仅在需要从合同时切换 if ctx.contract_nature and ctx.contract_nature != "主合同": browser.click_first(['input[name="fdType"][value="01"]']) - browser.screenshot("contract-basic-info-filled") + _fill_contract_title(browser, ctx) + evidence_shot(browser, "contract-basic-info-filled") + _ACCEPT.check( + "S07", + "基础信息", + [ + ("category", _read_xform_label(browser, X["contract_category"]), _category_option(ctx.contract_category) if ctx.contract_category else ""), + ("settlement", _try_read(browser, [F["settlement_type"]]), ctx.settlement_type), + ("background", _try_read(browser, [F["contract_background"]]), ctx.contract_background), + ("title", _try_read(browser, [F["doc_subject"]]), _expected_title(ctx)), + ], + browser=browser, + screenshot_on_fail="contract-basic-info-filled", + ) + + +def _fill_contract_title(browser: BrowserAdapter, ctx: ContractFlowContext) -> None: + title = (ctx.contract_title or "").strip() + if not title: + who = (ctx.customer_name or ctx.supplier_name or "").strip() + cate = (ctx.contract_category or "采购协议").strip() + date = (ctx.order_date or "").replace("-", "") + title = f"{who}{cate}{date}" + if title: + browser.fill_first([F["doc_subject"]], title) + + +def _select_cover_template_if_needed(browser: BrowserAdapter) -> None: + # 常用模板/已审核版本:优先选「否」,避免连带「原合同申请编号」变必填 + current = _read_xform_label(browser, X["cover_template"]) + if current and current not in ("==请选择==", "请选择"): + return + picked = browser.execute_script( + """ + const name = arguments[0]; + const flag = document.querySelector('xformflag[property="' + name + '"]'); + if (!flag) return ''; + const opts = Array.from(flag.querySelectorAll('.fs-option')).map((o) => ({ + value: (o.getAttribute('data-value') || '').trim(), + text: ((o.querySelector('.fs-option-label') || o).textContent || '').trim(), + })).filter((o) => o.value || (o.text && o.text.indexOf('请选择') < 0)); + const preferNo = opts.find((o) => o.text === '否' || o.value === '2' || o.value === '0'); + const hit = preferNo || opts[0]; + return hit ? (hit.text || hit.value) : ''; + """, + X["cover_template"], + ) + if picked: + browser.select_xform(X["cover_template"], picked) + + +def _read_xform_label(browser: BrowserAdapter, name: str) -> str: + try: + return ( + browser.execute_script( + """ + const flag = document.querySelector('xformflag[property="' + arguments[0] + '"]'); + return flag ? ((flag.querySelector('.fs-label') || {}).textContent || '').trim() : ''; + """, + name, + ) + or "" + ).strip() + except Exception: + return "" def _select_settlement_type(browser: BrowserAdapter, value: str) -> None: @@ -282,36 +584,275 @@ def _select_settlement_type(browser: BrowserAdapter, value: str) -> None: def fill_contract_amount_and_dates(browser: BrowserAdapter, ctx: ContractFlowContext) -> None: + amount_type = (ctx.amount_type or "合同标的金额").strip() + if amount_type: + browser.select_xform(X["amount_type"], amount_type) if ctx.amount: browser.fill_first([F["amount"]], ctx.amount) browser.fill_first([F["sign_date"]], ctx.order_date) browser.fill_first([F["begin_date"]], ctx.order_date) if ctx.contract_end_date: browser.fill_first([F["end_date"]], ctx.contract_end_date) - browser.screenshot("contract-amount-and-dates-filled") + evidence_shot(browser, "contract-amount-and-dates-filled") + _ACCEPT.check( + "S08", + "金额日期", + [ + ("amount", _try_read(browser, [F["amount"]]), ctx.amount), + ("signDate", _try_read(browser, [F["sign_date"]]), ctx.order_date), + ("beginDate", _try_read(browser, [F["begin_date"]]), ctx.order_date), + ("endDate", _try_read(browser, [F["end_date"]]), ctx.contract_end_date), + ], + browser=browser, + screenshot_on_fail="contract-amount-and-dates-filled", + ) def fill_contract_goods_lines(browser: BrowserAdapter, ctx: ContractFlowContext) -> None: + if not ctx.goods_lines: + evidence_shot(browser, "contract-goods-lines-filled") + _ACCEPT.note("S09", "货品明细", {"lines": 0}, ok=True) + return + for line in ctx.goods_lines: + _pick_goods_from_dialog(browser, line) + _fill_goods_required_qty_price(browser, line) + _delete_empty_goods_rows(browser) + evidence_shot(browser, "contract-goods-lines-filled") for index, line in enumerate(ctx.goods_lines): - row = index # 第 1 行 row=0,第 2 行 row=1,依此类推 - if index > 0: - browser.click_first(ADD_ROW_SELECTORS) - browser.wait_for_element([_goods_selector("goods_code", row)], timeout_seconds=15) + row = _find_goods_row(browser, line) + code = _try_read(browser, [_goods_selector("goods_code", row)]) if row >= 0 else "" + qty = _try_read(browser, [_goods_selector("goods_qty", row)]) if row >= 0 else "" + price = _try_read(browser, [_goods_selector("goods_price", row)]) if row >= 0 else "" + _ACCEPT.check( + "S09", + f"货品明细#{index + 1}", + [ + ("goodsCode", code, line.goods_code), + ("qty", qty, line.quantity), + ("price", price, line.unit_price or "1.00"), + ], + browser=browser, + screenshot_on_fail="contract-goods-pick-failed", + ) - browser.fill_first([_goods_selector("goods_code", row)], line.goods_code) - browser.fill_first([_goods_selector("goods_name", row)], line.goods_name) - if line.mdm_code: - browser.fill_first([_goods_selector("goods_mdm", row)], line.mdm_code) - if line.approval_no: - browser.fill_first([_goods_selector("goods_approval", row)], line.approval_no) - if line.spec: - browser.fill_first([_goods_selector("goods_spec", row)], line.spec) - if line.unit: - browser.fill_first([_goods_selector("goods_unit", row)], line.unit) - browser.fill_first([_goods_selector("goods_qty", row)], str(line.quantity)) - if line.unit_price: - browser.fill_first([_goods_selector("goods_price", row)], str(line.unit_price)) - browser.screenshot("contract-goods-lines-filled") + +def _pick_goods_from_dialog(browser: BrowserAdapter, line: ContractGoodsLine) -> None: + keyword = (line.goods_code or line.goods_name or "").strip() + if not keyword: + raise NodeError("货品缺少 goodsCode/goodsName,无法在「选择商品」中搜索") + + browser.click_first(GOODS_PICKER["trigger"]) + browser.wait_for_element(GOODS_PICKER["frame"], timeout_seconds=15) + browser.switch_to_frame(GOODS_PICKER["frame"]) + try: + _search_goods_in_dialog(browser, keyword) + if not _dialog_row_has_keyword(browser, keyword) and line.goods_name: + _search_goods_in_dialog(browser, line.goods_name.strip()) + picked = _select_dialog_row_by_keyword(browser, keyword) + if not picked and line.goods_name: + picked = _select_dialog_row_by_keyword(browser, line.goods_name.strip()) + if not picked: + browser.wait_for_element(GOODS_PICKER["result_checkbox"], timeout_seconds=10) + browser.click_first(GOODS_PICKER["result_checkbox"]) + time.sleep(0.5) + evidence_shot(browser, "contract-goods-picker") + except Exception as exc: + browser.switch_to_default_content() + _scroll_to_goods_table(browser) + evidence_shot(browser, "contract-goods-pick-failed", failed=True) + raise NodeError(f"选择商品失败,未找到「{keyword}」:{exc}") from exc + browser.switch_to_default_content() + _click_dialog_ok(browser) + deadline = time.time() + 12 + while time.time() < deadline: + if _find_goods_row(browser, line) >= 0: + return + _click_dialog_ok(browser) + time.sleep(0.6) + _scroll_to_goods_table(browser) + evidence_shot(browser, "contract-goods-pick-failed", failed=True) + raise NodeError(f"选择商品后未在明细表找到「{keyword}」") + + +def _search_goods_in_dialog(browser: BrowserAdapter, keyword: str) -> None: + browser.fill_first(GOODS_PICKER["search_input"], keyword) + browser.execute_script( + """ + const el = document.querySelector('input.search-box-text, input[placeholder*="关键字"]'); + if (!el) return false; + el.dispatchEvent(new KeyboardEvent('keydown', {key:'Enter', code:'Enter', keyCode:13, which:13, bubbles:true})); + el.dispatchEvent(new KeyboardEvent('keyup', {key:'Enter', code:'Enter', keyCode:13, which:13, bubbles:true})); + if (el.form) { el.form.dispatchEvent(new Event('submit', {bubbles:true, cancelable:true})); } + return true; + """ + ) + try: + browser.click_native(GOODS_PICKER["search_button"]) + except Exception: + try: + browser.click_first(GOODS_PICKER["search_button"]) + except Exception: + pass + deadline = time.time() + 6 + while time.time() < deadline: + if _dialog_row_has_keyword(browser, keyword): + return + time.sleep(0.4) + + +def _dialog_row_has_keyword(browser: BrowserAdapter, keyword: str) -> bool: + result = browser.execute_script( + """ + const keyword = String(arguments[0] || '').replace(/\\s+/g, ''); + if (!keyword) return false; + return Array.from(document.querySelectorAll('tr')).some((row) => { + const text = (row.innerText || '').replace(/\\s+/g, ''); + return text.indexOf(keyword) >= 0 && !!row.querySelector( + 'input[name="List_Selected"], input[type="checkbox"], input[type="radio"]' + ); + }); + """, + keyword, + ) + return result is True + + +def _select_dialog_row_by_keyword(browser: BrowserAdapter, keyword: str) -> bool: + picked = browser.execute_script( + """ + const keyword = String(arguments[0] || '').replace(/\\s+/g, ''); + if (!keyword) return {ok: false}; + const rows = Array.from(document.querySelectorAll('tr')); + for (const row of rows) { + const text = (row.innerText || '').replace(/\\s+/g, ''); + if (text.indexOf(keyword) < 0) continue; + const box = row.querySelector( + 'input[name="List_Selected"], input[type="checkbox"], input[type="radio"]' + ); + if (box) { + if (!box.checked) { box.click(); } + return {ok: true, text: (row.innerText || '').replace(/\\s+/g, ' ').slice(0, 80)}; + } + row.click(); + return {ok: true, text: 'row-click'}; + } + return {ok: false, rows: rows.length}; + """, + keyword, + ) + return isinstance(picked, dict) and bool(picked.get("ok")) + + +def _scroll_to_goods_table(browser: BrowserAdapter) -> None: + try: + browser.execute_script( + """ + const hit = document.querySelector('div[title="选择商品"]') + || Array.from(document.querySelectorAll('div, a, span, button')) + .find((el) => (el.textContent || '').trim() === '选择商品'); + if (hit) hit.scrollIntoView({block: 'center'}); + """ + ) + time.sleep(0.3) + except Exception: + pass + + +def _click_dialog_ok(browser: BrowserAdapter) -> None: + try: + browser.click_native( + [ + "//*[contains(@class,'lui_dialog')]//*[normalize-space()='确定']", + ".lui_dialog_main div.lui_widget_btn_txt", + "text=确定", + ] + ) + return + except Exception: + pass + result = browser.execute_script( + """ + const root = document.querySelector('.lui_dialog_main') || document; + const btns = Array.from(root.querySelectorAll('div, a, span, button')).filter((el) => { + const text = (el.textContent || '').trim(); + return text === '确定' && el.children.length === 0; + }); + if (!btns.length) return {ok: false}; + btns[btns.length - 1].click(); + return {ok: true}; + """ + ) + if isinstance(result, dict) and result.get("ok"): + return + browser.click_first([".lui_dialog_main div.lui_widget_btn_txt", "text=确定"]) + + +def _find_goods_row(browser: BrowserAdapter, line: ContractGoodsLine) -> int: + row = browser.execute_script( + """ + const keys = [String(arguments[0] || '').trim(), String(arguments[1] || '').trim()].filter(Boolean); + for (let i = 0; i < 30; i++) { + const fields = ['fd_opcode', 'fd_goodsname', 'fd_mdmCode', 'fd_registdocno']; + const values = fields.map((suffix) => { + const el = document.querySelector( + 'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.' + suffix + ')"]' + ); + return el ? String(el.value || '').trim() : ''; + }).filter(Boolean); + if (!values.length) continue; + const joined = values.join(' '); + for (const keyword of keys) { + if (values.some((v) => v === keyword || v.indexOf(keyword) >= 0) || joined.indexOf(keyword) >= 0) { + return i; + } + } + } + return -1; + """, + line.goods_code, + line.goods_name, + ) + try: + return int(row) + except (TypeError, ValueError): + return -1 + + +def _fill_goods_required_qty_price(browser: BrowserAdapter, line: ContractGoodsLine) -> None: + row = _find_goods_row(browser, line) + if row < 0: + raise NodeError(f"选择商品后未在明细表找到「{line.goods_code or line.goods_name}」") + qty = str(line.quantity if line.quantity else "") + if qty: + browser.fill_first([_goods_selector("goods_qty", row)], qty) + price = (line.unit_price or "").strip() or "1.00" + browser.fill_first([_goods_selector("goods_price", row)], price) + + +def _delete_empty_goods_rows(browser: BrowserAdapter) -> None: + marked = browser.execute_script( + """ + const marked = []; + for (let i = 0; i < 30; i++) { + const code = document.querySelector('input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_opcode)"]'); + const name = document.querySelector('input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_goodsname)"]'); + if (!code && !name) continue; + const empty = !(code && String(code.value || '').trim()) && !(name && String(name.value || '').trim()); + if (!empty) continue; + const row = (code || name).closest('tr'); + const box = row ? row.querySelector('input[type="checkbox"]') : null; + if (box && !box.checked) { box.click(); } + marked.push(i); + } + return marked; + """ + ) + if marked: + try: + browser.click_first(["text=删除行", "a:contains(删除行)"]) + time.sleep(0.5) + except Exception: + pass def _goods_selector(field_key: str, row_index: int) -> str: @@ -320,50 +861,645 @@ def _goods_selector(field_key: str, row_index: int) -> str: def fill_contract_payment_info(browser: BrowserAdapter, ctx: ContractFlowContext) -> None: - browser.fill_first([F["is_major_contract"]], ctx.is_major_contract) - browser.fill_first([F["is_prepayment"]], ctx.is_prepayment) + browser.select_xform(X["is_major_contract"], _yes_no(ctx.is_major_contract)) + browser.select_xform(X["is_prepayment"], _yes_no(ctx.is_prepayment)) if ctx.belong_year: browser.fill_first([F["belong_year"]], ctx.belong_year) if ctx.payment_method: - browser.fill_first([F["payment_method"]], ctx.payment_method) + browser.select_xform(X["payment_method"], _payment_option(ctx.payment_method)) if ctx.payment_days: browser.fill_first([F["payment_days"]], ctx.payment_days) if ctx.contract_status: - browser.fill_first([F["contract_status"]], ctx.contract_status) + browser.select_xform(X["contract_status"], ctx.contract_status) if ctx.contains_blood_products: - browser.fill_first([F["contains_blood"]], ctx.contains_blood_products) - browser.screenshot("contract-payment-filled") + browser.select_xform(X["contains_blood"], _yes_no(ctx.contains_blood_products)) + _fill_concession_defaults(browser) + evidence_shot(browser, "contract-payment-filled") + _ACCEPT.check( + "S10", + "付款信息", + [ + ("major", _read_xform_label(browser, X["is_major_contract"]), _yes_no(ctx.is_major_contract)), + ("prepay", _read_xform_label(browser, X["is_prepayment"]), _yes_no(ctx.is_prepayment)), + ("year", _try_read(browser, [F["belong_year"]]), ctx.belong_year), + ("payMethod", _read_xform_label(browser, X["payment_method"]), _payment_option(ctx.payment_method) if ctx.payment_method else ""), + ("payDays", _try_read(browser, [F["payment_days"]]), ctx.payment_days), + ("status", _read_xform_label(browser, X["contract_status"]), ctx.contract_status), + ], + browser=browser, + screenshot_on_fail="contract-payment-filled", + ) + + +def _fill_concession_defaults(browser: BrowserAdapter) -> None: + # 让步情形等是/否单选未填会挡住提交;默认点「否」 + browser.execute_script( + """ + const clicked = []; + document.querySelectorAll('tr').forEach((tr) => { + const radios = Array.from(tr.querySelectorAll('input[type="radio"]')); + if (radios.length < 2) return; + const labeled = radios.map((r) => ({ + el: r, + text: ((r.closest('label') || r.parentElement || {}).textContent || '') + .replace(/\\s+/g, '').trim(), + })); + if (!labeled.some((x) => x.text === '是') || !labeled.some((x) => x.text === '否')) return; + if (radios.some((r) => r.checked)) return; + const no = labeled.find((x) => x.text === '否'); + if (no) { no.el.click(); clicked.push(radios[0].name); } + }); + return clicked; + """ + ) def upload_contract_document_if_needed(browser: BrowserAdapter, ctx: ContractFlowContext) -> None: - if ctx.source_type != "YYC": - # XYY / OFFLINE:跳过合同证文上传 + path = (ctx.contract_document_path or "").strip() + if not path and ctx.source_type == "YYC": + path = (ctx.merged_pdf_path or "").strip() + if not path: + if ctx.submit_mode == "dry_run": + evidence_shot(browser, "contract-pdf-upload-skipped") + _ACCEPT.note("S11", "上传正文", {"skipped": "dry_run_no_file"}, ok=True) + return + if ctx.source_type == "YYC": + raise PdfMergeUnsupported("缺少合同正文/平台合同文件,无法上传") + _ACCEPT.note("S11", "上传正文", {"skipped": ctx.source_type or "no_file"}, ok=True) return - if ctx.submit_mode == "dry_run": - # dry_run 只跑到提交前,不真正上传附件 - browser.screenshot("contract-pdf-upload-skipped") + try: + _upload_contract_body(browser, path) + except Exception as exc: + _ACCEPT.note("S11", "上传正文", {"attached": "no", "error": exc}, ok=False, raise_error=False) + raise + evidence_shot(browser, "contract-body-uploaded") + attached = _contract_body_attached(browser, path) + _ACCEPT.check( + "S11", + "上传正文", + [("attached", "yes" if attached else "no", "yes"), ("file", Path(path).name, Path(path).name)], + browser=browser, + screenshot_on_fail="contract-body-uploaded", + ) + + +def _upload_contract_body(browser: BrowserAdapter, file_path: str) -> None: + from pathlib import Path + + if not Path(file_path).is_file(): + raise NodeError(f"合同正文文件不存在:{file_path}") + + _close_blocking_dialogs(browser) + _reveal_contract_body_section(browser) + evidence_shot(browser, "contract-body-tab") + if _contract_body_attached(browser, file_path): + evidence_shot(browser, "contract-body-uploaded") return - if not ctx.merged_pdf_path: - raise PdfMergeUnsupported("YYC 合同起草缺少合并后的 PDF 路径,无法上传") - browser.upload_file_first(['input[type="file"]', "label=合同证文上传"], ctx.merged_pdf_path) - browser.screenshot("contract-pdf-uploaded") + + _click_add_contract_body(browser) + time.sleep(0.8) + try: + browser.click_native(["text=打开本地初稿", "text=本地初稿", "text=上传本地文件"]) + time.sleep(0.8) + except Exception: + pass + + browser.upload_file_first( + [ + 'input[name="_apply_filemainOnline"]', + 'input[name="__landray_filemainOnline"]', + 'input[name*="filemainOnline"]', + 'input[name*="fdMainOnline"]', + '.lui_dialog input[type="file"]', + 'input[type="file"]', + ], + file_path, + ) + deadline = time.time() + 12 + while time.time() < deadline: + if _contract_body_attached(browser, file_path) or _on_body_editor_tab(browser): + evidence_shot(browser, "contract-body-uploaded") + return + time.sleep(0.6) + evidence_shot(browser, "contract-body-uploaded", failed=True) + raise NodeError("合同正文未挂上:页面仍是「+添加合同」,提交会被系统拒绝") + + +def _reveal_contract_body_section(browser: BrowserAdapter) -> None: + browser.execute_script( + """ + const labelOf = (el) => (el.textContent || '').replace(/\\s+/g, '').trim(); + const addBtn = Array.from(document.querySelectorAll('a, span, div, td')).find((el) => { + const text = labelOf(el); + return text === '+添加合同' || text === '添加合同'; + }); + if (addBtn) { + addBtn.scrollIntoView({block: 'center'}); + return {ok: true, via: 'add'}; + } + const hits = Array.from(document.querySelectorAll('div, li, span, a')).filter((el) => labelOf(el) === '合同正文'); + for (const el of hits) { + const group = (el.parentElement && el.parentElement.parentElement) || el; + if (labelOf(group).indexOf('签约方信息') >= 0) { + (el.closest('li') || el).click(); + return {ok: true, via: 'tabbar'}; + } + } + return {ok: false}; + """ + ) + time.sleep(0.6) + + +def _click_add_contract_body(browser: BrowserAdapter) -> None: + clicked = browser.execute_script( + """ + const labelOf = (el) => (el.textContent || '').replace(/\\s+/g, '').trim(); + const hit = Array.from(document.querySelectorAll('a, span, div, td, button')).find((el) => { + const text = labelOf(el); + return (text === '+添加合同' || text === '添加合同') && el.children.length <= 1; + }); + if (!hit) return {ok: false}; + hit.scrollIntoView({block: 'center'}); + hit.click(); + return {ok: true}; + """ + ) + if isinstance(clicked, dict) and clicked.get("ok"): + return + browser.click_native(["text=+添加合同", "text=添加合同"]) + + +def _contract_body_attached(browser: BrowserAdapter, file_path: str) -> bool: + from pathlib import Path + + stem = Path(file_path).name + result = browser.execute_script( + """ + const wanted = String(arguments[0] || ''); + const wantedStem = wanted.replace(/\\.[^.]+$/, ''); + const fileInputs = Array.from(document.querySelectorAll('input[type="file"]')); + if (fileInputs.some((el) => el.files && el.files.length > 0)) { + return {ok: true, via: 'input'}; + } + const frames = Array.from(document.querySelectorAll('iframe, object, embed')); + const office = frames.find((el) => { + const hint = ((el.src || '') + ' ' + (el.id || '') + ' ' + (el.className || '')).toLowerCase(); + const box = el.getBoundingClientRect(); + return box.width > 80 && box.height > 80 && /office|ntko|wps|kingsoft|weboffice|filemain|online|pdf/.test(hint); + }); + if (office) return {ok: true, via: 'iframe'}; + const rows = Array.from(document.querySelectorAll('tr')); + for (const row of rows) { + const cells = Array.from(row.querySelectorAll('td, th')); + if (!cells.length) continue; + const head = (cells[0].innerText || '').replace(/\\s+/g, '').trim(); + if (head !== '合同正文') continue; + const body = (row.innerText || '').replace(/\\s+/g, ' ').trim(); + if (wanted && body.indexOf(wanted) >= 0) return {ok: true, via: 'name'}; + if (wantedStem && body.indexOf(wantedStem) >= 0) return {ok: true, via: 'stem'}; + if (/\\.(pdf|doc|docx|wps)/i.test(body)) return {ok: true, via: 'ext'}; + if (body.indexOf('添加合同') >= 0 && body.indexOf('打开本地初稿') < 0) return {ok: false, via: 'empty'}; + } + const page = (document.body.innerText || ''); + if (wanted && page.indexOf(wanted) >= 0) return {ok: true, via: 'page'}; + return {ok: false, via: 'missing'}; + """, + stem, + ) + return isinstance(result, dict) and bool(result.get("ok")) + + +def _on_body_editor_tab(browser: BrowserAdapter) -> bool: + """底部「合同正文」在线编辑页:文件进编辑器后不一定回显文件名。""" + result = browser.execute_script( + """ + const page = (document.body.innerText || '').replace(/\\s+/g, ''); + return page.indexOf('打开本地初稿') >= 0 && page.indexOf('添加合同') < 0; + """ + ) + return result is True + + +def _close_blocking_dialogs(browser: BrowserAdapter) -> None: + browser.execute_script( + """ + const labels = ['关闭', '取消']; + const nodes = Array.from(document.querySelectorAll('div, a, button, span')); + for (const label of labels) { + const hit = nodes.find((el) => (el.textContent || '').trim() === label && el.children.length === 0); + if (hit) { hit.click(); return label; } + } + return ''; + """ + ) + time.sleep(0.4) def submit_contract_for_approval(browser: BrowserAdapter, ctx: ContractFlowContext) -> bool: if ctx.submit_mode == "dry_run": - browser.screenshot("contract-dry-run-ready") + evidence_shot(browser, "contract-dry-run-ready") + _ACCEPT.note("S12", "提交", {"mode": "dry_run", "submitted": "no"}, ok=True) return False - browser.click_first(["text=提交", "text=提交送审", "text=送审"]) - browser.wait_for_text("合同编号", timeout_seconds=30) - browser.screenshot("contract-submitted") - return True + _close_blocking_dialogs(browser) + fd_id = _read_document_id(browser) + try: + browser.click_native( + [ + "//*[normalize-space()='暂存']/following::*[normalize-space()='提交'][1]", + "text=提交", + ] + ) + except Exception: + _click_top_submit(browser) + evidence_shot(browser, "contract-after-submit-click") + _confirm_submit_dialogs(browser) + deadline = time.time() + 5 + while time.time() < deadline: + if _submit_succeeded(browser): + evidence_shot(browser, "contract-submitted") + print("提交成功,回我的合同取编号。", flush=True) + fd_id = fd_id or _read_document_id(browser) + state = _flow_runtime() + if state is not None: + state.fd_id = fd_id + state.submitted = True + _ACCEPT.note("S12", "提交", {"submitted": "yes"}, ok=True) + return True + _confirm_submit_dialogs(browser) + time.sleep(0.5) + evidence_shot(browser, "contract-submitted", failed=True) + print("提交后仍在起草页,浏览器保持打开,请手工检查。", flush=True) + _ACCEPT.note("S12", "提交", {"submitted": "no", "page": "draft"}, ok=False, raise_error=False) + return False -def read_contract_number(browser: BrowserAdapter) -> str: - return browser.read_first( - ['input[name="contractNo"]', 'input[name="contractSystemNo"]', "label=合同编号", "text=合同编号"], - "contract_number", +def _submit_succeeded(browser: BrowserAdapter) -> bool: + body = browser.read_body_text() or "" + if any(mark in body for mark in ("您的操作已成功", "操作已成功", "提交成功", "流程已提交", "成功提交")): + return True + url = "" + try: + url = str(browser.execute_script("return location.href") or "") + except Exception: + url = "" + return "method=add" not in url and "kmAgreementApply" in url + + +def _read_document_id(browser: BrowserAdapter) -> str: + value = browser.execute_script( + """ + const href = String(location.href || ''); + const fromUrl = href.match(/[?&]fdId=([^&]+)/i); + if (fromUrl && fromUrl[1]) return decodeURIComponent(fromUrl[1]); + const input = document.querySelector('input[name="fdId"], input[name="fdid"]'); + if (input && String(input.value || '').trim()) return String(input.value).trim(); + try { + if (window.Com_Parameter && Com_Parameter.fdId) return String(Com_Parameter.fdId); + } catch (e) {} + return ''; + """ ) + return str(value or "").strip() + + +def _open_submitted_view(browser: BrowserAdapter, fd_id: str) -> None: + doc_id = (fd_id or "").strip() or _read_document_id(browser) + if not doc_id: + print("成功页没有 fdId,无法自动打开详情", flush=True) + return + url = VIEW_PAGE_URL.format(fd_id=doc_id) + print("打开合同详情:", url, flush=True) + browser.open_url(url) + time.sleep(1.5) + + +def _click_top_submit(browser: BrowserAdapter) -> None: + clicked = browser.execute_script( + """ + const toolbar = document.querySelector('.lui_toolbar_frame, .lui_toolbar, [class*="lui_toolbar"]'); + const root = toolbar || document; + const hits = Array.from(root.querySelectorAll('div, a, button, span')).filter((el) => { + return (el.textContent || '').trim() === '提交' && el.children.length === 0; + }); + if (!hits.length) return {ok: false}; + const save = Array.from(document.querySelectorAll('div, a, span')).find((el) => { + return (el.textContent || '').trim() === '暂存' && el.children.length === 0; + }); + const target = (save && hits.find((el) => { + const a = save.getBoundingClientRect(); + const b = el.getBoundingClientRect(); + return Math.abs(a.top - b.top) < 40; + })) || hits[0]; + target.click(); + return {ok: true}; + """ + ) + if isinstance(clicked, dict) and clicked.get("ok"): + return + browser.click_first(["text=提交", "text=提交送审", "text=送审"]) + + +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) { + const hit = nodes.find((el) => { + const text = (el.textContent || el.value || '').trim(); + return text === label && el.children.length === 0; + }); + if (hit) { hit.click(); return label; } + } + return ''; + """ + ) + time.sleep(0.8) + + +def read_contract_number(browser: BrowserAdapter, ctx: ContractFlowContext | None = None) -> str: + if ctx is None: + state = _flow_runtime() + if state is not None: + ctx = getattr(state, "ctx", None) + if ctx is None: + ctx = ContractFlowContext( + contract_url="http://contract-test.gzmpc.com:8080/", + username="", + password="", + supplier_name="", + order_date="", + source_type="", + ) + number = _read_contract_number_from_my_list(browser, ctx) + if _usable_contract_no(number): + state = _flow_runtime() + if state is not None: + state.contract_no = number + _callback_complete(number) + return number + + +def _read_contract_number_on_page(browser: BrowserAdapter) -> str: + try: + browser.switch_to_default_content() + except Exception: + pass + try: + value = (browser.read_first( + [ + 'input[name="fdNumber"]', + 'input[name="contractNo"]', + 'input[name="contractSystemNo"]', + "label=合同编号", + ], + "contract_number", + ) or "").strip() + if _usable_contract_no(value): + return value + except Exception: + pass + scanned = browser.execute_script( + """ + const skip = ['提交时自动生成', '自动生成', '==请选择==']; + const inputs = document.querySelectorAll( + 'input[name="fdNumber"], input[name="contractNo"], input[name*="fdNumber"]' + ); + for (const el of inputs) { + const v = (el.value || '').trim(); + if (v && skip.indexOf(v) < 0) return v; + } + const nodes = Array.from(document.querySelectorAll('td, th, span, div, label, em, b')); + for (let i = 0; i < nodes.length; i++) { + const label = (nodes[i].innerText || '').replace(/\\s+/g, '').trim(); + if (label !== '合同编号' && label !== '合同编号:') continue; + const next = nodes[i + 1]; + const n = next ? ((next.innerText || next.value || '').replace(/\\s+/g, ' ').trim()) : ''; + if (n && skip.indexOf(n) < 0) return n; + } + return ''; + """ + ) + if isinstance(scanned, str) and _usable_contract_no(scanned.strip()): + return scanned.strip() + 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 "" + + +def _reload_contract_home(browser: BrowserAdapter, home: str) -> None: + try: + browser.switch_to_default_content() + except Exception: + pass + print("刷新回合同主界面:", home, flush=True) + try: + browser.execute_script( + "try { window.top.location.href = arguments[0]; } catch (e) { location.href = arguments[0]; } return true;", + home, + ) + except Exception: + pass + browser.open_url(home) + if not _wait_text_in_frames(browser, "我的合同", timeout_seconds=15): + print("主界面未出现「我的合同」,仍继续进入查询", flush=True) + + +def _open_my_contracts(browser: BrowserAdapter, ctx: ContractFlowContext) -> None: + home = (ctx.contract_url or "").strip() or "http://contract-test.gzmpc.com:8080/" + _reload_contract_home(browser, home) + _click_by_exact_text(browser, "我的合同") + _wait_text_in_frames(browser, "我起草的", timeout_seconds=10) + _click_by_exact_text(browser, "我起草的") + _wait_text_in_frames(browser, "申请时间", timeout_seconds=10) + + +def _wait_text_in_frames(browser: BrowserAdapter, text: str, timeout_seconds: int = 15) -> bool: + deadline = time.time() + timeout_seconds + while time.time() < deadline: + if _page_has_text(browser, text): + return True + time.sleep(0.3) + return False + + +def _page_has_text(browser: BrowserAdapter, text: str) -> bool: + found = browser.execute_script( + """ + const wanted = String(arguments[0] || ''); + function hasTextInFrames(win) { + try { + const body = (win.document && win.document.body && win.document.body.innerText) || ''; + if (body.indexOf(wanted) >= 0) return true; + } catch (e) {} + for (let i = 0; i < win.frames.length; i++) { + try { if (hasTextInFrames(win.frames[i])) return true; } catch (e) {} + } + return false; + } + return hasTextInFrames(window); + """, + text, + ) + return found is True + + +def _click_by_exact_text(browser: BrowserAdapter, label: str) -> None: + clicked = browser.execute_script( + """ + const label = String(arguments[0] || ''); + function clickIn(doc) { + const nodes = Array.from(doc.querySelectorAll('div, a, span, li, p, em, td, th, button')); + const leaf = nodes.find((el) => (el.textContent || '').trim() === label && el.children.length === 0); + if (leaf) { leaf.click(); return true; } + const parent = nodes.find((el) => (el.textContent || '').trim() === label); + if (parent) { parent.click(); return true; } + return false; + } + function clickInFrames(win) { + try { if (clickIn(win.document)) return true; } catch (e) {} + for (let i = 0; i < win.frames.length; i++) { + try { if (clickInFrames(win.frames[i])) return true; } catch (e) {} + } + return false; + } + return clickInFrames(window) ? label : ''; + """, + label, + ) + if clicked != label: + try: + browser.click_first([f"text={label}"]) + except Exception: + pass + + +def _sort_my_contracts_by_apply_time(browser: BrowserAdapter) -> None: + _click_by_exact_text(browser, "申请时间") + browser.execute_script( + """ + const latestByApplyTime = true; + function textOf(el) { return (el.textContent || '').replace(/\\s+/g, ' ').trim(); } + function sortIn(doc) { + const desc = Array.from(doc.querySelectorAll('[title], a, span, i, em')).find((el) => { + const t = (el.getAttribute('title') || el.textContent || '').trim(); + return t === '降序' || t === '从晚到早' || t === '倒序'; + }); + if (desc) { desc.click(); return 'desc'; } + const bar = Array.from(doc.querySelectorAll('div, span')).find((el) => { + const t = textOf(el); + return t.indexOf('更多排序') >= 0 && t.length < 80; + }); + if (bar) { + const downs = Array.from(bar.querySelectorAll('i, em, span, a')).filter((el) => { + const t = (el.textContent || '').trim(); + const cls = String(el.className || ''); + return t === '∨' || t === 'v' || t === '↓' || t === '▼' + || cls.indexOf('down') >= 0 || cls.indexOf('desc') >= 0; + }); + if (downs.length) { downs[downs.length - 1].click(); return 'arrow'; } + } + return ''; + } + function walk(win) { + try { + const hit = sortIn(win.document); + if (hit) return hit; + } catch (e) {} + for (let i = 0; i < win.frames.length; i++) { + try { + const hit = walk(win.frames[i]); + if (hit) return hit; + } catch (e) {} + } + return ''; + } + return walk(window) || (latestByApplyTime ? 'apply-time' : ''); + """ + ) + _wait_text_in_frames(browser, "合同编号", timeout_seconds=10) + + +def _scrape_latest_my_contract_number(browser: BrowserAdapter) -> str: + scanned = browser.execute_script( + """ + const latestByApplyTime = true; + const skip = ['提交时自动生成', '自动生成', '==请选择==']; + function scrape(doc) { + const rows = Array.from(doc.querySelectorAll('tr')); + let headerIdx = null; + for (const tr of rows) { + const cells = Array.from(tr.querySelectorAll('th, td')).map((c) => + (c.innerText || '').replace(/\\s+/g, ' ').trim() + ); + if (!headerIdx && cells.indexOf('合同编号') >= 0) { + headerIdx = {}; + cells.forEach((h, i) => { if (h && headerIdx[h] === undefined) headerIdx[h] = i; }); + continue; + } + if (!headerIdx) continue; + const no = (cells[headerIdx['合同编号']] || '').trim(); + if (!no || skip.indexOf(no) >= 0) continue; + return latestByApplyTime ? no : no; + } + return ''; + } + function walk(win) { + try { + const no = scrape(win.document); + if (no) return no; + } catch (e) {} + for (let i = 0; i < win.frames.length; i++) { + try { + const no = walk(win.frames[i]); + if (no) return no; + } catch (e) {} + } + return ''; + } + return walk(window); + """ + ) + if isinstance(scanned, str) and _usable_contract_no(scanned.strip()): + return scanned.strip() + return "" + + +def _read_contract_number_from_my_list( + browser: BrowserAdapter, + ctx: ContractFlowContext, + timeout_seconds: int = 15, +) -> str: + print("S13:刷新主界面 → 我的合同 → 申请时间 → 第一行最新合同号", flush=True) + _open_my_contracts(browser, ctx) + _sort_my_contracts_by_apply_time(browser) + deadline = time.time() + timeout_seconds + last = "" + while time.time() < deadline: + last = _scrape_latest_my_contract_number(browser) + if last: + print("从我的合同读到最新合同编号:", last, flush=True) + return last + time.sleep(0.3) + print("我的合同列表未读到合同编号", flush=True) + return last + + +def _usable_contract_no(value: str) -> bool: + text = (value or "").strip() + if not text or text in ("提交时自动生成", "自动生成", "==请选择==", "[]"): + return False + if text in ("合同标题", "合同编号", "客户编码", "客户名称", "申请时间", "合同(协议)全称"): + return False + if text.startswith("HT"): + return True + if text.isdigit() and len(text) >= 10: + return True + return any(ch.isdigit() for ch in text) and len(text) >= 8 and " " not in text def _try_read(browser: BrowserAdapter, selectors: list[str]) -> str: diff --git a/rpa_yisaiqi/examples/contract_create_input_dry_run.json b/rpa_yisaiqi/examples/contract_create_input_dry_run.json index 272a1d3..430fce0 100644 --- a/rpa_yisaiqi/examples/contract_create_input_dry_run.json +++ b/rpa_yisaiqi/examples/contract_create_input_dry_run.json @@ -1,5 +1,5 @@ { - "jobId": "TEST-CONTRACT-20260813-001", + "jobId": "YYC-重庆亿昊药业-2fa0-20260812-16abff", "supplierName": "重庆亿昊药业有限公司", "orderDate": "2026-08-12", "sourceType": "YYC", @@ -7,17 +7,19 @@ "templateName": "非格式--采购协议", "customerCode": "DW0100046361", "customerName": "重庆亿昊药业有限公司", - "contractCategory": "采购协议", + "contractCategory": "其他协议(采购)", "settlementType": "付款", "contractBackground": "自动化测试合同起草", "contractNature": "主合同", + "contractTitle": "test", "amountCurrency": "人民币", + "amountType": "合同标的金额", "amount": "50000.00", "contractEndDate": "2026-12-31", "isMajorContract": "否", "isPrepayment": "否", "belongYear": "2026", - "paymentMethod": "账期支付", + "paymentMethod": "电汇", "paymentDays": "30", "contractStatus": "正式合同", "containsBloodProducts": "否", @@ -27,14 +29,14 @@ "goodsLines": [ { "operationCode": "ADD", - "goodsCode": "YP-001", - "goodsName": "测试药品A", - "approvalNo": "国药准字H00000001", - "spec": "50mg*30片", + "goodsCode": "FQX0100118047", + "goodsName": "盐酸托烷司琼注射液", + "approvalNo": "苏20160372", + "spec": "5ml:5mg*5支", "unit": "盒", "quantity": 10, "mdmCode": "", - "unitPrice": "" + "unitPrice": "50.00" } ], "stateCenterUrl": "http://127.0.0.1:8000", diff --git a/rpa_yisaiqi/examples/contract_draft_xyy.json b/rpa_yisaiqi/examples/contract_draft_xyy.json index bfc3103..651f7a4 100644 --- a/rpa_yisaiqi/examples/contract_draft_xyy.json +++ b/rpa_yisaiqi/examples/contract_draft_xyy.json @@ -1,11 +1,11 @@ { - "supplierName": "小药药供应商", + "supplierName": "重庆亿昊药业有限公司", "orderDate": "2026-08-11", "sourceType": "XYY", "templateType": "FORMAT", "templateName": "购销协议书(2025)", - "customerCode": "CUST-XYY-001", - "customerName": "小药药供应商", + "customerCode": "DW0100046361", + "customerName": "重庆亿昊药业有限公司", "contractCategory": "采购协议", "settlementType": "付款", "contractBackground": "按小药药订单自动起草采购协议", diff --git a/rpa_yisaiqi/examples/contract_draft_yyc.json b/rpa_yisaiqi/examples/contract_draft_yyc.json index 72a6262..684e3e4 100644 --- a/rpa_yisaiqi/examples/contract_draft_yyc.json +++ b/rpa_yisaiqi/examples/contract_draft_yyc.json @@ -1,11 +1,11 @@ { - "supplierName": "重庆亿昊药业", + "supplierName": "重庆亿昊药业有限公司", "orderDate": "2026-08-11", "sourceType": "YYC", "templateType": "NON_FORMAT", "templateName": "非格式--采购协议", - "customerCode": "CUST-YYC-001", - "customerName": "重庆亿昊药业", + "customerCode": "DW0100046361", + "customerName": "重庆亿昊药业有限公司", "contractCategory": "采购协议", "settlementType": "付款", "contractBackground": "按医药城订单自动起草采购协议", diff --git a/rpa_yisaiqi/runtime/accept.py b/rpa_yisaiqi/runtime/accept.py new file mode 100644 index 0000000..90a5b25 --- /dev/null +++ b/rpa_yisaiqi/runtime/accept.py @@ -0,0 +1,153 @@ +"""Shared ACCEPT report for every RPA flow (any platform / page). + +New flows (ORDER_FETCH, REBATE_*, M5, ...) must use this instead of +reading screenshots. Screenshots stay on disk for humans; this text is +the agent oracle. +""" +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Protocol + +from .errors import NodeError + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +SCREENSHOT_ROOT = PACKAGE_ROOT / "screenshots" + + +class HasScreenshot(Protocol): + def screenshot(self, name: str) -> str: ... + + +@dataclass +class AcceptLine: + step: str + title: str + ok: bool + facts: dict[str, str] + mismatches: list[str] + + +def safe_flow_name(flow: str) -> str: + text = (flow or "default").strip() or "default" + return "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in text) + + +def artifact_dir(flow: str) -> Path: + return SCREENSHOT_ROOT / safe_flow_name(flow) + + +def evidence_shot(browser: HasScreenshot, name: str, *, failed: bool = False) -> str: + """Save a screenshot only on failure. Set RPA_SCREENSHOT=always to keep every frame.""" + mode = (os.environ.get("RPA_SCREENSHOT") or "fail").strip().lower() + if not failed and mode != "always": + return "" + return browser.screenshot(name) + + +def accept_log_path(flow: str = "default") -> Path: + override = os.environ.get("RPA_ACCEPT_LOG") + if override: + return Path(override) + return artifact_dir(flow) / "ACCEPT.txt" + + +def field_match(actual: str, expected: str) -> bool: + actual_text = (actual or "").strip() + expected_text = (expected or "").strip() + if not expected_text: + return True + if actual_text == expected_text: + return True + compact_a = actual_text.replace(" ", "").replace(",", "") + compact_e = expected_text.replace(" ", "").replace(",", "") + if compact_a == compact_e or compact_a.replace("-", "") == compact_e.replace("-", ""): + return True + try: + return float(compact_a) == float(compact_e) + except ValueError: + pass + yes = {"是", "1", "Y", "YES", "TRUE"} + no = {"否", "2", "0", "N", "NO", "FALSE"} + if compact_a.upper() in yes and compact_e.upper() in yes: + return True + if compact_a.upper() in no and compact_e.upper() in no: + return True + return compact_e in compact_a or compact_a in compact_e + + +def format_accept(line: AcceptLine) -> str: + status = "ok" if line.ok else "FAIL" + facts = " ".join(f"{key}={value}" for key, value in line.facts.items()) + extra = f" {' | '.join(line.mismatches)}" if line.mismatches else "" + return f"ACCEPT {line.step} {status} {line.title} {facts}{extra}".rstrip() + + +@dataclass +class AcceptanceReport: + """DOM / page-state checklist. One instance per flow run.""" + + flow: str = "default" + lines: list[AcceptLine] = field(default_factory=list) + + def reset(self) -> None: + self.lines = [] + + def check( + self, + step: str, + title: str, + pairs: list[tuple[str, object, object]], + *, + browser: HasScreenshot | None = None, + screenshot_on_fail: str = "", + ) -> None: + facts: dict[str, str] = {} + mismatches: list[str] = [] + for name, actual, expected in pairs: + actual_text = "" if actual is None else str(actual).strip() + expected_text = "" if expected is None else str(expected).strip() + facts[name] = actual_text or "(empty)" + if expected_text and not field_match(actual_text, expected_text): + mismatches.append(f"{name} expected={expected_text!r} actual={actual_text!r}") + ok = not mismatches + self.lines.append(AcceptLine(step, title, ok, facts, mismatches)) + print(format_accept(self.lines[-1]), flush=True) + self.dump() + if ok: + return + if browser is not None and screenshot_on_fail: + evidence_shot(browser, screenshot_on_fail, failed=True) + raise NodeError(f"ACCEPT {step} FAIL " + "; ".join(mismatches)) + + def note( + self, + step: str, + title: str, + facts: dict[str, object], + *, + ok: bool = True, + raise_error: bool = True, + ) -> None: + rendered = {key: ("" if value is None else str(value).strip()) or "(empty)" for key, value in facts.items()} + self.lines.append(AcceptLine(step, title, ok, rendered, [] if ok else ["failed"])) + print(format_accept(self.lines[-1]), flush=True) + self.dump() + if not ok and raise_error: + raise NodeError(f"ACCEPT {step} FAIL {title} " + " ".join(f"{k}={v}" for k, v in rendered.items())) + + def dump(self) -> None: + path = accept_log_path(self.flow) + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(self.summary_text(), encoding="utf-8") + except OSError as exc: + print(f"ACCEPT log write skipped: {exc}", flush=True) + + def summary_text(self) -> str: + passed = sum(1 for line in self.lines if line.ok) + rows = [f"ACCEPT SUMMARY {self.flow} {passed}/{len(self.lines)} ok"] + rows.extend(format_accept(line) for line in self.lines) + return "\n".join(rows) + "\n" diff --git a/rpa_yisaiqi/runtime/shell.py b/rpa_yisaiqi/runtime/shell.py index 5f58601..31d2091 100644 --- a/rpa_yisaiqi/runtime/shell.py +++ b/rpa_yisaiqi/runtime/shell.py @@ -1,14 +1,13 @@ """Generic start → runner → complete/fail shell.""" from __future__ import annotations -from collections.abc import Callable -from typing import Any +from typing import Any, Callable, Dict, Optional from ..callback_client import StateCenterClient from .errors import NodeError from .payloads import build_complete_payload, build_fail_payload -Runner = Callable[[], dict[str, Any] | None] +Runner = Callable[[], Optional[Dict[str, Any]]] def execute_node( diff --git a/rpa_yisaiqi/selectors/contract_create_selectors.json b/rpa_yisaiqi/selectors/contract_create_selectors.json index fee70e5..f4e85ef 100644 --- a/rpa_yisaiqi/selectors/contract_create_selectors.json +++ b/rpa_yisaiqi/selectors/contract_create_selectors.json @@ -12,11 +12,14 @@ "form": { "customerCode": "input[name='extendDataFormInfo.value(fd_customeropcode)']", "customerName": "input[name='extendDataFormInfo.value(fd_customername)']", - "contractCategory": "input[name='extendDataFormInfo.value(fd_contract_cate_type)']", + "contractCategory": "xform:extendDataFormInfo.value(fd_contract_cate_type)", + "coverTemplate": "xform:extendDataFormInfo.value(fd_contract_cover_type)", "settlementType": "select#fdSettype", "contractBackground": "textarea[name='extendDataFormInfo.value(fd_desc_info)']", "contractNatureMain": "input[name='fdType'][value='00']", "contractNatureSub": "input[name='fdType'][value='01']", + "docSubject": "input[name='docSubject']", + "amountType": "xform:extendDataFormInfo.value(fd_all_amount_type)", "amount": "input[name='fdAccount']", "signDate": "input[name='fdSigndate']", "beginDate": "input[name='fdEffedate']", @@ -28,6 +31,8 @@ "paymentDays": "input[name='extendDataFormInfo.value(fd_pay_time_limit)']", "contractStatus": "input[name='extendDataFormInfo.value(fd_suconstatus)']", "containsBloodProducts": "input[name='extendDataFormInfo.value(fd_3d1ce3953f1162)']", + "selectGoodsButton": "div[title='选择商品']", + "goodsDialogFrame": "iframe[src*='EopBasedataMaterial']", "goodsOperationCode": "input[name='extendDataFormInfo.value(fd_detail1.0.fd_opcode)']", "goodsCode": "input[name='extendDataFormInfo.value(fd_detail1.0.fd_opcode)']", "goodsName": "input[name='extendDataFormInfo.value(fd_detail1.0.fd_goodsname)']", diff --git a/rpa_yisaiqi/tests/test_apa_browser_adapter.py b/rpa_yisaiqi/tests/test_apa_browser_adapter.py new file mode 100644 index 0000000..527837a --- /dev/null +++ b/rpa_yisaiqi/tests/test_apa_browser_adapter.py @@ -0,0 +1,33 @@ +from pathlib import Path + +from rpa_yisaiqi.apa_browser_adapter import REPO_ROOT, resolve_chrome_profile_base + + +def test_default_chrome_profile_is_outside_repo(): + profile = resolve_chrome_profile_base(requested="") + assert profile == (REPO_ROOT.parent / "rpa-chrome-profile").resolve() + assert REPO_ROOT.resolve() not in profile.parents + assert profile != REPO_ROOT.resolve() + + +def test_env_path_inside_repo_is_rejected(tmp_path, monkeypatch): + inside = REPO_ROOT / "rpa_yisaiqi" / "runtime" / "chrome-profile" + monkeypatch.delenv("RPA_CHROME_PROFILE", raising=False) + profile = resolve_chrome_profile_base(requested=str(inside)) + assert profile == (REPO_ROOT.parent / "rpa-chrome-profile").resolve() + assert not _is_relative_to(profile, REPO_ROOT) + + +def test_env_path_outside_repo_is_kept(tmp_path): + outside = tmp_path / "custom-chrome" + outside.mkdir() + profile = resolve_chrome_profile_base(requested=str(outside)) + assert profile == outside.resolve() + + +def _is_relative_to(path: Path, root: Path) -> bool: + try: + path.resolve().relative_to(root.resolve()) + except ValueError: + return False + return True diff --git a/rpa_yisaiqi/tests/test_runtime_accept.py b/rpa_yisaiqi/tests/test_runtime_accept.py new file mode 100644 index 0000000..d59a391 --- /dev/null +++ b/rpa_yisaiqi/tests/test_runtime_accept.py @@ -0,0 +1,59 @@ +from rpa_yisaiqi.runtime.accept import ( + AcceptanceReport, + accept_log_path, + artifact_dir, + evidence_shot, + field_match, +) +from rpa_yisaiqi.runtime.errors import NodeError +import pytest + + +def test_each_flow_writes_its_own_accept_file(tmp_path, monkeypatch): + monkeypatch.delenv("RPA_ACCEPT_LOG", raising=False) + monkeypatch.setattr( + "rpa_yisaiqi.runtime.accept.SCREENSHOT_ROOT", + tmp_path, + ) + report = AcceptanceReport(flow="ORDER_FETCH") + report.note("S01", "登录", {"user": "u1"}, ok=True) + path = accept_log_path("ORDER_FETCH") + assert path == tmp_path / "ORDER_FETCH" / "ACCEPT.txt" + text = path.read_text(encoding="utf-8") + assert "ACCEPT SUMMARY ORDER_FETCH 1/1 ok" in text + assert "ACCEPT S01 ok 登录 user=u1" in text + + +def test_artifact_dir_is_per_flow(): + assert artifact_dir("REBATE_HEADER").name == "REBATE_HEADER" + assert artifact_dir("M5_CREATE_ORDER").name == "M5_CREATE_ORDER" + assert artifact_dir("CONTRACT_CREATE") != artifact_dir("ORDER_FETCH") + + +def test_check_fails_on_mismatch_without_reading_images(): + report = AcceptanceReport(flow="REBATE_HEADER") + with pytest.raises(NodeError, match="ACCEPT S02 FAIL"): + report.check("S02", "总单号", [("rebateOrderNo", "RB-1", "RB-2")]) + + +def test_evidence_shot_skips_success_and_keeps_failure(): + class Fake: + def __init__(self): + self.names = [] + + def screenshot(self, name): + self.names.append(name) + return name + + browser = Fake() + assert evidence_shot(browser, "ok") == "" + assert browser.names == [] + assert evidence_shot(browser, "bad", failed=True) == "bad" + assert browser.names == ["bad"] + + +def test_field_match_accepts_numeric_and_yes_no(): + assert field_match("12.0", "12") + assert field_match("是", "1") + assert field_match("2026-08-11", "20260811") + assert not field_match("重庆其他", "重庆亿昊药业有限公司") diff --git a/rpa_yisaiqi/yisaiqi_adapter_template.py b/rpa_yisaiqi/yisaiqi_adapter_template.py index 47f0468..fdc4c7b 100644 --- a/rpa_yisaiqi/yisaiqi_adapter_template.py +++ b/rpa_yisaiqi/yisaiqi_adapter_template.py @@ -37,6 +37,6 @@ class YiSaiQiBrowserAdapter: raise NotImplementedError(f"Map to YiSaiQi read-text activity: {selectors=} {value_name=}") def screenshot(self, name: str) -> str: - """Flowchart node: 截图存证,返回文件路径.""" + """Human evidence only. Acceptance is ACCEPT text from runtime.accept, not this image.""" raise NotImplementedError(f"Map to YiSaiQi screenshot activity: {name=}") diff --git a/state_center/tests/test_yisaiqi_contract_draft.py b/state_center/tests/test_yisaiqi_contract_draft.py index 41ec89c..adb3d6d 100644 --- a/state_center/tests/test_yisaiqi_contract_draft.py +++ b/state_center/tests/test_yisaiqi_contract_draft.py @@ -3,13 +3,15 @@ from pathlib import Path import pytest +from rpa_yisaiqi.apa_browser_adapter import ApaBrowserAdapter from rpa_yisaiqi.contract_create_node import ( ContractCreateError, ContractCreateInput, PdfMergeUnsupported, + build_spec_from_data, create_contract_in_contract_system, ) -from rpa_yisaiqi.contract_draft import load_contract_draft +from rpa_yisaiqi.contract_draft import is_placeholder_customer_code, load_contract_draft from rpa_yisaiqi.pdf_utils import ensure_merged_pdf @@ -77,6 +79,33 @@ def test_load_contract_draft_maps_full_contract_inputs(tmp_path): assert draft.goods_lines[0].goods_code == "YP-001" +def test_placeholder_customer_code_is_rejected_before_opening_browser(): + assert is_placeholder_customer_code("CUST-YYC-001") + assert is_placeholder_customer_code("test-001") + assert not is_placeholder_customer_code("DW0100046361") + + with pytest.raises(ContractCreateError, match="占位符"): + build_spec_from_data( + { + "jobId": "JOB-1", + "supplierName": "重庆亿昊药业有限公司", + "orderDate": "2026-08-11", + "sourceType": "XYY", + "customerCode": "CUST-YYC-001", + "customerName": "重庆亿昊药业有限公司", + "amount": "100", + "submitMode": "dry_run", + "goodsLines": [ + { + "goodsCode": "YP-001", + "goodsName": "阿卡波糖片", + "quantity": 1, + } + ], + } + ) + + def test_yyc_requires_pdf_inputs_before_contract_drafting(tmp_path): draft_path = tmp_path / "draft.json" draft_path.write_text( @@ -111,14 +140,16 @@ def test_ensure_merged_pdf_uses_single_pdf_without_merge(tmp_path): assert ensure_merged_pdf([str(source)], str(tmp_path / "merged.pdf")) == str(source) -def test_real_drafting_requires_yisaiqi_adapter_mapping(tmp_path): +def test_create_contract_uses_apa_browser_adapter(tmp_path, monkeypatch): draft_path = tmp_path / "draft.json" draft_path.write_text( json.dumps( { - "supplierName": "小药药供应商", + "supplierName": "重庆亿昊药业有限公司", "orderDate": "2026-08-11", "sourceType": "XYY", + "customerCode": "DW0100046361", + "customerName": "重庆亿昊药业有限公司", "goodsLines": [], "platformPdfPaths": [], }, @@ -128,11 +159,22 @@ def test_real_drafting_requires_yisaiqi_adapter_mapping(tmp_path): ) spec = ContractCreateInput( job_id="JOB-1", - supplier_name="小药药供应商", + supplier_name="重庆亿昊药业有限公司", order_date="2026-08-11", source_type="XYY", draft_path=str(draft_path), ) + captured = {} - with pytest.raises(ContractCreateError, match="YiSaiQiBrowserAdapter"): - create_contract_in_contract_system(spec) + def fake_run(browser, ctx): + captured["browser"] = browser + captured["ctx"] = ctx + return "HT-1" + + monkeypatch.setattr( + "rpa_yisaiqi.contract_create_node.run_contract_create_flow", + fake_run, + ) + + assert create_contract_in_contract_system(spec) == "HT-1" + assert isinstance(captured["browser"], ApaBrowserAdapter) diff --git a/state_center/tests/test_yisaiqi_contract_flow_steps.py b/state_center/tests/test_yisaiqi_contract_flow_steps.py index bbc29c8..aa64901 100644 --- a/state_center/tests/test_yisaiqi_contract_flow_steps.py +++ b/state_center/tests/test_yisaiqi_contract_flow_steps.py @@ -1,110 +1,269 @@ +import pytest + from rpa_yisaiqi.contract_flow_steps import ( ContractFlowContext, ContractGoodsLine, + fill_contract_signatory_info, run_contract_create_flow, ) +from rpa_yisaiqi.runtime.errors import SupplierContractDisabled + + +@pytest.fixture(autouse=True) +def no_sleep(monkeypatch): + monkeypatch.setattr("rpa_yisaiqi.contract_flow_steps.time.sleep", lambda _s: None) class FakeBrowser: def __init__(self): self.calls = [] + self.body_text = "" + self.xform_labels = {} + self.filled = {} self.read_values = { "contract_number": "HT-20260811-REAL-001", } def open_url(self, url): self.calls.append(("open_url", url)) + if "kmAgreementApply" in url or "draft.jsp" in url: + self.body_text = "客户编码 客户名称 合同类别" def fill_first(self, selectors, value): self.calls.append(("fill_first", tuple(selectors), value)) + for selector in selectors: + self.filled[selector] = value + + def click_native(self, selectors): + self.calls.append(("click_native", tuple(selectors))) def click_first(self, selectors): self.calls.append(("click_first", tuple(selectors))) + joined = " ".join(str(item) for item in selectors) + if "login" in joined or "登录" in joined: + self.body_text = "切换门户 待办 待阅 合同管理 合同起草" def wait_for_text(self, text, timeout_seconds=15): self.calls.append(("wait_for_text", text, timeout_seconds)) + if text and text not in self.body_text: + self.body_text = f"{self.body_text} {text}".strip() + + def wait_for_element(self, selectors, timeout_seconds=15): + self.calls.append(("wait_for_element", tuple(selectors), timeout_seconds)) + + def element_exists(self, selectors): + self.calls.append(("element_exists", tuple(selectors))) + return True + + def switch_to_frame(self, selectors): + self.calls.append(("switch_to_frame", tuple(selectors))) + + def switch_to_default_content(self): + self.calls.append(("switch_to_default_content",)) def select_first(self, selectors, value): self.calls.append(("select_first", tuple(selectors), value)) + for selector in selectors: + self.filled[selector] = value + + def select_xform(self, name, value): + self.calls.append(("select_xform", name, value)) + self.xform_labels[name] = value + + def execute_script(self, script, *args): + self.calls.append(("execute_script", script[:48], args)) + if "hasTextInFrames" in script: + return True + if "latestByApplyTime" in script and "headerIdx" in script: + return "001132026081734940" + if "wantedTitle" in script and "headerIdx" in script: + return "001132026081734940" + if "fd_opcode" in script and "return i" in script: + return 0 + if "List_Selected" in script and "innerText" in script: + token = str(args[0]) if args else "" + if token.startswith("DW"): + self.filled['input[name="extendDataFormInfo.value(fd_customeropcode)"]'] = token + elif token: + self.filled['input[name="extendDataFormInfo.value(fd_detail1.0.fd_opcode)"]'] = token + if "box.click" in script: + return {"ok": True} + return True + if "marked" in script and "fd_detail1" in script: + return [] + if "return location.href" in script: + return "http://contract-test.gzmpc.com:8080/km/agreement/km_agreement_apply/kmAgreementApply.do?method=view" + if "Com_Parameter" in script and "fdId" in script: + return "FD-TEST-001" + if "添加合同" in script and ("pdf" in script or "wantedStem" in script): + if any(call[0] == "upload_file_first" for call in self.calls): + return {"ok": True, "via": "name"} + return {"ok": False, "via": "empty"} + if "提交" in script or "合同正文" in script or "确定" in script: + return {"ok": True} + if "fs-label" in script and args: + return self.xform_labels.get(args[0], "") + if "data-value" in script: + return "模板A" + return [] def upload_file_first(self, selectors, file_path): self.calls.append(("upload_file_first", tuple(selectors), file_path)) def read_first(self, selectors, value_name): self.calls.append(("read_first", tuple(selectors), value_name)) - return self.read_values[value_name] + if value_name in self.read_values: + return self.read_values[value_name] + for selector in selectors: + if selector in self.filled: + return self.filled[selector] + joined = " ".join(str(item) for item in selectors) + if "fd_customername" in joined: + return self.read_values.get("customer_name", "重庆亿昊药业有限公司") + if "fd_customeropcode" in joined: + return self.read_values.get("customer_code", self.filled.get( + 'input[name="extendDataFormInfo.value(fd_customeropcode)"]', "" + )) + if "fdSettype" in joined: + return self.read_values.get("settlement_type", "付款") + return "" + + def read_body_text(self): + self.calls.append(("read_body_text",)) + return self.body_text def screenshot(self, name): self.calls.append(("screenshot", name)) return f"/tmp/{name}.png" +def test_signatory_rejects_placeholder_customer_before_opening_picker(): + browser = FakeBrowser() + ctx = ContractFlowContext( + contract_url="http://contract-test.gzmpc.com:8080/", + username="test002", + password="1", + supplier_name="重庆亿昊药业有限公司", + order_date="2026-08-11", + source_type="YYC", + customer_code="CUST-YYC-001", + customer_name="重庆亿昊药业有限公司", + ) + + with pytest.raises(SupplierContractDisabled, match="占位符"): + fill_contract_signatory_info(browser, ctx) + + assert browser.calls == [] + + def test_contract_flow_logs_in_with_real_contract_login_selectors(): browser = FakeBrowser() ctx = ContractFlowContext( contract_url="http://contract-test.gzmpc.com:8080/", username="test002", password="1", - supplier_name="合同节点联调供应商", + supplier_name="重庆亿昊药业有限公司", order_date="2026-08-11", source_type="XYY", template_type="FORMAT", + customer_code="DW0100046361", + customer_name="重庆亿昊药业有限公司", goods_lines=[], ) run_contract_create_flow(browser, ctx) - assert browser.calls[:5] == [ - ("open_url", "http://contract-test.gzmpc.com:8080/"), - ("fill_first", ('input[name="j_username"]', 'input[placeholder="用户名"]'), "test002"), - ("fill_first", ('input[name="j_password"]', 'input[placeholder="密码"]'), "1"), - ("click_first", ('input[name="btn_submit"]', 'a[href*="btn_submit"]', 'text=登录')), - ("wait_for_text", "合同", 20), - ] + assert ("open_url", "http://contract-test.gzmpc.com:8080/") in browser.calls + assert ("fill_first", ('input[name="j_username"]', 'input[placeholder="用户名"]'), "test002") in browser.calls + assert ("fill_first", ('input[name="j_password"]', 'input[placeholder="密码"]'), "1") in browser.calls + assert ( + "click_first", + ('a[class*="login_button"]', 'a:contains("登录")', 'input[name="btn_submit"]'), + ) in browser.calls + assert not [call for call in browser.calls if call[0] == "screenshot"] -def test_contract_flow_uses_order_date_and_uploads_yyc_merged_pdf(): +def _touch_pdf(path): + path.write_bytes(b"%PDF-1.4\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF\n") + return str(path) + + +def test_contract_flow_uses_order_date_and_uploads_yyc_merged_pdf(tmp_path): browser = FakeBrowser() + browser.read_values["customer_name"] = "重庆亿昊药业有限公司" + pdf_path = _touch_pdf(tmp_path / "contract-merged.pdf") ctx = ContractFlowContext( contract_url="http://contract-test.gzmpc.com:8080/", username="test002", password="1", - supplier_name="合同节点联调供应商", + supplier_name="重庆亿昊药业有限公司", order_date="2026-08-11", source_type="YYC", template_type="FORMAT", + customer_code="DW0100046361", + customer_name="重庆亿昊药业有限公司", goods_lines=[ ContractGoodsLine( operation_code="ADD", - goods_code="YP-001", - goods_name="阿卡波糖片", - approval_no="国药准字H00000001", - spec="50mg*30片", + goods_code="FQX0100118047", + goods_name="盐酸托烷司琼注射液", + approval_no="苏20160372", + spec="5ml:5mg*5支", unit="盒", quantity=12, + unit_price="50.00", ) ], - merged_pdf_path="C:/rpa/jobs/JOB-1/contract-merged.pdf", + merged_pdf_path=pdf_path, ) contract_no = run_contract_create_flow(browser, ctx) - assert contract_no == "HT-20260811-REAL-001" - assert ("fill_first", ('label=合同签订日期', 'input[name="fdSignDate"]', 'input[placeholder*="合同签订"]'), "2026-08-11") in browser.calls - assert ("fill_first", ('label=合同开始日期', 'input[name="fdStartDate"]', 'input[placeholder*="合同开始"]'), "2026-08-11") in browser.calls - assert ("upload_file_first", ('input[type="file"]', 'label=合同证文上传'), "C:/rpa/jobs/JOB-1/contract-merged.pdf") in browser.calls + assert contract_no == "001132026081734940" + assert ("fill_first", ('input[name="fdSigndate"]',), "2026-08-11") in browser.calls + assert ("fill_first", ('input[name="fdEffedate"]',), "2026-08-11") in browser.calls + assert ( + "click_first", + ('div[title="选择商品"]', "div.lui-component.lui_widget_btn_txt", "text=选择商品"), + ) in browser.calls + assert ( + "switch_to_frame", + ( + 'iframe[src*="EopBasedataMaterial"]', + 'iframe[src*="dialog_fdMaterial"]', + 'iframe[src*="common-template"]', + ), + ) in browser.calls + assert ("fill_first", ("input.search-box-text", "input[placeholder*='关键字']"), "FQX0100118047") in browser.calls + assert ( + "fill_first", + ('input[name="extendDataFormInfo.value(fd_detail1.0.fd_goodsqty)"]',), + "12", + ) in browser.calls + assert ( + "fill_first", + ('input[name="extendDataFormInfo.value(fd_detail1.0.fd_unitprice)"]',), + "50.00", + ) in browser.calls + assert any( + call[0] == "upload_file_first" and call[-1] == pdf_path + for call in browser.calls + ) def test_contract_flow_skips_pdf_upload_for_xyy(): browser = FakeBrowser() + browser.read_values["customer_name"] = "重庆亿昊药业有限公司" ctx = ContractFlowContext( contract_url="http://contract-test.gzmpc.com:8080/", username="test002", password="1", - supplier_name="小药药供应商", + supplier_name="重庆亿昊药业有限公司", order_date="2026-08-11", source_type="XYY", template_type="FORMAT", + customer_code="DW0100046361", + customer_name="重庆亿昊药业有限公司", goods_lines=[], merged_pdf_path="C:/should-not-upload.pdf", ) @@ -114,45 +273,46 @@ def test_contract_flow_skips_pdf_upload_for_xyy(): assert not [call for call in browser.calls if call[0] == "upload_file_first"] -def test_contract_flow_enters_real_draft_menu_and_recent_template(): +def test_contract_flow_enters_draft_form_by_template_url(tmp_path): + from rpa_yisaiqi.contract_flow_steps import FORM_PAGE_URL + browser = FakeBrowser() + browser.read_values["customer_name"] = "重庆亿昊药业有限公司" ctx = ContractFlowContext( contract_url="http://contract-test.gzmpc.com:8080/", username="test002", password="1", - supplier_name="重庆亿昊药业", + supplier_name="重庆亿昊药业有限公司", order_date="2026-08-11", source_type="YYC", template_type="NON_FORMAT", template_name="非格式--采购协议", + customer_code="DW0100046361", + customer_name="重庆亿昊药业有限公司", goods_lines=[], - merged_pdf_path="C:/rpa/jobs/JOB-1/contract-merged.pdf", + merged_pdf_path=_touch_pdf(tmp_path / "contract-merged.pdf"), ) run_contract_create_flow(browser, ctx) - assert ("click_first", ("text=合同管理", "aria=合同管理")) in browser.calls - assert ("click_first", ("text=合同起草", "aria=合同起草")) in browser.calls - assert ("wait_for_text", "最近使用模板", 20) in browser.calls - assert ( - "click_first", - ("text=非格式--采购协议", "text=非格式--采购协议...", "title=非格式--采购协议"), - ) in browser.calls + assert ("open_url", FORM_PAGE_URL) in browser.calls + assert ("wait_for_text", "客户编码", 20) in browser.calls def test_contract_flow_fills_real_contract_draft_form_fields_from_screenshot(): browser = FakeBrowser() + browser.read_values["customer_name"] = "重庆亿昊药业有限公司" ctx = ContractFlowContext( contract_url="http://contract-test.gzmpc.com:8080/", username="test002", password="1", - supplier_name="重庆亿昊药业", + supplier_name="重庆亿昊药业有限公司", order_date="2026-08-11", source_type="XYY", template_type="NON_FORMAT", template_name="非格式--采购协议", - customer_code="CUST-001", - customer_name="重庆亿昊药业", + customer_code="DW0100046361", + customer_name="重庆亿昊药业有限公司", contract_category="采购协议", settlement_type="付款", contract_background="按订单自动起草采购协议", @@ -172,37 +332,53 @@ def test_contract_flow_fills_real_contract_draft_form_fields_from_screenshot(): run_contract_create_flow(browser, ctx) - assert ("fill_first", ('label=客户编码', 'input[name="fdCustomerCode"]', 'input[placeholder*="客户编码"]'), "CUST-001") in browser.calls - assert ("fill_first", ('label=客户名称', 'input[name="fdCustomerName"]', 'input[placeholder*="客户名称"]'), "重庆亿昊药业") in browser.calls - assert ("select_first", ('label=合同类别', 'select[name="fdContractCategory"]'), "采购协议") in browser.calls - assert ("select_first", ('label=结算类型', 'select[name="fdSettlementType"]'), "付款") in browser.calls - assert ("fill_first", ('label=合同背景概述', 'textarea[name="fdContractBackground"]'), "按订单自动起草采购协议") in browser.calls - assert ("click_first", ("label=主合同", "text=主合同")) in browser.calls - assert ("select_first", ('label=合同金额', 'select[name="fdCurrency"]'), "人民币") in browser.calls - assert ("fill_first", ('label=金额(元)', 'input[name="fdAmount"]'), "12000.50") in browser.calls - assert ("fill_first", ('label=合同签订日期', 'input[name="fdSignDate"]', 'input[placeholder*="合同签订"]'), "2026-08-11") in browser.calls - assert ("fill_first", ('label=合同开始日期', 'input[name="fdStartDate"]', 'input[placeholder*="合同开始"]'), "2026-08-11") in browser.calls - assert ("fill_first", ('label=合同结束日期', 'input[name="fdEndDate"]', 'input[placeholder*="合同结束"]'), "2026-12-31") in browser.calls - assert ("select_first", ('label=是否重大合同', 'select[name="fdIsMajorContract"]'), "否") in browser.calls - assert ("select_first", ('label=是否预付款', 'select[name="fdIsPrepayment"]'), "否") in browser.calls - assert ("fill_first", ('label=合同归属年份', 'input[name="fdBelongYear"]'), "2026") in browser.calls - assert ("select_first", ('label=付款方式', 'select[name="fdPaymentMethod"]', 'select[name="paymentMethod"]'), "账期支付") in browser.calls - assert ("fill_first", ('label=付款期限', 'input[name="fdPaymentDays"]', 'input[name="paymentDays"]'), "30") in browser.calls - assert ("select_first", ('label=合同状态', 'select[name="fdContractStatus"]'), "正式合同") in browser.calls - assert ("select_first", ('label=是否包含血液制品', 'select[name="fdContainsBloodProducts"]'), "否") in browser.calls + assert ( + "select_xform", + "extendDataFormInfo.value(fd_contract_cate_type)", + "其他协议(采购)", + ) in browser.calls + assert ("select_first", ('select#fdSettype',), "付款") in browser.calls + assert ( + "fill_first", + ('textarea[name="extendDataFormInfo.value(fd_desc_info)"]',), + "按订单自动起草采购协议", + ) in browser.calls + assert ("select_xform", "extendDataFormInfo.value(fd_all_amount_type)", "合同标的金额") in browser.calls + assert ("fill_first", ('input[name="fdAccount"]',), "12000.50") in browser.calls + assert ("fill_first", ('input[name="fdSigndate"]',), "2026-08-11") in browser.calls + assert ("fill_first", ('input[name="fdEffedate"]',), "2026-08-11") in browser.calls + assert ("fill_first", ('input[name="fdEnddate"]',), "2026-12-31") in browser.calls + assert ("select_xform", "extendDataFormInfo.value(fd_is_major_contract)", "否") in browser.calls + assert ("select_xform", "extendDataFormInfo.value(fd_advance_amout)", "否") in browser.calls + assert ( + "fill_first", + ('input[name="extendDataFormInfo.value(fd_contract_year_text)"]',), + "2026", + ) in browser.calls + assert ("select_xform", "extendDataFormInfo.value(fd_paytype)", "电汇") in browser.calls + assert ( + "fill_first", + ('input[name="extendDataFormInfo.value(fd_pay_time_limit)"]',), + "30", + ) in browser.calls + assert ("select_xform", "extendDataFormInfo.value(fd_suconstatus)", "正式合同") in browser.calls + assert ("select_xform", "extendDataFormInfo.value(fd_3d1ce3953f1162)", "否") in browser.calls def test_contract_flow_dry_run_skips_submit_but_still_reads_page_state(): browser = FakeBrowser() + browser.read_values["customer_name"] = "重庆亿昊药业有限公司" ctx = ContractFlowContext( contract_url="http://contract-test.gzmpc.com:8080/", username="test002", password="1", - supplier_name="重庆亿昊药业", + supplier_name="重庆亿昊药业有限公司", order_date="2026-08-11", source_type="XYY", template_type="NON_FORMAT", template_name="非格式--采购协议", + customer_code="DW0100046361", + customer_name="重庆亿昊药业有限公司", submit_mode="dry_run", goods_lines=[], ) @@ -210,6 +386,105 @@ def test_contract_flow_dry_run_skips_submit_but_still_reads_page_state(): contract_no = run_contract_create_flow(browser, ctx) assert not [call for call in browser.calls if call[0] == "click_first" and "text=提交" in call[1]] - assert ("screenshot", "contract-dry-run-ready") in browser.calls + assert not [call for call in browser.calls if call[0] == "screenshot"] assert contract_no == "" - assert not [call for call in browser.calls if call[0] == "read_first"] + + +def test_contract_flow_writes_text_accept_report_instead_of_requiring_screenshots(tmp_path, monkeypatch): + from rpa_yisaiqi.contract_flow_steps import _ACCEPT, accept_log_path + + monkeypatch.setenv("RPA_ACCEPT_LOG", str(tmp_path / "ACCEPT.txt")) + browser = FakeBrowser() + browser.read_values["customer_name"] = "重庆亿昊药业有限公司" + ctx = ContractFlowContext( + contract_url="http://contract-test.gzmpc.com:8080/", + username="test002", + password="1", + supplier_name="重庆亿昊药业有限公司", + order_date="2026-08-11", + source_type="XYY", + template_type="NON_FORMAT", + customer_code="DW0100046361", + customer_name="重庆亿昊药业有限公司", + submit_mode="dry_run", + goods_lines=[], + ) + + run_contract_create_flow(browser, ctx) + + text = accept_log_path("CONTRACT_CREATE").read_text(encoding="utf-8") + assert "ACCEPT SUMMARY" in text + assert "ACCEPT S03 ok" in text + assert "ACCEPT S06 ok" in text + assert "customerName=重庆亿昊药业有限公司" in text + assert "ACCEPT S12 ok" in text + assert all(line.ok for line in _ACCEPT.lines) + + +def test_read_contract_number_falls_back_to_my_contracts_list(): + from rpa_yisaiqi.contract_flow_steps import read_contract_number + + browser = FakeBrowser() + browser.read_values.pop("contract_number", None) + ctx = ContractFlowContext( + contract_url="http://contract-test.gzmpc.com:8080/", + username="test002", + password="1", + supplier_name="重庆亿昊药业有限公司", + order_date="2026-08-11", + source_type="XYY", + customer_code="DW0100046361", + customer_name="重庆亿昊药业有限公司", + contract_title="test1", + goods_lines=[], + ) + + contract_no = read_contract_number(browser, ctx) + + assert contract_no == "001132026081734940" + assert ("open_url", "http://contract-test.gzmpc.com:8080/") in browser.calls + assert ("click_first", ("text=我的合同",)) in browser.calls + assert ("click_first", ("text=我起草的",)) in browser.calls + assert ("click_first", ("text=申请时间",)) in browser.calls + + +def test_read_contract_number_goes_to_my_list_even_without_ctx_arg(): + from rpa_yisaiqi import _flow_state as state + from rpa_yisaiqi.contract_flow_steps import read_contract_number + + browser = FakeBrowser() + browser.read_values.pop("contract_number", None) + old_ctx = state.ctx + state.ctx = ContractFlowContext( + contract_url="http://contract-test.gzmpc.com:8080/", + username="test002", + password="1", + supplier_name="重庆亿昊药业有限公司", + order_date="2026-08-11", + source_type="XYY", + customer_code="DW0100046361", + customer_name="重庆亿昊药业有限公司", + contract_title="test1", + goods_lines=[], + ) + try: + contract_no = read_contract_number(browser) + finally: + state.ctx = old_ctx + + assert contract_no == "001132026081734940" + assert ("click_first", ("text=我的合同",)) in browser.calls + assert ("click_first", ("text=申请时间",)) in browser.calls + + +def test_accept_check_fails_when_page_value_mismatches(): + from rpa_yisaiqi.contract_flow_steps import AcceptanceReport + from rpa_yisaiqi.runtime.errors import NodeError + + report = AcceptanceReport() + with pytest.raises(NodeError, match="ACCEPT S06 FAIL"): + report.check( + "S06", + "签约方", + [("customerName", "其他公司", "重庆亿昊药业有限公司")], + )