Compare commits
19 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd7d5f5fe9 | ||
|
|
0e05779f89 | ||
|
|
c8f895c759 | ||
|
|
c40016bba1 | ||
|
|
71e0d65f31 | ||
|
|
da0c3afc14 | ||
|
|
e857a58d00 | ||
|
|
cfac370e2e | ||
|
|
08354b44b6 | ||
|
|
ed593c25b0 | ||
|
|
c52b13cc51 | ||
|
|
7b910c5af6 | ||
|
|
961829a0c7 | ||
|
|
36faed679e | ||
|
|
cef7a4e4b0 | ||
|
|
a87f5756b2 | ||
|
|
0874259eba | ||
|
|
3ac3cbc7ff | ||
|
|
16dfc32f73 |
76 changed files with 1931 additions and 1190 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> */
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,9 +66,7 @@ class MoneyServiceProvider extends ServiceProvider
|
|||
*/
|
||||
public function boot()
|
||||
{
|
||||
if (! $this->app->runningInConsole()) {
|
||||
$this->sharetoView();
|
||||
}
|
||||
$this->sharetoView();
|
||||
}
|
||||
|
||||
private function sharetoView(): void
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) === '') {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
52
resources/sass/fo/vendor/_sliders.scss
vendored
52
resources/sass/fo/vendor/_sliders.scss
vendored
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@
|
|||
'model' => $faq,
|
||||
'first' => $loop->first,
|
||||
'last' => $loop->last,
|
||||
'isFirstPage' => $faqs->onFirstPage(),
|
||||
'isLastPage' => !$faqs->hasMorePages(),
|
||||
])
|
||||
<td>
|
||||
<div class="btn-group">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@
|
|||
'cols' => $cols,
|
||||
'mobileHide' => [],
|
||||
'ignore' => ['customer', 'pickup_date'],
|
||||
'defaultSortCol' => 'date',
|
||||
'defaultSortWay' => 'desc',
|
||||
])
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
|
|||
|
|
@ -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)) }} {{ Str::ucfirst(__('crud.actions.list')) }}
|
||||
{{ __('Today\'s Orders') }}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -79,6 +79,8 @@
|
|||
'model' => $product,
|
||||
'first' => $loop->first,
|
||||
'last' => $loop->last,
|
||||
'isFirstPage' => $products->onFirstPage(),
|
||||
'isLastPage' => !$products->hasMorePages(),
|
||||
'noOrder' => $noOrder ?? false,
|
||||
])
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@
|
|||
'model' => $range,
|
||||
'first' => $loop->first,
|
||||
'last' => $loop->last,
|
||||
'isFirstPage' => $ranges->onFirstPage(),
|
||||
'isLastPage' => !$ranges->hasMorePages(),
|
||||
])
|
||||
<td>
|
||||
@include('bo.modules.toggle-status', [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@
|
|||
'model' => $slide,
|
||||
'first' => $loop->first,
|
||||
'last' => $loop->last,
|
||||
'isFirstPage' => $slides->onFirstPage(),
|
||||
'isLastPage' => !$slides->hasMorePages(),
|
||||
])
|
||||
<td>
|
||||
<div class="btn-group">
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@
|
|||
'model' => $tag,
|
||||
'first' => $loop->first,
|
||||
'last' => $loop->last,
|
||||
'isFirstPage' => $tags->onFirstPage(),
|
||||
'isLastPage' => !$tags->hasMorePages(),
|
||||
])
|
||||
<td>
|
||||
@include('bo.modules.toggle-status', [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -194,4 +194,3 @@ describe('ProductsController (Ajax)', function () {
|
|||
->and($data[0]['discountedPriceTtc'])->not->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
92
tests/Feature/Bo/Modules/ChangeOrderBladeTest.php
Normal file
92
tests/Feature/Bo/Modules/ChangeOrderBladeTest.php
Normal 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"');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
86
tests/Integration/WithApp/App/Models/DiscountTest.php
Normal file
86
tests/Integration/WithApp/App/Models/DiscountTest.php
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
24
tests/Unit/Domain/Enums/Units/OrderUnitTest.php
Normal file
24
tests/Unit/Domain/Enums/Units/OrderUnitTest.php
Normal 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'],
|
||||
]);
|
||||
});
|
||||
|
|
@ -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 = ' ';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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.
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue