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:
ECO
2026-05-14 12:31:45 +00:00
parent c33ec8450e
commit a3f1d3291a
9 changed files with 260 additions and 69 deletions

View File

@@ -2,12 +2,16 @@ FROM python:3.12-slim
WORKDIR /app
# Dipendenze sistema per weasyprint (pango, cairo, fonts)
RUN apt-get update && apt-get install -y --no-install-recommends \
libpango-1.0-0 libpangoft2-1.0-0 libcairo2 libgdk-pixbuf-2.0-0 \
libffi-dev shared-mime-info fonts-dejavu-core fonts-liberation
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ app/
COPY frontend/ frontend/
COPY app ./app
COPY frontend ./frontend
EXPOSE 8020
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8020"]

View File

@@ -14,6 +14,8 @@ class Service(Base):
duration_min = Column(Integer, nullable=False, default=15)
description = Column(Text)
category = Column(Text, default="generale")
price_cents = Column(Integer)
availability_text = Column(Text)
active = Column(Boolean, default=True)
sort_order = Column(Integer, default=0)
created_at = Column(DateTime(timezone=True), server_default=func.now())
@@ -63,6 +65,8 @@ class Booking(Base):
end_at = Column(DateTime(timezone=True), nullable=False)
status = Column(Text, default="confirmed")
google_event_id = Column(Text)
receipt_number = Column(Text)
receipt_token = Column(Text)
notes = Column(Text)
reminder_sent = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())

View File

@@ -1,13 +1,17 @@
"""Endpoint pubblici — no auth. Usati dal widget di prenotazione."""
import secrets
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import text
from sqlalchemy.orm import Session, joinedload
from app.database import get_db
from app.models import Service, Booking, ProviderService
from app.schemas import ServiceOut, BookingCreate, BookingOut, TimeSlot
from app.services.availability import get_available_slots
from app.services.notifications import notify_booking_confirmed, notify_operator
from fastapi.responses import Response
from app.services.receipt import render_receipt_pdf
router = APIRouter(prefix="/api", tags=["public"])
TZ = ZoneInfo("Europe/Rome")
@@ -62,6 +66,7 @@ async def create_booking(data: BookingCreate, db: Session = Depends(get_db)):
end_at=end_at,
notes=data.notes,
)
booking.receipt_token = secrets.token_urlsafe(24)
db.add(booking)
db.commit()
db.refresh(booking)
@@ -111,3 +116,30 @@ def cancel_booking(booking_id: int, phone: str, db: Session = Depends(get_db)):
booking.status = "cancelled"
db.commit()
return {"ok": True, "message": "Prenotazione cancellata"}
@router.get("/receipts/{token}")
def get_receipt(token: str, db: Session = Depends(get_db)):
"""Restituisce il PDF della ricevuta. Token random anti-enumeration."""
booking = (
db.query(Booking)
.options(joinedload(Booking.service), joinedload(Booking.provider))
.filter(Booking.receipt_token == token)
.first()
)
if not booking:
raise HTTPException(404, "Ricevuta non trovata")
if booking.status not in ("confirmed", "completed"):
raise HTTPException(410, "Ricevuta non disponibile per questa prenotazione")
# Settings → dict
settings_rows = db.execute(text("SELECT key, value FROM settings")).fetchall()
settings_map = {k: v for k, v in settings_rows}
pdf_bytes = render_receipt_pdf(booking, booking.service, booking.provider, settings_map)
fname = f"ricevuta-{booking.receipt_number or booking.id}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="{fname}"'},
)

View File

@@ -11,6 +11,8 @@ class ServiceOut(BaseModel):
duration_min: int
description: Optional[str] = None
category: str
price_cents: Optional[int] = None
availability_text: Optional[str] = None
class Config:
from_attributes = True
@@ -22,6 +24,8 @@ class ServiceCreate(BaseModel):
duration_min: int = 15
description: Optional[str] = None
category: str = "generale"
price_cents: Optional[int] = None
availability_text: Optional[str] = None
# === Providers ===
@@ -77,6 +81,8 @@ class BookingOut(BaseModel):
status: str
notes: Optional[str] = None
created_at: datetime
receipt_number: Optional[str] = None
receipt_token: Optional[str] = None
service: Optional[ServiceOut] = None
provider: Optional[ProviderOut] = None

View File

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

View File

@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="utf-8">
<title>Ricevuta {{ booking.receipt_number }}</title>
<style>
@page { size: A4; margin: 18mm 16mm; }
* { box-sizing: border-box; }
body { font-family: 'DejaVu Sans', 'Liberation Sans', Arial, sans-serif; font-size: 10.5pt; color: #111; margin: 0; }
.header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 3px solid #002C50; padding-bottom: 12px; margin-bottom: 22px; }
.brand { color: #002C50; }
.brand h1 { margin: 0; font-size: 18pt; letter-spacing: 0.5px; }
.brand .sub { color: #6A971F; font-weight: 600; font-size: 10pt; letter-spacing: 0.3px; }
.pharm { text-align: right; font-size: 9.5pt; color: #444; line-height: 1.45; }
.pharm strong { color: #002C50; font-size: 11pt; display:block; margin-bottom: 2px; }
.receipt-block { background: #f5f7fa; border-left: 4px solid #6A971F; padding: 10px 14px; margin-bottom: 22px; }
.receipt-block .num { font-size: 13pt; font-weight: 700; color: #002C50; }
.receipt-block .meta { font-size: 9.5pt; color: #555; margin-top: 3px; }
h2 { color: #002C50; font-size: 12pt; margin: 18px 0 8px; text-transform: uppercase; letter-spacing: 0.5px; }
table.kv { width: 100%; border-collapse: collapse; margin-bottom: 12px; }
table.kv td { padding: 6px 0; vertical-align: top; }
table.kv td.label { color: #666; width: 32%; }
table.kv td.val { color: #111; font-weight: 500; }
.service-box { border: 1px solid #d8dde3; border-radius: 4px; padding: 14px; margin-bottom: 16px; }
.service-box .name { font-weight: 700; color: #002C50; font-size: 12pt; margin-bottom: 4px; }
.service-box .desc { color: #555; font-size: 9.5pt; margin-bottom: 8px; }
.price-row { display: flex; justify-content: space-between; align-items: center; border-top: 2px solid #002C50; margin-top: 12px; padding-top: 10px; }
.price-row .label { font-size: 11pt; color: #002C50; font-weight: 600; }
.price-row .amount { font-size: 16pt; color: #6A971F; font-weight: 700; }
.note { font-size: 8.5pt; color: #777; line-height: 1.4; margin-top: 26px; padding-top: 10px; border-top: 1px dashed #d8dde3; }
.footer { font-size: 8pt; color: #999; text-align: center; margin-top: 30px; }
</style>
</head>
<body>
<div class="header">
<div class="brand">
<h1>Farmacia Ianni</h1>
<div class="sub">Salute · Servizi · Prevenzione</div>
</div>
<div class="pharm">
<strong>{{ pharmacy.name }}</strong>
{{ pharmacy.address }}<br>
Tel. {{ pharmacy.phone }}<br>
{{ pharmacy.email }}
</div>
</div>
<div class="receipt-block">
<div class="num">Ricevuta di prenotazione N° {{ booking.receipt_number or '—' }}</div>
<div class="meta">Emessa il {{ now_str }} · {{ pharmacy.name }}</div>
</div>
<h2>Cliente</h2>
<table class="kv">
<tr><td class="label">Nome e cognome</td><td class="val">{{ booking.customer_name }}</td></tr>
<tr><td class="label">Telefono</td><td class="val">{{ booking.customer_phone }}</td></tr>
{% if booking.customer_email %}<tr><td class="label">Email</td><td class="val">{{ booking.customer_email }}</td></tr>{% endif %}
</table>
<h2>Appuntamento</h2>
<table class="kv">
<tr><td class="label">Data e ora</td><td class="val">{{ start_str }}</td></tr>
<tr><td class="label">Durata</td><td class="val">{{ service.duration_min }} minuti</td></tr>
<tr><td class="label">Operatore</td><td class="val">{{ provider.name }}</td></tr>
<tr><td class="label">Sede</td><td class="val">{{ pharmacy.address }}</td></tr>
</table>
<h2>Servizio prenotato</h2>
<div class="service-box">
<div class="name">{{ service.name }}</div>
{% if service.description %}<div class="desc">{{ service.description }}</div>{% endif %}
<table class="kv" style="margin-bottom:0">
<tr><td class="label">Categoria</td><td class="val">{{ service.category|capitalize }}</td></tr>
{% if service.availability_text %}<tr><td class="label">Fascia di erogazione</td><td class="val">{{ service.availability_text }}</td></tr>{% endif %}
</table>
<div class="price-row">
<div class="label">Importo previsto</div>
<div class="amount">{{ price_str }}</div>
</div>
</div>
{% if booking.notes %}
<h2>Note</h2>
<div style="background:#fff8e1;border-left:3px solid #f0c040;padding:8px 12px;font-size:10pt;">{{ booking.notes }}</div>
{% endif %}
<div class="note">
<strong>Avvertenze.</strong> Questa è una ricevuta di prenotazione: documenta l'appuntamento e l'importo previsto, ma NON sostituisce lo scontrino fiscale o la fattura sanitaria che verranno emessi dal personale di farmacia al momento dell'esecuzione del servizio. I servizi sanitari di diagnostica e prevenzione sono esenti IVA ai sensi dell'art. 10 DPR 633/1972. Per disdire l'appuntamento contattare la farmacia con almeno 24 ore di anticipo.
</div>
<div class="footer">
Token verifica: {{ booking.receipt_token[:8] }}… · Generato automaticamente da bookingservizi.farmaciaianni.it
</div>
</body>
</html>

View File

@@ -148,11 +148,15 @@ function renderServices(m) {
for (const [cat, list] of Object.entries(g)) {
html += '<div class="text-xs font-bold uppercase tracking-widest text-teal-600 mb-2 mt-5">'+cat+'</div>';
for (const s of list) {
html += '<div class="svc-card flex items-center justify-between p-4 mb-2 rounded-2xl bg-white/50 border-2 border-transparent" onclick="pickSvc('+s.id+')">';
html += '<div><div class="font-semibold text-[15px] text-gray-900">'+s.name+'</div>';
html += '<div class="svc-card flex items-start justify-between p-4 mb-2 rounded-2xl bg-white/50 border-2 border-transparent" onclick="pickSvc('+s.id+')">';
html += '<div class="flex-1 pr-3"><div class="font-semibold text-[15px] text-gray-900">'+s.name+'</div>';
if(s.description) html += '<div class="text-[13px] text-gray-500 mt-0.5">'+s.description+'</div>';
if(s.availability_text) html += '<div class="text-[12px] text-gray-400 mt-1">⏱ '+s.availability_text+'</div>';
html += '</div>';
html += '<div class="flex flex-col items-end gap-1 ml-2 whitespace-nowrap">';
if(s.price_cents != null) html += '<span class="text-[15px] font-bold text-gray-900">€ '+(s.price_cents/100).toFixed(2).replace(".",",")+'</span>';
html += '<span class="text-xs font-semibold text-teal-600 bg-teal-50 px-2.5 py-1 rounded-full">'+s.duration_min+' min</span>';
html += '</div>';
html += '<span class="text-xs font-semibold text-teal-600 bg-teal-50 px-3 py-1.5 rounded-full ml-4 whitespace-nowrap">'+s.duration_min+' min</span>';
html += '</div>';
}
}
@@ -310,6 +314,7 @@ async function submitBooking() {
})
});
if(!r.ok) { const e = await r.json(); throw new Error(e.detail || 'Errore'); }
lastBooking = await r.json();
go(3);
} catch(e) {
document.getElementById('err').innerHTML = '<div class="text-red-600 text-sm mb-4 p-3 bg-red-50 rounded-xl">'+e.message+'</div>';
@@ -326,9 +331,9 @@ function renderConfirm(m) {
'<p class="text-sm text-gray-500 leading-relaxed">' +
'<strong class="text-gray-900">'+sel.name+'</strong><br>' +
fl(selDate)+' alle <strong class="text-gray-900">'+selSlot.start+'</strong><br><br>' +
'Riceverai conferma su WhatsApp<br>al numero <strong class="text-gray-900">'+lastPhone+'</strong>' +
'Riceverai conferma su WhatsApp<br>al numero <strong class="text-gray-900">'+lastPhone+'</strong>' +(lastBooking && lastBooking.receipt_number ? '<br><br><span class="text-[11px] uppercase tracking-wider text-gray-400">Ricevuta</span><br><strong class="text-gray-900 text-sm">'+lastBooking.receipt_number+'</strong>' : '') +
'</p>' +
'<a href="https://wa.me/393930579002" target="_blank" class="inline-flex items-center gap-2 mt-6 px-6 py-3 rounded-xl text-white font-semibold text-sm" style="background:#25d366;text-decoration:none">💬 Contattaci su WhatsApp</a>' +
'<div class="mt-6 flex flex-col items-center gap-3">' +(lastBooking && lastBooking.receipt_token ? '<a href="'+API+'/api/receipts/'+lastBooking.receipt_token+'" target="_blank" class="inline-flex items-center gap-2 px-6 py-3 rounded-xl font-semibold text-sm" style="background:#002C50;color:white;text-decoration:none">📄 Scarica la ricevuta (PDF)</a>' : '') +'<a href="https://wa.me/393930579002" target="_blank" class="inline-flex items-center gap-2 px-6 py-3 rounded-xl text-white font-semibold text-sm" style="background:#25d366;text-decoration:none">💬 Contattaci su WhatsApp</a>' +'</div>' +
'<div class="mt-5"><button onclick="location.reload()" class="text-teal-600 text-sm font-semibold hover:underline cursor-pointer" style="background:none;border:none">Prenota un altro servizio</button></div>' +
'</div>';
}

View File

@@ -13,3 +13,4 @@ apscheduler>=3.10.4
authlib>=1.3.0
python-jose[cryptography]>=3.3.0
itsdangerous>=2.1.0
weasyprint>=63.0