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

@@ -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")