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
+105 -1
View File
@@ -18,6 +18,51 @@ const form = reactive({
})
const anagraficaCaricata = ref(false)
// Sconti applicabili dal cliente loggato (massimi decisi dall'amministratore). Il backend
// ricalcola e valida: qui servono solo per il riepilogo.
const scontiMassimi = reactive({ max_1: 0, max_2: 0 })
const sconti = reactive({ sconto_1: 0, sconto_2: 0 })
const puoScontare = computed(() => scontiMassimi.max_1 > 0 || scontiMassimi.max_2 > 0)
// Contributo trasporto: opzioni da /api/config, importo per pezzo (null = da quantificare).
const opzioniTrasporto = ref([])
const notaTrasporto = ref('')
const trasporto = ref('')
const maxPezziTrasporto = ref(null)
const etichettaOltreMax = ref('')
// Oltre il massimo di pezzi niente scelta: il trasporto è da quantificare.
const trasportoOltreMax = computed(() => maxPezziTrasporto.value != null && pezzi.value > maxPezziTrasporto.value)
const zonaTrasporto = computed(() => opzioniTrasporto.value.find((z) => z.valore === trasporto.value))
const pezzi = computed(() => carrello.items.reduce((n, item) => n + (item.quantita || 0), 0))
function percentuale(valore, max) {
const n = Number(valore)
return Number.isFinite(n) ? Math.min(Math.max(n, 0), max) : 0
}
// Stessa regola del backend: 1° sconto sul totale parziale, 2° extra sul totale già scontato.
const riepilogoSconti = computed(() => {
const parziale = carrello.totale || 0
const s1 = Math.round(parziale * percentuale(sconti.sconto_1, scontiMassimi.max_1)) / 100
const s2 = Math.round((parziale - s1) * percentuale(sconti.sconto_2, scontiMassimi.max_2)) / 100
const imponibile = Math.round((parziale - s1 - s2) * 100) / 100
const perPezzo = trasportoOltreMax.value ? null : zonaTrasporto.value?.importo
const trasportoImporto = perPezzo == null ? null : Math.round(perPezzo * pezzi.value * 100) / 100
return { parziale, s1, s2, imponibile, trasportoImporto, totale: imponibile + (trasportoImporto || 0) }
})
async function caricaOpzioniTrasporto() {
try {
const data = await api.config()
opzioniTrasporto.value = data.trasporto || []
notaTrasporto.value = data.trasporto_nota || ''
maxPezziTrasporto.value = data.trasporto_max_pezzi ?? null
etichettaOltreMax.value = data.trasporto_etichetta_oltre_max || ''
} catch (e) {
// Senza opzioni il backend risponderà con l'errore di validazione sul campo.
}
}
const isEmpty = computed(() => !carrello.items || carrello.items.length === 0)
// Se il cliente è loggato nel configuratore, precompila il form con i suoi dati già
@@ -31,6 +76,7 @@ async function precompilaAnagrafica() {
try {
const data = await api.me(clienteToken)
Object.assign(scontiMassimi, data.sconti || {})
const campi = ['nome', 'cognome', 'ragione_sociale', 'email', 'telefono', 'indirizzo', 'cap', 'citta', 'provincia', 'note']
for (const campo of campi) {
if (!form[campo] && data.cliente[campo]) {
@@ -45,6 +91,7 @@ async function precompilaAnagrafica() {
function vaiAlCheckout() {
step.value = 'checkout'
precompilaAnagrafica()
if (!opzioniTrasporto.value.length) caricaOpzioniTrasporto()
}
async function loadCarrello() {
@@ -83,7 +130,14 @@ async function conferma() {
errorMsg.value = ''
fieldErrors.value = {}
try {
const data = await api.conferma(token.value, { ...form })
const data = await api.conferma(token.value, {
...form,
...(trasportoOltreMax.value ? {} : { trasporto: trasporto.value }),
...(puoScontare.value ? {
sconto_1: percentuale(sconti.sconto_1, scontiMassimi.max_1),
sconto_2: percentuale(sconti.sconto_2, scontiMassimi.max_2),
} : {}),
})
numeroConfermato.value = data.numero
clearToken()
step.value = 'done'
@@ -224,6 +278,56 @@ onMounted(loadCarrello)
<textarea v-model="form.note" rows="3"></textarea>
</div>
<div v-if="trasportoOltreMax" class="field">
<label>Trasporto</label>
<div class="nota-trasporto">
<b>{{ etichettaOltreMax }}</b>: il preventivo contiene {{ pezzi }} pezzi e supera il limite di
{{ maxPezziTrasporto }} pezzi previsto per il contributo cadauno. {{ notaTrasporto }}
</div>
</div>
<div v-else class="field">
<label>Trasporto *</label>
<select v-model="trasporto">
<option value="" disabled>Seleziona…</option>
<option v-for="zona in opzioniTrasporto" :key="zona.valore" :value="zona.valore">
{{ zona.etichetta }}{{ zona.importo != null ? ` — ${formatEuro(zona.importo)} cad.` : '' }}
</option>
</select>
<div v-if="fieldErrors.trasporto" class="err">{{ fieldErrors.trasporto[0] }}</div>
<div v-if="zonaTrasporto && zonaTrasporto.importo == null" class="nota-trasporto">{{ notaTrasporto }}</div>
</div>
<div class="sconti">
<p v-if="puoScontare" class="sconti-titolo">Sconti</p>
<div v-if="puoScontare" class="grid-2">
<div v-if="scontiMassimi.max_1 > 0" class="field">
<label>1° sconto concesso (max {{ scontiMassimi.max_1 }}%)</label>
<input v-model.number="sconti.sconto_1" type="number" min="0" :max="scontiMassimi.max_1" step="0.5" />
<div v-if="fieldErrors.sconto_1" class="err">{{ fieldErrors.sconto_1[0] }}</div>
</div>
<div v-if="scontiMassimi.max_2 > 0" class="field">
<label>2° sconto extra (max {{ scontiMassimi.max_2 }}%)</label>
<input v-model.number="sconti.sconto_2" type="number" min="0" :max="scontiMassimi.max_2" step="0.5" />
<div v-if="fieldErrors.sconto_2" class="err">{{ fieldErrors.sconto_2[0] }}</div>
</div>
</div>
<div class="sconti-riga"><span>Totale parziale</span><span>{{ formatEuro(riepilogoSconti.parziale) }}</span></div>
<div v-if="riepilogoSconti.s1" class="sconti-riga"><span>1° sconto</span><span>− {{ formatEuro(riepilogoSconti.s1) }}</span></div>
<div v-if="riepilogoSconti.s2" class="sconti-riga"><span>2° sconto extra</span><span>− {{ formatEuro(riepilogoSconti.s2) }}</span></div>
<div class="sconti-riga forte"><span>Totale imponibile offerta</span><span>{{ formatEuro(riepilogoSconti.imponibile) }}</span></div>
<div v-if="trasportoOltreMax" class="sconti-riga">
<span>Trasporto</span><span>da quantificare</span>
</div>
<div v-else-if="zonaTrasporto" class="sconti-riga">
<span>Trasporto<template v-if="riepilogoSconti.trasportoImporto != null"> ({{ pezzi }} pz)</template></span>
<span>{{ riepilogoSconti.trasportoImporto != null ? formatEuro(riepilogoSconti.trasportoImporto) : 'da quantificare' }}</span>
</div>
<div class="totale-row">
<span>Totale <span class="iva-nota">IVA esclusa</span></span>
<span>{{ formatEuro(riepilogoSconti.totale) }}</span>
</div>
</div>
<button class="btn-primary" :disabled="submitting" @click="conferma">
{{ submitting ? 'Invio in corso…' : 'Conferma e invia' }}
</button>
+1
View File
@@ -20,6 +20,7 @@ async function request(path, options = {}) {
}
export const api = {
config: () => request('/config'),
getCarrello: (token) => request(`/carrello/${token}`),
rimuoviItem: (token, itemId) => request(`/carrello/${token}/item/${itemId}`, { method: 'DELETE' }),
// Se l'utente era loggato nel configuratore, passiamo il suo token al backend così
+7 -1
View File
@@ -78,7 +78,7 @@ a.btn-link { display: inline-block; margin-top: 16px; text-align: center; }
.field { margin-bottom: 12px; }
.field label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 4px; }
.field input, .field textarea {
.field input, .field textarea, .field select {
width: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; font: inherit;
}
.field .err { color: var(--danger); font-size: 12px; margin-top: 3px; }
@@ -91,3 +91,9 @@ a.btn-link { display: inline-block; margin-top: 16px; text-align: center; }
.success-icon { font-size: 48px; }
.done-logo { width: auto; border-radius: 3px; margin-bottom: 16px; }
.alert { background: #fdecea; color: var(--danger); padding: 10px 12px; border-radius: 8px; margin-bottom: 12px; font-size: 14px; }
.sconti { border-top: 1px solid var(--border); margin: 8px 0 16px; padding-top: 12px; }
.sconti-titolo { font-weight: 600; margin: 0 0 8px; }
.sconti-riga { display: flex; justify-content: space-between; font-size: 14px; color: var(--muted); padding: 2px 0; }
.sconti-riga.forte { color: var(--text); font-weight: 600; }
.nota-trasporto { margin-top: 6px; font-size: 13px; color: #7a5d00; background: #fff8d6; border-radius: 6px; padding: 6px 8px; }