2026-07-13 11:58:48 +08:00
|
|
|
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
|
2026-07-13 14:45:36 +08:00
|
|
|
assert {
|
|
|
|
|
"auth_sessions",
|
|
|
|
|
"expense_cases",
|
|
|
|
|
"expense_case_links",
|
|
|
|
|
"business_events",
|
|
|
|
|
}.isdisjoint(table_names)
|
2026-07-13 11:58:48 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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"
|