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