Реалізація бронювання аренди (авто, обладнання) на веб-сайту
Аренда відрізняється від готелю двома речами: одиниця аренди фізично перемішується (автомобіль уїжджає), а тривалість можуть вимірюватися в годинах, а не лише в добах. З цього витікають специфічні завдання: облік місця видачі та повернення, почасове ціноутворення, залог.
Модель даних
CREATE TABLE rental_items (
id SERIAL PRIMARY KEY,
category_id INTEGER,
name VARCHAR(255) NOT NULL,
description TEXT,
vin_or_serial VARCHAR(100), -- VIN для авто, серійний номер для техніки
license_plate VARCHAR(20), -- лише для авто
year SMALLINT,
status VARCHAR(20) DEFAULT 'available',
-- available | rented | maintenance | out_of_service
daily_rate NUMERIC(10,2),
hourly_rate NUMERIC(10,2),
deposit_amount NUMERIC(10,2),
min_rental_hours SMALLINT DEFAULT 24,
max_rental_days SMALLINT,
images JSONB DEFAULT '[]',
specs JSONB DEFAULT '{}', -- характеристики (мощність, обсяг, etc.)
is_active BOOLEAN DEFAULT TRUE
);
CREATE TABLE rental_locations (
id SERIAL PRIMARY KEY,
name VARCHAR(100),
address TEXT,
lat NUMERIC(9,6),
lng NUMERIC(9,6)
);
CREATE TABLE rentals (
id BIGSERIAL PRIMARY KEY,
item_id INTEGER REFERENCES rental_items(id),
customer_name VARCHAR(255) NOT NULL,
customer_email VARCHAR(255) NOT NULL,
customer_phone VARCHAR(50),
driver_license VARCHAR(50), -- для авто
pickup_location_id INTEGER REFERENCES rental_locations(id),
return_location_id INTEGER REFERENCES rental_locations(id),
pickup_at TIMESTAMP NOT NULL,
return_at TIMESTAMP NOT NULL,
actual_return_at TIMESTAMP, -- реальне повернення
status VARCHAR(20) DEFAULT 'pending',
-- pending | confirmed | active | completed | cancelled | overdue
total_amount NUMERIC(12,2),
deposit_amount NUMERIC(12,2),
deposit_status VARCHAR(20) DEFAULT 'not_charged',
-- not_charged | held | released | partially_withheld | withheld
extras JSONB DEFAULT '[]', -- додаткові послуги
notes TEXT,
created_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT no_item_overlap EXCLUDE USING gist (
item_id WITH =,
tsrange(pickup_at, return_at, '[)') WITH &&
) WHERE (status NOT IN ('cancelled'))
);
Розрахунок вартості
Аренда може тарифіціватися по-різному: перші години по одній ставці, потім суточна:
from decimal import Decimal
from datetime import datetime, timedelta
def calculate_rental_price(item: dict, pickup_at: datetime, return_at: datetime, extras: list = None) -> dict:
duration = return_at - pickup_at
total_hours = duration.total_seconds() / 3600
if total_hours <= 24 and item['hourly_rate']:
# Почасова аренда
base_price = Decimal(str(item['hourly_rate'])) * Decimal(str(total_hours))
billing_unit = 'hourly'
else:
# Суточна (ceil — неповний день вважається як повний)
import math
days = math.ceil(total_hours / 24)
base_price = Decimal(str(item['daily_rate'])) * days
billing_unit = 'daily'
extras_total = sum(
Decimal(str(e['price'])) * (e.get('quantity', 1))
for e in (extras or [])
)
return {
'base_price': base_price,
'extras_total': extras_total,
'total': base_price + extras_total,
'billing_unit': billing_unit,
'deposit': Decimal(str(item['deposit_amount'])),
}
Залог (депозит)
Залог замораживається на карті при підтвердженні бронювання через Payment Intent з capture_method='manual':
def charge_deposit(rental: Rental, payment_method_id: str) -> str:
intent = stripe.PaymentIntent.create(
amount=int(rental.deposit_amount * 100),
currency='usd',
payment_method=payment_method_id,
capture_method='manual', -- тільки заморозка, не списання
confirm=True,
metadata={'rental_id': str(rental.id), 'type': 'deposit'},
)
update_rental_deposit_status(rental.id, 'held', intent.id)
return intent.id
def release_deposit(rental: Rental):
stripe.PaymentIntent.cancel(rental.deposit_payment_intent_id)
update_rental_deposit_status(rental.id, 'released')
def withhold_deposit(rental: Rental, amount: Decimal, reason: str):
# Частичне або повне списання залога при пошкодженнях
stripe.PaymentIntent.capture(
rental.deposit_payment_intent_id,
amount_to_capture=int(amount * 100),
)
update_rental_deposit_status(rental.id, 'withheld' if amount == rental.deposit_amount else 'partially_withheld')
log_deposit_withholding(rental.id, amount, reason)
Облік просрочки повернення
Cron-завдання щих 30 хвилин знаходить активні аренди з просроченою return_at:
def check_overdue_rentals():
overdue = db.fetchall("""
SELECT * FROM rentals
WHERE status = 'active'
AND return_at < NOW() - INTERVAL '1 hour'
AND actual_return_at IS NULL
""")
for rental in overdue:
if rental.status != 'overdue':
update_status(rental.id, 'overdue')
send_overdue_notification(rental)
charge_overdue_fee(rental)
Доплата за просрочку = суточна ставка × кількість просроченых днів (мінімум 1).
Місця видачі та повернення
Аренда в різних пунктах (one-way) може стояти дорожче. Наценка за one-way зберігається в таблиці маршрутів:
CREATE TABLE location_transfer_fees (
from_location_id INTEGER,
to_location_id INTEGER,
fee NUMERIC(10,2),
PRIMARY KEY (from_location_id, to_location_id)
);
Документи клієнта
При аренді авто потрібні водійські права. Фото документів завантажуються при бронюванні і зберігаються в захищеному S3-бакеті з обмеженим доступом. Посилання дійсні 15 хвилин (presigned URL).
Строки реалізації
Базова аренда одного типу об'єктів без залога і one-way — 8–11 робочих днів. Суточне + почасове ціноутворення, залог через Stripe, просрочка, декілька пунктів видачі, завантаження документів, особистий кабінет клієнта — 14–20 робочих днів.







