ldc-boutique-cnc-sable/domain/Services/OrderTunnelService.php
Jérémy Munsch c40016bba1
feat(order): implement order placement and related DTOs
- Add methods to convert OrderLine and DeliveryAddress to DTOs.
- Introduce toPlacedEvent method in Order to generate domain event.
- Enhance OrderUnit with Regate code mapping.
- Implement tests for new functionality and ensure correctness.
2026-10-29 20:35:13 +01:00

231 lines
7.7 KiB
PHP

<?php
declare(strict_types=1);
namespace Domain\Services;
use Carbon\CarbonImmutable;
use Carbon\CarbonInterface;
use Domain\Builders\OrderBuilder;
use Domain\Builders\OrderBuilderCompleted;
use Domain\Builders\OrderBuilderWithBasket;
use Domain\Enums\OrderTunnelStep;
use Domain\Exceptions\OrderTunnel\MissingOrderBuilderException;
use Domain\Exceptions\OrderTunnel\OrderLimitExceededException;
use Domain\Exceptions\OrderTunnel\WeeklyLimitExceededException;
use Domain\Models\Entities\Basket;
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\PickupTimeSlot;
use Domain\Policies\CustomerOrderLimitPolicy;
use Domain\Ports\Repositories\OrderRepository;
use Domain\Ports\Repositories\ShopperRepository;
use Domain\Ports\Repositories\ShopRepository;
use Domain\Ports\Services\EmployeeStatisticsService;
use Domain\Ports\Services\OrderBuilderSessionManager;
use Domain\Ports\Services\TransactionManager;
use Shared\DDD\DomainEventDispatcher;
class OrderTunnelService
{
private const TIMESLOTS_DAY_TARGET = 28;
public function __construct(
private readonly BasketService $basketService,
private readonly DomainEventDispatcher $eventDispatcher,
private readonly ShopperService $shopperService,
private readonly OrderBuilderSessionManager $orderBuilderSessionManager,
private readonly PickupSchedulingService $pickupService,
private readonly OrderRepository $orderRepository,
private readonly CustomerOrderLimitPolicy $orderLimitPolicy,
private readonly TransactionManager $transactionManager,
private readonly ShopRepository $shopRepository,
private readonly ShopperRepository $shopperRepository,
private readonly OrderService $orderService,
private readonly EmployeeStatisticsService $employeeStatisticsService,
) {
}
public function hasValidBasket(): bool
{
return $this->basketService->getBasket()->countItems() > 0;
}
public function canAccessStep(OrderTunnelStep $step): bool
{
$builder = $this->orderBuilderSessionManager->get();
$hasValidBasket = $this->hasValidBasket();
return match ($step) {
OrderTunnelStep::REVIEW => $hasValidBasket,
OrderTunnelStep::BOOKING => $this->hasValidBasket() && $builder !== null && $builder->isFromBasket(),
OrderTunnelStep::CONFIRMATION => $this->hasValidBasket() && $builder !== null && $builder->isCompleted(),
};
}
/**
* Resolve the shop assigned to a customer.
*
* @throws \UnexpectedValueException If no shop can be resolved.
*/
public function getShopForCustomer(ShopperId $customerId): Shop
{
return $this->shopperService->getShopFor($customerId)
?? throw new \UnexpectedValueException(
"No shop found for customer '{$customerId->value}'"
);
}
/** @return array<int, PickupTimeSlot> */
public function getAvailableTimeSlots(Shop $shop): array
{
return $this->pickupService->getTimeSlotsFor(
$shop,
$this->getTimeslotsUntil()
);
}
public function initializeFromBasket(Basket $basket, ShopperId $customerId): void
{
/** @var OrderBuilder $builder */
$builder = OrderBuilder::fromBasket($basket, $customerId);
$this->orderBuilderSessionManager->save($builder);
}
/**
* @throws \Domain\Exceptions\OrderTunnel\MissingOrderBuilderException
*/
public function getBuilderFromBasketOrFail(): OrderBuilderWithBasket
{
$builder = $this->orderBuilderSessionManager->get();
if ($builder === null || ! $builder->isFromBasket()) {
throw new MissingOrderBuilderException();
}
/** @var OrderBuilderWithBasket $builder */
return $builder;
}
/**
* @throws \Domain\Exceptions\OrderTunnel\MissingOrderBuilderException
*/
public function getBuilderCompletedOrFail(): OrderBuilderCompleted
{
$builder = $this->orderBuilderSessionManager->get();
if ($builder === null || ! $builder->isCompleted()) {
throw new MissingOrderBuilderException();
}
/** @var OrderBuilderCompleted $builder */
return $builder;
}
/**
* Validate that the selected date matches an available slot and apply it to the builder.
*
* @param array<int, PickupTimeSlot> $availableSlots
*/
public function applyTimeSlot(
OrderBuilderWithBasket $builder,
Shop $shop,
CarbonInterface $selectedDate,
array $availableSlots
): ?PickupTimeSlot {
$matchedSlot = null;
foreach ($availableSlots as $ts) {
if ($selectedDate->isSameDay($ts->getAt())) {
$matchedSlot = $ts;
break;
}
}
if ($matchedSlot === null) {
return null;
}
/** @var OrderBuilder $completed */
$completed = $builder->withPickupTimeSlot($shop->id(), $matchedSlot);
$this->orderBuilderSessionManager->save($completed);
return $matchedSlot;
}
/** @throws \Domain\Exceptions\OrderTunnel\OrderLimitExceededException */
public function assertBasketAmountWithinLimit(Basket $basket): void
{
$this->orderLimitPolicy->assertCanPlaceOrder($basket->totalTaxIncluded());
}
/** @throws \Domain\Exceptions\OrderTunnel\WeeklyLimitExceededException */
public function assertBasketWeeklyAmountWithinLimitFor(
Shopper $shopper,
Basket $withBasket,
\DateTimeInterface $atDate
): void {
$startOfWeek = CarbonImmutable::instance($atDate)->startOfWeek(CarbonInterface::MONDAY);
$endOfWeek = CarbonImmutable::instance($atDate)->endOfWeek(CarbonInterface::SUNDAY);
$weekTotal = $this->employeeStatisticsService->getWeeklyTotalForEmployee(
$shopper->matricule,
$startOfWeek,
$endOfWeek
);
$this->orderLimitPolicy->assertCanPlaceWeeklyOrder($withBasket->totalTaxIncluded(), $weekTotal);
}
/**
* @throws WeeklyLimitExceededException
* @throws OrderLimitExceededException
*/
public function finalizeOrder(OrderBuilderCompleted $builder): Order
{
$shopperId = $builder->getShopperId();
$basket = $this->basketService->revalidateBasket($builder->getBasket());
$pickupTimeSlot = $builder->getPickupTimeSlot();
$shopId = $builder->getShopId();
$shopper = $this->shopperRepository->getByIdOrFail($shopperId);
$this->assertBasketAmountWithinLimit($basket);
$this->assertBasketWeeklyAmountWithinLimitFor(
$shopper,
$basket,
$pickupTimeSlot->getAt()
);
return $this->transactionManager->execute(function () use ($basket, $shopId, $shopper, $pickupTimeSlot) {
$shop = $this->shopRepository->find($shopId);
if (! $shop) {
throw new \DomainException("Shop with id '{$shopId->value}' not found.");
}
$order = $this->orderService->createOrderFromBasket(
$basket,
$pickupTimeSlot,
$shopper,
$shop
);
$this->orderRepository->persist($order);
$this->eventDispatcher->dispatch($order->toPlacedEvent());
$this->basketService->empty();
$this->orderBuilderSessionManager->clear();
return $order;
});
}
private function getTimeslotsUntil(): CarbonInterface
{
return CarbonImmutable::now()->addDays(self::TIMESLOTS_DAY_TARGET);
}
}