Ricette
Cinque flussi end-to-end pronti: sincronizzazione prenotazioni, CRM, DMS, ticketing e report giornaliero automatico.
Ogni ricetta è un flusso completo: obiettivo, architettura, eventi coinvolti, mappatura dei campi, codice e gestione degli errori. Adattale ai tuoi sistemi mantenendo i pattern di idempotenza e ritentativo.
Prima di iniziare
Tutte le ricette presuppongono un endpoint che verifica la firma HMAC e risponde 202 in
pochi millisecondi, come descritto in Firma HMAC.
Ricetta 1 — Prenotazioni verso ERP o AS/400
Obiettivo
Riportare ogni prenotazione confermata nel gestionale legacy, creando l'ordine di servizio e riportando il numero d'ordine nella prenotazione Blu Pantheon.
Eventi coinvolti
| Evento | Azione nel gestionale |
|---|---|
booking.confirmed | Crea ordine di servizio |
booking.rescheduled | Aggiorna data e orario dell'ordine |
booking.cancelled | Annulla l'ordine e libera la risorsa |
booking.no_show | Chiudi l'ordine con causale mancata presenza |
Architettura
booking.confirmed ──► Listener ──► Coda ──► Robot RPA
│
┌─────────────────────┴──────────────────────┐
│ AS/400: emulatore 5250, transazione ORD01 │
│ oppure ERP: chiamata SOAP/REST nativa │
└─────────────────────┬──────────────────────┘
│
PATCH /identity/v1/bookings/{id}
metadata.erpOrderNumber = "ORD-2026-88213"Mappatura dei campi
| Campo Blu Pantheon | Campo gestionale | Trasformazione |
|---|---|---|
data.reference | RIFEST | Troncamento a 20 caratteri |
data.client.id | CODCLI | Lookup su tabella di corrispondenza, fallback creazione |
data.client.displayName | RAGSOC | Maiuscolo, troncamento a 40 caratteri |
data.service.id | CODSER | Tabella di mappatura servizi |
data.startAt | DTAPPU + ORAPPU | Da ISO 8601 con offset a AAAAMMGG e HHMM in ora locale |
data.service.durationMinutes | DURATA | Nessuna |
data.resource.name | SQUADRA | Tabella di mappatura risorse |
data.location.address | INDIRZ | Troncamento a 60 caratteri |
data.price.amount | IMPORT | Da centesimi a euro, divisione per 100 |
data.customFields.matricola_contatore | MATRIC | Nessuna |
Fuso orario
startAt contiene l'offset (+02:00 d'estate, +01:00 d'inverno). I gestionali AS/400
lavorano quasi sempre in ora locale senza offset. Converti esplicitamente a Europe/Rome
prima di scrivere, altrimenti gli appuntamenti slittano di un'ora due volte l'anno.
Implementazione
Sottoscrivi gli eventi
Registra https://automation.acme.it/webhooks/blupantheon con i quattro eventi elencati sopra.
Normalizza il payload
from datetime import datetime
from zoneinfo import ZoneInfo
ROME = ZoneInfo("Europe/Rome")
SERVIZI = {
"bp_svc_2a11": "SRV001",
"bp_svc_2a12": "SRV002",
}
SQUADRE = {
"Squadra Nord": "SQN",
"Squadra Sud": "SQS",
}
def to_erp_record(event: dict) -> dict:
data = event["data"]
start = datetime.fromisoformat(data["startAt"]).astimezone(ROME)
return {
"RIFEST": data["reference"][:20],
"CODCLI": lookup_codcli(data["client"]),
"RAGSOC": data["client"]["displayName"].upper()[:40],
"CODSER": SERVIZI.get(data["service"]["id"], "SRV999"),
"DTAPPU": start.strftime("%Y%m%d"),
"ORAPPU": start.strftime("%H%M"),
"DURATA": data["service"]["durationMinutes"],
"SQUADRA": SQUADRE.get(data["resource"]["name"], "SQX"),
"INDIRZ": data.get("location", {}).get("address", "")[:60],
"IMPORT": round(data["price"]["amount"] / 100, 2),
"MATRIC": data.get("customFields", {}).get("matricola_contatore", ""),
}
def lookup_codcli(client: dict) -> str:
"""Cerca il codice cliente nel gestionale, altrimenti lo crea."""
existing = gestionale.find_client_by_vat(client.get("vatNumber"))
if existing:
return existing["CODCLI"]
return gestionale.create_client(client)["CODCLI"]Scrivi nel gestionale in modo idempotente
Prima di creare, cerca un ordine esistente con lo stesso RIFEST. Il gestionale non conosce le
chiavi di idempotenza: la deduplica è tua responsabilità.
def upsert_ordine(record: dict) -> str:
esistente = gestionale.find_order_by_reference(record["RIFEST"])
if esistente:
gestionale.update_order(esistente["NUMORD"], record)
return esistente["NUMORD"]
return gestionale.create_order(record)["NUMORD"]Riporta il numero d'ordine
client.patch_booking(
booking_id=event["data"]["bookingId"],
payload={"metadata": {"erpOrderNumber": numero_ordine}},
idempotency_key=delivery_id,
)Riconcilia ogni ora
curl -sS -G "$BP_API_BASE_URL/identity/v1/bookings" \
-H "Authorization: Bearer $BP_API_KEY" \
-H "X-BP-Project: $BP_PROJECT_ID" \
--data-urlencode "status=confirmed" \
--data-urlencode "updatedSince=$(date -u -v-6H +%Y-%m-%dT%H:%M:%SZ)" \
--data-urlencode "limit=100" \
| jq -r '.data[] | select(.metadata.erpOrderNumber == null) | .id'Le prenotazioni senza erpOrderNumber sono quelle non sincronizzate: rielaborale.
Errori e ritentativi
| Problema | Comportamento |
|---|---|
| Emulatore 5250 bloccato su una schermata inattesa | Cattura screenshot, chiudi la sessione, marca Failed come errore tecnico, ritenta |
| Codice servizio non mappato | Usa SRV999, apri un ticket interno, non bloccare il flusso |
| Cliente non trovato e creazione fallita | Errore di business: non ritentare, notifica l'amministrazione |
| Ordine già esistente | Aggiorna invece di creare, esito positivo |
API Blu Pantheon 429 | Attendi Retry-After e ritenta |
Ricetta 2 — Cliente nel CRM a fine conversazione
Obiettivo
Creare o aggiornare il contatto nel CRM quando una conversazione si chiude con esito qualificante, allegando il riepilogo generato dall'agente.
Eventi coinvolti
| Evento | Azione |
|---|---|
session.closed | Crea o aggiorna il contatto e registra l'attività |
client.created | Crea il lead se ancora assente |
client.updated | Allinea i dati anagrafici |
client.consent.updated | Aggiorna i flag di consenso marketing |
Architettura
session.closed ──► Listener ──► Filtro (outcome in {risolto, qualificato})
│
▼
GET /identity/v1/clients/{clientId}
│
▼
Ricerca nel CRM per email o partita IVA
│
┌───────────────┴───────────────┐
▼ ▼
Contatto esistente Nessun contatto
PATCH nel CRM POST nel CRM
│ │
└───────────────┬───────────────┘
▼
Crea attività con il riepilogoMappatura dei campi
| Campo Blu Pantheon | Campo CRM | Note |
|---|---|---|
data.clientId | bp_client_id (campo custom) | Chiave di correlazione, indicizzata |
client.email | email | Chiave di ricerca primaria |
client.vatNumber | vat_number | Chiave di ricerca per aziende |
client.displayName | company_name o full_name | In base a client.type |
client.phone | phone | Normalizza in formato E.164 |
client.address.city | city | Nessuna |
client.consents.marketing | opt_in_marketing | Booleano |
data.summary | corpo dell'attività | Testo del riepilogo |
data.tags | tags | Concatenazione |
data.rating.score | last_csat | Intero da 1 a 5 |
data.sessionId | bp_session_id | Per il collegamento alla conversazione |
Implementazione
Filtra le sessioni rilevanti
const OUTCOME_QUALIFICANTI = new Set(['risolto', 'qualificato', 'preventivo_richiesto']);
function isRilevante(event) {
const data = event.data;
return (
event.event === 'session.closed' &&
Boolean(data.clientId) &&
OUTCOME_QUALIFICANTI.has(data.outcome)
);
}Recupera l'anagrafica completa
Il payload di session.closed contiene solo clientId. Recupera i dati completi con una
chiamata inbound.
const client = await bpRequest(`/identity/v1/clients/${event.data.clientId}`);Upsert nel CRM
function normalizzaTelefono(phone) {
if (!phone) return null;
const digits = phone.replace(/[^\d+]/g, '');
if (digits.startsWith('+')) return digits;
if (digits.startsWith('00')) return `+${digits.slice(2)}`;
return `+39${digits}`;
}
async function upsertContatto(client, event) {
const dati = client.data;
const esistente =
(await crm.findByCustomField('bp_client_id', dati.id)) ??
(dati.vatNumber ? await crm.findByVat(dati.vatNumber) : null) ??
(dati.email ? await crm.findByEmail(dati.email) : null);
const payload = {
bp_client_id: dati.id,
bp_session_id: event.data.sessionId,
email: dati.email ?? null,
phone: normalizzaTelefono(dati.phone),
vat_number: dati.vatNumber ?? null,
company_name: dati.type === 'azienda' ? dati.displayName : null,
full_name: dati.type === 'persona' ? dati.displayName : null,
city: dati.address?.city ?? null,
opt_in_marketing: Boolean(dati.consents?.marketing),
last_csat: event.data.rating?.score ?? null,
tags: (event.data.tags ?? []).join(','),
};
if (esistente) {
await crm.updateContact(esistente.id, payload);
return esistente.id;
}
return (await crm.createContact(payload)).id;
}Registra l'attività
await crm.createActivity({
contactId,
type: 'conversazione',
subject: `Conversazione Blu Pantheon del ${new Date(event.createdAt).toLocaleDateString('it-IT')}`,
body: event.data.summary,
durationMinutes: Math.round(event.data.durationSeconds / 60),
externalId: event.id,
});Usa event.id come externalId e configura un vincolo di unicità nel CRM: un replay non
genererà attività duplicate.
Errori e ritentativi
| Problema | Comportamento |
|---|---|
| Contatto duplicato nel CRM | Merge manuale, log dell'anomalia. Non bloccare il flusso. |
| Email assente e partita IVA assente | Crea comunque con bp_client_id come sola chiave |
| CRM non raggiungibile | Ritenta 5 volte con backoff, poi dead letter locale |
| Consenso marketing negato | Non iscrivere a liste di invio. Aggiorna solo il flag. |
Attività già presente (externalId duplicato) | Ignora, esito positivo |
Consensi privacy
Non copiare mai un contatto in una lista di marketing senza aver verificato
consents.marketing. Vedi Conformità.
Ricetta 3 — Documenti verso DMS o protocollo informatico
Obiettivo
Depositare automaticamente i documenti classificati nel sistema documentale o nel protocollo informatico, con i metadati estratti.
Eventi coinvolti
| Evento | Azione |
|---|---|
document.classified | Scarica, deposita nel DMS, protocolla |
document.extraction.completed | Aggiorna i metadati del documento già depositato |
document.rejected | Notifica l'operatore, nessun deposito |
Architettura
document.classified ──► Listener ──► Coda
│
▼
GET /identity/v1/documents/{id}/content (Authorization richiesto)
│
▼
Calcolo hash SHA-256 e controllo duplicati
│
▼
Deposito nel DMS + protocollazione
│
▼
PATCH /identity/v1/documents/{id} metadata.protocolloMappatura dei campi
| Campo Blu Pantheon | Campo protocollo | Note |
|---|---|---|
data.fileName | nome_file | Sanifica i caratteri non ammessi |
data.classification.category | tipologia_documento | Tabella di mappatura |
data.extracted.numero_documento | numero_documento | Nessuna |
data.extracted.data_documento | data_documento | Da AAAA-MM-GG a GG/MM/AAAA |
data.extracted.fornitore | mittente_denominazione | Maiuscolo |
data.extracted.partita_iva | mittente_piva | Rimuovi il prefisso IT se richiesto |
data.extracted.totale | importo_totale | Due decimali |
event.id | id_esterno | Chiave di deduplica |
data.classification.confidence | — | Sotto 0.85 instrada a revisione manuale |
Implementazione
Scarica il documento
import hashlib
def scarica_documento(document_id: str) -> tuple[bytes, str]:
response = session.get(
f"{BASE_URL}/identity/v1/documents/{document_id}/content",
headers={"Authorization": f"Bearer {API_KEY}"},
timeout=60,
stream=True,
)
response.raise_for_status()
contenuto = response.content
digest = hashlib.sha256(contenuto).hexdigest()
return contenuto, digestURL a scadenza
Il campo downloadUrl del payload scade dopo un'ora. Scarica appena ricevuto l'evento; se il
messaggio resta a lungo in coda, ricostruisci l'URL dall'identificativo del documento.
Instrada per confidenza
SOGLIA_CONFIDENZA = 0.85
def instrada(event: dict) -> str:
confidenza = event["data"]["classification"]["confidence"]
if confidenza < SOGLIA_CONFIDENZA:
return "revisione_manuale"
return "deposito_automatico"Deposita e protocolla
def deposita(event: dict, contenuto: bytes, digest: str) -> str:
data = event["data"]
estratti = data.get("extracted", {})
if dms.exists_by_hash(digest):
return dms.get_by_hash(digest)["protocollo"]
documento_id = dms.upload(
contenuto=contenuto,
nome_file=sanifica(data["fileName"]),
mime_type=data["mimeType"],
hash_sha256=digest,
)
protocollo = protocollo_informatico.registra(
documento_id=documento_id,
tipologia=MAPPATURA_TIPOLOGIE.get(
data["classification"]["category"], "GENERICO"
),
numero_documento=estratti.get("numero_documento", ""),
data_documento=converti_data(estratti.get("data_documento")),
mittente=estratti.get("fornitore", "").upper(),
mittente_piva=(estratti.get("partita_iva") or "").removeprefix("IT"),
importo=estratti.get("totale"),
id_esterno=event["id"],
)
return protocollo
def sanifica(nome: str) -> str:
ammessi = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_."
return "".join(carattere if carattere in ammessi else "_" for carattere in nome)[:120]
def converti_data(iso: str | None) -> str:
if not iso:
return ""
anno, mese, giorno = iso.split("-")
return f"{giorno}/{mese}/{anno}"Riporta il numero di protocollo
client.patch_document(
document_id=event["data"]["documentId"],
payload={"metadata": {"protocollo": protocollo, "dms_hash": digest}},
idempotency_key=delivery_id,
)Errori e ritentativi
| Problema | Comportamento |
|---|---|
Download 401 | La chiave manca di identity:documents:read. Non ritentare. |
Download 410 | Il documento è stato cancellato. Chiudi con esito ignorato. |
| Hash già presente nel DMS | Riusa il protocollo esistente, esito positivo |
| Protocollo informatico non disponibile | Ritenta con backoff fino a 6 ore, poi allarme |
| Confidenza sotto soglia | Instrada a revisione manuale, non protocollare |
| File oltre il limite del DMS | Errore di business, notifica l'operatore |
Ricetta 4 — Escalation verso ticketing
Obiettivo
Aprire automaticamente un ticket in Jira, ServiceNow o Zendesk quando viene richiesto un handover, e mantenere allineati gli stati.
Eventi coinvolti
| Evento | Azione |
|---|---|
session.handover.requested | Crea il ticket |
session.handover.assigned | Assegna il ticket all'operatore |
session.handover.timeout | Aumenta la priorità ed escala al secondo livello |
session.closed | Chiudi il ticket con il riepilogo |
Mappatura dei campi
| Campo Blu Pantheon | Jira | ServiceNow | Zendesk |
|---|---|---|---|
data.sessionId | customfield_10050 | u_bp_session_id | custom_field_360001 |
data.summary | description | description | comment.body |
data.priority | priority.name | urgency | priority |
data.queue | components | assignment_group | group_id |
data.client.displayName | customfield_10051 | caller_id | requester.name |
data.client.email | reporter (via lookup) | u_email | requester.email |
event.id | customfield_10052 | correlation_id | external_id |
data.traceId | customfield_10053 | correlation_display | custom_field_360002 |
Mappatura delle priorità:
| Blu Pantheon | Jira | ServiceNow (urgency) | Zendesk |
|---|---|---|---|
critica | Highest | 1 - High | urgent |
alta | High | 2 - Medium | high |
media | Medium | 2 - Medium | normal |
bassa | Low | 3 - Low | low |
Implementazione
Crea il ticket
curl -sS -X POST "https://acme.atlassian.net/rest/api/3/issue" \
-H "Authorization: Basic $JIRA_BASIC_AUTH" \
-H "Content-Type: application/json" \
--data-raw '{
"fields": {
"project": { "key": "SUP" },
"issuetype": { "name": "Richiesta assistenza" },
"summary": "Handover sessione bp_sess_3d81f0aa",
"description": {
"type": "doc",
"version": 1,
"content": [
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Il cliente segnala errore E12 sul contatore." }
]
}
]
},
"priority": { "name": "High" },
"customfield_10050": "bp_sess_3d81f0aa",
"customfield_10052": "evt_01J9X5B0PZQ4T1N7C3H8M2AKE",
"customfield_10053": "trc_6b19e0d4c8f24a7f"
}
}' | jq -r '.key'Evita i ticket duplicati
Prima di creare, cerca per external_id o correlation_id uguale a event.id.
const esistente = await ticketing.findByExternalId(event.id);
if (esistente) {
return esistente.key;
}In Zendesk external_id è già unico se configurato. In Jira usa una JQL:
"BP Event ID[Short text]" ~ "evt_01J9X5B0PZQ4T1N7C3H8M2AKE".
Riporta il ticket nella sessione
await bpRequest(`/identity/v1/sessions/${event.data.sessionId}/notes`, {
method: 'POST',
idempotencyKey: deliveryId,
body: {
body: `Ticket aperto: ${ticketKey}`,
visibility: 'internal',
author: 'automazione-ticketing',
},
});Allinea la chiusura
Su session.closed, aggiorna il ticket con il riepilogo e chiudilo se l'esito è risolto.
if (event.event === 'session.closed') {
const ticket = await ticketing.findBySessionId(event.data.sessionId);
if (!ticket) return;
await ticketing.addComment(ticket.id, event.data.summary);
if (event.data.outcome === 'risolto') {
await ticketing.transition(ticket.id, 'Chiuso');
}
}Errori e ritentativi
| Problema | Comportamento |
|---|---|
| Campo custom inesistente | Errore di configurazione: correggi la mappatura, non ritentare |
| Utente richiedente non trovato | Crea il ticket con richiedente generico e annota l'email |
| Transizione di stato non consentita | Registra l'anomalia, lascia il ticket aperto |
| Ticketing non raggiungibile | Ritenta 5 volte con backoff, poi dead letter |
| Ticket già esistente | Riusa quello trovato, esito positivo |
Ricetta 5 — Report giornaliero automatico
Obiettivo
Generare ogni mattina un report delle attività del giorno precedente e distribuirlo via email e su SharePoint.
Architettura
Questa ricetta usa polling API, non webhook: è un processo pianificato, non event-driven.
Scheduler (cron 07:00 Europe/Rome)
│
▼
GET /identity/v1/sessions?from=...&to=... (paginato)
GET /identity/v1/bookings?from=...&to=... (paginato)
│
▼
Aggregazione e generazione XLSX
│
├──► Upload su SharePoint (Microsoft Graph)
└──► Invio email al teamMetriche da includere
| Metrica | Origine | Calcolo |
|---|---|---|
| Conversazioni totali | /sessions | Conteggio |
| Conversazioni con handover | /sessions | Filtro su handoverRequested |
| Tasso di handover | derivato | handover diviso totale, in percentuale |
| Durata media | /sessions | Media di durationSeconds |
| CSAT medio | /sessions | Media di rating.score sulle sessioni valutate |
| Prenotazioni confermate | /bookings | Filtro su status = confirmed |
| Prenotazioni annullate | /bookings | Filtro su status = cancelled |
| Mancate presenze | /bookings | Filtro su status = no_show |
| Nuovi clienti | /clients | Filtro su createdAt nel periodo |
Implementazione
Estrai i dati con paginazione
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
ROME = ZoneInfo("Europe/Rome")
def intervallo_ieri() -> tuple[str, str]:
oggi = datetime.now(ROME).replace(hour=0, minute=0, second=0, microsecond=0)
ieri = oggi - timedelta(days=1)
return (
ieri.astimezone(tz=None).isoformat(),
oggi.astimezone(tz=None).isoformat(),
)
def estrai_tutto(path: str, params: dict) -> list[dict]:
risultati: list[dict] = []
cursore = None
while True:
query = dict(params)
query["limit"] = 100
if cursore:
query["cursor"] = cursore
risposta = client._request("GET", path, params=query)
risultati.extend(risposta.get("data", []))
cursore = risposta.get("pagination", {}).get("nextCursor")
if not cursore:
break
return risultatiCalcola le metriche
def calcola_metriche(sessioni: list[dict], prenotazioni: list[dict]) -> dict:
totale = len(sessioni)
con_handover = sum(1 for s in sessioni if s.get("handoverRequested"))
valutate = [s["rating"]["score"] for s in sessioni if s.get("rating")]
durate = [s["durationSeconds"] for s in sessioni if s.get("durationSeconds")]
return {
"conversazioni_totali": totale,
"conversazioni_con_handover": con_handover,
"tasso_handover_pct": round(con_handover / totale * 100, 1) if totale else 0.0,
"durata_media_minuti": round(sum(durate) / len(durate) / 60, 1) if durate else 0.0,
"csat_medio": round(sum(valutate) / len(valutate), 2) if valutate else None,
"prenotazioni_confermate": sum(
1 for p in prenotazioni if p["status"] == "confirmed"
),
"prenotazioni_annullate": sum(
1 for p in prenotazioni if p["status"] == "cancelled"
),
"mancate_presenze": sum(1 for p in prenotazioni if p["status"] == "no_show"),
}Genera il file XLSX
from openpyxl import Workbook
def genera_xlsx(metriche: dict, sessioni: list[dict], percorso: str) -> None:
workbook = Workbook()
riepilogo = workbook.active
riepilogo.title = "Riepilogo"
riepilogo.append(["Metrica", "Valore"])
for chiave, valore in metriche.items():
riepilogo.append([chiave.replace("_", " ").capitalize(), valore])
dettaglio = workbook.create_sheet("Conversazioni")
dettaglio.append(
["ID sessione", "Canale", "Apertura", "Durata (min)", "Esito", "CSAT", "Etichette"]
)
for sessione in sessioni:
dettaglio.append(
[
sessione["id"],
sessione.get("channel", ""),
sessione.get("startedAt", ""),
round(sessione.get("durationSeconds", 0) / 60, 1),
sessione.get("outcome", ""),
(sessione.get("rating") or {}).get("score", ""),
", ".join(sessione.get("tags", [])),
]
)
workbook.save(percorso)Carica su SharePoint
import requests
def carica_su_sharepoint(percorso_locale: str, nome_file: str, graph_token: str) -> str:
site_id = "acme.sharepoint.com,11111111-2222-3333-4444-555555555555,66666666-7777-8888-9999-000000000000"
drive_path = f"/sites/{site_id}/drive/root:/Report Blu Pantheon/{nome_file}:/content"
with open(percorso_locale, "rb") as file:
risposta = requests.put(
f"https://graph.microsoft.com/v1.0{drive_path}",
headers={
"Authorization": f"Bearer {graph_token}",
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
},
data=file,
timeout=120,
)
risposta.raise_for_status()
return risposta.json()["webUrl"]Pianifica l'esecuzione
# Report giornaliero alle 07:00 ora italiana
0 7 * * * TZ=Europe/Rome /opt/automazioni/venv/bin/python -m src.report_giornaliero >> /var/log/bp-report.log 2>&1In alternativa, con Robocorp Control Room crea uno Scheduled process con espressione cron
0 7 * * * e fuso Europe/Rome.
Errori e ritentativi
| Problema | Comportamento |
|---|---|
API 429 durante la paginazione | Il client ritenta con Retry-After. Riduci limit a 50. |
| Nessun dato nel periodo | Genera comunque il report con valori a zero e segnalalo nell'oggetto |
| Token Graph scaduto | Rinnova con client credentials prima dell'upload |
| SharePoint non raggiungibile | Salva localmente, invia solo email, ritenta l'upload al ciclo successivo |
| Esecuzione fallita completamente | Allarme al canale on-call: un report mancante non deve passare inosservato |
Idempotenza del report
Usa il nome file report-YYYY-MM-DD.xlsx. Una seconda esecuzione dello stesso giorno
sovrascrive il file invece di crearne un duplicato.