from __future__ import annotations from datetime import UTC, date, datetime from decimal import Decimal import pytest from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool from app.db.base import Base from app.models.employee import Employee from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.schemas.orchestrator import OrchestratorRequest from app.services.agent_conversations import AgentConversationService from app.services.orchestrator import OrchestratorService def build_session_factory() -> sessionmaker[Session]: engine = create_engine( "sqlite+pysqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) Base.metadata.create_all(bind=engine) return sessionmaker(bind=engine, autoflush=False, autocommit=False) @pytest.fixture(autouse=True) def skip_agent_foundation_bootstrap(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( "app.services.agent_foundation.AgentFoundationService.ensure_foundation_ready", lambda *_args, **_kwargs: None, ) def test_review_next_step_run_submits_existing_claim_and_returns_draft_payload( monkeypatch, ) -> None: monkeypatch.setattr( "app.services.runtime_chat.RuntimeChatService.complete", lambda *_args, **_kwargs: None, ) session_factory = build_session_factory() with session_factory() as db: manager = Employee( employee_no="E9000", name="李经理", email="manager-next@example.com", ) employee = Employee( employee_no="E9001", name="张三", email="emp-next@example.com", manager=manager, ) claim = ExpenseClaim( id="claim-next-step", claim_no="EXP-202605-001", employee=employee, employee_id=employee.id, employee_name="张三", department_name="销售部", expense_type="office", reason="采购办公用品", location="上海", amount=Decimal("128.00"), currency="CNY", invoice_count=1, occurred_at=datetime(2026, 5, 20, 9, 0, tzinfo=UTC), status="draft", approval_stage="待提交", items=[ ExpenseClaimItem( item_date=date(2026, 5, 20), item_type="office", item_reason="采购办公用品", item_location="上海", item_amount=Decimal("128.00"), invoice_id="office-invoice.png", ) ], ) db.add_all([manager, employee, claim]) db.commit() response = OrchestratorService(db).run( OrchestratorRequest( source="user_message", user_id="emp-next@example.com", message="我已核对右侧识别结果,请进入下一步。", context_json={ "review_action": "next_step", "draft_claim_id": claim.id, "attachment_count": 1, "name": "张三", }, ) ) db.refresh(claim) assert response.status == "succeeded" assert response.requires_confirmation is False assert response.result["draft_payload"]["status"] == "submitted" assert response.result["draft_payload"]["approval_stage"] == "直属领导审批" assert claim.status == "submitted" assert claim.approval_stage == "直属领导审批" assert claim.submitted_at is not None assert response.conversation_id assert AgentConversationService(db).get_conversation(response.conversation_id) is None def test_review_next_step_blocked_returns_reasons_and_removes_next_step_action( monkeypatch, ) -> None: monkeypatch.setattr( "app.services.runtime_chat.RuntimeChatService.complete", lambda *_args, **_kwargs: None, ) session_factory = build_session_factory() with session_factory() as db: employee = Employee( employee_no="E9011", name="张三", email="emp-blocked@example.com", ) claim = ExpenseClaim( id="claim-next-step-blocked", claim_no="EXP-202605-002", employee=employee, employee_id=employee.id, employee_name="张三", department_name="待补充", expense_type="office", reason="采购办公用品", location="上海", amount=Decimal("128.00"), currency="CNY", invoice_count=1, occurred_at=datetime(2026, 5, 20, 9, 0, tzinfo=UTC), status="draft", approval_stage="待提交", items=[ ExpenseClaimItem( item_date=date(2026, 5, 20), item_type="office", item_reason="采购办公用品", item_location="上海", item_amount=Decimal("128.00"), invoice_id="office-invoice.png", ) ], ) db.add_all([employee, claim]) db.commit() response = OrchestratorService(db).run( OrchestratorRequest( source="user_message", user_id="emp-blocked@example.com", message="我已核对右侧识别结果,请进入下一步。", context_json={ "review_action": "next_step", "draft_claim_id": claim.id, "attachment_count": 1, "name": "张三", }, ) ) result = response.result review_payload = result["review_payload"] actions = { str(item.get("action_type") or "").strip() for item in review_payload["confirmation_actions"] } assert response.status == "succeeded" assert result["draft_payload"]["status"] == "draft" assert response.conversation_id assert AgentConversationService(db).get_conversation(response.conversation_id) is not None assert "AI预审暂未通过" in result["answer"] assert "所属部门未完善" in result["answer"] assert "next_step" not in actions assert "save_draft" in actions assert any( "所属部门未完善" in str(item.get("content") or "") for item in review_payload["risk_briefs"] ) def test_conversation_hydration_does_not_reuse_review_type_for_fresh_expense_prompt() -> None: session_factory = build_session_factory() with session_factory() as db: service = AgentConversationService(db) conversation = service.get_or_create_conversation( conversation_id="conv-review-type-lock", user_id="emp-review-type@example.com", source="user_message", context_json={ "session_type": "expense", "draft_claim_id": "claim-old", "attachment_names": ["old-train-ticket.pdf"], "attachment_count": 1, "review_form_values": { "expense_type": "差旅费", "business_location": "北京", }, }, ) fresh_context = service.hydrate_context_json( conversation=conversation, context_json={"draft_claim_id": "claim-old"}, message="业务发生时间:2026-02-20 至 2026-02-23,去上海支持上海电力部署项目,申请报销", ) continued_context = service.hydrate_context_json( conversation=conversation, context_json={}, message="继续补充酒店发票", ) assert "draft_claim_id" not in fresh_context assert "attachment_names" not in fresh_context assert "review_form_values" not in fresh_context assert fresh_context["conversation_state"]["review_form_values"]["expense_type"] == "差旅费" assert continued_context["draft_claim_id"] == "claim-old" assert continued_context["review_form_values"]["expense_type"] == "差旅费" def test_orchestrator_history_query_filters_location_time_and_returns_real_amount( monkeypatch, ) -> None: monkeypatch.setattr( "app.services.runtime_chat.RuntimeChatService.complete", lambda *_args, **_kwargs: None, ) session_factory = build_session_factory() with session_factory() as db: employee = Employee( id="emp-history-query", employee_no="E9020", name="张三", email="history-query@example.com", ) beijing_claim = ExpenseClaim( id="claim-history-beijing", claim_no="EXP-202506-001", employee=employee, employee_id=employee.id, employee_name="张三", department_name="交付部", expense_type="travel", reason="去北京支持客户项目", location="北京", amount=Decimal("321.45"), currency="CNY", invoice_count=2, occurred_at=datetime(2025, 6, 18, 9, 0, tzinfo=UTC), submitted_at=datetime(2025, 6, 19, 10, 0, tzinfo=UTC), status="paid", approval_stage="已入账", ) shanghai_claim = ExpenseClaim( id="claim-history-shanghai", claim_no="EXP-202507-001", employee=employee, employee_id=employee.id, employee_name="张三", department_name="交付部", expense_type="travel", reason="去上海支持项目", location="上海", amount=Decimal("888.00"), currency="CNY", invoice_count=1, occurred_at=datetime(2025, 7, 8, 9, 0, tzinfo=UTC), submitted_at=datetime(2025, 7, 9, 10, 0, tzinfo=UTC), status="paid", approval_stage="已入账", ) current_year_claim = ExpenseClaim( id="claim-history-beijing-current", claim_no="EXP-202601-001", employee=employee, employee_id=employee.id, employee_name="张三", department_name="交付部", expense_type="travel", reason="去北京支持年度项目", location="北京", amount=Decimal("666.00"), currency="CNY", invoice_count=1, occurred_at=datetime(2026, 1, 8, 9, 0, tzinfo=UTC), submitted_at=datetime(2026, 1, 9, 10, 0, tzinfo=UTC), status="paid", approval_stage="已入账", ) db.add_all([employee, beijing_claim, shanghai_claim, current_year_claim]) db.commit() response = OrchestratorService(db).run( OrchestratorRequest( source="user_message", user_id="history-query@example.com", message="我去年去北京报销的单据", context_json={ "client_now_iso": "2026-05-21T04:00:00.000Z", "client_timezone_offset_minutes": -480, }, ) ) query_payload = response.result["query_payload"] assert response.status == "succeeded" assert response.trace_summary.scenario == "expense" assert response.trace_summary.intent == "query" assert query_payload["record_count"] == 1 assert query_payload["total_amount"] == 321.45 assert [item["claim_no"] for item in query_payload["records"]] == ["EXP-202506-001"] assert "321.45" in response.result["answer"] def test_orchestrator_expense_preview_does_not_persist_claim_before_user_action( monkeypatch, ) -> None: monkeypatch.setattr( "app.services.runtime_chat.RuntimeChatService.complete", lambda *_args, **_kwargs: None, ) session_factory = build_session_factory() with session_factory() as db: employee = Employee( employee_no="E9030", name="预览员工", email="preview-orchestrator@example.com", ) db.add(employee) db.commit() response = OrchestratorService(db).run( OrchestratorRequest( source="user_message", user_id="preview-orchestrator@example.com", message="业务发生时间:2026-03-04,打车去客户现场,交通费32元,请帮我看看怎么报", context_json={ "name": "预览员工", "user_input_text": "业务发生时间:2026-03-04,打车去客户现场,交通费32元,请帮我看看怎么报", }, ) ) user_claims = [ claim for claim in db.query(ExpenseClaim).all() if claim.employee_name == "预览员工" ] assert response.status == "succeeded" assert response.result.get("review_payload") is not None assert response.result.get("draft_payload") is None assert "交通费通常以实际票据金额为基础" in response.result["answer"] assert user_claims == [] def test_orchestrator_prompts_scene_choices_before_review_for_fresh_ambiguous_expense( monkeypatch, ) -> None: monkeypatch.setattr( "app.services.runtime_chat.RuntimeChatService.complete", lambda *_args, **_kwargs: None, ) session_factory = build_session_factory() with session_factory() as db: service = AgentConversationService(db) conversation = service.get_or_create_conversation( conversation_id="conv-scene-choice", user_id="emp-scene-choice@example.com", source="user_message", context_json={ "session_type": "expense", "draft_claim_id": "claim-old", "review_form_values": { "expense_type": "差旅费", "business_location": "北京", }, }, ) response = OrchestratorService(db).run( OrchestratorRequest( source="user_message", user_id="emp-scene-choice@example.com", conversation_id=conversation.conversation_id, message="业务发生时间:2026-02-20 至 2026-02-23,去上海支持上海电力部署项目,申请报销", context_json={ "session_type": "expense", "draft_claim_id": "claim-old", }, ) ) result = response.result assert response.status == "succeeded" assert result.get("review_payload") is None assert result.get("draft_payload") is None assert "请先在下面选择报销场景" in result["answer"] assert [item["label"] for item in result["suggested_actions"][:3]] == ["差旅费", "交通费", "住宿费"]