from __future__ import annotations from datetime import UTC, datetime from typing import Any from sqlalchemy.orm import Session from app.api.deps import CurrentUserContext from app.schemas.ontology import OntologyParseResult, OntologyPermission from app.schemas.steward import ( StewardActionExecuteRequest, StewardActionExecuteResponse, StewardTask, ) from app.schemas.user_agent import UserAgentRequest from app.services.attachment_association_jobs import AttachmentAssociationJobRunner from app.services.expense_claims import ExpenseClaimService from app.services.user_agent import UserAgentService from app.services.user_agent_application_dates import resolve_application_days_from_time_range SUPPORTED_ACTIONS = { "fill_application_fields", "build_application_preview", "fill_reimbursement_fields", "build_reimbursement_preview", "validate_required_fields", "run_duplicate_precheck", "save_application_draft", "submit_application", "create_reimbursement_draft", "link_existing_application", "associate_attachments", } APPLICATION_SIDE_EFFECT_ACTIONS = {"save_application_draft", "submit_application"} REIMBURSEMENT_SIDE_EFFECT_ACTIONS = {"create_reimbursement_draft", "link_existing_application"} NOOP_ACTIONS = { "fill_application_fields", "build_application_preview", "fill_reimbursement_fields", "build_reimbursement_preview", "validate_required_fields", } TRANSPORT_MODE_LABELS = { "train": "火车", "rail": "火车", "high_speed_rail": "火车", "flight": "飞机", "airplane": "飞机", "plane": "飞机", "ship": "轮船", "boat": "轮船", "taxi": "出租车", } EXPENSE_TYPE_LABELS = { "travel": "差旅费", "transport": "交通费", "hotel": "住宿费", "meal": "业务招待费", "meeting": "会务费", "office": "办公用品费", "other": "其他费用", } APPLICATION_TYPE_LABELS = { "travel": "差旅费用申请", "travel_application": "差旅费用申请", "transport": "交通费用申请", "hotel": "住宿费用申请", "meeting": "会务费用申请", "office": "办公费用申请", } class StewardActionExecutor: """执行 LangGraph 规划出的确定性白名单动作。""" def __init__(self, db: Session) -> None: self.db = db def execute( self, request: StewardActionExecuteRequest, current_user: CurrentUserContext, ) -> StewardActionExecuteResponse: action_type = self._normalize_action_type(request.action_type) trace = [self._trace("received", action_type=action_type, plan_id=request.plan_id)] if action_type not in SUPPORTED_ACTIONS: return self._blocked( action_type, f"不支持的小财管家动作:{action_type or '空动作'}。", trace=[*trace, self._trace("blocked", reason="unsupported_action")], ) task = request.task if task is None and action_type not in NOOP_ACTIONS: return self._blocked( action_type, "动作缺少任务快照,无法安全执行。", trace=[*trace, self._trace("blocked", reason="missing_task")], ) blocked_reasons = self._resolve_task_blockers(task) if blocked_reasons: return self._blocked( action_type, "当前任务仍有必填信息缺失,已停止执行动作。", blocked_reasons=blocked_reasons, trace=[*trace, self._trace("blocked", reason="missing_fields")], ) if action_type in NOOP_ACTIONS: return StewardActionExecuteResponse( action_type=action_type, status="succeeded", message="动作已确认,无需调用外部业务服务。", result_payload={ "task_id": task.task_id if task is not None else "", "action_type": action_type, }, trace=[*trace, self._trace("completed", mode="noop")], ) if action_type == "run_duplicate_precheck": return self._run_duplicate_precheck(request, current_user, trace) if action_type in APPLICATION_SIDE_EFFECT_ACTIONS: return self._execute_application_action(request, current_user, action_type, trace) if action_type in REIMBURSEMENT_SIDE_EFFECT_ACTIONS: return self._execute_reimbursement_action(request, current_user, action_type, trace) if action_type == "associate_attachments": return self._execute_associate_attachments_action(request, current_user, trace) return self._blocked( action_type, f"动作 {action_type} 暂未接入执行器。", trace=[*trace, self._trace("blocked", reason="unwired_action")], ) def _run_duplicate_precheck( self, request: StewardActionExecuteRequest, current_user: CurrentUserContext, trace: list[dict[str, Any]], ) -> StewardActionExecuteResponse: payload = self._build_application_user_agent_request( request, current_user, action_type="submit_application", force_submit_message=False, ) service = UserAgentService(self.db) facts = service._resolve_expense_application_facts(payload) duplicate = service._find_duplicate_expense_application_record(payload, facts) if duplicate is not None: result_payload = { "status": "blocked", "blocking": True, "duplicate_claim_id": str(duplicate.id or ""), "duplicate_claim_no": str(duplicate.claim_no or ""), "duplicate_stage": str(duplicate.approval_stage or ""), } return StewardActionExecuteResponse( action_type="run_duplicate_precheck", status="blocked", message="检测到同一申请人、同一申请类型、同一时间段已有申请单,已停止直接提交。", blocked_reasons=["duplicate_application"], result_payload=result_payload, trace=[*trace, self._trace("blocked", reason="duplicate_application")], ) result_payload = {"status": "ok", "blocking": False} return StewardActionExecuteResponse( action_type="run_duplicate_precheck", status="succeeded", message="未发现重复或冲突申请,可以继续提交。", result_payload=result_payload, trace=[*trace, self._trace("completed", mode="duplicate_precheck")], ) def _execute_application_action( self, request: StewardActionExecuteRequest, current_user: CurrentUserContext, action_type: str, trace: list[dict[str, Any]], ) -> StewardActionExecuteResponse: if action_type == "submit_application" and not request.confirmed: return StewardActionExecuteResponse( action_type=action_type, status="needs_confirmation", requires_confirmation=True, message="提交申请前需要用户确认。请确认申请核对表无误后再提交。", trace=[*trace, self._trace("needs_confirmation")], ) if action_type == "submit_application": precheck_blocker = self._resolve_submit_precheck_blocker(request.context_json) if precheck_blocker: return self._blocked( action_type, precheck_blocker, blocked_reasons=["precheck_not_passed"], trace=[*trace, self._trace("blocked", reason="precheck_not_passed")], ) payload = self._build_application_user_agent_request( request, current_user, action_type=action_type, force_submit_message=action_type == "submit_application", ) try: user_agent_response = UserAgentService(self.db)._build_expense_application_response( payload, risk_flags=[], ) except ValueError as exc: return self._failed(action_type, str(exc), trace) draft_payload = ( user_agent_response.draft_payload.model_dump(mode="json") if user_agent_response.draft_payload is not None else None ) result_payload = { "answer": user_agent_response.answer, "suggested_actions": [ action.model_dump(mode="json") for action in user_agent_response.suggested_actions ], "requires_confirmation": user_agent_response.requires_confirmation, "draft_payload": draft_payload, } status = "succeeded" if draft_payload is not None else "blocked" blocked_reasons = [] if draft_payload is not None else ["application_not_persisted"] return StewardActionExecuteResponse( action_type=action_type, status=status, message=user_agent_response.answer, blocked_reasons=blocked_reasons, result_payload=result_payload, trace=[*trace, self._trace("completed", service="UserAgentService")], ) def _execute_reimbursement_action( self, request: StewardActionExecuteRequest, current_user: CurrentUserContext, action_type: str, trace: list[dict[str, Any]], ) -> StewardActionExecuteResponse: context_json = self._build_reimbursement_context_json(request, current_user) run_id = self._build_run_id(request, action_type) ontology = OntologyParseResult( scenario="expense", intent="draft", permission=OntologyPermission( level="draft_write", allowed=True, reason="小财管家白名单动作创建报销草稿。", ), confidence=1.0, run_id=run_id, ) try: result = ExpenseClaimService(self.db).save_or_submit_from_ontology( run_id=run_id, user_id=current_user.username, message=self._resolve_message(request), ontology=ontology, context_json=context_json, ) except ValueError as exc: return self._failed(action_type, str(exc), trace) persisted = str(result.get("claim_id") or "").strip() and str(result.get("status") or "").strip() == "draft" return StewardActionExecuteResponse( action_type=action_type, status="succeeded" if persisted else "blocked", message=str(result.get("message") or "报销草稿已生成。").strip(), blocked_reasons=[] if persisted else ["reimbursement_not_persisted"], result_payload=result, trace=[*trace, self._trace("completed", service="ExpenseClaimService")], ) def _execute_associate_attachments_action( self, request: StewardActionExecuteRequest, current_user: CurrentUserContext, trace: list[dict[str, Any]], ) -> StewardActionExecuteResponse: receipt_ids = self._resolve_receipt_ids(request) if not receipt_ids: return self._blocked( "associate_attachments", "关联附件前需要先提供票据夹 receipt_ids。", blocked_reasons=["missing_receipt_ids"], trace=[*trace, self._trace("blocked", reason="missing_receipt_ids")], ) try: result = AttachmentAssociationJobRunner(self.db).run( receipt_ids=receipt_ids, current_user=current_user, ) except ValueError as exc: return self._blocked( "associate_attachments", str(exc), blocked_reasons=["attachment_association_blocked"], trace=[*trace, self._trace("blocked", reason="attachment_association_blocked")], ) except Exception as exc: return self._failed("associate_attachments", str(exc), trace) return StewardActionExecuteResponse( action_type="associate_attachments", status="succeeded", message=str( result.get("message") or f"已自动关联到 {result.get('claim_no') or '报销草稿'}。" ).strip(), result_payload={ **dict(result or {}), "receipt_ids": receipt_ids, }, trace=[*trace, self._trace("completed", service="AttachmentAssociationJobRunner")], ) def _build_application_user_agent_request( self, request: StewardActionExecuteRequest, current_user: CurrentUserContext, *, action_type: str, force_submit_message: bool, ) -> UserAgentRequest: run_id = self._build_run_id(request, action_type) context_json = self._build_application_context_json(request, current_user, action_type) message = self._resolve_message(request) if force_submit_message and "确认提交" not in message and "直接提交" not in message: message = "\n".join([message, "确认提交"]).strip() ontology = OntologyParseResult( scenario="expense", intent="operate", permission=OntologyPermission( level="approval_required", allowed=True, reason="小财管家白名单动作执行申请操作。", ), confidence=1.0, run_id=run_id, ) return UserAgentRequest( run_id=run_id, user_id=current_user.username, message=message, ontology=ontology, context_json=context_json, tool_payload={}, selected_capability_codes=[], degraded=False, requires_confirmation=False, ) def _build_application_context_json( self, request: StewardActionExecuteRequest, current_user: CurrentUserContext, action_type: str, ) -> dict[str, Any]: fields = self._resolve_ontology_fields(request.task) preview_fields = { "applicationType": self._resolve_application_type_label(fields.get("expense_type")), "time": str(fields.get("time_range") or ""), "location": str(fields.get("location") or ""), "reason": str(fields.get("reason") or ""), "days": self._resolve_days_text(fields.get("time_range")), "transportMode": self._resolve_transport_label(fields.get("transport_mode")), "amount": str(fields.get("amount") or ""), "applicant": current_user.name, "department": current_user.department_name, "position": current_user.position, "grade": current_user.grade, "managerName": current_user.manager_name, } context_json = { **dict(request.context_json or {}), "session_type": "application", "entry_source": "steward_action_executor", "document_type": "expense_application", "application_stage": "expense_application", "role_codes": current_user.role_codes, "is_admin": current_user.is_admin, "username": current_user.username, "name": current_user.name, "department_name": current_user.department_name, "position": current_user.position, "grade": current_user.grade, "employee_no": current_user.employee_no, "manager_name": current_user.manager_name, "application_preview": {"fields": preview_fields}, } if action_type == "save_application_draft": context_json["application_action"] = "save_draft" context_json["application_save_mode"] = True return context_json def _build_reimbursement_context_json( self, request: StewardActionExecuteRequest, current_user: CurrentUserContext, ) -> dict[str, Any]: fields = self._resolve_ontology_fields(request.task) review_form_values = { "expense_type": self._resolve_expense_type_label(fields.get("expense_type")), "time_range": str(fields.get("time_range") or ""), "occurred_date": str(fields.get("time_range") or ""), "location": str(fields.get("location") or ""), "reason": str(fields.get("reason") or ""), "amount": str(fields.get("amount") or ""), "transport_mode": self._resolve_transport_label(fields.get("transport_mode")), "attachments": str(fields.get("attachments") or ""), } return { **dict(request.context_json or {}), "session_type": "expense", "entry_source": "steward_action_executor", "review_action": "save_draft", "review_form_values": review_form_values, "user_input_text": self._resolve_message(request), "role_codes": current_user.role_codes, "is_admin": current_user.is_admin, "username": current_user.username, "name": current_user.name, "department_name": current_user.department_name, "position": current_user.position, "grade": current_user.grade, "employee_no": current_user.employee_no, "manager_name": current_user.manager_name, } @staticmethod def _resolve_receipt_ids(request: StewardActionExecuteRequest) -> list[str]: context_json = dict(request.context_json or {}) raw_values = context_json.get("receipt_ids") or context_json.get("receiptIds") or [] if isinstance(raw_values, str): raw_values = [item.strip() for item in raw_values.split(",")] if not isinstance(raw_values, list): raw_values = [] receipt_ids = [ str(item or "").strip() for item in raw_values if str(item or "").strip() ] if receipt_ids: return list(dict.fromkeys(receipt_ids)) fields = StewardActionExecutor._resolve_ontology_fields(request.task) raw_receipt = str(fields.get("receipt_ids") or fields.get("receiptIds") or "").strip() if not raw_receipt: return [] return list(dict.fromkeys(item.strip() for item in raw_receipt.split(",") if item.strip())) @staticmethod def _resolve_submit_precheck_blocker(context_json: dict[str, Any]) -> str: precheck = context_json.get("precheck_result") or context_json.get("precheckResult") if not isinstance(precheck, dict): return "提交申请前需要先完成重复/冲突预检查。" if bool(precheck.get("blocking")): return str(precheck.get("summary") or "重复/冲突预检查未通过,已停止提交。").strip() status = str(precheck.get("status") or "").strip().lower() if status not in {"ok", "passed", "succeeded"}: return "重复/冲突预检查未通过,已停止提交。" return "" @staticmethod def _resolve_task_blockers(task: StewardTask | None) -> list[str]: if task is None: return [] return [ str(field or "").strip() for field in list(task.missing_fields or []) if str(field or "").strip() ] @staticmethod def _resolve_ontology_fields(task: StewardTask | None) -> dict[str, str]: if task is None or not isinstance(task.ontology_fields, dict): return {} return { str(key or "").strip(): str(value or "").strip() for key, value in task.ontology_fields.items() if str(key or "").strip() and str(value or "").strip() } @staticmethod def _resolve_message(request: StewardActionExecuteRequest) -> str: message = str(request.message or "").strip() if message: return message if request.task is not None: return str(request.task.summary or request.task.title or "").strip() return "小财管家动作执行" @staticmethod def _resolve_transport_label(value: Any) -> str: text = str(value or "").strip() return TRANSPORT_MODE_LABELS.get(text.lower(), text) @staticmethod def _resolve_application_type_label(value: Any) -> str: text = str(value or "").strip() return APPLICATION_TYPE_LABELS.get(text.lower(), text or "费用申请") @staticmethod def _resolve_expense_type_label(value: Any) -> str: text = str(value or "").strip() return EXPENSE_TYPE_LABELS.get(text.lower(), text or "其他费用") @staticmethod def _resolve_days_text(value: Any) -> str: days = resolve_application_days_from_time_range(str(value or "")) return f"{days}天" if days else "" @staticmethod def _normalize_action_type(value: str) -> str: return str(value or "").strip() @staticmethod def _build_run_id(request: StewardActionExecuteRequest, action_type: str) -> str: trace_id = str(request.client_trace_id or "").strip() if trace_id: return f"steward-action:{trace_id}" task_id = str(request.task.task_id if request.task is not None else "").strip() suffix = task_id or datetime.now(UTC).strftime("%Y%m%d%H%M%S%f") return f"steward-action:{action_type}:{suffix}" @staticmethod def _trace(stage: str, **extra: Any) -> dict[str, Any]: return { "stage": stage, "at": datetime.now(UTC).isoformat(), **extra, } @staticmethod def _blocked( action_type: str, message: str, *, blocked_reasons: list[str] | None = None, trace: list[dict[str, Any]] | None = None, ) -> StewardActionExecuteResponse: return StewardActionExecuteResponse( action_type=action_type, status="blocked", execution_source="rule_fallback", fallback_used=True, message=message, blocked_reasons=blocked_reasons or [message], trace=trace or [], ) def _failed( self, action_type: str, message: str, trace: list[dict[str, Any]], ) -> StewardActionExecuteResponse: return StewardActionExecuteResponse( action_type=action_type, status="failed", message=message or "动作执行失败。", blocked_reasons=[message] if message else [], trace=[*trace, self._trace("failed", reason=message)], )