feat: add Windows Selenium runner for CONTRACT_CREATE

This commit is contained in:
34BFF76918A9449ABAB323149DDCEA73 2026-08-13 15:26:34 +08:00
parent 4060d7b45d
commit ca79659e3e
10 changed files with 1077 additions and 274 deletions

View File

@ -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

View File

@ -1,47 +1,27 @@
"""CONTRACT_CREATE node skeleton for YiSaiQi Windows integration.
This first version proves the state-center callback path and leaves the
contract-system page automation behind a single replaceable runner function.
"""
"""CONTRACT_CREATE node: input building + state-center callback (runtime.shell) + Selenium drafting."""
from __future__ import annotations
import argparse
import json
import os
import uuid
import webbrowser
from dataclasses import dataclass
from dataclasses import dataclass, replace
from pathlib import Path
from typing import Any, Callable
from .apa_browser_adapter import ApaBrowserAdapter
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 .pdf_utils import ensure_merged_pdf
from .runtime.errors import LoginFailed as RuntimeLoginFailed
from .runtime.errors import NodeError
from .runtime.errors import PdfMergeUnsupported as RuntimePdfMergeUnsupported
from .runtime.errors import SupplierContractDisabled as RuntimeSupplierContractDisabled
from .runtime.errors import NodeError, PdfMergeUnsupported, UnknownRpaError
from .runtime.shell import execute_node
from .yisaiqi_adapter_template import YiSaiQiBrowserAdapter
NODE_CODE = "CONTRACT_CREATE"
CONTRACT_URL = "http://contract-test.gzmpc.com:8080/"
class ContractCreateError(NodeError):
pass
class LoginFailed(RuntimeLoginFailed, ContractCreateError):
pass
class SupplierContractDisabled(RuntimeSupplierContractDisabled, ContractCreateError):
pass
class PdfMergeUnsupported(RuntimePdfMergeUnsupported, ContractCreateError):
pass
"""入参缺失 / 页面元素找不到 / 页面改版,均映射 UI_ELEMENT_NOT_FOUND。"""
@dataclass(frozen=True)
@ -55,9 +35,12 @@ class ContractCreateInput:
contract_url: str = CONTRACT_URL
username: str = "test002"
password: str = "1"
open_browser: bool = False
submit_mode: str = "submit"
order_no_list: tuple[str, ...] = ()
template_name: str = ""
draft_path: str | None = None
merged_pdf_output: str | None = None
flow_context: ContractFlowContext | None = None
ContractRunner = Callable[[ContractCreateInput], str]
@ -71,13 +54,24 @@ def execute_contract_create(
) -> dict[str, Any]:
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]:
contract_no = runner(spec)
payload = build_complete_payload(spec, contract_no)
try:
contract_no = runner(spec)
except NodeError:
raise
except Exception as exc:
raise UnknownRpaError(str(exc)) from exc
if not contract_no:
raise ContractCreateError("submit 模式未读取到真实合同编号,无法 complete")
return {
"businessRefs": payload["businessRefs"],
"summary": payload["summary"],
"artifacts": payload["artifacts"],
"businessRefs": {"contractSystemNo": contract_no},
"summary": _build_summary(spec),
"artifacts": [],
}
return execute_node(
@ -91,34 +85,46 @@ def execute_contract_create(
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:
return spec.contract_system_no
if not spec.draft_path:
raise ContractCreateError("draft_path is required for real contract drafting")
draft = load_contract_draft(spec.draft_path)
ctx = spec.flow_context
if ctx is None:
if not spec.draft_path:
raise ContractCreateError("缺少 flow_context 或 draft_path,无法起草合同")
ctx = build_flow_context(spec, load_contract_draft(spec.draft_path))
browser = ApaBrowserAdapter(url=ctx.contract_url)
return run_contract_create_flow(browser, ctx)
def build_spec_from_data(data: dict[str, Any]) -> ContractCreateInput:
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)
browser = YiSaiQiBrowserAdapter()
try:
return run_contract_create_flow(browser, ctx)
except NotImplementedError as exc:
raise ContractCreateError(
"YiSaiQiBrowserAdapter still needs to be mapped to YiSaiQi designer activities"
) from exc
return replace(spec, flow_context=ctx)
def build_flow_context(spec: ContractCreateInput, draft: ContractDraft) -> ContractFlowContext:
merged_pdf_path = draft.merged_pdf_path
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
merged_pdf_path = _resolve_merged_pdf(draft, spec)
return ContractFlowContext(
contract_url=spec.contract_url,
username=spec.username,
@ -147,51 +153,143 @@ def build_flow_context(spec: ContractCreateInput, draft: ContractDraft) -> Contr
submit_mode=draft.submit_mode,
goods_lines=draft.goods_lines,
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 {
"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,
"contractDate": spec.order_date,
"sourceType": spec.source_type,
"contractUrl": spec.contract_url,
"uploadRule": "merge_pdf_before_upload" if spec.source_type == "YYC" else "skip_contract_upload",
},
"artifacts": [],
"supplierName": spec.supplier_name,
"contractDate": spec.order_date,
"sourceType": spec.source_type,
"orderNoList": list(spec.order_no_list),
"templateName": spec.template_name,
"pdfMerged": spec.source_type == "YYC",
"submitted": True,
}
def build_fail_payload(spec: ContractCreateInput, exc: ContractCreateError) -> dict[str, Any]:
return {
"attemptNo": spec.attempt_no,
"errorCode": exc.error_code,
"errorSummary": str(exc),
"retryable": exc.retryable,
"routeRole": exc.route_role,
"artifacts": [],
}
def _validate_data(data: dict[str, Any], draft: ContractDraft) -> None:
missing = []
for key in ("jobId", "supplierName", "orderDate", "sourceType", "customerCode"):
if not _str(data.get(key)):
missing.append(key)
if draft.submit_mode not in ("dry_run", "submit"):
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:
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("--token", default=os.getenv("AGENT_TOKEN", "dev-agent-token"))
parser.add_argument("--job-id", required=True)
parser.add_argument("--supplier-name", required=True)
parser.add_argument("--order-date", required=True)
parser.add_argument("--input-json", default=os.getenv("CONTRACT_INPUT_JSON"))
parser.add_argument("--job-id")
parser.add_argument("--supplier-name")
parser.add_argument("--order-date")
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("--attempt-no", type=int, default=1)
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("--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("--merged-pdf-output", default=os.getenv("MERGED_PDF_OUTPUT"))
return parser.parse_args()
@ -199,23 +297,29 @@ def parse_args() -> argparse.Namespace:
def main() -> int:
args = parse_args()
spec = ContractCreateInput(
job_id=args.job_id,
supplier_name=args.supplier_name,
order_date=args.order_date,
source_type=args.source_type,
contract_system_no=args.contract_system_no,
attempt_no=args.attempt_no,
contract_url=args.contract_url,
username=args.username,
password=args.password,
open_browser=args.open_browser,
draft_path=args.draft_path,
merged_pdf_output=args.merged_pdf_output,
)
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(
job_id=args.job_id,
supplier_name=args.supplier_name,
order_date=args.order_date,
source_type=args.source_type,
contract_system_no=args.contract_system_no,
attempt_no=args.attempt_no,
contract_url=args.contract_url,
username=args.username,
password=args.password,
draft_path=args.draft_path,
merged_pdf_output=args.merged_pdf_output,
)
result = execute_contract_create(spec, client)
print(result)
print(json.dumps(result, ensure_ascii=False))
return 0

View File

@ -36,6 +36,7 @@ class ContractDraft:
goods_lines: list[ContractGoodsLine] = field(default_factory=list)
platform_pdf_paths: list[str] = field(default_factory=list)
merged_pdf_path: str | None = None
order_no_list: list[str] = field(default_factory=list)
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", "")),
spec=str(item.get("spec", "")),
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", [])
]
@ -82,4 +85,5 @@ def contract_draft_from_dict(data: dict[str, Any]) -> ContractDraft:
goods_lines=goods_lines,
platform_pdf_paths=[str(path) for path in data.get("platformPdfPaths", [])],
merged_pdf_path=data.get("mergedPdfPath"),
order_no_list=[str(x) for x in data.get("orderNoList", [])],
)

View File

@ -5,18 +5,32 @@ 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 .runtime.errors import (
LoginFailed,
NodeError,
PdfMergeUnsupported,
SupplierContractDisabled,
)
class BrowserAdapter(Protocol):
def open_url(self, url: str) -> None: ...
def fill_first(self, selectors: list[str], value: str) -> None: ...
def click_first(self, selectors: list[str]) -> None: ...
def 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_first(self, selectors: list[str], value_name: str = "") -> str: ...
def read_body_text(self) -> str: ...
def screenshot(self, name: str) -> str: ...
@ -29,6 +43,8 @@ class ContractGoodsLine:
spec: str
unit: str
quantity: float
mdm_code: str = ""
unit_price: str = ""
@dataclass(frozen=True)
@ -60,6 +76,7 @@ class ContractFlowContext:
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:
@ -78,115 +95,256 @@ def run_contract_create_flow(browser: BrowserAdapter, ctx: ContractFlowContext)
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:
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(['input[name="btn_submit"]', 'a[href*="btn_submit"]', 'text=登录'])
browser.wait_for_text("合同", timeout_seconds=20)
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:
browser.click_first(["text=合同管理", "aria=合同管理"])
browser.click_first(["text=合同起草", "aria=合同起草"])
browser.wait_for_text("最近使用模板", timeout_seconds=20)
# 登录后直接导航到起草表单页,跳过模板选择(前端委托事件 Selenium 点击无效)
browser.open_url(FORM_PAGE_URL)
browser.wait_for_text("客户编码", timeout_seconds=20)
def select_contract_template(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
template_text = ctx.template_name or (
"非格式--采购协议" if ctx.template_type == "NON_FORMAT" else "购销协议书(2025)"
)
browser.click_first([f"text={template_text}", f"text={template_text}...", f"title={template_text}"])
browser.wait_for_text("合同起草", timeout_seconds=20)
# 已通过 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:
customer_name = ctx.customer_name or ctx.supplier_name
if ctx.customer_code:
browser.fill_first(
['label=客户编码', 'input[name="fdCustomerCode"]', 'input[placeholder*="客户编码"]'],
ctx.customer_code,
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 customer_name:
browser.fill_first(
['label=客户名称', 'input[name="fdCustomerName"]', 'input[placeholder*="客户名称"]'],
customer_name,
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:
browser.select_first(['label=合同版本类型', 'select[name="fdTemplateType"]'], contract_version_label(ctx))
if ctx.contract_category:
browser.select_first(['label=合同类别', 'select[name="fdContractCategory"]'], ctx.contract_category)
browser.select_first(['label=结算类型', 'select[name="fdSettlementType"]'], ctx.settlement_type)
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(
['label=合同背景概述', 'textarea[name="fdContractBackground"]'],
ctx.contract_background,
)
browser.click_first([f"label={ctx.contract_nature}", f"text={ctx.contract_nature}"])
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 NodeError(f"结算方式下拉未选中,仍停留在「==请选择==」,期望「{value}」")
def fill_contract_amount_and_dates(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
browser.select_first(['label=合同金额', 'select[name="fdCurrency"]'], ctx.amount_currency)
if ctx.amount:
browser.fill_first(['label=金额(元)', 'input[name="fdAmount"]'], ctx.amount)
browser.fill_first(
['label=合同签订日期', 'input[name="fdSignDate"]', 'input[placeholder*="合同签订"]'],
ctx.order_date,
)
browser.fill_first(
['label=合同开始日期', 'input[name="fdStartDate"]', 'input[placeholder*="合同开始"]'],
ctx.order_date,
)
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(
['label=合同结束日期', 'input[name="fdEndDate"]', 'input[placeholder*="合同结束"]'],
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, start=1):
if index > 1:
browser.click_first(["text=新增明细", "text=添加货品", "text=新增行"])
prefix = f"明细{index}"
browser.fill_first([f'label={prefix}操作码', 'input[name="operationCode"]'], line.operation_code)
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([f'label={prefix}国药准字', 'input[name="approvalNo"]'], line.approval_no)
browser.fill_first([f'label={prefix}规格', 'input[name="spec"]'], line.spec)
browser.fill_first([f'label={prefix}单位', 'input[name="unit"]'], line.unit)
browser.fill_first([f'label={prefix}数量', 'input[name="quantity"]'], str(line.quantity))
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.select_first(['label=是否重大合同', 'select[name="fdIsMajorContract"]'], ctx.is_major_contract)
browser.select_first(['label=是否预付款', 'select[name="fdIsPrepayment"]'], ctx.is_prepayment)
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(['label=合同归属年份', 'input[name="fdBelongYear"]'], ctx.belong_year)
browser.select_first(['label=付款方式', 'select[name="fdPaymentMethod"]', 'select[name="paymentMethod"]'], ctx.payment_method)
browser.fill_first(['label=付款期限', 'input[name="fdPaymentDays"]', 'input[name="paymentDays"]'], ctx.payment_days)
browser.select_first(['label=合同状态', 'select[name="fdContractStatus"]'], ctx.contract_status)
browser.select_first(
['label=是否包含血液制品', 'select[name="fdContainsBloodProducts"]'],
ctx.contains_blood_products,
)
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 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.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:
return "非格式合同" if ctx.template_type == "NON_FORMAT" else "格式合同"

View File

@ -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"
}

View File

@ -1 +1,4 @@
pypdf==5.1.0
selenium>=4.15.0
webdriver-manager>=4.0.0
requests>=2.31.0

View File

@ -23,3 +23,9 @@ class PdfMergeUnsupported(NodeError):
error_code = "CAP_PDF_MERGE_UNSUPPORTED"
retryable = False
route_role = "OPS"
class UnknownRpaError(NodeError):
error_code = "RPA_UNKNOWN_ERROR"
retryable = True
route_role = "OPS"

View File

@ -1,116 +1,42 @@
{
"knownLoginSelectors": {
"login": {
"username": "input[name='j_username']",
"password": "input[name='j_password']",
"submit": "input[name='btn_submit']"
"submit": "input[name='btn_submit']",
"submitLink": "text=登录"
},
"candidateSelectors": {
"contractManagementMenu": [
"text=合同管理",
"aria=合同管理"
],
"contractDraftMenu": [
"text=合同起草",
"aria=合同起草"
],
"recentNonFormatPurchaseTemplate": [
"text=非格式--采购协议",
"text=非格式--采购协议...",
"title=非格式--采购协议"
],
"customerCode": [
"label=客户编码",
"input[name='fdCustomerCode']",
"input[placeholder*='客户编码']"
],
"customerName": [
"label=客户名称",
"input[name='fdCustomerName']",
"input[placeholder*='客户名称']"
],
"contractVersionType": [
"label=合同版本类型",
"select[name='fdTemplateType']"
],
"contractCategory": [
"label=合同类别",
"select[name='fdContractCategory']"
],
"settlementType": [
"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=合同编号"
]
"navigation": {
"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",
"templatePrefix": "非格式-采购协议"
},
"form": {
"customerCode": "input[name='extendDataFormInfo.value(fd_customeropcode)']",
"customerName": "input[name='extendDataFormInfo.value(fd_customername)']",
"contractCategory": "input[name='extendDataFormInfo.value(fd_contract_cate_type)']",
"settlementType": "select#fdSettype",
"contractBackground": "textarea[name='extendDataFormInfo.value(fd_desc_info)']",
"contractNatureMain": "input[name='fdType'][value='00']",
"contractNatureSub": "input[name='fdType'][value='01']",
"amount": "input[name='fdAccount']",
"signDate": "input[name='fdSigndate']",
"beginDate": "input[name='fdEffedate']",
"endDate": "input[name='fdEnddate']",
"isMajorContract": "input[name='extendDataFormInfo.value(fd_is_major_contract)']",
"isPrepayment": "input[name='extendDataFormInfo.value(fd_advance_amout)']",
"belongYear": "input[name='extendDataFormInfo.value(fd_contract_year_text)']",
"paymentMethod": "input[name='extendDataFormInfo.value(fd_paytype)']",
"paymentDays": "input[name='extendDataFormInfo.value(fd_pay_time_limit)']",
"contractStatus": "input[name='extendDataFormInfo.value(fd_suconstatus)']",
"containsBloodProducts": "input[name='extendDataFormInfo.value(fd_3d1ce3953f1162)']",
"goodsOperationCode": "input[name='extendDataFormInfo.value(fd_detail1.0.fd_opcode)']",
"goodsCode": "input[name='extendDataFormInfo.value(fd_detail1.0.fd_opcode)']",
"goodsName": "input[name='extendDataFormInfo.value(fd_detail1.0.fd_goodsname)']",
"goodsMdmCode": "input[name='extendDataFormInfo.value(fd_detail1.0.fd_mdmCode)']",
"goodsApprovalNo": "input[name='extendDataFormInfo.value(fd_detail1.0.fd_registdocno)']",
"goodsSpec": "input[name='extendDataFormInfo.value(fd_detail1.0.fd_goodstype)']",
"goodsUnit": "input[name='extendDataFormInfo.value(fd_detail1.0.fd_goodsunit)']",
"goodsQuantity": "input[name='extendDataFormInfo.value(fd_detail1.0.fd_goodsqty)']",
"goodsUnitPrice": "input[name='extendDataFormInfo.value(fd_detail1.0.fd_unitprice)']",
"submit": "text=提交"
}
}

View File

@ -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"
}

View File

@ -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,[]))