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
@@ -0,0 +1,79 @@
<?php
namespace App\Filament\Pages;
use App\Models\Impostazione;
use Filament\Forms;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
/**
* Regola base di scontistica: sconti massimi che ogni cliente può applicare al preventivo,
* salvo massimi personalizzati sulla sua scheda.
*
* @property Form $form
*/
class Scontistica extends Page implements HasForms
{
use InteractsWithForms;
protected static ?string $navigationIcon = 'heroicon-o-receipt-percent';
protected static ?string $navigationLabel = 'Scontistica';
protected static ?string $title = 'Scontistica globale';
protected static ?int $navigationSort = 3;
protected static string $view = 'filament.pages.scontistica';
/** @var array<string, mixed> */
public ?array $data = [];
public function mount(): void
{
$this->form->fill(Impostazione::scontiBase());
}
public function form(Form $form): Form
{
return $form
->statePath('data')
->schema([
Forms\Components\Section::make('Regola base')
->description('Sconti massimi che i clienti possono applicare al preventivo prima dell\'invio: il 1° sull\'imponibile, lo sconto extra sul totale già scontato. Vale per tutti i clienti, salvo quelli con massimi personalizzati o sconti disabilitati nella loro scheda.')
->columns(2)
->schema([
Forms\Components\TextInput::make('max_1')
->label('1° sconto massimo')
->numeric()
->required()
->minValue(0)
->maxValue(100)
->step(0.01)
->suffix('%'),
Forms\Components\TextInput::make('max_2')
->label('Sconto extra massimo')
->numeric()
->required()
->minValue(0)
->maxValue(100)
->step(0.01)
->suffix('%'),
]),
]);
}
public function save(): void
{
$dati = $this->form->getState();
Impostazione::imposta('sconti.max_1', (float) $dati['max_1']);
Impostazione::imposta('sconti.max_2', (float) $dati['max_2']);
Notification::make()->title('Scontistica salvata')->success()->send();
}
}
@@ -4,6 +4,7 @@ namespace App\Filament\Resources;
use App\Filament\Resources\ClienteResource\Pages;
use App\Models\Cliente;
use App\Models\Impostazione;
use App\Support\ClienteCredenziali;
use Filament\Forms;
use Filament\Forms\Form;
@@ -63,6 +64,36 @@ class ClienteResource extends Resource
? 'Generata automaticamente. Ricordati di copiarla dopo il salvataggio: non sarà più leggibile.'
: 'Lascia vuoto per mantenere la password attuale, oppure genera/inserisci una nuova password.'),
]),
Forms\Components\Section::make('Scontistica')
->description(fn () => sprintf(
'Sconti che il cliente può applicare al preventivo prima dell\'invio. Lascia vuoto per la regola base (1° sconto fino al %s%%, sconto extra fino al %s%% sul totale già scontato).',
static::percentuale(Impostazione::scontiBase()['max_1']),
static::percentuale(Impostazione::scontiBase()['max_2']),
))
->columns(2)
->schema([
Forms\Components\Toggle::make('sconti_abilitati')
->label('Può applicare sconti')
->default(true)
->live()
->columnSpanFull(),
Forms\Components\TextInput::make('sconto_max_1')
->label('1° sconto massimo')
->numeric()
->minValue(0)
->maxValue(100)
->suffix('%')
->placeholder(fn () => static::percentuale(Impostazione::scontiBase()['max_1']).' (regola base)')
->visible(fn (Forms\Get $get) => (bool) $get('sconti_abilitati')),
Forms\Components\TextInput::make('sconto_max_2')
->label('Sconto extra massimo')
->numeric()
->minValue(0)
->maxValue(100)
->suffix('%')
->placeholder(fn () => static::percentuale(Impostazione::scontiBase()['max_2']).' (regola base)')
->visible(fn (Forms\Get $get) => (bool) $get('sconti_abilitati')),
]),
Forms\Components\Section::make('Anagrafica')
->description('Campi facoltativi.')
->columns(2)
@@ -124,6 +155,19 @@ class ClienteResource extends Resource
->schema([
Infolists\Components\TextEntry::make('username')->label('Nome utente')->copyable()->placeholder('—'),
Infolists\Components\TextEntry::make('created_at')->label('Cliente dal')->dateTime('d/m/Y H:i'),
Infolists\Components\TextEntry::make('scontistica')
->label('Scontistica')
->state(function (Cliente $record): string {
if (! $record->sconti_abilitati) {
return 'Disabilitata';
}
$massimi = $record->scontiMassimi();
$base = $record->sconto_max_1 === null && $record->sconto_max_2 === null ? ' (regola base)' : '';
return sprintf('Fino al %s%% + extra fino al %s%%%s',
static::percentuale($massimi['max_1']), static::percentuale($massimi['max_2']), $base);
})
->columnSpanFull(),
]),
Infolists\Components\Section::make('Anagrafica')
->columns(3)
@@ -140,6 +184,12 @@ class ClienteResource extends Resource
]);
}
/** 50.00 -> "50", 12.50 -> "12,5" */
private static function percentuale(mixed $valore): string
{
return rtrim(rtrim(number_format((float) $valore, 2, ',', ''), '0'), ',');
}
public static function getPages(): array
{
return [
@@ -145,7 +145,28 @@ class PreventivoResource extends Resource
->label('Stato')
->badge()
->formatStateUsing(fn (string $state) => Preventivo::STATI[$state] ?? $state),
Infolists\Components\TextEntry::make('totale')->label('Totale')->money('EUR'),
Infolists\Components\TextEntry::make('subtotale')
->label('Totale parziale')
->money('EUR')
->visible(fn (Preventivo $record) => $record->haSconti()),
Infolists\Components\TextEntry::make('sconti')
->label('Sconti applicati')
->visible(fn (Preventivo $record) => $record->haSconti())
->state(function (Preventivo $record): string {
$importi = $record->importiSconto();
$euro = fn (float $v) => '€ '.number_format($v, 2, ',', '.');
return sprintf('1° sconto %s%% (−%s) · extra %s%% (−%s)',
(float) $record->sconto_1, $euro($importi['sconto_1']),
(float) $record->sconto_2, $euro($importi['sconto_2']));
}),
Infolists\Components\TextEntry::make('trasporto_etichetta')
->label('Trasporto')
->visible(fn (Preventivo $record) => $record->trasporto !== null)
->formatStateUsing(fn (string $state, Preventivo $record) => $record->trasportoDaQuantificare()
? "{$state} — da quantificare"
: $state.' — € '.number_format($record->trasporto_importo, 2, ',', '.')),
Infolists\Components\TextEntry::make('totale')->label('Totale (IVA esclusa)')->money('EUR'),
Infolists\Components\TextEntry::make('confirmed_at')->label('Confermato il')->dateTime('d/m/Y H:i')->placeholder('—'),
Infolists\Components\TextEntry::make('created_at')->label('Creato il')->dateTime('d/m/Y H:i'),
]),
@@ -5,6 +5,7 @@ namespace App\Filament\Resources\PreventivoResource\Pages;
use App\Filament\Resources\PreventivoResource;
use App\Support\PreventivoPdf;
use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord;
use Illuminate\Support\Facades\Storage;
@@ -38,6 +39,21 @@ class ViewPreventivo extends ViewRecord
'preventivo-'.$this->record->numero.'.pdf'
);
}),
// Il PDF è salvato alla conferma e poi riservito così com'è: dopo modifiche al
// template o ai dati va rigenerato.
Actions\Action::make('rigeneraPdf')
->label('Rigenera PDF')
->icon('heroicon-o-arrow-path')
->color('gray')
->visible(fn () => $this->record->stato !== 'bozza')
->requiresConfirmation()
->modalDescription('Il PDF verrà ricreato con il modello e i dati attuali, sostituendo quello salvato.')
->action(function () {
$this->record->pdf_path = PreventivoPdf::genera($this->record);
$this->record->save();
Notification::make()->title('PDF rigenerato')->success()->send();
}),
Actions\EditAction::make(),
];
}