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

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

作るもの

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

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

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

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

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

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

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

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

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

作るものは6つです。

つくるもの役割
マイグレーション見積用の受注ステータスをマスタに追加
ステートマシンの設定ステータスの遷移を追加
ステータスIDの定数設定ファイルからもコントローラからも参照する
コントローラ見積の依頼・一覧・承認
イベントの購読承認時に在庫を引く
テンプレート見積一覧と、カートページのボタン

受注ステータスを足す

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

IDは定数として1箇所にまとめておきます。設定ファイルからもコントローラからも同じ値を参照するので、直に数字を書くと必ずどこかでずれます。「見積として扱うステータス」の一覧も一緒に持たせておくと、あとで判定に使えます。

触るマスタは3つある

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

受注エンティティを見ると、この1つの列に3つの関連がぶら下がっています。管理画面で使う名称が mtb_order_status、受注一覧のバッジの色が mtb_order_status_color、会員に見せる名称が mtb_customer_order_status同じIDの行を3つとも入れないと、入れ忘れたものだけが空のまま残ります。

マイグレーションで、2つのステータスを3つのマスタにまとめて入れます。

ID管理画面での名称一覧の色会員に見せる名称
101見積依頼中#EEB128見積依頼中
102見積提出済み#437ec4お見積りをご確認ください

管理画面のマスタには、件数を表示するかどうかの項目もあります。これを有効にしておくと、受注一覧の上に件数つきのタブが出ます。会員向けの名称は管理画面と変えられるので、「見積提出済み」は会員には「お見積りをご確認ください」と見せました。

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

マスタに行を入れただけでは、管理画面のステータス変更に新しいステータスは出てきません。 受注編集フォームがステータスの選択肢を1つずつ見て、ステートマシンで遷移できないものをリストから外しているからです。遷移が定義されていないステータスは、そもそも選べません。

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

遷移どこからどこへ誰が
見積の提出見積依頼中見積提出済み店舗
見積の承認見積提出済み新規受付会員
見積の取り下げ見積依頼中/見積提出済み注文取消し会員

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

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

やめました。本体の OrderStateMachine が、遷移の名前でイベントを購読しているからです。注文取消しの遷移を通ると、在庫を戻す処理と使ったポイントを戻す処理が走ります。見積の段階では在庫を引いていないので、戻されると在庫が増えます。だから別の名前の遷移にしました。遷移の名前は、そのまま在庫やポイントの処理と結びついています。

在庫をいつ引くか

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

EC-CUBEの購入フローは、在庫を引く処理を仮確定の段階に置いています。そして在庫を引く StockReduceProcessor は、購入フローの設定を見ると、種別が「購入手続き」のものにしか登録されていません。

ここから2つ分かります。

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

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

ワークフローのイベントを購読するクラスを1つ置きます。購読するのは、さきほど足した「見積の承認」の遷移だけ。その中で、本体の在庫を引く処理に受注を渡します。在庫が足りなければ本体の例外が飛ぶので、呼び出し側で受けます。

見積をつくる

まずカートから見積を作るところです。カートの内容を確かめてから受注を作り、ステータスを見積依頼中に差し替え、送料と手数料と税を計算させる。この順です。

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

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

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

カートページのボタンは、ログイン会員のときだけ出します。カートのフォームと入れ子にならないよう、別のフォームにします。 カートのテンプレートを app/template/default/ にコピーして、カートのフォームが閉じた直後に置きました。CSRFトークンの項目名は本体の定数に合わせます。

店舗が金額を調整する

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

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

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

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

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

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

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

会員が承認する

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

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

承認の処理でやることは、ステータスの遷移1つだけです。ステータスの書き換えも在庫の引き当ても、遷移の定義とイベント購読に任せます。

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

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

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

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

他人の見積を操作されないよう、承認と取り下げの前に確認を入れておきます。見るのは3つ。受注の会員がログイン中の本人であること。ステータスが見積用のものであること。そして、承認なら見積提出済み、と期待どおりの状態にあることです。

見積以外の受注の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 でプラグインが動かなくなる変更点にまとめています。

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

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

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

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

B2B サイト全体でどこまでが標準・プラグインで、どこからが作るものになるのかは、B2Bサイト全体の線引きにまとめました。