- Migration: services.price_cents, services.availability_text
- Migration: bookings.receipt_number (trigger annuale IANNI-YYYY-NNNN) + receipt_token
- Constraint EXCLUDE bookings_no_overlap (sala unica, status confirmed/completed)
- availability.py: calcolo slot globale (non più per-provider)
- 16 servizi reali da Servizi.xls inseriti, 9 demo archiviati con FK preservata
- provider_services: 3 profili orari (lun-sab 9-19, lun-mar 9-13, lun-gio 9-13)
- Endpoint GET /api/receipts/{token} → PDF WeasyPrint
- Template HTML ricevuta con palette Ianni
- Dockerfile: deps sistema weasyprint (pango/cairo/fonts)
- requirements: +weasyprint>=63.0
- Frontend index.html: prezzo + fascia oraria nelle card servizio, link Scarica ricevuta nella conferma
89 lines
3.1 KiB
Python
89 lines
3.1 KiB
Python
"""Calcolo slot disponibili — modello SALA UNICA.
|
|
|
|
La sala dei servizi è unica: nessun overlap a livello globale, indipendentemente
|
|
da provider o servizio. Le availability_rules sono per coppia (provider,service)
|
|
e servono a definire le fasce orarie in cui un certo servizio può essere prenotato.
|
|
Lo slot è confermato libero solo se nessun altro booking confirmed/completed lo intersecano.
|
|
"""
|
|
from datetime import datetime, date, timedelta, time
|
|
from zoneinfo import ZoneInfo
|
|
from sqlalchemy.orm import Session
|
|
from app.models import ProviderService, Booking
|
|
from app.schemas import TimeSlot
|
|
|
|
TZ = ZoneInfo("Europe/Rome")
|
|
|
|
|
|
def get_available_slots(
|
|
db: Session,
|
|
service_id: int,
|
|
target_date: date,
|
|
duration_min: int,
|
|
) -> list[TimeSlot]:
|
|
"""Slot liberi per service_id in target_date, considerando la sala unica."""
|
|
|
|
ps_list = (
|
|
db.query(ProviderService)
|
|
.filter(ProviderService.service_id == service_id)
|
|
.all()
|
|
)
|
|
if not ps_list:
|
|
return []
|
|
|
|
weekday = target_date.weekday() # 0=lunedì
|
|
|
|
# 1. Genero candidati: per ogni provider attivo, secondo le regole del giorno
|
|
# Mappa: datetime → primo (provider_id, provider_name) che lo offre
|
|
candidate_provider = {}
|
|
for ps in ps_list:
|
|
if not ps.provider or not ps.provider.active:
|
|
continue
|
|
rules = ps.availability_rules or []
|
|
for rule in [r for r in rules if r.get("weekday") == weekday]:
|
|
sh, sm = map(int, rule["start"].split(":"))
|
|
eh, em = map(int, rule["end"].split(":"))
|
|
t = datetime.combine(target_date, time(sh, sm), tzinfo=TZ)
|
|
limit = datetime.combine(target_date, time(eh, em), tzinfo=TZ)
|
|
while t + timedelta(minutes=duration_min) <= limit:
|
|
candidate_provider.setdefault(t, (ps.provider_id, ps.provider.name))
|
|
t += timedelta(minutes=duration_min)
|
|
|
|
if not candidate_provider:
|
|
return []
|
|
|
|
# 2. Tutte le prenotazioni del giorno con stato che blocca la sala (SALA UNICA)
|
|
day_start = datetime.combine(target_date, time(0, 0), tzinfo=TZ)
|
|
day_end = day_start + timedelta(days=1)
|
|
busy_rows = (
|
|
db.query(Booking.start_at, Booking.end_at)
|
|
.filter(
|
|
Booking.start_at >= day_start,
|
|
Booking.start_at < day_end,
|
|
Booking.status.in_(["confirmed", "completed"]),
|
|
)
|
|
.all()
|
|
)
|
|
busy = [(s, e) for s, e in busy_rows]
|
|
|
|
# 3. Tengo i candidati non in conflitto
|
|
slots: list[TimeSlot] = []
|
|
for slot_start in sorted(candidate_provider.keys()):
|
|
slot_end = slot_start + timedelta(minutes=duration_min)
|
|
if any(slot_start < be and slot_end > bs for bs, be in busy):
|
|
continue
|
|
pid, pname = candidate_provider[slot_start]
|
|
slots.append(TimeSlot(
|
|
start=slot_start.strftime("%H:%M"),
|
|
end=slot_end.strftime("%H:%M"),
|
|
provider_id=pid,
|
|
provider_name=pname,
|
|
))
|
|
|
|
# 4. Se oggi, scarto orari già passati
|
|
now = datetime.now(TZ)
|
|
if target_date == now.date():
|
|
cur = now.strftime("%H:%M")
|
|
slots = [s for s in slots if s.start > cur]
|
|
|
|
return slots
|