<?php declare(strict_types=1);
namespace Swkweb\ProductSet\Core\Content\ProductSet\SalesChannel;
use Shopware\Core\Content\Product\SalesChannel\SalesChannelProductEntity;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepositoryInterface;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Sorting\FieldSorting;
use Shopware\Core\Framework\Routing\Annotation\Entity;
use Shopware\Core\Framework\Uuid\Uuid;
use Shopware\Core\System\SalesChannel\Entity\SalesChannelRepositoryInterface;
use Shopware\Core\System\SalesChannel\SalesChannelContext;
use Swkweb\ProductSet\Core\Content\ProductSet\Aggregate\ProductSetOption\ProductSetOptionEntity;
use Swkweb\ProductSet\Core\Content\ProductSet\Aggregate\ProductSetOption\SalesChannel\SalesChannelProductSetOptionEntity;
use Swkweb\ProductSet\Core\Content\ProductSet\Aggregate\ProductSetPriceCalculation\ProductSetPriceCalculationDefinition;
use Swkweb\ProductSet\Core\Content\ProductSet\Aggregate\ProductSetPriceCalculation\ProductSetPriceCalculationEntity;
use Swkweb\ProductSet\Core\Content\ProductSet\Aggregate\ProductSetSlot\ProductSetSlotEntity;
use Swkweb\ProductSet\Core\Content\ProductSet\Aggregate\ProductSetSlot\SalesChannel\SalesChannelProductSetSlotEntity;
use Swkweb\ProductSet\Core\Content\ProductSet\Cart\ProductSetIterationHelper;
use Swkweb\ProductSet\Core\Content\ProductSet\Exception\InvalidProductSetOptionVariantIdException;
use Swkweb\ProductSet\Core\Content\ProductSet\Exception\ProductSetNotFoundException;
use Swkweb\ProductSet\Core\Content\ProductSet\SalesChannel\Price\ProductSetOptionPriceCalculator;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
/**
* @Route(defaults={"_routeScope"={"store-api"}})
*/
class ProductSetRoute implements ProductSetRouteInterface
{
private SalesChannelRepositoryInterface $productRepository;
private SalesChannelRepositoryInterface $productSetRepository;
private EntityRepositoryInterface $productSetPriceCalculationRepository;
private ProductSetOptionPriceCalculator $optionPriceCalculator;
private ProductSetIterationHelper $iterationHelper;
public function __construct(
SalesChannelRepositoryInterface $productRepository,
SalesChannelRepositoryInterface $productSetRepository,
EntityRepositoryInterface $productSetPriceCalculationRepository,
ProductSetOptionPriceCalculator $optionPriceCalculator,
ProductSetIterationHelper $iterationHelper
) {
$this->productRepository = $productRepository;
$this->productSetRepository = $productSetRepository;
$this->productSetPriceCalculationRepository = $productSetPriceCalculationRepository;
$this->optionPriceCalculator = $optionPriceCalculator;
$this->iterationHelper = $iterationHelper;
}
/**
* @Entity("swkweb_product_set")
*
* @Route("/store-api/swkweb-product-set/{productId}", name="store-api.swkweb-product-set.detail", methods={"POST"})
*/
public function load(string $productId, ?string $path, Request $request, SalesChannelContext $context, Criteria $criteria): ProductSetRouteResponse
{
$set = $this->loadSet($productId, $path, $context, $criteria);
$this->loadPriceCalculations($set, $context);
$this->loadOptionProducts($productId, $set, $request, $context);
$this->calculateOptionPrices($set, $context);
return new ProductSetRouteResponse($set, $path);
}
private function loadSet(string $productId, ?string $path, SalesChannelContext $context, Criteria $criteria): SalesChannelProductSetEntity
{
$setPath = $this->parsePath($path);
$criteria->setTitle('swkweb-product-set::product-set-route::load::product-set');
$slotAssignmentCriteria = $criteria->getAssociation('slotAssignments');
$slotCriteria = $slotAssignmentCriteria->getAssociation('slot');
$optionCriteria = $slotCriteria->getAssociation('options');
$criteria->addFilter(new EqualsFilter('setProducts.productId', $productId));
if (isset($setPath['slotId'])) {
$slotAssignmentCriteria->addFilter(new EqualsFilter('id', $setPath['slotId']));
}
if (isset($setPath['optionId'])) {
$optionCriteria->addFilter(new EqualsFilter('id', $setPath['optionId']));
}
$slotAssignmentCriteria->addSorting(new FieldSorting('position'));
$optionCriteria->addSorting(new FieldSorting('position'));
$productSet = $this->productSetRepository->search($criteria, $context)->first();
if (!$productSet instanceof SalesChannelProductSetEntity) {
throw new ProductSetNotFoundException($productId, $path);
}
return $productSet;
}
private function loadOptionProducts(string $productId, SalesChannelProductSetEntity $set, Request $request, SalesChannelContext $context): void
{
$optionProductIds = [];
foreach ($this->iterationHelper->iterateOptions($set) as $optionPath => $option) {
if (!$option->getOptionProductId()) {
continue;
}
$optionProductIds[] = $this->getOptionVariantProductId($optionPath, $request) ?? $option->getOptionProductId();
}
if (count($optionProductIds) === 0) {
return;
}
$criteria = new Criteria($optionProductIds);
$criteria->setTitle('swkweb-product-set::product-set-route::load::option-products');
$criteria->addAssociation('options.group');
$criteria->addAssociation('configuratorSettings.option.group');
$result = $this->productRepository->search($criteria, $context);
foreach ($this->iterationHelper->iterateSlots($set) as $slotPath => $slot) {
assert($slot instanceof SalesChannelProductSetSlotEntity);
foreach ($this->iterationHelper->iterateSlotOptions($slot, $slotPath) as $optionPath => $option) {
assert($option instanceof SalesChannelProductSetOptionEntity);
$optionProductId = $this->getOptionVariantProductId($optionPath, $request) ?? $option->getOptionProductId();
$optionProduct = $result->get($optionProductId);
if ($optionProductId !== $option->getOptionProductId()
&& (
!$optionProduct instanceof SalesChannelProductEntity
|| $optionProduct->getId() !== $option->getOptionProductId() && $optionProduct->getParentId() !== $option->getOptionProductId()
)
) {
throw new InvalidProductSetOptionVariantIdException($productId, $optionPath);
}
if (!$optionProduct instanceof SalesChannelProductEntity) {
assert($slot->getOptions() !== null);
$slot->getOptions()->remove($option->getId());
continue;
}
$option->setSlot($slot);
$option->setOptionProduct($optionProduct);
// First calculate the max purchase since the min purchase depends on this value
$this->buildOptionCalculatedMaxQuantity($slot, $option);
$this->buildOptionCalculatedMinQuantity($option);
}
}
}
private function loadPriceCalculations(SalesChannelProductSetEntity $set, SalesChannelContext $context): void
{
/**
* @var list<array{
* entity: ProductSetSlotEntity|ProductSetOptionEntity,
* priceCalculationId: string,
* }> $mapping
*/
$mapping = [];
foreach ($this->iterationHelper->iterateSlots($set) as $slot) {
if (empty($slot->getOptions())) {
continue;
}
$slotPriceCalculationId = $slot->getPriceCalculationId() ?? ProductSetPriceCalculationDefinition::DEFAULT;
$mapping[] = [
'entity' => $slot,
'priceCalculationId' => $slotPriceCalculationId,
];
foreach ($slot->getOptions() as $option) {
$mapping[] = [
'entity' => $option,
'priceCalculationId' => $option->getPriceCalculationId() ?? $slotPriceCalculationId,
];
}
}
if (count($mapping) === 0) {
return;
}
$criteria = new Criteria(array_unique(array_column($mapping, 'priceCalculationId')));
$result = $this->productSetPriceCalculationRepository->search($criteria, $context->getContext());
foreach ($mapping as ['entity' => $entity, 'priceCalculationId' => $priceCalculationId]) {
$priceCalculation = $result->get($priceCalculationId);
assert($priceCalculation instanceof ProductSetPriceCalculationEntity);
$entity->setPriceCalculation($priceCalculation);
}
}
private function calculateOptionPrices(SalesChannelProductSetEntity $set, SalesChannelContext $context): void
{
foreach ($this->iterationHelper->iterateOptions($set) as $option) {
assert($option instanceof SalesChannelProductSetOptionEntity);
$this->optionPriceCalculator->calculate($option, $context);
}
}
/**
* @return array{slotId: ?string, optionId: ?string, childPath: ?string}
*/
private function parsePath(?string $path): array
{
$parts = explode('/', $path ?? '', 3);
// Remove empty element caused by leading slash
if ($parts[0] === '') {
array_shift($parts);
}
$slotId = $parts[0] ?? '';
$slotId = Uuid::isValid($slotId) ? $slotId : null;
$optionId = $parts[1] ?? '';
$optionId = $slotId && Uuid::isValid($optionId) ? $optionId : null;
return [
'slotId' => $slotId,
'optionId' => $optionId,
'childPath' => $parts[2] ?? null,
];
}
private function getOptionVariantProductId(string $path, Request $request): ?string
{
$optionVariantProductIds = $request->get(self::OPTION_VARIANT_PRODUCT_IDS);
if (!is_array($optionVariantProductIds)) {
return null;
}
return $optionVariantProductIds[$path] ?? null;
}
private function buildOptionCalculatedMaxQuantity(
SalesChannelProductSetSlotEntity $slot,
SalesChannelProductSetOptionEntity $option
): void {
if (empty($option->getOptionProduct())) {
$option->setCalculatedMaxQuantity($option->getMaximumQuantity());
return;
}
$maxQuantities = [
$option->getMaximumQuantity(),
$option->getOptionProduct()->getCalculatedMaxPurchase(),
];
if ($slot->isLimitAggregatedOptionQuantities()) {
$maxQuantities[] = $slot->getMaximumSelectedOptions();
}
$option->setCalculatedMaxQuantity(min(...$maxQuantities));
}
private function buildOptionCalculatedMinQuantity(SalesChannelProductSetOptionEntity $option): void
{
// Limit of the aggregated option quantities or the product max purchase is already
// considered in the calculatedMaxQuantity of the option
$option->setCalculatedMinQuantity(min(
$option->getMinimumQuantity(),
$option->getCalculatedMaxQuantity()
));
}
}