You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
orquestrador/app/services/domain/rental_service.py

635 lines
22 KiB
Python

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

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.integrations.events import (
RENTAL_OPENED_EVENT,
RENTAL_PAYMENT_REGISTERED_EVENT,
RENTAL_RETURN_REGISTERED_EVENT,
)
from app.services.integrations.service import publish_business_event_safely
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)
result = {
"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),
"user_id": contract.user_id,
}
await publish_business_event_safely(RENTAL_OPENED_EVENT, result)
return result
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)
result = {
"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,
"user_id": contract.user_id,
}
await publish_business_event_safely(RENTAL_RETURN_REGISTERED_EVENT, result)
return result
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)
result = {
"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",
"user_id": record.user_id,
}
await publish_business_event_safely(RENTAL_PAYMENT_REGISTERED_EVENT, result)
return result
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()