Blu Pantheon
Automation & RPA

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

EventoAzione nel gestionale
booking.confirmedCrea ordine di servizio
booking.rescheduledAggiorna data e orario dell'ordine
booking.cancelledAnnulla l'ordine e libera la risorsa
booking.no_showChiudi 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 PantheonCampo gestionaleTrasformazione
data.referenceRIFESTTroncamento a 20 caratteri
data.client.idCODCLILookup su tabella di corrispondenza, fallback creazione
data.client.displayNameRAGSOCMaiuscolo, troncamento a 40 caratteri
data.service.idCODSERTabella di mappatura servizi
data.startAtDTAPPU + ORAPPUDa ISO 8601 con offset a AAAAMMGG e HHMM in ora locale
data.service.durationMinutesDURATANessuna
data.resource.nameSQUADRATabella di mappatura risorse
data.location.addressINDIRZTroncamento a 60 caratteri
data.price.amountIMPORTDa centesimi a euro, divisione per 100
data.customFields.matricola_contatoreMATRICNessuna

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

ConsoleConfigurazioneWebhookNuovo webhook

Registra https://automation.acme.it/webhooks/blupantheon con i quattro eventi elencati sopra.

Normalizza il payload

normalizza_prenotazione.py
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

ProblemaComportamento
Emulatore 5250 bloccato su una schermata inattesaCattura screenshot, chiudi la sessione, marca Failed come errore tecnico, ritenta
Codice servizio non mappatoUsa SRV999, apri un ticket interno, non bloccare il flusso
Cliente non trovato e creazione fallitaErrore di business: non ritentare, notifica l'amministrazione
Ordine già esistenteAggiorna invece di creare, esito positivo
API Blu Pantheon 429Attendi 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

EventoAzione
session.closedCrea o aggiorna il contatto e registra l'attività
client.createdCrea il lead se ancora assente
client.updatedAllinea i dati anagrafici
client.consent.updatedAggiorna 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 riepilogo

Mappatura dei campi

Campo Blu PantheonCampo CRMNote
data.clientIdbp_client_id (campo custom)Chiave di correlazione, indicizzata
client.emailemailChiave di ricerca primaria
client.vatNumbervat_numberChiave di ricerca per aziende
client.displayNamecompany_name o full_nameIn base a client.type
client.phonephoneNormalizza in formato E.164
client.address.citycityNessuna
client.consents.marketingopt_in_marketingBooleano
data.summarycorpo dell'attivitàTesto del riepilogo
data.tagstagsConcatenazione
data.rating.scorelast_csatIntero da 1 a 5
data.sessionIdbp_session_idPer 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

ProblemaComportamento
Contatto duplicato nel CRMMerge manuale, log dell'anomalia. Non bloccare il flusso.
Email assente e partita IVA assenteCrea comunque con bp_client_id come sola chiave
CRM non raggiungibileRitenta 5 volte con backoff, poi dead letter locale
Consenso marketing negatoNon 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

EventoAzione
document.classifiedScarica, deposita nel DMS, protocolla
document.extraction.completedAggiorna i metadati del documento già depositato
document.rejectedNotifica 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.protocollo

Mappatura dei campi

Campo Blu PantheonCampo protocolloNote
data.fileNamenome_fileSanifica i caratteri non ammessi
data.classification.categorytipologia_documentoTabella di mappatura
data.extracted.numero_documentonumero_documentoNessuna
data.extracted.data_documentodata_documentoDa AAAA-MM-GG a GG/MM/AAAA
data.extracted.fornitoremittente_denominazioneMaiuscolo
data.extracted.partita_ivamittente_pivaRimuovi il prefisso IT se richiesto
data.extracted.totaleimporto_totaleDue decimali
event.idid_esternoChiave 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, digest

URL 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

ProblemaComportamento
Download 401La chiave manca di identity:documents:read. Non ritentare.
Download 410Il documento è stato cancellato. Chiudi con esito ignorato.
Hash già presente nel DMSRiusa il protocollo esistente, esito positivo
Protocollo informatico non disponibileRitenta con backoff fino a 6 ore, poi allarme
Confidenza sotto sogliaInstrada a revisione manuale, non protocollare
File oltre il limite del DMSErrore 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

EventoAzione
session.handover.requestedCrea il ticket
session.handover.assignedAssegna il ticket all'operatore
session.handover.timeoutAumenta la priorità ed escala al secondo livello
session.closedChiudi il ticket con il riepilogo

Mappatura dei campi

Campo Blu PantheonJiraServiceNowZendesk
data.sessionIdcustomfield_10050u_bp_session_idcustom_field_360001
data.summarydescriptiondescriptioncomment.body
data.prioritypriority.nameurgencypriority
data.queuecomponentsassignment_groupgroup_id
data.client.displayNamecustomfield_10051caller_idrequester.name
data.client.emailreporter (via lookup)u_emailrequester.email
event.idcustomfield_10052correlation_idexternal_id
data.traceIdcustomfield_10053correlation_displaycustom_field_360002

Mappatura delle priorità:

Blu PantheonJiraServiceNow (urgency)Zendesk
criticaHighest1 - Highurgent
altaHigh2 - Mediumhigh
mediaMedium2 - Mediumnormal
bassaLow3 - Lowlow

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

ProblemaComportamento
Campo custom inesistenteErrore di configurazione: correggi la mappatura, non ritentare
Utente richiedente non trovatoCrea il ticket con richiedente generico e annota l'email
Transizione di stato non consentitaRegistra l'anomalia, lascia il ticket aperto
Ticketing non raggiungibileRitenta 5 volte con backoff, poi dead letter
Ticket già esistenteRiusa 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 team

Metriche da includere

MetricaOrigineCalcolo
Conversazioni totali/sessionsConteggio
Conversazioni con handover/sessionsFiltro su handoverRequested
Tasso di handoverderivatohandover diviso totale, in percentuale
Durata media/sessionsMedia di durationSeconds
CSAT medio/sessionsMedia di rating.score sulle sessioni valutate
Prenotazioni confermate/bookingsFiltro su status = confirmed
Prenotazioni annullate/bookingsFiltro su status = cancelled
Mancate presenze/bookingsFiltro su status = no_show
Nuovi clienti/clientsFiltro su createdAt nel periodo

Implementazione

Estrai i dati con paginazione

report_giornaliero.py
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 risultati

Calcola 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

crontab
# 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>&1

In alternativa, con Robocorp Control Room crea uno Scheduled process con espressione cron 0 7 * * * e fuso Europe/Rome.

Errori e ritentativi

ProblemaComportamento
API 429 durante la paginazioneIl client ritenta con Retry-After. Riduci limit a 50.
Nessun dato nel periodoGenera comunque il report con valori a zero e segnalalo nell'oggetto
Token Graph scadutoRinnova con client credentials prima dell'upload
SharePoint non raggiungibileSalva localmente, invia solo email, ritenta l'upload al ciclo successivo
Esecuzione fallita completamenteAllarme 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.

Passi successivi

On this page