- 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.
231 lines
7.7 KiB
PHP
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);
|
|
}
|
|
}
|