feat(expenses): add transactional expense case events

This commit is contained in:
caoxiaozhu
2026-07-13 11:58:48 +08:00
parent 9a84e125d0
commit 661990b27b
21 changed files with 1973 additions and 63 deletions

View File

@@ -38,6 +38,11 @@ from app.services.agent_foundation_spreadsheets import AgentFoundationSpreadshee
logger = get_logger("app.services.agent_foundation")
_foundation_ready_lock = threading.RLock()
_foundation_ready_keys: set[str] = set()
MIGRATION_OWNED_TABLES = {
"expense_cases",
"expense_case_links",
"business_events",
}
def prepare_agent_foundation() -> None:
@@ -77,7 +82,12 @@ class AgentFoundationService(
def _prepare_foundation(self) -> None:
try:
Base.metadata.create_all(bind=self.db.get_bind())
legacy_bootstrap_tables = [
table
for table in Base.metadata.sorted_tables
if table.name not in MIGRATION_OWNED_TABLES
]
Base.metadata.create_all(bind=self.db.get_bind(), tables=legacy_bootstrap_tables)
self._ensure_agent_asset_schema()
self._ensure_financial_record_schema()
self._seed_agent_assets()

View File

@@ -1,53 +1,54 @@
from __future__ import annotations
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Any
from sqlalchemy.orm import Session
from app.core.logging import get_logger
from app.models.audit_log import AuditLog
from app.repositories.audit_log import AuditLogRepository
from app.schemas.audit_log import AuditLogRead
from app.services.agent_foundation import AgentFoundationService
logger = get_logger("app.services.audit")
class AuditLogService:
def __init__(self, db: Session) -> None:
self.db = db
self.repository = AuditLogRepository(db)
def list_logs(
self,
*,
resource_type: str | None = None,
resource_id: str | None = None,
action: str | None = None,
limit: int = 50,
) -> list[AuditLogRead]:
self._ensure_ready()
items = self.repository.list(
resource_type=resource_type,
resource_id=resource_id,
action=action,
limit=limit,
)
return [AuditLogRead.model_validate(item) for item in items]
def log_action(
self,
*,
actor: str,
action: str,
resource_type: str,
resource_id: str,
before_json: dict[str, Any] | None = None,
after_json: dict[str, Any] | None = None,
request_id: str | None = None,
) -> AuditLog:
from sqlalchemy.orm import Session
from app.core.logging import get_logger
from app.models.audit_log import AuditLog
from app.repositories.audit_log import AuditLogRepository
from app.schemas.audit_log import AuditLogRead
from app.services.agent_foundation import AgentFoundationService
logger = get_logger("app.services.audit")
class AuditLogService:
def __init__(self, db: Session) -> None:
self.db = db
self.repository = AuditLogRepository(db)
def list_logs(
self,
*,
resource_type: str | None = None,
resource_id: str | None = None,
action: str | None = None,
limit: int = 50,
) -> list[AuditLogRead]:
self._ensure_ready()
items = self.repository.list(
resource_type=resource_type,
resource_id=resource_id,
action=action,
limit=limit,
)
return [AuditLogRead.model_validate(item) for item in items]
def log_action(
self,
*,
actor: str,
action: str,
resource_type: str,
resource_id: str,
before_json: dict[str, Any] | None = None,
after_json: dict[str, Any] | None = None,
request_id: str | None = None,
commit: bool = True,
) -> AuditLog:
log = AuditLog(
actor=actor,
action=action,
@@ -58,15 +59,15 @@ class AuditLogService:
request_id=request_id or uuid.uuid4().hex,
created_at=datetime.now(UTC),
)
created = self.repository.create(log)
logger.info(
"Created audit log id=%s action=%s resource=%s:%s",
created.id,
created.action,
created.resource_type,
created.resource_id,
)
return created
def _ensure_ready(self) -> None:
AgentFoundationService(self.db).ensure_foundation_ready()
created = self.repository.create(log, commit=commit)
logger.info(
"Created audit log id=%s action=%s resource=%s:%s",
created.id,
created.action,
created.resource_type,
created.resource_id,
)
return created
def _ensure_ready(self) -> None:
AgentFoundationService(self.db).ensure_foundation_ready()

View File

@@ -0,0 +1,306 @@
from __future__ import annotations
import hashlib
import uuid
from datetime import UTC, datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.models.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink
from app.models.financial_record import ExpenseClaim
from app.services.document_numbering import is_application_claim_no
DEFAULT_TENANT_ID = "default"
class ExpenseCaseService:
"""费用事件编排的最小持久化边界。
该服务只允许 add/flush不负责 commit。调用方必须让业务状态与事件在同一事务中提交。
"""
def __init__(self, db: Session) -> None:
self.db = db
@staticmethod
def normalize_tenant_id(value: str | None) -> str:
return str(value or DEFAULT_TENANT_ID).strip() or DEFAULT_TENANT_ID
@staticmethod
def normalize_correlation_id(value: str | None) -> str:
text = str(value or "").strip()
if not text:
return uuid.uuid4().hex
if len(text) <= 64:
return text
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
return f"sha256:{digest[:57]}"
def ensure_case_for_claim(
self,
claim: ExpenseClaim,
*,
tenant_id: str | None = None,
relation_type: str | None = None,
) -> ExpenseCase:
if not claim.id:
self.db.flush()
if not claim.id:
raise ValueError("费用单据尚未生成 ID无法关联费用事件。")
normalized_tenant = self.normalize_tenant_id(tenant_id)
existing_link = self.db.scalar(
select(ExpenseCaseLink).where(
ExpenseCaseLink.resource_type == "expense_claim",
ExpenseCaseLink.resource_id == claim.id,
)
)
if existing_link is not None:
if existing_link.tenant_id != normalized_tenant:
raise PermissionError("费用单据已属于其他租户。")
expense_case = self.db.get(ExpenseCase, existing_link.expense_case_id)
if expense_case is None or expense_case.tenant_id != normalized_tenant:
raise RuntimeError("费用事件关联已损坏。")
return expense_case
case_no = f"CASE-{str(claim.claim_no or claim.id).strip()}"
expense_case = self.db.scalar(
select(ExpenseCase).where(
ExpenseCase.tenant_id == normalized_tenant,
ExpenseCase.case_no == case_no,
)
)
if expense_case is None:
expense_case = ExpenseCase(
id=str(uuid.uuid4()),
tenant_id=normalized_tenant,
case_no=case_no,
scene_code=self._scene_code(claim),
title=self._case_title(claim),
owner_employee_id=claim.employee_id,
current_stage=self._stage_for_claim(claim),
status=self._case_status_for_claim(claim),
)
self.db.add(expense_case)
self.db.flush()
self.link_claim(
expense_case,
claim,
tenant_id=normalized_tenant,
relation_type=relation_type or self._relation_type(claim),
)
return expense_case
def link_claim(
self,
expense_case: ExpenseCase,
claim: ExpenseClaim,
*,
tenant_id: str | None = None,
relation_type: str,
) -> ExpenseCaseLink:
if not claim.id:
self.db.flush()
normalized_tenant = self.normalize_tenant_id(tenant_id or expense_case.tenant_id)
if expense_case.tenant_id != normalized_tenant:
raise PermissionError("不能把费用单据关联到其他租户的费用事件。")
existing_link = self.db.scalar(
select(ExpenseCaseLink).where(
ExpenseCaseLink.resource_type == "expense_claim",
ExpenseCaseLink.resource_id == claim.id,
)
)
if existing_link is not None:
if (
existing_link.tenant_id != normalized_tenant
or existing_link.expense_case_id != expense_case.id
):
raise PermissionError("费用单据已经关联到其他费用事件。")
return existing_link
link = ExpenseCaseLink(
id=str(uuid.uuid4()),
tenant_id=normalized_tenant,
expense_case_id=expense_case.id,
resource_type="expense_claim",
resource_id=claim.id,
relation_type=relation_type,
)
self.db.add(link)
self.db.flush()
return link
def record_claim_event(
self,
claim: ExpenseClaim,
*,
event_type: str,
actor_id: str,
tenant_id: str | None = None,
correlation_id: str | None = None,
idempotency_key: str | None = None,
causation_id: str | None = None,
previous_status: str | None = None,
previous_approval_stage: str | None = None,
extra_payload: dict[str, Any] | None = None,
expense_case: ExpenseCase | None = None,
relation_type: str | None = None,
update_case_state: bool = True,
) -> tuple[ExpenseCase, BusinessEvent]:
normalized_tenant = self.normalize_tenant_id(tenant_id)
if expense_case is None:
expense_case = self.ensure_case_for_claim(
claim,
tenant_id=normalized_tenant,
relation_type=relation_type,
)
else:
self.link_claim(
expense_case,
claim,
tenant_id=normalized_tenant,
relation_type=relation_type or self._relation_type(claim),
)
if update_case_state:
expense_case.current_stage = self._stage_for_claim(claim)
expense_case.status = self._case_status_for_claim(claim)
if claim.employee_id and not expense_case.owner_employee_id:
expense_case.owner_employee_id = claim.employee_id
payload: dict[str, Any] = {
"claim_no": str(claim.claim_no or ""),
"expense_type": str(claim.expense_type or ""),
"amount": self._money_text(claim.amount),
"currency": str(claim.currency or "CNY"),
"previous_status": str(previous_status or ""),
"previous_approval_stage": str(previous_approval_stage or ""),
"next_status": str(claim.status or ""),
"next_approval_stage": str(claim.approval_stage or ""),
}
payload.update(extra_payload or {})
normalized_correlation_id = self.normalize_correlation_id(correlation_id)
normalized_idempotency_key = self._normalize_idempotency_key(
idempotency_key or normalized_correlation_id
)
existing_event = self.db.scalar(
select(BusinessEvent).where(
BusinessEvent.tenant_id == normalized_tenant,
BusinessEvent.aggregate_type == "expense_claim",
BusinessEvent.aggregate_id == claim.id,
BusinessEvent.event_type == str(event_type).strip(),
BusinessEvent.idempotency_key == normalized_idempotency_key,
)
)
if existing_event is not None:
return expense_case, existing_event
event = BusinessEvent(
id=str(uuid.uuid4()),
tenant_id=normalized_tenant,
expense_case_id=expense_case.id,
aggregate_type="expense_claim",
aggregate_id=claim.id,
event_type=str(event_type).strip(),
event_version=1,
idempotency_key=normalized_idempotency_key,
correlation_id=normalized_correlation_id,
causation_id=self.normalize_correlation_id(causation_id) if causation_id else None,
actor_id=str(actor_id or "system").strip() or "system",
actor_type="system" if str(actor_id or "").strip() == "system" else "user",
payload_json=payload,
delivery_status="pending",
occurred_at=datetime.now(UTC),
)
self.db.add(event)
self.db.flush()
return expense_case, event
@staticmethod
def _normalize_idempotency_key(value: str) -> str:
text = str(value or "").strip()
if not text:
raise ValueError("业务事件必须提供幂等键。")
if len(text) <= 120:
return text
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
return f"sha256:{digest}"
def get_timeline_for_claim(
self,
claim_id: str,
*,
tenant_id: str | None = None,
) -> ExpenseCase | None:
normalized_tenant = self.normalize_tenant_id(tenant_id)
link = self.db.scalar(
select(ExpenseCaseLink).where(
ExpenseCaseLink.resource_type == "expense_claim",
ExpenseCaseLink.resource_id == claim_id,
)
)
if link is None or link.tenant_id != normalized_tenant:
return None
return self.db.scalar(
select(ExpenseCase)
.options(selectinload(ExpenseCase.links), selectinload(ExpenseCase.events))
.where(
ExpenseCase.id == link.expense_case_id,
ExpenseCase.tenant_id == normalized_tenant,
)
)
@staticmethod
def _money_text(value: Decimal | None) -> str:
return f"{Decimal(value or Decimal('0.00')).quantize(Decimal('0.01')):.2f}"
@staticmethod
def _scene_code(claim: ExpenseClaim) -> str:
expense_type = str(claim.expense_type or "").strip().lower()
return expense_type.removesuffix("_application") or "other"
@staticmethod
def _case_title(claim: ExpenseClaim) -> str:
reason = str(claim.reason or "").strip()
return reason[:200] or f"费用事件 {claim.claim_no}"
@classmethod
def _relation_type(cls, claim: ExpenseClaim) -> str:
claim_no = str(claim.claim_no or "").strip()
expense_type = str(claim.expense_type or "").strip().lower()
is_application = (
is_application_claim_no(claim_no)
or expense_type == "application"
or expense_type.endswith("_application")
)
return "application" if is_application else "claim"
@classmethod
def _stage_for_claim(cls, claim: ExpenseClaim) -> str:
status = str(claim.status or "").strip().lower()
if status in {"pending_payment"}:
return "paying"
if status in {"paid"}:
return "accounting"
if status in {"completed"}:
return "closed"
if status in {"submitted", "approved"}:
if cls._relation_type(claim) == "application" and status == "approved":
return "approved_to_spend"
return "reviewing"
return "claiming"
@staticmethod
def _case_status_for_claim(claim: ExpenseClaim) -> str:
status = str(claim.status or "").strip().lower()
if status in {"cancelled", "voided", "rejected"}:
return "cancelled"
if status == "completed":
return "closed"
return "active"

View File

@@ -238,6 +238,10 @@ class ExpenseClaimApplicationHandoffMixin:
{
"application_claim_id": application_claim.id,
"application_claim_no": str(application_claim.claim_no or "").strip(),
"archive_event_id": str(archive_flag.get("archive_event_id") or ""),
"previous_status": previous_status,
"previous_approval_stage": previous_stage,
"next_status": "approved",
"next_approval_stage": APPLICATION_ARCHIVE_STAGE,
}
)
@@ -248,6 +252,7 @@ class ExpenseClaimApplicationHandoffMixin:
resource_id=application_claim.id,
before_json=before_json,
after_json=self._serialize_claim(application_claim),
commit=False,
)
return archived_applications
@@ -396,6 +401,7 @@ class ExpenseClaimApplicationHandoffMixin:
resource_id=application_claim.id,
before_json=before_json,
after_json=self._serialize_claim(application_claim),
commit=False,
)
return synced_applications

View File

@@ -6,6 +6,7 @@ from decimal import Decimal, InvalidOperation
from typing import Any
from app.api.deps import CurrentUserContext
from app.models.financial_record import ExpenseClaim
from app.services.budget import BudgetService
from app.services.expense_claim_workflow_constants import (
APPLICATION_LINK_STATUS_STAGE,
@@ -45,6 +46,7 @@ class ExpenseClaimApprovalFlowMixin:
next_budget_manager = None
merged_budget_approval = False
route_decision_flag: dict[str, Any] | None = None
generated_draft = None
if previous_stage == DIRECT_MANAGER_APPROVAL_STAGE:
if not self._access_policy.can_approve_claim(current_user, claim):
raise ValueError("只有当前直属领导审批人可以审批通过该单据。")
@@ -254,6 +256,48 @@ class ExpenseClaimApprovalFlowMixin:
business_stage=business_stage,
)
correlation_id = str(approval_flag.get("approval_event_id") or uuid.uuid4())
structured_event_type = "approval_stage_completed"
if is_application_claim and next_status == "approved":
structured_event_type = "application_approved"
elif not is_application_claim and next_status == PAYMENT_PENDING_STATUS:
structured_event_type = "claim_approved"
expense_case, _event = self._expense_cases.record_claim_event(
claim,
event_type=structured_event_type,
actor_id=current_user.username,
tenant_id=getattr(current_user, "tenant_id", None),
correlation_id=correlation_id,
idempotency_key=correlation_id,
previous_status=str(before_json.get("status") or ""),
previous_approval_stage=previous_stage,
extra_payload={
"workflow_event_type": event_type,
"opinion": approval_opinion,
"route_requires_budget_review": bool(
route_decision_flag and route_decision_flag.get("requires_budget_review")
),
},
)
if generated_draft is not None:
self._expense_cases.record_claim_event(
generated_draft,
event_type="reimbursement_draft_generated",
actor_id="system",
tenant_id=getattr(current_user, "tenant_id", None),
correlation_id=correlation_id,
idempotency_key=correlation_id,
causation_id=_event.id,
previous_status="",
previous_approval_stage="",
extra_payload={
"application_claim_id": claim.id,
"application_claim_no": claim.claim_no,
},
expense_case=expense_case,
relation_type="generated_reimbursement",
)
self.db.commit()
self.db.refresh(claim)
self._access_policy.attach_budget_approval_snapshot(claim)
@@ -326,6 +370,47 @@ class ExpenseClaimApprovalFlowMixin:
claim.approval_stage = PAYMENT_PAID_STAGE
claim.risk_flags_json = [*list(claim.risk_flags_json or []), payment_flag]
payment_correlation_id = str(payment_flag.get("payment_event_id") or uuid.uuid4())
expense_case, payment_event = self._expense_cases.record_claim_event(
claim,
event_type="payment_completed",
actor_id=current_user.username,
tenant_id=getattr(current_user, "tenant_id", None),
correlation_id=payment_correlation_id,
idempotency_key=payment_correlation_id,
previous_status=str(before_json.get("status") or ""),
previous_approval_stage=previous_stage,
extra_payload={"archived_applications": archived_applications},
)
for archived_application in archived_applications:
application_claim = self.db.get(
ExpenseClaim,
str(archived_application.get("application_claim_id") or ""),
)
if application_claim is None:
raise RuntimeError("付款关联的申请单已不存在,无法记录归档事件。")
archive_event_id = str(archived_application.get("archive_event_id") or "").strip()
self._expense_cases.record_claim_event(
application_claim,
event_type="application_archived",
actor_id=current_user.username,
tenant_id=getattr(current_user, "tenant_id", None),
correlation_id=payment_correlation_id,
idempotency_key=archive_event_id,
causation_id=payment_event.id,
previous_status=str(archived_application.get("previous_status") or ""),
previous_approval_stage=str(
archived_application.get("previous_approval_stage") or ""
),
extra_payload={
"reimbursement_claim_id": claim.id,
"reimbursement_claim_no": claim.claim_no,
},
expense_case=expense_case,
relation_type="application",
update_case_state=False,
)
self.db.commit()
self.db.refresh(claim)

View File

@@ -1020,6 +1020,15 @@ class ExpenseClaimDraftFlowMixin(ExpenseClaimApplicationLinkMixin, ExpenseClaimD
self._sync_claim_from_items(claim)
if locked_expense_type:
claim.expense_type = locked_expense_type
self._expense_cases.record_claim_event(
claim,
event_type=("claim_draft_created" if is_new_claim else "claim_draft_updated"),
actor_id=user_id or claim.employee_name or "system",
correlation_id=run_id,
idempotency_key=run_id,
previous_status=str((before_json or {}).get("status") or ""),
previous_approval_stage=str((before_json or {}).get("approval_stage") or ""),
)
self.db.commit()
self.db.refresh(claim)
except IntegrityError as exc:
@@ -1066,4 +1075,3 @@ class ExpenseClaimDraftFlowMixin(ExpenseClaimApplicationLinkMixin, ExpenseClaimD
"amount": float(claim.amount),
"invoice_count": int(claim.invoice_count or 0),
}

View File

@@ -115,6 +115,7 @@ from app.services.expense_claim_constants import (
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
STANDARD_ADJUSTMENT_RISK_SOURCE,
)
from app.services.expense_cases import ExpenseCaseService
from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin
from app.services.expense_amounts import (
extract_amount_candidates,
@@ -645,6 +646,20 @@ class ExpenseClaimItemActionMixin:
claim.risk_flags_json = dedupe_claim_risk_flags(claim.risk_flags_json)
self._expense_cases.record_claim_event(
claim,
event_type=("application_submitted" if is_application_claim else "claim_submitted"),
actor_id=current_user.username,
tenant_id=getattr(current_user, "tenant_id", None),
idempotency_key=(
f"submit:{claim.id}:{claim.submitted_at.isoformat()}"
if claim.submitted_at is not None
else f"submit:{claim.id}:{before_json.get('status') or 'draft'}"
),
previous_status=str(before_json.get("status") or ""),
previous_approval_stage=str(before_json.get("approval_stage") or ""),
)
self.db.commit()
self.db.refresh(claim)
@@ -843,6 +858,20 @@ class ExpenseClaimItemActionMixin:
business_stage="expense_application" if is_application_claim else "reimbursement",
)
self._expense_cases.record_claim_event(
claim,
event_type=("application_returned" if is_application_claim else "claim_returned"),
actor_id=current_user.username,
tenant_id=getattr(current_user, "tenant_id", None),
idempotency_key=str(return_flag.get("return_event_id") or ""),
previous_status=previous_status,
previous_approval_stage=previous_stage,
extra_payload={
"reason": message,
"reason_codes": normalized_reason_codes,
},
)
self.db.commit()
self.db.refresh(claim)
@@ -862,6 +891,7 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA
def __init__(self, db: Session) -> None:
self.db = db
self.audit_service = AuditLogService(db)
self._expense_cases = ExpenseCaseService(db)
self._access_policy = ExpenseClaimAccessPolicy(db)
self._attachment_storage = ExpenseClaimAttachmentStorage()
self._attachment_presentation = ExpenseClaimAttachmentPresentation(self._attachment_storage)