🚗 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_database import SessionMockLocal
from app.db.mock_models import User, Vehicle 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 ( from app.services.orchestration.orchestrator_config import (
CANCEL_ORDER_REQUIRED_FIELDS, CANCEL_ORDER_REQUIRED_FIELDS,
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 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: 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: def _has_explicit_order_request(self, message: str) -> bool:
normalized = self._normalize_text(message).strip() normalized = self._normalize_text(message).strip()
order_terms = { order_terms = {
@ -30,25 +36,25 @@ class OrderFlowMixin:
} }
return any(term in normalized for term in order_terms) return any(term in normalized for term in order_terms)
def _is_valid_cpf(self, cpf: str) -> bool: def _has_stock_listing_request(self, message: str, turn_decision: dict | None = None) -> bool:
digits = re.sub(r"\D", "", cpf or "") if self._decision_intent(turn_decision) == "inventory_search":
if len(digits) != 11: return True
return False normalized = self._normalize_text(message).strip()
if digits == digits[0] * 11: stock_terms = {
return False "estoque",
"listar",
numbers = [int(d) for d in digits] "liste",
"mostrar",
sum_first = sum(n * w for n, w in zip(numbers[:9], range(10, 1, -1))) "mostre",
first_digit = 11 - (sum_first % 11) "ver carros",
first_digit = 0 if first_digit >= 10 else first_digit "ver veiculos",
if first_digit != numbers[9]: "opcoes",
return False "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))) def _is_valid_cpf(self, cpf: str) -> bool:
second_digit = 11 - (sum_second % 11) return is_valid_cpf(cpf)
second_digit = 0 if second_digit >= 10 else second_digit
return second_digit == numbers[10]
def _try_prefill_order_cpf_from_memory(self, user_id: int | None, payload: dict) -> None: def _try_prefill_order_cpf_from_memory(self, user_id: int | None, payload: dict) -> None:
if user_id is None or payload.get("cpf"): if user_id is None or payload.get("cpf"):
@ -88,6 +94,31 @@ class OrderFlowMixin:
selected_vehicle = context.get("selected_vehicle") selected_vehicle = context.get("selected_vehicle")
return dict(selected_vehicle) if isinstance(selected_vehicle, dict) else None 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: def _store_selected_vehicle(self, user_id: int | None, vehicle: dict | None) -> None:
if user_id is None: if user_id is None:
return return
@ -110,6 +141,26 @@ class OrderFlowMixin:
if selected_vehicle: if selected_vehicle:
payload.update(self._vehicle_to_payload(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: 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()] tokens = [token for token in re.findall(r"\d+", str(message or "")) if token.isdigit()]
if not tokens: if not tokens:
@ -146,6 +197,8 @@ class OrderFlowMixin:
db.close() db.close()
def _try_resolve_order_vehicle(self, message: str, user_id: int | None, payload: dict) -> dict | None: 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") vehicle_id = payload.get("vehicle_id")
if isinstance(vehicle_id, int) and vehicle_id > 0: if isinstance(vehicle_id, int) and vehicle_id > 0:
return self._load_vehicle_by_id(vehicle_id) 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.") lines.append("Pode responder com o numero da lista ou com o modelo do veiculo.")
return "\n".join(lines) 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: def _render_missing_cancel_order_fields_prompt(self, missing_fields: list[str]) -> str:
labels = { labels = {
"numero_pedido": "o numero do pedido (ex.: PED-20260305123456-ABC123)", "numero_pedido": "o numero do pedido (ex.: PED-20260305123456-ABC123)",
@ -203,6 +282,7 @@ class OrderFlowMixin:
user_id: int | None, user_id: int | None,
extracted_fields: dict | None = None, extracted_fields: dict | None = None,
intents: dict | None = None, intents: dict | None = None,
turn_decision: dict | None = None,
) -> str | None: ) -> str | None:
if user_id is None: if user_id is None:
return None return None
@ -211,14 +291,22 @@ class OrderFlowMixin:
draft = self.state.get_entry("pending_order_drafts", user_id, expire=True) draft = self.state.get_entry("pending_order_drafts", user_id, expire=True)
extracted = self._normalize_order_fields(extracted_fields) 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) explicit_order_request = self._has_explicit_order_request(message)
if ( if (
draft draft
and not has_intent and not has_intent
and ( 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_list", False)
or normalized_intents.get("review_cancel", False) or normalized_intents.get("review_cancel", False)
or normalized_intents.get("review_reschedule", False) or normalized_intents.get("review_reschedule", False)
@ -229,7 +317,7 @@ class OrderFlowMixin:
self.state.pop_entry("pending_order_drafts", user_id) self.state.pop_entry("pending_order_drafts", user_id)
return None 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 return None
if draft is None: if draft is None:
@ -248,6 +336,8 @@ class OrderFlowMixin:
payload=draft["payload"], payload=draft["payload"],
) )
if resolved_vehicle: 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) self._store_selected_vehicle(user_id=user_id, vehicle=resolved_vehicle)
draft["payload"].update(self._vehicle_to_payload(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"]] missing = [field for field in ORDER_REQUIRED_FIELDS if field not in draft["payload"]]
if missing: if missing:
if "vehicle_id" in 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) stock_results = self._get_last_stock_results(user_id=user_id)
if stock_results: if stock_results:
return self._render_vehicle_selection_from_stock_prompt(stock_results) return self._render_vehicle_selection_from_stock_prompt(stock_results)
@ -299,6 +397,7 @@ class OrderFlowMixin:
user_id: int | None, user_id: int | None,
extracted_fields: dict | None = None, extracted_fields: dict | None = None,
intents: dict | None = None, intents: dict | None = None,
turn_decision: dict | None = None,
) -> str | None: ) -> str | None:
if user_id is None: if user_id is None:
return None return None
@ -308,7 +407,8 @@ class OrderFlowMixin:
active_order_draft = self.state.get_entry("pending_order_drafts", user_id, expire=True) active_order_draft = self.state.get_entry("pending_order_drafts", user_id, expire=True)
extracted = self._normalize_cancel_order_fields(extracted_fields) 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 ( if (
draft is None draft is None
@ -324,7 +424,14 @@ class OrderFlowMixin:
draft draft
and not has_intent and not has_intent
and ( 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_list", False)
or normalized_intents.get("review_cancel", False) or normalized_intents.get("review_cancel", False)
or normalized_intents.get("review_reschedule", False) or normalized_intents.get("review_reschedule", False)
@ -349,6 +456,8 @@ class OrderFlowMixin:
and draft["payload"].get("numero_pedido") and draft["payload"].get("numero_pedido")
and not has_intent 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() free_text = (message or "").strip()
if free_text and len(free_text) >= 4: if free_text and len(free_text) >= 4:
extracted["motivo"] = free_text extracted["motivo"] = free_text

@ -2,6 +2,8 @@ from datetime import datetime
from typing import Any 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: def format_datetime_for_chat(value: str) -> str:
try: try:
dt = datetime.fromisoformat((value or "").replace("Z", "+00:00")) 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: 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 tool_name == "consultar_estoque" and isinstance(tool_result, list):
if not tool_result: if not tool_result:
return "Nao encontrei nenhum veiculo com os criterios informados." 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") numero = tool_result.get("numero_pedido", "N/A")
valor = format_currency_br(tool_result.get("valor_veiculo")) valor = format_currency_br(tool_result.get("valor_veiculo"))
modelo = tool_result.get("modelo_veiculo", "N/A") 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): if tool_name == "agendar_revisao" and isinstance(tool_result, dict):
placa = tool_result.get("placa", "N/A") placa = tool_result.get("placa", "N/A")

@ -1,5 +1,6 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import hashlib import hashlib
import logging
import re import re
from uuid import uuid4 from uuid import uuid4
from typing import Any, Dict, List, Optional 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_database import SessionMockLocal
from app.db.mock_models import Customer, Order, ReviewSchedule, User, Vehicle 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 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 # 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.
logger = logging.getLogger(__name__)
def normalize_cpf(value: str) -> str:
"""Normaliza CPF removendo qualquer caractere nao numerico."""
return re.sub(r"\D", "", value or "")
def _parse_float(value: Any, default: float = 0.0) -> float: def _parse_float(value: Any, default: float = 0.0) -> float:
"""Converte entradas numericas/textuais para float com fallback padrao.""" """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 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: def _stable_int(seed_text: str) -> int:
"""Gera inteiro deterministico a partir de um texto usando hash SHA-256.""" """Gera inteiro deterministico a partir de um texto usando hash SHA-256."""
digest = hashlib.sha256(seed_text.encode("utf-8")).hexdigest() 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.""" """Consulta veiculos no estoque com filtros opcionais e ordenacao por preco."""
db = SessionMockLocal() db = SessionMockLocal()
try: 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) query = db.query(Vehicle)
if reserved_vehicle_ids:
query = query.filter(~Vehicle.id.in_(reserved_vehicle_ids))
if preco_max is not None: if preco_max is not None:
query = query.filter(Vehicle.preco <= preco_max) 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: if not vehicle:
raise HTTPException(status_code=404, detail="Veiculo nao encontrado no estoque.") 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) valor_veiculo = float(vehicle.preco)
modelo_veiculo = str(vehicle.modelo) 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: if user and user.cpf != cpf_norm:
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( pedido = Order(
numero_pedido=numero_pedido, numero_pedido=numero_pedido,
user_id=user_id, 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) db.refresh(pedido)
except (OperationalError, SQLAlchemyError) as exc: except (OperationalError, SQLAlchemyError) as exc:
db.rollback() db.rollback()
lowered = str(exc).lower() legacy_schema_issue = _is_legacy_schema_issue(exc)
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
)
if not legacy_schema_issue: if not legacy_schema_issue:
raise raise
@ -703,6 +748,7 @@ async def realizar_pedido(cpf: str, vehicle_id: int, user_id: Optional[int] = No
"vehicle_id": vehicle.id, "vehicle_id": vehicle.id,
"modelo_veiculo": modelo_veiculo, "modelo_veiculo": modelo_veiculo,
"status": "Ativo", "status": "Ativo",
"status_veiculo": "Reservado",
"valor_veiculo": valor_veiculo, "valor_veiculo": valor_veiculo,
"aprovado_credito": True, "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, "vehicle_id": pedido.vehicle_id,
"modelo_veiculo": pedido.modelo_veiculo, "modelo_veiculo": pedido.modelo_veiculo,
"status": pedido.status, "status": pedido.status,
"status_veiculo": "Reservado",
"valor_veiculo": pedido.valor_veiculo, "valor_veiculo": pedido.valor_veiculo,
"aprovado_credito": True, "aprovado_credito": True,
} }

@ -56,6 +56,11 @@ class FakeRegistry:
async def execute(self, tool_name: str, arguments: dict, user_id: int | None = None): async def execute(self, tool_name: str, arguments: dict, user_id: int | None = None):
self.calls.append((tool_name, arguments, user_id)) 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": if tool_name == "realizar_pedido":
vehicle_map = { vehicle_map = {
1: ("Honda Civic 2021", 51524.0), 1: ("Honda Civic 2021", 51524.0),
@ -100,6 +105,11 @@ class OrderFlowHarness(OrderFlowMixin):
return str(exc) return str(exc)
def _fallback_format_tool_result(self, tool_name: str, tool_result) -> str: 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": if tool_name == "realizar_pedido":
return ( return (
f"Pedido criado com sucesso.\n" f"Pedido criado com sucesso.\n"
@ -156,6 +166,22 @@ class ConversationAdjustmentsTests(unittest.TestCase):
class CancelOrderFlowTests(unittest.IsolatedAsyncioTestCase): 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): async def test_cancel_order_flow_consumes_free_text_reason(self):
state = FakeState( state = FakeState(
entries={ entries={
@ -237,6 +263,138 @@ class CancelOrderFlowTests(unittest.IsolatedAsyncioTestCase):
class CreateOrderFlowWithVehicleTests(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): async def test_order_flow_requests_vehicle_selection_from_last_stock_results(self):
state = FakeState( state = FakeState(
contexts={ contexts={

Loading…
Cancel
Save