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.ontology import OntologyParseResult, OntologyPermission 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_conversation_hydration_preserves_incoming_application_time_context() -> None: session_factory = build_session_factory() with session_factory() as db: service = AgentConversationService(db) conversation = service.get_or_create_conversation( conversation_id="conv-application-time-context", user_id="emp-application-time@example.com", source="user_message", context_json={ "session_type": "application", "entry_source": "application", "business_time_context": { "mode": "single", "start_date": "2026-05-01", "end_date": "2026-05-01", "display_value": "2026-05-01", }, }, ) stale_context = service.hydrate_context_json( conversation=conversation, context_json={"session_type": "application", "entry_source": "application"}, message="apply travel expense", ) fresh_context = service.hydrate_context_json( conversation=conversation, context_json={ "session_type": "application", "entry_source": "application", "business_time_context": { "mode": "single", "start_date": "2026-05-25", "end_date": "2026-05-25", "display_value": "2026-05-25", }, }, message="apply travel expense", ) assert "business_time_context" not in stale_context assert fresh_context["business_time_context"]["start_date"] == "2026-05-25" def test_conversation_scope_creates_new_session_for_different_claim() -> None: session_factory = build_session_factory() with session_factory() as db: service = AgentConversationService(db) old_conversation = service.get_or_create_conversation( conversation_id="conv-old-claim-scope", user_id="emp-scope@example.com", source="user_message", context_json={ "session_type": "expense", "draft_claim_id": "claim-old", "attachment_names": ["old-hotel.pdf"], "attachment_count": 1, "review_form_values": { "expense_type": "住宿票", "merchant_name": "旧酒店", }, }, ) service.append_message( conversation_id=old_conversation.conversation_id, role="user", content="继续补充旧酒店发票", ) scoped_conversation = service.get_or_create_conversation( conversation_id=old_conversation.conversation_id, user_id="emp-scope@example.com", source="user_message", context_json={ "session_type": "expense", "draft_claim_id": "claim-current", }, ) conflict_context = service.hydrate_context_json( conversation=old_conversation, context_json={"draft_claim_id": "claim-current"}, message="继续补充当前单据的火车票", ) scoped_context = service.hydrate_context_json( conversation=scoped_conversation, context_json={"draft_claim_id": "claim-current"}, message="继续补充当前单据的火车票", ) db.refresh(old_conversation) assert scoped_conversation.conversation_id != old_conversation.conversation_id assert scoped_conversation.draft_claim_id == "claim-current" assert old_conversation.draft_claim_id == "claim-old" assert conflict_context == {"draft_claim_id": "claim-current"} assert scoped_context["draft_claim_id"] == "claim-current" assert scoped_context["conversation_history"] == [] 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_archive_query_filters_archived_claims_and_limits_preview( 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-archive-query", employee_no="E9021", name="归档员工", email="archive-query@example.com", ) claims = [] for index in range(6): claims.append( ExpenseClaim( id=f"claim-archive-query-{index}", claim_no=f"EXP-ARCHIVE-{index + 1:03d}", employee=employee, employee_id=employee.id, employee_name="归档员工", department_name="交付部", expense_type="travel", reason=f"归档差旅 {index + 1}", location="上海", amount=Decimal("100.00") + Decimal(index), currency="CNY", invoice_count=1, occurred_at=datetime(2026, 2, index + 1, 9, 0, tzinfo=UTC), submitted_at=datetime(2026, 2, index + 2, 10, 0, tzinfo=UTC), status="approved", approval_stage="归档入账", ) ) draft_claim = ExpenseClaim( id="claim-archive-query-draft", claim_no="EXP-ARCHIVE-DRAFT", employee=employee, employee_id=employee.id, employee_name="归档员工", department_name="交付部", expense_type="travel", reason="未归档草稿", location="上海", amount=Decimal("999.00"), currency="CNY", invoice_count=0, occurred_at=datetime(2026, 3, 1, 9, 0, tzinfo=UTC), submitted_at=None, status="draft", approval_stage="待提交", ) db.add_all([employee, *claims, draft_claim]) db.commit() response = OrchestratorService(db).run( OrchestratorRequest( source="user_message", user_id="archive-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.intent == "query" assert query_payload["record_count"] == 6 assert query_payload["preview_count"] == 5 assert query_payload["preview_limit"] == 5 assert query_payload["title"] == "最近 5 条你的归档报销单" assert all(record["status"] == "approved" for record in query_payload["records"]) assert "EXP-ARCHIVE-DRAFT" not in [record["claim_no"] for record in query_payload["records"]] assert response.result["suggested_actions"] == [] assert "下面先列出最近 5 条记录" 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]] == ["差旅费", "交通费", "住宿费"] def test_orchestrator_application_session_does_not_use_reimbursement_scene_prompt( monkeypatch, ) -> None: monkeypatch.setattr( "app.services.runtime_chat.RuntimeChatService.complete", lambda *_args, **_kwargs: None, ) session_factory = build_session_factory() message = ( "发生时间:2026-05-25\n" "地点:上海\n" "事由:支持上海国网服务器部署\n" "天数:3天" ) with session_factory() as db: response = OrchestratorService(db).run( OrchestratorRequest( source="user_message", user_id="application-session@example.com", message=message, context_json={ "session_type": "application", "entry_source": "application", "name": "申请员工", }, ) ) result = response.result assert response.status == "blocked" assert response.trace_summary.scenario == "expense" assert "费用申请" in result["answer"] assert "| 发生时间 | 2026-05-25" in result["answer"] assert "请先在下面选择报销场景" not in result["answer"] assert result.get("review_payload") is None def test_orchestrator_application_session_guides_transport_amount_and_submit( monkeypatch, ) -> None: monkeypatch.setattr( "app.services.runtime_chat.RuntimeChatService.complete", lambda *_args, **_kwargs: None, ) session_factory = build_session_factory() initial_message = ( "发生时间:2026-05-25\n" "地点:上海\n" "事由:支持上海国网服务器部署\n" "天数:3天" ) context_json = { "session_type": "application", "entry_source": "application", "name": "申请员工", "manager_name": "陈硕", } with session_factory() as db: service = OrchestratorService(db) first = service.run( OrchestratorRequest( source="user_message", user_id="application-flow@example.com", message=initial_message, context_json=context_json, ) ) second = service.run( OrchestratorRequest( source="user_message", user_id="application-flow@example.com", conversation_id=first.conversation_id, message="飞机", context_json=context_json, ) ) third = service.run( OrchestratorRequest( source="user_message", user_id="application-flow@example.com", conversation_id=first.conversation_id, message="预计总费用:12000元", context_json=context_json, ) ) fourth = service.run( OrchestratorRequest( source="user_message", user_id="application-flow@example.com", conversation_id=first.conversation_id, message="确认提交", context_json=context_json, ) ) assert first.status == "blocked" assert "当前还需要补充:出行方式、用户预估费用" in first.result["answer"] assert [item["label"] for item in first.result["suggested_actions"]] == ["一次性补充申请信息"] assert first.result["suggested_actions"][0]["payload"]["prompt_prefill"] == "出行方式:\n用户预估费用:" assert "当前还需要补充:用户预估费用" in second.result["answer"] assert [item["label"] for item in second.result["suggested_actions"]] == ["一次性补充申请信息"] assert second.result["suggested_actions"][0]["action_type"] == "prefill_composer" assert second.result["suggested_actions"][0]["payload"]["prompt_prefill"] == "用户预估费用:" assert "这是模拟的费用申请结果" in third.result["answer"] assert "| 事由 | 支持上海国网服务器部署 |" in third.result["answer"] assert "请核对上述信息无误" in third.result["answer"] assert "[确认](#application-submit)" in third.result["answer"] assert third.status == "blocked" assert third.result["requires_confirmation"] is True assert third.result["suggested_actions"] == [] assert fourth.status == "succeeded" assert fourth.result["clarification_required"] is False assert fourth.result["missing_slots"] == [] assert "当前操作已完成,单据已经推送给 陈硕 进行审核,请耐心等待" in fourth.result["answer"] assert "当前状态:陈硕审核中" in fourth.result["answer"] assert fourth.result["suggested_actions"] == [] application_claims = [ claim for claim in db.query(ExpenseClaim).all() if claim.claim_no.startswith("AP-") ] assert len(application_claims) == 1 assert application_claims[0].status == "submitted" assert application_claims[0].approval_stage == "直属领导审批" assert fourth.result["draft_payload"]["claim_no"] == application_claims[0].claim_no def test_orchestrator_application_submit_bypasses_generic_operation_block( monkeypatch, ) -> None: monkeypatch.setattr( "app.services.runtime_chat.RuntimeChatService.complete", lambda *_args, **_kwargs: None, ) session_factory = build_session_factory() initial_message = ( "发生时间:2026-05-25\n" "地点:上海\n" "事由:支持上海国网服务器部署\n" "天数:3天" ) context_json = { "session_type": "application", "entry_source": "application", "name": "申请员工", "manager_name": "陈硕", } with session_factory() as db: service = OrchestratorService(db) first = service.run( OrchestratorRequest( source="user_message", user_id="application-approval-required@example.com", message=initial_message, context_json=context_json, ) ) service.run( OrchestratorRequest( source="user_message", user_id="application-approval-required@example.com", conversation_id=first.conversation_id, message="飞机", context_json=context_json, ) ) preview = service.run( OrchestratorRequest( source="user_message", user_id="application-approval-required@example.com", conversation_id=first.conversation_id, message="预计总费用:12000元", context_json=context_json, ) ) def approval_required_parse_for_run(self, request, run_id): # noqa: ANN001 return OntologyParseResult( scenario="expense", intent="operate", entities=[], permission=OntologyPermission( level="approval_required", allowed=False, reason="操作类请求需要人工审批确认。", ), confidence=0.95, missing_slots=[], ambiguity=[], clarification_required=False, clarification_question=None, run_id=run_id, ) monkeypatch.setattr( "app.services.ontology.SemanticOntologyService.parse_for_run", approval_required_parse_for_run, ) submitted = service.run( OrchestratorRequest( source="user_message", user_id="application-approval-required@example.com", conversation_id=first.conversation_id, message="确认提交", context_json=context_json, ) ) assert preview.status == "blocked" assert submitted.status == "succeeded" assert submitted.requires_confirmation is False assert "操作类请求需要人工审批确认" not in submitted.result["answer"] assert "当前仅返回确认摘要" not in submitted.result["answer"] assert "当前操作已完成,单据已经推送给 陈硕 进行审核,请耐心等待" in submitted.result["answer"] assert submitted.result["draft_payload"]["status"] == "submitted"