from __future__ import annotations import logging from sqlalchemy.orm import Session from app.api.deps import CurrentUserContext from app.schemas.expense_application_preview import ( ExpenseApplicationPreviewDecisionCreate, ExpenseApplicationPreviewDecisionRead, ) from app.schemas.ontology import OntologyParseResult, OntologyPermission from app.schemas.reimbursement import ( ExpenseApplicationPreviewActionPayload, ExpenseApplicationPreviewActionResponse, ExpenseApplicationPreviewActionResult, ) from app.schemas.user_agent import UserAgentRequest from app.services.application_system_estimate import ( apply_application_system_estimate_to_facts, ) from app.services.expense_application_memory import ExpenseApplicationMemoryService from app.services.expense_application_preview_decisions import ( ExpenseApplicationPreviewDecisionService, PreviewDecisionConflictError, ) from app.services.expense_application_request_context import ( build_trusted_expense_application_context, ) from app.services.user_agent import UserAgentService logger = logging.getLogger(__name__) class ExpenseApplicationPreviewWorkflow: """编排费用申请预览的签发、消费和草稿续签。""" def __init__(self, db: Session) -> None: self.db = db def issue( self, payload: ExpenseApplicationPreviewDecisionCreate, current_user: CurrentUserContext, ) -> ExpenseApplicationPreviewDecisionRead: run_id = f"application-preview-decision:{payload.request_id}" request = UserAgentRequest( run_id=run_id, user_id=current_user.username or current_user.name, message=payload.message, ontology=OntologyParseResult(run_id=run_id), context_json=build_trusted_expense_application_context(current_user), tool_payload={}, selected_capability_codes=[], degraded=False, requires_confirmation=False, ) try: facts = UserAgentService(self.db)._resolve_expense_application_facts(request) memory_applications = ExpenseApplicationMemoryService( self.db ).apply_active_transport_memory(facts, current_user) if memory_applications: # 交通方式会影响系统预估;记忆补空后必须在签名前重算派生字段。 apply_application_system_estimate_to_facts(facts) issued = ExpenseApplicationPreviewDecisionService(self.db).issue( facts, current_user, conversation_id=payload.conversation_id or "", request_id=payload.request_id, ) self.db.commit() self.db.refresh(issued.decision) except (PreviewDecisionConflictError, ValueError): self.db.rollback() raise return ExpenseApplicationPreviewDecisionRead( decision_id=issued.decision.id, decision_source=issued.decision.decision_source, expires_at=issued.decision.expires_at, application_preview={ "fields": issued.fields, "memoryApplications": [ item.model_dump(mode="json") for item in memory_applications ], "decisionId": issued.decision.id, "decisionSource": issued.decision.decision_source, "decisionExpiresAt": issued.decision.expires_at.isoformat(), }, ) def execute( self, payload: ExpenseApplicationPreviewActionPayload, current_user: CurrentUserContext, ) -> ExpenseApplicationPreviewActionResponse: request = self._build_action_request(payload, current_user) try: user_agent_service = UserAgentService(self.db) facts = user_agent_service._resolve_expense_application_facts(request) resolved_step = user_agent_service._resolve_expense_application_step(request, facts) resolved_action = "save_draft" if resolved_step == "draft" else "submit" if payload.action_type and payload.action_type != resolved_action: raise ValueError("动作类型与申请内容不一致。") preview_decision_service = ExpenseApplicationPreviewDecisionService(self.db) preview_decision = None if payload.decision_id: preview_decision = preview_decision_service.require_for_action( payload.decision_id, current_user, conversation_id=payload.conversation_id or "", request_id=payload.request_id or "", action_type=resolved_action, final_values=facts, ) else: preview_decision_service.reject_active_decision_bypass( current_user, conversation_id=payload.conversation_id or "", ) consumed_preview_decision_id = ( preview_decision.id if preview_decision is not None else "" ) user_agent_response = user_agent_service._build_expense_application_response( request, risk_flags=[], learning_current_user=current_user, learning_preview_decision=preview_decision, learning_action_request_id=payload.request_id or "", ) except (PreviewDecisionConflictError, ValueError): self.db.rollback() raise next_preview_decision = None if ( preview_decision is not None and resolved_action == "save_draft" and user_agent_response.draft_payload is not None ): try: next_preview_decision = ( ExpenseApplicationPreviewDecisionService(self.db) .issue( facts, current_user, conversation_id=payload.conversation_id or "", request_id=( f"next:{consumed_preview_decision_id}:{payload.request_id or ''}" )[:120], decision_source="server_draft", ) .decision ) self.db.commit() self.db.refresh(next_preview_decision) except Exception as error: # 主业务动作已提交;续签失败只能降级,不能反转保存草稿结果。 self.db.rollback() next_preview_decision = None logger.warning( "费用申请草稿已保存,但后续预览决策签发失败:decision_id=%s error_type=%s", consumed_preview_decision_id, type(error).__name__, ) learning_receipts = self._resolve_learning_receipts( consumed_preview_decision_id, current_user, ) return ExpenseApplicationPreviewActionResponse( status="succeeded", conversation_id=payload.conversation_id, result=ExpenseApplicationPreviewActionResult( message=user_agent_response.answer, answer=user_agent_response.answer, suggested_actions=[ action.model_dump(mode="json") for action in user_agent_response.suggested_actions ], risk_flags=user_agent_response.risk_flags, requires_confirmation=user_agent_response.requires_confirmation, draft_payload=( user_agent_response.draft_payload.model_dump(mode="json") if user_agent_response.draft_payload is not None else None ), decision_id=( next_preview_decision.id if next_preview_decision is not None else None ), decision_expires_at=( next_preview_decision.expires_at if next_preview_decision is not None else None ), learning_receipts=[ item.model_dump(mode="json") for item in learning_receipts ], ), ) def _resolve_learning_receipts( self, preview_decision_id: str, current_user: CurrentUserContext, ) -> list: try: with self.db.begin_nested(): return ExpenseApplicationMemoryService( self.db ).learning_receipts_for_preview_decision( preview_decision_id, current_user, ) except Exception: logger.warning("个人记忆学习回执读取失败,本次申请动作保持成功。", exc_info=True) return [] @staticmethod def _build_action_request( payload: ExpenseApplicationPreviewActionPayload, current_user: CurrentUserContext, ) -> UserAgentRequest: context_json = build_trusted_expense_application_context( current_user, payload.context_json, ) if payload.action_type == "save_draft": context_json["application_action"] = "save_draft" context_json["application_save_mode"] = True elif payload.action_type == "submit": context_json.pop("application_action", None) context_json.pop("application_save_mode", None) run_id = f"application-preview-action:{payload.conversation_id or current_user.username}" return UserAgentRequest( run_id=run_id, user_id=current_user.username or current_user.name, message=payload.message, ontology=OntologyParseResult( scenario="expense", intent="operate", permission=OntologyPermission( level="approval_required", allowed=True, reason="application preview fast action", ), confidence=1.0, run_id=run_id, ), context_json=context_json, tool_payload={}, selected_capability_codes=[], degraded=False, requires_confirmation=False, )