Compare commits

..

19 commits

Author SHA1 Message Date
Jérémy Munsch
cd7d5f5fe9
feat(orders): implement default sorting for order queries
Added default sorting by date in descending order for order queries.
Updated related views and tests to reflect this change.
2026-10-29 21:16:06 +01:00
Jérémy Munsch
0e05779f89
feat(sliders): enhance carousel controls and responsiveness
Added padding for carousel controls on larger screens and improved button styles for better visibility and interaction.
2026-10-29 20:59:30 +01:00
Jérémy Munsch
c8f895c759
refactor: simplify use statements and type hints
- Removed fully qualified names for better readability.
- Streamlined code by using direct class references.
2026-10-29 20:35:35 +01:00
Jérémy Munsch
c40016bba1
feat(order): implement order placement and related DTOs
- Add methods to convert OrderLine and DeliveryAddress to DTOs.
- Introduce toPlacedEvent method in Order to generate domain event.
- Enhance OrderUnit with Regate code mapping.
- Implement tests for new functionality and ensure correctness.
2026-10-29 20:35:13 +01:00
Jérémy Munsch
71e0d65f31
refactor(OrderService): simplify order creation from basket
Updated the OrderService to utilize the new fromBasket method for creating orders, enhancing code clarity and maintainability. Removed the ProductAvailabilityService as its functionality is now integrated into the ProductRepository.
2026-10-29 20:21:32 +01:00
Jérémy Munsch
da0c3afc14
refactor(MoneyServiceProvider, ViewServiceProvider): streamline boot methods
Removed unnecessary conditionals and improved method calls for clarity.
2026-10-29 20:13:15 +01:00
Jérémy Munsch
e857a58d00
test(Product): add comprehensive tests for Product entity
- Implement tests for product publication status.
- Add tests for product orderability based on various conditions.
- Enhance pricing calculation tests for different discount scenarios.
- Remove outdated PricingServiceTest and refactor ProductAvailabilityServiceTest.
2026-10-29 19:48:38 +01:00
Jérémy Munsch
cfac370e2e
feat(discount): implement published discounts filtering logic
Added logic to filter published discounts, including those with null start or end dates.
2026-10-29 19:19:22 +01:00
Jérémy Munsch
08354b44b6
style(tests): format code for better readability
Improved code formatting in ChangeOrderBladeTest for consistency.
2026-10-29 19:13:31 +01:00
Jérémy Munsch
ed593c25b0
test(UpdateShopPickupExceptionsRequest): remove redundant message test and update error messages
Updated error messages for better clarity and removed unnecessary test for custom messages.
2026-10-29 19:12:21 +01:00
Jérémy Munsch
c52b13cc51
feat(order): implement soft delete handling in order changes
Added support for soft-deleted models in ChangeModelOrder and ChangeModelOrderPosition actions.
This allows for proper order adjustments even when models are soft-deleted.
2026-10-29 19:04:46 +01:00
Jérémy Munsch
7b910c5af6
refactor(UpdateShopPickupExceptionsRequest): remove unused messages method
Cleaned up the code by removing the messages method as it was not utilized.
2026-10-29 18:43:02 +01:00
Jérémy Munsch
961829a0c7
fix(pickup-exceptions): update noReplacement logic for checkbox state
Ensure noReplacement reflects checkbox status for accurate behavior.
2026-10-29 18:24:21 +01:00
Jérémy Munsch
36faed679e
fix(pickup-exceptions): correct spelling of 'Blocked week'
Updated the text from 'Bloqued week' to 'Blocked week' for consistency in error messages.
2026-10-29 18:23:36 +01:00
Jérémy Munsch
cef7a4e4b0
style(pickup-exceptions): fix formatting for readability
Adjusted indentation for better code clarity.
2026-10-29 18:22:51 +01:00
Jérémy Munsch
a87f5756b2
fix(PickupSchedulingService): use equals method for exception ID comparison
Updated the comparison of exception IDs to use the equals method for better accuracy.
2026-10-29 18:21:22 +01:00
Jérémy Munsch
0874259eba
style(FilteredProductResource): fix indentation for nutriscore line 2026-10-29 17:10:13 +01:00
Jérémy Munsch
3ac3cbc7ff
style(products): format change-selected-order route for readability 2026-10-29 17:08:16 +01:00
Jérémy Munsch
16dfc32f73
Merge branch 'issue-82' into develop 2026-10-29 17:06:27 +01:00
76 changed files with 1931 additions and 1190 deletions

View file

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

View file

@ -9,6 +9,7 @@ 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;
/**
@ -34,8 +35,11 @@ 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 = method_exists($model, 'withTrashed')
$baseQuery = $usesSoftDeletes
// @phpstan-ignore method.notFound (withTrashed)
? $model::query()->withTrashed()
: $model::query();

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -66,9 +66,7 @@ class MoneyServiceProvider extends ServiceProvider
*/
public function boot()
{
if (! $this->app->runningInConsole()) {
$this->sharetoView();
}
$this->sharetoView();
}
private function sharetoView(): void

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -98,11 +98,15 @@ class DataTableService
* @param class-string<TModel> $modelClass
* @param TBuilder $query
* @param array<int, string> $ignore Columns to ignore for sorting.
* @param array<string, string> $columnMap Map virtual column names to database columns.
*/
public function sortQuery(
string $modelClass,
Builder $query,
array $ignore = []
array $ignore = [],
?string $defaultSortCol = null,
SortDirection $defaultSortWay = SortDirection::Ascending,
array $columnMap = [],
): Builder {
$table = (new $modelClass())->getTable();
$rName = $this->request->route()?->getName() ?? 'noroute';
@ -115,15 +119,22 @@ class DataTableService
$sort_col = $sort['sort_col'];
$sort_way = $sort['sort_way'];
if ((! $sort_col || $sort_col === 'undefined') && $defaultSortCol !== null) {
$sort_col = $defaultSortCol;
$sort_way = $defaultSortWay;
}
$this->persistSortSession($rName, $sort_col, $sort_way);
if ($sort_col && $sort_col !== 'undefined' && $sort_way && ! in_array($sort_col, $ignore) && $this->hasTableColumn($table, $sort_col)) {
$dbCol = $columnMap[$sort_col] ?? $sort_col;
if ($sort_col && $sort_col !== 'undefined' && $sort_way && ! in_array($sort_col, $ignore) && $this->hasTableColumn($table, $dbCol)) {
$direction = match ($sort_way) {
SortDirection::Descending => 'desc',
default => 'asc',
};
$query->orderBy("{$table}.{$sort_col}", $direction);
$query->orderBy("{$table}.{$dbCol}", $direction);
} elseif ($this->hasTableColumn($table, self::ORDER_COLUMN)) {
$query->orderBy(self::ORDER_COLUMN, 'asc');

View file

@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Domain\Enums;
use Domain\Services\PricingService;
use Domain\Models\Entities\Product;
use Money\Money;
use Shared\Enums\BaseEnum;
@ -34,6 +34,17 @@ enum TaxRate: int
};
}
public static function fromPercent(float $percent): self
{
return match ($percent) {
self::TVA_20->percent() => self::TVA_20,
self::TVA_5_5->percent() => self::TVA_5_5,
default => self::tryFrom((int) $percent) ?? throw new \UnexpectedValueException(
sprintf('Unhandled tax rate: %s', $percent)
),
};
}
public function rate(): float
{
return $this->percent() / 100;
@ -54,6 +65,6 @@ enum TaxRate: int
public function applyTo(Money $price): Money
{
return $price->multiply($this->multiplier(), PricingService::ROUNDING);
return $price->multiply($this->multiplier(), Product::ROUNDING);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,7 +5,7 @@ declare(strict_types=1);
namespace Domain\Models\ValueObjects;
use Domain\Enums\DiscountType;
use Domain\Services\PricingService;
use Domain\Models\Entities\Product;
use Money\Money;
/**
@ -118,6 +118,6 @@ final readonly class DiscountValue
$rate = $this->getPercentageRate();
return $basePrice->multiply((string) $rate, PricingService::ROUNDING);
return $basePrice->multiply((string) $rate, Product::ROUNDING);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,131 +0,0 @@
<?php
declare(strict_types=1);
namespace Domain\Services;
use Domain\Enums\SellUnit;
use Domain\Enums\TaxRate;
use Domain\Enums\Units\InvoicingUnit;
use Domain\Models\Entities\Product;
use Domain\Models\ValueObjects\DiscountRate;
use Domain\Models\ValueObjects\ProductPricing;
use Money\Money;
class PricingService
{
public const ROUNDING = Money::ROUND_UP;
private const GRAMS_PER_KG = 1000;
/**
* 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

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

View file

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

View file

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

View file

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

View file

@ -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.",
"Bloqued week": "Bloqued week",
"Blocked week": "Blocked 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.",
"Bloqued week": "Semaine bloquée",
"Blocked week": "Semaine bloquée",
"Booking": "Réservation",
"Brand": "Marque",
"Breadcrumb": "Fil d'Ariane",

View file

@ -1,6 +1,14 @@
#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;
@ -18,6 +26,50 @@
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,6 +21,8 @@
'model' => $faq,
'first' => $loop->first,
'last' => $loop->last,
'isFirstPage' => $faqs->onFirstPage(),
'isLastPage' => !$faqs->hasMorePages(),
])
<td>
<div class="btn-group">

View file

@ -3,6 +3,24 @@
/** @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';
@ -25,6 +43,9 @@
// * 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">
@ -56,13 +77,13 @@
@csrf
@method('PATCH')
<input name="action" type="hidden" value="down">
@if (!$first)
@if (!$isFirst)
<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 (!$last)
@if (!$isLast)
<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,9 +2,16 @@
// * 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");
$currentSortWay = session()->get("{$routeName}.sort_way");
$currentSortCol = session()->get("{$routeName}.sort_col", $defaultSortCol);
$currentSortWay = session()->get("{$routeName}.sort_way", $defaultSortWayStr);
$searchQuery = request()->input('search');
@ -18,7 +25,7 @@
View::share('noOrder', $noOrder);
// * Reset button
$hasCustomSort = ($currentSortCol && $currentSortCol !== 'order') || ($currentSortCol === 'order' && $currentSortWay === 'desc');
$hasCustomSort = ($currentSortCol && $currentSortCol !== $defaultSortCol) || ($currentSortCol === $defaultSortCol && $currentSortWay !== $defaultSortWayStr);
$showReset = !empty($searchQuery) || $hasCustomSort;
@endphp

View file

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

View file

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

View file

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

View file

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

View file

@ -85,8 +85,7 @@
$associatedException?->pivot?->order_deadline_at?->format('Y-m-d\TH:i'),
);
$noReplacement =
empty($associatedException?->pivot?->replacement_date);
$noReplacement = $isChecked && empty($associatedException?->pivot?->replacement_date);
$isCritical = $dto->missingSubsitute || $dto->passedDeadline;
@ -122,7 +121,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> {{ __('Bloqued week') }}
<i class="fa-solid fa-triangle-exclamation text-danger me-1"></i> {{ __('Blocked week') }}
</span>
@elseif ($noReplacement)
<span class="d-block text-info small fw-bold mt-2">
@ -132,10 +131,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,6 +47,8 @@
'model' => $slide,
'first' => $loop->first,
'last' => $loop->last,
'isFirstPage' => $slides->onFirstPage(),
'isLastPage' => !$slides->hasMorePages(),
])
<td>
<div class="btn-group">

View file

@ -21,6 +21,8 @@
'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" id="products-carousel" data-bs-ride="false" data-bs-touch="true" role="region"
<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"
aria-roledescription="carousel" aria-label="{{ __('Offers Slider') }}">
<div class="carousel-inner p-2">
@ -91,8 +91,7 @@
</small>
</li>
<li class="position-relative z-3 py-2 text-center">
<li class="d-flex justify-content-between flex-row py-2">
<li class="position-relative z-3 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") }}"
@ -101,7 +100,7 @@
@endif
@can('addToBasket', $productWithPricing->product)
<div class="basket-buttons" data-json='@json(['id' => $productWithPricing->product->id])'></div>
<div class="basket-buttons ms-auto" data-json='@json(['id' => $productWithPricing->product->id])'></div>
@endcan
</li>
</ul>
@ -115,21 +114,15 @@
</div>
@if (count($starProductList) > 3)
<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>
<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>
<span class="visually-hidden">{{ __('pagination.previous') }}</span>
</button>
<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>
<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>
<span class="visually-hidden">{{ __('pagination.next') }}</span>
</button>
@endif

View file

@ -78,7 +78,10 @@ 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,25 +113,72 @@ describe('Bo\OrdersController', function (): void {
// THEN
expect($response->status())->toBe(200);
$response->assertViewHas('orders', function ($orders) use ($ref): bool {
return $orders instanceof LengthAwarePaginator
&& $orders->total() === 1
&& (string) $orders->first()->reference === $ref;
$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;
});
});
it('can access today orders', function (): void {
it('sorts index orders by default by date from newest to oldest', function (): void {
/** @var \Tests\TestCase $this */
// GIVEN
$shop = Shop::factory()->create();
Order::forceCreate([
'id' => Uuid::uuid7()->toString(),
$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_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(),
]);
@ -141,7 +188,10 @@ describe('Bo\OrdersController', function (): void {
// THEN
expect($response->status())->toBe(200);
$response->assertViewIs('bo.orders.index-today');
$response->assertViewHas('orders', fn ($orders) => $orders instanceof LengthAwarePaginator && $orders->total() >= 1);
/** @var \Illuminate\Pagination\LengthAwarePaginator<int, OrderDto> $orders */
$orders = $response->viewData('orders');
expect($orders->total())->toBe(1)
->and($orders->first()?->id)->toBe($todayOrder->id);
});
it('can access order show detail', function (): void {
@ -215,7 +265,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,4 +194,3 @@ describe('ProductsController (Ajax)', function () {
->and($data[0]['discountedPriceTtc'])->not->toBeNull();
});
});

View file

@ -54,19 +54,6 @@ 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();
@ -111,7 +98,7 @@ describe('UpdateShopPickupExceptionsRequest', function () {
// THEN
expect($validator->fails())->toBeTrue();
$errors = $validator->errors()->all();
expect($errors[0])->toBe('La fermeture exceptionnelle sélectionnée n\'est pas un identifiant valide.');
expect($errors[0])->toBe('Le champ fermeture exceptionnelle doit être un UUID valide.');
});
it('rejects nonexistent uuid with non cryptic message', function () {
@ -130,7 +117,7 @@ describe('UpdateShopPickupExceptionsRequest', function () {
// THEN
expect($validator->fails())->toBeTrue();
$errors = $validator->errors()->all();
expect($errors[0])->toBe('La fermeture exceptionnelle sélectionnée n\'existe pas.');
expect($errors[0])->toBe('Le champ fermeture exceptionnelle sélectionné est invalide.');
});
it('rejects order deadline after replacement date with human friendly message', function () {
@ -157,7 +144,7 @@ describe('UpdateShopPickupExceptionsRequest', function () {
// THEN
expect($validator->fails())->toBeTrue();
$errors = $validator->errors()->all();
expect($errors)->toContain('La date limite de commande doit être strictement antérieure au jour de remplacement.');
expect($errors)->toContain('Le champ date limite de commande doit être une date antérieure au jour de remplacement.');
});
it('rejects order deadline equal to replacement date with human friendly message', function () {
@ -184,7 +171,7 @@ describe('UpdateShopPickupExceptionsRequest', function () {
// THEN
expect($validator->fails())->toBeTrue();
$errors = $validator->errors()->all();
expect($errors)->toContain('La date limite de commande doit être strictement antérieure au jour de remplacement.');
expect($errors)->toContain('Le champ date limite de commande doit être une date antérieure au jour de remplacement.');
});
it('rejects order deadline without replacement date with human friendly message', function () {
@ -211,7 +198,7 @@ describe('UpdateShopPickupExceptionsRequest', function () {
// THEN
expect($validator->fails())->toBeTrue();
$errors = $validator->errors()->all();
expect($errors)->toContain('Le jour de remplacement est obligatoire lorsqu\'une date limite de commande est définie.');
expect($errors)->toContain('Le champ jour de remplacement est obligatoire quand date limite de commande est présent.');
});
it('transforms data to sync data format correctly', function () {

View file

@ -0,0 +1,92 @@
<?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,7 +5,49 @@ 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 {
@ -131,4 +173,30 @@ 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,7 +5,49 @@ 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 {
@ -65,4 +107,30 @@ 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

@ -0,0 +1,86 @@
<?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,6 +56,37 @@ 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,10 +6,7 @@ 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;
@ -85,22 +82,7 @@ it('retrieves a basket product', function () {
$id = new ProductId(Uuid::fromString($uuid));
// 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);
$repo = new EloquentProductRepository();
// WHEN
$result = $repo->getBasketProduct($id, 2);
@ -136,8 +118,7 @@ it('finds a product by id', function () {
$id = new ProductId(Uuid::fromString($uuid));
$pricingService = \Mockery::mock(PricingService::class);
$repo = new EloquentProductRepository($pricingService);
$repo = new EloquentProductRepository();
// WHEN
$result = $repo->find($id);
@ -150,8 +131,7 @@ it('returns null if product by id is not found', function () {
// GIVEN
$id = new ProductId(Uuid::uuid7());
$pricingService = \Mockery::mock(PricingService::class);
$repo = new EloquentProductRepository($pricingService);
$repo = new EloquentProductRepository();
// WHEN
$result = $repo->find($id);

View file

@ -68,9 +68,40 @@ 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,21 +13,19 @@ 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($service = null, $repo = null)
function getProductPolicy($repo = null)
{
return new ProductPolicy(
$service ?? \Mockery::mock(ProductAvailabilityService::class),
$repo ?? \Mockery::mock(ProductRepository::class)
$repo ?? \Mockery::mock(ProductRepository::class)
);
}
function createDomainProduct()
function createDomainProduct(bool $status = true)
{
return new DomainProduct(
new ProductId(Uuid::uuid7()),
@ -37,7 +35,7 @@ describe('ProductPolicy', function () {
InvoicingUnit::PCE,
SellUnit::PCE,
null,
true,
$status,
null,
null,
false
@ -324,7 +322,7 @@ describe('ProductPolicy', function () {
$repo = \Mockery::mock(ProductRepository::class);
$repo->shouldReceive('find')->andReturn(null);
$policy = getProductPolicy(null, $repo);
$policy = getProductPolicy($repo);
// WHEN
$result = $policy->viewPublished(null, $product);
@ -339,13 +337,11 @@ describe('ProductPolicy', function () {
$productId = new ProductId(Uuid::uuid7());
$product->shouldReceive('getId')->andReturn($productId);
$domainProduct = createDomainProduct();
$domainProduct = createDomainProduct(status: true);
$repo = \Mockery::mock(ProductRepository::class);
$repo->shouldReceive('find')->andReturn($domainProduct);
$service = \Mockery::mock(ProductAvailabilityService::class);
$service->shouldReceive('isPublished')->with($domainProduct)->andReturn(true);
$policy = getProductPolicy($service, $repo);
$policy = getProductPolicy($repo);
// WHEN
$result = $policy->viewPublished(null, $product);
@ -360,13 +356,11 @@ describe('ProductPolicy', function () {
$productId = new ProductId(Uuid::uuid7());
$product->shouldReceive('getId')->andReturn($productId);
$domainProduct = createDomainProduct();
$domainProduct = createDomainProduct(status: false);
$repo = \Mockery::mock(ProductRepository::class);
$repo->shouldReceive('find')->andReturn($domainProduct);
$service = \Mockery::mock(ProductAvailabilityService::class);
$service->shouldReceive('isPublished')->with($domainProduct)->andReturn(false);
$policy = getProductPolicy($service, $repo);
$policy = getProductPolicy($repo);
// WHEN
$result = $policy->viewPublished(null, $product);

View file

@ -5,12 +5,15 @@ 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\Services\ProductAvailabilityService;
use Domain\Ports\Repositories\ProductRepository;
use Money\Money;
use Ramsey\Uuid\Uuid;
@ -38,13 +41,30 @@ 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
$productService = \Mockery::mock(ProductAvailabilityService::class);
$policy = new BasketPolicy($productService);
$customer = \Mockery::mock(Customer::class);
$productRepository = \Mockery::mock(ProductRepository::class);
$policy = new BasketPolicy($productRepository);
$customer = \Mockery::mock(Customer::class);
// WHEN
$result = $policy->viewTotal($customer);
@ -55,9 +75,9 @@ describe('BasketPolicy', function () {
it('returns true when user is a User instance', function () {
// GIVEN
$productService = \Mockery::mock(ProductAvailabilityService::class);
$policy = new BasketPolicy($productService);
$user = \Mockery::mock(User::class);
$productRepository = \Mockery::mock(ProductRepository::class);
$policy = new BasketPolicy($productRepository);
$user = \Mockery::mock(User::class);
// WHEN
$result = $policy->viewTotal($user);
@ -68,8 +88,8 @@ describe('BasketPolicy', function () {
it('returns false when user is null', function () {
// GIVEN
$productService = \Mockery::mock(ProductAvailabilityService::class);
$policy = new BasketPolicy($productService);
$productRepository = \Mockery::mock(ProductRepository::class);
$policy = new BasketPolicy($productRepository);
// WHEN
$result = $policy->viewTotal(null);
@ -82,9 +102,9 @@ describe('BasketPolicy', function () {
describe('viewPrices', function () {
it('returns false when user is null', function () {
// GIVEN
$productService = \Mockery::mock(ProductAvailabilityService::class);
$policy = new BasketPolicy($productService);
$product = createDummyBasketProduct();
$productRepository = \Mockery::mock(ProductRepository::class);
$policy = new BasketPolicy($productRepository);
$product = createDummyBasketProduct();
// WHEN
$result = $policy->viewPrices(null, $product);
@ -95,15 +115,17 @@ describe('BasketPolicy', function () {
it('returns true when user is not null and product is published', function () {
// GIVEN
$productService = \Mockery::mock(ProductAvailabilityService::class);
$policy = new BasketPolicy($productService);
$customer = \Mockery::mock(Customer::class);
$product = createDummyBasketProduct();
$productRepository = \Mockery::mock(ProductRepository::class);
$policy = new BasketPolicy($productRepository);
$customer = \Mockery::mock(Customer::class);
$product = createDummyBasketProduct();
$productService->shouldReceive('isBasketProductPublished')
$domainProduct = createTestDomainProduct(status: true);
$productRepository->shouldReceive('find')
->once()
->with($product)
->andReturn(true);
->with($product->id)
->andReturn($domainProduct);
// WHEN
$result = $policy->viewPrices($customer, $product);
@ -114,15 +136,36 @@ describe('BasketPolicy', function () {
it('returns false when user is not null and product is not published', function () {
// GIVEN
$productService = \Mockery::mock(ProductAvailabilityService::class);
$policy = new BasketPolicy($productService);
$user = \Mockery::mock(User::class);
$product = createDummyBasketProduct();
$productRepository = \Mockery::mock(ProductRepository::class);
$policy = new BasketPolicy($productRepository);
$user = \Mockery::mock(User::class);
$product = createDummyBasketProduct();
$productService->shouldReceive('isBasketProductPublished')
$domainProduct = createTestDomainProduct(status: false);
$productRepository->shouldReceive('find')
->once()
->with($product)
->andReturn(false);
->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);
// WHEN
$result = $policy->viewPrices($user, $product);

View file

@ -14,14 +14,17 @@ 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(): DomainProduct
{
function createDummyDomainProduct(
bool $status = true,
?CarbonImmutable $orderableStart = null,
?CarbonImmutable $orderableEnd = null,
bool $hasInco = true
): DomainProduct {
$productId = new ProductId(Uuid::uuid7());
return new DomainProduct(
@ -32,10 +35,10 @@ function createDummyDomainProduct(): DomainProduct
InvoicingUnit::KGM,
SellUnit::KGM,
null,
true,
CarbonImmutable::now()->subDay(),
CarbonImmutable::now()->addDay(),
true
$status,
$orderableStart ?? CarbonImmutable::now()->subDay(),
$orderableEnd ?? CarbonImmutable::now()->addDay(),
$hasInco
);
}
@ -43,17 +46,16 @@ 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($productService, $productRepository);
$policy = new ProductPolicy($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
@ -62,21 +64,18 @@ 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($productService, $productRepository);
$policy = new ProductPolicy($productRepository);
$productId = new ProductId(Uuid::uuid7());
$eloquentProduct = \Mockery::mock(EloquentProduct::class);
$eloquentProduct->shouldReceive('getId')->once()->andReturn($productId);
$domainProduct = createDummyDomainProduct();
$domainProduct = createDummyDomainProduct(status: false);
$productRepository->shouldReceive('find')->once()->with($productId)->andReturn($domainProduct);
$productService->shouldReceive('isPublished')->once()->with($domainProduct)->andReturn(false);
// WHEN
// When
$result = $policy->viewPublished(null, $eloquentProduct);
// THEN
@ -85,21 +84,18 @@ 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($productService, $productRepository);
$policy = new ProductPolicy($productRepository);
$productId = new ProductId(Uuid::uuid7());
$eloquentProduct = \Mockery::mock(EloquentProduct::class);
$eloquentProduct->shouldReceive('getId')->once()->andReturn($productId);
$domainProduct = createDummyDomainProduct();
$domainProduct = createDummyDomainProduct(status: true);
$productRepository->shouldReceive('find')->once()->with($productId)->andReturn($domainProduct);
$productService->shouldReceive('isPublished')->once()->with($domainProduct)->andReturn(true);
// WHEN
// When
$result = $policy->viewPublished(null, $eloquentProduct);
// THEN
@ -110,17 +106,16 @@ 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($productService, $productRepository);
$policy = new ProductPolicy($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
@ -129,21 +124,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($productService, $productRepository);
$policy = new ProductPolicy($productRepository);
$productId = new ProductId(Uuid::uuid7());
$eloquentProduct = \Mockery::mock(EloquentProduct::class);
$eloquentProduct->shouldReceive('getId')->once()->andReturn($productId);
$domainProduct = createDummyDomainProduct();
$domainProduct = createDummyDomainProduct(
orderableStart: CarbonImmutable::now()->addDays(2),
orderableEnd: CarbonImmutable::now()->addDays(5)
);
$productRepository->shouldReceive('find')->once()->with($productId)->andReturn($domainProduct);
$productService->shouldReceive('isOrderable')->once()->with($domainProduct)->andReturn(false);
// WHEN
// When
$result = $policy->addToBasket(null, $eloquentProduct);
// THEN
@ -152,21 +147,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($productService, $productRepository);
$policy = new ProductPolicy($productRepository);
$productId = new ProductId(Uuid::uuid7());
$eloquentProduct = \Mockery::mock(EloquentProduct::class);
$eloquentProduct->shouldReceive('getId')->once()->andReturn($productId);
$domainProduct = createDummyDomainProduct();
$domainProduct = createDummyDomainProduct(
orderableStart: CarbonImmutable::now()->subDay(),
orderableEnd: CarbonImmutable::now()->addDay()
);
$productRepository->shouldReceive('find')->once()->with($productId)->andReturn($domainProduct);
$productService->shouldReceive('isOrderable')->once()->with($domainProduct)->andReturn(true);
// WHEN
// When
$result = $policy->addToBasket(null, $eloquentProduct);
// THEN
@ -177,13 +172,12 @@ 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($productService, $productRepository);
$policy = new ProductPolicy($productRepository);
$eloquentProduct = \Mockery::mock(EloquentProduct::class);
// WHEN
// When
$result = $policy->viewPrices(null, $eloquentProduct);
// THEN
@ -192,9 +186,8 @@ 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($productService, $productRepository);
$policy = new ProductPolicy($productRepository);
$customer = \Mockery::mock(Customer::class);
$productId = new ProductId(Uuid::uuid7());
@ -202,8 +195,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
@ -212,22 +205,19 @@ 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($productService, $productRepository);
$policy = new ProductPolicy($productRepository);
$user = \Mockery::mock(User::class);
$productId = new ProductId(Uuid::uuid7());
$eloquentProduct = \Mockery::mock(EloquentProduct::class);
$eloquentProduct->shouldReceive('getId')->once()->andReturn($productId);
$domainProduct = createDummyDomainProduct();
$domainProduct = createDummyDomainProduct(status: false);
$productRepository->shouldReceive('find')->once()->with($productId)->andReturn($domainProduct);
$productService->shouldReceive('isPublished')->once()->with($domainProduct)->andReturn(false);
// WHEN
// When
$result = $policy->viewPrices($user, $eloquentProduct);
// THEN
@ -236,22 +226,19 @@ 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($productService, $productRepository);
$policy = new ProductPolicy($productRepository);
$customer = \Mockery::mock(Customer::class);
$productId = new ProductId(Uuid::uuid7());
$eloquentProduct = \Mockery::mock(EloquentProduct::class);
$eloquentProduct->shouldReceive('getId')->once()->andReturn($productId);
$domainProduct = createDummyDomainProduct();
$domainProduct = createDummyDomainProduct(status: true);
$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,4 +51,53 @@ 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

@ -0,0 +1,24 @@
<?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,15 +4,23 @@ 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 () {
@ -42,6 +50,45 @@ 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 () {
@ -59,6 +106,25 @@ 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,15 +4,25 @@ 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;
@ -199,3 +209,96 @@ 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,4 +39,32 @@ 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,10 +11,38 @@ 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
@ -58,4 +86,413 @@ 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,4 +63,43 @@ 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

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

@ -1,239 +0,0 @@
<?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,6 +4,7 @@ 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;
@ -129,4 +130,64 @@ 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.
});
});