🚧 feat(rental): estruturar fluxo multimodal de aluguel no Telegram
- adiciona frota, contratos e eventos de aluguel ao banco mock, ao seed operacional e ao bootstrap para habilitar o dominio de locacao de ponta a ponta no ambiente local - cria o rental_service e o rental_flow com listagem da frota, selecao guiada por numero/placa/modelo, abertura e devolucao de contratos e continuidade incremental no orquestrador - integra o processamento multimodal no Telegram para comprovantes e multas de aluguel, amplia o estado conversacional com contexto de locacao e fixa a resposta deterministica da listagem para permitir escolha apos a consulta - adiciona cobertura para servico, seed, separacao entre compra e locacao, follow-ups do fluxo, resumo de contexto e cenarios multimodais do Telegram # Conflicts: # app/db/mock_seed.py # app/services/orchestration/orchestrator_config.py # tests/test_conversation_adjustments.pymain
parent
876b0dd2d1
commit
0ba1660c20
@ -0,0 +1,562 @@
|
|||||||
|
import math
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from app.core.time_utils import utc_now
|
||||||
|
from app.db.mock_database import SessionMockLocal
|
||||||
|
from app.db.mock_models import RentalContract, RentalFine, RentalPayment, RentalVehicle, User
|
||||||
|
from app.services.domain.tool_errors import raise_tool_http_error
|
||||||
|
from app.services.orchestration import technical_normalizer
|
||||||
|
from app.services.user.mock_customer_service import hydrate_mock_customer_from_cpf
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_contract_number(value: str | None) -> str | None:
|
||||||
|
text = str(value or "").strip().upper()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_text_field(value: str | None) -> str | None:
|
||||||
|
text = str(value or "").strip(" ,.;")
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_optional_datetime(value: str | None, *, field_name: str) -> datetime | None:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
|
||||||
|
normalized = re.sub(r"\s+(?:as|às)\s+", " ", text, flags=re.IGNORECASE)
|
||||||
|
for candidate in (text, normalized):
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(candidate.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for fmt in (
|
||||||
|
"%d/%m/%Y %H:%M",
|
||||||
|
"%d/%m/%Y",
|
||||||
|
"%Y-%m-%d %H:%M",
|
||||||
|
"%Y-%m-%d",
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(normalized, fmt)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
raise_tool_http_error(
|
||||||
|
status_code=400,
|
||||||
|
code="invalid_rental_datetime",
|
||||||
|
message=(
|
||||||
|
f"{field_name} invalido. Exemplos aceitos: "
|
||||||
|
"17/03/2026, 17/03/2026 14:30, 2026-03-17, 2026-03-17 14:30."
|
||||||
|
),
|
||||||
|
retryable=True,
|
||||||
|
field=field_name,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_required_datetime(value: str | None, *, field_name: str) -> datetime:
|
||||||
|
parsed = _parse_optional_datetime(value, field_name=field_name)
|
||||||
|
if parsed is None:
|
||||||
|
raise_tool_http_error(
|
||||||
|
status_code=400,
|
||||||
|
code="missing_rental_datetime",
|
||||||
|
message=f"Informe {field_name} para continuar a locacao.",
|
||||||
|
retryable=True,
|
||||||
|
field=field_name,
|
||||||
|
)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_money(value) -> float:
|
||||||
|
number = technical_normalizer.normalize_positive_number(value)
|
||||||
|
if number is None or float(number) <= 0:
|
||||||
|
raise_tool_http_error(
|
||||||
|
status_code=400,
|
||||||
|
code="invalid_amount",
|
||||||
|
message="Informe um valor monetario valido maior que zero.",
|
||||||
|
retryable=True,
|
||||||
|
field="valor",
|
||||||
|
)
|
||||||
|
return float(number)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_vehicle_id(value) -> int | None:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
numeric = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise_tool_http_error(
|
||||||
|
status_code=400,
|
||||||
|
code="invalid_rental_vehicle_id",
|
||||||
|
message="Informe um identificador numerico de veiculo para locacao.",
|
||||||
|
retryable=True,
|
||||||
|
field="rental_vehicle_id",
|
||||||
|
)
|
||||||
|
if numeric <= 0:
|
||||||
|
raise_tool_http_error(
|
||||||
|
status_code=400,
|
||||||
|
code="invalid_rental_vehicle_id",
|
||||||
|
message="Informe um identificador numerico de veiculo para locacao.",
|
||||||
|
retryable=True,
|
||||||
|
field="rental_vehicle_id",
|
||||||
|
)
|
||||||
|
return numeric
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_rental_days(start: datetime, end: datetime) -> int:
|
||||||
|
delta_seconds = (end - start).total_seconds()
|
||||||
|
if delta_seconds < 0:
|
||||||
|
raise_tool_http_error(
|
||||||
|
status_code=400,
|
||||||
|
code="invalid_rental_period",
|
||||||
|
message="A data final da locacao nao pode ser anterior a data inicial.",
|
||||||
|
retryable=True,
|
||||||
|
field="data_fim_prevista",
|
||||||
|
)
|
||||||
|
if delta_seconds == 0:
|
||||||
|
return 1
|
||||||
|
return max(1, math.ceil(delta_seconds / 86400))
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_rental_vehicle(
|
||||||
|
db,
|
||||||
|
*,
|
||||||
|
rental_vehicle_id: int | None = None,
|
||||||
|
placa: str | None = None,
|
||||||
|
) -> RentalVehicle | None:
|
||||||
|
if rental_vehicle_id is not None:
|
||||||
|
return db.query(RentalVehicle).filter(RentalVehicle.id == rental_vehicle_id).first()
|
||||||
|
|
||||||
|
normalized_plate = technical_normalizer.normalize_plate(placa)
|
||||||
|
if normalized_plate:
|
||||||
|
return db.query(RentalVehicle).filter(RentalVehicle.placa == normalized_plate).first()
|
||||||
|
|
||||||
|
raise_tool_http_error(
|
||||||
|
status_code=400,
|
||||||
|
code="missing_rental_vehicle_reference",
|
||||||
|
message="Informe a placa ou o identificador do veiculo de locacao.",
|
||||||
|
retryable=True,
|
||||||
|
field="placa",
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_contract_by_user_preference(query, user_id: int | None):
|
||||||
|
if user_id is None:
|
||||||
|
return query.order_by(RentalContract.created_at.desc()).first()
|
||||||
|
|
||||||
|
own_contract = query.filter(RentalContract.user_id == user_id).order_by(RentalContract.created_at.desc()).first()
|
||||||
|
if own_contract is not None:
|
||||||
|
return own_contract
|
||||||
|
|
||||||
|
return query.filter(RentalContract.user_id.is_(None)).order_by(RentalContract.created_at.desc()).first()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_rental_contract(
|
||||||
|
db,
|
||||||
|
*,
|
||||||
|
contrato_numero: str | None = None,
|
||||||
|
placa: str | None = None,
|
||||||
|
user_id: int | None = None,
|
||||||
|
active_only: bool = False,
|
||||||
|
) -> RentalContract | None:
|
||||||
|
normalized_contract = _normalize_contract_number(contrato_numero)
|
||||||
|
normalized_plate = technical_normalizer.normalize_plate(placa)
|
||||||
|
|
||||||
|
if normalized_contract:
|
||||||
|
query = db.query(RentalContract).filter(RentalContract.contrato_numero == normalized_contract)
|
||||||
|
if active_only:
|
||||||
|
query = query.filter(RentalContract.status == "ativa")
|
||||||
|
contract = _lookup_contract_by_user_preference(query, user_id)
|
||||||
|
if contract is not None:
|
||||||
|
return contract
|
||||||
|
|
||||||
|
if normalized_plate:
|
||||||
|
query = db.query(RentalContract).filter(RentalContract.placa == normalized_plate)
|
||||||
|
if active_only:
|
||||||
|
query = query.filter(RentalContract.status == "ativa")
|
||||||
|
contract = _lookup_contract_by_user_preference(query, user_id)
|
||||||
|
if contract is not None:
|
||||||
|
return contract
|
||||||
|
|
||||||
|
if user_id is not None:
|
||||||
|
query = db.query(RentalContract).filter(RentalContract.user_id == user_id)
|
||||||
|
if active_only:
|
||||||
|
query = query.filter(RentalContract.status == "ativa")
|
||||||
|
contracts = query.order_by(RentalContract.created_at.desc()).limit(2).all()
|
||||||
|
if len(contracts) == 1:
|
||||||
|
return contracts[0]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def consultar_frota_aluguel(
|
||||||
|
categoria: str | None = None,
|
||||||
|
valor_diaria_max: float | None = None,
|
||||||
|
modelo: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
ordenar_diaria: str | None = None,
|
||||||
|
limite: int | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
db = SessionMockLocal()
|
||||||
|
try:
|
||||||
|
query = db.query(RentalVehicle)
|
||||||
|
|
||||||
|
normalized_status = str(status or "").strip().lower() or "disponivel"
|
||||||
|
query = query.filter(RentalVehicle.status == normalized_status)
|
||||||
|
|
||||||
|
if categoria:
|
||||||
|
query = query.filter(RentalVehicle.categoria == str(categoria).strip().lower())
|
||||||
|
|
||||||
|
if valor_diaria_max is not None:
|
||||||
|
max_value = technical_normalizer.normalize_positive_number(valor_diaria_max)
|
||||||
|
if max_value is not None:
|
||||||
|
query = query.filter(RentalVehicle.valor_diaria <= float(max_value))
|
||||||
|
|
||||||
|
if modelo:
|
||||||
|
query = query.filter(RentalVehicle.modelo.ilike(f"%{str(modelo).strip()}%"))
|
||||||
|
|
||||||
|
if ordenar_diaria in {"asc", "desc"}:
|
||||||
|
query = query.order_by(
|
||||||
|
RentalVehicle.valor_diaria.asc()
|
||||||
|
if ordenar_diaria == "asc"
|
||||||
|
else RentalVehicle.valor_diaria.desc()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
query = query.order_by(RentalVehicle.valor_diaria.asc(), RentalVehicle.modelo.asc())
|
||||||
|
|
||||||
|
if limite is not None:
|
||||||
|
try:
|
||||||
|
query = query.limit(max(1, min(int(limite), 50)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
rows = query.all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": row.id,
|
||||||
|
"placa": row.placa,
|
||||||
|
"modelo": row.modelo,
|
||||||
|
"categoria": row.categoria,
|
||||||
|
"ano": int(row.ano),
|
||||||
|
"valor_diaria": float(row.valor_diaria),
|
||||||
|
"status": row.status,
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def abrir_locacao_aluguel(
|
||||||
|
data_inicio: str,
|
||||||
|
data_fim_prevista: str,
|
||||||
|
rental_vehicle_id: int | None = None,
|
||||||
|
placa: str | None = None,
|
||||||
|
cpf: str | None = None,
|
||||||
|
nome_cliente: str | None = None,
|
||||||
|
observacoes: str | None = None,
|
||||||
|
user_id: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
vehicle_id = _normalize_vehicle_id(rental_vehicle_id)
|
||||||
|
data_inicio_dt = _parse_required_datetime(data_inicio, field_name="data_inicio")
|
||||||
|
data_fim_dt = _parse_required_datetime(data_fim_prevista, field_name="data_fim_prevista")
|
||||||
|
diarias = _calculate_rental_days(data_inicio_dt, data_fim_dt)
|
||||||
|
|
||||||
|
cpf_norm = technical_normalizer.normalize_cpf(cpf)
|
||||||
|
if cpf and not cpf_norm:
|
||||||
|
raise_tool_http_error(
|
||||||
|
status_code=400,
|
||||||
|
code="invalid_cpf",
|
||||||
|
message="Informe um CPF valido para a locacao ou remova esse campo.",
|
||||||
|
retryable=True,
|
||||||
|
field="cpf",
|
||||||
|
)
|
||||||
|
|
||||||
|
if cpf_norm:
|
||||||
|
await hydrate_mock_customer_from_cpf(cpf=cpf_norm, user_id=user_id)
|
||||||
|
|
||||||
|
db = SessionMockLocal()
|
||||||
|
try:
|
||||||
|
vehicle = _lookup_rental_vehicle(db, rental_vehicle_id=vehicle_id, placa=placa)
|
||||||
|
if vehicle is None:
|
||||||
|
raise_tool_http_error(
|
||||||
|
status_code=404,
|
||||||
|
code="rental_vehicle_not_found",
|
||||||
|
message="Veiculo de aluguel nao encontrado.",
|
||||||
|
retryable=True,
|
||||||
|
field="placa",
|
||||||
|
)
|
||||||
|
|
||||||
|
if vehicle.status != "disponivel":
|
||||||
|
raise_tool_http_error(
|
||||||
|
status_code=409,
|
||||||
|
code="rental_vehicle_unavailable",
|
||||||
|
message=(
|
||||||
|
f"O veiculo {vehicle.placa} nao esta disponivel para locacao no momento. "
|
||||||
|
f"Status atual: {vehicle.status}."
|
||||||
|
),
|
||||||
|
retryable=True,
|
||||||
|
field="placa",
|
||||||
|
)
|
||||||
|
|
||||||
|
contract_number = f"LOC-{utc_now().strftime('%Y%m%d')}-{uuid4().hex[:8].upper()}"
|
||||||
|
total_preview = round(float(vehicle.valor_diaria) * diarias, 2)
|
||||||
|
|
||||||
|
contract = RentalContract(
|
||||||
|
contrato_numero=contract_number,
|
||||||
|
user_id=user_id,
|
||||||
|
cpf=cpf_norm,
|
||||||
|
rental_vehicle_id=vehicle.id,
|
||||||
|
placa=vehicle.placa,
|
||||||
|
modelo_veiculo=vehicle.modelo,
|
||||||
|
categoria=vehicle.categoria,
|
||||||
|
data_inicio=data_inicio_dt,
|
||||||
|
data_fim_prevista=data_fim_dt,
|
||||||
|
valor_diaria=float(vehicle.valor_diaria),
|
||||||
|
valor_previsto=total_preview,
|
||||||
|
status="ativa",
|
||||||
|
observacoes=_normalize_text_field(observacoes),
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_id is not None and cpf_norm:
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if user and user.cpf != cpf_norm:
|
||||||
|
user.cpf = cpf_norm
|
||||||
|
|
||||||
|
vehicle.status = "alugado"
|
||||||
|
db.add(contract)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(contract)
|
||||||
|
db.refresh(vehicle)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"contrato_numero": contract.contrato_numero,
|
||||||
|
"placa": contract.placa,
|
||||||
|
"modelo_veiculo": contract.modelo_veiculo,
|
||||||
|
"categoria": contract.categoria,
|
||||||
|
"data_inicio": contract.data_inicio.isoformat(),
|
||||||
|
"data_fim_prevista": contract.data_fim_prevista.isoformat(),
|
||||||
|
"valor_diaria": float(contract.valor_diaria),
|
||||||
|
"valor_previsto": float(contract.valor_previsto),
|
||||||
|
"status": contract.status,
|
||||||
|
"status_veiculo": vehicle.status,
|
||||||
|
"cpf": contract.cpf,
|
||||||
|
"nome_cliente": _normalize_text_field(nome_cliente),
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def registrar_devolucao_aluguel(
|
||||||
|
contrato_numero: str | None = None,
|
||||||
|
placa: str | None = None,
|
||||||
|
data_devolucao: str | None = None,
|
||||||
|
observacoes: str | None = None,
|
||||||
|
user_id: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
db = SessionMockLocal()
|
||||||
|
try:
|
||||||
|
contract = _resolve_rental_contract(
|
||||||
|
db,
|
||||||
|
contrato_numero=contrato_numero,
|
||||||
|
placa=placa,
|
||||||
|
user_id=user_id,
|
||||||
|
active_only=True,
|
||||||
|
)
|
||||||
|
if contract is None:
|
||||||
|
raise_tool_http_error(
|
||||||
|
status_code=404,
|
||||||
|
code="rental_contract_not_found",
|
||||||
|
message="Nao encontrei uma locacao ativa com os dados informados.",
|
||||||
|
retryable=True,
|
||||||
|
field="contrato_numero",
|
||||||
|
)
|
||||||
|
|
||||||
|
returned_at = _parse_optional_datetime(data_devolucao, field_name="data_devolucao") or utc_now()
|
||||||
|
if returned_at < contract.data_inicio:
|
||||||
|
raise_tool_http_error(
|
||||||
|
status_code=400,
|
||||||
|
code="invalid_return_datetime",
|
||||||
|
message="A data de devolucao nao pode ser anterior ao inicio da locacao.",
|
||||||
|
retryable=True,
|
||||||
|
field="data_devolucao",
|
||||||
|
)
|
||||||
|
|
||||||
|
rental_days = _calculate_rental_days(contract.data_inicio, returned_at)
|
||||||
|
contract.data_devolucao = returned_at
|
||||||
|
contract.valor_final = round(float(contract.valor_diaria) * rental_days, 2)
|
||||||
|
contract.status = "encerrada"
|
||||||
|
contract.observacoes = _normalize_text_field(observacoes) or contract.observacoes
|
||||||
|
|
||||||
|
vehicle = db.query(RentalVehicle).filter(RentalVehicle.id == contract.rental_vehicle_id).first()
|
||||||
|
if vehicle is not None:
|
||||||
|
vehicle.status = "disponivel"
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(contract)
|
||||||
|
if vehicle is not None:
|
||||||
|
db.refresh(vehicle)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"contrato_numero": contract.contrato_numero,
|
||||||
|
"placa": contract.placa,
|
||||||
|
"modelo_veiculo": contract.modelo_veiculo,
|
||||||
|
"data_devolucao": contract.data_devolucao.isoformat() if contract.data_devolucao else None,
|
||||||
|
"valor_previsto": float(contract.valor_previsto),
|
||||||
|
"valor_final": float(contract.valor_final) if contract.valor_final is not None else None,
|
||||||
|
"status": contract.status,
|
||||||
|
"status_veiculo": vehicle.status if vehicle is not None else None,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def registrar_pagamento_aluguel(
|
||||||
|
valor: float,
|
||||||
|
contrato_numero: str | None = None,
|
||||||
|
placa: str | None = None,
|
||||||
|
data_pagamento: str | None = None,
|
||||||
|
favorecido: str | None = None,
|
||||||
|
identificador_comprovante: str | None = None,
|
||||||
|
observacoes: str | None = None,
|
||||||
|
user_id: int | None = None,
|
||||||
|
) -> dict:
|
||||||
|
contract = None
|
||||||
|
normalized_contract = _normalize_contract_number(contrato_numero)
|
||||||
|
plate = technical_normalizer.normalize_plate(placa)
|
||||||
|
|
||||||
|
db = SessionMockLocal()
|
||||||
|
try:
|
||||||
|
contract = _resolve_rental_contract(
|
||||||
|
db,
|
||||||
|
contrato_numero=normalized_contract,
|
||||||
|
placa=plate,
|
||||||
|
user_id=user_id,
|
||||||
|
active_only=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if contract is not None:
|
||||||
|
normalized_contract = contract.contrato_numero
|
||||||
|
plate = contract.placa
|
||||||
|
|
||||||
|
if not normalized_contract and not plate:
|
||||||
|
raise_tool_http_error(
|
||||||
|
status_code=400,
|
||||||
|
code="missing_rental_reference",
|
||||||
|
message=(
|
||||||
|
"Preciso da placa, do numero do contrato ou de uma locacao ativa vinculada ao usuario "
|
||||||
|
"para registrar o pagamento do aluguel."
|
||||||
|
),
|
||||||
|
retryable=True,
|
||||||
|
field="placa",
|
||||||
|
)
|
||||||
|
|
||||||
|
record = RentalPayment(
|
||||||
|
protocolo=f"ALP-{utc_now().strftime('%Y%m%d')}-{uuid4().hex[:8].upper()}",
|
||||||
|
user_id=user_id,
|
||||||
|
rental_contract_id=contract.id if contract is not None else None,
|
||||||
|
contrato_numero=normalized_contract,
|
||||||
|
placa=plate,
|
||||||
|
valor=_normalize_money(valor),
|
||||||
|
data_pagamento=_parse_optional_datetime(data_pagamento, field_name="data_pagamento"),
|
||||||
|
favorecido=_normalize_text_field(favorecido),
|
||||||
|
identificador_comprovante=_normalize_text_field(identificador_comprovante),
|
||||||
|
observacoes=_normalize_text_field(observacoes),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(record)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(record)
|
||||||
|
return {
|
||||||
|
"protocolo": record.protocolo,
|
||||||
|
"rental_contract_id": record.rental_contract_id,
|
||||||
|
"contrato_numero": record.contrato_numero,
|
||||||
|
"placa": record.placa,
|
||||||
|
"valor": float(record.valor),
|
||||||
|
"data_pagamento": record.data_pagamento.isoformat() if record.data_pagamento else None,
|
||||||
|
"favorecido": record.favorecido,
|
||||||
|
"identificador_comprovante": record.identificador_comprovante,
|
||||||
|
"status": "registrado",
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def registrar_multa_aluguel(
|
||||||
|
valor: float,
|
||||||
|
placa: str | None = None,
|
||||||
|
contrato_numero: str | None = None,
|
||||||
|
auto_infracao: str | None = None,
|
||||||
|
orgao_emissor: str | None = None,
|
||||||
|
data_infracao: str | None = None,
|
||||||
|
vencimento: str | None = None,
|
||||||
|
observacoes: str | None = None,
|
||||||
|
user_id: int | None = None,
|
||||||
|
) -> dict:
|
||||||
|
normalized_contract = _normalize_contract_number(contrato_numero)
|
||||||
|
plate = technical_normalizer.normalize_plate(placa)
|
||||||
|
notice_number = _normalize_text_field(auto_infracao)
|
||||||
|
|
||||||
|
db = SessionMockLocal()
|
||||||
|
try:
|
||||||
|
contract = _resolve_rental_contract(
|
||||||
|
db,
|
||||||
|
contrato_numero=normalized_contract,
|
||||||
|
placa=plate,
|
||||||
|
user_id=user_id,
|
||||||
|
active_only=False,
|
||||||
|
)
|
||||||
|
if contract is not None:
|
||||||
|
normalized_contract = contract.contrato_numero
|
||||||
|
plate = contract.placa
|
||||||
|
|
||||||
|
if not normalized_contract and not plate and not notice_number:
|
||||||
|
raise_tool_http_error(
|
||||||
|
status_code=400,
|
||||||
|
code="missing_fine_reference",
|
||||||
|
message=(
|
||||||
|
"Preciso da placa, do numero do contrato, do auto da infracao ou de uma locacao "
|
||||||
|
"vinculada ao usuario para registrar a multa."
|
||||||
|
),
|
||||||
|
retryable=True,
|
||||||
|
field="placa",
|
||||||
|
)
|
||||||
|
|
||||||
|
record = RentalFine(
|
||||||
|
protocolo=f"ALM-{utc_now().strftime('%Y%m%d')}-{uuid4().hex[:8].upper()}",
|
||||||
|
user_id=user_id,
|
||||||
|
rental_contract_id=contract.id if contract is not None else None,
|
||||||
|
contrato_numero=normalized_contract,
|
||||||
|
placa=plate,
|
||||||
|
auto_infracao=notice_number,
|
||||||
|
orgao_emissor=_normalize_text_field(orgao_emissor),
|
||||||
|
valor=_normalize_money(valor),
|
||||||
|
data_infracao=_parse_optional_datetime(data_infracao, field_name="data_infracao"),
|
||||||
|
vencimento=_parse_optional_datetime(vencimento, field_name="vencimento"),
|
||||||
|
observacoes=_normalize_text_field(observacoes),
|
||||||
|
status="registrada",
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(record)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(record)
|
||||||
|
return {
|
||||||
|
"protocolo": record.protocolo,
|
||||||
|
"rental_contract_id": record.rental_contract_id,
|
||||||
|
"contrato_numero": record.contrato_numero,
|
||||||
|
"placa": record.placa,
|
||||||
|
"auto_infracao": record.auto_infracao,
|
||||||
|
"orgao_emissor": record.orgao_emissor,
|
||||||
|
"valor": float(record.valor),
|
||||||
|
"data_infracao": record.data_infracao.isoformat() if record.data_infracao else None,
|
||||||
|
"vencimento": record.vencimento.isoformat() if record.vencimento else None,
|
||||||
|
"status": record.status,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@ -0,0 +1,507 @@
|
|||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.core.time_utils import utc_now
|
||||||
|
from app.services.orchestration import technical_normalizer
|
||||||
|
from app.services.orchestration.orchestrator_config import (
|
||||||
|
PENDING_RENTAL_DRAFT_TTL_MINUTES,
|
||||||
|
PENDING_RENTAL_SELECTION_TTL_MINUTES,
|
||||||
|
RENTAL_REQUIRED_FIELDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RentalFlowMixin:
|
||||||
|
def _sanitize_rental_results(self, rental_results: list[dict] | None) -> list[dict]:
|
||||||
|
sanitized: list[dict] = []
|
||||||
|
for item in rental_results or []:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
rental_vehicle_id = int(item.get("id"))
|
||||||
|
valor_diaria = float(item.get("valor_diaria") or 0)
|
||||||
|
ano = int(item.get("ano")) if item.get("ano") is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
placa = technical_normalizer.normalize_plate(item.get("placa"))
|
||||||
|
if not placa:
|
||||||
|
continue
|
||||||
|
sanitized.append(
|
||||||
|
{
|
||||||
|
"id": rental_vehicle_id,
|
||||||
|
"placa": placa,
|
||||||
|
"modelo": str(item.get("modelo") or "").strip(),
|
||||||
|
"categoria": str(item.get("categoria") or "").strip().lower(),
|
||||||
|
"ano": ano,
|
||||||
|
"valor_diaria": valor_diaria,
|
||||||
|
"status": str(item.get("status") or "").strip().lower() or "disponivel",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return sanitized
|
||||||
|
|
||||||
|
def _mark_rental_flow_active(self, user_id: int | None, *, active_task: str | None = None) -> None:
|
||||||
|
if user_id is None:
|
||||||
|
return
|
||||||
|
context = self._get_user_context(user_id)
|
||||||
|
if not isinstance(context, dict):
|
||||||
|
return
|
||||||
|
context["active_domain"] = "rental"
|
||||||
|
if active_task is not None:
|
||||||
|
context["active_task"] = active_task
|
||||||
|
self._save_user_context(user_id=user_id, context=context)
|
||||||
|
|
||||||
|
def _get_last_rental_results(self, user_id: int | None) -> list[dict]:
|
||||||
|
pending_selection = self.state.get_entry("pending_rental_selections", user_id, expire=True)
|
||||||
|
if isinstance(pending_selection, dict):
|
||||||
|
payload = pending_selection.get("payload")
|
||||||
|
if isinstance(payload, list):
|
||||||
|
sanitized = self._sanitize_rental_results(payload)
|
||||||
|
if sanitized:
|
||||||
|
return sanitized
|
||||||
|
context = self._get_user_context(user_id)
|
||||||
|
if not isinstance(context, dict):
|
||||||
|
return []
|
||||||
|
rental_results = context.get("last_rental_results") or []
|
||||||
|
return self._sanitize_rental_results(rental_results if isinstance(rental_results, list) else [])
|
||||||
|
|
||||||
|
def _store_pending_rental_selection(self, user_id: int | None, rental_results: list[dict] | None) -> None:
|
||||||
|
if user_id is None:
|
||||||
|
return
|
||||||
|
sanitized = self._sanitize_rental_results(rental_results)
|
||||||
|
if not sanitized:
|
||||||
|
self.state.pop_entry("pending_rental_selections", user_id)
|
||||||
|
return
|
||||||
|
self.state.set_entry(
|
||||||
|
"pending_rental_selections",
|
||||||
|
user_id,
|
||||||
|
{
|
||||||
|
"payload": sanitized,
|
||||||
|
"expires_at": utc_now() + timedelta(minutes=PENDING_RENTAL_SELECTION_TTL_MINUTES),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_selected_rental_vehicle(self, user_id: int | None) -> dict | None:
|
||||||
|
context = self._get_user_context(user_id)
|
||||||
|
if not isinstance(context, dict):
|
||||||
|
return None
|
||||||
|
selected_vehicle = context.get("selected_rental_vehicle")
|
||||||
|
return dict(selected_vehicle) if isinstance(selected_vehicle, dict) else None
|
||||||
|
|
||||||
|
def _remember_rental_results(self, user_id: int | None, rental_results: list[dict] | None) -> None:
|
||||||
|
context = self._get_user_context(user_id)
|
||||||
|
if not isinstance(context, dict):
|
||||||
|
return
|
||||||
|
sanitized = self._sanitize_rental_results(rental_results)
|
||||||
|
context["last_rental_results"] = sanitized
|
||||||
|
self._store_pending_rental_selection(user_id=user_id, rental_results=sanitized)
|
||||||
|
if sanitized:
|
||||||
|
context["selected_rental_vehicle"] = None
|
||||||
|
context["active_domain"] = "rental"
|
||||||
|
self._save_user_context(user_id=user_id, context=context)
|
||||||
|
|
||||||
|
def _store_selected_rental_vehicle(self, user_id: int | None, vehicle: dict | None) -> None:
|
||||||
|
if user_id is None:
|
||||||
|
return
|
||||||
|
context = self._get_user_context(user_id)
|
||||||
|
if not isinstance(context, dict):
|
||||||
|
return
|
||||||
|
context["selected_rental_vehicle"] = dict(vehicle) if isinstance(vehicle, dict) else None
|
||||||
|
context["active_domain"] = "rental"
|
||||||
|
self.state.pop_entry("pending_rental_selections", user_id)
|
||||||
|
self._save_user_context(user_id=user_id, context=context)
|
||||||
|
|
||||||
|
def _rental_vehicle_to_payload(self, vehicle: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"rental_vehicle_id": int(vehicle["id"]),
|
||||||
|
"placa": str(vehicle["placa"]),
|
||||||
|
"modelo_veiculo": str(vehicle["modelo"]),
|
||||||
|
"categoria": str(vehicle.get("categoria") or ""),
|
||||||
|
"valor_diaria": round(float(vehicle.get("valor_diaria") or 0), 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _extract_rental_category_from_text(self, text: str) -> str | None:
|
||||||
|
normalized = self._normalize_text(text).strip()
|
||||||
|
aliases = {
|
||||||
|
"suv": "suv",
|
||||||
|
"sedan": "sedan",
|
||||||
|
"hatch": "hatch",
|
||||||
|
"pickup": "pickup",
|
||||||
|
"picape": "pickup",
|
||||||
|
}
|
||||||
|
for token, category in aliases.items():
|
||||||
|
if re.search(rf"(?<![a-z0-9]){re.escape(token)}(?![a-z0-9])", normalized):
|
||||||
|
return category
|
||||||
|
return None
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
def _normalize_rental_datetime_text(self, value) -> str | None:
|
||||||
|
text = technical_normalizer.normalize_datetime_connector(str(value or "").strip())
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
|
||||||
|
parsed = technical_normalizer.try_parse_iso_datetime(text)
|
||||||
|
if parsed is None:
|
||||||
|
parsed = technical_normalizer.try_parse_datetime_with_formats(
|
||||||
|
text,
|
||||||
|
(
|
||||||
|
"%d/%m/%Y %H:%M",
|
||||||
|
"%d/%m/%Y %H:%M:%S",
|
||||||
|
"%d/%m/%Y",
|
||||||
|
"%Y-%m-%d %H:%M",
|
||||||
|
"%Y-%m-%d %H:%M:%S",
|
||||||
|
"%Y-%m-%d",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if parsed is None:
|
||||||
|
return None
|
||||||
|
if ":" in text:
|
||||||
|
return parsed.strftime("%d/%m/%Y %H:%M")
|
||||||
|
return parsed.strftime("%d/%m/%Y")
|
||||||
|
|
||||||
|
def _normalize_rental_fields(self, data) -> dict:
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
payload: dict = {}
|
||||||
|
|
||||||
|
rental_vehicle_id = data.get("rental_vehicle_id")
|
||||||
|
if rental_vehicle_id is None:
|
||||||
|
rental_vehicle_id = data.get("vehicle_id")
|
||||||
|
try:
|
||||||
|
if rental_vehicle_id not in (None, ""):
|
||||||
|
numeric = int(rental_vehicle_id)
|
||||||
|
if numeric > 0:
|
||||||
|
payload["rental_vehicle_id"] = numeric
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
placa = technical_normalizer.normalize_plate(data.get("placa"))
|
||||||
|
if placa:
|
||||||
|
payload["placa"] = placa
|
||||||
|
|
||||||
|
cpf = technical_normalizer.normalize_cpf(data.get("cpf"))
|
||||||
|
if cpf:
|
||||||
|
payload["cpf"] = cpf
|
||||||
|
|
||||||
|
valor_diaria_max = technical_normalizer.normalize_positive_number(data.get("valor_diaria_max"))
|
||||||
|
if valor_diaria_max:
|
||||||
|
payload["valor_diaria_max"] = float(valor_diaria_max)
|
||||||
|
|
||||||
|
categoria = self._extract_rental_category_from_text(str(data.get("categoria") or ""))
|
||||||
|
if categoria:
|
||||||
|
payload["categoria"] = categoria
|
||||||
|
|
||||||
|
for field_name in ("data_inicio", "data_fim_prevista"):
|
||||||
|
normalized = self._normalize_rental_datetime_text(data.get(field_name))
|
||||||
|
if normalized:
|
||||||
|
payload[field_name] = normalized
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def _try_capture_rental_fields_from_message(self, message: str, payload: dict) -> None:
|
||||||
|
if payload.get("placa") is None:
|
||||||
|
words = re.findall(r"[A-Za-z0-9-]+", str(message or ""))
|
||||||
|
for word in words:
|
||||||
|
plate = technical_normalizer.normalize_plate(word)
|
||||||
|
if plate:
|
||||||
|
payload["placa"] = plate
|
||||||
|
break
|
||||||
|
|
||||||
|
if payload.get("cpf") is None:
|
||||||
|
cpf = technical_normalizer.extract_cpf_from_text(message)
|
||||||
|
if cpf and technical_normalizer.is_valid_cpf(cpf):
|
||||||
|
payload["cpf"] = cpf
|
||||||
|
|
||||||
|
if payload.get("categoria") is None:
|
||||||
|
category = self._extract_rental_category_from_text(message)
|
||||||
|
if category:
|
||||||
|
payload["categoria"] = category
|
||||||
|
|
||||||
|
if payload.get("valor_diaria_max") is None:
|
||||||
|
budget = technical_normalizer.extract_budget_from_text(message)
|
||||||
|
if budget:
|
||||||
|
payload["valor_diaria_max"] = float(budget)
|
||||||
|
|
||||||
|
datetimes = self._extract_rental_datetimes_from_text(message)
|
||||||
|
if datetimes:
|
||||||
|
if not payload.get("data_inicio"):
|
||||||
|
payload["data_inicio"] = datetimes[0]
|
||||||
|
if len(datetimes) >= 2 and not payload.get("data_fim_prevista"):
|
||||||
|
payload["data_fim_prevista"] = datetimes[1]
|
||||||
|
elif len(datetimes) == 1 and payload.get("data_inicio") and not payload.get("data_fim_prevista"):
|
||||||
|
if payload["data_inicio"] != datetimes[0]:
|
||||||
|
payload["data_fim_prevista"] = datetimes[0]
|
||||||
|
|
||||||
|
def _has_rental_listing_request(self, message: str, turn_decision: dict | None = None) -> bool:
|
||||||
|
decision_intent = self._decision_intent(turn_decision)
|
||||||
|
decision_domain = str((turn_decision or {}).get("domain") or "").strip().lower()
|
||||||
|
if decision_domain == "rental" and decision_intent in {"rental_list", "rental_search"}:
|
||||||
|
return True
|
||||||
|
normalized = self._normalize_text(message).strip()
|
||||||
|
rental_terms = {"aluguel", "alugar", "locacao", "locar"}
|
||||||
|
listing_terms = {"quais", "listar", "liste", "mostrar", "mostre", "disponiveis", "disponivel", "frota", "opcoes", "opcao"}
|
||||||
|
return any(term in normalized for term in rental_terms) and any(term in normalized for term in listing_terms)
|
||||||
|
|
||||||
|
def _has_explicit_rental_request(self, message: str) -> bool:
|
||||||
|
normalized = self._normalize_text(message).strip()
|
||||||
|
if any(term in normalized for term in {"multa", "comprovante", "pagamento", "devolucao", "devolver"}):
|
||||||
|
return False
|
||||||
|
request_terms = {
|
||||||
|
"quero alugar",
|
||||||
|
"quero locar",
|
||||||
|
"abrir locacao",
|
||||||
|
"abrir aluguel",
|
||||||
|
"fazer locacao",
|
||||||
|
"iniciar locacao",
|
||||||
|
"seguir com a locacao",
|
||||||
|
"seguir com aluguel",
|
||||||
|
"alugar o carro",
|
||||||
|
"locacao do carro",
|
||||||
|
}
|
||||||
|
return any(term in normalized for term in request_terms)
|
||||||
|
|
||||||
|
def _has_rental_return_request(self, message: str) -> bool:
|
||||||
|
normalized = self._normalize_text(message).strip()
|
||||||
|
return any(term in normalized for term in {"devolver", "devolucao", "encerrar locacao", "fechar locacao"})
|
||||||
|
|
||||||
|
def _has_rental_payment_or_fine_request(self, message: str) -> bool:
|
||||||
|
normalized = self._normalize_text(message).strip()
|
||||||
|
return any(term in normalized for term in {"multa", "comprovante", "pagamento", "boleto", "pix"})
|
||||||
|
|
||||||
|
def _match_rental_vehicle_from_message_index(self, message: str, rental_results: list[dict]) -> dict | None:
|
||||||
|
tokens = [token for token in re.findall(r"\d+", str(message or "")) if token.isdigit()]
|
||||||
|
if not tokens:
|
||||||
|
return None
|
||||||
|
choice = int(tokens[0])
|
||||||
|
if 1 <= choice <= len(rental_results):
|
||||||
|
return rental_results[choice - 1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _match_rental_vehicle_from_message_model(self, message: str, rental_results: list[dict]) -> dict | None:
|
||||||
|
normalized_message = self._normalize_text(message)
|
||||||
|
matches = []
|
||||||
|
for item in rental_results:
|
||||||
|
normalized_model = self._normalize_text(str(item.get("modelo") or ""))
|
||||||
|
normalized_plate = self._normalize_text(str(item.get("placa") or ""))
|
||||||
|
if (normalized_model and normalized_model in normalized_message) or (
|
||||||
|
normalized_plate and normalized_plate in normalized_message
|
||||||
|
):
|
||||||
|
matches.append(item)
|
||||||
|
if len(matches) == 1:
|
||||||
|
return matches[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _try_resolve_rental_vehicle(self, message: str, user_id: int | None, payload: dict) -> dict | None:
|
||||||
|
rental_vehicle_id = payload.get("rental_vehicle_id")
|
||||||
|
if isinstance(rental_vehicle_id, int) and rental_vehicle_id > 0:
|
||||||
|
for item in self._get_last_rental_results(user_id=user_id):
|
||||||
|
if int(item.get("id") or 0) == rental_vehicle_id:
|
||||||
|
return item
|
||||||
|
|
||||||
|
rental_results = self._get_last_rental_results(user_id=user_id)
|
||||||
|
selected_from_model = self._match_rental_vehicle_from_message_model(message=message, rental_results=rental_results)
|
||||||
|
if selected_from_model:
|
||||||
|
return selected_from_model
|
||||||
|
|
||||||
|
selected_from_index = self._match_rental_vehicle_from_message_index(message=message, rental_results=rental_results)
|
||||||
|
if selected_from_index:
|
||||||
|
return selected_from_index
|
||||||
|
|
||||||
|
normalized_plate = technical_normalizer.normalize_plate(payload.get("placa"))
|
||||||
|
if normalized_plate:
|
||||||
|
matches = [item for item in rental_results if str(item.get("placa") or "").strip().upper() == normalized_plate]
|
||||||
|
if len(matches) == 1:
|
||||||
|
return matches[0]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _should_bootstrap_rental_from_context(self, message: str, user_id: int | None, payload: dict | None = None) -> bool:
|
||||||
|
if user_id is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
rental_results = self._get_last_rental_results(user_id=user_id)
|
||||||
|
if not rental_results:
|
||||||
|
return False
|
||||||
|
|
||||||
|
normalized_payload = payload if isinstance(payload, dict) else {}
|
||||||
|
return bool(
|
||||||
|
self._match_rental_vehicle_from_message_model(message=message, rental_results=rental_results)
|
||||||
|
or self._match_rental_vehicle_from_message_index(message=message, rental_results=rental_results)
|
||||||
|
or (
|
||||||
|
normalized_payload.get("placa")
|
||||||
|
and self._try_resolve_rental_vehicle(message=message, user_id=user_id, payload=normalized_payload)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _render_missing_rental_fields_prompt(self, missing_fields: list[str]) -> str:
|
||||||
|
labels = {
|
||||||
|
"rental_vehicle_id": "qual veiculo da frota voce quer alugar",
|
||||||
|
"data_inicio": "a data e hora de inicio da locacao",
|
||||||
|
"data_fim_prevista": "a data e hora previstas para devolucao",
|
||||||
|
}
|
||||||
|
items = [f"- {labels[field]}" for field in missing_fields]
|
||||||
|
return "Para abrir a locacao, preciso dos dados abaixo:\n" + "\n".join(items)
|
||||||
|
|
||||||
|
def _render_rental_selection_from_fleet_prompt(self, rental_results: list[dict]) -> str:
|
||||||
|
lines = ["Para seguir com a locacao, escolha primeiro qual veiculo voce quer alugar:"]
|
||||||
|
for idx, item in enumerate(rental_results[:10], start=1):
|
||||||
|
lines.append(
|
||||||
|
f"- {idx}. {item.get('modelo', 'N/A')} {item.get('ano', 'N/A')} | "
|
||||||
|
f"{item.get('placa', 'N/A')} | {item.get('categoria', 'N/A')} | "
|
||||||
|
f"diaria R$ {float(item.get('valor_diaria', 0)):.2f}"
|
||||||
|
)
|
||||||
|
lines.append("Pode responder com o numero da lista, com a placa ou com o modelo.")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
async def _try_list_rental_fleet_for_selection(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
user_id: int | None,
|
||||||
|
payload: dict,
|
||||||
|
turn_decision: dict | None = None,
|
||||||
|
force: bool = False,
|
||||||
|
) -> str | None:
|
||||||
|
if user_id is None:
|
||||||
|
return None
|
||||||
|
if not force and not self._has_rental_listing_request(message, turn_decision=turn_decision):
|
||||||
|
return None
|
||||||
|
|
||||||
|
arguments: dict = {
|
||||||
|
"limite": 10,
|
||||||
|
"ordenar_diaria": "asc",
|
||||||
|
}
|
||||||
|
category = payload.get("categoria") or self._extract_rental_category_from_text(message)
|
||||||
|
if category:
|
||||||
|
arguments["categoria"] = str(category).strip().lower()
|
||||||
|
|
||||||
|
valor_diaria_max = payload.get("valor_diaria_max")
|
||||||
|
if not isinstance(valor_diaria_max, (int, float)):
|
||||||
|
valor_diaria_max = technical_normalizer.extract_budget_from_text(message)
|
||||||
|
if isinstance(valor_diaria_max, (int, float)) and float(valor_diaria_max) > 0:
|
||||||
|
arguments["valor_diaria_max"] = float(valor_diaria_max)
|
||||||
|
|
||||||
|
try:
|
||||||
|
tool_result = await self.tool_executor.execute(
|
||||||
|
"consultar_frota_aluguel",
|
||||||
|
arguments,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
except HTTPException as exc:
|
||||||
|
return self._http_exception_detail(exc)
|
||||||
|
|
||||||
|
rental_results = tool_result if isinstance(tool_result, list) else []
|
||||||
|
self._remember_rental_results(user_id=user_id, rental_results=rental_results)
|
||||||
|
self._mark_rental_flow_active(user_id=user_id)
|
||||||
|
return self._fallback_format_tool_result("consultar_frota_aluguel", tool_result)
|
||||||
|
|
||||||
|
async def _try_collect_and_open_rental(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
user_id: int | None,
|
||||||
|
extracted_fields: dict | None = None,
|
||||||
|
intents: dict | None = None,
|
||||||
|
turn_decision: dict | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
if user_id is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
draft = self.state.get_entry("pending_rental_drafts", user_id, expire=True)
|
||||||
|
extracted = self._normalize_rental_fields(extracted_fields)
|
||||||
|
decision_intent = self._decision_intent(turn_decision)
|
||||||
|
has_intent = decision_intent in {"rental_create", "rental_list", "rental_search"}
|
||||||
|
explicit_rental_request = self._has_explicit_rental_request(message)
|
||||||
|
rental_listing_request = self._has_rental_listing_request(message, turn_decision=turn_decision)
|
||||||
|
should_bootstrap_from_context = draft is None and self._should_bootstrap_rental_from_context(
|
||||||
|
message=message,
|
||||||
|
user_id=user_id,
|
||||||
|
payload=extracted,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
draft is None
|
||||||
|
and not has_intent
|
||||||
|
and not explicit_rental_request
|
||||||
|
and not rental_listing_request
|
||||||
|
and not should_bootstrap_from_context
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if draft is None:
|
||||||
|
draft = {
|
||||||
|
"payload": {},
|
||||||
|
"expires_at": utc_now() + timedelta(minutes=PENDING_RENTAL_DRAFT_TTL_MINUTES),
|
||||||
|
}
|
||||||
|
|
||||||
|
draft_payload = draft.get("payload", {})
|
||||||
|
if not isinstance(draft_payload, dict):
|
||||||
|
draft_payload = {}
|
||||||
|
draft["payload"] = draft_payload
|
||||||
|
|
||||||
|
draft_payload.update(extracted)
|
||||||
|
self._try_capture_rental_fields_from_message(message=message, payload=draft_payload)
|
||||||
|
|
||||||
|
selected_vehicle = self._get_selected_rental_vehicle(user_id=user_id)
|
||||||
|
if selected_vehicle and not draft_payload.get("rental_vehicle_id"):
|
||||||
|
draft_payload.update(self._rental_vehicle_to_payload(selected_vehicle))
|
||||||
|
|
||||||
|
resolved_vehicle = self._try_resolve_rental_vehicle(
|
||||||
|
message=message,
|
||||||
|
user_id=user_id,
|
||||||
|
payload=draft_payload,
|
||||||
|
)
|
||||||
|
if resolved_vehicle:
|
||||||
|
self._store_selected_rental_vehicle(user_id=user_id, vehicle=resolved_vehicle)
|
||||||
|
draft_payload.update(self._rental_vehicle_to_payload(resolved_vehicle))
|
||||||
|
|
||||||
|
draft["expires_at"] = utc_now() + timedelta(minutes=PENDING_RENTAL_DRAFT_TTL_MINUTES)
|
||||||
|
self.state.set_entry("pending_rental_drafts", user_id, draft)
|
||||||
|
self._mark_rental_flow_active(user_id=user_id, active_task="rental_create")
|
||||||
|
|
||||||
|
missing = [field for field in RENTAL_REQUIRED_FIELDS if field not in draft_payload]
|
||||||
|
if missing:
|
||||||
|
if "rental_vehicle_id" in missing:
|
||||||
|
fleet_response = await self._try_list_rental_fleet_for_selection(
|
||||||
|
message=message,
|
||||||
|
user_id=user_id,
|
||||||
|
payload=draft_payload,
|
||||||
|
turn_decision=turn_decision,
|
||||||
|
force=bool(draft) or explicit_rental_request or rental_listing_request or should_bootstrap_from_context,
|
||||||
|
)
|
||||||
|
if fleet_response:
|
||||||
|
return fleet_response
|
||||||
|
rental_results = self._get_last_rental_results(user_id=user_id)
|
||||||
|
if rental_results:
|
||||||
|
return self._render_rental_selection_from_fleet_prompt(rental_results)
|
||||||
|
return self._render_missing_rental_fields_prompt(missing)
|
||||||
|
|
||||||
|
try:
|
||||||
|
tool_result = await self.tool_executor.execute(
|
||||||
|
"abrir_locacao_aluguel",
|
||||||
|
{
|
||||||
|
"rental_vehicle_id": draft_payload["rental_vehicle_id"],
|
||||||
|
"placa": draft_payload.get("placa"),
|
||||||
|
"data_inicio": draft_payload["data_inicio"],
|
||||||
|
"data_fim_prevista": draft_payload["data_fim_prevista"],
|
||||||
|
"cpf": draft_payload.get("cpf"),
|
||||||
|
},
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
except HTTPException as exc:
|
||||||
|
return self._http_exception_detail(exc)
|
||||||
|
|
||||||
|
self._reset_pending_rental_states(user_id=user_id)
|
||||||
|
return self._fallback_format_tool_result("abrir_locacao_aluguel", tool_result)
|
||||||
@ -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,251 @@
|
|||||||
|
import unittest
|
||||||
|
from datetime import datetime
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from app.db.mock_database import MockBase
|
||||||
|
from app.db.mock_models import RentalContract, RentalFine, RentalPayment, RentalVehicle
|
||||||
|
from app.services.domain import rental_service
|
||||||
|
|
||||||
|
|
||||||
|
class RentalServiceTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
def _build_session_local(self):
|
||||||
|
engine = create_engine("sqlite:///:memory:")
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
MockBase.metadata.create_all(bind=engine)
|
||||||
|
self.addCleanup(engine.dispose)
|
||||||
|
return SessionLocal
|
||||||
|
|
||||||
|
def _create_rental_vehicle(
|
||||||
|
self,
|
||||||
|
db,
|
||||||
|
*,
|
||||||
|
placa: str = "ABC1D23",
|
||||||
|
modelo: str = "Chevrolet Tracker",
|
||||||
|
categoria: str = "suv",
|
||||||
|
ano: int = 2024,
|
||||||
|
valor_diaria: float = 219.9,
|
||||||
|
status: str = "disponivel",
|
||||||
|
) -> RentalVehicle:
|
||||||
|
vehicle = RentalVehicle(
|
||||||
|
placa=placa,
|
||||||
|
modelo=modelo,
|
||||||
|
categoria=categoria,
|
||||||
|
ano=ano,
|
||||||
|
valor_diaria=valor_diaria,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
db.add(vehicle)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(vehicle)
|
||||||
|
return vehicle
|
||||||
|
|
||||||
|
def _create_rental_contract(
|
||||||
|
self,
|
||||||
|
db,
|
||||||
|
vehicle: RentalVehicle,
|
||||||
|
*,
|
||||||
|
contrato_numero: str = "LOC-20260317-TESTE000",
|
||||||
|
user_id: int | None = None,
|
||||||
|
status: str = "ativa",
|
||||||
|
data_inicio: datetime | None = None,
|
||||||
|
data_fim_prevista: datetime | None = None,
|
||||||
|
) -> RentalContract:
|
||||||
|
contract = RentalContract(
|
||||||
|
contrato_numero=contrato_numero,
|
||||||
|
user_id=user_id,
|
||||||
|
rental_vehicle_id=vehicle.id,
|
||||||
|
placa=vehicle.placa,
|
||||||
|
modelo_veiculo=vehicle.modelo,
|
||||||
|
categoria=vehicle.categoria,
|
||||||
|
data_inicio=data_inicio or datetime(2026, 3, 17, 10, 0),
|
||||||
|
data_fim_prevista=data_fim_prevista or datetime(2026, 3, 20, 10, 0),
|
||||||
|
valor_diaria=float(vehicle.valor_diaria),
|
||||||
|
valor_previsto=round(float(vehicle.valor_diaria) * 3, 2),
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
db.add(contract)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(contract)
|
||||||
|
return contract
|
||||||
|
|
||||||
|
async def test_consultar_frota_aluguel_retains_only_available_by_default(self):
|
||||||
|
SessionLocal = self._build_session_local()
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
self._create_rental_vehicle(db, placa="AAA1A11", modelo="Chevrolet Tracker", status="disponivel")
|
||||||
|
self._create_rental_vehicle(db, placa="BBB2B22", modelo="Fiat Toro", categoria="pickup", status="alugado")
|
||||||
|
self._create_rental_vehicle(db, placa="CCC3C33", modelo="Jeep Renegade", status="manutencao")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
with patch("app.services.domain.rental_service.SessionMockLocal", SessionLocal):
|
||||||
|
result = await rental_service.consultar_frota_aluguel(valor_diaria_max=300)
|
||||||
|
|
||||||
|
self.assertEqual(len(result), 1)
|
||||||
|
self.assertEqual(result[0]["placa"], "AAA1A11")
|
||||||
|
self.assertEqual(result[0]["status"], "disponivel")
|
||||||
|
|
||||||
|
async def test_abrir_locacao_aluguel_cria_contrato_e_marca_veiculo_como_alugado(self):
|
||||||
|
SessionLocal = self._build_session_local()
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
vehicle = self._create_rental_vehicle(db)
|
||||||
|
vehicle_id = vehicle.id
|
||||||
|
vehicle_placa = vehicle.placa
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
with patch("app.services.domain.rental_service.SessionMockLocal", SessionLocal):
|
||||||
|
result = await rental_service.abrir_locacao_aluguel(
|
||||||
|
placa=vehicle_placa,
|
||||||
|
data_inicio="17/03/2026 10:00",
|
||||||
|
data_fim_prevista="20/03/2026 10:00",
|
||||||
|
)
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
stored_contract = db.query(RentalContract).one()
|
||||||
|
stored_vehicle = db.query(RentalVehicle).filter(RentalVehicle.id == vehicle_id).one()
|
||||||
|
self.assertEqual(stored_contract.placa, vehicle_placa)
|
||||||
|
self.assertEqual(stored_contract.status, "ativa")
|
||||||
|
self.assertEqual(stored_vehicle.status, "alugado")
|
||||||
|
self.assertEqual(result["status"], "ativa")
|
||||||
|
self.assertEqual(result["status_veiculo"], "alugado")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
async def test_registrar_devolucao_aluguel_fecha_contrato_e_libera_veiculo(self):
|
||||||
|
SessionLocal = self._build_session_local()
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
vehicle = self._create_rental_vehicle(db, status="alugado")
|
||||||
|
vehicle_id = vehicle.id
|
||||||
|
vehicle_diaria = float(vehicle.valor_diaria)
|
||||||
|
contract = self._create_rental_contract(db, vehicle)
|
||||||
|
contract_number = contract.contrato_numero
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
with patch("app.services.domain.rental_service.SessionMockLocal", SessionLocal):
|
||||||
|
result = await rental_service.registrar_devolucao_aluguel(
|
||||||
|
contrato_numero=contract_number,
|
||||||
|
data_devolucao="21/03/2026 09:00",
|
||||||
|
)
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
stored_contract = db.query(RentalContract).one()
|
||||||
|
stored_vehicle = db.query(RentalVehicle).filter(RentalVehicle.id == vehicle_id).one()
|
||||||
|
self.assertEqual(stored_contract.status, "encerrada")
|
||||||
|
self.assertEqual(stored_vehicle.status, "disponivel")
|
||||||
|
self.assertEqual(result["status"], "encerrada")
|
||||||
|
self.assertEqual(result["status_veiculo"], "disponivel")
|
||||||
|
self.assertEqual(result["valor_final"], round(vehicle_diaria * 4, 2))
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
async def test_registrar_pagamento_aluguel_persiste_registro(self):
|
||||||
|
SessionLocal = self._build_session_local()
|
||||||
|
|
||||||
|
with patch("app.services.domain.rental_service.SessionMockLocal", SessionLocal):
|
||||||
|
result = await rental_service.registrar_pagamento_aluguel(
|
||||||
|
contrato_numero="loc-123",
|
||||||
|
placa="abc1234",
|
||||||
|
valor=1540.5,
|
||||||
|
data_pagamento="17/03/2026 14:30",
|
||||||
|
favorecido="Locadora XPTO",
|
||||||
|
identificador_comprovante="NSU123",
|
||||||
|
user_id=9,
|
||||||
|
)
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
stored = db.query(RentalPayment).one()
|
||||||
|
self.assertEqual(stored.contrato_numero, "LOC-123")
|
||||||
|
self.assertEqual(stored.placa, "ABC1234")
|
||||||
|
self.assertEqual(float(stored.valor), 1540.5)
|
||||||
|
self.assertEqual(result["status"], "registrado")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
async def test_registrar_pagamento_aluguel_vincula_unica_locacao_ativa_do_usuario(self):
|
||||||
|
SessionLocal = self._build_session_local()
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
vehicle = self._create_rental_vehicle(db, status="alugado")
|
||||||
|
vehicle_placa = vehicle.placa
|
||||||
|
contract = self._create_rental_contract(db, vehicle, user_id=9)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
with patch("app.services.domain.rental_service.SessionMockLocal", SessionLocal):
|
||||||
|
result = await rental_service.registrar_pagamento_aluguel(
|
||||||
|
valor=879.90,
|
||||||
|
user_id=9,
|
||||||
|
)
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
stored = db.query(RentalPayment).one()
|
||||||
|
self.assertEqual(stored.rental_contract_id, contract.id)
|
||||||
|
self.assertEqual(stored.contrato_numero, contract.contrato_numero)
|
||||||
|
self.assertEqual(stored.placa, vehicle_placa)
|
||||||
|
self.assertEqual(result["contrato_numero"], contract.contrato_numero)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
async def test_registrar_multa_aluguel_persiste_registro(self):
|
||||||
|
SessionLocal = self._build_session_local()
|
||||||
|
|
||||||
|
with patch("app.services.domain.rental_service.SessionMockLocal", SessionLocal):
|
||||||
|
result = await rental_service.registrar_multa_aluguel(
|
||||||
|
placa="abc1d23",
|
||||||
|
auto_infracao="A123456",
|
||||||
|
valor=293.47,
|
||||||
|
data_infracao="17/03/2026",
|
||||||
|
vencimento="10/04/2026",
|
||||||
|
orgao_emissor="DETRAN-SP",
|
||||||
|
user_id=11,
|
||||||
|
)
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
stored = db.query(RentalFine).one()
|
||||||
|
self.assertEqual(stored.placa, "ABC1D23")
|
||||||
|
self.assertEqual(stored.auto_infracao, "A123456")
|
||||||
|
self.assertEqual(result["status"], "registrada")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
async def test_registrar_multa_aluguel_vincula_contrato_ativo_pela_placa(self):
|
||||||
|
SessionLocal = self._build_session_local()
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
vehicle = self._create_rental_vehicle(db, placa="ABC1D23", status="alugado")
|
||||||
|
contract = self._create_rental_contract(db, vehicle, user_id=11)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
with patch("app.services.domain.rental_service.SessionMockLocal", SessionLocal):
|
||||||
|
result = await rental_service.registrar_multa_aluguel(
|
||||||
|
placa="ABC1D23",
|
||||||
|
auto_infracao="A123456",
|
||||||
|
valor=293.47,
|
||||||
|
user_id=11,
|
||||||
|
)
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
stored = db.query(RentalFine).one()
|
||||||
|
self.assertEqual(stored.rental_contract_id, contract.id)
|
||||||
|
self.assertEqual(stored.contrato_numero, contract.contrato_numero)
|
||||||
|
self.assertEqual(result["contrato_numero"], contract.contrato_numero)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@ -0,0 +1,71 @@
|
|||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from app.integrations.telegram_satellite_service import TelegramSatelliteService
|
||||||
|
|
||||||
|
|
||||||
|
class _DummySession:
|
||||||
|
def close(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramMultimodalTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_process_message_uses_extracted_image_message(self):
|
||||||
|
service = TelegramSatelliteService("token-teste")
|
||||||
|
tools_db = _DummySession()
|
||||||
|
mock_db = _DummySession()
|
||||||
|
|
||||||
|
with patch("app.integrations.telegram_satellite_service.SessionLocal", return_value=tools_db), patch(
|
||||||
|
"app.integrations.telegram_satellite_service.SessionMockLocal",
|
||||||
|
return_value=mock_db,
|
||||||
|
), patch("app.integrations.telegram_satellite_service.UserService") as user_service_cls, patch(
|
||||||
|
"app.integrations.telegram_satellite_service.OrquestradorService"
|
||||||
|
) as orchestrator_cls, patch.object(
|
||||||
|
service,
|
||||||
|
"_build_orchestration_message_from_image",
|
||||||
|
AsyncMock(return_value="[imagem recebida no telegram]\nDados extraidos da imagem: Registrar multa de aluguel: placa ABC1D23; valor 293,47; auto_infracao A123456."),
|
||||||
|
):
|
||||||
|
user_service_cls.return_value.get_or_create.return_value = SimpleNamespace(id=7)
|
||||||
|
orchestrator_cls.return_value.handle_message = AsyncMock(return_value="ok")
|
||||||
|
|
||||||
|
answer = await service._process_message(
|
||||||
|
text="segue a multa",
|
||||||
|
sender={"id": 99, "first_name": "Vitor"},
|
||||||
|
chat_id=99,
|
||||||
|
image_attachments=[{"mime_type": "image/jpeg", "data": b"123"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(answer, "ok")
|
||||||
|
orchestrator_cls.return_value.handle_message.assert_awaited_once()
|
||||||
|
kwargs = orchestrator_cls.return_value.handle_message.await_args.kwargs
|
||||||
|
self.assertIn("Registrar multa de aluguel", kwargs["message"])
|
||||||
|
self.assertEqual(kwargs["user_id"], 7)
|
||||||
|
|
||||||
|
async def test_process_message_returns_direct_failure_for_unreadable_image(self):
|
||||||
|
service = TelegramSatelliteService("token-teste")
|
||||||
|
tools_db = _DummySession()
|
||||||
|
mock_db = _DummySession()
|
||||||
|
|
||||||
|
with patch("app.integrations.telegram_satellite_service.SessionLocal", return_value=tools_db), patch(
|
||||||
|
"app.integrations.telegram_satellite_service.SessionMockLocal",
|
||||||
|
return_value=mock_db,
|
||||||
|
), patch("app.integrations.telegram_satellite_service.UserService") as user_service_cls, patch(
|
||||||
|
"app.integrations.telegram_satellite_service.OrquestradorService"
|
||||||
|
) as orchestrator_cls, patch.object(
|
||||||
|
service,
|
||||||
|
"_build_orchestration_message_from_image",
|
||||||
|
AsyncMock(return_value="Nao consegui identificar os dados da imagem. Descreva o documento ou envie uma foto mais nitida."),
|
||||||
|
):
|
||||||
|
user_service_cls.return_value.get_or_create.return_value = SimpleNamespace(id=7)
|
||||||
|
orchestrator_cls.return_value.handle_message = AsyncMock()
|
||||||
|
|
||||||
|
answer = await service._process_message(
|
||||||
|
text="",
|
||||||
|
sender={"id": 99},
|
||||||
|
chat_id=99,
|
||||||
|
image_attachments=[{"mime_type": "image/jpeg", "data": b"123"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("Nao consegui identificar os dados da imagem", answer)
|
||||||
|
self.assertFalse(orchestrator_cls.return_value.handle_message.await_count)
|
||||||
Loading…
Reference in New Issue