"""CONTRACT_CREATE 节点编排:入参构建 + 状态中心回调 + Selenium 起草。""" from __future__ import annotations import argparse import json import os import uuid 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, load_contract_draft from .contract_flow_steps import ContractFlowContext, run_contract_create_flow from .errors import ( ContractCreateError, LoginFailed, PdfMergeUnsupported, SupplierContractDisabled, UnknownRpaError, ) from .pdf_utils import ensure_merged_pdf NODE_CODE = "CONTRACT_CREATE" CONTRACT_URL = "http://contract-test.gzmpc.com:8080/" @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]: client.start_node(spec.job_id, NODE_CODE, attempt_no=spec.attempt_no) runner = contract_runner or create_contract_in_contract_system try: contract_no = runner(spec) if spec.submit_mode == "dry_run": return {"status": "dry_run", "submitted": False} if not contract_no: raise ContractCreateError("submit 模式未读取到真实合同编号,无法 complete") return client.complete_node(spec.job_id, NODE_CODE, build_complete_payload(spec, contract_no)) except ContractCreateError as exc: return client.fail_node(spec.job_id, NODE_CODE, build_fail_payload(spec, exc)) except Exception as exc: return client.fail_node(spec.job_id, NODE_CODE, build_fail_payload(spec, UnknownRpaError(str(exc)))) 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) 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=draft.amount, 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, order_no_list=draft.order_no_list, ) def _resolve_merged_pdf(draft: ContractDraft, spec: ContractCreateInput) -> str | None: if draft.source_type != "YYC": 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_complete_payload(spec: ContractCreateInput, contract_no: str) -> 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, "orderNoList": list(spec.order_no_list), "templateName": spec.template_name, "pdfMerged": spec.source_type == "YYC", "submitted": True, }, "artifacts": [], } 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, label in ( ("jobId", "jobId"), ("supplierName", "supplierName"), ("orderDate", "orderDate"), ("sourceType", "sourceType"), ("customerCode", "customerCode"), ): if not _str(data.get(key)): missing.append(label) 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 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 # 保持旧导入名可用(errors 模块为新的统一异常来源) __all__ = [ "ContractCreateError", "LoginFailed", "SupplierContractDisabled", "PdfMergeUnsupported", "UnknownRpaError", "ContractCreateInput", "execute_contract_create", "create_contract_in_contract_system", "build_spec_from_data", "build_flow_context", "build_complete_payload", "build_fail_payload", "run_contract_create", "load_input_data", "NODE_CODE", "CONTRACT_URL", ] if __name__ == "__main__": raise SystemExit(main())