custom/static-plugins/CioFormBuilder/src/Subscriber/ProductDetailSubscriber.php line 89

Open in your IDE?
  1. <?php
  2. namespace CioFormBuilder\Subscriber;
  3. use CioBudget\Definition\Budget\BudgetEntity;
  4. use CioBudget\Service\BudgetLoaderService;
  5. use CioBudget\Service\SessionService;
  6. use CioFormBuilder\Definition\CioForm\CioFormEntity;
  7. use CioFormBuilder\Definition\CioFormField\CioFormFieldEntity;
  8. use CioFormBuilder\Model\CioFormBuilder;
  9. use CioFormBuilder\Model\Field\AbstractField;
  10. use CioFormBuilder\Model\Field\BudgetSelectField;
  11. use CioFormBuilder\Model\Field\FileField;
  12. use CioFormBuilder\Model\Field\SelectField;
  13. use CioFormBuilder\Model\Field\TextField;
  14. use CioPodProducts\CioPodProducts;
  15. use CioPodProducts\Error\PodInvalidFileTypeError;
  16. use Shopware\Core\Checkout\Cart\CartPersister;
  17. use Shopware\Core\Checkout\Cart\Event\AfterLineItemAddedEvent;
  18. use Shopware\Core\Checkout\Cart\Event\AfterLineItemQuantityChangedEvent;
  19. use Shopware\Core\Checkout\Cart\Event\BeforeLineItemAddedEvent;
  20. use Shopware\Core\Checkout\Cart\LineItem\LineItem;
  21. use Shopware\Core\Checkout\Cart\Price\Struct\QuantityPriceDefinition;
  22. use Shopware\Core\Checkout\Customer\CustomerEntity;
  23. use Shopware\Core\Content\Media\Exception\FileExtensionNotSupportedException;
  24. use Shopware\Core\Content\Media\File\FileSaver;
  25. use Shopware\Core\Content\Media\MediaService;
  26. use Shopware\Core\Content\Product\ProductEntity;
  27. use Shopware\Core\Framework\Context;
  28. use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
  29. use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
  30. use Shopware\Core\Framework\DataAbstractionLayer\Search\EntitySearchResult;
  31. use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
  32. use Shopware\Core\Framework\Uuid\Uuid;
  33. use Shopware\Storefront\Event\StorefrontRenderEvent;
  34. use Shopware\Storefront\Page\Checkout\Cart\CheckoutCartPageLoadedEvent;
  35. use Shopware\Storefront\Page\Checkout\Offcanvas\OffcanvasCartPageLoadedEvent;
  36. use Shopware\Storefront\Page\Product\ProductPage;
  37. use Symfony\Component\EventDispatcher\EventSubscriberInterface;
  38. use Symfony\Component\HttpFoundation\File\UploadedFile;
  39. use Symfony\Component\HttpFoundation\RequestStack;
  40. use Symfony\Contracts\Translation\TranslatorInterface;
  41. class ProductDetailSubscriber implements EventSubscriberInterface
  42. {
  43.     protected RequestStack $requestStack;
  44.     protected MediaService $mediaService;
  45.     protected CartPersister $cartPersister;
  46.     protected EntityRepository $cioFormRepository;
  47.     protected EntityRepository $productRepository;
  48.     protected \Psr\Log\LoggerInterface $logger;
  49.     protected BudgetLoaderService $budgetLoaderService;
  50.     protected SessionService $sessionService;
  51.     protected TranslatorInterface $translator;
  52.     public function __construct(RequestStack $requestStackMediaService $mediaServiceCartPersister $cartPersisterEntityRepository $cioFormRepositoryEntityRepository $productRepository\Psr\Log\LoggerInterface $loggerBudgetLoaderService $budgetLoaderServiceSessionService $sessionServiceTranslatorInterface $translator)
  53.     {
  54.         $this->requestStack $requestStack;
  55.         $this->mediaService $mediaService;
  56.         $this->cartPersister $cartPersister;
  57.         $this->cioFormRepository $cioFormRepository;
  58.         $this->productRepository $productRepository;
  59.         $this->logger $logger;
  60.         $this->budgetLoaderService $budgetLoaderService;
  61.         $this->sessionService $sessionService;
  62.         $this->translator $translator;
  63.     }
  64.     public static function getSubscribedEvents()
  65.     {
  66.         return [
  67.             StorefrontRenderEvent::class => 'addProductDetailFormData',
  68.             BeforeLineItemAddedEvent::class => 'onBeforeLineItemAddedEvent',
  69.             AfterLineItemAddedEvent::class => 'onAfterLineItemAddedEvent',
  70.             AfterLineItemQuantityChangedEvent::class => 'onAfterLineItemQuantityChangedEvent',
  71.             OffcanvasCartPageLoadedEvent::class => 'clearSuccessFlashOnInvalidUpload',
  72.             CheckoutCartPageLoadedEvent::class => 'clearSuccessFlashOnInvalidUpload',
  73.         ];
  74.     }
  75.     public function addProductDetailFormData(StorefrontRenderEvent $event)
  76.     {
  77.         $customer $event->getSalesChannelContext()->getCustomer();
  78.         if ($customer instanceof CustomerEntity) {
  79.             if (is_array($event->getParameters()) && key_exists('page'$event->getParameters())) {
  80.                 $page $event->getParameters()['page'];
  81.                 if ($page instanceof ProductPage) {
  82.                     $request $event->getRequest();
  83.                     $lineItem null;
  84.                     if ($request->query->has('podLineItemId') && $event->getSalesChannelContext()->getToken()) {
  85.                         $cart $this->cartPersister->load($event->getSalesChannelContext()->getToken(), $event->getSalesChannelContext());
  86.                         $lineItem $cart->getLineItems()->get($request->query->get('podLineItemId'));
  87.                     }
  88.                     $form null;
  89.                     $extensionFormData $page->getProduct()->getExtension('formData');
  90.                     // Fallback: Variante erbt Formular vom Parent
  91.                     if ($extensionFormData === null && $page->getProduct()->getParentId()) {
  92.                         $criteria = new Criteria();
  93.                         $criteria->addFilter(new EqualsFilter('id'$page->getProduct()->getParentId()));
  94.                         $criteria->addAssociation('formData');
  95.                         $criteria->addAssociation('formData.fields');
  96.                         $result $this->productRepository->search($criteria$event->getContext())->first();
  97.                         if ($result instanceof ProductEntity && $result->getExtension('formData') instanceof CioFormEntity) {
  98.                             $extensionFormData $result->getExtension('formData');
  99.                         }
  100.                     }
  101.                     if ($extensionFormData instanceof CioFormEntity) {
  102.                         $criteria = new Criteria();
  103.                         $criteria->addFilter(new EqualsFilter('id'$extensionFormData->getId()));
  104.                         $criteria->addAssociation('fields');
  105.                         /** @var CioFormEntity $formEntity */
  106.                         $formEntity $this->cioFormRepository->search($criteria$event->getContext())->first();
  107.                 $formEntity->getFields()->forEach(function (CioFormFieldEntity $field) use ($customer) {
  108.                     if ($field->getType() === CioFormBuilder::FIELD_TYPE_BUDGET_SELECT) {
  109.                         $budgets $this->budgetLoaderService->getActiveBudgetsByCustomer($customerContext::createDefaultContext());
  110.                         $currentBudgetId $this->sessionService->getCurrentBudgetId();
  111.                         if ($budgets instanceof EntitySearchResult && $budgets->getTotal() > 0) {
  112.                             $budgets $budgets->getElements();
  113.                             if (is_string($currentBudgetId) && Uuid::isValid($currentBudgetId)) {
  114.                                 // sort so that current budget is first
  115.                                 usort($budgets, function (BudgetEntity $aBudgetEntity $b) use ($currentBudgetId) {
  116.                                     if ($a->getId() === $currentBudgetId) {
  117.                                         return -1;
  118.                                     }
  119.                                     if ($b->getId() === $currentBudgetId) {
  120.                                         return 1;
  121.                                     }
  122.                                     return 0;
  123.                                 });
  124.                             }
  125.                             $selectionValues array_map(function (BudgetEntity $budget) {
  126.                                 return $budget->getName() . ' (' $budget->getStore()->getExtid() . ')';
  127.                             }, $budgets);
  128.                             $field->setSelectionValues(implode(';'$selectionValues));
  129.                         } else {
  130.                             $field->setSelectionValues('');
  131.                         }
  132.                     }
  133.                 });
  134.                         $form = new CioFormBuilder($formEntity);
  135.                         $form->formName 'productDetailPageBuyProductForm';
  136.                         if ($lineItem instanceof LineItem && is_array($lineItem->getPayload())) {
  137.                             $payload $lineItem->getPayload();
  138.                             if (array_key_exists('customFields'$payload) && is_array($payload['customFields'])) {
  139.                                 $cf $payload['customFields'];
  140.                                 // Dynamische Zeilenanzahl beim Bearbeiten übernehmen
  141.                                 if (!empty($cf['cioDynamicRowMode'])) {
  142.                                     if (isset($cf['cioDynamicRowCount'])) {
  143.                                         $form->setOverrideRowCount((int) $cf['cioDynamicRowCount']);
  144.                                     } elseif (isset($cf['cioTableFormData']) && is_array($cf['cioTableFormData'])) {
  145.                                         $form->setOverrideRowCount((int) count($cf['cioTableFormData']));
  146.                                     }
  147.                                 } elseif (isset($cf['cioTableFormData']) && is_array($cf['cioTableFormData'])) {
  148.                                     // Legacy-Fall: Anzahl aus vorhandenen Tabellenzeilen ableiten
  149.                                     $form->setOverrideRowCount((int) count($cf['cioTableFormData']));
  150.                                 }
  151.                                 self::mapPayloadDataToFieldDefaults($form$payload['customFields']);
  152.                             }
  153.                         }
  154.                     }
  155.                     $event->setParameter('cioFormOnProductDetailPage'$form);
  156.                 }
  157.             }
  158.         }
  159.     }
  160.     /**
  161.      * CIO-AI-Driven: Replace flow for POD line items.
  162.      *
  163.      * When editing a POD line item on PDP, the form submits to /checkout/line-item/add again.
  164.      * Because POD items are intentionally non-stackable, Shopware would try to merge quantities
  165.      * and throw "Line item not stackable". To avoid this, we remove the existing line item from
  166.      * the cart before it gets added again.
  167.      */
  168.     public function onBeforeLineItemAddedEvent(BeforeLineItemAddedEvent $event): void
  169.     {
  170.         $request $this->requestStack->getCurrentRequest();
  171.         if ($request === null) {
  172.             return;
  173.         }
  174.         $podLineItemId $request->request->get('podLineItemId');
  175.         if (!\is_string($podLineItemId) || $podLineItemId === '') {
  176.             return;
  177.         }
  178.         // Only for "edit same line item id" case.
  179.         if ($event->getLineItem()->getId() !== $podLineItemId) {
  180.             return;
  181.         }
  182.         $existing $event->getCart()->getLineItems()->get($podLineItemId);
  183.         if (!$existing instanceof LineItem) {
  184.             return;
  185.         }
  186.         // Only act for POD line items (robust check).
  187.         $payload $existing->getPayload();
  188.         if (\is_array($payload) && \array_key_exists('customFields'$payload)) {
  189.             $customFields $payload['customFields'];
  190.         } else {
  191.             $customFields null;
  192.         }
  193.         if (!\is_array($customFields) || (empty($customFields[CioPodProducts::POD_PRODUCTS_CUSTOM_FIELD_IS_POD]) && !\array_key_exists('cioFormData'$customFields) && !\array_key_exists('cioTableFormData'$customFields))) {
  194.             return;
  195.         }
  196.         try {
  197.             $event->getCart()->remove($podLineItemId);
  198.         } catch (\Throwable $e) {
  199.             // If removal is not possible for any reason, do not break the cart flow.
  200.         }
  201.     }
  202.     public function onAfterLineItemAddedEvent(AfterLineItemAddedEvent $event)
  203.     {
  204.         $request $this->requestStack->getCurrentRequest();
  205.         if (count($event->getLineItems()) === 1) {
  206.             if ($request->request->has('cioProductFormId')) {
  207.                 $criteria = new Criteria();
  208.                 $criteria->addFilter(new EqualsFilter('id'$request->request->get('cioProductFormId')));
  209.                 $criteria->addAssociation('fields');
  210.                 $formEntity $this->cioFormRepository->search($criteria$event->getContext())->first();
  211.                 if ($formEntity instanceof CioFormEntity) {
  212.                     $form = new CioFormBuilder($formEntity);
  213.                     $linkQtyToRows $formEntity->getLinkQuantityToRowCount();
  214.                     $formData = [];
  215.                     $tableFormData = [];
  216.                     if ($form->isValid($request)) {
  217.                         /** @var AbstractField $field */
  218.                         foreach ($form->getFormFields() as $field) {
  219.                             if ($field instanceof FileField) {
  220.                                 if (
  221.                                     $request->files->has($field->getEntity()->getId()) &&
  222.                                     !is_null($request->files->get($field->getEntity()->getId()))
  223.                                 ) {
  224.                                     $uploadedFile $request->files->get($field->getEntity()->getId());
  225.                                     if ($uploadedFile instanceof UploadedFile) {
  226.                                         $blob file_get_contents($uploadedFile->getPathname());
  227.                                         $extension $uploadedFile->getClientOriginalExtension();
  228.                                         $contentType $uploadedFile->getClientMimeType();
  229.                                         $filename $uploadedFile->getClientOriginalName();
  230.                                         try {
  231.                                             $mediaId $this->mediaService->saveFile(
  232.                                                 $blob,
  233.                                                 $extension,
  234.                                                 $contentType,
  235.                                                 // remove file extension from filename
  236.                                                 $this->cleanFilename($filename) . '_' Uuid::randomHex(),
  237.                                                 $event->getContext(),
  238.                                                 CioPodProducts::MEDIA_UPLOAD_FOLDER_ENTITY,
  239.                                                 null,
  240.                                                 false
  241.                                             );
  242.                                         } catch (FileExtensionNotSupportedException $exception) {
  243.                                             // Specific error for invalid file type in POD uploads.
  244.                                             $cart $event->getCart();
  245.                                             $lineItem $event->getLineItems()[0] ?? null;
  246.                                             if ($lineItem instanceof LineItem) {
  247.                                                 try {
  248.                                                     $cart->remove($lineItem->getId());
  249.                                                 } catch (\Throwable $removeException) {
  250.                                                     // Ignore removal errors to avoid breaking cart flow.
  251.                                                 }
  252.                                             }
  253.                                             $request $this->requestStack->getCurrentRequest();
  254.                                             $session $request $request->getSession() : null;
  255.                                             $flashBag $session $session->getFlashBag() : null;
  256.                                             if ($flashBag !== null) {
  257.                                                 // remove add-to-cart success message
  258.                                                 $flashBag->get('success');
  259.                                                 $flashBag->add('danger'$this->translator->trans('error.pod-invalid-file-type-error'));
  260.                                             }
  261.                                             $this->cartPersister->save($cart$event->getSalesChannelContext());
  262.                                             return;
  263.                                         }
  264.                                         $formData[] = [
  265.                                             'id' => $field->getEntity()->getId(),
  266.                                             'technicalName' => $field->getEntity()->getTechnicalName(),
  267.                                             'label' => $field->getEntity()->getLabel(),
  268.                                             'type' => $field->getEntity()->getType(),
  269.                                             'value' => $mediaId
  270.                                         ];
  271.                                     }
  272.                                 } else {
  273.                                     if ($request->request->has($field->getEntity()->getId() . '_media_id')) {
  274.                                         $formData[] = [
  275.                                             'id' => $field->getEntity()->getId(),
  276.                                             'technicalName' => $field->getEntity()->getTechnicalName(),
  277.                                             'label' => $field->getEntity()->getLabel(),
  278.                                             'type' => $field->getEntity()->getType(),
  279.                                             'value' => $request->request->get($field->getEntity()->getId() . '_media_id')
  280.                                         ];
  281.                                     }
  282.                                 }
  283.                             } else {
  284.                                 $formData[] = [
  285.                                     'id' => $field->getEntity()->getId(),
  286.                                     'technicalName' => $field->getEntity()->getTechnicalName(),
  287.                                     'label' => $field->getEntity()->getLabel(),
  288.                                     'type' => $field->getEntity()->getType(),
  289.                                     'value' => $field->getData($request)
  290.                                 ];
  291.                             }
  292.                         }
  293.                         // Build table form data
  294.                         if ($form->hasDynamicRowMode()) {
  295.                             $rowCount $this->computeDynamicRowCount($form$request);
  296.                             $tableFormData $this->buildTableFormDataDynamic($formEntity$request$rowCount);
  297.                         } else {
  298.                             if (is_array($form->getTableRepresentationFormFields()) && count($form->getTableRepresentationFormFields()) > && is_array($form->getTableRepresentationFormFields()[0])) {
  299.                                 /** @var array $row */
  300.                                 foreach ($form->getTableRepresentationFormFields() as $row) {
  301.                                     $rowData = [];
  302.                                     /** @var AbstractField $field */
  303.                                     foreach ($row as $field) {
  304.                                         if ($field instanceof TextField || $field instanceof SelectField) {
  305.                                             $rowData[] = [
  306.                                                 'id' => $field->getEntity()->getId(),
  307.                                                 'technicalName' => $field->getEntity()->getTechnicalName(),
  308.                                                 'label' => $field->getEntity()->getLabel(),
  309.                                                 'type' => $field->getEntity()->getType(),
  310.                                                 'value' => $field->getData($request)
  311.                                             ];
  312.                                         }
  313.                                     }
  314.                                     $tableFormData[] = $rowData;
  315.                                 }
  316.                             }
  317.                         }
  318.                         /** @var LineItem $newLineItem */
  319.                         $newLineItem $event->getLineItems()[0];
  320.                         // POD-Form-LIs dürfen nicht mit anderen zusammengeführt werden
  321.                         $newLineItem->setStackable(false);
  322.                         // ReferencedId bleibt Produkt-ID, damit Shopware das Produkt weiterhin korrekt referenziert
  323.                         // $newLineItem->setReferencedId(Uuid::randomHex());
  324.                         $payload $newLineItem->getPayload();
  325.                         if (!isset($payload['customFields']) || !is_array($payload['customFields'])) {
  326.                             $payload['customFields'] = [];
  327.                         }
  328.                         $payload['customFields']['cioFormData'] = $formData;
  329.                         $payload['customFields']['cioTableFormData'] = $tableFormData;
  330.                         $payload['customFields']['cioTableFormPrefix'] = $form->getEntity()->getTableRowsPrefix();
  331.                         $payload['customFields']['cioFormSurcharges'] = $this->buildSurchargeMeta($formEntity);
  332.                         // Mark dynamic mode and count if applicable
  333.                         $cartLineItem $event->getCart()->getLineItems()->get($newLineItem->getId());
  334.                         if ($form->hasDynamicRowMode()) {
  335.                             if (!isset($rowCount)) {
  336.                                 $rowCount $this->computeDynamicRowCount($form$request);
  337.                             }
  338.                             $payload['customFields']['cioDynamicRowMode'] = true;
  339.                             $payload['customFields']['cioDynamicRowCount'] = $rowCount;
  340.                             $payload['customFields']['cioLinkQuantityToRowCount'] = $linkQtyToRows;
  341.                         }
  342.                         // Always write payload first so user input is not lost even if quantity change fails
  343.                         $cartLineItem->setPayload($payload);
  344.                         // Optional: Menge an Zeilenanzahl koppeln (nur Dynamic-Mode und nur wenn konfiguriert)
  345.                         if ($form->hasDynamicRowMode() && $linkQtyToRows) {
  346.                             try {
  347.                                 $isStackable method_exists($cartLineItem'isStackable')
  348.                                     ? $cartLineItem->isStackable()
  349.                                     : (method_exists($cartLineItem'getStackable') ? $cartLineItem->getStackable() : true);
  350.                                 if ($isStackable) {
  351.                                     $cartLineItem->setQuantity(max(1, (int) $rowCount));
  352.                                 }
  353.                             } catch (\Throwable $e) {
  354.                                 // non-stackable or guarded – keep payload and continue without changing quantity
  355.                             }
  356.                         }
  357.                         // Preisaufschläge basierend auf Feldkonfiguration anwenden (minimal-invasiv)
  358.                         $this->applyFieldPriceSurcharges($cartLineItem$formEntity$formData);
  359.                         // Debug: Zustand nach Aufschlag
  360.                         $this->logger->info('[CIO-AI-Driven] afterAdd.applySurcharges', [
  361.                             'lineItemId' => $cartLineItem->getId(),
  362.                             'referencedId' => $cartLineItem->getReferencedId(),
  363.                             'stackable' => method_exists($cartLineItem'isStackable') ? $cartLineItem->isStackable() : null,
  364.                             'priceDefinitionClass' => $cartLineItem->getPriceDefinition() ? get_class($cartLineItem->getPriceDefinition()) : null,
  365.                             'priceDefinition' => $cartLineItem->getPriceDefinition() instanceof QuantityPriceDefinition ? [
  366.                                 'price' => $cartLineItem->getPriceDefinition()->getPrice(),
  367.                                 'qty' => $cartLineItem->getPriceDefinition()->getQuantity(),
  368.                                 'taxRules' => $cartLineItem->getPriceDefinition()->getTaxRules(),
  369.                                 'listPrice' => $cartLineItem->getPriceDefinition()->getListPrice(),
  370.                                 'regulationPrice' => $cartLineItem->getPriceDefinition()->getRegulationPrice(),
  371.                                 'reference' => $cartLineItem->getPriceDefinition()->getReferencePriceDefinition(),
  372.                             ] : null,
  373.                             'calculatedPrice' => $cartLineItem->getPrice() ? [
  374.                                 'unitPrice' => $cartLineItem->getPrice()->getUnitPrice(),
  375.                                 'totalPrice' => $cartLineItem->getPrice()->getTotalPrice(),
  376.                                 'taxes' => $cartLineItem->getPrice()->getCalculatedTaxes(),
  377.                                 'rules' => $cartLineItem->getPrice()->getTaxRules(),
  378.                                 'quantity' => $cartLineItem->getPrice()->getQuantity(),
  379.                             ] : null,
  380.                         ]);
  381.                         $this->cartPersister->save($event->getCart(), $event->getSalesChannelContext());
  382.                         // Debug: Zustand nach Cart-Persist
  383.                         $savedItem $event->getCart()->getLineItems()->get($cartLineItem->getId());
  384.                         if ($savedItem) {
  385.                             $this->logger->info('[CIO-AI-Driven] afterAdd.cartPersisted', [
  386.                                 'lineItemId' => $savedItem->getId(),
  387.                                 'priceDefinitionClass' => $savedItem->getPriceDefinition() ? get_class($savedItem->getPriceDefinition()) : null,
  388.                                 'priceDefinition' => $savedItem->getPriceDefinition() instanceof QuantityPriceDefinition ? [
  389.                                     'price' => $savedItem->getPriceDefinition()->getPrice(),
  390.                                     'qty' => $savedItem->getPriceDefinition()->getQuantity(),
  391.                                     'taxRules' => $savedItem->getPriceDefinition()->getTaxRules(),
  392.                                     'listPrice' => $savedItem->getPriceDefinition()->getListPrice(),
  393.                                     'regulationPrice' => $savedItem->getPriceDefinition()->getRegulationPrice(),
  394.                                     'reference' => $savedItem->getPriceDefinition()->getReferencePriceDefinition(),
  395.                                 ] : null,
  396.                                 'calculatedPrice' => $savedItem->getPrice() ? [
  397.                                     'unitPrice' => $savedItem->getPrice()->getUnitPrice(),
  398.                                     'totalPrice' => $savedItem->getPrice()->getTotalPrice(),
  399.                                     'taxes' => $savedItem->getPrice()->getCalculatedTaxes(),
  400.                                     'rules' => $savedItem->getPrice()->getTaxRules(),
  401.                                     'quantity' => $savedItem->getPrice()->getQuantity(),
  402.                                 ] : null,
  403.                             ]);
  404.                         }
  405.                         // TODO: check if save line item to database is needed?
  406.                     }
  407.                 }
  408.             }
  409.         }
  410.     }
  411.     protected function cleanFilename(string $filename): string
  412.     {
  413.         $filenameWithoutExtension substr($filename0strrpos($filename'.'));
  414.         $cleanFilename preg_replace('/[^A-Za-z0-9\-_]/''-'$filenameWithoutExtension);
  415.         $cleanFilename strtolower(trim($cleanFilename'-'));
  416.         return $cleanFilename;
  417.     }
  418.     protected static function mapPayloadDataToFieldDefaults(CioFormBuilder $formBuilder, array $payloadCustomFields): void
  419.     {
  420.         $simpleData = [];
  421.         if (array_key_exists('cioFormData'$payloadCustomFields) && is_array($payloadCustomFields['cioFormData'])) {
  422.             foreach ($payloadCustomFields['cioFormData'] as $fieldData) {
  423.                 if (array_key_exists('id'$fieldData)) {
  424.                     $simpleData[$fieldData['id']] = $fieldData['value'];
  425.                 }
  426.             }
  427.         }
  428.         $tableData = [];
  429.         if (array_key_exists('cioTableFormData'$payloadCustomFields) && is_array($payloadCustomFields['cioTableFormData'])) {
  430.             foreach ($payloadCustomFields['cioTableFormData'] as $row) {
  431.                 if (is_array($row)) {
  432.                     foreach ($row as $fieldData) {
  433.                         if (array_key_exists('id'$fieldData)) {
  434.                             $tableData[$fieldData['id']][] = $fieldData['value'];
  435.                         }
  436.                     }
  437.                 }
  438.             }
  439.         }
  440.         /** @var AbstractField $fieldInstance */
  441.         foreach ($formBuilder->getFormFields() as $fieldInstance) {
  442.             if (array_key_exists($fieldInstance->getEntity()->getId(), $simpleData)) {
  443.                 $fieldInstance->getEntity()->setDefaultValue($simpleData[$fieldInstance->getEntity()->getId()]);
  444.             }
  445.         }
  446.         foreach ($formBuilder->getTableRepresentationFormFields()[0] as $field) {
  447.             if (array_key_exists($field->getEntity()->getId(), $tableData)) {
  448.                 $field->getEntity()->setDefaultValue(json_encode($tableData[$field->getEntity()->getId()]));
  449.             }
  450.         }
  451.     }
  452.     /**
  453.      * @param OffcanvasCartPageLoadedEvent|CheckoutCartPageLoadedEvent $event
  454.      */
  455.     public function clearSuccessFlashOnInvalidUpload($event): void
  456.     {
  457.         if (!$event instanceof OffcanvasCartPageLoadedEvent && !$event instanceof CheckoutCartPageLoadedEvent) {
  458.             return;
  459.         }
  460.         $request $this->requestStack->getCurrentRequest();
  461.         if ($request === null) {
  462.             return;
  463.         }
  464.         $session $request->getSession();
  465.         if ($session === null) {
  466.             return;
  467.         }
  468.         $flashBag $session->getFlashBag();
  469.         if ($flashBag === null) {
  470.             return;
  471.         }
  472.         $errors $flashBag->peek('danger');
  473.         if ($errors === []) {
  474.             return;
  475.         }
  476.         $hasInvalidFileError false;
  477.         foreach ($errors as $error) {
  478.             if ($error === $this->translator->trans('error.pod-invalid-file-type-error')) {
  479.                 $hasInvalidFileError true;
  480.                 break;
  481.             }
  482.         }
  483.         if ($hasInvalidFileError) {
  484.             $flashBag->get('success');
  485.         }
  486.     }
  487.         /**
  488.          * Compute the dynamic row count from request and clamp to [min,max].
  489.          */
  490.         protected function computeDynamicRowCount(CioFormBuilder $form\Symfony\Component\HttpFoundation\Request $request): int
  491.         {
  492.             $min $form->getEntity()->getMinTableRowsNumber() ?? 0;
  493.             $max $form->getEntity()->getMaxTableRowsNumber() ?? 0;
  494.             // Prefer hidden field if present
  495.             $count 0;
  496.             if ($request->request->has('cioDynamicRowCount')) {
  497.                 $val = (int) $request->request->get('cioDynamicRowCount');
  498.                 if ($val 0) {
  499.                     $count $val;
  500.                 }
  501.             }
  502.             // Fallback: derive from posted table field names by finding max index+1
  503.             if ($count === 0) {
  504.                 $maxIndex = -1;
  505.                 // Build a list of field IDs that are shown as table fields (text/select)
  506.                 $tableFieldIds = [];
  507.                 foreach ($form->getEntity()->getFields() as $fieldEntity) {
  508.                     if ($fieldEntity instanceof \CioFormBuilder\Definition\CioFormField\CioFormFieldEntity) {
  509.                         if ($fieldEntity->getShowAsTable() === true) {
  510.                             $type $fieldEntity->getType();
  511.                             if (in_array($type, [CioFormBuilder::FIELD_TYPE_TEXTCioFormBuilder::FIELD_TYPE_SELECT], true)) {
  512.                                 $tableFieldIds[] = $fieldEntity->getId();
  513.                             }
  514.                         }
  515.                     }
  516.                 }
  517.                 foreach ($request->request->keys() as $name) {
  518.                     foreach ($tableFieldIds as $id) {
  519.                         // match id_N at the end
  520.                         if (preg_match('/^' preg_quote($id'/') . '_(\d+)$/'$name$m)) {
  521.                             $idx = (int) $m[1];
  522.                             if ($idx $maxIndex) {
  523.                                 $maxIndex $idx;
  524.                             }
  525.                         }
  526.                     }
  527.                 }
  528.                 if ($maxIndex >= 0) {
  529.                     $count $maxIndex 1;
  530.                 }
  531.             }
  532.             if ($min && $max >= $min) {
  533.                 if ($count === 0) {
  534.                     $count $min;
  535.                 }
  536.                 if ($count $min) {
  537.                     $count $min;
  538.                 }
  539.                 if ($count $max) {
  540.                     $count $max;
  541.                 }
  542.             }
  543.             return max(1, (int) $count);
  544.         }
  545.         /**
  546.          * Build table form data for dynamic mode up to given row count.
  547.          * Cuts off rows beyond the clamped max.
  548.          *
  549.          * @return array<array<array{id:string,technicalName:string,label:string,type:string,value:mixed}>>
  550.          */
  551.         protected function buildTableFormDataDynamic(CioFormEntity $formEntity\Symfony\Component\HttpFoundation\Request $requestint $rowCount): array
  552.         {
  553.             $result = [];
  554.             // Collect table field entities (only text/select supported for table)
  555.             $tableFields = [];
  556.             foreach ($formEntity->getFields() as $fieldEntity) {
  557.                 if ($fieldEntity instanceof \CioFormBuilder\Definition\CioFormField\CioFormFieldEntity) {
  558.                     if ($fieldEntity->getShowAsTable() === true) {
  559.                         $type $fieldEntity->getType();
  560.                         if (in_array($type, [CioFormBuilder::FIELD_TYPE_TEXTCioFormBuilder::FIELD_TYPE_SELECT], true)) {
  561.                             $tableFields[] = $fieldEntity;
  562.                         }
  563.                     }
  564.                 }
  565.             }
  566.             for ($i 0$i $rowCount$i++) {
  567.                 $rowData = [];
  568.                 foreach ($tableFields as $fieldEntity) {
  569.                     $name $fieldEntity->getId() . '_' $i;
  570.                     $value '';
  571.                     if ($request->request->has($name)) {
  572.                         $val $request->request->get($name);
  573.                         if (is_string($val)) {
  574.                             $value $val;
  575.                         }
  576.                     }
  577.                     $rowData[] = [
  578.                         'id' => $fieldEntity->getId(),
  579.                         'technicalName' => $fieldEntity->getTechnicalName(),
  580.                         'label' => $fieldEntity->getLabel(),
  581.                         'type' => $fieldEntity->getType(),
  582.                         'value' => $value
  583.                     ];
  584.                 }
  585.                 $result[] = $rowData;
  586.             }
  587.             return $result;
  588.         }
  589.         /**
  590.          * Enforce readonly quantity for dynamic-mode line items by resetting quantity to stored row count.
  591.          * Berücksichtigt dabei, ob die Kopplung Menge <=> Zeilenanzahl für das Formular aktiviert ist.
  592.          */
  593.         public function onAfterLineItemQuantityChangedEvent(AfterLineItemQuantityChangedEvent $event): void
  594.         {
  595.             $cart $event->getCart();
  596.             foreach ($cart->getLineItems() as $lineItem) {
  597.                 if (!$lineItem instanceof LineItem) {
  598.                     continue;
  599.                 }
  600.                 $payload $lineItem->getPayload();
  601.                 if (!is_array($payload) || !isset($payload['customFields']) || !is_array($payload['customFields'])) {
  602.                     continue;
  603.                 }
  604.                 $cf $payload['customFields'];
  605.                 // Menge ggf. auf RowCount zurücksetzen (Dynamic-Mode)
  606.                 if (!empty($cf['cioDynamicRowMode']) && isset($cf['cioDynamicRowCount'])) {
  607.                     if (!isset($cf['cioLinkQuantityToRowCount']) || $cf['cioLinkQuantityToRowCount'] !== false) {
  608.                         $rowCount = (int) $cf['cioDynamicRowCount'];
  609.                         try {
  610.                             $isStackable method_exists($lineItem'isStackable')
  611.                                 ? $lineItem->isStackable()
  612.                                 : (method_exists($lineItem'getStackable') ? $lineItem->getStackable() : true);
  613.                             if ($isStackable) {
  614.                                 $lineItem->setQuantity(max(1$rowCount));
  615.                             }
  616.                         } catch (\Throwable $e) {
  617.                             // ignore for non-stackable or guarded carts
  618.                         }
  619.                     }
  620.                 }
  621.                 // Preis neu berechnen, falls Surcharges im Payload hinterlegt sind
  622.                 if (isset($cf['cioFormSurcharges']) && is_array($cf['cioFormSurcharges'])) {
  623.                     $this->reapplyStoredSurcharges($lineItem);
  624.                 }
  625.             }
  626.             $this->cartPersister->save($cart$event->getSalesChannelContext());
  627.         }
  628.         /**
  629.          * Wendet konfigurierte Preisaufschläge aus den Feld-Definitionen auf den LineItem-Preis an.
  630.          *
  631.          * @param LineItem    $lineItem
  632.          * @param CioFormEntity $formEntity
  633.          * @param array<array{id:string,technicalName:string,label:string,type:string,value:mixed}> $formData
  634.          */
  635.         protected function applyFieldPriceSurcharges(LineItem $lineItemCioFormEntity $formEntity, array $formData): void
  636.         {
  637.             if (empty($formData)) {
  638.                 return;
  639.             }
  640.             $this->applySurcharges(
  641.                 $lineItem,
  642.                 $formData,
  643.                 $this->buildSurchargeMeta($formEntity)
  644.             );
  645.         }
  646.         /**
  647.          * Wird bei gespeicherten CustomFields erneut angewendet (z. B. nach Mengenänderung).
  648.          */
  649.         private function reapplyStoredSurcharges(LineItem $lineItem): void
  650.         {
  651.             $payload $lineItem->getPayload();
  652.             $cf $payload['customFields'] ?? [];
  653.             $formDataSimple $cf['cioFormData'] ?? [];
  654.             $tableFormData $cf['cioTableFormData'] ?? [];
  655.             $surcharges $cf['cioFormSurcharges'] ?? [];
  656.             // Table-Felder zum Flat-Array hinzufügen (für Value-Check)
  657.             if (\is_array($tableFormData)) {
  658.                 foreach ($tableFormData as $row) {
  659.                     if (\is_array($row)) {
  660.                         foreach ($row as $field) {
  661.                             $formDataSimple[] = $field;
  662.                         }
  663.                     }
  664.                 }
  665.             }
  666.             $this->applySurcharges($lineItem$formDataSimple$surcharges);
  667.         }
  668.         /**
  669.          * @param array<int, array{id:string,technicalName:string,label:string,type:string,value:mixed}> $formData
  670.          * @param array<int, array{id:string,mode:string,amount:float}> $surcharges
  671.          */
  672.         private function applySurcharges(LineItem $lineItem, array $formData, array $surcharges): void
  673.         {
  674.             if (empty($formData) || empty($surcharges)) {
  675.                 return;
  676.             }
  677.             $price $lineItem->getPrice();
  678.             if ($price === null) {
  679.                 return;
  680.             }
  681.             $surchargeMap = [];
  682.             foreach ($surcharges as $s) {
  683.                 if (!empty($s['id'])) {
  684.                     $surchargeMap[$s['id']] = $s;
  685.                 }
  686.             }
  687.             $positionSurcharge 0.0;
  688.             $perUnitSurcharge 0.0;
  689.             $matchedFields = [];
  690.             foreach ($formData as $fieldData) {
  691.                 $fieldId $fieldData['id'] ?? null;
  692.                 if (!$fieldId || !isset($surchargeMap[$fieldId])) {
  693.                     continue;
  694.                 }
  695.                 $config $surchargeMap[$fieldId];
  696.                 $amount = (float) ($config['amount'] ?? 0.0);
  697.                 if ($amount <= 0) {
  698.                     continue;
  699.                 }
  700.                 $mode $config['mode'] ?? 'position';
  701.                 $value $fieldData['value'] ?? null;
  702.                 $type  $fieldData['type'] ?? '';
  703.                 $hasValue false;
  704.                 if (\in_array($type, ['text''multiline_text''date'], true)) {
  705.                     $hasValue \is_string($value) && \trim($value) !== '';
  706.                 } elseif ($type === 'select') {
  707.                     $hasValue \is_string($value) && $value !== '';
  708.                 } elseif ($type === 'checkbox') {
  709.                     $hasValue = (bool) $value;
  710.                 }
  711.                 if (!$hasValue) {
  712.                     continue;
  713.                 }
  714.                 $matchedFields[] = [
  715.                     'id' => $fieldId,
  716.                     'type' => $type,
  717.                     'mode' => $mode,
  718.                     'amount' => $amount,
  719.                     'value' => $value,
  720.                 ];
  721.                 if ($mode === 'per_unit') {
  722.                     $perUnitSurcharge += $amount;
  723.                 } else {
  724.                     $positionSurcharge += $amount;
  725.                 }
  726.             }
  727.             if ($positionSurcharge === 0.0 && $perUnitSurcharge === 0.0) {
  728.                 return;
  729.             }
  730.             $currentPrice $lineItem->getPrice();
  731.             if ($currentPrice === null) {
  732.                 return;
  733.             }
  734.             $quantity max(1$lineItem->getQuantity());
  735.             $unitPrice $currentPrice->getUnitPrice();
  736.             // Verteile positionsbezogene Aufschläge gleichmäßig auf die Menge, damit das Repricing über PriceDefinition konsistent bleibt
  737.             $perUnitPosition $positionSurcharge ? ($positionSurcharge $quantity) : 0.0;
  738.             $unitPriceWithSurcharge $unitPrice $perUnitSurcharge $perUnitPosition;
  739.             $totalPrice $unitPriceWithSurcharge $quantity;
  740.             // Debug: alte Definition / Preiszustand
  741.             $priceDef $lineItem->getPriceDefinition();
  742.             $this->logger->info('[CIO-AI-Driven] applySurcharges.before', [
  743.                 'lineItemId' => $lineItem->getId(),
  744.                 'quantity' => $quantity,
  745.                 'unitPrice' => $unitPrice,
  746.                 'currentPriceTotal' => $currentPrice->getTotalPrice(),
  747.                 'priceDefClass' => $priceDef get_class($priceDef) : null,
  748.                 'priceDefData' => $priceDef instanceof QuantityPriceDefinition ? [
  749.                     'price' => $priceDef->getPrice(),
  750.                     'qty' => $priceDef->getQuantity(),
  751.                     'taxRules' => $priceDef->getTaxRules(),
  752.                     'listPrice' => $priceDef->getListPrice(),
  753.                     'regulationPrice' => $priceDef->getRegulationPrice(),
  754.                     'reference' => $priceDef->getReferencePriceDefinition(),
  755.                 ] : null,
  756.             ]);
  757.             $this->logger->info('[CIO-AI-Driven] applySurcharges.calculated', [
  758.                 'lineItemId' => $lineItem->getId(),
  759.                 'quantity' => $quantity,
  760.                 'unitPrice' => $unitPrice,
  761.                 'positionSurcharge' => $positionSurcharge,
  762.                 'perUnitPositionPart' => $perUnitPosition,
  763.                 'perUnitSurcharge' => $perUnitSurcharge,
  764.                 'unitPriceWithSurcharge' => $unitPriceWithSurcharge,
  765.                 'totalPrice' => $totalPrice,
  766.                 'matchedFields' => $matchedFields,
  767.             ]);
  768.             // PriceDefinition anpassen, damit spätere Re-Calculations die Aufschläge erhalten
  769.             if ($priceDef instanceof QuantityPriceDefinition) {
  770.                 $newDef = new QuantityPriceDefinition(
  771.                     $unitPriceWithSurcharge,
  772.                     $priceDef->getTaxRules(),
  773.                     $quantity,
  774.                     $priceDef->getReferencePriceDefinition(),
  775.                     $priceDef->getListPrice(),
  776.                     $priceDef->getRegulationPrice()
  777.                 );
  778.                 $lineItem->setPriceDefinition($newDef);
  779.             }
  780.             $newPrice = new \Shopware\Core\Checkout\Cart\Price\Struct\CalculatedPrice(
  781.                 $unitPriceWithSurcharge,
  782.                 $totalPrice,
  783.                 $currentPrice->getCalculatedTaxes(),
  784.                 $currentPrice->getTaxRules(),
  785.                 $quantity
  786.             );
  787.             $lineItem->setPrice($newPrice);
  788.             // Debug: neuer Zustand
  789.             $this->logger->info('[CIO-AI-Driven] applySurcharges.after', [
  790.                 'lineItemId' => $lineItem->getId(),
  791.                 'priceDefinitionClass' => $lineItem->getPriceDefinition() ? get_class($lineItem->getPriceDefinition()) : null,
  792.                 'priceDefinition' => $lineItem->getPriceDefinition() instanceof QuantityPriceDefinition ? [
  793.                     'price' => $lineItem->getPriceDefinition()->getPrice(),
  794.                     'qty' => $lineItem->getPriceDefinition()->getQuantity(),
  795.                     'taxRules' => $lineItem->getPriceDefinition()->getTaxRules(),
  796.                     'listPrice' => $lineItem->getPriceDefinition()->getListPrice(),
  797.                     'regulationPrice' => $lineItem->getPriceDefinition()->getRegulationPrice(),
  798.                     'reference' => $lineItem->getPriceDefinition()->getReferencePriceDefinition(),
  799.                 ] : null,
  800.                 'calculatedPrice' => [
  801.                     'unitPrice' => $lineItem->getPrice()->getUnitPrice(),
  802.                     'totalPrice' => $lineItem->getPrice()->getTotalPrice(),
  803.                     'taxes' => $lineItem->getPrice()->getCalculatedTaxes(),
  804.                     'rules' => $lineItem->getPrice()->getTaxRules(),
  805.                     'quantity' => $lineItem->getPrice()->getQuantity(),
  806.                 ],
  807.             ]);
  808.         }
  809.         /**
  810.          * @return array<int, array{id:string,mode:string,amount:float}>
  811.          */
  812.         private function buildSurchargeMeta(CioFormEntity $formEntity): array
  813.         {
  814.             $result = [];
  815.             foreach ($formEntity->getFields() as $fieldEntity) {
  816.                 if ($fieldEntity instanceof \CioFormBuilder\Definition\CioFormField\CioFormFieldEntity) {
  817.                     if ($fieldEntity->isPriceSurchargeActive() && $fieldEntity->getPriceSurchargeAmount() > 0) {
  818.                         $result[] = [
  819.                             'id' => $fieldEntity->getId(),
  820.                             'mode' => $fieldEntity->getPriceSurchargeMode() ?? 'position',
  821.                             'amount' => (float) $fieldEntity->getPriceSurchargeAmount(),
  822.                         ];
  823.                     }
  824.                 }
  825.             }
  826.             return $result;
  827.         }
  828.     }