Merge branch 'feat/aluguel-multimodal'
commit
74e83adc66
@ -0,0 +1,81 @@
|
||||
"""
|
||||
Rotina dedicada de bootstrap de banco de dados.
|
||||
Cria tabelas e executa seed inicial de forma explicita, fora do startup do app.
|
||||
"""
|
||||
|
||||
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,
|
||||
RentalContract,
|
||||
RentalFine,
|
||||
RentalPayment,
|
||||
RentalVehicle,
|
||||
ReviewSchedule,
|
||||
Vehicle,
|
||||
)
|
||||
from app.db.mock_seed import seed_mock_data
|
||||
from app.db.tool_seed import seed_tools
|
||||
|
||||
|
||||
def bootstrap_databases(
|
||||
*,
|
||||
run_tools_seed: bool | None = None,
|
||||
run_mock_seed: bool | None = None,
|
||||
) -> None:
|
||||
"""Cria tabelas e executa seed inicial em ambos os bancos."""
|
||||
print("Inicializando bancos...")
|
||||
failures: list[str] = []
|
||||
|
||||
should_seed_tools = settings.auto_seed_tools if run_tools_seed is None else bool(run_tools_seed)
|
||||
should_seed_mock = (
|
||||
settings.auto_seed_mock and settings.mock_seed_enabled
|
||||
if run_mock_seed is None
|
||||
else bool(run_mock_seed)
|
||||
)
|
||||
|
||||
try:
|
||||
print("Criando tabelas MySQL (tools)...")
|
||||
Base.metadata.create_all(bind=engine)
|
||||
if should_seed_tools:
|
||||
print("Populando tools iniciais...")
|
||||
seed_tools()
|
||||
else:
|
||||
print("Seed de tools desabilitada por configuracao.")
|
||||
print("MySQL tools OK.")
|
||||
except Exception as exc:
|
||||
print(f"Aviso: falha no MySQL (tools): {exc}")
|
||||
failures.append(f"tools={exc}")
|
||||
|
||||
try:
|
||||
print("Criando tabelas MySQL (dados ficticios)...")
|
||||
MockBase.metadata.create_all(bind=mock_engine)
|
||||
if should_seed_mock:
|
||||
print("Populando dados ficticios iniciais...")
|
||||
seed_mock_data()
|
||||
else:
|
||||
print("Seed mock desabilitada por configuracao.")
|
||||
print("MySQL mock OK.")
|
||||
except Exception as exc:
|
||||
print(f"Aviso: falha no MySQL mock: {exc}")
|
||||
failures.append(f"mock={exc}")
|
||||
|
||||
if failures:
|
||||
raise RuntimeError(
|
||||
"Falha ao inicializar bancos do orquestrador: " + " | ".join(failures)
|
||||
)
|
||||
|
||||
print("Bancos inicializados com sucesso!")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Executa o bootstrap dedicado quando chamado via modulo."""
|
||||
bootstrap_databases()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -0,0 +1,619 @@
|
||||
import math
|
||||
import random
|
||||
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
|
||||
|
||||
|
||||
# Normaliza o numero do contrato para comparacoes e buscas.
|
||||
def _normalize_contract_number(value: str | None) -> str | None:
|
||||
text = str(value or "").strip().upper()
|
||||
return text or None
|
||||
|
||||
|
||||
# Limpa campos textuais livres antes de salvar ou responder.
|
||||
def _normalize_text_field(value: str | None) -> str | None:
|
||||
text = str(value or "").strip(" ,.;")
|
||||
return text or None
|
||||
|
||||
|
||||
# Converte datas opcionais de aluguel em datetime com formatos aceitos.
|
||||
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
|
||||
|
||||
|
||||
# Exige uma data obrigatoria de aluguel e reaproveita a validacao comum.
|
||||
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
|
||||
|
||||
|
||||
# Valida e normaliza valores monetarios positivos usados no fluxo.
|
||||
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)
|
||||
|
||||
|
||||
# Garante que o identificador do veiculo seja um inteiro positivo.
|
||||
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
|
||||
|
||||
|
||||
# Calcula a quantidade de diarias cobradas entre inicio e fim da locacao.
|
||||
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))
|
||||
|
||||
|
||||
# Busca o veiculo da locacao por id ou placa normalizada.
|
||||
def _build_rental_vehicle_query(
|
||||
db,
|
||||
*,
|
||||
rental_vehicle_id: int | None = None,
|
||||
placa: str | None = None,
|
||||
) -> Any:
|
||||
if rental_vehicle_id is not None:
|
||||
return db.query(RentalVehicle).filter(RentalVehicle.id == rental_vehicle_id)
|
||||
|
||||
normalized_plate = technical_normalizer.normalize_plate(placa)
|
||||
if normalized_plate:
|
||||
return db.query(RentalVehicle).filter(RentalVehicle.placa == normalized_plate)
|
||||
|
||||
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 db.query(RentalVehicle).filter(RentalVehicle.id.is_(None))
|
||||
|
||||
|
||||
def _lookup_rental_vehicle(
|
||||
db,
|
||||
*,
|
||||
rental_vehicle_id: int | None = None,
|
||||
placa: str | None = None,
|
||||
) -> RentalVehicle | None:
|
||||
return _build_rental_vehicle_query(
|
||||
db,
|
||||
rental_vehicle_id=rental_vehicle_id,
|
||||
placa=placa,
|
||||
).first()
|
||||
|
||||
|
||||
# Recupera e trava o veiculo no mesmo turno transacional para evitar dupla locacao.
|
||||
def _get_rental_vehicle_for_update(
|
||||
db,
|
||||
*,
|
||||
rental_vehicle_id: int | None = None,
|
||||
placa: str | None = None,
|
||||
) -> RentalVehicle | None:
|
||||
return _build_rental_vehicle_query(
|
||||
db,
|
||||
rental_vehicle_id=rental_vehicle_id,
|
||||
placa=placa,
|
||||
).with_for_update().first()
|
||||
|
||||
# Prioriza contratos do proprio usuario antes de cair para contratos sem dono.
|
||||
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()
|
||||
|
||||
|
||||
# Resolve um contrato de aluguel usando contrato, placa ou contexto do usuario.
|
||||
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
|
||||
|
||||
|
||||
# Lista a frota de aluguel com filtros simples e ordenacao configuravel.
|
||||
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()}%"))
|
||||
|
||||
order_mode = str(ordenar_diaria or "").strip().lower()
|
||||
normalized_limit = None
|
||||
if limite is not None:
|
||||
try:
|
||||
normalized_limit = max(1, min(int(limite), 50))
|
||||
except (TypeError, ValueError):
|
||||
normalized_limit = None
|
||||
|
||||
if order_mode in {"asc", "desc"}:
|
||||
query = query.order_by(
|
||||
RentalVehicle.valor_diaria.asc()
|
||||
if order_mode == "asc"
|
||||
else RentalVehicle.valor_diaria.desc()
|
||||
)
|
||||
elif order_mode != "random":
|
||||
query = query.order_by(RentalVehicle.valor_diaria.asc(), RentalVehicle.modelo.asc())
|
||||
|
||||
if order_mode == "random":
|
||||
rows = query.all()
|
||||
random.shuffle(rows)
|
||||
if normalized_limit is not None:
|
||||
rows = rows[:normalized_limit]
|
||||
else:
|
||||
if normalized_limit is not None:
|
||||
query = query.limit(normalized_limit)
|
||||
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()
|
||||
|
||||
|
||||
# Abre uma locacao, reserva o veiculo e devolve o resumo do contrato.
|
||||
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 = _get_rental_vehicle_for_update(
|
||||
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()
|
||||
|
||||
|
||||
# Encerra a locacao ativa, calcula o valor final e libera o veiculo.
|
||||
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()
|
||||
|
||||
|
||||
# Registra um pagamento de aluguel e tenta vincular o contrato correto.
|
||||
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()
|
||||
|
||||
|
||||
# Registra uma multa ligada ao aluguel usando os identificadores disponiveis.
|
||||
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()
|
||||
@ -0,0 +1,131 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.time_utils import utc_now
|
||||
|
||||
|
||||
class FlowStateSupport:
|
||||
"""Utilitarios compartilhados para buckets e snapshots de fluxo."""
|
||||
|
||||
def __init__(self, service) -> None:
|
||||
self.service = service
|
||||
|
||||
def get_state_repository(self):
|
||||
return getattr(self.service, "state", None)
|
||||
|
||||
def get_state_entry(self, bucket: str, user_id: int | None, *, expire: bool = False):
|
||||
state = self.get_state_repository()
|
||||
if state is None or not hasattr(state, "get_entry"):
|
||||
return None
|
||||
return state.get_entry(bucket, user_id, expire=expire)
|
||||
|
||||
def set_state_entry(self, bucket: str, user_id: int | None, value) -> None:
|
||||
state = self.get_state_repository()
|
||||
if state is None or not hasattr(state, "set_entry"):
|
||||
return
|
||||
state.set_entry(bucket, user_id, value)
|
||||
|
||||
def pop_state_entry(self, bucket: str, user_id: int | None):
|
||||
state = self.get_state_repository()
|
||||
if state is None or not hasattr(state, "pop_entry"):
|
||||
return None
|
||||
return state.pop_entry(bucket, user_id)
|
||||
|
||||
def get_flow_snapshot(self, user_id: int | None, snapshot_key: str) -> dict | None:
|
||||
if user_id is None or not hasattr(self.service, "_get_user_context"):
|
||||
return None
|
||||
context = self.service._get_user_context(user_id)
|
||||
if not isinstance(context, dict):
|
||||
return None
|
||||
snapshots = context.get("flow_snapshots")
|
||||
if not isinstance(snapshots, dict):
|
||||
return None
|
||||
snapshot = snapshots.get(snapshot_key)
|
||||
return dict(snapshot) if isinstance(snapshot, dict) else None
|
||||
|
||||
def set_flow_snapshot(
|
||||
self,
|
||||
user_id: int | None,
|
||||
snapshot_key: str,
|
||||
value: dict | None,
|
||||
*,
|
||||
active_task: str | None = None,
|
||||
) -> None:
|
||||
if user_id is None or not hasattr(self.service, "_get_user_context") or not hasattr(self.service, "_save_user_context"):
|
||||
return
|
||||
context = self.service._get_user_context(user_id)
|
||||
if not isinstance(context, dict):
|
||||
return
|
||||
snapshots = context.get("flow_snapshots")
|
||||
if not isinstance(snapshots, dict):
|
||||
snapshots = {}
|
||||
context["flow_snapshots"] = snapshots
|
||||
|
||||
if isinstance(value, dict):
|
||||
snapshots[snapshot_key] = value
|
||||
if active_task:
|
||||
context["active_task"] = active_task
|
||||
collected_slots = context.get("collected_slots")
|
||||
if not isinstance(collected_slots, dict):
|
||||
collected_slots = {}
|
||||
context["collected_slots"] = collected_slots
|
||||
payload = value.get("payload")
|
||||
if isinstance(payload, dict):
|
||||
collected_slots[active_task] = dict(payload)
|
||||
else:
|
||||
snapshots.pop(snapshot_key, None)
|
||||
if active_task and context.get("active_task") == active_task:
|
||||
context["active_task"] = None
|
||||
collected_slots = context.get("collected_slots")
|
||||
if isinstance(collected_slots, dict) and active_task:
|
||||
collected_slots.pop(active_task, None)
|
||||
|
||||
self.service._save_user_context(user_id=user_id, context=context)
|
||||
|
||||
def get_flow_entry(self, bucket: str, user_id: int | None, snapshot_key: str) -> dict | None:
|
||||
entry = self.get_state_entry(bucket, user_id, expire=True)
|
||||
if entry:
|
||||
return entry
|
||||
|
||||
snapshot = self.get_flow_snapshot(user_id=user_id, snapshot_key=snapshot_key)
|
||||
if not snapshot:
|
||||
return None
|
||||
if snapshot.get("expires_at") and snapshot["expires_at"] < utc_now():
|
||||
self.set_flow_snapshot(user_id=user_id, snapshot_key=snapshot_key, value=None)
|
||||
return None
|
||||
|
||||
self.set_state_entry(bucket, user_id, snapshot)
|
||||
return snapshot
|
||||
|
||||
def set_flow_entry(
|
||||
self,
|
||||
bucket: str,
|
||||
user_id: int | None,
|
||||
snapshot_key: str,
|
||||
value: dict,
|
||||
*,
|
||||
active_task: str | None = None,
|
||||
) -> None:
|
||||
self.set_state_entry(bucket, user_id, value)
|
||||
self.set_flow_snapshot(
|
||||
user_id=user_id,
|
||||
snapshot_key=snapshot_key,
|
||||
value=value,
|
||||
active_task=active_task,
|
||||
)
|
||||
|
||||
def pop_flow_entry(
|
||||
self,
|
||||
bucket: str,
|
||||
user_id: int | None,
|
||||
snapshot_key: str,
|
||||
*,
|
||||
active_task: str | None = None,
|
||||
) -> dict | None:
|
||||
entry = self.pop_state_entry(bucket, user_id)
|
||||
self.set_flow_snapshot(
|
||||
user_id=user_id,
|
||||
snapshot_key=snapshot_key,
|
||||
value=None,
|
||||
active_task=active_task,
|
||||
)
|
||||
return entry
|
||||
@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from app.core.time_utils import utc_now
|
||||
from app.services.flows.flow_state_support import FlowStateSupport
|
||||
from app.services.orchestration.orchestrator_config import PENDING_ORDER_SELECTION_TTL_MINUTES
|
||||
|
||||
|
||||
class OrderFlowStateSupport(FlowStateSupport):
|
||||
"""Concentra estado, snapshots e selecoes do fluxo de vendas."""
|
||||
|
||||
def sanitize_stock_results(self, stock_results: list[dict] | None) -> list[dict]:
|
||||
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,
|
||||
"budget_relaxed": bool(item.get("budget_relaxed", False)),
|
||||
}
|
||||
)
|
||||
return sanitized
|
||||
|
||||
def get_last_stock_results(self, user_id: int | None) -> list[dict]:
|
||||
pending_selection = self.get_state_entry("pending_stock_selections", user_id, expire=True)
|
||||
if isinstance(pending_selection, dict):
|
||||
payload = pending_selection.get("payload")
|
||||
if isinstance(payload, list):
|
||||
sanitized = self.sanitize_stock_results(payload)
|
||||
if sanitized:
|
||||
return sanitized
|
||||
context = self.service._get_user_context(user_id)
|
||||
if not context:
|
||||
return []
|
||||
stock_results = context.get("last_stock_results") or []
|
||||
return self.sanitize_stock_results(stock_results if isinstance(stock_results, list) else [])
|
||||
|
||||
def store_pending_stock_selection(self, user_id: int | None, stock_results: list[dict] | None) -> None:
|
||||
if user_id is None:
|
||||
return
|
||||
sanitized = self.sanitize_stock_results(stock_results)
|
||||
if not sanitized:
|
||||
self.pop_state_entry("pending_stock_selections", user_id)
|
||||
return
|
||||
self.set_state_entry(
|
||||
"pending_stock_selections",
|
||||
user_id,
|
||||
{
|
||||
"payload": sanitized,
|
||||
"expires_at": utc_now() + timedelta(minutes=PENDING_ORDER_SELECTION_TTL_MINUTES),
|
||||
},
|
||||
)
|
||||
|
||||
def get_selected_vehicle(self, user_id: int | None) -> dict | None:
|
||||
context = self.service._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 get_pending_single_vehicle_confirmation(self, user_id: int | None) -> dict | None:
|
||||
context = self.service._get_user_context(user_id)
|
||||
if not context:
|
||||
return None
|
||||
pending_vehicle = context.get("pending_single_vehicle_confirmation")
|
||||
return dict(pending_vehicle) if isinstance(pending_vehicle, dict) else None
|
||||
|
||||
def remember_stock_results(self, user_id: int | None, stock_results: list[dict] | None) -> None:
|
||||
context = self.service._get_user_context(user_id)
|
||||
if not context:
|
||||
return
|
||||
sanitized = self.sanitize_stock_results(stock_results)
|
||||
context["last_stock_results"] = sanitized
|
||||
self.store_pending_stock_selection(user_id=user_id, stock_results=sanitized)
|
||||
if sanitized:
|
||||
context["selected_vehicle"] = None
|
||||
context["pending_single_vehicle_confirmation"] = None
|
||||
self.service._save_user_context(user_id=user_id, context=context)
|
||||
|
||||
def store_selected_vehicle(self, user_id: int | None, vehicle: dict | None) -> None:
|
||||
if user_id is None:
|
||||
return
|
||||
context = self.service._get_user_context(user_id)
|
||||
if not context:
|
||||
return
|
||||
context["selected_vehicle"] = dict(vehicle) if isinstance(vehicle, dict) else None
|
||||
context["pending_single_vehicle_confirmation"] = None
|
||||
self.pop_state_entry("pending_stock_selections", user_id)
|
||||
self.service._save_user_context(user_id=user_id, context=context)
|
||||
|
||||
def store_pending_single_vehicle_confirmation(self, user_id: int | None, vehicle: dict | None) -> None:
|
||||
if user_id is None:
|
||||
return
|
||||
context = self.service._get_user_context(user_id)
|
||||
if not context:
|
||||
return
|
||||
context["pending_single_vehicle_confirmation"] = dict(vehicle) if isinstance(vehicle, dict) else None
|
||||
self.service._save_user_context(user_id=user_id, context=context)
|
||||
|
||||
def clear_pending_single_vehicle_confirmation(self, user_id: int | None) -> None:
|
||||
if user_id is None:
|
||||
return
|
||||
context = self.service._get_user_context(user_id)
|
||||
if not isinstance(context, dict):
|
||||
return
|
||||
context["pending_single_vehicle_confirmation"] = None
|
||||
self.service._save_user_context(user_id=user_id, context=context)
|
||||
@ -0,0 +1,616 @@
|
||||
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,
|
||||
RENTAL_REQUIRED_FIELDS,
|
||||
)
|
||||
from app.services.flows.rental_flow_support import RentalFlowStateSupport
|
||||
|
||||
|
||||
class RentalFlowMixin:
|
||||
@property
|
||||
def _rental_flow_state_support(self) -> RentalFlowStateSupport:
|
||||
support = getattr(self, "__rental_flow_state_support", None)
|
||||
if support is None:
|
||||
support = RentalFlowStateSupport(self)
|
||||
setattr(self, "__rental_flow_state_support", support)
|
||||
return support
|
||||
|
||||
# Sanitiza resultados da frota antes de guardar no contexto.
|
||||
def _sanitize_rental_results(self, rental_results: list[dict] | None) -> list[dict]:
|
||||
return self._rental_flow_state_support.sanitize_rental_results(rental_results)
|
||||
|
||||
# Marca locacao como dominio ativo na conversa do usuario.
|
||||
def _mark_rental_flow_active(self, user_id: int | None, *, active_task: str | None = None) -> None:
|
||||
self._rental_flow_state_support.mark_rental_flow_active(
|
||||
user_id=user_id,
|
||||
active_task=active_task,
|
||||
)
|
||||
|
||||
# Recupera a ultima lista de veiculos disponiveis para locacao.
|
||||
def _get_last_rental_results(self, user_id: int | None) -> list[dict]:
|
||||
return self._rental_flow_state_support.get_last_rental_results(user_id=user_id)
|
||||
|
||||
# Guarda a lista atual para permitir selecao do veiculo em mensagens seguintes.
|
||||
def _store_pending_rental_selection(self, user_id: int | None, rental_results: list[dict] | None) -> None:
|
||||
self._rental_flow_state_support.store_pending_rental_selection(
|
||||
user_id=user_id,
|
||||
rental_results=rental_results,
|
||||
)
|
||||
|
||||
# Le o veiculo de locacao escolhido que ficou salvo no contexto.
|
||||
def _get_selected_rental_vehicle(self, user_id: int | None) -> dict | None:
|
||||
return self._rental_flow_state_support.get_selected_rental_vehicle(user_id=user_id)
|
||||
|
||||
# Filtra o payload do contrato para manter so dados uteis no contexto.
|
||||
def _sanitize_rental_contract_snapshot(self, payload) -> dict | None:
|
||||
return self._rental_flow_state_support.sanitize_rental_contract_snapshot(payload)
|
||||
|
||||
# Recupera o ultimo contrato de locacao lembrado para o usuario.
|
||||
def _get_last_rental_contract(self, user_id: int | None) -> dict | None:
|
||||
return self._rental_flow_state_support.get_last_rental_contract(user_id=user_id)
|
||||
|
||||
# Atualiza o ultimo contrato de locacao salvo no contexto.
|
||||
def _store_last_rental_contract(self, user_id: int | None, payload) -> None:
|
||||
self._rental_flow_state_support.store_last_rental_contract(
|
||||
user_id=user_id,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
# Persiste a ultima consulta de frota para reuso no fluxo incremental.
|
||||
def _remember_rental_results(self, user_id: int | None, rental_results: list[dict] | None) -> None:
|
||||
self._rental_flow_state_support.remember_rental_results(
|
||||
user_id=user_id,
|
||||
rental_results=rental_results,
|
||||
)
|
||||
|
||||
# Salva o veiculo escolhido e encerra a etapa de selecao pendente.
|
||||
def _store_selected_rental_vehicle(self, user_id: int | None, vehicle: dict | None) -> None:
|
||||
self._rental_flow_state_support.store_selected_rental_vehicle(
|
||||
user_id=user_id,
|
||||
vehicle=vehicle,
|
||||
)
|
||||
|
||||
# Converte um veiculo selecionado no payload esperado pela abertura da locacao.
|
||||
def _rental_vehicle_to_payload(self, vehicle: dict) -> dict:
|
||||
return self._rental_flow_state_support.rental_vehicle_to_payload(vehicle)
|
||||
|
||||
# Extrai a categoria de locacao mencionada livremente pelo usuario.
|
||||
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"(?<![a-z0-9]){re.escape(token)}(?![a-z0-9])", normalized):
|
||||
return category
|
||||
return None
|
||||
|
||||
# Extrai um modelo ou marca/modelo quando o pedido for mais especifico.
|
||||
def _extract_rental_model_from_text(self, text: str) -> str | None:
|
||||
normalized = self._normalize_text(text).strip()
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
normalized = re.sub(r"\b\d{1,2}[/-]\d{1,2}[/-]\d{4}(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?\b", " ", normalized)
|
||||
normalized = re.sub(r"\b\d{4}[/-]\d{1,2}[/-]\d{1,2}(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?\b", " ", normalized)
|
||||
normalized = re.sub(r"\b[a-z]{3}\d[a-z0-9]\d{2}\b", " ", normalized)
|
||||
normalized = re.sub(r"\br\$\s*\d+[\d\.,]*\b", " ", normalized)
|
||||
|
||||
category = self._extract_rental_category_from_text(normalized)
|
||||
if category:
|
||||
normalized = re.sub(rf"(?<![a-z0-9]){re.escape(category)}(?![a-z0-9])", " ", normalized)
|
||||
if category == "pickup":
|
||||
normalized = re.sub(r"(?<![a-z0-9])picape(?![a-z0-9])", " ", normalized)
|
||||
|
||||
candidate = None
|
||||
cue_patterns = (
|
||||
r"(?:quero|gostaria|preciso|procuro|procurando|busco|buscando)\s+(?:alugar|locar)?\s*(?:um|uma|o|a)?\s*(?P<candidate>.+)",
|
||||
r"(?:tem|ha|existe|existem|mostre|mostrar|liste|listar|quais)\s+(?:um|uma|o|a)?\s*(?P<candidate>.+)",
|
||||
r"(?P<candidate>.+?)\s+(?:para\s+aluguel|para\s+locacao)\b",
|
||||
)
|
||||
for pattern in cue_patterns:
|
||||
match = re.search(pattern, normalized)
|
||||
if match:
|
||||
candidate = str(match.group("candidate") or "").strip()
|
||||
if candidate:
|
||||
break
|
||||
if not candidate:
|
||||
return None
|
||||
|
||||
boundary_tokens = {
|
||||
"para",
|
||||
"pra",
|
||||
"com",
|
||||
"sem",
|
||||
"que",
|
||||
"por",
|
||||
"de",
|
||||
"do",
|
||||
"da",
|
||||
"dos",
|
||||
"das",
|
||||
"no",
|
||||
"na",
|
||||
"nos",
|
||||
"nas",
|
||||
"automatico",
|
||||
"automatica",
|
||||
"automaticos",
|
||||
"automaticas",
|
||||
"manual",
|
||||
"manuais",
|
||||
"barato",
|
||||
"barata",
|
||||
"economico",
|
||||
"economica",
|
||||
}
|
||||
generic_tokens = {
|
||||
"aluguel",
|
||||
"alugar",
|
||||
"locacao",
|
||||
"locar",
|
||||
"carro",
|
||||
"carros",
|
||||
"veiculo",
|
||||
"veiculos",
|
||||
"modelo",
|
||||
"categoria",
|
||||
"tipo",
|
||||
"disponiveis",
|
||||
"disponivel",
|
||||
"frota",
|
||||
"opcoes",
|
||||
"opcao",
|
||||
"esta",
|
||||
"estao",
|
||||
"estava",
|
||||
"estavam",
|
||||
"existe",
|
||||
"existem",
|
||||
"ha",
|
||||
"tem",
|
||||
"um",
|
||||
"uma",
|
||||
"o",
|
||||
"a",
|
||||
"os",
|
||||
"as",
|
||||
"suv",
|
||||
"sedan",
|
||||
"hatch",
|
||||
"pickup",
|
||||
"picape",
|
||||
}
|
||||
|
||||
tokens: list[str] = []
|
||||
for token in re.findall(r"[a-z0-9]+", candidate):
|
||||
if token in boundary_tokens:
|
||||
break
|
||||
if token in generic_tokens:
|
||||
continue
|
||||
if re.fullmatch(r"(?:19|20)\d{2}", token):
|
||||
continue
|
||||
if len(token) < 2:
|
||||
continue
|
||||
tokens.append(token)
|
||||
if len(tokens) >= 3:
|
||||
break
|
||||
|
||||
if not tokens:
|
||||
return None
|
||||
|
||||
return " ".join(tokens).title().strip() or None
|
||||
|
||||
# Coleta datas de locacao em texto livre mantendo a ordem encontrada.
|
||||
def _extract_rental_datetimes_from_text(self, text: str) -> 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
|
||||
|
||||
# Normaliza datas de locacao para um formato unico aceito pelo fluxo.
|
||||
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")
|
||||
|
||||
# Normaliza campos estruturados de aluguel antes de montar o draft.
|
||||
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
|
||||
|
||||
model_hint = str(data.get("modelo") or data.get("modelo_veiculo") or "").strip(" ,.;")
|
||||
if model_hint and not self._extract_rental_category_from_text(model_hint):
|
||||
payload["modelo"] = model_hint.title()
|
||||
|
||||
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
|
||||
|
||||
# Enriquece o draft com placa, cpf, categoria, budget e datas extraidos da mensagem.
|
||||
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)
|
||||
|
||||
if payload.get("modelo") is None:
|
||||
model_hint = self._extract_rental_model_from_text(message)
|
||||
if model_hint:
|
||||
payload["modelo"] = model_hint
|
||||
|
||||
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]
|
||||
|
||||
# Detecta pedidos para listar a frota de aluguel.
|
||||
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)
|
||||
|
||||
# Detecta quando o usuario quer iniciar uma nova locacao.
|
||||
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)
|
||||
|
||||
# Detecta pedidos de devolucao ou encerramento da locacao.
|
||||
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"})
|
||||
|
||||
# Detecta quando a mensagem parece tratar de pagamento ou multa de aluguel.
|
||||
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"})
|
||||
|
||||
# Interpreta selecoes numericas com base na ultima lista apresentada.
|
||||
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
|
||||
|
||||
# Tenta casar a resposta do usuario com modelo ou placa da frota mostrada.
|
||||
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
|
||||
|
||||
# Resolve o veiculo escolhido reaproveitando contexto e texto livre.
|
||||
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
|
||||
|
||||
# Decide se a mensagem atual pode continuar uma selecao de aluguel ja iniciada.
|
||||
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)
|
||||
)
|
||||
)
|
||||
|
||||
# Monta a pergunta objetiva com os campos que faltam para abrir a locacao.
|
||||
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)
|
||||
|
||||
# Formata a lista curta da frota para o usuario escolher um veiculo.
|
||||
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)
|
||||
|
||||
# Consulta a frota e guarda o resultado para a etapa de selecao.
|
||||
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,
|
||||
}
|
||||
category = payload.get("categoria") or self._extract_rental_category_from_text(message)
|
||||
if category:
|
||||
arguments["categoria"] = str(category).strip().lower()
|
||||
|
||||
model_hint = str(payload.get("modelo") or self._extract_rental_model_from_text(message) or "").strip()
|
||||
if model_hint:
|
||||
arguments["modelo"] = model_hint
|
||||
|
||||
arguments["ordenar_diaria"] = "asc" if (category or model_hint) else "random"
|
||||
|
||||
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)
|
||||
|
||||
# Conduz a coleta incremental dos dados e abre a locacao quando estiver completa.
|
||||
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._store_last_rental_contract(user_id=user_id, payload=tool_result)
|
||||
self._reset_pending_rental_states(user_id=user_id)
|
||||
return self._fallback_format_tool_result("abrir_locacao_aluguel", tool_result)
|
||||
|
||||
@ -0,0 +1,310 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy import or_
|
||||
|
||||
from app.core.time_utils import utc_now
|
||||
from app.db.mock_database import SessionMockLocal
|
||||
from app.db.mock_models import RentalContract, RentalFine, RentalPayment
|
||||
from app.services.flows.flow_state_support import FlowStateSupport
|
||||
from app.services.orchestration import technical_normalizer
|
||||
from app.services.orchestration.orchestrator_config import PENDING_RENTAL_SELECTION_TTL_MINUTES
|
||||
|
||||
|
||||
class RentalFlowStateSupport(FlowStateSupport):
|
||||
"""Concentra estado e contexto incremental do fluxo de locacao."""
|
||||
|
||||
def _load_last_rental_contract_snapshot(self, user_id: int | None) -> dict | None:
|
||||
if user_id is None:
|
||||
return None
|
||||
db = None
|
||||
try:
|
||||
db = SessionMockLocal()
|
||||
base_query = db.query(RentalContract).filter(RentalContract.user_id == user_id)
|
||||
contract = (
|
||||
base_query.filter(RentalContract.status == "ativa")
|
||||
.order_by(RentalContract.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if contract is None:
|
||||
contract = base_query.order_by(RentalContract.created_at.desc()).first()
|
||||
if contract is None:
|
||||
return None
|
||||
|
||||
payload = {
|
||||
"contrato_numero": contract.contrato_numero,
|
||||
"placa": contract.placa,
|
||||
"modelo_veiculo": contract.modelo_veiculo,
|
||||
"categoria": contract.categoria,
|
||||
"data_inicio": contract.data_inicio.isoformat() if contract.data_inicio else None,
|
||||
"data_fim_prevista": contract.data_fim_prevista.isoformat() if contract.data_fim_prevista else None,
|
||||
"data_devolucao": contract.data_devolucao.isoformat() if contract.data_devolucao else None,
|
||||
"valor_diaria": contract.valor_diaria,
|
||||
"valor_previsto": contract.valor_previsto,
|
||||
"valor_final": contract.valor_final,
|
||||
"status": contract.status,
|
||||
}
|
||||
|
||||
latest_payment = (
|
||||
db.query(RentalPayment)
|
||||
.filter(
|
||||
or_(
|
||||
RentalPayment.rental_contract_id == contract.id,
|
||||
RentalPayment.contrato_numero == contract.contrato_numero,
|
||||
)
|
||||
)
|
||||
.order_by(RentalPayment.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if latest_payment is not None:
|
||||
payload.update(
|
||||
{
|
||||
"valor": latest_payment.valor,
|
||||
"data_pagamento": latest_payment.data_pagamento.isoformat()
|
||||
if latest_payment.data_pagamento
|
||||
else None,
|
||||
"favorecido": latest_payment.favorecido,
|
||||
"status": "registrado",
|
||||
}
|
||||
)
|
||||
|
||||
latest_fine = (
|
||||
db.query(RentalFine)
|
||||
.filter(
|
||||
or_(
|
||||
RentalFine.rental_contract_id == contract.id,
|
||||
RentalFine.contrato_numero == contract.contrato_numero,
|
||||
)
|
||||
)
|
||||
.order_by(RentalFine.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if latest_fine is not None:
|
||||
payload.update(
|
||||
{
|
||||
"auto_infracao": latest_fine.auto_infracao,
|
||||
"data_infracao": latest_fine.data_infracao.isoformat()
|
||||
if latest_fine.data_infracao
|
||||
else None,
|
||||
"vencimento": latest_fine.vencimento.isoformat() if latest_fine.vencimento else None,
|
||||
}
|
||||
)
|
||||
if latest_fine.valor is not None:
|
||||
payload["valor_multa"] = float(latest_fine.valor)
|
||||
|
||||
return self.sanitize_rental_contract_snapshot(payload)
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
if db is not None:
|
||||
try:
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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.service._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.service._save_user_context(user_id=user_id, context=context)
|
||||
|
||||
def get_last_rental_results(self, user_id: int | None) -> list[dict]:
|
||||
pending_selection = self.get_state_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.service._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.pop_state_entry("pending_rental_selections", user_id)
|
||||
return
|
||||
self.set_state_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.service._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 sanitize_rental_contract_snapshot(self, payload) -> dict | None:
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
|
||||
contract_number = str(payload.get("contrato_numero") or "").strip().upper()
|
||||
plate = technical_normalizer.normalize_plate(payload.get("placa"))
|
||||
if not contract_number and not plate:
|
||||
return None
|
||||
|
||||
snapshot: dict = {}
|
||||
if contract_number:
|
||||
snapshot["contrato_numero"] = contract_number
|
||||
if plate:
|
||||
snapshot["placa"] = plate
|
||||
|
||||
for field_name in (
|
||||
"modelo_veiculo",
|
||||
"categoria",
|
||||
"status_veiculo",
|
||||
"data_inicio",
|
||||
"data_fim_prevista",
|
||||
"data_devolucao",
|
||||
):
|
||||
value = str(payload.get(field_name) or "").strip()
|
||||
if value:
|
||||
snapshot[field_name] = value
|
||||
|
||||
status_value = str(payload.get("status") or "").strip()
|
||||
if status_value:
|
||||
if payload.get("data_pagamento"):
|
||||
snapshot["status_pagamento"] = status_value
|
||||
else:
|
||||
snapshot["status"] = status_value
|
||||
|
||||
for field_name in ("valor_diaria", "valor_previsto", "valor_final"):
|
||||
number = technical_normalizer.normalize_positive_number(payload.get(field_name))
|
||||
if number is not None:
|
||||
snapshot[field_name] = float(number)
|
||||
|
||||
payment_date = str(payload.get("data_pagamento") or "").strip()
|
||||
if payment_date:
|
||||
snapshot["data_pagamento"] = payment_date
|
||||
payment_value = technical_normalizer.normalize_positive_number(payload.get("valor"))
|
||||
if payment_value is not None:
|
||||
snapshot["valor_pagamento"] = float(payment_value)
|
||||
favorecido = str(payload.get("favorecido") or "").strip()
|
||||
if favorecido:
|
||||
snapshot["favorecido"] = favorecido
|
||||
snapshot.setdefault("status_pagamento", "registrado")
|
||||
|
||||
violation_date = str(payload.get("data_infracao") or "").strip()
|
||||
if violation_date:
|
||||
snapshot["data_infracao"] = violation_date
|
||||
due_date = str(payload.get("vencimento") or "").strip()
|
||||
if due_date:
|
||||
snapshot["vencimento"] = due_date
|
||||
infraction_notice = str(payload.get("auto_infracao") or "").strip()
|
||||
if infraction_notice:
|
||||
snapshot["auto_infracao"] = infraction_notice
|
||||
if violation_date or infraction_notice:
|
||||
fine_value = technical_normalizer.normalize_positive_number(payload.get("valor"))
|
||||
if fine_value is not None:
|
||||
snapshot["valor_multa"] = float(fine_value)
|
||||
|
||||
return snapshot
|
||||
|
||||
def get_last_rental_contract(self, user_id: int | None) -> dict | None:
|
||||
context = self.service._get_user_context(user_id)
|
||||
if isinstance(context, dict):
|
||||
contract = context.get("last_rental_contract")
|
||||
if isinstance(contract, dict):
|
||||
return dict(contract)
|
||||
|
||||
snapshot = self._load_last_rental_contract_snapshot(user_id=user_id)
|
||||
if snapshot and isinstance(context, dict):
|
||||
context["last_rental_contract"] = dict(snapshot)
|
||||
self.service._save_user_context(user_id=user_id, context=context)
|
||||
return dict(snapshot) if isinstance(snapshot, dict) else None
|
||||
|
||||
def store_last_rental_contract(self, user_id: int | None, payload) -> None:
|
||||
if user_id is None:
|
||||
return
|
||||
context = self.service._get_user_context(user_id)
|
||||
if not isinstance(context, dict):
|
||||
return
|
||||
sanitized = self.sanitize_rental_contract_snapshot(payload)
|
||||
if sanitized is None:
|
||||
context.pop("last_rental_contract", None)
|
||||
else:
|
||||
existing = context.get("last_rental_contract")
|
||||
merged = dict(existing) if isinstance(existing, dict) else {}
|
||||
merged.update(sanitized)
|
||||
if merged.get("data_pagamento") and not merged.get("status_pagamento"):
|
||||
merged["status_pagamento"] = "registrado"
|
||||
elif merged.get("contrato_numero") and not merged.get("data_devolucao") and not merged.get("status_pagamento"):
|
||||
merged["status_pagamento"] = "em aberto"
|
||||
context["last_rental_contract"] = merged
|
||||
self.service._save_user_context(user_id=user_id, context=context)
|
||||
|
||||
def remember_rental_results(self, user_id: int | None, rental_results: list[dict] | None) -> None:
|
||||
context = self.service._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.service._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.service._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.pop_state_entry("pending_rental_selections", user_id)
|
||||
self.service._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),
|
||||
}
|
||||
@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.core.time_utils import utc_now
|
||||
from app.services.flows.flow_state_support import FlowStateSupport
|
||||
from app.services.orchestration.orchestrator_config import LAST_REVIEW_PACKAGE_TTL_MINUTES
|
||||
|
||||
|
||||
class ReviewFlowStateSupport(FlowStateSupport):
|
||||
"""Concentra estado e utilitarios de suporte do fluxo de revisao."""
|
||||
|
||||
def review_now(self) -> datetime:
|
||||
provider = getattr(self.service, "_review_now_provider", None)
|
||||
if callable(provider):
|
||||
return provider()
|
||||
return datetime.now()
|
||||
|
||||
def log_review_flow_source(
|
||||
self,
|
||||
source: str,
|
||||
payload: dict | None = None,
|
||||
missing_fields: list[str] | None = None,
|
||||
) -> None:
|
||||
if not hasattr(self.service, "_log_turn_event"):
|
||||
return
|
||||
self.service._log_turn_event(
|
||||
"review_flow_progress",
|
||||
review_flow_source=source,
|
||||
payload_keys=sorted((payload or {}).keys()),
|
||||
missing_fields=list(missing_fields or []),
|
||||
)
|
||||
|
||||
def active_domain(self, user_id: int | None) -> str:
|
||||
if user_id is None or not hasattr(self.service, "_get_user_context"):
|
||||
return "general"
|
||||
context = self.service._get_user_context(user_id)
|
||||
if not isinstance(context, dict):
|
||||
return "general"
|
||||
return str(context.get("active_domain") or "general").strip().lower()
|
||||
|
||||
def store_last_review_package(self, user_id: int | None, payload: dict | None) -> None:
|
||||
if user_id is None or not isinstance(payload, dict):
|
||||
return
|
||||
package = {
|
||||
"placa": payload.get("placa"),
|
||||
"modelo": payload.get("modelo"),
|
||||
"ano": payload.get("ano"),
|
||||
"km": payload.get("km"),
|
||||
"revisao_previa_concessionaria": payload.get("revisao_previa_concessionaria"),
|
||||
}
|
||||
sanitized = {key: value for key, value in package.items() if value is not None}
|
||||
required = {"placa", "modelo", "ano", "km", "revisao_previa_concessionaria"}
|
||||
if not required.issubset(sanitized.keys()):
|
||||
return
|
||||
self.set_state_entry(
|
||||
"last_review_packages",
|
||||
user_id,
|
||||
{
|
||||
"payload": sanitized,
|
||||
"expires_at": utc_now() + timedelta(minutes=LAST_REVIEW_PACKAGE_TTL_MINUTES),
|
||||
},
|
||||
)
|
||||
|
||||
def get_last_review_package(self, user_id: int | None) -> dict | None:
|
||||
if user_id is None:
|
||||
return None
|
||||
cached = self.get_state_entry("last_review_packages", user_id, expire=True)
|
||||
if not cached:
|
||||
return None
|
||||
payload = cached.get("payload")
|
||||
return dict(payload) if isinstance(payload, dict) else None
|
||||
@ -0,0 +1,420 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.services.orchestration.orchestrator_config import USER_CONTEXT_TTL_MINUTES
|
||||
|
||||
|
||||
class OrchestratorContextManager:
|
||||
"""Agrupa a gestao de contexto e efeitos colaterais do turno."""
|
||||
|
||||
def __init__(self, service) -> None:
|
||||
self.service = service
|
||||
|
||||
def upsert_user_context(self, user_id: int | None) -> None:
|
||||
override = self.service.__dict__.get("_upsert_user_context")
|
||||
if callable(override):
|
||||
override(user_id)
|
||||
return
|
||||
state = getattr(self.service, "state", None)
|
||||
if state is None:
|
||||
return
|
||||
state.upsert_user_context(
|
||||
user_id=user_id,
|
||||
ttl_minutes=USER_CONTEXT_TTL_MINUTES,
|
||||
)
|
||||
|
||||
def get_user_context(self, user_id: int | None) -> dict | None:
|
||||
override = self.service.__dict__.get("_get_user_context")
|
||||
if callable(override):
|
||||
return override(user_id)
|
||||
state = getattr(self.service, "state", None)
|
||||
if state is None:
|
||||
return None
|
||||
return 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
|
||||
override = self.service.__dict__.get("_save_user_context")
|
||||
if callable(override):
|
||||
override(user_id, context)
|
||||
return
|
||||
state = getattr(self.service, "state", None)
|
||||
if state is None:
|
||||
return
|
||||
state.save_user_context(user_id=user_id, context=context)
|
||||
|
||||
def extract_generic_memory_fields(self, llm_generic_fields: dict | None = None) -> dict:
|
||||
extracted: dict[str, Any] = {}
|
||||
llm_fields = llm_generic_fields or {}
|
||||
|
||||
normalized_plate = self.service._normalize_plate(llm_fields.get("placa"))
|
||||
if normalized_plate:
|
||||
extracted["placa"] = normalized_plate
|
||||
|
||||
normalized_cpf = self.service._normalize_cpf(llm_fields.get("cpf"))
|
||||
if normalized_cpf:
|
||||
extracted["cpf"] = normalized_cpf
|
||||
|
||||
normalized_budget = self.service._normalize_positive_number(llm_fields.get("orcamento_max"))
|
||||
if normalized_budget:
|
||||
extracted["orcamento_max"] = int(round(normalized_budget))
|
||||
|
||||
normalized_profile = self.service._normalize_vehicle_profile(llm_fields.get("perfil_veiculo"))
|
||||
if normalized_profile:
|
||||
extracted["perfil_veiculo"] = normalized_profile
|
||||
|
||||
return extracted
|
||||
|
||||
def capture_generic_memory(
|
||||
self,
|
||||
user_id: int | None,
|
||||
llm_generic_fields: dict | None = None,
|
||||
) -> None:
|
||||
context = self.get_user_context(user_id)
|
||||
if not context:
|
||||
return
|
||||
fields = self.extract_generic_memory_fields(llm_generic_fields=llm_generic_fields)
|
||||
if fields:
|
||||
context["generic_memory"].update(fields)
|
||||
context.setdefault("shared_memory", {}).update(fields)
|
||||
self.save_user_context(user_id=user_id, context=context)
|
||||
|
||||
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
|
||||
context["last_tool_result"] = {
|
||||
"tool_name": tool_name,
|
||||
"result_type": type(tool_result).__name__,
|
||||
}
|
||||
if tool_name == "consultar_frota_aluguel" and isinstance(tool_result, list):
|
||||
sanitized_rental = self.service._sanitize_rental_results(tool_result[:20])
|
||||
context["last_rental_results"] = sanitized_rental
|
||||
self.service._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
|
||||
|
||||
sanitized = self.service._sanitize_stock_results(tool_result[:5])
|
||||
context["last_stock_results"] = sanitized
|
||||
self.service._store_pending_stock_selection(
|
||||
user_id=user_id,
|
||||
stock_results=sanitized,
|
||||
)
|
||||
if sanitized:
|
||||
context["selected_vehicle"] = None
|
||||
self.save_user_context(user_id=user_id, context=context)
|
||||
|
||||
def capture_successful_tool_side_effects(
|
||||
self,
|
||||
tool_name: str,
|
||||
arguments: dict | None,
|
||||
tool_result,
|
||||
user_id: int | None,
|
||||
) -> None:
|
||||
if tool_name == "agendar_revisao" and isinstance(arguments, dict):
|
||||
self.service._store_last_review_package(user_id=user_id, payload=arguments)
|
||||
if tool_name in {
|
||||
"abrir_locacao_aluguel",
|
||||
"registrar_devolucao_aluguel",
|
||||
"registrar_pagamento_aluguel",
|
||||
"registrar_multa_aluguel",
|
||||
} and isinstance(tool_result, dict):
|
||||
self.service._store_last_rental_contract(user_id=user_id, payload=tool_result)
|
||||
self.capture_tool_result_context(
|
||||
tool_name=tool_name,
|
||||
tool_result=tool_result,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
async def maybe_build_stock_suggestion_response(
|
||||
self,
|
||||
tool_name: str,
|
||||
arguments: dict | None,
|
||||
tool_result,
|
||||
user_id: int | None,
|
||||
) -> str | None:
|
||||
if tool_name != "consultar_estoque" or not isinstance(tool_result, list) or tool_result:
|
||||
return None
|
||||
|
||||
budget = self.service._normalize_positive_number((arguments or {}).get("preco_max"))
|
||||
if not budget:
|
||||
return None
|
||||
|
||||
relaxed_arguments = dict(arguments or {})
|
||||
relaxed_arguments["preco_max"] = max(float(budget) * 1.2, float(budget) + 10000.0)
|
||||
relaxed_arguments["limite"] = min(max(int((arguments or {}).get("limite") or 5), 1), 5)
|
||||
relaxed_arguments["ordenar_preco"] = "asc"
|
||||
|
||||
try:
|
||||
relaxed_result = await self.service.tool_executor.execute(
|
||||
"consultar_estoque",
|
||||
relaxed_arguments,
|
||||
user_id=user_id,
|
||||
)
|
||||
except HTTPException:
|
||||
return None
|
||||
|
||||
if not isinstance(relaxed_result, list):
|
||||
return None
|
||||
|
||||
nearby = []
|
||||
for item in relaxed_result:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
try:
|
||||
price = float(item.get("preco") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if price > float(budget):
|
||||
nearby.append(item)
|
||||
|
||||
if not nearby:
|
||||
return None
|
||||
|
||||
nearby = [{**item, "budget_relaxed": True} for item in nearby]
|
||||
self.capture_tool_result_context(
|
||||
tool_name="consultar_estoque",
|
||||
tool_result=nearby,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
budget_label = f"R$ {float(budget):,.0f}".replace(",", ".")
|
||||
lines = [f"Nao encontrei veiculos ate {budget_label}."]
|
||||
lines.append("Mas achei algumas opcoes proximas ao seu orcamento:")
|
||||
for idx, item in enumerate(nearby[:5], start=1):
|
||||
modelo = str(item.get("modelo") or "N/A")
|
||||
categoria = str(item.get("categoria") or "N/A")
|
||||
codigo = item.get("id", "N/A")
|
||||
preco = f"R$ {float(item.get('preco') or 0):,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
|
||||
lines.append(f"{idx}. [{codigo}] {modelo} ({categoria}) - {preco}")
|
||||
lines.append("Se quiser, responda com o numero da lista ou com o modelo.")
|
||||
return "\n".join(lines)
|
||||
|
||||
def new_tab_memory(self, user_id: int | None) -> dict:
|
||||
context = self.get_user_context(user_id)
|
||||
if not context:
|
||||
return {}
|
||||
shared = context.get("shared_memory", {})
|
||||
if not isinstance(shared, dict):
|
||||
return {}
|
||||
return dict(shared)
|
||||
|
||||
def reset_pending_rental_states(self, user_id: int | None) -> None:
|
||||
if user_id is None:
|
||||
return
|
||||
self.service.state.pop_entry("pending_rental_drafts", user_id)
|
||||
self.service.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
|
||||
self.service.state.pop_entry("pending_review_drafts", user_id)
|
||||
self.service.state.pop_entry("pending_review_confirmations", user_id)
|
||||
self.service.state.pop_entry("pending_review_management_drafts", user_id)
|
||||
self.service.state.pop_entry("pending_review_reuse_confirmations", user_id)
|
||||
context = self.get_user_context(user_id)
|
||||
if isinstance(context, dict):
|
||||
snapshots = context.get("flow_snapshots")
|
||||
if isinstance(snapshots, dict):
|
||||
snapshots.pop("review_schedule", None)
|
||||
snapshots.pop("review_confirmation", None)
|
||||
snapshots.pop("review_management", None)
|
||||
snapshots.pop("review_reuse_confirmation", None)
|
||||
collected_slots = context.get("collected_slots")
|
||||
if isinstance(collected_slots, dict):
|
||||
collected_slots.pop("review_schedule", None)
|
||||
collected_slots.pop("review_management", None)
|
||||
if context.get("active_task") in {"review_schedule", "review_management"}:
|
||||
context["active_task"] = None
|
||||
self.save_user_context(user_id=user_id, context=context)
|
||||
|
||||
def reset_pending_order_states(self, user_id: int | None) -> None:
|
||||
if user_id is None:
|
||||
return
|
||||
self.service.state.pop_entry("pending_order_drafts", user_id)
|
||||
self.service.state.pop_entry("pending_cancel_order_drafts", user_id)
|
||||
self.service.state.pop_entry("pending_stock_selections", user_id)
|
||||
context = self.get_user_context(user_id)
|
||||
if isinstance(context, dict):
|
||||
snapshots = context.get("flow_snapshots")
|
||||
if isinstance(snapshots, dict):
|
||||
snapshots.pop("order_create", None)
|
||||
snapshots.pop("order_cancel", None)
|
||||
collected_slots = context.get("collected_slots")
|
||||
if isinstance(collected_slots, dict):
|
||||
collected_slots.pop("order_create", None)
|
||||
collected_slots.pop("order_cancel", None)
|
||||
if context.get("active_task") in {"order_create", "order_cancel"}:
|
||||
context["active_task"] = None
|
||||
self.save_user_context(user_id=user_id, context=context)
|
||||
|
||||
def clear_user_conversation_state(self, user_id: int | None) -> None:
|
||||
context = self.get_user_context(user_id)
|
||||
if not context:
|
||||
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.service.state.pop_entry("last_review_packages", user_id)
|
||||
context["active_domain"] = "general"
|
||||
context["active_task"] = None
|
||||
context["generic_memory"] = {}
|
||||
context["shared_memory"] = {}
|
||||
context["collected_slots"] = {}
|
||||
context["flow_snapshots"] = {}
|
||||
context["last_tool_result"] = None
|
||||
context["order_queue"] = []
|
||||
context["pending_order_selection"] = None
|
||||
context["pending_switch"] = None
|
||||
context["last_stock_results"] = []
|
||||
context["selected_vehicle"] = None
|
||||
context["last_rental_results"] = []
|
||||
context["selected_rental_vehicle"] = None
|
||||
context.pop("last_rental_contract", None)
|
||||
self.save_user_context(user_id=user_id, context=context)
|
||||
|
||||
def clear_pending_order_navigation(self, user_id: int | None) -> int:
|
||||
context = self.get_user_context(user_id)
|
||||
if not context:
|
||||
return 0
|
||||
dropped = len(context.get("order_queue", []))
|
||||
if context.get("pending_switch"):
|
||||
dropped += 1
|
||||
if context.get("pending_order_selection"):
|
||||
pending_orders = context["pending_order_selection"].get("orders") or []
|
||||
dropped += len(pending_orders)
|
||||
context["order_queue"] = []
|
||||
context["pending_switch"] = None
|
||||
context["pending_order_selection"] = None
|
||||
self.save_user_context(user_id=user_id, context=context)
|
||||
return dropped
|
||||
|
||||
def cancel_active_flow(self, user_id: int | None) -> str:
|
||||
context = self.get_user_context(user_id)
|
||||
if not context:
|
||||
return "Nao havia contexto ativo para cancelar."
|
||||
|
||||
active_domain = context.get("active_domain", "general")
|
||||
had_flow = self.service._has_open_flow(user_id=user_id, domain=active_domain)
|
||||
if active_domain == "review":
|
||||
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)
|
||||
if had_flow:
|
||||
return f"Fluxo atual de {self.service._domain_label(active_domain)} cancelado."
|
||||
return "Nao havia fluxo em andamento para cancelar."
|
||||
|
||||
async def continue_next_order_now(self, user_id: int | None) -> str:
|
||||
context = self.get_user_context(user_id)
|
||||
if not context:
|
||||
return "Nao encontrei contexto ativo para continuar."
|
||||
|
||||
if context.get("pending_order_selection"):
|
||||
return "Ainda preciso que voce escolha qual das duas acoes deseja iniciar primeiro."
|
||||
|
||||
pending_switch = context.get("pending_switch")
|
||||
if isinstance(pending_switch, dict):
|
||||
queued_message = str(pending_switch.get("queued_message") or "").strip()
|
||||
if queued_message:
|
||||
target_domain = str(pending_switch.get("target_domain") or "general")
|
||||
memory_seed = dict(pending_switch.get("memory_seed") or {})
|
||||
self.service._apply_domain_switch(user_id=user_id, target_domain=target_domain)
|
||||
refreshed = self.get_user_context(user_id)
|
||||
if refreshed is not None:
|
||||
refreshed["generic_memory"] = memory_seed
|
||||
self.save_user_context(user_id=user_id, context=refreshed)
|
||||
transition = self.service._build_next_order_transition(target_domain)
|
||||
next_response = await self.service.handle_message(queued_message, user_id=user_id)
|
||||
return f"{transition}\n{next_response}"
|
||||
|
||||
next_order = self.service._pop_next_order(user_id=user_id)
|
||||
if not next_order:
|
||||
return "Nao ha pedidos pendentes na fila para continuar."
|
||||
|
||||
target_domain = str(next_order.get("domain") or "general")
|
||||
memory_seed = dict(next_order.get("memory_seed") or self.new_tab_memory(user_id=user_id))
|
||||
self.service._apply_domain_switch(user_id=user_id, target_domain=target_domain)
|
||||
refreshed = self.get_user_context(user_id)
|
||||
if refreshed is not None:
|
||||
refreshed["generic_memory"] = memory_seed
|
||||
self.save_user_context(user_id=user_id, context=refreshed)
|
||||
transition = self.service._build_next_order_transition(target_domain)
|
||||
next_response = await self.service.handle_message(
|
||||
str(next_order.get("message") or ""),
|
||||
user_id=user_id,
|
||||
)
|
||||
return f"{transition}\n{next_response}"
|
||||
|
||||
async def tool_limpar_contexto_conversa(
|
||||
self,
|
||||
motivo: str | None = None,
|
||||
user_id: int | None = None,
|
||||
) -> dict:
|
||||
self.clear_user_conversation_state(user_id=user_id)
|
||||
message = "Contexto da conversa limpo. Podemos recomecar do zero."
|
||||
if motivo:
|
||||
message = f"{message}\nMotivo registrado: {motivo.strip()}"
|
||||
return {"message": message}
|
||||
|
||||
async def tool_descartar_pedidos_pendentes(
|
||||
self,
|
||||
motivo: str | None = None,
|
||||
user_id: int | None = None,
|
||||
) -> dict:
|
||||
dropped = self.clear_pending_order_navigation(user_id=user_id)
|
||||
if dropped <= 0:
|
||||
message = "Nao havia pedidos pendentes na fila para descartar."
|
||||
elif dropped == 1:
|
||||
message = "Descartei 1 pedido pendente da fila."
|
||||
else:
|
||||
message = f"Descartei {dropped} pedidos pendentes da fila."
|
||||
if motivo:
|
||||
message = f"{message}\nMotivo registrado: {motivo.strip()}"
|
||||
return {"message": message}
|
||||
|
||||
async def tool_cancelar_fluxo_atual(
|
||||
self,
|
||||
motivo: str | None = None,
|
||||
user_id: int | None = None,
|
||||
) -> dict:
|
||||
message = self.cancel_active_flow(user_id=user_id)
|
||||
if motivo:
|
||||
message = f"{message}\nMotivo registrado: {motivo.strip()}"
|
||||
return {"message": message}
|
||||
|
||||
async def tool_continuar_proximo_pedido(self, user_id: int | None = None) -> str:
|
||||
return await self.continue_next_order_now(user_id=user_id)
|
||||
|
||||
@ -0,0 +1,296 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from time import perf_counter
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.core.time_utils import utc_now
|
||||
from app.services.orchestration.entity_normalizer import EntityNormalizer
|
||||
from app.services.orchestration.orchestrator_config import (
|
||||
DETERMINISTIC_RESPONSE_TOOLS,
|
||||
LOW_VALUE_RESPONSES,
|
||||
ORCHESTRATION_CONTROL_TOOLS,
|
||||
)
|
||||
from app.services.orchestration.prompt_builders import (
|
||||
build_force_tool_prompt,
|
||||
build_result_prompt,
|
||||
build_router_prompt,
|
||||
)
|
||||
from app.services.orchestration.sensitive_data import mask_sensitive_payload, mask_sensitive_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OrchestratorExecutionManager:
|
||||
"""Centraliza instrumentacao, prompts e execucao tecnica de tools."""
|
||||
|
||||
def __init__(self, service, logger_instance=None) -> None:
|
||||
self.service = service
|
||||
self.logger = logger_instance or logger
|
||||
|
||||
def build_router_prompt(self, user_message: str, user_id: int | None) -> str:
|
||||
conversation_context = self.service._build_context_summary(user_id=user_id)
|
||||
return build_router_prompt(
|
||||
user_message=user_message,
|
||||
user_id=user_id,
|
||||
conversation_context=conversation_context,
|
||||
)
|
||||
|
||||
def build_force_tool_prompt(self, user_message: str, user_id: int | None) -> str:
|
||||
conversation_context = self.service._build_context_summary(user_id=user_id)
|
||||
return build_force_tool_prompt(
|
||||
user_message=user_message,
|
||||
user_id=user_id,
|
||||
conversation_context=conversation_context,
|
||||
)
|
||||
|
||||
def build_result_prompt(
|
||||
self,
|
||||
user_message: str,
|
||||
user_id: int | None,
|
||||
tool_name: str,
|
||||
tool_result,
|
||||
) -> str:
|
||||
conversation_context = self.service._build_context_summary(user_id=user_id)
|
||||
return build_result_prompt(
|
||||
user_message=user_message,
|
||||
user_id=user_id,
|
||||
tool_name=tool_name,
|
||||
tool_result=tool_result,
|
||||
conversation_context=conversation_context,
|
||||
)
|
||||
|
||||
def capture_turn_decision_trace(self, turn_decision: dict | None) -> None:
|
||||
trace = getattr(self.service, "_turn_trace", None)
|
||||
if not isinstance(trace, dict) or not isinstance(turn_decision, dict):
|
||||
return
|
||||
trace["intent"] = str(turn_decision.get("intent") or "").strip() or None
|
||||
trace["domain"] = str(turn_decision.get("domain") or "").strip() or None
|
||||
trace["action"] = str(turn_decision.get("action") or "").strip() or None
|
||||
|
||||
def capture_tool_invocation_trace(self, tool_name: str, arguments: dict | None) -> None:
|
||||
trace = getattr(self.service, "_turn_trace", None)
|
||||
if not isinstance(trace, dict):
|
||||
return
|
||||
trace["tool_name"] = str(tool_name or "").strip() or None
|
||||
trace["tool_arguments"] = mask_sensitive_payload(dict(arguments or {})) if isinstance(arguments, dict) else None
|
||||
|
||||
def finalize_turn_history(
|
||||
self,
|
||||
*,
|
||||
user_message: str,
|
||||
assistant_response: str | None,
|
||||
turn_status: str,
|
||||
error_detail: str | None = None,
|
||||
) -> None:
|
||||
history_service = getattr(self.service, "history_service", None)
|
||||
if history_service is None:
|
||||
return
|
||||
|
||||
trace = getattr(self.service, "_turn_trace", {}) or {}
|
||||
history_service.record_turn(
|
||||
request_id=str(trace.get("request_id") or ""),
|
||||
conversation_id=str(trace.get("conversation_id") or "anonymous"),
|
||||
user_id=trace.get("user_id"),
|
||||
user_message=str(user_message or ""),
|
||||
assistant_response=assistant_response,
|
||||
turn_status=str(turn_status or "completed"),
|
||||
intent=trace.get("intent"),
|
||||
domain=trace.get("domain"),
|
||||
action=trace.get("action"),
|
||||
tool_name=trace.get("tool_name"),
|
||||
tool_arguments=trace.get("tool_arguments"),
|
||||
error_detail=error_detail,
|
||||
started_at=trace.get("started_at"),
|
||||
completed_at=utc_now(),
|
||||
elapsed_ms=trace.get("elapsed_ms"),
|
||||
)
|
||||
|
||||
def format_turn_error(self, exc: Exception) -> str:
|
||||
if isinstance(exc, HTTPException):
|
||||
detail = exc.detail
|
||||
if isinstance(detail, dict):
|
||||
return json.dumps(mask_sensitive_payload(detail), ensure_ascii=True, separators=(",", ":"), default=str)
|
||||
return str(mask_sensitive_text(str(detail)))
|
||||
return str(mask_sensitive_text(f"{type(exc).__name__}: {exc}"))
|
||||
|
||||
def log_turn_event(self, event: str, **payload) -> None:
|
||||
trace = getattr(self.service, "_turn_trace", {}) or {}
|
||||
safe_payload = mask_sensitive_payload(
|
||||
{
|
||||
"request_id": trace.get("request_id"),
|
||||
"conversation_id": trace.get("conversation_id"),
|
||||
**payload,
|
||||
}
|
||||
)
|
||||
self.logger.info(
|
||||
"turn_event=%s payload=%s",
|
||||
event,
|
||||
safe_payload,
|
||||
)
|
||||
|
||||
async def call_llm_with_trace(self, operation: str, message: str, tools):
|
||||
started_at = perf_counter()
|
||||
try:
|
||||
result = await self.service.llm.generate_response(message=message, tools=tools)
|
||||
elapsed_ms = round((perf_counter() - started_at) * 1000, 2)
|
||||
self.log_turn_event(
|
||||
"llm_completed",
|
||||
operation=operation,
|
||||
elapsed_ms=elapsed_ms,
|
||||
tool_call=bool(result.get("tool_call")),
|
||||
)
|
||||
return result
|
||||
except Exception:
|
||||
elapsed_ms = round((perf_counter() - started_at) * 1000, 2)
|
||||
self.log_turn_event(
|
||||
"llm_failed",
|
||||
operation=operation,
|
||||
elapsed_ms=elapsed_ms,
|
||||
)
|
||||
raise
|
||||
|
||||
def merge_pending_draft_tool_arguments(
|
||||
self,
|
||||
tool_name: str,
|
||||
arguments: dict,
|
||||
user_id: int | None,
|
||||
) -> dict:
|
||||
if user_id is None or not isinstance(arguments, dict):
|
||||
return dict(arguments or {})
|
||||
if not hasattr(self.service, "state") or self.service.state is None:
|
||||
return dict(arguments)
|
||||
|
||||
bucket_map = {
|
||||
"agendar_revisao": "pending_review_drafts",
|
||||
"realizar_pedido": "pending_order_drafts",
|
||||
"cancelar_pedido": "pending_cancel_order_drafts",
|
||||
"cancelar_agendamento_revisao": "pending_review_management_drafts",
|
||||
"editar_data_revisao": "pending_review_management_drafts",
|
||||
}
|
||||
bucket = bucket_map.get(tool_name)
|
||||
if not bucket:
|
||||
return dict(arguments)
|
||||
|
||||
draft = self.service.state.get_entry(bucket, user_id, expire=True)
|
||||
if not isinstance(draft, dict):
|
||||
return dict(arguments)
|
||||
payload = draft.get("payload")
|
||||
if not isinstance(payload, dict):
|
||||
return dict(arguments)
|
||||
|
||||
merged_arguments = dict(payload)
|
||||
merged_arguments.update(arguments)
|
||||
return merged_arguments
|
||||
|
||||
def normalize_tool_invocation(
|
||||
self,
|
||||
tool_name: str,
|
||||
arguments: dict | None,
|
||||
user_id: int | None,
|
||||
) -> tuple[str, dict]:
|
||||
normalizer = getattr(self.service, "normalizer", None)
|
||||
if normalizer is None:
|
||||
normalizer = EntityNormalizer()
|
||||
self.service.normalizer = normalizer
|
||||
normalized_tool_name = normalizer.normalize_tool_name(tool_name) or str(tool_name or "").strip()
|
||||
normalized_arguments = normalizer.normalize_tool_arguments(normalized_tool_name, arguments or {})
|
||||
normalized_arguments = self.merge_pending_draft_tool_arguments(
|
||||
tool_name=normalized_tool_name,
|
||||
arguments=normalized_arguments,
|
||||
user_id=user_id,
|
||||
)
|
||||
return normalized_tool_name, normalized_arguments
|
||||
|
||||
async def execute_tool_with_trace(self, tool_name: str, arguments: dict, user_id: int | None):
|
||||
tool_name, arguments = self.normalize_tool_invocation(
|
||||
tool_name=tool_name,
|
||||
arguments=arguments,
|
||||
user_id=user_id,
|
||||
)
|
||||
self.capture_tool_invocation_trace(tool_name=tool_name, arguments=arguments)
|
||||
started_at = perf_counter()
|
||||
try:
|
||||
result = await self.service.tool_executor.execute(tool_name, arguments, user_id=user_id)
|
||||
elapsed_ms = round((perf_counter() - started_at) * 1000, 2)
|
||||
self.log_turn_event(
|
||||
"tool_completed",
|
||||
tool_name=tool_name,
|
||||
elapsed_ms=elapsed_ms,
|
||||
arguments=arguments,
|
||||
result=result,
|
||||
)
|
||||
return result
|
||||
except HTTPException as exc:
|
||||
elapsed_ms = round((perf_counter() - started_at) * 1000, 2)
|
||||
self.log_turn_event(
|
||||
"tool_failed",
|
||||
tool_name=tool_name,
|
||||
elapsed_ms=elapsed_ms,
|
||||
arguments=arguments,
|
||||
error=self.service.tool_executor.coerce_http_error(exc),
|
||||
)
|
||||
raise
|
||||
|
||||
async def render_tool_response_with_fallback(
|
||||
self,
|
||||
user_message: str,
|
||||
user_id: int | None,
|
||||
tool_name: str,
|
||||
tool_result,
|
||||
) -> str:
|
||||
fallback_response = self.fallback_format_tool_result(tool_name, tool_result)
|
||||
if self.should_use_deterministic_response(tool_name):
|
||||
self.log_turn_event(
|
||||
"tool_response_fallback",
|
||||
tool_name=tool_name,
|
||||
reason="deterministic_tool",
|
||||
)
|
||||
return fallback_response
|
||||
|
||||
try:
|
||||
final_response = await self.call_llm_with_trace(
|
||||
operation="tool_result_response",
|
||||
message=self.build_result_prompt(
|
||||
user_message=user_message,
|
||||
user_id=user_id,
|
||||
tool_name=tool_name,
|
||||
tool_result=tool_result,
|
||||
),
|
||||
tools=[],
|
||||
)
|
||||
except Exception:
|
||||
self.log_turn_event(
|
||||
"tool_response_fallback",
|
||||
tool_name=tool_name,
|
||||
reason="llm_failure",
|
||||
)
|
||||
return fallback_response
|
||||
|
||||
text = (final_response.get("response") or "").strip()
|
||||
if self.is_low_value_response(text):
|
||||
self.log_turn_event(
|
||||
"tool_response_fallback",
|
||||
tool_name=tool_name,
|
||||
reason="low_value_response",
|
||||
)
|
||||
return fallback_response
|
||||
return text or fallback_response
|
||||
|
||||
def should_use_deterministic_response(self, tool_name: str) -> bool:
|
||||
return tool_name in DETERMINISTIC_RESPONSE_TOOLS or tool_name in ORCHESTRATION_CONTROL_TOOLS
|
||||
|
||||
def is_low_value_response(self, text: str) -> bool:
|
||||
return text.strip().lower() in LOW_VALUE_RESPONSES
|
||||
|
||||
def http_exception_detail(self, exc: HTTPException) -> str:
|
||||
return self.service.tool_executor.http_exception_detail(exc)
|
||||
|
||||
def fallback_format_tool_result(self, tool_name: str, tool_result: Any) -> str:
|
||||
return self.service.tool_executor.fallback_format_tool_result(
|
||||
tool_name=tool_name,
|
||||
tool_result=tool_result,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,144 @@
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
_CPF_PATTERN = re.compile(r"(?<!\d)(\d{3}\.?\d{3}\.?\d{3}-?\d{2})(?!\d)")
|
||||
_PLATE_PATTERN = re.compile(r"(?<![A-Za-z0-9])([A-Za-z]{3}\d{4}|[A-Za-z]{3}\d[A-Za-z]\d{2})(?![A-Za-z0-9])")
|
||||
_LABELED_EXTERNAL_ID_PATTERN = re.compile(
|
||||
r'(?i)(["\']?external_id["\']?\s*[:=]\s*["\']?)([A-Za-z0-9._:-]{4,})'
|
||||
)
|
||||
_LABELED_RECEIPT_IDENTIFIER_PATTERN = re.compile(
|
||||
r'(?i)(["\']?(?:identificador(?:_?do)?_?comprovante|comprovante_id|receipt_id|receipt_identifier|nsu|transaction_id|pix_e2e_id|end_to_end_id)["\']?\s*[:=]\s*["\']?)([A-Za-z0-9._:-]{4,})'
|
||||
)
|
||||
|
||||
_CPF_KEYS = {
|
||||
"cpf",
|
||||
"customer_cpf",
|
||||
"cpf_cliente",
|
||||
}
|
||||
_PLATE_KEYS = {
|
||||
"placa",
|
||||
"placa_veiculo",
|
||||
"vehicle_plate",
|
||||
"plate",
|
||||
}
|
||||
_EXTERNAL_ID_KEYS = {
|
||||
"external_id",
|
||||
}
|
||||
_RECEIPT_IDENTIFIER_KEYS = {
|
||||
"identificador_comprovante",
|
||||
"comprovante_id",
|
||||
"receipt_id",
|
||||
"receipt_identifier",
|
||||
"nsu",
|
||||
"transaction_id",
|
||||
"pix_e2e_id",
|
||||
"end_to_end_id",
|
||||
}
|
||||
|
||||
|
||||
def mask_sensitive_text(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value)
|
||||
if not text:
|
||||
return text
|
||||
|
||||
masked = _LABELED_EXTERNAL_ID_PATTERN.sub(
|
||||
lambda match: f"{match.group(1)}{_mask_identifier_value(match.group(2), suffix=3)}",
|
||||
text,
|
||||
)
|
||||
masked = _LABELED_RECEIPT_IDENTIFIER_PATTERN.sub(
|
||||
lambda match: f"{match.group(1)}{_mask_identifier_value(match.group(2), suffix=3)}",
|
||||
masked,
|
||||
)
|
||||
masked = _CPF_PATTERN.sub(lambda match: _mask_cpf_value(match.group(1)), masked)
|
||||
masked = _PLATE_PATTERN.sub(lambda match: _mask_plate_value(match.group(1)), masked)
|
||||
return masked
|
||||
|
||||
|
||||
def mask_sensitive_payload(value: Any, *, key: str | None = None) -> Any:
|
||||
key_kind = _classify_sensitive_key(key)
|
||||
if key_kind is not None:
|
||||
return _mask_value_by_kind(value, key_kind)
|
||||
|
||||
if isinstance(value, dict):
|
||||
return {item_key: mask_sensitive_payload(item_value, key=item_key) for item_key, item_value in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [mask_sensitive_payload(item, key=key) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return tuple(mask_sensitive_payload(item, key=key) for item in value)
|
||||
if isinstance(value, set):
|
||||
return {mask_sensitive_payload(item, key=key) for item in value}
|
||||
if isinstance(value, str):
|
||||
return mask_sensitive_text(value)
|
||||
return value
|
||||
|
||||
|
||||
def _classify_sensitive_key(key: str | None) -> str | None:
|
||||
normalized = _normalize_key(key)
|
||||
if not normalized:
|
||||
return None
|
||||
if normalized in _CPF_KEYS or normalized.endswith("_cpf"):
|
||||
return "cpf"
|
||||
if normalized in _PLATE_KEYS or normalized.endswith("_placa") or normalized.endswith("_plate"):
|
||||
return "placa"
|
||||
if normalized in _EXTERNAL_ID_KEYS:
|
||||
return "external_id"
|
||||
if normalized in _RECEIPT_IDENTIFIER_KEYS:
|
||||
return "receipt_identifier"
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_key(key: str | None) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "_", str(key or "").strip().lower()).strip("_")
|
||||
|
||||
|
||||
def _mask_value_by_kind(value: Any, kind: str) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return text
|
||||
if "*" in text:
|
||||
return text
|
||||
if kind == "cpf":
|
||||
return _mask_cpf_value(text)
|
||||
if kind == "placa":
|
||||
return _mask_plate_value(text)
|
||||
if kind in {"external_id", "receipt_identifier"}:
|
||||
return _mask_identifier_value(text, suffix=3)
|
||||
return mask_sensitive_text(text)
|
||||
|
||||
|
||||
def _mask_cpf_value(value: str) -> str:
|
||||
if "*" in value:
|
||||
return value
|
||||
digits = re.sub(r"\D", "", str(value or ""))
|
||||
if len(digits) >= 2:
|
||||
return f"***.***.***-{digits[-2:]}"
|
||||
return "***.***.***-**"
|
||||
|
||||
|
||||
def _mask_plate_value(value: str) -> str:
|
||||
if "*" in value:
|
||||
return value
|
||||
normalized = re.sub(r"[^A-Za-z0-9]", "", str(value or "")).upper()
|
||||
if not normalized:
|
||||
return "***"
|
||||
if len(normalized) <= 4:
|
||||
return "***"
|
||||
hidden_count = max(len(normalized) - 4, 3)
|
||||
return f"{normalized[:3]}{'*' * hidden_count}{normalized[-1:]}"
|
||||
|
||||
|
||||
def _mask_identifier_value(value: str, *, suffix: int = 3) -> str:
|
||||
if "*" in value:
|
||||
return value
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return text
|
||||
if len(text) <= suffix:
|
||||
return "*" * max(len(text), 3)
|
||||
hidden_count = max(len(text) - suffix, 3)
|
||||
return f"{'*' * hidden_count}{text[-suffix:]}"
|
||||
@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=AI Orquestrador Database Bootstrap
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=vitor
|
||||
Group=vitor
|
||||
WorkingDirectory=/opt/orquestrador
|
||||
EnvironmentFile=/opt/orquestrador/.env.prod
|
||||
Environment=PATH=/opt/orquestrador/venv/bin
|
||||
ExecStart=/opt/orquestrador/venv/bin/python -m app.db.bootstrap
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@ -0,0 +1,39 @@
|
||||
import unittest
|
||||
|
||||
from app.services.orchestration.conversation_state_store import ConversationStateStore
|
||||
|
||||
|
||||
class ConversationStateStoreTests(unittest.TestCase):
|
||||
def test_save_user_context_preserves_existing_expiration_when_missing(self):
|
||||
store = ConversationStateStore()
|
||||
store.upsert_user_context(1, ttl_minutes=30)
|
||||
|
||||
original_expires_at = store.get_user_context(1)["expires_at"]
|
||||
store.save_user_context(
|
||||
1,
|
||||
{
|
||||
"active_domain": "sales",
|
||||
"active_task": "order_create",
|
||||
"generic_memory": {},
|
||||
"shared_memory": {},
|
||||
"collected_slots": {},
|
||||
"flow_snapshots": {},
|
||||
"last_tool_result": None,
|
||||
"order_queue": [],
|
||||
"pending_order_selection": None,
|
||||
"pending_switch": None,
|
||||
"last_stock_results": [],
|
||||
"selected_vehicle": None,
|
||||
"last_rental_results": [],
|
||||
"selected_rental_vehicle": None,
|
||||
},
|
||||
)
|
||||
|
||||
stored_context = store.get_user_context(1)
|
||||
self.assertEqual(stored_context["active_domain"], "sales")
|
||||
self.assertEqual(stored_context["active_task"], "order_create")
|
||||
self.assertEqual(stored_context["expires_at"], original_expires_at)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -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))
|
||||
|
||||
@ -0,0 +1,488 @@
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
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 RentalLockingQuery:
|
||||
def __init__(self, result):
|
||||
self.result = result
|
||||
self.with_for_update_called = False
|
||||
|
||||
def filter(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def with_for_update(self):
|
||||
self.with_for_update_called = True
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return self.result
|
||||
|
||||
|
||||
class RentalLockingSession:
|
||||
def __init__(self, vehicle=None):
|
||||
self.vehicle = vehicle
|
||||
self.query_instance = RentalLockingQuery(vehicle)
|
||||
self.added = []
|
||||
self.committed = False
|
||||
self.closed = False
|
||||
self.refreshed = []
|
||||
|
||||
def query(self, model):
|
||||
if model is rental_service.RentalVehicle:
|
||||
return self.query_instance
|
||||
raise AssertionError(f"unexpected model query: {model}")
|
||||
|
||||
def add(self, item):
|
||||
self.added.append(item)
|
||||
|
||||
def commit(self):
|
||||
self.committed = True
|
||||
|
||||
def refresh(self, item):
|
||||
self.refreshed.append(item)
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
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 _build_threadsafe_session_local(self):
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
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_consultar_frota_aluguel_filtra_por_modelo(self):
|
||||
SessionLocal = self._build_session_local()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
self._create_rental_vehicle(db, placa="AAA1A11", modelo="Chevrolet Tracker")
|
||||
self._create_rental_vehicle(db, placa="BBB2B22", modelo="Fiat Pulse")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
with patch("app.services.domain.rental_service.SessionMockLocal", SessionLocal):
|
||||
result = await rental_service.consultar_frota_aluguel(modelo="tracker")
|
||||
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0]["modelo"], "Chevrolet Tracker")
|
||||
|
||||
async def test_consultar_frota_aluguel_randomiza_resultados_quando_solicitado(self):
|
||||
SessionLocal = self._build_session_local()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
self._create_rental_vehicle(db, placa="AAA1A11", modelo="Chevrolet Tracker", valor_diaria=219.9)
|
||||
self._create_rental_vehicle(db, placa="BBB2B22", modelo="Fiat Pulse", valor_diaria=189.9)
|
||||
self._create_rental_vehicle(db, placa="CCC3C33", modelo="Renault Kwid", valor_diaria=119.9)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
with patch("app.services.domain.rental_service.SessionMockLocal", SessionLocal), patch(
|
||||
"app.services.domain.rental_service.random.shuffle",
|
||||
side_effect=lambda items: items.reverse(),
|
||||
):
|
||||
result = await rental_service.consultar_frota_aluguel(ordenar_diaria="random", limite=2)
|
||||
|
||||
self.assertEqual(len(result), 2)
|
||||
self.assertEqual([item["placa"] for item in result], ["CCC3C33", "BBB2B22"])
|
||||
|
||||
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_abrir_locacao_aluguel_uses_row_lock_before_reserving_vehicle(self):
|
||||
vehicle = RentalVehicle(
|
||||
id=8,
|
||||
placa="ABC1D23",
|
||||
modelo="Chevrolet Tracker",
|
||||
categoria="suv",
|
||||
ano=2024,
|
||||
valor_diaria=219.9,
|
||||
status="disponivel",
|
||||
)
|
||||
session = RentalLockingSession(vehicle=vehicle)
|
||||
fake_uuid = SimpleNamespace(hex="abc123def456")
|
||||
fixed_now = datetime(2026, 3, 20, 9, 0)
|
||||
|
||||
with patch.object(rental_service, "SessionMockLocal", return_value=session), patch.object(
|
||||
rental_service,
|
||||
"uuid4",
|
||||
return_value=fake_uuid,
|
||||
), patch.object(rental_service, "utc_now", return_value=fixed_now):
|
||||
result = await rental_service.abrir_locacao_aluguel(
|
||||
rental_vehicle_id=8,
|
||||
data_inicio="17/03/2026 10:00",
|
||||
data_fim_prevista="20/03/2026 10:00",
|
||||
)
|
||||
|
||||
self.assertTrue(session.query_instance.with_for_update_called)
|
||||
self.assertTrue(session.committed)
|
||||
self.assertEqual(len(session.added), 1)
|
||||
self.assertEqual(session.added[0].rental_vehicle_id, 8)
|
||||
self.assertEqual(vehicle.status, "alugado")
|
||||
self.assertEqual(result["contrato_numero"], "LOC-20260320-ABC123DE")
|
||||
self.assertEqual(result["status_veiculo"], "alugado")
|
||||
self.assertTrue(session.closed)
|
||||
|
||||
async def test_abrir_locacao_aluguel_returns_conflict_when_vehicle_status_is_already_rented_after_lock(self):
|
||||
vehicle = RentalVehicle(
|
||||
id=8,
|
||||
placa="ABC1D23",
|
||||
modelo="Chevrolet Tracker",
|
||||
categoria="suv",
|
||||
ano=2024,
|
||||
valor_diaria=219.9,
|
||||
status="alugado",
|
||||
)
|
||||
session = RentalLockingSession(vehicle=vehicle)
|
||||
|
||||
with patch.object(rental_service, "SessionMockLocal", return_value=session):
|
||||
with self.assertRaises(HTTPException) as ctx:
|
||||
await rental_service.abrir_locacao_aluguel(
|
||||
rental_vehicle_id=8,
|
||||
data_inicio="17/03/2026 10:00",
|
||||
data_fim_prevista="20/03/2026 10:00",
|
||||
)
|
||||
|
||||
self.assertTrue(session.query_instance.with_for_update_called)
|
||||
self.assertEqual(ctx.exception.status_code, 409)
|
||||
self.assertEqual(ctx.exception.detail["code"], "rental_vehicle_unavailable")
|
||||
self.assertFalse(session.committed)
|
||||
self.assertEqual(session.added, [])
|
||||
self.assertTrue(session.closed)
|
||||
|
||||
async def test_abrir_locacao_aluguel_allows_single_success_under_race(self):
|
||||
SessionLocal = self._build_threadsafe_session_local()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
vehicle = self._create_rental_vehicle(db, placa="RAC1E01")
|
||||
vehicle_id = vehicle.id
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
attempts = 4
|
||||
start_barrier = threading.Barrier(attempts)
|
||||
vehicle_lock = threading.Lock()
|
||||
|
||||
def _get_locked_vehicle(db, *, rental_vehicle_id=None, placa=None):
|
||||
acquired = vehicle_lock.acquire(timeout=2)
|
||||
if not acquired:
|
||||
raise AssertionError("failed to acquire rental race lock")
|
||||
|
||||
if not db.info.get("_test_rental_lock_wrapped"):
|
||||
original_close = db.close
|
||||
|
||||
def close_with_lock_release():
|
||||
try:
|
||||
original_close()
|
||||
finally:
|
||||
held_lock = db.info.pop("_test_rental_vehicle_lock", None)
|
||||
if held_lock and held_lock.locked():
|
||||
held_lock.release()
|
||||
|
||||
db.close = close_with_lock_release
|
||||
db.info["_test_rental_lock_wrapped"] = True
|
||||
|
||||
db.info["_test_rental_vehicle_lock"] = vehicle_lock
|
||||
time.sleep(0.05)
|
||||
return rental_service._build_rental_vehicle_query(
|
||||
db,
|
||||
rental_vehicle_id=rental_vehicle_id,
|
||||
placa=placa,
|
||||
).first()
|
||||
|
||||
def _sync_open_rental():
|
||||
start_barrier.wait(timeout=5)
|
||||
return asyncio.run(
|
||||
rental_service.abrir_locacao_aluguel(
|
||||
rental_vehicle_id=vehicle_id,
|
||||
data_inicio="17/03/2026 10:00",
|
||||
data_fim_prevista="20/03/2026 10:00",
|
||||
)
|
||||
)
|
||||
|
||||
with patch.object(rental_service, "SessionMockLocal", SessionLocal), patch.object(
|
||||
rental_service,
|
||||
"_get_rental_vehicle_for_update",
|
||||
side_effect=_get_locked_vehicle,
|
||||
):
|
||||
results = await asyncio.gather(
|
||||
*[asyncio.to_thread(_sync_open_rental) for _ in range(attempts)],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
successes = [result for result in results if isinstance(result, dict)]
|
||||
conflicts = [
|
||||
result
|
||||
for result in results
|
||||
if isinstance(result, HTTPException)
|
||||
and isinstance(result.detail, dict)
|
||||
and result.detail.get("code") == "rental_vehicle_unavailable"
|
||||
]
|
||||
unexpected = [result for result in results if result not in successes and result not in conflicts]
|
||||
|
||||
self.assertEqual(len(successes), 1)
|
||||
self.assertEqual(len(conflicts), attempts - 1)
|
||||
self.assertEqual(unexpected, [])
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
contracts = db.query(RentalContract).all()
|
||||
stored_vehicle = db.query(RentalVehicle).filter(RentalVehicle.id == vehicle_id).one()
|
||||
self.assertEqual(len(contracts), 1)
|
||||
self.assertEqual(stored_vehicle.status, "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()
|
||||
@ -0,0 +1,234 @@
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db.mock_database import MockBase
|
||||
from app.db.mock_models import ReviewSchedule
|
||||
from app.services.domain import review_service
|
||||
|
||||
|
||||
class ReviewLockingQuery:
|
||||
def __init__(self, results=None):
|
||||
self.results = list(results or [])
|
||||
|
||||
def filter(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
if self.results:
|
||||
return self.results.pop(0)
|
||||
return None
|
||||
|
||||
|
||||
class ReviewLockingSession:
|
||||
def __init__(self, *, query_results=None, lock_acquired=1):
|
||||
self.query_instance = ReviewLockingQuery(query_results)
|
||||
self.lock_acquired = lock_acquired
|
||||
self.execute_calls = []
|
||||
self.added = []
|
||||
self.committed = False
|
||||
self.closed = False
|
||||
self.refreshed = []
|
||||
|
||||
def query(self, model):
|
||||
if model is review_service.ReviewSchedule:
|
||||
return self.query_instance
|
||||
raise AssertionError(f"unexpected model query: {model}")
|
||||
|
||||
def execute(self, statement, params=None):
|
||||
sql_text = str(statement)
|
||||
self.execute_calls.append((sql_text, params))
|
||||
if "GET_LOCK" in sql_text:
|
||||
return SimpleNamespace(scalar=lambda: self.lock_acquired)
|
||||
if "RELEASE_LOCK" in sql_text:
|
||||
return SimpleNamespace(scalar=lambda: 1)
|
||||
raise AssertionError(f"unexpected execute call: {sql_text}")
|
||||
|
||||
def add(self, item):
|
||||
self.added.append(item)
|
||||
|
||||
def commit(self):
|
||||
self.committed = True
|
||||
|
||||
def refresh(self, item):
|
||||
self.refreshed.append(item)
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
class ReviewServiceLockingTests(unittest.IsolatedAsyncioTestCase):
|
||||
def _build_threadsafe_session_local(self):
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
MockBase.metadata.create_all(bind=engine)
|
||||
self.addCleanup(engine.dispose)
|
||||
return SessionLocal
|
||||
|
||||
def test_acquire_review_slot_lock_returns_conflict_when_slot_is_busy(self):
|
||||
session = ReviewLockingSession(lock_acquired=0)
|
||||
|
||||
with self.assertRaises(HTTPException) as ctx:
|
||||
review_service._acquire_review_slot_lock(
|
||||
session,
|
||||
requested_dt=datetime(2026, 3, 18, 9, 0),
|
||||
)
|
||||
|
||||
self.assertEqual(ctx.exception.status_code, 409)
|
||||
self.assertEqual(ctx.exception.detail["code"], "review_slot_busy")
|
||||
self.assertTrue(any("GET_LOCK" in sql for sql, _ in session.execute_calls))
|
||||
|
||||
async def test_agendar_revisao_uses_slot_lock_and_releases_after_success(self):
|
||||
session = ReviewLockingSession(query_results=[None, None])
|
||||
|
||||
with patch.object(review_service, "SessionMockLocal", return_value=session):
|
||||
result = await review_service.agendar_revisao(
|
||||
placa="ABC1234",
|
||||
data_hora="18/03/2026 09:00",
|
||||
modelo="Onix",
|
||||
ano=2022,
|
||||
km=15000,
|
||||
revisao_previa_concessionaria=False,
|
||||
user_id=7,
|
||||
)
|
||||
|
||||
self.assertTrue(any("GET_LOCK" in sql for sql, _ in session.execute_calls))
|
||||
self.assertTrue(any("RELEASE_LOCK" in sql for sql, _ in session.execute_calls))
|
||||
self.assertTrue(session.committed)
|
||||
self.assertEqual(len(session.added), 1)
|
||||
self.assertEqual(result["status"], "agendado")
|
||||
self.assertTrue(session.closed)
|
||||
|
||||
async def test_editar_data_revisao_releases_slot_lock_when_conflict_is_detected(self):
|
||||
current_schedule = ReviewSchedule(
|
||||
id=1,
|
||||
protocolo="REV-20260318-AAAA1111",
|
||||
user_id=7,
|
||||
placa="ABC1234",
|
||||
data_hora=datetime(2026, 3, 18, 9, 0),
|
||||
status="agendado",
|
||||
)
|
||||
conflicting_schedule = ReviewSchedule(
|
||||
id=2,
|
||||
protocolo="REV-20260319-BBBB2222",
|
||||
user_id=8,
|
||||
placa="XYZ9876",
|
||||
data_hora=datetime(2026, 3, 19, 10, 0),
|
||||
status="agendado",
|
||||
)
|
||||
session = ReviewLockingSession(query_results=[current_schedule, conflicting_schedule])
|
||||
|
||||
with patch.object(review_service, "SessionMockLocal", return_value=session):
|
||||
with self.assertRaises(HTTPException) as ctx:
|
||||
await review_service.editar_data_revisao(
|
||||
protocolo=current_schedule.protocolo,
|
||||
nova_data_hora="19/03/2026 10:00",
|
||||
user_id=7,
|
||||
)
|
||||
|
||||
self.assertTrue(any("GET_LOCK" in sql for sql, _ in session.execute_calls))
|
||||
self.assertTrue(any("RELEASE_LOCK" in sql for sql, _ in session.execute_calls))
|
||||
self.assertEqual(ctx.exception.status_code, 409)
|
||||
self.assertEqual(ctx.exception.detail["code"], "review_schedule_conflict")
|
||||
self.assertFalse(session.committed)
|
||||
self.assertTrue(session.closed)
|
||||
|
||||
async def test_agendar_revisao_allows_single_success_under_race(self):
|
||||
SessionLocal = self._build_threadsafe_session_local()
|
||||
attempts = 4
|
||||
start_barrier = threading.Barrier(attempts)
|
||||
slot_locks: dict[str, threading.Lock] = {}
|
||||
slot_locks_guard = threading.Lock()
|
||||
|
||||
def _acquire_slot_lock(db, *, requested_dt, timeout_seconds=5, field_name="data_hora"):
|
||||
lock_name = review_service._review_slot_lock_name(requested_dt)
|
||||
with slot_locks_guard:
|
||||
slot_lock = slot_locks.setdefault(lock_name, threading.Lock())
|
||||
acquired = slot_lock.acquire(timeout=timeout_seconds)
|
||||
if not acquired:
|
||||
review_service.raise_tool_http_error(
|
||||
status_code=409,
|
||||
code="review_slot_busy",
|
||||
message="Outro atendimento esta finalizando este horario de revisao. Tente novamente.",
|
||||
retryable=True,
|
||||
field=field_name,
|
||||
)
|
||||
db.info.setdefault("_test_review_slot_locks", {})[lock_name] = slot_lock
|
||||
time.sleep(0.05)
|
||||
return lock_name
|
||||
|
||||
def _release_slot_lock(db, lock_name):
|
||||
if not lock_name:
|
||||
return
|
||||
held_lock = db.info.get("_test_review_slot_locks", {}).pop(lock_name, None)
|
||||
if held_lock and held_lock.locked():
|
||||
held_lock.release()
|
||||
|
||||
def _sync_schedule_review():
|
||||
start_barrier.wait(timeout=5)
|
||||
return asyncio.run(
|
||||
review_service.agendar_revisao(
|
||||
placa="ABC1234",
|
||||
data_hora="18/03/2026 09:00",
|
||||
modelo="Onix",
|
||||
ano=2022,
|
||||
km=15000,
|
||||
revisao_previa_concessionaria=False,
|
||||
user_id=7,
|
||||
)
|
||||
)
|
||||
|
||||
with patch.object(review_service, "SessionMockLocal", SessionLocal), patch.object(
|
||||
review_service,
|
||||
"_acquire_review_slot_lock",
|
||||
side_effect=_acquire_slot_lock,
|
||||
), patch.object(
|
||||
review_service,
|
||||
"_release_review_slot_lock",
|
||||
side_effect=_release_slot_lock,
|
||||
):
|
||||
results = await asyncio.gather(
|
||||
*[asyncio.to_thread(_sync_schedule_review) for _ in range(attempts)],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
successes = [result for result in results if isinstance(result, dict)]
|
||||
conflict_codes = {"review_schedule_conflict", "review_slot_busy"}
|
||||
conflicts = [
|
||||
result
|
||||
for result in results
|
||||
if isinstance(result, HTTPException)
|
||||
and isinstance(result.detail, dict)
|
||||
and result.detail.get("code") in conflict_codes
|
||||
]
|
||||
unexpected = [result for result in results if result not in successes and result not in conflicts]
|
||||
|
||||
self.assertEqual(len(successes), 1)
|
||||
self.assertEqual(len(conflicts), attempts - 1)
|
||||
self.assertEqual(unexpected, [])
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
schedules = db.query(ReviewSchedule).all()
|
||||
self.assertEqual(len(schedules), 1)
|
||||
self.assertEqual(schedules[0].status, "agendado")
|
||||
self.assertEqual(schedules[0].placa, "ABC1234")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -0,0 +1,467 @@
|
||||
import unittest
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import aiohttp
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.integrations.telegram_satellite_service import (
|
||||
TELEGRAM_RUNTIME_BUCKET,
|
||||
TELEGRAM_RUNTIME_OWNER_ID,
|
||||
TelegramSatelliteService,
|
||||
)
|
||||
from app.services.orchestration.conversation_state_store import ConversationStateStore
|
||||
|
||||
|
||||
class _DummySession:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
|
||||
class _FakeTelegramResponse:
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return None
|
||||
|
||||
async def json(self):
|
||||
return self.payload
|
||||
|
||||
|
||||
class _FakeTelegramSession:
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
self.calls = []
|
||||
|
||||
def post(self, url, json):
|
||||
self.calls.append((url, json))
|
||||
return _FakeTelegramResponse(self.payload)
|
||||
|
||||
|
||||
class _FlakyTelegramResponse:
|
||||
def __init__(self, outcome):
|
||||
self.outcome = outcome
|
||||
|
||||
async def __aenter__(self):
|
||||
if isinstance(self.outcome, BaseException):
|
||||
raise self.outcome
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return None
|
||||
|
||||
async def json(self):
|
||||
return self.outcome
|
||||
|
||||
|
||||
class _FlakyTelegramSession:
|
||||
def __init__(self, outcomes):
|
||||
self.outcomes = list(outcomes)
|
||||
self.calls = []
|
||||
|
||||
def post(self, url, json):
|
||||
self.calls.append((url, json))
|
||||
if self.outcomes:
|
||||
outcome = self.outcomes.pop(0)
|
||||
else:
|
||||
outcome = {"ok": True}
|
||||
return _FlakyTelegramResponse(outcome)
|
||||
|
||||
|
||||
class TelegramMultimodalTests(unittest.IsolatedAsyncioTestCase):
|
||||
def _build_service(self) -> TelegramSatelliteService:
|
||||
service = TelegramSatelliteService(
|
||||
"token-teste",
|
||||
state_repository=ConversationStateStore(),
|
||||
)
|
||||
self._service_under_test = service
|
||||
return service
|
||||
|
||||
async def asyncTearDown(self):
|
||||
service = getattr(self, "_service_under_test", None)
|
||||
if service is not None:
|
||||
await service._shutdown_chat_workers()
|
||||
|
||||
async def test_process_message_uses_extracted_image_message(self):
|
||||
service = self._build_service()
|
||||
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 = self._build_service()
|
||||
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)
|
||||
|
||||
async def test_process_message_returns_direct_failure_for_receipt_without_watermark(self):
|
||||
service = self._build_service()
|
||||
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="O comprovante enviado nao e valido. Envie um comprovante valido com a marca d'agua SysaltiIA visivel."),
|
||||
):
|
||||
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="segue o comprovante",
|
||||
sender={"id": 99},
|
||||
chat_id=99,
|
||||
image_attachments=[{"mime_type": "image/jpeg", "data": b"123"}],
|
||||
)
|
||||
|
||||
self.assertIn("marca d'agua SysaltiIA visivel", answer)
|
||||
self.assertFalse(orchestrator_cls.return_value.handle_message.await_count)
|
||||
|
||||
async def test_process_message_offloads_blocking_turn_to_worker_thread(self):
|
||||
service = self._build_service()
|
||||
|
||||
with patch(
|
||||
"app.integrations.telegram_satellite_service.asyncio.to_thread",
|
||||
AsyncMock(return_value="ok"),
|
||||
) as to_thread:
|
||||
answer = await service._process_message(
|
||||
text="quero ver a frota",
|
||||
sender={"id": 99, "first_name": "Vitor"},
|
||||
chat_id=99,
|
||||
image_attachments=[],
|
||||
)
|
||||
|
||||
self.assertEqual(answer, "ok")
|
||||
self.assertEqual(to_thread.await_count, 1)
|
||||
self.assertEqual(to_thread.await_args.kwargs["message_text"], "quero ver a frota")
|
||||
self.assertEqual(to_thread.await_args.kwargs["chat_id"], 99)
|
||||
self.assertEqual(to_thread.await_args.kwargs["sender"]["id"], 99)
|
||||
|
||||
async def test_handle_update_masks_sensitive_domain_error_in_logs(self):
|
||||
service = self._build_service()
|
||||
update = {
|
||||
"update_id": 1,
|
||||
"message": {
|
||||
"chat": {"id": 99},
|
||||
"from": {"id": 99},
|
||||
"text": "segue o pagamento",
|
||||
},
|
||||
}
|
||||
|
||||
with patch.object(service, "_extract_image_attachments", AsyncMock(return_value=[])), patch.object(
|
||||
service,
|
||||
"_process_message",
|
||||
AsyncMock(
|
||||
side_effect=HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"cpf": "12345678909",
|
||||
"placa": "ABC1D23",
|
||||
"external_id": "987654321",
|
||||
"identificador_comprovante": "NSU123",
|
||||
},
|
||||
)
|
||||
),
|
||||
), patch.object(service, "_send_message", AsyncMock()), patch(
|
||||
"app.integrations.telegram_satellite_service.logger.warning"
|
||||
) as logger_warning:
|
||||
await service._handle_update(session=SimpleNamespace(), update=update)
|
||||
|
||||
self.assertTrue(logger_warning.called)
|
||||
logged_detail = str(logger_warning.call_args.args[1])
|
||||
self.assertNotIn("12345678909", logged_detail)
|
||||
self.assertNotIn("ABC1D23", logged_detail)
|
||||
self.assertNotIn("987654321", logged_detail)
|
||||
self.assertNotIn("NSU123", logged_detail)
|
||||
self.assertIn("***.***.***-09", logged_detail)
|
||||
self.assertIn("ABC***3", logged_detail)
|
||||
self.assertIn("******321", logged_detail)
|
||||
self.assertIn("***123", logged_detail)
|
||||
|
||||
async def test_handle_update_reuses_cached_answer_for_duplicate_message(self):
|
||||
service = self._build_service()
|
||||
update = {
|
||||
"update_id": 10,
|
||||
"message": {
|
||||
"message_id": 77,
|
||||
"chat": {"id": 99},
|
||||
"from": {"id": 99},
|
||||
"text": "quero ver a frota",
|
||||
},
|
||||
}
|
||||
|
||||
with patch.object(service, "_extract_image_attachments", AsyncMock(return_value=[])), patch.object(
|
||||
service,
|
||||
"_process_message",
|
||||
AsyncMock(return_value="Segue a frota disponivel."),
|
||||
) as process_message, patch.object(service, "_send_message", AsyncMock()) as send_message:
|
||||
await service._handle_update(session=SimpleNamespace(), update=update)
|
||||
await service._handle_update(session=SimpleNamespace(), update=update)
|
||||
|
||||
self.assertEqual(process_message.await_count, 1)
|
||||
self.assertEqual(send_message.await_count, 2)
|
||||
first_text = send_message.await_args_list[0].kwargs["text"]
|
||||
second_text = send_message.await_args_list[1].kwargs["text"]
|
||||
self.assertEqual(first_text, "Segue a frota disponivel.")
|
||||
self.assertEqual(second_text, "Segue a frota disponivel.")
|
||||
|
||||
async def test_handle_update_processes_same_text_again_when_message_id_changes(self):
|
||||
service = self._build_service()
|
||||
first_update = {
|
||||
"update_id": 10,
|
||||
"message": {
|
||||
"message_id": 77,
|
||||
"chat": {"id": 99},
|
||||
"from": {"id": 99},
|
||||
"text": "quero ver a frota",
|
||||
},
|
||||
}
|
||||
second_update = {
|
||||
"update_id": 11,
|
||||
"message": {
|
||||
"message_id": 78,
|
||||
"chat": {"id": 99},
|
||||
"from": {"id": 99},
|
||||
"text": "quero ver a frota",
|
||||
},
|
||||
}
|
||||
|
||||
with patch.object(service, "_extract_image_attachments", AsyncMock(return_value=[])), patch.object(
|
||||
service,
|
||||
"_process_message",
|
||||
AsyncMock(side_effect=["Resposta 1", "Resposta 2"]),
|
||||
) as process_message, patch.object(service, "_send_message", AsyncMock()) as send_message:
|
||||
await service._handle_update(session=SimpleNamespace(), update=first_update)
|
||||
await service._handle_update(session=SimpleNamespace(), update=second_update)
|
||||
|
||||
self.assertEqual(process_message.await_count, 2)
|
||||
self.assertEqual(send_message.await_count, 2)
|
||||
self.assertEqual(send_message.await_args_list[0].kwargs["text"], "Resposta 1")
|
||||
self.assertEqual(send_message.await_args_list[1].kwargs["text"], "Resposta 2")
|
||||
|
||||
async def test_initialize_offset_uses_persisted_cursor(self):
|
||||
service = self._build_service()
|
||||
service.state.set_entry(
|
||||
TELEGRAM_RUNTIME_BUCKET,
|
||||
TELEGRAM_RUNTIME_OWNER_ID,
|
||||
{"last_update_id": 41},
|
||||
)
|
||||
|
||||
offset = await service._initialize_offset(session=SimpleNamespace())
|
||||
|
||||
self.assertEqual(offset, 42)
|
||||
self.assertEqual(service._last_update_id, 41)
|
||||
|
||||
async def test_initialize_offset_bootstraps_cursor_once_when_missing(self):
|
||||
service = self._build_service()
|
||||
session = _FakeTelegramSession(
|
||||
{
|
||||
"ok": True,
|
||||
"result": [
|
||||
{"update_id": 5},
|
||||
{"update_id": 6},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
offset = await service._initialize_offset(session=session)
|
||||
|
||||
self.assertEqual(offset, 7)
|
||||
self.assertEqual(service._last_update_id, 6)
|
||||
entry = service.state.get_entry(TELEGRAM_RUNTIME_BUCKET, TELEGRAM_RUNTIME_OWNER_ID)
|
||||
self.assertEqual(entry["last_update_id"], 6)
|
||||
self.assertEqual(len(session.calls), 1)
|
||||
|
||||
async def test_handle_update_persists_runtime_cursor(self):
|
||||
service = self._build_service()
|
||||
update = {
|
||||
"update_id": 14,
|
||||
"message": {
|
||||
"message_id": 88,
|
||||
"chat": {"id": 99},
|
||||
"from": {"id": 99},
|
||||
"text": "status do pedido",
|
||||
},
|
||||
}
|
||||
|
||||
with patch.object(service, "_extract_image_attachments", AsyncMock(return_value=[])), patch.object(
|
||||
service,
|
||||
"_process_message",
|
||||
AsyncMock(return_value="Pedido encontrado."),
|
||||
), patch.object(service, "_send_message", AsyncMock()):
|
||||
await service._handle_update(session=SimpleNamespace(), update=update)
|
||||
|
||||
entry = service.state.get_entry(TELEGRAM_RUNTIME_BUCKET, TELEGRAM_RUNTIME_OWNER_ID)
|
||||
self.assertEqual(entry["last_update_id"], 14)
|
||||
|
||||
async def test_send_message_retries_transient_transport_failure(self):
|
||||
service = self._build_service()
|
||||
session = _FlakyTelegramSession(
|
||||
[
|
||||
asyncio.TimeoutError(),
|
||||
aiohttp.ClientConnectionError("falha temporaria"),
|
||||
{"ok": True},
|
||||
]
|
||||
)
|
||||
|
||||
with patch("app.integrations.telegram_satellite_service.asyncio.sleep", AsyncMock()) as sleep_mock:
|
||||
await service._send_message(session=session, chat_id=99, text="resposta teste")
|
||||
|
||||
self.assertEqual(len(session.calls), 3)
|
||||
self.assertEqual(sleep_mock.await_count, 2)
|
||||
|
||||
async def test_handle_update_swallows_unexpected_delivery_failure(self):
|
||||
service = self._build_service()
|
||||
update = {
|
||||
"update_id": 15,
|
||||
"message": {
|
||||
"message_id": 89,
|
||||
"chat": {"id": 99},
|
||||
"from": {"id": 99},
|
||||
"text": "status do pedido",
|
||||
},
|
||||
}
|
||||
|
||||
with patch.object(service, "_extract_image_attachments", AsyncMock(return_value=[])), patch.object(
|
||||
service,
|
||||
"_process_message",
|
||||
AsyncMock(return_value="Pedido encontrado."),
|
||||
), patch.object(
|
||||
service,
|
||||
"_send_message",
|
||||
AsyncMock(side_effect=RuntimeError("falha inesperada de entrega")),
|
||||
), patch("app.integrations.telegram_satellite_service.logger.exception") as logger_exception:
|
||||
await service._handle_update(session=SimpleNamespace(), update=update)
|
||||
|
||||
self.assertTrue(logger_exception.called)
|
||||
|
||||
async def test_persist_last_processed_update_id_keeps_highest_seen_value(self):
|
||||
service = self._build_service()
|
||||
|
||||
service._persist_last_processed_update_id(11)
|
||||
service._persist_last_processed_update_id(10)
|
||||
|
||||
entry = service.state.get_entry(TELEGRAM_RUNTIME_BUCKET, TELEGRAM_RUNTIME_OWNER_ID)
|
||||
self.assertEqual(entry["last_update_id"], 11)
|
||||
self.assertEqual(service._last_update_id, 11)
|
||||
|
||||
async def test_schedule_update_processing_allows_parallel_chats(self):
|
||||
service = self._build_service()
|
||||
release_first_chat = asyncio.Event()
|
||||
chat_one_started = asyncio.Event()
|
||||
started_chats: list[int] = []
|
||||
|
||||
async def fake_handle_update(*, session, update):
|
||||
chat_id = update["message"]["chat"]["id"]
|
||||
started_chats.append(chat_id)
|
||||
if chat_id == 1:
|
||||
chat_one_started.set()
|
||||
await release_first_chat.wait()
|
||||
|
||||
with patch.object(service, "_handle_update", new=fake_handle_update):
|
||||
await service._schedule_update_processing(
|
||||
session=SimpleNamespace(),
|
||||
update={"update_id": 1, "message": {"chat": {"id": 1}, "text": "primeiro"}},
|
||||
)
|
||||
await chat_one_started.wait()
|
||||
await service._schedule_update_processing(
|
||||
session=SimpleNamespace(),
|
||||
update={"update_id": 2, "message": {"chat": {"id": 2}, "text": "segundo"}},
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
self.assertEqual(started_chats, [1, 2])
|
||||
release_first_chat.set()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
async def test_schedule_update_processing_preserves_order_per_chat(self):
|
||||
service = self._build_service()
|
||||
first_started = asyncio.Event()
|
||||
allow_first_to_finish = asyncio.Event()
|
||||
second_started = asyncio.Event()
|
||||
started_updates: list[int] = []
|
||||
|
||||
async def fake_handle_update(*, session, update):
|
||||
update_id = update["update_id"]
|
||||
started_updates.append(update_id)
|
||||
if update_id == 1:
|
||||
first_started.set()
|
||||
await allow_first_to_finish.wait()
|
||||
return
|
||||
second_started.set()
|
||||
|
||||
with patch.object(service, "_handle_update", new=fake_handle_update):
|
||||
await service._schedule_update_processing(
|
||||
session=SimpleNamespace(),
|
||||
update={"update_id": 1, "message": {"chat": {"id": 1}, "text": "primeiro"}},
|
||||
)
|
||||
await first_started.wait()
|
||||
await service._schedule_update_processing(
|
||||
session=SimpleNamespace(),
|
||||
update={"update_id": 2, "message": {"chat": {"id": 1}, "text": "segundo"}},
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
self.assertFalse(second_started.is_set())
|
||||
|
||||
allow_first_to_finish.set()
|
||||
await asyncio.wait_for(second_started.wait(), timeout=1)
|
||||
|
||||
self.assertEqual(started_updates, [1, 2])
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue