B2B の話をしていると必ず出てくるのが「1社で何人も発注する」です。購買部に3人いて、営業所からも発注が上がる。EC-CUBE の会員はメールアドレス1つに1アカウントなので、そのままだと3人でログイン情報を使い回すことになります。

卸売サイトを立ち上げる順番でも見積依頼でも触れずに来た部分です。作ってみます。

作るもの

取引先の管理者が、マイページから担当者のアカウントを発行できるようにします。

マイページの担当者アカウント画面。ナビに「担当者アカウント」のタブがあり、東京商事株式会社の担当者として鈴木 花子と佐藤 健の2名がメールアドレスと電話番号つきで並んでいる

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

やることは4つです。

やること置き場所
会員に親子関係を持たせるapp/Customize/Entity/CustomerTrait.php
担当者を追加・削除する画面app/Customize/Controller/Mypage/CompanyMemberController.php
注文履歴を会社単位にするapp/Customize/Repository/QueryCustomizer/EventListener/
マイページのナビと履歴の表示app/template/default/Mypage/

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

取引先を作るか、会員に親を持たせるか

最初に設計の話をします。素直に考えると「取引先」というエンティティを作って、そこに会員をぶら下げたくなります。作りませんでした。

取引先にあたるものは、会員グループ管理プラグインの会員グループがすでに持っているからです。卸価格も、見せる商品も、使える支払い方法も、紐づく先はグループです。ここに会社をもう1つ足すと、価格の根拠が二重になります。

必要なのは「誰が誰の代わりに発注しているか」だけです。会員に親を1つ持たせれば足ります。

会員役割
山田 太郎なし取引先の管理者
鈴木 花子山田 太郎担当者
佐藤 健山田 太郎担当者

親を持たない会員が管理者、親を持つ会員が担当者です。同じ親を持つ会員の集まりが、1つの会社になります。

会員に親を持たせる

app/Customize/Entity/CustomerTrait.php を作ります。

<?php

namespace Customize\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Eccube\Attribute\EntityExtension;
use Eccube\Entity\Customer;
use Eccube\Entity\Master\CustomerStatus;

#[EntityExtension(Customer::class)]
trait CustomerTrait
{
    /**
     * 親アカウント(取引先の管理者). 管理者自身は null.
     */
    #[ORM\ManyToOne(targetEntity: Customer::class, inversedBy: 'CompanyMembers')]
    #[ORM\JoinColumn(name: 'parent_customer_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
    private ?Customer $CompanyOwner = null;

    /**
     * @var Collection<int, Customer>|null
     */
    #[ORM\OneToMany(mappedBy: 'CompanyOwner', targetEntity: Customer::class)]
    #[ORM\OrderBy(['id' => 'ASC'])]
    private $CompanyMembers;

    public function getCompanyOwner(): ?Customer
    {
        return $this->CompanyOwner;
    }

    public function setCompanyOwner(?Customer $CompanyOwner): self
    {
        $this->CompanyOwner = $CompanyOwner;

        return $this;
    }

    /**
     * @return Collection<int, Customer>
     */
    public function getCompanyMembers(): Collection
    {
        if (null === $this->CompanyMembers) {
            $this->CompanyMembers = new ArrayCollection();
        }

        return $this->CompanyMembers;
    }

    /**
     * 退会していない担当者アカウント.
     *
     * @return Collection<int, Customer>
     */
    public function getActiveCompanyMembers(): Collection
    {
        return $this->getCompanyMembers()->filter(
            fn (Customer $Member) => CustomerStatus::WITHDRAWING != $Member->getStatus()?->getId()
        );
    }
}

Customer から Customer への自己参照です。getCompanyMembers() を遅延初期化しているのは、トレイトにコンストラクタを書けないためです。プラグインで Customer を拡張するときも同じ書き方になります。

列を足すので migration も要ります。app/DoctrineMigrations/ に置きます。

<?php

declare(strict_types=1);

namespace DoctrineMigrations;

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

final class Version20260805000000 extends AbstractMigration
{
    public function up(Schema $schema): void
    {
        $table = $schema->getTable('dtb_customer');

        if ($table->hasColumn('parent_customer_id')) {
            return;
        }

        $table->addColumn('parent_customer_id', 'integer', [
            'notnull' => false,
            'unsigned' => true,
        ]);
        $table->addIndex(['parent_customer_id'], 'dtb_customer_parent_customer_id_idx');
        $table->addForeignKeyConstraint(
            'dtb_customer',
            ['parent_customer_id'],
            ['id'],
            ['onDelete' => 'SET NULL'],
            'fk_customer_parent_customer'
        );
    }

    public function down(Schema $schema): void
    {
        // 省略
    }
}

dtb_customer.idunsigned なので、外部キーになる列にも unsigned を付けます。付け忘れると型が合わずに外部キーを張れません。

app/Customize のエンティティ拡張はプラグインと違って自動で反映されないので、プロキシの再生成と migration を自分で流します。

bin/console eccube:generate:proxies
bin/console doctrine:migrations:migrate
bin/console cache:clear

誰を管理者として扱うか

親を持たない会員が管理者、と書きました。ただそのままだと、一般消費者にも「担当者アカウント」のメニューが出てしまいます。

卸売サイトなら、会員グループに所属しているかどうかで分けられます。同じトレイトに足します。

    /**
     * 取引先の管理者か.
     */
    public function isCompanyAdmin(): bool
    {
        if (null !== $this->CompanyOwner) {
            return false;
        }

        if (method_exists($this, 'getGroups')) {
            return $this->getGroups()->count() > 0;
        }

        return true;
    }

method_exists() で見ているのは、会員グループ管理プラグインが Customer に足す getGroups() です。プラグインを入れていない環境でも壊れないようにこう書いています。入れていれば「グループに所属している会員だけ」、入れていなければ「親を持たない会員すべて」が管理者になります。

グループ以外の条件で分けたいなら、会員に判定用の列を足して管理画面から立てるほうが確実です。その場合は管理画面の会員編集フォームにも項目が要ります。

担当者を登録する

フォームから作ります。app/Customize/Form/Type/Front/CompanyMemberType.php です。

<?php

namespace Customize\Form\Type\Front;

use Eccube\Entity\Customer;
use Eccube\Form\Type\KanaType;
use Eccube\Form\Type\NameType;
use Eccube\Form\Type\PhoneNumberType;
use Eccube\Form\Type\RepeatedEmailType;
use Eccube\Form\Type\RepeatedPasswordType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class CompanyMemberType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('name', NameType::class, ['required' => true])
            ->add('kana', KanaType::class, [])
            ->add('phone_number', PhoneNumberType::class, ['required' => true])
            ->add('email', RepeatedEmailType::class)
            ->add('plain_password', RepeatedPasswordType::class);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults(['data_class' => Customer::class]);
    }

    public function getBlockPrefix(): string
    {
        return 'company_member';
    }
}

会員登録フォーム(EntryType)から住所と生年月日と性別を落としたものです。住所を入力させないのは、担当者の住所は会社の住所だからです。 管理者のものをそのままコピーします。

data_classCustomer を指定しているので、メールアドレスの重複は本体の Customer に付いている UniqueEntity が見てくれます。担当者に既存会員のメールアドレスを入れられても弾かれます。

担当者アカウントの追加画面。お名前、お名前(カナ)、電話番号、メールアドレス、パスワードの入力欄が並び、その下に「住所と会社名、会員グループは 東京商事株式会社 のものを引き継ぎます。」と書かれている

コントローラは app/Customize/Controller/Mypage/CompanyMemberController.php です。

<?php

namespace Customize\Controller\Mypage;

use Customize\Form\Type\Front\CompanyMemberType;
use Eccube\Controller\AbstractController;
use Eccube\Entity\Customer;
use Eccube\Entity\Master\CustomerStatus;
use Eccube\Repository\CustomerRepository;
use Eccube\Repository\Master\CustomerStatusRepository;
use Eccube\Util\StringUtil;
use Symfony\Bridge\Twig\Attribute\Template;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;

class CompanyMemberController extends AbstractController
{
    public function __construct(
        private readonly CustomerRepository $customerRepository,
        private readonly CustomerStatusRepository $customerStatusRepository,
        private readonly UserPasswordHasherInterface $passwordHasher,
    ) {
    }

    #[Route(path: '/mypage/company/members', name: 'mypage_company_member', methods: ['GET'])]
    #[Template(template: 'Mypage/company_member.twig')]
    public function index(): array
    {
        $Owner = $this->getCompanyAdmin();

        return [
            'Owner' => $Owner,
            'Members' => $Owner->getActiveCompanyMembers(),
        ];
    }

    #[Route(path: '/mypage/company/members/new', name: 'mypage_company_member_new', methods: ['GET', 'POST'])]
    #[Template(template: 'Mypage/company_member_edit.twig')]
    public function new(Request $request): RedirectResponse|array
    {
        $Owner = $this->getCompanyAdmin();

        /** @var Customer $Member */
        $Member = $this->customerRepository->newCustomer();
        $this->inheritFromOwner($Member, $Owner);

        $form = $this->formFactory->create(CompanyMemberType::class, $Member);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            // 管理者が登録するアカウントなので、仮会員を経由せず本会員にする
            $Member->setStatus($this->customerStatusRepository->find(CustomerStatus::REGULAR));
            $Member->setPassword(
                $this->passwordHasher->hashPassword($Member, $Member->getPlainPassword())
            );

            $this->entityManager->persist($Member);
            $this->entityManager->flush();

            $this->addSuccess('担当者アカウントを登録しました。');

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

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

    private function getCompanyAdmin(): Customer
    {
        $Customer = $this->getUser();

        if (!$Customer instanceof Customer || !$Customer->isCompanyAdmin()) {
            throw new AccessDeniedHttpException();
        }

        return $Customer;
    }
}

CustomerRepository::newCustomer() は仮会員ステータスの Customer を作って返します。会員登録の入り口と同じものです。ただここでは本会員に上書きします。 誰が登録したのか分からない相手ではなく、取引先の管理者が身元を確認して登録するアカウントなので、確認メールを送って本人にクリックさせる意味がありません。

newCustomer() を経由しているのは、シークレットキーの採番をやってくれるからです。自分で new Customer() すると、パスワード再発行のときにキーがなくて困ります。

引き継ぎの処理です。同じクラスに足します。

    private function inheritFromOwner(Customer $Member, Customer $Owner): void
    {
        $Member
            ->setCompanyOwner($Owner)
            ->setCompanyName($Owner->getCompanyName())
            ->setPostalCode($Owner->getPostalCode())
            ->setPref($Owner->getPref())
            ->setAddr01($Owner->getAddr01())
            ->setAddr02($Owner->getAddr02());

        // 会員グループ管理を入れているときは、卸価格の根拠になる会員グループも引き継ぐ
        if (method_exists($Owner, 'getGroups') && method_exists($Member, 'addGroup')) {
            foreach ($Owner->getGroups() as $Group) {
                $Member->addGroup($Group);
            }
        }
    }

会員グループを引き継ぐところが一番の勘所です。 ここをコピーしておけば、担当者は管理者と同じ条件で買い物ができます。忘れると、担当者だけ定価で買うことになります。

手元の環境では、卸売Aグループ(7掛け)に所属する管理者が担当者を追加すると、担当者にも同じグループが付いて、税込 ¥3,080 の商品が ¥2,156 で表示されました。

担当者を削除する

一覧の × ボタンです。同じコントローラに足します。

    #[Route(path: '/mypage/company/members/{id}/withdraw', name: 'mypage_company_member_withdraw', methods: ['DELETE'], requirements: ['id' => '\d+'])]
    public function withdraw(int $id): RedirectResponse
    {
        $this->isTokenValid();

        $Owner = $this->getCompanyAdmin();

        $Member = $this->customerRepository->find($id);
        if (null === $Member || $Member->getCompanyOwner() !== $Owner) {
            throw new NotFoundHttpException();
        }

        // 本体の退会と同じく、ステータスを退会にしてメールアドレスを解放する
        $Member->setStatus($this->customerStatusRepository->find(CustomerStatus::WITHDRAWING));
        $Member->setEmail(StringUtil::random(60).'@dummy.dummy');

        $this->entityManager->flush();

        $this->addSuccess('担当者アカウントを削除しました。');

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

レコードは消しません。 消すと過去の受注から会員が消えます。本体の退会処理(WithdrawController)と同じく、ステータスを退会にして、メールアドレスをダミーに置き換えます。こうしておくと同じメールアドレスで再登録できます。

$Member->getCompanyOwner() !== $Owner の確認は必須です。ここを省くと、ID を書き換えるだけで他社の会員を退会させられます。

テンプレート側は本体のお届け先一覧と同じ書き方です。

<a class="ec-addressList__remove"
   href="{{ url('mypage_company_member_withdraw', { id: Member.id }) }}"
   {{ csrf_token_for_anchor() }} data-method="delete"
   data-message="{{ Member.name01 }} {{ Member.name02 }} さんのアカウントを削除します。よろしいですか?">
    <div class="ec-icon">
        <img src="{{ asset('assets/icon/cross.svg') }}" alt="remove">
    </div>
</a>

data-method="delete" は本体の JavaScript がフォームに組み立て直してくれます。ルーティングを DELETE にしているのはそのためです。

注文履歴を会社単位にする

担当者が発注できるようになると、次に言われるのが「誰が何を頼んだか管理者が見たい」です。

マイページの注文履歴は OrderRepository::getQueryBuilderByCustomer() が組み立てています。最後の行を見てください。

return $this->queries->customize(QueryKey::ORDER_SEARCH_BY_CUSTOMER, $qb, ['customer' => $Customer]);

EC-CUBE はここに割り込み口を用意しています。 QueryCustomizer を実装したクラスを置くだけで、コントローラもリポジトリも触らずに条件を足せます。

app/Customize/Repository/QueryCustomizer/CompanyOrderCustomizer.php です。

<?php

namespace Customize\Repository\QueryCustomizer;

use Doctrine\ORM\QueryBuilder;
use Eccube\Doctrine\Query\QueryCustomizer;
use Eccube\Entity\Customer;
use Eccube\Repository\QueryKey;

class CompanyOrderCustomizer implements QueryCustomizer
{
    public function customize(QueryBuilder $builder, array $params, string $queryKey): void
    {
        $Customer = $params['customer'] ?? null;

        if (!$Customer instanceof Customer || !$Customer->isCompanyAdmin()) {
            return;
        }

        $Members = $Customer->getActiveCompanyMembers();
        if ($Members->isEmpty()) {
            return;
        }

        // 元の条件(o.Customer = :Customer)は残したまま担当者分を足す
        $builder
            ->orWhere($builder->expr()->in('o.Customer', ':CompanyMembers'))
            ->setParameter('CompanyMembers', $Members);
    }

    public function getQueryKey(): string
    {
        return QueryKey::ORDER_SEARCH_BY_CUSTOMER;
    }
}

orWhere() で足していて、元の o.Customer = :Customer は消していません。消すと :Customer が使われないパラメータになって、Doctrine が例外を投げます。 条件を丸ごと差し替えたいときは、パラメータのほうも一緒に整理する必要があります。

サービスの登録は要りません。app/Customize/Resource/config/services.yamlCustomize\ を PSR-4 で登録してあれば、QueryCustomizer を実装したクラスは EC-CUBE が自動で拾います。

これで管理者のマイページに自社の受注が並びます。

管理者のマイページの注文履歴。3件の履歴があり、ご注文番号5には「発注者 佐藤 健」、ご注文番号4には「発注者 鈴木 花子」が表示され、管理者自身のご注文番号6には発注者の行がない

担当者のほうは今までどおり、自分の分しか見えません。

担当者(鈴木 花子)のマイページの注文履歴。1件の履歴だけが表示され、ナビには「担当者アカウント」と「退会手続き」のタブがない

ナビのタブが管理者と違います。担当者に担当者管理は要らず、退会は管理者の仕事なので、どちらも出していません。

履歴の詳細を開けるようにする

一覧に並んでも、「詳細を見る」を押すと 404 になります。MypageController::history() が受注をこう引いているためです。

$Order = $this->orderRepository->findOneBy([
    'order_no' => $order_no,
    'Customer' => $this->getUser(),
]);

クエリビルダを使っていないので、さっきのクエリカスタマイザは効きません。ただ、この直後にイベントが飛んでいます。

$this->eventDispatcher->dispatch($event, EccubeEvents::FRONT_MYPAGE_MYPAGE_HISTORY_INITIALIZE);

/** @var Order|null $Order */
$Order = $event->getArgument('Order');

if (!$Order) {
    throw new NotFoundHttpException();
}

受注を取り直してから 404 の判定をしています。 イベントで差し替えられます。

app/Customize/EventListener/CompanyOrderHistorySubscriber.php です。

<?php

namespace Customize\EventListener;

use Eccube\Entity\Customer;
use Eccube\Event\EccubeEvents;
use Eccube\Event\EventArgs;
use Eccube\Repository\OrderRepository;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

class CompanyOrderHistorySubscriber implements EventSubscriberInterface
{
    public function __construct(
        private readonly OrderRepository $orderRepository,
        private readonly Security $security,
    ) {
    }

    public static function getSubscribedEvents(): array
    {
        return [
            EccubeEvents::FRONT_MYPAGE_MYPAGE_HISTORY_INITIALIZE => 'onHistoryInitialize',
        ];
    }

    public function onHistoryInitialize(EventArgs $event): void
    {
        if (null !== $event->getArgument('Order')) {
            return;
        }

        $Customer = $this->security->getUser();
        if (!$Customer instanceof Customer || !$Customer->isCompanyAdmin()) {
            return;
        }

        $Members = $Customer->getActiveCompanyMembers();
        if ($Members->isEmpty()) {
            return;
        }

        $orderNo = $event->getRequest()?->attributes->get('order_no');
        if (null === $orderNo) {
            return;
        }

        $Order = $this->orderRepository->findOneBy([
            'order_no' => $orderNo,
            'Customer' => $Members->toArray(),
        ]);

        if (null !== $Order) {
            $event->setArgument('Order', $Order);
        }
    }
}

本体が受注を取れたときは何もしません。取れなかったときだけ、担当者の受注として引き直します。注文番号は EventArgs に入っていないので、リクエストの属性から取ります。

一覧と詳細で手段が変わるのは気持ち悪いところですが、コントローラを丸ごと上書きするより副作用が小さくて済みます。

テンプレート

ナビに担当者アカウントのタブを足します。app/template/default/Mypage/navi.twig に本体からコピーして、2箇所を書き換えます。

{# 取引先の管理者にだけ担当者アカウントの管理を出す #}
{% if app.user.companyAdmin %}
    <li class="ec-navlistRole__item {% if mypageno|default('') == 'company_member' %}active{% endif %}">
        <a href="{{ url('mypage_company_member') }}">担当者アカウント</a>
    </li>
{% endif %}

{# 担当者アカウントは自分で退会できない(管理者が削除する) #}
{% if app.user.companyOwner is null %}
    <li class="ec-navlistRole__item {% if mypageno|default('') == 'withdraw' %}active{% endif %}">
        <a href="{{ url('mypage_withdraw') }}">{{ 'front.mypage.nav__withdrow'|trans }}</a>
    </li>
{% endif %}

app.user.companyAdminisCompanyAdmin() を呼びます。Twig がメソッド名を補完してくれるので、is は書かなくて構いません。

注文履歴の一覧は app/template/default/Mypage/index.twig をコピーして、発注者の行を足します。

{# 担当者アカウントの受注も並ぶため、自分以外の発注は発注者名を出す #}
{% if Order.Customer and Order.Customer.id != app.user.id %}
    <dl class="ec-definitions">
        <dt>発注者</dt>
        <dd>{{ Order.Customer.name01 }}&nbsp;{{ Order.Customer.name02 }}</dd>
    </dl>
{% endif %}

自分の発注には出しません。全部の行に自分の名前が並んでも読みにくいだけです。

新しく作るのは2つです。一覧の app/template/default/Mypage/company_member.twig から。

{% extends 'default_frame.twig' %}

{% set mypageno = 'company_member' %}
{% set body_class = 'mypage' %}

{% block main %}
    <div class="ec-layoutRole__main">
        <div class="ec-mypageRole">
            <div class="ec-pageHeader">
                <h1>マイページ/担当者アカウント</h1>
            </div>
            {{ include('Mypage/navi.twig') }}
        </div>
        <div class="ec-mypageRole">
            <div class="ec-off1Grid">
                <div class="ec-off1Grid__cell">
                    <div class="ec-addressRole">
                        <div class="ec-addressRole__actions">
                            <a class="ec-inlineBtn" href="{{ url('mypage_company_member_new') }}">担当者を追加する</a>
                        </div>
                    </div>
                    {% if Members|length > 0 %}
                        <div class="ec-addressList">
                            {% for Member in Members %}
                                <div class="ec-addressList__item">
                                    {# 削除リンクは前掲 #}
                                    <div class="ec-addressList__address">
                                        <div>{{ Member.name01 }}&nbsp;{{ Member.name02 }}</div>
                                        <div>{{ Member.email }}</div>
                                        <div>{{ Member.phone_number }}</div>
                                    </div>
                                </div>
                            {% endfor %}
                        </div>
                    {% else %}
                        <p class="ec-para-normal">担当者アカウントはまだ登録されていません。</p>
                    {% endif %}
                </div>
            </div>
        </div>
    </div>
{% endblock %}

お届け先一覧(Mypage/delivery.twig)と同じ組み方です。ec-addressList をそのまま使うと、追加ボタンと × ボタンの見た目が本体と揃います。

追加フォームの app/template/default/Mypage/company_member_edit.twig は、項目が5つ並ぶだけなので1つ分だけ載せます。

{% extends 'default_frame.twig' %}

{% set mypageno = 'company_member' %}
{% set body_class = 'mypage' %}

{% block main %}
    <div class="ec-layoutRole__main">
        <div class="ec-mypageRole">
            <div class="ec-pageHeader">
                <h1>マイページ/担当者アカウントの追加</h1>
            </div>
            {{ include('Mypage/navi.twig') }}
        </div>
        <div class="ec-mypageRole">
            <form method="post" action="{{ url('mypage_company_member_new') }}" novalidate>
                {{ form_widget(form._token) }}
                <div class="ec-borderedDefs">
                    <dl>
                        <dt><label class="ec-label required">{{ 'common.name'|trans }}</label></dt>
                        <dd>
                            <div class="ec-halfInput{{ has_errors(form.name) ? ' error' }}">
                                {{ form_widget(form.name.name01) }}
                                {{ form_widget(form.name.name02) }}
                                {{ form_errors(form.name.name01) }}
                                {{ form_errors(form.name.name02) }}
                            </div>
                        </dd>
                    </dl>
                    {# kana / phone_number / email / plain_password も同じ形で並べる #}
                </div>
                <p class="ec-para-normal">
                    住所と会社名、会員グループは {{ Owner.company_name|default('管理者') }} のものを引き継ぎます。
                </p>
                <div class="ec-RegisterRole__actions">
                    <div class="ec-off4Grid">
                        <div class="ec-off4Grid__cell">
                            <button type="submit" class="ec-blockBtn--action">登録する</button>
                            <a class="ec-blockBtn--cancel" href="{{ url('mypage_company_member') }}">{{ 'common.back'|trans }}</a>
                        </div>
                    </div>
                </div>
            </form>
        </div>
    </div>
{% endblock %}

emailplain_passwordRepeatedEmailType / RepeatedPasswordType なので、form.email.firstform.email.second のように2つに分けて出します。まとめて form_widget(form.email) にすると確認用の欄だけ枠から外れます。

クラス名を本体に合わせておけば CSS は書かなくて済みます。has_errors() は EC-CUBE が用意している関数で、エラーのある項目に error クラスを付けるためのものです。

テンプレートを本体からコピーすると、EC-CUBE のバージョンアップで本体側が変わったとき、差分を当て直すのは自分の仕事になります。プラグインとして配布するなら TemplateEvent で差し込みますが、自社サイトのカスタマイズなら、コピーして Git で管理するほうが読みやすいと思います。

割り切ったところ

担当者は自分で会員情報を編集できます。 マイページの会員情報編集をそのまま使えるので、住所や会社名を勝手に書き換えられます。会社の情報を固定したいなら、編集フォームから該当項目を落とすか、保存時に管理者の値へ戻す処理が要ります。

発注に上限を設けていません。 担当者が1回で1000万円の発注をしても通ります。1回あたりの上限や月間の枠を持たせるなら、購入フローの検証に条件を足してください。

担当者の数に制限がありません。 会員登録の入り口ではないので大量に作られる心配は薄いのですが、気になるなら追加画面で getActiveCompanyMembers()->count() を見て上限を掛けてください。

管理者が退会すると担当者が宙に浮きます。 外部キーを SET NULL にしているので、親が消えると担当者は親を持たない会員になり、条件次第では管理者として扱われます。管理者の退会時に担当者もまとめて退会させる処理を足すのが安全です。

承認フローは入れていません。 担当者が発注したものは、そのまま受注になります。「担当者が起票して管理者が承認してから発注」という社内稟議の形にするなら、受注ステータスを1つ足して、承認するまで店舗側に流さない作りにします。ステータスを増やす手順は見積依頼機能の記事に書いたものと同じです。

EC-CUBE 4.2 / 4.3 での違い

このコードは 4.4 向けです。4.2 / 4.3 では書き方が変わります。

4.2 / 4.34.4
@Route / @Template のアノテーション#[Route] / #[Template] の属性記法
Sensio\Bundle\FrameworkExtraBundle\Configuration\TemplateSymfony\Bridge\Twig\Attribute\Template
Eccube\Annotation\EntityExtension@EntityExtensionEccube\Attribute\EntityExtension#[EntityExtension]
@ORM\ManyToOne などのアノテーション#[ORM\ManyToOne] などの属性記法

エンティティの書き方が変わるのが一番の違いです。4.3 までは本体のエンティティも Doctrine のアノテーションなので、拡張するトレイトも合わせます。

Security の名前空間も注意が要ります。4.2 は Symfony 5.4 なので Symfony\Component\Security\Core\Security、4.3 は Symfony 6.4 なので 4.4 と同じ Symfony\Bundle\SecurityBundle\Security です。

QueryCustomizerFRONT_MYPAGE_MYPAGE_HISTORY_INITIALIZE は 4.2 にもあり、MypageController::history() がイベントのあとで受注を取り直す作りも同じでした。この記事の仕組みはそのまま動きます。変更点の全体はEC-CUBE 4.4 でプラグインが動かなくなる変更点にまとめています。

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

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

このカスタマイズは、会員グループを担当者へコピーするところだけがプラグインとの接点です。あとは EC-CUBE の会員として普通に振る舞うので、会員グループ価格管理アドオンの卸価格も、支払い方法管理アドオンの請求書払いも、担当者にそのまま効きます。

グループを取引先ごとに作っているなら、担当者アカウントは会社の中の話なので何も変わりません。掛け率ごとのグループにしているなら、同じ掛け率の別会社と1つのグループを共有しますが、担当者の親は自社の管理者なので、注文履歴が混ざる心配は要りません。

価格の条件はグループ、社内の関係は親子。分けておくと、取引先が増えても担当者が増えても、触る場所が変わりません。