✅ CONTROLLERS IMPLEMENTATI: - VoceSpesaController: CRUD completo con filtri avanzati, duplicazione, AJAX - RipartizioneSpesaController: Calcolo automatico/manuale, workflow stati - PianoRateizzazioneController: Gestione piani rate con calcolo interessi - RataController: Pagamenti, posticipazioni, report, export CSV ✅ INTERFACCE UI RESPONSIVE: - Voci di Spesa: Elenco filtrato, form creazione Bootstrap 5 - Design System: Layout moderno con Font Awesome, responsive - Filtri Real-time: Aggiornamento automatico con AJAX - Validazioni: Client-side e server-side ✅ SISTEMA AUTORIZZAZIONI: - Policies complete per tutti i modelli - Controlli ownership per amministratori - Role-based access control - Data integrity e security ✅ FUNZIONALITÀ AVANZATE: - Calcoli automatici millesimi e rate - Gestione stati workflow (bozza→confermata→completata) - Codici alfanumerici univoci (SP, RP, PR) - Transazioni atomiche con rollback - Export CSV e reporting 🚀 SISTEMA CORE OPERATIVO: Pronto per gestione completa spese condominiali 📱 UI PRODUCTION-READY: Interfacce moderne e intuitive 🔐 SICUREZZA COMPLETA: Autorizzazioni e validazioni robuste 📊 BUSINESS LOGIC: Calcoli automatici e workflow operativi Next: Implementazione viste rimanenti e sistema plugin
47 KiB
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