From 9f04d009a03c0622b1c6684ed9f2bef0888dcec5 Mon Sep 17 00:00:00 2001 From: Paul Maillardet Date: Tue, 20 Apr 2021 11:44:47 +0200 Subject: [PATCH] =?UTF-8?q?Ajoute=20la=20cr=C3=A9ation=20/=20suppression?= =?UTF-8?q?=20de=20devis=20pour=20les=20=C3=A9v=C3=A9nements=20(#5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 + .../BillEstimateCreationForm.scss | 47 ++ .../BillEstimateCreationForm.vue | 67 +++ .../BillEstimateCreationForm/index.js | 35 ++ .../components/EventBilling/EventBilling.scss | 109 ++--- .../components/EventBilling/EventBilling.vue | 158 +++--- client/src/components/EventBilling/index.js | 36 +- .../components/EventDetails/EventDetails.scss | 15 + .../components/EventDetails/EventDetails.vue | 47 +- client/src/components/EventDetails/index.js | 170 +++++-- .../EventEstimates/EventEstimates.scss | 138 ++++++ .../EventEstimates/EventEstimates.vue | 109 +++++ client/src/components/EventEstimates/index.js | 140 ++++++ .../EventOverview/EventOverview.scss | 21 +- .../EventOverview/EventOverview.vue | 54 ++- client/src/components/EventOverview/index.js | 113 ++++- client/src/locale/en/common.js | 29 +- client/src/locale/fr/common.js | 29 +- server/composer.json | 1 + server/phpunit.xml | 3 + server/src/App/ApiRouter.php | 3 + server/src/App/App.php | 1 + server/src/App/Controllers/BillController.php | 4 +- .../App/Controllers/EstimateController.php | 67 +++ .../src/App/Controllers/EventController.php | 9 +- server/src/App/I18n/locales/en/messages.php | 11 +- server/src/App/I18n/locales/fr/messages.php | 11 +- server/src/App/Lib/Domain/EventBill.php | 14 +- server/src/App/Lib/Domain/EventEstimate.php | 339 +++++++++++++ server/src/App/Models/Estimate.php | 200 ++++++++ server/src/App/Models/Event.php | 7 + .../20210420090840_create_estimates.php | 49 ++ server/src/views/pdf/estimate-default.twig | 269 +++++++++++ server/tests/Fixtures/seed/estimates.json | 47 ++ server/tests/endpoints/EstimatesTest.php | 182 +++++++ server/tests/endpoints/EventsTest.php | 16 + server/tests/libs/domain/EventBillTest.php | 457 ++++++++++++++++++ .../tests/libs/domain/EventEstimateTest.php | 440 +++++++++++++++++ server/tests/{other => libs/pdf}/PdfTest.php | 8 +- .../{other/Pdf => libs/pdf/files}/.gitignore | 0 .../Pdf => libs/pdf/files}/expected_save.pdf | Bin .../{other/Pdf => libs/pdf/files}/test.html | 0 server/tests/models/BillTest.php | 7 + server/tests/models/EstimateTest.php | 221 +++++++++ server/tests/models/EventTest.php | 15 + server/tests/other/EventBillTest.php | 412 ---------------- 46 files changed, 3397 insertions(+), 717 deletions(-) create mode 100644 client/src/components/BillEstimateCreationForm/BillEstimateCreationForm.scss create mode 100644 client/src/components/BillEstimateCreationForm/BillEstimateCreationForm.vue create mode 100644 client/src/components/BillEstimateCreationForm/index.js create mode 100644 client/src/components/EventEstimates/EventEstimates.scss create mode 100644 client/src/components/EventEstimates/EventEstimates.vue create mode 100644 client/src/components/EventEstimates/index.js create mode 100644 server/src/App/Controllers/EstimateController.php create mode 100644 server/src/App/Lib/Domain/EventEstimate.php create mode 100644 server/src/App/Models/Estimate.php create mode 100644 server/src/database/migrations/20210420090840_create_estimates.php create mode 100644 server/src/views/pdf/estimate-default.twig create mode 100644 server/tests/Fixtures/seed/estimates.json create mode 100644 server/tests/endpoints/EstimatesTest.php create mode 100644 server/tests/libs/domain/EventBillTest.php create mode 100644 server/tests/libs/domain/EventEstimateTest.php rename server/tests/{other => libs/pdf}/PdfTest.php (91%) rename server/tests/{other/Pdf => libs/pdf/files}/.gitignore (100%) rename server/tests/{other/Pdf => libs/pdf/files}/expected_save.pdf (100%) rename server/tests/{other/Pdf => libs/pdf/files}/test.html (100%) create mode 100644 server/tests/models/EstimateTest.php delete mode 100644 server/tests/other/EventBillTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 78768e6e7..f9b80338a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ 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.13.0 (UNRELEASED) + +- Ajoute la création / suppression de devis pour les événements (#5). + ## 0.12.0 (2021-03-29) - Améliore le calcul du matériel restant dans les événements. diff --git a/client/src/components/BillEstimateCreationForm/BillEstimateCreationForm.scss b/client/src/components/BillEstimateCreationForm/BillEstimateCreationForm.scss new file mode 100644 index 000000000..37fd65b0e --- /dev/null +++ b/client/src/components/BillEstimateCreationForm/BillEstimateCreationForm.scss @@ -0,0 +1,47 @@ +.BillEstimateCreationForm { + &__discount-input .FormField__input { + max-width: 90px; + } + + &__discount-target-input .FormField__input { + max-width: 110px; + } + + &__beneficiary { + display: flex; + flex-direction: column; + margin-top: $content-padding-small-vertical; + + &__label { + display: block; + padding: 5px 0; + } + } + + &__save { + margin-top: $content-padding-large-vertical; + } + + @media(min-width: $screen-tablet) { + &__beneficiary { + flex-direction: row; + align-items: center; + height: 2.5rem; + + &__label { + flex: 0 0 $form-label-width; + padding: 0 $input-padding-horizontal; + text-align: right; + } + + &__name { + flex: 1; + } + } + + &__save { + margin-top: $content-padding-small-vertical; + padding-left: $form-label-width; + } + } +} diff --git a/client/src/components/BillEstimateCreationForm/BillEstimateCreationForm.vue b/client/src/components/BillEstimateCreationForm/BillEstimateCreationForm.vue new file mode 100644 index 000000000..3263f7b59 --- /dev/null +++ b/client/src/components/BillEstimateCreationForm/BillEstimateCreationForm.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/client/src/components/BillEstimateCreationForm/index.js b/client/src/components/BillEstimateCreationForm/index.js new file mode 100644 index 000000000..d00069596 --- /dev/null +++ b/client/src/components/BillEstimateCreationForm/index.js @@ -0,0 +1,35 @@ +import Config from '@/config/globalConfig'; +import FormField from '@/components/FormField/FormField.vue'; + +export default { + name: 'BillEstimateCreationForm', + components: { FormField }, + props: { + discountRate: Number, + discountTarget: Number, + maxAmount: Number, + beneficiary: Object, + saveLabel: String, + isRegeneration: Boolean, + loading: Boolean, + }, + data() { + return { + currency: Config.currency.symbol, + }; + }, + methods: { + handleChangeRate(value) { + this.$emit('change', { field: 'rate', value: Number.parseFloat(value) }); + }, + + handleChangeAmount(value) { + this.$emit('change', { field: 'amount', value: Number.parseFloat(value) }); + }, + + handleSubmit(e) { + e.preventDefault(); + this.$emit('submit'); + }, + }, +}; diff --git a/client/src/components/EventBilling/EventBilling.scss b/client/src/components/EventBilling/EventBilling.scss index 1c28a1425..df34d218f 100644 --- a/client/src/components/EventBilling/EventBilling.scss +++ b/client/src/components/EventBilling/EventBilling.scss @@ -1,93 +1,42 @@ .EventBilling { - &__last-bill { - margin-bottom: $content-padding-large-vertical; + margin-bottom: $content-padding-large-vertical; - &__no-bill, - &__regenerate__text { - margin: 0 0 .5rem; - color: $text-light-color; - font-style: italic; - margin-bottom: $content-padding-large-vertical; - } - - &__not-billable { - color: $text-danger-color; - - &__text { - margin: 0; - font-style: italic; - } - } - - &__discount-input .FormField__input { - max-width: 90px; - } - - &__discount-target-input .FormField__input { - max-width: 110px; - } - - &__beneficiary { - display: flex; - flex-direction: column; - margin-top: $content-padding-small-vertical; - - &__label { - display: block; - padding: 5px 0; - } - } - - &__save { - margin-top: 1.2rem; - } + &__no-bill, + &__regenerate__text { + margin: 0 0 $content-padding-large-horizontal; + color: $text-light-color; + font-style: italic; + } - &__download { - font-size: 1.1rem; - margin-bottom: 1rem; + &__no-estimate { + margin: 0 0 $content-padding-large-horizontal; + color: $text-warning-color; + font-size: 1.1rem; + } - &__text { - margin: 0 0 .5rem; - } + &__not-billable { + color: $text-danger-color; - &__link { - display: inline-block; - margin-top: .5rem; - padding: 1rem 1rem 1rem .5rem; - border: 1px solid; - border-radius: $input-border-radius; - } - } - - .fas { - width: 1.5rem; - margin-right: .5rem; - text-align: center; + &__text { + margin: 0; + font-style: italic; } } - @media(min-width: $screen-tablet) { - &__last-bill { - &__beneficiary { - flex-direction: row; - align-items: center; - height: 2.5rem; - - &__label { - flex: 0 0 $form-label-width; - padding: 0 $input-padding-horizontal; - text-align: right; - } + &__download { + font-size: 1.1rem; + margin-bottom: 1rem; - &__name { - flex: 1; - } - } + &__text { + margin: 0 0 .5rem; + } - &__save { - margin-top: 1rem; - padding-left: $form-label-width; - } + &__link { + display: inline-block; + margin-top: .5rem; + padding: 1rem 1rem 1rem .5rem; + border: 1px solid; + border-radius: $input-border-radius; } } } diff --git a/client/src/components/EventBilling/EventBilling.vue b/client/src/components/EventBilling/EventBilling.vue index 2cd9d3709..789833fc0 100644 --- a/client/src/components/EventBilling/EventBilling.vue +++ b/client/src/components/EventBilling/EventBilling.vue @@ -1,107 +1,67 @@ diff --git a/client/src/components/EventBilling/index.js b/client/src/components/EventBilling/index.js index 2f1454895..6c727d027 100644 --- a/client/src/components/EventBilling/index.js +++ b/client/src/components/EventBilling/index.js @@ -6,13 +6,14 @@ import getEventOneDayTotalDiscountable from '@/utils/getEventOneDayTotalDiscount import getEventGrandTotal from '@/utils/getEventGrandTotal'; import getEventReplacementTotal from '@/utils/getEventReplacementTotal'; import decimalRound from '@/utils/decimalRound'; -import FormField from '@/components/FormField/FormField.vue'; +import BillEstimateCreationForm from '@/components/BillEstimateCreationForm/BillEstimateCreationForm.vue'; export default { name: 'EventBilling', - components: { FormField }, + components: { BillEstimateCreationForm }, props: { lastBill: Object, + lastEstimate: Object, beneficiaries: Array, materials: Array, loading: Boolean, @@ -20,9 +21,16 @@ export default { end: Object, }, data() { + let discountRate = 0; + if (this.lastEstimate) { + discountRate = this.lastEstimate.discount_rate; + } else if (this.lastBill) { + discountRate = this.lastBill.discount_rate; + } + return { duration: this.end ? this.end.diff(this.start, 'days') + 1 : 1, - discountRate: this.lastBill ? this.lastBill.discount_rate : 0, + discountRate, currency: Config.currency.symbol, isBillable: this.beneficiaries.length > 0, displayCreateBill: false, @@ -30,7 +38,7 @@ export default { }, watch: { discountRate(newRate) { - this.$emit('discountRateChange', parseFloat(newRate)); + this.$emit('discountRateChange', Number.parseFloat(newRate)); }, }, computed: { @@ -38,7 +46,7 @@ export default { return this.$store.getters['auth/is'](['admin', 'member']); }, - billPdfUrl() { + pdfUrl() { const { baseUrl } = Config; const { id } = this.lastBill || { id: null }; return `${baseUrl}/bills/${id}/pdf`; @@ -92,12 +100,15 @@ export default { }, }, methods: { - recalcDiscountRate(newVal) { - this.discountTarget = parseFloat(newVal); + handleChangeDiscount({ field, value }) { + if (field === 'amount') { + this.discountTarget = value; + } else if (field === 'rate') { + this.discountRate = value; + } }, - createBill(e) { - e.preventDefault(); + createBill() { this.displayCreateBill = false; if (this.loading) { return; @@ -112,6 +123,13 @@ export default { closeBillRegeneration() { this.displayCreateBill = false; + + if (this.lastEstimate) { + this.discountRate = this.lastEstimate.discount_rate; + } + if (this.lastBill) { + this.discountRate = this.lastBill.discount_rate; + } }, formatAmount(amount) { diff --git a/client/src/components/EventDetails/EventDetails.scss b/client/src/components/EventDetails/EventDetails.scss index a05734876..601b33d07 100644 --- a/client/src/components/EventDetails/EventDetails.scss +++ b/client/src/components/EventDetails/EventDetails.scss @@ -28,6 +28,10 @@ flex: 1; padding: 1.5rem; background-color: $bg-color-body; + + .Help { + margin-bottom: $content-padding-small-vertical; + } } } @@ -100,6 +104,17 @@ color: $text-warning-color; } + .EventEstimates { + display: flex; + flex-direction: column; + align-items: flex-start; + + &__list { + width: 100%; + max-width: 600px; + } + } + &__totals { .EventTotals { padding: 0; diff --git a/client/src/components/EventDetails/EventDetails.vue b/client/src/components/EventDetails/EventDetails.vue index 06f9b7aae..71f841e6b 100644 --- a/client/src/components/EventDetails/EventDetails.vue +++ b/client/src/components/EventDetails/EventDetails.vue @@ -8,16 +8,13 @@
- - + +
{{ $t('in') }} @@ -93,6 +90,7 @@
+ + + + +
+

+ + {{ $t('event-not-billable') }} +

+

+ +

+
+
+ @@ -133,8 +161,11 @@ +
diff --git a/client/src/components/EventDetails/index.js b/client/src/components/EventDetails/index.js index f8018fe0d..ac56c5270 100644 --- a/client/src/components/EventDetails/index.js +++ b/client/src/components/EventDetails/index.js @@ -1,9 +1,11 @@ import moment from 'moment'; import { Tabs, Tab } from 'vue-slim-tabs'; import Config from '@/config/globalConfig'; +import Alert from '@/components/Alert'; import Help from '@/components/Help/Help.vue'; import EventMaterials from '@/components/EventMaterials/EventMaterials.vue'; import EventMissingMaterials from '@/components/EventMissingMaterials/EventMissingMaterials.vue'; +import EventEstimates from '@/components/EventEstimates/EventEstimates.vue'; import EventBilling from '@/components/EventBilling/EventBilling.vue'; import EventTotals from '@/components/EventTotals/EventTotals.vue'; import formatTimelineEvent from '@/utils/timeline-event/format'; @@ -18,6 +20,7 @@ export default { Help, EventMaterials, EventMissingMaterials, + EventEstimates, EventBilling, EventTotals, }, @@ -26,16 +29,18 @@ export default { }, data() { return { - help: '', - error: null, - isLoading: false, event: null, beneficiaries: [], discountRate: 0, assignees: [], showBilling: Config.billingMode !== 'none', lastBill: null, - billLoading: false, + lastEstimate: null, + successMessage: null, + error: null, + isLoading: false, + isCreating: false, + deletingId: null, }; }, created() { @@ -50,52 +55,131 @@ export default { }, }, methods: { - getEvent() { - const { eventId } = this.$props; - const url = `events/${eventId}`; - this.error = null; - this.isLoading = true; - this.$http.get(url) - .then(({ data }) => { - this.setData(data); - this.isLoading = false; - }) - .catch(this.handleError); + async getEvent() { + try { + this.error = null; + this.successMessage = null; + this.isLoading = true; + + const { eventId } = this.$props; + const url = `events/${eventId}`; + + const { data } = await this.$http.get(url); + this.setData(data); + } catch (error) { + this.handleError(error); + } finally { + this.isLoading = false; + } }, handleChangeDiscountRate(discountRate) { this.discountRate = discountRate; }, - handleCreateBill(discountRate) { - this.error = null; - this.billLoading = true; - const { eventId } = this.$props; - const url = `events/${eventId}/bill`; - this.$http.post(url, { discountRate }) - .then(({ data }) => { - this.lastBill = { ...data, date: moment(data.date) }; - }) - .catch(this.handleError) - .finally(() => { - this.billLoading = false; - }); + handleChangeTab() { + this.successMessage = null; }, - setEventIsBillable() { - this.error = null; - this.isLoading = true; - const { eventId } = this.$props; - const putData = { is_billable: true }; - this.$http.put(`events/${eventId}`, putData) - .then(({ data }) => { - this.setData(data); - this.isLoading = false; - }) - .catch(this.handleError); + async handleCreateEstimate(discountRate) { + if (this.isCreating || this.deletingId) { + return; + } + + try { + this.error = null; + this.successMessage = null; + this.isCreating = true; + + const { id } = this.event; + const { data } = await this.$http.post(`events/${id}/estimate`, { discountRate }); + + this.event.estimates.unshift(data); + this.lastEstimate = { ...data, date: moment(data.date) }; + this.successMessage = this.$t('estimate-created'); + } catch (error) { + this.handleError(error); + } finally { + this.isCreating = false; + } }, - handleSaved(newData) { + async handleDeleteEstimate(id) { + if (this.deletingId || this.isCreating) { + return; + } + + const { value } = await Alert.ConfirmDelete(this.$t, 'estimate', false); + if (!value) { + return; + } + + try { + this.error = null; + this.successMessage = null; + this.deletingId = id; + + const { data } = await this.$http.delete(`estimates/${id}`); + + const { estimates } = this.event; + const newEstimatesList = estimates.filter((estimate) => (estimate.id !== data.id)); + this.event.estimates = newEstimatesList; + + const [lastOne] = newEstimatesList; + this.lastEstimate = lastOne; + this.successMessage = this.$t('estimate-deleted'); + } catch (error) { + this.handleError(error); + } finally { + this.deletingId = null; + } + }, + + async handleCreateBill(discountRate) { + if (this.deletingId || this.isCreating) { + return; + } + + try { + this.error = null; + this.successMessage = null; + this.isCreating = true; + const { eventId } = this.$props; + const url = `events/${eventId}/bill`; + + const { data } = await this.$http.post(url, { discountRate }); + this.lastBill = { ...data, date: moment(data.date) }; + this.successMessage = this.$t('bill-created'); + } catch (error) { + this.handleError(error); + } finally { + this.isCreating = false; + } + }, + + async setEventIsBillable() { + if (this.isLoading || this.deletingId || this.isCreating) { + return; + } + + try { + this.error = null; + this.successMessage = null; + this.isLoading = true; + const { eventId } = this.$props; + const putData = { is_billable: true }; + + const { data } = await this.$http.put(`events/${eventId}`, putData); + this.setData(data); + this.successMessage = this.$t('event-is-now-billable'); + } catch (error) { + this.handleError(error); + } finally { + this.isLoading = false; + } + }, + + handleSavedFromHeader(newData) { this.error = null; this.setData(newData); // Ne fonctionne pas comme espéré, pffff @@ -130,6 +214,12 @@ export default { ); } + if (data.estimates.length > 0) { + const [lastEstimate] = data.estimates; + this.lastEstimate = { ...lastEstimate, date: moment(lastEstimate.date) }; + this.discountRate = lastEstimate ? lastEstimate.discount_rate : 0; + } + if (data.bills.length > 0) { const [lastBill] = data.bills; this.lastBill = { ...lastBill, date: moment(lastBill.date) }; diff --git a/client/src/components/EventEstimates/EventEstimates.scss b/client/src/components/EventEstimates/EventEstimates.scss new file mode 100644 index 000000000..b9e1d3273 --- /dev/null +++ b/client/src/components/EventEstimates/EventEstimates.scss @@ -0,0 +1,138 @@ +.EventEstimates { + margin-bottom: $content-padding-large-vertical; + + &__no-estimate, + &__regenerate__text { + margin: 0 0 $content-padding-large-horizontal; + color: $text-light-color; + font-style: italic; + } + + &__list { + margin: 0 0 1rem; + padding: 0; + font-size: 1.1rem; + + &__item { + display: flex; + list-style: none; + align-items: center; + margin-bottom: $content-padding-large-horizontal; + width: 100%; + + &__icon { + flex: 0 0 auto; + font-size: 1.5rem; + } + + &__text { + flex: 1; + padding: 0 $content-padding-small-vertical; + line-height: 1.3; + font-size: 1rem; + } + + &__actions { + flex: 0 0 auto; + } + + &__download { + display: inline-block; + padding: 6px 9px; + border: 1px solid; + border-radius: $input-border-radius; + font-size: 1rem; + cursor: pointer; + + &--disabled { + opacity: .6; + cursor: not-allowed !important; + } + } + + &__delete { + margin-left: $content-padding-small-vertical; + } + + &--old { + .EventEstimates__list__item__icon, + .EventEstimates__list__item__text { + color: $text-light-color; + } + } + } + } + + &__loading { + margin: $content-padding-large-vertical 0; + font-size: 1.2rem; + text-align: center; + } + + &__regenerate { + display: flex; + flex-direction: column; + align-items: center; + } + + &__warning-has-bill { + margin: 0 0 $content-padding-large-horizontal; + color: $text-warning-color; + font-size: 1.1rem; + } + + &__not-billable { + color: $text-danger-color; + + &__text { + margin: 0; + font-style: italic; + } + } + + &__discount-input .FormField__input { + max-width: 90px; + } + + &__discount-target-input .FormField__input { + max-width: 110px; + } + + &__beneficiary { + display: flex; + flex-direction: column; + margin-top: $content-padding-small-vertical; + + &__label { + display: block; + padding: 5px 0; + } + } + + &__save { + margin-top: $content-padding-large-vertical; + } + + @media(min-width: $screen-tablet) { + &__beneficiary { + flex-direction: row; + align-items: center; + height: 2.5rem; + + &__label { + flex: 0 0 $form-label-width; + padding: 0 $input-padding-horizontal; + text-align: right; + } + + &__name { + flex: 1; + } + } + + &__save { + margin-top: 1rem; + padding-left: $form-label-width; + } + } +} diff --git a/client/src/components/EventEstimates/EventEstimates.vue b/client/src/components/EventEstimates/EventEstimates.vue new file mode 100644 index 000000000..2f4400edc --- /dev/null +++ b/client/src/components/EventEstimates/EventEstimates.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/client/src/components/EventEstimates/index.js b/client/src/components/EventEstimates/index.js new file mode 100644 index 000000000..3926ed784 --- /dev/null +++ b/client/src/components/EventEstimates/index.js @@ -0,0 +1,140 @@ +import moment from 'moment'; +import Config from '@/config/globalConfig'; +import formatAmount from '@/utils/formatAmount'; +import getMaterialItemsCount from '@/utils/getMaterialItemsCount'; +import getEventOneDayTotal from '@/utils/getEventOneDayTotal'; +import getEventOneDayTotalDiscountable from '@/utils/getEventOneDayTotalDiscountable'; +import getEventGrandTotal from '@/utils/getEventGrandTotal'; +import getEventReplacementTotal from '@/utils/getEventReplacementTotal'; +import decimalRound from '@/utils/decimalRound'; +import BillEstimateCreationForm from '@/components/BillEstimateCreationForm/BillEstimateCreationForm.vue'; + +export default { + name: 'EventEstimates', + components: { BillEstimateCreationForm }, + props: { + beneficiaries: Array, + materials: Array, + estimates: Array, + lastBill: Object, + loading: Boolean, + deletingId: Number, + start: Object, + end: Object, + }, + data() { + const [lastEstimate] = this.estimates; + + return { + duration: this.end ? this.end.diff(this.start, 'days') + 1 : 1, + discountRate: lastEstimate ? lastEstimate.discount_rate : 0, + currency: Config.currency.symbol, + isBillable: this.beneficiaries.length > 0, + displayCreateEstimate: false, + }; + }, + watch: { + discountRate(newRate) { + this.$emit('discountRateChange', Number.parseFloat(newRate)); + }, + }, + computed: { + userCanEdit() { + return this.$store.getters['auth/is'](['admin', 'member']); + }, + + ratio() { + return Config.degressiveRate(this.duration); + }, + + itemsCount() { + return getMaterialItemsCount(this.materials); + }, + + total() { + return getEventOneDayTotal(this.materials); + }, + + grandTotal() { + return getEventGrandTotal(this.total, this.duration); + }, + + totalDiscountable() { + return getEventOneDayTotalDiscountable(this.materials); + }, + + grandTotalDiscountable() { + return getEventGrandTotal(this.totalDiscountable, this.duration); + }, + + discountAmount() { + return this.grandTotalDiscountable * (this.discountRate / 100); + }, + + discountTarget: { + get() { + return decimalRound((this.grandTotal - this.discountAmount)); + }, + set(value) { + const diff = this.grandTotal - value; + const rate = 100 * (diff / this.grandTotalDiscountable); + this.discountRate = decimalRound(rate, 4); + }, + }, + + grandTotalWithDiscount() { + return this.grandTotal - this.discountAmount; + }, + + replacementTotal() { + return getEventReplacementTotal(this.materials); + }, + }, + methods: { + handleChangeDiscount({ field, value }) { + if (field === 'amount') { + this.discountTarget = value; + } else if (field === 'rate') { + this.discountRate = value; + } + }, + + createEstimate() { + this.displayCreateEstimate = false; + if (this.loading) { + return; + } + + this.$emit('createEstimate', this.discountRate); + }, + + getPdfUrl(id) { + if (this.deletingId === id) { + return '#'; + } + + const { baseUrl } = Config; + return `${baseUrl}/estimates/${id}/pdf`; + }, + + openCreateEstimate() { + this.displayCreateEstimate = true; + }, + + closeCreateEstimate() { + this.displayCreateEstimate = false; + }, + + formatDate(date) { + const momentDate = moment(date); + return { + date: momentDate.format('L'), + hour: momentDate.format('HH:mm'), + }; + }, + + formatAmount(amount) { + return formatAmount(amount); + }, + }, +}; diff --git a/client/src/components/EventOverview/EventOverview.scss b/client/src/components/EventOverview/EventOverview.scss index 12ebd94b9..3d7d0e854 100644 --- a/client/src/components/EventOverview/EventOverview.scss +++ b/client/src/components/EventOverview/EventOverview.scss @@ -126,23 +126,26 @@ &__billing { display: flex; - flex-direction: column-reverse; + flex-direction: column; padding-bottom: $info-spacing; - .EventBilling { - flex: 1; - min-width: 300px; - padding: 0 .5rem; - padding-right: $content-padding-large-horizontal; + .EventTotals { + flex: 0 0 auto; + text-align: left; + padding: $content-padding-small-vertical $content-padding-large-horizontal $content-padding-large-vertical; border-left: 3px solid #383838; } - .EventTotals { + &__tabs { flex: 1; - padding-bottom: $info-spacing; - padding-left: $content-padding-large-horizontal; + min-width: 350px; + padding: 0 $content-padding-large-horizontal; border-left: 3px solid #383838; } + + .Help { + margin-bottom: $content-padding-small-vertical; + } } @media(min-width: $screen-desktop) { diff --git a/client/src/components/EventOverview/EventOverview.vue b/client/src/components/EventOverview/EventOverview.vue index 4d8803730..1c92e7c68 100644 --- a/client/src/components/EventOverview/EventOverview.vue +++ b/client/src/components/EventOverview/EventOverview.vue @@ -103,17 +103,6 @@ {{ $t('billing') }}
- + + + + + + + + + + + +

{{ $t('page-events.warning-no-material') }} diff --git a/client/src/components/EventOverview/index.js b/client/src/components/EventOverview/index.js index e84896248..a53bffc4a 100644 --- a/client/src/components/EventOverview/index.js +++ b/client/src/components/EventOverview/index.js @@ -1,27 +1,47 @@ import moment from 'moment'; +import { Tabs, Tab } from 'vue-slim-tabs'; import Config from '@/config/globalConfig'; +import Alert from '@/components/Alert'; +import Help from '@/components/Help/Help.vue'; import EventMaterials from '@/components/EventMaterials/EventMaterials.vue'; import EventMissingMaterials from '@/components/EventMissingMaterials/EventMissingMaterials.vue'; import EventBilling from '@/components/EventBilling/EventBilling.vue'; +import EventEstimates from '@/components/EventEstimates/EventEstimates.vue'; import EventTotals from '@/components/EventTotals/EventTotals.vue'; export default { name: 'EventOverview', components: { + Tabs, + Tab, + Help, EventMaterials, EventMissingMaterials, EventBilling, + EventEstimates, EventTotals, }, props: { event: Object }, data() { const [lastBill] = this.event.bills; - const discountRate = lastBill ? lastBill.discount_rate : 0; + const [lastEstimate] = this.event.estimates; + + let discountRate = 0; + if (lastEstimate) { + discountRate = lastEstimate.discount_rate; + } + if (lastBill) { + discountRate = lastBill.discount_rate; + } return { showBilling: Config.billingMode !== 'none', lastBill: lastBill ? { ...lastBill, date: moment(lastBill.date) } : null, - billLoading: false, + lastEstimate: lastEstimate ? { ...lastEstimate, date: moment(lastEstimate.date) } : null, + isCreating: false, + deletingId: null, + successMessage: null, + error: null, discountRate, }; }, @@ -55,17 +75,84 @@ export default { this.discountRate = discountRate; }, - handleCreateBill(discountRate) { - this.billLoading = true; - const { id } = this.event; - this.$http.post(`events/${id}/bill`, { discountRate }) - .then(({ data }) => { - this.lastBill = { ...data, date: moment(data.date) }; - }) - .catch(this.handleError) - .finally(() => { - this.billLoading = false; - }); + handleChangeBillingTab() { + this.successMessage = null; + }, + + async handleCreateEstimate(discountRate) { + if (this.isCreating || this.deletingId) { + return; + } + + try { + this.error = null; + this.successMessage = null; + this.isCreating = true; + + const { id } = this.event; + const { data } = await this.$http.post(`events/${id}/estimate`, { discountRate }); + + this.event.estimates.unshift(data); + this.lastEstimate = { ...data, date: moment(data.date) }; + this.successMessage = this.$t('estimate-created'); + } catch (error) { + this.error = error; + } finally { + this.isCreating = false; + } + }, + + async handleDeleteEstimate(id) { + if (this.deletingId || this.isCreating) { + return; + } + + const { value } = await Alert.ConfirmDelete(this.$t, 'estimate', false); + if (!value) { + return; + } + + try { + this.error = null; + this.successMessage = null; + this.deletingId = id; + + const { data } = await this.$http.delete(`estimates/${id}`); + + const { estimates } = this.event; + const newEstimatesList = estimates.filter((estimate) => (estimate.id !== data.id)); + this.event.estimates = newEstimatesList; + + const [lastOne] = newEstimatesList; + this.lastEstimate = lastOne; + this.successMessage = this.$t('estimate-deleted'); + } catch (error) { + this.error = error; + } finally { + this.deletingId = null; + } + }, + + async handleCreateBill(discountRate) { + if (this.isCreating || this.deletingId) { + return; + } + + try { + this.error = null; + this.successMessage = null; + this.isCreating = true; + + const { id } = this.event; + const { data } = await this.$http.post(`events/${id}/bill`, { discountRate }); + + this.lastBill = { ...data, date: moment(data.date) }; + this.successMessage = this.$t('bill-created'); + } catch (error) { + this.error = error; + } finally { + this.isCreating = false; + } }, }, }; diff --git a/client/src/locale/en/common.js b/client/src/locale/en/common.js index e22c28737..d024d568e 100644 --- a/client/src/locale/en/common.js +++ b/client/src/locale/en/common.js @@ -125,6 +125,7 @@ export default { 'confirmed': "Confirmed", 'not-confirmed': "Not confirmed", 'is-billable': "Is billable?", + 'event-is-now-billable': "This event is now billable.", 'is-not-billable-help': "«\u00a0Loan\u00a0» Mode: no billing.", 'is-billable-help': "«\u00a0Rent\u00a0» Mode: billing possible.", 'event-not-billable': "This event is defined as «\u00a0not billable\u00a0».", @@ -146,23 +147,39 @@ export default { 'show-materials-details': "Show detailed materials list", 'hide-materials-details': "Hide materials list", 'bill': "Bill", + 'estimate': "Estimate", + 'estimates': "Estimates", 'no-bill-help': "No bill for this event yet.", + 'no-estimate-help': "No estimate for this event yet.", + 'warning-no-estimate-before-billing': "Warning, this event does not have any estimate!", + 'warning-event-has-bill': "Warning, this event already have a bill!", + 'estimate-item-help': "Estimate of {date} at {hour}", 'missing-beneficiary': "Missing beneficiary", - 'not-billable-help': "You can't create a bill for an event without at least one beneficiary.", + 'not-billable-help': "You can't create a bill (or estimate) for an event without at least one beneficiary.", 'click-edit-to-create-one': "Click on «\u00a0Edit\u00a0» button to add one.", - 'download-bill-help1': "Bill n° {number}, generated on {date}", - 'download-bill-help2': "with a discount rate of {discountRate}\u00a0%", - 'download-bill-help3': "for an amount of {amount}", + 'bill-number-generated-at': "Bill n° {number}, generated on {date}", + 'with-amount-of': "for an amount of {amount}", 'create-bill-help': "You can create a new bill for the first beneficiary in the list, and give it a discount rate or amount:", + 'create-estimate-help': "You can create an estimate for the first beneficiary in the list, and give it a discount rate or amount:", 'contact-someone-to-create-bill': "If needed, contact a member of the team and ask them to edit the invoice.", + 'contact-someone-to-create-estimate': "If needed, contact a member of the team and ask them to create an estimate.", 'discount': "Discount", 'without-discount': "Without discount", + 'with-discount': "with a discount rate of {rate}\u00a0%", + 'discount-rate': "{rate}\u00a0% off", 'wanted-rate': "Rate in %", 'wanted-amount': "Wanted amount", 'create-bill': "Create bill", - 'download-bill-pdf': "Download PDF file", + 'create-estimate': "Create estimate", + 'download': "Download", + 'download-pdf': "Download PDF file", 'regenerate-bill-help': "You can regenerate the bill to change discount, or if the event has been modified.", - 'click-here-to-regenerate': "Click here to create a new bill.", + 'regenerate-estimate-help': "You can create another estimate, if the event has changed, or if you want another discount rate.", + 'click-here-to-regenerate-bill': "Click here to regenerate the bill.", + 'create-new-estimate': "Create a new estimate", + 'estimate-created': "Estimate created.", + 'estimate-deleted': "The estimate has been deleted.", + 'bill-created': "Bill created.", 'total': "Total", 'total-discountable': "Total discountable", 'items-count': [ diff --git a/client/src/locale/fr/common.js b/client/src/locale/fr/common.js index 5486e5699..8cda6f28f 100644 --- a/client/src/locale/fr/common.js +++ b/client/src/locale/fr/common.js @@ -125,6 +125,7 @@ export default { 'confirmed': "Confirmé", 'not-confirmed': "Non confirmé", 'is-billable': "Est facturable\u00a0?", + 'event-is-now-billable': "Cet événement est maintenant facturable.", 'is-not-billable-help': "Mode «\u00a0prêt\u00a0»\u00a0: pas de facturation.", 'is-billable-help': "Mode «\u00a0location\u00a0»\u00a0: facturation possible.", 'event-not-billable': "Cet événement est défini comme «\u00a0non facturable\u00a0».", @@ -146,23 +147,39 @@ export default { 'show-materials-details': "Afficher la liste détaillée du matériel", 'hide-materials-details': "Cacher la liste du matériel", 'bill': "Facture", + 'estimate': "Devis", + 'estimates': "Devis", 'no-bill-help': "Cet événement n'a aucune facture pour le moment.", + 'no-estimate-help': "Cet événement n'a aucun devis pour le moment.", + 'estimate-item-help': "Devis du {date} à {hour}", + 'warning-no-estimate-before-billing': "Attention, cet événement n'a pas eu de devis\u00a0!", + 'warning-event-has-bill': "Attention, cet événement a déjà une facture\u00a0!", 'missing-beneficiary': "Bénéficiaire manquant", - 'not-billable-help': "Vous ne pouvez pas créer de facture pour un événement qui n'a pas encore de bénéficiaire.", + 'not-billable-help': "Vous ne pouvez pas créer de devis ou facture pour un événement qui n'a pas encore de bénéficiaire.", 'click-edit-to-create-one': "Cliquez sur le bouton «\u00a0Modifier\u00a0» pour en ajouter un.", - 'download-bill-help1': "Facture n° {number}, générée le {date}", - 'download-bill-help2': "avec une remise de {discountRate}\u00a0%", - 'download-bill-help3': "pour un montant de {amount}", + 'bill-number-generated-at': "Facture n° {number}, générée le {date}", + 'with-amount-of': "pour un montant de {amount}", 'create-bill-help': "Vous pouvez créer une facture au nom du premier bénéficiaire de la liste, en utilisant une remise (taux ou montant)\u00a0:", + 'create-estimate-help': "Vous pouvez créer un devis au nom du premier bénéficiaire de la liste, en utilisant une remise (taux ou montant)\u00a0:", 'contact-someone-to-create-bill': "Si besoin, contactez un membre de l'équipe pour lui demander d'éditer la facture.", + 'contact-someone-to-create-estimate': "Si besoin, contactez un membre de l'équipe pour lui demander d'éditer un devis.", 'discount': "Remise", 'without-discount': "sans remise", + 'with-discount': "avec une remise de {rate}\u00a0%", + 'discount-rate': "remise {rate}\u00a0%", 'wanted-rate': "Taux en %", 'wanted-amount': "Montant souhaité", 'create-bill': "Créer la facture", - 'download-bill-pdf': "Télécharger au format PDF", + 'create-estimate': "Créer un devis", + 'download': "Télécharger", + 'download-pdf': "Télécharger au format PDF", 'regenerate-bill-help': "Vous pouvez re-générer la facture pour en changer la remise, ou si l'événement a été modifié.", - 'click-here-to-regenerate': "Cliquez ici pour refaire une facture.", + 'regenerate-estimate-help': "Vous pouvez créer un autre devis si l'événement a été modifié, ou si vous voulez une remise différente.", + 'click-here-to-regenerate-bill': "Cliquez ici pour refaire une facture.", + 'create-new-estimate': "Créer un nouveau devis", + 'estimate-created': "Le devis a bien été créé.", + 'estimate-deleted': "Le devis a été supprimé.", + 'bill-created': "La facture a bien été créée.", 'total': "Total", 'total-discountable': "Total remisable", 'items-count': [ diff --git a/server/composer.json b/server/composer.json index 51f2ddfc4..fa5af78bc 100644 --- a/server/composer.json +++ b/server/composer.json @@ -19,6 +19,7 @@ "test": "src/vendor/bin/phpunit --colors=always --coverage-html tests/coverage", "testapi": "src/vendor/bin/phpunit --colors=always --testsuite api --filter", "testmodels": "src/vendor/bin/phpunit --colors=always --testsuite models --filter", + "testlibs": "src/vendor/bin/phpunit --colors=always --testsuite libs --filter", "testother": "src/vendor/bin/phpunit --colors=always --testsuite other --filter" }, "config": { diff --git a/server/phpunit.xml b/server/phpunit.xml index 7e9f62bb1..7cc532274 100644 --- a/server/phpunit.xml +++ b/server/phpunit.xml @@ -3,6 +3,9 @@ tests/other + + tests/libs + tests/models diff --git a/server/src/App/ApiRouter.php b/server/src/App/ApiRouter.php index fe25bf321..558ad77de 100644 --- a/server/src/App/ApiRouter.php +++ b/server/src/App/ApiRouter.php @@ -36,6 +36,7 @@ class ApiRouter '/events/{id:[0-9]+}[/]' => 'EventController:getOne', '/events/{id:[0-9]+}/missing-materials[/]' => 'EventController:getMissingMaterials', '/bills/{id:[0-9]+}[/]' => 'BillController:getOne', + '/estimates/{id:[0-9]+}[/]' => 'EstimateController:getOne', ], 'post' => [ '/session[/]' => 'AuthController:loginWithForm', @@ -51,6 +52,7 @@ class ApiRouter '/attributes[/]' => 'AttributeController:create', '/events[/]' => 'EventController:create', '/events/{eventId:[0-9]+}/bill[/]' => 'BillController:create', + '/events/{eventId:[0-9]+}/estimate[/]' => 'EstimateController:create', ], 'put' => [ '/users/{id:[0-9]+}[/]' => 'UserController:update', @@ -87,6 +89,7 @@ class ApiRouter '/events/{id:[0-9]+}[/]' => 'EventController:delete', '/bills/{id:[0-9]+}[/]' => 'BillController:delete', '/documents/{id:[0-9]+}[/]' => 'DocumentController:delete', + '/estimates/{id:[0-9]+}[/]' => 'EstimateController:delete', ], ]; diff --git a/server/src/App/App.php b/server/src/App/App.php index 3ab24390d..8705ff279 100644 --- a/server/src/App/App.php +++ b/server/src/App/App.php @@ -158,6 +158,7 @@ private function _setAppRoutes() // - Download files $this->app->get('/bills/{id:[0-9]+}/pdf[/]', 'BillController:getOnePdf')->setName('getBillPdf'); + $this->app->get('/estimates/{id:[0-9]+}/pdf[/]', 'EstimateController:getOnePdf')->setName('getBillPdf'); $this->app->get('/events/{id:[0-9]+}/pdf[/]', 'EventController:getOnePdf')->setName('getEventPdf'); $this->app->get('/documents/{id:[0-9]+}/download[/]', 'DocumentController:getOne')->setName('getDocumentFile'); diff --git a/server/src/App/Controllers/BillController.php b/server/src/App/Controllers/BillController.php index d0476d2dc..18fda825f 100644 --- a/server/src/App/Controllers/BillController.php +++ b/server/src/App/Controllers/BillController.php @@ -15,9 +15,9 @@ class BillController use WithPdf; protected $container; - protected $model; - protected $dataFolder = 'bills'; + /** @var Bill */ + protected $model; public function __construct($container) { diff --git a/server/src/App/Controllers/EstimateController.php b/server/src/App/Controllers/EstimateController.php new file mode 100644 index 000000000..3c3b5a03e --- /dev/null +++ b/server/src/App/Controllers/EstimateController.php @@ -0,0 +1,67 @@ +container = $container; + $this->model = new Estimate(); + } + + // —————————————————————————————————————————————————————— + // — + // — Getters + // — + // —————————————————————————————————————————————————————— + + public function getOne(Request $request, Response $response): Response + { + $id = (int)$request->getAttribute('id'); + $model = $this->model->find($id); + if (!$model) { + throw new Errors\NotFoundException; + } + + return $response->withJson($model->toArray()); + } + + // —————————————————————————————————————————————————————— + // — + // — Setters + // — + // —————————————————————————————————————————————————————— + + public function create(Request $request, Response $response): Response + { + $eventId = (int)$request->getAttribute('eventId'); + $discountRate = (float)$request->getParsedBodyParam('discountRate'); + $result = $this->model->createFromEvent($eventId, Auth::user()->id, $discountRate); + return $response->withJson($result->toArray(), SUCCESS_CREATED); + } + + public function delete(Request $request, Response $response): Response + { + $id = (int)$request->getAttribute('id'); + $model = $this->model->remove($id); + + $data = $model ? $model->toArray() : ['destroyed' => true]; + return $response->withJson($data, SUCCESS_OK); + } +} diff --git a/server/src/App/Controllers/EventController.php b/server/src/App/Controllers/EventController.php index 81ff5889f..91865821a 100644 --- a/server/src/App/Controllers/EventController.php +++ b/server/src/App/Controllers/EventController.php @@ -148,14 +148,17 @@ protected function _getFormattedEvent(int $id): array ->with('Beneficiaries') ->with('Materials') ->with('Bills') + ->with('Estimates') ->find($id); $result = $model->toArray(); - if (!$model->bills) { - return $result; + if ($model->bills) { + $result['bills'] = $model->bills; + } + if ($model->estimates) { + $result['estimates'] = $model->estimates; } - $result['bills'] = $model->bills; return $result; } } diff --git a/server/src/App/I18n/locales/en/messages.php b/server/src/App/I18n/locales/en/messages.php index 2e9419906..6fec26326 100644 --- a/server/src/App/I18n/locales/en/messages.php +++ b/server/src/App/I18n/locales/en/messages.php @@ -2495,15 +2495,21 @@ => "Done!", // - // - Bills messages + // - Bills & estimates messages // "Bill" => "Bill", + "Estimate" + => "Devis", + "billTitle" => "Bill in %s, N° %s", + "estimateTitle" + => "Estimate in %s", + "onDate" => "On %s", @@ -2621,6 +2627,9 @@ "billDetailsTitle" => "Details of bill N° %s", + "estimateDetailsTitle" + => "Estimate details", + "ref" => "Ref.", diff --git a/server/src/App/I18n/locales/fr/messages.php b/server/src/App/I18n/locales/fr/messages.php index 6da1f8a3b..355ffbb7e 100644 --- a/server/src/App/I18n/locales/fr/messages.php +++ b/server/src/App/I18n/locales/fr/messages.php @@ -2497,15 +2497,21 @@ => "Terminé !", // - // - Bills messages + // - Bills & estimates messages // "Bill" => "Facture", + "Estimate" + => "Devis", + "billTitle" => "Facture en %s, N° %s", + "estimateTitle" + => "Devis en %s", + "onDate" => "Le %s", @@ -2623,6 +2629,9 @@ "billDetailsTitle" => "Détails de la facture N° %s", + "estimateDetailsTitle" + => "Détails du devis", + "ref" => "Réf.", diff --git a/server/src/App/Lib/Domain/EventBill.php b/server/src/App/Lib/Domain/EventBill.php index 95fde072e..3bb4cefe2 100644 --- a/server/src/App/Lib/Domain/EventBill.php +++ b/server/src/App/Lib/Domain/EventBill.php @@ -22,7 +22,7 @@ class EventBill public function __construct(\DateTime $date, array $event, string $billNumber, ?int $userId = null) { - if (empty($event) || empty($event['beneficiaries'] || empty($event['materials']))) { + if (empty($event) || empty($event['beneficiaries']) || empty($event['materials'])) { throw new \InvalidArgumentException( "Cannot create EventBill value-object without complete event's data." ); @@ -62,10 +62,6 @@ public function setDiscountRate(float $rate): self public function getDailyAmount(): float { - if (!$this->materials || count($this->materials) === 0) { - return 0.0; - } - $total = 0.0; foreach ($this->materials as $material) { $total += $material['rental_price'] * $material['pivot']['quantity']; @@ -75,10 +71,6 @@ public function getDailyAmount(): float public function getDiscountableDailyAmount(): float { - if (!$this->materials || count($this->materials) === 0) { - return 0.0; - } - $total = 0.0; foreach ($this->materials as $material) { if (!$material['is_discountable']) { @@ -92,10 +84,6 @@ public function getDiscountableDailyAmount(): float public function getReplacementAmount(): float { - if (!$this->materials || count($this->materials) === 0) { - return 0.0; - } - $total = 0.0; foreach ($this->materials as $material) { $total += $material['replacement_price'] * $material['pivot']['quantity']; diff --git a/server/src/App/Lib/Domain/EventEstimate.php b/server/src/App/Lib/Domain/EventEstimate.php new file mode 100644 index 000000000..e7f250a6b --- /dev/null +++ b/server/src/App/Lib/Domain/EventEstimate.php @@ -0,0 +1,339 @@ +_eventData = $event; + + $this->date = $date; + $this->userId = $userId; + $this->beneficiaryId = $event['beneficiaries'][0]['id']; + $this->eventId = $event['id']; + $this->vatRate = (float)Config::getSettings('companyData')['vatRate']; + $this->materials = $event['materials']; + + $this->_setDaysCount(); + $this->_setDegressiveRate(); + } + + // ------------------------------------------------------ + // - + // - Setters + // - + // ------------------------------------------------------ + + public function setDiscountRate(float $rate): self + { + $this->discountRate = $rate; + return $this; + } + + // ------------------------------------------------------ + // - + // - Getters + // - + // ------------------------------------------------------ + + public function getDailyAmount(): float + { + if (!$this->materials || count($this->materials) === 0) { + return 0.0; + } + + $total = 0.0; + foreach ($this->materials as $material) { + $total += $material['rental_price'] * $material['pivot']['quantity']; + }; + return $total; + } + + public function getDiscountableDailyAmount(): float + { + if (!$this->materials || count($this->materials) === 0) { + return 0.0; + } + + $total = 0.0; + foreach ($this->materials as $material) { + if (!$material['is_discountable']) { + continue; + } + + $total += $material['rental_price'] * $material['pivot']['quantity']; + }; + return $total; + } + + public function getReplacementAmount(): float + { + if (!$this->materials || count($this->materials) === 0) { + return 0.0; + } + + $total = 0.0; + foreach ($this->materials as $material) { + $total += $material['replacement_price'] * $material['pivot']['quantity']; + }; + return $total; + } + + public function getCategoriesTotals(array $categories): array + { + $categoriesTotals = []; + foreach ($this->materials as $material) { + $price = $material['rental_price']; + $isHidden = $material['is_hidden_on_bill']; + if ($isHidden && $price === 0.0) { + continue; + } + + $categoryId = $material['category_id']; + $quantity = $material['pivot']['quantity']; + + if (!isset($categoriesTotals[$categoryId])) { + $categoriesTotals[$categoryId] = [ + 'id' => $categoryId, + 'name' => $this->getCategoryName($categories, $categoryId), + 'quantity' => $quantity, + 'subTotal' => $quantity * $price, + ]; + continue; + } + + $categoriesTotals[$categoryId]['quantity'] += $quantity; + $categoriesTotals[$categoryId]['subTotal'] += $quantity * $price; + } + + return array_values($categoriesTotals); + } + + public function getMaterialBySubCategories(array $categories): array + { + $subCategoriesMaterials = []; + foreach ($this->materials as $material) { + $subCategoryId = $material['sub_category_id'] ?: 0; + + $isHidden = $material['is_hidden_on_bill']; + $price = $material['rental_price']; + if ($isHidden && $price === 0.0) { + continue; + } + + if (!isset($subCategoriesMaterials[$subCategoryId])) { + $subCategoriesMaterials[$subCategoryId] = [ + 'id' => $subCategoryId, + 'name' => $this->getSubCategoryName($categories, $subCategoryId), + 'materials' => [], + ]; + } + + $quantity = $material['pivot']['quantity']; + $replacementPrice = $material['replacement_price']; + + $subCategoriesMaterials[$subCategoryId]['materials'][] = [ + 'reference' => $material['reference'], + 'name' => $material['name'], + 'quantity' => $quantity, + 'rentalPrice' => $price, + 'replacementPrice' => $replacementPrice, + 'total' => $price * $quantity, + 'totalReplacementPrice' => $replacementPrice * $quantity, + ]; + } + + return array_reverse(array_values($subCategoriesMaterials)); + } + + public function getMaterials() + { + $materials = []; + foreach ($this->materials as $material) { + $materials[] = [ + 'id' => $material['id'], + 'name' => $material['name'], + 'reference' => $material['reference'], + 'park_id' => $material['park_id'], + 'category_id' => $material['category_id'], + 'sub_category_id' => $material['sub_category_id'], + 'rental_price' => $material['rental_price'], + 'replacement_price' => $material['replacement_price'], + 'is_hidden_on_bill' => $material['is_hidden_on_bill'], + 'is_discountable' => $material['is_discountable'], + 'quantity' => $material['pivot']['quantity'], + ]; + } + + return $materials; + } + + public function toModelArray(): array + { + $totals = $this->_calcTotals(); + + return [ + 'date' => $this->date->format('Y-m-d H:i:s'), + 'event_id' => $this->eventId, + 'beneficiary_id' => $this->beneficiaryId, + 'materials' => $this->getMaterials(), + 'degressive_rate' => (string)$this->degressiveRate, + 'discount_rate' => (string)$this->discountRate, + 'vat_rate' => (string)$this->vatRate, + 'due_amount' => (string)round($totals['dueAmount'], 2), + 'replacement_amount' => (string)$this->getReplacementAmount(), + 'currency' => Config::getSettings('currency')['iso'], + 'user_id' => $this->userId, + ]; + } + + public function toPdfTemplateArray(array $categories): array + { + $totals = $this->_calcTotals(); + + return [ + 'date' => $this->date, + 'event' => $this->_eventData, + 'dailyAmount' => $totals['dailyAmount'], + 'discountableDailyAmount' => $totals['discountableDailyAmount'], + 'daysCount' => $this->daysCount, + 'degressiveRate' => $this->degressiveRate, + 'discountRate' => $this->discountRate / 100, + 'discountAmount' => $totals['discountAmount'], + 'vatRate' => $this->vatRate / 100, + 'totalDailyExclVat' => round($totals['dailyTotal'], 2), + 'totalDailyInclVat' => round($totals['dailyTotalVat'], 2), + 'totalExclVat' => round($totals['dailyTotal'] * $this->degressiveRate, 2), + 'vatAmount' => round($totals['vatAmount'], 2), + 'totalInclVat' => round($totals['dailyTotalVat'] * $this->degressiveRate, 2), + 'totalReplacement' => $this->getReplacementAmount(), + 'categoriesSubTotals' => $this->getCategoriesTotals($categories), + 'materialBySubCategories' => $this->getMaterialBySubCategories($categories), + 'company' => Config::getSettings('companyData'), + 'locale' => Config::getSettings('defaultLang'), + 'currency' => Config::getSettings('currency')['iso'], + 'currencyName' => Config::getSettings('currency')['name'], + ]; + } + + // ------------------------------------------------------ + // - + // - Internal Methods + // - + // ------------------------------------------------------ + + protected function _setDaysCount(): void + { + $start = new \DateTime($this->_eventData['start_date']); + $end = new \DateTime($this->_eventData['end_date']); + if (!$start || !$end || ($end < $start)) { + throw new \InvalidArgumentException("Wrong event dates."); + } + + $diff = $start->diff($end); + $this->daysCount = (int)$diff->format('%a') + 1; + + if ($this->daysCount <= 0) { + throw new \InvalidArgumentException("Days count of event should be 1 or more."); + } + } + + protected function _setDegressiveRate(): void + { + if (empty($this->daysCount) || $this->daysCount <= 0) { + throw new \InvalidArgumentException("Days count of event should be 1 or more."); + } + + $jsFunction = Config::getSettings('degressiveRateFunction'); + if (empty($jsFunction) || !strpos($jsFunction, 'daysCount')) { + $this->degressiveRate = (float)$this->daysCount; + } + $function = preg_replace('/daysCount/', $this->daysCount, $jsFunction); + + $result = null; + // phpcs:disable + eval(sprintf('$result = %s;', $function)); + // phpcs:enable + $this->degressiveRate = !$result ? 1.0 : $result; + } + + protected function _calcTotals(): array + { + $discountableDailyAmount = $this->getDiscountableDailyAmount(); + $discountAmount = ($discountableDailyAmount * ($this->discountRate / 100)); + + $dailyAmount = $this->getDailyAmount(); + $dailyTotal = $dailyAmount - $discountAmount; + + $vatAmount = ($dailyTotal * ($this->vatRate / 100)); + $dailyTotalVat = $dailyTotal + $vatAmount; + + $dueAmount = $dailyTotal * $this->degressiveRate; + + return compact( + 'discountableDailyAmount', + 'discountAmount', + 'dailyAmount', + 'dailyTotal', + 'vatAmount', + 'dailyTotalVat', + 'dueAmount' + ); + } + + protected function getCategoryName(array $categories, int $categoryId): ?string + { + if (empty($categories)) { + throw new \InvalidArgumentException( + "Missing categories data." + ); + } + + foreach ($categories as $category) { + if ($categoryId === $category['id']) { + return $category['name']; + } + } + return null; + } + + protected function getSubCategoryName(array $categories, int $subCategoryId): string + { + if (empty($categories)) { + throw new \InvalidArgumentException( + "Missing categories data." + ); + } + + foreach ($categories as $category) { + foreach ($category['sub_categories'] as $subCategory) { + if ($subCategoryId === $subCategory['id']) { + return $subCategory['name']; + } + } + } + return '---'; + } +} diff --git a/server/src/App/Models/Estimate.php b/server/src/App/Models/Estimate.php new file mode 100644 index 000000000..fec699419 --- /dev/null +++ b/server/src/App/Models/Estimate.php @@ -0,0 +1,200 @@ +pdfTemplate = 'estimate-default'; + + $this->validation = [ + 'date' => V::notEmpty()->date(), + 'event_id' => V::notEmpty()->numeric(), + 'beneficiary_id' => V::notEmpty()->numeric(), + 'materials' => V::notEmpty(), + 'degressive_rate' => V::notEmpty()->floatVal()->between(0.0, 99.99, true), + 'discount_rate' => V::optional(V::floatVal()->between(0.0, 99.9999, true)), + 'vat_rate' => V::optional(V::floatVal()->between(0.0, 99.99, true)), + 'due_amount' => V::notEmpty()->floatVal()->between(0.0, 999999.99, true), + 'replacement_amount' => V::notEmpty()->floatVal()->between(0.0, 999999.99, true), + 'currency' => V::notEmpty()->length(3), + 'user_id' => V::optional(V::numeric()), + ]; + } + + // ------------------------------------------------------ + // - + // - Relations + // - + // ------------------------------------------------------ + + public function Event() + { + return $this->belongsTo('Robert2\API\Models\Event') + ->select(['events.id', 'title', 'location', 'start_date', 'end_date']); + } + + public function Beneficiary() + { + return $this->belongsTo('Robert2\API\Models\Person') + ->select(['persons.id', 'first_name', 'last_name', 'street', 'postal_code', 'locality']); + } + + public function User() + { + return $this->belongsTo('Robert2\API\Models\User') + ->select(['users.id', 'pseudo', 'email', 'group_id']); + } + + // ------------------------------------------------------ + // - + // - Mutators + // - + // ------------------------------------------------------ + + protected $casts = [ + 'date' => 'string', + 'event_id' => 'integer', + 'beneficiary_id' => 'integer', + 'materials' => 'array', + 'degressive_rate' => 'float', + 'discount_rate' => 'float', + 'vat_rate' => 'float', + 'due_amount' => 'float', + 'replacement_amount' => 'float', + 'currency' => 'string', + 'user_id' => 'integer', + ]; + + // ------------------------------------------------------ + // - + // - Setters + // - + // ------------------------------------------------------ + + protected $fillable = [ + 'date', + 'event_id', + 'beneficiary_id', + 'materials', + 'degressive_rate', + 'discount_rate', + 'vat_rate', + 'due_amount', + 'replacement_amount', + 'currency', + 'user_id', + ]; + + public function createFromEvent(int $eventId, int $userId, float $discountRate = 0.0): Model + { + $Event = new Event(); + $estimateEvent = $Event + ->with('Beneficiaries') + ->with('Materials') + ->find($eventId); + + if (!$estimateEvent) { + throw new Errors\NotFoundException("Event not found."); + } + + $date = new \DateTime(); + $eventData = $estimateEvent->toArray(); + + if (!$eventData['is_billable']) { + throw new \InvalidArgumentException("Event is not billable."); + } + + $EventEstimate = new EventEstimate($date, $eventData, $userId); + $EventEstimate->setDiscountRate($discountRate); + + $newEstimateData = $EventEstimate->toModelArray(); + + $newEstimate = new Estimate(); + $newEstimate->fill($newEstimateData)->save(); + + return $newEstimate; + } + + public function getPdfName(int $id): string + { + $model = $this->withTrashed()->find($id); + if (!$model) { + throw new NotFoundException(sprintf('Record %d not found.', $id)); + } + + $company = Config::getSettings('companyData'); + $date = new \DateTime($model->date); + + $i18n = new I18n(Config::getSettings('defaultLang')); + $fileName = sprintf( + '%s-%s-%s-%s.pdf', + $i18n->translate('Estimate'), + slugify($company['name']), + $date->format('Ymd-Hi'), + slugify($model->Beneficiary->full_name) + ); + if (isTestMode()) { + $fileName = sprintf('TEST-%s', $fileName); + } + + return $fileName; + } + + public function getPdfContent(int $id): string + { + if (!$this->exists($id)) { + throw new Errors\NotFoundException; + } + + $estimate = self::find($id); + + $date = new \DateTime($estimate->date); + + $Event = new Event(); + $eventData = $Event + ->with('Beneficiaries') + ->with('Materials') + ->find($estimate->event_id) + ->toArray(); + + $EventEstimate = new EventEstimate($date, $eventData, $estimate->user_id); + $EventEstimate->setDiscountRate($estimate->discount_rate); + + $categories = (new Category())->getAll()->get()->toArray(); + + $estimatePdf = $this->_getPdfAsString($EventEstimate->toPdfTemplateArray($categories)); + if (!$estimatePdf) { + $lastError = error_get_last(); + throw new \RuntimeException(sprintf( + "Unable to create PDF file. Reason: %s", + $lastError['message'] + )); + } + + return $estimatePdf; + } +} diff --git a/server/src/App/Models/Event.php b/server/src/App/Models/Event.php index 68b7754d8..352f0a567 100644 --- a/server/src/App/Models/Event.php +++ b/server/src/App/Models/Event.php @@ -136,6 +136,13 @@ public function Bills() ->orderBy('date', 'desc'); } + public function Estimates() + { + return $this->hasMany('Robert2\API\Models\Estimate') + ->select(['estimates.id', 'date', 'discount_rate', 'due_amount']) + ->orderBy('date', 'desc'); + } + // —————————————————————————————————————————————————————— // — // — Mutators diff --git a/server/src/database/migrations/20210420090840_create_estimates.php b/server/src/database/migrations/20210420090840_create_estimates.php new file mode 100644 index 000000000..411123fec --- /dev/null +++ b/server/src/database/migrations/20210420090840_create_estimates.php @@ -0,0 +1,49 @@ +table('estimates'); + $table + ->addColumn('date', 'datetime') + ->addColumn('event_id', 'integer') + ->addColumn('beneficiary_id', 'integer') + ->addColumn('materials', 'json') + ->addColumn('degressive_rate', 'decimal', ['precision' => 4, 'scale' => 2]) + ->addColumn('discount_rate', 'decimal', ['null' => true, 'precision' => 6, 'scale' => 4]) + ->addColumn('vat_rate', 'decimal', ['null' => true, 'precision' => 4, 'scale' => 2]) + ->addColumn('due_amount', 'decimal', ['precision' => 8, 'scale' => 2]) + ->addColumn('replacement_amount', 'decimal', ['precision' => 8, 'scale' => 2]) + ->addColumn('currency', 'string', ['length' => 3]) + ->addColumn('user_id', 'integer', ['null' => true]) + ->addColumn('created_at', 'datetime', ['null' => true]) + ->addColumn('updated_at', 'datetime', ['null' => true]) + ->addColumn('deleted_at', 'datetime', ['null' => true]) + ->addIndex(['event_id']) + ->addIndex(['beneficiary_id']) + ->addIndex(['user_id']) + ->addForeignKey('event_id', 'events', 'id', [ + 'delete' => 'CASCADE', + 'update' => 'NO_ACTION', + 'constraint' => 'fk_estimate_event' + ]) + ->addForeignKey('beneficiary_id', 'persons', 'id', [ + 'delete' => 'CASCADE', + 'update' => 'NO_ACTION', + 'constraint' => 'fk_estimate_beneficiary' + ]) + ->addForeignKey('user_id', 'users', 'id', [ + 'delete' => 'SET_NULL', + 'update' => 'NO_ACTION', + 'constraint' => 'fk_estimate_user' + ]) + ->create(); + } + + public function down() + { + $this->table('estimates')->drop()->save(); + } +} diff --git a/server/src/views/pdf/estimate-default.twig b/server/src/views/pdf/estimate-default.twig new file mode 100644 index 000000000..9de0a7f69 --- /dev/null +++ b/server/src/views/pdf/estimate-default.twig @@ -0,0 +1,269 @@ +{% extends "base.twig" %} + +{% block title %}{{ translate("Estimate") }}{% endblock %} + +{% block content %} + + + + + +
+

+ {{ company['name'] }}
+ {{ company['street'] }}
+ {{ company['zipCode'] }} {{ company['locality'] }}
+ {{ company['country'] }} +

+

+ {% for legalNumber in company['legalNumbers'] %} + {{ legalNumber['name'] }}: {{ legalNumber['value'] }}
+ {% endfor %} +

+

+ {{ translate('phone', [company['phone']]) }}
+ {{ translate('email', [company['email']]) }} +

+ {% if company['vatNumber'] %} +

+ {{ translate('vatNumber', [company['vatNumber']]) }} +

+ {% endif %} +
+

{{ translate('estimateTitle', [currencyName]) }}

+ + + + + +
{{ translate('onDate', date|format_date('short', locale=locale)) }}{{ translate('page', 1) }}
+

+ {{ translate('beneficiary') }} +

+

+ {% if event['beneficiaries'][0]['company'] %} + {{ event['beneficiaries'][0]['company']['legal_name'] }}
+ {% else %} + {{ event['beneficiaries'][0]['full_name'] }}
+ {% endif %} + {{ event['beneficiaries'][0]['street'] }}
+ {{ event['beneficiaries'][0]['postal_code'] }} {{ event['beneficiaries'][0]['locality'] }} +

+
+ +

+

{{ translate('event', [event['title']]) }}

+

+ {% if event['start_date'] == event['end_date'] %} + {{ translate('onDate', [event['start_date']|format_date('full', locale=locale)]) }}, + {% else %} + {{ translate('fromToDates', [event['start_date']|format_date('full', locale=locale), event['end_date']|format_date('full', locale=locale)]) }}, + {% endif %} + {% if event['location'] %} + {{ translate('inLocation', [event['location']]) }} + {% endif %} +

+ + + + + + + + + + {% for category in categoriesSubTotals %} + + + + + + {% endfor %} + +
{{ translate('category') }}{{ translate('quantity') }}{{ translate(vatRate > 0 ? 'totalDayExclVat' : 'totalDay') }}
{{ category['name'] }}{{ plural('itemsCount', category['quantity']) }} + {{ category['subTotal']|format_currency(currency, formatCurrencyOptions, locale) }} +
+

{{ translate('billNoteDetailNextPage') }}

+
+ +
+ + + + + + + + + + + + + + + + + {% if vatRate > 0 %} + + + {% endif %} + {% if vatRate == 0 %} + + + {% endif %} + + {% if vatRate > 0 %} + + + + + + + + + {% endif %} + +
{{ translate(vatRate > 0 ? 'dailyAmountExclVat' : 'dailyAmount') }}{{ translate('discountRate') }}{{ translate('totalDay') }}{{ translate('degressiveRate') }}{{ translate('totals') }}
+
+ {{ dailyAmount|format_currency(currency, formatCurrencyOptions, locale) }} +
+
+ {{ translate('discountableTotal') }}
+ {{ discountableDailyAmount|format_currency(currency, formatCurrencyOptions, locale) }} +
+
+
+ {% if discountRate > 0 %} + {{ discountRate|format_percent_number({fraction_digit: 4}) }}

+ - {{ discountAmount|format_currency(currency, formatCurrencyOptions, locale) }} + {% else %} + {{ translate('noDiscount') }} + {% endif %} +
+
+ {% if vatRate > 0 %} +
+ {{ translate('exclVat') }}
+ {{ totalDailyExclVat|format_currency(currency, formatCurrencyOptions, locale) }} +
+
+ {{ translate('vat') }} ({{ vatRate|format_percent_number({fraction_digit: 1}) }})
+ {{ vatAmount|format_currency(currency, formatCurrencyOptions, locale) }} +
+ {% endif %} +
+ {% if vatRate > 0 %}{{ translate('inclVat') }}
{% endif %} + {{ totalDailyInclVat|format_currency(currency, formatCurrencyOptions, locale) }} +
+
+
+ × {{ degressiveRate|format_number }}

+ + ({{ plural('numberOfDays', daysCount) }}) + +
+
+
{{ translate('totalExclVat') }}
+
+
+ {{ totalExclVat|format_currency(currency, formatCurrencyOptions, locale) }} +
+
+
+ {{ translate('totalDue') }} +
+
+ {{ translate('vatNotApplicable') }} +
+
+
+ {{ totalInclVat|format_currency(currency, formatCurrencyOptions, locale) }} +
+
+
{{ translate('totalInclVat') }}
+
+
+ {{ totalInclVat|format_currency(currency, formatCurrencyOptions, locale) }} +
+
+
+ {{ translate('totalDue') }} +
+
+
+ {{ totalInclVat|format_currency(currency, formatCurrencyOptions, locale) }} +
+
+
+ +
+ + + + + +
+

+ {{ company['name'] }}
+ {{ company['street'] }}
+ {{ company['zipCode'] }} {{ company['locality'] }}
+ {{ company['country'] }} +

+

+ {{ translate('phone', [company['phone']]) }}
+ {{ translate('email', [company['email']]) }} +

+
+

{{ translate('estimateDetailsTitle') }}

+ + + + + +
{{ translate('onDate', date|format_date('short', locale=locale)) }}{{ translate('page', 2) }}
+
+
+ + + + + + + + + + + + + + {% for subCategory in materialBySubCategories %} + + + + + + + + + {% for material in subCategory['materials'] %} + + + + + + + + + {% endfor %} + {% endfor %} + +
{{ translate('ref') }}{{ translate('designation') }}{{ translate('qty') }}{{ translate(vatRate > 0 ? 'unitPriceExclVat' : 'unitPrice') }}{{ translate('replacementValue') }}{{ translate(vatRate > 0 ? 'totalExclVat' : 'total') }}
{{ subCategory['name'] }}
{{ material['reference'] }}{{ material['name'] }} + {{ material['quantity'] }} + + {{ material['rentalPrice']|format_currency(currency, formatCurrencyOptions, locale) }} + + {{ material['replacementPrice']|format_currency(currency, formatCurrencyOptions, locale) }} + + {{ material['total']|format_currency(currency, formatCurrencyOptions, locale) }} +
+{% endblock %} \ No newline at end of file diff --git a/server/tests/Fixtures/seed/estimates.json b/server/tests/Fixtures/seed/estimates.json new file mode 100644 index 000000000..21b32278a --- /dev/null +++ b/server/tests/Fixtures/seed/estimates.json @@ -0,0 +1,47 @@ +[ + { + "date": "2021-01-30 14:00:00", + "event_id": 1, + "beneficiary_id": 3, + "materials": [ + { + "id": 1, + "name": "Console Yamaha CL3", + "reference": "PM5D", + "park_id": 1, + "category_id": 1, + "sub_category_id": 1, + "rental_price": 300, + "stock_quantity": 5, + "out_of_order_quantity": 1, + "replacement_price": 19400, + "is_hidden_on_bill": false, + "is_discountable": false + }, + { + "id": 2, + "name": "Processeur DBX PA2", + "reference": "DBXPA2", + "park_id": 1, + "category_id": 1, + "sub_category_id": 2, + "rental_price": 25.5, + "stock_quantity": 2, + "out_of_order_quantity": null, + "replacement_price": 349.9, + "is_hidden_on_bill": false, + "is_discountable": true + } + ], + "degressive_rate": 1.75, + "discount_rate": 50, + "vat_rate": 0.2, + "due_amount": 325.5, + "replacement_amount": 325.5, + "currency": "EUR", + "user_id": 1, + "created_at": "2021-01-30 14:00:00", + "updated_at": "2021-01-30 14:00:00", + "deleted_at": null + } +] diff --git a/server/tests/endpoints/EstimatesTest.php b/server/tests/endpoints/EstimatesTest.php new file mode 100644 index 000000000..b50005d15 --- /dev/null +++ b/server/tests/endpoints/EstimatesTest.php @@ -0,0 +1,182 @@ +client->get('/api/estimates/1'); + $this->assertStatusCode(SUCCESS_OK); + $this->assertResponseData([ + 'id' => 1, + 'date' => '2021-01-30 14:00:00', + 'event_id' => 1, + 'beneficiary_id' => 3, + 'materials' => [ + [ + 'id' => 1, + 'name' => "Console Yamaha CL3", + 'reference' => "PM5D", + 'park_id' => 1, + 'category_id' => 1, + 'sub_category_id' => 1, + 'rental_price' => 300.0, + 'stock_quantity' => 5, + 'out_of_order_quantity' => 1, + 'replacement_price' => 19400.0, + 'is_hidden_on_bill' => false, + 'is_discountable' => false, + ], + [ + 'id' => 2, + 'name' => "Processeur DBX PA2", + 'reference' => "DBXPA2", + 'park_id' => 1, + 'category_id' => 1, + 'sub_category_id' => 2, + 'rental_price' => 25.5, + 'stock_quantity' => 2, + 'out_of_order_quantity' => null, + 'replacement_price' => 349.9, + 'is_hidden_on_bill' => false, + 'is_discountable' => true, + ], + ], + 'degressive_rate' => 1.75, + 'discount_rate' => 50, + 'vat_rate' => 0.2, + 'due_amount' => 325.5, + 'replacement_amount' => 325.5, + 'currency' => 'EUR', + 'user_id' => 1, + 'created_at' => '2021-01-30 14:00:00', + 'updated_at' => '2021-01-30 14:00:00', + 'deleted_at' => null, + ]); + } + + public function testCreateEstimate() + { + $this->client->post('/api/events/2/estimate'); + $this->assertStatusCode(SUCCESS_CREATED); + $this->assertResponseData([ + 'id' => 2, + 'date' => 'fakedTestContent', + 'event_id' => 2, + 'beneficiary_id' => 3, + 'materials' => [ + [ + 'id' => 2, + 'name' => 'Processeur DBX PA2', + 'reference' => 'DBXPA2', + 'park_id' => 1, + 'category_id' => 1, + 'sub_category_id' => 2, + 'rental_price' => 25.5, + 'replacement_price' => 349.9, + 'is_hidden_on_bill' => false, + 'is_discountable' => true, + 'quantity' => 2, + ], + [ + 'id' => 1, + 'name' => 'Console Yamaha CL3', + 'reference' => 'CL3', + 'park_id' => 1, + 'category_id' => 1, + 'sub_category_id' => 1, + 'rental_price' => 300, + 'replacement_price' => 19400, + 'is_hidden_on_bill' => false, + 'is_discountable' => false, + 'quantity' => 3 + ], + ], + 'degressive_rate' => 1.75, + 'discount_rate' => 0, + 'vat_rate' => 20, + 'due_amount' => 1664.25, + 'replacement_amount' => 58899.8, + 'currency' => 'EUR', + 'user_id' => 1, + 'created_at' => 'fakedTestContent', + 'updated_at' => 'fakedTestContent', + ], ['date', 'created_at', 'updated_at']); + } + + public function testCreateEstimateWithDiscount() + { + $this->client->post('/api/events/2/estimate', ['discountRate' => 50.0]); + $this->assertStatusCode(SUCCESS_CREATED); + $this->assertResponseData([ + 'id' => 2, + 'date' => 'fakedTestContent', + 'event_id' => 2, + 'beneficiary_id' => 3, + 'materials' => [ + [ + 'id' => 2, + 'name' => 'Processeur DBX PA2', + 'reference' => 'DBXPA2', + 'park_id' => 1, + 'category_id' => 1, + 'sub_category_id' => 2, + 'rental_price' => 25.5, + 'replacement_price' => 349.9, + 'is_hidden_on_bill' => false, + 'is_discountable' => true, + 'quantity' => 2, + ], + [ + 'id' => 1, + 'name' => 'Console Yamaha CL3', + 'reference' => 'CL3', + 'park_id' => 1, + 'category_id' => 1, + 'sub_category_id' => 1, + 'rental_price' => 300, + 'replacement_price' => 19400, + 'is_hidden_on_bill' => false, + 'is_discountable' => false, + 'quantity' => 3 + ], + ], + 'degressive_rate' => 1.75, + 'discount_rate' => 50, + 'vat_rate' => 20, + 'due_amount' => 1619.63, + 'replacement_amount' => 58899.8, + 'currency' => 'EUR', + 'user_id' => 1, + 'created_at' => 'fakedTestContent', + 'updated_at' => 'fakedTestContent', + ], ['date', 'created_at', 'updated_at']); + } + + public function testDeleteAndDestroyEstimate() + { + // - First call: sets `deleted_at` not null + $this->client->delete('/api/estimates/1'); + $this->assertStatusCode(SUCCESS_OK); + $response = $this->_getResponseAsArray(); + $this->assertNotEmpty($response['deleted_at']); + + // - Second call: actually DESTROY record from DB + $this->client->delete('/api/estimates/1'); + $this->assertStatusCode(SUCCESS_OK); + $this->assertResponseData(['destroyed' => true]); + } + + public function testDownloadPdf() + { + // - Estimate does not exists + $this->client->get('/estimates/999/pdf'); + $this->assertStatusCode(404); + + // - Download bill n°1 PDF file + $this->client->get('/estimates/1/pdf'); + $this->assertStatusCode(200); + $responseStream = $this->client->response->getBody(); + $this->assertTrue($responseStream->isReadable()); + } +} diff --git a/server/tests/endpoints/EventsTest.php b/server/tests/endpoints/EventsTest.php index a0213ada1..1b5454216 100644 --- a/server/tests/endpoints/EventsTest.php +++ b/server/tests/endpoints/EventsTest.php @@ -381,6 +381,14 @@ public function testGetOneEvent() 'due_amount' => 325.5, ], ], + 'estimates' => [ + [ + 'id' => 1, + 'date' => '2021-01-30 14:00:00', + 'discount_rate' => 50.0, + 'due_amount' => 325.5, + ], + ], ]); } @@ -694,6 +702,14 @@ public function testUpdateEvent() 'due_amount' => 325.5, ], ], + 'estimates' => [ + [ + 'id' => 1, + 'date' => '2021-01-30 14:00:00', + 'discount_rate' => 50.0, + 'due_amount' => 325.5, + ], + ], 'created_at' => null, 'updated_at' => 'fakedTestContent', 'deleted_at' => null, diff --git a/server/tests/libs/domain/EventBillTest.php b/server/tests/libs/domain/EventBillTest.php new file mode 100644 index 000000000..38724d953 --- /dev/null +++ b/server/tests/libs/domain/EventBillTest.php @@ -0,0 +1,457 @@ +fail(sprintf("Unable to reset fixtures: %s", $e->getMessage())); + } + + try { + $this->_date = new \DateTime(); + + $event = (new Event()) + ->with('Beneficiaries') + ->with('Materials') + ->find(1); + if (!$event) { + $this->fail("Unable to find event's data"); + } + $this->_eventData = $event->toArray(); + + $this->_number = sprintf('%s-00001', $this->_date->format('Y')); + + $this->_categories = (new Category())->getAll()->get()->toArray(); + + $this->EventBill = new EventBill($this->_date, $this->_eventData, $this->_number, 1); + } catch (\Exception $e) { + $this->fail($e->getMessage()); + } + } + + // ------------------------------------------------------ + // - + // - Instanciation tests methods + // - + // ------------------------------------------------------ + + public function testEmptyEvent() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionCode(0); + $this->expectExceptionMessage("Cannot create EventBill value-object without complete event's data."); + $empty = new EventBill($this->_date, [], $this->_number); + } + + public function testNoBeneficiary() + { + $event = [ + 'id' => 99, + 'title' => "fake event", + 'start_date' => "2021-04-21 00:00:00", + 'end_date' => "2021-04-21 23:59:59", + 'beneficiaries' => [], + 'materials' => [ + ['id' => 4, 'name' => 'Showtec SDS-6', 'reference' => 'SDS-6-01'], + ], + ]; + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionCode(0); + $this->expectExceptionMessage("Cannot create EventBill value-object without complete event's data."); + $noBeneficiaries = new EventBill($this->_date, $event, $this->_number); + } + + public function testNoMaterials() + { + $event = [ + 'id' => 99, + 'title' => "fake event", + 'start_date' => "2021-04-21 00:00:00", + 'end_date' => "2021-04-21 23:59:59", + 'beneficiaries' => [ + ['id' => 3, 'first_name' => 'Client', 'last_name' => 'Benef'], + ], + 'materials' => [], + ]; + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionCode(0); + $this->expectExceptionMessage("Cannot create EventBill value-object without complete event's data."); + $noMaterials = new EventBill($this->_date, $event, $this->_number); + } + + // ------------------------------------------------------ + // - + // - Setters tests methods + // - + // ------------------------------------------------------ + + public function testSetDiscountRate() + { + $this->EventBill->setDiscountRate(33.33); + $this->assertEquals(33.33, $this->EventBill->discountRate); + } + + public function testCreateNumber() + { + $date = new \DateTime(); + + $result = EventBill::createNumber($date, 1); + $this->assertEquals(sprintf('%s-00002', date('Y')), $result); + + $result = EventBill::createNumber($date, 155); + $this->assertEquals(sprintf('%s-00156', date('Y')), $result); + } + + // ------------------------------------------------------ + // - + // - Getters tests methods + // - + // ------------------------------------------------------ + + public function testGetDailyAmount() + { + $this->assertEquals(341.45, $this->EventBill->getDailyAmount()); + } + + public function testGetDiscountableDailyAmount() + { + $this->assertEquals(41.45, $this->EventBill->getDiscountableDailyAmount()); + } + + public function testGetReplacementAmount() + { + $this->assertEquals(19808.9, $this->EventBill->getReplacementAmount()); + } + + public function testGetCategoriesTotals() + { + $result = $this->EventBill->getCategoriesTotals($this->_categories); + $expected = [ + ['id' => 2, 'name' => "light", 'quantity' => 1, 'subTotal' => 15.95], + ['id' => 1, 'name' => "sound", 'quantity' => 2, 'subTotal' => 325.5], + ]; + $this->assertEquals($expected, $result); + } + + public function testGetMaterialBySubCategories() + { + $result = $this->EventBill->getMaterialBySubCategories($this->_categories); + $expected = [ + [ + 'id' => 1, + 'name' => 'mixers', + 'materials' => [ + [ + 'reference' => 'CL3', + 'name' => 'Console Yamaha CL3', + 'quantity' => 1, + 'rentalPrice' => 300.0, + 'replacementPrice' => 19400.0, + 'total' => 300.0, + 'totalReplacementPrice' => 19400.0, + ], + ], + ], + [ + 'id' => 2, + 'name' => 'processors', + 'materials' => [ + [ + 'reference' => 'DBXPA2', + 'name' => 'Processeur DBX PA2', + 'quantity' => 1, + 'rentalPrice' => 25.5, + 'replacementPrice' => 349.9, + 'total' => 25.5, + 'totalReplacementPrice' => 349.9, + ], + ], + ], + [ + 'id' => 4, + 'name' => 'dimmers', + 'materials' => [ + [ + 'reference' => 'SDS-6-01', + 'name' => 'Showtec SDS-6', + 'quantity' => 1, + 'rentalPrice' => 15.95, + 'replacementPrice' => 59.0, + 'total' => 15.95, + 'totalReplacementPrice' => 59.0, + ], + ], + ], + ]; + $this->assertEquals($expected, $result); + } + + public function testGetMaterials() + { + $result = $this->EventBill->getMaterials(); + $expected = [ + [ + 'id' => 4, + 'name' => 'Showtec SDS-6', + 'reference' => 'SDS-6-01', + 'park_id' => 1, + 'category_id' => 2, + 'sub_category_id' => 4, + 'rental_price' => 15.95, + 'replacement_price' => 59.0, + 'is_hidden_on_bill' => false, + 'is_discountable' => true, + 'quantity' => 1, + ], + [ + 'id' => 2, + 'name' => 'Processeur DBX PA2', + 'reference' => 'DBXPA2', + 'park_id' => 1, + 'category_id' => 1, + 'sub_category_id' => 2, + 'rental_price' => 25.5, + 'replacement_price' => 349.9, + 'is_hidden_on_bill' => false, + 'is_discountable' => true, + 'quantity' => 1, + ], + [ + 'id' => 1, + 'name' => 'Console Yamaha CL3', + 'reference' => 'CL3', + 'park_id' => 1, + 'category_id' => 1, + 'sub_category_id' => 1, + 'rental_price' => 300.0, + 'replacement_price' => 19400.0, + 'is_hidden_on_bill' => false, + 'is_discountable' => false, + 'quantity' => 1, + ], + ]; + $this->assertEquals($expected, $result); + } + + public function testToModelArray() + { + $result = $this->EventBill->toModelArray(); + $expected = [ + 'number' => $this->_number, + 'date' => $this->_date->format('Y-m-d H:i:s'), + 'event_id' => 1, + 'beneficiary_id' => 3, + 'materials' => [ + [ + 'id' => 4, + 'name' => 'Showtec SDS-6', + 'reference' => 'SDS-6-01', + 'park_id' => 1, + 'category_id' => 2, + 'sub_category_id' => 4, + 'rental_price' => 15.95, + 'replacement_price' => 59.0, + 'is_hidden_on_bill' => false, + 'is_discountable' => true, + 'quantity' => 1 + ], + [ + 'id' => 2, + 'name' => 'Processeur DBX PA2', + 'reference' => 'DBXPA2', + 'park_id' => 1, + 'category_id' => 1, + 'sub_category_id' => 2, + 'rental_price' => 25.5, + 'replacement_price' => 349.9, + 'is_hidden_on_bill' => false, + 'is_discountable' => true, + 'quantity' => 1, + ], + [ + 'id' => 1, + 'name' => 'Console Yamaha CL3', + 'reference' => 'CL3', + 'park_id' => 1, + 'category_id' => 1, + 'sub_category_id' => 1, + 'rental_price' => 300.0, + 'replacement_price' => 19400.0, + 'is_hidden_on_bill' => false, + 'is_discountable' => false, + 'quantity' => 1, + ], + ], + 'degressive_rate' => 1.75, + 'discount_rate' => 0.0, + 'vat_rate' => 20.0, + 'due_amount' => 597.54, + 'replacement_amount' => 19808.9, + 'currency' => Config::getSettings('currency')['iso'], + 'user_id' => 1, + ]; + $this->assertEquals($expected, $result); + } + + public function testToModelArrayWithDiscount() + { + $this->EventBill->setDiscountRate(33.33); + $result = $this->EventBill->toModelArray(); + $expected = [ + 'number' => $this->_number, + 'date' => $this->_date->format('Y-m-d H:i:s'), + 'event_id' => 1, + 'beneficiary_id' => 3, + 'materials' => [ + [ + 'id' => 4, + 'name' => 'Showtec SDS-6', + 'reference' => 'SDS-6-01', + 'park_id' => 1, + 'category_id' => 2, + 'sub_category_id' => 4, + 'rental_price' => 15.95, + 'replacement_price' => 59.0, + 'is_hidden_on_bill' => false, + 'is_discountable' => true, + 'quantity' => 1 + ], + [ + 'id' => 2, + 'name' => 'Processeur DBX PA2', + 'reference' => 'DBXPA2', + 'park_id' => 1, + 'category_id' => 1, + 'sub_category_id' => 2, + 'rental_price' => 25.5, + 'replacement_price' => 349.9, + 'is_hidden_on_bill' => false, + 'is_discountable' => true, + 'quantity' => 1, + ], + [ + 'id' => 1, + 'name' => 'Console Yamaha CL3', + 'reference' => 'CL3', + 'park_id' => 1, + 'category_id' => 1, + 'sub_category_id' => 1, + 'rental_price' => 300.0, + 'replacement_price' => 19400.0, + 'is_hidden_on_bill' => false, + 'is_discountable' => false, + 'quantity' => 1, + ], + ], + 'degressive_rate' => 1.75, + 'discount_rate' => 33.33, + 'vat_rate' => 20.0, + 'due_amount' => 573.36, + 'replacement_amount' => 19808.9, + 'currency' => Config::getSettings('currency')['iso'], + 'user_id' => 1, + ]; + $this->assertEquals($expected, $result); + } + + public function testToPdfTemplateArray() + { + $result = $this->EventBill->toPdfTemplateArray($this->_categories); + $expected = [ + 'number' => $this->_number, + 'date' => $this->_date, + 'event' => $this->_eventData, + 'dailyAmount' => 341.45, + 'discountableDailyAmount' => 41.45, + 'daysCount' => 2, + 'degressiveRate' => 1.75, + 'discountRate' => 0.0, + 'discountAmount' => 0.0, + 'vatRate' => 0.2, + 'vatAmount' => 68.29, + 'totalDailyExclVat' => 341.45, + 'totalDailyInclVat' => 409.74, + 'totalExclVat' => 597.54, + 'totalInclVat' => 717.05, + 'totalReplacement' => 19808.9, + 'categoriesSubTotals' => [ + ['id' => 2, 'name' => "light", 'quantity' => 1, 'subTotal' => 15.95], + ['id' => 1, 'name' => "sound", 'quantity' => 2, 'subTotal' => 325.5], + ], + 'materialBySubCategories' => [ + [ + 'id' => 1, + 'name' => "mixers", + 'materials' => [ + [ + 'reference' => 'CL3', + 'name' => 'Console Yamaha CL3', + 'quantity' => 1, + 'rentalPrice' => 300.0, + 'replacementPrice' => 19400.0, + 'total' => 300.0, + 'totalReplacementPrice' => 19400.0, + ], + ], + ], + [ + 'id' => 2, + 'name' => "processors", + 'materials' => [ + [ + 'reference' => 'DBXPA2', + 'name' => 'Processeur DBX PA2', + 'quantity' => 1, + 'rentalPrice' => 25.5, + 'replacementPrice' => 349.9, + 'total' => 25.5, + 'totalReplacementPrice' => 349.9, + ], + ], + ], + [ + 'id' => 4, + 'name' => "dimmers", + 'materials' => [ + [ + 'reference' => 'SDS-6-01', + 'name' => 'Showtec SDS-6', + 'quantity' => 1, + 'rentalPrice' => 15.95, + 'replacementPrice' => 59.0, + 'total' => 15.95, + 'totalReplacementPrice' => 59.0, + ], + ], + ], + ], + 'company' => Config::getSettings('companyData'), + 'locale' => Config::getSettings('defaultLang'), + 'currency' => Config::getSettings('currency')['iso'], + 'currencyName' => Config::getSettings('currency')['name'], + ]; + $this->assertEquals($expected, $result); + } +} diff --git a/server/tests/libs/domain/EventEstimateTest.php b/server/tests/libs/domain/EventEstimateTest.php new file mode 100644 index 000000000..7d58cc709 --- /dev/null +++ b/server/tests/libs/domain/EventEstimateTest.php @@ -0,0 +1,440 @@ +fail(sprintf("Unable to reset fixtures: %s", $e->getMessage())); + } + + try { + $this->_date = new \DateTime(); + + $event = (new Event()) + ->with('Beneficiaries') + ->with('Materials') + ->find(1); + if (!$event) { + $this->fail("Unable to find event's data"); + } + $this->_eventData = $event->toArray(); + + $this->_categories = (new Category())->getAll()->get()->toArray(); + + $this->EventEstimate = new EventEstimate($this->_date, $this->_eventData, 1); + } catch (\Exception $e) { + $this->fail($e->getMessage()); + } + } + + // ------------------------------------------------------ + // - + // - Instanciation tests methods + // - + // ------------------------------------------------------ + + public function testEmptyEvent() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionCode(0); + $this->expectExceptionMessage("Cannot create EventEstimate value-object without complete event's data."); + $empty = new EventEstimate($this->_date, []); + } + + public function testNoBeneficiary() + { + $event = [ + 'id' => 99, + 'title' => "fake event", + 'start_date' => "2021-04-21 00:00:00", + 'end_date' => "2021-04-21 23:59:59", + 'beneficiaries' => [], + 'materials' => [ + ['id' => 4, 'name' => 'Showtec SDS-6', 'reference' => 'SDS-6-01'], + ], + ]; + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionCode(0); + $this->expectExceptionMessage("Cannot create EventEstimate value-object without complete event's data."); + $noBeneficiaries = new EventEstimate($this->_date, $event); + } + + public function testNoMaterials() + { + $event = [ + 'id' => 99, + 'title' => "fake event", + 'start_date' => "2021-04-21 00:00:00", + 'end_date' => "2021-04-21 23:59:59", + 'beneficiaries' => [ + ['id' => 3, 'first_name' => 'Client', 'last_name' => 'Benef'], + ], + 'materials' => [], + ]; + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionCode(0); + $this->expectExceptionMessage("Cannot create EventEstimate value-object without complete event's data."); + $noMaterials = new EventEstimate($this->_date, $event); + } + + // ------------------------------------------------------ + // - + // - Setters tests methods + // - + // ------------------------------------------------------ + + public function testSetDiscountRate() + { + $this->EventEstimate->setDiscountRate(33.33); + $this->assertEquals(33.33, $this->EventEstimate->discountRate); + } + + // ------------------------------------------------------ + // - + // - Getters tests methods + // - + // ------------------------------------------------------ + + public function testGetDailyAmount() + { + $this->assertEquals(341.45, $this->EventEstimate->getDailyAmount()); + } + + public function testGetDiscountableDailyAmount() + { + $this->assertEquals(41.45, $this->EventEstimate->getDiscountableDailyAmount()); + } + + public function testGetReplacementAmount() + { + $this->assertEquals(19808.9, $this->EventEstimate->getReplacementAmount()); + } + + public function testGetCategoriesTotals() + { + $result = $this->EventEstimate->getCategoriesTotals($this->_categories); + $expected = [ + ['id' => 2, 'name' => "light", 'quantity' => 1, 'subTotal' => 15.95], + ['id' => 1, 'name' => "sound", 'quantity' => 2, 'subTotal' => 325.5], + ]; + $this->assertEquals($expected, $result); + } + + public function testGetMaterialBySubCategories() + { + $result = $this->EventEstimate->getMaterialBySubCategories($this->_categories); + $expected = [ + [ + 'id' => 1, + 'name' => 'mixers', + 'materials' => [ + [ + 'reference' => 'CL3', + 'name' => 'Console Yamaha CL3', + 'quantity' => 1, + 'rentalPrice' => 300.0, + 'replacementPrice' => 19400.0, + 'total' => 300.0, + 'totalReplacementPrice' => 19400.0, + ], + ], + ], + [ + 'id' => 2, + 'name' => 'processors', + 'materials' => [ + [ + 'reference' => 'DBXPA2', + 'name' => 'Processeur DBX PA2', + 'quantity' => 1, + 'rentalPrice' => 25.5, + 'replacementPrice' => 349.9, + 'total' => 25.5, + 'totalReplacementPrice' => 349.9, + ], + ], + ], + [ + 'id' => 4, + 'name' => 'dimmers', + 'materials' => [ + [ + 'reference' => 'SDS-6-01', + 'name' => 'Showtec SDS-6', + 'quantity' => 1, + 'rentalPrice' => 15.95, + 'replacementPrice' => 59.0, + 'total' => 15.95, + 'totalReplacementPrice' => 59.0, + ], + ], + ], + ]; + $this->assertEquals($expected, $result); + } + + public function testGetMaterials() + { + $result = $this->EventEstimate->getMaterials(); + $expected = [ + [ + 'id' => 4, + 'name' => 'Showtec SDS-6', + 'reference' => 'SDS-6-01', + 'park_id' => 1, + 'category_id' => 2, + 'sub_category_id' => 4, + 'rental_price' => 15.95, + 'replacement_price' => 59.0, + 'is_hidden_on_bill' => false, + 'is_discountable' => true, + 'quantity' => 1, + ], + [ + 'id' => 2, + 'name' => 'Processeur DBX PA2', + 'reference' => 'DBXPA2', + 'park_id' => 1, + 'category_id' => 1, + 'sub_category_id' => 2, + 'rental_price' => 25.5, + 'replacement_price' => 349.9, + 'is_hidden_on_bill' => false, + 'is_discountable' => true, + 'quantity' => 1, + ], + [ + 'id' => 1, + 'name' => 'Console Yamaha CL3', + 'reference' => 'CL3', + 'park_id' => 1, + 'category_id' => 1, + 'sub_category_id' => 1, + 'rental_price' => 300.0, + 'replacement_price' => 19400.0, + 'is_hidden_on_bill' => false, + 'is_discountable' => false, + 'quantity' => 1, + ], + ]; + $this->assertEquals($expected, $result); + } + + public function testToModelArray() + { + $result = $this->EventEstimate->toModelArray(); + $expected = [ + 'date' => $this->_date->format('Y-m-d H:i:s'), + 'event_id' => 1, + 'beneficiary_id' => 3, + 'materials' => [ + [ + 'id' => 4, + 'name' => 'Showtec SDS-6', + 'reference' => 'SDS-6-01', + 'park_id' => 1, + 'category_id' => 2, + 'sub_category_id' => 4, + 'rental_price' => 15.95, + 'replacement_price' => 59.0, + 'is_hidden_on_bill' => false, + 'is_discountable' => true, + 'quantity' => 1 + ], + [ + 'id' => 2, + 'name' => 'Processeur DBX PA2', + 'reference' => 'DBXPA2', + 'park_id' => 1, + 'category_id' => 1, + 'sub_category_id' => 2, + 'rental_price' => 25.5, + 'replacement_price' => 349.9, + 'is_hidden_on_bill' => false, + 'is_discountable' => true, + 'quantity' => 1, + ], + [ + 'id' => 1, + 'name' => 'Console Yamaha CL3', + 'reference' => 'CL3', + 'park_id' => 1, + 'category_id' => 1, + 'sub_category_id' => 1, + 'rental_price' => 300.0, + 'replacement_price' => 19400.0, + 'is_hidden_on_bill' => false, + 'is_discountable' => false, + 'quantity' => 1, + ], + ], + 'degressive_rate' => 1.75, + 'discount_rate' => 0.0, + 'vat_rate' => 20.0, + 'due_amount' => 597.54, + 'replacement_amount' => 19808.9, + 'currency' => Config::getSettings('currency')['iso'], + 'user_id' => 1, + ]; + $this->assertEquals($expected, $result); + } + + public function testToModelArrayWithDiscount() + { + $this->EventEstimate->setDiscountRate(33.33); + $result = $this->EventEstimate->toModelArray(); + $expected = [ + 'date' => $this->_date->format('Y-m-d H:i:s'), + 'event_id' => 1, + 'beneficiary_id' => 3, + 'materials' => [ + [ + 'id' => 4, + 'name' => 'Showtec SDS-6', + 'reference' => 'SDS-6-01', + 'park_id' => 1, + 'category_id' => 2, + 'sub_category_id' => 4, + 'rental_price' => 15.95, + 'replacement_price' => 59.0, + 'is_hidden_on_bill' => false, + 'is_discountable' => true, + 'quantity' => 1 + ], + [ + 'id' => 2, + 'name' => 'Processeur DBX PA2', + 'reference' => 'DBXPA2', + 'park_id' => 1, + 'category_id' => 1, + 'sub_category_id' => 2, + 'rental_price' => 25.5, + 'replacement_price' => 349.9, + 'is_hidden_on_bill' => false, + 'is_discountable' => true, + 'quantity' => 1, + ], + [ + 'id' => 1, + 'name' => 'Console Yamaha CL3', + 'reference' => 'CL3', + 'park_id' => 1, + 'category_id' => 1, + 'sub_category_id' => 1, + 'rental_price' => 300.0, + 'replacement_price' => 19400.0, + 'is_hidden_on_bill' => false, + 'is_discountable' => false, + 'quantity' => 1, + ], + ], + 'degressive_rate' => 1.75, + 'discount_rate' => 33.33, + 'vat_rate' => 20.0, + 'due_amount' => 573.36, + 'replacement_amount' => 19808.9, + 'currency' => Config::getSettings('currency')['iso'], + 'user_id' => 1, + ]; + $this->assertEquals($expected, $result); + } + + public function testToPdfTemplateArray() + { + $result = $this->EventEstimate->toPdfTemplateArray($this->_categories); + $expected = [ + 'date' => $this->_date, + 'event' => $this->_eventData, + 'dailyAmount' => 341.45, + 'discountableDailyAmount' => 41.45, + 'daysCount' => 2, + 'degressiveRate' => 1.75, + 'discountRate' => 0.0, + 'discountAmount' => 0.0, + 'vatRate' => 0.2, + 'vatAmount' => 68.29, + 'totalDailyExclVat' => 341.45, + 'totalDailyInclVat' => 409.74, + 'totalExclVat' => 597.54, + 'totalInclVat' => 717.05, + 'totalReplacement' => 19808.9, + 'categoriesSubTotals' => [ + ['id' => 2, 'name' => "light", 'quantity' => 1, 'subTotal' => 15.95], + ['id' => 1, 'name' => "sound", 'quantity' => 2, 'subTotal' => 325.5], + ], + 'materialBySubCategories' => [ + [ + 'id' => 1, + 'name' => "mixers", + 'materials' => [ + [ + 'reference' => 'CL3', + 'name' => 'Console Yamaha CL3', + 'quantity' => 1, + 'rentalPrice' => 300.0, + 'replacementPrice' => 19400.0, + 'total' => 300.0, + 'totalReplacementPrice' => 19400.0, + ], + ], + ], + [ + 'id' => 2, + 'name' => "processors", + 'materials' => [ + [ + 'reference' => 'DBXPA2', + 'name' => 'Processeur DBX PA2', + 'quantity' => 1, + 'rentalPrice' => 25.5, + 'replacementPrice' => 349.9, + 'total' => 25.5, + 'totalReplacementPrice' => 349.9, + ], + ], + ], + [ + 'id' => 4, + 'name' => "dimmers", + 'materials' => [ + [ + 'reference' => 'SDS-6-01', + 'name' => 'Showtec SDS-6', + 'quantity' => 1, + 'rentalPrice' => 15.95, + 'replacementPrice' => 59.0, + 'total' => 15.95, + 'totalReplacementPrice' => 59.0, + ], + ], + ], + ], + 'company' => Config::getSettings('companyData'), + 'locale' => Config::getSettings('defaultLang'), + 'currency' => Config::getSettings('currency')['iso'], + 'currencyName' => Config::getSettings('currency')['name'], + ]; + $this->assertEquals($expected, $result); + } +} diff --git a/server/tests/other/PdfTest.php b/server/tests/libs/pdf/PdfTest.php similarity index 91% rename from server/tests/other/PdfTest.php rename to server/tests/libs/pdf/PdfTest.php index ce5f2555d..e92cca3de 100644 --- a/server/tests/other/PdfTest.php +++ b/server/tests/libs/pdf/PdfTest.php @@ -11,8 +11,8 @@ final class PdfTest extends ModelTestCase { - protected $_testHtmlFile = __DIR__ . DS . 'Pdf/test.html'; - protected $_pdfResultFile = __DIR__ . DS . 'Pdf/result.pdf'; + protected $_testHtmlFile = __DIR__ . DS . 'files/test.html'; + protected $_pdfResultFile = __DIR__ . DS . 'files/result.pdf'; public function testGetResult(): void { @@ -36,10 +36,10 @@ public function testSaveToFile(): void // - Check if result file and expected file have the same size $resultSize = filesize($this->_pdfResultFile); - $expectedSize = filesize(__DIR__ . DS . 'Pdf/expected_save.pdf'); + $expectedSize = filesize(__DIR__ . DS . 'files/expected_save.pdf'); $this->assertEquals($expectedSize, $resultSize); - // - Clean result file (comment this line if you want to check the content of './Pdf/result.pdf') + // - Clean result file (comment this line if you want to check the content of './files/result.pdf') unlink($this->_pdfResultFile); } diff --git a/server/tests/other/Pdf/.gitignore b/server/tests/libs/pdf/files/.gitignore similarity index 100% rename from server/tests/other/Pdf/.gitignore rename to server/tests/libs/pdf/files/.gitignore diff --git a/server/tests/other/Pdf/expected_save.pdf b/server/tests/libs/pdf/files/expected_save.pdf similarity index 100% rename from server/tests/other/Pdf/expected_save.pdf rename to server/tests/libs/pdf/files/expected_save.pdf diff --git a/server/tests/other/Pdf/test.html b/server/tests/libs/pdf/files/test.html similarity index 100% rename from server/tests/other/Pdf/test.html rename to server/tests/libs/pdf/files/test.html diff --git a/server/tests/models/BillTest.php b/server/tests/models/BillTest.php index e2d4f1eb0..2778d41a4 100644 --- a/server/tests/models/BillTest.php +++ b/server/tests/models/BillTest.php @@ -209,6 +209,13 @@ public function testCreateFromEvent() $this->assertEquals($expected, $safeResult); } + public function testGetPdfName() + { + $result = $this->model->getPdfName(1); + $expected = 'TEST-Facture-Testing_corp.-2020-00001-Client_Benef.pdf'; + $this->assertEquals($expected, $result); + } + public function testGetPdfContent() { $result = $this->model->getPdfContent(1); diff --git a/server/tests/models/EstimateTest.php b/server/tests/models/EstimateTest.php new file mode 100644 index 000000000..94fbfe5aa --- /dev/null +++ b/server/tests/models/EstimateTest.php @@ -0,0 +1,221 @@ +model = new Models\Estimate(); + } + + public function testTableName(): void + { + $this->assertEquals('estimates', $this->model->getTable()); + } + + public function testGetAll(): void + { + $result = $this->model->getAll()->get()->toArray(); + $this->assertCount(1, $result); + } + + public function testGetEvent() + { + $result = $this->model::find(1)->event->toArray(); + $expected = [ + 'id' => 1, + 'title' => 'Premier événement', + 'location' => 'Gap', + 'start_date' => '2018-12-17 00:00:00', + 'end_date' => '2018-12-18 23:59:59', + ]; + $this->assertEquals($expected, $result); + } + + public function testGetBeneficiary() + { + $result = $this->model::find(1)->beneficiary->toArray(); + $expected = [ + 'id' => 3, + 'first_name' => "Client", + 'last_name' => "Benef", + 'street' => "156 bis, avenue des tests poussés", + 'postal_code' => "88080", + 'locality' => "Wazzaville", + 'full_name' => "Client Benef", + 'company' => null, + 'country' => null, + ]; + $this->assertEquals($expected, $result); + } + + public function testGetUser() + { + $result = $this->model::find(1)->user->toArray(); + $expected = [ + 'id' => 1, + 'pseudo' => 'test1', + 'email' => 'tester@robertmanager.net', + 'group_id' => 'admin', + 'person' => [ + 'id' => 1, + 'user_id' => 1, + 'first_name' => 'Jean', + 'last_name' => 'Fountain', + 'nickname' => null, + 'email' => 'tester@robertmanager.net', + 'phone' => null, + 'street' => '1, somewhere av.', + 'postal_code' => '1234', + 'locality' => 'Megacity', + 'country_id' => 1, + 'company_id' => 1, + 'note' => null, + 'created_at' => null, + 'updated_at' => null, + 'deleted_at' => null, + 'full_name' => 'Jean Fountain', + 'company' => [ + 'id' => 1, + 'legal_name' => 'Testing, Inc', + 'street' => '1, company st.', + 'postal_code' => '1234', + 'locality' => 'Megacity', + 'country_id' => 1, + 'phone' => '+4123456789', + 'note' => 'Just for tests', + 'created_at' => null, + 'updated_at' => null, + 'deleted_at' => null, + 'country' => [ + 'id' => 1, + 'name' => 'France', + 'code' => 'FR', + ], + ], + 'country' => [ + 'id' => 1, + 'name' => 'France', + 'code' => 'FR', + ], + ], + ]; + $this->assertEquals($expected, $result); + } + + public function testGetMaterials() + { + $result = $this->model::find(1)->materials; + $expected = [ + [ + 'id' => 1, + 'name' => "Console Yamaha CL3", + 'reference' => "PM5D", + 'park_id' => 1, + 'category_id' => 1, + 'sub_category_id' => 1, + 'rental_price' => 300.0, + 'stock_quantity' => 5, + 'out_of_order_quantity' => 1, + 'replacement_price' => 19400.0, + 'is_hidden_on_bill' => false, + 'is_discountable' => false, + ], + [ + 'id' => 2, + 'name' => "Processeur DBX PA2", + 'reference' => "DBXPA2", + 'park_id' => 1, + 'category_id' => 1, + 'sub_category_id' => 2, + 'rental_price' => 25.5, + 'stock_quantity' => 2, + 'out_of_order_quantity' => null, + 'replacement_price' => 349.9, + 'is_hidden_on_bill' => false, + 'is_discountable' => true, + ], + ]; + $this->assertEquals($expected, $result); + } + + public function testCreateFromEventNotFound() + { + $this->expectException(Errors\NotFoundException::class); + $this->expectExceptionMessage("Event not found."); + $this->model->createFromEvent(999, 1, 25); + } + + public function testCreateFromEvent() + { + $result = $this->model->createFromEvent(2, 1, 25.9542); + $expected = [ + 'id' => 2, + 'date' => 'fakedTestContent', + 'event_id' => 2, + 'beneficiary_id' => 3, + 'materials' => [ + [ + 'id' => 2, + 'name' => 'Processeur DBX PA2', + 'reference' => 'DBXPA2', + 'park_id' => 1, + 'category_id' => 1, + 'sub_category_id' => 2, + 'rental_price' => 25.5, + 'replacement_price' => 349.9, + 'is_hidden_on_bill' => false, + 'is_discountable' => true, + 'quantity' => 2, + ], + [ + 'id' => 1, + 'name' => 'Console Yamaha CL3', + 'reference' => 'CL3', + 'park_id' => 1, + 'category_id' => 1, + 'sub_category_id' => 1, + 'rental_price' => 300, + 'replacement_price' => 19400, + 'is_hidden_on_bill' => false, + 'is_discountable' => false, + 'quantity' => 3 + ], + ], + 'degressive_rate' => 1.75, + 'discount_rate' => 25.9542, + 'vat_rate' => 20.0, + 'due_amount' => 1641.09, + 'replacement_amount' => 58899.8, + 'currency' => 'EUR', + 'user_id' => 1, + 'created_at' => 'fakedTestContent', + 'updated_at' => 'fakedTestContent', + ]; + $safeResult = $result->toArray(); + foreach (['date', 'created_at', 'updated_at'] as $field) { + $safeResult[$field] = 'fakedTestContent'; + } + $this->assertEquals($expected, $safeResult); + } + + public function testGetPdfName() + { + $result = $this->model->getPdfName(1); + $expected = 'TEST-Devis-Testing_corp.-20210130-1400-Client_Benef.pdf'; + $this->assertEquals($expected, $result); + } + + public function testGetPdfContent() + { + $result = $this->model->getPdfContent(1); + $this->assertNotEmpty($result); + } +} diff --git a/server/tests/models/EventTest.php b/server/tests/models/EventTest.php index fbd96655e..f4278f90a 100644 --- a/server/tests/models/EventTest.php +++ b/server/tests/models/EventTest.php @@ -233,6 +233,21 @@ public function testGetUser() $this->assertEquals($expected, $results); } + public function testGetEstimates() + { + $Event = $this->model::find(1); + $results = $Event->estimates; + $expected = [ + [ + 'id' => 1, + 'date' => '2021-01-30 14:00:00', + 'discount_rate' => 50.0, + 'due_amount' => 325.5, + ] + ]; + $this->assertEquals($expected, $results->toArray()); + } + public function testGetBills() { $Event = $this->model::find(1); diff --git a/server/tests/other/EventBillTest.php b/server/tests/other/EventBillTest.php deleted file mode 100644 index ce82f2fc6..000000000 --- a/server/tests/other/EventBillTest.php +++ /dev/null @@ -1,412 +0,0 @@ -fail(sprintf("Unable to reset fixtures: %s", $e->getMessage())); - } - - try { - $this->_date = new \DateTime(); - - $event = (new Event()) - ->with('Beneficiaries') - ->with('Materials') - ->find(1); - if (!$event) { - $this->fail("Unable to find event's data"); - } - $this->_eventData = $event->toArray(); - - $this->_number = sprintf( - '%s-%05d', - $this->_date->format('Y'), - $this->_eventData['id'] - ); - - $this->_categories = (new Category())->getAll()->get()->toArray(); - - $this->EventBill = new EventBill($this->_date, $this->_eventData, $this->_number, 1); - } catch (\Exception $e) { - $this->fail($e->getMessage()); - } - } - - // ------------------------------------------------------ - // - - // - Setters tests methods - // - - // ------------------------------------------------------ - - public function testSetDiscountRate() - { - $this->EventBill->setDiscountRate(33.33); - $this->assertEquals(33.33, $this->EventBill->discountRate); - } - - public function testCreateNumber() - { - $date = new \DateTime(); - - $result = EventBill::createNumber($date, 1); - $this->assertEquals(sprintf('%s-00002', date('Y')), $result); - - $result = EventBill::createNumber($date, 155); - $this->assertEquals(sprintf('%s-00156', date('Y')), $result); - } - - // ------------------------------------------------------ - // - - // - Getters tests methods - // - - // ------------------------------------------------------ - - public function testGetDailyAmount() - { - $this->assertEquals(341.45, $this->EventBill->getDailyAmount()); - } - - public function testGetDiscountableDailyAmount() - { - $this->assertEquals(41.45, $this->EventBill->getDiscountableDailyAmount()); - } - - public function testGetReplacementAmount() - { - $this->assertEquals(19808.9, $this->EventBill->getReplacementAmount()); - } - - public function testGetCategoriesTotals() - { - $result = $this->EventBill->getCategoriesTotals($this->_categories); - $expected = [ - ['id' => 2, 'name' => "light", 'quantity' => 1, 'subTotal' => 15.95], - ['id' => 1, 'name' => "sound", 'quantity' => 2, 'subTotal' => 325.5], - ]; - $this->assertEquals($expected, $result); - } - - public function testGetMaterialBySubCategories() - { - $result = $this->EventBill->getMaterialBySubCategories($this->_categories); - $expected = [ - [ - 'id' => 1, - 'name' => "mixers", - 'materials' => [ - [ - 'reference' => 'CL3', - 'name' => 'Console Yamaha CL3', - 'quantity' => 1, - 'rentalPrice' => 300.0, - 'replacementPrice' => 19400.0, - 'total' => 300.0, - 'totalReplacementPrice' => 19400.0, - ], - ], - ], - [ - 'id' => 2, - 'name' => "processors", - 'materials' => [ - [ - 'reference' => 'DBXPA2', - 'name' => 'Processeur DBX PA2', - 'quantity' => 1, - 'rentalPrice' => 25.5, - 'replacementPrice' => 349.9, - 'total' => 25.5, - 'totalReplacementPrice' => 349.9, - ], - ], - ], - [ - 'id' => 4, - 'name' => "dimmers", - 'materials' => [ - [ - 'reference' => 'SDS-6-01', - 'name' => 'Showtec SDS-6', - 'quantity' => 1, - 'rentalPrice' => 15.95, - 'replacementPrice' => 59.0, - 'total' => 15.95, - 'totalReplacementPrice' => 59.0, - ], - ], - ], - ]; - $this->assertEquals($expected, $result); - } - - public function testGetMaterials() - { - $result = $this->EventBill->getMaterials(); - $expected = [ - [ - 'id' => 4, - 'name' => 'Showtec SDS-6', - 'reference' => 'SDS-6-01', - 'park_id' => 1, - 'category_id' => 2, - 'sub_category_id' => 4, - 'rental_price' => 15.95, - 'replacement_price' => 59.0, - 'is_hidden_on_bill' => false, - 'is_discountable' => true, - 'quantity' => 1 - ], - [ - 'id' => 2, - 'name' => 'Processeur DBX PA2', - 'reference' => 'DBXPA2', - 'park_id' => 1, - 'category_id' => 1, - 'sub_category_id' => 2, - 'rental_price' => 25.5, - 'replacement_price' => 349.9, - 'is_hidden_on_bill' => false, - 'is_discountable' => true, - 'quantity' => 1, - ], - [ - 'id' => 1, - 'name' => 'Console Yamaha CL3', - 'reference' => 'CL3', - 'park_id' => 1, - 'category_id' => 1, - 'sub_category_id' => 1, - 'rental_price' => 300.0, - 'replacement_price' => 19400.0, - 'is_hidden_on_bill' => false, - 'is_discountable' => false, - 'quantity' => 1, - ], - ]; - $this->assertEquals($expected, $result); - } - - public function testToModelArray() - { - $result = $this->EventBill->toModelArray(); - $expected = [ - 'number' => $this->_number, - 'date' => $this->_date->format('Y-m-d H:i:s'), - 'event_id' => 1, - 'beneficiary_id' => 3, - 'materials' => [ - [ - 'id' => 4, - 'name' => 'Showtec SDS-6', - 'reference' => 'SDS-6-01', - 'park_id' => 1, - 'category_id' => 2, - 'sub_category_id' => 4, - 'rental_price' => 15.95, - 'replacement_price' => 59.0, - 'is_hidden_on_bill' => false, - 'is_discountable' => true, - 'quantity' => 1 - ], - [ - 'id' => 2, - 'name' => 'Processeur DBX PA2', - 'reference' => 'DBXPA2', - 'park_id' => 1, - 'category_id' => 1, - 'sub_category_id' => 2, - 'rental_price' => 25.5, - 'replacement_price' => 349.9, - 'is_hidden_on_bill' => false, - 'is_discountable' => true, - 'quantity' => 1, - ], - [ - 'id' => 1, - 'name' => 'Console Yamaha CL3', - 'reference' => 'CL3', - 'park_id' => 1, - 'category_id' => 1, - 'sub_category_id' => 1, - 'rental_price' => 300.0, - 'replacement_price' => 19400.0, - 'is_hidden_on_bill' => false, - 'is_discountable' => false, - 'quantity' => 1, - ], - ], - 'degressive_rate' => 1.75, - 'discount_rate' => 0.0, - 'vat_rate' => 20.0, - 'due_amount' => 597.54, - 'replacement_amount' => 19808.9, - 'currency' => Config::getSettings('currency')['iso'], - 'user_id' => 1, - ]; - $this->assertEquals($expected, $result); - } - - public function testToModelArrayWithDiscount() - { - $this->EventBill->setDiscountRate(33.33); - $result = $this->EventBill->toModelArray(); - $expected = [ - 'number' => $this->_number, - 'date' => $this->_date->format('Y-m-d H:i:s'), - 'event_id' => 1, - 'beneficiary_id' => 3, - 'materials' => [ - [ - 'id' => 4, - 'name' => 'Showtec SDS-6', - 'reference' => 'SDS-6-01', - 'park_id' => 1, - 'category_id' => 2, - 'sub_category_id' => 4, - 'rental_price' => 15.95, - 'replacement_price' => 59.0, - 'is_hidden_on_bill' => false, - 'is_discountable' => true, - 'quantity' => 1 - ], - [ - 'id' => 2, - 'name' => 'Processeur DBX PA2', - 'reference' => 'DBXPA2', - 'park_id' => 1, - 'category_id' => 1, - 'sub_category_id' => 2, - 'rental_price' => 25.5, - 'replacement_price' => 349.9, - 'is_hidden_on_bill' => false, - 'is_discountable' => true, - 'quantity' => 1, - ], - [ - 'id' => 1, - 'name' => 'Console Yamaha CL3', - 'reference' => 'CL3', - 'park_id' => 1, - 'category_id' => 1, - 'sub_category_id' => 1, - 'rental_price' => 300.0, - 'replacement_price' => 19400.0, - 'is_hidden_on_bill' => false, - 'is_discountable' => false, - 'quantity' => 1, - ], - ], - 'degressive_rate' => 1.75, - 'discount_rate' => 33.33, - 'vat_rate' => 20.0, - 'due_amount' => 573.36, - 'replacement_amount' => 19808.9, - 'currency' => Config::getSettings('currency')['iso'], - 'user_id' => 1, - ]; - $this->assertEquals($expected, $result); - } - - public function testToPdfTemplateArray() - { - $result = $this->EventBill->toPdfTemplateArray($this->_categories); - $expected = [ - 'number' => $this->_number, - 'date' => $this->_date, - 'event' => $this->_eventData, - 'dailyAmount' => 341.45, - 'discountableDailyAmount' => 41.45, - 'daysCount' => 2, - 'degressiveRate' => 1.75, - 'discountRate' => 0.0, - 'discountAmount' => 0.0, - 'vatRate' => 0.2, - 'vatAmount' => 68.29, - 'totalDailyExclVat' => 341.45, - 'totalDailyInclVat' => 409.74, - 'totalExclVat' => 597.54, - 'totalInclVat' => 717.05, - 'totalReplacement' => 19808.9, - 'categoriesSubTotals' => [ - ['id' => 2, 'name' => "light", 'quantity' => 1, 'subTotal' => 15.95], - ['id' => 1, 'name' => "sound", 'quantity' => 2, 'subTotal' => 325.5], - ], - 'materialBySubCategories' => [ - [ - 'id' => 1, - 'name' => "mixers", - 'materials' => [ - [ - 'reference' => 'CL3', - 'name' => 'Console Yamaha CL3', - 'quantity' => 1, - 'rentalPrice' => 300.0, - 'replacementPrice' => 19400.0, - 'total' => 300.0, - 'totalReplacementPrice' => 19400.0, - ], - ], - ], - [ - 'id' => 2, - 'name' => "processors", - 'materials' => [ - [ - 'reference' => 'DBXPA2', - 'name' => 'Processeur DBX PA2', - 'quantity' => 1, - 'rentalPrice' => 25.5, - 'replacementPrice' => 349.9, - 'total' => 25.5, - 'totalReplacementPrice' => 349.9, - ], - ], - ], - [ - 'id' => 4, - 'name' => "dimmers", - 'materials' => [ - [ - 'reference' => 'SDS-6-01', - 'name' => 'Showtec SDS-6', - 'quantity' => 1, - 'rentalPrice' => 15.95, - 'replacementPrice' => 59.0, - 'total' => 15.95, - 'totalReplacementPrice' => 59.0, - ], - ], - ], - ], - 'company' => Config::getSettings('companyData'), - 'locale' => Config::getSettings('defaultLang'), - 'currency' => Config::getSettings('currency')['iso'], - 'currencyName' => Config::getSettings('currency')['name'], - ]; - $this->assertEquals($expected, $result); - } -}