v1.4.0 — 16 servizi reali Ianni, sala unica, ricevuta PDF
- 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
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
"""Calcolo slot disponibili per un servizio in una data.
|
||||
"""Calcolo slot disponibili — modello SALA UNICA.
|
||||
|
||||
Logica: regole orario provider - prenotazioni esistenti - busy Google Calendar.
|
||||
Google Calendar è opzionale (fase 2).
|
||||
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
|
||||
@@ -18,9 +20,8 @@ def get_available_slots(
|
||||
target_date: date,
|
||||
duration_min: int,
|
||||
) -> list[TimeSlot]:
|
||||
"""Restituisce tutti gli slot liberi per un servizio in una data."""
|
||||
"""Slot liberi per service_id in target_date, considerando la sala unica."""
|
||||
|
||||
# 1. Trova tutti i provider che erogano questo servizio
|
||||
ps_list = (
|
||||
db.query(ProviderService)
|
||||
.filter(ProviderService.service_id == service_id)
|
||||
@@ -30,71 +31,58 @@ def get_available_slots(
|
||||
return []
|
||||
|
||||
weekday = target_date.weekday() # 0=lunedì
|
||||
all_slots = []
|
||||
|
||||
# 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.active:
|
||||
if not ps.provider or not ps.provider.active:
|
||||
continue
|
||||
|
||||
# 2. Filtra le regole di disponibilità per il giorno della settimana
|
||||
rules = ps.availability_rules or []
|
||||
day_rules = [r for r in rules if r.get("weekday") == weekday]
|
||||
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 day_rules:
|
||||
continue
|
||||
if not candidate_provider:
|
||||
return []
|
||||
|
||||
# 3. Genera tutti gli slot possibili dalle regole
|
||||
raw_slots = []
|
||||
for rule in day_rules:
|
||||
start_h, start_m = map(int, rule["start"].split(":"))
|
||||
end_h, end_m = map(int, rule["end"].split(":"))
|
||||
|
||||
slot_start = datetime.combine(target_date, time(start_h, start_m), tzinfo=TZ)
|
||||
slot_end_limit = datetime.combine(target_date, time(end_h, end_m), tzinfo=TZ)
|
||||
|
||||
while slot_start + timedelta(minutes=duration_min) <= slot_end_limit:
|
||||
raw_slots.append(slot_start)
|
||||
slot_start += timedelta(minutes=duration_min)
|
||||
|
||||
# 4. Filtra via le prenotazioni esistenti (confirmed)
|
||||
day_start = datetime.combine(target_date, time(0, 0), tzinfo=TZ)
|
||||
day_end = day_start + timedelta(days=1)
|
||||
|
||||
existing = (
|
||||
db.query(Booking)
|
||||
.filter(
|
||||
Booking.provider_id == ps.provider_id,
|
||||
Booking.start_at >= day_start,
|
||||
Booking.start_at < day_end,
|
||||
Booking.status == "confirmed",
|
||||
)
|
||||
.all()
|
||||
# 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]
|
||||
|
||||
busy_ranges = [(b.start_at, b.end_at) for b in existing]
|
||||
# 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,
|
||||
))
|
||||
|
||||
for slot_start in raw_slots:
|
||||
slot_end = slot_start + timedelta(minutes=duration_min)
|
||||
conflict = any(
|
||||
slot_start < busy_end and slot_end > busy_start
|
||||
for busy_start, busy_end in busy_ranges
|
||||
)
|
||||
if not conflict:
|
||||
all_slots.append(
|
||||
TimeSlot(
|
||||
start=slot_start.strftime("%H:%M"),
|
||||
end=slot_end.strftime("%H:%M"),
|
||||
provider_id=ps.provider_id,
|
||||
provider_name=ps.provider.name,
|
||||
)
|
||||
)
|
||||
|
||||
# 5. Ordina per orario
|
||||
all_slots.sort(key=lambda s: s.start)
|
||||
|
||||
# 6. Filtra slot nel passato se target_date è oggi
|
||||
# 4. Se oggi, scarto orari già passati
|
||||
now = datetime.now(TZ)
|
||||
if target_date == now.date():
|
||||
all_slots = [s for s in all_slots if s.start > now.strftime("%H:%M")]
|
||||
cur = now.strftime("%H:%M")
|
||||
slots = [s for s in slots if s.start > cur]
|
||||
|
||||
return all_slots
|
||||
return slots
|
||||
|
||||
54
app/services/receipt.py
Normal file
54
app/services/receipt.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Generazione ricevuta PDF per booking."""
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from weasyprint import HTML
|
||||
|
||||
TZ = ZoneInfo("Europe/Rome")
|
||||
TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "templates"
|
||||
|
||||
_env = Environment(
|
||||
loader=FileSystemLoader(str(TEMPLATE_DIR)),
|
||||
autoescape=select_autoescape(["html"]),
|
||||
)
|
||||
|
||||
_WEEKDAY_IT = ["lunedì", "martedì", "mercoledì", "giovedì", "venerdì", "sabato", "domenica"]
|
||||
_MONTH_IT = ["", "gennaio", "febbraio", "marzo", "aprile", "maggio", "giugno",
|
||||
"luglio", "agosto", "settembre", "ottobre", "novembre", "dicembre"]
|
||||
|
||||
|
||||
def _fmt_datetime(dt: datetime) -> str:
|
||||
d = dt.astimezone(TZ)
|
||||
return f"{_WEEKDAY_IT[d.weekday()]} {d.day} {_MONTH_IT[d.month]} {d.year} alle ore {d:%H:%M}"
|
||||
|
||||
|
||||
def _fmt_now() -> str:
|
||||
d = datetime.now(TZ)
|
||||
return f"{d.day} {_MONTH_IT[d.month]} {d.year} alle ore {d:%H:%M}"
|
||||
|
||||
|
||||
def _price_str(price_cents):
|
||||
if price_cents is None:
|
||||
return "—"
|
||||
return f"€ {price_cents/100:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
|
||||
|
||||
|
||||
def render_receipt_pdf(booking, service, provider, settings_map: dict) -> bytes:
|
||||
"""Costruisce il PDF della ricevuta. settings_map è {key: value} dalla tabella settings."""
|
||||
template = _env.get_template("receipt.html")
|
||||
html = template.render(
|
||||
booking=booking,
|
||||
service=service,
|
||||
provider=provider,
|
||||
pharmacy={
|
||||
"name": settings_map.get("pharmacy_name", "Farmacia Ianni SNC"),
|
||||
"address": settings_map.get("pharmacy_address", ""),
|
||||
"phone": settings_map.get("pharmacy_phone", ""),
|
||||
"email": settings_map.get("smtp_from", ""),
|
||||
},
|
||||
start_str=_fmt_datetime(booking.start_at),
|
||||
now_str=_fmt_now(),
|
||||
price_str=_price_str(service.price_cents),
|
||||
)
|
||||
return HTML(string=html).write_pdf()
|
||||
Reference in New Issue
Block a user