型番を入力しての一括発注は、EC-CUBE にもプラグインにも用意がありません。B2B ECの選び方取引先ごとの卸価格卸売サイトを立ち上げる順番と、3回続けて「ありません」と書いてきました。

必要なら作ることになります。作るとどれくらいのものになるのか、実際に動く形で置いておきます。

作るもの

型番と数量を並べて入力して、まとめてカートに入れる画面です。

型番でまとめて注文の画面。型番と数量の入力欄が10行並び、上から cube-01 に3、sand-01 に2、cube-05 に10 を入力した状態

画面は EC-CUBE 4.4 の開発環境で動かしたものです。デモデータの型番をそのまま入力しています。

取引先はカタログや前回の注文書を見ながら発注します。商品ページを1つずつ開いてカートに入れる操作は、20品目もあるとやっていられません。型番が分かっているなら、並べて入力できるほうが早い。

作るファイルは4つです。

ファイル役割
app/Customize/Form/Type/QuickOrderItemType.php1行分(型番と数量)のフォーム
app/Customize/Form/Type/QuickOrderType.php行をまとめるフォーム
app/Customize/Controller/QuickOrderController.php画面とカート追加
app/template/default/QuickOrder/index.twigテンプレート

プラグインにはせず、app/Customize に置く前提で書いています。

先に、型番の話

作り始める前に知っておきたいことがあります。EC-CUBE の型番は一意ではありません。

型番は dtb_product_classproduct_code 列です。この列に一意制約はありません。管理画面の商品登録フォームで型番に付いている検証も、文字数の上限だけです。必須でもないので、空のまま登録できます。

つまり同じ型番の商品規格が複数あっても、EC-CUBE は何も言いません。型番から商品を引く画面を作るなら、引いた結果が1件に決まらない場合をどうするかを先に決めておく必要があります。

ここでは、1件に決まらない型番は該当なしとして弾きます。適当に先頭を選ぶと、取引先が意図しない商品を注文してしまいます。一括発注を運用に乗せるなら、型番の重複が起きない運用にするのが前提です。

1行分のフォーム

app/Customize/Form/Type/QuickOrderItemType.php を作ります。

<?php

namespace Customize\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints as Assert;

class QuickOrderItemType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('code', TextType::class, [
                'required' => false,
                'attr' => ['placeholder' => '型番'],
                'constraints' => [
                    new Assert\Length(['max' => 255]),
                ],
            ])
            ->add('quantity', IntegerType::class, [
                'required' => false,
                'constraints' => [
                    new Assert\Range(['min' => 1, 'max' => 9999]),
                ],
            ]);
    }
}

エンティティに紐づけないので data_class は指定しません。フォームのデータは ['code' => ..., 'quantity' => ...] の配列になります。

どちらの項目も requiredfalse です。10行並べたうち3行だけ埋めて送信する、という使い方をするためで、空行はコントローラ側で読み飛ばします。

行をまとめるフォーム

app/Customize/Form/Type/QuickOrderType.php です。

<?php

namespace Customize\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\FormBuilderInterface;

class QuickOrderType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('items', CollectionType::class, [
                'entry_type' => QuickOrderItemType::class,
                'allow_add' => true,
                'prototype' => true,
            ]);
    }
}

allow_addprototype を有効にしてあるので、行を増やすボタンを JavaScript で足せます。行数を固定にするなら不要です。

コントローラ

app/Customize/Controller/QuickOrderController.php です。ここが本体になります。

<?php

namespace Customize\Controller;

use Customize\Form\Type\QuickOrderType;
use Eccube\Controller\AbstractController;
use Eccube\Entity\Master\ProductStatus;
use Eccube\Entity\ProductClass;
use Eccube\Repository\ProductClassRepository;
use Eccube\Service\CartService;
use Eccube\Service\PurchaseFlow\PurchaseContext;
use Eccube\Service\PurchaseFlow\PurchaseFlow;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;

class QuickOrderController extends AbstractController
{
    /**
     * @var ProductClassRepository
     */
    private $productClassRepository;

    /**
     * @var CartService
     */
    private $cartService;

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

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

    /**
     * @Route("/quick_order", name="quick_order", methods={"GET", "POST"})
     * @Template("QuickOrder/index.twig")
     */
    public function index(Request $request)
    {
        $form = $this->createForm(QuickOrderType::class, [
            'items' => array_fill(0, 10, ['code' => null, 'quantity' => null]),
        ]);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $added = 0;
            $hasError = false;

            foreach ($form->get('items') as $itemForm) {
                $code = trim((string) $itemForm->get('code')->getData());
                if ($code === '') {
                    continue;
                }

                $ProductClass = $this->findProductClass($code);
                if (null === $ProductClass) {
                    $itemForm->get('code')->addError(new FormError('該当する商品が見つかりません。'));
                    $hasError = true;
                    continue;
                }

                $quantity = $itemForm->get('quantity')->getData() ?: 1;

                if ($this->addToCart($ProductClass, $quantity, $itemForm)) {
                    $added++;
                } else {
                    $hasError = true;
                }
            }

            $this->cartService->save();

            if ($added === 0 && !$hasError) {
                $form->addError(new FormError('型番を1件以上入力してください。'));
            }

            if ($added > 0 && !$hasError) {
                return $this->redirectToRoute('cart');
            }
        }

        return [
            'form' => $form->createView(),
        ];
    }

    // findProductClass() と addToCart() は次に書きます
}

送信された行を上から見て、型番が空なら読み飛ばし、商品が引けなければその行にエラーを付けます。1行でも失敗したらカート画面へは飛ばさず、入力内容とエラーを表示したまま画面に留まります。全部成功したときだけカート画面へ送ります。

存在しない型番 cube-99 を入力して送信した画面。その行だけ入力欄が赤くなり「該当する商品が見つかりません。」と表示され、他の行の入力内容はそのまま残っている

成功した行と失敗した行が混ざったとき、成功した分はカートに残ります。20行のうち1行だけ型番を間違えた場合に、19行を入力し直させるのは酷なので、この形にしました。上の画面でも、エラーになった行以外の2件はカートに入っています。

型番から商品規格を引く

先ほどのコメント部分に、型番から商品規格を引くメソッドを足します。

    private function findProductClass(string $code): ?ProductClass
    {
        $ProductClasses = $this->productClassRepository->findBy([
            'code' => $code,
            'visible' => true,
        ]);

        // 型番に一意制約がないため、1件に決まらないものは扱わない
        if (count($ProductClasses) !== 1) {
            return null;
        }

        $ProductClass = $ProductClasses[0];

        if ($ProductClass->getProduct()->getStatus()->getId() !== ProductStatus::DISPLAY_SHOW) {
            return null;
        }

        return $ProductClass;
    }

visible で絞っているのは、規格を変更したときに残る無効な商品規格を除くためです。公開ステータスの確認は本体の商品詳細と同じで、非公開の商品を型番から拾えてしまうと商品ページを非公開にした意味がなくなります。

カートへ入れる

同じくクラスの中に、カートへ入れるメソッドを足します。ここが一番の勘所です。

    private function addToCart(ProductClass $ProductClass, int $quantity, FormInterface $itemForm): bool
    {
        if (!$this->cartService->addProduct($ProductClass, $quantity)) {
            $itemForm->get('code')->addError(new FormError('この商品はカートに入れられません。'));

            return false;
        }

        $hasError = false;

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

            // 復旧できないエラーが出た行は、追加した明細を取り消す
            if ($result->hasError()) {
                $this->cartService->removeProduct($ProductClass);
                $hasError = true;

                foreach ($result->getErrors() as $error) {
                    $itemForm->get('code')->addError(new FormError($error->getMessage()));
                }
            }

            foreach ($result->getWarning() as $warning) {
                $this->addRequestError($warning->getMessage());
            }
        }

        return !$hasError;
    }

CartService::addProduct() を呼んだあと、必ず購入フローの検証を通します。本体の商品詳細ページからカートに入れる処理(ProductController::addCart)と同じ手順です。

自分で書いた画面だと、カートに入れて終わりにしたくなります。ただ、在庫数の確認も、購入数量の上限も、販売制限も、実際に効いているのは購入フローの検証です。ここを飛ばすと、在庫を超える数量がカートに入り、注文手続きの途中で初めてエラーになります。エラーが出た明細を取り消しているのも本体と同じで、こうしておかないと購入できない明細がカートに残り続けます。

getWarning() のほうは行のエラーにせず、フラッシュメッセージに回しています。在庫に合わせて数量を減らした、といった通知がここに来ます。明細自体は残るので、行のエラーにすると話が合いません。

テンプレート

app/template/default/QuickOrder/index.twig を作ります。

{% extends 'default_frame.twig' %}

{% set body_class = 'quick_order_page' %}

{% block main %}
    <div class="ec-role">
        <div class="ec-pageHeader">
            <h1>型番でまとめて注文</h1>
        </div>

        {{ form_start(form) }}
        {{ form_errors(form) }}

        <table class="ec-borderedDefs">
            <thead>
                <tr>
                    <th>型番</th>
                    <th>数量</th>
                </tr>
            </thead>
            <tbody>
                {% for item in form.items %}
                    <tr>
                        <td>
                            {{ form_widget(item.code) }}
                            {{ form_errors(item.code) }}
                        </td>
                        <td>
                            {{ form_widget(item.quantity) }}
                            {{ form_errors(item.quantity) }}
                        </td>
                    </tr>
                {% endfor %}
            </tbody>
        </table>

        <div class="ec-RegisterRole__actions">
            <button type="submit" class="ec-blockBtn--action">カートに入れる</button>
        </div>

        {{ form_end(form) }}
    </div>
{% endblock %}

/quick_order を開くと、10行の入力欄が並びます。会員だけに使わせたいなら、ルーティングを /mypage 配下に置くか、コントローラで is_granted('ROLE_USER') を確認してください。

送信すると、入力した行がそのままカートの明細になります。

送信後のショッピングカート画面。入力した3行が明細になり、彩のジェラートCUBE(バニラ)が3点、チェリーアイスサンドが2点、彩のジェラートCUBE(抹茶)が10点並んでいる

型番は商品規格ごとに付くので、同じ商品でも規格が違えば別の行として入ります。上の画面の1行目と3行目は、どちらも同じ商品の別の規格です。

Excel から貼り付けたい場合

実際の発注は、Excel の注文書から型番と数量をコピーしてくることが多いと思います。テキストエリアを1つ置いて、貼り付けた内容を行に分解する形にすれば、フォームの構造は変えずに済みます。

$rows = preg_split('/\R/u', $text, -1, PREG_SPLIT_NO_EMPTY);
foreach ($rows as $row) {
    [$code, $quantity] = array_pad(preg_split('/[,\t]/', trim($row), 2), 2, 1);
    $code = trim($code);
    $quantity = (int) $quantity ?: 1;
    // 以降は行入力と同じ処理
}

区切り文字にタブを含めておくと、Excel の2列をそのままコピーして貼り付けられます。カンマ区切りの CSV を貼る運用にも同じコードで対応できます。

会員グループ管理を入れている場合

卸売サイトなら会員グループ管理プラグインと組み合わせることになると思います。この実装はそのまま乗ります。

取引先に見せていない商品の型番を入力されても購入できません。 プラグインの GroupValidator が購入フローの検証に入っているためで、所属していないグループの商品は明細が落ちてエラーになります。上のコードはそのエラーメッセージをそのまま行に表示します。購入フローを通す作りにしておく利点がここにも出ます。

卸価格もそのまま反映されます。 会員グループ価格管理アドオンは、商品規格を読み込んだ時点(Doctrine の postLoad)で価格を差し替えます。リポジトリから取り出した ProductClass はすでに卸価格になっているので、カートに入れる処理を自分で書いても価格の出し分けは効きます。

つまり一括発注の画面を足すだけで、価格と販売可否はプラグイン側に任せられます。

割り切ったところ

正直に書いておきます。

エラーになった行の取り消しは、もともとカートにあった分も消します。 CartService::removeProduct() は同じ商品規格の明細を丸ごと削除するためです。先にカートへ入れておいた商品の型番を一括発注でもう一度入力して、それがエラーになると、元の分まで消えます。本体の商品詳細からの追加も同じ挙動なので合わせましたが、気になるなら追加前の数量を控えておいて戻す処理が要ります。

販売種別が違う商品を混ぜると、カートが分かれます。 EC-CUBE のカートは販売種別ごとに分かれる仕様です。一括発注の画面では1回の送信に見えても、購入手続きは分けて行うことになります。

数量の妥当性は購入フロー任せです。 入力欄には上限9999しか付けていません。在庫や購入制限に引っかかったときのメッセージは、本体が出すものがそのまま行に表示されます。

見積機能のほうは、これより大きい話になります。見積のエンティティを持って、承認の状態を管理して、受注へ変換する流れが要ります。一括発注はカートへ入れるだけなので、この記事の分量で収まっています。

EC-CUBE 4.4 での違い

このコードは 4.2 / 4.3 向けです。4.4 では2箇所変わります。

4.2 / 4.34.4
@Route / @Template のアノテーション#[Route] / #[Template] の属性記法
Sensio\Bundle\FrameworkExtraBundle\Configuration\TemplateSymfony\Bridge\Twig\Attribute\Template

4.4 では SensioFrameworkExtraBundle への依存がなくなり、RouteSymfony\Component\Routing\Attribute\Route に移ります。このあたりの変更点はEC-CUBE 4.4 でプラグインが動かなくなる変更点にまとめました。

カートへ入れる手順そのものは 4.4 でも変わりません。