Passed
Push — trunk ( 4a81e1...ba1209 )
by Christian
19:55 queued 07:47
created

CartLineItemController::deleteLineItems()   B

Complexity

Conditions 7
Paths 1

Size

Total Lines 29
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 7
eloc 17
nc 1
nop 3
dl 0
loc 29
rs 8.8333
c 0
b 0
f 0
1
<?php declare(strict_types=1);
2
3
namespace Shopware\Storefront\Controller;
4
5
use Shopware\Core\Checkout\Cart\Cart;
6
use Shopware\Core\Checkout\Cart\CartException;
7
use Shopware\Core\Checkout\Cart\Error\Error;
8
use Shopware\Core\Checkout\Cart\LineItem\LineItem;
9
use Shopware\Core\Checkout\Cart\LineItemFactoryHandler\ProductLineItemFactory;
10
use Shopware\Core\Checkout\Cart\LineItemFactoryRegistry;
11
use Shopware\Core\Checkout\Cart\SalesChannel\CartService;
12
use Shopware\Core\Checkout\Promotion\Cart\PromotionCartAddedInformationError;
13
use Shopware\Core\Checkout\Promotion\Cart\PromotionItemBuilder;
14
use Shopware\Core\Content\Product\Exception\ProductNotFoundException;
15
use Shopware\Core\Content\Product\SalesChannel\AbstractProductListRoute;
16
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
17
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
18
use Shopware\Core\Framework\Feature;
19
use Shopware\Core\Framework\Log\Package;
20
use Shopware\Core\Framework\Routing\RoutingException;
21
use Shopware\Core\Framework\Util\HtmlSanitizer;
22
use Shopware\Core\Framework\Validation\DataBag\RequestDataBag;
23
use Shopware\Core\Profiling\Profiler;
24
use Shopware\Core\System\SalesChannel\SalesChannelContext;
25
use Symfony\Component\HttpFoundation\Request;
26
use Symfony\Component\HttpFoundation\Response;
27
use Symfony\Component\Routing\Annotation\Route;
28
29
/**
30
 * @internal
31
 * Do not use direct or indirect repository calls in a controller. Always use a store-api route to get or put data
32
 */
33
#[Route(defaults: ['_routeScope' => ['storefront']])]
34
#[Package('storefront')]
35
class CartLineItemController extends StorefrontController
36
{
37
    /**
38
     * @internal
39
     */
40
    public function __construct(
41
        private readonly CartService $cartService,
42
        private readonly PromotionItemBuilder $promotionItemBuilder,
43
        private readonly ProductLineItemFactory $productLineItemFactory,
44
        private readonly HtmlSanitizer $htmlSanitizer,
45
        private readonly AbstractProductListRoute $productListRoute,
46
        private readonly LineItemFactoryRegistry $lineItemFactoryRegistry
47
    ) {
48
    }
49
50
    #[Route(path: '/checkout/line-item/delete/{id}', name: 'frontend.checkout.line-item.delete', defaults: ['XmlHttpRequest' => true], methods: ['POST', 'DELETE'])]
51
    public function deleteLineItem(Cart $cart, string $id, Request $request, SalesChannelContext $context): Response
52
    {
53
        return Profiler::trace('cart::delete-line-item', function () use ($cart, $id, $request, $context) {
54
            try {
55
                if (!$cart->has($id)) {
56
                    throw CartException::lineItemNotFound($id);
57
                }
58
59
                $cart = $this->cartService->remove($cart, $id, $context);
60
61
                if (!$this->traceErrors($cart)) {
62
                    $this->addFlash(self::SUCCESS, $this->trans('checkout.cartUpdateSuccess'));
63
                }
64
            } catch (\Exception) {
65
                $this->addFlash(self::DANGER, $this->trans('error.message-default'));
66
            }
67
68
            return $this->createActionResponse($request);
69
        });
70
    }
71
72
    /**
73
     * requires the provided items in the following form
74
     * 'ids' => [
75
     *     'firstLineItemId',
76
     *     'secondLineItemId',
77
     *     'thirdLineItemId',
78
     * ]
79
     */
80
    #[Route(path: '/checkout/line-item/delete', name: 'frontend.checkout.line-items.delete', defaults: ['XmlHttpRequest' => true], methods: ['POST', 'DELETE'])]
81
    public function deleteLineItems(Cart $cart, Request $request, SalesChannelContext $context): Response
82
    {
83
        return Profiler::trace('cart::delete-line-items', function () use ($cart, $request, $context) {
84
            try {
85
                $idData = $request->get('ids');
86
                if (!\is_array($idData) || $idData === []) {
87
                    throw RoutingException::missingRequestParameter('ids');
88
                }
89
90
                $ids = [];
91
                foreach ($idData as $key => $id) {
92
                    if (!\is_string($id)) {
93
                        throw RoutingException::invalidRequestParameter("ids[{$key}]");
94
                    }
95
96
                    $ids[] = $id;
97
                }
98
99
                $cart = $this->cartService->removeItems($cart, $ids, $context);
100
101
                if (!$this->traceErrors($cart)) {
102
                    $this->addFlash(self::SUCCESS, $this->trans('checkout.cartUpdateSuccess'));
103
                }
104
            } catch (\Exception) {
105
                $this->addFlash(self::DANGER, $this->trans('error.message-default'));
106
            }
107
108
            return $this->createActionResponse($request);
109
        });
110
    }
111
112
    /**
113
     * It has some individual code for the storefront layouts, like visual
114
     * error and success messages.
115
     */
116
    #[Route(path: '/checkout/promotion/add', name: 'frontend.checkout.promotion.add', defaults: ['XmlHttpRequest' => true], methods: ['POST'])]
117
    public function addPromotion(Cart $cart, Request $request, SalesChannelContext $context): Response
118
    {
119
        return Profiler::trace('cart::add-promotion', function () use ($cart, $request, $context) {
120
            try {
121
                $code = (string) $request->request->get('code');
122
123
                if ($code === '') {
124
                    throw RoutingException::missingRequestParameter('code');
125
                }
126
127
                $lineItem = $this->promotionItemBuilder->buildPlaceholderItem($code);
128
129
                $cart = $this->cartService->add($cart, $lineItem, $context);
130
131
                // we basically show all cart errors or notices
132
                // at the moments its not possible to show success messages with "green" color
133
                // from the cart...thus it has to be done in the storefront level
134
                // so if we have an promotion added notice, we simply convert this to
135
                // a success flash message
136
                $addedEvents = $cart->getErrors()->filterInstance(PromotionCartAddedInformationError::class);
137
                if ($addedEvents->count() > 0) {
138
                    $this->addFlash(self::SUCCESS, $this->trans('checkout.codeAddedSuccessful'));
139
140
                    return $this->createActionResponse($request);
141
                }
142
143
                // if we have no custom error message above
144
                // then simply continue with the default display
145
                // of the cart errors and notices
146
                $this->traceErrors($cart);
147
            } catch (\Exception) {
148
                $this->addFlash(self::DANGER, $this->trans('error.message-default'));
149
            }
150
151
            return $this->createActionResponse($request);
152
        });
153
    }
154
155
    #[Route(path: '/checkout/line-item/change-quantity/{id}', name: 'frontend.checkout.line-item.change-quantity', defaults: ['XmlHttpRequest' => true], methods: ['POST'])]
156
    public function changeQuantity(Cart $cart, string $id, Request $request, SalesChannelContext $context): Response
157
    {
158
        return Profiler::trace('cart::change-quantity', function () use ($cart, $id, $request, $context) {
159
            try {
160
                $quantity = $request->get('quantity');
161
162
                if ($quantity === null) {
163
                    throw RoutingException::missingRequestParameter('quantity');
164
                }
165
166
                if (!$cart->has($id)) {
167
                    throw CartException::lineItemNotFound($id);
168
                }
169
170
                $cart = $this->cartService->changeQuantity($cart, $id, (int) $quantity, $context);
171
172
                if (!$this->traceErrors($cart)) {
173
                    $this->addFlash(self::SUCCESS, $this->trans('checkout.cartUpdateSuccess'));
174
                }
175
            } catch (\Exception) {
176
                $this->addFlash(self::DANGER, $this->trans('error.message-default'));
177
            }
178
179
            return $this->createActionResponse($request);
180
        });
181
    }
182
183
    /**
184
     * requires the provided items in the following form
185
     * 'lineItems' => [
186
     *     'anyKey' => [
187
     *         'id' => 'someKey'
188
     *         'quantity' => 2,
189
     *     ],
190
     *     'randomKey' => [
191
     *         'id' => 'otherKey'
192
     *         'quantity' => 2,
193
     *     ]
194
     * ]
195
     */
196
    #[Route(path: '/checkout/line-item/update', name: 'frontend.checkout.line-items.update', defaults: ['XmlHttpRequest' => true], methods: ['POST', 'PATCH'])]
197
    public function updateLineItems(Cart $cart, RequestDataBag $requestDataBag, Request $request, SalesChannelContext $context): Response
198
    {
199
        return Profiler::trace('cart::update-line-items', function () use ($cart, $requestDataBag, $request, $context) {
200
            try {
201
                $lineItems = $requestDataBag->get('lineItems');
202
                if (!$lineItems instanceof RequestDataBag) {
203
                    throw RoutingException::missingRequestParameter('lineItems');
204
                }
205
206
                $items = [];
207
                foreach ($lineItems as $lineItemData) {
208
                    $items[] = $this->getLineItemArray($lineItemData, null);
209
                }
210
211
                $this->cartService->update($cart, $items, $context);
212
213
                if (!$this->traceErrors($cart)) {
214
                    $this->addFlash(self::SUCCESS, $this->trans('checkout.cartUpdateSuccess'));
215
                }
216
            } catch (\Exception) {
217
                $this->addFlash(self::DANGER, $this->trans('error.message-default'));
218
            }
219
220
            return $this->createActionResponse($request);
221
        });
222
    }
223
224
    #[Route(path: '/checkout/product/add-by-number', name: 'frontend.checkout.product.add-by-number', methods: ['POST'])]
225
    public function addProductByNumber(Request $request, SalesChannelContext $context): Response
226
    {
227
        return Profiler::trace('cart::add-product-by-number', function () use ($request, $context) {
228
            $number = (string) $request->request->get('number');
229
230
            if (!$number) {
231
                throw RoutingException::missingRequestParameter('number');
232
            }
233
234
            $criteria = new Criteria();
235
            $criteria->setLimit(1);
236
            $criteria->addFilter(new EqualsFilter('productNumber', $number));
237
238
            $data = $this->productListRoute->load($criteria, $context)->getProducts()->getIds();
239
240
            if (empty($data)) {
241
                $this->addFlash(self::DANGER, $this->trans(
242
                    'error.productNotFound',
243
                    ['%number%' => $this->htmlSanitizer->sanitize($number, null, true)]
244
                ));
245
246
                return $this->createActionResponse($request);
247
            }
248
249
            /** @var string $productId */
250
            $productId = array_shift($data);
251
252
            $product = $this->productLineItemFactory->create(['id' => $productId, 'referencedId' => $productId], $context);
253
254
            $cart = $this->cartService->getCart($context->getToken(), $context);
255
256
            $cart = $this->cartService->add($cart, $product, $context);
257
258
            if (!$this->traceErrors($cart)) {
259
                $this->addFlash(self::SUCCESS, $this->trans('checkout.addToCartSuccess', ['%count%' => 1]));
260
            }
261
262
            return $this->createActionResponse($request);
263
        });
264
    }
265
266
    /**
267
     * requires the provided items in the following form
268
     * 'lineItems' => [
269
     *     'anyKey' => [
270
     *         'id' => 'someKey'
271
     *         'quantity' => 2,
272
     *         'type' => 'someType'
273
     *     ],
274
     *     'randomKey' => [
275
     *         'id' => 'otherKey'
276
     *         'quantity' => 2,
277
     *         'type' => 'otherType'
278
     *     ]
279
     * ]
280
     */
281
    #[Route(path: '/checkout/line-item/add', name: 'frontend.checkout.line-item.add', defaults: ['XmlHttpRequest' => true], methods: ['POST'])]
282
    public function addLineItems(Cart $cart, RequestDataBag $requestDataBag, Request $request, SalesChannelContext $context): Response
283
    {
284
        return Profiler::trace('cart::add-line-item', function () use ($cart, $requestDataBag, $request, $context) {
285
            /** @var RequestDataBag|null $lineItems */
286
            $lineItems = $requestDataBag->get('lineItems');
287
            if (!$lineItems) {
288
                throw RoutingException::missingRequestParameter('lineItems');
289
            }
290
291
            $count = 0;
292
293
            try {
294
                $items = [];
295
                /** @var RequestDataBag $lineItemData */
296
                foreach ($lineItems as $lineItemData) {
297
                    try {
298
                        $item = $this->lineItemFactoryRegistry->create($this->getLineItemArray($lineItemData, [
299
                            'quantity' => 1,
300
                            'stackable' => true,
301
                            'removable' => true,
302
                        ]), $context);
303
                        $count += $item->getQuantity();
304
305
                        $items[] = $item;
306
                    } catch (CartException $e) {
307
                        if ($e->getErrorCode() === CartException::CART_INVALID_LINE_ITEM_QUANTITY_CODE) {
308
                            $this->addFlash(
309
                                self::DANGER,
310
                                $this->trans(
311
                                    'error.CHECKOUT__CART_INVALID_LINE_ITEM_QUANTITY',
312
                                    [
313
                                        '%quantity%' => $e->getParameter('quantity'),
314
                                    ]
315
                                )
316
                            );
317
318
                            return $this->createActionResponse($request);
319
                        }
320
321
                        if ($e->getErrorCode() !== CartException::CART_LINE_ITEM_TYPE_NOT_SUPPORTED_CODE) {
322
                            throw $e;
323
                        }
324
325
                        /**
326
                         * @deprecated tag:v6.6.0 - remove complete catch below and just leave the try content
327
                         */
328
                        Feature::triggerDeprecationOrThrow(
329
                            'v6.6.0.0',
330
                            'With Shopware 6.6.0.0, you will only be able to create line items only with registered LineItemFactories',
331
                        );
332
333
                        $lineItem = new LineItem(
334
                            $lineItemData->getAlnum('id'),
335
                            $lineItemData->getAlnum('type'),
336
                            $lineItemData->get('referencedId'),
337
                            $lineItemData->getInt('quantity', 1)
338
                        );
339
340
                        $lineItem->setStackable($lineItemData->getBoolean('stackable', true));
341
                        $lineItem->setRemovable($lineItemData->getBoolean('removable', true));
342
343
                        $count += $lineItem->getQuantity();
344
345
                        $items[] = $lineItem;
346
                    }
347
                }
348
349
                $cart = $this->cartService->add($cart, $items, $context);
350
351
                if (!$this->traceErrors($cart)) {
352
                    $this->addFlash(self::SUCCESS, $this->trans('checkout.addToCartSuccess', ['%count%' => $count]));
353
                }
354
            } catch (ProductNotFoundException|RoutingException) {
355
                $this->addFlash(self::DANGER, $this->trans('error.addToCartError'));
356
            }
357
358
            return $this->createActionResponse($request);
359
        });
360
    }
361
362
    private function traceErrors(Cart $cart): bool
363
    {
364
        if ($cart->getErrors()->count() <= 0) {
365
            return false;
366
        }
367
368
        $this->addCartErrors($cart, fn (Error $error) => $error->isPersistent());
369
370
        return true;
371
    }
372
373
    /**
374
     * @param array{quantity?: int, stackable?: bool, removable?: bool} $defaultValues
375
     *
376
     * @return array<string|int, mixed>
377
     */
378
    private function getLineItemArray(RequestDataBag $lineItemData, ?array $defaultValues): array
379
    {
380
        if ($lineItemData->has('payload')) {
381
            $payload = $lineItemData->get('payload');
382
383
            if (mb_strlen($payload, '8bit') > (1024 * 256)) {
384
                throw RoutingException::invalidRequestParameter('payload');
385
            }
386
387
            $lineItemData->set('payload', json_decode($payload, true, 512, \JSON_THROW_ON_ERROR));
388
        }
389
        $lineItemArray = $lineItemData->all();
390
391
        if (isset($lineItemArray['quantity'])) {
392
            $lineItemArray['quantity'] = (int) $lineItemArray['quantity'];
393
        } elseif (isset($defaultValues['quantity'])) {
394
            $lineItemArray['quantity'] = $defaultValues['quantity'];
395
        }
396
397
        if (isset($lineItemArray['stackable'])) {
398
            $lineItemArray['stackable'] = (bool) $lineItemArray['stackable'];
399
        } elseif (isset($defaultValues['stackable'])) {
400
            $lineItemArray['stackable'] = $defaultValues['stackable'];
401
        }
402
403
        if (isset($lineItemArray['removable'])) {
404
            $lineItemArray['removable'] = (bool) $lineItemArray['removable'];
405
        } elseif (isset($defaultValues['removable'])) {
406
            $lineItemArray['removable'] = $defaultValues['removable'];
407
        }
408
409
        if (isset($lineItemArray['priceDefinition']) && isset($lineItemArray['priceDefinition']['quantity'])) {
410
            $lineItemArray['priceDefinition']['quantity'] = (int) $lineItemArray['priceDefinition']['quantity'];
411
        }
412
413
        if (isset($lineItemArray['priceDefinition']) && isset($lineItemArray['priceDefinition']['isCalculated'])) {
414
            $lineItemArray['priceDefinition']['isCalculated'] = (int) $lineItemArray['priceDefinition']['isCalculated'];
415
        }
416
417
        return $lineItemArray;
418
    }
419
}
420