feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造
- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制 - 引入费用审批动态路由、平台风险分级、预审与风险阶段管理 - 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板 - 新增 Hermes 风险线索收集器、Agent 链路追踪中心 - 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估 - 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
@@ -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] == []
|
||||
|
||||
Reference in New Issue
Block a user