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

@@ -0,0 +1,162 @@
"""add expense case and transactional business event tables
This is the first migration-owned schema slice in a legacy database that still
bootstraps older tables through SQLAlchemy metadata. The server startup runs
this revision before the legacy bootstrap, and the legacy bootstrap explicitly
excludes these migration-owned tables.
Revision ID: 20260713_0001
Revises:
Create Date: 2026-07-13 11:45:00
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20260713_0001"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"expense_cases",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("case_no", sa.String(length=80), nullable=False),
sa.Column("scene_code", sa.String(length=50), nullable=False),
sa.Column("title", sa.String(length=200), nullable=False),
sa.Column("owner_employee_id", sa.String(length=36), nullable=True),
sa.Column("current_stage", sa.String(length=40), nullable=False),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "case_no", name="uq_expense_cases_tenant_case_no"),
)
op.create_index("ix_expense_cases_tenant_id", "expense_cases", ["tenant_id"])
op.create_index("ix_expense_cases_owner_employee_id", "expense_cases", ["owner_employee_id"])
op.create_index(
"ix_expense_cases_tenant_stage",
"expense_cases",
["tenant_id", "current_stage"],
)
op.create_index(
"ix_expense_cases_tenant_status",
"expense_cases",
["tenant_id", "status"],
)
op.create_table(
"expense_case_links",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("expense_case_id", sa.String(length=36), nullable=False),
sa.Column("resource_type", sa.String(length=50), nullable=False),
sa.Column("resource_id", sa.String(length=100), nullable=False),
sa.Column("relation_type", sa.String(length=50), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.ForeignKeyConstraint(["expense_case_id"], ["expense_cases.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("resource_type", "resource_id", name="uq_expense_case_links_resource"),
)
op.create_index("ix_expense_case_links_tenant_id", "expense_case_links", ["tenant_id"])
op.create_index(
"ix_expense_case_links_tenant_case",
"expense_case_links",
["tenant_id", "expense_case_id"],
)
op.create_table(
"business_events",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("expense_case_id", sa.String(length=36), nullable=False),
sa.Column("aggregate_type", sa.String(length=50), nullable=False),
sa.Column("aggregate_id", sa.String(length=100), nullable=False),
sa.Column("event_type", sa.String(length=80), nullable=False),
sa.Column("event_version", sa.Integer(), nullable=False),
sa.Column("idempotency_key", sa.String(length=120), nullable=False),
sa.Column("correlation_id", sa.String(length=64), nullable=False),
sa.Column("causation_id", sa.String(length=64), nullable=True),
sa.Column("actor_id", sa.String(length=120), nullable=False),
sa.Column("actor_type", sa.String(length=30), nullable=False),
sa.Column("payload_json", sa.JSON(), nullable=False),
sa.Column("delivery_status", sa.String(length=20), nullable=False),
sa.Column("delivery_attempts", sa.Integer(), nullable=False),
sa.Column("last_delivery_error", sa.Text(), nullable=True),
sa.Column(
"occurred_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["expense_case_id"], ["expense_cases.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"tenant_id",
"aggregate_type",
"aggregate_id",
"event_type",
"idempotency_key",
name="uq_business_event_idempotency",
),
)
op.create_index("ix_business_events_tenant_id", "business_events", ["tenant_id"])
op.create_index("ix_business_events_event_type", "business_events", ["event_type"])
op.create_index("ix_business_events_correlation_id", "business_events", ["correlation_id"])
op.create_index(
"ix_business_events_tenant_case_time",
"business_events",
["tenant_id", "expense_case_id", "occurred_at"],
)
op.create_index(
"ix_business_events_outbox",
"business_events",
["delivery_status", "occurred_at"],
)
op.create_index(
"ix_business_events_aggregate",
"business_events",
["aggregate_type", "aggregate_id"],
)
def downgrade() -> None:
op.drop_index("ix_business_events_aggregate", table_name="business_events")
op.drop_index("ix_business_events_outbox", table_name="business_events")
op.drop_index("ix_business_events_tenant_case_time", table_name="business_events")
op.drop_index("ix_business_events_correlation_id", table_name="business_events")
op.drop_index("ix_business_events_event_type", table_name="business_events")
op.drop_index("ix_business_events_tenant_id", table_name="business_events")
op.drop_table("business_events")
op.drop_index("ix_expense_case_links_tenant_case", table_name="expense_case_links")
op.drop_index("ix_expense_case_links_tenant_id", table_name="expense_case_links")
op.drop_table("expense_case_links")
op.drop_index("ix_expense_cases_tenant_status", table_name="expense_cases")
op.drop_index("ix_expense_cases_tenant_stage", table_name="expense_cases")
op.drop_index("ix_expense_cases_owner_employee_id", table_name="expense_cases")
op.drop_index("ix_expense_cases_tenant_id", table_name="expense_cases")
op.drop_table("expense_cases")

View File

@@ -378,6 +378,12 @@ ensure_dependencies() {
info "Server dependencies are ready."
}
run_database_migrations() {
info "Applying database migrations..."
"$PYTHON_BIN" -m alembic -c "$SCRIPT_DIR/alembic.ini" upgrade head
info "Database migrations are up to date."
}
start_server() {
info "Starting FastAPI server..."
info "Access: http://$SERVER_HOST:$SERVER_PORT"
@@ -402,6 +408,7 @@ case "$MODE" in
;;
start)
ensure_dependencies
run_database_migrations
start_server
;;
*)

View File

@@ -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(),

View 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)

View File

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

View File

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

View File

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

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

View File

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

View 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)

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)

View File

@@ -0,0 +1,423 @@
from __future__ import annotations
from datetime import UTC, date, datetime
from decimal import Decimal
import pytest
from sqlalchemy import create_engine, inspect, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import CurrentUserContext
from app.db.base import Base
from app.models.audit_log import AuditLog
from app.models.budget import BudgetAllocation
from app.models.employee import Employee
from app.models.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.models.organization import OrganizationUnit
from app.services.agent_foundation import AgentFoundationService
from app.services.expense_cases import ExpenseCaseService
from app.services.expense_claim_workflow_constants import (
APPLICATION_ARCHIVE_STAGE,
APPLICATION_LINK_STATUS_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE,
)
from app.services.expense_claims import ExpenseClaimService
def build_session() -> Session:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
return session_factory()
def build_claim(
*,
claim_no: str,
employee: Employee | None = None,
status: str = "draft",
approval_stage: str = "待提交",
expense_type: str = "transport",
) -> ExpenseClaim:
claim = ExpenseClaim(
claim_no=claim_no,
employee_id=employee.id if employee is not None else None,
employee_name=employee.name if employee is not None else "张三",
department_name="市场部",
project_code="PRJ-CASE",
expense_type=expense_type,
reason="客户现场差旅",
location="上海",
amount=Decimal("88.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 7, 13, 9, 0, tzinfo=UTC),
submitted_at=(datetime(2026, 7, 13, 10, 0, tzinfo=UTC) if status != "draft" else None),
status=status,
approval_stage=approval_stage,
risk_flags_json=[],
)
if status == "draft":
claim.items = [
ExpenseClaimItem(
item_date=date(2026, 7, 13),
item_type="transport",
item_reason="客户现场交通",
item_location="上海",
item_note="",
item_amount=Decimal("88.00"),
invoice_id="invoice-case-1",
)
]
return claim
def test_event_write_uses_caller_transaction_and_tenant_scope() -> None:
with build_session() as db:
claim = build_claim(claim_no="RE-CASE-ROLLBACK")
db.add(claim)
db.commit()
service = ExpenseCaseService(db)
service.record_claim_event(
claim,
event_type="claim_draft_created",
actor_id="owner@example.com",
tenant_id="tenant-a",
correlation_id="run-case-rollback",
)
assert db.scalar(select(ExpenseCase)) is not None
assert db.scalar(select(BusinessEvent)) is not None
db.rollback()
assert db.scalar(select(ExpenseCase)) is None
assert db.scalar(select(ExpenseCaseLink)) is None
assert db.scalar(select(BusinessEvent)) is None
service.record_claim_event(
claim,
event_type="claim_draft_created",
actor_id="owner@example.com",
tenant_id="tenant-a",
)
db.commit()
assert service.get_timeline_for_claim(claim.id, tenant_id="tenant-a") is not None
assert service.get_timeline_for_claim(claim.id, tenant_id="tenant-b") is None
with pytest.raises(PermissionError, match="其他租户"):
service.record_claim_event(
claim,
event_type="claim_draft_updated",
actor_id="other@example.com",
tenant_id="tenant-b",
)
def test_legacy_bootstrap_excludes_migration_owned_tables(
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:")
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
with session_factory() as db:
service = AgentFoundationService(db)
monkeypatch.setattr(service, "_ensure_agent_asset_schema", lambda: None)
monkeypatch.setattr(service, "_ensure_financial_record_schema", lambda: None)
monkeypatch.setattr(service, "_seed_agent_assets", lambda: None)
monkeypatch.setattr(service, "_sync_demo_financial_records", lambda: None)
monkeypatch.setattr(service, "_seed_runs_and_logs", lambda: None)
service._prepare_foundation()
table_names = set(inspect(engine).get_table_names())
assert "employees" in table_names
assert {"expense_cases", "expense_case_links", "business_events"}.isdisjoint(table_names)
def test_event_write_is_idempotent_for_same_business_operation() -> None:
with build_session() as db:
claim = build_claim(claim_no="RE-CASE-IDEMPOTENT")
db.add(claim)
db.commit()
service = ExpenseCaseService(db)
_case, first_event = service.record_claim_event(
claim,
event_type="claim_draft_updated",
actor_id="owner@example.com",
tenant_id="tenant-a",
correlation_id="run-idempotent",
idempotency_key="save-operation-1",
)
_case, repeated_event = service.record_claim_event(
claim,
event_type="claim_draft_updated",
actor_id="owner@example.com",
tenant_id="tenant-a",
correlation_id="run-idempotent",
idempotency_key="save-operation-1",
)
db.commit()
assert repeated_event.id == first_event.id
assert len(list(db.scalars(select(BusinessEvent)).all())) == 1
def test_submit_claim_creates_case_link_and_structured_event() -> None:
current_user = CurrentUserContext(
username="employee-case@example.com",
name="张三",
role_codes=[],
is_admin=False,
)
with build_session() as db:
manager = Employee(
employee_no="CASE-MANAGER",
name="李经理",
email="manager-case@example.com",
)
employee = Employee(
employee_no="CASE-EMPLOYEE",
name="张三",
email=current_user.username,
manager=manager,
)
db.add_all([manager, employee])
db.flush()
claim = build_claim(claim_no="RE-CASE-SUBMIT", employee=employee)
claim.risk_flags_json = [
{
"source": "ai_pre_review",
"status": "passed",
"passed": True,
"severity": "info",
"blocking_risk_count": 0,
}
]
db.add(claim)
db.commit()
submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)
assert submitted is not None
assert submitted.status == "submitted"
link = db.scalar(select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == submitted.id))
assert link is not None
event = db.scalar(select(BusinessEvent).where(BusinessEvent.aggregate_id == submitted.id))
assert event is not None
assert event.event_type == "claim_submitted"
assert event.delivery_status == "pending"
assert event.payload_json["previous_status"] == "draft"
assert event.payload_json["next_status"] == "submitted"
def test_payment_event_failure_rolls_back_payment_archive_and_nested_audit(
monkeypatch: pytest.MonkeyPatch,
) -> None:
current_user = CurrentUserContext(
username="finance-case@example.com",
name="财务付款",
role_codes=["finance"],
is_admin=False,
)
with build_session() as db:
application_claim = build_claim(
claim_no="AP-CASE-ARCHIVE",
status="approved",
approval_stage="关联单据状态",
expense_type="travel_application",
)
reimbursement_claim = build_claim(
claim_no="RE-CASE-PAY",
status="pending_payment",
approval_stage="待付款",
expense_type="travel",
)
reimbursement_claim.risk_flags_json = [
{
"source": "application_handoff",
"application_claim_id": application_claim.id,
"application_claim_no": application_claim.claim_no,
}
]
db.add_all([application_claim, reimbursement_claim])
db.commit()
service = ExpenseClaimService(db)
def fail_event(*args, **kwargs):
raise RuntimeError("outbox unavailable")
monkeypatch.setattr(service._expense_cases, "record_claim_event", fail_event)
with pytest.raises(RuntimeError, match="outbox unavailable"):
service.mark_claim_paid(reimbursement_claim.id, current_user)
db.rollback()
db.refresh(reimbursement_claim)
db.refresh(application_claim)
assert reimbursement_claim.status == "pending_payment"
assert reimbursement_claim.approval_stage == "待付款"
assert application_claim.status == "approved"
assert application_claim.approval_stage != APPLICATION_ARCHIVE_STAGE
assert db.scalar(select(AuditLog)) is None
def test_application_approval_links_generated_reimbursement_to_same_case() -> None:
with build_session() as db:
department = OrganizationUnit(
unit_code="CASE-TRAVEL",
name="差旅试点部",
unit_type="department",
)
manager = Employee(
employee_no="CASE-APP-MANAGER",
name="差旅经理",
email="travel-manager@example.com",
organization_unit=department,
)
employee = Employee(
employee_no="CASE-APP-EMPLOYEE",
name="差旅员工",
email="travel-employee@example.com",
manager=manager,
organization_unit=department,
)
db.add_all([department, manager, employee])
db.flush()
db.add(
BudgetAllocation(
budget_no="BUD-CASE-TRAVEL",
fiscal_year=2026,
period_type="year",
period_key="2026",
department_id=department.id,
department_name=department.name,
cost_center=None,
project_code=None,
subject_code="travel",
subject_name="差旅费",
original_amount=Decimal("50000.00"),
adjusted_amount=Decimal("0.00"),
status="active",
warning_threshold=Decimal("80.00"),
control_action="block",
)
)
application_claim = build_claim(
claim_no="AP-CASE-GENERATE",
employee=employee,
status="submitted",
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
expense_type="travel_application",
)
application_claim.amount = Decimal("500.00")
db.add(application_claim)
db.commit()
approved = ExpenseClaimService(db).approve_claim(
application_claim.id,
CurrentUserContext(
username=manager.email,
name=manager.name,
role_codes=["manager"],
is_admin=False,
),
opinion="业务必要,同意申请",
)
assert approved is not None
assert approved.status == "approved"
assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
expense_case = db.scalar(select(ExpenseCase))
assert expense_case is not None
links = list(
db.scalars(
select(ExpenseCaseLink)
.where(ExpenseCaseLink.expense_case_id == expense_case.id)
.order_by(ExpenseCaseLink.created_at)
).all()
)
assert {link.relation_type for link in links} == {
"application",
"generated_reimbursement",
}
events = list(
db.scalars(
select(BusinessEvent)
.where(BusinessEvent.expense_case_id == expense_case.id)
.order_by(BusinessEvent.occurred_at)
).all()
)
assert [event.event_type for event in events] == [
"application_approved",
"reimbursement_draft_generated",
]
assert len({event.correlation_id for event in events}) == 1
assert events[1].causation_id == events[0].id
def test_payment_records_application_archive_event_in_same_case() -> None:
current_user = CurrentUserContext(
username="finance-archive@example.com",
name="财务付款",
role_codes=["finance"],
is_admin=False,
)
with build_session() as db:
application_claim = build_claim(
claim_no="AP-CASE-ARCHIVE-EVENT",
status="approved",
approval_stage="关联单据状态",
expense_type="travel_application",
)
reimbursement_claim = build_claim(
claim_no="RE-CASE-PAY-EVENT",
status="pending_payment",
approval_stage="待付款",
expense_type="travel",
)
reimbursement_claim.risk_flags_json = [
{
"source": "application_handoff",
"application_claim_no": application_claim.claim_no,
}
]
db.add_all([application_claim, reimbursement_claim])
db.commit()
paid = ExpenseClaimService(db).mark_claim_paid(reimbursement_claim.id, current_user)
assert paid is not None
expense_case = db.scalar(select(ExpenseCase))
assert expense_case is not None
links = list(
db.scalars(
select(ExpenseCaseLink).where(ExpenseCaseLink.expense_case_id == expense_case.id)
).all()
)
assert {link.resource_id for link in links} == {
application_claim.id,
reimbursement_claim.id,
}
events = list(
db.scalars(
select(BusinessEvent)
.where(BusinessEvent.expense_case_id == expense_case.id)
.order_by(BusinessEvent.occurred_at)
).all()
)
assert [event.event_type for event in events] == [
"payment_completed",
"application_archived",
]
assert events[1].causation_id == events[0].id
assert expense_case.current_stage == "accounting"