feat(expenses): add transactional expense case events
This commit is contained in:
@@ -26,6 +26,7 @@ class CurrentUserContext:
|
||||
name: str
|
||||
role_codes: list[str]
|
||||
is_admin: bool
|
||||
tenant_id: str = "default"
|
||||
department_name: str = ""
|
||||
cost_center: str = ""
|
||||
position: str = ""
|
||||
@@ -101,6 +102,7 @@ def get_current_user(
|
||||
name=name or username,
|
||||
role_codes=role_codes,
|
||||
is_admin=is_admin,
|
||||
tenant_id="default",
|
||||
department_name=(x_auth_department or "").strip(),
|
||||
cost_center=(x_auth_cost_center or "").strip(),
|
||||
position=(x_auth_position or "").strip(),
|
||||
|
||||
42
server/src/app/api/v1/endpoints/expense_cases.py
Normal file
42
server/src/app/api/v1/endpoints/expense_cases.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUserContext, get_current_user, get_db
|
||||
from app.schemas.expense_case import ExpenseCaseTimelineRead
|
||||
from app.services.expense_cases import ExpenseCaseService
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
|
||||
router = APIRouter(prefix="/expense-cases")
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/by-claim/{claim_id}",
|
||||
response_model=ExpenseCaseTimelineRead,
|
||||
summary="查询报销单所属费用事件时间线",
|
||||
description="返回当前用户有权查看的费用事件、关联单据和结构化业务事件。",
|
||||
)
|
||||
def get_expense_case_by_claim(
|
||||
claim_id: str,
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
) -> ExpenseCaseTimelineRead:
|
||||
claim = ExpenseClaimService(db).get_claim(claim_id, current_user)
|
||||
if claim is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="费用单据不存在。")
|
||||
|
||||
expense_case = ExpenseCaseService(db).get_timeline_for_claim(
|
||||
claim.id,
|
||||
tenant_id=current_user.tenant_id,
|
||||
)
|
||||
if expense_case is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="该单据尚未纳入统一费用事件。",
|
||||
)
|
||||
return ExpenseCaseTimelineRead.model_validate(expense_case)
|
||||
@@ -12,6 +12,7 @@ from app.api.v1.endpoints.auth import router as auth_router
|
||||
from app.api.v1.endpoints.bootstrap import router as bootstrap_router
|
||||
from app.api.v1.endpoints.budgets import router as budgets_router
|
||||
from app.api.v1.endpoints.employees import router as employees_router
|
||||
from app.api.v1.endpoints.expense_cases import router as expense_cases_router
|
||||
from app.api.v1.endpoints.employee_profiles import router as employee_profiles_router
|
||||
from app.api.v1.endpoints.health import router as health_router
|
||||
from app.api.v1.endpoints.knowledge import router as knowledge_router
|
||||
@@ -48,6 +49,7 @@ router.include_router(ontology_router, tags=["ontology"])
|
||||
router.include_router(orchestrator_router, tags=["orchestrator"])
|
||||
router.include_router(receipt_folder_router, tags=["receipt-folder"])
|
||||
router.include_router(employees_router, prefix="/employees", tags=["employees"])
|
||||
router.include_router(expense_cases_router, tags=["expense-cases"])
|
||||
router.include_router(employee_profiles_router, tags=["employee-profiles"])
|
||||
router.include_router(reimbursements_router, prefix="/reimbursements", tags=["reimbursements"])
|
||||
router.include_router(risk_observations_router, tags=["risk-observations"])
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransac
|
||||
from app.models.employee_change_log import EmployeeChangeLog
|
||||
from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot
|
||||
from app.models.employee import Employee
|
||||
from app.models.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink
|
||||
from app.models.few_shot_sample import FewShotSample
|
||||
from app.models.financial_record import (
|
||||
AccountsPayableRecord,
|
||||
@@ -56,6 +57,9 @@ __all__ = [
|
||||
"BudgetReservation",
|
||||
"BudgetTransaction",
|
||||
"Employee",
|
||||
"ExpenseCase",
|
||||
"ExpenseCaseLink",
|
||||
"BusinessEvent",
|
||||
"EmployeeBehaviorProfileSnapshot",
|
||||
"EmployeeChangeLog",
|
||||
"ExpenseClaim",
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransac
|
||||
from app.models.employee_change_log import EmployeeChangeLog
|
||||
from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot
|
||||
from app.models.employee import Employee
|
||||
from app.models.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink
|
||||
from app.models.few_shot_sample import FewShotSample
|
||||
from app.models.financial_record import (
|
||||
AccountsPayableRecord,
|
||||
@@ -47,6 +48,9 @@ __all__ = [
|
||||
"BudgetReservation",
|
||||
"BudgetTransaction",
|
||||
"Employee",
|
||||
"ExpenseCase",
|
||||
"ExpenseCaseLink",
|
||||
"BusinessEvent",
|
||||
"EmployeeBehaviorProfileSnapshot",
|
||||
"EmployeeChangeLog",
|
||||
"ExpenseClaim",
|
||||
|
||||
112
server/src/app/models/expense_case.py
Normal file
112
server/src/app/models/expense_case.py
Normal file
@@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
def _new_id() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class ExpenseCase(Base):
|
||||
__tablename__ = "expense_cases"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "case_no", name="uq_expense_cases_tenant_case_no"),
|
||||
Index("ix_expense_cases_tenant_stage", "tenant_id", "current_stage"),
|
||||
Index("ix_expense_cases_tenant_status", "tenant_id", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
case_no: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
scene_code: Mapped[str] = mapped_column(String(50), nullable=False, default="other")
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
owner_employee_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
current_stage: Mapped[str] = mapped_column(String(40), nullable=False, default="claiming")
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="active")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
links = relationship(
|
||||
"ExpenseCaseLink",
|
||||
back_populates="expense_case",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="asc(ExpenseCaseLink.created_at)",
|
||||
)
|
||||
events = relationship(
|
||||
"BusinessEvent",
|
||||
back_populates="expense_case",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="asc(BusinessEvent.occurred_at)",
|
||||
)
|
||||
|
||||
|
||||
class ExpenseCaseLink(Base):
|
||||
__tablename__ = "expense_case_links"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("resource_type", "resource_id", name="uq_expense_case_links_resource"),
|
||||
Index("ix_expense_case_links_tenant_case", "tenant_id", "expense_case_id"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
expense_case_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("expense_cases.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
resource_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
resource_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
relation_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
expense_case = relationship("ExpenseCase", back_populates="links")
|
||||
|
||||
|
||||
class BusinessEvent(Base):
|
||||
__tablename__ = "business_events"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"aggregate_type",
|
||||
"aggregate_id",
|
||||
"event_type",
|
||||
"idempotency_key",
|
||||
name="uq_business_event_idempotency",
|
||||
),
|
||||
Index("ix_business_events_tenant_case_time", "tenant_id", "expense_case_id", "occurred_at"),
|
||||
Index("ix_business_events_outbox", "delivery_status", "occurred_at"),
|
||||
Index("ix_business_events_aggregate", "aggregate_type", "aggregate_id"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
expense_case_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("expense_cases.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
aggregate_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
aggregate_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
event_type: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||
event_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
correlation_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
causation_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
actor_id: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
actor_type: Mapped[str] = mapped_column(String(30), nullable=False, default="user")
|
||||
payload_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
||||
delivery_status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
|
||||
delivery_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
last_delivery_error: Mapped[str | None] = mapped_column(Text(), nullable=True)
|
||||
occurred_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
expense_case = relationship("ExpenseCase", back_populates="events")
|
||||
@@ -50,8 +50,11 @@ class AuditLogRepository:
|
||||
stmt = stmt.limit(limit)
|
||||
return list(self.db.scalars(stmt).all())
|
||||
|
||||
def create(self, log: AuditLog) -> AuditLog:
|
||||
def create(self, log: AuditLog, *, commit: bool = True) -> AuditLog:
|
||||
self.db.add(log)
|
||||
self.db.commit()
|
||||
if commit:
|
||||
self.db.commit()
|
||||
else:
|
||||
self.db.flush()
|
||||
self.db.refresh(log)
|
||||
return log
|
||||
|
||||
51
server/src/app/schemas/expense_case.py
Normal file
51
server/src/app/schemas/expense_case.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ExpenseCaseLinkRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
resource_type: str
|
||||
resource_id: str
|
||||
relation_type: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class BusinessEventRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
event_type: str
|
||||
event_version: int
|
||||
idempotency_key: str
|
||||
aggregate_type: str
|
||||
aggregate_id: str
|
||||
correlation_id: str
|
||||
causation_id: str | None
|
||||
actor_id: str
|
||||
actor_type: str
|
||||
payload_json: dict[str, Any] = Field(default_factory=dict)
|
||||
delivery_status: str
|
||||
occurred_at: datetime
|
||||
published_at: datetime | None
|
||||
|
||||
|
||||
class ExpenseCaseTimelineRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
case_no: str
|
||||
scene_code: str
|
||||
title: str
|
||||
owner_employee_id: str | None
|
||||
current_stage: str
|
||||
status: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
links: list[ExpenseCaseLinkRead] = Field(default_factory=list)
|
||||
events: list[BusinessEventRead] = Field(default_factory=list)
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
306
server/src/app/services/expense_cases.py
Normal file
306
server/src/app/services/expense_cases.py
Normal 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"
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user