Chapter 05

バックエンド - 受注・受注明細(バリデーション、集計処理)

ta.toshio
ta.toshio
2021.06.06に更新

参考資料

https://doc4.ec-cube.net/spec_order

https://www.slideshare.net/chihiroadachi3/201927-eccubeugpurchaseflow-130874190

https://doc4.ec-cube.net/customize_service

https://github.com/EC-CUBE/ec-cube/pull/2424

https://github.com/EC-CUBE/ec-cube/pull/3325

クラス図

引用: https://github.com/EC-CUBE/ec-cube/pull/2424

アクティビティ図

引用: https://github.com/EC-CUBE/ec-cube/pull/2424

処理フロー

  1. カートの中身を見る
    カートの中身(受注明細予定)を精査する。
    |~ src/Eccube/Controller/CartController.php
  2. PurchaseFlow
    精査する処理を司る。
    |~ src/Eccube/Service/PurchaseFlow/PurchaseFlow.php
  3. PurchaseFlowでどんなバリデーションをしてほしいかの定義
    |~ app/config/eccube/packages/purchaseflow.yaml
  4. バリデーションを実行するクラス
    |~ src/Eccube/Service/PurchaseFlow/ItemValidator.php
    |~ src/Eccube/Service/PurchaseFlow/ItemHolderValidator.php
  5. バリデーション処理いくつか

この記事の内容としてはpurchaseflowの処理フローを追っていく。

受注(注文)見出し予定、受注(注文)明細予定の情報が購入できる状態どうかチェックをする。
また税額、送料、送料無料になるか、手数料の有無など必要があれば受注明細に加える。
これらを担当しているのがpurchaseflowの役割。

1.カートの中身を見る(コントローラー)

まずはカート一覧画面で、いまのカートの状態をチェックし、受注見出し予定と受注明細予定の情報を出力。

src/Eccube/Controller/CartController.php

    /**
     * カート画面.
     *
     * @Route("/cart", name="cart")
     * @Template("Cart/index.twig")
     */
    public function index(Request $request)
    {
        // カートを取得して明細の正規化を実行
        $Carts = $this->cartService->getCarts();

	////////////////////////////////////////////////
	// ここの処理を深く見ていく
        $this->execPurchaseFlow($Carts);

...

        return [
            'totalPrice' => $totalPrice,
            'totalQuantity' => $totalQuantity,
            // 空のカートを削除し取得し直す
            'Carts' => $this->cartService->getCarts(true),
	    // 送料無料になるまであといくら購入すれば、のいくらの情報が入る
            'least' => $least, 
	    // 送料無料になるまであと何個購入すれば、の何個の情報が入る
            'quantity' => $quantity,
            'is_delivery_free' => $isDeliveryFree,
        ];
    }
    
    /**
     * カート明細の加算/減算/削除を行う.
     *
     * - 加算
     *      - 明細の個数を1増やす
     * - 減算
     *      - 明細の個数を1減らす
     *      - 個数が0になる場合は、明細を削除する
     * - 削除
     *      - 明細を削除する
     *
     * @Route(
     *     path="/cart/{operation}/{productClassId}",
     *     name="cart_handle_item",
     *     methods={"PUT"},
     *     requirements={
     *          "operation": "up|down|remove",
     *          "productClassId": "\d+"
     *     }
     * )
     */
    public function handleCartItem($operation, $productClassId)
    {
...
        // 明細の増減・削除
        switch ($operation) {
            case 'up':
                $this->cartService->addProduct($ProductClass, 1);
                break;
            case 'down':
                $this->cartService->addProduct($ProductClass, -1);
                break;
            case 'remove':
                $this->cartService->removeProduct($ProductClass);
                break;
        }

        // カートを取得して明細の正規化を実行
        $Carts = $this->cartService->getCarts();
	
	////////////////////////////////////////////////
	// ここの処理を深く見ていく
        $this->execPurchaseFlow($Carts);

        log_info('カート演算処理終了', ['operation' => $operation, 'product_class_id' => $productClassId]);

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

    /**
     * @param $Carts
     *
     * @return \Symfony\Component\HttpFoundation\RedirectResponse
     */
    protected function execPurchaseFlow($Carts)
    {
        /** @var PurchaseFlowResult[] $flowResults */
        $flowResults = array_map(function ($Cart) {

            // purchaseFlowにてバリデーション処理や、追加する明細があれば追加
            $purchaseContext = new PurchaseContext($Cart, $this->getUser());
            return $this->purchaseFlow->validate($Cart, $purchaseContext);
        }, $Carts);

        // 復旧不可のエラーが発生した場合はカートをクリアして再描画
        $hasError = false;
        foreach ($flowResults as $result) {
            if ($result->hasError()) {
                $hasError = true;
                foreach ($result->getErrors() as $error) {
                    $this->addRequestError($error->getMessage());
                }
            }
        }
        if ($hasError) {
            $this->cartService->clear();

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

        $this->cartService->save();

        foreach ($flowResults as $index => $result) {
            foreach ($result->getWarning() as $warning) {
                if ($Carts[$index]->getItems()->count() > 0) {
                    $cart_key = $Carts[$index]->getCartKey();
                    $this->addRequestError($warning->getMessage(), "front.cart.${cart_key}");
                } else {
                    // キーが存在しない場合はグローバルにエラーを表示する
                    $this->addRequestError($warning->getMessage());
                }
            }
        }
    }

2.PurchaseFlow

src/Eccube/Service/PurchaseFlow/PurchaseFlow.php

    public function validate(ItemHolderInterface $itemHolder, PurchaseContext $context)
    {
        $context->setFlowType($this->flowType);

        $this->calculateAll($itemHolder);

        $flowResult = new PurchaseFlowResult($itemHolder);

	// $itemHolder=カート
	// カートの中の商品に対してバリデーションチェックをかけていく
        foreach ($itemHolder->getItems() as $item) {
	    // $this->itemValidatorsの中身を下で解説
            foreach ($this->itemValidators as $itemValidator) {
		// バリデーション実行
                $result = $itemValidator->execute($item, $context);
                $flowResult->addProcessResult($result);
            }
        }

	// 送料、手数料、値引き、小計、税額、金額を計算
        $this->calculateAll($itemHolder);

	// カートに対してバリデーションチェックをかけていく
	// $this->itemHolderValidatorsの中身を下で解説
        foreach ($this->itemHolderValidators as $itemHolderValidator) {
            $result = $itemHolderValidator->execute($itemHolder, $context);
            $flowResult->addProcessResult($result);
        }

	// 再度計算
        $this->calculateAll($itemHolder);

        foreach ($itemHolder->getItems() as $item) {
            foreach ($this->itemPreprocessors as $itemPreprocessor) {
                $itemPreprocessor->process($item, $context);
            }
        }

	// さらに再度
        $this->calculateAll($itemHolder);

	// $this->itemHolderPreprocessorsの中身を下で解説
        foreach ($this->itemHolderPreprocessors as $holderPreprocessor) {
            $result = $holderPreprocessor->process($itemHolder, $context);
            if ($result) {
                $flowResult->addProcessResult($result);
            }

	    // またまた再度
            $this->calculateAll($itemHolder);
        }

        foreach ($this->discountProcessors as $discountProcessor) {
            $discountProcessor->removeDiscountItem($itemHolder, $context);
        }

	// 再度再度
        $this->calculateAll($itemHolder);

        foreach ($this->discountProcessors as $discountProcessor) {
            $result = $discountProcessor->addDiscountItem($itemHolder, $context);
            if ($result) {
                $flowResult->addProcessResult($result);
            }
	    
	    // ここでも
            $this->calculateAll($itemHolder);
        }

        foreach ($this->itemHolderPostValidators as $itemHolderPostValidator) {
            $result = $itemHolderPostValidator->execute($itemHolder, $context);
            $flowResult->addProcessResult($result);

	    // ここでも
            $this->calculateAll($itemHolder);
        }

        return $flowResult;
    }

3.PurchaseFlowでどんなバリデーションをしてほしいかの定義

Purcahseflowの定義箇所。
$this->itemValidators,$this->itemHolderValidators,$this->itemHolderPreprocessorsの中身が定義されている

app/config/eccube/packages/purchaseflow.yaml

services:

    # Purchase Flow for Cart

    # symfonyがclassで定義しているクラスに対して、callで定義した関数に値をDIしていると予想
    eccube.purchase.flow.cart:
        class: Eccube\Service\PurchaseFlow\PurchaseFlow
        calls:
            - [setFlowType, ['cart']]
            - [setItemValidators, ['@eccube.purchase.flow.cart.item_validators']]
            - [setItemHolderValidators, ['@eccube.purchase.flow.cart.holder_validators']]
            - [setItemPreprocessors, ['@eccube.purchase.flow.cart.item_preprocessors']]
            - [setItemHolderPreprocessors, ['@eccube.purchase.flow.cart.holder_preprocessors']]
            - [setItemHolderPostValidators, ['@eccube.purchase.flow.cart.holder_post_validators']]

    eccube.purchase.flow.cart.item_validators:
        class: Doctrine\Common\Collections\ArrayCollection
        arguments:
            - #
                - '@Eccube\Service\PurchaseFlow\Processor\DeliverySettingValidator' # 配送設定のチェック
                - '@Eccube\Service\PurchaseFlow\Processor\ProductStatusValidator' # 商品の公開状態のチェック
                - '@Eccube\Service\PurchaseFlow\Processor\PriceChangeValidator' # 商品価格の変更検知
                - '@Eccube\Service\PurchaseFlow\Processor\StockValidator' # 在庫のチェック
                - '@Eccube\Service\PurchaseFlow\Processor\SaleLimitValidator' # 販売制限数のチェック

    eccube.purchase.flow.cart.holder_validators:
        class: Doctrine\Common\Collections\ArrayCollection
        arguments:
            - #
                - '@Eccube\Service\PurchaseFlow\Processor\EmptyItemsValidator'  # 空明細の削除処理

    eccube.purchase.flow.cart.item_preprocessors:
        class: Doctrine\Common\Collections\ArrayCollection

    eccube.purchase.flow.cart.holder_preprocessors:
        class: Doctrine\Common\Collections\ArrayCollection

    eccube.purchase.flow.cart.holder_post_validators:
        class: Doctrine\Common\Collections\ArrayCollection
        arguments:
            - #
                - '@Eccube\Service\PurchaseFlow\Processor\PaymentValidator' # 使用できない支払い方法が含まれていないかどうか
                - '@Eccube\Service\PurchaseFlow\Processor\PaymentTotalLimitValidator' # 支払金額の上限チェック
                - '@Eccube\Service\PurchaseFlow\Processor\PaymentTotalNegativeValidator'  # 支払金額のマイナスチェック

4. バリデーションを実行するクラス

購入予定の商品が購入可能かどうかチェックする。
いくつか挙げておく。

クラス図

CartItem、OrderItemクラスに対するバリデーション実行クラスは以下のような形。

Cart, Orderクラスに対するバリデーション実行クラスは以下のような形。

購入した商品種別に宅配種別が設定されているか

設定されていなかったらエラー

src/Eccube/Service/PurchaseFlow/Processor/DeliverySettingValidator.php

    /**
     * @param ItemInterface $item
     * @param PurchaseContext $context
     *
     * @throws InvalidItemException
     */
    protected function validate(ItemInterface $item, PurchaseContext $context)
    {
        if (!$item->isProduct()) {
            return;
        }

        $SaleType = $item->getProductClass()->getSaleType();
        $Deliveries = $this->deliveryRepository->findBy(['SaleType' => $SaleType, 'visible' => true]);

        if (empty($Deliveries)) {
            $this->throwInvalidItemException('front.shopping.in_preparation', $item->getProductClass());
        }
    }

5.バリデーション処理例

カートに商品が入っているか

src/Eccube/Service/PurchaseFlow/Processor/EmptyItemsValidator.php

購入数が0であればカートから、または受注明細から削除。受注済みで、shippingに紐づく商品明細がない場合はエラー

登場クラス補足

PurchaseContext

src/Eccube/Service/PurchaseFlow/PurchaseContext.php

PurchaseContextはどのユースケースで使用されているかの文脈を表現しているクラス。
cart、shopping、orderの3種類がある模様。
実態はユーザー情報と、カート情報(ItemHolderInterface)と実行コンテキストのタイプ値(cart, shopping, order)を持っているだけ。

/**
 * PurchaseFlowの実行中コンテキスト.
 */
class PurchaseContext extends \SplObjectStorage
{
    private $user;

    private $originHolder;

    private $flowType;

    const ORDER_FLOW = 'order';

    const SHOPPING_FLOW = 'shopping';

    const CART_FLOW = 'cart';

    public function __construct(ItemHolderInterface $originHolder = null, ?Customer $user = null)
    {
        $this->originHolder = $originHolder;
        $this->user = $user;
    }

    /**
     * PurchaseFlow実行前の{@link ItemHolderInterface}を取得.
     *
     * @return ItemHolderInterface
     */
    public function getOriginHolder()
    {
        return $this->originHolder;
    }

    /**
     * 会員情報を取得.
     *
     * @return Customer
     */
    public function getUser()
    {
        return $this->user;
    }

    public function setFlowType($flowType)
    {
        $this->flowType = $flowType;
    }

PurchaseFlowResultまわり

PurchaseFlowResultを生成して、itemValidatorsやitemPreprocessorsの返却値でProcessResultを受け取る

        $flowResult = new PurchaseFlowResult($itemHolder);

        foreach ($itemHolder->getItems() as $item) {
            foreach ($this->itemValidators as $itemValidator) {
                $result = $itemValidator->execute($item, $context);
                $flowResult->addProcessResult($result);
            }
        }
    final public function execute(ItemInterface $item, PurchaseContext $context)
    {
        try {
            $this->validate($item, $context);

            return ProcessResult::success(null, static::class);
        } catch (InvalidItemException $e) {
            $this->handle($item, $context);

            return ProcessResult::warn($e->getMessage(), static::class);
        }
    }

src/Eccube/Service/PurchaseFlow/PurchaseFlowResult.php

内容は単純でProcessResultを集合として持っていて、エラーであるProcessResultを取得したり、存在するか判定するメソッドがある。

    /**
     * @return array|ProcessResult[]
     */
    public function getErrors()
    {
        return array_filter($this->processResults, function (ProcessResult $processResult) {
            return $processResult->isError();
        });
    }

    /**
     * @return array|ProcessResult[]
     */
    public function getWarning()
    {
        return array_filter($this->processResults, function (ProcessResult $processResult) {
            return $processResult->isWarning();
        });
    }

    public function hasError()
    {
        return !empty($this->getErrors());
    }

    public function hasWarning()
    {
        return !empty($this->getWarning());
    

src/Eccube/Service/PurchaseFlow/ProcessResult.php

内容は単純で自身のインスタンスがSUCCESSかWARNINGかERRORを表現するためのクラス。エラーがあればそのエラーメッセージを保持。

    public function isError()
    {
        return $this->type === self::ERROR;
    }

    public function isWarning()
    {
        return $this->type === self::WARNING;
    }

    public function isSuccess()
    {
        return $this->type === self::SUCCESS;
    }

    public function getMessage()
    {
        return $this->message;
    }

ItemCollection

src/Eccube/Service/PurchaseFlow/ItemCollection.php

CartItem、OrderItemを集合として保持。それらのアイテムに対してコレクションヘルパーメソッドを定義。
ItemHolderからItemCollectionを取得できるようにしている。以下のような形で。

    class Cart extends AbstractEntity implements PurchaseInterface, ItemHolderInterface
    {
        use PointTrait;
        /**
         * Alias of getCartItems()
         */
        public function getItems()
        {
            return (new ItemCollection($this->getCartItems()))->sort();
        }

金額計算

src/Eccube/Service/PurchaseFlow/PurchaseFlow.php

taxだけ

    /**
     * @param ItemHolderInterface $itemHolder
     */
    protected function calculateAll(ItemHolderInterface $itemHolder)
    {
        $this->calculateDeliveryFeeTotal($itemHolder);
        $this->calculateCharge($itemHolder);
        $this->calculateDiscount($itemHolder);
        $this->calculateSubTotal($itemHolder); // Order の場合のみ
        $this->calculateTax($itemHolder);
        $this->calculateTotal($itemHolder);
    }
...
    /**
     * @param ItemHolderInterface $itemHolder
     */
    protected function calculateTax(ItemHolderInterface $itemHolder)
    {
        $total = $itemHolder->getItems()
            ->reduce(function ($sum, ItemInterface $item) {
                if ($item instanceof OrderItem) {
                    $sum += $item->getTax() * $item->getQuantity();
                } else {
                    $sum += ($item->getPriceIncTax() - $item->getPrice()) * $item->getQuantity();
                }

                return $sum;
            }, 0);
        $itemHolder->setTax($total);
    }