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.
620 lines
22 KiB
Python
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()
|