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

620 lines
22 KiB
Python

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()