Files
X-Financial/server/src/app/services/expense_application_preview_workflow.py
2026-07-14 17:16:23 +08:00

261 lines
10 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.
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,
)