AutoRpa/codes/rpa_yisaiqi/contract_flow_steps.py

380 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""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 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 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 = {
"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)
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.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:
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:
# 客商选择窗口是一个 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)
browser.wait_for_element(CUSTOMER_PICKER["result_checkbox"], timeout_seconds=10)
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.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 "格式合同"