Compare commits

..

No commits in common. "develop" and "issue-82" have entirely different histories.

76 changed files with 1190 additions and 1931 deletions

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

@ -9,7 +9,6 @@ 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;
/**
@ -35,11 +34,8 @@ final class ChangeModelOrderPosition implements Action
return true;
}
$usesSoftDeletes = in_array(SoftDeletes::class, class_uses_recursive($model), true)
|| method_exists($model, 'trashed');
/** @var Builder<Model&ModelWithOrder> $baseQuery */
$baseQuery = $usesSoftDeletes
$baseQuery = method_exists($model, 'withTrashed')
// @phpstan-ignore method.notFound (withTrashed)
? $model::query()->withTrashed()
: $model::query();

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

@ -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

@ -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,10 @@ 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(),
'nutriscore' => $this->product->inco?->nutriscore?->value(),
];
}
}

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

@ -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

@ -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

@ -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;
@ -141,6 +142,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 +155,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

@ -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

@ -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');

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;
@ -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

@ -15,15 +15,6 @@ 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
*

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,7 +5,7 @@ declare(strict_types=1);
namespace Domain\Models\ValueObjects;
use Domain\Enums\DiscountType;
use Domain\Models\Entities\Product;
use Domain\Services\PricingService;
use Money\Money;
/**
@ -118,6 +118,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,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;
}

View file

@ -0,0 +1,131 @@
<?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;
/**
* Calculates all pricing values (base, discounted, per-kg, and discount rate) for a product.
*
* Business rules:
* - When a product is invoiced by the kilo (InvoicingUnit::KGM) and sold by the piece (SellUnit::PCE),
* $product->price is the reference price per kg HT, while $basePriceHt is the price of the piece HT.
* - All discounts (percentage or fixed amount) always apply to the piece (sold unit).
*/
public function calculateForProduct(Product $product): ProductPricing
{
$basePriceHt = $this->priceHt($product);
$basePriceTtc = $this->applyTax($basePriceHt, $product->tax_rate);
// * All discounts apply to the piece ($basePriceHt), capped at the piece price to prevent negative prices
$discountAmountHt = $product->validDiscount?->calculateDiscountAmount($basePriceHt)
?? new Money(0, $basePriceHt->getCurrency());
if ($discountAmountHt->greaterThan($basePriceHt)) {
$discountAmountHt = $basePriceHt;
}
$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 x 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
{
if ($product->invoicing_unit === InvoicingUnit::KGM) {
// * The product price is already a price per kg: it is the reference for the kg price
$discountAmountKgHt = $product->validDiscount?->calculateDiscountAmount($product->price)
?? new Money(0, $product->price->getCurrency());
$grams = $product->product_weight?->toGrams() ?? 0;
if (
$product->validDiscount !== null
&& ! $product->validDiscount->isPercentage()
&& $product->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 = $product->price->subtract($discountAmountKgHt);
if ($discountedKgPriceHt->isNegative()) {
$discountedKgPriceHt = new Money(0, $product->price->getCurrency());
}
return $this->applyTax($discountedKgPriceHt, $product->tax_rate);
}
$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 x 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

@ -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

@ -97,7 +97,7 @@
"Basket has been cleared": "Basket has been cleared",
"Basket Summary": "Basket Summary",
"Before proceeding, please check your email for a verification link.": "Before proceeding, please check your email for a verification link.",
"Blocked week": "Blocked week",
"Bloqued week": "Bloqued week",
"Booking": "Booking",
"Brand": "Brand",
"Breadcrumb": "Breadcrumb",

View file

@ -96,7 +96,7 @@
"Basket has been cleared": "Le panier a été vidé",
"Basket Summary": "Récapitulatif de votre panier",
"Before proceeding, please check your email for a verification link.": "Avant de continuer, merci de vérifier votre e-mail contenant un lien de vérification.",
"Blocked week": "Semaine bloquée",
"Bloqued week": "Semaine bloquée",
"Booking": "Réservation",
"Brand": "Marque",
"Breadcrumb": "Fil d'Ariane",

View file

@ -1,14 +1,6 @@
#products-carousel {
position: relative;
&.has-controls,
&:has(.carousel-control-prev) {
@include media-breakpoint-up(lg) {
padding-left: 3.5rem;
padding-right: 3.5rem;
}
}
.carousel-inner {
display: flex !important;
flex-wrap: nowrap !important;
@ -26,50 +18,6 @@
transition: none !important;
}
.carousel-control-prev,
.carousel-control-next {
position: absolute;
top: 50%;
bottom: auto;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
color: var(--bs-dark);
cursor: pointer;
background-color: var(--bs-primary);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 50%;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
opacity: 1;
filter: none;
transform: translateY(-50%);
transition: background-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
&:hover,
&:focus-visible {
color: var(--bs-dark);
background-color: color-mix(in srgb, var(--bs-primary) 85%, black);
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.2);
opacity: 1;
transform: translateY(-50%) scale(1.08);
}
&:active {
transform: translateY(-50%) scale(0.96);
}
}
.carousel-control-prev {
left: 0.5rem;
}
.carousel-control-next {
right: 0.5rem;
}
}
.carousel-indicators {

View file

@ -21,8 +21,6 @@
'model' => $faq,
'first' => $loop->first,
'last' => $loop->last,
'isFirstPage' => $faqs->onFirstPage(),
'isLastPage' => !$faqs->hasMorePages(),
])
<td>
<div class="btn-group">

View file

@ -3,24 +3,6 @@
/** @var \Illuminate\Database\Eloquent\Model $model */
/** @var bool $first */
/** @var bool $last */
/** @var bool $isFirstPage */
/** @var bool $isLastPage */
if (!isset($first) || !is_bool($first)) {
throw new \InvalidArgumentException('The $first variable must be set and must be a boolean.');
}
if (!isset($last) || !is_bool($last)) {
throw new \InvalidArgumentException('The $last variable must be set and must be a boolean.');
}
if (!isset($isFirstPage) || !is_bool($isFirstPage)) {
throw new \InvalidArgumentException('The $isFirstPage variable must be set and must be a boolean.');
}
if (!isset($isLastPage) || !is_bool($isLastPage)) {
throw new \InvalidArgumentException('The $isLastPage variable must be set and must be a boolean.');
}
// * Route vars
$routeName = request()->route()?->getName() ?? 'noroute';
@ -43,9 +25,6 @@
// * Reset button
$hasCustomSort = ($currentSortCol && $currentSortCol !== 'order') || ($currentSortCol === 'order' && $currentSortWay === 'desc');
$showReset = !empty($searchQuery) || $hasCustomSort;
$isFirst = $first && $isFirstPage;
$isLast = $last && $isLastPage;
@endphp
@if (!$noOrder)
<td class="text-nowrap align-middle">
@ -77,13 +56,13 @@
@csrf
@method('PATCH')
<input name="action" type="hidden" value="down">
@if (!$isFirst)
@if (!$first)
<button class="btn btn-link text-decoration-none p-0" formaction="{{ route($route, [$model, 'action' => 'up']) }}" type="submit"
title="{{ __('Move up') }}">
<i class="icon-size fas fa-arrow-alt-circle-up fa-2x" name="down_up"></i>
</button>
@endif
@if (!$isLast)
@if (!$last)
<button class="btn btn-link text-decoration-none p-0" formaction="{{ route($route, [$model, 'action' => 'down']) }}"
type="submit" title="{{ __('Move down') }}">
<i class="icon-size fas fa-arrow-alt-circle-down fa-2x" name="down_arrow"></i>

View file

@ -2,16 +2,9 @@
// * Route vars
$routeName = request()->route()?->getName() ?? 'noroute';
// * Default sort vars
$defaultSortCol = $defaultSortCol ?? 'order';
$defaultSortWay = $defaultSortWay ?? 'asc';
$defaultSortWayStr = $defaultSortWay instanceof \SortDirection
? ($defaultSortWay === \SortDirection::Descending ? 'desc' : 'asc')
: (string) $defaultSortWay;
// * Request vars
$currentSortCol = session()->get("{$routeName}.sort_col", $defaultSortCol);
$currentSortWay = session()->get("{$routeName}.sort_way", $defaultSortWayStr);
$currentSortCol = session()->get("{$routeName}.sort_col");
$currentSortWay = session()->get("{$routeName}.sort_way");
$searchQuery = request()->input('search');
@ -25,7 +18,7 @@
View::share('noOrder', $noOrder);
// * Reset button
$hasCustomSort = ($currentSortCol && $currentSortCol !== $defaultSortCol) || ($currentSortCol === $defaultSortCol && $currentSortWay !== $defaultSortWayStr);
$hasCustomSort = ($currentSortCol && $currentSortCol !== 'order') || ($currentSortCol === 'order' && $currentSortWay === 'desc');
$showReset = !empty($searchQuery) || $hasCustomSort;
@endphp

View file

@ -16,8 +16,6 @@
'cols' => $cols,
'mobileHide' => [],
'ignore' => ['customer', 'pickup_date'],
'defaultSortCol' => 'date',
'defaultSortWay' => 'desc',
])
</thead>
<tbody>

View file

@ -1,6 +1,10 @@
@extends('bo.layouts.layout')
@section('title', __('Today\'s Orders'))
@if (request()->routeIs('bo.merch.starred'))
@section('title', __('crud.helpers.starred'))
@else
@section('title', Str::ucfirst(trans_choice('models.classes.orders', \INF)))
@endif
@section('breadcrumb', request()->route()->getName())
@ -10,7 +14,7 @@
<div class="row">
<div class="col">
<h1 class="h3">
{{ __('Today\'s Orders') }}
{{ Str::ucfirst(trans_choice('models.classes.orders', \INF)) }}&nbsp;{{ Str::ucfirst(__('crud.actions.list')) }}
</h1>
</div>
</div>

View file

@ -79,8 +79,6 @@
'model' => $product,
'first' => $loop->first,
'last' => $loop->last,
'isFirstPage' => $products->onFirstPage(),
'isLastPage' => !$products->hasMorePages(),
'noOrder' => $noOrder ?? false,
])
@endif

View file

@ -25,8 +25,6 @@
'model' => $range,
'first' => $loop->first,
'last' => $loop->last,
'isFirstPage' => $ranges->onFirstPage(),
'isLastPage' => !$ranges->hasMorePages(),
])
<td>
@include('bo.modules.toggle-status', [

View file

@ -85,7 +85,8 @@
$associatedException?->pivot?->order_deadline_at?->format('Y-m-d\TH:i'),
);
$noReplacement = $isChecked && empty($associatedException?->pivot?->replacement_date);
$noReplacement =
empty($associatedException?->pivot?->replacement_date);
$isCritical = $dto->missingSubsitute || $dto->passedDeadline;
@ -121,7 +122,7 @@
@if ($isCritical)
<span class="d-block text-danger small fw-bold mt-2" data-bs-toggle="tooltip"
title="{{ $criticalReason }}">
<i class="fa-solid fa-triangle-exclamation text-danger me-1"></i> {{ __('Blocked week') }}
<i class="fa-solid fa-triangle-exclamation text-danger me-1"></i> {{ __('Bloqued week') }}
</span>
@elseif ($noReplacement)
<span class="d-block text-info small fw-bold mt-2">
@ -131,10 +132,10 @@
<span class="d-block text-primary small fw-bold mt-2">
<i class="fa-solid fa-circle-info me-1"></i>
<span>{{ __('Replacement date :date', [
'date' => Date::forHumans(\Carbon\Carbon::parse($associatedException?->pivot?->replacement_date)),
'date' => Date::forHumans(\Carbon\Carbon::parse($associatedException?->pivot?->replacement_date)),
]) }}</span>
</span>
@endif
</span>
</label>
<button

View file

@ -47,8 +47,6 @@
'model' => $slide,
'first' => $loop->first,
'last' => $loop->last,
'isFirstPage' => $slides->onFirstPage(),
'isLastPage' => !$slides->hasMorePages(),
])
<td>
<div class="btn-group">

View file

@ -21,8 +21,6 @@
'model' => $tag,
'first' => $loop->first,
'last' => $loop->last,
'isFirstPage' => $tags->onFirstPage(),
'isLastPage' => !$tags->hasMorePages(),
])
<td>
@include('bo.modules.toggle-status', [

View file

@ -1,7 +1,7 @@
<div class="row">
<div class="col-12 position-relative overflow-hidden">
<div class="carousel slide carousel-dark @if (count($starProductList) > 3) has-controls @endif" id="products-carousel" data-bs-ride="false" data-bs-touch="true" role="region"
<div class="carousel slide carousel-dark" id="products-carousel" data-bs-ride="false" data-bs-touch="true" role="region"
aria-roledescription="carousel" aria-label="{{ __('Offers Slider') }}">
<div class="carousel-inner p-2">
@ -91,7 +91,8 @@
</small>
</li>
<li class="position-relative z-3 d-flex justify-content-between flex-row py-2">
<li class="position-relative z-3 py-2 text-center">
<li class="d-flex justify-content-between flex-row py-2">
@if ($productWithPricing->product->inco?->nutriscore)
<img class="object-fit-contain img-fluid mw-5" data-bs-toggle="tooltip"
src="{{ Vite::asset("resources/assets/images/nutriscore/nutriscore-{$productWithPricing->product->inco->nutriscore->value()}.png") }}"
@ -100,7 +101,7 @@
@endif
@can('addToBasket', $productWithPricing->product)
<div class="basket-buttons ms-auto" data-json='@json(['id' => $productWithPricing->product->id])'></div>
<div class="basket-buttons" data-json='@json(['id' => $productWithPricing->product->id])'></div>
@endcan
</li>
</ul>
@ -114,15 +115,21 @@
</div>
@if (count($starProductList) > 3)
<button class="carousel-control-prev d-none d-lg-flex" data-bs-target="#products-carousel"
data-bs-slide="prev" type="button" aria-label="{{ __('pagination.previous') }}">
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
<button class="carousel-control-prev d-none d-lg-flex justify-content-start opacity-100" data-bs-target="#products-carousel"
data-bs-slide="prev" type="button" style="filter: none; z-index: 10;">
<div class="btn btn-primary rounded-circle d-flex justify-content-center align-items-center ms-2 shadow-sm"
style="width: 45px; height: 45px; pointer-events: none;">
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
</div>
<span class="visually-hidden">{{ __('pagination.previous') }}</span>
</button>
<button class="carousel-control-next d-none d-lg-flex" data-bs-target="#products-carousel"
data-bs-slide="next" type="button" aria-label="{{ __('pagination.next') }}">
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
<button class="carousel-control-next d-none d-lg-flex justify-content-end opacity-100" data-bs-target="#products-carousel"
data-bs-slide="next" type="button" style="filter: none; z-index: 10;">
<div class="btn btn-primary rounded-circle d-flex justify-content-center align-items-center me-2 shadow-sm"
style="width: 45px; height: 45px; pointer-events: none;">
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
</div>
<span class="visually-hidden">{{ __('pagination.next') }}</span>
</button>
@endif

View file

@ -78,10 +78,7 @@ Route::middleware(['auth:backend', 'has-role:Admin'])->group(function (): void {
Route::post('/products/mass-actions', [ProductsController::class, 'massActions'])->name('products.mass-actions');
Route::patch('/products/{product}/toggle-status', [ProductsController::class, 'toggleStatus'])->name('products.toggle-status');
Route::patch('/products/{product}/toggle-star', [ProductsController::class, 'toggleStar'])->name('products.toggle-star');
Route::patch(
'/products/{product}/change-selected-order',
[ProductsController::class, 'changeSelectedOrder']
)->name('products.change-selected-order');
Route::patch('/products/{product}/change-selected-order', [ProductsController::class, 'changeSelectedOrder'])->name('products.change-selected-order');
Route::patch('/products/{product}/{action}', [ProductsController::class, 'changeOrder'])->name('products.change-order');
// * PRODUCTS INCO

View file

@ -113,72 +113,25 @@ describe('Bo\OrdersController', function (): void {
// THEN
expect($response->status())->toBe(200);
$response->assertViewHas('orders', function (mixed $orders) use ($ref): bool {
/** @var \Illuminate\Pagination\LengthAwarePaginator<int, OrderDto> $orders */
return $orders instanceof \Illuminate\Pagination\LengthAwarePaginator
&& $orders->total() === 1
&& (string) $orders->first()?->reference === $ref;
$response->assertViewHas('orders', function ($orders) use ($ref): bool {
return $orders instanceof LengthAwarePaginator
&& $orders->total() === 1
&& (string) $orders->first()->reference === $ref;
});
});
it('sorts index orders by default by date from newest to oldest', function (): void {
it('can access today orders', function (): void {
/** @var \Tests\TestCase $this */
// GIVEN
$shop = Shop::factory()->create();
$olderOrder = Order::forceCreate([
'id' => Uuid::uuid7(Date::now()->subHours(2))->toString(),
'shop_id' => $shop->id,
'reference' => new OrderReference(Uuid::uuid7()),
'status' => OrderStatus::VALIDATED->value,
'total' => new Money(1000, new Currency('EUR')),
'employee' => new OrderEmployee(new Matricule('1000198'), 'Old', 'Order', new ShopperEstablishmentCode('FAC01'), new ShopperSiteCode('SITE'), new ShopperServiceCode('101')),
'delivery_address' => new DeliveryAddress('Shop Name', 'Address', '75000', 'Paris'),
'pickup_day' => Date::now()->addDays(7)->toDateString(),
]);
$newerOrder = Order::forceCreate([
'id' => Uuid::uuid7(Date::now())->toString(),
'shop_id' => $shop->id,
'reference' => new OrderReference(Uuid::uuid7()),
'status' => OrderStatus::VALIDATED->value,
'total' => new Money(2000, new Currency('EUR')),
'employee' => new OrderEmployee(new Matricule('1000199'), 'New', 'Order', new ShopperEstablishmentCode('FAC01'), new ShopperSiteCode('SITE'), new ShopperServiceCode('101')),
'delivery_address' => new DeliveryAddress('Shop Name', 'Address', '75000', 'Paris'),
'pickup_day' => Date::now()->addDays(7)->toDateString(),
]);
// WHEN
$response = $this->get('/bo/orders');
// THEN
expect($response->status())->toBe(200);
/** @var \Illuminate\Pagination\LengthAwarePaginator<int, OrderDto> $orders */
$orders = $response->viewData('orders');
expect($orders->first()?->id)->toBe($newerOrder->id)
->and($orders->last()?->id)->toBe($olderOrder->id);
});
it('can access today orders and filters out past orders', function (): void {
/** @var \Tests\TestCase $this */
// GIVEN
$shop = Shop::factory()->create();
$todayOrder = Order::forceCreate([
'id' => Uuid::uuid7(Date::now())->toString(),
$shop = Shop::factory()->create();
Order::forceCreate([
'id' => Uuid::uuid7()->toString(),
'shop_id' => $shop->id,
'reference' => new OrderReference(Uuid::uuid7()),
'status' => OrderStatus::VALIDATED->value,
'total' => new Money(3000, new Currency('EUR')),
'employee' => new OrderEmployee(new Matricule('1000103'), 'Charlie', 'Brown', new ShopperEstablishmentCode('FAC01'), new ShopperSiteCode('SITE'), new ShopperServiceCode('101')),
'delivery_address' => new DeliveryAddress('Shop Name', 'Address', '75000', 'Paris'),
'pickup_day' => Date::now()->addDays(7)->toDateString(),
]);
$pastOrder = Order::forceCreate([
'id' => Uuid::uuid7(Date::now()->subDays(3))->toString(),
'shop_id' => $shop->id,
'reference' => new OrderReference(Uuid::uuid7()),
'status' => OrderStatus::VALIDATED->value,
'total' => new Money(4000, new Currency('EUR')),
'employee' => new OrderEmployee(new Matricule('1000104'), 'Past', 'Order', new ShopperEstablishmentCode('FAC01'), new ShopperSiteCode('SITE'), new ShopperServiceCode('101')),
'delivery_address' => new DeliveryAddress('Shop Name', 'Address', '75000', 'Paris'),
'pickup_day' => Date::now()->toDateString(),
]);
@ -188,10 +141,7 @@ describe('Bo\OrdersController', function (): void {
// THEN
expect($response->status())->toBe(200);
$response->assertViewIs('bo.orders.index-today');
/** @var \Illuminate\Pagination\LengthAwarePaginator<int, OrderDto> $orders */
$orders = $response->viewData('orders');
expect($orders->total())->toBe(1)
->and($orders->first()?->id)->toBe($todayOrder->id);
$response->assertViewHas('orders', fn ($orders) => $orders instanceof LengthAwarePaginator && $orders->total() >= 1);
});
it('can access order show detail', function (): void {
@ -265,7 +215,7 @@ describe('Bo\OrdersController', function (): void {
$refreshedOrder = Order::query()->find($order->id);
expect($refreshedOrder)->not->toBeNull()
->and($refreshedOrder?->status)->toBe(OrderStatus::CANCELED);
->and($refreshedOrder->status)->toBe(OrderStatus::CANCELED);
});
it('rejects unauthenticated users', function (): void {

View file

@ -194,3 +194,4 @@ describe('ProductsController (Ajax)', function () {
->and($data[0]['discountedPriceTtc'])->not->toBeNull();
});
});

View file

@ -54,6 +54,19 @@ describe('UpdateShopPickupExceptionsRequest', function () {
->and($attributes['pickup-exceptions.*'])->toBe('fermeture exceptionnelle');
});
it('returns human friendly custom messages', function () {
// GIVEN
$authService = mock(AppAdminAuthService::class);
$request = new UpdateShopPickupExceptionsRequest($authService);
// WHEN
$messages = $request->messages();
// THEN
expect($messages)->toHaveKey('pickup-exceptions.*.uuid')
->and($messages)->toHaveKey('pickup-exceptions.*.exists');
});
it('validates successfully with valid pickup exceptions and dates', function () {
// GIVEN
$exception1 = PickupException::factory()->create();
@ -98,7 +111,7 @@ describe('UpdateShopPickupExceptionsRequest', function () {
// THEN
expect($validator->fails())->toBeTrue();
$errors = $validator->errors()->all();
expect($errors[0])->toBe('Le champ fermeture exceptionnelle doit être un UUID valide.');
expect($errors[0])->toBe('La fermeture exceptionnelle sélectionnée n\'est pas un identifiant valide.');
});
it('rejects nonexistent uuid with non cryptic message', function () {
@ -117,7 +130,7 @@ describe('UpdateShopPickupExceptionsRequest', function () {
// THEN
expect($validator->fails())->toBeTrue();
$errors = $validator->errors()->all();
expect($errors[0])->toBe('Le champ fermeture exceptionnelle sélectionné est invalide.');
expect($errors[0])->toBe('La fermeture exceptionnelle sélectionnée n\'existe pas.');
});
it('rejects order deadline after replacement date with human friendly message', function () {
@ -144,7 +157,7 @@ describe('UpdateShopPickupExceptionsRequest', function () {
// THEN
expect($validator->fails())->toBeTrue();
$errors = $validator->errors()->all();
expect($errors)->toContain('Le champ date limite de commande doit être une date antérieure au jour de remplacement.');
expect($errors)->toContain('La date limite de commande doit être strictement antérieure au jour de remplacement.');
});
it('rejects order deadline equal to replacement date with human friendly message', function () {
@ -171,7 +184,7 @@ describe('UpdateShopPickupExceptionsRequest', function () {
// THEN
expect($validator->fails())->toBeTrue();
$errors = $validator->errors()->all();
expect($errors)->toContain('Le champ date limite de commande doit être une date antérieure au jour de remplacement.');
expect($errors)->toContain('La date limite de commande doit être strictement antérieure au jour de remplacement.');
});
it('rejects order deadline without replacement date with human friendly message', function () {
@ -198,7 +211,7 @@ describe('UpdateShopPickupExceptionsRequest', function () {
// THEN
expect($validator->fails())->toBeTrue();
$errors = $validator->errors()->all();
expect($errors)->toContain('Le champ jour de remplacement est obligatoire quand date limite de commande est présent.');
expect($errors)->toContain('Le jour de remplacement est obligatoire lorsqu\'une date limite de commande est définie.');
});
it('transforms data to sync data format correctly', function () {

View file

@ -1,92 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Tag;
use Illuminate\Pagination\LengthAwarePaginator;
describe('change-order blade component', function (): void {
it('shows down arrow on the last item of page 1 when there are multiple pages', function (): void {
// GIVEN
$tag = new Tag();
$tag->id = 1;
$tag->order = 15;
$paginator = new LengthAwarePaginator([$tag], 30, 15, 1);
// WHEN
$html = view('bo.modules.change-order', [
'route' => 'bo.tags.change-order',
'model' => $tag,
'first' => false,
'last' => true,
'isFirstPage' => $paginator->onFirstPage(),
'isLastPage' => ! $paginator->hasMorePages(),
])->render();
// THEN
expect($html)->toContain('name="down_arrow"');
});
it('shows up arrow on the first item of page 2 when there are multiple pages', function (): void {
// GIVEN
$tag = new Tag();
$tag->id = 2;
$tag->order = 16;
$paginator = new LengthAwarePaginator([$tag], 30, 15, 2);
// WHEN
$html = view('bo.modules.change-order', [
'route' => 'bo.tags.change-order',
'model' => $tag,
'first' => true,
'last' => false,
'isFirstPage' => $paginator->onFirstPage(),
'isLastPage' => ! $paginator->hasMorePages(),
])->render();
// THEN
expect($html)->toContain('name="down_up"');
});
it('hides up arrow on the first item of the first page', function (): void {
// GIVEN
$tag = new Tag();
$tag->id = 1;
$tag->order = 1;
$paginator = new LengthAwarePaginator([$tag], 30, 15, 1);
// WHEN
$html = view('bo.modules.change-order', [
'route' => 'bo.tags.change-order',
'model' => $tag,
'first' => true,
'last' => false,
'isFirstPage' => $paginator->onFirstPage(),
'isLastPage' => ! $paginator->hasMorePages(),
])->render();
// THEN
expect($html)->not->toContain('name="down_up"');
});
it('hides down arrow on the last item of the last page', function (): void {
// GIVEN
$tag = new Tag();
$tag->id = 2;
$tag->order = 30;
$paginator = new LengthAwarePaginator([$tag], 30, 15, 2);
// WHEN
$html = view('bo.modules.change-order', [
'route' => 'bo.tags.change-order',
'model' => $tag,
'first' => false,
'last' => true,
'isFirstPage' => $paginator->onFirstPage(),
'isLastPage' => ! $paginator->hasMorePages(),
])->render();
// THEN
expect($html)->not->toContain('name="down_arrow"');
});
});

View file

@ -5,49 +5,7 @@ declare(strict_types=1);
use App\Actions\Common\ChangeModelOrderPosition;
use App\Actions\Common\ChangeModelOrderPositionInput;
use App\Contracts\ActionInput;
use App\Models\Product;
use App\Models\Range;
use App\Models\Tag;
use Domain\Enums\ProductBrand;
use Domain\Enums\SellUnit;
use Domain\Enums\TaxRate;
use Domain\Enums\Units\InvoicingUnit;
use Domain\Enums\Units\OrderUnit;
use Money\Money;
function createChangeModelOrderPositionProduct(int $order): Product
{
$range = new Range();
$range->name = 'Test Range ' . uniqid();
$range->status = true;
$range->order = random_int(1, 9999);
$range->picture_alt = 'alt';
$range->picture_title = 'title';
$range->saveOrFail();
$product = new Product([
'range_id' => $range->id,
'name' => 'Test Product ' . uniqid(),
'brand' => ProductBrand::loue,
'invoicing_unit' => InvoicingUnit::PCE,
'sell_unit' => SellUnit::PCE,
'order_unit' => OrderUnit::PCE,
'tax_rate' => TaxRate::TVA_20,
'description' => 'Test description',
'status' => true,
]);
$product->price = Money::EUR(1000);
$product->short_description = 'Short test description';
$product->picture_alt = 'test alt';
$product->picture_title = 'test title';
$product->picture_large_alt = 'test alt';
$product->picture_large_title = 'test title';
$product->order = $order;
$product->star = false;
$product->saveOrFail();
return $product;
}
describe('ChangeModelOrderPosition action', function (): void {
it('shifts orders when moving a model up to target position', function (): void {
@ -173,30 +131,4 @@ describe('ChangeModelOrderPosition action', function (): void {
// THEN
->toThrow(\InvalidArgumentException::class);
});
it('shifts soft-deleted models when moving a model across them', function (): void {
// GIVEN
$product1 = createChangeModelOrderPositionProduct(1);
$product2 = createChangeModelOrderPositionProduct(2);
$product3 = createChangeModelOrderPositionProduct(3);
$product2->delete();
$action = new ChangeModelOrderPosition();
$input = new ChangeModelOrderPositionInput($product1, 3);
// WHEN
$result = $action($input);
// THEN
expect($result)->toBeTrue();
$fresh1 = Product::query()->withTrashed()->find($product1->id);
$fresh2 = Product::query()->withTrashed()->find($product2->id);
$fresh3 = Product::query()->withTrashed()->find($product3->id);
expect($fresh1?->getOrder())->toBe(3)
->and($fresh2?->getOrder())->toBe(1)
->and($fresh3?->getOrder())->toBe(2);
});
});

View file

@ -5,49 +5,7 @@ declare(strict_types=1);
use App\Actions\Common\ChangeModelOrder;
use App\Actions\Common\ChangeModelOrderInput;
use App\Contracts\ActionInput;
use App\Models\Product;
use App\Models\Range;
use App\Models\Tag;
use Domain\Enums\ProductBrand;
use Domain\Enums\SellUnit;
use Domain\Enums\TaxRate;
use Domain\Enums\Units\InvoicingUnit;
use Domain\Enums\Units\OrderUnit;
use Money\Money;
function createChangeModelOrderProduct(int $order): Product
{
$range = new Range();
$range->name = 'Test Range ' . uniqid();
$range->status = true;
$range->order = random_int(1, 9999);
$range->picture_alt = 'alt';
$range->picture_title = 'title';
$range->saveOrFail();
$product = new Product([
'range_id' => $range->id,
'name' => 'Test Product ' . uniqid(),
'brand' => ProductBrand::loue,
'invoicing_unit' => InvoicingUnit::PCE,
'sell_unit' => SellUnit::PCE,
'order_unit' => OrderUnit::PCE,
'tax_rate' => TaxRate::TVA_20,
'description' => 'Test description',
'status' => true,
]);
$product->price = Money::EUR(1000);
$product->short_description = 'Short test description';
$product->picture_alt = 'test alt';
$product->picture_title = 'test title';
$product->picture_large_alt = 'test alt';
$product->picture_large_title = 'test title';
$product->order = $order;
$product->star = false;
$product->saveOrFail();
return $product;
}
describe('ChangeModelOrder action', function (): void {
it('permutes order between adjacent models', function (): void {
@ -107,30 +65,4 @@ describe('ChangeModelOrder action', function (): void {
// THEN
->toThrow(\InvalidArgumentException::class);
});
it('takes into account soft-deleted models when permuting order', function (): void {
// GIVEN
$product1 = createChangeModelOrderProduct(1);
$product2 = createChangeModelOrderProduct(2);
$product3 = createChangeModelOrderProduct(3);
$product2->delete();
$action = new ChangeModelOrder();
$input = new ChangeModelOrderInput($product1, \SortDirection::Descending);
// WHEN
$result = $action($input);
// THEN
expect($result)->toBeTrue();
$fresh1 = Product::query()->withTrashed()->find($product1->id);
$fresh2 = Product::query()->withTrashed()->find($product2->id);
$fresh3 = Product::query()->withTrashed()->find($product3->id);
expect($fresh1?->getOrder())->toBe(2)
->and($fresh2?->getOrder())->toBe(1)
->and($fresh3?->getOrder())->toBe(3);
});
});

View file

@ -1,86 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Integration\WithApp\App\Models;
use App\Models\Discount;
use Domain\Enums\DiscountType;
use Domain\Models\ValueObjects\DiscountValue;
use Illuminate\Support\Carbon;
describe('Discount model', function (): void {
it('filters published discounts including those with null start or end dates', function (): void {
// GIVEN
Carbon::setTestNow('2026-06-15 12:00:00');
// 1. Permanent discount (no start, no end) -> published
$permanent = new Discount();
$permanent->type = DiscountType::PERCENT;
$permanent->value = DiscountValue::fromPercentage(10);
$permanent->is_old_price_displayed = true;
$permanent->date_start = null;
$permanent->date_end = null;
$permanent->save();
// 2. Immediate discount with end date in future -> published
$immediate = new Discount();
$immediate->type = DiscountType::PERCENT;
$immediate->value = DiscountValue::fromPercentage(15);
$immediate->is_old_price_displayed = true;
$immediate->date_start = null;
$immediate->date_end = Carbon::parse('2026-06-20 23:59:59');
$immediate->save();
// 3. Started discount with no end date -> published
$ongoing = new Discount();
$ongoing->type = DiscountType::PERCENT;
$ongoing->value = DiscountValue::fromPercentage(20);
$ongoing->is_old_price_displayed = true;
$ongoing->date_start = Carbon::parse('2026-06-10 00:00:00');
$ongoing->date_end = null;
$ongoing->save();
// 4. Bounded active discount -> published
$bounded = new Discount();
$bounded->type = DiscountType::PERCENT;
$bounded->value = DiscountValue::fromPercentage(25);
$bounded->is_old_price_displayed = true;
$bounded->date_start = Carbon::parse('2026-06-10 00:00:00');
$bounded->date_end = Carbon::parse('2026-06-20 23:59:59');
$bounded->save();
// 5. Future discount -> not published
$future = new Discount();
$future->type = DiscountType::PERCENT;
$future->value = DiscountValue::fromPercentage(30);
$future->is_old_price_displayed = true;
$future->date_start = Carbon::parse('2026-06-20 00:00:00');
$future->date_end = Carbon::parse('2026-06-25 23:59:59');
$future->save();
// 6. Expired discount -> not published
$expired = new Discount();
$expired->type = DiscountType::PERCENT;
$expired->value = DiscountValue::fromPercentage(35);
$expired->is_old_price_displayed = true;
$expired->date_start = Carbon::parse('2026-06-01 00:00:00');
$expired->date_end = Carbon::parse('2026-06-10 23:59:59');
$expired->save();
// WHEN
$publishedDiscounts = Discount::query()->published()->get();
// THEN
$publishedIds = $publishedDiscounts->pluck('id')->all();
expect($publishedIds)->toContain($permanent->id)
->and($publishedIds)->toContain($immediate->id)
->and($publishedIds)->toContain($ongoing->id)
->and($publishedIds)->toContain($bounded->id)
->and($publishedIds)->not->toContain($future->id)
->and($publishedIds)->not->toContain($expired->id);
Carbon::setTestNow();
});
});

View file

@ -56,37 +56,6 @@ describe('OrderLineProductCast', function () {
->and($vo->picture)->toBe('poulet.jpg');
});
it('can cast JSON to OrderLineProduct with TVA 20% and null picture correctly', function () {
// GIVEN
$cast = new OrderLineProductCast();
$model = new class () extends Model
{
};
$json = json_encode([
'inco_id' => 123456,
'original_id' => Uuid::uuid7()->toString(),
'name' => 'Produit TVA 20',
'weight' => 500,
'weight_variable' => false,
'brand' => ProductBrand::loue->value,
'order_unit' => OrderUnit::PCE->value,
'price' => 1050,
'discounted_price' => 950,
'price_kg_ttc' => 700,
'tax_rate' => TaxRate::TVA_20->percent(), // json_encode encodes 20.0 as 20
'picture' => null,
]);
// WHEN
$vo = $cast->get($model, 'product', $json, []);
// THEN
expect($vo)->toBeInstanceOf(OrderLineProduct::class)
->and($vo->taxRate)->toBe(TaxRate::TVA_20)
->and($vo->picture)->toBeNull();
});
it('can set OrderLineProduct to JSON correctly', function () {
// GIVEN
$cast = new OrderLineProductCast();

View file

@ -6,7 +6,10 @@ use App\Models\Product;
use Domain\Enums\Units\OrderUnit;
use Domain\Models\Entities\Product as DomainProduct;
use Domain\Models\ValueObjects\BasketProduct;
use Domain\Models\ValueObjects\DiscountRate;
use Domain\Models\ValueObjects\Entities\ProductId;
use Domain\Models\ValueObjects\ProductPricing;
use Domain\Services\PricingService;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
@ -82,7 +85,22 @@ it('retrieves a basket product', function () {
$id = new ProductId(Uuid::fromString($uuid));
$repo = new EloquentProductRepository();
// Instanciation réelle du Value Object sans mock
$pricing = new ProductPricing(
basePriceHt: new Money(100, $currency),
basePriceTtc: new Money(120, $currency),
discountAmountHt: new Money(10, $currency),
discountedPriceHt: new Money(90, $currency),
discountedPriceTtc: new Money(108, $currency),
priceKgTtc: new Money(5, $currency),
discountRate: DiscountRate::fromRatio(0.1),
hasDiscount: true
);
$pricingService = \Mockery::mock(PricingService::class);
$pricingService->shouldReceive('calculateForProduct')->andReturn($pricing);
$repo = new EloquentProductRepository($pricingService);
// WHEN
$result = $repo->getBasketProduct($id, 2);
@ -118,7 +136,8 @@ it('finds a product by id', function () {
$id = new ProductId(Uuid::fromString($uuid));
$repo = new EloquentProductRepository();
$pricingService = \Mockery::mock(PricingService::class);
$repo = new EloquentProductRepository($pricingService);
// WHEN
$result = $repo->find($id);
@ -131,7 +150,8 @@ it('returns null if product by id is not found', function () {
// GIVEN
$id = new ProductId(Uuid::uuid7());
$repo = new EloquentProductRepository();
$pricingService = \Mockery::mock(PricingService::class);
$repo = new EloquentProductRepository($pricingService);
// WHEN
$result = $repo->find($id);

View file

@ -68,40 +68,9 @@ it('gets an order line product', function () {
->and($product->name)->toBe('Product Name')
->and($product->weight->value)->toBe(1000)
->and($product->weight->isVariable)->toBeFalse()
->and($product->taxRate)->toBe(TaxRate::TVA_5_5)
->and($product->picture)->toBe('pic.jpg');
});
it('gets an order line product with integer tax rate and null picture', function () {
// GIVEN
$cast = new OrderLineProductCast();
$model = \Mockery::mock(Model::class);
$uuid = Uuid::uuid7()->toString();
$json = json_encode([
'inco_id' => 123,
'original_id' => $uuid,
'name' => 'Product Name',
'weight' => 1000,
'weight_variable' => false,
'brand' => ProductBrand::loue->value,
'order_unit' => OrderUnit::PCE->value,
'price' => '1000',
'discounted_price' => '800',
'price_kg_ttc' => '1000',
'tax_rate' => 20, // integer from json_decode for 20%
'picture' => null,
]);
// WHEN
$product = $cast->get($model, 'product', $json, []);
// THEN
expect($product)->toBeInstanceOf(OrderLineProduct::class)
->and($product->taxRate)->toBe(TaxRate::TVA_20)
->and($product->picture)->toBeNull();
});
it('throws exception when setting invalid type', function () {
// GIVEN
$cast = new OrderLineProductCast();

View file

@ -13,19 +13,21 @@ use Domain\Enums\UserRole;
use Domain\Models\Entities\Product as DomainProduct;
use Domain\Models\ValueObjects\Entities\ProductId;
use Domain\Ports\Repositories\ProductRepository;
use Domain\Services\ProductAvailabilityService;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Money\Money;
use Ramsey\Uuid\Uuid;
describe('ProductPolicy', function () {
function getProductPolicy($repo = null)
function getProductPolicy($service = null, $repo = null)
{
return new ProductPolicy(
$repo ?? \Mockery::mock(ProductRepository::class)
$service ?? \Mockery::mock(ProductAvailabilityService::class),
$repo ?? \Mockery::mock(ProductRepository::class)
);
}
function createDomainProduct(bool $status = true)
function createDomainProduct()
{
return new DomainProduct(
new ProductId(Uuid::uuid7()),
@ -35,7 +37,7 @@ describe('ProductPolicy', function () {
InvoicingUnit::PCE,
SellUnit::PCE,
null,
$status,
true,
null,
null,
false
@ -322,7 +324,7 @@ describe('ProductPolicy', function () {
$repo = \Mockery::mock(ProductRepository::class);
$repo->shouldReceive('find')->andReturn(null);
$policy = getProductPolicy($repo);
$policy = getProductPolicy(null, $repo);
// WHEN
$result = $policy->viewPublished(null, $product);
@ -337,11 +339,13 @@ describe('ProductPolicy', function () {
$productId = new ProductId(Uuid::uuid7());
$product->shouldReceive('getId')->andReturn($productId);
$domainProduct = createDomainProduct(status: true);
$domainProduct = createDomainProduct();
$repo = \Mockery::mock(ProductRepository::class);
$repo->shouldReceive('find')->andReturn($domainProduct);
$policy = getProductPolicy($repo);
$service = \Mockery::mock(ProductAvailabilityService::class);
$service->shouldReceive('isPublished')->with($domainProduct)->andReturn(true);
$policy = getProductPolicy($service, $repo);
// WHEN
$result = $policy->viewPublished(null, $product);
@ -356,11 +360,13 @@ describe('ProductPolicy', function () {
$productId = new ProductId(Uuid::uuid7());
$product->shouldReceive('getId')->andReturn($productId);
$domainProduct = createDomainProduct(status: false);
$domainProduct = createDomainProduct();
$repo = \Mockery::mock(ProductRepository::class);
$repo->shouldReceive('find')->andReturn($domainProduct);
$policy = getProductPolicy($repo);
$service = \Mockery::mock(ProductAvailabilityService::class);
$service->shouldReceive('isPublished')->with($domainProduct)->andReturn(false);
$policy = getProductPolicy($service, $repo);
// WHEN
$result = $policy->viewPublished(null, $product);

View file

@ -5,15 +5,12 @@ declare(strict_types=1);
use App\Models\Customer;
use App\Models\User;
use App\Policies\Frontend\BasketPolicy;
use Domain\Enums\SellUnit;
use Domain\Enums\TaxRate;
use Domain\Enums\Units\InvoicingUnit;
use Domain\Enums\Units\OrderUnit;
use Domain\Models\Entities\Product;
use Domain\Models\ValueObjects\BasketProduct;
use Domain\Models\ValueObjects\DiscountRate;
use Domain\Models\ValueObjects\Entities\ProductId;
use Domain\Ports\Repositories\ProductRepository;
use Domain\Services\ProductAvailabilityService;
use Money\Money;
use Ramsey\Uuid\Uuid;
@ -41,30 +38,13 @@ function createDummyBasketProduct(): BasketProduct
);
}
function createTestDomainProduct(bool $status = true): Product
{
return new Product(
new ProductId(Uuid::uuid7()),
Money::EUR(1000),
TaxRate::TVA_20,
null,
InvoicingUnit::PCE,
SellUnit::PCE,
null,
$status,
null,
null,
true
);
}
describe('BasketPolicy', function () {
describe('viewTotal', function () {
it('returns true when user is a Customer instance', function () {
// GIVEN
$productRepository = \Mockery::mock(ProductRepository::class);
$policy = new BasketPolicy($productRepository);
$customer = \Mockery::mock(Customer::class);
$productService = \Mockery::mock(ProductAvailabilityService::class);
$policy = new BasketPolicy($productService);
$customer = \Mockery::mock(Customer::class);
// WHEN
$result = $policy->viewTotal($customer);
@ -75,9 +55,9 @@ describe('BasketPolicy', function () {
it('returns true when user is a User instance', function () {
// GIVEN
$productRepository = \Mockery::mock(ProductRepository::class);
$policy = new BasketPolicy($productRepository);
$user = \Mockery::mock(User::class);
$productService = \Mockery::mock(ProductAvailabilityService::class);
$policy = new BasketPolicy($productService);
$user = \Mockery::mock(User::class);
// WHEN
$result = $policy->viewTotal($user);
@ -88,8 +68,8 @@ describe('BasketPolicy', function () {
it('returns false when user is null', function () {
// GIVEN
$productRepository = \Mockery::mock(ProductRepository::class);
$policy = new BasketPolicy($productRepository);
$productService = \Mockery::mock(ProductAvailabilityService::class);
$policy = new BasketPolicy($productService);
// WHEN
$result = $policy->viewTotal(null);
@ -102,9 +82,9 @@ describe('BasketPolicy', function () {
describe('viewPrices', function () {
it('returns false when user is null', function () {
// GIVEN
$productRepository = \Mockery::mock(ProductRepository::class);
$policy = new BasketPolicy($productRepository);
$product = createDummyBasketProduct();
$productService = \Mockery::mock(ProductAvailabilityService::class);
$policy = new BasketPolicy($productService);
$product = createDummyBasketProduct();
// WHEN
$result = $policy->viewPrices(null, $product);
@ -115,17 +95,15 @@ describe('BasketPolicy', function () {
it('returns true when user is not null and product is published', function () {
// GIVEN
$productRepository = \Mockery::mock(ProductRepository::class);
$policy = new BasketPolicy($productRepository);
$customer = \Mockery::mock(Customer::class);
$product = createDummyBasketProduct();
$productService = \Mockery::mock(ProductAvailabilityService::class);
$policy = new BasketPolicy($productService);
$customer = \Mockery::mock(Customer::class);
$product = createDummyBasketProduct();
$domainProduct = createTestDomainProduct(status: true);
$productRepository->shouldReceive('find')
$productService->shouldReceive('isBasketProductPublished')
->once()
->with($product->id)
->andReturn($domainProduct);
->with($product)
->andReturn(true);
// WHEN
$result = $policy->viewPrices($customer, $product);
@ -136,36 +114,15 @@ describe('BasketPolicy', function () {
it('returns false when user is not null and product is not published', function () {
// GIVEN
$productRepository = \Mockery::mock(ProductRepository::class);
$policy = new BasketPolicy($productRepository);
$user = \Mockery::mock(User::class);
$product = createDummyBasketProduct();
$productService = \Mockery::mock(ProductAvailabilityService::class);
$policy = new BasketPolicy($productService);
$user = \Mockery::mock(User::class);
$product = createDummyBasketProduct();
$domainProduct = createTestDomainProduct(status: false);
$productRepository->shouldReceive('find')
$productService->shouldReceive('isBasketProductPublished')
->once()
->with($product->id)
->andReturn($domainProduct);
// WHEN
$result = $policy->viewPrices($user, $product);
// THEN
expect($result)->toBeFalse();
});
it('returns false when user is not null and product is not found', function () {
// GIVEN
$productRepository = \Mockery::mock(ProductRepository::class);
$policy = new BasketPolicy($productRepository);
$user = \Mockery::mock(User::class);
$product = createDummyBasketProduct();
$productRepository->shouldReceive('find')
->once()
->with($product->id)
->andReturn(null);
->with($product)
->andReturn(false);
// WHEN
$result = $policy->viewPrices($user, $product);

View file

@ -14,17 +14,14 @@ use Domain\Models\Entities\Product as DomainProduct;
use Domain\Models\ValueObjects\Entities\ProductId;
use Domain\Models\ValueObjects\ProductWeight;
use Domain\Ports\Repositories\ProductRepository;
use Domain\Services\ProductAvailabilityService;
use Money\Money;
use Ramsey\Uuid\Uuid;
covers(ProductPolicy::class);
function createDummyDomainProduct(
bool $status = true,
?CarbonImmutable $orderableStart = null,
?CarbonImmutable $orderableEnd = null,
bool $hasInco = true
): DomainProduct {
function createDummyDomainProduct(): DomainProduct
{
$productId = new ProductId(Uuid::uuid7());
return new DomainProduct(
@ -35,10 +32,10 @@ function createDummyDomainProduct(
InvoicingUnit::KGM,
SellUnit::KGM,
null,
$status,
$orderableStart ?? CarbonImmutable::now()->subDay(),
$orderableEnd ?? CarbonImmutable::now()->addDay(),
$hasInco
true,
CarbonImmutable::now()->subDay(),
CarbonImmutable::now()->addDay(),
true
);
}
@ -46,16 +43,17 @@ describe('ProductPolicy', function () {
describe('viewPublished', function () {
it('returns false when domain product is not found', function () {
// GIVEN
$productService = \Mockery::mock(ProductAvailabilityService::class);
$productRepository = \Mockery::mock(ProductRepository::class);
$policy = new ProductPolicy($productRepository);
$policy = new ProductPolicy($productService, $productRepository);
$productId = new ProductId(Uuid::uuid7());
$eloquentProduct = \Mockery::mock(EloquentProduct::class);
$eloquentProduct->shouldReceive('getId')->once()->andReturn($productId);
$productRepository->shouldReceive('find')->once()->with($productId)->andReturn(null);
// WHEN
// When
$result = $policy->viewPublished(null, $eloquentProduct);
// THEN
@ -64,18 +62,21 @@ describe('ProductPolicy', function () {
it('returns false when domain product is found but not published', function () {
// GIVEN
$productService = \Mockery::mock(ProductAvailabilityService::class);
$productRepository = \Mockery::mock(ProductRepository::class);
$policy = new ProductPolicy($productRepository);
$policy = new ProductPolicy($productService, $productRepository);
$productId = new ProductId(Uuid::uuid7());
$eloquentProduct = \Mockery::mock(EloquentProduct::class);
$eloquentProduct->shouldReceive('getId')->once()->andReturn($productId);
$domainProduct = createDummyDomainProduct(status: false);
$domainProduct = createDummyDomainProduct();
$productRepository->shouldReceive('find')->once()->with($productId)->andReturn($domainProduct);
$productService->shouldReceive('isPublished')->once()->with($domainProduct)->andReturn(false);
// WHEN
// When
$result = $policy->viewPublished(null, $eloquentProduct);
// THEN
@ -84,18 +85,21 @@ describe('ProductPolicy', function () {
it('returns true when domain product is found and published', function () {
// GIVEN
$productService = \Mockery::mock(ProductAvailabilityService::class);
$productRepository = \Mockery::mock(ProductRepository::class);
$policy = new ProductPolicy($productRepository);
$policy = new ProductPolicy($productService, $productRepository);
$productId = new ProductId(Uuid::uuid7());
$eloquentProduct = \Mockery::mock(EloquentProduct::class);
$eloquentProduct->shouldReceive('getId')->once()->andReturn($productId);
$domainProduct = createDummyDomainProduct(status: true);
$domainProduct = createDummyDomainProduct();
$productRepository->shouldReceive('find')->once()->with($productId)->andReturn($domainProduct);
$productService->shouldReceive('isPublished')->once()->with($domainProduct)->andReturn(true);
// WHEN
// When
$result = $policy->viewPublished(null, $eloquentProduct);
// THEN
@ -106,16 +110,17 @@ describe('ProductPolicy', function () {
describe('addToBasket', function () {
it('returns false when domain product is not found', function () {
// GIVEN
$productService = \Mockery::mock(ProductAvailabilityService::class);
$productRepository = \Mockery::mock(ProductRepository::class);
$policy = new ProductPolicy($productRepository);
$policy = new ProductPolicy($productService, $productRepository);
$productId = new ProductId(Uuid::uuid7());
$eloquentProduct = \Mockery::mock(EloquentProduct::class);
$eloquentProduct->shouldReceive('getId')->once()->andReturn($productId);
$productRepository->shouldReceive('find')->once()->with($productId)->andReturn(null);
// WHEN
// When
$result = $policy->addToBasket(null, $eloquentProduct);
// THEN
@ -124,21 +129,21 @@ describe('ProductPolicy', function () {
it('returns false when domain product is found but not orderable', function () {
// GIVEN
$productService = \Mockery::mock(ProductAvailabilityService::class);
$productRepository = \Mockery::mock(ProductRepository::class);
$policy = new ProductPolicy($productRepository);
$policy = new ProductPolicy($productService, $productRepository);
$productId = new ProductId(Uuid::uuid7());
$eloquentProduct = \Mockery::mock(EloquentProduct::class);
$eloquentProduct->shouldReceive('getId')->once()->andReturn($productId);
$domainProduct = createDummyDomainProduct(
orderableStart: CarbonImmutable::now()->addDays(2),
orderableEnd: CarbonImmutable::now()->addDays(5)
);
$domainProduct = createDummyDomainProduct();
$productRepository->shouldReceive('find')->once()->with($productId)->andReturn($domainProduct);
$productService->shouldReceive('isOrderable')->once()->with($domainProduct)->andReturn(false);
// WHEN
// When
$result = $policy->addToBasket(null, $eloquentProduct);
// THEN
@ -147,21 +152,21 @@ describe('ProductPolicy', function () {
it('returns true when domain product is found and orderable', function () {
// GIVEN
$productService = \Mockery::mock(ProductAvailabilityService::class);
$productRepository = \Mockery::mock(ProductRepository::class);
$policy = new ProductPolicy($productRepository);
$policy = new ProductPolicy($productService, $productRepository);
$productId = new ProductId(Uuid::uuid7());
$eloquentProduct = \Mockery::mock(EloquentProduct::class);
$eloquentProduct->shouldReceive('getId')->once()->andReturn($productId);
$domainProduct = createDummyDomainProduct(
orderableStart: CarbonImmutable::now()->subDay(),
orderableEnd: CarbonImmutable::now()->addDay()
);
$domainProduct = createDummyDomainProduct();
$productRepository->shouldReceive('find')->once()->with($productId)->andReturn($domainProduct);
$productService->shouldReceive('isOrderable')->once()->with($domainProduct)->andReturn(true);
// WHEN
// When
$result = $policy->addToBasket(null, $eloquentProduct);
// THEN
@ -172,12 +177,13 @@ describe('ProductPolicy', function () {
describe('viewPrices', function () {
it('returns false when user is null', function () {
// GIVEN
$productService = \Mockery::mock(ProductAvailabilityService::class);
$productRepository = \Mockery::mock(ProductRepository::class);
$policy = new ProductPolicy($productRepository);
$policy = new ProductPolicy($productService, $productRepository);
$eloquentProduct = \Mockery::mock(EloquentProduct::class);
// WHEN
// When
$result = $policy->viewPrices(null, $eloquentProduct);
// THEN
@ -186,8 +192,9 @@ describe('ProductPolicy', function () {
it('returns false when user is a Customer and domain product is not found', function () {
// GIVEN
$productService = \Mockery::mock(ProductAvailabilityService::class);
$productRepository = \Mockery::mock(ProductRepository::class);
$policy = new ProductPolicy($productRepository);
$policy = new ProductPolicy($productService, $productRepository);
$customer = \Mockery::mock(Customer::class);
$productId = new ProductId(Uuid::uuid7());
@ -195,8 +202,8 @@ describe('ProductPolicy', function () {
$eloquentProduct->shouldReceive('getId')->once()->andReturn($productId);
$productRepository->shouldReceive('find')->once()->with($productId)->andReturn(null);
// WHEN
// When
$result = $policy->viewPrices($customer, $eloquentProduct);
// THEN
@ -205,19 +212,22 @@ describe('ProductPolicy', function () {
it('returns false when user is a User and domain product is found but not published', function () {
// GIVEN
$productService = \Mockery::mock(ProductAvailabilityService::class);
$productRepository = \Mockery::mock(ProductRepository::class);
$policy = new ProductPolicy($productRepository);
$policy = new ProductPolicy($productService, $productRepository);
$user = \Mockery::mock(User::class);
$productId = new ProductId(Uuid::uuid7());
$eloquentProduct = \Mockery::mock(EloquentProduct::class);
$eloquentProduct->shouldReceive('getId')->once()->andReturn($productId);
$domainProduct = createDummyDomainProduct(status: false);
$domainProduct = createDummyDomainProduct();
$productRepository->shouldReceive('find')->once()->with($productId)->andReturn($domainProduct);
$productService->shouldReceive('isPublished')->once()->with($domainProduct)->andReturn(false);
// WHEN
// When
$result = $policy->viewPrices($user, $eloquentProduct);
// THEN
@ -226,19 +236,22 @@ describe('ProductPolicy', function () {
it('returns true when user is a Customer and domain product is found and published', function () {
// GIVEN
$productService = \Mockery::mock(ProductAvailabilityService::class);
$productRepository = \Mockery::mock(ProductRepository::class);
$policy = new ProductPolicy($productRepository);
$policy = new ProductPolicy($productService, $productRepository);
$customer = \Mockery::mock(Customer::class);
$productId = new ProductId(Uuid::uuid7());
$eloquentProduct = \Mockery::mock(EloquentProduct::class);
$eloquentProduct->shouldReceive('getId')->once()->andReturn($productId);
$domainProduct = createDummyDomainProduct(status: true);
$domainProduct = createDummyDomainProduct();
$productRepository->shouldReceive('find')->once()->with($productId)->andReturn($domainProduct);
$productService->shouldReceive('isPublished')->once()->with($domainProduct)->andReturn(true);
// WHEN
// When
$result = $policy->viewPrices($customer, $eloquentProduct);
// THEN

View file

@ -51,53 +51,4 @@ describe('TaxRateEnum', function () {
expect($amountWithTax20)->toBe('12000') // 120.00 EUR
->and($amountWithTax55)->toBe('10550'); // 105.50 EUR
});
it('creates TaxRate from percent with float, int, or string', function () {
// GIVEN
$int20 = 20;
$float20 = 20.0;
$float55 = 5.5;
// WHEN
$rateFromInt = TaxRate::fromPercent($int20);
$rateFromFloat20 = TaxRate::fromPercent($float20);
$rateFromFloat55 = TaxRate::fromPercent($float55);
// THEN
expect($rateFromInt)->toBe(TaxRate::TVA_20)
->and($rateFromFloat20)->toBe(TaxRate::TVA_20)
->and($rateFromFloat55)->toBe(TaxRate::TVA_5_5);
});
it('creates TaxRate from enum value fallback', function () {
// GIVEN
$val1 = 1;
$val2 = 2;
// WHEN
$rate1 = TaxRate::fromPercent($val1);
$rate2 = TaxRate::fromPercent($val2);
// THEN
expect($rate1)->toBe(TaxRate::TVA_20)
->and($rate2)->toBe(TaxRate::TVA_5_5);
});
it('throws when percent is not numeric', function () {
// GIVEN
$invalid = 0.8;
// WHEN / THEN
expect(fn () => TaxRate::fromPercent($invalid))
->toThrow(\UnexpectedValueException::class, 'Unhandled tax rate: 0.8');
});
it('throws when percent is unhandled', function () {
// GIVEN
$unhandled = 10.0;
// WHEN / THEN
expect(fn () => TaxRate::fromPercent($unhandled))
->toThrow(\UnexpectedValueException::class, 'Unhandled tax rate: 10');
});
});

View file

@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Enums\Units;
use Domain\Enums\Units\OrderUnit;
describe('OrderUnit', function (): void {
it('returns the correct Regate code for each unit', function (OrderUnit $unit, string $expectedCode): void {
// GIVEN
// $unit and $expectedCode from dataset
// WHEN
$code = $unit->toRegateCode();
// THEN
expect($code)->toBe($expectedCode);
})->with([
'colis' => [OrderUnit::COLIS, 'UEA'],
'kgm' => [OrderUnit::KGM, 'KGM'],
'pce' => [OrderUnit::PCE, 'UB'],
]);
});

View file

@ -4,23 +4,15 @@ declare(strict_types=1);
namespace Tests\Unit\Domain\Events;
use Domain\Enums\ProductBrand;
use Domain\Enums\TaxRate;
use Domain\Enums\Units\OrderUnit;
use Domain\Events\Dto\OrderPlacedLine;
use Domain\Events\Dto\OrderPlacedShop;
use Domain\Events\Dto\RegateOrderCsvGenerated;
use Domain\Events\Dto\SalariesHaveBeenSynced;
use Domain\Models\ValueObjects\DeliveryAddress;
use Domain\Models\ValueObjects\Entities\ProductId;
use Domain\Models\ValueObjects\IncoId;
use Domain\Models\ValueObjects\Matricule;
use Domain\Models\ValueObjects\OrderLine;
use Domain\Models\ValueObjects\OrderLineProduct;
use Domain\Models\ValueObjects\ProductWeight;
use Money\Currency;
use Money\Money;
use Ramsey\Uuid\Uuid;
describe('OrderPlacedLine', function () {
it('creates OrderPlacedLine correctly', function () {
@ -50,45 +42,6 @@ describe('OrderPlacedLine', function () {
// THEN
expect($action)->toThrow(\DomainException::class, 'Quantity must be greater than 0');
});
it('creates OrderPlacedLine from OrderLine', function () {
// GIVEN
$product = new OrderLineProduct(
incoId: new IncoId(42),
originalId: new ProductId(Uuid::uuid7()),
name: 'Poulet rôti',
weight: new ProductWeight(1000, false),
brand: ProductBrand::none,
orderUnit: OrderUnit::PCE,
price: Money::EUR(1000),
discountedPrice: Money::EUR(1000),
priceKgTtc: Money::EUR(1200),
taxRate: TaxRate::TVA_20,
picture: null
);
$line = new OrderLine(
product: $product,
quantity: 3,
unitPriceHt: Money::EUR(1000),
unitDiscount: Money::EUR(0),
unitTax: Money::EUR(200),
totalHt: Money::EUR(3000),
totalDiscount: Money::EUR(0),
totalTax: Money::EUR(600),
totalTtc: Money::EUR(3600)
);
// WHEN
$dto = OrderPlacedLine::fromOrderLine($line);
// THEN
expect($dto->productName)->toBe('Poulet rôti')
->and($dto->productIncoId->value)->toBe(42)
->and($dto->quantity)->toBe(3)
->and($dto->totalTtc->getAmount())->toBe('3600')
->and($dto->productOrderUnit)->toBe(OrderUnit::PCE);
});
});
describe('OrderPlacedShop', function () {
@ -106,25 +59,6 @@ describe('OrderPlacedShop', function () {
expect($shop->name)->toBe('Name');
});
it('creates OrderPlacedShop from DeliveryAddress', function () {
// GIVEN
$address = new DeliveryAddress(
name: 'Boutique Paris',
address: '10 avenue des Champs',
postcode: '75008',
city: 'Paris'
);
// WHEN
$dto = OrderPlacedShop::fromDeliveryAddress($address);
// THEN
expect($dto->name)->toBe('Boutique Paris')
->and($dto->address)->toBe('10 avenue des Champs')
->and($dto->postcode)->toBe('75008')
->and($dto->city)->toBe('Paris');
});
it('throws exception if shop name is empty', function () {
// GIVEN
$name = ' ';

View file

@ -4,25 +4,15 @@ declare(strict_types=1);
namespace Tests\Unit\Domain\Models\Entities;
use Carbon\CarbonImmutable;
use Domain\Enums\OrderStatus;
use Domain\Enums\ProductBrand;
use Domain\Enums\TaxRate;
use Domain\Enums\Units\OrderUnit;
use Domain\Events\AnOrderHasBeenPlaced;
use Domain\Models\Entities\Basket;
use Domain\Models\Entities\Order;
use Domain\Models\Entities\Shop;
use Domain\Models\Entities\Shopper;
use Domain\Models\ValueObjects\BasketProduct;
use Domain\Models\ValueObjects\DeliveryAddress;
use Domain\Models\ValueObjects\DiscountRate;
use Domain\Models\ValueObjects\Email;
use Domain\Models\ValueObjects\Entities\BasketId;
use Domain\Models\ValueObjects\Entities\OrderId;
use Domain\Models\ValueObjects\Entities\ProductId;
use Domain\Models\ValueObjects\Entities\ShopId;
use Domain\Models\ValueObjects\Entities\ShopperId;
use Domain\Models\ValueObjects\IncoId;
use Domain\Models\ValueObjects\Matricule;
use Domain\Models\ValueObjects\OrderEmployee;
@ -209,96 +199,3 @@ describe('Order - id', function (): void {
expect($id)->toBeInstanceOf(OrderId::class);
});
});
describe('Order - fromBasket factory', function (): void {
it('creates an order from a basket with all lines mapped', function (): void {
// GIVEN
$basketId = new BasketId(Uuid::uuid7());
$basket = new Basket($basketId);
$basketProduct = new BasketProduct(
id: new ProductId(Uuid::uuid7()),
name: 'Poulet Rôti',
qty: 2,
unitPriceHt: Money::EUR(1000),
unitDiscount: Money::EUR(100),
taxRate: TaxRate::TVA_5_5,
weightGrams: 1200,
brand: ProductBrand::marie,
incoId: new IncoId(45),
orderUnit: OrderUnit::PCE,
slug: 'poulet-roti',
picture: 'images/poulet.jpg',
description: 'Un bon poulet',
weightVariable: false,
discountRate: DiscountRate::fromRatio(0.1),
priceKgTtc: Money::EUR(1500)
);
$basket->addOrUpdate($basketProduct);
$orderId = new OrderId(Uuid::uuid7());
$reference = new OrderReference(Uuid::uuid7());
$pickupTimeSlot = new PickupTimeSlot(
new \DateTimeImmutable('+1 day 10:00'),
new \DateInterval('PT10M')
);
$shopper = new Shopper(
id: new ShopperId(Uuid::uuid7()),
email: new Email('jean.dupont@example.com'),
matricule: new Matricule('1234567'),
nom: 'Dupont',
prenom: 'Jean',
startDate: CarbonImmutable::now(),
endDate: null,
etablissement: new ShopperEstablishmentCode('ETAB01'),
site: new ShopperSiteCode('SITEA'),
service: new ShopperServiceCode('101')
);
$shop = new Shop(
id: new ShopId(Uuid::uuid7()),
name: 'Cocotte Lyon',
address: '12 rue de la Paix',
postcode: '69001',
city: 'Lyon',
phone: '0478123456'
);
// WHEN
$order = Order::fromBasket($orderId, $reference, $basket, $pickupTimeSlot, $shopper, $shop);
// THEN
expect($order->id())->toBe($orderId)
->and($order->reference)->toBe($reference)
->and($order->status)->toBe(OrderStatus::VALIDATED)
->and($order->employee->firstname)->toBe('Jean')
->and($order->employee->lastname)->toBe('Dupont')
->and($order->deliveryAddress->name)->toBe('Cocotte Lyon')
->and($order->getLines())->toHaveCount(1)
->and($order->getLines()[0]->product->name)->toBe('Poulet Rôti')
->and($order->getLines()[0]->quantity)->toBe(2);
});
});
describe('Order - Domain Events', function (): void {
it('produces an AnOrderHasBeenPlaced domain event', function (): void {
// GIVEN
$order = makeOrder();
// WHEN
$event = $order->toPlacedEvent();
// THEN
expect($event)->toBeInstanceOf(AnOrderHasBeenPlaced::class)
->and($event->orderId)->toBe($order->id())
->and($event->reference)->toBe($order->reference)
->and($event->totalTtc->getAmount())->toBe($order->getTotal()->getAmount())
->and($event->byMatricule)->toBe($order->employee->matricule)
->and($event->employee->matricule)->toBe($order->employee->matricule)
->and($event->shop->name)->toBe($order->deliveryAddress->name)
->and($event->lines)->toHaveCount(count($order->getLines()))
->and($event->lines[0]->productName)->toBe($order->getLines()[0]->product->name);
});
});

View file

@ -39,32 +39,4 @@ describe('PickupExceptionOnDate', function (): void {
->and($entity->repeatFrequency)->toBe($repeatFrequency)
->and($entity->autoUpdated)->toBe($autoUpdated);
});
it('determines if exception falls on a pickup day', function (): void {
// GIVEN
$fridayDate = CarbonImmutable::create(2025, 1, 3); // 2025-01-03 is Friday
$mondayDate = CarbonImmutable::create(2025, 1, 6); // 2025-01-06 is Monday
$fridayException = new PickupExceptionOnDate(
new PickupExceptionId(Uuid::uuid7()),
$fridayDate,
'Friday holiday',
false,
RepeatFrequency::YEARLY,
false
);
$mondayException = new PickupExceptionOnDate(
new PickupExceptionId(Uuid::uuid7()),
$mondayDate,
'Monday holiday',
false,
RepeatFrequency::YEARLY,
false
);
// WHEN & THEN
expect($fridayException->isPickupDay())->toBeTrue()
->and($mondayException->isPickupDay())->toBeFalse();
});
});

View file

@ -11,38 +11,10 @@ use Domain\Enums\Units\InvoicingUnit;
use Domain\Models\Entities\Product;
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 Ramsey\Uuid\Uuid;
function makeProduct(
?Money $price = null,
TaxRate $taxRate = TaxRate::TVA_20,
?ProductWeight $weight = null,
InvoicingUnit $invoicingUnit = InvoicingUnit::PCE,
SellUnit $sellUnit = SellUnit::PCE,
?DiscountValue $discount = null,
bool $status = true,
?CarbonImmutable $orderableStart = null,
?CarbonImmutable $orderableEnd = null,
bool $hasInco = true
): Product {
return new Product(
id: new ProductId(Uuid::uuid7()),
price: $price ?? Money::EUR(1000),
tax_rate: $taxRate,
product_weight: $weight,
invoicing_unit: $invoicingUnit,
sell_unit: $sellUnit,
validDiscount: $discount,
status: $status,
orderable_start: $orderableStart,
orderable_end: $orderableEnd,
hasInco: $hasInco
);
}
describe('Product', function (): void {
it('can instantiate correctly', function (): void {
// GIVEN
@ -86,413 +58,4 @@ describe('Product', function (): void {
->and($entity->orderable_end)->toBe($orderableEnd)
->and($entity->hasInco)->toBe($hasInco);
});
describe('isPublished', function (): void {
it('returns true when status is active', function (): void {
// GIVEN
$product = makeProduct(status: true);
// WHEN
$result = $product->isPublished();
// THEN
expect($result)->toBeTrue();
});
it('returns false when status is inactive', function (): void {
// GIVEN
$product = makeProduct(status: false);
// WHEN
$result = $product->isPublished();
// THEN
expect($result)->toBeFalse();
});
});
describe('hasInco', function (): void {
it('reflects the hasInco property', function (): void {
// GIVEN
$withInco = makeProduct(hasInco: true);
$withoutInco = makeProduct(hasInco: false);
// WHEN & THEN
expect($withInco->hasInco())->toBeTrue()
->and($withoutInco->hasInco())->toBeFalse();
});
});
describe('hasDiscount', function (): void {
it('returns true when validDiscount exists', function (): void {
// GIVEN
$product = makeProduct(discount: DiscountValue::fromPercentage(15));
// WHEN
$result = $product->hasDiscount();
// THEN
expect($result)->toBeTrue();
});
it('returns false when validDiscount is null', function (): void {
// GIVEN
$product = makeProduct(discount: null);
// WHEN
$result = $product->hasDiscount();
// THEN
expect($result)->toBeFalse();
});
});
describe('canBeOrderOn', function (): void {
it('returns true when orderable dates are not defined and product is active with inco', function (): void {
// GIVEN
$product = makeProduct(orderableStart: null, orderableEnd: null, status: true, hasInco: true);
$now = CarbonImmutable::now();
// WHEN
$result = $product->canBeOrderOn($now);
// THEN
expect($result)->toBeTrue();
});
it('returns true when date is between start and end', function (): void {
// GIVEN
$now = CarbonImmutable::now();
$product = makeProduct(
status: true,
orderableStart: $now->subDay(),
orderableEnd: $now->addDay(),
hasInco: true
);
// WHEN
$result = $product->canBeOrderOn($now);
// THEN
expect($result)->toBeTrue();
});
it('returns false when date is before start', function (): void {
// GIVEN
$now = CarbonImmutable::now();
$product = makeProduct(
status: true,
orderableStart: $now->addDay(),
orderableEnd: $now->addDays(3),
hasInco: true
);
// WHEN
$result = $product->canBeOrderOn($now);
// THEN
expect($result)->toBeFalse();
});
it('returns false when date is after end', function (): void {
// GIVEN
$now = CarbonImmutable::now();
$product = makeProduct(
status: true,
orderableStart: $now->subDays(3),
orderableEnd: $now->subDay(),
hasInco: true
);
// WHEN
$result = $product->canBeOrderOn($now);
// THEN
expect($result)->toBeFalse();
});
it('returns false when product has no inco', function (): void {
// GIVEN
$now = CarbonImmutable::now();
$product = makeProduct(
status: true,
orderableStart: null,
orderableEnd: null,
hasInco: false
);
// WHEN
$result = $product->canBeOrderOn($now);
// THEN
expect($result)->toBeFalse();
});
});
describe('isOrderable', function (): void {
it('returns true when published and within orderable range', function (): void {
// GIVEN
$now = CarbonImmutable::now();
$product = makeProduct(
status: true,
orderableStart: $now->subDay(),
orderableEnd: $now->addDay(),
hasInco: true
);
// WHEN
$result = $product->isOrderable($now);
// THEN
expect($result)->toBeTrue();
});
it('returns false when not published even if within date range', function (): void {
// GIVEN
$now = CarbonImmutable::now();
$product = makeProduct(
status: false,
orderableStart: $now->subDay(),
orderableEnd: $now->addDay(),
hasInco: true
);
// WHEN
$result = $product->isOrderable($now);
// THEN
expect($result)->toBeFalse();
});
it('returns false when published but outside date range', function (): void {
// GIVEN
$now = CarbonImmutable::now();
$product = makeProduct(
status: true,
orderableStart: $now->addDay(),
orderableEnd: $now->addDays(2),
hasInco: true
);
// WHEN
$result = $product->isOrderable($now);
// THEN
expect($result)->toBeFalse();
});
});
describe('pricing calculations', function (): void {
it('calculates standard pricing without discounts', function (): void {
// GIVEN
$product = makeProduct(
price: Money::EUR(1000), // 10.00 EUR HT
taxRate: TaxRate::TVA_20,
invoicingUnit: InvoicingUnit::PCE,
sellUnit: SellUnit::PCE
);
// WHEN
$pricing = $product->pricing();
// THEN
expect($pricing)->toBeInstanceOf(ProductPricing::class)
->and($pricing->basePriceHt())->toEqual(Money::EUR(1000))
->and($pricing->basePriceTtc())->toEqual(Money::EUR(1200))
->and($pricing->discountAmountHt())->toEqual(Money::EUR(0))
->and($pricing->discountedPriceHt())->toEqual(Money::EUR(1000))
->and($pricing->discountedPriceTtc())->toEqual(Money::EUR(1200))
->and($pricing->hasDiscount)->toBeFalse()
->and($pricing->discountRate->ratio())->toBe(0.0);
});
it('calculates weighted base price HT when invoiced by KGM and sold by PCE', function (): void {
// GIVEN
// 20€/kg HT, 500g weight => 10€ HT
$product = makeProduct(
price: Money::EUR(2000),
taxRate: TaxRate::TVA_20,
weight: new ProductWeight(500, false),
invoicingUnit: InvoicingUnit::KGM,
sellUnit: SellUnit::PCE
);
// WHEN
$basePriceHt = $product->basePriceHt();
// THEN
expect($basePriceHt)->toEqual(Money::EUR(1000));
});
it('applies percentage discount properly', function (): void {
// GIVEN
$product = makeProduct(
price: Money::EUR(1000), // 10.00 EUR HT
taxRate: TaxRate::TVA_20,
discount: DiscountValue::fromPercentage(20) // 20% discount
);
// WHEN
$pricing = $product->pricing();
// THEN
expect($pricing->hasDiscount)->toBeTrue()
->and($pricing->discountAmountHt())->toEqual(Money::EUR(200))
->and($pricing->discountedPriceHt())->toEqual(Money::EUR(800))
->and($pricing->discountedPriceTtc())->toEqual(Money::EUR(960))
->and($pricing->discountRate->percentage())->toBe(20.0);
});
it('applies fixed discount properly and caps at base price HT', function (): void {
// GIVEN
$product = makeProduct(
price: Money::EUR(1000), // 10.00 EUR HT
taxRate: TaxRate::TVA_20,
discount: DiscountValue::fromAmount(Money::EUR(1500)) // 15.00 EUR discount > 10.00 EUR
);
// WHEN
$pricing = $product->pricing();
// THEN
expect($pricing->discountAmountHt())->toEqual(Money::EUR(1000))
->and($pricing->discountedPriceHt())->toEqual(Money::EUR(0))
->and($pricing->discountedPriceTtc())->toEqual(Money::EUR(0));
});
it('calculates virtual price per KG TTC for PCE product with weight', function (): void {
// GIVEN
// 10€ HT, 20% VAT => 12€ TTC. Weight: 500g (0.5kg) => 24€/kg TTC
$product = makeProduct(
price: Money::EUR(1000),
taxRate: TaxRate::TVA_20,
weight: new ProductWeight(500, false),
invoicingUnit: InvoicingUnit::PCE,
sellUnit: SellUnit::PCE
);
// WHEN
$priceKgTtc = $product->priceKgTtc();
// THEN
expect($priceKgTtc)->toEqual(Money::EUR(2400));
});
it('returns standard price as price per KG TTC if the product status is false', function (): void {
// GIVEN
$product = makeProduct(
price: Money::EUR(1000),
taxRate: TaxRate::TVA_20,
weight: new ProductWeight(250, false),
invoicingUnit: InvoicingUnit::PCE,
sellUnit: SellUnit::PCE,
status: false
);
// WHEN
$result = $product->pricing();
// THEN
expect($result->priceKgTtc())->toEqual(Money::EUR(1200));
});
it('returns a 0.0 discount rate if base price HT is 0', function (): void {
// GIVEN
$product = makeProduct(
price: Money::EUR(0),
discount: DiscountValue::fromAmount(Money::EUR(0))
);
// WHEN
$result = $product->pricing();
// THEN
expect($result->discountRate->basisPoints())->toBe(0);
});
it('calculates the price per KG TTC for a product invoiced by KGM and sold by PCE', function (): void {
// GIVEN
$product = makeProduct(
price: Money::EUR(865), // 8.65 €/kg
taxRate: TaxRate::TVA_5_5,
weight: new ProductWeight(360, false), // 360g
invoicingUnit: InvoicingUnit::KGM,
sellUnit: SellUnit::PCE,
status: true
);
// WHEN
$result = $product->pricing();
// THEN
expect($result->basePriceHt())->toEqual(Money::EUR(312))
->and($result->basePriceTtc())->toEqual(Money::EUR(330))
->and($result->priceKgTtc())->toEqual(Money::EUR(913));
});
it('calculates the price per KG TTC for a product invoiced by KGM with percentage discount', function (): void {
// GIVEN
$product = makeProduct(
price: Money::EUR(865), // 8.65 €/kg
taxRate: TaxRate::TVA_5_5,
weight: new ProductWeight(360, false), // 360g
invoicingUnit: InvoicingUnit::KGM,
sellUnit: SellUnit::PCE,
discount: DiscountValue::fromPercentage(10),
status: true
);
// WHEN
$result = $product->pricing();
// THEN
expect($result->discountedPriceHt())->toEqual(Money::EUR(280))
->and($result->discountedPriceTtc())->toEqual(Money::EUR(296))
->and($result->priceKgTtc())->toEqual(Money::EUR(821));
});
it('calculates the price per KG TTC for a product invoiced by KGM with fixed amount discount', function (): void {
// GIVEN
$product = makeProduct(
price: Money::EUR(865), // 8.65 €/kg
taxRate: TaxRate::TVA_5_5,
weight: new ProductWeight(360, false),
invoicingUnit: InvoicingUnit::KGM,
sellUnit: SellUnit::PCE,
discount: DiscountValue::fromAmount(Money::EUR(50)), // 50 cts discount on piece
status: true
);
// WHEN
$result = $product->pricing();
// THEN
// 50 cts off a 360 g piece => 139 cts off per kg: (865 - 139) HT x 1.055
expect($result->discountedPriceTtc())->toEqual(Money::EUR(277))
->and($result->priceKgTtc())->toEqual(Money::EUR(766));
});
it('caps the discount at the piece price when a fixed discount exceeds the piece price for KGM', function (): void {
// GIVEN
$product = makeProduct(
price: Money::EUR(865), // 8.65 €/kg => 3.12 € HT for 360g
taxRate: TaxRate::TVA_5_5,
weight: new ProductWeight(360, false),
invoicingUnit: InvoicingUnit::KGM,
sellUnit: SellUnit::PCE,
discount: DiscountValue::fromAmount(Money::EUR(500)), // 5.00 € discount > 3.12 €
status: true
);
// WHEN
$result = $product->pricing();
// THEN
expect($result->discountAmountHt())->toEqual(Money::EUR(312))
->and($result->discountedPriceHt())->toEqual(Money::EUR(0))
->and($result->discountedPriceTtc())->toEqual(Money::EUR(0))
->and($result->priceKgTtc())->toEqual(Money::EUR(0))
->and($result->discountRate->basisPoints())->toBe(10000);
});
});
});

View file

@ -63,43 +63,4 @@ describe('ShopPickupOverride', function (): void {
// THEN
expect($action)->toThrow(\DomainException::class, 'Order deadline must be strictly before the replacement date');
});
it('correctly handles replacement and deadlines', function (): void {
// GIVEN
$exception = new PickupExceptionOnDate(
new PickupExceptionId(Uuid::uuid7()),
CarbonImmutable::create(2025, 5, 1),
'Labour Day',
false,
RepeatFrequency::YEARLY,
false
);
$defaultDeadline = CarbonImmutable::create(2025, 4, 28, 14, 0, 0);
$blockedOverride = new ShopPickupOverride(
exception: $exception,
replacementDate: null,
orderDeadlineAt: null
);
$customDeadline = CarbonImmutable::create(2025, 4, 29, 12, 0, 0);
$activeOverride = new ShopPickupOverride(
exception: $exception,
replacementDate: CarbonImmutable::create(2025, 5, 2),
orderDeadlineAt: $customDeadline
);
// WHEN & THEN
expect($blockedOverride->hasReplacement())->toBeFalse()
->and($blockedOverride->effectiveDeadline($defaultDeadline)->equalTo($defaultDeadline))->toBeTrue()
->and($activeOverride->hasReplacement())->toBeTrue()
->and($activeOverride->effectiveDeadline($defaultDeadline)->equalTo($customDeadline))->toBeTrue();
$beforeCustom = CarbonImmutable::create(2025, 4, 29, 10, 0, 0);
$afterCustom = CarbonImmutable::create(2025, 4, 29, 15, 0, 0);
expect($activeOverride->isDeadlinePassed($beforeCustom, $defaultDeadline))->toBeFalse()
->and($activeOverride->isDeadlinePassed($afterCustom, $defaultDeadline))->toBeTrue();
});
});

View file

@ -0,0 +1,362 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Services;
use Domain\Enums\SellUnit;
use Domain\Enums\TaxRate;
use Domain\Enums\Units\InvoicingUnit;
use Domain\Models\Entities\Product;
use Domain\Models\ValueObjects\DiscountValue;
use Domain\Models\ValueObjects\Entities\ProductId;
use Domain\Models\ValueObjects\ProductPricing;
use Domain\Models\ValueObjects\ProductWeight;
use Domain\Services\PricingService;
use Money\Currency;
use Money\Money;
use Ramsey\Uuid\Uuid;
/**
* @param array{
* id?: \Domain\Models\ValueObjects\Entities\ProductId,
* price?: \Money\Money,
* tax_rate?: \Domain\Enums\TaxRate,
* product_weight?: \Domain\Models\ValueObjects\ProductWeight|null,
* invoicing_unit?: \Domain\Enums\Units\InvoicingUnit,
* sell_unit?: \Domain\Enums\SellUnit,
* validDiscount?: \Domain\Models\ValueObjects\DiscountValue|null,
* status?: bool,
* orderable_start?: \Carbon\CarbonImmutable|null,
* orderable_end?: \Carbon\CarbonImmutable|null,
* hasInco?: bool,
* } $attributes
*/
function createBaseProduct(array $attributes = []): Product
{
$currency = new Currency('EUR');
$defaultAttributes = [
'id' => new ProductId(Uuid::uuid7()),
'price' => new Money(1000, $currency), // 10.00 €
'tax_rate' => TaxRate::TVA_20,
'product_weight' => null,
'invoicing_unit' => InvoicingUnit::PCE,
'sell_unit' => SellUnit::PCE,
'validDiscount' => null,
'status' => true,
'orderable_start' => null,
'orderable_end' => null,
'hasInco' => false,
];
$data = array_merge($defaultAttributes, $attributes);
return new Product(
id: $data['id'],
price: $data['price'],
tax_rate: $data['tax_rate'],
product_weight: $data['product_weight'],
invoicing_unit: $data['invoicing_unit'],
sell_unit: $data['sell_unit'],
validDiscount: $data['validDiscount'] instanceof DiscountValue ? clone $data['validDiscount'] : null,
status: $data['status'],
orderable_start: $data['orderable_start'],
orderable_end: $data['orderable_end'],
hasInco: $data['hasInco']
);
}
function createDiscount(Money $amount, bool $isPercentage = false, float $percentageRate = 0.0): DiscountValue
{
if ($isPercentage) {
return DiscountValue::fromPercentage((int) $percentageRate);
}
return DiscountValue::fromAmount($amount);
}
function createWeight(int $grams): ProductWeight
{
return new ProductWeight($grams, false);
}
dataset('weight_ratios', [
'500g weight on 20€/kg price' => [500, 2000, '1000'], // 20.00€ * (500/1000) = 10.00€ HT
'250g weight on 10€/kg price' => [250, 1000, '250'], // 10.00€ * (250/1000) = 2.50€ HT
'1500g weight on 10€/kg price' => [1500, 1000, '1500'], // 10.00€ * (1500/1000) = 15.00€ HT
]);
describe(PricingService::class, function (): void {
it('calculates standard pricing for a product without discount', function (): void {
// GIVEN
$currency = new Currency('EUR');
$basePrice = new Money(1000, $currency);
$product = createBaseProduct([
'price' => $basePrice,
'tax_rate' => TaxRate::TVA_20,
]);
$service = new PricingService();
// WHEN
$result = $service->calculateForProduct($product);
// THEN
expect($result)->toBeInstanceOf(ProductPricing::class)
->and($result->basePriceHt()->getAmount())->toBe('1000')
->and($result->basePriceTtc()->getAmount())->toBe('1200')
->and($result->discountAmountHt()->getAmount())->toBe('0')
->and($result->discountedPriceHt()->getAmount())->toBe('1000')
->and($result->discountedPriceTtc()->getAmount())->toBe('1200')
->and($result->discountRate->basisPoints())->toBe(0)
->and($result->hasDiscount)->toBeFalse();
});
it('calculates base price HT depending on weight when invoicing by KGM and selling by PCE', function (int $grams, int $inputPriceCents, string $expectedPriceHtCents): void {
// GIVEN
$currency = new Currency('EUR');
$product = createBaseProduct([
'price' => new Money($inputPriceCents, $currency),
'invoicing_unit' => InvoicingUnit::KGM,
'sell_unit' => SellUnit::PCE,
'product_weight' => createWeight($grams),
]);
$service = new PricingService();
// WHEN
$result = $service->calculateForProduct($product);
// THEN
expect($result->basePriceHt()->getAmount())->toBe($expectedPriceHtCents);
})->with('weight_ratios');
it('applies a fixed discount amount and calculates the discount rate', function (): void {
// GIVEN
$currency = new Currency('EUR');
$discount = createDiscount(
amount: new Money(200, $currency),
isPercentage: false
);
$product = createBaseProduct([
'price' => new Money(1000, $currency),
'validDiscount' => $discount,
]);
$service = new PricingService();
// WHEN
$result = $service->calculateForProduct($product);
// THEN
expect($result->hasDiscount)->toBeTrue()
->and($result->discountAmountHt()->getAmount())->toBe('200')
->and($result->discountedPriceHt()->getAmount())->toBe('800')
->and($result->discountedPriceTtc()->getAmount())->toBe('960')
->and($result->discountRate->basisPoints())->toBe(2000);
});
it('applies a percentage discount and uses the specific rate from the discount object', function (): void {
// GIVEN
$currency = new Currency('EUR');
$discount = createDiscount(
amount: new Money(150, $currency),
isPercentage: true,
percentageRate: 15.0
);
$product = createBaseProduct([
'price' => new Money(1000, $currency),
'validDiscount' => $discount,
]);
$service = new PricingService();
// WHEN
$result = $service->calculateForProduct($product);
// THEN
expect($result->hasDiscount)->toBeTrue()
->and($result->discountAmountHt()->getAmount())->toBe('150')
->and($result->discountedPriceHt()->getAmount())->toBe('850')
->and($result->discountRate->basisPoints())->toBe(1500);
});
it('calculates the virtual price per KG TTC for a PCE product with a defined weight', function (): void {
// GIVEN
$currency = new Currency('EUR');
$product = createBaseProduct([
'price' => new Money(1000, $currency),
'tax_rate' => TaxRate::TVA_20,
'invoicing_unit' => InvoicingUnit::PCE,
'product_weight' => createWeight(250),
'status' => true,
]);
$service = new PricingService();
// WHEN
$result = $service->calculateForProduct($product);
// THEN
expect($result->priceKgTtc()->getAmount())->toBe('4800');
});
it('returns standard price as price per KG TTC if the product status is false', function (): void {
// GIVEN
$currency = new Currency('EUR');
$product = createBaseProduct([
'price' => new Money(1000, $currency),
'invoicing_unit' => InvoicingUnit::PCE,
'product_weight' => createWeight(250),
'status' => false,
]);
$service = new PricingService();
// WHEN
$result = $service->calculateForProduct($product);
// THEN
expect($result->priceKgTtc()->getAmount())->toBe('1200');
});
it('returns a 0.0 discount rate if base price HT is 0', function (): void {
// GIVEN
$currency = new Currency('EUR');
$discount = createDiscount(
amount: new Money(0, $currency),
isPercentage: false
);
$product = createBaseProduct([
'price' => new Money(0, $currency),
'validDiscount' => $discount,
]);
$service = new PricingService();
// WHEN
$result = $service->calculateForProduct($product);
// THEN
expect($result->discountRate->basisPoints())->toBe(0);
});
it('calculates the price per KG TTC for a product invoiced by KGM and sold by PCE', function (): void {
// GIVEN
$currency = new Currency('EUR');
$product = createBaseProduct([
'price' => new Money(865, $currency), // 8.65 €/kg
'tax_rate' => TaxRate::TVA_5_5,
'invoicing_unit' => InvoicingUnit::KGM,
'sell_unit' => SellUnit::PCE,
'product_weight' => createWeight(360), // 360g
'status' => true,
]);
$service = new PricingService();
// WHEN
$result = $service->calculateForProduct($product);
// THEN
expect($result->basePriceHt()->getAmount())->toBe('312')
->and($result->basePriceTtc()->getAmount())->toBe('330')
->and($result->priceKgTtc()->getAmount())->toBe('913');
});
it('calculates the price per KG TTC for a product invoiced by KGM with percentage discount', function (): void {
// GIVEN
$currency = new Currency('EUR');
$discount = createDiscount(
amount: new Money(0, $currency),
isPercentage: true,
percentageRate: 10.0
);
$product = createBaseProduct([
'price' => new Money(865, $currency), // 8.65 €/kg
'tax_rate' => TaxRate::TVA_5_5,
'invoicing_unit' => InvoicingUnit::KGM,
'sell_unit' => SellUnit::PCE,
'product_weight' => createWeight(360), // 360g
'validDiscount' => $discount,
'status' => true,
]);
$service = new PricingService();
// WHEN
$result = $service->calculateForProduct($product);
// THEN
expect($result->discountedPriceHt()->getAmount())->toBe('280')
->and($result->discountedPriceTtc()->getAmount())->toBe('296')
->and($result->priceKgTtc()->getAmount())->toBe('821');
});
it('calculates the price per KG TTC for a product invoiced by KGM with fixed amount discount', function (): void {
// GIVEN
$currency = new Currency('EUR');
$discount = createDiscount(
amount: new Money(50, $currency),
isPercentage: false
);
$product = createBaseProduct([
'price' => new Money(865, $currency), // 8.65 €/kg
'tax_rate' => TaxRate::TVA_5_5,
'invoicing_unit' => InvoicingUnit::KGM,
'sell_unit' => SellUnit::PCE,
'product_weight' => createWeight(360),
'validDiscount' => $discount,
'status' => true,
]);
$service = new PricingService();
// WHEN
$result = $service->calculateForProduct($product);
// THEN
// 50 cts off a 360 g piece => 139 cts off per kg (rounded up): (865 - 139) HT x 1.055
expect($result->discountedPriceTtc()->getAmount())->toBe('277')
->and($result->priceKgTtc()->getAmount())->toBe('766');
});
it('caps the discount at the piece price when a fixed discount exceeds the piece price', function (): void {
// GIVEN
$currency = new Currency('EUR');
$discount = createDiscount(
amount: new Money(500, $currency), // 5.00 € discount on a 3.12 € piece
isPercentage: false
);
$product = createBaseProduct([
'price' => new Money(865, $currency), // 8.65 €/kg => 3.12 € HT for 360g
'tax_rate' => TaxRate::TVA_5_5,
'invoicing_unit' => InvoicingUnit::KGM,
'sell_unit' => SellUnit::PCE,
'product_weight' => createWeight(360),
'validDiscount' => $discount,
'status' => true,
]);
$service = new PricingService();
// WHEN
$result = $service->calculateForProduct($product);
// THEN
expect($result->discountAmountHt()->getAmount())->toBe('312')
->and($result->discountedPriceHt()->getAmount())->toBe('0')
->and($result->discountedPriceTtc()->getAmount())->toBe('0')
->and($result->priceKgTtc()->getAmount())->toBe('0')
->and($result->discountRate->basisPoints())->toBe(10000);
});
});

View file

@ -0,0 +1,239 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Services;
use Carbon\CarbonImmutable;
use Domain\Enums\SellUnit;
use Domain\Enums\TaxRate;
use Domain\Enums\Units\InvoicingUnit;
use Domain\Enums\Units\OrderUnit;
use Domain\Models\Entities\Product;
use Domain\Models\ValueObjects\BasketProduct;
use Domain\Models\ValueObjects\DiscountRate;
use Domain\Models\ValueObjects\Entities\ProductId;
use Domain\Ports\Repositories\ProductRepository;
use Domain\Services\ProductAvailabilityService;
use Mockery;
use Money\Money;
use Ramsey\Uuid\Uuid;
function createTestProduct(
bool $status = true,
?CarbonImmutable $orderableStart = null,
?CarbonImmutable $orderableEnd = null,
bool $hasInco = true
): Product {
return new Product(
id: new ProductId(Uuid::uuid7()),
price: Money::EUR(100),
tax_rate: TaxRate::TVA_20,
product_weight: null,
invoicing_unit: InvoicingUnit::PCE,
sell_unit: SellUnit::PCE,
validDiscount: null,
status: $status,
orderable_start: $orderableStart,
orderable_end: $orderableEnd,
hasInco: $hasInco
);
}
function createTestBasketProduct(ProductId $id): BasketProduct
{
return new BasketProduct(
id: $id,
name: 'Test Product',
qty: 1,
unitPriceHt: Money::EUR(100),
unitDiscount: Money::EUR(0),
taxRate: TaxRate::TVA_20,
weightGrams: 1000,
brand: null,
incoId: null,
orderUnit: OrderUnit::PCE,
slug: 'test-product',
picture: null,
description: null,
weightVariable: false,
discountRate: DiscountRate::fromRatio(0.0),
priceKgTtc: Money::EUR(100),
options: []
);
}
describe('ProductAvailabilityService', function (): void {
it('returns true when product is fully orderable', function (): void {
// GIVEN
$repository = Mockery::mock(ProductRepository::class);
$service = new ProductAvailabilityService($repository);
$now = CarbonImmutable::now();
$product = createTestProduct(
status: true,
orderableStart: $now->subDay(),
orderableEnd: $now->addDay(),
hasInco: true
);
// WHEN
$result = $service->isOrderable($product);
// THEN
expect($result)->toBeTrue();
});
it('returns false when product status is inactive', function (): void {
// GIVEN
$repository = Mockery::mock(ProductRepository::class);
$service = new ProductAvailabilityService($repository);
$now = CarbonImmutable::now();
$product = createTestProduct(
status: false,
orderableStart: $now->subDay(),
orderableEnd: $now->addDay(),
hasInco: true
);
// WHEN
$result = $service->isOrderable($product);
// THEN
expect($result)->toBeFalse();
});
it('returns false when product has no inco', function (): void {
// GIVEN
$repository = Mockery::mock(ProductRepository::class);
$service = new ProductAvailabilityService($repository);
$now = CarbonImmutable::now();
$product = createTestProduct(
status: true,
orderableStart: $now->subDay(),
orderableEnd: $now->addDay(),
hasInco: false
);
// WHEN
$result = $service->isOrderable($product);
// THEN
expect($result)->toBeFalse();
});
it('returns false when orderable start is in the future', function (): void {
// GIVEN
$repository = Mockery::mock(ProductRepository::class);
$service = new ProductAvailabilityService($repository);
$now = CarbonImmutable::now();
$product = createTestProduct(
status: true,
orderableStart: $now->addDay(),
orderableEnd: $now->addDays(2),
hasInco: true
);
// WHEN
$result = $service->isOrderable($product);
// THEN
expect($result)->toBeFalse();
});
it('returns false when orderable end is in the past', function (): void {
// GIVEN
$repository = Mockery::mock(ProductRepository::class);
$service = new ProductAvailabilityService($repository);
$now = CarbonImmutable::now();
$product = createTestProduct(
status: true,
orderableStart: null,
orderableEnd: $now->subDay(),
hasInco: true
);
// WHEN
$result = $service->isOrderable($product);
// THEN
expect($result)->toBeFalse();
});
it('determines if product is published', function (): void {
// GIVEN
$repository = Mockery::mock(ProductRepository::class);
$service = new ProductAvailabilityService($repository);
$product = createTestProduct(status: true);
// WHEN
$result = $service->isPublished($product);
// THEN
expect($result)->toBeTrue();
});
it('determines if product is not published when status is false', function (): void {
// GIVEN
$repository = Mockery::mock(ProductRepository::class);
$service = new ProductAvailabilityService($repository);
$product = createTestProduct(status: false);
// WHEN
$result = $service->isPublished($product);
// THEN
expect($result)->toBeFalse();
});
it('returns false when basket product is not found in repository', function (): void {
// GIVEN
$repository = Mockery::mock(ProductRepository::class);
$service = new ProductAvailabilityService($repository);
$productId = new ProductId(Uuid::uuid7());
$basketProduct = createTestBasketProduct($productId);
$repository->shouldReceive('find')
->once()
->with($productId)
->andReturn(null);
// WHEN
$result = $service->isBasketProductPublished($basketProduct);
// THEN
expect($result)->toBeFalse();
});
it('returns true when basket product is found and published', function (): void {
// GIVEN
$repository = Mockery::mock(ProductRepository::class);
$service = new ProductAvailabilityService($repository);
$productId = new ProductId(Uuid::uuid7());
$basketProduct = createTestBasketProduct($productId);
$product = createTestProduct(status: true);
$repository->shouldReceive('find')
->once()
->with($productId)
->andReturn($product);
// WHEN
$result = $service->isBasketProductPublished($basketProduct);
// THEN
expect($result)->toBeTrue();
});
it('returns true when product has inco via isAssociatedWithInco directly', function (): void {
// GIVEN
$repository = Mockery::mock(ProductRepository::class);
$service = new ProductAvailabilityService($repository);
$product = createTestProduct(hasInco: true);
// WHEN
$result = $service->isAssociatedWithInco($product);
// THEN
expect($result)->toBeTrue();
});
});

View file

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Tests\Unit\Services;
use App\Models\Order;
use App\Models\Product;
use App\Services\DataTableService;
use Illuminate\Contracts\Database\Query\Builder;
@ -130,64 +129,4 @@ describe('DataTableService', function () {
// THEN
// Mockery verifies on teardown that orderBy('order', 'asc') was called once.
});
it('sorts by custom default column and way when no sort column is specified', function () {
// GIVEN
$request = Request::create('/bo/orders', 'GET');
$session = mock(Session::class);
$session->shouldReceive('get')->andReturn(null);
Schema::shouldReceive('getColumnListing')
->once()
->with('orders')
->andReturn(['id', 'reference', 'status', 'total']);
$query = mock(Builder::class);
$query->shouldReceive('orderBy')->once()->with('orders.id', 'desc');
$service = new DataTableService($request, $session);
// WHEN
$service->sortQuery(
Order::class,
$query,
defaultSortCol: 'date',
defaultSortWay: SortDirection::Descending,
columnMap: ['date' => 'id'],
);
// THEN
// Mockery verifies on teardown that orderBy('orders.id', 'desc') was called once.
});
it('uses columnMap when sorting by an explicit virtual column', function () {
// GIVEN
$request = Request::create('/bo/orders', 'GET', [
'sort_col' => 'date',
'sort_way' => 'asc',
]);
$session = mock(Session::class);
$session->shouldReceive('get')->andReturn(null);
$session->shouldReceive('put')->twice();
Schema::shouldReceive('getColumnListing')
->once()
->with('orders')
->andReturn(['id', 'reference', 'status', 'total']);
$query = mock(Builder::class);
$query->shouldReceive('orderBy')->once()->with('orders.id', 'asc');
$service = new DataTableService($request, $session);
// WHEN
$service->sortQuery(
Order::class,
$query,
columnMap: ['date' => 'id'],
);
// THEN
// Mockery verifies on teardown that orderBy('orders.id', 'asc') was called once.
});
});