from __future__ import annotations

import re
import threading
import uuid
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal

import pytest
from sqlalchemy import create_engine, or_, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool

from app.api.deps import CurrentUserContext
from app.core.config import get_settings
from app.db.base import Base
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.models.organization import OrganizationUnit
from app.models.role import Role
from app.models.savings import (
    ProfileBaselineSnapshot,
    SavingsEvidenceLink,
    SavingsOpportunity,
    SavingsRealization,
)
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
from app.schemas.ontology import OntologyParseRequest
from app.schemas.reimbursement import (
    ExpenseClaimItemCreate,
    ExpenseClaimItemUpdate,
    ExpenseClaimStandardAdjustmentPayload,
    ExpenseClaimUpdate,
)
from app.services.agent_conversations import AgentConversationService
from app.services.budget import BudgetService
from app.services.document_preview import DocumentPreviewAssets
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin
from app.services.expense_claim_errors import ExpenseClaimPreReviewBlockedError
from app.services.expense_claim_workflow_constants import (
    APPLICATION_ARCHIVE_STAGE,
    APPLICATION_LINK_STATUS_STAGE,
    APPROVAL_DONE_STAGE,
    BUDGET_MANAGER_APPROVAL_STAGE,
    DIRECT_MANAGER_APPROVAL_STAGE,
)
from app.services.expense_claims import ExpenseClaimService
from app.services.ocr import OcrService
from app.services.ontology import SemanticOntologyService
from app.services.receipt_folder import ReceiptFolderService
from app.services.savings_discovery import SavingsDiscoveryService


def build_claim(*, expense_type: str, location: str) -> ExpenseClaim:
    claim = ExpenseClaim(
        id="claim-1",
        claim_no="EXP-202605-001",
        employee_id="emp-1",
        employee_name="张三",
        department_id="dept-1",
        department_name="市场部",
        project_code=None,
        expense_type=expense_type,
        reason="费用报销",
        location=location,
        amount=Decimal("88.00"),
        currency="CNY",
        invoice_count=1,
        occurred_at=datetime(2026, 5, 13, tzinfo=UTC),
        submitted_at=None,
        status="draft",
        approval_stage="待提交",
        risk_flags_json=[],
    )
    claim.items = [
        ExpenseClaimItem(
            id="item-1",
            claim_id="claim-1",
            item_date=date(2026, 5, 13),
            item_type=expense_type,
            item_reason="费用报销",
            item_location=location,
            item_amount=Decimal("88.00"),
            invoice_id="invoice-1",
        )
    ]
    return claim


def build_application_claim(
    *,
    id: str,
    claim_no: str,
    employee: Employee,
    status: str = "approved",
    amount: Decimal = Decimal("3000.00"),
) -> ExpenseClaim:
    return ExpenseClaim(
        id=id,
        claim_no=claim_no,
        employee_id=employee.id,
        employee_name=employee.name,
        department_id=employee.organization_unit_id,
        department_name="Tech",
        project_code=None,
        expense_type="travel_application",
        reason="support deployment",
        location="Shanghai",
        amount=amount,
        currency="CNY",
        invoice_count=0,
        occurred_at=datetime(2026, 2, 20, tzinfo=UTC),
        submitted_at=datetime(2026, 2, 18, tzinfo=UTC),
        status=status,
        approval_stage=APPROVAL_DONE_STAGE if status in {"approved", "completed"} else "Pending",
        risk_flags_json=[],
    )


def build_session() -> Session:
    engine = create_engine(
        "sqlite+pysqlite:///:memory:",
        connect_args={"check_same_thread": False},
        poolclass=StaticPool,
    )
    Base.metadata.create_all(bind=engine)
    session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
    return session_factory()


def reimbursement_claim_query(db: Session):
    return db.query(ExpenseClaim).filter(
        or_(
            ExpenseClaim.claim_no.like("RE-%"),
            ExpenseClaim.claim_no.like("R________"),
        )
    )


def test_append_budget_flags_replaces_duplicate_budget_warning() -> None:
    base_warning = {
        "source": "budget_control",
        "event_type": "budget_warning",
        "severity": "medium",
        "label": "预算接近预警线",
        "message": "预算 SIM-BUD-2026-R0048 本次占用后使用率预计达到 98.00%，已达到预警线 80.00%。",
        "budget_no": "SIM-BUD-2026-R0048",
        "allocation_id": "allocation-0048",
        "subject_code": "travel",
        "created_at": "2026-06-03T10:00:00+00:00",
    }
    latest_warning = {
        **base_warning,
        "message": "预算 SIM-BUD-2026-R0048 本次占用后使用率预计达到 99.27%，已达到预警线 80.00%。",
        "created_at": "2026-06-03T10:01:00+00:00",
    }

    flags = ExpenseClaimBudgetFlowMixin._append_budget_flags(
        [base_warning],
        latest_warning,
        business_stage="reimbursement",
    )

    warnings = [
        flag
        for flag in flags
        if isinstance(flag, dict)
        and flag.get("source") == "budget_control"
        and flag.get("event_type") == "budget_warning"
    ]
    assert len(warnings) == 1
    assert "99.27%" in warnings[0]["message"]
    assert warnings[0]["business_stage"] == "reimbursement"


def _count_claims(db: Session) -> int:
    return int(db.query(ExpenseClaim).count())


def _seed_budget_allocation(
    db: Session,
    *,
    department_id: str | None,
    department_name: str,
    subject_code: str = "travel",
    amount: Decimal = Decimal("50000.00"),
    period_key: str = "2026Q2",
) -> BudgetAllocation:
    allocation = BudgetAllocation(
        budget_no=f"BUD-TEST-{uuid.uuid4().hex[:8]}",
        fiscal_year=2026,
        period_type="quarter",
        period_key=period_key,
        department_id=department_id,
        department_name=department_name,
        cost_center=None,
        project_code=None,
        subject_code=subject_code,
        subject_name=subject_code,
        original_amount=amount,
        adjusted_amount=Decimal("0.00"),
        status="active",
        warning_threshold=Decimal("80.00"),
        control_action="block",
    )
    db.add(allocation)
    db.commit()
    return allocation


def _seed_submitter_with_manager(
    db: Session,
    *,
    employee_id: str,
    email: str,
    name: str = "张三",
) -> Employee:
    manager = Employee(
        tenant_id="default",
        employee_no=f"{employee_id}-manager",
        name="李经理",
        email=f"{employee_id}-manager@example.com",
    )
    employee = Employee(
        id=employee_id,
        tenant_id="default",
        employee_no=f"{employee_id}-owner",
        name=name,
        email=email,
        manager=manager,
    )
    db.add_all([manager, employee])
    db.flush()
    return employee


def _seed_budget_monitor_role(db: Session) -> Role:
    role = db.query(Role).filter(Role.role_code == "budget_monitor").one_or_none()
    if role is not None:
        return role
    role = Role(role_code="budget_monitor", name="预算监控员")
    db.add(role)
    db.flush()
    return role


def _seed_executive_role(db: Session) -> Role:
    role = db.query(Role).filter(Role.role_code == "executive").one_or_none()
    if role is not None:
        return role
    role = Role(role_code="executive", name="Senior finance")
    db.add(role)
    db.flush()
    return role


def test_validate_claim_for_submission_allows_office_claim_without_location() -> None:
    service = ExpenseClaimService.__new__(ExpenseClaimService)
    claim = build_claim(expense_type="office", location="待补充")

    issues = service._validate_claim_for_submission(claim)

    assert "业务地点未完善" not in issues
    assert not any("缺少地点" in item for item in issues)


def test_validate_claim_for_submission_allows_transport_claim_without_location() -> None:
    service = ExpenseClaimService.__new__(ExpenseClaimService)
    claim = build_claim(expense_type="transport", location="待补充")

    issues = service._validate_claim_for_submission(claim)

    assert "业务地点未完善" not in issues
    assert not any("缺少地点" in item for item in issues)


def test_validate_claim_for_submission_still_requires_location_for_travel_claim() -> None:
    service = ExpenseClaimService.__new__(ExpenseClaimService)
    claim = build_claim(expense_type="travel", location="待补充")

    issues = service._validate_claim_for_submission(claim)

    assert "业务地点未完善" in issues
    assert not any("缺少地点" in item for item in issues)


def test_validate_claim_for_submission_does_not_require_optional_ride_receipt() -> None:
    service = ExpenseClaimService.__new__(ExpenseClaimService)
    claim = build_claim(expense_type="transport", location="待补充")
    claim.invoice_count = 0
    claim.items[0].item_type = "ride_ticket"
    claim.items[0].invoice_id = ""

    issues = service._validate_claim_for_submission(claim)

    assert "票据附件数量不足" not in issues
    assert not any("缺少票据标识" in item for item in issues)


def test_validate_claim_for_submission_still_requires_hotel_receipt() -> None:
    service = ExpenseClaimService.__new__(ExpenseClaimService)
    claim = build_claim(expense_type="hotel", location="北京")
    claim.invoice_count = 0
    claim.items[0].item_type = "hotel_ticket"
    claim.items[0].invoice_id = ""

    issues = service._validate_claim_for_submission(claim)

    assert "票据附件数量不足" in issues
    assert any("缺少票据标识" in item for item in issues)


def test_validate_claim_for_submission_does_not_block_uploaded_receipt_ocr_gaps() -> None:
    service = ExpenseClaimService.__new__(ExpenseClaimService)
    claim = build_claim(expense_type="hotel", location="北京")
    claim.invoice_count = 1
    claim.amount = Decimal("1086.00")
    claim.items[0].item_type = "hotel_ticket"
    claim.items[0].item_date = None
    claim.items[0].item_reason = ""
    claim.items[0].item_amount = Decimal("0.00")
    claim.items[0].invoice_id = "claim-1/item-1/hotel-invoice.png"

    issues = service._validate_claim_for_submission(claim)

    assert not any("缺少日期" in item for item in issues)
    assert not any("缺少说明" in item for item in issues)
    assert not any("缺少金额" in item for item in issues)
    assert not any("缺少票据标识" in item for item in issues)


def test_validate_claim_for_submission_ignores_trailing_placeholder_item() -> None:
    service = ExpenseClaimService.__new__(ExpenseClaimService)
    claim = build_claim(expense_type="travel", location="上海")
    claim.amount = Decimal("1086.00")
    claim.invoice_count = 1
    claim.items[0].item_type = "hotel_ticket"
    claim.items[0].item_reason = "上海喜来登酒店"
    claim.items[0].item_location = "上海"
    claim.items[0].item_amount = Decimal("1086.00")
    claim.items[0].invoice_id = "claim-1/item-1/hotel-invoice.png"
    claim.items.append(
        ExpenseClaimItem(
            id="item-2",
            claim_id="claim-1",
            item_date=date(2026, 2, 23),
            item_type="hotel_ticket",
            item_reason="",
            item_location="",
            item_amount=Decimal("0.00"),
            invoice_id="",
        )
    )

    issues = service._validate_claim_for_submission(claim)

    assert not any(item.startswith("费用明细第 2 条") for item in issues)


def test_validate_claim_for_submission_skips_generated_allowance_item() -> None:
    service = ExpenseClaimService.__new__(ExpenseClaimService)
    claim = build_claim(expense_type="travel", location="上海")
    claim.amount = Decimal("1486.00")
    claim.invoice_count = 1
    claim.items[0].item_type = "hotel_ticket"
    claim.items[0].item_reason = "上海喜来登酒店"
    claim.items[0].item_location = "上海"
    claim.items[0].item_amount = Decimal("1086.00")
    claim.items[0].invoice_id = "claim-1/item-1/hotel-invoice.png"
    claim.items.append(
        ExpenseClaimItem(
            id="allowance-1",
            claim_id="claim-1",
            item_date=date(2026, 2, 23),
            item_type="travel_allowance",
            item_reason="",
            item_location="",
            item_amount=Decimal("0.00"),
            invoice_id="",
        )
    )

    issues = service._validate_claim_for_submission(claim)

    assert not any(item.startswith("费用明细第 2 条") for item in issues)


def test_save_or_submit_preview_does_not_create_claim_without_explicit_action() -> None:
    user_id = "preview-only@example.com"
    message = "业务发生时间:2026-03-04，打车去客户现场，交通费32元，请帮我看看怎么报"

    with build_session() as db:
        employee = Employee(
            employee_no="E5100",
            name="预览员工",
            email=user_id,
        )
        db.add(employee)
        db.commit()
        ontology = SemanticOntologyService(db).parse(
            OntologyParseRequest(
                query=message,
                user_id=user_id,
            )
        )
        before_count = _count_claims(db)

        result = ExpenseClaimService(db).save_or_submit_from_ontology(
            run_id=ontology.run_id,
            user_id=user_id,
            message=message,
            ontology=ontology,
            context_json={
                "tenant_id": "default",
                "name": "预览员工",
                "user_input_text": message,
            },
        )

        assert result["preview_only"] is True
        assert result["status"] == "preview"
        assert "报销测算参考：" in result["message"]
        assert "| 项目 | 当前信息 | 复核口径 |" in result["message"]
        assert "交通票据金额 + 住宿标准" not in result["message"]
        assert _count_claims(db) == before_count


def test_save_or_submit_persists_claim_only_after_save_draft_action() -> None:
    user_id = "save-draft-explicit@example.com"
    message = "业务发生时间:2026-03-04，打车去客户现场，交通费32元，请帮我看看怎么报"

    with build_session() as db:
        employee = Employee(
            employee_no="E5101",
            name="保存员工",
            email=user_id,
        )
        db.add(employee)
        db.commit()
        ontology = SemanticOntologyService(db).parse(
            OntologyParseRequest(
                query=message,
                user_id=user_id,
            )
        )
        before_count = _count_claims(db)

        result = ExpenseClaimService(db).save_or_submit_from_ontology(
            run_id=ontology.run_id,
            user_id=user_id,
            message=message,
            ontology=ontology,
            context_json={
                "tenant_id": "default",
                "name": "保存员工",
                "user_input_text": message,
                "review_action": "save_draft",
            },
        )

        assert result["draft_only"] is True
        assert result["claim_id"]
        assert result["status"] == "draft"
        assert _count_claims(db) == before_count + 1


def test_save_draft_persists_user_changed_expense_category() -> None:
    user_id = "save-draft-category@example.com"
    message = "业务发生时间:2026-03-04，打车去客户现场，交通费32元，请帮我看看怎么报"

    with build_session() as db:
        employee = Employee(
            employee_no="E5102",
            name="分类员工",
            email=user_id,
        )
        db.add(employee)
        db.commit()
        ontology = SemanticOntologyService(db).parse(
            OntologyParseRequest(
                query=message,
                user_id=user_id,
            )
        )

        result = ExpenseClaimService(db).save_or_submit_from_ontology(
            run_id=ontology.run_id,
            user_id=user_id,
            message=message,
            ontology=ontology,
            context_json={
                "tenant_id": "default",
                "name": "分类员工",
                "user_input_text": message,
                "review_action": "save_draft",
                "review_form_values": {
                    "expense_type": "办公用品费",
                    "amount": "32元",
                    "occurred_date": "2026-03-04",
                    "reason": "右侧核对后改为办公用品费",
                },
            },
        )

        claim = db.get(ExpenseClaim, result["claim_id"])
        assert claim is not None
        assert claim.expense_type == "office"
        assert claim.items[0].item_type == "office"


def test_upsert_draft_from_ontology_persists_linked_application_context() -> None:
    user_id = "linked-application-context@example.com"
    message = "业务发生时间:2026-05-20，去北京支撑国网部署，火车票354元，申请差旅费报销"

    with build_session() as db:
        employee = Employee(
            employee_no="E5103",
            name="关联员工",
            email=user_id,
        )
        db.add(employee)
        db.flush()
        db.add(
            build_application_claim(
                id="application-linked-1",
                claim_no="AP-202605-001",
                employee=employee,
            )
        )
        db.commit()
        ontology = SemanticOntologyService(db).parse(
            OntologyParseRequest(
                query=message,
                user_id=user_id,
            )
        )

        result = ExpenseClaimService(db).upsert_draft_from_ontology(
            run_id=ontology.run_id,
            user_id=user_id,
            message=message,
            ontology=ontology,
            context_json={
                "tenant_id": "default",
                "name": "关联员工",
                "user_input_text": message,
                "review_action": "save_draft",
                "review_form_values": {
                    "expense_type": "差旅费",
                    "amount": "354元",
                    "application_claim_id": "application-linked-1",
                    "application_claim_no": "AP-202605-001",
                    "application_reason": "支撑国网仿生产环境部署",
                    "application_location": "北京",
                    "application_amount": "3000",
                },
                "expense_scene_selection": {
                    "expense_type": "travel",
                    "application_claim_id": "application-linked-1",
                    "application_claim_no": "AP-202605-001",
                },
            },
        )

        claim = db.get(ExpenseClaim, result["claim_id"])
        assert claim is not None
        link_flag = next(
            flag
            for flag in claim.risk_flags_json
            if isinstance(flag, dict) and flag.get("source") == "application_link"
        )
        assert link_flag["application_claim_no"] == "AP-202605-001"
        assert link_flag["application_claim_id"] == "application-linked-1"
        assert link_flag["application_detail"]["application_reason"] == "支撑国网仿生产环境部署"


def test_upsert_linked_application_draft_without_receipts_has_no_placeholder_item() -> None:
    user_id = "linked-application-no-receipt@example.com"
    message = (
        "报销类型：差旅费\n"
        "关联申请单：AP-202606-001 / 支撑国网仿生产服务器部署 / "
        "2026-02-20 至 2026-02-23 / 上海 / ¥3,000\n"
        "报销票据：草稿生成后在详情中上传"
    )

    with build_session() as db:
        employee = Employee(
            employee_no="E5104",
            name="关联员工",
            email=user_id,
            grade="P5",
        )
        db.add(employee)
        db.flush()
        db.add(
            build_application_claim(
                id="application-linked-no-receipt",
                claim_no="AP-202606-001",
                employee=employee,
            )
        )
        db.commit()
        ontology = SemanticOntologyService(db).parse(
            OntologyParseRequest(
                query=message,
                user_id=user_id,
            )
        )

        result = ExpenseClaimService(db).upsert_draft_from_ontology(
            run_id=ontology.run_id,
            user_id=user_id,
            message=message,
            ontology=ontology,
            context_json={
                "tenant_id": "default",
                "name": "关联员工",
                "user_input_text": message,
                "review_action": "save_draft",
                "review_form_values": {
                    "expense_type": "差旅费",
                    "amount": "¥3,000",
                    "reason": "支撑国网仿生产服务器部署",
                    "location": "上海",
                    "business_location": "上海",
                    "time_range": "2026-02-20 至 2026-02-23",
                    "business_time": "2026-02-20 至 2026-02-23",
                    "application_claim_id": "application-linked-no-receipt",
                    "application_claim_no": "AP-202606-001",
                    "application_reason": "支撑国网仿生产服务器部署",
                    "application_location": "上海",
                    "application_amount": "3000",
                    "application_amount_label": "¥3,000",
                    "application_business_time": "2026-02-20 至 2026-02-23",
                    "application_date": "2026-06-02T00:58:00Z",
                    "application_days": "4 天",
                    "application_transport_mode": "火车",
                    "application_lodging_daily_cap": "600元/天",
                    "application_subsidy_daily_cap": "120元/天",
                    "application_transport_policy": "按真实票据复核",
                    "application_policy_estimate": "交通 1,160元 + 住宿 2,400元 + 补贴 480元",
                },
                "expense_scene_selection": {
                    "expense_type": "travel",
                    "application_claim_id": "application-linked-no-receipt",
                    "application_claim_no": "AP-202606-001",
                },
            },
        )

        claim = db.get(ExpenseClaim, result["claim_id"])
        assert claim is not None
        assert claim.expense_type == "travel"
        assert claim.reason == "支撑国网仿生产服务器部署"
        assert claim.location == "上海"
        assert claim.amount == Decimal("0.00")
        assert claim.invoice_count == 0
        assert claim.occurred_at.date() == date(2026, 2, 20)
        assert claim.items == []
        link_flag = next(
            flag
            for flag in claim.risk_flags_json
            if isinstance(flag, dict) and flag.get("source") == "application_link"
        )
        assert link_flag["application_claim_no"] == "AP-202606-001"
        assert link_flag["application_detail"]["application_time"] == "2026-02-20 至 2026-02-23"
        assert (
            link_flag["application_detail"]["application_business_time"]
            == "2026-02-20 至 2026-02-23"
        )
        assert link_flag["application_detail"]["application_date"] == "2026-06-02T00:58:00Z"
        assert link_flag["application_detail"]["application_amount"] == "3000"
        assert link_flag["application_detail"]["application_days"] == "4 天"
        assert link_flag["application_detail"]["application_transport_mode"] == "火车"
        assert link_flag["application_detail"]["application_lodging_daily_cap"] == "600元/天"
        assert link_flag["application_detail"]["application_subsidy_daily_cap"] == "120元/天"


def test_upsert_linked_application_draft_clears_existing_placeholder_item() -> None:
    user_id = "linked-application-existing-placeholder@example.com"
    message = (
        "报销类型：差旅费\n"
        "关联申请单：AP-202606-002 / 支撑国网仿生产服务器部署 / 上海 / ¥3,000\n"
        "报销票据：草稿生成后在详情中上传"
    )

    with build_session() as db:
        employee = Employee(
            employee_no="E5105",
            name="关联员工",
            email=user_id,
            grade="P5",
        )
        db.add(employee)
        db.flush()
        db.add(
            build_application_claim(
                id="application-linked-existing-placeholder",
                claim_no="AP-202606-002",
                employee=employee,
            )
        )
        existing_claim = ExpenseClaim(
            claim_no="RE-202606020001-PLACEHOLDER",
            employee_id=employee.id,
            employee_name="关联员工",
            department_name="技术部",
            project_code=None,
            expense_type="travel",
            reason="支撑国网仿生产服务器部署",
            location="上海",
            amount=Decimal("3000.00"),
            currency="CNY",
            invoice_count=0,
            occurred_at=datetime(2026, 2, 20, tzinfo=UTC),
            status="draft",
            approval_stage="待提交",
            risk_flags_json=[],
        )
        existing_claim.items = [
            ExpenseClaimItem(
                claim_id=existing_claim.id,
                item_date=date(2026, 2, 20),
                item_type="travel",
                item_reason="支撑国网仿生产服务器部署",
                item_location="上海",
                item_amount=Decimal("3000.00"),
                invoice_id=None,
            )
        ]
        db.add(existing_claim)
        db.commit()

        ontology = SemanticOntologyService(db).parse(
            OntologyParseRequest(
                query=message,
                user_id=user_id,
            )
        )
        result = ExpenseClaimService(db).upsert_draft_from_ontology(
            run_id=ontology.run_id,
            user_id=user_id,
            message=message,
            ontology=ontology,
            context_json={
                "tenant_id": "default",
                "name": "关联员工",
                "draft_claim_id": existing_claim.id,
                "user_input_text": message,
                "review_action": "save_draft",
                "review_form_values": {
                    "expense_type": "差旅费",
                    "amount": "¥3,000",
                    "reason": "支撑国网仿生产服务器部署",
                    "location": "上海",
                    "business_location": "上海",
                    "application_claim_id": "application-linked-existing-placeholder",
                    "application_claim_no": "AP-202606-002",
                    "application_reason": "支撑国网仿生产服务器部署",
                    "application_location": "上海",
                    "application_amount": "3000",
                    "application_amount_label": "¥3,000",
                },
                "expense_scene_selection": {
                    "expense_type": "travel",
                    "application_claim_id": "application-linked-existing-placeholder",
                    "application_claim_no": "AP-202606-002",
                },
            },
        )

        claim = db.get(ExpenseClaim, result["claim_id"])
        assert claim is not None
        assert claim.id == existing_claim.id
        assert claim.amount == Decimal("0.00")
        assert claim.invoice_count == 0
        assert claim.items == []


def test_upsert_linked_application_requires_approved_application() -> None:
    user_id = "linked-application-status-block@example.com"
    message = "save reimbursement draft from linked travel application"

    with build_session() as db:
        employee = Employee(employee_no="E5108", name="Linked Employee", email=user_id)
        db.add(employee)
        db.flush()
        db.add(
            build_application_claim(
                id="application-returned-blocked",
                claim_no="AP-202606-STATUS",
                employee=employee,
                status="returned",
            )
        )
        db.commit()

        ontology = SemanticOntologyService(db).parse(
            OntologyParseRequest(query=message, user_id=user_id)
        )
        result = ExpenseClaimService(db).upsert_draft_from_ontology(
            run_id=ontology.run_id,
            user_id=user_id,
            message=message,
            ontology=ontology,
            context_json={
                "tenant_id": "default",
                "name": "Linked Employee",
                "user_input_text": message,
                "review_action": "save_draft",
                "review_form_values": {
                    "expense_type": "travel",
                    "application_claim_id": "application-returned-blocked",
                    "application_claim_no": "AP-202606-STATUS",
                },
                "expense_scene_selection": {
                    "expense_type": "travel",
                    "application_claim_id": "application-returned-blocked",
                    "application_claim_no": "AP-202606-STATUS",
                },
            },
        )

        assert result["status"] == "blocked"
        assert result["application_link_blocked"] is True
        assert result["application_claim_no"] == "AP-202606-STATUS"
        assert _count_claims(db) == 1


def test_upsert_linked_application_rejects_duplicate_reimbursement_draft() -> None:
    user_id = "linked-application-duplicate-block@example.com"
    message = "save another reimbursement draft from linked travel application"

    with build_session() as db:
        employee = Employee(employee_no="E5109", name="Linked Employee", email=user_id)
        db.add(employee)
        db.flush()
        db.add(
            build_application_claim(
                id="application-duplicate-blocked",
                claim_no="AP-202606-DUP",
                employee=employee,
            )
        )
        existing_claim = ExpenseClaim(
            claim_no="RE-202606-DUP-DRAFT",
            employee_id=employee.id,
            employee_name=employee.name,
            department_name="Tech",
            project_code=None,
            expense_type="travel",
            reason="support deployment",
            location="Shanghai",
            amount=Decimal("0.00"),
            currency="CNY",
            invoice_count=0,
            occurred_at=datetime(2026, 2, 20, tzinfo=UTC),
            status="draft",
            approval_stage="Pending",
            risk_flags_json=[
                {
                    "source": "application_link",
                    "application_claim_id": "application-duplicate-blocked",
                    "application_claim_no": "AP-202606-DUP",
                }
            ],
        )
        db.add(existing_claim)
        db.commit()

        ontology = SemanticOntologyService(db).parse(
            OntologyParseRequest(query=message, user_id=user_id)
        )
        result = ExpenseClaimService(db).upsert_draft_from_ontology(
            run_id=ontology.run_id,
            user_id=user_id,
            message=message,
            ontology=ontology,
            context_json={
                "tenant_id": "default",
                "name": "Linked Employee",
                "user_input_text": message,
                "review_action": "save_draft",
                "review_form_values": {
                    "expense_type": "travel",
                    "application_claim_id": "application-duplicate-blocked",
                    "application_claim_no": "AP-202606-DUP",
                },
                "expense_scene_selection": {
                    "expense_type": "travel",
                    "application_claim_id": "application-duplicate-blocked",
                    "application_claim_no": "AP-202606-DUP",
                },
            },
        )

        assert result["status"] == "blocked"
        assert result["application_link_blocked"] is True
        assert result["existing_claim_no"] == "RE-202606-DUP-DRAFT"
        assert _count_claims(db) == 2


def test_sync_travel_allowance_uses_linked_application_range_days() -> None:
    with build_session() as db:
        employee = Employee(
            employee_no="E5106",
            name="关联差旅员工",
            email="linked-application-allowance@example.com",
            grade="P4",
        )
        db.add(employee)
        db.flush()

        claim = build_claim(expense_type="travel", location="上海")
        claim.employee_id = employee.id
        claim.employee_name = employee.name
        claim.amount = Decimal("354.00")
        claim.items[0].item_date = date(2026, 2, 20)
        claim.items[0].item_type = "train_ticket"
        claim.items[0].item_reason = "武汉-上海"
        claim.items[0].item_location = "上海"
        claim.items[0].item_amount = Decimal("354.00")
        claim.risk_flags_json = [
            {
                "source": "application_link",
                "application_claim_no": "AP-202606-003",
                "application_detail": {
                    "application_time": "2026-02-20 至 2026-02-23",
                    "application_days": "4 天",
                    "application_location": "上海",
                    "application_transport_mode": "火车",
                },
            }
        ]
        db.add(claim)
        db.commit()

        service = ExpenseClaimService(db)
        service._sync_claim_from_items(claim)
        db.commit()
        db.refresh(claim)

        allowance_item = next(item for item in claim.items if item.item_type == "travel_allowance")
        assert "4天" in allowance_item.item_reason
        assert allowance_item.item_date == date(2026, 2, 23)


def test_sync_travel_allowance_backfills_range_from_linked_application_claim() -> None:
    with build_session() as db:
        employee = Employee(
            employee_no="E5107",
            name="旧关联差旅员工",
            email="linked-application-allowance-backfill@example.com",
            grade="P4",
        )
        db.add(employee)
        db.flush()
        application_claim = ExpenseClaim(
            claim_no="AP-202606-004",
            employee_id=employee.id,
            employee_name=employee.name,
            department_name="技术部",
            project_code=None,
            expense_type="travel_application",
            reason="支撑国网仿生产环境部署",
            location="上海",
            amount=Decimal("3000.00"),
            currency="CNY",
            invoice_count=0,
            occurred_at=datetime(2026, 2, 20, tzinfo=UTC),
            status="approved",
            approval_stage="审批完成",
            risk_flags_json=[
                {
                    "source": "application_detail",
                    "application_detail": {
                        "time": "2026-02-20 至 2026-02-23",
                        "days": "4 天",
                        "location": "上海",
                        "reason": "支撑国网仿生产环境部署",
                        "transport_mode": "火车",
                    },
                }
            ],
        )
        db.add(application_claim)
        db.flush()

        claim = build_claim(expense_type="travel", location="上海")
        claim.employee_id = employee.id
        claim.employee_name = employee.name
        claim.amount = Decimal("354.00")
        claim.items[0].item_date = date(2026, 2, 20)
        claim.items[0].item_type = "train_ticket"
        claim.items[0].item_reason = "武汉-上海"
        claim.items[0].item_location = "上海"
        claim.items[0].item_amount = Decimal("354.00")
        claim.risk_flags_json = [
            {
                "source": "application_link",
                "application_claim_no": "AP-202606-004",
            }
        ]
        db.add(claim)
        db.commit()

        service = ExpenseClaimService(db)
        service._sync_claim_from_items(claim)
        db.commit()
        db.refresh(claim)

        allowance_item = next(item for item in claim.items if item.item_type == "travel_allowance")
        assert "4天" in allowance_item.item_reason
        assert allowance_item.item_date == date(2026, 2, 23)


def test_unsaved_conversation_expires_after_retention_but_saved_conversation_stays() -> None:
    with build_session() as db:
        service = AgentConversationService(db)
        unsaved = service.get_or_create_conversation(
            conversation_id="conv-unsaved-expire",
            user_id="expire@example.com",
            source="user_message",
            context_json={"tenant_id": "default", "session_type": "expense"},
        )
        saved = service.get_or_create_conversation(
            conversation_id="conv-saved-keep",
            user_id="expire@example.com",
            source="user_message",
            context_json={
                "tenant_id": "default",
                "session_type": "expense",
                "draft_claim_id": "claim-saved",
            },
        )
        old_time = datetime.now(UTC) - timedelta(days=4)
        unsaved.updated_at = old_time
        saved.updated_at = old_time
        db.add_all([unsaved, saved])
        db.commit()

        deleted_count = service.prune_expired_conversations(retention_days=3)

        assert deleted_count == 1
        assert service.get_conversation("conv-unsaved-expire") is None
        assert service.get_conversation("conv-saved-keep") is not None


def test_resolve_expense_type_maps_office_supplies_review_value_to_office() -> None:
    expense_type = ExpenseClaimService._resolve_expense_type(
        [],
        context_json={"review_form_values": {"expense_type": "办公用品"}},
    )

    assert expense_type == "office"


def test_resolve_expense_type_maps_riding_fare_review_value_to_transport() -> None:
    expense_type = ExpenseClaimService._resolve_expense_type(
        [],
        context_json={"review_form_values": {"expense_type": "乘车费用"}},
    )

    assert expense_type == "transport"


def test_upsert_draft_from_ontology_defers_multi_document_association_choice() -> None:
    user_id = "zhangsan@example.com"

    with build_session() as db:
        employee = Employee(
            employee_no="E5001",
            name="张三",
            email=user_id,
        )
        db.add(employee)
        db.flush()
        existing_claim = ExpenseClaim(
            claim_no="EXP-202605-010",
            employee_id=employee.id,
            employee_name="张三",
            department_name="市场部",
            project_code=None,
            expense_type="transport",
            reason="原有交通报销",
            location="深圳",
            amount=Decimal("20.00"),
            currency="CNY",
            invoice_count=1,
            occurred_at=datetime(2026, 5, 13, tzinfo=UTC),
            status="draft",
            approval_stage="待提交",
            risk_flags_json=[],
        )
        existing_claim.items = [
            ExpenseClaimItem(
                claim_id=existing_claim.id,
                item_date=date(2026, 5, 13),
                item_type="transport",
                item_reason="原有交通报销",
                item_location="深圳",
                item_amount=Decimal("20.00"),
                invoice_id="old-trip.png",
            )
        ]
        db.add(existing_claim)
        db.commit()

        ontology = SemanticOntologyService(db).parse(
            OntologyParseRequest(
                query="我上传了两张交通票据，帮我生成报销草稿",
                user_id=user_id,
            )
        )
        service = ExpenseClaimService(db)
        result = service.upsert_draft_from_ontology(
            run_id=ontology.run_id,
            user_id=user_id,
            message="我上传了两张交通票据，帮我生成报销草稿",
            ontology=ontology,
            context_json={
                "tenant_id": "default",
                "name": "张三",
                "attachment_names": ["didi-trip.png", "parking-ticket.jpg"],
                "attachment_count": 2,
                "draft_claim_id": existing_claim.id,
                "ocr_documents": [
                    {
                        "filename": "didi-trip.png",
                        "summary": "滴滴出行 支付金额 32 元",
                        "text": "滴滴出行 支付金额 32 元",
                    },
                    {
                        "filename": "parking-ticket.jpg",
                        "summary": "停车费 合计 18 元",
                        "text": "停车费 合计 18 元",
                    },
                ],
            },
        )

        db.refresh(existing_claim)
        assert result["pending_association_decision"] is True
        assert result["association_candidate_claim_id"] == existing_claim.id
        assert existing_claim.invoice_count == 1
        assert len(existing_claim.items) == 1
        assert existing_claim.items[0].invoice_id == "old-trip.png"


def test_linked_document_supplement_keeps_existing_claim_expense_type() -> None:
    user_id = "type-lock@example.com"

    with build_session() as db:
        employee = Employee(
            employee_no="E5010",
            name="类型锁定员工",
            email=user_id,
        )
        db.add(employee)
        db.flush()
        existing_claim = ExpenseClaim(
            claim_no="EXP-202605-020",
            employee_id=employee.id,
            employee_name="类型锁定员工",
            department_name="市场部",
            project_code=None,
            expense_type="transport",
            reason="原有交通报销",
            location="深圳",
            amount=Decimal("32.00"),
            currency="CNY",
            invoice_count=1,
            occurred_at=datetime(2026, 5, 13, tzinfo=UTC),
            status="draft",
            approval_stage="待提交",
            risk_flags_json=[],
        )
        existing_claim.items = [
            ExpenseClaimItem(
                claim_id=existing_claim.id,
                item_date=date(2026, 5, 13),
                item_type="transport",
                item_reason="原有交通报销",
                item_location="深圳",
                item_amount=Decimal("32.00"),
                invoice_id="old-trip.png",
            )
        ]
        db.add(existing_claim)
        db.commit()

        context_json = {
            "tenant_id": "default",
            "name": "类型锁定员工",
            "review_action": "link_to_existing_draft",
            "draft_claim_id": existing_claim.id,
            "attachment_names": ["hotel-invoice.pdf"],
            "attachment_count": 1,
            "ocr_documents": [
                {
                    "filename": "hotel-invoice.pdf",
                    "document_type": "hotel_invoice",
                    "scene_code": "hotel",
                    "scene_label": "住宿票据",
                    "summary": "酒店住宿 发票金额 300 元",
                    "text": "酒店住宿 发票金额 ￥300.00",
                    "document_fields": [
                        {"key": "amount", "label": "金额", "value": "300"},
                        {"key": "merchant", "label": "酒店名称", "value": "上海酒店"},
                    ],
                }
            ],
        }
        ontology = SemanticOntologyService(db).parse(
            OntologyParseRequest(
                query="把酒店发票补充到现有草稿",
                user_id=user_id,
                context_json=context_json,
            )
        )
        ExpenseClaimService(db).upsert_draft_from_ontology(
            run_id=ontology.run_id,
            user_id=user_id,
            message="把酒店发票补充到现有草稿",
            ontology=ontology,
            context_json=context_json,
        )

        db.refresh(existing_claim)
        assert existing_claim.expense_type == "transport"
        assert any(item.item_type == "hotel_ticket" for item in existing_claim.items)


def test_upsert_draft_from_ontology_keeps_reason_missing_for_attachment_only_upload() -> None:
    user_id = "wangwu@example.com"

    with build_session() as db:
        employee = Employee(
            employee_no="E5003",
            name="王五",
            email=user_id,
        )
        db.add(employee)
        db.commit()

        ontology = SemanticOntologyService(db).parse(
            OntologyParseRequest(
                query="我上传了 1 份票据，请结合附件名称给出报销建议并尽量生成草稿。",
                user_id=user_id,
            )
        )
        service = ExpenseClaimService(db)
        result = service.upsert_draft_from_ontology(
            run_id=ontology.run_id,
            user_id=user_id,
            message=(
                "我上传了 1 份票据，请结合附件名称给出报销建议并尽量生成草稿。\n"
                "附件名称：didi-trip.png"
            ),
            ontology=ontology,
            context_json={
                "tenant_id": "default",
                "name": "王五",
                "user_input_text": "",
                "attachment_names": ["didi-trip.png"],
                "attachment_count": 1,
                "ocr_documents": [
                    {
                        "filename": "didi-trip.png",
                        "summary": "滴滴出行 支付金额 32 元",
                        "text": "滴滴出行 支付金额 32 元",
                        "document_type": "taxi_receipt",
                        "scene_code": "transport",
                    }
                ],
            },
        )

        claim = db.get(ExpenseClaim, result["claim_id"])
        assert claim is not None
        assert claim.reason == "待补充"


def test_upsert_draft_from_ontology_strips_recognized_business_time_from_reason() -> None:
    user_id = "transport-time@example.com"
    message = "业务发生时间:2026-03-04，送客户去林萃小区办事，请报销乘车费用"

    with build_session() as db:
        employee = Employee(
            employee_no="E5004",
            name="赵六",
            email=user_id,
        )
        db.add(employee)
        db.commit()

        ontology = SemanticOntologyService(db).parse(
            OntologyParseRequest(
                query=message,
                user_id=user_id,
            )
        )
        result = ExpenseClaimService(db).upsert_draft_from_ontology(
            run_id=ontology.run_id,
            user_id=user_id,
            message=message,
            ontology=ontology,
            context_json={
                "tenant_id": "default",
                "name": "赵六",
                "user_input_text": message,
            },
        )

        claim = db.get(ExpenseClaim, result["claim_id"])
        assert claim is not None
        assert claim.occurred_at.date() == date(2026, 3, 4)
        assert claim.reason == "送客户去林萃小区办事，请报销乘车费用"
        assert len(claim.items) == 1
        assert claim.items[0].item_date == date(2026, 3, 4)
        assert claim.items[0].item_reason == "送客户去林萃小区办事，请报销乘车费用"
        assert "客户单位" not in result["message"]
        assert "票据附件" not in result["message"]
        assert "费用明细" not in result["message"]


def test_upsert_draft_from_ontology_supports_link_or_create_for_multi_documents() -> None:
    user_id = "lisi@example.com"

    with build_session() as db:
        employee = Employee(
            employee_no="E5002",
            name="李四",
            email=user_id,
        )
        db.add(employee)
        db.flush()
        existing_claim = ExpenseClaim(
            claim_no="EXP-202605-011",
            employee_id=employee.id,
            employee_name="李四",
            department_name="销售部",
            project_code=None,
            expense_type="transport",
            reason="原有交通报销",
            location="上海",
            amount=Decimal("20.00"),
            currency="CNY",
            invoice_count=1,
            occurred_at=datetime(2026, 5, 13, tzinfo=UTC),
            status="draft",
            approval_stage="待提交",
            risk_flags_json=[],
        )
        existing_claim.items = [
            ExpenseClaimItem(
                claim_id=existing_claim.id,
                item_date=date(2026, 5, 13),
                item_type="transport",
                item_reason="原有交通报销",
                item_location="上海",
                item_amount=Decimal("20.00"),
                invoice_id="existing.png",
            )
        ]
        db.add(existing_claim)
        db.commit()

        ontology = SemanticOntologyService(db).parse(
            OntologyParseRequest(
                query="我上传了两张交通票据，帮我生成报销草稿",
                user_id=user_id,
            )
        )
        service = ExpenseClaimService(db)
        context_json = {
            "tenant_id": "default",
            "name": "李四",
            "attachment_names": ["didi-trip.png", "parking-ticket.jpg"],
            "attachment_count": 2,
            "draft_claim_id": existing_claim.id,
            "ocr_documents": [
                {
                    "filename": "didi-trip.png",
                    "summary": "滴滴出行",
                    "text": "滴滴出行 支付金额 32.50 元",
                    "document_type": "taxi_receipt",
                    "scene_code": "transport",
                    "document_fields": [{"key": "amount", "label": "支付金额", "value": "32.50"}],
                },
                {
                    "filename": "parking-ticket.jpg",
                    "summary": "停车票",
                    "text": "停车费 合计 18 元",
                    "document_type": "parking_toll_receipt",
                    "scene_code": "transport",
                    "document_fields": [
                        {"key": "total_amount", "label": "合计金额", "value": "18"}
                    ],
                },
            ],
        }

        link_result = service.upsert_draft_from_ontology(
            run_id=ontology.run_id,
            user_id=user_id,
            message="把这两张票据关联到已有草稿",
            ontology=ontology,
            context_json={
                "tenant_id": "default",
                **context_json,
                "review_action": "link_to_existing_draft",
            },
        )

        db.refresh(existing_claim)
        assert link_result["claim_id"] == existing_claim.id
        assert existing_claim.invoice_count == 3
        assert len(existing_claim.items) == 3
        assert float(existing_claim.amount) == 70.5

        create_result = service.upsert_draft_from_ontology(
            run_id=f"{ontology.run_id}-new",
            user_id=user_id,
            message="单独新建一张报销单",
            ontology=ontology,
            context_json={
                "tenant_id": "default",
                **context_json,
                "review_action": "create_new_claim_from_documents",
            },
        )

        assert create_result["claim_id"] != existing_claim.id
        new_claim = db.get(ExpenseClaim, create_result["claim_id"])
        assert new_claim is not None
        assert new_claim.invoice_count == 2
        assert len(new_claim.items) == 2
        assert float(new_claim.amount) == 50.5


def test_link_existing_draft_blocks_duplicate_uploaded_invoice() -> None:
    user_id = "duplicate@example.com"

    with build_session() as db:
        employee = Employee(
            employee_no="E5010",
            name="重复票据员工",
            email=user_id,
        )
        db.add(employee)
        db.flush()
        existing_claim = ExpenseClaim(
            claim_no="EXP-202605-021",
            employee_id=employee.id,
            employee_name="重复票据员工",
            department_name="销售部",
            project_code=None,
            expense_type="transport",
            reason="原有交通报销",
            location="上海",
            amount=Decimal("32.50"),
            currency="CNY",
            invoice_count=1,
            occurred_at=datetime(2026, 5, 13, tzinfo=UTC),
            status="draft",
            approval_stage="待提交",
            risk_flags_json=[],
        )
        existing_claim.items = [
            ExpenseClaimItem(
                claim_id=existing_claim.id,
                item_date=date(2026, 5, 13),
                item_type="transport",
                item_reason="原有交通报销",
                item_location="上海",
                item_amount=Decimal("32.50"),
                invoice_id="didi-trip.png",
            )
        ]
        db.add(existing_claim)
        db.commit()

        context_json = {
            "tenant_id": "default",
            "name": "重复票据员工",
            "review_action": "link_to_existing_draft",
            "draft_claim_id": existing_claim.id,
            "attachment_names": ["didi-trip.png"],
            "attachment_count": 1,
            "ocr_documents": [
                {
                    "filename": "didi-trip.png",
                    "summary": "滴滴出行 支付金额 32.50 元",
                    "text": "滴滴出行 支付金额 32.50 元",
                    "document_type": "taxi_receipt",
                    "scene_code": "transport",
                    "document_fields": [{"key": "amount", "label": "支付金额", "value": "32.50"}],
                }
            ],
        }
        ontology = SemanticOntologyService(db).parse(
            OntologyParseRequest(
                query="把这张票据关联到已有草稿",
                user_id=user_id,
                context_json=context_json,
            )
        )

        result = ExpenseClaimService(db).upsert_draft_from_ontology(
            run_id=ontology.run_id,
            user_id=user_id,
            message="把这张票据关联到已有草稿",
            ontology=ontology,
            context_json=context_json,
        )

        db.refresh(existing_claim)
        assert result["duplicate_attachment_blocked"] is True
        assert result["submission_blocked"] is True
        assert "重复" in result["message"]
        assert "重新上传不同的票据" in result["message"]
        assert len(existing_claim.items) == 1
        assert existing_claim.invoice_count == 1
        assert float(existing_claim.amount) == 32.5


def test_upsert_travel_draft_uses_ticket_item_types_and_auto_allowance() -> None:
    user_id = "travel-allowance@example.com"

    with build_session() as db:
        employee = Employee(
            employee_no="E5010",
            name="差旅员工",
            email=user_id,
            grade="P4",
        )
        db.add(employee)
        db.commit()

        ontology = SemanticOntologyService(db).parse(
            OntologyParseRequest(
                query="我去北京出差 3 天，上传了火车票，帮我生成差旅费报销草稿",
                user_id=user_id,
            )
        )
        result = ExpenseClaimService(db).upsert_draft_from_ontology(
            run_id=ontology.run_id,
            user_id=user_id,
            message="我去北京出差 3 天，上传了火车票，帮我生成差旅费报销草稿",
            ontology=ontology,
            context_json={
                "tenant_id": "default",
                "name": "差旅员工",
                "grade": "P4",
                "attachment_names": ["train-ticket.png"],
                "attachment_count": 1,
                "review_form_values": {
                    "expense_type": "差旅费",
                    "location": "北京",
                    "time_range": "2026-05-13 至 2026-05-15",
                },
                "business_time_context": {
                    "mode": "range",
                    "start_date": "2026-05-13",
                    "end_date": "2026-05-15",
                    "display_value": "2026-05-13 至 2026-05-15",
                },
                "ocr_documents": [
                    {
                        "filename": "train-ticket.png",
                        "document_type": "train_ticket",
                        "document_type_label": "火车/高铁票",
                        "scene_code": "travel",
                        "scene_label": "差旅费",
                        "summary": "中国铁路电子客票 广州南-北京南 二等座 票价 354 元",
                        "text": "中国铁路电子客票 广州南-北京南 二等座 票价:￥354.00",
                        "document_fields": [
                            {"key": "amount", "label": "票价", "value": "￥354.00"},
                            {"key": "route", "label": "行程", "value": "广州南-北京南"},
                        ],
                    }
                ],
            },
        )

        claim = db.get(ExpenseClaim, result["claim_id"])
        assert claim is not None
        assert claim.expense_type == "travel"
        assert claim.invoice_count == 1
        assert len(claim.items) == 2
        train_item = next(item for item in claim.items if item.item_type == "train_ticket")
        allowance_item = next(item for item in claim.items if item.item_type == "travel_allowance")
        assert train_item.item_amount == Decimal("354.00")
        assert train_item.item_reason == "广州南-北京南"
        assert allowance_item.item_amount == Decimal("300.00")
        assert allowance_item.invoice_id is None
        assert allowance_item.is_system_generated is True
        assert claim.amount == Decimal("654.00")

        with pytest.raises(ValueError, match="系统自动计算"):
            ExpenseClaimService(db).update_claim_item(
                claim_id=claim.id,
                item_id=allowance_item.id,
                payload=ExpenseClaimItemUpdate(item_amount=Decimal("1.00")),
                current_user=CurrentUserContext(
                    tenant_id="default",
                    username=user_id,
                    name="差旅员工",
                    role_codes=[],
                    is_admin=False,
                ),
            )


def test_upsert_travel_draft_uses_explicit_text_days_for_allowance() -> None:
    user_id = "travel-explicit-days@example.com"
    message = (
        "业务发生时间:2026-05-20 至 2026-05-23，"
        "去上海支撑上海电力服务器部署，出差3天，申请差旅费报销"
    )

    with build_session() as db:
        employee = Employee(
            employee_no="E5012",
            name="文本差旅员工",
            email=user_id,
            grade="P4",
        )
        db.add(employee)
        db.commit()

        ontology = SemanticOntologyService(db).parse(
            OntologyParseRequest(
                query=message,
                user_id=user_id,
                context_json={"name": "文本差旅员工", "grade": "P4"},
            )
        )
        result = ExpenseClaimService(db).upsert_draft_from_ontology(
            run_id=ontology.run_id,
            user_id=user_id,
            message=message,
            ontology=ontology,
            context_json={
                "tenant_id": "default",
                "name": "文本差旅员工",
                "grade": "P4",
                "user_input_text": message,
                "review_form_values": {
                    "expense_type": "差旅费",
                    "business_location": "上海",
                    "reason": "去上海支撑上海电力服务器部署，出差3天",
                    "time_range": "2026-05-20 至 2026-05-23",
                    "business_time": "2026-05-20 至 2026-05-23",
                },
                "business_time_context": {
                    "mode": "range",
                    "start_date": "2026-05-20",
                    "end_date": "2026-05-23",
                    "display_value": "2026-05-20 至 2026-05-23",
                },
            },
        )

        claim = db.get(ExpenseClaim, result["claim_id"])
        assert claim is not None
        assert claim.expense_type == "travel"
        allowance_item = next(item for item in claim.items if item.item_type == "travel_allowance")
        assert allowance_item.item_amount == Decimal("300.00")
        assert "3天" in allowance_item.item_reason
        assert allowance_item.item_date == date(2026, 5, 22)
        assert claim.amount == Decimal("300.00")


def test_sync_travel_claim_adds_allowance_from_manual_ticket_dates() -> None:
    with build_session() as db:
        employee = Employee(
            employee_no="E5011",
            name="手工差旅员工",
            email="manual-travel-allowance@example.com",
            grade="P4",
        )
        db.add(employee)
        db.flush()

        claim = build_claim(expense_type="travel", location="北京")
        claim.employee_id = employee.id
        claim.employee_name = employee.name
        claim.items[0].item_date = date(2026, 5, 13)
        claim.items[0].item_type = "train_ticket"
        claim.items[0].item_reason = "广州南-北京南"
        claim.items[0].item_location = "北京"
        claim.items[0].item_amount = Decimal("354.00")
        claim.items.append(
            ExpenseClaimItem(
                claim_id=claim.id,
                item_date=date(2026, 5, 15),
                item_type="train_ticket",
                item_reason="北京南-广州南",
                item_location="北京",
                item_amount=Decimal("354.00"),
                invoice_id="return-train.png",
            )
        )
        db.add(claim)
        db.commit()

        service = ExpenseClaimService(db)
        service._sync_claim_from_items(claim)
        db.commit()
        db.refresh(claim)

        allowance_item = next(item for item in claim.items if item.item_type == "travel_allowance")
        assert allowance_item.item_amount == Decimal("300.00")
        assert "3天" in allowance_item.item_reason
        assert allowance_item.invoice_id is None
        assert claim.amount == Decimal("1008.00")


def test_update_claim_item_allows_placeholder_date_reason_and_amount() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="emp-1",
        name="张三",
        role_codes=[],
        is_admin=False,
    )

    with build_session() as db:
        claim = build_claim(expense_type="office", location="深圳")
        db.add(claim)
        db.commit()

        updated = ExpenseClaimService(db).update_claim_item(
            claim_id=claim.id,
            item_id=claim.items[0].id,
            payload=ExpenseClaimItemUpdate(
                item_reason="",
                item_location="",
                item_note="票据行程存在改签，已核对业务真实发生。",
                item_amount=Decimal("0.00"),
            ),
            current_user=current_user,
        )

        assert updated is not None
        db.refresh(claim)
        assert claim.items[0].item_date == date(2026, 5, 13)
        assert claim.items[0].item_reason == ""
        assert claim.items[0].item_location == ""
        assert claim.items[0].item_note == "票据行程存在改签，已核对业务真实发生。"
        assert claim.items[0].item_amount == Decimal("0.00")


def test_upsert_draft_from_ontology_updates_returned_claim_and_preserves_return_events() -> None:
    user_id = "returned-owner@example.com"
    return_flag = {
        "source": "manual_return",
        "return_event_id": "return-event-1",
        "message": "第一次退回：附件缺失。",
        "reason": "附件缺失。",
        "return_count": 1,
        "return_stage": "直属领导审批",
        "return_stage_key": "direct_manager",
        "risk_points": ["附件缺失或不清晰"],
    }

    with build_session() as db:
        employee = Employee(
            employee_no="E5004",
            name="赵六",
            email=user_id,
        )
        db.add(employee)
        db.flush()
        existing_claim = ExpenseClaim(
            claim_no="EXP-202605-012",
            employee_id=employee.id,
            employee_name="赵六",
            department_name="市场部",
            project_code=None,
            expense_type="transport",
            reason="原有交通报销",
            location="上海",
            amount=Decimal("20.00"),
            currency="CNY",
            invoice_count=1,
            occurred_at=datetime(2026, 5, 13, tzinfo=UTC),
            status="returned",
            approval_stage="待提交",
            risk_flags_json=[return_flag],
        )
        existing_claim.items = [
            ExpenseClaimItem(
                claim_id=existing_claim.id,
                item_date=date(2026, 5, 13),
                item_type="transport",
                item_reason="原有交通报销",
                item_location="上海",
                item_amount=Decimal("20.00"),
                invoice_id="old-trip.png",
            )
        ]
        db.add(existing_claim)
        db.commit()

        ontology = SemanticOntologyService(db).parse(
            OntologyParseRequest(
                query="我补充了交通票据，更新这张退回单据",
                user_id=user_id,
            )
        )
        ontology.risk_flags = ["系统识别：票据金额待人工核对。"]

        result = ExpenseClaimService(db).upsert_draft_from_ontology(
            run_id=ontology.run_id,
            user_id=user_id,
            message="我补充了交通票据，更新这张退回单据",
            ontology=ontology,
            context_json={
                "tenant_id": "default",
                "name": "赵六",
                "draft_claim_id": existing_claim.id,
                "attachment_names": ["new-trip.png"],
                "attachment_count": 1,
                "ocr_documents": [
                    {
                        "filename": "new-trip.png",
                        "summary": "滴滴出行 支付金额 32 元",
                        "text": "滴滴出行 支付金额 32 元",
                        "document_type": "taxi_receipt",
                        "scene_code": "transport",
                    }
                ],
            },
        )

        db.refresh(existing_claim)
        assert result["claim_id"] == existing_claim.id
        assert existing_claim.status == "draft"
        assert "系统识别：票据金额待人工核对。" in existing_claim.risk_flags_json
        manual_returns = [
            flag
            for flag in list(existing_claim.risk_flags_json or [])
            if isinstance(flag, dict) and flag.get("source") == "manual_return"
        ]
        assert manual_returns == [return_flag]


def test_generate_claim_no_uses_short_reimbursement_prefix_and_random_suffix() -> None:
    with build_session() as db:
        db.add_all(
            [
                ExpenseClaim(
                    claim_no="EXP-202605-001",
                    employee_name="张三",
                    department_name="市场部",
                    project_code=None,
                    expense_type="transport",
                    reason="交通报销",
                    location="深圳",
                    amount=Decimal("10.00"),
                    currency="CNY",
                    invoice_count=1,
                    occurred_at=datetime(2026, 5, 10, tzinfo=UTC),
                    status="draft",
                    approval_stage="待提交",
                    risk_flags_json=[],
                ),
                ExpenseClaim(
                    claim_no="EXP-202605-003",
                    employee_name="李四",
                    department_name="销售部",
                    project_code=None,
                    expense_type="transport",
                    reason="交通报销",
                    location="上海",
                    amount=Decimal("20.00"),
                    currency="CNY",
                    invoice_count=1,
                    occurred_at=datetime(2026, 5, 11, tzinfo=UTC),
                    status="submitted",
                    approval_stage="审批中",
                    risk_flags_json=[],
                ),
            ]
        )
        db.commit()

        service = ExpenseClaimService(db)

        assert re.fullmatch(
            r"R[A-HJ-NP-Z2-9]{8}",
            service._generate_claim_no(datetime(2026, 5, 14, tzinfo=UTC)),
        )


def test_upsert_draft_from_ontology_retries_claim_no_conflict() -> None:
    user_id = "zhaoliu-claimno@example.com"

    with build_session() as db:
        employee = Employee(
            employee_no="E5006",
            name="赵六",
            email=user_id,
        )
        db.add(employee)
        db.flush()
        db.add(
            ExpenseClaim(
                claim_no="RABCDEFGH",
                employee_name="历史单据",
                department_name="财务部",
                project_code=None,
                expense_type="other",
                reason="历史草稿",
                location="北京",
                amount=Decimal("0.00"),
                currency="CNY",
                invoice_count=0,
                occurred_at=datetime(2026, 5, 12, tzinfo=UTC),
                status="submitted",
                approval_stage="审批中",
                risk_flags_json=[],
            )
        )
        db.commit()

        ontology = SemanticOntologyService(db).parse(
            OntologyParseRequest(
                query="帮我生成报销草稿，我昨天交通费 13.4 元",
                user_id=user_id,
            )
        )
        service = ExpenseClaimService(db)
        generated_claim_nos = iter(["RABCDEFGH", "RHGFEDCBA"])
        service._generate_claim_no = lambda occurred_at: next(generated_claim_nos)

        result = service.upsert_draft_from_ontology(
            run_id=ontology.run_id,
            user_id=user_id,
            message="帮我生成报销草稿，我昨天交通费 13.4 元",
            ontology=ontology,
            context_json={
                "tenant_id": "default",
                "name": "赵六",
                "user_input_text": "帮我生成报销草稿，我昨天交通费 13.4 元",
            },
        )

        created_claim = db.get(ExpenseClaim, result["claim_id"])
        assert created_claim is not None
        assert created_claim.claim_no == "RHGFEDCBA"
        assert result["claim_no"] == "RHGFEDCBA"


def test_create_claim_item_adds_blank_draft_row_without_forcing_attachment() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="emp-1",
        name="张三",
        role_codes=[],
        is_admin=False,
    )

    with build_session() as db:
        claim = build_claim(expense_type="office", location="深圳南山")
        db.add(claim)
        db.commit()

        service = ExpenseClaimService(db)
        updated = service.create_claim_item(
            claim_id=claim.id,
            payload=ExpenseClaimItemCreate(item_note="待上传异常票据说明"),
            current_user=current_user,
        )

        assert updated is not None
        assert len(updated.items) == 2
        assert updated.amount == Decimal("88.00")
        assert updated.invoice_count == 1

        new_item = next(item for item in updated.items if item.id != "item-1")
        assert new_item.item_type == "office"
        assert new_item.item_reason == ""
        assert new_item.item_location == ""
        assert new_item.item_note == "待上传异常票据说明"
        assert new_item.item_amount == Decimal("0.00")
        assert new_item.invoice_id is None


def test_update_claim_reason_only_allows_draft_pending_submission() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="emp-1",
        name="张三",
        role_codes=[],
        is_admin=False,
    )

    with build_session() as db:
        claim = build_claim(expense_type="travel", location="北京")
        db.add(claim)
        db.commit()

        service = ExpenseClaimService(db)
        updated = service.update_claim(
            claim_id=claim.id,
            payload=ExpenseClaimUpdate(reason="去北京客户现场出差，处理项目验收事项"),
            current_user=current_user,
        )

        assert updated is not None
        assert updated.reason == "去北京客户现场出差，处理项目验收事项"

        claim.status = "submitted"
        claim.submitted_at = datetime(2026, 5, 14, tzinfo=UTC)
        claim.approval_stage = "直属领导审批"
        db.commit()

        with pytest.raises(ValueError, match="草稿待提交"):
            service.update_claim(
                claim_id=claim.id,
                payload=ExpenseClaimUpdate(reason="提交后不能改"),
                current_user=current_user,
            )


def test_update_claim_item_reanalyzes_existing_attachment(monkeypatch, tmp_path) -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="emp-1",
        name="张三",
        role_codes=[],
        is_admin=False,
    )

    def fake_recognize(
        self,
        files: list[tuple[str, bytes, str | None]],
    ) -> OcrRecognizeBatchRead:
        return OcrRecognizeBatchRead(
            total_file_count=1,
            success_count=1,
            documents=[
                OcrRecognizeDocumentRead(
                    filename="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)

    with build_session() as db:
        claim = build_claim(expense_type="office", location="深圳南山")
        claim.invoice_count = 0
        claim.items[0].invoice_id = None
        claim.items[0].item_reason = "办公用品采购"
        db.add(claim)
        db.commit()

        service = ExpenseClaimService(db)
        service.upload_claim_item_attachment(
            claim_id=claim.id,
            item_id=claim.items[0].id,
            filename="office-note.png",
            content=b"fake-image-bytes",
            media_type="image/png",
            current_user=current_user,
        )

        uploaded_meta = service.get_claim_item_attachment_meta(
            claim_id=claim.id,
            item_id=claim.items[0].id,
            current_user=current_user,
        )
        assert uploaded_meta is not None
        assert uploaded_meta["preview_kind"] == "image"
        assert uploaded_meta["preview_url"].endswith(
            f"/reimbursements/claims/{claim.id}/items/{claim.items[0].id}/attachment/preview"
        )
        assert uploaded_meta["analysis"]["severity"] == "pass"
        assert uploaded_meta["document_info"]["document_type"] == "office_invoice"
        assert uploaded_meta["requirement_check"]["matches"] is True

        updated = service.update_claim_item(
            claim_id=claim.id,
            item_id=claim.items[0].id,
            payload=ExpenseClaimItemUpdate(
                item_type="transport",
                item_reason="打车报销",
            ),
            current_user=current_user,
        )

        assert updated is not None
        assert any(flag.get("source") == "attachment_analysis" for flag in updated.risk_flags_json)

        refreshed_meta = service.get_claim_item_attachment_meta(
            claim_id=claim.id,
            item_id=claim.items[0].id,
            current_user=current_user,
        )
        assert refreshed_meta is not None
        assert refreshed_meta["analysis"]["severity"] == "high"
        assert refreshed_meta["requirement_check"]["matches"] is False
        assert any("附件类型要求" in point for point in refreshed_meta["analysis"]["points"])


def test_upload_attachment_refreshes_claim_pre_review(monkeypatch, tmp_path) -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="emp-1",
        name="submitter",
        role_codes=[],
        is_admin=False,
    )
    review_calls: list[str] = []

    def fake_recognize(
        self,
        files: list[tuple[str, bytes, str | None]],
    ) -> OcrRecognizeBatchRead:
        return OcrRecognizeBatchRead(
            total_file_count=1,
            success_count=1,
            documents=[
                OcrRecognizeDocumentRead(
                    filename="receipt.png",
                    media_type="image/png",
                    text="office receipt amount 88 2026-05-13",
                    summary="recognized office receipt",
                    avg_score=0.98,
                    line_count=1,
                    page_count=1,
                    warnings=[],
                )
            ],
        )

    def fake_review(self, reviewed_claim):
        review_calls.append(reviewed_claim.id)
        return {
            "risk_flags": [
                *list(reviewed_claim.risk_flags_json or []),
                {
                    "source": "submission_review",
                    "severity": "high",
                    "label": "upload-time-risk",
                    "message": "risk generated after attachment upload",
                },
            ]
        }

    monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
    monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
    monkeypatch.setattr(ExpenseClaimService, "_run_ai_submission_review", fake_review)

    with build_session() as db:
        claim = build_claim(expense_type="office", location="Shanghai")
        claim.invoice_count = 0
        claim.items[0].invoice_id = None
        db.add(claim)
        db.commit()

        payload = ExpenseClaimService(db).upload_claim_item_attachment(
            claim_id=claim.id,
            item_id=claim.items[0].id,
            filename="receipt.png",
            content=b"fake-image-bytes",
            media_type="image/png",
            current_user=current_user,
        )

        flags = payload["claim_risk_flags"]
        assert review_calls == [claim.id]
        assert any(flag.get("label") == "upload-time-risk" for flag in flags)
        pre_review = next(flag for flag in flags if flag.get("source") == "ai_pre_review")
        assert pre_review["status"] == "failed"
        assert pre_review["blocking_risk_count"] >= 1


def test_upload_train_ticket_attachment_backfills_item_amount(monkeypatch, tmp_path) -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="emp-1",
        name="张三",
        role_codes=[],
        is_admin=False,
    )

    def fake_recognize(
        self,
        files: list[tuple[str, bytes, str | None]],
    ) -> OcrRecognizeBatchRead:
        return OcrRecognizeBatchRead(
            total_file_count=1,
            success_count=1,
            documents=[
                OcrRecognizeDocumentRead(
                    filename="train-ticket.png",
                    media_type="image/png",
                    text="中国铁路电子客票 广州南-北京南 二等座 2026-02-20 08:30开 票价:￥354.00",
                    summary="铁路电子客票，2026-02-20 08:30 广州南至北京南，票价 354 元。",
                    avg_score=0.98,
                    line_count=1,
                    page_count=1,
                    document_type="train_ticket",
                    document_type_label="火车/高铁票",
                    scene_code="travel",
                    scene_label="差旅费",
                    document_fields=[
                        {"key": "invoice_date", "label": "开票日期", "value": "2026-02-18"},
                        {"key": "trip_date", "label": "行程日期", "value": "2026-02-20 08:30"},
                        {"key": "fare", "label": "票价", "value": "￥354.00"},
                    ],
                )
            ],
        )

    monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
    monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)

    with build_session() as db:
        claim = build_claim(expense_type="travel", location="北京")
        claim.amount = Decimal("0.00")
        claim.invoice_count = 0
        claim.items[0].item_amount = Decimal("0.00")
        claim.items[0].invoice_id = None
        db.add(claim)
        db.commit()

        service = ExpenseClaimService(db)
        updated = service.upload_claim_item_attachment(
            claim_id=claim.id,
            item_id=claim.items[0].id,
            filename="train-ticket.png",
            content=b"fake-image-bytes",
            media_type="image/png",
            current_user=current_user,
        )

        assert updated is not None
        assert updated["item_amount"] == Decimal("354.00")
        assert updated["item_date"] == "2026-02-20"
        assert updated["item_type"] == "train_ticket"
        assert updated["item_reason"] == "广州南-北京南"
        assert updated["claim_amount"] == Decimal("354.00")
        db.refresh(claim)
        assert claim.items[0].item_amount == Decimal("354.00")
        assert claim.items[0].item_type == "train_ticket"
        assert claim.items[0].item_date == date(2026, 2, 20)
        assert claim.items[0].item_reason == "广州南-北京南"
        assert claim.amount == Decimal("354.00")
        uploaded_meta = service.get_claim_item_attachment_meta(
            claim_id=claim.id,
            item_id=claim.items[0].id,
            current_user=current_user,
        )
        assert uploaded_meta is not None
        assert uploaded_meta["document_info"]["document_type"] == "train_ticket"
        assert any(
            field["label"] == "列车出发时间" and field["value"] == "2026-02-20 08:30"
            for field in uploaded_meta["document_info"]["fields"]
        )
        assert any(
            field["label"] == "开票日期" and field["value"] == "2026-02-18"
            for field in uploaded_meta["document_info"]["fields"]
        )
        assert any(
            field["label"] == "票价" and field["value"] == "￥354.00"
            for field in uploaded_meta["document_info"]["fields"]
        )
        assert not any("用途字段" in point for point in uploaded_meta["analysis"]["points"])


def test_upload_auto_collected_attachment_uses_source_receipt_ocr_result(
    monkeypatch,
    tmp_path,
) -> None:
    monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
    get_settings.cache_clear()
    try:
        current_user = CurrentUserContext(
            tenant_id="default",
            username="auto-collect-travel@example.com",
            name="张三",
            role_codes=[],
            is_admin=False,
        )

        def fake_recognize(
            self,
            files: list[tuple[str, bytes, str | None]],
        ) -> OcrRecognizeBatchRead:
            return OcrRecognizeBatchRead(
                total_file_count=1,
                success_count=1,
                documents=[
                    OcrRecognizeDocumentRead(
                        filename="2月22 深圳-上海.pdf",
                        media_type="application/pdf",
                        text="",
                        summary="",
                        avg_score=0.0,
                        line_count=0,
                        page_count=1,
                        document_type="other",
                        document_type_label="其他单据",
                        scene_code="other",
                        scene_label="其他票据",
                    )
                ],
            )

        monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
        monkeypatch.setattr(
            ExpenseClaimAttachmentStorage,
            "root",
            lambda self: tmp_path / "attachments",
        )

        with build_session() as db:
            employee = Employee(
                employee_no="E-AUTO-COLLECT",
                name="张三",
                email=current_user.username,
                grade="P4",
            )
            db.add(employee)
            db.flush()

            claim = build_claim(expense_type="travel", location="上海")
            claim.employee = employee
            claim.employee_id = employee.id
            claim.employee_name = employee.name
            claim.amount = Decimal("0.00")
            claim.invoice_count = 0
            claim.risk_flags_json = [
                {
                    "source": "attachment_analysis",
                    "severity": "high",
                    "message": "票据类型：未识别到发票、票据、电子行程单等关键字。",
                }
            ]
            claim.items[0].item_type = "travel"
            claim.items[0].item_reason = ""
            claim.items[0].item_location = "上海"
            claim.items[0].item_amount = Decimal("0.00")
            claim.items[0].invoice_id = None
            db.add(claim)
            db.commit()

            receipt = ReceiptFolderService().save_receipt(
                filename="2月22 深圳-上海.pdf",
                content=b"%PDF-1.4 fake-train-ticket",
                media_type="application/pdf",
                current_user=current_user,
                document=OcrRecognizeDocumentRead(
                    filename="2月22 深圳-上海.pdf",
                    media_type="application/pdf",
                    text="中国铁路电子客票 深圳北-上海虹桥 2026-02-22 票价:￥388.00",
                    summary="铁路电子客票，深圳至上海，2026-02-22 出发，票价 388 元。",
                    avg_score=0.98,
                    line_count=1,
                    page_count=1,
                    document_type="train_ticket",
                    document_type_label="火车/高铁票",
                    scene_code="travel",
                    scene_label="差旅票据",
                    document_fields=[
                        {"key": "origin", "label": "出发城市", "value": "深圳"},
                        {"key": "destination", "label": "到达城市", "value": "上海"},
                        {"key": "trip_date", "label": "列车出发时间", "value": "2026-02-22"},
                        {"key": "fare", "label": "票价", "value": "￥388.00"},
                    ],
                ),
            )

            service = ExpenseClaimService(db)
            updated = service.upload_claim_item_attachment(
                claim_id=claim.id,
                item_id=claim.items[0].id,
                filename="2月22 深圳-上海.pdf",
                content=b"%PDF-1.4 fake-train-ticket",
                media_type="application/pdf",
                current_user=current_user,
                source_receipt_id=receipt.id,
            )

            assert updated is not None
            assert updated["item_type"] == "train_ticket"
            assert updated["item_amount"] == Decimal("388.00")
            assert updated["item_date"] == "2026-02-22"
            assert updated["item_reason"] == "深圳北-上海虹桥"

            uploaded_meta = service.get_claim_item_attachment_meta(
                claim_id=claim.id,
                item_id=claim.items[0].id,
                current_user=current_user,
            )
            assert uploaded_meta is not None
            assert uploaded_meta["document_info"]["document_type"] == "train_ticket"
            assert uploaded_meta["requirement_check"]["matches"] is True
            assert not any(
                "未识别到发票" in point or "当前识别为其他单据" in point
                for point in uploaded_meta["analysis"]["points"]
            )

            db.refresh(claim)
            allowance_item = next(
                item for item in claim.items if item.item_type == "travel_allowance"
            )
            assert allowance_item.item_amount > Decimal("0.00")
            assert "1天" in allowance_item.item_reason
            assert claim.amount == Decimal("388.00") + allowance_item.item_amount
            assert not any(
                isinstance(flag, dict)
                and str(flag.get("source") or "").strip() == "attachment_analysis"
                and "未识别到发票" in str(flag.get("message") or "")
                for flag in list(claim.risk_flags_json or [])
            )

            linked_receipt = ReceiptFolderService().get_receipt(receipt.id, current_user)
            assert linked_receipt.status == "linked"
            assert linked_receipt.linked_claim_id == claim.id
            assert linked_receipt.linked_claim_no == claim.claim_no
    finally:
        get_settings.cache_clear()


def test_upload_attachment_response_includes_refreshed_rule_center_risk_flags(
    monkeypatch,
    tmp_path,
) -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="emp-1",
        name="张三",
        role_codes=[],
        is_admin=False,
    )

    def fake_recognize(
        self,
        files: list[tuple[str, bytes, str | None]],
    ) -> OcrRecognizeBatchRead:
        return OcrRecognizeBatchRead(
            total_file_count=1,
            success_count=1,
            documents=[
                OcrRecognizeDocumentRead(
                    filename="train-ticket.png",
                    media_type="image/png",
                    text="中国铁路电子客票 武汉-上海 2026-02-20 票价354元",
                    summary="铁路电子客票，武汉至上海，票价 354 元。",
                    avg_score=0.98,
                    line_count=1,
                    page_count=1,
                    document_type="train_ticket",
                    document_type_label="火车/高铁票",
                    scene_code="travel",
                    scene_label="差旅费",
                    document_fields=[
                        {"key": "route", "label": "行程", "value": "武汉-上海"},
                        {"key": "trip_date", "label": "行程日期", "value": "2026-02-20"},
                        {"key": "fare", "label": "票价", "value": "354元"},
                    ],
                )
            ],
        )

    def fake_evaluate_platform_risk_rules(self, claim, **kwargs):
        assert kwargs.get("business_stage") == "reimbursement"
        return {
            "flags": [
                {
                    "source": "submission_review",
                    "hit_source": "rule_center",
                    "rule_type": "risk",
                    "rule_code": "risk.test.upload_preview",
                    "severity": "high",
                    "message": "测试规则命中",
                    "business_stage": "reimbursement",
                    "risk_domain": "invoice",
                    "visibility_scope": "submitter",
                    "actionability": "fixable_by_submitter",
                }
            ],
            "blocking_reasons": ["测试规则命中"],
        }

    monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
    monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
    monkeypatch.setattr(
        ExpenseClaimService,
        "evaluate_platform_risk_rules",
        fake_evaluate_platform_risk_rules,
    )

    with build_session() as db:
        claim = build_claim(expense_type="travel", location="北京")
        claim.items[0].invoice_id = None
        db.add(claim)
        db.commit()

        payload = ExpenseClaimService(db).upload_claim_item_attachment(
            claim_id=claim.id,
            item_id=claim.items[0].id,
            filename="train-ticket.png",
            content=b"fake-image-bytes",
            media_type="image/png",
            current_user=current_user,
        )

        assert payload is not None
        assert any(
            flag.get("rule_code") == "risk.test.upload_preview"
            for flag in payload["claim_risk_flags"]
        )
        db.refresh(claim)
        assert payload["claim_risk_flags"] == claim.risk_flags_json


def test_upload_attachment_runs_rule_center_city_risk_from_origin_destination_fields(
    monkeypatch,
    tmp_path,
) -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="emp-1",
        name="张三",
        role_codes=[],
        is_admin=False,
    )

    def fake_recognize(
        self,
        files: list[tuple[str, bytes, str | None]],
    ) -> OcrRecognizeBatchRead:
        return OcrRecognizeBatchRead(
            total_file_count=1,
            success_count=1,
            documents=[
                OcrRecognizeDocumentRead(
                    filename="train-ticket.png",
                    media_type="image/png",
                    text="铁路电子客票 出发城市 武汉 到达城市 北京 2026-02-20 票价354元",
                    summary="铁路电子客票，武汉至北京，票价 354 元。",
                    avg_score=0.98,
                    line_count=1,
                    page_count=1,
                    document_type="train_ticket",
                    document_type_label="火车/高铁票",
                    scene_code="travel",
                    scene_label="差旅票据",
                    document_fields=[
                        {"key": "origin", "label": "出发城市", "value": "武汉"},
                        {"key": "destination", "label": "到达城市", "value": "北京"},
                        {"key": "trip_date", "label": "列车出发时间", "value": "2026-02-20"},
                        {"key": "fare", "label": "票价", "value": "354元"},
                    ],
                )
            ],
        )

    monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
    monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)

    with build_session() as db:
        claim = build_claim(expense_type="travel", location="上海")
        claim.reason = "支撑国网仿生产环境部署"
        claim.items[0].item_location = "上海"
        claim.items[0].invoice_id = None
        db.add(claim)
        db.commit()

        payload = ExpenseClaimService(db).upload_claim_item_attachment(
            claim_id=claim.id,
            item_id=claim.items[0].id,
            filename="train-ticket.png",
            content=b"fake-image-bytes",
            media_type="image/png",
            current_user=current_user,
        )

        flags = payload["claim_risk_flags"]
        assert any(
            isinstance(flag, dict) and flag.get("rule_code") == "risk.travel.high.city_mismatch"
            for flag in flags
        )
        city_flag = next(
            flag
            for flag in flags
            if isinstance(flag, dict) and flag.get("rule_code") == "risk.travel.high.city_mismatch"
        )
        assert city_flag.get("item_ids") == [claim.items[0].id]


def test_upload_attachment_uses_linked_application_business_time_for_date_risk(
    monkeypatch,
    tmp_path,
) -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="emp-1",
        name="张三",
        role_codes=[],
        is_admin=False,
    )

    def fake_recognize(
        self,
        files: list[tuple[str, bytes, str | None]],
    ) -> OcrRecognizeBatchRead:
        return OcrRecognizeBatchRead(
            total_file_count=1,
            success_count=1,
            documents=[
                OcrRecognizeDocumentRead(
                    filename="late-train-ticket.png",
                    media_type="image/png",
                    text="铁路电子客票 武汉-上海 2026-03-01 票价354元",
                    summary="铁路电子客票，武汉至上海，2026-03-01 出发。",
                    avg_score=0.98,
                    line_count=1,
                    page_count=1,
                    document_type="train_ticket",
                    document_type_label="火车/高铁票",
                    scene_code="travel",
                    scene_label="差旅票据",
                    document_fields=[
                        {"key": "route", "label": "行程", "value": "武汉-上海"},
                        {"key": "trip_date", "label": "列车出发时间", "value": "2026-03-01"},
                        {"key": "fare", "label": "票价", "value": "354元"},
                    ],
                )
            ],
        )

    monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
    monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)

    with build_session() as db:
        claim = build_claim(expense_type="travel", location="上海")
        claim.reason = "支撑国网仿生产环境部署"
        claim.items[0].item_location = "上海"
        claim.items[0].invoice_id = None
        claim.risk_flags_json = [
            {
                "source": "application_link",
                "application_claim_id": "application-date-risk",
                "application_claim_no": "AP-20260220-TEST",
                "application_detail": {
                    "application_business_time": "2026-02-20 至 2026-02-23",
                    "application_location": "上海",
                },
            }
        ]
        db.add(claim)
        db.commit()

        payload = ExpenseClaimService(db).upload_claim_item_attachment(
            claim_id=claim.id,
            item_id=claim.items[0].id,
            filename="late-train-ticket.png",
            content=b"fake-image-bytes",
            media_type="image/png",
            current_user=current_user,
        )

        flags = payload["claim_risk_flags"]
        assert any(
            isinstance(flag, dict) and flag.get("rule_code") == "risk.travel.high.date_outside_trip"
            for flag in flags
        )


def test_upload_hotel_attachment_audits_date_like_amount(monkeypatch, tmp_path) -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="emp-1",
        name="张三",
        role_codes=[],
        is_admin=False,
    )

    def fake_recognize(
        self,
        files: list[tuple[str, bytes, str | None]],
    ) -> OcrRecognizeBatchRead:
        return OcrRecognizeBatchRead(
            total_file_count=1,
            success_count=1,
            documents=[
                OcrRecognizeDocumentRead(
                    filename="hotel-invoice.png",
                    media_type="image/png",
                    text="北京中心酒店 总费用是828元 入住日期 2026-02-20 离店日期 2026-02-21",
                    summary="酒店住宿票据，住宿总费用 828 元。",
                    avg_score=0.96,
                    line_count=1,
                    page_count=1,
                    document_type="hotel_invoice",
                    document_type_label="酒店住宿票据",
                    scene_code="hotel",
                    scene_label="住宿票据",
                    document_fields=[
                        {"key": "amount", "label": "金额", "value": "2026元"},
                        {"key": "hotel_name", "label": "酒店", "value": "北京中心酒店"},
                        {"key": "check_in", "label": "入住日期", "value": "2026-02-20"},
                        {"key": "check_out", "label": "离店日期", "value": "2026-02-21"},
                    ],
                )
            ],
        )

    monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
    monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)

    with build_session() as db:
        claim = build_claim(expense_type="hotel", location="北京")
        claim.amount = Decimal("0.00")
        claim.invoice_count = 0
        claim.items[0].item_type = "hotel"
        claim.items[0].item_reason = ""
        claim.items[0].item_amount = Decimal("0.00")
        claim.items[0].invoice_id = None
        db.add(claim)
        db.commit()

        service = ExpenseClaimService(db)
        updated = service.upload_claim_item_attachment(
            claim_id=claim.id,
            item_id=claim.items[0].id,
            filename="hotel-invoice.png",
            content=b"fake-image-bytes",
            media_type="image/png",
            current_user=current_user,
        )

        assert updated is not None
        assert updated["item_type"] == "hotel_ticket"
        assert updated["item_amount"] == Decimal("828.00")
        assert updated["claim_amount"] == Decimal("828.00")
        db.refresh(claim)
        assert claim.items[0].item_amount == Decimal("828.00")
        assert claim.amount == Decimal("828.00")
        uploaded_meta = service.get_claim_item_attachment_meta(
            claim_id=claim.id,
            item_id=claim.items[0].id,
            current_user=current_user,
        )
        assert uploaded_meta is not None
        assert uploaded_meta["analysis"]["severity"] == "medium"
        assert any(
            "费用核算" in point and "828.00 元" in point
            for point in uploaded_meta["analysis"]["points"]
        )
        assert not any(
            "2026.00 元与报销金额" in point for point in uploaded_meta["analysis"]["points"]
        )


def test_upload_hotel_attachment_flags_amount_over_travel_policy(monkeypatch, tmp_path) -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="emp-hotel-risk@example.com",
        name="张三",
        role_codes=[],
        is_admin=False,
    )

    def fake_recognize(
        self,
        files: list[tuple[str, bytes, str | None]],
    ) -> OcrRecognizeBatchRead:
        return OcrRecognizeBatchRead(
            total_file_count=1,
            success_count=1,
            documents=[
                OcrRecognizeDocumentRead(
                    filename="hotel-risk.png",
                    media_type="image/png",
                    text="北京全季酒店 住宿 1晚 金额800元 2026-05-13",
                    summary="北京全季酒店住宿发票，住宿 1 晚，金额 800 元。",
                    avg_score=0.98,
                    line_count=1,
                    page_count=1,
                    document_type="hotel_invoice",
                    document_type_label="酒店住宿票据",
                    scene_code="hotel",
                    scene_label="住宿票据",
                    document_fields=[
                        {"key": "merchant_name", "label": "商户", "value": "北京全季酒店"},
                        {"key": "amount", "label": "金额", "value": "800元"},
                        {"key": "date", "label": "日期", "value": "2026-05-13"},
                    ],
                )
            ],
        )

    monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
    monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)

    with build_session() as db:
        employee = Employee(
            employee_no="E7401",
            name="张三",
            email="emp-hotel-risk@example.com",
            grade="P4",
        )
        db.add(employee)
        db.flush()

        claim = build_claim(expense_type="travel", location="北京")
        claim.employee = employee
        claim.employee_id = employee.id
        claim.reason = "北京客户现场出差"
        claim.amount = Decimal("0.00")
        claim.invoice_count = 0
        claim.items[0].item_type = "hotel"
        claim.items[0].item_reason = "北京住宿"
        claim.items[0].item_location = "北京"
        claim.items[0].item_amount = Decimal("0.00")
        claim.items[0].invoice_id = None
        db.add(claim)
        db.commit()

        service = ExpenseClaimService(db)
        updated = service.upload_claim_item_attachment(
            claim_id=claim.id,
            item_id=claim.items[0].id,
            filename="hotel-risk.png",
            content=b"fake-image-bytes",
            media_type="image/png",
            current_user=current_user,
        )

        assert updated is not None
        uploaded_meta = service.get_claim_item_attachment_meta(
            claim_id=claim.id,
            item_id=claim.items[0].id,
            current_user=current_user,
        )
        assert uploaded_meta is not None
        analysis = uploaded_meta["analysis"]
        assert analysis["severity"] == "high"
        assert analysis["headline"] == "AI提示：住宿金额超出报销标准"
        assert "保留在单据中" in analysis["summary"]
        assert "特殊情况" in analysis["summary"]
        assert any("住宿标准" in point and "800.00 元" in point for point in analysis["points"])
        assert any("住宿费按员工职级" in basis for basis in analysis["rule_basis"])
        db.refresh(claim)
        hotel_item = next(item for item in claim.items if str(item.invoice_id or "").strip())
        assert hotel_item.item_amount == Decimal("800.00")
        assert claim.invoice_count == 1
        assert any(
            isinstance(flag, dict)
            and str(flag.get("source") or "").strip() == "attachment_analysis"
            and flag.get("item_id") == hotel_item.id
            and str(flag.get("severity") or "").strip() == "high"
            for flag in list(claim.risk_flags_json or [])
        )


def test_upload_hotel_attachment_does_not_add_generic_auto_review_summary(
    monkeypatch,
    tmp_path,
) -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="emp-hotel-summary@example.com",
        name="张三",
        role_codes=[],
        is_admin=False,
    )

    def fake_recognize(
        self,
        files: list[tuple[str, bytes, str | None]],
    ) -> OcrRecognizeBatchRead:
        return OcrRecognizeBatchRead(
            total_file_count=1,
            success_count=1,
            documents=[
                OcrRecognizeDocumentRead(
                    filename="hotel-summary-risk.png",
                    media_type="image/png",
                    text="北京全季酒店 住宿 1晚 金额800元 2026-05-13",
                    summary="北京全季酒店住宿发票，住宿 1 晚，金额 800 元。",
                    avg_score=0.98,
                    line_count=1,
                    page_count=1,
                    document_type="hotel_invoice",
                    document_type_label="酒店住宿票据",
                    scene_code="hotel",
                    scene_label="住宿票据",
                    document_fields=[
                        {"key": "merchant_name", "label": "商户", "value": "北京全季酒店"},
                        {"key": "amount", "label": "金额", "value": "800元"},
                        {"key": "date", "label": "日期", "value": "2026-05-13"},
                    ],
                )
            ],
        )

    monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
    monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)

    with build_session() as db:
        manager = Employee(
            employee_no="E7410",
            name="李经理",
            email="manager-hotel-summary@example.com",
        )
        employee = Employee(
            employee_no="E7411",
            name="张三",
            email="emp-hotel-summary@example.com",
            grade="P4",
            manager=manager,
        )
        db.add_all([manager, employee])
        db.flush()

        claim = build_claim(expense_type="travel", location="北京")
        claim.employee = employee
        claim.employee_id = employee.id
        claim.reason = "北京客户现场出差"
        claim.amount = Decimal("0.00")
        claim.invoice_count = 0
        claim.items[0].item_type = "hotel"
        claim.items[0].item_reason = "北京住宿"
        claim.items[0].item_location = "北京"
        claim.items[0].item_amount = Decimal("0.00")
        claim.items[0].invoice_id = None
        db.add(claim)
        db.commit()

        ExpenseClaimService(db).upload_claim_item_attachment(
            claim_id=claim.id,
            item_id=claim.items[0].id,
            filename="hotel-summary-risk.png",
            content=b"fake-image-bytes",
            media_type="image/png",
            current_user=current_user,
        )

        db.refresh(claim)
        labels = [
            str(flag.get("label") or "").strip()
            for flag in list(claim.risk_flags_json or [])
            if isinstance(flag, dict)
        ]
        assert "自动检测重点复核" not in labels
        assert "自动检测提醒" not in labels
        assert any(
            isinstance(flag, dict)
            and str(flag.get("source") or "").strip() == "attachment_analysis"
            and str(flag.get("severity") or "").strip() == "high"
            for flag in list(claim.risk_flags_json or [])
        )


def test_delete_claim_item_attachment_removes_attachment_analysis_risk(
    monkeypatch, tmp_path
) -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="emp-hotel-risk@example.com",
        name="张三",
        role_codes=[],
        is_admin=False,
    )

    def fake_recognize(
        self,
        files: list[tuple[str, bytes, str | None]],
    ) -> OcrRecognizeBatchRead:
        return OcrRecognizeBatchRead(
            total_file_count=1,
            success_count=1,
            documents=[
                OcrRecognizeDocumentRead(
                    filename="hotel-risk.png",
                    media_type="image/png",
                    text="北京全季酒店 住宿 1晚 金额800元 2026-05-13",
                    summary="北京全季酒店住宿发票，住宿 1 晚，金额 800 元。",
                    avg_score=0.98,
                    line_count=1,
                    page_count=1,
                    document_type="hotel_invoice",
                    document_type_label="酒店住宿票据",
                    scene_code="hotel",
                    scene_label="住宿票据",
                    document_fields=[
                        {"key": "merchant_name", "label": "商户", "value": "北京全季酒店"},
                        {"key": "amount", "label": "金额", "value": "800元"},
                        {"key": "date", "label": "日期", "value": "2026-05-13"},
                    ],
                )
            ],
        )

    monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
    monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)

    with build_session() as db:
        employee = Employee(
            employee_no="E7402",
            name="张三",
            email="emp-hotel-risk@example.com",
            grade="P4",
        )
        db.add(employee)
        db.flush()

        claim = build_claim(expense_type="travel", location="北京")
        claim.employee = employee
        claim.employee_id = employee.id
        claim.reason = "北京客户现场出差"
        claim.invoice_count = 0
        claim.items[0].item_type = "hotel"
        claim.items[0].item_reason = "北京住宿"
        claim.items[0].item_location = "北京"
        claim.items[0].item_amount = Decimal("0.00")
        claim.items[0].invoice_id = None
        db.add(claim)
        db.commit()

        service = ExpenseClaimService(db)
        upload_payload = service.upload_claim_item_attachment(
            claim_id=claim.id,
            item_id=claim.items[0].id,
            filename="hotel-risk.png",
            content=b"fake-image-bytes",
            media_type="image/png",
            current_user=current_user,
        )

        assert upload_payload is not None
        assert any(
            isinstance(flag, dict)
            and str(flag.get("source") or "").strip() == "attachment_analysis"
            for flag in upload_payload["claim_risk_flags"]
        )

        delete_payload = service.delete_claim_item_attachment(
            claim_id=claim.id,
            item_id=claim.items[0].id,
            current_user=current_user,
        )

        assert delete_payload is not None
        assert delete_payload["invoice_id"] is None
        assert not any(
            isinstance(flag, dict)
            and str(flag.get("source") or "").strip() == "attachment_analysis"
            for flag in delete_payload["claim_risk_flags"]
        )
        assert not any(
            "高风险附件" in str(flag.get("message") or "")
            for flag in delete_payload["claim_risk_flags"]
            if isinstance(flag, dict)
        )

        db.refresh(claim)
        assert claim.invoice_count == 0
        assert claim.items[0].invoice_id is None
        assert not any(
            isinstance(flag, dict)
            and str(flag.get("source") or "").strip() == "attachment_analysis"
            for flag in list(claim.risk_flags_json or [])
        )


def test_attachment_analysis_does_not_compare_business_purpose_with_ticket_scene() -> None:
    with build_session() as db:
        claim = build_claim(expense_type="travel", location="上海")
        claim.items[0].item_type = "train_ticket"
        claim.items[0].item_reason = "2026-02-20 至 2026-02-23，支撑上海电力项目部署"
        claim.items[0].item_amount = Decimal("354.00")
        db.add(claim)
        db.commit()

        document = OcrRecognizeDocumentRead(
            filename="train-ticket.png",
            media_type="image/png",
            text="中国铁路电子客票 上海虹桥-武汉 二等座 2026-02-20 票价:￥354.00",
            summary="铁路电子客票，上海虹桥至武汉，票价 354 元。",
            avg_score=0.98,
            line_count=1,
            page_count=1,
            document_type="train_ticket",
            document_type_label="火车/高铁票",
            scene_code="travel",
            scene_label="差旅票据",
            document_fields=[
                {"key": "amount", "label": "票价", "value": "￥354.00"},
                {"key": "date", "label": "日期", "value": "2026-02-20"},
                {"key": "route", "label": "行程", "value": "上海虹桥-武汉"},
            ],
        )

        analysis = ExpenseClaimService(db)._build_attachment_analysis(
            document=document,
            item=claim.items[0],
        )

        assert analysis["severity"] == "medium"
        assert not any("用途字段" in point for point in analysis["points"])
        assert any("行程说明" in point and "起始地-目的地" in point for point in analysis["points"])


def test_attachment_risk_flag_message_uses_specific_points(monkeypatch, tmp_path) -> None:
    with build_session() as db:
        claim = build_claim(expense_type="travel", location="上海")
        claim.items[0].invoice_id = "invoice.png"
        db.add(claim)
        db.commit()

        generic_summary = "当前附件可见部分内容，但金额、用途、日期或附件类型仍有缺失或不一致。"
        file_path = tmp_path / "invoice.png"
        file_path.write_bytes(b"fake")
        service = ExpenseClaimService(db)
        monkeypatch.setattr(
            ExpenseClaimAttachmentStorage,
            "resolve_path",
            lambda self, storage_key: file_path,
        )
        monkeypatch.setattr(
            service._attachment_storage,
            "read_meta",
            lambda path: {
                "analysis": {
                    "severity": "medium",
                    "label": "中风险",
                    "summary": generic_summary,
                    "points": [
                        "日期字段：未识别到开票日期或业务发生日期。",
                        "金额字段：附件识别金额 300.00 元与报销金额 88.00 元不一致。",
                    ],
                }
            },
        )

        flags = service._build_claim_attachment_risk_flags([claim.items[0]])

        assert len(flags) == 1
        assert "日期字段：未识别到开票日期或业务发生日期。" in flags[0]["message"]
        assert "当前附件可见部分内容" not in flags[0]["message"]
        assert flags[0]["summary"] == generic_summary
        assert flags[0]["points"] == [
            "日期字段：未识别到开票日期或业务发生日期。",
            "金额字段：附件识别金额 300.00 元与报销金额 88.00 元不一致。",
        ]


def test_upload_ride_receipt_backfills_item_reason_from_addresses(monkeypatch, tmp_path) -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="emp-1",
        name="张三",
        role_codes=[],
        is_admin=False,
    )

    def fake_recognize(
        self,
        files: list[tuple[str, bytes, str | None]],
    ) -> OcrRecognizeBatchRead:
        return OcrRecognizeBatchRead(
            total_file_count=1,
            success_count=1,
            documents=[
                OcrRecognizeDocumentRead(
                    filename="ride-receipt.png",
                    media_type="image/png",
                    text="滴滴出行订单 起点：深圳北站 终点：腾讯滨海大厦 实付金额：42.00元",
                    summary="滴滴出行乘车票据，深圳北站到腾讯滨海大厦，金额 42 元。",
                    avg_score=0.98,
                    line_count=1,
                    page_count=1,
                    document_type="taxi_receipt",
                    document_type_label="出租车/网约车票据",
                    scene_code="transport",
                    scene_label="交通票据",
                    document_fields=[
                        {"key": "start_location", "label": "起点", "value": "深圳北站"},
                        {"key": "end_location", "label": "终点", "value": "腾讯滨海大厦"},
                        {"key": "amount", "label": "实付金额", "value": "42.00元"},
                    ],
                )
            ],
        )

    monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
    monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)

    with build_session() as db:
        claim = build_claim(expense_type="transport", location="深圳")
        claim.amount = Decimal("0.00")
        claim.invoice_count = 0
        claim.items[0].item_type = "transport"
        claim.items[0].item_reason = "打车报销"
        claim.items[0].item_amount = Decimal("0.00")
        claim.items[0].invoice_id = None
        db.add(claim)
        db.commit()

        service = ExpenseClaimService(db)
        updated = service.upload_claim_item_attachment(
            claim_id=claim.id,
            item_id=claim.items[0].id,
            filename="ride-receipt.png",
            content=b"fake-image-bytes",
            media_type="image/png",
            current_user=current_user,
        )

        assert updated is not None
        db.refresh(claim)
        assert claim.items[0].item_type == "ride_ticket"
        assert claim.items[0].item_reason == "深圳北站-腾讯滨海大厦"
        assert claim.items[0].item_amount == Decimal("42.00")
        assert claim.amount == Decimal("42.00")


def test_delete_claim_item_removes_row_and_attachment_files(monkeypatch, tmp_path) -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="emp-1",
        name="张三",
        role_codes=[],
        is_admin=False,
    )

    def fake_recognize(
        self,
        files: list[tuple[str, bytes, str | None]],
    ) -> OcrRecognizeBatchRead:
        return OcrRecognizeBatchRead(
            total_file_count=1,
            success_count=1,
            documents=[
                OcrRecognizeDocumentRead(
                    filename="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)

    with build_session() as db:
        claim = build_claim(expense_type="office", location="深圳南山")
        claim.invoice_count = 0
        claim.items[0].invoice_id = None
        claim.items[0].item_reason = "办公用品采购"
        db.add(claim)
        db.commit()

        service = ExpenseClaimService(db)
        upload_payload = service.upload_claim_item_attachment(
            claim_id=claim.id,
            item_id=claim.items[0].id,
            filename="office-note.png",
            content=b"fake-image-bytes",
            media_type="image/png",
            current_user=current_user,
        )

        assert upload_payload is not None
        attachment_root = tmp_path / claim.id / claim.items[0].id
        assert attachment_root.exists()

        delete_payload = service.delete_claim_item(
            claim_id=claim.id,
            item_id=claim.items[0].id,
            current_user=current_user,
        )

        assert delete_payload is not None
        assert delete_payload["claim_id"] == claim.id
        refreshed_claim = service.get_claim(claim.id, current_user)
        assert refreshed_claim is not None
        assert refreshed_claim.items == []
        assert refreshed_claim.amount == Decimal("0.00")
        assert refreshed_claim.invoice_count == 0
        assert not attachment_root.exists()


def test_delete_claim_removes_all_claim_attachment_files(monkeypatch, tmp_path) -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="admin",
        name="张三",
        role_codes=["admin"],
        is_admin=True,
    )
    monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)

    with build_session() as db:
        claim = build_claim(expense_type="office", location="深圳南山")
        attachment_dir = tmp_path / claim.id / claim.items[0].id
        attachment_dir.mkdir(parents=True)
        attachment_path = attachment_dir / "office-note.png"
        attachment_path.write_bytes(b"fake-image-bytes")
        (attachment_dir / "office-note.png.meta.json").write_text("{}", encoding="utf-8")
        orphan_path = tmp_path / claim.id / "orphan-preview.png"
        orphan_path.write_bytes(b"orphan-preview")
        claim.items[0].invoice_id = f"{claim.id}/{claim.items[0].id}/office-note.png"
        db.add(claim)
        db.commit()
        conversation = AgentConversationService(db).get_or_create_conversation(
            conversation_id=None,
            user_id=current_user.username,
            source="user_message",
            context_json={
                "tenant_id": "default",
                "session_type": "expense",
                "draft_claim_id": claim.id,
            },
        )
        claim_id = claim.id
        claim_root = tmp_path / claim.id

        deleted = ExpenseClaimService(db).delete_claim(claim_id, current_user)

        assert deleted is not None
        assert db.get(ExpenseClaim, claim_id) is None
        assert not claim_root.exists()
        assert AgentConversationService(db).get_conversation(conversation.conversation_id) is None


def test_applicant_can_delete_own_editable_draft_claim(monkeypatch, tmp_path) -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="emp-1",
        name="张三",
        role_codes=[],
        is_admin=False,
    )
    monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)

    with build_session() as db:
        claim = build_claim(expense_type="office", location="深圳南山")
        db.add(claim)
        db.commit()
        claim_id = claim.id

        deleted = ExpenseClaimService(db).delete_claim(claim_id, current_user)

        assert deleted is not None
        assert db.get(ExpenseClaim, claim_id) is None


def test_attachment_preview_resolves_legacy_filename_in_claim_item_directory(
    monkeypatch, tmp_path
) -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="emp-1",
        name="张三",
        role_codes=[],
        is_admin=False,
    )

    monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)

    with build_session() as db:
        claim = build_claim(expense_type="transport", location="上海")
        claim.items[0].invoice_id = "legacy-ticket.pdf"
        db.add(claim)
        db.commit()

        attachment_dir = tmp_path / claim.id / claim.items[0].id
        attachment_dir.mkdir(parents=True)
        file_path = attachment_dir / "legacy-ticket.pdf"
        file_path.write_bytes(b"legacy-pdf-bytes")
        (attachment_dir / "legacy-ticket.pdf.meta.json").write_text(
            '{"file_name":"legacy-ticket.pdf","media_type":"application/pdf","previewable":true}',
            encoding="utf-8",
        )

        payload = ExpenseClaimService(db).get_claim_item_attachment_preview_content(
            claim_id=claim.id,
            item_id=claim.items[0].id,
            current_user=current_user,
        )

        assert payload is not None
        resolved_path, media_type, filename = payload
        assert resolved_path == file_path
        assert media_type == "application/pdf"
        assert filename == "legacy-ticket.pdf"


def test_attachment_pdf_preview_falls_back_to_source_when_render_fonts_missing(
    monkeypatch, tmp_path
) -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="emp-1",
        name="张三",
        role_codes=[],
        is_admin=False,
    )

    monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)

    with build_session() as db:
        claim = build_claim(expense_type="train", location="上海")
        db.add(claim)
        db.commit()

        attachment_dir = tmp_path / claim.id / claim.items[0].id
        attachment_dir.mkdir(parents=True)
        file_path = attachment_dir / "2月20_武汉-上海.pdf"
        preview_path = attachment_dir / "2月20_武汉-上海.preview.png"
        file_path.write_bytes(b"%PDF-1.7 fake")
        preview_path.write_bytes(b"broken-preview")
        claim.items[0].invoice_id = f"{claim.id}/{claim.items[0].id}/{file_path.name}"
        db.commit()

        storage = ExpenseClaimAttachmentStorage()
        storage.write_meta(
            file_path,
            {
                "file_name": file_path.name,
                "storage_key": storage.to_storage_key(file_path),
                "media_type": "application/pdf",
                "previewable": True,
                "preview_kind": "image",
                "preview_storage_key": storage.to_storage_key(preview_path),
                "preview_media_type": "image/png",
                "preview_file_name": preview_path.name,
                "preview_rendered_with": "pdftoppm-png-r160-poppler-data",
            },
        )

        def fake_render_pdf_first_page(*, pdf_path, preview_path, timeout_seconds):
            raise RuntimeError("Missing language pack for 'Adobe-GB1' mapping")

        monkeypatch.setattr(
            DocumentPreviewAssets, "render_pdf_first_page", fake_render_pdf_first_page
        )

        resolved_path, media_type, filename = ExpenseClaimService(
            db
        ).get_claim_item_attachment_preview_content(
            claim_id=claim.id,
            item_id=claim.items[0].id,
            current_user=current_user,
        )

        assert resolved_path == file_path
        assert media_type == "application/pdf"
        assert filename == file_path.name
        refreshed_meta = storage.read_meta(file_path)
        assert refreshed_meta["preview_kind"] == "pdf"
        assert refreshed_meta["preview_storage_key"] == storage.to_storage_key(file_path)
        assert refreshed_meta["preview_media_type"] == "application/pdf"
        assert refreshed_meta["preview_file_name"] == file_path.name
        assert refreshed_meta["preview_rendered_with"] == ""


def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="emp-submit@example.com",
        name="张三",
        role_codes=[],
        is_admin=False,
    )

    with build_session() as db:
        manager = Employee(
            employee_no="E7000",
            name="李经理",
            email="manager@example.com",
        )
        employee = Employee(
            employee_no="E7001",
            name="张三",
            email="emp-submit@example.com",
            manager=manager,
        )
        claim = build_claim(expense_type="transport", location="上海")
        claim.employee = employee
        claim.employee_id = employee.id
        claim.items[0].invoice_id = "taxi-ticket.png"
        db.add_all([manager, employee, claim])
        db.commit()

        submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)

        assert submitted is not None
        assert submitted.status == "submitted"
        assert submitted.approval_stage == "直属领导审批"
        assert submitted.submitted_at is not None


def test_submit_claim_refreshes_legacy_pre_review_without_fingerprint(monkeypatch) -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="emp-submit@example.com",
        name="submitter",
        role_codes=[],
        is_admin=False,
    )

    original_review = ExpenseClaimService._run_ai_submission_review
    review_calls = 0

    def count_review(self, reviewed_claim):
        nonlocal review_calls
        review_calls += 1
        return original_review(self, reviewed_claim)

    monkeypatch.setattr(ExpenseClaimService, "_run_ai_submission_review", count_review)

    with build_session() as db:
        manager = Employee(
            employee_no="E7010",
            name="Manager",
            email="manager-reuse@example.com",
        )
        employee = Employee(
            employee_no="E7011",
            name="submitter",
            email="emp-submit@example.com",
            manager=manager,
        )
        claim = build_claim(expense_type="transport", location="Shanghai")
        claim.employee = employee
        claim.employee_id = employee.id
        claim.items[0].invoice_id = "taxi-ticket.png"
        claim.risk_flags_json = [
            {
                "source": "submission_review",
                "severity": "medium",
                "label": "upload-time-warning",
                "message": "generated before submit",
            },
            {
                "source": "ai_pre_review",
                "status": "passed",
                "passed": True,
                "severity": "info",
                "blocking_risk_count": 0,
            },
        ]
        db.add_all([manager, employee, claim])
        db.commit()

        submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)

        assert submitted is not None
        assert submitted.status == "submitted"
        assert review_calls == 1
        assert not any(
            flag.get("label") == "upload-time-warning" for flag in submitted.risk_flags_json
        )
        pre_review_flag = next(
            flag for flag in submitted.risk_flags_json if flag.get("source") == "ai_pre_review"
        )
        assert pre_review_flag["review_id"]
        assert pre_review_flag["input_fingerprint"].startswith("sha256:")


def test_accept_standard_adjustment_recalculates_claim_amount_and_preserves_on_submit() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="emp-standard@example.com",
        name="张三",
        role_codes=[],
        is_admin=False,
    )

    with build_session() as db:
        manager = Employee(
            employee_no="E7030",
            name="李经理",
            email="manager-standard@example.com",
        )
        employee = Employee(
            employee_no="E7031",
            name="张三",
            email="emp-standard@example.com",
            manager=manager,
        )
        claim = build_claim(expense_type="hotel", location="北京")
        claim.employee = employee
        claim.employee_id = employee.id
        claim.amount = Decimal("880.00")
        claim.items[0].item_type = "hotel_ticket"
        claim.items[0].item_reason = "北京住宿"
        claim.items[0].item_amount = Decimal("880.00")
        db.add_all([manager, employee, claim])
        db.commit()

        service = ExpenseClaimService(db)
        adjusted = service.accept_standard_adjustment(
            claim_id=claim.id,
            payload=ExpenseClaimStandardAdjustmentPayload(
                risks=[
                    {
                        "risk_id": "risk-hotel-1",
                        "item_id": claim.items[0].id,
                        "title": "住宿超标待说明",
                        "risk": "住宿标准为 600 元/晚，当前酒店识别金额约 880 元/晚。",
                        "original_amount": Decimal("880.00"),
                        "reimbursable_amount": Decimal("600.00"),
                    }
                ]
            ),
            current_user=current_user,
        )

        assert adjusted is not None
        assert adjusted.amount == Decimal("450.00")
        standard_flag = next(
            flag
            for flag in adjusted.risk_flags_json
            if isinstance(flag, dict) and flag.get("source") == "reimbursement_standard_adjustment"
        )
        assert standard_flag["original_amount"] == "880.00"
        assert standard_flag["reimbursable_amount"] == "450.00"
        assert standard_flag["employee_absorbed_amount"] == "430.00"
        assert standard_flag["visibility_scope"] == "leader"
        opportunity = db.scalar(
            select(SavingsOpportunity).where(SavingsOpportunity.claim_id == claim.id)
        )
        assert opportunity is not None
        assert opportunity.status == "in_progress"
        assert opportunity.baseline_amount == Decimal("880.0000")
        assert opportunity.target_amount == Decimal("450.0000")
        assert opportunity.estimated_net == Decimal("430.0000")
        assert opportunity.discovery_business_event_id
        baseline = db.get(ProfileBaselineSnapshot, opportunity.baseline_snapshot_id)
        assert baseline is not None
        assert baseline.policy_version == standard_flag["policy_rule_version"]
        evidence_links = list(
            db.scalars(
                select(SavingsEvidenceLink).where(
                    SavingsEvidenceLink.tenant_id == "default",
                    SavingsEvidenceLink.entity_id.in_([baseline.id, opportunity.id]),
                )
            ).all()
        )
        assert len(evidence_links) == 2
        assert all(item.verification_status == "verified" for item in evidence_links)

        submitted = service.submit_claim(claim.id, current_user)

        assert submitted is not None
        assert submitted.status == "submitted"
        assert submitted.amount == Decimal("450.00")
        assert any(
            isinstance(flag, dict) and flag.get("source") == "reimbursement_standard_adjustment"
            for flag in submitted.risk_flags_json
        )


def test_accept_standard_adjustment_ignores_client_amount_and_days_snapshots() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="emp-policy-standard@example.com",
        name="张三",
        role_codes=[],
        is_admin=False,
        grade="P4",
    )

    with build_session() as db:
        manager = Employee(
            employee_no="E7032",
            name="李经理",
            email="manager-policy-standard@example.com",
        )
        employee = Employee(
            employee_no="E7033",
            name="张三",
            email="emp-policy-standard@example.com",
            grade="P4",
            manager=manager,
        )
        claim = build_claim(expense_type="hotel", location="北京")
        claim.employee = employee
        claim.employee_id = employee.id
        claim.amount = Decimal("1000.00")
        claim.items[0].item_type = "hotel_ticket"
        claim.items[0].item_reason = "北京住宿"
        claim.items[0].item_location = "北京"
        claim.items[0].item_amount = Decimal("1000.00")
        db.add_all([manager, employee, claim])
        db.commit()

        adjusted = ExpenseClaimService(db).accept_standard_adjustment(
            claim_id=claim.id,
            payload=ExpenseClaimStandardAdjustmentPayload(
                risks=[
                    {
                        "risk_id": "risk-hotel-policy-1",
                        "item_id": claim.items[0].id,
                        "title": "住宿超标待说明",
                        "risk": "住宿票据金额超过职级标准。",
                        "application_days": 99,
                        "original_amount": Decimal("1.00"),
                        "reimbursable_amount": Decimal("999999.00"),
                    }
                ]
            ),
            current_user=current_user,
        )

        assert adjusted is not None
        assert adjusted.amount == Decimal("450.00")
        assert adjusted.items[0].item_amount == Decimal("1000.00")
        standard_flag = next(
            flag
            for flag in adjusted.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_location"] == "北京市"
        assert standard_flag["policy_matched_city"] == "北京"
        assert standard_flag["policy_grade"] == "P4"
        assert standard_flag["policy_hotel_rate"] == "450.00"
        assert standard_flag["policy_hotel_amount"] == "450.00"
        assert standard_flag["policy_rule_name"]
        assert standard_flag["policy_rule_version"]
        assert standard_flag["calculation_fingerprint"].startswith("sha256:")
        assert standard_flag["visibility_scope"] == "leader"


def test_accept_standard_adjustment_fails_closed_without_server_policy() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="emp-policy-missing@example.com",
        name="张三",
        role_codes=[],
        is_admin=False,
        grade="P4",
    )

    with build_session() as db:
        employee = Employee(
            employee_no="E7034",
            name="张三",
            email="emp-policy-missing@example.com",
            grade="P4",
        )
        claim = build_claim(expense_type="hotel", location="待补充")
        claim.employee = employee
        claim.employee_id = employee.id
        claim.amount = Decimal("1000.00")
        claim.location = "待补充"
        claim.items[0].item_type = "hotel_ticket"
        claim.items[0].item_reason = "住宿"
        claim.items[0].item_location = "待补充"
        claim.items[0].item_amount = Decimal("1000.00")
        db.add_all([employee, claim])
        db.commit()

        with pytest.raises(ValueError, match="暂未取得服务端可验证的报销标准"):
            ExpenseClaimService(db).accept_standard_adjustment(
                claim_id=claim.id,
                payload=ExpenseClaimStandardAdjustmentPayload(
                    risks=[
                        {
                            "item_id": claim.items[0].id,
                            "application_days": 2,
                            "original_amount": Decimal("1.00"),
                            "reimbursable_amount": Decimal("1.00"),
                        }
                    ]
                ),
                current_user=current_user,
            )

        db.refresh(claim)
        assert claim.amount == Decimal("1000.00")
        assert claim.items[0].item_amount == Decimal("1000.00")
        assert not any(
            isinstance(flag, dict) and flag.get("source") == "reimbursement_standard_adjustment"
            for flag in claim.risk_flags_json
        )


def test_accept_standard_adjustment_replays_same_request_without_rewriting_snapshot() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="emp-standard-replay@example.com",
        name="张三",
        role_codes=[],
        is_admin=False,
        grade="P4",
    )

    with build_session() as db:
        employee = Employee(
            employee_no="E7035",
            name="张三",
            email="emp-standard-replay@example.com",
            grade="P4",
        )
        claim = build_claim(expense_type="hotel", location="北京")
        claim.employee = employee
        claim.employee_id = employee.id
        claim.amount = Decimal("1000.00")
        claim.items[0].item_type = "hotel_ticket"
        claim.items[0].item_reason = "北京住宿"
        claim.items[0].item_location = "北京"
        claim.items[0].item_amount = Decimal("1000.00")
        db.add_all([employee, claim])
        db.commit()

        payload = ExpenseClaimStandardAdjustmentPayload(
            request_id="standard-adjustment-replay-1",
            expected_updated_at=claim.updated_at,
            risks=[{"item_id": claim.items[0].id, "risk_id": "risk-replay-1"}],
        )
        service = ExpenseClaimService(db)
        first = service.accept_standard_adjustment(
            claim_id=claim.id,
            payload=payload,
            current_user=current_user,
        )
        assert first is not None
        first_flag = next(
            flag
            for flag in first.risk_flags_json
            if isinstance(flag, dict) and flag.get("source") == "reimbursement_standard_adjustment"
        )
        first_created_at = first_flag["created_at"]
        first_calculation_fingerprint = first_flag["calculation_fingerprint"]

        replayed = service.accept_standard_adjustment(
            claim_id=claim.id,
            payload=payload,
            current_user=current_user,
        )

        assert replayed is not None
        replayed_flags = [
            flag
            for flag in replayed.risk_flags_json
            if isinstance(flag, dict) and flag.get("source") == "reimbursement_standard_adjustment"
        ]
        assert len(replayed_flags) == 1
        assert replayed_flags[0]["created_at"] == first_created_at
        assert replayed_flags[0]["calculation_fingerprint"] == first_calculation_fingerprint
        assert replayed.amount == Decimal("450.00")


def test_standard_adjustment_fallback_lock_serializes_same_tenant_claim() -> None:
    first_service = ExpenseClaimService(build_session())
    second_service = ExpenseClaimService(build_session())
    current_user = CurrentUserContext(
        username="emp-standard-lock@example.com",
        name="张三",
        role_codes=[],
        is_admin=False,
        tenant_id="tenant-standard-lock",
    )
    contender_started = threading.Event()
    contender_acquired = threading.Event()

    def contend_for_lock() -> None:
        contender_started.set()
        with second_service._serialize_standard_adjustment(
            claim_id="claim-standard-lock",
            current_user=current_user,
        ):
            contender_acquired.set()

    try:
        with first_service._serialize_standard_adjustment(
            claim_id="claim-standard-lock",
            current_user=current_user,
        ):
            contender = threading.Thread(target=contend_for_lock)
            contender.start()
            assert contender_started.wait(timeout=1)
            assert not contender_acquired.wait(timeout=0.05)

        contender.join(timeout=1)
        assert not contender.is_alive()
        assert contender_acquired.is_set()
    finally:
        first_service.db.close()
        second_service.db.close()


def test_accept_standard_adjustment_rejects_stale_claim_version() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="emp-standard-stale@example.com",
        name="张三",
        role_codes=[],
        is_admin=False,
        grade="P4",
    )

    with build_session() as db:
        employee = Employee(
            employee_no="E7036",
            name="张三",
            email="emp-standard-stale@example.com",
            grade="P4",
        )
        claim = build_claim(expense_type="hotel", location="北京")
        claim.employee = employee
        claim.employee_id = employee.id
        claim.items[0].item_type = "hotel_ticket"
        claim.items[0].item_location = "北京"
        claim.items[0].item_amount = Decimal("1000.00")
        db.add_all([employee, claim])
        db.commit()

        with pytest.raises(ValueError, match="已被其他操作更新"):
            ExpenseClaimService(db).accept_standard_adjustment(
                claim_id=claim.id,
                payload=ExpenseClaimStandardAdjustmentPayload(
                    request_id="standard-adjustment-stale-1",
                    expected_updated_at=datetime(2020, 1, 1, tzinfo=UTC),
                    risks=[{"item_id": claim.items[0].id}],
                ),
                current_user=current_user,
            )


def test_accept_standard_adjustment_preserves_other_item_snapshot() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="emp-standard-multi@example.com",
        name="张三",
        role_codes=[],
        is_admin=False,
        grade="P4",
    )

    with build_session() as db:
        employee = Employee(
            employee_no="E7037",
            name="张三",
            email="emp-standard-multi@example.com",
            grade="P4",
        )
        claim = build_claim(expense_type="hotel", location="北京")
        claim.employee = employee
        claim.employee_id = employee.id
        first_item = claim.items[0]
        first_item.item_type = "hotel_ticket"
        first_item.item_reason = "北京住宿第一晚"
        first_item.item_location = "北京"
        first_item.item_amount = Decimal("1000.00")
        second_item = ExpenseClaimItem(
            id="item-standard-second",
            claim_id=claim.id,
            item_date=date(2026, 5, 14),
            item_type="hotel_ticket",
            item_reason="北京住宿第二晚",
            item_location="北京",
            item_amount=Decimal("800.00"),
            invoice_id="invoice-standard-second",
        )
        claim.items.append(second_item)
        claim.amount = Decimal("1800.00")
        db.add_all([employee, claim])
        db.commit()

        service = ExpenseClaimService(db)
        first = service.accept_standard_adjustment(
            claim_id=claim.id,
            payload=ExpenseClaimStandardAdjustmentPayload(
                request_id="standard-adjustment-multi-1",
                risks=[{"item_id": first_item.id}],
            ),
            current_user=current_user,
        )
        assert first is not None
        assert first.amount == Decimal("1250.00")

        with pytest.raises(ValueError, match="request_id 已用于另一组"):
            service.accept_standard_adjustment(
                claim_id=claim.id,
                payload=ExpenseClaimStandardAdjustmentPayload(
                    request_id="standard-adjustment-multi-1",
                    risks=[{"item_id": second_item.id}],
                ),
                current_user=current_user,
            )

        second = service.accept_standard_adjustment(
            claim_id=claim.id,
            payload=ExpenseClaimStandardAdjustmentPayload(
                request_id="standard-adjustment-multi-2",
                risks=[{"item_id": second_item.id}],
            ),
            current_user=current_user,
        )

        assert second is not None
        standard_flags = [
            flag
            for flag in second.risk_flags_json
            if isinstance(flag, dict) and flag.get("source") == "reimbursement_standard_adjustment"
        ]
        assert {flag["item_id"] for flag in standard_flags} == {
            first_item.id,
            second_item.id,
        }
        assert second.amount == Decimal("900.00")


def test_pre_review_claim_records_ai_result_without_submitting() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="emp-pre-review@example.com",
        name="张三",
        role_codes=[],
        is_admin=False,
    )

    with build_session() as db:
        manager = Employee(
            employee_no="E7050",
            name="李经理",
            email="manager-pre-review@example.com",
        )
        employee = Employee(
            employee_no="E7051",
            name="张三",
            email="emp-pre-review@example.com",
            manager=manager,
        )
        claim = build_claim(expense_type="transport", location="上海")
        claim.employee = employee
        claim.employee_id = employee.id
        claim.items[0].invoice_id = "taxi-ticket.png"
        claim.risk_flags_json = [
            {
                "source": "manual_risk",
                "severity": "high",
                "label": "票据风险",
                "message": "票据金额与行程不匹配。",
            }
        ]
        db.add_all([manager, employee, claim])
        db.commit()

        reviewed = ExpenseClaimService(db).pre_review_claim(claim.id, current_user)

        assert reviewed is not None
        assert reviewed.status == "draft"
        assert reviewed.approval_stage == "待提交"
        assert reviewed.submitted_at is None
        pre_review_flag = next(
            flag
            for flag in reviewed.risk_flags_json
            if isinstance(flag, dict) and flag.get("source") == "ai_pre_review"
        )
        assert pre_review_flag["status"] == "failed"
        assert pre_review_flag["next_action"] == "risk_explanation_required"


def test_submit_claim_allows_returned_claim_to_be_resubmitted() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="emp-submit@example.com",
        name="张三",
        role_codes=[],
        is_admin=False,
    )

    with build_session() as db:
        manager = Employee(
            employee_no="E7100",
            name="李经理",
            email="manager-returned@example.com",
        )
        employee = Employee(
            employee_no="E7101",
            name="张三",
            email="emp-submit@example.com",
            manager=manager,
        )
        claim = build_claim(expense_type="transport", location="上海")
        claim.employee = employee
        claim.employee_id = employee.id
        claim.status = "returned"
        claim.approval_stage = "待补充"
        claim.items[0].invoice_id = "taxi-ticket.png"
        db.add_all([manager, employee, claim])
        db.commit()

        submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)

        assert submitted is not None
        assert submitted.status == "submitted"
        assert submitted.approval_stage == "直属领导审批"
        assert submitted.submitted_at is not None


def test_submit_claim_backfills_department_from_current_employee() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="emp-dept@example.com",
        name="张三",
        role_codes=[],
        is_admin=False,
    )

    with build_session() as db:
        department = OrganizationUnit(
            unit_code="D7200",
            name="销售部",
        )
        manager = Employee(
            employee_no="E7200",
            name="李经理",
            email="manager-dept@example.com",
        )
        employee = Employee(
            employee_no="E7201",
            name="张三",
            email="emp-dept@example.com",
            organization_unit=department,
            manager=manager,
        )
        claim = build_claim(expense_type="transport", location="待补充")
        claim.employee = None
        claim.employee_id = None
        claim.employee_name = "张三"
        claim.department_id = None
        claim.department_name = "待补充"
        claim.items[0].item_location = "待补充"
        db.add_all([department, manager, employee, claim])
        db.commit()

        submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)

        assert submitted is not None
        assert submitted.status == "submitted"
        assert submitted.department_id == department.id
        assert submitted.department_name == "销售部"
        assert submitted.approval_stage == "直属领导审批"


def test_submit_claim_blocks_high_risk_attachment_until_submitter_fixes_it(
    monkeypatch,
    tmp_path,
) -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="emp-risk@example.com",
        name="张三",
        role_codes=[],
        is_admin=False,
    )

    def fake_recognize(
        self,
        files: list[tuple[str, bytes, str | None]],
    ) -> OcrRecognizeBatchRead:
        return OcrRecognizeBatchRead(
            total_file_count=1,
            success_count=1,
            documents=[
                OcrRecognizeDocumentRead(
                    filename="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)

    with build_session() as db:
        manager = Employee(
            employee_no="E7100",
            name="李经理",
            email="manager2@example.com",
        )
        employee = Employee(
            employee_no="E7101",
            name="张三",
            email="emp-risk@example.com",
            manager=manager,
        )
        claim = build_claim(expense_type="office", location="深圳南山")
        claim.employee = employee
        claim.employee_id = employee.id
        claim.invoice_count = 0
        claim.items[0].invoice_id = None
        claim.items[0].item_reason = "办公用品采购"
        db.add_all([manager, employee, claim])
        db.commit()

        service = ExpenseClaimService(db)
        service.upload_claim_item_attachment(
            claim_id=claim.id,
            item_id=claim.items[0].id,
            filename="taxi-note.png",
            content=b"fake-image-bytes",
            media_type="image/png",
            current_user=current_user,
        )

        with pytest.raises(ExpenseClaimPreReviewBlockedError) as error_info:
            service.submit_claim(claim.id, current_user)

        blocked = db.get(ExpenseClaim, claim.id)
        assert blocked is not None
        assert blocked.status == "draft"
        assert blocked.submitted_at is None
        assert error_info.value.review["decision"] == "needs_fix"
        assert any(
            finding["severity"] == "high" and finding["disposition"] == "fix"
            for finding in error_info.value.review["findings"]
        )


def test_submit_claim_blocks_travel_route_mismatch_until_submitter_explains_it(
    monkeypatch,
    tmp_path,
) -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="emp-travel@example.com",
        name="张三",
        role_codes=[],
        is_admin=False,
    )

    def fake_recognize(
        self,
        files: list[tuple[str, bytes, str | None]],
    ) -> OcrRecognizeBatchRead:
        documents: list[OcrRecognizeDocumentRead] = []
        for filename, _, media_type in files:
            if filename == "outbound.png":
                documents.append(
                    OcrRecognizeDocumentRead(
                        filename=filename,
                        media_type=media_type or "image/png",
                        text="电子行程单 2026-05-13 经济舱 武汉-上海 金额 480元 航班号 MU5101",
                        summary="武汉到上海机票",
                        avg_score=0.98,
                        line_count=1,
                        page_count=1,
                        document_type="flight_itinerary",
                        document_type_label="机票/航班行程单",
                        scene_code="travel",
                        scene_label="差旅票据",
                        document_fields=[
                            {"key": "route", "label": "行程", "value": "武汉-上海"},
                            {"key": "amount", "label": "金额", "value": "480元"},
                            {"key": "date", "label": "日期", "value": "2026-05-13"},
                        ],
                        warnings=[],
                    )
                )
            elif filename == "onward.png":
                documents.append(
                    OcrRecognizeDocumentRead(
                        filename=filename,
                        media_type=media_type or "image/png",
                        text="电子行程单 2026-05-14 经济舱 上海-成都 金额 360元 航班号 MU5402",
                        summary="上海到成都机票",
                        avg_score=0.98,
                        line_count=1,
                        page_count=1,
                        document_type="flight_itinerary",
                        document_type_label="机票/航班行程单",
                        scene_code="travel",
                        scene_label="差旅票据",
                        document_fields=[
                            {"key": "route", "label": "行程", "value": "上海-成都"},
                            {"key": "amount", "label": "金额", "value": "360元"},
                            {"key": "date", "label": "日期", "value": "2026-05-14"},
                        ],
                        warnings=[],
                    )
                )
        return OcrRecognizeBatchRead(
            total_file_count=len(files),
            success_count=len(documents),
            documents=documents,
        )

    monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
    monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)

    with build_session() as db:
        manager = Employee(
            employee_no="E7200",
            name="李经理",
            email="manager-travel@example.com",
        )
        employee = Employee(
            employee_no="E7201",
            name="张三",
            email="emp-travel@example.com",
            grade="P4",
            manager=manager,
        )
        db.add_all([manager, employee])
        db.flush()

        claim = build_claim(expense_type="travel", location="上海")
        claim.reason = "上海客户现场出差"
        claim.employee = employee
        claim.employee_id = employee.id
        claim.items = [
            ExpenseClaimItem(
                id="travel-item-1",
                claim_id=claim.id,
                item_date=date(2026, 5, 13),
                item_type="travel",
                item_reason="赴上海客户现场",
                item_location="上海",
                item_amount=Decimal("480.00"),
                invoice_id=None,
            ),
            ExpenseClaimItem(
                id="travel-item-2",
                claim_id=claim.id,
                item_date=date(2026, 5, 14),
                item_type="travel",
                item_reason="赴上海客户现场",
                item_location="上海",
                item_amount=Decimal("360.00"),
                invoice_id=None,
            ),
        ]
        claim.amount = Decimal("840.00")
        claim.invoice_count = 0
        db.add(claim)
        db.commit()

        service = ExpenseClaimService(db)
        service.upload_claim_item_attachment(
            claim_id=claim.id,
            item_id="travel-item-1",
            filename="outbound.png",
            content=b"outbound-image",
            media_type="image/png",
            current_user=current_user,
        )
        service.upload_claim_item_attachment(
            claim_id=claim.id,
            item_id="travel-item-2",
            filename="onward.png",
            content=b"onward-image",
            media_type="image/png",
            current_user=current_user,
        )

        def fake_platform_route_review(self, claim, *, rule_codes=None, business_stage=None):
            return {
                "flags": [
                    {
                        "source": "submission_review",
                        "hit_source": "rule_center",
                        "rule_code": "risk.travel.medium.multi_city_no_reason",
                        "severity": "medium",
                        "label": "多城市行程缺少说明中风险",
                        "message": (
                            "本次报销识别到多城市行程（上海、武汉、成都），"
                            "但事由中未说明中转、多地拜访或改签原因。"
                        ),
                        "item_ids": ["travel-item-2"],
                        "business_stage": "reimbursement",
                    }
                ],
                "blocking_reasons": [],
            }

        monkeypatch.setattr(
            ExpenseClaimService,
            "evaluate_platform_risk_rules",
            fake_platform_route_review,
        )

        with pytest.raises(ExpenseClaimPreReviewBlockedError) as error_info:
            service.submit_claim(claim.id, current_user)

        blocked = db.get(ExpenseClaim, claim.id)
        assert blocked is not None
        assert blocked.status == "draft"
        assert error_info.value.review["decision"] == "needs_fix"
        route_findings = [
            finding
            for finding in error_info.value.review["findings"]
            if "多城市" in finding["message"] or "终点" in finding["message"]
        ]
        assert route_findings
        assert any("travel-item-2" in finding["item_ids"] for finding in route_findings)


def test_submit_claim_allows_round_trip_ticket_origin_inferred_from_route(
    monkeypatch,
    tmp_path,
) -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="emp-round-trip@example.com",
        name="张三",
        role_codes=[],
        is_admin=False,
    )

    def fake_recognize(
        self,
        files: list[tuple[str, bytes, str | None]],
    ) -> OcrRecognizeBatchRead:
        documents: list[OcrRecognizeDocumentRead] = []
        for filename, _, media_type in files:
            if filename == "outbound.png":
                documents.append(
                    OcrRecognizeDocumentRead(
                        filename=filename,
                        media_type=media_type or "image/png",
                        text="铁路电子客票 2026-02-20 武汉-上海 二等座 票价 ￥354.00",
                        summary="武汉到上海高铁票",
                        avg_score=0.98,
                        line_count=1,
                        page_count=1,
                        document_type="train_ticket",
                        document_type_label="铁路电子客票",
                        scene_code="travel",
                        scene_label="差旅票据",
                        document_fields=[
                            {"key": "route", "label": "行程", "value": "武汉-上海"},
                            {"key": "amount", "label": "金额", "value": "354元"},
                            {"key": "date", "label": "日期", "value": "2026-02-20"},
                        ],
                        warnings=[],
                    )
                )
            elif filename == "return.png":
                documents.append(
                    OcrRecognizeDocumentRead(
                        filename=filename,
                        media_type=media_type or "image/png",
                        text="铁路电子客票 2026-02-23 上海-武汉 二等座 票价 ￥354.00",
                        summary="上海到武汉高铁票",
                        avg_score=0.98,
                        line_count=1,
                        page_count=1,
                        document_type="train_ticket",
                        document_type_label="铁路电子客票",
                        scene_code="travel",
                        scene_label="差旅票据",
                        document_fields=[
                            {"key": "route", "label": "行程", "value": "上海-武汉"},
                            {"key": "amount", "label": "金额", "value": "354元"},
                            {"key": "date", "label": "日期", "value": "2026-02-23"},
                        ],
                        warnings=[],
                    )
                )
        return OcrRecognizeBatchRead(
            total_file_count=len(files),
            success_count=len(documents),
            documents=documents,
        )

    monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
    monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)

    with build_session() as db:
        manager = Employee(
            employee_no="E7210",
            name="李经理",
            email="manager-round-trip@example.com",
        )
        employee = Employee(
            employee_no="E7211",
            name="张三",
            email="emp-round-trip@example.com",
            grade="P4",
            location="上海",
            manager=manager,
        )
        db.add_all([manager, employee])
        db.flush()

        claim = build_claim(expense_type="travel", location="上海")
        claim.reason = "支撑国网仿生产环境部署"
        claim.employee = employee
        claim.employee_id = employee.id
        claim.items = [
            ExpenseClaimItem(
                id="round-trip-item-1",
                claim_id=claim.id,
                item_date=date(2026, 2, 20),
                item_type="travel",
                item_reason="支撑国网仿生产环境部署",
                item_location="上海",
                item_amount=Decimal("354.00"),
                invoice_id=None,
            ),
            ExpenseClaimItem(
                id="round-trip-item-2",
                claim_id=claim.id,
                item_date=date(2026, 2, 23),
                item_type="travel",
                item_reason="支撑国网仿生产环境部署",
                item_location="上海",
                item_amount=Decimal("354.00"),
                invoice_id=None,
            ),
        ]
        claim.amount = Decimal("708.00")
        claim.invoice_count = 0
        db.add(claim)
        db.commit()

        service = ExpenseClaimService(db)
        service.upload_claim_item_attachment(
            claim_id=claim.id,
            item_id="round-trip-item-1",
            filename="outbound.png",
            content=b"outbound-image",
            media_type="image/png",
            current_user=current_user,
        )
        service.upload_claim_item_attachment(
            claim_id=claim.id,
            item_id="round-trip-item-2",
            filename="return.png",
            content=b"return-image",
            media_type="image/png",
            current_user=current_user,
        )

        submitted = service.submit_claim(claim.id, current_user)

        assert submitted is not None
        assert submitted.status == "submitted"
        assert submitted.approval_stage == "直属领导审批"
        assert not any(
            isinstance(flag, dict)
            and str(flag.get("rule_code") or "").strip() == "risk.travel.high.city_mismatch"
            for flag in list(submitted.risk_flags_json or [])
        )


def test_submit_claim_blocks_hotel_amount_over_policy_until_standard_adjustment(
    monkeypatch,
    tmp_path,
) -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="emp-hotel@example.com",
        name="张三",
        role_codes=[],
        is_admin=False,
    )

    def fake_recognize(
        self,
        files: list[tuple[str, bytes, str | None]],
    ) -> OcrRecognizeBatchRead:
        documents: list[OcrRecognizeDocumentRead] = []
        for filename, _, media_type in files:
            if filename == "beijing-trip.png":
                documents.append(
                    OcrRecognizeDocumentRead(
                        filename=filename,
                        media_type=media_type or "image/png",
                        text="电子行程单 2026-05-13 经济舱 武汉-北京 金额 520元 航班号 MU6101",
                        summary="武汉到北京机票",
                        avg_score=0.97,
                        line_count=1,
                        page_count=1,
                        document_type="flight_itinerary",
                        document_type_label="机票/航班行程单",
                        scene_code="travel",
                        scene_label="差旅票据",
                        document_fields=[
                            {"key": "route", "label": "行程", "value": "武汉-北京"},
                            {"key": "amount", "label": "金额", "value": "520元"},
                            {"key": "date", "label": "日期", "value": "2026-05-13"},
                        ],
                        warnings=[],
                    )
                )
            elif filename == "beijing-hotel.png":
                documents.append(
                    OcrRecognizeDocumentRead(
                        filename=filename,
                        media_type=media_type or "image/png",
                        text="北京全季酒店 1晚 金额 880元 2026-05-13",
                        summary="北京全季酒店住宿发票",
                        avg_score=0.98,
                        line_count=1,
                        page_count=1,
                        document_type="hotel_invoice",
                        document_type_label="酒店住宿票据",
                        scene_code="hotel",
                        scene_label="住宿票据",
                        document_fields=[
                            {"key": "merchant_name", "label": "商户", "value": "北京全季酒店"},
                            {"key": "amount", "label": "金额", "value": "880元"},
                            {"key": "date", "label": "日期", "value": "2026-05-13"},
                        ],
                        warnings=[],
                    )
                )
        return OcrRecognizeBatchRead(
            total_file_count=len(files),
            success_count=len(documents),
            documents=documents,
        )

    monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
    monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)

    with build_session() as db:
        manager = Employee(
            employee_no="E7300",
            name="李经理",
            email="manager-hotel@example.com",
        )
        employee = Employee(
            employee_no="E7301",
            name="张三",
            email="emp-hotel@example.com",
            grade="P4",
            manager=manager,
        )
        db.add_all([manager, employee])
        db.flush()

        claim = build_claim(expense_type="travel", location="北京")
        claim.reason = "北京客户现场出差"
        claim.employee = employee
        claim.employee_id = employee.id
        claim.items = [
            ExpenseClaimItem(
                id="hotel-trip-item",
                claim_id=claim.id,
                item_date=date(2026, 5, 13),
                item_type="travel",
                item_reason="赴北京客户现场",
                item_location="北京",
                item_amount=Decimal("520.00"),
                invoice_id=None,
            ),
            ExpenseClaimItem(
                id="hotel-item",
                claim_id=claim.id,
                item_date=date(2026, 5, 13),
                item_type="hotel",
                item_reason="北京住宿",
                item_location="北京",
                item_amount=Decimal("880.00"),
                invoice_id=None,
            ),
        ]
        claim.amount = Decimal("1400.00")
        claim.invoice_count = 0
        db.add(claim)
        db.commit()

        service = ExpenseClaimService(db)
        service.upload_claim_item_attachment(
            claim_id=claim.id,
            item_id="hotel-trip-item",
            filename="beijing-trip.png",
            content=b"travel-image",
            media_type="image/png",
            current_user=current_user,
        )
        service.upload_claim_item_attachment(
            claim_id=claim.id,
            item_id="hotel-item",
            filename="beijing-hotel.png",
            content=b"hotel-image",
            media_type="image/png",
            current_user=current_user,
        )

        with pytest.raises(ExpenseClaimPreReviewBlockedError) as error_info:
            service.submit_claim(claim.id, current_user)

        blocked = db.get(ExpenseClaim, claim.id)
        assert blocked is not None
        assert blocked.status == "draft"
        assert error_info.value.review["decision"] == "needs_fix"
        assert any(
            finding.get("remediation", {}).get("alternative_action") == "accept_standard_limit"
            for finding in error_info.value.review["findings"]
        )
        assert any(
            "住宿" in finding["message"] or "酒店" in finding["message"]
            for finding in error_info.value.review["findings"]
        )


def test_list_claims_scopes_to_current_user_id_even_when_names_duplicate() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="zhangsan1@example.com",
        name="张三",
        role_codes=["manager"],
        is_admin=False,
    )

    with build_session() as db:
        employee_a = Employee(
            employee_no="E2001",
            name="张三",
            email="zhangsan1@example.com",
        )
        employee_b = Employee(
            employee_no="E2002",
            name="张三",
            email="zhangsan2@example.com",
        )
        db.add_all([employee_a, employee_b])
        db.flush()
        db.add_all(
            [
                ExpenseClaim(
                    claim_no="EXP-DUP-001",
                    employee_id=employee_a.id,
                    employee_name="张三",
                    department_name="市场部",
                    project_code="PRJ-A",
                    expense_type="travel",
                    reason="本人报销",
                    location="上海",
                    amount=Decimal("120.00"),
                    currency="CNY",
                    invoice_count=1,
                    occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
                    submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
                    status="submitted",
                    approval_stage="finance_review",
                    risk_flags_json=[],
                ),
                ExpenseClaim(
                    claim_no="EXP-DUP-002",
                    employee_id=employee_b.id,
                    employee_name="张三",
                    department_name="销售部",
                    project_code="PRJ-B",
                    expense_type="meal",
                    reason="他人报销",
                    location="杭州",
                    amount=Decimal("300.00"),
                    currency="CNY",
                    invoice_count=1,
                    occurred_at=datetime(2026, 5, 12, 12, 0, tzinfo=UTC),
                    submitted_at=datetime(2026, 5, 12, 13, 0, tzinfo=UTC),
                    status="approved",
                    approval_stage="completed",
                    risk_flags_json=[],
                ),
            ]
        )
        db.commit()

        claims = ExpenseClaimService(db).list_claims(current_user)

        assert len(claims) == 1
        assert claims[0].claim_no == "EXP-DUP-001"


def test_list_claims_resolves_short_username_to_unique_employee_email_prefix() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="caoxiaozhu",
        name="caoxiaozhu",
        role_codes=["employee"],
        is_admin=False,
    )

    with build_session() as db:
        employee = Employee(
            employee_no="E90919",
            name="曹笑竹",
            email="caoxiaozhu@xf.com",
        )
        db.add(employee)
        db.flush()
        db.add(
            ExpenseClaim(
                claim_no="AP-SHORT-USERNAME-001",
                employee_id=employee.id,
                employee_name=employee.name,
                department_name="研发部",
                project_code="PRJ-APP",
                expense_type="travel_application",
                reason="客户现场实施",
                location="北京",
                amount=Decimal("1600.00"),
                currency="CNY",
                invoice_count=0,
                occurred_at=datetime(2026, 6, 23, 9, 0, tzinfo=UTC),
                submitted_at=datetime(2026, 6, 23, 10, 0, tzinfo=UTC),
                status="approved",
                approval_stage=APPROVAL_DONE_STAGE,
                risk_flags_json=[],
            )
        )
        db.commit()

        claims = ExpenseClaimService(db).list_claims(current_user)

        assert len(claims) == 1
        assert claims[0].claim_no == "AP-SHORT-USERNAME-001"


def test_list_claims_limits_finance_to_personal_records() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="finance@example.com",
        name="财务",
        role_codes=["finance"],
        is_admin=False,
    )

    with build_session() as db:
        db.add_all(
            [
                ExpenseClaim(
                    claim_no="EXP-FIN-OWN",
                    employee_name="财务",
                    department_name="A部",
                    project_code="PRJ-A",
                    expense_type="travel",
                    reason="A 报销",
                    location="上海",
                    amount=Decimal("120.00"),
                    currency="CNY",
                    invoice_count=1,
                    occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
                    submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
                    status="submitted",
                    approval_stage="finance_review",
                    risk_flags_json=[],
                ),
                ExpenseClaim(
                    claim_no="EXP-FIN-OTHER-DRAFT",
                    employee_name="乙",
                    department_name="B部",
                    project_code="PRJ-B",
                    expense_type="meal",
                    reason="B 报销",
                    location="杭州",
                    amount=Decimal("300.00"),
                    currency="CNY",
                    invoice_count=1,
                    occurred_at=datetime(2026, 5, 11, 12, 0, tzinfo=UTC),
                    submitted_at=None,
                    status="draft",
                    approval_stage="待提交",
                    risk_flags_json=[],
                ),
            ]
        )
        db.commit()

        claims = ExpenseClaimService(db).list_claims(current_user)

        assert len(claims) == 1
        assert claims[0].claim_no == "EXP-FIN-OWN"


def test_list_claims_returns_company_reimbursements_for_finance_document_center() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="finance@example.com",
        name="财务",
        role_codes=["finance"],
        is_admin=False,
    )

    with build_session() as db:
        db.add_all(
            [
                ExpenseClaim(
                    claim_no="EXP-FIN-COMPANY-SUBMITTED",
                    employee_name="乙",
                    department_name="市场部",
                    project_code="PRJ-MKT",
                    expense_type="travel",
                    reason="客户拜访差旅",
                    location="上海",
                    amount=Decimal("1200.00"),
                    currency="CNY",
                    invoice_count=1,
                    occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
                    submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
                    status="submitted",
                    approval_stage="finance_review",
                    risk_flags_json=[],
                ),
                ExpenseClaim(
                    claim_no="EXP-FIN-COMPANY-DRAFT",
                    employee_name="丙",
                    department_name="技术部",
                    project_code="PRJ-TECH",
                    expense_type="office",
                    reason="办公用品",
                    location="北京",
                    amount=Decimal("300.00"),
                    currency="CNY",
                    invoice_count=1,
                    occurred_at=datetime(2026, 5, 11, 12, 0, tzinfo=UTC),
                    submitted_at=None,
                    status="draft",
                    approval_stage="待提交",
                    risk_flags_json=[],
                ),
                ExpenseClaim(
                    claim_no="EXP-FIN-COMPANY-PAID",
                    employee_name="丁",
                    department_name="财务部",
                    project_code="PRJ-FIN",
                    expense_type="meal",
                    reason="客户沟通",
                    location="杭州",
                    amount=Decimal("500.00"),
                    currency="CNY",
                    invoice_count=1,
                    occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
                    submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
                    status="paid",
                    approval_stage="payment",
                    risk_flags_json=[],
                ),
            ]
        )
        db.commit()

        claim_nos = {claim.claim_no for claim in ExpenseClaimService(db).list_claims(current_user)}
        archived_nos = {
            claim.claim_no for claim in ExpenseClaimService(db).list_archived_claims(current_user)
        }

        assert "EXP-FIN-COMPANY-SUBMITTED" in claim_nos
        assert "EXP-FIN-COMPANY-DRAFT" not in claim_nos
        assert "EXP-FIN-COMPANY-PAID" not in claim_nos
        assert "EXP-FIN-COMPANY-PAID" in archived_nos


def test_list_claims_returns_all_active_documents_for_admin_document_center() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="admin",
        name="admin",
        role_codes=["admin"],
        is_admin=True,
    )

    with build_session() as db:
        db.add_all(
            [
                ExpenseClaim(
                    claim_no="AP-ADMIN-DRAFT",
                    employee_name="Applicant A",
                    department_name="Tech",
                    project_code="PRJ-APP",
                    expense_type="travel_application",
                    reason="Travel application draft",
                    location="Shanghai",
                    amount=Decimal("1200.00"),
                    currency="CNY",
                    invoice_count=0,
                    occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
                    submitted_at=None,
                    status="draft",
                    approval_stage="draft",
                    risk_flags_json=[],
                ),
                ExpenseClaim(
                    claim_no="AP-ADMIN-LINKING",
                    employee_name="Applicant B",
                    department_name="Tech",
                    project_code="PRJ-APP",
                    expense_type="travel_application",
                    reason="Travel application approved",
                    location="Beijing",
                    amount=Decimal("2200.00"),
                    currency="CNY",
                    invoice_count=0,
                    occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
                    submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
                    status="approved",
                    approval_stage=APPLICATION_LINK_STATUS_STAGE,
                    risk_flags_json=[],
                ),
                ExpenseClaim(
                    claim_no="EXP-ADMIN-DRAFT",
                    employee_name="Applicant C",
                    department_name="Finance",
                    project_code="PRJ-EXP",
                    expense_type="office",
                    reason="Office draft",
                    location="Hangzhou",
                    amount=Decimal("300.00"),
                    currency="CNY",
                    invoice_count=0,
                    occurred_at=datetime(2026, 5, 13, 9, 0, tzinfo=UTC),
                    submitted_at=None,
                    status="draft",
                    approval_stage="draft",
                    risk_flags_json=[],
                ),
                ExpenseClaim(
                    claim_no="EXP-ADMIN-ARCHIVED",
                    employee_name="Applicant D",
                    department_name="Finance",
                    project_code="PRJ-EXP",
                    expense_type="office",
                    reason="Archived reimbursement",
                    location="Hangzhou",
                    amount=Decimal("500.00"),
                    currency="CNY",
                    invoice_count=1,
                    occurred_at=datetime(2026, 5, 14, 9, 0, tzinfo=UTC),
                    submitted_at=datetime(2026, 5, 14, 10, 0, tzinfo=UTC),
                    status="paid",
                    approval_stage="payment",
                    risk_flags_json=[],
                ),
            ]
        )
        db.commit()

        claim_nos = {claim.claim_no for claim in ExpenseClaimService(db).list_claims(current_user)}
        archived_nos = {
            claim.claim_no for claim in ExpenseClaimService(db).list_archived_claims(current_user)
        }

        assert "AP-ADMIN-DRAFT" in claim_nos
        assert "AP-ADMIN-LINKING" in claim_nos
        assert "EXP-ADMIN-DRAFT" in claim_nos
        assert "EXP-ADMIN-ARCHIVED" not in claim_nos
        assert "EXP-ADMIN-ARCHIVED" in archived_nos


def test_list_claims_limits_executive_to_personal_records() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="executive@example.com",
        name="高管",
        role_codes=["executive"],
        is_admin=False,
    )

    with build_session() as db:
        db.add_all(
            [
                ExpenseClaim(
                    claim_no="EXP-EXE-OWN",
                    employee_name="高管",
                    department_name="A部",
                    project_code="PRJ-A",
                    expense_type="travel",
                    reason="A 报销",
                    location="上海",
                    amount=Decimal("120.00"),
                    currency="CNY",
                    invoice_count=1,
                    occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
                    submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
                    status="submitted",
                    approval_stage="直属领导审批",
                    risk_flags_json=[],
                ),
                ExpenseClaim(
                    claim_no="EXP-EXE-OTHER-DRAFT",
                    employee_name="乙",
                    department_name="B部",
                    project_code="PRJ-B",
                    expense_type="meal",
                    reason="B 报销",
                    location="杭州",
                    amount=Decimal("300.00"),
                    currency="CNY",
                    invoice_count=1,
                    occurred_at=datetime(2026, 5, 11, 12, 0, tzinfo=UTC),
                    submitted_at=None,
                    status="draft",
                    approval_stage="待提交",
                    risk_flags_json=[],
                ),
            ]
        )
        db.commit()

        claims = ExpenseClaimService(db).list_claims(current_user)

        assert len(claims) == 1
        assert claims[0].claim_no == "EXP-EXE-OWN"


def test_list_claims_keeps_own_archived_claim_for_finance_applicant() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="finance@example.com",
        name="财务",
        role_codes=["finance"],
        is_admin=False,
    )

    with build_session() as db:
        db.add(
            ExpenseClaim(
                claim_no="EXP-FIN-OWN-ARCH",
                employee_name="财务",
                department_name="财务部",
                project_code="PRJ-FIN",
                expense_type="meal",
                reason="本人报销",
                location="上海",
                amount=Decimal("88.00"),
                currency="CNY",
                invoice_count=1,
                occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
                submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
                status="approved",
                approval_stage="归档入账",
                risk_flags_json=[],
            )
        )
        db.commit()

        claims = ExpenseClaimService(db).list_claims(current_user)

        assert len(claims) == 1
        assert claims[0].claim_no == "EXP-FIN-OWN-ARCH"


def test_list_archived_claims_returns_company_archived_records_for_finance() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="finance@example.com",
        name="财务",
        role_codes=["finance"],
        is_admin=False,
    )

    with build_session() as db:
        db.add_all(
            [
                ExpenseClaim(
                    claim_no="EXP-ARCH-101",
                    employee_name="甲",
                    department_name="A部",
                    project_code="PRJ-A",
                    expense_type="travel",
                    reason="A 报销",
                    location="上海",
                    amount=Decimal("120.00"),
                    currency="CNY",
                    invoice_count=1,
                    occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
                    submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
                    status="approved",
                    approval_stage="归档入账",
                    risk_flags_json=[],
                ),
                ExpenseClaim(
                    claim_no="EXP-ARCH-102",
                    employee_name="乙",
                    department_name="B部",
                    project_code="PRJ-B",
                    expense_type="meal",
                    reason="B 报销",
                    location="杭州",
                    amount=Decimal("300.00"),
                    currency="CNY",
                    invoice_count=1,
                    occurred_at=datetime(2026, 5, 11, 12, 0, tzinfo=UTC),
                    submitted_at=datetime(2026, 5, 11, 13, 0, tzinfo=UTC),
                    status="submitted",
                    approval_stage="财务审批",
                    risk_flags_json=[],
                ),
                ExpenseClaim(
                    claim_no="EXP-ARCH-PAID",
                    employee_name="丙",
                    department_name="C部",
                    project_code="PRJ-C",
                    expense_type="office",
                    reason="C 报销",
                    location="深圳",
                    amount=Decimal("180.00"),
                    currency="CNY",
                    invoice_count=1,
                    occurred_at=datetime(2026, 5, 11, 14, 0, tzinfo=UTC),
                    submitted_at=datetime(2026, 5, 11, 15, 0, tzinfo=UTC),
                    status="paid",
                    approval_stage="已付款",
                    risk_flags_json=[],
                ),
                ExpenseClaim(
                    claim_no="AP-20260525120000-ABCDEFGH",
                    employee_name="丙",
                    department_name="C部",
                    project_code="PRJ-C",
                    expense_type="travel_application",
                    reason="C 申请",
                    location="成都",
                    amount=Decimal("800.00"),
                    currency="CNY",
                    invoice_count=0,
                    occurred_at=datetime(2026, 5, 11, 14, 0, tzinfo=UTC),
                    submitted_at=datetime(2026, 5, 11, 15, 0, tzinfo=UTC),
                    status="approved",
                    approval_stage="审批完成",
                    risk_flags_json=[],
                ),
                ExpenseClaim(
                    claim_no="AP-20260525121000-ARCHIVED",
                    employee_name="戊",
                    department_name="E部",
                    project_code="PRJ-E",
                    expense_type="travel_application",
                    reason="E 申请",
                    location="广州",
                    amount=Decimal("600.00"),
                    currency="CNY",
                    invoice_count=0,
                    occurred_at=datetime(2026, 5, 11, 15, 0, tzinfo=UTC),
                    submitted_at=datetime(2026, 5, 11, 16, 0, tzinfo=UTC),
                    status="approved",
                    approval_stage=APPLICATION_ARCHIVE_STAGE,
                    risk_flags_json=[],
                ),
                ExpenseClaim(
                    claim_no="AP-20260525123000-HGFEDCBA",
                    employee_name="丁",
                    department_name="D部",
                    project_code="PRJ-D",
                    expense_type="travel_application",
                    reason="D 申请",
                    location="北京",
                    amount=Decimal("500.00"),
                    currency="CNY",
                    invoice_count=0,
                    occurred_at=datetime(2026, 5, 11, 16, 0, tzinfo=UTC),
                    submitted_at=datetime(2026, 5, 11, 17, 0, tzinfo=UTC),
                    status="submitted",
                    approval_stage="直属领导审批",
                    risk_flags_json=[],
                ),
            ]
        )
        db.commit()

        claims = ExpenseClaimService(db).list_archived_claims(current_user)

        assert {claim.claim_no for claim in claims} == {
            "EXP-ARCH-101",
            "EXP-ARCH-PAID",
            "AP-20260525121000-ARCHIVED",
        }


def test_list_archived_claims_returns_only_own_records_for_regular_employee() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="zhangsan@example.com",
        name="张三",
        role_codes=["employee"],
        is_admin=False,
    )

    with build_session() as db:
        db.add_all(
            [
                ExpenseClaim(
                    claim_no="EXP-ARCH-EMP",
                    employee_name="张三",
                    department_name="研发部",
                    project_code="PRJ-EMP",
                    expense_type="travel",
                    reason="本人报销",
                    location="北京",
                    amount=Decimal("200.00"),
                    currency="CNY",
                    invoice_count=1,
                    occurred_at=datetime(2026, 5, 10, 9, 0, tzinfo=UTC),
                    submitted_at=datetime(2026, 5, 10, 10, 0, tzinfo=UTC),
                    status="approved",
                    approval_stage="归档入账",
                    risk_flags_json=[],
                ),
                ExpenseClaim(
                    claim_no="AP-20260525130000-ABCDEFGH",
                    employee_name="李四",
                    department_name="研发部",
                    project_code="PRJ-EMP",
                    expense_type="travel_application",
                    reason="他人申请",
                    location="上海",
                    amount=Decimal("500.00"),
                    currency="CNY",
                    invoice_count=0,
                    occurred_at=datetime(2026, 5, 10, 9, 0, tzinfo=UTC),
                    submitted_at=datetime(2026, 5, 10, 10, 0, tzinfo=UTC),
                    status="approved",
                    approval_stage="审批完成",
                    risk_flags_json=[],
                ),
            ]
        )
        db.commit()

        claims = ExpenseClaimService(db).list_archived_claims(current_user)

        assert [claim.claim_no for claim in claims] == ["EXP-ARCH-EMP"]


def test_finance_can_return_but_cannot_delete_submitted_claim() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="finance@example.com",
        name="财务",
        role_codes=["finance"],
        is_admin=False,
    )

    with build_session() as db:
        claim = ExpenseClaim(
            claim_no="EXP-RET-101",
            employee_name="张三",
            department_name="市场部",
            project_code="PRJ-A",
            expense_type="travel",
            reason="差旅报销",
            location="上海",
            amount=Decimal("120.00"),
            currency="CNY",
            invoice_count=1,
            occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
            submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
            status="submitted",
            approval_stage="财务审批",
            risk_flags_json=[],
        )
        db.add(claim)
        db.commit()
        claim_id = claim.id

        service = ExpenseClaimService(db)
        returned = service.return_claim(claim_id, current_user, reason="资料不完整")

        assert returned is not None
        assert returned.status == "returned"
        assert returned.approval_stage == "待提交"
        assert any(
            isinstance(flag, dict)
            and flag.get("source") == "manual_return"
            and flag.get("message") == "资料不完整"
            for flag in returned.risk_flags_json
        )

        with pytest.raises(ValueError, match="申请人本人可以删除单据"):
            service.delete_claim(claim_id, current_user)

        assert db.get(ExpenseClaim, claim_id) is not None


def test_executive_cannot_delete_submitted_claim_without_admin_role() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="executive-delete@example.com",
        name="高管",
        role_codes=["executive"],
        is_admin=False,
    )

    with build_session() as db:
        claim = ExpenseClaim(
            claim_no="EXP-DEL-EXEC-101",
            employee_name="张三",
            department_name="市场部",
            project_code="PRJ-A",
            expense_type="travel",
            reason="差旅报销",
            location="上海",
            amount=Decimal("120.00"),
            currency="CNY",
            invoice_count=1,
            occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
            submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
            status="submitted",
            approval_stage="财务审批",
            risk_flags_json=[],
        )
        db.add(claim)
        db.commit()
        claim_id = claim.id

        with pytest.raises(ValueError, match="只有草稿"):
            ExpenseClaimService(db).delete_claim(claim_id, current_user)

        assert db.get(ExpenseClaim, claim_id) is not None


def test_direct_manager_cannot_delete_application_claim() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="manager-delete-application@example.com",
        name="李经理",
        role_codes=["manager"],
        is_admin=False,
    )

    with build_session() as db:
        manager = Employee(
            employee_no="E-APP-DEL-MANAGER",
            name="李经理",
            email="manager-delete-application@example.com",
        )
        employee = Employee(
            employee_no="E-APP-DEL-EMP",
            name="张三",
            email="zhangsan-application-delete@example.com",
            manager=manager,
        )
        db.add_all([manager, employee])
        db.flush()
        claim = ExpenseClaim(
            claim_no="APP-DEL-MANAGER-101",
            employee_id=employee.id,
            employee_name="张三",
            department_name="市场部",
            project_code=None,
            expense_type="travel_application",
            reason="差旅申请",
            location="上海",
            amount=Decimal("1200.00"),
            currency="CNY",
            invoice_count=0,
            occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
            submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
            status="submitted",
            approval_stage="直属领导审批",
            risk_flags_json=[],
        )
        db.add(claim)
        db.commit()
        claim_id = claim.id

        with pytest.raises(ValueError, match="只有草稿、待补充或退回待提交状态的单据"):
            ExpenseClaimService(db).delete_claim(claim_id, current_user)

        assert db.get(ExpenseClaim, claim_id) is not None


def test_applicant_can_delete_returned_application_claim() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="zhangsan-application-return-delete@example.com",
        name="张三",
        role_codes=[],
        is_admin=False,
    )

    with build_session() as db:
        employee = Employee(
            employee_no="E-APP-DEL-RETURN",
            name="张三",
            email="zhangsan-application-return-delete@example.com",
        )
        db.add(employee)
        db.flush()
        claim = ExpenseClaim(
            claim_no="APP-DEL-RETURN-101",
            employee_id=employee.id,
            employee_name="张三",
            department_name="市场部",
            project_code=None,
            expense_type="travel_application",
            reason="差旅申请",
            location="上海",
            amount=Decimal("1200.00"),
            currency="CNY",
            invoice_count=0,
            occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
            submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
            status="returned",
            approval_stage="待提交",
            risk_flags_json=[
                {
                    "source": "manual_return",
                    "event_type": "expense_application_return",
                    "message": "请补充出差事由",
                }
            ],
        )
        db.add(claim)
        db.commit()
        claim_id = claim.id

        deleted = ExpenseClaimService(db).delete_claim(claim_id, current_user)

        assert deleted is not None
        assert deleted.claim_no == "APP-DEL-RETURN-101"
        assert db.get(ExpenseClaim, claim_id) is None


def test_admin_can_delete_application_claim() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="superadmin",
        name="系统管理员",
        role_codes=["manager"],
        is_admin=True,
    )

    with build_session() as db:
        claim = ExpenseClaim(
            claim_no="APP-DEL-ADMIN-101",
            employee_name="张三",
            department_name="市场部",
            project_code=None,
            expense_type="travel_application",
            reason="差旅申请",
            location="上海",
            amount=Decimal("1200.00"),
            currency="CNY",
            invoice_count=0,
            occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
            submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
            status="submitted",
            approval_stage="直属领导审批",
            risk_flags_json=[],
        )
        db.add(claim)
        db.commit()
        claim_id = claim.id

        deleted = ExpenseClaimService(db).delete_claim(claim_id, current_user)

        assert deleted is not None
        assert deleted.claim_no == "APP-DEL-ADMIN-101"
        assert db.get(ExpenseClaim, claim_id) is None


def test_executive_cannot_delete_archived_claim() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="executive-archive-delete@example.com",
        name="高管",
        role_codes=["executive"],
        is_admin=False,
    )

    with build_session() as db:
        claim = ExpenseClaim(
            claim_no="EXP-DEL-ARCHIVE-101",
            employee_name="张三",
            department_name="市场部",
            project_code="PRJ-A",
            expense_type="travel",
            reason="差旅报销",
            location="上海",
            amount=Decimal("120.00"),
            currency="CNY",
            invoice_count=1,
            occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
            submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
            status="approved",
            approval_stage="归档入账",
            risk_flags_json=[],
        )
        db.add(claim)
        db.commit()
        claim_id = claim.id

        with pytest.raises(ValueError, match="已归档单据不能删除"):
            ExpenseClaimService(db).delete_claim(claim_id, current_user)

        assert db.get(ExpenseClaim, claim_id) is not None


def test_admin_can_delete_archived_claim() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="superadmin",
        name="系统管理员",
        role_codes=["manager"],
        is_admin=True,
    )

    with build_session() as db:
        claim = ExpenseClaim(
            claim_no="EXP-DEL-ARCHIVE-102",
            employee_name="张三",
            department_name="市场部",
            project_code="PRJ-A",
            expense_type="travel",
            reason="差旅报销",
            location="上海",
            amount=Decimal("120.00"),
            currency="CNY",
            invoice_count=1,
            occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
            submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
            status="approved",
            approval_stage="归档入账",
            risk_flags_json=[],
        )
        db.add(claim)
        db.commit()
        claim_id = claim.id

        deleted = ExpenseClaimService(db).delete_claim(claim_id, current_user)

        assert deleted is not None
        assert deleted.claim_no == "EXP-DEL-ARCHIVE-102"
        assert db.get(ExpenseClaim, claim_id) is None


def test_admin_delete_claim_unlinks_receipt_folder_items(monkeypatch, tmp_path) -> None:
    monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
    get_settings.cache_clear()
    try:
        receipt_owner = CurrentUserContext(
            tenant_id="default",
            username="emp-1",
            name="Employee",
            role_codes=[],
            is_admin=False,
        )
        admin_user = CurrentUserContext(
            tenant_id="default",
            username="superadmin",
            name="Admin",
            role_codes=["manager"],
            is_admin=True,
        )

        with build_session() as db:
            claim = build_claim(expense_type="travel", location="Shanghai")
            db.add(claim)
            db.commit()
            claim_id = claim.id
            claim_no = claim.claim_no
            item_id = claim.items[0].id

            receipt_service = ReceiptFolderService()
            receipt = receipt_service.save_receipt(
                filename="admin-delete-linked-receipt.pdf",
                content=b"%PDF-1.4 linked",
                media_type="application/pdf",
                current_user=receipt_owner,
                linked_claim_id=claim_id,
                linked_claim_no=claim_no,
                linked_item_id=item_id,
                document=OcrRecognizeDocumentRead(
                    filename="admin-delete-linked-receipt.pdf",
                    media_type="application/pdf",
                    text="invoice number 123 amount 100",
                    document_type="vat_invoice",
                    document_type_label="invoice",
                    scene_code="other",
                    scene_label="receipt",
                ),
            )
            assert receipt.status == "linked"

            deleted = ExpenseClaimService(db).delete_claim(claim_id, admin_user)

            assert deleted is not None
            assert db.get(ExpenseClaim, claim_id) is None
            unlinked_receipt = receipt_service.get_receipt(receipt.id, receipt_owner)
            assert unlinked_receipt.status == "unlinked"
            assert unlinked_receipt.linked_claim_id == ""
            assert unlinked_receipt.linked_claim_no == ""
            assert unlinked_receipt.linked_at is None
    finally:
        get_settings.cache_clear()


def test_admin_delete_linked_reimbursement_resets_application_link_status() -> None:
    admin_user = CurrentUserContext(
        tenant_id="default",
        username="superadmin",
        name="系统管理员",
        role_codes=["admin"],
        is_admin=True,
    )

    with build_session() as db:
        application_claim = ExpenseClaim(
            id="application-delete-linked-reimbursement",
            claim_no="APP-DEL-LINKED-APPLICATION",
            employee_name="张三",
            department_name="交付部",
            project_code="PRJ-A",
            expense_type="travel_application",
            reason="支撑国网仿生产环境部署",
            location="上海",
            amount=Decimal("3000.00"),
            currency="CNY",
            invoice_count=0,
            occurred_at=datetime(2026, 6, 21, 22, 30, tzinfo=UTC),
            submitted_at=datetime(2026, 6, 21, 22, 35, tzinfo=UTC),
            status="approved",
            approval_stage=APPLICATION_LINK_STATUS_STAGE,
            risk_flags_json=[
                {
                    "source": "manual_approval",
                    "event_type": "expense_application_approval",
                    "operator": "向万红",
                    "previous_approval_stage": DIRECT_MANAGER_APPROVAL_STAGE,
                    "next_status": "approved",
                    "next_approval_stage": APPLICATION_LINK_STATUS_STAGE,
                    "generated_draft_claim_id": "reimbursement-delete-linked-application",
                    "generated_draft_claim_no": "RDELETE01",
                    "created_at": "2026-06-21T22:45:00+00:00",
                }
            ],
        )
        reimbursement_claim = ExpenseClaim(
            id="reimbursement-delete-linked-application",
            claim_no="RDELETE01",
            employee_name="张三",
            department_name="交付部",
            project_code="PRJ-A",
            expense_type="travel",
            reason="支撑国网仿生产环境部署报销",
            location="上海",
            amount=Decimal("3000.00"),
            currency="CNY",
            invoice_count=1,
            occurred_at=datetime(2026, 6, 21, 22, 30, tzinfo=UTC),
            submitted_at=None,
            status="draft",
            approval_stage="待提交",
            risk_flags_json=[
                {
                    "source": "application_handoff",
                    "event_type": "expense_application_to_reimbursement_draft",
                    "application_claim_id": application_claim.id,
                    "application_claim_no": application_claim.claim_no,
                }
            ],
        )
        db.add_all([application_claim, reimbursement_claim])
        db.commit()

        deleted = ExpenseClaimService(db).delete_claim(reimbursement_claim.id, admin_user)

        assert deleted is not None
        assert deleted.claim_no == "RDELETE01"
        assert db.get(ExpenseClaim, reimbursement_claim.id) is None
        db.refresh(application_claim)
        assert application_claim.status == "approved"
        assert application_claim.approval_stage == APPLICATION_LINK_STATUS_STAGE

        approval_flag = next(
            flag
            for flag in application_claim.risk_flags_json
            if isinstance(flag, dict) and flag.get("event_type") == "expense_application_approval"
        )
        assert "generated_draft_claim_id" not in approval_flag
        assert "generated_draft_claim_no" not in approval_flag

        sync_flag = next(
            flag
            for flag in application_claim.risk_flags_json
            if isinstance(flag, dict)
            and flag.get("event_type") == "expense_application_reimbursement_deleted"
        )
        assert sync_flag["source"] == "application_link_sync"
        assert sync_flag["severity"] == "info"
        assert sync_flag["actionability"] == "system_trace"
        assert (
            sync_flag["deleted_reimbursement_claim_id"] == "reimbursement-delete-linked-application"
        )
        assert sync_flag["deleted_reimbursement_claim_no"] == "RDELETE01"
        assert sync_flag["next_approval_stage"] == APPLICATION_LINK_STATUS_STAGE


def test_direct_manager_can_return_subordinate_claim_to_pending_submission() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="manager-return@example.com",
        name="李经理",
        role_codes=["manager"],
        is_admin=False,
    )

    with build_session() as db:
        manager = Employee(
            employee_no="E8100",
            name="李经理",
            email="manager-return@example.com",
        )
        employee = Employee(
            employee_no="E8101",
            name="张三",
            email="zhangsan-return@example.com",
            manager=manager,
        )
        db.add_all([manager, employee])
        db.flush()
        claim = ExpenseClaim(
            claim_no="EXP-RET-201",
            employee_id=employee.id,
            employee_name="张三",
            department_name="市场部",
            project_code="PRJ-A",
            expense_type="transport",
            reason="交通报销",
            location="上海",
            amount=Decimal("66.00"),
            currency="CNY",
            invoice_count=1,
            occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
            submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
            status="submitted",
            approval_stage="直属领导审批",
            risk_flags_json=[],
        )
        db.add(claim)
        db.commit()
        claim_id = claim.id

        returned = ExpenseClaimService(db).return_claim(
            claim_id, current_user, reason="请补充行程说明"
        )

        assert returned is not None
        assert returned.status == "returned"
        assert returned.approval_stage == "待提交"
        assert returned.submitted_at is None
        assert any(
            isinstance(flag, dict)
            and flag.get("source") == "manual_return"
            and flag.get("message") == "请补充行程说明"
            for flag in returned.risk_flags_json
        )


def test_direct_manager_can_approve_subordinate_claim_to_finance_review() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="manager-approve@example.com",
        name="李经理",
        role_codes=["manager"],
        is_admin=False,
    )

    with build_session() as db:
        manager = Employee(
            employee_no="E8110",
            name="李经理",
            email="manager-approve@example.com",
        )
        employee = Employee(
            employee_no="E8111",
            name="张三",
            email="zhangsan-approve@example.com",
            manager=manager,
        )
        db.add_all([manager, employee])
        db.flush()
        claim = ExpenseClaim(
            claim_no="EXP-APP-201",
            employee_id=employee.id,
            employee_name="张三",
            department_name="市场部",
            project_code="PRJ-A",
            expense_type="transport",
            reason="交通报销",
            location="上海",
            amount=Decimal("66.00"),
            currency="CNY",
            invoice_count=1,
            occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
            submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
            status="submitted",
            approval_stage="直属领导审批",
            risk_flags_json=[],
        )
        db.add(claim)
        db.commit()
        claim_id = claim.id

        approved = ExpenseClaimService(db).approve_claim(
            claim_id,
            current_user,
            opinion="情况属实，同意报销。",
        )

        assert approved is not None
        assert approved.status == "submitted"
        assert approved.approval_stage == "财务审批"
        assert approved.submitted_at is not None
        assert any(
            isinstance(flag, dict)
            and flag.get("source") == "manual_approval"
            and flag.get("event_type") == "expense_claim_approval"
            and flag.get("opinion") == "情况属实，同意报销。"
            and flag.get("previous_approval_stage") == "直属领导审批"
            and flag.get("next_approval_stage") == "财务审批"
            for flag in approved.risk_flags_json
        )


def test_manager_cannot_operate_own_claim_submitted_to_direct_manager() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="manager-own-approval@example.com",
        name="李经理",
        role_codes=["manager"],
        is_admin=False,
    )

    with build_session() as db:
        superior = Employee(
            employee_no="E8112",
            name="王总",
            email="superior-own-approval@example.com",
        )
        manager = Employee(
            employee_no="E8113",
            name="李经理",
            email="manager-own-approval@example.com",
            manager=superior,
        )
        db.add_all([superior, manager])
        db.flush()
        claim = ExpenseClaim(
            claim_no="EXP-APP-SELF-201",
            employee_id=manager.id,
            employee_name="李经理",
            department_name="市场部",
            project_code="PRJ-A",
            expense_type="transport",
            reason="交通报销",
            location="上海",
            amount=Decimal("66.00"),
            currency="CNY",
            invoice_count=1,
            occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
            submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
            status="submitted",
            approval_stage="直属领导审批",
            risk_flags_json=[],
        )
        db.add(claim)
        db.commit()
        claim_id = claim.id
        service = ExpenseClaimService(db)

        with pytest.raises(LookupError, match="Approval task not found"):
            service.approve_claim(claim_id, current_user, opinion="同意")

        with pytest.raises(LookupError, match="Approval task not found"):
            service.return_claim(claim_id, current_user, reason="退回")

        db.refresh(claim)
        assert claim.status == "submitted"
        assert claim.approval_stage == "直属领导审批"
        assert claim.risk_flags_json == []


def test_direct_manager_budget_monitor_routes_reimbursement_directly_to_finance() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="manager-budget-monitor-reimbursement@example.com",
        name="李预算经理",
        role_codes=["manager", "budget_monitor", "executive"],
        is_admin=False,
    )

    with build_session() as db:
        budget_role = _seed_budget_monitor_role(db)
        department = OrganizationUnit(
            unit_code="DELIVERY-REIMBURSEMENT-MERGED",
            name="交付部",
            unit_type="department",
        )
        manager = Employee(
            employee_no="E-RB-MERGED-MGR",
            name="李预算经理",
            email="manager-budget-monitor-reimbursement@example.com",
            grade="P8",
            organization_unit=department,
            roles=[budget_role],
        )
        employee = Employee(
            employee_no="E-RB-MERGED-EMP",
            name="张三",
            email="zhangsan-budget-monitor-reimbursement@example.com",
            manager=manager,
            organization_unit=department,
        )
        db.add_all([department, manager, employee])
        db.flush()
        claim = ExpenseClaim(
            claim_no="RE-20260525-MERGED",
            employee_id=employee.id,
            employee_name="张三",
            department_id=department.id,
            department_name="交付部",
            project_code="PRJ-A",
            expense_type="travel",
            reason="上海出差报销",
            location="上海",
            amount=Decimal("3020.00"),
            currency="CNY",
            invoice_count=3,
            occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
            submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
            status="submitted",
            approval_stage="直属领导审批",
            risk_flags_json=[
                {
                    "source": "submission_review",
                    "severity": "high",
                    "actionability": "route_review",
                    "label": "报销风险复核",
                    "message": "多城市行程和住宿超标需要预算管理者二次确认。",
                }
            ],
        )
        db.add(claim)
        db.commit()

        approved = ExpenseClaimService(db).approve_claim(
            claim.id,
            current_user,
            opinion="业务必要且预算可承接，同意报销。",
        )

        assert approved is not None
        assert approved.status == "submitted"
        assert approved.approval_stage == "财务审批"
        assert not any(
            isinstance(flag, dict) and flag.get("next_approval_stage") == "预算管理者审批"
            for flag in approved.risk_flags_json
        )
        assert any(
            isinstance(flag, dict)
            and flag.get("source") == "manual_approval"
            and flag.get("event_type") == "expense_claim_approval"
            and flag.get("label") == "领导及预算审核通过"
            and flag.get("opinion") == "业务必要且预算可承接，同意报销。"
            and flag.get("previous_approval_stage") == "直属领导审批"
            and flag.get("next_status") == "submitted"
            and flag.get("next_approval_stage") == "财务审批"
            and flag.get("budget_approval_merged") is True
            and flag.get("budget_approval_merged_reason")
            == "direct_manager_is_department_budget_approver"
            for flag in approved.risk_flags_json
        )


def test_legacy_duplicate_budget_stage_is_not_mutated_by_read() -> None:
    admin_user = CurrentUserContext(
        tenant_id="default",
        username="admin",
        name="admin",
        role_codes=["admin"],
        is_admin=True,
    )

    with build_session() as db:
        budget_role = _seed_budget_monitor_role(db)
        department = OrganizationUnit(
            unit_code="DELIVERY-LEGACY-REPAIR",
            name="交付部",
            unit_type="department",
        )
        manager = Employee(
            employee_no="E-LEGACY-MGR",
            name="李预算经理",
            email="manager-legacy-repair@example.com",
            grade="P8",
            organization_unit=department,
            roles=[budget_role],
        )
        employee = Employee(
            employee_no="E-LEGACY-EMP",
            name="张三",
            email="zhangsan-legacy-repair@example.com",
            manager=manager,
            organization_unit=department,
        )
        db.add_all([department, manager, employee])
        db.flush()
        claim = ExpenseClaim(
            claim_no="RE-20260525-LEGACY",
            employee_id=employee.id,
            employee_name=employee.name,
            department_id=department.id,
            department_name=department.name,
            project_code="PRJ-A",
            expense_type="travel",
            reason="上海出差报销",
            location="上海",
            amount=Decimal("3020.00"),
            currency="CNY",
            invoice_count=3,
            occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
            submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
            status="submitted",
            approval_stage=BUDGET_MANAGER_APPROVAL_STAGE,
            risk_flags_json=[
                {
                    "source": "manual_approval",
                    "event_type": "expense_claim_approval",
                    "approval_event_id": "legacy-approval-event",
                    "operator": manager.name,
                    "operator_username": manager.email,
                    "previous_approval_stage": DIRECT_MANAGER_APPROVAL_STAGE,
                    "next_approval_stage": BUDGET_MANAGER_APPROVAL_STAGE,
                    "next_approver_name": manager.name,
                    "next_approver_employee_id": manager.id,
                }
            ],
        )
        db.add(claim)
        db.commit()

        result = ExpenseClaimService(db).get_claim(claim.id, admin_user)

        assert result is not None
        assert result.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE
        assert not any(
            isinstance(flag, dict)
            and flag.get("source") == "approval_flow_repair"
            and flag.get("event_type") == "duplicate_budget_approval_stage_repaired"
            for flag in result.risk_flags_json
        )
        db.expire_all()
        persisted = db.get(ExpenseClaim, claim.id)
        assert persisted is not None
        assert persisted.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE


def test_application_submit_skips_ai_review_and_receipt_requirements(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="application-owner@example.com",
        name="张三",
        role_codes=["employee"],
        is_admin=True,
    )

    with build_session() as db:
        manager = Employee(
            tenant_id="default",
            employee_no="APP-SUBMIT-MANAGER",
            name="李经理",
            email="application-submit-manager@example.com",
        )
        employee = Employee(
            tenant_id="default",
            employee_no="APP-SUBMIT-OWNER",
            name="张三",
            email=current_user.username,
            manager=manager,
        )
        claim = ExpenseClaim(
            tenant_id="default",
            claim_no="APP-20260525-SUBMIT",
            employee_name="张三",
            department_name="交付部",
            project_code="PRJ-A",
            expense_type="travel_application",
            reason="支撑国网服务器上线部署",
            location="上海",
            amount=Decimal("12000.00"),
            currency="CNY",
            invoice_count=0,
            occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
            submitted_at=None,
            status="draft",
            approval_stage="待提交",
            risk_flags_json=[
                {
                    "source": "platform_risk",
                    "severity": "medium",
                    "message": "旧 AI 预审提示不应保留到申请单提交结果。",
                }
            ],
        )
        claim.employee = employee
        db.add_all([manager, employee, claim])
        db.commit()
        claim_id = claim.id
        service = ExpenseClaimService(db)

        def fail_ai_review(_claim: ExpenseClaim) -> dict[str, object]:
            raise AssertionError("费用申请提交不应进入 AI 预审")

        monkeypatch.setattr(service, "_run_ai_submission_review", fail_ai_review)

        submitted = service.submit_claim(claim_id, current_user)

        assert submitted is not None
        assert submitted.status == "submitted"
        assert submitted.approval_stage == "直属领导审批"
        assert submitted.invoice_count == 0
        assert submitted.items == []
        assert not any(
            isinstance(flag, dict) and flag.get("source") == "submission_review"
            for flag in submitted.risk_flags_json
        )
        assert any(
            isinstance(flag, dict)
            and flag.get("source") == "application_submission"
            and flag.get("event_type") == "expense_application_submission"
            and flag.get("next_approval_stage") == "直属领导审批"
            for flag in submitted.risk_flags_json
        )


def test_application_submit_reserves_budget_once() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="application-budget-owner@example.com",
        name="张三",
        role_codes=["employee"],
        is_admin=True,
    )

    with build_session() as db:
        _seed_budget_allocation(
            db,
            department_id="dept-budget",
            department_name="交付部",
            amount=Decimal("50000.00"),
        )
        _seed_submitter_with_manager(
            db,
            employee_id="emp-budget",
            email=current_user.username,
        )
        claim = ExpenseClaim(
            tenant_id="default",
            claim_no="APP-20260525-BUDGET",
            employee_id="emp-budget",
            employee_name="张三",
            department_id="dept-budget",
            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, 9, 0, tzinfo=UTC),
            submitted_at=None,
            status="draft",
            approval_stage="待提交",
            risk_flags_json=[],
        )
        db.add(claim)
        db.commit()

        submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)

        assert submitted is not None
        reservations = db.query(BudgetReservation).all()
        assert len(reservations) == 1
        assert reservations[0].source_type == "application"
        assert reservations[0].source_id == claim.id
        assert reservations[0].amount == Decimal("12000.00")
        transactions = db.query(BudgetTransaction).all()
        assert any(item.transaction_type == "reserve" for item in transactions)
        assert any(
            isinstance(flag, dict)
            and flag.get("source") == "budget_control"
            and flag.get("event_type") == "budget_reserved"
            for flag in submitted.risk_flags_json
        )


def test_application_submit_blocks_when_budget_insufficient_without_state_change() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="application-budget-block@example.com",
        name="张三",
        role_codes=["employee"],
        is_admin=True,
    )

    with build_session() as db:
        _seed_budget_allocation(
            db,
            department_id="dept-budget-block",
            department_name="交付部",
            amount=Decimal("1000.00"),
        )
        claim = ExpenseClaim(
            claim_no="APP-20260525-BLOCK",
            employee_id="emp-budget-block",
            employee_name="张三",
            department_id="dept-budget-block",
            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, 9, 0, tzinfo=UTC),
            submitted_at=None,
            status="draft",
            approval_stage="待提交",
            risk_flags_json=[],
        )
        db.add(claim)
        db.commit()

        with pytest.raises(ValueError):
            ExpenseClaimService(db).submit_claim(claim.id, current_user)

        db.refresh(claim)
        assert claim.status == "draft"
        assert db.query(BudgetReservation).count() == 0
        assert db.query(BudgetTransaction).count() == 0


def test_reimbursement_submit_keeps_budget_insufficient_as_review_risk() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="reimbursement-budget-risk@example.com",
        name="张三",
        role_codes=["employee"],
        is_admin=True,
    )

    with build_session() as db:
        _seed_budget_allocation(
            db,
            department_id="dept-1",
            department_name="市场部",
            subject_code="office",
            amount=Decimal("1000.00"),
        )
        employee = _seed_submitter_with_manager(
            db,
            employee_id="emp-reimbursement-budget-risk",
            email=current_user.username,
        )
        claim = build_claim(expense_type="office", location="待补充")
        claim.employee = employee
        claim.amount = Decimal("1200.00")
        claim.items[0].item_amount = Decimal("1200.00")
        db.add(claim)
        db.commit()

        submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)

        assert submitted is not None
        assert submitted.status == "submitted"
        assert submitted.submitted_at is not None
        assert any(
            isinstance(flag, dict)
            and flag.get("source") == "budget_control"
            and flag.get("event_type") == "budget_insufficient"
            and flag.get("business_stage") == "reimbursement"
            for flag in submitted.risk_flags_json
        )
        assert db.query(BudgetReservation).count() == 0
        assert db.query(BudgetTransaction).count() == 0


def test_application_submit_skips_budget_for_non_demo_subject() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="application-budget-skip@example.com",
        name="张三",
        role_codes=["employee"],
        is_admin=True,
    )

    with build_session() as db:
        _seed_budget_allocation(
            db,
            department_id="dept-budget-skip",
            department_name="交付部",
            amount=Decimal("1000.00"),
        )
        _seed_submitter_with_manager(
            db,
            employee_id="emp-budget-skip",
            email=current_user.username,
        )
        claim = ExpenseClaim(
            tenant_id="default",
            claim_no="APP-20260525-SKIP",
            employee_id="emp-budget-skip",
            employee_name="张三",
            department_id="dept-budget-skip",
            department_name="交付部",
            project_code=None,
            expense_type="software_application",
            reason="采购演示软件服务",
            location="深圳",
            amount=Decimal("12000.00"),
            currency="CNY",
            invoice_count=0,
            occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
            submitted_at=None,
            status="draft",
            approval_stage="待提交",
            risk_flags_json=[],
        )
        db.add(claim)
        db.commit()

        submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)

        assert submitted is not None
        assert submitted.status == "submitted"
        assert submitted.approval_stage == "直属领导审批"
        assert db.query(BudgetReservation).count() == 0
        assert db.query(BudgetTransaction).count() == 0
        assert not any(
            isinstance(flag, dict) and str(flag.get("source") or "").strip() == "budget_control"
            for flag in submitted.risk_flags_json
        )


def test_direct_manager_routes_application_then_budget_manager_creates_draft() -> None:
    manager_user = CurrentUserContext(
        tenant_id="default",
        username="manager-application-approve@example.com",
        name="李经理",
        role_codes=["manager"],
        is_admin=False,
    )
    budget_user = CurrentUserContext(
        tenant_id="default",
        username="budget-p8-application-approve@example.com",
        name="赵预算",
        role_codes=["budget_monitor"],
        is_admin=False,
    )

    with build_session() as db:
        budget_role = _seed_budget_monitor_role(db)
        department = OrganizationUnit(
            unit_code="DELIVERY-BUDGET-APPROVE",
            name="交付部",
            unit_type="department",
        )
        manager = Employee(
            employee_no="E8112",
            name="李经理",
            email="manager-application-approve@example.com",
            organization_unit=department,
        )
        budget_manager = Employee(
            employee_no="E8112-BUDGET",
            name="赵预算",
            email="budget-p8-application-approve@example.com",
            grade="P8",
            organization_unit=department,
            roles=[budget_role],
        )
        employee = Employee(
            employee_no="E8113",
            name="张三",
            email="zhangsan-application-approve@example.com",
            manager=manager,
            organization_unit=department,
        )
        db.add_all([department, manager, budget_manager, employee])
        db.flush()
        claim = ExpenseClaim(
            claim_no="APP-20260525-APPROVE",
            employee_id=employee.id,
            employee_name="张三",
            department_id=department.id,
            department_name="交付部",
            project_code="PRJ-A",
            expense_type="travel_application",
            reason="支撑国网服务器上线部署",
            location="上海",
            amount=Decimal("12000.00"),
            currency="CNY",
            invoice_count=0,
            occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
            submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
            status="submitted",
            approval_stage="直属领导审批",
            risk_flags_json=[
                {
                    "source": "application_detail",
                    "application_detail": {
                        "application_type": "差旅费用申请",
                        "time": "2026-05-25 至 2026-05-27",
                        "location": "上海",
                        "reason": "支撑国网服务器上线部署",
                        "days": "3 天",
                        "transport_mode": "高铁",
                        "lodging_daily_cap": "600元/天",
                        "subsidy_daily_cap": "120元/天",
                        "transport_policy": "按真实票据复核",
                        "policy_estimate": "交通按真实票据 + 住宿 1,800元 + 补贴 360元",
                        "rule_name": "差旅标准规则",
                        "rule_version": "2026.05",
                        "amount": "12000.00",
                    },
                },
                {
                    "source": "submission_review",
                    "severity": "high",
                    "actionability": "route_review",
                    "label": "申请风险复核",
                    "message": "申请金额和行程安排需要预算管理者二次确认。",
                },
            ],
        )
        db.add(claim)
        db.commit()
        claim_id = claim.id

        leader_approved = ExpenseClaimService(db).approve_claim(
            claim_id,
            manager_user,
            opinion="业务必要，同意申请。",
        )

        assert leader_approved is not None
        assert leader_approved.status == "submitted"
        assert leader_approved.approval_stage == "预算管理者审批"
        assert reimbursement_claim_query(db).count() == 0
        assert any(
            isinstance(flag, dict)
            and flag.get("source") == "manual_approval"
            and flag.get("event_type") == "expense_application_approval"
            and flag.get("opinion") == "业务必要，同意申请。"
            and flag.get("previous_approval_stage") == "直属领导审批"
            and flag.get("next_status") == "submitted"
            and flag.get("next_approval_stage") == "预算管理者审批"
            and flag.get("next_approver_name") == "赵预算"
            and flag.get("next_approver_grade") == "P8"
            for flag in leader_approved.risk_flags_json
        )

        approved = ExpenseClaimService(db).approve_claim(
            claim_id,
            budget_user,
            opinion="预算额度可承接，同意。",
        )

        assert approved is not None
        assert approved.status == "approved"
        assert approved.approval_stage == "关联单据状态"
        archived_claims = ExpenseClaimService(db).list_archived_claims(
            CurrentUserContext(
                tenant_id="default",
                username="finance-archive@example.com",
                name="财务归档员",
                role_codes=["finance"],
                is_admin=False,
            )
        )
        assert all(claim.claim_no != "APP-20260525-APPROVE" for claim in archived_claims)
        generated_draft = reimbursement_claim_query(db).one()
        assert generated_draft.status == "draft"
        assert generated_draft.approval_stage == "待提交"
        assert generated_draft.expense_type == "travel"
        assert generated_draft.employee_id == employee.id
        assert generated_draft.employee_name == "张三"
        assert generated_draft.department_name == "交付部"
        assert generated_draft.reason == "支撑国网服务器上线部署"
        assert generated_draft.location == "上海"
        assert generated_draft.amount == Decimal("12000.00")
        assert generated_draft.invoice_count == 0
        assert generated_draft.items == []
        assert any(
            isinstance(flag, dict)
            and flag.get("source") == "application_handoff"
            and flag.get("event_type") == "expense_application_to_reimbursement_draft"
            and flag.get("application_claim_no") == "APP-20260525-APPROVE"
            and flag.get("application_detail", {}).get("application_content")
            == "差旅费用申请 / 上海"
            and flag.get("application_detail", {}).get("application_reason")
            == "支撑国网服务器上线部署"
            and flag.get("application_detail", {}).get("application_days") == "3 天"
            and flag.get("application_detail", {}).get("application_transport_mode") == "高铁"
            and flag.get("application_detail", {}).get("application_lodging_daily_cap")
            == "600元/天"
            and flag.get("application_detail", {}).get("application_subsidy_daily_cap")
            == "120元/天"
            and flag.get("application_detail", {}).get("application_transport_policy")
            == "按真实票据复核"
            and flag.get("application_detail", {}).get("application_policy_estimate")
            == "交通按真实票据 + 住宿 1,800元 + 补贴 360元"
            and flag.get("application_detail", {}).get("application_rule_name") == "差旅标准规则"
            and flag.get("application_detail", {}).get("application_rule_version") == "2026.05"
            and flag.get("leader_opinion") == "业务必要，同意申请。"
            and flag.get("budget_opinion") == "预算额度可承接，同意。"
            for flag in generated_draft.risk_flags_json
        )
        assert any(
            isinstance(flag, dict)
            and flag.get("source") == "budget_approval"
            and flag.get("event_type") == "expense_application_budget_approval"
            and flag.get("opinion") == "预算额度可承接，同意。"
            and flag.get("previous_approval_stage") == "预算管理者审批"
            and flag.get("next_status") == "approved"
            and flag.get("next_approval_stage") == "关联单据状态"
            and flag.get("generated_draft_claim_id") == generated_draft.id
            and flag.get("generated_draft_claim_no") == generated_draft.claim_no
            for flag in approved.risk_flags_json
        )


def test_application_routes_to_department_p8_executive_with_approver_name() -> None:
    manager_user = CurrentUserContext(
        tenant_id="default",
        username="manager-executive-route@example.com",
        name="Manager",
        role_codes=["manager"],
        is_admin=False,
    )
    budget_user = CurrentUserContext(
        tenant_id="default",
        username="p8-executive-route@example.com",
        name="P8 Executive",
        role_codes=["executive"],
        is_admin=False,
    )

    with build_session() as db:
        executive_role = _seed_executive_role(db)
        department = OrganizationUnit(
            unit_code="DELIVERY-EXECUTIVE-ROUTE",
            name="Engineering",
            unit_type="department",
        )
        manager = Employee(
            employee_no="E-EXEC-ROUTE-MGR",
            name="Manager",
            email="manager-executive-route@example.com",
            organization_unit=department,
        )
        budget_manager = Employee(
            employee_no="E-EXEC-ROUTE-P8",
            name="P8 Executive",
            email="p8-executive-route@example.com",
            grade="P8",
            organization_unit=department,
            roles=[executive_role],
        )
        employee = Employee(
            employee_no="E-EXEC-ROUTE-APP",
            name="Applicant",
            email="applicant-executive-route@example.com",
            manager=manager,
            organization_unit=department,
        )
        db.add_all([department, manager, budget_manager, employee])
        db.flush()
        claim = ExpenseClaim(
            claim_no="APP-20260531-EXEC-ROUTE",
            employee_id=employee.id,
            employee_name=employee.name,
            department_id=department.id,
            department_name=department.name,
            project_code="PRJ-A",
            expense_type="travel_application",
            reason="Production deployment support",
            location="Beijing",
            amount=Decimal("12000.00"),
            currency="CNY",
            invoice_count=0,
            occurred_at=datetime(2026, 5, 31, 9, 0, tzinfo=UTC),
            submitted_at=datetime(2026, 5, 31, 10, 0, tzinfo=UTC),
            status="submitted",
            approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
            risk_flags_json=[
                {
                    "source": "submission_review",
                    "severity": "high",
                    "actionability": "route_review",
                    "label": "Route risk",
                    "message": "Application requires budget confirmation.",
                }
            ],
        )
        db.add(claim)
        db.commit()
        claim_id = claim.id

        routed = ExpenseClaimService(db).approve_claim(
            claim_id,
            manager_user,
            opinion="Approved by direct manager.",
        )

        assert routed is not None
        assert routed.status == "submitted"
        assert routed.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE
        assert getattr(routed, "budget_approver_name", "") == "P8 Executive"
        assert getattr(routed, "budget_approver_grade", "") == "P8"
        assert getattr(routed, "budget_approver_role_code", "") == "executive"
        assert any(
            isinstance(flag, dict)
            and flag.get("source") == "manual_approval"
            and flag.get("next_approval_stage") == BUDGET_MANAGER_APPROVAL_STAGE
            and flag.get("next_approver_name") == "P8 Executive"
            and flag.get("next_approver_grade") == "P8"
            and flag.get("next_approver_role_code") == "executive"
            for flag in routed.risk_flags_json
        )

        approved = ExpenseClaimService(db).approve_claim(
            claim_id,
            budget_user,
            opinion="Budget confirmed.",
        )

        assert approved is not None
        assert approved.status == "approved"
        assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE


def test_direct_manager_cannot_route_application_to_missing_budget_approver() -> None:
    manager_user = CurrentUserContext(
        tenant_id="default",
        username="manager-missing-budget@example.com",
        name="Manager",
        role_codes=["manager"],
        is_admin=False,
    )

    with build_session() as db:
        department = OrganizationUnit(
            unit_code="DELIVERY-MISSING-BUDGET",
            name="Engineering",
            unit_type="department",
        )
        manager = Employee(
            employee_no="E-MISSING-BUDGET-MGR",
            name="Manager",
            email="manager-missing-budget@example.com",
            organization_unit=department,
        )
        employee = Employee(
            employee_no="E-MISSING-BUDGET-APP",
            name="Applicant",
            email="applicant-missing-budget@example.com",
            manager=manager,
            organization_unit=department,
        )
        db.add_all([department, manager, employee])
        db.flush()
        claim = ExpenseClaim(
            claim_no="APP-20260531-MISSING-BUDGET",
            employee_id=employee.id,
            employee_name=employee.name,
            department_id=department.id,
            department_name=department.name,
            project_code="PRJ-A",
            expense_type="travel_application",
            reason="Production deployment support",
            location="Beijing",
            amount=Decimal("12000.00"),
            currency="CNY",
            invoice_count=0,
            occurred_at=datetime(2026, 5, 31, 9, 0, tzinfo=UTC),
            submitted_at=datetime(2026, 5, 31, 10, 0, tzinfo=UTC),
            status="submitted",
            approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
            risk_flags_json=[
                {
                    "source": "submission_review",
                    "severity": "high",
                    "actionability": "route_review",
                    "label": "Route risk",
                    "message": "Application requires budget confirmation.",
                }
            ],
        )
        db.add(claim)
        db.commit()
        claim_id = claim.id

        with pytest.raises(ValueError, match="未找到同部门 P8 预算审批人"):
            ExpenseClaimService(db).approve_claim(
                claim_id,
                manager_user,
                opinion="Approved by direct manager.",
            )

        db.refresh(claim)
        assert claim.status == "submitted"
        assert claim.approval_stage == DIRECT_MANAGER_APPROVAL_STAGE
        assert reimbursement_claim_query(db).count() == 0


def test_direct_manager_p8_executive_completes_application_without_duplicate_budget_approval() -> (
    None
):
    manager_user = CurrentUserContext(
        tenant_id="default",
        username="manager-executive-merged@example.com",
        name="P8 Manager",
        role_codes=["manager"],
        is_admin=False,
    )

    with build_session() as db:
        executive_role = _seed_executive_role(db)
        department = OrganizationUnit(
            unit_code="DELIVERY-EXECUTIVE-MERGED",
            name="Engineering",
            unit_type="department",
        )
        manager = Employee(
            employee_no="E-EXEC-MERGED-MGR",
            name="P8 Manager",
            email="manager-executive-merged@example.com",
            grade="P8",
            organization_unit=department,
            roles=[executive_role],
        )
        employee = Employee(
            employee_no="E-EXEC-MERGED-APP",
            name="Applicant",
            email="applicant-executive-merged@example.com",
            manager=manager,
            organization_unit=department,
        )
        db.add_all([department, manager, employee])
        db.flush()
        claim = ExpenseClaim(
            claim_no="APP-20260531-EXEC-MERGED",
            employee_id=employee.id,
            employee_name=employee.name,
            department_id=department.id,
            department_name=department.name,
            project_code="PRJ-A",
            expense_type="travel_application",
            reason="Production deployment support",
            location="Beijing",
            amount=Decimal("12000.00"),
            currency="CNY",
            invoice_count=0,
            occurred_at=datetime(2026, 5, 31, 9, 0, tzinfo=UTC),
            submitted_at=datetime(2026, 5, 31, 10, 0, tzinfo=UTC),
            status="submitted",
            approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
            risk_flags_json=[
                {
                    "source": "submission_review",
                    "severity": "high",
                    "actionability": "route_review",
                    "label": "Route risk",
                    "message": "Application requires budget confirmation.",
                }
            ],
        )
        db.add(claim)
        db.commit()
        claim_id = claim.id

        approved = ExpenseClaimService(db).approve_claim(
            claim_id,
            manager_user,
            opinion="Approved by direct manager and budget owner.",
        )

        assert approved is not None
        assert approved.status == "approved"
        assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
        assert reimbursement_claim_query(db).count() == 1
        assert not any(
            isinstance(flag, dict)
            and flag.get("next_approval_stage") == BUDGET_MANAGER_APPROVAL_STAGE
            for flag in approved.risk_flags_json
        )
        assert any(
            isinstance(flag, dict)
            and flag.get("source") == "manual_approval"
            and flag.get("next_status") == "approved"
            and flag.get("next_approval_stage") == APPLICATION_LINK_STATUS_STAGE
            and flag.get("budget_approval_merged") is True
            and flag.get("budget_approval_merged_reason")
            == "direct_manager_is_department_budget_approver"
            for flag in approved.risk_flags_json
        )


def test_direct_manager_budget_monitor_avoids_duplicate_budget_approval() -> None:
    manager_user = CurrentUserContext(
        tenant_id="default",
        username="manager-budget-monitor-application@example.com",
        name="李预算经理",
        role_codes=["manager", "budget_monitor", "executive"],
        is_admin=False,
    )

    with build_session() as db:
        budget_role = _seed_budget_monitor_role(db)
        department = OrganizationUnit(
            unit_code="DELIVERY-BUDGET-MERGED",
            name="交付部",
            unit_type="department",
        )
        manager = Employee(
            employee_no="E8112-MERGED",
            name="李预算经理",
            email="manager-budget-monitor-application@example.com",
            grade="P8",
            organization_unit=department,
            roles=[budget_role],
        )
        employee = Employee(
            employee_no="E8113-MERGED",
            name="张三",
            email="zhangsan-budget-monitor-application@example.com",
            manager=manager,
            organization_unit=department,
        )
        db.add_all([department, manager, employee])
        db.flush()
        claim = ExpenseClaim(
            claim_no="APP-20260525-MERGED",
            employee_id=employee.id,
            employee_name="张三",
            department_id=department.id,
            department_name="交付部",
            project_code="PRJ-A",
            expense_type="travel_application",
            reason="支撑国网服务器上线部署",
            location="上海",
            amount=Decimal("12000.00"),
            currency="CNY",
            invoice_count=0,
            occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
            submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
            status="submitted",
            approval_stage="直属领导审批",
            risk_flags_json=[
                {
                    "source": "submission_review",
                    "severity": "high",
                    "actionability": "route_review",
                    "label": "申请风险复核",
                    "message": "申请金额和行程安排需要预算管理者二次确认。",
                }
            ],
        )
        db.add(claim)
        db.commit()
        claim_id = claim.id

        approved = ExpenseClaimService(db).approve_claim(
            claim_id,
            manager_user,
            opinion="业务必要且预算可承接，同意申请。",
        )

        assert approved is not None
        assert approved.status == "approved"
        assert approved.approval_stage == "关联单据状态"
        assert reimbursement_claim_query(db).count() == 1
        assert not any(
            isinstance(flag, dict) and flag.get("next_approval_stage") == "预算管理者审批"
            for flag in approved.risk_flags_json
        )
        assert any(
            isinstance(flag, dict)
            and flag.get("source") == "manual_approval"
            and flag.get("event_type") == "expense_application_approval"
            and flag.get("label") == "领导及预算审核通过"
            and flag.get("opinion") == "业务必要且预算可承接，同意申请。"
            and flag.get("previous_approval_stage") == "直属领导审批"
            and flag.get("next_status") == "approved"
            and flag.get("next_approval_stage") == "关联单据状态"
            and flag.get("budget_approval_merged") is True
            and flag.get("budget_approval_merged_reason")
            == "direct_manager_is_department_budget_approver"
            for flag in approved.risk_flags_json
        )
        generated_draft = reimbursement_claim_query(db).one()
        assert generated_draft.status == "draft"
        assert generated_draft.expense_type == "travel"
        reviewer_claims = ExpenseClaimService(db).list_claims(manager_user)
        assert all(claim.claim_no != generated_draft.claim_no for claim in reviewer_claims)
        applicant_claims = ExpenseClaimService(db).list_claims(
            CurrentUserContext(
                tenant_id="default",
                username="zhangsan-budget-monitor-application@example.com",
                name="张三",
                role_codes=[],
                is_admin=False,
            )
        )
        assert any(claim.claim_no == generated_draft.claim_no for claim in applicant_claims)
        assert any(
            isinstance(flag, dict)
            and flag.get("source") == "application_handoff"
            and flag.get("event_type") == "expense_application_to_reimbursement_draft"
            and flag.get("application_claim_no") == "APP-20260525-MERGED"
            and flag.get("leader_opinion") == "业务必要且预算可承接，同意申请。"
            and flag.get("budget_opinion") == "业务必要且预算可承接，同意申请。"
            for flag in generated_draft.risk_flags_json
        )


def test_direct_manager_return_application_claim_records_return_node_and_opinion() -> None:
    manager_user = CurrentUserContext(
        tenant_id="default",
        username="manager-application-return@example.com",
        name="李经理",
        role_codes=["manager"],
        is_admin=False,
    )

    with build_session() as db:
        manager = Employee(
            employee_no="E8114",
            name="李经理",
            email="manager-application-return@example.com",
        )
        employee = Employee(
            employee_no="E8115",
            name="张三",
            email="zhangsan-application-return@example.com",
            manager=manager,
        )
        db.add_all([manager, employee])
        db.flush()
        claim = ExpenseClaim(
            claim_no="APP-20260525-RETURN",
            employee_id=employee.id,
            employee_name="张三",
            department_name="交付部",
            project_code="PRJ-A",
            expense_type="travel_application",
            reason="支撑国网服务器上线部署",
            location="上海",
            amount=Decimal("12000.00"),
            currency="CNY",
            invoice_count=0,
            occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
            submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
            status="submitted",
            approval_stage="直属领导审批",
            risk_flags_json=[],
        )
        db.add(claim)
        db.commit()

        with pytest.raises(ValueError, match="退单类型"):
            ExpenseClaimService(db).return_claim(
                claim.id,
                manager_user,
                reason="预算说明不够清楚，请补充项目必要性。",
            )
        db.refresh(claim)
        assert claim.status == "submitted"
        assert claim.risk_flags_json == []

        returned = ExpenseClaimService(db).return_claim(
            claim.id,
            manager_user,
            reason="预算说明不够清楚，请补充项目必要性。",
            reason_codes=["application_budget_basis_missing"],
        )

        assert returned is not None
        assert returned.status == "returned"
        assert returned.approval_stage == "待提交"
        return_event = next(
            flag
            for flag in returned.risk_flags_json
            if isinstance(flag, dict) and flag.get("event_type") == "expense_application_return"
        )
        assert return_event["label"] == "领导退回"
        assert return_event["node_key"] == "returned"
        assert return_event["node_label"] == "退回"
        assert return_event["approval_node"] == "退回"
        assert return_event["operator"] == "李经理"
        assert return_event["opinion"] == "预算说明不够清楚，请补充项目必要性。"
        assert return_event["leader_opinion"] == "预算说明不够清楚，请补充项目必要性。"
        assert return_event["return_stage"] == "直属领导审批"
        assert return_event["return_stage_key"] == "direct_manager"
        assert return_event["reason_codes"] == ["application_budget_basis_missing"]
        assert return_event["risk_points"] == ["预算测算依据不足"]
        assert return_event["next_status"] == "returned"
        assert return_event["next_approval_stage"] == "待提交"


def test_application_approval_transfers_budget_reservation_to_reimbursement_draft() -> None:
    owner = CurrentUserContext(
        tenant_id="default",
        username="application-budget-owner-approve@example.com",
        name="张三",
        role_codes=["employee"],
        is_admin=False,
    )
    manager_user = CurrentUserContext(
        tenant_id="default",
        username="manager-application-budget@example.com",
        name="李经理",
        role_codes=["manager"],
        is_admin=False,
    )
    budget_user = CurrentUserContext(
        tenant_id="default",
        username="budget-p8-transfer@example.com",
        name="赵预算",
        role_codes=["budget_monitor"],
        is_admin=False,
    )

    with build_session() as db:
        budget_role = _seed_budget_monitor_role(db)
        department = OrganizationUnit(
            id="dept-budget-transfer",
            unit_code="DELIVERY-BUDGET-TRANSFER",
            name="交付部",
            unit_type="department",
        )
        manager = Employee(
            employee_no="M-BUDGET-APP",
            name="李经理",
            email="manager-application-budget@example.com",
            organization_unit=department,
        )
        budget_manager = Employee(
            employee_no="P8-BUDGET-APP",
            name="赵预算",
            email="budget-p8-transfer@example.com",
            grade="P8",
            organization_unit=department,
            roles=[budget_role],
        )
        employee = Employee(
            employee_no="E-BUDGET-APP",
            name="张三",
            email="application-budget-owner-approve@example.com",
            manager=manager,
            organization_unit=department,
        )
        db.add_all([department, manager, budget_manager, employee])
        db.flush()
        _seed_budget_allocation(
            db,
            department_id="dept-budget-transfer",
            department_name="交付部",
            amount=Decimal("50000.00"),
        )
        claim = ExpenseClaim(
            claim_no="APP-20260525-TRANSFER",
            employee_id=employee.id,
            employee_name="张三",
            department_id="dept-budget-transfer",
            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, 9, 0, tzinfo=UTC),
            submitted_at=None,
            status="draft",
            approval_stage="待提交",
            risk_flags_json=[
                {
                    "source": "platform_risk",
                    "severity": "high",
                    "actionability": "route_review",
                    "label": "申请风险复核",
                    "message": "申请金额和行程安排需要预算管理者二次确认。",
                }
            ],
        )
        db.add(claim)
        db.commit()
        service = ExpenseClaimService(db)

        service.submit_claim(claim.id, owner)
        leader_approved = service.approve_claim(claim.id, manager_user, opinion="同意申请")
        reservation = db.query(BudgetReservation).one()

        assert leader_approved is not None
        assert leader_approved.approval_stage == "预算管理者审批"
        assert reservation.source_type == "application"
        assert reservation.source_id == claim.id
        assert reimbursement_claim_query(db).count() == 0

        approved = service.approve_claim(claim.id, budget_user, opinion="预算通过")
        generated_draft = reimbursement_claim_query(db).one()
        db.refresh(reservation)

        assert approved is not None
        assert reservation.source_type == "claim"
        assert reservation.source_id == generated_draft.id
        assert reservation.source_no == generated_draft.claim_no
        assert any(
            item.transaction_type == "transfer" for item in db.query(BudgetTransaction).all()
        )
        assert any(
            isinstance(flag, dict) and flag.get("event_type") == "budget_reservation_transferred"
            for flag in generated_draft.risk_flags_json
        )

        deleted = service.delete_claim(
            generated_draft.id,
            CurrentUserContext(
                tenant_id="default",
                username="browser-session-user",
                name="",
                role_codes=["user"],
                is_admin=False,
                employee_no="E-BUDGET-APP",
            ),
        )
        db.refresh(reservation)

        assert deleted is not None
        assert db.get(ExpenseClaim, generated_draft.id) is None
        assert reservation.source_status == "released"
        assert reservation.released_amount == Decimal("12000.00")


def test_direct_manager_approval_defaults_blank_opinion_to_agree() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="manager-application-required-opinion@example.com",
        name="李经理",
        role_codes=["manager"],
        is_admin=False,
    )

    with build_session() as db:
        manager = Employee(
            employee_no="E8122",
            name="李经理",
            email="manager-application-required-opinion@example.com",
        )
        employee = Employee(
            employee_no="E8123",
            name="张三",
            email="zhangsan-application-required-opinion@example.com",
            manager=manager,
        )
        db.add_all([manager, employee])
        db.flush()
        claim = ExpenseClaim(
            claim_no="APP-20260525-REQUIRE-OPINION",
            employee_id=employee.id,
            employee_name="张三",
            department_name="交付部",
            project_code="PRJ-A",
            expense_type="travel_application",
            reason="支撑国网服务器上线部署",
            location="上海",
            amount=Decimal("12000.00"),
            currency="CNY",
            invoice_count=0,
            occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
            submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
            status="submitted",
            approval_stage="直属领导审批",
            risk_flags_json=[],
        )
        db.add(claim)
        db.commit()
        claim_id = claim.id

        approved = ExpenseClaimService(db).approve_claim(
            claim_id,
            current_user,
            opinion="   ",
        )

        assert approved is not None
        assert approved.status == "approved"
        assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
        assert any(
            isinstance(flag, dict)
            and flag.get("event_type") == "expense_application_approval"
            and flag.get("opinion") == "同意"
            and flag.get("next_approval_stage") == APPLICATION_LINK_STATUS_STAGE
            for flag in approved.risk_flags_json
        )
        assert reimbursement_claim_query(db).count() == 1


def test_budget_analysis_uses_current_application_reservation_without_double_counting() -> None:
    owner = CurrentUserContext(
        tenant_id="default",
        username="application-budget-analysis-owner@example.com",
        name="张三",
        role_codes=["employee"],
        is_admin=False,
    )

    with build_session() as db:
        manager = Employee(
            tenant_id="default",
            employee_no="E-BUDGET-ANALYSIS-MANAGER",
            name="李经理",
            email="application-budget-analysis-manager@example.com",
        )
        employee = Employee(
            tenant_id="default",
            employee_no="E-BUDGET-ANALYSIS",
            name="张三",
            email="application-budget-analysis-owner@example.com",
            manager=manager,
        )
        db.add_all([manager, employee])
        db.flush()
        _seed_budget_allocation(
            db,
            department_id="dept-budget-analysis",
            department_name="交付部",
            amount=Decimal("50000.00"),
        )
        claim = ExpenseClaim(
            claim_no="APP-20260525-ANALYSIS",
            employee_id=employee.id,
            employee_name="张三",
            department_id="dept-budget-analysis",
            department_name="交付部",
            project_code="PRJ-A",
            expense_type="travel_application",
            reason="客户现场交付",
            location="上海",
            amount=Decimal("12000.00"),
            currency="CNY",
            invoice_count=0,
            occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
            submitted_at=None,
            status="draft",
            approval_stage="待提交",
            risk_flags_json=[],
        )
        db.add(claim)
        db.commit()

        ExpenseClaimService(db).submit_claim(claim.id, owner)
        analysis = BudgetService(db).analyze_claim_budget(claim)

        assert analysis["metrics"]["claim_amount_ratio"] == "24.00"
        assert analysis["metrics"]["after_usage_rate"] == "24.00"
        assert analysis["budget_context"]["current_reserved_amount"] == "12000.00"
        assert analysis["score"] >= 70
        assert any("本次申请金额 12000.00 元，占预算 24.00%" in item for item in analysis["basis"])


def test_finance_approve_reimbursement_consumes_budget_reservation() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="finance-budget-approve@example.com",
        name="财务",
        role_codes=["finance"],
        is_admin=False,
    )

    with build_session() as db:
        allocation = _seed_budget_allocation(
            db,
            department_id="dept-finance-budget",
            department_name="交付部",
            amount=Decimal("50000.00"),
        )
        claim = ExpenseClaim(
            claim_no="RE-20260525-BUDGET",
            employee_id="emp-finance-budget",
            employee_name="张三",
            department_id="dept-finance-budget",
            department_name="交付部",
            project_code=None,
            expense_type="travel",
            reason="客户现场交付",
            location="上海",
            amount=Decimal("12000.00"),
            currency="CNY",
            invoice_count=1,
            occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
            submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
            status="submitted",
            approval_stage="财务审批",
            risk_flags_json=[],
        )
        db.add(claim)
        db.flush()
        reservation = BudgetReservation(
            reservation_no=f"BRS-TEST-{uuid.uuid4().hex[:8]}",
            allocation_id=allocation.id,
            source_type="claim",
            source_id=claim.id,
            source_no=claim.claim_no,
            source_status="active",
            amount=Decimal("12000.00"),
            context_json={},
        )
        db.add(reservation)
        db.commit()

        approved = ExpenseClaimService(db).approve_claim(claim.id, current_user, opinion="同意入账")

        assert approved is not None
        db.refresh(reservation)
        assert reservation.source_status == "consumed"
        assert reservation.consumed_amount == Decimal("12000.00")
        assert (
            db.query(BudgetTransaction)
            .filter(BudgetTransaction.transaction_type == "consume")
            .count()
            == 1
        )
        assert any(
            isinstance(flag, dict)
            and flag.get("source") == "budget_control"
            and flag.get("event_type") == "budget_consumed"
            for flag in approved.risk_flags_json
        )


def test_finance_cannot_operate_own_claim_in_finance_stage() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="finance-own-approval@example.com",
        name="财务",
        role_codes=["finance"],
        is_admin=False,
    )

    with build_session() as db:
        employee = Employee(
            employee_no="E8124",
            name="财务",
            email="finance-own-approval@example.com",
        )
        db.add(employee)
        db.flush()
        claim = ExpenseClaim(
            claim_no="RE-20260525-FINANCE-SELF",
            employee_id=employee.id,
            employee_name="财务",
            department_name="财务部",
            project_code=None,
            expense_type="travel",
            reason="差旅报销",
            location="上海",
            amount=Decimal("800.00"),
            currency="CNY",
            invoice_count=1,
            occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
            submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
            status="submitted",
            approval_stage="财务审批",
            risk_flags_json=[],
        )
        db.add(claim)
        db.commit()
        service = ExpenseClaimService(db)

        with pytest.raises(ValueError, match="申请人不能审批自己的费用单"):
            service.approve_claim(claim.id, current_user, opinion="同意入账")
        with pytest.raises(ValueError, match="申请人不能审批自己的费用单"):
            service.return_claim(claim.id, current_user, reason="退回")

        db.refresh(claim)
        assert claim.status == "submitted"
        assert claim.approval_stage == "财务审批"
        assert claim.risk_flags_json == []


def test_finance_can_approve_claim_to_pending_payment_stage() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="finance-approve@example.com",
        name="财务复核",
        role_codes=["finance"],
        is_admin=False,
    )

    with build_session() as db:
        claim = ExpenseClaim(
            claim_no="EXP-FIN-APP-201",
            employee_name="张三",
            department_name="市场部",
            project_code="PRJ-A",
            expense_type="transport",
            reason="交通报销",
            location="上海",
            amount=Decimal("66.00"),
            currency="CNY",
            invoice_count=1,
            occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
            submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
            status="submitted",
            approval_stage="财务审批",
            risk_flags_json=[],
        )
        db.add(claim)
        db.commit()
        claim_id = claim.id

        approved = ExpenseClaimService(db).approve_claim(
            claim_id,
            current_user,
            opinion="票据与明细一致，同意入账。",
        )

        assert approved is not None
        assert approved.status == "pending_payment"
        assert approved.approval_stage == "待付款"
        assert any(
            isinstance(flag, dict)
            and flag.get("source") == "finance_approval"
            and flag.get("event_type") == "expense_claim_finance_approval"
            and flag.get("opinion") == "票据与明细一致，同意入账。"
            and flag.get("previous_approval_stage") == "财务审批"
            and flag.get("next_status") == "pending_payment"
            and flag.get("next_approval_stage") == "待付款"
            for flag in approved.risk_flags_json
        )


def test_finance_can_mark_pending_payment_claim_as_paid() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="finance-pay@example.com",
        name="财务付款",
        role_codes=["finance"],
        is_admin=False,
    )

    with build_session() as db:
        claim = ExpenseClaim(
            claim_no="EXP-FIN-PAY-201",
            employee_name="张三",
            department_name="市场部",
            project_code="PRJ-A",
            expense_type="transport",
            reason="交通报销",
            location="上海",
            amount=Decimal("66.00"),
            currency="CNY",
            invoice_count=1,
            occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
            submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
            status="pending_payment",
            approval_stage="待付款",
            risk_flags_json=[],
        )
        item = ExpenseClaimItem(
            id=str(uuid.uuid4()),
            claim=claim,
            item_date=date(2026, 5, 12),
            item_type="hotel",
            item_reason="上海住宿",
            item_location="上海",
            item_note="",
            item_amount=Decimal("100.00"),
        )
        db.add(claim)
        db.commit()

        opportunities = SavingsDiscoveryService(db).discover_standard_adjustments(
            claim=claim,
            items_by_id={item.id: item},
            adjustment_flags=[
                {
                    "item_id": item.id,
                    "message": "付款集成测试标准调整",
                    "original_amount": "100.00",
                    "reimbursable_amount": "66.00",
                    "employee_absorbed_amount": "34.00",
                    "policy_rule_version": "payment-integration-v1",
                    "policy_grade": "P6",
                    "policy_matched_city": "上海",
                    "calculation_fingerprint": "sha256:" + "b" * 64,
                }
            ],
            current_user=current_user,
            request_id="payment-integration-adjustment",
        )
        db.commit()

        paid = ExpenseClaimService(db).mark_claim_paid(claim.id, current_user)

        assert paid is not None
        assert paid.status == "paid"
        assert paid.approval_stage == "已付款"
        assert any(
            isinstance(flag, dict)
            and flag.get("source") == "payment"
            and flag.get("event_type") == "expense_claim_payment_completed"
            and flag.get("previous_status") == "pending_payment"
            and flag.get("next_status") == "paid"
            and flag.get("next_approval_stage") == "已付款"
            for flag in paid.risk_flags_json
        )
        realization = db.scalar(
            select(SavingsRealization).where(
                SavingsRealization.opportunity_id == opportunities[0].id
            )
        )
        assert realization is not None
        assert realization.status == "pending_confirmation"
        assert realization.actual_net == Decimal("34.0000")
        db.refresh(opportunities[0])
        assert opportunities[0].status == "realized"


def test_marking_linked_reimbursement_paid_archives_application_claim() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="finance-pay-linked-application@example.com",
        name="财务付款",
        role_codes=["finance"],
        is_admin=False,
    )

    with build_session() as db:
        application_claim = ExpenseClaim(
            claim_no="AP-202606050001-ARCHIVE",
            employee_name="张三",
            department_name="交付部",
            project_code="PRJ-APP",
            expense_type="travel_application",
            reason="支撑国网部署",
            location="上海",
            amount=Decimal("3000.00"),
            currency="CNY",
            invoice_count=0,
            occurred_at=datetime(2026, 6, 5, 9, 0, tzinfo=UTC),
            submitted_at=datetime(2026, 6, 5, 10, 0, tzinfo=UTC),
            status="approved",
            approval_stage=APPROVAL_DONE_STAGE,
            risk_flags_json=[],
        )
        db.add(application_claim)
        db.flush()

        reimbursement_claim = ExpenseClaim(
            claim_no="RE-202606050001-ARCHIVE",
            employee_name="张三",
            department_name="交付部",
            project_code="PRJ-APP",
            expense_type="travel",
            reason="支撑国网部署报销",
            location="上海",
            amount=Decimal("3000.00"),
            currency="CNY",
            invoice_count=2,
            occurred_at=datetime(2026, 6, 5, 9, 0, tzinfo=UTC),
            submitted_at=datetime(2026, 6, 6, 10, 0, tzinfo=UTC),
            status="pending_payment",
            approval_stage="待付款",
            risk_flags_json=[
                {
                    "source": "application_handoff",
                    "event_type": "expense_application_to_reimbursement_draft",
                    "application_claim_id": application_claim.id,
                    "application_claim_no": application_claim.claim_no,
                }
            ],
        )
        db.add(reimbursement_claim)
        db.commit()

        archived_before = ExpenseClaimService(db).list_archived_claims(current_user)
        assert all(claim.claim_no != application_claim.claim_no for claim in archived_before)

        paid = ExpenseClaimService(db).mark_claim_paid(reimbursement_claim.id, current_user)

        assert paid is not None
        db.refresh(application_claim)
        assert application_claim.status == "approved"
        assert application_claim.approval_stage == APPLICATION_ARCHIVE_STAGE
        assert any(
            isinstance(flag, dict)
            and flag.get("source") == "application_archive_sync"
            and flag.get("event_type") == "expense_application_archived_by_reimbursement"
            and flag.get("reimbursement_claim_no") == reimbursement_claim.claim_no
            and flag.get("next_approval_stage") == APPLICATION_ARCHIVE_STAGE
            for flag in application_claim.risk_flags_json
        )
        assert any(
            isinstance(flag, dict)
            and flag.get("source") == "payment"
            and any(
                item.get("application_claim_no") == application_claim.claim_no
                for item in flag.get("archived_application_claims", [])
                if isinstance(item, dict)
            )
            for flag in paid.risk_flags_json
        )

        archived_after = ExpenseClaimService(db).list_archived_claims(current_user)
        assert any(claim.claim_no == application_claim.claim_no for claim in archived_after)


def test_return_claim_rejects_already_returned_claim_without_adding_event() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="finance-returned@example.com",
        name="财务",
        role_codes=["finance"],
        is_admin=False,
    )
    return_flag = {
        "source": "manual_return",
        "return_event_id": "return-event-existing",
        "message": "请补充附件。",
        "return_count": 1,
        "return_stage": "直属领导审批",
        "return_stage_key": "direct_manager",
    }

    with build_session() as db:
        claim = ExpenseClaim(
            claim_no="EXP-RET-202",
            employee_name="张三",
            department_name="市场部",
            project_code="PRJ-A",
            expense_type="transport",
            reason="交通报销",
            location="上海",
            amount=Decimal("66.00"),
            currency="CNY",
            invoice_count=1,
            occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
            submitted_at=None,
            status="returned",
            approval_stage="待提交",
            risk_flags_json=[return_flag],
        )
        db.add(claim)
        db.commit()
        claim_id = claim.id

        with pytest.raises(ValueError, match="没有可执行的审批任务"):
            ExpenseClaimService(db).return_claim(claim_id, current_user, reason="重复退回")

        db.refresh(claim)
        manual_returns = [
            flag
            for flag in list(claim.risk_flags_json or [])
            if isinstance(flag, dict) and flag.get("source") == "manual_return"
        ]
        assert manual_returns == [return_flag]


def test_return_claim_records_each_return_event_with_stage_reason_and_counts() -> None:
    manager_user = CurrentUserContext(
        tenant_id="default",
        username="manager-return-count@example.com",
        name="李经理",
        role_codes=["manager"],
        is_admin=False,
    )
    finance_user = CurrentUserContext(
        tenant_id="default",
        username="finance-return@example.com",
        name="财务复核",
        role_codes=["finance"],
        is_admin=False,
    )

    with build_session() as db:
        manager = Employee(
            employee_no="E8130",
            name="李经理",
            email="manager-return-count@example.com",
        )
        employee = Employee(
            employee_no="E8131",
            name="张三",
            email="zhangsan-return-count@example.com",
            manager=manager,
        )
        db.add_all([manager, employee])
        db.flush()
        claim = ExpenseClaim(
            claim_no="EXP-RET-301",
            employee_id=employee.id,
            employee_name="张三",
            department_name="市场部",
            project_code="PRJ-A",
            expense_type="transport",
            reason="交通报销",
            location="上海",
            amount=Decimal("66.00"),
            currency="CNY",
            invoice_count=1,
            occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
            submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
            status="submitted",
            approval_stage="直属领导审批",
            risk_flags_json=[],
        )
        db.add(claim)
        db.commit()
        claim_id = claim.id

        service = ExpenseClaimService(db)
        first_returned = service.return_claim(
            claim_id,
            manager_user,
            reason="发票金额与明细金额不一致，请重新核对。",
            reason_codes=["invoice_mismatch", "business_explanation"],
        )
        assert first_returned is not None

        first_returned.status = "submitted"
        first_returned.approval_stage = "财务审批"
        first_returned.submitted_at = datetime(2026, 5, 12, 11, 0, tzinfo=UTC)
        db.commit()

        second_returned = service.return_claim(
            claim_id,
            finance_user,
            reason="超标说明仍不完整，请补充制度例外依据。",
            reason_codes=["over_policy"],
        )

        assert second_returned is not None
        return_events = [
            flag
            for flag in list(second_returned.risk_flags_json or [])
            if isinstance(flag, dict) and flag.get("source") == "manual_return"
        ]
        assert len(return_events) == 2
        assert return_events[0]["return_count"] == 1
        assert return_events[0]["stage_return_count"] == 1
        assert return_events[0]["return_stage"] == "直属领导审批"
        assert return_events[0]["reason_codes"] == ["invoice_mismatch", "business_explanation"]
        assert return_events[0]["risk_points"] == [
            "票据类型/金额与明细不一致",
            "业务事由/地点/人员信息不完整",
        ]
        assert return_events[0]["reason"] == "发票金额与明细金额不一致，请重新核对。"
        assert return_events[0]["operator_role_codes"] == ["manager"]
        assert return_events[1]["return_count"] == 2
        assert return_events[1]["stage_return_count"] == 1
        assert return_events[1]["return_stage"] == "财务审批"
        assert return_events[1]["risk_points"] == ["超出制度标准或缺少超标说明"]


def test_submit_returned_claim_preserves_manual_return_events() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="emp-submit-returned@example.com",
        name="张三",
        role_codes=[],
        is_admin=False,
    )
    return_flag = {
        "source": "manual_return",
        "return_event_id": "return-event-submit",
        "message": "第一次退回：业务说明不完整。",
        "reason": "业务说明不完整。",
        "return_count": 1,
        "return_stage": "直属领导审批",
        "return_stage_key": "direct_manager",
        "risk_points": ["业务事由/地点/人员信息不完整"],
    }

    with build_session() as db:
        manager = Employee(
            employee_no="E8200",
            name="李经理",
            email="manager-submit-returned@example.com",
        )
        employee = Employee(
            employee_no="E8201",
            name="张三",
            email="emp-submit-returned@example.com",
            manager=manager,
        )
        claim = build_claim(expense_type="office", location="上海")
        claim.employee = employee
        claim.employee_id = employee.id
        claim.employee_name = "张三"
        claim.department_name = "市场部"
        claim.status = "returned"
        claim.approval_stage = "待提交"
        claim.risk_flags_json = [return_flag]
        db.add_all([manager, employee, claim])
        db.commit()
        conversation = AgentConversationService(db).get_or_create_conversation(
            conversation_id=None,
            user_id=current_user.username,
            source="user_message",
            context_json={
                "tenant_id": "default",
                "session_type": "expense",
                "draft_claim_id": claim.id,
            },
        )
        conversation_id = conversation.conversation_id

        submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)

        assert submitted is not None
        assert submitted.status == "submitted"
        assert submitted.approval_stage == "直属领导审批"
        assert any(
            isinstance(flag, dict)
            and flag.get("source") == "manual_return"
            and flag.get("return_event_id") == "return-event-submit"
            for flag in list(submitted.risk_flags_json or [])
        )
        assert AgentConversationService(db).get_conversation(conversation_id) is None


def test_manager_personal_claims_exclude_subordinate_pending_approval_claims() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="manager-personal@example.com",
        name="李经理",
        role_codes=["manager"],
        is_admin=False,
    )

    with build_session() as db:
        manager = Employee(
            employee_no="E8300",
            name="李经理",
            email="manager-personal@example.com",
        )
        employee = Employee(
            employee_no="E8301",
            name="张三",
            email="zhangsan-personal@example.com",
            manager=manager,
        )
        db.add_all([manager, employee])
        db.flush()
        db.add_all(
            [
                ExpenseClaim(
                    claim_no="EXP-MGR-OWN",
                    employee_id=manager.id,
                    employee_name="李经理",
                    department_name="市场部",
                    project_code="PRJ-MGR",
                    expense_type="office",
                    reason="本人报销",
                    location="上海",
                    amount=Decimal("88.00"),
                    currency="CNY",
                    invoice_count=1,
                    occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
                    submitted_at=None,
                    status="draft",
                    approval_stage="待提交",
                    risk_flags_json=[],
                ),
                ExpenseClaim(
                    claim_no="EXP-MGR-SUB",
                    employee_id=employee.id,
                    employee_name="张三",
                    department_name="市场部",
                    project_code="PRJ-MGR",
                    expense_type="transport",
                    reason="下属待审批报销",
                    location="上海",
                    amount=Decimal("66.00"),
                    currency="CNY",
                    invoice_count=1,
                    occurred_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
                    submitted_at=datetime(2026, 5, 12, 11, 0, tzinfo=UTC),
                    status="submitted",
                    approval_stage="直属领导审批",
                    risk_flags_json=[],
                ),
            ]
        )
        db.commit()

        service = ExpenseClaimService(db)
        personal_claims = service.list_claims(current_user)
        approval_claims = service.list_approval_claims(current_user)

        assert [claim.claim_no for claim in personal_claims] == ["EXP-MGR-OWN"]
        assert [claim.claim_no for claim in approval_claims] == ["EXP-MGR-SUB"]


def test_list_approval_claims_allows_direct_manager_to_view_pending_claims_for_approval() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="manager@example.com",
        name="李经理",
        role_codes=["manager"],
        is_admin=False,
    )

    with build_session() as db:
        manager = Employee(
            employee_no="E8000",
            name="李经理",
            email="manager@example.com",
        )
        employee = Employee(
            employee_no="E8001",
            name="张三",
            email="zhangsan@example.com",
            manager=manager,
        )
        outsider_manager = Employee(
            employee_no="E8002",
            name="王经理",
            email="other-manager@example.com",
        )
        outsider = Employee(
            employee_no="E8003",
            name="李四",
            email="lisi@example.com",
            manager=outsider_manager,
        )
        db.add_all([manager, employee, outsider_manager, outsider])
        db.flush()
        db.add_all(
            [
                ExpenseClaim(
                    claim_no="EXP-MGR-201",
                    employee_id=employee.id,
                    employee_name="张三",
                    department_name="市场部",
                    project_code="PRJ-MGR",
                    expense_type="transport",
                    reason="滴滴报销",
                    location="上海",
                    amount=Decimal("66.00"),
                    currency="CNY",
                    invoice_count=1,
                    occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
                    submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
                    status="submitted",
                    approval_stage="直属领导审批",
                    risk_flags_json=[],
                ),
                ExpenseClaim(
                    claim_no="EXP-MGR-202",
                    employee_id=outsider.id,
                    employee_name="李四",
                    department_name="销售部",
                    project_code="PRJ-OTHER",
                    expense_type="meal",
                    reason="客户用餐",
                    location="杭州",
                    amount=Decimal("188.00"),
                    currency="CNY",
                    invoice_count=1,
                    occurred_at=datetime(2026, 5, 12, 12, 0, tzinfo=UTC),
                    submitted_at=datetime(2026, 5, 12, 13, 0, tzinfo=UTC),
                    status="submitted",
                    approval_stage="直属领导审批",
                    risk_flags_json=[],
                ),
            ]
        )
        db.commit()

        claims = ExpenseClaimService(db).list_approval_claims(current_user)

        assert len(claims) == 1
        assert claims[0].claim_no == "EXP-MGR-201"


def test_list_approval_claims_limits_finance_to_finance_stage_claims() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="finance-approval-list@example.com",
        name="财务",
        role_codes=["finance"],
        is_admin=False,
    )

    with build_session() as db:
        db.add_all(
            [
                ExpenseClaim(
                    claim_no="EXP-FIN-LIST-201",
                    employee_name="张三",
                    department_name="市场部",
                    project_code="PRJ-FIN",
                    expense_type="transport",
                    reason="直属领导待审",
                    location="上海",
                    amount=Decimal("66.00"),
                    currency="CNY",
                    invoice_count=1,
                    occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
                    submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
                    status="submitted",
                    approval_stage="直属领导审批",
                    risk_flags_json=[],
                ),
                ExpenseClaim(
                    claim_no="EXP-FIN-LIST-202",
                    employee_name="李四",
                    department_name="销售部",
                    project_code="PRJ-FIN",
                    expense_type="meal",
                    reason="财务待审",
                    location="杭州",
                    amount=Decimal("188.00"),
                    currency="CNY",
                    invoice_count=1,
                    occurred_at=datetime(2026, 5, 12, 12, 0, tzinfo=UTC),
                    submitted_at=datetime(2026, 5, 12, 13, 0, tzinfo=UTC),
                    status="submitted",
                    approval_stage="财务审批",
                    risk_flags_json=[],
                ),
            ]
        )
        db.commit()

        claims = ExpenseClaimService(db).list_approval_claims(current_user)

        assert [claim.claim_no for claim in claims] == ["EXP-FIN-LIST-202"]


def test_list_approval_claims_allows_budget_monitor_to_view_budget_stage_applications() -> None:
    current_user = CurrentUserContext(
        tenant_id="default",
        username="budget-p8-list@example.com",
        name="赵预算",
        role_codes=["budget_monitor"],
        is_admin=False,
    )
    p8_without_budget_role = CurrentUserContext(
        tenant_id="default",
        username="p8-without-budget-list@example.com",
        name="budget manager",
        role_codes=["manager"],
        is_admin=False,
    )

    with build_session() as db:
        budget_role = _seed_budget_monitor_role(db)
        delivery_department = OrganizationUnit(
            unit_code="DELIVERY-BUDGET-LIST",
            name="交付部",
            unit_type="department",
        )
        market_department = OrganizationUnit(
            unit_code="MARKET-BUDGET-LIST",
            name="市场部",
            unit_type="department",
        )
        budget_manager = Employee(
            employee_no="E-P8-BUDGET-LIST",
            name="赵预算",
            email="budget-p8-list@example.com",
            grade="P8",
            organization_unit=delivery_department,
            roles=[budget_role],
        )
        p8_without_budget_employee = Employee(
            employee_no="E-P8-NO-BUDGET-LIST",
            name="P8 No Budget Role",
            email="p8-without-budget-list@example.com",
            grade="P8",
            organization_unit=delivery_department,
        )
        employee = Employee(
            employee_no="E-BUDGET-LIST-OWNER",
            name="张三",
            email="budget-list-owner@example.com",
            organization_unit=delivery_department,
        )
        market_employee = Employee(
            employee_no="E-BUDGET-LIST-MARKET",
            name="王五",
            email="budget-list-market@example.com",
            organization_unit=market_department,
        )
        db.add_all(
            [
                delivery_department,
                market_department,
                budget_manager,
                p8_without_budget_employee,
                employee,
                market_employee,
            ]
        )
        db.flush()
        db.add_all(
            [
                ExpenseClaim(
                    claim_no="APP-BUDGET-LIST-201",
                    employee_id=employee.id,
                    employee_name="张三",
                    department_id=delivery_department.id,
                    department_name="交付部",
                    project_code="PRJ-BUDGET",
                    expense_type="travel_application",
                    reason="预算待审申请",
                    location="上海",
                    amount=Decimal("12000.00"),
                    currency="CNY",
                    invoice_count=0,
                    occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
                    submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
                    status="submitted",
                    approval_stage="预算管理者审批",
                    risk_flags_json=[],
                ),
                ExpenseClaim(
                    claim_no="APP-BUDGET-LIST-OTHER-DEPT",
                    employee_id=market_employee.id,
                    employee_name="王五",
                    department_id=market_department.id,
                    department_name="市场部",
                    project_code="PRJ-BUDGET",
                    expense_type="travel_application",
                    reason="其他部门预算待审申请",
                    location="上海",
                    amount=Decimal("13000.00"),
                    currency="CNY",
                    invoice_count=0,
                    occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
                    submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
                    status="submitted",
                    approval_stage="预算管理者审批",
                    risk_flags_json=[],
                ),
                ExpenseClaim(
                    claim_no="EXP-BUDGET-LIST-202",
                    employee_id=employee.id,
                    employee_name="张三",
                    department_id=delivery_department.id,
                    department_name="交付部",
                    project_code="PRJ-BUDGET",
                    expense_type="transport",
                    reason="财务待审报销",
                    location="上海",
                    amount=Decimal("88.00"),
                    currency="CNY",
                    invoice_count=1,
                    occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
                    submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
                    status="submitted",
                    approval_stage="财务审批",
                    risk_flags_json=[],
                ),
            ]
        )
        db.commit()

        claims = ExpenseClaimService(db).list_approval_claims(current_user)

        assert [claim.claim_no for claim in claims] == ["APP-BUDGET-LIST-201"]
        assert getattr(claims[0], "budget_approver_name", "") == "赵预算"
        assert getattr(claims[0], "budget_approver_grade", "") == "P8"
        assert getattr(claims[0], "budget_approver_role_code", "") == "budget_monitor"
        claims_without_budget_role = ExpenseClaimService(db).list_approval_claims(
            p8_without_budget_role
        )
        assert [claim.claim_no for claim in claims_without_budget_role] == []
