feat(expenses): secure timeline and draft events
This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user