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

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