# 合同页面共用:类型、选择器、工具栏、fdId、验收报告。 # # 各 S0x 步骤从这里取 BrowserAdapter / ContractFlowContext / _ACCEPT。 # 改字段选择器或工具栏点击,步骤文件一起变。 from __future__ import annotations import time from dataclasses import dataclass, field from typing import Protocol from accept import AcceptanceReport # 合同页面操作口,由 ApaBrowserAdapter 实现。 class BrowserAdapter(Protocol): # 打开指定 URL。 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: ... # 选择器列表里是否已有匹配元素。 def element_exists(self, selectors: list[str]) -> bool: ... # 切入第一个匹配到的 iframe。 def switch_to_frame(self, selectors: list[str]) -> None: ... # 切回主文档。 def switch_to_default_content(self) -> None: ... # 在第一个匹配到的 select 上选中指定值。 def select_first(self, selectors: list[str], value: str) -> None: ... # 按 xform property 名选中 fancy-select 选项。 def select_xform(self, name: str, value: str) -> None: ... # 在当前页跑一段脚本并返回结果。 def execute_script(self, script: str, *args): ... # 把本地文件塞进第一个匹配到的 file input。 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: ... # 按名字截一张图,返回保存路径。 def screenshot(self, name: str) -> str: ... # 有原生 alert 就点确定并返回文案。 def accept_alert(self) -> str: ... # 关掉当前页再打开指定 URL。 def reopen_url(self, url: str) -> None: ... # 合同货品明细一行,供选择商品弹窗和明细表回填。 @dataclass(frozen=True) class ContractGoodsLine: operation_code: str goods_code: str goods_name: str approval_no: str spec: str unit: str quantity: float mdm_code: str = "" unit_price: str = "" # 一次合同起草页的入参。账密、客商、货品、提交方式都在这里。 @dataclass(frozen=True) class ContractFlowContext: contract_url: str username: str password: str supplier_name: str order_date: str source_type: str template_type: str = "FORMAT" template_name: str = "" customer_code: str = "" customer_name: str = "" contract_category: str = "" settlement_type: str = "付款" contract_background: str = "" contract_nature: str = "主合同" amount_currency: str = "人民币" amount_type: str = "合同标的金额" amount: str = "" contract_title: str = "" contract_end_date: str = "" contract_sign_date: str = "" contract_start_date: str = "" is_major_contract: str = "否" is_prepayment: str = "否" belong_year: str = "" payment_method: str = "账期支付" payment_days: str = "30" contract_status: str = "正式合同" contains_blood_products: str = "否" 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") # 拼合同标题:有 contract_title 用它,否则客商名+类别+订单日。 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}" # 合同起草页(模板选择)直达 URL,绕过 SPA 菜单/iframe 复杂性 DRAFT_PAGE_URL = ( "http://contract-test.gzmpc.com:8080/km/agreement/km_agreement_apply/draft/" "draft.jsp?isSimpleCategory=false" "&mainModelName=com.landray.kmss.km.agreement.model.KmAgreementApply" "&modelName=com.landray.kmss.km.agreement.model.KmAgreementTmpl" ) # 非格式-采购协议(2025集团版)模板 ID(测试环境固定值) # 直接跳转表单页,绕过「点模板」这一依赖前端委托事件、Selenium 点击无效的步骤 TEMPLATE_ID = "198d10b04325279b8ffeb8b4c6493b5a" FORM_PAGE_URL = ( "http://contract-test.gzmpc.com:8080/km/agreement/km_agreement_apply/" "kmAgreementApply.do?method=add" f"&fdTemplateId={TEMPLATE_ID}" f"&.fdTemplate={TEMPLATE_ID}" f"&i.docTemplate={TEMPLATE_ID}" "&s_css=default" ) VIEW_PAGE_URL = ( "http://contract-test.gzmpc.com:8080/km/agreement/km_agreement_apply/" "kmAgreementApply.do?method=view&fdId={fd_id}" ) EDIT_PAGE_URL = ( "http://contract-test.gzmpc.com:8080/km/agreement/km_agreement_apply/" "kmAgreementApply.do?method=edit&fdId={fd_id}" ) # Landray/LUI 表单字段的真实 name F = { "customer_code": 'input[name="extendDataFormInfo.value(fd_customeropcode)"]', "customer_name": 'input[name="extendDataFormInfo.value(fd_customername)"]', "contract_category": 'input[name="extendDataFormInfo.value(fd_contract_cate_type)"]', "settlement_type": 'select#fdSettype', "contract_background": 'textarea[name="extendDataFormInfo.value(fd_desc_info)"]', "nature_main": 'input[name="fdType"][value="00"]', "amount": 'input[name="fdAccount"]', "sign_date": 'input[name="fdSigndate"]', "begin_date": 'input[name="fdEffedate"]', "end_date": 'input[name="fdEnddate"]', "is_major_contract": 'input[name="extendDataFormInfo.value(fd_is_major_contract)"]', "is_prepayment": 'input[name="extendDataFormInfo.value(fd_advance_amout)"]', "belong_year": 'input[name="extendDataFormInfo.value(fd_contract_year_text)"]', "payment_method": 'input[name="extendDataFormInfo.value(fd_paytype)"]', "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 = { "账期支付": "电汇", "账期": "电汇", "承兑": "银行承兑汇票", } # 把 1/2、Y/N 收成页面上的「是」「否」。 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", "goods_name": "fd_goodsname", "goods_mdm": "fd_mdmCode", "goods_approval": "fd_registdocno", "goods_spec": "fd_goodstype", "goods_unit": "fd_goodsunit", "goods_qty": "fd_goodsqty", "goods_price": "fd_unitprice", } ADD_ROW_SELECTORS = [ "text=添加行", "text=新增明细", "text=新增行", "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": [ 'div.inputselectsgl[onclick*="fdCustomerEdit"]', 'div[class*="inputselect"][onclick*="fdCustomer"]', ], "frame": [ 'iframe[src*="common-template"]', 'iframe[src*="KmRelativeMain"]', 'iframe[src*="relative"]', ], "search_input": ["input.search-box-text"], "search_button": ["input.search-box-btn"], "result_checkbox": ['input[name="List_Selected"]'], "confirm_button": ["div.btn.ok", "div.lui_toolbar_btn_def"], } _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) # 取签订日。接口 3 给了用它,否则回退 order_date。 def sign_date_for(ctx: ContractFlowContext) -> str: return (ctx.contract_sign_date or ctx.order_date or "").strip() # 取开始日。接口 3 给了用它,否则回退 order_date。 def begin_date_for(ctx: ContractFlowContext) -> str: return (ctx.contract_start_date or ctx.order_date or "").strip() # 关掉挡在表单前面的「关闭/取消」弹层。 def _close_blocking_dialogs(browser: BrowserAdapter) -> None: browser.execute_script( """ const labels = ['关闭', '取消']; function clickIn(win) { const nodes = Array.from(win.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; } } for (let i = 0; i < win.frames.length; i++) { try { const hit = clickIn(win.frames[i]); if (hit) return hit; } catch (e) {} } return ''; } return clickIn(window); """ ) time.sleep(0.4) # 读当前页 URL,跨 iframe 时尽量取 top.location。 def _current_url(browser: BrowserAdapter) -> str: try: return str( browser.execute_script( "try { return (window.top.location && window.top.location.href) || location.href; }" " catch (e) { return location.href; }" ) or "" ) except Exception: return "" # 从 URL、隐藏域或 Com_Parameter 读本单 fdId。 def _read_document_id(browser: BrowserAdapter) -> str: try: browser.switch_to_default_content() except Exception: pass value = browser.execute_script( """ function fromHref(href) { const fromUrl = String(href || '').match(/[?&]fdId=([^&]+)/i); if (fromUrl && fromUrl[1]) return decodeURIComponent(fromUrl[1]); return ''; } function fromDoc(win) { try { const id = fromHref(win.location && win.location.href); if (id) return id; } catch (e) {} try { const input = win.document.querySelector('input[name="fdId"], input[name="fdid"]'); if (input && String(input.value || '').trim()) return String(input.value).trim(); } catch (e) {} try { if (win.Com_Parameter && win.Com_Parameter.fdId) return String(win.Com_Parameter.fdId); } catch (e) {} return ''; } function walk(win) { const id = fromDoc(win); if (id) return id; for (let i = 0; i < win.frames.length; i++) { try { const inner = walk(win.frames[i]); if (inner) return inner; } catch (e) {} } return ''; } return walk(window); """ ) return str(value or "").strip() # 用 fdId 打开合同详情页。没有 fdId 则什么都不做。 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) # 拼 LUI 工具栏按钮的精确文本 xpath,避免 contains 点到别的按钮。 def _lui_btn_xpath(label: str) -> str: safe = (label or "").replace("'", "") return ( "//*[contains(@class,'lui_widget_btn_txt') and normalize-space()='%s']" % safe ) # 点 LUI 工具栏上指定文案的按钮。脚本点不到再走 native/first。 def _click_toolbar_button(browser: BrowserAdapter, label: str) -> bool: try: browser.switch_to_default_content() except Exception: pass clicked = browser.execute_script(_LUI_TOOLBAR_CLICK_JS, label) print(f"toolbar {label} js={clicked}", flush=True) if isinstance(clicked, dict) and clicked.get("ok"): return True selectors = [_lui_btn_xpath(label)] try: browser.click_native(selectors) print(f"toolbar {label} native=ok", flush=True) return True except Exception as exc: print(f"toolbar {label} native={exc}", flush=True) try: browser.click_first(selectors) print(f"toolbar {label} first=ok", flush=True) return True except Exception as exc: print(f"toolbar {label} first={exc}", flush=True) return False _LUI_TOOLBAR_FIND_JS = """ const label = String(arguments[0] || ''); function visible(el) { const box = el.getBoundingClientRect(); return box.width > 2 && box.height > 2; } function textOf(el) { return (el.textContent || '').replace(/\\s+/g, '').trim(); } function findIn(win) { const nodes = Array.from(win.document.querySelectorAll( 'div.lui-component.lui_widget_btn_txt, .lui_widget_btn_txt' )); const hit = nodes.find((el) => textOf(el) === label && visible(el)); if (hit) { return { ok: true, cid: hit.getAttribute('data-lui-cid') || '', id: hit.id || '', count: nodes.length }; } for (let i = 0; i < win.frames.length; i++) { try { const inner = findIn(win.frames[i]); if (inner && inner.ok) return inner; } catch (e) {} } return { ok: false, texts: nodes.map((el) => textOf(el)).filter(Boolean).slice(0, 12) }; } return findIn(window); """ _LUI_TOOLBAR_CLICK_JS = """ const label = String(arguments[0] || ''); function visible(el) { const box = el.getBoundingClientRect(); return box.width > 2 && box.height > 2; } function textOf(el) { return (el.textContent || '').replace(/\\s+/g, '').trim(); } function fire(el, win) { const target = el.closest('[data-lui-cid]') || el; try { target.scrollIntoView({block: 'center', inline: 'nearest'}); } catch (e) {} ['mouseover', 'mousedown', 'mouseup', 'click'].forEach((type) => { target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: win, buttons: 1 })); }); target.click(); } function clickIn(win) { const nodes = Array.from(win.document.querySelectorAll( 'div.lui-component.lui_widget_btn_txt, .lui_widget_btn_txt' )); const hit = nodes.find((el) => textOf(el) === label && visible(el)); if (hit) { fire(hit, win); return { ok: true, via: 'btn_txt', cid: hit.getAttribute('data-lui-cid') || '', id: hit.id || '' }; } for (let i = 0; i < win.frames.length; i++) { try { const inner = clickIn(win.frames[i]); if (inner && inner.ok) return inner; } catch (e) {} } return { ok: false, label: label, texts: nodes.map((el) => textOf(el)).filter(Boolean).slice(0, 12) }; } return clickIn(window); """ # 工具栏上是否已经出现指定文案的可见按钮。 def _lui_toolbar_button_exists(browser: BrowserAdapter, label: str) -> bool: found = browser.execute_script(_LUI_TOOLBAR_FIND_JS, label) ok = isinstance(found, dict) and bool(found.get("ok")) print(f"toolbar {label} exists={found}", flush=True) return ok # 等到工具栏出现指定按钮。超时返回 False。 def _wait_lui_toolbar_button( browser: BrowserAdapter, label: str, timeout_seconds: float = 10 ) -> bool: deadline = time.time() + timeout_seconds while time.time() < deadline: if _lui_toolbar_button_exists(browser, label): return True time.sleep(0.4) return False # 点工具栏「提交」。 def _click_top_submit(browser: BrowserAdapter) -> bool: return _click_toolbar_button(browser, "提交") # 判断字符串是不是正式合同号。占位符、表头、手机号不算。 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: if not browser.element_exists(selectors): return "" try: return (browser.read_first(selectors, "") or "").strip() except Exception: return "" # 合同版本类型的验收文案:非格式合同或格式合同。 def contract_version_label(ctx: ContractFlowContext) -> str: return "非格式合同" if ctx.template_type == "NON_FORMAT" else "格式合同"