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