Compare commits

..

No commits in common. "develop" and "massive-bump" have entirely different histories.

191 changed files with 1584 additions and 4192 deletions

View file

@ -15,7 +15,7 @@ include:
file: "build/composer.yml"
- project: "ldc-web/shared-ci"
ref: "main"
file: "build/docker-multistage.yml"
file: "build/docker.yml"
- project: "ldc-web/shared-ci"
ref: "main"
file: "test/test.yml"
@ -313,16 +313,3 @@ 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,5 +1,3 @@
ARG PHP_8_5_TAG=latest
FROM composer:2.9 AS vendor
ARG GITLAB_USER
@ -10,7 +8,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 \
@ -37,13 +35,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/ /app/srcs/public/
COPY --chown=${USER}:${USER} ./resources/favicon/front/ /app/srcs/public/
COPY --chown=${USER}:${USER} ./resources/favicon/back/ /app/srcs/public/back/
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,17 +2,16 @@
## 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 |
@ -27,23 +26,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,9 +7,7 @@ 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;
@ -32,17 +30,8 @@ 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 = $baseQuery
$adjacentModel = $model::query()
->where('order', $goUp ? '<' : '>', $currentOrder)
->orderBy('order', $goUp ? SortDirection::Descending : SortDirection::Ascending)
->first();

View file

@ -1,102 +0,0 @@
<?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

@ -1,22 +0,0 @@
<?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,32 +24,23 @@ final class UpdateCustomer implements Action
#[\Override]
public function __invoke(ActionInput $input): UpdateCustomerOutput
{
Assert::isInstanceOf($input, UpdateCustomerInput::class);
Assert::isInstanceOf($input, UnsubscribeFromNewsletterInput::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,
$data
[
'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,
]
);
return new UpdateCustomerOutput($result);

View file

@ -35,13 +35,6 @@ 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(
@ -57,8 +50,14 @@ 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: TaxRate::fromPercent($taxRate),
picture: is_string($picture = Arr::get($data, 'picture')) ? $picture : null,
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),
);
}

View file

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

View file

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

View file

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

View file

@ -16,6 +16,8 @@ abstract class AuthController extends Controller
{
public function __construct()
{
// @phpstan-ignore assign.propertyType
ResetPassword::$createUrlCallback = $this->createResetUrl(...);
VerifyEmail::$createUrlCallback = $this->createValidationUrl(...);
}
@ -48,12 +50,8 @@ abstract class AuthController extends Controller
);
}
public function createResetUrl(mixed $notifiable, string $token): string
public function createResetUrl(Customer $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,37 +34,35 @@ class CustomerController extends Controller
{
$this->authorize('manageOwnProfile', Customer::class);
$output = $this->executor->execute(
$crmCustomer = $this->executor->execute(
$getCrmCustomerQuery,
new GetCrmCustomerQueryInput()
);
abort_unless(
$output->customer instanceof CrmCustomer,
$crmCustomer->customer instanceof CrmCustomer,
503,
trans('An error occurred, please try again later.')
);
return view('fo.customer.edit.infos', [
'crmCustomer' => $output->customer,
]);
return view('fo.customer.edit.infos', compact('crmCustomer'));
}
public function editInfosSubmit(UpdateInfosRequest $request, UpdateCustomer $updateCustomer): RedirectResponse
{
$this->authorize('manageOwnProfile', Customer::class);
$output = $this->executor->execute(
$result = $this->executor->execute(
$updateCustomer,
$request->toUpdateCustomerInput()
);
return match (true) {
$output->result instanceof CrmCustomer => back()
return match ($result) {
$result instanceof CrmCustomer => back()
->with('success', trans('Your information has been saved.')),
$output->result instanceof ValidationException => back()
$result instanceof ValidationException => back()
->withInput()
->withErrors($output->result->getMessage()),
->withErrors($result->getMessage()),
default => back()
->withInput()
->withErrors(trans('An error occurred, please try again later.')),

View file

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

View file

@ -1,42 +0,0 @@
<?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->resolveFilterValue('discount', $session);
$discount = $this->resolveBooleanFilter('discount', false, $session);
$star = $this->resolveBooleanFilter('star', $starred, $session);
$search = $this->filled('search') ? $this->string('search')->value() : null;
@ -85,7 +85,8 @@ class IndexProductRequest extends FormRequest
protected function prepareForValidation(): void
{
$this->merge([
'star' => $this->boolean('star'),
'discount' => $this->boolean('discount'),
'star' => $this->boolean('star'),
]);
}
@ -94,7 +95,7 @@ class IndexProductRequest extends FormRequest
{
return [
'discount' => ['sometimes', 'nullable', 'string', 'max:255'],
'discount' => ['required', 'boolean'],
'star' => ['required', 'boolean'],
'brand' => ['sometimes', 'nullable', 'string', 'min:3', 'max:255'],
];
@ -104,7 +105,7 @@ class IndexProductRequest extends FormRequest
public function attributes(): array
{
return [
'discount' => trans_choice('models.classes.discounts', 1),
'discount' => trans('crud.helpers.discounted'),
'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.custom.attributes.postal_address'),
'address' => trans('validation.attributes.address'),
'postcode' => trans('validation.attributes.postal_code'),
'city' => trans('validation.attributes.city'),
'phone' => trans('validation.attributes.phone'),

View file

@ -103,6 +103,18 @@ 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.custom.attributes.postal_address'),
'address' => trans('validation.attributes.address'),
'phone' => trans('validation.attributes.phone'),
'city' => trans('validation.attributes.city'),
'postcode' => trans('validation.attributes.postal_code'),

View file

@ -8,7 +8,6 @@ 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
{
@ -18,22 +17,11 @@ class ContactFormRequest extends FormRequest
return [
'last_name' => ['required', 'string', 'min:2'],
'first_name' => ['required', 'string', 'min:2'],
'email' => [
'required',
Rule::email()
->rfcCompliant()
->preventSpoofing()
->validateMxRecord(),
],
'email' => ['required', 'email:rfc,dns,spoof'],
'subject' => ['required', 'string', 'min:2'],
'order_ref' => ['nullable', 'string', 'regex:/^[0-9]+$/'],
'message' => ['required', 'string', 'min:20', 'max:1900'],
'file' => [
'nullable',
Rule::file()
->max(5120)
->types(['jpg', 'jpeg', 'png', 'pdf']),
],
'file' => ['nullable', 'file', 'mimes:jpg,jpeg,png,pdf', 'max:5120'],
'ok_cgv' => ['accepted'],
];
}
@ -79,13 +67,4 @@ 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.custom.attributes.postal_address'),
'address' => trans('validation.attributes.address'),
'phone' => trans('validation.attributes.phone'),
'city' => trans('validation.attributes.city'),
'postcode' => trans('validation.attributes.postal_code'),

View file

@ -6,6 +6,8 @@ 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;
@ -21,10 +23,12 @@ class FilteredProductResource extends JsonResource
#[\Override]
public function toArray(Request $request): array
{
$productRepository = resolve(EloquentProductRepository::class);
$productRepository = resolve(EloquentProductRepository::class);
$productAvailabilityService = resolve(ProductAvailabilityService::class);
$pricingService = resolve(PricingService::class);
$domainProduct = $productRepository->toEntity($this->product);
$productPricing = $domainProduct->pricing();
$productPricing = $pricingService->calculateForProduct($domainProduct);
$discount = $productPricing->hasDiscount ? $productPricing->discountedPriceTtc() : null;
return [
@ -44,10 +48,11 @@ class FilteredProductResource extends JsonResource
MoneyHelper::serialized($productPricing->priceKgTtc()) : null,
'discountedPriceTtc' => Gate::allows('viewPrices', $this->product) ?
MoneyHelper::serialized($discount) : null,
'is_available' => $domainProduct->isOrderable(),
'is_available' => $productAvailabilityService->isOrderable($domainProduct),
'can_add_to_basket' => Gate::allows('addToBasket', $this->product),
'showStrippedPrice' => (bool) $this->product->validDiscount?->is_old_price_displayed,
'nutriscore' => $this->product->inco?->nutriscore?->value(),
'showStrippedPrice' => $this->product->validDiscount()
->value('is_old_price_displayed') ? true : false,
'nutriscore' => $this->product->inco?->nutriscore?->value(),
];
}
}

View file

@ -70,8 +70,8 @@ class ExceptionsHelper
*
* @template T of mixed
*
* @param array<array-key, T> $params
* @return array<string|T>
* @param array<T> $params
* @return array<T>
*/
private static function getAnonymizedSentryParams(array $params): array
{
@ -89,6 +89,7 @@ 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 (ASCII range check)
$byte <= 0x7F => chr($byte),
isset($overrides[$byte]) => $overrides[$byte],
default => self::encodeLatin1ByteAsUtf8($byte),
// @phpstan-ignore argument.type
$byte <= 0x7F => chr($byte),
isset($overrides[$byte]) => $overrides[$byte],
default => self::encodeLatin1ByteAsUtf8($byte),
};
}

View file

@ -16,6 +16,7 @@ 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;
@ -178,15 +179,16 @@ class ToolboxHelper extends ToolboxHelperCore
*/
public static function getOriginal(Model $model, string $attribute): mixed
{
// @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)
if (
\in_array('Spatie\Translatable\HasTranslations', \class_uses_recursive($model)) && // @phpstan-ignore-next-line
$model->isTranslatableAttribute($attribute)
) {
// @phpstan-ignore-next-line
return $model->getTranslation(
$attribute,
// @phpstan-ignore method.notFound (\Spatie\Translatable\HasTranslations could be absent)
// @phpstan-ignore-next-line
$model->getLocale(),
// @phpstan-ignore method.notFound (\Spatie\Translatable\HasTranslations could be absent)
// @phpstan-ignore-next-line
$model->useFallbackLocale()
);
}
@ -214,7 +216,7 @@ class ToolboxHelper extends ToolboxHelperCore
throw_unless(preg_match($localeRegex, $currentLocale), \InvalidArgumentException::class, 'Invalid locale identifier.');
/** @var array<string,string> $locales */
$locales = Config::array('app.locales', []);
$locales = config('app.locales', []);
$locales = Collection::make($locales)
->filter(fn (string $locale) => (bool) preg_match($localeRegex, $locale))
->mapWithKeys(fn (string $locale) => [$locale => $locale]);
@ -375,6 +377,25 @@ 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,12 +54,8 @@ class Discount extends Model
protected function scopePublished(Builder $query): Builder
{
return $query
->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());
});
->where('date_start', '<=', Date::now())
->where('date_end', '>=', Date::now());
}
/** @return \Illuminate\Database\Eloquent\Relations\HasMany<\App\Models\Product,$this> */

View file

@ -147,11 +147,8 @@ 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);
if ($model->order === null) {
$max = self::query()->max('order');
$model->order = is_scalar($max) ? intval($max) + 1 : 1;
}
// @phpstan-ignore cast.int
$model->order ??= (int) self::query()->max('order');
}
private static function assertNameIsUnique(?string $name, ?string $id = null): void

View file

@ -96,10 +96,9 @@ class Slide extends Model implements ModelWithOrder, ModelWithStatus
private static function manageFields(self $model): void
{
$model->picture = FileStorageHelper::storeFile($model, $model->picture, true);
if ($model->order === null) {
$max = self::query()->max('order');
$model->order = is_scalar($max) ? intval($max) + 1 : 1;
if (is_null($model->order)) {
// @phpstan-ignore argument.type
$model->order = intval(self::query()->max('order'));
}
}
@ -111,7 +110,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 ($model->order === null) {
$max = self::query()->max('order');
$model->order = is_scalar($max) ? intval($max) + 1 : 1;
if (is_null($model->order)) {
// @phpstan-ignore argument.type
$model->order = intval(self::query()->max('order'));
}
}

View file

@ -16,7 +16,6 @@ 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
{
@ -31,7 +30,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)) {
@ -40,7 +39,8 @@ 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,11 +11,13 @@ 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
) {
}
@ -127,6 +129,6 @@ class ProductPolicy
$domainProduct = $this->productRepository->find($productId);
return $domainProduct && $domainProduct->isPublished();
return $domainProduct && $this->productService->isPublished($domainProduct);
}
}

View file

@ -8,30 +8,25 @@ 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
{
// FIXME: The Arcadia refresh job dispatching is currently disabled and needs to be enabled when api will be available.
return false;
if ($user instanceof Customer) {
return false;
}
// if ($user instanceof Customer) {
// return false;
// }
// return $user->role->isAtLeastEqualsTo(UserRole::Admin);
return $user->role->isAtLeastEqualsTo(UserRole::Admin);
}
public function update(User | Customer $user, Setting | SettingDto | null $_ = null): bool
{
// FIXME: The Arcadia refresh job dispatching is currently disabled and needs to be enabled when api will be available.
return false;
if ($user instanceof Customer) {
return false;
}
// if ($user instanceof Customer) {
// return false;
// }
// return $user->role->isAtLeastEqualsTo(UserRole::Admin);
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\Ports\Repositories\ProductRepository;
use Domain\Services\ProductAvailabilityService;
class BasketPolicy
{
public function __construct(
private readonly ProductRepository $productRepository
private readonly ProductAvailabilityService $productService
) {
}
@ -27,8 +27,6 @@ class BasketPolicy
return false;
}
$domainProduct = $this->productRepository->find($product->id);
return $domainProduct && $domainProduct->isPublished();
return $this->productService->isBasketProductPublished($product);
}
}

View file

@ -8,10 +8,12 @@ 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
) {
}
@ -20,14 +22,14 @@ class ProductPolicy
{
$domainProduct = $this->productRepository->find($product->getId());
return $domainProduct && $domainProduct->isPublished();
return $domainProduct && $this->productService->isPublished($domainProduct);
}
public function addToBasket(null | User | Customer $_, Product $product): bool
{
$domainProduct = $this->productRepository->find($product->getId());
return $domainProduct && $domainProduct->isOrderable();
return $domainProduct && $this->productService->isOrderable($domainProduct);
}
public function viewPrices(null | User | Customer $_, Product $product): bool
@ -38,6 +40,6 @@ class ProductPolicy
$domainProduct = $this->productRepository->find($product->getId());
return $domainProduct && $domainProduct->isPublished();
return $domainProduct && $this->productService->isPublished($domainProduct);
}
}

View file

@ -13,7 +13,6 @@ 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;
@ -30,7 +29,6 @@ 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;
@ -113,11 +111,6 @@ class DomainServiceProvider extends ServiceProvider
EmployeeImportStorageService::class,
EloquentEmployeeImportStorageService::class
);
$this->app->bind(
CsvGenerator::class,
CsvGeneratorImpl::class
);
}
/**

View file

@ -5,7 +5,6 @@ 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;
@ -30,8 +29,8 @@ class MoneyServiceProvider extends ServiceProvider
$this->app->singleton(
Currency::class,
fn () => new Currency(
// @phpstan-ignore argument.type (Should throw if not provided)
Config::string(
// @phpstan-ignore argument.type
config(
'app.currency',
'EUR'
)
@ -41,7 +40,8 @@ class MoneyServiceProvider extends ServiceProvider
$this->app->singleton(
\NumberFormatter::class,
fn () => new \NumberFormatter(
Config::string('app.locale', 'fr_FR'),
// @phpstan-ignore argument.type
config('app.locale', 'fr_FR'),
\NumberFormatter::CURRENCY
)
);
@ -66,7 +66,9 @@ class MoneyServiceProvider extends ServiceProvider
*/
public function boot()
{
$this->sharetoView();
if (! $this->app->runningInConsole()) {
$this->sharetoView();
}
}
private function sharetoView(): void

View file

@ -14,6 +14,7 @@ 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;
@ -108,6 +109,7 @@ class RouteServiceProvider extends ServiceProvider
fn (string $value) => $this->resolvePublishedProductWithPricing($value)
);
// * CSP Route
$this->cspReportRoute();
}
@ -141,6 +143,7 @@ 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()
@ -153,7 +156,7 @@ class RouteServiceProvider extends ServiceProvider
$product->load(['inco', 'validDiscount', 'products']);
$domainProduct = $domainProductRepository->find($product->getId());
$pricing = $domainProduct?->pricing();
$pricing = $domainProduct ? $pricingService->calculateForProduct($domainProduct) : null;
if (! $pricing) {
throw new NotFoundHttpException();

View file

@ -7,6 +7,7 @@ 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;
@ -22,13 +23,12 @@ 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,7 +54,8 @@ class ViewServiceProvider extends ServiceProvider
{
Blade::if(
'orderable',
fn (Product $product) => $productRepository->toEntity($product)->isOrderable()
fn (Product $product) => resolve(ProductAvailabilityService::class)
->isOrderable($productRepository->toEntity($product))
);
}
}

View file

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

View file

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

View file

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

View file

@ -7,14 +7,12 @@ 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;
/**
@ -24,7 +22,6 @@ final readonly class GetBoPaginatedProductsQuery implements Query
{
public function __construct(
private DataTableService $dataTable,
private IntlMoneyFormatter $moneyFormatter,
) {
}
@ -56,12 +53,8 @@ final readonly class GetBoPaginatedProductsQuery implements Query
$query->where('star', true);
}
if ($input->discount !== null && $input->discount !== '') {
if ($input->discount === '0') {
$query->whereHas('discount');
} else {
$query->where('discount_id', $input->discount);
}
if ($input->discount) {
$query->whereHas('discount');
}
if (! empty($input->tag)) {
@ -74,19 +67,13 @@ 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<array-key, \App\Dto\ProductDto> $productsDtoPaginator */
$paginator = $this->dataTable->paginateQuery($query, $input->pagination);
/** @var \Illuminate\Contracts\Pagination\LengthAwarePaginator<int, 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'),
@ -97,27 +84,7 @@ 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 ?string $discount = null,
public bool $discount = false,
public ?string $tag = null,
public ?string $range = null,
) {

View file

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

View file

@ -11,6 +11,7 @@ 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;
@ -25,6 +26,7 @@ final readonly class GetPaginatedProductsQuery implements Query
public function __construct(
private DataTableService $dataTable,
private ProductRepository $domainProductRepository,
private PricingService $pricingService,
) {
}
@ -59,7 +61,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 = $domainProduct->pricing();
$pricing = $this->pricingService->calculateForProduct($domainProduct);
return new ProductWithPricingDto(
product: $product,

View file

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

View file

@ -6,10 +6,8 @@ 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;
@ -38,27 +36,4 @@ 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'),
];

View file

@ -1,158 +0,0 @@
<?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::class, 'sentryEventHandler'],
'before_send' => ExceptionsHelper::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 classConstant.unused (Base enum usage)
* @phpstan-ignore-next-line
*/
private const LABELS = [
self::AMOUNT->name => 'Montant',

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -18,7 +18,7 @@ enum StatsCustomerType: int
/**
* Optionnal labels definition
*
* @phpstan-ignore-next-line classConstant.unused (Base enum usage)
* @phpstan-ignore-next-line
*/
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 classConstant.unused (Base enum usage)
* @phpstan-ignore-next-line
*/
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\Models\Entities\Product;
use Domain\Services\PricingService;
use Money\Money;
use Shared\Enums\BaseEnum;
@ -19,7 +19,7 @@ enum TaxRate: int
/**
* Optional labels definition
*
* @phpstan-ignore-next-line classConstant.unused (Base enum usage)
* @phpstan-ignore-next-line
*/
private const LABELS = [
self::TVA_20->name => 'TVA 20%',
@ -34,17 +34,6 @@ 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;
@ -65,6 +54,6 @@ enum TaxRate: int
public function applyTo(Money $price): Money
{
return $price->multiply($this->multiplier(), Product::ROUNDING);
return $price->multiply($this->multiplier(), PricingService::ROUNDING);
}
}

View file

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

View file

@ -15,19 +15,10 @@ 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 classConstant.unused (Base enum usage)
* @phpstan-ignore-next-line
*/
private const LABELS = [
self::PCE->name => 'Pièce',

View file

@ -6,7 +6,6 @@ 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
@ -24,17 +23,4 @@ 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,8 +4,6 @@ declare(strict_types=1);
namespace Domain\Events\Dto;
use Domain\Models\ValueObjects\DeliveryAddress;
final readonly class OrderPlacedShop
{
public function __construct(
@ -27,14 +25,4 @@ 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,12 +4,7 @@ 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;
@ -70,47 +65,4 @@ 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,7 +5,6 @@ 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;
@ -28,9 +27,4 @@ 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,14 +5,11 @@ 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;
@ -20,10 +17,6 @@ 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,
@ -44,145 +37,4 @@ 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,29 +58,22 @@ 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
{
return $this->taxRate->applyTo($this->unitPriceDiscounted());
$unitPriceDiscounted = $this->unitPriceHt->subtract($this->unitDiscount);
// applyTo renvoie déjà le montant TTC (900 + 180 = 1080)
return $this->taxRate->applyTo($unitPriceDiscounted);
}
public function discountTotal(): Money
{
return $this->unitDiscount->multiply($this->qty, Product::ROUNDING);
return $this->unitDiscount->multiply($this->qty, PricingService::ROUNDING);
}
public function priceTotalHt(): Money
{
return $this->unitPriceHt->multiply($this->qty, Product::ROUNDING);
return $this->unitPriceHt->multiply($this->qty, PricingService::ROUNDING);
}
public function subtotalHt(): Money

View file

@ -4,8 +4,6 @@ declare(strict_types=1);
namespace Domain\Models\ValueObjects;
use Domain\Models\Entities\Shop;
final readonly class DeliveryAddress
{
public function __construct(
@ -20,19 +18,6 @@ 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,14 +5,9 @@ declare(strict_types=1);
namespace Domain\Models\ValueObjects;
use Domain\Enums\DiscountType;
use Domain\Models\Entities\Product;
use Domain\Services\PricingService;
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(
@ -118,6 +113,6 @@ final readonly class DiscountValue
$rate = $this->getPercentageRate();
return $basePrice->multiply((string) $rate, Product::ROUNDING);
return $basePrice->multiply((string) $rate, PricingService::ROUNDING);
}
}

View file

@ -4,8 +4,6 @@ declare(strict_types=1);
namespace Domain\Models\ValueObjects;
use Domain\Models\Entities\Shopper;
final readonly class OrderEmployee
{
public function __construct(
@ -20,18 +18,6 @@ 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,19 +51,4 @@ 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,21 +44,4 @@ 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,9 +4,7 @@ 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
@ -19,29 +17,9 @@ 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 UuidHelper::toBase58($this->value);
return $this->value->toString();
}
}

View file

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Domain\Models\ValueObjects;
use Carbon\CarbonImmutable;
use Carbon\CarbonInterface;
use Domain\Models\Entities\PickupExceptionOnDate;
@ -21,21 +20,4 @@ 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,13 +4,20 @@ 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;
@ -27,13 +34,62 @@ class OrderService
Shopper $shopper,
Shop $shop
): Order {
return Order::fromBasket(
$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(
id: $this->newOrderId(),
shopId: clone $shop->id(),
reference: $this->newReference(),
basket: $basket,
pickupTimeSlot: $pickupTimeSlot,
shopper: $shopper,
shop: $shop
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
);
}

View file

@ -10,6 +10,9 @@ 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;
@ -18,6 +21,7 @@ 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;
@ -215,7 +219,8 @@ class OrderTunnelService
$this->orderRepository->persist($order);
$this->eventDispatcher->dispatch($order->toPlacedEvent());
$event = $this->mapNewOrderEvent($order);
$this->eventDispatcher->dispatch($event);
$this->basketService->empty();
$this->orderBuilderSessionManager->clear();
@ -228,4 +233,39 @@ 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,6 +71,11 @@ 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
{
@ -80,23 +85,24 @@ class PickupSchedulingService
return array_map(function (PickupExceptionOnDate $pickupException) use ($shopExceptions) {
$associated = null;
foreach ($shopExceptions as $shopEx) {
if ($shopEx->exception->id->equals($pickupException->id)) {
if ($shopEx->exception->id->value === $pickupException->id->value) {
$associated = $shopEx;
break;
}
}
$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);
$isChecked = $associated !== null;
$isPickupDay = $this->isPickupDay($pickupException);
$deadline = $associated?->orderDeadlineAt
? CarbonImmutable::createFromInterface($associated->orderDeadlineAt)
: $this->getDefaultDeadline(CarbonImmutable::createFromInterface($pickupException->date));
return new EditablePickupExceptionForShop(
$pickupException,
$isPickupDay,
$isChecked && $isPickupDay && $isBlocked,
$isChecked && $isPickupDay && $isPastDeadline
$isChecked && $isPickupDay && $associated->replacementDate === null,
$isChecked && $isPickupDay && $this->getNow()->greaterThan($deadline)
);
}, $allExceptions);
}
@ -154,15 +160,23 @@ class PickupSchedulingService
protected function getTimeSlotForException(ShopPickupOverride $shopPickupException): ?PickupTimeSlot
{
if (! $shopPickupException->hasReplacement() || $shopPickupException->replacementDate === null) {
$replacement = $shopPickupException->replacementDate;
// ? Is there a substitute date configured ?
if ($replacement === null) {
// * Blocked week: exception active but no replacement provided
return null;
}
$defaultDeadline = $this->getDefaultDeadline(CarbonImmutable::createFromInterface($shopPickupException->exception->date));
$deadline = $shopPickupException->effectiveDeadline($defaultDeadline);
// ? 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));
return $this->isBefore($deadline)
? $this->createTimeSlot($shopPickupException->replacementDate->toImmutable())
? $this->createTimeSlot($replacement->toImmutable())
: null;
}
@ -181,16 +195,12 @@ class PickupSchedulingService
->addDays(self::CUTOFF_DAY->value - 1)
->addHours(self::CUTOFF_HOUR);
return $this->getCutoffWeekOffset() !== 0
? $deadline->addWeeks($this->getCutoffWeekOffset())
// @phpstan-ignore notIdentical.alwaysFalse
return self::CUTOFF_WEEK_OFFSET !== 0
? $deadline->addWeeks(self::CUTOFF_WEEK_OFFSET)
: $deadline;
}
private function getCutoffWeekOffset(): int
{
return self::CUTOFF_WEEK_OFFSET;
}
protected function isBefore(CarbonInterface $date): bool
{
return $this->getNow()->lessThanOrEqualTo($date);

View file

@ -0,0 +1,90 @@
<?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

@ -0,0 +1,50 @@
<?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,6 +5,7 @@ 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;
@ -53,7 +54,7 @@ class RegateService
(string) $line->productIncoId->value,
'commande ferme',
(string) $line->productOrderUnit->value,
$line->productOrderUnit->toRegateCode(),
$this->maptoRegateUnit($line->productOrderUnit),
'',
$pickupDay->format('d/m/Y'),
(string) $line->quantity,
@ -76,4 +77,13 @@ 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,7 +36,11 @@ class ShopperService
{
$shopper = $this->getShopperFor($shopperId);
return $shopper !== null ? $this->resolveShopForEmployee($shopper) : null;
if ($shopper === null) {
return null;
}
return $this->resolveShopForEmployee($shopper);
}
public function resolveShopForEmployee(Shopper $shopper): Shop
@ -68,6 +72,12 @@ class ShopperService
public function canCustomerPassOrdersApp(ShopperId $shopperId): bool
{
return $this->getShopperFor($shopperId)?->isActive() ?? false;
$shopper = $this->getShopperFor($shopperId);
if ($shopper === null) {
return false;
}
return $shopper->isActive();
}
}

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, $ex->pivot))
->map(fn (PickupException $ex) => $this->toShopDomain($ex))
->all();
}

View file

@ -11,11 +11,17 @@ 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
{
@ -66,12 +72,11 @@ 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 = $domainProduct->pricing();
$pricing = $this->pricingService->calculateForProduct($domainProduct);
return new BasketProduct(
id: $id,

View file

@ -46,7 +46,6 @@
"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",
@ -97,7 +96,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.",
"Blocked week": "Blocked week",
"Bloqued week": "Bloqued week",
"Booking": "Booking",
"Brand": "Brand",
"Breadcrumb": "Breadcrumb",
@ -286,7 +285,6 @@
"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",
@ -732,7 +730,6 @@
"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.",
@ -798,7 +795,6 @@
"Unprocessable Entity": "Unprocessable Entity",
"Unsubscribe": "Unsubscribe",
"Unsupported Media Type": "Unsupported Media Type",
"until :date": "until :date",
"Up": "Up",
"Update": "Update",
"Update :name": "Update :name",
@ -871,11 +867,5 @@
"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",
"Your search": "Your search",
"Choose position": "Choose position",
"Move": "Move",
"Move down": "Move down",
"Move up": "Move up",
"New position": "New position"
}
"Zip code": "Zip code"
}

View file

@ -26,8 +26,7 @@ return [
'delete_model' => 'Delete :model',
'save_model' => 'Save :model',
'edit_model' => 'Update :model',
'no_results' => 'No results',
'no_results_for_model' => 'No results for :model',
'no_results' => '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 pickup day :/desc": ":title Choisissez :/title :desc votre jour de retrait :/desc",
":title Select :/title :desc your time slot :/desc": ":title Choisissez :/title :desc votre créneau horaire :/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,7 +45,6 @@
"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",
@ -96,7 +95,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.",
"Blocked week": "Semaine bloquée",
"Bloqued week": "Semaine bloquée",
"Booking": "Réservation",
"Brand": "Marque",
"Breadcrumb": "Fil d'Ariane",
@ -283,7 +282,6 @@
"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",
@ -447,7 +445,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 remplacement configuré",
"No replacement date configured": "Pas de jour de remplacemenet 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.",
@ -721,7 +719,6 @@
"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.",
@ -785,7 +782,6 @@
"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",
@ -858,11 +854,5 @@
"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",
"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"
}
"Zip code": "Code postal"
}

View file

@ -15,26 +15,25 @@ 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',
'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
'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
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,7 +350,6 @@ 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',
@ -371,7 +370,6 @@ 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,9 +72,6 @@ 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
@ -114,9 +111,9 @@ spec:
imagePullSecrets:
- name: docker-registry-credentials
securityContext:
runAsUser: 82
runAsGroup: 82
fsGroup: 82
runAsUser: 33
runAsGroup: 33
fsGroup: 33
fsGroupChangePolicy: "OnRootMismatch"
volumes:
- name: storage
@ -200,8 +197,8 @@ spec:
persistentVolumeClaim:
claimName: ${CI_PROJECT_PATH_SLUG}-pvc-storage
securityContext:
runAsUser: 82
runAsGroup: 82
fsGroup: 82
runAsUser: 33
runAsGroup: 33
fsGroup: 33
fsGroupChangePolicy: "OnRootMismatch"
restartPolicy: OnFailure

View file

@ -22,7 +22,6 @@
<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: 47 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -21,16 +21,3 @@
.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,7 +2,6 @@
$primary: $color-primary;
$secondary: $color-secondary;
$tertiary: $color-tertiary;
$success: $color-success;
$info: $color-info;
$warning: $color-warning;
@ -12,7 +11,6 @@ $dark: $color-dark;
$custom-theme-colors: (
primary: $primary,
secondary: $secondary,
tertiary: $tertiary,
success: $success,
info: $info,
warning: $warning,
@ -65,8 +63,3 @@ $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,7 +10,6 @@ $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