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:
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -67,6 +67,8 @@ class AuthController extends Controller
|
||||
'provincia' => $cliente->provincia,
|
||||
'note' => $cliente->note,
|
||||
],
|
||||
// Sconti massimi (%) applicabili al preventivo prima dell'invio (0 = non consentito).
|
||||
'sconti' => $cliente->scontiMassimi(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class CarrelloController extends Controller
|
||||
{
|
||||
@@ -142,6 +143,15 @@ class CarrelloController extends Controller
|
||||
'note' => ['nullable', 'string', 'max:2000'],
|
||||
]);
|
||||
|
||||
// Oltre il massimo di pezzi il trasporto non si sceglie: è da quantificare.
|
||||
$trasporto = Preventivo::oltreMaxPezzi((int) $preventivo->items->sum('quantita'))
|
||||
? Preventivo::TRASPORTO_OLTRE_MAX
|
||||
: $request->validate([
|
||||
'trasporto' => ['required', 'string', Rule::in(array_keys(config('preventivi.trasporto.zone')))],
|
||||
], [
|
||||
'trasporto.required' => 'Seleziona il trasporto.',
|
||||
])['trasporto'];
|
||||
|
||||
// Se la richiesta arriva da un cliente loggato nel configuratore (token Sanctum
|
||||
// valido), il preventivo va agganciato al suo record esistente — aggiornando
|
||||
// l'anagrafica con i dati confermati nel form — e NON a un nuovo cliente.
|
||||
@@ -149,7 +159,17 @@ class CarrelloController extends Controller
|
||||
// per i guest (o token assenti/scaduti/non validi) restituisce null.
|
||||
$clienteLoggato = Auth::guard('sanctum')->user();
|
||||
|
||||
DB::transaction(function () use ($preventivo, $dati, $clienteLoggato) {
|
||||
// Sconti solo per i clienti loggati, entro i massimi decisi dall'amministratore.
|
||||
$massimi = $clienteLoggato?->scontiMassimi() ?? ['max_1' => 0, 'max_2' => 0];
|
||||
$sconti = $request->validate([
|
||||
'sconto_1' => ['nullable', 'numeric', 'min:0', 'max:'.$massimi['max_1']],
|
||||
'sconto_2' => ['nullable', 'numeric', 'min:0', 'max:'.$massimi['max_2']],
|
||||
], [
|
||||
'sconto_1.max' => 'Il 1° sconto non può superare il :max%.',
|
||||
'sconto_2.max' => 'Lo sconto extra non può superare il :max%.',
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($preventivo, $dati, $clienteLoggato, $sconti, $trasporto) {
|
||||
$cliente = $clienteLoggato;
|
||||
|
||||
if ($cliente) {
|
||||
@@ -162,7 +182,10 @@ class CarrelloController extends Controller
|
||||
$preventivo->stato = 'inviato';
|
||||
$preventivo->numero = Preventivo::prossimoNumero();
|
||||
$preventivo->confirmed_at = now();
|
||||
$preventivo->save();
|
||||
$preventivo->sconto_1 = round((float) ($sconti['sconto_1'] ?? 0), 2);
|
||||
$preventivo->sconto_2 = round((float) ($sconti['sconto_2'] ?? 0), 2);
|
||||
$preventivo->trasporto = $trasporto;
|
||||
$preventivo->ricalcolaTotale();
|
||||
});
|
||||
|
||||
$preventivo->refresh()->load('cliente', 'items');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||
use Filament\Navigation\NavigationItem;
|
||||
use Filament\Pages;
|
||||
use Filament\Panel;
|
||||
use Filament\PanelProvider;
|
||||
@@ -39,6 +40,12 @@ class AdminPanelProvider extends PanelProvider
|
||||
->pages([
|
||||
Pages\Dashboard::class,
|
||||
])
|
||||
->navigationItems([
|
||||
NavigationItem::make('Apri configuratore')
|
||||
->url(fn (): string => config('preventivi.configuratore_url'), shouldOpenInNewTab: true)
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->sort(100),
|
||||
])
|
||||
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
|
||||
->widgets([])
|
||||
->middleware([
|
||||
|
||||
@@ -13,10 +13,16 @@ use Illuminate\Validation\ValidationException;
|
||||
/**
|
||||
* Prezzo di una configurazione, calcolato solo lato server dalle regole del backoffice:
|
||||
*
|
||||
* prezzo = base x (1 + supplementi % / 100) + supplementi fissi + variazione vetro
|
||||
* a fasi, ciascuna calcolata sul totale della precedente:
|
||||
*
|
||||
* serramento = base x (1 + finitura % / 100) + finitura fissi
|
||||
* con profili = serramento x (1 + profili extra % / 100) + profili extra fissi/al ml
|
||||
* prezzo = con profili x (1 + altre opzioni % / 100) + altre opzioni fisse + variazione vetro
|
||||
*
|
||||
* - base: prima misura del listino >= a quella richiesta (mm), entro min/max del listino;
|
||||
* - supplementi: tipo finitura (bianco/uniforme/bicolore) e opzioni scelte (gruppi di Opzione);
|
||||
* - finitura: tipo finitura (bianco/uniforme/bicolore), cioè solo misure + finiture;
|
||||
* - profili extra: sul serramento finito, prima di vetro e accessori;
|
||||
* - altre opzioni: telaio, apertura, accessori (gruppi di Opzione);
|
||||
* - variazione vetro: somma delle aree fatturabili delle lastre (max(area, minimo) per lastra)
|
||||
* per la maggiorazione €/m² del riempimento, con le lastre date dalla regola vetro del modello.
|
||||
*
|
||||
@@ -24,6 +30,9 @@ use Illuminate\Validation\ValidationException;
|
||||
*/
|
||||
class CalcolatorePrezzo
|
||||
{
|
||||
/** Gruppo calcolato sul serramento finito (misure + finiture), prima di vetro e accessori. */
|
||||
private const PROFILI_EXTRA = 'profili_extra';
|
||||
|
||||
/**
|
||||
* @param array{larghezza?:mixed,altezza?:mixed,finitura_interna?:mixed,finitura_esterna?:mixed,maniglia?:mixed,vetro?:mixed,opzioni?:array<string,mixed>} $scelte
|
||||
* @return array{prezzo:float,scelte:array,configurazione:array,dettaglio:array}
|
||||
@@ -37,7 +46,8 @@ class CalcolatorePrezzo
|
||||
[$base, $misuraListino] = $this->prezzoBase($modello, $larghezza, $altezza);
|
||||
|
||||
$opzioni = Opzione::perModello($modello);
|
||||
$supplementi = [];
|
||||
// Opzioni scelte per fase: [voce, Opzione, valore scelto]; gli importi dopo, a cascata.
|
||||
$fasi = ['finitura' => [], 'profili' => [], 'altro' => []];
|
||||
$configurazione = [
|
||||
'nome' => $modello->titolo ?: ($modello->descrizione ?: $modello->nome),
|
||||
'width' => $larghezza,
|
||||
@@ -60,7 +70,7 @@ class CalcolatorePrezzo
|
||||
default => 'uniforme',
|
||||
};
|
||||
$voce = $opzioni->get('tipo_finitura', collect())->firstWhere('valore', $tipo);
|
||||
$supplementi[] = $this->supplemento('Tipo finitura', $voce, $base, $larghezza, $altezza, $tipo);
|
||||
$fasi['finitura'][] = ['Tipo finitura', $voce, $tipo];
|
||||
|
||||
$configurazione['tipo_finitura'] = $voce?->etichetta ?? ucfirst($tipo);
|
||||
$configurazione['finitura_interna'] = $interna->descrizione;
|
||||
@@ -87,10 +97,12 @@ class CalcolatorePrezzo
|
||||
continue;
|
||||
}
|
||||
|
||||
$fase = $gruppo === self::PROFILI_EXTRA ? 'profili' : 'altro';
|
||||
|
||||
if ($definizione->ui === 'multipla') {
|
||||
$scelteGruppo = $this->scegliMultiple($righe, $scelte['opzioni'][$gruppo] ?? [], "opzioni.{$gruppo}");
|
||||
foreach ($scelteGruppo as $scelta) {
|
||||
$supplementi[] = $this->supplemento($definizione->etichetta, $scelta, $base, $larghezza, $altezza);
|
||||
$fasi[$fase][] = [$definizione->etichetta, $scelta, null];
|
||||
}
|
||||
if ($scelteGruppo->isNotEmpty()) {
|
||||
$configurazione[$gruppo] = $scelteGruppo->pluck('etichetta')->implode(', ');
|
||||
@@ -101,7 +113,7 @@ class CalcolatorePrezzo
|
||||
}
|
||||
|
||||
$scelta = $this->scegli($righe, $scelte['opzioni'][$gruppo] ?? null, "opzioni.{$gruppo}", null, 'valore');
|
||||
$supplementi[] = $this->supplemento($definizione->etichetta, $scelta, $base, $larghezza, $altezza);
|
||||
$fasi[$fase][] = [$definizione->etichetta, $scelta, null];
|
||||
$configurazione[$gruppo] = $scelta->etichetta;
|
||||
$normalizzate['opzioni'][$gruppo] = $scelta->valore;
|
||||
}
|
||||
@@ -117,11 +129,22 @@ class CalcolatorePrezzo
|
||||
}
|
||||
}
|
||||
|
||||
$supplementi = array_values(array_filter($supplementi));
|
||||
$percentuale = array_sum(array_column(array_filter($supplementi, fn ($s) => $s['tipo'] === 'percentuale'), 'valore'));
|
||||
// Importi fissi e al metro lineare (già calcolati sulle misure).
|
||||
$fisso = array_sum(array_column(array_filter($supplementi, fn ($s) => $s['tipo'] !== 'percentuale'), 'importo'));
|
||||
$prezzo = $base * (1 + $percentuale / 100) + $fisso + ($vetro['importo'] ?? 0);
|
||||
// Ogni fase applica le sue percentuali al totale della precedente.
|
||||
$supplementi = [];
|
||||
$subtotali = [];
|
||||
$totale = $base;
|
||||
foreach ($fasi as $fase => $voci) {
|
||||
$daAggiungere = 0.0;
|
||||
foreach ($voci as [$etichetta, $opzione, $valore]) {
|
||||
if ($supplemento = $this->supplemento($etichetta, $opzione, $totale, $larghezza, $altezza, $valore)) {
|
||||
$supplementi[] = $supplemento + ['fase' => $fase];
|
||||
$daAggiungere += $supplemento['importo'];
|
||||
}
|
||||
}
|
||||
$totale += $daAggiungere;
|
||||
$subtotali[$fase] = $totale;
|
||||
}
|
||||
$prezzo = $totale + ($vetro['importo'] ?? 0);
|
||||
|
||||
return [
|
||||
'prezzo' => round($prezzo, 2),
|
||||
@@ -131,8 +154,8 @@ class CalcolatorePrezzo
|
||||
'prezzo_base' => $base,
|
||||
'misura_listino' => $misuraListino,
|
||||
'supplementi' => $supplementi,
|
||||
'percentuale_totale' => $percentuale,
|
||||
'fisso_totale' => $fisso,
|
||||
// Totale dopo ogni fase (finitura = serramento, profili = con profili extra).
|
||||
'subtotali' => $subtotali,
|
||||
'vetro' => $vetro,
|
||||
],
|
||||
];
|
||||
@@ -215,6 +238,7 @@ class CalcolatorePrezzo
|
||||
return $righe->filter(fn (Opzione $o) => $scelte->contains(fn (Opzione $s) => $s->is($o)))->values();
|
||||
}
|
||||
|
||||
/** @param float $base totale della fase precedente, su cui si applicano le percentuali */
|
||||
private function supplemento(string $voce, ?Opzione $opzione, float $base, int $larghezza, int $altezza, ?string $valore = null): ?array
|
||||
{
|
||||
if (! $opzione || ! $opzione->supplemento_tipo || ! $opzione->supplemento) {
|
||||
|
||||
Reference in New Issue
Block a user