Wenn man das mit einem wachen Auge macht, dann gibts auch keinen Fehler
Dankeschön für den Hinweis mit der MariaDB.
Ich hab’s jetzt sauber eingebaut und nun funktioniert es - scheinbar ist der Fix in der 6.7.5.1 nicht drin.
<?php declare(strict_types=1);
namespace Shopware\Core\Content\Product\Subscriber;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Shopware\Core\Content\Product\DataAbstractionLayer\Indexing\ProductListingPriceIndexingMessage;
use Shopware\Core\Content\Product\DataAbstractionLayer\Indexing\ProductSortingIndexingMessage;
use Shopware\Core\Content\Product\DataAbstractionLayer\Indexing\ProductStreamIndexingMessage;
use Shopware\Core\Content\Product\DataAbstractionLayer\ProductIndexingMessage;
use Shopware\Core\Content\Product\DataAbstractionLayer\ProductListingSeoIndexer;
use Shopware\Core\Content\Product\DataAbstractionLayer\ProductManufacturerIndexer;
use Shopware\Core\Content\Product\DataAbstractionLayer\ProductSearchKeywordIndexer;
use Shopware\Core\Content\Product\DataAbstractionLayer\ProductStockIndexer;
use Shopware\Core\Content\Product\DataAbstractionLayer\ProductStreamProcessor;
use Shopware\Core\Content\Product\ProductDefinition;
use Shopware\Core\Content\Product\ProductEntity;
use Shopware\Core\Content\ProductExport\ExportProcessor;
use Shopware\Core\Content\Property\PropertyGroupDefinition;
use Shopware\Core\Content\Property\PropertyGroupOptionCollection;
use Shopware\Core\Content\Property\PropertyGroupOptionEntity;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\Doctrine\FetchModeHelper;
use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityWrittenContainerEvent;
use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityWrittenEvent;
use Shopware\Core\Framework\DataAbstractionLayer\Event\NestedEventCollection;
use Shopware\Core\Framework\DataAbstractionLayer\Event\PartialEntityWrittenEvent;
use Shopware\Core\Framework\DataAbstractionLayer\Event\PartialEntityWrittenEventFactory;
use Shopware\Core\Framework\DataAbstractionLayer\Event\PreWriteValidationEvent;
use Shopware\Core\Framework\DataAbstractionLayer\Pricing\PriceCollection;
use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\DeleteCommand;
use Shopware\Core\Framework\DataAbstractionLayer\Write\WriteContext;
use Shopware\Core\Framework\Event\BusinessEventCollector;
use Shopware\Core\Framework\Feature;
use Shopware\Core\Framework\Uuid\Uuid;
use Shopware\Core\Framework\Validation\Exception\WriteConstraintViolationException;
use Shopware\Core\Framework\Validation\WriteConstraintViolation;
use Shopware\Core\System\NumberRange\ValueGenerator\NumberRangeValueGeneratorInterface;
use Shopware\Core\System\SalesChannel\SalesChannelContext;
use Shopware\Core\System\SalesChannel\SalesChannelDefinition;
use Shopware\Core\System\SalesChannel\SalesChannelEntity;
use Shopware\Core\System\SystemConfig\SystemConfigService;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class ProductSubscriber implements EventSubscriberInterface
{
private Connection $connection;
private NumberRangeValueGeneratorInterface $numberRangeValueGenerator;
private SystemConfigService $systemConfigService;
private EventDispatcherInterface $dispatcher;
private ValidatorInterface $validator;
/**
* @var array<string, string>
*/
private array $listingConfig = [];
private PartialEntityWrittenEventFactory $partialEventFactory;
public function __construct(
Connection $connection,
NumberRangeValueGeneratorInterface $numberRangeValueGenerator,
SystemConfigService $systemConfigService,
EventDispatcherInterface $dispatcher,
ValidatorInterface $validator,
PartialEntityWrittenEventFactory $partialEventFactory
) {
$this->connection = $connection;
$this->numberRangeValueGenerator = $numberRangeValueGenerator;
$this->systemConfigService = $systemConfigService;
$this->dispatcher = $dispatcher;
$this->validator = $validator;
$this->partialEventFactory = $partialEventFactory;
}
public static function getSubscribedEvents(): array
{
return [
PreWriteValidationEvent::class => [
['checkParentAssignment', 100],
['checkCategoryAssignment', 100],
['validateAfterSwitchTax', -100],
],
EntityWrittenContainerEvent::class => 'written',
];
}
public function checkParentAssignment(PreWriteValidationEvent $event): void
{
$commands = $event->getCommandsForEntity(ProductDefinition::class);
if (empty($commands)) {
return;
}
$idsToDelete = [];
foreach ($commands as $command) {
if (!$command instanceof DeleteCommand) {
continue;
}
$idsToDelete[] = $command->getPrimaryKey()['id'];
}
if (empty($idsToDelete)) {
return;
}
$qb = $this->connection->createQueryBuilder();
$qb->select('parent.id as parent_id', 'AnyChild.id as child_id')
->from('product', 'parent')
->innerJoin('parent', 'product', 'AnyChild', 'AnyChild.parent_id = parent.id')
->where('parent.id IN (:ids)')
->setParameter('ids', Uuid::fromHexToBytesList($idsToDelete), Connection::PARAM_STR_ARRAY);
$data = FetchModeHelper::groupUnique($qb->fetchAllAssociative(), 'parent_id', 'child_id');
if (empty($data)) {
return;
}
$errors = [];
foreach ($data as $parentId => $children) {
if (empty($children)) {
continue;
}
$errors[] = new WriteConstraintViolation(
'You can not delete a product that has variants. Please delete all variants first.',
'/id',
$parentId
);
}
if (!empty($errors)) {
throw new WriteConstraintViolationException($errors, $event->getPath());
}
}
public function checkCategoryAssignment(PreWriteValidationEvent $event): void
{
$commands = $event->getCommandsForEntity(ProductDefinition::class);
if (empty($commands)) {
return;
}
$idsToDelete = [];
foreach ($commands as $command) {
if (!$command instanceof DeleteCommand) {
continue;
}
$idsToDelete[] = $command->getPrimaryKey()['id'];
}
if (empty($idsToDelete)) {
return;
}
$qb = $this->connection->createQueryBuilder();
$qb->select('product.id as product_id', 'category_.name as category_name')
->from('product', 'product')
->innerJoin('product', 'product_category', 'product_category', 'product_category.product_id = product.id')
->innerJoin('product_category', 'category', 'category_', 'category_.id = product_category.category_id')
->where('product.id IN (:ids)')
->setParameter('ids', Uuid::fromHexToBytesList($idsToDelete), Connection::PARAM_STR_ARRAY);
$data = FetchModeHelper::groupUnique($qb->fetchAllAssociative(), 'product_id', 'category_name');
if (empty($data)) {
return;
}
$errors = [];
foreach ($data as $productId => $categories) {
if (empty($categories)) {
continue;
}
$errors[] = new WriteConstraintViolation(
\sprintf(
'You cannot delete product %s because it is already assigned to categories: %s. Please remove those assignments first.',
$productId,
\implode(', ', $categories)
),
'/id',
$productId
);
}
if (!empty($errors)) {
throw new WriteConstraintViolationException($errors, $event->getPath());
}
}
public function validateAfterSwitchTax(PreWriteValidationEvent $event): void
{
// this line hard-codes Shopware's tax toggle feature flag
if (!Feature::isActive('DISABLE_LEGACY_TAX')) {
return;
}
$commands = $event->getCommandsForEntity(ProductDefinition::class);
if (empty($commands)) {
return;
}
$errors = [];
foreach ($commands as $command) {
if (!$command instanceof DeleteCommand) {
continue;
}
$updatedData = $command->getPayload();
$prices = $updatedData['price'] ?? null;
if ($prices instanceof PriceCollection) {
$prices = $prices->getElements();
}
if (!$prices) {
continue;
}
foreach ($prices as $price) {
if (!isset($price['net']) && isset($price['gross'])) {
$errors[] = new WriteConstraintViolation(
'Please provide net price when tax calculation is based on the net amount.',
'/price/net'
);
}
}
}
if (!empty($errors)) {
throw new WriteConstraintViolationException($errors, $event->getPath());
}
}
public function written(EntityWrittenContainerEvent $event): void
{
$productWrittenEvent = $event->getEventByEntityName(ProductDefinition::ENTITY_NAME);
if (!$productWrittenEvent instanceof EntityWrittenEvent) {
return;
}
$context = $productWrittenEvent->getContext();
/** @var EntityWrittenEvent|null $salesChannelEvent */
$salesChannelEvent = $event->getEventByEntityName(SalesChannelDefinition::ENTITY_NAME);
$salesChannelIds = $salesChannelEvent ? $salesChannelEvent->getIds() : [];
$this->ensureProductNumber($productWrittenEvent, $context);
$this->setCanonicalUrl($productWrittenEvent, $context);
$salesChannel = $this->getSalesChannel($salesChannelIds, $context);
$this->setDefaultLayoutForSalesChannels($productWrittenEvent, $salesChannel, $context);
$this->cleanupConfiguratorSettings($productWrittenEvent->getIds(), $context->getVersionId());
$this->indexProducts($event, $productWrittenEvent, $context);
$this->indexProductStreams($event, $productWrittenEvent, $context);
$this->indexProductManufacturer($event, $productWrittenEvent, $context);
$this->indexProductListingSeo($event, $productWrittenEvent, $context);
$this->indexListingPrices($event, $productWrittenEvent, $context);
$this->indexProductSearchKeywords($event, $productWrittenEvent, $context);
$this->indexProductSorting($event, $productWrittenEvent, $context);
$this->indexProductStock($event, $productWrittenEvent, $context);
$this->exportProducts($event, $productWrittenEvent, $context);
$this->callBusinessEvents($event, $productWrittenEvent, $context);
}
private function ensureProductNumber(EntityWrittenEvent $event, Context $context): void
{
$productsWithoutProductNumber = [];
foreach ($event->getWriteResults() as $writeResult) {
$payload = $writeResult->getPayload();
if (!isset($payload['productNumber']) || $payload['productNumber'] !== null) {
continue;
}
$productId = $writeResult->getPrimaryKey()['id'] ?? null;
if (!$productId) {
continue;
}
$productsWithoutProductNumber[] = $productId;
}
if (empty($productsWithoutProductNumber)) {
return;
}
$productNumbers = $this->numberRangeValueGenerator->batchGenerate(
ProductDefinition::ENTITY_NAME,
$context,
\count($productsWithoutProductNumber)
);
$update = [];
foreach ($productsWithoutProductNumber as $index => $productId) {
$update[] = [
'id' => $productId,
'productNumber' => $productNumbers[$index],
];
}
if (!empty($update)) {
$this->connection->update('product', ['product_number' => $productNumbers[$index]], ['id' => $productId]);
}
}
private function setCanonicalUrl(EntityWrittenEvent $event, Context $context): void
{
if (!Feature::isActive('v6.5.0.0')) {
return;
}
$writtenIds = $event->getIds();
$query = $this->connection->createQueryBuilder()
->select('LOWER(HEX(product.id)) AS productId', 'productSeo.url')
->from('product', 'product')
->innerJoin('product', 'seo_url', 'productSeo', 'productSeo.foreign_key = product.id')
->where('product.id IN (:ids)')
->andWhere('productSeo.route_name = :productRoute')
->andWhere('productSeo.is_canonical = 1')
->setParameter('ids', Uuid::fromHexToBytesList($writtenIds), Connection::PARAM_STR_ARRAY)
->setParameter('productRoute', 'frontend.detail.page');
$urls = FetchModeHelper::group($query->executeQuery()->fetchAllAssociative(), 'productId');
foreach ($urls as $productId => $urlData) {
$urls[$productId] = \array_column($urlData, 'url');
}
$violations = [];
foreach ($event->getWriteResults() as $writeResult) {
$payload = $writeResult->getPayload();
$productId = $writeResult->getPrimaryKey()['id'] ?? null;
if (!isset($urls[$productId]) || !isset($payload['canonicalProduct'])) {
continue;
}
$canonicalProductId = $payload['canonicalProduct']['id'];
if ($canonicalProductId === $productId) {
continue;
}
if (!isset($urls[$canonicalProductId])) {
continue;
}
if (\array_intersect($urls[$productId], $urls[$canonicalProductId])) {
$violations[] = new WriteConstraintViolation(
\sprintf('Product %s and canonical product %s share the same canonical URL.', $productId, $canonicalProductId),
'/canonicalProduct/id'
);
}
}
if (!empty($violations)) {
throw new WriteConstraintViolationException($violations, '/products');
}
}
/**
* @param EntityWrittenEvent[]|NestedEventCollection[]|EntityWrittenContainerEvent[] $events
*/
private function indexProducts($events, EntityWrittenEvent $productWrittenEvent, Context $context): void
{
$ids = $productWrittenEvent->getIds();
$message = new ProductIndexingMessage($ids, $context->getContext());
$this->dispatcher->dispatch($message);
}
private function indexProductStreams(EntityWrittenContainerEvent $event, EntityWrittenEvent $productWrittenEvent, Context $context): void
{
if (!Feature::isActive('remove-legacy-product-streams')) {
return;
}
/** @var NestedEventCollection|null $productStreamEvent */
$productStreamEvent = $event->getEventByEntityName(ProductStreamProcessor::class);
if (!$productStreamEvent) {
return;
}
$ids = $productStreamEvent->getEvents()[ProductStreamIndexingMessage::class]->getIds();
$message = new ProductStreamIndexingMessage($ids, $context->getContext());
$this->dispatcher->dispatch($message);
}
private function indexProductManufacturer(EntityWrittenContainerEvent $event, EntityWrittenEvent $productWrittenEvent, Context $context): void
{
/** @var EntityWrittenEvent|null $manufacturerEvent */
$manufacturerEvent = $event->getEventByEntityName(ProductManufacturerIndexer::class);
if (!$manufacturerEvent) {
return;
}
$ids = $manufacturerEvent->getIds();
$message = new ProductManufacturerIndexer($ids, $context->getContext());
$this->dispatcher->dispatch($message);
}
private function indexProductListingSeo(EntityWrittenContainerEvent $event, EntityWrittenEvent $productWrittenEvent, Context $context): void
{
/** @var EntityWrittenEvent|null $listingSeoEvent */
$listingSeoEvent = $event->getEventByEntityName(ProductListingSeoIndexer::class);
if (!$listingSeoEvent) {
return;
}
$ids = $listingSeoEvent->getIds();
$message = new ProductListingSeoIndexer($ids, $context->getContext());
$this->dispatcher->dispatch($message);
}
private function indexListingPrices(EntityWrittenContainerEvent $event, EntityWrittenEvent $productWrittenEvent, Context $context): void
{
/** @var EntityWrittenEvent|null $listingPriceEvent */
$listingPriceEvent = $event->getEventByEntityName(ProductListingPriceIndexingMessage::class);
if (!$listingPriceEvent) {
return;
}
$ids = $listingPriceEvent->getIds();
$message = new ProductListingPriceIndexingMessage($ids, $context->getContext());
$this->dispatcher->dispatch($message);
}
private function indexProductSearchKeywords(EntityWrittenContainerEvent $event, EntityWrittenEvent $productWrittenEvent, Context $context): void
{
/** @var EntityWrittenEvent|null $productSearchKeywordEvent */
$productSearchKeywordEvent = $event->getEventByEntityName(ProductSearchKeywordIndexer::class);
if (!$productSearchKeywordEvent) {
return;
}
$ids = $productSearchKeywordEvent->getIds();
$message = new ProductSearchKeywordIndexer($ids, $context->getContext());
$this->dispatcher->dispatch($message);
}
private function indexProductSorting(EntityWrittenContainerEvent $event, EntityWrittenEvent $productWrittenEvent, Context $context): void
{
/** @var EntityWrittenEvent|null $productSortingEvent */
$productSortingEvent = $event->getEventByEntityName(ProductSortingIndexingMessage::class);
if (!$productSortingEvent) {
return;
}
$ids = $productSortingEvent->getIds();
$message = new ProductSortingIndexingMessage($ids, $context->getContext());
$this->dispatcher->dispatch($message);
}
private function indexProductStock(EntityWrittenContainerEvent $event, EntityWrittenEvent $productWrittenEvent, Context $context): void
{
/** @var EntityWrittenEvent|null $stockEvent */
$stockEvent = $event->getEventByEntityName(ProductStockIndexer::class);
if (!$stockEvent) {
return;
}
$ids = $stockEvent->getIds();
$message = new ProductStockIndexer($ids, $context->getContext());
$this->dispatcher->dispatch($message);
}
private function exportProducts(EntityWrittenContainerEvent $event, EntityWrittenEvent $productWrittenEvent, Context $context): void
{
if (!Feature::isActive('v6.5.0.0')) {
return;
}
/** @var EntityWrittenEvent|null $exportEvent */
$exportEvent = $event->getEventByEntityName(ExportProcessor::class);
if (!$exportEvent) {
return;
}
$ids = $exportEvent->getIds();
$message = new ExportProcessor($ids, $context->getContext());
$this->dispatcher->dispatch($message);
}
private function callBusinessEvents(EntityWrittenContainerEvent $event, EntityWrittenEvent $productWrittenEvent, Context $context): void
{
/** @var NestedEventCollection|null $businessEventCollection */
$businessEventCollection = $event->getEventByEntityName(BusinessEventCollector::class);
if (!$businessEventCollection) {
return;
}
foreach ($businessEventCollection->getEvents() as $businessEvent) {
$this->dispatcher->dispatch($businessEvent);
}
}
private function cleanupConfiguratorSettings(array $parentIds, string $versionBytes): void
{
if (empty($parentIds)) {
return;
}
// Clean up configurator settings for parents that no longer have variants using those options
$this->connection->executeStatement(
'DELETE FROM product_configurator_setting
WHERE product_configurator_setting.product_id IN (:parentIds)
AND product_configurator_setting.product_version_id = :versionId
AND NOT EXISTS (
SELECT 1
FROM product_option po
INNER JOIN product p ON p.id = po.product_id AND p.version_id = po.product_version_id
WHERE p.parent_id = product_configurator_setting.product_id
AND p.version_id = :versionId
AND po.property_group_option_id = product_configurator_setting.property_group_option_id
AND po.product_version_id = :versionId
)',
[
'parentIds' => $parentIds,
'versionId' => $versionBytes,
],
[
'parentIds' => ArrayParameterType::BINARY,
]
);
}
/**
* @param Entity $product - typehint as Entity because it could be a ProductEntity or PartialEntity
*/
private function setDefaultLayout(Entity $product, ?string $salesChannelId = null): void
{
if (!$product->has('cmsPageId')) {
return;
}
if ($product->get('cmsPageId') !== null) {
return;
}
$cmsPageId = $this->systemConfigService->get(ProductDefinition::CONFIG_KEY_DEFAULT_CMS_PAGE_PRODUCT, $salesChannelId);
$product->assign(['cmsPageId' => $cmsPageId]);
}
private function setDefaultLayoutForSalesChannels(EntityWrittenEvent $productWrittenEvent, ?SalesChannelEntity $salesChannel, Context $context): void
{
$products = $productWrittenEvent->getEntities();
if ($salesChannel === null) {
foreach ($products as $product) {
$this->setDefaultLayout($product);
}
return;
}
foreach ($products as $product) {
$this->setDefaultLayout($product, $salesChannel->getId());
}
}
private function getSalesChannel(array $salesChannelIds, Context $context): ?SalesChannelEntity
{
if (empty($salesChannelIds)) {
return null;
}
$criteria = new Criteria($salesChannelIds);
$criteria->addAssociation('domains');
$criteria->addAssociation('languages');
$criteria->addAssociation('currencies');
$criteria->addAssociation('type');
$criteria->addAssociation('country');
$criteria->addAssociation('shippingMethods');
$criteria->addAssociation('paymentMethods');
$criteria->addAssociation('customerGroups');
return $this->salesChannelRepository->search($criteria, $context)->first();
}
}