feat: 新增预算后端服务与差旅风险规则库
后端新增预算模型、端点和服务模块,支持预算 CRUD 和余额 查询,清理旧生成规则文件并替换为按严重等级分类的差旅风 险规则库,优化认证权限和报销单访问策略,新增财务规则目 录和演示数据构建脚本,前端预算中心增加对话框交互,完善 审计页面运行时模型和元数据展示,补充单元测试。
This commit is contained in:
@@ -32,6 +32,7 @@ from app.schemas.agent_asset import (
|
||||
AgentAssetVersionCreate,
|
||||
)
|
||||
from app.schemas.reimbursement import TravelReimbursementCalculatorRequest
|
||||
from app.services import agent_foundation as agent_foundation_module
|
||||
from app.services.agent_asset_spreadsheet import (
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
|
||||
@@ -43,6 +44,9 @@ from app.services.agent_assets import AgentAssetService
|
||||
from app.services.agent_runs import AgentRunService
|
||||
from app.services.audit import AuditLogService
|
||||
from app.services.expense_rule_runtime import ExpenseRuleRuntimeService
|
||||
from app.services.finance_rule_catalog import (
|
||||
DEPRECATED_FINANCE_RULE_CODES,
|
||||
)
|
||||
from app.services.settings import OnlyOfficeRuntimeConfig
|
||||
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
||||
|
||||
@@ -80,6 +84,7 @@ def isolate_rule_file_storage(tmp_path, monkeypatch) -> None:
|
||||
|
||||
|
||||
def build_session() -> Session:
|
||||
agent_foundation_module._foundation_ready_keys.clear()
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
@@ -163,12 +168,59 @@ def test_finance_rules_use_risk_rule_scenario_categories() -> None:
|
||||
travel_config = travel_rule.config_json or {}
|
||||
communication_config = communication_rule.config_json or {}
|
||||
|
||||
assert travel_rule.scenario_json == ["差旅"]
|
||||
assert travel_config["scenario_category"] == "差旅"
|
||||
assert travel_config["ai_review_category"] == "差旅"
|
||||
assert communication_rule.scenario_json == ["费用科目"]
|
||||
assert communication_config["scenario_category"] == "费用科目"
|
||||
assert communication_config["ai_review_category"] == "费用科目"
|
||||
assert travel_rule.scenario_json == ["差旅费"]
|
||||
assert travel_config["scenario_category"] == "差旅费"
|
||||
assert travel_config["ai_review_category"] == "差旅费"
|
||||
assert communication_rule.scenario_json == ["通信费"]
|
||||
assert communication_config["scenario_category"] == "通信费"
|
||||
assert communication_config["ai_review_category"] == "通信费"
|
||||
|
||||
|
||||
def test_non_standard_finance_rule_spreadsheets_are_not_seeded() -> None:
|
||||
with build_session() as db:
|
||||
service = AgentAssetService(db)
|
||||
service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||||
|
||||
for code in DEPRECATED_FINANCE_RULE_CODES:
|
||||
asset = db.scalar(select(AgentAsset).where(AgentAsset.code == code))
|
||||
assert asset is None or asset.config_json["tag"] == "废弃规则"
|
||||
|
||||
|
||||
def test_demo_budget_risk_rules_sync_with_finance_rule_references() -> None:
|
||||
with build_session() as db:
|
||||
service = AgentAssetService(db)
|
||||
service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||||
|
||||
budget_rule = db.scalar(
|
||||
select(AgentAsset).where(
|
||||
AgentAsset.code == "risk.budget.available_balance_insufficient"
|
||||
)
|
||||
)
|
||||
marketing_rule = db.scalar(
|
||||
select(AgentAsset).where(
|
||||
AgentAsset.code == "risk.application.marketing_without_campaign"
|
||||
)
|
||||
)
|
||||
|
||||
assert budget_rule is not None
|
||||
assert "差旅费" in budget_rule.scenario_json
|
||||
assert "市场推广费" in budget_rule.scenario_json
|
||||
assert "软件服务费" in budget_rule.scenario_json
|
||||
assert budget_rule.config_json["budget_required"] is True
|
||||
assert "marketing" in budget_rule.config_json["expense_types"]
|
||||
assert budget_rule.config_json["business_stage"] == [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution",
|
||||
]
|
||||
assert budget_rule.config_json["finance_rule_code"] == "budget.execution.policy"
|
||||
|
||||
assert marketing_rule is not None
|
||||
assert marketing_rule.scenario_json == ["市场推广费"]
|
||||
assert marketing_rule.config_json["finance_rule_code"] == "expense.application.policy"
|
||||
assert marketing_rule.config_json["finance_rule_sheet"] == "费用申请前置规则"
|
||||
assert marketing_rule.config_json["expense_types"] == ["marketing"]
|
||||
assert marketing_rule.config_json["budget_required"] is True
|
||||
|
||||
|
||||
def test_agent_asset_service_can_activate_rule_after_review() -> None:
|
||||
|
||||
199
server/tests/test_budget_endpoints.py
Normal file
199
server/tests/test_budget_endpoints.py
Normal file
@@ -0,0 +1,199 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.db.base import Base
|
||||
from app.main import create_app
|
||||
from app.models.budget import BudgetAllocation
|
||||
|
||||
|
||||
def build_session_factory() -> sessionmaker[Session]:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
return sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
|
||||
def build_client() -> tuple[TestClient, sessionmaker[Session]]:
|
||||
session_factory = build_session_factory()
|
||||
app = create_app()
|
||||
|
||||
def override_db() -> Generator[Session, None, None]:
|
||||
db = session_factory()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
return TestClient(app), session_factory
|
||||
|
||||
|
||||
def seed_budget_allocations(db: Session) -> None:
|
||||
now = datetime.now(UTC)
|
||||
db.add_all(
|
||||
[
|
||||
BudgetAllocation(
|
||||
id="budget-market-travel",
|
||||
budget_no="BUD-MARKET-TRAVEL",
|
||||
fiscal_year=2026,
|
||||
period_type="quarter",
|
||||
period_key="2026Q2",
|
||||
department_id="dept-market",
|
||||
department_name="市场部",
|
||||
cost_center="CC-4100",
|
||||
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",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
),
|
||||
BudgetAllocation(
|
||||
id="budget-finance-office",
|
||||
budget_no="BUD-FINANCE-OFFICE",
|
||||
fiscal_year=2026,
|
||||
period_type="quarter",
|
||||
period_key="2026Q2",
|
||||
department_id="dept-finance",
|
||||
department_name="财务部",
|
||||
cost_center="CC-2100",
|
||||
project_code=None,
|
||||
subject_code="office",
|
||||
subject_name="办公费",
|
||||
original_amount=Decimal("30000.00"),
|
||||
adjusted_amount=Decimal("0.00"),
|
||||
status="active",
|
||||
warning_threshold=Decimal("80.00"),
|
||||
control_action="block",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
),
|
||||
BudgetAllocation(
|
||||
id="budget-market-software-hidden",
|
||||
budget_no="BUD-MARKET-SOFTWARE",
|
||||
fiscal_year=2026,
|
||||
period_type="quarter",
|
||||
period_key="2026Q2",
|
||||
department_id="dept-market",
|
||||
department_name="市场部",
|
||||
cost_center="CC-4100",
|
||||
project_code=None,
|
||||
subject_code="software",
|
||||
subject_name="软件服务费",
|
||||
original_amount=Decimal("90000.00"),
|
||||
adjusted_amount=Decimal("0.00"),
|
||||
status="active",
|
||||
warning_threshold=Decimal("80.00"),
|
||||
control_action="block",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_admin_can_view_all_budget_allocations_without_is_admin_header() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
seed_budget_allocations(db)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/budgets/summary",
|
||||
headers={"x-auth-username": "admin", "x-auth-role-codes": "manager"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert {item["department_name"] for item in payload["allocations"]} == {"市场部", "财务部"}
|
||||
assert {item["subject_code"] for item in payload["allocations"]} == {"travel", "office"}
|
||||
|
||||
|
||||
def test_budget_monitor_is_limited_to_own_department_scope() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
seed_budget_allocations(db)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/budgets/summary?cost_center=CC-2100",
|
||||
headers={
|
||||
"x-auth-username": "monitor@example.com",
|
||||
"x-auth-role-codes": "budget_monitor",
|
||||
"x-auth-cost-center": "CC-4100",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert [item["cost_center"] for item in payload["allocations"]] == ["CC-4100"]
|
||||
assert [item["subject_code"] for item in payload["allocations"]] == ["travel"]
|
||||
|
||||
|
||||
def test_finance_user_cannot_enter_budget_center() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
seed_budget_allocations(db)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/budgets/summary",
|
||||
headers={"x-auth-username": "finance@example.com", "x-auth-role-codes": "finance"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_budget_monitor_cannot_edit_and_admin_can_edit() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
seed_budget_allocations(db)
|
||||
|
||||
payload = {
|
||||
"fiscal_year": 2026,
|
||||
"period_type": "quarter",
|
||||
"period_key": "2026Q2",
|
||||
"department_id": "dept-sales",
|
||||
"department_name": "销售部",
|
||||
"cost_center": "CC-5100",
|
||||
"project_code": None,
|
||||
"subject_code": "travel",
|
||||
"subject_name": "差旅费",
|
||||
"original_amount": "20000.00",
|
||||
"warning_threshold": "80.00",
|
||||
"control_action": "block",
|
||||
"description": "销售部差旅预算",
|
||||
}
|
||||
|
||||
monitor_response = client.post(
|
||||
"/api/v1/budgets/allocations",
|
||||
json=payload,
|
||||
headers={
|
||||
"x-auth-username": "monitor@example.com",
|
||||
"x-auth-role-codes": "budget_monitor",
|
||||
"x-auth-cost-center": "CC-4100",
|
||||
},
|
||||
)
|
||||
assert monitor_response.status_code == 403
|
||||
|
||||
admin_response = client.post(
|
||||
"/api/v1/budgets/allocations",
|
||||
json=payload,
|
||||
headers={"x-auth-username": "admin", "x-auth-role-codes": "manager"},
|
||||
)
|
||||
assert admin_response.status_code == 201
|
||||
assert admin_response.json()["department_name"] == "销售部"
|
||||
@@ -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",
|
||||
|
||||
@@ -10,7 +10,12 @@ from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentReviewStatus
|
||||
from app.core.agent_enums import (
|
||||
AgentAssetDomain,
|
||||
AgentAssetStatus,
|
||||
AgentAssetType,
|
||||
AgentReviewStatus,
|
||||
)
|
||||
from app.db.base import Base
|
||||
from app.models.agent_asset import AgentAsset
|
||||
from app.models.employee import Employee
|
||||
@@ -27,6 +32,7 @@ from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
|
||||
from app.services.agent_assets import AgentAssetService
|
||||
from app.services.agent_foundation_risk_rules import AgentFoundationRiskRuleMixin
|
||||
from app.services.expense_claim_platform_risk import ExpenseClaimPlatformRiskMixin
|
||||
from app.services.risk_rule_flow_diagram import (
|
||||
RiskRuleFlowDiagramRenderer,
|
||||
RiskRuleFlowDiagramSpec,
|
||||
@@ -384,6 +390,92 @@ def test_platform_risk_sync_skips_natural_language_drafts() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_stale_demo_risk_rules_are_marked_deprecated() -> None:
|
||||
class FoundationRiskSyncProbe(AgentFoundationRiskRuleMixin):
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
with build_session() as db:
|
||||
stale_asset = AgentAsset(
|
||||
asset_type=AgentAssetType.RULE.value,
|
||||
code="risk.standard.training_per_capita_over_limit",
|
||||
name="培训费人均超标准",
|
||||
domain=AgentAssetDomain.EXPENSE.value,
|
||||
owner="风控与审计部",
|
||||
reviewer="顾承宇",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
config_json={
|
||||
"enabled": True,
|
||||
"tag": "风险规则",
|
||||
"source_ref": "费用管控 Demo 风险规则库",
|
||||
},
|
||||
)
|
||||
kept_asset = AgentAsset(
|
||||
asset_type=AgentAssetType.RULE.value,
|
||||
code="risk.standard.software_contract_missing",
|
||||
name="软件服务费缺少合同",
|
||||
domain=AgentAssetDomain.EXPENSE.value,
|
||||
owner="风控与审计部",
|
||||
reviewer="顾承宇",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
config_json={
|
||||
"enabled": True,
|
||||
"tag": "风险规则",
|
||||
"source_ref": "费用管控 Demo 风险规则库",
|
||||
},
|
||||
)
|
||||
db.add_all([stale_asset, kept_asset])
|
||||
db.flush()
|
||||
|
||||
FoundationRiskSyncProbe(db)._hide_stale_demo_risk_rules(
|
||||
{"risk.standard.software_contract_missing"}
|
||||
)
|
||||
|
||||
assert stale_asset.status == AgentAssetStatus.DISABLED.value
|
||||
assert stale_asset.config_json["tag"] == "废弃风险规则"
|
||||
assert stale_asset.config_json["enabled"] is False
|
||||
assert kept_asset.status == AgentAssetStatus.ACTIVE.value
|
||||
assert kept_asset.config_json["tag"] == "风险规则"
|
||||
|
||||
|
||||
def test_platform_risk_applies_to_chinese_expense_type_labels() -> None:
|
||||
class PlatformRiskProbe(ExpenseClaimPlatformRiskMixin):
|
||||
pass
|
||||
|
||||
claim = ExpenseClaim(
|
||||
claim_no="TEST-MARKETING-RISK",
|
||||
employee_name="测试员工",
|
||||
department_name="市场部",
|
||||
expense_type="市场推广费",
|
||||
reason="品牌投放活动",
|
||||
amount=Decimal("20000.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime.now(UTC),
|
||||
status="draft",
|
||||
)
|
||||
manifest = {
|
||||
"applies_to": {
|
||||
"domains": ["expense"],
|
||||
"expense_types": ["marketing"],
|
||||
}
|
||||
}
|
||||
|
||||
assert PlatformRiskProbe()._risk_manifest_applies_to_claim(
|
||||
manifest,
|
||||
claim=claim,
|
||||
contexts=[],
|
||||
)
|
||||
|
||||
manifest["applies_to"]["expense_types"] = ["software"]
|
||||
|
||||
assert not PlatformRiskProbe()._risk_manifest_applies_to_claim(
|
||||
manifest,
|
||||
claim=claim,
|
||||
contexts=[],
|
||||
)
|
||||
|
||||
|
||||
def test_risk_rule_flow_diagram_uses_risk_level_palette() -> None:
|
||||
renderer = RiskRuleFlowDiagramRenderer()
|
||||
|
||||
@@ -449,7 +541,10 @@ def test_current_keyword_city_consistency_rule_hits_ticket_city_mismatch() -> No
|
||||
"attachment.route_cities",
|
||||
"item.item_location",
|
||||
],
|
||||
"natural_language": "差旅报销时,交通票或住宿票据中的城市必须与申报目的地一致;未说明绕行、跨城或改签原因时标记风险。",
|
||||
"natural_language": (
|
||||
"差旅报销时,交通票或住宿票据中的城市必须与申报目的地一致;"
|
||||
"未说明绕行、跨城或改签原因时标记风险。"
|
||||
),
|
||||
"condition_summary": "检查住宿城市、申报地点、行程城市是否一致",
|
||||
"keywords": ["绕行", "跨城", "改签", "变更"],
|
||||
},
|
||||
@@ -659,7 +754,10 @@ def test_travel_route_rule_hits_unexpected_intermediate_city_even_when_returning
|
||||
"home_city_fields": ["employee.location"],
|
||||
"exception_fields": ["claim.reason"],
|
||||
"exception_keywords": ["绕行", "跨城办事", "临时改签"],
|
||||
"condition_summary": "A=票据路线城市,B=申报城市,C=员工常驻地,A中出现B∪C之外城市则命中。",
|
||||
"condition_summary": (
|
||||
"A=票据路线城市,B=申报城市,C=员工常驻地,"
|
||||
"A中出现B∪C之外城市则命中。"
|
||||
),
|
||||
},
|
||||
"outcomes": {"fail": {"severity": "high"}},
|
||||
}
|
||||
@@ -700,7 +798,11 @@ def test_travel_route_rule_hits_unexpected_intermediate_city_even_when_returning
|
||||
"document_info": {
|
||||
"route_cities": ["上海", "北京", "武汉"],
|
||||
"fields": [
|
||||
{"key": "route_cities", "label": "行程城市", "value": ["上海", "北京", "武汉"]}
|
||||
{
|
||||
"key": "route_cities",
|
||||
"label": "行程城市",
|
||||
"value": ["上海", "北京", "武汉"],
|
||||
}
|
||||
],
|
||||
},
|
||||
"ocr_text": "上海 到 北京 到 武汉",
|
||||
|
||||
Reference in New Issue
Block a user