AutoRpa/rpa_contract/contract_flow_steps.py

1450 lines
57 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.

"""合同系统页面步骤:登录、起草、填表、提交、读合同号。"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Protocol
from accept import AcceptanceReport, accept_log_path, evidence_shot
from errors import (
LoginFailed,
NodeError,
PdfMergeUnsupported,
SupplierContractDisabled,
)
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: ...
def element_exists(self, selectors: list[str]) -> bool: ...
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: ...
def screenshot(self, name: str) -> str: ...
@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 = ""
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")
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:
_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)
_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 复杂性
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}"
)
# 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 = {
"账期支付": "电汇",
"账期": "电汇",
"承兑": "银行承兑汇票",
}
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=新增行",
"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"],
}
def open_contract_system(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
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"]'])
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:
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
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() 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:
expected_name = (ctx.customer_name or ctx.supplier_name).strip()
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:
evidence_shot(browser, "contract-customer-load-failed", failed=True)
raise SupplierContractDisabled(
f"客商加载失败,请确认输入的客商操作码是否有误:{ctx.customer_code}"
)
if expected_name and loaded_name != expected_name:
evidence_shot(browser, "contract-customer-name-mismatch", failed=True)
raise SupplierContractDisabled(
f"客商名称校验不通过:期望「{expected_name}」,实际「{loaded_name}」"
)
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:
deadline = time.time() + timeout
while time.time() < deadline:
body = browser.read_body_text()
if "自动加载客商失败" in body or "客商操作码是否有误" in body:
return ""
name = _try_read(browser, [F["customer_name"]])
if name and name not in ("==请选择==", "请选择"):
return name
time.sleep(0.5)
return ""
def _pick_customer_from_dialog(browser: BrowserAdapter, code: str) -> None:
# 客商选择窗口是一个 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.5)
browser.wait_for_element(CUSTOMER_PICKER["result_checkbox"], timeout_seconds=10)
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()
def fill_contract_basic_info(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
if 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:
browser.fill_first([F["contract_background"]], ctx.contract_background)
# 合同性质默认主合同(页面已勾选),仅在需要从合同时切换
if ctx.contract_nature and ctx.contract_nature != "主合同":
browser.click_first(['input[name="fdType"][value="01"]'])
_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:
browser.select_first([F["settlement_type"]], value)
selected = _try_read(browser, [F["settlement_type"]])
if not selected or selected in ("==请选择==", "请选择"):
# LUI 自定义下拉兜底:点击展开后按可见文本选择
browser.click_first([F["settlement_type"]])
browser.click_first([f"//option[contains(text(),'{value}')]", f"text={value}"])
selected = _try_read(browser, [F["settlement_type"]])
if not selected or selected in ("==请选择==", "请选择"):
raise NodeError(f"结算方式下拉未选中,仍停留在「==请选择==」,期望「{value}」")
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)
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 = _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",
)
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:
suffix = GOODS_FIELD_SUFFIXES[field_key]
return f'input[name="extendDataFormInfo.value(fd_detail1.{row_index}.{suffix})"]'
def fill_contract_payment_info(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
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.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.select_xform(X["contract_status"], ctx.contract_status)
if ctx.contains_blood_products:
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:
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
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
_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":
evidence_shot(browser, "contract-dry-run-ready")
_ACCEPT.note("S12", "提交", {"mode": "dry_run", "submitted": "no"}, ok=True)
return False
_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)
_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 _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:
ctx = ContractFlowContext(
contract_url="http://contract-test.gzmpc.com:8080/",
username="",
password="",
supplier_name="",
order_date="",
source_type="",
)
return _read_contract_number_from_my_list(browser, ctx)
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:
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 "格式合同"