feat(expenses): secure timeline and draft events

This commit is contained in:
caoxiaozhu
2026-07-14 00:07:07 +08:00
parent 22669a9071
commit 1347366b95
14 changed files with 1044 additions and 59 deletions

View File

@@ -0,0 +1,124 @@
from __future__ import annotations
import hashlib
import json
import uuid
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.models.expense_case import BusinessEvent
from app.models.financial_record import ExpenseClaim
from app.schemas.user_agent import UserAgentRequest
from app.services.expense_cases import ExpenseCaseService
class ExpenseApplicationDraftEventService:
"""为 AI 申请草稿生成稳定、租户隔离的费用事件。"""
def __init__(self, db: Session) -> None:
self.db = db
def record(
self,
payload: UserAgentRequest,
claim: ExpenseClaim,
current_user: CurrentUserContext,
*,
event_type: str,
previous_status: str = "",
previous_approval_stage: str = "",
) -> None:
ExpenseCaseService(self.db).record_claim_event(
claim,
event_type=event_type,
actor_id=current_user.username,
tenant_id=current_user.tenant_id,
correlation_id=payload.run_id,
idempotency_key=self._build_idempotency_key(
payload,
claim,
current_user,
event_type=event_type,
),
previous_status=previous_status,
previous_approval_stage=previous_approval_stage,
)
def prepare_created_draft(
self,
payload: UserAgentRequest,
claim: ExpenseClaim,
current_user: CurrentUserContext,
) -> tuple[str, ExpenseClaim | None]:
"""生成数据库级稳定聚合 ID并查找同一动作已创建的草稿。"""
idempotency_key = self._build_idempotency_key(
payload,
claim,
current_user,
event_type="claim_draft_created",
)
claim.id = str(uuid.uuid5(uuid.NAMESPACE_URL, idempotency_key))
existing = self.find_created_draft(current_user, idempotency_key=idempotency_key)
if existing is not None:
return idempotency_key, existing
if self.db.get(ExpenseClaim, claim.id) is not None:
raise RuntimeError("申请草稿幂等状态不完整,请重试。")
return idempotency_key, None
def find_created_draft(
self,
current_user: CurrentUserContext,
*,
idempotency_key: str,
) -> ExpenseClaim | None:
event = self.db.scalar(
select(BusinessEvent)
.where(
BusinessEvent.tenant_id == current_user.tenant_id,
BusinessEvent.aggregate_type == "expense_claim",
BusinessEvent.event_type == "claim_draft_created",
BusinessEvent.idempotency_key == idempotency_key,
BusinessEvent.actor_id == current_user.username,
)
.order_by(BusinessEvent.occurred_at.desc())
.limit(1)
)
if event is None:
return None
return self.db.get(ExpenseClaim, event.aggregate_id)
@staticmethod
def _build_idempotency_key(
payload: UserAgentRequest,
claim: ExpenseClaim,
current_user: CurrentUserContext,
*,
event_type: str,
) -> str:
snapshot = {
"tenant_id": str(current_user.tenant_id or "default"),
"actor_id": str(current_user.username or "anonymous"),
"run_id": str(payload.run_id or "application-draft"),
"expense_type": str(claim.expense_type or ""),
"reason": str(claim.reason or ""),
"location": str(claim.location or ""),
"amount": str(claim.amount or Decimal("0.00")),
"currency": str(claim.currency or "CNY"),
"status": str(claim.status or ""),
"approval_stage": str(claim.approval_stage or ""),
"risk_flags": claim.risk_flags_json or [],
}
serialized = json.dumps(
snapshot,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
)
digest = hashlib.sha256(serialized.encode("utf-8")).hexdigest()
return f"application-draft:{event_type}:{digest}"

View File

@@ -422,6 +422,7 @@ class StewardActionExecutor:
"entry_source": "steward_action_executor",
"document_type": "expense_application",
"application_stage": "expense_application",
"tenant_id": current_user.tenant_id,
"role_codes": current_user.role_codes,
"is_admin": current_user.is_admin,
"username": current_user.username,

View File

@@ -5,6 +5,7 @@ from datetime import UTC, datetime
from decimal import Decimal, InvalidOperation
from sqlalchemy import or_, select
from sqlalchemy.exc import IntegrityError
from app.api.deps import CurrentUserContext
from app.models.financial_record import ExpenseClaim
@@ -24,6 +25,9 @@ from app.services.document_numbering import (
build_document_number,
generate_unique_expense_claim_no,
)
from app.services.expense_application_draft_events import (
ExpenseApplicationDraftEventService,
)
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
from app.services.expense_claim_risk_stage import with_risk_business_stage
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
@@ -701,6 +705,8 @@ class UserAgentApplicationPersistenceMixin:
submit: bool,
) -> ExpenseClaim:
current_user = self._build_application_current_user(payload)
previous_status = str(claim.status or "").strip()
previous_approval_stage = str(claim.approval_stage or "").strip()
flags = claim.risk_flags_json
if isinstance(flags, dict):
flags = [flags]
@@ -725,9 +731,21 @@ class UserAgentApplicationPersistenceMixin:
claim.status = "draft"
claim.approval_stage = "待提交"
claim.submitted_at = None
self.db.commit()
self.db.refresh(claim)
return claim
try:
ExpenseApplicationDraftEventService(self.db).record(
payload,
claim,
current_user,
event_type="claim_draft_updated",
previous_status=previous_status,
previous_approval_stage=previous_approval_stage,
)
self.db.commit()
self.db.refresh(claim)
return claim
except Exception:
self.db.rollback()
raise
from app.services.expense_claims import ExpenseClaimService
@@ -743,15 +761,6 @@ class UserAgentApplicationPersistenceMixin:
*,
submit: bool,
) -> ExpenseClaim:
claim_no = self._build_application_claim_no(payload, facts)
existing = self.db.scalar(
select(ExpenseClaim)
.where(ExpenseClaim.claim_no == claim_no)
.limit(1)
)
if existing is not None:
return existing
current_user = self._build_application_current_user(payload)
access_policy = ExpenseClaimAccessPolicy(self.db)
employee = access_policy.resolve_current_employee(current_user)
@@ -768,7 +777,7 @@ class UserAgentApplicationPersistenceMixin:
department_name = str(employee.organization_unit.name).strip()
claim = ExpenseClaim(
claim_no=claim_no,
claim_no=self._build_application_claim_no(payload, facts),
employee_id=employee_id,
employee_name=employee_name,
department_id=department_id,
@@ -786,25 +795,52 @@ class UserAgentApplicationPersistenceMixin:
approval_stage="待提交",
risk_flags_json=[self._build_application_detail_flag(facts)],
)
self.db.add(claim)
self.db.flush()
draft_event_service = ExpenseApplicationDraftEventService(self.db)
draft_idempotency_key = ""
if not submit:
self.db.commit()
self.db.refresh(claim)
return claim
from app.services.expense_claims import ExpenseClaimService
draft_idempotency_key, existing = draft_event_service.prepare_created_draft(
payload,
claim,
current_user,
)
if existing is not None:
return existing
self.db.add(claim)
try:
try:
self.db.flush()
except IntegrityError:
self.db.rollback()
if draft_idempotency_key:
existing = draft_event_service.find_created_draft(
current_user,
idempotency_key=draft_idempotency_key,
)
if existing is not None:
return existing
raise
if not submit:
draft_event_service.record(
payload,
claim,
current_user,
event_type="claim_draft_created",
)
self.db.commit()
self.db.refresh(claim)
return claim
from app.services.expense_claims import ExpenseClaimService
submitted = ExpenseClaimService(self.db).submit_claim(claim.id, current_user)
if submitted is None:
raise ValueError("未找到可提交的申请单。")
return submitted
except Exception:
# 外层编排会记录失败工具调用并提交事务,必须先回滚本次已 flush 的草稿。
self.db.rollback()
raise
if submitted is None:
self.db.rollback()
raise ValueError("未找到可提交的申请单。")
return submitted
def _find_duplicate_expense_application_record(
self,
@@ -974,6 +1010,12 @@ class UserAgentApplicationPersistenceMixin:
name=name or username or "anonymous",
role_codes=role_codes,
is_admin=bool(context_json.get("is_admin")),
tenant_id=str(
context_json.get("tenant_id")
or context_json.get("tenantId")
or "default"
).strip()
or "default",
department_name=str(
context_json.get("department_name")
or context_json.get("department")