<?php
namespace CioFormBuilder\Subscriber;
use CioBudget\Definition\Budget\BudgetEntity;
use CioBudget\Service\BudgetLoaderService;
use CioBudget\Service\SessionService;
use CioFormBuilder\Definition\CioForm\CioFormEntity;
use CioFormBuilder\Definition\CioFormField\CioFormFieldEntity;
use CioFormBuilder\Model\CioFormBuilder;
use CioFormBuilder\Model\Field\AbstractField;
use CioFormBuilder\Model\Field\BudgetSelectField;
use CioFormBuilder\Model\Field\FileField;
use CioFormBuilder\Model\Field\SelectField;
use CioFormBuilder\Model\Field\TextField;
use CioPodProducts\CioPodProducts;
use Shopware\Core\Checkout\Cart\CartPersister;
use Shopware\Core\Checkout\Cart\Event\AfterLineItemAddedEvent;
use Shopware\Core\Checkout\Cart\Event\AfterLineItemQuantityChangedEvent;
use Shopware\Core\Checkout\Cart\Event\BeforeLineItemAddedEvent;
use Shopware\Core\Checkout\Cart\LineItem\LineItem;
use Shopware\Core\Checkout\Cart\Price\Struct\QuantityPriceDefinition;
use Shopware\Core\Checkout\Customer\CustomerEntity;
use Shopware\Core\Content\Media\File\FileSaver;
use Shopware\Core\Content\Media\MediaService;
use Shopware\Core\Content\Product\ProductEntity;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\EntitySearchResult;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
use Shopware\Core\Framework\Uuid\Uuid;
use Shopware\Storefront\Event\StorefrontRenderEvent;
use Shopware\Storefront\Page\Product\ProductPage;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\RequestStack;
class ProductDetailSubscriber implements EventSubscriberInterface
{
protected RequestStack $requestStack;
protected MediaService $mediaService;
protected CartPersister $cartPersister;
protected EntityRepository $cioFormRepository;
protected EntityRepository $productRepository;
protected \Psr\Log\LoggerInterface $logger;
protected BudgetLoaderService $budgetLoaderService;
protected SessionService $sessionService;
public function __construct(RequestStack $requestStack, MediaService $mediaService, CartPersister $cartPersister, EntityRepository $cioFormRepository, EntityRepository $productRepository, \Psr\Log\LoggerInterface $logger, BudgetLoaderService $budgetLoaderService, SessionService $sessionService)
{
$this->requestStack = $requestStack;
$this->mediaService = $mediaService;
$this->cartPersister = $cartPersister;
$this->cioFormRepository = $cioFormRepository;
$this->productRepository = $productRepository;
$this->logger = $logger;
$this->budgetLoaderService = $budgetLoaderService;
$this->sessionService = $sessionService;
}
public static function getSubscribedEvents()
{
return [
StorefrontRenderEvent::class => 'addProductDetailFormData',
BeforeLineItemAddedEvent::class => 'onBeforeLineItemAddedEvent',
AfterLineItemAddedEvent::class => 'onAfterLineItemAddedEvent',
AfterLineItemQuantityChangedEvent::class => 'onAfterLineItemQuantityChangedEvent'
];
}
public function addProductDetailFormData(StorefrontRenderEvent $event)
{
$customer = $event->getSalesChannelContext()->getCustomer();
if ($customer instanceof CustomerEntity) {
if (is_array($event->getParameters()) && key_exists('page', $event->getParameters())) {
$page = $event->getParameters()['page'];
if ($page instanceof ProductPage) {
$request = $event->getRequest();
$lineItem = null;
if ($request->query->has('podLineItemId') && $event->getSalesChannelContext()->getToken()) {
$cart = $this->cartPersister->load($event->getSalesChannelContext()->getToken(), $event->getSalesChannelContext());
$lineItem = $cart->getLineItems()->get($request->query->get('podLineItemId'));
}
$form = null;
$extensionFormData = $page->getProduct()->getExtension('formData');
// Fallback: Variante erbt Formular vom Parent
if ($extensionFormData === null && $page->getProduct()->getParentId()) {
$criteria = new Criteria();
$criteria->addFilter(new EqualsFilter('id', $page->getProduct()->getParentId()));
$criteria->addAssociation('formData');
$criteria->addAssociation('formData.fields');
$result = $this->productRepository->search($criteria, $event->getContext())->first();
if ($result instanceof ProductEntity && $result->getExtension('formData') instanceof CioFormEntity) {
$extensionFormData = $result->getExtension('formData');
}
}
if ($extensionFormData instanceof CioFormEntity) {
$criteria = new Criteria();
$criteria->addFilter(new EqualsFilter('id', $extensionFormData->getId()));
$criteria->addAssociation('fields');
/** @var CioFormEntity $formEntity */
$formEntity = $this->cioFormRepository->search($criteria, $event->getContext())->first();
$formEntity->getFields()->forEach(function (CioFormFieldEntity $field) use ($customer) {
if ($field->getType() === CioFormBuilder::FIELD_TYPE_BUDGET_SELECT) {
$budgets = $this->budgetLoaderService->getActiveBudgetsByCustomer($customer, Context::createDefaultContext());
$currentBudgetId = $this->sessionService->getCurrentBudgetId();
if ($budgets instanceof EntitySearchResult && $budgets->getTotal() > 0) {
$budgets = $budgets->getElements();
if (is_string($currentBudgetId) && UUID::isValid($currentBudgetId)) {
// sort so that current budget is first
usort($budgets, function (BudgetEntity $a, BudgetEntity $b) use ($currentBudgetId) {
if ($a->getId() === $currentBudgetId) {
return -1;
}
if ($b->getId() === $currentBudgetId) {
return 1;
}
return 0;
});
}
$selectionValues = array_map(function (BudgetEntity $budget) {
return $budget->getName() . ' (' . $budget->getStore()->getExtid() . ')';
}, $budgets);
$field->setSelectionValues(implode(';', $selectionValues));
} else {
$field->setSelectionValues('');
}
}
});
$form = new CioFormBuilder($formEntity);
$form->formName = 'productDetailPageBuyProductForm';
if ($lineItem instanceof LineItem && is_array($lineItem->getPayload())) {
$payload = $lineItem->getPayload();
if (array_key_exists('customFields', $payload) && is_array($payload['customFields'])) {
$cf = $payload['customFields'];
// Dynamische Zeilenanzahl beim Bearbeiten übernehmen
if (!empty($cf['cioDynamicRowMode'])) {
if (isset($cf['cioDynamicRowCount'])) {
$form->setOverrideRowCount((int) $cf['cioDynamicRowCount']);
} elseif (isset($cf['cioTableFormData']) && is_array($cf['cioTableFormData'])) {
$form->setOverrideRowCount((int) count($cf['cioTableFormData']));
}
} elseif (isset($cf['cioTableFormData']) && is_array($cf['cioTableFormData'])) {
// Legacy-Fall: Anzahl aus vorhandenen Tabellenzeilen ableiten
$form->setOverrideRowCount((int) count($cf['cioTableFormData']));
}
self::mapPayloadDataToFieldDefaults($form, $payload['customFields']);
}
}
}
$event->setParameter('cioFormOnProductDetailPage', $form);
}
}
}
}
/**
* CIO-AI-Driven: Replace flow for POD line items.
*
* When editing a POD line item on PDP, the form submits to /checkout/line-item/add again.
* Because POD items are intentionally non-stackable, Shopware would try to merge quantities
* and throw "Line item not stackable". To avoid this, we remove the existing line item from
* the cart before it gets added again.
*/
public function onBeforeLineItemAddedEvent(BeforeLineItemAddedEvent $event): void
{
$request = $this->requestStack->getCurrentRequest();
if ($request === null) {
return;
}
$podLineItemId = $request->request->get('podLineItemId');
if (!\is_string($podLineItemId) || $podLineItemId === '') {
return;
}
// Only for "edit same line item id" case.
if ($event->getLineItem()->getId() !== $podLineItemId) {
return;
}
$existing = $event->getCart()->getLineItems()->get($podLineItemId);
if (!$existing instanceof LineItem) {
return;
}
// Only act for POD line items (robust check).
$payload = $existing->getPayload();
$customFields = \is_array($payload) ? ($payload['customFields'] ?? null) : null;
if (!\is_array($customFields) || (empty($customFields[CioPodProducts::POD_PRODUCTS_CUSTOM_FIELD_IS_POD]) && !\array_key_exists('cioFormData', $customFields) && !\array_key_exists('cioTableFormData', $customFields))) {
return;
}
try {
$event->getCart()->remove($podLineItemId);
} catch (\Throwable $e) {
// If removal is not possible for any reason, do not break the cart flow.
}
}
public function onAfterLineItemAddedEvent(AfterLineItemAddedEvent $event)
{
$request = $this->requestStack->getCurrentRequest();
if (count($event->getLineItems()) === 1) {
if ($request->request->has('cioProductFormId')) {
$criteria = new Criteria();
$criteria->addFilter(new EqualsFilter('id', $request->request->get('cioProductFormId')));
$criteria->addAssociation('fields');
$formEntity = $this->cioFormRepository->search($criteria, $event->getContext())->first();
if ($formEntity instanceof CioFormEntity) {
$form = new CioFormBuilder($formEntity);
$linkQtyToRows = $formEntity->getLinkQuantityToRowCount();
$formData = [];
$tableFormData = [];
if ($form->isValid($request)) {
/** @var AbstractField $field */
foreach ($form->getFormFields() as $field) {
if ($field instanceof FileField) {
if (
$request->files->has($field->getEntity()->getId()) &&
!is_null($request->files->get($field->getEntity()->getId()))
) {
$uploadedFile = $request->files->get($field->getEntity()->getId());
if ($uploadedFile instanceof UploadedFile) {
$blob = file_get_contents($uploadedFile->getPathname());
$extension = $uploadedFile->getClientOriginalExtension();
$contentType = $uploadedFile->getClientMimeType();
$filename = $uploadedFile->getClientOriginalName();
$mediaId = $this->mediaService->saveFile(
$blob,
$extension,
$contentType,
// remove file extension from filename
$this->cleanFilename($filename) . '_' . Uuid::randomHex(),
$event->getContext(),
CioPodProducts::MEDIA_UPLOAD_FOLDER_ENTITY,
null,
false
);
$formData[] = [
'id' => $field->getEntity()->getId(),
'technicalName' => $field->getEntity()->getTechnicalName(),
'label' => $field->getEntity()->getLabel(),
'type' => $field->getEntity()->getType(),
'value' => $mediaId
];
}
} else {
if ($request->request->has($field->getEntity()->getId() . '_media_id')) {
$formData[] = [
'id' => $field->getEntity()->getId(),
'technicalName' => $field->getEntity()->getTechnicalName(),
'label' => $field->getEntity()->getLabel(),
'type' => $field->getEntity()->getType(),
'value' => $request->request->get($field->getEntity()->getId() . '_media_id')
];
}
}
} else {
$formData[] = [
'id' => $field->getEntity()->getId(),
'technicalName' => $field->getEntity()->getTechnicalName(),
'label' => $field->getEntity()->getLabel(),
'type' => $field->getEntity()->getType(),
'value' => $field->getData($request)
];
}
}
// Build table form data
if ($form->hasDynamicRowMode()) {
$rowCount = $this->computeDynamicRowCount($form, $request);
$tableFormData = $this->buildTableFormDataDynamic($formEntity, $request, $rowCount);
} else {
if (is_array($form->getTableRepresentationFormFields()) && count($form->getTableRepresentationFormFields()) > 0 && is_array($form->getTableRepresentationFormFields()[0])) {
/** @var array $row */
foreach ($form->getTableRepresentationFormFields() as $row) {
$rowData = [];
/** @var AbstractField $field */
foreach ($row as $field) {
if ($field instanceof TextField || $field instanceof SelectField) {
$rowData[] = [
'id' => $field->getEntity()->getId(),
'technicalName' => $field->getEntity()->getTechnicalName(),
'label' => $field->getEntity()->getLabel(),
'type' => $field->getEntity()->getType(),
'value' => $field->getData($request)
];
}
}
$tableFormData[] = $rowData;
}
}
}
/** @var LineItem $newLineItem */
$newLineItem = $event->getLineItems()[0];
// POD-Form-LIs dürfen nicht mit anderen zusammengeführt werden
$newLineItem->setStackable(false);
// ReferencedId bleibt Produkt-ID, damit Shopware das Produkt weiterhin korrekt referenziert
// $newLineItem->setReferencedId(Uuid::randomHex());
$payload = $newLineItem->getPayload();
if (!isset($payload['customFields']) || !is_array($payload['customFields'])) {
$payload['customFields'] = [];
}
$payload['customFields']['cioFormData'] = $formData;
$payload['customFields']['cioTableFormData'] = $tableFormData;
$payload['customFields']['cioTableFormPrefix'] = $form->getEntity()->getTableRowsPrefix();
$payload['customFields']['cioFormSurcharges'] = $this->buildSurchargeMeta($formEntity);
// Mark dynamic mode and count if applicable
$cartLineItem = $event->getCart()->getLineItems()->get($newLineItem->getId());
if ($form->hasDynamicRowMode()) {
$rowCount = $rowCount ?? $this->computeDynamicRowCount($form, $request);
$payload['customFields']['cioDynamicRowMode'] = true;
$payload['customFields']['cioDynamicRowCount'] = $rowCount;
$payload['customFields']['cioLinkQuantityToRowCount'] = $linkQtyToRows;
}
// Always write payload first so user input is not lost even if quantity change fails
$cartLineItem->setPayload($payload);
// Optional: Menge an Zeilenanzahl koppeln (nur Dynamic-Mode und nur wenn konfiguriert)
if ($form->hasDynamicRowMode() && $linkQtyToRows) {
try {
$isStackable = method_exists($cartLineItem, 'isStackable')
? $cartLineItem->isStackable()
: (method_exists($cartLineItem, 'getStackable') ? $cartLineItem->getStackable() : true);
if ($isStackable) {
$cartLineItem->setQuantity(max(1, (int) $rowCount));
}
} catch (\Throwable $e) {
// non-stackable or guarded – keep payload and continue without changing quantity
}
}
// Preisaufschläge basierend auf Feldkonfiguration anwenden (minimal-invasiv)
$this->applyFieldPriceSurcharges($cartLineItem, $formEntity, $formData);
// Debug: Zustand nach Aufschlag
$this->logger->info('[CIO-AI-Driven] afterAdd.applySurcharges', [
'lineItemId' => $cartLineItem->getId(),
'referencedId' => $cartLineItem->getReferencedId(),
'stackable' => method_exists($cartLineItem, 'isStackable') ? $cartLineItem->isStackable() : null,
'priceDefinitionClass' => $cartLineItem->getPriceDefinition() ? get_class($cartLineItem->getPriceDefinition()) : null,
'priceDefinition' => $cartLineItem->getPriceDefinition() instanceof QuantityPriceDefinition ? [
'price' => $cartLineItem->getPriceDefinition()->getPrice(),
'qty' => $cartLineItem->getPriceDefinition()->getQuantity(),
'taxRules' => $cartLineItem->getPriceDefinition()->getTaxRules(),
'listPrice' => $cartLineItem->getPriceDefinition()->getListPrice(),
'regulationPrice' => $cartLineItem->getPriceDefinition()->getRegulationPrice(),
'reference' => $cartLineItem->getPriceDefinition()->getReferencePriceDefinition(),
] : null,
'calculatedPrice' => $cartLineItem->getPrice() ? [
'unitPrice' => $cartLineItem->getPrice()->getUnitPrice(),
'totalPrice' => $cartLineItem->getPrice()->getTotalPrice(),
'taxes' => $cartLineItem->getPrice()->getCalculatedTaxes(),
'rules' => $cartLineItem->getPrice()->getTaxRules(),
'quantity' => $cartLineItem->getPrice()->getQuantity(),
] : null,
]);
$this->cartPersister->save($event->getCart(), $event->getSalesChannelContext());
// Debug: Zustand nach Cart-Persist
$savedItem = $event->getCart()->getLineItems()->get($cartLineItem->getId());
if ($savedItem) {
$this->logger->info('[CIO-AI-Driven] afterAdd.cartPersisted', [
'lineItemId' => $savedItem->getId(),
'priceDefinitionClass' => $savedItem->getPriceDefinition() ? get_class($savedItem->getPriceDefinition()) : null,
'priceDefinition' => $savedItem->getPriceDefinition() instanceof QuantityPriceDefinition ? [
'price' => $savedItem->getPriceDefinition()->getPrice(),
'qty' => $savedItem->getPriceDefinition()->getQuantity(),
'taxRules' => $savedItem->getPriceDefinition()->getTaxRules(),
'listPrice' => $savedItem->getPriceDefinition()->getListPrice(),
'regulationPrice' => $savedItem->getPriceDefinition()->getRegulationPrice(),
'reference' => $savedItem->getPriceDefinition()->getReferencePriceDefinition(),
] : null,
'calculatedPrice' => $savedItem->getPrice() ? [
'unitPrice' => $savedItem->getPrice()->getUnitPrice(),
'totalPrice' => $savedItem->getPrice()->getTotalPrice(),
'taxes' => $savedItem->getPrice()->getCalculatedTaxes(),
'rules' => $savedItem->getPrice()->getTaxRules(),
'quantity' => $savedItem->getPrice()->getQuantity(),
] : null,
]);
}
// TODO: check if save line item to database is needed?
}
}
}
}
}
protected function cleanFilename(string $filename): string
{
$filenameWithoutExtension = substr($filename, 0, strrpos($filename, '.'));
$cleanFilename = preg_replace('/[^A-Za-z0-9\-_]/', '-', $filenameWithoutExtension);
$cleanFilename = strtolower(trim($cleanFilename, '-'));
return $cleanFilename;
}
protected static function mapPayloadDataToFieldDefaults(CioFormBuilder $formBuilder, array $payloadCustomFields): void
{
$simpleData = [];
if (array_key_exists('cioFormData', $payloadCustomFields) && is_array($payloadCustomFields['cioFormData'])) {
foreach ($payloadCustomFields['cioFormData'] as $fieldData) {
if (array_key_exists('id', $fieldData)) {
$simpleData[$fieldData['id']] = $fieldData['value'];
}
}
}
$tableData = [];
if (array_key_exists('cioTableFormData', $payloadCustomFields) && is_array($payloadCustomFields['cioTableFormData'])) {
foreach ($payloadCustomFields['cioTableFormData'] as $row) {
if (is_array($row)) {
foreach ($row as $fieldData) {
if (array_key_exists('id', $fieldData)) {
$tableData[$fieldData['id']][] = $fieldData['value'];
}
}
}
}
}
/** @var AbstractField $fieldInstance */
foreach ($formBuilder->getFormFields() as $fieldInstance) {
if (array_key_exists($fieldInstance->getEntity()->getId(), $simpleData)) {
$fieldInstance->getEntity()->setDefaultValue($simpleData[$fieldInstance->getEntity()->getId()]);
}
}
foreach ($formBuilder->getTableRepresentationFormFields()[0] as $field) {
if (array_key_exists($field->getEntity()->getId(), $tableData)) {
$field->getEntity()->setDefaultValue(json_encode($tableData[$field->getEntity()->getId()]));
}
}
}
/**
* Compute the dynamic row count from request and clamp to [min,max].
*/
protected function computeDynamicRowCount(CioFormBuilder $form, \Symfony\Component\HttpFoundation\Request $request): int
{
$min = $form->getEntity()->getMinTableRowsNumber() ?? 0;
$max = $form->getEntity()->getMaxTableRowsNumber() ?? 0;
// Prefer hidden field if present
$count = 0;
if ($request->request->has('cioDynamicRowCount')) {
$val = (int) $request->request->get('cioDynamicRowCount');
if ($val > 0) {
$count = $val;
}
}
// Fallback: derive from posted table field names by finding max index+1
if ($count === 0) {
$maxIndex = -1;
// Build a list of field IDs that are shown as table fields (text/select)
$tableFieldIds = [];
foreach ($form->getEntity()->getFields() as $fieldEntity) {
if ($fieldEntity instanceof \CioFormBuilder\Definition\CioFormField\CioFormFieldEntity) {
if ($fieldEntity->getShowAsTable() === true) {
$type = $fieldEntity->getType();
if (in_array($type, [CioFormBuilder::FIELD_TYPE_TEXT, CioFormBuilder::FIELD_TYPE_SELECT], true)) {
$tableFieldIds[] = $fieldEntity->getId();
}
}
}
}
foreach ($request->request->keys() as $name) {
foreach ($tableFieldIds as $id) {
// match id_N at the end
if (preg_match('/^' . preg_quote($id, '/') . '_(\d+)$/', $name, $m)) {
$idx = (int) $m[1];
if ($idx > $maxIndex) {
$maxIndex = $idx;
}
}
}
}
if ($maxIndex >= 0) {
$count = $maxIndex + 1;
}
}
if ($min > 0 && $max >= $min) {
if ($count === 0) {
$count = $min;
}
if ($count < $min) {
$count = $min;
}
if ($count > $max) {
$count = $max;
}
}
return max(1, (int) $count);
}
/**
* Build table form data for dynamic mode up to given row count.
* Cuts off rows beyond the clamped max.
*
* @return array<array<array{id:string,technicalName:string,label:string,type:string,value:mixed}>>
*/
protected function buildTableFormDataDynamic(CioFormEntity $formEntity, \Symfony\Component\HttpFoundation\Request $request, int $rowCount): array
{
$result = [];
// Collect table field entities (only text/select supported for table)
$tableFields = [];
foreach ($formEntity->getFields() as $fieldEntity) {
if ($fieldEntity instanceof \CioFormBuilder\Definition\CioFormField\CioFormFieldEntity) {
if ($fieldEntity->getShowAsTable() === true) {
$type = $fieldEntity->getType();
if (in_array($type, [CioFormBuilder::FIELD_TYPE_TEXT, CioFormBuilder::FIELD_TYPE_SELECT], true)) {
$tableFields[] = $fieldEntity;
}
}
}
}
for ($i = 0; $i < $rowCount; $i++) {
$rowData = [];
foreach ($tableFields as $fieldEntity) {
$name = $fieldEntity->getId() . '_' . $i;
$value = '';
if ($request->request->has($name)) {
$val = $request->request->get($name);
if (is_string($val)) {
$value = $val;
}
}
$rowData[] = [
'id' => $fieldEntity->getId(),
'technicalName' => $fieldEntity->getTechnicalName(),
'label' => $fieldEntity->getLabel(),
'type' => $fieldEntity->getType(),
'value' => $value
];
}
$result[] = $rowData;
}
return $result;
}
/**
* Enforce readonly quantity for dynamic-mode line items by resetting quantity to stored row count.
* Berücksichtigt dabei, ob die Kopplung Menge <=> Zeilenanzahl für das Formular aktiviert ist.
*/
public function onAfterLineItemQuantityChangedEvent(AfterLineItemQuantityChangedEvent $event): void
{
$cart = $event->getCart();
foreach ($cart->getLineItems() as $lineItem) {
if (!$lineItem instanceof LineItem) {
continue;
}
$payload = $lineItem->getPayload();
if (!is_array($payload) || !isset($payload['customFields']) || !is_array($payload['customFields'])) {
continue;
}
$cf = $payload['customFields'];
// Menge ggf. auf RowCount zurücksetzen (Dynamic-Mode)
if (!empty($cf['cioDynamicRowMode']) && isset($cf['cioDynamicRowCount'])) {
if (!isset($cf['cioLinkQuantityToRowCount']) || $cf['cioLinkQuantityToRowCount'] !== false) {
$rowCount = (int) $cf['cioDynamicRowCount'];
try {
$isStackable = method_exists($lineItem, 'isStackable')
? $lineItem->isStackable()
: (method_exists($lineItem, 'getStackable') ? $lineItem->getStackable() : true);
if ($isStackable) {
$lineItem->setQuantity(max(1, $rowCount));
}
} catch (\Throwable $e) {
// ignore for non-stackable or guarded carts
}
}
}
// Preis neu berechnen, falls Surcharges im Payload hinterlegt sind
if (isset($cf['cioFormSurcharges']) && is_array($cf['cioFormSurcharges'])) {
$this->reapplyStoredSurcharges($lineItem);
}
}
$this->cartPersister->save($cart, $event->getSalesChannelContext());
}
/**
* Wendet konfigurierte Preisaufschläge aus den Feld-Definitionen auf den LineItem-Preis an.
*
* @param LineItem $lineItem
* @param CioFormEntity $formEntity
* @param array<array{id:string,technicalName:string,label:string,type:string,value:mixed}> $formData
*/
protected function applyFieldPriceSurcharges(LineItem $lineItem, CioFormEntity $formEntity, array $formData): void
{
if (empty($formData)) {
return;
}
$this->applySurcharges(
$lineItem,
$formData,
$this->buildSurchargeMeta($formEntity)
);
}
/**
* Wird bei gespeicherten CustomFields erneut angewendet (z. B. nach Mengenänderung).
*/
private function reapplyStoredSurcharges(LineItem $lineItem): void
{
$payload = $lineItem->getPayload();
$cf = $payload['customFields'] ?? [];
$formDataSimple = $cf['cioFormData'] ?? [];
$tableFormData = $cf['cioTableFormData'] ?? [];
$surcharges = $cf['cioFormSurcharges'] ?? [];
// Table-Felder zum Flat-Array hinzufügen (für Value-Check)
if (\is_array($tableFormData)) {
foreach ($tableFormData as $row) {
if (\is_array($row)) {
foreach ($row as $field) {
$formDataSimple[] = $field;
}
}
}
}
$this->applySurcharges($lineItem, $formDataSimple, $surcharges);
}
/**
* @param array<int, array{id:string,technicalName:string,label:string,type:string,value:mixed}> $formData
* @param array<int, array{id:string,mode:string,amount:float}> $surcharges
*/
private function applySurcharges(LineItem $lineItem, array $formData, array $surcharges): void
{
if (empty($formData) || empty($surcharges)) {
return;
}
$price = $lineItem->getPrice();
if ($price === null) {
return;
}
$surchargeMap = [];
foreach ($surcharges as $s) {
if (!empty($s['id'])) {
$surchargeMap[$s['id']] = $s;
}
}
$positionSurcharge = 0.0;
$perUnitSurcharge = 0.0;
$matchedFields = [];
foreach ($formData as $fieldData) {
$fieldId = $fieldData['id'] ?? null;
if (!$fieldId || !isset($surchargeMap[$fieldId])) {
continue;
}
$config = $surchargeMap[$fieldId];
$amount = (float) ($config['amount'] ?? 0.0);
if ($amount <= 0) {
continue;
}
$mode = $config['mode'] ?? 'position';
$value = $fieldData['value'] ?? null;
$type = $fieldData['type'] ?? '';
$hasValue = false;
if (\in_array($type, ['text', 'multiline_text', 'date'], true)) {
$hasValue = \is_string($value) && \trim($value) !== '';
} elseif ($type === 'select') {
$hasValue = \is_string($value) && $value !== '';
} elseif ($type === 'checkbox') {
$hasValue = (bool) $value;
}
if (!$hasValue) {
continue;
}
$matchedFields[] = [
'id' => $fieldId,
'type' => $type,
'mode' => $mode,
'amount' => $amount,
'value' => $value,
];
if ($mode === 'per_unit') {
$perUnitSurcharge += $amount;
} else {
$positionSurcharge += $amount;
}
}
if ($positionSurcharge === 0.0 && $perUnitSurcharge === 0.0) {
return;
}
$currentPrice = $lineItem->getPrice();
if ($currentPrice === null) {
return;
}
$quantity = max(1, $lineItem->getQuantity());
$unitPrice = $currentPrice->getUnitPrice();
// Verteile positionsbezogene Aufschläge gleichmäßig auf die Menge, damit das Repricing über PriceDefinition konsistent bleibt
$perUnitPosition = $positionSurcharge > 0 ? ($positionSurcharge / $quantity) : 0.0;
$unitPriceWithSurcharge = $unitPrice + $perUnitSurcharge + $perUnitPosition;
$totalPrice = $unitPriceWithSurcharge * $quantity;
// Debug: alte Definition / Preiszustand
$priceDef = $lineItem->getPriceDefinition();
$this->logger->info('[CIO-AI-Driven] applySurcharges.before', [
'lineItemId' => $lineItem->getId(),
'quantity' => $quantity,
'unitPrice' => $unitPrice,
'currentPriceTotal' => $currentPrice->getTotalPrice(),
'priceDefClass' => $priceDef ? get_class($priceDef) : null,
'priceDefData' => $priceDef instanceof QuantityPriceDefinition ? [
'price' => $priceDef->getPrice(),
'qty' => $priceDef->getQuantity(),
'taxRules' => $priceDef->getTaxRules(),
'listPrice' => $priceDef->getListPrice(),
'regulationPrice' => $priceDef->getRegulationPrice(),
'reference' => $priceDef->getReferencePriceDefinition(),
] : null,
]);
$this->logger->info('[CIO-AI-Driven] applySurcharges.calculated', [
'lineItemId' => $lineItem->getId(),
'quantity' => $quantity,
'unitPrice' => $unitPrice,
'positionSurcharge' => $positionSurcharge,
'perUnitPositionPart' => $perUnitPosition,
'perUnitSurcharge' => $perUnitSurcharge,
'unitPriceWithSurcharge' => $unitPriceWithSurcharge,
'totalPrice' => $totalPrice,
'matchedFields' => $matchedFields,
]);
// PriceDefinition anpassen, damit spätere Re-Calculations die Aufschläge erhalten
if ($priceDef instanceof QuantityPriceDefinition) {
$newDef = new QuantityPriceDefinition(
$unitPriceWithSurcharge,
$priceDef->getTaxRules(),
$quantity,
$priceDef->getReferencePriceDefinition(),
$priceDef->getListPrice(),
$priceDef->getRegulationPrice()
);
$lineItem->setPriceDefinition($newDef);
}
$newPrice = new \Shopware\Core\Checkout\Cart\Price\Struct\CalculatedPrice(
$unitPriceWithSurcharge,
$totalPrice,
$currentPrice->getCalculatedTaxes(),
$currentPrice->getTaxRules(),
$quantity
);
$lineItem->setPrice($newPrice);
// Debug: neuer Zustand
$this->logger->info('[CIO-AI-Driven] applySurcharges.after', [
'lineItemId' => $lineItem->getId(),
'priceDefinitionClass' => $lineItem->getPriceDefinition() ? get_class($lineItem->getPriceDefinition()) : null,
'priceDefinition' => $lineItem->getPriceDefinition() instanceof QuantityPriceDefinition ? [
'price' => $lineItem->getPriceDefinition()->getPrice(),
'qty' => $lineItem->getPriceDefinition()->getQuantity(),
'taxRules' => $lineItem->getPriceDefinition()->getTaxRules(),
'listPrice' => $lineItem->getPriceDefinition()->getListPrice(),
'regulationPrice' => $lineItem->getPriceDefinition()->getRegulationPrice(),
'reference' => $lineItem->getPriceDefinition()->getReferencePriceDefinition(),
] : null,
'calculatedPrice' => [
'unitPrice' => $lineItem->getPrice()->getUnitPrice(),
'totalPrice' => $lineItem->getPrice()->getTotalPrice(),
'taxes' => $lineItem->getPrice()->getCalculatedTaxes(),
'rules' => $lineItem->getPrice()->getTaxRules(),
'quantity' => $lineItem->getPrice()->getQuantity(),
],
]);
}
/**
* @return array<int, array{id:string,mode:string,amount:float}>
*/
private function buildSurchargeMeta(CioFormEntity $formEntity): array
{
$result = [];
foreach ($formEntity->getFields() as $fieldEntity) {
if ($fieldEntity instanceof \CioFormBuilder\Definition\CioFormField\CioFormFieldEntity) {
if ($fieldEntity->isPriceSurchargeActive() && $fieldEntity->getPriceSurchargeAmount() > 0) {
$result[] = [
'id' => $fieldEntity->getId(),
'mode' => $fieldEntity->getPriceSurchargeMode() ?? 'position',
'amount' => (float) $fieldEntity->getPriceSurchargeAmount(),
];
}
}
}
return $result;
}
}