EC-CUBE4 購入時オプションを追加

## 概要

商品を購入（カートに入れる）時にオプションを指定できるようにするカスタマイズ方法です。
以下で手順は、**ラベル** 項目を追加する簡単なサンプルです。

## エンティティの拡張

### エンティティ拡張ファイルの作成

`CartItem` エンティティと `OrderItem` エンティティの2つを拡張します。

```php:app/Customize/Entity/CartItemExtension.php
<?php

declare(strict_types=1);

namespace Customize\Entity;

use Doctrine\ORM\Mapping as ORM;
use Eccube\Annotation\EntityExtension;

/**
 * @EntityExtension("Eccube\Entity\CartItem")
 */
trait CartItemExtension
{
    /**
     * @var string|null
     *
     * @ORM\Column(name="label", type="text", nullable=true)
     */
    private $label;

    public function getLabel(): ?string
    {
        return $this->label;
    }

    public function setLabel(?string $label): self
    {
        $this->label = $label;

        return $this;
    }
}
```

```php:app/Customize/Entity/OrderItemExtension.php
<?php

declare(strict_types=1);

namespace Customize\Entity;

use Doctrine\ORM\Mapping as ORM;
use Eccube\Annotation\EntityExtension;

/**
 * @EntityExtension("Eccube\Entity\OrderItem")
 */
trait OrderItemExtension
{
    /**
     * @var string|null
     *
     * @ORM\Column(name="label", type="text", nullable=true)
     */
    private $label;

    public function getLabel(): ?string
    {
        return $this->label;
    }

    public function setLabel(?string $label): self
    {
        $this->label = $label;

        return $this;
    }
}
```

### エンティティ拡張をDBに反映

次のコマンドを実行して、エンティティの拡張内容をデータベースに反映します。

```bash
# プロキシークラスの生成
./bin/console eccube:generate:proxies

# キャッシュファイルの削除
./bin/console cache:clear --no-warmup

# 実行するDDLの確認
./bin/console doctrine:schema:update --dump-sql

# DDLを実行
./bin/console doctrine:schema:update --dump-sql --force
```

正常に完了すると `dtb_cart_item.label` と `dtb_order_item.label` とが追加されています。

![スクリーンショット 2021-02-09 8.54.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2019/fcc76dd3-4bd3-2181-6c79-5139fb198f83.png)

![スクリーンショット 2021-02-09 8.55.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2019/4634371d-941a-b879-b9fe-bda8a225dd03.png)


## フォームの拡張

商品ページの「カートに入れる」ボタンのフォームを拡張します。

![スクリーンショット 2021-02-09 8.53.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2019/28cd0f1c-8bed-8da2-8963-713b8b47809c.png)

### フォーム拡張ファイルの作成

```php:app/Customize/Form/Extension/Front/AddCartTypeExtension.php
<?php

declare(strict_types=1);

namespace Customize\Form\Extension\Front;

use Eccube\Form\Type\AddCartType;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;

class AddCartTypeExtension extends AbstractTypeExtension
{
    public function getExtendedType(): string
    {
        return AddCartType::class;
    }

    public function getExtendedTypes(): iterable
    {
        return [AddCartType::class];
    }

    /**
     * {@inheritdoc}
     */
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('label', TextType::class);
    }
}
```

### 追加フィールドをフォームに表示する

```diff_twig:app/template/YOUR_TEMPLATE_CODE/Product/detail.twig
<div class="ec-numberInput"><span>{{ 'common.quantity'|trans }}</span>
    {{ form_widget(form.quantity) }}
    {{ form_errors(form.quantity) }}
</div>
+ <div class="ec-numberInput"><span>ラベル</span>
+     {{ form_widget(form.label) }}
+     {{ form_errors(form.label) }}
+ </div>
```

![スクリーンショット 2021-02-09 9.01.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2019/6663d190-0371-eaf1-82c3-f167f22a7561.png)

## カートアイテムの分類分けを変更

標準は、商品規格が同一あれば数量が更新され、商品規格が異なれば別商品としてカートアイテムが識別されます。
今回は、更に先ほど拡張したラベルを比較して、識別されるように変更します。

### カートアイテムの比較クラスの作成

```php:app/Customize/Service/Cart/ProductClassAndLabelComparator.php
<?php

declare(strict_types=1);

namespace Customize\Service\Cart;

use Eccube\Entity\CartItem;
use Eccube\Service\Cart\CartItemComparator;

class ProductClassAndLabelComparator implements CartItemComparator
{
    public function compare(CartItem $item1, CartItem $item2): bool
    {
        $classA = $item1->getProductClass();
        $classB = $item2->getProductClass();

        $a = $classA === null ? null : (string)$classA->getId();
        $b = $classB === null ? null : (string)$classB->getId();

        if ($a !== $b) {
            return false;
        }

        $labelA = $item1->getLabel();
        $labelB = $item2->getLabel();

        return $labelA === $labelB;
    }
}
```

### カートアイテム比較クラスの変更

```yaml:app/config/eccube/packages/cart.yaml
services:
    Eccube\Service\Cart\CartItemComparator:
        class: Customize\Service\Cart\ProductClassAndLabelComparator
```

## カートサービスクラスの拡張

```php:app/Customize/Service/CartService.php
<?php

declare(strict_types=1);

namespace Customize\Service;

use Eccube\Entity\CartItem;
use Eccube\Entity\ProductClass;

class CartService extends \Eccube\Service\CartService
{
    /**
     * @param array{string, string} $options
     */
    public function addCartItem(ProductClass $productClass, int $quantity = 1, array $options = []): bool
    {
        $classCategory1 = $productClass->getClassCategory1();
        if ($classCategory1 && ! $classCategory1->isVisible()) {
            return false;
        }

        $classCategory2 = $productClass->getClassCategory2();
        if ($classCategory2 && ! $classCategory2->isVisible()) {
            return false;
        }

        $cartItem = new CartItem();
        $cartItem->setProductClass($productClass)
                 ->setPrice($productClass->getPrice02IncTax())
                 ->setQuantity($quantity);

        if (isset($options['label'])) {
            $cartItem->setLable($options['label']);
        }

        $cartItems = $this->mergeAllCartItems([$cartItem]);
        $this->restoreCarts($cartItems);

        return true;
    }

    public function changeCartItemQuantity(int $cartItemId, int $quantity): bool
    {
        $itemFound = false;

        $carts = $this->getCarts();
        foreach ($carts as $cart) {
            foreach ($cart->getCartItems() as $cartItem) {
                if ($cartItem->getId() !== $cartItemId) {
                    continue;
                }

                $cartItem->setQuantity($quantity);
                $itemFound = true;

                break;
            }

            if ($itemFound) {
                break;
            }
        }

        if ($itemFound === false) {
            return false;
        }

        $cartItems = $this->mergeAllCartItems();
        $this->restoreCarts($cartItems);

        return true;
    }
}
```

```diff_yaml:app/config/eccube/services.yaml
    Eccube\Session\Storage\Handler\SameSiteNoneCompatSessionHandler:
        arguments:
            - '@native_file_session_handler'
+
+    Customize\Service\CartService:
+        decorates: Eccube\Service\CartService
```

## カートに入れる処理を変更

```php
<?php

declare(strict_types=1);

namespace Customize\Controller;

use Customize\Service\CartService as CustomizedCartService;
use Eccube\Controller\AbstractController;
use Eccube\Entity\CartItem;
use Eccube\Entity\Master\ProductStatus;
use Eccube\Entity\Product;
use Eccube\Event\EccubeEvents;
use Eccube\Event\EventArgs;
use Eccube\Form\Type\AddCartType;
use Eccube\Repository\ProductClassRepository;
use Eccube\Service\CartService;
use Eccube\Service\PurchaseFlow\PurchaseContext;
use Eccube\Service\PurchaseFlow\PurchaseFlow;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Annotation\Route;

class FrontController extends AbstractController
{
    /** @var CartService */
    private $cartService;

    /** @var PurchaseFlow */
    protected $purchaseFlow;

    public function __construct(CartService $cartService, PurchaseFlow $purchaseFlow)
    {
        $this->cartService = $cartService;
        $this->purchaseFlow = $purchaseFlow;
    }

    /**
     * EC-CUBE標準の「カートに追加」を上書き
     *
     * @Route("/products/add_cart/{id}", name="product_add_cart", methods={"POST"}, requirements={"id" = "\d+"})
     */
    public function productAddCart(Request $request, Product $product): Response
    {
        if (! $this->isAvailableProduct($product)) {
            throw new NotFoundHttpException();
        }

        $builder = $this->formFactory->createNamedBuilder(
            '',
            AddCartType::class,
            null,
            [
                'product' => $product,
                'id_add_product_id' => false,
            ]
        );

        $event = new EventArgs(
            [
                'builder' => $builder,
                'Product' => $product,
            ],
            $request
        );
        $this->eventDispatcher->dispatch(EccubeEvents::FRONT_PRODUCT_CART_ADD_INITIALIZE, $event);

        $form = $builder->getForm();
        $form->handleRequest($request);

        if (! $form->isValid()) {
            throw new NotFoundHttpException();
        }

        $data = $form->getData();
        assert($data instanceof CartItem);

        log_info(
            'カート追加処理開始',
            [
                'product_id' => $product->getId(),
                'product_class_id' => $data->getProductClassId(),
                'quantity' => $data->getQuantity(),
            ]
        );

        assert($this->cartService instanceof CustomizedCartService);

        $this->cartService->addCartItem($data->getProductClass(), $data->getQuantity(), [
            'label' => $data->getLabel(),
        ]);

        $errorMessages = [];
        $carts = $this->cartService->getCarts();
        foreach ($carts as $Cart) {
            $result = $this->purchaseFlow->validate($Cart, new PurchaseContext($Cart, $this->getUser()));

            if ($result->hasError()) {
                $this->cartService->removeProduct($data->getProductClassId());
                foreach ($result->getErrors() as $error) {
                    $errorMessages[] = $error->getMessage();
                }
            }

            foreach ($result->getWarning() as $warning) {
                $errorMessages[] = $warning->getMessage();
            }
        }

        $this->cartService->save();

        log_info(
            'カート追加処理完了',
            [
                'product_id' => $product->getId(),
                'product_class_id' => $data->getProductClassId(),
                'quantity' => $data->getQuantity(),
            ]
        );

        $event = new EventArgs(
            [
                'form' => $form,
                'Product' => $product,
            ],
            $request
        );
        $this->eventDispatcher->dispatch(EccubeEvents::FRONT_PRODUCT_CART_ADD_COMPLETE, $event);

        if ($event->getResponse() !== null) {
            return $event->getResponse();
        }

        if ($request->isXmlHttpRequest()) {
            if (empty($errorMessages)) {
                return $this->json([
                    'done' => true,
                    'messages' => [trans('front.product.add_cart_complete')],
                ]);
            }

            return $this->json([
                'done' => false,
                'messages' => $errorMessages,
            ]);
        }

        foreach ($errorMessages as $errorMessage) {
            $this->addRequestError($errorMessage);
        }

        return $this->redirectToRoute('cart');
    }

    /**
     * @see Eccube\Controller\ProductController::checkVisibility()
     */
    private function isAvailableProduct(Product $product): bool
    {
        if ($this->session->has('_security_admin')) {
            return true;
        }

        $status = $product->getStatus();

        return $status !== null && $status->getId() === ProductStatus::DISPLAY_SHOW;
    }
}
```

## カート一覧で数量を変更したときの対応

[現在の実装](https://github.com/EC-CUBE/ec-cube/blob/4.1/src/Eccube/Controller/CartController.php#L198)は、product_class_id に対して数量を加減しているようです。
ということは、同じの商品（つまり product_class_id が同じ）で、ラベル違いの商品がカートに入っている場合、理想の結果にはなりませんので、カート数量変更は、カートアイテム毎に行うようにします。

```php
// FIXME
```

## 注文サービスクラスの拡張

```php:app/Customize/Service/OrderHelper.php
<?php

declare(strict_types=1);

namespace Customize\Service;

class OrderHelper extends \Eccube\Service\OrderHelper
{

}
```

```php:app/Customize/Service/OrderPdfService.php
<?php

declare(strict_types=1);

namespace Customize\Service;

class OrderPdfService extends \Eccube\Service\OrderPdfService
{

}
```

```diff_yaml:app/config/eccube/services.yaml
    Eccube\Session\Storage\Handler\SameSiteNoneCompatSessionHandler:
        arguments:
            - '@native_file_session_handler'

    Customize\Service\CartService:
        decorates: Eccube\Service\CartService

+    Customize\Service\OrderHelper:
+        decorates: Eccube\Service\OrderHelper
+
+    Customize\Service\OrderPdfService:
+        decorates: Eccube\Service\OrderPdfService
```

### 注意点

一部のプロパティが `private` になっています。
そのため、オリジナルクラスを継承したサブクラスでも、アクセスできないプロパティがあります。
その場合は、カスタマイズ領域に用意したサービスクラスで DI します。

**例：OrderPdfService**

```php
/** @var EccubeConfig */
private $eccubeConfig;

/**
    * @param EccubeConfig $eccubeConfig
    *
    * @required
    */
public function setEccubeConfig(EccubeConfig $eccubeConfig): void
{
    $this->eccubeConfig = $eccubeConfig;
}
```

※ [Autowiring other Methods (e.g. Setters)](https://symfony.com/doc/3.4/service_container/autowiring.html#autowiring-other-methods-e-g-setters)