Compare commits

...
Sign in to create a new pull request.

73 commits

Author SHA1 Message Date
Jérémy Munsch
cd7d5f5fe9
feat(orders): implement default sorting for order queries
Added default sorting by date in descending order for order queries.
Updated related views and tests to reflect this change.
2026-10-29 21:16:06 +01:00
Jérémy Munsch
0e05779f89
feat(sliders): enhance carousel controls and responsiveness
Added padding for carousel controls on larger screens and improved button styles for better visibility and interaction.
2026-10-29 20:59:30 +01:00
Jérémy Munsch
c8f895c759
refactor: simplify use statements and type hints
- Removed fully qualified names for better readability.
- Streamlined code by using direct class references.
2026-10-29 20:35:35 +01:00
Jérémy Munsch
c40016bba1
feat(order): implement order placement and related DTOs
- Add methods to convert OrderLine and DeliveryAddress to DTOs.
- Introduce toPlacedEvent method in Order to generate domain event.
- Enhance OrderUnit with Regate code mapping.
- Implement tests for new functionality and ensure correctness.
2026-10-29 20:35:13 +01:00
Jérémy Munsch
71e0d65f31
refactor(OrderService): simplify order creation from basket
Updated the OrderService to utilize the new fromBasket method for creating orders, enhancing code clarity and maintainability. Removed the ProductAvailabilityService as its functionality is now integrated into the ProductRepository.
2026-10-29 20:21:32 +01:00
Jérémy Munsch
da0c3afc14
refactor(MoneyServiceProvider, ViewServiceProvider): streamline boot methods
Removed unnecessary conditionals and improved method calls for clarity.
2026-10-29 20:13:15 +01:00
Jérémy Munsch
e857a58d00
test(Product): add comprehensive tests for Product entity
- Implement tests for product publication status.
- Add tests for product orderability based on various conditions.
- Enhance pricing calculation tests for different discount scenarios.
- Remove outdated PricingServiceTest and refactor ProductAvailabilityServiceTest.
2026-10-29 19:48:38 +01:00
Jérémy Munsch
cfac370e2e
feat(discount): implement published discounts filtering logic
Added logic to filter published discounts, including those with null start or end dates.
2026-10-29 19:19:22 +01:00
Jérémy Munsch
08354b44b6
style(tests): format code for better readability
Improved code formatting in ChangeOrderBladeTest for consistency.
2026-10-29 19:13:31 +01:00
Jérémy Munsch
ed593c25b0
test(UpdateShopPickupExceptionsRequest): remove redundant message test and update error messages
Updated error messages for better clarity and removed unnecessary test for custom messages.
2026-10-29 19:12:21 +01:00
Jérémy Munsch
c52b13cc51
feat(order): implement soft delete handling in order changes
Added support for soft-deleted models in ChangeModelOrder and ChangeModelOrderPosition actions.
This allows for proper order adjustments even when models are soft-deleted.
2026-10-29 19:04:46 +01:00
Jérémy Munsch
7b910c5af6
refactor(UpdateShopPickupExceptionsRequest): remove unused messages method
Cleaned up the code by removing the messages method as it was not utilized.
2026-10-29 18:43:02 +01:00
Jérémy Munsch
961829a0c7
fix(pickup-exceptions): update noReplacement logic for checkbox state
Ensure noReplacement reflects checkbox status for accurate behavior.
2026-10-29 18:24:21 +01:00
Jérémy Munsch
36faed679e
fix(pickup-exceptions): correct spelling of 'Blocked week'
Updated the text from 'Bloqued week' to 'Blocked week' for consistency in error messages.
2026-10-29 18:23:36 +01:00
Jérémy Munsch
cef7a4e4b0
style(pickup-exceptions): fix formatting for readability
Adjusted indentation for better code clarity.
2026-10-29 18:22:51 +01:00
Jérémy Munsch
a87f5756b2
fix(PickupSchedulingService): use equals method for exception ID comparison
Updated the comparison of exception IDs to use the equals method for better accuracy.
2026-10-29 18:21:22 +01:00
Jérémy Munsch
0874259eba
style(FilteredProductResource): fix indentation for nutriscore line 2026-10-29 17:10:13 +01:00
Jérémy Munsch
3ac3cbc7ff
style(products): format change-selected-order route for readability 2026-10-29 17:08:16 +01:00
Jérémy Munsch
16dfc32f73
Merge branch 'issue-82' into develop 2026-10-29 17:06:27 +01:00
Jérémy Munsch
65df9aa04e
test(SettingsController): update assertions to check forbidden access
Updated test cases to assert forbidden responses instead of successful ones for employee site settings.
2026-10-29 17:05:34 +01:00
Jérémy Munsch
584fee7e69
fix(EloquentPickupExceptionRepository): include pivot data in exception mapping
Updated the mapping function to include pivot data for pickup exceptions, ensuring accurate retrieval of associated order deadlines and replacement dates.
2026-10-29 17:02:07 +01:00
Jérémy Munsch
5d36639705
fix(lang): correct typo in French translation for replacement date
Fixes a typo in the French language file for the replacement date message.
2026-10-29 16:42:41 +01:00
Jérémy Munsch
8e42639134
feat(order): implement change selected order functionality
Add functionality to change the order of models in the product list.
This includes new actions, requests, and UI components for user interaction.
2026-10-29 16:39:23 +01:00
Jérémy Munsch
a94d8a4b49
feat(products): add discount filtering to product index
- Implement discount filtering in product queries and views.
- Update related request and response handling for discounts.
2026-10-29 16:09:41 +01:00
Jérémy Munsch
b9999caf8d
feat(discounts): add discount logic for piece pricing
Implement discount calculations that apply to the piece (sold unit) for products invoiced by weight.
2026-10-29 15:43:37 +01:00
Jérémy Munsch
4d6ff9b726
fix(SettingPolicy): enable Arcadia refresh job dispatching
The Arcadia refresh job dispatching is currently disabled and needs to be enabled when the API is available.
2026-10-29 15:26:45 +01:00
Jérémy Munsch
82697ed416
fix(products): respect is_old_price_displayed discount setting in product cards
- Condition strikethrough price display on showStrippedPrice in ProductCard.vue
- Add showStrippedPrice to product Zod schemas, TypeScript types, repositories and store
- Optimize showStrippedPrice in FilteredProductResource using eager-loaded validDiscount
- Add unit tests for productModelSchema and integration tests in ProductsControllerTest
2026-10-29 15:08:53 +01:00
Jérémy Munsch
0d5958aae6
fix(products): update discount logic for price display
Corrected the logic for displaying discounted prices in the product views.
2026-10-29 14:56:35 +01:00
Jérémy Munsch
0edef37e50
fix(orders): correct closing tag for order reference span
This change fixes a typo in the order reference display by correcting the closing tag.
2026-10-28 15:25:28 +01:00
Jérémy Munsch
0ea946a2f6
style(orders): update button alignment and styling for order references
Improve button presentation for better visual consistency.
2026-10-28 15:18:56 +01:00
Jérémy Munsch
f882b431b7
refactor(AisleViewer): update page title logic and imports
Improved the logic for displaying the page title based on search criteria.
2026-10-28 15:08:46 +01:00
Jérémy Munsch
889cc66c07
style(faq): update accordion button states for consistency
Adjusted button classes and attributes for accordion items to ensure consistent behavior and appearance.
2026-10-28 15:06:19 +01:00
Jérémy Munsch
9d28162ec4
style(logging): update comment formatting for clarity
Improved the formatting of comments in the logging configuration file for better readability.
2026-10-28 15:03:25 +01:00
Jérémy Munsch
db684cf35f
refactor(products): update column classes for filter form
Adjusted column classes from 'col-md' to 'col-xl' for better responsiveness in the product filter form.
2026-10-28 13:15:30 +01:00
Jérémy Munsch
7a5c91e55e
refactor(crud): simplify no results messages in views
Updated no results messages to remove model references for consistency across various views.
2026-10-28 13:04:46 +01:00
Jérémy Munsch
e2263911f3
refactor(products): order tags by 'order' in query
This change ensures that the tags are retrieved in a consistent order.
2026-10-28 12:59:37 +01:00
Jérémy Munsch
aef658645b
refactor(slides): update column definitions and formatting
- Removed unused 'label' and 'text' columns.
- Adjusted 'tableColSorter' parameters for clarity.
2026-10-28 12:55:44 +01:00
Jérémy Munsch
1e1172b757
feat(validation): update address attribute to use postal_address
Updated the address attribute in multiple requests and views to use the new 'postal_address' translation key for consistency.
2026-10-28 12:51:06 +01:00
Jérémy Munsch
a15cc29a61
style(faq, legal-notice, privacy, terms): add padding to section containers 2026-10-28 12:19:38 +01:00
Jérémy Munsch
b31602b040
feat(faq): implement accordion for FAQ section
Enhances the FAQ page by replacing the list with an accordion component for better user experience.
2026-10-28 12:18:54 +01:00
Jérémy Munsch
020e2f8526
test(DiscountProductDto): update tests to include picture attribute
Added picture attribute to the DiscountProductDto tests to ensure proper serialization.
2026-10-28 11:57:37 +01:00
Jérémy Munsch
c5fe5987da
feat(fontawesome): add cash register icon to library
feat(confirmation): update button with cash register icon

feat(pick-time-slot): update back button with chevron icon
2026-10-28 11:53:21 +01:00
Jérémy Munsch
4b0775b5ea
feat(ProductFilterViewModel): enhance URL parameter handling
Improve loading of search, ranges, tags, and brands from URL.
Trigger loading state and refresh list when filters are applied.
2026-10-28 11:36:40 +01:00
Jérémy Munsch
7a6c154e5f
feat(products): add price info section to product details
This update introduces a new price information section in the product details view, enhancing user experience by providing clear pricing details.
2026-10-28 11:26:23 +01:00
Jérémy Munsch
769949b3c3
feat(AisleViewer): update page title based on search state
Add computed properties to dynamically change the page title
between "Your search" and "All aisles" based on user input.
2026-10-28 11:10:05 +01:00
Jérémy Munsch
5a0cc6c519
feat(ContactFormRequest): add attributes method for validation messages
Added a method to define custom attribute names for validation.
2026-10-28 10:56:39 +01:00
Jérémy Munsch
99586179d7
feat(services): update pickup day text in services view
Updated the text for selecting the pickup day in the services view for better clarity.
2026-10-28 10:51:55 +01:00
Jérémy Munsch
87e66c1d13
style(navbar): update button styles for consistency and visibility
Updated button styles to use 'btn-tertiary' and added focus rings for better accessibility.
2026-10-28 10:43:44 +01:00
Jérémy Munsch
b3dc0ff3c4
style(BasketPanel): update button styles for better visibility
Improved button text weight for enhanced readability.
2026-10-28 10:41:08 +01:00
Jérémy Munsch
14ceb300cf
feat(ui): update button styles and add focus ring
- Added new tertiary button style with focus ring.
- Updated button classes in navbar and basket components for consistency.
2026-10-28 10:41:03 +01:00
Jérémy Munsch
44ffc4d34e
feat(DiscountProductDto): add picture property and update fromModel method
feat(ProductDragAndDropComponent): enhance product display with picture and improve layout
2026-10-28 10:02:43 +01:00
Jérémy Munsch
f9ded6d01c
refactor(ToolboxHelper): remove unused getValidatedEnum method
Cleaned up ToolboxHelper by removing the unused getValidatedEnum method to enhance code clarity.
2026-10-28 08:57:35 +01:00
Jérémy Munsch
4ff3daecab
refactor(Auth): improve reset URL handling and validation
refactor(Helpers): update parameter types and remove ignores

refactor(Models): streamline order assignment logic

refactor(Enums): clarify unused constant warnings

refactor(Services): encapsulate cutoff week offset logic
2026-10-28 08:53:34 +01:00
Jérémy Munsch
1899fd1f0e
chore: phpcs fix 2026-10-27 20:20:52 +01:00
Jérémy Munsch
8e213e2e33
fix: htmlsanitizer and contact form validation 2026-10-27 20:18:08 +01:00
Jérémy Munsch
c7535d3d04
feat(Providers): bind CsvGenerator to CsvGeneratorImpl
Add binding for CsvGenerator service to its implementation.
2026-10-27 19:59:49 +01:00
Jérémy Munsch
b8a0f16efa
fix(Crm): update customer input handling in UpdateCustomer action
Refactor input validation and data preparation for customer updates.
2026-10-27 18:57:46 +01:00
Jérémy Munsch
57e12e06b6
docs: to force rebuild 2026-10-27 17:33:42 +01:00
Jérémy Munsch
afdd6cd0a7
fix(config): correct environment variable usage for phone reception
Ensure the default phone reception number is used correctly when the environment variable is not set.
2026-10-27 17:07:15 +01:00
Jérémy Munsch
aeb8addbb8
fix(config): simplify environment variable usage for default settings
Updated the default settings in filesystems, mail, and session configurations to use a more concise syntax for environment variable retrieval.
2026-10-27 16:39:23 +01:00
Jérémy Munsch
dd05d9166c
fix(value-objects): simplify Uuid usage in OrderReference
Refactor the OrderReference class to use the Uuid alias directly for better readability.
2026-10-27 16:20:50 +01:00
Jérémy Munsch
1cf15950c6
fix: add missing CACHE_STORE environment variable
This change ensures that the CACHE_STORE environment variable is defined for the testing environment.
2026-10-27 16:17:40 +01:00
Jérémy Munsch
713c937890
fix(casts): update OrderReferenceCast to use base58 encoding
Refactor OrderReferenceCast to utilize base58 encoding for order references instead of UUID.
This change improves compatibility with the new OrderReference implementation.
2026-10-27 16:17:28 +01:00
Jérémy Munsch
91a82af969
fix: add missing logging config file 2026-10-27 16:08:23 +01:00
Jérémy Munsch
d8f419cd2c
docs: rgpd to force rebuild 2026-10-27 15:23:42 +01:00
Jérémy Munsch
c1ab6ef066
build fix docker image 2026-10-27 13:55:27 +01:00
Jérémy Munsch
0a1bfc9aed
build: fix build npm-security 2026-10-27 13:53:22 +01:00
Jérémy Munsch
fee9609907
build: fix build npm-security 2026-10-27 13:50:35 +01:00
Jérémy Munsch
cecd808a80
build: fix build npm-security 2026-10-27 13:50:27 +01:00
Jérémy Munsch
bd0cb02389
build: fix multistage 2026-10-27 13:03:28 +01:00
Jérémy Munsch
146bbf57bf
build: fix build config 2026-10-27 12:47:58 +01:00
Jérémy Munsch
c8eba4a932
fix: sentry config optimization 2026-10-27 12:25:38 +01:00
Jérémy
639a8459d2 Merge branch 'massive-bump' into 'develop'
Massive bump

See merge request ldc-web/projets/boutique-cnc-sable!84
2026-10-27 11:40:27 +01:00
191 changed files with 4189 additions and 1581 deletions

View file

@ -15,7 +15,7 @@ include:
file: "build/composer.yml"
- project: "ldc-web/shared-ci"
ref: "main"
file: "build/docker.yml"
file: "build/docker-multistage.yml"
- project: "ldc-web/shared-ci"
ref: "main"
file: "test/test.yml"
@ -313,3 +313,16 @@ npm-build:
script:
- echo "Frontend is built directly within Docker multi-stage build"
npm-security:
stage: security
script:
- echo "Npm security would run bun security"
dependencies:
- bun-security
needs:
- job: bun-security
optional: true
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "trunk" || $CI_COMMIT_BRANCH == "master" || $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_TAG != null

View file

@ -1,3 +1,5 @@
ARG PHP_8_5_TAG=latest
FROM composer:2.9 AS vendor
ARG GITLAB_USER
@ -8,7 +10,7 @@ COPY database/ database/
COPY composer.json composer.json
COPY composer.lock composer.lock
RUN composer config gitlab-token.lab.s-web.fr ${GITLAB_USER} ${GITLAB_PASSWORD}
RUN composer config gitlab-token.lab.s-web.fr "${GITLAB_USER}" "${GITLAB_PASSWORD}"
RUN composer install \
--ignore-platform-reqs \
--no-interaction \
@ -35,13 +37,13 @@ RUN echo "@ldc-web:registry=https://lab.s-web.fr/api/v4/packages/npm/" >> .npmrc
RUN bun install --frozen-lockfile && bun run prod
ARG PHP_8_5_TAG
FROM registry.s-web.fr/ldc-web/projets/ldc-web-images-docker/uwil-production-8.5:${PHP_8_5_TAG}
ARG USER=www-data
COPY --chown=${USER}:${USER} . /app/srcs
COPY --chown=${USER}:${USER} ./resources/favicon/front/ /app/srcs/public/
COPY --chown=${USER}:${USER} ./resources/favicon/back/ /app/srcs/public/back/
COPY --chown=${USER}:${USER} ./resources/favicon/ /app/srcs/public/
COPY --from=vendor --chown=${USER}:${USER} /app/vendor/ /app/srcs/vendor/
COPY --from=frontend --chown=${USER}:${USER} /app/public/build/ /app/srcs/public/build/

51
RGPD.md
View file

@ -2,16 +2,17 @@
## Site
### En base de données
| Table | Colonne | Contenu | Utilisation |
| ----------- | ------------------ | ----------------------------------------------- | -------------------------------------------------------- |
| customers | email | Adresse email | Identifiant du client |
| customers | hashcode | Code unique de l'utilisateur en CRM | Identifiant du client en CRM |
| customers | created_at | Date | Suivi d'activité |
| customers | updated_at | Date | Suivi d'activité |
| customers | last_login_at | Date | Suivi de la dernière connexion au compte client |
| --- | --- | --- | --- |
| Table | Colonne | Contenu | Utilisation |
| ----------- | -------------------------- | ----------------------------------------------- | -------------------------------------------------------- |
| customers | email | Adresse email | Identifiant du client |
| customers | hashcode | Code unique de l'utilisateur en CRM | Identifiant du client en CRM |
| customers | created_at | Date | Suivi d'activité |
| customers | updated_at | Date | Suivi d'activité |
| customers | last_login_at | Date | Suivi de la dernière connexion au compte client |
| --- | --- | --- | --- |
| orders | employee.matricule | Matricule de l'employé | Identifiant unique de l'employé lié à la commande |
| orders | employee.firstname | Prénom de l'employé | Affichage et personnalisation de la commande |
| orders | employee.lastname | Nom de l'employé | Affichage et personnalisation de la commande |
@ -26,23 +27,23 @@
| orders | delivery_address.latitude | Latitude de l'adresse de livraison | Géolocalisation pour optimiser la livraison |
| orders | delivery_address.longitude | Longitude de l'adresse de livraison | Géolocalisation pour optimiser la livraison |
| orders | employee_matricule | Matricule de l'employé (colonne virtuelle) | Indexation et recherche rapide des commandes par employé |
| --- | --- | --- | --- |
| order_lines | product_id | Identifiant du produit présent dans la commande | Détail de la commande |
| order_lines | quantité | Quantité du produit présent dans la commande | Détail de la commande |
| --- | --- | --- | --- |
| employees | customer_id | Identifiant client | Permet de relier un salarié à un compte client du site |
| employees | matricule | Matricule | Matricule du salarié |
| employees | nom | Nom | Nom du salarié |
| employees | prenom | Prénom | Prénom du salarié |
| employees | start_date | Date de début du contrat | Activation du compte |
| employees | end_date | Date de fin du contrat | Désactivation du compte |
| employees | etablissement | Identifiant établissement | Localiser l'employé pour le dépôt de la commande |
| employees | site | Identifiant site | Localiser l'employé pour le dépôt de la commande |
| employees | service | Identifiant service | Localiser l'employé pour le dépôt de la commande |
| --- | --- | --- | --- |
| users | first_name | Prénom | Affichage à différents endroits du BO |
| users | last_name | Nom | Affichage à différents endroits du BO |
| users | email | Adresse mail | Identifiant, sert à la connexion au BO |
| --- | --- | --- | --- |
| order_lines | product_id | Identifiant du produit présent dans la commande | Détail de la commande |
| order_lines | quantité | Quantité du produit présent dans la commande | Détail de la commande |
| --- | --- | --- | --- |
| employees | customer_id | Identifiant client | Permet de relier un salarié à un compte client du site |
| employees | matricule | Matricule | Matricule du salarié |
| employees | nom | Nom | Nom du salarié |
| employees | prenom | Prénom | Prénom du salarié |
| employees | start_date | Date de début du contrat | Activation du compte |
| employees | end_date | Date de fin du contrat | Désactivation du compte |
| employees | etablissement | Identifiant établissement | Localiser l'employé pour le dépôt de la commande |
| employees | site | Identifiant site | Localiser l'employé pour le dépôt de la commande |
| employees | service | Identifiant service | Localiser l'employé pour le dépôt de la commande |
| --- | --- | --- | --- |
| users | first_name | Prénom | Affichage à différents endroits du BO |
| users | last_name | Nom | Affichage à différents endroits du BO |
| users | email | Adresse mail | Identifiant, sert à la connexion au BO |
### Formulaire de contact (marketing)

View file

@ -7,7 +7,9 @@ namespace App\Actions\Common;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Interfaces\ModelWithOrder;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use SortDirection;
use Webmozart\Assert\Assert;
@ -30,8 +32,17 @@ final class ChangeModelOrder implements Action
$goUp = $input->direction === SortDirection::Ascending;
$currentOrder = $model->getOrder();
$usesSoftDeletes = in_array(SoftDeletes::class, class_uses_recursive($model), true)
|| method_exists($model, 'trashed');
/** @var Builder<Model&ModelWithOrder> $baseQuery */
$baseQuery = $usesSoftDeletes
// @phpstan-ignore method.notFound (withTrashed)
? $model::query()->withTrashed()
: $model::query();
/** @var (Model&ModelWithOrder)|null $adjacentModel */
$adjacentModel = $model::query()
$adjacentModel = $baseQuery
->where('order', $goUp ? '<' : '>', $currentOrder)
->orderBy('order', $goUp ? SortDirection::Descending : SortDirection::Ascending)
->first();

View file

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Actions\Common;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Interfaces\ModelWithOrder;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Common\ChangeModelOrderPositionInput, bool>
*/
final class ChangeModelOrderPosition implements Action
{
private const int MAX_ORDER_VALUE = 4294967295;
/**
* @param \App\Actions\Common\ChangeModelOrderPositionInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): bool
{
Assert::isInstanceOf($input, ChangeModelOrderPositionInput::class);
$model = $input->model;
$currentOrder = $model->getOrder();
$targetOrder = $input->targetOrder;
if ($currentOrder === $targetOrder) {
return true;
}
$usesSoftDeletes = in_array(SoftDeletes::class, class_uses_recursive($model), true)
|| method_exists($model, 'trashed');
/** @var Builder<Model&ModelWithOrder> $baseQuery */
$baseQuery = $usesSoftDeletes
// @phpstan-ignore method.notFound (withTrashed)
? $model::query()->withTrashed()
: $model::query();
/** @var int|null $maxOrder */
$maxOrder = (clone $baseQuery)->max('order');
if ($maxOrder !== null && $targetOrder > $maxOrder) {
$targetOrder = (int) $maxOrder;
}
if ($currentOrder === $targetOrder) {
return true;
}
// Deactivate timestamps
$model->timestamps = false;
// Step 1: Temporarily park the moving model at MAX_ORDER_VALUE
$model->setOrder(self::MAX_ORDER_VALUE);
$model->saveQuietly();
if ($targetOrder < $currentOrder) {
// Moving UP
/** @var \Illuminate\Database\Eloquent\Collection<int, Model&ModelWithOrder> $itemsToShift */
$itemsToShift = (clone $baseQuery)
->where('order', '>=', $targetOrder)
->where('order', '<', $currentOrder)
->whereKeyNot($model->getKey())
->orderBy('order', 'desc')
->get();
foreach ($itemsToShift as $item) {
$item->timestamps = false;
$item->setOrder($item->getOrder() + 1);
$item->saveQuietly();
}
} else {
// Moving DOWN
/** @var \Illuminate\Database\Eloquent\Collection<int, Model&ModelWithOrder> $itemsToShift */
$itemsToShift = (clone $baseQuery)
->where('order', '>', $currentOrder)
->where('order', '<=', $targetOrder)
->whereKeyNot($model->getKey())
->orderBy('order', 'asc')
->get();
foreach ($itemsToShift as $item) {
$item->timestamps = false;
$item->setOrder($item->getOrder() - 1);
$item->saveQuietly();
}
}
// Step 2: Set the moving model to the target order
$model->setOrder($targetOrder);
$model->saveQuietly();
return true;
}
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Actions\Common;
use App\Contracts\ActionInput;
use App\Interfaces\ModelWithOrder;
use Illuminate\Database\Eloquent\Model;
use Webmozart\Assert\Assert;
final readonly class ChangeModelOrderPositionInput implements ActionInput
{
public function __construct(
public Model & ModelWithOrder $model,
public int $targetOrder,
) {
Assert::isInstanceOf($this->model, Model::class);
Assert::isInstanceOf($this->model, ModelWithOrder::class);
Assert::greaterThanEq($this->targetOrder, 1);
}
}

View file

@ -24,23 +24,32 @@ final class UpdateCustomer implements Action
#[\Override]
public function __invoke(ActionInput $input): UpdateCustomerOutput
{
Assert::isInstanceOf($input, UnsubscribeFromNewsletterInput::class);
Assert::isInstanceOf($input, UpdateCustomerInput::class);
$customer = $this->authService->getCustomerAsMandatory();
try {
/** @var array{firstname:string,lastname:string,birth_date:string,phone?:string,address?:string,postcode:string,city:string,opt_in?:bool} $data */
$data = [
'firstname' => $input->firstname,
'lastname' => $input->lastname,
'birth_date' => $input->birthDate,
'postcode' => $input->postcode,
'city' => $input->city,
'opt_in' => $input->optIn,
];
if ($input->phone !== null) {
$data['phone'] = $input->phone;
}
if ($input->address !== null) {
$data['address'] = $input->address;
}
$result = $this->crmService->updateCustomerInfos(
$customer,
[
'firstname' => $input->firstname,
'lastname' => $input->lastname,
'birth_date' => $input->birthDate,
'phone' => $input->phone,
'address' => $input->address,
'postcode' => $input->postcode,
'city' => $input->city,
'opt_in' => $input->optIn,
]
$data
);
return new UpdateCustomerOutput($result);

View file

@ -35,6 +35,13 @@ class OrderLineProductCast implements CastsAttributes
$data = Arr::wrap(json_decode($value, true));
$taxRate = Arr::get($data, 'tax_rate');
$taxRate = is_numeric($taxRate) ? (float) $taxRate : null;
if (is_null($taxRate)) {
throw new \InvalidArgumentException('The tax rate cannot be null.');
}
return new OrderLineProduct(
incoId: new IncoId(Arr::integer($data, 'inco_id')),
originalId: new ProductId(Uuid::fromString(
@ -50,14 +57,8 @@ class OrderLineProductCast implements CastsAttributes
price: new MoneyCast()->get($model, 'price', Arr::get($data, 'price'), []),
discountedPrice: new MoneyCast()->get($model, 'discounted_price', Arr::get($data, 'discounted_price'), []),
priceKgTtc: new MoneyCast()->get($model, 'price_kg_ttc', Arr::get($data, 'price_kg_ttc'), []),
taxRate: match ((float) Arr::float($data, 'tax_rate')) {
TaxRate::TVA_20->percent() => TaxRate::TVA_20,
TaxRate::TVA_5_5->percent() => TaxRate::TVA_5_5,
default => throw new \UnexpectedValueException(
'Unhandled tax'
)
},
picture: Arr::string($data, 'picture', null),
taxRate: TaxRate::fromPercent($taxRate),
picture: is_string($picture = Arr::get($data, 'picture')) ? $picture : null,
);
}

View file

@ -6,7 +6,6 @@ namespace App\Casts;
use Domain\Models\ValueObjects\OrderReference;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Ramsey\Uuid\Uuid;
/**
* @implements CastsAttributes<OrderReference,OrderReference>
@ -20,7 +19,7 @@ class OrderReferenceCast implements CastsAttributes
throw new \UnexpectedValueException('OrderReference cannot be null.');
}
return new OrderReference(Uuid::fromString($value));
return OrderReference::fromString($value);
}
/** @param array<string, mixed> $attributes */
@ -30,6 +29,6 @@ class OrderReferenceCast implements CastsAttributes
throw new \UnexpectedValueException('The given value is not an instance of OrderReference.');
}
return $value->value->toString();
return (string) $value;
}
}

View file

@ -15,6 +15,7 @@ final readonly class DiscountProductDto implements \JsonSerializable, Arrayable
public function __construct(
public string $id,
public string $name,
public ?string $picture,
) {
}
@ -23,6 +24,7 @@ final readonly class DiscountProductDto implements \JsonSerializable, Arrayable
return new self(
id: (string) $product->id,
name: (string) $product->name,
picture: $product->picture,
);
}
@ -33,8 +35,9 @@ final readonly class DiscountProductDto implements \JsonSerializable, Arrayable
public function toArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'id' => $this->id,
'name' => $this->name,
'picture' => $this->picture,
];
}

View file

@ -67,6 +67,7 @@ class ProductsController extends Controller implements MassActions
'brands' => $output->brands,
'ranges' => $output->ranges,
'tags' => $output->tags,
'discounts' => $output->discounts,
'searchFields' => $output->searchFields,
]);
}

View file

@ -16,8 +16,6 @@ abstract class AuthController extends Controller
{
public function __construct()
{
// @phpstan-ignore assign.propertyType
ResetPassword::$createUrlCallback = $this->createResetUrl(...);
VerifyEmail::$createUrlCallback = $this->createValidationUrl(...);
}
@ -50,8 +48,12 @@ abstract class AuthController extends Controller
);
}
public function createResetUrl(Customer $notifiable, string $token): string
public function createResetUrl(mixed $notifiable, string $token): string
{
if (! is_object($notifiable) || ! ($notifiable instanceof Customer)) {
throw new \InvalidArgumentException('Invalid notifiable provided.');
}
return url(
URL::temporarySignedRoute(
'fo.auth.reset-password',

View file

@ -34,35 +34,37 @@ class CustomerController extends Controller
{
$this->authorize('manageOwnProfile', Customer::class);
$crmCustomer = $this->executor->execute(
$output = $this->executor->execute(
$getCrmCustomerQuery,
new GetCrmCustomerQueryInput()
);
abort_unless(
$crmCustomer->customer instanceof CrmCustomer,
$output->customer instanceof CrmCustomer,
503,
trans('An error occurred, please try again later.')
);
return view('fo.customer.edit.infos', compact('crmCustomer'));
return view('fo.customer.edit.infos', [
'crmCustomer' => $output->customer,
]);
}
public function editInfosSubmit(UpdateInfosRequest $request, UpdateCustomer $updateCustomer): RedirectResponse
{
$this->authorize('manageOwnProfile', Customer::class);
$result = $this->executor->execute(
$output = $this->executor->execute(
$updateCustomer,
$request->toUpdateCustomerInput()
);
return match ($result) {
$result instanceof CrmCustomer => back()
return match (true) {
$output->result instanceof CrmCustomer => back()
->with('success', trans('Your information has been saved.')),
$result instanceof ValidationException => back()
$output->result instanceof ValidationException => back()
->withInput()
->withErrors($result->getMessage()),
->withErrors($output->result->getMessage()),
default => back()
->withInput()
->withErrors(trans('An error occurred, please try again later.')),

View file

@ -6,6 +6,7 @@ namespace App\Http\Middleware;
use App\Lib\Helpers\ToolboxHelper;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Mews\Purifier\Purifier;
use Symfony\Component\HttpFoundation\Response;
@ -25,7 +26,8 @@ class HtmlSanitizer
public function handle(Request $request, \Closure $next, string ...$params): Response
{
$inputs = $request->all();
\array_walk($inputs, function (null | string | array &$input, string $name) use ($params): void {
\array_walk($inputs, function (null | string | array | UploadedFile &$input, string $name) use ($params): void {
$this->cleanInput($input, $name, null, $params);
});
$request->merge($inputs);
@ -34,15 +36,22 @@ class HtmlSanitizer
}
/**
* @param null|string|array<string,mixed> $input
* @param null|string|array<string,mixed>|\Illuminate\Http\UploadedFile $input
* @param string[] $params
*/
private function cleanInput(null | string | array &$input, string $name, ?string $rootName, array $params): void
{
private function cleanInput(
null | string | array | UploadedFile &$input,
string $name,
?string $rootName,
array $params
): void {
if (is_array($input)) {
array_walk_recursive($input, function (null | string | array &$nestedInput, string $nestedName) use ($name, $params): void {
$this->cleanInput($nestedInput, $nestedName, $name, $params);
});
array_walk_recursive(
$input,
function (null | string | array | UploadedFile &$nestedInput, string $nestedName) use ($name, $params): void {
$this->cleanInput($nestedInput, $nestedName, $name, $params);
}
);
}
if (! is_string($input)) {

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Bo;
use App\Actions\Common\ChangeModelOrderPositionInput;
use App\Interfaces\ModelWithOrder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Http\FormRequest;
use Infrastructure\Adapters\Services\AppAdminAuthService;
class ChangeSelectedOrderRequest extends FormRequest
{
public function __construct(
private readonly AppAdminAuthService $authService
) {
}
public function authorize(): bool
{
return $this->authService->check();
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'order' => ['required', 'integer', 'min:1'],
];
}
public function toChangeModelOrderPositionInput(Model & ModelWithOrder $model): ChangeModelOrderPositionInput
{
return new ChangeModelOrderPositionInput(
model: $model,
targetOrder: $this->integer('order'),
);
}
}

View file

@ -25,7 +25,7 @@ class IndexProductRequest extends FormRequest
$tag = $this->resolveFilterValue('tag', $session);
$range = $this->resolveFilterValue('range', $session);
$discount = $this->resolveBooleanFilter('discount', false, $session);
$discount = $this->resolveFilterValue('discount', $session);
$star = $this->resolveBooleanFilter('star', $starred, $session);
$search = $this->filled('search') ? $this->string('search')->value() : null;
@ -85,8 +85,7 @@ class IndexProductRequest extends FormRequest
protected function prepareForValidation(): void
{
$this->merge([
'discount' => $this->boolean('discount'),
'star' => $this->boolean('star'),
'star' => $this->boolean('star'),
]);
}
@ -95,7 +94,7 @@ class IndexProductRequest extends FormRequest
{
return [
'discount' => ['required', 'boolean'],
'discount' => ['sometimes', 'nullable', 'string', 'max:255'],
'star' => ['required', 'boolean'],
'brand' => ['sometimes', 'nullable', 'string', 'min:3', 'max:255'],
];
@ -105,7 +104,7 @@ class IndexProductRequest extends FormRequest
public function attributes(): array
{
return [
'discount' => trans('crud.helpers.discounted'),
'discount' => trans_choice('models.classes.discounts', 1),
'star' => trans('crud.helpers.starred'),
'description' => trans('validation.attributes.description'),
];

View file

@ -117,7 +117,7 @@ class StoreShopRequest extends FormRequest
{
return [
'name' => trans('validation.attributes.name'),
'address' => trans('validation.attributes.address'),
'address' => trans('validation.custom.attributes.postal_address'),
'postcode' => trans('validation.attributes.postal_code'),
'city' => trans('validation.attributes.city'),
'phone' => trans('validation.attributes.phone'),

View file

@ -103,18 +103,6 @@ class UpdateShopPickupExceptionsRequest extends FormRequest
];
}
/** @return array<string, string> */
#[\Override]
public function messages(): array
{
return [
'pickup-exceptions.*.uuid' => 'La fermeture exceptionnelle sélectionnée n\'est pas un identifiant valide.',
'pickup-exceptions.*.exists' => 'La fermeture exceptionnelle sélectionnée n\'existe pas.',
'order_deadline_at.*.before' => 'La date limite de commande doit être strictement antérieure au jour de remplacement.',
'replacement_date.*.required_with' => 'Le jour de remplacement est obligatoire lorsqu\'une date limite de commande est définie.',
];
}
/**
* @return array<string, array{replacement_date: string|null, order_deadline_at: string|null}>
*/

View file

@ -105,7 +105,7 @@ class RegisterRequest extends FormRequest
'firstname' => trans('validation.attributes.first_name'),
'lastname' => trans('validation.attributes.last_name'),
'birth_date' => trans('validation.attributes.date_of_birth'),
'address' => trans('validation.attributes.address'),
'address' => trans('validation.custom.attributes.postal_address'),
'phone' => trans('validation.attributes.phone'),
'city' => trans('validation.attributes.city'),
'postcode' => trans('validation.attributes.postal_code'),

View file

@ -8,6 +8,7 @@ use Domain\Models\ValueObjects\AttachedFile;
use Domain\Models\ValueObjects\ContactFormData;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
class ContactFormRequest extends FormRequest
{
@ -17,11 +18,22 @@ class ContactFormRequest extends FormRequest
return [
'last_name' => ['required', 'string', 'min:2'],
'first_name' => ['required', 'string', 'min:2'],
'email' => ['required', 'email:rfc,dns,spoof'],
'email' => [
'required',
Rule::email()
->rfcCompliant()
->preventSpoofing()
->validateMxRecord(),
],
'subject' => ['required', 'string', 'min:2'],
'order_ref' => ['nullable', 'string', 'regex:/^[0-9]+$/'],
'message' => ['required', 'string', 'min:20', 'max:1900'],
'file' => ['nullable', 'file', 'mimes:jpg,jpeg,png,pdf', 'max:5120'],
'file' => [
'nullable',
Rule::file()
->max(5120)
->types(['jpg', 'jpeg', 'png', 'pdf']),
],
'ok_cgv' => ['accepted'],
];
}
@ -67,4 +79,13 @@ class ContactFormRequest extends FormRequest
file: $attachedFile,
);
}
/** @return array<string,string> */
#[\Override]
public function attributes(): array
{
return [
'subject' => trans('validation.custom.attributes.object'),
];
}
}

View file

@ -86,7 +86,7 @@ class UpdateInfosRequest extends FormRequest
'firstname' => trans('validation.attributes.first_name'),
'lastname' => trans('validation.attributes.last_name'),
'birth_date' => trans('validation.attributes.date_of_birth'),
'address' => trans('validation.attributes.address'),
'address' => trans('validation.custom.attributes.postal_address'),
'phone' => trans('validation.attributes.phone'),
'city' => trans('validation.attributes.city'),
'postcode' => trans('validation.attributes.postal_code'),

View file

@ -6,8 +6,6 @@ namespace App\Http\Resources\Fo\Ajax;
use App\Lib\Helpers\MoneyHelper;
use App\Models\Product;
use Domain\Services\PricingService;
use Domain\Services\ProductAvailabilityService;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Gate;
@ -23,12 +21,10 @@ class FilteredProductResource extends JsonResource
#[\Override]
public function toArray(Request $request): array
{
$productRepository = resolve(EloquentProductRepository::class);
$productAvailabilityService = resolve(ProductAvailabilityService::class);
$pricingService = resolve(PricingService::class);
$productRepository = resolve(EloquentProductRepository::class);
$domainProduct = $productRepository->toEntity($this->product);
$productPricing = $pricingService->calculateForProduct($domainProduct);
$productPricing = $domainProduct->pricing();
$discount = $productPricing->hasDiscount ? $productPricing->discountedPriceTtc() : null;
return [
@ -48,11 +44,10 @@ class FilteredProductResource extends JsonResource
MoneyHelper::serialized($productPricing->priceKgTtc()) : null,
'discountedPriceTtc' => Gate::allows('viewPrices', $this->product) ?
MoneyHelper::serialized($discount) : null,
'is_available' => $productAvailabilityService->isOrderable($domainProduct),
'is_available' => $domainProduct->isOrderable(),
'can_add_to_basket' => Gate::allows('addToBasket', $this->product),
'showStrippedPrice' => $this->product->validDiscount()
->value('is_old_price_displayed') ? true : false,
'nutriscore' => $this->product->inco?->nutriscore?->value(),
'showStrippedPrice' => (bool) $this->product->validDiscount?->is_old_price_displayed,
'nutriscore' => $this->product->inco?->nutriscore?->value(),
];
}
}

View file

@ -70,8 +70,8 @@ class ExceptionsHelper
*
* @template T of mixed
*
* @param array<T> $params
* @return array<T>
* @param array<array-key, T> $params
* @return array<string|T>
*/
private static function getAnonymizedSentryParams(array $params): array
{
@ -89,7 +89,6 @@ class ExceptionsHelper
};
}
// @phpstan-ignore return.type
return $params;
}

View file

@ -277,10 +277,10 @@ final class StringEncoder
foreach ($bytes as $byte) {
$parts[] = match (true) {
// @phpstan-ignore argument.type
$byte <= 0x7F => chr($byte),
isset($overrides[$byte]) => $overrides[$byte],
default => self::encodeLatin1ByteAsUtf8($byte),
// @phpstan-ignore argument.type (ASCII range check)
$byte <= 0x7F => chr($byte),
isset($overrides[$byte]) => $overrides[$byte],
default => self::encodeLatin1ByteAsUtf8($byte),
};
}

View file

@ -16,7 +16,6 @@ use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Enum;
use Illuminate\Validation\ValidationException;
use Propaganistas\LaravelPhone\PhoneNumber;
use Symfony\Component\Finder\SplFileInfo;
@ -179,16 +178,15 @@ class ToolboxHelper extends ToolboxHelperCore
*/
public static function getOriginal(Model $model, string $attribute): mixed
{
if (
\in_array('Spatie\Translatable\HasTranslations', \class_uses_recursive($model)) && // @phpstan-ignore-next-line
$model->isTranslatableAttribute($attribute)
) {
// @phpstan-ignore-next-line
// @phpstan-ignore method.notFound (\Spatie\Translatable\HasTranslations could be absent)
if (\in_array('Spatie\Translatable\HasTranslations', \class_uses_recursive($model)) && $model->isTranslatableAttribute($attribute)) {
// @phpstan-ignore method.notFound (\Spatie\Translatable\HasTranslations could be absent)
return $model->getTranslation(
$attribute,
// @phpstan-ignore-next-line
// @phpstan-ignore method.notFound (\Spatie\Translatable\HasTranslations could be absent)
$model->getLocale(),
// @phpstan-ignore-next-line
// @phpstan-ignore method.notFound (\Spatie\Translatable\HasTranslations could be absent)
$model->useFallbackLocale()
);
}
@ -216,7 +214,7 @@ class ToolboxHelper extends ToolboxHelperCore
throw_unless(preg_match($localeRegex, $currentLocale), \InvalidArgumentException::class, 'Invalid locale identifier.');
/** @var array<string,string> $locales */
$locales = config('app.locales', []);
$locales = Config::array('app.locales', []);
$locales = Collection::make($locales)
->filter(fn (string $locale) => (bool) preg_match($localeRegex, $locale))
->mapWithKeys(fn (string $locale) => [$locale => $locale]);
@ -377,25 +375,6 @@ class ToolboxHelper extends ToolboxHelperCore
);
}
/**
* Get validated enum value from request or return default value if validation fails
*
* @param int $default The default value to return if validation fails.
* @param string $field The request field to validate.
* @param class-string<\UnitEnum> $enumPath The enum class path to validate against.
*/
public static function getValidatedEnum(int $default, string $field, string $enumPath): int
{
try {
return \intval(Validator::make(
[$field => \request()->input($field)],
[$field => new Enum($enumPath)]
)->validated()[$field]);
} catch (ValidationException) {
return $default;
}
}
/**
* Convert Hex color to RGB
*

View file

@ -54,8 +54,12 @@ class Discount extends Model
protected function scopePublished(Builder $query): Builder
{
return $query
->where('date_start', '<=', Date::now())
->where('date_end', '>=', Date::now());
->where(function (Builder $q): void {
$q->whereNull('date_start')->orWhere('date_start', '<=', Date::now());
})
->where(function (Builder $q): void {
$q->whereNull('date_end')->orWhere('date_end', '>=', Date::now());
});
}
/** @return \Illuminate\Database\Eloquent\Relations\HasMany<\App\Models\Product,$this> */

View file

@ -147,8 +147,11 @@ class Product extends Model implements ModelWithOrder, ModelWithStar, ModelWithS
$model->slug = Str::slug($model->name);
$model->picture = FileStorageHelper::storeFile($model, $model->picture, true);
$model->picture_large = FileStorageHelper::storeFile($model, $model->picture_large, true);
// @phpstan-ignore cast.int
$model->order ??= (int) self::query()->max('order');
if ($model->order === null) {
$max = self::query()->max('order');
$model->order = is_scalar($max) ? intval($max) + 1 : 1;
}
}
private static function assertNameIsUnique(?string $name, ?string $id = null): void

View file

@ -96,9 +96,10 @@ class Slide extends Model implements ModelWithOrder, ModelWithStatus
private static function manageFields(self $model): void
{
$model->picture = FileStorageHelper::storeFile($model, $model->picture, true);
if (is_null($model->order)) {
// @phpstan-ignore argument.type
$model->order = intval(self::query()->max('order'));
if ($model->order === null) {
$max = self::query()->max('order');
$model->order = is_scalar($max) ? intval($max) + 1 : 1;
}
}
@ -110,7 +111,7 @@ class Slide extends Model implements ModelWithOrder, ModelWithStatus
if ($actualStatus !== $model->status) {
resolve(Session::class)->flash('bo.info', \trans(
'The publication status is updated according to the scheduled dates. '
. 'To change the publication status, please edit or remove the corresponding dates.'
. 'To change the publication status, please edit or remove the corresponding dates.'
));
}
}

View file

@ -76,9 +76,9 @@ class Tag extends Model implements ModelWithOrder, ModelWithStatus
self::assertLabelIsUnique($model->label, $model->id);
$model->slug = Str::slug($model->label);
if (is_null($model->order)) {
// @phpstan-ignore argument.type
$model->order = intval(self::query()->max('order'));
if ($model->order === null) {
$max = self::query()->max('order');
$model->order = is_scalar($max) ? intval($max) + 1 : 1;
}
}

View file

@ -16,6 +16,7 @@ use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Config;
class MissingOrderReminderNotification extends Notification implements ShouldQueue
{
@ -30,7 +31,7 @@ class MissingOrderReminderNotification extends Notification implements ShouldQue
public function __construct(
private readonly CarbonInterface $pickupDay
) {
$appUrl = config()->string('app.url');
$appUrl = Config::string('app.url');
$appHost = parse_url($appUrl, \PHP_URL_HOST);
if (empty($appHost)) {
@ -39,8 +40,7 @@ class MissingOrderReminderNotification extends Notification implements ShouldQue
$this->appUrl = $appUrl;
$this->appHost = $appHost;
$this->appName = config()
->string('app.name', 'La Boutique de Cocotte');
$this->appName = Config::string('app.name', 'La Boutique de Cocotte');
}
/**

View file

@ -11,13 +11,11 @@ use App\Models\User;
use Domain\Enums\UserRole;
use Domain\Models\ValueObjects\Entities\ProductId;
use Domain\Ports\Repositories\ProductRepository;
use Domain\Services\ProductAvailabilityService;
use Ramsey\Uuid\Uuid;
class ProductPolicy
{
public function __construct(
private readonly ProductAvailabilityService $productService,
private readonly ProductRepository $productRepository
) {
}
@ -129,6 +127,6 @@ class ProductPolicy
$domainProduct = $this->productRepository->find($productId);
return $domainProduct && $this->productService->isPublished($domainProduct);
return $domainProduct && $domainProduct->isPublished();
}
}

View file

@ -8,25 +8,30 @@ use App\Dto\SettingDto;
use App\Models\Customer;
use App\Models\Setting;
use App\Models\User;
use Domain\Enums\UserRole;
class SettingPolicy
{
public function view(User | Customer $user, Setting | SettingDto | null $_ = null): bool
{
if ($user instanceof Customer) {
return false;
}
// FIXME: The Arcadia refresh job dispatching is currently disabled and needs to be enabled when api will be available.
return false;
return $user->role->isAtLeastEqualsTo(UserRole::Admin);
// if ($user instanceof Customer) {
// return false;
// }
// return $user->role->isAtLeastEqualsTo(UserRole::Admin);
}
public function update(User | Customer $user, Setting | SettingDto | null $_ = null): bool
{
if ($user instanceof Customer) {
return false;
}
// FIXME: The Arcadia refresh job dispatching is currently disabled and needs to be enabled when api will be available.
return false;
return $user->role->isAtLeastEqualsTo(UserRole::Admin);
// if ($user instanceof Customer) {
// return false;
// }
// return $user->role->isAtLeastEqualsTo(UserRole::Admin);
}
}

View file

@ -7,12 +7,12 @@ namespace App\Policies\Frontend;
use App\Models\Customer;
use App\Models\User;
use Domain\Models\ValueObjects\BasketProduct;
use Domain\Services\ProductAvailabilityService;
use Domain\Ports\Repositories\ProductRepository;
class BasketPolicy
{
public function __construct(
private readonly ProductAvailabilityService $productService
private readonly ProductRepository $productRepository
) {
}
@ -27,6 +27,8 @@ class BasketPolicy
return false;
}
return $this->productService->isBasketProductPublished($product);
$domainProduct = $this->productRepository->find($product->id);
return $domainProduct && $domainProduct->isPublished();
}
}

View file

@ -8,12 +8,10 @@ use App\Models\Customer;
use App\Models\Product;
use App\Models\User;
use Domain\Ports\Repositories\ProductRepository;
use Domain\Services\ProductAvailabilityService;
class ProductPolicy
{
public function __construct(
private readonly ProductAvailabilityService $productService,
private readonly ProductRepository $productRepository
) {
}
@ -22,14 +20,14 @@ class ProductPolicy
{
$domainProduct = $this->productRepository->find($product->getId());
return $domainProduct && $this->productService->isPublished($domainProduct);
return $domainProduct && $domainProduct->isPublished();
}
public function addToBasket(null | User | Customer $_, Product $product): bool
{
$domainProduct = $this->productRepository->find($product->getId());
return $domainProduct && $this->productService->isOrderable($domainProduct);
return $domainProduct && $domainProduct->isOrderable();
}
public function viewPrices(null | User | Customer $_, Product $product): bool
@ -40,6 +38,6 @@ class ProductPolicy
$domainProduct = $this->productRepository->find($product->getId());
return $domainProduct && $this->productService->isPublished($domainProduct);
return $domainProduct && $domainProduct->isPublished();
}
}

View file

@ -13,6 +13,7 @@ use Domain\Ports\Repositories\ProductRepository;
use Domain\Ports\Repositories\ShopperRepository;
use Domain\Ports\Repositories\ShopRepository;
use Domain\Ports\Services\AdminAuthenticationService;
use Domain\Ports\Services\CsvGenerator;
use Domain\Ports\Services\CustomerAuthenticationService;
use Domain\Ports\Services\EmployeeImportStorageService;
use Domain\Ports\Services\EmployeeStatisticsService;
@ -29,6 +30,7 @@ use Infrastructure\Adapters\Repositories\EloquentShopRepository;
use Infrastructure\Adapters\Repositories\SessionBasketRepository;
use Infrastructure\Adapters\Services\AppAdminAuthService;
use Infrastructure\Adapters\Services\AppCustomerAuthService;
use Infrastructure\Adapters\Services\CsvGeneratorImpl;
use Infrastructure\Adapters\Services\EloquentEmployeeImportStorageService;
use Infrastructure\Adapters\Services\EloquentEmployeeStatisticsService;
use Infrastructure\Adapters\Services\LaravelTransactionManager;
@ -111,6 +113,11 @@ class DomainServiceProvider extends ServiceProvider
EmployeeImportStorageService::class,
EloquentEmployeeImportStorageService::class
);
$this->app->bind(
CsvGenerator::class,
CsvGeneratorImpl::class
);
}
/**

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Providers;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\ServiceProvider;
use Money\Currencies\ISOCurrencies;
use Money\Currency;
@ -29,8 +30,8 @@ class MoneyServiceProvider extends ServiceProvider
$this->app->singleton(
Currency::class,
fn () => new Currency(
// @phpstan-ignore argument.type
config(
// @phpstan-ignore argument.type (Should throw if not provided)
Config::string(
'app.currency',
'EUR'
)
@ -40,8 +41,7 @@ class MoneyServiceProvider extends ServiceProvider
$this->app->singleton(
\NumberFormatter::class,
fn () => new \NumberFormatter(
// @phpstan-ignore argument.type
config('app.locale', 'fr_FR'),
Config::string('app.locale', 'fr_FR'),
\NumberFormatter::CURRENCY
)
);
@ -66,9 +66,7 @@ class MoneyServiceProvider extends ServiceProvider
*/
public function boot()
{
if (! $this->app->runningInConsole()) {
$this->sharetoView();
}
$this->sharetoView();
}
private function sharetoView(): void

View file

@ -14,7 +14,6 @@ use App\Models\Range;
use App\Models\Slide;
use App\Models\Tag;
use Domain\Ports\Repositories\ProductRepository;
use Domain\Services\PricingService;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
@ -109,7 +108,6 @@ class RouteServiceProvider extends ServiceProvider
fn (string $value) => $this->resolvePublishedProductWithPricing($value)
);
// * CSP Route
$this->cspReportRoute();
}
@ -143,7 +141,6 @@ class RouteServiceProvider extends ServiceProvider
private function resolvePublishedProductWithPricing(string $value): ProductWithPricingDto
{
$domainProductRepository = resolve(ProductRepository::class);
$pricingService = resolve(PricingService::class);
$product = Product::query()
->where('slug', $value)
->published()
@ -156,7 +153,7 @@ class RouteServiceProvider extends ServiceProvider
$product->load(['inco', 'validDiscount', 'products']);
$domainProduct = $domainProductRepository->find($product->getId());
$pricing = $domainProduct ? $pricingService->calculateForProduct($domainProduct) : null;
$pricing = $domainProduct?->pricing();
if (! $pricing) {
throw new NotFoundHttpException();

View file

@ -7,7 +7,6 @@ namespace App\Providers;
use App\Models\Page;
use App\Models\Product;
use App\Services\EmployeeSyncService;
use Domain\Services\ProductAvailabilityService;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\View;
@ -23,12 +22,13 @@ class ViewServiceProvider extends ServiceProvider
*/
public function boot(EloquentProductRepository $productRepository)
{
$this->setUpBladeMacros($productRepository);
if ($this->app->runningInConsole() && ! $this->app->runningUnitTests()) {
return;
}
$this->setupPaginator();
$this->setUpBladeMacros($productRepository);
}
private function setupPaginator(): void
@ -54,8 +54,7 @@ class ViewServiceProvider extends ServiceProvider
{
Blade::if(
'orderable',
fn (Product $product) => resolve(ProductAvailabilityService::class)
->isOrderable($productRepository->toEntity($product))
fn (Product $product) => $productRepository->toEntity($product)->isOrderable()
);
}
}

View file

@ -29,14 +29,14 @@ final readonly class GetDiscountFormDataQuery implements Query
if ($input->discount !== null) {
$selectedProds = Product::query()
->select(['id', 'name'])
->select(['id', 'name', 'picture'])
->where('discount_id', $input->discount->id)
->orderBy('name')
->get()
->map(fn (Product $p): DiscountProductDto => DiscountProductDto::fromModel($p));
$existingProds = Product::query()
->select(['id', 'name'])
->select(['id', 'name', 'picture'])
->whereNotIn('id', $selectedProds->pluck('id'))
->orderBy('name')
->get()
@ -46,7 +46,7 @@ final readonly class GetDiscountFormDataQuery implements Query
} else {
$selectedProds = collect();
$existingProds = Product::query()
->select(['id', 'name'])
->select(['id', 'name', 'picture'])
->orderBy('name')
->get()
->map(fn (Product $p): DiscountProductDto => DiscountProductDto::fromModel($p));

View file

@ -68,7 +68,13 @@ final readonly class GetBoPaginatedOrdersQuery implements Query
$query->where('status', $input->status);
}
$this->dataTable->sortQuery(Order::class, $query);
$this->dataTable->sortQuery(
Order::class,
$query,
defaultSortCol: 'date',
defaultSortWay: \SortDirection::Descending,
columnMap: ['date' => 'id'],
);
$paginator = $this->dataTable->paginateQuery($query, $input->pagination);
/** @var LengthAwarePaginator<int, OrderDto> $ordersDtoPaginator */

View file

@ -37,9 +37,15 @@ final readonly class GetBoTodayOrdersQuery implements Query
$query = Order::query()->with(['shop']);
$query->where('pickup_day', Date::now()->toDateString());
$query->whereCreatedAtBetween(Date::now(), Date::now());
$this->dataTable->sortQuery(Order::class, $query);
$this->dataTable->sortQuery(
Order::class,
$query,
defaultSortCol: 'date',
defaultSortWay: \SortDirection::Descending,
columnMap: ['date' => 'id'],
);
$paginator = $this->dataTable->paginateQuery($query, $input->pagination);

View file

@ -7,12 +7,14 @@ namespace App\Queries\Products;
use App\Contracts\Query;
use App\Contracts\QueryInput;
use App\Dto\ProductDto;
use App\Models\Discount;
use App\Models\Product;
use App\Models\Range;
use App\Models\Tag;
use App\Services\DataTableService;
use Domain\Enums\ProductBrand;
use Illuminate\Contracts\Database\Query\Builder;
use Money\Formatter\IntlMoneyFormatter;
use Webmozart\Assert\Assert;
/**
@ -22,6 +24,7 @@ final readonly class GetBoPaginatedProductsQuery implements Query
{
public function __construct(
private DataTableService $dataTable,
private IntlMoneyFormatter $moneyFormatter,
) {
}
@ -53,8 +56,12 @@ final readonly class GetBoPaginatedProductsQuery implements Query
$query->where('star', true);
}
if ($input->discount) {
$query->whereHas('discount');
if ($input->discount !== null && $input->discount !== '') {
if ($input->discount === '0') {
$query->whereHas('discount');
} else {
$query->where('discount_id', $input->discount);
}
}
if (! empty($input->tag)) {
@ -67,13 +74,19 @@ final readonly class GetBoPaginatedProductsQuery implements Query
$this->dataTable->sortQuery(Product::class, $query, ['inco']);
$paginator = $this->dataTable->paginateQuery($query, $input->pagination);
/** @var \Illuminate\Contracts\Pagination\LengthAwarePaginator<int, ProductDto> $productsDtoPaginator */
$paginator = $this->dataTable->paginateQuery($query, $input->pagination);
/** @var \Illuminate\Contracts\Pagination\LengthAwarePaginator<array-key, \App\Dto\ProductDto> $productsDtoPaginator */
$productsDtoPaginator = $paginator->through(fn (Product $product): ProductDto => ProductDto::fromModel($product));
$brands = ProductBrand::cases();
$ranges = Range::query()->pluck('name', 'id');
$tags = Tag::query()->pluck('label', 'id');
$discounts = Discount::query()
->orderByDesc('id')
->get()
->mapWithKeys(fn (Discount $d): array => [
$d->id => $this->formatDiscountLabel($d),
]);
$searchFields = str(collect([
trans('validation.attributes.name'),
trans('validation.custom.attributes.inco_id'),
@ -84,7 +97,27 @@ final readonly class GetBoPaginatedProductsQuery implements Query
brands: $brands,
ranges: $ranges,
tags: $tags,
discounts: $discounts,
searchFields: $searchFields,
);
}
private function formatDiscountLabel(Discount $discount): string
{
$formattedValue = $discount->value->isPercentage()
? $discount->value->getPercentage() . ' %'
: $this->moneyFormatter->format($discount->value->getAmount());
$label = "#{$discount->id} - {$discount->type->label()} ({$formattedValue})";
if ($discount->date_start !== null && $discount->date_end !== null) {
$label .= " [{$discount->date_start->format('d/m/Y')} - {$discount->date_end->format('d/m/Y')}]";
} elseif ($discount->date_start !== null) {
$label .= ' [' . __('from :date', ['date' => $discount->date_start->format('d/m/Y')]) . ']';
} elseif ($discount->date_end !== null) {
$label .= ' [' . __('until :date', ['date' => $discount->date_end->format('d/m/Y')]) . ']';
}
return $label;
}
}

View file

@ -14,7 +14,7 @@ final readonly class GetBoPaginatedProductsQueryInput implements QueryInput
public ?string $search = null,
public ?string $brand = null,
public bool $starred = false,
public bool $discount = false,
public ?string $discount = null,
public ?string $tag = null,
public ?string $range = null,
) {

View file

@ -16,12 +16,14 @@ final readonly class GetBoPaginatedProductsQueryOutput
* @param list<ProductBrand> $brands
* @param Collection<int|string, mixed> $ranges
* @param Collection<int|string, mixed> $tags
* @param Collection<int, string> $discounts
*/
public function __construct(
public LengthAwarePaginator $products,
public array $brands,
public Collection $ranges,
public Collection $tags,
public Collection $discounts,
public string $searchFields,
) {
}

View file

@ -58,6 +58,7 @@ final readonly class GetFoProductIndexPageQuery implements Query
$tagList = Tag::query()
->published()
->orderBy('order')
->get()
->map(fn (Tag $tag): TagDto => TagDto::fromModel($tag));

View file

@ -11,7 +11,6 @@ use App\Dto\ProductWithPricingDto;
use App\Models\Product;
use App\Services\DataTableService;
use Domain\Ports\Repositories\ProductRepository;
use Domain\Services\PricingService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Webmozart\Assert\Assert;
@ -26,7 +25,6 @@ final readonly class GetPaginatedProductsQuery implements Query
public function __construct(
private DataTableService $dataTable,
private ProductRepository $domainProductRepository,
private PricingService $pricingService,
) {
}
@ -61,7 +59,7 @@ final readonly class GetPaginatedProductsQuery implements Query
$result = $paginator->through(function (Product $product): ProductWithPricingDto {
$domainProduct = $this->domainProductRepository->find($product->getId());
Assert::notNull($domainProduct, "Domain product not found for ID {$product->id}");
$pricing = $this->pricingService->calculateForProduct($domainProduct);
$pricing = $domainProduct->pricing();
return new ProductWithPricingDto(
product: $product,

View file

@ -98,11 +98,15 @@ class DataTableService
* @param class-string<TModel> $modelClass
* @param TBuilder $query
* @param array<int, string> $ignore Columns to ignore for sorting.
* @param array<string, string> $columnMap Map virtual column names to database columns.
*/
public function sortQuery(
string $modelClass,
Builder $query,
array $ignore = []
array $ignore = [],
?string $defaultSortCol = null,
SortDirection $defaultSortWay = SortDirection::Ascending,
array $columnMap = [],
): Builder {
$table = (new $modelClass())->getTable();
$rName = $this->request->route()?->getName() ?? 'noroute';
@ -115,15 +119,22 @@ class DataTableService
$sort_col = $sort['sort_col'];
$sort_way = $sort['sort_way'];
if ((! $sort_col || $sort_col === 'undefined') && $defaultSortCol !== null) {
$sort_col = $defaultSortCol;
$sort_way = $defaultSortWay;
}
$this->persistSortSession($rName, $sort_col, $sort_way);
if ($sort_col && $sort_col !== 'undefined' && $sort_way && ! in_array($sort_col, $ignore) && $this->hasTableColumn($table, $sort_col)) {
$dbCol = $columnMap[$sort_col] ?? $sort_col;
if ($sort_col && $sort_col !== 'undefined' && $sort_way && ! in_array($sort_col, $ignore) && $this->hasTableColumn($table, $dbCol)) {
$direction = match ($sort_way) {
SortDirection::Descending => 'desc',
default => 'asc',
};
$query->orderBy("{$table}.{$sort_col}", $direction);
$query->orderBy("{$table}.{$dbCol}", $direction);
} elseif ($this->hasTableColumn($table, self::ORDER_COLUMN)) {
$query->orderBy(self::ORDER_COLUMN, 'asc');
@ -196,7 +207,7 @@ class DataTableService
return array_values(array_filter(
$cast::cases(),
fn ($item): bool => str_contains(
// @phpstan-ignore method.notFound
// @phpstan-ignore method.notFound (PhpStan cannot detect the label() method on BaseEnum cases)
mb_strtolower((string) $item->label()),
$searchLower
)

View file

@ -6,8 +6,10 @@ namespace App\Traits\Controllers;
use App\Actions\Common\ChangeModelOrder;
use App\Actions\Common\ChangeModelOrderInput;
use App\Actions\Common\ChangeModelOrderPosition;
use App\Contracts\Executor;
use App\Http\Requests\Bo\ChangeOrderRequest;
use App\Http\Requests\Bo\ChangeSelectedOrderRequest;
use App\Interfaces\ModelWithOrder;
use App\Services\FlashMessageService;
use Illuminate\Database\Eloquent\Model;
@ -36,4 +38,27 @@ trait ChangesModelOrder
return back();
}
public function changeSelectedOrder(
Model & ModelWithOrder $model,
ChangeSelectedOrderRequest $request,
ChangeModelOrderPosition $action,
Executor $executor,
FlashMessageService $flashMessageService
): RedirectResponse {
$changed = $executor->execute(
$action,
$request->toChangeModelOrderPositionInput($model)
);
if (! $changed) {
$flashMessageService->error(trans('crud.messages.order_not_changed'));
return back();
}
$flashMessageService->success(trans('crud.messages.order_changed'));
return back();
}
}

View file

@ -15,7 +15,7 @@ return [
|
*/
'default' => env('FILESYSTEM_DISK', 'local'),
'default' => env('FILESYSTEM_DISK') ?: 'local',
/*
|--------------------------------------------------------------------------

View file

@ -3,5 +3,5 @@
declare(strict_types=1);
return [
'headquarters_phone_reception' => env('LDC_HEADQUARTERS_PHONE_RECEPTION', '02 43 62 70 00'),
'headquarters_phone_reception' => env('LDC_HEADQUARTERS_PHONE_RECEPTION') ?: '02 43 62 70 00',
];

158
config/logging.php Normal file
View file

@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that is utilized to write
| messages to your logs. The value provided here should match one of
| the channels present in the list of "channels" configured below.
|
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
|--------------------------------------------------------------------------
| Deprecations Log Channel
|--------------------------------------------------------------------------
|
| This option controls the log channel that should be used to log warnings
| regarding deprecated PHP and library features. This allows you to get
| your application ready for upcoming major versions of dependencies.
|
*/
'deprecations' => [
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
],
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Laravel
| utilizes the Monolog PHP logging library, which includes a variety
| of powerful log handlers and formatters that you're free to use.
|
| Available drivers: "single", "daily", "monthly", "slack", "syslog",
| "errorlog", "monolog", "custom", "stack"
|
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'max_files' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
'monthly' => [
'driver' => 'monthly',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'max_files' => 3,
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
'connectionString' => 'tls://' . env('PAPERTRAIL_URL') . ':' . env('PAPERTRAIL_PORT'),
],
'processors' => [PsrLogMessageProcessor::class],
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'handler_with' => [
'stream' => 'php://stderr',
],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
'stdout' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'formatter' => env('LOG_STDOUT_FORMATTER'),
'with' => [
'stream' => 'php://stdout',
],
],
'k8s' => [
'driver' => 'stack',
'channels' => ['daily', 'stdout'],
'ignore_exceptions' => false,
],
],
];

View file

@ -16,7 +16,7 @@ return [
|
*/
'default' => env('MAIL_MAILER', 'log'),
'default' => env('MAIL_MAILER') ?: 'log',
/*
|--------------------------------------------------------------------------

View file

@ -61,5 +61,5 @@ return [
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#traces-sample-rate.
'traces_sample_rate' => env('SENTRY_TRACES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_TRACES_SAMPLE_RATE'),
'before_send' => ExceptionsHelper::sentryEventHandler(...),
'before_send' => [ExceptionsHelper::class, 'sentryEventHandler'],
];

View file

@ -19,7 +19,7 @@ return [
* | "memcached", "redis", "dynamodb", "array"
* |
*/
'driver' => env('SESSION_DRIVER', 'file'),
'driver' => env('SESSION_DRIVER') ?: 'file',
/**
* |--------------------------------------------------------------------------

View file

@ -17,7 +17,7 @@ enum DiscountType: int
/**
* Optionnal labels definition
*
* @phpstan-ignore-next-line
* @phpstan-ignore-next-line classConstant.unused (Base enum usage)
*/
private const LABELS = [
self::AMOUNT->name => 'Montant',

View file

@ -30,7 +30,7 @@ enum EmployeeSite: string
/**
* Optionnal labels definition
*
* @phpstan-ignore-next-line
* @phpstan-ignore-next-line classConstant.unused (Base enum usage)
*/
private const LABELS = [
self::slhp->name => 'SLHP',

View file

@ -18,7 +18,7 @@ enum OrderStatus: int
/**
* Optional labels definition
*
* @phpstan-ignore-next-line
* @phpstan-ignore-next-line classConstant.unused (Base enum usage)
*/
private const LABELS = [
self::WAITING->name => 'En attente',

View file

@ -17,7 +17,7 @@ enum PriceShowingMode: string
/**
* Optionnal labels definition
*
* @phpstan-ignore-next-line
* @phpstan-ignore-next-line classConstant.unused (Base enum usage)
*/
private const LABELS = [
self::authenticated->name => 'Client Connecté',

View file

@ -27,7 +27,7 @@ enum ProductBrand: string
/**
* Optionnal labels definition
*
* @phpstan-ignore-next-line
* @phpstan-ignore-next-line classConstant.unused (Base enum usage)
*/
private const LABELS = [
self::none->name => 'Sans marque',

View file

@ -18,7 +18,7 @@ enum RepeatFrequency: string
/**
* Optionnal labels definition
*
* @phpstan-ignore-next-line
* @phpstan-ignore-next-line classConstant.unused (Base enum usage)
*/
private const LABELS = [
self::WEEKLY->name => 'Toutes les semaines',

View file

@ -18,7 +18,7 @@ enum SellUnit: string
/**
* Optionnal labels definition
*
* @phpstan-ignore-next-line
* @phpstan-ignore-next-line classConstant.unused (Base enum usage)
*/
private const LABELS = [
self::PCE->name => 'Pièce',

View file

@ -20,7 +20,7 @@ enum StaticPageSlug: string
/**
* Optionnal labels definition
*
* @phpstan-ignore-next-line
* @phpstan-ignore-next-line classConstant.unused (Base enum usage)
*/
private const LABELS = [
self::CONTACT->name => 'contact',

View file

@ -18,7 +18,7 @@ enum StatsCustomerType: int
/**
* Optionnal labels definition
*
* @phpstan-ignore-next-line
* @phpstan-ignore-next-line classConstant.unused (Base enum usage)
*/
private const LABELS = [
self::nbSalarie->name => 'Nombre de salariés',

View file

@ -28,7 +28,7 @@ enum StatsShopType: int
/**
* Optionnal labels definition
*
* @phpstan-ignore-next-line
* @phpstan-ignore-next-line classConstant.unused (Base enum usage)
*/
private const LABELS = [
self::nbActiveClients->name => 'Nombre de clients actifs',

View file

@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Domain\Enums;
use Domain\Services\PricingService;
use Domain\Models\Entities\Product;
use Money\Money;
use Shared\Enums\BaseEnum;
@ -19,7 +19,7 @@ enum TaxRate: int
/**
* Optional labels definition
*
* @phpstan-ignore-next-line
* @phpstan-ignore-next-line classConstant.unused (Base enum usage)
*/
private const LABELS = [
self::TVA_20->name => 'TVA 20%',
@ -34,6 +34,17 @@ enum TaxRate: int
};
}
public static function fromPercent(float $percent): self
{
return match ($percent) {
self::TVA_20->percent() => self::TVA_20,
self::TVA_5_5->percent() => self::TVA_5_5,
default => self::tryFrom((int) $percent) ?? throw new \UnexpectedValueException(
sprintf('Unhandled tax rate: %s', $percent)
),
};
}
public function rate(): float
{
return $this->percent() / 100;
@ -54,6 +65,6 @@ enum TaxRate: int
public function applyTo(Money $price): Money
{
return $price->multiply($this->multiplier(), PricingService::ROUNDING);
return $price->multiply($this->multiplier(), Product::ROUNDING);
}
}

View file

@ -18,7 +18,7 @@ enum InvoicingUnit: string
/**
* Optionnal labels definition
*
* @phpstan-ignore-next-line
* @phpstan-ignore-next-line classConstant.unused (Base enum usage)
*/
private const LABELS = [
self::PCE->name => 'Pièce',

View file

@ -15,10 +15,19 @@ enum OrderUnit: string
case KGM = 'KGM';
case COLIS = 'COLIS';
public function toRegateCode(): string
{
return match ($this) {
self::COLIS => 'UEA',
self::KGM => 'KGM',
self::PCE => 'UB',
};
}
/**
* Optionnal labels definition
*
* @phpstan-ignore-next-line
* @phpstan-ignore-next-line classConstant.unused (Base enum usage)
*/
private const LABELS = [
self::PCE->name => 'Pièce',

View file

@ -6,6 +6,7 @@ namespace Domain\Events\Dto;
use Domain\Enums\Units\OrderUnit;
use Domain\Models\ValueObjects\IncoId;
use Domain\Models\ValueObjects\OrderLine;
use Money\Money;
final readonly class OrderPlacedLine
@ -23,4 +24,17 @@ final readonly class OrderPlacedLine
throw new \DomainException('Quantity must be greater than 0');
}
}
public static function fromOrderLine(OrderLine $line): self
{
return new self(
productName: $line->product->name,
productIncoId: clone $line->product->incoId,
productWeightIsVariable: $line->product->weight->isVariable,
productPriceKgTtc: clone $line->product->priceKgTtc,
productOrderUnit: $line->product->orderUnit,
quantity: $line->quantity,
totalTtc: clone $line->totalTtc
);
}
}

View file

@ -4,6 +4,8 @@ declare(strict_types=1);
namespace Domain\Events\Dto;
use Domain\Models\ValueObjects\DeliveryAddress;
final readonly class OrderPlacedShop
{
public function __construct(
@ -25,4 +27,14 @@ final readonly class OrderPlacedShop
throw new \DomainException('Shop city is required');
}
}
public static function fromDeliveryAddress(DeliveryAddress $address): self
{
return new self(
name: $address->name,
address: $address->address,
postcode: $address->postcode,
city: $address->city,
);
}
}

View file

@ -4,7 +4,12 @@ declare(strict_types=1);
namespace Domain\Models\Entities;
use Carbon\CarbonImmutable;
use Domain\Enums\OrderStatus;
use Domain\Events\AnOrderHasBeenPlaced;
use Domain\Events\Dto\OrderPlacedLine;
use Domain\Events\Dto\OrderPlacedShop;
use Domain\Models\ValueObjects\BasketProduct;
use Domain\Models\ValueObjects\DeliveryAddress;
use Domain\Models\ValueObjects\Entities\OrderId;
use Domain\Models\ValueObjects\Entities\ShopId;
@ -65,4 +70,47 @@ final class Order implements Entity
$this->status = OrderStatus::CANCELED;
}
public static function fromBasket(
OrderId $id,
OrderReference $reference,
Basket $basket,
PickupTimeSlot $pickupTimeSlot,
Shopper $shopper,
Shop $shop
): self {
$lines = array_map(
fn (BasketProduct $item) => OrderLine::fromBasketProduct($item),
array_values($basket->items())
);
return new self(
id: $id,
shopId: clone $shop->id(),
reference: $reference,
employee: OrderEmployee::fromShopper($shopper),
deliveryAddress: DeliveryAddress::fromShop($shop),
pickupTimeSlot: clone $pickupTimeSlot,
status: OrderStatus::VALIDATED,
total: clone $basket->totalTaxIncluded(),
lines: $lines
);
}
public function toPlacedEvent(): AnOrderHasBeenPlaced
{
return new AnOrderHasBeenPlaced(
orderId: $this->id(),
reference: $this->reference,
totalTtc: $this->getTotal(),
byMatricule: $this->employee->matricule,
employee: clone $this->employee,
shop: OrderPlacedShop::fromDeliveryAddress($this->deliveryAddress),
pickupDay: CarbonImmutable::instance($this->pickupTimeSlot->getAt()),
lines: array_map(
fn (OrderLine $line) => OrderPlacedLine::fromOrderLine($line),
$this->lines
)
);
}
}

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Domain\Models\Entities;
use Carbon\CarbonInterface;
use Carbon\WeekDay;
use Domain\Enums\RepeatFrequency;
use Domain\Models\ValueObjects\Entities\PickupExceptionId;
use Shared\DDD\Entity;
@ -27,4 +28,9 @@ final readonly class PickupExceptionOnDate implements Entity
{
return $this->id;
}
public function isPickupDay(WeekDay $pickupDay = WeekDay::Friday): bool
{
return $this->date->isDayOfWeek($pickupDay->value);
}
}

View file

@ -5,11 +5,14 @@ declare(strict_types=1);
namespace Domain\Models\Entities;
use Carbon\CarbonImmutable;
use Carbon\CarbonInterface;
use Domain\Enums\SellUnit;
use Domain\Enums\TaxRate;
use Domain\Enums\Units\InvoicingUnit;
use Domain\Models\ValueObjects\DiscountRate;
use Domain\Models\ValueObjects\DiscountValue;
use Domain\Models\ValueObjects\Entities\ProductId;
use Domain\Models\ValueObjects\ProductPricing;
use Domain\Models\ValueObjects\ProductWeight;
use Money\Money;
use Shared\DDD\Entity;
@ -17,6 +20,10 @@ use Shared\DDD\Entity;
/** @implements \Shared\DDD\Entity<\Domain\Models\ValueObjects\Entities\ProductId> */
final readonly class Product implements Entity
{
public const int ROUNDING = Money::ROUND_UP;
private const int GRAMS_PER_KG = 1000;
public function __construct(
private ProductId $id,
public Money $price,
@ -37,4 +44,145 @@ final readonly class Product implements Entity
{
return $this->id;
}
public function canBeOrderOn(?CarbonInterface $date = null): bool
{
$target = $date ?? CarbonImmutable::now();
if ($this->orderable_start && $target < $this->orderable_start) {
return false;
}
if ($this->orderable_end && $target > $this->orderable_end) {
return false;
}
return $this->status && $this->hasInco;
}
public function isOrderable(?CarbonInterface $date = null): bool
{
return $this->canBeOrderOn($date);
}
public function isPublished(): bool
{
return $this->status;
}
public function hasInco(): bool
{
return $this->hasInco;
}
public function hasDiscount(): bool
{
return $this->validDiscount !== null;
}
public function basePriceHt(): Money
{
if ($this->invoicing_unit === InvoicingUnit::KGM && $this->sell_unit === SellUnit::PCE && $this->product_weight) {
// * Price per kg => price of the piece: price x grams / 1000 (single rounding, no float)
return $this->price
->multiply($this->product_weight->toGrams())
->divide(self::GRAMS_PER_KG, self::ROUNDING);
}
return $this->price;
}
public function basePriceTtc(): Money
{
return $this->tax_rate->applyTo($this->basePriceHt());
}
public function discountAmountHt(): Money
{
$basePriceHt = $this->basePriceHt();
$discountAmount = $this->validDiscount?->calculateDiscountAmount($basePriceHt)
?? new Money(0, $basePriceHt->getCurrency());
if ($discountAmount->greaterThan($basePriceHt)) {
return $basePriceHt;
}
return $discountAmount;
}
public function discountedPriceHt(): Money
{
return $this->basePriceHt()->subtract($this->discountAmountHt());
}
public function discountedPriceTtc(): Money
{
return $this->tax_rate->applyTo($this->discountedPriceHt());
}
public function priceKgTtc(): Money
{
if ($this->invoicing_unit === InvoicingUnit::KGM) {
// * The product price is already a price per kg: it is the reference for the kg price
$discountAmountKgHt = $this->validDiscount?->calculateDiscountAmount($this->price)
?? new Money(0, $this->price->getCurrency());
$grams = $this->product_weight?->toGrams() ?? 0;
if (
$this->validDiscount !== null
&& ! $this->validDiscount->isPercentage()
&& $this->sell_unit === SellUnit::PCE
&& $grams > 0
) {
// * A fixed amount applies to the piece: bring it back to a per-kg amount
$discountAmountKgHt = $discountAmountKgHt
->multiply(self::GRAMS_PER_KG)
->divide($grams, self::ROUNDING);
}
$discountedKgPriceHt = $this->price->subtract($discountAmountKgHt);
if ($discountedKgPriceHt->isNegative()) {
$discountedKgPriceHt = new Money(0, $this->price->getCurrency());
}
return $this->tax_rate->applyTo($discountedKgPriceHt);
}
$priceTtc = $this->discountedPriceTtc();
$grams = $this->product_weight?->toGrams() ?? 0;
// NOTE: If the product is not published or not sold by piece, we don't need to convert the price to per kg
if ($grams > 0 && $this->status && $this->invoicing_unit === InvoicingUnit::PCE) {
// * Price of the piece => price per kg: price x 1000 / grams (single rounding, no float)
return $priceTtc
->multiply(self::GRAMS_PER_KG)
->divide($grams, self::ROUNDING);
}
return $priceTtc;
}
public function discountRate(): DiscountRate
{
if ($this->validDiscount?->isPercentage()) {
return DiscountRate::fromPercentage($this->validDiscount->getPercentage());
}
return DiscountRate::fromAmounts($this->discountAmountHt(), $this->basePriceHt());
}
public function pricing(): ProductPricing
{
return new ProductPricing(
basePriceHt: $this->basePriceHt(),
basePriceTtc: $this->basePriceTtc(),
discountAmountHt: $this->discountAmountHt(),
discountedPriceHt: $this->discountedPriceHt(),
discountedPriceTtc: $this->discountedPriceTtc(),
priceKgTtc: $this->priceKgTtc(),
discountRate: $this->discountRate(),
hasDiscount: $this->hasDiscount()
);
}
}

View file

@ -7,8 +7,8 @@ namespace Domain\Models\ValueObjects;
use Domain\Enums\ProductBrand;
use Domain\Enums\TaxRate;
use Domain\Enums\Units\OrderUnit;
use Domain\Models\Entities\Product;
use Domain\Models\ValueObjects\Entities\ProductId;
use Domain\Services\PricingService;
use Money\Money;
final readonly class BasketProduct
@ -58,22 +58,29 @@ final readonly class BasketProduct
return $this->unitPriceHt;
}
public function unitPriceDiscounted(): Money
{
return $this->unitPriceHt->subtract($this->unitDiscount);
}
public function unitTax(): Money
{
return $this->unitTtc()->subtract($this->unitPriceDiscounted());
}
public function unitTtc(): Money
{
$unitPriceDiscounted = $this->unitPriceHt->subtract($this->unitDiscount);
// applyTo renvoie déjà le montant TTC (900 + 180 = 1080)
return $this->taxRate->applyTo($unitPriceDiscounted);
return $this->taxRate->applyTo($this->unitPriceDiscounted());
}
public function discountTotal(): Money
{
return $this->unitDiscount->multiply($this->qty, PricingService::ROUNDING);
return $this->unitDiscount->multiply($this->qty, Product::ROUNDING);
}
public function priceTotalHt(): Money
{
return $this->unitPriceHt->multiply($this->qty, PricingService::ROUNDING);
return $this->unitPriceHt->multiply($this->qty, Product::ROUNDING);
}
public function subtotalHt(): Money

View file

@ -4,6 +4,8 @@ declare(strict_types=1);
namespace Domain\Models\ValueObjects;
use Domain\Models\Entities\Shop;
final readonly class DeliveryAddress
{
public function __construct(
@ -18,6 +20,19 @@ final readonly class DeliveryAddress
$this->validate();
}
public static function fromShop(Shop $shop): self
{
return new self(
name: $shop->name,
address: $shop->address,
postcode: $shop->postcode,
city: $shop->city,
phone: $shop->phone,
latitude: $shop->latitude,
longitude: $shop->longitude
);
}
private function validate(): void
{
if (trim($this->name) === '') {

View file

@ -5,9 +5,14 @@ declare(strict_types=1);
namespace Domain\Models\ValueObjects;
use Domain\Enums\DiscountType;
use Domain\Services\PricingService;
use Domain\Models\Entities\Product;
use Money\Money;
/**
* Represents a product discount (either a percentage or a fixed amount).
*
* Business rule: All discounts apply to the piece (sold unit), regardless of the product's invoicing unit.
*/
final readonly class DiscountValue
{
private function __construct(
@ -113,6 +118,6 @@ final readonly class DiscountValue
$rate = $this->getPercentageRate();
return $basePrice->multiply((string) $rate, PricingService::ROUNDING);
return $basePrice->multiply((string) $rate, Product::ROUNDING);
}
}

View file

@ -4,6 +4,8 @@ declare(strict_types=1);
namespace Domain\Models\ValueObjects;
use Domain\Models\Entities\Shopper;
final readonly class OrderEmployee
{
public function __construct(
@ -18,6 +20,18 @@ final readonly class OrderEmployee
$this->assertNotEmpty('lastname', $lastname);
}
public static function fromShopper(Shopper $shopper): self
{
return new self(
matricule: clone $shopper->matricule,
firstname: $shopper->prenom,
lastname: $shopper->nom,
facility: $shopper->etablissement,
site: $shopper->site,
service: $shopper->service
);
}
private function assertNotEmpty(string $attribute, string $value): void
{
if (empty(trim($value))) {

View file

@ -51,4 +51,19 @@ final readonly class OrderLine
throw new \DomainException('Total TTC must be non-negative');
}
}
public static function fromBasketProduct(BasketProduct $item): self
{
return new self(
product: OrderLineProduct::fromBasketProduct($item),
quantity: $item->qty,
unitPriceHt: clone $item->unitPriceHt,
unitDiscount: clone $item->unitDiscount,
unitTax: clone $item->unitTax(),
totalHt: clone $item->subtotalHt(),
totalDiscount: clone $item->discountTotal(),
totalTax: clone $item->taxTotal(),
totalTtc: clone $item->totalTtc()
);
}
}

View file

@ -44,4 +44,21 @@ final readonly class OrderLineProduct
throw new \DomainException('Price per kg TTC has to be positive');
}
}
public static function fromBasketProduct(BasketProduct $item): self
{
return new self(
incoId: $item->incoId ?? throw new \DomainException('incoId cannot be null for an order'),
originalId: $item->id,
name: $item->name,
weight: new ProductWeight($item->weightGrams, $item->weightVariable),
brand: $item->brand ?? ProductBrand::none,
orderUnit: $item->orderUnit,
price: clone $item->unitPriceHt,
discountedPrice: $item->unitPriceDiscounted(),
priceKgTtc: clone $item->priceKgTtc,
taxRate: $item->taxRate,
picture: $item->picture
);
}
}

View file

@ -4,7 +4,9 @@ declare(strict_types=1);
namespace Domain\Models\ValueObjects;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
use Shared\Exceptions\InvalidUuidException;
use Shared\Helpers\UuidHelper;
final readonly class OrderReference implements \Stringable
@ -17,9 +19,29 @@ final readonly class OrderReference implements \Stringable
}
}
public static function fromString(string $value): self
{
if (UuidHelper::isValid($value)) {
return new self(Uuid::fromString($value));
}
return self::fromBase58($value);
}
public static function fromBase58(string $base58): self
{
try {
$uuid = UuidHelper::fromBase58($base58);
} catch (InvalidUuidException) {
throw new \DomainException('Order reference must be a valid base-58 string');
}
return new self($uuid);
}
#[\Override]
public function __toString(): string
{
return $this->value->toString();
return UuidHelper::toBase58($this->value);
}
}

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Domain\Models\ValueObjects;
use Carbon\CarbonImmutable;
use Carbon\CarbonInterface;
use Domain\Models\Entities\PickupExceptionOnDate;
@ -20,4 +21,21 @@ final readonly class ShopPickupOverride
);
}
}
public function hasReplacement(): bool
{
return $this->replacementDate !== null;
}
public function effectiveDeadline(CarbonInterface $defaultDeadline): CarbonImmutable
{
return $this->orderDeadlineAt !== null
? CarbonImmutable::createFromInterface($this->orderDeadlineAt)
: CarbonImmutable::createFromInterface($defaultDeadline);
}
public function isDeadlinePassed(CarbonInterface $now, CarbonInterface $defaultDeadline): bool
{
return $now->greaterThan($this->effectiveDeadline($defaultDeadline));
}
}

View file

@ -4,20 +4,13 @@ declare(strict_types=1);
namespace Domain\Services;
use Domain\Enums\OrderStatus;
use Domain\Enums\ProductBrand;
use Domain\Models\Entities\Basket;
use Domain\Models\Entities\Order;
use Domain\Models\Entities\Shop;
use Domain\Models\Entities\Shopper;
use Domain\Models\ValueObjects\DeliveryAddress;
use Domain\Models\ValueObjects\Entities\OrderId;
use Domain\Models\ValueObjects\OrderEmployee;
use Domain\Models\ValueObjects\OrderLine;
use Domain\Models\ValueObjects\OrderLineProduct;
use Domain\Models\ValueObjects\OrderReference;
use Domain\Models\ValueObjects\PickupTimeSlot;
use Domain\Models\ValueObjects\ProductWeight;
use Domain\Ports\Repositories\OrderRepository;
use Ramsey\Uuid\Uuid;
@ -34,62 +27,13 @@ class OrderService
Shopper $shopper,
Shop $shop
): Order {
$lines = [];
foreach ($basket->items() as $item) {
$orderLineProduct = new OrderLineProduct(
incoId: $item->incoId ?? throw new \DomainException('incoId cannot be null for an order'),
originalId: $item->id,
name: $item->name,
weight: new ProductWeight($item->weightGrams, $item->weightVariable),
brand: $item->brand ?? ProductBrand::none,
orderUnit: $item->orderUnit,
price: clone $item->unitPriceHt,
discountedPrice: $item->unitPriceHt->subtract($item->unitDiscount),
priceKgTtc: clone $item->priceKgTtc,
taxRate: $item->taxRate,
picture: $item->picture
);
$lines[] = new OrderLine(
product: $orderLineProduct,
quantity: $item->qty,
unitPriceHt: clone $item->unitPriceHt,
unitDiscount: clone $item->unitDiscount,
unitTax: clone $item->taxRate
->applyTo($item->unitPriceHt
->subtract($item->unitDiscount))
->subtract($item->unitPriceHt
->subtract($item->unitDiscount)),
totalHt: clone $item->subtotalHt(),
totalDiscount: clone $item->discountTotal(),
totalTax: clone $item->taxTotal(),
totalTtc: clone $item->totalTtc()
);
}
return new Order(
return Order::fromBasket(
id: $this->newOrderId(),
shopId: clone $shop->id(),
reference: $this->newReference(),
employee: new OrderEmployee(
matricule: clone $shopper->matricule,
firstname: $shopper->prenom,
lastname: $shopper->nom,
facility: $shopper->etablissement,
site: $shopper->site,
service: $shopper->service
),
deliveryAddress: new DeliveryAddress(
name: $shop->name,
address: $shop->address,
postcode: $shop->postcode,
city: $shop->city,
phone: $shop->phone
),
pickupTimeSlot: clone $pickupTimeSlot,
status: OrderStatus::VALIDATED,
total: clone $basket->totalTaxIncluded(),
lines: $lines
basket: $basket,
pickupTimeSlot: $pickupTimeSlot,
shopper: $shopper,
shop: $shop
);
}

View file

@ -10,9 +10,6 @@ use Domain\Builders\OrderBuilder;
use Domain\Builders\OrderBuilderCompleted;
use Domain\Builders\OrderBuilderWithBasket;
use Domain\Enums\OrderTunnelStep;
use Domain\Events\AnOrderHasBeenPlaced;
use Domain\Events\Dto\OrderPlacedLine;
use Domain\Events\Dto\OrderPlacedShop;
use Domain\Exceptions\OrderTunnel\MissingOrderBuilderException;
use Domain\Exceptions\OrderTunnel\OrderLimitExceededException;
use Domain\Exceptions\OrderTunnel\WeeklyLimitExceededException;
@ -21,7 +18,6 @@ use Domain\Models\Entities\Order;
use Domain\Models\Entities\Shop;
use Domain\Models\Entities\Shopper;
use Domain\Models\ValueObjects\Entities\ShopperId;
use Domain\Models\ValueObjects\OrderLine;
use Domain\Models\ValueObjects\PickupTimeSlot;
use Domain\Policies\CustomerOrderLimitPolicy;
use Domain\Ports\Repositories\OrderRepository;
@ -219,8 +215,7 @@ class OrderTunnelService
$this->orderRepository->persist($order);
$event = $this->mapNewOrderEvent($order);
$this->eventDispatcher->dispatch($event);
$this->eventDispatcher->dispatch($order->toPlacedEvent());
$this->basketService->empty();
$this->orderBuilderSessionManager->clear();
@ -233,39 +228,4 @@ class OrderTunnelService
{
return CarbonImmutable::now()->addDays(self::TIMESLOTS_DAY_TARGET);
}
private function mapNewOrderEvent(Order $order): AnOrderHasBeenPlaced
{
return new AnOrderHasBeenPlaced(
orderId: $order->id(),
byMatricule: $order->employee->matricule,
employee: clone $order->employee,
reference: $order->reference,
totalTtc: $order->getTotal(),
shop: new OrderPlacedShop(
name: $order->deliveryAddress->name,
address: $order->deliveryAddress->address,
postcode: $order->deliveryAddress->postcode,
city: $order->deliveryAddress->city
),
pickupDay: CarbonImmutable::instance($order->pickupTimeSlot->getAt()),
lines: array_map(
[$this, 'mapToPlaceOrderLine'],
$order->getLines()
)
);
}
private function mapToPlaceOrderLine(OrderLine $line): OrderPlacedLine
{
return new OrderPlacedLine(
productName: $line->product->name,
productIncoId: clone $line->product->incoId,
productWeightIsVariable: $line->product->weight->isVariable,
productPriceKgTtc: clone $line->product->priceKgTtc,
productOrderUnit: $line->product->orderUnit,
quantity: $line->quantity,
totalTtc: clone $line->totalTtc
);
}
}

View file

@ -71,11 +71,6 @@ class PickupSchedulingService
return CarbonPeriod::create($defaultDeadLine, $pickupDate->endOfDay());
}
public function isPickupDay(PickupExceptionOnDate $exception): bool
{
return $exception->date->isDayOfWeek(self::PICKUP_DAY->value);
}
/** @return \Domain\Models\ValueObjects\EditablePickupExceptionForShop[] */
public function getEditablePickupExceptionsFor(Shop $shop): array
{
@ -85,24 +80,23 @@ class PickupSchedulingService
return array_map(function (PickupExceptionOnDate $pickupException) use ($shopExceptions) {
$associated = null;
foreach ($shopExceptions as $shopEx) {
if ($shopEx->exception->id->value === $pickupException->id->value) {
if ($shopEx->exception->id->equals($pickupException->id)) {
$associated = $shopEx;
break;
}
}
$isChecked = $associated !== null;
$isPickupDay = $this->isPickupDay($pickupException);
$deadline = $associated?->orderDeadlineAt
? CarbonImmutable::createFromInterface($associated->orderDeadlineAt)
: $this->getDefaultDeadline(CarbonImmutable::createFromInterface($pickupException->date));
$isChecked = $associated !== null;
$isPickupDay = $pickupException->isPickupDay(self::PICKUP_DAY);
$defaultDeadline = $this->getDefaultDeadline($pickupException->date);
$isBlocked = $associated !== null && ! $associated->hasReplacement();
$isPastDeadline = $associated !== null && $associated->isDeadlinePassed($this->getNow(), $defaultDeadline);
return new EditablePickupExceptionForShop(
$pickupException,
$isPickupDay,
$isChecked && $isPickupDay && $associated->replacementDate === null,
$isChecked && $isPickupDay && $this->getNow()->greaterThan($deadline)
$isChecked && $isPickupDay && $isBlocked,
$isChecked && $isPickupDay && $isPastDeadline
);
}, $allExceptions);
}
@ -160,23 +154,15 @@ class PickupSchedulingService
protected function getTimeSlotForException(ShopPickupOverride $shopPickupException): ?PickupTimeSlot
{
$replacement = $shopPickupException->replacementDate;
// ? Is there a substitute date configured ?
if ($replacement === null) {
// * Blocked week: exception active but no replacement provided
if (! $shopPickupException->hasReplacement() || $shopPickupException->replacementDate === null) {
return null;
}
// ? Is there an order deadline ?
$deadline = $shopPickupException->orderDeadlineAt
// * Use exception deadline.
? CarbonImmutable::createFromInterface($shopPickupException->orderDeadlineAt)
// * Use default deadline.
: $this->getDefaultDeadline(CarbonImmutable::createFromInterface($shopPickupException->exception->date));
$defaultDeadline = $this->getDefaultDeadline(CarbonImmutable::createFromInterface($shopPickupException->exception->date));
$deadline = $shopPickupException->effectiveDeadline($defaultDeadline);
return $this->isBefore($deadline)
? $this->createTimeSlot($replacement->toImmutable())
? $this->createTimeSlot($shopPickupException->replacementDate->toImmutable())
: null;
}
@ -195,12 +181,16 @@ class PickupSchedulingService
->addDays(self::CUTOFF_DAY->value - 1)
->addHours(self::CUTOFF_HOUR);
// @phpstan-ignore notIdentical.alwaysFalse
return self::CUTOFF_WEEK_OFFSET !== 0
? $deadline->addWeeks(self::CUTOFF_WEEK_OFFSET)
return $this->getCutoffWeekOffset() !== 0
? $deadline->addWeeks($this->getCutoffWeekOffset())
: $deadline;
}
private function getCutoffWeekOffset(): int
{
return self::CUTOFF_WEEK_OFFSET;
}
protected function isBefore(CarbonInterface $date): bool
{
return $this->getNow()->lessThanOrEqualTo($date);

View file

@ -1,90 +0,0 @@
<?php
declare(strict_types=1);
namespace Domain\Services;
use Domain\Enums\SellUnit;
use Domain\Enums\TaxRate;
use Domain\Enums\Units\InvoicingUnit;
use Domain\Models\Entities\Product;
use Domain\Models\ValueObjects\DiscountRate;
use Domain\Models\ValueObjects\ProductPricing;
use Money\Money;
class PricingService
{
public const ROUNDING = Money::ROUND_UP;
private const GRAMS_PER_KG = 1000;
public function calculateForProduct(Product $product): ProductPricing
{
$basePriceHt = $this->priceHt($product);
$basePriceTtc = $this->applyTax($basePriceHt, $product->tax_rate);
$discountAmountHt = $product->validDiscount?->calculateDiscountAmount($basePriceHt)
?? new Money(0, $basePriceHt->getCurrency());
$discountedPriceHt = $basePriceHt->subtract($discountAmountHt);
$discountedPriceTtc = $this->applyTax($discountedPriceHt, $product->tax_rate);
$priceKgTtc = $this->calculatePriceKgTtc($discountedPriceHt, $product);
$discountRate = $this->calculateDiscountRate($basePriceHt, $discountAmountHt, $product);
return new ProductPricing(
basePriceHt: $basePriceHt,
basePriceTtc: $basePriceTtc,
discountAmountHt: $discountAmountHt,
discountedPriceHt: $discountedPriceHt,
discountedPriceTtc: $discountedPriceTtc,
priceKgTtc: $priceKgTtc,
discountRate: $discountRate,
hasDiscount: $product->validDiscount !== null
);
}
private function priceHt(Product $product): Money
{
if ($product->invoicing_unit === InvoicingUnit::KGM && $product->sell_unit === SellUnit::PCE && $product->product_weight) {
// * Price per kg => price of the piece: price × grams ÷ 1000 (single rounding, no float)
return $product->price
->multiply($product->product_weight->toGrams())
->divide(self::GRAMS_PER_KG, PricingService::ROUNDING);
}
return $product->price;
}
private function calculatePriceKgTtc(Money $discountedPriceHt, Product $product): Money
{
$priceTtc = $this->applyTax($discountedPriceHt, $product->tax_rate);
$grams = $product->product_weight?->toGrams() ?? 0;
if ($grams > 0 && $product->status && $product->invoicing_unit === InvoicingUnit::PCE) {
// * Price of the piece => price per kg: price × 1000 ÷ grams (single rounding, no float)
return $priceTtc
->multiply(self::GRAMS_PER_KG)
->divide($grams, self::ROUNDING);
}
return $priceTtc;
}
private function applyTax(Money $price, TaxRate $taxRate): Money
{
return $taxRate->applyTo($price);
}
private function calculateDiscountRate(Money $priceHt, Money $discountAmount, Product $product): DiscountRate
{
$discount = $product->validDiscount;
if ($discount?->isPercentage()) {
return DiscountRate::fromPercentage($discount->getPercentage());
}
return DiscountRate::fromAmounts($discountAmount, $priceHt);
}
}

View file

@ -1,50 +0,0 @@
<?php
declare(strict_types=1);
namespace Domain\Services;
use Carbon\CarbonImmutable;
use Domain\Models\Entities\Product;
use Domain\Models\ValueObjects\BasketProduct;
use Domain\Ports\Repositories\ProductRepository;
class ProductAvailabilityService
{
public function __construct(
private readonly ProductRepository $productRepository
) {
}
public function isOrderable(Product $product): bool
{
$now = CarbonImmutable::now();
$start = ! $product->orderable_start || $now->gt($product->orderable_start);
$end = ! $product->orderable_end || $now->lt($product->orderable_end);
$isOrderable = $start && $end;
return $isOrderable && $product->status && $this->isAssociatedWithInco($product);
}
public function isPublished(Product $product): bool
{
return $product->status;
}
public function isBasketProductPublished(BasketProduct $basketProduct): bool
{
$product = $this->productRepository->find($basketProduct->id);
if (! $product) {
// NOTE: Don't throw an exception here, as the product may have been deleted after being added to the basket
return false;
}
return $this->isPublished($product);
}
public function isAssociatedWithInco(Product $product): bool
{
return $product->hasInco;
}
}

View file

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Domain\Services;
use Carbon\CarbonImmutable;
use Domain\Enums\Units\OrderUnit as UnitsOrderUnit;
use Domain\Events\Dto\RegateOrderCsvGenerated;
use Domain\Models\ValueObjects\Matricule;
use Domain\Models\ValueObjects\OrderReference;
@ -54,7 +53,7 @@ class RegateService
(string) $line->productIncoId->value,
'commande ferme',
(string) $line->productOrderUnit->value,
$this->maptoRegateUnit($line->productOrderUnit),
$line->productOrderUnit->toRegateCode(),
'',
$pickupDay->format('d/m/Y'),
(string) $line->quantity,
@ -77,13 +76,4 @@ class RegateService
new RegateOrderCsvGenerated($csvContent, $fileName)
);
}
private function maptoRegateUnit(UnitsOrderUnit $unit): string
{
return match ($unit) {
UnitsOrderUnit::COLIS => 'UEA',
UnitsOrderUnit::KGM => 'KGM',
UnitsOrderUnit::PCE => 'UB',
};
}
}

View file

@ -36,11 +36,7 @@ class ShopperService
{
$shopper = $this->getShopperFor($shopperId);
if ($shopper === null) {
return null;
}
return $this->resolveShopForEmployee($shopper);
return $shopper !== null ? $this->resolveShopForEmployee($shopper) : null;
}
public function resolveShopForEmployee(Shopper $shopper): Shop
@ -72,12 +68,6 @@ class ShopperService
public function canCustomerPassOrdersApp(ShopperId $shopperId): bool
{
$shopper = $this->getShopperFor($shopperId);
if ($shopper === null) {
return false;
}
return $shopper->isActive();
return $this->getShopperFor($shopperId)?->isActive() ?? false;
}
}

View file

@ -62,7 +62,7 @@ class EloquentPickupExceptionRepository implements PickupExceptionRepository
->where('date', '>', $period->getStartDate()->toImmutable())
->where('date', '<=', $period->getEndDate()?->toImmutable())
->get()
->map(fn (PickupException $ex) => $this->toShopDomain($ex))
->map(fn (PickupException $ex) => $this->toShopDomain($ex, $ex->pivot))
->all();
}

View file

@ -11,17 +11,11 @@ use Domain\Models\Entities\Product as DomainProduct;
use Domain\Models\ValueObjects\BasketProduct;
use Domain\Models\ValueObjects\Entities\ProductId;
use Domain\Ports\Repositories\ProductRepository;
use Domain\Services\PricingService;
use Illuminate\Support\Carbon;
use Ramsey\Uuid\Uuid;
class EloquentProductRepository implements ProductRepository
{
public function __construct(
private readonly PricingService $pricingService
) {
}
#[\Override]
public function find(ProductId $id): ?DomainProduct
{
@ -72,11 +66,12 @@ class EloquentProductRepository implements ProductRepository
public function getBasketProduct(ProductId $id, int $qty): BasketProduct
{
$product = Product::query()
->with(['inco', 'validDiscount'])
->where('id', $id->value)
->firstOrFail();
$domainProduct = $this->toEntity($product);
$pricing = $this->pricingService->calculateForProduct($domainProduct);
$pricing = $domainProduct->pricing();
return new BasketProduct(
id: $id,

View file

@ -46,6 +46,7 @@
"Agree": "Agree",
"Aisles": "Aisles",
"All aisles": "All aisles",
"All discounts": "All discounts",
"All products have been selected.": "All products have been selected.",
"All rights reserved.": "All rights reserved.",
"Allergens": "Allergens",
@ -96,7 +97,7 @@
"Basket has been cleared": "Basket has been cleared",
"Basket Summary": "Basket Summary",
"Before proceeding, please check your email for a verification link.": "Before proceeding, please check your email for a verification link.",
"Bloqued week": "Bloqued week",
"Blocked week": "Blocked week",
"Booking": "Booking",
"Brand": "Brand",
"Breadcrumb": "Breadcrumb",
@ -285,6 +286,7 @@
"Frequency": "Frequency",
"Frequently asked questions.": "Frequently asked questions.",
"from": "from",
"from :date": "from :date",
"From date": "From date",
"Gateway Timeout": "Gateway Timeout",
"general conditions": "general conditions",
@ -730,6 +732,7 @@
"The answer": "The answer",
"The brand associated with the product": "The brand associated with the product",
"The customer account was not found.": "The customer account was not found.",
"The discount applies to the piece (sold unit).": "The discount applies to the piece (sold unit).",
"The employee sync job has been dispatched": "The employee sync job has been dispatched",
"The existing encrypted environment file is not in readable format. Use --force to overwrite it.": "The existing encrypted environment file is not in readable format. Use --force to overwrite it.",
"The existing encryption key is required to update the encrypted environment file.": "The existing encryption key is required to update the encrypted environment file.",
@ -795,6 +798,7 @@
"Unprocessable Entity": "Unprocessable Entity",
"Unsubscribe": "Unsubscribe",
"Unsupported Media Type": "Unsupported Media Type",
"until :date": "until :date",
"Up": "Up",
"Update": "Update",
"Update :name": "Update :name",
@ -867,5 +871,11 @@
"Your password has been changed on :appname": "Your password has been changed on :appname",
"Your password has been changed. If you are not at the origin of this change, please contact us as soon as possible using the contact form or by contacting your manager.": "Your password has been changed. If you are not at the origin of this change, please contact us as soon as possible using the contact form or by contacting your manager.",
"your_message": "your_message",
"Zip code": "Zip code"
}
"Zip code": "Zip code",
"Your search": "Your search",
"Choose position": "Choose position",
"Move": "Move",
"Move down": "Move down",
"Move up": "Move up",
"New position": "New position"
}

View file

@ -26,7 +26,8 @@ return [
'delete_model' => 'Delete :model',
'save_model' => 'Save :model',
'edit_model' => 'Update :model',
'no_results' => 'No results for :model',
'no_results' => 'No results',
'no_results_for_model' => 'No results for :model',
'publish' => 'Publish',
'unpublish' => 'Unpublish',
'starred' => 'Highlighted',

View file

@ -17,7 +17,7 @@
":title Pick up your order :/title :desc :/desc": ":title Retirez :/title :desc votre commande :/desc",
":title Save money :/title :desc Get even more out of our gourmet products by buying directly :/desc": ":title Des économies :/title :desc Profitez encore plus de nos produits gourmands en achetant en direct :/desc",
":title Select :/title :desc your products online :/desc": ":title Sélectionnez :/title :desc vos produits en ligne :/desc",
":title Select :/title :desc your time slot :/desc": ":title Choisissez :/title :desc votre créneau horaire :/desc",
":title Select :/title :desc your pickup day :/desc": ":title Choisissez :/title :desc votre jour de retrait :/desc",
"A confirmation has just been sent to your email address": "Une confirmation vient de vous être envoyée sur votre adresse e-mail",
"A decryption key is required.": "Une clé de déchiffrement est requise.",
"A fresh verification link has been sent to your email address.": "Un nouveau lien de vérification a été envoyé à votre adresse e-mail.",
@ -45,6 +45,7 @@
"Agree": "Accepter",
"Aisles": "Rayons",
"All aisles": "Tous les rayons",
"All discounts": "Toutes les promotions",
"All products have been selected.": "Tous les produits ont été sélectionnés.",
"All rights reserved.": "Tous droits réservés.",
"Allergens": "Allergènes",
@ -95,7 +96,7 @@
"Basket has been cleared": "Le panier a été vidé",
"Basket Summary": "Récapitulatif de votre panier",
"Before proceeding, please check your email for a verification link.": "Avant de continuer, merci de vérifier votre e-mail contenant un lien de vérification.",
"Bloqued week": "Semaine bloquée",
"Blocked week": "Semaine bloquée",
"Booking": "Réservation",
"Brand": "Marque",
"Breadcrumb": "Fil d'Ariane",
@ -282,6 +283,7 @@
"Frequency": "Fréquence",
"Frequently asked questions.": "Foire aux questions fréquemment posées.",
"from": "de",
"from :date": "du :date",
"From date": "Du",
"Gateway Timeout": "Temps d'attente de la passerelle dépassé",
"general conditions": "conditions générales",
@ -445,7 +447,7 @@
"No pickup exception has been created for now.": "Aucune exception de retrait n'a été créée pour le moment.",
"No picture available": "Aucune image disponible",
"No product matches your search.": "Aucun produit ne correspond à votre recherche.",
"No replacement date configured": "Pas de jour de remplacemenet configuré",
"No replacement date configured": "Pas de jour de remplacement configuré",
"No results found.": "Aucun résultat trouvé.",
"No site configured. Click the button below to add a site.": "Aucun site configuré. Cliquez sur le bouton ci-dessous pour ajouter un site.",
"No substitute days have been set up. No time slots will be generated for this week.": "Aucun jour de remplacement configuré. Aucun créneau ne sera généré pour cette semaine.",
@ -719,6 +721,7 @@
"The answer": "La réponse",
"The brand associated with the product": "La marque associée au produit",
"The customer account was not found.": "Le compte client n'est pas trouvé.",
"The discount applies to the piece (sold unit).": "La remise s'applique à la pièce (unité de vente).",
"The employee sync job has been dispatched": "Le job de synchronisation des salariés a été lancé",
"The existing encrypted environment file is not in readable format. Use --force to overwrite it.": "The existing encrypted environment file is not in readable format. Use --force to overwrite it.",
"The existing encryption key is required to update the encrypted environment file.": "The existing encryption key is required to update the encrypted environment file.",
@ -782,6 +785,7 @@
"Unprocessable Entity": "Entité non traitable",
"Unsubscribe": "Se désabonner",
"Unsupported Media Type": "Type de média non supporté",
"until :date": "au :date",
"Up": "Monter",
"Update": "Mettre à jour",
"Update :name": "Mettre à jour :name",
@ -854,5 +858,11 @@
"Your password has been changed on :appname": "Votre mot de passe a été changé sur :appname",
"Your password has been changed. If you are not at the origin of this change, please contact us as soon as possible using the contact form or by contacting your manager.": "Votre mot de passe a été changé. Si vous n'êtes pas à l'origine de ceci, veuillez nous contacter au plus vite en utilisant le formulaire de contact ou en vous rapprochant de votre responsable.",
"your_message": "Votre message",
"Zip code": "Code postal"
}
"Zip code": "Code postal",
"Your search": "Votre recherche",
"Choose position": "Choisir une position",
"Move": "Déplacer",
"Move down": "Descendre d'un cran",
"Move up": "Monter d'un cran",
"New position": "Nouvelle position"
}

View file

@ -15,25 +15,26 @@ return [
],
'helpers' => [
'list' => 'Liste',
'view_associated' => 'Voir les :model liés',
'list_all' => 'Lister tous les :model',
'create_model' => 'Créer :model',
'view_model' => 'Voir :model',
'associate_model' => 'Associer :model',
'update_model' => 'Éditer :model',
'duplicate_model' => 'Dupliquer :model',
'delete_model' => 'Supprimer :model',
'save_model' => 'Sauvegarder :model',
'edit_model' => 'Mettre à jour :model',
'no_results' => 'Aucun résultat pour :model',
'publish' => 'Publier',
'unpublish' => 'Dépublier',
'starred' => 'Mis en avant',
'discounted' => 'En promotion',
'star' => 'Mettre en avant',
'unstar' => 'Ne pas mettre en avant',
'seo_title' => 'Doit décrire précisément la page, recommandation pour un positionnement
'list' => 'Liste',
'view_associated' => 'Voir les :model liés',
'list_all' => 'Lister tous les :model',
'create_model' => 'Créer :model',
'view_model' => 'Voir :model',
'associate_model' => 'Associer :model',
'update_model' => 'Éditer :model',
'duplicate_model' => 'Dupliquer :model',
'delete_model' => 'Supprimer :model',
'save_model' => 'Sauvegarder :model',
'edit_model' => 'Mettre à jour :model',
'no_results' => 'Aucun résultat',
'no_results_for_model' => 'Aucun résultat pour :model',
'publish' => 'Publier',
'unpublish' => 'Dépublier',
'starred' => 'Mis en avant',
'discounted' => 'En promotion',
'star' => 'Mettre en avant',
'unstar' => 'Ne pas mettre en avant',
'seo_title' => 'Doit décrire précisément la page, recommandation pour un positionnement
optimal dans les moteurs de recherche (balise <title>).',
'seo_description' => 'Doit décrire précisément le contenu de la page (balise <meta> description).',
'required_fields' => 'Champs obligatoires',

View file

@ -350,6 +350,7 @@ return [
'nv_serving_size_g' => 'Taille d\'une portion (g)',
'nv_serving_sugar_g' => 'Sucres par portion (g)',
'nv_sugar_g' => 'Sucres (g)',
'object' => 'objet',
'ok_cgv' => 'conditions générales de vente',
'optin' => 'inscription à la newsletter',
'order' => 'ordre',
@ -370,6 +371,7 @@ return [
'picture_large_title' => 'titre de l\'image large',
'picture_title' => 'titre de l\'image',
'preparation' => 'Préparation',
'postal_address' => 'adresse postale',
'question' => 'question',
'reference' => 'référence',
'repeat_frequency' => 'fréquence de répétition',

View file

@ -72,6 +72,9 @@ spec:
containers:
- name: ${CI_PROJECT_PATH_SLUG}-container
image: $IMAGE_TAG
env:
- name: XDG_CONFIG_HOME # For tinker
value: /tmp
envFrom:
- configMapRef:
name: ${CI_PROJECT_PATH_SLUG}-config-map
@ -111,9 +114,9 @@ spec:
imagePullSecrets:
- name: docker-registry-credentials
securityContext:
runAsUser: 33
runAsGroup: 33
fsGroup: 33
runAsUser: 82
runAsGroup: 82
fsGroup: 82
fsGroupChangePolicy: "OnRootMismatch"
volumes:
- name: storage
@ -197,8 +200,8 @@ spec:
persistentVolumeClaim:
claimName: ${CI_PROJECT_PATH_SLUG}-pvc-storage
securityContext:
runAsUser: 33
runAsGroup: 33
fsGroup: 33
runAsUser: 82
runAsGroup: 82
fsGroup: 82
fsGroupChangePolicy: "OnRootMismatch"
restartPolicy: OnFailure

View file

@ -22,6 +22,7 @@
<env name="APP_ENV" value="testing"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="mysql"/>
<!-- <env name="DB_DATABASE" value="testing_db"/> -->
<env name="MAIL_MAILER" value="array"/>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 47 KiB

View file

@ -21,3 +21,16 @@
.stretched-link::after {
cursor: pointer;
}
.btn-tertiary {
border-color: var(--bs-dark) !important;
}
.focus-ring-dark {
--bs-focus-ring-color: rgb(var(--bs-dark-rgb), 0.25) !important;
}
.btn-tertiary:hover {
background-color: rgb(var(--bs-light-rgb), 0.85) !important;
}

View file

@ -2,6 +2,7 @@
$primary: $color-primary;
$secondary: $color-secondary;
$tertiary: $color-tertiary;
$success: $color-success;
$info: $color-info;
$warning: $color-warning;
@ -11,6 +12,7 @@ $dark: $color-dark;
$custom-theme-colors: (
primary: $primary,
secondary: $secondary,
tertiary: $tertiary,
success: $success,
info: $info,
warning: $warning,
@ -63,3 +65,8 @@ $form-file-button-bg: var(--bs-secondary-bg, #e9ecef);
$form-file-button-color: var(--bs-body-color, #212529);
$form-file-button-hover-bg: var(--bs-secondary-bg, #e9ecef);
$form-file-button-hover-color: var(--bs-body-color, #212529);
// * ACCORDION
$accordion-color: var(--bs-body-color);
$accordion-button-active-bg: var(--bs-primary);
$accordion-button-active-color: var(--bs-light-text);

View file

@ -10,6 +10,7 @@ $color-secondary: #5c636a;
$color-info: #045487;
$color-warning: #ffc454;
$color-primary: $color-cocotte;
$color-tertiary: #f8f6ed;
// * BODY
$body-bg: #f8f9fa;

Some files were not shown because too many files have changed in this diff Show more