🚗 feat(sales): listar estoque pelo contexto e reservar veiculos no pedido

Passa a aproveitar orcamento e perfil guardados na conversa para sugerir estoque quando a compra ainda nao tem veiculo definido, preservando a selecao na memoria e guiando o usuario ate o fechamento do pedido.

Tambem impede que veiculos reservados continuem aparecendo como disponiveis, devolve o status do veiculo na resposta deterministica do pedido e amplia os testes de regressao dos fluxos de compra e cancelamento.
main
parent 8cf79174ee
commit 82fc846e01

@ -5,6 +5,7 @@ from fastapi import HTTPException
from app.db.mock_database import SessionMockLocal
from app.db.mock_models import User, Vehicle
from app.services.orchestration.technical_normalizer import is_valid_cpf
from app.services.orchestration.orchestrator_config import (
CANCEL_ORDER_REQUIRED_FIELDS,
ORDER_REQUIRED_FIELDS,
@ -14,7 +15,12 @@ from app.services.orchestration.orchestrator_config import (
from app.services.user.mock_customer_service import hydrate_mock_customer_from_cpf
# Esse mixin cuida dos fluxos de venda:
# criacao de pedido, selecao de veiculo e cancelamento.
class OrderFlowMixin:
def _decision_intent(self, turn_decision: dict | None) -> str:
return str((turn_decision or {}).get("intent") or "").strip().lower()
def _has_explicit_order_request(self, message: str) -> bool:
normalized = self._normalize_text(message).strip()
order_terms = {
@ -30,25 +36,25 @@ class OrderFlowMixin:
}
return any(term in normalized for term in order_terms)
def _is_valid_cpf(self, cpf: str) -> bool:
digits = re.sub(r"\D", "", cpf or "")
if len(digits) != 11:
return False
if digits == digits[0] * 11:
return False
numbers = [int(d) for d in digits]
sum_first = sum(n * w for n, w in zip(numbers[:9], range(10, 1, -1)))
first_digit = 11 - (sum_first % 11)
first_digit = 0 if first_digit >= 10 else first_digit
if first_digit != numbers[9]:
return False
def _has_stock_listing_request(self, message: str, turn_decision: dict | None = None) -> bool:
if self._decision_intent(turn_decision) == "inventory_search":
return True
normalized = self._normalize_text(message).strip()
stock_terms = {
"estoque",
"listar",
"liste",
"mostrar",
"mostre",
"ver carros",
"ver veiculos",
"opcoes",
"modelos",
}
return any(term in normalized for term in stock_terms)
sum_second = sum(n * w for n, w in zip(numbers[:10], range(11, 1, -1)))
second_digit = 11 - (sum_second % 11)
second_digit = 0 if second_digit >= 10 else second_digit
return second_digit == numbers[10]
def _is_valid_cpf(self, cpf: str) -> bool:
return is_valid_cpf(cpf)
def _try_prefill_order_cpf_from_memory(self, user_id: int | None, payload: dict) -> None:
if user_id is None or payload.get("cpf"):
@ -88,6 +94,31 @@ class OrderFlowMixin:
selected_vehicle = context.get("selected_vehicle")
return dict(selected_vehicle) if isinstance(selected_vehicle, dict) else None
def _remember_stock_results(self, user_id: int | None, stock_results: list[dict] | None) -> None:
context = self._get_user_context(user_id)
if not context:
return
sanitized: list[dict] = []
for item in stock_results or []:
if not isinstance(item, dict):
continue
try:
vehicle_id = int(item.get("id"))
preco = float(item.get("preco") or 0)
except (TypeError, ValueError):
continue
sanitized.append(
{
"id": vehicle_id,
"modelo": str(item.get("modelo") or "").strip(),
"categoria": str(item.get("categoria") or "").strip(),
"preco": preco,
}
)
context["last_stock_results"] = sanitized
if sanitized:
context["selected_vehicle"] = None
def _store_selected_vehicle(self, user_id: int | None, vehicle: dict | None) -> None:
if user_id is None:
return
@ -110,6 +141,26 @@ class OrderFlowMixin:
if selected_vehicle:
payload.update(self._vehicle_to_payload(selected_vehicle))
def _build_stock_lookup_arguments(self, user_id: int | None, payload: dict | None = None) -> dict:
context = self._get_user_context(user_id)
generic_memory = context.get("generic_memory", {}) if isinstance(context, dict) else {}
source = payload if isinstance(payload, dict) else {}
budget = generic_memory.get("orcamento_max")
if budget is None:
budget = source.get("valor_veiculo")
arguments: dict = {}
if isinstance(budget, (int, float)) and float(budget) > 0:
arguments["preco_max"] = float(budget)
perfil = generic_memory.get("perfil_veiculo")
if isinstance(perfil, list) and perfil:
arguments["categoria"] = str(perfil[0]).strip().lower()
arguments["limite"] = 5
arguments["ordenar_preco"] = "asc"
return arguments
def _match_vehicle_from_message_index(self, message: str, stock_results: list[dict]) -> dict | None:
tokens = [token for token in re.findall(r"\d+", str(message or "")) if token.isdigit()]
if not tokens:
@ -146,6 +197,8 @@ class OrderFlowMixin:
db.close()
def _try_resolve_order_vehicle(self, message: str, user_id: int | None, payload: dict) -> dict | None:
# Primeiro tenta um vehicle_id explicito; depois tenta casar
# a resposta do usuario com a ultima lista de estoque mostrada.
vehicle_id = payload.get("vehicle_id")
if isinstance(vehicle_id, int) and vehicle_id > 0:
return self._load_vehicle_by_id(vehicle_id)
@ -189,6 +242,32 @@ class OrderFlowMixin:
lines.append("Pode responder com o numero da lista ou com o modelo do veiculo.")
return "\n".join(lines)
async def _try_list_stock_for_order_selection(
self,
message: str,
user_id: int | None,
payload: dict,
turn_decision: dict | None = None,
) -> str | None:
if user_id is None or not self._has_stock_listing_request(message, turn_decision=turn_decision):
return None
arguments = self._build_stock_lookup_arguments(user_id=user_id, payload=payload)
if "preco_max" not in arguments and "categoria" not in arguments:
return None
try:
tool_result = await self.registry.execute(
"consultar_estoque",
arguments,
user_id=user_id,
)
except HTTPException as exc:
return self._http_exception_detail(exc)
self._remember_stock_results(user_id=user_id, stock_results=tool_result if isinstance(tool_result, list) else [])
return self._fallback_format_tool_result("consultar_estoque", tool_result)
def _render_missing_cancel_order_fields_prompt(self, missing_fields: list[str]) -> str:
labels = {
"numero_pedido": "o numero do pedido (ex.: PED-20260305123456-ABC123)",
@ -203,6 +282,7 @@ class OrderFlowMixin:
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
@ -211,14 +291,22 @@ class OrderFlowMixin:
draft = self.state.get_entry("pending_order_drafts", user_id, expire=True)
extracted = self._normalize_order_fields(extracted_fields)
has_intent = normalized_intents.get("order_create", False)
decision_intent = self._decision_intent(turn_decision)
has_intent = decision_intent == "order_create" or normalized_intents.get("order_create", False)
explicit_order_request = self._has_explicit_order_request(message)
if (
draft
and not has_intent
and (
normalized_intents.get("review_schedule", False)
decision_intent in {
"review_schedule",
"review_list",
"review_cancel",
"review_reschedule",
"order_cancel",
}
or normalized_intents.get("review_schedule", False)
or normalized_intents.get("review_list", False)
or normalized_intents.get("review_cancel", False)
or normalized_intents.get("review_reschedule", False)
@ -229,7 +317,7 @@ class OrderFlowMixin:
self.state.pop_entry("pending_order_drafts", user_id)
return None
if draft is None and not (has_intent and explicit_order_request):
if draft is None and not has_intent and not explicit_order_request:
return None
if draft is None:
@ -248,6 +336,8 @@ class OrderFlowMixin:
payload=draft["payload"],
)
if resolved_vehicle:
# Mantem a selecao no estado para que o usuario informe
# o CPF depois sem perder o veiculo escolhido.
self._store_selected_vehicle(user_id=user_id, vehicle=resolved_vehicle)
draft["payload"].update(self._vehicle_to_payload(resolved_vehicle))
@ -273,6 +363,14 @@ class OrderFlowMixin:
missing = [field for field in ORDER_REQUIRED_FIELDS if field not in draft["payload"]]
if missing:
if "vehicle_id" in missing:
stock_response = await self._try_list_stock_for_order_selection(
message=message,
user_id=user_id,
payload=draft["payload"],
turn_decision=turn_decision,
)
if stock_response:
return stock_response
stock_results = self._get_last_stock_results(user_id=user_id)
if stock_results:
return self._render_vehicle_selection_from_stock_prompt(stock_results)
@ -299,6 +397,7 @@ class OrderFlowMixin:
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
@ -308,7 +407,8 @@ class OrderFlowMixin:
active_order_draft = self.state.get_entry("pending_order_drafts", user_id, expire=True)
extracted = self._normalize_cancel_order_fields(extracted_fields)
has_intent = normalized_intents.get("order_cancel", False)
decision_intent = self._decision_intent(turn_decision)
has_intent = decision_intent == "order_cancel" or normalized_intents.get("order_cancel", False)
if (
draft is None
@ -324,7 +424,14 @@ class OrderFlowMixin:
draft
and not has_intent
and (
normalized_intents.get("review_schedule", False)
decision_intent in {
"review_schedule",
"review_list",
"review_cancel",
"review_reschedule",
"order_create",
}
or normalized_intents.get("review_schedule", False)
or normalized_intents.get("review_list", False)
or normalized_intents.get("review_cancel", False)
or normalized_intents.get("review_reschedule", False)
@ -349,6 +456,8 @@ class OrderFlowMixin:
and draft["payload"].get("numero_pedido")
and not has_intent
):
# Quando o pedido ja foi identificado, um texto livre curto
# e tratado como motivo do cancelamento.
free_text = (message or "").strip()
if free_text and len(free_text) >= 4:
extracted["motivo"] = free_text

@ -2,6 +2,8 @@ from datetime import datetime
from typing import Any
# Formatter deterministico usado como fallback seguro quando a resposta
# final nao deve depender do LLM.
def format_datetime_for_chat(value: str) -> str:
try:
dt = datetime.fromisoformat((value or "").replace("Z", "+00:00"))
@ -20,6 +22,7 @@ def format_currency_br(value: Any) -> str:
def fallback_format_tool_result(tool_name: str, tool_result: Any) -> str:
# Cada tool recebe um formato enxuto e previsivel para o usuario final.
if tool_name == "consultar_estoque" and isinstance(tool_result, list):
if not tool_result:
return "Nao encontrei nenhum veiculo com os criterios informados."
@ -48,7 +51,16 @@ def fallback_format_tool_result(tool_name: str, tool_result: Any) -> str:
numero = tool_result.get("numero_pedido", "N/A")
valor = format_currency_br(tool_result.get("valor_veiculo"))
modelo = tool_result.get("modelo_veiculo", "N/A")
return f"Pedido criado com sucesso.\nNumero: {numero}\nVeiculo: {modelo}\nValor: {valor}"
status_veiculo = str(tool_result.get("status_veiculo") or "").strip()
lines = [
"Pedido criado com sucesso.",
f"Numero: {numero}",
f"Veiculo: {modelo}",
f"Valor: {valor}",
]
if status_veiculo:
lines.append(f"Status do veiculo: {status_veiculo}")
return "\n".join(lines)
if tool_name == "agendar_revisao" and isinstance(tool_result, dict):
placa = tool_result.get("placa", "N/A")

@ -1,5 +1,6 @@
from datetime import datetime, timedelta, timezone
import hashlib
import logging
import re
from uuid import uuid4
from typing import Any, Dict, List, Optional
@ -11,15 +12,13 @@ from sqlalchemy.sql import text
from app.db.mock_database import SessionMockLocal
from app.db.mock_models import Customer, Order, ReviewSchedule, User, Vehicle
from app.services.orchestration.technical_normalizer import normalize_cpf
from app.services.user.mock_customer_service import hydrate_mock_customer_from_cpf
# Nesse arquivo eu faço a normalização dos dados para persisti-los no DB
# Tambem ficam as tools mock que simulam regras de negocio do dominio.
def normalize_cpf(value: str) -> str:
"""Normaliza CPF removendo qualquer caractere nao numerico."""
return re.sub(r"\D", "", value or "")
logger = logging.getLogger(__name__)
def _parse_float(value: Any, default: float = 0.0) -> float:
"""Converte entradas numericas/textuais para float com fallback padrao."""
@ -35,6 +34,17 @@ def _parse_float(value: Any, default: float = 0.0) -> float:
return default
def _is_legacy_schema_issue(exc: Exception) -> bool:
lowered = str(exc).lower()
return (
"unknown column" in lowered
or "invalid column" in lowered
or "has no column named" in lowered
or "no such column" in lowered
or "column count doesn't match" in lowered
)
def _stable_int(seed_text: str) -> int:
"""Gera inteiro deterministico a partir de um texto usando hash SHA-256."""
digest = hashlib.sha256(seed_text.encode("utf-8")).hexdigest()
@ -107,7 +117,27 @@ async def consultar_estoque(
"""Consulta veiculos no estoque com filtros opcionais e ordenacao por preco."""
db = SessionMockLocal()
try:
reserved_vehicle_ids = set()
try:
reserved_vehicle_ids = {
int(vehicle_id)
for (vehicle_id,) in (
db.query(Order.vehicle_id)
.filter(Order.vehicle_id.isnot(None))
.filter(Order.status != "Cancelado")
.all()
)
if vehicle_id is not None
}
except (OperationalError, SQLAlchemyError) as exc:
if not _is_legacy_schema_issue(exc):
raise
db.rollback()
logger.warning("Schema legado sem vehicle_id em orders; estoque nao filtrara reservas.")
query = db.query(Vehicle)
if reserved_vehicle_ids:
query = query.filter(~Vehicle.id.in_(reserved_vehicle_ids))
if preco_max is not None:
query = query.filter(Vehicle.preco <= preco_max)
@ -638,6 +668,25 @@ async def realizar_pedido(cpf: str, vehicle_id: int, user_id: Optional[int] = No
if not vehicle:
raise HTTPException(status_code=404, detail="Veiculo nao encontrado no estoque.")
existing_order = None
try:
existing_order = (
db.query(Order)
.filter(Order.vehicle_id == vehicle_id)
.filter(Order.status != "Cancelado")
.first()
)
except (OperationalError, SQLAlchemyError) as exc:
if not _is_legacy_schema_issue(exc):
raise
db.rollback()
logger.warning("Schema legado sem vehicle_id em orders; reserva exclusiva de veiculo desativada.")
if existing_order:
raise HTTPException(
status_code=409,
detail="Este veiculo ja esta reservado e nao aparece mais no estoque disponivel.",
)
valor_veiculo = float(vehicle.preco)
modelo_veiculo = str(vehicle.modelo)
@ -658,6 +707,8 @@ async def realizar_pedido(cpf: str, vehicle_id: int, user_id: Optional[int] = No
if user and user.cpf != cpf_norm:
user.cpf = cpf_norm
# Tenta gravar no schema novo; se a tabela ainda estiver
# no formato legado, cai para um insert minimo compativel.
pedido = Order(
numero_pedido=numero_pedido,
user_id=user_id,
@ -673,13 +724,7 @@ async def realizar_pedido(cpf: str, vehicle_id: int, user_id: Optional[int] = No
db.refresh(pedido)
except (OperationalError, SQLAlchemyError) as exc:
db.rollback()
lowered = str(exc).lower()
legacy_schema_issue = (
"unknown column" in lowered
or "invalid column" in lowered
or "has no column named" in lowered
or "column count doesn't match" in lowered
)
legacy_schema_issue = _is_legacy_schema_issue(exc)
if not legacy_schema_issue:
raise
@ -703,6 +748,7 @@ async def realizar_pedido(cpf: str, vehicle_id: int, user_id: Optional[int] = No
"vehicle_id": vehicle.id,
"modelo_veiculo": modelo_veiculo,
"status": "Ativo",
"status_veiculo": "Reservado",
"valor_veiculo": valor_veiculo,
"aprovado_credito": True,
}
@ -714,6 +760,7 @@ async def realizar_pedido(cpf: str, vehicle_id: int, user_id: Optional[int] = No
"vehicle_id": pedido.vehicle_id,
"modelo_veiculo": pedido.modelo_veiculo,
"status": pedido.status,
"status_veiculo": "Reservado",
"valor_veiculo": pedido.valor_veiculo,
"aprovado_credito": True,
}

@ -56,6 +56,11 @@ class FakeRegistry:
async def execute(self, tool_name: str, arguments: dict, user_id: int | None = None):
self.calls.append((tool_name, arguments, user_id))
if tool_name == "consultar_estoque":
return [
{"id": 1, "modelo": "Honda Civic 2021", "categoria": "sedan", "preco": 48500.0},
{"id": 2, "modelo": "Toyota Yaris 2020", "categoria": "hatch", "preco": 49900.0},
]
if tool_name == "realizar_pedido":
vehicle_map = {
1: ("Honda Civic 2021", 51524.0),
@ -100,6 +105,11 @@ class OrderFlowHarness(OrderFlowMixin):
return str(exc)
def _fallback_format_tool_result(self, tool_name: str, tool_result) -> str:
if tool_name == "consultar_estoque":
lines = [f"Encontrei {len(tool_result)} veiculo(s):"]
for idx, item in enumerate(tool_result, start=1):
lines.append(f"{idx}. [{item['id']}] {item['modelo']} ({item['categoria']}) - R$ {item['preco']:.2f}")
return "\n".join(lines)
if tool_name == "realizar_pedido":
return (
f"Pedido criado com sucesso.\n"
@ -156,6 +166,22 @@ class ConversationAdjustmentsTests(unittest.TestCase):
class CancelOrderFlowTests(unittest.IsolatedAsyncioTestCase):
async def test_cancel_order_flow_accepts_turn_decision_without_legacy_intents(self):
state = FakeState()
registry = FakeRegistry()
flow = OrderFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_cancel_order(
message="cancelar pedido PED-20260305120000-ABC123",
user_id=42,
extracted_fields={"numero_pedido": "PED-20260305120000-ABC123"},
intents={},
turn_decision={"intent": "order_cancel", "domain": "sales", "action": "collect_order_cancel"},
)
self.assertIn("o motivo do cancelamento", response)
self.assertIsNotNone(state.get_entry("pending_cancel_order_drafts", 42))
async def test_cancel_order_flow_consumes_free_text_reason(self):
state = FakeState(
entries={
@ -237,6 +263,138 @@ class CancelOrderFlowTests(unittest.IsolatedAsyncioTestCase):
class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase):
async def test_order_flow_auto_lists_stock_on_first_purchase_message_when_budget_exists(self):
state = FakeState(
contexts={
10: {
"generic_memory": {"cpf": "12345678909", "orcamento_max": 50000},
"last_stock_results": [],
"selected_vehicle": None,
}
}
)
registry = FakeRegistry()
flow = OrderFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_create_order(
message="Quero comprar um carro de 50 mil, meu CPF e 12345678909",
user_id=10,
extracted_fields={"cpf": "12345678909"},
intents={},
turn_decision={"intent": "order_create", "domain": "sales", "action": "collect_order_create"},
)
self.assertIn("qual veiculo do estoque voce quer comprar", response.lower())
async def test_order_flow_lists_stock_from_budget_when_vehicle_is_missing(self):
state = FakeState(
entries={
"pending_order_drafts": {
10: {
"payload": {"cpf": "12345678909"},
"expires_at": datetime.utcnow() + timedelta(minutes=30),
}
}
},
contexts={
10: {
"generic_memory": {"cpf": "12345678909", "orcamento_max": 50000},
"last_stock_results": [],
"selected_vehicle": None,
}
},
)
registry = FakeRegistry()
flow = OrderFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_create_order(
message="liste os carros com esse valor em estoque",
user_id=10,
extracted_fields={},
intents={},
turn_decision={"intent": "inventory_search", "domain": "sales", "action": "call_tool"},
)
self.assertEqual(registry.calls[0][0], "consultar_estoque")
self.assertIn("Encontrei 2 veiculo(s):", response)
self.assertIn("Honda Civic 2021", response)
self.assertEqual(len(flow._get_last_stock_results(user_id=10)), 2)
async def test_order_flow_accepts_turn_decision_without_legacy_intents(self):
state = FakeState(
contexts={
10: {
"generic_memory": {"cpf": "12345678909"},
"last_stock_results": [
{"id": 1, "modelo": "Honda Civic 2021", "categoria": "sedan", "preco": 51524.0},
],
"selected_vehicle": None,
}
}
)
registry = FakeRegistry()
flow = OrderFlowHarness(state=state, registry=registry)
async def fake_hydrate_mock_customer_from_cpf(cpf: str, user_id: int | None = None):
return {"cpf": cpf, "user_id": user_id}
with patch(
"app.services.flows.order_flow.hydrate_mock_customer_from_cpf",
new=fake_hydrate_mock_customer_from_cpf,
):
response = await flow._try_collect_and_create_order(
message="esse",
user_id=10,
extracted_fields={"vehicle_id": 1},
intents={},
turn_decision={"intent": "order_create", "domain": "sales", "action": "collect_order_create"},
)
self.assertEqual(len(registry.calls), 1)
tool_name, arguments, tool_user_id = registry.calls[0]
self.assertEqual(tool_name, "realizar_pedido")
self.assertEqual(tool_user_id, 10)
self.assertEqual(arguments["vehicle_id"], 1)
self.assertEqual(arguments["cpf"], "12345678909")
self.assertIn("Pedido criado com sucesso.", response)
async def test_order_flow_accepts_model_intent_without_keyword_trigger(self):
state = FakeState(
contexts={
10: {
"generic_memory": {"cpf": "12345678909"},
"last_stock_results": [
{"id": 1, "modelo": "Honda Civic 2021", "categoria": "sedan", "preco": 51524.0},
],
"selected_vehicle": None,
}
}
)
registry = FakeRegistry()
flow = OrderFlowHarness(state=state, registry=registry)
async def fake_hydrate_mock_customer_from_cpf(cpf: str, user_id: int | None = None):
return {"cpf": cpf, "user_id": user_id}
with patch(
"app.services.flows.order_flow.hydrate_mock_customer_from_cpf",
new=fake_hydrate_mock_customer_from_cpf,
):
response = await flow._try_collect_and_create_order(
message="esse",
user_id=10,
extracted_fields={"vehicle_id": 1},
intents={"order_create": True},
)
self.assertEqual(len(registry.calls), 1)
tool_name, arguments, tool_user_id = registry.calls[0]
self.assertEqual(tool_name, "realizar_pedido")
self.assertEqual(tool_user_id, 10)
self.assertEqual(arguments["vehicle_id"], 1)
self.assertEqual(arguments["cpf"], "12345678909")
self.assertIn("Pedido criado com sucesso.", response)
async def test_order_flow_requests_vehicle_selection_from_last_stock_results(self):
state = FakeState(
contexts={

Loading…
Cancel
Save