型番を入力しての一括発注に続いて、見積のほうも作ってみました。

B2B ECの選び方取引先ごとの卸価格卸売サイトを立ち上げる順番型番一括発注と、4回続けて見積機能はありませんと書いてきました。前回は最後に、見積はこれより大きい話になる、と書いて逃げています。その中身を出しておきます。

作るもの

会員がカートの内容で見積を依頼し、店舗が管理画面で金額を調整して提出し、会員が承認すると受注になる。この4段階です。

段階誰がどこで
見積依頼会員カートページのボタン
金額の調整・提出店舗管理画面の受注編集
承認会員マイページの見積一覧
受注として処理店舗いつもどおりの受注管理

ショッピングカートの下部。合計¥125,400の下に「レジに進む」「お買い物を続ける」「この内容で見積を依頼する」の3つのボタンが縦に並んでいる

画面は EC-CUBE 4.4 の開発環境で動かしたものです。

独自テーブルにするか、受注を流用するか

作り始める前に決めることが1つあります。見積を独自のテーブルで持つか、受注を流用するか。

独自テーブル受注を流用
見積の編集画面自分で作る管理画面の受注編集がそのまま使える
金額計算・税計算自分で書く購入フローに任せられる
受注への変換変換処理を書くステータスを変えるだけ
受注一覧・売上集計混ざらない混ざる
注文履歴混ざらない混ざる

ここでは受注を流用します。管理画面の受注編集が、そのまま見積の編集画面になるからです。明細の追加も、単価の書き換えも、値引き行も、税額の再計算も、帳票の出力も、全部ついてきます。これを自分で作り直すのは割に合いません。

代わりに、見積が受注一覧と注文履歴に混ざります。これは後で書きます。

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

ファイル役割
app/DoctrineMigrations/VersionXXXX.php見積用の受注ステータスをマスタに追加
app/config/eccube/packages/order_state_machine.phpステータスの遷移を追加
app/Customize/Service/QuoteStatus.php追加したステータスIDの定数
app/Customize/Controller/QuoteController.php見積の依頼・一覧・承認
app/Customize/EventListener/QuoteStockSubscriber.php承認時に在庫を引く
app/template/default/Quote/index.twig見積一覧のテンプレート

これに加えて、カートページのテンプレートにボタンを1つ足します。

受注ステータスを足す

見積は「見積依頼中」と「見積提出済み」の2つのステータスで表します。IDは本体とぶつからない 101 と 102 にしました。

先に定数だけ切っておきます。設定ファイルからもコントローラからも参照するためです。

<?php

namespace Customize\Service;

final class QuoteStatus
{
    /** 見積依頼中(会員が依頼した直後) */
    public const REQUESTED = 101;

    /** 見積提出済み(店舗が金額を入れて提出した状態) */
    public const SUBMITTED = 102;

    /** 見積として扱うステータス */
    public const ALL = [self::REQUESTED, self::SUBMITTED];
}

触るマスタは3つある

ここが最初の落とし穴です。dtb_order.order_status_id は、3つのマスタテーブルから同じ列で参照されています。

        #[ORM\ManyToOne(targetEntity: CustomerOrderStatus::class)]
        #[ORM\JoinColumn(name: 'order_status_id', referencedColumnName: 'id')]
        private ?CustomerOrderStatus $CustomerOrderStatus = null;

        #[ORM\ManyToOne(targetEntity: OrderStatusColor::class)]
        #[ORM\JoinColumn(name: 'order_status_id', referencedColumnName: 'id')]
        private ?OrderStatusColor $OrderStatusColor = null;

        #[ORM\ManyToOne(targetEntity: OrderStatus::class)]
        #[ORM\JoinColumn(name: 'order_status_id', referencedColumnName: 'id')]
        private ?OrderStatus $OrderStatus = null;

Eccube\Entity\Order の抜粋です。管理画面で使う名称が mtb_order_status、受注一覧のバッジの色が mtb_order_status_color、会員に見せる名称が mtb_customer_order_status同じIDの行を3つとも入れないと、入れ忘れたものだけが null のまま残ります。

マイグレーションで3つまとめて入れます。

<?php

declare(strict_types=1);

namespace CustomizeMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260804000000 extends AbstractMigration
{
    private const STATUSES = [
        // id, 管理画面での名称, 一覧の色, 会員向けの名称, sort_no
        [101, '見積依頼中', '#EEB128', '見積依頼中', 100],
        [102, '見積提出済み', '#437ec4', 'お見積りをご確認ください', 101],
    ];

    public function up(Schema $schema): void
    {
        foreach (self::STATUSES as [$id, $name, $color, $customerName, $sortNo]) {
            $this->addSql(
                'INSERT INTO mtb_order_status (id, name, sort_no, display_order_count, discriminator_type)
                 VALUES (:id, :name, :sort_no, 1, \'orderstatus\')',
                ['id' => $id, 'name' => $name, 'sort_no' => $sortNo]
            );
            $this->addSql(
                'INSERT INTO mtb_order_status_color (id, name, sort_no, discriminator_type)
                 VALUES (:id, :name, :sort_no, \'orderstatuscolor\')',
                ['id' => $id, 'name' => $color, 'sort_no' => $sortNo]
            );
            $this->addSql(
                'INSERT INTO mtb_customer_order_status (id, name, sort_no, discriminator_type)
                 VALUES (:id, :name, :sort_no, \'customerorderstatus\')',
                ['id' => $id, 'name' => $customerName, 'sort_no' => $sortNo]
            );
        }
    }

    public function down(Schema $schema): void
    {
        foreach (self::STATUSES as [$id]) {
            $this->addSql('DELETE FROM mtb_customer_order_status WHERE id = :id', ['id' => $id]);
            $this->addSql('DELETE FROM mtb_order_status_color WHERE id = :id', ['id' => $id]);
            $this->addSql('DELETE FROM mtb_order_status WHERE id = :id', ['id' => $id]);
        }
    }
}

display_order_count を1にしてあるので、受注一覧の上に件数つきのタブが出ます。会員向けの名称は管理画面と変えられるので、「見積提出済み」は会員には「お見積りをご確認ください」と見せています。

ステートマシンに遷移を足す

マスタに行を入れただけでは、管理画面のステータス変更に新しいステータスは出てきません。 受注編集フォームが、選択肢をステートマシンで絞っているからです。

        foreach ($OrderStatuses as $Status) {
            // 同一ステータスはスキップ
            if ($Order->getOrderStatus()->getId() == $Status->getId()) {
                continue;
            }
            // 遷移できないステータスはリストから除外する.
            if (!$this->orderStateMachine->can($Order, $Status)) {
                $OrderStatuses->removeElement($Status);
            }
        }

Eccube\Form\Type\Admin\OrderType の抜粋です。遷移が定義されていないステータスは選択肢から消えます。

遷移の定義は app/config/eccube/packages/order_state_machine.php にあります。本体の定義に places を2つと transitions を3つ足します。

use Customize\Service\QuoteStatus;
use Eccube\Entity\Master\OrderStatus as Status;

            'places' => [
                // …本体の8件はそのまま…
                (string) QuoteStatus::REQUESTED,
                (string) QuoteStatus::SUBMITTED,
            ],
            'transitions' => [
                // …本体の7件はそのまま…

                // 店舗が金額を入れて提出する
                'submit_quote' => [
                    'from' => (string) QuoteStatus::REQUESTED,
                    'to' => (string) QuoteStatus::SUBMITTED,
                ],
                // 会員が承認して受注になる(在庫はこの時点で引く)
                'accept_quote' => [
                    'from' => (string) QuoteStatus::SUBMITTED,
                    'to' => (string) Status::NEW,
                ],
                // 見積のまま取り消す
                'decline_quote' => [
                    'from' => [(string) QuoteStatus::REQUESTED, (string) QuoteStatus::SUBMITTED],
                    'to' => (string) Status::CANCEL,
                ],
            ],

取り消しに cancel を使わなかった理由

見積の取り下げも行き先は「注文取消し」です。それなら本体の cancelfrom に見積のステータスを足せば済みそうに見えます。

やめました。本体の OrderStateMachine が、遷移の名前でイベントを購読しているからです。

            'workflow.order.transition.cancel' => [['rollbackStock'], ['rollbackUsePoint']],

cancel を通ると在庫が戻ります。見積の段階では在庫を引いていないので、戻されると在庫が増えます。だから別の名前の遷移にしました。遷移の名前は、そのまま在庫やポイントの処理と結びついています。

在庫をいつ引くか

見積の段階で在庫を引きたくはありません。かといって、承認された時点では引かないと困ります。

EC-CUBEの購入フローは、在庫を引く処理を prepare() に置いています。そして在庫を引く StockReduceProcessor は、購入フローのうち shopping にしか登録されていません。

    eccube.purchase.flow.purchase.processor.stock.reduce.processor:
        class: Eccube\Service\PurchaseFlow\Processor\StockReduceProcessor
        arguments:
            - '@Eccube\Repository\ProductStockRepository'
            - '@doctrine.orm.default_entity_manager'
        tags:
            - { name: eccube.purchase.processor, flow_type: shopping, priority: 800 }

app/config/eccube/packages/purchaseflow.yaml の抜粋です。ここから2つ分かります。

  • validate() を呼ぶだけなら在庫は動かない。 送料と手数料、税の計算は validate() の中で終わります。見積を作るときはこれだけ呼べばいい。
  • 管理画面の受注編集(order フロー)でも在庫は引かれない。 order フローには代わりに StockDiffProcessor が入っていて、編集前後の数量の差分だけを在庫に反映します。だから見積の明細を管理画面で書き換えても、在庫は動きません。

残るのは承認したときです。ここは本体が、注文取消しから対応中に戻したときにやっているのと同じことをします。

<?php

namespace Customize\EventListener;

use Eccube\Service\OrderStateMachineContext;
use Eccube\Service\PurchaseFlow\Processor\StockReduceProcessor;
use Eccube\Service\PurchaseFlow\PurchaseContext;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Workflow\Event\Event;

class QuoteStockSubscriber implements EventSubscriberInterface
{
    public function __construct(private readonly StockReduceProcessor $stockReduceProcessor)
    {
    }

    public static function getSubscribedEvents(): array
    {
        return [
            'workflow.order.transition.accept_quote' => ['reduceStock'],
        ];
    }

    public function reduceStock(Event $event): void
    {
        /** @var OrderStateMachineContext $context */
        $context = $event->getSubject();

        // 在庫が足りなければ ShoppingException が飛ぶ。呼び出し側で受ける。
        $this->stockReduceProcessor->prepare($context->getOrder(), new PurchaseContext());
    }
}

さきほど足した accept_quote の遷移だけを購読しています。

見積をつくる

app/Customize/Controller/QuoteController.php です。まずカートから見積を作るところ。

    #[Route('/quote/request', name: 'quote_request', methods: ['POST'])]
    public function request(Request $request): Response
    {
        $this->isTokenValid();

        $Cart = $this->cartService->getCart();
        if (null === $Cart || !$this->orderHelper->verifyCart($Cart)) {
            return $this->redirectToRoute('cart');
        }

        // 受注をつくるところまでは購入手続きと同じ. ステータスだけ見積に差し替える.
        $Order = $this->orderHelper->createPurchaseProcessingOrder($Cart, $this->getUser());
        $Order->setOrderStatus($this->orderStatusRepository->find(QuoteStatus::REQUESTED));
        $this->em->flush();

        // 送料・手数料・税を計算させる. validate は在庫を引かない.
        $flowResult = $this->shoppingPurchaseFlow->validate($Order, new PurchaseContext($Order, $this->getUser()));
        if ($flowResult->hasError()) {
            foreach ($flowResult->getErrors() as $error) {
                $this->addError($error->getMessage());
            }
            $this->em->remove($Order);
            $this->em->flush();

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

        $this->cartService->clear();
        $this->cartService->save();

        $this->addSuccess('見積を依頼しました。回答をお待ちください。');

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

受注を作るところは購入手続きと同じ OrderHelper::createPurchaseProcessingOrder() を呼んでいます。会員情報のコピーも、販売種別ごとの出荷情報の作成も、既定の配送方法と支払い方法の設定も、これ1つで済みます。作ったあとにステータスだけ差し替えます。

flush() を先に1回挟んでいるのは、受注番号を振る OrderNoProcessor が、IDの決まっていない受注には番号を振らずに帰る作りだからです。先にIDを確定させてから validate() を呼ぶと、見積にも番号が付きます。

マイページのお見積り画面。見積番号917、ステータス「見積依頼中」で、彩のジェラートCUBEが¥5,500×20、チェリーアイスサンドが¥3,080×5、小計¥125,400、送料¥0、お見積り金額¥125,400と表示されている

カートページのボタンは、カートのフォームと入れ子にならないよう別のフォームにします。

{% if is_granted('ROLE_USER') %}
    <form method="post" action="{{ url('quote_request') }}" class="ec-cartRole">
        <input type="hidden" name="{{ constant('Eccube\\Common\\Constant::TOKEN_NAME') }}" value="{{ csrf_token(constant('Eccube\\Common\\Constant::TOKEN_NAME')) }}">
        <div class="ec-cartRole__actions">
            <button type="submit" class="ec-blockBtn--cancel">この内容で見積を依頼する</button>
        </div>
    </form>
{% endif %}

Cart/index.twigapp/template/default/ にコピーして、カートのフォームの </form> の直後に置きます。

店舗が金額を調整する

ここからは本体の画面です。受注一覧に見積用のタブが増えます。

管理画面の受注一覧。対応状況の絞り込みに「見積依頼中(1)」「見積提出済み(0)」のチェックボックスが増えており、一覧には注文者「見積太郎」、対応状況が黄色いバッジの「見積依頼中」、購入金額¥125,400の行が1件表示されている

編集画面では、対応状況の選択肢が「見積依頼中」「見積提出済み」「注文取消し」の3つだけになります。ステートマシンに書いたとおりです。見積の段階から「発送済み」に飛ばすことはできません。

金額の調整は「その他の明細を追加」から値引き行を足します。

管理画面の受注編集の商品情報。商品2行と送料・手数料に加えて「お見積り値引き」という明細が-12,540円で入っており、合計欄が小計¥125,400、値引き-¥13,794、お支払い合計¥111,606になっている

値引き行を1行足して対応状況を「見積提出済み」にし、登録するだけです。税額も支払い合計も本体が計算し直します。見積のために書いたコードはここには1行もありません。

単価そのものを書き換えても構いません。取引先ごとに単価を出したいなら、そちらのほうが見積書らしくなります。

会員が承認する

会員側は、金額を確認して注文するか取り下げるかを選びます。

マイページのお見積り画面。ステータスが「お見積りをご確認ください」に変わり、小計¥125,400、値引き-¥13,794、お見積り金額¥111,606の下に「この見積で注文する」「見積を取り下げる」のボタンが並んでいる

承認の処理です。

    #[Route('/mypage/quote/{id}/accept', name: 'mypage_quote_accept', methods: ['POST'], requirements: ['id' => '\d+'])]
    public function accept(Request $request, Order $Order): Response
    {
        $this->isTokenValid();
        $this->assertOwnQuote($Order, QuoteStatus::SUBMITTED);

        $NewStatus = $this->orderStatusRepository->find(OrderStatus::NEW);

        try {
            // StockReduceProcessor が entityManager->lock() を使うためトランザクションが要る
            $this->em->wrapInTransaction(function () use ($Order, $NewStatus): void {
                $this->orderStateMachine->apply($Order, $NewStatus);
            });
        } catch (ShoppingException $e) {
            $this->addError($e->getMessage());

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

        $this->addSuccess('見積を承認しました。ご注文として承ります。');

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

OrderStateMachine::apply() を呼ぶだけです。ステータスの書き換えも在庫の引き当ても、遷移の定義とイベント購読に任せます。

トランザクションで包んでいるのは、在庫を引く処理が EntityManager::lock() を使うからです。本体の購入手続きも同じ理由でトランザクションを張っています。ここを省くと、在庫が足りなかったときに中途半端な状態で止まります。

在庫が足りなければこうなります。

マイページのお見積り画面の上部に赤字で「「チェリーアイスサンド」の在庫が足りません。」と表示され、見積はそのまま残っている

メッセージは本体のものがそのまま出ます。ステータスは見積提出済みのまま、在庫も動きません。トランザクションで包んでいるので、途中まで進んだ分ごと巻き戻ります。

他人の見積を操作されないよう、承認と取り下げの前に確認を入れておきます。

    private function assertOwnQuote(Order $Order, ?int $expectedStatus = null): void
    {
        if ($Order->getCustomer() !== $this->getUser()) {
            throw $this->createNotFoundException();
        }

        $statusId = $Order->getOrderStatus()->getId();
        if (!in_array($statusId, QuoteStatus::ALL, true)) {
            throw $this->createNotFoundException();
        }

        if (null !== $expectedStatus && $statusId !== $expectedStatus) {
            throw $this->createNotFoundException();
        }
    }

見積以外の受注のIDを入れられても弾きます。これがないと、URLを書き換えるだけで他人の受注を「新規受付」にできてしまいます。

承認したあとは、ステータスが「新規受付」の普通の受注です。管理画面の見え方も、出荷の手順も、いつもと同じになります。

割り切ったところ

正直に書いておきます。

見積が注文履歴に並びます。 EC-CUBEの注文履歴は「購入処理中」と「決済処理中」だけを除外する作りで、それ以外のステータスは全部並びます。見積用に足したステータスも例外ではありません。

マイページのご注文履歴。「2件の履歴があります」の下に、ご注文番号918・ご注文状況「見積依頼中」の行と、ご注文番号917・ご注文状況「注文受付」の行が並んでいる

見積のほうは注文日が空です。注文日は購入手続きの完了時にしか入らないためで、上の画面でも918には日付がありません。隠したいなら、注文履歴の絞り込みを上書きすることになります。

受注一覧と売上集計にも混ざります。 受注一覧はタブで分けられますが、絞り込みを指定しない検索には見積も出てきます。売上集計を見積抜きで出したいなら、そちらも手を入れることになります。受注を流用すると決めた時点で引き受ける代償です。

見積書のPDFは入っていません。 見積の実体は受注なので、管理画面の納品書出力(OrderPdfService)を見積書の書式で複製すれば出せます。セット商品の内訳を納品書に出すのときと同じ場所です。

有効期限がありません。 見積に期限を持たせるなら、dtb_order に列を足すか、Customize\Entity でトレイトを当てて拡張することになります。期限切れの見積を承認させない判定も要ります。

カートが分かれているときは1つ目しか見ません。 CartService::getCart() は主として扱われているカートを返します。販売種別が違う商品を混ぜると EC-CUBE はカートを分けるので、見積も分かれた単位で依頼させるのが素直です。

決済は承認のあとです。 この実装では承認すると「新規受付」の受注になるだけで、決済は走りません。銀行振込や請求書払いのような後払いを前提にしています。カード決済まで通したいなら、承認のタイミングで購入手続きの決済処理に合流させることになります。

EC-CUBE 4.2 / 4.3 での違い

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

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

OrderHelper::createPurchaseProcessingOrder() の引数、StockReduceProcessor が shopping フローにしか登録されていないこと、ステートマシンの設定ファイルの場所は 4.3 でも同じでした。このあたりの変更点はEC-CUBE 4.4 でプラグインが動かなくなる変更点にまとめています。

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

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

見積を作る流れはカートから受注を作る購入手続きと同じなので、会員グループ価格管理アドオンが差し替えた卸価格が、そのまま見積の金額になります。取引先ごとの卸価格を土台にして、そこから案件ごとの値引きを乗せる、という運用ができます。

見積依頼のボタンをログイン会員だけに出しているのも、そのためです。誰が見ても同じ価格の見積を出すなら、そもそも見積機能は要りません。