feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造

- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制
- 引入费用审批动态路由、平台风险分级、预审与风险阶段管理
- 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板
- 新增 Hermes 风险线索收集器、Agent 链路追踪中心
- 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估
- 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-01 17:07:14 +08:00
parent 7989f3a159
commit 92444e7eae
285 changed files with 25075 additions and 2986 deletions

View File

@@ -24,6 +24,11 @@ from app.services.agent_conversations import AgentConversationService
from app.services.budget import BudgetService
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
from app.services.expense_claims import ExpenseClaimService
from app.services.expense_claim_workflow_constants import (
APPROVAL_DONE_STAGE,
BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE,
)
from app.services.ontology import SemanticOntologyService
from app.services.ocr import OcrService
@@ -120,6 +125,16 @@ def _seed_budget_monitor_role(db: Session) -> Role:
return role
def _seed_executive_role(db: Session) -> Role:
role = db.query(Role).filter(Role.role_code == "executive").one_or_none()
if role is not None:
return role
role = Role(role_code="executive", name="Senior finance")
db.add(role)
db.flush()
return role
def test_validate_claim_for_submission_allows_office_claim_without_location() -> None:
service = ExpenseClaimService.__new__(ExpenseClaimService)
claim = build_claim(expense_type="office", location="待补充")
@@ -270,6 +285,63 @@ def test_save_draft_persists_user_changed_expense_category() -> None:
assert claim.items[0].item_type == "office"
def test_upsert_draft_from_ontology_persists_linked_application_context() -> None:
user_id = "linked-application-context@example.com"
message = "业务发生时间:2026-05-20去北京支撑国网部署火车票354元申请差旅费报销"
with build_session() as db:
employee = Employee(
employee_no="E5103",
name="关联员工",
email=user_id,
)
db.add(employee)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id=user_id,
)
)
result = ExpenseClaimService(db).upsert_draft_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message=message,
ontology=ontology,
context_json={
"name": "关联员工",
"user_input_text": message,
"review_action": "save_draft",
"review_form_values": {
"expense_type": "差旅费",
"amount": "354元",
"application_claim_id": "application-linked-1",
"application_claim_no": "AP-202605-001",
"application_reason": "支撑国网仿生产环境部署",
"application_location": "北京",
"application_amount": "3000",
},
"expense_scene_selection": {
"expense_type": "travel",
"application_claim_id": "application-linked-1",
"application_claim_no": "AP-202605-001",
},
},
)
claim = db.get(ExpenseClaim, result["claim_id"])
assert claim is not None
link_flag = next(
flag
for flag in claim.risk_flags_json
if isinstance(flag, dict) and flag.get("source") == "application_link"
)
assert link_flag["application_claim_no"] == "AP-202605-001"
assert link_flag["application_claim_id"] == "application-linked-1"
assert link_flag["application_detail"]["application_reason"] == "支撑国网仿生产环境部署"
def test_unsaved_conversation_expires_after_retention_but_saved_conversation_stays() -> None:
with build_session() as db:
service = AgentConversationService(db)
@@ -1446,6 +1518,98 @@ def test_upload_train_ticket_attachment_backfills_item_amount(monkeypatch, tmp_p
assert not any("用途字段" in point for point in uploaded_meta["analysis"]["points"])
def test_upload_attachment_response_includes_refreshed_rule_center_risk_flags(
monkeypatch,
tmp_path,
) -> None:
current_user = CurrentUserContext(
username="emp-1",
name="张三",
role_codes=[],
is_admin=False,
)
def fake_recognize(
self,
files: list[tuple[str, bytes, str | None]],
) -> OcrRecognizeBatchRead:
return OcrRecognizeBatchRead(
total_file_count=1,
success_count=1,
documents=[
OcrRecognizeDocumentRead(
filename="train-ticket.png",
media_type="image/png",
text="中国铁路电子客票 武汉-上海 2026-02-20 票价354元",
summary="铁路电子客票,武汉至上海,票价 354 元。",
avg_score=0.98,
line_count=1,
page_count=1,
document_type="train_ticket",
document_type_label="火车/高铁票",
scene_code="travel",
scene_label="差旅费",
document_fields=[
{"key": "route", "label": "行程", "value": "武汉-上海"},
{"key": "trip_date", "label": "行程日期", "value": "2026-02-20"},
{"key": "fare", "label": "票价", "value": "354元"},
],
)
],
)
def fake_evaluate_platform_risk_rules(self, claim, **kwargs):
assert kwargs.get("business_stage") == "reimbursement"
return {
"flags": [
{
"source": "submission_review",
"hit_source": "rule_center",
"rule_type": "risk",
"rule_code": "risk.test.upload_preview",
"severity": "high",
"message": "测试规则命中",
"business_stage": "reimbursement",
"risk_domain": "invoice",
"visibility_scope": "submitter",
"actionability": "fixable_by_submitter",
}
],
"blocking_reasons": ["测试规则命中"],
}
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
monkeypatch.setattr(
ExpenseClaimService,
"evaluate_platform_risk_rules",
fake_evaluate_platform_risk_rules,
)
with build_session() as db:
claim = build_claim(expense_type="travel", location="北京")
claim.items[0].invoice_id = None
db.add(claim)
db.commit()
payload = ExpenseClaimService(db).upload_claim_item_attachment(
claim_id=claim.id,
item_id=claim.items[0].id,
filename="train-ticket.png",
content=b"fake-image-bytes",
media_type="image/png",
current_user=current_user,
)
assert payload is not None
assert any(
flag.get("rule_code") == "risk.test.upload_preview"
for flag in payload["claim_risk_flags"]
)
db.refresh(claim)
assert payload["claim_risk_flags"] == claim.risk_flags_json
def test_upload_hotel_attachment_audits_date_like_amount(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext(
username="emp-1",
@@ -1962,6 +2126,56 @@ def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None:
assert submitted.submitted_at is not None
def test_pre_review_claim_records_ai_result_without_submitting() -> None:
current_user = CurrentUserContext(
username="emp-pre-review@example.com",
name="张三",
role_codes=[],
is_admin=False,
)
with build_session() as db:
manager = Employee(
employee_no="E7050",
name="李经理",
email="manager-pre-review@example.com",
)
employee = Employee(
employee_no="E7051",
name="张三",
email="emp-pre-review@example.com",
manager=manager,
)
claim = build_claim(expense_type="transport", location="上海")
claim.employee = employee
claim.employee_id = employee.id
claim.items[0].invoice_id = "taxi-ticket.png"
claim.risk_flags_json = [
{
"source": "manual_risk",
"severity": "high",
"label": "票据风险",
"message": "票据金额与行程不匹配。",
}
]
db.add_all([manager, employee, claim])
db.commit()
reviewed = ExpenseClaimService(db).pre_review_claim(claim.id, current_user)
assert reviewed is not None
assert reviewed.status == "draft"
assert reviewed.approval_stage == "AI预审"
assert reviewed.submitted_at is None
pre_review_flag = next(
flag
for flag in reviewed.risk_flags_json
if isinstance(flag, dict) and flag.get("source") == "ai_pre_review"
)
assert pre_review_flag["status"] == "failed"
assert pre_review_flag["next_action"] == "risk_explanation_required"
def test_submit_claim_allows_returned_claim_to_be_resubmitted() -> None:
current_user = CurrentUserContext(
username="emp-submit@example.com",
@@ -3163,7 +3377,7 @@ def test_application_submit_skips_ai_review_and_receipt_requirements(monkeypatch
approval_stage="待提交",
risk_flags_json=[
{
"source": "submission_review",
"source": "platform_risk",
"severity": "medium",
"message": "旧 AI 预审提示不应保留到申请单提交结果。",
}
@@ -3423,6 +3637,12 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
"transport_mode": "高铁",
"amount": "12000.00",
},
},
{
"source": "submission_review",
"severity": "high",
"label": "申请风险复核",
"message": "申请金额和行程安排需要预算管理者二次确认。",
}
],
)
@@ -3510,6 +3730,273 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
)
def test_application_routes_to_department_p8_executive_with_approver_name() -> None:
manager_user = CurrentUserContext(
username="manager-executive-route@example.com",
name="Manager",
role_codes=["manager"],
is_admin=False,
)
budget_user = CurrentUserContext(
username="p8-executive-route@example.com",
name="P8 Executive",
role_codes=["executive"],
is_admin=False,
)
with build_session() as db:
executive_role = _seed_executive_role(db)
department = OrganizationUnit(
unit_code="DELIVERY-EXECUTIVE-ROUTE",
name="Engineering",
unit_type="department",
)
manager = Employee(
employee_no="E-EXEC-ROUTE-MGR",
name="Manager",
email="manager-executive-route@example.com",
organization_unit=department,
)
budget_manager = Employee(
employee_no="E-EXEC-ROUTE-P8",
name="P8 Executive",
email="p8-executive-route@example.com",
grade="P8",
organization_unit=department,
roles=[executive_role],
)
employee = Employee(
employee_no="E-EXEC-ROUTE-APP",
name="Applicant",
email="applicant-executive-route@example.com",
manager=manager,
organization_unit=department,
)
db.add_all([department, manager, budget_manager, employee])
db.flush()
claim = ExpenseClaim(
claim_no="APP-20260531-EXEC-ROUTE",
employee_id=employee.id,
employee_name=employee.name,
department_id=department.id,
department_name=department.name,
project_code="PRJ-A",
expense_type="travel_application",
reason="Production deployment support",
location="Beijing",
amount=Decimal("12000.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 31, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 31, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
risk_flags_json=[
{
"source": "submission_review",
"severity": "high",
"label": "Route risk",
"message": "Application requires budget confirmation.",
}
],
)
db.add(claim)
db.commit()
claim_id = claim.id
routed = ExpenseClaimService(db).approve_claim(
claim_id,
manager_user,
opinion="Approved by direct manager.",
)
assert routed is not None
assert routed.status == "submitted"
assert routed.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE
assert getattr(routed, "budget_approver_name", "") == "P8 Executive"
assert getattr(routed, "budget_approver_grade", "") == "P8"
assert getattr(routed, "budget_approver_role_code", "") == "executive"
assert any(
isinstance(flag, dict)
and flag.get("source") == "manual_approval"
and flag.get("next_approval_stage") == BUDGET_MANAGER_APPROVAL_STAGE
and flag.get("next_approver_name") == "P8 Executive"
and flag.get("next_approver_grade") == "P8"
and flag.get("next_approver_role_code") == "executive"
for flag in routed.risk_flags_json
)
approved = ExpenseClaimService(db).approve_claim(
claim_id,
budget_user,
opinion="Budget confirmed.",
)
assert approved is not None
assert approved.status == "approved"
assert approved.approval_stage == APPROVAL_DONE_STAGE
def test_direct_manager_cannot_route_application_to_missing_budget_approver() -> None:
manager_user = CurrentUserContext(
username="manager-missing-budget@example.com",
name="Manager",
role_codes=["manager"],
is_admin=False,
)
with build_session() as db:
department = OrganizationUnit(
unit_code="DELIVERY-MISSING-BUDGET",
name="Engineering",
unit_type="department",
)
manager = Employee(
employee_no="E-MISSING-BUDGET-MGR",
name="Manager",
email="manager-missing-budget@example.com",
organization_unit=department,
)
employee = Employee(
employee_no="E-MISSING-BUDGET-APP",
name="Applicant",
email="applicant-missing-budget@example.com",
manager=manager,
organization_unit=department,
)
db.add_all([department, manager, employee])
db.flush()
claim = ExpenseClaim(
claim_no="APP-20260531-MISSING-BUDGET",
employee_id=employee.id,
employee_name=employee.name,
department_id=department.id,
department_name=department.name,
project_code="PRJ-A",
expense_type="travel_application",
reason="Production deployment support",
location="Beijing",
amount=Decimal("12000.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 31, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 31, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
risk_flags_json=[
{
"source": "submission_review",
"severity": "high",
"label": "Route risk",
"message": "Application requires budget confirmation.",
}
],
)
db.add(claim)
db.commit()
claim_id = claim.id
with pytest.raises(ValueError, match="未找到同部门 P8 预算审批人"):
ExpenseClaimService(db).approve_claim(
claim_id,
manager_user,
opinion="Approved by direct manager.",
)
db.refresh(claim)
assert claim.status == "submitted"
assert claim.approval_stage == DIRECT_MANAGER_APPROVAL_STAGE
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 0
def test_direct_manager_p8_executive_completes_application_without_duplicate_budget_approval() -> None:
manager_user = CurrentUserContext(
username="manager-executive-merged@example.com",
name="P8 Manager",
role_codes=["manager"],
is_admin=False,
)
with build_session() as db:
executive_role = _seed_executive_role(db)
department = OrganizationUnit(
unit_code="DELIVERY-EXECUTIVE-MERGED",
name="Engineering",
unit_type="department",
)
manager = Employee(
employee_no="E-EXEC-MERGED-MGR",
name="P8 Manager",
email="manager-executive-merged@example.com",
grade="P8",
organization_unit=department,
roles=[executive_role],
)
employee = Employee(
employee_no="E-EXEC-MERGED-APP",
name="Applicant",
email="applicant-executive-merged@example.com",
manager=manager,
organization_unit=department,
)
db.add_all([department, manager, employee])
db.flush()
claim = ExpenseClaim(
claim_no="APP-20260531-EXEC-MERGED",
employee_id=employee.id,
employee_name=employee.name,
department_id=department.id,
department_name=department.name,
project_code="PRJ-A",
expense_type="travel_application",
reason="Production deployment support",
location="Beijing",
amount=Decimal("12000.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 31, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 31, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
risk_flags_json=[
{
"source": "submission_review",
"severity": "high",
"label": "Route risk",
"message": "Application requires budget confirmation.",
}
],
)
db.add(claim)
db.commit()
claim_id = claim.id
approved = ExpenseClaimService(db).approve_claim(
claim_id,
manager_user,
opinion="Approved by direct manager and budget owner.",
)
assert approved is not None
assert approved.status == "approved"
assert approved.approval_stage == APPROVAL_DONE_STAGE
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 1
assert not any(
isinstance(flag, dict)
and flag.get("next_approval_stage") == BUDGET_MANAGER_APPROVAL_STAGE
for flag in approved.risk_flags_json
)
assert any(
isinstance(flag, dict)
and flag.get("source") == "manual_approval"
and flag.get("next_status") == "approved"
and flag.get("next_approval_stage") == APPROVAL_DONE_STAGE
and flag.get("budget_approval_merged") is True
and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver"
for flag in approved.risk_flags_json
)
def test_direct_manager_budget_monitor_completes_application_claim_without_duplicate_budget_approval() -> None:
manager_user = CurrentUserContext(
username="manager-budget-monitor-application@example.com",
@@ -3559,7 +4046,14 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli
submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
risk_flags_json=[
{
"source": "submission_review",
"severity": "high",
"label": "申请风险复核",
"message": "申请金额和行程安排需要预算管理者二次确认。",
}
],
)
db.add(claim)
db.commit()
@@ -3590,7 +4084,7 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli
and flag.get("next_status") == "approved"
and flag.get("next_approval_stage") == "审批完成"
and flag.get("budget_approval_merged") is True
and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_monitor"
and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver"
for flag in approved.risk_flags_json
)
generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one()
@@ -3775,7 +4269,14 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf
submitted_at=None,
status="draft",
approval_stage="待提交",
risk_flags_json=[],
risk_flags_json=[
{
"source": "platform_risk",
"severity": "high",
"label": "申请风险复核",
"message": "申请金额和行程安排需要预算管理者二次确认。",
}
],
)
db.add(claim)
db.commit()
@@ -3806,6 +4307,23 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf
for flag in generated_draft.risk_flags_json
)
deleted = service.delete_claim(
generated_draft.id,
CurrentUserContext(
username="browser-session-user",
name="",
role_codes=["user"],
is_admin=False,
employee_no="E-BUDGET-APP",
),
)
db.refresh(reservation)
assert deleted is not None
assert db.get(ExpenseClaim, generated_draft.id) is None
assert reservation.source_status == "released"
assert reservation.released_amount == Decimal("12000.00")
def test_direct_manager_approval_defaults_blank_opinion_to_agree() -> None:
current_user = CurrentUserContext(
@@ -4554,7 +5072,7 @@ def test_list_approval_claims_allows_budget_monitor_to_view_budget_stage_applica
is_admin=False,
)
p8_without_budget_role = CurrentUserContext(
username="budget-p8-list@example.com",
username="p8-without-budget-list@example.com",
name="budget manager",
role_codes=["manager"],
is_admin=False,
@@ -4580,6 +5098,13 @@ def test_list_approval_claims_allows_budget_monitor_to_view_budget_stage_applica
organization_unit=delivery_department,
roles=[budget_role],
)
p8_without_budget_employee = Employee(
employee_no="E-P8-NO-BUDGET-LIST",
name="P8 No Budget Role",
email="p8-without-budget-list@example.com",
grade="P8",
organization_unit=delivery_department,
)
employee = Employee(
employee_no="E-BUDGET-LIST-OWNER",
name="张三",
@@ -4592,7 +5117,14 @@ def test_list_approval_claims_allows_budget_monitor_to_view_budget_stage_applica
email="budget-list-market@example.com",
organization_unit=market_department,
)
db.add_all([delivery_department, market_department, budget_manager, employee, market_employee])
db.add_all([
delivery_department,
market_department,
budget_manager,
p8_without_budget_employee,
employee,
market_employee,
])
db.flush()
db.add_all(
[
@@ -4660,5 +5192,8 @@ def test_list_approval_claims_allows_budget_monitor_to_view_budget_stage_applica
claims = ExpenseClaimService(db).list_approval_claims(current_user)
assert [claim.claim_no for claim in claims] == ["APP-BUDGET-LIST-201"]
assert getattr(claims[0], "budget_approver_name", "") == "赵预算"
assert getattr(claims[0], "budget_approver_grade", "") == "P8"
assert getattr(claims[0], "budget_approver_role_code", "") == "budget_monitor"
claims_without_budget_role = ExpenseClaimService(db).list_approval_claims(p8_without_budget_role)
assert [claim.claim_no for claim in claims_without_budget_role] == []