2533 lines
94 KiB
Python
2533 lines
94 KiB
Python
"""合同系统页面步骤:登录、起草、填表、提交、读合同号。"""
|
||
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 (
|
||
DiligenceError,
|
||
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: ...
|
||
def accept_alert(self) -> str: ...
|
||
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")
|
||
|
||
|
||
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)
|
||
check_customer_diligence(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)
|
||
fd_id = _read_document_id(browser)
|
||
if not submit_contract_for_approval(browser, ctx, fd_id=fd_id):
|
||
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 ""
|
||
fd_id = fd_id or _read_document_id(browser)
|
||
contract_no, via = _read_contract_number_routed(browser, ctx, fd_id=fd_id)
|
||
_ACCEPT.note(
|
||
"S13",
|
||
"读合同号",
|
||
{
|
||
"contractNo": contract_no or "(none)",
|
||
"via": via,
|
||
"fdId": fd_id or "(none)",
|
||
},
|
||
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}"
|
||
)
|
||
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 = {
|
||
"账期支付": "电汇",
|
||
"账期": "电汇",
|
||
"承兑": "银行承兑汇票",
|
||
}
|
||
|
||
|
||
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"],
|
||
}
|
||
|
||
|
||
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.wait_for_element(
|
||
['input[name="j_username"]', 'input[placeholder="用户名"]'],
|
||
timeout_seconds=15,
|
||
)
|
||
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 点击无效)
|
||
reopen = getattr(browser, "reopen_url", None)
|
||
if callable(reopen):
|
||
reopen(FORM_PAGE_URL)
|
||
else:
|
||
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 sign_date_for(ctx: ContractFlowContext) -> str:
|
||
return (ctx.contract_sign_date or ctx.order_date or "").strip()
|
||
|
||
|
||
def begin_date_for(ctx: ContractFlowContext) -> str:
|
||
return (ctx.contract_start_date or ctx.order_date or "").strip()
|
||
|
||
|
||
def check_customer_diligence(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||
text = browser.read_body_text() or ""
|
||
if "未尽调" in text:
|
||
raise DiligenceError("客户未尽调")
|
||
|
||
|
||
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)
|
||
sign_date = sign_date_for(ctx)
|
||
begin_date = begin_date_for(ctx)
|
||
browser.fill_first([F["sign_date"]], sign_date)
|
||
browser.fill_first([F["begin_date"]], begin_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"]]), sign_date),
|
||
("beginDate", _try_read(browser, [F["begin_date"]]), begin_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
|
||
used: list[int] = []
|
||
assigned: list[int] = []
|
||
for line in ctx.goods_lines:
|
||
row = _find_goods_row(browser, line, skip=used)
|
||
if row < 0:
|
||
same = _existing_used_match(browser, line, used)
|
||
if same >= 0:
|
||
row = _append_same_goods_row(browser, same)
|
||
else:
|
||
if used and not _empty_goods_row_indexes(browser):
|
||
_add_goods_row(browser)
|
||
row = _pick_goods_from_dialog(browser, line, skip=used)
|
||
_fill_goods_required_qty_price(browser, line, row)
|
||
used.append(row)
|
||
assigned.append(row)
|
||
_delete_empty_goods_rows(browser)
|
||
used = []
|
||
assigned = []
|
||
for line in ctx.goods_lines:
|
||
row = _find_goods_row(browser, line, skip=used)
|
||
assigned.append(row)
|
||
if row >= 0:
|
||
used.append(row)
|
||
evidence_shot(browser, "contract-goods-lines-filled")
|
||
for index, (line, row) in enumerate(zip(ctx.goods_lines, assigned, strict=True)):
|
||
opcode = _try_read(browser, [_goods_selector("goods_code", row)]) if row >= 0 else ""
|
||
mdm = _try_read(browser, [_goods_selector("goods_mdm", 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 ""
|
||
if line.mdm_code:
|
||
code_pair = ("goodsMdm", mdm or opcode, line.mdm_code)
|
||
else:
|
||
code_pair = ("goodsCode", opcode, line.goods_code)
|
||
_ACCEPT.check(
|
||
"S09",
|
||
f"货品明细#{index + 1}",
|
||
[
|
||
code_pair,
|
||
("qty", qty, line.quantity),
|
||
("price", price, line.unit_price or "1.00"),
|
||
],
|
||
browser=browser,
|
||
screenshot_on_fail="contract-goods-pick-failed",
|
||
)
|
||
|
||
|
||
def _goods_search_keyword(line: ContractGoodsLine) -> str:
|
||
return (line.mdm_code or line.goods_code or line.goods_name or "").strip()
|
||
|
||
|
||
def _looks_like_goods_opcode(keyword: str) -> bool:
|
||
compact = (keyword or "").replace(" ", "").upper()
|
||
return compact.startswith(("FQX", "QX")) and any(ch.isdigit() for ch in compact)
|
||
|
||
|
||
def _picker_rows_dump(browser: BrowserAdapter) -> str:
|
||
dump = browser.execute_script(
|
||
"""
|
||
return Array.from(document.querySelectorAll('tr')).map((row) => {
|
||
const text = (row.innerText || '').replace(/\\s+/g, ' ').trim();
|
||
const box = row.querySelector(
|
||
'input[name="List_Selected"], input[type="checkbox"], input[type="radio"]'
|
||
);
|
||
if (!box || !text) return '';
|
||
return text.slice(0, 80);
|
||
}).filter(Boolean).slice(0, 8).join('|');
|
||
"""
|
||
)
|
||
return str(dump or "")
|
||
|
||
|
||
def _pick_goods_from_dialog(
|
||
browser: BrowserAdapter, line: ContractGoodsLine, skip: list[int] | tuple[int, ...] = ()
|
||
) -> int:
|
||
keyword = _goods_search_keyword(line)
|
||
if not keyword:
|
||
raise NodeError("货品缺少 goodsMdmCode/goodsCode,无法在「选择商品」中搜索")
|
||
|
||
_select_goods_in_picker(browser, line, keyword)
|
||
row = _wait_unused_goods_row(browser, line, skip, timeout=12)
|
||
if row >= 0:
|
||
return row
|
||
_add_goods_row(browser)
|
||
_select_goods_in_picker(browser, line, keyword)
|
||
row = _wait_unused_goods_row(browser, line, skip, timeout=12)
|
||
if row >= 0:
|
||
return row
|
||
_scroll_to_goods_table(browser)
|
||
evidence_shot(browser, "contract-goods-pick-failed", failed=True)
|
||
dump = _goods_table_dump(browser)
|
||
raise NodeError(f"选择商品后未在明细表找到新的一行「{keyword}」 dump={dump}")
|
||
|
||
|
||
def _select_goods_in_picker(browser: BrowserAdapter, line: ContractGoodsLine, keyword: str) -> None:
|
||
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):
|
||
_search_goods_in_dialog(browser, keyword)
|
||
picked = _select_dialog_row_by_keyword(browser, keyword)
|
||
if not picked and line.goods_name and not _looks_like_goods_opcode(keyword):
|
||
_search_goods_in_dialog(browser, line.goods_name.strip())
|
||
picked = _select_dialog_row_by_keyword(browser, keyword)
|
||
if not picked:
|
||
picked = _select_dialog_row_by_keyword(browser, line.goods_name.strip())
|
||
if not picked:
|
||
dump = _picker_rows_dump(browser)
|
||
raise NodeError(f"选择商品失败,未找到「{keyword}」 dump={dump}")
|
||
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)
|
||
|
||
|
||
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 _as_index_list(value) -> list[int]:
|
||
if value is None or value is False:
|
||
return []
|
||
if isinstance(value, bool):
|
||
return []
|
||
if isinstance(value, (int, float)):
|
||
return [int(value)]
|
||
if isinstance(value, str):
|
||
parts = [part.strip() for part in value.replace(";", ",").split(",") if part.strip()]
|
||
out: list[int] = []
|
||
for part in parts:
|
||
try:
|
||
out.append(int(float(part)))
|
||
except ValueError:
|
||
continue
|
||
return out
|
||
try:
|
||
seq = list(value)
|
||
except TypeError:
|
||
return []
|
||
out: list[int] = []
|
||
for item in seq:
|
||
try:
|
||
out.append(int(item))
|
||
except (TypeError, ValueError):
|
||
continue
|
||
return out
|
||
|
||
|
||
def _next_unused_goods_row(matches: list[int], skip: list[int] | tuple[int, ...] = ()) -> int:
|
||
used = {int(i) for i in skip}
|
||
for row in matches:
|
||
if row not in used:
|
||
return row
|
||
return -1
|
||
|
||
|
||
def _find_goods_row(
|
||
browser: BrowserAdapter, line: ContractGoodsLine, skip: list[int] | tuple[int, ...] = ()
|
||
) -> int:
|
||
row = browser.execute_script(
|
||
"""
|
||
const keys = [String(arguments[0] || '').trim(), String(arguments[1] || '').trim()].filter(Boolean);
|
||
const skip = {};
|
||
String(arguments[2] || '').split(',').forEach((part) => {
|
||
if (part !== '') skip[Number(part)] = true;
|
||
});
|
||
for (let i = 0; i < 30; i++) {
|
||
if (skip[i]) continue;
|
||
const fields = ['fd_opcode', 'fd_mdmCode'];
|
||
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;
|
||
for (const keyword of keys) {
|
||
if (values.some((v) => v === keyword)) {
|
||
return i;
|
||
}
|
||
}
|
||
}
|
||
return -1;
|
||
""",
|
||
line.mdm_code or line.goods_code,
|
||
line.goods_name,
|
||
",".join(str(int(i)) for i in skip),
|
||
)
|
||
try:
|
||
return int(row)
|
||
except (TypeError, ValueError):
|
||
return -1
|
||
|
||
|
||
def _goods_table_dump(browser: BrowserAdapter) -> str:
|
||
dump = browser.execute_script(
|
||
"""
|
||
const out = [];
|
||
for (let i = 0; i < 10; i++) {
|
||
const mdm = document.querySelector(
|
||
'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_mdmCode)"]'
|
||
);
|
||
const qty = document.querySelector(
|
||
'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_goodsqty)"]'
|
||
);
|
||
const price = document.querySelector(
|
||
'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_unitprice)"]'
|
||
);
|
||
if (!mdm && !qty && !price) continue;
|
||
out.push(
|
||
String(i) + ':' + (mdm ? String(mdm.value || '') : '') +
|
||
':' + (qty ? String(qty.value || '') : '') +
|
||
':' + (price ? String(price.value || '') : '')
|
||
);
|
||
}
|
||
return out.join('|');
|
||
"""
|
||
)
|
||
return str(dump or "")
|
||
|
||
|
||
def _wait_unused_goods_row(
|
||
browser: BrowserAdapter,
|
||
line: ContractGoodsLine,
|
||
skip: list[int] | tuple[int, ...],
|
||
timeout: float,
|
||
) -> int:
|
||
deadline = time.time() + timeout
|
||
while True:
|
||
row = _find_goods_row(browser, line, skip=skip)
|
||
if row >= 0:
|
||
return row
|
||
if time.time() >= deadline:
|
||
return -1
|
||
_click_dialog_ok(browser)
|
||
time.sleep(0.6)
|
||
|
||
|
||
def _empty_goods_row_indexes(browser: BrowserAdapter) -> list[int]:
|
||
rows = browser.execute_script(
|
||
"""
|
||
const found = [];
|
||
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)"]'
|
||
);
|
||
const mdm = document.querySelector(
|
||
'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_mdmCode)"]'
|
||
);
|
||
if (!code && !name && !mdm) continue;
|
||
const empty = !(code && String(code.value || '').trim())
|
||
&& !(name && String(name.value || '').trim())
|
||
&& !(mdm && String(mdm.value || '').trim());
|
||
if (empty) found.push(i);
|
||
}
|
||
return found.join(',');
|
||
"""
|
||
)
|
||
return _as_index_list(rows)
|
||
|
||
|
||
def _occupied_goods_row_indexes(browser: BrowserAdapter) -> list[int]:
|
||
rows = browser.execute_script(
|
||
"""
|
||
const found = [];
|
||
for (let i = 0; i < 30; i++) {
|
||
const mdm = document.querySelector(
|
||
'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_mdmCode)"]'
|
||
);
|
||
const code = document.querySelector(
|
||
'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_opcode)"]'
|
||
);
|
||
const filled = (mdm && String(mdm.value || '').trim())
|
||
|| (code && String(code.value || '').trim());
|
||
if (filled) found.push(i);
|
||
}
|
||
return found.join(',');
|
||
"""
|
||
)
|
||
return _as_index_list(rows)
|
||
|
||
|
||
def _add_goods_row(browser: BrowserAdapter) -> None:
|
||
before = _occupied_goods_row_indexes(browser)
|
||
_scroll_to_goods_table(browser)
|
||
result = browser.execute_script(
|
||
"""
|
||
const wanted = ['添加行', '新增明细', '新增行'];
|
||
const nodes = Array.from(document.querySelectorAll('a, div, span, button, input'));
|
||
for (const el of nodes) {
|
||
const text = (el.value || el.textContent || '').replace(/\\s+/g, '').trim();
|
||
if (!wanted.includes(text)) continue;
|
||
if (el.children && el.children.length > 2) continue;
|
||
const rect = el.getBoundingClientRect();
|
||
if (rect.width < 2 || rect.height < 2) continue;
|
||
el.click();
|
||
return {ok: true, via: text};
|
||
}
|
||
const tables = Array.from(document.querySelectorAll('table[id]')).map((t) => t.id);
|
||
if (typeof DocList_AddRow === 'function') {
|
||
const hit = tables.find((id) =>
|
||
id.toLowerCase().indexOf('detail') >= 0 || id.indexOf('DocList') >= 0
|
||
);
|
||
if (hit) {
|
||
DocList_AddRow(hit);
|
||
return {ok: true, via: 'DocList:' + hit};
|
||
}
|
||
DocList_AddRow();
|
||
return {ok: true, via: 'DocList()'};
|
||
}
|
||
return {ok: false, tables: tables.slice(0, 8).join(',')};
|
||
"""
|
||
)
|
||
deadline = time.time() + 5
|
||
while time.time() < deadline:
|
||
if _empty_goods_row_indexes(browser):
|
||
return
|
||
if len(_occupied_goods_row_indexes(browser)) > len(before):
|
||
return
|
||
time.sleep(0.3)
|
||
if isinstance(result, dict) and not result.get("ok"):
|
||
raise NodeError(f"同品种需要新的明细行,未找到「添加行」 tables={result.get('tables')}")
|
||
try:
|
||
browser.click_native(ADD_ROW_SELECTORS)
|
||
except Exception:
|
||
browser.click_first(ADD_ROW_SELECTORS)
|
||
time.sleep(0.5)
|
||
if _empty_goods_row_indexes(browser):
|
||
return
|
||
dump = _goods_table_dump(browser)
|
||
raise NodeError(f"同品种需要新的明细行,点击「添加行」后没有空行 dump={dump}")
|
||
|
||
|
||
def _existing_used_match(
|
||
browser: BrowserAdapter, line: ContractGoodsLine, used: list[int]
|
||
) -> int:
|
||
key = (line.mdm_code or line.goods_code or "").strip()
|
||
name = (line.goods_name or "").strip()
|
||
for row in used:
|
||
blob = " ".join(
|
||
value
|
||
for value in (
|
||
_try_read(browser, [_goods_selector("goods_mdm", row)]),
|
||
_try_read(browser, [_goods_selector("goods_code", row)]),
|
||
_try_read(browser, [_goods_selector("goods_name", row)]),
|
||
)
|
||
if value
|
||
)
|
||
if key and key in blob:
|
||
return row
|
||
if name and name in blob:
|
||
return row
|
||
return -1
|
||
|
||
|
||
def _append_same_goods_row(browser: BrowserAdapter, src_row: int) -> int:
|
||
empties = _empty_goods_row_indexes(browser)
|
||
if not empties:
|
||
_add_goods_row(browser)
|
||
empties = _empty_goods_row_indexes(browser)
|
||
if not empties:
|
||
dump = _goods_table_dump(browser)
|
||
raise NodeError(f"同品种需要新的明细行,添加行后仍没有空行 dump={dump}")
|
||
dest = empties[0]
|
||
_clone_goods_identity(browser, src_row, dest)
|
||
return dest
|
||
|
||
|
||
def _clone_goods_identity(browser: BrowserAdapter, src_row: int, dest_row: int) -> None:
|
||
browser.execute_script(
|
||
"""
|
||
const src = Number(arguments[0]);
|
||
const dest = Number(arguments[1]);
|
||
const fields = [
|
||
'fd_opcode', 'fd_goodsname', 'fd_mdmCode', 'fd_registdocno',
|
||
'fd_goodstype', 'fd_goodsunit'
|
||
];
|
||
for (const field of fields) {
|
||
const from = document.querySelector(
|
||
'input[name="extendDataFormInfo.value(fd_detail1.' + src + '.' + field + ')"]'
|
||
);
|
||
const to = document.querySelector(
|
||
'input[name="extendDataFormInfo.value(fd_detail1.' + dest + '.' + field + ')"]'
|
||
);
|
||
if (!from || !to) continue;
|
||
to.value = from.value;
|
||
to.dispatchEvent(new Event('input', {bubbles: true}));
|
||
to.dispatchEvent(new Event('change', {bubbles: true}));
|
||
}
|
||
""",
|
||
src_row,
|
||
dest_row,
|
||
)
|
||
|
||
|
||
def _fill_goods_required_qty_price(browser: BrowserAdapter, line: ContractGoodsLine, row: int) -> None:
|
||
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 _resolve_contract_body_path(ctx: ContractFlowContext) -> str:
|
||
path = (ctx.contract_document_path or "").strip()
|
||
if not path and ctx.source_type == "YYC":
|
||
path = (ctx.merged_pdf_path or "").strip()
|
||
if path:
|
||
return path
|
||
if ctx.submit_mode == "dry_run":
|
||
return ""
|
||
from pdf_utils import ensure_placeholder_contract_pdf
|
||
|
||
return ensure_placeholder_contract_pdf()
|
||
|
||
|
||
def upload_contract_document_if_needed(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||
path = _resolve_contract_body_path(ctx)
|
||
if not path:
|
||
evidence_shot(browser, "contract-pdf-upload-skipped")
|
||
_ACCEPT.note("S11", "上传正文", {"skipped": "dry_run_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
|
||
|
||
|
||
_ERROR_MARKS = (
|
||
"不能为空",
|
||
"请填写",
|
||
"校验失败",
|
||
"提交失败",
|
||
"必填项未填",
|
||
"该文档不存在",
|
||
"操作失败",
|
||
)
|
||
_SUCCESS_MARKS = ("您的操作已成功", "操作已成功", "提交成功", "流程已提交", "成功提交")
|
||
_CONFIRM_MARKS = ("确定提交", "是否提交", "确认提交", "是否确定", "确定要提交")
|
||
|
||
|
||
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)
|
||
|
||
|
||
def submit_contract_for_approval(
|
||
browser: BrowserAdapter, ctx: ContractFlowContext, *, fd_id: str = ""
|
||
) -> 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 = (fd_id or "").strip() or _read_document_id(browser)
|
||
print(f"S12 fdId={fd_id or '-'}", flush=True)
|
||
# 测试环境直接提交会落到「该文档不存在」:先暂存落库,再提交。合同号从详情 #_xform_docNumber 读。
|
||
try:
|
||
saved_click = _click_toolbar_button(browser, "暂存")
|
||
except Exception as exc:
|
||
print(f"暂存未点到:{exc}", flush=True)
|
||
saved_click = False
|
||
save_kind, save_text = _wait_submit_outcome(browser, timeout_seconds=15)
|
||
if save_kind == "error":
|
||
evidence_shot(browser, "contract-submitted", failed=True)
|
||
_ACCEPT.note(
|
||
"S12",
|
||
"提交",
|
||
{"submitted": "no", "error": save_text, "at": "暂存", "clickedSave": "yes" if saved_click else "no"},
|
||
ok=False,
|
||
raise_error=False,
|
||
)
|
||
return False
|
||
if save_kind != "success":
|
||
print("暂存后仍在表单页,继续点提交。", flush=True)
|
||
fd_id = fd_id or _read_document_id(browser)
|
||
if not _lui_toolbar_button_exists(browser, "提交"):
|
||
if not fd_id:
|
||
evidence_shot(browser, "contract-submitted", failed=True)
|
||
_ACCEPT.note(
|
||
"S12",
|
||
"提交",
|
||
{"submitted": "no", "error": "暂存后离开表单且无 fdId,无法再点提交", "clicked": "no"},
|
||
ok=False,
|
||
raise_error=False,
|
||
)
|
||
return False
|
||
print(f"暂存后打开编辑页 fdId={fd_id}", flush=True)
|
||
browser.open_url(EDIT_PAGE_URL.format(fd_id=fd_id))
|
||
if not _wait_lui_toolbar_button(browser, "提交", timeout_seconds=12):
|
||
evidence_shot(browser, "contract-submitted", failed=True)
|
||
_ACCEPT.note(
|
||
"S12",
|
||
"提交",
|
||
{"submitted": "no", "error": "编辑页未见提交按钮 lui_widget_btn_txt", "clicked": "no", "fdId": fd_id},
|
||
ok=False,
|
||
raise_error=False,
|
||
)
|
||
return False
|
||
try:
|
||
clicked = _click_toolbar_button(browser, "提交")
|
||
except Exception as exc:
|
||
evidence_shot(browser, "contract-submitted", failed=True)
|
||
_ACCEPT.note("S12", "提交", {"submitted": "no", "error": str(exc)[:120], "clicked": "no"}, ok=False, raise_error=False)
|
||
return False
|
||
if not clicked:
|
||
evidence_shot(browser, "contract-submitted", failed=True)
|
||
_ACCEPT.note("S12", "提交", {"submitted": "no", "error": "未点到提交按钮 lui_widget_btn_txt", "clicked": "no"}, ok=False, raise_error=False)
|
||
return False
|
||
evidence_shot(browser, "contract-after-submit-click")
|
||
kind, dialog = _wait_submit_outcome(browser, timeout_seconds=20, trust_body_success=False)
|
||
if kind == "error":
|
||
evidence_shot(browser, "contract-submitted", failed=True)
|
||
_ACCEPT.note(
|
||
"S12",
|
||
"提交",
|
||
{"submitted": "no", "clicked": "yes", "error": dialog},
|
||
ok=False,
|
||
raise_error=False,
|
||
)
|
||
return False
|
||
print(f"提交已点 kind={kind} dialog={dialog[:80]!r},S13 从详情读合同号。", flush=True)
|
||
_ACCEPT.note(
|
||
"S12",
|
||
"提交",
|
||
{
|
||
"submitted": "yes",
|
||
"clicked": "yes",
|
||
"via": "暂存后提交",
|
||
"signal": kind,
|
||
"dialog": dialog[:80] or "(none)",
|
||
},
|
||
ok=True,
|
||
)
|
||
return True
|
||
|
||
|
||
def _submit_error_from_body(body: str) -> str:
|
||
text = body or ""
|
||
if "该文档不存在" in text:
|
||
return "该文档不存在"
|
||
if "操作失败" in text and "您的请求已提交" in text:
|
||
return "操作失败"
|
||
return ""
|
||
|
||
|
||
def _classify_dialog_text(text: str) -> str:
|
||
blob = text or ""
|
||
for mark in _ERROR_MARKS:
|
||
if mark in blob:
|
||
return "error"
|
||
for mark in _SUCCESS_MARKS:
|
||
if mark in blob:
|
||
return "success"
|
||
for mark in _CONFIRM_MARKS:
|
||
if mark in blob:
|
||
return "confirm"
|
||
return "none"
|
||
|
||
|
||
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 ""
|
||
|
||
|
||
def _wait_submit_outcome(
|
||
browser: BrowserAdapter,
|
||
timeout_seconds: float,
|
||
*,
|
||
trust_body_success: bool = True,
|
||
) -> tuple[str, str]:
|
||
deadline = time.time() + timeout_seconds
|
||
last_dialog = ""
|
||
saw_confirm = False
|
||
while time.time() < deadline:
|
||
kind, text, confirm = _read_submit_signal(
|
||
browser,
|
||
trust_body_success=trust_body_success or saw_confirm,
|
||
)
|
||
if confirm:
|
||
saw_confirm = True
|
||
if text:
|
||
last_dialog = text
|
||
if kind == "error":
|
||
return "error", text
|
||
if kind == "success":
|
||
return "success", text
|
||
time.sleep(0.4)
|
||
kind, text, _confirm = _read_submit_signal(
|
||
browser,
|
||
trust_body_success=trust_body_success or saw_confirm,
|
||
)
|
||
if kind in ("error", "success"):
|
||
return kind, text
|
||
return "none", text or last_dialog
|
||
|
||
|
||
def _read_submit_signal(
|
||
browser: BrowserAdapter,
|
||
*,
|
||
trust_body_success: bool = True,
|
||
) -> tuple[str, str, bool]:
|
||
alert_text = _accept_native_alert(browser)
|
||
if alert_text:
|
||
kind = _classify_dialog_text(alert_text)
|
||
if kind == "none":
|
||
kind = "confirm"
|
||
print(f"S12 native alert kind={kind} text={alert_text[:80]!r}", flush=True)
|
||
if kind == "error":
|
||
return "error", alert_text, False
|
||
if kind == "success":
|
||
return "success", alert_text, False
|
||
if kind == "confirm":
|
||
return _finish_submit_signal(browser, alert_text, True, trust_body_success)
|
||
snapshot = _handle_submit_dialogs(browser)
|
||
dialog_text = str(snapshot.get("text") or "").strip()
|
||
kind = _classify_dialog_text(dialog_text) if dialog_text else "none"
|
||
if kind == "none":
|
||
kind = str(snapshot.get("kind") or "none")
|
||
if kind == "error":
|
||
return "error", dialog_text or "弹窗报错", False
|
||
if kind == "success":
|
||
return "success", dialog_text or "ok", False
|
||
return _finish_submit_signal(
|
||
browser,
|
||
dialog_text,
|
||
kind == "confirm" or bool(snapshot.get("clicked")),
|
||
trust_body_success,
|
||
)
|
||
|
||
|
||
def _finish_submit_signal(
|
||
browser: BrowserAdapter,
|
||
dialog_text: str,
|
||
saw_confirm: bool,
|
||
trust_body_success: bool,
|
||
) -> tuple[str, str, bool]:
|
||
body_error = _submit_error_from_body(browser.read_body_text() or "")
|
||
if body_error:
|
||
return "error", body_error, saw_confirm
|
||
if _submit_view_page(browser):
|
||
return "success", dialog_text or "view", saw_confirm
|
||
if (trust_body_success or saw_confirm) and _submit_success_text(browser):
|
||
return "success", dialog_text or "ok", saw_confirm
|
||
return "none", dialog_text, saw_confirm
|
||
|
||
|
||
def _accept_native_alert(browser: BrowserAdapter) -> str:
|
||
accept = getattr(browser, "accept_alert", None)
|
||
if not callable(accept):
|
||
return ""
|
||
try:
|
||
return str(accept() or "").strip()
|
||
except Exception:
|
||
return ""
|
||
|
||
|
||
def _submit_view_page(browser: BrowserAdapter) -> bool:
|
||
url = _current_url(browser)
|
||
return "method=view" in url and "kmAgreementApply" in url
|
||
|
||
|
||
def _submit_success_text(browser: BrowserAdapter) -> bool:
|
||
body = browser.read_body_text() or ""
|
||
if _submit_error_from_body(body):
|
||
return False
|
||
return any(mark in body for mark in _SUCCESS_MARKS)
|
||
|
||
|
||
def _submit_succeeded(browser: BrowserAdapter) -> bool:
|
||
if _submit_error_from_body(browser.read_body_text() or ""):
|
||
return False
|
||
return _submit_success_text(browser) or _submit_view_page(browser)
|
||
|
||
|
||
def _submit_validation_error(browser: BrowserAdapter) -> str:
|
||
kind, text, _confirm = _read_submit_signal(browser, trust_body_success=True)
|
||
if kind == "error":
|
||
return text
|
||
return ""
|
||
|
||
|
||
def _handle_submit_dialogs(browser: BrowserAdapter) -> dict:
|
||
raw = browser.execute_script(
|
||
"""
|
||
const errorMarks = ['不能为空','请填写','校验失败','提交失败','必填项未填','该文档不存在','操作失败'];
|
||
const successMarks = ['您的操作已成功','操作已成功','提交成功','流程已提交','成功提交'];
|
||
const confirmMarks = ['确定提交','是否提交','确认提交','是否确定','确定要提交'];
|
||
function classify(text) {
|
||
if (errorMarks.some((m) => text.indexOf(m) >= 0)) return 'error';
|
||
if (successMarks.some((m) => text.indexOf(m) >= 0)) return 'success';
|
||
if (confirmMarks.some((m) => text.indexOf(m) >= 0)) return 'confirm';
|
||
return 'none';
|
||
}
|
||
function dialogText(doc) {
|
||
const roots = Array.from(doc.querySelectorAll(
|
||
'.lui_dialog, .lui_message, .lui_alert, .lui_dialog_content, .prompt_frame, .lui_dialog_common'
|
||
));
|
||
return roots.map((el) => (el.innerText || '').trim()).filter(Boolean).join('\\n');
|
||
}
|
||
function clickLabel(doc, labels) {
|
||
const nodes = Array.from(doc.querySelectorAll('div, a, button, span, input'));
|
||
for (const label of labels) {
|
||
const hit = nodes.find((el) => {
|
||
const t = (el.textContent || el.value || '').trim();
|
||
return t === label && (!el.children || el.children.length === 0);
|
||
});
|
||
if (hit) { hit.click(); return label; }
|
||
}
|
||
return '';
|
||
}
|
||
function walk(win) {
|
||
let found = {kind: 'none', text: '', clicked: ''};
|
||
try {
|
||
const text = dialogText(win.document);
|
||
const kind = classify(text);
|
||
if (kind === 'error') return {kind: kind, text: text.slice(0, 200), clicked: ''};
|
||
if (kind === 'confirm') {
|
||
const clicked = clickLabel(win.document, ['确定', '确认', '是', 'OK']);
|
||
return {kind: kind, text: text.slice(0, 200), clicked: clicked};
|
||
}
|
||
if (kind === 'success') {
|
||
const clicked = clickLabel(win.document, ['确定', '关闭', '确认']);
|
||
return {kind: kind, text: text.slice(0, 200), clicked: clicked};
|
||
}
|
||
if (text) {
|
||
const clicked = clickLabel(win.document, ['确定', '确认']);
|
||
if (clicked) return {kind: 'confirm', text: text.slice(0, 200), clicked: clicked};
|
||
}
|
||
} catch (e) {}
|
||
for (let i = 0; i < win.frames.length; i++) {
|
||
try {
|
||
const inner = walk(win.frames[i]);
|
||
if (inner.kind !== 'none' || inner.clicked) return inner;
|
||
} catch (e) {}
|
||
}
|
||
return found;
|
||
}
|
||
return walk(window);
|
||
"""
|
||
)
|
||
if not isinstance(raw, dict):
|
||
return {"kind": "none", "text": "", "clicked": ""}
|
||
return {
|
||
"kind": str(raw.get("kind") or "none"),
|
||
"text": str(raw.get("text") or "").strip(),
|
||
"clicked": str(raw.get("clicked") or ""),
|
||
}
|
||
|
||
|
||
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()
|
||
|
||
|
||
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 _lui_btn_xpath(label: str) -> str:
|
||
safe = (label or "").replace("'", "")
|
||
return (
|
||
"//*[contains(@class,'lui_widget_btn_txt') and normalize-space()='%s']" % safe
|
||
)
|
||
|
||
|
||
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
|
||
|
||
|
||
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 _wait_lui_dialog_gone(browser: BrowserAdapter, timeout_seconds: float = 8) -> None:
|
||
deadline = time.time() + timeout_seconds
|
||
while time.time() < deadline:
|
||
snapshot = _handle_submit_dialogs(browser)
|
||
kind = str(snapshot.get("kind") or "none")
|
||
text = str(snapshot.get("text") or "").strip()
|
||
if kind == "confirm":
|
||
return
|
||
if kind in ("none", "") and not text:
|
||
return
|
||
_close_blocking_dialogs(browser)
|
||
time.sleep(0.4)
|
||
_close_blocking_dialogs(browser)
|
||
|
||
|
||
def _click_top_submit(browser: BrowserAdapter) -> bool:
|
||
return _click_toolbar_button(browser, "提交")
|
||
|
||
|
||
def read_contract_number(
|
||
browser: BrowserAdapter, ctx: ContractFlowContext | None = None, *, fd_id: str = ""
|
||
) -> str:
|
||
no, _via = _read_contract_number_routed(browser, ctx, fd_id=fd_id)
|
||
return no
|
||
|
||
|
||
def _read_contract_number_routed(
|
||
browser: BrowserAdapter, ctx: ContractFlowContext | None = None, *, fd_id: str = ""
|
||
) -> tuple[str, str]:
|
||
del ctx
|
||
doc_id = (fd_id or "").strip() or _read_document_id(browser)
|
||
if not doc_id:
|
||
print("S13 没有 fdId,无法打开详情", flush=True)
|
||
return "", "none"
|
||
print(f"S13:开详情读号 fdId={doc_id}", flush=True)
|
||
no = _read_number_by_fd_id(browser, doc_id)
|
||
if no:
|
||
return no, "docNumber"
|
||
print("S13 详情未读到合同编号", flush=True)
|
||
return "", "none"
|
||
|
||
|
||
def _read_contract_number_on_page(browser: BrowserAdapter) -> str:
|
||
try:
|
||
browser.switch_to_default_content()
|
||
except Exception:
|
||
pass
|
||
scanned = browser.execute_script(
|
||
"""
|
||
const skip = ['提交时自动生成', '自动生成', '==请选择==', '合同编号', '合同标题'];
|
||
function usable(raw) {
|
||
const v = String(raw || '').replace(/\\s+/g, ' ').trim();
|
||
if (!v || skip.indexOf(v) >= 0) return '';
|
||
return v;
|
||
}
|
||
function fromDoc(doc) {
|
||
const node = doc.querySelector('#_xform_docNumber');
|
||
if (node) {
|
||
const typed = node.querySelector('input, textarea');
|
||
const v = usable(typed ? typed.value : (node.innerText || node.textContent || ''));
|
||
if (v) return v;
|
||
}
|
||
const input = doc.querySelector('input[name="docNumber"]');
|
||
if (input) {
|
||
const v = usable(input.value || '');
|
||
if (v) return v;
|
||
}
|
||
return '';
|
||
}
|
||
function walk(win) {
|
||
try {
|
||
const no = fromDoc(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()
|
||
if scanned:
|
||
print(f"S13 详情页扫到不可用值={scanned!r}", flush=True)
|
||
return ""
|
||
|
||
|
||
def _reload_contract_home(browser: BrowserAdapter, home: str) -> bool:
|
||
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
|
||
try:
|
||
browser.open_url(home)
|
||
except Exception as exc:
|
||
print(f"open_url 主界面失败:{exc}", flush=True)
|
||
return _wait_text_in_frames(browser, "我的合同", timeout_seconds=12)
|
||
|
||
|
||
def _reopen_contract_home(browser: BrowserAdapter, ctx: ContractFlowContext, home: str) -> bool:
|
||
print("主界面刷新失败,关闭当前页后重开", flush=True)
|
||
reopen = getattr(browser, "reopen_url", None)
|
||
if callable(reopen):
|
||
reopen(home)
|
||
else:
|
||
browser.open_url(home)
|
||
if _wait_text_in_frames(browser, "我的合同", timeout_seconds=12):
|
||
return True
|
||
if not _is_logged_in(browser):
|
||
login_contract_system(browser, ctx)
|
||
browser.open_url(home)
|
||
return _wait_text_in_frames(browser, "我的合同", timeout_seconds=12)
|
||
|
||
|
||
def _open_my_contracts(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||
home = (ctx.contract_url or "").strip() or "http://contract-test.gzmpc.com:8080/"
|
||
if not _reload_contract_home(browser, home):
|
||
_reopen_contract_home(browser, ctx, home)
|
||
if not _click_my_contracts_sidebar(browser):
|
||
print("未点到侧栏「我的合同」,关闭页面后重开", flush=True)
|
||
_reopen_contract_home(browser, ctx, home)
|
||
_click_my_contracts_sidebar(browser)
|
||
_wait_text_in_frames(browser, "我起草的", timeout_seconds=10)
|
||
_click_by_exact_text(browser, "我起草的")
|
||
_wait_text_in_frames(browser, "申请时间", timeout_seconds=10)
|
||
_wait_text_in_frames(browser, "合同编号", timeout_seconds=10)
|
||
|
||
|
||
def _click_my_contracts_sidebar(browser: BrowserAdapter) -> bool:
|
||
clicked = browser.execute_script(
|
||
"""
|
||
const label = '我的合同';
|
||
function clickIn(win) {
|
||
const nodes = Array.from(win.document.querySelectorAll('a, span, div, li, p, em, td'));
|
||
const hits = nodes.filter((el) => (el.textContent || '').trim() === label);
|
||
hits.sort((a, b) => {
|
||
const ax = a.getBoundingClientRect().left;
|
||
const bx = b.getBoundingClientRect().left;
|
||
if (ax !== bx) return ax - bx;
|
||
return a.children.length - b.children.length;
|
||
});
|
||
if (hits.length) { hits[0].click(); return true; }
|
||
for (let i = 0; i < win.frames.length; i++) {
|
||
try { if (clickIn(win.frames[i])) return true; } catch (e) {}
|
||
}
|
||
return false;
|
||
}
|
||
return clickIn(window);
|
||
"""
|
||
)
|
||
if clicked is True:
|
||
return _wait_text_in_frames(browser, "我起草的", timeout_seconds=8)
|
||
_click_by_exact_text(browser, "我的合同")
|
||
return _wait_text_in_frames(browser, "我起草的", timeout_seconds=8)
|
||
|
||
|
||
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 _pick_contract_row(
|
||
rows: list[dict],
|
||
*,
|
||
fd_id: str = "",
|
||
title: str = "",
|
||
customer_code: str = "",
|
||
) -> str:
|
||
fd_id = (fd_id or "").strip()
|
||
title = (title or "").strip()
|
||
customer_code = (customer_code or "").strip()
|
||
if fd_id:
|
||
for row in rows:
|
||
href = str(row.get("href") or "")
|
||
no = str(row.get("no") or "").strip()
|
||
if fd_id in href and _usable_contract_no(no):
|
||
return no
|
||
if title and customer_code:
|
||
for row in rows:
|
||
row_title = str(row.get("title") or "")
|
||
row_cust = str(row.get("customer") or "")
|
||
no = str(row.get("no") or "").strip()
|
||
if title in row_title and customer_code in row_cust and _usable_contract_no(no):
|
||
return no
|
||
if title:
|
||
for row in rows:
|
||
no = str(row.get("no") or "").strip()
|
||
if title in str(row.get("title") or "") and _usable_contract_no(no):
|
||
return no
|
||
return ""
|
||
|
||
|
||
def _search_my_contracts(browser: BrowserAdapter, title: str, customer_code: str) -> None:
|
||
title = (title or "").strip()
|
||
customer_code = (customer_code or "").strip()
|
||
if not title and not customer_code:
|
||
return
|
||
browser.execute_script(
|
||
"""
|
||
const title = String(arguments[0] || '');
|
||
const customer = String(arguments[1] || '');
|
||
function setValue(el, value) {
|
||
if (!el) return false;
|
||
el.focus();
|
||
el.value = value;
|
||
el.dispatchEvent(new Event('input', {bubbles: true}));
|
||
el.dispatchEvent(new Event('change', {bubbles: true}));
|
||
return true;
|
||
}
|
||
function fillNearLabel(doc, label, value) {
|
||
if (!value) return false;
|
||
const nodes = Array.from(doc.querySelectorAll('td, th, span, label, div, em'));
|
||
for (const node of nodes) {
|
||
const text = (node.innerText || '').replace(/\\s+/g, '').trim();
|
||
if (text !== label && text !== label + ':' && text !== label + ':') continue;
|
||
const box = node.parentElement || node;
|
||
const input = box.querySelector('input[type="text"], input:not([type]), input[type="search"]');
|
||
if (setValue(input, value)) return true;
|
||
const next = node.nextElementSibling;
|
||
if (next && setValue(next.querySelector('input'), value)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
function fillPlaceholder(doc, hint, value) {
|
||
if (!value) return false;
|
||
const inputs = Array.from(doc.querySelectorAll('input'));
|
||
const el = inputs.find((i) => (i.getAttribute('placeholder') || '').indexOf(hint) >= 0);
|
||
if (!el) return false;
|
||
setValue(el, value);
|
||
const btn = el.parentElement && el.parentElement.querySelector('a, button, span, i, em');
|
||
if (btn) btn.click();
|
||
return true;
|
||
}
|
||
function clickSearch(doc) {
|
||
const labels = ['搜索', '查询'];
|
||
const nodes = Array.from(doc.querySelectorAll('a, div, span, button, input'));
|
||
for (const label of labels) {
|
||
const hit = nodes.find((el) => {
|
||
const text = (el.textContent || el.value || '').trim();
|
||
return text === label && (!el.children || el.children.length === 0);
|
||
});
|
||
if (hit) { hit.click(); return true; }
|
||
}
|
||
return false;
|
||
}
|
||
function walk(win) {
|
||
try {
|
||
fillPlaceholder(win.document, '合同(协议)全称', title)
|
||
|| fillPlaceholder(win.document, '合同标题', title)
|
||
|| fillNearLabel(win.document, '合同标题', title)
|
||
|| fillNearLabel(win.document, '合同(协议)全称', title);
|
||
fillPlaceholder(win.document, '客户编码', customer)
|
||
|| fillNearLabel(win.document, '客户编码', customer);
|
||
clickSearch(win.document);
|
||
} catch (e) {}
|
||
for (let i = 0; i < win.frames.length; i++) {
|
||
try { walk(win.frames[i]); } catch (e) {}
|
||
}
|
||
}
|
||
walk(window);
|
||
return true;
|
||
""",
|
||
title,
|
||
customer_code,
|
||
)
|
||
_wait_text_in_frames(browser, "合同编号", timeout_seconds=10)
|
||
|
||
|
||
def _scrape_my_contract_rows(browser: BrowserAdapter) -> list[dict]:
|
||
raw = browser.execute_script(
|
||
"""
|
||
function scrapeTable(doc) {
|
||
const rows = Array.from(doc.querySelectorAll('tr'));
|
||
let header = null;
|
||
const found = [];
|
||
for (const tr of rows) {
|
||
const cells = Array.from(tr.querySelectorAll('th, td')).map((c) =>
|
||
(c.innerText || '').replace(/\\s+/g, ' ').trim()
|
||
);
|
||
if (!header && cells.indexOf('合同编号') >= 0) {
|
||
header = {};
|
||
cells.forEach((h, i) => { if (h && header[h] === undefined) header[h] = i; });
|
||
continue;
|
||
}
|
||
if (!header) continue;
|
||
const no = (cells[header['合同编号']] || '').trim();
|
||
const title = (
|
||
cells[header['合同标题']]
|
||
|| cells[header['合同(协议)全称']]
|
||
|| cells[header['合同名称']]
|
||
|| cells[header['主题']]
|
||
|| cells[header['标题']]
|
||
|| ''
|
||
).trim();
|
||
const customer = (
|
||
cells[header['客户编码']] || cells[header['客户名称']] || cells[header['客商编码']] || ''
|
||
).trim();
|
||
const link = tr.querySelector('a[href*="fdId"], a[href*="fdid"]');
|
||
const box = tr.querySelector('input[name="List_Selected"]');
|
||
const href = link
|
||
? String(link.getAttribute('href') || '')
|
||
: (box ? String(box.value || '') : (tr.innerHTML || ''));
|
||
if (!no && !href) continue;
|
||
found.push({no: no, title: title, customer: customer, href: href});
|
||
}
|
||
return found;
|
||
}
|
||
function scrapeLinks(doc) {
|
||
const found = [];
|
||
const links = Array.from(doc.querySelectorAll('a[href*="fdId"], a[href*="fdid"]'));
|
||
for (const a of links) {
|
||
const href = String(a.getAttribute('href') || '');
|
||
const tr = a.closest('tr') || a.parentElement;
|
||
const blob = ((tr && tr.innerText) || a.innerText || '').replace(/\\s+/g, ' ').trim();
|
||
const hit = blob.match(/HT[0-9A-Za-z-]+/) || blob.match(/\\b\\d{10,}\\b/);
|
||
found.push({
|
||
no: hit ? hit[0] : '',
|
||
title: blob,
|
||
customer: blob,
|
||
href: href
|
||
});
|
||
}
|
||
return found;
|
||
}
|
||
function walk(win) {
|
||
let all = [];
|
||
try { all = all.concat(scrapeTable(win.document)); } catch (e) {}
|
||
try { all = all.concat(scrapeLinks(win.document)); } catch (e) {}
|
||
for (let i = 0; i < win.frames.length; i++) {
|
||
try { all = all.concat(walk(win.frames[i])); } catch (e) {}
|
||
}
|
||
return all;
|
||
}
|
||
return walk(window);
|
||
"""
|
||
)
|
||
if not isinstance(raw, list):
|
||
return []
|
||
rows: list[dict] = []
|
||
for item in raw:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
rows.append(
|
||
{
|
||
"no": str(item.get("no") or "").strip(),
|
||
"title": str(item.get("title") or "").strip(),
|
||
"customer": str(item.get("customer") or "").strip(),
|
||
"href": str(item.get("href") or "").strip(),
|
||
}
|
||
)
|
||
return rows
|
||
|
||
|
||
def _row_has_fd_id(rows: list[dict], fd_id: str) -> bool:
|
||
token = (fd_id or "").strip()
|
||
if not token:
|
||
return False
|
||
return any(token in str(row.get("href") or "") for row in rows)
|
||
|
||
|
||
def _read_number_by_fd_id(browser: BrowserAdapter, fd_id: str) -> str:
|
||
doc_id = (fd_id or "").strip()
|
||
if not doc_id:
|
||
return ""
|
||
_open_submitted_view(browser, doc_id)
|
||
deadline = time.time() + 10
|
||
while time.time() < deadline:
|
||
body = browser.read_body_text() or ""
|
||
if "该文档不存在" in body or "操作失败" in body:
|
||
print(f"S13 详情不存在 fdId={doc_id}", flush=True)
|
||
return ""
|
||
no = _read_contract_number_on_page(browser)
|
||
if no:
|
||
print("从合同详情读到合同编号:", no, flush=True)
|
||
return no
|
||
time.sleep(0.8)
|
||
return ""
|
||
|
||
|
||
def _read_contract_number_from_my_list(
|
||
browser: BrowserAdapter,
|
||
ctx: ContractFlowContext,
|
||
timeout_seconds: int = 25,
|
||
fd_id: str = "",
|
||
) -> str:
|
||
title = _expected_title(ctx)
|
||
customer = (ctx.customer_code or "").strip()
|
||
print(
|
||
f"S13:回我的合同查询 fdId={fd_id or '-'} title={title} customer={customer}",
|
||
flush=True,
|
||
)
|
||
_open_my_contracts(browser, ctx)
|
||
deadline = time.time() + timeout_seconds
|
||
searched = False
|
||
saw_row = False
|
||
while time.time() < deadline:
|
||
rows = _scrape_my_contract_rows(browser)
|
||
print(f"S13 列表行数={len(rows)}", flush=True)
|
||
for row in rows[:6]:
|
||
print(
|
||
"S13 row"
|
||
f" no={row.get('no')!r}"
|
||
f" title={str(row.get('title') or '')[:60]!r}"
|
||
f" customer={str(row.get('customer') or '')[:30]!r}"
|
||
f" href={str(row.get('href') or '')[:90]!r}",
|
||
flush=True,
|
||
)
|
||
no = _pick_contract_row(
|
||
rows,
|
||
fd_id=fd_id,
|
||
title=title,
|
||
customer_code=customer,
|
||
)
|
||
if no:
|
||
print("从我的合同读到合同编号:", no, flush=True)
|
||
return no
|
||
if fd_id and _row_has_fd_id(rows, fd_id):
|
||
saw_row = True
|
||
if not searched:
|
||
_search_my_contracts(browser, title, customer)
|
||
searched = True
|
||
time.sleep(1)
|
||
if saw_row and fd_id:
|
||
no = _read_number_by_fd_id(browser, fd_id)
|
||
if no:
|
||
return no
|
||
print("我的合同列表未匹配到合同编号", flush=True)
|
||
return ""
|
||
|
||
|
||
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 "格式合同"
|