feat: 增强风险规则生成引擎与预算中心页面
后端拆分风险规则生成为解释器、语义分析、本体对齐等子模块, 优化模板执行和流程图生成,完善员工种子数据和导入逻辑,增强 报销单权限策略和草稿持久化,前端新增预算中心视图和趋势图 组件,重构审计页面和风险规则测试对话框交互,完善文档中心 和报销创建页面细节,补充单元测试覆盖。
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
@@ -1042,7 +1043,7 @@ def test_upsert_draft_from_ontology_updates_returned_claim_and_preserves_return_
|
||||
assert manual_returns == [return_flag]
|
||||
|
||||
|
||||
def test_generate_claim_no_uses_max_suffix_instead_of_count() -> None:
|
||||
def test_generate_claim_no_uses_re_prefix_timestamp_and_random_suffix() -> None:
|
||||
with build_session() as db:
|
||||
db.add_all(
|
||||
[
|
||||
@@ -1084,7 +1085,10 @@ def test_generate_claim_no_uses_max_suffix_instead_of_count() -> None:
|
||||
|
||||
service = ExpenseClaimService(db)
|
||||
|
||||
assert service._generate_claim_no(datetime(2026, 5, 14, tzinfo=UTC)) == "EXP-202605-004"
|
||||
assert re.fullmatch(
|
||||
r"RE-\d{14}-[A-HJ-NP-Z2-9]{8}",
|
||||
service._generate_claim_no(datetime(2026, 5, 14, tzinfo=UTC)),
|
||||
)
|
||||
|
||||
|
||||
def test_upsert_draft_from_ontology_retries_claim_no_conflict() -> None:
|
||||
@@ -1100,7 +1104,7 @@ def test_upsert_draft_from_ontology_retries_claim_no_conflict() -> None:
|
||||
db.flush()
|
||||
db.add(
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-202605-004",
|
||||
claim_no="RE-20260525101010-ABCDEFGH",
|
||||
employee_name="历史单据",
|
||||
department_name="财务部",
|
||||
project_code=None,
|
||||
@@ -1125,7 +1129,9 @@ def test_upsert_draft_from_ontology_retries_claim_no_conflict() -> None:
|
||||
)
|
||||
)
|
||||
service = ExpenseClaimService(db)
|
||||
generated_claim_nos = iter(["EXP-202605-004", "EXP-202605-005"])
|
||||
generated_claim_nos = iter(
|
||||
["RE-20260525101010-ABCDEFGH", "RE-20260525101010-HGFEDCBA"]
|
||||
)
|
||||
service._generate_claim_no = lambda occurred_at: next(generated_claim_nos)
|
||||
|
||||
result = service.upsert_draft_from_ontology(
|
||||
@@ -1141,8 +1147,8 @@ def test_upsert_draft_from_ontology_retries_claim_no_conflict() -> None:
|
||||
|
||||
created_claim = db.get(ExpenseClaim, result["claim_id"])
|
||||
assert created_claim is not None
|
||||
assert created_claim.claim_no == "EXP-202605-005"
|
||||
assert result["claim_no"] == "EXP-202605-005"
|
||||
assert created_claim.claim_no == "RE-20260525101010-HGFEDCBA"
|
||||
assert result["claim_no"] == "RE-20260525101010-HGFEDCBA"
|
||||
|
||||
|
||||
def test_create_claim_item_adds_blank_draft_row_without_forcing_attachment() -> None:
|
||||
@@ -2629,17 +2635,53 @@ def test_list_archived_claims_returns_company_archived_records_for_finance() ->
|
||||
approval_stage="财务审批",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
ExpenseClaim(
|
||||
claim_no="AP-20260525120000-ABCDEFGH",
|
||||
employee_name="丙",
|
||||
department_name="C部",
|
||||
project_code="PRJ-C",
|
||||
expense_type="travel_application",
|
||||
reason="C 申请",
|
||||
location="成都",
|
||||
amount=Decimal("800.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 11, 14, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 11, 15, 0, tzinfo=UTC),
|
||||
status="approved",
|
||||
approval_stage="审批完成",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
ExpenseClaim(
|
||||
claim_no="AP-20260525123000-HGFEDCBA",
|
||||
employee_name="丁",
|
||||
department_name="D部",
|
||||
project_code="PRJ-D",
|
||||
expense_type="travel_application",
|
||||
reason="D 申请",
|
||||
location="北京",
|
||||
amount=Decimal("500.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 11, 16, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 11, 17, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
claims = ExpenseClaimService(db).list_archived_claims(current_user)
|
||||
|
||||
assert len(claims) == 1
|
||||
assert claims[0].claim_no == "EXP-ARCH-101"
|
||||
assert {claim.claim_no for claim in claims} == {
|
||||
"EXP-ARCH-101",
|
||||
"AP-20260525120000-ABCDEFGH",
|
||||
}
|
||||
|
||||
|
||||
def test_list_archived_claims_is_empty_for_regular_employee() -> None:
|
||||
def test_list_archived_claims_returns_only_own_records_for_regular_employee() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="zhangsan@example.com",
|
||||
name="张三",
|
||||
@@ -2648,30 +2690,49 @@ def test_list_archived_claims_is_empty_for_regular_employee() -> None:
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
db.add(
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-ARCH-EMP",
|
||||
employee_name="张三",
|
||||
department_name="研发部",
|
||||
project_code="PRJ-EMP",
|
||||
expense_type="travel",
|
||||
reason="本人报销",
|
||||
location="北京",
|
||||
amount=Decimal("200.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 10, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 10, 10, 0, tzinfo=UTC),
|
||||
status="approved",
|
||||
approval_stage="归档入账",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add_all(
|
||||
[
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-ARCH-EMP",
|
||||
employee_name="张三",
|
||||
department_name="研发部",
|
||||
project_code="PRJ-EMP",
|
||||
expense_type="travel",
|
||||
reason="本人报销",
|
||||
location="北京",
|
||||
amount=Decimal("200.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 10, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 10, 10, 0, tzinfo=UTC),
|
||||
status="approved",
|
||||
approval_stage="归档入账",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
ExpenseClaim(
|
||||
claim_no="AP-20260525130000-ABCDEFGH",
|
||||
employee_name="李四",
|
||||
department_name="研发部",
|
||||
project_code="PRJ-EMP",
|
||||
expense_type="travel_application",
|
||||
reason="他人申请",
|
||||
location="上海",
|
||||
amount=Decimal("500.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 10, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 10, 10, 0, tzinfo=UTC),
|
||||
status="approved",
|
||||
approval_stage="审批完成",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
claims = ExpenseClaimService(db).list_archived_claims(current_user)
|
||||
|
||||
assert claims == []
|
||||
assert [claim.claim_no for claim in claims] == ["EXP-ARCH-EMP"]
|
||||
|
||||
|
||||
def test_finance_can_return_but_cannot_delete_submitted_claim() -> None:
|
||||
@@ -2760,6 +2821,79 @@ def test_executive_can_delete_submitted_claim() -> None:
|
||||
assert db.get(ExpenseClaim, claim_id) is None
|
||||
|
||||
|
||||
def test_executive_cannot_delete_archived_claim() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="executive-archive-delete@example.com",
|
||||
name="高管",
|
||||
role_codes=["executive"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
claim = ExpenseClaim(
|
||||
claim_no="EXP-DEL-ARCHIVE-101",
|
||||
employee_name="张三",
|
||||
department_name="市场部",
|
||||
project_code="PRJ-A",
|
||||
expense_type="travel",
|
||||
reason="差旅报销",
|
||||
location="上海",
|
||||
amount=Decimal("120.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
|
||||
status="approved",
|
||||
approval_stage="归档入账",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
with pytest.raises(ValueError, match="已归档单据不能删除"):
|
||||
ExpenseClaimService(db).delete_claim(claim_id, current_user)
|
||||
|
||||
assert db.get(ExpenseClaim, claim_id) is not None
|
||||
|
||||
|
||||
def test_admin_can_delete_archived_claim() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="superadmin",
|
||||
name="系统管理员",
|
||||
role_codes=["manager"],
|
||||
is_admin=True,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
claim = ExpenseClaim(
|
||||
claim_no="EXP-DEL-ARCHIVE-102",
|
||||
employee_name="张三",
|
||||
department_name="市场部",
|
||||
project_code="PRJ-A",
|
||||
expense_type="travel",
|
||||
reason="差旅报销",
|
||||
location="上海",
|
||||
amount=Decimal("120.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
|
||||
status="approved",
|
||||
approval_stage="归档入账",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
deleted = ExpenseClaimService(db).delete_claim(claim_id, current_user)
|
||||
|
||||
assert deleted is not None
|
||||
assert deleted.claim_no == "EXP-DEL-ARCHIVE-102"
|
||||
assert db.get(ExpenseClaim, claim_id) is None
|
||||
|
||||
|
||||
def test_direct_manager_can_return_subordinate_claim_to_pending_submission() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="manager-return@example.com",
|
||||
@@ -2945,7 +3079,7 @@ def test_application_submit_skips_ai_review_and_receipt_requirements(monkeypatch
|
||||
)
|
||||
|
||||
|
||||
def test_direct_manager_can_approve_application_claim_to_completed_stage() -> None:
|
||||
def test_direct_manager_can_approve_application_claim_to_reimbursement_draft() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="manager-application-approve@example.com",
|
||||
name="李经理",
|
||||
@@ -2998,6 +3132,35 @@ def test_direct_manager_can_approve_application_claim_to_completed_stage() -> No
|
||||
assert approved is not None
|
||||
assert approved.status == "approved"
|
||||
assert approved.approval_stage == "审批完成"
|
||||
archived_claims = ExpenseClaimService(db).list_archived_claims(
|
||||
CurrentUserContext(
|
||||
username="finance-archive@example.com",
|
||||
name="财务归档员",
|
||||
role_codes=["finance"],
|
||||
is_admin=False,
|
||||
)
|
||||
)
|
||||
assert any(claim.claim_no == "APP-20260525-APPROVE" for claim in archived_claims)
|
||||
generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one()
|
||||
assert generated_draft.status == "draft"
|
||||
assert generated_draft.approval_stage == "待提交"
|
||||
assert generated_draft.expense_type == "travel"
|
||||
assert generated_draft.employee_id == employee.id
|
||||
assert generated_draft.employee_name == "张三"
|
||||
assert generated_draft.department_name == "交付部"
|
||||
assert generated_draft.reason == "支撑国网服务器上线部署"
|
||||
assert generated_draft.location == "上海"
|
||||
assert generated_draft.amount == Decimal("12000.00")
|
||||
assert generated_draft.invoice_count == 0
|
||||
assert generated_draft.items == []
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "application_handoff"
|
||||
and flag.get("event_type") == "expense_application_to_reimbursement_draft"
|
||||
and flag.get("application_claim_no") == "APP-20260525-APPROVE"
|
||||
and flag.get("leader_opinion") == "业务必要,同意申请。"
|
||||
for flag in generated_draft.risk_flags_json
|
||||
)
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "manual_approval"
|
||||
@@ -3006,10 +3169,69 @@ def test_direct_manager_can_approve_application_claim_to_completed_stage() -> No
|
||||
and flag.get("previous_approval_stage") == "直属领导审批"
|
||||
and flag.get("next_status") == "approved"
|
||||
and flag.get("next_approval_stage") == "审批完成"
|
||||
and flag.get("generated_draft_claim_id") == generated_draft.id
|
||||
and flag.get("generated_draft_claim_no") == generated_draft.claim_no
|
||||
for flag in approved.risk_flags_json
|
||||
)
|
||||
|
||||
|
||||
def test_direct_manager_approval_requires_leader_opinion() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="manager-application-required-opinion@example.com",
|
||||
name="李经理",
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
manager = Employee(
|
||||
employee_no="E8122",
|
||||
name="李经理",
|
||||
email="manager-application-required-opinion@example.com",
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="E8123",
|
||||
name="张三",
|
||||
email="zhangsan-application-required-opinion@example.com",
|
||||
manager=manager,
|
||||
)
|
||||
db.add_all([manager, employee])
|
||||
db.flush()
|
||||
claim = ExpenseClaim(
|
||||
claim_no="APP-20260525-REQUIRE-OPINION",
|
||||
employee_id=employee.id,
|
||||
employee_name="张三",
|
||||
department_name="交付部",
|
||||
project_code="PRJ-A",
|
||||
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=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
with pytest.raises(ValueError, match="领导审核意见不能为空"):
|
||||
ExpenseClaimService(db).approve_claim(
|
||||
claim_id,
|
||||
current_user,
|
||||
opinion=" ",
|
||||
)
|
||||
|
||||
db.refresh(claim)
|
||||
assert claim.status == "submitted"
|
||||
assert claim.approval_stage == "直属领导审批"
|
||||
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 0
|
||||
|
||||
|
||||
def test_finance_can_approve_claim_to_archive_stage() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance-approve@example.com",
|
||||
|
||||
Reference in New Issue
Block a user