feat: add Windows Selenium runner for CONTRACT_CREATE
This commit is contained in:
parent
4060d7b45d
commit
ca79659e3e
|
|
@ -0,0 +1,343 @@
|
||||||
|
# coding=utf-8
|
||||||
|
"""
|
||||||
|
APA (艺赛旗) Selenium Chrome BrowserAdapter 实现
|
||||||
|
通过 Selenium WebDriver 直接操控 Chrome,不依赖 APA 浏览器模块
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
class ApaBrowserAdapter:
|
||||||
|
"""Selenium Chrome 浏览器适配器,实现 BrowserAdapter 协议"""
|
||||||
|
|
||||||
|
def __init__(self, url: str = ""):
|
||||||
|
self._url = url
|
||||||
|
self._driver = None
|
||||||
|
self._is_open = False
|
||||||
|
|
||||||
|
def _get_driver(self):
|
||||||
|
"""创建或获取 Chrome WebDriver"""
|
||||||
|
if self._driver is not None:
|
||||||
|
return self._driver
|
||||||
|
|
||||||
|
from selenium import webdriver
|
||||||
|
from selenium.webdriver.chrome.service import Service
|
||||||
|
from selenium.webdriver.chrome.options import Options
|
||||||
|
from webdriver_manager.chrome import ChromeDriverManager
|
||||||
|
|
||||||
|
options = Options()
|
||||||
|
# 不自动关闭浏览器,方便调试
|
||||||
|
options.add_experimental_option("detach", True)
|
||||||
|
# 避免被检测为自动化
|
||||||
|
options.add_experimental_option("excludeSwitches", ["enable-automation"])
|
||||||
|
options.add_experimental_option("useAutomationExtension", False)
|
||||||
|
# 基本参数
|
||||||
|
options.add_argument("--disable-blink-features=AutomationControlled")
|
||||||
|
options.add_argument("--no-first-run")
|
||||||
|
options.add_argument("--no-default-browser-check")
|
||||||
|
|
||||||
|
service = Service(ChromeDriverManager().install())
|
||||||
|
self._driver = webdriver.Chrome(service=service, options=options)
|
||||||
|
return self._driver
|
||||||
|
|
||||||
|
def _ensure_open(self):
|
||||||
|
if self._driver is None:
|
||||||
|
driver = self._get_driver()
|
||||||
|
if self._url:
|
||||||
|
driver.get(self._url)
|
||||||
|
self._is_open = True
|
||||||
|
|
||||||
|
# ---- 内部查找方法 ----
|
||||||
|
|
||||||
|
def _find(self, selector: str, timeout: int = 5):
|
||||||
|
"""
|
||||||
|
智能查找元素:支持 CSS、XPath、text=、label=、aria=、title=、:contains()
|
||||||
|
"""
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
from selenium.common.exceptions import NoSuchElementException
|
||||||
|
import re
|
||||||
|
|
||||||
|
# 1. text=xxx → 查找包含对应文本的元素
|
||||||
|
if selector.startswith("text="):
|
||||||
|
text_val = selector[5:].strip('"').strip("'")
|
||||||
|
xpath = f'//*[contains(text(),"{text_val}")]'
|
||||||
|
return self._driver.find_element(By.XPATH, xpath)
|
||||||
|
|
||||||
|
# 2. label=xxx → 找到对应标签的输入框
|
||||||
|
if selector.startswith("label="):
|
||||||
|
text_val = selector[6:].strip('"').strip("'")
|
||||||
|
xpath = f'//label[contains(text(),"{text_val}")]/following-sibling::*[1][self::input or self::select or self::textarea]'
|
||||||
|
try:
|
||||||
|
return self._driver.find_element(By.XPATH, xpath)
|
||||||
|
except NoSuchElementException:
|
||||||
|
# Fallback: find the label's "for" attribute
|
||||||
|
try:
|
||||||
|
for_id = self._driver.find_element(
|
||||||
|
By.XPATH, f'//label[contains(text(),"{text_val}")]'
|
||||||
|
).get_attribute("for")
|
||||||
|
if for_id:
|
||||||
|
return self._driver.find_element(By.ID, for_id)
|
||||||
|
except NoSuchElementException:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
# 3. aria=xxx → aria-label 属性
|
||||||
|
if selector.startswith("aria="):
|
||||||
|
text_val = selector[5:].strip('"').strip("'")
|
||||||
|
return self._driver.find_element(By.CSS_SELECTOR, f'[aria-label="{text_val}"]')
|
||||||
|
|
||||||
|
# 4. title=xxx → title 属性
|
||||||
|
if selector.startswith("title="):
|
||||||
|
text_val = selector[6:].strip('"').strip("'")
|
||||||
|
return self._driver.find_element(By.CSS_SELECTOR, f'[title="{text_val}"]')
|
||||||
|
|
||||||
|
# 5. :contains() → 转 XPath(支持 button:contains(文本) / a:contains("文本"))
|
||||||
|
if ":contains(" in selector:
|
||||||
|
m = re.match(r'(.+?):contains\(["\']?(.+?)["\']?\)', selector)
|
||||||
|
if m:
|
||||||
|
tag_part, text_val = m.groups()
|
||||||
|
xpath = f'//{tag_part}[contains(.,"{text_val}")]'
|
||||||
|
return self._driver.find_element(By.XPATH, xpath)
|
||||||
|
|
||||||
|
# 6. 显式 XPath(以 / 或 // 开头)优先按 XPath 查找
|
||||||
|
if selector.startswith("//") or selector.startswith("/"):
|
||||||
|
return self._driver.find_element(By.XPATH, selector)
|
||||||
|
|
||||||
|
# 7. 标准 CSS selector
|
||||||
|
return self._driver.find_element(By.CSS_SELECTOR, selector)
|
||||||
|
|
||||||
|
# ---- BrowserAdapter 协议实现 ----
|
||||||
|
|
||||||
|
def open_url(self, url: str) -> None:
|
||||||
|
self._url = url
|
||||||
|
self._get_driver().get(url)
|
||||||
|
self._is_open = True
|
||||||
|
|
||||||
|
def _set_value_via_js(self, el, value: str) -> None:
|
||||||
|
"""通过原生 value setter + 事件注入设置值,兼容 LUI/React 受控组件"""
|
||||||
|
self._driver.execute_script(
|
||||||
|
"""
|
||||||
|
var el = arguments[0];
|
||||||
|
var value = arguments[1];
|
||||||
|
var proto = el.tagName === 'TEXTAREA'
|
||||||
|
? window.HTMLTextAreaElement.prototype
|
||||||
|
: window.HTMLInputElement.prototype;
|
||||||
|
var setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
|
||||||
|
setter.call(el, value);
|
||||||
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
el.dispatchEvent(new Event('blur', { bubbles: true }));
|
||||||
|
""",
|
||||||
|
el,
|
||||||
|
str(value),
|
||||||
|
)
|
||||||
|
|
||||||
|
def fill_first(self, selectors: list, value: str) -> None:
|
||||||
|
self._ensure_open()
|
||||||
|
last_error = None
|
||||||
|
for sel in selectors:
|
||||||
|
try:
|
||||||
|
el = self._find(sel)
|
||||||
|
# 先尝试原生输入;LUI 受控组件用 JS 注入兜底
|
||||||
|
try:
|
||||||
|
el.clear()
|
||||||
|
el.send_keys(str(value))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if el.get_attribute("value") != str(value):
|
||||||
|
self._set_value_via_js(el, str(value))
|
||||||
|
time.sleep(0.3)
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
continue
|
||||||
|
raise RuntimeError(f"fill_first failed: {selectors}, last: {last_error}")
|
||||||
|
|
||||||
|
def click_first(self, selectors: list) -> None:
|
||||||
|
self._ensure_open()
|
||||||
|
last_error = None
|
||||||
|
for sel in selectors:
|
||||||
|
try:
|
||||||
|
el = self._find(sel)
|
||||||
|
# LUI/委托事件用 JS 点击最可靠
|
||||||
|
self._driver.execute_script("arguments[0].click()", el)
|
||||||
|
time.sleep(0.5)
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
continue
|
||||||
|
raise RuntimeError(f"click_first failed: {selectors}, last: {last_error}")
|
||||||
|
|
||||||
|
def double_click_first(self, selectors: list) -> None:
|
||||||
|
self._ensure_open()
|
||||||
|
from selenium.webdriver.common.action_chains import ActionChains
|
||||||
|
last_error = None
|
||||||
|
for sel in selectors:
|
||||||
|
try:
|
||||||
|
el = self._find(sel)
|
||||||
|
ActionChains(self._driver).double_click(el).perform()
|
||||||
|
time.sleep(0.5)
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
continue
|
||||||
|
raise RuntimeError(f"double_click_first failed: {selectors}, last: {last_error}")
|
||||||
|
|
||||||
|
def wait_for_text(self, text: str, timeout_seconds: int = 15) -> None:
|
||||||
|
self._ensure_open()
|
||||||
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
|
from selenium.webdriver.support import expected_conditions as EC
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
try:
|
||||||
|
WebDriverWait(self._driver, timeout_seconds).until(
|
||||||
|
EC.text_to_be_present_in_element((By.TAG_NAME, "body"), text)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise TimeoutError(f"wait_for_text '{text}' timed out: {e}")
|
||||||
|
|
||||||
|
def wait_for_element(self, selectors: list, timeout_seconds: int = 15) -> None:
|
||||||
|
self._ensure_open()
|
||||||
|
deadline = time.time() + timeout_seconds
|
||||||
|
last_error = None
|
||||||
|
while time.time() < deadline:
|
||||||
|
for sel in selectors:
|
||||||
|
try:
|
||||||
|
self._find(sel, timeout=1)
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
time.sleep(0.5)
|
||||||
|
raise TimeoutError(f"wait_for_element timed out: {selectors}, last: {last_error}")
|
||||||
|
|
||||||
|
def element_exists(self, selectors: list) -> bool:
|
||||||
|
self._ensure_open()
|
||||||
|
for sel in selectors:
|
||||||
|
try:
|
||||||
|
self._find(sel, timeout=1)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
|
def switch_to_frame(self, selectors: list) -> None:
|
||||||
|
"""切换到匹配到的 iframe(客商选择弹窗等)。"""
|
||||||
|
self._ensure_open()
|
||||||
|
last_error = None
|
||||||
|
for sel in selectors:
|
||||||
|
try:
|
||||||
|
el = self._find(sel, timeout=1)
|
||||||
|
self._driver.switch_to.frame(el)
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
continue
|
||||||
|
raise RuntimeError(f"switch_to_frame failed: {selectors}, last: {last_error}")
|
||||||
|
|
||||||
|
def switch_to_default_content(self) -> None:
|
||||||
|
"""切回主文档(顶层 frame)。"""
|
||||||
|
self._ensure_open()
|
||||||
|
self._driver.switch_to.default_content()
|
||||||
|
|
||||||
|
def select_first(self, selectors: list, value: str) -> None:
|
||||||
|
self._ensure_open()
|
||||||
|
from selenium.webdriver.support.ui import Select
|
||||||
|
last_error = None
|
||||||
|
for sel in selectors:
|
||||||
|
try:
|
||||||
|
el = self._find(sel)
|
||||||
|
# 原生 <select>
|
||||||
|
try:
|
||||||
|
Select(el).select_by_visible_text(value)
|
||||||
|
time.sleep(0.3)
|
||||||
|
if self._selected_text_matches(el, value):
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# 兜底:JS 定位 option 并设置选中
|
||||||
|
self._select_via_js(el, value)
|
||||||
|
time.sleep(0.3)
|
||||||
|
if self._selected_text_matches(el, value):
|
||||||
|
return
|
||||||
|
raise RuntimeError(f"select_first 未选中目标项: {value}")
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
continue
|
||||||
|
raise RuntimeError(f"select_first failed: {selectors}, last: {last_error}")
|
||||||
|
|
||||||
|
def _selected_text_matches(self, el, value: str) -> bool:
|
||||||
|
try:
|
||||||
|
script = (
|
||||||
|
"var el = arguments[0];"
|
||||||
|
"if (el.selectedIndex != null) {"
|
||||||
|
" var o = el.options[el.selectedIndex];"
|
||||||
|
" return o ? (o.textContent || o.text || '').trim() : '';"
|
||||||
|
"}"
|
||||||
|
"return (el.value || '').trim();"
|
||||||
|
)
|
||||||
|
selected = self._driver.execute_script(script, el)
|
||||||
|
return (selected or "").strip() == str(value).strip()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _select_via_js(self, el, value: str) -> None:
|
||||||
|
self._driver.execute_script(
|
||||||
|
"""
|
||||||
|
var el = arguments[0];
|
||||||
|
var value = arguments[1];
|
||||||
|
var options = el.options || [];
|
||||||
|
for (var i = 0; i < options.length; i++) {
|
||||||
|
var t = (options[i].textContent || options[i].text || '').trim();
|
||||||
|
if (t === value || options[i].value === value) {
|
||||||
|
el.selectedIndex = i;
|
||||||
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
el,
|
||||||
|
str(value),
|
||||||
|
)
|
||||||
|
|
||||||
|
def upload_file_first(self, selectors: list, file_path: str) -> None:
|
||||||
|
self._ensure_open()
|
||||||
|
last_error = None
|
||||||
|
for sel in selectors:
|
||||||
|
try:
|
||||||
|
el = self._find(sel)
|
||||||
|
el.send_keys(file_path)
|
||||||
|
time.sleep(1)
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
continue
|
||||||
|
raise RuntimeError(f"upload_file_first failed: {selectors}, last: {last_error}")
|
||||||
|
|
||||||
|
def read_first(self, selectors: list, value_name: str = "") -> str:
|
||||||
|
self._ensure_open()
|
||||||
|
last_error = None
|
||||||
|
for sel in selectors:
|
||||||
|
try:
|
||||||
|
el = self._find(sel)
|
||||||
|
result = el.text or el.get_attribute("value") or ""
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
continue
|
||||||
|
raise RuntimeError(f"read_first failed: {selectors}, last: {last_error}")
|
||||||
|
|
||||||
|
def read_body_text(self) -> str:
|
||||||
|
self._ensure_open()
|
||||||
|
try:
|
||||||
|
return self._driver.find_element("tag name", "body").text
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def screenshot(self, name: str) -> str:
|
||||||
|
import os
|
||||||
|
save_dir = os.path.join(os.path.dirname(__file__), "screenshots")
|
||||||
|
os.makedirs(save_dir, exist_ok=True)
|
||||||
|
path = os.path.join(save_dir, f"{name}.png")
|
||||||
|
if self._driver:
|
||||||
|
self._driver.save_screenshot(path)
|
||||||
|
return path
|
||||||
|
|
@ -1,47 +1,27 @@
|
||||||
"""CONTRACT_CREATE node skeleton for YiSaiQi Windows integration.
|
"""CONTRACT_CREATE node: input building + state-center callback (runtime.shell) + Selenium drafting."""
|
||||||
|
|
||||||
This first version proves the state-center callback path and leaves the
|
|
||||||
contract-system page automation behind a single replaceable runner function.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import uuid
|
from dataclasses import dataclass, replace
|
||||||
import webbrowser
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from .apa_browser_adapter import ApaBrowserAdapter
|
||||||
from .callback_client import StateCenterClient
|
from .callback_client import StateCenterClient
|
||||||
from .contract_draft import ContractDraft, load_contract_draft
|
from .contract_draft import ContractDraft, contract_draft_from_dict, load_contract_draft
|
||||||
from .contract_flow_steps import ContractFlowContext, run_contract_create_flow
|
from .contract_flow_steps import ContractFlowContext, run_contract_create_flow
|
||||||
from .pdf_utils import ensure_merged_pdf
|
from .pdf_utils import ensure_merged_pdf
|
||||||
from .runtime.errors import LoginFailed as RuntimeLoginFailed
|
from .runtime.errors import NodeError, PdfMergeUnsupported, UnknownRpaError
|
||||||
from .runtime.errors import NodeError
|
|
||||||
from .runtime.errors import PdfMergeUnsupported as RuntimePdfMergeUnsupported
|
|
||||||
from .runtime.errors import SupplierContractDisabled as RuntimeSupplierContractDisabled
|
|
||||||
from .runtime.shell import execute_node
|
from .runtime.shell import execute_node
|
||||||
from .yisaiqi_adapter_template import YiSaiQiBrowserAdapter
|
|
||||||
|
|
||||||
NODE_CODE = "CONTRACT_CREATE"
|
NODE_CODE = "CONTRACT_CREATE"
|
||||||
CONTRACT_URL = "http://contract-test.gzmpc.com:8080/"
|
CONTRACT_URL = "http://contract-test.gzmpc.com:8080/"
|
||||||
|
|
||||||
|
|
||||||
class ContractCreateError(NodeError):
|
class ContractCreateError(NodeError):
|
||||||
pass
|
"""入参缺失 / 页面元素找不到 / 页面改版,均映射 UI_ELEMENT_NOT_FOUND。"""
|
||||||
|
|
||||||
|
|
||||||
class LoginFailed(RuntimeLoginFailed, ContractCreateError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class SupplierContractDisabled(RuntimeSupplierContractDisabled, ContractCreateError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class PdfMergeUnsupported(RuntimePdfMergeUnsupported, ContractCreateError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -55,9 +35,12 @@ class ContractCreateInput:
|
||||||
contract_url: str = CONTRACT_URL
|
contract_url: str = CONTRACT_URL
|
||||||
username: str = "test002"
|
username: str = "test002"
|
||||||
password: str = "1"
|
password: str = "1"
|
||||||
open_browser: bool = False
|
submit_mode: str = "submit"
|
||||||
|
order_no_list: tuple[str, ...] = ()
|
||||||
|
template_name: str = ""
|
||||||
draft_path: str | None = None
|
draft_path: str | None = None
|
||||||
merged_pdf_output: str | None = None
|
merged_pdf_output: str | None = None
|
||||||
|
flow_context: ContractFlowContext | None = None
|
||||||
|
|
||||||
|
|
||||||
ContractRunner = Callable[[ContractCreateInput], str]
|
ContractRunner = Callable[[ContractCreateInput], str]
|
||||||
|
|
@ -71,13 +54,24 @@ def execute_contract_create(
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
runner = contract_runner or create_contract_in_contract_system
|
runner = contract_runner or create_contract_in_contract_system
|
||||||
|
|
||||||
|
# dry_run:只跑到提交前,不回调状态中心(不 start/complete/fail)
|
||||||
|
if spec.submit_mode == "dry_run":
|
||||||
|
runner(spec)
|
||||||
|
return {"status": "dry_run", "submitted": False}
|
||||||
|
|
||||||
def _run() -> dict[str, Any]:
|
def _run() -> dict[str, Any]:
|
||||||
|
try:
|
||||||
contract_no = runner(spec)
|
contract_no = runner(spec)
|
||||||
payload = build_complete_payload(spec, contract_no)
|
except NodeError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise UnknownRpaError(str(exc)) from exc
|
||||||
|
if not contract_no:
|
||||||
|
raise ContractCreateError("submit 模式未读取到真实合同编号,无法 complete")
|
||||||
return {
|
return {
|
||||||
"businessRefs": payload["businessRefs"],
|
"businessRefs": {"contractSystemNo": contract_no},
|
||||||
"summary": payload["summary"],
|
"summary": _build_summary(spec),
|
||||||
"artifacts": payload["artifacts"],
|
"artifacts": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
return execute_node(
|
return execute_node(
|
||||||
|
|
@ -91,34 +85,46 @@ def execute_contract_create(
|
||||||
|
|
||||||
|
|
||||||
def create_contract_in_contract_system(spec: ContractCreateInput) -> str:
|
def create_contract_in_contract_system(spec: ContractCreateInput) -> str:
|
||||||
"""Draft the contract in the contract system and return contract number."""
|
"""在合同系统中起草合同并返回合同编号。"""
|
||||||
if spec.open_browser:
|
|
||||||
webbrowser.open(spec.contract_url)
|
|
||||||
if spec.contract_system_no:
|
if spec.contract_system_no:
|
||||||
return spec.contract_system_no
|
return spec.contract_system_no
|
||||||
|
|
||||||
|
ctx = spec.flow_context
|
||||||
|
if ctx is None:
|
||||||
if not spec.draft_path:
|
if not spec.draft_path:
|
||||||
raise ContractCreateError("draft_path is required for real contract drafting")
|
raise ContractCreateError("缺少 flow_context 或 draft_path,无法起草合同")
|
||||||
draft = load_contract_draft(spec.draft_path)
|
ctx = build_flow_context(spec, load_contract_draft(spec.draft_path))
|
||||||
ctx = build_flow_context(spec, draft)
|
|
||||||
browser = YiSaiQiBrowserAdapter()
|
browser = ApaBrowserAdapter(url=ctx.contract_url)
|
||||||
try:
|
|
||||||
return run_contract_create_flow(browser, ctx)
|
return run_contract_create_flow(browser, ctx)
|
||||||
except NotImplementedError as exc:
|
|
||||||
raise ContractCreateError(
|
|
||||||
"YiSaiQiBrowserAdapter still needs to be mapped to YiSaiQi designer activities"
|
def build_spec_from_data(data: dict[str, Any]) -> ContractCreateInput:
|
||||||
) from exc
|
draft = contract_draft_from_dict(data)
|
||||||
|
_validate_data(data, draft)
|
||||||
|
|
||||||
|
spec = ContractCreateInput(
|
||||||
|
job_id=_require(data, "jobId"),
|
||||||
|
supplier_name=draft.supplier_name or _require(data, "supplierName"),
|
||||||
|
order_date=draft.order_date or _require(data, "orderDate"),
|
||||||
|
source_type=draft.source_type or _require(data, "sourceType"),
|
||||||
|
contract_system_no=_opt(data.get("contractSystemNo")),
|
||||||
|
attempt_no=int(data.get("attemptNo", 1) or 1),
|
||||||
|
contract_url=_clean(data.get("contractUrl")) or os.getenv("CONTRACT_URL", CONTRACT_URL),
|
||||||
|
username=_clean(data.get("username")) or os.getenv("CONTRACT_USERNAME", "test002"),
|
||||||
|
password=str(data.get("password") or os.getenv("CONTRACT_PASSWORD", "1")),
|
||||||
|
submit_mode=draft.submit_mode,
|
||||||
|
order_no_list=tuple(draft.order_no_list),
|
||||||
|
template_name=draft.template_name,
|
||||||
|
merged_pdf_output=_opt(data.get("mergedPdfOutput")) or os.getenv("MERGED_PDF_OUTPUT"),
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx = build_flow_context(spec, draft)
|
||||||
|
return replace(spec, flow_context=ctx)
|
||||||
|
|
||||||
|
|
||||||
def build_flow_context(spec: ContractCreateInput, draft: ContractDraft) -> ContractFlowContext:
|
def build_flow_context(spec: ContractCreateInput, draft: ContractDraft) -> ContractFlowContext:
|
||||||
merged_pdf_path = draft.merged_pdf_path
|
merged_pdf_path = _resolve_merged_pdf(draft, spec)
|
||||||
if draft.source_type == "YYC":
|
|
||||||
output_path = spec.merged_pdf_output or str(
|
|
||||||
Path("runtime") / "contracts" / spec.job_id / "yyc-contract-merged.pdf"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
merged_pdf_path = ensure_merged_pdf(draft.platform_pdf_paths, output_path)
|
|
||||||
except (RuntimeError, ValueError) as exc:
|
|
||||||
raise PdfMergeUnsupported(str(exc)) from exc
|
|
||||||
return ContractFlowContext(
|
return ContractFlowContext(
|
||||||
contract_url=spec.contract_url,
|
contract_url=spec.contract_url,
|
||||||
username=spec.username,
|
username=spec.username,
|
||||||
|
|
@ -147,51 +153,143 @@ def build_flow_context(spec: ContractCreateInput, draft: ContractDraft) -> Contr
|
||||||
submit_mode=draft.submit_mode,
|
submit_mode=draft.submit_mode,
|
||||||
goods_lines=draft.goods_lines,
|
goods_lines=draft.goods_lines,
|
||||||
merged_pdf_path=merged_pdf_path,
|
merged_pdf_path=merged_pdf_path,
|
||||||
|
order_no_list=draft.order_no_list,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_complete_payload(spec: ContractCreateInput, contract_no: str) -> dict[str, Any]:
|
def _resolve_merged_pdf(draft: ContractDraft, spec: ContractCreateInput) -> str | None:
|
||||||
|
if draft.source_type != "YYC":
|
||||||
|
return None
|
||||||
|
if spec.submit_mode == "dry_run":
|
||||||
|
# dry_run 只跑到提交前,不校验也不上传 PDF
|
||||||
|
return None
|
||||||
|
|
||||||
|
paths = [str(p).strip() for p in draft.platform_pdf_paths if str(p).strip()]
|
||||||
|
if not paths:
|
||||||
|
raise PdfMergeUnsupported("YYC 缺少平台合同 PDF(platformPdfPaths 为空)")
|
||||||
|
for path in paths:
|
||||||
|
if not Path(path).exists():
|
||||||
|
raise PdfMergeUnsupported(f"平台合同 PDF 不存在:{path}")
|
||||||
|
if len(paths) == 1:
|
||||||
|
return paths[0]
|
||||||
|
|
||||||
|
output_path = spec.merged_pdf_output or str(
|
||||||
|
Path("runtime") / "contracts" / spec.job_id / "yyc-contract-merged.pdf"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return ensure_merged_pdf(paths, output_path)
|
||||||
|
except Exception as exc:
|
||||||
|
raise PdfMergeUnsupported(f"YYC 平台合同 PDF 合并失败:{exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _build_summary(spec: ContractCreateInput) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"attemptNo": spec.attempt_no,
|
|
||||||
"executorRunId": f"YSQ-CONTRACT-{uuid.uuid4().hex[:12]}",
|
|
||||||
"idempotencyKey": f"{spec.job_id}:{NODE_CODE}:{spec.attempt_no}:{contract_no}",
|
|
||||||
"businessRefs": {"contractSystemNo": contract_no},
|
|
||||||
"summary": {
|
|
||||||
"supplierName": spec.supplier_name,
|
"supplierName": spec.supplier_name,
|
||||||
"contractDate": spec.order_date,
|
"contractDate": spec.order_date,
|
||||||
"sourceType": spec.source_type,
|
"sourceType": spec.source_type,
|
||||||
"contractUrl": spec.contract_url,
|
"orderNoList": list(spec.order_no_list),
|
||||||
"uploadRule": "merge_pdf_before_upload" if spec.source_type == "YYC" else "skip_contract_upload",
|
"templateName": spec.template_name,
|
||||||
},
|
"pdfMerged": spec.source_type == "YYC",
|
||||||
"artifacts": [],
|
"submitted": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_fail_payload(spec: ContractCreateInput, exc: ContractCreateError) -> dict[str, Any]:
|
def _validate_data(data: dict[str, Any], draft: ContractDraft) -> None:
|
||||||
return {
|
missing = []
|
||||||
"attemptNo": spec.attempt_no,
|
for key in ("jobId", "supplierName", "orderDate", "sourceType", "customerCode"):
|
||||||
"errorCode": exc.error_code,
|
if not _str(data.get(key)):
|
||||||
"errorSummary": str(exc),
|
missing.append(key)
|
||||||
"retryable": exc.retryable,
|
|
||||||
"routeRole": exc.route_role,
|
if draft.submit_mode not in ("dry_run", "submit"):
|
||||||
"artifacts": [],
|
raise ContractCreateError(f"submitMode 非法:{draft.submit_mode}(仅支持 dry_run / submit)")
|
||||||
}
|
|
||||||
|
if not _str(draft.amount):
|
||||||
|
missing.append("amount")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
float(draft.amount)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ContractCreateError(f"amount 不是合法数字:{draft.amount}")
|
||||||
|
|
||||||
|
if not draft.goods_lines:
|
||||||
|
raise ContractCreateError("goodsLines 为空,至少需要一行货品明细")
|
||||||
|
for index, line in enumerate(draft.goods_lines, start=1):
|
||||||
|
if not _str(line.goods_code) or not _str(line.goods_name) or line.quantity <= 0:
|
||||||
|
raise ContractCreateError(
|
||||||
|
f"goodsLines[{index}] 缺少 goodsCode/goodsName,或 quantity<=0"
|
||||||
|
)
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
raise ContractCreateError("缺少必填字段:" + ", ".join(missing))
|
||||||
|
|
||||||
|
|
||||||
|
def _require(data: dict[str, Any], key: str) -> str:
|
||||||
|
value = _str(data.get(key))
|
||||||
|
if not value:
|
||||||
|
raise ContractCreateError(f"缺少必填字段:{key}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _opt(value: Any) -> str | None:
|
||||||
|
value = _str(value)
|
||||||
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
|
def _str(value: Any) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
return str(value).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(value: Any) -> str:
|
||||||
|
value = _str(value)
|
||||||
|
if len(value) >= 2 and value[0] == "`" and value[-1] == "`":
|
||||||
|
value = value[1:-1].strip()
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def load_input_data(raw: str) -> dict[str, Any]:
|
||||||
|
"""解析入参:优先按 JSON 字符串,其次按 JSON 文件路径。"""
|
||||||
|
raw = (raw or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
obj = json.loads(raw)
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return obj
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if os.path.isfile(raw):
|
||||||
|
with open(raw, "r", encoding="utf-8") as file:
|
||||||
|
return json.load(file)
|
||||||
|
raise ContractCreateError(f"无法解析入参:既不是合法 JSON,也不是存在的文件路径:{raw}")
|
||||||
|
|
||||||
|
|
||||||
|
def run_contract_create(
|
||||||
|
data: dict[str, Any],
|
||||||
|
*,
|
||||||
|
state_center_url: str,
|
||||||
|
agent_token: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
spec = build_spec_from_data(data)
|
||||||
|
client = StateCenterClient(state_center_url, agent_token)
|
||||||
|
return execute_contract_create(spec, client)
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser(description="Run YiSaiQi CONTRACT_CREATE callback skeleton.")
|
parser = argparse.ArgumentParser(description="Run YiSaiQi CONTRACT_CREATE node.")
|
||||||
parser.add_argument("--state-center-url", default=os.getenv("STATE_CENTER_URL", "http://127.0.0.1:8000"))
|
parser.add_argument("--state-center-url", default=os.getenv("STATE_CENTER_URL", "http://127.0.0.1:8000"))
|
||||||
parser.add_argument("--token", default=os.getenv("AGENT_TOKEN", "dev-agent-token"))
|
parser.add_argument("--token", default=os.getenv("AGENT_TOKEN", "dev-agent-token"))
|
||||||
parser.add_argument("--job-id", required=True)
|
parser.add_argument("--input-json", default=os.getenv("CONTRACT_INPUT_JSON"))
|
||||||
parser.add_argument("--supplier-name", required=True)
|
parser.add_argument("--job-id")
|
||||||
parser.add_argument("--order-date", required=True)
|
parser.add_argument("--supplier-name")
|
||||||
|
parser.add_argument("--order-date")
|
||||||
parser.add_argument("--source-type", choices=("YYC", "XYY", "OFFLINE"), default="YYC")
|
parser.add_argument("--source-type", choices=("YYC", "XYY", "OFFLINE"), default="YYC")
|
||||||
parser.add_argument("--contract-system-no", default=os.getenv("CONTRACT_SYSTEM_NO"))
|
parser.add_argument("--contract-system-no", default=os.getenv("CONTRACT_SYSTEM_NO"))
|
||||||
parser.add_argument("--attempt-no", type=int, default=1)
|
parser.add_argument("--attempt-no", type=int, default=1)
|
||||||
parser.add_argument("--contract-url", default=os.getenv("CONTRACT_URL", CONTRACT_URL))
|
parser.add_argument("--contract-url", default=os.getenv("CONTRACT_URL", CONTRACT_URL))
|
||||||
parser.add_argument("--username", default=os.getenv("CONTRACT_USERNAME", "test002"))
|
parser.add_argument("--username", default=os.getenv("CONTRACT_USERNAME", "test002"))
|
||||||
parser.add_argument("--password", default=os.getenv("CONTRACT_PASSWORD", "1"))
|
parser.add_argument("--password", default=os.getenv("CONTRACT_PASSWORD", "1"))
|
||||||
parser.add_argument("--open-browser", action="store_true")
|
|
||||||
parser.add_argument("--draft-path", default=os.getenv("CONTRACT_DRAFT_PATH"))
|
parser.add_argument("--draft-path", default=os.getenv("CONTRACT_DRAFT_PATH"))
|
||||||
parser.add_argument("--merged-pdf-output", default=os.getenv("MERGED_PDF_OUTPUT"))
|
parser.add_argument("--merged-pdf-output", default=os.getenv("MERGED_PDF_OUTPUT"))
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
@ -199,6 +297,13 @@ def parse_args() -> argparse.Namespace:
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
|
client = StateCenterClient(args.state_center_url, args.token)
|
||||||
|
|
||||||
|
if args.input_json:
|
||||||
|
spec = build_spec_from_data(load_input_data(args.input_json))
|
||||||
|
else:
|
||||||
|
if not (args.job_id and args.supplier_name and args.order_date):
|
||||||
|
raise SystemExit("缺少 --job-id/--supplier-name/--order-date 或 --input-json")
|
||||||
spec = ContractCreateInput(
|
spec = ContractCreateInput(
|
||||||
job_id=args.job_id,
|
job_id=args.job_id,
|
||||||
supplier_name=args.supplier_name,
|
supplier_name=args.supplier_name,
|
||||||
|
|
@ -209,13 +314,12 @@ def main() -> int:
|
||||||
contract_url=args.contract_url,
|
contract_url=args.contract_url,
|
||||||
username=args.username,
|
username=args.username,
|
||||||
password=args.password,
|
password=args.password,
|
||||||
open_browser=args.open_browser,
|
|
||||||
draft_path=args.draft_path,
|
draft_path=args.draft_path,
|
||||||
merged_pdf_output=args.merged_pdf_output,
|
merged_pdf_output=args.merged_pdf_output,
|
||||||
)
|
)
|
||||||
client = StateCenterClient(args.state_center_url, args.token)
|
|
||||||
result = execute_contract_create(spec, client)
|
result = execute_contract_create(spec, client)
|
||||||
print(result)
|
print(json.dumps(result, ensure_ascii=False))
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ class ContractDraft:
|
||||||
goods_lines: list[ContractGoodsLine] = field(default_factory=list)
|
goods_lines: list[ContractGoodsLine] = field(default_factory=list)
|
||||||
platform_pdf_paths: list[str] = field(default_factory=list)
|
platform_pdf_paths: list[str] = field(default_factory=list)
|
||||||
merged_pdf_path: str | None = None
|
merged_pdf_path: str | None = None
|
||||||
|
order_no_list: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
def load_contract_draft(path: str | Path) -> ContractDraft:
|
def load_contract_draft(path: str | Path) -> ContractDraft:
|
||||||
|
|
@ -52,7 +53,9 @@ def contract_draft_from_dict(data: dict[str, Any]) -> ContractDraft:
|
||||||
approval_no=str(item.get("approvalNo", "")),
|
approval_no=str(item.get("approvalNo", "")),
|
||||||
spec=str(item.get("spec", "")),
|
spec=str(item.get("spec", "")),
|
||||||
unit=str(item.get("unit", "")),
|
unit=str(item.get("unit", "")),
|
||||||
quantity=float(item.get("quantity", 0)),
|
quantity=float(item.get("quantity", 0) or 0),
|
||||||
|
mdm_code=str(item.get("mdmCode", "")),
|
||||||
|
unit_price=str(item.get("unitPrice", "")),
|
||||||
)
|
)
|
||||||
for item in data.get("goodsLines", [])
|
for item in data.get("goodsLines", [])
|
||||||
]
|
]
|
||||||
|
|
@ -82,4 +85,5 @@ def contract_draft_from_dict(data: dict[str, Any]) -> ContractDraft:
|
||||||
goods_lines=goods_lines,
|
goods_lines=goods_lines,
|
||||||
platform_pdf_paths=[str(path) for path in data.get("platformPdfPaths", [])],
|
platform_pdf_paths=[str(path) for path in data.get("platformPdfPaths", [])],
|
||||||
merged_pdf_path=data.get("mergedPdfPath"),
|
merged_pdf_path=data.get("mergedPdfPath"),
|
||||||
|
order_no_list=[str(x) for x in data.get("orderNoList", [])],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -5,18 +5,32 @@ node or one script block behind a flowchart node.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
|
||||||
|
from .runtime.errors import (
|
||||||
|
LoginFailed,
|
||||||
|
NodeError,
|
||||||
|
PdfMergeUnsupported,
|
||||||
|
SupplierContractDisabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BrowserAdapter(Protocol):
|
class BrowserAdapter(Protocol):
|
||||||
def open_url(self, url: str) -> None: ...
|
def open_url(self, url: str) -> None: ...
|
||||||
def fill_first(self, selectors: list[str], value: str) -> None: ...
|
def fill_first(self, selectors: list[str], value: str) -> None: ...
|
||||||
def click_first(self, selectors: list[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_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_first(self, selectors: list[str], value: str) -> None: ...
|
||||||
def upload_file_first(self, selectors: list[str], file_path: 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_first(self, selectors: list[str], value_name: str = "") -> str: ...
|
||||||
|
def read_body_text(self) -> str: ...
|
||||||
def screenshot(self, name: str) -> str: ...
|
def screenshot(self, name: str) -> str: ...
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -29,6 +43,8 @@ class ContractGoodsLine:
|
||||||
spec: str
|
spec: str
|
||||||
unit: str
|
unit: str
|
||||||
quantity: float
|
quantity: float
|
||||||
|
mdm_code: str = ""
|
||||||
|
unit_price: str = ""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -60,6 +76,7 @@ class ContractFlowContext:
|
||||||
submit_mode: str = "submit"
|
submit_mode: str = "submit"
|
||||||
goods_lines: list[ContractGoodsLine] = field(default_factory=list)
|
goods_lines: list[ContractGoodsLine] = field(default_factory=list)
|
||||||
merged_pdf_path: str | None = None
|
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:
|
def run_contract_create_flow(browser: BrowserAdapter, ctx: ContractFlowContext) -> str:
|
||||||
|
|
@ -78,115 +95,256 @@ def run_contract_create_flow(browser: BrowserAdapter, ctx: ContractFlowContext)
|
||||||
return read_contract_number(browser)
|
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:
|
def open_contract_system(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||||
browser.open_url(ctx.contract_url)
|
browser.open_url(ctx.contract_url)
|
||||||
|
|
||||||
|
|
||||||
def login_contract_system(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
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_username"]', 'input[placeholder="用户名"]'], ctx.username)
|
||||||
browser.fill_first(['input[name="j_password"]', 'input[placeholder="密码"]'], ctx.password)
|
browser.fill_first(['input[name="j_password"]', 'input[placeholder="密码"]'], ctx.password)
|
||||||
browser.click_first(['input[name="btn_submit"]', 'a[href*="btn_submit"]', 'text=登录'])
|
browser.click_first(['a[class*="login_button"]', 'a:contains("登录")', 'input[name="btn_submit"]'])
|
||||||
browser.wait_for_text("合同", timeout_seconds=20)
|
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:
|
def enter_contract_create(browser: BrowserAdapter) -> None:
|
||||||
browser.click_first(["text=合同管理", "aria=合同管理"])
|
# 登录后直接导航到起草表单页,跳过模板选择(前端委托事件 Selenium 点击无效)
|
||||||
browser.click_first(["text=合同起草", "aria=合同起草"])
|
browser.open_url(FORM_PAGE_URL)
|
||||||
browser.wait_for_text("最近使用模板", timeout_seconds=20)
|
browser.wait_for_text("客户编码", timeout_seconds=20)
|
||||||
|
|
||||||
|
|
||||||
def select_contract_template(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
def select_contract_template(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||||
template_text = ctx.template_name or (
|
# 已通过 URL 直接指定模板,本步骤仅兜底:若尚未进入表单则再次导航
|
||||||
"非格式--采购协议" if ctx.template_type == "NON_FORMAT" else "购销协议书(2025)"
|
if "客户编码" not in browser.read_body_text():
|
||||||
)
|
browser.open_url(FORM_PAGE_URL)
|
||||||
browser.click_first([f"text={template_text}", f"text={template_text}...", f"title={template_text}"])
|
browser.wait_for_text("客户编码", timeout_seconds=20)
|
||||||
browser.wait_for_text("合同起草", timeout_seconds=20)
|
|
||||||
|
|
||||||
|
|
||||||
def fill_contract_signatory_info(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
def fill_contract_signatory_info(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||||
customer_name = ctx.customer_name or ctx.supplier_name
|
expected_name = (ctx.customer_name or ctx.supplier_name).strip()
|
||||||
if ctx.customer_code:
|
if not ctx.customer_code:
|
||||||
browser.fill_first(
|
raise SupplierContractDisabled("客商操作码(customerCode)为空,无法加载客商")
|
||||||
['label=客户编码', 'input[name="fdCustomerCode"]', 'input[placeholder*="客户编码"]'],
|
|
||||||
ctx.customer_code,
|
# 点击客户名称字段旁的「选择」按钮打开客商选择窗口,按编码搜索并带回客商
|
||||||
|
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 customer_name:
|
|
||||||
browser.fill_first(
|
if expected_name and loaded_name != expected_name:
|
||||||
['label=客户名称', 'input[name="fdCustomerName"]', 'input[placeholder*="客户名称"]'],
|
browser.screenshot("contract-customer-name-mismatch")
|
||||||
customer_name,
|
raise SupplierContractDisabled(
|
||||||
|
f"客商名称校验不通过:期望「{expected_name}」,实际「{loaded_name}」"
|
||||||
)
|
)
|
||||||
|
|
||||||
browser.screenshot("contract-signatory-info-filled")
|
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:
|
def fill_contract_basic_info(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||||
browser.select_first(['label=合同版本类型', 'select[name="fdTemplateType"]'], contract_version_label(ctx))
|
|
||||||
if ctx.contract_category:
|
if ctx.contract_category:
|
||||||
browser.select_first(['label=合同类别', 'select[name="fdContractCategory"]'], ctx.contract_category)
|
browser.fill_first([F["contract_category"]], ctx.contract_category)
|
||||||
browser.select_first(['label=结算类型', 'select[name="fdSettlementType"]'], ctx.settlement_type)
|
if ctx.settlement_type:
|
||||||
|
_select_settlement_type(browser, ctx.settlement_type)
|
||||||
if ctx.contract_background:
|
if ctx.contract_background:
|
||||||
browser.fill_first(
|
browser.fill_first([F["contract_background"]], ctx.contract_background)
|
||||||
['label=合同背景概述', 'textarea[name="fdContractBackground"]'],
|
# 合同性质默认主合同(页面已勾选),仅在需要从合同时切换
|
||||||
ctx.contract_background,
|
if ctx.contract_nature and ctx.contract_nature != "主合同":
|
||||||
)
|
browser.click_first(['input[name="fdType"][value="01"]'])
|
||||||
browser.click_first([f"label={ctx.contract_nature}", f"text={ctx.contract_nature}"])
|
|
||||||
browser.screenshot("contract-basic-info-filled")
|
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 NodeError(f"结算方式下拉未选中,仍停留在「==请选择==」,期望「{value}」")
|
||||||
|
|
||||||
|
|
||||||
def fill_contract_amount_and_dates(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
def fill_contract_amount_and_dates(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||||
browser.select_first(['label=合同金额', 'select[name="fdCurrency"]'], ctx.amount_currency)
|
|
||||||
if ctx.amount:
|
if ctx.amount:
|
||||||
browser.fill_first(['label=金额(元)', 'input[name="fdAmount"]'], ctx.amount)
|
browser.fill_first([F["amount"]], ctx.amount)
|
||||||
browser.fill_first(
|
browser.fill_first([F["sign_date"]], ctx.order_date)
|
||||||
['label=合同签订日期', 'input[name="fdSignDate"]', 'input[placeholder*="合同签订"]'],
|
browser.fill_first([F["begin_date"]], ctx.order_date)
|
||||||
ctx.order_date,
|
|
||||||
)
|
|
||||||
browser.fill_first(
|
|
||||||
['label=合同开始日期', 'input[name="fdStartDate"]', 'input[placeholder*="合同开始"]'],
|
|
||||||
ctx.order_date,
|
|
||||||
)
|
|
||||||
if ctx.contract_end_date:
|
if ctx.contract_end_date:
|
||||||
browser.fill_first(
|
browser.fill_first([F["end_date"]], ctx.contract_end_date)
|
||||||
['label=合同结束日期', 'input[name="fdEndDate"]', 'input[placeholder*="合同结束"]'],
|
|
||||||
ctx.contract_end_date,
|
|
||||||
)
|
|
||||||
browser.screenshot("contract-amount-and-dates-filled")
|
browser.screenshot("contract-amount-and-dates-filled")
|
||||||
|
|
||||||
|
|
||||||
def fill_contract_goods_lines(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
def fill_contract_goods_lines(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||||
for index, line in enumerate(ctx.goods_lines, start=1):
|
for index, line in enumerate(ctx.goods_lines):
|
||||||
if index > 1:
|
row = index # 第 1 行 row=0,第 2 行 row=1,依此类推
|
||||||
browser.click_first(["text=新增明细", "text=添加货品", "text=新增行"])
|
if index > 0:
|
||||||
prefix = f"明细{index}"
|
browser.click_first(ADD_ROW_SELECTORS)
|
||||||
browser.fill_first([f'label={prefix}操作码', 'input[name="operationCode"]'], line.operation_code)
|
browser.wait_for_element([_goods_selector("goods_code", row)], timeout_seconds=15)
|
||||||
browser.fill_first([f'label={prefix}货品编码', 'input[name="goodsCode"]'], line.goods_code)
|
|
||||||
browser.fill_first([f'label={prefix}名称', 'input[name="goodsName"]'], line.goods_name)
|
browser.fill_first([_goods_selector("goods_code", row)], line.goods_code)
|
||||||
browser.fill_first([f'label={prefix}国药准字', 'input[name="approvalNo"]'], line.approval_no)
|
browser.fill_first([_goods_selector("goods_name", row)], line.goods_name)
|
||||||
browser.fill_first([f'label={prefix}规格', 'input[name="spec"]'], line.spec)
|
if line.mdm_code:
|
||||||
browser.fill_first([f'label={prefix}单位', 'input[name="unit"]'], line.unit)
|
browser.fill_first([_goods_selector("goods_mdm", row)], line.mdm_code)
|
||||||
browser.fill_first([f'label={prefix}数量', 'input[name="quantity"]'], str(line.quantity))
|
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")
|
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:
|
def fill_contract_payment_info(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||||
browser.select_first(['label=是否重大合同', 'select[name="fdIsMajorContract"]'], ctx.is_major_contract)
|
browser.fill_first([F["is_major_contract"]], ctx.is_major_contract)
|
||||||
browser.select_first(['label=是否预付款', 'select[name="fdIsPrepayment"]'], ctx.is_prepayment)
|
browser.fill_first([F["is_prepayment"]], ctx.is_prepayment)
|
||||||
if ctx.belong_year:
|
if ctx.belong_year:
|
||||||
browser.fill_first(['label=合同归属年份', 'input[name="fdBelongYear"]'], ctx.belong_year)
|
browser.fill_first([F["belong_year"]], ctx.belong_year)
|
||||||
browser.select_first(['label=付款方式', 'select[name="fdPaymentMethod"]', 'select[name="paymentMethod"]'], ctx.payment_method)
|
if ctx.payment_method:
|
||||||
browser.fill_first(['label=付款期限', 'input[name="fdPaymentDays"]', 'input[name="paymentDays"]'], ctx.payment_days)
|
browser.fill_first([F["payment_method"]], ctx.payment_method)
|
||||||
browser.select_first(['label=合同状态', 'select[name="fdContractStatus"]'], ctx.contract_status)
|
if ctx.payment_days:
|
||||||
browser.select_first(
|
browser.fill_first([F["payment_days"]], ctx.payment_days)
|
||||||
['label=是否包含血液制品', 'select[name="fdContainsBloodProducts"]'],
|
if ctx.contract_status:
|
||||||
ctx.contains_blood_products,
|
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")
|
browser.screenshot("contract-payment-filled")
|
||||||
|
|
||||||
|
|
||||||
def upload_contract_document_if_needed(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
def upload_contract_document_if_needed(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||||
if ctx.source_type != "YYC":
|
if ctx.source_type != "YYC":
|
||||||
|
# XYY / OFFLINE:跳过合同证文上传
|
||||||
|
return
|
||||||
|
if ctx.submit_mode == "dry_run":
|
||||||
|
# dry_run 只跑到提交前,不真正上传附件
|
||||||
|
browser.screenshot("contract-pdf-upload-skipped")
|
||||||
return
|
return
|
||||||
if not ctx.merged_pdf_path:
|
if not ctx.merged_pdf_path:
|
||||||
raise ValueError("YYC contract creation requires merged_pdf_path before upload")
|
raise PdfMergeUnsupported("YYC 合同起草缺少合并后的 PDF 路径,无法上传")
|
||||||
browser.upload_file_first(['input[type="file"]', "label=合同证文上传"], ctx.merged_pdf_path)
|
browser.upload_file_first(['input[type="file"]', "label=合同证文上传"], ctx.merged_pdf_path)
|
||||||
browser.screenshot("contract-pdf-uploaded")
|
browser.screenshot("contract-pdf-uploaded")
|
||||||
|
|
||||||
|
|
@ -208,5 +366,14 @@ def read_contract_number(browser: BrowserAdapter) -> str:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def contract_version_label(ctx: ContractFlowContext) -> str:
|
||||||
return "非格式合同" if ctx.template_type == "NON_FORMAT" else "格式合同"
|
return "非格式合同" if ctx.template_type == "NON_FORMAT" else "格式合同"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"jobId": "TEST-CONTRACT-20260813-001",
|
||||||
|
"supplierName": "重庆亿昊药业有限公司",
|
||||||
|
"orderDate": "2026-08-12",
|
||||||
|
"sourceType": "YYC",
|
||||||
|
"templateType": "NON_FORMAT",
|
||||||
|
"templateName": "非格式--采购协议",
|
||||||
|
"customerCode": "DW0100046361",
|
||||||
|
"customerName": "重庆亿昊药业有限公司",
|
||||||
|
"contractCategory": "采购协议",
|
||||||
|
"settlementType": "付款",
|
||||||
|
"contractBackground": "自动化测试合同起草",
|
||||||
|
"contractNature": "主合同",
|
||||||
|
"amountCurrency": "人民币",
|
||||||
|
"amount": "50000.00",
|
||||||
|
"contractEndDate": "2026-12-31",
|
||||||
|
"isMajorContract": "否",
|
||||||
|
"isPrepayment": "否",
|
||||||
|
"belongYear": "2026",
|
||||||
|
"paymentMethod": "账期支付",
|
||||||
|
"paymentDays": "30",
|
||||||
|
"contractStatus": "正式合同",
|
||||||
|
"containsBloodProducts": "否",
|
||||||
|
"submitMode": "dry_run",
|
||||||
|
"orderNoList": [],
|
||||||
|
"platformPdfPaths": [],
|
||||||
|
"goodsLines": [
|
||||||
|
{
|
||||||
|
"operationCode": "ADD",
|
||||||
|
"goodsCode": "YP-001",
|
||||||
|
"goodsName": "测试药品A",
|
||||||
|
"approvalNo": "国药准字H00000001",
|
||||||
|
"spec": "50mg*30片",
|
||||||
|
"unit": "盒",
|
||||||
|
"quantity": 10,
|
||||||
|
"mdmCode": "",
|
||||||
|
"unitPrice": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stateCenterUrl": "http://127.0.0.1:8000",
|
||||||
|
"agentToken": "dev-agent-token"
|
||||||
|
}
|
||||||
|
|
@ -1 +1,4 @@
|
||||||
pypdf==5.1.0
|
pypdf==5.1.0
|
||||||
|
selenium>=4.15.0
|
||||||
|
webdriver-manager>=4.0.0
|
||||||
|
requests>=2.31.0
|
||||||
|
|
|
||||||
|
|
@ -23,3 +23,9 @@ class PdfMergeUnsupported(NodeError):
|
||||||
error_code = "CAP_PDF_MERGE_UNSUPPORTED"
|
error_code = "CAP_PDF_MERGE_UNSUPPORTED"
|
||||||
retryable = False
|
retryable = False
|
||||||
route_role = "OPS"
|
route_role = "OPS"
|
||||||
|
|
||||||
|
|
||||||
|
class UnknownRpaError(NodeError):
|
||||||
|
error_code = "RPA_UNKNOWN_ERROR"
|
||||||
|
retryable = True
|
||||||
|
route_role = "OPS"
|
||||||
|
|
|
||||||
|
|
@ -1,116 +1,42 @@
|
||||||
{
|
{
|
||||||
"knownLoginSelectors": {
|
"login": {
|
||||||
"username": "input[name='j_username']",
|
"username": "input[name='j_username']",
|
||||||
"password": "input[name='j_password']",
|
"password": "input[name='j_password']",
|
||||||
"submit": "input[name='btn_submit']"
|
"submit": "input[name='btn_submit']",
|
||||||
|
"submitLink": "text=登录"
|
||||||
},
|
},
|
||||||
"candidateSelectors": {
|
"navigation": {
|
||||||
"contractManagementMenu": [
|
"draftPage": "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",
|
||||||
"text=合同管理",
|
"templatePrefix": "非格式-采购协议"
|
||||||
"aria=合同管理"
|
},
|
||||||
],
|
"form": {
|
||||||
"contractDraftMenu": [
|
"customerCode": "input[name='extendDataFormInfo.value(fd_customeropcode)']",
|
||||||
"text=合同起草",
|
"customerName": "input[name='extendDataFormInfo.value(fd_customername)']",
|
||||||
"aria=合同起草"
|
"contractCategory": "input[name='extendDataFormInfo.value(fd_contract_cate_type)']",
|
||||||
],
|
"settlementType": "select#fdSettype",
|
||||||
"recentNonFormatPurchaseTemplate": [
|
"contractBackground": "textarea[name='extendDataFormInfo.value(fd_desc_info)']",
|
||||||
"text=非格式--采购协议",
|
"contractNatureMain": "input[name='fdType'][value='00']",
|
||||||
"text=非格式--采购协议...",
|
"contractNatureSub": "input[name='fdType'][value='01']",
|
||||||
"title=非格式--采购协议"
|
"amount": "input[name='fdAccount']",
|
||||||
],
|
"signDate": "input[name='fdSigndate']",
|
||||||
"customerCode": [
|
"beginDate": "input[name='fdEffedate']",
|
||||||
"label=客户编码",
|
"endDate": "input[name='fdEnddate']",
|
||||||
"input[name='fdCustomerCode']",
|
"isMajorContract": "input[name='extendDataFormInfo.value(fd_is_major_contract)']",
|
||||||
"input[placeholder*='客户编码']"
|
"isPrepayment": "input[name='extendDataFormInfo.value(fd_advance_amout)']",
|
||||||
],
|
"belongYear": "input[name='extendDataFormInfo.value(fd_contract_year_text)']",
|
||||||
"customerName": [
|
"paymentMethod": "input[name='extendDataFormInfo.value(fd_paytype)']",
|
||||||
"label=客户名称",
|
"paymentDays": "input[name='extendDataFormInfo.value(fd_pay_time_limit)']",
|
||||||
"input[name='fdCustomerName']",
|
"contractStatus": "input[name='extendDataFormInfo.value(fd_suconstatus)']",
|
||||||
"input[placeholder*='客户名称']"
|
"containsBloodProducts": "input[name='extendDataFormInfo.value(fd_3d1ce3953f1162)']",
|
||||||
],
|
"goodsOperationCode": "input[name='extendDataFormInfo.value(fd_detail1.0.fd_opcode)']",
|
||||||
"contractVersionType": [
|
"goodsCode": "input[name='extendDataFormInfo.value(fd_detail1.0.fd_opcode)']",
|
||||||
"label=合同版本类型",
|
"goodsName": "input[name='extendDataFormInfo.value(fd_detail1.0.fd_goodsname)']",
|
||||||
"select[name='fdTemplateType']"
|
"goodsMdmCode": "input[name='extendDataFormInfo.value(fd_detail1.0.fd_mdmCode)']",
|
||||||
],
|
"goodsApprovalNo": "input[name='extendDataFormInfo.value(fd_detail1.0.fd_registdocno)']",
|
||||||
"contractCategory": [
|
"goodsSpec": "input[name='extendDataFormInfo.value(fd_detail1.0.fd_goodstype)']",
|
||||||
"label=合同类别",
|
"goodsUnit": "input[name='extendDataFormInfo.value(fd_detail1.0.fd_goodsunit)']",
|
||||||
"select[name='fdContractCategory']"
|
"goodsQuantity": "input[name='extendDataFormInfo.value(fd_detail1.0.fd_goodsqty)']",
|
||||||
],
|
"goodsUnitPrice": "input[name='extendDataFormInfo.value(fd_detail1.0.fd_unitprice)']",
|
||||||
"settlementType": [
|
"submit": "text=提交"
|
||||||
"label=结算类型",
|
|
||||||
"select[name='fdSettlementType']"
|
|
||||||
],
|
|
||||||
"contractBackground": [
|
|
||||||
"label=合同背景概述",
|
|
||||||
"textarea[name='fdContractBackground']"
|
|
||||||
],
|
|
||||||
"contractNatureMain": [
|
|
||||||
"label=主合同",
|
|
||||||
"text=主合同"
|
|
||||||
],
|
|
||||||
"amountCurrency": [
|
|
||||||
"label=合同金额",
|
|
||||||
"select[name='fdCurrency']"
|
|
||||||
],
|
|
||||||
"amount": [
|
|
||||||
"label=金额(元)",
|
|
||||||
"input[name='fdAmount']"
|
|
||||||
],
|
|
||||||
"signDate": [
|
|
||||||
"label=合同签订日期",
|
|
||||||
"input[name='fdSignDate']",
|
|
||||||
"input[placeholder*='合同签订']"
|
|
||||||
],
|
|
||||||
"beginDate": [
|
|
||||||
"label=合同开始日期",
|
|
||||||
"input[name='fdStartDate']",
|
|
||||||
"input[placeholder*='合同开始']"
|
|
||||||
],
|
|
||||||
"endDate": [
|
|
||||||
"label=合同结束日期",
|
|
||||||
"input[name='fdEndDate']",
|
|
||||||
"input[placeholder*='合同结束']"
|
|
||||||
],
|
|
||||||
"isMajorContract": [
|
|
||||||
"label=是否重大合同",
|
|
||||||
"select[name='fdIsMajorContract']"
|
|
||||||
],
|
|
||||||
"isPrepayment": [
|
|
||||||
"label=是否预付款",
|
|
||||||
"select[name='fdIsPrepayment']"
|
|
||||||
],
|
|
||||||
"belongYear": [
|
|
||||||
"label=合同归属年份",
|
|
||||||
"input[name='fdBelongYear']"
|
|
||||||
],
|
|
||||||
"paymentMethod": [
|
|
||||||
"label=付款方式",
|
|
||||||
"select[name='fdPaymentMethod']",
|
|
||||||
"select[name='paymentMethod']"
|
|
||||||
],
|
|
||||||
"paymentDays": [
|
|
||||||
"label=付款期限",
|
|
||||||
"input[name='fdPaymentDays']",
|
|
||||||
"input[name='paymentDays']"
|
|
||||||
],
|
|
||||||
"contractStatus": [
|
|
||||||
"label=合同状态",
|
|
||||||
"select[name='fdContractStatus']"
|
|
||||||
],
|
|
||||||
"containsBloodProducts": [
|
|
||||||
"label=是否包含血液制品",
|
|
||||||
"select[name='fdContainsBloodProducts']"
|
|
||||||
],
|
|
||||||
"submit": [
|
|
||||||
"text=提交",
|
|
||||||
"text=提交送审",
|
|
||||||
"text=送审"
|
|
||||||
],
|
|
||||||
"contractNumber": [
|
|
||||||
"input[name='contractNo']",
|
|
||||||
"input[name='contractSystemNo']",
|
|
||||||
"label=合同编号",
|
|
||||||
"text=合同编号"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
{
|
||||||
|
"LineList": [
|
||||||
|
{
|
||||||
|
"LineColor": "255, 255, 255",
|
||||||
|
"LineCommented": 0,
|
||||||
|
"LineEnd": {
|
||||||
|
"Pidx": "20260813200000/EPin_Flow_In",
|
||||||
|
"Px": "512",
|
||||||
|
"Py": "300"
|
||||||
|
},
|
||||||
|
"LineNote": "",
|
||||||
|
"LineSplit": {
|
||||||
|
"Px": "",
|
||||||
|
"Py": ""
|
||||||
|
},
|
||||||
|
"LineStart": {
|
||||||
|
"Pidx": "20260813100000/EPin_Flow_Out",
|
||||||
|
"Px": "457",
|
||||||
|
"Py": "246"
|
||||||
|
},
|
||||||
|
"LineTag": "20260813300000",
|
||||||
|
"LineType": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"NodeList": [
|
||||||
|
{
|
||||||
|
"NodePanelData": {
|
||||||
|
"ExpandedGroups": [],
|
||||||
|
"InputProperty": {
|
||||||
|
"mouse_x": 0,
|
||||||
|
"mouse_y": 0
|
||||||
|
},
|
||||||
|
"PubProperty": "",
|
||||||
|
"RetPins": []
|
||||||
|
},
|
||||||
|
"NodeProperty": {
|
||||||
|
"EP_CONTROL": {
|
||||||
|
"EP_CONTROL_IMPORT": "{\n \"PImport\": \"\",\n \"PBCode\": \"aW1wb3J0IHN5cywgb3MsIGpzb24KZnJvbSBwYXRobGliIGltcG9ydCBQYXRoCgpfUkVQT19ST09UID0gbmV4dCgKICAgIHAgZm9yIHAgaW4gUGF0aChfX2ZpbGVfXykucmVzb2x2ZSgpLnBhcmVudHMKICAgIGlmIChwIC8gInJwYV95aXNhaXFpIiAvICJfX2luaXRfXy5weSIpLmV4aXN0cygpCikKaWYgc3RyKF9SRVBPX1JPT1QpIG5vdCBpbiBzeXMucGF0aDoKICAgIHN5cy5wYXRoLmluc2VydCgwLCBzdHIoX1JFUE9fUk9PVCkpCgpmcm9tIHJwYV95aXNhaXFpLmNvbnRyYWN0X2NyZWF0ZV9ub2RlIGltcG9ydCAoCiAgICBDb250cmFjdENyZWF0ZUVycm9yLCBsb2FkX2lucHV0X2RhdGEsIHJ1bl9jb250cmFjdF9jcmVhdGUsCikKCmRlZiBfZmlyc3QoKnZhbHVlcyk6CiAgICBmb3IgdmFsdWUgaW4gdmFsdWVzOgogICAgICAgIGlmIHZhbHVlIGlzIG5vdCBOb25lIGFuZCBzdHIodmFsdWUpLnN0cmlwKCk6CiAgICAgICAgICAgIHJldHVybiBzdHIodmFsdWUpLnN0cmlwKCkKICAgIHJldHVybiAiIgoKcmF3ID0gKHNlbGYuaW5wdXRfYXJnIG9yICIiKS5zdHJpcCgpCmlmIG5vdCByYXc6CiAgICByYWlzZSBDb250cmFjdENyZWF0ZUVycm9yKCLnvLrlsJHkuJrliqHlhaXlj4LvvJror7fpgJrov4cgLWkvLS1pbnB1dCDkvKDlhaUgSlNPTiDlrZfnrKbkuLLmiJbmlofku7bot6/lvoQiKQoKZGF0YSA9IGxvYWRfaW5wdXRfZGF0YShyYXcpCgpzdGF0ZV9jZW50ZXJfdXJsID0gX2ZpcnN0KAogICAgZGF0YS5nZXQoInN0YXRlQ2VudGVyVXJsIiksIGRhdGEuZ2V0KCJzdGF0ZUNlbnRlclVSTCIpLAogICAgb3MuZ2V0ZW52KCJTVEFURV9DRU5URVJfVVJMIiksICJodHRwOi8vMTI3LjAuMC4xOjgwMDAiLAopCmFnZW50X3Rva2VuID0gX2ZpcnN0KAogICAgZGF0YS5nZXQoImFnZW50VG9rZW4iKSwgb3MuZ2V0ZW52KCJBR0VOVF9UT0tFTiIpLCAiZGV2LWFnZW50LXRva2VuIiwKKQoKcHJpbnQoIj0iICogNjApCnByaW50KCLlkIjlkIzlu7rljZUgQ09OVFJBQ1RfQ1JFQVRFIC0g5byA5aeLIikKcHJpbnQoImpvYklkPSVzIHNvdXJjZVR5cGU9JXMgc3VibWl0TW9kZT0lcyIgJSAoCiAgICBkYXRhLmdldCgiam9iSWQiKSwgZGF0YS5nZXQoInNvdXJjZVR5cGUiKSwgZGF0YS5nZXQoInN1Ym1pdE1vZGUiKSkpCnByaW50KCI9IiAqIDYwKQoKcmVzdWx0ID0gcnVuX2NvbnRyYWN0X2NyZWF0ZSgKICAgIGRhdGEsCiAgICBzdGF0ZV9jZW50ZXJfdXJsPXN0YXRlX2NlbnRlcl91cmwsCiAgICBhZ2VudF90b2tlbj1hZ2VudF90b2tlbiwKKQpwcmludChqc29uLmR1bXBzKHJlc3VsdCwgZW5zdXJlX2FzY2lpPUZhbHNlKSkK\"\n}"
|
||||||
|
},
|
||||||
|
"EP_Gen": {
|
||||||
|
"EP_Gen_AFTERDELAY": "{\n \"val\": \"MA==\"\n}",
|
||||||
|
"EP_Gen_BEFOREDELAY": "{\n \"val\": \"MA==\"\n}",
|
||||||
|
"EP_Gen_NOTE": "{\n \"val\": \"6K+75Y+W5YWl5Y+C5bm25omn6KGMIENPTlRSQUNUX0NSRUFURe+8iOWQq+eKtuaAgeS4reW/g+Wbnuiwg++8iQ==\"\n}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"NodeUI": {
|
||||||
|
"NodeElementID": "",
|
||||||
|
"NodeModel": "2",
|
||||||
|
"NodePropErr": false,
|
||||||
|
"NodePx": "515",
|
||||||
|
"NodePy": "270",
|
||||||
|
"NodeSubType": "EnumWidget_Code_Custom",
|
||||||
|
"NodeTag": "20260813200000",
|
||||||
|
"NodeTitle": "读取入参并执行合同建单"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"NodePanelData": {
|
||||||
|
"ExpandedGroups": [],
|
||||||
|
"InputProperty": {
|
||||||
|
"mouse_x": 0,
|
||||||
|
"mouse_y": 0
|
||||||
|
},
|
||||||
|
"PubProperty": "",
|
||||||
|
"RetPins": []
|
||||||
|
},
|
||||||
|
"NodeProperty": "",
|
||||||
|
"NodeUI": {
|
||||||
|
"NodeElementID": "",
|
||||||
|
"NodeModel": "2",
|
||||||
|
"NodePropErr": false,
|
||||||
|
"NodePx": "330",
|
||||||
|
"NodePy": "230",
|
||||||
|
"NodeSubType": "EnumWidget_Begin",
|
||||||
|
"NodeTag": "20260813100000",
|
||||||
|
"NodeTitle": "开始"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SeqTag": "Main",
|
||||||
|
"ViewCenterPx": "600",
|
||||||
|
"ViewCenterPy": "300",
|
||||||
|
"ViewScaling": "1"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
# coding=utf-8
|
||||||
|
# 编译日期:2026-08-13 14:30:00
|
||||||
|
# 版权所有:www.i-search.com.cn
|
||||||
|
import ubpa.init_input as iinput
|
||||||
|
from ubpa.base_util import StdOutHook, ExceptionHandler
|
||||||
|
import time
|
||||||
|
import pdb
|
||||||
|
from ubpa.ilog import ILog
|
||||||
|
import getopt
|
||||||
|
from sys import argv
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import datetime as is_datetime
|
||||||
|
import pandas
|
||||||
|
from ubpa.base_img import *
|
||||||
|
import ubpa.iplatform as iplatform
|
||||||
|
|
||||||
|
class AutoRpa:
|
||||||
|
|
||||||
|
def __init__(self,**kwargs):
|
||||||
|
self.__logger = ILog(__file__)
|
||||||
|
self.path = set_img_res_path(__file__)
|
||||||
|
self.robot_no = ''
|
||||||
|
self.proc_no = ''
|
||||||
|
self.job_no = ''
|
||||||
|
self.input_arg = ''
|
||||||
|
if('robot_no' in kwargs.keys()):
|
||||||
|
self.robot_no = kwargs['robot_no']
|
||||||
|
if('proc_no' in kwargs.keys()):
|
||||||
|
self.proc_no = kwargs['proc_no']
|
||||||
|
if('job_no' in kwargs.keys()):
|
||||||
|
self.job_no = kwargs['job_no']
|
||||||
|
ILog.JOB_NO, ILog.OLD_STDOUT = self.job_no, sys.stdout
|
||||||
|
sys.stdout = StdOutHook(self.job_no, sys.stdout)
|
||||||
|
ExceptionHandler.JOB_NO, ExceptionHandler.OLD_STDERR = self.job_no, sys.stderr
|
||||||
|
sys.excepthook = ExceptionHandler.handle_exception
|
||||||
|
if('input_arg' in kwargs.keys()):
|
||||||
|
self.input_arg = kwargs['input_arg']
|
||||||
|
if(len(self.input_arg) <= 0):
|
||||||
|
self.input_arg = iinput.load_init(__file__)
|
||||||
|
if self.input_arg is None:
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
def Main(self):
|
||||||
|
import sys, os, json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_REPO_ROOT = next(
|
||||||
|
p for p in Path(__file__).resolve().parents
|
||||||
|
if (p / "rpa_yisaiqi" / "__init__.py").exists()
|
||||||
|
)
|
||||||
|
if str(_REPO_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_REPO_ROOT))
|
||||||
|
|
||||||
|
from rpa_yisaiqi.contract_create_node import (
|
||||||
|
ContractCreateError, load_input_data, run_contract_create,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _first(*values):
|
||||||
|
for value in values:
|
||||||
|
if value is not None and str(value).strip():
|
||||||
|
return str(value).strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
raw = (self.input_arg or "").strip()
|
||||||
|
if not raw:
|
||||||
|
raise ContractCreateError("缺少业务入参:请通过 -i/--input 传入 JSON 字符串或文件路径")
|
||||||
|
|
||||||
|
data = load_input_data(raw)
|
||||||
|
|
||||||
|
state_center_url = _first(
|
||||||
|
data.get("stateCenterUrl"), data.get("stateCenterURL"),
|
||||||
|
os.getenv("STATE_CENTER_URL"), "http://127.0.0.1:8000",
|
||||||
|
)
|
||||||
|
agent_token = _first(
|
||||||
|
data.get("agentToken"), os.getenv("AGENT_TOKEN"), "dev-agent-token",
|
||||||
|
)
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("合同建单 CONTRACT_CREATE - 开始")
|
||||||
|
print("jobId=%s sourceType=%s submitMode=%s" % (
|
||||||
|
data.get("jobId"), data.get("sourceType"), data.get("submitMode")))
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
result = run_contract_create(
|
||||||
|
data,
|
||||||
|
state_center_url=state_center_url,
|
||||||
|
agent_token=agent_token,
|
||||||
|
)
|
||||||
|
print(json.dumps(result, ensure_ascii=False))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
ILog.begin_init()
|
||||||
|
robot_no = ''
|
||||||
|
proc_no = ''
|
||||||
|
job_no = ''
|
||||||
|
input_arg = ''
|
||||||
|
params = {}
|
||||||
|
fn = "Main"
|
||||||
|
flow_params={'Main':[]}
|
||||||
|
try:
|
||||||
|
argv = sys.argv[1:]
|
||||||
|
opts, args = getopt.getopt(argv,"hr:p:j:i:c:",["robot = ","proc = ","job = ","input = ","params = "])
|
||||||
|
except getopt.GetoptError:
|
||||||
|
print ('robot.py -r <robot> -p <proc> -j <job>')
|
||||||
|
for opt, arg in opts:
|
||||||
|
if opt == '-h':
|
||||||
|
print ('robot.py -r <robot> -p <proc> -j <job>')
|
||||||
|
elif opt in ("-r", "--robot"):
|
||||||
|
robot_no = arg
|
||||||
|
elif opt in ("-p", "--proc"):
|
||||||
|
proc_no = arg
|
||||||
|
elif opt in ("-j", "--job"):
|
||||||
|
job_no = arg
|
||||||
|
elif opt in ("-i", "--input"):
|
||||||
|
input_arg = arg
|
||||||
|
elif opt in ("-c", "--params"):
|
||||||
|
cParam: dict = iinput.parseConnectorParam(arg)
|
||||||
|
params = cParam.pop("Params", params)
|
||||||
|
fn = cParam.pop("FlowName", fn)
|
||||||
|
pro = AutoRpa(robot_no=robot_no,proc_no=proc_no,job_no=job_no,input_arg=input_arg)
|
||||||
|
iplatform.Return(MainResult=getattr(pro, fn)(**params), job_no=job_no, fn=fn, flow_return_params=flow_params.get(fn,[]))
|
||||||
Loading…
Reference in New Issue