diff --git a/Dockerfile b/Dockerfile
index 54772c5..29b77d4 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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"]
diff --git a/app/models.py b/app/models.py
index 1a9c390..e108677 100644
--- a/app/models.py
+++ b/app/models.py
@@ -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())
diff --git a/app/routers/public.py b/app/routers/public.py
index 794edd9..a3e5679 100644
--- a/app/routers/public.py
+++ b/app/routers/public.py
@@ -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}"'},
+ )
diff --git a/app/schemas.py b/app/schemas.py
index 7738120..ffa825c 100644
--- a/app/schemas.py
+++ b/app/schemas.py
@@ -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
diff --git a/app/services/availability.py b/app/services/availability.py
index 8a535b7..86996b9 100644
--- a/app/services/availability.py
+++ b/app/services/availability.py
@@ -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
diff --git a/app/services/receipt.py b/app/services/receipt.py
new file mode 100644
index 0000000..da46254
--- /dev/null
+++ b/app/services/receipt.py
@@ -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()
diff --git a/app/templates/receipt.html b/app/templates/receipt.html
new file mode 100644
index 0000000..2db3f54
--- /dev/null
+++ b/app/templates/receipt.html
@@ -0,0 +1,97 @@
+
+
+
+
+Ricevuta {{ booking.receipt_number }}
+
+
+
+
+
+
+
+
Ricevuta di prenotazione N° {{ booking.receipt_number or '—' }}
+
Emessa il {{ now_str }} · {{ pharmacy.name }}
+
+
+Cliente
+
+ Nome e cognome {{ booking.customer_name }}
+ Telefono {{ booking.customer_phone }}
+ {% if booking.customer_email %}Email {{ booking.customer_email }} {% endif %}
+
+
+Appuntamento
+
+ Data e ora {{ start_str }}
+ Durata {{ service.duration_min }} minuti
+ Operatore {{ provider.name }}
+ Sede {{ pharmacy.address }}
+
+
+Servizio prenotato
+
+
{{ service.name }}
+ {% if service.description %}
{{ service.description }}
{% endif %}
+
+ Categoria {{ service.category|capitalize }}
+ {% if service.availability_text %}Fascia di erogazione {{ service.availability_text }} {% endif %}
+
+
+
Importo previsto
+
{{ price_str }}
+
+
+
+{% if booking.notes %}
+Note
+{{ booking.notes }}
+{% endif %}
+
+
+Avvertenze. 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.
+
+
+
+
+
+
diff --git a/frontend/index.html b/frontend/index.html
index 77dd3ad..821f7d7 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -148,11 +148,15 @@ function renderServices(m) {
for (const [cat, list] of Object.entries(g)) {
html += ''+cat+'
';
for (const s of list) {
- html += '';
- html += '
'+s.name+'
';
+ html += '
';
+ html += '
'+s.name+'
';
if(s.description) html += '
'+s.description+'
';
+ if(s.availability_text) html += '
⏱ '+s.availability_text+'
';
+ html += '
';
+ html += '
';
+ if(s.price_cents != null) html += '€ '+(s.price_cents/100).toFixed(2).replace(".",",")+' ';
+ html += ''+s.duration_min+' min ';
html += '
';
- html += '
'+s.duration_min+' min ';
html += '
';
}
}
@@ -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 = '
'+e.message+'
';
@@ -326,9 +331,9 @@ function renderConfirm(m) {
'
' +
''+sel.name+' ' +
fl(selDate)+' alle '+selSlot.start+' ' +
- 'Riceverai conferma su WhatsApp al numero '+lastPhone+' ' +
+ 'Riceverai conferma su WhatsApp al numero '+lastPhone+' ' +(lastBooking && lastBooking.receipt_number ? 'Ricevuta '+lastBooking.receipt_number+' ' : '') +
'
' +
- '
💬 Contattaci su WhatsApp ' +
+ '
' +
'
Prenota un altro servizio
' +
'
';
}
diff --git a/requirements.txt b/requirements.txt
index 37004e9..f435518 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -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