feat: 新增预算后端服务与差旅风险规则库

后端新增预算模型、端点和服务模块,支持预算 CRUD 和余额
查询,清理旧生成规则文件并替换为按严重等级分类的差旅风
险规则库,优化认证权限和报销单访问策略,新增财务规则目
录和演示数据构建脚本,前端预算中心增加对话框交互,完善
审计页面运行时模型和元数据展示,补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-26 17:29:35 +08:00
parent e1e515ecae
commit e7bef0883d
85 changed files with 6443 additions and 1497 deletions

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import re
import uuid
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
@@ -11,6 +12,7 @@ from sqlalchemy.pool import StaticPool
from app.api.deps import CurrentUserContext
from app.db.base import Base
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.models.organization import OrganizationUnit
@@ -75,6 +77,37 @@ def _count_claims(db: Session) -> int:
return int(db.query(ExpenseClaim).count())
def _seed_budget_allocation(
db: Session,
*,
department_id: str | None,
department_name: str,
subject_code: str = "travel",
amount: Decimal = Decimal("50000.00"),
period_key: str = "2026Q2",
) -> BudgetAllocation:
allocation = BudgetAllocation(
budget_no=f"BUD-TEST-{uuid.uuid4().hex[:8]}",
fiscal_year=2026,
period_type="quarter",
period_key=period_key,
department_id=department_id,
department_name=department_name,
cost_center=None,
project_code=None,
subject_code=subject_code,
subject_name=subject_code,
original_amount=amount,
adjusted_amount=Decimal("0.00"),
status="active",
warning_threshold=Decimal("80.00"),
control_action="block",
)
db.add(allocation)
db.commit()
return allocation
def test_validate_claim_for_submission_allows_office_claim_without_location() -> None:
service = ExpenseClaimService.__new__(ExpenseClaimService)
claim = build_claim(expense_type="office", location="待补充")
@@ -2778,7 +2811,7 @@ def test_finance_can_return_but_cannot_delete_submitted_claim() -> None:
for flag in returned.risk_flags_json
)
with pytest.raises(ValueError, match="只有高级管理人员可以删除"):
with pytest.raises(ValueError, match="只有高级财务人员可以删除"):
service.delete_claim(claim_id, current_user)
assert db.get(ExpenseClaim, claim_id) is not None
@@ -3079,6 +3112,157 @@ def test_application_submit_skips_ai_review_and_receipt_requirements(monkeypatch
)
def test_application_submit_reserves_budget_once() -> None:
current_user = CurrentUserContext(
username="application-budget-owner@example.com",
name="张三",
role_codes=["employee"],
is_admin=True,
)
with build_session() as db:
_seed_budget_allocation(
db,
department_id="dept-budget",
department_name="交付部",
amount=Decimal("50000.00"),
)
claim = ExpenseClaim(
claim_no="APP-20260525-BUDGET",
employee_id="emp-budget",
employee_name="张三",
department_id="dept-budget",
department_name="交付部",
project_code=None,
expense_type="travel_application",
reason="客户现场交付",
location="上海",
amount=Decimal("12000.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
submitted_at=None,
status="draft",
approval_stage="待提交",
risk_flags_json=[],
)
db.add(claim)
db.commit()
submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)
assert submitted is not None
reservations = db.query(BudgetReservation).all()
assert len(reservations) == 1
assert reservations[0].source_type == "application"
assert reservations[0].source_id == claim.id
assert reservations[0].amount == Decimal("12000.00")
transactions = db.query(BudgetTransaction).all()
assert any(item.transaction_type == "reserve" for item in transactions)
assert any(
isinstance(flag, dict)
and flag.get("source") == "budget_control"
and flag.get("event_type") == "budget_reserved"
for flag in submitted.risk_flags_json
)
def test_application_submit_blocks_when_budget_insufficient_without_state_change() -> None:
current_user = CurrentUserContext(
username="application-budget-block@example.com",
name="张三",
role_codes=["employee"],
is_admin=True,
)
with build_session() as db:
_seed_budget_allocation(
db,
department_id="dept-budget-block",
department_name="交付部",
amount=Decimal("1000.00"),
)
claim = ExpenseClaim(
claim_no="APP-20260525-BLOCK",
employee_id="emp-budget-block",
employee_name="张三",
department_id="dept-budget-block",
department_name="交付部",
project_code=None,
expense_type="travel_application",
reason="客户现场交付",
location="上海",
amount=Decimal("12000.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
submitted_at=None,
status="draft",
approval_stage="待提交",
risk_flags_json=[],
)
db.add(claim)
db.commit()
with pytest.raises(ValueError):
ExpenseClaimService(db).submit_claim(claim.id, current_user)
db.refresh(claim)
assert claim.status == "draft"
assert db.query(BudgetReservation).count() == 0
assert db.query(BudgetTransaction).count() == 0
def test_application_submit_skips_budget_for_non_demo_subject() -> None:
current_user = CurrentUserContext(
username="application-budget-skip@example.com",
name="张三",
role_codes=["employee"],
is_admin=True,
)
with build_session() as db:
_seed_budget_allocation(
db,
department_id="dept-budget-skip",
department_name="交付部",
amount=Decimal("1000.00"),
)
claim = ExpenseClaim(
claim_no="APP-20260525-SKIP",
employee_id="emp-budget-skip",
employee_name="张三",
department_id="dept-budget-skip",
department_name="交付部",
project_code=None,
expense_type="software_application",
reason="采购演示软件服务",
location="深圳",
amount=Decimal("12000.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
submitted_at=None,
status="draft",
approval_stage="待提交",
risk_flags_json=[],
)
db.add(claim)
db.commit()
submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)
assert submitted is not None
assert submitted.status == "submitted"
assert submitted.approval_stage == "直属领导审批"
assert db.query(BudgetReservation).count() == 0
assert db.query(BudgetTransaction).count() == 0
assert not any(
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "budget_control"
for flag in submitted.risk_flags_json
)
def test_direct_manager_can_approve_application_claim_to_reimbursement_draft() -> None:
current_user = CurrentUserContext(
username="manager-application-approve@example.com",
@@ -3175,6 +3359,80 @@ def test_direct_manager_can_approve_application_claim_to_reimbursement_draft() -
)
def test_application_approval_transfers_budget_reservation_to_reimbursement_draft() -> None:
owner = CurrentUserContext(
username="application-budget-owner-approve@example.com",
name="张三",
role_codes=["employee"],
is_admin=False,
)
manager_user = CurrentUserContext(
username="manager-application-budget@example.com",
name="李经理",
role_codes=["manager"],
is_admin=False,
)
with build_session() as db:
manager = Employee(
employee_no="M-BUDGET-APP",
name="李经理",
email="manager-application-budget@example.com",
)
employee = Employee(
employee_no="E-BUDGET-APP",
name="张三",
email="application-budget-owner-approve@example.com",
manager=manager,
)
db.add_all([manager, employee])
db.flush()
_seed_budget_allocation(
db,
department_id="dept-budget-transfer",
department_name="交付部",
amount=Decimal("50000.00"),
)
claim = ExpenseClaim(
claim_no="APP-20260525-TRANSFER",
employee_id=employee.id,
employee_name="张三",
department_id="dept-budget-transfer",
department_name="交付部",
project_code=None,
expense_type="travel_application",
reason="客户现场交付",
location="上海",
amount=Decimal("12000.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
submitted_at=None,
status="draft",
approval_stage="待提交",
risk_flags_json=[],
)
db.add(claim)
db.commit()
service = ExpenseClaimService(db)
service.submit_claim(claim.id, owner)
approved = service.approve_claim(claim.id, manager_user, opinion="同意申请")
generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one()
reservation = db.query(BudgetReservation).one()
assert approved is not None
assert reservation.source_type == "claim"
assert reservation.source_id == generated_draft.id
assert reservation.source_no == generated_draft.claim_no
assert any(item.transaction_type == "transfer" for item in db.query(BudgetTransaction).all())
assert any(
isinstance(flag, dict)
and flag.get("event_type") == "budget_reservation_transferred"
for flag in generated_draft.risk_flags_json
)
def test_direct_manager_approval_requires_leader_opinion() -> None:
current_user = CurrentUserContext(
username="manager-application-required-opinion@example.com",
@@ -3232,6 +3490,70 @@ def test_direct_manager_approval_requires_leader_opinion() -> None:
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 0
def test_finance_approve_reimbursement_consumes_budget_reservation() -> None:
current_user = CurrentUserContext(
username="finance-budget-approve@example.com",
name="财务",
role_codes=["finance"],
is_admin=False,
)
with build_session() as db:
allocation = _seed_budget_allocation(
db,
department_id="dept-finance-budget",
department_name="交付部",
amount=Decimal("50000.00"),
)
claim = ExpenseClaim(
claim_no="RE-20260525-BUDGET",
employee_id="emp-finance-budget",
employee_name="张三",
department_id="dept-finance-budget",
department_name="交付部",
project_code=None,
expense_type="travel",
reason="客户现场交付",
location="上海",
amount=Decimal("12000.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="财务审批",
risk_flags_json=[],
)
db.add(claim)
db.flush()
reservation = BudgetReservation(
reservation_no=f"BRS-TEST-{uuid.uuid4().hex[:8]}",
allocation_id=allocation.id,
source_type="claim",
source_id=claim.id,
source_no=claim.claim_no,
source_status="active",
amount=Decimal("12000.00"),
context_json={},
)
db.add(reservation)
db.commit()
approved = ExpenseClaimService(db).approve_claim(claim.id, current_user, opinion="同意入账")
assert approved is not None
db.refresh(reservation)
assert reservation.source_status == "consumed"
assert reservation.consumed_amount == Decimal("12000.00")
assert db.query(BudgetTransaction).filter(BudgetTransaction.transaction_type == "consume").count() == 1
assert any(
isinstance(flag, dict)
and flag.get("source") == "budget_control"
and flag.get("event_type") == "budget_consumed"
for flag in approved.risk_flags_json
)
def test_finance_can_approve_claim_to_archive_stage() -> None:
current_user = CurrentUserContext(
username="finance-approve@example.com",