From a3f1d3291a2f6bf6db314d15bc34d16bcb66011b Mon Sep 17 00:00:00 2001 From: ECO Date: Thu, 14 May 2026 12:31:45 +0000 Subject: [PATCH] =?UTF-8?q?v1.4.0=20=E2=80=94=2016=20servizi=20reali=20Ian?= =?UTF-8?q?ni,=20sala=20unica,=20ricevuta=20PDF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Dockerfile | 10 +++- app/models.py | 4 ++ app/routers/public.py | 32 ++++++++++ app/schemas.py | 6 ++ app/services/availability.py | 110 ++++++++++++++++------------------- app/services/receipt.py | 54 +++++++++++++++++ app/templates/receipt.html | 97 ++++++++++++++++++++++++++++++ frontend/index.html | 15 +++-- requirements.txt | 1 + 9 files changed, 260 insertions(+), 69 deletions(-) create mode 100644 app/services/receipt.py create mode 100644 app/templates/receipt.html 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 }} + + + + +
+
+

Farmacia Ianni

+
Salute · Servizi · Prevenzione
+
+
+ {{ pharmacy.name }} + {{ pharmacy.address }}
+ Tel. {{ pharmacy.phone }}
+ {{ pharmacy.email }} +
+
+ +
+
Ricevuta di prenotazione N° {{ booking.receipt_number or '—' }}
+
Emessa il {{ now_str }} · {{ pharmacy.name }}
+
+ +

Cliente

+ + + + {% if booking.customer_email %}{% endif %} +
Nome e cognome{{ booking.customer_name }}
Telefono{{ booking.customer_phone }}
Email{{ booking.customer_email }}
+ +

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 %} + + + {% if service.availability_text %}{% endif %} +
Categoria{{ service.category|capitalize }}
Fascia di erogazione{{ service.availability_text }}
+
+
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' + + '
' +(lastBooking && lastBooking.receipt_token ? '📄 Scarica la ricevuta (PDF)' : '') +'💬 Contattaci su WhatsApp' +'
' + '
' + '
'; } 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