🚗 feat(sales): vincular pedido ao veiculo selecionado e endurecer fluxos conversacionais

Passa a criar pedidos de compra a partir de um veiculo concreto do estoque selecionado na conversa, reaproveitando a ultima consulta e exibindo o modelo escolhido na resposta final.

Tambem endurece a orquestracao contra vazamento de contexto entre compra, cancelamento e revisao, preserva o estado necessario no fluxo e adiciona testes de regressao para os cenarios validados no Telegram.
main
parent 9316e3e495
commit 134a5fef41

@ -87,5 +87,5 @@ class CancelarPedidoRequest(BaseModel):
class RealizarPedidoRequest(BaseModel):
cpf: str
valor_veiculo: float
vehicle_id: int
user_id: Optional[int] = None

@ -54,6 +54,9 @@ class Order(MockBase):
numero_pedido = Column(String(40), 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=False, index=True)
vehicle_id = Column(Integer, ForeignKey("vehicles.id"), nullable=True, index=True)
modelo_veiculo = Column(String(120), nullable=True)
valor_veiculo = Column(Float, nullable=True)
status = Column(String(20), nullable=False, default="Ativo")
motivo_cancelamento = Column(Text, nullable=True)
data_cancelamento = Column(DateTime, nullable=True)

@ -217,8 +217,9 @@ def get_tools_definitions():
"name": "realizar_pedido",
"description": (
"Use esta ferramenta quando o cliente quiser efetivar uma compra/pedido. "
"Ela recebe CPF e valor do veiculo, valida credito e, se aprovado, cria "
"um novo pedido com numero unico."
"Ela recebe CPF e o identificador do veiculo escolhido no estoque, valida "
"credito com base no preco real do carro e, se aprovado, cria um novo pedido "
"com numero unico."
),
"parameters": {
"type": "object",
@ -227,12 +228,12 @@ def get_tools_definitions():
"type": "string",
"description": "CPF do cliente, com ou sem formatacao.",
},
"valor_veiculo": {
"type": "number",
"description": "Valor do veiculo em reais (BRL) para gerar o pedido.",
"vehicle_id": {
"type": "integer",
"description": "Codigo do veiculo escolhido no estoque.",
},
},
"required": ["cpf", "valor_veiculo"],
"required": ["cpf", "vehicle_id"],
},
},
{

@ -4,7 +4,7 @@ from datetime import datetime, timedelta
from fastapi import HTTPException
from app.db.mock_database import SessionMockLocal
from app.db.mock_models import User
from app.db.mock_models import User, Vehicle
from app.services.orchestration.orchestrator_config import (
CANCEL_ORDER_REQUIRED_FIELDS,
ORDER_REQUIRED_FIELDS,
@ -50,18 +50,6 @@ class OrderFlowMixin:
second_digit = 0 if second_digit >= 10 else second_digit
return second_digit == numbers[10]
def _try_prefill_order_value_from_memory(self, user_id: int | None, payload: dict) -> None:
if user_id is None or payload.get("valor_veiculo") is not None:
return
context = self._get_user_context(user_id)
if not context:
return
memory = context.get("generic_memory", {})
budget = memory.get("orcamento_max")
if isinstance(budget, (int, float)) and budget > 0:
payload["valor_veiculo"] = float(budget)
def _try_prefill_order_cpf_from_memory(self, user_id: int | None, payload: dict) -> None:
if user_id is None or payload.get("cpf"):
return
@ -86,14 +74,121 @@ class OrderFlowMixin:
finally:
db.close()
def _get_last_stock_results(self, user_id: int | None) -> list[dict]:
context = self._get_user_context(user_id)
if not context:
return []
stock_results = context.get("last_stock_results") or []
return stock_results if isinstance(stock_results, list) else []
def _get_selected_vehicle(self, user_id: int | None) -> dict | None:
context = self._get_user_context(user_id)
if not context:
return None
selected_vehicle = context.get("selected_vehicle")
return dict(selected_vehicle) if isinstance(selected_vehicle, dict) else None
def _store_selected_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 context:
return
context["selected_vehicle"] = dict(vehicle) if isinstance(vehicle, dict) else None
def _vehicle_to_payload(self, vehicle: dict) -> dict:
return {
"vehicle_id": int(vehicle["id"]),
"modelo_veiculo": str(vehicle["modelo"]),
"valor_veiculo": round(float(vehicle["preco"]), 2),
}
def _try_prefill_order_vehicle_from_context(self, user_id: int | None, payload: dict) -> None:
if user_id is None or payload.get("vehicle_id"):
return
selected_vehicle = self._get_selected_vehicle(user_id=user_id)
if selected_vehicle:
payload.update(self._vehicle_to_payload(selected_vehicle))
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:
return None
choice = int(tokens[0])
if 1 <= choice <= len(stock_results):
return stock_results[choice - 1]
return None
def _match_vehicle_from_message_model(self, message: str, stock_results: list[dict]) -> dict | None:
normalized_message = self._normalize_text(message)
matches = []
for item in stock_results:
normalized_model = self._normalize_text(str(item.get("modelo") or ""))
if normalized_model and normalized_model in normalized_message:
matches.append(item)
if len(matches) == 1:
return matches[0]
return None
def _load_vehicle_by_id(self, vehicle_id: int) -> dict | None:
db = SessionMockLocal()
try:
vehicle = db.query(Vehicle).filter(Vehicle.id == vehicle_id).first()
if not vehicle:
return None
return {
"id": int(vehicle.id),
"modelo": str(vehicle.modelo),
"categoria": str(vehicle.categoria),
"preco": float(vehicle.preco),
}
finally:
db.close()
def _try_resolve_order_vehicle(self, message: str, user_id: int | None, payload: dict) -> dict | None:
vehicle_id = payload.get("vehicle_id")
if isinstance(vehicle_id, int) and vehicle_id > 0:
return self._load_vehicle_by_id(vehicle_id)
stock_results = self._get_last_stock_results(user_id=user_id)
selected_from_model = self._match_vehicle_from_message_model(message=message, stock_results=stock_results)
if selected_from_model:
return selected_from_model
selected_from_index = self._match_vehicle_from_message_index(message=message, stock_results=stock_results)
if selected_from_index:
return selected_from_index
normalized_model = self._normalize_text(str(payload.get("modelo_veiculo") or ""))
if normalized_model:
matches = [
item
for item in stock_results
if self._normalize_text(str(item.get("modelo") or "")) == normalized_model
]
if len(matches) == 1:
return matches[0]
return None
def _render_missing_order_fields_prompt(self, missing_fields: list[str]) -> str:
labels = {
"cpf": "o CPF do cliente",
"valor_veiculo": "o valor do veiculo (R$)",
"vehicle_id": "qual veiculo do estoque voce quer comprar",
}
itens = [f"- {labels[field]}" for field in missing_fields]
return "Para realizar o pedido, preciso dos dados abaixo:\n" + "\n".join(itens)
def _render_vehicle_selection_from_stock_prompt(self, stock_results: list[dict]) -> str:
lines = ["Para realizar o pedido, escolha primeiro qual veiculo voce quer comprar:"]
for idx, item in enumerate(stock_results[:5], start=1):
lines.append(
f"- {idx}. [{item.get('id', 'N/A')}] {item.get('modelo', 'N/A')} "
f"({item.get('categoria', 'N/A')}) - R$ {float(item.get('preco', 0)):.2f}"
)
lines.append("Pode responder com o numero da lista ou com o modelo do veiculo.")
return "\n".join(lines)
def _render_missing_cancel_order_fields_prompt(self, missing_fields: list[str]) -> str:
labels = {
"numero_pedido": "o numero do pedido (ex.: PED-20260305123456-ABC123)",
@ -144,7 +239,17 @@ class OrderFlowMixin:
}
draft["payload"].update(extracted)
self._try_prefill_order_value_from_memory(user_id=user_id, payload=draft["payload"])
self._try_prefill_order_cpf_from_memory(user_id=user_id, payload=draft["payload"])
self._try_prefill_order_vehicle_from_context(user_id=user_id, payload=draft["payload"])
resolved_vehicle = self._try_resolve_order_vehicle(
message=message,
user_id=user_id,
payload=draft["payload"],
)
if resolved_vehicle:
self._store_selected_vehicle(user_id=user_id, vehicle=resolved_vehicle)
draft["payload"].update(self._vehicle_to_payload(resolved_vehicle))
cpf_value = draft["payload"].get("cpf")
if cpf_value and not self._is_valid_cpf(str(cpf_value)):
@ -162,28 +267,24 @@ class OrderFlowMixin:
self.state.set_entry("pending_order_drafts", user_id, draft)
return "Para seguir com o pedido, preciso de um CPF valido. Pode me informar novamente?"
valor = draft["payload"].get("valor_veiculo")
if valor is not None:
try:
parsed = float(valor)
if parsed <= 0:
draft["payload"].pop("valor_veiculo", None)
else:
draft["payload"]["valor_veiculo"] = round(parsed, 2)
except (TypeError, ValueError):
draft["payload"].pop("valor_veiculo", None)
draft["expires_at"] = datetime.utcnow() + timedelta(minutes=PENDING_ORDER_DRAFT_TTL_MINUTES)
self.state.set_entry("pending_order_drafts", user_id, draft)
missing = [field for field in ORDER_REQUIRED_FIELDS if field not in draft["payload"]]
if missing:
if "vehicle_id" in missing:
stock_results = self._get_last_stock_results(user_id=user_id)
if stock_results:
return self._render_vehicle_selection_from_stock_prompt(stock_results)
return self._render_missing_order_fields_prompt(missing)
try:
tool_result = await self.registry.execute(
"realizar_pedido",
draft["payload"],
{
"cpf": draft["payload"]["cpf"],
"vehicle_id": draft["payload"]["vehicle_id"],
},
user_id=user_id,
)
except HTTPException as exc:
@ -204,10 +305,21 @@ class OrderFlowMixin:
normalized_intents = self._normalize_intents(intents)
draft = self.state.get_entry("pending_cancel_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)
has_intent = normalized_intents.get("order_cancel", False)
if (
draft is None
and active_order_draft is not None
and (
not has_intent
or ("numero_pedido" not in extracted and "motivo" not in extracted)
)
):
return None
if (
draft
and not has_intent

@ -82,8 +82,8 @@ class ConversationPolicy:
order_fields = self.service.normalizer.normalize_order_fields(entities.get("order_fields"))
if order_fields.get("cpf") and not seed.get("cpf"):
seed["cpf"] = order_fields["cpf"]
if order_fields.get("valor_veiculo") and not seed.get("orcamento_max"):
seed["orcamento_max"] = int(round(order_fields["valor_veiculo"]))
if order_fields.get("modelo_veiculo") and not seed.get("modelo_veiculo"):
seed["modelo_veiculo"] = order_fields["modelo_veiculo"]
return seed
@ -308,6 +308,57 @@ class ConversationPolicy:
return self.contains_any_term(normalized, operational_terms)
# Distingue um comando global explicito de cancelamento do fluxo atual de um texto livre
# que deve ser consumido como dado do rascunho aberto.
def is_explicit_flow_cancel_message(self, message: str) -> bool:
normalized = self.service.normalizer.normalize_text(message).strip()
explicit_terms = {
"cancelar fluxo",
"cancela o fluxo",
"cancelar esse fluxo",
"cancela esse fluxo",
"cancelar fluxo atual",
"cancela o fluxo atual",
"encerrar fluxo",
"encerrar esse fluxo",
"parar fluxo",
"parar esse fluxo",
"abandonar fluxo",
"abandonar esse fluxo",
"desistir desse fluxo",
"desistir deste fluxo",
"desistir dessa compra",
"desistir desta compra",
}
return normalized in explicit_terms
# Evita que frases como "desisti" sejam tratadas como comando global quando o sistema
# esta justamente esperando o motivo do cancelamento.
def should_defer_flow_cancellation_control(self, message: str, user_id: int | None) -> bool:
if user_id is None or self.is_explicit_flow_cancel_message(message):
return False
pending_cancel_order = self.service.state.get_entry("pending_cancel_order_drafts", user_id, expire=True)
if pending_cancel_order:
payload = pending_cancel_order.get("payload", {})
if payload.get("numero_pedido") and not payload.get("motivo"):
free_text = str(message or "").strip()
if len(free_text) >= 4:
return True
pending_review_management = self.service.state.get_entry("pending_review_management_drafts", user_id, expire=True)
if pending_review_management:
payload = pending_review_management.get("payload", {})
action = pending_review_management.get("action", "cancel")
if action == "cancel" and payload.get("protocolo") and not payload.get("motivo"):
free_text = str(message or "").strip()
if len(free_text) >= 4 and not self.service._is_affirmative_message(free_text):
return True
return False
# Interpreta a resposta do usuário na etapa de seleção.
def detect_selected_order_index(self, message: str, orders: list[dict]) -> tuple[int | None, bool]:
normalized = self.strip_choice_message(self.service.normalizer.normalize_text(message))
@ -649,6 +700,12 @@ class ConversationPolicy:
memory = context.get("generic_memory", {})
if memory:
summary.append(f"Memoria generica temporaria: {memory}.")
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')}.")
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).")
order_queue = context.get("order_queue", [])
if order_queue:
summary.append(f"Fila de pedidos pendentes: {len(order_queue)}.")

@ -1,7 +1,9 @@
from datetime import datetime, timedelta
from app.services.orchestration.conversation_state_repository import ConversationStateRepository
class ConversationStateStore:
class ConversationStateStore(ConversationStateRepository):
def __init__(self) -> None:
self.user_contexts: dict[int, dict] = {}
self.pending_review_confirmations: dict[int, dict] = {}
@ -27,6 +29,8 @@ class ConversationStateStore:
"order_queue": [],
"pending_order_selection": None,
"pending_switch": None,
"last_stock_results": [],
"selected_vehicle": None,
"expires_at": now + timedelta(minutes=ttl_minutes),
}

@ -158,12 +158,7 @@ class EntityNormalizer:
def normalize_datetime_connector(self, text: str) -> str:
compact = " ".join(str(text or "").strip().split())
lowered = compact.lower()
marker = " as "
if marker in lowered:
index = lowered.index(marker)
return f"{compact[:index]} {compact[index + len(marker):]}".strip()
return compact
return re.sub(r"\s+[aàáâã]s\s+", " ", compact, flags=re.IGNORECASE).strip()
def try_parse_iso_datetime(self, text: str) -> datetime | None:
candidate = str(text or "").strip()
@ -366,9 +361,12 @@ class EntityNormalizer:
cpf = self.normalize_cpf(data.get("cpf"))
if cpf:
extracted["cpf"] = cpf
value = self.normalize_positive_number(data.get("valor_veiculo"))
if value:
extracted["valor_veiculo"] = round(value, 2)
vehicle_id = self.normalize_positive_number(data.get("vehicle_id"))
if vehicle_id:
extracted["vehicle_id"] = int(round(vehicle_id))
model = str(data.get("modelo_veiculo") or data.get("modelo") or "").strip(" ,.;")
if model:
extracted["modelo_veiculo"] = model.title()
return extracted
def normalize_cancel_order_fields(self, data) -> dict:

@ -26,7 +26,7 @@ class MessagePlanner:
' "generic_memory": {"placa": null, "cpf": null, "orcamento_max": null, "perfil_veiculo": []},\n'
' "review_fields": {"placa": null, "data_hora": null, "modelo": null, "ano": null, "km": null, "revisao_previa_concessionaria": null},\n'
' "review_management_fields": {"protocolo": null, "nova_data_hora": null, "motivo": null},\n'
' "order_fields": {"cpf": null, "valor_veiculo": null},\n'
' "order_fields": {"cpf": null, "vehicle_id": null, "modelo_veiculo": null},\n'
' "cancel_order_fields": {"numero_pedido": null, "motivo": null},\n'
' "intents": {"review_schedule": false, "review_list": false, "review_cancel": false, "review_reschedule": false, "order_create": false, "order_cancel": false}\n'
" }\n"
@ -94,7 +94,8 @@ class MessagePlanner:
" },\n"
' "order_fields": {\n'
' "cpf": null,\n'
' "valor_veiculo": null\n'
' "vehicle_id": null,\n'
' "modelo_veiculo": null\n'
" },\n"
' "cancel_order_fields": {\n'
' "numero_pedido": null,\n'

@ -18,7 +18,7 @@ REVIEW_REQUIRED_FIELDS = (
ORDER_REQUIRED_FIELDS = (
"cpf",
"valor_veiculo",
"vehicle_id",
)
CANCEL_ORDER_REQUIRED_FIELDS = (

@ -12,9 +12,10 @@ from app.services.orchestration.orchestrator_config import (
)
from app.services.ai.llm_service import LLMService
from app.services.orchestration.conversation_policy import ConversationPolicy
from app.services.orchestration.conversation_state_store import ConversationStateStore
from app.services.orchestration.conversation_state_repository import ConversationStateRepository
from app.services.orchestration.entity_normalizer import EntityNormalizer
from app.services.orchestration.message_planner import MessagePlanner
from app.services.orchestration.state_repository_factory import get_conversation_state_repository
from app.services.flows.order_flow import OrderFlowMixin
from app.services.orchestration.prompt_builders import (
build_force_tool_prompt,
@ -29,11 +30,13 @@ logger = logging.getLogger(__name__)
class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
default_state_repository = ConversationStateStore()
def __init__(self, db: Session):
def __init__(
self,
db: Session,
state_repository: ConversationStateRepository | None = None,
):
"""Inicializa servicos de LLM e registro de tools para a sessao atual."""
self.state = self.default_state_repository
self.state = state_repository or get_conversation_state_repository()
self.llm = LLMService()
self.normalizer = EntityNormalizer()
self.planner = MessagePlanner(llm=self.llm, normalizer=self.normalizer)
@ -218,6 +221,11 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
user_id=user_id,
)
return await finish(self._http_exception_detail(exc), queue_notice=queue_notice)
self._capture_tool_result_context(
tool_name=tool_name,
tool_result=tool_result,
user_id=user_id,
)
if self._should_use_deterministic_response(tool_name):
return await finish(
@ -271,6 +279,11 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
tool_call = llm_result.get("tool_call") or {}
tool_name = tool_call.get("name")
if tool_name in ORCHESTRATION_CONTROL_TOOLS:
if (
tool_name == "cancelar_fluxo_atual"
and self.policy.should_defer_flow_cancellation_control(message=message, user_id=user_id)
):
return None
try:
tool_result = await self.tool_executor.execute(
tool_name,
@ -306,6 +319,11 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
forced_tool_name = forced_tool_call.get("name")
if forced_tool_name not in ORCHESTRATION_CONTROL_TOOLS:
return None
if (
forced_tool_name == "cancelar_fluxo_atual"
and self.policy.should_defer_flow_cancellation_control(message=message, user_id=user_id)
):
return None
try:
tool_result = await self.tool_executor.execute(
@ -346,6 +364,8 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
context["order_queue"] = []
context["pending_order_selection"] = None
context["pending_switch"] = None
context["last_stock_results"] = []
context["selected_vehicle"] = None
def _clear_pending_order_navigation(self, user_id: int | None) -> int:
context = self._get_user_context(user_id)
@ -499,6 +519,40 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
context["generic_memory"].update(fields)
context.setdefault("shared_memory", {}).update(fields)
def _capture_tool_result_context(
self,
tool_name: str,
tool_result,
user_id: int | None,
) -> None:
context = self._get_user_context(user_id)
if not context:
return
if tool_name != "consultar_estoque" or not isinstance(tool_result, list):
return
sanitized: list[dict] = []
for item in tool_result[:20]:
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 _new_tab_memory(self, user_id: int | None) -> dict:
context = self._get_user_context(user_id)
if not context:

@ -28,7 +28,8 @@ def fallback_format_tool_result(tool_name: str, tool_result: Any) -> str:
modelo = item.get("modelo", "N/A")
categoria = item.get("categoria", "N/A")
preco = format_currency_br(item.get("preco"))
linhas.append(f"{idx}. {modelo} ({categoria}) - {preco}")
codigo = item.get("id", "N/A")
linhas.append(f"{idx}. [{codigo}] {modelo} ({categoria}) - {preco}")
restantes = len(tool_result) - 10
if restantes > 0:
linhas.append(f"... e mais {restantes} veiculo(s).")
@ -46,7 +47,8 @@ def fallback_format_tool_result(tool_name: str, tool_result: Any) -> str:
if tool_name == "realizar_pedido" and isinstance(tool_result, dict):
numero = tool_result.get("numero_pedido", "N/A")
valor = format_currency_br(tool_result.get("valor_veiculo"))
return f"Pedido criado com sucesso.\nNumero: {numero}\nValor: {valor}"
modelo = tool_result.get("modelo_veiculo", "N/A")
return f"Pedido criado com sucesso.\nNumero: {numero}\nVeiculo: {modelo}\nValor: {valor}"
if tool_name == "agendar_revisao" and isinstance(tool_result, dict):
placa = tool_result.get("placa", "N/A")

@ -6,6 +6,8 @@ from typing import Any, Dict, List, Optional
from fastapi import HTTPException
from sqlalchemy import func
from sqlalchemy.exc import OperationalError, SQLAlchemyError
from sqlalchemy.sql import text
from app.db.mock_database import SessionMockLocal
from app.db.mock_models import Customer, Order, ReviewSchedule, User, Vehicle
@ -200,7 +202,7 @@ def _parse_data_hora_revisao(value: str) -> datetime:
if not text:
raise ValueError("data_hora vazia")
normalized = re.sub(r"\s+[aA]s\s+", " ", text)
normalized = re.sub(r"\s+[aàáâã]s\s+", " ", text, flags=re.IGNORECASE)
iso_candidates = [text, normalized]
for candidate in iso_candidates:
try:
@ -627,9 +629,18 @@ async def cancelar_pedido(numero_pedido: str, motivo: str, user_id: Optional[int
db.close()
async def realizar_pedido(cpf: str, valor_veiculo: float, user_id: Optional[int] = None) -> Dict[str, Any]:
"""Cria um novo pedido de compra quando o cliente estiver aprovado para o valor informado."""
async def realizar_pedido(cpf: str, vehicle_id: int, user_id: Optional[int] = None) -> Dict[str, Any]:
"""Cria um novo pedido de compra quando o cliente estiver aprovado para o veiculo selecionado."""
cpf_norm = normalize_cpf(cpf)
db = SessionMockLocal()
try:
vehicle = db.query(Vehicle).filter(Vehicle.id == vehicle_id).first()
if not vehicle:
raise HTTPException(status_code=404, detail="Veiculo nao encontrado no estoque.")
valor_veiculo = float(vehicle.preco)
modelo_veiculo = str(vehicle.modelo)
await hydrate_mock_customer_from_cpf(cpf=cpf_norm, user_id=user_id)
avaliacao = await validar_cliente_venda(cpf=cpf_norm, valor_veiculo=valor_veiculo)
if not avaliacao.get("aprovado"):
@ -642,8 +653,6 @@ async def realizar_pedido(cpf: str, valor_veiculo: float, user_id: Optional[int]
)
numero_pedido = f"PED-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{uuid4().hex[:6].upper()}"
db = SessionMockLocal()
try:
if user_id is not None:
user = db.query(User).filter(User.id == user_id).first()
if user and user.cpf != cpf_norm:
@ -653,18 +662,59 @@ async def realizar_pedido(cpf: str, valor_veiculo: float, user_id: Optional[int]
numero_pedido=numero_pedido,
user_id=user_id,
cpf=cpf_norm,
vehicle_id=vehicle.id,
modelo_veiculo=modelo_veiculo,
valor_veiculo=valor_veiculo,
status="Ativo",
)
db.add(pedido)
try:
db.commit()
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
)
if not legacy_schema_issue:
raise
db.execute(
text(
"INSERT INTO orders (numero_pedido, user_id, cpf, status) "
"VALUES (:numero_pedido, :user_id, :cpf, :status)"
),
{
"numero_pedido": numero_pedido,
"user_id": user_id,
"cpf": cpf_norm,
"status": "Ativo",
},
)
db.commit()
return {
"numero_pedido": numero_pedido,
"user_id": user_id,
"cpf": cpf_norm,
"vehicle_id": vehicle.id,
"modelo_veiculo": modelo_veiculo,
"status": "Ativo",
"valor_veiculo": valor_veiculo,
"aprovado_credito": True,
}
return {
"numero_pedido": pedido.numero_pedido,
"user_id": pedido.user_id,
"cpf": pedido.cpf,
"vehicle_id": pedido.vehicle_id,
"modelo_veiculo": pedido.modelo_veiculo,
"status": pedido.status,
"valor_veiculo": valor_veiculo,
"valor_veiculo": pedido.valor_veiculo,
"aprovado_credito": True,
}
finally:

@ -0,0 +1,315 @@
import os
import unittest
from datetime import datetime, timedelta
from unittest.mock import patch
os.environ.setdefault("DEBUG", "false")
from app.services.flows.order_flow import OrderFlowMixin
from app.services.orchestration.conversation_policy import ConversationPolicy
from app.services.orchestration.entity_normalizer import EntityNormalizer
from app.services.tools.handlers import _parse_data_hora_revisao
class FakeState:
def __init__(self, entries=None, contexts=None):
self.entries = entries or {}
self.contexts = contexts or {}
def get_entry(self, bucket: str, user_id: int | None, *, expire: bool = False):
if user_id is None:
return None
return self.entries.get(bucket, {}).get(user_id)
def set_entry(self, bucket: str, user_id: int | None, value: dict):
if user_id is None:
return
self.entries.setdefault(bucket, {})[user_id] = value
def pop_entry(self, bucket: str, user_id: int | None):
if user_id is None:
return None
return self.entries.get(bucket, {}).pop(user_id, None)
def get_user_context(self, user_id: int | None):
if user_id is None:
return None
return self.contexts.get(user_id)
class FakeService:
def __init__(self, state):
self.state = state
self.normalizer = EntityNormalizer()
def _is_affirmative_message(self, text: str) -> bool:
normalized = self.normalizer.normalize_text(text).strip().rstrip(".!?,;:")
return normalized in {"sim", "pode", "ok", "confirmo", "aceito", "fechado", "pode sim", "tenho", "tenho sim"}
def _get_user_context(self, user_id: int | None):
return self.state.get_user_context(user_id)
class FakeRegistry:
def __init__(self):
self.calls = []
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 == "realizar_pedido":
vehicle_map = {
1: ("Honda Civic 2021", 51524.0),
2: ("Toyota Corolla 2020", 58476.0),
}
modelo_veiculo, valor_veiculo = vehicle_map[arguments["vehicle_id"]]
return {
"numero_pedido": "PED-TESTE-123",
"status": "Ativo",
"modelo_veiculo": modelo_veiculo,
"valor_veiculo": valor_veiculo,
}
return {
"numero_pedido": arguments["numero_pedido"],
"status": "Cancelado",
"motivo": arguments["motivo"],
}
class OrderFlowHarness(OrderFlowMixin):
def __init__(self, state, registry):
self.state = state
self.registry = registry
self.normalizer = EntityNormalizer()
def _get_user_context(self, user_id: int | None):
return self.state.get_user_context(user_id)
def _normalize_intents(self, data) -> dict:
return self.normalizer.normalize_intents(data)
def _normalize_cancel_order_fields(self, data) -> dict:
return self.normalizer.normalize_cancel_order_fields(data)
def _normalize_order_fields(self, data) -> dict:
return self.normalizer.normalize_order_fields(data)
def _normalize_text(self, text: str) -> str:
return self.normalizer.normalize_text(text)
def _http_exception_detail(self, exc) -> str:
return str(exc)
def _fallback_format_tool_result(self, tool_name: str, tool_result) -> str:
if tool_name == "realizar_pedido":
return (
f"Pedido criado com sucesso.\n"
f"Numero: {tool_result['numero_pedido']}\n"
f"Veiculo: {tool_result['modelo_veiculo']}\n"
f"Valor: R$ {tool_result['valor_veiculo']:.2f}"
)
return (
f"Pedido {tool_result['numero_pedido']} atualizado.\n"
f"Status: {tool_result['status']}\n"
f"Motivo: {tool_result['motivo']}"
)
def _try_prefill_order_cpf_from_user_profile(self, user_id: int | None, payload: dict) -> None:
return None
def _load_vehicle_by_id(self, vehicle_id: int) -> dict | None:
for context in self.state.contexts.values():
for item in context.get("last_stock_results", []):
if int(item["id"]) == int(vehicle_id):
return dict(item)
return None
class ConversationAdjustmentsTests(unittest.TestCase):
def test_defer_flow_cancel_when_order_cancel_draft_waits_for_reason(self):
state = FakeState(
entries={
"pending_cancel_order_drafts": {
7: {
"payload": {"numero_pedido": "PED-20260305120000-ABC123"},
"expires_at": datetime.utcnow() + timedelta(minutes=30),
}
}
}
)
policy = ConversationPolicy(service=FakeService(state))
self.assertTrue(policy.should_defer_flow_cancellation_control("desisti", user_id=7))
self.assertFalse(policy.should_defer_flow_cancellation_control("cancelar fluxo atual", user_id=7))
def test_normalize_datetime_connector_accepts_as_com_acento(self):
normalizer = EntityNormalizer()
self.assertEqual(
normalizer.normalize_datetime_connector("10/03/2026 às 09:00"),
"10/03/2026 09:00",
)
def test_parse_review_datetime_accepts_as_com_acento(self):
parsed = _parse_data_hora_revisao("10/03/2026 às 09:00")
self.assertEqual(parsed, datetime(2026, 3, 10, 9, 0))
class CancelOrderFlowTests(unittest.IsolatedAsyncioTestCase):
async def test_cancel_order_flow_consumes_free_text_reason(self):
state = FakeState(
entries={
"pending_cancel_order_drafts": {
42: {
"payload": {"numero_pedido": "PED-20260305120000-ABC123"},
"expires_at": datetime.utcnow() + timedelta(minutes=30),
}
}
}
)
registry = FakeRegistry()
flow = OrderFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_cancel_order(
message="desisti",
user_id=42,
extracted_fields={},
intents={},
)
self.assertEqual(len(registry.calls), 1)
tool_name, arguments, tool_user_id = registry.calls[0]
self.assertEqual(tool_name, "cancelar_pedido")
self.assertEqual(tool_user_id, 42)
self.assertEqual(arguments["numero_pedido"], "PED-20260305120000-ABC123")
self.assertEqual(arguments["motivo"], "desisti")
self.assertIn("Status: Cancelado", response)
self.assertIsNone(state.get_entry("pending_cancel_order_drafts", 42))
async def test_cancel_order_flow_still_requests_reason_when_message_is_too_short(self):
state = FakeState(
entries={
"pending_cancel_order_drafts": {
42: {
"payload": {"numero_pedido": "PED-20260305120000-ABC123"},
"expires_at": datetime.utcnow() + timedelta(minutes=30),
}
}
}
)
registry = FakeRegistry()
flow = OrderFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_cancel_order(
message="ok",
user_id=42,
extracted_fields={},
intents={},
)
self.assertEqual(registry.calls, [])
self.assertIn("o motivo do cancelamento", response)
self.assertIsNotNone(state.get_entry("pending_cancel_order_drafts", 42))
async def test_cancel_order_flow_does_not_override_active_order_creation_draft(self):
state = FakeState(
entries={
"pending_order_drafts": {
42: {
"payload": {},
"expires_at": datetime.utcnow() + timedelta(minutes=30),
}
}
}
)
registry = FakeRegistry()
flow = OrderFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_cancel_order(
message="2",
user_id=42,
extracted_fields={},
intents={"order_cancel": True},
)
self.assertIsNone(response)
self.assertEqual(registry.calls, [])
class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase):
async def test_order_flow_requests_vehicle_selection_from_last_stock_results(self):
state = FakeState(
contexts={
10: {
"generic_memory": {},
"last_stock_results": [
{"id": 1, "modelo": "Honda Civic 2021", "categoria": "sedan", "preco": 51524.0},
{"id": 2, "modelo": "Toyota Corolla 2020", "categoria": "hatch", "preco": 58476.0},
],
"selected_vehicle": None,
}
}
)
registry = FakeRegistry()
flow = OrderFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_create_order(
message="Quero fazer um pedido",
user_id=10,
extracted_fields={},
intents={"order_create": True},
)
self.assertIn("escolha primeiro qual veiculo", response.lower())
self.assertIn("Honda Civic 2021", response)
self.assertEqual(registry.calls, [])
async def test_order_flow_creates_order_with_selected_vehicle_from_list_index(self):
state = FakeState(
entries={
"pending_order_drafts": {
10: {
"payload": {"cpf": "12345678909"},
"expires_at": datetime.utcnow() + timedelta(minutes=30),
}
}
},
contexts={
10: {
"generic_memory": {"cpf": "12345678909"},
"last_stock_results": [
{"id": 1, "modelo": "Honda Civic 2021", "categoria": "sedan", "preco": 51524.0},
{"id": 2, "modelo": "Toyota Corolla 2020", "categoria": "hatch", "preco": 58476.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="2",
user_id=10,
extracted_fields={},
intents={},
)
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"], 2)
self.assertEqual(arguments["cpf"], "12345678909")
self.assertIn("Veiculo: Toyota Corolla 2020", response)
if __name__ == "__main__":
unittest.main()
Loading…
Cancel
Save