2026-07-16 15:34:58 +08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
|
from datetime import UTC, datetime
|
|
|
|
|
from decimal import Decimal
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from sqlalchemy import create_engine, func, select
|
|
|
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
|
|
|
|
|
|
from app.api.deps import CurrentUserContext
|
|
|
|
|
from app.db.base import Base
|
|
|
|
|
from app.models.approval_action import ApprovalActionLedger
|
|
|
|
|
from app.models.audit_log import AuditLog
|
|
|
|
|
from app.models.employee import Employee
|
|
|
|
|
from app.models.expense_case import BusinessEvent
|
|
|
|
|
from app.models.financial_record import ExpenseClaim
|
2026-07-16 15:49:43 +08:00
|
|
|
from app.schemas.reimbursement import ExpenseClaimRead
|
2026-07-16 15:34:58 +08:00
|
|
|
from app.services.approval_action_protocol import (
|
|
|
|
|
ApprovalActionConflictError,
|
|
|
|
|
ApprovalActionProtocol,
|
|
|
|
|
)
|
|
|
|
|
from app.services.expense_claims import ExpenseClaimService
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _manager_user() -> CurrentUserContext:
|
|
|
|
|
return CurrentUserContext(
|
|
|
|
|
username="manager-action@example.com",
|
|
|
|
|
name="李经理",
|
|
|
|
|
role_codes=["manager"],
|
|
|
|
|
is_admin=False,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _finance_user() -> CurrentUserContext:
|
|
|
|
|
return CurrentUserContext(
|
|
|
|
|
username="finance-action@example.com",
|
|
|
|
|
name="王财务",
|
|
|
|
|
role_codes=["finance"],
|
|
|
|
|
is_admin=False,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _seed_claim(
|
|
|
|
|
db: Session,
|
|
|
|
|
*,
|
|
|
|
|
claim_id: str = "claim-action-1",
|
|
|
|
|
manager_email: str = "manager-action@example.com",
|
|
|
|
|
) -> ExpenseClaim:
|
|
|
|
|
manager = Employee(
|
|
|
|
|
id=f"manager-{claim_id}",
|
|
|
|
|
employee_no=f"M-{claim_id}",
|
|
|
|
|
name="李经理",
|
|
|
|
|
email=manager_email,
|
|
|
|
|
)
|
|
|
|
|
employee = Employee(
|
|
|
|
|
id=f"employee-{claim_id}",
|
|
|
|
|
employee_no=f"E-{claim_id}",
|
|
|
|
|
name="张三",
|
|
|
|
|
email=f"employee-{claim_id}@example.com",
|
|
|
|
|
manager=manager,
|
|
|
|
|
)
|
|
|
|
|
claim = ExpenseClaim(
|
|
|
|
|
id=claim_id,
|
|
|
|
|
claim_no=f"EXP-{claim_id}",
|
|
|
|
|
employee=employee,
|
|
|
|
|
employee_name="张三",
|
|
|
|
|
department_name="市场部",
|
|
|
|
|
expense_type="transport",
|
|
|
|
|
reason="客户拜访",
|
|
|
|
|
location="上海",
|
|
|
|
|
amount=Decimal("88.00"),
|
|
|
|
|
currency="CNY",
|
|
|
|
|
invoice_count=1,
|
|
|
|
|
occurred_at=datetime(2026, 7, 16, tzinfo=UTC),
|
|
|
|
|
submitted_at=datetime(2026, 7, 16, tzinfo=UTC),
|
|
|
|
|
status="submitted",
|
|
|
|
|
approval_stage="直属领导审批",
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
)
|
|
|
|
|
db.add(claim)
|
|
|
|
|
db.commit()
|
|
|
|
|
return claim
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def session_factory() -> sessionmaker[Session]:
|
|
|
|
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
|
|
|
|
Base.metadata.create_all(engine)
|
|
|
|
|
factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
|
|
|
|
try:
|
|
|
|
|
yield factory
|
|
|
|
|
finally:
|
|
|
|
|
engine.dispose()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_approve_replay_persists_one_ledger_event_and_audit(
|
|
|
|
|
session_factory: sessionmaker[Session],
|
|
|
|
|
) -> None:
|
|
|
|
|
with session_factory() as db:
|
|
|
|
|
claim = _seed_claim(db)
|
|
|
|
|
service = ExpenseClaimService(db)
|
|
|
|
|
first = service.approve_claim(
|
|
|
|
|
claim.id,
|
|
|
|
|
_manager_user(),
|
|
|
|
|
opinion="同意",
|
|
|
|
|
request_id="approve-retry-1",
|
|
|
|
|
expected_status="submitted",
|
|
|
|
|
expected_approval_stage="直属领导审批",
|
|
|
|
|
)
|
|
|
|
|
replay = service.approve_claim(
|
|
|
|
|
claim.id,
|
|
|
|
|
_manager_user(),
|
|
|
|
|
opinion="同意",
|
|
|
|
|
request_id="approve-retry-1",
|
|
|
|
|
expected_status="submitted",
|
|
|
|
|
expected_approval_stage="直属领导审批",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert first is not None and replay is not None
|
2026-07-16 15:49:43 +08:00
|
|
|
assert ExpenseClaimRead.model_validate(replay).model_dump(mode="json") == (
|
|
|
|
|
ExpenseClaimRead.model_validate(first).model_dump(mode="json")
|
|
|
|
|
)
|
2026-07-16 15:34:58 +08:00
|
|
|
assert replay.approval_stage == "财务审批"
|
|
|
|
|
assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 1
|
|
|
|
|
assert db.scalar(select(func.count()).select_from(BusinessEvent)) == 1
|
|
|
|
|
assert (
|
|
|
|
|
db.scalar(
|
|
|
|
|
select(func.count())
|
|
|
|
|
.select_from(AuditLog)
|
|
|
|
|
.where(AuditLog.request_id == "approve-retry-1")
|
|
|
|
|
)
|
|
|
|
|
== 1
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 15:49:43 +08:00
|
|
|
def test_approve_replay_returns_original_snapshot_after_claim_moves_forward(
|
|
|
|
|
session_factory: sessionmaker[Session],
|
|
|
|
|
) -> None:
|
|
|
|
|
with session_factory() as db:
|
|
|
|
|
claim = _seed_claim(db, claim_id="claim-action-original-response")
|
|
|
|
|
service = ExpenseClaimService(db)
|
|
|
|
|
first = service.approve_claim(
|
|
|
|
|
claim.id,
|
|
|
|
|
_manager_user(),
|
|
|
|
|
opinion="同意",
|
|
|
|
|
request_id="approve-original-response-1",
|
|
|
|
|
expected_status="submitted",
|
|
|
|
|
expected_approval_stage="直属领导审批",
|
|
|
|
|
)
|
|
|
|
|
assert first is not None
|
|
|
|
|
first_json = ExpenseClaimRead.model_validate(first).model_dump(mode="json")
|
|
|
|
|
|
|
|
|
|
current = db.get(ExpenseClaim, claim.id)
|
|
|
|
|
assert current is not None
|
|
|
|
|
current.status = "pending_payment"
|
|
|
|
|
current.approval_stage = "待付款"
|
|
|
|
|
current.risk_flags_json = [
|
|
|
|
|
*list(current.risk_flags_json or []),
|
|
|
|
|
{"source": "future_state", "message": "首次响应之后产生的内部状态"},
|
|
|
|
|
]
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
replay = service.approve_claim(
|
|
|
|
|
claim.id,
|
|
|
|
|
_manager_user(),
|
|
|
|
|
opinion="同意",
|
|
|
|
|
request_id="approve-original-response-1",
|
|
|
|
|
expected_status="submitted",
|
|
|
|
|
expected_approval_stage="直属领导审批",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert replay is not None
|
|
|
|
|
assert ExpenseClaimRead.model_validate(replay).model_dump(mode="json") == first_json
|
|
|
|
|
assert replay.approval_stage == "财务审批"
|
|
|
|
|
assert all(
|
|
|
|
|
item.get("source") != "future_state" for item in replay.risk_flags_json or []
|
|
|
|
|
)
|
|
|
|
|
persisted = db.get(ExpenseClaim, claim.id)
|
|
|
|
|
assert persisted is not None
|
|
|
|
|
assert persisted.status == "pending_payment"
|
|
|
|
|
assert persisted.approval_stage == "待付款"
|
|
|
|
|
ledger = db.scalar(
|
|
|
|
|
select(ApprovalActionLedger).where(
|
|
|
|
|
ApprovalActionLedger.request_id == "approve-original-response-1"
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
assert ledger is not None
|
|
|
|
|
assert ledger.response_json == first_json
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 15:34:58 +08:00
|
|
|
def test_request_id_payload_mismatch_and_stale_preconditions_return_conflict(
|
|
|
|
|
session_factory: sessionmaker[Session],
|
|
|
|
|
) -> None:
|
|
|
|
|
with session_factory() as db:
|
|
|
|
|
claim = _seed_claim(db, claim_id="claim-action-conflict")
|
|
|
|
|
service = ExpenseClaimService(db)
|
|
|
|
|
with pytest.raises(ApprovalActionConflictError, match="单据状态已从"):
|
|
|
|
|
service.approve_claim(
|
|
|
|
|
claim.id,
|
|
|
|
|
_manager_user(),
|
|
|
|
|
opinion="同意",
|
|
|
|
|
request_id="approve-stale-1",
|
|
|
|
|
expected_status="draft",
|
|
|
|
|
expected_approval_stage="直属领导审批",
|
|
|
|
|
)
|
|
|
|
|
service.approve_claim(
|
|
|
|
|
claim.id,
|
|
|
|
|
_manager_user(),
|
|
|
|
|
opinion="同意",
|
|
|
|
|
request_id="approve-conflict-1",
|
|
|
|
|
expected_status="submitted",
|
|
|
|
|
expected_approval_stage="直属领导审批",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ApprovalActionConflictError, match="已用于另一项"):
|
|
|
|
|
service.approve_claim(
|
|
|
|
|
claim.id,
|
|
|
|
|
_manager_user(),
|
|
|
|
|
opinion="改为有条件通过",
|
|
|
|
|
request_id="approve-conflict-1",
|
|
|
|
|
expected_status="submitted",
|
|
|
|
|
expected_approval_stage="直属领导审批",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert (
|
|
|
|
|
db.scalar(
|
|
|
|
|
select(func.count())
|
|
|
|
|
.select_from(ApprovalActionLedger)
|
|
|
|
|
.where(ApprovalActionLedger.request_id == "approve-stale-1")
|
|
|
|
|
)
|
|
|
|
|
== 0
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_action_failure_rolls_back_ledger_claim_event_and_audit(
|
|
|
|
|
session_factory: sessionmaker[Session],
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
with session_factory() as db:
|
|
|
|
|
claim = _seed_claim(db, claim_id="claim-action-rollback")
|
|
|
|
|
service = ExpenseClaimService(db)
|
|
|
|
|
|
|
|
|
|
def fail_completion(*args, **kwargs):
|
|
|
|
|
raise RuntimeError("ledger completion failed")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(ApprovalActionProtocol, "complete", fail_completion)
|
|
|
|
|
with pytest.raises(RuntimeError, match="ledger completion failed"):
|
|
|
|
|
service.approve_claim(
|
|
|
|
|
claim.id,
|
|
|
|
|
_manager_user(),
|
|
|
|
|
opinion="同意",
|
|
|
|
|
request_id="approve-rollback-1",
|
|
|
|
|
expected_status="submitted",
|
|
|
|
|
expected_approval_stage="直属领导审批",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
db.expire_all()
|
|
|
|
|
persisted = db.get(ExpenseClaim, claim.id)
|
|
|
|
|
assert persisted is not None
|
|
|
|
|
assert persisted.status == "submitted"
|
|
|
|
|
assert persisted.approval_stage == "直属领导审批"
|
|
|
|
|
assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 0
|
|
|
|
|
assert db.scalar(select(func.count()).select_from(BusinessEvent)) == 0
|
|
|
|
|
assert db.scalar(select(func.count()).select_from(AuditLog)) == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_legacy_stage_repair_cannot_commit_inside_action_protocol(
|
|
|
|
|
session_factory: sessionmaker[Session],
|
|
|
|
|
) -> None:
|
|
|
|
|
with session_factory() as db:
|
|
|
|
|
claim = _seed_claim(db, claim_id="claim-action-stage-repair")
|
|
|
|
|
claim.approval_stage = "预算管理者审批"
|
|
|
|
|
claim.risk_flags_json = [
|
|
|
|
|
{
|
|
|
|
|
"source": "manual_approval",
|
|
|
|
|
"event_type": "expense_claim_approval",
|
|
|
|
|
"previous_approval_stage": "直属领导审批",
|
|
|
|
|
"next_approval_stage": "预算管理者审批",
|
|
|
|
|
"operator": "李经理",
|
|
|
|
|
"next_approver_name": "李经理",
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
db.commit()
|
|
|
|
|
admin_user = CurrentUserContext(
|
|
|
|
|
username="admin-action@example.com",
|
|
|
|
|
name="审批管理员",
|
|
|
|
|
role_codes=["admin"],
|
|
|
|
|
is_admin=True,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ApprovalActionConflictError, match="审批节点已从"):
|
|
|
|
|
ExpenseClaimService(db).approve_claim(
|
|
|
|
|
claim.id,
|
|
|
|
|
admin_user,
|
|
|
|
|
opinion="同意",
|
|
|
|
|
request_id="approve-stage-repair-1",
|
|
|
|
|
expected_status="submitted",
|
|
|
|
|
expected_approval_stage="预算管理者审批",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
db.expire_all()
|
|
|
|
|
persisted = db.get(ExpenseClaim, claim.id)
|
|
|
|
|
assert persisted is not None
|
|
|
|
|
assert persisted.approval_stage == "预算管理者审批"
|
|
|
|
|
assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_return_and_pay_actions_use_the_same_protocol(
|
|
|
|
|
session_factory: sessionmaker[Session],
|
|
|
|
|
) -> None:
|
|
|
|
|
with session_factory() as db:
|
|
|
|
|
returned_claim = _seed_claim(db, claim_id="claim-action-return")
|
|
|
|
|
paid_claim = _seed_claim(
|
|
|
|
|
db,
|
|
|
|
|
claim_id="claim-action-pay",
|
|
|
|
|
manager_email="manager-pay-action@example.com",
|
|
|
|
|
)
|
|
|
|
|
paid_claim.status = "pending_payment"
|
|
|
|
|
paid_claim.approval_stage = "待付款"
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
returned = ExpenseClaimService(db).return_claim(
|
|
|
|
|
returned_claim.id,
|
|
|
|
|
_manager_user(),
|
|
|
|
|
reason="请补充材料",
|
|
|
|
|
request_id="return-action-1",
|
|
|
|
|
expected_status="submitted",
|
|
|
|
|
expected_approval_stage="直属领导审批",
|
|
|
|
|
)
|
|
|
|
|
paid = ExpenseClaimService(db).mark_claim_paid(
|
|
|
|
|
paid_claim.id,
|
|
|
|
|
_finance_user(),
|
|
|
|
|
request_id="pay-action-1",
|
|
|
|
|
expected_status="pending_payment",
|
|
|
|
|
expected_approval_stage="待付款",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert returned is not None and returned.status == "returned"
|
|
|
|
|
assert paid is not None and paid.status == "paid"
|
|
|
|
|
ledgers = list(
|
|
|
|
|
db.scalars(select(ApprovalActionLedger).order_by(ApprovalActionLedger.action)).all()
|
|
|
|
|
)
|
|
|
|
|
assert [(item.action, item.result_status) for item in ledgers] == [
|
|
|
|
|
("pay", "paid"),
|
|
|
|
|
("return", "returned"),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_concurrent_identical_request_executes_once(tmp_path) -> None:
|
|
|
|
|
engine = create_engine(
|
|
|
|
|
f"sqlite+pysqlite:///{tmp_path / 'approval-action.db'}",
|
|
|
|
|
connect_args={"check_same_thread": False},
|
|
|
|
|
)
|
|
|
|
|
Base.metadata.create_all(engine)
|
|
|
|
|
factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
|
|
|
|
with factory() as db:
|
|
|
|
|
_seed_claim(db, claim_id="claim-action-concurrent")
|
|
|
|
|
|
|
|
|
|
def approve() -> str:
|
|
|
|
|
with factory() as db:
|
|
|
|
|
result = ExpenseClaimService(db).approve_claim(
|
|
|
|
|
"claim-action-concurrent",
|
|
|
|
|
_manager_user(),
|
|
|
|
|
opinion="同意",
|
|
|
|
|
request_id="approve-concurrent-1",
|
|
|
|
|
expected_status="submitted",
|
|
|
|
|
expected_approval_stage="直属领导审批",
|
|
|
|
|
)
|
|
|
|
|
assert result is not None
|
|
|
|
|
return str(result.approval_stage)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
|
|
|
results = list(pool.map(lambda _: approve(), range(2)))
|
|
|
|
|
assert results == ["财务审批", "财务审批"]
|
|
|
|
|
with factory() as db:
|
|
|
|
|
assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 1
|
|
|
|
|
assert db.scalar(select(func.count()).select_from(BusinessEvent)) == 1
|
|
|
|
|
finally:
|
|
|
|
|
engine.dispose()
|