AutoRpa/rpa_contract/contract_create_node.py

365 lines
13 KiB
Python
Raw 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: 入参组装 + Selenium 起草。
#
# 结果返回合同号,不回调旧状态中心。
# 合同系统账密只认 CONTRACT_USERNAME / CONTRACT_PASSWORD 或入参,缺则失败。
from __future__ import annotations
import argparse
import json
import os
from dataclasses import dataclass, replace
from pathlib import Path
from typing import Any, Callable
from apa_browser_adapter import ApaBrowserAdapter
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 NodeError, PdfMergeUnsupported, UnknownRpaError
from pdf_utils import ensure_merged_pdf
CONTRACT_URL = "http://contract-test.gzmpc.com:8080/"
# 入参缺失 / 页面元素找不到 / 页面改版。
class ContractCreateError(NodeError):
pass
# 返回本包 examples 下默认干跑 JSON 的路径。
def default_contract_input_path() -> str:
return str(
Path(__file__).resolve().parent / "examples" / "contract_create_input_dry_run.json"
)
# 一次合同起草的入参。账密为空时由 resolve_contract_credentials 补环境变量。
@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 = ""
password: str = ""
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]
# 取出合同系统账密。入参优先,否则读 CONTRACT_USERNAME / CONTRACT_PASSWORD。
#
# 用户名、密码缺一即抛 ContractCreateError。不内置测试账号。
def resolve_contract_credentials(
username: str | None = None,
password: str | None = None,
) -> tuple[str, str]:
user = (username or "").strip() or (os.getenv("CONTRACT_USERNAME") or "").strip()
if password is None or password == "":
pwd = os.getenv("CONTRACT_PASSWORD") or ""
else:
pwd = str(password)
if not user or pwd == "":
raise ContractCreateError("未配置 CONTRACT_USERNAME / CONTRACT_PASSWORD")
return user, pwd
# 按入参起草合同。dry_run 不点提交;submit 必须读到合同号。
def execute_contract_create(
spec: ContractCreateInput,
*,
contract_runner: ContractRunner | None = None,
) -> dict[str, Any]:
runner = contract_runner or create_contract_in_contract_system
if spec.submit_mode == "dry_run":
runner(spec)
return {"status": "dry_run", "submitted": False}
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 模式未读取到真实合同编号")
return {
"status": "done",
"contractSystemNo": contract_no,
"summary": _build_summary(spec),
}
# 在合同系统中起草合同并返回合同编号。
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)
# 把 JSON 字典收成 ContractCreateInput,并挂上页面上下文。
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")),
password="" if data.get("password") in (None, "") else str(data.get("password")),
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)
username, password = resolve_contract_credentials(spec.username, spec.password)
return ContractFlowContext(
contract_url=spec.contract_url,
username=username,
password=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,
contract_sign_date=draft.contract_sign_date,
contract_start_date=draft.contract_start_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,
)
# YYC 才合并平台 PDF。dry_run 不校验不上传。
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,
}
# 检查必填字段、金额和货品行。不通过则抛 ContractCreateError。
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
# 把值收成非空字符串,空白则 None。
def _opt(value: Any) -> str | None:
value = _str(value)
return value or None
# 把任意值收成去两端空白的字符串。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
# 解析入参:优先按 JSON 字符串,其次按 JSON 文件路径。
def load_input_data(raw: str) -> dict[str, Any]:
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}")
# 从 JSON 字典起草合同并返回结果。
def run_contract_create(data: dict[str, Any]) -> dict[str, Any]:
spec = build_spec_from_data(data)
return execute_contract_create(spec)
# 解析命令行。账密默认读环境变量,没有内置账号。
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="合同系统页面起草。")
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"))
parser.add_argument("--password", default=os.getenv("CONTRACT_PASSWORD"))
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()
# 命令行入口:JSON 或参数起草一份合同,打印 JSON 结果。
def main() -> int:
args = parse_args()
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")
username, password = resolve_contract_credentials(args.username, args.password)
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=username,
password=password,
draft_path=args.draft_path,
merged_pdf_output=args.merged_pdf_output,
)
result = execute_contract_create(spec)
print(json.dumps(result, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())