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": 99, "original_amount": "1.00", "reimbursable_amount": "999999.00", } ], "request_id": "standard-adjustment-endpoint-1", }, 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"] == "450.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"] == "450.00" assert standard_flag["employee_absorbed_amount"] == "550.00" assert standard_flag["calculation_source"] == "server_policy" assert standard_flag["policy_days"] == 1 assert standard_flag["policy_grade"] == "P4" assert standard_flag["policy_matched_city"] == "北京" assert standard_flag["policy_hotel_rate"] == "450.00" assert standard_flag["policy_rule_name"] assert standard_flag["policy_rule_version"] 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"] with session_factory() as db: moved_forward = db.get(ExpenseClaim, "claim-approve-1") assert moved_forward is not None moved_forward.status = "pending_payment" moved_forward.approval_stage = "待付款" moved_forward.risk_flags_json = [ *list(moved_forward.risk_flags_json or []), {"source": "future_state", "message": "首次响应之后的状态"}, ] db.commit() 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() == payload assert all( item.get("source") != "future_state" for item in replay_response.json()["risk_flags_json"] ) 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_claim_owner_cannot_discover_or_operate_manager_approval_task() -> None: client, session_factory = build_client() with session_factory() as db: superior = Employee( id="manager-self-approval-superior", employee_no="E-SELF-APPROVAL-SUPERIOR", name="王总", email="superior-self-approval-api@example.com", ) manager = Employee( id="manager-self-approval-owner", employee_no="E-SELF-APPROVAL-OWNER", name="李经理", email="manager-self-approval-api@example.com", manager=superior, ) claim = ExpenseClaim( id="claim-self-approval-api", claim_no="EXP-SELF-APPROVAL-API", employee_id=manager.id, employee_name=manager.name, department_name="市场部", expense_type="transport", reason="交通报销", location="上海", amount=Decimal("66.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([superior, manager, claim]) db.commit() headers = { "X-Auth-Username": "manager-self-approval-api@example.com", "X-Auth-Name": "manager-self-approval-api@example.com", "X-Auth-Role-Codes": "manager", } approve_response = client.post( "/api/v1/reimbursements/claims/claim-self-approval-api/approve", json={ "opinion": "同意", "request_id": "claim-self-approval-approve", "expected_status": "submitted", "expected_approval_stage": "直属领导审批", }, headers=headers, ) return_response = client.post( "/api/v1/reimbursements/claims/claim-self-approval-api/return", json={ "reason": "退回", "request_id": "claim-self-approval-return", "expected_status": "submitted", "expected_approval_stage": "直属领导审批", }, headers=headers, ) assert approve_response.status_code == 404 assert return_response.status_code == 404 assert approve_response.json()["detail"] == "Approval task not found." assert return_response.json()["detail"] == "Approval task not found." with session_factory() as db: claim = db.get(ExpenseClaim, "claim-self-approval-api") assert claim is not None assert claim.status == "submitted" assert claim.approval_stage == "直属领导审批" assert claim.risk_flags_json == [] assert db.scalar(select(func.count(ApprovalActionLedger.id))) == 0 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"