feat: 增强风险规则生成引擎与预算中心页面

后端拆分风险规则生成为解释器、语义分析、本体对齐等子模块,
优化模板执行和流程图生成,完善员工种子数据和导入逻辑,增强
报销单权限策略和草稿持久化,前端新增预算中心视图和趋势图
组件,重构审计页面和风险规则测试对话框交互,完善文档中心
和报销创建页面细节,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-26 09:15:14 +08:00
parent d0e946cf47
commit 0e861d8fa6
150 changed files with 14953 additions and 4099 deletions

View File

@@ -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",