AutoRpa/rpa_yisaiqi/contract_create_node.py

384 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

"""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())