From 9a31b0c5aeff7125756bf57a2debc9a5e4d6ea42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vitor=20Hugo=20Belorio=20Sim=C3=A3o?= Date: Mon, 30 Mar 2026 15:44:36 -0300 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(admin):=20estruturar=20configu?= =?UTF-8?q?racao=20e=20relatorios=20da=20fase=204?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entrega a camada de backend da fase 4 com rotas administrativas para configuracao funcional do sistema, separacao explicita dos runtimes do atendimento e da geracao de tools, e as estruturas iniciais de relatorios de vendas, arrecadacao, locacao, fluxo do bot e telemetria conversacional. Tambem adiciona a protecao de escrita no runtime administrativo para bloquear writes diretos nas tabelas operacionais do product, expoe esse snapshot no sistema e amplia a cobertura com testes web para configuracao, relatorios e governanca de escrita. --- admin_app/api/router.py | 6 +- admin_app/api/routes/panel_reports.py | 143 +++ admin_app/api/routes/reports.py | 477 +++++++++ admin_app/api/routes/system.py | 116 ++- admin_app/api/schemas.py | 515 +++++++++- admin_app/db/database.py | 13 +- admin_app/db/write_governance.py | 104 ++ admin_app/services/__init__.py | 4 +- admin_app/services/report_service.py | 963 ++++++++++++++++++ admin_app/services/system_service.py | 179 +++- tests/test_admin_bot_flow_reports_web.py | 116 +++ ...dmin_conversation_telemetry_reports_web.py | 126 +++ tests/test_admin_rental_reports_web.py | 117 +++ tests/test_admin_reports_web.py | 114 +++ tests/test_admin_revenue_reports_web.py | 116 +++ tests/test_admin_sales_reports_web.py | 114 +++ tests/test_admin_system_configuration_web.py | 23 + ...min_system_functional_configuration_web.py | 122 +++ ..._system_model_runtime_configuration_web.py | 117 +++ tests/test_admin_write_governance.py | 60 ++ 20 files changed, 3539 insertions(+), 6 deletions(-) create mode 100644 admin_app/api/routes/panel_reports.py create mode 100644 admin_app/api/routes/reports.py create mode 100644 admin_app/db/write_governance.py create mode 100644 admin_app/services/report_service.py create mode 100644 tests/test_admin_bot_flow_reports_web.py create mode 100644 tests/test_admin_conversation_telemetry_reports_web.py create mode 100644 tests/test_admin_rental_reports_web.py create mode 100644 tests/test_admin_reports_web.py create mode 100644 tests/test_admin_revenue_reports_web.py create mode 100644 tests/test_admin_sales_reports_web.py create mode 100644 tests/test_admin_system_functional_configuration_web.py create mode 100644 tests/test_admin_system_model_runtime_configuration_web.py create mode 100644 tests/test_admin_write_governance.py diff --git a/admin_app/api/router.py b/admin_app/api/router.py index 298a32e..5b6d0a7 100644 --- a/admin_app/api/router.py +++ b/admin_app/api/router.py @@ -5,7 +5,9 @@ from admin_app.api.routes.auth import router as auth_router from admin_app.api.routes.collaborators import router as collaborators_router from admin_app.api.routes.panel_auth import router as panel_auth_router from admin_app.api.routes.panel_collaborators import router as panel_collaborators_router +from admin_app.api.routes.panel_reports import router as panel_reports_router from admin_app.api.routes.panel_tools import router as panel_tools_router +from admin_app.api.routes.reports import router as reports_router from admin_app.api.routes.system import router as system_router from admin_app.api.routes.tools import router as tools_router @@ -13,8 +15,10 @@ api_router = APIRouter() api_router.include_router(auth_router) api_router.include_router(panel_auth_router) api_router.include_router(panel_collaborators_router) +api_router.include_router(panel_reports_router) api_router.include_router(panel_tools_router) api_router.include_router(system_router) +api_router.include_router(reports_router) api_router.include_router(collaborators_router) api_router.include_router(tools_router) -api_router.include_router(audit_router) +api_router.include_router(audit_router) \ No newline at end of file diff --git a/admin_app/api/routes/panel_reports.py b/admin_app/api/routes/panel_reports.py new file mode 100644 index 0000000..ee30dab --- /dev/null +++ b/admin_app/api/routes/panel_reports.py @@ -0,0 +1,143 @@ +from fastapi import APIRouter, Depends + +from admin_app.api.dependencies import get_settings, require_panel_admin_permission +from admin_app.api.schemas import ( + AdminBotFlowReportOverviewResponse, + AdminConversationTelemetryReportOverviewResponse, + AdminRentalReportOverviewResponse, + AdminRevenueReportOverviewResponse, + AdminSalesReportOverviewResponse, +) +from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal +from admin_app.services import ReportService +from shared.contracts import AdminPermission + +router = APIRouter(prefix="/panel/reports", tags=["panel-reports"]) + + +def _build_service(settings: AdminSettings) -> ReportService: + return ReportService(settings) + + +@router.get( + "/sales/overview", + response_model=AdminSalesReportOverviewResponse, +) +def panel_sales_reports_overview( + settings: AdminSettings = Depends(get_settings), + _: AuthenticatedStaffPrincipal = Depends( + require_panel_admin_permission(AdminPermission.VIEW_REPORTS) + ), +): + service = _build_service(settings) + payload = service.build_sales_overview_payload() + return AdminSalesReportOverviewResponse( + service="orquestrador-admin", + domain=payload["domain"], + mode=payload["mode"], + source_dataset_keys=payload["source_dataset_keys"], + metrics=payload["metrics"], + materialization=payload["materialization"], + reports=payload["reports"], + next_steps=payload["next_steps"], + ) + + +@router.get( + "/arrecadacao/overview", + response_model=AdminRevenueReportOverviewResponse, +) +def panel_revenue_reports_overview( + settings: AdminSettings = Depends(get_settings), + _: AuthenticatedStaffPrincipal = Depends( + require_panel_admin_permission(AdminPermission.VIEW_REPORTS) + ), +): + service = _build_service(settings) + payload = service.build_revenue_overview_payload() + return AdminRevenueReportOverviewResponse( + service="orquestrador-admin", + area=payload["area"], + source_domain=payload["source_domain"], + mode=payload["mode"], + source_dataset_keys=payload["source_dataset_keys"], + metrics=payload["metrics"], + materialization=payload["materialization"], + reports=payload["reports"], + next_steps=payload["next_steps"], + ) + + +@router.get( + "/locacao/overview", + response_model=AdminRentalReportOverviewResponse, +) +def panel_rental_reports_overview( + settings: AdminSettings = Depends(get_settings), + _: AuthenticatedStaffPrincipal = Depends( + require_panel_admin_permission(AdminPermission.VIEW_REPORTS) + ), +): + service = _build_service(settings) + payload = service.build_rental_overview_payload() + return AdminRentalReportOverviewResponse( + service="orquestrador-admin", + area=payload["area"], + source_domain=payload["source_domain"], + mode=payload["mode"], + source_dataset_keys=payload["source_dataset_keys"], + metrics=payload["metrics"], + materialization=payload["materialization"], + reports=payload["reports"], + next_steps=payload["next_steps"], + ) + + +@router.get( + "/fluxo-bot/overview", + response_model=AdminBotFlowReportOverviewResponse, +) +def panel_bot_flow_reports_overview( + settings: AdminSettings = Depends(get_settings), + _: AuthenticatedStaffPrincipal = Depends( + require_panel_admin_permission(AdminPermission.VIEW_REPORTS) + ), +): + service = _build_service(settings) + payload = service.build_bot_flow_overview_payload() + return AdminBotFlowReportOverviewResponse( + service="orquestrador-admin", + area=payload["area"], + source_domain=payload["source_domain"], + mode=payload["mode"], + source_dataset_keys=payload["source_dataset_keys"], + metrics=payload["metrics"], + materialization=payload["materialization"], + reports=payload["reports"], + next_steps=payload["next_steps"], + ) + + +@router.get( + "/telemetria-conversacional/overview", + response_model=AdminConversationTelemetryReportOverviewResponse, +) +def panel_conversation_telemetry_reports_overview( + settings: AdminSettings = Depends(get_settings), + _: AuthenticatedStaffPrincipal = Depends( + require_panel_admin_permission(AdminPermission.VIEW_REPORTS) + ), +): + service = _build_service(settings) + payload = service.build_conversation_telemetry_overview_payload() + return AdminConversationTelemetryReportOverviewResponse( + service="orquestrador-admin", + area=payload["area"], + source_domain=payload["source_domain"], + mode=payload["mode"], + source_dataset_keys=payload["source_dataset_keys"], + metrics=payload["metrics"], + materialization=payload["materialization"], + reports=payload["reports"], + next_steps=payload["next_steps"], + ) diff --git a/admin_app/api/routes/reports.py b/admin_app/api/routes/reports.py new file mode 100644 index 0000000..ed6d1be --- /dev/null +++ b/admin_app/api/routes/reports.py @@ -0,0 +1,477 @@ +from fastapi import APIRouter, Depends, HTTPException, status + +from admin_app.api.dependencies import get_settings, require_admin_permission +from admin_app.api.schemas import ( + AdminBotFlowReportCatalogResponse, + AdminBotFlowReportOverviewResponse, + AdminBotFlowReportResponse, + AdminConversationTelemetryReportCatalogResponse, + AdminConversationTelemetryReportOverviewResponse, + AdminConversationTelemetryReportResponse, + AdminRentalReportCatalogResponse, + AdminRentalReportOverviewResponse, + AdminRentalReportResponse, + AdminReportDatasetListResponse, + AdminReportDatasetResponse, + AdminReportOverviewResponse, + AdminRevenueReportCatalogResponse, + AdminRevenueReportOverviewResponse, + AdminRevenueReportResponse, + AdminSalesReportCatalogResponse, + AdminSalesReportOverviewResponse, + AdminSalesReportResponse, +) +from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal +from admin_app.services import ReportService +from shared.contracts import AdminPermission + +router = APIRouter(prefix="/reports", tags=["reports"]) + + +def _build_service(settings: AdminSettings) -> ReportService: + return ReportService(settings) + + +@router.get( + "/overview", + response_model=AdminReportOverviewResponse, +) +def reports_overview( + settings: AdminSettings = Depends(get_settings), + _: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.VIEW_REPORTS) + ), +): + service = _build_service(settings) + payload = service.build_overview_payload() + return AdminReportOverviewResponse( + service="orquestrador-admin", + mode=payload["mode"], + metrics=payload["metrics"], + materialization=payload["materialization"], + report_families=payload["report_families"], + next_steps=payload["next_steps"], + ) + + +@router.get( + "/datasets", + response_model=AdminReportDatasetListResponse, +) +def report_datasets( + settings: AdminSettings = Depends(get_settings), + _: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.VIEW_REPORTS) + ), +): + service = _build_service(settings) + payload = service.list_datasets_payload() + return AdminReportDatasetListResponse( + service="orquestrador-admin", + source=payload["source"], + materialization=payload["materialization"], + datasets=payload["datasets"], + ) + + +@router.get( + "/datasets/{dataset_key}", + response_model=AdminReportDatasetResponse, +) +def report_dataset_detail( + dataset_key: str, + settings: AdminSettings = Depends(get_settings), + _: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.VIEW_REPORTS) + ), +): + service = _build_service(settings) + payload = service.get_dataset_payload(dataset_key) + if payload is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Dataset operacional nao encontrado para relatorio.", + ) + + return AdminReportDatasetResponse( + service="orquestrador-admin", + source=payload["source"], + materialization=payload["materialization"], + dataset=payload["dataset"], + ) + + +@router.get( + "/sales/overview", + response_model=AdminSalesReportOverviewResponse, +) +def sales_reports_overview( + settings: AdminSettings = Depends(get_settings), + _: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.VIEW_REPORTS) + ), +): + service = _build_service(settings) + payload = service.build_sales_overview_payload() + return AdminSalesReportOverviewResponse( + service="orquestrador-admin", + domain=payload["domain"], + mode=payload["mode"], + source_dataset_keys=payload["source_dataset_keys"], + metrics=payload["metrics"], + materialization=payload["materialization"], + reports=payload["reports"], + next_steps=payload["next_steps"], + ) + + +@router.get( + "/sales/reports", + response_model=AdminSalesReportCatalogResponse, +) +def sales_reports_catalog( + settings: AdminSettings = Depends(get_settings), + _: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.VIEW_REPORTS) + ), +): + service = _build_service(settings) + payload = service.list_sales_reports_payload() + return AdminSalesReportCatalogResponse( + service="orquestrador-admin", + domain=payload["domain"], + source=payload["source"], + materialization=payload["materialization"], + reports=payload["reports"], + ) + + +@router.get( + "/sales/reports/{report_key}", + response_model=AdminSalesReportResponse, +) +def sales_report_detail( + report_key: str, + settings: AdminSettings = Depends(get_settings), + _: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.VIEW_REPORTS) + ), +): + service = _build_service(settings) + payload = service.get_sales_report_payload(report_key) + if payload is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Relatorio de vendas nao encontrado.", + ) + + return AdminSalesReportResponse( + service="orquestrador-admin", + domain=payload["domain"], + source=payload["source"], + materialization=payload["materialization"], + report=payload["report"], + ) + + +@router.get( + "/arrecadacao/overview", + response_model=AdminRevenueReportOverviewResponse, +) +def revenue_reports_overview( + settings: AdminSettings = Depends(get_settings), + _: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.VIEW_REPORTS) + ), +): + service = _build_service(settings) + payload = service.build_revenue_overview_payload() + return AdminRevenueReportOverviewResponse( + service="orquestrador-admin", + area=payload["area"], + source_domain=payload["source_domain"], + mode=payload["mode"], + source_dataset_keys=payload["source_dataset_keys"], + metrics=payload["metrics"], + materialization=payload["materialization"], + reports=payload["reports"], + next_steps=payload["next_steps"], + ) + + +@router.get( + "/arrecadacao/reports", + response_model=AdminRevenueReportCatalogResponse, +) +def revenue_reports_catalog( + settings: AdminSettings = Depends(get_settings), + _: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.VIEW_REPORTS) + ), +): + service = _build_service(settings) + payload = service.list_revenue_reports_payload() + return AdminRevenueReportCatalogResponse( + service="orquestrador-admin", + area=payload["area"], + source_domain=payload["source_domain"], + source=payload["source"], + materialization=payload["materialization"], + reports=payload["reports"], + ) + + +@router.get( + "/arrecadacao/reports/{report_key}", + response_model=AdminRevenueReportResponse, +) +def revenue_report_detail( + report_key: str, + settings: AdminSettings = Depends(get_settings), + _: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.VIEW_REPORTS) + ), +): + service = _build_service(settings) + payload = service.get_revenue_report_payload(report_key) + if payload is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Relatorio de arrecadacao nao encontrado.", + ) + + return AdminRevenueReportResponse( + service="orquestrador-admin", + area=payload["area"], + source_domain=payload["source_domain"], + source=payload["source"], + materialization=payload["materialization"], + report=payload["report"], + ) + + +@router.get( + "/locacao/overview", + response_model=AdminRentalReportOverviewResponse, +) +def rental_reports_overview( + settings: AdminSettings = Depends(get_settings), + _: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.VIEW_REPORTS) + ), +): + service = _build_service(settings) + payload = service.build_rental_overview_payload() + return AdminRentalReportOverviewResponse( + service="orquestrador-admin", + area=payload["area"], + source_domain=payload["source_domain"], + mode=payload["mode"], + source_dataset_keys=payload["source_dataset_keys"], + metrics=payload["metrics"], + materialization=payload["materialization"], + reports=payload["reports"], + next_steps=payload["next_steps"], + ) + + +@router.get( + "/locacao/reports", + response_model=AdminRentalReportCatalogResponse, +) +def rental_reports_catalog( + settings: AdminSettings = Depends(get_settings), + _: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.VIEW_REPORTS) + ), +): + service = _build_service(settings) + payload = service.list_rental_reports_payload() + return AdminRentalReportCatalogResponse( + service="orquestrador-admin", + area=payload["area"], + source_domain=payload["source_domain"], + source=payload["source"], + materialization=payload["materialization"], + reports=payload["reports"], + ) + + +@router.get( + "/locacao/reports/{report_key}", + response_model=AdminRentalReportResponse, +) +def rental_report_detail( + report_key: str, + settings: AdminSettings = Depends(get_settings), + _: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.VIEW_REPORTS) + ), +): + service = _build_service(settings) + payload = service.get_rental_report_payload(report_key) + if payload is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Relatorio de locacao nao encontrado.", + ) + + return AdminRentalReportResponse( + service="orquestrador-admin", + area=payload["area"], + source_domain=payload["source_domain"], + source=payload["source"], + materialization=payload["materialization"], + report=payload["report"], + ) + + +@router.get( + "/fluxo-bot/overview", + response_model=AdminBotFlowReportOverviewResponse, +) +def bot_flow_reports_overview( + settings: AdminSettings = Depends(get_settings), + _: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.VIEW_REPORTS) + ), +): + service = _build_service(settings) + payload = service.build_bot_flow_overview_payload() + return AdminBotFlowReportOverviewResponse( + service="orquestrador-admin", + area=payload["area"], + source_domain=payload["source_domain"], + mode=payload["mode"], + source_dataset_keys=payload["source_dataset_keys"], + metrics=payload["metrics"], + materialization=payload["materialization"], + reports=payload["reports"], + next_steps=payload["next_steps"], + ) + + +@router.get( + "/fluxo-bot/reports", + response_model=AdminBotFlowReportCatalogResponse, +) +def bot_flow_reports_catalog( + settings: AdminSettings = Depends(get_settings), + _: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.VIEW_REPORTS) + ), +): + service = _build_service(settings) + payload = service.list_bot_flow_reports_payload() + return AdminBotFlowReportCatalogResponse( + service="orquestrador-admin", + area=payload["area"], + source_domain=payload["source_domain"], + source=payload["source"], + materialization=payload["materialization"], + reports=payload["reports"], + ) + + +@router.get( + "/fluxo-bot/reports/{report_key}", + response_model=AdminBotFlowReportResponse, +) +def bot_flow_report_detail( + report_key: str, + settings: AdminSettings = Depends(get_settings), + _: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.VIEW_REPORTS) + ), +): + service = _build_service(settings) + payload = service.get_bot_flow_report_payload(report_key) + if payload is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Relatorio operacional do fluxo do bot nao encontrado.", + ) + + return AdminBotFlowReportResponse( + service="orquestrador-admin", + area=payload["area"], + source_domain=payload["source_domain"], + source=payload["source"], + materialization=payload["materialization"], + report=payload["report"], + ) + +@router.get( + "/telemetria-conversacional/overview", + response_model=AdminConversationTelemetryReportOverviewResponse, +) +def conversation_telemetry_reports_overview( + settings: AdminSettings = Depends(get_settings), + _: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.VIEW_REPORTS) + ), +): + service = _build_service(settings) + payload = service.build_conversation_telemetry_overview_payload() + return AdminConversationTelemetryReportOverviewResponse( + service="orquestrador-admin", + area=payload["area"], + source_domain=payload["source_domain"], + mode=payload["mode"], + source_dataset_keys=payload["source_dataset_keys"], + metrics=payload["metrics"], + materialization=payload["materialization"], + reports=payload["reports"], + next_steps=payload["next_steps"], + ) + + +@router.get( + "/telemetria-conversacional/reports", + response_model=AdminConversationTelemetryReportCatalogResponse, +) +def conversation_telemetry_reports_catalog( + settings: AdminSettings = Depends(get_settings), + _: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.VIEW_REPORTS) + ), +): + service = _build_service(settings) + payload = service.list_conversation_telemetry_reports_payload() + return AdminConversationTelemetryReportCatalogResponse( + service="orquestrador-admin", + area=payload["area"], + source_domain=payload["source_domain"], + source=payload["source"], + materialization=payload["materialization"], + reports=payload["reports"], + ) + + +@router.get( + "/telemetria-conversacional/reports/{report_key}", + response_model=AdminConversationTelemetryReportResponse, +) +def conversation_telemetry_report_detail( + report_key: str, + settings: AdminSettings = Depends(get_settings), + _: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.VIEW_REPORTS) + ), +): + service = _build_service(settings) + payload = service.get_conversation_telemetry_report_payload(report_key) + if payload is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Relatorio de telemetria conversacional nao encontrado.", + ) + + return AdminConversationTelemetryReportResponse( + service="orquestrador-admin", + area=payload["area"], + source_domain=payload["source_domain"], + source=payload["source"], + materialization=payload["materialization"], + report=payload["report"], + ) \ No newline at end of file diff --git a/admin_app/api/routes/system.py b/admin_app/api/routes/system.py index d4da99d..6215459 100644 --- a/admin_app/api/routes/system.py +++ b/admin_app/api/routes/system.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.responses import RedirectResponse, Response from admin_app.api.dependencies import ( @@ -18,10 +18,15 @@ from admin_app.api.schemas import ( AdminCapabilityResponse, AdminCurrentAccessResponse, AdminHealthResponse, + AdminSystemBotGovernedConfigurationResponse, AdminSystemConfigurationResponse, + AdminSystemFunctionalConfigurationCatalogResponse, + AdminSystemFunctionalConfigurationDetailResponse, AdminSystemInfoResponse, + AdminSystemModelRuntimeSeparationResponse, AdminSystemRuntimeConfigurationResponse, AdminSystemSecurityConfigurationResponse, + AdminSystemWriteGovernanceResponse, ) from admin_app.core import AdminSecurityService, AuthenticatedStaffPrincipal from admin_app.core.settings import AdminSettings @@ -127,6 +132,8 @@ def system_configuration( service="orquestrador-admin", runtime=runtime_payload, security=service.build_security_configuration_payload(), + model_runtimes=service.build_model_runtime_separation_payload(), + write_governance=service.build_write_governance_payload(), sources=service.build_configuration_sources_payload(), ) @@ -167,6 +174,113 @@ def system_security_configuration( ) +@router.get( + "/system/configuration/model-runtimes", + response_model=AdminSystemModelRuntimeSeparationResponse, +) +def system_model_runtime_separation( + settings: AdminSettings = Depends(get_settings), + security_service: AdminSecurityService = Depends(get_security_service), + _: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.MANAGE_SETTINGS) + ), +): + service = _build_service(settings, security_service) + return AdminSystemModelRuntimeSeparationResponse( + service="orquestrador-admin", + model_runtimes=service.build_model_runtime_separation_payload(), + ) + + +@router.get( + "/system/configuration/write-governance", + response_model=AdminSystemWriteGovernanceResponse, +) +def system_write_governance_configuration( + settings: AdminSettings = Depends(get_settings), + security_service: AdminSecurityService = Depends(get_security_service), + _: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.MANAGE_SETTINGS) + ), +): + service = _build_service(settings, security_service) + return AdminSystemWriteGovernanceResponse( + service="orquestrador-admin", + write_governance=service.build_write_governance_payload(), + ) + + +@router.get( + "/system/configuration/functional", + response_model=AdminSystemFunctionalConfigurationCatalogResponse, +) +def system_functional_configuration_catalog( + settings: AdminSettings = Depends(get_settings), + security_service: AdminSecurityService = Depends(get_security_service), + _: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.VIEW_SYSTEM) + ), +): + service = _build_service(settings, security_service) + payload = service.build_functional_configuration_catalog_payload() + return AdminSystemFunctionalConfigurationCatalogResponse( + service="orquestrador-admin", + mode=payload["mode"], + configurations=payload["configurations"], + bot_governed_parent_config_keys=payload["bot_governed_parent_config_keys"], + next_steps=payload["next_steps"], + ) + + +@router.get( + "/system/configuration/functional/bot-governance", + response_model=AdminSystemBotGovernedConfigurationResponse, +) +def system_bot_governed_configuration( + settings: AdminSettings = Depends(get_settings), + security_service: AdminSecurityService = Depends(get_security_service), + _: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.VIEW_SYSTEM) + ), +): + service = _build_service(settings, security_service) + payload = service.build_bot_governed_configuration_payload() + return AdminSystemBotGovernedConfigurationResponse( + service="orquestrador-admin", + parent_config_keys=payload["parent_config_keys"], + settings=payload["settings"], + ) + + +@router.get( + "/system/configuration/functional/{config_key}", + response_model=AdminSystemFunctionalConfigurationDetailResponse, +) +def system_functional_configuration_detail( + config_key: str, + settings: AdminSettings = Depends(get_settings), + security_service: AdminSecurityService = Depends(get_security_service), + _: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.VIEW_SYSTEM) + ), +): + service = _build_service(settings, security_service) + payload = service.get_functional_configuration_payload(config_key) + if payload is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Configuracao funcional do sistema nao encontrada.", + ) + + return AdminSystemFunctionalConfigurationDetailResponse( + service="orquestrador-admin", + configuration=payload["configuration"], + linked_bot_settings=payload["linked_bot_settings"], + related_runtime_profile=payload["related_runtime_profile"], + managed_by_bot_governance=payload["managed_by_bot_governance"], + ) + + def _build_runtime_configuration_payload( service: SystemService, settings: AdminSettings, diff --git a/admin_app/api/schemas.py b/admin_app/api/schemas.py index 300eff3..b18e663 100644 --- a/admin_app/api/schemas.py +++ b/admin_app/api/schemas.py @@ -3,7 +3,22 @@ from datetime import datetime from pydantic import BaseModel, Field, field_validator from admin_app.core import AdminCredentialStrategy -from shared.contracts import AdminPermission, ServiceName, StaffRole, ToolLifecycleStatus, ToolParameterType +from shared.contracts import ( + AdminPermission, + OperationalConsistencyModel, + OperationalDataDomain, + OperationalDataSensitivity, + OperationalFreshnessTarget, + OperationalQuerySurface, + OperationalReadGranularity, + OperationalReadModel, + OperationalStorageShape, + OperationalSyncStrategy, + ServiceName, + StaffRole, + ToolLifecycleStatus, + ToolParameterType, +) class AdminRootResponse(BaseModel): @@ -104,6 +119,51 @@ class AdminConfigurationSourceResponse(BaseModel): description: str +class AdminFunctionalConfigurationFieldResponse(BaseModel): + name: str + description: str + writable: bool + secret: bool + + +class AdminFunctionalConfigurationContractResponse(BaseModel): + config_key: str + domain: str + description: str + source: str + read_permission: str + write_permission: str | None = None + mutability: str + propagation: str + affects_product_runtime: bool + direct_product_write_allowed: bool + fields: list[AdminFunctionalConfigurationFieldResponse] + + +class AdminModelRuntimeProfileResponse(BaseModel): + runtime_target: str + config_key: str + catalog_runtime_target: str + purpose: str + consumed_by_service: str + description: str + read_permission: str + write_permission: str + published_independently: bool + rollback_independently: bool + cross_target_propagation_allowed: bool + affects_customer_response: bool + can_generate_code: bool + + +class AdminSystemModelRuntimeSeparationPayload(BaseModel): + runtime_profiles: list[AdminModelRuntimeProfileResponse] + separation_rules: list[str] + atendimento_runtime_configuration: AdminFunctionalConfigurationContractResponse + tool_generation_runtime_configuration: AdminFunctionalConfigurationContractResponse + bot_governed_parent_config_keys: list[str] + + class AdminSystemRuntimeConfigurationResponse(BaseModel): service: str runtime: AdminSystemRuntimeConfigurationPayload @@ -114,13 +174,466 @@ class AdminSystemSecurityConfigurationResponse(BaseModel): security: AdminCredentialStrategy +class AdminSystemWriteGovernancePayload(BaseModel): + mode: str + allowed_direct_write_tables: list[str] + blocked_operational_dataset_keys: list[str] + blocked_product_source_tables: list[str] + governed_configuration_keys: list[str] + enforcement_points: list[str] + governance_rules: list[str] + + class AdminSystemConfigurationResponse(BaseModel): service: str runtime: AdminSystemRuntimeConfigurationPayload security: AdminCredentialStrategy + model_runtimes: AdminSystemModelRuntimeSeparationPayload + write_governance: AdminSystemWriteGovernancePayload sources: list[AdminConfigurationSourceResponse] +class AdminSystemModelRuntimeSeparationResponse(BaseModel): + service: str + model_runtimes: AdminSystemModelRuntimeSeparationPayload + + +class AdminSystemWriteGovernanceResponse(BaseModel): + service: str + write_governance: AdminSystemWriteGovernancePayload + + +class AdminBotGovernedSettingResponse(BaseModel): + setting_key: str + parent_config_key: str + field_name: str + area: str + description: str + read_permission: str + write_permission: str + mutability: str + versioned_publication_required: bool + direct_product_write_allowed: bool + + +class AdminSystemFunctionalConfigurationCatalogResponse(BaseModel): + service: str + mode: str + configurations: list[AdminFunctionalConfigurationContractResponse] + bot_governed_parent_config_keys: list[str] + next_steps: list[str] + + +class AdminSystemFunctionalConfigurationDetailResponse(BaseModel): + service: str + configuration: AdminFunctionalConfigurationContractResponse + linked_bot_settings: list[AdminBotGovernedSettingResponse] + related_runtime_profile: AdminModelRuntimeProfileResponse | None = None + managed_by_bot_governance: bool + + +class AdminSystemBotGovernedConfigurationResponse(BaseModel): + service: str + parent_config_keys: list[str] + settings: list[AdminBotGovernedSettingResponse] + + +class AdminReportMetricResponse(BaseModel): + key: str + label: str + value: str + description: str + + +class AdminReportFamilyResponse(BaseModel): + key: str + label: str + description: str + dataset_keys: list[str] + + +class AdminReportMaterializationResponse(BaseModel): + report_read_model: OperationalReadModel + consistency_model: OperationalConsistencyModel + sync_strategy: OperationalSyncStrategy + storage_shape: OperationalStorageShape + query_surface: OperationalQuerySurface + uses_product_replica: bool + direct_product_query_allowed: bool + refresh_behavior: str + + +class AdminReportFieldResponse(BaseModel): + name: str + description: str + sensitivity: OperationalDataSensitivity + + +class AdminReportDatasetSummaryResponse(BaseModel): + dataset_key: str + domain: OperationalDataDomain + description: str + source_table: str + freshness_target: OperationalFreshnessTarget + allowed_granularities: list[OperationalReadGranularity] + allowed_field_count: int + blocked_field_count: int + write_allowed: bool + materialization_status: str + last_consolidated_at: datetime | None = None + source_watermark: str | None = None + + +class AdminReportDatasetDetailResponse(BaseModel): + dataset_key: str + domain: OperationalDataDomain + description: str + source_table: str + read_permission: AdminPermission + report_read_model: OperationalReadModel + consistency_model: OperationalConsistencyModel + sync_strategy: OperationalSyncStrategy + storage_shape: OperationalStorageShape + query_surface: OperationalQuerySurface + uses_product_replica: bool + direct_product_query_allowed: bool + freshness_target: OperationalFreshnessTarget + allowed_granularities: list[OperationalReadGranularity] + write_allowed: bool + materialization_status: str + last_consolidated_at: datetime | None = None + source_watermark: str | None = None + allowed_fields: list[AdminReportFieldResponse] + blocked_fields: list[AdminReportFieldResponse] + + +class AdminReportOverviewResponse(BaseModel): + service: str + mode: str + metrics: list[AdminReportMetricResponse] + materialization: AdminReportMaterializationResponse + report_families: list[AdminReportFamilyResponse] + next_steps: list[str] + + +class AdminReportDatasetListResponse(BaseModel): + service: str + source: str + materialization: AdminReportMaterializationResponse + datasets: list[AdminReportDatasetSummaryResponse] + + +class AdminReportDatasetResponse(BaseModel): + service: str + source: str + materialization: AdminReportMaterializationResponse + dataset: AdminReportDatasetDetailResponse + + +class AdminSalesReportMetricDefinitionResponse(BaseModel): + key: str + label: str + aggregation: str + description: str + + +class AdminSalesReportDimensionResponse(BaseModel): + field_name: str + label: str + description: str + default_group_by: bool = False + + +class AdminSalesReportFilterResponse(BaseModel): + field_name: str + label: str + filter_type: str + description: str + required: bool = False + + +class AdminSalesReportDefinitionSummaryResponse(BaseModel): + report_key: str + label: str + description: str + dataset_key: str + default_time_field: str + default_granularity: OperationalReadGranularity + supported_metric_keys: list[str] + supported_dimension_fields: list[str] + materialization_status: str + last_consolidated_at: datetime | None = None + source_watermark: str | None = None + + +class AdminSalesReportDefinitionDetailResponse(BaseModel): + report_key: str + label: str + description: str + dataset_key: str + default_time_field: str + default_granularity: OperationalReadGranularity + metrics: list[AdminSalesReportMetricDefinitionResponse] + dimensions: list[AdminSalesReportDimensionResponse] + filters: list[AdminSalesReportFilterResponse] + dataset: AdminReportDatasetDetailResponse + + +class AdminSalesReportOverviewResponse(BaseModel): + service: str + domain: OperationalDataDomain + mode: str + source_dataset_keys: list[str] + metrics: list[AdminReportMetricResponse] + materialization: AdminReportMaterializationResponse + reports: list[AdminSalesReportDefinitionSummaryResponse] + next_steps: list[str] + + +class AdminSalesReportCatalogResponse(BaseModel): + service: str + domain: OperationalDataDomain + source: str + materialization: AdminReportMaterializationResponse + reports: list[AdminSalesReportDefinitionSummaryResponse] + + +class AdminSalesReportResponse(BaseModel): + service: str + domain: OperationalDataDomain + source: str + materialization: AdminReportMaterializationResponse + report: AdminSalesReportDefinitionDetailResponse + + +class AdminRevenueReportDefinitionSummaryResponse(BaseModel): + report_key: str + label: str + description: str + dataset_key: str + default_time_field: str + default_granularity: OperationalReadGranularity + supported_metric_keys: list[str] + supported_dimension_fields: list[str] + materialization_status: str + last_consolidated_at: datetime | None = None + source_watermark: str | None = None + + +class AdminRevenueReportDefinitionDetailResponse(BaseModel): + report_key: str + label: str + description: str + dataset_key: str + default_time_field: str + default_granularity: OperationalReadGranularity + metrics: list[AdminSalesReportMetricDefinitionResponse] + dimensions: list[AdminSalesReportDimensionResponse] + filters: list[AdminSalesReportFilterResponse] + dataset: AdminReportDatasetDetailResponse + + +class AdminRevenueReportOverviewResponse(BaseModel): + service: str + area: str + source_domain: OperationalDataDomain + mode: str + source_dataset_keys: list[str] + metrics: list[AdminReportMetricResponse] + materialization: AdminReportMaterializationResponse + reports: list[AdminRevenueReportDefinitionSummaryResponse] + next_steps: list[str] + + +class AdminRevenueReportCatalogResponse(BaseModel): + service: str + area: str + source_domain: OperationalDataDomain + source: str + materialization: AdminReportMaterializationResponse + reports: list[AdminRevenueReportDefinitionSummaryResponse] + + +class AdminRevenueReportResponse(BaseModel): + service: str + area: str + source_domain: OperationalDataDomain + source: str + materialization: AdminReportMaterializationResponse + report: AdminRevenueReportDefinitionDetailResponse + + +class AdminRentalReportDefinitionSummaryResponse(BaseModel): + report_key: str + label: str + description: str + dataset_key: str + default_time_field: str + default_granularity: OperationalReadGranularity + supported_metric_keys: list[str] + supported_dimension_fields: list[str] + materialization_status: str + last_consolidated_at: datetime | None = None + source_watermark: str | None = None + + +class AdminRentalReportDefinitionDetailResponse(BaseModel): + report_key: str + label: str + description: str + dataset_key: str + default_time_field: str + default_granularity: OperationalReadGranularity + metrics: list[AdminSalesReportMetricDefinitionResponse] + dimensions: list[AdminSalesReportDimensionResponse] + filters: list[AdminSalesReportFilterResponse] + dataset: AdminReportDatasetDetailResponse + + +class AdminRentalReportOverviewResponse(BaseModel): + service: str + area: str + source_domain: OperationalDataDomain + mode: str + source_dataset_keys: list[str] + metrics: list[AdminReportMetricResponse] + materialization: AdminReportMaterializationResponse + reports: list[AdminRentalReportDefinitionSummaryResponse] + next_steps: list[str] + + +class AdminRentalReportCatalogResponse(BaseModel): + service: str + area: str + source_domain: OperationalDataDomain + source: str + materialization: AdminReportMaterializationResponse + reports: list[AdminRentalReportDefinitionSummaryResponse] + + +class AdminRentalReportResponse(BaseModel): + service: str + area: str + source_domain: OperationalDataDomain + source: str + materialization: AdminReportMaterializationResponse + report: AdminRentalReportDefinitionDetailResponse + + +class AdminBotFlowReportDefinitionSummaryResponse(BaseModel): + report_key: str + label: str + description: str + dataset_key: str + default_time_field: str + default_granularity: OperationalReadGranularity + supported_metric_keys: list[str] + supported_dimension_fields: list[str] + materialization_status: str + last_consolidated_at: datetime | None = None + source_watermark: str | None = None + + +class AdminBotFlowReportDefinitionDetailResponse(BaseModel): + report_key: str + label: str + description: str + dataset_key: str + default_time_field: str + default_granularity: OperationalReadGranularity + metrics: list[AdminSalesReportMetricDefinitionResponse] + dimensions: list[AdminSalesReportDimensionResponse] + filters: list[AdminSalesReportFilterResponse] + dataset: AdminReportDatasetDetailResponse + + +class AdminBotFlowReportOverviewResponse(BaseModel): + service: str + area: str + source_domain: OperationalDataDomain + mode: str + source_dataset_keys: list[str] + metrics: list[AdminReportMetricResponse] + materialization: AdminReportMaterializationResponse + reports: list[AdminBotFlowReportDefinitionSummaryResponse] + next_steps: list[str] + + +class AdminBotFlowReportCatalogResponse(BaseModel): + service: str + area: str + source_domain: OperationalDataDomain + source: str + materialization: AdminReportMaterializationResponse + reports: list[AdminBotFlowReportDefinitionSummaryResponse] + + +class AdminBotFlowReportResponse(BaseModel): + service: str + area: str + source_domain: OperationalDataDomain + source: str + materialization: AdminReportMaterializationResponse + report: AdminBotFlowReportDefinitionDetailResponse + + +class AdminConversationTelemetryReportDefinitionSummaryResponse(BaseModel): + report_key: str + label: str + description: str + dataset_key: str + default_time_field: str + default_granularity: OperationalReadGranularity + supported_metric_keys: list[str] + supported_dimension_fields: list[str] + materialization_status: str + last_consolidated_at: datetime | None = None + source_watermark: str | None = None + + +class AdminConversationTelemetryReportDefinitionDetailResponse(BaseModel): + report_key: str + label: str + description: str + dataset_key: str + default_time_field: str + default_granularity: OperationalReadGranularity + metrics: list[AdminSalesReportMetricDefinitionResponse] + dimensions: list[AdminSalesReportDimensionResponse] + filters: list[AdminSalesReportFilterResponse] + dataset: AdminReportDatasetDetailResponse + + +class AdminConversationTelemetryReportOverviewResponse(BaseModel): + service: str + area: str + source_domain: OperationalDataDomain + mode: str + source_dataset_keys: list[str] + metrics: list[AdminReportMetricResponse] + materialization: AdminReportMaterializationResponse + reports: list[AdminConversationTelemetryReportDefinitionSummaryResponse] + next_steps: list[str] + + +class AdminConversationTelemetryReportCatalogResponse(BaseModel): + service: str + area: str + source_domain: OperationalDataDomain + source: str + materialization: AdminReportMaterializationResponse + reports: list[AdminConversationTelemetryReportDefinitionSummaryResponse] + + +class AdminConversationTelemetryReportResponse(BaseModel): + service: str + area: str + source_domain: OperationalDataDomain + source: str + materialization: AdminReportMaterializationResponse + report: AdminConversationTelemetryReportDefinitionDetailResponse + + class AdminLoginRequest(BaseModel): email: str password: str = Field(min_length=1) diff --git a/admin_app/db/database.py b/admin_app/db/database.py index 95fc261..df9c23e 100644 --- a/admin_app/db/database.py +++ b/admin_app/db/database.py @@ -1,9 +1,10 @@ from collections.abc import Generator -from sqlalchemy import create_engine +from sqlalchemy import create_engine, event from sqlalchemy.orm import Session, declarative_base, sessionmaker from admin_app.core.settings import get_admin_settings +from admin_app.db.write_governance import enforce_admin_session_write_governance # monta a conexão do banco administrativo e expõe get_admin_db_session(). Esse generator é o que alimenta as dependências FastAPI para repositórios e serviços. @@ -33,6 +34,16 @@ AdminSessionLocal = sessionmaker( bind=admin_engine, ) + +@event.listens_for(AdminSessionLocal, "before_flush") +def _block_unguarded_admin_writes(session, flush_context, instances): + enforce_admin_session_write_governance( + new=session.new, + dirty=session.dirty, + deleted=session.deleted, + ) + + AdminBase = declarative_base() diff --git a/admin_app/db/write_governance.py b/admin_app/db/write_governance.py new file mode 100644 index 0000000..8ecd773 --- /dev/null +++ b/admin_app/db/write_governance.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from collections.abc import Iterable + +from shared.contracts import ( + PRODUCT_OPERATIONAL_DATASETS, + SYSTEM_FUNCTIONAL_CONFIGURATIONS, + FunctionalConfigurationPropagation, +) + +ALLOWED_ADMIN_WRITE_TABLES: tuple[str, ...] = ( + "admin_audit_logs", + "staff_accounts", + "staff_sessions", +) + + +class AdminWriteGovernanceViolation(RuntimeError): + """Raised when the admin runtime attempts an ungoverned direct write.""" + + +def ensure_direct_admin_write_allowed(table_name: str) -> None: + normalized_table_name = str(table_name or "").strip().lower() + if normalized_table_name in ALLOWED_ADMIN_WRITE_TABLES: + return + + raise AdminWriteGovernanceViolation( + "Escrita direta do admin bloqueada para a tabela " + f"'{normalized_table_name or 'desconhecida'}'. " + "Use um fluxo governado, versionado e auditavel antes de publicar qualquer efeito no product." + ) + + +def enforce_admin_session_write_governance( + *, + new: Iterable[object] = (), + dirty: Iterable[object] = (), + deleted: Iterable[object] = (), +) -> None: + seen_tables: set[str] = set() + for instance in (*tuple(new), *tuple(dirty), *tuple(deleted)): + table_name = _resolve_table_name(instance) + if table_name is None or table_name in seen_tables: + continue + ensure_direct_admin_write_allowed(table_name) + seen_tables.add(table_name) + + +def build_admin_write_governance_payload() -> dict: + governed_configuration_keys = sorted( + configuration.config_key + for configuration in SYSTEM_FUNCTIONAL_CONFIGURATIONS + if configuration.propagation == FunctionalConfigurationPropagation.VERSIONED_PUBLICATION + ) + return { + "mode": "admin_internal_tables_only", + "allowed_direct_write_tables": list(ALLOWED_ADMIN_WRITE_TABLES), + "blocked_operational_dataset_keys": sorted( + dataset.dataset_key for dataset in PRODUCT_OPERATIONAL_DATASETS + ), + "blocked_product_source_tables": sorted( + {dataset.source_table for dataset in PRODUCT_OPERATIONAL_DATASETS} + ), + "governed_configuration_keys": governed_configuration_keys, + "enforcement_points": [ + "AdminSession.before_flush bloqueia escrita ORM fora do allowlist interno do admin.", + "Contratos compartilhados mantem datasets operacionais com write_allowed=false.", + "Configuracoes que afetam o runtime do product seguem versioned_publication antes de qualquer efeito operacional.", + ], + "governance_rules": [ + "O admin nao escreve diretamente nas tabelas operacionais do product.", + "Toda alteracao com efeito no product nasce como estado administrativo versionado.", + "O product consome apenas configuracao publicada e aprovada.", + ], + } + + +def build_admin_write_governance_source_payload() -> dict: + return { + "key": "admin_write_governance", + "source": "runtime_guard", + "mutable": False, + "description": ( + "Guard no AdminSession bloqueia escrita ORM fora das tabelas internas do admin e preserva a governanca versionada antes de qualquer efeito no product." + ), + } + + +def _resolve_table_name(instance: object) -> str | None: + table = getattr(instance, "__table__", None) + if table is not None: + table_name = getattr(table, "name", None) + if table_name: + return str(table_name).strip().lower() + + class_table_name = getattr(type(instance), "__tablename__", None) + if class_table_name: + return str(class_table_name).strip().lower() + + instance_table_name = getattr(instance, "__tablename__", None) + if instance_table_name: + return str(instance_table_name).strip().lower() + + return None diff --git a/admin_app/services/__init__.py b/admin_app/services/__init__.py index 8e3ef70..d552591 100644 --- a/admin_app/services/__init__.py +++ b/admin_app/services/__init__.py @@ -5,6 +5,7 @@ from admin_app.services.audit_service import ( ) from admin_app.services.auth_service import AuthService from admin_app.services.collaborator_management_service import CollaboratorManagementService +from admin_app.services.report_service import ReportService from admin_app.services.system_service import SystemService from admin_app.services.tool_management_service import ToolManagementService @@ -14,6 +15,7 @@ __all__ = [ "AuditService", "AuthService", "CollaboratorManagementService", + "ReportService", "SystemService", "ToolManagementService", -] +] \ No newline at end of file diff --git a/admin_app/services/report_service.py b/admin_app/services/report_service.py new file mode 100644 index 0000000..92ea039 --- /dev/null +++ b/admin_app/services/report_service.py @@ -0,0 +1,963 @@ +from admin_app.core.settings import AdminSettings +from shared.contracts import ( + PRODUCT_OPERATIONAL_DATASETS, + OperationalDatasetContract, + OperationalReadGranularity, + get_operational_dataset, +) + +_MATERIALIZATION_STATUS = "contract_defined_pending_snapshot_view" +_REFRESH_BEHAVIOR = "manual_refresh_triggers_sync_boundary" +_REPORT_SOURCE = "shared_contract_catalog" +_SALES_DATASET_KEY = "sales_orders" + +_SALES_REPORT_METRICS = { + "total_orders": {"key": "total_orders", "label": "Pedidos totais", "aggregation": "count", "description": "Quantidade total de pedidos consolidados no periodo."}, + "gross_order_value": {"key": "gross_order_value", "label": "Valor bruto negociado", "aggregation": "sum", "description": "Soma do valor negociado dos pedidos incluidos no recorte."}, + "active_orders": {"key": "active_orders", "label": "Pedidos ativos", "aggregation": "count_where_status_active", "description": "Quantidade de pedidos ainda em fluxo operacional ativo."}, + "cancelled_orders": {"key": "cancelled_orders", "label": "Pedidos cancelados", "aggregation": "count_where_status_cancelled", "description": "Quantidade de pedidos cancelados no recorte selecionado."}, + "cancellation_rate": {"key": "cancellation_rate", "label": "Taxa de cancelamento", "aggregation": "ratio", "description": "Relacao entre pedidos cancelados e total de pedidos consolidados."}, + "average_ticket": {"key": "average_ticket", "label": "Ticket medio", "aggregation": "avg", "description": "Media do valor negociado por pedido dentro do recorte."}, +} + +_SALES_DIMENSIONS = { + "created_at": {"field_name": "created_at", "label": "Periodo de criacao", "description": "Agrupamento temporal da criacao do pedido.", "default_group_by": True}, + "updated_at": {"field_name": "updated_at", "label": "Periodo de atualizacao", "description": "Agrupamento temporal da ultima atualizacao do pedido.", "default_group_by": True}, + "data_cancelamento": {"field_name": "data_cancelamento", "label": "Periodo de cancelamento", "description": "Agrupamento temporal do cancelamento registrado.", "default_group_by": True}, + "status": {"field_name": "status", "label": "Status do pedido", "description": "Separa os pedidos por etapa operacional."}, + "modelo_veiculo": {"field_name": "modelo_veiculo", "label": "Modelo do veiculo", "description": "Recorte por modelo comercial negociado."}, + "motivo_cancelamento": {"field_name": "motivo_cancelamento", "label": "Motivo do cancelamento", "description": "Separa cancelamentos pelo motivo operacional registrado."}, +} + +_SALES_FILTERS = { + "created_at": {"field_name": "created_at", "label": "Periodo", "filter_type": "date_range", "description": "Intervalo de criacao do pedido consolidado.", "required": True}, + "updated_at": {"field_name": "updated_at", "label": "Periodo", "filter_type": "date_range", "description": "Intervalo da ultima atualizacao do pedido.", "required": True}, + "data_cancelamento": {"field_name": "data_cancelamento", "label": "Periodo", "filter_type": "date_range", "description": "Intervalo em que o cancelamento foi registrado.", "required": True}, + "status": {"field_name": "status", "label": "Status", "filter_type": "enum", "description": "Restringe o consolidado para um ou mais status operacionais."}, + "modelo_veiculo": {"field_name": "modelo_veiculo", "label": "Modelo do veiculo", "filter_type": "enum", "description": "Filtra pedidos por modelo comercial reservado."}, + "motivo_cancelamento": {"field_name": "motivo_cancelamento", "label": "Motivo do cancelamento", "filter_type": "enum", "description": "Restringe o consolidado para um ou mais motivos operacionais."}, +} + +_SALES_REPORTS = ( + {"report_key": "orders_volume", "label": "Volume de pedidos", "description": "Acompanha o volume bruto de pedidos por periodo e status operacional.", "default_time_field": "created_at", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("total_orders", "gross_order_value"), "dimension_fields": ("created_at", "status", "modelo_veiculo"), "filter_fields": ("created_at", "status", "modelo_veiculo")}, + {"report_key": "active_vs_cancelled", "label": "Pedidos ativos e cancelados", "description": "Compara pedidos em andamento com pedidos cancelados para leitura operacional da conversao.", "default_time_field": "updated_at", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("active_orders", "cancelled_orders", "cancellation_rate"), "dimension_fields": ("updated_at", "status", "modelo_veiculo"), "filter_fields": ("updated_at", "status", "modelo_veiculo")}, + {"report_key": "average_ticket", "label": "Ticket medio", "description": "Consolida a evolucao do valor medio negociado por periodo e por modelo.", "default_time_field": "created_at", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("average_ticket", "gross_order_value", "total_orders"), "dimension_fields": ("created_at", "modelo_veiculo", "status"), "filter_fields": ("created_at", "status", "modelo_veiculo")}, + {"report_key": "cancellations_by_period", "label": "Cancelamentos por periodo", "description": "Organiza o volume de cancelamentos e seus motivos ao longo do tempo.", "default_time_field": "data_cancelamento", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("cancelled_orders", "cancellation_rate"), "dimension_fields": ("data_cancelamento", "motivo_cancelamento", "modelo_veiculo"), "filter_fields": ("data_cancelamento", "motivo_cancelamento", "modelo_veiculo")}, +) + +_REVENUE_DATASET_KEY = "rental_payments" + +_REVENUE_REPORT_METRICS = { + "total_payments": {"key": "total_payments", "label": "Pagamentos totais", "aggregation": "count", "description": "Quantidade total de pagamentos liquidados no periodo."}, + "collected_amount": {"key": "collected_amount", "label": "Valor arrecadado", "aggregation": "sum", "description": "Soma do valor liquidado dos pagamentos incluidos no recorte."}, + "average_payment_amount": {"key": "average_payment_amount", "label": "Valor medio por pagamento", "aggregation": "avg", "description": "Media do valor liquidado por pagamento no recorte selecionado."}, + "distinct_contracts": {"key": "distinct_contracts", "label": "Contratos conciliados", "aggregation": "count_distinct", "description": "Quantidade de contratos distintos com pagamento consolidado no periodo."}, +} + +_REVENUE_DIMENSIONS = { + "data_pagamento": {"field_name": "data_pagamento", "label": "Periodo do pagamento", "description": "Agrupamento temporal do pagamento liquidado.", "default_group_by": True}, + "created_at": {"field_name": "created_at", "label": "Periodo de registro", "description": "Agrupamento temporal do registro do pagamento no read model administrativo.", "default_group_by": True}, + "contrato_numero": {"field_name": "contrato_numero", "label": "Contrato", "description": "Recorte por contrato associado ao pagamento."}, + "placa": {"field_name": "placa", "label": "Placa", "description": "Recorte por veiculo vinculado ao contrato pago."}, + "protocolo": {"field_name": "protocolo", "label": "Protocolo", "description": "Rastreio por protocolo publico do pagamento."}, +} + +_REVENUE_FILTERS = { + "data_pagamento": {"field_name": "data_pagamento", "label": "Periodo do pagamento", "filter_type": "date_range", "description": "Intervalo em que o pagamento foi liquidado.", "required": True}, + "created_at": {"field_name": "created_at", "label": "Periodo de registro", "filter_type": "date_range", "description": "Intervalo em que o pagamento foi registrado no dataset administrativo.", "required": True}, + "contrato_numero": {"field_name": "contrato_numero", "label": "Contrato", "filter_type": "exact_match", "description": "Filtra pagamentos por contrato associado."}, + "placa": {"field_name": "placa", "label": "Placa", "filter_type": "exact_match", "description": "Filtra pagamentos pela placa vinculada ao contrato."}, + "protocolo": {"field_name": "protocolo", "label": "Protocolo", "filter_type": "exact_match", "description": "Filtra o consolidado por protocolo publico do pagamento."}, +} + +_REVENUE_REPORTS = ( + {"report_key": "payments_volume", "label": "Volume de pagamentos", "description": "Acompanha a quantidade de pagamentos liquidados por periodo, contrato e veiculo.", "default_time_field": "data_pagamento", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("total_payments", "distinct_contracts"), "dimension_fields": ("data_pagamento", "contrato_numero", "placa"), "filter_fields": ("data_pagamento", "contrato_numero", "placa")}, + {"report_key": "collected_amount", "label": "Arrecadacao por periodo", "description": "Consolida o valor arrecadado por periodo com apoio de contrato e placa para leitura operacional.", "default_time_field": "data_pagamento", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("collected_amount", "average_payment_amount", "total_payments"), "dimension_fields": ("data_pagamento", "contrato_numero", "placa"), "filter_fields": ("data_pagamento", "contrato_numero", "placa")}, + {"report_key": "contract_reconciliation", "label": "Pagamentos por contrato", "description": "Organiza pagamentos conciliados por contrato com rastreio por placa e protocolo publico.", "default_time_field": "data_pagamento", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("collected_amount", "total_payments"), "dimension_fields": ("contrato_numero", "placa", "protocolo"), "filter_fields": ("data_pagamento", "contrato_numero", "placa", "protocolo")}, +) + +_RENTAL_FLEET_DATASET_KEY = "rental_fleet" +_RENTAL_CONTRACTS_DATASET_KEY = "rental_contracts" + +_RENTAL_REPORT_METRICS = { + "total_fleet_vehicles": {"key": "total_fleet_vehicles", "label": "Veiculos da frota", "aggregation": "count", "description": "Quantidade total de veiculos consolidados na frota administrativa."}, + "available_fleet_vehicles": {"key": "available_fleet_vehicles", "label": "Veiculos disponiveis", "aggregation": "count_where_status_available", "description": "Quantidade de veiculos em status operacional disponivel para locacao."}, + "average_daily_rate": {"key": "average_daily_rate", "label": "Diaria media", "aggregation": "avg", "description": "Media do valor de diaria vigente dos veiculos incluidos no recorte."}, + "total_contracts": {"key": "total_contracts", "label": "Contratos totais", "aggregation": "count", "description": "Quantidade total de contratos consolidados no periodo selecionado."}, + "active_contracts": {"key": "active_contracts", "label": "Contratos ativos", "aggregation": "count_where_status_active", "description": "Quantidade de contratos ainda em curso no recorte operacional."}, + "closed_contracts": {"key": "closed_contracts", "label": "Contratos encerrados", "aggregation": "count_where_status_closed", "description": "Quantidade de contratos concluidos ou encerrados no recorte selecionado."}, + "overdue_contracts": {"key": "overdue_contracts", "label": "Devolucoes em atraso", "aggregation": "count_overdue", "description": "Quantidade de contratos com fim previsto vencido e sem devolucao consolidada."}, + "occupied_vehicles": {"key": "occupied_vehicles", "label": "Veiculos ocupados", "aggregation": "count_distinct_active_vehicles", "description": "Quantidade de veiculos distintos associados a contratos ativos no periodo."}, + "projected_revenue": {"key": "projected_revenue", "label": "Receita prevista", "aggregation": "sum", "description": "Soma do valor previsto dos contratos incluidos no recorte."}, + "final_revenue": {"key": "final_revenue", "label": "Receita final", "aggregation": "sum", "description": "Soma do valor final consolidado dos contratos no recorte selecionado."}, + "revenue_delta": {"key": "revenue_delta", "label": "Desvio entre previsto e final", "aggregation": "difference", "description": "Diferenca consolidada entre receita prevista e receita final dos contratos."}, +} + +_RENTAL_DIMENSIONS = { + "created_at": {"field_name": "created_at", "label": "Periodo de cadastro", "description": "Agrupamento temporal do cadastro no read model administrativo.", "default_group_by": True}, + "categoria": {"field_name": "categoria", "label": "Categoria", "description": "Recorte por categoria comercial da locacao."}, + "status": {"field_name": "status", "label": "Status", "description": "Separa frota ou contratos por status operacional."}, + "modelo": {"field_name": "modelo", "label": "Modelo", "description": "Recorte por modelo do veiculo de locacao."}, + "placa": {"field_name": "placa", "label": "Placa", "description": "Rastreio por placa do veiculo locado."}, + "data_inicio": {"field_name": "data_inicio", "label": "Inicio da locacao", "description": "Agrupamento temporal da abertura do contrato.", "default_group_by": True}, + "data_fim_prevista": {"field_name": "data_fim_prevista", "label": "Fim previsto", "description": "Agrupamento temporal do fim previsto da locacao.", "default_group_by": True}, + "data_devolucao": {"field_name": "data_devolucao", "label": "Data de devolucao", "description": "Agrupamento temporal da devolucao consolidada do contrato.", "default_group_by": True}, + "updated_at": {"field_name": "updated_at", "label": "Ultima atualizacao", "description": "Agrupamento temporal da ultima atualizacao do contrato.", "default_group_by": True}, + "modelo_veiculo": {"field_name": "modelo_veiculo", "label": "Modelo do veiculo", "description": "Recorte por modelo do veiculo vinculado ao contrato."}, + "contrato_numero": {"field_name": "contrato_numero", "label": "Contrato", "description": "Rastreio por numero publico do contrato."}, +} + +_RENTAL_FILTERS = { + "created_at": {"field_name": "created_at", "label": "Periodo de cadastro", "filter_type": "date_range", "description": "Intervalo de cadastro no dataset administrativo.", "required": True}, + "categoria": {"field_name": "categoria", "label": "Categoria", "filter_type": "enum", "description": "Filtra frota ou contratos por categoria comercial."}, + "status": {"field_name": "status", "label": "Status", "filter_type": "enum", "description": "Restringe o consolidado para um ou mais status operacionais."}, + "modelo": {"field_name": "modelo", "label": "Modelo", "filter_type": "enum", "description": "Filtra o consolidado por modelo da frota."}, + "placa": {"field_name": "placa", "label": "Placa", "filter_type": "exact_match", "description": "Filtra o consolidado pela placa do veiculo."}, + "data_inicio": {"field_name": "data_inicio", "label": "Inicio da locacao", "filter_type": "date_range", "description": "Intervalo de abertura dos contratos de locacao.", "required": True}, + "data_fim_prevista": {"field_name": "data_fim_prevista", "label": "Fim previsto", "filter_type": "date_range", "description": "Intervalo do fim previsto dos contratos de locacao.", "required": True}, + "updated_at": {"field_name": "updated_at", "label": "Ultima atualizacao", "filter_type": "date_range", "description": "Intervalo da ultima atualizacao operacional do contrato.", "required": True}, + "modelo_veiculo": {"field_name": "modelo_veiculo", "label": "Modelo do veiculo", "filter_type": "enum", "description": "Filtra contratos pelo modelo do veiculo locado."}, + "contrato_numero": {"field_name": "contrato_numero", "label": "Contrato", "filter_type": "exact_match", "description": "Filtra o consolidado por numero publico do contrato."}, +} + +_RENTAL_REPORTS = ( + {"report_key": "fleet_availability", "label": "Disponibilidade da frota", "description": "Resume disponibilidade, status e diaria vigente da frota de locacao.", "dataset_key": _RENTAL_FLEET_DATASET_KEY, "default_time_field": "created_at", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("total_fleet_vehicles", "available_fleet_vehicles", "average_daily_rate"), "dimension_fields": ("created_at", "categoria", "status", "modelo"), "filter_fields": ("created_at", "categoria", "status", "modelo", "placa")}, + {"report_key": "contracts_lifecycle", "label": "Contratos ativos e encerrados", "description": "Organiza o ciclo operacional dos contratos de locacao entre abertos, ativos e encerrados.", "dataset_key": _RENTAL_CONTRACTS_DATASET_KEY, "default_time_field": "data_inicio", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("total_contracts", "active_contracts", "closed_contracts"), "dimension_fields": ("data_inicio", "categoria", "status", "modelo_veiculo"), "filter_fields": ("data_inicio", "categoria", "status", "placa", "contrato_numero")}, + {"report_key": "overdue_returns", "label": "Devolucoes em atraso", "description": "Acompanha contratos com fim previsto vencido e sem devolucao consolidada.", "dataset_key": _RENTAL_CONTRACTS_DATASET_KEY, "default_time_field": "data_fim_prevista", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("overdue_contracts", "active_contracts"), "dimension_fields": ("data_fim_prevista", "categoria", "status", "placa"), "filter_fields": ("data_fim_prevista", "categoria", "status", "placa", "contrato_numero")}, + {"report_key": "fleet_occupancy", "label": "Ocupacao da frota", "description": "Consolida o uso da frota por contratos ativos ao longo do tempo e por categoria.", "dataset_key": _RENTAL_CONTRACTS_DATASET_KEY, "default_time_field": "data_inicio", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("occupied_vehicles", "active_contracts", "projected_revenue"), "dimension_fields": ("data_inicio", "categoria", "modelo_veiculo", "status"), "filter_fields": ("data_inicio", "categoria", "status", "modelo_veiculo", "placa")}, + {"report_key": "projected_vs_final_revenue", "label": "Receita prevista versus final", "description": "Compara o valor previsto na abertura do contrato com o valor final consolidado da locacao.", "dataset_key": _RENTAL_CONTRACTS_DATASET_KEY, "default_time_field": "updated_at", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("projected_revenue", "final_revenue", "revenue_delta"), "dimension_fields": ("updated_at", "categoria", "status", "modelo_veiculo"), "filter_fields": ("updated_at", "categoria", "status", "placa", "contrato_numero")}, +) + +_BOT_FLOW_DATASET_KEY = "conversation_turns" + +_BOT_FLOW_REPORT_METRICS = { + "total_turns": {"key": "total_turns", "label": "Turnos totais", "aggregation": "count", "description": "Quantidade total de turnos processados no recorte operacional."}, + "completed_turns": {"key": "completed_turns", "label": "Turnos concluidos", "aggregation": "count_where_status_completed", "description": "Quantidade de turnos concluidos pelo fluxo operacional do bot."}, + "errored_turns": {"key": "errored_turns", "label": "Turnos com falha", "aggregation": "count_where_status_error", "description": "Quantidade de turnos com falha operacional no processamento."}, + "tool_routed_turns": {"key": "tool_routed_turns", "label": "Turnos com tool", "aggregation": "count_where_tool_called", "description": "Quantidade de turnos que acionaram pelo menos uma tool no fluxo."}, + "fallback_turns": {"key": "fallback_turns", "label": "Turnos em fallback", "aggregation": "count_where_action_fallback", "description": "Quantidade de turnos encaminhados para fallback funcional do bot."}, + "handoff_turns": {"key": "handoff_turns", "label": "Turnos em handoff", "aggregation": "count_where_action_handoff", "description": "Quantidade de turnos que escalaram para handoff humano."}, +} + +_BOT_FLOW_DIMENSIONS = { + "started_at": {"field_name": "started_at", "label": "Inicio do turno", "description": "Agrupamento temporal do inicio do processamento do turno.", "default_group_by": True}, + "completed_at": {"field_name": "completed_at", "label": "Fim do turno", "description": "Agrupamento temporal da finalizacao do turno processado.", "default_group_by": True}, + "channel": {"field_name": "channel", "label": "Canal", "description": "Recorte por canal operacional do atendimento."}, + "turn_status": {"field_name": "turn_status", "label": "Status do turno", "description": "Separa o fluxo pelos estados operacionais do turno."}, + "action": {"field_name": "action", "label": "Acao do fluxo", "description": "Recorte pela acao tomada pelo orquestrador durante o turno."}, + "tool_name": {"field_name": "tool_name", "label": "Tool acionada", "description": "Rastreio da tool utilizada durante o turno do bot."}, + "domain": {"field_name": "domain", "label": "Dominio operacional", "description": "Recorte pelo dominio operacional associado ao turno."}, + "intent": {"field_name": "intent", "label": "Intencao", "description": "Recorte pela intencao classificada para o turno."}, +} + +_BOT_FLOW_FILTERS = { + "started_at": {"field_name": "started_at", "label": "Inicio do turno", "filter_type": "date_range", "description": "Intervalo de inicio do processamento do turno.", "required": True}, + "completed_at": {"field_name": "completed_at", "label": "Fim do turno", "filter_type": "date_range", "description": "Intervalo de finalizacao do turno processado."}, + "channel": {"field_name": "channel", "label": "Canal", "filter_type": "enum", "description": "Filtra o fluxo por canal operacional."}, + "turn_status": {"field_name": "turn_status", "label": "Status do turno", "filter_type": "enum", "description": "Restringe o consolidado para um ou mais status do turno."}, + "action": {"field_name": "action", "label": "Acao do fluxo", "filter_type": "enum", "description": "Restringe o consolidado para uma ou mais acoes do fluxo do bot."}, + "tool_name": {"field_name": "tool_name", "label": "Tool acionada", "filter_type": "enum", "description": "Filtra os turnos pela tool utilizada no atendimento."}, + "domain": {"field_name": "domain", "label": "Dominio operacional", "filter_type": "enum", "description": "Filtra o fluxo pelo dominio operacional associado ao turno."}, + "intent": {"field_name": "intent", "label": "Intencao", "filter_type": "enum", "description": "Filtra o consolidado pela intencao classificada para o turno."}, +} + +_BOT_FLOW_REPORTS = ( + {"report_key": "turn_status_overview", "label": "Status dos turnos", "description": "Acompanha o andamento operacional dos turnos por status, canal e dominio.", "default_time_field": "started_at", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("total_turns", "completed_turns", "errored_turns"), "dimension_fields": ("started_at", "turn_status", "channel", "domain"), "filter_fields": ("started_at", "turn_status", "channel", "domain")}, + {"report_key": "action_routing_flow", "label": "Roteamento do fluxo", "description": "Organiza as acoes do orquestrador entre resposta, fallback, handoff e outros caminhos operacionais.", "default_time_field": "started_at", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("total_turns", "fallback_turns", "handoff_turns"), "dimension_fields": ("started_at", "action", "channel", "domain"), "filter_fields": ("started_at", "action", "channel", "domain", "intent")}, + {"report_key": "tool_activation_flow", "label": "Uso operacional de tools", "description": "Mostra quais turnos acionaram tools e como isso se distribui no fluxo do bot.", "default_time_field": "started_at", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("tool_routed_turns", "completed_turns", "errored_turns"), "dimension_fields": ("started_at", "tool_name", "action", "domain"), "filter_fields": ("started_at", "tool_name", "action", "domain", "intent")}, + {"report_key": "fallback_and_handoff", "label": "Fallback e handoff", "description": "Destaca turnos que saem do fluxo padrao para fallback funcional ou handoff humano.", "default_time_field": "started_at", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("fallback_turns", "handoff_turns", "errored_turns"), "dimension_fields": ("started_at", "action", "channel", "intent"), "filter_fields": ("started_at", "action", "channel", "intent", "domain")}, + {"report_key": "operational_failures", "label": "Falhas operacionais do fluxo", "description": "Ajuda a triar turnos com falha por status, acao e canal operacional.", "default_time_field": "started_at", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("errored_turns", "total_turns", "tool_routed_turns"), "dimension_fields": ("started_at", "turn_status", "action", "channel"), "filter_fields": ("started_at", "turn_status", "action", "channel", "tool_name")}, +) + +_CONVERSATION_TELEMETRY_DATASET_KEY = "conversation_turns" + +_CONVERSATION_TELEMETRY_REPORT_METRICS = { + "total_turns": {"key": "total_turns", "label": "Turnos totais", "aggregation": "count", "description": "Quantidade total de turnos observados no recorte de telemetria."}, + "distinct_conversations": {"key": "distinct_conversations", "label": "Conversas distintas", "aggregation": "count_distinct", "description": "Quantidade de conversas distintas observadas no recorte selecionado."}, + "average_latency_ms": {"key": "average_latency_ms", "label": "Latencia media", "aggregation": "avg", "description": "Media do tempo de processamento do turno em milissegundos."}, + "p95_latency_ms": {"key": "p95_latency_ms", "label": "Latencia p95", "aggregation": "percentile_p95", "description": "Percentil 95 do tempo de processamento dos turnos observados."}, + "tool_routed_turns": {"key": "tool_routed_turns", "label": "Turnos com tool", "aggregation": "count_where_tool_called", "description": "Quantidade de turnos que acionaram pelo menos uma tool no atendimento."}, + "errored_turns": {"key": "errored_turns", "label": "Turnos com falha", "aggregation": "count_where_status_error", "description": "Quantidade de turnos com falha no recorte de telemetria."}, +} + +_CONVERSATION_TELEMETRY_DIMENSIONS = { + "started_at": {"field_name": "started_at", "label": "Inicio do turno", "description": "Agrupamento temporal do inicio do processamento do turno.", "default_group_by": True}, + "completed_at": {"field_name": "completed_at", "label": "Fim do turno", "description": "Agrupamento temporal da finalizacao do turno.", "default_group_by": True}, + "channel": {"field_name": "channel", "label": "Canal", "description": "Recorte por canal operacional do atendimento."}, + "domain": {"field_name": "domain", "label": "Dominio operacional", "description": "Recorte pelo dominio operacional associado ao turno."}, + "intent": {"field_name": "intent", "label": "Intencao", "description": "Recorte pela intencao classificada para o turno."}, + "tool_name": {"field_name": "tool_name", "label": "Tool acionada", "description": "Rastreio da tool utilizada durante o turno."}, + "turn_status": {"field_name": "turn_status", "label": "Status do turno", "description": "Separa a telemetria pelos estados observados do turno."}, + "action": {"field_name": "action", "label": "Acao do turno", "description": "Recorte pela acao operacional tomada pelo orquestrador."}, +} + +_CONVERSATION_TELEMETRY_FILTERS = { + "started_at": {"field_name": "started_at", "label": "Inicio do turno", "filter_type": "date_range", "description": "Intervalo de inicio do processamento do turno.", "required": True}, + "completed_at": {"field_name": "completed_at", "label": "Fim do turno", "filter_type": "date_range", "description": "Intervalo de finalizacao do turno processado."}, + "channel": {"field_name": "channel", "label": "Canal", "filter_type": "enum", "description": "Filtra a telemetria por canal operacional."}, + "domain": {"field_name": "domain", "label": "Dominio operacional", "filter_type": "enum", "description": "Filtra a telemetria pelo dominio associado ao turno."}, + "intent": {"field_name": "intent", "label": "Intencao", "filter_type": "enum", "description": "Filtra o recorte pela intencao classificada."}, + "tool_name": {"field_name": "tool_name", "label": "Tool acionada", "filter_type": "enum", "description": "Filtra os turnos pela tool utilizada durante o atendimento."}, + "turn_status": {"field_name": "turn_status", "label": "Status do turno", "filter_type": "enum", "description": "Restringe o consolidado para um ou mais status observados."}, + "action": {"field_name": "action", "label": "Acao do turno", "filter_type": "enum", "description": "Restringe o consolidado para uma ou mais acoes do orquestrador."}, +} + +_CONVERSATION_TELEMETRY_REPORTS = ( + {"report_key": "conversation_volume", "label": "Volume de atendimento", "description": "Consolida o volume de turnos e conversas por periodo, canal e dominio.", "default_time_field": "started_at", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("total_turns", "distinct_conversations"), "dimension_fields": ("started_at", "channel", "domain", "intent"), "filter_fields": ("started_at", "channel", "domain", "intent")}, + {"report_key": "latency_profile", "label": "Perfil de latencia", "description": "Organiza sinais de latencia media e p95 por canal, dominio e intencao.", "default_time_field": "completed_at", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("average_latency_ms", "p95_latency_ms", "total_turns"), "dimension_fields": ("completed_at", "channel", "domain", "intent"), "filter_fields": ("started_at", "completed_at", "channel", "domain", "intent")}, + {"report_key": "domain_distribution", "label": "Distribuicao por dominio", "description": "Mostra como o atendimento se distribui entre dominios, intencoes e canais.", "default_time_field": "started_at", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("total_turns", "distinct_conversations"), "dimension_fields": ("started_at", "domain", "intent", "channel"), "filter_fields": ("started_at", "domain", "intent", "channel")}, + {"report_key": "tool_usage_telemetry", "label": "Uso de tools", "description": "Expoe quais tools aparecem com mais frequencia no atendimento e em quais contextos.", "default_time_field": "started_at", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("tool_routed_turns", "total_turns", "errored_turns"), "dimension_fields": ("started_at", "tool_name", "domain", "channel"), "filter_fields": ("started_at", "tool_name", "domain", "channel", "intent")}, + {"report_key": "turn_health_status", "label": "Saude por status", "description": "Acompanha estados de saude do atendimento por status observado e acao tomada.", "default_time_field": "started_at", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("errored_turns", "total_turns", "average_latency_ms"), "dimension_fields": ("started_at", "turn_status", "action", "channel"), "filter_fields": ("started_at", "turn_status", "action", "channel", "domain")}, +) + +_REPORT_FAMILIES = ( + { + "key": "sales", + "label": "Vendas", + "description": "Pedidos, conversao comercial e cancelamentos usados pela operacao interna.", + "dataset_keys": ["sales_orders"], + }, + { + "key": "arrecadacao", + "label": "Arrecadacao", + "description": "Recebimentos de locacao e conciliacao operacional do faturamento.", + "dataset_keys": ["rental_payments"], + }, + { + "key": "operacao", + "label": "Operacao", + "description": "Estoque, revisoes, frota e contratos que suportam o acompanhamento do dia a dia.", + "dataset_keys": [ + "vehicle_inventory", + "review_schedules", + "rental_fleet", + "rental_contracts", + ], + }, + { + "key": "telemetria_atendimento", + "label": "Telemetria de atendimento", + "description": "Turnos conversacionais, uso de tools e sinais de eficiencia do bot.", + "dataset_keys": ["conversation_turns"], + }, + { + "key": "integration_deliveries", + "label": "Entregas de integracao", + "description": "Rastreio operacional das entregas para provedores e falhas de despacho.", + "dataset_keys": ["integration_deliveries"], + }, +) + + +class ReportService: + def __init__(self, settings: AdminSettings): + self.settings = settings + + def build_overview_payload(self) -> dict: + datasets = PRODUCT_OPERATIONAL_DATASETS + near_real_time_count = sum(1 for dataset in datasets if dataset.freshness_target.value == "near_real_time") + intra_hour_count = sum(1 for dataset in datasets if dataset.freshness_target.value == "intra_hour") + return { + "mode": "shared_contract_bootstrap", + "metrics": [ + { + "key": "datasets", + "label": "Datasets liberados", + "value": str(len(datasets)), + "description": "Datasets operacionais explicitamente liberados para relatorios administrativos.", + }, + { + "key": "domains", + "label": "Dominios operacionais", + "value": str(len({dataset.domain for dataset in datasets})), + "description": "Dominios cobertos pelo catalogo inicial de leitura administrativa.", + }, + { + "key": "near_real_time_targets", + "label": "Metas near real time", + "value": str(near_real_time_count), + "description": "Datasets cuja UX espera consolidacao mais frequente sem leitura live do produto.", + }, + { + "key": "intra_hour_targets", + "label": "Metas intra-hour", + "value": str(intra_hour_count), + "description": "Datasets de apoio operacional e telemetria servidos por consolidacao eventual intra-horaria.", + }, + ], + "materialization": self._build_materialization_payload(), + "report_families": list(_REPORT_FAMILIES), + "next_steps": [ + "Criar snapshots sanitizados no admin para vendas, arrecadacao e operacao.", + "Servir views dedicadas por caso de uso em vez de espelhar o schema operacional do produto.", + "Exibir carimbo de atualizacao e watermark quando a camada de sincronizacao entrar em producao.", + ], + } + + def list_datasets_payload(self) -> dict: + return { + "source": _REPORT_SOURCE, + "materialization": self._build_materialization_payload(), + "datasets": [ + self._serialize_dataset_summary(dataset) + for dataset in sorted( + PRODUCT_OPERATIONAL_DATASETS, + key=lambda item: (item.domain.value, item.dataset_key), + ) + ], + } + + def get_dataset_payload(self, dataset_key: str) -> dict | None: + dataset = get_operational_dataset(dataset_key) + if dataset is None: + return None + + return { + "source": _REPORT_SOURCE, + "materialization": self._build_materialization_payload(dataset), + "dataset": self._serialize_dataset_detail(dataset), + } + + def build_sales_overview_payload(self) -> dict: + dataset = self._get_sales_dataset() + return { + "domain": dataset.domain, + "mode": "sales_contract_bootstrap", + "source_dataset_keys": [dataset.dataset_key], + "metrics": [ + { + "key": "source_datasets", + "label": "Datasets fonte", + "value": "1", + "description": "A estrutura inicial de vendas nasce apoiada em um dataset sanitizado de pedidos.", + }, + { + "key": "initial_reports", + "label": "Relatorios iniciais", + "value": str(len(_SALES_REPORTS)), + "description": "Casos de uso de vendas previstos para a primeira superficie administrativa do dominio.", + }, + { + "key": "allowed_fields", + "label": "Campos liberados", + "value": str(len(dataset.allowed_fields)), + "description": "Campos operacionais expostos para agregacao e filtros de vendas.", + }, + { + "key": "blocked_fields", + "label": "Campos bloqueados", + "value": str(len(dataset.blocked_fields)), + "description": "Campos sensiveis que permanecem fora do read model administrativo.", + }, + { + "key": "freshness_target", + "label": "Meta de frescor", + "value": dataset.freshness_target.value, + "description": "Objetivo inicial de consolidacao para a UX dos relatorios de vendas.", + }, + ], + "materialization": self._build_materialization_payload(dataset), + "reports": [self._serialize_sales_report_summary(report) for report in _SALES_REPORTS], + "next_steps": [ + "Materializar snapshot sanitizado de sales_orders no banco administrativo.", + "Criar dedicated views separadas para volume, ticket medio e cancelamentos.", + "Exibir watermark e timestamp da ultima consolidacao quando o ETL incremental entrar em producao.", + ], + } + + def list_sales_reports_payload(self) -> dict: + dataset = self._get_sales_dataset() + return { + "domain": dataset.domain, + "source": _REPORT_SOURCE, + "materialization": self._build_materialization_payload(dataset), + "reports": [self._serialize_sales_report_summary(report) for report in _SALES_REPORTS], + } + + def get_sales_report_payload(self, report_key: str) -> dict | None: + normalized_report_key = self._normalize_key(report_key) + dataset = self._get_sales_dataset() + for report in _SALES_REPORTS: + if report["report_key"] == normalized_report_key: + return { + "domain": dataset.domain, + "source": _REPORT_SOURCE, + "materialization": self._build_materialization_payload(dataset), + "report": self._serialize_sales_report_detail(report, dataset), + } + return None + + def build_revenue_overview_payload(self) -> dict: + dataset = self._get_revenue_dataset() + return { + "area": "arrecadacao", + "source_domain": dataset.domain, + "mode": "revenue_contract_bootstrap", + "source_dataset_keys": [dataset.dataset_key], + "metrics": [ + { + "key": "source_datasets", + "label": "Datasets fonte", + "value": "1", + "description": "A estrutura inicial de arrecadacao nasce apoiada em um dataset sanitizado de pagamentos.", + }, + { + "key": "initial_reports", + "label": "Relatorios iniciais", + "value": str(len(_REVENUE_REPORTS)), + "description": "Casos de uso iniciais de arrecadacao previstos para a primeira superficie administrativa.", + }, + { + "key": "allowed_fields", + "label": "Campos liberados", + "value": str(len(dataset.allowed_fields)), + "description": "Campos operacionais expostos para agregacao e conciliacao de pagamentos.", + }, + { + "key": "blocked_fields", + "label": "Campos bloqueados", + "value": str(len(dataset.blocked_fields)), + "description": "Campos sensiveis que permanecem fora do read model administrativo.", + }, + { + "key": "freshness_target", + "label": "Meta de frescor", + "value": dataset.freshness_target.value, + "description": "Objetivo inicial de consolidacao para a UX dos relatorios de arrecadacao.", + }, + ], + "materialization": self._build_materialization_payload(dataset), + "reports": [self._serialize_revenue_report_summary(report) for report in _REVENUE_REPORTS], + "next_steps": [ + "Materializar snapshot sanitizado de rental_payments no banco administrativo.", + "Criar dedicated views separadas para arrecadacao por periodo e conciliacao por contrato.", + "Cruzar contratos e pagamentos em uma etapa futura para abrir inadimplencia operacional sem leitura live do produto.", + ], + } + + def list_revenue_reports_payload(self) -> dict: + dataset = self._get_revenue_dataset() + return { + "area": "arrecadacao", + "source_domain": dataset.domain, + "source": _REPORT_SOURCE, + "materialization": self._build_materialization_payload(dataset), + "reports": [self._serialize_revenue_report_summary(report) for report in _REVENUE_REPORTS], + } + + def get_revenue_report_payload(self, report_key: str) -> dict | None: + normalized_report_key = self._normalize_key(report_key) + dataset = self._get_revenue_dataset() + for report in _REVENUE_REPORTS: + if report["report_key"] == normalized_report_key: + return { + "area": "arrecadacao", + "source_domain": dataset.domain, + "source": _REPORT_SOURCE, + "materialization": self._build_materialization_payload(dataset), + "report": self._serialize_revenue_report_detail(report, dataset), + } + return None + + def build_rental_overview_payload(self) -> dict: + fleet_dataset = self._get_rental_fleet_dataset() + contracts_dataset = self._get_rental_contracts_dataset() + return { + "area": "locacao", + "source_domain": contracts_dataset.domain, + "mode": "rental_contract_bootstrap", + "source_dataset_keys": [fleet_dataset.dataset_key, contracts_dataset.dataset_key], + "metrics": [ + { + "key": "source_datasets", + "label": "Datasets fonte", + "value": "2", + "description": "A estrutura inicial de locacao nasce sobre snapshots sanitizados de frota e contratos.", + }, + { + "key": "initial_reports", + "label": "Relatorios iniciais", + "value": str(len(_RENTAL_REPORTS)), + "description": "Casos de uso operacionais de locacao previstos para a primeira superficie administrativa.", + }, + { + "key": "fleet_allowed_fields", + "label": "Campos liberados da frota", + "value": str(len(fleet_dataset.allowed_fields)), + "description": "Campos expostos para disponibilidade, categoria e diaria vigente da frota.", + }, + { + "key": "contracts_allowed_fields", + "label": "Campos liberados dos contratos", + "value": str(len(contracts_dataset.allowed_fields)), + "description": "Campos expostos para ciclo do contrato, ocupacao e devolucao operacional.", + }, + { + "key": "freshness_target", + "label": "Meta de frescor", + "value": contracts_dataset.freshness_target.value, + "description": "Objetivo inicial de consolidacao para a UX dos relatorios de locacao.", + }, + ], + "materialization": self._build_materialization_payload(contracts_dataset), + "reports": [self._serialize_rental_report_summary(report) for report in _RENTAL_REPORTS], + "next_steps": [ + "Materializar snapshots sanitizados de rental_fleet e rental_contracts no banco administrativo.", + "Criar dedicated views separadas para disponibilidade da frota, contratos em curso e devolucoes em atraso.", + "Combinar frota e contratos em uma camada futura de ocupacao sem consultar tabelas live do produto.", + ], + } + + def list_rental_reports_payload(self) -> dict: + contracts_dataset = self._get_rental_contracts_dataset() + return { + "area": "locacao", + "source_domain": contracts_dataset.domain, + "source": _REPORT_SOURCE, + "materialization": self._build_materialization_payload(contracts_dataset), + "reports": [self._serialize_rental_report_summary(report) for report in _RENTAL_REPORTS], + } + + def get_rental_report_payload(self, report_key: str) -> dict | None: + normalized_report_key = self._normalize_key(report_key) + for report in _RENTAL_REPORTS: + if report["report_key"] == normalized_report_key: + dataset = self._get_rental_dataset(report["dataset_key"]) + return { + "area": "locacao", + "source_domain": dataset.domain, + "source": _REPORT_SOURCE, + "materialization": self._build_materialization_payload(dataset), + "report": self._serialize_rental_report_detail(report, dataset), + } + return None + + def build_bot_flow_overview_payload(self) -> dict: + dataset = self._get_bot_flow_dataset() + return { + "area": "fluxo_bot", + "source_domain": dataset.domain, + "mode": "bot_flow_contract_bootstrap", + "source_dataset_keys": [dataset.dataset_key], + "metrics": [ + { + "key": "source_datasets", + "label": "Datasets fonte", + "value": "1", + "description": "A estrutura inicial do fluxo do bot nasce apoiada em um dataset sanitizado de turnos operacionais.", + }, + { + "key": "initial_reports", + "label": "Relatorios iniciais", + "value": str(len(_BOT_FLOW_REPORTS)), + "description": "Casos de uso de operacao do fluxo do bot previstos para a primeira superficie administrativa.", + }, + { + "key": "allowed_fields", + "label": "Campos liberados", + "value": str(len(dataset.allowed_fields)), + "description": "Campos operacionais expostos para triagem de status, acao e uso de tools.", + }, + { + "key": "blocked_fields", + "label": "Campos bloqueados", + "value": str(len(dataset.blocked_fields)), + "description": "Campos sensiveis e mensagens livres que permanecem fora da operacao administrativa.", + }, + { + "key": "freshness_target", + "label": "Meta de frescor", + "value": dataset.freshness_target.value, + "description": "Objetivo inicial de consolidacao para a UX dos relatorios operacionais do fluxo do bot.", + }, + ], + "materialization": self._build_materialization_payload(dataset), + "reports": [self._serialize_bot_flow_report_summary(report) for report in _BOT_FLOW_REPORTS], + "next_steps": [ + "Materializar snapshot sanitizado de conversation_turns no banco administrativo.", + "Criar dedicated views separadas para status do turno, roteamento operacional e falhas do fluxo.", + "Reservar latencia e eficiencia detalhada para a etapa seguinte de telemetria conversacional.", + ], + } + + def list_bot_flow_reports_payload(self) -> dict: + dataset = self._get_bot_flow_dataset() + return { + "area": "fluxo_bot", + "source_domain": dataset.domain, + "source": _REPORT_SOURCE, + "materialization": self._build_materialization_payload(dataset), + "reports": [self._serialize_bot_flow_report_summary(report) for report in _BOT_FLOW_REPORTS], + } + + def get_bot_flow_report_payload(self, report_key: str) -> dict | None: + normalized_report_key = self._normalize_key(report_key) + dataset = self._get_bot_flow_dataset() + for report in _BOT_FLOW_REPORTS: + if report["report_key"] == normalized_report_key: + return { + "area": "fluxo_bot", + "source_domain": dataset.domain, + "source": _REPORT_SOURCE, + "materialization": self._build_materialization_payload(dataset), + "report": self._serialize_bot_flow_report_detail(report, dataset), + } + return None + + def build_conversation_telemetry_overview_payload(self) -> dict: + dataset = self._get_conversation_telemetry_dataset() + return { + "area": "telemetria_conversacional", + "source_domain": dataset.domain, + "mode": "conversation_telemetry_contract_bootstrap", + "source_dataset_keys": [dataset.dataset_key], + "metrics": [ + { + "key": "source_datasets", + "label": "Datasets fonte", + "value": "1", + "description": "A estrutura inicial de telemetria conversacional nasce apoiada em um dataset sanitizado de turnos.", + }, + { + "key": "initial_reports", + "label": "Relatorios iniciais", + "value": str(len(_CONVERSATION_TELEMETRY_REPORTS)), + "description": "Casos de uso iniciais de observabilidade conversacional previstos para a primeira superficie administrativa.", + }, + { + "key": "allowed_fields", + "label": "Campos liberados", + "value": str(len(dataset.allowed_fields)), + "description": "Campos expostos para volume, latencia, distribuicao por dominio e uso de tools.", + }, + { + "key": "blocked_fields", + "label": "Campos bloqueados", + "value": str(len(dataset.blocked_fields)), + "description": "Campos sensiveis e texto livre que permanecem fora da telemetria administrativa.", + }, + { + "key": "freshness_target", + "label": "Meta de frescor", + "value": dataset.freshness_target.value, + "description": "Objetivo inicial de consolidacao para a UX dos relatorios de telemetria conversacional.", + }, + ], + "materialization": self._build_materialization_payload(dataset), + "reports": [self._serialize_conversation_telemetry_report_summary(report) for report in _CONVERSATION_TELEMETRY_REPORTS], + "next_steps": [ + "Materializar snapshot sanitizado de conversation_turns no banco administrativo.", + "Criar dedicated views separadas para volume, latencia e distribuicao por dominio do atendimento.", + "Preparar buckets e watermark de consolidacao para comparativos historicos da telemetria.", + ], + } + + def list_conversation_telemetry_reports_payload(self) -> dict: + dataset = self._get_conversation_telemetry_dataset() + return { + "area": "telemetria_conversacional", + "source_domain": dataset.domain, + "source": _REPORT_SOURCE, + "materialization": self._build_materialization_payload(dataset), + "reports": [self._serialize_conversation_telemetry_report_summary(report) for report in _CONVERSATION_TELEMETRY_REPORTS], + } + + def get_conversation_telemetry_report_payload(self, report_key: str) -> dict | None: + normalized_report_key = self._normalize_key(report_key) + dataset = self._get_conversation_telemetry_dataset() + for report in _CONVERSATION_TELEMETRY_REPORTS: + if report["report_key"] == normalized_report_key: + return { + "area": "telemetria_conversacional", + "source_domain": dataset.domain, + "source": _REPORT_SOURCE, + "materialization": self._build_materialization_payload(dataset), + "report": self._serialize_conversation_telemetry_report_detail(report, dataset), + } + return None + + @staticmethod + def _build_materialization_payload(dataset: OperationalDatasetContract | None = None) -> dict: + reference_dataset = dataset or PRODUCT_OPERATIONAL_DATASETS[0] + return { + "report_read_model": reference_dataset.report_read_model, + "consistency_model": reference_dataset.consistency_model, + "sync_strategy": reference_dataset.sync_strategy, + "storage_shape": reference_dataset.storage_shape, + "query_surface": reference_dataset.query_surface, + "uses_product_replica": reference_dataset.uses_product_replica, + "direct_product_query_allowed": reference_dataset.direct_product_query_allowed, + "refresh_behavior": _REFRESH_BEHAVIOR, + } + + @staticmethod + def _serialize_dataset_summary(dataset) -> dict: + return { + "dataset_key": dataset.dataset_key, + "domain": dataset.domain, + "description": dataset.description, + "source_table": dataset.source_table, + "freshness_target": dataset.freshness_target, + "allowed_granularities": list(dataset.allowed_granularities), + "allowed_field_count": len(dataset.allowed_fields), + "blocked_field_count": len(dataset.blocked_fields), + "write_allowed": dataset.write_allowed, + "materialization_status": _MATERIALIZATION_STATUS, + "last_consolidated_at": None, + "source_watermark": None, + } + + @classmethod + def _serialize_dataset_detail(cls, dataset) -> dict: + return { + "dataset_key": dataset.dataset_key, + "domain": dataset.domain, + "description": dataset.description, + "source_table": dataset.source_table, + "read_permission": dataset.read_permission, + "report_read_model": dataset.report_read_model, + "consistency_model": dataset.consistency_model, + "sync_strategy": dataset.sync_strategy, + "storage_shape": dataset.storage_shape, + "query_surface": dataset.query_surface, + "uses_product_replica": dataset.uses_product_replica, + "direct_product_query_allowed": dataset.direct_product_query_allowed, + "freshness_target": dataset.freshness_target, + "allowed_granularities": list(dataset.allowed_granularities), + "write_allowed": dataset.write_allowed, + "materialization_status": _MATERIALIZATION_STATUS, + "last_consolidated_at": None, + "source_watermark": None, + "allowed_fields": [cls._serialize_field(field) for field in dataset.allowed_fields], + "blocked_fields": [cls._serialize_field(field) for field in dataset.blocked_fields], + } + + @classmethod + def _serialize_sales_report_summary(cls, report_definition: dict) -> dict: + return { + "report_key": report_definition["report_key"], + "label": report_definition["label"], + "description": report_definition["description"], + "dataset_key": _SALES_DATASET_KEY, + "default_time_field": report_definition["default_time_field"], + "default_granularity": report_definition["default_granularity"], + "supported_metric_keys": list(report_definition["metric_keys"]), + "supported_dimension_fields": list(report_definition["dimension_fields"]), + "materialization_status": _MATERIALIZATION_STATUS, + "last_consolidated_at": None, + "source_watermark": None, + } + + @classmethod + def _serialize_sales_report_detail( + cls, + report_definition: dict, + dataset: OperationalDatasetContract, + ) -> dict: + return { + "report_key": report_definition["report_key"], + "label": report_definition["label"], + "description": report_definition["description"], + "dataset_key": _SALES_DATASET_KEY, + "default_time_field": report_definition["default_time_field"], + "default_granularity": report_definition["default_granularity"], + "metrics": [dict(_SALES_REPORT_METRICS[key]) for key in report_definition["metric_keys"]], + "dimensions": [dict(_SALES_DIMENSIONS[field_name]) for field_name in report_definition["dimension_fields"]], + "filters": [dict(_SALES_FILTERS[field_name]) for field_name in report_definition["filter_fields"]], + "dataset": cls._serialize_dataset_detail(dataset), + } + + @classmethod + def _serialize_revenue_report_summary(cls, report_definition: dict) -> dict: + return { + "report_key": report_definition["report_key"], + "label": report_definition["label"], + "description": report_definition["description"], + "dataset_key": _REVENUE_DATASET_KEY, + "default_time_field": report_definition["default_time_field"], + "default_granularity": report_definition["default_granularity"], + "supported_metric_keys": list(report_definition["metric_keys"]), + "supported_dimension_fields": list(report_definition["dimension_fields"]), + "materialization_status": _MATERIALIZATION_STATUS, + "last_consolidated_at": None, + "source_watermark": None, + } + + @classmethod + def _serialize_revenue_report_detail( + cls, + report_definition: dict, + dataset: OperationalDatasetContract, + ) -> dict: + return { + "report_key": report_definition["report_key"], + "label": report_definition["label"], + "description": report_definition["description"], + "dataset_key": _REVENUE_DATASET_KEY, + "default_time_field": report_definition["default_time_field"], + "default_granularity": report_definition["default_granularity"], + "metrics": [dict(_REVENUE_REPORT_METRICS[key]) for key in report_definition["metric_keys"]], + "dimensions": [dict(_REVENUE_DIMENSIONS[field_name]) for field_name in report_definition["dimension_fields"]], + "filters": [dict(_REVENUE_FILTERS[field_name]) for field_name in report_definition["filter_fields"]], + "dataset": cls._serialize_dataset_detail(dataset), + } + + @classmethod + def _serialize_rental_report_summary(cls, report_definition: dict) -> dict: + return { + "report_key": report_definition["report_key"], + "label": report_definition["label"], + "description": report_definition["description"], + "dataset_key": report_definition["dataset_key"], + "default_time_field": report_definition["default_time_field"], + "default_granularity": report_definition["default_granularity"], + "supported_metric_keys": list(report_definition["metric_keys"]), + "supported_dimension_fields": list(report_definition["dimension_fields"]), + "materialization_status": _MATERIALIZATION_STATUS, + "last_consolidated_at": None, + "source_watermark": None, + } + + @classmethod + def _serialize_rental_report_detail( + cls, + report_definition: dict, + dataset: OperationalDatasetContract, + ) -> dict: + return { + "report_key": report_definition["report_key"], + "label": report_definition["label"], + "description": report_definition["description"], + "dataset_key": report_definition["dataset_key"], + "default_time_field": report_definition["default_time_field"], + "default_granularity": report_definition["default_granularity"], + "metrics": [dict(_RENTAL_REPORT_METRICS[key]) for key in report_definition["metric_keys"]], + "dimensions": [dict(_RENTAL_DIMENSIONS[field_name]) for field_name in report_definition["dimension_fields"]], + "filters": [dict(_RENTAL_FILTERS[field_name]) for field_name in report_definition["filter_fields"]], + "dataset": cls._serialize_dataset_detail(dataset), + } + + @classmethod + def _serialize_bot_flow_report_summary(cls, report_definition: dict) -> dict: + return { + "report_key": report_definition["report_key"], + "label": report_definition["label"], + "description": report_definition["description"], + "dataset_key": _BOT_FLOW_DATASET_KEY, + "default_time_field": report_definition["default_time_field"], + "default_granularity": report_definition["default_granularity"], + "supported_metric_keys": list(report_definition["metric_keys"]), + "supported_dimension_fields": list(report_definition["dimension_fields"]), + "materialization_status": _MATERIALIZATION_STATUS, + "last_consolidated_at": None, + "source_watermark": None, + } + + @classmethod + def _serialize_bot_flow_report_detail( + cls, + report_definition: dict, + dataset: OperationalDatasetContract, + ) -> dict: + return { + "report_key": report_definition["report_key"], + "label": report_definition["label"], + "description": report_definition["description"], + "dataset_key": _BOT_FLOW_DATASET_KEY, + "default_time_field": report_definition["default_time_field"], + "default_granularity": report_definition["default_granularity"], + "metrics": [dict(_BOT_FLOW_REPORT_METRICS[key]) for key in report_definition["metric_keys"]], + "dimensions": [dict(_BOT_FLOW_DIMENSIONS[field_name]) for field_name in report_definition["dimension_fields"]], + "filters": [dict(_BOT_FLOW_FILTERS[field_name]) for field_name in report_definition["filter_fields"]], + "dataset": cls._serialize_dataset_detail(dataset), + } + + @classmethod + def _serialize_conversation_telemetry_report_summary(cls, report_definition: dict) -> dict: + return { + "report_key": report_definition["report_key"], + "label": report_definition["label"], + "description": report_definition["description"], + "dataset_key": _CONVERSATION_TELEMETRY_DATASET_KEY, + "default_time_field": report_definition["default_time_field"], + "default_granularity": report_definition["default_granularity"], + "supported_metric_keys": list(report_definition["metric_keys"]), + "supported_dimension_fields": list(report_definition["dimension_fields"]), + "materialization_status": _MATERIALIZATION_STATUS, + "last_consolidated_at": None, + "source_watermark": None, + } + + @classmethod + def _serialize_conversation_telemetry_report_detail( + cls, + report_definition: dict, + dataset: OperationalDatasetContract, + ) -> dict: + return { + "report_key": report_definition["report_key"], + "label": report_definition["label"], + "description": report_definition["description"], + "dataset_key": _CONVERSATION_TELEMETRY_DATASET_KEY, + "default_time_field": report_definition["default_time_field"], + "default_granularity": report_definition["default_granularity"], + "metrics": [dict(_CONVERSATION_TELEMETRY_REPORT_METRICS[key]) for key in report_definition["metric_keys"]], + "dimensions": [dict(_CONVERSATION_TELEMETRY_DIMENSIONS[field_name]) for field_name in report_definition["dimension_fields"]], + "filters": [dict(_CONVERSATION_TELEMETRY_FILTERS[field_name]) for field_name in report_definition["filter_fields"]], + "dataset": cls._serialize_dataset_detail(dataset), + } + + @staticmethod + def _serialize_field(field) -> dict: + return { + "name": field.name, + "description": field.description, + "sensitivity": field.sensitivity, + } + + @staticmethod + def _normalize_key(value: str) -> str: + return str(value or "").strip().lower() + + @staticmethod + def _get_sales_dataset() -> OperationalDatasetContract: + dataset = get_operational_dataset(_SALES_DATASET_KEY) + if dataset is None: + raise RuntimeError("sales_orders contract is required to build sales reports") + return dataset + + @staticmethod + def _get_revenue_dataset() -> OperationalDatasetContract: + dataset = get_operational_dataset(_REVENUE_DATASET_KEY) + if dataset is None: + raise RuntimeError("rental_payments contract is required to build revenue reports") + return dataset + + @staticmethod + def _get_rental_dataset(dataset_key: str) -> OperationalDatasetContract: + dataset = get_operational_dataset(dataset_key) + if dataset is None: + raise RuntimeError(f"{dataset_key} contract is required to build rental reports") + return dataset + + @classmethod + def _get_rental_fleet_dataset(cls) -> OperationalDatasetContract: + return cls._get_rental_dataset(_RENTAL_FLEET_DATASET_KEY) + + @classmethod + def _get_rental_contracts_dataset(cls) -> OperationalDatasetContract: + return cls._get_rental_dataset(_RENTAL_CONTRACTS_DATASET_KEY) + + @staticmethod + def _get_bot_flow_dataset() -> OperationalDatasetContract: + dataset = get_operational_dataset(_BOT_FLOW_DATASET_KEY) + if dataset is None: + raise RuntimeError("conversation_turns contract is required to build bot flow reports") + return dataset + + @staticmethod + def _get_conversation_telemetry_dataset() -> OperationalDatasetContract: + dataset = get_operational_dataset(_CONVERSATION_TELEMETRY_DATASET_KEY) + if dataset is None: + raise RuntimeError("conversation_turns contract is required to build conversation telemetry reports") + return dataset \ No newline at end of file diff --git a/admin_app/services/system_service.py b/admin_app/services/system_service.py index 7703fa1..1b0bef5 100644 --- a/admin_app/services/system_service.py +++ b/admin_app/services/system_service.py @@ -1,5 +1,17 @@ -from admin_app.core import AdminCredentialStrategy, AdminSecurityService +from admin_app.core import AdminCredentialStrategy, AdminSecurityService +from admin_app.db.write_governance import ( + build_admin_write_governance_payload, + build_admin_write_governance_source_payload, +) from admin_app.core.settings import AdminSettings +from shared.contracts import ( + BOT_GOVERNED_SETTINGS, + FunctionalConfigurationPropagation, + MODEL_RUNTIME_PROFILES, + MODEL_RUNTIME_SEPARATION_RULES, + SYSTEM_FUNCTIONAL_CONFIGURATIONS, + get_functional_configuration, +) class SystemService: @@ -56,6 +68,95 @@ class SystemService: def build_security_configuration_payload(self) -> AdminCredentialStrategy: return self.security_service.build_credential_strategy() + def build_model_runtime_separation_payload(self) -> dict: + atendimento_configuration = get_functional_configuration("atendimento_runtime_profile") + tool_generation_configuration = get_functional_configuration("tool_generation_runtime_profile") + if atendimento_configuration is None or tool_generation_configuration is None: + raise RuntimeError("Shared functional configuration contracts are not available.") + + return { + "runtime_profiles": [ + self._serialize_model_runtime_profile(runtime_profile) + for runtime_profile in MODEL_RUNTIME_PROFILES + ], + "separation_rules": list(MODEL_RUNTIME_SEPARATION_RULES), + "atendimento_runtime_configuration": self._serialize_functional_configuration( + atendimento_configuration + ), + "tool_generation_runtime_configuration": self._serialize_functional_configuration( + tool_generation_configuration + ), + "bot_governed_parent_config_keys": sorted( + {setting.parent_config_key for setting in BOT_GOVERNED_SETTINGS} + ), + } + + def build_functional_configuration_catalog_payload(self) -> dict: + return { + "mode": "shared_contract_bootstrap", + "configurations": [ + self._serialize_functional_configuration(configuration) + for configuration in SYSTEM_FUNCTIONAL_CONFIGURATIONS + ], + "bot_governed_parent_config_keys": sorted( + {setting.parent_config_key for setting in BOT_GOVERNED_SETTINGS} + ), + "next_steps": [ + "Persistir estado funcional governado no admin antes da publicacao para o produto.", + "Adicionar versionamento, auditoria e aprovacao humana para configuracoes alteraveis.", + "Conectar o estado desejado do admin ao estado efetivo publicado no product.", + ], + } + + def get_functional_configuration_payload(self, config_key: str) -> dict | None: + configuration = get_functional_configuration(config_key) + if configuration is None: + return None + + linked_bot_settings = [ + self._serialize_bot_governed_setting(setting) + for setting in BOT_GOVERNED_SETTINGS + if setting.parent_config_key == configuration.config_key + ] + related_runtime_profile = next( + ( + self._serialize_model_runtime_profile(runtime_profile) + for runtime_profile in MODEL_RUNTIME_PROFILES + if runtime_profile.config_key == configuration.config_key + ), + None, + ) + return { + "configuration": self._serialize_functional_configuration(configuration), + "linked_bot_settings": linked_bot_settings, + "related_runtime_profile": related_runtime_profile, + "managed_by_bot_governance": bool(linked_bot_settings), + } + + def build_bot_governed_configuration_payload(self) -> dict: + ordered_settings = sorted( + BOT_GOVERNED_SETTINGS, + key=lambda setting: (setting.parent_config_key, setting.area.value, setting.setting_key), + ) + return { + "parent_config_keys": sorted( + {setting.parent_config_key for setting in BOT_GOVERNED_SETTINGS} + ), + "settings": [ + self._serialize_bot_governed_setting(setting) + for setting in ordered_settings + ], + } + + def build_write_governance_payload(self) -> dict: + payload = build_admin_write_governance_payload() + payload["governed_configuration_keys"] = sorted( + configuration.config_key + for configuration in SYSTEM_FUNCTIONAL_CONFIGURATIONS + if configuration.propagation == FunctionalConfigurationPropagation.VERSIONED_PUBLICATION + ) + return payload + def build_configuration_sources_payload(self) -> list[dict]: return [ { @@ -82,4 +183,80 @@ class SystemService: "mutable": False, "description": "Cookies e sessao web do painel derivam da configuracao ativa do admin.", }, + { + "key": "functional_configuration_contracts", + "source": "shared_contract", + "mutable": False, + "description": "Catalogo compartilhado das configuracoes funcionais governadas entre admin e product.", + }, + { + "key": "bot_governed_configuration_contracts", + "source": "shared_contract", + "mutable": False, + "description": "Regras compartilhadas dos campos do bot que ficam sob governanca administrativa.", + }, + { + "key": "model_runtime_separation", + "source": "shared_contract", + "mutable": False, + "description": "Contratos compartilhados que separam o runtime do atendimento do runtime de geracao de tools.", + }, + build_admin_write_governance_source_payload(), ] + + @staticmethod + def _serialize_model_runtime_profile(runtime_profile) -> dict: + return { + "runtime_target": runtime_profile.runtime_target, + "config_key": runtime_profile.config_key, + "catalog_runtime_target": runtime_profile.catalog_runtime_target, + "purpose": runtime_profile.purpose, + "consumed_by_service": runtime_profile.consumed_by_service, + "description": runtime_profile.description, + "read_permission": runtime_profile.read_permission, + "write_permission": runtime_profile.write_permission, + "published_independently": runtime_profile.published_independently, + "rollback_independently": runtime_profile.rollback_independently, + "cross_target_propagation_allowed": runtime_profile.cross_target_propagation_allowed, + "affects_customer_response": runtime_profile.affects_customer_response, + "can_generate_code": runtime_profile.can_generate_code, + } + + @staticmethod + def _serialize_functional_configuration(configuration) -> dict: + return { + "config_key": configuration.config_key, + "domain": configuration.domain, + "description": configuration.description, + "source": configuration.source, + "read_permission": configuration.read_permission, + "write_permission": configuration.write_permission, + "mutability": configuration.mutability, + "propagation": configuration.propagation, + "affects_product_runtime": configuration.affects_product_runtime, + "direct_product_write_allowed": configuration.direct_product_write_allowed, + "fields": [ + { + "name": field.name, + "description": field.description, + "writable": field.writable, + "secret": field.secret, + } + for field in configuration.fields + ], + } + + @staticmethod + def _serialize_bot_governed_setting(setting) -> dict: + return { + "setting_key": setting.setting_key, + "parent_config_key": setting.parent_config_key, + "field_name": setting.field_name, + "area": setting.area, + "description": setting.description, + "read_permission": setting.read_permission, + "write_permission": setting.write_permission, + "mutability": setting.mutability, + "versioned_publication_required": setting.versioned_publication_required, + "direct_product_write_allowed": setting.direct_product_write_allowed, + } diff --git a/tests/test_admin_bot_flow_reports_web.py b/tests/test_admin_bot_flow_reports_web.py new file mode 100644 index 0000000..273033e --- /dev/null +++ b/tests/test_admin_bot_flow_reports_web.py @@ -0,0 +1,116 @@ +import unittest + +from fastapi.testclient import TestClient + +from admin_app.api.dependencies import get_current_staff_principal +from admin_app.app_factory import create_app +from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal +from shared.contracts import StaffRole + + +class AdminBotFlowReportsWebTests(unittest.TestCase): + def _build_client_with_role( + self, + role: StaffRole, + settings: AdminSettings | None = None, + ) -> tuple[TestClient, object]: + app = create_app( + settings + or AdminSettings( + admin_auth_token_secret="test-secret", + admin_api_prefix="/admin", + ) + ) + app.dependency_overrides[get_current_staff_principal] = lambda: AuthenticatedStaffPrincipal( + id=71, + email="colaborador@empresa.com" if role == StaffRole.COLABORADOR else "diretor@empresa.com", + display_name="Equipe de Operacao do Bot", + role=role, + is_active=True, + ) + return TestClient(app), app + + def test_bot_flow_reports_overview_requires_authentication(self): + app = create_app(AdminSettings(admin_auth_token_secret="test-secret", admin_api_prefix="/admin")) + client = TestClient(app) + + response = client.get("/admin/reports/fluxo-bot/overview") + + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json()["detail"], "Autenticacao administrativa obrigatoria.") + + def test_bot_flow_reports_overview_returns_bootstrap_structure_for_colaborador(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.get("/admin/reports/fluxo-bot/overview", headers={"Authorization": "Bearer token"}) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["service"], "orquestrador-admin") + self.assertEqual(payload["area"], "fluxo_bot") + self.assertEqual(payload["source_domain"], "conversation") + self.assertEqual(payload["mode"], "bot_flow_contract_bootstrap") + self.assertEqual(payload["source_dataset_keys"], ["conversation_turns"]) + self.assertEqual(payload["materialization"]["sync_strategy"], "etl_incremental") + self.assertEqual(len(payload["reports"]), 5) + self.assertIn("fallback_and_handoff", [item["report_key"] for item in payload["reports"]]) + self.assertEqual(payload["metrics"][1]["value"], "5") + + def test_bot_flow_reports_catalog_returns_initial_report_definitions(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.get("/admin/reports/fluxo-bot/reports", headers={"Authorization": "Bearer token"}) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["area"], "fluxo_bot") + self.assertEqual(payload["source_domain"], "conversation") + self.assertEqual(payload["source"], "shared_contract_catalog") + self.assertEqual(len(payload["reports"]), 5) + routing = next(item for item in payload["reports"] if item["report_key"] == "action_routing_flow") + self.assertEqual(routing["dataset_key"], "conversation_turns") + self.assertEqual(routing["default_granularity"], "aggregate") + self.assertEqual(routing["materialization_status"], "contract_defined_pending_snapshot_view") + self.assertIn("action", routing["supported_dimension_fields"]) + + def test_bot_flow_report_detail_returns_metrics_filters_and_dataset_contract(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.get( + "/admin/reports/fluxo-bot/reports/operational_failures", + headers={"Authorization": "Bearer token"}, + ) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 200) + payload = response.json()["report"] + self.assertEqual(payload["report_key"], "operational_failures") + self.assertEqual(payload["dataset_key"], "conversation_turns") + self.assertEqual(payload["default_time_field"], "started_at") + self.assertIn("errored_turns", [metric["key"] for metric in payload["metrics"]]) + self.assertIn("action", [item["field_name"] for item in payload["dimensions"]]) + self.assertIn("tool_name", [item["field_name"] for item in payload["filters"]]) + self.assertFalse(payload["dataset"]["write_allowed"]) + self.assertIn("assistant_response", [field["name"] for field in payload["dataset"]["blocked_fields"]]) + + def test_bot_flow_report_detail_returns_404_for_unknown_report(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.get( + "/admin/reports/fluxo-bot/reports/channel_heatmap", + headers={"Authorization": "Bearer token"}, + ) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["detail"], "Relatorio operacional do fluxo do bot nao encontrado.") + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_admin_conversation_telemetry_reports_web.py b/tests/test_admin_conversation_telemetry_reports_web.py new file mode 100644 index 0000000..8d3ed79 --- /dev/null +++ b/tests/test_admin_conversation_telemetry_reports_web.py @@ -0,0 +1,126 @@ +import unittest + +from fastapi.testclient import TestClient + +from admin_app.api.dependencies import get_current_staff_principal +from admin_app.app_factory import create_app +from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal +from shared.contracts import StaffRole + + +class AdminConversationTelemetryReportsWebTests(unittest.TestCase): + def _build_client_with_role( + self, + role: StaffRole, + settings: AdminSettings | None = None, + ) -> tuple[TestClient, object]: + app = create_app( + settings + or AdminSettings( + admin_auth_token_secret="test-secret", + admin_api_prefix="/admin", + ) + ) + app.dependency_overrides[get_current_staff_principal] = lambda: AuthenticatedStaffPrincipal( + id=81, + email="colaborador@empresa.com" if role == StaffRole.COLABORADOR else "diretor@empresa.com", + display_name="Equipe de Telemetria Conversacional", + role=role, + is_active=True, + ) + return TestClient(app), app + + def test_conversation_telemetry_reports_overview_requires_authentication(self): + app = create_app(AdminSettings(admin_auth_token_secret="test-secret", admin_api_prefix="/admin")) + client = TestClient(app) + + response = client.get("/admin/reports/telemetria-conversacional/overview") + + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json()["detail"], "Autenticacao administrativa obrigatoria.") + + def test_conversation_telemetry_reports_overview_returns_bootstrap_structure_for_colaborador(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.get( + "/admin/reports/telemetria-conversacional/overview", + headers={"Authorization": "Bearer token"}, + ) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["service"], "orquestrador-admin") + self.assertEqual(payload["area"], "telemetria_conversacional") + self.assertEqual(payload["source_domain"], "conversation") + self.assertEqual(payload["mode"], "conversation_telemetry_contract_bootstrap") + self.assertEqual(payload["source_dataset_keys"], ["conversation_turns"]) + self.assertEqual(payload["materialization"]["sync_strategy"], "etl_incremental") + self.assertEqual(len(payload["reports"]), 5) + self.assertIn("latency_profile", [item["report_key"] for item in payload["reports"]]) + self.assertEqual(payload["metrics"][1]["value"], "5") + + def test_conversation_telemetry_reports_catalog_returns_initial_report_definitions(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.get( + "/admin/reports/telemetria-conversacional/reports", + headers={"Authorization": "Bearer token"}, + ) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["area"], "telemetria_conversacional") + self.assertEqual(payload["source_domain"], "conversation") + self.assertEqual(payload["source"], "shared_contract_catalog") + self.assertEqual(len(payload["reports"]), 5) + volume = next(item for item in payload["reports"] if item["report_key"] == "conversation_volume") + self.assertEqual(volume["dataset_key"], "conversation_turns") + self.assertEqual(volume["default_granularity"], "aggregate") + self.assertEqual(volume["materialization_status"], "contract_defined_pending_snapshot_view") + self.assertIn("domain", volume["supported_dimension_fields"]) + + def test_conversation_telemetry_report_detail_returns_metrics_filters_and_dataset_contract(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.get( + "/admin/reports/telemetria-conversacional/reports/latency_profile", + headers={"Authorization": "Bearer token"}, + ) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 200) + payload = response.json()["report"] + self.assertEqual(payload["report_key"], "latency_profile") + self.assertEqual(payload["dataset_key"], "conversation_turns") + self.assertEqual(payload["default_time_field"], "completed_at") + self.assertIn("average_latency_ms", [metric["key"] for metric in payload["metrics"]]) + self.assertIn("p95_latency_ms", [metric["key"] for metric in payload["metrics"]]) + self.assertIn("intent", [item["field_name"] for item in payload["dimensions"]]) + self.assertIn("completed_at", [item["field_name"] for item in payload["filters"]]) + self.assertFalse(payload["dataset"]["write_allowed"]) + self.assertIn("assistant_response", [field["name"] for field in payload["dataset"]["blocked_fields"]]) + + def test_conversation_telemetry_report_detail_returns_404_for_unknown_report(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.get( + "/admin/reports/telemetria-conversacional/reports/sentiment_breakdown", + headers={"Authorization": "Bearer token"}, + ) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 404) + self.assertEqual( + response.json()["detail"], + "Relatorio de telemetria conversacional nao encontrado.", + ) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_admin_rental_reports_web.py b/tests/test_admin_rental_reports_web.py new file mode 100644 index 0000000..1802ad4 --- /dev/null +++ b/tests/test_admin_rental_reports_web.py @@ -0,0 +1,117 @@ +import unittest + +from fastapi.testclient import TestClient + +from admin_app.api.dependencies import get_current_staff_principal +from admin_app.app_factory import create_app +from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal +from shared.contracts import StaffRole + + +class AdminRentalReportsWebTests(unittest.TestCase): + def _build_client_with_role( + self, + role: StaffRole, + settings: AdminSettings | None = None, + ) -> tuple[TestClient, object]: + app = create_app( + settings + or AdminSettings( + admin_auth_token_secret="test-secret", + admin_api_prefix="/admin", + ) + ) + app.dependency_overrides[get_current_staff_principal] = lambda: AuthenticatedStaffPrincipal( + id=61, + email="colaborador@empresa.com" if role == StaffRole.COLABORADOR else "diretor@empresa.com", + display_name="Equipe de Locacao", + role=role, + is_active=True, + ) + return TestClient(app), app + + def test_rental_reports_overview_requires_authentication(self): + app = create_app(AdminSettings(admin_auth_token_secret="test-secret", admin_api_prefix="/admin")) + client = TestClient(app) + + response = client.get("/admin/reports/locacao/overview") + + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json()["detail"], "Autenticacao administrativa obrigatoria.") + + def test_rental_reports_overview_returns_bootstrap_structure_for_colaborador(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.get("/admin/reports/locacao/overview", headers={"Authorization": "Bearer token"}) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["service"], "orquestrador-admin") + self.assertEqual(payload["area"], "locacao") + self.assertEqual(payload["source_domain"], "rental") + self.assertEqual(payload["mode"], "rental_contract_bootstrap") + self.assertEqual(payload["source_dataset_keys"], ["rental_fleet", "rental_contracts"]) + self.assertEqual(payload["materialization"]["sync_strategy"], "etl_incremental") + self.assertEqual(len(payload["reports"]), 5) + self.assertIn("fleet_occupancy", [item["report_key"] for item in payload["reports"]]) + self.assertEqual(payload["metrics"][1]["value"], "5") + + def test_rental_reports_catalog_returns_initial_report_definitions(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.get("/admin/reports/locacao/reports", headers={"Authorization": "Bearer token"}) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["area"], "locacao") + self.assertEqual(payload["source_domain"], "rental") + self.assertEqual(payload["source"], "shared_contract_catalog") + self.assertEqual(len(payload["reports"]), 5) + fleet = next(item for item in payload["reports"] if item["report_key"] == "fleet_availability") + self.assertEqual(fleet["dataset_key"], "rental_fleet") + self.assertEqual(fleet["default_granularity"], "aggregate") + self.assertEqual(fleet["materialization_status"], "contract_defined_pending_snapshot_view") + self.assertIn("categoria", fleet["supported_dimension_fields"]) + + def test_rental_report_detail_returns_metrics_filters_and_dataset_contract(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.get( + "/admin/reports/locacao/reports/projected_vs_final_revenue", + headers={"Authorization": "Bearer token"}, + ) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 200) + payload = response.json()["report"] + self.assertEqual(payload["report_key"], "projected_vs_final_revenue") + self.assertEqual(payload["dataset_key"], "rental_contracts") + self.assertEqual(payload["default_time_field"], "updated_at") + self.assertIn("projected_revenue", [metric["key"] for metric in payload["metrics"]]) + self.assertIn("revenue_delta", [metric["key"] for metric in payload["metrics"]]) + self.assertIn("status", [item["field_name"] for item in payload["dimensions"]]) + self.assertIn("contrato_numero", [item["field_name"] for item in payload["filters"]]) + self.assertFalse(payload["dataset"]["write_allowed"]) + self.assertIn("cpf", [field["name"] for field in payload["dataset"]["blocked_fields"]]) + + def test_rental_report_detail_returns_404_for_unknown_report(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.get( + "/admin/reports/locacao/reports/fleet_idle_heatmap", + headers={"Authorization": "Bearer token"}, + ) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["detail"], "Relatorio de locacao nao encontrado.") + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_admin_reports_web.py b/tests/test_admin_reports_web.py new file mode 100644 index 0000000..227ee7d --- /dev/null +++ b/tests/test_admin_reports_web.py @@ -0,0 +1,114 @@ +import unittest + +from fastapi.testclient import TestClient + +from admin_app.api.dependencies import get_current_staff_principal +from admin_app.app_factory import create_app +from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal +from shared.contracts import StaffRole + + +class AdminReportsWebTests(unittest.TestCase): + def _build_client_with_role( + self, + role: StaffRole, + settings: AdminSettings | None = None, + ) -> tuple[TestClient, object]: + app = create_app( + settings + or AdminSettings( + admin_auth_token_secret="test-secret", + admin_api_prefix="/admin", + ) + ) + app.dependency_overrides[get_current_staff_principal] = lambda: AuthenticatedStaffPrincipal( + id=31, + email="colaborador@empresa.com" if role == StaffRole.COLABORADOR else "diretor@empresa.com", + display_name="Equipe de Relatorios", + role=role, + is_active=True, + ) + return TestClient(app), app + + def test_reports_overview_requires_authentication(self): + app = create_app(AdminSettings(admin_auth_token_secret="test-secret", admin_api_prefix="/admin")) + client = TestClient(app) + + response = client.get("/admin/reports/overview") + + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json()["detail"], "Autenticacao administrativa obrigatoria.") + + def test_reports_overview_returns_contract_snapshot_for_colaborador(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.get("/admin/reports/overview", headers={"Authorization": "Bearer token"}) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["service"], "orquestrador-admin") + self.assertEqual(payload["mode"], "shared_contract_bootstrap") + self.assertEqual(payload["materialization"]["sync_strategy"], "etl_incremental") + self.assertEqual(payload["materialization"]["storage_shape"], "snapshot_table") + self.assertEqual(payload["materialization"]["query_surface"], "dedicated_view") + self.assertIn("sales", [item["key"] for item in payload["report_families"]]) + self.assertEqual(payload["metrics"][0]["value"], "8") + + def test_report_datasets_return_catalog_for_colaborador(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.get("/admin/reports/datasets", headers={"Authorization": "Bearer token"}) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["source"], "shared_contract_catalog") + self.assertEqual(len(payload["datasets"]), 8) + self.assertIn("sales_orders", [item["dataset_key"] for item in payload["datasets"]]) + self.assertIn("conversation_turns", [item["dataset_key"] for item in payload["datasets"]]) + sales = next(item for item in payload["datasets"] if item["dataset_key"] == "sales_orders") + self.assertEqual(sales["freshness_target"], "near_real_time") + self.assertEqual(sales["materialization_status"], "contract_defined_pending_snapshot_view") + + def test_report_dataset_detail_returns_allowed_and_blocked_fields(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.get( + "/admin/reports/datasets/sales_orders", + headers={"Authorization": "Bearer token"}, + ) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 200) + payload = response.json()["dataset"] + self.assertEqual(payload["dataset_key"], "sales_orders") + self.assertEqual(payload["domain"], "sales") + self.assertEqual(payload["read_permission"], "view_reports") + self.assertFalse(payload["direct_product_query_allowed"]) + self.assertFalse(payload["write_allowed"]) + self.assertIn("numero_pedido", [field["name"] for field in payload["allowed_fields"]]) + self.assertIn("cpf", [field["name"] for field in payload["blocked_fields"]]) + + def test_report_dataset_detail_returns_404_for_unknown_dataset(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.get( + "/admin/reports/datasets/customers", + headers={"Authorization": "Bearer token"}, + ) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 404) + self.assertEqual( + response.json()["detail"], + "Dataset operacional nao encontrado para relatorio.", + ) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_admin_revenue_reports_web.py b/tests/test_admin_revenue_reports_web.py new file mode 100644 index 0000000..9799589 --- /dev/null +++ b/tests/test_admin_revenue_reports_web.py @@ -0,0 +1,116 @@ +import unittest + +from fastapi.testclient import TestClient + +from admin_app.api.dependencies import get_current_staff_principal +from admin_app.app_factory import create_app +from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal +from shared.contracts import StaffRole + + +class AdminRevenueReportsWebTests(unittest.TestCase): + def _build_client_with_role( + self, + role: StaffRole, + settings: AdminSettings | None = None, + ) -> tuple[TestClient, object]: + app = create_app( + settings + or AdminSettings( + admin_auth_token_secret="test-secret", + admin_api_prefix="/admin", + ) + ) + app.dependency_overrides[get_current_staff_principal] = lambda: AuthenticatedStaffPrincipal( + id=51, + email="colaborador@empresa.com" if role == StaffRole.COLABORADOR else "diretor@empresa.com", + display_name="Equipe Financeira", + role=role, + is_active=True, + ) + return TestClient(app), app + + def test_revenue_reports_overview_requires_authentication(self): + app = create_app(AdminSettings(admin_auth_token_secret="test-secret", admin_api_prefix="/admin")) + client = TestClient(app) + + response = client.get("/admin/reports/arrecadacao/overview") + + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json()["detail"], "Autenticacao administrativa obrigatoria.") + + def test_revenue_reports_overview_returns_bootstrap_structure_for_colaborador(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.get("/admin/reports/arrecadacao/overview", headers={"Authorization": "Bearer token"}) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["service"], "orquestrador-admin") + self.assertEqual(payload["area"], "arrecadacao") + self.assertEqual(payload["source_domain"], "rental") + self.assertEqual(payload["mode"], "revenue_contract_bootstrap") + self.assertEqual(payload["source_dataset_keys"], ["rental_payments"]) + self.assertEqual(payload["materialization"]["sync_strategy"], "etl_incremental") + self.assertEqual(len(payload["reports"]), 3) + self.assertIn("collected_amount", [item["report_key"] for item in payload["reports"]]) + self.assertEqual(payload["metrics"][1]["value"], "3") + + def test_revenue_reports_catalog_returns_initial_report_definitions(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.get("/admin/reports/arrecadacao/reports", headers={"Authorization": "Bearer token"}) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["area"], "arrecadacao") + self.assertEqual(payload["source_domain"], "rental") + self.assertEqual(payload["source"], "shared_contract_catalog") + self.assertEqual(len(payload["reports"]), 3) + collected = next(item for item in payload["reports"] if item["report_key"] == "collected_amount") + self.assertEqual(collected["dataset_key"], "rental_payments") + self.assertEqual(collected["default_granularity"], "aggregate") + self.assertEqual(collected["materialization_status"], "contract_defined_pending_snapshot_view") + self.assertIn("contrato_numero", collected["supported_dimension_fields"]) + + def test_revenue_report_detail_returns_metrics_filters_and_dataset_contract(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.get( + "/admin/reports/arrecadacao/reports/contract_reconciliation", + headers={"Authorization": "Bearer token"}, + ) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 200) + payload = response.json()["report"] + self.assertEqual(payload["report_key"], "contract_reconciliation") + self.assertEqual(payload["dataset_key"], "rental_payments") + self.assertEqual(payload["default_time_field"], "data_pagamento") + self.assertIn("collected_amount", [metric["key"] for metric in payload["metrics"]]) + self.assertIn("contrato_numero", [item["field_name"] for item in payload["dimensions"]]) + self.assertIn("protocolo", [item["field_name"] for item in payload["filters"]]) + self.assertFalse(payload["dataset"]["write_allowed"]) + self.assertIn("identificador_comprovante", [field["name"] for field in payload["dataset"]["blocked_fields"]]) + + def test_revenue_report_detail_returns_404_for_unknown_report(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.get( + "/admin/reports/arrecadacao/reports/cash_flow_projection", + headers={"Authorization": "Bearer token"}, + ) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["detail"], "Relatorio de arrecadacao nao encontrado.") + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_admin_sales_reports_web.py b/tests/test_admin_sales_reports_web.py new file mode 100644 index 0000000..1a667ac --- /dev/null +++ b/tests/test_admin_sales_reports_web.py @@ -0,0 +1,114 @@ +import unittest + +from fastapi.testclient import TestClient + +from admin_app.api.dependencies import get_current_staff_principal +from admin_app.app_factory import create_app +from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal +from shared.contracts import StaffRole + + +class AdminSalesReportsWebTests(unittest.TestCase): + def _build_client_with_role( + self, + role: StaffRole, + settings: AdminSettings | None = None, + ) -> tuple[TestClient, object]: + app = create_app( + settings + or AdminSettings( + admin_auth_token_secret="test-secret", + admin_api_prefix="/admin", + ) + ) + app.dependency_overrides[get_current_staff_principal] = lambda: AuthenticatedStaffPrincipal( + id=41, + email="colaborador@empresa.com" if role == StaffRole.COLABORADOR else "diretor@empresa.com", + display_name="Equipe Comercial", + role=role, + is_active=True, + ) + return TestClient(app), app + + def test_sales_reports_overview_requires_authentication(self): + app = create_app(AdminSettings(admin_auth_token_secret="test-secret", admin_api_prefix="/admin")) + client = TestClient(app) + + response = client.get("/admin/reports/sales/overview") + + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json()["detail"], "Autenticacao administrativa obrigatoria.") + + def test_sales_reports_overview_returns_bootstrap_structure_for_colaborador(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.get("/admin/reports/sales/overview", headers={"Authorization": "Bearer token"}) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["service"], "orquestrador-admin") + self.assertEqual(payload["domain"], "sales") + self.assertEqual(payload["mode"], "sales_contract_bootstrap") + self.assertEqual(payload["source_dataset_keys"], ["sales_orders"]) + self.assertEqual(payload["materialization"]["sync_strategy"], "etl_incremental") + self.assertEqual(len(payload["reports"]), 4) + self.assertIn("average_ticket", [item["report_key"] for item in payload["reports"]]) + self.assertEqual(payload["metrics"][1]["value"], "4") + + def test_sales_reports_catalog_returns_initial_report_definitions(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.get("/admin/reports/sales/reports", headers={"Authorization": "Bearer token"}) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["domain"], "sales") + self.assertEqual(payload["source"], "shared_contract_catalog") + self.assertEqual(len(payload["reports"]), 4) + volume = next(item for item in payload["reports"] if item["report_key"] == "orders_volume") + self.assertEqual(volume["dataset_key"], "sales_orders") + self.assertEqual(volume["default_granularity"], "aggregate") + self.assertEqual(volume["materialization_status"], "contract_defined_pending_snapshot_view") + self.assertIn("status", volume["supported_dimension_fields"]) + + def test_sales_report_detail_returns_metrics_filters_and_dataset_contract(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.get( + "/admin/reports/sales/reports/cancellations_by_period", + headers={"Authorization": "Bearer token"}, + ) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 200) + payload = response.json()["report"] + self.assertEqual(payload["report_key"], "cancellations_by_period") + self.assertEqual(payload["dataset_key"], "sales_orders") + self.assertEqual(payload["default_time_field"], "data_cancelamento") + self.assertIn("cancelled_orders", [metric["key"] for metric in payload["metrics"]]) + self.assertIn("motivo_cancelamento", [item["field_name"] for item in payload["dimensions"]]) + self.assertIn("data_cancelamento", [item["field_name"] for item in payload["filters"]]) + self.assertFalse(payload["dataset"]["write_allowed"]) + self.assertIn("cpf", [field["name"] for field in payload["dataset"]["blocked_fields"]]) + + def test_sales_report_detail_returns_404_for_unknown_report(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.get( + "/admin/reports/sales/reports/orders_by_store", + headers={"Authorization": "Bearer token"}, + ) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["detail"], "Relatorio de vendas nao encontrado.") + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_admin_system_configuration_web.py b/tests/test_admin_system_configuration_web.py index 854b52e..a25514e 100644 --- a/tests/test_admin_system_configuration_web.py +++ b/tests/test_admin_system_configuration_web.py @@ -84,7 +84,11 @@ class AdminSystemConfigurationWebTests(unittest.TestCase): self.assertTrue(payload["security"]["password"]["pepper_configured"]) self.assertTrue(payload["security"]["bootstrap"]["enabled"]) self.assertTrue(payload["security"]["bootstrap"]["password_configured"]) + self.assertEqual(payload["write_governance"]["mode"], "admin_internal_tables_only") + self.assertIn("staff_accounts", payload["write_governance"]["allowed_direct_write_tables"]) + self.assertIn("orders", payload["write_governance"]["blocked_product_source_tables"]) self.assertIn("panel_session", [item["key"] for item in payload["sources"]]) + self.assertIn("admin_write_governance", [item["key"] for item in payload["sources"]]) def test_runtime_configuration_route_exposes_panel_cookie_metadata(self): settings = AdminSettings( @@ -130,6 +134,25 @@ class AdminSystemConfigurationWebTests(unittest.TestCase): self.assertEqual(security["bootstrap"]["role"], "diretor") + def test_write_governance_route_exposes_internal_allowlist_and_product_blocks(self): + settings = AdminSettings( + admin_auth_token_secret="test-secret", + admin_api_prefix="/admin", + ) + client, app = self._build_client_with_role(StaffRole.DIRETOR, settings) + try: + response = client.get("/admin/system/configuration/write-governance", headers={"Authorization": "Bearer token"}) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 200) + payload = response.json()["write_governance"] + self.assertEqual(payload["mode"], "admin_internal_tables_only") + self.assertIn("staff_sessions", payload["allowed_direct_write_tables"]) + self.assertIn("conversation_turns", payload["blocked_product_source_tables"]) + self.assertIn("channel_operation_policy", payload["governed_configuration_keys"]) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_admin_system_functional_configuration_web.py b/tests/test_admin_system_functional_configuration_web.py new file mode 100644 index 0000000..62b8395 --- /dev/null +++ b/tests/test_admin_system_functional_configuration_web.py @@ -0,0 +1,122 @@ +import unittest + +from fastapi.testclient import TestClient + +from admin_app.api.dependencies import get_current_staff_principal +from admin_app.app_factory import create_app +from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal +from shared.contracts import StaffRole + + +class AdminSystemFunctionalConfigurationWebTests(unittest.TestCase): + def _build_client_with_role( + self, + role: StaffRole, + settings: AdminSettings | None = None, + ) -> tuple[TestClient, object]: + app = create_app( + settings + or AdminSettings( + admin_auth_token_secret="test-secret", + admin_api_prefix="/admin", + ) + ) + app.dependency_overrides[get_current_staff_principal] = lambda: AuthenticatedStaffPrincipal( + id=41, + email="colaborador@empresa.com" if role == StaffRole.COLABORADOR else "diretor@empresa.com", + display_name="Equipe de Configuracao", + role=role, + is_active=True, + ) + return TestClient(app), app + + def test_functional_configuration_catalog_requires_authentication(self): + app = create_app(AdminSettings(admin_auth_token_secret="test-secret", admin_api_prefix="/admin")) + client = TestClient(app) + + response = client.get("/admin/system/configuration/functional") + + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json()["detail"], "Autenticacao administrativa obrigatoria.") + + def test_colaborador_can_consult_functional_configuration_catalog(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.get( + "/admin/system/configuration/functional", + headers={"Authorization": "Bearer token"}, + ) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["mode"], "shared_contract_bootstrap") + self.assertEqual(len(payload["configurations"]), 6) + self.assertIn( + "allowed_model_catalog", + [item["config_key"] for item in payload["configurations"]], + ) + self.assertIn( + "published_runtime_state", + [item["config_key"] for item in payload["configurations"]], + ) + self.assertIn("atendimento_runtime_profile", payload["bot_governed_parent_config_keys"]) + self.assertNotIn("tool_generation_runtime_profile", payload["bot_governed_parent_config_keys"]) + + def test_colaborador_can_consult_bot_governed_configuration_route(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.get( + "/admin/system/configuration/functional/bot-governance", + headers={"Authorization": "Bearer token"}, + ) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(len(payload["settings"]), 15) + self.assertIn("bot_behavior_policy", payload["parent_config_keys"]) + self.assertIn("channel_operation_policy", payload["parent_config_keys"]) + self.assertNotIn("tool_generation_runtime_profile", payload["parent_config_keys"]) + + def test_functional_configuration_detail_links_runtime_and_bot_governance(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.get( + "/admin/system/configuration/functional/atendimento_runtime_profile", + headers={"Authorization": "Bearer token"}, + ) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["configuration"]["config_key"], "atendimento_runtime_profile") + self.assertTrue(payload["managed_by_bot_governance"]) + self.assertEqual(payload["related_runtime_profile"]["runtime_target"], "atendimento") + self.assertIn( + "bot_tool_policy_ref", + [item["setting_key"] for item in payload["linked_bot_settings"]], + ) + + def test_functional_configuration_detail_returns_404_for_unknown_key(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.get( + "/admin/system/configuration/functional/segredos_infra", + headers={"Authorization": "Bearer token"}, + ) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 404) + self.assertEqual( + response.json()["detail"], + "Configuracao funcional do sistema nao encontrada.", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_admin_system_model_runtime_configuration_web.py b/tests/test_admin_system_model_runtime_configuration_web.py new file mode 100644 index 0000000..255488e --- /dev/null +++ b/tests/test_admin_system_model_runtime_configuration_web.py @@ -0,0 +1,117 @@ +import unittest + +from fastapi.testclient import TestClient + +from admin_app.api.dependencies import get_current_staff_principal +from admin_app.app_factory import create_app +from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal +from shared.contracts import StaffRole + + +class AdminSystemModelRuntimeConfigurationWebTests(unittest.TestCase): + def _build_client_with_role( + self, + role: StaffRole, + settings: AdminSettings | None = None, + ) -> tuple[TestClient, object]: + app = create_app( + settings + or AdminSettings( + admin_auth_token_secret="test-secret", + admin_api_prefix="/admin", + admin_environment="development", + admin_debug=True, + ) + ) + app.dependency_overrides[get_current_staff_principal] = lambda: AuthenticatedStaffPrincipal( + id=99, + email="diretor@empresa.com" if role == StaffRole.DIRETOR else "colaborador@empresa.com", + display_name="Equipe Interna", + role=role, + is_active=True, + ) + return TestClient(app), app + + def test_model_runtime_route_requires_manage_settings_permission(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.get( + "/admin/system/configuration/model-runtimes", + headers={"Authorization": "Bearer token"}, + ) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 403) + self.assertEqual( + response.json()["detail"], + "Permissao administrativa insuficiente: 'manage_settings'.", + ) + + def test_model_runtime_route_exposes_separated_atendimento_and_generation_profiles(self): + client, app = self._build_client_with_role(StaffRole.DIRETOR) + try: + response = client.get( + "/admin/system/configuration/model-runtimes", + headers={"Authorization": "Bearer token"}, + ) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 200) + payload = response.json()["model_runtimes"] + self.assertEqual(len(payload["runtime_profiles"]), 2) + self.assertEqual( + {profile["config_key"] for profile in payload["runtime_profiles"]}, + {"atendimento_runtime_profile", "tool_generation_runtime_profile"}, + ) + self.assertEqual( + {profile["consumed_by_service"] for profile in payload["runtime_profiles"]}, + {"product", "admin"}, + ) + self.assertIn("no_implicit_propagation", payload["separation_rules"]) + self.assertEqual( + payload["atendimento_runtime_configuration"]["config_key"], + "atendimento_runtime_profile", + ) + self.assertEqual( + payload["tool_generation_runtime_configuration"]["config_key"], + "tool_generation_runtime_profile", + ) + self.assertIn("prompt_profile_ref", [ + field["name"] for field in payload["atendimento_runtime_configuration"]["fields"] + ]) + self.assertIn("reasoning_profile", [ + field["name"] for field in payload["tool_generation_runtime_configuration"]["fields"] + ]) + self.assertIn("atendimento_runtime_profile", payload["bot_governed_parent_config_keys"]) + self.assertNotIn("tool_generation_runtime_profile", payload["bot_governed_parent_config_keys"]) + + def test_configuration_overview_includes_model_runtime_separation_snapshot(self): + client, app = self._build_client_with_role(StaffRole.DIRETOR) + try: + response = client.get( + "/admin/system/configuration", + headers={"Authorization": "Bearer token"}, + ) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual( + payload["model_runtimes"]["atendimento_runtime_configuration"]["config_key"], + "atendimento_runtime_profile", + ) + self.assertEqual( + payload["model_runtimes"]["tool_generation_runtime_configuration"]["config_key"], + "tool_generation_runtime_profile", + ) + self.assertIn( + "model_runtime_separation", + [item["key"] for item in payload["sources"]], + ) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_admin_write_governance.py b/tests/test_admin_write_governance.py new file mode 100644 index 0000000..c3ff34c --- /dev/null +++ b/tests/test_admin_write_governance.py @@ -0,0 +1,60 @@ +import unittest + +from admin_app.db.write_governance import ( + AdminWriteGovernanceViolation, + build_admin_write_governance_payload, + ensure_direct_admin_write_allowed, + enforce_admin_session_write_governance, +) + + +class _FakeTabledObject: + def __init__(self, table_name: str): + self.__tablename__ = table_name + + +class AdminWriteGovernanceTests(unittest.TestCase): + def test_payload_exposes_internal_allowlist_and_governed_targets(self): + payload = build_admin_write_governance_payload() + + self.assertEqual(payload["mode"], "admin_internal_tables_only") + self.assertEqual( + payload["allowed_direct_write_tables"], + ["admin_audit_logs", "staff_accounts", "staff_sessions"], + ) + self.assertIn("sales_orders", payload["blocked_operational_dataset_keys"]) + self.assertIn("orders", payload["blocked_product_source_tables"]) + self.assertIn("conversation_turns", payload["blocked_product_source_tables"]) + self.assertIn("atendimento_runtime_profile", payload["governed_configuration_keys"]) + self.assertIn("bot_behavior_policy", payload["governed_configuration_keys"]) + + def test_internal_admin_tables_are_allowed_for_direct_write(self): + ensure_direct_admin_write_allowed("staff_accounts") + ensure_direct_admin_write_allowed("staff_sessions") + ensure_direct_admin_write_allowed("admin_audit_logs") + + def test_unknown_or_product_tables_raise_governance_violation(self): + with self.assertRaises(AdminWriteGovernanceViolation): + ensure_direct_admin_write_allowed("orders") + + with self.assertRaises(AdminWriteGovernanceViolation): + ensure_direct_admin_write_allowed("conversation_turns") + + def test_session_guard_accepts_only_internal_admin_tables(self): + enforce_admin_session_write_governance( + new=(_FakeTabledObject("staff_accounts"),), + dirty=(_FakeTabledObject("staff_sessions"),), + deleted=(_FakeTabledObject("admin_audit_logs"),), + ) + + def test_session_guard_blocks_direct_operational_write_attempt(self): + with self.assertRaises(AdminWriteGovernanceViolation) as context: + enforce_admin_session_write_governance( + new=(_FakeTabledObject("orders"),), + ) + + self.assertIn("fluxo governado, versionado e auditavel", str(context.exception)) + + +if __name__ == "__main__": + unittest.main()