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') +
+
+
+
+
+

+ Nuova Voce di Spesa +

+ + Torna all'elenco + +
+ +
+
+ @csrf + +
+ +
+
+ + + @error('stabile_id') +
{{ $message }}
+ @enderror +
+ +
+ + + @error('denominazione') +
{{ $message }}
+ @enderror +
+ +
+
+
+ + + @error('categoria') +
{{ $message }}
+ @enderror +
+
+
+
+ + + @error('sottocategoria') +
{{ $message }}
+ @enderror +
+
+
+ +
+ + + @error('descrizione') +
{{ $message }}
+ @enderror +
+
+ + +
+
+
+
+ + + @error('importo_previsto') +
{{ $message }}
+ @enderror +
+
+
+
+ + + @error('periodicita') +
{{ $message }}
+ @enderror +
+
+
+ +
+ + + @error('tabella_millesimale_default_id') +
{{ $message }}
+ @enderror +
+ +
+
+ + +
+ + Consente di modificare manualmente la ripartizione per singola unitΓ  immobiliare + +
+ +
+ + + @error('stato') +
{{ $message }}
+ @enderror +
+
+
+ +
+
+
+ + + + Utilizzare per categorizzare e cercare piΓΉ facilmente le voci di spesa + + @error('tags') +
{{ $message }}
+ @enderror +
+
+
+
+ + + @error('note') +
{{ $message }}
+ @enderror +
+
+
+ +
+ + Annulla + + +
+
+
+
+
+
+
+@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') +
+
+
+
+
+

+ Voci di Spesa +

+ + Nuova Voce di Spesa + +
+ +
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + Reset + +
+
+
+
+
+ + +
+ + + + + + + + + + + + + + + @forelse($vociSpesa as $voceSpesa) + + + + + + + + + + + @empty + + + + @endforelse + +
CodiceDenominazioneStabileCategoriaImporto PrevistoPeriodicitΓ StatoAzioni
+ {{ $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 + +
+ + + + + + +
+ @csrf + +
+
+ @csrf + @method('DELETE') + +
+
+
+
+ +

Nessuna voce di spesa trovata

+ + Crea la prima voce di spesa + +
+
+
+ + + @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');