feat(expenses): add transactional expense case events
This commit is contained in:
@@ -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")
|
||||
@@ -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
|
||||
;;
|
||||
*)
|
||||
|
||||
@@ -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)
|
||||
|
||||
423
server/tests/test_expense_case_service.py
Normal file
423
server/tests/test_expense_case_service.py
Normal 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"
|
||||
Reference in New Issue
Block a user