"""CONTRACT_CREATE node: input building + state-center callback (runtime.shell) + Selenium drafting.""" from __future__ import annotations import argparse import json import os import sys 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, contract_draft_from_dict, is_placeholder_customer_code, load_contract_draft, ) from .contract_flow_steps import ContractFlowContext, run_contract_create_flow from .pdf_utils import ensure_merged_pdf from .runtime.errors import NodeError, PdfMergeUnsupported, UnknownRpaError from .runtime.shell import execute_node NODE_CODE = "CONTRACT_CREATE" CONTRACT_URL = "http://contract-test.gzmpc.com:8080/" class ContractCreateError(NodeError): """入参缺失 / 页面元素找不到 / 页面改版,均映射 UI_ELEMENT_NOT_FOUND。""" def __init__(self, message: str = "") -> None: # Studio 编译出来的旧 S01 在 input_arg 为空时会直接 raise。 # 这里拦下来改走默认联调 JSON,避免每次改 Main.py 都被覆盖。 if _should_bootstrap_default_input(message): _bootstrap_default_input_and_exit() super().__init__(message) def default_contract_input_path() -> str: return str( Path(__file__).resolve().parent / "examples" / "contract_create_input_dry_run.json" ) def _should_bootstrap_default_input(message: str) -> bool: if "缺少业务入参" not in str(message): return False script = os.path.basename(str(sys.argv[0] if sys.argv else "")).lower() return script == "main.py" def _bootstrap_default_input_and_exit() -> None: path = default_contract_input_path() if not Path(path).is_file(): return print("未传入 -i/--input,使用默认联调入参:", path, flush=True) codes_dir = Path(__file__).resolve().parent / "studio" / "codes" if str(codes_dir) not in sys.path: sys.path.insert(0, str(codes_dir)) from run_contract import main raise SystemExit(main()) @dataclass(frozen=True) class ContractCreateInput: job_id: str supplier_name: str order_date: str source_type: str contract_system_no: str | None = None attempt_no: int = 1 contract_url: str = CONTRACT_URL username: str = "test002" password: str = "1" 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] def execute_contract_create( spec: ContractCreateInput, client: StateCenterClient, *, contract_runner: ContractRunner | None = None, ) -> 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]: 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": {"contractSystemNo": contract_no}, "summary": _build_summary(spec), "artifacts": [], } return execute_node( client, spec.job_id, NODE_CODE, _run, attempt_no=spec.attempt_no, operator="agent", ) def create_contract_in_contract_system(spec: ContractCreateInput) -> str: """在合同系统中起草合同并返回合同编号。""" if spec.contract_system_no: return spec.contract_system_no 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, flow="CONTRACT_CREATE") 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) return replace(spec, flow_context=ctx) def build_flow_context(spec: ContractCreateInput, draft: ContractDraft) -> ContractFlowContext: merged_pdf_path = _resolve_merged_pdf(draft, spec) return ContractFlowContext( contract_url=spec.contract_url, username=spec.username, password=spec.password, supplier_name=draft.supplier_name or spec.supplier_name, order_date=draft.order_date or spec.order_date, source_type=draft.source_type or spec.source_type, template_type=draft.template_type, template_name=draft.template_name, customer_code=draft.customer_code, customer_name=draft.customer_name, contract_category=draft.contract_category, settlement_type=draft.settlement_type, contract_background=draft.contract_background, contract_nature=draft.contract_nature, amount_currency=draft.amount_currency, amount_type=draft.amount_type, amount=draft.amount, contract_title=draft.contract_title, contract_end_date=draft.contract_end_date, is_major_contract=draft.is_major_contract, is_prepayment=draft.is_prepayment, belong_year=draft.belong_year, payment_method=draft.payment_method, payment_days=draft.payment_days, contract_status=draft.contract_status, contains_blood_products=draft.contains_blood_products, submit_mode=draft.submit_mode, goods_lines=draft.goods_lines, merged_pdf_path=merged_pdf_path, contract_document_path=draft.contract_document_path, order_no_list=draft.order_no_list, ) 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: if draft.contract_document_path: return None 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 { "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 _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) customer_code = draft.customer_code or _str(data.get("customerCode")) if customer_code and is_placeholder_customer_code(customer_code): raise ContractCreateError( f"customerCode「{customer_code}」是占位符,合同系统客商选择窗搜不到。" "请换成测试环境真实存在的客商操作码,例如 DW0100046361(重庆亿昊药业有限公司)" ) 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: fallback = default_contract_input_path() if Path(fallback).is_file(): print("未传入 -i/--input,使用默认联调入参:", fallback, flush=True) raw = fallback else: 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 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("--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("--draft-path", default=os.getenv("CONTRACT_DRAFT_PATH")) parser.add_argument("--merged-pdf-output", default=os.getenv("MERGED_PDF_OUTPUT")) return parser.parse_args() def main() -> int: 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( 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(json.dumps(result, ensure_ascii=False)) return 0 if __name__ == "__main__": raise SystemExit(main())