Files
X-Financial/server/tests/test_reimbursement_endpoints.py
2026-07-16 15:34:58 +08:00

1596 lines
58 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import base64
import json
from collections.abc import Generator
from datetime import UTC, date, datetime
from decimal import Decimal
import pytest
from auth_helpers import install_legacy_header_auth_override
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, func, select
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.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
from app.models.approval_action import ApprovalActionLedger
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
from app.models.employee import Employee
from app.models.expense_case import BusinessEvent, ExpenseCaseLink
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.models.organization import OrganizationUnit
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
from app.models.role import Role
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
from app.schemas.ontology import OntologyParseResult
from app.schemas.user_agent import UserAgentRequest
from app.services.document_preview import DocumentPreviewAssets
from app.services.expense_cases import ExpenseCaseService
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
from app.services.expense_claims import ExpenseClaimService
from app.services.ocr import OcrService
from app.services.user_agent import UserAgentService
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()
install_legacy_header_auth_override(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_claim(db: Session) -> tuple[ExpenseClaim, ExpenseClaimItem]:
manager = Employee(
id="mgr-1",
employee_no="E20001",
name="李总",
email="manager@example.com",
position="市场总监",
grade="P7",
)
role = Role(
id="role-user",
role_code="user",
name="员工",
description="普通员工",
)
employee = Employee(
id="emp-1",
employee_no="E10001",
name="张三",
email="zhangsan@example.com",
position="招商主管",
grade="P4",
manager=manager,
roles=[role],
)
claim = ExpenseClaim(
id="claim-attachment-1",
claim_no="EXP-202605-101",
employee_id=employee.id,
employee_name="张三",
department_id="dept-1",
department_name="市场部",
project_code=None,
expense_type="office",
reason="办公用品采购",
location="深圳南山",
amount=Decimal("88.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 13, tzinfo=UTC),
submitted_at=None,
status="draft",
approval_stage="待提交",
risk_flags_json=[],
)
item = ExpenseClaimItem(
id="item-attachment-1",
claim_id=claim.id,
item_date=date(2026, 5, 13),
item_type="office",
item_reason="办公用品采购",
item_location="深圳南山",
item_amount=Decimal("88.00"),
invoice_id=None,
)
claim.items = [item]
db.add(manager)
db.add(role)
db.add(employee)
db.add(claim)
db.commit()
return claim, item
def test_claim_pre_review_and_submit_share_structured_handshake() -> None:
client, session_factory = build_client()
with session_factory() as db:
claim, item = seed_claim(db)
claim.invoice_count = 1
item.invoice_id = "office-receipt.png"
db.commit()
headers = {
"x-auth-username": "zhangsan@example.com",
"x-request-id": "pre-review-endpoint-ready-1",
}
review_response = client.post(
"/api/v1/reimbursements/claims/claim-attachment-1/pre-review",
headers=headers,
)
assert review_response.status_code == 200
review = review_response.json()["pre_review"]
assert review["decision"] in {"ready", "ready_with_review"}
assert review["review_id"]
assert review["input_fingerprint"].startswith("sha256:")
assert review["rule_set_fingerprint"].startswith("sha256:")
submit_response = client.post(
"/api/v1/reimbursements/claims/claim-attachment-1/submit",
headers={"x-auth-username": "zhangsan@example.com"},
json={
"pre_review_id": review["review_id"],
"input_fingerprint": review["input_fingerprint"],
},
)
assert submit_response.status_code == 200
assert submit_response.json()["status"] == "submitted"
with session_factory() as db:
events = list(
db.scalars(
select(BusinessEvent)
.where(BusinessEvent.aggregate_id == "claim-attachment-1")
.order_by(BusinessEvent.occurred_at.asc())
).all()
)
assert [event.event_type for event in events] == [
"claim_pre_review_completed",
"claim_submitted",
]
assert events[0].correlation_id == "pre-review-endpoint-ready-1"
assert events[1].correlation_id == events[0].correlation_id
assert events[1].causation_id == events[0].id
def test_claim_submit_returns_structured_pre_review_conflict() -> None:
client, session_factory = build_client()
with session_factory() as db:
claim, item = seed_claim(db)
claim.invoice_count = 1
item.invoice_id = "wrong-receipt.png"
claim.risk_flags_json = [
{
"source": "manual_risk",
"severity": "high",
"actionability": "fixable_by_submitter",
"business_stage": "reimbursement",
"label": "票据与明细不一致",
"message": "请更正费用明细或重新上传正确票据。",
"item_ids": [item.id],
}
]
db.commit()
response = client.post(
"/api/v1/reimbursements/claims/claim-attachment-1/submit",
headers={"x-auth-username": "zhangsan@example.com"},
json={},
)
assert response.status_code == 409
detail = response.json()["detail"]
assert detail["code"] == "PRE_REVIEW_NEEDS_FIX"
assert detail["review"]["decision"] == "needs_fix"
assert detail["review"]["blocking_count"] == 1
assert detail["review"]["findings"][0]["remediation"]["action"] == "edit_item_note"
with session_factory() as db:
claim = db.get(ExpenseClaim, "claim-attachment-1")
assert claim is not None
assert claim.status == "draft"
assert claim.submitted_at is None
assert (
db.scalar(
select(BusinessEvent).where(
BusinessEvent.aggregate_id == claim.id,
BusinessEvent.event_type == "claim_submitted",
)
)
is None
)
def test_claim_submit_returns_changed_conflict_when_dynamic_review_changes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
client, session_factory = build_client()
with session_factory() as db:
claim, item = seed_claim(db)
claim.invoice_count = 1
item.invoice_id = "office-receipt.png"
db.commit()
headers = {"x-auth-username": "zhangsan@example.com"}
review_response = client.post(
"/api/v1/reimbursements/claims/claim-attachment-1/pre-review",
headers=headers,
)
assert review_response.status_code == 200
review = review_response.json()["pre_review"]
assert review["decision"] in {"ready", "ready_with_review"}
def changed_review(_service, _claim):
return {
"risk_flags": [
{
"source": "submission_review",
"severity": "medium",
"actionability": "review_decision",
"business_stage": "reimbursement",
"label": "新增历史风险",
"message": "预审后发现新的历史风险记录,请确认后重试。",
}
],
"rule_set_fingerprint": "rules-v1",
}
monkeypatch.setattr(
ExpenseClaimService,
"_run_ai_submission_review",
changed_review,
)
response = client.post(
"/api/v1/reimbursements/claims/claim-attachment-1/submit",
headers=headers,
json={
"pre_review_id": review["review_id"],
"input_fingerprint": review["input_fingerprint"],
},
)
assert response.status_code == 409
detail = response.json()["detail"]
assert detail["code"] == "PRE_REVIEW_CHANGED"
assert detail["review"]["review_id"] != review["review_id"]
assert detail["review"]["decision"] == "ready_with_review"
with session_factory() as db:
claim = db.get(ExpenseClaim, "claim-attachment-1")
assert claim is not None
assert claim.status == "draft"
def test_claim_read_uses_organization_manager_and_dedupes_budget_warnings() -> None:
client, session_factory = build_client()
with session_factory() as db:
department = OrganizationUnit(
id="dept-org-manager",
unit_code="DEPT-ORG-MANAGER",
name="交付部",
manager_name="王总",
)
employee = Employee(
id="emp-org-manager",
employee_no="E30001",
name="赵六",
email="zhaoliu@example.com",
organization_unit=department,
position="实施顾问",
grade="P5",
finance_owner_name="Wang Finance",
)
duplicated_warning = {
"source": "budget_control",
"event_type": "budget_warning",
"severity": "medium",
"label": "预算接近预警线",
"message": "预算 SIM-BUD-2026-R0048 本次占用后使用率预计达到 99.27%,已达到预警线 80.00%",
"budget_no": "SIM-BUD-2026-R0048",
"allocation_id": "allocation-0048",
"subject_code": "travel",
}
claim = ExpenseClaim(
id="claim-org-manager",
claim_no="EXP-202606-ORG-MGR",
employee_id=employee.id,
employee_name=employee.name,
department_id=department.id,
department_name=department.name,
project_code=None,
expense_type="travel",
reason="差旅报销",
location="上海",
amount=Decimal("880.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 6, 3, tzinfo=UTC),
submitted_at=datetime(2026, 6, 3, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[
{**duplicated_warning, "created_at": "2026-06-03T10:00:00+00:00"},
{**duplicated_warning, "created_at": "2026-06-03T10:01:00+00:00"},
],
)
db.add_all([department, employee, claim])
db.commit()
headers = {"x-auth-username": "zhaoliu@example.com"}
response = client.get("/api/v1/reimbursements/claims/claim-org-manager", headers=headers)
assert response.status_code == 200
payload = response.json()
assert payload["manager_name"] == "王总"
assert payload["finance_owner_name"] == "Wang Finance"
budget_warnings = [
flag
for flag in payload["risk_flags_json"]
if flag.get("source") == "budget_control" and flag.get("event_type") == "budget_warning"
]
assert len(budget_warnings) == 1
assert budget_warnings[0]["message"] == duplicated_warning["message"]
def test_claim_read_attaches_finance_approver_name_for_finance_stage() -> None:
client, session_factory = build_client()
with session_factory() as db:
finance_role = Role(
id="role-finance-reader",
role_code="finance",
name="财务",
description="可处理财务复核任务",
)
applicant = Employee(
id="emp-finance-stage-applicant",
employee_no="E30002",
name="钱七",
email="qianqi@example.com",
position="实施顾问",
grade="P5",
finance_owner_name="Wang Finance Group",
)
finance_user = Employee(
id="emp-finance-stage-approver",
employee_no="F30002",
name="Wang Finance",
email="wang.finance@example.com",
position="财务专员",
grade="P6",
finance_owner_name="Wang Finance Group",
roles=[finance_role],
)
claim = ExpenseClaim(
id="claim-finance-stage-reader",
claim_no="EXP-202606-FINANCE-MGR",
employee_id=applicant.id,
employee_name=applicant.name,
department_id=None,
department_name="交付部",
project_code=None,
expense_type="travel",
reason="差旅报销",
location="上海",
amount=Decimal("880.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 6, 3, tzinfo=UTC),
submitted_at=datetime(2026, 6, 3, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="财务审批",
risk_flags_json=[],
)
db.add_all([finance_role, applicant, finance_user, claim])
db.commit()
headers = {"x-auth-username": "qianqi@example.com"}
response = client.get(
"/api/v1/reimbursements/claims/claim-finance-stage-reader", headers=headers
)
assert response.status_code == 200
payload = response.json()
assert payload["finance_owner_name"] == "Wang Finance Group"
assert payload["finance_approver_name"] == "Wang Finance"
def test_claim_standard_adjustment_endpoint_recalculates_and_marks_reviewer_notice() -> None:
client, session_factory = build_client()
with session_factory() as db:
claim, item = seed_claim(db)
claim.expense_type = "hotel"
claim.location = "北京"
claim.amount = Decimal("1000.00")
item.item_type = "hotel_ticket"
item.item_reason = "北京住宿"
item.item_location = "北京"
item.item_amount = Decimal("1000.00")
db.commit()
claim_id = claim.id
item_id = item.id
response = client.post(
f"/api/v1/reimbursements/claims/{claim_id}/standard-adjustment",
json={
"risks": [
{
"risk_id": "risk-hotel-endpoint-1",
"item_id": item_id,
"title": "住宿超标待说明",
"risk": "住宿票据金额超过职级标准。",
"application_days": 2,
"original_amount": "1000.00",
"reimbursable_amount": "1000.00",
}
]
},
headers={"x-auth-username": "emp-1", "x-auth-name": "Zhang San", "x-auth-grade": "P4"},
)
assert response.status_code == 200
payload = response.json()
assert payload["amount"] == "900.00"
standard_flag = next(
flag
for flag in payload["risk_flags_json"]
if isinstance(flag, dict) and flag.get("source") == "reimbursement_standard_adjustment"
)
assert standard_flag["original_amount"] == "1000.00"
assert standard_flag["reimbursable_amount"] == "900.00"
assert standard_flag["employee_absorbed_amount"] == "100.00"
assert standard_flag["visibility_scope"] == "leader"
def test_claim_item_attachment_upload_preview_and_delete(monkeypatch, tmp_path) -> None:
def fake_recognize(
self,
files: list[tuple[str, bytes, str | None]],
) -> OcrRecognizeBatchRead:
assert files[0][0] == "office-note.png"
return OcrRecognizeBatchRead(
total_file_count=1,
success_count=1,
documents=[
OcrRecognizeDocumentRead(
filename="office-note.png",
media_type="image/png",
text="办公用品发票 金额88元 2026-05-13",
summary="识别到办公用品发票,金额 88 元。",
avg_score=0.98,
line_count=1,
page_count=1,
warnings=[],
)
],
)
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
client, session_factory = build_client()
with session_factory() as db:
claim, item = seed_claim(db)
claim_id = claim.id
item_id = item.id
headers = {"x-auth-username": "emp-1", "x-auth-name": "Zhang San"}
file_bytes = b"fake-image-bytes"
upload_response = client.post(
f"/api/v1/reimbursements/claims/{claim_id}/items/{item_id}/attachment",
headers=headers,
files=[("file", ("office-note.png", file_bytes, "image/png"))],
)
assert upload_response.status_code == 200
upload_payload = upload_response.json()
assert upload_payload["attachment"]["file_name"] == "office-note.png"
assert upload_payload["attachment"]["analysis"]["label"] == "AI提示符合条件"
assert upload_payload["attachment"]["document_info"]["document_type"] == "office_invoice"
assert upload_payload["attachment"]["requirement_check"]["matches"] is True
assert upload_payload["invoice_id"]
assert upload_payload["item_type"] == "office"
assert upload_payload["item_reason"] == "识别到办公用品发票,金额 88 元。"
assert upload_payload["item_location"] == "深圳南山"
assert upload_payload["item_date"] == "2026-05-13"
assert upload_payload["item_amount"] == "88.00"
meta_response = client.get(
f"/api/v1/reimbursements/claims/{claim_id}/items/{item_id}/attachment/meta",
headers=headers,
)
assert meta_response.status_code == 200
meta_payload = meta_response.json()
assert meta_payload["media_type"] == "image/png"
assert meta_payload["preview_kind"] == "image"
assert meta_payload["preview_url"].endswith(
f"/reimbursements/claims/{claim_id}/items/{item_id}/attachment/preview"
)
assert meta_payload["analysis"]["headline"]
assert meta_payload["document_info"]["fields"][0]["label"] == "金额"
preview_response = client.get(
f"/api/v1/reimbursements/claims/{claim_id}/items/{item_id}/attachment/preview",
headers=headers,
)
assert preview_response.status_code == 200
assert preview_response.content == file_bytes
content_response = client.get(
f"/api/v1/reimbursements/claims/{claim_id}/items/{item_id}/attachment",
headers=headers,
)
assert content_response.status_code == 200
assert content_response.content == file_bytes
delete_response = client.delete(
f"/api/v1/reimbursements/claims/{claim_id}/items/{item_id}/attachment",
headers=headers,
)
assert delete_response.status_code == 200
assert delete_response.json()["invoice_id"] is None
deleted_meta_response = client.get(
f"/api/v1/reimbursements/claims/{claim_id}/items/{item_id}/attachment/meta",
headers=headers,
)
assert deleted_meta_response.status_code == 404
def test_claim_item_attachment_upload_flags_purpose_and_amount_mismatch(
monkeypatch, tmp_path
) -> None:
def fake_recognize(
self,
files: list[tuple[str, bytes, str | None]],
) -> OcrRecognizeBatchRead:
return OcrRecognizeBatchRead(
total_file_count=1,
success_count=1,
documents=[
OcrRecognizeDocumentRead(
filename="taxi-note.png",
media_type="image/png",
text="滴滴出行电子发票 金额120元 2026-05-13",
summary="识别到交通出行发票,金额 120 元。",
avg_score=0.97,
line_count=1,
page_count=1,
warnings=[],
)
],
)
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
client, session_factory = build_client()
with session_factory() as db:
claim, item = seed_claim(db)
claim_id = claim.id
item_id = item.id
headers = {"x-auth-username": "emp-1", "x-auth-name": "Zhang San"}
upload_response = client.post(
f"/api/v1/reimbursements/claims/{claim_id}/items/{item_id}/attachment",
headers=headers,
files=[("file", ("taxi-note.png", b"fake-image-bytes", "image/png"))],
)
assert upload_response.status_code == 200
analysis = upload_response.json()["attachment"]["analysis"]
assert analysis["severity"] == "high"
assert any("金额字段" in point for point in analysis["points"])
assert any("附件类型要求" in point for point in analysis["points"])
assert upload_response.json()["attachment"]["requirement_check"]["matches"] is False
def test_claim_item_attachment_upload_flags_non_invoice_image_as_high_risk(
monkeypatch, tmp_path
) -> None:
def fake_recognize(
self,
files: list[tuple[str, bytes, str | None]],
) -> OcrRecognizeBatchRead:
return OcrRecognizeBatchRead(
total_file_count=1,
success_count=1,
documents=[
OcrRecognizeDocumentRead(
filename="random-image.png",
media_type="image/png",
text="",
summary="",
avg_score=0.0,
line_count=0,
page_count=1,
warnings=[],
)
],
)
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
client, session_factory = build_client()
with session_factory() as db:
claim, item = seed_claim(db)
claim_id = claim.id
item_id = item.id
headers = {"x-auth-username": "emp-1", "x-auth-name": "Zhang San"}
upload_response = client.post(
f"/api/v1/reimbursements/claims/{claim_id}/items/{item_id}/attachment",
headers=headers,
files=[("file", ("random-image.png", b"fake-image-bytes", "image/png"))],
)
assert upload_response.status_code == 200
analysis = upload_response.json()["attachment"]["analysis"]
assert analysis["severity"] == "high"
assert any("附件内容" in point for point in analysis["points"])
def test_approve_claim_endpoint_routes_direct_manager_claim_to_finance_review() -> None:
client, session_factory = build_client()
with session_factory() as db:
manager = Employee(
id="mgr-approve-1",
employee_no="E21001",
name="李经理",
email="manager-approve-api@example.com",
)
employee = Employee(
id="emp-approve-1",
employee_no="E11001",
name="张三",
email="zhangsan-approve-api@example.com",
manager=manager,
)
claim = ExpenseClaim(
id="claim-approve-1",
claim_no="EXP-APP-API-001",
employee_id=employee.id,
employee_name="张三",
department_id="dept-1",
department_name="市场部",
project_code=None,
expense_type="transport",
reason="交通报销",
location="上海",
amount=Decimal("88.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 13, tzinfo=UTC),
submitted_at=datetime(2026, 5, 13, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
)
db.add_all([manager, employee, claim])
db.commit()
action_headers = {
"X-Auth-Username": "manager-approve-api@example.com",
"X-Auth-Name": "manager-approve-api@example.com",
"X-Auth-Role-Codes": "manager",
}
stale_response = client.post(
"/api/v1/reimbursements/claims/claim-approve-1/approve",
json={
"opinion": "情况属实,同意报销。",
"request_id": "approve-api-stale-1",
"expected_status": "draft",
"expected_approval_stage": "直属领导审批",
},
headers=action_headers,
)
assert stale_response.status_code == 409
response = client.post(
"/api/v1/reimbursements/claims/claim-approve-1/approve",
json={
"opinion": "情况属实,同意报销。",
"request_id": "approve-api-claim-1",
"expected_status": "submitted",
"expected_approval_stage": "直属领导审批",
},
headers=action_headers,
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "submitted"
assert payload["approval_stage"] == "财务审批"
assert any(
item["source"] == "manual_approval"
and item["opinion"] == "情况属实,同意报销。"
and item["operator"] == "李经理"
and item["next_approval_stage"] == "财务审批"
for item in payload["risk_flags_json"]
)
approval_events = [
item for item in payload["risk_flags_json"] if item["source"] == "manual_approval"
]
assert approval_events[0]["operator"] == "李经理"
assert "manager-approve-api@example.com" not in approval_events[0]["message"]
replay_response = client.post(
"/api/v1/reimbursements/claims/claim-approve-1/approve",
json={
"opinion": "情况属实,同意报销。",
"request_id": "approve-api-claim-1",
"expected_status": "submitted",
"expected_approval_stage": "直属领导审批",
},
headers=action_headers,
)
assert replay_response.status_code == 200
assert replay_response.json()["approval_stage"] == "财务审批"
changed_payload_response = client.post(
"/api/v1/reimbursements/claims/claim-approve-1/approve",
json={
"opinion": "改为有条件通过",
"request_id": "approve-api-claim-1",
"expected_status": "submitted",
"expected_approval_stage": "直属领导审批",
},
headers=action_headers,
)
assert changed_payload_response.status_code == 409
with session_factory() as db:
ledgers = list(db.scalars(select(ApprovalActionLedger)).all())
assert len(ledgers) == 1
assert ledgers[0].completed_at is not None
def test_approve_claim_endpoint_blocks_open_high_risk_with_machine_readable_detail() -> None:
client, session_factory = build_client()
with session_factory() as db:
manager = Employee(
id="manager-risk-block-api",
employee_no="M-RISK-BLOCK-API",
name="风险经理",
email="manager-risk-block-api@example.com",
)
employee = Employee(
id="employee-risk-block-api",
employee_no="E-RISK-BLOCK-API",
name="风险员工",
email="employee-risk-block-api@example.com",
manager=manager,
)
claim = ExpenseClaim(
id="claim-risk-block-api",
claim_no="EXP-RISK-BLOCK-API",
employee=employee,
employee_name=employee.name,
department_name="风控部",
expense_type="travel",
reason="客户拜访",
location="上海",
amount=Decimal("1200.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 7, 16, tzinfo=UTC),
submitted_at=datetime(2026, 7, 16, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
)
observation = RiskObservation(
id="risk-block-api-observation",
tenant_id="default",
observation_key="risk:claim-risk-block-api:duplicate",
subject_type="expense_claim",
subject_key="claim:claim-risk-block-api",
subject_label=claim.claim_no,
claim_id=claim.id,
claim_no=claim.claim_no,
risk_type="duplicate_invoice",
risk_signal="duplicate_invoice",
title="重复票据风险",
description="同一票据可能重复报销。",
risk_score=92,
risk_level="high",
confidence_score=0.95,
control_stage="reimbursement",
control_mode="risk_observation",
automation_mode="semi_auto_review",
source="financial_risk_graph",
algorithm_version="financial_risk_graph.v1",
status="pending_review",
feedback_status="unreviewed",
)
db.add_all([manager, employee, claim, observation])
db.commit()
manager_email = manager.email
response = client.post(
"/api/v1/reimbursements/claims/claim-risk-block-api/approve",
headers={
"X-Auth-Username": manager_email,
"X-Auth-Name": "Risk Manager",
"X-Auth-Role-Codes": "manager",
},
json={
"opinion": "同意",
"request_id": "approve-risk-block-api-001",
"expected_status": "submitted",
"expected_approval_stage": "直属领导审批",
},
)
assert response.status_code == 409
detail = response.json()["detail"]
assert detail["code"] == "APPROVAL_BLOCKED_BY_OPEN_HIGH_RISK"
assert detail["observations"] == [
{
"id": "risk-block-api-observation",
"title": "重复票据风险",
"risk_level": "high",
"adjudication": "unreviewed",
"lifecycle_status": "open",
}
]
with session_factory() as db:
persisted = db.get(ExpenseClaim, "claim-risk-block-api")
assert persisted is not None
assert persisted.approval_stage == "直属领导审批"
assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 0
def test_approve_application_endpoint_routes_direct_manager_review_to_budget_review() -> None:
client, session_factory = build_client()
with session_factory() as db:
department = OrganizationUnit(
id="dept-1",
unit_code="DELIVERY-API",
name="交付部",
unit_type="department",
)
budget_role = Role(
id="role-budget-application-approve-1",
role_code="budget_monitor",
name="预算监控员",
)
manager = Employee(
id="mgr-application-approve-1",
employee_no="E21002",
name="李经理",
email="manager-application-approve-api@example.com",
organization_unit=department,
)
budget_manager = Employee(
id="budget-application-approve-1",
employee_no="E31002",
name="赵预算",
email="budget-application-approve-api@example.com",
grade="P8",
organization_unit=department,
roles=[budget_role],
)
employee = Employee(
id="emp-application-approve-1",
employee_no="E11002",
name="张三",
email="zhangsan-application-approve-api@example.com",
manager=manager,
organization_unit=department,
)
claim = ExpenseClaim(
id="claim-application-approve-1",
claim_no="APP-20260525-API001",
employee_id=employee.id,
employee_name="张三",
department_id="dept-1",
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, tzinfo=UTC),
submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[
{
"source": "submission_review",
"severity": "high",
"actionability": "route_review",
"label": "申请风险复核",
"message": "申请金额和行程安排需要预算管理者二次确认。",
}
],
)
db.add_all([department, budget_role, manager, budget_manager, employee, claim])
db.commit()
response = client.post(
"/api/v1/reimbursements/claims/claim-application-approve-1/approve",
json={
"opinion": "业务必要,同意申请。",
"request_id": "approve-api-application-1",
"expected_status": "submitted",
"expected_approval_stage": "直属领导审批",
},
headers={
"X-Auth-Username": "manager-application-approve-api@example.com",
"X-Auth-Name": "manager-application-approve-api@example.com",
"X-Auth-Role-Codes": "manager",
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "submitted"
assert payload["approval_stage"] == "预算管理者审批"
assert any(
item["source"] == "manual_approval"
and item["event_type"] == "expense_application_approval"
and item["opinion"] == "业务必要,同意申请。"
and item["operator"] == "李经理"
and item["next_status"] == "submitted"
and item["next_approval_stage"] == "预算管理者审批"
and item["next_approver_name"] == "赵预算"
for item in payload["risk_flags_json"]
)
def test_claim_item_pdf_attachment_preview_returns_generated_image(monkeypatch, tmp_path) -> None:
preview_bytes = b"fake-preview-png"
preview_data_url = f"data:image/png;base64,{base64.b64encode(preview_bytes).decode('ascii')}"
def fake_recognize(
self,
files: list[tuple[str, bytes, str | None]],
) -> OcrRecognizeBatchRead:
return OcrRecognizeBatchRead(
total_file_count=1,
success_count=1,
documents=[
OcrRecognizeDocumentRead(
filename="invoice.pdf",
media_type="application/pdf",
text="滴滴出行电子发票 金额13.4元",
summary="识别到交通票据,金额 13.4 元。",
avg_score=0.96,
line_count=1,
page_count=1,
document_type="taxi_receipt",
document_type_label="出租车/网约车票据",
scene_code="transport",
scene_label="交通票据",
preview_kind="image",
preview_data_url=preview_data_url,
warnings=[],
)
],
)
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
client, session_factory = build_client()
with session_factory() as db:
claim, item = seed_claim(db)
claim_id = claim.id
item_id = item.id
headers = {"x-auth-username": "emp-1", "x-auth-name": "Zhang San"}
upload_response = client.post(
f"/api/v1/reimbursements/claims/{claim_id}/items/{item_id}/attachment",
headers=headers,
files=[("file", ("invoice.pdf", b"%PDF-1.4 fake", "application/pdf"))],
)
assert upload_response.status_code == 200
meta_payload = upload_response.json()["attachment"]
assert meta_payload["preview_kind"] == "image"
assert meta_payload["preview_url"].endswith(
f"/reimbursements/claims/{claim_id}/items/{item_id}/attachment/preview"
)
meta_path = next(tmp_path.rglob("invoice.pdf.meta.json"))
stored_meta = json.loads(meta_path.read_text(encoding="utf-8"))
assert stored_meta["preview_rendered_with"] == DocumentPreviewAssets.PDF_RENDERER_ID
preview_response = client.get(
f"/api/v1/reimbursements/claims/{claim_id}/items/{item_id}/attachment/preview",
headers=headers,
)
assert preview_response.status_code == 200
assert preview_response.headers["content-type"].startswith("image/png")
assert preview_response.content == preview_bytes
def test_claim_item_delete_removes_item_and_attachment(monkeypatch, tmp_path) -> None:
def fake_recognize(
self,
files: list[tuple[str, bytes, str | None]],
) -> OcrRecognizeBatchRead:
return OcrRecognizeBatchRead(
total_file_count=1,
success_count=1,
documents=[
OcrRecognizeDocumentRead(
filename="office-note.png",
media_type="image/png",
text="办公用品发票 金额88元 2026-05-13",
summary="识别到办公用品发票,金额 88 元。",
avg_score=0.98,
line_count=1,
page_count=1,
warnings=[],
)
],
)
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
client, session_factory = build_client()
with session_factory() as db:
claim, item = seed_claim(db)
claim_id = claim.id
item_id = item.id
headers = {"x-auth-username": "emp-1", "x-auth-name": "Zhang San"}
upload_response = client.post(
f"/api/v1/reimbursements/claims/{claim_id}/items/{item_id}/attachment",
headers=headers,
files=[("file", ("office-note.png", b"fake-image-bytes", "image/png"))],
)
assert upload_response.status_code == 200
assert (tmp_path / claim_id / item_id).exists()
delete_response = client.delete(
f"/api/v1/reimbursements/claims/{claim_id}/items/{item_id}",
headers=headers,
)
assert delete_response.status_code == 200
assert delete_response.json()["item_id"] == item_id
assert not (tmp_path / claim_id / item_id).exists()
detail_response = client.get(
f"/api/v1/reimbursements/claims/{claim_id}",
headers=headers,
)
assert detail_response.status_code == 200
detail_payload = detail_response.json()
assert detail_payload["items"] == []
assert detail_payload["invoice_count"] == 0
assert detail_payload["employee_position"] == "招商主管"
assert detail_payload["employee_grade"] == "P4"
assert detail_payload["manager_name"] == "李总"
assert detail_payload["role_labels"] == ["员工"]
deleted_meta_response = client.get(
f"/api/v1/reimbursements/claims/{claim_id}/items/{item_id}/attachment/meta",
headers=headers,
)
assert deleted_meta_response.status_code == 404
def test_claim_delete_allows_admin_and_cleans_risk_observations(monkeypatch, tmp_path) -> None:
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
client, session_factory = build_client()
with session_factory() as db:
claim, _ = seed_claim(db)
observation = RiskObservation(
id="risk-observation-delete-1",
observation_key="claim-delete-risk-observation-1",
subject_type="expense_claim",
subject_key=claim.id,
subject_label=claim.claim_no,
claim_id=claim.id,
claim_no=claim.claim_no,
risk_type="policy",
risk_signal="draft_pre_review",
title="草稿预审风险",
description="删除草稿时应同步清理关联风险观察。",
risk_score=70,
risk_level="medium",
confidence_score=0.8,
)
feedback = RiskObservationFeedback(
id="risk-observation-feedback-delete-1",
observation=observation,
feedback_type="confirm",
actor="auditor",
)
db.add(observation)
db.add(feedback)
db.commit()
claim_id = claim.id
response = client.delete(
f"/api/v1/reimbursements/claims/{claim_id}",
headers={"x-auth-username": "admin", "x-auth-name": "Admin User"},
)
assert response.status_code == 200
payload = response.json()
assert payload["claim_id"] == claim_id
assert payload["status"] == "deleted"
with session_factory() as db:
assert db.get(ExpenseClaim, claim_id) is None
assert db.get(RiskObservation, "risk-observation-delete-1") is None
assert db.get(RiskObservationFeedback, "risk-observation-feedback-delete-1") is None
def test_claim_delete_allows_applicant_to_delete_own_draft(monkeypatch, tmp_path) -> None:
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
client, session_factory = build_client()
with session_factory() as db:
claim, _ = seed_claim(db)
claim.claim_no = "AP-20260620-DRAFT"
claim.expense_type = "travel_application"
claim_id = claim.id
db.commit()
response = client.delete(
f"/api/v1/reimbursements/claims/{claim_id}",
headers={
"x-auth-username": "zhangsan@example.com",
"x-auth-name": "Zhang San",
"x-auth-employee-no": "E10001",
"x-auth-role-codes": "user",
},
)
assert response.status_code == 200
payload = response.json()
assert payload["claim_id"] == claim_id
assert payload["status"] == "deleted"
assert "申请单已删除" in payload["message"]
with session_factory() as db:
assert db.get(ExpenseClaim, claim_id) is None
def test_claim_delete_allows_legacy_superadmin_without_is_admin_header(
monkeypatch, tmp_path
) -> None:
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
client, session_factory = build_client()
with session_factory() as db:
claim, _ = seed_claim(db)
claim_id = claim.id
response = client.delete(
f"/api/v1/reimbursements/claims/{claim_id}",
headers={
"x-auth-username": "superadmin",
"x-auth-name": "superadmin",
"x-auth-role-codes": "manager",
},
)
assert response.status_code == 200
payload = response.json()
assert payload["claim_id"] == claim_id
assert payload["status"] == "deleted"
with session_factory() as db:
assert db.get(ExpenseClaim, claim_id) is None
def test_application_preview_action_submits_without_orchestrator_run(monkeypatch, tmp_path) -> None:
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
client, session_factory = build_client()
with session_factory() as db:
seed_claim(db)
response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers={
"x-auth-username": "zhangsan@example.com",
"x-auth-name": "Zhang San",
"x-auth-employee-no": "E10001",
"x-auth-role-codes": "user",
"x-auth-department": "Marketing",
},
json={
"source": "user_message",
"user_id": "zhangsan@example.com",
"conversation_id": "conversation-fast-submit",
"message": "\n".join(
[
"差旅费用申请提交审批",
"申请类型:差旅费用申请",
"申请时间2026-07-01 至 2026-07-03",
"地点:北京",
"事由:项目实施",
"天数3天",
"出行方式:火车",
"申请金额1000元",
"直接提交",
]
),
"context_json": {
"session_type": "application",
"entry_source": "workbench_ai_inline",
"document_type": "expense_application",
"application_stage": "expense_application",
"application_preview": {
"fields": {
"applicationType": "差旅费用申请",
"time": "2026-07-01 至 2026-07-03",
"location": "北京",
"reason": "项目实施",
"days": "3天",
"transportMode": "火车",
"amount": "1000元",
"applicant": "张三",
"department": "市场部",
"position": "招商主管",
"grade": "P4",
"managerName": "李总",
}
},
},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "succeeded"
draft_payload = payload["result"]["draft_payload"]
assert draft_payload["draft_type"] == "expense_application"
assert draft_payload["status"] == "submitted"
assert draft_payload["approval_stage"] == "直属领导审批"
assert draft_payload["claim_no"].startswith("A")
with session_factory() as db:
claim = db.get(ExpenseClaim, draft_payload["claim_id"])
assert claim is not None
assert claim.status == "submitted"
assert claim.employee_name == "张三"
reservation = db.scalar(
select(BudgetReservation).where(
BudgetReservation.source_type == "application",
BudgetReservation.source_id == claim.id,
BudgetReservation.source_status == "active",
)
)
assert reservation is not None
assert reservation.amount == Decimal("1000.00")
case_link = db.scalar(
select(ExpenseCaseLink).where(
ExpenseCaseLink.resource_type == "expense_claim",
ExpenseCaseLink.resource_id == claim.id,
)
)
assert case_link is not None
event = db.scalar(
select(BusinessEvent).where(
BusinessEvent.aggregate_id == claim.id,
BusinessEvent.event_type == "application_submitted",
)
)
assert event is not None
assert event.expense_case_id == case_link.expense_case_id
assert event.payload_json["previous_status"] == "draft"
assert event.payload_json["next_status"] == "submitted"
decision = db.scalar(select(AIDecision).where(AIDecision.expense_claim_id == claim.id))
feedback = db.scalar(
select(AIDecisionFeedback).where(AIDecisionFeedback.expense_claim_id == claim.id)
)
outcome = db.scalar(
select(WorkflowOutcome).where(WorkflowOutcome.expense_claim_id == claim.id)
)
assert decision is not None
assert decision.expense_case_id == case_link.expense_case_id
assert decision.correlation_id == event.correlation_id
assert decision.business_event_id == event.id
assert feedback is not None
assert feedback.feedback_type == "accepted"
assert feedback.action_type == "submit"
assert outcome is not None
assert outcome.outcome_type == "application_submitted"
assert outcome.business_event_id == event.id
assert any(
isinstance(flag, dict) and flag.get("event_type") == "expense_application_submission"
for flag in list(claim.risk_flags_json or [])
)
def test_application_direct_submit_rolls_back_budget_when_case_event_fails(
monkeypatch,
) -> None:
session_factory = build_session_factory()
def fail_event(*_args, **_kwargs):
raise RuntimeError("模拟费用事件写入失败")
monkeypatch.setattr(ExpenseCaseService, "record_claim_event", fail_event)
request = UserAgentRequest(
run_id="application-submit-rollback",
user_id="rollback@example.com",
message="直接提交",
ontology=OntologyParseResult(run_id="application-submit-rollback"),
context_json={
"session_type": "application",
"name": "回滚测试用户",
"department_name": "Test Department",
},
)
facts = {
"application_type": "差旅费用申请",
"time": "2026-07-01 至 2026-07-03",
"location": "北京",
"reason": "验证提交失败回滚",
"amount": "1000元",
}
with session_factory() as db:
with pytest.raises(RuntimeError, match="模拟费用事件写入失败"):
UserAgentService(db)._create_expense_application_record(
request,
facts,
submit=True,
)
# 模拟外层捕获异常后继续记录工具日志并提交,失败草稿仍不能被顺带落库。
db.commit()
assert list(db.scalars(select(ExpenseClaim)).all()) == []
assert list(db.scalars(select(BudgetAllocation)).all()) == []
assert list(db.scalars(select(BudgetReservation)).all()) == []
assert list(db.scalars(select(BudgetTransaction)).all()) == []
assert list(db.scalars(select(ExpenseCaseLink)).all()) == []
assert list(db.scalars(select(BusinessEvent)).all()) == []
assert list(db.scalars(select(AIDecision)).all()) == []
assert list(db.scalars(select(AIDecisionFeedback)).all()) == []
assert list(db.scalars(select(WorkflowOutcome)).all()) == []
def test_application_preview_action_saves_draft_with_detail_reference(
monkeypatch, tmp_path
) -> None:
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
client, session_factory = build_client()
with session_factory() as db:
seed_claim(db)
response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers={
"x-auth-username": "zhangsan@example.com",
"x-auth-name": "Zhang San",
"x-auth-employee-no": "E10001",
"x-auth-role-codes": "user",
},
json={
"source": "user_message",
"user_id": "zhangsan@example.com",
"conversation_id": "conversation-fast-save",
"message": "\n".join(
[
"费用申请保存草稿",
"申请类型:差旅费用申请",
"申请时间2026-07-04 至 2026-07-05",
"地点:上海",
"事由:项目验收",
"天数2天",
"出行方式:火车",
"申请金额800元",
"保存草稿",
]
),
"context_json": {
"session_type": "application",
"entry_source": "workbench_ai_inline",
"document_type": "expense_application",
"application_stage": "expense_application",
"application_action": "save_draft",
"application_save_mode": True,
"application_preview": {
"fields": {
"applicationType": "差旅费用申请",
"time": "2026-07-04 至 2026-07-05",
"location": "上海",
"reason": "项目验收",
"days": "2天",
"transportMode": "火车",
"amount": "800元",
"applicant": "张三",
"department": "市场部",
"position": "招商主管",
"grade": "P4",
"managerName": "李总",
}
},
},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "succeeded"
draft_payload = payload["result"]["draft_payload"]
assert draft_payload["draft_type"] == "expense_application"
assert draft_payload["status"] == "draft"
assert draft_payload["approval_stage"] == "待提交"
assert draft_payload["claim_id"]
assert draft_payload["claim_no"].startswith("A")
with session_factory() as db:
claim = db.get(ExpenseClaim, draft_payload["claim_id"])
assert claim is not None
assert claim.status == "draft"
assert claim.approval_stage == "待提交"
assert claim.submitted_at is None
assert claim.employee_name == "张三"
assert (
db.scalar(select(BudgetReservation).where(BudgetReservation.source_id == claim.id))
is None
)
event = db.scalar(select(BusinessEvent).where(BusinessEvent.aggregate_id == claim.id))
assert event is not None
assert event.event_type == "claim_draft_created"
assert event.payload_json["previous_status"] == ""
assert event.payload_json["next_status"] == "draft"
link = db.scalar(select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == claim.id))
assert link is not None
assert link.relation_type == "application"
def test_application_preview_action_rejects_forged_identity_when_editing_other_claim() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_claim(db)
outsider = Employee(
id="emp-outsider",
employee_no="E90001",
name="其他员工",
email="outsider@example.com",
)
outsider_claim = ExpenseClaim(
id="application-outsider-1",
claim_no="AP-OUTSIDER-001",
employee_id=outsider.id,
employee_name=outsider.name,
department_name="交付部",
expense_type="travel_application",
reason="其他员工原申请",
location="北京",
amount=Decimal("500.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 7, 10, tzinfo=UTC),
submitted_at=None,
status="returned",
approval_stage="退回补充",
risk_flags_json=[],
)
db.add_all([outsider, outsider_claim])
db.commit()
response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers={
"x-auth-username": "zhangsan@example.com",
"x-auth-name": "Zhang San",
"x-auth-employee-no": "E10001",
"x-auth-role-codes": "user",
},
json={
"source": "user_message",
"user_id": "outsider@example.com",
"conversation_id": "conversation-forged-identity",
"message": "费用申请保存草稿\n申请时间2026-07-13 至 2026-07-14\n地点:上海\n事由:恶意修改\n申请金额880元\n保存草稿",
"context_json": {
"session_type": "application",
"application_action": "save_draft",
"application_save_mode": True,
"application_edit_mode": True,
"application_edit_claim_id": "application-outsider-1",
"username": "outsider@example.com",
"name": "其他员工",
"employee_no": "E90001",
"role_codes": ["admin"],
"is_admin": True,
},
},
)
assert response.status_code == 400
assert response.json()["detail"] == "只能修改本人被退回的申请单。"
with session_factory() as db:
persisted = db.get(ExpenseClaim, "application-outsider-1")
assert persisted is not None
assert persisted.reason == "其他员工原申请"
assert persisted.status == "returned"
assert persisted.approval_stage == "退回补充"
assert (
db.scalar(select(BusinessEvent).where(BusinessEvent.aggregate_id == persisted.id))
is None
)
def test_application_preview_action_reuses_created_draft_for_identical_retry() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_claim(db)
request_payload = {
"source": "user_message",
"user_id": "zhangsan@example.com",
"conversation_id": "conversation-fast-save-retry",
"message": "费用申请保存草稿\n地点:上海\n事由:项目验收\n申请金额880元\n保存草稿",
"context_json": {
"session_type": "application",
"application_action": "save_draft",
"application_save_mode": True,
},
}
headers = {
"x-auth-username": "zhangsan@example.com",
"x-auth-name": "Zhang San",
"x-auth-employee-no": "E10001",
"x-auth-role-codes": "user",
}
first_response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers=headers,
json=request_payload,
)
second_response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers=headers,
json=request_payload,
)
assert first_response.status_code == 200
assert second_response.status_code == 200
first_draft = first_response.json()["result"]["draft_payload"]
second_draft = second_response.json()["result"]["draft_payload"]
assert second_draft["claim_id"] == first_draft["claim_id"]
assert second_draft["claim_no"] == first_draft["claim_no"]
with session_factory() as db:
application_claims = list(
db.scalars(
select(ExpenseClaim).where(ExpenseClaim.expense_type == "travel_application")
).all()
)
assert len(application_claims) == 1
events = list(
db.scalars(
select(BusinessEvent).where(BusinessEvent.aggregate_id == application_claims[0].id)
).all()
)
assert len(events) == 1
assert events[0].event_type == "claim_draft_created"