Chapter 04

バックエンド - カート

ta.toshio
ta.toshio
2021.06.06に更新

カートに商品追加を例に

登場クラス

CartService

CartServiceから見た関係のあるクラス郡。
UML図のように線がないので関係性は少し分かりづらいです。すみません。

CartとCartItem

処理フロー

商品一覧での例

  1. 「商品をカートに追加」するフォームを商品ごとに作成
    |- src/Eccube/Controller/ProductController.php
  2. 商品をカートに追加する処理
    |- src/Eccube/Controller/ProductController.php
    |- src/Eccube/Service/CartService.php
  3. カートアイテムに対して税金の計算
    |- src/Eccube/Doctrine/EventSubscriber/TaxRuleEventSubscriber.php
  4. 税金の計算処理
    |- src/Eccube/Service/TaxRuleService.php

1. 「商品をカートに追加」フォームを表示

src/Eccube/Controller/ProductController.php

まず「商品をカートに追加する」を実現するフォームを表示。
商品一覧画面に対応するindex actionで担当している。

        // addCart form
        $forms = [];
        foreach ($pagination as $Product) {
            /* @var $builder \Symfony\Component\Form\FormBuilderInterface */
            $builder = $this->formFactory->createNamedBuilder(
                '',
                AddCartType::class,
                null,
                [
                    'product' => $ProductsAndClassCategories[$Product->getId()],
                    'allow_extra_fields' => true,
                ]
            );
            $addCartForm = $builder->getForm();

            $forms[$Product->getId()] = $addCartForm->createView();
        }

src/Eccube/Resource/template/default/Product/list.twig

index actionに対応するlist.twigが実行

{% set form = forms[Product.id] %}
    <form name="form{{ Product.id }}" id="productForm{{ Product.id }}" action="{{ url('product_add_cart', {id:Product.id}) }}" method="post">
        <div class="ec-productRole__actions">
            {% if form.classcategory_id1 is defined %}
                <div class="ec-select">
                    {{ form_widget(form.classcategory_id1) }}
                    {{ form_errors(form.classcategory_id1) }}
                </div>
                {% if form.classcategory_id2 is defined %}
                    <div class="ec-select">
                        {{ form_widget(form.classcategory_id2) }}
                        {{ form_errors(form.classcategory_id2) }}
                    </div>
                {% endif %}
            {% endif %}
            <div class="ec-numberInput"><span>{{ 'common.quantity'|trans }}</span>
                {{ form_widget(form.quantity, {'attr': {'class': 'quantity'}}) }}
                {{ form_errors(form.quantity) }}
            </div>
        </div>
        {{ form_rest(form) }}
    </form>

↑のform action属性にあるようにカートに追加したらPOSTでroutes.product_add_cartに送信される。

フォームを表現しているクラス

src/Eccube/Form/Type/AddCartType.php

    /**
     * {@inheritdoc}
     */
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
...

// モデル(ProductClass)データをセット
// htmlで表現するときはaddModelTransformer(new EntityToIdTransformer)でid値に変換している。
            ->add(
                $builder
                    ->create('ProductClass', HiddenType::class, [
                        'data_class' => null,
                        'data' => $Product->hasProductClass() ? null : $ProductClasses->first(),
                        'constraints' => [
                            new Assert\NotBlank(),
                        ],
                    ])
                    ->addModelTransformer(new EntityToIdTransformer($this->doctrine->getManager(), ProductClass::class))
            );

...
            $builder->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) {
	        // $event->getData()でCartItemに変換された値が渡ってくる。
		// おそらく下記のメソッドにあるconfigureOptionsのsetDefaultsがそうしているのだろう
                /** @var CartItem $CartItem */
                $CartItem = $event->getData();
                $ProductClass = $CartItem->getProductClass();
                // FIXME 価格の設定箇所、ここでいいのか
                if ($ProductClass) {
                    $CartItem
                        ->setProductClass($ProductClass)
			// $ProductClass->getPrice02IncTax()はただ$this->price02_inc_tax
			// を返却しているだけだが、税金を考慮した計算はevent, listenerの機能で実現している。
			// 下に記載しておく。
                        ->setPrice($ProductClass->getPrice02IncTax());
                }
            });
...

    /**
     * {@inheritdoc}
     */
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setRequired('product');
        $resolver->setDefaults([
	    // ここの設定でgetDataをするとCartItem::classとして値がセットされて取得できるのだろう
            'data_class' => CartItem::class,
            'id_add_product_id' => true,
            'constraints' => [
                // FIXME new Assert\Callback(array($this, 'validate')),
            ],
        ]);
    }

ここの意図がよく分からなかった。

2. 商品をカートに追加する処理

routes.product_add_cartは以下が実行される

src/Eccube/Controller/ProductController.php

    /**
     * カートに追加.
     *
     * @Route("/products/add_cart/{id}", name="product_add_cart", methods={"POST"}, requirements={"id" = "\d+"})
     */
    public function addCart(Request $request, Product $Product)
    {
        // エラーメッセージの配列
        $errorMessages = [];
        if (!$this->checkVisibility($Product)) {
            throw new NotFoundHttpException();
        }

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

        /* @var $form \Symfony\Component\Form\FormInterface */
        $form = $builder->getForm();
        $form->handleRequest($request);

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

        $addCartData = $form->getData();

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

        // カートへ追加
        $this->cartService->addProduct($addCartData['product_class_id'], $addCartData['quantity']);

        // 明細の正規化
        $Carts = $this->cartService->getCarts();
        foreach ($Carts as $Cart) {
	    // ここの解説は購入の項で
            $result = $this->purchaseFlow->validate($Cart, new PurchaseContext($Cart, $this->getUser()));
            // 復旧不可のエラーが発生した場合は追加した明細を削除.
            if ($result->hasError()) {
                $this->cartService->removeProduct($addCartData['product_class_id']);
                foreach ($result->getErrors() as $error) {
                    $errorMessages[] = $error->getMessage();
                }
            }
            foreach ($result->getWarning() as $warning) {
                $errorMessages[] = $warning->getMessage();
            }
        }

	// DBとセッションに保存
        $this->cartService->save();

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

...

        if ($request->isXmlHttpRequest()) {
            // ajaxでのリクエストの場合は結果をjson形式で返す。

            // 初期化
            $done = null;
            $messages = [];

            if (empty($errorMessages)) {
                // エラーが発生していない場合
                $done = true;
                array_push($messages, trans('front.product.add_cart_complete'));
            } else {
                // エラーが発生している場合
                $done = false;
                $messages = $errorMessages;
            }

            return $this->json(['done' => $done, 'messages' => $messages]);
        } else {
            // ajax以外でのリクエストの場合はカート画面へリダイレクト
            foreach ($errorMessages as $errorMessage) {
                $this->addRequestError($errorMessage);
            }

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

src/Eccube/Service/CartService.php


    /**
     * カートに商品を追加します.
     *
     * @param $ProductClass ProductClass 商品規格
     * @param $quantity int 数量
     *
     * @return bool 商品を追加できた場合はtrue
     */
    public function addProduct($ProductClass, $quantity = 1)
    {
...
        $newItem = new CartItem();
        $newItem->setQuantity($quantity);
        $newItem->setPrice($ProductClass->getPrice02IncTax());
        $newItem->setProductClass($ProductClass);

	// 同じ商品があったら纏めて、量を加減する。
        $allCartItems = $this->mergeAllCartItems([$newItem]);
	 // 既存のカート情報を削除して、新たにカートオブジェクトを作成
        $this->restoreCarts($allCartItems);

        return true;
    }

...
    /**
     * 現在のカートの配列を取得する.
     *
     * 本サービスのインスタンスのメンバーが空の場合は、DBまたはセッションからカートを取得する
     *
     * @param bool $empty_delete true の場合、商品明細が空のカートが存在した場合は削除する
     *
     * @return Cart[]
     */
    public function getCarts($empty_delete = false)
    {
...
        if ($this->getUser()) {
            $this->carts = $this->getPersistedCarts();
        } else {
            $this->carts = $this->getSessionCarts();
        }

        return $this->carts;
    }

    public function save()
    {
        $cartKeys = [];
        foreach ($this->carts as $Cart) {
            $Cart->setCustomer($this->getUser());
            $this->entityManager->persist($Cart);
            foreach ($Cart->getCartItems() as $item) {
                $this->entityManager->persist($item);
            }
            $this->entityManager->flush();
            $cartKeys[] = $Cart->getCartKey();
        }

        $this->session->set('cart_keys', $cartKeys);

        return;
    }
    
    protected function mergeCartItems($cartItems, $allCartItems)
    {
...
            foreach ($allCartItems as $itemInArray) {
                // 同じ明細があればマージする
		// ここでは同じproduct_class_idだったら同値とみなす
		// cartItemComparatorをDIでセットしているので、商品によっては別な比較処理も想定している
                if ($this->cartItemComparator->compare($item, $itemInArray)) {
                    $itemInArray->setQuantity($itemInArray->getQuantity() + $item->getQuantity());
                    $itemExists = true;
                    break;
...


    protected function restoreCarts($cartItems)
    {
        foreach ($this->getCarts() as $Cart) {
            foreach ($Cart->getCartItems() as $i) {
                $this->entityManager->remove($i);
                $this->entityManager->flush();
            }
            $this->entityManager->remove($Cart);
            $this->entityManager->flush();
        }
        $this->carts = [];

        /** @var Cart[] $Carts */
        $Carts = [];
...
                /** @var Cart $Cart */
                $Cart = $this->cartRepository->findOneBy(['cart_key' => $cartKey]);
                if ($Cart) {
                    foreach ($Cart->getCartItems() as $i) {
                        $this->entityManager->remove($i);
                        $this->entityManager->flush();
                    }
                    $this->entityManager->remove($Cart);
                    $this->entityManager->flush();
                }
                $Cart = new Cart();
                $Cart->setCartKey($cartKey);
                $Cart->addCartItem($item);
                $item->setCart($Cart);
                $Carts[$cartKey] = $Cart;
            }
        }

        $this->carts = array_values($Carts);
    }

3. カートアイテムに対して税金の計算

src/Eccube/Doctrine/EventSubscriber/TaxRuleEventSubscriber.php

以下で新規登録前(prePersist)、DBから取得した時、またはリフレッシュ操作が適用された時(postLoad)、保存された後(postPersist)、更新された後(postUpdate)のタイミング(Eventsの種類と説明)で、price01_inc_taxとprice02_inc_taxに全金を考慮した価格をセットする処理を適用している。

class TaxRuleEventSubscriber implements EventSubscriber

...
    public function getTaxRuleService()
    {
        return $this->container->get(TaxRuleService::class);
    }

    public function getSubscribedEvents()
    {
        return [
            Events::prePersist,
            Events::postLoad,
            Events::postPersist,
            Events::postUpdate,
        ];
    }

    public function prePersist(LifecycleEventArgs $args)
    {
        $entity = $args->getObject();

        if ($entity instanceof ProductClass) {
            $entity->setPrice01IncTax($this->getTaxRuleService()->getPriceIncTax($entity->getPrice01(),
                $entity->getProduct(), $entity));
            $entity->setPrice02IncTax($this->getTaxRuleService()->getPriceIncTax($entity->getPrice02(),
                $entity->getProduct(), $entity));
        }
    }
...

4. 税金の計算処理

src/Eccube/Service/TaxRuleService.php


    /**
     * calcIncTax
     *
     * @param  int                                    $price        計算対象の金額
     * @param  int|\Eccube\Entity\Product|null        $product      商品
     * @param  int|\Eccube\Entity\ProductClass|null   $productClass 商品規格
     * @param  int|\Eccube\Entity\Master\Pref|null    $pref         都道府県
     * @param  int|\Eccube\Entity\Master\Country|null $country      国
     *
     * @return double
     */
    public function getPriceIncTax($price, $product = null, $productClass = null, $pref = null, $country = null)
    {
        return $price + $this->getTax($price, $product, $productClass, $pref, $country);
    }

    /**
     * 設定情報に基づいて税金の金額を返す
     *
     * @param  int                                    $price        計算対象の金額
     * @param  int|\Eccube\Entity\Product|null        $product      商品
     * @param  int|\Eccube\Entity\ProductClass|null   $productClass 商品規格
     * @param  int|\Eccube\Entity\Master\Pref|null    $pref         都道府県
     * @param  int|\Eccube\Entity\Master\Country|null $country      国
     *
     * @return double                                 税金付与した金額
     */
    public function getTax($price, $product = null, $productClass = null, $pref = null, $country = null)
    {
        /*
         * 商品別税率が有効で商品別税率が設定されている場合は商品別税率
         * 商品別税率が有効で商品別税率が設定されていない場合は基本税率
         * 商品別税率が無効の場合は基本税率
         */
        /* @var $TaxRule \Eccube\Entity\TaxRule */
        if ($this->BaseInfo->isOptionProductTaxRule() && $productClass) {
            if ($productClass instanceof ProductClass) {
                $TaxRule = $productClass->getTaxRule() ?: $this->taxRuleRepository->getByRule(null, null, $pref, $country);
            } else {
                $TaxRule = $this->taxRuleRepository->getByRule($product, $productClass, $pref, $country);
            }
        } else {
            $TaxRule = $this->taxRuleRepository->getByRule(null, null, $pref, $country);
        }

        return $this->calcTax($price, $TaxRule->getTaxRate(), $TaxRule->getRoundingType()->getId(), $TaxRule->getTaxAdjust());
    }

    /**
     * 税金額を計算する
     *
     * @param  int    $price     計算対象の金額
     * @param  int    $taxRate   税率(%単位)
     * @param  int    $RoundingType  端数処理
     * @param  int    $taxAdjust 調整額
     *
     * @return double 税金額
     */
    public function calcTax($price, $taxRate, $RoundingType, $taxAdjust = 0)
    {
        $tax = $price * $taxRate / 100;
        $roundTax = $this->roundByRoundingType($tax, $RoundingType);

        return $roundTax + $taxAdjust;
    }

    /**
     * 課税規則に応じて端数処理を行う
     *
     * @param  integer $value    端数処理を行う数値
     * @param integer $RoundingType
     *
     * @return double        端数処理後の数値
     */
    public function roundByRoundingType($value, $RoundingType)
    {
        switch ($RoundingType) {
            // 四捨五入
            case \Eccube\Entity\Master\RoundingType::ROUND:
                $ret = round($value);
                break;
            // 切り捨て
            case \Eccube\Entity\Master\RoundingType::FLOOR:
                $ret = floor($value);
                break;
            // 切り上げ
            case \Eccube\Entity\Master\RoundingType::CEIL:
                $ret = ceil($value);
                break;
            // デフォルト:切り上げ
            default:
                $ret = ceil($value);
                break;
        }

        return $ret;
    }

その他税金処理に関する情報
https://github.com/EC-CUBE/ec-cube/pull/3420
https://doc4.ec-cube.net/spec_tax

CartServiceまわりのクラスを分類

UML観点で分けてみたが、分かりやすくなったのかよく分からない。とりあえず記載しておく。

集約

  • CartService:
    • Cart
      • CartItem

一つの、または複数のカートを管理。カートとそのアイテム情報をセッションとDBに保存する役割。商品に販売種別(sale_type)があるが、この値が違うとカートが分かれる。

依存

  • ProductController

    • CartService
  • CartService

    • 永続化
      • SessionInterface
      • EntityManagerInterface
      • TokenStorageInterface
    • 認証
      • AuthorizationCheckerInterface
    • ドメインロジック
      • CartItemComparator
      • CartItemAllocator
    • リポジトリ
      • ProductClassRepository
      • CartRepository
      • OrderRepository

実現

  • Cart

    • PurchaseInterface
    • ItemHolderInterface
  • CartItem

    • ItemInterface