Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions app/core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,32 @@ class AIReportGenerationError(MandacaError):
def __init__(self, detail: str) -> None:
super().__init__(f"Falha ao gerar relatório IA: {detail}")
self.detail = detail


# ---------------------------------------------------------------------------
# Exceções de Auto Apply (auto_apply_service)
# ---------------------------------------------------------------------------


class FieldNotAllowedError(MandacaError):
def __init__(self, campo: str) -> None:
super().__init__(f"Campo não permitido para alteração automática: {campo}")
self.campo = campo


class InvalidFieldValueError(MandacaError):
def __init__(self, campo: str, detail: str) -> None:
super().__init__(f"Valor inválido para o campo {campo}: {detail}")
self.campo = campo
self.detail = detail


class AutoApplyPersistenceError(MandacaError):
def __init__(self) -> None:
super().__init__("Falha ao aplicar alteração no banco de dados.")


class SuggestionExtractionError(MandacaError):
def __init__(self, detail: str) -> None:
super().__init__(f"Falha ao extrair sugestões do relatório: {detail}")
self.detail = detail
27 changes: 26 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,29 @@
AudioServiceTimeoutError,
AudioTooLargeError,
AudioTranscriptionError,
AutoApplyPersistenceError,
BusinessContextNotFoundError,
ChatRateLimitError,
ChatServiceConnectionError,
ChatServiceError,
ChatServiceTimeoutError,
DuplicateEnterpriseNameError,
EnterpriseNotFoundError,
FieldNotAllowedError,
GeocodingUnavailableError,
InvalidContextDataError,
InvalidFieldValueError,
MandacaError,
MenuNotFoundError,
SuggestionExtractionError,
UnsupportedAudioFormatError,
UserAlreadyHasEnterpriseError,
UserAlreadyLinkedError,
UserNotFoundError,
)
from app.routers import (
assessments,
auto_apply,
business_context,
chat,
enterprises,
Expand All @@ -52,13 +58,19 @@
app.include_router(menus.router)
app.include_router(business_context.router)
app.include_router(reports.router)
app.include_router(auto_apply.router)


# ---------------------------------------------------------------------------
# Handlers de exceções de domínio — conversão domínio → HTTP única e central
# ---------------------------------------------------------------------------

_NOT_FOUND_TYPES = (EnterpriseNotFoundError, UserNotFoundError, AIReportNotFoundError)
_NOT_FOUND_TYPES = (
EnterpriseNotFoundError,
UserNotFoundError,
AIReportNotFoundError,
MenuNotFoundError,
)
_BAD_REQUEST_TYPES = (
DuplicateEnterpriseNameError,
UserAlreadyHasEnterpriseError,
Expand All @@ -70,7 +82,9 @@
ChatServiceConnectionError,
ChatServiceError,
AIReportGenerationError,
SuggestionExtractionError,
)
_INTERNAL_ERROR_TYPES = (AutoApplyPersistenceError,)


async def _handle_400(request: Request, exc: MandacaError) -> JSONResponse:
Expand Down Expand Up @@ -109,6 +123,13 @@ async def _handle_504(request: Request, exc: MandacaError) -> JSONResponse:
return JSONResponse(status_code=504, content={"detail": str(exc)})


async def _handle_500(request: Request, exc: MandacaError) -> JSONResponse:
return JSONResponse(
status_code=500,
content={"detail": "Erro interno do servidor."},
)


def _register_handlers(fastapi_app: FastAPI) -> None:
for exc_class in _NOT_FOUND_TYPES:
fastapi_app.add_exception_handler(exc_class, _handle_404)
Expand All @@ -126,6 +147,10 @@ def _register_handlers(fastapi_app: FastAPI) -> None:
fastapi_app.add_exception_handler(ChatServiceTimeoutError, _handle_504)
fastapi_app.add_exception_handler(BusinessContextNotFoundError, _handle_404)
fastapi_app.add_exception_handler(InvalidContextDataError, _handle_422)
fastapi_app.add_exception_handler(FieldNotAllowedError, _handle_422)
fastapi_app.add_exception_handler(InvalidFieldValueError, _handle_422)
for exc_class in _INTERNAL_ERROR_TYPES:
fastapi_app.add_exception_handler(exc_class, _handle_500)


_register_handlers(app)
Expand Down
25 changes: 25 additions & 0 deletions app/routers/auto_apply.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session

from app.core.session import get_db
from app.schemas.auto_apply import AutoApplyRequest, AutoApplyResponse
from app.services.auto_apply_service import AutoApplyService

router = APIRouter(prefix="/auto-apply", tags=["auto-apply"])


def get_auto_apply_service() -> AutoApplyService:
return AutoApplyService()


@router.post(
"",
response_model=AutoApplyResponse,
status_code=status.HTTP_200_OK,
)
async def auto_apply(
payload: AutoApplyRequest,
db: Session = Depends(get_db),
service: AutoApplyService = Depends(get_auto_apply_service),
) -> AutoApplyResponse:
return service.apply(payload, db)
19 changes: 19 additions & 0 deletions app/routers/reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@

from app.core.session import get_db
from app.models.report import AIReport
from app.schemas.auto_apply import ReportAutoApplyResponse
from app.schemas.reports import AIReportDetail, AIReportSummary
from app.services.report_auto_apply_service import ReportAutoApplyService
from app.services.report_service import ReportService

router = APIRouter(prefix="/reports", tags=["reports"])
Expand All @@ -15,6 +17,10 @@ def get_report_service() -> ReportService:
return ReportService()


def get_report_auto_apply_service() -> ReportAutoApplyService:
return ReportAutoApplyService()


@router.post(
"/generate/{empresa_id}",
response_model=AIReportDetail,
Expand Down Expand Up @@ -44,3 +50,16 @@ async def get_report(
service: ReportService = Depends(get_report_service),
) -> AIReport:
return service.get_by_id(report_id, db)


@router.post(
"/{report_id}/auto-apply",
response_model=ReportAutoApplyResponse,
status_code=status.HTTP_200_OK,
)
async def auto_apply_from_report(
report_id: UUID,
db: Session = Depends(get_db),
service: ReportAutoApplyService = Depends(get_report_auto_apply_service),
) -> ReportAutoApplyResponse:
return service.apply_from_report(report_id, db)
54 changes: 54 additions & 0 deletions app/schemas/auto_apply.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
from enum import Enum
from uuid import UUID

from pydantic import BaseModel, model_validator


class AutoApplyTarget(str, Enum):
ENTERPRISE = "enterprise"
MENU_ITEM = "menu_item"


class AutoApplyRequest(BaseModel):
enterprise_id: UUID
target: AutoApplyTarget
menu_item_id: UUID | None = None
campo_para_alterar: str
novo_valor: str

@model_validator(mode="after")
def _menu_item_id_required_when_menu(self) -> "AutoApplyRequest":
if self.target == AutoApplyTarget.MENU_ITEM and self.menu_item_id is None:
raise ValueError("menu_item_id é obrigatório quando target=menu_item")
return self


class SuggestionStatus(str, Enum):
APPLIED = "aplicado"
REJECTED = "rejeitado"


class AutoApplyResponse(BaseModel):
campo_alterado: str
status: SuggestionStatus = SuggestionStatus.APPLIED


class AutoApplySuggestion(BaseModel):
target: AutoApplyTarget
menu_item_id: UUID | None = None
campo_para_alterar: str
novo_valor: str


class AutoApplySuggestionResult(BaseModel):
sugestao: AutoApplySuggestion
status: SuggestionStatus
erro: str | None = None


class ReportAutoApplyResponse(BaseModel):
report_id: UUID
total: int
aplicadas: int
rejeitadas: int
resultados: list[AutoApplySuggestionResult]
134 changes: 134 additions & 0 deletions app/services/auto_apply_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import re
from datetime import time
from decimal import Decimal, InvalidOperation
from typing import Any
from uuid import UUID

from sqlalchemy import select
from sqlalchemy.orm import Session

from app.core.exceptions import (
AutoApplyPersistenceError,
EnterpriseNotFoundError,
FieldNotAllowedError,
InvalidFieldValueError,
MenuNotFoundError,
)
from app.models.enterprise import Enterprise
from app.models.menu import Menu
from app.schemas.auto_apply import (
AutoApplyRequest,
AutoApplyResponse,
AutoApplyTarget,
SuggestionStatus,
)

# Whitelist: campo lógico (enviado pela IA) -> coluna real no modelo.
# horario_funcionamento é caso especial: gera duas colunas (hora_abrir + hora_fechar).
_ENTERPRISE_FIELD_MAP: dict[str, str | None] = {
"historia": "historia",
"telefone": "telefone",
"endereco": "endereco",
"horario_funcionamento": None,
}

# Inversão intencional: o modelo Menu usa nomes históricos de coluna.
# "descricao" armazena o nome exibido do item; "historia" armazena a descrição longa.
_MENU_FIELD_MAP: dict[str, str] = {
"nome": "descricao",
"descricao": "historia",
"preco": "preco",
}

_HORARIO_PATTERN = re.compile(r"^(\d{2}:\d{2})-(\d{2}:\d{2})$")


class AutoApplyService:
def apply(
self, payload: AutoApplyRequest, db: Session, *, commit: bool = True
) -> AutoApplyResponse:
if payload.target == AutoApplyTarget.ENTERPRISE:
self._apply_to_enterprise(payload, db)
else:
self._apply_to_menu_item(payload, db)

if commit:
self._persist(db)

return AutoApplyResponse(
campo_alterado=payload.campo_para_alterar,
status=SuggestionStatus.APPLIED,
)

def _apply_to_enterprise(self, payload: AutoApplyRequest, db: Session) -> None:
if payload.campo_para_alterar not in _ENTERPRISE_FIELD_MAP:
raise FieldNotAllowedError(payload.campo_para_alterar)

enterprise = self._get_enterprise(payload.enterprise_id, db)

if payload.campo_para_alterar == "horario_funcionamento":
hora_abrir, hora_fechar = self._parse_horario(payload.novo_valor)
enterprise.hora_abrir = hora_abrir
enterprise.hora_fechar = hora_fechar
return

coluna = _ENTERPRISE_FIELD_MAP[payload.campo_para_alterar]
setattr(enterprise, coluna, payload.novo_valor)

def _apply_to_menu_item(self, payload: AutoApplyRequest, db: Session) -> None:
if payload.campo_para_alterar not in _MENU_FIELD_MAP:
raise FieldNotAllowedError(payload.campo_para_alterar)

menu = self._get_menu(payload.menu_item_id, db)
coluna = _MENU_FIELD_MAP[payload.campo_para_alterar]
valor = self._coerce_menu_value(payload.campo_para_alterar, payload.novo_valor)
setattr(menu, coluna, valor)

def _get_enterprise(self, enterprise_id: UUID, db: Session) -> Enterprise:
enterprise = db.get(Enterprise, enterprise_id)
if not enterprise:
raise EnterpriseNotFoundError(enterprise_id)
return enterprise

def _get_menu(self, menu_item_id: UUID | None, db: Session) -> Menu:
menu = db.execute(
select(Menu).where(
Menu.id_cardapio == menu_item_id,
Menu.status.is_(True),
)
).scalar_one_or_none()
if not menu:
raise MenuNotFoundError(menu_item_id)
return menu

def _coerce_menu_value(self, campo_logico: str, novo_valor: str) -> Any:
if campo_logico == "preco":
try:
return Decimal(novo_valor)
except InvalidOperation as exc:
raise InvalidFieldValueError(campo_logico, "preço inválido") from exc
return novo_valor

def _parse_horario(self, valor: str) -> tuple[time, time]:
match = _HORARIO_PATTERN.match(valor)
if not match:
raise InvalidFieldValueError(
"horario_funcionamento",
"formato esperado HH:MM-HH:MM",
)
try:
hora_abrir = time.fromisoformat(match.group(1))
hora_fechar = time.fromisoformat(match.group(2))
except ValueError as exc:
raise InvalidFieldValueError(
"horario_funcionamento",
"horário inválido",
) from exc
return hora_abrir, hora_fechar

def _persist(self, db: Session) -> None:
try:
db.commit()
except Exception as exc:
db.rollback()
raise AutoApplyPersistenceError() from exc
Loading
Loading