2026-07-14 16:03:05 +08:00
|
|
|
|
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
|
2026-07-14 17:16:23 +08:00
|
|
|
|
from app.services.application_system_estimate import (
|
|
|
|
|
|
apply_application_system_estimate_to_facts,
|
|
|
|
|
|
)
|
|
|
|
|
|
from app.services.expense_application_memory import ExpenseApplicationMemoryService
|
2026-07-14 16:03:05 +08:00
|
|
|
|
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)
|
2026-07-14 17:16:23 +08:00
|
|
|
|
memory_applications = ExpenseApplicationMemoryService(
|
|
|
|
|
|
self.db
|
|
|
|
|
|
).apply_active_transport_memory(facts, current_user)
|
|
|
|
|
|
if memory_applications:
|
|
|
|
|
|
# 交通方式会影响系统预估;记忆补空后必须在签名前重算派生字段。
|
|
|
|
|
|
apply_application_system_estimate_to_facts(facts)
|
2026-07-14 16:03:05 +08:00
|
|
|
|
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,
|
2026-07-14 17:16:23 +08:00
|
|
|
|
"memoryApplications": [
|
|
|
|
|
|
item.model_dump(mode="json") for item in memory_applications
|
|
|
|
|
|
],
|
2026-07-14 16:03:05 +08:00
|
|
|
|
"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__,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-14 17:16:23 +08:00
|
|
|
|
learning_receipts = self._resolve_learning_receipts(
|
|
|
|
|
|
consumed_preview_decision_id,
|
|
|
|
|
|
current_user,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-14 16:03:05 +08:00
|
|
|
|
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
|
|
|
|
|
|
),
|
2026-07-14 17:16:23 +08:00
|
|
|
|
learning_receipts=[
|
|
|
|
|
|
item.model_dump(mode="json") for item in learning_receipts
|
|
|
|
|
|
],
|
2026-07-14 16:03:05 +08:00
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-14 17:16:23 +08:00
|
|
|
|
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 []
|
|
|
|
|
|
|
2026-07-14 16:03:05 +08:00
|
|
|
|
@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,
|
|
|
|
|
|
)
|