diff --git a/CHANGELOG.md b/CHANGELOG.md index a04f2ce4e..657b71988 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ Tous les changements notables sur le projet sont documentés dans ce fichier. Ce projet adhère au principe du [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.23.2 (2024-02-12) + +- Sur le calendrier, la vérification du matériel manquant est différée pour optimiser les temps de chargement. +- Dans les devis et factures, le calcul de la remise s'applique sur le montant total, non plus sur le total journalier. + ## 0.23.1 (2023-12-16) - Les fiches de sorties des événements peuvent être éditées même en l'absence d'un bénéficiaire. diff --git a/VERSION b/VERSION index 610e28725..fda96dcf6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.23.1 +0.23.2 diff --git a/client/src/locale/en/common.js b/client/src/locale/en/common.js index edae620f0..ec62cb5de 100644 --- a/client/src/locale/en/common.js +++ b/client/src/locale/en/common.js @@ -286,10 +286,10 @@ export default { 'time': "time", 'delete-selected': "Delete-selected", + "daily-total-without-tax": "Daily total excl. tax:", "daily-total": "Daily total:", - "daily-total-discountable": "Daily total discountable:", - "daily-total-without-tax-after-discount": "Daily total excl. tax after discount:", - "daily-total-after-discount": "Daily Total after discount:", + "total-discountable": "Total discountable:", + "total-after-discount": "Total with discount:", "total-without-taxes": "Total excl. tax:", "total-taxes": "Taxes:", "total-with-taxes": "Total incl. taxes:", diff --git a/client/src/locale/fr/common.js b/client/src/locale/fr/common.js index d6da238e2..d859a04ca 100644 --- a/client/src/locale/fr/common.js +++ b/client/src/locale/fr/common.js @@ -285,10 +285,10 @@ export default { 'time': "heure", 'delete-selected': "Effacer la selection", + "daily-total-without-tax": "Total H.T.\u00A0/\u00A0jour\u00A0:", "daily-total": "Total\u00A0/\u00A0jour\u00A0:", - "daily-total-discountable": "Total remisable\u00A0/\u00A0jour\u00A0:", - "daily-total-without-tax-after-discount": "Total H.T.\u00A0/\u00A0jour après remise\u00A0:", - "daily-total-after-discount": "Total\u00A0/\u00A0jour après remise\u00A0:", + "total-discountable": "Total remisable\u00A0:", + "total-after-discount": "Total après remise\u00A0:", "total-without-taxes": "Total H.T.\u00A0:", "total-taxes": "T.V.A.\u00A0:", "total-with-taxes": "Total T.T.C.\u00A0:", diff --git a/client/src/stores/api/events.ts b/client/src/stores/api/events.ts index c1a4c2989..08608ab08 100644 --- a/client/src/stores/api/events.ts +++ b/client/src/stores/api/events.ts @@ -106,12 +106,10 @@ export type RawEvent< degressive_rate: DecimalType, discount_rate: DecimalType, vat_rate: DecimalType, - daily_total_without_discount: DecimalType, - daily_total_discountable: DecimalType, - daily_total_discount: DecimalType, - daily_total_without_taxes: DecimalType, - daily_total_taxes: DecimalType, - daily_total_with_taxes: DecimalType, + daily_total: DecimalType, + total_without_discount: DecimalType, + total_discountable: DecimalType, + total_discount: DecimalType, total_without_taxes: DecimalType, total_taxes: DecimalType, total_with_taxes: DecimalType, @@ -193,12 +191,10 @@ export const normalize = (rawEvent: RawEvent): Event => { vat_rate: new Decimal(event.vat_rate), degressive_rate: new Decimal(event.degressive_rate), discount_rate: new Decimal(event.discount_rate), - daily_total_without_discount: new Decimal(event.daily_total_without_discount), - daily_total_discountable: new Decimal(event.daily_total_discountable), - daily_total_discount: new Decimal(event.daily_total_discount), - daily_total_without_taxes: new Decimal(event.daily_total_without_taxes), - daily_total_taxes: new Decimal(event.daily_total_taxes), - daily_total_with_taxes: new Decimal(event.daily_total_with_taxes), + daily_total: new Decimal(event.daily_total), + total_without_discount: new Decimal(event.total_without_discount), + total_discountable: new Decimal(event.total_discountable), + total_discount: new Decimal(event.total_discount), total_without_taxes: new Decimal(event.total_without_taxes), total_taxes: new Decimal(event.total_taxes), total_with_taxes: new Decimal(event.total_with_taxes), diff --git a/client/src/themes/default/components/EventTotals/index.scss b/client/src/themes/default/components/EventTotals/index.scss index fd4d67670..92f69787c 100644 --- a/client/src/themes/default/components/EventTotals/index.scss +++ b/client/src/themes/default/components/EventTotals/index.scss @@ -41,6 +41,11 @@ text-align: right; } + &--secondary { + margin-bottom: globals.$spacing-medium; + color: globals.$text-light-color; + } + &--grand-total { margin-bottom: globals.$spacing-medium; diff --git a/client/src/themes/default/components/EventTotals/index.tsx b/client/src/themes/default/components/EventTotals/index.tsx index a6c96ad7c..97ac37d3e 100644 --- a/client/src/themes/default/components/EventTotals/index.tsx +++ b/client/src/themes/default/components/EventTotals/index.tsx @@ -39,7 +39,7 @@ const EventTotals = defineComponent({ } const { vat_rate: vatRate } = this.event; - return vatRate.toNumber() > 0; + return !vatRate.isZero(); }, hasDiscount(): boolean { @@ -49,28 +49,28 @@ const EventTotals = defineComponent({ } const { discount_rate: discountRate } = this.event; - return discountRate.toNumber() > 0; + return !discountRate.isZero(); }, - discountableDifferentFromTotal(): boolean { + isNotFullyDiscountable(): boolean { const { is_billable: isBillable } = this.event; if (!isBillable) { return false; } const { - daily_total_discountable: dailyTotalDiscountable, - daily_total_without_taxes: dailyTotalWithoutTaxes, + total_without_discount: totalWithoutDiscount, + total_discountable: totalDiscountable, } = this.event; - return dailyTotalDiscountable.toNumber() !== dailyTotalWithoutTaxes.toNumber(); + return !totalDiscountable.eq(totalWithoutDiscount); }, }, created() { this.$store.dispatch('categories/fetch'); }, render() { - const { $t: __, event, itemsCount, useTaxes, hasDiscount, discountableDifferentFromTotal } = this; + const { $t: __, event, itemsCount, useTaxes, hasDiscount, isNotFullyDiscountable } = this; const { is_billable: isBillable, duration, @@ -107,11 +107,11 @@ const EventTotals = defineComponent({ const { degressive_rate: degressiveRate, vat_rate: vatRate, + daily_total: dailyTotal, + total_without_discount: totalWithoutDiscount, discount_rate: discountRate, - daily_total_without_discount: dailyTotalWithoutDiscount, - daily_total_discountable: dailyTotalDiscountable, - daily_total_discount: dailyTotalDiscount, - daily_total_without_taxes: dailyTotalWithoutTaxes, + total_discountable: totalDiscountable, + total_discount: totalDiscount, total_without_taxes: totalWithoutTaxes, total_taxes: totalTaxes, total_with_taxes: totalWithTaxes, @@ -123,10 +123,10 @@ const EventTotals = defineComponent({
- {__('daily-total')} + {useTaxes ? __('daily-total-without-tax') : __('daily-total')}
- {formatAmount(dailyTotalWithoutDiscount, currency)} + {formatAmount(dailyTotal, currency)}
@@ -142,18 +142,18 @@ const EventTotals = defineComponent({ {useTaxes ? __('total-without-taxes') : __('total')}
- {formatAmount(totalWithoutTaxes, currency)} + {formatAmount(totalWithoutDiscount, currency)}
{hasDiscount && ( - {discountableDifferentFromTotal && ( + {isNotFullyDiscountable && (
- {__('daily-total-discountable')} + {__('total-discountable')}
- {formatAmount(dailyTotalDiscountable, currency)} + {formatAmount(totalDiscountable, currency)}
)} @@ -162,15 +162,15 @@ const EventTotals = defineComponent({ {__('discount-rate', { rate: discountRate })}
- - {formatAmount(dailyTotalDiscount, currency)} + - {formatAmount(totalDiscount, currency)}
- {useTaxes ? __('daily-total-without-tax-after-discount') : __('daily-total-after-discount')} + {__('total-after-discount')}
- {formatAmount(dailyTotalWithoutTaxes, currency)} + {formatAmount(totalWithoutTaxes, currency)}
@@ -195,6 +195,16 @@ const EventTotals = defineComponent({ )} + {(!hasDiscount && isNotFullyDiscountable) && ( +
+
+ {__('total-discountable')} +
+
+ {formatAmount(totalDiscountable, currency)} +
+
+ )} ); diff --git a/client/src/themes/default/layouts/Default/components/Sidebar/Menu/index.js b/client/src/themes/default/layouts/Default/components/Sidebar/Menu/index.js index 202753e63..14f82ddd1 100644 --- a/client/src/themes/default/layouts/Default/components/Sidebar/Menu/index.js +++ b/client/src/themes/default/layouts/Default/components/Sidebar/Menu/index.js @@ -7,10 +7,6 @@ import Item from './Item'; const DefaultLayoutSidebarMenu = defineComponent({ name: 'DefaultLayoutSidebarMenu', computed: { - reservationsEnabled() { - return this.$store.state.settings.reservation.enabled; - }, - links() { const links = [ { ident: 'calendar', url: '/', icon: 'calendar-alt', exact: true }, diff --git a/client/src/themes/default/modals/EventDetails/components/BillingForm/index.js b/client/src/themes/default/modals/EventDetails/components/BillingForm/index.tsx similarity index 60% rename from client/src/themes/default/modals/EventDetails/components/BillingForm/index.js rename to client/src/themes/default/modals/EventDetails/components/BillingForm/index.tsx index 53c6865fb..d69c29958 100644 --- a/client/src/themes/default/modals/EventDetails/components/BillingForm/index.js +++ b/client/src/themes/default/modals/EventDetails/components/BillingForm/index.tsx @@ -1,46 +1,118 @@ import './index.scss'; +import { defineComponent } from '@vue/composition-api'; import Decimal from 'decimal.js'; import config from '@/globals/config'; import Fragment from '@/components/Fragment'; import FormField from '@/themes/default/components/FormField'; import Button from '@/themes/default/components/Button'; -// @vue/component -export default { +import type { PropType } from '@vue/composition-api'; +import type { Beneficiary } from '@/stores/api/beneficiaries'; + +type Props = { + /** + * Le taux de remise, en pourcent. + * Doit être un nombre compris entre 0 et 100. + */ + discountRate: Decimal, + + /** + * Le montant total voulu après remise. + * Doit être un nombre compris entre 0 (ou la valeur de minAmount) + * et le montant total sans remise (ou la valeur de maxAmount). + */ + discountTarget: Decimal, + + /** + * Le taux maximum de la remise, en pourcent. + * + * @default 100 + */ + maxRate?: Decimal, + + /** + * Le montant total minimum applicable, pour limiter la saisie dans le champ. + * Doit être un nombre compris entre 0 et le montant total sans remise. + */ + minAmount?: Decimal, + + /** + * Le montant total maximum applicable, pour limiter la saisie dans le champ. + * Doit être un nombre compris entre 0 et le montant total sans remise. + */ + maxAmount?: Decimal, + + /** + * Le devis (ou la facture) est en cours de création ? + * + * @default false + */ + loading?: boolean, + + /** Le bénéficiaire, si on veut afficher son nom. */ + beneficiary?: Beneficiary, + + /** + * Le texte à afficher dans le bouton de sauvegarde. + * + * @default "Sauvegarder" (traduit) + */ + saveLabel?: string, +}; + +/** Formulaire permettant de créer un devis ou une facture. */ +const EventDetailsBillingForm = defineComponent({ name: 'EventDetailsBillingForm', props: { - discountRate: { type: Number, required: true }, - discountTarget: { type: Number, required: true }, - maxRate: { type: Object, default: () => new Decimal(100) }, - minAmount: { type: Object, default: undefined }, - maxAmount: { type: Object, default: undefined }, - loading: { type: Boolean, default: false }, - beneficiary: { type: Object, default: undefined }, + discountRate: { + type: Object as PropType, + required: true, + }, + discountTarget: { + type: Object as PropType, + required: true, + }, + maxRate: { + type: Object as PropType['maxRate']>, + default: () => new Decimal(100), + }, + minAmount: { + type: Object as PropType, + default: undefined, + }, + maxAmount: { + type: Object as PropType, + default: undefined, + }, + loading: { + type: Boolean as PropType['loading']>, + default: false, + }, + beneficiary: { + type: Object as PropType, + default: undefined, + }, saveLabel: { - type: String, + type: String as PropType['saveLabel']>, default() { const { $t: __ } = this; return __('save'); }, }, }, + emits: ['change', 'submit', 'cancel'], computed: { - isDiscountable() { + isDiscountable(): boolean { const { maxRate } = this; return maxRate.greaterThan(0); }, - targetAmount() { - const { discountTarget } = this; - return (new Decimal(discountTarget)).toFixed(2); - }, - - currency() { + currency(): string { return config.currency.symbol; }, }, methods: { - handleChangeRate(givenValue) { + handleChangeRate(givenValue: string) { const rate = new Decimal(givenValue); if (rate.isNaN() || !rate.isFinite()) { return; @@ -50,7 +122,7 @@ export default { this.$emit('change', { field: 'rate', value }); }, - handleChangeAmount(givenValue) { + handleChangeAmount(givenValue: string) { const amount = new Decimal(givenValue); if (amount.isNaN() || !amount.isFinite()) { return; @@ -60,7 +132,7 @@ export default { this.$emit('change', { field: 'amount', value }); }, - handleSubmit(e) { + handleSubmit(e: SubmitEvent) { e.preventDefault(); this.$emit('submit'); }, @@ -81,7 +153,7 @@ export default { maxAmount, isDiscountable, discountRate, - targetAmount, + discountTarget, handleSubmit, handleCancel, handleChangeRate, @@ -95,7 +167,7 @@ export default { ]; return ( -
+ {!isDiscountable && (

{__('no-discount-applicable')}

)} @@ -107,7 +179,7 @@ export default { class="EventDetailsBillingForm__discount-input" name="discountRate" disabled={loading} - value={discountRate} + value={discountRate.toFixed(4)} step={0.0001} min={0.0} max={maxRate.toNumber()} @@ -125,7 +197,7 @@ export default { class="EventDetailsBillingForm__discount-target-input" name="discountTarget" disabled={loading} - value={targetAmount} + value={discountTarget.toFixed(2)} step={0.01} min={minAmount?.toNumber() ?? undefined} max={maxAmount?.toNumber() ?? undefined} @@ -155,4 +227,6 @@ export default {
); }, -}; +}); + +export default EventDetailsBillingForm; diff --git a/client/src/themes/default/modals/EventDetails/tabs/Estimates/index.js b/client/src/themes/default/modals/EventDetails/tabs/Estimates/index.js index cf19a5d32..b2b3a329b 100644 --- a/client/src/themes/default/modals/EventDetails/tabs/Estimates/index.js +++ b/client/src/themes/default/modals/EventDetails/tabs/Estimates/index.js @@ -5,7 +5,6 @@ import { defineComponent } from '@vue/composition-api'; import { Group } from '@/stores/api/groups'; import apiEvents from '@/stores/api/events'; import getEventDiscountRate from '@/utils/getEventDiscountRate'; -import { round } from '@/utils/decimalRound'; import Fragment from '@/components/Fragment'; import Icon from '@/themes/default/components/Icon'; import Button from '@/themes/default/components/Button'; @@ -47,30 +46,29 @@ const EventDetailsEstimates = defineComponent({ return this.$store.getters['auth/is']([Group.ADMIN, Group.MEMBER]); }, - totalDiscountable() { + maxDiscountRate() { const { - degressive_rate: degressiveRate, - daily_total_discountable: dailyTotalDiscountable, + total_without_discount: totalWithoutDiscount, + total_discountable: totalDiscountable, } = this.event; - return dailyTotalDiscountable.times(degressiveRate); - }, - - maxDiscountRate() { - const { event, totalDiscountable } = this; - const { total_without_taxes: totalWithoutTaxes } = event; + if (totalWithoutDiscount > 0) { + return (totalDiscountable.times(100)) + .div(totalWithoutDiscount) + .toDecimalPlaces(4, Decimal.ROUND_UP); + } - return totalWithoutTaxes > 0 - ? (totalDiscountable.times(100)).div(totalWithoutTaxes) - : new Decimal(0); + return new Decimal(0); }, minTotalAmount() { - const { event, totalDiscountable } = this; - const { total_without_taxes: totalWithoutTaxes } = event; + const { + total_without_discount: totalWithoutDiscount, + total_discountable: totalDiscountable, + } = this.event; - return totalWithoutTaxes > 0 - ? totalWithoutTaxes.sub(totalDiscountable) + return totalWithoutDiscount > 0 + ? totalWithoutDiscount.sub(totalDiscountable) : new Decimal(0); }, @@ -80,38 +78,46 @@ const EventDetailsEstimates = defineComponent({ if (unsavedDiscountRate !== null) { return this.unsavedDiscountRate; } - return Math.min(getEventDiscountRate(event), maxDiscountRate.toNumber()); + + const eventDiscountRate = new Decimal(getEventDiscountRate(event)); + return Decimal.min(eventDiscountRate, maxDiscountRate); }, set(value) { - this.unsavedDiscountRate = Math.min(value, this.maxDiscountRate.toNumber()); + this.unsavedDiscountRate = Decimal.min(new Decimal(value), this.maxDiscountRate); }, }, discountTarget: { get() { const { event, discountRate, minTotalAmount } = this; - const { total_without_taxes: totalWithoutTaxes } = event; + const { total_without_discount: totalWithoutDiscount } = event; - const discountAmount = totalWithoutTaxes.times(discountRate / 100); - const totalAmount = totalWithoutTaxes.sub(discountAmount).toNumber(); - return Math.max(totalAmount, minTotalAmount.toNumber()); + const discountAmount = totalWithoutDiscount.times(discountRate / 100); + const totalAmount = totalWithoutDiscount.sub(discountAmount); + return Decimal.max(totalAmount, minTotalAmount); }, set(value) { - const { event, totalDiscountable, maxDiscountRate } = this; - const { total_without_taxes: totalWithoutTaxes } = event; + const { event, maxDiscountRate } = this; + const { + total_without_discount: totalWithoutDiscount, + total_discountable: totalDiscountable, + } = event; - if (totalWithoutTaxes <= 0 || totalDiscountable === 0) { + if (totalWithoutDiscount <= 0 || totalDiscountable.isZero()) { this.unsavedDiscountRate = 0; return; } - let discountAmount = totalWithoutTaxes.sub(value); + let discountAmount = totalWithoutDiscount.sub(value); if (discountAmount.greaterThan(totalDiscountable)) { discountAmount = totalDiscountable; } - const rate = discountAmount.times(100).div(totalWithoutTaxes).toNumber(); - this.unsavedDiscountRate = Math.min(round(rate, 4), maxDiscountRate.toNumber()); + const rate = discountAmount.times(100) + .div(totalWithoutDiscount) + .toDecimalPlaces(4, Decimal.ROUND_UP); + + this.unsavedDiscountRate = Decimal.min(rate, maxDiscountRate); }, }, }, diff --git a/client/src/themes/default/modals/EventDetails/tabs/Invoices/index.js b/client/src/themes/default/modals/EventDetails/tabs/Invoices/index.js index ed926c471..e1524afbc 100644 --- a/client/src/themes/default/modals/EventDetails/tabs/Invoices/index.js +++ b/client/src/themes/default/modals/EventDetails/tabs/Invoices/index.js @@ -3,7 +3,6 @@ import invariant from 'invariant'; import Decimal from 'decimal.js'; import { defineComponent } from '@vue/composition-api'; import getEventDiscountRate from '@/utils/getEventDiscountRate'; -import { round } from '@/utils/decimalRound'; import apiEvents from '@/stores/api/events'; import { Group } from '@/stores/api/groups'; import Fragment from '@/components/Fragment'; @@ -56,30 +55,29 @@ const EventDetailsInvoices = defineComponent({ return this.$store.getters['auth/is']([Group.ADMIN, Group.MEMBER]); }, - totalDiscountable() { + maxDiscountRate() { const { - degressive_rate: degressiveRate, - daily_total_discountable: dailyTotalDiscountable, + total_without_discount: totalWithoutDiscount, + total_discountable: totalDiscountable, } = this.event; - return dailyTotalDiscountable.times(degressiveRate); - }, - - maxDiscountRate() { - const { event, totalDiscountable } = this; - const { total_without_taxes: totalWithoutTaxes } = event; + if (totalWithoutDiscount > 0) { + return (totalDiscountable.times(100)) + .div(totalWithoutDiscount) + .toDecimalPlaces(4, Decimal.ROUND_UP); + } - return totalWithoutTaxes > 0 - ? (totalDiscountable.times(100)).div(totalWithoutTaxes) - : new Decimal(0); + return new Decimal(0); }, minTotalAmount() { - const { event, totalDiscountable } = this; - const { total_without_taxes: totalWithoutTaxes } = event; + const { + total_without_discount: totalWithoutDiscount, + total_discountable: totalDiscountable, + } = this.event; - return totalWithoutTaxes > 0 - ? totalWithoutTaxes.sub(totalDiscountable) + return totalWithoutDiscount > 0 + ? totalWithoutDiscount.sub(totalDiscountable) : new Decimal(0); }, @@ -89,38 +87,46 @@ const EventDetailsInvoices = defineComponent({ if (unsavedDiscountRate !== null) { return this.unsavedDiscountRate; } - return Math.min(getEventDiscountRate(event), maxDiscountRate.toNumber()); + + const eventDiscountRate = new Decimal(getEventDiscountRate(event)); + return Decimal.min(eventDiscountRate, maxDiscountRate); }, set(value) { - this.unsavedDiscountRate = Math.min(value, this.maxDiscountRate.toNumber()); + this.unsavedDiscountRate = Decimal.min(new Decimal(value), this.maxDiscountRate); }, }, discountTarget: { get() { const { event, discountRate, minTotalAmount } = this; - const { total_without_taxes: totalWithoutTaxes } = event; + const { total_without_discount: totalWithoutDiscount } = event; - const discountAmount = totalWithoutTaxes.times(discountRate / 100); - const totalAmount = totalWithoutTaxes.sub(discountAmount).toNumber(); - return Math.max(totalAmount, minTotalAmount.toNumber()); + const discountAmount = totalWithoutDiscount.times(discountRate / 100); + const totalAmount = totalWithoutDiscount.sub(discountAmount); + return Decimal.max(totalAmount, minTotalAmount); }, set(value) { - const { event, totalDiscountable, maxDiscountRate } = this; - const { total_without_taxes: totalWithoutTaxes } = event; + const { event, maxDiscountRate } = this; + const { + total_without_discount: totalWithoutDiscount, + total_discountable: totalDiscountable, + } = event; - if (totalWithoutTaxes <= 0 || totalDiscountable === 0) { + if (totalWithoutDiscount <= 0 || totalDiscountable.isZero()) { this.unsavedDiscountRate = 0; return; } - let discountAmount = totalWithoutTaxes.sub(value); + let discountAmount = totalWithoutDiscount.sub(value); if (discountAmount.greaterThan(totalDiscountable)) { discountAmount = totalDiscountable; } - const rate = discountAmount.times(100).div(totalWithoutTaxes).toNumber(); - this.unsavedDiscountRate = Math.min(round(rate, 4), maxDiscountRate.toNumber()); + const rate = discountAmount.times(100) + .div(totalWithoutDiscount) + .toDecimalPlaces(4, Decimal.ROUND_UP); + + this.unsavedDiscountRate = Decimal.min(rate, maxDiscountRate); }, }, }, diff --git a/client/src/themes/default/pages/BeneficiaryView/components/BookingsItem/index.scss b/client/src/themes/default/pages/BeneficiaryView/components/BookingsItem/index.scss index b7fc9d5dc..e7c195c8d 100644 --- a/client/src/themes/default/pages/BeneficiaryView/components/BookingsItem/index.scss +++ b/client/src/themes/default/pages/BeneficiaryView/components/BookingsItem/index.scss @@ -86,4 +86,10 @@ background-color: #913410; } } + + &--warning { + #{$block}__booking__icon { + color: globals.$text-danger-color; + } + } } diff --git a/client/src/themes/default/pages/BeneficiaryView/components/BookingsItem/index.tsx b/client/src/themes/default/pages/BeneficiaryView/components/BookingsItem/index.tsx index c17546399..ee4f4a6bb 100644 --- a/client/src/themes/default/pages/BeneficiaryView/components/BookingsItem/index.tsx +++ b/client/src/themes/default/pages/BeneficiaryView/components/BookingsItem/index.tsx @@ -152,7 +152,7 @@ const BeneficiaryViewBookingsItem = defineComponent({ }, }, render() { - const { duration } = this.booking; + const { duration, has_missing_materials: hasMissingMaterials } = this.booking; const { $t: __, icon, @@ -172,6 +172,7 @@ const BeneficiaryViewBookingsItem = defineComponent({ 'BeneficiaryViewBookingsItem--future': !isPast, 'BeneficiaryViewBookingsItem--current': isOngoing, 'BeneficiaryViewBookingsItem--confirmed': isConfirmed, + 'BeneficiaryViewBookingsItem--warning': hasMissingMaterials, }]; return ( diff --git a/client/src/themes/default/pages/BeneficiaryView/tabs/Borrowings/index.tsx b/client/src/themes/default/pages/BeneficiaryView/tabs/Borrowings/index.tsx index 4fd065fe5..ef6ade740 100644 --- a/client/src/themes/default/pages/BeneficiaryView/tabs/Borrowings/index.tsx +++ b/client/src/themes/default/pages/BeneficiaryView/tabs/Borrowings/index.tsx @@ -6,6 +6,8 @@ import { BookingEntity } from '@/stores/api/bookings'; import formatTimelineBooking from '@/utils/formatTimelineBooking'; import showModal from '@/utils/showModal'; import apiBeneficiaries from '@/stores/api/beneficiaries'; +import apiEvents from '@/stores/api/events'; +import Queue from 'p-queue'; import EventDetails from '@/themes/default/modals/EventDetails'; import CriticalError from '@/themes/default/components/CriticalError'; import Loading from '@/themes/default/components/Loading'; @@ -28,6 +30,7 @@ type Props = { type InstanceProperties = { cancelOngoingFetch: (() => void) | undefined, nowTimer: ReturnType | undefined, + fetchMissingMaterialsQueue: Queue | undefined, }; type Data = { @@ -39,6 +42,13 @@ type Data = { now: number, }; +/** + * Nombre de requêtes simultanées maximum pour la récupération du + * matériel manquant (au delà, elles seront placées dans une file d'attente). + */ +const MAX_CONCURRENT_FETCHES = 5; + +/** Nombre de millisecondes pour faire un jour entier. */ const ONE_DAY = 1000 * 3600 * 24; /* Contenu de l'onglet "emprunts" de la page de détails d'un bénéficiaire. */ @@ -53,6 +63,7 @@ const BeneficiaryViewBorrowings = defineComponent({ setup: (): InstanceProperties => ({ cancelOngoingFetch: undefined, nowTimer: undefined, + fetchMissingMaterialsQueue: undefined, }), data: (): Data => ({ bookings: [], @@ -98,6 +109,9 @@ const BeneficiaryViewBorrowings = defineComponent({ })); }, }, + created() { + this.fetchMissingMaterialsQueue = new Queue({ concurrency: MAX_CONCURRENT_FETCHES }); + }, mounted() { this.fetchData(); @@ -107,6 +121,9 @@ const BeneficiaryViewBorrowings = defineComponent({ beforeDestroy() { this.cancelOngoingFetch?.(); + // - Vide la file d'attente des requêtes. + this.fetchMissingMaterialsQueue?.clear(); + if (this.nowTimer) { clearInterval(this.nowTimer); } @@ -209,6 +226,9 @@ const BeneficiaryViewBorrowings = defineComponent({ async fetchData() { const { beneficiary } = this; + // - Vide la file d'attente des requêtes avant de la re-peupler. + this.fetchMissingMaterialsQueue?.clear(); + // - Annule la récupération en cours, s'il y en a une. this.cancelOngoingFetch?.(); @@ -229,6 +249,35 @@ const BeneficiaryViewBorrowings = defineComponent({ } else { this.bookings.push(...data); } + + const promises = data + .filter((booking: BookingSummary) => ( + !booking.is_archived && + !( + moment(booking.end_date).isBefore(this.now, 'day') && + booking.is_return_inventory_done + ) + )) + .map((booking: BookingSummary) => async () => { + const missingMaterials = await apiEvents.missingMaterials(booking.id); + + const originalBookingIndex = this.bookings.findIndex( + ({ entity, id }: BookingSummary) => ( + entity === booking.entity && id === booking.id + ), + ); + if (originalBookingIndex === -1) { + return; + } + + this.$set(this.bookings, originalBookingIndex, { + ...booking, + has_missing_materials: missingMaterials.length > 0, + }); + }); + + await this.fetchMissingMaterialsQueue?.addAll(promises); + this.isPartiallyFetched = true; this.isFullyFetched = false; diff --git a/client/src/themes/default/pages/BeneficiaryView/tabs/Infos/NextBookings/index.tsx b/client/src/themes/default/pages/BeneficiaryView/tabs/Infos/NextBookings/index.tsx index 8de9ed9fc..46b0c7109 100644 --- a/client/src/themes/default/pages/BeneficiaryView/tabs/Infos/NextBookings/index.tsx +++ b/client/src/themes/default/pages/BeneficiaryView/tabs/Infos/NextBookings/index.tsx @@ -5,6 +5,8 @@ import { defineComponent } from '@vue/composition-api'; import showModal from '@/utils/showModal'; import { BookingEntity } from '@/stores/api/bookings'; import apiBeneficiaries from '@/stores/api/beneficiaries'; +import apiEvents from '@/stores/api/events'; +import Queue from 'p-queue'; import Loading from '@/themes/default/components/Loading'; import EventDetails from '@/themes/default/modals/EventDetails'; import Item from '../../../components/BookingsItem'; @@ -20,6 +22,7 @@ type Props = { type InstanceProperties = { cancelOngoingFetch: (() => void) | undefined, + fetchMissingMaterialsQueue: Queue | undefined, }; type Data = { @@ -29,6 +32,12 @@ type Data = { isFullyFetched: boolean, }; +/** + * Nombre de requêtes simultanées maximum pour la récupération du + * matériel manquant (au delà, elles seront placées dans une file d'attente). + */ +const MAX_CONCURRENT_FETCHES = 5; + /* Liste des emprunts en cours ou futurs d'un bénéficiaire. */ const BeneficiaryViewNextBookings = defineComponent({ name: 'BeneficiaryViewNextBookings', @@ -40,6 +49,7 @@ const BeneficiaryViewNextBookings = defineComponent({ }, setup: (): InstanceProperties => ({ cancelOngoingFetch: undefined, + fetchMissingMaterialsQueue: undefined, }), data: (): Data => ({ bookings: [], @@ -48,10 +58,15 @@ const BeneficiaryViewNextBookings = defineComponent({ isFullyFetched: false, }), created() { + this.fetchMissingMaterialsQueue = new Queue({ concurrency: MAX_CONCURRENT_FETCHES }); + this.fetchData(); }, beforeDestroy() { this.cancelOngoingFetch?.(); + + // - Vide la file d'attente des requêtes. + this.fetchMissingMaterialsQueue?.clear(); }, methods: { // ------------------------------------------------------ @@ -98,6 +113,9 @@ const BeneficiaryViewNextBookings = defineComponent({ const { id } = this; const now = moment(); + // - Vide la file d'attente des requêtes avant de la re-peupler. + this.fetchMissingMaterialsQueue?.clear(); + // - Annule la récupération en cours, s'il y en a une. this.cancelOngoingFetch?.(); @@ -119,6 +137,27 @@ const BeneficiaryViewNextBookings = defineComponent({ } else { this.bookings.push(...data); } + + const promises = data.map((booking: BookingSummary) => async () => { + const missingMaterials = await apiEvents.missingMaterials(booking.id); + + const originalBookingIndex = this.bookings.findIndex( + ({ entity, id: searchId }: BookingSummary) => ( + entity === booking.entity && searchId === booking.id + ), + ); + if (originalBookingIndex === -1) { + return; + } + + this.$set(this.bookings, originalBookingIndex, { + ...booking, + has_missing_materials: missingMaterials.length > 0, + }); + }); + + await this.fetchMissingMaterialsQueue?.addAll(promises); + this.isPartiallyFetched = true; this.isFullyFetched = false; diff --git a/client/src/themes/default/pages/Calendar/index.js b/client/src/themes/default/pages/Calendar/index.js index 034fe02f4..9875a4064 100644 --- a/client/src/themes/default/pages/Calendar/index.js +++ b/client/src/themes/default/pages/Calendar/index.js @@ -6,6 +6,7 @@ import { Group } from '@/stores/api/groups'; import { DATE_DB_FORMAT } from '@/globals/constants'; import apiBookings, { BookingEntity } from '@/stores/api/bookings'; import apiEvents from '@/stores/api/events'; +import Queue from 'p-queue'; import EventDetails from '@/themes/default/modals/EventDetails'; import Page from '@/themes/default/components/Page'; import CriticalError from '@/themes/default/components/CriticalError'; @@ -21,9 +22,18 @@ const ONE_DAY = 1000 * 3600 * 24; const FETCH_DELTA_DAYS = 3; const MAX_ZOOM_MONTH = 3; +/** + * Nombre de requêtes simultanées maximum pour la récupération du + * matériel manquant (au delà, elles seront placées dans une file d'attente). + */ +const MAX_CONCURRENT_FETCHES = 5; + /** Page du calendrier des réservations / événements. */ const Calendar = defineComponent({ name: 'Calendar', + setup: () => ({ + fetchMissingMaterialsQueue: undefined, + }), data() { const parkFilter = this.$route.query.park; const { start, end } = getDefaultPeriod(); @@ -120,11 +130,17 @@ const Calendar = defineComponent({ }); }, }, + created() { + this.fetchMissingMaterialsQueue = new Queue({ concurrency: MAX_CONCURRENT_FETCHES }); + }, mounted() { // - Actualise le timestamp courant toutes les minutes. this.nowTimer = setInterval(() => { this.now = Date.now(); }, 60_000); }, beforeDestroy() { + // - Vide la file d'attente des requêtes. + this.fetchMissingMaterialsQueue?.clear(); + if (this.nowTimer) { clearInterval(this.nowTimer); } @@ -346,8 +362,31 @@ const Calendar = defineComponent({ this.isLoading = true; this.isModalOpened = false; + // - Vide la file d'attente des requêtes avant de la re-peupler. + this.fetchMissingMaterialsQueue?.clear(); + try { this.bookings = await apiBookings.all(this.fetchStart, this.fetchEnd); + + const promises = this.bookings + .filter((booking) => ( + !booking.is_archived && + !( + moment(booking.end_date).isBefore(this.now, 'day') && + booking.is_return_inventory_done + ) + )) + .map((booking) => async () => { + const missingMaterials = await apiEvents.missingMaterials(booking.id); + + this.handleUpdatedBooking({ + ...booking, + has_missing_materials: missingMaterials.length > 0, + }); + }); + + await this.fetchMissingMaterialsQueue?.addAll(promises); + this.isFetched = true; } catch (error) { if (isRequestErrorStatusCode(error, HttpCode.ClientErrorRangeNotSatisfiable)) { diff --git a/client/src/utils/decimalRound.js b/client/src/utils/decimalRound.js deleted file mode 100644 index 941b68193..000000000 --- a/client/src/utils/decimalRound.js +++ /dev/null @@ -1,7 +0,0 @@ -export const round = (value, depth = 2) => ( - Math.round((value + Number.EPSILON) * 10 ** depth) / 10 ** depth -); - -export const floor = (value, depth = 2) => ( - Math.floor((value + Number.EPSILON) * 10 ** depth) / 10 ** depth -); diff --git a/client/tests/specs/utils/decimalRound.js b/client/tests/specs/utils/decimalRound.js deleted file mode 100644 index 25a34b620..000000000 --- a/client/tests/specs/utils/decimalRound.js +++ /dev/null @@ -1,59 +0,0 @@ -import { round, floor } from '@/utils/decimalRound'; - -describe('decimalRound', () => { - describe('round()', () => { - it('return the value rounded to 2 decimals', () => { - expect(round(1)).toEqual(1); - expect(round(1.1)).toEqual(1.1); - expect(round(1.008)).toEqual(1.01); - expect(round(1.014)).toEqual(1.01); - expect(round(1.015)).toEqual(1.02); - expect(round(1.0001)).toEqual(1); - }); - - it('return the value rounded to 4 decimals', () => { - expect(round(1, 4)).toEqual(1); - expect(round(1.1, 4)).toEqual(1.1); - expect(round(1.0001, 4)).toEqual(1.0001); - expect(round(1.000_08, 4)).toEqual(1.0001); - }); - - it('return 0 if value is falsy', () => { - expect(round(null)).toEqual(0); - expect(round(false)).toEqual(0); - }); - - it('return NaN if value is not a number', () => { - expect(round('NotANumber')).toEqual(NaN); - expect(round({ a: 1 })).toEqual(NaN); - }); - }); - - describe('floor()', () => { - it('return the value rounded to 2 decimals', () => { - expect(floor(1)).toEqual(1); - expect(floor(1.1)).toEqual(1.1); - expect(floor(1.008)).toEqual(1); - expect(floor(1.014)).toEqual(1.01); - expect(floor(1.015)).toEqual(1.01); - expect(floor(1.0001)).toEqual(1); - }); - - it('return the value rounded to 4 decimals', () => { - expect(floor(1, 4)).toEqual(1); - expect(floor(1.1, 4)).toEqual(1.1); - expect(floor(1.0001, 4)).toEqual(1.0001); - expect(floor(1.000_08, 4)).toEqual(1); - }); - - it('return 0 if value is falsy', () => { - expect(floor(null)).toEqual(0); - expect(floor(false)).toEqual(0); - }); - - it('return NaN if value is not a number', () => { - expect(floor('NotANumber')).toEqual(NaN); - expect(floor({ a: 1 })).toEqual(NaN); - }); - }); -}); diff --git a/server/src/App/Controllers/BookingController.php b/server/src/App/Controllers/BookingController.php index 57719143e..f45c82c5d 100644 --- a/server/src/App/Controllers/BookingController.php +++ b/server/src/App/Controllers/BookingController.php @@ -6,7 +6,6 @@ use Fig\Http\Message\StatusCodeInterface as StatusCode; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; -use Loxya\Contracts\PeriodInterface; use Loxya\Errors\Enums\ApiErrorCode; use Loxya\Errors\Exception\ApiBadRequestException; use Loxya\Errors\Exception\HttpUnprocessableEntityException; @@ -14,7 +13,6 @@ use Loxya\Models\Event; use Loxya\Models\Park; use Loxya\Support\Database\QueryAggregator; -use Loxya\Support\Period; use Psr\Http\Message\ResponseInterface; use Slim\Exception\HttpBadRequestException; use Slim\Exception\HttpException; @@ -61,33 +59,6 @@ public function getAll(Request $request, Response $response): ResponseInterface if ($bookings->isEmpty()) { return $response->withJson([], StatusCode::STATUS_OK); } - - $period = $bookings->reduce( - fn(?Period $currentPeriod, PeriodInterface $booking) => ( - $currentPeriod === null - ? new Period($booking) - : $currentPeriod->merge($booking) - ) - ); - - // - NOTE : Ne pas prefetch le materiel des bookables via `->with()`, - // car cela peut surcharger la mémoire rapidement. - $allConcurrentBookables = (new Collection()) - // - Événements. - ->concat( - Event::inPeriod($period)->get() - ); - - foreach ($bookings as $booking) { - $booking->__cachedConcurrentBookables = $allConcurrentBookables - ->filter(fn($otherBookable) => ( - !$booking->is($otherBookable) && - $booking->getStartDate() <= $otherBookable->getEndDate() && - $booking->getEndDate() >= $otherBookable->getStartDate() - )) - ->values(); - } - $useMultipleParks = Park::count() > 1; $data = $bookings diff --git a/server/src/App/Models/Estimate.php b/server/src/App/Models/Estimate.php index fe06c0087..58a9e0e29 100644 --- a/server/src/App/Models/Estimate.php +++ b/server/src/App/Models/Estimate.php @@ -4,6 +4,7 @@ namespace Loxya\Models; use Brick\Math\BigDecimal as Decimal; +use Brick\Math\RoundingMode; use Carbon\Carbon; use Carbon\CarbonImmutable; use Illuminate\Database\Eloquent\Collection; @@ -40,12 +41,10 @@ * @property Decimal $degressive_rate * @property Decimal $discount_rate * @property Decimal $vat_rate - * @property Decimal $daily_total_without_discount - * @property Decimal $daily_total_discountable - * @property Decimal $daily_total_discount - * @property Decimal $daily_total_without_taxes - * @property Decimal $daily_total_taxes - * @property Decimal $daily_total_with_taxes + * @property Decimal $daily_total + * @property Decimal $total_without_discount + * @property Decimal $total_discountable + * @property Decimal $total_discount * @property Decimal $total_without_taxes * @property Decimal $total_taxes * @property Decimal $total_with_taxes @@ -84,12 +83,10 @@ public function __construct(array $attributes = []) 'degressive_rate' => V::custom([$this, 'checkDegressiveRate']), 'discount_rate' => V::custom([$this, 'checkDiscountRate']), 'vat_rate' => V::custom([$this, 'checkVatRate']), - 'daily_total_without_discount' => V::custom([$this, 'checkAmount']), - 'daily_total_discountable' => V::custom([$this, 'checkAmount']), - 'daily_total_discount' => V::custom([$this, 'checkAmount']), - 'daily_total_without_taxes' => V::custom([$this, 'checkAmount']), - 'daily_total_taxes' => V::custom([$this, 'checkAmount']), - 'daily_total_with_taxes' => V::custom([$this, 'checkAmount']), + 'daily_total' => V::custom([$this, 'checkAmount']), + 'total_without_discount' => V::custom([$this, 'checkAmount']), + 'total_discountable' => V::custom([$this, 'checkAmount']), + 'total_discount' => V::custom([$this, 'checkAmount']), 'total_without_taxes' => V::custom([$this, 'checkAmount']), 'total_taxes' => V::custom([$this, 'checkAmount']), 'total_with_taxes' => V::custom([$this, 'checkAmount']), @@ -163,11 +160,37 @@ public function checkDiscountRate($value) V::floatVal()->check($value); $value = Decimal::of($value); - return ( - $value->isGreaterThanOrEqualTo(0) && - $value->isLessThan(100) && - $value->getScale() <= 4 - ); + $totalWithoutDiscountRaw = $this->getAttributeFromArray('total_without_discount'); + if (!$this->validation['total_without_discount']->validate($totalWithoutDiscountRaw)) { + return true; + } + $totalWithoutDiscount = Decimal::of($totalWithoutDiscountRaw); + + // - Si le total sans remise est à 0, la remise est forcément à 0 aussi. + if ($totalWithoutDiscount->isZero()) { + return $value->isZero(); + } + + $totalDiscountableRaw = $this->getAttributeFromArray('total_discountable'); + if ($totalDiscountableRaw === null) { + return $value->isLessThan(100) ?: 'discount-rate-exceeds-maximum'; + } + + if (!$this->validation['total_discountable']->validate($totalDiscountableRaw)) { + return true; + } + $totalDiscountable = Decimal::of($totalDiscountableRaw); + + // - Si le total remisable est à 0, il ne peut pas y avoir de remise. + if ($totalDiscountable->isZero()) { + return $value->isZero(); + } + + $maxDiscountRate = $totalDiscountable + ->multipliedBy(100) + ->dividedBy($totalWithoutDiscount, 4, RoundingMode::HALF_UP); + + return !$value->isGreaterThan($maxDiscountRate) ?: 'discount-rate-exceeds-maximum'; } public function checkVatRate($value) @@ -262,12 +285,10 @@ public function author() 'degressive_rate' => AsDecimal::class, 'discount_rate' => AsDecimal::class, 'vat_rate' => AsDecimal::class, - 'daily_total_without_discount' => AsDecimal::class, - 'daily_total_discountable' => AsDecimal::class, - 'daily_total_discount' => AsDecimal::class, - 'daily_total_without_taxes' => AsDecimal::class, - 'daily_total_taxes' => AsDecimal::class, - 'daily_total_with_taxes' => AsDecimal::class, + 'daily_total' => AsDecimal::class, + 'total_without_discount' => AsDecimal::class, + 'total_discountable' => AsDecimal::class, + 'total_discount' => AsDecimal::class, 'total_without_taxes' => AsDecimal::class, 'total_taxes' => AsDecimal::class, 'total_with_taxes' => AsDecimal::class, @@ -384,13 +405,12 @@ protected function getPdfData(): array 'degressiveRate' => $this->degressive_rate, 'discountRate' => $this->discount_rate->dividedBy(100, 6), 'vatRate' => $this->vat_rate->dividedBy(100, 4), - 'dailyTotalWithoutDiscount' => $this->daily_total_without_discount, - 'dailyTotalDiscountable' => $this->daily_total_discountable, - 'dailyTotalDiscount' => $this->daily_total_discount, - 'dailyTotalWithoutTaxes' => $this->daily_total_without_taxes, - 'dailyTotalTaxes' => $this->daily_total_taxes, - 'dailyTotalWithTaxes' => $this->daily_total_with_taxes, + 'dailyTotal' => $this->daily_total, + 'totalWithoutDiscount' => $this->total_without_discount, + 'totalDiscountable' => $this->total_discountable, + 'totalDiscount' => $this->total_discount, 'totalWithoutTaxes' => $this->total_without_taxes, + 'totalTaxes' => $this->total_taxes, 'totalWithTaxes' => $this->total_with_taxes, 'categoriesSubTotals' => $categoriesTotals, 'materials' => ( @@ -414,12 +434,10 @@ protected function getPdfData(): array 'degressive_rate', 'discount_rate', 'vat_rate', - 'daily_total_without_discount', - 'daily_total_discountable', - 'daily_total_discount', - 'daily_total_without_taxes', - 'daily_total_taxes', - 'daily_total_with_taxes', + 'daily_total', + 'total_without_discount', + 'total_discountable', + 'total_discount', 'total_without_taxes', 'total_taxes', 'total_with_taxes', @@ -472,15 +490,13 @@ public static function createFromBooking(Event $booking, User $creator): Estimat 'discount_rate' => $booking->discount_rate, 'vat_rate' => $booking->vat_rate, + // - Total / jour. + 'daily_total' => $booking->daily_total, + // - Remise. - 'daily_total_without_discount' => $booking->daily_total_without_discount, - 'daily_total_discountable' => $booking->daily_total_discountable, - 'daily_total_discount' => $booking->daily_total_discount, - 'daily_total_without_taxes' => $booking->daily_total_without_taxes, - - // - Taxes. - 'daily_total_taxes' => $booking->daily_total_taxes, - 'daily_total_with_taxes' => $booking->daily_total_with_taxes, + 'total_without_discount' => $booking->total_without_discount, + 'total_discountable' => $booking->total_discountable, + 'total_discount' => $booking->total_discount, // - Totaux. 'total_without_taxes' => $booking->total_without_taxes, diff --git a/server/src/App/Models/Event.php b/server/src/App/Models/Event.php index 94f6ab33b..23a9b9980 100644 --- a/server/src/App/Models/Event.php +++ b/server/src/App/Models/Event.php @@ -46,12 +46,10 @@ * @property-read Decimal|null $degressive_rate * @property Decimal|null $discount_rate * @property-read Decimal|null $vat_rate - * @property-read Decimal|null $daily_total_without_discount - * @property-read Decimal|null $daily_total_discountable - * @property-read Decimal|null $daily_total_discount - * @property-read Decimal|null $daily_total_without_taxes - * @property-read Decimal|null $daily_total_taxes - * @property-read Decimal|null $daily_total_with_taxes + * @property-read Decimal|null $daily_total + * @property-read Decimal|null $total_discountable + * @property-read Decimal|null $total_discount + * @property-read Decimal|null $total_without_discount * @property-read Decimal|null $total_without_taxes * @property-read Decimal|null $total_taxes * @property-read Decimal|null $total_with_taxes @@ -560,10 +558,10 @@ function ($totals, $material) { } // - // - Daily totals. + // - Daily total. // - public function getDailyTotalWithoutDiscountAttribute(): ?Decimal + public function getDailyTotalAttribute(): ?Decimal { if (!$this->is_billable) { return null; @@ -579,72 +577,55 @@ public function getDailyTotalWithoutDiscountAttribute(): ?Decimal ->toScale(2, RoundingMode::UNNECESSARY); } - public function getDailyTotalDiscountableAttribute(): ?Decimal - { - if (!$this->is_billable) { - return null; - } - - return $this->materials->pluck('pivot') - ->reduce( - fn (Decimal $currentTotal, EventMaterial $material) => ( - $material->is_discountable - ? $currentTotal->plus($material->total_price) - : $currentTotal - ), - Decimal::zero() - ) - ->toScale(2, RoundingMode::UNNECESSARY); - } + // + // - Discount. + // - public function getDailyTotalDiscountAttribute(): ?Decimal + public function getTotalWithoutDiscountAttribute(): ?Decimal { if (!$this->is_billable) { return null; } - return $this->daily_total_without_discount - ->multipliedBy($this->discount_rate->dividedBy(100, 6)) + return $this->daily_total + ->multipliedBy($this->degressive_rate) // @see https://www.ibm.com/docs/en/order-management-sw/9.2.1?topic=rounding-price // @see https://wiki.dolibarr.org/index.php?title=VAT_setup,_calculation_and_rounding_rules ->toScale(2, RoundingMode::HALF_UP); } - public function getDailyTotalWithoutTaxesAttribute(): ?Decimal + public function getTotalDiscountableAttribute(): ?Decimal { if (!$this->is_billable) { return null; } - return $this->daily_total_without_discount - ->minus($this->daily_total_discount) - ->toScale(2, RoundingMode::UNNECESSARY); + return $this->materials->pluck('pivot') + ->reduce( + fn (Decimal $currentTotal, EventMaterial $material) => ( + $material->is_discountable + ? $currentTotal->plus($material->total_price) + : $currentTotal + ), + Decimal::zero() + ) + ->multipliedBy($this->degressive_rate) + ->toScale(2, RoundingMode::HALF_UP); } - public function getDailyTotalTaxesAttribute(): ?Decimal + public function getTotalDiscountAttribute(): ?Decimal { if (!$this->is_billable) { return null; } - return $this->daily_total_without_taxes - ->multipliedBy($this->vat_rate->dividedBy(100, 4)) + return $this->total_without_discount + ->multipliedBy($this->discount_rate->dividedBy(100, 6)) // @see https://www.ibm.com/docs/en/order-management-sw/9.2.1?topic=rounding-price // @see https://wiki.dolibarr.org/index.php?title=VAT_setup,_calculation_and_rounding_rules ->toScale(2, RoundingMode::HALF_UP); } - public function getDailyTotalWithTaxesAttribute(): ?Decimal - { - if (!$this->is_billable) { - return null; - } - - return $this->daily_total_without_taxes - ->plus($this->daily_total_taxes) - ->toScale(2, RoundingMode::UNNECESSARY); - } - // // - Totals. // @@ -655,8 +636,8 @@ public function getTotalWithoutTaxesAttribute(): ?Decimal return null; } - return $this->daily_total_without_taxes - ->multipliedBy($this->degressive_rate) + return $this->total_without_discount + ->minus($this->total_discount) // @see https://www.ibm.com/docs/en/order-management-sw/9.2.1?topic=rounding-price // @see https://wiki.dolibarr.org/index.php?title=VAT_setup,_calculation_and_rounding_rules ->toScale(2, RoundingMode::HALF_UP); @@ -1773,7 +1754,6 @@ public function serialize(string $format = self::SERIALIZE_DEFAULT): array 'duration', 'technicians', 'beneficiaries', - 'has_missing_materials', 'has_not_returned_materials', 'parks', ]); @@ -1800,12 +1780,10 @@ public function serialize(string $format = self::SERIALIZE_DEFAULT): array 'degressive_rate', 'vat_rate', 'discount_rate', - 'daily_total_without_discount', - 'daily_total_discountable', - 'daily_total_discount', - 'daily_total_without_taxes', - 'daily_total_taxes', - 'daily_total_with_taxes', + 'daily_total', + 'total_without_discount', + 'total_discountable', + 'total_discount', 'total_without_taxes', 'total_taxes', 'total_with_taxes', diff --git a/server/src/App/Models/Invoice.php b/server/src/App/Models/Invoice.php index 691336148..5f7830247 100644 --- a/server/src/App/Models/Invoice.php +++ b/server/src/App/Models/Invoice.php @@ -4,6 +4,7 @@ namespace Loxya\Models; use Brick\Math\BigDecimal as Decimal; +use Brick\Math\RoundingMode; use Carbon\Carbon; use Carbon\CarbonImmutable; use Illuminate\Database\Eloquent\Collection; @@ -41,12 +42,10 @@ * @property Decimal $degressive_rate * @property Decimal $discount_rate * @property Decimal $vat_rate - * @property Decimal $daily_total_without_discount - * @property Decimal $daily_total_discountable - * @property Decimal $daily_total_discount - * @property Decimal $daily_total_without_taxes - * @property Decimal $daily_total_taxes - * @property Decimal $daily_total_with_taxes + * @property Decimal $daily_total + * @property Decimal $total_without_discount + * @property Decimal $total_discountable + * @property Decimal $total_discount * @property Decimal $total_without_taxes * @property Decimal $total_taxes * @property Decimal $total_with_taxes @@ -66,7 +65,7 @@ final class Invoice extends BaseModel implements Serializable use Serializer; use SoftDeletable; - protected const PDF_TEMPLATE = 'invoice-default'; + public const PDF_TEMPLATE = 'invoice-default'; public function __construct(array $attributes = []) { @@ -86,12 +85,10 @@ public function __construct(array $attributes = []) 'degressive_rate' => V::custom([$this, 'checkDegressiveRate']), 'discount_rate' => V::custom([$this, 'checkDiscountRate']), 'vat_rate' => V::custom([$this, 'checkVatRate']), - 'daily_total_without_discount' => V::custom([$this, 'checkAmount']), - 'daily_total_discountable' => V::custom([$this, 'checkAmount']), - 'daily_total_discount' => V::custom([$this, 'checkAmount']), - 'daily_total_without_taxes' => V::custom([$this, 'checkAmount']), - 'daily_total_taxes' => V::custom([$this, 'checkAmount']), - 'daily_total_with_taxes' => V::custom([$this, 'checkAmount']), + 'daily_total' => V::custom([$this, 'checkAmount']), + 'total_without_discount' => V::custom([$this, 'checkAmount']), + 'total_discountable' => V::custom([$this, 'checkAmount']), + 'total_discount' => V::custom([$this, 'checkAmount']), 'total_without_taxes' => V::custom([$this, 'checkAmount']), 'total_taxes' => V::custom([$this, 'checkAmount']), 'total_with_taxes' => V::custom([$this, 'checkAmount']), @@ -183,11 +180,37 @@ public function checkDiscountRate($value) V::floatVal()->check($value); $value = Decimal::of($value); - return ( - $value->isGreaterThanOrEqualTo(0) && - $value->isLessThan(100) && - $value->getScale() <= 4 - ); + $totalWithoutDiscountRaw = $this->getAttributeFromArray('total_without_discount'); + if (!$this->validation['total_without_discount']->validate($totalWithoutDiscountRaw)) { + return true; + } + $totalWithoutDiscount = Decimal::of($totalWithoutDiscountRaw); + + // - Si le total sans remise est à 0, la remise est forcément à 0 aussi. + if ($totalWithoutDiscount->isZero()) { + return $value->isZero(); + } + + $totalDiscountableRaw = $this->getAttributeFromArray('total_discountable'); + if ($totalDiscountableRaw === null) { + return $value->isLessThan(100) ?: 'discount-rate-exceeds-maximum'; + } + + if (!$this->validation['total_discountable']->validate($totalDiscountableRaw)) { + return true; + } + $totalDiscountable = Decimal::of($totalDiscountableRaw); + + // - Si le total remisable est à 0, il ne peut pas y avoir de remise. + if ($totalDiscountable->isZero()) { + return $value->isZero(); + } + + $maxDiscountRate = $totalDiscountable + ->multipliedBy(100) + ->dividedBy($totalWithoutDiscount, 4, RoundingMode::HALF_UP); + + return !$value->isGreaterThan($maxDiscountRate) ?: 'discount-rate-exceeds-maximum'; } public function checkVatRate($value) @@ -283,12 +306,10 @@ public function author() 'degressive_rate' => AsDecimal::class, 'discount_rate' => AsDecimal::class, 'vat_rate' => AsDecimal::class, - 'daily_total_without_discount' => AsDecimal::class, - 'daily_total_discountable' => AsDecimal::class, - 'daily_total_discount' => AsDecimal::class, - 'daily_total_without_taxes' => AsDecimal::class, - 'daily_total_taxes' => AsDecimal::class, - 'daily_total_with_taxes' => AsDecimal::class, + 'daily_total' => AsDecimal::class, + 'total_without_discount' => AsDecimal::class, + 'total_discountable' => AsDecimal::class, + 'total_discount' => AsDecimal::class, 'total_without_taxes' => AsDecimal::class, 'total_taxes' => AsDecimal::class, 'total_with_taxes' => AsDecimal::class, @@ -406,13 +427,12 @@ protected function getPdfData(): array 'degressiveRate' => $this->degressive_rate, 'discountRate' => $this->discount_rate->dividedBy(100, 6), 'vatRate' => $this->vat_rate->dividedBy(100, 4), - 'dailyTotalWithoutDiscount' => $this->daily_total_without_discount, - 'dailyTotalDiscountable' => $this->daily_total_discountable, - 'dailyTotalDiscount' => $this->daily_total_discount, - 'dailyTotalWithoutTaxes' => $this->daily_total_without_taxes, - 'dailyTotalTaxes' => $this->daily_total_taxes, - 'dailyTotalWithTaxes' => $this->daily_total_with_taxes, + 'dailyTotal' => $this->daily_total, + 'totalWithoutDiscount' => $this->total_without_discount, + 'totalDiscountable' => $this->total_discountable, + 'totalDiscount' => $this->total_discount, 'totalWithoutTaxes' => $this->total_without_taxes, + 'totalTaxes' => $this->total_taxes, 'totalWithTaxes' => $this->total_with_taxes, 'categoriesSubTotals' => $categoriesTotals, 'materials' => ( @@ -437,12 +457,10 @@ protected function getPdfData(): array 'degressive_rate', 'discount_rate', 'vat_rate', - 'daily_total_without_discount', - 'daily_total_discountable', - 'daily_total_discount', - 'daily_total_without_taxes', - 'daily_total_taxes', - 'daily_total_with_taxes', + 'daily_total', + 'total_without_discount', + 'total_discountable', + 'total_discount', 'total_without_taxes', 'total_taxes', 'total_with_taxes', @@ -496,15 +514,13 @@ public static function createFromBooking(Event $booking, User $creator): Invoice 'discount_rate' => $booking->discount_rate, 'vat_rate' => $booking->vat_rate, + // - Total / jour. + 'daily_total' => $booking->daily_total, + // - Remise. - 'daily_total_without_discount' => $booking->daily_total_without_discount, - 'daily_total_discountable' => $booking->daily_total_discountable, - 'daily_total_discount' => $booking->daily_total_discount, - 'daily_total_without_taxes' => $booking->daily_total_without_taxes, - - // - Taxes. - 'daily_total_taxes' => $booking->daily_total_taxes, - 'daily_total_with_taxes' => $booking->daily_total_with_taxes, + 'total_without_discount' => $booking->total_without_discount, + 'total_discountable' => $booking->total_discountable, + 'total_discount' => $booking->total_discount, // - Totaux. 'total_without_taxes' => $booking->total_without_taxes, diff --git a/server/src/locales/en/common.php b/server/src/locales/en/common.php index 97eda2960..1e31762da 100644 --- a/server/src/locales/en/common.php +++ b/server/src/locales/en/common.php @@ -116,6 +116,9 @@ "invoice-note-detail-next-page" => "Note: you'll find the detail of materials in next pages.", + "amount" + => "Amount", + "daily-amount" => "Daily price", @@ -131,12 +134,15 @@ "discountable" => "Discountable", - "discount-rate" + "discount" => "Discount", "no-discount" => "No discount", + "discount-of-amount" + => "%s of %s", + "totals" => "Totals", diff --git a/server/src/locales/en/validation.php b/server/src/locales/en/validation.php index 9c8b6cda8..c50834f79 100644 --- a/server/src/locales/en/validation.php +++ b/server/src/locales/en/validation.php @@ -1214,6 +1214,9 @@ "invalid-csv-delimiter" => "The CSV delimiter is invalid.", + "discount-rate-exceeds-maximum" + => "The discount rate exceeds the maximum.", + // // - Existence messages // diff --git a/server/src/locales/fr/common.php b/server/src/locales/fr/common.php index 9d90d2c7a..2ecbd3290 100644 --- a/server/src/locales/fr/common.php +++ b/server/src/locales/fr/common.php @@ -116,6 +116,9 @@ "invoice-note-detail-next-page" => "Remarque\xc2\xa0: vous trouverez le détail du matériel aux pages suivantes.", + "amount" + => "Montant", + "daily-amount" => "Tarif\xc2\xa0/\xc2\xa0jour", @@ -131,12 +134,15 @@ "discountable" => "Remisable", - "discount-rate" + "discount" => "Remise", "no-discount" => "Sans remise", + "discount-of-amount" + => "%s de %s", + "totals" => "Totaux", diff --git a/server/src/locales/fr/validation.php b/server/src/locales/fr/validation.php index 51214d455..c85cb06f0 100644 --- a/server/src/locales/fr/validation.php +++ b/server/src/locales/fr/validation.php @@ -1217,6 +1217,9 @@ "invalid-csv-delimiter" => "Le délimiteur CSV est invalide.", + "discount-rate-exceeds-maximum" + => "Le taux de remise dépasse le maximum.", + // // - Existence messages // diff --git a/server/src/migrations/20240118115610_fix_estimates_and_invoices_discount_fields.php b/server/src/migrations/20240118115610_fix_estimates_and_invoices_discount_fields.php new file mode 100644 index 000000000..1e59a7d71 --- /dev/null +++ b/server/src/migrations/20240118115610_fix_estimates_and_invoices_discount_fields.php @@ -0,0 +1,179 @@ +table($tableName); + $table + ->addColumn('total_without_discount', 'decimal', [ + 'precision' => 14, + 'scale' => 2, + 'null' => true, + 'after' => 'daily_total_with_taxes', + ]) + ->addColumn('total_discountable', 'decimal', [ + 'precision' => 14, + 'scale' => 2, + 'null' => true, + 'after' => 'total_without_discount', + ]) + ->addColumn('total_discount', 'decimal', [ + 'precision' => 14, + 'scale' => 2, + 'null' => true, + 'after' => 'total_discountable', + ]) + ->save(); + + // - Re-calcul des remises pour les devis existants. + $allEntries = $this->fetchAll(sprintf("SELECT * FROM `%s%s`", $prefix, $tableName)); + foreach ($allEntries as $entry) { + $degressiveRate = Decimal::of($entry['degressive_rate']); + $totalWithoutDiscount = Decimal::of($entry['daily_total_without_discount']) + ->multipliedBy($degressiveRate) + ->toScale(2, RoundingMode::HALF_UP); + $totalDiscountable = Decimal::of($entry['daily_total_discountable']) + ->multipliedBy($degressiveRate) + ->toScale(2, RoundingMode::HALF_UP); + $totalDiscount = Decimal::of($entry['daily_total_discount']) + ->multipliedBy($degressiveRate) + ->toScale(2, RoundingMode::HALF_UP); + + $this->execute( + sprintf( + "UPDATE `%s%s` SET " . + "`total_without_discount` = ?, `total_discountable` = ?, `total_discount` = ? " . + "WHERE `id` = ?", + $prefix, + $tableName, + ), + [$totalWithoutDiscount, $totalDiscountable, $totalDiscount, $entry['id']], + ); + } + + $table + ->renameColumn('daily_total_without_discount', 'daily_total') + ->removeColumn('daily_total_discountable') + ->removeColumn('daily_total_discount') + ->removeColumn('daily_total_without_taxes') + ->removeColumn('daily_total_taxes') + ->removeColumn('daily_total_with_taxes') + ->save(); + } + } + + public function down(): void + { + $prefix = Config::get('db.prefix'); + + foreach (['invoices', 'estimates'] as $tableName) { + $table = $this->table($tableName); + $table + ->addColumn('daily_total_without_discount', 'decimal', [ + 'precision' => 14, + 'scale' => 2, + 'null' => true, + 'after' => 'vat_rate', + ]) + ->addColumn('daily_total_discountable', 'decimal', [ + 'precision' => 14, + 'scale' => 2, + 'null' => true, + 'after' => 'daily_total_without_discount', + ]) + ->addColumn('daily_total_discount', 'decimal', [ + 'precision' => 14, + 'scale' => 2, + 'null' => true, + 'after' => 'daily_total_discountable', + ]) + ->addColumn('daily_total_without_taxes', 'decimal', [ + 'precision' => 14, + 'scale' => 2, + 'null' => true, + 'after' => 'daily_total_discount', + ]) + ->addColumn('daily_total_taxes', 'decimal', [ + 'precision' => 14, + 'scale' => 2, + 'null' => true, + 'after' => 'daily_total_without_taxes', + ]) + ->addColumn('daily_total_with_taxes', 'decimal', [ + 'precision' => 14, + 'scale' => 2, + 'null' => true, + 'after' => 'daily_total_taxes', + ]) + ->save(); + + $allEntries = $this->fetchAll(sprintf("SELECT * FROM `%s%s`", $prefix, $tableName)); + foreach ($allEntries as $entry) { + $degressiveRate = Decimal::of($entry['degressive_rate']); + $vatRate = Decimal::of($entry['vat_rate']); + $vatRate = $vatRate->isZero() + ? Decimal::of(1) + : $vatRate->dividedBy(100, 4, RoundingMode::UNNECESSARY); + + $dailyTotalWithoutDiscount = Decimal::of($entry['daily_total']) + ->toScale(2, RoundingMode::HALF_UP); + $dailyTotalDiscountable = Decimal::of($entry['total_discountable']) + ->dividedBy($degressiveRate, 2, RoundingMode::HALF_UP) + ->toScale(2, RoundingMode::HALF_UP); + $dailyTotalDiscount = Decimal::of($entry['total_discount']) + ->dividedBy($degressiveRate, 2, RoundingMode::HALF_UP) + ->toScale(2, RoundingMode::HALF_UP); + $dailyTotalWithoutTaxes = $dailyTotalWithoutDiscount + ->minus($dailyTotalDiscount) + ->toScale(2, RoundingMode::HALF_UP); + $dailyTotalTaxes = $dailyTotalWithoutTaxes + ->dividedBy($vatRate, 2, RoundingMode::HALF_UP) + ->toScale(2, RoundingMode::HALF_UP); + $dailyTotalWithTaxes = $dailyTotalWithoutTaxes + ->plus($dailyTotalTaxes) + ->toScale(2, RoundingMode::HALF_UP); + + $this->execute( + sprintf( + "UPDATE `%s%s` SET " . + "`daily_total_without_discount` = ?, " . + "`daily_total_discountable` = ?, " . + "`daily_total_discount` = ?, " . + "`daily_total_without_taxes` = ?, " . + "`daily_total_taxes` = ?, " . + "`daily_total_with_taxes` = ? " . + "WHERE `id` = ?", + $prefix, + $tableName, + ), + [ + $dailyTotalWithoutDiscount, + $dailyTotalDiscountable, + $dailyTotalDiscount, + $dailyTotalWithoutTaxes, + $dailyTotalTaxes, + $dailyTotalWithTaxes, + $entry['id'], + ] + ); + } + + $table + ->removeColumn('daily_total') + ->removeColumn('total_without_discount') + ->removeColumn('total_discountable') + ->removeColumn('total_discount') + ->save(); + } + } +} diff --git a/server/src/views/pdf/base.twig b/server/src/views/pdf/base.twig index da9f7b1fa..0477a428c 100644 --- a/server/src/views/pdf/base.twig +++ b/server/src/views/pdf/base.twig @@ -100,6 +100,10 @@ .listing-table > tbody > tr > td:last-child { border-right: 1pt solid #888; } + .totals-table > tbody > tr > td { + border-bottom: 1pt solid #888; + vertical-align: middle; + } .inset { padding: 5mm 3mm 8mm !important; border: 1pt solid #999 !important; diff --git a/server/src/views/pdf/estimate-default.twig b/server/src/views/pdf/estimate-default.twig index 7df8e59c3..9ccd9f874 100644 --- a/server/src/views/pdf/estimate-default.twig +++ b/server/src/views/pdf/estimate-default.twig @@ -75,106 +75,89 @@
- +
- - + {% if hasDiscount -%} + + {% endif -%} - - - - - {% if hasVat %} - - - {% endif %} - {% if not hasVat %} - - + + - {% endif %} - - {% if hasVat %} - + {% if hasVat -%} + {% endif -%} + {% if hasVat -%} diff --git a/server/src/views/pdf/invoice-default.twig b/server/src/views/pdf/invoice-default.twig index 656ddfb9e..a4aab1e9f 100644 --- a/server/src/views/pdf/invoice-default.twig +++ b/server/src/views/pdf/invoice-default.twig @@ -75,106 +75,89 @@
-
{{ translate(hasVat ? 'daily-amount-excl-tax' : 'daily-amount') }}{{ translate('discount-rate') }} {{ translate('daily-total') }} {{ translate('degressive-rate') }}{{ translate('discount') }}{{ translate('totals') }}
+
- {{ dailyTotalWithoutDiscount|format_currency(currency, locale=locale) }} -
-
- {{ translate('discountable') }}
- {{ dailyTotalDiscountable|format_currency(currency, locale=locale) }} + {{ dailyTotal|format_currency(currency, locale=locale) }}
+
- {% if hasDiscount %} - {{ discountRate|format_percent_number({fraction_digit: 4}, locale=locale) }}

- - {{ dailyTotalDiscount|format_currency(currency, locale=locale) }} - {% else %} - {{ translate('no-discount') }} - {% endif %} + × {{ degressiveRate|format_number(locale=locale) }} + ({{ plural('number-of-days', booking.duration.days) }})
- {% if hasVat %} -
- {{ translate('excl-tax') }}
- {{ dailyTotalWithoutTaxes|format_currency(currency, locale=locale) }} -
-
- {{ translate('taxes') }} ({{ vatRate|format_percent_number({fraction_digit: 1}, locale=locale) }})
- {{ dailyTotalTaxes|format_currency(currency, locale=locale) }} + {% if hasDiscount -%} +
+
+ {{ translate('discount-of-amount', [ + discountRate|format_percent_number({max_fraction_digit: 4}, locale=locale), + totalWithoutDiscount|format_currency(currency, locale=locale), + ]) }}
- {% endif %} -
- {% if hasVat %}{{ translate('incl-tax') }}
{% endif %} - {{ dailyTotalWithTaxes|format_currency(currency, locale=locale) }} +
+ - {{ totalDiscount|format_currency(currency, locale=locale) }}
-
- × {{ degressiveRate|format_number(locale=locale) }}

- - ({{ plural('number-of-days', booking.duration.days) }}) - + {% endif -%} +
+
+ {% if hasVat -%} + {{ translate('total-excl-tax') }} + {% else -%} + {{ translate('total-due') }}
+ {{ translate('tax-not-applicable') }} + {% endif -%}
-
{{ translate('total-excl-tax') }}
-
+
{{ totalWithoutTaxes|format_currency(currency, locale=locale) }}
-
- {{ translate('total-due') }} -
-
- {{ translate('tax-not-applicable') }} -
-
-
- {{ totalWithTaxes|format_currency(currency, locale=locale) }} +
+
+ = {{ totalWithoutDiscount|format_currency(currency, locale=locale) }}
-
{{ translate('total-incl-taxes') }}
+
+ {{ translate('taxes') }} + ({{ vatRate|format_percent_number({max_fraction_digit: 2}, locale=locale) }}) +
- {{ totalWithTaxes|format_currency(currency, locale=locale) }} + {{ totalTaxes|format_currency(currency, locale=locale) }}
-
+
+ {{ translate('total-incl-taxes') }}
{{ translate('total-due') }}
-
+
{{ totalWithTaxes|format_currency(currency, locale=locale) }}
+
- - + {% if hasDiscount -%} + + {% endif -%} - - - - - {% if hasVat %} - - - {% endif %} - {% if not hasVat %} - - + + - {% endif %} - - {% if hasVat %} - + {% if hasVat -%} + {% endif -%} + {% if hasVat -%} diff --git a/server/tests/endpoints/EstimatesTest.php b/server/tests/endpoints/EstimatesTest.php index 8f5847b22..1fa58cff1 100644 --- a/server/tests/endpoints/EstimatesTest.php +++ b/server/tests/endpoints/EstimatesTest.php @@ -15,9 +15,9 @@ public static function data(int $id) 'id' => 1, 'date' => '2021-01-30 14:00:00', 'url' => 'http://loxya.test/estimates/1/pdf', - 'discount_rate' => '50.0000', - 'total_with_taxes' => '662.56', - 'total_without_taxes' => '552.13', + 'discount_rate' => '5.0000', + 'total_without_taxes' => '550.28', + 'total_with_taxes' => '660.34', 'currency' => 'EUR', ], ]); diff --git a/server/tests/endpoints/EventsTest.php b/server/tests/endpoints/EventsTest.php index 9932014a2..d3b9ff880 100644 --- a/server/tests/endpoints/EventsTest.php +++ b/server/tests/endpoints/EventsTest.php @@ -33,12 +33,10 @@ public static function data(?int $id, string $format = Event::SERIALIZE_DEFAULT) 'discount_rate' => '0', 'vat_rate' => '20.00', 'currency' => 'EUR', - 'daily_total_without_discount' => '341.45', - 'daily_total_discountable' => '41.45', - 'daily_total_discount' => '0.00', - 'daily_total_without_taxes' => '341.45', - 'daily_total_taxes' => '68.29', - 'daily_total_with_taxes' => '409.74', + 'daily_total' => '341.45', + 'total_without_discount' => '597.54', + 'total_discountable' => '72.54', + 'total_discount' => '0.00', 'total_without_taxes' => '597.54', 'total_taxes' => '119.51', 'total_with_taxes' => '717.05', @@ -137,12 +135,10 @@ public static function data(?int $id, string $format = Event::SERIALIZE_DEFAULT) 'discount_rate' => '0', 'vat_rate' => '20.00', 'currency' => 'EUR', - 'daily_total_without_discount' => '951.00', - 'daily_total_discountable' => '51.00', - 'daily_total_discount' => '0.00', - 'daily_total_without_taxes' => '951.00', - 'daily_total_taxes' => '190.20', - 'daily_total_with_taxes' => '1141.20', + 'daily_total' => '951.00', + 'total_without_discount' => '1664.25', + 'total_discountable' => '89.25', + 'total_discount' => '0.00', 'total_without_taxes' => '1664.25', 'total_taxes' => '332.85', 'total_with_taxes' => '1997.10', @@ -425,12 +421,10 @@ public static function data(?int $id, string $format = Event::SERIALIZE_DEFAULT) 'discount_rate' => '0', 'vat_rate' => '20.00', 'currency' => 'EUR', - 'daily_total_without_discount' => '1031.88', - 'daily_total_discountable' => '31.90', - 'daily_total_discount' => '0.00', - 'daily_total_without_taxes' => '1031.88', - 'daily_total_taxes' => '206.38', - 'daily_total_with_taxes' => '1238.26', + 'daily_total' => '1031.88', + 'total_without_discount' => '3353.61', + 'total_discountable' => '103.68', + 'total_discount' => '0.00', 'total_without_taxes' => '3353.61', 'total_taxes' => '670.72', 'total_with_taxes' => '4024.33', @@ -544,7 +538,7 @@ public static function data(?int $id, string $format = Event::SERIALIZE_DEFAULT) Event::SERIALIZE_BOOKING_DEFAULT => $events->map(fn($event) => ( Arr::except($event, ['parks', 'categories']) )), - Event::SERIALIZE_BOOKING_SUMMARY => $events->map(fn($event) => ( + Event::SERIALIZE_BOOKING_SUMMARY => $events->map(fn ($event) => ( Arr::only($event, [ 'id', 'title', @@ -562,7 +556,6 @@ public static function data(?int $id, string $format = Event::SERIALIZE_DEFAULT) 'is_archived', 'is_departure_inventory_done', 'is_return_inventory_done', - 'has_missing_materials', 'has_not_returned_materials', 'parks', 'categories', @@ -622,12 +615,10 @@ public function testCreateEvent(): void 'discount_rate' => '0', 'vat_rate' => '20.00', 'currency' => 'EUR', - 'daily_total_without_discount' => '0.00', - 'daily_total_discountable' => '0.00', - 'daily_total_discount' => '0.00', - 'daily_total_without_taxes' => '0.00', - 'daily_total_taxes' => '0.00', - 'daily_total_with_taxes' => '0.00', + 'daily_total' => '0.00', + 'total_without_discount' => '0.00', + 'total_discountable' => '0.00', + 'total_discount' => '0.00', 'total_without_taxes' => '0.00', 'total_taxes' => '0.00', 'total_with_taxes' => '0.00', @@ -686,12 +677,10 @@ public function testCreateEvent(): void $this->assertResponseData(array_replace($expected, [ 'id' => 9, 'title' => "Encore un événement", - 'daily_total_without_taxes' => '357.40', - 'daily_total_discountable' => '57.40', - 'daily_total_discount' => '0.00', - 'daily_total_without_discount' => '357.40', - 'daily_total_taxes' => '71.48', - 'daily_total_with_taxes' => '428.88', + 'daily_total' => '357.40', + 'total_without_discount' => '893.50', + 'total_discountable' => '143.50', + 'total_discount' => '0.00', 'total_without_taxes' => '893.50', 'total_taxes' => '178.70', 'total_with_taxes' => '1072.20', @@ -790,12 +779,10 @@ public function testUpdateEvent(): void 'hours' => 984, ], 'has_missing_materials' => true, - 'daily_total_discount' => '0.00', - 'daily_total_discountable' => '0.00', - 'daily_total_taxes' => '260.00', - 'daily_total_with_taxes' => '1559.98', - 'daily_total_without_discount' => '1299.98', - 'daily_total_without_taxes' => '1299.98', + 'daily_total' => '1299.98', + 'total_without_discount' => '40299.38', + 'total_discountable' => '0.00', + 'total_discount' => '0.00', 'degressive_rate' => '31.00', 'discount_rate' => '0', 'vat_rate' => '20.00', @@ -843,12 +830,10 @@ public function testUpdateEvent(): void 'hours' => 984, ], 'has_missing_materials' => true, - 'daily_total_discount' => '0.00', - 'daily_total_discountable' => '149.85', - 'daily_total_taxes' => '149.97', - 'daily_total_with_taxes' => '899.82', - 'daily_total_without_discount' => '749.85', - 'daily_total_without_taxes' => '749.85', + 'daily_total' => '749.85', + 'total_without_discount' => '23245.35', + 'total_discountable' => '4645.35', + 'total_discount' => '0.00', 'degressive_rate' => '31.00', 'discount_rate' => '0', 'vat_rate' => '20.00', @@ -953,6 +938,8 @@ public function testDuplicateEvent(): void 'hours' => 72, ], 'degressive_rate' => '2.50', + 'total_without_discount' => '853.63', + 'total_discountable' => '103.63', 'total_taxes' => '170.73', 'total_with_taxes' => '1024.36', 'total_without_taxes' => '853.63', @@ -2258,8 +2245,17 @@ public function testCreateInvoiceWithDiscount(): void { Carbon::setTestNow(Carbon::create(2020, 10, 22, 18, 42, 36)); + // - Test avec un taux de remise dépassant le taux maximum (5.3628 %). $this->client->post('/api/events/2/invoices', [ - 'discountRate' => 50.0, + 'discountRate' => '5.3629', + ]); + $this->assertApiValidationError([ + 'discount_rate' => ['The discount rate exceeds the maximum.'], + ]); + + // - Test avec le taux de remise maximum possible. + $this->client->post('/api/events/2/invoices', [ + 'discountRate' => '5.3628', ]); $this->assertStatusCode(StatusCode::STATUS_CREATED); $this->assertResponseData([ @@ -2267,9 +2263,9 @@ public function testCreateInvoiceWithDiscount(): void 'number' => '2020-00002', 'date' => '2020-10-22 18:42:36', 'url' => 'http://loxya.test/invoices/2/pdf', - 'discount_rate' => '50.0000', - 'total_without_taxes' => '832.13', - 'total_with_taxes' => '998.56', + 'discount_rate' => '5.3628', + 'total_without_taxes' => '1575.00', + 'total_with_taxes' => '1890.00', 'currency' => 'EUR', ]); } @@ -2295,17 +2291,26 @@ public function testCreateEstimateWithDiscount(): void { Carbon::setTestNow(Carbon::create(2022, 10, 22, 18, 42, 36)); + // - Test avec un taux de remise dépassant le taux maximum (5.3628 %). + $this->client->post('/api/events/2/estimates', [ + 'discountRate' => '5.3629', + ]); + $this->assertApiValidationError([ + 'discount_rate' => ['The discount rate exceeds the maximum.'], + ]); + + // - Test avec le taux de remise maximum possible. $this->client->post('/api/events/2/estimates', [ - 'discountRate' => 50.0, + 'discountRate' => '5.3628', ]); $this->assertStatusCode(StatusCode::STATUS_CREATED); $this->assertResponseData([ 'id' => 2, 'date' => '2022-10-22 18:42:36', 'url' => 'http://loxya.test/estimates/2/pdf', - 'discount_rate' => '50.0000', - 'total_without_taxes' => '832.13', - 'total_with_taxes' => '998.56', + 'discount_rate' => '5.3628', + 'total_without_taxes' => '1575.00', + 'total_with_taxes' => '1890.00', 'currency' => 'EUR', ]); } diff --git a/server/tests/endpoints/InvoicesTest.php b/server/tests/endpoints/InvoicesTest.php index 3ec8a5005..a3d8712e9 100644 --- a/server/tests/endpoints/InvoicesTest.php +++ b/server/tests/endpoints/InvoicesTest.php @@ -15,9 +15,9 @@ public static function data(int $id) 'number' => '2020-00001', 'date' => '2020-01-30 14:00:00', 'url' => 'http://loxya.test/invoices/1/pdf', - 'discount_rate' => '50.0000', - 'total_without_taxes' => '547.31', - 'total_with_taxes' => '658.52', + 'discount_rate' => '4.4766', + 'total_without_taxes' => '544.13', + 'total_with_taxes' => '652.96', 'currency' => 'EUR', ], ]); diff --git a/server/tests/fixtures/seed/estimates.json b/server/tests/fixtures/seed/estimates.json index cc0a87576..31a12286c 100644 --- a/server/tests/fixtures/seed/estimates.json +++ b/server/tests/fixtures/seed/estimates.json @@ -9,18 +9,16 @@ "booking_end_date": "2018-12-18 23:59:59", "beneficiary_id": 1, "degressive_rate": 1.75, - "discount_rate": 50, + "discount_rate": 5, "vat_rate": 20, - "daily_total_without_discount": 331, - "daily_total_discountable": 31, - "daily_total_discount": 15.5, - "daily_total_without_taxes": 315.5, - "daily_total_taxes": 63.1, - "daily_total_with_taxes": 378.6, - "total_without_taxes": 552.13, - "total_taxes": 110.43, - "total_with_taxes": 662.56, - "total_replacement": 21769.9, + "daily_total": 331, + "total_without_discount": 579.25, + "total_discountable": 54.25, + "total_discount": 28.97, + "total_without_taxes": 550.28, + "total_taxes": 110.06, + "total_with_taxes": 660.34, + "total_replacement": 19769.9, "currency": "EUR", "author_id": null, "created_at": "2021-01-30 14:00:00", diff --git a/server/tests/fixtures/seed/invoices.json b/server/tests/fixtures/seed/invoices.json index 2b3b3f23c..f6f746bcd 100644 --- a/server/tests/fixtures/seed/invoices.json +++ b/server/tests/fixtures/seed/invoices.json @@ -10,17 +10,15 @@ "booking_end_date": "2018-12-18 23:59:59", "beneficiary_id": 1, "degressive_rate": 1.75, - "discount_rate": 50, + "discount_rate": 4.4766, "vat_rate": 20, - "daily_total_without_discount": 325.5, - "daily_total_discountable": 25.5, - "daily_total_discount": 12.75, - "daily_total_without_taxes": 312.75, - "daily_total_taxes": 63.55, - "daily_total_with_taxes": 376.3, - "total_without_taxes": 547.31, - "total_taxes": 111.21, - "total_with_taxes": 658.52, + "daily_total": 325.5, + "total_without_discount": 569.63, + "total_discountable": 25.5, + "total_discount": 25.5, + "total_without_taxes": 544.13, + "total_taxes": 108.82, + "total_with_taxes": 652.96, "total_replacement": 19749.9, "currency": "EUR", "author_id": 1, diff --git a/server/tests/fixtures/snapshots/EstimateTest__testToPdf__1.html b/server/tests/fixtures/snapshots/EstimateTest__testToPdf__1.html index 5031e1f98..237decad3 100644 --- a/server/tests/fixtures/snapshots/EstimateTest__testToPdf__1.html +++ b/server/tests/fixtures/snapshots/EstimateTest__testToPdf__1.html @@ -100,6 +100,10 @@ .listing-table > tbody > tr > td:last-child { border-right: 1pt solid #888; } + .totals-table > tbody > tr > td { + border-bottom: 1pt solid #888; + vertical-align: middle; + } .inset { padding: 5mm 3mm 8mm !important; border: 1pt solid #999 !important; @@ -203,82 +207,75 @@

-

{{ translate(hasVat ? 'daily-amount-excl-tax' : 'daily-amount') }}{{ translate('discount-rate') }} {{ translate('daily-total') }} {{ translate('degressive-rate') }}{{ translate('discount') }}{{ translate('totals') }}
+
- {{ dailyTotalWithoutDiscount|format_currency(currency, locale=locale) }} -
-
- {{ translate('discountable') }}
- {{ dailyTotalDiscountable|format_currency(currency, locale=locale) }} + {{ dailyTotal|format_currency(currency, locale=locale) }}
+
- {% if hasDiscount %} - {{ discountRate|format_percent_number({fraction_digit: 4}, locale=locale) }}

- - {{ dailyTotalDiscount|format_currency(currency, locale=locale) }} - {% else %} - {{ translate('no-discount') }} - {% endif %} + × {{ degressiveRate|format_number(locale=locale) }} + ({{ plural('number-of-days', booking.duration.days) }})
- {% if hasVat %} -
- {{ translate('excl-tax') }}
- {{ dailyTotalWithoutTaxes|format_currency(currency, locale=locale) }} -
-
- {{ translate('taxes') }} ({{ vatRate|format_percent_number({fraction_digit: 1}, locale=locale) }})
- {{ dailyTotalTaxes|format_currency(currency, locale=locale) }} + {% if hasDiscount -%} +
+
+ {{ translate('discount-of-amount', [ + discountRate|format_percent_number({max_fraction_digit: 4}, locale=locale), + totalWithoutDiscount|format_currency(currency, locale=locale), + ]) }}
- {% endif %} -
- {% if hasVat %}{{ translate('incl-tax') }}
{% endif %} - {{ dailyTotalWithTaxes|format_currency(currency, locale=locale) }} +
+ - {{ totalDiscount|format_currency(currency, locale=locale) }}
-
- × {{ degressiveRate|format_number(locale=locale) }}

- - ({{ plural('number-of-days', booking.duration.days) }}) - + {% endif -%} +
+
+ {% if hasVat -%} + {{ translate('total-excl-tax') }} + {% else -%} + {{ translate('total-due') }}
+ {{ translate('tax-not-applicable') }} + {% endif -%}
-
{{ translate('total-excl-tax') }}
-
+
{{ totalWithoutTaxes|format_currency(currency, locale=locale) }}
-
- {{ translate('total-due') }} -
-
- {{ translate('tax-not-applicable') }} -
-
-
- {{ totalWithTaxes|format_currency(currency, locale=locale) }} +
+
+ = {{ totalWithoutDiscount|format_currency(currency, locale=locale) }}
-
{{ translate('total-incl-taxes') }}
+
+ {{ translate('taxes') }} + ({{ vatRate|format_percent_number({max_fraction_digit: 2}, locale=locale) }}) +
- {{ totalWithTaxes|format_currency(currency, locale=locale) }} + {{ totalTaxes|format_currency(currency, locale=locale) }}
-
+
+ {{ translate('total-incl-taxes') }}
{{ translate('total-due') }}
-
+
{{ totalWithTaxes|format_currency(currency, locale=locale) }}
+
- - + - - - - - - + + + - - - + diff --git a/server/tests/fixtures/snapshots/EstimateTest__testToPdf__2.html b/server/tests/fixtures/snapshots/EstimateTest__testToPdf__2.html index f29ef3808..0fc5c1f46 100644 --- a/server/tests/fixtures/snapshots/EstimateTest__testToPdf__2.html +++ b/server/tests/fixtures/snapshots/EstimateTest__testToPdf__2.html @@ -100,6 +100,10 @@ .listing-table > tbody > tr > td:last-child { border-right: 1pt solid #888; } + .totals-table > tbody > tr > td { + border-bottom: 1pt solid #888; + vertical-align: middle; + } .inset { padding: 5mm 3mm 8mm !important; border: 1pt solid #999 !important; @@ -209,11 +213,9 @@

-

Tarif H.T. / jourRemise Total / jour Coef. dégressifRemise Totaux
+
331,00 €
-
- Remisable
- 31,00 € -
+
- 50,0000 %

- - 15,50 € -
-
-
- H.T.
- 315,50 € -
-
- T.V.A. (20,0 %)
- 63,10 € -
-
- T.T.C.
378,60 € + × 1,75 + (2 jours)
+
- × 1,75

- - (2 jours) - + 5 % de 579,25 € +
+
+ - 28,97 €
-
Total H.T.
+
+
+ Total H.T. +
+
- 552,13 € + 550,28 € +
+
+
+ = 579,25 €
-
Total T.T.C.
+
+ T.V.A. + (20 %) +
- 662,56 € + 110,06 €
-
+
+ Total T.T.C.
Montant net à payer
-
- 662,56 € +
+ 660,34 €
+
- - @@ -221,68 +223,55 @@

- - - - - - - - + + + - + diff --git a/server/tests/fixtures/snapshots/EstimatesTest__testDownloadPdf__1.html b/server/tests/fixtures/snapshots/EstimatesTest__testDownloadPdf__1.html index be6e35f49..b56b05ae2 100644 --- a/server/tests/fixtures/snapshots/EstimatesTest__testDownloadPdf__1.html +++ b/server/tests/fixtures/snapshots/EstimatesTest__testDownloadPdf__1.html @@ -100,6 +100,10 @@ .listing-table > tbody > tr > td:last-child { border-right: 1pt solid #888; } + .totals-table > tbody > tr > td { + border-bottom: 1pt solid #888; + vertical-align: middle; + } .inset { padding: 5mm 3mm 8mm !important; border: 1pt solid #999 !important; @@ -203,82 +207,75 @@

-

Tarif H.T. / jourRemise Total / jour Coef. dégressif Totaux
-
- 926,45 € -
-
- Remisable
- 26,45 € -
-
+
- Sans remise -
-
-
- H.T.
926,45 €
-
- T.V.A. (20,0 %)
- 185,29 € -
-
- T.T.C.
1 111,74 € -
+
- × 3,8

- - (4 jours) - + × 3,8 + (4 jours)
-
Total H.T.
+
+
+ Total H.T. +
+
3 520,51 €
+
+ = 3 520,51 € +
+
-
Total T.T.C.
+
+ T.V.A. + (20 %) +
- 4 224,61 € + 704,10 €
-
+
+ Total T.T.C.
Montant net à payer
-
+
4 224,61 €
+
- - + - - - - - - + + + - - - + diff --git a/server/tests/fixtures/snapshots/EventTest__testToPdf__1.html b/server/tests/fixtures/snapshots/EventTest__testToPdf__1.html index 3b2e2103a..5f64fd40c 100644 --- a/server/tests/fixtures/snapshots/EventTest__testToPdf__1.html +++ b/server/tests/fixtures/snapshots/EventTest__testToPdf__1.html @@ -100,6 +100,10 @@ .listing-table > tbody > tr > td:last-child { border-right: 1pt solid #888; } + .totals-table > tbody > tr > td { + border-bottom: 1pt solid #888; + vertical-align: middle; + } .inset { padding: 5mm 3mm 8mm !important; border: 1pt solid #999 !important; diff --git a/server/tests/fixtures/snapshots/EventTest__testToPdf__2.html b/server/tests/fixtures/snapshots/EventTest__testToPdf__2.html index a6c80ca37..a9553e824 100644 --- a/server/tests/fixtures/snapshots/EventTest__testToPdf__2.html +++ b/server/tests/fixtures/snapshots/EventTest__testToPdf__2.html @@ -100,6 +100,10 @@ .listing-table > tbody > tr > td:last-child { border-right: 1pt solid #888; } + .totals-table > tbody > tr > td { + border-bottom: 1pt solid #888; + vertical-align: middle; + } .inset { padding: 5mm 3mm 8mm !important; border: 1pt solid #999 !important; diff --git a/server/tests/fixtures/snapshots/EventsTest__testDownloadPdf__1.html b/server/tests/fixtures/snapshots/EventsTest__testDownloadPdf__1.html index f41f84abf..73c444d38 100644 --- a/server/tests/fixtures/snapshots/EventsTest__testDownloadPdf__1.html +++ b/server/tests/fixtures/snapshots/EventsTest__testDownloadPdf__1.html @@ -100,6 +100,10 @@ .listing-table > tbody > tr > td:last-child { border-right: 1pt solid #888; } + .totals-table > tbody > tr > td { + border-bottom: 1pt solid #888; + vertical-align: middle; + } .inset { padding: 5mm 3mm 8mm !important; border: 1pt solid #999 !important; diff --git a/server/tests/fixtures/snapshots/EventsTest__testDownloadPdf__2.html b/server/tests/fixtures/snapshots/EventsTest__testDownloadPdf__2.html index a6c80ca37..a9553e824 100644 --- a/server/tests/fixtures/snapshots/EventsTest__testDownloadPdf__2.html +++ b/server/tests/fixtures/snapshots/EventsTest__testDownloadPdf__2.html @@ -100,6 +100,10 @@ .listing-table > tbody > tr > td:last-child { border-right: 1pt solid #888; } + .totals-table > tbody > tr > td { + border-bottom: 1pt solid #888; + vertical-align: middle; + } .inset { padding: 5mm 3mm 8mm !important; border: 1pt solid #999 !important; diff --git a/server/tests/fixtures/snapshots/EventsTest__testDownloadPdf__3.html b/server/tests/fixtures/snapshots/EventsTest__testDownloadPdf__3.html index 673f3d921..c09fe4ef3 100644 --- a/server/tests/fixtures/snapshots/EventsTest__testDownloadPdf__3.html +++ b/server/tests/fixtures/snapshots/EventsTest__testDownloadPdf__3.html @@ -100,6 +100,10 @@ .listing-table > tbody > tr > td:last-child { border-right: 1pt solid #888; } + .totals-table > tbody > tr > td { + border-bottom: 1pt solid #888; + vertical-align: middle; + } .inset { padding: 5mm 3mm 8mm !important; border: 1pt solid #999 !important; diff --git a/server/tests/fixtures/snapshots/EventsTest__testDownloadPdf__4.html b/server/tests/fixtures/snapshots/EventsTest__testDownloadPdf__4.html index 9313e8212..004fc64c8 100644 --- a/server/tests/fixtures/snapshots/EventsTest__testDownloadPdf__4.html +++ b/server/tests/fixtures/snapshots/EventsTest__testDownloadPdf__4.html @@ -100,6 +100,10 @@ .listing-table > tbody > tr > td:last-child { border-right: 1pt solid #888; } + .totals-table > tbody > tr > td { + border-bottom: 1pt solid #888; + vertical-align: middle; + } .inset { padding: 5mm 3mm 8mm !important; border: 1pt solid #999 !important; diff --git a/server/tests/fixtures/snapshots/InvoiceTest__testCreateFromEvent__1.html b/server/tests/fixtures/snapshots/InvoiceTest__testCreateFromEvent__1.html new file mode 100644 index 000000000..4bb778cdd --- /dev/null +++ b/server/tests/fixtures/snapshots/InvoiceTest__testCreateFromEvent__1.html @@ -0,0 +1,349 @@ + + + + + Facture n°2022-00001 + + + +
Daily price excl. VATDiscount Total / day Degressive rateDiscount Totals
+
€331.00
-
- Discountable
- €31.00 -
+
- 50.0000%

- - €15.50 -
-
-
- Excl. VAT
- €315.50 -
-
- V.A.T. (20.0%)
- €63.10 -
-
- Incl. VAT
€378.60 + × 1.75 + (2 days)
+
- × 1.75

- - (2 days) - + 5% of €579.25 +
+
+ - €28.97
-
Total excl. VAT
+
+
+ Total excl. VAT +
+
- €552.13 + €550.28 +
+
+
+ = €579.25
-
Total incl. VAT
+
+ V.A.T. + (20%) +
- €662.56 + €110.06
-
+
+ Total incl. VAT
Amount due
-
- €662.56 +
+ €660.34
+ + + + +
+

Testing corp.
5 rue des tests
05555 Testville
France

+

SIRET: 543 210 080 20145
APE: 947A

+

Tél. : +33123456789
E-mail : jean@testing-corp.dev

+

N° TVA Intracom. : FR11223344556600

+
+

Facture en Euro, N° 2022-00001

+ + + + + +
Le 22/10/2022Page 1
+

+ Bénéficiaire +

+

+ Client Benef
156 bis, avenue des tests poussés
+88080 Wazzaville
(Réf. 0003)
Tél. : +33123456789
E-mail : client@beneficiaires.com
+

+
+ +
+

Événement : Second événement

+

+ Du mardi 18 décembre 2018 au mercredi 19 décembre 2018, + À Lyon +

+ + + + + + + + + + + + + + + +
CatégorieQuantitéTotal H.T. / jour
+ Son + 5 articles + 951,00 € +
+

Remarque : vous trouverez le détail du matériel aux pages suivantes.

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
Total / jourCoef. dégressifRemiseTotaux
+
+ 951,00 € +
+
+
+ × 1,75 + (2 jours) +
+
+
+ 1,3923 % de 1 664,25 € +
+
+ - 23,17 € +
+
+
+ Total H.T. +
+
+
+ 1 641,08 € +
+
+
+ = 1 664,25 € +
+
+
+ T.V.A. + (20 %) +
+
+
+ 328,22 € +
+
+
+ Total T.T.C.
+ Montant net à payer +
+
+
+ 1 969,30 € +
+
+
+
+ + + + + +
+

Testing corp.
5 rue des tests
05555 Testville
France

+

Tél. : +33123456789
E-mail : jean@testing-corp.dev

+
+

Détails de la facture N° 2022-00001

+ + + + + +
Le 22/10/2022Page 2
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Réf.DésignationQtéP.U. H.T.Val. Remp.Total H.T.
+ Son - Mixeurs +
CL3Console Yamaha CL33 + 300,00 € + + 19 400,00 € + + 900,00 € +
+ Son - Processeurs +
DBXPA2Processeur DBX PA22 + 25,50 € + + 349,90 € + + 51,00 € +
+ + diff --git a/server/tests/fixtures/snapshots/InvoiceTest__testToPdf__1.html b/server/tests/fixtures/snapshots/InvoiceTest__testToPdf__1.html index 58b3a09ad..7b0fde4da 100644 --- a/server/tests/fixtures/snapshots/InvoiceTest__testToPdf__1.html +++ b/server/tests/fixtures/snapshots/InvoiceTest__testToPdf__1.html @@ -100,6 +100,10 @@ .listing-table > tbody > tr > td:last-child { border-right: 1pt solid #888; } + .totals-table > tbody > tr > td { + border-bottom: 1pt solid #888; + vertical-align: middle; + } .inset { padding: 5mm 3mm 8mm !important; border: 1pt solid #999 !important; @@ -194,82 +198,75 @@

- +
- - + - - - - - - + + + - - - + diff --git a/server/tests/fixtures/snapshots/InvoiceTest__testToPdf__2.html b/server/tests/fixtures/snapshots/InvoiceTest__testToPdf__2.html index 5f5e4e1f7..fb5c4c4c9 100644 --- a/server/tests/fixtures/snapshots/InvoiceTest__testToPdf__2.html +++ b/server/tests/fixtures/snapshots/InvoiceTest__testToPdf__2.html @@ -100,6 +100,10 @@ .listing-table > tbody > tr > td:last-child { border-right: 1pt solid #888; } + .totals-table > tbody > tr > td { + border-bottom: 1pt solid #888; + vertical-align: middle; + } .inset { padding: 5mm 3mm 8mm !important; border: 1pt solid #999 !important; @@ -194,82 +198,75 @@

-

Tarif H.T. / jourRemise Total / jour Coef. dégressifRemise Totaux
+
325,50 €
-
- Remisable
- 25,50 € -
+
- 50,0000 %

- - 12,75 € -
-
-
- H.T.
- 312,75 € -
-
- T.V.A. (20,0 %)
- 63,55 € -
-
- T.T.C.
376,30 € + × 1,75 + (2 jours)
+
- × 1,75

- - (2 jours) - + 4,4766 % de 569,63 € +
+
+ - 25,50 €
-
Total H.T.
+
+
+ Total H.T. +
+
- 547,31 € + 544,13 € +
+
+
+ = 569,63 €
-
Total T.T.C.
+
+ T.V.A. + (20 %) +
- 658,52 € + 108,82 €
-
+
+ Total T.T.C.
Montant net à payer
-
- 658,52 € +
+ 652,96 €
+
- - + - - - - - - + + + - - - + diff --git a/server/tests/fixtures/snapshots/InvoiceTest__testToPdf__3.html b/server/tests/fixtures/snapshots/InvoiceTest__testToPdf__3.html index 41bb35e44..bddb165f8 100644 --- a/server/tests/fixtures/snapshots/InvoiceTest__testToPdf__3.html +++ b/server/tests/fixtures/snapshots/InvoiceTest__testToPdf__3.html @@ -100,6 +100,10 @@ .listing-table > tbody > tr > td:last-child { border-right: 1pt solid #888; } + .totals-table > tbody > tr > td { + border-bottom: 1pt solid #888; + vertical-align: middle; + } .inset { padding: 5mm 3mm 8mm !important; border: 1pt solid #999 !important; @@ -209,11 +213,9 @@

-

Tarif H.T. / jourRemise Total / jour Coef. dégressifRemise Totaux
+
325.50 €
-
- Remisable
- 25.50 € -
+
- 50,0000%

- - 12.75 € -
-
-
- H.T.
- 312.75 € -
-
- T.V.A. (20,0%)
- 63.55 € -
-
- T.T.C.
376.30 € + × 1,75 + (2 jours)
+
- × 1,75

- - (2 jours) - + 4,4766% de 569.63 € +
+
+ - 25.50 €
-
Total H.T.
+
+
+ Total H.T. +
+
- 547.31 € + 544.13 € +
+
+
+ = 569.63 €
-
Total T.T.C.
+
+ T.V.A. + (20%) +
- 658.52 € + 108.82 €
-
+
+ Total T.T.C.
Montant net à payer
-
- 658.52 € +
+ 652.96 €
+
- - @@ -221,68 +223,55 @@

- - - - - - - - + + + - + diff --git a/server/tests/fixtures/snapshots/InvoicesTest__testDownloadPdf__1.html b/server/tests/fixtures/snapshots/InvoicesTest__testDownloadPdf__1.html index ebab09f25..61625c58c 100644 --- a/server/tests/fixtures/snapshots/InvoicesTest__testDownloadPdf__1.html +++ b/server/tests/fixtures/snapshots/InvoicesTest__testDownloadPdf__1.html @@ -100,6 +100,10 @@ .listing-table > tbody > tr > td:last-child { border-right: 1pt solid #888; } + .totals-table > tbody > tr > td { + border-bottom: 1pt solid #888; + vertical-align: middle; + } .inset { padding: 5mm 3mm 8mm !important; border: 1pt solid #999 !important; @@ -194,82 +198,75 @@

-

Tarif H.T. / jourRemise Total / jour Coef. dégressif Totaux
-
- 926,45 € -
-
- Remisable
- 26,45 € -
-
+
- Sans remise -
-
-
- H.T.
926,45 €
-
- T.V.A. (20,0 %)
- 185,29 € -
-
- T.T.C.
1 111,74 € -
+
- × 3,8

- - (4 jours) - + × 3,8 + (4 jours)
-
Total H.T.
+
+
+ Total H.T. +
+
3 520,51 €
+
+ = 3 520,51 € +
+
-
Total T.T.C.
+
+ T.V.A. + (20 %) +
- 4 224,61 € + 704,10 €
-
+
+ Total T.T.C.
Montant net à payer
-
+
4 224,61 €
+
- - + - - - - - - + + + - - - + diff --git a/server/tests/fixtures/snapshots/MaterialsTest__testGetAllPdf__1.html b/server/tests/fixtures/snapshots/MaterialsTest__testGetAllPdf__1.html index d48015e03..2fdea3d84 100644 --- a/server/tests/fixtures/snapshots/MaterialsTest__testGetAllPdf__1.html +++ b/server/tests/fixtures/snapshots/MaterialsTest__testGetAllPdf__1.html @@ -100,6 +100,10 @@ .listing-table > tbody > tr > td:last-child { border-right: 1pt solid #888; } + .totals-table > tbody > tr > td { + border-bottom: 1pt solid #888; + vertical-align: middle; + } .inset { padding: 5mm 3mm 8mm !important; border: 1pt solid #999 !important; diff --git a/server/tests/models/EstimateTest.php b/server/tests/models/EstimateTest.php index 33f9d2dbe..fe85131a0 100644 --- a/server/tests/models/EstimateTest.php +++ b/server/tests/models/EstimateTest.php @@ -5,6 +5,7 @@ use Brick\Math\BigDecimal as Decimal; use Illuminate\Support\Carbon; +use Loxya\Errors\Exception\ValidationException; use Loxya\Models\Beneficiary; use Loxya\Models\Estimate; use Loxya\Models\Event; @@ -21,7 +22,6 @@ public function testValidation(): void 'booking_start_date' => '', 'booking_end_date' => '', 'degressive_rate' => 100_000.0, - 'discount_rate' => 101.5, 'vat_rate' => -5.0, 'total_without_taxes' => 1_000_000_000_000, 'total_replacement' => -20, @@ -34,7 +34,7 @@ public function testValidation(): void $expectedErrors = [ 'date' => ["Ce champ est obligatoire.", "Cette date est invalide."], 'degressive_rate' => ["Ce champ est invalide."], - 'discount_rate' => ["Ce champ est invalide."], + 'discount_rate' => ["Ce champ doit contenir un chiffre à virgule."], 'vat_rate' => ["Ce champ est invalide."], 'total_replacement' => ["Ce champ est invalide."], 'currency' => [ @@ -44,17 +44,55 @@ public function testValidation(): void ], 'booking_start_date' => ["Cette date est invalide."], 'booking_end_date' => ["Cette date est invalide."], - 'daily_total_without_discount' => ["Ce champ doit contenir un chiffre à virgule."], - 'daily_total_discountable' => ["Ce champ doit contenir un chiffre à virgule."], - 'daily_total_discount' => ["Ce champ doit contenir un chiffre à virgule."], - 'daily_total_without_taxes' => ["Ce champ doit contenir un chiffre à virgule."], - 'daily_total_taxes' => ["Ce champ doit contenir un chiffre à virgule."], - 'daily_total_with_taxes' => ["Ce champ doit contenir un chiffre à virgule."], + 'daily_total' => ["Ce champ doit contenir un chiffre à virgule."], + 'total_without_discount' => ["Ce champ doit contenir un chiffre à virgule."], + 'total_discountable' => ["Ce champ doit contenir un chiffre à virgule."], + 'total_discount' => ["Ce champ doit contenir un chiffre à virgule."], 'total_without_taxes' => ["Ce champ est invalide."], 'total_taxes' => ["Ce champ doit contenir un chiffre à virgule."], 'total_with_taxes' => ["Ce champ doit contenir un chiffre à virgule."], ]; $this->assertEquals($expectedErrors, $errors); + + // - Test de validation du taux de remise. + $estimate = new Estimate([ + 'date' => '2024-01-19 16:00:00', + 'booking_start_date' => '2018-12-17 00:00:00', + 'booking_end_date' => '2018-12-18 23:59:59', + 'degressive_rate' => 1.75, + 'discount_rate' => 50.0, + 'vat_rate' => 20.0, + 'daily_total' => 1000.0, + 'total_without_discount' => 1750.0, + 'total_discountable' => 437.5, // => 25% de remise max. + 'total_discount' => 875.0, + 'total_without_taxes' => 875.0, + 'total_taxes' => 175.0, + 'total_with_taxes' => 1050.0, + 'currency' => 'EUR', + 'total_replacement' => 2000, + ]); + $estimate->booking()->associate(Event::findOrFail(1)); + $estimate->beneficiary()->associate(Beneficiary::findOrFail(1)); + $errors = $estimate->validationErrors(); + + $expectedErrors = [ + 'discount_rate' => ["Le taux de remise dépasse le maximum."], + ]; + $this->assertEquals($expectedErrors, $errors); + } + + public function testCreateFromEventBadDiscountRate(): void + { + Carbon::setTestNow(Carbon::create(2022, 10, 22, 18, 42, 36)); + + $event = tap(Event::findOrFail(2), function ($event) { + // - Pour cet événement, le taux de remise maximum est de 5.6328 % + $event->discount_rate = Decimal::of('5.3629'); + }); + + $this->expectException(ValidationException::class); + Estimate::createFromBooking($event, User::findOrFail(1)); } public function testCreateFromEvent(): void @@ -107,14 +145,13 @@ public function testCreateFromEvent(): void 'discount_rate' => '1.3923', 'vat_rate' => '20.00', - 'daily_total_without_discount' => '951.00', - 'daily_total_discountable' => '51.00', - 'daily_total_discount' => '13.24', - 'daily_total_without_taxes' => '937.76', + // - Total / jour. + 'daily_total' => '951.00', - // - Taxes. - 'daily_total_taxes' => '187.55', - 'daily_total_with_taxes' => '1125.31', + // - Remise. + 'total_without_discount' => '1664.25', + 'total_discountable' => '89.25', + 'total_discount' => '23.17', // - Totaux. 'total_without_taxes' => '1641.08', diff --git a/server/tests/models/EventTest.php b/server/tests/models/EventTest.php index dbddf6f3c..9cd35e22e 100644 --- a/server/tests/models/EventTest.php +++ b/server/tests/models/EventTest.php @@ -234,14 +234,14 @@ public function testHasNotReturnedMaterials(): void $this->assertSame(null, $result->hasNotReturnedMaterials); } - public function testDailyAmountWithoutDiscount(): void + public function testTotalWithoutDiscount(): void { - $this->assertEquals('341.45', (string) Event::find(1)->daily_total_without_discount); + $this->assertEquals('597.54', (string) Event::find(1)->total_without_discount); } - public function testDailyTotalDiscountable(): void + public function testTotalDiscountable(): void { - $this->assertEquals('41.45', (string) Event::find(1)->daily_total_discountable); + $this->assertEquals('72.54', (string) Event::find(1)->total_discountable); } public function testTotalReplacement(): void diff --git a/server/tests/models/InvoiceTest.php b/server/tests/models/InvoiceTest.php index 25a2ea11a..76aa4b8bc 100644 --- a/server/tests/models/InvoiceTest.php +++ b/server/tests/models/InvoiceTest.php @@ -5,6 +5,8 @@ use Brick\Math\BigDecimal as Decimal; use Illuminate\Support\Carbon; +use Loxya\Errors\Exception\ValidationException; +use Loxya\Models\Beneficiary; use Loxya\Models\Event; use Loxya\Models\Invoice; use Loxya\Models\User; @@ -13,6 +15,90 @@ final class InvoiceTest extends TestCase { + public function testValidation(): void + { + $invoice = new Invoice([ + 'number' => '', + 'date' => '', + 'booking_start_date' => '', + 'booking_end_date' => '', + 'degressive_rate' => 100_000.0, + 'vat_rate' => -5.0, + 'total_without_taxes' => 1_000_000_000_000, + 'total_replacement' => -20, + 'currency' => 'a', + ]); + $invoice->booking()->associate(Event::findOrFail(1)); + $invoice->beneficiary()->associate(Beneficiary::findOrFail(1)); + $errors = $invoice->validationErrors(); + + $expectedErrors = [ + 'number' => ["Ce champ est obligatoire."], + 'date' => ["Ce champ est obligatoire.", "Cette date est invalide."], + 'degressive_rate' => ["Ce champ est invalide."], + 'discount_rate' => ["Ce champ doit contenir un chiffre à virgule."], + 'vat_rate' => ["Ce champ est invalide."], + 'total_replacement' => ["Ce champ est invalide."], + 'currency' => [ + "Toutes les règles requises doivent être validées\xc2\xa0:", + "Ce champ doit être en majuscule.", + "3 caractères attendus.", + ], + 'booking_start_date' => ["Cette date est invalide."], + 'booking_end_date' => ["Cette date est invalide."], + 'daily_total' => ["Ce champ doit contenir un chiffre à virgule."], + 'total_without_discount' => ["Ce champ doit contenir un chiffre à virgule."], + 'total_discountable' => ["Ce champ doit contenir un chiffre à virgule."], + 'total_discount' => ["Ce champ doit contenir un chiffre à virgule."], + 'total_without_taxes' => ["Ce champ est invalide."], + 'total_taxes' => ["Ce champ doit contenir un chiffre à virgule."], + 'total_with_taxes' => ["Ce champ doit contenir un chiffre à virgule."], + ]; + $this->assertEquals($expectedErrors, $errors); + + // - Test de validation du numéro de facture et du taux de remise. + $invoice = new Invoice([ + 'number' => '2020-00001', + 'date' => '2024-01-19 16:00:00', + 'booking_start_date' => '2018-12-17 00:00:00', + 'booking_end_date' => '2018-12-18 23:59:59', + 'degressive_rate' => 1.75, + 'discount_rate' => 50.0, + 'vat_rate' => 20.0, + 'daily_total' => 1000.0, + 'total_without_discount' => 1750.0, + 'total_discountable' => 437.5, // => 25% de remise max. + 'total_discount' => 875.0, + 'total_without_taxes' => 875.0, + 'total_taxes' => 175.0, + 'total_with_taxes' => 1050.0, + 'currency' => 'EUR', + 'total_replacement' => 2000, + ]); + $invoice->booking()->associate(Event::findOrFail(1)); + $invoice->beneficiary()->associate(Beneficiary::findOrFail(1)); + $errors = $invoice->validationErrors(); + + $expectedErrors = [ + 'number' => ["Une facture existe déjà avec ce numéro."], + 'discount_rate' => ["Le taux de remise dépasse le maximum."], + ]; + $this->assertEquals($expectedErrors, $errors); + } + + public function testCreateFromEventBadDiscountRate(): void + { + Carbon::setTestNow(Carbon::create(2022, 10, 22, 18, 42, 36)); + + $event = tap(Event::findOrFail(2), function ($event) { + // - Pour cet événement, le taux de remise maximum est de 5.6328 % + $event->discount_rate = Decimal::of('5.3629'); + }); + + $this->expectException(ValidationException::class); + Invoice::createFromBooking($event, User::findOrFail(1)); + } + public function testCreateFromEvent(): void { Carbon::setTestNow(Carbon::create(2022, 10, 22, 18, 42, 36)); @@ -64,14 +150,13 @@ public function testCreateFromEvent(): void 'discount_rate' => '1.3923', 'vat_rate' => '20.00', - 'daily_total_without_discount' => '951.00', - 'daily_total_discountable' => '51.00', - 'daily_total_discount' => '13.24', - 'daily_total_without_taxes' => '937.76', + // - Total / jour. + 'daily_total' => '951.00', - // - Taxes. - 'daily_total_taxes' => '187.55', - 'daily_total_with_taxes' => '1125.31', + // - Remise. + 'total_without_discount' => '1664.25', + 'total_discountable' => '89.25', + 'total_discount' => '23.17', // - Totaux. 'total_without_taxes' => '1641.08',
Daily price excl. VATDiscount Total / day Degressive rateDiscount Totals
+
€325.50
-
- Discountable
- €25.50 -
+
- 50.0000%

- - €12.75 -
-
-
- Excl. VAT
- €312.75 -
-
- V.A.T. (20.0%)
- €63.55 -
-
- Incl. VAT
€376.30 + × 1.75 + (2 days)
+
- × 1.75

- - (2 days) - + 4.4766% of €569.63 +
+
+ - €25.50
-
Total excl. VAT
+
+
+ Total excl. VAT +
+
- €547.31 + €544.13 +
+
+
+ = €569.63
-
Total incl. VAT
+
+ V.A.T. + (20%) +
- €658.52 + €108.82
-
+
+ Total incl. VAT
Amount due
-
- €658.52 +
+ €652.96