Salta al contenuto principale

Supply Chain Alimentare: ETL Pattern da Farm a Retailer

Ogni anno, circa un terzo di tutto il cibo prodotto per il consumo umano viene perso o sprecato lungo la filiera alimentare…

Cosa ne pensi di questo articolo?

Supply Chain Alimentare: ETL Pattern da Farm a Retailer
FoodTechFoto:Kampus ProductionPexels

In Breve

Ogni anno, circa un terzo di tutto il cibo prodotto per il consumo umano viene perso o sprecato lungo la filiera alimentare…

Supply Chain Alimentare: ETL Pattern da Farm a Retailer

Ogni anno, circa un terzo di tutto il cibo prodotto per il consumo umano viene perso o sprecato lungo la filiera alimentare: un valore che supera i 473 miliardi di dollari solo negli USA e che si traduce in un miliardo di pasti buttati ogni giorno a livello globale. Paradossalmente, una quota significativa di questo spreco non dipende da fattori climatici o biologici, ma da un problema di dati: visibilità insufficiente, sistemi legacy non integrati, KPI calcolati in ritardo e decisioni prese su informazioni incomplete o errate.

Il mercato globale del food supply chain management vale 182,81 miliardi di dollari nel 2025 e crescera a 359,39 miliardi entro il 2034 (CAGR 7,8%). Eppure il 48% dei fornitori alimentari usa ancora fogli di calcolo per le operazioni quotidiane, e il 60% segnala attivita manuali ripetitive che consumano tempo prezioso. Il gap tra quello che la tecnologia permette e quello che le aziende agroalimentari effettivamente implementano e enorme.

Questo articolo - l'ultimo della serie FoodTech - affronta il problema alla radice: come costruire una pipeline ETL/ELT robusta e scalabile per integrare dati eterogenei dalla farm al retailer, orchestrare i flussi con Apache Airflow, trasformare i dati con dbt, garantire la qualità con Great Expectations, e misurare le performance con KPI supply chain specifici per il settore alimentare.

Cosa Imparerai

  • Architettura dati end-to-end della supply chain alimentare (farm → processing → distribution → retail)
  • Sorgenti dati eterogenee: ERP (SAP/Oracle), MES, LIMS, WMS, TMS, POS, IoT, EDI
  • ETL vs ELT: quando scegliere quale approccio per i dati alimentari
  • DAG Apache Airflow completi per pipeline alimentari con error handling e retry
  • Modelli dbt per KPI supply chain: waste rate, OTIF, fill rate, days of supply
  • Data quality con Great Expectations per cold chain e date di scadenza
  • Integrazione sistemi legacy: SAP RFC/BAPI, EDI EDIFACT, CDC con Debezium
  • Case study: cooperativa agricola 500 farm, 3 centri di lavorazione, 200 punti vendita
  • Riepilogo completo dell'intera serie FoodTech (10 articoli)

La Serie FoodTech: Dove Siamo

Questa e la decima e ultima puntata della serie FoodTech, che ha esplorato le principali tecnologie digitali applicabili all'intera filiera alimentare: dalla raccolta dati in campo fino alla vendita al dettaglio. Ecco il quadro completo:

# Titolo Tecnologie Livello
00 Pipeline IoT per Agricoltura di Precisione con Python e MQTT IoT, MQTT, Python, Data Lake Advanced
01 ML Edge per Rilevamento Malattie Colture: TensorFlow Lite su Raspberry Pi TensorFlow Lite, Edge ML, Raspberry Pi Advanced
02 API Satellitari e Meteorologiche per AgriTech: Dati Predittivi Sentinel, Planet, Weather API, NDVI Advanced
03 Sistema di Tracciabilita Alimentare: Blockchain, RFID e IoT Blockchain, RFID, IoT, Compliance Advanced
04 Computer Vision per Controllo qualità Alimentare con PyTorch YOLO YOLO, PyTorch, Computer Vision Advanced
05 Automazione FSMA 204: Tracking, Alert e Recall via Python FSMA, Compliance, Python, Recall Advanced
06 Automazione Vertical Farming: Controllo Robotico via API Robotics, API, Automation Advanced
07 Demand Forecasting per Riduzione Spreco: ML Time-Series LSTM, Prophet, Time-Series ML Advanced
08 Dashboard Real-Time per Farm IoT con Angular e Grafana Angular, Grafana, InfluxDB, Real-Time Advanced
09 Supply Chain Alimentare: ETL Pattern da Farm a Retailer <-- Sei qui Airflow, dbt, Great Expectations, ETL Advanced

perchè questo articolo chiude la serie

Gli articoli precedenti hanno affrontato singoli nodi della catena: raccolta dati IoT in campo, modelli ML edge, tracciabilita blockchain, computer vision per la qualità, compliance FSMA, vertical farming, demand forecasting e dashboard real-time. Questo articolo li connette tutti: l'ETL pipeline e la spina dorsale che integra ogni fonte dati, trasforma le informazioni grezze in KPI azionabili e porta visibilità end-to-end dall'azienda agricola allo scaffale del supermercato.

Architettura Dati della Supply Chain Alimentare

La filiera alimentare e composta da nodi distinti, ciascuno con i propri sistemi informativi, formati dati e frequenze di aggiornamento. Una pipeline ETL deve gestire questa eterogeneita in modo robusto.

# Architettura logica della supply chain alimentare
# (dati generati ad ogni step)

FARM SYSTEMS
    |-- Sensori IoT (MQTT): temp suolo, umidita, pH, EC
    |-- Sistema ERP agricolo: colture pianificate, costi input
    |-- Registro trattamenti fitosanitari (LIMS)
    |-- Geo-dati GPS macchinari agricoli
    |-- API meteo (OpenMeteo, Copernicus)
    |
    v
PROCESSING PLANTS (Centri di Lavorazione)
    |-- MES (Manufacturing Execution System): ordini produzione, rese
    |-- LIMS: analisi chimiche, microbiologiche, allergeni
    |-- ERP SAP/Oracle: distinta base, lotti, tracciabilita
    |-- WMS (Warehouse Management): magazzino materie prime/finiti
    |-- Sensori cold chain: temperatura/umidita durante lavorazione
    |
    v
DISTRIBUTION CENTERS
    |-- WMS: gestione stock, picking, slotting
    |-- TMS (Transport Management): spedizioni, veicoli, routing
    |-- Sensori cold chain: logger temperatura nei camion frigoriferi
    |-- EDI: ordini da retailer (EDIFACT ORDERS, DESADV)
    |-- Tracker GPS: posizione veicoli in tempo reale
    |
    v
RETAIL POS
    |-- POS data: vendite per SKU, store, ora
    |-- Gestione shelf life: prodotti prossimi scadenza
    |-- EDI: conferme ricezione (RECADV), fatture (INVOIC)
    |-- Inventario store: stock disponibile per punto vendita
    |
    v
CONSUMER (segnali indiretti)
    |-- App loyalty: acquisti per cliente, frequenza
    |-- Reviews prodotto: qualità percepita
    |-- Social sentiment: trend di consumo

Ogni nodo genera dati in formati diversi (SQL relazionale, file flat CSV/EDI, JSON da API REST, messaggi MQTT binari, file SFTP legacy) con frequenze che variano da millisecondi (sensori IoT) a settimanalmente (report ERP). La sfida del data engineer alimentare e unificare tutto questo in un unico modello analitico coerente.

Sorgenti Dati Eterogenee: Tabella di Riferimento

Prima di progettare qualsiasi pipeline, e fondamentale censire le sorgenti. Ecco le principali per una supply chain alimentare di media complessità:

Sistema Tipo Formato Volume/giorno Frequenza Latenza accettabile
SAP ERP (farm/plant) ERP RFC/BAPI, IDoc, SQL 50K–500K record Batch notturno / CDC T+1h
Oracle EBS ERP SQL, REST API 100K–1M record Batch / CDC T+1h
MES (produzione) MES OPC-UA, SQL, REST 1M–10M eventi Quasi real-time (1 min) 5 min
LIMS (laboratorio) LIMS HL7, CSV, REST 1K–10K analisi Giornaliero T+4h
WMS (magazzino) WMS SQL, EDI, REST 10K–100K movimenti Ogni 15 min 15 min
TMS (trasporti) TMS REST API, EDI 5K–50K spedizioni Ogni 5 min (GPS) 5 min
POS retail POS CSV, REST, SQL 1M–50M transazioni Ogni ora / batch serale T+2h
IoT sensori cold chain IoT MQTT, JSON, Protobuf 100M+ letture Ogni 30 sec–5 min Real-time (<1 min)
EDI partner (EDIFACT) EDI EDIFACT, X12, GS1 XML 100–10K messaggi Event-driven / batch T+30 min
API meteo (OpenMeteo) API esterna JSON REST 1K–10K chiamate Oraria T+1h
AGEA (sussidi PAC) Ente pubblico XML, CSV SFTP 1–100 file Mensile / stagionale T+24h
GPS tracker veicoli IoT/Telematics REST, WebSocket 10M+ punti GPS Ogni 30 sec <2 min

Attenzione: ERP Legacy e "Finestre di Estrazione"

Molti ERP agricoli e agroalimentari non supportano CDC nativo. Spesso l'unico modo per estrarre dati e tramite job batch notturni che lockano tabelle, o tramite interfacce RFC/BAPI di SAP con finestre di manutenzione rigide. Pianifica le finestre di estrazione in anticipo e prevedi meccanismi di recupero in caso di fallimento del batch notturno.

ETL vs ELT per la Supply Chain Alimentare

Il dibattito ETL vs ELT e particolarmente rilevante nel settore alimentare, dove coesistono dati di alta frequenza (IoT cold chain) con dati batch tradizionali (ERP SAP) e requisiti normativi stringenti (tracciabilita, FSMA 204).

Criterio ETL Tradizionale ELT Moderno (dbt + Data Lake)
Trasformazione Prima del caricamento (staging server) Nel data warehouse (SQL nativo)
Scalabilità Limitata dal server ETL Scala con il DWH (Snowflake, BigQuery, DuckDB)
Raw data retention Spesso no (dati già trasformati) Si: layer Bronze conserva i raw
Auditability (FSMA) Difficile: lineage offuscata Eccellente: dbt lineage graph completo
Latenza Batch (T+1h, T+1g) Micro-batch o streaming (Flink/Spark)
Errori cold chain Perdita dati se ETL fallisce Raw salvato sempre; retry solo trasformazione
Team skills richiesti Java/Informatica/SSIS developer SQL + Python (dbt + Airflow)
Costo infrastruttura Server dedicato always-on Pay-per-query (Snowflake/BigQuery)

Per la supply chain alimentare moderna, la raccomandazione e un'architettura Medallion ELT:

# Architettura Medallion per Food Supply Chain

BRONZE LAYER (Raw, immutabile)
    |-- Tutti i dati sorgente caricati as-is
    |-- Partitioned by: source_system, ingestion_date
    |-- Retention: 7 anni (compliance FSMA/EU)
    |-- Formato: Parquet su S3/GCS o Delta Lake
    |
    v
SILVER LAYER (Cleansed, deduplicato)
    |-- Dbt models: normalizzazione schemi eterogenei
    |-- Deduplicazione lotti, SKU, location codes
    |-- Type casting: date, temperature (C/F), pesi (kg/lb)
    |-- NULL handling: sensori offline, ERP null fields
    |-- PII masking: dati operatori, conducenti
    |
    v
GOLD LAYER (KPI pronti per business)
    |-- Aggregazioni: fill rate, waste rate, OTIF per SKU
    |-- Metriche cold chain: violazioni temperatura per lotto
    |-- Dashboard BI: Power BI, Tableau, Grafana
    |-- API ML: feature store per demand forecasting
    |
    v
DATA MART
    |-- Retail mart: sell-through, shelf life at receipt
    |-- Operations mart: OEE impianti, rendimenti produzione
    |-- Finance mart: costo per lotto, margine per SKU
    |-- Compliance mart: tracciabilita FSMA, recall readiness

Apache Airflow per Orchestrazione Pipeline Alimentare

Apache Airflow e lo standard de facto per orchestrare workflow ETL complessi. Nella supply chain alimentare, un singolo DAG deve coordinare decine di sorgenti con latenze, dipendenze e politiche di retry diverse. Vediamo un DAG completo per la pipeline giornaliera.

DAG Principale: Food Supply Chain Daily ETL

# food_supply_chain_dag.py
from __future__ import annotations

import logging
from datetime import datetime, timedelta
from typing import Any

from airflow import DAG
from airflow.decorators import task
from airflow.operators.python import PythonOperator
from airflow.operators.bash import BashOperator
from airflow.providers.postgres.hooks.postgres import PostgresHook
from airflow.providers.amazon.aws.hooks.s3 import S3Hook
from airflow.providers.http.sensors.http import HttpSensor
from airflow.utils.trigger_rule import TriggerRule

logger = logging.getLogger(__name__)

DEFAULT_ARGS = {
    "owner": "data-engineering",
    "depends_on_past": False,
    "email_on_failure": True,
    "email_on_retry": False,
    "retries": 3,
    "retry_delay": timedelta(minutes=5),
    "retry_exponential_backoff": True,
    "max_retry_delay": timedelta(minutes=30),
}

with DAG(
    dag_id="food_supply_chain_daily_etl",
    description="Pipeline ETL giornaliera: farm -> processing -> distribution -> retail",
    default_args=DEFAULT_ARGS,
    schedule_interval="0 2 * * *",  # ogni notte alle 02:00 UTC
    start_date=datetime(2025, 1, 1),
    catchup=False,
    tags=["foodtech", "supply-chain", "etl", "production"],
    max_active_runs=1,
    doc_md="""
    ## Food Supply Chain Daily ETL
    Estrae dati da: SAP ERP, MES impianti, WMS, TMS, POS retail.
    Carica su Bronze S3, poi lancia dbt per Silver e Gold.
    """,
) as dag:

    # ----------------------------------------------------------------
    # STEP 1: Health checks sulle sorgenti
    # ----------------------------------------------------------------
    check_sap_available = HttpSensor(
        task_id="check_sap_api_available",
        http_conn_id="sap_erp_api",
        endpoint="/api/health",
        timeout=30,
        poke_interval=60,
        mode="reschedule",
        soft_fail=False,
    )

    check_mes_available = HttpSensor(
        task_id="check_mes_api_available",
        http_conn_id="mes_plant_api",
        endpoint="/health",
        timeout=30,
        poke_interval=60,
        mode="reschedule",
    )

    # ----------------------------------------------------------------
    # STEP 2: Estrazione SAP ERP (lotti, ordini, inventario)
    # ----------------------------------------------------------------
    @task(task_id="extract_sap_erp", retries=3)
    def extract_sap_erp(**context: Any) -> dict:
        """Estrae dati da SAP ERP via RFC/BAPI wrapper REST."""
        from src.connectors.sap_connector import SAPConnector

        execution_date = context["ds"]  # 'YYYY-MM-DD'
        sap = SAPConnector(conn_id="sap_erp_api")

        # Estrai: lotti produzione del giorno precedente
        batches = sap.get_production_batches(
            date=execution_date,
            plants=["PL001", "PL002", "PL003"],
        )

        # Estrai: movimenti magazzino
        stock_movements = sap.get_stock_movements(
            date=execution_date,
            movement_types=["101", "261", "501", "601"],
        )

        # Estrai: ordini di vendita confermati
        sales_orders = sap.get_sales_orders(
            date=execution_date,
            status=["CONF", "DLVR"],
        )

        # Salva su S3 Bronze layer
        s3 = S3Hook(aws_conn_id="aws_s3")
        prefix = f"bronze/sap_erp/dt={execution_date}"

        s3.load_string(
            string_data=batches.to_json(orient="records", lines=True),
            key=f"{prefix}/batches.jsonl",
            bucket_name="food-data-lake",
            replace=True,
        )
        s3.load_string(
            string_data=stock_movements.to_json(orient="records", lines=True),
            key=f"{prefix}/stock_movements.jsonl",
            bucket_name="food-data-lake",
            replace=True,
        )

        logger.info(
            "SAP extraction OK: %d batches, %d movements, %d orders",
            len(batches),
            len(stock_movements),
            len(sales_orders),
        )
        return {
            "batches_count": len(batches),
            "movements_count": len(stock_movements),
            "orders_count": len(sales_orders),
        }

    # ----------------------------------------------------------------
    # STEP 3: Estrazione dati cold chain (IoT sensori temperatura)
    # ----------------------------------------------------------------
    @task(task_id="extract_cold_chain_iot")
    def extract_cold_chain_iot(**context: Any) -> dict:
        """Estrae letture temperatura da InfluxDB (IoT cold chain)."""
        from influxdb_client import InfluxDBClient

        execution_date = context["ds"]

        client = InfluxDBClient(
            url="{{ var.value.influxdb_url }}",
            token="{{ var.value.influxdb_token }}",
            org="foodtech-org",
        )
        query_api = client.query_api()

        # Query Flux: temperature readings per lotto di trasporto
        flux_query = f"""
        from(bucket: "cold-chain")
            |> range(start: {execution_date}T00:00:00Z,
                       stop: {execution_date}T23:59:59Z)
            |> filter(fn: (r) => r._measurement == "temperature")
            |> filter(fn: (r) => r._field == "celsius")
            |> aggregateWindow(every: 5m, fn: mean, createEmpty: false)
            |> pivot(rowKey: ["_time", "lot_id"], columnKey: ["_field"], valueColumn: "_value")
        """

        result = query_api.query_data_frame(flux_query)

        # Calcola violazioni: temperatura fuori range per categoria prodotto
        violations = result[
            (result["celsius"] < result["min_temp_required"]) |
            (result["celsius"] > result["max_temp_required"])
        ]

        s3 = S3Hook(aws_conn_id="aws_s3")
        prefix = f"bronze/cold_chain/dt={execution_date}"

        s3.load_string(
            string_data=result.to_json(orient="records", lines=True),
            key=f"{prefix}/temperature_readings.jsonl",
            bucket_name="food-data-lake",
            replace=True,
        )

        logger.info(
            "Cold chain extraction OK: %d readings, %d violations",
            len(result),
            len(violations),
        )

        if len(violations) > 0:
            logger.warning(
                "COLD CHAIN VIOLATIONS: %d eventi fuori temperatura!",
                len(violations),
            )

        return {
            "readings_count": len(result),
            "violations_count": len(violations),
        }

    # ----------------------------------------------------------------
    # STEP 4: Estrazione EDI dai partner retailer
    # ----------------------------------------------------------------
    @task(task_id="extract_edi_partners")
    def extract_edi_partners(**context: Any) -> dict:
        """Processa file EDI EDIFACT da SFTP partners."""
        from src.connectors.edi_connector import EDIFACTConnector

        execution_date = context["ds"]
        edi = EDIFACTConnector(sftp_conn_id="edi_sftp_partners")

        # Download e parsing ORDERS (EDIFACT ORDERS D.01B)
        orders = edi.process_orders(date=execution_date)

        # Download e parsing DESADV (Despatch Advice)
        despatch_advices = edi.process_desadv(date=execution_date)

        # Download e parsing RECADV (Receiving Advice)
        receiving_advices = edi.process_recadv(date=execution_date)

        s3 = S3Hook(aws_conn_id="aws_s3")
        prefix = f"bronze/edi_partners/dt={execution_date}"

        s3.load_string(
            string_data=orders.to_json(orient="records", lines=True),
            key=f"{prefix}/orders.jsonl",
            bucket_name="food-data-lake",
            replace=True,
        )

        return {
            "orders_count": len(orders),
            "desadv_count": len(despatch_advices),
            "recadv_count": len(receiving_advices),
        }

    # ----------------------------------------------------------------
    # STEP 5: Estrazione POS retail
    # ----------------------------------------------------------------
    @task(task_id="extract_pos_data")
    def extract_pos_data(**context: Any) -> dict:
        """Estrae dati vendite POS da database retailer partner."""
        from src.connectors.retail_connector import RetailPOSConnector

        execution_date = context["ds"]
        pos = RetailPOSConnector(conn_id="retail_pos_db")

        # Vendite per SKU/store con shelf life info
        sales = pos.get_daily_sales(
            date=execution_date,
            include_promotions=True,
            include_markdowns=True,
        )

        # Near-expiry: prodotti a <3 giorni scadenza ancora in shelf
        near_expiry = pos.get_near_expiry_stock(
            date=execution_date,
            days_threshold=3,
        )

        s3 = S3Hook(aws_conn_id="aws_s3")
        prefix = f"bronze/pos_retail/dt={execution_date}"

        s3.load_string(
            string_data=sales.to_json(orient="records", lines=True),
            key=f"{prefix}/sales.jsonl",
            bucket_name="food-data-lake",
            replace=True,
        )

        logger.info("POS extraction OK: %d transazioni", len(sales))
        return {
            "sales_count": len(sales),
            "near_expiry_skus": len(near_expiry),
        }

    # ----------------------------------------------------------------
    # STEP 6: dbt run (Silver + Gold transformations)
    # ----------------------------------------------------------------
    dbt_run_silver = BashOperator(
        task_id="dbt_run_silver_layer",
        bash_command=(
            "cd /opt/dbt/food_supply_chain && "
            "dbt run --select tag:silver --target prod "
            "--vars '{\"execution_date\": \"{{ ds }}\", \"source_env\": \"prod\"}'"
        ),
        retries=2,
    )

    dbt_run_gold = BashOperator(
        task_id="dbt_run_gold_layer",
        bash_command=(
            "cd /opt/dbt/food_supply_chain && "
            "dbt run --select tag:gold --target prod "
            "--vars '{\"execution_date\": \"{{ ds }}\", \"source_env\": \"prod\"}'"
        ),
        retries=2,
    )

    # ----------------------------------------------------------------
    # STEP 7: dbt test (data quality checks)
    # ----------------------------------------------------------------
    dbt_test = BashOperator(
        task_id="dbt_test_all",
        bash_command=(
            "cd /opt/dbt/food_supply_chain && "
            "dbt test --select tag:silver tag:gold --target prod"
        ),
        trigger_rule=TriggerRule.ALL_SUCCESS,
    )

    # ----------------------------------------------------------------
    # STEP 8: Great Expectations validation
    # ----------------------------------------------------------------
    @task(task_id="run_great_expectations_validation")
    def run_ge_validation(**context: Any) -> dict:
        """Lancia Great Expectations per validare data quality cold chain."""
        from great_expectations.data_context import get_context

        context_ge = get_context(project_root_dir="/opt/great_expectations")
        execution_date = context["ds"]

        results = {}
        suites_to_run = [
            ("cold_chain_temperature", "bronze_cold_chain_checkpoint"),
            ("lot_traceability", "silver_lots_checkpoint"),
            ("kpi_supply_chain", "gold_kpi_checkpoint"),
        ]

        for suite_name, checkpoint_name in suites_to_run:
            result = context_ge.run_checkpoint(
                checkpoint_name=checkpoint_name,
                batch_request={
                    "runtime_parameters": {"path": f"s3://food-data-lake/bronze/cold_chain/dt={execution_date}"},
                    "batch_identifiers": {"execution_date": execution_date},
                },
            )
            results[suite_name] = result.success

        failed = [name for name, ok in results.items() if not ok]
        if failed:
            raise ValueError(f"GE validation FAILED per suite: {failed}")

        logger.info("Great Expectations: tutte le suite OK")
        return results

    # ----------------------------------------------------------------
    # STEP 9: KPI alert (email se waste rate > soglia)
    # ----------------------------------------------------------------
    @task(task_id="check_kpi_alerts", trigger_rule=TriggerRule.ALL_SUCCESS)
    def check_kpi_alerts(**context: Any) -> None:
        """Controlla KPI critici e invia alert se fuori soglia."""
        from src.services.kpi_alert_service import KPIAlertService

        execution_date = context["ds"]
        alert_service = KPIAlertService(conn_id="snowflake_dw")

        kpis = alert_service.get_daily_kpis(date=execution_date)

        alerts_sent = []

        # Waste rate > 5%: alert critico
        if kpis["waste_rate_pct"] > 5.0:
            alert_service.send_alert(
                severity="HIGH",
                message=f"Waste rate {kpis['waste_rate_pct']:.1f}% supera soglia 5%",
                kpi="waste_rate",
                value=kpis["waste_rate_pct"],
            )
            alerts_sent.append("waste_rate")

        # OTIF < 95%: alert warning
        if kpis["otif_pct"] < 95.0:
            alert_service.send_alert(
                severity="MEDIUM",
                message=f"OTIF {kpis['otif_pct']:.1f}% sotto soglia 95%",
                kpi="otif",
                value=kpis["otif_pct"],
            )
            alerts_sent.append("otif")

        # Cold chain violations > 0: alert immediato
        if kpis["cold_chain_violations"] > 0:
            alert_service.send_alert(
                severity="CRITICAL",
                message=f"{kpis['cold_chain_violations']} violazioni temperatura cold chain!",
                kpi="cold_chain_violations",
                value=kpis["cold_chain_violations"],
            )
            alerts_sent.append("cold_chain")

        logger.info("KPI check completato. Alert inviati: %s", alerts_sent)

    # ----------------------------------------------------------------
    # DAG wiring: dipendenze tra task
    # ----------------------------------------------------------------
    extract_sap = extract_sap_erp()
    extract_cold = extract_cold_chain_iot()
    extract_edi = extract_edi_partners()
    extract_pos = extract_pos_data()
    ge_validation = run_ge_validation()
    kpi_alerts = check_kpi_alerts()

    # Health checks prima di tutto
    [check_sap_available, check_mes_available] >> extract_sap

    # Estrazione parallela di tutte le sorgenti
    [extract_sap, extract_cold, extract_edi, extract_pos] >> dbt_run_silver

    # Silver prima di Gold
    dbt_run_silver >> dbt_run_gold

    # Test dopo Gold
    dbt_run_gold >> dbt_test

    # GE validation e KPI alerts dopo i test
    dbt_test >> ge_validation >> kpi_alerts

dbt per Trasformazioni: Modelli SQL per KPI Alimentari

dbt (data build tool) e lo strumento ideale per trasformare i dati nel layer Silver e Gold. Vediamo i modelli principali per la supply chain alimentare, con test e documentazione integrata.

Schema.yml: Documentazione e Test dbt

# models/silver/schema.yml
version: 2

models:
  - name: silver_lots
    description: "Lotti di produzione normalizzati da SAP ERP"
    config:
      tags: ["silver", "lots", "traceability"]
      materialized: incremental
      incremental_strategy: merge
      unique_key: lot_id
    columns:
      - name: lot_id
        description: "Identificativo univoco lotto (GS1 SGTIN)"
        tests:
          - not_null
          - unique
      - name: production_date
        tests:
          - not_null
      - name: expiry_date
        tests:
          - not_null
          - dbt_utils.expression_is_true:
              expression: "expiry_date > production_date"
      - name: plant_code
        tests:
          - not_null
          - accepted_values:
              values: ["PL001", "PL002", "PL003", "PL004"]
      - name: temperature_class
        tests:
          - accepted_values:
              values: ["ambient", "chilled", "frozen", "ultra_frozen"]
      - name: net_weight_kg
        tests:
          - dbt_utils.expression_is_true:
              expression: "net_weight_kg > 0 AND net_weight_kg < 50000"

  - name: silver_shipments
    description: "Spedizioni normalizzate da TMS e EDI DESADV"
    config:
      tags: ["silver", "logistics"]
      materialized: incremental
      unique_key: shipment_id
    columns:
      - name: shipment_id
        tests: [not_null, unique]
      - name: planned_delivery_date
        tests: [not_null]
      - name: actual_delivery_date
        description: "NULL se non ancora consegnato"
      - name: otif_flag
        description: "True se consegnato On-Time and In-Full"
        tests:
          - accepted_values:
              values: [true, false, null]
              quote: false

  - name: gold_kpi_supply_chain
    description: "KPI giornalieri supply chain aggregati per SKU/plant/customer"
    config:
      tags: ["gold", "kpi", "dashboard"]
      materialized: table
    columns:
      - name: kpi_date
        tests: [not_null]
      - name: waste_rate_pct
        description: "% prodotto sprecato su prodotto totale"
        tests:
          - dbt_utils.expression_is_true:
              expression: "waste_rate_pct >= 0 AND waste_rate_pct <= 100"
      - name: otif_pct
        tests:
          - dbt_utils.expression_is_true:
              expression: "otif_pct >= 0 AND otif_pct <= 100"

Modello Silver: Lotti di Produzione

-- models/silver/silver_lots.sql
-- {{ config(tags=["silver", "lots"], materialized="incremental") }}

WITH source_sap AS (
    SELECT
        lot_number                                          AS lot_id,
        material_number                                     AS sku_code,
        plant                                               AS plant_code,
        production_date                                     AS production_date,
        -- Normalizza data scadenza (SAP usa formato YYYYMMDD)
        TO_DATE(CAST(expiry_date_sap AS VARCHAR), 'YYYYMMDD') AS expiry_date,
        quantity_kg                                         AS net_weight_kg,
        -- Classi temperatura da tabella materiali SAP
        CASE
            WHEN temp_cond_sap = '0001' THEN 'ambient'
            WHEN temp_cond_sap = '0002' THEN 'chilled'
            WHEN temp_cond_sap = '0003' THEN 'frozen'
            WHEN temp_cond_sap = '0004' THEN 'ultra_frozen'
            ELSE 'unknown'
        END                                                 AS temperature_class,
        -- Range temperatura richiesto (gradi Celsius)
        CASE
            WHEN temp_cond_sap = '0001' THEN STRUCT(15.0 AS min_c, 25.0 AS max_c)
            WHEN temp_cond_sap = '0002' THEN STRUCT(2.0 AS min_c, 8.0 AS max_c)
            WHEN temp_cond_sap = '0003' THEN STRUCT(-25.0 AS min_c, -15.0 AS max_c)
            WHEN temp_cond_sap = '0004' THEN STRUCT(-60.0 AS min_c, -30.0 AS max_c)
        END                                                 AS temp_range,
        batch_status                                        AS lot_status,
        _ingestion_timestamp                                AS ingested_at
    FROM {{ source('bronze', 'sap_erp_batches') }}
    WHERE lot_number IS NOT NULL
        AND quantity_kg > 0

    {% if is_incremental() %}
        AND _ingestion_timestamp > (
            SELECT MAX(ingested_at) FROM {{ this }}
        )
    {% endif %}
),

deduplicated AS (
    SELECT *,
        ROW_NUMBER() OVER (
            PARTITION BY lot_id
            ORDER BY ingested_at DESC
        ) AS row_num
    FROM source_sap
),

final AS (
    SELECT
        lot_id,
        sku_code,
        plant_code,
        production_date,
        expiry_date,
        net_weight_kg,
        temperature_class,
        temp_range,
        lot_status,
        -- Shelf life totale in giorni
        DATEDIFF('day', production_date, expiry_date)   AS shelf_life_days,
        -- Shelf life residua rispetto a oggi
        DATEDIFF('day', CURRENT_DATE, expiry_date)      AS shelf_life_remaining_days,
        ingested_at
    FROM deduplicated
    WHERE row_num = 1
        AND expiry_date > production_date
)

SELECT * FROM final

Modello Gold: KPI Supply Chain

-- models/gold/gold_kpi_supply_chain.sql
-- {{ config(tags=["gold", "kpi"], materialized="table") }}

WITH lots AS (
    SELECT * FROM {{ ref('silver_lots') }}
),

shipments AS (
    SELECT * FROM {{ ref('silver_shipments') }}
),

pos_sales AS (
    SELECT * FROM {{ ref('silver_pos_sales') }}
),

inventory AS (
    SELECT * FROM {{ ref('silver_inventory_snapshot') }}
),

-- KPI 1: Waste Rate (% prodotto sprecato)
waste_kpi AS (
    SELECT
        production_date                                         AS kpi_date,
        plant_code,
        -- Prodotto scaduto o smaltito come spreco
        SUM(CASE WHEN lot_status IN ('WASTE', 'EXPIRED', 'DOWNGRADE')
                 THEN net_weight_kg ELSE 0 END)                 AS wasted_kg,
        SUM(net_weight_kg)                                      AS total_produced_kg,
        ROUND(
            100.0 * SUM(CASE WHEN lot_status IN ('WASTE', 'EXPIRED', 'DOWNGRADE')
                             THEN net_weight_kg ELSE 0 END)
            / NULLIF(SUM(net_weight_kg), 0),
            2
        )                                                       AS waste_rate_pct
    FROM lots
    GROUP BY 1, 2
),

-- KPI 2: OTIF (On-Time In-Full)
otif_kpi AS (
    SELECT
        DATE(planned_delivery_date)                             AS kpi_date,
        customer_code,
        COUNT(*)                                                AS total_orders,
        SUM(CASE WHEN otif_flag = TRUE THEN 1 ELSE 0 END)      AS otif_orders,
        ROUND(
            100.0 * SUM(CASE WHEN otif_flag = TRUE THEN 1 ELSE 0 END)
            / NULLIF(COUNT(*), 0),
            2
        )                                                       AS otif_pct
    FROM shipments
    WHERE actual_delivery_date IS NOT NULL
    GROUP BY 1, 2
),

-- KPI 3: Days of Supply (scorta in giorni)
dos_kpi AS (
    SELECT
        inv.snapshot_date                                       AS kpi_date,
        inv.sku_code,
        inv.location_code,
        inv.stock_qty_units,
        COALESCE(avg_daily_sales.avg_daily_units, 0)           AS avg_daily_demand,
        ROUND(
            inv.stock_qty_units
            / NULLIF(avg_daily_sales.avg_daily_units, 0),
            1
        )                                                       AS days_of_supply
    FROM inventory inv
    LEFT JOIN (
        SELECT sku_code, location_code,
               AVG(units_sold) AS avg_daily_units
        FROM pos_sales
        WHERE sale_date >= DATEADD('day', -30, CURRENT_DATE)
        GROUP BY 1, 2
    ) avg_daily_sales
        ON inv.sku_code = avg_daily_sales.sku_code
        AND inv.location_code = avg_daily_sales.location_code
),

-- KPI 4: Shelf Life at Receipt (qualità al ricevimento)
slr_kpi AS (
    SELECT
        DATE(s.actual_delivery_date)                            AS kpi_date,
        l.sku_code,
        -- % shelf life residua al momento della consegna
        ROUND(
            100.0 * l.shelf_life_remaining_days / NULLIF(l.shelf_life_days, 0),
            1
        )                                                       AS shelf_life_pct_at_receipt,
        -- Media per SKU
        AVG(
            100.0 * l.shelf_life_remaining_days / NULLIF(l.shelf_life_days, 0)
        ) OVER (PARTITION BY DATE(s.actual_delivery_date), l.sku_code)
                                                                AS avg_slr_pct
    FROM shipments s
    INNER JOIN lots l ON s.lot_id = l.lot_id
    WHERE s.actual_delivery_date IS NOT NULL
)

-- Join finale per dashboard KPI unificato
SELECT
    w.kpi_date,
    w.plant_code,
    w.waste_rate_pct,
    w.wasted_kg,
    w.total_produced_kg,
    o.otif_pct,
    o.total_orders,
    d.days_of_supply,
    d.avg_daily_demand,
    s.avg_slr_pct                                               AS shelf_life_at_receipt_pct,
    -- Score complessivo supply chain (0-100)
    ROUND(
        (
            (100.0 - COALESCE(w.waste_rate_pct, 0)) * 0.30 +
            COALESCE(o.otif_pct, 0) * 0.40 +
            LEAST(d.days_of_supply / 14.0 * 100, 100) * 0.15 +
            COALESCE(s.avg_slr_pct, 0) * 0.15
        ),
        1
    )                                                           AS supply_chain_health_score
FROM waste_kpi w
LEFT JOIN otif_kpi o ON w.kpi_date = o.kpi_date
LEFT JOIN dos_kpi d ON w.kpi_date = d.kpi_date
LEFT JOIN slr_kpi s ON w.kpi_date = s.kpi_date

Data Quality per Dati Alimentari: Great Expectations

Nel settore alimentare, la data quality non e solo una questione di pulizia: e un requisito normativo. Dati errati su temperature, date di scadenza o codici lotto possono comportare sanzioni FSMA, recall costosissimi e danni alla reputazione. Great Expectations permette di definire regole di validazione dichiarative e di integrarle nella pipeline Airflow.

# great_expectations/expectations/cold_chain_temperature_suite.py
import great_expectations as gx
from great_expectations.core.batch import BatchRequest

context = gx.get_context()

# Crea o aggiorna la suite di aspettative
suite = context.add_or_update_expectation_suite(
    expectation_suite_name="cold_chain_temperature_suite"
)

validator = context.get_validator(
    batch_request=BatchRequest(
        datasource_name="s3_bronze_datasource",
        data_connector_name="cold_chain_connector",
        data_asset_name="temperature_readings",
    ),
    expectation_suite_name="cold_chain_temperature_suite",
)

# REGOLA 1: Temperatura mai NULL per prodotti refrigerati/surgelati
validator.expect_column_values_to_not_be_null(
    column="celsius",
    mostly=0.99,  # tolleranza 1% per sensori offline temporanei
    meta={
        "notes": "Sensori IoT cold chain: max 1% di letture mancanti",
        "regulatory_reference": "EU Reg 37/2005 cold chain monitoring",
    }
)

# REGOLA 2: Range temperatura prodotti refrigerati (2-8°C)
validator.expect_column_values_to_be_between(
    column="celsius",
    min_value=-30.0,
    max_value=35.0,
    meta={
        "notes": "Range assoluto: include frozen (-25°C) e ambient (25°C)",
    }
)

# REGOLA 3: Lot ID sempre presente e formato GS1 SGTIN valido
validator.expect_column_values_to_match_regex(
    column="lot_id",
    regex=r"^[0-9]{14}$",  # GS1 GTIN-14 format
    meta={"notes": "GS1 SGTIN-14 standard per tracciabilita alimentare"}
)

# REGOLA 4: Timestamp monotonicamente crescente per sensore
validator.expect_column_values_to_be_increasing(
    column="reading_timestamp",
    strictly=False,  # allow duplicates (es. batch upload)
    parse_strings_as_datetimes=True,
    meta={"notes": "Verifico che i timestamp non siano retroattivi"}
)

# REGOLA 5: Numero di letture per lotto (almeno 1 ogni 30 min)
validator.expect_table_row_count_to_be_between(
    min_value=48,     # 24h * 2 letture/ora = 48 minimo
    max_value=10000,  # max ragionevole per 24h
    meta={"notes": "Minimo 1 lettura ogni 30 min per cold chain attiva"}
)

# REGOLA 6: Distribuzione temperatura per classe prodotto
# Prodotti refrigerati: mediana deve essere 2-8°C
validator.expect_column_median_to_be_between(
    column="celsius",
    min_value=1.0,
    max_value=9.0,
    meta={
        "notes": "Solo per lotti con temperature_class='chilled'",
        "applies_to": "filtered_batches_chilled",
    }
)

# Salva le aspettative
validator.save_expectation_suite(discard_failed_expectations=False)

# Checkpoint per Airflow
checkpoint = context.add_or_update_checkpoint(
    name="bronze_cold_chain_checkpoint",
    config_version=1,
    template_name=None,
    module_name="great_expectations.checkpoint",
    class_name="Checkpoint",
    run_name_template="%Y%m%d-%H%M%S-cold-chain",
    expectation_suite_name="cold_chain_temperature_suite",
    action_list=[
        {
            "name": "store_validation_result",
            "action": {"class_name": "StoreValidationResultAction"},
        },
        {
            "name": "send_slack_notification_on_failure",
            "action": {
                "class_name": "SlackNotificationAction",
                "slack_webhook": "{{ var.value.slack_webhook_cold_chain }}",
                "notify_on": "failure",
                "renderer": {
                    "module_name": "great_expectations.render.renderer",
                    "class_name": "SlackRenderer",
                },
            },
        },
        {
            "name": "update_data_docs",
            "action": {"class_name": "UpdateDataDocsAction"},
        },
    ],
)

KPI e Metriche Supply Chain Alimentare

Definire i KPI giusti e il presupposto per prendere decisioni data-driven. Ecco le metriche fondamentali per la supply chain alimentare, con formule, benchmark di settore e target realistici.

KPI Formula Benchmark Settore Target Best-in-Class Alert Threshold
Perfect Order Rate Ordini perfetti / Totale ordini * 100 85-92% >95% <90%
OTIF (On-Time In-Full) (Consegne puntali e complete) / Totale * 100 88-94% >97% <92%
Waste Rate % Kg sprecati / Kg prodotti totali * 100 3-8% <2% >5%
Inventory Turnover COGS annuo / Inventario medio 12-20x (fresco) >25x (fresco) <10x
Days of Supply (DOS) Stock disponibile / Domanda media giornaliera 3-7 giorni (fresco) 2-4 giorni >10 giorni
Fill Rate Unita consegnate / Unita ordinate * 100 92-96% >98% <94%
Shelf Life at Receipt (SLR) Giorni residui consegna / Shelf life totale * 100 60-75% >80% <60%
Cold Chain Compliance Lotti senza violazioni temp / Totale lotti * 100 97-99% >99.5% <98%
Forecast Accuracy 1 - (MAE / Domanda media) * 100 75-85% >90% <75%
Cash-to-Cash Cycle DIO + DSO - DPO (giorni) 15-35 giorni <15 giorni >45 giorni

Come calcolare il Supply Chain Health Score

Ponderare i KPI in un unico score composito aiuta il management a monitorare la salute complessiva della filiera con un solo numero:

  • OTIF: peso 40% (impatto diretto su customer satisfaction)
  • Waste Rate: peso 30% (impatto su margini e sostenibilità)
  • Shelf Life at Receipt: peso 15% (qualità al consumatore)
  • Cold Chain Compliance: peso 15% (sicurezza alimentare e compliance)

Score > 90: eccellente | 80-90: buono | 70-80: da migliorare | <70: critico

Real-Time vs Batch: Quando Usare Quale Architettura

Non tutto deve essere real-time. Il costo di un'architettura streaming e significativamente superiore a un'architettura batch tradizionale. Nella supply chain alimentare, la scelta giusta dipende dalla latenza accettabile e dalle conseguenze operative.

Use Case Architettura Latenza Strumenti Motivazione
Cold chain monitoring (temperatura) Real-time streaming <1 min Kafka + Flink Violazione temp = perdita lotto immediata
Recall management Real-time / event-driven <5 min Kafka + alerting FSMA: traccia lotti in <2 ore
GPS tracking veicoli Quasi real-time <2 min MQTT + InfluxDB ETA aggiornato per clienti
KPI giornalieri (OTIF, waste) Batch giornaliero T+2h Airflow + dbt Report operativo mattutino
Demand forecasting Batch giornaliero/settimanale T+6h Airflow + MLflow Piano produzione non richiede real-time
Inventory snapshot Micro-batch (ogni 15 min) 15 min Airflow + Snowflake Operatori magazzino: visibilità sufficiente
Analisi spreco settimanale Batch settimanale T+24h dbt + BI tool Decisioni strategiche, non operative
Compliance audit FSMA Batch on-demand Su richiesta dbt + data vault Ispezioni pianificate, non quotidiane
# Architettura Lambda per Food Supply Chain
# Speed layer (real-time) + Batch layer (accuracy)

# SPEED LAYER: Kafka + Apache Flink
# Processa eventi in milliseconds: cold chain, recall alerts

from pyflink.datastream import StreamExecutionEnvironment
from pyflink.datastream.connectors.kafka import KafkaSource, KafkaOffsetsInitializer
from pyflink.common.serialization import SimpleStringSchema

env = StreamExecutionEnvironment.get_execution_environment()
env.set_parallelism(4)

kafka_source = KafkaSource.builder() \
    .set_bootstrap_servers("kafka:9092") \
    .set_topics("cold-chain-readings") \
    .set_group_id("flink-cold-chain-consumer") \
    .set_starting_offsets(KafkaOffsetsInitializer.latest()) \
    .set_value_only_deserializer(SimpleStringSchema()) \
    .build()

cold_chain_stream = env.from_source(
    kafka_source,
    watermark_strategy=WatermarkStrategy.for_bounded_out_of_orderness(
        Duration.of_seconds(30)
    ),
    source_name="cold-chain-kafka",
)

# Filtra violazioni temperatura in tempo reale
violations_stream = cold_chain_stream \
    .map(lambda msg: json.loads(msg)) \
    .filter(lambda r: (
        r.get("temperature_class") == "chilled" and
        (r["celsius"] < 2.0 or r["celsius"] > 8.0)
    )) \
    .map(lambda r: {
        **r,
        "violation_severity": "CRITICAL" if r["celsius"] > 12.0 else "WARNING",
        "violation_detected_at": datetime.utcnow().isoformat(),
    })

# Scrivi violations su Kafka alert topic
violations_stream.sink_to(
    KafkaSink.builder()
        .set_bootstrap_servers("kafka:9092")
        .set_record_serializer(
            KafkaRecordSerializationSchema.builder()
                .set_topic("cold-chain-violations")
                .set_value_serialization_schema(SimpleStringSchema())
                .build()
        )
        .build()
)

env.execute("food-cold-chain-monitoring")

Integrazione con Sistemi Legacy

La maggior parte delle aziende agroalimentari opera con sistemi legacy: SAP R/3 degli anni '90, file EDI EDIFACT su SFTP, database Oracle senza API REST. L'integrazione di questi sistemi richiede approcci specifici.

Connettore SAP: RFC e BAPI

# src/connectors/sap_connector.py
"""
Connettore SAP ERP via pyrfc (SAP RFC/BAPI wrapper).
Richiede: SAP NetWeaver RFC SDK + pyrfc library.
"""
import pyrfc
import pandas as pd
from dataclasses import dataclass
from typing import Optional
import logging

logger = logging.getLogger(__name__)

@dataclass
class SAPConnectionConfig:
    ashost: str
    sysnr: str
    client: str
    user: str
    passwd: str
    lang: str = "IT"

class SAPConnector:
    """Wrapper per SAP RFC/BAPI calls per dati supply chain."""

    def __init__(self, config: SAPConnectionConfig):
        self._config = config
        self._conn: Optional[pyrfc.Connection] = None

    def __enter__(self):
        self._conn = pyrfc.Connection(
            ashost=self._config.ashost,
            sysnr=self._config.sysnr,
            client=self._config.client,
            user=self._config.user,
            passwd=self._config.passwd,
            lang=self._config.lang,
        )
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        if self._conn:
            self._conn.close()

    def get_production_batches(
        self,
        date_from: str,
        date_to: str,
        plants: list[str],
    ) -> pd.DataFrame:
        """
        Estrae lotti di produzione da SAP tramite BAPI_BATCH_GET_DETAIL.
        Args:
            date_from: Data inizio formato YYYYMMDD
            date_to: Data fine formato YYYYMMDD
            plants: Lista codici stabilimento SAP
        """
        all_batches = []

        for plant in plants:
            try:
                # Chiama BAPI SAP per lista lotti
                result = self._conn.call(
                    "BAPI_BATCH_GET_DETAIL",
                    PLANT=plant,
                    DATE_FROM=date_from,
                    DATE_TO=date_to,
                    BATCH_STATUS="",  # tutti gli stati
                )

                batches = result.get("BATCH_DETAIL_LIST", [])
                logger.info(
                    "SAP plant %s: %d lotti estratti",
                    plant,
                    len(batches),
                )

                for batch in batches:
                    all_batches.append({
                        "lot_id": f"{batch['MATNR']}-{batch['CHARG']}",
                        "material_number": batch["MATNR"].strip(),
                        "lot_number": batch["CHARG"].strip(),
                        "plant": plant,
                        "production_date": batch.get("HSDAT"),
                        "expiry_date_sap": batch.get("VFDAT"),
                        "batch_status": batch.get("ZUSTD"),
                        "quantity_kg": float(batch.get("CLABS", 0)),
                        "temp_cond_sap": batch.get("MHDRZ", "0001"),
                        "_source": "SAP_ERP",
                        "_extracted_at": pd.Timestamp.utcnow().isoformat(),
                    })

            except pyrfc.ABAPApplicationError as e:
                logger.error(
                    "SAP BAPI error per plant %s: %s",
                    plant,
                    str(e),
                )
                # Non fallisce tutta l'estrazione per un singolo plant
                continue

        return pd.DataFrame(all_batches)

    def get_stock_movements(
        self,
        date_from: str,
        date_to: str,
        movement_types: list[str],
    ) -> pd.DataFrame:
        """
        Estrae movimenti di magazzino SAP via MB51 BAPI equivalent.
        movement_types: ['101'=GR, '261'=GI to production, '601'=GI to customer]
        """
        result = self._conn.call(
            "BAPI_GOODSMVT_GETDETAIL",
            GOODSMVT_DATE_FROM=date_from,
            GOODSMVT_DATE_TO=date_to,
        )

        movements = []
        for item in result.get("GOODSMVT_ITEMS", []):
            if item["BWART"] in movement_types:
                movements.append({
                    "movement_id": f"{item['MBLNR']}-{item['ZEILE']}",
                    "material": item["MATNR"].strip(),
                    "plant": item["WERKS"],
                    "movement_type": item["BWART"],
                    "quantity": float(item.get("MENGE", 0)),
                    "unit": item.get("MEINS", "KG"),
                    "lot_number": item.get("CHARG", ""),
                    "posting_date": item["BUDAT"],
                    "_source": "SAP_MOVEMENTS",
                })

        return pd.DataFrame(movements)

CDC con Debezium per Streaming da Database Legacy

# debezium-config/oracle-erp-connector.json
# Configurazione Debezium per CDC da Oracle EBS (ERP legacy)
{
  "name": "oracle-erp-food-supply-chain",
  "config": {
    "connector.class": "io.debezium.connector.oracle.OracleConnector",
    "tasks.max": "1",
    "database.hostname": "oracle-erp.internal",
    "database.port": "1521",
    "database.user": "debezium_cdc",
    "database.password": "${ORACLE_CDC_PASSWORD}",
    "database.dbname": "FOOD_ERP",
    "database.pdb.name": "FOOD_PDB",
    "database.server.name": "oracle-erp",
    "table.include.list": "FOOD_ERP.INV_TRANSACTIONS,FOOD_ERP.MTL_LOT_NUMBERS,FOOD_ERP.WSH_DELIVERY_DETAILS",
    "database.history.kafka.bootstrap.servers": "kafka:9092",
    "database.history.kafka.topic": "dbhistory.oracle.food_erp",
    "schema.history.internal.kafka.bootstrap.servers": "kafka:9092",
    "schema.history.internal.kafka.topic": "schemahistory.oracle.food_erp",
    "transforms": "route,addFields",
    "transforms.route.type": "org.apache.kafka.connect.transforms.ReplaceField$Value",
    "transforms.addFields.type": "org.apache.kafka.connect.transforms.InsertField$Value",
    "transforms.addFields.static.field": "_cdc_source",
    "transforms.addFields.static.value": "oracle-erp",
    "include.schema.changes": "true",
    "snapshot.mode": "initial_only",
    "decimal.handling.mode": "double",
    "time.precision.mode": "connect",
    "tombstones.on.delete": "true",
    "topic.prefix": "food-cdc"
  }
}

Parser EDI EDIFACT per Ordini Retailer

# src/connectors/edi_connector.py
"""
Parser EDI EDIFACT per messaggi ORDERS, DESADV, RECADV (GS1 subset).
Usa pydifact library per parsing EDIFACT messages.
"""
import pandas as pd
from pydifact.segmentcollection import SegmentCollection
from pydifact.segments import Segment
from pathlib import Path
import logging
from datetime import datetime

logger = logging.getLogger(__name__)

def parse_edifact_orders(file_path: str | Path) -> pd.DataFrame:
    """
    Parsa un file EDI EDIFACT ORDERS D.01B (GS1 EANCOM).
    Restituisce DataFrame con righe ordine normalizzate.

    Struttura messaggio ORDERS EDIFACT:
    UNB (Interchange header)
    UNH (Message header)
    BGM (Order message identifier)
    DTM (Date/time)
    NAD+BY (Buyer = retailer)
    NAD+SU (Supplier = nostro sistema)
    LIN (Line item)
      +QTY (Quantity ordered)
      +DTM (Requested delivery date)
      +PIA (Product code GTIN)
    UNT (Message trailer)
    """
    file_content = Path(file_path).read_text(encoding="latin-1")
    collection = SegmentCollection.from_str(file_content)

    orders = []
    current_order: dict = {}
    current_line: dict = {}

    for segment in collection.segments:
        tag = segment.tag

        if tag == "BGM":
            # Inizio nuovo ordine
            current_order = {
                "order_number": segment.elements[1] if len(segment.elements) > 1 else None,
                "document_type": segment.elements[0][0] if segment.elements else None,
                "lines": [],
            }

        elif tag == "DTM" and current_order:
            # Data ordine o data consegna richiesta
            qualifier = segment.elements[0][0]
            date_str = segment.elements[0][1]
            date_fmt = segment.elements[0][2]

            if qualifier == "137":  # Document date
                try:
                    current_order["order_date"] = datetime.strptime(
                        date_str, "%Y%m%d"
                    ).date().isoformat()
                except ValueError:
                    logger.warning("DTM parse error: %s", date_str)

            elif qualifier == "2":  # Requested delivery date
                try:
                    current_order["requested_delivery_date"] = datetime.strptime(
                        date_str, "%Y%m%d"
                    ).date().isoformat()
                except ValueError:
                    pass

        elif tag == "NAD":
            qualifier = segment.elements[0]
            party_id = segment.elements[1][0] if len(segment.elements) > 1 else None
            party_name = segment.elements[3] if len(segment.elements) > 3 else None

            if qualifier == "BY":  # Buyer (retailer)
                current_order["buyer_gln"] = party_id
                current_order["buyer_name"] = party_name
            elif qualifier == "SU":  # Supplier
                current_order["supplier_gln"] = party_id

        elif tag == "LIN":
            # Salva riga precedente se esiste
            if current_line and current_order:
                current_order["lines"].append(current_line)

            current_line = {
                "line_number": segment.elements[0],
                "gtin": None,
                "qty_ordered": None,
                "delivery_date": None,
            }

        elif tag == "PIA" and current_line:
            # Product identification: GTIN-13 o GTIN-14
            if len(segment.elements) > 1:
                qualifier = segment.elements[1][1] if len(segment.elements[1]) > 1 else ""
                if qualifier in ["SRV", "GE1"]:  # GTIN qualifiers
                    current_line["gtin"] = segment.elements[1][0]

        elif tag == "QTY" and current_line:
            qualifier = segment.elements[0][0]
            if qualifier == "21":  # Ordered quantity
                current_line["qty_ordered"] = float(segment.elements[0][1])
                current_line["qty_unit"] = segment.elements[0][2] if len(segment.elements[0]) > 2 else "PCE"

    # Normalizza output
    for order in orders:
        for line in order.get("lines", []):
            orders.append({
                "order_number": order.get("order_number"),
                "order_date": order.get("order_date"),
                "requested_delivery_date": order.get("requested_delivery_date"),
                "buyer_gln": order.get("buyer_gln"),
                "buyer_name": order.get("buyer_name"),
                "supplier_gln": order.get("supplier_gln"),
                "line_number": line.get("line_number"),
                "gtin": line.get("gtin"),
                "qty_ordered": line.get("qty_ordered"),
                "qty_unit": line.get("qty_unit"),
                "_source": "EDI_EDIFACT_ORDERS",
                "_parsed_at": datetime.utcnow().isoformat(),
            })

    return pd.DataFrame(orders)

Case Study: Cooperativa Agricola con 500 Farm

Vediamo come questi pattern si applicano in un caso reale: una grande cooperativa agricola del Nord Italia con 500 aziende agricole associate, 3 centri di lavorazione e trasformazione, e 200 punti vendita tra GDO e mercati locali.

Profilo cooperativa

  • 500 farm associate: ortofrutta, cereali, legumi - dati IoT MQTT da 8.000 sensori
  • 3 centri di lavorazione: SAP R/3 legacy (anni 2005), MES custom, LIMS
  • 200 punti vendita: POS eterogenei (3 vendor diversi), EDI EDIFACT con 15 retailer
  • Situazione di partenza: fogli Excel, dati siloed, nessuna visibility end-to-end
  • Obiettivi: ridurre spreco del 40%, migliorare OTIF a 95%, compliance FSMA

Fase 1: Assessment e Inventario Sorgenti (Settimane 1-4)

# Inventario sorgenti dati: output assessment
SOURCE_INVENTORY = {
    "farm_iot": {
        "count": 8000,
        "protocol": "MQTT v3.1.1",
        "broker": "Mosquitto on-premise",
        "format": "JSON custom",
        "issues": [
            "Schema non standardizzato tra vendor IoT diversi",
            "15% sensori con timestamp errati (clock drift)",
            "3 farm su 500 senza connettivita stabile (4G intermittente)",
        ],
        "recommended_fix": "Normalizzazione schema + edge buffer (Mosquitto local)",
    },
    "sap_erp": {
        "version": "SAP R/3 4.7 (anno 2005)",
        "tables_relevant": ["MCHA", "MCH1", "MSEG", "MKPF", "VBAK", "VBAP"],
        "issues": [
            "No REST API: solo RFC/BAPI",
            "Batch extraction window: 01:00-03:00 (finestra ristretta)",
            "Codifica caratteri: ISO-8859-1 (non UTF-8)",
            "Date in formato SAP (YYYYMMDD come intero)",
        ],
        "recommended_fix": "pyrfc + ETL incremental con delta timestamp",
    },
    "edi_partners": {
        "partners": 15,
        "standards": ["EANCOM D.01B", "X12 850/856", "GS1 XML 3.1"],
        "transport": "SFTP (12 partner), AS2 (3 partner)",
        "issues": [
            "3 retailer usano EDIFACT non-standard (varianti proprietarie)",
            "Mancanza di RECADV da 5 retailer: no conferma ricezione",
        ],
    },
    "pos_retail": {
        "vendors": ["Cassa Easy", "NCR Aloha", "Custom PHP legacy"],
        "issues": [
            "3 formati CSV diversi",
            "Nessun campo shelf_life nei dati POS",
            "Granularità: ora (non transazione)",
        ],
    },
}

Fase 2: Implementazione Stack Tecnico (Settimane 5-16)

# Stack tecnico implementato per la cooperativa

INFRASTRUCTURE = {
    "data_lake": "AWS S3 (Medallion: Bronze/Silver/Gold)",
    "compute": "AWS EMR Serverless (Spark per bulk load iniziale)",
    "warehouse": "Snowflake (pay-per-query, snowpark per ML features)",
    "orchestration": "Apache Airflow 2.9 su AWS MWAA (managed)",
    "transformation": "dbt Cloud (Team plan: 8 developers)",
    "data_quality": "Great Expectations + Slack alerts",
    "streaming": "Apache Kafka su MSK (managed) per cold chain",
    "monitoring": "Grafana + Prometheus (Airflow metrics, DWH queries)",
    "ci_cd": "GitHub Actions (dbt CI: test su ogni PR)",
    "secret_management": "AWS Secrets Manager",
}

TIMELINE = [
    {"week": "1-4",  "milestone": "Assessment sorgenti + architettura design"},
    {"week": "5-7",  "milestone": "Bronze layer: connettori SAP + IoT MQTT"},
    {"week": "8-10", "milestone": "Bronze layer: EDI + POS + LIMS"},
    {"week": "11-12","milestone": "Silver layer: dbt models + data quality"},
    {"week": "13-14","milestone": "Gold layer: KPI dashboard + alerting"},
    {"week": "15-16","milestone": "UAT + go-live + team training"},
}

TEAM = {
    "data_engineers": 3,
    "dbt_developers": 2,
    "sap_consultant": 1,  # part-time per RFC/BAPI
    "project_manager": 1,
}

Risultati Ottenuti dopo 6 Mesi

KPI Prima (baseline) Dopo 6 mesi Miglioramento
Waste Rate 7.2% 4.3% -40% spreco (-2.9pp)
OTIF 88% 94% +6pp
Shelf Life at Receipt 58% 74% +16pp
Cold Chain Compliance 94% (stimato) 99.1% (misurato) +5.1pp con visibility reale
Tempo risoluzione recall 48-72 ore 2-4 ore -93% tempo risposta
Report manuale direttori 3-4 ore/settimana Dashboard automatica -100% lavoro manuale
Forecast accuracy domanda 71% 86% +15pp
Days of Supply (fresco) 8-12 giorni 4-6 giorni -50% stock in eccesso

Lezioni apprese dal case study

  • SAP RFC/BAPI e più lento di quanto pensi: la finestra batch notturna era il collo di bottiglia più critico. La soluzione e stata negoziare con IT una finestra più ampia e usare estrazione incrementale per minimizzare il volume.
  • EDI non e mai standard: su 15 partner retailer, 11 avevano varianti proprietarie dello standard EANCOM. Il parser EDIFACT ha richiesto 2 settimane di calibrazione contro le specifiche di ciascun partner.
  • La data quality e un problema culturale, non solo tecnico: il 30% dei problemi di qualità derivava da inserimenti manuali errati in SAP da parte degli operatori. La soluzione tecnica (Great Expectations) ha evidenziato il problema, ma la risoluzione ha richiesto training e change management.
  • Bronze layer salva tutto: tre volte in sei mesi e stato necessario rieseguire le trasformazioni Silver per correggere bug nei modelli dbt. Grazie al Bronze layer immutabile, nessun dato e andato perso.
  • dbt CI/CD cambia il modo di lavorare: testare ogni modello su ogni PR ha prevenuto 4 incidenti di produzione stimati nel primo mese.

Riepilogo Completo della Serie FoodTech

Questa e stata una lunga cavalcata attraverso le tecnologie che stanno trasformando il settore alimentare. Ecco cosa abbiamo costruito insieme in questa serie di 10 articoli:

# Articolo Concetti chiave appresi Stack tecnologico
00 Pipeline IoT per Agricoltura di Precisione MQTT broker, sensori campo, data lake ingestion, QoS levels Python, MQTT, Mosquitto, MinIO
01 ML Edge per Rilevamento Malattie Colture TensorFlow Lite quantization, edge inference, model deployment su ARM TFLite, Raspberry Pi, Python, OpenCV
02 API Satellitari e Meteorologiche per AgriTech Sentinel-2 NDVI, Planet Labs API, feature engineering meteo per ML Sentinel Hub, OpenMeteo, rasterio, GeoPandas
03 Sistema di Tracciabilita Alimentare Blockchain private (Hyperledger), RFID EPCIS, GS1 standards Hyperledger Fabric, RFID, EPCIS, Python
04 Computer Vision per Controllo qualità YOLO fine-tuning su dataset prodotti, inference pipeline industriale PyTorch, YOLO, FastAPI, OpenCV
05 Automazione FSMA 204 KDE, CTEs required foods, lot traceability, recall automation Python, FastAPI, PostgreSQL, alerting
06 Automazione Vertical Farming Controllo parametri ambientali, scheduling robotico, API REST per farm Python, FastAPI, InfluxDB, MQTT
07 Demand Forecasting per Riduzione Spreco LSTM, Prophet, feature engineering stagionalita alimentare, backtesting PyTorch, Prophet, MLflow, pandas
08 Dashboard Real-Time per Farm IoT Angular signals, Grafana provisioning, InfluxDB queries, alerting Angular 21, Grafana, InfluxDB, WebSocket
09 Supply Chain ETL da Farm a Retailer Medallion architecture, Airflow DAGs, dbt models, Great Expectations, KPI Airflow, dbt, Snowflake, GE, SAP RFC, EDI

Roadmap per chi vuole approfondire

Se hai letto tutta la serie, hai ora una base solida per affrontare progetti FoodTech reali. Ecco una roadmap strutturata per approfondire ciascun dominio:

Percorso di approfondimento consigliato

  • IoT e Edge Computing: studia MQTT 5.0 (rispetto al 3.1.1 usato in questo articolo), Azure IoT Hub o AWS IoT Core per soluzioni managed, e Apache NiFi per data ingestion da fonti IoT eterogenee.
  • ML in produzione: la serie MLOps di questo blog ti insegnera come versionare modelli, fare A/B testing e monitorare drift in produzione con MLflow e Seldon.
  • Architettura dati: approfondisci il Data Lakehouse (serie Data & AI Business) per capire come Snowflake, Databricks e Apache Iceberg si confrontano come piattaforme per supply chain analytics.
  • LLM e AI generativa per supply chain: i Large Language Model possono estrarre informazioni strutturate da documenti di conformità, analizzare contratti fornitori e generare report automatici. La serie AI Engineering del blog copre RAG e fine-tuning.
  • Standard di settore da studiare: GS1 Global Standards (GTIN, GLN, SSCC, EPCIS), FSMA 204 (FDA), Reg. EU 178/2002 (tracciabilita alimentare), ISO 22000 (HACCP).
  • Certificazioni rilevanti: AWS Certified Data Engineer, dbt Certified Developer, Astronomer Certified Apache Airflow Developer, Databricks Data Engineer Associate.

Best Practices e Anti-Pattern per ETL Alimentare

Best Practices

  • Bronze layer immutabile: non modificare mai i dati raw. Se trovi un bug in un modello dbt Silver, correggi il modello e riesegui - il Bronze rimane intatto. Questo e fondamentale per audit trail FSMA e EU Reg. 178/2002.
  • Idempotenza dei DAG Airflow: ogni task deve poter essere rieseguito senza effetti collaterali. Usa replace=True per upload S3, MERGE invece di INSERT per database, e chiavi univoche nei modelli dbt incrementali.
  • Timeout espliciti su ogni task: i sistemi legacy come SAP RFC possono bloccarsi indefinitamente. Imposta sempre execution_timeout=timedelta(hours=2) su ogni task Airflow.
  • Monitoring dei lag di ingestion: una pipeline che si avvia alle 02:00 ma non completa entro le 06:00 (prima del turno operativo) e inutile. Monitora i tempi di completamento e imposta SLA alerts su Airflow.
  • Separazione schema EDI per partner: mantieni un parser EDI dedicato per ciascun retailer partner. Le varianti proprietarie rendono impossibile un parser unico universale.
  • Data lineage documentata: dbt genera automaticamente un grafo di lineage. Usalo per rispondere alle domande di audit: "Questo KPI OTIF da dove viene?".

Anti-Pattern da Evitare

  • Trasformazioni nel layer di estrazione (ETL classico): trasformare i dati prima di salvarli nel Bronze layer significa perdere la possibilità di fare backfill o correggere errori. Carica sempre raw, trasforma dopo con dbt.
  • Un DAG monolitico per tutto: un singolo DAG con 50+ task diventa impossibile da debuggare. Dividi per dominio (SAP DAG, IoT DAG, EDI DAG) e usa Airflow TriggerDagRunOperator per le dipendenze cross-DAG.
  • Ignorare i fusi orari: una farm in Sicilia, un centro di distribuzione a Milano e un retailer in Germania usano fusi orari diversi. Converti sempre in UTC nel Bronze layer e usa timestamp consapevoli del fuso (timezone-aware) ovunque.
  • Hard-coded credentials nei DAG: MAI inserire password SAP o token API nel codice Airflow. Usa Airflow Connections e Variables, oppure AWS Secrets Manager.
  • Skip data quality sul Bronze: validare solo il Gold layer e troppo tardi. I problemi vanno identificati il prima possibile: un dato di temperatura errato nel Bronze diventa un recall falso positivo nel Gold.
  • Scheduler Airflow come executor: il task non deve essere eseguito dallo scheduler Airflow stesso. Usa sempre un Executor separato (CeleryExecutor, KubernetesExecutor) per isolare i workload pesanti.

Conclusioni: La Fine di un Percorso, l'Inizio di un Altro

Siamo arrivati alla fine della serie FoodTech. In dieci articoli, abbiamo costruito pezzo per pezzo la visione di una filiera alimentare completamente data-driven: sensori in campo che trasmettono dati in tempo reale, modelli ML che rilevano malattie prima dell'occhio umano, blockchain per tracciabilita immutabile, computer vision per il controllo qualità, compliance FSMA automatizzata, vertical farm controllate via API, demand forecasting che riduce gli sprechi, dashboard real-time per il management e, in questo ultimo articolo, la pipeline ETL che tiene tutto insieme.

Il settore agroalimentare e uno dei più complessi da digitalizzare: stagionalita imprevedibile, prodotti deperibili, normative stringenti, sistemi legacy consolidati da decenni, filiere globali con centinaia di attori. Ma proprio per questo, le opportunità di creare valore con la tecnologia sono enormi. Ridurre il waste rate dal 7% al 4% in una cooperativa con 500 farm non e solo un miglioramento operativo: e denaro, e cibo, e impatto ambientale.

La supply chain alimentare del 2030 sarà definita da dati in tempo reale (ogni lotto tracciato dalla semina alla forchetta), AI predittiva (zero overstock, zero stockout, zero sprechi), compliance automatizzata (audit FSMA in 2 ore, non 2 settimane) e sostenibilità misurabile (carbon footprint per lotto, water usage per ettaro).

Le tecnologie ci sono tutte. L'ETL pattern che hai imparato in questo articolo - Airflow per orchestrazione, dbt per trasformazioni, Great Expectations per data quality, Medallion architecture per struttura - e applicabile immediatamente in qualsiasi azienda agroalimentare di medie o grandi dimensioni.

Esplora altre serie correlate del blog

  • Data & AI Business (serie 14): approfondisce data warehouse (Snowflake, Databricks, BigQuery), ETL/ELT con dbt e Airbyte, MLOps enterprise e governance dei dati - il complemento naturale a questa serie FoodTech.
  • MLOps (serie 5): come portare in produzione i modelli ML di demand forecasting e computer vision che abbiamo costruito negli articoli 01, 04 e 07.
  • AI Engineering/RAG (serie 6): come usare LLM per interrogare documenti di compliance, analizzare certificati HACCP e generare report automatici per la supply chain.
  • PostgreSQL AI (serie 10): come usare pgvector per ricerca semantica su descrizioni prodotti, ricette e specifiche tecniche alimentari - utile per la gestione del catalogo prodotti in ambito food.
  • EnergyTech (serie): molte tecnologie IoT e pipeline dati della serie FoodTech sono identiche a quelle usate per monitoraggio energetico di impianti industriali alimentari.

Grazie per aver seguito la serie FoodTech fino in fondo. Buon coding, e buon appetito.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti