AutoRpa/rpa_contract/contract_common.py

607 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 合同页面共用:类型、选择器、工具栏、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 "格式合同"