import json import os import tempfile from datetime import date, datetime, timedelta from pathlib import Path import pytest from fastapi import HTTPException from sqlalchemy import select from app.modules.ai_agent.constants import AIProviderName, AIResponseKey from app.modules.business.constants import BusinessResponseKey, StatusValue _db = tempfile.NamedTemporaryFile(delete=False, suffix=".db") _db.close() os.environ["DATABASE_URL"] = "sqlite:///" + _db.name.replace("\\", "/") os.environ["API_KEY"] = "test-key" os.environ["AUDIT_API_KEY"] = "audit-key" os.environ["AUDIT_API_ACTOR"] = "audit-manager" os.environ["FEISHU_APP_ID"] = "" os.environ["FEISHU_APP_SECRET"] = "" os.environ["FEISHU_VERIFICATION_TOKEN"] = "test-feishu-token" os.environ["LEGACY_ALLOWED_QUERIES"] = "{}" os.environ["LEGACY_DATABASE_URL"] = "" os.environ["LEGACY_PROJECT_QUERY"] = "" os.environ["MODEL_PROVIDER"] = AIProviderName.NOOP os.environ["SCHEDULER_ENABLED"] = "false" from fastapi.testclient import TestClient from app.core.config import Settings, get_settings from app.core.database import Base, SessionLocal, engine from app.core.http.pagination import bounded_limit, bounded_offset from app.core.security import require_api_key, require_audit_api_key from app.main import _allow_cors_credentials, app from app.modules.audit.constants import AUDIT_REDACTED_VALUE from app.modules.ai_memory.constants import ( AIMemoryPayloadKey, AIMemoryResponseKey, AIMemoryStatus, ) from app.modules.events.constants import ( EventAggregateType, EventPayloadKey, EventSource, EventStatus, EventType, ) from app.modules.events.services import EventService from app.modules.business.registry import get_domain_model from app.modules.business.models import ( Employee, Project, ProjectCashFlow, ProjectContract, ProjectMember, ProjectMilestone, ) from app.modules.business.service import _model_payload, serialize_model from app.modules.legacy_mysql.services import LegacyMySQLService from app.modules.legacy_mysql.intasect import ( CONTRACT_RECEIVABLE_SQL, CONTRACT_SQL, EPOCH, IntasectSyncService, PROJECT_FUND_SQL, _contract_payload, _contract_receivable_payload, _employee_payload, _project_fund_payload, _project_payload, ) from app.modules.business.constants import ( CashFlowDirection, CashFlowType, DataQualityStatus, ) from app.modules.ai_memory.service import AIMemoryService from app.modules.ai_memory.models import AIMemoryEntry from app.modules.observability.constants import ( HeartbeatComponent, ObservabilityKey, ) from app.modules.observability.service import ObservabilityService from app.modules.reports.constants import ( EnterpriseAnalyticsKey, LifecycleAttentionKey, LifecycleResponseKey, LifecycleSection, MetricKey, ReportPushStatus, ReportTitle, ReportType, ) from app.modules.reports.lifecycle_pipeline import LifecyclePipelineService from app.modules.reports.chart import render_lifecycle_chart from app.modules.reports.services import ReportService from app.modules.feishu.service import FeishuService from app.modules.feishu.commands import FeishuCommandService from app.modules.feishu.events import FeishuEventService from app.modules.feishu.constants import FeishuEventSource from app.modules.risk.constants import RiskEventActionValue from app.modules.workflows.constants import WorkflowStatus, WorkflowType from app.modules.workflows.models import WorkflowInstance Base.metadata.create_all(bind=engine) client = TestClient(app) headers = {"X-API-Key": "test-key"} audit_headers = {"X-API-Key": "test-key", "X-Audit-API-Key": "audit-key"} class SeedResponse: def __init__(self, payload: dict, status_code: int = 200): self.status_code = status_code self._payload = payload def json(self) -> dict: return self._payload def create_business_record(domain: str, data: dict, actor: str = "pytest") -> SeedResponse: _ = actor db = SessionLocal() try: model = get_domain_model(domain) record = model(**_model_payload(domain, model, data)) db.add(record) db.commit() db.refresh(record) return SeedResponse( { BusinessResponseKey.DOMAIN: domain, BusinessResponseKey.DATA: serialize_model(record), } ) finally: db.close() def teardown_module() -> None: engine.dispose() path = Path(_db.name) if path.exists(): path.unlink() def test_project_report_and_feishu_command_preview() -> None: response = create_business_record( "projects", { "code": "P-SMOKE-001", "name": "Smoke Project", "owner": "tester", "status": "执行中", "budget_amount": 1000, "actual_amount": 200, }, ) assert response.status_code == 200 assert response.json()["data"]["code"] == "P-SMOKE-001" response = client.get("/api/v1/reports/daily-brief", headers=headers) assert response.status_code == 200 assert response.json()["title"] == "每日经营晨报" response = client.post( "/api/v1/integrations/feishu/commands/preview", headers=headers, json={"text": "日报", "auto_reply": False}, ) assert response.status_code == 200 assert response.json()["command"] == "daily_brief" def test_feishu_webhook_routes_message_event() -> None: payload = { "schema": "2.0", "header": { "event_id": "evt-smoke-risk-001", "event_type": "im.message.receive_v1", "token": "test-feishu-token", }, "event": { "sender": {"sender_id": {"open_id": "ou_test"}}, "message": { "chat_id": "oc_test", "message_id": "om_smoke_risk_001", "message_type": "text", "content": json.dumps({"text": "risk"}), }, }, } response = client.post("/api/v1/integrations/feishu/webhook", json=payload) assert response.status_code == 200 data = response.json() assert data["handled"] is True assert data["result"]["command"] == "risk_summary" duplicate_response = client.post("/api/v1/integrations/feishu/webhook", json=payload) assert duplicate_response.status_code == 200 assert duplicate_response.json()["duplicate"] is True blocked_logs_response = client.get("/api/v1/audit/logs", headers=headers) assert blocked_logs_response.status_code == 401 logs_response = client.get("/api/v1/audit/logs", headers=audit_headers) assert logs_response.status_code == 200 audit_payload = json.dumps(logs_response.json(), ensure_ascii=False) assert "test-feishu-token" not in audit_payload assert AUDIT_REDACTED_VALUE in audit_payload def test_feishu_rule_commands_create_list_disable_and_enable(monkeypatch) -> None: monkeypatch.setattr( FeishuService, "send_text", lambda *args, **kwargs: pytest.fail("auto_reply=False must not send to Feishu"), ) rule_text = "每日建议必须说明负责人角色、截止时间和验收指标" created = client.post( "/api/v1/integrations/feishu/commands/preview", headers=headers, json={"text": f"学习规则 80:{rule_text}", "auto_reply": False}, ) assert created.status_code == 200 assert created.json()["command"] == "rule_create" assert "规则已学习" in created.json()["content"] db = SessionLocal() try: rule = db.execute( select(AIMemoryEntry).where(AIMemoryEntry.content == rule_text) ).scalar_one() assert rule.importance == 80 assert rule.scope == "global" assert rule.subject == "company" assert "feishu" in rule.tags service = FeishuCommandService(db) listed = service.handle_text("查看规则", auto_reply=False) assert listed["command"] == "rule_list" assert rule.code in listed["content"] disabled = service.handle_text(f"停用规则 {rule.code}", auto_reply=False) assert disabled["command"] == "rule_disable" assert "已停用" in disabled["content"] db.refresh(rule) assert rule.status == AIMemoryStatus.ARCHIVED enabled = service.handle_text(f"启用规则 {rule.code}", auto_reply=False) assert enabled["command"] == "rule_enable" assert "已启用" in enabled["content"] db.refresh(rule) assert rule.status == AIMemoryStatus.ACTIVE finally: db.close() def test_feishu_rule_command_preserves_sender_and_rejects_invalid_input() -> None: payload = { "schema": "2.0", "header": { "event_id": "evt-smoke-rule-actor-001", "event_type": "im.message.receive_v1", "token": "test-feishu-token", }, "event": { "sender": {"sender_id": {"open_id": "ou_rule_teacher"}}, "message": { "chat_id": "oc_test", "message_id": "om_smoke_rule_actor_001", "message_type": "text", "content": json.dumps( {"text": "学习规则:风险建议先写事实依据再写行动"} ), }, }, } db = SessionLocal() try: result = FeishuEventService(db).handle_event( payload, source=FeishuEventSource.WEBHOOK, auto_reply=False, ) assert result["result"]["command"] == "rule_create" rule = db.execute( select(AIMemoryEntry).where( AIMemoryEntry.content == "风险建议先写事实依据再写行动" ) ).scalar_one() assert rule.actor == "ou_rule_teacher" empty = FeishuCommandService(db).handle_text("学习规则:", auto_reply=False) assert empty["command"] == "rule_create" assert "不能为空" in empty["content"] invalid_priority = FeishuCommandService(db).handle_text( "学习规则 101:先写结论", auto_reply=False, ) assert "1 到 100" in invalid_priority["content"] secret = FeishuCommandService(db).handle_text( "学习规则:请保存 password=example", auto_reply=False, ) assert "已拒绝学习" in secret["content"] finally: db.close() def test_v3_request_id_health_and_metrics() -> None: db = SessionLocal() try: ObservabilityService(db).record_heartbeat( component=HeartbeatComponent.WORKER, instance_id="pytest-worker", actor="pytest", ) finally: db.close() response = client.get("/api/v1/health/live", headers={"X-Request-ID": "rid-v3-smoke"}) assert response.status_code == 200 assert response.headers["X-Request-ID"] == "rid-v3-smoke" assert response.json()["status"] == "ok" response = client.get("/api/v1/health/ready") assert response.status_code == 200 assert response.json()["status"] in {"ok", "degraded"} response = client.get("/api/v1/metrics", headers=headers) assert response.status_code == 200 metrics = response.json()["metrics"] assert ObservabilityKey.EVENTS in metrics assert ObservabilityKey.HEARTBEATS in metrics def test_v3_event_idempotency_and_workflow_dispatch() -> None: from app.core.database import SessionLocal db = SessionLocal() try: service = EventService(db) event = service.emit( event_type=EventType.RISK_ACTION_RECORDED, source=EventSource.RISK, aggregate_type=EventAggregateType.RISK_EVENT, aggregate_id="risk-v3-idem", actor="pytest", payload={ EventPayloadKey.ACTION: RiskEventActionValue.ASSIGN, EventPayloadKey.STATUS: StatusValue.OPEN, }, idempotency_key="v3-risk-idempotency", dispatch=True, ) duplicate = service.emit( event_type=EventType.RISK_ACTION_RECORDED, source=EventSource.RISK, aggregate_type=EventAggregateType.RISK_EVENT, aggregate_id="risk-v3-idem", actor="pytest", payload={ EventPayloadKey.ACTION: RiskEventActionValue.ASSIGN, EventPayloadKey.STATUS: StatusValue.OPEN, }, idempotency_key="v3-risk-idempotency", dispatch=True, ) assert duplicate.event_id == event.event_id assert event.status == EventStatus.PROCESSED workflow = db.execute( select(WorkflowInstance).where( WorkflowInstance.workflow_type == WorkflowType.RISK_EVENT_REVIEW, WorkflowInstance.aggregate_id == "risk-v3-idem", ) ).scalar_one() assert workflow.status == WorkflowStatus.RUNNING finally: db.close() def test_v3_event_retry_and_dispatch_pending_route() -> None: db = SessionLocal() try: event = EventService(db).emit( event_type=EventType.REPORT_PUSH_FAILED, source=EventSource.REPORTS, aggregate_type=EventAggregateType.REPORT_PUSH_RUN, aggregate_id="push-v3-retry", actor="pytest", payload={ EventPayloadKey.CODE: "push-v3-retry", EventPayloadKey.STATUS: ReportPushStatus.FAILED, }, idempotency_key="v3-report-push-retry", ) event.status = EventStatus.FAILED event.last_error = "transient" event.attempts = event.max_attempts db.commit() event_id = event.event_id finally: db.close() retry_response = client.post(f"/api/v1/events/{event_id}/retry", headers=headers) assert retry_response.status_code == 200 assert retry_response.json()["event"]["status"] == EventStatus.PENDING assert retry_response.json()["event"]["attempts"] == 0 dispatch_response = client.post("/api/v1/events/dispatch-pending", headers=headers) assert dispatch_response.status_code == 200 dispatched = [ item for item in dispatch_response.json()["items"] if item["event_id"] == event_id ] assert dispatched assert dispatched[0]["status"] == EventStatus.PROCESSED def test_v3_ai_memory_recall_and_auto_write() -> None: response = client.post( "/api/v1/ai/ask", headers=headers, json={ "prompt": "Summarize quarterly cash planning for project memory smoke", "context": { AIMemoryPayloadKey.SCOPE: "project", AIMemoryPayloadKey.SUBJECT: "P-MEM-SMOKE", }, }, ) assert response.status_code == 200 data = response.json() assert data[AIResponseKey.PROVIDER] == AIProviderName.NOOP assert data[AIResponseKey.RAW][AIResponseKey.MEMORY_WRITE][AIMemoryPayloadKey.STATUS] == ( AIMemoryStatus.ACTIVE ) list_response = client.get( "/api/v1/ai/memory?scope=project&subject=P-MEM-SMOKE", headers=headers, ) assert list_response.status_code == 200 assert list_response.json()[AIMemoryResponseKey.ITEMS] recall_response = client.post( "/api/v1/ai/memory/recall", headers=headers, json={ "query": "quarterly cash planning", "scope": "project", "subject": "P-MEM-SMOKE", }, ) assert recall_response.status_code == 200 assert recall_response.json()[AIMemoryResponseKey.ITEMS] def test_v3_risk_action_routes_are_disabled_in_read_only_mode() -> None: response = create_business_record( "risk-events", { "code": "RISK-V3-WF-001", "title": "V3 workflow risk", "risk_type": "manual", "source_domain": "projects", }, ) assert response.status_code == 200 risk_id = response.json()["data"]["id"] response = client.post( f"/api/v1/risks/events/{risk_id}/assign", headers=headers, json={"assigned_to": "risk-owner", "comment": "route to owner"}, ) assert response.status_code == 405 def test_writeback_and_approval_routes_are_removed() -> None: response = client.post( "/api/v1/writebacks", headers=headers, json={ "domain": "projects", "record_id": "P-V3-WB", "action": "sync", "payload": {"code": "P-V3-WB", "name": "Writeback target"}, }, ) assert response.status_code == 404 response = client.post( "/api/v1/approvals", headers=headers, json={ "domain": "projects", "record_id": "P-V3-WB", "action": "writeback:projects", "reason": "V3 writeback gate", "payload": {"code": "P-V3-WB", "name": "Writeback target"}, }, ) assert response.status_code == 404 def test_feishu_webhook_challenge_uses_event_service_verification() -> None: response = client.post( "/api/v1/integrations/feishu/webhook", json={ "challenge": "challenge-token", "token": "test-feishu-token", }, ) assert response.status_code == 200 assert response.json()["challenge"] == "challenge-token" def test_api_key_and_feishu_webhook_fail_closed(monkeypatch) -> None: monkeypatch.setenv("API_KEY", "") get_settings.cache_clear() try: with pytest.raises(HTTPException) as exc_info: require_api_key("test-key") assert exc_info.value.status_code == 503 monkeypatch.setenv("API_KEY", "test-key") get_settings.cache_clear() response = client.post( "/api/v1/integrations/feishu/webhook", json={"schema": "2.0", "header": {"event_type": "im.message.receive_v1"}}, ) assert response.status_code == 401 monkeypatch.setenv("AUDIT_API_KEY", "") get_settings.cache_clear() with pytest.raises(HTTPException) as audit_exc_info: require_audit_api_key("audit-key") assert audit_exc_info.value.status_code == 503 finally: monkeypatch.setenv("API_KEY", "test-key") monkeypatch.setenv("AUDIT_API_KEY", "audit-key") get_settings.cache_clear() def test_service_key_rotation_config(monkeypatch) -> None: monkeypatch.setenv("API_KEY", "") monkeypatch.setenv( "API_KEYS", json.dumps( [ {"key": "disabled-key", "actor": "disabled", "enabled": False}, {"key": "rotated-key", "actor": "rotated-api", "enabled": True}, ] ), ) get_settings.cache_clear() try: assert require_api_key("rotated-key").actor == "rotated-api" with pytest.raises(HTTPException) as exc_info: require_api_key("disabled-key") assert exc_info.value.status_code == 401 finally: monkeypatch.setenv("API_KEY", "test-key") monkeypatch.delenv("API_KEYS", raising=False) get_settings.cache_clear() def test_config_and_pagination_guardrails() -> None: settings = Settings(cors_origins='["https://app.example.com", "https://admin.example.com"]') assert settings.cors_origins == ["https://app.example.com", "https://admin.example.com"] assert _allow_cors_credentials(["*"]) is False assert _allow_cors_credentials(["https://app.example.com"]) is True assert bounded_limit(-1) == 1 assert bounded_limit(1000) == 500 assert bounded_offset(-10) == 0 negative_limit_response = client.get("/api/v1/business/projects?limit=-1", headers=headers) assert negative_limit_response.status_code == 422 oversized_limit_response = client.get("/api/v1/risks/events?limit=501", headers=headers) assert oversized_limit_response.status_code == 422 negative_offset_response = client.get("/api/v1/business/projects?offset=-1", headers=headers) assert negative_offset_response.status_code == 422 def test_dashboard_and_response_masking() -> None: expense_response = create_business_record( "expenses", { "code": "EXP-MASK-001", "expense_type": "办公", "amount": 20, "payment_account": "6222000000000000", }, ) assert expense_response.status_code == 200 masked_response = client.get("/api/v1/business/expenses", headers=headers) assert masked_response.status_code == 200 masked_items = masked_response.json()["items"] assert any(item["code"] == "EXP-MASK-001" for item in masked_items) assert next( item["payment_account"] for item in masked_items if item["code"] == "EXP-MASK-001" ) == "[MASKED]" dashboard_response = client.get("/api/v1/dashboard/summary", headers=headers) assert dashboard_response.status_code == 200 assert "metrics" in dashboard_response.json() def test_configured_domain_response_masking(monkeypatch) -> None: monkeypatch.setenv("MASKED_RESPONSE_FIELDS", json.dumps(["expenses.amount"])) get_settings.cache_clear() try: response = create_business_record( "expenses", { "code": "EXP-MASK-CONFIG-001", "expense_type": "测试", "amount": 123, }, ) assert response.status_code == 200 list_response = client.get("/api/v1/business/expenses", headers=headers) assert list_response.status_code == 200 item = next( item for item in list_response.json()["items"] if item["code"] == "EXP-MASK-CONFIG-001" ) assert item["amount"] == "[MASKED]" finally: monkeypatch.delenv("MASKED_RESPONSE_FIELDS", raising=False) get_settings.cache_clear() def test_business_write_routes_are_disabled_in_read_only_mode() -> None: create_response = client.post( "/api/v1/business/projects", headers=headers, json={ "data": { "code": "P-APPROVAL-BLOCKED", "name": "Blocked project", }, }, ) assert create_response.status_code == 405 update_response = client.patch( "/api/v1/business/projects/1", headers=headers, json={ "data": { "name": "Blocked update", }, }, ) assert update_response.status_code == 405 def test_approval_and_feishu_approval_card_routes_are_removed() -> None: approval_response = client.post( "/api/v1/approvals", headers=headers, json={ "domain": "fund-accounts", "record_id": "feishu-card-test", "action": "update:fund-accounts", "reason": "Card action smoke test", "payload": {"current_balance": 300}, }, ) assert approval_response.status_code == 404 callback_response = client.post( "/api/v1/integrations/feishu/approval-card-action", json={ "token": "test-feishu-token", "operator": {"operator_id": {"open_id": "ou_card_approver"}}, "action": { "value": { "ticket_id": "APR-DISABLED", "decision": "approve", "comment": "approved from card", } }, }, ) assert callback_response.status_code == 404 def test_new_ledgers_reports_and_risk_events() -> None: domains_response = client.get("/api/v1/business/domains", headers=headers) assert domains_response.status_code == 200 domains = domains_response.json()["domains"] assert "attendance-records" in domains assert "work-reports" in domains assert "risk-events" in domains today = date.today() attendance_response = create_business_record( "attendance-records", { "code": "ATT-SMOKE-001", "employee_name": "Tester", "department": "QA", "work_date": today.isoformat(), "status": "正常", }, ) assert attendance_response.status_code == 200 task_response = create_business_record( "tasks", { "code": "TASK-RISK-001", "title": "Overdue smoke task", "owner": "tester", "status": "待办", "due_date": (today - timedelta(days=1)).isoformat(), }, ) assert task_response.status_code == 200 attendance_summary = client.get("/api/v1/reports/attendance-summary", headers=headers) assert attendance_summary.status_code == 200 assert attendance_summary.json()["total"] >= 1 report_response = client.post( "/api/v1/reports/work-reports/generate", headers=headers, json={"report_type": ReportType.DAILY, "reporter": "pytest", "actor": "pytest"}, ) assert report_response.status_code == 200 assert report_response.json()["data"] is None assert report_response.json()["report"]["report_type"] == ReportType.DAILY risk_response = client.post( "/api/v1/risks/events/generate?actor=pytest", headers=headers, ) assert risk_response.status_code == 405 enqueue_response = client.post("/api/v1/risks/events/enqueue", headers=headers) assert enqueue_response.status_code == 405 overdue_response = client.get("/api/v1/risks/overdue-tasks", headers=headers) assert overdue_response.status_code == 200 assert any(item["code"] == "TASK-RISK-001" for item in overdue_response.json()["items"]) def test_project_lifecycle_report_summarizes_progress_cost_and_risk() -> None: today = date.today() project_code = "P-LIFECYCLE-001" project_response = create_business_record( "projects", { "code": project_code, "name": "Lifecycle Project", "owner": "lifecycle-owner", "status": "执行中", "progress_percent": 40, "budget_amount": 1000, "actual_amount": 1500, "due_date": (today - timedelta(days=1)).isoformat(), }, ) assert project_response.status_code == 200 task_response = create_business_record( "tasks", { "code": "TASK-LIFECYCLE-001", "title": "Lifecycle overdue task", "project_code": project_code, "owner": "lifecycle-owner", "status": "待办", "due_date": (today - timedelta(days=1)).isoformat(), "blocker": "waiting for decision", }, ) assert task_response.status_code == 200 procurement_response = create_business_record( "procurements", { "code": "PROC-LIFECYCLE-001", "name": "Lifecycle procurement", "project_code": project_code, "expected_amount": 300, "actual_amount": 100, "approval_status": StatusValue.PENDING_APPROVAL, "delivery_status": StatusValue.UNDELIVERED, "payment_status": StatusValue.UNPAID, }, ) assert procurement_response.status_code == 200 expense_response = create_business_record( "expenses", { "code": "EXP-LIFECYCLE-001", "expense_type": "差旅", "amount": 80, "project_code": project_code, "approval_status": StatusValue.PENDING_APPROVAL, "payment_status": StatusValue.UNPAID, }, ) assert expense_response.status_code == 200 attendance_response = create_business_record( "attendance-records", { "code": "ATT-LIFECYCLE-001", "employee_name": "Lifecycle Tester", "project_code": project_code, "work_date": today.isoformat(), "status": StatusValue.MISSING_PUNCH, }, ) assert attendance_response.status_code == 200 response = client.get( f"/api/v1/reports/project-lifecycle?project_code={project_code}", headers=headers, ) assert response.status_code == 200 data = response.json() assert data[LifecycleResponseKey.TITLE] == ReportTitle.PROJECT_LIFECYCLE assert data[LifecycleResponseKey.METRICS][LifecycleSection.PROJECTS][MetricKey.TOTAL] == 1 assert data[LifecycleResponseKey.METRICS][LifecycleSection.PROJECTS][MetricKey.DELAYED] == 1 assert ( data[LifecycleResponseKey.METRICS][LifecycleSection.PROJECTS][MetricKey.OVER_BUDGET] == 1 ) assert data[LifecycleResponseKey.METRICS][LifecycleSection.TASKS][MetricKey.OVERDUE] == 1 assert ( data[LifecycleResponseKey.METRICS][LifecycleSection.PROCUREMENTS][ MetricKey.PENDING_APPROVAL ] == 1 ) assert ( data[LifecycleResponseKey.METRICS][LifecycleSection.EXPENSES][ MetricKey.PENDING_APPROVAL ] == 1 ) assert data[LifecycleResponseKey.METRICS][LifecycleSection.ATTENDANCE][MetricKey.ABNORMAL] == 1 assert ( data[LifecycleResponseKey.ATTENTION][LifecycleAttentionKey.DELAYED_PROJECTS][0]["code"] == project_code ) assert "生命周期健康分" in data[LifecycleResponseKey.CONTENT] assert data[LifecycleResponseKey.RECOMMENDATIONS] ai_response = client.get( f"/api/v1/reports/project-lifecycle?project_code={project_code}&include_ai=true", headers=headers, ) assert ai_response.status_code == 200 ai_data = ai_response.json() assert ai_data[LifecycleResponseKey.AI_ANALYSIS][AIResponseKey.OK] is True assert ( ai_data[LifecycleResponseKey.AI_ANALYSIS][AIResponseKey.PROVIDER] == AIProviderName.NOOP ) def test_v3_enterprise_analytics_returns_read_only_sections() -> None: performance_response = create_business_record( "performance-metrics", { "code": "PERF-V3-001", "name": "V3 delivery score", "weight": 20, "auto_score": 82, "confirmed_score": 78, "status": StatusValue.REVIEWED, }, ) assert performance_response.status_code == 200 response = client.get("/api/v1/reports/enterprise-analytics", headers=headers) assert response.status_code == 200 data = response.json() assert data[EnterpriseAnalyticsKey.TITLE] == ReportTitle.ENTERPRISE_ANALYTICS assert EnterpriseAnalyticsKey.FINANCE in data assert EnterpriseAnalyticsKey.PROCUREMENT in data assert EnterpriseAnalyticsKey.PERFORMANCE in data assert EnterpriseAnalyticsKey.OPERATIONS in data assert data[EnterpriseAnalyticsKey.PERFORMANCE][MetricKey.CONFIRMED] >= 1 db = SessionLocal() try: workflow = db.execute( select(WorkflowInstance).where( WorkflowInstance.workflow_type == WorkflowType.ENTERPRISE_ANALYTICS, WorkflowInstance.aggregate_id == data[EnterpriseAnalyticsKey.CODE], ) ).scalar_one() assert workflow.status == WorkflowStatus.COMPLETED finally: db.close() def test_work_report_counts_pending_approval_backlog_outside_period() -> None: today = date.today() project_code = "P-BACKLOG-001" report_day = today - timedelta(days=7) procurement_response = create_business_record( "procurements", { "code": "PROC-BACKLOG-001", "name": "Backlog procurement", "project_code": project_code, "approval_status": StatusValue.PENDING_APPROVAL, }, ) assert procurement_response.status_code == 200 expense_response = create_business_record( "expenses", { "code": "EXP-BACKLOG-001", "expense_type": "办公", "amount": 50, "project_code": project_code, "approval_status": StatusValue.PENDING_APPROVAL, }, ) assert expense_response.status_code == 200 report_response = client.post( "/api/v1/reports/work-reports/generate", headers=headers, json={ "report_type": ReportType.DAILY, "project_code": project_code, "period_start": report_day.isoformat(), "period_end": report_day.isoformat(), "persist": False, }, ) assert report_response.status_code == 200 metrics = report_response.json()["report"]["metrics"] assert metrics["procurements_pending"] == 1 assert metrics["expenses_pending"] == 1 def test_legacy_task_read_query_allowed_but_sync_disabled(monkeypatch) -> None: rows = [ { "id": 9001, "task_name": "Legacy task one", "project_code": "P-SMOKE-001", "owner": "legacy-owner", "status": "待办", } ] def fake_execute_allowed_query(self, query_name, params=None, limit=100): return {"columns": list(rows[0]), "rows": rows, "row_count": len(rows)} monkeypatch.setattr( LegacyMySQLService, "execute_allowed_query", fake_execute_allowed_query, ) query_response = client.post( "/api/v1/integrations/mysql/query", headers=headers, json={"query_name": "legacy_tasks"}, ) assert query_response.status_code == 200 assert query_response.json()["row_count"] == 1 assert query_response.json()["rows"][0]["task_name"] == "Legacy task one" response = client.post( "/api/v1/integrations/mysql/tasks/sync", headers=headers, json={ "dry_run": False, "field_map": {"title": "task_name"}, }, ) assert response.status_code == 405 def test_risk_event_action_routes_are_disabled_in_read_only_mode() -> None: create_response = create_business_record( "risk-events", { "code": "RISK-FLOW-001", "title": "Workflow risk", "risk_type": "manual", "risk_level": "medium", "source_domain": "projects", "source_record_id": "P-SMOKE-001", "status": "open", }, ) assert create_response.status_code == 200 event_id = create_response.json()["data"]["id"] assign_response = client.post( f"/api/v1/risks/events/{event_id}/assign", headers=headers, json={"assigned_to": "risk-owner", "comment": "please handle"}, ) assert assign_response.status_code == 405 comment_response = client.post( f"/api/v1/risks/events/{event_id}/comment", headers=headers, json={"comment": "working on it", "payload": {"step": 1}}, ) assert comment_response.status_code == 405 resolve_response = client.post( f"/api/v1/risks/events/{event_id}/resolve", headers=headers, json={"comment": "resolved"}, ) assert resolve_response.status_code == 405 close_response = client.post( f"/api/v1/risks/events/{event_id}/close", headers=headers, json={ "closed_reason": "verified", "review_summary": "handled", }, ) assert close_response.status_code == 405 reopen_response = client.post( f"/api/v1/risks/events/{event_id}/reopen", headers=headers, json={"comment": "recheck"}, ) assert reopen_response.status_code == 405 actions_response = client.get( f"/api/v1/risks/events/{event_id}/actions", headers=headers, ) assert actions_response.status_code == 200 assert actions_response.json()["items"] == [] def test_report_push_failure_is_recorded() -> None: response = client.post( "/api/v1/reports/daily-brief/push", headers=headers, json={"receive_id": "oc_missing_config"}, ) assert response.status_code == 503 runs_response = client.get( f"/api/v1/reports/push-runs?status={ReportPushStatus.FAILED}", headers=headers, ) assert runs_response.status_code == 200 assert any( item["title"] == ReportTitle.DAILY_BRIEF for item in runs_response.json()["items"] ) dashboard_response = client.get("/api/v1/dashboard/summary", headers=headers) assert dashboard_response.status_code == 200 assert dashboard_response.json()["metrics"]["failed_push_runs"] >= 1 def test_ai_noop_provider() -> None: response = client.post( "/api/v1/ai/ask", headers=headers, json={ "prompt": "生成项目摘要", "actor": "pytest", "context": {"project": "P-SMOKE-001"}, }, ) assert response.status_code == 200 assert response.json()[AIResponseKey.PROVIDER] == AIProviderName.NOOP def test_legacy_project_payload_does_not_create_legacy_none_code() -> None: payload = LegacyMySQLService(None)._project_payload({"name": "Missing Id"}, {}) assert payload["code"] is None assert payload["external_id"] is None def test_legacy_readonly_query_requires_allowlist(monkeypatch) -> None: monkeypatch.delenv("LEGACY_PROJECT_QUERY", raising=False) get_settings.cache_clear() try: with pytest.raises(HTTPException) as blocked_exc_info: LegacyMySQLService(None).execute_readonly("SELECT id FROM secret_projects") assert blocked_exc_info.value.status_code == 403 monkeypatch.setenv("LEGACY_PROJECT_QUERY", "SELECT id FROM projects") get_settings.cache_clear() def unavailable_engine(): raise HTTPException(status_code=503, detail="legacy unavailable") monkeypatch.setattr( LegacyMySQLService, "_ensure_engine", staticmethod(unavailable_engine), ) with pytest.raises(HTTPException) as engine_exc_info: LegacyMySQLService(None).execute_readonly("SELECT id FROM projects") assert engine_exc_info.value.status_code == 503 finally: monkeypatch.delenv("LEGACY_PROJECT_QUERY", raising=False) get_settings.cache_clear() def test_legacy_readonly_query_clamps_param_limit(monkeypatch) -> None: captured: dict[str, dict] = {} class FakeResult: def mappings(self) -> "FakeResult": return self def all(self) -> list: return [] class FakeConnection: def __enter__(self) -> "FakeConnection": return self def __exit__(self, exc_type, exc, traceback) -> None: return None def execute(self, statement, params): captured["params"] = params return FakeResult() class FakeEngine: def connect(self) -> FakeConnection: return FakeConnection() monkeypatch.setenv("LEGACY_PROJECT_QUERY", "SELECT id FROM projects LIMIT :limit") get_settings.cache_clear() monkeypatch.setattr( LegacyMySQLService, "_ensure_engine", staticmethod(lambda: FakeEngine()), ) try: result = LegacyMySQLService(None).execute_readonly( "SELECT id FROM projects LIMIT :limit", {"limit": 9999}, limit=9999, ) assert result["row_count"] == 0 assert captured["params"]["limit"] == 500 with pytest.raises(HTTPException) as exc_info: LegacyMySQLService(None).execute_readonly( "SELECT id FROM projects LIMIT :limit", {"limit": "invalid"}, ) assert exc_info.value.status_code == 422 finally: monkeypatch.delenv("LEGACY_PROJECT_QUERY", raising=False) get_settings.cache_clear() def test_intasect_mapping_uses_stable_ids_and_preserves_unknown_stage() -> None: seen_at = datetime(2026, 7, 12, 9, 0) project = _project_payload( { "source_id": 99, "business_code": None, "pro_sn": None, "name": "Lifecycle Project", "mgr_deptid": 7, "mgr_deptname": "Delivery", "mgr_user_id": 8, "mgr_user_name": "Manager", "project_stage": "ZZYGD", "stage_label": "ZZYGD", "archive_flag": "0", "source_created_at": seen_at, "source_updated_at": seen_at, "contract_date": None, "project_information": None, }, seen_at, ) employee = _employee_payload( { "source_id": 8, "employee_name": "Employee", "dept_id": 7, "dept_name": "Delivery", "employment_status": "0", "ding_id": None, "title": "Engineer", "hired_date": None, "source_created_at": seen_at, "source_updated_at": seen_at, }, seen_at, ) assert project["code"] == "INTASECT-PROJECT-99" assert project["display_code"] is None assert project["source_stage_label"] == "ZZYGD" assert project["progress_percent"] == 0 assert employee["code"] == "INTASECT-EMPLOYEE-8" assert employee["ding_user_id"] is None def test_intasect_full_sync_marks_missing_projects_inactive() -> None: row = { "source_key": "99101", "source_id": 99101, "business_code": "B-99101", "pro_sn": None, "name": "Synced Project", "mgr_deptid": None, "mgr_deptname": None, "mgr_user_id": None, "mgr_user_name": None, "project_stage": "XMQD", "stage_label": "项目启动", "archive_flag": "0", "source_created_at": datetime(2026, 1, 1), "source_updated_at": datetime(2026, 7, 1), "contract_date": None, "project_done_date": None, "project_information": None, } class FakeSource: def __init__(self, rows): self.rows = rows def fetch_page(self, dataset, after_key, watermark_at, limit): assert dataset == "projects" assert watermark_at == EPOCH return self.rows if not after_key else [] db = SessionLocal() try: IntasectSyncService(db, FakeSource([row])).sync_dataset("projects", "RUN-1") project = db.execute( select(Project).where(Project.external_id == "99101") ).scalar_one() assert project.is_active is True assert project.display_code == "B-99101" IntasectSyncService(db, FakeSource([])).sync_dataset("projects", "RUN-2") db.refresh(project) assert project.is_active is False finally: db.close() def test_personnel_lifecycle_does_not_treat_missing_ding_mapping_as_absence() -> None: db = SessionLocal() try: employee = Employee( code="INTASECT-EMPLOYEE-99102", name="Lifecycle Employee", department_name="Delivery", employment_status="在职", source_system="legacy_mysql", external_id="99102", ding_user_id=None, is_active=True, ) project = Project( code="INTASECT-PROJECT-99102", name="Lifecycle Report Project", status=StatusValue.RUNNING, source_system="legacy_mysql", external_id="99102", source_archived=False, is_active=True, ) db.add_all([employee, project]) db.flush() db.add( ProjectMember( code="INTASECT-MEMBER-99102", project_code=project.code, employee_code=employee.code, workload_percent=120, source_system="legacy_mysql", external_id="99102", is_active=True, ) ) db.add( ProjectMilestone( code="INTASECT-MILESTONE-99102", project_code=project.code, source_stage_id="stage-1", stage_name="项目启动", plan_end=date.today() - timedelta(days=1), status=StatusValue.RUNNING, is_overdue=True, source_system="legacy_mysql", external_id="99102", is_active=True, ) ) db.commit() report = ReportService(db).personnel_lifecycle_report( employee_code=employee.code, project_code=project.code, ) item = report["items"][0] assert item["attendance_covered"] is False assert item["attendance_abnormal"] == 0 assert item["needs_attention"] is True management = ReportService(db).management_lifecycle_report( ReportType.DAILY, include_ai=True, ) assert management["metrics"]["projects"]["milestones"]["overdue"] >= 1 assert management["ai_analysis"]["ok"] is False assert "降级为确定性基础报告" not in management["content"] assert "首期未接入" in management["content"] finally: db.close() def test_lifecycle_pipeline_is_idempotent(monkeypatch) -> None: monkeypatch.setattr( IntasectSyncService, "sync_all", lambda self, run_code, force_full=False, batch_size=500: { "projects": {"processed": 1} }, ) monkeypatch.setattr( ReportService, "management_lifecycle_report", lambda self, report_type, actor, include_ai: { "title": "Lifecycle", "report_type": report_type, "lines": ["ok"], "content": "ok", "ai_analysis": {"ok": True, "answer": "analysis"}, }, ) monkeypatch.setattr(ReportService, "push_report", lambda self, *args, **kwargs: {"ok": True}) db = SessionLocal() try: service = LifecyclePipelineService(db) first = service.run(ReportType.WEEKLY, actor="pytest") second = service.run(ReportType.WEEKLY, actor="pytest") assert first["deduplicated"] is False assert second["deduplicated"] is True assert first["workflow_code"] == second["workflow_code"] finally: db.close() def test_lifecycle_enqueue_respects_read_only_guard(monkeypatch) -> None: monkeypatch.setenv("READ_ONLY_MODE", "true") get_settings.cache_clear() try: response = client.post( "/api/v1/reports/lifecycle/enqueue", headers=headers, json={"report_type": "daily"}, ) assert response.status_code == 405 finally: monkeypatch.delenv("READ_ONLY_MODE", raising=False) get_settings.cache_clear() def test_lifecycle_report_api_validates_filters_and_report_type() -> None: response = client.get( "/api/v1/reports/personnel-lifecycle", headers=headers, params={"department": "Delivery"}, ) assert response.status_code == 200 assert "metrics" in response.json() invalid = client.post( "/api/v1/reports/lifecycle/enqueue", headers=headers, json={"report_type": "monthly"}, ) assert invalid.status_code == 422 def test_ai_unavailable_sends_notice_without_business_report(monkeypatch) -> None: monkeypatch.setattr( IntasectSyncService, "sync_all", lambda self, run_code, force_full=False, batch_size=500: { "projects": {"processed": 1} }, ) monkeypatch.setattr( ReportService, "management_lifecycle_report", lambda self, report_type, actor, include_ai: { "title": "Lifecycle", "report_type": report_type, "lines": ["must not be sent"], "content": "must not be sent", "ai_analysis": {"ok": False, "type": "TimeoutError"}, }, ) monkeypatch.setattr( ReportService, "push_report", lambda self, *args, **kwargs: pytest.fail("business report must not be sent"), ) monkeypatch.setattr( LifecyclePipelineService, "_notify_ai_unavailable", lambda self, *args, **kwargs: True, ) db = SessionLocal() try: result = LifecyclePipelineService(db).run(ReportType.DAILY, actor="pytest") assert result["status"] == WorkflowStatus.FAILED assert result["ai_unavailable"] is True assert result["notified"] is True finally: db.close() def test_user_rules_are_prioritized_in_ai_context(monkeypatch) -> None: captured: dict = {} class RuleAwareAdapter: provider_name = "rule-aware" def ask(self, prompt, context): captured["context"] = context return {"answer": "followed", "raw": {}} monkeypatch.setenv("AI_MEMORY_AUTO_WRITE_ENABLED", "false") get_settings.cache_clear() monkeypatch.setattr("app.modules.ai_agent.service.get_adapter", lambda: RuleAwareAdapter()) db = SessionLocal() try: rule = AIMemoryService(db).create_rule( content="所有项目风险建议必须注明负责人角色和完成时间", scope="global", subject="company", priority=90, tags=["report"], actor="pytest", ) from app.modules.ai_agent.service import AIService response = AIService(db).ask("分析项目风险", actor="pytest") assert response["answer"] == "followed" assert captured["context"]["user_rules"][0]["rule"] == rule["content"] AIMemoryService(db).update_rule( code=rule["code"], content=None, priority=None, tags=None, enabled=False, actor="pytest", ) assert all(item["code"] != rule["code"] for item in AIMemoryService(db).active_rules()) finally: db.close() monkeypatch.delenv("AI_MEMORY_AUTO_WRITE_ENABLED", raising=False) get_settings.cache_clear() def test_lifecycle_chart_is_uploaded_and_embedded_in_feishu_card(monkeypatch) -> None: chart_data = { "period": "2026-07-11", "projects": {"total": 100, "unarchived": 70, "archived": 30}, "risks": {"overdue_milestones": 5, "overdue_tasks": 8, "open_events": 3}, "people": {"active": 60, "attention": 7, "attendance_mapped": 42}, } png = render_lifecycle_chart(chart_data) assert png.startswith(b"\x89PNG\r\n\x1a\n") captured: dict = {} monkeypatch.setattr( FeishuService, "upload_image", lambda self, image, actor: {"data": {"image_key": "img_test"}}, ) def fake_send_card(self, card, receive_id, receive_id_type, actor): captured["card"] = card return {"code": 0} monkeypatch.setattr(FeishuService, "send_card", fake_send_card) db = SessionLocal() try: ReportService(db).push_report( { "title": "Lifecycle", "report_type": "daily", "lines": ["AI analysis"], "content": "AI analysis", "chart_data": chart_data, }, "chat-test", "chat_id", "pytest", ) assert captured["card"]["elements"][0]["tag"] == "img" assert captured["card"]["elements"][0]["img_key"] == "img_test" finally: db.close() def test_user_rule_api_creates_and_disables_rule(monkeypatch) -> None: monkeypatch.setenv("READ_ONLY_MODE", "false") get_settings.cache_clear() try: created = client.post( "/api/v1/ai/rules", headers=headers, json={ "content": "日报分析先说明延期项目,再给出负责人和时限", "scope": "global", "subject": "company", "priority": 80, "tags": ["daily"], }, ) assert created.status_code == 200 code = created.json()["data"]["code"] disabled = client.patch( f"/api/v1/ai/rules/{code}", headers=headers, json={"enabled": False}, ) assert disabled.status_code == 200 assert disabled.json()["data"]["status"] == "archived" finally: monkeypatch.delenv("READ_ONLY_MODE", raising=False) get_settings.cache_clear() def test_intasect_finance_mapping_normalizes_units_and_excludes_sensitive_fields() -> None: seen_at = datetime(2026, 7, 12, 10, 0) project = _project_payload( { "source_id": 99301, "business_code": "FIN-99301", "pro_sn": None, "name": "Finance Project", "mgr_deptid": None, "mgr_deptname": None, "mgr_user_id": None, "mgr_user_name": None, "project_stage": "JD50", "stage_label": "执行中", "archive_flag": "0", "contract_money": "12.34", "project_invest_amount": "56.78", "contract_date": None, "project_done_date": None, "project_information": None, "source_created_at": seen_at, "source_updated_at": seen_at, }, seen_at, ) contract = _contract_payload( { "source_id": 501, "project_id": 99301, "linked_project_id": 99301, "project_del_flag": "0", "contract_type_code": "3", "contract_type_label": "监理合同", "contract_amount": "123400", "contract_date": date(2026, 1, 1), "date_start": date(2026, 1, 1), "date_end": date(2026, 12, 31), "invoice_type_code": "D1", "source_created_at": seen_at, }, seen_at, ) receivable = _contract_receivable_payload( { "source_id": 601, "source_contract_id": 501, "linked_contract_id": 501, "project_id": 99301, "linked_project_id": 99301, "project_del_flag": "0", "category_code": "HTQSH", "category_label": "合同签署后", "planned_amount": "1000", "actual_amount": "200", "planned_date": date(2026, 7, 20), "actual_date": date(2026, 7, 10), "payment_status": "N", "invoice_status": "Y", "source_created_at": seen_at, }, seen_at, ) fund = _project_fund_payload( { "source_id": 701, "project_id": 99301, "linked_project_id": 99301, "project_del_flag": "0", "cost_class": "0", "category_code": "2", "category_label": "投标保证金", "planned_amount": "3000", "approval_status": "1", "confirm_status": "Y", "trade_time": datetime(2026, 7, 11, 9, 0), "source_created_at": seen_at, "source_updated_at": seen_at, }, seen_at, ) assert project["source_contract_amount"] == 123400 assert project["source_project_investment_amount"] == 567800 assert contract["amount"] == 123400 assert receivable["direction"] == CashFlowDirection.INFLOW assert receivable["data_quality_status"] == DataQualityStatus.STATUS_AMOUNT_MISMATCH assert fund["direction"] == CashFlowDirection.OUTFLOW assert fund["actual_amount"] == 3000 for sql in (CONTRACT_SQL, CONTRACT_RECEIVABLE_SQL, PROJECT_FUND_SQL): lowered = sql.lower() assert "bank_account" not in lowered assert "phone" not in lowered assert "payee_mobile" not in lowered def test_project_finance_needs_calculates_horizons_and_funding_range() -> None: db = SessionLocal() project_code = "INTASECT-PROJECT-99302" reference = date(2026, 7, 12) try: project = Project( code=project_code, display_code="FIN-99302", name="Funding Needs Project", status=StatusValue.RUNNING, source_system="legacy_mysql", external_id="99302", source_archived=False, is_active=True, ) db.add(project) db.add( ProjectContract( code="INTASECT-CONTRACT-99302", project_code=project_code, amount=10000, data_quality_status=DataQualityStatus.VALID, source_system="legacy_mysql", external_id="99302", is_active=True, ) ) db.add_all( [ ProjectCashFlow( code="INTASECT-RECEIVABLE-9930201", project_code=project_code, contract_code="INTASECT-CONTRACT-99302", flow_type=CashFlowType.CONTRACT_RECEIVABLE, direction=CashFlowDirection.INFLOW, planned_amount=1000, actual_amount=200, planned_date=reference + timedelta(days=8), payment_status="N", data_quality_status=DataQualityStatus.STATUS_AMOUNT_MISMATCH, source_system="legacy_mysql", external_id="receivable:9930201", is_active=True, ), ProjectCashFlow( code="INTASECT-RECEIVABLE-9930202", project_code=project_code, contract_code="INTASECT-CONTRACT-99302", flow_type=CashFlowType.CONTRACT_RECEIVABLE, direction=CashFlowDirection.INFLOW, planned_amount=500, actual_amount=0, planned_date=reference - timedelta(days=1), payment_status="N", data_quality_status=DataQualityStatus.VALID, source_system="legacy_mysql", external_id="receivable:9930202", is_active=True, ), ProjectCashFlow( code="INTASECT-FUND-9930203", project_code=project_code, flow_type=CashFlowType.PROJECT_FUND, direction=CashFlowDirection.OUTFLOW, planned_amount=1500, approval_status="1", confirmation_status="N", data_quality_status=DataQualityStatus.VALID, source_system="legacy_mysql", external_id="fund:9930203", is_active=True, ), ProjectCashFlow( code="INTASECT-FUND-9930204", project_code=project_code, flow_type=CashFlowType.PROJECT_FUND, direction=CashFlowDirection.OUTFLOW, planned_amount=300, actual_amount=300, approval_status="1", confirmation_status="Y", data_quality_status=DataQualityStatus.VALID, source_system="legacy_mysql", external_id="fund:9930204", is_active=True, ), ] ) db.commit() report = ReportService(db).project_finance_needs_report( project_code=project_code, as_of=reference, ) item = report["items"][0] assert item["actual_receipt"] == 200 assert item["overdue_receivable"] == 500 assert item["receivable_due"]["7"] == 0 assert item["receivable_due"]["30"] == 800 assert item["funding_need"]["7"] == {"lower": 1500, "upper": 1500} assert item["funding_need"]["30"] == {"lower": 700, "upper": 1500} assert report["summary"]["confirmed_outflow"] == 300 assert report["disclaimer"].startswith("项目资金安排需求不包含公司账户余额") assert "不代表真实融资缺口" in report["content"] finally: db.close() def test_project_finance_needs_does_not_render_missing_data_as_zero() -> None: db = SessionLocal() project_code = "INTASECT-PROJECT-99304" try: db.add( Project( code=project_code, name="No Finance Data Project", status=StatusValue.RUNNING, source_system="legacy_mysql", external_id="99304", source_archived=False, is_active=True, ) ) db.commit() report = ReportService(db).project_finance_needs_report(project_code=project_code) assert report["summary"]["data_available"] is False assert report["summary"]["contract_revenue"] is None assert report["items"][0]["pending_outflow"] is None assert "金额不按零值解释" in report["content"] assert report["finance_chart_data"] is None finally: db.close() def test_finance_sync_soft_deactivates_missing_contract() -> None: row = { "source_key": "99303", "source_id": 99303, "project_id": 99303, "linked_project_id": 99303, "project_del_flag": "0", "contract_type_code": "3", "contract_type_label": "监理合同", "contract_amount": 5000, "contract_date": date(2026, 1, 1), "date_start": None, "date_end": None, "invoice_type_code": "D1", "source_created_at": datetime(2026, 1, 1), } class FakeSource: def __init__(self, rows): self.rows = rows def fetch_page(self, dataset, after_key, watermark_at, limit): assert dataset == "contracts" assert limit == 500 return self.rows if not after_key else [] db = SessionLocal() try: IntasectSyncService(db, FakeSource([row])).sync_dataset("contracts", "FIN-RUN-1") contract = db.execute( select(ProjectContract).where(ProjectContract.external_id == "99303") ).scalar_one() assert contract.is_active is True IntasectSyncService(db, FakeSource([])).sync_dataset("contracts", "FIN-RUN-2") db.refresh(contract) assert contract.is_active is False finally: db.close() def test_finance_api_and_feishu_command_fail_closed_when_ai_unavailable(monkeypatch) -> None: monkeypatch.setenv("FINANCE_NEEDS_ENABLED", "true") get_settings.cache_clear() try: response = client.get( "/api/v1/reports/project-finance-needs", headers=headers, params={"project_code": "INTASECT-PROJECT-99302", "as_of": "2026-07-12"}, ) assert response.status_code == 200 assert response.json()["currency"] == "CNY" db = SessionLocal() try: result = FeishuCommandService(db).handle_text( "项目资金 FIN-99302", actor="ou_finance_test", auto_reply=False, ) assert result["command"] == "project_finance" assert result["reply_type"] == "text" assert "AI 当前不可用" in result["content"] assert "Funding Needs Project" not in result["content"] finally: db.close() finally: monkeypatch.delenv("FINANCE_NEEDS_ENABLED", raising=False) get_settings.cache_clear() def test_feishu_finance_command_returns_ai_analysis_when_available(monkeypatch) -> None: class FinanceAdapter: provider_name = "finance-test" def ask(self, prompt, context): assert context["summary"]["data_available"] is True assert "user_rules" in context assert "owner" not in json.dumps(context["attention"], ensure_ascii=False) return {"answer": "优先安排关键项目资金,并由财务负责人复核。", "raw": {}} monkeypatch.setenv("FINANCE_NEEDS_ENABLED", "true") monkeypatch.setenv("AI_MEMORY_AUTO_WRITE_ENABLED", "false") get_settings.cache_clear() monkeypatch.setattr("app.modules.ai_agent.service.get_adapter", lambda: FinanceAdapter()) db = SessionLocal() rule = None try: rule = AIMemoryService(db).create_rule( content="资金建议必须要求人工确认", scope="global", subject="company", priority=95, tags=["finance"], actor="pytest", ) result = FeishuCommandService(db).handle_text( "项目资金 FIN-99302", actor="ou_finance_test", auto_reply=False, ) assert result["command"] == "project_finance" assert result["reply_type"] == "card" assert "优先安排关键项目资金" in result["content"] finally: if rule is not None: AIMemoryService(db).update_rule( code=rule["code"], content=None, priority=None, tags=None, enabled=False, actor="pytest", ) db.close() monkeypatch.delenv("FINANCE_NEEDS_ENABLED", raising=False) monkeypatch.delenv("AI_MEMORY_AUTO_WRITE_ENABLED", raising=False) get_settings.cache_clear() def test_lifecycle_chart_renders_finance_section() -> None: png = render_lifecycle_chart( { "period": "2026-07-12", "projects": {"total": 1, "unarchived": 1, "archived": 0}, "risks": {}, "people": {}, "finance": { "cashflows": { "confirmed_inflow": 1000, "confirmed_outflow": 300, "pending_outflow": 700, }, "top_projects": [{"name": "Project A", "amount": 700}], }, } ) assert png.startswith(b"\x89PNG\r\n\x1a\n")