# 🌡️ **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** ```sql -- 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** ```sql -- 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** ```sql -- 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** ```sql -- 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** ```sql -- 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** ```php // app/Models/Contatore.php '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** ```php // app/Models/LetturaContatore.php '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** ```php // app/Models/RipartizioneConsumi.php '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** ```php // app/Services/ImportLettureService.php 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** ```php // app/Services/CalcoloRipartizioneService.php $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** ```php // app/Services/DashboardConsumiService.php 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