'decimal:2', 'subtotale' => 'decimal:2', 'sconto_1' => 'decimal:2', 'sconto_2' => 'decimal:2', 'totale' => 'decimal:2', 'confirmed_at' => 'datetime', ]; /** Trasporto oltre il numero massimo di pezzi della tariffa cadauno: da quantificare. */ public const TRASPORTO_OLTRE_MAX = 'oltre_max_pezzi'; public const STATI = [ 'bozza' => 'Bozza', 'inviato' => 'Inviato', 'in_lavorazione' => 'In lavorazione', 'chiuso' => 'Chiuso', ]; protected static function booted(): void { static::deleting(function (Preventivo $preventivo): void { // Elimina immagini degli item (disk 'public') e PDF (disk 'local') prima della cancellazione. foreach ($preventivo->items()->get() as $item) { if ($item->immagine_path && Storage::disk('public')->exists($item->immagine_path)) { Storage::disk('public')->delete($item->immagine_path); } $item->delete(); } if ($preventivo->pdf_path && Storage::disk('local')->exists($preventivo->pdf_path)) { Storage::disk('local')->delete($preventivo->pdf_path); } }); } public function cliente(): BelongsTo { return $this->belongsTo(Cliente::class); } public function items(): HasMany { return $this->hasMany(PreventivoItem::class); } /** * Ricalcola e salva subtotale (somma degli item), contributo trasporto e totale: * imponibile scontato + trasporto (che non entra negli sconti). */ public function ricalcolaTotale(): void { $this->subtotale = $this->items()->sum('prezzo_totale'); $pezzi = (int) $this->items()->sum('quantita'); if ($this->trasporto !== null && static::oltreMaxPezzi($pezzi)) { $this->trasporto = self::TRASPORTO_OLTRE_MAX; } $perPezzo = config("preventivi.trasporto.zone.{$this->trasporto}.importo"); $this->trasporto_importo = $perPezzo === null ? null : round($perPezzo * $pezzi, 2); $this->totale = $this->importiSconto()['imponibile'] + (float) $this->trasporto_importo; $this->save(); } public function getTrasportoEtichettaAttribute(): ?string { if ($this->trasporto === self::TRASPORTO_OLTRE_MAX) { return config('preventivi.trasporto.etichetta_oltre_max'); } return $this->trasporto ? config("preventivi.trasporto.zone.{$this->trasporto}.etichetta", $this->trasporto) : null; } /** Oltre il massimo di pezzi il trasporto non si sceglie: è da quantificare. */ public static function oltreMaxPezzi(int $pezzi): bool { return $pezzi > (int) config('preventivi.trasporto.max_pezzi'); } /** Avviso per il cliente quando il trasporto è da quantificare. */ public function notaTrasporto(): ?string { if (! $this->trasportoDaQuantificare()) { return null; } $nota = config('preventivi.trasporto.nota_da_quantificare'); return $this->trasporto === self::TRASPORTO_OLTRE_MAX ? sprintf('%s: il preventivo supera i %d pezzi. %s', config('preventivi.trasporto.etichetta_oltre_max'), config('preventivi.trasporto.max_pezzi'), $nota) : $nota; } /** Zona scelta senza un importo fisso ("Altro"): il costo va chiesto all'azienda. */ public function trasportoDaQuantificare(): bool { return $this->trasporto !== null && $this->trasporto_importo === null; } /** * Sconti a cascata sul totale parziale (IVA esclusa): il 1° sul subtotale, il 2° extra * sul totale già scontato. Es. 1000 -50% = 500, -10% = 450. * * @return array{sconto_1:float,sconto_2:float,imponibile:float} */ public function importiSconto(): array { $subtotale = (float) $this->subtotale; $sconto1 = round($subtotale * (float) $this->sconto_1 / 100, 2); $sconto2 = round(($subtotale - $sconto1) * (float) $this->sconto_2 / 100, 2); return [ 'sconto_1' => $sconto1, 'sconto_2' => $sconto2, 'imponibile' => round($subtotale - $sconto1 - $sconto2, 2), ]; } public function haSconti(): bool { return (float) $this->sconto_1 > 0 || (float) $this->sconto_2 > 0; } /** Numero progressivo assegnato alla conferma (max+1). */ public static function prossimoNumero(): int { return (int) static::max('numero') + 1; } }