netgescon-master/docs/specifiche/CONSUMI_WATER_HEATING_SYSTEM.md
2025-07-20 14:57:25 +00:00

47 KiB

🌡️ NetGesCon Laravel - Gestione Consumi Acqua e Riscaldamento

📍 OVERVIEW SISTEMA CONSUMI

Modulo completo per gestione consumi acqua e riscaldamento con supporto per letture manuali, import da ditta, collegamento fatture, gestione millesimi non standard e calcolo automatico ripartizione.


🏗️ ARCHITETTURA DATABASE CONSUMI

1. Tabella Contatori

-- Migration: create_contatori_table.php
CREATE TABLE contatori (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    stabile_id BIGINT UNSIGNED NOT NULL,
    unita_immobiliare_id BIGINT UNSIGNED NULL, -- NULL per contatori generali
    tipo_contatore ENUM('acqua_fredda', 'acqua_calda', 'riscaldamento', 'gas', 'energia_elettrica') NOT NULL,
    codice_contatore VARCHAR(50) NOT NULL,
    numero_matricola VARCHAR(100) NOT NULL,
    marca VARCHAR(100),
    modello VARCHAR(100),
    anno_installazione YEAR,
    coefficiente_moltiplicatore DECIMAL(8,4) DEFAULT 1.0000,
    unita_misura ENUM('mc', 'kWh', 'kcal', 'Gj') NOT NULL,
    posizione_contatore VARCHAR(255),
    note VARCHAR(500),
    attivo BOOLEAN DEFAULT TRUE,
    data_installazione DATE,
    data_dismissione DATE NULL,
    creato_da BIGINT UNSIGNED NOT NULL,
    modificato_da BIGINT UNSIGNED NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    
    -- Indexes
    INDEX idx_stabile_tipo (stabile_id, tipo_contatore),
    INDEX idx_unita_tipo (unita_immobiliare_id, tipo_contatore),
    INDEX idx_codice (codice_contatore),
    INDEX idx_matricola (numero_matricola),
    INDEX idx_attivo (attivo),
    
    -- Constraints
    UNIQUE KEY uk_stabile_codice (stabile_id, codice_contatore),
    UNIQUE KEY uk_matricola (numero_matricola),
    
    -- Foreign Keys
    FOREIGN KEY (stabile_id) REFERENCES stabili(id) ON DELETE CASCADE,
    FOREIGN KEY (unita_immobiliare_id) REFERENCES unita_immobiliari(id) ON DELETE CASCADE,
    FOREIGN KEY (creato_da) REFERENCES users(id),
    FOREIGN KEY (modificato_da) REFERENCES users(id)
);

2. Tabella Letture Contatori

-- Migration: create_letture_contatori_table.php
CREATE TABLE letture_contatori (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    contatore_id BIGINT UNSIGNED NOT NULL,
    stabile_id BIGINT UNSIGNED NOT NULL,
    periodo_riferimento VARCHAR(7) NOT NULL, -- YYYY-MM formato
    data_lettura DATE NOT NULL,
    lettura_precedente DECIMAL(10,3) DEFAULT 0.000,
    lettura_attuale DECIMAL(10,3) NOT NULL,
    consumo_calcolato DECIMAL(10,3) AS (lettura_attuale - lettura_precedente) STORED,
    consumo_rettificato DECIMAL(10,3) NULL, -- Per correzioni manuali
    tipo_lettura ENUM('manuale', 'automatica', 'stimata', 'import_ditta') NOT NULL,
    fonte_lettura VARCHAR(100), -- Nome ditta o utente
    note_lettura TEXT,
    validata BOOLEAN DEFAULT FALSE,
    validata_da BIGINT UNSIGNED NULL,
    validata_at TIMESTAMP NULL,
    importo_unitario DECIMAL(8,4) NULL, -- Per collegamento fatture
    importo_totale DECIMAL(10,2) NULL,
    fattura_collegata VARCHAR(255) NULL,
    -- Metadati per import automatico
    import_batch_id VARCHAR(50) NULL,
    import_file_name VARCHAR(255) NULL,
    import_riga_numero INT NULL,
    -- Audit fields
    inserita_da BIGINT UNSIGNED NOT NULL,
    modificata_da BIGINT UNSIGNED NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    
    -- Indexes
    INDEX idx_contatore_periodo (contatore_id, periodo_riferimento),
    INDEX idx_stabile_periodo (stabile_id, periodo_riferimento),
    INDEX idx_data_lettura (data_lettura),
    INDEX idx_tipo_lettura (tipo_lettura),
    INDEX idx_validata (validata),
    INDEX idx_import_batch (import_batch_id),
    
    -- Constraints
    UNIQUE KEY uk_contatore_periodo (contatore_id, periodo_riferimento),
    CHECK (lettura_attuale >= lettura_precedente),
    
    -- Foreign Keys
    FOREIGN KEY (contatore_id) REFERENCES contatori(id) ON DELETE CASCADE,
    FOREIGN KEY (stabile_id) REFERENCES stabili(id) ON DELETE CASCADE,
    FOREIGN KEY (validata_da) REFERENCES users(id),
    FOREIGN KEY (inserita_da) REFERENCES users(id),
    FOREIGN KEY (modificata_da) REFERENCES users(id)
);

3. Tabella Ripartizione Consumi

-- Migration: create_ripartizione_consumi_table.php
CREATE TABLE ripartizione_consumi (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    stabile_id BIGINT UNSIGNED NOT NULL,
    periodo_riferimento VARCHAR(7) NOT NULL, -- YYYY-MM
    tipo_consumo ENUM('acqua_fredda', 'acqua_calda', 'riscaldamento', 'gas', 'energia_elettrica') NOT NULL,
    modalita_ripartizione ENUM('contatori_individuali', 'millesimi_standard', 'millesimi_personalizzati', 'misto') NOT NULL,
    tabella_millesimale_id BIGINT UNSIGNED NULL, -- Per ripartizione millesimale
    consumo_totale_periodo DECIMAL(12,3) NOT NULL,
    importo_totale_periodo DECIMAL(12,2) NOT NULL,
    consumo_generale DECIMAL(12,3) DEFAULT 0.000, -- Da contatore generale
    consumo_individuale_totale DECIMAL(12,3) DEFAULT 0.000, -- Somma contatori individuali
    differenza_consumo DECIMAL(12,3) AS (consumo_generale - consumo_individuale_totale) STORED,
    percentuale_perdite DECIMAL(5,2) AS (
        CASE 
            WHEN consumo_generale > 0 THEN ((consumo_generale - consumo_individuale_totale) / consumo_generale) * 100
            ELSE 0
        END
    ) STORED,
    -- Configurazione ripartizione
    ripartizione_perdite ENUM('proporzionale', 'millesimi', 'fissa', 'escludi') DEFAULT 'proporzionale',
    quota_fissa_percentuale DECIMAL(5,2) DEFAULT 0.00, -- % quota fissa
    quota_variabile_percentuale DECIMAL(5,2) DEFAULT 100.00, -- % quota variabile
    -- Stato processamento
    stato_ripartizione ENUM('bozza', 'calcolata', 'verificata', 'confermata', 'fatturata') DEFAULT 'bozza',
    data_calcolo TIMESTAMP NULL,
    calcolata_da BIGINT UNSIGNED NULL,
    data_conferma TIMESTAMP NULL,
    confermata_da BIGINT UNSIGNED NULL,
    note_ripartizione TEXT,
    -- Audit
    creata_da BIGINT UNSIGNED NOT NULL,
    modificata_da BIGINT UNSIGNED NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    
    -- Indexes
    INDEX idx_stabile_periodo (stabile_id, periodo_riferimento),
    INDEX idx_tipo_consumo (tipo_consumo),
    INDEX idx_stato (stato_ripartizione),
    INDEX idx_modalita (modalita_ripartizione),
    
    -- Constraints
    UNIQUE KEY uk_stabile_periodo_tipo (stabile_id, periodo_riferimento, tipo_consumo),
    CHECK (quota_fissa_percentuale + quota_variabile_percentuale = 100),
    
    -- Foreign Keys
    FOREIGN KEY (stabile_id) REFERENCES stabili(id) ON DELETE CASCADE,
    FOREIGN KEY (tabella_millesimale_id) REFERENCES tabelle_millesimali(id) ON DELETE SET NULL,
    FOREIGN KEY (calcolata_da) REFERENCES users(id),
    FOREIGN KEY (confermata_da) REFERENCES users(id),
    FOREIGN KEY (creata_da) REFERENCES users(id),
    FOREIGN KEY (modificata_da) REFERENCES users(id)
);

4. Tabella Dettaglio Ripartizione Consumi

-- Migration: create_dettaglio_ripartizione_consumi_table.php
CREATE TABLE dettaglio_ripartizione_consumi (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    ripartizione_consumo_id BIGINT UNSIGNED NOT NULL,
    unita_immobiliare_id BIGINT UNSIGNED NOT NULL,
    contatore_id BIGINT UNSIGNED NULL, -- NULL se ripartizione millesimale
    -- Consumi
    consumo_individuale DECIMAL(10,3) DEFAULT 0.000,
    consumo_quota_fissa DECIMAL(10,3) DEFAULT 0.000,
    consumo_quota_variabile DECIMAL(10,3) DEFAULT 0.000,
    consumo_perdite DECIMAL(10,3) DEFAULT 0.000,
    consumo_totale DECIMAL(10,3) AS (consumo_individuale + consumo_quota_fissa + consumo_quota_variabile + consumo_perdite) STORED,
    -- Importi
    importo_quota_fissa DECIMAL(10,2) DEFAULT 0.00,
    importo_quota_variabile DECIMAL(10,2) DEFAULT 0.00,
    importo_perdite DECIMAL(10,2) DEFAULT 0.00,
    importo_totale DECIMAL(10,2) AS (importo_quota_fissa + importo_quota_variabile + importo_perdite) STORED,
    -- Millesimi utilizzati
    millesimi_quota_fissa DECIMAL(8,4) DEFAULT 0.0000,
    millesimi_quota_variabile DECIMAL(8,4) DEFAULT 0.0000,
    millesimi_perdite DECIMAL(8,4) DEFAULT 0.0000,
    -- Flags
    esentato BOOLEAN DEFAULT FALSE,
    note_esenzione VARCHAR(255) NULL,
    -- Audit
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    
    -- Indexes
    INDEX idx_ripartizione (ripartizione_consumo_id),
    INDEX idx_unita (unita_immobiliare_id),
    INDEX idx_contatore (contatore_id),
    INDEX idx_esentato (esentato),
    
    -- Constraints
    UNIQUE KEY uk_ripartizione_unita (ripartizione_consumo_id, unita_immobiliare_id),
    
    -- Foreign Keys
    FOREIGN KEY (ripartizione_consumo_id) REFERENCES ripartizione_consumi(id) ON DELETE CASCADE,
    FOREIGN KEY (unita_immobiliare_id) REFERENCES unita_immobiliari(id) ON DELETE CASCADE,
    FOREIGN KEY (contatore_id) REFERENCES contatori(id) ON DELETE SET NULL
);

5. Tabella Parametri Calcolo

-- Migration: create_parametri_calcolo_consumi_table.php
CREATE TABLE parametri_calcolo_consumi (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    stabile_id BIGINT UNSIGNED NOT NULL,
    tipo_consumo ENUM('acqua_fredda', 'acqua_calda', 'riscaldamento', 'gas', 'energia_elettrica') NOT NULL,
    -- Parametri generali
    millesimi_totali DECIMAL(8,4) DEFAULT 1000.0000,
    gestione_perdite ENUM('proporzionale', 'millesimi', 'fissa', 'escludi') DEFAULT 'proporzionale',
    soglia_perdite_percentuale DECIMAL(5,2) DEFAULT 10.00, -- Alert se perdite > soglia
    -- Quote fisse/variabili
    quota_fissa_abilitata BOOLEAN DEFAULT FALSE,
    quota_fissa_percentuale DECIMAL(5,2) DEFAULT 0.00,
    quota_variabile_percentuale DECIMAL(5,2) DEFAULT 100.00,
    -- Tariffe
    tariffa_base DECIMAL(8,4) DEFAULT 0.0000,
    tariffa_scaglioni JSON NULL, -- Array di scaglioni progressivi
    -- Configurazioni avanzate
    arrotondamento_centesimi ENUM('matematico', 'eccesso', 'difetto') DEFAULT 'matematico',
    decimali_consumo TINYINT DEFAULT 3,
    decimali_importo TINYINT DEFAULT 2,
    -- Validità
    valido_da DATE NOT NULL,
    valido_fino DATE NULL,
    attivo BOOLEAN DEFAULT TRUE,
    -- Audit
    creato_da BIGINT UNSIGNED NOT NULL,
    modificato_da BIGINT UNSIGNED NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    
    -- Indexes
    INDEX idx_stabile_tipo (stabile_id, tipo_consumo),
    INDEX idx_validita (valido_da, valido_fino),
    INDEX idx_attivo (attivo),
    
    -- Constraints
    UNIQUE KEY uk_stabile_tipo_validita (stabile_id, tipo_consumo, valido_da),
    CHECK (quota_fissa_percentuale + quota_variabile_percentuale = 100),
    
    -- Foreign Keys
    FOREIGN KEY (stabile_id) REFERENCES stabili(id) ON DELETE CASCADE,
    FOREIGN KEY (creato_da) REFERENCES users(id),
    FOREIGN KEY (modificato_da) REFERENCES users(id)
);

🎯 MODELLI ELOQUENT

1. Modello Contatore

// app/Models/Contatore.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

class Contatore extends Model
{
    protected $table = 'contatori';

    protected $fillable = [
        'stabile_id', 'unita_immobiliare_id', 'tipo_contatore',
        'codice_contatore', 'numero_matricola', 'marca', 'modello',
        'anno_installazione', 'coefficiente_moltiplicatore', 'unita_misura',
        'posizione_contatore', 'note', 'attivo', 'data_installazione',
        'data_dismissione', 'creato_da'
    ];

    protected $casts = [
        'coefficiente_moltiplicatore' => 'decimal:4',
        'attivo' => 'boolean',
        'data_installazione' => 'date',
        'data_dismissione' => 'date',
        'anno_installazione' => 'integer'
    ];

    // Relazioni
    public function stabile()
    {
        return $this->belongsTo(Stabile::class);
    }

    public function unitaImmobiliare()
    {
        return $this->belongsTo(UnitaImmobiliare::class);
    }

    public function lettureContatori()
    {
        return $this->hasMany(LetturaContatore::class);
    }

    public function creatoDA()
    {
        return $this->belongsTo(User::class, 'creato_da');
    }

    public function modificatoDa()
    {
        return $this->belongsTo(User::class, 'modificato_da');
    }

    // Scopes
    public function scopeAttivi($query)
    {
        return $query->where('attivo', true);
    }

    public function scopePerTipo($query, $tipo)
    {
        return $query->where('tipo_contatore', $tipo);
    }

    public function scopeGenerali($query)
    {
        return $query->whereNull('unita_immobiliare_id');
    }

    public function scopeIndividuali($query)
    {
        return $query->whereNotNull('unita_immobiliare_id');
    }

    // Metodi utilità
    public function getUltimaLettura()
    {
        return $this->lettureContatori()
                   ->orderBy('data_lettura', 'desc')
                   ->first();
    }

    public function getConsumoMedio($mesi = 12)
    {
        $letture = $this->lettureContatori()
                       ->where('data_lettura', '>=', now()->subMonths($mesi))
                       ->where('validata', true)
                       ->get();

        if ($letture->count() < 2) {
            return 0;
        }

        $consumoTotale = $letture->sum('consumo_calcolato');
        return $consumoTotale / $letture->count();
    }

    public function applicaCoefficiente($valore)
    {
        return $valore * $this->coefficiente_moltiplicatore;
    }

    public function getDescrizioneCompletaAttribute()
    {
        $descrizione = $this->codice_contatore . ' - ' . $this->getTipoContatore();
        
        if ($this->unitaImmobiliare) {
            $descrizione .= ' (Unità: ' . $this->unitaImmobiliare->codice_unita . ')';
        } else {
            $descrizione .= ' (Generale)';
        }

        return $descrizione;
    }

    public function getTipoContatore()
    {
        $tipi = [
            'acqua_fredda' => 'Acqua Fredda',
            'acqua_calda' => 'Acqua Calda',
            'riscaldamento' => 'Riscaldamento',
            'gas' => 'Gas',
            'energia_elettrica' => 'Energia Elettrica'
        ];

        return $tipi[$this->tipo_contatore] ?? $this->tipo_contatore;
    }

    public static function getTipiContatore()
    {
        return [
            'acqua_fredda' => 'Acqua Fredda',
            'acqua_calda' => 'Acqua Calda',
            'riscaldamento' => 'Riscaldamento',
            'gas' => 'Gas',
            'energia_elettrica' => 'Energia Elettrica'
        ];
    }

    public static function getUnitaMisura()
    {
        return [
            'mc' => 'Metri Cubi (m³)',
            'kWh' => 'Kilowattora (kWh)',
            'kcal' => 'Kilocalorie (kcal)',
            'Gj' => 'Gigajoule (GJ)'
        ];
    }

    // Boot method per generazione codice automatica
    protected static function boot()
    {
        parent::boot();
        
        static::creating(function ($model) {
            if (empty($model->codice_contatore)) {
                $model->codice_contatore = self::generateUniqueCode($model->stabile_id, $model->tipo_contatore);
            }
        });
    }

    private static function generateUniqueCode($stabileId, $tipo)
    {
        $prefisso = strtoupper(substr($tipo, 0, 2));
        $numero = self::where('stabile_id', $stabileId)
                      ->where('tipo_contatore', $tipo)
                      ->count() + 1;
        
        do {
            $code = $prefisso . str_pad($numero, 3, '0', STR_PAD_LEFT);
            $numero++;
        } while (self::where('stabile_id', $stabileId)->where('codice_contatore', $code)->exists());
        
        return $code;
    }
}

2. Modello Lettura Contatore

// app/Models/LetturaContatore.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class LetturaContatore extends Model
{
    protected $table = 'letture_contatori';

    protected $fillable = [
        'contatore_id', 'stabile_id', 'periodo_riferimento', 'data_lettura',
        'lettura_precedente', 'lettura_attuale', 'consumo_rettificato',
        'tipo_lettura', 'fonte_lettura', 'note_lettura', 'validata',
        'importo_unitario', 'importo_totale', 'fattura_collegata',
        'import_batch_id', 'import_file_name', 'import_riga_numero',
        'inserita_da'
    ];

    protected $casts = [
        'data_lettura' => 'date',
        'lettura_precedente' => 'decimal:3',
        'lettura_attuale' => 'decimal:3',
        'consumo_rettificato' => 'decimal:3',
        'importo_unitario' => 'decimal:4',
        'importo_totale' => 'decimal:2',
        'validata' => 'boolean',
        'validata_at' => 'datetime',
        'import_riga_numero' => 'integer'
    ];

    // Relazioni
    public function contatore()
    {
        return $this->belongsTo(Contatore::class);
    }

    public function stabile()
    {
        return $this->belongsTo(Stabile::class);
    }

    public function inseritaDa()
    {
        return $this->belongsTo(User::class, 'inserita_da');
    }

    public function validataDa()
    {
        return $this->belongsTo(User::class, 'validata_da');
    }

    // Scopes
    public function scopeValidate($query)
    {
        return $query->where('validata', true);
    }

    public function scopePerPeriodo($query, $periodo)
    {
        return $query->where('periodo_riferimento', $periodo);
    }

    public function scopePerTipo($query, $tipo)
    {
        return $query->where('tipo_lettura', $tipo);
    }

    public function scopeImportBatch($query, $batchId)
    {
        return $query->where('import_batch_id', $batchId);
    }

    // Accessors
    public function getConsumoFinaleAttribute()
    {
        return $this->consumo_rettificato ?? $this->consumo_calcolato;
    }

    public function getConsumoCalcolatoAttribute()
    {
        return $this->lettura_attuale - $this->lettura_precedente;
    }

    public function getImportoCalcolatoAttribute()
    {
        if ($this->importo_unitario > 0) {
            return $this->consumo_finale * $this->importo_unitario;
        }
        return $this->importo_totale;
    }

    // Metodi
    public function valida($userId = null)
    {
        $this->update([
            'validata' => true,
            'validata_da' => $userId ?? auth()->id(),
            'validata_at' => now()
        ]);
    }

    public function invalidate()
    {
        $this->update([
            'validata' => false,
            'validata_da' => null,
            'validata_at' => null
        ]);
    }

    public function rettificaConsumo($nuovoConsumo, $motivo = null)
    {
        $this->update([
            'consumo_rettificato' => $nuovoConsumo,
            'note_lettura' => $this->note_lettura . "\n[Rettifica: {$motivo}]"
        ]);
    }

    public function calcolaVariazionePercentuale()
    {
        $letturaPrec = self::where('contatore_id', $this->contatore_id)
                          ->where('data_lettura', '<', $this->data_lettura)
                          ->orderBy('data_lettura', 'desc')
                          ->first();

        if (!$letturaPrec || $letturaPrec->consumo_finale == 0) {
            return null;
        }

        return (($this->consumo_finale - $letturaPrec->consumo_finale) / $letturaPrec->consumo_finale) * 100;
    }

    public static function getTipiLettura()
    {
        return [
            'manuale' => 'Lettura Manuale',
            'automatica' => 'Lettura Automatica',
            'stimata' => 'Lettura Stimata',
            'import_ditta' => 'Import da Ditta'
        ];
    }

    // Observer hooks
    protected static function boot()
    {
        parent::boot();
        
        static::creating(function ($model) {
            // Calcola lettura precedente automaticamente
            if (empty($model->lettura_precedente)) {
                $ultimaLettura = self::where('contatore_id', $model->contatore_id)
                                   ->where('data_lettura', '<', $model->data_lettura)
                                   ->orderBy('data_lettura', 'desc')
                                   ->first();
                
                $model->lettura_precedente = $ultimaLettura ? $ultimaLettura->lettura_attuale : 0;
            }
        });

        static::created(function ($model) {
            // Aggiorna letture successive se necessario
            $model->aggiornaLettureSuccessive();
        });
    }

    private function aggiornaLettureSuccessive()
    {
        $lettureSuccessive = self::where('contatore_id', $this->contatore_id)
                                ->where('data_lettura', '>', $this->data_lettura)
                                ->orderBy('data_lettura', 'asc')
                                ->get();

        $letturaPrec = $this->lettura_attuale;
        
        foreach ($lettureSuccessive as $lettura) {
            $lettura->update(['lettura_precedente' => $letturaPrec]);
            $letturaPrec = $lettura->lettura_attuale;
        }
    }
}

3. Modello Ripartizione Consumi

// app/Models/RipartizioneConsumi.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class RipartizioneConsumi extends Model
{
    protected $table = 'ripartizione_consumi';

    protected $fillable = [
        'stabile_id', 'periodo_riferimento', 'tipo_consumo', 'modalita_ripartizione',
        'tabella_millesimale_id', 'consumo_totale_periodo', 'importo_totale_periodo',
        'consumo_generale', 'consumo_individuale_totale', 'ripartizione_perdite',
        'quota_fissa_percentuale', 'quota_variabile_percentuale', 'stato_ripartizione',
        'note_ripartizione', 'creata_da'
    ];

    protected $casts = [
        'consumo_totale_periodo' => 'decimal:3',
        'importo_totale_periodo' => 'decimal:2',
        'consumo_generale' => 'decimal:3',
        'consumo_individuale_totale' => 'decimal:3',
        'quota_fissa_percentuale' => 'decimal:2',
        'quota_variabile_percentuale' => 'decimal:2',
        'data_calcolo' => 'datetime',
        'data_conferma' => 'datetime'
    ];

    // Relazioni
    public function stabile()
    {
        return $this->belongsTo(Stabile::class);
    }

    public function tabellaMillesimale()
    {
        return $this->belongsTo(TabellaMillesimale::class);
    }

    public function dettagliRipartizione()
    {
        return $this->hasMany(DettaglioRipartizioneConsumi::class);
    }

    public function calcolataDa()
    {
        return $this->belongsTo(User::class, 'calcolata_da');
    }

    public function confermataDa()
    {
        return $this->belongsTo(User::class, 'confermata_da');
    }

    // Scopes
    public function scopePerStabile($query, $stabileId)
    {
        return $query->where('stabile_id', $stabileId);
    }

    public function scopePerPeriodo($query, $periodo)
    {
        return $query->where('periodo_riferimento', $periodo);
    }

    public function scopePerTipo($query, $tipo)
    {
        return $query->where('tipo_consumo', $tipo);
    }

    public function scopePerStato($query, $stato)
    {
        return $query->where('stato_ripartizione', $stato);
    }

    // Metodi principali
    public function calcolaRipartizione()
    {
        if ($this->stato_ripartizione !== 'bozza') {
            throw new \Exception('La ripartizione non è in stato bozza');
        }

        switch ($this->modalita_ripartizione) {
            case 'contatori_individuali':
                return $this->calcolaRipartizioneContatori();
            case 'millesimi_standard':
                return $this->calcolaRipartizioneMillesimi();
            case 'millesimi_personalizzati':
                return $this->calcolaRipartizioneMillesimiPersonalizzati();
            case 'misto':
                return $this->calcolaRipartizioneMista();
            default:
                throw new \Exception('Modalità di ripartizione non supportata');
        }
    }

    private function calcolaRipartizioneContatori()
    {
        $unitaImmobiliari = $this->stabile->unitaImmobiliari()
                                         ->with(['contatori' => function($query) {
                                             $query->where('tipo_contatore', $this->tipo_consumo)
                                                   ->where('attivo', true);
                                         }])
                                         ->get();

        $dettagli = [];
        $consumoTotaleIndividuale = 0;

        foreach ($unitaImmobiliari as $unita) {
            $contatore = $unita->contatori->first();
            $consumoUnita = 0;

            if ($contatore) {
                $lettura = $contatore->lettureContatori()
                                   ->where('periodo_riferimento', $this->periodo_riferimento)
                                   ->where('validata', true)
                                   ->first();

                if ($lettura) {
                    $consumoUnita = $lettura->consumo_finale;
                    $consumoTotaleIndividuale += $consumoUnita;
                }
            }

            $dettagli[] = [
                'unita_immobiliare_id' => $unita->id,
                'contatore_id' => $contatore?->id,
                'consumo_individuale' => $consumoUnita
            ];
        }

        // Calcola perdite da ripartire
        $perdite = $this->consumo_generale - $consumoTotaleIndividuale;

        // Ripartisce perdite e quote fisse
        foreach ($dettagli as &$dettaglio) {
            $unita = $unitaImmobiliari->find($dettaglio['unita_immobiliare_id']);
            
            // Quota fissa (se abilitata)
            if ($this->quota_fissa_percentuale > 0) {
                $quotaFissa = $this->calcolaQuotaFissa($unita);
                $dettaglio['consumo_quota_fissa'] = $quotaFissa;
            }

            // Ripartizione perdite
            if ($perdite > 0) {
                $dettaglio['consumo_perdite'] = $this->calcolaPerdite($unita, $perdite, $consumoTotaleIndividuale);
            }

            // Calcola importi
            $dettaglio['importo_quota_fissa'] = $this->calcolaImporto($dettaglio['consumo_quota_fissa'] ?? 0);
            $dettaglio['importo_quota_variabile'] = $this->calcolaImporto($dettaglio['consumo_individuale']);
            $dettaglio['importo_perdite'] = $this->calcolaImporto($dettaglio['consumo_perdite'] ?? 0);
        }

        // Salva dettagli
        $this->dettagliRipartizione()->delete();
        foreach ($dettagli as $dettaglio) {
            $this->dettagliRipartizione()->create($dettaglio);
        }

        $this->update([
            'consumo_individuale_totale' => $consumoTotaleIndividuale,
            'stato_ripartizione' => 'calcolata',
            'data_calcolo' => now(),
            'calcolata_da' => auth()->id()
        ]);

        return $this;
    }

    private function calcolaQuotaFissa($unita)
    {
        if (!$this->tabellaMillesimale) {
            return 0;
        }

        $millesimi = $this->tabellaMillesimale->dettagliTabellaMillesimale()
                                             ->where('unita_immobiliare_id', $unita->id)
                                             ->first();

        if (!$millesimi) {
            return 0;
        }

        $consumoQuotaFissa = $this->consumo_totale_periodo * ($this->quota_fissa_percentuale / 100);
        return $consumoQuotaFissa * ($millesimi->millesimi / 1000);
    }

    private function calcolaPerdite($unita, $perdite, $consumoTotaleIndividuale)
    {
        switch ($this->ripartizione_perdite) {
            case 'proporzionale':
                if ($consumoTotaleIndividuale > 0) {
                    $dettaglio = $this->dettagliRipartizione()->where('unita_immobiliare_id', $unita->id)->first();
                    return $perdite * ($dettaglio['consumo_individuale'] / $consumoTotaleIndividuale);
                }
                return 0;

            case 'millesimi':
                if (!$this->tabellaMillesimale) {
                    return 0;
                }
                $millesimi = $this->tabellaMillesimale->dettagliTabellaMillesimale()
                                                     ->where('unita_immobiliare_id', $unita->id)
                                                     ->first();
                return $perdite * ($millesimi->millesimi / 1000);

            case 'fissa':
                $numeroUnita = $this->stabile->unitaImmobiliari()->count();
                return $perdite / $numeroUnita;

            case 'escludi':
            default:
                return 0;
        }
    }

    private function calcolaImporto($consumo)
    {
        if ($this->consumo_totale_periodo > 0) {
            return ($consumo / $this->consumo_totale_periodo) * $this->importo_totale_periodo;
        }
        return 0;
    }

    public function confermaRipartizione()
    {
        if ($this->stato_ripartizione !== 'calcolata') {
            throw new \Exception('La ripartizione deve essere calcolata prima di essere confermata');
        }

        $this->update([
            'stato_ripartizione' => 'confermata',
            'data_conferma' => now(),
            'confermata_da' => auth()->id()
        ]);

        return $this;
    }

    public function annullaRipartizione()
    {
        $this->update([
            'stato_ripartizione' => 'bozza',
            'data_calcolo' => null,
            'calcolata_da' => null,
            'data_conferma' => null,
            'confermata_da' => null
        ]);

        $this->dettagliRipartizione()->delete();
        
        return $this;
    }

    // Accessors
    public function getDifferenzaConsumoAttribute()
    {
        return $this->consumo_generale - $this->consumo_individuale_totale;
    }

    public function getPercentualePerditeAttribute()
    {
        if ($this->consumo_generale > 0) {
            return (($this->consumo_generale - $this->consumo_individuale_totale) / $this->consumo_generale) * 100;
        }
        return 0;
    }

    // Metodi statici
    public static function getTipiConsumo()
    {
        return [
            'acqua_fredda' => 'Acqua Fredda',
            'acqua_calda' => 'Acqua Calda',
            'riscaldamento' => 'Riscaldamento',
            'gas' => 'Gas',
            'energia_elettrica' => 'Energia Elettrica'
        ];
    }

    public static function getModalitaRipartizione()
    {
        return [
            'contatori_individuali' => 'Contatori Individuali',
            'millesimi_standard' => 'Millesimi Standard',
            'millesimi_personalizzati' => 'Millesimi Personalizzati',
            'misto' => 'Modalità Mista'
        ];
    }

    public static function getStatiRipartizione()
    {
        return [
            'bozza' => 'Bozza',
            'calcolata' => 'Calcolata',
            'verificata' => 'Verificata',
            'confermata' => 'Confermata',
            'fatturata' => 'Fatturata'
        ];
    }
}

🔧 SERVIZI SPECIALIZZATI

1. Servizio Import Letture

// app/Services/ImportLettureService.php
<?php

namespace App\Services;

use App\Models\LetturaContatore;
use App\Models\Contatore;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\IOFactory;

class ImportLettureService
{
    public function importaDaExcel($filePath, $stabileId, $periodo)
    {
        $spreadsheet = IOFactory::load($filePath);
        $worksheet = $spreadsheet->getActiveSheet();
        $batchId = uniqid('import_');
        
        DB::beginTransaction();
        
        try {
            $righe = $worksheet->toArray();
            $header = array_shift($righe); // Rimuove intestazione
            
            foreach ($righe as $indice => $riga) {
                if (empty($riga[0])) continue; // Skip righe vuote
                
                $this->processaRigaImport($riga, $stabileId, $periodo, $batchId, $indice + 2);
            }
            
            DB::commit();
            return ['success' => true, 'batch_id' => $batchId];
            
        } catch (\Exception $e) {
            DB::rollBack();
            return ['success' => false, 'error' => $e->getMessage()];
        }
    }

    private function processaRigaImport($riga, $stabileId, $periodo, $batchId, $numeroRiga)
    {
        $codiceContatore = $riga[0];
        $letturaAttuale = (float) $riga[1];
        $dataLettura = $riga[2];
        $note = $riga[3] ?? '';
        
        $contatore = Contatore::where('stabile_id', $stabileId)
                             ->where('codice_contatore', $codiceContatore)
                             ->first();
        
        if (!$contatore) {
            throw new \Exception("Contatore {$codiceContatore} non trovato alla riga {$numeroRiga}");
        }
        
        // Verifica se lettura già esistente
        $esistente = LetturaContatore::where('contatore_id', $contatore->id)
                                   ->where('periodo_riferimento', $periodo)
                                   ->first();
        
        if ($esistente) {
            // Aggiorna lettura esistente
            $esistente->update([
                'lettura_attuale' => $letturaAttuale,
                'data_lettura' => $dataLettura,
                'tipo_lettura' => 'import_ditta',
                'note_lettura' => $note,
                'import_batch_id' => $batchId,
                'import_file_name' => basename($filePath),
                'import_riga_numero' => $numeroRiga
            ]);
        } else {
            // Crea nuova lettura
            LetturaContatore::create([
                'contatore_id' => $contatore->id,
                'stabile_id' => $stabileId,
                'periodo_riferimento' => $periodo,
                'data_lettura' => $dataLettura,
                'lettura_attuale' => $letturaAttuale,
                'tipo_lettura' => 'import_ditta',
                'note_lettura' => $note,
                'import_batch_id' => $batchId,
                'import_file_name' => basename($filePath),
                'import_riga_numero' => $numeroRiga,
                'inserita_da' => auth()->id()
            ]);
        }
    }

    public function validaBatchImport($batchId)
    {
        $letture = LetturaContatore::where('import_batch_id', $batchId)->get();
        
        foreach ($letture as $lettura) {
            // Validazioni automatiche
            if ($this->validaLettura($lettura)) {
                $lettura->valida();
            }
        }
        
        return $letture->count();
    }

    private function validaLettura($lettura)
    {
        // Controlla se consumo è ragionevole
        $consumo = $lettura->consumo_calcolato;
        $media = $lettura->contatore->getConsumoMedio();
        
        if ($media > 0) {
            $variazione = abs($consumo - $media) / $media;
            if ($variazione > 0.5) { // Variazione > 50%
                return false;
            }
        }
        
        // Controlla se lettura è crescente
        if ($lettura->lettura_attuale < $lettura->lettura_precedente) {
            return false;
        }
        
        return true;
    }
}

2. Servizio Calcolo Ripartizione

// app/Services/CalcoloRipartizioneService.php
<?php

namespace App\Services;

use App\Models\RipartizioneConsumi;
use App\Models\DettaglioRipartizioneConsumi;
use App\Models\ParametriCalcoloConsumi;

class CalcoloRipartizioneService
{
    public function creaRipartizione($stabileId, $periodo, $tipoConsumo, $parametri)
    {
        $ripartizione = RipartizioneConsumi::create([
            'stabile_id' => $stabileId,
            'periodo_riferimento' => $periodo,
            'tipo_consumo' => $tipoConsumo,
            'modalita_ripartizione' => $parametri['modalita_ripartizione'],
            'tabella_millesimale_id' => $parametri['tabella_millesimale_id'] ?? null,
            'consumo_totale_periodo' => $parametri['consumo_totale'],
            'importo_totale_periodo' => $parametri['importo_totale'],
            'consumo_generale' => $parametri['consumo_generale'] ?? 0,
            'ripartizione_perdite' => $parametri['ripartizione_perdite'] ?? 'proporzionale',
            'quota_fissa_percentuale' => $parametri['quota_fissa_percentuale'] ?? 0,
            'quota_variabile_percentuale' => $parametri['quota_variabile_percentuale'] ?? 100,
            'creata_da' => auth()->id()
        ]);

        return $ripartizione;
    }

    public function calcolaAnteprimaRipartizione($stabileId, $periodo, $tipoConsumo, $parametri)
    {
        // Crea ripartizione temporanea senza salvare
        $ripartizione = new RipartizioneConsumi([
            'stabile_id' => $stabileId,
            'periodo_riferimento' => $periodo,
            'tipo_consumo' => $tipoConsumo,
            'modalita_ripartizione' => $parametri['modalita_ripartizione'],
            'tabella_millesimale_id' => $parametri['tabella_millesimale_id'] ?? null,
            'consumo_totale_periodo' => $parametri['consumo_totale'],
            'importo_totale_periodo' => $parametri['importo_totale'],
            'consumo_generale' => $parametri['consumo_generale'] ?? 0,
            'ripartizione_perdite' => $parametri['ripartizione_perdite'] ?? 'proporzionale',
            'quota_fissa_percentuale' => $parametri['quota_fissa_percentuale'] ?? 0,
            'quota_variabile_percentuale' => $parametri['quota_variabile_percentuale'] ?? 100
        ]);

        // Simula calcolo senza salvare
        return $this->simulaCalcoloRipartizione($ripartizione);
    }

    private function simulaCalcoloRipartizione($ripartizione)
    {
        // Implementa logica di simulazione simile a calcolaRipartizione
        // ma senza salvare i dati, solo per anteprima
        
        $anteprima = [];
        // ... logica di calcolo ...
        
        return $anteprima;
    }

    public function verificaCoerenzaRipartizione($ripartizioneId)
    {
        $ripartizione = RipartizioneConsumi::find($ripartizioneId);
        $dettagli = $ripartizione->dettagliRipartizione;
        
        $verifiche = [
            'totale_consumo_coerente' => $this->verificaTotaleConsumo($ripartizione, $dettagli),
            'totale_importo_coerente' => $this->verificaTotaleImporto($ripartizione, $dettagli),
            'millesimi_bilanciati' => $this->verificaMillesimi($ripartizione, $dettagli),
            'perdite_ragionevoli' => $this->verificaPerdite($ripartizione)
        ];
        
        return $verifiche;
    }

    private function verificaTotaleConsumo($ripartizione, $dettagli)
    {
        $totaleCalcolato = $dettagli->sum('consumo_totale');
        $differenza = abs($totaleCalcolato - $ripartizione->consumo_totale_periodo);
        
        return $differenza < 0.01; // Tolleranza 0.01 m³
    }

    private function verificaTotaleImporto($ripartizione, $dettagli)
    {
        $totaleCalcolato = $dettagli->sum('importo_totale');
        $differenza = abs($totaleCalcolato - $ripartizione->importo_totale_periodo);
        
        return $differenza < 0.01; // Tolleranza 1 centesimo
    }

    private function verificaMillesimi($ripartizione, $dettagli)
    {
        if (!$ripartizione->tabellaMillesimale) {
            return true;
        }
        
        $totaleMillesimi = $dettagli->sum('millesimi_quota_fissa') + 
                          $dettagli->sum('millesimi_quota_variabile') + 
                          $dettagli->sum('millesimi_perdite');
        
        return abs($totaleMillesimi - 1000) < 0.01;
    }

    private function verificaPerdite($ripartizione)
    {
        $percentualePerdite = $ripartizione->percentuale_perdite;
        
        // Considera ragionevoli perdite fino al 15%
        return $percentualePerdite <= 15;
    }
}

📊 DASHBOARD E REPORTING

1. Dashboard Consumi

// app/Services/DashboardConsumiService.php
<?php

namespace App\Services;

use App\Models\Contatore;
use App\Models\LetturaContatore;
use App\Models\RipartizioneConsumi;
use Carbon\Carbon;

class DashboardConsumiService
{
    public function getDashboardData($stabileId, $periodo = null)
    {
        $periodo = $periodo ?? Carbon::now()->format('Y-m');
        
        return [
            'riepilogo_consumi' => $this->getRiepilogoConsumi($stabileId, $periodo),
            'grafici_andamento' => $this->getGraficiAndamento($stabileId),
            'alert_anomalie' => $this->getAlertAnomalie($stabileId, $periodo),
            'statistiche_perdite' => $this->getStatistichePerdite($stabileId),
            'scadenze_letture' => $this->getScadenzeLettureAmministratore($stabileId)
        ];
    }

    private function getRiepilogoConsumi($stabileId, $periodo)
    {
        $tipiConsumo = ['acqua_fredda', 'acqua_calda', 'riscaldamento', 'gas', 'energia_elettrica'];
        $riepilogo = [];
        
        foreach ($tipiConsumo as $tipo) {
            $ripartizione = RipartizioneConsumi::where('stabile_id', $stabileId)
                                             ->where('periodo_riferimento', $periodo)
                                             ->where('tipo_consumo', $tipo)
                                             ->first();
            
            if ($ripartizione) {
                $riepilogo[$tipo] = [
                    'consumo_totale' => $ripartizione->consumo_totale_periodo,
                    'importo_totale' => $ripartizione->importo_totale_periodo,
                    'perdite_percentuale' => $ripartizione->percentuale_perdite,
                    'stato' => $ripartizione->stato_ripartizione
                ];
            }
        }
        
        return $riepilogo;
    }

    private function getGraficiAndamento($stabileId)
    {
        $ultimi12Mesi = [];
        
        for ($i = 11; $i >= 0; $i--) {
            $data = Carbon::now()->subMonths($i);
            $periodo = $data->format('Y-m');
            
            $ripartizioni = RipartizioneConsumi::where('stabile_id', $stabileId)
                                             ->where('periodo_riferimento', $periodo)
                                             ->get();
            
            $ultimi12Mesi[] = [
                'periodo' => $periodo,
                'mese_nome' => $data->format('M Y'),
                'consumi' => $ripartizioni->mapWithKeys(function($r) {
                    return [$r->tipo_consumo => $r->consumo_totale_periodo];
                })->toArray()
            ];
        }
        
        return $ultimi12Mesi;
    }

    private function getAlertAnomalie($stabileId, $periodo)
    {
        $alert = [];
        
        // Cerca letture con variazioni anomale
        $lettureAnomale = LetturaContatore::whereHas('contatore', function($q) use ($stabileId) {
                                                $q->where('stabile_id', $stabileId);
                                            })
                                            ->where('periodo_riferimento', $periodo)
                                            ->get()
                                            ->filter(function($lettura) {
                                                $variazione = $lettura->calcolaVariazionePercentuale();
                                                return $variazione !== null && abs($variazione) > 50;
                                            });
        
        foreach ($lettureAnomale as $lettura) {
            $alert[] = [
                'tipo' => 'variazione_anomala',
                'messaggio' => "Variazione anomala contatore {$lettura->contatore->codice_contatore}",
                'dettagli' => "Variazione: " . round($lettura->calcolaVariazionePercentuale(), 1) . "%"
            ];
        }
        
        // Cerca perdite eccessive
        $ripartizioniPerdite = RipartizioneConsumi::where('stabile_id', $stabileId)
                                                 ->where('periodo_riferimento', $periodo)
                                                 ->get()
                                                 ->filter(function($r) {
                                                     return $r->percentuale_perdite > 15;
                                                 });
        
        foreach ($ripartizioniPerdite as $ripartizione) {
            $alert[] = [
                'tipo' => 'perdite_eccessive',
                'messaggio' => "Perdite eccessive per {$ripartizione->getTipoConsumo()}",
                'dettagli' => "Perdite: " . round($ripartizione->percentuale_perdite, 1) . "%"
            ];
        }
        
        return $alert;
    }

    private function getStatistichePerdite($stabileId)
    {
        $ultimi6Mesi = Carbon::now()->subMonths(6)->format('Y-m');
        
        return RipartizioneConsumi::where('stabile_id', $stabileId)
                                 ->where('periodo_riferimento', '>=', $ultimi6Mesi)
                                 ->selectRaw('tipo_consumo, AVG(CASE WHEN consumo_generale > 0 THEN ((consumo_generale - consumo_individuale_totale) / consumo_generale) * 100 ELSE 0 END) as media_perdite')
                                 ->groupBy('tipo_consumo')
                                 ->get()
                                 ->mapWithKeys(function($item) {
                                     return [$item->tipo_consumo => round($item->media_perdite, 2)];
                                 });
    }

    private function getScadenzeLettureAmministratore($stabileId)
    {
        $prossimoMese = Carbon::now()->addMonth()->format('Y-m');
        
        return Contatore::where('stabile_id', $stabileId)
                       ->where('attivo', true)
                       ->whereNotExists(function($query) use ($prossimoMese) {
                           $query->select('id')
                                 ->from('letture_contatori')
                                 ->whereColumn('contatore_id', 'contatori.id')
                                 ->where('periodo_riferimento', $prossimoMese);
                       })
                       ->count();
    }
}

🎯 CHECKLIST IMPLEMENTAZIONE

Database

  • Migration tabelle contatori
  • Migration letture contatori
  • Migration ripartizione consumi
  • Migration dettaglio ripartizione
  • Migration parametri calcolo
  • Seed dati di test

Modelli Eloquent

  • Modello Contatore con relazioni
  • Modello LetturaContatore con metodi calcolo
  • Modello RipartizioneConsumi con logica business
  • Modello DettaglioRipartizioneConsumi
  • Modello ParametriCalcoloConsumi

Servizi

  • ImportLettureService per Excel/CSV
  • CalcoloRipartizioneService per algoritmi
  • DashboardConsumiService per report
  • NotificazioneService per alert

Controller e API

  • Controller per gestione contatori
  • Controller per inserimento letture
  • Controller per calcolo ripartizione
  • API per dashboard e grafici

Frontend

  • Interfaccia gestione contatori
  • Wizard inserimento letture
  • Wizard calcolo ripartizione
  • Dashboard consumi con grafici

Aggiornato: 2025-01-27
Versione: 1.0
Prossimo step: Implementazione migration e modelli base per consumi