diff --git a/app/db/init_db.py b/app/db/init_db.py index 3e7c01b..6da05b4 100644 --- a/app/db/init_db.py +++ b/app/db/init_db.py @@ -7,7 +7,17 @@ from app.core.settings import settings from app.db.database import Base, engine from app.db.mock_database import MockBase, mock_engine from app.db.models import Tool -from app.db.mock_models import ConversationTurn, Customer, Order, ReviewSchedule, Vehicle +from app.db.mock_models import ( + ConversationTurn, + Customer, + Order, + RentalContract, + RentalFine, + RentalPayment, + RentalVehicle, + ReviewSchedule, + Vehicle, +) from app.db.mock_seed import seed_mock_data from app.db.tool_seed import seed_tools diff --git a/app/db/mock_models.py b/app/db/mock_models.py index 9193425..fb7b331 100644 --- a/app/db/mock_models.py +++ b/app/db/mock_models.py @@ -81,6 +81,82 @@ class ReviewSchedule(MockBase): created_at = Column(DateTime, server_default=func.current_timestamp()) +class RentalVehicle(MockBase): + __tablename__ = "rental_vehicles" + + id = Column(Integer, primary_key=True, index=True) + placa = Column(String(10), unique=True, nullable=False, index=True) + modelo = Column(String(120), nullable=False, index=True) + categoria = Column(String(50), nullable=False, index=True) + ano = Column(Integer, nullable=False, index=True) + valor_diaria = Column(Float, nullable=False, index=True) + status = Column(String(20), nullable=False, default="disponivel", index=True) + created_at = Column(DateTime, server_default=func.current_timestamp()) + + +class RentalContract(MockBase): + __tablename__ = "rental_contracts" + + id = Column(Integer, primary_key=True, index=True) + contrato_numero = Column(String(50), unique=True, nullable=False, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) + cpf = Column(String(11), ForeignKey("customers.cpf"), nullable=True, index=True) + rental_vehicle_id = Column(Integer, ForeignKey("rental_vehicles.id"), nullable=False, index=True) + placa = Column(String(10), nullable=False, index=True) + modelo_veiculo = Column(String(120), nullable=False) + categoria = Column(String(50), nullable=False, index=True) + data_inicio = Column(DateTime, nullable=False, index=True) + data_fim_prevista = Column(DateTime, nullable=False, index=True) + data_devolucao = Column(DateTime, nullable=True, index=True) + valor_diaria = Column(Float, nullable=False) + valor_previsto = Column(Float, nullable=False) + valor_final = Column(Float, nullable=True) + status = Column(String(20), nullable=False, default="ativa", index=True) + observacoes = Column(Text, nullable=True) + created_at = Column(DateTime, server_default=func.current_timestamp()) + updated_at = Column( + DateTime, + server_default=func.current_timestamp(), + onupdate=func.current_timestamp(), + ) + + +class RentalPayment(MockBase): + __tablename__ = "rental_payments" + + id = Column(Integer, primary_key=True, index=True) + protocolo = Column(String(50), unique=True, nullable=False, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) + rental_contract_id = Column(Integer, ForeignKey("rental_contracts.id"), nullable=True, index=True) + contrato_numero = Column(String(50), nullable=True, index=True) + placa = Column(String(10), nullable=True, index=True) + valor = Column(Float, nullable=False) + data_pagamento = Column(DateTime, nullable=True, index=True) + favorecido = Column(String(120), nullable=True) + identificador_comprovante = Column(String(120), nullable=True, index=True) + observacoes = Column(Text, nullable=True) + created_at = Column(DateTime, server_default=func.current_timestamp()) + + +class RentalFine(MockBase): + __tablename__ = "rental_fines" + + id = Column(Integer, primary_key=True, index=True) + protocolo = Column(String(50), unique=True, nullable=False, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) + rental_contract_id = Column(Integer, ForeignKey("rental_contracts.id"), nullable=True, index=True) + contrato_numero = Column(String(50), nullable=True, index=True) + placa = Column(String(10), nullable=True, index=True) + auto_infracao = Column(String(60), nullable=True, index=True) + orgao_emissor = Column(String(120), nullable=True) + valor = Column(Float, nullable=False) + data_infracao = Column(DateTime, nullable=True, index=True) + vencimento = Column(DateTime, nullable=True, index=True) + status = Column(String(20), nullable=False, default="registrada", index=True) + observacoes = Column(Text, nullable=True) + created_at = Column(DateTime, server_default=func.current_timestamp()) + + class ConversationTurn(MockBase): __tablename__ = "conversation_turns" diff --git a/app/db/mock_seed.py b/app/db/mock_seed.py index 4133271..b6ebffe 100644 --- a/app/db/mock_seed.py +++ b/app/db/mock_seed.py @@ -3,7 +3,7 @@ from datetime import datetime from app.core.settings import settings from app.db.mock_database import SessionMockLocal -from app.db.mock_models import Customer, Order, Vehicle +from app.db.mock_models import Customer, Order, RentalVehicle, Vehicle VEHICLE_MODELS = [ @@ -59,6 +59,33 @@ VEHICLE_PRICE_BANDS = ( 141990, ) +RENTAL_FLEET = [ + ("RAA1A01", "Chevrolet Tracker", "suv", 2024, 219.90, "disponivel"), + ("RAA1A02", "Fiat Pulse", "suv", 2024, 189.90, "disponivel"), + ("RAA1A03", "Volkswagen Nivus", "suv", 2024, 209.90, "disponivel"), + ("RAA1A04", "Hyundai Creta", "suv", 2024, 239.90, "disponivel"), + ("RAA1A05", "Jeep Renegade", "suv", 2023, 249.90, "disponivel"), + ("RAA1A06", "Honda HR-V", "suv", 2024, 269.90, "disponivel"), + ("RAA1A07", "Toyota Corolla Cross", "suv", 2024, 289.90, "disponivel"), + ("RAA1A08", "Fiat Toro", "pickup", 2024, 319.90, "disponivel"), + ("RAA1A09", "Chevrolet S10", "pickup", 2023, 369.90, "manutencao"), + ("RAA1A10", "Toyota Hilux", "pickup", 2023, 429.90, "disponivel"), + ("RAA1A11", "Ram Rampage", "pickup", 2024, 459.90, "disponivel"), + ("RAA1A12", "Peugeot 208", "hatch", 2024, 149.90, "disponivel"), + ("RAA1A13", "Renault Kwid", "hatch", 2024, 119.90, "disponivel"), + ("RAA1A14", "Volkswagen Polo", "hatch", 2024, 159.90, "disponivel"), + ("RAA1A15", "BYD Dolphin", "hatch", 2024, 279.90, "disponivel"), + ("RAA1A16", "Toyota Yaris", "sedan", 2023, 179.90, "disponivel"), + ("RAA1A17", "Honda City", "sedan", 2024, 199.90, "disponivel"), + ("RAA1A18", "Nissan Versa", "sedan", 2024, 189.90, "disponivel"), + ("RAA1A19", "Chevrolet Onix Plus", "sedan", 2024, 169.90, "alugado"), + ("RAA1A20", "Fiat Fastback", "suv", 2024, 229.90, "disponivel"), + ("RAA1A21", "Caoa Chery Tiggo 5X", "suv", 2024, 219.90, "disponivel"), + ("RAA1A22", "Hyundai HB20S", "sedan", 2024, 164.90, "disponivel"), + ("RAA1A23", "Jeep Compass", "suv", 2023, 309.90, "alugado"), + ("RAA1A24", "Ford Maverick", "pickup", 2024, 399.90, "disponivel"), +] + def _cpf_check_digit(base_digits: str) -> str: total = sum(int(digit) * weight for digit, weight in zip(base_digits, range(len(base_digits) + 1, 1, -1))) @@ -145,6 +172,23 @@ def seed_mock_data() -> None: db.add_all(vehicles) db.commit() + existing_rental_plates = {placa for (placa,) in db.query(RentalVehicle.placa).all()} + missing_rental_vehicles = [ + RentalVehicle( + placa=plate, + modelo=model, + categoria=category, + ano=year, + valor_diaria=daily_rate, + status=status, + ) + for plate, model, category, year, daily_rate, status in RENTAL_FLEET + if plate not in existing_rental_plates + ] + if missing_rental_vehicles: + db.add_all(missing_rental_vehicles) + db.commit() + customer_count = db.query(Customer).count() if customer_count < TARGET_CUSTOMER_COUNT: customers = _seed_customer_records( @@ -164,4 +208,3 @@ def seed_mock_data() -> None: db.commit() finally: db.close() - diff --git a/app/db/tool_seed.py b/app/db/tool_seed.py index c447618..d289d19 100644 --- a/app/db/tool_seed.py +++ b/app/db/tool_seed.py @@ -285,6 +285,207 @@ def get_tools_definitions(): "required": ["numero_pedido", "motivo"], }, }, + { + "name": "consultar_frota_aluguel", + "description": ( + "Use esta ferramenta quando o cliente quiser consultar carros disponiveis para locacao. " + "Ela retorna a frota de aluguel separada da frota de venda e permite filtrar por " + "categoria, modelo, valor maximo da diaria e status." + ), + "parameters": { + "type": "object", + "properties": { + "categoria": { + "type": "string", + "description": "Categoria do veiculo para locacao, por exemplo hatch, sedan, suv ou pickup.", + }, + "modelo": { + "type": "string", + "description": "Trecho do nome do modelo desejado para locacao. Opcional.", + }, + "valor_diaria_max": { + "type": "number", + "description": "Valor maximo da diaria em reais para filtrar a frota.", + }, + "status": { + "type": "string", + "description": "Status da frota para filtrar. Por padrao, consulte veiculos disponiveis.", + }, + "ordenar_diaria": { + "type": "string", + "description": "Ordenacao por diaria. Use 'asc' para menor diaria e 'desc' para maior diaria.", + }, + "limite": { + "type": "integer", + "description": "Quantidade maxima de veiculos retornados. Opcional.", + }, + }, + "required": [], + }, + }, + { + "name": "abrir_locacao_aluguel", + "description": ( + "Use esta ferramenta quando o cliente quiser iniciar uma locacao de carro. " + "Ela recebe a placa ou o identificador do veiculo da frota de aluguel, data inicial, " + "data final prevista e opcionalmente CPF do cliente. Quando a locacao e aberta, o sistema " + "gera um contrato e marca o veiculo como alugado." + ), + "parameters": { + "type": "object", + "properties": { + "rental_vehicle_id": { + "type": "integer", + "description": "Identificador do veiculo na frota de aluguel. Opcional se a placa for informada.", + }, + "placa": { + "type": "string", + "description": "Placa do veiculo da frota de aluguel. Opcional se o identificador for informado.", + }, + "data_inicio": { + "type": "string", + "description": "Data e hora de inicio da locacao. Aceita formatos como 17/03/2026 e 17/03/2026 10:00.", + }, + "data_fim_prevista": { + "type": "string", + "description": "Data e hora previstas para devolucao da locacao.", + }, + "cpf": { + "type": "string", + "description": "CPF do cliente, com ou sem formatacao. Opcional.", + }, + "nome_cliente": { + "type": "string", + "description": "Nome do cliente para referencia textual. Opcional.", + }, + "observacoes": { + "type": "string", + "description": "Observacoes adicionais sobre a locacao. Opcional.", + }, + }, + "required": ["data_inicio", "data_fim_prevista"], + }, + }, + { + "name": "registrar_devolucao_aluguel", + "description": ( + "Use esta ferramenta quando o cliente informar a devolucao de um carro alugado. " + "Ela identifica a locacao pelo numero do contrato ou pela placa, fecha o contrato e " + "deixa o veiculo disponivel novamente para a frota de aluguel." + ), + "parameters": { + "type": "object", + "properties": { + "contrato_numero": { + "type": "string", + "description": "Numero do contrato de locacao. Opcional se a placa for informada.", + }, + "placa": { + "type": "string", + "description": "Placa do veiculo alugado. Opcional se o contrato for informado.", + }, + "data_devolucao": { + "type": "string", + "description": "Data e hora da devolucao. Opcional; se nao vier, o sistema usa o horario atual.", + }, + "observacoes": { + "type": "string", + "description": "Observacoes adicionais sobre a devolucao. Opcional.", + }, + }, + "required": [], + }, + }, + { + "name": "registrar_pagamento_aluguel", + "description": ( + "Use esta ferramenta quando o usuario enviar um comprovante de pagamento " + "de aluguel ou pedir para registrar que o aluguel foi pago. Ela registra " + "o pagamento usando placa ou numero do contrato, valor, data do pagamento " + "e dados auxiliares do comprovante." + ), + "parameters": { + "type": "object", + "properties": { + "contrato_numero": { + "type": "string", + "description": "Numero do contrato de aluguel, quando disponivel.", + }, + "placa": { + "type": "string", + "description": "Placa do veiculo alugado, quando disponivel.", + }, + "valor": { + "type": "number", + "description": "Valor pago no comprovante de aluguel.", + }, + "data_pagamento": { + "type": "string", + "description": "Data do pagamento. Aceita formatos como 17/03/2026 e 2026-03-17 14:30.", + }, + "favorecido": { + "type": "string", + "description": "Nome do recebedor ou favorecido, quando aparecer no comprovante.", + }, + "identificador_comprovante": { + "type": "string", + "description": "Codigo, autenticacao, NSU ou identificador do comprovante.", + }, + "observacoes": { + "type": "string", + "description": "Resumo livre do que foi identificado na imagem.", + }, + }, + "required": ["valor"], + }, + }, + { + "name": "registrar_multa_aluguel", + "description": ( + "Use esta ferramenta quando o usuario enviar uma multa de transito de um " + "carro alugado ou pedir para registrar a multa no sistema. Ela registra " + "os dados da autuacao como placa, contrato, auto de infracao, orgao emissor, " + "valor, data da infracao e vencimento." + ), + "parameters": { + "type": "object", + "properties": { + "placa": { + "type": "string", + "description": "Placa do veiculo relacionado a multa, quando disponivel.", + }, + "contrato_numero": { + "type": "string", + "description": "Numero do contrato de aluguel, quando aparecer no documento.", + }, + "auto_infracao": { + "type": "string", + "description": "Numero do auto de infracao ou identificador da multa.", + }, + "orgao_emissor": { + "type": "string", + "description": "Orgao emissor da multa, por exemplo DETRAN ou prefeitura.", + }, + "valor": { + "type": "number", + "description": "Valor da multa.", + }, + "data_infracao": { + "type": "string", + "description": "Data da infracao. Aceita formatos como 17/03/2026 e 2026-03-17.", + }, + "vencimento": { + "type": "string", + "description": "Data de vencimento da multa. Aceita formatos como 17/03/2026 e 2026-03-17.", + }, + "observacoes": { + "type": "string", + "description": "Resumo livre do que foi identificado no documento.", + }, + }, + "required": ["valor"], + }, + }, { "name": "limpar_contexto_conversa", "description": ( diff --git a/app/integrations/telegram_satellite_service.py b/app/integrations/telegram_satellite_service.py index 532e674..bb40d7d 100644 --- a/app/integrations/telegram_satellite_service.py +++ b/app/integrations/telegram_satellite_service.py @@ -123,6 +123,7 @@ class TelegramSatelliteService: def __init__(self, token: str): """Configura cliente Telegram com URL base e timeouts padrao.""" self.base_url = f"https://api.telegram.org/bot{token}" + self.file_base_url = f"https://api.telegram.org/file/bot{token}" self.polling_timeout = settings.telegram_polling_timeout self.request_timeout = settings.telegram_request_timeout self._last_update_id = -1 @@ -212,16 +213,23 @@ class TelegramSatelliteService: ) -> None: """Processa uma atualizacao recebida e envia resposta ao chat.""" message = update.get("message", {}) - text = message.get("text") + text = message.get("text") or message.get("caption") chat = message.get("chat", {}) chat_id = chat.get("id") sender = message.get("from", {}) - if not text or not chat_id: + image_attachments = await self._extract_image_attachments(session=session, message=message) + + if (not text and not image_attachments) or not chat_id: return try: - answer = await self._process_message(text=text, sender=sender, chat_id=chat_id) + answer = await self._process_message( + text=text or "", + sender=sender, + chat_id=chat_id, + image_attachments=image_attachments, + ) except HTTPException as exc: logger.warning("Falha de dominio ao processar mensagem no Telegram: %s", exc.detail) answer = str(exc.detail) if exc.detail else "Nao foi possivel concluir a operacao solicitada." @@ -248,7 +256,13 @@ class TelegramSatelliteService: if not data.get("ok"): logger.warning("Falha em sendMessage: %s", data) - async def _process_message(self, text: str, sender: Dict[str, Any], chat_id: int) -> str: + async def _process_message( + self, + text: str, + sender: Dict[str, Any], + chat_id: int, + image_attachments: List[Dict[str, Any]] | None = None, + ) -> str: """Encaminha mensagem ao orquestrador com usuario identificado e retorna resposta.""" tools_db = SessionLocal() mock_db = SessionMockLocal() @@ -267,13 +281,125 @@ class TelegramSatelliteService: username=username, ) + message_text = text + if image_attachments: + image_message = await self._build_orchestration_message_from_image( + caption=text, + image_attachments=image_attachments, + ) + if self._is_image_analysis_failure_message(image_message): + return image_message + message_text = image_message + service = OrquestradorService(tools_db) - return await service.handle_message(message=text, user_id=user.id) + return await service.handle_message(message=message_text, user_id=user.id) finally: tools_db.close() mock_db.close() + def _is_supported_image_document(self, document: Dict[str, Any]) -> bool: + mime_type = str((document or {}).get("mime_type") or "").strip().lower() + return mime_type.startswith("image/") + + def _is_image_analysis_failure_message(self, text: str) -> bool: + normalized = str(text or "").strip().lower() + return normalized.startswith("nao consegui identificar os dados da imagem") + + async def _extract_image_attachments( + self, + session: aiohttp.ClientSession, + message: Dict[str, Any], + ) -> List[Dict[str, Any]]: + attachments: List[Dict[str, Any]] = [] + + photos = message.get("photo") or [] + if isinstance(photos, list) and photos: + best_photo = max( + (item for item in photos if isinstance(item, dict) and item.get("file_id")), + key=lambda item: int(item.get("file_size") or 0), + default=None, + ) + if best_photo is not None: + attachment = await self._download_image_attachment( + session=session, + file_id=str(best_photo.get("file_id")), + mime_type="image/jpeg", + file_name="telegram_photo.jpg", + ) + if attachment: + attachments.append(attachment) + + document = message.get("document") or {} + if isinstance(document, dict) and document.get("file_id") and self._is_supported_image_document(document): + attachment = await self._download_image_attachment( + session=session, + file_id=str(document.get("file_id")), + mime_type=str(document.get("mime_type") or "image/jpeg"), + file_name=str(document.get("file_name") or "telegram_image"), + ) + if attachment: + attachments.append(attachment) + + return attachments + + async def _download_image_attachment( + self, + session: aiohttp.ClientSession, + file_id: str, + mime_type: str, + file_name: str, + ) -> Dict[str, Any] | None: + async with session.post(f"{self.base_url}/getFile", json={"file_id": file_id}) as response: + data = await response.json() + if not data.get("ok"): + logger.warning("Falha em getFile para imagem do Telegram: %s", data) + return None + + file_path = str((data.get("result") or {}).get("file_path") or "").strip() + if not file_path: + return None + + async with session.get(f"{self.file_base_url}/{file_path}") as file_response: + if file_response.status >= 400: + logger.warning("Falha ao baixar arquivo do Telegram: status=%s path=%s", file_response.status, file_path) + return None + payload = await file_response.read() + + if not payload: + return None + return { + "mime_type": mime_type, + "file_name": file_name, + "data": payload, + } + + async def _build_orchestration_message_from_image( + self, + *, + caption: str, + image_attachments: List[Dict[str, Any]], + ) -> str: + extracted_message = await LLMService().extract_image_workflow_message( + caption=caption, + attachments=image_attachments, + ) + caption_text = str(caption or "").strip() + extracted_text = str(extracted_message or "").strip() + if self._is_image_analysis_failure_message(extracted_text): + return extracted_text + if caption_text and extracted_text: + return ( + "[imagem recebida no telegram]\n" + f"Legenda do usuario: {caption_text}\n" + f"Dados extraidos da imagem: {extracted_text}" + ) + if extracted_text: + return f"[imagem recebida no telegram]\nDados extraidos da imagem: {extracted_text}" + if caption_text: + return caption_text + return "Recebi uma imagem, mas preciso que voce descreva o documento ou envie uma foto mais nitida." + async def main() -> None: """Inicializa servico satelite do Telegram e inicia processamento continuo.""" token = settings.telegram_bot_token diff --git a/app/services/ai/llm_service.py b/app/services/ai/llm_service.py index 7d88e01..e658306 100644 --- a/app/services/ai/llm_service.py +++ b/app/services/ai/llm_service.py @@ -4,7 +4,7 @@ from typing import Dict, Any, List, Optional import vertexai from google.api_core.exceptions import NotFound -from vertexai.generative_models import FunctionDeclaration, GenerativeModel, Tool +from vertexai.generative_models import FunctionDeclaration, GenerativeModel, Part, Tool from app.core.settings import settings from app.models.tool_model import ToolDefinition @@ -30,6 +30,62 @@ class LLMService: fallback_models = ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.0-flash-001"] self.model_names = [configured] + [m for m in fallback_models if m != configured] + async def extract_image_workflow_message( + self, + *, + caption: str | None, + attachments: List[Dict[str, Any]], + ) -> str: + """Analisa imagem(ns) e devolve uma mensagem textual pronta para o orquestrador.""" + if not attachments: + return str(caption or "").strip() + + prompt = ( + "Voce esta preparando uma mensagem textual curta para um orquestrador de atendimento automotivo e locacao. " + "Analise a imagem enviada pelo usuario e a legenda opcional. " + "Se for comprovante de pagamento de aluguel, responda com uma frase objetiva em portugues no formato: " + "Registrar pagamento de aluguel: contrato <...>; placa <...>; valor <...>; data_pagamento <...>; favorecido <...>; identificador_comprovante <...>; observacoes <...>. " + "Se for multa de transito relacionada a carro alugado, responda com uma frase objetiva em portugues no formato: " + "Registrar multa de aluguel: placa <...>; contrato <...>; auto_infracao <...>; orgao_emissor <...>; valor <...>; data_infracao <...>; vencimento <...>; observacoes <...>. " + "Se for outro documento automotivo util, resuma em uma frase com os dados importantes. " + "Se nao conseguir identificar com seguranca, responda exatamente: Nao consegui identificar os dados da imagem. Descreva o documento ou envie uma foto mais nitida. " + "Use apenas dados observaveis e nao invente informacoes. " + f"Legenda do usuario: {(caption or '').strip() or 'sem legenda'}" + ) + + contents: List[Any] = [prompt] + for attachment in attachments: + raw_data = attachment.get("data") + mime_type = str(attachment.get("mime_type") or "image/jpeg").strip() or "image/jpeg" + if not isinstance(raw_data, (bytes, bytearray)) or not raw_data: + continue + contents.append(Part.from_data(data=bytes(raw_data), mime_type=mime_type)) + + if len(contents) == 1: + return "Nao consegui identificar os dados da imagem. Descreva o documento ou envie uma foto mais nitida." + + response = None + last_error = None + for model_name in self.model_names: + try: + model = self._get_model(model_name) + response = await asyncio.to_thread(model.generate_content, contents) + break + except NotFound as err: + last_error = err + LLMService._models.pop(model_name, None) + continue + + if response is None: + if last_error: + raise RuntimeError( + f"Nenhum modelo Vertex disponivel para analise de imagem. Erro: {last_error}" + ) from last_error + raise RuntimeError("Falha ao analisar imagem no Vertex AI.") + + payload = self._extract_response_payload(response) + return (payload.get("response") or "").strip() or (caption or "").strip() + def build_vertex_tools(self, tools: List[ToolDefinition]) -> Optional[List[Tool]]: """Converte tools internas para o formato esperado pelo Vertex AI.""" # Vertex espera uma lista de Tool, com function_declarations agrupadas em um unico Tool. diff --git a/app/services/domain/rental_service.py b/app/services/domain/rental_service.py new file mode 100644 index 0000000..a67e885 --- /dev/null +++ b/app/services/domain/rental_service.py @@ -0,0 +1,562 @@ +import math +import re +from datetime import datetime +from typing import Any +from uuid import uuid4 + +from app.core.time_utils import utc_now +from app.db.mock_database import SessionMockLocal +from app.db.mock_models import RentalContract, RentalFine, RentalPayment, RentalVehicle, User +from app.services.domain.tool_errors import raise_tool_http_error +from app.services.orchestration import technical_normalizer +from app.services.user.mock_customer_service import hydrate_mock_customer_from_cpf + + +def _normalize_contract_number(value: str | None) -> str | None: + text = str(value or "").strip().upper() + return text or None + + +def _normalize_text_field(value: str | None) -> str | None: + text = str(value or "").strip(" ,.;") + return text or None + + +def _parse_optional_datetime(value: str | None, *, field_name: str) -> datetime | None: + text = str(value or "").strip() + if not text: + return None + + normalized = re.sub(r"\s+(?:as|às)\s+", " ", text, flags=re.IGNORECASE) + for candidate in (text, normalized): + try: + return datetime.fromisoformat(candidate.replace("Z", "+00:00")) + except ValueError: + pass + + for fmt in ( + "%d/%m/%Y %H:%M", + "%d/%m/%Y", + "%Y-%m-%d %H:%M", + "%Y-%m-%d", + ): + try: + return datetime.strptime(normalized, fmt) + except ValueError: + continue + + raise_tool_http_error( + status_code=400, + code="invalid_rental_datetime", + message=( + f"{field_name} invalido. Exemplos aceitos: " + "17/03/2026, 17/03/2026 14:30, 2026-03-17, 2026-03-17 14:30." + ), + retryable=True, + field=field_name, + ) + return None + + +def _parse_required_datetime(value: str | None, *, field_name: str) -> datetime: + parsed = _parse_optional_datetime(value, field_name=field_name) + if parsed is None: + raise_tool_http_error( + status_code=400, + code="missing_rental_datetime", + message=f"Informe {field_name} para continuar a locacao.", + retryable=True, + field=field_name, + ) + return parsed + + +def _normalize_money(value) -> float: + number = technical_normalizer.normalize_positive_number(value) + if number is None or float(number) <= 0: + raise_tool_http_error( + status_code=400, + code="invalid_amount", + message="Informe um valor monetario valido maior que zero.", + retryable=True, + field="valor", + ) + return float(number) + + +def _normalize_vehicle_id(value) -> int | None: + if value is None or value == "": + return None + try: + numeric = int(value) + except (TypeError, ValueError): + raise_tool_http_error( + status_code=400, + code="invalid_rental_vehicle_id", + message="Informe um identificador numerico de veiculo para locacao.", + retryable=True, + field="rental_vehicle_id", + ) + if numeric <= 0: + raise_tool_http_error( + status_code=400, + code="invalid_rental_vehicle_id", + message="Informe um identificador numerico de veiculo para locacao.", + retryable=True, + field="rental_vehicle_id", + ) + return numeric + + +def _calculate_rental_days(start: datetime, end: datetime) -> int: + delta_seconds = (end - start).total_seconds() + if delta_seconds < 0: + raise_tool_http_error( + status_code=400, + code="invalid_rental_period", + message="A data final da locacao nao pode ser anterior a data inicial.", + retryable=True, + field="data_fim_prevista", + ) + if delta_seconds == 0: + return 1 + return max(1, math.ceil(delta_seconds / 86400)) + + +def _lookup_rental_vehicle( + db, + *, + rental_vehicle_id: int | None = None, + placa: str | None = None, +) -> RentalVehicle | None: + if rental_vehicle_id is not None: + return db.query(RentalVehicle).filter(RentalVehicle.id == rental_vehicle_id).first() + + normalized_plate = technical_normalizer.normalize_plate(placa) + if normalized_plate: + return db.query(RentalVehicle).filter(RentalVehicle.placa == normalized_plate).first() + + raise_tool_http_error( + status_code=400, + code="missing_rental_vehicle_reference", + message="Informe a placa ou o identificador do veiculo de locacao.", + retryable=True, + field="placa", + ) + return None + + +def _lookup_contract_by_user_preference(query, user_id: int | None): + if user_id is None: + return query.order_by(RentalContract.created_at.desc()).first() + + own_contract = query.filter(RentalContract.user_id == user_id).order_by(RentalContract.created_at.desc()).first() + if own_contract is not None: + return own_contract + + return query.filter(RentalContract.user_id.is_(None)).order_by(RentalContract.created_at.desc()).first() + + +def _resolve_rental_contract( + db, + *, + contrato_numero: str | None = None, + placa: str | None = None, + user_id: int | None = None, + active_only: bool = False, +) -> RentalContract | None: + normalized_contract = _normalize_contract_number(contrato_numero) + normalized_plate = technical_normalizer.normalize_plate(placa) + + if normalized_contract: + query = db.query(RentalContract).filter(RentalContract.contrato_numero == normalized_contract) + if active_only: + query = query.filter(RentalContract.status == "ativa") + contract = _lookup_contract_by_user_preference(query, user_id) + if contract is not None: + return contract + + if normalized_plate: + query = db.query(RentalContract).filter(RentalContract.placa == normalized_plate) + if active_only: + query = query.filter(RentalContract.status == "ativa") + contract = _lookup_contract_by_user_preference(query, user_id) + if contract is not None: + return contract + + if user_id is not None: + query = db.query(RentalContract).filter(RentalContract.user_id == user_id) + if active_only: + query = query.filter(RentalContract.status == "ativa") + contracts = query.order_by(RentalContract.created_at.desc()).limit(2).all() + if len(contracts) == 1: + return contracts[0] + + return None + + +async def consultar_frota_aluguel( + categoria: str | None = None, + valor_diaria_max: float | None = None, + modelo: str | None = None, + status: str | None = None, + ordenar_diaria: str | None = None, + limite: int | None = None, +) -> list[dict[str, Any]]: + db = SessionMockLocal() + try: + query = db.query(RentalVehicle) + + normalized_status = str(status or "").strip().lower() or "disponivel" + query = query.filter(RentalVehicle.status == normalized_status) + + if categoria: + query = query.filter(RentalVehicle.categoria == str(categoria).strip().lower()) + + if valor_diaria_max is not None: + max_value = technical_normalizer.normalize_positive_number(valor_diaria_max) + if max_value is not None: + query = query.filter(RentalVehicle.valor_diaria <= float(max_value)) + + if modelo: + query = query.filter(RentalVehicle.modelo.ilike(f"%{str(modelo).strip()}%")) + + if ordenar_diaria in {"asc", "desc"}: + query = query.order_by( + RentalVehicle.valor_diaria.asc() + if ordenar_diaria == "asc" + else RentalVehicle.valor_diaria.desc() + ) + else: + query = query.order_by(RentalVehicle.valor_diaria.asc(), RentalVehicle.modelo.asc()) + + if limite is not None: + try: + query = query.limit(max(1, min(int(limite), 50))) + except (TypeError, ValueError): + pass + + rows = query.all() + return [ + { + "id": row.id, + "placa": row.placa, + "modelo": row.modelo, + "categoria": row.categoria, + "ano": int(row.ano), + "valor_diaria": float(row.valor_diaria), + "status": row.status, + } + for row in rows + ] + finally: + db.close() + + +async def abrir_locacao_aluguel( + data_inicio: str, + data_fim_prevista: str, + rental_vehicle_id: int | None = None, + placa: str | None = None, + cpf: str | None = None, + nome_cliente: str | None = None, + observacoes: str | None = None, + user_id: int | None = None, +) -> dict[str, Any]: + vehicle_id = _normalize_vehicle_id(rental_vehicle_id) + data_inicio_dt = _parse_required_datetime(data_inicio, field_name="data_inicio") + data_fim_dt = _parse_required_datetime(data_fim_prevista, field_name="data_fim_prevista") + diarias = _calculate_rental_days(data_inicio_dt, data_fim_dt) + + cpf_norm = technical_normalizer.normalize_cpf(cpf) + if cpf and not cpf_norm: + raise_tool_http_error( + status_code=400, + code="invalid_cpf", + message="Informe um CPF valido para a locacao ou remova esse campo.", + retryable=True, + field="cpf", + ) + + if cpf_norm: + await hydrate_mock_customer_from_cpf(cpf=cpf_norm, user_id=user_id) + + db = SessionMockLocal() + try: + vehicle = _lookup_rental_vehicle(db, rental_vehicle_id=vehicle_id, placa=placa) + if vehicle is None: + raise_tool_http_error( + status_code=404, + code="rental_vehicle_not_found", + message="Veiculo de aluguel nao encontrado.", + retryable=True, + field="placa", + ) + + if vehicle.status != "disponivel": + raise_tool_http_error( + status_code=409, + code="rental_vehicle_unavailable", + message=( + f"O veiculo {vehicle.placa} nao esta disponivel para locacao no momento. " + f"Status atual: {vehicle.status}." + ), + retryable=True, + field="placa", + ) + + contract_number = f"LOC-{utc_now().strftime('%Y%m%d')}-{uuid4().hex[:8].upper()}" + total_preview = round(float(vehicle.valor_diaria) * diarias, 2) + + contract = RentalContract( + contrato_numero=contract_number, + user_id=user_id, + cpf=cpf_norm, + rental_vehicle_id=vehicle.id, + placa=vehicle.placa, + modelo_veiculo=vehicle.modelo, + categoria=vehicle.categoria, + data_inicio=data_inicio_dt, + data_fim_prevista=data_fim_dt, + valor_diaria=float(vehicle.valor_diaria), + valor_previsto=total_preview, + status="ativa", + observacoes=_normalize_text_field(observacoes), + ) + + if user_id is not None and cpf_norm: + user = db.query(User).filter(User.id == user_id).first() + if user and user.cpf != cpf_norm: + user.cpf = cpf_norm + + vehicle.status = "alugado" + db.add(contract) + db.commit() + db.refresh(contract) + db.refresh(vehicle) + + return { + "contrato_numero": contract.contrato_numero, + "placa": contract.placa, + "modelo_veiculo": contract.modelo_veiculo, + "categoria": contract.categoria, + "data_inicio": contract.data_inicio.isoformat(), + "data_fim_prevista": contract.data_fim_prevista.isoformat(), + "valor_diaria": float(contract.valor_diaria), + "valor_previsto": float(contract.valor_previsto), + "status": contract.status, + "status_veiculo": vehicle.status, + "cpf": contract.cpf, + "nome_cliente": _normalize_text_field(nome_cliente), + } + finally: + db.close() + + +async def registrar_devolucao_aluguel( + contrato_numero: str | None = None, + placa: str | None = None, + data_devolucao: str | None = None, + observacoes: str | None = None, + user_id: int | None = None, +) -> dict[str, Any]: + db = SessionMockLocal() + try: + contract = _resolve_rental_contract( + db, + contrato_numero=contrato_numero, + placa=placa, + user_id=user_id, + active_only=True, + ) + if contract is None: + raise_tool_http_error( + status_code=404, + code="rental_contract_not_found", + message="Nao encontrei uma locacao ativa com os dados informados.", + retryable=True, + field="contrato_numero", + ) + + returned_at = _parse_optional_datetime(data_devolucao, field_name="data_devolucao") or utc_now() + if returned_at < contract.data_inicio: + raise_tool_http_error( + status_code=400, + code="invalid_return_datetime", + message="A data de devolucao nao pode ser anterior ao inicio da locacao.", + retryable=True, + field="data_devolucao", + ) + + rental_days = _calculate_rental_days(contract.data_inicio, returned_at) + contract.data_devolucao = returned_at + contract.valor_final = round(float(contract.valor_diaria) * rental_days, 2) + contract.status = "encerrada" + contract.observacoes = _normalize_text_field(observacoes) or contract.observacoes + + vehicle = db.query(RentalVehicle).filter(RentalVehicle.id == contract.rental_vehicle_id).first() + if vehicle is not None: + vehicle.status = "disponivel" + + db.commit() + db.refresh(contract) + if vehicle is not None: + db.refresh(vehicle) + + return { + "contrato_numero": contract.contrato_numero, + "placa": contract.placa, + "modelo_veiculo": contract.modelo_veiculo, + "data_devolucao": contract.data_devolucao.isoformat() if contract.data_devolucao else None, + "valor_previsto": float(contract.valor_previsto), + "valor_final": float(contract.valor_final) if contract.valor_final is not None else None, + "status": contract.status, + "status_veiculo": vehicle.status if vehicle is not None else None, + } + finally: + db.close() + + +async def registrar_pagamento_aluguel( + valor: float, + contrato_numero: str | None = None, + placa: str | None = None, + data_pagamento: str | None = None, + favorecido: str | None = None, + identificador_comprovante: str | None = None, + observacoes: str | None = None, + user_id: int | None = None, +) -> dict: + contract = None + normalized_contract = _normalize_contract_number(contrato_numero) + plate = technical_normalizer.normalize_plate(placa) + + db = SessionMockLocal() + try: + contract = _resolve_rental_contract( + db, + contrato_numero=normalized_contract, + placa=plate, + user_id=user_id, + active_only=False, + ) + + if contract is not None: + normalized_contract = contract.contrato_numero + plate = contract.placa + + if not normalized_contract and not plate: + raise_tool_http_error( + status_code=400, + code="missing_rental_reference", + message=( + "Preciso da placa, do numero do contrato ou de uma locacao ativa vinculada ao usuario " + "para registrar o pagamento do aluguel." + ), + retryable=True, + field="placa", + ) + + record = RentalPayment( + protocolo=f"ALP-{utc_now().strftime('%Y%m%d')}-{uuid4().hex[:8].upper()}", + user_id=user_id, + rental_contract_id=contract.id if contract is not None else None, + contrato_numero=normalized_contract, + placa=plate, + valor=_normalize_money(valor), + data_pagamento=_parse_optional_datetime(data_pagamento, field_name="data_pagamento"), + favorecido=_normalize_text_field(favorecido), + identificador_comprovante=_normalize_text_field(identificador_comprovante), + observacoes=_normalize_text_field(observacoes), + ) + + db.add(record) + db.commit() + db.refresh(record) + return { + "protocolo": record.protocolo, + "rental_contract_id": record.rental_contract_id, + "contrato_numero": record.contrato_numero, + "placa": record.placa, + "valor": float(record.valor), + "data_pagamento": record.data_pagamento.isoformat() if record.data_pagamento else None, + "favorecido": record.favorecido, + "identificador_comprovante": record.identificador_comprovante, + "status": "registrado", + } + finally: + db.close() + + +async def registrar_multa_aluguel( + valor: float, + placa: str | None = None, + contrato_numero: str | None = None, + auto_infracao: str | None = None, + orgao_emissor: str | None = None, + data_infracao: str | None = None, + vencimento: str | None = None, + observacoes: str | None = None, + user_id: int | None = None, +) -> dict: + normalized_contract = _normalize_contract_number(contrato_numero) + plate = technical_normalizer.normalize_plate(placa) + notice_number = _normalize_text_field(auto_infracao) + + db = SessionMockLocal() + try: + contract = _resolve_rental_contract( + db, + contrato_numero=normalized_contract, + placa=plate, + user_id=user_id, + active_only=False, + ) + if contract is not None: + normalized_contract = contract.contrato_numero + plate = contract.placa + + if not normalized_contract and not plate and not notice_number: + raise_tool_http_error( + status_code=400, + code="missing_fine_reference", + message=( + "Preciso da placa, do numero do contrato, do auto da infracao ou de uma locacao " + "vinculada ao usuario para registrar a multa." + ), + retryable=True, + field="placa", + ) + + record = RentalFine( + protocolo=f"ALM-{utc_now().strftime('%Y%m%d')}-{uuid4().hex[:8].upper()}", + user_id=user_id, + rental_contract_id=contract.id if contract is not None else None, + contrato_numero=normalized_contract, + placa=plate, + auto_infracao=notice_number, + orgao_emissor=_normalize_text_field(orgao_emissor), + valor=_normalize_money(valor), + data_infracao=_parse_optional_datetime(data_infracao, field_name="data_infracao"), + vencimento=_parse_optional_datetime(vencimento, field_name="vencimento"), + observacoes=_normalize_text_field(observacoes), + status="registrada", + ) + + db.add(record) + db.commit() + db.refresh(record) + return { + "protocolo": record.protocolo, + "rental_contract_id": record.rental_contract_id, + "contrato_numero": record.contrato_numero, + "placa": record.placa, + "auto_infracao": record.auto_infracao, + "orgao_emissor": record.orgao_emissor, + "valor": float(record.valor), + "data_infracao": record.data_infracao.isoformat() if record.data_infracao else None, + "vencimento": record.vencimento.isoformat() if record.vencimento else None, + "status": record.status, + } + finally: + db.close() diff --git a/app/services/flows/rental_flow.py b/app/services/flows/rental_flow.py new file mode 100644 index 0000000..d6df897 --- /dev/null +++ b/app/services/flows/rental_flow.py @@ -0,0 +1,507 @@ +import re +from datetime import datetime, timedelta + +from fastapi import HTTPException + +from app.core.time_utils import utc_now +from app.services.orchestration import technical_normalizer +from app.services.orchestration.orchestrator_config import ( + PENDING_RENTAL_DRAFT_TTL_MINUTES, + PENDING_RENTAL_SELECTION_TTL_MINUTES, + RENTAL_REQUIRED_FIELDS, +) + + +class RentalFlowMixin: + def _sanitize_rental_results(self, rental_results: list[dict] | None) -> list[dict]: + sanitized: list[dict] = [] + for item in rental_results or []: + if not isinstance(item, dict): + continue + try: + rental_vehicle_id = int(item.get("id")) + valor_diaria = float(item.get("valor_diaria") or 0) + ano = int(item.get("ano")) if item.get("ano") is not None else None + except (TypeError, ValueError): + continue + placa = technical_normalizer.normalize_plate(item.get("placa")) + if not placa: + continue + sanitized.append( + { + "id": rental_vehicle_id, + "placa": placa, + "modelo": str(item.get("modelo") or "").strip(), + "categoria": str(item.get("categoria") or "").strip().lower(), + "ano": ano, + "valor_diaria": valor_diaria, + "status": str(item.get("status") or "").strip().lower() or "disponivel", + } + ) + return sanitized + + def _mark_rental_flow_active(self, user_id: int | None, *, active_task: str | None = None) -> None: + if user_id is None: + return + context = self._get_user_context(user_id) + if not isinstance(context, dict): + return + context["active_domain"] = "rental" + if active_task is not None: + context["active_task"] = active_task + self._save_user_context(user_id=user_id, context=context) + + def _get_last_rental_results(self, user_id: int | None) -> list[dict]: + pending_selection = self.state.get_entry("pending_rental_selections", user_id, expire=True) + if isinstance(pending_selection, dict): + payload = pending_selection.get("payload") + if isinstance(payload, list): + sanitized = self._sanitize_rental_results(payload) + if sanitized: + return sanitized + context = self._get_user_context(user_id) + if not isinstance(context, dict): + return [] + rental_results = context.get("last_rental_results") or [] + return self._sanitize_rental_results(rental_results if isinstance(rental_results, list) else []) + + def _store_pending_rental_selection(self, user_id: int | None, rental_results: list[dict] | None) -> None: + if user_id is None: + return + sanitized = self._sanitize_rental_results(rental_results) + if not sanitized: + self.state.pop_entry("pending_rental_selections", user_id) + return + self.state.set_entry( + "pending_rental_selections", + user_id, + { + "payload": sanitized, + "expires_at": utc_now() + timedelta(minutes=PENDING_RENTAL_SELECTION_TTL_MINUTES), + }, + ) + + def _get_selected_rental_vehicle(self, user_id: int | None) -> dict | None: + context = self._get_user_context(user_id) + if not isinstance(context, dict): + return None + selected_vehicle = context.get("selected_rental_vehicle") + return dict(selected_vehicle) if isinstance(selected_vehicle, dict) else None + + def _remember_rental_results(self, user_id: int | None, rental_results: list[dict] | None) -> None: + context = self._get_user_context(user_id) + if not isinstance(context, dict): + return + sanitized = self._sanitize_rental_results(rental_results) + context["last_rental_results"] = sanitized + self._store_pending_rental_selection(user_id=user_id, rental_results=sanitized) + if sanitized: + context["selected_rental_vehicle"] = None + context["active_domain"] = "rental" + self._save_user_context(user_id=user_id, context=context) + + def _store_selected_rental_vehicle(self, user_id: int | None, vehicle: dict | None) -> None: + if user_id is None: + return + context = self._get_user_context(user_id) + if not isinstance(context, dict): + return + context["selected_rental_vehicle"] = dict(vehicle) if isinstance(vehicle, dict) else None + context["active_domain"] = "rental" + self.state.pop_entry("pending_rental_selections", user_id) + self._save_user_context(user_id=user_id, context=context) + + def _rental_vehicle_to_payload(self, vehicle: dict) -> dict: + return { + "rental_vehicle_id": int(vehicle["id"]), + "placa": str(vehicle["placa"]), + "modelo_veiculo": str(vehicle["modelo"]), + "categoria": str(vehicle.get("categoria") or ""), + "valor_diaria": round(float(vehicle.get("valor_diaria") or 0), 2), + } + + def _extract_rental_category_from_text(self, text: str) -> str | None: + normalized = self._normalize_text(text).strip() + aliases = { + "suv": "suv", + "sedan": "sedan", + "hatch": "hatch", + "pickup": "pickup", + "picape": "pickup", + } + for token, category in aliases.items(): + if re.search(rf"(? list[str]: + normalized = technical_normalizer.normalize_datetime_connector(text) + patterns = ( + r"\b\d{1,2}[/-]\d{1,2}[/-]\d{4}(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?\b", + r"\b\d{4}[/-]\d{1,2}[/-]\d{1,2}(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?\b", + ) + results: list[str] = [] + for pattern in patterns: + for match in re.finditer(pattern, normalized): + candidate = self._normalize_rental_datetime_text(match.group(0)) + if candidate and candidate not in results: + results.append(candidate) + return results + + def _normalize_rental_datetime_text(self, value) -> str | None: + text = technical_normalizer.normalize_datetime_connector(str(value or "").strip()) + if not text: + return None + + parsed = technical_normalizer.try_parse_iso_datetime(text) + if parsed is None: + parsed = technical_normalizer.try_parse_datetime_with_formats( + text, + ( + "%d/%m/%Y %H:%M", + "%d/%m/%Y %H:%M:%S", + "%d/%m/%Y", + "%Y-%m-%d %H:%M", + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d", + ), + ) + if parsed is None: + return None + if ":" in text: + return parsed.strftime("%d/%m/%Y %H:%M") + return parsed.strftime("%d/%m/%Y") + + def _normalize_rental_fields(self, data) -> dict: + if not isinstance(data, dict): + return {} + + payload: dict = {} + + rental_vehicle_id = data.get("rental_vehicle_id") + if rental_vehicle_id is None: + rental_vehicle_id = data.get("vehicle_id") + try: + if rental_vehicle_id not in (None, ""): + numeric = int(rental_vehicle_id) + if numeric > 0: + payload["rental_vehicle_id"] = numeric + except (TypeError, ValueError): + pass + + placa = technical_normalizer.normalize_plate(data.get("placa")) + if placa: + payload["placa"] = placa + + cpf = technical_normalizer.normalize_cpf(data.get("cpf")) + if cpf: + payload["cpf"] = cpf + + valor_diaria_max = technical_normalizer.normalize_positive_number(data.get("valor_diaria_max")) + if valor_diaria_max: + payload["valor_diaria_max"] = float(valor_diaria_max) + + categoria = self._extract_rental_category_from_text(str(data.get("categoria") or "")) + if categoria: + payload["categoria"] = categoria + + for field_name in ("data_inicio", "data_fim_prevista"): + normalized = self._normalize_rental_datetime_text(data.get(field_name)) + if normalized: + payload[field_name] = normalized + + return payload + + def _try_capture_rental_fields_from_message(self, message: str, payload: dict) -> None: + if payload.get("placa") is None: + words = re.findall(r"[A-Za-z0-9-]+", str(message or "")) + for word in words: + plate = technical_normalizer.normalize_plate(word) + if plate: + payload["placa"] = plate + break + + if payload.get("cpf") is None: + cpf = technical_normalizer.extract_cpf_from_text(message) + if cpf and technical_normalizer.is_valid_cpf(cpf): + payload["cpf"] = cpf + + if payload.get("categoria") is None: + category = self._extract_rental_category_from_text(message) + if category: + payload["categoria"] = category + + if payload.get("valor_diaria_max") is None: + budget = technical_normalizer.extract_budget_from_text(message) + if budget: + payload["valor_diaria_max"] = float(budget) + + datetimes = self._extract_rental_datetimes_from_text(message) + if datetimes: + if not payload.get("data_inicio"): + payload["data_inicio"] = datetimes[0] + if len(datetimes) >= 2 and not payload.get("data_fim_prevista"): + payload["data_fim_prevista"] = datetimes[1] + elif len(datetimes) == 1 and payload.get("data_inicio") and not payload.get("data_fim_prevista"): + if payload["data_inicio"] != datetimes[0]: + payload["data_fim_prevista"] = datetimes[0] + + def _has_rental_listing_request(self, message: str, turn_decision: dict | None = None) -> bool: + decision_intent = self._decision_intent(turn_decision) + decision_domain = str((turn_decision or {}).get("domain") or "").strip().lower() + if decision_domain == "rental" and decision_intent in {"rental_list", "rental_search"}: + return True + normalized = self._normalize_text(message).strip() + rental_terms = {"aluguel", "alugar", "locacao", "locar"} + listing_terms = {"quais", "listar", "liste", "mostrar", "mostre", "disponiveis", "disponivel", "frota", "opcoes", "opcao"} + return any(term in normalized for term in rental_terms) and any(term in normalized for term in listing_terms) + + def _has_explicit_rental_request(self, message: str) -> bool: + normalized = self._normalize_text(message).strip() + if any(term in normalized for term in {"multa", "comprovante", "pagamento", "devolucao", "devolver"}): + return False + request_terms = { + "quero alugar", + "quero locar", + "abrir locacao", + "abrir aluguel", + "fazer locacao", + "iniciar locacao", + "seguir com a locacao", + "seguir com aluguel", + "alugar o carro", + "locacao do carro", + } + return any(term in normalized for term in request_terms) + + def _has_rental_return_request(self, message: str) -> bool: + normalized = self._normalize_text(message).strip() + return any(term in normalized for term in {"devolver", "devolucao", "encerrar locacao", "fechar locacao"}) + + def _has_rental_payment_or_fine_request(self, message: str) -> bool: + normalized = self._normalize_text(message).strip() + return any(term in normalized for term in {"multa", "comprovante", "pagamento", "boleto", "pix"}) + + def _match_rental_vehicle_from_message_index(self, message: str, rental_results: list[dict]) -> dict | None: + tokens = [token for token in re.findall(r"\d+", str(message or "")) if token.isdigit()] + if not tokens: + return None + choice = int(tokens[0]) + if 1 <= choice <= len(rental_results): + return rental_results[choice - 1] + return None + + def _match_rental_vehicle_from_message_model(self, message: str, rental_results: list[dict]) -> dict | None: + normalized_message = self._normalize_text(message) + matches = [] + for item in rental_results: + normalized_model = self._normalize_text(str(item.get("modelo") or "")) + normalized_plate = self._normalize_text(str(item.get("placa") or "")) + if (normalized_model and normalized_model in normalized_message) or ( + normalized_plate and normalized_plate in normalized_message + ): + matches.append(item) + if len(matches) == 1: + return matches[0] + return None + + def _try_resolve_rental_vehicle(self, message: str, user_id: int | None, payload: dict) -> dict | None: + rental_vehicle_id = payload.get("rental_vehicle_id") + if isinstance(rental_vehicle_id, int) and rental_vehicle_id > 0: + for item in self._get_last_rental_results(user_id=user_id): + if int(item.get("id") or 0) == rental_vehicle_id: + return item + + rental_results = self._get_last_rental_results(user_id=user_id) + selected_from_model = self._match_rental_vehicle_from_message_model(message=message, rental_results=rental_results) + if selected_from_model: + return selected_from_model + + selected_from_index = self._match_rental_vehicle_from_message_index(message=message, rental_results=rental_results) + if selected_from_index: + return selected_from_index + + normalized_plate = technical_normalizer.normalize_plate(payload.get("placa")) + if normalized_plate: + matches = [item for item in rental_results if str(item.get("placa") or "").strip().upper() == normalized_plate] + if len(matches) == 1: + return matches[0] + + return None + + def _should_bootstrap_rental_from_context(self, message: str, user_id: int | None, payload: dict | None = None) -> bool: + if user_id is None: + return False + + rental_results = self._get_last_rental_results(user_id=user_id) + if not rental_results: + return False + + normalized_payload = payload if isinstance(payload, dict) else {} + return bool( + self._match_rental_vehicle_from_message_model(message=message, rental_results=rental_results) + or self._match_rental_vehicle_from_message_index(message=message, rental_results=rental_results) + or ( + normalized_payload.get("placa") + and self._try_resolve_rental_vehicle(message=message, user_id=user_id, payload=normalized_payload) + ) + ) + + def _render_missing_rental_fields_prompt(self, missing_fields: list[str]) -> str: + labels = { + "rental_vehicle_id": "qual veiculo da frota voce quer alugar", + "data_inicio": "a data e hora de inicio da locacao", + "data_fim_prevista": "a data e hora previstas para devolucao", + } + items = [f"- {labels[field]}" for field in missing_fields] + return "Para abrir a locacao, preciso dos dados abaixo:\n" + "\n".join(items) + + def _render_rental_selection_from_fleet_prompt(self, rental_results: list[dict]) -> str: + lines = ["Para seguir com a locacao, escolha primeiro qual veiculo voce quer alugar:"] + for idx, item in enumerate(rental_results[:10], start=1): + lines.append( + f"- {idx}. {item.get('modelo', 'N/A')} {item.get('ano', 'N/A')} | " + f"{item.get('placa', 'N/A')} | {item.get('categoria', 'N/A')} | " + f"diaria R$ {float(item.get('valor_diaria', 0)):.2f}" + ) + lines.append("Pode responder com o numero da lista, com a placa ou com o modelo.") + return "\n".join(lines) + + async def _try_list_rental_fleet_for_selection( + self, + message: str, + user_id: int | None, + payload: dict, + turn_decision: dict | None = None, + force: bool = False, + ) -> str | None: + if user_id is None: + return None + if not force and not self._has_rental_listing_request(message, turn_decision=turn_decision): + return None + + arguments: dict = { + "limite": 10, + "ordenar_diaria": "asc", + } + category = payload.get("categoria") or self._extract_rental_category_from_text(message) + if category: + arguments["categoria"] = str(category).strip().lower() + + valor_diaria_max = payload.get("valor_diaria_max") + if not isinstance(valor_diaria_max, (int, float)): + valor_diaria_max = technical_normalizer.extract_budget_from_text(message) + if isinstance(valor_diaria_max, (int, float)) and float(valor_diaria_max) > 0: + arguments["valor_diaria_max"] = float(valor_diaria_max) + + try: + tool_result = await self.tool_executor.execute( + "consultar_frota_aluguel", + arguments, + user_id=user_id, + ) + except HTTPException as exc: + return self._http_exception_detail(exc) + + rental_results = tool_result if isinstance(tool_result, list) else [] + self._remember_rental_results(user_id=user_id, rental_results=rental_results) + self._mark_rental_flow_active(user_id=user_id) + return self._fallback_format_tool_result("consultar_frota_aluguel", tool_result) + + async def _try_collect_and_open_rental( + self, + message: str, + user_id: int | None, + extracted_fields: dict | None = None, + intents: dict | None = None, + turn_decision: dict | None = None, + ) -> str | None: + if user_id is None: + return None + + draft = self.state.get_entry("pending_rental_drafts", user_id, expire=True) + extracted = self._normalize_rental_fields(extracted_fields) + decision_intent = self._decision_intent(turn_decision) + has_intent = decision_intent in {"rental_create", "rental_list", "rental_search"} + explicit_rental_request = self._has_explicit_rental_request(message) + rental_listing_request = self._has_rental_listing_request(message, turn_decision=turn_decision) + should_bootstrap_from_context = draft is None and self._should_bootstrap_rental_from_context( + message=message, + user_id=user_id, + payload=extracted, + ) + + if ( + draft is None + and not has_intent + and not explicit_rental_request + and not rental_listing_request + and not should_bootstrap_from_context + ): + return None + + if draft is None: + draft = { + "payload": {}, + "expires_at": utc_now() + timedelta(minutes=PENDING_RENTAL_DRAFT_TTL_MINUTES), + } + + draft_payload = draft.get("payload", {}) + if not isinstance(draft_payload, dict): + draft_payload = {} + draft["payload"] = draft_payload + + draft_payload.update(extracted) + self._try_capture_rental_fields_from_message(message=message, payload=draft_payload) + + selected_vehicle = self._get_selected_rental_vehicle(user_id=user_id) + if selected_vehicle and not draft_payload.get("rental_vehicle_id"): + draft_payload.update(self._rental_vehicle_to_payload(selected_vehicle)) + + resolved_vehicle = self._try_resolve_rental_vehicle( + message=message, + user_id=user_id, + payload=draft_payload, + ) + if resolved_vehicle: + self._store_selected_rental_vehicle(user_id=user_id, vehicle=resolved_vehicle) + draft_payload.update(self._rental_vehicle_to_payload(resolved_vehicle)) + + draft["expires_at"] = utc_now() + timedelta(minutes=PENDING_RENTAL_DRAFT_TTL_MINUTES) + self.state.set_entry("pending_rental_drafts", user_id, draft) + self._mark_rental_flow_active(user_id=user_id, active_task="rental_create") + + missing = [field for field in RENTAL_REQUIRED_FIELDS if field not in draft_payload] + if missing: + if "rental_vehicle_id" in missing: + fleet_response = await self._try_list_rental_fleet_for_selection( + message=message, + user_id=user_id, + payload=draft_payload, + turn_decision=turn_decision, + force=bool(draft) or explicit_rental_request or rental_listing_request or should_bootstrap_from_context, + ) + if fleet_response: + return fleet_response + rental_results = self._get_last_rental_results(user_id=user_id) + if rental_results: + return self._render_rental_selection_from_fleet_prompt(rental_results) + return self._render_missing_rental_fields_prompt(missing) + + try: + tool_result = await self.tool_executor.execute( + "abrir_locacao_aluguel", + { + "rental_vehicle_id": draft_payload["rental_vehicle_id"], + "placa": draft_payload.get("placa"), + "data_inicio": draft_payload["data_inicio"], + "data_fim_prevista": draft_payload["data_fim_prevista"], + "cpf": draft_payload.get("cpf"), + }, + user_id=user_id, + ) + except HTTPException as exc: + return self._http_exception_detail(exc) + + self._reset_pending_rental_states(user_id=user_id) + return self._fallback_format_tool_result("abrir_locacao_aluguel", tool_result) diff --git a/app/services/orchestration/conversation_policy.py b/app/services/orchestration/conversation_policy.py index 30f95fc..fc9a9c6 100644 --- a/app/services/orchestration/conversation_policy.py +++ b/app/services/orchestration/conversation_policy.py @@ -66,6 +66,10 @@ CONTEXT_FIELD_LABELS = { "modelo_veiculo": "modelo_veiculo", "valor_veiculo": "valor_veiculo", "numero_pedido": "numero_pedido", + "rental_vehicle_id": "rental_vehicle_id", + "data_inicio": "data de inicio", + "data_fim_prevista": "data fim prevista", + "valor_diaria_max": "valor maximo da diaria", } ACTIVE_TASK_LABELS = { @@ -73,6 +77,7 @@ ACTIVE_TASK_LABELS = { "review_management": "gestao de revisao", "order_create": "criacao de pedido", "order_cancel": "cancelamento de pedido", + "rental_create": "abertura de locacao", } # essa classe é responsável por controlar qual o assunto está ativo na conversa, se existe fluxo aberto, se o usuário mandou dois pedidos ao mesmo tempo... @@ -322,6 +327,7 @@ class ConversationPolicy: domain_prefix = { "review": "Revisao", "sales": "Venda", + "rental": "Locacao", "general": "Atendimento", }.get(domain, "Atendimento") return f"{domain_prefix}: {message}" @@ -396,7 +402,7 @@ class ConversationPolicy: def looks_like_fresh_operational_request(self, message: str, turn_decision: dict | None = None) -> bool: decision_domain = self._decision_domain(turn_decision) decision_intent = self._decision_intent(turn_decision) - if decision_domain in {"review", "sales"} or decision_intent not in {"", "general"}: + if decision_domain in {"review", "sales", "rental"} or decision_intent not in {"", "general"}: return True return self.looks_like_fresh_operational_request_from_text(message) @@ -415,6 +421,11 @@ class ConversationPolicy: "veiculo", "remarcar", "tambem", + "aluguel", + "alugar", + "locacao", + "locar", + "devolver", } return self.contains_any_term(normalized, operational_terms) @@ -710,7 +721,7 @@ class ConversationPolicy: def is_context_switch_confirmation(self, message: str, turn_decision: dict | None = None) -> bool: if self._decision_action(turn_decision) in {"continue_queue", "cancel_active_flow", "clear_context", "discard_queue"}: return True - if self._decision_domain(turn_decision) in {"review", "sales"}: + if self._decision_domain(turn_decision) in {"review", "sales", "rental"}: return True return self.service._is_affirmative_message(message) or self.service._is_negative_message(message) @@ -801,6 +812,11 @@ class ConversationPolicy: self.service.state.get_entry("pending_order_drafts", user_id, expire=True) or self.service.state.get_entry("pending_cancel_order_drafts", user_id, expire=True) ) + if domain == "rental": + return bool( + self.service.state.get_entry("pending_rental_drafts", user_id, expire=True) + or self.service.state.get_entry("pending_rental_selections", user_id, expire=True) + ) return False @@ -814,6 +830,8 @@ class ConversationPolicy: self.service._reset_pending_review_states(user_id=user_id) if previous_domain == "sales": self.service._reset_pending_order_states(user_id=user_id) + if previous_domain == "rental": + self.service._reset_pending_rental_states(user_id=user_id) context["active_domain"] = target_domain context["generic_memory"] = self.service._new_tab_memory(user_id=user_id) context["pending_order_selection"] = None @@ -838,7 +856,7 @@ class ConversationPolicy: context["pending_switch"] = None self._save_context(user_id=user_id, context=context) elif ( - self._decision_domain(turn_decision) in {"review", "sales"} + self._decision_domain(turn_decision) in {"review", "sales", "rental"} and self._decision_domain(turn_decision) != pending_switch["target_domain"] ): context["pending_switch"] = None @@ -890,6 +908,7 @@ class ConversationPolicy: labels = { "review": "agendamento de revisao", "sales": "compra de veiculo", + "rental": "locacao de veiculo", "general": "atendimento geral", } return labels.get(domain, "atendimento") @@ -909,6 +928,8 @@ class ConversationPolicy: return "Pode me dizer a faixa de preco, o modelo ou o tipo de carro que voce procura." if target_domain == "review": return "Pode me informar a placa ou, se preferir, ja mandar placa, data/hora, modelo, ano, km e se ja fez revisao." + if target_domain == "rental": + return "Pode me dizer qual carro voce quer alugar e o periodo desejado para a locacao." return "Pode me dizer o que voce quer fazer agora?" def render_context_switched_message(self, target_domain: str) -> str: @@ -1008,9 +1029,15 @@ class ConversationPolicy: selected_vehicle = context.get("selected_vehicle") if isinstance(selected_vehicle, dict) and selected_vehicle.get("modelo"): summary.append(f"Veiculo selecionado para compra: {selected_vehicle.get('modelo')}.") + selected_rental_vehicle = context.get("selected_rental_vehicle") + if isinstance(selected_rental_vehicle, dict) and selected_rental_vehicle.get("modelo"): + summary.append(f"Veiculo selecionado para locacao: {selected_rental_vehicle.get('modelo')}.") stock_results = context.get("last_stock_results") or [] if isinstance(stock_results, list) and stock_results: summary.append(f"Ultima consulta de estoque com {len(stock_results)} opcao(oes) disponivel(is).") + rental_results = context.get("last_rental_results") or [] + if isinstance(rental_results, list) and rental_results: + summary.append(f"Ultima consulta de locacao com {len(rental_results)} opcao(oes) disponivel(is).") last_tool_result = context.get("last_tool_result") if isinstance(last_tool_result, dict) and last_tool_result.get("tool_name"): tool_name = str(last_tool_result.get("tool_name") or "").strip() @@ -1125,4 +1152,22 @@ class ConversationPolicy: payload = stock_selection.get("payload") if isinstance(payload, list) and payload: summary.append(f"Aguardando escolha de veiculo em {len(payload)} opcao(oes) de estoque.") + + rental_draft = self._get_pending_entry(user_id, "pending_rental_drafts") + if isinstance(rental_draft, dict): + payload = rental_draft.get("payload") + known_fields = self._summarize_payload(payload, ("placa", "rental_vehicle_id", "data_inicio", "data_fim_prevista", "cpf")) + missing_fields = self._summarize_missing_fields(payload, ("rental_vehicle_id", "data_inicio", "data_fim_prevista")) + draft_summary = "Rascunho aberto de locacao." + if known_fields: + draft_summary = f"{draft_summary} Dados atuais: {known_fields}." + if missing_fields: + draft_summary = f"{draft_summary} Faltando: {missing_fields}." + summary.append(draft_summary) + + rental_selection = self._get_pending_entry(user_id, "pending_rental_selections") + if isinstance(rental_selection, dict): + payload = rental_selection.get("payload") + if isinstance(payload, list) and payload: + summary.append(f"Aguardando escolha de veiculo em {len(payload)} opcao(oes) de locacao.") return " ".join(summary) diff --git a/app/services/orchestration/conversation_state_store.py b/app/services/orchestration/conversation_state_store.py index ecc4366..89f87d5 100644 --- a/app/services/orchestration/conversation_state_store.py +++ b/app/services/orchestration/conversation_state_store.py @@ -17,6 +17,8 @@ class ConversationStateStore(ConversationStateRepository): self.pending_order_drafts: dict[int, dict] = {} self.pending_cancel_order_drafts: dict[int, dict] = {} self.pending_stock_selections: dict[int, dict] = {} + self.pending_rental_drafts: dict[int, dict] = {} + self.pending_rental_selections: dict[int, dict] = {} def upsert_user_context(self, user_id: int | None, ttl_minutes: int) -> None: if user_id is None: @@ -39,6 +41,8 @@ class ConversationStateStore(ConversationStateRepository): "pending_switch": None, "last_stock_results": [], "selected_vehicle": None, + "last_rental_results": [], + "selected_rental_vehicle": None, "expires_at": now + timedelta(minutes=ttl_minutes), } diff --git a/app/services/orchestration/orchestrator_config.py b/app/services/orchestration/orchestrator_config.py index 02e6673..02cfbbd 100644 --- a/app/services/orchestration/orchestrator_config.py +++ b/app/services/orchestration/orchestrator_config.py @@ -1,13 +1,15 @@ -# Constantes compartilhadas do orquestrador: +# Constantes compartilhadas do orquestrador: # TTLs, campos obrigatorios, respostas de baixo valor e tools especiais. USER_CONTEXT_TTL_MINUTES = 60 PENDING_ORDER_SELECTION_TTL_MINUTES = 15 +PENDING_RENTAL_SELECTION_TTL_MINUTES = 15 PENDING_REVIEW_TTL_MINUTES = 30 PENDING_REVIEW_DRAFT_TTL_MINUTES = 30 LAST_REVIEW_PACKAGE_TTL_MINUTES = 20 PENDING_ORDER_DRAFT_TTL_MINUTES = 30 PENDING_CANCEL_ORDER_DRAFT_TTL_MINUTES = 30 +PENDING_RENTAL_DRAFT_TTL_MINUTES = 30 REVIEW_REQUIRED_FIELDS = ( "placa", @@ -23,6 +25,12 @@ ORDER_REQUIRED_FIELDS = ( "vehicle_id", ) +RENTAL_REQUIRED_FIELDS = ( + "rental_vehicle_id", + "data_inicio", + "data_fim_prevista", +) + CANCEL_ORDER_REQUIRED_FIELDS = ( "numero_pedido", "motivo", @@ -42,11 +50,16 @@ LOW_VALUE_RESPONSES = { DETERMINISTIC_RESPONSE_TOOLS = { "consultar_estoque", "avaliar_veiculo_troca", + "consultar_frota_aluguel", "cancelar_pedido", "listar_pedidos", "listar_agendamentos_revisao", "cancelar_agendamento_revisao", "editar_data_revisao", + "abrir_locacao_aluguel", + "registrar_devolucao_aluguel", + "registrar_pagamento_aluguel", + "registrar_multa_aluguel", "limpar_contexto_conversa", "continuar_proximo_pedido", "descartar_pedidos_pendentes", @@ -59,3 +72,6 @@ ORCHESTRATION_CONTROL_TOOLS = { "descartar_pedidos_pendentes", "cancelar_fluxo_atual", } + + + diff --git a/app/services/orchestration/orquestrador_service.py b/app/services/orchestration/orquestrador_service.py index 26a3ca6..2a8fc8c 100644 --- a/app/services/orchestration/orquestrador_service.py +++ b/app/services/orchestration/orquestrador_service.py @@ -23,6 +23,7 @@ from app.services.orchestration.message_planner import MessagePlanner from app.services.orchestration.conversation_history_service import ConversationHistoryService from app.services.orchestration.state_repository_factory import get_conversation_state_repository from app.services.flows.order_flow import OrderFlowMixin +from app.services.flows.rental_flow import RentalFlowMixin from app.services.orchestration.prompt_builders import ( build_force_tool_prompt, build_result_prompt, @@ -37,7 +38,7 @@ logger = logging.getLogger(__name__) # Coordenador principal do turno conversacional: # atualiza estado, pede decisoes ao modelo, continua fluxos e executa tools. -class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): +class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin): def __init__( self, db: Session, @@ -130,6 +131,20 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): ) if active_sales_follow_up: return active_sales_follow_up + pending_rental_selection_follow_up = await self._try_handle_pending_rental_selection_follow_up( + message=message, + user_id=user_id, + finish=finish, + ) + if pending_rental_selection_follow_up: + return pending_rental_selection_follow_up + active_rental_follow_up = await self._try_handle_active_rental_follow_up( + message=message, + user_id=user_id, + finish=finish, + ) + if active_rental_follow_up: + return active_rental_follow_up active_review_follow_up = await self._try_handle_active_review_follow_up( message=message, user_id=user_id, @@ -261,6 +276,11 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): turn_decision=turn_decision, user_id=user_id, ) + should_prioritize_rental_flow = self._should_prioritize_rental_flow( + turn_decision=turn_decision, + user_id=user_id, + message=routing_message, + ) domain_hint = self._domain_from_turn_decision(turn_decision) if domain_hint == "general": domain_hint = self._domain_from_intents(extracted_entities.get("intents", {})) @@ -272,6 +292,8 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): extracted_entities=extracted_entities, ): domain_hint = "sales" + elif self._has_rental_listing_request(routing_message, turn_decision=turn_decision) or self._has_explicit_rental_request(routing_message): + domain_hint = "rental" context_switch_response = self._handle_context_switch( message=routing_message, user_id=user_id, @@ -311,6 +333,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): and not should_prioritize_review_flow and not should_prioritize_review_management and not should_prioritize_order_flow + and not should_prioritize_rental_flow ): return await finish(decision_response, queue_notice=queue_notice) if ( @@ -319,6 +342,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): and not should_prioritize_review_flow and not should_prioritize_review_management and not should_prioritize_order_flow + and not should_prioritize_rental_flow ): return await finish(decision_response, queue_notice=queue_notice) @@ -391,6 +415,15 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): ) if order_response: return await finish(order_response, queue_notice=queue_notice) + rental_response = await self._try_collect_and_open_rental( + message=routing_message, + user_id=user_id, + extracted_fields={}, + intents={}, + turn_decision=turn_decision, + ) + if rental_response: + return await finish(rental_response, queue_notice=queue_notice) tools = self.registry.get_tools() @@ -559,6 +592,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): and ( self._has_open_flow(user_id=user_id, domain="review") or self._has_open_flow(user_id=user_id, domain="sales") + or self._has_open_flow(user_id=user_id, domain="rental") or bool((self._get_user_context(user_id) or {}).get("pending_switch")) or bool((self._get_user_context(user_id) or {}).get("order_queue")) ) @@ -629,6 +663,84 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): return None return await finish(response) + async def _try_handle_pending_rental_selection_follow_up( + self, + message: str, + user_id: int | None, + finish, + ) -> str | None: + if user_id is None: + return None + pending_selection = self.state.get_entry("pending_rental_selections", user_id, expire=True) + if not pending_selection: + return None + if not self._should_bootstrap_rental_from_context( + message=message, + user_id=user_id, + payload={}, + ): + return None + response = await self._try_collect_and_open_rental( + message=message, + user_id=user_id, + extracted_fields={}, + intents={}, + turn_decision={ + "intent": "rental_create", + "domain": "rental", + "action": "collect_rental_create", + }, + ) + if not response: + return None + return await finish(response) + + async def _try_handle_active_rental_follow_up( + self, + message: str, + user_id: int | None, + finish, + ) -> str | None: + if user_id is None: + return None + context = self._get_user_context(user_id) + if not isinstance(context, dict): + return None + if str(context.get("active_domain") or "").strip().lower() != "rental": + return None + + normalized_message = self.normalizer.normalize_text(message).strip() + if self._looks_like_explicit_domain_shift_request(normalized_message): + return None + if ( + self._has_order_listing_request(message) + or self._has_explicit_order_request(message) + or self._has_stock_listing_request(message) + or self._has_rental_return_request(message) + or self._has_rental_payment_or_fine_request(message) + ): + return None + + pending_rental_draft = self.state.get_entry("pending_rental_drafts", user_id, expire=True) + pending_rental_selection = self.state.get_entry("pending_rental_selections", user_id, expire=True) + if not pending_rental_draft and not pending_rental_selection: + return None + + response = await self._try_collect_and_open_rental( + message=message, + user_id=user_id, + extracted_fields={}, + intents={}, + turn_decision={ + "intent": "rental_create", + "domain": "rental", + "action": "collect_rental_create", + }, + ) + if response: + return await finish(response) + return None + async def _try_handle_active_sales_follow_up( self, message: str, @@ -852,6 +964,21 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): queue_notice=queue_notice, ) + def _reset_pending_rental_states(self, user_id: int | None) -> None: + if user_id is None: + return + self.state.pop_entry("pending_rental_drafts", user_id) + self.state.pop_entry("pending_rental_selections", user_id) + context = self._get_user_context(user_id) + if isinstance(context, dict): + context["last_rental_results"] = [] + context["selected_rental_vehicle"] = None + if context.get("active_task") == "rental_create": + context["active_task"] = None + if str(context.get("active_domain") or "").strip().lower() == "rental": + context["active_domain"] = "general" + self._save_user_context(user_id=user_id, context=context) + def _reset_pending_review_states(self, user_id: int | None) -> None: if user_id is None: return @@ -901,6 +1028,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): return self._reset_pending_review_states(user_id=user_id) self._reset_pending_order_states(user_id=user_id) + self._reset_pending_rental_states(user_id=user_id) self.state.pop_entry("last_review_packages", user_id) context["active_domain"] = "general" context["active_task"] = None @@ -914,6 +1042,8 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): context["pending_switch"] = None context["last_stock_results"] = [] context["selected_vehicle"] = None + context["last_rental_results"] = [] + context["selected_rental_vehicle"] = None self._save_user_context(user_id=user_id, context=context) def _clear_pending_order_navigation(self, user_id: int | None) -> int: @@ -943,6 +1073,8 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): self._reset_pending_review_states(user_id=user_id) elif active_domain == "sales": self._reset_pending_order_states(user_id=user_id) + elif active_domain == "rental": + self._reset_pending_rental_states(user_id=user_id) context["pending_switch"] = None self._save_user_context(user_id=user_id, context=context) @@ -1091,6 +1223,16 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): "tool_name": tool_name, "result_type": type(tool_result).__name__, } + if tool_name == "consultar_frota_aluguel" and isinstance(tool_result, list): + sanitized_rental = self._sanitize_rental_results(tool_result[:20]) + context["last_rental_results"] = sanitized_rental + self._store_pending_rental_selection(user_id=user_id, rental_results=sanitized_rental) + if sanitized_rental: + context["selected_rental_vehicle"] = None + context["active_domain"] = "rental" + self._save_user_context(user_id=user_id, context=context) + return + if tool_name != "consultar_estoque" or not isinstance(tool_result, list): self._save_user_context(user_id=user_id, context=context) return @@ -1353,7 +1495,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): def _domain_from_turn_decision(self, turn_decision: dict | None) -> str: domain = str((turn_decision or {}).get("domain") or "general").strip().lower() - if domain in {"review", "sales", "general"}: + if domain in {"review", "sales", "rental", "general"}: return domain return "general" @@ -1442,6 +1584,9 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): ): return False + if self.state.get_entry("pending_rental_drafts", user_id, expire=True) or self.state.get_entry("pending_rental_selections", user_id, expire=True): + return False + entities = extracted_entities if isinstance(extracted_entities, dict) else {} order_fields = entities.get("order_fields") if not isinstance(order_fields, dict): @@ -1470,9 +1615,44 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): "agendamento", "cancelar revisao", "remarcar revisao", + "alugar", + "aluguel", + "locacao", + "locar", + "devolver", ) return any(term in normalized_message for term in shift_terms) + def _should_prioritize_rental_flow( + self, + turn_decision: dict | None, + user_id: int | None = None, + message: str | None = None, + ) -> bool: + has_open_rental_draft = bool( + user_id is not None + and ( + self.state.get_entry("pending_rental_drafts", user_id, expire=True) + or self.state.get_entry("pending_rental_selections", user_id, expire=True) + ) + ) + if has_open_rental_draft: + return True + + active_domain = str(((self._get_user_context(user_id) or {}) if user_id is not None else {}).get("active_domain") or "").strip().lower() + if active_domain == "rental": + return True + + if message and user_id is not None and self._should_bootstrap_rental_from_context(message=message, user_id=user_id, payload={}): + return True + + decision = turn_decision or {} + decision_intent = str(decision.get("intent") or "").strip().lower() + if decision_intent in {"rental_create", "rental_list", "rental_search"}: + return True + + return bool(message and (self._has_rental_listing_request(message, turn_decision=turn_decision) or self._has_explicit_rental_request(message))) + def _should_prioritize_review_management( self, turn_decision: dict | None, diff --git a/app/services/orchestration/redis_state_repository.py b/app/services/orchestration/redis_state_repository.py index 9741d97..c4e1b95 100644 --- a/app/services/orchestration/redis_state_repository.py +++ b/app/services/orchestration/redis_state_repository.py @@ -44,6 +44,8 @@ class RedisConversationStateRepository(ConversationStateRepository): "pending_switch": None, "last_stock_results": [], "selected_vehicle": None, + "last_rental_results": [], + "selected_rental_vehicle": None, "expires_at": now, } diff --git a/app/services/orchestration/response_formatter.py b/app/services/orchestration/response_formatter.py index 981e906..b5a119d 100644 --- a/app/services/orchestration/response_formatter.py +++ b/app/services/orchestration/response_formatter.py @@ -164,6 +164,90 @@ def fallback_format_tool_result(tool_name: str, tool_result: Any) -> str: f"Limite: {limite}" ) + if tool_name == "consultar_frota_aluguel" and isinstance(tool_result, list): + if not tool_result: + return "Nao encontrei veiculos disponiveis para locacao com os criterios informados." + linhas = [f"Encontrei {len(tool_result)} veiculo(s) para locacao:"] + for idx, item in enumerate(tool_result[:10], start=1): + modelo = item.get("modelo", "N/A") + placa = item.get("placa", "N/A") + categoria = item.get("categoria", "N/A") + ano = item.get("ano", "N/A") + diaria = format_currency_br(item.get("valor_diaria")) + linhas.append(f"{idx}. {modelo} {ano} | {placa} | {categoria} | diaria {diaria}") + restantes = len(tool_result) - 10 + if restantes > 0: + linhas.append(f"... e mais {restantes} veiculo(s) para locacao.") + linhas.append("Se quiser seguir com a locacao de um deles, responda com o numero da lista, a placa ou o modelo.") + return "\n".join(linhas) + + if tool_name == "abrir_locacao_aluguel" and isinstance(tool_result, dict): + contrato = tool_result.get("contrato_numero", "N/A") + placa = tool_result.get("placa", "N/A") + modelo = tool_result.get("modelo_veiculo", "N/A") + data_inicio = format_datetime_for_chat(tool_result.get("data_inicio", "N/A")) + data_fim = format_datetime_for_chat(tool_result.get("data_fim_prevista", "N/A")) + valor_diaria = format_currency_br(tool_result.get("valor_diaria")) + valor_previsto = format_currency_br(tool_result.get("valor_previsto")) + return ( + "Locacao aberta com sucesso.\n" + f"Contrato: {contrato}\n" + f"Veiculo: {modelo}\n" + f"Placa: {placa}\n" + f"Inicio: {data_inicio}\n" + f"Devolucao prevista: {data_fim}\n" + f"Diaria: {valor_diaria}\n" + f"Valor previsto: {valor_previsto}" + ) + + if tool_name == "registrar_devolucao_aluguel" and isinstance(tool_result, dict): + contrato = tool_result.get("contrato_numero", "N/A") + placa = tool_result.get("placa", "N/A") + modelo = tool_result.get("modelo_veiculo", "N/A") + data_devolucao = format_datetime_for_chat(tool_result.get("data_devolucao", "N/A")) + valor_final = format_currency_br(tool_result.get("valor_final")) + return ( + "Devolucao de aluguel registrada com sucesso.\n" + f"Contrato: {contrato}\n" + f"Veiculo: {modelo}\n" + f"Placa: {placa}\n" + f"Data da devolucao: {data_devolucao}\n" + f"Valor final estimado: {valor_final}" + ) + + if tool_name == "registrar_pagamento_aluguel" and isinstance(tool_result, dict): + protocolo = tool_result.get("protocolo", "N/A") + contrato = tool_result.get("contrato_numero") or "N/A" + placa = tool_result.get("placa") or "N/A" + valor = format_currency_br(tool_result.get("valor")) + data_pagamento = format_datetime_for_chat(tool_result.get("data_pagamento", "N/A")) + return ( + "Pagamento de aluguel registrado com sucesso.\n" + f"Protocolo: {protocolo}\n" + f"Contrato: {contrato}\n" + f"Placa: {placa}\n" + f"Valor: {valor}\n" + f"Data do pagamento: {data_pagamento}" + ) + + if tool_name == "registrar_multa_aluguel" and isinstance(tool_result, dict): + protocolo = tool_result.get("protocolo", "N/A") + contrato = tool_result.get("contrato_numero") or "N/A" + placa = tool_result.get("placa") or "N/A" + valor = format_currency_br(tool_result.get("valor")) + data_infracao = format_datetime_for_chat(tool_result.get("data_infracao", "N/A")) + vencimento = format_datetime_for_chat(tool_result.get("vencimento", "N/A")) + auto_infracao = tool_result.get("auto_infracao") or "N/A" + return ( + "Multa de aluguel registrada com sucesso.\n" + f"Protocolo: {protocolo}\n" + f"Contrato: {contrato}\n" + f"Placa: {placa}\n" + f"Auto de infracao: {auto_infracao}\n" + f"Valor: {valor}\n" + f"Data da infracao: {data_infracao}\n" + f"Vencimento: {vencimento}" + ) if tool_name in { "limpar_contexto_conversa", "continuar_proximo_pedido", diff --git a/app/services/tools/handlers.py b/app/services/tools/handlers.py index 4094a0d..e13ce77 100644 --- a/app/services/tools/handlers.py +++ b/app/services/tools/handlers.py @@ -3,6 +3,13 @@ from typing import Any from app.services.domain.credit_service import validar_cliente_venda from app.services.domain.inventory_service import avaliar_veiculo_troca, consultar_estoque from app.services.domain.order_service import cancelar_pedido, listar_pedidos, realizar_pedido +from app.services.domain.rental_service import ( + abrir_locacao_aluguel, + consultar_frota_aluguel, + registrar_devolucao_aluguel, + registrar_multa_aluguel, + registrar_pagamento_aluguel, +) from app.services.domain.review_service import ( agendar_revisao, cancelar_agendamento_revisao, @@ -26,9 +33,14 @@ __all__ = [ "cancelar_agendamento_revisao", "cancelar_pedido", "consultar_estoque", + "consultar_frota_aluguel", "editar_data_revisao", "listar_agendamentos_revisao", "listar_pedidos", + "abrir_locacao_aluguel", + "registrar_devolucao_aluguel", + "registrar_multa_aluguel", + "registrar_pagamento_aluguel", "realizar_pedido", "validar_cliente_venda", ] diff --git a/app/services/tools/tool_registry.py b/app/services/tools/tool_registry.py index 9c516b8..71c7e0c 100644 --- a/app/services/tools/tool_registry.py +++ b/app/services/tools/tool_registry.py @@ -15,6 +15,11 @@ from app.services.tools.handlers import ( listar_agendamentos_revisao, listar_pedidos, consultar_estoque, + consultar_frota_aluguel, + abrir_locacao_aluguel, + registrar_devolucao_aluguel, + registrar_multa_aluguel, + registrar_pagamento_aluguel, realizar_pedido, validar_cliente_venda, ) @@ -22,6 +27,7 @@ from app.services.tools.handlers import ( HANDLERS: Dict[str, Callable] = { "consultar_estoque": consultar_estoque, + "consultar_frota_aluguel": consultar_frota_aluguel, "validar_cliente_venda": validar_cliente_venda, "avaliar_veiculo_troca": avaliar_veiculo_troca, "agendar_revisao": agendar_revisao, @@ -31,6 +37,10 @@ HANDLERS: Dict[str, Callable] = { "cancelar_pedido": cancelar_pedido, "listar_pedidos": listar_pedidos, "realizar_pedido": realizar_pedido, + "abrir_locacao_aluguel": abrir_locacao_aluguel, + "registrar_devolucao_aluguel": registrar_devolucao_aluguel, + "registrar_pagamento_aluguel": registrar_pagamento_aluguel, + "registrar_multa_aluguel": registrar_multa_aluguel, } diff --git a/tests/test_context_summary.py b/tests/test_context_summary.py index 5828f0f..b84b7a6 100644 --- a/tests/test_context_summary.py +++ b/tests/test_context_summary.py @@ -200,6 +200,58 @@ class ContextSummaryTests(unittest.TestCase): self.assertIn("Dados atuais: placa=ABC1C23, modelo=Onix, ano=2024.", summary) self.assertIn("Faltando: data/hora, km, revisao previa na concessionaria.", summary) + def test_build_context_summary_describes_open_rental_flow(self): + now = utc_now() + state = FakeState( + entries={ + "pending_rental_drafts": { + 5: { + "payload": { + "placa": "RAA1A01", + "rental_vehicle_id": 1, + "data_inicio": "20/03/2026 10:00", + }, + "expires_at": now + timedelta(minutes=15), + } + }, + "pending_rental_selections": { + 5: { + "payload": [ + {"id": 1, "placa": "RAA1A01", "modelo": "Chevrolet Tracker", "categoria": "suv", "ano": 2024, "valor_diaria": 219.9, "status": "disponivel"}, + ], + "expires_at": now + timedelta(minutes=15), + } + }, + }, + contexts={ + 5: { + "active_domain": "rental", + "active_task": "rental_create", + "generic_memory": {}, + "shared_memory": {}, + "order_queue": [], + "pending_order_selection": None, + "pending_switch": None, + "last_stock_results": [], + "selected_vehicle": None, + "last_rental_results": [ + {"id": 1, "placa": "RAA1A01", "modelo": "Chevrolet Tracker", "categoria": "suv", "ano": 2024, "valor_diaria": 219.9, "status": "disponivel"}, + ], + "selected_rental_vehicle": {"id": 1, "placa": "RAA1A01", "modelo": "Chevrolet Tracker"}, + "last_tool_result": {"tool_name": "consultar_frota_aluguel", "result_type": "list"}, + } + }, + ) + summary = ConversationPolicy(service=FakeService(state)).build_context_summary(5) + + self.assertIn("Fluxo ativo: abertura de locacao.", summary) + self.assertIn("Veiculo selecionado para locacao: Chevrolet Tracker.", summary) + self.assertIn("Ultima consulta de locacao com 1 opcao(oes) disponivel(is).", summary) + self.assertIn("Rascunho aberto de locacao.", summary) + self.assertIn("Faltando: data fim prevista.", summary) + self.assertIn("Aguardando escolha de veiculo em 1 opcao(oes) de locacao.", summary) + + if __name__ == "__main__": unittest.main() \ No newline at end of file diff --git a/tests/test_conversation_adjustments.py b/tests/test_conversation_adjustments.py index 8ce8e74..74f239d 100644 --- a/tests/test_conversation_adjustments.py +++ b/tests/test_conversation_adjustments.py @@ -1,4 +1,4 @@ -import os +import os import unittest from datetime import datetime, timedelta from app.core.time_utils import utc_now @@ -9,6 +9,7 @@ os.environ.setdefault("DEBUG", "false") from fastapi import HTTPException from app.services.flows.order_flow import OrderFlowMixin +from app.services.flows.rental_flow import RentalFlowMixin from app.services.flows.review_flow import ReviewFlowMixin from app.integrations.telegram_satellite_service import _ensure_supported_runtime_configuration, _split_telegram_text from app.models.tool_model import ToolDefinition @@ -84,6 +85,25 @@ class FakeRegistry: ] reverse = str(arguments.get("ordenar_preco") or "asc").lower() == "desc" return sorted(stock_results, key=lambda item: float(item["preco"]), reverse=reverse) + if tool_name == "consultar_frota_aluguel": + return [ + {"id": 1, "placa": "RAA1A01", "modelo": "Chevrolet Tracker", "categoria": "suv", "ano": 2024, "valor_diaria": 219.9, "status": "disponivel"}, + {"id": 2, "placa": "RAA1A02", "modelo": "Fiat Pulse", "categoria": "suv", "ano": 2024, "valor_diaria": 189.9, "status": "disponivel"}, + ] + if tool_name == "abrir_locacao_aluguel": + return { + "contrato_numero": "LOC-TESTE-123", + "placa": arguments.get("placa") or "RAA1A01", + "modelo_veiculo": "Chevrolet Tracker", + "categoria": "suv", + "data_inicio": arguments["data_inicio"], + "data_fim_prevista": arguments["data_fim_prevista"], + "valor_diaria": 219.9, + "valor_previsto": 659.7, + "status": "ativa", + "status_veiculo": "alugado", + "cpf": arguments.get("cpf"), + } if tool_name == "listar_pedidos": return [ { @@ -240,6 +260,66 @@ class OrderFlowHarness(OrderFlowMixin): return None +class RentalFlowHarness(RentalFlowMixin): + def __init__(self, state, registry): + self.state = state + self.registry = registry + self.tool_executor = registry + self.normalizer = EntityNormalizer() + + def _get_user_context(self, user_id: int | None): + return self.state.get_user_context(user_id) + + def _save_user_context(self, user_id: int | None, context: dict | None) -> None: + if user_id is None or not isinstance(context, dict): + return + self.state.save_user_context(user_id, context) + + def _normalize_text(self, text: str) -> str: + return self.normalizer.normalize_text(text) + + def _decision_intent(self, turn_decision: dict | None) -> str: + return str((turn_decision or {}).get("intent") or "").strip().lower() + + def _http_exception_detail(self, exc) -> str: + return str(exc) + + def _reset_pending_rental_states(self, user_id: int | None) -> None: + if user_id is None: + return + self.state.pop_entry("pending_rental_drafts", user_id) + self.state.pop_entry("pending_rental_selections", user_id) + context = self._get_user_context(user_id) + if isinstance(context, dict): + context["last_rental_results"] = [] + context["selected_rental_vehicle"] = None + if context.get("active_task") == "rental_create": + context["active_task"] = None + if str(context.get("active_domain") or "").strip().lower() == "rental": + context["active_domain"] = "general" + self._save_user_context(user_id=user_id, context=context) + + def _fallback_format_tool_result(self, tool_name: str, tool_result) -> str: + if tool_name == "consultar_frota_aluguel": + lines = [f"Encontrei {len(tool_result)} veiculo(s) para locacao:"] + for idx, item in enumerate(tool_result, start=1): + lines.append( + f"{idx}. {item['modelo']} {item['ano']} | {item['placa']} | " + f"{item['categoria']} | diaria R$ {item['valor_diaria']:.2f}" + ) + lines.append("Para seguir com a locacao, informe a placa ou o numero da opcao desejada.") + return "\n".join(lines) + if tool_name == "abrir_locacao_aluguel": + return ( + "Locacao aberta com sucesso.\n" + f"Contrato: {tool_result['contrato_numero']}\n" + f"Placa: {tool_result['placa']}\n" + f"Inicio: {tool_result['data_inicio']}\n" + f"Devolucao prevista: {tool_result['data_fim_prevista']}" + ) + return str(tool_result) + + class ReviewFlowHarness(ReviewFlowMixin): def __init__(self, state, registry, review_now_provider=None): self.state = state @@ -1731,6 +1811,108 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase): ) +class RentalFlowDraftTests(unittest.IsolatedAsyncioTestCase): + def _base_context(self): + return { + "active_domain": "general", + "active_task": None, + "generic_memory": {}, + "shared_memory": {}, + "order_queue": [], + "pending_order_selection": None, + "pending_switch": None, + "last_stock_results": [], + "selected_vehicle": None, + "last_rental_results": [], + "selected_rental_vehicle": None, + } + + async def test_rental_flow_lists_fleet_and_stores_pending_selection(self): + state = FakeState(contexts={21: self._base_context()}) + registry = FakeRegistry() + flow = RentalFlowHarness(state=state, registry=registry) + + response = await flow._try_collect_and_open_rental( + message="quais carros estao disponiveis para aluguel", + user_id=21, + extracted_fields={}, + intents={}, + turn_decision={"intent": "rental_list", "domain": "rental", "action": "answer_user"}, + ) + + self.assertEqual(registry.calls[0][0], "consultar_frota_aluguel") + self.assertIn("veiculo(s) para locacao", response) + self.assertIsNotNone(state.get_entry("pending_rental_selections", 21)) + self.assertEqual(state.get_user_context(21)["active_domain"], "rental") + + async def test_rental_flow_accepts_vehicle_selection_from_list_index(self): + state = FakeState( + entries={ + "pending_rental_selections": { + 21: { + "payload": [ + {"id": 1, "placa": "RAA1A01", "modelo": "Chevrolet Tracker", "categoria": "suv", "ano": 2024, "valor_diaria": 219.9, "status": "disponivel"}, + {"id": 2, "placa": "RAA1A02", "modelo": "Fiat Pulse", "categoria": "suv", "ano": 2024, "valor_diaria": 189.9, "status": "disponivel"}, + ], + "expires_at": utc_now() + timedelta(minutes=15), + } + } + }, + contexts={21: self._base_context() | {"active_domain": "rental"}}, + ) + registry = FakeRegistry() + flow = RentalFlowHarness(state=state, registry=registry) + + response = await flow._try_collect_and_open_rental( + message="1", + user_id=21, + extracted_fields={}, + intents={}, + turn_decision={"intent": "rental_create", "domain": "rental", "action": "answer_user"}, + ) + + draft = state.get_entry("pending_rental_drafts", 21) + self.assertIsNotNone(draft) + self.assertEqual(draft["payload"]["rental_vehicle_id"], 1) + self.assertEqual(state.get_user_context(21)["selected_rental_vehicle"]["placa"], "RAA1A01") + self.assertIn("a data e hora de inicio da locacao", response) + self.assertIn("a data e hora previstas para devolucao", response) + + async def test_rental_flow_opens_contract_after_collecting_dates(self): + state = FakeState( + entries={ + "pending_rental_drafts": { + 21: { + "payload": { + "rental_vehicle_id": 1, + "placa": "RAA1A01", + "modelo_veiculo": "Chevrolet Tracker", + }, + "expires_at": utc_now() + timedelta(minutes=15), + } + } + }, + contexts={21: self._base_context() | {"active_domain": "rental", "selected_rental_vehicle": {"id": 1, "placa": "RAA1A01", "modelo": "Chevrolet Tracker", "categoria": "suv", "ano": 2024, "valor_diaria": 219.9, "status": "disponivel"}}}, + ) + registry = FakeRegistry() + flow = RentalFlowHarness(state=state, registry=registry) + + response = await flow._try_collect_and_open_rental( + message="20/03/2026 10:00 ate 23/03/2026 10:00", + user_id=21, + extracted_fields={}, + intents={}, + turn_decision={"intent": "rental_create", "domain": "rental", "action": "answer_user"}, + ) + + self.assertEqual(registry.calls[0][0], "abrir_locacao_aluguel") + self.assertEqual(registry.calls[0][1]["rental_vehicle_id"], 1) + self.assertEqual(registry.calls[0][1]["data_inicio"], "20/03/2026 10:00") + self.assertEqual(registry.calls[0][1]["data_fim_prevista"], "23/03/2026 10:00") + self.assertIsNone(state.get_entry("pending_rental_drafts", 21)) + self.assertIn("LOC-TESTE-123", response) + + class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase): async def test_review_flow_extracts_relative_datetime_from_followup_message(self): fixed_now = lambda: datetime(2026, 3, 12, 9, 0) @@ -2920,3 +3102,6 @@ class ToolRegistryExecutionTests(unittest.IsolatedAsyncioTestCase): if __name__ == "__main__": unittest.main() + + + diff --git a/tests/test_inventory_service.py b/tests/test_inventory_service.py new file mode 100644 index 0000000..08301bb --- /dev/null +++ b/tests/test_inventory_service.py @@ -0,0 +1,65 @@ +import unittest +from unittest.mock import patch + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from app.db.mock_database import MockBase +from app.db.mock_models import Order, RentalVehicle, Vehicle +from app.services.domain.inventory_service import consultar_estoque + + +class InventoryServiceIsolationTests(unittest.IsolatedAsyncioTestCase): + def _build_session_local(self): + engine = create_engine("sqlite:///:memory:") + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + MockBase.metadata.create_all(bind=engine) + self.addCleanup(engine.dispose) + return SessionLocal + + async def test_consultar_estoque_uses_only_sales_fleet(self): + SessionLocal = self._build_session_local() + db = SessionLocal() + try: + reserved_sale_vehicle = Vehicle( + modelo="Toyota Corolla 2024", + categoria="sedan", + preco=76087.0, + ) + available_sale_vehicle = Vehicle( + modelo="Renault Duster 2022", + categoria="suv", + preco=73666.0, + ) + rental_vehicle = RentalVehicle( + placa="RAA1A01", + modelo="Chevrolet Tracker", + categoria="suv", + ano=2024, + valor_diaria=219.90, + status="disponivel", + ) + db.add_all([reserved_sale_vehicle, available_sale_vehicle, rental_vehicle]) + db.flush() + db.add( + Order( + numero_pedido="PED-TESTE-0001", + cpf="12345678909", + vehicle_id=reserved_sale_vehicle.id, + modelo_veiculo=reserved_sale_vehicle.modelo, + valor_veiculo=reserved_sale_vehicle.preco, + status="Ativo", + ) + ) + db.commit() + finally: + db.close() + + with patch("app.services.domain.inventory_service.SessionMockLocal", SessionLocal): + result = await consultar_estoque(preco_max=80000) + + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["modelo"], "Renault Duster 2022") + self.assertEqual(result[0]["categoria"], "suv") + self.assertTrue(all(item["modelo"] != "Chevrolet Tracker" for item in result)) + diff --git a/tests/test_rental_seed.py b/tests/test_rental_seed.py new file mode 100644 index 0000000..beefb9a --- /dev/null +++ b/tests/test_rental_seed.py @@ -0,0 +1,44 @@ +import unittest +from unittest.mock import patch + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from app.db import mock_seed as mock_seed_module +from app.db.mock_database import MockBase +from app.db.mock_models import RentalVehicle, Vehicle + + +class RentalSeedTests(unittest.TestCase): + def _build_session_local(self): + engine = create_engine("sqlite:///:memory:") + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + MockBase.metadata.create_all(bind=engine) + self.addCleanup(engine.dispose) + return SessionLocal + + def test_seed_mock_data_creates_independent_sales_and_rental_fleets(self): + SessionLocal = self._build_session_local() + + with patch("app.db.mock_seed.SessionMockLocal", SessionLocal), patch.object( + mock_seed_module.settings, + "mock_seed_enabled", + True, + ): + mock_seed_module.seed_mock_data() + + db = SessionLocal() + try: + self.assertEqual(db.query(Vehicle).count(), mock_seed_module.TARGET_VEHICLE_COUNT) + self.assertEqual(db.query(RentalVehicle).count(), len(mock_seed_module.RENTAL_FLEET)) + self.assertGreater( + db.query(RentalVehicle).filter(RentalVehicle.status == "disponivel").count(), + 0, + ) + self.assertGreater( + db.query(RentalVehicle).filter(RentalVehicle.status == "alugado").count(), + 0, + ) + finally: + db.close() + diff --git a/tests/test_rental_service.py b/tests/test_rental_service.py new file mode 100644 index 0000000..9249457 --- /dev/null +++ b/tests/test_rental_service.py @@ -0,0 +1,251 @@ +import unittest +from datetime import datetime +from unittest.mock import patch + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from app.db.mock_database import MockBase +from app.db.mock_models import RentalContract, RentalFine, RentalPayment, RentalVehicle +from app.services.domain import rental_service + + +class RentalServiceTests(unittest.IsolatedAsyncioTestCase): + def _build_session_local(self): + engine = create_engine("sqlite:///:memory:") + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + MockBase.metadata.create_all(bind=engine) + self.addCleanup(engine.dispose) + return SessionLocal + + def _create_rental_vehicle( + self, + db, + *, + placa: str = "ABC1D23", + modelo: str = "Chevrolet Tracker", + categoria: str = "suv", + ano: int = 2024, + valor_diaria: float = 219.9, + status: str = "disponivel", + ) -> RentalVehicle: + vehicle = RentalVehicle( + placa=placa, + modelo=modelo, + categoria=categoria, + ano=ano, + valor_diaria=valor_diaria, + status=status, + ) + db.add(vehicle) + db.commit() + db.refresh(vehicle) + return vehicle + + def _create_rental_contract( + self, + db, + vehicle: RentalVehicle, + *, + contrato_numero: str = "LOC-20260317-TESTE000", + user_id: int | None = None, + status: str = "ativa", + data_inicio: datetime | None = None, + data_fim_prevista: datetime | None = None, + ) -> RentalContract: + contract = RentalContract( + contrato_numero=contrato_numero, + user_id=user_id, + rental_vehicle_id=vehicle.id, + placa=vehicle.placa, + modelo_veiculo=vehicle.modelo, + categoria=vehicle.categoria, + data_inicio=data_inicio or datetime(2026, 3, 17, 10, 0), + data_fim_prevista=data_fim_prevista or datetime(2026, 3, 20, 10, 0), + valor_diaria=float(vehicle.valor_diaria), + valor_previsto=round(float(vehicle.valor_diaria) * 3, 2), + status=status, + ) + db.add(contract) + db.commit() + db.refresh(contract) + return contract + + async def test_consultar_frota_aluguel_retains_only_available_by_default(self): + SessionLocal = self._build_session_local() + db = SessionLocal() + try: + self._create_rental_vehicle(db, placa="AAA1A11", modelo="Chevrolet Tracker", status="disponivel") + self._create_rental_vehicle(db, placa="BBB2B22", modelo="Fiat Toro", categoria="pickup", status="alugado") + self._create_rental_vehicle(db, placa="CCC3C33", modelo="Jeep Renegade", status="manutencao") + finally: + db.close() + + with patch("app.services.domain.rental_service.SessionMockLocal", SessionLocal): + result = await rental_service.consultar_frota_aluguel(valor_diaria_max=300) + + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["placa"], "AAA1A11") + self.assertEqual(result[0]["status"], "disponivel") + + async def test_abrir_locacao_aluguel_cria_contrato_e_marca_veiculo_como_alugado(self): + SessionLocal = self._build_session_local() + db = SessionLocal() + try: + vehicle = self._create_rental_vehicle(db) + vehicle_id = vehicle.id + vehicle_placa = vehicle.placa + finally: + db.close() + + with patch("app.services.domain.rental_service.SessionMockLocal", SessionLocal): + result = await rental_service.abrir_locacao_aluguel( + placa=vehicle_placa, + data_inicio="17/03/2026 10:00", + data_fim_prevista="20/03/2026 10:00", + ) + + db = SessionLocal() + try: + stored_contract = db.query(RentalContract).one() + stored_vehicle = db.query(RentalVehicle).filter(RentalVehicle.id == vehicle_id).one() + self.assertEqual(stored_contract.placa, vehicle_placa) + self.assertEqual(stored_contract.status, "ativa") + self.assertEqual(stored_vehicle.status, "alugado") + self.assertEqual(result["status"], "ativa") + self.assertEqual(result["status_veiculo"], "alugado") + finally: + db.close() + + async def test_registrar_devolucao_aluguel_fecha_contrato_e_libera_veiculo(self): + SessionLocal = self._build_session_local() + db = SessionLocal() + try: + vehicle = self._create_rental_vehicle(db, status="alugado") + vehicle_id = vehicle.id + vehicle_diaria = float(vehicle.valor_diaria) + contract = self._create_rental_contract(db, vehicle) + contract_number = contract.contrato_numero + finally: + db.close() + + with patch("app.services.domain.rental_service.SessionMockLocal", SessionLocal): + result = await rental_service.registrar_devolucao_aluguel( + contrato_numero=contract_number, + data_devolucao="21/03/2026 09:00", + ) + + db = SessionLocal() + try: + stored_contract = db.query(RentalContract).one() + stored_vehicle = db.query(RentalVehicle).filter(RentalVehicle.id == vehicle_id).one() + self.assertEqual(stored_contract.status, "encerrada") + self.assertEqual(stored_vehicle.status, "disponivel") + self.assertEqual(result["status"], "encerrada") + self.assertEqual(result["status_veiculo"], "disponivel") + self.assertEqual(result["valor_final"], round(vehicle_diaria * 4, 2)) + finally: + db.close() + + async def test_registrar_pagamento_aluguel_persiste_registro(self): + SessionLocal = self._build_session_local() + + with patch("app.services.domain.rental_service.SessionMockLocal", SessionLocal): + result = await rental_service.registrar_pagamento_aluguel( + contrato_numero="loc-123", + placa="abc1234", + valor=1540.5, + data_pagamento="17/03/2026 14:30", + favorecido="Locadora XPTO", + identificador_comprovante="NSU123", + user_id=9, + ) + + db = SessionLocal() + try: + stored = db.query(RentalPayment).one() + self.assertEqual(stored.contrato_numero, "LOC-123") + self.assertEqual(stored.placa, "ABC1234") + self.assertEqual(float(stored.valor), 1540.5) + self.assertEqual(result["status"], "registrado") + finally: + db.close() + + async def test_registrar_pagamento_aluguel_vincula_unica_locacao_ativa_do_usuario(self): + SessionLocal = self._build_session_local() + db = SessionLocal() + try: + vehicle = self._create_rental_vehicle(db, status="alugado") + vehicle_placa = vehicle.placa + contract = self._create_rental_contract(db, vehicle, user_id=9) + finally: + db.close() + + with patch("app.services.domain.rental_service.SessionMockLocal", SessionLocal): + result = await rental_service.registrar_pagamento_aluguel( + valor=879.90, + user_id=9, + ) + + db = SessionLocal() + try: + stored = db.query(RentalPayment).one() + self.assertEqual(stored.rental_contract_id, contract.id) + self.assertEqual(stored.contrato_numero, contract.contrato_numero) + self.assertEqual(stored.placa, vehicle_placa) + self.assertEqual(result["contrato_numero"], contract.contrato_numero) + finally: + db.close() + + async def test_registrar_multa_aluguel_persiste_registro(self): + SessionLocal = self._build_session_local() + + with patch("app.services.domain.rental_service.SessionMockLocal", SessionLocal): + result = await rental_service.registrar_multa_aluguel( + placa="abc1d23", + auto_infracao="A123456", + valor=293.47, + data_infracao="17/03/2026", + vencimento="10/04/2026", + orgao_emissor="DETRAN-SP", + user_id=11, + ) + + db = SessionLocal() + try: + stored = db.query(RentalFine).one() + self.assertEqual(stored.placa, "ABC1D23") + self.assertEqual(stored.auto_infracao, "A123456") + self.assertEqual(result["status"], "registrada") + finally: + db.close() + + async def test_registrar_multa_aluguel_vincula_contrato_ativo_pela_placa(self): + SessionLocal = self._build_session_local() + db = SessionLocal() + try: + vehicle = self._create_rental_vehicle(db, placa="ABC1D23", status="alugado") + contract = self._create_rental_contract(db, vehicle, user_id=11) + finally: + db.close() + + with patch("app.services.domain.rental_service.SessionMockLocal", SessionLocal): + result = await rental_service.registrar_multa_aluguel( + placa="ABC1D23", + auto_infracao="A123456", + valor=293.47, + user_id=11, + ) + + db = SessionLocal() + try: + stored = db.query(RentalFine).one() + self.assertEqual(stored.rental_contract_id, contract.id) + self.assertEqual(stored.contrato_numero, contract.contrato_numero) + self.assertEqual(result["contrato_numero"], contract.contrato_numero) + finally: + db.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_telegram_multimodal.py b/tests/test_telegram_multimodal.py new file mode 100644 index 0000000..edfdecb --- /dev/null +++ b/tests/test_telegram_multimodal.py @@ -0,0 +1,71 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from app.integrations.telegram_satellite_service import TelegramSatelliteService + + +class _DummySession: + def close(self): + return None + + +class TelegramMultimodalTests(unittest.IsolatedAsyncioTestCase): + async def test_process_message_uses_extracted_image_message(self): + service = TelegramSatelliteService("token-teste") + tools_db = _DummySession() + mock_db = _DummySession() + + with patch("app.integrations.telegram_satellite_service.SessionLocal", return_value=tools_db), patch( + "app.integrations.telegram_satellite_service.SessionMockLocal", + return_value=mock_db, + ), patch("app.integrations.telegram_satellite_service.UserService") as user_service_cls, patch( + "app.integrations.telegram_satellite_service.OrquestradorService" + ) as orchestrator_cls, patch.object( + service, + "_build_orchestration_message_from_image", + AsyncMock(return_value="[imagem recebida no telegram]\nDados extraidos da imagem: Registrar multa de aluguel: placa ABC1D23; valor 293,47; auto_infracao A123456."), + ): + user_service_cls.return_value.get_or_create.return_value = SimpleNamespace(id=7) + orchestrator_cls.return_value.handle_message = AsyncMock(return_value="ok") + + answer = await service._process_message( + text="segue a multa", + sender={"id": 99, "first_name": "Vitor"}, + chat_id=99, + image_attachments=[{"mime_type": "image/jpeg", "data": b"123"}], + ) + + self.assertEqual(answer, "ok") + orchestrator_cls.return_value.handle_message.assert_awaited_once() + kwargs = orchestrator_cls.return_value.handle_message.await_args.kwargs + self.assertIn("Registrar multa de aluguel", kwargs["message"]) + self.assertEqual(kwargs["user_id"], 7) + + async def test_process_message_returns_direct_failure_for_unreadable_image(self): + service = TelegramSatelliteService("token-teste") + tools_db = _DummySession() + mock_db = _DummySession() + + with patch("app.integrations.telegram_satellite_service.SessionLocal", return_value=tools_db), patch( + "app.integrations.telegram_satellite_service.SessionMockLocal", + return_value=mock_db, + ), patch("app.integrations.telegram_satellite_service.UserService") as user_service_cls, patch( + "app.integrations.telegram_satellite_service.OrquestradorService" + ) as orchestrator_cls, patch.object( + service, + "_build_orchestration_message_from_image", + AsyncMock(return_value="Nao consegui identificar os dados da imagem. Descreva o documento ou envie uma foto mais nitida."), + ): + user_service_cls.return_value.get_or_create.return_value = SimpleNamespace(id=7) + orchestrator_cls.return_value.handle_message = AsyncMock() + + answer = await service._process_message( + text="", + sender={"id": 99}, + chat_id=99, + image_attachments=[{"mime_type": "image/jpeg", "data": b"123"}], + ) + + self.assertIn("Nao consegui identificar os dados da imagem", answer) + self.assertFalse(orchestrator_cls.return_value.handle_message.await_count) diff --git a/tests/test_turn_decision_contract.py b/tests/test_turn_decision_contract.py index 5616bea..b5ecea0 100644 --- a/tests/test_turn_decision_contract.py +++ b/tests/test_turn_decision_contract.py @@ -11,6 +11,7 @@ from app.services.orchestration.conversation_policy import ConversationPolicy from app.services.orchestration.entity_normalizer import EntityNormalizer from app.services.orchestration.message_planner import MessagePlanner from app.services.orchestration.orquestrador_service import OrquestradorService +from app.services.orchestration.tool_executor import ToolExecutor class FakeLLM: @@ -80,6 +81,16 @@ class FakeToolExecutor: } +class StaticToolRegistry: + def __init__(self, result=None): + self.result = result if result is not None else {"ok": True} + self.calls = [] + + async def execute(self, tool_name: str, arguments: dict, user_id: int | None = None): + self.calls.append((tool_name, arguments, user_id)) + return self.result + + class FakePolicyService: def __init__(self, state): self.state = state @@ -149,7 +160,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): "action": "ask_missing_fields", "entities": { "generic_memory": {}, - "review_fields": {"placa": "abc1234", "data_hora": "10/03/2026 às 09:00"}, + "review_fields": {"placa": "abc1234", "data_hora": "10/03/2026 \u00e0s 09:00"}, "review_management_fields": {}, "order_fields": {}, "cancel_order_fields": {} @@ -166,14 +177,14 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): ) planner = MessagePlanner(llm=llm, normalizer=EntityNormalizer()) - decision = await planner.extract_turn_decision("Quero agendar revisão amanhã às 09:00", user_id=7) + decision = await planner.extract_turn_decision("Quero agendar revis\u00e3o amanh\u00e3 \u00e0s 09:00", user_id=7) self.assertEqual(llm.calls, 2) self.assertEqual(decision["intent"], "review_schedule") self.assertEqual(decision["domain"], "review") self.assertEqual(decision["action"], "ask_missing_fields") self.assertEqual(decision["entities"]["review_fields"]["placa"], "ABC1234") - self.assertEqual(decision["entities"]["review_fields"]["data_hora"], "10/03/2026 às 09:00") + self.assertEqual(decision["entities"]["review_fields"]["data_hora"], "10/03/2026 \u00e0s 09:00") self.assertEqual(decision["missing_fields"], ["modelo", "ano", "km"]) def test_parse_json_object_accepts_python_style_dict_with_trailing_commas(self): @@ -972,6 +983,52 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(response, "Pedido PED-1 atualizado.\nStatus: Cancelado") self.assertEqual(service.llm.calls, 0) + async def test_turn_decision_rental_fleet_listing_uses_deterministic_response_without_result_llm(self): + registry = StaticToolRegistry( + result=[ + {"id": 1, "placa": "RAA1A01", "modelo": "Chevrolet Tracker", "categoria": "suv", "ano": 2024, "valor_diaria": 219.9, "status": "disponivel"}, + {"id": 2, "placa": "RAA1A02", "modelo": "Fiat Pulse", "categoria": "suv", "ano": 2024, "valor_diaria": 189.9, "status": "disponivel"}, + ] + ) + service = OrquestradorService.__new__(OrquestradorService) + service.state = FakeState() + service.normalizer = EntityNormalizer() + service.tool_executor = ToolExecutor(registry=registry) + service.llm = FakeLLM([]) + service._capture_review_confirmation_suggestion = lambda **kwargs: None + service._capture_tool_result_context = lambda **kwargs: None + service._capture_tool_invocation_trace = lambda **kwargs: None + service._log_turn_event = lambda *args, **kwargs: None + + async def fake_render_tool_response_with_fallback(**kwargs): + raise AssertionError("nao deveria usar llm para listagem de locacao") + + service._render_tool_response_with_fallback = fake_render_tool_response_with_fallback + service._http_exception_detail = lambda exc: str(exc) + service._is_low_value_response = lambda text: False + + async def finish(response: str, queue_notice: str | None = None) -> str: + return response if not queue_notice else f"{queue_notice}\n{response}" + + response = await service._try_execute_business_tool_from_turn_decision( + message="quais carros estao disponiveis para aluguel", + user_id=7, + turn_decision={ + "action": "call_tool", + "tool_name": "consultar_frota_aluguel", + "tool_arguments": {"valor_diaria_max": 220}, + }, + queue_notice=None, + finish=finish, + ) + + self.assertEqual(registry.calls[0][0], "consultar_frota_aluguel") + self.assertEqual(registry.calls[0][2], 7) + self.assertIn("Encontrei 2 veiculo(s) para locacao:", response) + self.assertIn("RAA1A01", response) + self.assertIn("numero da lista, a placa ou o modelo", response) + self.assertEqual(service.llm.calls, 0) + async def test_confirm_pending_review_clears_open_review_draft_after_suggested_time_success(self): state = FakeState( entries={ @@ -2156,6 +2213,82 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): self.assertIn("CPF do cliente", response) + async def test_handle_message_short_circuits_llm_when_pending_rental_selection_matches_list_choice(self): + state = FakeState( + entries={ + "pending_rental_selections": { + 1: { + "payload": [ + {"id": 1, "placa": "RAA1A01", "modelo": "Chevrolet Tracker", "categoria": "suv", "ano": 2024, "valor_diaria": 219.9, "status": "disponivel"}, + {"id": 2, "placa": "RAA1A02", "modelo": "Fiat Pulse", "categoria": "suv", "ano": 2024, "valor_diaria": 189.9, "status": "disponivel"}, + ], + "expires_at": utc_now() + timedelta(minutes=15), + } + } + }, + contexts={ + 1: { + "active_domain": "rental", + "generic_memory": {}, + "shared_memory": {}, + "order_queue": [], + "pending_order_selection": None, + "pending_switch": None, + "last_stock_results": [], + "selected_vehicle": None, + "last_rental_results": [], + "selected_rental_vehicle": None, + } + } + ) + service = OrquestradorService.__new__(OrquestradorService) + service.state = state + service.normalizer = EntityNormalizer() + service.policy = ConversationPolicy(service=service) + service._empty_extraction_payload = service.normalizer.empty_extraction_payload + service._log_turn_event = lambda *args, **kwargs: None + service._compose_order_aware_response = lambda response, user_id, queue_notice=None: response + service._get_user_context = lambda user_id: state.get_user_context(user_id) + service._save_user_context = lambda user_id, context: state.save_user_context(user_id, context) + + async def fake_maybe_auto_advance_next_order(base_response: str, user_id: int | None): + return base_response + + service._maybe_auto_advance_next_order = fake_maybe_auto_advance_next_order + service._upsert_user_context = lambda user_id: None + + async def fake_extract_turn_decision(message: str, user_id: int | None): + raise AssertionError("nao deveria consultar o LLM para selecao pendente de locacao") + + service._extract_turn_decision_with_llm = fake_extract_turn_decision + + async def fake_try_handle_immediate_context_reset(**kwargs): + return None + + service._try_handle_immediate_context_reset = fake_try_handle_immediate_context_reset + + async def fake_try_resolve_pending_order_selection(**kwargs): + return None + + service._try_resolve_pending_order_selection = fake_try_resolve_pending_order_selection + + async def fake_try_continue_queued_order(**kwargs): + return None + + service._try_continue_queued_order = fake_try_continue_queued_order + + async def fake_try_collect_and_open_rental(**kwargs): + return "Para abrir a locacao, preciso dos dados abaixo:\n- a data e hora de inicio da locacao" + + service._try_collect_and_open_rental = fake_try_collect_and_open_rental + + response = await service.handle_message( + "1", + user_id=1, + ) + + self.assertIn("inicio da locacao", response) + async def test_handle_message_keeps_sales_flow_when_cpf_follow_up_is_misclassified_as_review(self): state = FakeState( entries={