388 lines
16 KiB
Python
388 lines
16 KiB
Python
"""Flowchart-sized CONTRACT_CREATE browser steps for YiSaiQi.
|
||
|
||
Each function is intentionally small enough to map to one YiSaiQi flowchart
|
||
node or one script block behind a flowchart node.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import time
|
||
from dataclasses import dataclass, field
|
||
from typing import Protocol
|
||
|
||
from .errors import (
|
||
ContractCreateError,
|
||
LoginFailed,
|
||
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 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 select_first(self, selectors: list[str], value: str) -> None: ...
|
||
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: 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
|
||
order_no_list: list[str] = field(default_factory=list)
|
||
|
||
|
||
def run_contract_create_flow(browser: BrowserAdapter, ctx: ContractFlowContext) -> str:
|
||
open_contract_system(browser, ctx)
|
||
login_contract_system(browser, ctx)
|
||
enter_contract_create(browser)
|
||
select_contract_template(browser, ctx)
|
||
fill_contract_signatory_info(browser, ctx)
|
||
fill_contract_basic_info(browser, ctx)
|
||
fill_contract_amount_and_dates(browser, ctx)
|
||
fill_contract_goods_lines(browser, ctx)
|
||
fill_contract_payment_info(browser, ctx)
|
||
upload_contract_document_if_needed(browser, ctx)
|
||
if not submit_contract_for_approval(browser, ctx):
|
||
return ""
|
||
return read_contract_number(browser)
|
||
|
||
|
||
# 合同起草页(模板选择)直达 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"
|
||
)
|
||
|
||
# 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)"]',
|
||
}
|
||
|
||
# 货品明细行的字段后缀;行索引从 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(添加行)",
|
||
]
|
||
|
||
# 客商选择窗口(双击客户名称后弹出)选择器。若真实页面改版,仅需调整此处。
|
||
CUSTOMER_PICKER = {
|
||
"search_input": [
|
||
'input[placeholder*="客商"]',
|
||
'input[placeholder*="编码"]',
|
||
'input[placeholder*="搜索"]',
|
||
'input[name*="search"]',
|
||
'input[class*="search"]',
|
||
],
|
||
"search_button": [
|
||
"text=搜索",
|
||
"text=查询",
|
||
"button:contains(搜索)",
|
||
"a:contains(搜索)",
|
||
],
|
||
"result_rows": [
|
||
"//table//tr[contains(.,'{code}')]",
|
||
"//*[contains(@class,'list')]//*[contains(text(),'{code}')]",
|
||
"text={code}",
|
||
],
|
||
"confirm_button": [
|
||
"text=确定",
|
||
"text=确认",
|
||
"button:contains(确定)",
|
||
"button:contains(确认)",
|
||
],
|
||
}
|
||
|
||
|
||
def open_contract_system(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||
browser.open_url(ctx.contract_url)
|
||
|
||
|
||
def login_contract_system(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||
try:
|
||
browser.fill_first(['input[name="j_username"]', 'input[placeholder="用户名"]'], ctx.username)
|
||
browser.fill_first(['input[name="j_password"]', 'input[placeholder="密码"]'], ctx.password)
|
||
browser.click_first(['a[class*="login_button"]', 'a:contains("登录")', 'input[name="btn_submit"]'])
|
||
browser.wait_for_text("合同", timeout_seconds=20)
|
||
except Exception as exc:
|
||
browser.screenshot("contract-login-failed")
|
||
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)
|
||
|
||
|
||
def select_contract_template(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||
# 已通过 URL 直接指定模板,本步骤仅兜底:若尚未进入表单则再次导航
|
||
if "客户编码" not in browser.read_body_text():
|
||
browser.open_url(FORM_PAGE_URL)
|
||
browser.wait_for_text("客户编码", timeout_seconds=20)
|
||
|
||
|
||
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)为空,无法加载客商")
|
||
|
||
# 双击客户名称字段打开客商选择窗口,按编码搜索并带回客商
|
||
browser.double_click_first([F["customer_name"]])
|
||
_pick_customer_from_dialog(browser, ctx.customer_code)
|
||
loaded_name = _wait_customer_name(browser, timeout=8)
|
||
|
||
# 兜底:双击未弹出选择窗时,直接填客商操作码触发自动加载
|
||
if not loaded_name:
|
||
browser.fill_first([F["customer_code"]], ctx.customer_code)
|
||
loaded_name = _wait_customer_name(browser, timeout=8)
|
||
|
||
if not loaded_name:
|
||
browser.screenshot("contract-customer-load-failed")
|
||
raise SupplierContractDisabled(
|
||
f"客商加载失败,请确认输入的客商操作码是否有误:{ctx.customer_code}"
|
||
)
|
||
|
||
if expected_name and loaded_name != expected_name:
|
||
browser.screenshot("contract-customer-name-mismatch")
|
||
raise SupplierContractDisabled(
|
||
f"客商名称校验不通过:期望「{expected_name}」,实际「{loaded_name}」"
|
||
)
|
||
|
||
browser.screenshot("contract-signatory-info-filled")
|
||
|
||
|
||
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:
|
||
browser.wait_for_element(CUSTOMER_PICKER["search_input"], timeout_seconds=10)
|
||
browser.fill_first(CUSTOMER_PICKER["search_input"], code)
|
||
browser.click_first(CUSTOMER_PICKER["search_button"])
|
||
time.sleep(1)
|
||
rows = [sel.format(code=code) for sel in CUSTOMER_PICKER["result_rows"]]
|
||
browser.click_first(rows)
|
||
browser.click_first(CUSTOMER_PICKER["confirm_button"])
|
||
|
||
|
||
def fill_contract_basic_info(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||
if ctx.contract_category:
|
||
browser.fill_first([F["contract_category"]], ctx.contract_category)
|
||
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"]'])
|
||
browser.screenshot("contract-basic-info-filled")
|
||
|
||
|
||
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 ContractCreateError(f"结算方式下拉未选中,仍停留在「==请选择==」,期望「{value}」")
|
||
|
||
|
||
def fill_contract_amount_and_dates(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||
if ctx.amount:
|
||
browser.fill_first([F["amount"]], ctx.amount)
|
||
browser.fill_first([F["sign_date"]], ctx.order_date)
|
||
browser.fill_first([F["begin_date"]], ctx.order_date)
|
||
if ctx.contract_end_date:
|
||
browser.fill_first([F["end_date"]], ctx.contract_end_date)
|
||
browser.screenshot("contract-amount-and-dates-filled")
|
||
|
||
|
||
def fill_contract_goods_lines(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||
for index, line in enumerate(ctx.goods_lines):
|
||
row = index # 第 1 行 row=0,第 2 行 row=1,依此类推
|
||
if index > 0:
|
||
browser.click_first(ADD_ROW_SELECTORS)
|
||
browser.wait_for_element([_goods_selector("goods_code", row)], timeout_seconds=15)
|
||
|
||
browser.fill_first([_goods_selector("goods_code", row)], line.goods_code)
|
||
browser.fill_first([_goods_selector("goods_name", row)], line.goods_name)
|
||
if line.mdm_code:
|
||
browser.fill_first([_goods_selector("goods_mdm", row)], line.mdm_code)
|
||
if line.approval_no:
|
||
browser.fill_first([_goods_selector("goods_approval", row)], line.approval_no)
|
||
if line.spec:
|
||
browser.fill_first([_goods_selector("goods_spec", row)], line.spec)
|
||
if line.unit:
|
||
browser.fill_first([_goods_selector("goods_unit", row)], line.unit)
|
||
browser.fill_first([_goods_selector("goods_qty", row)], str(line.quantity))
|
||
if line.unit_price:
|
||
browser.fill_first([_goods_selector("goods_price", row)], str(line.unit_price))
|
||
browser.screenshot("contract-goods-lines-filled")
|
||
|
||
|
||
def _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.fill_first([F["is_major_contract"]], ctx.is_major_contract)
|
||
browser.fill_first([F["is_prepayment"]], ctx.is_prepayment)
|
||
if ctx.belong_year:
|
||
browser.fill_first([F["belong_year"]], ctx.belong_year)
|
||
if ctx.payment_method:
|
||
browser.fill_first([F["payment_method"]], ctx.payment_method)
|
||
if ctx.payment_days:
|
||
browser.fill_first([F["payment_days"]], ctx.payment_days)
|
||
if ctx.contract_status:
|
||
browser.fill_first([F["contract_status"]], ctx.contract_status)
|
||
if ctx.contains_blood_products:
|
||
browser.fill_first([F["contains_blood"]], ctx.contains_blood_products)
|
||
browser.screenshot("contract-payment-filled")
|
||
|
||
|
||
def upload_contract_document_if_needed(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||
if ctx.source_type != "YYC":
|
||
# XYY / OFFLINE:跳过合同证文上传
|
||
return
|
||
if ctx.submit_mode == "dry_run":
|
||
# dry_run 只跑到提交前,不真正上传附件
|
||
browser.screenshot("contract-pdf-upload-skipped")
|
||
return
|
||
if not ctx.merged_pdf_path:
|
||
raise PdfMergeUnsupported("YYC 合同起草缺少合并后的 PDF 路径,无法上传")
|
||
browser.upload_file_first(['input[type="file"]', "label=合同证文上传"], ctx.merged_pdf_path)
|
||
browser.screenshot("contract-pdf-uploaded")
|
||
|
||
|
||
def submit_contract_for_approval(browser: BrowserAdapter, ctx: ContractFlowContext) -> bool:
|
||
if ctx.submit_mode == "dry_run":
|
||
browser.screenshot("contract-dry-run-ready")
|
||
return False
|
||
browser.click_first(["text=提交", "text=提交送审", "text=送审"])
|
||
browser.wait_for_text("合同编号", timeout_seconds=30)
|
||
browser.screenshot("contract-submitted")
|
||
return True
|
||
|
||
|
||
def read_contract_number(browser: BrowserAdapter) -> str:
|
||
return browser.read_first(
|
||
['input[name="contractNo"]', 'input[name="contractSystemNo"]', "label=合同编号", "text=合同编号"],
|
||
"contract_number",
|
||
)
|
||
|
||
|
||
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 "格式合同"
|