diff --git a/CONSUMI_WATER_HEATING_SYSTEM.md b/CONSUMI_WATER_HEATING_SYSTEM.md
new file mode 100644
index 00000000..591cca74
--- /dev/null
+++ b/CONSUMI_WATER_HEATING_SYSTEM.md
@@ -0,0 +1,1382 @@
+# π‘οΈ **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
diff --git a/DOCUMENT_MANAGEMENT_SYSTEM.md b/DOCUMENT_MANAGEMENT_SYSTEM.md
new file mode 100644
index 00000000..86b0a091
--- /dev/null
+++ b/DOCUMENT_MANAGEMENT_SYSTEM.md
@@ -0,0 +1,766 @@
+# π **NetGesCon Laravel - Sistema Gestione Documentale**
+
+## π **OVERVIEW GENERALE**
+Sistema integrato per gestione documenti condominiali con supporto per archiviazione locale, cloud (Office 365, Google Drive) e audit documentale completo.
+
+---
+
+## ποΈ **ARCHITETTURA ARCHIVIAZIONE DOCUMENTI**
+
+### **1. Struttura Database Documenti**
+
+#### **A. Tabella Principale Documenti**
+```sql
+-- Migration: create_documenti_table.php
+CREATE TABLE documenti (
+ id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
+ stabile_id BIGINT UNSIGNED NOT NULL,
+ amministratore_id BIGINT UNSIGNED NOT NULL,
+ categoria_documento ENUM('contratto', 'fattura', 'verbale', 'corrispondenza', 'tecnico', 'legale', 'assicurazione', 'altro') NOT NULL,
+ tipo_documento VARCHAR(100) NOT NULL,
+ titolo VARCHAR(255) NOT NULL,
+ descrizione TEXT,
+ nome_file VARCHAR(255) NOT NULL,
+ nome_file_originale VARCHAR(255) NOT NULL,
+ path_relativo VARCHAR(500) NOT NULL,
+ path_assoluto VARCHAR(1000) NOT NULL,
+ dimensione_file BIGINT UNSIGNED NOT NULL,
+ mime_type VARCHAR(100) NOT NULL,
+ hash_file VARCHAR(64) NOT NULL, -- SHA256 per integritΓ
+ stato_documento ENUM('bozza', 'attivo', 'archiviato', 'eliminato') DEFAULT 'attivo',
+ data_documento DATE,
+ data_scadenza DATE NULL,
+ numero_protocollo VARCHAR(50) NULL,
+ anno_protocollo YEAR NULL,
+ note_interne TEXT,
+ metadati_personalizzati JSON,
+ visibilita ENUM('privato', 'amministratore', 'condomini', 'pubblico') DEFAULT 'amministratore',
+ -- Audit fields
+ caricato_da BIGINT UNSIGNED NOT NULL,
+ modificato_da BIGINT UNSIGNED NULL,
+ verificato_da BIGINT UNSIGNED NULL,
+ verificato_at TIMESTAMP NULL,
+ -- Cloud sync
+ sincronizzato_cloud BOOLEAN DEFAULT FALSE,
+ cloud_provider ENUM('office365', 'google_drive', 'dropbox') NULL,
+ cloud_file_id VARCHAR(255) NULL,
+ cloud_ultimo_sync TIMESTAMP NULL,
+ -- Timestamps
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ deleted_at TIMESTAMP NULL,
+
+ -- Indexes
+ INDEX idx_stabile_categoria (stabile_id, categoria_documento),
+ INDEX idx_data_documento (data_documento),
+ INDEX idx_data_scadenza (data_scadenza),
+ INDEX idx_stato (stato_documento),
+ INDEX idx_hash (hash_file),
+ INDEX idx_protocollo (numero_protocollo, anno_protocollo),
+
+ -- Foreign Keys
+ FOREIGN KEY (stabile_id) REFERENCES stabili(id) ON DELETE CASCADE,
+ FOREIGN KEY (amministratore_id) REFERENCES amministratori(id) ON DELETE CASCADE,
+ FOREIGN KEY (caricato_da) REFERENCES users(id),
+ FOREIGN KEY (modificato_da) REFERENCES users(id),
+ FOREIGN KEY (verificato_da) REFERENCES users(id)
+);
+```
+
+#### **B. Tabella Collegamenti Documenti**
+```sql
+-- Per collegare documenti a voci di spesa, ripartizioni, etc.
+CREATE TABLE collegamenti_documenti (
+ id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
+ documento_id BIGINT UNSIGNED NOT NULL,
+ entita_tipo VARCHAR(50) NOT NULL, -- 'voce_spesa', 'ripartizione_spese', 'rata', etc.
+ entita_id BIGINT UNSIGNED NOT NULL,
+ tipo_collegamento ENUM('supporto', 'fattura', 'ricevuta', 'autorizzazione', 'altro') NOT NULL,
+ note VARCHAR(255),
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+
+ INDEX idx_documento (documento_id),
+ INDEX idx_entita (entita_tipo, entita_id),
+
+ FOREIGN KEY (documento_id) REFERENCES documenti(id) ON DELETE CASCADE
+);
+```
+
+#### **C. Tabella Versioni Documenti**
+```sql
+CREATE TABLE versioni_documenti (
+ id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
+ documento_id BIGINT UNSIGNED NOT NULL,
+ numero_versione INT NOT NULL DEFAULT 1,
+ nome_file VARCHAR(255) NOT NULL,
+ path_relativo VARCHAR(500) NOT NULL,
+ dimensione_file BIGINT UNSIGNED NOT NULL,
+ hash_file VARCHAR(64) NOT NULL,
+ modifiche_descrizione TEXT,
+ creato_da BIGINT UNSIGNED NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+
+ INDEX idx_documento_versione (documento_id, numero_versione),
+
+ FOREIGN KEY (documento_id) REFERENCES documenti(id) ON DELETE CASCADE,
+ FOREIGN KEY (creato_da) REFERENCES users(id)
+);
+```
+
+---
+
+## ποΈ **STRUTTURA CARTELLE FISICHE**
+
+### **1. Organizzazione Filesystem**
+```
+storage/app/
+βββ documenti/
+β βββ amministratore_{id}/
+β β βββ stabile_{id}/
+β β β βββ {anno}/
+β β β β βββ contratti/
+β β β β βββ fatture/
+β β β β β βββ {mese}/
+β β β β β β βββ fornitori/
+β β β β β β βββ utenze/
+β β β β βββ verbali/
+β β β β β βββ assemblee/
+β β β β β βββ consiglio/
+β β β β βββ corrispondenza/
+β β β β β βββ ingresso/
+β β β β β βββ uscita/
+β β β β βββ tecnici/
+β β β β β βββ progetti/
+β β β β β βββ certificati/
+β β β β β βββ collaudi/
+β β β β βββ legali/
+β β β β βββ assicurazioni/
+β β β β βββ altro/
+β β β βββ versioni/
+β β β βββ {documento_id}/
+β β βββ backup/
+β β βββ {data}/
+β βββ templates/
+β βββ contratti/
+β βββ lettere/
+β βββ verbali/
+```
+
+### **2. Naming Convention**
+```
+Formato: {YYYY}{MM}{DD}_{categoria}_{progressivo}_{descrizione_breve}.{ext}
+Esempio: 20250127_fattura_001_enel_energia_elettrica.pdf
+ 20250127_verbale_001_assemblea_ordinaria.pdf
+ 20250127_contratto_001_pulizie_ditta_abc.pdf
+```
+
+---
+
+## π **MODELLO ELOQUENT E SERVIZI**
+
+### **1. Modello Documento**
+```php
+// app/Models/Documento.php
+ 'date',
+ 'data_scadenza' => 'date',
+ 'metadati_personalizzati' => 'array',
+ 'sincronizzato_cloud' => 'boolean',
+ 'verificato_at' => 'datetime',
+ 'cloud_ultimo_sync' => 'datetime'
+ ];
+
+ // Relazioni
+ public function stabile()
+ {
+ return $this->belongsTo(Stabile::class);
+ }
+
+ public function amministratore()
+ {
+ return $this->belongsTo(Amministratore::class);
+ }
+
+ public function caricatoDa()
+ {
+ return $this->belongsTo(User::class, 'caricato_da');
+ }
+
+ public function modificatoDa()
+ {
+ return $this->belongsTo(User::class, 'modificato_da');
+ }
+
+ public function verificatoDa()
+ {
+ return $this->belongsTo(User::class, 'verificato_da');
+ }
+
+ public function versioni()
+ {
+ return $this->hasMany(VersioneDocumento::class);
+ }
+
+ public function collegamenti()
+ {
+ return $this->hasMany(CollegamentoDocumento::class);
+ }
+
+ // Scopes
+ public function scopePerStabile($query, $stabileId)
+ {
+ return $query->where('stabile_id', $stabileId);
+ }
+
+ public function scopePerCategoria($query, $categoria)
+ {
+ return $query->where('categoria_documento', $categoria);
+ }
+
+ public function scopeInScadenza($query, $giorni = 30)
+ {
+ return $query->whereNotNull('data_scadenza')
+ ->whereBetween('data_scadenza', [
+ now()->toDateString(),
+ now()->addDays($giorni)->toDateString()
+ ]);
+ }
+
+ public function scopeAttivi($query)
+ {
+ return $query->where('stato_documento', 'attivo');
+ }
+
+ // Metodi utilitΓ
+ public function getPathCompletoAttribute()
+ {
+ return Storage::path($this->path_relativo);
+ }
+
+ public function getUrlDownloadAttribute()
+ {
+ return route('documenti.download', $this->id);
+ }
+
+ public function getDimensioneFormattataAttribute()
+ {
+ return $this->formatBytes($this->dimensione_file);
+ }
+
+ private function formatBytes($size)
+ {
+ $units = ['B', 'KB', 'MB', 'GB'];
+ $i = 0;
+ while ($size >= 1024 && $i < count($units) - 1) {
+ $size /= 1024;
+ $i++;
+ }
+ return round($size, 2) . ' ' . $units[$i];
+ }
+
+ public function verificaIntegrita()
+ {
+ if (!Storage::exists($this->path_relativo)) {
+ return false;
+ }
+
+ $hashCorrente = hash_file('sha256', Storage::path($this->path_relativo));
+ return $hashCorrente === $this->hash_file;
+ }
+
+ public function creaVersione($motivo = null)
+ {
+ return VersioneDocumento::create([
+ 'documento_id' => $this->id,
+ 'numero_versione' => $this->versioni()->max('numero_versione') + 1,
+ 'nome_file' => $this->nome_file,
+ 'path_relativo' => $this->path_relativo,
+ 'dimensione_file' => $this->dimensione_file,
+ 'hash_file' => $this->hash_file,
+ 'modifiche_descrizione' => $motivo,
+ 'creato_da' => auth()->id()
+ ]);
+ }
+
+ // Categorie statiche
+ public static function getCategorie()
+ {
+ return [
+ 'contratto' => 'Contratti',
+ 'fattura' => 'Fatture',
+ 'verbale' => 'Verbali',
+ 'corrispondenza' => 'Corrispondenza',
+ 'tecnico' => 'Documenti Tecnici',
+ 'legale' => 'Documenti Legali',
+ 'assicurazione' => 'Assicurazioni',
+ 'altro' => 'Altro'
+ ];
+ }
+
+ public static function getTipiVisibilita()
+ {
+ return [
+ 'privato' => 'Solo Amministratore',
+ 'amministratore' => 'Staff Amministrazione',
+ 'condomini' => 'Condomini',
+ 'pubblico' => 'Pubblico'
+ ];
+ }
+}
+```
+
+### **2. Servizio Gestione Documenti**
+```php
+// app/Services/DocumentoService.php
+validaFile($file);
+
+ // Genera path di destinazione
+ $pathRelativo = $this->generaPath($dati);
+
+ // Calcola hash per integritΓ
+ $hashFile = hash_file('sha256', $file->getPathname());
+
+ // Verifica duplicati
+ if ($this->verificaDuplicato($hashFile, $dati['stabile_id'])) {
+ throw new \Exception('Il documento Γ¨ giΓ presente nell\'archivio');
+ }
+
+ // Genera nome file univoco
+ $nomeFile = $this->generaNomeFile($file, $dati);
+ $pathCompleto = $pathRelativo . '/' . $nomeFile;
+
+ // Salva il file
+ $file->storeAs(dirname($pathCompleto), basename($pathCompleto));
+
+ // Crea record in database
+ return Documento::create([
+ 'stabile_id' => $dati['stabile_id'],
+ 'amministratore_id' => $dati['amministratore_id'],
+ 'categoria_documento' => $dati['categoria_documento'],
+ 'tipo_documento' => $dati['tipo_documento'],
+ 'titolo' => $dati['titolo'],
+ 'descrizione' => $dati['descrizione'] ?? null,
+ 'nome_file' => $nomeFile,
+ 'nome_file_originale' => $file->getClientOriginalName(),
+ 'path_relativo' => $pathCompleto,
+ 'path_assoluto' => Storage::path($pathCompleto),
+ 'dimensione_file' => $file->getSize(),
+ 'mime_type' => $file->getMimeType(),
+ 'hash_file' => $hashFile,
+ 'data_documento' => $dati['data_documento'] ?? now()->toDateString(),
+ 'data_scadenza' => $dati['data_scadenza'] ?? null,
+ 'numero_protocollo' => $dati['numero_protocollo'] ?? null,
+ 'anno_protocollo' => $dati['anno_protocollo'] ?? now()->year,
+ 'note_interne' => $dati['note_interne'] ?? null,
+ 'metadati_personalizzati' => $dati['metadati'] ?? [],
+ 'visibilita' => $dati['visibilita'] ?? 'amministratore',
+ 'caricato_da' => auth()->id()
+ ]);
+ }
+
+ private function generaPath(array $dati)
+ {
+ $anno = $dati['anno'] ?? now()->year;
+ $mese = $dati['mese'] ?? now()->format('m');
+
+ return "documenti/amministratore_{$dati['amministratore_id']}/stabile_{$dati['stabile_id']}/{$anno}/{$dati['categoria_documento']}" .
+ ($dati['categoria_documento'] === 'fattura' ? "/{$mese}" : '');
+ }
+
+ private function generaNomeFile(UploadedFile $file, array $dati)
+ {
+ $data = now()->format('Ymd');
+ $categoria = $dati['categoria_documento'];
+ $progressivo = $this->getProgressivo($dati['stabile_id'], $categoria);
+ $descrizione = Str::slug($dati['descrizione_breve'] ?? 'documento');
+ $estensione = $file->getClientOriginalExtension();
+
+ return "{$data}_{$categoria}_{$progressivo}_{$descrizione}.{$estensione}";
+ }
+
+ private function getProgressivo($stabileId, $categoria)
+ {
+ $ultimo = Documento::where('stabile_id', $stabileId)
+ ->where('categoria_documento', $categoria)
+ ->whereDate('created_at', now()->toDateString())
+ ->count();
+
+ return str_pad($ultimo + 1, 3, '0', STR_PAD_LEFT);
+ }
+
+ private function validaFile(UploadedFile $file)
+ {
+ $tipiConsentiti = [
+ 'application/pdf',
+ 'image/jpeg',
+ 'image/png',
+ 'application/msword',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'application/vnd.ms-excel',
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
+ ];
+
+ if (!in_array($file->getMimeType(), $tipiConsentiti)) {
+ throw new \Exception('Tipo di file non consentito');
+ }
+
+ if ($file->getSize() > 50 * 1024 * 1024) { // 50MB
+ throw new \Exception('File troppo grande (max 50MB)');
+ }
+ }
+
+ private function verificaDuplicato($hash, $stabileId)
+ {
+ return Documento::where('hash_file', $hash)
+ ->where('stabile_id', $stabileId)
+ ->where('stato_documento', 'attivo')
+ ->exists();
+ }
+}
+```
+
+---
+
+## βοΈ **INTEGRAZIONE CLOUD STORAGE**
+
+### **1. Servizio Office 365**
+```php
+// app/Services/Office365Service.php
+graph = new Graph();
+ $this->graph->setAccessToken($this->getAccessToken());
+ }
+
+ public function sincronizzaDocumento(Documento $documento)
+ {
+ try {
+ // Crea cartella se non esiste
+ $cartellaPadre = $this->creaCatellaStabile($documento->stabile);
+
+ // Upload del file
+ $fileContent = Storage::get($documento->path_relativo);
+ $uploadedFile = $this->graph->createRequest('PUT',
+ "/me/drive/items/{$cartellaPadre}/children/{$documento->nome_file}/content")
+ ->attachBody($fileContent)
+ ->execute();
+
+ // Aggiorna documento con info cloud
+ $documento->update([
+ 'sincronizzato_cloud' => true,
+ 'cloud_provider' => 'office365',
+ 'cloud_file_id' => $uploadedFile->getId(),
+ 'cloud_ultimo_sync' => now()
+ ]);
+
+ return true;
+ } catch (\Exception $e) {
+ \Log::error('Errore sincronizzazione Office 365: ' . $e->getMessage());
+ return false;
+ }
+ }
+
+ private function creaCatellaStabile($stabile)
+ {
+ $nomeCartella = "Stabile_{$stabile->codice}_{$stabile->denominazione}";
+
+ // Verifica se esiste
+ $folders = $this->graph->createRequest('GET',
+ "/me/drive/root/children?filter=name eq '{$nomeCartella}'")
+ ->execute();
+
+ if ($folders->getBody()['value']) {
+ return $folders->getBody()['value'][0]['id'];
+ }
+
+ // Crea nuova cartella
+ $folderData = [
+ 'name' => $nomeCartella,
+ 'folder' => new \stdClass()
+ ];
+
+ $newFolder = $this->graph->createRequest('POST', '/me/drive/root/children')
+ ->attachBody($folderData)
+ ->execute();
+
+ return $newFolder->getId();
+ }
+
+ private function getAccessToken()
+ {
+ // Implementa OAuth2 flow per Office 365
+ // Restituisce access token valido
+ }
+}
+```
+
+### **2. Servizio Google Drive**
+```php
+// app/Services/GoogleDriveService.php
+client = new Google_Client();
+ $this->client->setClientId(config('services.google.client_id'));
+ $this->client->setClientSecret(config('services.google.client_secret'));
+ $this->client->setRedirectUri(config('services.google.redirect_uri'));
+ $this->client->addScope(Google_Service_Drive::DRIVE);
+
+ $this->service = new Google_Service_Drive($this->client);
+ }
+
+ public function sincronizzaDocumento(Documento $documento)
+ {
+ try {
+ // Crea cartella se non esiste
+ $cartellaPadre = $this->creaCatellaStabile($documento->stabile);
+
+ // Prepara file per upload
+ $fileMetadata = new Google_Service_Drive_DriveFile([
+ 'name' => $documento->nome_file,
+ 'parents' => [$cartellaPadre]
+ ]);
+
+ $content = Storage::get($documento->path_relativo);
+
+ $file = $this->service->files->create($fileMetadata, [
+ 'data' => $content,
+ 'mimeType' => $documento->mime_type,
+ 'uploadType' => 'multipart'
+ ]);
+
+ // Aggiorna documento
+ $documento->update([
+ 'sincronizzato_cloud' => true,
+ 'cloud_provider' => 'google_drive',
+ 'cloud_file_id' => $file->id,
+ 'cloud_ultimo_sync' => now()
+ ]);
+
+ return true;
+ } catch (\Exception $e) {
+ \Log::error('Errore sincronizzazione Google Drive: ' . $e->getMessage());
+ return false;
+ }
+ }
+
+ private function creaCatellaStabile($stabile)
+ {
+ $nomeCartella = "Stabile_{$stabile->codice}_{$stabile->denominazione}";
+
+ // Cerca cartella esistente
+ $response = $this->service->files->listFiles([
+ 'q' => "name='{$nomeCartella}' and mimeType='application/vnd.google-apps.folder'",
+ 'spaces' => 'drive'
+ ]);
+
+ if (count($response->files) > 0) {
+ return $response->files[0]->id;
+ }
+
+ // Crea nuova cartella
+ $fileMetadata = new Google_Service_Drive_DriveFile([
+ 'name' => $nomeCartella,
+ 'mimeType' => 'application/vnd.google-apps.folder'
+ ]);
+
+ $folder = $this->service->files->create($fileMetadata);
+ return $folder->id;
+ }
+}
+```
+
+---
+
+## π **SISTEMA AUDIT DOCUMENTALE**
+
+### **1. Modello Audit Log**
+```php
+// app/Models/AuditDocumento.php
+ 'array',
+ 'dati_precedenti' => 'array',
+ 'dati_nuovi' => 'array'
+ ];
+
+ public function documento()
+ {
+ return $this->belongsTo(Documento::class);
+ }
+
+ public function utente()
+ {
+ return $this->belongsTo(User::class);
+ }
+
+ public static function logAzione($documento, $azione, $dettagli = [])
+ {
+ return self::create([
+ 'documento_id' => $documento->id,
+ 'utente_id' => auth()->id(),
+ 'azione' => $azione,
+ 'dettagli' => $dettagli,
+ 'ip_address' => request()->ip(),
+ 'user_agent' => request()->userAgent(),
+ 'dati_precedenti' => $documento->getOriginal(),
+ 'dati_nuovi' => $documento->getAttributes()
+ ]);
+ }
+}
+```
+
+### **2. Observer per Audit Automatico**
+```php
+// app/Observers/DocumentoObserver.php
+ 'Documento caricato'
+ ]);
+ }
+
+ public function updated(Documento $documento)
+ {
+ $modifiche = [];
+ foreach ($documento->getDirty() as $campo => $nuovoValore) {
+ $modifiche[$campo] = [
+ 'da' => $documento->getOriginal($campo),
+ 'a' => $nuovoValore
+ ];
+ }
+
+ AuditDocumento::logAzione($documento, 'updated', [
+ 'messaggio' => 'Documento modificato',
+ 'modifiche' => $modifiche
+ ]);
+ }
+
+ public function deleted(Documento $documento)
+ {
+ AuditDocumento::logAzione($documento, 'deleted', [
+ 'messaggio' => 'Documento eliminato'
+ ]);
+ }
+}
+```
+
+---
+
+## π **CHECKLIST IMPLEMENTAZIONE**
+
+### **Database e Modelli**
+- [ ] Migration tabelle documenti
+- [ ] Migration collegamenti documenti
+- [ ] Migration versioni documenti
+- [ ] Migration audit documenti
+- [ ] Modelli Eloquent completi
+- [ ] Observer per audit automatico
+
+### **Servizi Core**
+- [ ] DocumentoService per gestione base
+- [ ] Office365Service per integrazione cloud
+- [ ] GoogleDriveService per integrazione cloud
+- [ ] AuditService per tracciamento completo
+
+### **Controller e API**
+- [ ] DocumentoController per CRUD
+- [ ] API per upload tramite drag&drop
+- [ ] API per sincronizzazione cloud
+- [ ] API per ricerca avanzata
+
+### **Frontend**
+- [ ] Interfaccia upload documenti
+- [ ] Visualizzatore documenti integrato
+- [ ] Sistema ricerca e filtri
+- [ ] Dashboard audit e statistiche
+
+---
+
+**Aggiornato**: 2025-01-27
+**Versione**: 1.0
+**Prossimo step**: Implementazione migration e modelli base
diff --git a/LARAVEL_FORMS_DOCUMENTATION.md b/LARAVEL_FORMS_DOCUMENTATION.md
new file mode 100644
index 00000000..5d879fdb
--- /dev/null
+++ b/LARAVEL_FORMS_DOCUMENTATION.md
@@ -0,0 +1,493 @@
+# π **NetGesCon Laravel - Documentazione Maschere e Interfacce**
+
+## π **STATO DEL PROGETTO**
+- **Fase**: Business Logic Core completata
+- **Prossima fase**: Sviluppo interfacce utente responsive
+- **Livello**: Pronto per sviluppo controller e viste
+
+---
+
+## π― **STRUTTURA VERIFICATA - COLLEGAMENTI FUNZIONALI**
+
+### β
**Voci di Spesa β Tabelle Millesimali**
+Le voci di spesa sono correttamente collegate al sistema millesimale:
+
+```php
+// VoceSpesa.php - Relazione con tabella millesimale
+public function tabellaMillesimaleDefault()
+{
+ return $this->belongsTo(TabellaMillesimale::class, 'tabella_millesimale_default_id');
+}
+
+// Collegamento alla ripartizione spese
+public function ripartizioniSpese()
+{
+ return $this->hasMany(RipartizioneSpese::class);
+}
+```
+
+### β
**Workflow Completo di Ripartizione**
+1. **Voce Spesa** β definisce la spesa e tabella millesimale di default
+2. **Ripartizione Spese** β calcola la ripartizione per ogni unitΓ
+3. **Dettaglio Ripartizione** β importo specifico per ogni unitΓ
+4. **Piano Rateizzazione** β gestione pagamenti dilazionati
+5. **Rate** β singole scadenze di pagamento
+
+---
+
+## π¨ **STANDARDIZZAZIONE SVILUPPO UI**
+
+### **1. Struttura Base per Maschere Laravel**
+
+#### **A. Layout Base Responsive**
+```php
+// resources/views/layouts/app.blade.php
+
+
+
+
+
+ @yield('title', 'NetGesCon')
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @include('partials.sidebar')
+
+
+ @yield('content')
+
+
+
+
+
+
+
+ @yield('scripts')
+
+
+```
+
+#### **B. Componenti Standard per Form**
+```php
+// resources/views/components/form-input.blade.php
+@props(['name', 'label', 'type' => 'text', 'required' => false, 'value' => ''])
+
+
+
+
+ @error($name)
+
{{ $message }}
+ @enderror
+
+```
+
+#### **C. Componente Select per Relazioni**
+```php
+// resources/views/components/form-select.blade.php
+@props(['name', 'label', 'options' => [], 'selected' => '', 'required' => false])
+
+
+
+
+ @error($name)
+
{{ $message }}
+ @enderror
+
+```
+
+---
+
+## ποΈ **ARCHITETTURA CONTROLLER E VISTE**
+
+### **1. Controller Standard Pattern**
+
+#### **A. Controller Base per Gestione CRUD**
+```php
+// app/Http/Controllers/BaseController.php
+model::query();
+
+ // Filtri standard
+ if ($request->has('search')) {
+ $query = $this->applySearch($query, $request->search);
+ }
+
+ if ($request->has('stabile_id')) {
+ $query->where('stabile_id', $request->stabile_id);
+ }
+
+ $items = $query->paginate(20);
+
+ return view("{$this->viewPath}.index", compact('items'));
+ }
+
+ abstract protected function applySearch($query, $search);
+
+ public function create()
+ {
+ return view("{$this->viewPath}.create", $this->getFormData());
+ }
+
+ public function store(Request $request)
+ {
+ $this->validateRequest($request);
+
+ DB::beginTransaction();
+ try {
+ $item = $this->model::create($request->validated());
+ DB::commit();
+
+ return redirect()
+ ->route("{$this->routePrefix}.show", $item)
+ ->with('success', 'Elemento creato con successo');
+ } catch (\Exception $e) {
+ DB::rollBack();
+ return back()->with('error', 'Errore durante la creazione: ' . $e->getMessage());
+ }
+ }
+
+ abstract protected function validateRequest(Request $request);
+ abstract protected function getFormData();
+}
+```
+
+#### **B. Controller Specifico per Voci di Spesa**
+```php
+// app/Http/Controllers/VoceSpesaController.php
+where(function($q) use ($search) {
+ $q->where('codice', 'LIKE', "%{$search}%")
+ ->orWhere('descrizione', 'LIKE', "%{$search}%");
+ });
+ }
+
+ protected function validateRequest(Request $request)
+ {
+ return $request->validate([
+ 'stabile_id' => 'required|exists:stabili,id',
+ 'descrizione' => 'required|string|max:255',
+ 'tipo_gestione' => 'required|in:ordinaria,straordinaria,speciale',
+ 'categoria' => 'required|string',
+ 'tabella_millesimale_default_id' => 'nullable|exists:tabelle_millesimali,id',
+ 'ritenuta_acconto_default' => 'nullable|numeric|between:0,100',
+ 'attiva' => 'boolean',
+ 'ordinamento' => 'nullable|integer'
+ ]);
+ }
+
+ protected function getFormData()
+ {
+ return [
+ 'stabili' => Stabile::pluck('denominazione', 'id'),
+ 'tabelleMillesimali' => TabellaMillesimale::pluck('denominazione', 'id'),
+ 'categorie' => VoceSpesa::getCategorieStandard(),
+ 'tipiGestione' => VoceSpesa::getTipiGestione()
+ ];
+ }
+}
+```
+
+---
+
+## π― **MASCHERE PRINCIPALI DA IMPLEMENTARE**
+
+### **1. Gestione Voci di Spesa**
+- **Lista**: Filtri per stabile, categoria, tipo gestione
+- **Dettaglio**: Visualizzazione completa con storico ripartizioni
+- **Creazione/Modifica**: Form con validazione e selezione tabella millesimale
+
+### **2. Gestione Ripartizione Spese**
+- **Wizard di Ripartizione**: Step-by-step per calcolo automatico
+- **Anteprima Ripartizione**: Visualizzazione prima della conferma
+- **Gestione Esenzioni**: Interfaccia per escludere unitΓ specifiche
+
+### **3. Gestione Rate**
+- **Piano Rateizzazione**: Creazione piani di pagamento
+- **Calendario Scadenze**: Vista calendario con rate in scadenza
+- **Gestione Pagamenti**: Registrazione e storico pagamenti
+
+### **4. Dashboard Amministratore**
+- **Riepilogo Finanziario**: Grafici e statistiche
+- **Scadenze Imminenti**: Alert per rate in scadenza
+- **Stato Ripartizioni**: Monitoraggio ripartizioni aperte
+
+---
+
+## π§ **STRUMENTI E INTEGRAZIONI**
+
+### **1. Validazione Client-Side**
+```javascript
+// public/js/netgescon.js
+class NetgesconValidator {
+ static validateRipartizione(form) {
+ const importo = parseFloat(form.importo_totale.value);
+ const tabellaId = form.tabella_millesimale_id.value;
+
+ if (importo <= 0) {
+ this.showError('L\'importo deve essere maggiore di zero');
+ return false;
+ }
+
+ if (!tabellaId) {
+ this.showError('Seleziona una tabella millesimale');
+ return false;
+ }
+
+ return true;
+ }
+
+ static showError(message) {
+ // Implementazione notifica errore
+ }
+}
+```
+
+### **2. API JSON per Interazioni Ajax**
+```php
+// app/Http/Controllers/Api/RipartizioneSpesaController.php
+public function calcolaAnteprima(Request $request)
+{
+ $voceSpesa = VoceSpesa::find($request->voce_spesa_id);
+ $importoTotale = $request->importo_totale;
+
+ $ripartizione = new RipartizioneSpese();
+ $anteprima = $ripartizione->calcolaAnteprimaRipartizione(
+ $voceSpesa,
+ $importoTotale,
+ $request->tabella_millesimale_id
+ );
+
+ return response()->json([
+ 'success' => true,
+ 'anteprima' => $anteprima,
+ 'totale_calcolato' => $anteprima->sum('importo_calcolato')
+ ]);
+}
+```
+
+---
+
+## π¨ **TEMI CSS PERSONALIZZATI**
+
+### **1. Variabili CSS NetGesCon**
+```css
+/* public/css/netgescon.css */
+:root {
+ --primary-color: #2c3e50;
+ --secondary-color: #3498db;
+ --success-color: #27ae60;
+ --warning-color: #f39c12;
+ --danger-color: #e74c3c;
+ --light-bg: #f8f9fa;
+ --dark-text: #2c3e50;
+}
+
+.card-netgescon {
+ border: none;
+ border-radius: 8px;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+}
+
+.btn-netgescon {
+ border-radius: 6px;
+ padding: 8px 16px;
+ font-weight: 500;
+}
+
+.table-netgescon {
+ border-collapse: separate;
+ border-spacing: 0;
+}
+
+.table-netgescon th {
+ background-color: var(--light-bg);
+ border-bottom: 2px solid var(--primary-color);
+ font-weight: 600;
+}
+```
+
+---
+
+## π **PLUGIN SYSTEM ARCHITECTURE**
+
+### **1. Struttura Base per Plugin**
+```php
+// app/Plugins/PluginInterface.php
+interface PluginInterface
+{
+ public function register(): void;
+ public function boot(): void;
+ public function getMenuItems(): array;
+ public function getRoutes(): array;
+ public function getViews(): array;
+}
+
+// app/Plugins/BasePlugin.php
+abstract class BasePlugin implements PluginInterface
+{
+ protected $name;
+ protected $version;
+ protected $description;
+
+ public function register(): void
+ {
+ // Registrazione servizi base
+ }
+
+ abstract public function boot(): void;
+}
+```
+
+### **2. Plugin Manager**
+```php
+// app/Services/PluginManager.php
+class PluginManager
+{
+ protected $plugins = [];
+
+ public function loadPlugin($pluginClass)
+ {
+ $plugin = new $pluginClass();
+ $plugin->register();
+ $plugin->boot();
+
+ $this->plugins[] = $plugin;
+ }
+
+ public function getMenuItems()
+ {
+ $items = [];
+ foreach ($this->plugins as $plugin) {
+ $items = array_merge($items, $plugin->getMenuItems());
+ }
+ return $items;
+ }
+}
+```
+
+---
+
+## π **PROSSIMI STEP IMPLEMENTATIVI**
+
+### **Fase 1: Implementazione Controller e Viste Base**
+1. Creazione controller per VoceSpesa, RipartizioneSpese, Rate
+2. Implementazione viste index, create, edit, show
+3. Configurazione rotte e middleware
+
+### **Fase 2: Interfacce Avanzate**
+1. Wizard ripartizione spese con preview
+2. Dashboard amministratore con grafici
+3. Calendario scadenze interattivo
+
+### **Fase 3: Integrazioni**
+1. Sistema notifiche (email, SMS)
+2. Export PDF/Excel
+3. API REST per app mobile
+
+---
+
+## π― **CHECKLIST SVILUPPO**
+
+### **Backend**
+- [x] Modelli Eloquent completi
+- [x] Relazioni verificate
+- [x] Migration funzionali
+- [ ] Controller CRUD
+- [ ] Validazione form
+- [ ] API endpoints
+
+### **Frontend**
+- [ ] Layout responsive
+- [ ] Componenti riutilizzabili
+- [ ] Validazione client-side
+- [ ] Interfacce wizard
+- [ ] Dashboard grafici
+
+### **Integrazioni**
+- [ ] Sistema notifiche
+- [ ] Export documenti
+- [ ] Plugin system
+- [ ] Mobile API
+
+---
+
+**Aggiornato**: 2025-01-27
+**Versione**: 1.0
+**Prossimo update**: Dopo implementazione primi controller
diff --git a/app/Http/Controllers/Admin/PianoRateizzazioneController.php b/app/Http/Controllers/Admin/PianoRateizzazioneController.php
new file mode 100644
index 00000000..4163300a
--- /dev/null
+++ b/app/Http/Controllers/Admin/PianoRateizzazioneController.php
@@ -0,0 +1,452 @@
+whereHas('ripartizioneSpese.voceSpesa.stabile', function($q) {
+ $q->where('amministratore_id', Auth::user()->amministratore->id_amministratore ?? null);
+ });
+
+ // Filtro per stabile
+ if ($request->filled('stabile_id')) {
+ $query->whereHas('ripartizioneSpese.voceSpesa', function($q) use ($request) {
+ $q->where('stabile_id', $request->stabile_id);
+ });
+ }
+
+ // Filtro per unitΓ immobiliare
+ if ($request->filled('unita_immobiliare_id')) {
+ $query->where('unita_immobiliare_id', $request->unita_immobiliare_id);
+ }
+
+ // Filtro per stato
+ if ($request->filled('stato')) {
+ $query->where('stato', $request->stato);
+ }
+
+ // Filtro per data
+ if ($request->filled('data_da')) {
+ $query->where('data_inizio', '>=', $request->data_da);
+ }
+ if ($request->filled('data_a')) {
+ $query->where('data_inizio', '<=', $request->data_a);
+ }
+
+ $pianiRateizzazione = $query->orderBy('data_inizio', 'desc')->paginate(15);
+
+ // Dati per i filtri
+ $stabili = \App\Models\Stabile::where('amministratore_id', Auth::user()->amministratore->id_amministratore ?? null)
+ ->orderBy('denominazione')
+ ->get();
+
+ return view('admin.piani-rateizzazione.index', compact('pianiRateizzazione', 'stabili'));
+ }
+
+ /**
+ * Show the form for creating a new resource.
+ */
+ public function create(Request $request)
+ {
+ $ripartizioneSpesa = null;
+ $dettaglioRipartizione = null;
+
+ // Se arriva da una ripartizione specifica
+ if ($request->filled('ripartizione_spesa_id')) {
+ $ripartizioneSpesa = RipartizioneSpese::with(['voceSpesa.stabile', 'dettagli.unitaImmobiliare'])
+ ->whereHas('voceSpesa.stabile', function($q) {
+ $q->where('amministratore_id', Auth::user()->amministratore->id_amministratore ?? null);
+ })
+ ->findOrFail($request->ripartizione_spesa_id);
+ }
+
+ // Se arriva da un dettaglio specifico
+ if ($request->filled('dettaglio_ripartizione_id')) {
+ $dettaglioRipartizione = DettaglioRipartizioneSpese::with(['ripartizioneSpese.voceSpesa.stabile', 'unitaImmobiliare'])
+ ->whereHas('ripartizioneSpese.voceSpesa.stabile', function($q) {
+ $q->where('amministratore_id', Auth::user()->amministratore->id_amministratore ?? null);
+ })
+ ->findOrFail($request->dettaglio_ripartizione_id);
+
+ $ripartizioneSpesa = $dettaglioRipartizione->ripartizioneSpese;
+ }
+
+ // Ripartizioni disponibili
+ $ripartizioni = RipartizioneSpese::with(['voceSpesa.stabile'])
+ ->whereHas('voceSpesa.stabile', function($q) {
+ $q->where('amministratore_id', Auth::user()->amministratore->id_amministratore ?? null);
+ })
+ ->where('stato', 'confermata')
+ ->orderBy('data_ripartizione', 'desc')
+ ->get();
+
+ return view('admin.piani-rateizzazione.create', compact('ripartizioni', 'ripartizioneSpesa', 'dettaglioRipartizione'));
+ }
+
+ /**
+ * Store a newly created resource in storage.
+ */
+ public function store(Request $request)
+ {
+ $request->validate([
+ 'ripartizione_spese_id' => 'required|exists:ripartizioni_spese,id',
+ 'unita_immobiliare_id' => 'required|exists:unita_immobiliari,id',
+ 'denominazione' => 'required|string|max:255',
+ 'importo_totale' => 'required|numeric|min:0',
+ 'numero_rate' => 'required|integer|min:1|max:60',
+ 'data_inizio' => 'required|date',
+ 'frequenza' => 'required|in:mensile,bimestrale,trimestrale,semestrale',
+ 'importo_prima_rata' => 'nullable|numeric|min:0',
+ 'note' => 'nullable|string',
+ 'applica_interessi' => 'nullable|boolean',
+ 'tasso_interesse' => 'nullable|numeric|min:0|max:100',
+ ]);
+
+ // Verifica che la ripartizione appartenga all'amministratore
+ $ripartizioneSpesa = RipartizioneSpese::whereHas('voceSpesa.stabile', function($q) {
+ $q->where('amministratore_id', Auth::user()->amministratore->id_amministratore ?? null);
+ })
+ ->where('stato', 'confermata')
+ ->findOrFail($request->ripartizione_spese_id);
+
+ // Verifica che l'unitΓ immobiliare appartenga al stabile della ripartizione
+ $unitaImmobiliare = UnitaImmobiliare::where('id', $request->unita_immobiliare_id)
+ ->where('stabile_id', $ripartizioneSpesa->voceSpesa->stabile_id)
+ ->firstOrFail();
+
+ // Verifica che l'unitΓ abbia un dettaglio nella ripartizione
+ $dettaglioRipartizione = DettaglioRipartizioneSpese::where('ripartizione_spese_id', $request->ripartizione_spese_id)
+ ->where('unita_immobiliare_id', $request->unita_immobiliare_id)
+ ->firstOrFail();
+
+ // Verifica che l'importo totale non superi l'importo del dettaglio
+ if ($request->importo_totale > $dettaglioRipartizione->importo) {
+ return redirect()->back()
+ ->withInput()
+ ->with('error', 'L\'importo totale del piano non puΓ² superare l\'importo della ripartizione (' . number_format($dettaglioRipartizione->importo, 2) . ' β¬).');
+ }
+
+ DB::beginTransaction();
+
+ try {
+ // Crea il piano di rateizzazione
+ $pianoRateizzazione = PianoRateizzazione::create([
+ 'codice_piano' => $this->generateCodicePiano(),
+ 'ripartizione_spese_id' => $request->ripartizione_spese_id,
+ 'unita_immobiliare_id' => $request->unita_immobiliare_id,
+ 'denominazione' => $request->denominazione,
+ 'importo_totale' => $request->importo_totale,
+ 'numero_rate' => $request->numero_rate,
+ 'data_inizio' => $request->data_inizio,
+ 'frequenza' => $request->frequenza,
+ 'importo_prima_rata' => $request->importo_prima_rata,
+ 'note' => $request->note,
+ 'applica_interessi' => $request->boolean('applica_interessi'),
+ 'tasso_interesse' => $request->tasso_interesse,
+ 'stato' => 'bozza',
+ 'created_by' => Auth::id(),
+ ]);
+
+ // Calcola e crea le rate
+ $this->calcolaRate($pianoRateizzazione);
+
+ DB::commit();
+
+ return redirect()->route('admin.piani-rateizzazione.show', $pianoRateizzazione)
+ ->with('success', 'Piano di rateizzazione creato con successo.');
+
+ } catch (\Exception $e) {
+ DB::rollBack();
+ return redirect()->back()
+ ->withInput()
+ ->with('error', 'Errore durante la creazione del piano: ' . $e->getMessage());
+ }
+ }
+
+ /**
+ * Display the specified resource.
+ */
+ public function show(PianoRateizzazione $pianoRateizzazione)
+ {
+ // Verifica autorizzazione
+ $this->authorize('view', $pianoRateizzazione);
+
+ $pianoRateizzazione->load([
+ 'ripartizioneSpese.voceSpesa.stabile',
+ 'unitaImmobiliare.anagraficaCondominiale.soggetto',
+ 'rate' => function($query) {
+ $query->orderBy('numero_rata');
+ },
+ 'createdBy',
+ 'updatedBy'
+ ]);
+
+ return view('admin.piani-rateizzazione.show', compact('pianoRateizzazione'));
+ }
+
+ /**
+ * Show the form for editing the specified resource.
+ */
+ public function edit(PianoRateizzazione $pianoRateizzazione)
+ {
+ // Verifica autorizzazione
+ $this->authorize('update', $pianoRateizzazione);
+
+ // Solo i piani in bozza possono essere modificati
+ if ($pianoRateizzazione->stato !== 'bozza') {
+ return redirect()->route('admin.piani-rateizzazione.show', $pianoRateizzazione)
+ ->with('error', 'Impossibile modificare un piano giΓ attivato.');
+ }
+
+ $pianoRateizzazione->load([
+ 'ripartizioneSpese.voceSpesa.stabile',
+ 'unitaImmobiliare',
+ 'rate' => function($query) {
+ $query->orderBy('numero_rata');
+ }
+ ]);
+
+ return view('admin.piani-rateizzazione.edit', compact('pianoRateizzazione'));
+ }
+
+ /**
+ * Update the specified resource in storage.
+ */
+ public function update(Request $request, PianoRateizzazione $pianoRateizzazione)
+ {
+ // Verifica autorizzazione
+ $this->authorize('update', $pianoRateizzazione);
+
+ // Solo i piani in bozza possono essere modificati
+ if ($pianoRateizzazione->stato !== 'bozza') {
+ return redirect()->route('admin.piani-rateizzazione.show', $pianoRateizzazione)
+ ->with('error', 'Impossibile modificare un piano giΓ attivato.');
+ }
+
+ $request->validate([
+ 'denominazione' => 'required|string|max:255',
+ 'importo_totale' => 'required|numeric|min:0',
+ 'numero_rate' => 'required|integer|min:1|max:60',
+ 'data_inizio' => 'required|date',
+ 'frequenza' => 'required|in:mensile,bimestrale,trimestrale,semestrale',
+ 'importo_prima_rata' => 'nullable|numeric|min:0',
+ 'note' => 'nullable|string',
+ 'applica_interessi' => 'nullable|boolean',
+ 'tasso_interesse' => 'nullable|numeric|min:0|max:100',
+ ]);
+
+ // Verifica che l'importo totale non superi l'importo del dettaglio
+ $dettaglioRipartizione = DettaglioRipartizioneSpese::where('ripartizione_spese_id', $pianoRateizzazione->ripartizione_spese_id)
+ ->where('unita_immobiliare_id', $pianoRateizzazione->unita_immobiliare_id)
+ ->firstOrFail();
+
+ if ($request->importo_totale > $dettaglioRipartizione->importo) {
+ return redirect()->back()
+ ->withInput()
+ ->with('error', 'L\'importo totale del piano non puΓ² superare l\'importo della ripartizione (' . number_format($dettaglioRipartizione->importo, 2) . ' β¬).');
+ }
+
+ DB::beginTransaction();
+
+ try {
+ // Aggiorna il piano
+ $pianoRateizzazione->update([
+ 'denominazione' => $request->denominazione,
+ 'importo_totale' => $request->importo_totale,
+ 'numero_rate' => $request->numero_rate,
+ 'data_inizio' => $request->data_inizio,
+ 'frequenza' => $request->frequenza,
+ 'importo_prima_rata' => $request->importo_prima_rata,
+ 'note' => $request->note,
+ 'applica_interessi' => $request->boolean('applica_interessi'),
+ 'tasso_interesse' => $request->tasso_interesse,
+ 'updated_by' => Auth::id(),
+ ]);
+
+ // Elimina le rate esistenti
+ $pianoRateizzazione->rate()->delete();
+
+ // Ricalcola le rate
+ $this->calcolaRate($pianoRateizzazione);
+
+ DB::commit();
+
+ return redirect()->route('admin.piani-rateizzazione.show', $pianoRateizzazione)
+ ->with('success', 'Piano di rateizzazione aggiornato con successo.');
+
+ } catch (\Exception $e) {
+ DB::rollBack();
+ return redirect()->back()
+ ->withInput()
+ ->with('error', 'Errore durante l\'aggiornamento del piano: ' . $e->getMessage());
+ }
+ }
+
+ /**
+ * Remove the specified resource from storage.
+ */
+ public function destroy(PianoRateizzazione $pianoRateizzazione)
+ {
+ // Verifica autorizzazione
+ $this->authorize('delete', $pianoRateizzazione);
+
+ // Solo i piani in bozza possono essere eliminati
+ if ($pianoRateizzazione->stato !== 'bozza') {
+ return redirect()->route('admin.piani-rateizzazione.index')
+ ->with('error', 'Impossibile eliminare un piano giΓ attivato.');
+ }
+
+ DB::beginTransaction();
+
+ try {
+ // Elimina le rate
+ $pianoRateizzazione->rate()->delete();
+
+ // Elimina il piano
+ $pianoRateizzazione->delete();
+
+ DB::commit();
+
+ return redirect()->route('admin.piani-rateizzazione.index')
+ ->with('success', 'Piano di rateizzazione eliminato con successo.');
+
+ } catch (\Exception $e) {
+ DB::rollBack();
+ return redirect()->route('admin.piani-rateizzazione.index')
+ ->with('error', 'Errore durante l\'eliminazione del piano: ' . $e->getMessage());
+ }
+ }
+
+ /**
+ * Attiva un piano di rateizzazione
+ */
+ public function attiva(PianoRateizzazione $pianoRateizzazione)
+ {
+ // Verifica autorizzazione
+ $this->authorize('update', $pianoRateizzazione);
+
+ if ($pianoRateizzazione->stato !== 'bozza') {
+ return redirect()->route('admin.piani-rateizzazione.show', $pianoRateizzazione)
+ ->with('error', 'Il piano Γ¨ giΓ stato attivato.');
+ }
+
+ // Verifica che ci siano rate
+ if (!$pianoRateizzazione->rate()->exists()) {
+ return redirect()->route('admin.piani-rateizzazione.show', $pianoRateizzazione)
+ ->with('error', 'Impossibile attivare un piano senza rate.');
+ }
+
+ $pianoRateizzazione->update([
+ 'stato' => 'attivo',
+ 'data_attivazione' => now(),
+ 'attivato_by' => Auth::id(),
+ ]);
+
+ return redirect()->route('admin.piani-rateizzazione.show', $pianoRateizzazione)
+ ->with('success', 'Piano di rateizzazione attivato con successo.');
+ }
+
+ /**
+ * Sospende un piano di rateizzazione
+ */
+ public function sospendi(PianoRateizzazione $pianoRateizzazione)
+ {
+ // Verifica autorizzazione
+ $this->authorize('update', $pianoRateizzazione);
+
+ if ($pianoRateizzazione->stato !== 'attivo') {
+ return redirect()->route('admin.piani-rateizzazione.show', $pianoRateizzazione)
+ ->with('error', 'Il piano non Γ¨ attivo.');
+ }
+
+ $pianoRateizzazione->update([
+ 'stato' => 'sospeso',
+ 'data_sospensione' => now(),
+ 'sospeso_by' => Auth::id(),
+ ]);
+
+ return redirect()->route('admin.piani-rateizzazione.show', $pianoRateizzazione)
+ ->with('success', 'Piano di rateizzazione sospeso con successo.');
+ }
+
+ /**
+ * Calcola le rate per un piano di rateizzazione
+ */
+ private function calcolaRate(PianoRateizzazione $piano)
+ {
+ $dataScadenza = Carbon::parse($piano->data_inizio);
+ $importoRata = $piano->importo_totale / $piano->numero_rate;
+ $importoPrimaRata = $piano->importo_prima_rata ?? $importoRata;
+
+ // Calcola gli interessi se applicabili
+ $importoTotaleConInteressi = $piano->importo_totale;
+ if ($piano->applica_interessi && $piano->tasso_interesse > 0) {
+ $importoTotaleConInteressi = $piano->importo_totale * (1 + ($piano->tasso_interesse / 100));
+ $importoRata = $importoTotaleConInteressi / $piano->numero_rate;
+ }
+
+ for ($i = 1; $i <= $piano->numero_rate; $i++) {
+ $importoCorrente = ($i === 1) ? $importoPrimaRata : $importoRata;
+
+ // Aggiusta l'ultima rata per eventuali arrotondamenti
+ if ($i === $piano->numero_rate) {
+ $totaleRatePrecedenti = $importoPrimaRata + (($piano->numero_rate - 2) * $importoRata);
+ $importoCorrente = $importoTotaleConInteressi - $totaleRatePrecedenti;
+ }
+
+ Rata::create([
+ 'piano_rateizzazione_id' => $piano->id,
+ 'numero_rata' => $i,
+ 'importo' => round($importoCorrente, 2),
+ 'data_scadenza' => $dataScadenza->copy(),
+ 'stato' => 'da_pagare',
+ ]);
+
+ // Calcola la prossima data di scadenza
+ switch ($piano->frequenza) {
+ case 'mensile':
+ $dataScadenza->addMonth();
+ break;
+ case 'bimestrale':
+ $dataScadenza->addMonths(2);
+ break;
+ case 'trimestrale':
+ $dataScadenza->addMonths(3);
+ break;
+ case 'semestrale':
+ $dataScadenza->addMonths(6);
+ break;
+ }
+ }
+ }
+
+ /**
+ * Genera un codice piano univoco
+ */
+ private function generateCodicePiano(): string
+ {
+ do {
+ $codice = 'PR' . strtoupper(Str::random(6));
+ } while (PianoRateizzazione::where('codice_piano', $codice)->exists());
+
+ return $codice;
+ }
+}
diff --git a/app/Http/Controllers/Admin/RataController.php b/app/Http/Controllers/Admin/RataController.php
new file mode 100644
index 00000000..325ef3c7
--- /dev/null
+++ b/app/Http/Controllers/Admin/RataController.php
@@ -0,0 +1,463 @@
+whereHas('pianoRateizzazione.ripartizioneSpese.voceSpesa.stabile', function($q) {
+ $q->where('amministratore_id', Auth::user()->amministratore->id_amministratore ?? null);
+ });
+
+ // Filtro per stabile
+ if ($request->filled('stabile_id')) {
+ $query->whereHas('pianoRateizzazione.ripartizioneSpese.voceSpesa', function($q) use ($request) {
+ $q->where('stabile_id', $request->stabile_id);
+ });
+ }
+
+ // Filtro per unitΓ immobiliare
+ if ($request->filled('unita_immobiliare_id')) {
+ $query->whereHas('pianoRateizzazione', function($q) use ($request) {
+ $q->where('unita_immobiliare_id', $request->unita_immobiliare_id);
+ });
+ }
+
+ // Filtro per stato
+ if ($request->filled('stato')) {
+ $query->where('stato', $request->stato);
+ }
+
+ // Filtro per scadenza
+ if ($request->filled('scadenza_da')) {
+ $query->where('data_scadenza', '>=', $request->scadenza_da);
+ }
+ if ($request->filled('scadenza_a')) {
+ $query->where('data_scadenza', '<=', $request->scadenza_a);
+ }
+
+ // Filtro per rate in scadenza
+ if ($request->filled('in_scadenza')) {
+ $giorni = (int) $request->in_scadenza;
+ $query->where('data_scadenza', '<=', Carbon::now()->addDays($giorni))
+ ->where('stato', 'da_pagare');
+ }
+
+ // Filtro per rate scadute
+ if ($request->filled('scadute')) {
+ $query->where('data_scadenza', '<', Carbon::now())
+ ->where('stato', 'da_pagare');
+ }
+
+ $rate = $query->orderBy('data_scadenza')->paginate(20);
+
+ // Dati per i filtri
+ $stabili = \App\Models\Stabile::where('amministratore_id', Auth::user()->amministratore->id_amministratore ?? null)
+ ->orderBy('denominazione')
+ ->get();
+
+ // Statistiche
+ $statistiche = [
+ 'totale_rate' => $query->count(),
+ 'da_pagare' => $query->where('stato', 'da_pagare')->count(),
+ 'pagate' => $query->where('stato', 'pagata')->count(),
+ 'scadute' => $query->where('data_scadenza', '<', Carbon::now())->where('stato', 'da_pagare')->count(),
+ 'importo_totale' => $query->sum('importo'),
+ 'importo_pagato' => $query->where('stato', 'pagata')->sum('importo'),
+ ];
+
+ return view('admin.rate.index', compact('rate', 'stabili', 'statistiche'));
+ }
+
+ /**
+ * Display the specified resource.
+ */
+ public function show(Rata $rata)
+ {
+ // Verifica autorizzazione
+ $this->authorize('view', $rata);
+
+ $rata->load([
+ 'pianoRateizzazione.ripartizioneSpese.voceSpesa.stabile',
+ 'pianoRateizzazione.unitaImmobiliare.anagraficaCondominiale.soggetto',
+ 'createdBy',
+ 'updatedBy'
+ ]);
+
+ return view('admin.rate.show', compact('rata'));
+ }
+
+ /**
+ * Show the form for editing the specified resource.
+ */
+ public function edit(Rata $rata)
+ {
+ // Verifica autorizzazione
+ $this->authorize('update', $rata);
+
+ // Solo le rate da pagare possono essere modificate
+ if ($rata->stato !== 'da_pagare') {
+ return redirect()->route('admin.rate.show', $rata)
+ ->with('error', 'Impossibile modificare una rata giΓ pagata o annullata.');
+ }
+
+ $rata->load([
+ 'pianoRateizzazione.ripartizioneSpese.voceSpesa.stabile',
+ 'pianoRateizzazione.unitaImmobiliare'
+ ]);
+
+ return view('admin.rate.edit', compact('rata'));
+ }
+
+ /**
+ * Update the specified resource in storage.
+ */
+ public function update(Request $request, Rata $rata)
+ {
+ // Verifica autorizzazione
+ $this->authorize('update', $rata);
+
+ // Solo le rate da pagare possono essere modificate
+ if ($rata->stato !== 'da_pagare') {
+ return redirect()->route('admin.rate.show', $rata)
+ ->with('error', 'Impossibile modificare una rata giΓ pagata o annullata.');
+ }
+
+ $request->validate([
+ 'importo' => 'required|numeric|min:0',
+ 'data_scadenza' => 'required|date',
+ 'note' => 'nullable|string',
+ ]);
+
+ $rata->update([
+ 'importo' => $request->importo,
+ 'data_scadenza' => $request->data_scadenza,
+ 'note' => $request->note,
+ 'updated_by' => Auth::id(),
+ ]);
+
+ return redirect()->route('admin.rate.show', $rata)
+ ->with('success', 'Rata aggiornata con successo.');
+ }
+
+ /**
+ * Registra il pagamento di una rata
+ */
+ public function registraPagamento(Request $request, Rata $rata)
+ {
+ // Verifica autorizzazione
+ $this->authorize('update', $rata);
+
+ if ($rata->stato !== 'da_pagare') {
+ return redirect()->route('admin.rate.show', $rata)
+ ->with('error', 'La rata Γ¨ giΓ stata pagata o annullata.');
+ }
+
+ $request->validate([
+ 'importo_pagato' => 'required|numeric|min:0',
+ 'data_pagamento' => 'required|date',
+ 'metodo_pagamento' => 'required|string|max:100',
+ 'riferimento_pagamento' => 'nullable|string|max:255',
+ 'note_pagamento' => 'nullable|string',
+ ]);
+
+ DB::beginTransaction();
+
+ try {
+ $rata->update([
+ 'stato' => 'pagata',
+ 'importo_pagato' => $request->importo_pagato,
+ 'data_pagamento' => $request->data_pagamento,
+ 'metodo_pagamento' => $request->metodo_pagamento,
+ 'riferimento_pagamento' => $request->riferimento_pagamento,
+ 'note_pagamento' => $request->note_pagamento,
+ 'registrato_by' => Auth::id(),
+ 'updated_by' => Auth::id(),
+ ]);
+
+ // Verifica se il piano Γ¨ completato
+ $pianoRateizzazione = $rata->pianoRateizzazione;
+ $rateRimanenti = $pianoRateizzazione->rate()->where('stato', 'da_pagare')->count();
+
+ if ($rateRimanenti === 0) {
+ $pianoRateizzazione->update([
+ 'stato' => 'completato',
+ 'data_completamento' => now(),
+ ]);
+ }
+
+ DB::commit();
+
+ return redirect()->route('admin.rate.show', $rata)
+ ->with('success', 'Pagamento registrato con successo.');
+
+ } catch (\Exception $e) {
+ DB::rollBack();
+ return redirect()->back()
+ ->withInput()
+ ->with('error', 'Errore durante la registrazione del pagamento: ' . $e->getMessage());
+ }
+ }
+
+ /**
+ * Annulla il pagamento di una rata
+ */
+ public function annullaPagamento(Rata $rata)
+ {
+ // Verifica autorizzazione
+ $this->authorize('update', $rata);
+
+ if ($rata->stato !== 'pagata') {
+ return redirect()->route('admin.rate.show', $rata)
+ ->with('error', 'La rata non Γ¨ stata pagata.');
+ }
+
+ DB::beginTransaction();
+
+ try {
+ $rata->update([
+ 'stato' => 'da_pagare',
+ 'importo_pagato' => null,
+ 'data_pagamento' => null,
+ 'metodo_pagamento' => null,
+ 'riferimento_pagamento' => null,
+ 'note_pagamento' => null,
+ 'registrato_by' => null,
+ 'updated_by' => Auth::id(),
+ ]);
+
+ // Aggiorna lo stato del piano se necessario
+ $pianoRateizzazione = $rata->pianoRateizzazione;
+ if ($pianoRateizzazione->stato === 'completato') {
+ $pianoRateizzazione->update([
+ 'stato' => 'attivo',
+ 'data_completamento' => null,
+ ]);
+ }
+
+ DB::commit();
+
+ return redirect()->route('admin.rate.show', $rata)
+ ->with('success', 'Pagamento annullato con successo.');
+
+ } catch (\Exception $e) {
+ DB::rollBack();
+ return redirect()->route('admin.rate.show', $rata)
+ ->with('error', 'Errore durante l\'annullamento del pagamento: ' . $e->getMessage());
+ }
+ }
+
+ /**
+ * Posticipa una rata
+ */
+ public function posticipa(Request $request, Rata $rata)
+ {
+ // Verifica autorizzazione
+ $this->authorize('update', $rata);
+
+ if ($rata->stato !== 'da_pagare') {
+ return redirect()->route('admin.rate.show', $rata)
+ ->with('error', 'Impossibile posticipare una rata giΓ pagata o annullata.');
+ }
+
+ $request->validate([
+ 'nuova_data_scadenza' => 'required|date|after:' . $rata->data_scadenza,
+ 'motivo_posticipo' => 'required|string|max:255',
+ ]);
+
+ $rata->update([
+ 'data_scadenza' => $request->nuova_data_scadenza,
+ 'motivo_posticipo' => $request->motivo_posticipo,
+ 'posticipata_by' => Auth::id(),
+ 'updated_by' => Auth::id(),
+ ]);
+
+ return redirect()->route('admin.rate.show', $rata)
+ ->with('success', 'Rata posticipata con successo.');
+ }
+
+ /**
+ * Mostra il form per registrare un pagamento
+ */
+ public function showPagamentoForm(Rata $rata)
+ {
+ // Verifica autorizzazione
+ $this->authorize('update', $rata);
+
+ if ($rata->stato !== 'da_pagare') {
+ return redirect()->route('admin.rate.show', $rata)
+ ->with('error', 'La rata Γ¨ giΓ stata pagata o annullata.');
+ }
+
+ $rata->load([
+ 'pianoRateizzazione.ripartizioneSpese.voceSpesa.stabile',
+ 'pianoRateizzazione.unitaImmobiliare.anagraficaCondominiale.soggetto'
+ ]);
+
+ return view('admin.rate.pagamento', compact('rata'));
+ }
+
+ /**
+ * Mostra il form per posticipare una rata
+ */
+ public function showPosticipoForm(Rata $rata)
+ {
+ // Verifica autorizzazione
+ $this->authorize('update', $rata);
+
+ if ($rata->stato !== 'da_pagare') {
+ return redirect()->route('admin.rate.show', $rata)
+ ->with('error', 'Impossibile posticipare una rata giΓ pagata o annullata.');
+ }
+
+ $rata->load([
+ 'pianoRateizzazione.ripartizioneSpese.voceSpesa.stabile',
+ 'pianoRateizzazione.unitaImmobiliare'
+ ]);
+
+ return view('admin.rate.posticipo', compact('rata'));
+ }
+
+ /**
+ * Genera un report delle rate
+ */
+ public function report(Request $request)
+ {
+ $query = Rata::with([
+ 'pianoRateizzazione.ripartizioneSpese.voceSpesa.stabile',
+ 'pianoRateizzazione.unitaImmobiliare.anagraficaCondominiale.soggetto'
+ ])->whereHas('pianoRateizzazione.ripartizioneSpese.voceSpesa.stabile', function($q) {
+ $q->where('amministratore_id', Auth::user()->amministratore->id_amministratore ?? null);
+ });
+
+ // Applica filtri
+ if ($request->filled('stabile_id')) {
+ $query->whereHas('pianoRateizzazione.ripartizioneSpese.voceSpesa', function($q) use ($request) {
+ $q->where('stabile_id', $request->stabile_id);
+ });
+ }
+
+ if ($request->filled('stato')) {
+ $query->where('stato', $request->stato);
+ }
+
+ if ($request->filled('data_da')) {
+ $query->where('data_scadenza', '>=', $request->data_da);
+ }
+
+ if ($request->filled('data_a')) {
+ $query->where('data_scadenza', '<=', $request->data_a);
+ }
+
+ $rate = $query->orderBy('data_scadenza')->get();
+
+ // Statistiche per il report
+ $statistiche = [
+ 'totale_rate' => $rate->count(),
+ 'da_pagare' => $rate->where('stato', 'da_pagare')->count(),
+ 'pagate' => $rate->where('stato', 'pagata')->count(),
+ 'scadute' => $rate->where('data_scadenza', '<', Carbon::now())->where('stato', 'da_pagare')->count(),
+ 'importo_totale' => $rate->sum('importo'),
+ 'importo_pagato' => $rate->where('stato', 'pagata')->sum('importo_pagato'),
+ 'importo_da_pagare' => $rate->where('stato', 'da_pagare')->sum('importo'),
+ ];
+
+ return view('admin.rate.report', compact('rate', 'statistiche'));
+ }
+
+ /**
+ * Esporta le rate in CSV
+ */
+ public function exportCsv(Request $request)
+ {
+ $query = Rata::with([
+ 'pianoRateizzazione.ripartizioneSpese.voceSpesa.stabile',
+ 'pianoRateizzazione.unitaImmobiliare.anagraficaCondominiale.soggetto'
+ ])->whereHas('pianoRateizzazione.ripartizioneSpese.voceSpesa.stabile', function($q) {
+ $q->where('amministratore_id', Auth::user()->amministratore->id_amministratore ?? null);
+ });
+
+ // Applica gli stessi filtri del report
+ if ($request->filled('stabile_id')) {
+ $query->whereHas('pianoRateizzazione.ripartizioneSpese.voceSpesa', function($q) use ($request) {
+ $q->where('stabile_id', $request->stabile_id);
+ });
+ }
+
+ if ($request->filled('stato')) {
+ $query->where('stato', $request->stato);
+ }
+
+ if ($request->filled('data_da')) {
+ $query->where('data_scadenza', '>=', $request->data_da);
+ }
+
+ if ($request->filled('data_a')) {
+ $query->where('data_scadenza', '<=', $request->data_a);
+ }
+
+ $rate = $query->orderBy('data_scadenza')->get();
+
+ $filename = 'rate_' . Carbon::now()->format('Y-m-d_H-i-s') . '.csv';
+
+ $headers = [
+ 'Content-Type' => 'text/csv',
+ 'Content-Disposition' => "attachment; filename=\"$filename\"",
+ ];
+
+ $callback = function() use ($rate) {
+ $file = fopen('php://output', 'w');
+
+ // Intestazioni CSV
+ fputcsv($file, [
+ 'Stabile',
+ 'UnitΓ Immobiliare',
+ 'Condomino',
+ 'Piano Rateizzazione',
+ 'Numero Rata',
+ 'Importo',
+ 'Data Scadenza',
+ 'Stato',
+ 'Data Pagamento',
+ 'Importo Pagato',
+ 'Metodo Pagamento',
+ 'Riferimento'
+ ]);
+
+ // Dati
+ foreach ($rate as $rata) {
+ fputcsv($file, [
+ $rata->pianoRateizzazione->ripartizioneSpese->voceSpesa->stabile->denominazione,
+ $rata->pianoRateizzazione->unitaImmobiliare->denominazione,
+ $rata->pianoRateizzazione->unitaImmobiliare->anagraficaCondominiale?->soggetto?->denominazione,
+ $rata->pianoRateizzazione->denominazione,
+ $rata->numero_rata,
+ $rata->importo,
+ $rata->data_scadenza,
+ $rata->stato,
+ $rata->data_pagamento,
+ $rata->importo_pagato,
+ $rata->metodo_pagamento,
+ $rata->riferimento_pagamento
+ ]);
+ }
+
+ fclose($file);
+ };
+
+ return response()->stream($callback, 200, $headers);
+ }
+}
diff --git a/app/Http/Controllers/Admin/RipartizioneSpesaController.php b/app/Http/Controllers/Admin/RipartizioneSpesaController.php
new file mode 100644
index 00000000..e7f029ba
--- /dev/null
+++ b/app/Http/Controllers/Admin/RipartizioneSpesaController.php
@@ -0,0 +1,397 @@
+whereHas('voceSpesa.stabile', function($q) {
+ $q->where('amministratore_id', Auth::user()->amministratore->id_amministratore ?? null);
+ });
+
+ // Filtro per stabile
+ if ($request->filled('stabile_id')) {
+ $query->whereHas('voceSpesa', function($q) use ($request) {
+ $q->where('stabile_id', $request->stabile_id);
+ });
+ }
+
+ // Filtro per voce di spesa
+ if ($request->filled('voce_spesa_id')) {
+ $query->where('voce_spesa_id', $request->voce_spesa_id);
+ }
+
+ // Filtro per stato
+ if ($request->filled('stato')) {
+ $query->where('stato', $request->stato);
+ }
+
+ // Filtro per data
+ if ($request->filled('data_da')) {
+ $query->where('data_ripartizione', '>=', $request->data_da);
+ }
+ if ($request->filled('data_a')) {
+ $query->where('data_ripartizione', '<=', $request->data_a);
+ }
+
+ $ripartizioni = $query->orderBy('data_ripartizione', 'desc')->paginate(15);
+
+ // Dati per i filtri
+ $stabili = Stabile::where('amministratore_id', Auth::user()->amministratore->id_amministratore ?? null)
+ ->orderBy('denominazione')
+ ->get();
+
+ $vociSpesa = VoceSpesa::whereHas('stabile', function($q) {
+ $q->where('amministratore_id', Auth::user()->amministratore->id_amministratore ?? null);
+ })
+ ->orderBy('denominazione')
+ ->get();
+
+ return view('admin.ripartizioni-spesa.index', compact('ripartizioni', 'stabili', 'vociSpesa'));
+ }
+
+ /**
+ * Show the form for creating a new resource.
+ */
+ public function create(Request $request)
+ {
+ $vociSpesa = VoceSpesa::with(['stabile', 'tabellaMillesimaleDefault'])
+ ->whereHas('stabile', function($q) {
+ $q->where('amministratore_id', Auth::user()->amministratore->id_amministratore ?? null);
+ })
+ ->where('stato', 'attiva')
+ ->orderBy('denominazione')
+ ->get();
+
+ $voceSpesaSelezionata = null;
+ if ($request->filled('voce_spesa_id')) {
+ $voceSpesaSelezionata = $vociSpesa->firstWhere('id', $request->voce_spesa_id);
+ }
+
+ return view('admin.ripartizioni-spesa.create', compact('vociSpesa', 'voceSpesaSelezionata'));
+ }
+
+ /**
+ * Store a newly created resource in storage.
+ */
+ public function store(Request $request)
+ {
+ $request->validate([
+ 'voce_spesa_id' => 'required|exists:voci_spesa,id',
+ 'descrizione' => 'required|string|max:255',
+ 'importo_totale' => 'required|numeric|min:0',
+ 'data_ripartizione' => 'required|date',
+ 'tabella_millesimale_id' => 'required|exists:tabelle_millesimali,id',
+ 'periodo_riferimento' => 'nullable|string|max:100',
+ 'note' => 'nullable|string',
+ 'dettagli' => 'nullable|array',
+ 'dettagli.*.unita_immobiliare_id' => 'required|exists:unita_immobiliari,id',
+ 'dettagli.*.importo' => 'required|numeric|min:0',
+ 'dettagli.*.esclusa' => 'nullable|boolean',
+ 'dettagli.*.note' => 'nullable|string|max:255',
+ ]);
+
+ // Verifica che la voce di spesa appartenga all'amministratore
+ $voceSpesa = VoceSpesa::whereHas('stabile', function($q) {
+ $q->where('amministratore_id', Auth::user()->amministratore->id_amministratore ?? null);
+ })
+ ->findOrFail($request->voce_spesa_id);
+
+ // Verifica che la tabella millesimale appartenga allo stabile
+ $tabellaMillesimale = TabellaMillesimale::where('id', $request->tabella_millesimale_id)
+ ->where('stabile_id', $voceSpesa->stabile_id)
+ ->firstOrFail();
+
+ DB::beginTransaction();
+
+ try {
+ // Crea la ripartizione principale
+ $ripartizione = RipartizioneSpese::create([
+ 'codice_ripartizione' => $this->generateCodiceRipartizione(),
+ 'voce_spesa_id' => $request->voce_spesa_id,
+ 'descrizione' => $request->descrizione,
+ 'importo_totale' => $request->importo_totale,
+ 'data_ripartizione' => $request->data_ripartizione,
+ 'tabella_millesimale_id' => $request->tabella_millesimale_id,
+ 'periodo_riferimento' => $request->periodo_riferimento,
+ 'note' => $request->note,
+ 'stato' => 'bozza',
+ 'created_by' => Auth::id(),
+ ]);
+
+ // Se non sono stati forniti dettagli, calcola automaticamente
+ if (empty($request->dettagli)) {
+ $this->calcolaRipartizioneAutomatica($ripartizione);
+ } else {
+ // Crea i dettagli forniti
+ foreach ($request->dettagli as $dettaglio) {
+ DettaglioRipartizioneSpese::create([
+ 'ripartizione_spese_id' => $ripartizione->id,
+ 'unita_immobiliare_id' => $dettaglio['unita_immobiliare_id'],
+ 'importo' => $dettaglio['importo'],
+ 'esclusa' => $dettaglio['esclusa'] ?? false,
+ 'note' => $dettaglio['note'] ?? null,
+ ]);
+ }
+ }
+
+ DB::commit();
+
+ return redirect()->route('admin.ripartizioni-spesa.show', $ripartizione)
+ ->with('success', 'Ripartizione spesa creata con successo.');
+
+ } catch (\Exception $e) {
+ DB::rollBack();
+ return redirect()->back()
+ ->withInput()
+ ->with('error', 'Errore durante la creazione della ripartizione: ' . $e->getMessage());
+ }
+ }
+
+ /**
+ * Display the specified resource.
+ */
+ public function show(RipartizioneSpese $ripartizioneSpesa)
+ {
+ // Verifica autorizzazione
+ $this->authorize('view', $ripartizioneSpesa);
+
+ $ripartizioneSpesa->load([
+ 'voceSpesa.stabile',
+ 'tabellaMillesimale',
+ 'dettagli.unitaImmobiliare.anagraficaCondominiale.soggetto',
+ 'createdBy',
+ 'updatedBy'
+ ]);
+
+ return view('admin.ripartizioni-spesa.show', compact('ripartizioneSpesa'));
+ }
+
+ /**
+ * Show the form for editing the specified resource.
+ */
+ public function edit(RipartizioneSpese $ripartizioneSpesa)
+ {
+ // Verifica autorizzazione
+ $this->authorize('update', $ripartizioneSpesa);
+
+ // Solo le ripartizioni in bozza possono essere modificate
+ if ($ripartizioneSpesa->stato !== 'bozza') {
+ return redirect()->route('admin.ripartizioni-spesa.show', $ripartizioneSpesa)
+ ->with('error', 'Impossibile modificare una ripartizione giΓ confermata.');
+ }
+
+ $ripartizioneSpesa->load([
+ 'voceSpesa.stabile',
+ 'tabellaMillesimale',
+ 'dettagli.unitaImmobiliare'
+ ]);
+
+ $tabelleMillesimali = TabellaMillesimale::where('stabile_id', $ripartizioneSpesa->voceSpesa->stabile_id)
+ ->where('stato', 'attiva')
+ ->orderBy('denominazione')
+ ->get();
+
+ return view('admin.ripartizioni-spesa.edit', compact('ripartizioneSpesa', 'tabelleMillesimali'));
+ }
+
+ /**
+ * Update the specified resource in storage.
+ */
+ public function update(Request $request, RipartizioneSpese $ripartizioneSpesa)
+ {
+ // Verifica autorizzazione
+ $this->authorize('update', $ripartizioneSpesa);
+
+ // Solo le ripartizioni in bozza possono essere modificate
+ if ($ripartizioneSpesa->stato !== 'bozza') {
+ return redirect()->route('admin.ripartizioni-spesa.show', $ripartizioneSpesa)
+ ->with('error', 'Impossibile modificare una ripartizione giΓ confermata.');
+ }
+
+ $request->validate([
+ 'descrizione' => 'required|string|max:255',
+ 'importo_totale' => 'required|numeric|min:0',
+ 'data_ripartizione' => 'required|date',
+ 'tabella_millesimale_id' => 'required|exists:tabelle_millesimali,id',
+ 'periodo_riferimento' => 'nullable|string|max:100',
+ 'note' => 'nullable|string',
+ 'dettagli' => 'nullable|array',
+ 'dettagli.*.unita_immobiliare_id' => 'required|exists:unita_immobiliari,id',
+ 'dettagli.*.importo' => 'required|numeric|min:0',
+ 'dettagli.*.esclusa' => 'nullable|boolean',
+ 'dettagli.*.note' => 'nullable|string|max:255',
+ ]);
+
+ // Verifica che la tabella millesimale appartenga allo stabile
+ $tabellaMillesimale = TabellaMillesimale::where('id', $request->tabella_millesimale_id)
+ ->where('stabile_id', $ripartizioneSpesa->voceSpesa->stabile_id)
+ ->firstOrFail();
+
+ DB::beginTransaction();
+
+ try {
+ // Aggiorna la ripartizione principale
+ $ripartizioneSpesa->update([
+ 'descrizione' => $request->descrizione,
+ 'importo_totale' => $request->importo_totale,
+ 'data_ripartizione' => $request->data_ripartizione,
+ 'tabella_millesimale_id' => $request->tabella_millesimale_id,
+ 'periodo_riferimento' => $request->periodo_riferimento,
+ 'note' => $request->note,
+ 'updated_by' => Auth::id(),
+ ]);
+
+ // Elimina i dettagli esistenti
+ $ripartizioneSpesa->dettagli()->delete();
+
+ // Se non sono stati forniti dettagli, calcola automaticamente
+ if (empty($request->dettagli)) {
+ $this->calcolaRipartizioneAutomatica($ripartizioneSpesa);
+ } else {
+ // Crea i nuovi dettagli
+ foreach ($request->dettagli as $dettaglio) {
+ DettaglioRipartizioneSpese::create([
+ 'ripartizione_spese_id' => $ripartizioneSpesa->id,
+ 'unita_immobiliare_id' => $dettaglio['unita_immobiliare_id'],
+ 'importo' => $dettaglio['importo'],
+ 'esclusa' => $dettaglio['esclusa'] ?? false,
+ 'note' => $dettaglio['note'] ?? null,
+ ]);
+ }
+ }
+
+ DB::commit();
+
+ return redirect()->route('admin.ripartizioni-spesa.show', $ripartizioneSpesa)
+ ->with('success', 'Ripartizione spesa aggiornata con successo.');
+
+ } catch (\Exception $e) {
+ DB::rollBack();
+ return redirect()->back()
+ ->withInput()
+ ->with('error', 'Errore durante l\'aggiornamento della ripartizione: ' . $e->getMessage());
+ }
+ }
+
+ /**
+ * Remove the specified resource from storage.
+ */
+ public function destroy(RipartizioneSpese $ripartizioneSpesa)
+ {
+ // Verifica autorizzazione
+ $this->authorize('delete', $ripartizioneSpesa);
+
+ // Solo le ripartizioni in bozza possono essere eliminate
+ if ($ripartizioneSpesa->stato !== 'bozza') {
+ return redirect()->route('admin.ripartizioni-spesa.index')
+ ->with('error', 'Impossibile eliminare una ripartizione giΓ confermata.');
+ }
+
+ DB::beginTransaction();
+
+ try {
+ // Elimina i dettagli
+ $ripartizioneSpesa->dettagli()->delete();
+
+ // Elimina la ripartizione
+ $ripartizioneSpesa->delete();
+
+ DB::commit();
+
+ return redirect()->route('admin.ripartizioni-spesa.index')
+ ->with('success', 'Ripartizione spesa eliminata con successo.');
+
+ } catch (\Exception $e) {
+ DB::rollBack();
+ return redirect()->route('admin.ripartizioni-spesa.index')
+ ->with('error', 'Errore durante l\'eliminazione della ripartizione: ' . $e->getMessage());
+ }
+ }
+
+ /**
+ * Conferma una ripartizione spesa
+ */
+ public function conferma(RipartizioneSpese $ripartizioneSpesa)
+ {
+ // Verifica autorizzazione
+ $this->authorize('update', $ripartizioneSpesa);
+
+ if ($ripartizioneSpesa->stato !== 'bozza') {
+ return redirect()->route('admin.ripartizioni-spesa.show', $ripartizioneSpesa)
+ ->with('error', 'La ripartizione Γ¨ giΓ stata confermata.');
+ }
+
+ // Verifica che ci siano dettagli
+ if (!$ripartizioneSpesa->dettagli()->exists()) {
+ return redirect()->route('admin.ripartizioni-spesa.show', $ripartizioneSpesa)
+ ->with('error', 'Impossibile confermare una ripartizione senza dettagli.');
+ }
+
+ // Verifica che la somma dei dettagli corrisponda all'importo totale
+ $sommaDettagli = $ripartizioneSpesa->dettagli()->sum('importo');
+ if (abs($sommaDettagli - $ripartizioneSpesa->importo_totale) > 0.01) {
+ return redirect()->route('admin.ripartizioni-spesa.show', $ripartizioneSpesa)
+ ->with('error', 'La somma dei dettagli non corrisponde all\'importo totale.');
+ }
+
+ $ripartizioneSpesa->update([
+ 'stato' => 'confermata',
+ 'data_conferma' => now(),
+ 'confermata_by' => Auth::id(),
+ ]);
+
+ return redirect()->route('admin.ripartizioni-spesa.show', $ripartizioneSpesa)
+ ->with('success', 'Ripartizione spesa confermata con successo.');
+ }
+
+ /**
+ * Calcola automaticamente la ripartizione basata sui millesimi
+ */
+ private function calcolaRipartizioneAutomatica(RipartizioneSpese $ripartizione)
+ {
+ $tabella = $ripartizione->tabellaMillesimale;
+ $unita = UnitaImmobiliare::where('stabile_id', $ripartizione->voceSpesa->stabile_id)->get();
+
+ foreach ($unita as $unita_immobiliare) {
+ $millesimi = $tabella->getMillesimiForUnita($unita_immobiliare->id);
+ $importo = ($ripartizione->importo_totale * $millesimi) / 1000;
+
+ DettaglioRipartizioneSpese::create([
+ 'ripartizione_spese_id' => $ripartizione->id,
+ 'unita_immobiliare_id' => $unita_immobiliare->id,
+ 'importo' => round($importo, 2),
+ 'esclusa' => false,
+ ]);
+ }
+ }
+
+ /**
+ * Genera un codice ripartizione univoco
+ */
+ private function generateCodiceRipartizione(): string
+ {
+ do {
+ $codice = 'RP' . strtoupper(Str::random(6));
+ } while (RipartizioneSpese::where('codice_ripartizione', $codice)->exists());
+
+ return $codice;
+ }
+}
diff --git a/app/Http/Controllers/Admin/VoceSpesaController.php b/app/Http/Controllers/Admin/VoceSpesaController.php
new file mode 100644
index 00000000..1b608341
--- /dev/null
+++ b/app/Http/Controllers/Admin/VoceSpesaController.php
@@ -0,0 +1,282 @@
+whereHas('stabile', function($q) {
+ $q->where('amministratore_id', Auth::user()->amministratore->id_amministratore ?? null);
+ });
+
+ // Filtro per stabile
+ if ($request->filled('stabile_id')) {
+ $query->where('stabile_id', $request->stabile_id);
+ }
+
+ // Filtro per categoria
+ if ($request->filled('categoria')) {
+ $query->where('categoria', $request->categoria);
+ }
+
+ // Filtro per stato
+ if ($request->filled('stato')) {
+ $query->where('stato', $request->stato);
+ }
+
+ // Ricerca per denominazione
+ if ($request->filled('search')) {
+ $query->where('denominazione', 'like', '%' . $request->search . '%');
+ }
+
+ $vociSpesa = $query->orderBy('denominazione')->paginate(15);
+
+ // Dati per i filtri
+ $stabili = Stabile::where('amministratore_id', Auth::user()->amministratore->id_amministratore ?? null)
+ ->orderBy('denominazione')
+ ->get();
+
+ $categorie = VoceSpesa::distinct()->pluck('categoria')->filter()->sort();
+
+ return view('admin.voci-spesa.index', compact('vociSpesa', 'stabili', 'categorie'));
+ }
+
+ /**
+ * Show the form for creating a new resource.
+ */
+ public function create()
+ {
+ $stabili = Stabile::where('amministratore_id', Auth::user()->amministratore->id_amministratore ?? null)
+ ->orderBy('denominazione')
+ ->get();
+
+ $tabelleMillesimali = TabellaMillesimale::whereHas('stabile', function($q) {
+ $q->where('amministratore_id', Auth::user()->amministratore->id_amministratore ?? null);
+ })
+ ->orderBy('denominazione')
+ ->get();
+
+ return view('admin.voci-spesa.create', compact('stabili', 'tabelleMillesimali'));
+ }
+
+ /**
+ * Store a newly created resource in storage.
+ */
+ public function store(Request $request)
+ {
+ $request->validate([
+ 'stabile_id' => 'required|exists:stabili,id',
+ 'denominazione' => 'required|string|max:255',
+ 'categoria' => 'required|string|max:100',
+ 'sottocategoria' => 'nullable|string|max:100',
+ 'descrizione' => 'nullable|string',
+ 'importo_previsto' => 'nullable|numeric|min:0',
+ 'periodicita' => 'nullable|in:una_tantum,mensile,trimestrale,semestrale,annuale',
+ 'tabella_millesimale_default_id' => 'required|exists:tabelle_millesimali,id',
+ 'ripartizione_personalizzata' => 'nullable|boolean',
+ 'stato' => 'required|in:attiva,inattiva,archiviata',
+ 'note' => 'nullable|string',
+ 'tags' => 'nullable|string|max:500',
+ ]);
+
+ // Verifica che lo stabile appartenga all'amministratore
+ $stabile = Stabile::where('id', $request->stabile_id)
+ ->where('amministratore_id', Auth::user()->amministratore->id_amministratore ?? null)
+ ->firstOrFail();
+
+ // Verifica che la tabella millesimale appartenga allo stabile
+ $tabellaMillesimale = TabellaMillesimale::where('id', $request->tabella_millesimale_default_id)
+ ->where('stabile_id', $request->stabile_id)
+ ->firstOrFail();
+
+ $voceSpesa = VoceSpesa::create([
+ 'codice_spesa' => $this->generateCodiceSpesa(),
+ 'stabile_id' => $request->stabile_id,
+ 'denominazione' => $request->denominazione,
+ 'categoria' => $request->categoria,
+ 'sottocategoria' => $request->sottocategoria,
+ 'descrizione' => $request->descrizione,
+ 'importo_previsto' => $request->importo_previsto,
+ 'periodicita' => $request->periodicita,
+ 'tabella_millesimale_default_id' => $request->tabella_millesimale_default_id,
+ 'ripartizione_personalizzata' => $request->boolean('ripartizione_personalizzata'),
+ 'stato' => $request->stato,
+ 'note' => $request->note,
+ 'tags' => $request->tags,
+ 'created_by' => Auth::id(),
+ ]);
+
+ return redirect()->route('admin.voci-spesa.index')
+ ->with('success', 'Voce di spesa creata con successo.');
+ }
+
+ /**
+ * Display the specified resource.
+ */
+ public function show(VoceSpesa $voceSpesa)
+ {
+ // Verifica autorizzazione
+ $this->authorize('view', $voceSpesa);
+
+ $voceSpesa->load(['stabile', 'tabellaMillesimaleDefault', 'ripartizioniSpese.dettagli.unitaImmobiliare']);
+
+ return view('admin.voci-spesa.show', compact('voceSpesa'));
+ }
+
+ /**
+ * Show the form for editing the specified resource.
+ */
+ public function edit(VoceSpesa $voceSpesa)
+ {
+ // Verifica autorizzazione
+ $this->authorize('update', $voceSpesa);
+
+ $stabili = Stabile::where('amministratore_id', Auth::user()->amministratore->id_amministratore ?? null)
+ ->orderBy('denominazione')
+ ->get();
+
+ $tabelleMillesimali = TabellaMillesimale::where('stabile_id', $voceSpesa->stabile_id)
+ ->orderBy('denominazione')
+ ->get();
+
+ return view('admin.voci-spesa.edit', compact('voceSpesa', 'stabili', 'tabelleMillesimali'));
+ }
+
+ /**
+ * Update the specified resource in storage.
+ */
+ public function update(Request $request, VoceSpesa $voceSpesa)
+ {
+ // Verifica autorizzazione
+ $this->authorize('update', $voceSpesa);
+
+ $request->validate([
+ 'denominazione' => 'required|string|max:255',
+ 'categoria' => 'required|string|max:100',
+ 'sottocategoria' => 'nullable|string|max:100',
+ 'descrizione' => 'nullable|string',
+ 'importo_previsto' => 'nullable|numeric|min:0',
+ 'periodicita' => 'nullable|in:una_tantum,mensile,trimestrale,semestrale,annuale',
+ 'tabella_millesimale_default_id' => 'required|exists:tabelle_millesimali,id',
+ 'ripartizione_personalizzata' => 'nullable|boolean',
+ 'stato' => 'required|in:attiva,inattiva,archiviata',
+ 'note' => 'nullable|string',
+ 'tags' => 'nullable|string|max:500',
+ ]);
+
+ // Verifica che la tabella millesimale appartenga allo stabile
+ $tabellaMillesimale = TabellaMillesimale::where('id', $request->tabella_millesimale_default_id)
+ ->where('stabile_id', $voceSpesa->stabile_id)
+ ->firstOrFail();
+
+ $voceSpesa->update([
+ 'denominazione' => $request->denominazione,
+ 'categoria' => $request->categoria,
+ 'sottocategoria' => $request->sottocategoria,
+ 'descrizione' => $request->descrizione,
+ 'importo_previsto' => $request->importo_previsto,
+ 'periodicita' => $request->periodicita,
+ 'tabella_millesimale_default_id' => $request->tabella_millesimale_default_id,
+ 'ripartizione_personalizzata' => $request->boolean('ripartizione_personalizzata'),
+ 'stato' => $request->stato,
+ 'note' => $request->note,
+ 'tags' => $request->tags,
+ 'updated_by' => Auth::id(),
+ ]);
+
+ return redirect()->route('admin.voci-spesa.index')
+ ->with('success', 'Voce di spesa aggiornata con successo.');
+ }
+
+ /**
+ * Remove the specified resource from storage.
+ */
+ public function destroy(VoceSpesa $voceSpesa)
+ {
+ // Verifica autorizzazione
+ $this->authorize('delete', $voceSpesa);
+
+ // Verifica che non ci siano ripartizioni associate
+ if ($voceSpesa->ripartizioniSpese()->exists()) {
+ return redirect()->route('admin.voci-spesa.index')
+ ->with('error', 'Impossibile eliminare la voce di spesa: esistono ripartizioni associate.');
+ }
+
+ $voceSpesa->delete();
+
+ return redirect()->route('admin.voci-spesa.index')
+ ->with('success', 'Voce di spesa eliminata con successo.');
+ }
+
+ /**
+ * Duplica una voce di spesa esistente
+ */
+ public function duplicate(VoceSpesa $voceSpesa)
+ {
+ // Verifica autorizzazione
+ $this->authorize('view', $voceSpesa);
+
+ $nuovaVoceSpesa = $voceSpesa->replicate();
+ $nuovaVoceSpesa->codice_spesa = $this->generateCodiceSpesa();
+ $nuovaVoceSpesa->denominazione = $voceSpesa->denominazione . ' (Copia)';
+ $nuovaVoceSpesa->stato = 'inattiva';
+ $nuovaVoceSpesa->created_by = Auth::id();
+ $nuovaVoceSpesa->save();
+
+ return redirect()->route('admin.voci-spesa.edit', $nuovaVoceSpesa)
+ ->with('success', 'Voce di spesa duplicata con successo.');
+ }
+
+ /**
+ * Ottieni le tabelle millesimali per uno stabile (AJAX)
+ */
+ public function getTabelleMillesimali(Request $request)
+ {
+ $stabileId = $request->get('stabile_id');
+
+ if (!$stabileId) {
+ return response()->json([]);
+ }
+
+ // Verifica che lo stabile appartenga all'amministratore
+ $stabile = Stabile::where('id', $stabileId)
+ ->where('amministratore_id', Auth::user()->amministratore->id_amministratore ?? null)
+ ->first();
+
+ if (!$stabile) {
+ return response()->json([]);
+ }
+
+ $tabelle = TabellaMillesimale::where('stabile_id', $stabileId)
+ ->where('stato', 'attiva')
+ ->orderBy('denominazione')
+ ->get(['id', 'denominazione', 'tipo_tabella']);
+
+ return response()->json($tabelle);
+ }
+
+ /**
+ * Genera un codice spesa univoco
+ */
+ private function generateCodiceSpesa(): string
+ {
+ do {
+ $codice = 'SP' . strtoupper(Str::random(6));
+ } while (VoceSpesa::where('codice_spesa', $codice)->exists());
+
+ return $codice;
+ }
+}
diff --git a/app/Policies/PianoRateizzazionePolicy.php b/app/Policies/PianoRateizzazionePolicy.php
new file mode 100644
index 00000000..94f387d0
--- /dev/null
+++ b/app/Policies/PianoRateizzazionePolicy.php
@@ -0,0 +1,90 @@
+hasRole(['admin', 'amministratore']);
+ }
+
+ /**
+ * Determine whether the user can view the model.
+ */
+ public function view(User $user, PianoRateizzazione $pianoRateizzazione): bool
+ {
+ if ($user->hasRole('admin')) {
+ return true;
+ }
+
+ if ($user->hasRole('amministratore')) {
+ return $pianoRateizzazione->ripartizioneSpese->voceSpesa->stabile->amministratore_id === $user->amministratore->id_amministratore;
+ }
+
+ return false;
+ }
+
+ /**
+ * Determine whether the user can create models.
+ */
+ public function create(User $user): bool
+ {
+ return $user->hasRole(['admin', 'amministratore']);
+ }
+
+ /**
+ * Determine whether the user can update the model.
+ */
+ public function update(User $user, PianoRateizzazione $pianoRateizzazione): bool
+ {
+ if ($user->hasRole('admin')) {
+ return true;
+ }
+
+ if ($user->hasRole('amministratore')) {
+ return $pianoRateizzazione->ripartizioneSpese->voceSpesa->stabile->amministratore_id === $user->amministratore->id_amministratore;
+ }
+
+ return false;
+ }
+
+ /**
+ * Determine whether the user can delete the model.
+ */
+ public function delete(User $user, PianoRateizzazione $pianoRateizzazione): bool
+ {
+ if ($user->hasRole('admin')) {
+ return true;
+ }
+
+ if ($user->hasRole('amministratore')) {
+ return $pianoRateizzazione->ripartizioneSpese->voceSpesa->stabile->amministratore_id === $user->amministratore->id_amministratore;
+ }
+
+ return false;
+ }
+
+ /**
+ * Determine whether the user can restore the model.
+ */
+ public function restore(User $user, PianoRateizzazione $pianoRateizzazione): bool
+ {
+ return $this->delete($user, $pianoRateizzazione);
+ }
+
+ /**
+ * Determine whether the user can permanently delete the model.
+ */
+ public function forceDelete(User $user, PianoRateizzazione $pianoRateizzazione): bool
+ {
+ return $user->hasRole('admin');
+ }
+}
diff --git a/app/Policies/RataPolicy.php b/app/Policies/RataPolicy.php
new file mode 100644
index 00000000..0a98437f
--- /dev/null
+++ b/app/Policies/RataPolicy.php
@@ -0,0 +1,90 @@
+hasRole(['admin', 'amministratore']);
+ }
+
+ /**
+ * Determine whether the user can view the model.
+ */
+ public function view(User $user, Rata $rata): bool
+ {
+ if ($user->hasRole('admin')) {
+ return true;
+ }
+
+ if ($user->hasRole('amministratore')) {
+ return $rata->pianoRateizzazione->ripartizioneSpese->voceSpesa->stabile->amministratore_id === $user->amministratore->id_amministratore;
+ }
+
+ return false;
+ }
+
+ /**
+ * Determine whether the user can create models.
+ */
+ public function create(User $user): bool
+ {
+ return $user->hasRole(['admin', 'amministratore']);
+ }
+
+ /**
+ * Determine whether the user can update the model.
+ */
+ public function update(User $user, Rata $rata): bool
+ {
+ if ($user->hasRole('admin')) {
+ return true;
+ }
+
+ if ($user->hasRole('amministratore')) {
+ return $rata->pianoRateizzazione->ripartizioneSpese->voceSpesa->stabile->amministratore_id === $user->amministratore->id_amministratore;
+ }
+
+ return false;
+ }
+
+ /**
+ * Determine whether the user can delete the model.
+ */
+ public function delete(User $user, Rata $rata): bool
+ {
+ if ($user->hasRole('admin')) {
+ return true;
+ }
+
+ if ($user->hasRole('amministratore')) {
+ return $rata->pianoRateizzazione->ripartizioneSpese->voceSpesa->stabile->amministratore_id === $user->amministratore->id_amministratore;
+ }
+
+ return false;
+ }
+
+ /**
+ * Determine whether the user can restore the model.
+ */
+ public function restore(User $user, Rata $rata): bool
+ {
+ return $this->delete($user, $rata);
+ }
+
+ /**
+ * Determine whether the user can permanently delete the model.
+ */
+ public function forceDelete(User $user, Rata $rata): bool
+ {
+ return $user->hasRole('admin');
+ }
+}
diff --git a/app/Policies/RipartizioneSpesaPolicy.php b/app/Policies/RipartizioneSpesaPolicy.php
new file mode 100644
index 00000000..fecabc06
--- /dev/null
+++ b/app/Policies/RipartizioneSpesaPolicy.php
@@ -0,0 +1,90 @@
+hasRole(['admin', 'amministratore']);
+ }
+
+ /**
+ * Determine whether the user can view the model.
+ */
+ public function view(User $user, RipartizioneSpese $ripartizioneSpese): bool
+ {
+ if ($user->hasRole('admin')) {
+ return true;
+ }
+
+ if ($user->hasRole('amministratore')) {
+ return $ripartizioneSpese->voceSpesa->stabile->amministratore_id === $user->amministratore->id_amministratore;
+ }
+
+ return false;
+ }
+
+ /**
+ * Determine whether the user can create models.
+ */
+ public function create(User $user): bool
+ {
+ return $user->hasRole(['admin', 'amministratore']);
+ }
+
+ /**
+ * Determine whether the user can update the model.
+ */
+ public function update(User $user, RipartizioneSpese $ripartizioneSpese): bool
+ {
+ if ($user->hasRole('admin')) {
+ return true;
+ }
+
+ if ($user->hasRole('amministratore')) {
+ return $ripartizioneSpese->voceSpesa->stabile->amministratore_id === $user->amministratore->id_amministratore;
+ }
+
+ return false;
+ }
+
+ /**
+ * Determine whether the user can delete the model.
+ */
+ public function delete(User $user, RipartizioneSpese $ripartizioneSpese): bool
+ {
+ if ($user->hasRole('admin')) {
+ return true;
+ }
+
+ if ($user->hasRole('amministratore')) {
+ return $ripartizioneSpese->voceSpesa->stabile->amministratore_id === $user->amministratore->id_amministratore;
+ }
+
+ return false;
+ }
+
+ /**
+ * Determine whether the user can restore the model.
+ */
+ public function restore(User $user, RipartizioneSpese $ripartizioneSpese): bool
+ {
+ return $this->delete($user, $ripartizioneSpese);
+ }
+
+ /**
+ * Determine whether the user can permanently delete the model.
+ */
+ public function forceDelete(User $user, RipartizioneSpese $ripartizioneSpese): bool
+ {
+ return $user->hasRole('admin');
+ }
+}
diff --git a/app/Policies/VoceSpesaPolicy.php b/app/Policies/VoceSpesaPolicy.php
new file mode 100644
index 00000000..29cecbc2
--- /dev/null
+++ b/app/Policies/VoceSpesaPolicy.php
@@ -0,0 +1,90 @@
+hasRole(['admin', 'amministratore']);
+ }
+
+ /**
+ * Determine whether the user can view the model.
+ */
+ public function view(User $user, VoceSpesa $voceSpesa): bool
+ {
+ if ($user->hasRole('admin')) {
+ return true;
+ }
+
+ if ($user->hasRole('amministratore')) {
+ return $voceSpesa->stabile->amministratore_id === $user->amministratore->id_amministratore;
+ }
+
+ return false;
+ }
+
+ /**
+ * Determine whether the user can create models.
+ */
+ public function create(User $user): bool
+ {
+ return $user->hasRole(['admin', 'amministratore']);
+ }
+
+ /**
+ * Determine whether the user can update the model.
+ */
+ public function update(User $user, VoceSpesa $voceSpesa): bool
+ {
+ if ($user->hasRole('admin')) {
+ return true;
+ }
+
+ if ($user->hasRole('amministratore')) {
+ return $voceSpesa->stabile->amministratore_id === $user->amministratore->id_amministratore;
+ }
+
+ return false;
+ }
+
+ /**
+ * Determine whether the user can delete the model.
+ */
+ public function delete(User $user, VoceSpesa $voceSpesa): bool
+ {
+ if ($user->hasRole('admin')) {
+ return true;
+ }
+
+ if ($user->hasRole('amministratore')) {
+ return $voceSpesa->stabile->amministratore_id === $user->amministratore->id_amministratore;
+ }
+
+ return false;
+ }
+
+ /**
+ * Determine whether the user can restore the model.
+ */
+ public function restore(User $user, VoceSpesa $voceSpesa): bool
+ {
+ return $this->delete($user, $voceSpesa);
+ }
+
+ /**
+ * Determine whether the user can permanently delete the model.
+ */
+ public function forceDelete(User $user, VoceSpesa $voceSpesa): bool
+ {
+ return $user->hasRole('admin');
+ }
+}
diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php
index 867c060e..6c4157b1 100644
--- a/app/Providers/AuthServiceProvider.php
+++ b/app/Providers/AuthServiceProvider.php
@@ -6,7 +6,15 @@ use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvid
use Illuminate\Support\Facades\Gate;
use App\Models\User;
use App\Models\Stabile;
+use App\Models\VoceSpesa;
+use App\Models\RipartizioneSpese;
+use App\Models\PianoRateizzazione;
+use App\Models\Rata;
use App\Policies\StabilePolicy;
+use App\Policies\VoceSpesaPolicy;
+use App\Policies\RipartizioneSpesaPolicy;
+use App\Policies\PianoRateizzazionePolicy;
+use App\Policies\RataPolicy;
class AuthServiceProvider extends ServiceProvider
@@ -16,8 +24,11 @@ class AuthServiceProvider extends ServiceProvider
* @var array
*/
protected $policies = [
- //
Stabile::class => StabilePolicy::class,
+ VoceSpesa::class => VoceSpesaPolicy::class,
+ RipartizioneSpese::class => RipartizioneSpesaPolicy::class,
+ PianoRateizzazione::class => PianoRateizzazionePolicy::class,
+ Rata::class => RataPolicy::class,
];
/**
diff --git a/resources/views/admin/voci-spesa/create.blade.php b/resources/views/admin/voci-spesa/create.blade.php
new file mode 100644
index 00000000..6390b4df
--- /dev/null
+++ b/resources/views/admin/voci-spesa/create.blade.php
@@ -0,0 +1,242 @@
+@extends('layouts.app')
+
+@section('title', 'Nuova Voce di Spesa')
+
+@section('content')
+
+@endsection
+
+@section('scripts')
+
+@endsection
diff --git a/resources/views/admin/voci-spesa/index.blade.php b/resources/views/admin/voci-spesa/index.blade.php
new file mode 100644
index 00000000..345c10a0
--- /dev/null
+++ b/resources/views/admin/voci-spesa/index.blade.php
@@ -0,0 +1,207 @@
+@extends('layouts.app')
+
+@section('title', 'Voci di Spesa')
+
+@section('content')
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Codice |
+ Denominazione |
+ Stabile |
+ Categoria |
+ Importo Previsto |
+ PeriodicitΓ |
+ Stato |
+ Azioni |
+
+
+
+ @forelse($vociSpesa as $voceSpesa)
+
+
+ {{ $voceSpesa->codice_spesa }}
+ |
+
+ {{ $voceSpesa->denominazione }}
+ @if($voceSpesa->sottocategoria)
+ {{ $voceSpesa->sottocategoria }}
+ @endif
+ |
+ {{ $voceSpesa->stabile->denominazione }} |
+
+ {{ $voceSpesa->categoria }}
+ |
+
+ @if($voceSpesa->importo_previsto)
+ β¬ {{ number_format($voceSpesa->importo_previsto, 2, ',', '.') }}
+ @else
+ Non specificato
+ @endif
+ |
+
+ @if($voceSpesa->periodicita)
+ {{ ucfirst(str_replace('_', ' ', $voceSpesa->periodicita)) }}
+ @else
+ -
+ @endif
+ |
+
+ @switch($voceSpesa->stato)
+ @case('attiva')
+ Attiva
+ @break
+ @case('inattiva')
+ Inattiva
+ @break
+ @case('archiviata')
+ Archiviata
+ @break
+ @endswitch
+ |
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+ @empty
+
+ |
+
+ |
+
+ @endforelse
+
+
+
+
+
+ @if($vociSpesa->hasPages())
+
+ {{ $vociSpesa->appends(request()->query())->links() }}
+
+ @endif
+
+
+
+
+
+@endsection
+
+@section('scripts')
+
+@endsection
diff --git a/routes/web.php b/routes/web.php
index 57c13c2d..371d2bb3 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -13,6 +13,10 @@ use App\Http\Controllers\Admin\ContabilitaController;
use App\Http\Controllers\Admin\DocumentoController;
use App\Http\Controllers\Admin\PreventivoController;
use App\Http\Controllers\Admin\BilancioController;
+use App\Http\Controllers\Admin\VoceSpesaController;
+use App\Http\Controllers\Admin\RipartizioneSpesaController;
+use App\Http\Controllers\Admin\PianoRateizzazioneController;
+use App\Http\Controllers\Admin\RataController;
use App\Http\Controllers\Condomino\DashboardController as CondominoDashboardController;
use App\Http\Controllers\Condomino\TicketController as CondominoTicketController;
use App\Http\Controllers\Condomino\DocumentoController as CondominoDocumentoController;
@@ -139,7 +143,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/automazioni/dashboard', [BilancioController::class, 'automazioniDashboard'])->name('automazioni');
});
- // ContabilitΓ
+ // Gestione ContabilitΓ
Route::prefix('contabilita')->name('contabilita.')->group(function () {
Route::get('/', [ContabilitaController::class, 'index'])->name('index');
Route::get('/movimenti', [ContabilitaController::class, 'movimenti'])->name('movimenti');
@@ -149,6 +153,30 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::post('/import-xml', [ContabilitaController::class, 'importXml'])->name('import-xml.store');
});
+ // Gestione Voci di Spesa
+ Route::resource('voci-spesa', VoceSpesaController::class);
+ Route::post('voci-spesa/{voceSpesa}/duplicate', [VoceSpesaController::class, 'duplicate'])->name('voci-spesa.duplicate');
+ Route::get('ajax/tabelle-millesimali', [VoceSpesaController::class, 'getTabelleMillesimali'])->name('ajax.tabelle-millesimali');
+
+ // Gestione Ripartizioni Spesa
+ Route::resource('ripartizioni-spesa', RipartizioneSpesaController::class);
+ Route::post('ripartizioni-spesa/{ripartizioneSpesa}/conferma', [RipartizioneSpesaController::class, 'conferma'])->name('ripartizioni-spesa.conferma');
+
+ // Gestione Piani Rateizzazione
+ Route::resource('piani-rateizzazione', PianoRateizzazioneController::class);
+ Route::post('piani-rateizzazione/{pianoRateizzazione}/attiva', [PianoRateizzazioneController::class, 'attiva'])->name('piani-rateizzazione.attiva');
+ Route::post('piani-rateizzazione/{pianoRateizzazione}/sospendi', [PianoRateizzazioneController::class, 'sospendi'])->name('piani-rateizzazione.sospendi');
+
+ // Gestione Rate
+ Route::resource('rate', RataController::class)->only(['index', 'show', 'edit', 'update']);
+ Route::get('rate/{rata}/pagamento', [RataController::class, 'showPagamentoForm'])->name('rate.pagamento');
+ Route::post('rate/{rata}/registra-pagamento', [RataController::class, 'registraPagamento'])->name('rate.registra-pagamento');
+ Route::delete('rate/{rata}/annulla-pagamento', [RataController::class, 'annullaPagamento'])->name('rate.annulla-pagamento');
+ Route::get('rate/{rata}/posticipo', [RataController::class, 'showPosticipoForm'])->name('rate.posticipo');
+ Route::post('rate/{rata}/posticipa', [RataController::class, 'posticipa'])->name('rate.posticipa');
+ Route::get('rate/report', [RataController::class, 'report'])->name('rate.report');
+ Route::get('rate/export/csv', [RataController::class, 'exportCsv'])->name('rate.export.csv');
+
// Impostazioni e API Tokens
Route::get('impostazioni', [ImpostazioniController::class, 'index'])->name('impostazioni.index');
Route::post('impostazioni', [ImpostazioniController::class, 'store'])->name('impostazioni.store');