feat: implement discount and transport features in the quote system

- Added maximum discount settings configurable by the administrator.
- Implemented discount calculations in the quote confirmation process.
- Introduced transport options with associated costs, including handling for exceeding maximum piece limits.
- Updated frontend to display discounts and transport information in the quote summary.
- Created new database migrations for storing discount and transport settings.
- Developed a new Filament page for managing global discount settings.
- Enhanced email templates to include discount and transport details.
- Added tests to ensure proper functionality of discounts and transport calculations.
This commit is contained in:
2026-10-20 17:45:50 +02:00
parent 06617a985a
commit 8bef57b64b
27 changed files with 875 additions and 26 deletions
+22
View File
@@ -14,6 +14,7 @@ class Cliente extends Model
protected $fillable = [
'username', 'password',
'sconti_abilitati', 'sconto_max_1', 'sconto_max_2',
'nome', 'cognome', 'ragione_sociale', 'email', 'telefono',
'indirizzo', 'cap', 'citta', 'provincia', 'note',
];
@@ -24,8 +25,29 @@ class Cliente extends Model
protected $casts = [
'password' => 'hashed',
'sconti_abilitati' => 'boolean',
'sconto_max_1' => 'decimal:2',
'sconto_max_2' => 'decimal:2',
];
/**
* Sconti massimi (%) che il cliente può applicare al preventivo: i suoi, se personalizzati,
* altrimenti la regola base. Zero per entrambi se l'amministratore li ha disabilitati.
*
* @return array{max_1:float,max_2:float}
*/
public function scontiMassimi(): array
{
if (! $this->sconti_abilitati) {
return ['max_1' => 0.0, 'max_2' => 0.0];
}
return [
'max_1' => (float) ($this->sconto_max_1 ?? Impostazione::scontiBase()['max_1']),
'max_2' => (float) ($this->sconto_max_2 ?? Impostazione::scontiBase()['max_2']),
];
}
public function preventivi(): HasMany
{
return $this->hasMany(Preventivo::class);
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
class Impostazione extends Model
{
protected $table = 'impostazioni';
protected $fillable = ['chiave', 'valore'];
protected $casts = [
'valore' => 'json',
];
private const CACHE_KEY = 'impostazioni';
/** Valore dell'impostazione, o $default se non è mai stata salvata. */
public static function valore(string $chiave, mixed $default = null): mixed
{
$tutte = Cache::rememberForever(self::CACHE_KEY, fn () => static::query()->pluck('valore', 'chiave')->all());
return $tutte[$chiave] ?? $default;
}
public static function imposta(string $chiave, mixed $valore): void
{
static::query()->updateOrCreate(['chiave' => $chiave], ['valore' => $valore]);
}
/**
* Regola base di scontistica (%), valida per i clienti senza massimi personalizzati:
* quella salvata dall'amministratore, altrimenti config/preventivi.php.
*
* @return array{max_1:float,max_2:float}
*/
public static function scontiBase(): array
{
return [
'max_1' => (float) static::valore('sconti.max_1', config('preventivi.sconti.max_1')),
'max_2' => (float) static::valore('sconti.max_2', config('preventivi.sconti.max_2')),
];
}
protected static function booted(): void
{
static::saved(fn () => Cache::forget(self::CACHE_KEY));
static::deleted(fn () => Cache::forget(self::CACHE_KEY));
}
}
+82 -3
View File
@@ -12,14 +12,21 @@ class Preventivo extends Model
protected $table = 'preventivi';
protected $fillable = [
'cliente_id', 'token', 'numero', 'stato', 'totale', 'pdf_path', 'confirmed_at',
'cliente_id', 'token', 'numero', 'stato', 'subtotale', 'sconto_1', 'sconto_2', 'trasporto', 'trasporto_importo', 'totale', 'pdf_path', 'confirmed_at',
];
protected $casts = [
'trasporto_importo' => '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',
@@ -55,13 +62,85 @@ class Preventivo extends Model
return $this->hasMany(PreventivoItem::class);
}
/** Ricalcola e salva il totale dagli item. */
/**
* Ricalcola e salva subtotale (somma degli item), contributo trasporto e totale:
* imponibile scontato + trasporto (che non entra negli sconti).
*/
public function ricalcolaTotale(): void
{
$this->totale = $this->items()->sum('prezzo_totale');
$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
{